mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 00:27:39 +00:00
Merge remote-tracking branch 'origin/main' into nwparker/131-crashes
# Conflicts: # src/relay/git-handler-worktree-remove.ts
This commit is contained in:
@@ -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': {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user