mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
* fix(worktrees): stop surfacing prunable git worktrees as live workspaces A worktree still registered in git but whose directory was deleted (git's `prunable` state) was enumerated as a normal workspace, producing repeated pty:spawn DaemonProtocolError / fs:readDir ENOENT loops and a blank pane. - Parse the `prunable` porcelain field (Git >= 2.36) in both the main and relay worktree-list parsers. - For Git < 2.36 (no `prunable` field), probe each linked worktree path for existence on the fallback line-block path, skipping locked registrations to mirror git's own prunable rules. - Omit prunable worktrees from the detected-workspace enumeration only; removal/cleanup flows keep seeing them. - Extend the real-binary compatibility contract with the 2.36 `prunable` boundary. Fixes #8389 Claude-Session: https://claude.ai/code/session_018Rg1Bpq4GGwmz613hq6RSD * fix(worktrees): pin the prunable/locked porcelain annotations to their real Git 2.31 boundary The prunable and locked annotations landed in Git 2.31, five releases before `worktree list -z` (2.36); only -z defines the capability fallback boundary. Correct the compatibility contract so a future matrix entry in the 2.31-2.35 range passes, and reword the fallback comments: on 2.31-2.35 the annotations still parse and the existence probe is a backstop; only Git <2.31 relies on it outright. * fix(worktrees): omit prunable registrations from the Space scan A prunable registration has no directory to size or reclaim, so Space rendered it as a dead "Missing" row whose checkbox stayed disabled with no prune/remove affordance (reported on macOS after a reboot cleared /private/tmp under 16 registrations). Skip prunable entries in the scan, matching the workspace enumeration; removal flows list worktrees separately and still see them. --------- Co-authored-by: kaynan <kaynan.camargo@terceiro-sky.com.br> Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
177 lines
5.8 KiB
TypeScript
177 lines
5.8 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { parseStatusOutput } from './git-status-output-parser'
|
|
import { isUnsupportedWorktreeListZError, parseWorktreeList } from './git-handler-utils'
|
|
|
|
describe('parseWorktreeList', () => {
|
|
it('preserves SSH worktree lock metadata from porcelain output', () => {
|
|
expect(
|
|
parseWorktreeList(
|
|
'worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /locked\nHEAD def\nbranch refs/heads/feature\nlocked remote session\n'
|
|
)[1]
|
|
).toMatchObject({
|
|
path: '/locked',
|
|
locked: true,
|
|
lockReason: 'remote session'
|
|
})
|
|
})
|
|
|
|
it('decodes C-quoted lock reasons from legacy line porcelain output', () => {
|
|
const output =
|
|
'worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /locked\nHEAD def\nbranch refs/heads/feature\nlocked "first line\\nsecond line \\303\\251"\n'
|
|
|
|
expect(parseWorktreeList(output)[1]).toMatchObject({
|
|
locked: true,
|
|
lockReason: 'first line\nsecond line é'
|
|
})
|
|
})
|
|
|
|
it('keeps NUL-delimited lock reasons raw', () => {
|
|
const output = [
|
|
'worktree /repo',
|
|
'HEAD abc',
|
|
'branch refs/heads/main',
|
|
'',
|
|
'worktree /locked',
|
|
'HEAD def',
|
|
'branch refs/heads/feature',
|
|
'locked "literal\\nquote"',
|
|
''
|
|
].join('\0')
|
|
|
|
expect(parseWorktreeList(output, { nulDelimited: true })[1]).toMatchObject({
|
|
locked: true,
|
|
lockReason: '"literal\\nquote"'
|
|
})
|
|
})
|
|
|
|
it('preserves a prunable marker and its reason', () => {
|
|
expect(
|
|
parseWorktreeList(
|
|
'worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /stale\nHEAD def\nbranch refs/heads/feature\nprunable gitdir file points to non-existent location\n'
|
|
)[1]
|
|
).toMatchObject({
|
|
path: '/stale',
|
|
prunable: true,
|
|
prunableReason: 'gitdir file points to non-existent location'
|
|
})
|
|
})
|
|
|
|
it('preserves a NUL-delimited prunable marker', () => {
|
|
const output = [
|
|
'worktree /repo',
|
|
'HEAD abc',
|
|
'branch refs/heads/main',
|
|
'',
|
|
'worktree /stale',
|
|
'HEAD def',
|
|
'branch refs/heads/feature',
|
|
'prunable gitdir file points to non-existent location',
|
|
''
|
|
].join('\0')
|
|
|
|
expect(parseWorktreeList(output, { nulDelimited: true })[1]).toMatchObject({
|
|
path: '/stale',
|
|
prunable: true,
|
|
prunableReason: 'gitdir file points to non-existent location'
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('isUnsupportedWorktreeListZError', () => {
|
|
it('detects an unknown-switch usage error from stderr when the exit code is absent', () => {
|
|
// Isolates the regex fallback: no numeric code, so only the stderr text
|
|
// (a runner that dropped the exit code) can classify the rejection.
|
|
const error = Object.assign(new Error('worktree list -z'), {
|
|
stderr: "error: unknown switch `z'\nusage: git worktree list [<options>]\n"
|
|
})
|
|
expect(isUnsupportedWorktreeListZError(error)).toBe(true)
|
|
})
|
|
|
|
it('detects a localized (non-English) usage error via exit code 129', () => {
|
|
// The SSH remote may run under a non-English locale where the stderr text is
|
|
// translated; the numeric exit code must still classify the -z rejection.
|
|
const error = Object.assign(new Error('worktree list -z'), {
|
|
code: 129,
|
|
stderr: 'Fehler: Unbekannter Schalter »z«\nAufruf: git worktree list [<Optionen>]\n'
|
|
})
|
|
expect(isUnsupportedWorktreeListZError(error)).toBe(true)
|
|
})
|
|
|
|
it('does not classify a fatal (exit 128) error as an unsupported -z rejection', () => {
|
|
const error = Object.assign(new Error('fatal'), {
|
|
code: 128,
|
|
stderr: 'fatal: unable to read tree\n'
|
|
})
|
|
expect(isUnsupportedWorktreeListZError(error)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('parseStatusOutput', () => {
|
|
it('parses upstream ahead/behind from porcelain v2 branch headers', () => {
|
|
const result = parseStatusOutput(
|
|
[
|
|
'# branch.oid abcdef1234567890',
|
|
'# branch.head feature/prompts',
|
|
'# branch.upstream origin/feature/prompts',
|
|
'# branch.ab +2 -3',
|
|
''
|
|
].join('\n')
|
|
)
|
|
|
|
expect(result.upstreamStatus).toEqual({
|
|
hasUpstream: true,
|
|
upstreamName: 'origin/feature/prompts',
|
|
ahead: 2,
|
|
behind: 3
|
|
})
|
|
})
|
|
|
|
it('reports no upstream when porcelain v2 omits branch.upstream', () => {
|
|
const result = parseStatusOutput(
|
|
['# branch.oid abcdef1234567890', '# branch.head feature/prompts', ''].join('\n')
|
|
)
|
|
|
|
expect(result.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 })
|
|
})
|
|
|
|
it('parses ignored porcelain records separately from actionable entries', () => {
|
|
const result = parseStatusOutput(['! dist/', '! .env', '? scratch.txt', ''].join('\n'))
|
|
|
|
expect(result.ignoredPaths).toEqual(['dist/', '.env'])
|
|
expect(result.entries).toEqual([
|
|
{ path: 'scratch.txt', status: 'untracked', area: 'untracked' }
|
|
])
|
|
})
|
|
|
|
it('parses rename records with spaces in the paths', () => {
|
|
const result = parseStatusOutput(
|
|
'2 R. N... 100644 100644 100644 aaaa bbbb R100 src/new name.ts\tsrc/old name.ts\n'
|
|
)
|
|
|
|
expect(result.entries).toEqual([
|
|
{ path: 'src/new name.ts', oldPath: 'src/old name.ts', status: 'renamed', area: 'staged' }
|
|
])
|
|
})
|
|
|
|
it('parses submodule dirtiness flags from porcelain records', () => {
|
|
const result = parseStatusOutput(
|
|
'1 AM S..U 000000 160000 160000 0000000000000000000000000000000000000000 7844cb64e631f17a9ca5b548f3500ef7cecd2f17 nested-repo\n'
|
|
)
|
|
|
|
expect(result.entries).toEqual([
|
|
{
|
|
path: 'nested-repo',
|
|
status: 'added',
|
|
area: 'staged',
|
|
submodule: { commitChanged: false, trackedChanges: false, untrackedChanges: true }
|
|
},
|
|
{
|
|
path: 'nested-repo',
|
|
status: 'modified',
|
|
area: 'unstaged',
|
|
submodule: { commitChanged: false, trackedChanges: false, untrackedChanges: true }
|
|
}
|
|
])
|
|
})
|
|
})
|