Files
orca/src/main/git/worktree-list-paths.test.ts
T
6e2a4a824d fix(worktrees): stop surfacing prunable git worktrees as live workspaces (#8409)
* 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>
2026-07-15 15:24:15 -07:00

156 lines
5.2 KiB
TypeScript

import { execFileSync } from 'node:child_process'
import { mkdtemp, realpath, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { listWorktrees, removeWorktree } from './worktree'
const tempRoots: string[] = []
function git(cwd: string, args: string[]): string {
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
}
async function createRepoWithNewlineWorktree(): Promise<{
repoPath: string
worktreePath: string
}> {
const root = await mkdtemp(path.join(tmpdir(), 'orca-worktree-paths-'))
tempRoots.push(root)
const repoPath = path.join(root, 'repo')
const requestedWorktreePath = path.join(root, 'linked\nworktree')
execFileSync('git', ['init', '--quiet', repoPath])
git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main'])
git(repoPath, ['config', 'user.email', 'test@example.com'])
git(repoPath, ['config', 'user.name', 'Test User'])
git(repoPath, ['commit', '--allow-empty', '--quiet', '-m', 'initial'])
git(repoPath, ['worktree', 'add', '--quiet', '-b', 'feature/newline', requestedWorktreePath])
return {
repoPath: await realpath(repoPath),
worktreePath: await realpath(requestedWorktreePath)
}
}
async function createRepoWithLockedDeletedWorktree(): Promise<{
repoPath: string
worktreePath: string
}> {
const root = await mkdtemp(path.join(tmpdir(), 'orca-worktree-locked-delete-'))
tempRoots.push(root)
const repoPath = path.join(root, 'repo')
const requestedWorktreePath = path.join(root, 'locked deleted worktree')
execFileSync('git', ['init', '--quiet', repoPath])
git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main'])
git(repoPath, ['config', 'user.email', 'test@example.com'])
git(repoPath, ['config', 'user.name', 'Test User'])
git(repoPath, ['commit', '--allow-empty', '--quiet', '-m', 'initial'])
git(repoPath, [
'worktree',
'add',
'--quiet',
'-b',
'feature/locked-delete',
requestedWorktreePath
])
git(repoPath, ['worktree', 'lock', '--reason', 'orca locked stale repro', requestedWorktreePath])
const worktreePath = await realpath(requestedWorktreePath)
await rm(worktreePath, { recursive: true, force: true })
return {
repoPath: await realpath(repoPath),
worktreePath
}
}
async function createRepoWithPrunableWorktree(): Promise<{
repoPath: string
worktreePath: string
}> {
const root = await mkdtemp(path.join(tmpdir(), 'orca-worktree-prunable-'))
tempRoots.push(root)
const repoPath = path.join(root, 'repo')
const requestedWorktreePath = path.join(root, 'stale-worktree')
execFileSync('git', ['init', '--quiet', repoPath])
git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main'])
git(repoPath, ['config', 'user.email', 'test@example.com'])
git(repoPath, ['config', 'user.name', 'Test User'])
git(repoPath, ['commit', '--allow-empty', '--quiet', '-m', 'initial'])
git(repoPath, ['worktree', 'add', '--quiet', '-b', 'feature/stale', requestedWorktreePath])
const worktreePath = await realpath(requestedWorktreePath)
await rm(worktreePath, { recursive: true, force: true })
return {
repoPath: await realpath(repoPath),
worktreePath
}
}
function branchExists(repoPath: string, branchName: string): boolean {
try {
git(repoPath, ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`])
return true
} catch {
return false
}
}
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('git worktree paths', () => {
it.skipIf(process.platform === 'win32')(
'lists worktrees whose paths contain newlines',
async () => {
const { repoPath, worktreePath } = await createRepoWithNewlineWorktree()
const worktrees = await listWorktrees(repoPath)
expect(worktrees.map((worktree) => worktree.path)).toContain(worktreePath)
}
)
it.skipIf(process.platform === 'win32')(
'deletes the matching local branch after removing a newline-path worktree',
async () => {
const { repoPath, worktreePath } = await createRepoWithNewlineWorktree()
await removeWorktree(repoPath, worktreePath)
expect(branchExists(repoPath, 'feature/newline')).toBe(false)
}
)
it('annotates a worktree whose directory was deleted as prunable', async () => {
// Why: an un-pruned registration with a deleted directory must not surface
// as a live workspace (issue #8389).
const { repoPath, worktreePath } = await createRepoWithPrunableWorktree()
const worktrees = await listWorktrees(repoPath)
const stale = worktrees.find(
(worktree) => worktree.path.replaceAll('\\', '/') === worktreePath.replaceAll('\\', '/')
)
expect(stale).toMatchObject({ prunable: true })
})
it('preserves a locked worktree whose directory was deleted manually', async () => {
const { repoPath, worktreePath } = await createRepoWithLockedDeletedWorktree()
await expect(removeWorktree(repoPath, worktreePath, true)).rejects.toThrow(
'Worktree is locked by Git'
)
expect(git(repoPath, ['worktree', 'list', '--porcelain'])).toContain(
worktreePath.replaceAll('\\', '/')
)
expect(branchExists(repoPath, 'feature/locked-delete')).toBe(true)
})
})