mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 08:02:33 +00:00
* refactor(tests): split oversized test files off the max-lines suppression list Every `*.test.ts`/`*.spec.ts` that carried an `eslint/oxlint-disable max-lines` directive is now split into focused, behavior-scoped suites that fit the 800-line test budget, with shared setup extracted into co-located `*-test-harness.ts` / `*-test-fixtures.ts` modules (300-line budget). 83 files became ~930; the largest output is 797 effective lines. `orca-runtime.test.ts` is intentionally untouched. Test bodies were moved by scripted line-range slicing rather than retyped, so assertions are byte-identical. The only permitted body edits were mechanical rebinding where a shared value moved into a harness (e.g. `tmpHome` -> `homes.tmpHome`). Registries that enumerate test files were updated in lockstep: - config/max-lines-baseline.txt: pruned 341 -> 258 entries (all 83 removed). - config/reliability-gates.jsonc: 33 gates repointed at the split files, with assertionRefs split per file where a gate's coverage now spans several. - .github/workflows/pr.yml: the real-zsh lane now lists the 4 split files that actually exercise zsh, so they keep running in the dedicated shell lane. Also renamed agent-hooks `server-test-fixtures.ts` to `server.test-fixtures.ts` so the global-fetch call-site audit keeps skipping it, and added `.js` extensions to the CLI suites' dynamic harness imports (node16 resolution) to unbreak `build:cli`. Verification: full suite 52,449 passing vs 52,448 at baseline with zero assertions lost; `pnpm lint`, `pnpm typecheck`, and `pnpm build:cli` all exit 0; the terminal-pane e2e spec runs 31/31 headless. * refactor(tests): split hook-idle arbitration suite that oxfmt pushed over budget The pre-commit oxfmt pass reflowed pty-connection-hook-idle-arbitration.test.ts to 811 effective lines, 11 over the test budget. Split the hook-completion side effect and replacement-agent veto cases into their own suite; both files now sit well under the cap and the 15 tests are unchanged. * test: port upstream test changes into the split files after rebase Rebasing onto main surfaced 27 tests that main had added to files this branch deleted, plus edits to tests that had already moved. Taking the deletion side of those modify/delete conflicts would have dropped that coverage silently, so each upstream change is ported into the split file that now owns the behavior — for example main's six orchestration mailbox tests land across orchestration-runs, -send, and -check. Also repoints `orchestration.notification-mailbox-consistency`, a gate main added after this branch's gate remap, at those same three split files, and re-prunes the max-lines baseline against main's (257 entries). Verified: all 27 upstream test titles present; full suite 52,761 passing with the only diff vs baseline being 12 tests main itself removed and 3 that moved from skipped to passing; lint and typecheck exit 0. * fix(test): flush pending continuations before tearing down terminal test globals CI shard 5/16 failed on both Node 24 and 26 with `ReferenceError: window is not defined` from pty-connection.ts, surfacing through pty-connection-daemon-snapshot-replay.test.ts. The reattach/settle chains `await` a real promise and then touch `window.api`. Under fake timers those continuations cannot run, so they only become schedulable once restoreTerminalTestGlobals() switches back to real timers — which previously happened immediately before `delete globalThis.window`, so a late continuation threw and failed the whole file. Flush async ticks in that window instead. This is latent in the source rather than new: the pre-split 25k-line file kept running other tests after these, which gave the chains time to settle before teardown. Splitting the file moved teardown directly behind them. * fix(test): keep an inert window after terminal test teardown instead of deleting it The async-tick flush was not enough: the reattach/settle chain can resolve after teardown regardless of how long we drain, so CI shard 5/16 still failed with `ReferenceError: window is not defined` from pty-connection.ts. A real renderer never loses `window`, so deleting it was the artificial part. Swap in an inert proxy whose properties resolve to callables and whose calls resolve to undefined, making a late `window.api.pty.*` call a harmless no-op. The next test replaces it wholesale via installTerminalTestGlobals(), and no test asserts that `window` is absent.
653 lines
19 KiB
TypeScript
653 lines
19 KiB
TypeScript
import type * as FsPromises from 'node:fs/promises'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const {
|
|
gitExecFileAsyncMock,
|
|
gitExecFileSyncMock,
|
|
translateWslOutputPathsMock,
|
|
statMock,
|
|
readFileMock,
|
|
resolveGitDirMock,
|
|
moveWorktreeDirectoryToTrashMock,
|
|
restoreWorktreeDirectoryFromTrashMock,
|
|
scheduleWorktreeTrashDeletionMock
|
|
} = vi.hoisted(() => ({
|
|
gitExecFileAsyncMock: vi.fn(),
|
|
gitExecFileSyncMock: vi.fn(),
|
|
translateWslOutputPathsMock: vi.fn((output: string) => output),
|
|
statMock: vi.fn(),
|
|
readFileMock: vi.fn(),
|
|
resolveGitDirMock: vi.fn(),
|
|
moveWorktreeDirectoryToTrashMock: vi.fn(),
|
|
restoreWorktreeDirectoryFromTrashMock: vi.fn(),
|
|
scheduleWorktreeTrashDeletionMock: vi.fn()
|
|
}))
|
|
|
|
vi.mock('../worktree-trash', () => ({
|
|
moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock,
|
|
restoreWorktreeDirectoryFromTrash: restoreWorktreeDirectoryFromTrashMock,
|
|
scheduleWorktreeTrashDeletion: scheduleWorktreeTrashDeletionMock
|
|
}))
|
|
|
|
vi.mock('./runner', () => ({
|
|
gitExecFileAsync: gitExecFileAsyncMock,
|
|
gitExecFileSync: gitExecFileSyncMock,
|
|
translateWslOutputPaths: translateWslOutputPathsMock
|
|
}))
|
|
|
|
vi.mock('./status', () => ({
|
|
resolveGitDir: resolveGitDirMock,
|
|
runWithGitReadCacheInvalidation: <T>(run: () => Promise<T>) => run()
|
|
}))
|
|
|
|
vi.mock('fs/promises', async () => {
|
|
const actual = await vi.importActual<typeof FsPromises>('fs/promises')
|
|
return { ...actual, stat: statMock, readFile: readFileMock }
|
|
})
|
|
|
|
import {
|
|
createGitCallReader,
|
|
createGitCommandMocker,
|
|
expectGitCallOrder,
|
|
resetWorktreeGitMocks,
|
|
resetWorktreeRemovalState
|
|
} from './remove-worktree-test-harness'
|
|
|
|
import { removeWorktree, WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from './worktree'
|
|
|
|
const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock)
|
|
const getGitCalls = createGitCallReader(gitExecFileAsyncMock)
|
|
|
|
beforeEach(() => {
|
|
resetWorktreeRemovalState({
|
|
moveWorktreeDirectoryToTrashMock,
|
|
restoreWorktreeDirectoryFromTrashMock,
|
|
scheduleWorktreeTrashDeletionMock
|
|
})
|
|
})
|
|
|
|
describe('removeWorktree', () => {
|
|
beforeEach(() => {
|
|
resetWorktreeGitMocks({
|
|
gitExecFileAsyncMock,
|
|
gitExecFileSyncMock,
|
|
translateWslOutputPathsMock,
|
|
statMock,
|
|
readFileMock,
|
|
resolveGitDirMock
|
|
})
|
|
})
|
|
|
|
it('removes the worktree and deletes its local branch', 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
|
|
`
|
|
},
|
|
'git worktree list --porcelain#2': {
|
|
stdout: `worktree /repo
|
|
HEAD abc123
|
|
branch refs/heads/main
|
|
`
|
|
}
|
|
})
|
|
|
|
await removeWorktree('/repo', '/repo-feature')
|
|
|
|
const calls = getGitCalls()
|
|
expect(calls).toEqual(
|
|
expect.arrayContaining(['git worktree remove /repo-feature', 'git branch -d -- feature/test'])
|
|
)
|
|
expect(calls).not.toContain('git worktree prune')
|
|
expectGitCallOrder(calls, 'git worktree remove /repo-feature', 'git branch -d -- feature/test')
|
|
})
|
|
|
|
it('preserves the branch when requested for a pre-existing local branch checkout', 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
|
|
`
|
|
}
|
|
})
|
|
|
|
await removeWorktree('/repo', '/repo-feature', false, { deleteBranch: false })
|
|
|
|
const calls = getGitCalls()
|
|
expect(calls).toContain('git worktree remove /repo-feature')
|
|
expect(calls).not.toContain('git worktree prune')
|
|
expect(calls).not.toContain('git branch -d -- feature/test')
|
|
expect(calls).not.toContain('git branch -D -- feature/test')
|
|
})
|
|
|
|
it('skips branch deletion when another worktree still points at the branch', 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
|
|
|
|
worktree /repo-feature-copy
|
|
HEAD def456
|
|
branch refs/heads/feature/test
|
|
`
|
|
},
|
|
'git worktree list --porcelain#2': {
|
|
stdout: `worktree /repo
|
|
HEAD abc123
|
|
branch refs/heads/main
|
|
|
|
worktree /repo-feature-copy
|
|
HEAD def456
|
|
branch refs/heads/feature/test
|
|
`
|
|
},
|
|
'git branch -d -- feature/test': {
|
|
error: new Error(
|
|
"cannot delete branch 'feature/test' used by worktree at '/repo-feature-copy'"
|
|
)
|
|
},
|
|
'git branch -d -- feature/test#2': {
|
|
error: new Error(
|
|
"cannot delete branch 'feature/test' used by worktree at '/repo-feature-copy'"
|
|
)
|
|
}
|
|
})
|
|
|
|
await removeWorktree('/repo', '/repo-feature')
|
|
|
|
const calls = getGitCalls()
|
|
expect(calls).toEqual(
|
|
expect.arrayContaining([
|
|
'git worktree remove /repo-feature',
|
|
'git branch -d -- feature/test',
|
|
'git worktree prune'
|
|
])
|
|
)
|
|
expect(calls.filter((call) => call === 'git branch -d -- feature/test')).toHaveLength(2)
|
|
expect(calls).not.toContain('git branch -D -- feature/test')
|
|
})
|
|
|
|
it('deletes the branch after prune removes stale sibling worktree entries', 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
|
|
|
|
worktree /repo-stale
|
|
HEAD 0000000
|
|
branch refs/heads/feature/test
|
|
prunable gitdir file points to non-existent location
|
|
`
|
|
},
|
|
'git worktree list --porcelain#2': {
|
|
stdout: `worktree /repo
|
|
HEAD abc123
|
|
branch refs/heads/main
|
|
`
|
|
},
|
|
'git branch -d -- feature/test': {
|
|
error: new Error("cannot delete branch 'feature/test' used by worktree at '/repo-stale'")
|
|
},
|
|
'git branch -d -- feature/test#2': {
|
|
stdout: ''
|
|
}
|
|
})
|
|
|
|
await removeWorktree('/repo', '/repo-feature')
|
|
|
|
const calls = getGitCalls()
|
|
expect(calls).toEqual([
|
|
'git worktree list --porcelain -z',
|
|
// The cleanliness probe that decides whether the checkout may be renamed aside.
|
|
'git status --porcelain --untracked-files=all',
|
|
'git worktree remove /repo-feature',
|
|
'git branch -d -- feature/test',
|
|
'git worktree prune',
|
|
'git branch -d -- feature/test'
|
|
])
|
|
})
|
|
|
|
it('renames the checkout aside and deregisters the missing path', 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
|
|
`
|
|
},
|
|
'git worktree list --porcelain#2': {
|
|
stdout: `worktree /repo
|
|
HEAD abc123
|
|
branch refs/heads/main
|
|
`
|
|
}
|
|
})
|
|
moveWorktreeDirectoryToTrashMock.mockResolvedValue('/trash/wt-1-abcdef01')
|
|
|
|
await removeWorktree('/repo', '/repo-feature')
|
|
|
|
const calls = getGitCalls()
|
|
expect(moveWorktreeDirectoryToTrashMock).toHaveBeenCalledWith('/repo-feature')
|
|
expect(scheduleWorktreeTrashDeletionMock).toHaveBeenCalledWith('/trash/wt-1-abcdef01')
|
|
expect(calls).toContain('git worktree remove --force /repo-feature')
|
|
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
|
['worktree', 'remove', '--force', '/repo-feature'],
|
|
{ cwd: '/repo', timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS }
|
|
)
|
|
expect(calls).not.toContain('git worktree remove /repo-feature')
|
|
expect(calls).not.toContain('git worktree prune')
|
|
expect(calls).toContain('git branch -d -- feature/test')
|
|
})
|
|
|
|
it('prunes the registration when deregistering the moved checkout fails', 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
|
|
`
|
|
},
|
|
'git worktree list --porcelain#2': {
|
|
stdout: `worktree /repo
|
|
HEAD abc123
|
|
branch refs/heads/main
|
|
`
|
|
},
|
|
'git worktree remove --force /repo-feature': {
|
|
error: new Error('fatal: validation failed, cannot remove working directory')
|
|
}
|
|
})
|
|
moveWorktreeDirectoryToTrashMock.mockResolvedValue('/trash/wt-2-abcdef02')
|
|
|
|
await removeWorktree('/repo', '/repo-feature')
|
|
|
|
expect(getGitCalls()).toContain('git worktree prune')
|
|
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], {
|
|
cwd: '/repo',
|
|
timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
|
|
})
|
|
expect(
|
|
gitExecFileAsyncMock.mock.calls.filter(
|
|
([args, options]) =>
|
|
args.join(' ') === 'worktree list --porcelain -z' &&
|
|
options.timeout === WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
|
|
)
|
|
).toHaveLength(2)
|
|
expect(scheduleWorktreeTrashDeletionMock).toHaveBeenCalledWith('/trash/wt-2-abcdef02')
|
|
expect(restoreWorktreeDirectoryFromTrashMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('restores the moved checkout and removes in place when the registration survives pruning', 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
|
|
`
|
|
},
|
|
'git worktree remove --force /repo-feature': {
|
|
error: new Error('fatal: validation failed, cannot remove working directory')
|
|
}
|
|
})
|
|
moveWorktreeDirectoryToTrashMock.mockResolvedValue('/trash/wt-3-abcdef03')
|
|
|
|
await removeWorktree('/repo', '/repo-feature')
|
|
|
|
expect(restoreWorktreeDirectoryFromTrashMock).toHaveBeenCalledWith(
|
|
'/trash/wt-3-abcdef03',
|
|
'/repo-feature'
|
|
)
|
|
expect(scheduleWorktreeTrashDeletionMock).not.toHaveBeenCalled()
|
|
expect(getGitCalls()).toContain('git worktree remove /repo-feature')
|
|
})
|
|
|
|
it('removes in place when the checkout cannot be renamed aside', 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
|
|
`
|
|
},
|
|
'git worktree list --porcelain#2': {
|
|
stdout: `worktree /repo
|
|
HEAD abc123
|
|
branch refs/heads/main
|
|
`
|
|
}
|
|
})
|
|
// Windows open handles and cross-volume renames both surface as an unavailable rename.
|
|
moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined)
|
|
|
|
await removeWorktree('/repo', '/repo-feature')
|
|
|
|
expect(getGitCalls()).toContain('git worktree remove /repo-feature')
|
|
expect(scheduleWorktreeTrashDeletionMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('never renames a dirty checkout aside', 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
|
|
`
|
|
},
|
|
'git status --porcelain --untracked-files=all': { stdout: ' M src/app.ts\n' },
|
|
'git worktree remove /repo-feature': {
|
|
error: new Error('fatal: contains modified or untracked files, use --force to delete it')
|
|
}
|
|
})
|
|
|
|
await expect(removeWorktree('/repo', '/repo-feature')).rejects.toThrow(
|
|
'contains modified or untracked files'
|
|
)
|
|
expect(moveWorktreeDirectoryToTrashMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('deletes WSL-hosted checkouts in place inside the distro', 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
|
|
`
|
|
},
|
|
'git worktree list --porcelain#2': {
|
|
stdout: `worktree /repo
|
|
HEAD abc123
|
|
branch refs/heads/main
|
|
`
|
|
}
|
|
})
|
|
|
|
await removeWorktree('/repo', '/repo-feature', false, { wslDistro: 'Ubuntu' })
|
|
|
|
expect(moveWorktreeDirectoryToTrashMock).not.toHaveBeenCalled()
|
|
expect(getGitCalls()).not.toContain('git status --porcelain --untracked-files=all')
|
|
expect(getGitCalls()).toContain('git worktree remove /repo-feature')
|
|
})
|
|
|
|
it('does not rename a WSL checkout configured for a native Windows repo', async () => {
|
|
const originalPlatform = process.platform
|
|
const worktreePath = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\feature'
|
|
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
|
try {
|
|
mockGitCommands({})
|
|
|
|
await removeWorktree('C:\\repo', worktreePath, false, {
|
|
knownRemovedWorktree: { branch: '', head: '', locked: false }
|
|
})
|
|
|
|
expect(moveWorktreeDirectoryToTrashMock).not.toHaveBeenCalled()
|
|
expect(getGitCalls()).toEqual([`git worktree remove ${worktreePath}`])
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
|
}
|
|
})
|
|
|
|
it('passes one --force before the worktree path for dirty-file removal', 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
|
|
`
|
|
},
|
|
'git worktree list --porcelain#2': {
|
|
stdout: `worktree /repo
|
|
HEAD abc123
|
|
branch refs/heads/main
|
|
`
|
|
}
|
|
})
|
|
|
|
await removeWorktree('/repo', '/repo-feature', true)
|
|
|
|
expect(getGitCalls()).toContain('git worktree remove --force /repo-feature')
|
|
})
|
|
|
|
it('force-retries removal when git refuses a clean worktree containing an initialised submodule', 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
|
|
`
|
|
},
|
|
'git worktree list --porcelain#2': {
|
|
stdout: `worktree /repo
|
|
HEAD abc123
|
|
branch refs/heads/main
|
|
`
|
|
},
|
|
'git worktree remove /repo-feature': {
|
|
error: new Error('git worktree remove failed'),
|
|
stderr: 'fatal: working trees containing submodules cannot be moved or removed'
|
|
},
|
|
'git status --porcelain --untracked-files=all': { stdout: '' }
|
|
})
|
|
|
|
await removeWorktree('/repo', '/repo-feature')
|
|
|
|
const calls = getGitCalls()
|
|
expectGitCallOrder(
|
|
calls,
|
|
'git worktree remove /repo-feature',
|
|
'git worktree remove --force /repo-feature'
|
|
)
|
|
// The re-proof of cleanliness between the refusal and the forced retry.
|
|
expect(calls.lastIndexOf('git status --porcelain --untracked-files=all')).toBeGreaterThan(
|
|
calls.indexOf('git worktree remove /repo-feature')
|
|
)
|
|
expect(calls.lastIndexOf('git status --porcelain --untracked-files=all')).toBeLessThan(
|
|
calls.indexOf('git worktree remove --force /repo-feature')
|
|
)
|
|
expect(calls).toContain('git branch -d -- feature/test')
|
|
})
|
|
|
|
it('surfaces uncommitted changes instead of force-removing a dirty submodule worktree', 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
|
|
`
|
|
},
|
|
'git worktree remove /repo-feature': {
|
|
error: new Error('git worktree remove failed'),
|
|
stderr: 'fatal: working trees containing submodules cannot be moved or removed'
|
|
},
|
|
'git status --porcelain --untracked-files=all': { stdout: ' M sub\n' }
|
|
})
|
|
|
|
await expect(removeWorktree('/repo', '/repo-feature')).rejects.toThrow(
|
|
'Worktree has uncommitted or untracked changes.'
|
|
)
|
|
expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature')
|
|
})
|
|
|
|
it('does not force-retry when the caller already forced removal', 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
|
|
`
|
|
},
|
|
'git worktree remove --force /repo-feature': {
|
|
error: new Error('git worktree remove failed'),
|
|
stderr: 'fatal: working trees containing submodules cannot be moved or removed'
|
|
}
|
|
})
|
|
|
|
await expect(removeWorktree('/repo', '/repo-feature', true)).rejects.toThrow()
|
|
expect(
|
|
getGitCalls().filter((call) => call === 'git worktree remove --force /repo-feature')
|
|
).toHaveLength(1)
|
|
})
|
|
|
|
it('does not force-retry unrelated non-force remove failures', 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
|
|
`
|
|
},
|
|
'git worktree remove /repo-feature': {
|
|
error: new Error('git worktree remove failed'),
|
|
stderr: 'fatal: contains modified or untracked files, use --force to delete it'
|
|
}
|
|
})
|
|
|
|
await expect(removeWorktree('/repo', '/repo-feature')).rejects.toThrow(
|
|
'git worktree remove failed'
|
|
)
|
|
expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature')
|
|
// Only the pre-rename probe ran: an unrelated failure must not re-prove cleanliness.
|
|
expect(
|
|
getGitCalls().filter((call) => call === 'git status --porcelain --untracked-files=all')
|
|
).toHaveLength(1)
|
|
})
|
|
|
|
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': {
|
|
stdout: `worktree C:/repo
|
|
HEAD abc123
|
|
branch refs/heads/main
|
|
|
|
worktree C:/Workspaces/Delete-Branch-Ui-Test
|
|
HEAD def456
|
|
branch refs/heads/feature/test
|
|
`
|
|
},
|
|
'git worktree list --porcelain#2': {
|
|
stdout: `worktree C:/repo
|
|
HEAD abc123
|
|
branch refs/heads/main
|
|
`
|
|
}
|
|
})
|
|
|
|
await removeWorktree('C:\\repo', 'c:\\workspaces\\delete-branch-ui-test')
|
|
|
|
const calls = getGitCalls()
|
|
expect(calls).toEqual(
|
|
expect.arrayContaining([
|
|
'git worktree remove c:\\workspaces\\delete-branch-ui-test',
|
|
'git branch -d -- feature/test'
|
|
])
|
|
)
|
|
expect(calls).not.toContain('git worktree prune')
|
|
})
|
|
})
|