perf(git): reuse queries and stop canceled catalog scans (#24923)

* perf(git): reuse queries and stop canceled catalog scans

* refactor(git): check relay filesystem error codes
This commit is contained in:
Neil
2026-10-03 02:54:20 -07:00
committed by GitHub
parent bde1c09866
commit a40e5012f5
17 changed files with 798 additions and 32 deletions
+38
View File
@@ -71,4 +71,42 @@ describe('getDefaultRemote', () => {
'Failed to resolve default remote for repo.'
)
})
it('keeps configured branch precedence when a remote list is already available', async () => {
gitExecFileAsyncMock.mockImplementation(async (argv: string[]) => {
if (argv[0] === 'for-each-ref') {
return { stdout: 'refs/remotes/origin/HEAD\0refs/remotes/origin/main\n' }
}
if (argv[0] === 'config') {
return { stdout: 'upstream\n' }
}
throw new Error('unexpected command')
})
await expect(getDefaultRemote('/repo', {}, ['origin', 'upstream'])).resolves.toBe('upstream')
expect(gitExecFileAsyncMock.mock.calls.some(([args]) => args[0] === 'remote')).toBe(false)
})
it.each([
[['origin', 'upstream'], 'origin'],
[['company'], 'company']
])('reuses known remotes %j when no default ref resolves', async (remotes, expected) => {
gitExecFileAsyncMock.mockRejectedValue(new Error('missing ref'))
await expect(getDefaultRemote('/repo', {}, remotes)).resolves.toBe(expected)
expect(gitExecFileAsyncMock.mock.calls.some(([args]) => args[0] === 'remote')).toBe(false)
})
it.each([
[[], 'Repo has no configured git remotes.'],
[
['upstream', 'fork'],
'Repo has multiple remotes (upstream, fork) and no default is configured. Set branch.<default>.remote.'
]
])('preserves errors for known remotes %j', async (remotes, message) => {
gitExecFileAsyncMock.mockRejectedValue(new Error('missing ref'))
await expect(getDefaultRemote('/repo', {}, remotes)).rejects.toThrow(message)
expect(gitExecFileAsyncMock.mock.calls.some(([args]) => args[0] === 'remote')).toBe(false)
})
})
+10 -6
View File
@@ -125,7 +125,8 @@ export async function getRemoteCount(path: string): Promise<number> {
/** Resolve the configured push remote without assuming a provider. */
export async function getDefaultRemote(
path: string,
options: LocalGitExecOptions = {}
options: LocalGitExecOptions = {},
knownRemoteNames?: readonly string[]
): Promise<string> {
const defaultRef = await getDefaultBaseRefAsync(path, options)
const defaultBranch = defaultRef
@@ -150,11 +151,14 @@ export async function getDefaultRemote(
}
try {
const { stdout } = await gitExecFileAsync(['remote'], gitExecOptions(path, options))
const remotes = stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
let remotes = knownRemoteNames
if (remotes === undefined) {
const { stdout } = await gitExecFileAsync(['remote'], gitExecOptions(path, options))
remotes = stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
}
if (remotes.includes('origin')) {
return 'origin'
}
@@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as FsPromises from 'node:fs/promises'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import type { gitExecFileAsync } from './runner'
const { statProbe, gitExec } = vi.hoisted(() => ({
statProbe: vi.fn<(worktreePath: string) => Promise<void>>(),
gitExec: vi.fn<typeof gitExecFileAsync>()
}))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof FsPromises>()),
stat: statProbe
}))
vi.mock('./runner', () => ({
gitExecFileAsync: gitExec,
translateWslOutputPaths: (output: string) => output
}))
vi.mock('../../shared/git-worktree-admin', () => ({
annotateWorktreeLocksFromAdmin: async (_repoPath: string, rows: GitWorktreeInfo[]) => rows
}))
import { clearGitCapabilityStateForTests } from './git-capability-state'
import { readWorktreeList } from './worktree-list-reader'
function porcelainRow(worktreePath: string, markers: string[] = []): string {
return [
`worktree ${worktreePath}`,
`HEAD ${'a'.repeat(40)}`,
'branch refs/heads/main',
...markers,
'',
''
].join('\n')
}
let porcelain = ''
function nextTurn(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve))
}
beforeEach(() => {
clearGitCapabilityStateForTests()
statProbe.mockReset()
gitExec.mockReset()
porcelain =
porcelainRow('/repo') +
Array.from({ length: 32 }, (_, index) => porcelainRow(`/repo/task-${index}`)).join('')
gitExec.mockImplementation(async (args) => {
if (args.includes('-z')) {
throw Object.assign(new Error("unknown switch `z'"), { stderr: "error: unknown switch `z'" })
}
return { stdout: porcelain, stderr: '' }
})
})
describe('old-Git native worktree listing cancellation', () => {
it('rejects before pending existence probes finish and prevents further probes', async () => {
const controller = new AbortController()
const reason = new Error('Listing closed')
const releases: (() => void)[] = []
statProbe.mockImplementation(() => new Promise((resolve) => releases.push(resolve)))
const outcome = vi.fn<(result: unknown) => void>()
const observed = readWorktreeList('/repo', { signal: controller.signal }).then(
(result) => outcome(result),
(error: unknown) => outcome(error)
)
try {
await nextTurn()
expect(gitExec.mock.calls.map(([args]) => args)).toEqual([
['worktree', 'list', '--porcelain', '-z'],
['worktree', 'list', '--porcelain']
])
expect(statProbe).toHaveBeenCalledTimes(8)
controller.abort(reason)
await nextTurn()
expect(outcome).toHaveBeenCalledExactlyOnceWith(reason)
expect(statProbe).toHaveBeenCalledTimes(8)
} finally {
releases.splice(0).forEach((release) => release())
}
await observed
await nextTurn()
expect(outcome).toHaveBeenCalledExactlyOnceWith(reason)
expect(statProbe).toHaveBeenCalledTimes(8)
})
it('starts no existence probes when the request is already aborted', async () => {
const controller = new AbortController()
const reason = new Error('Already closed')
controller.abort(reason)
await expect(readWorktreeList('/repo', { signal: controller.signal })).rejects.toBe(reason)
await nextTurn()
expect(statProbe).not.toHaveBeenCalled()
})
it('handles synchronous cancellation by the first existence probe without an unhandled rejection', async () => {
const controller = new AbortController()
const reason = new Error('First probe closed the request')
const releases: (() => void)[] = []
const unhandled: unknown[] = []
const onUnhandled = (error: unknown): void => {
unhandled.push(error)
}
process.on('unhandledRejection', onUnhandled)
statProbe.mockImplementation(() => {
controller.abort(reason)
return new Promise((resolve) => releases.push(resolve))
})
try {
await expect(readWorktreeList('/repo', { signal: controller.signal })).rejects.toBe(reason)
expect(statProbe).toHaveBeenCalledTimes(1)
releases.splice(0).forEach((release) => release())
await nextTurn()
expect(statProbe).toHaveBeenCalledTimes(1)
expect(unhandled).toEqual([])
} finally {
releases.splice(0).forEach((release) => release())
process.off('unhandledRejection', onUnhandled)
}
})
it('keeps lock and prunable protections and treats only ENOENT as absence', async () => {
porcelain = [
porcelainRow('/repo'),
porcelainRow('/repo/bare', ['bare']),
porcelainRow('/repo/locked', ['locked agent session']),
porcelainRow('/repo/prunable', ['prunable missing directory']),
porcelainRow('/repo/missing'),
porcelainRow('/repo/denied'),
porcelainRow('/repo/live')
].join('')
statProbe.mockImplementation(async (worktreePath) => {
if (worktreePath.endsWith('/missing')) {
throw Object.assign(new Error('missing'), { code: 'ENOENT' })
}
if (worktreePath.endsWith('/denied')) {
throw Object.assign(new Error('denied'), { code: 'EACCES' })
}
})
const result = await readWorktreeList('/repo')
expect(statProbe.mock.calls.map(([worktreePath]) => worktreePath)).toEqual([
'/repo/missing',
'/repo/denied',
'/repo/live'
])
expect(result.filter((row) => row.prunable).map((row) => row.path)).toEqual([
'/repo/prunable',
'/repo/missing'
])
expect(result.find((row) => row.path === '/repo/locked')).toMatchObject({
locked: true,
lockReason: 'agent session'
})
})
})
+7 -1
View File
@@ -1,3 +1,4 @@
import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import { annotateWorktreeLocksFromAdmin } from '../../shared/git-worktree-admin'
import { stat } from 'node:fs/promises'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
@@ -263,6 +264,7 @@ async function annotatePrunableByExistence(
async function probeNext(): Promise<void> {
while (nextIndex < worktrees.length) {
throwIfSignalAborted(options.signal)
const index = nextIndex
nextIndex += 1
const worktree = worktrees[index]
@@ -287,7 +289,11 @@ async function annotatePrunableByExistence(
}
const workerCount = Math.min(PRUNABLE_EXISTENCE_PROBE_CONCURRENCY, worktrees.length)
await Promise.all(Array.from({ length: workerCount }, () => probeNext()))
await waitForPromiseWithSignal(
Promise.all(Array.from({ length: workerCount }, () => probeNext())),
options.signal
)
throwIfSignalAborted(options.signal)
return annotated
}
@@ -0,0 +1,140 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import type { GitWorktreeExecOptions } from './worktree-operation-options'
const { sparseProbe } = vi.hoisted(() => ({
sparseProbe:
vi.fn<
(repoPath: string, worktreePath: string, options?: GitWorktreeExecOptions) => Promise<boolean>
>()
}))
vi.mock('./worktree-sparse-checkout-cache', () => ({
detectSparseCheckoutCached: sparseProbe
}))
vi.mock('./worktree-list-reader', () => ({
readCheckedOutBranchRef: vi.fn(),
readRepoCommonDirFromGit: vi.fn(),
readRepoLocation: vi.fn(),
readTranslatedWorktreeGraph: vi.fn(),
readWorktreeHeadOid: vi.fn(),
readWorktreeList: vi.fn()
}))
import { annotateSparseCheckoutStatus } from './worktree-listing'
function listedWorktree(index: number): GitWorktreeInfo {
return {
path: `/repo/task-${index}`,
head: 'a'.repeat(40),
branch: `refs/heads/task-${index}`,
isBare: false,
isMainWorktree: false
}
}
function nextTurn(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve))
}
beforeEach(() => {
sparseProbe.mockReset()
})
describe('sparse worktree listing cancellation', () => {
it('rejects while eight probes are pending and claims no more rows after they settle', async () => {
const rows = Array.from({ length: 32 }, (_, index) => listedWorktree(index))
const controller = new AbortController()
const reason = new Error('Listing closed')
const releases: (() => void)[] = []
sparseProbe.mockImplementation(
() => new Promise((resolve) => releases.push(() => resolve(true)))
)
const outcome = vi.fn<(result: unknown) => void>()
const observed = annotateSparseCheckoutStatus('/repo', rows, {
signal: controller.signal,
wslDistro: 'Ubuntu'
}).then(
(result) => outcome(result),
(error: unknown) => outcome(error)
)
try {
await nextTurn()
expect(sparseProbe).toHaveBeenCalledTimes(8)
expect(sparseProbe).toHaveBeenCalledWith('/repo', rows[0]?.path, {
signal: controller.signal,
wslDistro: 'Ubuntu'
})
controller.abort(reason)
await nextTurn()
expect(outcome).toHaveBeenCalledExactlyOnceWith(reason)
expect(sparseProbe).toHaveBeenCalledTimes(8)
} finally {
releases.splice(0).forEach((release) => release())
}
await observed
await nextTurn()
expect(sparseProbe).toHaveBeenCalledTimes(8)
expect(outcome).toHaveBeenCalledExactlyOnceWith(reason)
expect(rows.every((row) => row.isSparse === undefined)).toBe(true)
})
it('starts no probes for a pre-aborted request, including an empty listing', async () => {
const controller = new AbortController()
const reason = new Error('Already closed')
controller.abort(reason)
for (const rows of [[listedWorktree(0)], []]) {
await expect(
annotateSparseCheckoutStatus('/repo', rows, { signal: controller.signal })
).rejects.toBe(reason)
}
await nextTurn()
expect(sparseProbe).not.toHaveBeenCalled()
})
it('observes worker rejections when the first probe synchronously aborts the request', async () => {
const controller = new AbortController()
const reason = new Error('First probe closed the request')
const releases: (() => void)[] = []
const unhandled: unknown[] = []
const onUnhandled = (error: unknown): void => {
unhandled.push(error)
}
process.on('unhandledRejection', onUnhandled)
sparseProbe.mockImplementation(() => {
controller.abort(reason)
return new Promise((resolve) => releases.push(() => resolve(true)))
})
try {
await expect(
annotateSparseCheckoutStatus(
'/repo',
Array.from({ length: 32 }, (_, index) => listedWorktree(index)),
{ signal: controller.signal }
)
).rejects.toBe(reason)
expect(sparseProbe).toHaveBeenCalledTimes(1)
releases.splice(0).forEach((release) => release())
await nextTurn()
expect(sparseProbe).toHaveBeenCalledTimes(1)
expect(unhandled).toEqual([])
} finally {
releases.splice(0).forEach((release) => release())
process.off('unhandledRejection', onUnhandled)
}
})
it('preserves existing sparse and bare rows during a successful listing', async () => {
const rows = [
{ ...listedWorktree(0), isBare: true },
{ ...listedWorktree(1), isSparse: true },
listedWorktree(2)
]
sparseProbe.mockResolvedValue(true)
const result = await annotateSparseCheckoutStatus('/repo', rows)
expect(sparseProbe).toHaveBeenCalledExactlyOnceWith('/repo', rows[2]?.path, {})
expect(result).toEqual([rows[0], rows[1], { ...rows[2], isSparse: true }])
expect(rows[2]?.isSparse).toBeUndefined()
})
})
+8 -1
View File
@@ -1,3 +1,4 @@
import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import { readFile, realpath, stat } from 'node:fs/promises'
import { join, posix } from 'node:path'
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
@@ -128,6 +129,7 @@ export async function annotateSparseCheckoutStatus(
async function detectNext(): Promise<void> {
while (nextIndex < worktrees.length) {
throwIfSignalAborted(options.signal)
const index = nextIndex
nextIndex += 1
const worktree = worktrees[index]
@@ -135,6 +137,7 @@ export async function annotateSparseCheckoutStatus(
continue
}
const isSparse = await detectSparseCheckoutCached(repoPath, worktree.path, options)
throwIfSignalAborted(options.signal)
if (isSparse) {
annotated[index] = { ...worktree, isSparse }
}
@@ -143,7 +146,11 @@ export async function annotateSparseCheckoutStatus(
// Why: cap concurrency so status-poll refreshes don't fan out many sparse-checkout filesystem probes at once.
const workerCount = Math.min(SPARSE_CHECKOUT_DETECTION_CONCURRENCY, worktrees.length)
await Promise.all(Array.from({ length: workerCount }, () => detectNext()))
await waitForPromiseWithSignal(
Promise.all(Array.from({ length: workerCount }, () => detectNext())),
options.signal
)
throwIfSignalAborted(options.signal)
return annotated
}
+1 -1
View File
@@ -98,7 +98,7 @@ describe('resolveGitHubReviewHeadRemote', () => {
expect(remote).toBe('origin')
expect(getGitHubApiRepositoryForRemoteMock).not.toHaveBeenCalled()
expect(getDefaultRemoteMock).toHaveBeenCalledWith('/repo', { wslDistro: 'Ubuntu' })
expect(getDefaultRemoteMock).toHaveBeenCalledWith('/repo', { wslDistro: 'Ubuntu' }, ['origin'])
})
it('prefers origin over other remotes on SSH repos when no identity resolves', async () => {
+1 -1
View File
@@ -49,5 +49,5 @@ export async function resolveGitHubReviewHeadRemote(args: {
if (args.connectionId) {
return pickPreferredGitRemote(remotes)
}
return getDefaultRemote(args.repoPath, args.localGitOptions ?? {})
return getDefaultRemote(args.repoPath, args.localGitOptions ?? {}, remotes)
}
@@ -462,7 +462,11 @@ describe('registerWorktreeHandlers', () => {
['rev-parse', '--verify', 'origin/feature/add-feature'],
{ cwd: '/workspace/repo', wslDistro: 'Ubuntu' }
)
expect(getDefaultRemoteMock).toHaveBeenCalledWith('/workspace/repo', { wslDistro: 'Ubuntu' })
expect(getDefaultRemoteMock).toHaveBeenCalledWith(
'/workspace/repo',
{ wslDistro: 'Ubuntu' },
[]
)
expect(result).toMatchObject({
baseBranch: 'def456',
headSha: 'def456',
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as FsPromises from 'node:fs/promises'
import type { GitWorktreeInfo } from '../shared/worktree/types'
const { statProbe } = vi.hoisted(() => ({
statProbe: vi.fn<(worktreePath: string) => Promise<void>>()
}))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof FsPromises>()),
stat: statProbe
}))
vi.mock('../shared/git-worktree-admin', () => ({
annotateWorktreeLocksFromAdmin: vi.fn()
}))
import { annotatePrunableWorktreesByExistence } from './git-handler-worktree-list'
function listedWorktree(index: number): GitWorktreeInfo {
return {
path: `/remote/repo/task-${index}`,
head: 'a'.repeat(40),
branch: `refs/heads/task-${index}`,
isBare: false,
isMainWorktree: false
}
}
function nextTurn(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve))
}
beforeEach(() => {
statProbe.mockReset()
})
describe('relay worktree existence cancellation', () => {
it('rejects before eight pending stats finish and starts no more after they settle', async () => {
const rows = Array.from({ length: 32 }, (_, index) => listedWorktree(index))
const controller = new AbortController()
const reason = new Error('Remote listing closed')
const releases: (() => void)[] = []
statProbe.mockImplementation(() => new Promise((resolve) => releases.push(resolve)))
const outcome = vi.fn<(result: unknown) => void>()
const observed = annotatePrunableWorktreesByExistence(rows, controller.signal).then(
(result) => outcome(result),
(error: unknown) => outcome(error)
)
try {
await nextTurn()
expect(statProbe).toHaveBeenCalledTimes(8)
controller.abort(reason)
await nextTurn()
expect(outcome).toHaveBeenCalledExactlyOnceWith(reason)
expect(statProbe).toHaveBeenCalledTimes(8)
} finally {
releases.splice(0).forEach((release) => release())
}
await observed
await nextTurn()
expect(statProbe).toHaveBeenCalledTimes(8)
expect(outcome).toHaveBeenCalledExactlyOnceWith(reason)
expect(rows.every((row) => row.prunable === undefined)).toBe(true)
})
it('starts no stats for an already-aborted request, including an empty catalog', async () => {
const controller = new AbortController()
const reason = new Error('Already closed')
controller.abort(reason)
for (const rows of [[listedWorktree(0)], []]) {
await expect(annotatePrunableWorktreesByExistence(rows, controller.signal)).rejects.toBe(
reason
)
}
await nextTurn()
expect(statProbe).not.toHaveBeenCalled()
})
it('handles synchronous abort in the first stat without abandoning rejected workers', async () => {
const controller = new AbortController()
const reason = new Error('First probe closed the request')
const releases: (() => void)[] = []
const unhandled: unknown[] = []
const onUnhandled = (error: unknown): void => {
unhandled.push(error)
}
process.on('unhandledRejection', onUnhandled)
statProbe.mockImplementation(() => {
controller.abort(reason)
return new Promise((resolve) => releases.push(resolve))
})
try {
await expect(
annotatePrunableWorktreesByExistence(
Array.from({ length: 32 }, (_, index) => listedWorktree(index)),
controller.signal
)
).rejects.toBe(reason)
expect(statProbe).toHaveBeenCalledTimes(1)
releases.splice(0).forEach((release) => release())
await nextTurn()
expect(statProbe).toHaveBeenCalledTimes(1)
expect(unhandled).toEqual([])
} finally {
releases.splice(0).forEach((release) => release())
process.off('unhandledRejection', onUnhandled)
}
})
})
@@ -0,0 +1,209 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as FsPromises from 'node:fs/promises'
import type { GitWorktreeInfo } from '../shared/worktree/types'
import type { GitHandlerOperationHost } from './git-handler-operation-context'
import { createGitHandlerRelay } from './git-handler-test-harness'
const { statProbe, annotateLocks } = vi.hoisted(() => ({
statProbe: vi.fn<(worktreePath: string) => Promise<void>>(),
annotateLocks:
vi.fn<
(
repoPath: string,
rows: GitWorktreeInfo[],
options?: { signal?: AbortSignal }
) => Promise<GitWorktreeInfo[]>
>()
}))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof FsPromises>()),
stat: statProbe
}))
vi.mock('../shared/git-worktree-admin', () => ({
annotateWorktreeLocksFromAdmin: annotateLocks
}))
function porcelainRow(worktreePath: string): string {
return `worktree ${worktreePath}\nHEAD ${'a'.repeat(40)}\nbranch refs/heads/main\n\n`
}
function unsupportedZError(): Error {
return Object.assign(new Error('git usage error'), {
code: 129,
stderr: 'usage: git worktree list [<options>]\n'
})
}
function unsupportedPathFormatError(): Error {
return Object.assign(new Error('unsupported path format'), {
stderr: 'error: unknown option `path-format=absolute`\n'
})
}
function nextTurn(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve))
}
let relay: ReturnType<typeof createGitHandlerRelay>
beforeEach(() => {
relay = createGitHandlerRelay()
statProbe.mockReset()
annotateLocks.mockReset().mockImplementation(async (_repoPath, rows) => rows)
})
afterEach(() => relay.handler.dispose())
describe('relay worktree listing request cancellation', () => {
it('forwards the dispatcher signal through the old-Git existence fallback', async () => {
const controller = new AbortController()
const reason = new Error('Remote request closed')
const porcelain =
porcelainRow('/remote/repo') +
Array.from({ length: 32 }, (_, index) => porcelainRow(`/remote/repo/task-${index}`)).join('')
const git = vi.fn<GitHandlerOperationHost['git']>(async (args) => {
if (args.includes('-z')) {
throw unsupportedZError()
}
return { stdout: porcelain, stderr: '' }
})
Object.assign(relay.handler, { git })
const releases: (() => void)[] = []
statProbe.mockImplementation(() => new Promise((resolve) => releases.push(resolve)))
const outcome = vi.fn<(result: unknown) => void>()
const observed = relay.dispatcher
.callRequest(
'git.listWorktrees',
{ repoPath: '/remote/repo' },
{ isStale: () => false, signal: controller.signal }
)
.then(
(result) => outcome(result),
(error: unknown) => outcome(error)
)
try {
await nextTurn()
expect(statProbe).toHaveBeenCalledTimes(8)
expect(git.mock.calls.map(([, , options]) => options?.signal)).toEqual([
controller.signal,
controller.signal
])
expect(annotateLocks).toHaveBeenCalledWith('/remote/repo', expect.any(Array), {
signal: controller.signal
})
controller.abort(reason)
await nextTurn()
expect(outcome).toHaveBeenCalledExactlyOnceWith(reason)
} finally {
releases.splice(0).forEach((release) => release())
}
await observed
await nextTurn()
expect(statProbe).toHaveBeenCalledTimes(8)
expect(outcome).toHaveBeenCalledExactlyOnceWith(reason)
})
it.each(['preferred', 'fallback'] as const)(
'preserves cancellation from the %s normalization command',
async (mode) => {
const controller = new AbortController()
const reason = new Error('Normalization canceled')
const git = vi.fn<GitHandlerOperationHost['git']>(async (args, _cwd, options) => {
expect(options?.signal).toBe(controller.signal)
if (args[0] === 'worktree') {
return { stdout: porcelainRow('/remote/git-store'), stderr: '' }
}
if (mode === 'fallback' && args.includes('--path-format=absolute')) {
throw unsupportedPathFormatError()
}
controller.abort(reason)
throw reason
})
Object.assign(relay.handler, { git })
await expect(
relay.dispatcher.callRequest(
'git.listWorktrees',
{ repoPath: '/remote/repo' },
{ isStale: () => false, signal: controller.signal }
)
).rejects.toBe(reason)
const locationCommands = git.mock.calls
.filter(([args]) => args[0] === 'rev-parse')
.map(([args]) => args)
expect(locationCommands).toEqual(
mode === 'preferred'
? [
[
'rev-parse',
'--path-format=absolute',
'--show-toplevel',
'--git-common-dir',
'--git-dir'
]
]
: [
[
'rev-parse',
'--path-format=absolute',
'--show-toplevel',
'--git-common-dir',
'--git-dir'
],
['rev-parse', '--show-toplevel', '--git-common-dir', '--git-dir']
]
)
expect(statProbe).not.toHaveBeenCalled()
}
)
it.each(['preferred', 'fallback'] as const)(
'keeps separate-git-dir normalization working with the %s command',
async (mode) => {
const controller = new AbortController()
const git = vi.fn<GitHandlerOperationHost['git']>(async (args, _cwd, options) => {
expect(options?.signal).toBe(controller.signal)
if (args[0] === 'worktree') {
return { stdout: porcelainRow('/remote/git-store'), stderr: '' }
}
if (mode === 'fallback' && args.includes('--path-format=absolute')) {
throw unsupportedPathFormatError()
}
return { stdout: '/remote/repo\n/remote/git-store\n/remote/git-store\n', stderr: '' }
})
Object.assign(relay.handler, { git })
await expect(
relay.dispatcher.callRequest(
'git.listWorktrees',
{ repoPath: '/remote/repo' },
{ isStale: () => false, signal: controller.signal }
)
).resolves.toEqual([
expect.objectContaining({
path: '/remote/repo',
isMainWorktree: true
})
])
expect(statProbe).not.toHaveBeenCalled()
}
)
it('rejects a pre-aborted dispatcher request before normalization or existence probes', async () => {
const controller = new AbortController()
const reason = new Error('Already closed')
controller.abort(reason)
const git = vi.fn<GitHandlerOperationHost['git']>(async () => ({
stdout: porcelainRow('/remote/git-store'),
stderr: ''
}))
Object.assign(relay.handler, { git })
await expect(
relay.dispatcher.callRequest(
'git.listWorktrees',
{ repoPath: '/remote/repo' },
{ isStale: () => false, signal: controller.signal }
)
).rejects.toBe(reason)
expect(git).toHaveBeenCalledOnce()
expect(annotateLocks).not.toHaveBeenCalled()
expect(statProbe).not.toHaveBeenCalled()
})
})
+11 -7
View File
@@ -1,3 +1,4 @@
import { throwIfSignalAborted, waitForPromiseWithSignal } from '../shared/abort-signal-reason'
import { annotateWorktreeLocksFromAdmin } from '../shared/git-worktree-admin'
import { expandTilde } from './context'
import { stat } from 'node:fs/promises'
@@ -46,21 +47,20 @@ const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8
* harmless backstop. The relay owns the filesystem, so a plain stat is
* authoritative. */
export async function annotatePrunableWorktreesByExistence(
worktrees: GitWorktreeInfo[]
worktrees: GitWorktreeInfo[],
signal?: AbortSignal
): Promise<GitWorktreeInfo[]> {
const annotated = [...worktrees]
let nextIndex = 0
async function probeNext(): Promise<void> {
while (nextIndex < worktrees.length) {
throwIfSignalAborted(signal)
const index = nextIndex
nextIndex += 1
const worktree = worktrees[index]
const worktreePath = worktree?.path ?? ''
// Git only marks linked worktrees prunable, and never locked ones (a
// lock shields the registration even when the directory is missing). The
// Older Git locks are annotated from the host admin directory. A missing main
// worktree is surfaced by the repo-level failure paths.
// Locks protect missing linked worktrees; repository failures own the main row.
if (
!worktreePath ||
worktree.isMainWorktree === true ||
@@ -73,7 +73,7 @@ export async function annotatePrunableWorktreesByExistence(
try {
await stat(worktreePath)
} catch (err) {
if ((err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
if (typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT') {
annotated[index] = { ...worktree, prunable: true }
}
}
@@ -81,7 +81,11 @@ export async function annotatePrunableWorktreesByExistence(
}
const workerCount = Math.min(PRUNABLE_EXISTENCE_PROBE_CONCURRENCY, worktrees.length)
await Promise.all(Array.from({ length: workerCount }, () => probeNext()))
await waitForPromiseWithSignal(
Promise.all(Array.from({ length: workerCount }, () => probeNext())),
signal
)
throwIfSignalAborted(signal)
return annotated
}
+24 -8
View File
@@ -1,3 +1,4 @@
import { throwIfSignalAborted } from '../shared/abort-signal-reason'
import { annotateWorktreeLocksFromAdmin } from '../shared/git-worktree-admin'
import * as path from 'node:path'
import type { RequestContext } from './dispatcher'
@@ -67,7 +68,10 @@ export class GitHandlerWorktreeOperations extends GitHandlerOperationContext {
}
}
private async readRepoLocation(repoPath: string): Promise<RelayRepoLocation | undefined> {
private async readRepoLocation(
repoPath: string,
signal?: AbortSignal
): Promise<RelayRepoLocation | undefined> {
try {
return await this.gitCapabilities.runWithFallback(
'rev-parse-path-format',
@@ -80,7 +84,8 @@ export class GitHandlerWorktreeOperations extends GitHandlerOperationContext {
'--git-common-dir',
'--git-dir'
],
repoPath
repoPath,
{ signal }
)
if (hasUnsupportedRevParsePathFormatEcho(stdout)) {
// Why: old Git echoes the unknown option and exits zero; remember the signal though the paths still parse.
@@ -91,21 +96,25 @@ export class GitHandlerWorktreeOperations extends GitHandlerOperationContext {
async () => {
const { stdout } = await this.git(
['rev-parse', '--show-toplevel', '--git-common-dir', '--git-dir'],
repoPath
repoPath,
{ signal }
)
return parseRelayRepoLocation(repoPath, stdout)
},
isUnsupportedRevParsePathFormatError
)
} catch {
throwIfSignalAborted(signal)
return undefined
}
}
private async normalizeMainWorktreePath(
repoPath: string,
worktrees: GitWorktreeInfo[]
worktrees: GitWorktreeInfo[],
signal?: AbortSignal
): Promise<GitWorktreeInfo[]> {
throwIfSignalAborted(signal)
const mainIndex = worktrees.findIndex((worktree) => worktree.isMainWorktree === true)
const mainWorktree = worktrees[mainIndex]
const mainPath = mainWorktree?.path ?? ''
@@ -115,7 +124,8 @@ export class GitHandlerWorktreeOperations extends GitHandlerOperationContext {
return worktrees
}
const location = await this.readRepoLocation(resolvedRepoPath)
const location = await this.readRepoLocation(resolvedRepoPath, signal)
throwIfSignalAborted(signal)
if (!location) {
return worktrees
}
@@ -144,7 +154,8 @@ export class GitHandlerWorktreeOperations extends GitHandlerOperationContext {
})
return this.normalizeMainWorktreePath(
repoPath,
parseWorktreeList(stdout, { nulDelimited: true })
parseWorktreeList(stdout, { nulDelimited: true }),
context?.signal
)
},
async () => {
@@ -154,12 +165,17 @@ export class GitHandlerWorktreeOperations extends GitHandlerOperationContext {
const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, {
signal: context?.signal
})
const normalized = await this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout))
const normalized = await this.normalizeMainWorktreePath(
repoPath,
parseWorktreeList(stdout),
context?.signal
)
// Why: Git <2.31 emits no `prunable` annotation, so probe each linked worktree's existence instead of trusting stale registrations (issue #8389).
return annotatePrunableWorktreesByExistence(
await annotateWorktreeLocksFromAdmin(expandTilde(repoPath), normalized, {
signal: context?.signal
})
}),
context?.signal
)
},
isUnsupportedWorktreeListZError
+14
View File
@@ -0,0 +1,14 @@
import { expect, it } from 'vitest'
import { waitForPromiseWithSignal } from './abort-signal-reason'
it('observes work that rejects after its caller has already canceled', async () => {
const controller = new AbortController()
const reason = new Error('Caller canceled')
controller.abort(reason)
const work = new Promise<never>((_resolve, reject) => {
queueMicrotask(() => reject(new Error('Late filesystem failure')))
})
await expect(waitForPromiseWithSignal(work, controller.signal)).rejects.toBe(reason)
await new Promise<void>((resolve) => setImmediate(resolve))
})
+2
View File
@@ -16,6 +16,8 @@ export function waitForPromiseWithSignal<T>(promise: Promise<T>, signal?: AbortS
return promise
}
if (signal.aborted) {
// Observe abandoned work so its later rejection stays handled.
void promise.catch(() => undefined)
return Promise.reject(abortSignalReason(signal))
}
return new Promise<T>((resolve, reject) => {
+57 -3
View File
@@ -16,6 +16,10 @@ function createRunner(overrides: {
originExists?: boolean
upstreamExists?: boolean
aheadBehind?: string
equalTips?: boolean
ancestryError?: boolean
commitOutput?: string
countError?: boolean
}): { runGit: GitForkSyncRunner; calls: string[][] } {
const calls: string[][] = []
const runGit = vi.fn(async (args: string[]) => {
@@ -42,14 +46,22 @@ function createRunner(overrides: {
throw new Error('missing upstream branch')
}
return {
stdout: ref.includes('upstream')
? '2222222222222222222222222222222222222222\n'
: '1111111111111111111111111111111111111111\n'
stdout:
overrides.commitOutput ??
(ref.includes('upstream') && !overrides.equalTips
? '2222222222222222222222222222222222222222\n'
: '1111111111111111111111111111111111111111\n')
}
}
if (args[0] === 'rev-list') {
if (overrides.countError) {
throw new Error('invalid revision')
}
return { stdout: overrides.aheadBehind ?? '0\t2\n' }
}
if (args[0] === 'merge-base' && overrides.ancestryError) {
throw new Error('missing parent object')
}
return { stdout: '' }
})
return { runGit, calls }
@@ -106,6 +118,48 @@ describe('syncForkDefaultBranch', () => {
expect(flattenedCommands(calls)).not.toContain('push origin')
})
it('skips history counts for equal verified tips while retaining fetches and ancestry validation', async () => {
const { runGit, calls } = createRunner({ equalTips: true })
await expect(syncForkDefaultBranch(runGit)).resolves.toMatchObject({
status: 'up-to-date',
ahead: 0,
behind: 0
})
expect(calls.filter((args) => args[0] === 'fetch')).toHaveLength(2)
expect(calls.filter((args) => args[0] === 'rev-parse')).toHaveLength(2)
expect(calls.some((args) => args[0] === 'rev-list' || args[0] === 'push')).toBe(false)
expect(calls).toContainEqual([
'merge-base',
'--is-ancestor',
'1111111111111111111111111111111111111111',
'1111111111111111111111111111111111111111'
])
})
it('still blocks equal tips when ancestry cannot be verified', async () => {
const { runGit, calls } = createRunner({ equalTips: true, ancestryError: true })
await expect(syncForkDefaultBranch(runGit)).resolves.toMatchObject({
status: 'blocked',
reason: 'diverged',
ahead: 0,
behind: 0
})
expect(calls.some((args) => args[0] === 'push')).toBe(false)
})
it('keeps the count query error when a wrapper returns identical non-object output', async () => {
const { runGit, calls } = createRunner({
commitOutput: 'wrapper banner\n',
countError: true
})
await expect(syncForkDefaultBranch(runGit)).rejects.toThrow('invalid revision')
expect(calls.some((args) => args[0] === 'rev-list')).toBe(true)
expect(calls.some((args) => args[0] === 'push')).toBe(false)
})
it('scans newline-heavy remote and default-branch output without line-array splitting', async () => {
const splitSpy = vi.spyOn(String.prototype, 'split')
const { runGit } = createRunner({
+7 -3
View File
@@ -278,9 +278,13 @@ export async function syncForkDefaultBranch(
return { ...resultWithBranch, status: 'blocked', reason: 'missing-origin-branch' }
}
const counts = parseAheadBehind(
(await runGit(['rev-list', '--left-right', '--count', `${originOid}...${upstreamOid}`])).stdout
)
const counts =
originOid === upstreamOid && /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/.test(originOid)
? { ahead: 0, behind: 0 }
: parseAheadBehind(
(await runGit(['rev-list', '--left-right', '--count', `${originOid}...${upstreamOid}`]))
.stdout
)
if (counts.ahead > 0 || !(await isAncestor(runGit, originOid, upstreamOid))) {
return { ...resultWithBranch, ...counts, status: 'blocked', reason: 'diverged' }