Merge remote-tracking branch 'origin/main' into nwparker/131-crashes

# Conflicts:
#	src/relay/git-handler-worktree-remove.ts
This commit is contained in:
Neil
2026-07-10 18:05:04 -07:00
64 changed files with 3587 additions and 334 deletions
+43 -1
View File
@@ -253,7 +253,7 @@ branch refs/heads/main
])
})
it('passes --force before the worktree path when forced removal is requested', async () => {
it('passes one --force before the worktree path for dirty-file removal', async () => {
mockGitCommands({
'git worktree list --porcelain': {
stdout: `worktree /repo
@@ -278,6 +278,48 @@ branch refs/heads/main
expect(getGitCalls()).toContain('git worktree remove --force /repo-feature')
})
it('rejects a locked worktree with stable app-owned copy before invoking remove', async () => {
mockGitCommands({
'git worktree list --porcelain': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
locked active agent session
`
}
})
await expect(removeWorktree('/repo', '/repo-feature', true)).rejects.toThrow(
'Worktree is locked by Git. Lock reason: active agent session.'
)
expect(getGitCalls()).not.toContain('git worktree remove /repo-feature')
})
it('does not treat dirty-file force as permission to override a lock', async () => {
mockGitCommands({
'git worktree list --porcelain': {
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-feature
HEAD def456
branch refs/heads/feature/test
locked active agent session
`
}
})
await expect(removeWorktree('/repo', '/repo-feature', true)).rejects.toThrow(
'Worktree is locked by Git. Lock reason: active agent session.'
)
expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature')
})
it('matches Windows worktree paths before deleting the branch', async () => {
mockGitCommands({
'git worktree list --porcelain': {
+46
View File
@@ -33,6 +33,39 @@ async function createRepoWithNewlineWorktree(): Promise<{
}
}
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
}
}
function branchExists(repoPath: string, branchName: string): boolean {
try {
git(repoPath, ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`])
@@ -68,4 +101,17 @@ describe('git worktree paths', () => {
expect(branchExists(repoPath, 'feature/newline')).toBe(false)
}
)
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)
})
})
+41
View File
@@ -104,6 +104,47 @@ describe('listWorktrees in-flight sharing', () => {
})
describe('parseWorktreeList', () => {
it('preserves a locked marker and its reason', () => {
expect(
parseWorktreeList(
'worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /locked\nHEAD def\nbranch refs/heads/feature\nlocked active agent session\n'
)[1]
).toMatchObject({
path: '/locked',
locked: true,
lockReason: 'active agent 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('parses regular and bare worktree blocks from porcelain output', () => {
const output = `
worktree /repo
+15 -1
View File
@@ -13,6 +13,8 @@ import type {
LocalBaseRefUpdateSuggestion,
RemoveWorktreeResult
} from '../../shared/types'
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 {
@@ -52,7 +54,7 @@ export type AddWorktreeOptions = GitWorktreeExecOptions & {
export type RemoveWorktreeOptions = GitWorktreeExecOptions & {
deleteBranch?: boolean
forceBranchDelete?: boolean
knownRemovedWorktree?: Pick<GitWorktreeInfo, 'branch' | 'head'>
knownRemovedWorktree?: Pick<GitWorktreeInfo, 'branch' | 'head' | 'locked' | 'lockReason'>
}
type LocalBaseRefRefreshability =
@@ -487,6 +489,8 @@ export function parseWorktreeList(
let branch = ''
let isBare = false
let isSparse = false
let locked = false
let lockReason = ''
for (const line of lines) {
if (line.startsWith('worktree ')) {
@@ -499,6 +503,10 @@ export function parseWorktreeList(
isBare = true
} else if (line === 'sparse') {
isSparse = true
} else if (line === 'locked' || line.startsWith('locked ')) {
locked = true
const rawReason = line.slice('locked'.length).trim()
lockReason = options.nulDelimited ? rawReason : decodeGitCQuotedPath(rawReason)
}
}
@@ -510,6 +518,8 @@ export function parseWorktreeList(
branch,
isBare,
...(isSparse ? { isSparse } : {}),
...(locked ? { locked: true } : {}),
...(lockReason ? { lockReason } : {}),
isMainWorktree: worktrees.length === 0
})
}
@@ -1079,6 +1089,10 @@ async function performRemoveWorktree(
const branchName = normalizeLocalBranchRef(removedWorktree?.branch ?? '')
const branchHead = removedWorktree?.head ?? ''
// Why: callers outside the IPC/runtime preflight must not bypass Git's lock
// contract or depend on localized stderr to discover it after side effects.
assertWorktreeUnlockedForRemoval(removedWorktree)
const args = ['worktree', 'remove']
if (force) {
args.push('--force')