mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 00:02:19 +00:00
fix(worktree): block removal when the archive hook fails
A repo's orca.yaml archive hook is the user's last chance to save work off a checkout Orca is about to delete. A failed hook was logged as advisory and stepped over, so the removal went ahead with nothing archived — and the caller could still be told it succeeded. The hook is now a blocking precondition, evaluated while the checkout, its Git registration, its agents and Orca's ownership evidence are all still intact: it sits ahead of the registration re-read, the lock/dirty preflights, stopPtys() and removeWorktree in every orchestrator that runs it. Failure is typed (worktree_archive_hook_failed) and carries the worktree path, outcome, exit code where one was observed, and the hook's output. unverifiable stays distinct from exited, so loss of contact is never read as a pass. The waiver rides its own field at every layer and is never implied by --force, which already carries the PTY-stop waiver; when used, the waived failure comes back on result.archiveHookOverride rather than being swallowed. worktree.archive-failure-blocking.v1 is advertised so an integration can tell "accepts --run-hooks" from "safely propagates a failing hook" without risking the data loss to find out. The runtime's SSH path cannot run a hook at all, so rather than delete with the archive step silently skipped it refuses — waivable like every other refusal here. #18563 retires that gate by making the path run the hook for real. Stacked on #20559, which makes a timed-out hook report honestly; without it a hook that traps SIGTERM and exits 0 would defeat this gate. Fixes #19334
This commit is contained in:
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* Real-repo verification for #19334 — run with:
|
||||
* node config/scripts/archive-hook-removal-repro.mjs
|
||||
*
|
||||
* Requires a prior `build:cli` and `build:electron-vite`; it drives the BUILT CLI against the
|
||||
* BUILT headless runtime, so it proves the shipped artifacts rather than the test harness.
|
||||
*: a failed archive hook must BLOCK a destructive
|
||||
* worktree removal, and the checkout, its git registration and its files must all survive.
|
||||
*
|
||||
* Boots the BUILT headless runtime (`out/main/index.js --serve`), pairs the BUILT CLI to it,
|
||||
* and drives `orca worktree rm` end to end against real git worktrees on disk.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, dirname, resolve } from 'node:path'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const serveEntry = join(projectDir, 'out', 'main', 'index.js')
|
||||
const cliEntry = join(projectDir, 'out', 'cli', 'index.js')
|
||||
const PORT = 6900 + Math.floor(Math.random() * 400)
|
||||
const READY_TIMEOUT_MS = 180_000
|
||||
|
||||
const control = mkdtempSync(join(tmpdir(), 'agh-control-'))
|
||||
const modeFile = join(control, 'mode')
|
||||
const ranFile = join(control, 'ran')
|
||||
const setMode = (m) => writeFileSync(modeFile, m)
|
||||
const hookRuns = () => (existsSync(ranFile) ? readFileSync(ranFile, 'utf8').trim().split('\n') : [])
|
||||
|
||||
let failures = 0
|
||||
const out = (s) => process.stdout.write(`${s}\n`)
|
||||
const banner = (s) => out(`\n${'='.repeat(78)}\n${s}\n${'='.repeat(78)}`)
|
||||
function check(label, ok, detail = '') {
|
||||
out(` ${ok ? 'PASS' : 'FAIL'} ${label}${detail ? ` -- ${detail}` : ''}`)
|
||||
if (!ok) {
|
||||
failures++
|
||||
}
|
||||
}
|
||||
|
||||
let pairingCode = null
|
||||
|
||||
/** Run the real CLI against the booted server. Returns the raw process result. */
|
||||
function cli(args, { json = true } = {}) {
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[cliEntry, ...args, '--pairing-code', pairingCode, ...(json ? ['--json'] : [])],
|
||||
{ encoding: 'utf8', shell: false }
|
||||
)
|
||||
}
|
||||
|
||||
/** Run the CLI and require success, returning result payload. */
|
||||
function ok(args) {
|
||||
const r = cli(args)
|
||||
const parsed = parseJsonLine(r)
|
||||
if (!parsed) {
|
||||
throw new Error(`orca ${args.join(' ')} produced no JSON:\n${r.stdout}\n${r.stderr}`)
|
||||
}
|
||||
if (parsed.ok === false) {
|
||||
throw new Error(`orca ${args.join(' ')} failed: ${parsed.error?.code} ${parsed.error?.message}`)
|
||||
}
|
||||
return parsed.result
|
||||
}
|
||||
|
||||
/** The CLI pretty-prints one JSON document to stdout. */
|
||||
function parseJsonLine(r) {
|
||||
const text = (r.stdout ?? '').trim()
|
||||
const start = text.indexOf('{')
|
||||
if (start === -1) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text.slice(start))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function git(cwd, ...args) {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8' })
|
||||
if (r.status !== 0) {
|
||||
throw new Error(`git ${args.join(' ')}: ${r.stderr || r.stdout}`)
|
||||
}
|
||||
return r.stdout
|
||||
}
|
||||
|
||||
const ARCHIVE_HOOK = `echo "[archive-hook] running in $PWD"
|
||||
echo "$PWD" >> ${JSON.stringify(ranFile).slice(1, -1)}
|
||||
mode=$(cat ${JSON.stringify(modeFile).slice(1, -1)})
|
||||
case "$mode" in
|
||||
ok) echo "[archive-hook] archived OK"; exit 0 ;;
|
||||
fail) echo "[archive-hook] backup target unreachable" >&2; exit 23 ;;
|
||||
signal) echo "[archive-hook] losing the execution host now"; kill -KILL $$ ;;
|
||||
esac
|
||||
echo "unknown mode $mode" >&2; exit 99
|
||||
`
|
||||
|
||||
/** A throwaway git repo with one commit; optionally an orca.yaml archive hook. */
|
||||
function seedGitRepo(label, withHook, githubSlug) {
|
||||
const dir = mkdtempSync(join(tmpdir(), `agh-repo-${label}-`))
|
||||
writeFileSync(join(dir, 'README.md'), `# ${label}\n`)
|
||||
if (withHook) {
|
||||
writeFileSync(
|
||||
join(dir, 'orca.yaml'),
|
||||
`scripts:\n archive: |\n${ARCHIVE_HOOK.split('\n')
|
||||
.map((l) => ` ${l}`)
|
||||
.join('\n')}\n`
|
||||
)
|
||||
}
|
||||
git(dir, 'init', '-b', 'main')
|
||||
git(dir, 'config', 'user.email', 'verify@orca.test')
|
||||
git(dir, 'config', 'user.name', 'Archive Gate Verify')
|
||||
if (githubSlug) {
|
||||
git(dir, 'remote', 'add', 'origin', `https://github.com/agh-owner/${githubSlug}.git`)
|
||||
}
|
||||
git(dir, 'add', '-A')
|
||||
git(dir, 'commit', '-m', 'seed')
|
||||
return dir
|
||||
}
|
||||
|
||||
function waitForReady(child) {
|
||||
return new Promise((res, rej) => {
|
||||
let buffered = ''
|
||||
let serverErr = ''
|
||||
const timer = setTimeout(
|
||||
() => rej(new Error(`no ready payload in ${READY_TIMEOUT_MS}ms\n${serverErr}`)),
|
||||
READY_TIMEOUT_MS
|
||||
)
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stderr.on('data', (c) => {
|
||||
serverErr += c
|
||||
})
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk) => {
|
||||
buffered += chunk
|
||||
for (const line of buffered.split('\n')) {
|
||||
if (!line.startsWith('{')) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const p = JSON.parse(line)
|
||||
if (p.type === 'orca_server_ready') {
|
||||
clearTimeout(timer)
|
||||
res(p)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
/* partial */
|
||||
}
|
||||
}
|
||||
})
|
||||
child.on('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
rej(new Error(`server exited ${code} before ready:\n${serverErr}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Filesystem + git truth about a worktree, read directly rather than through Orca. */
|
||||
function evidence(repoPath, wtPath) {
|
||||
const ls = spawnSync('ls', ['-la', wtPath], { encoding: 'utf8' })
|
||||
const list = spawnSync('git', ['worktree', 'list'], { cwd: repoPath, encoding: 'utf8' })
|
||||
return {
|
||||
dirExists: existsSync(wtPath),
|
||||
fileExists: existsSync(join(wtPath, 'PRECIOUS.txt')),
|
||||
fileBody: existsSync(join(wtPath, 'PRECIOUS.txt'))
|
||||
? readFileSync(join(wtPath, 'PRECIOUS.txt'), 'utf8').trim()
|
||||
: null,
|
||||
registered: (list.stdout ?? '').includes(wtPath),
|
||||
ls: (ls.stdout ?? '').trim(),
|
||||
worktreeList: (list.stdout ?? '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
function showEvidence(e) {
|
||||
out(' --- ls -la <worktree> ---')
|
||||
out(
|
||||
e.ls
|
||||
.split('\n')
|
||||
.map((l) => ` ${l}`)
|
||||
.join('\n')
|
||||
)
|
||||
out(' --- git worktree list (in the repo) ---')
|
||||
out(
|
||||
e.worktreeList
|
||||
.split('\n')
|
||||
.map((l) => ` ${l}`)
|
||||
.join('\n')
|
||||
)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const userDataDir = mkdtempSync(join(tmpdir(), 'agh-userdata-'))
|
||||
out(`booting headless runtime on port ${PORT}, userData ${userDataDir}`)
|
||||
const child = spawn(
|
||||
'npx',
|
||||
[
|
||||
'electron',
|
||||
serveEntry,
|
||||
'--serve',
|
||||
'--serve-port',
|
||||
String(PORT),
|
||||
'--serve-json',
|
||||
`--user-data-dir=${userDataDir}`
|
||||
],
|
||||
{
|
||||
cwd: projectDir,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }
|
||||
}
|
||||
)
|
||||
const created = []
|
||||
|
||||
try {
|
||||
const ready = await waitForReady(child)
|
||||
pairingCode = new URL(ready.pairing.url).searchParams.get('code')
|
||||
out(`ready: ${ready.advertisedEndpoint}`)
|
||||
|
||||
// ---------------------------------------------------------------- setup
|
||||
const hookRepoPath = seedGitRepo('hooked', true)
|
||||
const folderProjectSlug = `agh-folder-proof-${randomBytes(3).toString('hex')}`
|
||||
const bareRepoPath = seedGitRepo('nohook', false, folderProjectSlug)
|
||||
const hookRepo = ok(['repo', 'add', '--path', hookRepoPath]).repo
|
||||
const bareRepo = ok(['repo', 'add', '--path', bareRepoPath]).repo
|
||||
out(`repo with archive hook: ${hookRepoPath} (${hookRepo.id})`)
|
||||
out(`repo without archive hook: ${bareRepoPath} (${bareRepo.id})`)
|
||||
|
||||
const makeWorktree = (repo, repoPath, name) => {
|
||||
const wt = ok([
|
||||
'worktree',
|
||||
'create',
|
||||
'--repo',
|
||||
`id:${repo.id}`,
|
||||
'--name',
|
||||
name,
|
||||
'--setup',
|
||||
'skip'
|
||||
]).worktree
|
||||
created.push(wt)
|
||||
const unarchivedBody = `unarchived work for ${name}`
|
||||
writeFileSync(join(wt.path, 'PRECIOUS.txt'), `${unarchivedBody}\n`)
|
||||
return { ...wt, repoPath, unarchivedBody }
|
||||
}
|
||||
|
||||
// ============================================================ SCENARIO 1
|
||||
banner('SCENARIO 1 — archive hook exits 23: removal MUST be refused, nothing deleted')
|
||||
setMode('fail')
|
||||
const wt1 = makeWorktree(hookRepo, hookRepoPath, `gate-fail-${randomBytes(3).toString('hex')}`)
|
||||
out(`worktree: ${wt1.path}`)
|
||||
const before = evidence(wt1.repoPath, wt1.path)
|
||||
|
||||
out('\n$ orca worktree rm --worktree <id> --run-hooks (human output)')
|
||||
const human = cli(['worktree', 'rm', '--worktree', wt1.id, '--run-hooks'], { json: false })
|
||||
out(` exit code: ${human.status}`)
|
||||
out(' --- stdout ---')
|
||||
out(
|
||||
(human.stdout ?? '')
|
||||
.trimEnd()
|
||||
.split('\n')
|
||||
.map((l) => ` ${l}`)
|
||||
.join('\n')
|
||||
)
|
||||
out(' --- stderr ---')
|
||||
out(
|
||||
(human.stderr ?? '')
|
||||
.trimEnd()
|
||||
.split('\n')
|
||||
.map((l) => ` ${l}`)
|
||||
.join('\n')
|
||||
)
|
||||
|
||||
out(
|
||||
'\n$ orca worktree rm --worktree <id> --force --run-hooks --json (--force must NOT waive)'
|
||||
)
|
||||
const forced = cli(['worktree', 'rm', '--worktree', wt1.id, '--force', '--run-hooks'])
|
||||
const forcedJson = parseJsonLine(forced)
|
||||
out(` exit code: ${forced.status}`)
|
||||
out(` ${JSON.stringify(forcedJson)}`)
|
||||
|
||||
const after1 = evidence(wt1.repoPath, wt1.path)
|
||||
showEvidence(after1)
|
||||
|
||||
check('CLI exits non-zero', human.status !== 0, `got ${human.status}`)
|
||||
check(
|
||||
'human stderr names the archive hook',
|
||||
/Archive hook failed for worktree/.test(human.stderr ?? '')
|
||||
)
|
||||
check('--force also refused (non-zero)', forced.status !== 0, `got ${forced.status}`)
|
||||
check(
|
||||
'typed error code',
|
||||
forcedJson?.error?.code === 'worktree_archive_hook_failed',
|
||||
JSON.stringify(forcedJson?.error?.code)
|
||||
)
|
||||
check(
|
||||
"error data outcome is 'exited'",
|
||||
forcedJson?.error?.data?.outcome === 'exited',
|
||||
JSON.stringify(forcedJson?.error?.data)
|
||||
)
|
||||
check('error data carries exitCode 23', forcedJson?.error?.data?.exitCode === 23)
|
||||
check('checkout directory still exists', after1.dirExists)
|
||||
// Assert the CONTENTS, not just the path: a file that survived as an empty stub would prove
|
||||
// nothing about the work the archive hook was supposed to rescue.
|
||||
check(
|
||||
'unarchived file PRECIOUS.txt survives with its contents',
|
||||
after1.fileExists && after1.fileBody === wt1.unarchivedBody,
|
||||
`exists=${after1.fileExists} body=${JSON.stringify(after1.fileBody)}`
|
||||
)
|
||||
check('git worktree registration survives', after1.registered)
|
||||
check(
|
||||
'nothing changed vs. before the attempt',
|
||||
before.dirExists === after1.dirExists && before.registered === after1.registered
|
||||
)
|
||||
const shown = ok(['worktree', 'show', '--worktree', wt1.id]).worktree
|
||||
check('Orca still resolves the worktree', shown?.id === wt1.id)
|
||||
check(
|
||||
'the hook really ran (twice: plain + --force)',
|
||||
hookRuns().length >= 2,
|
||||
`runs=${hookRuns().length}`
|
||||
)
|
||||
// The checkout is dirty (untracked PRECIOUS.txt). The plain run reported the ARCHIVE failure,
|
||||
// not the dirty-preflight failure, so the gate is evaluated before that preflight.
|
||||
check(
|
||||
'archive gate precedes the dirty preflight (dirty checkout, archive error reported)',
|
||||
/Archive hook failed/.test(human.stderr ?? '') &&
|
||||
!/\?\? PRECIOUS\.txt/.test(human.stderr ?? '')
|
||||
)
|
||||
|
||||
// ============================================================ SCENARIO 2
|
||||
banner('SCENARIO 2 — --allow-failed-archive-hook: removal proceeds, waiver recorded')
|
||||
// --force here waives only the DIRTY preflight (PRECIOUS.txt is untracked on purpose);
|
||||
// scenario 1 already proved it does not waive the archive gate.
|
||||
const waived = cli([
|
||||
'worktree',
|
||||
'rm',
|
||||
'--worktree',
|
||||
wt1.id,
|
||||
'--force',
|
||||
'--run-hooks',
|
||||
'--allow-failed-archive-hook'
|
||||
])
|
||||
const waivedJson = parseJsonLine(waived)
|
||||
out(` exit code: ${waived.status}`)
|
||||
out(` ${JSON.stringify(waivedJson)}`)
|
||||
const after2 = evidence(wt1.repoPath, wt1.path)
|
||||
out(` checkout still on disk: ${after2.dirExists}`)
|
||||
out(
|
||||
` --- git worktree list ---\n${after2.worktreeList
|
||||
.split('\n')
|
||||
.map((l) => ` ${l}`)
|
||||
.join('\n')}`
|
||||
)
|
||||
check('override exits zero', waived.status === 0, `got ${waived.status}`)
|
||||
check('removal reported', waivedJson?.result?.removed === true)
|
||||
check('checkout is GONE', !after2.dirExists)
|
||||
check('git registration is gone', !after2.registered)
|
||||
check(
|
||||
'archiveHookOverride recorded',
|
||||
waivedJson?.result?.archiveHookOverride?.overridden === true,
|
||||
JSON.stringify(waivedJson?.result?.archiveHookOverride)
|
||||
)
|
||||
check(
|
||||
'override records exit 23 / exited',
|
||||
waivedJson?.result?.archiveHookOverride?.exitCode === 23 &&
|
||||
waivedJson?.result?.archiveHookOverride?.outcome === 'exited'
|
||||
)
|
||||
|
||||
// ============================================================ SCENARIO 3
|
||||
banner('SCENARIO 3 — archive hook exits 0: removal proceeds')
|
||||
setMode('ok')
|
||||
const wt3 = makeWorktree(hookRepo, hookRepoPath, `gate-ok-${randomBytes(3).toString('hex')}`)
|
||||
out(`worktree: ${wt3.path}`)
|
||||
const okRm = cli(['worktree', 'rm', '--worktree', wt3.id, '--force', '--run-hooks'])
|
||||
const okJson = parseJsonLine(okRm)
|
||||
out(` exit code: ${okRm.status}`)
|
||||
out(` ${JSON.stringify(okJson)}`)
|
||||
const after3 = evidence(wt3.repoPath, wt3.path)
|
||||
check('exits zero', okRm.status === 0)
|
||||
check('checkout deleted', !after3.dirExists)
|
||||
check('git registration gone', !after3.registered)
|
||||
check(
|
||||
'no archiveHookOverride on a clean run',
|
||||
okJson?.result?.archiveHookOverride === undefined
|
||||
)
|
||||
|
||||
// ============================================================ SCENARIO 4
|
||||
banner('SCENARIO 4 — no archive hook configured: removal proceeds unchanged')
|
||||
const wt4 = makeWorktree(
|
||||
bareRepo,
|
||||
bareRepoPath,
|
||||
`gate-nohook-${randomBytes(3).toString('hex')}`
|
||||
)
|
||||
out(`worktree: ${wt4.path}`)
|
||||
const runsBefore = hookRuns().length
|
||||
const noHook = cli(['worktree', 'rm', '--worktree', wt4.id, '--force', '--run-hooks'])
|
||||
const noHookJson = parseJsonLine(noHook)
|
||||
out(` exit code: ${noHook.status}`)
|
||||
out(` ${JSON.stringify(noHookJson)}`)
|
||||
const after4 = evidence(wt4.repoPath, wt4.path)
|
||||
check('exits zero', noHook.status === 0)
|
||||
check('checkout deleted', !after4.dirExists)
|
||||
check('no hook was run', hookRuns().length === runsBefore)
|
||||
|
||||
// ============================================================ SCENARIO 5
|
||||
banner('SCENARIO 5 — hook never reports an exit (killed): must BLOCK as `unverifiable`')
|
||||
setMode('signal')
|
||||
const wt5 = makeWorktree(hookRepo, hookRepoPath, `gate-unver-${randomBytes(3).toString('hex')}`)
|
||||
out(`worktree: ${wt5.path}`)
|
||||
const unver = cli(['worktree', 'rm', '--worktree', wt5.id, '--run-hooks'])
|
||||
const unverJson = parseJsonLine(unver)
|
||||
out(` exit code: ${unver.status}`)
|
||||
out(` ${JSON.stringify(unverJson)}`)
|
||||
const after5 = evidence(wt5.repoPath, wt5.path)
|
||||
showEvidence(after5)
|
||||
check('blocked (non-zero)', unver.status !== 0, `got ${unver.status}`)
|
||||
check('typed error code', unverJson?.error?.code === 'worktree_archive_hook_failed')
|
||||
check(
|
||||
"outcome is 'unverifiable', NOT 'exited'",
|
||||
unverJson?.error?.data?.outcome === 'unverifiable',
|
||||
JSON.stringify(unverJson?.error?.data)
|
||||
)
|
||||
check(
|
||||
'exit code is WITHHELD (never read as a pass)',
|
||||
unverJson?.error?.data?.exitCode === undefined
|
||||
)
|
||||
check('checkout survives', after5.dirExists && after5.fileExists)
|
||||
check('git registration survives', after5.registered)
|
||||
|
||||
// clean up scenario 5 with the waiver so the temp dirs go away
|
||||
setMode('ok')
|
||||
cli(['worktree', 'rm', '--worktree', wt5.id, '--force'])
|
||||
|
||||
// ============================================================ SCENARIO 6
|
||||
banner('SCENARIO 6 — folder workspace removal (the boundary that runs no hook) is unchanged')
|
||||
const folderDir = mkdtempSync(join(tmpdir(), 'agh-folder-'))
|
||||
mkdirSync(join(folderDir, 'src'))
|
||||
writeFileSync(join(folderDir, 'src', 'app.txt'), 'folder workspace content\n')
|
||||
// A folder workspace is imported against an existing project identity, so anchor it on the
|
||||
// hookless repo's GitHub-derived project.
|
||||
const folderProjectId = `github:agh-owner/${folderProjectSlug}`
|
||||
ok([
|
||||
'project',
|
||||
'setup-existing-folder',
|
||||
'--project',
|
||||
folderProjectId,
|
||||
'--host',
|
||||
'local',
|
||||
'--path',
|
||||
folderDir,
|
||||
'--kind',
|
||||
'folder'
|
||||
])
|
||||
const folderRepo = (ok(['repo', 'list']).repos ?? []).find((r) => r.path === folderDir) ?? null
|
||||
out(`folder repo: ${folderDir} (${folderRepo?.id}) kind=${folderRepo?.kind}`)
|
||||
check('registered repo kind is folder', folderRepo?.kind === 'folder', String(folderRepo?.kind))
|
||||
// The project ROOT of a folder project is not deletable (pre-existing rule, unrelated to the
|
||||
// gate); the deletable folder workspace is a child created under it.
|
||||
const folderRoot = ok(['worktree', 'show', '--worktree', `path:${folderDir}`]).worktree
|
||||
const rootRm = cli(['worktree', 'rm', '--worktree', folderRoot.id, '--force', '--run-hooks'])
|
||||
out(
|
||||
` root refusal (unchanged): exit ${rootRm.status} ${parseJsonLine(rootRm)?.error?.code} -- ${parseJsonLine(rootRm)?.error?.message}`
|
||||
)
|
||||
check(
|
||||
'folder project root still refuses for its own reason, not the archive gate',
|
||||
rootRm.status !== 0 && parseJsonLine(rootRm)?.error?.code !== 'worktree_archive_hook_failed'
|
||||
)
|
||||
|
||||
const folderChild = ok([
|
||||
'worktree',
|
||||
'create',
|
||||
'--repo',
|
||||
`id:${folderRepo.id}`,
|
||||
'--name',
|
||||
'agh-folder-child',
|
||||
'--setup',
|
||||
'skip'
|
||||
]).worktree
|
||||
out(`folder workspace: ${folderChild.id}`)
|
||||
const runsBeforeFolder = hookRuns().length
|
||||
out(`\n$ orca worktree rm --worktree <folder workspace> --force --run-hooks`)
|
||||
const folderRm = cli(['worktree', 'rm', '--worktree', folderChild.id, '--force', '--run-hooks'])
|
||||
const folderJson = parseJsonLine(folderRm)
|
||||
out(` exit code: ${folderRm.status}`)
|
||||
out(` ${JSON.stringify(folderJson)}`)
|
||||
const stillThere = cli(['worktree', 'show', '--worktree', folderChild.id])
|
||||
check(
|
||||
'folder workspace removal exits zero',
|
||||
folderRm.status === 0,
|
||||
`${folderRm.status} ${folderRm.stderr}`
|
||||
)
|
||||
check('folder removal ran no archive hook', hookRuns().length === runsBeforeFolder)
|
||||
check(
|
||||
'folder contents left on disk (forget, not delete)',
|
||||
existsSync(join(folderDir, 'src', 'app.txt'))
|
||||
)
|
||||
check(
|
||||
'folder workspace is deregistered',
|
||||
stillThere.status !== 0 && parseJsonLine(stillThere)?.error?.code === 'selector_not_found'
|
||||
)
|
||||
rmSync(folderDir, { recursive: true, force: true })
|
||||
|
||||
banner(failures === 0 ? 'ALL CHECKS PASSED' : `${failures} CHECK(S) FAILED`)
|
||||
} catch (error) {
|
||||
out(`\nHARNESS ERROR: ${error instanceof Error ? error.stack : String(error)}`)
|
||||
failures++
|
||||
} finally {
|
||||
for (const wt of created) {
|
||||
if (existsSync(wt.path)) {
|
||||
cli(['worktree', 'rm', '--worktree', wt.id, '--force'])
|
||||
rmSync(dirname(wt.path), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGTERM')
|
||||
await Promise.race([
|
||||
new Promise((r) => child.on('exit', r)),
|
||||
new Promise((r) => setTimeout(r, 15_000))
|
||||
])
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
}
|
||||
process.exitCode = failures === 0 ? 0 : 1
|
||||
}
|
||||
|
||||
setMode('ok')
|
||||
main()
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
formatArchiveHookOverride,
|
||||
type ArchiveHookOverride
|
||||
} from '../../shared/worktree/archive-hook-removal-gate'
|
||||
|
||||
type HookWarningResult = {
|
||||
warning?: string
|
||||
archiveHookOverride?: ArchiveHookOverride
|
||||
}
|
||||
|
||||
type PreservedBranchResult = {
|
||||
preservedBranch?: {
|
||||
branchName: string
|
||||
}
|
||||
}
|
||||
|
||||
export function printHookWarning(result: HookWarningResult, json: boolean): void {
|
||||
if (json) {
|
||||
return
|
||||
}
|
||||
if (result.warning) {
|
||||
console.error(`warning: ${result.warning}`)
|
||||
}
|
||||
// Why (#19334): a waived archive-hook failure is the one case where Orca deleted a checkout
|
||||
// whose archive step did not succeed. It has to stay visible in human output.
|
||||
if (result.archiveHookOverride) {
|
||||
console.error(`warning: ${formatArchiveHookOverride(result.archiveHookOverride)}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function printPreservedBranchWarning(result: PreservedBranchResult, json: boolean): void {
|
||||
if (!json && result.preservedBranch) {
|
||||
console.error(
|
||||
`warning: local branch "${result.preservedBranch.branchName}" was kept because Git could not safely delete it`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
RuntimeWorktreeRemoveResult
|
||||
} from '../../shared/runtime-types'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { printHookWarning, printPreservedBranchWarning } from './worktree-removal-warnings'
|
||||
import { formatWorktreeList, formatWorktreePs, formatWorktreeShow, printResult } from '../format'
|
||||
import {
|
||||
annotateOmittedHostScope,
|
||||
@@ -38,30 +39,6 @@ import {
|
||||
} from './worktree-create-parent-selector'
|
||||
import { getOptionalLinearIssueLinkFlag } from './worktree-linear-issue-link'
|
||||
|
||||
type HookWarningResult = {
|
||||
warning?: string
|
||||
}
|
||||
|
||||
type PreservedBranchResult = {
|
||||
preservedBranch?: {
|
||||
branchName: string
|
||||
}
|
||||
}
|
||||
|
||||
function printHookWarning(result: HookWarningResult, json: boolean): void {
|
||||
if (!json && result.warning) {
|
||||
console.error(`warning: ${result.warning}`)
|
||||
}
|
||||
}
|
||||
|
||||
function printPreservedBranchWarning(result: PreservedBranchResult, json: boolean): void {
|
||||
if (!json && result.preservedBranch) {
|
||||
console.error(
|
||||
`warning: local branch "${result.preservedBranch.branchName}" was kept because Git could not safely delete it`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertParentWorktreeFlagsCompatible(flags: Map<string, string | boolean>): void {
|
||||
if (flags.has('parent-worktree') && flags.get('no-parent') === true) {
|
||||
throw new RuntimeClientError(
|
||||
@@ -305,13 +282,24 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
|
||||
'Orca cannot tell which host owns this workspace. Refresh projects and try again.'
|
||||
)
|
||||
}
|
||||
// Why (#19334): the waiver only ever applies to a hook that ran, so without --run-hooks it
|
||||
// silently does nothing. Rejecting it beats letting someone believe they waived something.
|
||||
if (flags.get('allow-failed-archive-hook') === true && flags.get('run-hooks') !== true) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--allow-failed-archive-hook waives a FAILED archive hook, but without --run-hooks no hook runs at all. Pass --run-hooks too, or drop the waiver.'
|
||||
)
|
||||
}
|
||||
const result = await client.call<RuntimeWorktreeRemoveResult>('worktree.rm', {
|
||||
worktree,
|
||||
hostId,
|
||||
force: flags.get('force') === true,
|
||||
// Why (#11960): --force is explicit here, so it may also waive PTY-stop proof.
|
||||
allowUnverifiedPtyStop: flags.get('force') === true,
|
||||
runHooks: flags.get('run-hooks') === true
|
||||
runHooks: flags.get('run-hooks') === true,
|
||||
// Why (#19334): deliberately NOT coupled to --force, which above already waives PTY-stop
|
||||
// proof. Waiving a failed archive hook is a separate decision about the user's data.
|
||||
allowFailedArchiveHook: flags.get('allow-failed-archive-hook') === true
|
||||
})
|
||||
printHookWarning(result.result, json)
|
||||
printPreservedBranchWarning(result.result, json)
|
||||
|
||||
@@ -135,6 +135,83 @@ describe('command aliases dispatch to the canonical handler', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// #19334: a failed archive hook blocks removal, so the CLI must exit non-zero rather than
|
||||
// report a delete that did not happen — and the waiver must ride its own flag, never --force.
|
||||
it('exits non-zero when worktree removal is refused by a failed archive hook', async () => {
|
||||
queueFixtures(callMock, okFixture('req_show', { worktree: { hostId: 'local' } }))
|
||||
callMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('Archive hook failed for worktree: /tmp/wt — exited 23.'), {
|
||||
code: 'worktree_archive_hook_failed'
|
||||
})
|
||||
)
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
try {
|
||||
await main(
|
||||
['worktree', 'rm', '--worktree', 'id:wt-1', '--force', '--run-hooks', '--json'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'worktree.rm',
|
||||
expect.objectContaining({
|
||||
runHooks: true,
|
||||
allowFailedArchiveHook: false
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
process.exitCode = priorExitCode
|
||||
}
|
||||
})
|
||||
|
||||
// #19334 S4: the waiver only applies to a hook that ran, so alone it silently does nothing.
|
||||
it('rejects the archive-hook waiver without --run-hooks instead of ignoring it', async () => {
|
||||
queueFixtures(callMock, okFixture('req_show', { worktree: { hostId: 'local' } }))
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
try {
|
||||
await main(
|
||||
['worktree', 'rm', '--worktree', 'id:wt-1', '--allow-failed-archive-hook', '--json'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
// The removal must never have been attempted.
|
||||
expect(callMock).not.toHaveBeenCalledWith('worktree.rm', expect.anything())
|
||||
} finally {
|
||||
process.exitCode = priorExitCode
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards the explicit archive-hook waiver on worktree rm', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_show', { worktree: { hostId: 'local' } }),
|
||||
okFixture('req', { removed: true })
|
||||
)
|
||||
|
||||
await main(
|
||||
[
|
||||
'worktree',
|
||||
'rm',
|
||||
'--worktree',
|
||||
'id:wt-1',
|
||||
'--run-hooks',
|
||||
'--allow-failed-archive-hook',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'worktree.rm',
|
||||
expect.objectContaining({ runHooks: true, allowFailedArchiveHook: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('still runs `terminal focus` after the handler de-duplication', async () => {
|
||||
queueFixtures(callMock, okFixture('req', { focus: { ok: true } }))
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [
|
||||
' orca worktree show --worktree <selector> [--json]',
|
||||
' orca worktree current [--json]',
|
||||
' orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--linear-issue <identifier-or-url|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]',
|
||||
' orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]',
|
||||
' orca worktree rm --worktree <selector> [--force] [--run-hooks] [--allow-failed-archive-hook] [--json]',
|
||||
' orca worktree ps [--limit <n>] [--json]',
|
||||
' orca file open <path> [--worktree <selector>] [--json]',
|
||||
' orca file diff <path> [--staged] [--worktree <selector>] [--json]',
|
||||
|
||||
@@ -172,10 +172,13 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
],
|
||||
destructive: true,
|
||||
summary: 'Remove a worktree from Orca and git',
|
||||
usage: 'orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force', 'run-hooks'],
|
||||
usage:
|
||||
'orca worktree rm --worktree <selector> [--force] [--run-hooks] [--allow-failed-archive-hook] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force', 'run-hooks', 'allow-failed-archive-hook'],
|
||||
notes: [
|
||||
'Repo-defined orca.yaml archive hooks are skipped unless --run-hooks is passed.',
|
||||
'With --run-hooks, a failed archive hook blocks the removal: nothing is stopped, deleted or deregistered, and the command exits non-zero with error code worktree_archive_hook_failed. --force does not waive this.',
|
||||
'Pass --allow-failed-archive-hook to delete anyway after the hook has run and failed; the waived failure is reported back on result.archiveHookOverride. It requires --run-hooks and is rejected without it, because with no hook running there is no failure to waive.',
|
||||
'For Git worktrees, removal also attempts to delete the checked-out local branch, with or without --force. Orca retains branches it knows predated the worktree and any branch whose changes it cannot prove are already merged.'
|
||||
]
|
||||
},
|
||||
|
||||
@@ -14,6 +14,13 @@ import {
|
||||
} from './worktrees-test-module-mocks'
|
||||
import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness'
|
||||
import { mockKnownFeatureWorktree } from './worktrees-test-fixtures'
|
||||
import {
|
||||
ARCHIVE_HOOK_FAILED_REMOVAL_CODE,
|
||||
asArchiveHookRefusal,
|
||||
type WorktreeArchiveHookFailedError
|
||||
} from '../../shared/worktree/archive-hook-removal-gate'
|
||||
import type { RemoveWorktreeResult } from '../../shared/worktree/create-types'
|
||||
import type { RemoveWorktreeArgs } from './worktrees/ipc-context-schemas'
|
||||
import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub'
|
||||
|
||||
vi.mock('electron', async () =>
|
||||
@@ -98,6 +105,25 @@ vi.mock('../runtime/worktree-teardown', async () =>
|
||||
)
|
||||
vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock())
|
||||
|
||||
// The shared IPC surface types every handler as returning `unknown`; removal's contract is
|
||||
// narrower, and #19334's whole point is that a caller can name and branch on it.
|
||||
async function removeWorktreeViaIpc(args: RemoveWorktreeArgs): Promise<RemoveWorktreeResult> {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry types every handler as `(...) => unknown`, so this is the only place the real `worktrees:remove` return shape can be named; the production caller in worktree-ipc.ts declares the same type.
|
||||
return (await handlers['worktrees:remove'](null, args)) as RemoveWorktreeResult
|
||||
}
|
||||
|
||||
/** Narrows through the exported error class — the same branch a real caller would write. */
|
||||
async function expectArchiveHookRefusal(
|
||||
args: RemoveWorktreeArgs
|
||||
): Promise<WorktreeArchiveHookFailedError> {
|
||||
try {
|
||||
await removeWorktreeViaIpc(args)
|
||||
} catch (error) {
|
||||
return asArchiveHookRefusal(error)
|
||||
}
|
||||
throw new Error(`expected removal of ${args.worktreeId} to be refused by the archive hook`)
|
||||
}
|
||||
|
||||
describe('registerWorktreeHandlers', () => {
|
||||
let runtimeStub: WorktreeRuntimeStub
|
||||
|
||||
@@ -443,7 +469,8 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', true)
|
||||
})
|
||||
|
||||
it('continues SSH worktree removal when the archive hook fails', async () => {
|
||||
// Was "continues SSH worktree removal when the archive hook fails" (#19334): it now refuses.
|
||||
it('refuses SSH worktree removal when the remote archive hook exits non-zero', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
@@ -453,7 +480,6 @@ describe('registerWorktreeHandlers', () => {
|
||||
connectionId: 'conn-1',
|
||||
worktreeBaseRef: null
|
||||
}
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const provider = {
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
@@ -492,21 +518,18 @@ describe('registerWorktreeHandlers', () => {
|
||||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||||
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { archive: 'exit 7' } })
|
||||
|
||||
try {
|
||||
await handlers['worktrees:remove'](null, {
|
||||
worktreeId: 'repo-ssh::/remote/feature-wt'
|
||||
})
|
||||
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[hooks] archive hook failed for /remote/feature-wt:',
|
||||
expect.stringContaining('archive hook exited 7')
|
||||
)
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore()
|
||||
}
|
||||
const refusal = await expectArchiveHookRefusal({
|
||||
worktreeId: 'repo-ssh::/remote/feature-wt'
|
||||
})
|
||||
|
||||
expect(refusal.code).toBe(ARCHIVE_HOOK_FAILED_REMOVAL_CODE)
|
||||
expect(refusal.data).toMatchObject({ outcome: 'exited', exitCode: 7 })
|
||||
expect(provider.worktreeIsClean).not.toHaveBeenCalled()
|
||||
expect(provider.removeWorktree).not.toHaveBeenCalled()
|
||||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('continues SSH worktree removal when archive hook execution rejects', async () => {
|
||||
it('does not read a lost SSH connection as an archive hook that passed', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
@@ -516,7 +539,6 @@ describe('registerWorktreeHandlers', () => {
|
||||
connectionId: 'conn-1',
|
||||
worktreeBaseRef: null
|
||||
}
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const provider = {
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
@@ -550,18 +572,15 @@ describe('registerWorktreeHandlers', () => {
|
||||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||||
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { archive: 'echo archived' } })
|
||||
|
||||
try {
|
||||
await handlers['worktrees:remove'](null, {
|
||||
worktreeId: 'repo-ssh::/remote/feature-wt'
|
||||
})
|
||||
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[hooks] archive hook failed for /remote/feature-wt:',
|
||||
'relay disconnected'
|
||||
)
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore()
|
||||
}
|
||||
const refusal = await expectArchiveHookRefusal({
|
||||
worktreeId: 'repo-ssh::/remote/feature-wt'
|
||||
})
|
||||
|
||||
// Loss of contact is `unverifiable`, never evidence the hook succeeded.
|
||||
expect(refusal.data).toMatchObject({ outcome: 'unverifiable' })
|
||||
expect(refusal.data.exitCode).toBeUndefined()
|
||||
expect(provider.removeWorktree).not.toHaveBeenCalled()
|
||||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses cmd.exe for archive hooks on Windows-like SSH worktree paths', async () => {
|
||||
@@ -681,4 +700,116 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect(provider.execNonInteractive).not.toHaveBeenCalled()
|
||||
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
|
||||
})
|
||||
|
||||
// Regression cover for #19334: a failed archive hook is a blocking precondition, not an advisory.
|
||||
it('refuses removal and mutates nothing when the local archive hook exits 23', async () => {
|
||||
mockKnownFeatureWorktree()
|
||||
removeWorktreeMock.mockResolvedValue(undefined)
|
||||
getEffectiveHooksMock.mockReturnValue({
|
||||
scripts: { archive: 'echo archived' }
|
||||
})
|
||||
runHookMock.mockResolvedValue({
|
||||
success: false,
|
||||
output: 'backup target unreachable',
|
||||
exitCode: 23
|
||||
})
|
||||
|
||||
const refusal = await expectArchiveHookRefusal({
|
||||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||||
})
|
||||
|
||||
expect(refusal.code).toBe(ARCHIVE_HOOK_FAILED_REMOVAL_CODE)
|
||||
expect(refusal.data).toEqual({
|
||||
worktreePath: '/workspace/feature-wt',
|
||||
outcome: 'exited',
|
||||
exitCode: 23,
|
||||
output: 'backup target unreachable'
|
||||
})
|
||||
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(assertWorktreeCleanForRemovalMock).not.toHaveBeenCalled()
|
||||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(removeWorktreeLinkedPathsMock).not.toHaveBeenCalled()
|
||||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('classifies a local archive hook that never reported an exit as unverifiable', async () => {
|
||||
mockKnownFeatureWorktree()
|
||||
getEffectiveHooksMock.mockReturnValue({
|
||||
scripts: { archive: 'echo archived' }
|
||||
})
|
||||
runHookMock.mockResolvedValue({
|
||||
success: false,
|
||||
output: 'Hook timed out after 120000ms.'
|
||||
})
|
||||
|
||||
const refusal = await expectArchiveHookRefusal({
|
||||
worktreeId: 'repo-1::/workspace/feature-wt'
|
||||
})
|
||||
|
||||
expect(refusal.data).toEqual({
|
||||
worktreePath: '/workspace/feature-wt',
|
||||
outcome: 'unverifiable',
|
||||
output: 'Hook timed out after 120000ms.'
|
||||
})
|
||||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes and records the waiver when a failed archive hook is explicitly overridden', async () => {
|
||||
mockKnownFeatureWorktree()
|
||||
removeWorktreeMock.mockResolvedValue({})
|
||||
getEffectiveHooksMock.mockReturnValue({
|
||||
scripts: { archive: 'echo archived' }
|
||||
})
|
||||
runHookMock.mockResolvedValue({
|
||||
success: false,
|
||||
output: 'boom',
|
||||
exitCode: 23
|
||||
})
|
||||
|
||||
const result = await removeWorktreeViaIpc({
|
||||
worktreeId: 'repo-1::/workspace/feature-wt',
|
||||
allowFailedArchiveHook: true
|
||||
})
|
||||
|
||||
expect(result.archiveHookOverride).toEqual({
|
||||
worktreePath: '/workspace/feature-wt',
|
||||
outcome: 'exited',
|
||||
exitCode: 23,
|
||||
output: 'boom',
|
||||
overridden: true
|
||||
})
|
||||
expect(removeWorktreeMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The folder-workspace path runs no archive hook at all (no Git removal step), so the gate has
|
||||
// nothing to evaluate there. Pinned so a future hook added to that path is a deliberate change.
|
||||
it('removes a folder workspace without consulting the archive hook', async () => {
|
||||
const repo = {
|
||||
id: 'repo-folder',
|
||||
path: '/workspace/folder-project',
|
||||
displayName: 'folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'folder' as const,
|
||||
worktreeBaseRef: null
|
||||
}
|
||||
store.getRepos.mockReturnValue([repo])
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
getEffectiveHooksMock.mockReturnValue({ scripts: { archive: 'exit 23' } })
|
||||
runHookMock.mockResolvedValue({
|
||||
success: false,
|
||||
output: 'boom',
|
||||
exitCode: 23
|
||||
})
|
||||
|
||||
const result = await removeWorktreeViaIpc({
|
||||
worktreeId: 'repo-folder::/workspace/folder-project/nested'
|
||||
})
|
||||
|
||||
expect(result).toEqual({})
|
||||
expect(runHookMock).not.toHaveBeenCalled()
|
||||
expect(removeWorktreeMock).not.toHaveBeenCalled()
|
||||
expect(store.removeWorktreeMeta).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,9 @@ export type RemoveWorktreeArgs = {
|
||||
/** Explicit Force Delete only — `force` alone is set by the ordinary confirmation (#11960). */
|
||||
allowUnverifiedPtyStop?: boolean
|
||||
skipArchive?: boolean
|
||||
/** Explicit waiver for a FAILED archive hook (#19334). Distinct from `skipArchive`, which
|
||||
* never runs the hook at all, and never implied by `force`. */
|
||||
allowFailedArchiveHook?: boolean
|
||||
snapshotPruneBatchId?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import { isPrunableGitFileWorktree } from '../../../worktree-prunable-git-file'
|
||||
import { findRegisteredDeletableWorktree } from '../../../worktree-removal-safety'
|
||||
import { removeStaleLocalWorktreeRegistration } from '../../../local-worktree-removal-recovery'
|
||||
import { runHook } from '../../../hooks'
|
||||
import type { ArchiveHookOverride } from '../../../../shared/worktree/archive-hook-removal-gate'
|
||||
import { gateWorktreeRemovalOnArchiveHook } from '../../../worktree-archive-hook-gate'
|
||||
import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation'
|
||||
import {
|
||||
cleanupUnusedWorktreePushTargetRemote,
|
||||
@@ -126,10 +128,18 @@ export async function executeWorktreeRemoval(
|
||||
return removalResult ?? {}
|
||||
}
|
||||
|
||||
// No connectionId override here, deliberately: this path derives its host from the repo row
|
||||
// (`getRepoExecutionHostId` in register-worktree-removal-handlers) and resolves its provider, git
|
||||
// options, listing and dispatch from `repo.connectionId` alone. Passing a different owner to the
|
||||
// hook reader would read one host's orca.yaml while running the other host's git. The runtime's
|
||||
// SSH path is the one that carries a route owner separate from the row, and it passes it.
|
||||
const hooks = await getArchiveHooksForRemoval(repo)
|
||||
|
||||
const archiveScript = hooks?.scripts.archive
|
||||
|
||||
// Precondition, not an advisory (#19334): both branches below stop PTYs and delete the
|
||||
// checkout, so a hook failure has to throw here — before either is reached.
|
||||
let archiveHookOverride: ArchiveHookOverride | undefined
|
||||
if (archiveScript && !args.skipArchive) {
|
||||
// Why the branch on connectionId: this block is shared by both flows, so a hardcoded
|
||||
// 'remote' would file every local archive hook under the SSH breakdown.
|
||||
@@ -146,38 +156,40 @@ export async function executeWorktreeRemoval(
|
||||
undefined,
|
||||
localWorktreeGitOptions
|
||||
)
|
||||
if (!result.success) {
|
||||
console.error(`[hooks] archive hook failed for ${canonicalWorktreePath}:`, result.output)
|
||||
}
|
||||
archiveHookOverride = gateWorktreeRemovalOnArchiveHook({
|
||||
worktreePath: canonicalWorktreePath,
|
||||
result,
|
||||
allowFailure: args.allowFailedArchiveHook === true
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const remoteConnectionId = repo.connectionId ?? undefined
|
||||
if (remoteConnectionId) {
|
||||
return removeRegisteredRemoteWorktree(
|
||||
context,
|
||||
args,
|
||||
repo,
|
||||
repoId,
|
||||
canonicalWorktreePath,
|
||||
removalHostId,
|
||||
registeredWorktree,
|
||||
removedPushTarget,
|
||||
provider!,
|
||||
deleteBranch
|
||||
)
|
||||
}
|
||||
return removeRegisteredLocalWorktree(
|
||||
context,
|
||||
args,
|
||||
repo,
|
||||
repoId,
|
||||
canonicalWorktreePath,
|
||||
removalHostId,
|
||||
removedPushTarget,
|
||||
localWorktreeGitOptions,
|
||||
hasLocalWorktreeGitOptions,
|
||||
deleteBranch
|
||||
)
|
||||
const result = remoteConnectionId
|
||||
? await removeRegisteredRemoteWorktree(
|
||||
context,
|
||||
args,
|
||||
repo,
|
||||
repoId,
|
||||
canonicalWorktreePath,
|
||||
removalHostId,
|
||||
registeredWorktree,
|
||||
removedPushTarget,
|
||||
provider!,
|
||||
deleteBranch
|
||||
)
|
||||
: await removeRegisteredLocalWorktree(
|
||||
context,
|
||||
args,
|
||||
repo,
|
||||
repoId,
|
||||
canonicalWorktreePath,
|
||||
removalHostId,
|
||||
removedPushTarget,
|
||||
localWorktreeGitOptions,
|
||||
hasLocalWorktreeGitOptions,
|
||||
deleteBranch
|
||||
)
|
||||
return archiveHookOverride ? { ...result, archiveHookOverride } : result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
|
||||
const { getSshFilesystemProviderMock, getEffectiveHooksMock } = vi.hoisted(() => ({
|
||||
getSshFilesystemProviderMock: vi.fn(),
|
||||
getEffectiveHooksMock: vi.fn()
|
||||
}))
|
||||
vi.mock('../../../providers/ssh-filesystem-dispatch', () => ({
|
||||
getSshFilesystemProvider: getSshFilesystemProviderMock
|
||||
}))
|
||||
vi.mock('../../../hooks', () => ({ getEffectiveHooks: getEffectiveHooksMock }))
|
||||
|
||||
import { getArchiveHooksForRemoval } from './worktree-archive-hook'
|
||||
|
||||
const REMOTE_REPO: Repo = {
|
||||
id: 'r',
|
||||
path: '/home/orca/repo',
|
||||
displayName: 'r',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
|
||||
// Why (#19334): a worktree row can name its owner only as `executionHostId: 'ssh:<target>'`, leaving
|
||||
// `repo.connectionId` null. Resolving hooks off the row alone then reads THIS machine's disk for a
|
||||
// repo that lives on an SSH host — the committed archive hook goes unseen and the removal proceeds
|
||||
// as though none were configured, which is the bug the gate exists to stop.
|
||||
describe('getArchiveHooksForRemoval owner resolution', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
getSshFilesystemProviderMock.mockReturnValue(undefined)
|
||||
getEffectiveHooksMock.mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('asks the execution host when only the route knows the connection', async () => {
|
||||
await getArchiveHooksForRemoval(REMOTE_REPO, 'ssh-target')
|
||||
|
||||
expect(getSshFilesystemProviderMock).toHaveBeenCalledWith('ssh-target')
|
||||
// The local reader must never answer for a repo that lives on another host.
|
||||
expect(getEffectiveHooksMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the repo row when the caller names no owner', async () => {
|
||||
await getArchiveHooksForRemoval({ ...REMOTE_REPO, connectionId: 'row-connection' })
|
||||
|
||||
expect(getSshFilesystemProviderMock).toHaveBeenCalledWith('row-connection')
|
||||
expect(getEffectiveHooksMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads locally only when neither names a connection', async () => {
|
||||
await getArchiveHooksForRemoval({ ...REMOTE_REPO, path: '/local/repo' })
|
||||
|
||||
expect(getSshFilesystemProviderMock).not.toHaveBeenCalled()
|
||||
expect(getEffectiveHooksMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Known limitation, pinned so it is a decision rather than a surprise: the relay rewrites a
|
||||
// non-numeric error code to -32000, so a missing orca.yaml and an unreachable host arrive
|
||||
// identically. Both answer "no hook", which lets the removal proceed. Reporting them apart needs
|
||||
// a provider contract that returns absence as a successful outcome — tracked in #20196.
|
||||
it('answers "no hook" when the host cannot be read, missing or unreachable alike', async () => {
|
||||
getSshFilesystemProviderMock.mockReturnValue({
|
||||
readFile: vi.fn().mockRejectedValue(
|
||||
Object.assign(new Error('transport closed'), {
|
||||
code: -32000
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
await expect(getArchiveHooksForRemoval(REMOTE_REPO, 'ssh-target')).resolves.toEqual(null)
|
||||
})
|
||||
})
|
||||
@@ -7,15 +7,34 @@ import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-disp
|
||||
import { requireSshGitProvider } from '../../../providers/ssh-git-dispatch'
|
||||
import { joinWorktreeRelativePath } from '../../../runtime/runtime-relative-paths'
|
||||
import { getSetupRunnerEnvVars } from '../../../setup-hook-env-vars'
|
||||
import type { ArchiveHookRunResult } from '../../../../shared/worktree/archive-hook-removal-gate'
|
||||
|
||||
const WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS = 120_000
|
||||
|
||||
export async function getArchiveHooksForRemoval(repo: Repo): Promise<OrcaHooks | null> {
|
||||
if (!repo.connectionId) {
|
||||
/**
|
||||
* Resolve the archive hook against the host that owns the worktree.
|
||||
*
|
||||
* A failed read is answered as "no hook", which is a known limitation rather than a judgement: a
|
||||
* missing `orca.yaml` is indistinguishable from an unreachable one here, because the relay rewrites
|
||||
* a non-numeric error code to `-32000` (`src/relay/dispatcher-rpc-routing.ts`), so nothing survives
|
||||
* to tell ENOENT from a transport failure. Reporting it as unreadable fired on every SSH repo that
|
||||
* simply has no orca.yaml; blocking on it would refuse those deletes outright. Distinguishing the
|
||||
* two needs a provider contract that reports absence as a successful outcome — tracked in #20196.
|
||||
*
|
||||
* @param connectionId Overrides `repo.connectionId`, which answers null for a row that names its
|
||||
* owner only as `executionHostId: 'ssh:<target>'`. Callers holding a resolved removal route must
|
||||
* pass it, or an SSH-hosted repo is read on the local disk and its archive hook goes unseen.
|
||||
*/
|
||||
export async function getArchiveHooksForRemoval(
|
||||
repo: Repo,
|
||||
connectionId?: string
|
||||
): Promise<OrcaHooks | null> {
|
||||
const owner = connectionId ?? repo.connectionId
|
||||
if (!owner) {
|
||||
return getEffectiveHooks(repo)
|
||||
}
|
||||
|
||||
const fsProvider = getSshFilesystemProvider(repo.connectionId)
|
||||
const fsProvider = getSshFilesystemProvider(owner)
|
||||
if (!fsProvider) {
|
||||
return getEffectiveHooksFromConfig(repo, null)
|
||||
}
|
||||
@@ -33,7 +52,7 @@ export async function runRemoteArchiveHook(
|
||||
repo: Repo,
|
||||
worktreePath: string,
|
||||
script: string
|
||||
): Promise<{ success: boolean; output: string }> {
|
||||
): Promise<ArchiveHookRunResult> {
|
||||
if (!repo.connectionId) {
|
||||
return { success: true, output: '' }
|
||||
}
|
||||
@@ -70,8 +89,15 @@ export async function runRemoteArchiveHook(
|
||||
.join('\n')
|
||||
.trim()
|
||||
|
||||
// Why (#19334): a spawn error or timeout means the host never reported an exit for this run, so
|
||||
// the code is withheld and the gate classifies the failure `unverifiable` rather than `exited`.
|
||||
const observedExit =
|
||||
!result.spawnError && !result.timedOut && typeof result.exitCode === 'number'
|
||||
? result.exitCode
|
||||
: undefined
|
||||
return {
|
||||
success: !result.spawnError && !result.timedOut && result.exitCode === 0,
|
||||
output
|
||||
success: observedExit === 0,
|
||||
output,
|
||||
...(observedExit !== undefined ? { exitCode: observedExit } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,21 @@ export type WorktreeRemovalInFlight = {
|
||||
}
|
||||
|
||||
export function getWorktreeRemovalOptionsKey(
|
||||
args: Pick<RemoveWorktreeArgs, 'force' | 'allowUnverifiedPtyStop' | 'skipArchive'>
|
||||
args: Pick<
|
||||
RemoveWorktreeArgs,
|
||||
'force' | 'allowUnverifiedPtyStop' | 'skipArchive' | 'allowFailedArchiveHook'
|
||||
>
|
||||
): string {
|
||||
const forceKey = args.force === true ? 'force' : 'normal'
|
||||
const archiveKey = args.skipArchive === true ? 'skip-archive' : 'run-archive'
|
||||
// Why: a Force Delete retry must not coalesce onto the in-flight attempt that
|
||||
// just failed the PTY gate — it would inherit that failure instead of retrying.
|
||||
const ptyKey = args.allowUnverifiedPtyStop === true ? 'allow-unverified-pty' : 'require-pty-stop'
|
||||
return `${forceKey}:${archiveKey}:${ptyKey}`
|
||||
// Same reason for the archive waiver: a retry that waives the failed hook must not coalesce
|
||||
// onto the in-flight attempt that is about to refuse on it.
|
||||
const archiveFailureKey =
|
||||
args.allowFailedArchiveHook === true ? 'allow-failed-archive' : 'require-archive'
|
||||
return `${forceKey}:${archiveKey}:${ptyKey}:${archiveFailureKey}`
|
||||
}
|
||||
|
||||
export function getWorktreeRemovalInFlightKey(
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
import type { RemoveWorktreeResult } from '../../shared/worktree/create-types'
|
||||
import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
|
||||
import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup'
|
||||
import { getRuntimeWorktreeRemovalOptionsKey } from './runtime-worktree-selection'
|
||||
import {
|
||||
getRuntimeWorktreeRemovalOptionsKey,
|
||||
type RemoveManagedWorktreeOptions
|
||||
} from './runtime-worktree-selection'
|
||||
import { withWorktreeSpan } from '../observability/instrumentation'
|
||||
import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth'
|
||||
import { resolveWorktreeRemovalRoute } from '../worktree-removal-execution-host-route'
|
||||
@@ -30,11 +33,15 @@ import { deleteRemoteWorktreeHistory } from '../remote-worktree-history-cleanup'
|
||||
export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateManagedRemoteWorktree {
|
||||
async removeManagedWorktree(
|
||||
worktreeSelector: string,
|
||||
force = false,
|
||||
runHooks = false,
|
||||
allowUnverifiedPtyStop = false,
|
||||
hostId?: string
|
||||
options: RemoveManagedWorktreeOptions = {}
|
||||
): Promise<RemoveWorktreeResult & { warning?: string }> {
|
||||
const {
|
||||
force = false,
|
||||
runHooks = false,
|
||||
allowUnverifiedPtyStop = false,
|
||||
allowFailedArchiveHook = false,
|
||||
hostId
|
||||
} = options
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
@@ -45,7 +52,12 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM
|
||||
worktreeId: removalTarget.id,
|
||||
hostId: cleanupHostId
|
||||
})
|
||||
const optionsKey = getRuntimeWorktreeRemovalOptionsKey(force, runHooks, allowUnverifiedPtyStop)
|
||||
const optionsKey = getRuntimeWorktreeRemovalOptionsKey({
|
||||
force,
|
||||
runHooks,
|
||||
allowUnverifiedPtyStop,
|
||||
allowFailedArchiveHook
|
||||
})
|
||||
const inFlightRemoval = this.removeManagedWorktreeInFlight.get(
|
||||
cleanupScopeKey,
|
||||
removalTarget.id,
|
||||
@@ -190,6 +202,8 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM
|
||||
}
|
||||
if (route.kind === 'ssh') {
|
||||
return removeRuntimeRegisteredRemoteWorktree({
|
||||
runHooks,
|
||||
allowFailedArchiveHook,
|
||||
repo,
|
||||
target: removalTarget,
|
||||
registeredWorktree,
|
||||
@@ -240,6 +254,7 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM
|
||||
hasLocalOptions: hasLocalWorktreeGitOptions,
|
||||
force,
|
||||
runHooks,
|
||||
allowFailedArchiveHook,
|
||||
allowUnverifiedPtyStop,
|
||||
deleteBranch,
|
||||
acquireWatcherRemoval: this.acquireFileWatcherRemoval,
|
||||
|
||||
@@ -396,7 +396,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
try {
|
||||
await runtime.removeManagedWorktree('path:/remote/feature', true, false)
|
||||
await runtime.removeManagedWorktree('path:/remote/feature', { force: true, runHooks: false })
|
||||
} finally {
|
||||
unregisterSshGitProvider('ssh-1')
|
||||
}
|
||||
@@ -472,7 +472,7 @@ describe('OrcaRuntimeService', () => {
|
||||
runtime.registerPty('pty-local-same-id', `${TEST_REPO_ID}::/remote/feature`, null)
|
||||
|
||||
try {
|
||||
await runtime.removeManagedWorktree('path:/remote/feature', true, false)
|
||||
await runtime.removeManagedWorktree('path:/remote/feature', { force: true, runHooks: false })
|
||||
} finally {
|
||||
unregisterSshGitProvider('ssh-1')
|
||||
}
|
||||
@@ -519,9 +519,9 @@ describe('OrcaRuntimeService', () => {
|
||||
const runtime = new OrcaRuntimeService(remoteStore as never)
|
||||
|
||||
try {
|
||||
await expect(runtime.removeManagedWorktree('path:/remote/repo', true)).rejects.toThrow(
|
||||
'Refusing to delete protected worktree path: /remote/repo'
|
||||
)
|
||||
await expect(
|
||||
runtime.removeManagedWorktree('path:/remote/repo', { force: true })
|
||||
).rejects.toThrow('Refusing to delete protected worktree path: /remote/repo')
|
||||
} finally {
|
||||
unregisterSshGitProvider('ssh-1')
|
||||
}
|
||||
|
||||
+8
-6
@@ -298,7 +298,7 @@ describe('OrcaRuntimeService', () => {
|
||||
.mockResolvedValue([])
|
||||
|
||||
try {
|
||||
const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID, true)
|
||||
const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true })
|
||||
|
||||
expect(result).toEqual({
|
||||
preservedBranch: { branchName: 'feature/foo', head: 'abc' },
|
||||
@@ -340,7 +340,9 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
|
||||
try {
|
||||
await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, true)).rejects.toThrow(
|
||||
await expect(
|
||||
runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true })
|
||||
).rejects.toThrow(
|
||||
`Failed to force delete worktree at ${TEST_WORKTREE_PATH}. error: failed to delete deep/file.txt: Filename too long`
|
||||
)
|
||||
expect(removePathSpy).not.toHaveBeenCalled()
|
||||
@@ -387,7 +389,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await runtime.removeManagedWorktree(worktreeId, true)
|
||||
const result = await runtime.removeManagedWorktree(worktreeId, { force: true })
|
||||
|
||||
expect(result).toEqual({
|
||||
preservedBranch: { branchName: 'feature/foo', head: 'abc' }
|
||||
@@ -425,9 +427,9 @@ describe('OrcaRuntimeService', () => {
|
||||
vi.mocked(listWorktreesStrict).mockResolvedValue(registeredWorktrees)
|
||||
vi.mocked(removeWorktree).mockResolvedValue({})
|
||||
|
||||
await expect(runtime.removeManagedWorktree(worktreeId, true, false)).rejects.toThrow(
|
||||
'Worktree is locked by Git. Lock reason: active agent session'
|
||||
)
|
||||
await expect(
|
||||
runtime.removeManagedWorktree(worktreeId, { force: true, runHooks: false })
|
||||
).rejects.toThrow('Worktree is locked by Git. Lock reason: active agent session')
|
||||
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
expect(removeWorktreeMeta).not.toHaveBeenCalled()
|
||||
|
||||
+28
-17
@@ -94,7 +94,12 @@ describe('OrcaRuntimeService', () => {
|
||||
const runtime = createWorktreeRemovalRuntime(runtimeStore)
|
||||
|
||||
try {
|
||||
await runtime.removeManagedWorktree(TEST_WORKTREE_ID, false, false, false, 'ssh:ssh-1')
|
||||
await runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
|
||||
force: false,
|
||||
runHooks: false,
|
||||
allowUnverifiedPtyStop: false,
|
||||
hostId: 'ssh:ssh-1'
|
||||
})
|
||||
expect(provider.removeWorktree).toHaveBeenCalledWith(TEST_WORKTREE_PATH, false)
|
||||
expect(metaById[TEST_WORKTREE_ID]?.hostId).toBe('local')
|
||||
const result = await runtime.forceDeletePreservedBranch(
|
||||
@@ -181,8 +186,8 @@ describe('OrcaRuntimeService', () => {
|
||||
return {}
|
||||
})
|
||||
|
||||
const first = runtime.removeManagedWorktree(TEST_WORKTREE_ID, true)
|
||||
const second = runtime.removeManagedWorktree(TEST_WORKTREE_ID, true)
|
||||
const first = runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true })
|
||||
const second = runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true })
|
||||
|
||||
await removeStarted.promise
|
||||
await Promise.resolve()
|
||||
@@ -238,14 +243,18 @@ describe('OrcaRuntimeService', () => {
|
||||
registerSshGitProvider('host-b', provider as never)
|
||||
|
||||
try {
|
||||
const local = runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'local')
|
||||
const remote = runtime.removeManagedWorktree(
|
||||
TEST_WORKTREE_ID,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
'ssh:host-b'
|
||||
)
|
||||
const local = runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
|
||||
force: true,
|
||||
runHooks: false,
|
||||
allowUnverifiedPtyStop: false,
|
||||
hostId: 'local'
|
||||
})
|
||||
const remote = runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
|
||||
force: true,
|
||||
runHooks: false,
|
||||
allowUnverifiedPtyStop: false,
|
||||
hostId: 'ssh:host-b'
|
||||
})
|
||||
|
||||
await bothStarted.promise
|
||||
expect(removeWorktree).toHaveBeenCalledTimes(1)
|
||||
@@ -271,7 +280,7 @@ describe('OrcaRuntimeService', () => {
|
||||
const first = runtime.removeManagedWorktree(TEST_WORKTREE_ID)
|
||||
|
||||
await removeStarted.promise
|
||||
await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, true)).rejects.toThrow(
|
||||
await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true })).rejects.toThrow(
|
||||
'Worktree deletion already in progress'
|
||||
)
|
||||
|
||||
@@ -292,7 +301,7 @@ describe('OrcaRuntimeService', () => {
|
||||
try {
|
||||
vi.mocked(listWorktrees).mockResolvedValue([])
|
||||
|
||||
await expect(runtime.removeManagedWorktree(worktreeId, true)).resolves.toEqual({})
|
||||
await expect(runtime.removeManagedWorktree(worktreeId, { force: true })).resolves.toEqual({})
|
||||
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
// The repo resolved to the local host, so the metadata purge names it —
|
||||
@@ -458,7 +467,9 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
try {
|
||||
await expect(runtime.removeManagedWorktree(`id:${worktreeId}`, true)).resolves.toEqual({})
|
||||
await expect(
|
||||
runtime.removeManagedWorktree(`id:${worktreeId}`, { force: true })
|
||||
).resolves.toEqual({})
|
||||
} finally {
|
||||
unregisterSshGitProvider(repo.connectionId)
|
||||
unregisterSshFilesystemProvider(repo.connectionId)
|
||||
@@ -513,7 +524,7 @@ describe('OrcaRuntimeService', () => {
|
||||
try {
|
||||
vi.mocked(listWorktrees).mockResolvedValue([])
|
||||
|
||||
await expect(runtime.removeManagedWorktree(worktreeId, true)).resolves.toEqual({})
|
||||
await expect(runtime.removeManagedWorktree(worktreeId, { force: true })).resolves.toEqual({})
|
||||
|
||||
await expect(lstat(orphanPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(closeLocalWatcherForWorktreePathMock).toHaveBeenCalledWith(
|
||||
@@ -586,7 +597,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
expect(removeWorktreeMeta).not.toHaveBeenCalled()
|
||||
|
||||
await expect(runtime.removeManagedWorktree(worktreeId, true)).resolves.toEqual({})
|
||||
await expect(runtime.removeManagedWorktree(worktreeId, { force: true })).resolves.toEqual({})
|
||||
|
||||
await expect(lstat(leftoverPath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(assertWorktreeCleanForRemoval).not.toHaveBeenCalled()
|
||||
@@ -642,7 +653,7 @@ describe('OrcaRuntimeService', () => {
|
||||
try {
|
||||
vi.mocked(listWorktrees).mockResolvedValue([])
|
||||
|
||||
await expect(runtime.removeManagedWorktree(worktreeId, true)).rejects.toThrow(
|
||||
await expect(runtime.removeManagedWorktree(worktreeId, { force: true })).rejects.toThrow(
|
||||
`Refusing to delete unregistered worktree path: ${standalonePath}`
|
||||
)
|
||||
|
||||
|
||||
+14
-10
@@ -90,9 +90,9 @@ describe('OrcaRuntimeService', () => {
|
||||
const runtime = createWorktreeRemovalRuntime(runtimeStore)
|
||||
|
||||
try {
|
||||
await expect(runtime.removeManagedWorktree(`id:${worktreeId}`, true)).rejects.toThrow(
|
||||
'SSH filesystem provider unavailable'
|
||||
)
|
||||
await expect(
|
||||
runtime.removeManagedWorktree(`id:${worktreeId}`, { force: true })
|
||||
).rejects.toThrow('SSH filesystem provider unavailable')
|
||||
|
||||
await expect(lstat(localPath)).resolves.toBeTruthy()
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
@@ -112,7 +112,7 @@ describe('OrcaRuntimeService', () => {
|
||||
try {
|
||||
vi.mocked(listWorktrees).mockResolvedValue([])
|
||||
|
||||
await expect(runtime.removeManagedWorktree(worktreeId, true)).rejects.toThrow(
|
||||
await expect(runtime.removeManagedWorktree(worktreeId, { force: true })).rejects.toThrow(
|
||||
'Refusing to delete unregistered worktree path'
|
||||
)
|
||||
|
||||
@@ -177,7 +177,9 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, true)).rejects.toThrow(
|
||||
await expect(
|
||||
runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true, runHooks: true })
|
||||
).rejects.toThrow(
|
||||
`Refusing to delete worktree because it contains another registered worktree: ${TEST_WORKTREE_PATH}/child`
|
||||
)
|
||||
|
||||
@@ -238,7 +240,9 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
])
|
||||
|
||||
await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, true)).rejects.toThrow(
|
||||
await expect(
|
||||
runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true, runHooks: true })
|
||||
).rejects.toThrow(
|
||||
`Failed to force delete worktree at ${TEST_WORKTREE_PATH}. Worktree is locked by Git.`
|
||||
)
|
||||
|
||||
@@ -278,9 +282,9 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
])
|
||||
|
||||
await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, true)).rejects.toThrow(
|
||||
'Worktree is locked by Git'
|
||||
)
|
||||
await expect(
|
||||
runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true, runHooks: true })
|
||||
).rejects.toThrow('Worktree is locked by Git')
|
||||
|
||||
expect(runHook).toHaveBeenCalled()
|
||||
expect(removeWorktreeLinkedPathsMock).not.toHaveBeenCalled()
|
||||
@@ -377,7 +381,7 @@ describe('OrcaRuntimeService', () => {
|
||||
vi.mocked(runHook).mockResolvedValue({ success: true, output: '' })
|
||||
vi.mocked(removeWorktree).mockResolvedValue({})
|
||||
|
||||
await runtime.removeManagedWorktree(TEST_WORKTREE_ID, false, true)
|
||||
await runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: false, runHooks: true })
|
||||
|
||||
expect(runHook).toHaveBeenCalledWith(
|
||||
'archive',
|
||||
|
||||
@@ -612,7 +612,12 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.removeManagedWorktree(TEST_WORKTREE_ID, false, false, false, 'runtime:env-b')
|
||||
runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
|
||||
force: false,
|
||||
runHooks: false,
|
||||
allowUnverifiedPtyStop: false,
|
||||
hostId: 'runtime:env-b'
|
||||
})
|
||||
).rejects.toThrow('no longer belongs to runtime:env-b')
|
||||
|
||||
expect(localProvider.listProcesses).not.toHaveBeenCalled()
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
// Regression cover for #19334: a failed archive hook used to be logged and stepped over, so the
|
||||
// checkout was deleted with nothing archived. The hook is a blocking precondition now.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
assertWorktreeCleanForRemoval,
|
||||
deleteWorktreeHistoryDirMock,
|
||||
getEffectiveHooks,
|
||||
invalidateAuthorizedRootsCacheMock,
|
||||
listWorktreesStrict,
|
||||
removeWorktree,
|
||||
removeWorktreeLinkedPathsMock,
|
||||
runHook
|
||||
} from '../orca-runtime-test-mocks.spec'
|
||||
import {
|
||||
TEST_REPO_PATH,
|
||||
TEST_WORKTREE_ID,
|
||||
TEST_WORKTREE_PATH,
|
||||
createStaleRuntimeWorktreeStore,
|
||||
deferred
|
||||
} from '../orca-runtime-test-fixtures.spec'
|
||||
import { createWorktreeRemovalRuntime } from '../orca-runtime-test-scenario-builders.spec'
|
||||
import {
|
||||
ARCHIVE_HOOK_FAILED_REMOVAL_CODE,
|
||||
asArchiveHookRefusal
|
||||
} from '../../../shared/worktree/archive-hook-removal-gate'
|
||||
|
||||
function withArchiveHook(): void {
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue({
|
||||
scripts: { archive: 'pnpm worktree:archive' }
|
||||
})
|
||||
}
|
||||
|
||||
function expectNothingMutated(removeWorktreeMeta: ReturnType<typeof vi.fn>): void {
|
||||
// The checkout, its Git registration, its agents and Orca's ownership evidence all survive.
|
||||
expect(removeWorktree).not.toHaveBeenCalled()
|
||||
expect(removeWorktreeMeta).not.toHaveBeenCalled()
|
||||
expect(removeWorktreeLinkedPathsMock).not.toHaveBeenCalled()
|
||||
expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled()
|
||||
expect(invalidateAuthorizedRootsCacheMock).not.toHaveBeenCalled()
|
||||
// The gate runs before the registration re-read, so even the preflights never start. The one
|
||||
// listing is the orchestrator's own lookup ahead of the hook; the post-hook refresh never runs.
|
||||
expect(listWorktreesStrict).toHaveBeenCalledTimes(1)
|
||||
expect(assertWorktreeCleanForRemoval).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
describe('archive hook removal gate', () => {
|
||||
// These specs are imported into one aggregate test file, so the module-level mocks arrive with
|
||||
// calls from earlier specs. Clear counts here and restore the shared defaults afterwards.
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue(null)
|
||||
vi.mocked(runHook).mockResolvedValue({ success: true, output: '' })
|
||||
})
|
||||
|
||||
it('refuses removal and mutates nothing when the archive hook exits 23', async () => {
|
||||
const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID)
|
||||
const runtime = createWorktreeRemovalRuntime(runtimeStore)
|
||||
withArchiveHook()
|
||||
vi.mocked(runHook).mockResolvedValue({
|
||||
success: false,
|
||||
output: 'backup target unreachable',
|
||||
exitCode: 23
|
||||
})
|
||||
|
||||
const failure = await runtime
|
||||
.removeManagedWorktree(TEST_WORKTREE_ID, { force: false, runHooks: true })
|
||||
.catch((error: unknown) => error)
|
||||
|
||||
const refusal = asArchiveHookRefusal(failure)
|
||||
expect(refusal.code).toBe(ARCHIVE_HOOK_FAILED_REMOVAL_CODE)
|
||||
expect(refusal.data).toEqual({
|
||||
worktreePath: TEST_WORKTREE_PATH,
|
||||
outcome: 'exited',
|
||||
exitCode: 23,
|
||||
output: 'backup target unreachable'
|
||||
})
|
||||
expectNothingMutated(removeWorktreeMeta)
|
||||
})
|
||||
|
||||
it('refuses removal when the hook never reported an exit, without claiming it passed', async () => {
|
||||
const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID)
|
||||
const runtime = createWorktreeRemovalRuntime(runtimeStore)
|
||||
withArchiveHook()
|
||||
// A timeout or a lost execution host yields no exit code: `unverifiable`, never a pass.
|
||||
vi.mocked(runHook).mockResolvedValue({
|
||||
success: false,
|
||||
output: 'Hook timed out after 120000ms.'
|
||||
})
|
||||
|
||||
const failure = await runtime
|
||||
.removeManagedWorktree(TEST_WORKTREE_ID, { force: false, runHooks: true })
|
||||
.catch((error: unknown) => error)
|
||||
|
||||
const refusal = asArchiveHookRefusal(failure)
|
||||
expect(refusal.data).toEqual({
|
||||
worktreePath: TEST_WORKTREE_PATH,
|
||||
outcome: 'unverifiable',
|
||||
output: 'Hook timed out after 120000ms.'
|
||||
})
|
||||
expectNothingMutated(removeWorktreeMeta)
|
||||
})
|
||||
|
||||
it('does not let --force waive a failed archive hook', async () => {
|
||||
const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID)
|
||||
const runtime = createWorktreeRemovalRuntime(runtimeStore)
|
||||
withArchiveHook()
|
||||
vi.mocked(runHook).mockResolvedValue({
|
||||
success: false,
|
||||
output: 'boom',
|
||||
exitCode: 23
|
||||
})
|
||||
|
||||
await expect(
|
||||
// force + the PTY-stop waiver, i.e. everything the desktop Force Delete sets.
|
||||
runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
|
||||
force: true,
|
||||
runHooks: true,
|
||||
allowUnverifiedPtyStop: true
|
||||
})
|
||||
).rejects.toMatchObject({ code: ARCHIVE_HOOK_FAILED_REMOVAL_CODE })
|
||||
expectNothingMutated(removeWorktreeMeta)
|
||||
})
|
||||
|
||||
it('removes and records the waiver when the failure is explicitly overridden', async () => {
|
||||
const runtime = createWorktreeRemovalRuntime()
|
||||
withArchiveHook()
|
||||
vi.mocked(runHook).mockResolvedValue({
|
||||
success: false,
|
||||
output: 'boom',
|
||||
exitCode: 23
|
||||
})
|
||||
vi.mocked(removeWorktree).mockResolvedValue({})
|
||||
|
||||
const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
|
||||
force: false,
|
||||
runHooks: true,
|
||||
allowUnverifiedPtyStop: false,
|
||||
allowFailedArchiveHook: true
|
||||
})
|
||||
|
||||
expect(result.archiveHookOverride).toEqual({
|
||||
worktreePath: TEST_WORKTREE_PATH,
|
||||
outcome: 'exited',
|
||||
exitCode: 23,
|
||||
output: 'boom',
|
||||
overridden: true
|
||||
})
|
||||
expect(removeWorktree).toHaveBeenCalledWith(
|
||||
TEST_REPO_PATH,
|
||||
TEST_WORKTREE_PATH,
|
||||
false,
|
||||
expect.objectContaining({
|
||||
knownRemovedWorktree: expect.objectContaining({
|
||||
path: TEST_WORKTREE_PATH
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('removes without an override record when the hook succeeds', async () => {
|
||||
const runtime = createWorktreeRemovalRuntime()
|
||||
withArchiveHook()
|
||||
vi.mocked(runHook).mockResolvedValue({
|
||||
success: true,
|
||||
output: '',
|
||||
exitCode: 0
|
||||
})
|
||||
vi.mocked(removeWorktree).mockResolvedValue({})
|
||||
|
||||
const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
|
||||
force: false,
|
||||
runHooks: true
|
||||
})
|
||||
|
||||
expect(result.archiveHookOverride).toBeUndefined()
|
||||
expect(removeWorktree).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes when the hook is configured but not requested', async () => {
|
||||
const runtime = createWorktreeRemovalRuntime()
|
||||
withArchiveHook()
|
||||
vi.mocked(removeWorktree).mockResolvedValue({})
|
||||
|
||||
const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID)
|
||||
|
||||
expect(runHook).not.toHaveBeenCalled()
|
||||
expect(result.warning).toContain('archive hook skipped')
|
||||
expect(removeWorktree).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes when no archive hook is configured', async () => {
|
||||
const runtime = createWorktreeRemovalRuntime()
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue(null)
|
||||
vi.mocked(removeWorktree).mockResolvedValue({})
|
||||
|
||||
const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
|
||||
force: false,
|
||||
runHooks: true
|
||||
})
|
||||
|
||||
expect(runHook).not.toHaveBeenCalled()
|
||||
expect(result.warning).toBeUndefined()
|
||||
expect(removeWorktree).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not coalesce an override retry onto the refusal already in flight', async () => {
|
||||
const runtime = createWorktreeRemovalRuntime()
|
||||
withArchiveHook()
|
||||
const hookRun = deferred<{
|
||||
success: boolean
|
||||
output: string
|
||||
exitCode?: number
|
||||
}>()
|
||||
vi.mocked(runHook).mockReturnValue(hookRun.promise)
|
||||
vi.mocked(removeWorktree).mockResolvedValue({})
|
||||
|
||||
const refused = runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
|
||||
force: false,
|
||||
runHooks: true
|
||||
})
|
||||
await vi.waitFor(() => expect(runHook).toHaveBeenCalled())
|
||||
|
||||
// The waiver is part of the in-flight options key, so a concurrent waived retry is refused
|
||||
// outright rather than handed the in-flight attempt that is about to reject on the hook.
|
||||
await expect(
|
||||
runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
|
||||
force: false,
|
||||
runHooks: true,
|
||||
allowUnverifiedPtyStop: false,
|
||||
allowFailedArchiveHook: true
|
||||
})
|
||||
).rejects.toThrow('Worktree deletion already in progress')
|
||||
|
||||
hookRun.resolve({ success: false, output: 'boom', exitCode: 23 })
|
||||
await expect(refused).rejects.toMatchObject({
|
||||
code: ARCHIVE_HOOK_FAILED_REMOVAL_CODE
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -102,7 +102,12 @@ describe('OrcaRuntimeService worktree removal execution host', () => {
|
||||
vi.spyOn(runtime, 'acquireFileWatcherRemoval').mockResolvedValue({ finish: vi.fn() })
|
||||
|
||||
try {
|
||||
await runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'ssh:target-a')
|
||||
await runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
|
||||
force: true,
|
||||
runHooks: false,
|
||||
allowUnverifiedPtyStop: false,
|
||||
hostId: 'ssh:target-a'
|
||||
})
|
||||
|
||||
expect(provider.listWorktrees).toHaveBeenCalledWith(REMOTE_REPO_PATH)
|
||||
expect(provider.removeWorktree).toHaveBeenCalledWith(TEST_WORKTREE_PATH, true)
|
||||
@@ -127,7 +132,12 @@ describe('OrcaRuntimeService worktree removal execution host', () => {
|
||||
|
||||
try {
|
||||
await expect(
|
||||
runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'ssh:target-a')
|
||||
runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
|
||||
force: true,
|
||||
runHooks: false,
|
||||
allowUnverifiedPtyStop: false,
|
||||
hostId: 'ssh:target-a'
|
||||
})
|
||||
).resolves.toEqual({})
|
||||
|
||||
expect(provider.listWorktrees).toHaveBeenCalledWith(REMOTE_REPO_PATH)
|
||||
@@ -152,7 +162,12 @@ describe('OrcaRuntimeService worktree removal execution host', () => {
|
||||
vi.spyOn(runtime, 'acquireFileWatcherRemoval').mockResolvedValue({ finish: vi.fn() })
|
||||
|
||||
try {
|
||||
await runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'ssh:target-b')
|
||||
await runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
|
||||
force: true,
|
||||
runHooks: false,
|
||||
allowUnverifiedPtyStop: false,
|
||||
hostId: 'ssh:target-b'
|
||||
})
|
||||
|
||||
expect(providerB.removeWorktree).toHaveBeenCalledWith(TEST_WORKTREE_PATH, true)
|
||||
expect(providerA.listWorktrees).not.toHaveBeenCalled()
|
||||
@@ -168,7 +183,12 @@ describe('OrcaRuntimeService worktree removal execution host', () => {
|
||||
const runtime = createWorktreeRemovalRuntime(runtimeStore)
|
||||
|
||||
await expect(
|
||||
runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'ssh:target-a')
|
||||
runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
|
||||
force: true,
|
||||
runHooks: false,
|
||||
allowUnverifiedPtyStop: false,
|
||||
hostId: 'ssh:target-a'
|
||||
})
|
||||
).rejects.toThrow('Remote connection dropped')
|
||||
|
||||
expect(listWorktreesStrict).not.toHaveBeenCalled()
|
||||
@@ -181,7 +201,12 @@ describe('OrcaRuntimeService worktree removal execution host', () => {
|
||||
const runtime = createWorktreeRemovalRuntime(runtimeStore)
|
||||
|
||||
await expect(
|
||||
runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'runtime:env-1')
|
||||
runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
|
||||
force: true,
|
||||
runHooks: false,
|
||||
allowUnverifiedPtyStop: false,
|
||||
hostId: 'runtime:env-1'
|
||||
})
|
||||
).rejects.toThrow('not dispatched by this process')
|
||||
|
||||
expect(listWorktreesStrict).not.toHaveBeenCalled()
|
||||
@@ -201,7 +226,12 @@ describe('OrcaRuntimeService worktree removal execution host', () => {
|
||||
|
||||
try {
|
||||
await expect(
|
||||
runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'runtime:env-1')
|
||||
runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
|
||||
force: true,
|
||||
runHooks: false,
|
||||
allowUnverifiedPtyStop: false,
|
||||
hostId: 'runtime:env-1'
|
||||
})
|
||||
).rejects.toThrow('not dispatched by this process')
|
||||
|
||||
// Selector resolution still lists through the raw field before removal begins — a read on
|
||||
|
||||
@@ -110,6 +110,7 @@ await import('./orca-runtime-tests/worktree-removal-and-reconciliation.spec')
|
||||
await import('./orca-runtime-tests/worktree-removal-and-reconciliation-part-02.spec')
|
||||
await import('./orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec')
|
||||
await import('./orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec')
|
||||
await import('./orca-runtime-tests/worktree-removal-archive-hook-gate.spec')
|
||||
await import('./orca-runtime-tests/worktree-removal-execution-host.spec')
|
||||
await import('./orca-runtime-tests/targeting-and-resilience.spec')
|
||||
await import('./orca-runtime-tests/worktree-scan-cache-ttl.spec')
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '../../../shared/skill-install-failure'
|
||||
import { GIT_DIFF_TOO_LARGE_CODE } from '../../../shared/git-diff-transport-budget'
|
||||
import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../../shared/automation-owner-conflict'
|
||||
import { ARCHIVE_HOOK_FAILED_REMOVAL_CODE } from '../../../shared/worktree/archive-hook-removal-gate'
|
||||
import { NESTED_WORKER_DEPTH_EXCEEDED_CODE } from '../../../shared/nested-worker-depth'
|
||||
|
||||
export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess {
|
||||
@@ -126,6 +127,9 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
|
||||
'stale_delivery',
|
||||
'waiter_exists',
|
||||
'invalid_argument',
|
||||
// Why (#19334): "your archive hook failed, nothing was deleted" is a distinct decision — retry,
|
||||
// waive, or skip the hook. Flattened to runtime_error a caller can only pattern-match the text.
|
||||
ARCHIVE_HOOK_FAILED_REMOVAL_CODE,
|
||||
NESTED_WORKER_DEPTH_EXCEEDED_CODE,
|
||||
GIT_DIFF_TOO_LARGE_CODE,
|
||||
ARTIFACT_SHARING_DISABLED_CODE,
|
||||
|
||||
@@ -20,6 +20,15 @@ function makeRequest(params: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method: 'worktree.rm', params }
|
||||
}
|
||||
|
||||
/** The removal options every case forwards; only the resolved host differs. */
|
||||
const forwarded = (hostId?: string): Record<string, unknown> => ({
|
||||
force: true,
|
||||
runHooks: false,
|
||||
allowUnverifiedPtyStop: false,
|
||||
allowFailedArchiveHook: false,
|
||||
...(hostId ? { hostId } : {})
|
||||
})
|
||||
|
||||
describe('worktree.rm host qualification', () => {
|
||||
it('routes an explicitly qualified removal to that host', async () => {
|
||||
const runtime = makeRuntime()
|
||||
@@ -29,13 +38,7 @@ describe('worktree.rm host qualification', () => {
|
||||
makeRequest({ worktree: 'id:wt-1', hostId: 'local', force: true, runHooks: false })
|
||||
)
|
||||
|
||||
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
|
||||
'id:wt-1',
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
'local'
|
||||
)
|
||||
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith('id:wt-1', forwarded('local'))
|
||||
expect(response).toMatchObject({ ok: true, result: { removed: true } })
|
||||
})
|
||||
|
||||
@@ -54,10 +57,7 @@ describe('worktree.rm host qualification', () => {
|
||||
|
||||
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
|
||||
`id:${WORKTREE_ID}`,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
'local'
|
||||
forwarded('local')
|
||||
)
|
||||
expect(response).toMatchObject({ ok: true, result: { removed: true } })
|
||||
})
|
||||
@@ -77,10 +77,7 @@ describe('worktree.rm host qualification', () => {
|
||||
|
||||
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
|
||||
`id:${WORKTREE_ID}`,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
'runtime:env-1'
|
||||
forwarded('runtime:env-1')
|
||||
)
|
||||
})
|
||||
|
||||
@@ -119,10 +116,7 @@ describe('worktree.rm host qualification', () => {
|
||||
|
||||
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
|
||||
`id:${WORKTREE_ID}`,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
'ssh:target-a'
|
||||
forwarded('ssh:target-a')
|
||||
)
|
||||
})
|
||||
|
||||
@@ -151,13 +145,7 @@ describe('worktree.rm host qualification', () => {
|
||||
)
|
||||
|
||||
expect(runtime.showManagedWorktree).toHaveBeenCalledWith('id:wt-1')
|
||||
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
|
||||
'id:wt-1',
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
'local'
|
||||
)
|
||||
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith('id:wt-1', forwarded('local'))
|
||||
expect(response).toMatchObject({ ok: true, result: { removed: true } })
|
||||
})
|
||||
|
||||
@@ -205,13 +193,7 @@ describe('worktree.rm host qualification', () => {
|
||||
expect(response).toMatchObject({ ok: true, result: { removed: true } })
|
||||
// Unqualified on purpose: removeManagedWorktree owns the stale-row path and
|
||||
// still refuses on its own if the id turns out to have two owners.
|
||||
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
|
||||
'id:wt-gone',
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
undefined
|
||||
)
|
||||
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith('id:wt-gone', forwarded())
|
||||
})
|
||||
|
||||
it('propagates a non-missing lookup failure instead of deleting unqualified', async () => {
|
||||
|
||||
@@ -14,74 +14,75 @@ function makeRuntime(): OrcaRuntimeService {
|
||||
} as unknown as OrcaRuntimeService
|
||||
}
|
||||
|
||||
// Why (#11960): waiving the proof that every PTY stopped must ride its own field.
|
||||
// The desktop sets `force` for an ordinary confirmed delete, so keying the waiver
|
||||
// off `force` would silently disable the gate on the primary delete path.
|
||||
describe('worktree.rm PTY-stop waiver', () => {
|
||||
it('forwards an explicit waiver to the runtime', async () => {
|
||||
/** The dispatcher validates against the Zod schema, so the test spells the wire shape. */
|
||||
type RmParams = {
|
||||
hostId?: string
|
||||
force?: boolean
|
||||
runHooks?: boolean
|
||||
allowUnverifiedPtyStop?: boolean
|
||||
allowFailedArchiveHook?: boolean
|
||||
}
|
||||
|
||||
async function dispatchRm(runtime: OrcaRuntimeService, params: RmParams): Promise<void> {
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
|
||||
const request: RpcRequest = {
|
||||
id: 'req-1',
|
||||
authToken: 'tok',
|
||||
method: 'worktree.rm',
|
||||
params: { worktree: 'id:wt-1', ...params }
|
||||
}
|
||||
await dispatcher.dispatch(request)
|
||||
}
|
||||
|
||||
/** Every waiver off unless a case turns it on — the defaults are the assertion. */
|
||||
const forwarded = (overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> => ({
|
||||
force: false,
|
||||
runHooks: false,
|
||||
allowUnverifiedPtyStop: false,
|
||||
allowFailedArchiveHook: false,
|
||||
hostId: 'local',
|
||||
...overrides
|
||||
})
|
||||
|
||||
// Why (#11960 and #19334): each waiver rides its own field. The desktop sets `force` for an
|
||||
// ordinary confirmed delete, so keying either waiver off `force` would silently disable that gate
|
||||
// on the primary delete path. These cases exist to keep `force` from acquiring a second meaning.
|
||||
describe('worktree.rm waivers travel on their own fields', () => {
|
||||
it.each([
|
||||
[
|
||||
'an explicit PTY-stop waiver reaches the runtime',
|
||||
{ hostId: 'local', force: true, allowUnverifiedPtyStop: true, runHooks: false },
|
||||
forwarded({ force: true, allowUnverifiedPtyStop: true })
|
||||
],
|
||||
[
|
||||
'force alone does NOT waive the PTY-stop proof',
|
||||
{ hostId: 'local', force: true, runHooks: false },
|
||||
forwarded({ force: true })
|
||||
],
|
||||
[
|
||||
'an explicit archive-hook waiver reaches the runtime',
|
||||
{ hostId: 'local', runHooks: true, allowFailedArchiveHook: true },
|
||||
forwarded({ runHooks: true, allowFailedArchiveHook: true })
|
||||
],
|
||||
[
|
||||
'force plus a PTY waiver does NOT waive a failed archive hook',
|
||||
{ hostId: 'local', force: true, allowUnverifiedPtyStop: true, runHooks: true },
|
||||
forwarded({ force: true, runHooks: true, allowUnverifiedPtyStop: true })
|
||||
]
|
||||
])('%s', async (_name, params, expected) => {
|
||||
const runtime = makeRuntime()
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
|
||||
|
||||
await dispatcher.dispatch({
|
||||
id: 'req-1',
|
||||
authToken: 'tok',
|
||||
method: 'worktree.rm',
|
||||
params: {
|
||||
worktree: 'id:wt-1',
|
||||
hostId: 'local',
|
||||
force: true,
|
||||
allowUnverifiedPtyStop: true,
|
||||
runHooks: false
|
||||
}
|
||||
} satisfies RpcRequest)
|
||||
|
||||
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
|
||||
'id:wt-1',
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
'local'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not infer a waiver from force alone', async () => {
|
||||
const runtime = makeRuntime()
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
|
||||
|
||||
await dispatcher.dispatch({
|
||||
id: 'req-1',
|
||||
authToken: 'tok',
|
||||
method: 'worktree.rm',
|
||||
params: { worktree: 'id:wt-1', hostId: 'local', force: true, runHooks: false }
|
||||
} satisfies RpcRequest)
|
||||
|
||||
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
|
||||
'id:wt-1',
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
'local'
|
||||
)
|
||||
await dispatchRm(runtime, params)
|
||||
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith('id:wt-1', expected)
|
||||
})
|
||||
|
||||
it('resolves the host before forwarding an unqualified removal', async () => {
|
||||
const runtime = makeRuntime()
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
|
||||
|
||||
await dispatcher.dispatch({
|
||||
id: 'req-1',
|
||||
authToken: 'tok',
|
||||
method: 'worktree.rm',
|
||||
params: { worktree: 'id:wt-1', force: true, runHooks: false }
|
||||
} satisfies RpcRequest)
|
||||
await dispatchRm(runtime, { force: true, runHooks: false })
|
||||
|
||||
expect(runtime.showManagedWorktree).toHaveBeenCalledWith('id:wt-1')
|
||||
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
|
||||
'id:wt-1',
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
'ssh:builder'
|
||||
forwarded({ force: true, hostId: 'ssh:builder' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -235,13 +235,13 @@ export const WORKTREE_METHODS = [
|
||||
}
|
||||
}
|
||||
}
|
||||
const removalArgs = [
|
||||
params.worktree,
|
||||
params.force === true,
|
||||
params.runHooks === true,
|
||||
params.allowUnverifiedPtyStop === true
|
||||
] as const
|
||||
const result = await runtime.removeManagedWorktree(...removalArgs, resolvedHostId)
|
||||
const result = await runtime.removeManagedWorktree(params.worktree, {
|
||||
force: params.force === true,
|
||||
runHooks: params.runHooks === true,
|
||||
allowUnverifiedPtyStop: params.allowUnverifiedPtyStop === true,
|
||||
allowFailedArchiveHook: params.allowFailedArchiveHook === true,
|
||||
...(resolvedHostId ? { hostId: resolvedHostId } : {})
|
||||
})
|
||||
return { removed: true, ...result }
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types'
|
||||
import type { RemoveWorktreeResult } from '../../shared/worktree/create-types'
|
||||
import type { ArchiveHookOverride } from '../../shared/worktree/archive-hook-removal-gate'
|
||||
import { gateWorktreeRemovalOnArchiveHook } from '../worktree-archive-hook-gate'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal'
|
||||
import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
|
||||
@@ -40,6 +42,8 @@ export async function removeRuntimeRegisteredLocalWorktree(args: {
|
||||
hasLocalOptions: boolean
|
||||
force: boolean
|
||||
runHooks: boolean
|
||||
/** Explicit waiver for a FAILED archive hook. Never implied by `force` — see #19334. */
|
||||
allowFailedArchiveHook: boolean
|
||||
allowUnverifiedPtyStop: boolean
|
||||
deleteBranch: boolean
|
||||
acquireWatcherRemoval: (path: string) => Promise<{ finish: (removed: boolean) => Promise<void> }>
|
||||
@@ -60,6 +64,9 @@ export async function removeRuntimeRegisteredLocalWorktree(args: {
|
||||
const canonicalPath = registeredWorktree.path
|
||||
const hooks = getEffectiveHooks(repo)
|
||||
let warning: string | undefined
|
||||
// Precondition, not an advisory: this runs before the registration refresh, the preflights, the
|
||||
// PTY stop and `removeWorktree`, so a throw here leaves every one of them untouched (#19334).
|
||||
let archiveHookOverride: ArchiveHookOverride | undefined
|
||||
if (hooks?.scripts.archive && args.runHooks) {
|
||||
const result = await runHook(
|
||||
'archive',
|
||||
@@ -68,9 +75,11 @@ export async function removeRuntimeRegisteredLocalWorktree(args: {
|
||||
undefined,
|
||||
args.hasLocalOptions ? localOptions : undefined
|
||||
)
|
||||
if (!result.success) {
|
||||
console.error(`[hooks] archive hook failed for ${canonicalPath}:`, result.output)
|
||||
}
|
||||
archiveHookOverride = gateWorktreeRemovalOnArchiveHook({
|
||||
worktreePath: canonicalPath,
|
||||
result,
|
||||
allowFailure: args.allowFailedArchiveHook
|
||||
})
|
||||
} else if (hooks?.scripts.archive) {
|
||||
warning = `orca.yaml archive hook skipped for ${canonicalPath}; pass --run-hooks to run it.`
|
||||
console.warn(`[hooks] ${warning}`)
|
||||
@@ -151,7 +160,10 @@ export async function removeRuntimeRegisteredLocalWorktree(args: {
|
||||
await cleanupPushTarget(args)
|
||||
args.finishRemoval(undefined, false, refreshed.head)
|
||||
completed = true
|
||||
return warning ? { warning } : {}
|
||||
return {
|
||||
...(archiveHookOverride ? { archiveHookOverride } : {}),
|
||||
...(warning ? { warning } : {})
|
||||
}
|
||||
} else {
|
||||
throw new Error(formatWorktreeRemovalError(error, canonicalPath, args.force))
|
||||
}
|
||||
@@ -162,7 +174,11 @@ export async function removeRuntimeRegisteredLocalWorktree(args: {
|
||||
}
|
||||
await cleanupPushTarget(args)
|
||||
args.finishRemoval(removalResult, true, refreshed.head)
|
||||
return { ...removalResult, ...(warning ? { warning } : {}) }
|
||||
return {
|
||||
...removalResult,
|
||||
...(archiveHookOverride ? { archiveHookOverride } : {}),
|
||||
...(warning ? { warning } : {})
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupOrphanedDirectory(
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SshGitProvider } from '../providers/ssh-git-provider'
|
||||
import { cleanupUnusedWorktreePushTargetRemoteSsh } from '../ipc/worktree-remote'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
import type { RuntimeWorktreeRemovalTarget } from './runtime-worktree-selection'
|
||||
import { gateRemovalWhereArchiveHookCannotRun } from '../worktree-archive-hook-gate'
|
||||
|
||||
export async function removeRuntimeRegisteredRemoteWorktree(args: {
|
||||
repo: Repo
|
||||
@@ -15,6 +16,10 @@ export async function removeRuntimeRegisteredRemoteWorktree(args: {
|
||||
provider: SshGitProvider
|
||||
/** From the resolved removal route; `repo.connectionId!` answered null for an `ssh:`-only row. */
|
||||
connectionId: string
|
||||
/** #19334: this path runs no archive hook, so the gate below decides what that means. */
|
||||
runHooks: boolean
|
||||
/** Explicit waiver for that refusal; without it the block has no exit on this path. */
|
||||
allowFailedArchiveHook: boolean
|
||||
force: boolean
|
||||
allowUnverifiedPtyStop: boolean
|
||||
deleteBranch: boolean
|
||||
@@ -29,8 +34,17 @@ export async function removeRuntimeRegisteredRemoteWorktree(args: {
|
||||
fallbackHead: string | undefined
|
||||
) => RemoveWorktreeResult
|
||||
finishRemoval: (result: RemoveWorktreeResult) => void
|
||||
}): Promise<RemoveWorktreeResult> {
|
||||
}): Promise<RemoveWorktreeResult & { warning?: string }> {
|
||||
const { repo, target, registeredWorktree, provider, connectionId } = args
|
||||
// Precondition, before anything is stopped or deleted: no archive hook runs here, so a removal
|
||||
// that asked for one refuses rather than deleting with the archive step silently skipped.
|
||||
const hookGate = await gateRemovalWhereArchiveHookCannotRun({
|
||||
repo,
|
||||
connectionId,
|
||||
worktreePath: registeredWorktree.path,
|
||||
runHooks: args.runHooks,
|
||||
allowFailedArchiveHook: args.allowFailedArchiveHook
|
||||
})
|
||||
const removeOptions = !args.deleteBranch ? { deleteBranch: args.deleteBranch } : {}
|
||||
const gate = await args.acquireWatcherRemoval(registeredWorktree.path, connectionId)
|
||||
let rawResult: RemoveWorktreeResult | undefined
|
||||
@@ -54,5 +68,9 @@ export async function removeRuntimeRegisteredRemoteWorktree(args: {
|
||||
)
|
||||
await args.deleteHistory()
|
||||
args.finishRemoval(result)
|
||||
return result
|
||||
return {
|
||||
...result,
|
||||
...(hookGate.override ? { archiveHookOverride: hookGate.override } : {}),
|
||||
...(hookGate.warning ? { warning: hookGate.warning } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runtimeRepoMatchesExecutionHost } from './runtime-worktree-selection'
|
||||
import {
|
||||
getRuntimeWorktreeRemovalOptionsKey,
|
||||
runtimeRepoMatchesExecutionHost
|
||||
} from './runtime-worktree-selection'
|
||||
|
||||
describe('getRuntimeWorktreeRemovalOptionsKey', () => {
|
||||
it('separates a waived archive-hook retry from the attempt about to refuse on it (#19334)', () => {
|
||||
const strict = getRuntimeWorktreeRemovalOptionsKey({ runHooks: true })
|
||||
expect(
|
||||
getRuntimeWorktreeRemovalOptionsKey({ runHooks: true, allowFailedArchiveHook: true })
|
||||
).not.toBe(strict)
|
||||
})
|
||||
|
||||
it('keeps every waiver on its own axis, so none of them coalesce', () => {
|
||||
const keys = [
|
||||
{},
|
||||
{ force: true },
|
||||
{ runHooks: true },
|
||||
{ allowUnverifiedPtyStop: true },
|
||||
{ allowFailedArchiveHook: true }
|
||||
].map(getRuntimeWorktreeRemovalOptionsKey)
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
})
|
||||
|
||||
it('treats an omitted option as its off value', () => {
|
||||
expect(getRuntimeWorktreeRemovalOptionsKey({})).toBe(
|
||||
getRuntimeWorktreeRemovalOptionsKey({
|
||||
force: false,
|
||||
runHooks: false,
|
||||
allowUnverifiedPtyStop: false,
|
||||
allowFailedArchiveHook: false
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runtimeRepoMatchesExecutionHost', () => {
|
||||
it('matches an unstamped SSH repo against its own host (#11163)', () => {
|
||||
|
||||
@@ -26,15 +26,35 @@ export function gitStatusErrorMeansNotRepository(error: unknown): boolean {
|
||||
return /not a git repository/i.test(`${message}\n${stderr}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for `removeManagedWorktree`. Named rather than positional on purpose: three of the
|
||||
* four are interchangeable booleans that each waive a different safety check on a destructive
|
||||
* delete, so a transposition would silently delete a checkout the caller meant to protect.
|
||||
*/
|
||||
export type RemoveManagedWorktreeOptions = {
|
||||
force?: boolean
|
||||
runHooks?: boolean
|
||||
/** Waives proof that every PTY stopped (#11960). Set by explicit Force Delete only. */
|
||||
allowUnverifiedPtyStop?: boolean
|
||||
/** Waives a FAILED archive hook (#19334). Never implied by `force`, never by `runHooks`. */
|
||||
allowFailedArchiveHook?: boolean
|
||||
hostId?: string
|
||||
}
|
||||
|
||||
export function getRuntimeWorktreeRemovalOptionsKey(
|
||||
force: boolean,
|
||||
runHooks: boolean,
|
||||
allowUnverifiedPtyStop: boolean
|
||||
options: Pick<
|
||||
RemoveManagedWorktreeOptions,
|
||||
'force' | 'runHooks' | 'allowUnverifiedPtyStop' | 'allowFailedArchiveHook'
|
||||
>
|
||||
): string {
|
||||
// Why: a forced retry must not coalesce onto the in-flight attempt that just
|
||||
// failed the PTY gate — it would inherit that failure instead of retrying.
|
||||
const ptyKey = allowUnverifiedPtyStop ? 'allow-unverified-pty' : 'require-pty-stop'
|
||||
return `${force ? 'force' : 'normal'}:${runHooks ? 'run-hooks' : 'skip-hooks'}:${ptyKey}`
|
||||
const ptyKey = options.allowUnverifiedPtyStop ? 'allow-unverified-pty' : 'require-pty-stop'
|
||||
// Same reason for the archive waiver: a retry that waives the failed hook must not coalesce
|
||||
// onto the in-flight attempt that is about to refuse on it.
|
||||
const archiveKey = options.allowFailedArchiveHook ? 'allow-failed-archive' : 'require-archive'
|
||||
const hooksKey = options.runHooks ? 'run-hooks' : 'skip-hooks'
|
||||
return `${options.force ? 'force' : 'normal'}:${hooksKey}:${ptyKey}:${archiveKey}`
|
||||
}
|
||||
|
||||
// Null executionHostId means host-unaware: path-only callers match any repo, and the first runtime
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo } from '../shared/repo-types'
|
||||
import { gateRemovalWhereArchiveHookCannotRun } from './worktree-archive-hook-gate'
|
||||
import {
|
||||
ARCHIVE_HOOK_FAILED_REMOVAL_CODE,
|
||||
asArchiveHookRefusal
|
||||
} from '../shared/worktree/archive-hook-removal-gate'
|
||||
|
||||
// Mocked at the SSH-aware reader, because that is the whole point: on an SSH worktree the hook
|
||||
// lives on the execution host, not on the runtime's local disk.
|
||||
const { getArchiveHooksForRemovalMock } = vi.hoisted(() => ({
|
||||
getArchiveHooksForRemovalMock: vi.fn()
|
||||
}))
|
||||
vi.mock('./ipc/worktrees/removal/worktree-archive-hook', () => ({
|
||||
getArchiveHooksForRemoval: getArchiveHooksForRemovalMock
|
||||
}))
|
||||
|
||||
const REPO: Repo = { id: 'r', path: '/repo', displayName: 'r', badgeColor: '#000', addedAt: 0 }
|
||||
|
||||
const withArchiveHook = (present: boolean): void => {
|
||||
getArchiveHooksForRemovalMock.mockResolvedValue(
|
||||
present ? { scripts: { archive: 'archive.sh' } } : null
|
||||
)
|
||||
}
|
||||
|
||||
const gate = (over: Partial<Parameters<typeof gateRemovalWhereArchiveHookCannotRun>[0]> = {}) =>
|
||||
gateRemovalWhereArchiveHookCannotRun({
|
||||
repo: REPO,
|
||||
connectionId: undefined,
|
||||
worktreePath: '/w/f',
|
||||
runHooks: true,
|
||||
allowFailedArchiveHook: false,
|
||||
...over
|
||||
})
|
||||
|
||||
// Why (#19334 / S1): the runtime's SSH path runs no archive hook. Silently deleting there would
|
||||
// reproduce the reported bug in the one place `worktree.archive-failure-blocking.v1` promises it
|
||||
// cannot happen, so the capability would be advertising a guarantee it does not keep.
|
||||
describe('gateRemovalWhereArchiveHookCannotRun', () => {
|
||||
it('lets a repo with no archive hook through untouched', async () => {
|
||||
withArchiveHook(false)
|
||||
await expect(gate()).resolves.toEqual({})
|
||||
})
|
||||
|
||||
it('warns rather than refuses when hooks were not requested', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
withArchiveHook(true)
|
||||
await expect(gate({ runHooks: false })).resolves.toMatchObject({
|
||||
warning: expect.stringContaining('pass --run-hooks to run it')
|
||||
})
|
||||
})
|
||||
|
||||
// Why (#19334): reading locally would miss the committed hook on an SSH host entirely.
|
||||
it('asks the execution host whether a hook exists, not the local disk', async () => {
|
||||
withArchiveHook(false)
|
||||
await gate({ connectionId: 'ssh-target' })
|
||||
expect(getArchiveHooksForRemovalMock).toHaveBeenCalledWith(REPO, 'ssh-target')
|
||||
})
|
||||
|
||||
it('refuses a hooks-requested removal it cannot honour, as unverifiable', async () => {
|
||||
withArchiveHook(true)
|
||||
const refusal = asArchiveHookRefusal(await gate().catch((error: unknown) => error))
|
||||
|
||||
expect(refusal.code).toBe(ARCHIVE_HOOK_FAILED_REMOVAL_CODE)
|
||||
// Never `exited`: nothing ran, so nothing reported an exit to read.
|
||||
expect(refusal.data).toMatchObject({ worktreePath: '/w/f', outcome: 'unverifiable' })
|
||||
expect(refusal.data.exitCode).toBeUndefined()
|
||||
})
|
||||
|
||||
// Why this matters: without it the refusal is a dead loop. The desktop's "Delete Anyway" and the
|
||||
// CLI's --allow-failed-archive-hook both land here, and a block with no reachable exit on the
|
||||
// surface where it happens is the failure mode this PR fixed on the desktop path.
|
||||
it('deletes anyway when the refusal is explicitly waived, and records it', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
withArchiveHook(true)
|
||||
|
||||
const result = await gate({ allowFailedArchiveHook: true })
|
||||
|
||||
expect(result.warning).toBeUndefined()
|
||||
expect(result.override).toMatchObject({
|
||||
worktreePath: '/w/f',
|
||||
outcome: 'unverifiable',
|
||||
overridden: true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Repo } from '../shared/repo-types'
|
||||
import { getArchiveHooksForRemoval } from './ipc/worktrees/removal/worktree-archive-hook'
|
||||
import {
|
||||
WorktreeArchiveHookFailedError,
|
||||
formatArchiveHookOverride,
|
||||
type ArchiveHookFailure,
|
||||
classifyArchiveHookFailure,
|
||||
formatArchiveHookFailure,
|
||||
type ArchiveHookOverride,
|
||||
type ArchiveHookRunResult
|
||||
} from '../shared/worktree/archive-hook-removal-gate'
|
||||
|
||||
/**
|
||||
* The archive-hook precondition for a destructive worktree removal (#19334). Call it while the
|
||||
* checkout, its registration, its agents and its ownership evidence are all still intact: on a
|
||||
* failure it throws, and no caller may stop a PTY, deregister, or delete before it has returned.
|
||||
*
|
||||
* Returns the override record when the failure was explicitly waived, `undefined` on success.
|
||||
*/
|
||||
export function gateWorktreeRemovalOnArchiveHook(args: {
|
||||
worktreePath: string
|
||||
result: ArchiveHookRunResult
|
||||
allowFailure: boolean
|
||||
}): ArchiveHookOverride | undefined {
|
||||
if (args.result.success) {
|
||||
return undefined
|
||||
}
|
||||
const failure = classifyArchiveHookFailure(args.worktreePath, args.result)
|
||||
if (!args.allowFailure) {
|
||||
console.error(`[hooks] ${formatArchiveHookFailure(failure)}`)
|
||||
throw new WorktreeArchiveHookFailedError(failure)
|
||||
}
|
||||
console.warn(
|
||||
`[hooks] archive hook failure overridden for ${args.worktreePath}; deleting anyway:`,
|
||||
args.result.output
|
||||
)
|
||||
return { ...failure, overridden: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* The runtime's SSH removal path cannot run an archive hook at all (see #18563, which adds it).
|
||||
* Until it can, a removal that asked for hooks has to refuse rather than delete: deleting would
|
||||
* repeat exactly the bug this gate exists to stop, and reporting success would make
|
||||
* `worktree.archive-failure-blocking.v1` a lie in the one case the reporter asked it to cover.
|
||||
*
|
||||
* Modelled as `unverifiable` because that is what it is — the hook's outcome was never observed —
|
||||
* so it reuses the same typed error, the same `--allow-failed-archive-hook` waiver, and the same
|
||||
* desktop "Delete Anyway" affordance as any other unobserved hook. Waiving it records the same
|
||||
* `archiveHookOverride` the other paths return, so a caller is told what it accepted.
|
||||
*
|
||||
* Returns the skipped-hook warning when hooks were not requested, matching the local path.
|
||||
*
|
||||
* Hooks are read through `getArchiveHooksForRemoval` rather than `getEffectiveHooks`: on an
|
||||
* SSH-hosted worktree `repo.path` names a path on the EXECUTION host, so a local read would miss
|
||||
* the committed `orca.yaml` this gate exists for, and could refuse on a coincidental local one.
|
||||
*/
|
||||
export async function gateRemovalWhereArchiveHookCannotRun(args: {
|
||||
repo: Repo
|
||||
/** The removal route's owner; `repo.connectionId` is null for an `ssh:`-only row. */
|
||||
connectionId: string | undefined
|
||||
worktreePath: string
|
||||
runHooks: boolean
|
||||
/** Explicit waiver. Without it the refusal below has no exit on this path. */
|
||||
allowFailedArchiveHook: boolean
|
||||
}): Promise<{ warning?: string; override?: ArchiveHookOverride }> {
|
||||
const hooks = await getArchiveHooksForRemoval(args.repo, args.connectionId)
|
||||
if (!hooks?.scripts.archive) {
|
||||
return {}
|
||||
}
|
||||
if (!args.runHooks) {
|
||||
const warning = `orca.yaml archive hook skipped for ${args.worktreePath}; pass --run-hooks to run it.`
|
||||
console.warn(`[hooks] ${warning}`)
|
||||
return { warning }
|
||||
}
|
||||
const failure: ArchiveHookFailure = {
|
||||
worktreePath: args.worktreePath,
|
||||
outcome: 'unverifiable',
|
||||
output:
|
||||
'This host cannot run an archive hook for an SSH-hosted worktree, so the hook never ran. Remove it from the desktop app, which does run it, or delete anyway to accept that nothing was archived.'
|
||||
}
|
||||
if (!args.allowFailedArchiveHook) {
|
||||
throw new WorktreeArchiveHookFailedError(failure)
|
||||
}
|
||||
console.warn(`[hooks] ${formatArchiveHookOverride({ ...failure, overridden: true })}`)
|
||||
return { override: { ...failure, overridden: true } }
|
||||
}
|
||||
@@ -98,6 +98,9 @@ export type WorktreeApi = {
|
||||
// may waive the proof that every PTY stopped.
|
||||
allowUnverifiedPtyStop?: boolean
|
||||
skipArchive?: boolean
|
||||
// Why (#19334): distinct from `skipArchive` (never runs the hook) and never implied by
|
||||
// `force` — this waives a hook that ran and FAILED.
|
||||
allowFailedArchiveHook?: boolean
|
||||
snapshotPruneBatchId?: string
|
||||
}) => Promise<RemoveWorktreeResult>
|
||||
// Forget a workspace from Orca only (no remote Git/FS work) — for workspaces pinned to a removed/disconnected SSH host.
|
||||
|
||||
@@ -102,6 +102,13 @@ function showDeleteFailureToast(): void {
|
||||
),
|
||||
canForceDelete: true,
|
||||
forceDeleteReason: 'dirty',
|
||||
onDeleteAnyway: () =>
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.settings.DevToolsPane.deleteAnywayClicked',
|
||||
'Delete Anyway clicked'
|
||||
)
|
||||
),
|
||||
onViewChanges: () =>
|
||||
toast.message(
|
||||
translate(
|
||||
|
||||
@@ -51,6 +51,7 @@ describe('showDeleteWorktreeFailureToast', () => {
|
||||
it('uses a persistent in-body action footer when force delete is available', () => {
|
||||
const onViewChanges = vi.fn()
|
||||
const onForceDelete = vi.fn()
|
||||
const onDeleteAnyway = vi.fn()
|
||||
|
||||
showDeleteWorktreeFailureToast({
|
||||
error: 'branch has changes',
|
||||
@@ -58,6 +59,7 @@ describe('showDeleteWorktreeFailureToast', () => {
|
||||
forceDeleteReason: 'dirty',
|
||||
onViewChanges,
|
||||
onForceDelete,
|
||||
onDeleteAnyway,
|
||||
worktreeId: 'wt-1',
|
||||
worktreeName: 'feature/foo'
|
||||
})
|
||||
@@ -97,6 +99,7 @@ describe('showDeleteWorktreeFailureToast', () => {
|
||||
forceDeleteReason: null,
|
||||
onViewChanges,
|
||||
onForceDelete: vi.fn(),
|
||||
onDeleteAnyway: vi.fn(),
|
||||
worktreeId: 'wt-2',
|
||||
worktreeName: 'feature/bar'
|
||||
})
|
||||
@@ -119,6 +122,53 @@ describe('showDeleteWorktreeFailureToast', () => {
|
||||
expect(onViewChanges).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// #19334: the archive-hook refusal is the one failure a user clears by waiving rather than by
|
||||
// fixing state, and the desktop is where most people meet it.
|
||||
it('offers Delete Anyway when the archive hook refused the removal', () => {
|
||||
const onDeleteAnyway = vi.fn()
|
||||
|
||||
showDeleteWorktreeFailureToast({
|
||||
error: 'Archive hook failed for worktree: /w/feature — exited 23.',
|
||||
canForceDelete: false,
|
||||
forceDeleteReason: null,
|
||||
canWaiveArchiveHook: true,
|
||||
onViewChanges: vi.fn(),
|
||||
onForceDelete: vi.fn(),
|
||||
onDeleteAnyway,
|
||||
worktreeId: 'wt-archive',
|
||||
worktreeName: 'feature/archive'
|
||||
})
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
'Failed to delete workspace feature/archive',
|
||||
// The user has to read the reason before choosing, so this toast must not expire.
|
||||
expect.objectContaining({ duration: Infinity })
|
||||
)
|
||||
|
||||
const body = renderToastBody('error')
|
||||
expect(body.textContent).toContain('Delete Anyway')
|
||||
expect(body.textContent).not.toContain('Force Delete')
|
||||
|
||||
clickButton(body, 'Delete Anyway')
|
||||
expect(toast.dismiss).toHaveBeenCalledWith('delete-worktree-failure:wt-archive')
|
||||
expect(onDeleteAnyway).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not offer Delete Anyway for an ordinary failure', () => {
|
||||
showDeleteWorktreeFailureToast({
|
||||
error: 'permission denied',
|
||||
canForceDelete: false,
|
||||
forceDeleteReason: null,
|
||||
onViewChanges: vi.fn(),
|
||||
onForceDelete: vi.fn(),
|
||||
onDeleteAnyway: vi.fn(),
|
||||
worktreeId: 'wt-plain',
|
||||
worktreeName: 'feature/plain'
|
||||
})
|
||||
|
||||
expect(renderToastBody('error').textContent).not.toContain('Delete Anyway')
|
||||
})
|
||||
|
||||
it('offers neither force delete nor View for a locked workspace', () => {
|
||||
const onViewChanges = vi.fn()
|
||||
|
||||
@@ -128,6 +178,7 @@ describe('showDeleteWorktreeFailureToast', () => {
|
||||
forceDeleteReason: null,
|
||||
onViewChanges,
|
||||
onForceDelete: vi.fn(),
|
||||
onDeleteAnyway: vi.fn(),
|
||||
worktreeId: 'wt-locked',
|
||||
worktreeName: 'feature/locked'
|
||||
})
|
||||
@@ -151,6 +202,7 @@ describe('showDeleteWorktreeFailureToast', () => {
|
||||
hasKnownChanges: true,
|
||||
onViewChanges: vi.fn(),
|
||||
onForceDelete: vi.fn(),
|
||||
onDeleteAnyway: vi.fn(),
|
||||
worktreeId: 'wt-locked-dirty',
|
||||
worktreeName: 'feature/locked-dirty'
|
||||
})
|
||||
|
||||
@@ -13,8 +13,11 @@ type DeleteWorktreeFailureToastOptions = {
|
||||
forceDeleteReason: WorktreeForceDeleteReason | null
|
||||
lockReason?: string | null
|
||||
hasKnownChanges?: boolean
|
||||
/** The archive hook refused this removal, so the user may waive it (#19334). */
|
||||
canWaiveArchiveHook?: boolean
|
||||
onViewChanges: () => void
|
||||
onForceDelete: () => void
|
||||
onDeleteAnyway: () => void
|
||||
worktreeId: string
|
||||
worktreeName: string
|
||||
}
|
||||
@@ -26,16 +29,20 @@ function deleteWorktreeFailureToastId(worktreeId: string): string {
|
||||
function DeleteWorktreeFailureToastBody({
|
||||
description,
|
||||
canForceDelete,
|
||||
canWaiveArchiveHook,
|
||||
showViewChanges,
|
||||
onViewChanges,
|
||||
onForceDelete,
|
||||
onDeleteAnyway,
|
||||
toastId
|
||||
}: {
|
||||
description?: string
|
||||
canForceDelete: boolean
|
||||
canWaiveArchiveHook: boolean
|
||||
showViewChanges: boolean
|
||||
onViewChanges: () => void
|
||||
onForceDelete: () => void
|
||||
onDeleteAnyway: () => void
|
||||
toastId: string
|
||||
}): React.JSX.Element {
|
||||
const viewChanges = (): void => {
|
||||
@@ -46,6 +53,10 @@ function DeleteWorktreeFailureToastBody({
|
||||
toast.dismiss(toastId)
|
||||
onForceDelete()
|
||||
}
|
||||
const deleteAnyway = (): void => {
|
||||
toast.dismiss(toastId)
|
||||
onDeleteAnyway()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
@@ -63,6 +74,14 @@ function DeleteWorktreeFailureToastBody({
|
||||
{translate('auto.components.sidebar.delete.worktree.flow.2b20ce87b3', 'Force Delete')}
|
||||
</Button>
|
||||
) : null}
|
||||
{canWaiveArchiveHook ? (
|
||||
<Button type="button" variant="destructive" size="sm" onClick={deleteAnyway}>
|
||||
{translate(
|
||||
'auto.components.sidebar.delete.worktree.failure.archive.waiver',
|
||||
'Delete Anyway'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -74,8 +93,10 @@ export function showDeleteWorktreeFailureToast({
|
||||
forceDeleteReason,
|
||||
lockReason,
|
||||
hasKnownChanges,
|
||||
canWaiveArchiveHook,
|
||||
onViewChanges,
|
||||
onForceDelete,
|
||||
onDeleteAnyway,
|
||||
worktreeId,
|
||||
worktreeName
|
||||
}: DeleteWorktreeFailureToastOptions): void {
|
||||
@@ -96,13 +117,16 @@ export function showDeleteWorktreeFailureToast({
|
||||
<DeleteWorktreeFailureToastBody
|
||||
description={toastCopy.description}
|
||||
canForceDelete={canForceDelete}
|
||||
canWaiveArchiveHook={canWaiveArchiveHook === true}
|
||||
showViewChanges={!isLockedWorktreeRemovalError(error) || hasKnownChanges === true}
|
||||
onViewChanges={onViewChanges}
|
||||
onForceDelete={onForceDelete}
|
||||
onDeleteAnyway={onDeleteAnyway}
|
||||
toastId={id}
|
||||
/>
|
||||
),
|
||||
duration: canForceDelete ? Infinity : 10000,
|
||||
// A toast offering a destructive choice must not expire before the user reads the reason.
|
||||
duration: canForceDelete || canWaiveArchiveHook === true ? Infinity : 10000,
|
||||
dismissible: true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
type MockWorktreeDeleteState = {
|
||||
isDeleting?: boolean
|
||||
error?: string | null
|
||||
canForceDelete?: boolean
|
||||
forceDeleteReason?: 'dirty' | null
|
||||
lockReason?: string | null
|
||||
canWaiveArchiveHook?: boolean
|
||||
executionHostId?: ExecutionHostId | null
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
// Declared up here so the empty initialisers can be typed rather than asserted.
|
||||
const gitStatusByWorktree: Record<string, unknown[]> = {}
|
||||
const deleteStateByWorktreeId: Record<string, MockWorktreeDeleteState> = {}
|
||||
const state = {
|
||||
settings: { skipDeleteWorktreeConfirm: false },
|
||||
worktreeMap: new Map<
|
||||
@@ -35,18 +48,8 @@ const mocks = vi.hoisted(() => {
|
||||
setRightSidebarTab: vi.fn(),
|
||||
setRightSidebarOpen: vi.fn(),
|
||||
removeWorktree: vi.fn().mockResolvedValue({ ok: true }),
|
||||
gitStatusByWorktree: {} as Record<string, unknown[]>,
|
||||
deleteStateByWorktreeId: {} as Record<
|
||||
string,
|
||||
{
|
||||
isDeleting?: boolean
|
||||
error?: string | null
|
||||
canForceDelete?: boolean
|
||||
forceDeleteReason?: 'dirty' | null
|
||||
lockReason?: string | null
|
||||
executionHostId?: ExecutionHostId | null
|
||||
}
|
||||
>
|
||||
gitStatusByWorktree,
|
||||
deleteStateByWorktreeId
|
||||
}
|
||||
return { state }
|
||||
})
|
||||
@@ -631,4 +634,42 @@ describe('delete worktree flow', () => {
|
||||
description: 'Refresh Space and try again if the workspace list looks stale.'
|
||||
})
|
||||
})
|
||||
|
||||
// #19334: a waived delete is still a delete — the caller's bookkeeping has to hear about it, or a
|
||||
// batch/Space-panel list keeps showing the workspace it just removed.
|
||||
it('reports a Delete Anyway success to the caller like a force retry', async () => {
|
||||
mocks.state.settings = { skipDeleteWorktreeConfirm: true }
|
||||
mocks.state.removeWorktree
|
||||
.mockImplementationOnce(async () => {
|
||||
mocks.state.deleteStateByWorktreeId['wt-1'] = {
|
||||
isDeleting: false,
|
||||
error: 'Archive hook failed for worktree: /w/one — exited 23.',
|
||||
canForceDelete: false,
|
||||
forceDeleteReason: null,
|
||||
canWaiveArchiveHook: true
|
||||
}
|
||||
return { ok: false, error: 'Archive hook failed for worktree: /w/one — exited 23.' }
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
setWorktrees([{ id: 'wt-1', displayName: 'one' }])
|
||||
const onDeleted = vi.fn()
|
||||
|
||||
expect(runWorktreeBatchDelete(['wt-1'], { onDeleted })).toBe(true)
|
||||
|
||||
await vi.waitFor(() => expect(showDeleteWorktreeFailureToast).toHaveBeenCalled())
|
||||
const toastOptions = vi.mocked(showDeleteWorktreeFailureToast).mock.calls[0]?.[0]
|
||||
expect(toastOptions?.canWaiveArchiveHook).toBe(true)
|
||||
toastOptions?.onDeleteAnyway()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// The waiver rides its own option; force stays whatever the original attempt used.
|
||||
expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{ id: 'wt-1', executionHostId: null },
|
||||
false,
|
||||
{ allowFailedArchiveHook: true }
|
||||
)
|
||||
expect(onDeleted).toHaveBeenCalledWith([{ id: 'wt-1', executionHostId: null }])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,6 +38,54 @@ export function runWorktreeDeleteWithToast(
|
||||
...(options.suppressPreservedBranchToast ? { suppressPreservedBranchToast: true } : {}),
|
||||
...(options.snapshotPruneBatchId ? { snapshotPruneBatchId: options.snapshotPruneBatchId } : {})
|
||||
}
|
||||
// Both toast buttons do the same thing: recapture focus (the user may have navigated while the
|
||||
// toast was open), retry with one waiver added, and report a success through `onForceDeleted` so
|
||||
// the caller's bookkeeping runs. Only the waiver and the failure copy differ.
|
||||
const retryFromToast = (retry: {
|
||||
force: boolean
|
||||
allowUnverifiedPtyStop?: boolean
|
||||
allowFailedArchiveHook?: boolean
|
||||
failedTitle?: string
|
||||
withViewAction?: boolean
|
||||
}): void => {
|
||||
const commitRetryFocus = prepareActiveWorktreeFocusAfterDelete(worktreeId)
|
||||
const viewAction = retry.withViewAction
|
||||
? {
|
||||
action: {
|
||||
label: translate('auto.components.sidebar.delete.worktree.flow.7488ed8711', 'View'),
|
||||
onClick: () => viewWorktreeDiff(worktreeId, target.executionHostId)
|
||||
}
|
||||
}
|
||||
: {}
|
||||
const failed = (description: string): void => {
|
||||
toast.error(
|
||||
retry.failedTitle ??
|
||||
translate(
|
||||
'auto.components.sidebar.delete.worktree.flow.ae57cbf6e4',
|
||||
'Failed to delete workspace'
|
||||
),
|
||||
{ description, ...viewAction }
|
||||
)
|
||||
}
|
||||
useAppStore
|
||||
.getState()
|
||||
.removeWorktree(target, retry.force, {
|
||||
...(retry.allowUnverifiedPtyStop ? { allowUnverifiedPtyStop: true } : {}),
|
||||
...(retry.allowFailedArchiveHook ? { allowFailedArchiveHook: true } : {})
|
||||
})
|
||||
.then((result) => {
|
||||
if (!result.ok) {
|
||||
failed(result.error)
|
||||
return
|
||||
}
|
||||
commitRetryFocus()
|
||||
// "A retry started from this toast completed the delete" — callers hang their bookkeeping
|
||||
// off it, so without this a batch or Space-panel delete keeps listing what it removed.
|
||||
options.onForceDeleted?.(target)
|
||||
})
|
||||
.catch((err: unknown) => failed(err instanceof Error ? err.message : String(err)))
|
||||
}
|
||||
|
||||
const removal =
|
||||
Object.keys(removeOptions).length > 0
|
||||
? removeWorktree(target, options.force === true, removeOptions)
|
||||
@@ -71,60 +119,26 @@ export function runWorktreeDeleteWithToast(
|
||||
showDeleteWorktreeFailureToast({
|
||||
error: result.error,
|
||||
canForceDelete,
|
||||
canWaiveArchiveHook: state?.canWaiveArchiveHook === true,
|
||||
forceDeleteReason: state?.forceDeleteReason ?? null,
|
||||
lockReason: state?.lockReason ?? null,
|
||||
hasKnownChanges,
|
||||
onViewChanges: () => viewWorktreeDiff(worktreeId, target.executionHostId),
|
||||
onForceDelete: () => {
|
||||
// Recapture focus because the user may have navigated while the toast was open.
|
||||
const commitForceFocus = prepareActiveWorktreeFocusAfterDelete(worktreeId)
|
||||
// The explicit Force Delete retry may waive an unverified PTY-stop proof.
|
||||
const forceRemoval = useAppStore
|
||||
.getState()
|
||||
.removeWorktree(target, true, { allowUnverifiedPtyStop: true })
|
||||
forceRemoval
|
||||
.then((forceResult) => {
|
||||
if (!forceResult.ok) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.sidebar.delete.worktree.flow.4f3876c0f5',
|
||||
'Force delete failed'
|
||||
),
|
||||
{
|
||||
description: forceResult.error,
|
||||
action: {
|
||||
label: translate(
|
||||
'auto.components.sidebar.delete.worktree.flow.7488ed8711',
|
||||
'View'
|
||||
),
|
||||
onClick: () => viewWorktreeDiff(worktreeId, target.executionHostId)
|
||||
}
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
commitForceFocus()
|
||||
options.onForceDeleted?.(target)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.sidebar.delete.worktree.flow.ae57cbf6e4',
|
||||
'Failed to delete workspace'
|
||||
),
|
||||
{
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
action: {
|
||||
label: translate(
|
||||
'auto.components.sidebar.delete.worktree.flow.7488ed8711',
|
||||
'View'
|
||||
),
|
||||
onClick: () => viewWorktreeDiff(worktreeId, target.executionHostId)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
},
|
||||
// Why (#19334): re-runs the archive hook and waives the failure this time, so the waiver
|
||||
// is an informed choice made after reading the refusal -- not something `force` implied.
|
||||
onDeleteAnyway: () =>
|
||||
retryFromToast({ force: options.force === true, allowFailedArchiveHook: true }),
|
||||
// The explicit Force Delete retry may waive an unverified PTY-stop proof.
|
||||
onForceDelete: () =>
|
||||
retryFromToast({
|
||||
force: true,
|
||||
allowUnverifiedPtyStop: true,
|
||||
failedTitle: translate(
|
||||
'auto.components.sidebar.delete.worktree.flow.4f3876c0f5',
|
||||
'Force delete failed'
|
||||
),
|
||||
withViewAction: true
|
||||
}),
|
||||
worktreeId,
|
||||
worktreeName
|
||||
})
|
||||
|
||||
@@ -5802,6 +5802,11 @@
|
||||
"unstoppedPtyLive": "This workspace still has running terminals, so Orca stopped before deleting any files. Force Delete will kill them and discard any uncommitted work they hold.",
|
||||
"runningAgentSession": "Orca could not confirm every agent session in this workspace has closed, so it stopped before deleting any files. Use Force Delete to remove it anyway.",
|
||||
"runningAgentSessionLive": "This workspace still has running agent sessions that Orca could not close, so it stopped before deleting any files. Force Delete will discard any work they hold."
|
||||
},
|
||||
"failure": {
|
||||
"archive": {
|
||||
"waiver": "Delete Anyway"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -10953,7 +10958,8 @@
|
||||
"orcaCloudSignOut": "Sign out",
|
||||
"orcaCloudConnect": "Connect profile",
|
||||
"orcaCloudRefresh": "Refresh status",
|
||||
"orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build."
|
||||
"orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build.",
|
||||
"deleteAnywayClicked": "Delete Anyway clicked"
|
||||
},
|
||||
"EphemeralVmRecipeRow": {
|
||||
"useInWorkspace": "Use in workspace"
|
||||
|
||||
@@ -131,3 +131,38 @@ describe('readIpcErrorDetail', () => {
|
||||
).toBe('SSH connection failed: Relay package not found.')
|
||||
})
|
||||
})
|
||||
|
||||
// Why (#19334): a typed main-process error keeps its class name after the wrapper comes off, and
|
||||
// the worktree-removal refusal is rendered to a user who does not care what the class was called.
|
||||
describe('typed main-process errors', () => {
|
||||
const wrapped = new Error(
|
||||
"Error invoking remote method 'worktrees:remove': WorktreeArchiveHookFailedError: " +
|
||||
'Archive hook failed for worktree: /w/feature — exited 23.\nbackup target unreachable'
|
||||
)
|
||||
|
||||
it('drops the error class name along with the wrapper', () => {
|
||||
expect(readIpcErrorDetail(wrapped)).toBe(
|
||||
'Archive hook failed for worktree: /w/feature — exited 23.\nbackup target unreachable'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the detail lines a refusal needs, unlike the clamped read', () => {
|
||||
// The hook's own output is the actionable part of an archive refusal, so the unclamped read
|
||||
// has to survive the newline that `readIpcErrorMessage` deliberately cuts at.
|
||||
expect(readIpcErrorMessage(wrapped)).toBe(
|
||||
'Archive hook failed for worktree: /w/feature — exited 23.'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves a renderer-local error its class name, having unwrapped nothing', () => {
|
||||
expect(readIpcErrorDetail(new Error('TypeError: x is not a function'))).toBe(
|
||||
'TypeError: x is not a function'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not mistake an errno prefix for a class name', () => {
|
||||
expect(
|
||||
readIpcErrorDetail(new Error("Error occurred in handler for 'fs:read': EACCES: denied"))
|
||||
).toBe('EACCES: denied')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Unanchored so caller-owned context around an Electron wrapper survives.
|
||||
const IPC_INVOKE_PREFIX = /Error invoking remote method '[^']*':\s*(?:Error:\s*)?/
|
||||
const IPC_HANDLER_PREFIX = /Error occurred in handler for '[^']*':\s*(?:Error:\s*)?/
|
||||
// Why (#19334): once the wrapper is gone, a typed main-process error still leads with its class
|
||||
// name — "WorktreeArchiveHookFailedError: Archive hook failed for worktree: …". That is noise to
|
||||
// someone reading a toast, and it pushes the sentence that matters off the first line.
|
||||
const ERROR_CLASS_PREFIX = /^(?:[A-Za-z_$][\w$]*)?Error:\s*/
|
||||
|
||||
function unwrapIpcErrorMessage(message: string): string | undefined {
|
||||
const detail = message.replace(IPC_INVOKE_PREFIX, '').replace(IPC_HANDLER_PREFIX, '').trim()
|
||||
@@ -11,7 +15,17 @@ export function compactIpcErrorMessage(message: string): string | undefined {
|
||||
return unwrapIpcErrorMessage(message)?.split('\n')[0]?.trim() || undefined
|
||||
}
|
||||
export function readIpcErrorDetail(error: unknown): string | undefined {
|
||||
return error instanceof Error ? unwrapIpcErrorMessage(error.message) : undefined
|
||||
if (!(error instanceof Error)) {
|
||||
return undefined
|
||||
}
|
||||
const detail = unwrapIpcErrorMessage(error.message)
|
||||
if (!detail) {
|
||||
return undefined
|
||||
}
|
||||
// Only strip the class name off something we actually unwrapped, so a renderer-local
|
||||
// `TypeError: …` keeps the prefix that tells you what it was.
|
||||
const wrapped = IPC_INVOKE_PREFIX.test(error.message) || IPC_HANDLER_PREFIX.test(error.message)
|
||||
return wrapped ? detail.replace(ERROR_CLASS_PREFIX, '').trim() || undefined : detail
|
||||
}
|
||||
|
||||
export function readIpcErrorMessage(error: unknown): string | undefined {
|
||||
|
||||
@@ -377,18 +377,34 @@ describe('removeWorktree cascade', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('offers force delete for Electron-wrapped local dirty preflight errors', async () => {
|
||||
// Why a table (#19334): these three differ only in the wrapped message and the classification it
|
||||
// earns. The shared body is what matters — the IPC wrapper is stripped for display while
|
||||
// classification still reads the wrapped input.
|
||||
it.each([
|
||||
[
|
||||
'offers force delete for Electron-wrapped local dirty preflight errors',
|
||||
"Error invoking remote method 'worktrees:remove': Error: Failed to delete worktree at /workspace/feature-wt. ?? scratch.txt",
|
||||
'Failed to delete worktree at /workspace/feature-wt. ?? scratch.txt',
|
||||
{ canForceDelete: true, forceDeleteReason: 'dirty' }
|
||||
],
|
||||
[
|
||||
'offers force delete when Git already removed an unregistered worktree',
|
||||
"Error invoking remote method 'worktrees:remove': Error: Worktree is no longer registered with Git and its directory is already gone.",
|
||||
'Worktree is no longer registered with Git and its directory is already gone.',
|
||||
{ canForceDelete: true, forceDeleteReason: 'missing-registration' }
|
||||
],
|
||||
[
|
||||
'does not offer force delete when Electron wraps SSH filesystem provider failures',
|
||||
"Error invoking remote method 'worktrees:remove': Error: SSH filesystem provider unavailable",
|
||||
'SSH filesystem provider unavailable',
|
||||
{ canForceDelete: false, forceDeleteReason: null }
|
||||
]
|
||||
])('%s', async (_title, wrapped, displayed, classification) => {
|
||||
const store = createTestStore()
|
||||
const worktreeId = 'repo1::/workspace/feature-wt'
|
||||
const error =
|
||||
"Error invoking remote method 'worktrees:remove': Error: Failed to delete worktree at /workspace/feature-wt. ?? scratch.txt"
|
||||
|
||||
mockApi.worktrees.remove.mockRejectedValueOnce(new Error(error))
|
||||
|
||||
mockApi.worktrees.remove.mockRejectedValueOnce(new Error(wrapped))
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1' })]
|
||||
},
|
||||
worktreesByRepo: { repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1' })] },
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
terminalLayoutsByTabId: {}
|
||||
@@ -396,12 +412,11 @@ describe('removeWorktree cascade', () => {
|
||||
|
||||
const result = await store.getState().removeWorktree({ id: worktreeId, executionHostId: null })
|
||||
|
||||
expect(result).toEqual({ ok: false, error })
|
||||
expect(result).toEqual({ ok: false, error: displayed })
|
||||
expect(store.getState().deleteStateByWorktreeId[worktreeId]).toEqual({
|
||||
isDeleting: false,
|
||||
error,
|
||||
canForceDelete: true,
|
||||
forceDeleteReason: 'dirty'
|
||||
error: displayed,
|
||||
...classification
|
||||
})
|
||||
})
|
||||
|
||||
@@ -462,34 +477,6 @@ describe('removeWorktree cascade', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('offers force delete when Git already removed an unregistered worktree', async () => {
|
||||
const store = createTestStore()
|
||||
const worktreeId = 'repo1::/workspace/deleted-wt'
|
||||
const error =
|
||||
"Error invoking remote method 'worktrees:remove': Error: Worktree is no longer registered with Git and its directory is already gone."
|
||||
|
||||
mockApi.worktrees.remove.mockRejectedValueOnce(new Error(error))
|
||||
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1' })]
|
||||
},
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
terminalLayoutsByTabId: {}
|
||||
})
|
||||
|
||||
const result = await store.getState().removeWorktree({ id: worktreeId, executionHostId: null })
|
||||
|
||||
expect(result).toEqual({ ok: false, error })
|
||||
expect(store.getState().deleteStateByWorktreeId[worktreeId]).toEqual({
|
||||
isDeleting: false,
|
||||
error,
|
||||
canForceDelete: true,
|
||||
forceDeleteReason: 'missing-registration'
|
||||
})
|
||||
})
|
||||
|
||||
it('sets canForceDelete=false when force=true removal fails', async () => {
|
||||
const store = createTestStore()
|
||||
const worktreeId = 'repo1::/path/wt1'
|
||||
@@ -579,34 +566,6 @@ describe('removeWorktree cascade', () => {
|
||||
expect(store.getState().deleteStateByWorktreeId[worktreeId]?.canForceDelete).toBe(false)
|
||||
})
|
||||
|
||||
it('does not offer force delete when Electron wraps SSH filesystem provider failures', async () => {
|
||||
const store = createTestStore()
|
||||
const worktreeId = 'repo1::/path/wt1'
|
||||
const error =
|
||||
"Error invoking remote method 'worktrees:remove': Error: SSH filesystem provider unavailable"
|
||||
|
||||
mockApi.worktrees.remove.mockRejectedValueOnce(new Error(error))
|
||||
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1' })]
|
||||
},
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
terminalLayoutsByTabId: {}
|
||||
})
|
||||
|
||||
const result = await store.getState().removeWorktree({ id: worktreeId, executionHostId: null })
|
||||
|
||||
expect(result).toEqual({ ok: false, error })
|
||||
expect(store.getState().deleteStateByWorktreeId[worktreeId]).toEqual({
|
||||
isDeleting: false,
|
||||
error,
|
||||
canForceDelete: false,
|
||||
forceDeleteReason: null
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'Could not connect to the remote Orca runtime.',
|
||||
'Remote Orca runtime closed the connection.',
|
||||
@@ -617,6 +576,8 @@ describe('removeWorktree cascade', () => {
|
||||
const store = createTestStore()
|
||||
const worktreeId = 'repo1::/path/wt1'
|
||||
const error = `Error invoking remote method 'runtime-environments:call': Error: ${runtimeFailure}`
|
||||
// The wrapper is stripped for display; the runtime failure text is what the user sees.
|
||||
const displayed = runtimeFailure
|
||||
|
||||
mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => {
|
||||
const compatibility = createCompatibleRuntimeStatusResponseIfNeeded(args)
|
||||
@@ -648,10 +609,10 @@ describe('removeWorktree cascade', () => {
|
||||
.getState()
|
||||
.removeWorktree({ id: worktreeId, executionHostId: null })
|
||||
|
||||
expect(result).toEqual({ ok: false, error })
|
||||
expect(result).toEqual({ ok: false, error: displayed })
|
||||
expect(store.getState().deleteStateByWorktreeId[worktreeId]).toEqual({
|
||||
isDeleting: false,
|
||||
error,
|
||||
error: displayed,
|
||||
canForceDelete: false,
|
||||
forceDeleteReason: null
|
||||
})
|
||||
|
||||
@@ -10,6 +10,8 @@ export type WorktreeDeleteState = {
|
||||
canForceDelete: boolean
|
||||
forceDeleteReason: WorktreeForceDeleteReason | null
|
||||
lockReason?: string | null
|
||||
/** The removal was refused by a failed archive hook, so "Delete anyway" is offered (#19334). */
|
||||
canWaiveArchiveHook?: boolean
|
||||
}
|
||||
|
||||
export type WorktreeDeleteStateTarget = Pick<Worktree, 'id' | 'hostId'>
|
||||
|
||||
@@ -9,6 +9,9 @@ export type RemoveWorktreeOptions = {
|
||||
// Why (#11960): only an explicit Force Delete waives the proof that every
|
||||
// PTY stopped; `force` alone is set by the ordinary delete confirmation.
|
||||
allowUnverifiedPtyStop?: boolean
|
||||
// Why (#19334): waives a FAILED archive hook. Set only by the explicit "Delete anyway" retry
|
||||
// after the user has seen the refusal -- never by the ordinary confirmation, never by `force`.
|
||||
allowFailedArchiveHook?: boolean
|
||||
snapshotPruneBatchId?: string
|
||||
/** Fresh cleanup-scan evidence for a same-id owner not represented in the catalog. */
|
||||
sameIdSurvivingHostId?: ExecutionHostId
|
||||
|
||||
@@ -601,6 +601,8 @@ describe('worktree remote runtime mutations', () => {
|
||||
force: undefined,
|
||||
// Why (#11960): an ordinary remove never waives the PTY-stop proof.
|
||||
allowUnverifiedPtyStop: false,
|
||||
// Why (#19334): nor a failed archive hook — only the explicit "Delete anyway" retry does.
|
||||
allowFailedArchiveHook: false,
|
||||
skipArchive: false
|
||||
})
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
|
||||
@@ -36,6 +36,7 @@ export async function dispatchWorktreeRemoval(args: {
|
||||
hostId,
|
||||
force,
|
||||
allowUnverifiedPtyStop: options?.allowUnverifiedPtyStop === true,
|
||||
allowFailedArchiveHook: options?.allowFailedArchiveHook === true,
|
||||
skipArchive,
|
||||
...snapshotPruneBatch
|
||||
})
|
||||
@@ -50,6 +51,9 @@ export async function dispatchWorktreeRemoval(args: {
|
||||
...(effectiveHostId ? { hostId: effectiveHostId } : {}),
|
||||
force,
|
||||
allowUnverifiedPtyStop: options?.allowUnverifiedPtyStop === true,
|
||||
// Why only when set, unlike the IPC branch: this crosses a version boundary, and a host
|
||||
// that predates the gate drops unknown params silently. Send it when it means something.
|
||||
...(options?.allowFailedArchiveHook === true ? { allowFailedArchiveHook: true } : {}),
|
||||
runHooks: !skipArchive
|
||||
},
|
||||
{ timeoutMs: 60_000 }
|
||||
|
||||
@@ -7,6 +7,8 @@ import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
|
||||
import { getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client'
|
||||
import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-control-huge-repo-warning-dismissals'
|
||||
import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent'
|
||||
import { readIpcErrorDetail } from '@/lib/ipc-error'
|
||||
import { isArchiveHookRemovalError } from '../../../../../../shared/worktree/archive-hook-removal-gate'
|
||||
import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast'
|
||||
import {
|
||||
resolveWorktreeOperationRouteResult,
|
||||
@@ -297,13 +299,18 @@ export function createRemoveWorktree(
|
||||
} catch (err) {
|
||||
// Why: git refusing a non-force delete for dirty/untracked files is a handled user decision, not an app error.
|
||||
console.warn('Failed to remove worktree:', err)
|
||||
const error = err instanceof Error ? err.message : String(err)
|
||||
// The raw message arrives wrapped in Electron's IPC channel and class names; this string is
|
||||
// read by a user in a toast, and the refusal sentence has to lead it.
|
||||
const error = readIpcErrorDetail(err) ?? (err instanceof Error ? err.message : String(err))
|
||||
const forceDeleteReason = classifyWorktreeForceDeleteReason(
|
||||
error,
|
||||
force,
|
||||
options?.allowUnverifiedPtyStop === true
|
||||
)
|
||||
const locked = isLockedWorktreeRemovalError(error)
|
||||
// Why (#19334): the refusal is the only failure a retry can clear by waiving rather than by
|
||||
// fixing state, so the toast needs to know it may offer that choice.
|
||||
const canWaiveArchiveHook = isArchiveHookRemovalError(error)
|
||||
set((s) => ({
|
||||
deleteStateByWorktreeId: {
|
||||
...s.deleteStateByWorktreeId,
|
||||
@@ -313,6 +320,7 @@ export function createRemoveWorktree(
|
||||
error,
|
||||
canForceDelete: forceDeleteReason !== null,
|
||||
forceDeleteReason,
|
||||
...(canWaiveArchiveHook ? { canWaiveArchiveHook: true } : {}),
|
||||
...(locked ? { lockReason: getLockedWorktreeRemovalReason(error) } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export const CLI_GLOBAL_FLAGS: readonly string[] = ['help', 'json', ...CLI_GLOBA
|
||||
|
||||
export const CLI_BOOLEAN_FLAGS = new Set([
|
||||
'all',
|
||||
'allow-failed-archive-hook',
|
||||
'attachments',
|
||||
'children',
|
||||
'comments',
|
||||
|
||||
@@ -114,6 +114,13 @@ export const TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY = 'terminal.quick-comman
|
||||
// status.worktreeCreateIdempotency carries the optional host retention policy.
|
||||
export const WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY =
|
||||
'worktree.create-idempotency.v1' as const
|
||||
// Why (#19334): "accepts --run-hooks" and "refuses to delete when the archive hook fails" were
|
||||
// indistinguishable from the outside — both take the flag and behave identically on success, so
|
||||
// the only way to tell an unfixed host apart was to fail a hook and see whether the checkout
|
||||
// survived. Lifecycle integrations keep teardown evidence inside the checkout and cannot risk
|
||||
// that. Advertised unconditionally: every build carrying this constant has the gate.
|
||||
export const WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY =
|
||||
'worktree.archive-failure-blocking.v1' as const
|
||||
export const CODEX_RESET_CREDIT_RUNTIME_CAPABILITY = 'accounts.codex-reset-credit.v1' as const
|
||||
export const ACCOUNT_IMPORT_RUNTIME_CAPABILITY = 'accounts.import-host-credentials.v1' as const
|
||||
// Why: older hosts cannot reconcile terminal.create's mutation after losing the reply, so clients may only retry unknown outcomes when advertised.
|
||||
@@ -283,6 +290,7 @@ export const RUNTIME_CAPABILITIES = [
|
||||
TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY,
|
||||
TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY,
|
||||
WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
|
||||
WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY,
|
||||
TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
|
||||
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
|
||||
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
|
||||
|
||||
@@ -171,7 +171,10 @@ export const WorktreeRemove = WorktreeSelector.extend({
|
||||
// desktop sets `force` for an ordinary confirmed delete too, so the PTY-stop
|
||||
// waiver travels on its own field.
|
||||
allowUnverifiedPtyStop: OptionalBoolean,
|
||||
runHooks: OptionalBoolean
|
||||
runHooks: OptionalBoolean,
|
||||
// Why (#19334): a failed archive hook blocks removal. This waives that refusal and is recorded
|
||||
// in the result; it is NOT `force`, and it does not decide whether the hook runs.
|
||||
allowFailedArchiveHook: OptionalBoolean
|
||||
})
|
||||
|
||||
export const WorktreeForceDeleteBranch = WorktreeSelector.extend({
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
RUNTIME_CAPABILITIES,
|
||||
WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY
|
||||
} from '../protocol-version'
|
||||
|
||||
// Why (#19334): the reporter's integration (Harbour) keeps its teardown ownership evidence inside
|
||||
// the checkout, so it must know *before* removing anything whether this host refuses to delete on a
|
||||
// failed archive hook. It cannot probe for that — probing means risking the data loss.
|
||||
describe('worktree.archive-failure-blocking.v1', () => {
|
||||
it('uses the id the reporting integration already codes against', () => {
|
||||
expect(WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY).toBe(
|
||||
'worktree.archive-failure-blocking.v1'
|
||||
)
|
||||
})
|
||||
|
||||
it('is advertised by every build that carries the gate', () => {
|
||||
expect(RUNTIME_CAPABILITIES).toContain(WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
// Why (#19334): the archive hook is a user's last chance to save work off a checkout Orca is
|
||||
// about to delete. A failed hook used to be logged and stepped over, so the delete went ahead
|
||||
// with nothing archived. It is a precondition, evaluated before any stop/delete mutation.
|
||||
|
||||
/** RPC/CLI error code for a removal refused because the repo's archive hook did not succeed. */
|
||||
export const ARCHIVE_HOOK_FAILED_REMOVAL_CODE = 'worktree_archive_hook_failed'
|
||||
|
||||
export const ARCHIVE_HOOK_FAILED_REMOVAL_PREFIX = 'Archive hook failed for worktree:'
|
||||
|
||||
// One string, three surfaces: the CLI, RPC callers, and the desktop toast that now carries its own
|
||||
// Delete Anyway button. Naming only the CLI flag sent desktop users to a terminal for a button that
|
||||
// was six inches away, so both affordances are named and neither is presented as the only one.
|
||||
export const ARCHIVE_HOOK_OVERRIDE_HINT =
|
||||
'Nothing was stopped, deleted or deregistered. Fix the hook and retry, or delete anyway with an explicit waiver — "Delete Anyway" in the app, or --allow-failed-archive-hook on the CLI.'
|
||||
|
||||
/**
|
||||
* `exited` means the host reported a non-zero exit for this hook run. `unverifiable` covers every
|
||||
* case where the hook's outcome was never observed — spawn failure, timeout, lost contact with the
|
||||
* execution host. Loss of contact is never evidence that the hook passed, so both block removal.
|
||||
* Vocabulary is deliberately the `UnstoppedPtyVerdict` spelling; see docs/reference/ssh-execution-boundary.md.
|
||||
*/
|
||||
export type ArchiveHookOutcome = 'exited' | 'unverifiable'
|
||||
|
||||
export type ArchiveHookFailure = {
|
||||
worktreePath: string
|
||||
outcome: ArchiveHookOutcome
|
||||
/** Only ever set for `exited` — an absent code is not a zero code. */
|
||||
exitCode?: number
|
||||
output: string
|
||||
}
|
||||
|
||||
/** What a caller sees when the failure was explicitly overridden instead of blocking. */
|
||||
export type ArchiveHookOverride = ArchiveHookFailure & { overridden: true }
|
||||
|
||||
export class WorktreeArchiveHookFailedError extends Error {
|
||||
readonly code = ARCHIVE_HOOK_FAILED_REMOVAL_CODE
|
||||
readonly data: ArchiveHookFailure
|
||||
|
||||
constructor(failure: ArchiveHookFailure) {
|
||||
super(formatArchiveHookFailure(failure))
|
||||
this.name = 'WorktreeArchiveHookFailedError'
|
||||
this.data = failure
|
||||
}
|
||||
}
|
||||
|
||||
function describeArchiveHookVerdict(failure: ArchiveHookFailure): string {
|
||||
return failure.outcome === 'exited'
|
||||
? `exited ${failure.exitCode}`
|
||||
: 'outcome unverifiable (the hook never reported an exit)'
|
||||
}
|
||||
|
||||
export function formatArchiveHookFailure(failure: ArchiveHookFailure): string {
|
||||
const output = failure.output.trim()
|
||||
return [
|
||||
`${ARCHIVE_HOOK_FAILED_REMOVAL_PREFIX} ${failure.worktreePath} — ${describeArchiveHookVerdict(failure)}.`,
|
||||
ARCHIVE_HOOK_OVERRIDE_HINT,
|
||||
...(output ? [output] : [])
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* The waived case says the opposite of the refusal: the removal DID go ahead. Reusing
|
||||
* `formatArchiveHookFailure` here printed "Nothing was stopped, deleted or deregistered" directly
|
||||
* after deleting the checkout.
|
||||
*/
|
||||
export function formatArchiveHookOverride(override: ArchiveHookOverride): string {
|
||||
const output = override.output.trim()
|
||||
return [
|
||||
`Archive hook failed for worktree: ${override.worktreePath} — ${describeArchiveHookVerdict(override)}.`,
|
||||
'Deleted anyway because the failure was explicitly waived; nothing was archived.',
|
||||
...(output ? [output] : [])
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow an unknown rejection to the typed refusal, or rethrow it. This is the branch a real
|
||||
* caller writes, so tests asserting on a refusal should go through it rather than re-deriving it.
|
||||
*/
|
||||
export function asArchiveHookRefusal(error: unknown): WorktreeArchiveHookFailedError {
|
||||
if (error instanceof WorktreeArchiveHookFailedError) {
|
||||
return error
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
/** Recognise the refusal on a surface that only has the message, e.g. a renderer toast. */
|
||||
export function isArchiveHookRemovalError(error: string): boolean {
|
||||
return error.includes(ARCHIVE_HOOK_FAILED_REMOVAL_PREFIX)
|
||||
}
|
||||
|
||||
/** Shape both the local and the SSH archive runners answer with. */
|
||||
export type ArchiveHookRunResult = {
|
||||
success: boolean
|
||||
output: string
|
||||
/** Omitted whenever no exit was observed, which classifies the failure as `unverifiable`. */
|
||||
exitCode?: number
|
||||
}
|
||||
|
||||
export function classifyArchiveHookFailure(
|
||||
worktreePath: string,
|
||||
result: ArchiveHookRunResult
|
||||
): ArchiveHookFailure {
|
||||
return {
|
||||
worktreePath,
|
||||
outcome: typeof result.exitCode === 'number' ? 'exited' : 'unverifiable',
|
||||
...(typeof result.exitCode === 'number' ? { exitCode: result.exitCode } : {}),
|
||||
output: result.output
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ExecutionHostId } from '../execution-host'
|
||||
import type { ArchiveHookOverride } from './archive-hook-removal-gate'
|
||||
import type { WorkspaceSource } from '../workspace-source'
|
||||
import type { TaskSourceContext } from '../task-source-context'
|
||||
import type { WorkspaceKey } from '../folder-workspace-types'
|
||||
@@ -207,6 +208,8 @@ export type PreservedWorktreeBranch = {
|
||||
|
||||
export type RemoveWorktreeResult = {
|
||||
preservedBranch?: PreservedWorktreeBranch
|
||||
/** Present only when a FAILED archive hook was explicitly waived for this removal (#19334). */
|
||||
archiveHookOverride?: ArchiveHookOverride
|
||||
}
|
||||
|
||||
export type ForceDeleteWorktreeBranchResult = {
|
||||
|
||||
Reference in New Issue
Block a user