From 533992bdda40085e3c1bfbf31fa711b9622e229d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:19:36 -0700 Subject: [PATCH] fix(git): cache unsupported capabilities per host (#8109) * fix(git): cache unsupported capabilities per host Old Git worktree, ref-search, and merge-tree fallbacks retried unsupported flags on recurring operations, flooding subprocess traces. Centralize capability probing per native, WSL, and SSH execution host, coalesce concurrent probes, and retry periodically for in-place Git upgrades. * fix(git): recognize real old-Git merge-tree rejection * test(git): enforce real binary compatibility matrix * fix(ci): preserve Git compatibility test ownership * fix(git): retain supported capability state --- .github/workflows/pr.yml | 29 +++- AGENTS.md | 12 ++ ...git-binary-compatibility-workflow.test.mjs | 20 +++ docs/reference/git-compatibility.md | 62 ++++++++ src/main/git/git-capability-state.test.ts | 37 +++++ src/main/git/git-capability-state.ts | 44 ++++++ src/main/git/remove-worktree.test.ts | 6 + src/main/git/repo-search-ref-compat.test.ts | 6 +- src/main/git/repo.ts | 56 +++---- .../git/worktree-git-capabilities.test.ts | 138 +++++++++++++++++ src/main/git/worktree.test.ts | 6 + src/main/git/worktree.ts | 120 ++++++++------- src/main/github/conflict-summary.test.ts | 17 ++ src/main/github/conflict-summary.ts | 87 ++++++----- src/main/ipc/repos-remote.test.ts | 14 +- src/main/ipc/repos.ts | 52 ++++--- src/main/runtime/orca-runtime.test.ts | 5 +- src/main/runtime/orca-runtime.ts | 52 ++++--- src/relay/git-handler-branch-cleanup.test.ts | 24 ++- src/relay/git-handler-branch-cleanup.ts | 8 +- .../git-handler-local-base-ref-refresh.ts | 6 +- src/relay/git-handler-utils.ts | 31 +--- ...-handler-worktree-git-capabilities.test.ts | 115 ++++++++++++++ src/relay/git-handler-worktree-list.ts | 43 ++++++ src/relay/git-handler-worktree-ops.test.ts | 31 ++-- src/relay/git-handler-worktree-ops.ts | 36 +---- src/relay/git-handler-worktree-paths.test.ts | 12 +- src/relay/git-handler-worktree-remove.ts | 54 ++----- src/relay/git-handler.ts | 119 +++++++------- src/shared/git-binary-compatibility.test.ts | 145 ++++++++++++++++++ src/shared/git-branch-cleanup.test.ts | 64 +++++++- src/shared/git-branch-cleanup.ts | 43 ++++-- src/shared/git-capability-cache.test.ts | 133 ++++++++++++++++ src/shared/git-capability-cache.ts | 115 ++++++++++++++ src/shared/git-merge-tree-capability.test.ts | 32 ++++ src/shared/git-merge-tree-capability.ts | 27 ++++ src/shared/git-ref-command-capabilities.ts | 14 ++ .../git-worktree-command-capabilities.ts | 34 ++++ 38 files changed, 1467 insertions(+), 382 deletions(-) create mode 100644 config/scripts/git-binary-compatibility-workflow.test.mjs create mode 100644 docs/reference/git-compatibility.md create mode 100644 src/main/git/git-capability-state.test.ts create mode 100644 src/main/git/git-capability-state.ts create mode 100644 src/main/git/worktree-git-capabilities.test.ts create mode 100644 src/relay/git-handler-worktree-git-capabilities.test.ts create mode 100644 src/relay/git-handler-worktree-list.ts create mode 100644 src/shared/git-binary-compatibility.test.ts create mode 100644 src/shared/git-capability-cache.test.ts create mode 100644 src/shared/git-capability-cache.ts create mode 100644 src/shared/git-merge-tree-capability.test.ts create mode 100644 src/shared/git-merge-tree-capability.ts create mode 100644 src/shared/git-ref-command-capabilities.ts create mode 100644 src/shared/git-worktree-command-capabilities.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2f91c946b..0a4169c6b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@v6 - name: Install native build tools - run: sudo apt-get update && sudo apt-get install -y build-essential python3 zsh + run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh - name: Setup Node.js uses: actions/setup-node@v6 @@ -103,6 +103,33 @@ jobs: - name: Typecheck run: pnpm typecheck + # Why: real old Git diagnostics differ from mocked errors. Keep the + # fallback predicates executable across the baseline, transition, and + # current command shapes so a newly added flag cannot silently regress. + - name: Verify Git binary compatibility matrix + run: | + archive="$RUNNER_TEMP/git-2.25.5.tar.gz" + source="$RUNNER_TEMP/git-2.25.5" + curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive" + echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \ + | sha256sum --check + mkdir -p "$source" + tar -xzf "$archive" -C "$source" --strip-components=1 + make -C "$source" -j2 NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git + ORCA_GIT_COMPAT_BINARY="$source/git" ORCA_GIT_COMPAT_VERSION="2.25.5" \ + pnpm exec vitest run --config config/vitest.config.ts \ + src/shared/git-binary-compatibility.test.ts + + for spec in \ + "alpine/git:edge-2.38.1|2.38.1" \ + "alpine/git:v2.49.1|2.49.1"; do + image="${spec%%|*}" + version="${spec#*|}" + ORCA_GIT_COMPAT_IMAGE="$image" ORCA_GIT_COMPAT_VERSION="$version" \ + pnpm exec vitest run --config config/vitest.config.ts \ + src/shared/git-binary-compatibility.test.ts + done + # Why: postinstall rebuilds better-sqlite3 for Electron's ABI via # @electron/rebuild, but vitest runs under system Node.js. Rebuild # it for Node so orchestration tests can load the native module. diff --git a/AGENTS.md b/AGENTS.md index b10035e9b..989fa6112 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,18 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh All changes must consider the SSH use case. Don't assume local-only execution. +## Git Binary Compatibility + +Orca runs the user's Git binary on native, WSL, and SSH hosts, which may all have different versions. Treat Git 2.25 as the core-workflow baseline and follow [`docs/reference/git-compatibility.md`](./docs/reference/git-compatibility.md). + +When adding or changing a Git command: + +- Check when every subcommand and option was introduced. For newer behavior, keep a baseline-compatible fallback or degrade safely. +- Use `GitCapabilityCache` with a narrow unsupported-error predicate so recurring operations do not retry a known-invalid command. Do not rely only on `git --version`; wrappers such as `simple-git` do not remove host-version differences. +- Scope capability state to the host that executes Git: native, WSL distro, SSH provider, or relay connection. Cover the first fallback, later cached calls, concurrent probes, and relevant host isolation in tests. +- Keep the real-binary compatibility contract in PR CI current. When adopting a newer Git feature, add its version boundary so the preferred command and fallback both run against representative Git releases. +- Preserve commands that begin with global Git options such as `-c` before the subcommand, including auto-maintenance suppression used by worktree-create fetches. + ## Git Provider Compatibility Source-control and review changes must consider GitLab and other supported git providers, not only GitHub. Keep provider-specific behavior behind explicit checks, and avoid GitHub-only naming for generic review concepts. diff --git a/config/scripts/git-binary-compatibility-workflow.test.mjs b/config/scripts/git-binary-compatibility-workflow.test.mjs new file mode 100644 index 000000000..67509f7f6 --- /dev/null +++ b/config/scripts/git-binary-compatibility-workflow.test.mjs @@ -0,0 +1,20 @@ +import { readFileSync } from 'node:fs' +import { parse } from 'yaml' +import { describe, expect, it } from 'vitest' + +describe('Git binary compatibility PR gate', () => { + it('runs the real-binary contract at each compatibility boundary', () => { + const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) + const step = workflow.jobs.verify.steps.find( + (candidate) => candidate.name === 'Verify Git binary compatibility matrix' + ) + + expect(step?.run).toContain('git-2.25.5.tar.gz') + expect(step?.run).toContain('41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf') + expect(step?.run).toContain('ORCA_GIT_COMPAT_BINARY="$source/git"') + expect(step?.run).toContain('alpine/git:edge-2.38.1|2.38.1') + expect(step?.run).toContain('alpine/git:v2.49.1|2.49.1') + expect(step?.run).toContain('ORCA_GIT_COMPAT_IMAGE="$image"') + expect(step?.run).toContain('src/shared/git-binary-compatibility.test.ts') + }) +}) diff --git a/docs/reference/git-compatibility.md b/docs/reference/git-compatibility.md new file mode 100644 index 000000000..c943bc9ce --- /dev/null +++ b/docs/reference/git-compatibility.md @@ -0,0 +1,62 @@ +# Git Compatibility Policy + +## Scope + +Orca executes the user's Git binary on three kinds of execution host: native, +WSL, and SSH. Each host can have a different Git version, so compatibility +state must be scoped to the host that actually runs the command. + +Git 2.25 is the core-workflow compatibility baseline for command selection. It +is the oldest line that covers Orca's baseline use of porcelain v2, `branch +--show-current`, `restore`, and sparse checkout. Optional features that need a +newer Git must degrade safely and cache the missing capability. Orca does not +currently block older Git at startup, but new command construction should not +assume features introduced after this baseline. + +## Capability Rules + +When a newer Git feature materially improves correctness or performance: + +1. Keep a baseline-compatible command or parser as the fallback. +2. Detect rejection with a narrow predicate for that option or subcommand. +3. Run the preferred command through `GitCapabilityCache` so a rejection is + remembered for the native host, WSL distro, or SSH provider that produced it. +4. Retry after the cache interval so an in-place Git upgrade self-heals without + restarting Orca. +5. Test the first fallback, later calls that skip the rejected probe, concurrent + probe coalescing, and execution-host isolation where applicable. + +Do not branch only on a parsed `git --version`. Vendor builds can backport +features, and wrappers can report a host version that differs from the binary +used inside WSL or SSH. A behavior probe plus a precise fallback is the final +authority. + +## Current Capabilities + +| Capability | Preferred behavior | Compatibility behavior | +| ----------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `worktree-list-z` | NUL-delimited worktree paths | Line-block parser for Git before `worktree list -z` | +| `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo | +| `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca | +| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 | +| `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form | + +## Why Not `simple-git` + +`simple-git` is a process wrapper around the installed Git binary. Its custom +options and `raw` API pass arguments through to Git, so it cannot make a newer +flag work on an older binary or choose Orca's semantic fallback automatically. +It provides version reporting and subprocess queueing, but Orca already needs +its own WSL/SSH routing, cancellation, tracing, redaction, process cleanup, and +bounded output handling. Replacing the runner would move—not remove—the +capability problem. + +## CI Contract + +PR checks run the capability contract against real Git 2.25.5, 2.38.1, and +2.49.1 binaries. This spans the core-workflow baseline, the transitional +`merge-tree --write-tree` behavior before `--merge-base`, and current Git. + +Keep the unit tests alongside that matrix. They cover concurrent probes, +native/WSL/SSH/relay isolation, and error-stream shapes that a single real +binary invocation cannot exercise deterministically. diff --git a/src/main/git/git-capability-state.test.ts b/src/main/git/git-capability-state.test.ts new file mode 100644 index 000000000..0d499aea1 --- /dev/null +++ b/src/main/git/git-capability-state.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + clearGitCapabilityStateForTests, + getLocalGitCapabilityCache, + getSshGitCapabilityCache +} from './git-capability-state' + +describe('Git capability execution-host state', () => { + beforeEach(() => { + clearGitCapabilityStateForTests() + }) + + it('shares native state while isolating each WSL distro', () => { + expect(getLocalGitCapabilityCache({ cwd: '/repo-a' })).toBe( + getLocalGitCapabilityCache({ cwd: '/repo-b' }) + ) + expect(getLocalGitCapabilityCache({ wslDistro: 'Ubuntu' })).toBe( + getLocalGitCapabilityCache({ cwd: '\\\\wsl.localhost\\Ubuntu\\home\\repo' }) + ) + expect(getLocalGitCapabilityCache({ wslDistro: 'Ubuntu' })).not.toBe( + getLocalGitCapabilityCache({ wslDistro: 'Debian' }) + ) + expect(getLocalGitCapabilityCache()).not.toBe( + getLocalGitCapabilityCache({ wslDistro: 'Ubuntu' }) + ) + }) + + it('shares one SSH provider lifetime without leaking into a replacement provider', () => { + const provider = {} + const replacementProvider = {} + + expect(getSshGitCapabilityCache(provider)).toBe(getSshGitCapabilityCache(provider)) + expect(getSshGitCapabilityCache(provider)).not.toBe( + getSshGitCapabilityCache(replacementProvider) + ) + }) +}) diff --git a/src/main/git/git-capability-state.ts b/src/main/git/git-capability-state.ts new file mode 100644 index 000000000..aec7a369a --- /dev/null +++ b/src/main/git/git-capability-state.ts @@ -0,0 +1,44 @@ +import { GitCapabilityCache } from '../../shared/git-capability-cache' +import { parseWslUncPath } from '../../shared/wsl-paths' + +type LocalGitCapabilityTarget = { + cwd?: string + wslDistro?: string +} + +const localCapabilitiesByExecutionHost = new Map() +// Why: reconnecting creates a new provider, while concurrent IPC/runtime users +// of one SSH connection must share the same remote Git capability results. +let sshCapabilitiesByProvider = new WeakMap() + +function getLocalGitExecutionHostKey(target: LocalGitCapabilityTarget): string { + const wslDistro = + target.wslDistro ?? (target.cwd ? parseWslUncPath(target.cwd)?.distro : undefined) + return wslDistro ? `wsl:${wslDistro}` : 'local' +} + +export function getLocalGitCapabilityCache( + target: LocalGitCapabilityTarget = {} +): GitCapabilityCache { + const executionHost = getLocalGitExecutionHostKey(target) + let cache = localCapabilitiesByExecutionHost.get(executionHost) + if (!cache) { + cache = new GitCapabilityCache() + localCapabilitiesByExecutionHost.set(executionHost, cache) + } + return cache +} + +export function getSshGitCapabilityCache(provider: object): GitCapabilityCache { + let cache = sshCapabilitiesByProvider.get(provider) + if (!cache) { + cache = new GitCapabilityCache() + sshCapabilitiesByProvider.set(provider, cache) + } + return cache +} + +export function clearGitCapabilityStateForTests(): void { + localCapabilitiesByExecutionHost.clear() + sshCapabilitiesByProvider = new WeakMap() +} diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index 53c424e50..5c3a07693 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -32,6 +32,8 @@ vi.mock('fs/promises', async () => { return { ...actual, stat: statMock } }) +import { clearGitCapabilityStateForTests } from './git-capability-state' + import { addSparseWorktree, assertWorktreeCleanForRemoval, @@ -40,6 +42,10 @@ import { removeWorktree } from './worktree' +beforeEach(() => { + clearGitCapabilityStateForTests() +}) + type MockResult = { error?: Error stdout?: string diff --git a/src/main/git/repo-search-ref-compat.test.ts b/src/main/git/repo-search-ref-compat.test.ts index 9e9659a11..7c29357f5 100644 --- a/src/main/git/repo-search-ref-compat.test.ts +++ b/src/main/git/repo-search-ref-compat.test.ts @@ -9,10 +9,12 @@ vi.mock('./runner', () => ({ gitExecFileSync: vi.fn() })) +import { clearGitCapabilityStateForTests } from './git-capability-state' import { searchBaseRefs } from './repo' describe('searchBaseRefs git compatibility', () => { afterEach(() => { + clearGitCapabilityStateForTests() gitExecFileAsyncMock.mockReset() }) @@ -35,13 +37,15 @@ describe('searchBaseRefs git compatibility', () => { } }) + await expect(searchBaseRefs('/repo', '', 1)).resolves.toEqual(['origin/main']) await expect(searchBaseRefs('/repo', '', 1)).resolves.toEqual(['origin/main']) const forEachRefCalls = gitExecFileAsyncMock.mock.calls.filter( (call) => (call[0] as string[])[0] === 'for-each-ref' ) - expect(forEachRefCalls).toHaveLength(2) + expect(forEachRefCalls).toHaveLength(3) expect(forEachRefCalls[0][0]).toContain('--exclude=refs/remotes/**/HEAD') expect(forEachRefCalls[1][0]).not.toContain('--exclude=refs/remotes/**/HEAD') expect(forEachRefCalls[1][0]).toContain('--count=104') + expect(forEachRefCalls[2][0]).not.toContain('--exclude=refs/remotes/**/HEAD') }) }) diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index 68900f810..f649017cd 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -5,9 +5,11 @@ import { gitExecFileSync, gitExecFileAsync } from './runner' import type { BaseRefSearchResult } from '../../shared/types' import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' +import { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' import { parseWslUncPath } from '../../shared/wsl-paths' import { toWindowsWslPath } from '../wsl' import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from './hosted-remote-url' +import { getLocalGitCapabilityCache } from './git-capability-state' type LocalGitExecOptions = { wslDistro?: string @@ -788,27 +790,27 @@ async function runSearchBaseRefsGit( limit: number, options: { remoteNames: readonly string[]; patternGroup?: RefSearchPatternGroup } ): Promise<{ stdout: string }> { - try { - return await gitExecFileAsync( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - remoteNames: options.remoteNames, - patternGroup: options.patternGroup - }), - { cwd: path } - ) - } catch (err) { - if (!isForEachRefExcludeUnsupportedError(err)) { - throw err - } - return gitExecFileAsync( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - excludeRemoteHead: false, - remoteNames: options.remoteNames, - patternGroup: options.patternGroup - }), - { cwd: path } - ) - } + return getLocalGitCapabilityCache({ cwd: path }).runWithFallback( + 'for-each-ref-exclude', + () => + gitExecFileAsync( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + remoteNames: options.remoteNames, + patternGroup: options.patternGroup + }), + { cwd: path } + ), + () => + gitExecFileAsync( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + excludeRemoteHead: false, + remoteNames: options.remoteNames, + patternGroup: options.patternGroup + }), + { cwd: path } + ), + isForEachRefExcludeUnsupportedError + ) } export function mergeBaseRefSearchResultGroups( @@ -834,17 +836,7 @@ export function mergeBaseRefSearchResultGroups( return merged } -export function isForEachRefExcludeUnsupportedError(error: unknown): boolean { - if (!error || typeof error !== 'object') { - return false - } - const maybe = error as { message?: unknown; stderr?: unknown; stdout?: unknown } - const text = [maybe.message, maybe.stderr, maybe.stdout] - .filter((value): value is string => typeof value === 'string') - .join('\n') - .toLowerCase() - return text.includes('unknown option') && text.includes('exclude') -} +export { isForEachRefExcludeUnsupportedError } from '../../shared/git-ref-command-capabilities' /** * Resolve the default push remote for a repo. diff --git a/src/main/git/worktree-git-capabilities.test.ts b/src/main/git/worktree-git-capabilities.test.ts new file mode 100644 index 000000000..c6d7cdfc6 --- /dev/null +++ b/src/main/git/worktree-git-capabilities.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn() +})) + +vi.mock('./runner', () => ({ + gitExecFileAsync: gitExecFileAsyncMock, + gitExecFileSync: vi.fn(), + translateWslOutputPaths: (output: string) => output +})) + +import { clearGitCapabilityStateForTests } from './git-capability-state' +import { listWorktrees } from './worktree' + +const WORKTREE_LIST_OUTPUT = `worktree /repo +HEAD abc123 +branch refs/heads/main +` + +describe('worktree Git capabilities', () => { + beforeEach(() => { + clearGitCapabilityStateForTests() + gitExecFileAsyncMock.mockReset() + }) + + it('does not repeat a known-unsupported -z probe on later scans', async () => { + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.includes('-z')) { + return Promise.reject( + Object.assign(new Error('git usage error'), { + code: 129, + stderr: 'usage: git worktree list []\n' + }) + ) + } + return Promise.resolve({ stdout: WORKTREE_LIST_OUTPUT }) + }) + + await listWorktrees('/repo') + await listWorktrees('/repo') + + expect(gitExecFileAsyncMock.mock.calls.map(([args]) => args)).toEqual([ + ['worktree', 'list', '--porcelain', '-z'], + ['worktree', 'list', '--porcelain'], + ['worktree', 'list', '--porcelain'] + ]) + }) + + it('keeps native and WSL Git capability results separate', async () => { + gitExecFileAsyncMock.mockImplementation( + (args: string[], options: { cwd: string; wslDistro?: string }) => { + if (args.includes('-z') && !options.wslDistro) { + return Promise.reject( + Object.assign(new Error('git usage error'), { + code: 129, + stderr: 'usage: git worktree list []\n' + }) + ) + } + return Promise.resolve({ + stdout: `worktree ${options.cwd}\nHEAD abc123\nbranch refs/heads/main\n` + }) + } + ) + + await listWorktrees('/native-repo') + await listWorktrees('/wsl-repo', { wslDistro: 'Ubuntu' }) + + expect( + gitExecFileAsyncMock.mock.calls.map(([args, options]) => ({ + args, + wslDistro: options.wslDistro + })) + ).toEqual([ + { args: ['worktree', 'list', '--porcelain', '-z'], wslDistro: undefined }, + { args: ['worktree', 'list', '--porcelain'], wslDistro: undefined }, + { args: ['worktree', 'list', '--porcelain', '-z'], wslDistro: 'Ubuntu' } + ]) + }) + + it('does not repeat a known-unsupported rev-parse --path-format probe', async () => { + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args[0] === 'worktree') { + return Promise.resolve({ + stdout: 'worktree /git-store/project.git\nHEAD abc123\nbranch refs/heads/main\n' + }) + } + if (args.includes('--path-format=absolute')) { + return Promise.reject( + Object.assign(new Error('unknown option: --path-format=absolute'), { + stderr: 'error: unknown option `path-format=absolute`\n' + }) + ) + } + return Promise.resolve({ stdout: '/repo\n/git-store/project.git\n' }) + }) + + await listWorktrees('/repo') + await listWorktrees('/repo') + + const revParseCalls = gitExecFileAsyncMock.mock.calls.filter( + ([args]) => (args as string[])[0] === 'rev-parse' + ) + expect(revParseCalls.map(([args]) => args)).toEqual([ + ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], + ['rev-parse', '--show-toplevel', '--git-common-dir'], + ['rev-parse', '--show-toplevel', '--git-common-dir'] + ]) + }) + + it('remembers old Git that echoes --path-format while exiting successfully', async () => { + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args[0] === 'worktree') { + return Promise.resolve({ + stdout: 'worktree /git-store/project.git\nHEAD abc123\nbranch refs/heads/main\n' + }) + } + if (args.includes('--path-format=absolute')) { + return Promise.resolve({ + stdout: '--path-format=absolute\n/repo\n/git-store/project.git\n' + }) + } + return Promise.resolve({ stdout: '/repo\n/git-store/project.git\n' }) + }) + + await listWorktrees('/repo') + await listWorktrees('/repo') + + const revParseCalls = gitExecFileAsyncMock.mock.calls.filter( + ([args]) => (args as string[])[0] === 'rev-parse' + ) + expect(revParseCalls.map(([args]) => args)).toEqual([ + ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], + ['rev-parse', '--show-toplevel', '--git-common-dir'] + ]) + }) +}) diff --git a/src/main/git/worktree.test.ts b/src/main/git/worktree.test.ts index 6388ccdbc..858b7c2bf 100644 --- a/src/main/git/worktree.test.ts +++ b/src/main/git/worktree.test.ts @@ -18,6 +18,8 @@ vi.mock('./runner', () => ({ translateWslOutputPaths: translateWslOutputPathsMock })) +import { clearGitCapabilityStateForTests } from './git-capability-state' + import { addSparseWorktree, addWorktree, @@ -29,6 +31,10 @@ import { WORKTREE_ADD_TIMEOUT_MS } from './worktree' +beforeEach(() => { + clearGitCapabilityStateForTests() +}) + describe('listWorktrees in-flight sharing', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 11961404f..19b84a7b3 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -17,6 +17,12 @@ import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree-removal' import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path' import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' import { parseWslUncPath } from '../../shared/wsl-paths' +import { + hasUnsupportedRevParsePathFormatEcho, + isUnsupportedRevParsePathFormatError, + isUnsupportedWorktreeListZError +} from '../../shared/git-worktree-command-capabilities' +import { getLocalGitCapabilityCache } from './git-capability-state' import { gitExecFileAsync, translateWslOutputPaths } from './runner' import { resolveGitDir } from './status' import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' @@ -111,22 +117,6 @@ function isNotGitRepositoryError(error: unknown): boolean { return /not a git repository/i.test(getErrorText(error)) } -function isUnsupportedWorktreeListZError(error: unknown): boolean { - // `-z` is this command's only flag older Git (<2.36) lacks, so its usage - // exit 129 signals the rejection in any locale; stderr match is a fallback. - if (getErrorCode(error) === '129') { - return true - } - - return /(?:unknown|invalid|unrecognized) (?:switch|option).*`?-?z'?/i.test(getErrorText(error)) -} - -function isUnsupportedRevParsePathFormatError(error: unknown): boolean { - return /(?:unknown|invalid|unrecognized).*(?:--path-format|path-format)/i.test( - getErrorText(error) - ) -} - function isBranchCheckedOutInWorktreeError(error: unknown): boolean { return /cannot delete branch .*(?:used by worktree|checked out)|branch .*is checked out/i.test( getErrorText(error) @@ -410,24 +400,34 @@ async function readRepoLocation( resolveBasePath: string, options: GitWorktreeExecOptions = {} ): Promise { + const capabilities = getLocalGitCapabilityCache({ + cwd: repoPath, + wslDistro: options.wslDistro + }) try { - const { stdout } = await gitExecFileAsync( - ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], - gitExecOptions(repoPath, options) + return await capabilities.runWithFallback( + 'rev-parse-path-format', + async () => { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], + gitExecOptions(repoPath, options) + ) + if (hasUnsupportedRevParsePathFormatEcho(stdout)) { + // Why: some old Git versions echo the unknown option and exit zero; + // remember that compatibility signal even though parsing can recover. + capabilities.rememberUnsupported('rev-parse-path-format') + } + return parseRepoLocation(resolveBasePath, stdout) + }, + async () => { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--show-toplevel', '--git-common-dir'], + gitExecOptions(repoPath, options) + ) + return parseRepoLocation(resolveBasePath, stdout) + }, + isUnsupportedRevParsePathFormatError ) - return parseRepoLocation(resolveBasePath, stdout) - } catch (error) { - if (!isUnsupportedRevParsePathFormatError(error)) { - return undefined - } - } - - try { - const { stdout } = await gitExecFileAsync( - ['rev-parse', '--show-toplevel', '--git-common-dir'], - gitExecOptions(repoPath, options) - ) - return parseRepoLocation(resolveBasePath, stdout) } catch { return undefined } @@ -565,29 +565,34 @@ async function readWorktreeList( repoPath: string, options: GitWorktreeExecOptions = {} ): Promise { - try { - const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain', '-z'], { - cwd: repoPath, - ...options - }) - return normalizeMainWorktreePath( - repoPath, - parseWorktreeList(stdout, { nulDelimited: true }), - options - ) - } catch (error) { - if (!isUnsupportedWorktreeListZError(error)) { - throw error - } - } - - // Why: `-z` is required to preserve worktree paths containing newlines, but - // Git <2.36 rejects it. Keep the old parser as a compatibility fallback. - const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], { + const capabilities = getLocalGitCapabilityCache({ cwd: repoPath, - ...options + wslDistro: options.wslDistro }) - return normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout), options) + return capabilities.runWithFallback( + 'worktree-list-z', + async () => { + const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain', '-z'], { + cwd: repoPath, + ...options + }) + return normalizeMainWorktreePath( + repoPath, + parseWorktreeList(stdout, { nulDelimited: true }), + options + ) + }, + async () => { + // Why: `-z` is required to preserve worktree paths containing newlines, + // but Git <2.36 rejects it. Keep the line parser as the fallback. + const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], { + cwd: repoPath, + ...options + }) + return normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout), options) + }, + isUnsupportedWorktreeListZError + ) } async function readTranslatedWorktreeGraph( @@ -1210,7 +1215,14 @@ async function deleteAlreadyMergedBranchAfterSafeDeleteFailure( // Why: squash merges rewrite commit IDs, so `branch -d` can reject a branch // whose changes are already on the base ref. Delete only when Git can prove // the branch contributes no tree changes to that base. - if (!(await branchHasNoUnmergedChangesOnAnyTarget(runGit, branchName, targetRefs))) { + if ( + !(await branchHasNoUnmergedChangesOnAnyTarget( + runGit, + branchName, + targetRefs, + getLocalGitCapabilityCache({ cwd: repoPath, wslDistro: options.wslDistro }) + )) + ) { return false } await forceDeleteLocalBranch(repoPath, branchName, branchHead, (args, cwd) => diff --git a/src/main/github/conflict-summary.test.ts b/src/main/github/conflict-summary.test.ts index a9b5e9aee..506b1c9f9 100644 --- a/src/main/github/conflict-summary.test.ts +++ b/src/main/github/conflict-summary.test.ts @@ -328,4 +328,21 @@ describe('getPRConflictSummary caching', () => { await expect(deriveSummary()).resolves.toEqual(expectedSummary) expect(spawnCount('merge-base')).toBe(2) }) + + it('does not repeat merge-tree --write-tree after an old-Git rejection', async () => { + mockGitDispatch({ + 'merge-tree': () => + Promise.reject( + Object.assign(new Error('unknown option'), { + stdout: 'usage: git merge-tree ' + }) + ) + }) + + await expect(deriveSummary('head-oid-1')).resolves.toBeUndefined() + await expect(deriveSummary('head-oid-2')).resolves.toBeUndefined() + + expect(spawnCount('merge-base')).toBe(2) + expect(spawnCount('merge-tree')).toBe(1) + }) }) diff --git a/src/main/github/conflict-summary.ts b/src/main/github/conflict-summary.ts index 964455504..73ec65797 100644 --- a/src/main/github/conflict-summary.ts +++ b/src/main/github/conflict-summary.ts @@ -1,5 +1,13 @@ import type { PRConflictSummary } from '../../shared/types' +import { + isUnsupportedMergeTreeMergeBaseError, + isUnsupportedMergeTreeWriteTreeError +} from '../../shared/git-merge-tree-capability' import { gitExecFileAsync } from '../git/runner' +import { + clearGitCapabilityStateForTests, + getLocalGitCapabilityCache +} from '../git/git-capability-state' import { __resetPRConflictSummaryDerivationCachesForTests, buildConflictSummaryCacheKey, @@ -17,10 +25,8 @@ type LocalGitExecOptions = { wslDistro?: string } -const mergeTreeMergeBaseUnsupportedRuntimes = new Set() - export function __resetPRConflictSummaryCachesForTests(): void { - mergeTreeMergeBaseUnsupportedRuntimes.clear() + clearGitCapabilityStateForTests() __resetPRConflictSummaryDerivationCachesForTests() } @@ -213,7 +219,10 @@ async function loadConflictingFiles( baseOid: string, localGitOptions: LocalGitExecOptions ): Promise { - const capabilityKey = getConflictSummaryGitRuntimeKey(localGitOptions.wslDistro) + const capabilities = getLocalGitCapabilityCache({ + cwd: repoPath, + wslDistro: localGitOptions.wslDistro + }) const modernArgs = [ 'merge-tree', '--write-tree', @@ -235,32 +244,41 @@ async function loadConflictingFiles( baseOid ] - if (mergeTreeMergeBaseUnsupportedRuntimes.has(capabilityKey)) { - return loadConflictingFilesWithLegacyMergeTree(repoPath, legacyArgs, localGitOptions) - } - - try { - const result = await gitExecFileAsync(modernArgs, { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) - }) - return parseMergeTreeNameOnlyOutput(result.stdout) - } catch (error) { - // Why: `git merge-tree --write-tree` exits with status 1 when it finds - // conflicts, but still writes the conflicted file list to stdout. Treat - // that stdout as the useful result instead of dropping the summary. - const stdoutFromError = getGitErrorOutput(error, 'stdout') - if (stdoutFromError) { - return parseMergeTreeNameOnlyOutput(stdoutFromError) - } - - if (!isUnsupportedMergeBaseOption(error)) { - throw error - } - - mergeTreeMergeBaseUnsupportedRuntimes.add(capabilityKey) - return loadConflictingFilesWithLegacyMergeTree(repoPath, legacyArgs, localGitOptions) - } + return capabilities.runWithFallback( + 'merge-tree-write-tree', + () => + capabilities.runWithFallback( + 'merge-tree-merge-base', + async () => { + try { + const result = await gitExecFileAsync(modernArgs, { + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + }) + return parseMergeTreeNameOnlyOutput(result.stdout) + } catch (error) { + if (isUnsupportedMergeTreeWriteTreeError(error)) { + throw error + } + // Why: `git merge-tree --write-tree` exits 1 for conflicts but still + // writes the useful file list; only option rejection reaches fallback. + const stdoutFromError = getGitErrorOutput(error, 'stdout') + if (stdoutFromError) { + return parseMergeTreeNameOnlyOutput(stdoutFromError) + } + throw error + } + }, + () => loadConflictingFilesWithLegacyMergeTree(repoPath, legacyArgs, localGitOptions), + isUnsupportedMergeTreeMergeBaseError + ), + async () => { + // Why: Git before 2.38 cannot derive a reliable real-merge conflict list; + // fail closed without respawning the same rejected command every refresh. + throw new Error('Git merge-tree --write-tree is unavailable on this execution host.') + }, + isUnsupportedMergeTreeWriteTreeError + ) } async function loadConflictingFilesWithLegacyMergeTree( @@ -300,12 +318,3 @@ function getGitErrorOutput(error: unknown, key: 'stdout' | 'stderr'): string { const output = (error as Partial>)[key] return typeof output === 'string' ? output : '' } - -function isUnsupportedMergeBaseOption(error: unknown): boolean { - const output = `${getGitErrorOutput(error, 'stderr')}\n${ - error instanceof Error ? error.message : '' - }` - return /(?:unknown|unrecognized) option(?::|\s+)[`']?(?:--?)?merge-base[`']?(?:\s|$)/i.test( - output - ) -} diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index bd2541c7e..7a23522ac 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -12,6 +12,7 @@ import { join } from 'node:path' import type * as RepoModule from '../git/repo' import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' import { getGitRepoRoot, isGitRepo } from '../git/repo' +import { clearGitCapabilityStateForTests } from '../git/git-capability-state' const { handleMock, @@ -147,6 +148,10 @@ vi.mock('./ssh', () => ({ import { registerRepoHandlers } from './repos' +beforeEach(() => { + clearGitCapabilityStateForTests() +}) + describe('projectGroups IPC validation', () => { const handlers = new Map unknown>() const mockWindow = { @@ -2698,15 +2703,22 @@ describe('repos:searchBaseRefs SSH relay', () => { query: '', limit: 1 }) + const repeatedResult = await handlers.get('repos:searchBaseRefs')!(null, { + repoId: 'r1', + query: '', + limit: 1 + }) expect(result).toEqual(['origin/main']) + expect(repeatedResult).toEqual(['origin/main']) const forEachRefCalls = mockGitProvider.exec.mock.calls.filter( (call) => (call[0] as string[])[0] === 'for-each-ref' ) - expect(forEachRefCalls).toHaveLength(2) + expect(forEachRefCalls).toHaveLength(3) expect(forEachRefCalls[0][0]).toContain('--exclude=refs/remotes/**/HEAD') expect(forEachRefCalls[1][0]).not.toContain('--exclude=refs/remotes/**/HEAD') expect(forEachRefCalls[1][0]).toContain('--count=104') + expect(forEachRefCalls[2][0]).not.toContain('--exclude=refs/remotes/**/HEAD') }) it('sends the widened `**` argv so all remotes and slash-named branches are discoverable', async () => { diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index c5d1c8f2a..57225c63c 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -74,6 +74,7 @@ import { searchBaseRefDetails } from '../git/repo' import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import { getSshGitCapabilityCache } from '../git/git-capability-state' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { getSshGitUsername, resolveLocalGitUsername } from '../git/git-username' import { enrichRepoGitUsernames } from '../repo-git-username-enrichment' @@ -2548,32 +2549,33 @@ async function searchBaseRefDetailsForRepo( .split('\n') .map((line) => line.trim()) .filter(Boolean) + const capabilities = getSshGitCapabilityCache(provider) const runSearch = async (patternGroup?: 'segmented' | 'branchRoot'): Promise => { - try { - return ( - await provider.exec( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - remoteNames: remotes, - patternGroup - }), - repo.path - ) - ).stdout - } catch (err) { - if (!isForEachRefExcludeUnsupportedError(err)) { - throw err - } - return ( - await provider.exec( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - excludeRemoteHead: false, - remoteNames: remotes, - patternGroup - }), - repo.path - ) - ).stdout - } + return capabilities.runWithFallback( + 'for-each-ref-exclude', + async () => + ( + await provider.exec( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + remoteNames: remotes, + patternGroup + }), + repo.path + ) + ).stdout, + async () => + ( + await provider.exec( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + excludeRemoteHead: false, + remoteNames: remotes, + patternGroup + }), + repo.path + ) + ).stdout, + isForEachRefExcludeUnsupportedError + ) } // Why: delegate the NUL-parse + HEAD filter + dedup + limit pipeline // to the shared helper so the SSH and local paths cannot diverge. diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index c934a06a7..090251dc8 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -22258,19 +22258,22 @@ describe('OrcaRuntimeService', () => { const runtime = new OrcaRuntimeService(runtimeStore as never) const result = await runtime.searchRepoRefs('id:remote-repo', '', 1) + const repeatedResult = await runtime.searchRepoRefs('id:remote-repo', '', 1) expect(result).toEqual({ refs: ['origin/main'], refDetails: [{ refName: 'origin/main', localBranchName: 'main' }], truncated: true }) + expect(repeatedResult).toEqual(result) const forEachRefCalls = provider.exec.mock.calls.filter( (call) => (call[0] as string[])[0] === 'for-each-ref' ) - expect(forEachRefCalls).toHaveLength(2) + expect(forEachRefCalls).toHaveLength(3) expect(forEachRefCalls[0][0]).toContain('--exclude=refs/remotes/**/HEAD') expect(forEachRefCalls[1][0]).not.toContain('--exclude=refs/remotes/**/HEAD') expect(forEachRefCalls[1][0]).toContain('--count=108') + expect(forEachRefCalls[2][0]).not.toContain('--exclude=refs/remotes/**/HEAD') }) it('resolves SSH worktrees when manually updating lineage', async () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 789a50ce9..88a8f69b8 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -619,6 +619,7 @@ import { import { hasCommitObjectViaGitExec } from '../git/commit-object-ref' import { hasWorktreeBaseCommitRef } from '../git/worktree-base-ref-probe' import { resolveLocalGitUsername } from '../git/git-username' +import { getSshGitCapabilityCache } from '../git/git-capability-state' import { listWorktrees, listWorktreesStrict, @@ -11809,32 +11810,33 @@ export class OrcaRuntimeService { .split('\n') .map((line) => line.trim()) .filter(Boolean) + const capabilities = getSshGitCapabilityCache(provider) const runSearch = async (patternGroup?: 'segmented' | 'branchRoot'): Promise => { - try { - return ( - await provider.exec( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - remoteNames: remotes, - patternGroup - }), - repo.path - ) - ).stdout - } catch (err) { - if (!isForEachRefExcludeUnsupportedError(err)) { - throw err - } - return ( - await provider.exec( - buildSearchBaseRefsArgv(normalizedQuery, limit, { - excludeRemoteHead: false, - remoteNames: remotes, - patternGroup - }), - repo.path - ) - ).stdout - } + return capabilities.runWithFallback( + 'for-each-ref-exclude', + async () => + ( + await provider.exec( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + remoteNames: remotes, + patternGroup + }), + repo.path + ) + ).stdout, + async () => + ( + await provider.exec( + buildSearchBaseRefsArgv(normalizedQuery, limit, { + excludeRemoteHead: false, + remoteNames: remotes, + patternGroup + }), + repo.path + ) + ).stdout, + isForEachRefExcludeUnsupportedError + ) } const searchTokens = normalizedQuery.split('/').filter((token) => token.length > 0) if (searchTokens.length > 1) { diff --git a/src/relay/git-handler-branch-cleanup.test.ts b/src/relay/git-handler-branch-cleanup.test.ts index cf84b54d5..8058d55ae 100644 --- a/src/relay/git-handler-branch-cleanup.test.ts +++ b/src/relay/git-handler-branch-cleanup.test.ts @@ -1,9 +1,17 @@ import { describe, expect, it, vi } from 'vitest' import * as path from 'node:path' +import { GitCapabilityCache } from '../shared/git-capability-cache' import type { GitExec } from './git-handler-ops' import { removeWorktreeOp } from './git-handler-worktree-ops' import { forceDeletePreservedRelayBranch } from './git-handler-branch-cleanup' +function removeWorktreeWithCapabilityCache( + git: GitExec, + params: Parameters[1] +) { + return removeWorktreeOp(git, params, new GitCapabilityCache()) +} + function worktreeList(...entries: { path: string; branch?: string }[]): string { return entries .map((entry, index) => @@ -192,7 +200,9 @@ describe('removeWorktreeOp branch cleanup', () => { return { stdout: '', stderr: '' } }) - await expect(removeWorktreeOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({}) + await expect( + removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) + ).resolves.toEqual({}) expect(git).toHaveBeenCalledWith(['branch', '-d', '--', 'feature/test'], expect.any(String)) expect(git).toHaveBeenCalledWith( @@ -279,7 +289,9 @@ describe('removeWorktreeOp branch cleanup', () => { return { stdout: '', stderr: '' } }) - await expect(removeWorktreeOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({}) + await expect( + removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) + ).resolves.toEqual({}) expect(git).toHaveBeenCalledWith( ['update-ref', '-d', 'refs/heads/feature/test', '1'], @@ -341,7 +353,9 @@ describe('removeWorktreeOp branch cleanup', () => { return { stdout: '', stderr: '' } }) - await expect(removeWorktreeOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({}) + await expect( + removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) + ).resolves.toEqual({}) const commandIndex = (expectedArgs: string[]) => calls.findIndex(({ args }) => JSON.stringify(args) === JSON.stringify(expectedArgs)) @@ -404,7 +418,9 @@ describe('removeWorktreeOp branch cleanup', () => { return { stdout: '', stderr: '' } }) - await expect(removeWorktreeOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({ + await expect( + removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) + ).resolves.toEqual({ preservedBranch: { branchName: 'feature/test', head: '1' } }) diff --git a/src/relay/git-handler-branch-cleanup.ts b/src/relay/git-handler-branch-cleanup.ts index 1454334db..51aa0be52 100644 --- a/src/relay/git-handler-branch-cleanup.ts +++ b/src/relay/git-handler-branch-cleanup.ts @@ -3,6 +3,7 @@ import { getBranchCleanupTargetRefs, refreshBranchCleanupTargetRefs } from '../shared/git-branch-cleanup' +import type { GitCapabilityCache } from '../shared/git-capability-cache' import type { GitExec } from './git-handler-ops' import { parseWorktreeList } from './git-handler-utils' @@ -10,7 +11,8 @@ export async function deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure( git: GitExec, repoPath: string, branchName: string, - branchHead: string + branchHead: string, + capabilities: GitCapabilityCache ): Promise { const runGit = (args: string[], options?: { stdin?: string }) => options ? git(args, repoPath, options) : git(args, repoPath) @@ -19,7 +21,9 @@ export async function deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure( // Why: SSH worktrees hit the same squash-merge shape as local worktrees. // Git's no-op merge proof lets us clean up only branches whose changes // already exist on the saved base ref. - if (!(await branchHasNoUnmergedChangesOnAnyTarget(runGit, branchName, targetRefs))) { + if ( + !(await branchHasNoUnmergedChangesOnAnyTarget(runGit, branchName, targetRefs, capabilities)) + ) { return false } await deleteRelayBranchAtExpectedHead(git, repoPath, branchName, branchHead) diff --git a/src/relay/git-handler-local-base-ref-refresh.ts b/src/relay/git-handler-local-base-ref-refresh.ts index 2ff1967a7..299780799 100644 --- a/src/relay/git-handler-local-base-ref-refresh.ts +++ b/src/relay/git-handler-local-base-ref-refresh.ts @@ -1,9 +1,11 @@ import type { GitExec } from './git-handler-ops' import { areRelayWorktreePathsEqual, readRelayWorktreeList } from './git-handler-worktree-ops' +import type { GitCapabilityCache } from '../shared/git-capability-cache' export async function refreshLocalBaseRefForWorktreeCreateOp( git: GitExec, - params: Record + params: Record, + capabilities: GitCapabilityCache ): Promise { const repoPath = params.repoPath as string const fullRef = params.fullRef as string @@ -42,7 +44,7 @@ export async function refreshLocalBaseRefForWorktreeCreateOp( throw new Error('Local base ref is not a fast-forward update.') } - const worktrees = await readRelayWorktreeList(git, repoPath) + const worktrees = await readRelayWorktreeList(git, repoPath, capabilities) const ownerWorktree = worktrees.find((worktree) => worktree.branch === fullRef) if (ownerWorktree) { if (ownerWorktreePath && !areRelayWorktreePathsEqual(ownerWorktree.path, ownerWorktreePath)) { diff --git a/src/relay/git-handler-utils.ts b/src/relay/git-handler-utils.ts index 744f4b65d..6fbb8ec4a 100644 --- a/src/relay/git-handler-utils.ts +++ b/src/relay/git-handler-utils.ts @@ -10,6 +10,7 @@ import * as path from 'node:path' import { decodeGitCQuotedPath } from '../shared/git-cquoted-path' import { isBinaryBuffer } from '../shared/binary-buffer' import type { GitLineStats } from '../shared/git-uncommitted-line-stats' +export { isUnsupportedWorktreeListZError } from '../shared/git-worktree-command-capabilities' export function parseBranchStatusChar(char: string): string { switch (char) { @@ -134,36 +135,6 @@ export function parseBranchDiff( // ─── Worktree parsing ──────────────────────────────────────────────── -function getErrorText(error: unknown): string { - if (typeof error === 'object' && error !== null) { - const parts: string[] = [] - if ('message' in error && typeof error.message === 'string') { - parts.push(error.message) - } - if ('stderr' in error && typeof error.stderr === 'string') { - parts.push(error.stderr) - } - return parts.join('\n') - } - return String(error) -} - -function getErrorCode(error: unknown): string | undefined { - return typeof error === 'object' && error !== null && 'code' in error - ? String((error as { code?: unknown }).code) - : undefined -} - -export function isUnsupportedWorktreeListZError(error: unknown): boolean { - // `-z` is this command's only flag older Git (<2.36) lacks, so its usage exit - // 129 signals the rejection in any locale; key for SSH remotes on old Git. - if (getErrorCode(error) === '129') { - return true - } - - return /(?:unknown|invalid|unrecognized) (?:switch|option).*`?-?z'?/i.test(getErrorText(error)) -} - export function parseWorktreeList( output: string, options: { nulDelimited?: boolean } = {} diff --git a/src/relay/git-handler-worktree-git-capabilities.test.ts b/src/relay/git-handler-worktree-git-capabilities.test.ts new file mode 100644 index 000000000..6d58a9993 --- /dev/null +++ b/src/relay/git-handler-worktree-git-capabilities.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RelayContext } from './context' +import { GitHandler } from './git-handler' +import { + createMockDispatcher, + type MockDispatcher, + type RelayDispatcher +} from './git-handler-test-setup' + +type GitSpyTarget = { + git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> +} + +const WORKTREE_LIST_OUTPUT = `worktree /repo +HEAD abc123 +branch refs/heads/main +` + +describe('relay worktree Git capabilities', () => { + let dispatcher: MockDispatcher + let handler: GitHandler + + beforeEach(() => { + dispatcher = createMockDispatcher() + handler = new GitHandler(dispatcher as unknown as RelayDispatcher, new RelayContext()) + }) + + it('does not repeat a known-unsupported -z probe on later scans', async () => { + const gitSpy = vi + .spyOn(handler as unknown as GitSpyTarget, 'git') + .mockImplementation((args: string[]) => { + if (args.includes('-z')) { + return Promise.reject( + Object.assign(new Error('git usage error'), { + code: 129, + stderr: 'usage: git worktree list []\n' + }) + ) + } + return Promise.resolve({ stdout: WORKTREE_LIST_OUTPUT, stderr: '' }) + }) + + await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) + await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) + + expect(gitSpy.mock.calls.map(([args]) => args)).toEqual([ + ['worktree', 'list', '--porcelain', '-z'], + ['worktree', 'list', '--porcelain'], + ['worktree', 'list', '--porcelain'] + ]) + }) + + it('re-probes after a relay handler is replaced', async () => { + const mockOldGit = (target: GitHandler) => + vi.spyOn(target as unknown as GitSpyTarget, 'git').mockImplementation((args: string[]) => { + if (args.includes('-z')) { + return Promise.reject( + Object.assign(new Error('git usage error'), { + code: 129, + stderr: 'usage: git worktree list []\n' + }) + ) + } + return Promise.resolve({ stdout: WORKTREE_LIST_OUTPUT, stderr: '' }) + }) + const firstGit = mockOldGit(handler) + const replacementDispatcher = createMockDispatcher() + const replacementHandler = new GitHandler( + replacementDispatcher as unknown as RelayDispatcher, + new RelayContext() + ) + const replacementGit = mockOldGit(replacementHandler) + + await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) + await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) + await replacementDispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) + + expect(firstGit.mock.calls.filter(([args]) => args.includes('-z'))).toHaveLength(1) + expect(replacementGit.mock.calls.filter(([args]) => args.includes('-z'))).toHaveLength(1) + }) + + it('does not repeat a known-unsupported rev-parse --path-format probe', async () => { + const gitSpy = vi + .spyOn(handler as unknown as GitSpyTarget, 'git') + .mockImplementation((args: string[]) => { + if (args[0] === 'worktree') { + return Promise.resolve({ + stdout: 'worktree /git-store/project.git\nHEAD abc123\nbranch refs/heads/main\n', + stderr: '' + }) + } + if (args.includes('--path-format=absolute')) { + return Promise.reject( + Object.assign(new Error('unknown option: --path-format=absolute'), { + stderr: 'error: unknown option `path-format=absolute`\n' + }) + ) + } + return Promise.resolve({ + stdout: '/repo\n/git-store/project.git\n', + stderr: '' + }) + }) + + await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) + await dispatcher.callRequest('git.listWorktrees', { repoPath: '/repo' }) + + const revParseCalls = gitSpy.mock.calls.filter(([args]) => args[0] === 'rev-parse') + expect(revParseCalls.map(([args]) => args)).toEqual([ + ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], + ['rev-parse', '--show-toplevel', '--git-common-dir'], + ['rev-parse', '--show-toplevel', '--git-common-dir'] + ]) + }) +}) diff --git a/src/relay/git-handler-worktree-list.ts b/src/relay/git-handler-worktree-list.ts new file mode 100644 index 000000000..72bec899a --- /dev/null +++ b/src/relay/git-handler-worktree-list.ts @@ -0,0 +1,43 @@ +import type { GitCapabilityCache } from '../shared/git-capability-cache' +import type { GitExec } from './git-handler-ops' +import { isUnsupportedWorktreeListZError, parseWorktreeList } from './git-handler-utils' + +export type RelayWorktreeInfo = { + path: string + branch?: string + head?: string + locked?: boolean + lockReason?: string +} + +export async function readRelayWorktreeList( + git: GitExec, + repoPath: string, + capabilities: GitCapabilityCache +): Promise { + return capabilities.runWithFallback( + 'worktree-list-z', + async () => { + const { stdout } = await git(['worktree', 'list', '--porcelain', '-z'], repoPath) + return normalizeRelayWorktrees(parseWorktreeList(stdout, { nulDelimited: true })) + }, + async () => { + // Why: `-z` preserves newlines; fallback keeps Git <2.36 compatible. + const { stdout } = await git(['worktree', 'list', '--porcelain'], repoPath) + return normalizeRelayWorktrees(parseWorktreeList(stdout)) + }, + isUnsupportedWorktreeListZError + ) +} + +function normalizeRelayWorktrees(worktrees: Record[]): RelayWorktreeInfo[] { + return worktrees + .map((worktree) => ({ + path: typeof worktree.path === 'string' ? worktree.path : '', + head: typeof worktree.head === 'string' ? worktree.head : undefined, + branch: typeof worktree.branch === 'string' ? worktree.branch : undefined, + locked: worktree.locked === true ? true : undefined, + lockReason: typeof worktree.lockReason === 'string' ? worktree.lockReason : undefined + })) + .filter((worktree) => worktree.path.length > 0) +} diff --git a/src/relay/git-handler-worktree-ops.test.ts b/src/relay/git-handler-worktree-ops.test.ts index e16d4d61f..ed252df6a 100644 --- a/src/relay/git-handler-worktree-ops.test.ts +++ b/src/relay/git-handler-worktree-ops.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it, vi } from 'vitest' import * as path from 'node:path' +import { GitCapabilityCache } from '../shared/git-capability-cache' import type { GitExec } from './git-handler-ops' import { addWorktreeOp, removeWorktreeOp } from './git-handler-worktree-ops' +function removeWorktreeWithCapabilityCache( + git: GitExec, + params: Parameters[1] +) { + return removeWorktreeOp(git, params, new GitCapabilityCache()) +} + function worktreeList(...entries: { path: string; branch?: string }[]): string { return entries .map((entry, index) => @@ -145,9 +153,9 @@ describe('removeWorktreeOp', () => { return { stdout: '', stderr: '' } }) - await expect(removeWorktreeOp(git, { worktreePath: '/repo-feature' })).rejects.toThrow( - 'Worktree is locked by Git. Lock reason: remote agent.' - ) + await expect( + removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) + ).rejects.toThrow('Worktree is locked by Git. Lock reason: remote agent.') expect(git).not.toHaveBeenCalledWith( ['worktree', 'remove', '/repo-feature'], expect.any(String) @@ -178,7 +186,7 @@ describe('removeWorktreeOp', () => { return { stdout: '', stderr: '' } }) - await removeWorktreeOp(git, { worktreePath: '/repo-feature' }) + await removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) expect(calls).toEqual([ '/repo-feature$ rev-parse --git-common-dir', @@ -214,7 +222,9 @@ describe('removeWorktreeOp', () => { }) // The unmerged-branch refusal must be surfaced without failing workspace removal. - await expect(removeWorktreeOp(git, { worktreePath: '/repo-feature' })).resolves.toEqual({ + await expect( + removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) + ).resolves.toEqual({ preservedBranch: { branchName: 'feature/test', head: '1' } }) @@ -244,7 +254,7 @@ describe('removeWorktreeOp', () => { return { stdout: '', stderr: '' } }) - await removeWorktreeOp(git, { + await removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature', force: true, forceBranchDelete: true @@ -276,7 +286,7 @@ describe('removeWorktreeOp', () => { }) await expect( - removeWorktreeOp(git, { worktreePath: '/repo-feature', force: true }) + removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature', force: true }) ).rejects.toThrow('Worktree is locked by Git. Lock reason: remote agent') expect(git).not.toHaveBeenCalledWith( @@ -304,7 +314,10 @@ describe('removeWorktreeOp', () => { return { stdout: '', stderr: '' } }) - await removeWorktreeOp(git, { worktreePath: '/repo-feature', deleteBranch: false }) + await removeWorktreeWithCapabilityCache(git, { + worktreePath: '/repo-feature', + deleteBranch: false + }) expect(calls).toEqual([ '/repo-feature$ rev-parse --git-common-dir', @@ -343,7 +356,7 @@ describe('removeWorktreeOp', () => { return { stdout: '', stderr: '' } }) - await removeWorktreeOp(git, { worktreePath: '/repo-feature' }) + await removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) expect(git).toHaveBeenCalledWith(['branch', '-d', '--', 'feature/test'], expect.any(String)) expect(git).toHaveBeenCalledWith(['worktree', 'prune'], expect.any(String)) diff --git a/src/relay/git-handler-worktree-ops.ts b/src/relay/git-handler-worktree-ops.ts index a1fc31944..487fabd76 100644 --- a/src/relay/git-handler-worktree-ops.ts +++ b/src/relay/git-handler-worktree-ops.ts @@ -1,8 +1,8 @@ import * as path from 'node:path' import { resolveWorktreeAddBaseRef } from '../shared/worktree-base-ref' import type { GitExec } from './git-handler-ops' -import { isUnsupportedWorktreeListZError, parseWorktreeList } from './git-handler-utils' export { removeWorktreeOp } from './git-handler-worktree-remove' +export { readRelayWorktreeList } from './git-handler-worktree-list' async function persistRelayWorktreeCreationBase( git: GitExec, @@ -111,40 +111,6 @@ export async function addWorktreeOp(git: GitExec, params: Record { - try { - const { stdout } = await git(['worktree', 'list', '--porcelain', '-z'], repoPath) - return normalizeRelayWorktrees(parseWorktreeList(stdout, { nulDelimited: true })) - } catch (error) { - if (!isUnsupportedWorktreeListZError(error)) { - throw error - } - } - - // Why: `-z` preserves newlines; fallback keeps Git <2.36 compatible. - const { stdout } = await git(['worktree', 'list', '--porcelain'], repoPath) - return normalizeRelayWorktrees(parseWorktreeList(stdout)) -} - -function normalizeRelayWorktrees(worktrees: Record[]): RelayWorktreeInfo[] { - return worktrees - .map((worktree) => ({ - path: typeof worktree.path === 'string' ? worktree.path : '', - head: typeof worktree.head === 'string' ? worktree.head : undefined, - branch: typeof worktree.branch === 'string' ? worktree.branch : undefined - })) - .filter((worktree) => worktree.path.length > 0) -} - function isPosixAbsolutePath(value: string): boolean { return value.startsWith('/') } diff --git a/src/relay/git-handler-worktree-paths.test.ts b/src/relay/git-handler-worktree-paths.test.ts index 7bdb08265..d5f7a7755 100644 --- a/src/relay/git-handler-worktree-paths.test.ts +++ b/src/relay/git-handler-worktree-paths.test.ts @@ -1,8 +1,16 @@ import { describe, expect, it, vi } from 'vitest' import * as path from 'node:path' +import { GitCapabilityCache } from '../shared/git-capability-cache' import type { GitExec } from './git-handler-ops' import { removeWorktreeOp } from './git-handler-worktree-ops' +function removeWorktreeWithCapabilityCache( + git: GitExec, + params: Parameters[1] +) { + return removeWorktreeOp(git, params, new GitCapabilityCache()) +} + function lineWorktreeList(...entries: { path: string; branch?: string }[]): string { return entries .map((entry, index) => @@ -56,7 +64,7 @@ describe('relay worktree path parsing', () => { return { stdout: '', stderr: '' } }) - await removeWorktreeOp(git, { worktreePath }) + await removeWorktreeWithCapabilityCache(git, { worktreePath }) expect(git).toHaveBeenCalledWith(['branch', '-d', '--', 'feature/newline'], resolvedRepoPath()) }) @@ -90,7 +98,7 @@ describe('relay worktree path parsing', () => { return { stdout: '', stderr: '' } }) - await removeWorktreeOp(git, { worktreePath: '/repo-feature' }) + await removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) expect(calls).toEqual([ '/repo-feature$ rev-parse --git-common-dir', diff --git a/src/relay/git-handler-worktree-remove.ts b/src/relay/git-handler-worktree-remove.ts index 7c426facc..2c2ce54a8 100644 --- a/src/relay/git-handler-worktree-remove.ts +++ b/src/relay/git-handler-worktree-remove.ts @@ -3,15 +3,8 @@ import type { RemoveWorktreeResult } from '../shared/types' import { assertWorktreeUnlockedForRemoval } from '../shared/worktree-removal' import { deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure } from './git-handler-branch-cleanup' import type { GitExec } from './git-handler-ops' -import { isUnsupportedWorktreeListZError, parseWorktreeList } from './git-handler-utils' - -type RelayWorktreeInfo = { - path: string - branch?: string - head?: string - locked?: boolean - lockReason?: string -} +import type { GitCapabilityCache } from '../shared/git-capability-cache' +import { readRelayWorktreeList } from './git-handler-worktree-list' function getErrorText(error: unknown): string { if (typeof error === 'object' && error !== null) { @@ -77,36 +70,13 @@ function areRelayWorktreePathsEqual(leftPath: string, rightPath: string): boolea return compareCaseInsensitive ? left.toLowerCase() === right.toLowerCase() : left === right } -function normalizeRelayWorktrees(worktrees: Record[]): RelayWorktreeInfo[] { - return worktrees - .map((worktree) => ({ - path: typeof worktree.path === 'string' ? worktree.path : '', - head: typeof worktree.head === 'string' ? worktree.head : undefined, - branch: typeof worktree.branch === 'string' ? worktree.branch : undefined, - locked: worktree.locked === true ? true : undefined, - lockReason: typeof worktree.lockReason === 'string' ? worktree.lockReason : undefined - })) - .filter((worktree) => worktree.path.length > 0) -} - -async function readRelayWorktreeList(git: GitExec, repoPath: string): Promise { +async function listRelayWorktreesForRemoval( + git: GitExec, + repoPath: string, + capabilities: GitCapabilityCache +) { try { - const { stdout } = await git(['worktree', 'list', '--porcelain', '-z'], repoPath) - return normalizeRelayWorktrees(parseWorktreeList(stdout, { nulDelimited: true })) - } catch (error) { - if (!isUnsupportedWorktreeListZError(error)) { - throw error - } - } - - // Why: `-z` preserves newlines; fallback keeps Git <2.36 compatible. - const { stdout } = await git(['worktree', 'list', '--porcelain'], repoPath) - return normalizeRelayWorktrees(parseWorktreeList(stdout)) -} - -async function listRelayWorktreesForRemoval(git: GitExec, repoPath: string) { - try { - return await readRelayWorktreeList(git, repoPath) + return await readRelayWorktreeList(git, repoPath, capabilities) } catch { return [] } @@ -153,7 +123,8 @@ async function deleteRelayBranchAfterWorktreeRemoval( export async function removeWorktreeOp( git: GitExec, - params: Record + params: Record, + capabilities: GitCapabilityCache ): Promise { const worktreePath = params.worktreePath as string const force = params.force as boolean | undefined @@ -171,7 +142,7 @@ export async function removeWorktreeOp( // fall through with worktreePath as repo } - const worktreesBeforeRemoval = await listRelayWorktreesForRemoval(git, repoPath) + const worktreesBeforeRemoval = await listRelayWorktreesForRemoval(git, repoPath, capabilities) const removedWorktree = worktreesBeforeRemoval.find((worktree) => areRelayWorktreePathsEqual(worktree.path, worktreePath) ) @@ -217,7 +188,8 @@ export async function removeWorktreeOp( git, repoPath, branchName, - branchHead + branchHead, + capabilities ) ) { return {} diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 3bdeb860c..533c06f27 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -61,6 +61,11 @@ import { getGitCloneFailureMessage } from '../shared/git-clone-failure-message' import { syncForkDefaultBranch, validateGitForkSyncExpectedUpstream } from '../shared/git-fork-sync' import { InFlightPromiseDedupe, stableInFlightKey } from '../shared/in-flight-promise-dedupe' import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../shared/git-fetch-auto-maintenance' +import { GitCapabilityCache } from '../shared/git-capability-cache' +import { + hasUnsupportedRevParsePathFormatEcho, + isUnsupportedRevParsePathFormatError +} from '../shared/git-worktree-command-capabilities' import { GitResponseStreamRegistry } from './git-response-stream' import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol' @@ -77,26 +82,6 @@ function resolveSubmoduleStatusArea( return 'unstaged' } -function getErrorText(error: unknown): string { - if (typeof error === 'object' && error !== null) { - const parts: string[] = [] - if ('message' in error && typeof error.message === 'string') { - parts.push(error.message) - } - if ('stderr' in error && typeof error.stderr === 'string') { - parts.push(error.stderr) - } - return parts.join('\n') - } - return String(error) -} - -function isUnsupportedRevParsePathFormatError(error: unknown): boolean { - return /(?:unknown|invalid|unrecognized).*(?:--path-format|path-format)/i.test( - getErrorText(error) - ) -} - function isWindowsAbsolutePath(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') } @@ -173,6 +158,7 @@ function execFileWithStdin( export class GitHandler { private dispatcher: RelayDispatcher private readonly gitDiffReadDedupe = new InFlightPromiseDedupe() + private readonly gitCapabilities = new GitCapabilityCache() // Why: large diff/exec responses are chunked onto the bulk lane so they do // not head-of-line-block interactive pty.data echo on the shared SSH channel. private readonly responseStreams = new GitResponseStreamRegistry() @@ -1277,23 +1263,29 @@ export class GitHandler { private async readRepoLocation(repoPath: string): Promise { try { - const { stdout } = await this.git( - ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], - repoPath + return await this.gitCapabilities.runWithFallback( + 'rev-parse-path-format', + async () => { + const { stdout } = await this.git( + ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], + repoPath + ) + if (hasUnsupportedRevParsePathFormatEcho(stdout)) { + // Why: some old Git versions echo the unknown option and exit zero; + // remember that signal even though the trailing paths remain usable. + this.gitCapabilities.rememberUnsupported('rev-parse-path-format') + } + return parseRelayRepoLocation(repoPath, stdout) + }, + async () => { + const { stdout } = await this.git( + ['rev-parse', '--show-toplevel', '--git-common-dir'], + repoPath + ) + return parseRelayRepoLocation(repoPath, stdout) + }, + isUnsupportedRevParsePathFormatError ) - return parseRelayRepoLocation(repoPath, stdout) - } catch (error) { - if (!isUnsupportedRevParsePathFormatError(error)) { - return undefined - } - } - - try { - const { stdout } = await this.git( - ['rev-parse', '--show-toplevel', '--git-common-dir'], - repoPath - ) - return parseRelayRepoLocation(repoPath, stdout) } catch { return undefined } @@ -1333,30 +1325,33 @@ export class GitHandler { private async listWorktrees(params: Record, context?: RequestContext) { const repoPath = params.repoPath as string - try { - const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath, { - signal: context?.signal - }) - return this.normalizeMainWorktreePath( - repoPath, - parseWorktreeList(stdout, { nulDelimited: true }) + return this.gitCapabilities + .runWithFallback( + 'worktree-list-z', + async () => { + const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath, { + signal: context?.signal + }) + return this.normalizeMainWorktreePath( + repoPath, + parseWorktreeList(stdout, { nulDelimited: true }) + ) + }, + async () => { + // Why: `-z` keeps newline-containing SSH worktree paths intact, but + // Git <2.36 requires the line-block parser. + try { + const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, { + signal: context?.signal + }) + return this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout)) + } catch { + return [] + } + }, + isUnsupportedWorktreeListZError ) - } catch (error) { - if (!isUnsupportedWorktreeListZError(error)) { - return [] - } - } - - // Why: `-z` keeps newline-containing SSH worktree paths intact, but older - // Git rejects it. Fall back to the original line-block parser there. - try { - const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, { - signal: context?.signal - }) - return this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout)) - } catch { - return [] - } + .catch(() => []) } private async addWorktree(params: Record) { @@ -1364,7 +1359,9 @@ export class GitHandler { } private async removeWorktree(params: Record) { - return this.runWithDiffDedupeClear(() => removeWorktreeOp(this.git.bind(this), params)) + return this.runWithDiffDedupeClear(() => + removeWorktreeOp(this.git.bind(this), params, this.gitCapabilities) + ) } private async worktreeIsClean(params: Record) { @@ -1373,7 +1370,7 @@ export class GitHandler { private async refreshLocalBaseRefForWorktreeCreate(params: Record) { return this.runWithDiffDedupeClear(() => - refreshLocalBaseRefForWorktreeCreateOp(this.git.bind(this), params) + refreshLocalBaseRefForWorktreeCreateOp(this.git.bind(this), params, this.gitCapabilities) ) } } diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts new file mode 100644 index 000000000..fdaaa7f69 --- /dev/null +++ b/src/shared/git-binary-compatibility.test.ts @@ -0,0 +1,145 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + isUnsupportedMergeTreeMergeBaseError, + isUnsupportedMergeTreeWriteTreeError +} from './git-merge-tree-capability' +import { isForEachRefExcludeUnsupportedError } from './git-ref-command-capabilities' +import { + hasUnsupportedRevParsePathFormatEcho, + isUnsupportedWorktreeListZError +} from './git-worktree-command-capabilities' + +const execFileAsync = promisify(execFile) +const image = process.env.ORCA_GIT_COMPAT_IMAGE +const binary = process.env.ORCA_GIT_COMPAT_BINARY +const expectedVersion = process.env.ORCA_GIT_COMPAT_VERSION +const describeBinaryCompatibility = image || binary ? describe : describe.skip + +type GitResult = { stdout: string; stderr: string } + +describeBinaryCompatibility('real Git binary compatibility', () => { + let repoPath = '' + let version = { major: 0, minor: 0 } + + async function runGit(args: string[]): Promise { + if (image) { + const dockerUser = + typeof process.getuid === 'function' && typeof process.getgid === 'function' + ? ['--user', `${process.getuid()}:${process.getgid()}`] + : [] + return execFileAsync( + 'docker', + [ + 'run', + '--rm', + '--network=none', + ...dockerUser, + '-v', + `${repoPath}:/repo`, + '-w', + '/repo', + image, + '-c', + 'safe.directory=/repo', + ...args + ], + { maxBuffer: 2 * 1024 * 1024 } + ) + } + return execFileAsync(binary!, args, { cwd: repoPath, maxBuffer: 2 * 1024 * 1024 }) + } + + function supports(major: number, minor: number): boolean { + return version.major > major || (version.major === major && version.minor >= minor) + } + + async function expectPreferredOrRecognizedFallback( + args: string[], + expectedSupport: boolean, + recognizesUnsupported: (error: unknown) => boolean + ): Promise { + try { + await runGit(args) + expect(expectedSupport).toBe(true) + } catch (error) { + expect(expectedSupport).toBe(false) + expect(recognizesUnsupported(error)).toBe(true) + } + } + + beforeAll(async () => { + repoPath = await mkdtemp(join(tmpdir(), 'orca-git-binary-compat-')) + const versionOutput = await runGit(['--version']) + expect(versionOutput.stdout).toContain(`git version ${expectedVersion}`) + const match = versionOutput.stdout.match(/git version (\d+)\.(\d+)/) + expect(match).not.toBeNull() + version = { major: Number(match![1]), minor: Number(match![2]) } + + await runGit(['init', '-q']) + await runGit(['config', 'user.email', 'compatibility@example.invalid']) + await runGit(['config', 'user.name', 'Compatibility Test']) + await writeFile(join(repoPath, 'tracked.txt'), 'compatibility\n') + await runGit(['add', 'tracked.txt']) + await runGit(['commit', '-qm', 'initial']) + }) + + afterAll(async () => { + if (repoPath) { + await rm(repoPath, { recursive: true, force: true }) + } + }) + + it('recognizes worktree-list and rev-parse compatibility boundaries', async () => { + await expectPreferredOrRecognizedFallback( + ['worktree', 'list', '--porcelain', '-z'], + supports(2, 36), + isUnsupportedWorktreeListZError + ) + await expect(runGit(['worktree', 'list', '--porcelain'])).resolves.toMatchObject({ + stdout: expect.stringContaining('worktree ') + }) + + const preferred = await runGit([ + 'rev-parse', + '--path-format=absolute', + '--show-toplevel', + '--git-common-dir' + ]) + expect(hasUnsupportedRevParsePathFormatEcho(preferred.stdout)).toBe(!supports(2, 31)) + await expect( + runGit(['rev-parse', '--show-toplevel', '--git-common-dir']) + ).resolves.toBeDefined() + }) + + it('recognizes ref and merge-tree compatibility boundaries', async () => { + await expectPreferredOrRecognizedFallback( + ['for-each-ref', '--format=%(refname)', '--exclude=refs/remotes/**/HEAD', '--count=10'], + supports(2, 42), + isForEachRefExcludeUnsupportedError + ) + await expect( + runGit(['for-each-ref', '--format=%(refname)', '--count=10']) + ).resolves.toBeDefined() + + await expectPreferredOrRecognizedFallback( + ['merge-tree', '--write-tree', 'HEAD', 'HEAD'], + supports(2, 38), + isUnsupportedMergeTreeWriteTreeError + ) + if (supports(2, 38)) { + const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim() + const legacyArgs = ['merge-tree', '--write-tree', '--name-only', '-z', '--no-messages'] + await expectPreferredOrRecognizedFallback( + [...legacyArgs, '--merge-base', head, head, head], + supports(2, 40), + isUnsupportedMergeTreeMergeBaseError + ) + await expect(runGit([...legacyArgs, head, head])).resolves.toBeDefined() + } + }) +}) diff --git a/src/shared/git-branch-cleanup.test.ts b/src/shared/git-branch-cleanup.test.ts index 6728bf897..5c25089d5 100644 --- a/src/shared/git-branch-cleanup.test.ts +++ b/src/shared/git-branch-cleanup.test.ts @@ -4,6 +4,7 @@ import { refreshBranchCleanupTargetRefs, type GitBranchCleanupExec } from './git-branch-cleanup' +import { GitCapabilityCache } from './git-capability-cache' function baseProofResponses( responses: Partial> = {} @@ -94,7 +95,12 @@ describe('branchHasNoUnmergedChangesOnAnyTarget', () => { const runGit = baseProofResponses() await expect( - branchHasNoUnmergedChangesOnAnyTarget(runGit, 'feature/test', ['refs/remotes/origin/main']) + branchHasNoUnmergedChangesOnAnyTarget( + runGit, + 'feature/test', + ['refs/remotes/origin/main'], + new GitCapabilityCache() + ) ).resolves.toBe(true) expect(runGit).toHaveBeenCalledWith(['patch-id', '--stable'], { stdin: 'branch-diff' }) @@ -106,7 +112,12 @@ describe('branchHasNoUnmergedChangesOnAnyTarget', () => { const runGit = baseProofResponses({ squashPatchId: 'other-patch squash\n' }) await expect( - branchHasNoUnmergedChangesOnAnyTarget(runGit, 'feature/test', ['refs/remotes/origin/main']) + branchHasNoUnmergedChangesOnAnyTarget( + runGit, + 'feature/test', + ['refs/remotes/origin/main'], + new GitCapabilityCache() + ) ).resolves.toBe(false) }) @@ -116,7 +127,12 @@ describe('branchHasNoUnmergedChangesOnAnyTarget', () => { }) await expect( - branchHasNoUnmergedChangesOnAnyTarget(runGit, 'feature/test', ['refs/remotes/origin/main']) + branchHasNoUnmergedChangesOnAnyTarget( + runGit, + 'feature/test', + ['refs/remotes/origin/main'], + new GitCapabilityCache() + ) ).resolves.toBe(false) }) @@ -127,7 +143,12 @@ describe('branchHasNoUnmergedChangesOnAnyTarget', () => { }) await expect( - branchHasNoUnmergedChangesOnAnyTarget(runGit, 'feature/test', ['refs/remotes/origin/main']) + branchHasNoUnmergedChangesOnAnyTarget( + runGit, + 'feature/test', + ['refs/remotes/origin/main'], + new GitCapabilityCache() + ) ).resolves.toBe(false) expect(runGit).not.toHaveBeenCalledWith(['show', '--format=', 'commit-0']) @@ -137,7 +158,40 @@ describe('branchHasNoUnmergedChangesOnAnyTarget', () => { const runGit = baseProofResponses({ branchPatchId: new Error('patch-id failed') }) await expect( - branchHasNoUnmergedChangesOnAnyTarget(runGit, 'feature/test', ['refs/remotes/origin/main']) + branchHasNoUnmergedChangesOnAnyTarget( + runGit, + 'feature/test', + ['refs/remotes/origin/main'], + new GitCapabilityCache() + ) ).resolves.toBe(false) }) + + it('does not repeat a rejected merge-tree --write-tree proof on old Git', async () => { + const unsupported = Object.assign(new Error('unknown option'), { + stderr: 'fatal: unknown rev --write-tree' + }) + const runGit = baseProofResponses({ + 'merge-tree --write-tree target refs/heads/feature/test': unsupported, + 'rev-list --right-only --merges --count target...refs/heads/feature/test': '0\n', + 'cherry -v target refs/heads/feature/test': '+ branch-only commit\n' + }) + const capabilities = new GitCapabilityCache() + + await branchHasNoUnmergedChangesOnAnyTarget( + runGit, + 'feature/test', + ['refs/remotes/origin/main'], + capabilities + ) + await branchHasNoUnmergedChangesOnAnyTarget( + runGit, + 'feature/test', + ['refs/remotes/origin/main'], + capabilities + ) + + const mergeTreeCalls = vi.mocked(runGit).mock.calls.filter(([args]) => args[0] === 'merge-tree') + expect(mergeTreeCalls).toHaveLength(1) + }) }) diff --git a/src/shared/git-branch-cleanup.ts b/src/shared/git-branch-cleanup.ts index 3e177bc6c..2addd33ec 100644 --- a/src/shared/git-branch-cleanup.ts +++ b/src/shared/git-branch-cleanup.ts @@ -1,3 +1,6 @@ +import type { GitCapabilityCache } from './git-capability-cache' +import { isUnsupportedMergeTreeWriteTreeError } from './git-merge-tree-capability' + export type GitBranchCleanupExec = ( argv: string[], options?: { stdin?: string } @@ -102,14 +105,26 @@ async function hasBranchOnlyMergeCommits( async function branchMergesWithoutTreeChanges( runGit: GitBranchCleanupExec, targetOid: string, - branchRef: string + branchRef: string, + capabilities: GitCapabilityCache ): Promise { - const mergedTree = await readOptionalGitStdout(runGit, [ - 'merge-tree', - '--write-tree', - targetOid, - branchRef - ]) + const args = ['merge-tree', '--write-tree', targetOid, branchRef] + const readMergedTree = async (): Promise => { + try { + return await capabilities.runWithFallback( + 'merge-tree-write-tree', + async () => (await runGit(args)).stdout.trim() || null, + async () => null, + isUnsupportedMergeTreeWriteTreeError + ) + } catch { + return null + } + } + const mergedTree = await readMergedTree() + if (!mergedTree) { + return false + } const targetTree = await readOptionalGitStdout(runGit, [ 'rev-parse', '--verify', @@ -159,7 +174,8 @@ async function computeStablePatchId( async function branchNetPatchMatchesTargetSquashCommit( runGit: GitBranchCleanupExec, targetOid: string, - branchRef: string + branchRef: string, + capabilities: GitCapabilityCache ): Promise { const mergeBase = await readOptionalGitStdout(runGit, ['merge-base', targetOid, branchRef]) if (!mergeBase) { @@ -198,7 +214,7 @@ async function branchNetPatchMatchesTargetSquashCommit( // tree merge proves the branch contributes no additional changes there. if ( commitPatchId === branchPatchId && - (await branchMergesWithoutTreeChanges(runGit, commitOid, branchRef)) + (await branchMergesWithoutTreeChanges(runGit, commitOid, branchRef, capabilities)) ) { return true } @@ -209,7 +225,8 @@ async function branchNetPatchMatchesTargetSquashCommit( export async function branchHasNoUnmergedChangesOnAnyTarget( runGit: GitBranchCleanupExec, branchName: string, - targetRefs: string[] + targetRefs: string[], + capabilities: GitCapabilityCache ): Promise { const branchRef = `refs/heads/${branchName}` @@ -218,11 +235,13 @@ export async function branchHasNoUnmergedChangesOnAnyTarget( if (!targetOid) { continue } - if (await branchMergesWithoutTreeChanges(runGit, targetOid, branchRef)) { + if (await branchMergesWithoutTreeChanges(runGit, targetOid, branchRef, capabilities)) { return true } if (await hasBranchOnlyMergeCommits(runGit, targetOid, branchRef)) { - if (await branchNetPatchMatchesTargetSquashCommit(runGit, targetOid, branchRef)) { + if ( + await branchNetPatchMatchesTargetSquashCommit(runGit, targetOid, branchRef, capabilities) + ) { return true } continue diff --git a/src/shared/git-capability-cache.test.ts b/src/shared/git-capability-cache.test.ts new file mode 100644 index 000000000..86f84a508 --- /dev/null +++ b/src/shared/git-capability-cache.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, vi } from 'vitest' +import { GIT_CAPABILITY_RETRY_INTERVAL_MS, GitCapabilityCache } from './git-capability-cache' + +describe('GitCapabilityCache', () => { + it('retries a capability after the compatibility interval', () => { + const cache = new GitCapabilityCache() + cache.rememberUnsupported('worktree-list-z', 1_000) + + expect(cache.shouldTry('worktree-list-z', 1_000 + GIT_CAPABILITY_RETRY_INTERVAL_MS - 1)).toBe( + false + ) + expect(cache.shouldTry('worktree-list-z', 1_000 + GIT_CAPABILITY_RETRY_INTERVAL_MS)).toBe(true) + }) + + it('coalesces concurrent capability probes after an unsupported result', async () => { + const cache = new GitCapabilityCache() + let rejectProbe!: (error: Error) => void + const firstPreferred = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectProbe = reject + }) + ) + const secondPreferred = vi.fn(async () => 'unexpected') + const firstFallback = vi.fn(async () => 'first-fallback') + const secondFallback = vi.fn(async () => 'second-fallback') + const isUnsupported = (error: unknown): boolean => + error instanceof Error && error.message === 'unsupported' + + const first = cache.runWithFallback( + 'for-each-ref-exclude', + firstPreferred, + firstFallback, + isUnsupported + ) + const second = cache.runWithFallback( + 'for-each-ref-exclude', + secondPreferred, + secondFallback, + isUnsupported + ) + rejectProbe(new Error('unsupported')) + + await expect(Promise.all([first, second])).resolves.toEqual([ + 'first-fallback', + 'second-fallback' + ]) + expect(firstPreferred).toHaveBeenCalledTimes(1) + expect(secondPreferred).not.toHaveBeenCalled() + expect(firstFallback).toHaveBeenCalledTimes(1) + expect(secondFallback).toHaveBeenCalledTimes(1) + }) + + it('does not serialize calls after a capability is known to be supported', async () => { + const cache = new GitCapabilityCache() + const isUnsupported = vi.fn(() => false) + await cache.runWithFallback( + 'for-each-ref-exclude', + async () => 'initial-result', + async () => 'unexpected-fallback', + isUnsupported + ) + + const releases: (() => void)[] = [] + let activeCalls = 0 + let maxConcurrentCalls = 0 + const runPreferred = vi.fn( + () => + new Promise((resolve) => { + activeCalls += 1 + maxConcurrentCalls = Math.max(maxConcurrentCalls, activeCalls) + releases.push(() => { + activeCalls -= 1 + resolve('result') + }) + }) + ) + + const first = cache.runWithFallback( + 'for-each-ref-exclude', + runPreferred, + async () => 'unexpected-fallback', + isUnsupported + ) + const second = cache.runWithFallback( + 'for-each-ref-exclude', + runPreferred, + async () => 'unexpected-fallback', + isUnsupported + ) + + expect(runPreferred).toHaveBeenCalledTimes(2) + expect(maxConcurrentCalls).toBe(2) + for (const release of releases) { + release() + } + await expect(Promise.all([first, second])).resolves.toEqual(['result', 'result']) + }) + + it('drops known support when a later call reports the capability unsupported', async () => { + const cache = new GitCapabilityCache() + const isUnsupported = (error: unknown): boolean => + error instanceof Error && error.message === 'unsupported' + await cache.runWithFallback( + 'for-each-ref-exclude', + async () => 'supported', + async () => 'unexpected-fallback', + isUnsupported + ) + + await expect( + cache.runWithFallback( + 'for-each-ref-exclude', + async () => { + throw new Error('unsupported') + }, + async () => 'fallback', + isUnsupported + ) + ).resolves.toBe('fallback') + + const laterPreferred = vi.fn(async () => 'unexpected-preferred') + await expect( + cache.runWithFallback( + 'for-each-ref-exclude', + laterPreferred, + async () => 'cached-fallback', + isUnsupported + ) + ).resolves.toBe('cached-fallback') + expect(laterPreferred).not.toHaveBeenCalled() + }) +}) diff --git a/src/shared/git-capability-cache.ts b/src/shared/git-capability-cache.ts new file mode 100644 index 000000000..a3959cc95 --- /dev/null +++ b/src/shared/git-capability-cache.ts @@ -0,0 +1,115 @@ +// Why: suppress hot-loop failures while still detecting an in-place Git +// upgrade during a long Orca session without requiring a restart. +export const GIT_CAPABILITY_RETRY_INTERVAL_MS = 30 * 60_000 + +export type GitCapability = + | 'for-each-ref-exclude' + | 'merge-tree-merge-base' + | 'merge-tree-write-tree' + | 'rev-parse-path-format' + | 'worktree-list-z' + +type GitCapabilityProbeOutcome = 'supported' | 'unsupported' | 'unknown' + +export class GitCapabilityCache { + private readonly retryAfterByCapability = new Map() + private readonly probesByCapability = new Map>() + private readonly supportedCapabilities = new Set() + + shouldTry(capability: GitCapability, nowMs = Date.now()): boolean { + const retryAfterMs = this.retryAfterByCapability.get(capability) + if (retryAfterMs === undefined) { + return true + } + if (nowMs < retryAfterMs) { + return false + } + this.retryAfterByCapability.delete(capability) + return true + } + + rememberUnsupported(capability: GitCapability, nowMs = Date.now()): void { + // Why: optimistic probes preserve newer Git behavior, but repeating a + // known failure on every poll/search wastes subprocesses and trace space. + this.supportedCapabilities.delete(capability) + this.retryAfterByCapability.set(capability, nowMs + GIT_CAPABILITY_RETRY_INTERVAL_MS) + } + + async runWithFallback( + capability: GitCapability, + runPreferred: () => Promise, + runFallback: () => Promise, + isUnsupportedError: (error: unknown) => boolean + ): Promise { + if (this.supportedCapabilities.has(capability)) { + // Why: supported commands are real work, not disposable probes. Let + // sibling repo/SSH calls retain their intended concurrency. + return this.runPreferredOrFallback(capability, runPreferred, runFallback, isUnsupportedError) + } + if (!this.shouldTry(capability)) { + return runFallback() + } + + const inFlightProbe = this.probesByCapability.get(capability) + if (inFlightProbe) { + const outcome = await inFlightProbe + if (outcome === 'unsupported' || !this.shouldTry(capability)) { + return runFallback() + } + return this.runPreferredOrFallback(capability, runPreferred, runFallback, isUnsupportedError) + } + + let settleProbe!: (outcome: GitCapabilityProbeOutcome) => void + const probe = new Promise((resolve) => { + settleProbe = resolve + }) + this.probesByCapability.set(capability, probe) + try { + return await this.runPreferredOrFallback( + capability, + runPreferred, + runFallback, + isUnsupportedError, + settleProbe + ) + } finally { + if (this.probesByCapability.get(capability) === probe) { + this.probesByCapability.delete(capability) + } + } + } + + clear(): void { + this.retryAfterByCapability.clear() + this.probesByCapability.clear() + this.supportedCapabilities.clear() + } + + private async runPreferredOrFallback( + capability: GitCapability, + runPreferred: () => Promise, + runFallback: () => Promise, + isUnsupportedError: (error: unknown) => boolean, + settleProbe?: (outcome: GitCapabilityProbeOutcome) => void + ): Promise { + try { + const result = await runPreferred() + // A preferred callback can detect old Git's exit-zero option echo and + // remember it as unsupported, so do not overwrite that stronger signal. + const outcome = this.retryAfterByCapability.has(capability) ? 'unsupported' : 'supported' + if (outcome === 'supported') { + this.supportedCapabilities.add(capability) + } + settleProbe?.(outcome) + return result + } catch (error) { + if (!isUnsupportedError(error)) { + settleProbe?.('unknown') + throw error + } + this.rememberUnsupported(capability) + settleProbe?.('unsupported') + return runFallback() + } + } +} diff --git a/src/shared/git-merge-tree-capability.test.ts b/src/shared/git-merge-tree-capability.test.ts new file mode 100644 index 000000000..b83cf40ae --- /dev/null +++ b/src/shared/git-merge-tree-capability.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { + isUnsupportedMergeTreeMergeBaseError, + isUnsupportedMergeTreeWriteTreeError +} from './git-merge-tree-capability' + +describe('isUnsupportedMergeTreeWriteTreeError', () => { + it.each([ + { stderr: 'fatal: unknown rev --write-tree' }, + { stdout: 'usage: git merge-tree ' }, + new Error("error: unknown option 'write-tree'") + ])('recognizes old-Git write-tree rejection shapes', (error) => { + expect(isUnsupportedMergeTreeWriteTreeError(error)).toBe(true) + }) + + it('does not classify an ordinary merge failure as unsupported', () => { + expect( + isUnsupportedMergeTreeWriteTreeError({ + stderr: 'fatal: refusing to merge unrelated histories' + }) + ).toBe(false) + }) + + it('recognizes only an unsupported merge-base option', () => { + expect( + isUnsupportedMergeTreeMergeBaseError({ + stderr: "error: unknown option `merge-base'" + }) + ).toBe(true) + expect(isUnsupportedMergeTreeMergeBaseError({ stderr: 'fatal: merge-base failed' })).toBe(false) + }) +}) diff --git a/src/shared/git-merge-tree-capability.ts b/src/shared/git-merge-tree-capability.ts new file mode 100644 index 000000000..0ec56cd02 --- /dev/null +++ b/src/shared/git-merge-tree-capability.ts @@ -0,0 +1,27 @@ +function getGitErrorText(error: unknown): string { + if (typeof error !== 'object' || error === null) { + return error instanceof Error ? error.message : String(error) + } + const values = ['message', 'stderr', 'stdout'] + .map((key) => (error as Record)[key]) + .filter((value): value is string => typeof value === 'string') + return values.join('\n') +} + +export function isUnsupportedMergeTreeWriteTreeError(error: unknown): boolean { + const output = getGitErrorText(error) + return ( + /(?:unknown|invalid|unrecognized) option(?::|\s+)[`']?(?:--?)?write-tree[`']?(?:\s|$)/i.test( + output + ) || + /unknown rev [`']?--write-tree[`']?(?:\s|$)/i.test(output) || + /usage:\s*git merge-tree\s+\s+\s+/i.test(output) + ) +} + +export function isUnsupportedMergeTreeMergeBaseError(error: unknown): boolean { + const output = getGitErrorText(error) + return /(?:unknown|invalid|unrecognized) option(?::|\s+)[`']?(?:--?)?merge-base[`']?(?:\s|$)/i.test( + output + ) +} diff --git a/src/shared/git-ref-command-capabilities.ts b/src/shared/git-ref-command-capabilities.ts new file mode 100644 index 000000000..be3f5d9e0 --- /dev/null +++ b/src/shared/git-ref-command-capabilities.ts @@ -0,0 +1,14 @@ +function getGitErrorText(error: unknown): string { + if (typeof error !== 'object' || error === null) { + return error instanceof Error ? error.message : String(error) + } + const values = ['message', 'stderr', 'stdout'] + .map((key) => (error as Record)[key]) + .filter((value): value is string => typeof value === 'string') + return values.join('\n') +} + +export function isForEachRefExcludeUnsupportedError(error: unknown): boolean { + const output = getGitErrorText(error).toLowerCase() + return output.includes('unknown option') && output.includes('exclude') +} diff --git a/src/shared/git-worktree-command-capabilities.ts b/src/shared/git-worktree-command-capabilities.ts new file mode 100644 index 000000000..76bd70543 --- /dev/null +++ b/src/shared/git-worktree-command-capabilities.ts @@ -0,0 +1,34 @@ +function getGitErrorText(error: unknown): string { + if (typeof error !== 'object' || error === null) { + return error instanceof Error ? error.message : String(error) + } + const values = ['message', 'stderr', 'stdout'] + .map((key) => (error as Record)[key]) + .filter((value): value is string => typeof value === 'string') + return values.join('\n') +} + +function getGitErrorCode(error: unknown): string | undefined { + return typeof error === 'object' && error !== null && 'code' in error + ? String((error as { code?: unknown }).code) + : undefined +} + +export function isUnsupportedWorktreeListZError(error: unknown): boolean { + // `-z` is this fixed command's only post-baseline flag, so exit 129 is the + // locale-independent rejection signal on old native, WSL, and SSH Git. + if (getGitErrorCode(error) === '129') { + return true + } + return /(?:unknown|invalid|unrecognized) (?:switch|option).*`?-?z'?/i.test(getGitErrorText(error)) +} + +export function isUnsupportedRevParsePathFormatError(error: unknown): boolean { + return /(?:unknown|invalid|unrecognized).*(?:--path-format|path-format)/i.test( + getGitErrorText(error) + ) +} + +export function hasUnsupportedRevParsePathFormatEcho(output: string): boolean { + return output.split(/\r?\n/).some((line) => line.startsWith('--path-format')) +}