Preserve preparation ownership until Git removal starts

This commit is contained in:
Neil
2026-09-05 19:41:23 -07:00
parent 5c413b63ab
commit 96ba61f7e6
3 changed files with 52 additions and 9 deletions
@@ -55,6 +55,39 @@ afterEach(async () => {
})
describe('prepared worktree creation with real Git', () => {
it('retains preparation ownership when the removal command cannot start', async () => {
const fixture = await createRepo()
const repoPath = await realpath(fixture.repoPath)
const root = await realpath(fixture.root)
const preparedPath = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY, 'owned-removal')
await mkdir(join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY), { recursive: true })
const lockReason = createWorktreePreparationLockReason('removal-failure')
await prepareWorktreeCreateCheckout(repoPath, preparedPath, 'main', lockReason)
const original = gitRunner.gitExecFileAsync
const spy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation((args, options) => {
if (args.includes('remove') && args.includes(preparedPath)) {
return Promise.reject(new Error('injected removal launch failure'))
}
return original(args, options)
})
try {
await expect(discardPreparedWorktree(repoPath, preparedPath)).rejects.toThrow(
'injected removal launch failure'
)
const remaining = await listWorktrees(repoPath, { includeCreatePreparations: true })
const prepared = remaining.find((worktree) =>
areWorktreePathsEqual(worktree.path, preparedPath)
)
expect(prepared).toBeDefined()
expect(prepared?.lockReason).toBe(lockReason)
expect(await readFile(join(preparedPath, 'version.txt'), 'utf8')).toBe('one\n')
} finally {
spy.mockRestore()
await discardPreparedWorktree(repoPath, preparedPath)
}
expect(existsSync(preparedPath)).toBe(false)
})
it('creates and finalizes while dead-owner reclamation is stalled', async () => {
const fixture = await createRepo()
const repoPath = await realpath(fixture.repoPath)
+9 -9
View File
@@ -45,16 +45,16 @@ async function performDiscardPreparedWorktree(
timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
}
try {
// Preserve the ownership lock if removal cannot start; Git 2.25 supports locked removal.
await gitExecFileAsync(
[...windowsLongPathGitArgs(repoPath), 'worktree', 'unlock', worktreePath],
cleanupGitOptions
)
} catch {
// It may be unlocked already or only partially registered.
}
try {
await gitExecFileAsync(
[...windowsLongPathGitArgs(repoPath), 'worktree', 'remove', '--force', worktreePath],
[
...windowsLongPathGitArgs(repoPath),
'worktree',
'remove',
'--force',
'--force',
worktreePath
],
cleanupGitOptions
)
} finally {
@@ -204,6 +204,16 @@ describeBinaryCompatibility('real Git binary compatibility', () => {
await rm(join(repoPath, 'deferred-trash'), { recursive: true, force: true })
})
it('removes locked prepared worktrees without a separate unlock', async () => {
await runGit(['worktree', 'add', '--detach', '--no-checkout', 'compat-discard', 'HEAD'])
await runGit(['-C', 'compat-discard', 'reset', '--hard', 'HEAD'])
await runGit(['worktree', 'lock', '--reason', 'owned preparation', 'compat-discard'])
await runGit(['worktree', 'remove', '--force', '--force', 'compat-discard'])
expect((await runGit(['worktree', 'list', '--porcelain'])).stdout).not.toContain(
'compat-discard'
)
})
it('supports prepared worktree creation and finalization', async () => {
await runGit(['worktree', 'add', '--detach', '--no-checkout', 'compat-prepared', 'HEAD'])
await runGit(['-C', 'compat-prepared', 'reset', '--hard', 'HEAD'])