Files
orca/src/main/git/status-submodule.test.ts
T
Neil c5ed53a1f5 perf(git): overlap the three independent submodule status reads (#18623)
getSubmoduleStatus awaited the inner status, the parent gitlink oid and the
submodule HEAD one after another, though none depends on another's result. The
Source Control panel re-runs this on every parent status poll while a submodule
row is expanded, and the same function backs the git.submoduleStatus RPC, so
over SSH or WSL each read was a separate round trip.

Total latency drops from the sum of the round trips to the longest one.
2026-09-04 14:31:54 -07:00

430 lines
16 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as BoundedFileReader from '../../shared/node-bounded-file-reader'
import path from 'node:path'
import {
createBoundedFileReaderModuleMock,
createFsPromisesModuleMock,
createGitRunnerModuleMock
} from './status-test-harness'
const {
gitExecFileAsyncMock,
gitExecFileAsyncBufferMock,
gitStreamOptionsMock,
lstatMock,
realpathMock,
readFileMock,
statMock,
rmMock,
existsSyncMock
} = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
gitExecFileAsyncBufferMock: vi.fn(),
gitStreamOptionsMock: vi.fn(),
lstatMock: vi.fn(),
realpathMock: vi.fn(),
readFileMock: vi.fn(),
statMock: vi.fn(),
rmMock: vi.fn(),
existsSyncMock: vi.fn()
}))
vi.mock('./runner', () =>
createGitRunnerModuleMock({
gitExecFileAsyncMock,
gitExecFileAsyncBufferMock,
gitStreamOptionsMock
})
)
vi.mock('fs/promises', () =>
createFsPromisesModuleMock({ lstatMock, realpathMock, readFileMock, statMock, rmMock })
)
vi.mock('fs', () => ({
existsSync: existsSyncMock
}))
vi.mock('../../shared/node-bounded-file-reader', async (importOriginal) =>
createBoundedFileReaderModuleMock(await importOriginal<typeof BoundedFileReader>(), {
readFileMock,
statMock
})
)
import {
clearEffectiveUpstreamStatusCacheForTests,
clearSubmodulePathsCacheForTests,
getDiff,
getSubmoduleStatus,
listSubmodulePaths,
resolveSubmoduleWorktreePath
} from './status'
describe('submodule diff routing', () => {
const OLD_OID = 'a'.repeat(40)
const NEW_OID = 'b'.repeat(40)
const PARENT = path.resolve('/repo-sm')
const SUBMODULE = path.join(PARENT, 'flutter_mine')
beforeEach(() => {
clearSubmodulePathsCacheForTests()
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncBufferMock.mockReset()
lstatMock.mockReset()
readFileMock.mockReset()
statMock.mockReset()
existsSyncMock.mockReset()
statMock.mockResolvedValue({ isFile: () => true, size: 12 })
// Route git invocations by argv/cwd so the routing logic can resolve the
// gitlink oids without touching a real repo.
gitExecFileAsyncMock.mockImplementation((args: string[], options?: { cwd?: string }) => {
if (args[0] === 'config' && args.includes('.gitmodules')) {
// Only the parent worktree declares the submodule; the recursion into the
// submodule worktree must see no nested submodules.
return Promise.resolve({
stdout: options?.cwd === PARENT ? 'submodule.flutter_mine.path flutter_mine\n' : ''
})
}
if (args[0] === 'ls-files') {
return Promise.resolve({ stdout: `160000 ${OLD_OID} 0\tflutter_mine\n` })
}
if (args[0] === 'ls-tree') {
return Promise.resolve({ stdout: `160000 commit ${OLD_OID}\tflutter_mine\n` })
}
if (args[0] === 'rev-parse') {
return Promise.resolve({ stdout: `${NEW_OID}\n` })
}
return Promise.resolve({ stdout: '' })
})
})
it('synthesizes a Subproject commit pointer diff for the gitlink root', async () => {
const result = await getDiff(PARENT, 'flutter_mine', false)
expect(result.kind).toBe('text')
expect(result.originalContent).toBe(`Subproject commit ${OLD_OID}\n`)
expect(result.modifiedContent).toBe(`Subproject commit ${NEW_OID}\n`)
})
it('diffs inner files across the two commits when the gitlink moved', async () => {
gitExecFileAsyncBufferMock.mockImplementation((args: string[]) => {
const spec = String(args.at(-1))
if (spec.startsWith(`${OLD_OID}:`)) {
return Promise.resolve({ stdout: Buffer.from('v1\n') })
}
if (spec.startsWith(`${NEW_OID}:`)) {
return Promise.resolve({ stdout: Buffer.from('v2\n') })
}
return Promise.resolve({ stdout: Buffer.from('') })
})
const result = await getDiff(PARENT, 'flutter_mine/lib/main.dart', false)
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith(
['show', '--end-of-options', `${OLD_OID}:lib/main.dart`],
{ cwd: SUBMODULE, maxBuffer: 10 * 1024 * 1024, preferWslDirectGit: true }
)
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith(
['show', '--end-of-options', `${NEW_OID}:lib/main.dart`],
{ cwd: SUBMODULE, maxBuffer: 10 * 1024 * 1024, preferWslDirectGit: true }
)
expect(result.kind).toBe('text')
expect(result.originalContent).toBe('v1\n')
expect(result.modifiedContent).toBe('v2\n')
})
it('diffs staged inner files from parent HEAD to parent index', async () => {
gitExecFileAsyncMock.mockImplementation((args: string[], options?: { cwd?: string }) => {
if (args[0] === 'config' && args.includes('.gitmodules')) {
return Promise.resolve({
stdout: options?.cwd === PARENT ? 'submodule.flutter_mine.path flutter_mine\n' : ''
})
}
if (args[0] === 'ls-files') {
return Promise.resolve({ stdout: `160000 ${NEW_OID} 0\tflutter_mine\n` })
}
if (args[0] === 'ls-tree') {
return Promise.resolve({ stdout: `160000 commit ${OLD_OID}\tflutter_mine\n` })
}
if (args[0] === 'rev-parse') {
return Promise.resolve({ stdout: `${NEW_OID}\n` })
}
return Promise.resolve({ stdout: '' })
})
gitExecFileAsyncBufferMock.mockImplementation((args: string[]) => {
const spec = String(args.at(-1))
if (spec.startsWith(`${OLD_OID}:`)) {
return Promise.resolve({ stdout: Buffer.from('v1\n') })
}
if (spec.startsWith(`${NEW_OID}:`)) {
return Promise.resolve({ stdout: Buffer.from('v2\n') })
}
return Promise.resolve({ stdout: Buffer.from('') })
})
const result = await getDiff(PARENT, 'flutter_mine/lib/main.dart', true)
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith(
['show', '--end-of-options', `${OLD_OID}:lib/main.dart`],
{ cwd: SUBMODULE, maxBuffer: 10 * 1024 * 1024, preferWslDirectGit: true }
)
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith(
['show', '--end-of-options', `${NEW_OID}:lib/main.dart`],
{ cwd: SUBMODULE, maxBuffer: 10 * 1024 * 1024, preferWslDirectGit: true }
)
expect(result.kind).toBe('text')
expect(result.originalContent).toBe('v1\n')
expect(result.modifiedContent).toBe('v2\n')
})
it('reads inner files from the working tree when the commit is unchanged', async () => {
// Override the gitlink oids so recorded == checked-out (no pointer move),
// routing the inner diff back to the index/working-tree blob read.
gitExecFileAsyncMock.mockImplementation((args: string[], options?: { cwd?: string }) => {
if (args[0] === 'config' && args.includes('.gitmodules')) {
return Promise.resolve({
stdout: options?.cwd === PARENT ? 'submodule.flutter_mine.path flutter_mine\n' : ''
})
}
if (args[0] === 'ls-files') {
return Promise.resolve({ stdout: `160000 ${OLD_OID} 0\tflutter_mine\n` })
}
if (args[0] === 'rev-parse') {
return Promise.resolve({ stdout: `${OLD_OID}\n` })
}
return Promise.resolve({ stdout: '' })
})
gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: Buffer.from('old\n') })
readFileMock.mockResolvedValue(Buffer.from('new'))
const result = await getDiff(PARENT, 'flutter_mine/lib/main.dart', false)
expect(gitExecFileAsyncBufferMock).toHaveBeenCalledWith(['show', ':lib/main.dart'], {
cwd: SUBMODULE,
maxBuffer: 10 * 1024 * 1024,
preferWslDirectGit: true
})
expect(readFileMock).toHaveBeenCalledWith(path.join(SUBMODULE, 'lib/main.dart'))
expect(result.kind).toBe('text')
expect(result.originalContent).toBe('old\n')
expect(result.modifiedContent).toBe('new')
})
it('rejects inner submodule diffs whose .gitmodules path escapes the worktree', async () => {
// A crafted .gitmodules path must not let the inner diff read escape the
// selected worktree; loadDiff routes through resolveSubmoduleWorktreePath.
gitExecFileAsyncMock.mockImplementation((args: string[], options?: { cwd?: string }) => {
if (args[0] === 'config' && args.includes('.gitmodules')) {
return Promise.resolve({
stdout: options?.cwd === PARENT ? 'submodule.evil.path ../evil\n' : ''
})
}
return Promise.resolve({ stdout: '' })
})
await expect(getDiff(PARENT, '../evil/secret.txt', false)).rejects.toThrow('Access denied')
})
it('rejects gitlink pointer diffs whose .gitmodules path escapes the worktree', async () => {
gitExecFileAsyncMock.mockImplementation((args: string[], options?: { cwd?: string }) => {
if (args[0] === 'config' && args.includes('.gitmodules')) {
return Promise.resolve({
stdout: options?.cwd === PARENT ? 'submodule.evil.path ../evil\n' : ''
})
}
return Promise.resolve({ stdout: '' })
})
await expect(getDiff(PARENT, '../evil', false)).rejects.toThrow('Access denied')
})
})
describe('resolveSubmoduleWorktreePath', () => {
it('resolves a relative submodule path inside the worktree', () => {
expect(resolveSubmoduleWorktreePath('/repo', 'flutter_mine')).toBe(
path.resolve('/repo', 'flutter_mine')
)
})
it('rejects absolute and escaping submodule paths', () => {
expect(() => resolveSubmoduleWorktreePath('/repo', path.resolve('/etc'))).toThrow(
'Access denied'
)
expect(() => resolveSubmoduleWorktreePath('/repo', '../outside')).toThrow('Access denied')
})
})
describe('listSubmodulePaths', () => {
beforeEach(() => {
clearSubmodulePathsCacheForTests()
gitExecFileAsyncMock.mockReset()
})
it('keeps cached .gitmodules paths separate per WSL distro', async () => {
gitExecFileAsyncMock.mockImplementation((_args: string[], options?: { wslDistro?: string }) =>
Promise.resolve({
stdout:
options?.wslDistro === 'debian'
? 'submodule.lib.path debian-lib\n'
: 'submodule.lib.path ubuntu-lib\n'
})
)
await expect(listSubmodulePaths('/repo', { wslDistro: 'ubuntu' })).resolves.toEqual([
'ubuntu-lib'
])
await expect(listSubmodulePaths('/repo', { wslDistro: 'debian' })).resolves.toEqual([
'debian-lib'
])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
})
describe('getSubmoduleStatus', () => {
beforeEach(() => {
clearEffectiveUpstreamStatusCacheForTests()
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncBufferMock.mockReset()
gitStreamOptionsMock.mockReset()
lstatMock.mockReset()
readFileMock.mockReset()
existsSyncMock.mockReset()
gitExecFileAsyncMock.mockResolvedValue({ stdout: '' })
})
it('runs status inside the submodule worktree and returns inner entries', async () => {
readFileMock.mockResolvedValue('gitdir: /repo/flutter_mine/.git\n')
existsSyncMock.mockReturnValue(false)
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout:
'1 .M N... 100644 100644 100644 ce013625030ba8dba906f756967f9e9ca394464a ce013625030ba8dba906f756967f9e9ca394464a lib/main.dart\n'
})
const result = await getSubmoduleStatus('/repo', 'flutter_mine')
expect(result.entries).toContainEqual({
path: 'lib/main.dart',
status: 'modified',
area: 'unstaged'
})
})
it('includes commit-range entries when the submodule pointer moved', async () => {
const OLD_OID = 'a'.repeat(40)
const NEW_OID = 'b'.repeat(40)
readFileMock.mockResolvedValue('gitdir: /repo/flutter_mine/.git\n')
existsSyncMock.mockReturnValue(false)
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
// Clean worktree: the inner status stream returns nothing.
if (args.includes('--name-status')) {
return Promise.resolve({ stdout: 'M\tlib/main.dart\n' })
}
if (args[0] === 'ls-files') {
return Promise.resolve({ stdout: `160000 ${OLD_OID} 0\tflutter_mine\n` })
}
if (args[0] === 'rev-parse') {
return Promise.resolve({ stdout: `${NEW_OID}\n` })
}
return Promise.resolve({ stdout: '' })
})
const result = await getSubmoduleStatus('/repo', 'flutter_mine')
expect(result.entries).toContainEqual(
expect.objectContaining({ path: 'lib/main.dart', status: 'modified', area: 'unstaged' })
)
})
it('includes staged commit-range entries from parent HEAD to parent index', async () => {
const OLD_OID = 'a'.repeat(40)
const NEW_OID = 'b'.repeat(40)
readFileMock.mockResolvedValue('gitdir: /repo/flutter_mine/.git\n')
existsSyncMock.mockReturnValue(false)
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
// Clean submodule worktree: the staged parent gitlink still has files to show.
if (args.includes('--name-status')) {
return Promise.resolve({ stdout: 'M\tlib/main.dart\n' })
}
if (args[0] === 'ls-files') {
return Promise.resolve({ stdout: `160000 ${NEW_OID} 0\tflutter_mine\n` })
}
if (args[0] === 'ls-tree') {
return Promise.resolve({ stdout: `160000 commit ${OLD_OID}\tflutter_mine\n` })
}
if (args[0] === 'rev-parse') {
return Promise.resolve({ stdout: `${NEW_OID}\n` })
}
return Promise.resolve({ stdout: '' })
})
const result = await getSubmoduleStatus('/repo', 'flutter_mine', { staged: true })
expect(result.entries).toContainEqual(
expect.objectContaining({ path: 'lib/main.dart', status: 'modified', area: 'unstaged' })
)
expect(gitExecFileAsyncMock.mock.calls.some(([args]) => args.includes('status'))).toBe(false)
})
it('caps staged commit-range entries before returning them to the renderer', async () => {
const OLD_OID = 'a'.repeat(40)
const NEW_OID = 'b'.repeat(40)
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncMock.mockImplementation((args: string[]) => {
if (args.includes('--name-status')) {
return Promise.resolve({ stdout: 'M\tlib/a.dart\nM\tlib/b.dart\n' })
}
if (args[0] === 'ls-files') {
return Promise.resolve({ stdout: `160000 ${NEW_OID} 0\tflutter_mine\n` })
}
if (args[0] === 'ls-tree') {
return Promise.resolve({ stdout: `160000 commit ${OLD_OID}\tflutter_mine\n` })
}
return Promise.resolve({ stdout: '' })
})
const result = await getSubmoduleStatus('/repo', 'flutter_mine', {
staged: true,
limit: 1
})
expect(result.entries).toHaveLength(1)
expect(result.didHitLimit).toBe(true)
expect(result.statusLength).toBe(2)
})
it('overlaps the inner status, index and HEAD reads instead of serializing them', async () => {
const OLD_OID = 'a'.repeat(40)
const NEW_OID = 'b'.repeat(40)
readFileMock.mockResolvedValue('gitdir: /repo/flutter_mine/.git\n')
existsSyncMock.mockReturnValue(false)
const events: string[] = []
gitExecFileAsyncMock.mockReset()
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
const name = args.includes('status') ? 'status' : args[0]
events.push(`start:${name}`)
if (name === 'status') {
// Hold the inner status open so a serialized caller could not have started the oid reads.
await new Promise((resolve) => setTimeout(resolve, 5))
}
events.push(`end:${name}`)
if (name === 'ls-files') {
return { stdout: `160000 ${OLD_OID} 0\tflutter_mine\n` }
}
if (name === 'rev-parse') {
return { stdout: `${NEW_OID}\n` }
}
return { stdout: '' }
})
await getSubmoduleStatus('/repo', 'flutter_mine')
// Serialized reads only start ls-files/rev-parse once the inner status has resolved.
expect(events.indexOf('start:ls-files')).toBeLessThan(events.indexOf('end:status'))
expect(events.indexOf('start:rev-parse')).toBeLessThan(events.indexOf('end:status'))
})
})