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
This commit is contained in:
parent
73e082b577
commit
533992bdda
|
|
@ -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.
|
||||
|
|
|
|||
12
AGENTS.md
12
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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<string, GitCapabilityCache>()
|
||||
// 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<object, GitCapabilityCache>()
|
||||
|
||||
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()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 [<options>]\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 [<options>]\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']
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<RepoLocation | undefined> {
|
||||
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<GitWorktreeInfo[]> {
|
||||
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) =>
|
||||
|
|
|
|||
|
|
@ -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 <base-tree> <branch1> <branch2>'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<string>()
|
||||
|
||||
export function __resetPRConflictSummaryCachesForTests(): void {
|
||||
mergeTreeMergeBaseUnsupportedRuntimes.clear()
|
||||
clearGitCapabilityStateForTests()
|
||||
__resetPRConflictSummaryDerivationCachesForTests()
|
||||
}
|
||||
|
||||
|
|
@ -213,7 +219,10 @@ async function loadConflictingFiles(
|
|||
baseOid: string,
|
||||
localGitOptions: LocalGitExecOptions
|
||||
): Promise<string[]> {
|
||||
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<Record<'stdout' | 'stderr', unknown>>)[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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, (_event: unknown, args: unknown) => 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 () => {
|
||||
|
|
|
|||
|
|
@ -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<string> => {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<string> => {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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<typeof removeWorktreeOp>[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' }
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>
|
||||
params: Record<string, unknown>,
|
||||
capabilities: GitCapabilityCache
|
||||
): Promise<void> {
|
||||
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)) {
|
||||
|
|
|
|||
|
|
@ -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 } = {}
|
||||
|
|
|
|||
|
|
@ -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 [<options>]\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 [<options>]\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']
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -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<RelayWorktreeInfo[]> {
|
||||
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<string, unknown>[]): 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)
|
||||
}
|
||||
|
|
@ -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<typeof removeWorktreeOp>[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))
|
||||
|
|
|
|||
|
|
@ -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<string, unknown
|
|||
}
|
||||
}
|
||||
|
||||
type RelayWorktreeInfo = {
|
||||
path: string
|
||||
branch?: string
|
||||
head?: string
|
||||
}
|
||||
|
||||
export async function readRelayWorktreeList(
|
||||
git: GitExec,
|
||||
repoPath: string
|
||||
): Promise<RelayWorktreeInfo[]> {
|
||||
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<string, unknown>[]): 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('/')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof removeWorktreeOp>[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',
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>[]): 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<RelayWorktreeInfo[]> {
|
||||
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<string, unknown>
|
||||
params: Record<string, unknown>,
|
||||
capabilities: GitCapabilityCache
|
||||
): Promise<RemoveWorktreeResult> {
|
||||
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 {}
|
||||
|
|
|
|||
|
|
@ -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<unknown>()
|
||||
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<RelayRepoLocation | undefined> {
|
||||
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<string, unknown>, 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<string, unknown>) {
|
||||
|
|
@ -1364,7 +1359,9 @@ export class GitHandler {
|
|||
}
|
||||
|
||||
private async removeWorktree(params: Record<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
|
|
@ -1373,7 +1370,7 @@ export class GitHandler {
|
|||
|
||||
private async refreshLocalBaseRefForWorktreeCreate(params: Record<string, unknown>) {
|
||||
return this.runWithDiffDedupeClear(() =>
|
||||
refreshLocalBaseRefForWorktreeCreateOp(this.git.bind(this), params)
|
||||
refreshLocalBaseRefForWorktreeCreateOp(this.git.bind(this), params, this.gitCapabilities)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<GitResult> {
|
||||
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<void> {
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -4,6 +4,7 @@ import {
|
|||
refreshBranchCleanupTargetRefs,
|
||||
type GitBranchCleanupExec
|
||||
} from './git-branch-cleanup'
|
||||
import { GitCapabilityCache } from './git-capability-cache'
|
||||
|
||||
function baseProofResponses(
|
||||
responses: Partial<Record<string, string | Error>> = {}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<boolean> {
|
||||
const mergedTree = await readOptionalGitStdout(runGit, [
|
||||
'merge-tree',
|
||||
'--write-tree',
|
||||
targetOid,
|
||||
branchRef
|
||||
])
|
||||
const args = ['merge-tree', '--write-tree', targetOid, branchRef]
|
||||
const readMergedTree = async (): Promise<string | null> => {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<string>((_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<string>((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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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<GitCapability, number>()
|
||||
private readonly probesByCapability = new Map<GitCapability, Promise<GitCapabilityProbeOutcome>>()
|
||||
private readonly supportedCapabilities = new Set<GitCapability>()
|
||||
|
||||
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<T>(
|
||||
capability: GitCapability,
|
||||
runPreferred: () => Promise<T>,
|
||||
runFallback: () => Promise<T>,
|
||||
isUnsupportedError: (error: unknown) => boolean
|
||||
): Promise<T> {
|
||||
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<GitCapabilityProbeOutcome>((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<T>(
|
||||
capability: GitCapability,
|
||||
runPreferred: () => Promise<T>,
|
||||
runFallback: () => Promise<T>,
|
||||
isUnsupportedError: (error: unknown) => boolean,
|
||||
settleProbe?: (outcome: GitCapabilityProbeOutcome) => void
|
||||
): Promise<T> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <base-tree> <branch1> <branch2>' },
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<string, unknown>)[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+<base-tree>\s+<branch1>\s+<branch2>/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
|
||||
)
|
||||
}
|
||||
|
|
@ -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<string, unknown>)[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')
|
||||
}
|
||||
|
|
@ -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<string, unknown>)[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'))
|
||||
}
|
||||
Loading…
Reference in New Issue