diff --git a/src/ac.ts b/src/ac.ts index 6d15d5c..991e2b9 100644 --- a/src/ac.ts +++ b/src/ac.ts @@ -705,14 +705,39 @@ async function main(): Promise { } if (cmd === 'off') { + // `suit off` defaults to the cwd project, but accepts `--project ` + // (or `--home `, the alias `suit inject` uses) so an operator can tear + // down a lockfile — including its `injected` list — at an inject target + // that isn't the cwd. `--keep-injected` removes only the up-applied files + // and preserves the injected components. const rest = argv.slice(1); + let projectDir = dirs.projectDir; let force = false; + let keepInjected = false; let err: string | null = null; - for (const a of rest) { - if (a === '--force') { + for (let i = 0; i < rest.length; i++) { + const arg = rest[i]; + let flag = arg; + let eqValue: string | undefined; + const eq = arg.indexOf('='); + if (arg.startsWith('--') && eq !== -1) { + flag = arg.slice(0, eq); + eqValue = arg.slice(eq + 1); + } + if (flag === '--force') { force = true; + } else if (flag === '--keep-injected') { + keepInjected = true; + } else if (flag === '--project' || flag === '--home') { + const value = eqValue ?? rest[i + 1]; + if (value === undefined || value.startsWith('-')) { + err = err ?? `suit off: ${flag} requires a value`; + continue; + } + projectDir = path.resolve(value); + if (eqValue === undefined) i += 1; } else { - err = err ?? `suit off: unrecognized argument "${a}"`; + err = err ?? `suit off: unrecognized argument "${arg}"`; } } if (err) { @@ -720,7 +745,7 @@ async function main(): Promise { return 2; } return runOff( - { projectDir: dirs.projectDir, force }, + { projectDir, force, keepInjected }, { stdout: (s) => process.stdout.write(s), stderr: (s) => process.stderr.write(s), diff --git a/src/lib/ac/off.ts b/src/lib/ac/off.ts index 753cfc2..d6b48ef 100644 --- a/src/lib/ac/off.ts +++ b/src/lib/ac/off.ts @@ -5,6 +5,19 @@ * unless `--force`), prunes now-empty parent directories that the apply created, * and finally removes the lockfile + `.suit/` dir if it was solely ours. * + * Injection awareness (slice e/4): a lockfile may also carry an `injected` list + * (components pushed by `suit inject`). Those files use the IDENTICAL LockEntry + * removal contract as `up`-applied `files[]`, so both flow through one shared + * per-file path here — drift scan, delete/strip, dir-prune, and tallies all + * treat an injected file the same as an up file. Without this, an inject-only + * lockfile (files:[], injected:[...]) would have its lockfile deleted while the + * injected files were orphaned on disk. + * + * `--keep-injected`: remove only the `up`-applied files and REWRITE the lockfile + * with an empty `files`/reset `resolution` but the `injected` list preserved, so + * a worker's injected components survive a teardown of the up-applied outfit. If + * there are no injected entries, it degrades to a normal full `off`. + * * Idempotency: a missing lockfile is a no-op (exit 0 with a friendly message). * Drift collection is greedy — without `--force`, every hand-edited file is * reported in one batch before exiting non-zero, so the user sees the full list @@ -12,12 +25,27 @@ */ import fs from 'node:fs/promises'; import path from 'node:path'; -import { LOCKFILE_PATH, deleteLockfile, readLockfile, sha256OfBuffer, sha256OfFile } from '../lockfile.js'; +import { + LOCKFILE_PATH, + deleteLockfile, + readLockfile, + sha256OfBuffer, + sha256OfFile, + writeLockfile, + type LockEntry, +} from '../lockfile.js'; import { extractSuitBlockFull, stripSuitBlocks } from '../writer.js'; export interface RunOffArgs { projectDir: string; force: boolean; + /** + * Remove only the `up`-applied `files[]`; leave every `injected[].files` on + * disk and rewrite (rather than delete) the lockfile so the injected entries + * survive. No-op extra behavior when the lockfile has no injected entries — + * in that case `off` is a normal full teardown. + */ + keepInjected?: boolean; } export interface RunOffDeps { @@ -64,6 +92,124 @@ async function pruneEmptyAncestors(projectDir: string, startRel: string): Promis } } +/** + * Drift verdict for a single tracked file. `null` means "no drift to report" + * (in-sync, already stripped, or already gone). A string means the relative + * path drifted and should be collected for the batch refusal. + */ +async function detectDrift(projectDir: string, f: LockEntry): Promise { + const full = path.join(projectDir, f.path); + let exists = false; + try { + await fs.stat(full); + exists = true; + } catch { + exists = false; + } + if (!exists) return null; // already gone, no drift to detect + + if (f.mode === 'additive') { + // For additive entries the recorded sha is the marker-block hash, not the + // whole-file hash. Find the block; absent → already stripped (no drift), + // present-but-mutated → drift. + const fileContent = await fs.readFile(full, 'utf8'); + const blockFull = extractSuitBlockFull(fileContent); + if (blockFull === null) return null; // already stripped — nothing to do + const blockSha = sha256OfBuffer(blockFull); + return blockSha !== f.sha256 ? f.path : null; + } + + const currentSha = await sha256OfFile(full); + return currentSha !== f.sha256 ? f.path : null; +} + +/** Mutable tallies threaded through the per-file removal pass. */ +interface RemovalState { + removedDirs: Set; + removed: number; + skippedMissing: number; + forcedDrift: string[]; +} + +/** + * Remove a single tracked file using the contract shared by `up`-applied and + * injected entries: + * - missing on disk → counted as skippedMissing, no-op + * - additive → strip the marker block; delete the host file if it becomes + * empty, otherwise write the remaining user content back + * - replace → unlink the whole file + * Under `--force`, a sha mismatch is recorded in `forcedDrift` (informational, + * not fatal — the drift refusal already happened in the preflight). Removed + * parents are registered for the later dir-prune pass. + */ +async function removeTrackedFile( + projectDir: string, + f: LockEntry, + force: boolean, + state: RemovalState, +): Promise { + const full = path.join(projectDir, f.path); + let exists = false; + try { + await fs.stat(full); + exists = true; + } catch { + exists = false; + } + if (!exists) { + state.skippedMissing++; + return; + } + + if (f.mode === 'additive') { + const before = await fs.readFile(full, 'utf8'); + const blockFull = extractSuitBlockFull(before); + if (force && blockFull !== null) { + const blockSha = sha256OfBuffer(blockFull); + if (blockSha !== f.sha256) state.forcedDrift.push(f.path); + } + const after = stripSuitBlocks(before); + if (after.trim().length === 0) { + await fs.unlink(full); + const parentRel = path.posix.dirname(f.path); + if (parentRel && parentRel !== '.' && parentRel !== '/') { + state.removedDirs.add(parentRel); + } + } else { + await fs.writeFile(full, after); + } + state.removed++; + return; + } + + if (force) { + // Re-check sha for the report. A mismatch under --force is informational. + try { + const currentSha = await sha256OfFile(full); + if (currentSha !== f.sha256) state.forcedDrift.push(f.path); + } catch { + // best-effort — proceed with delete + } + } + + try { + await fs.unlink(full); + state.removed++; + const parentRel = path.posix.dirname(f.path); + if (parentRel && parentRel !== '.' && parentRel !== '/') { + state.removedDirs.add(parentRel); + } + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + // raced with something else removing it; treat as missing + state.skippedMissing++; + return; + } + throw err; + } +} + export async function runOff(args: RunOffArgs, deps: RunOffDeps): Promise { const lock = await readLockfile(args.projectDir); if (!lock) { @@ -71,33 +217,24 @@ export async function runOff(args: RunOffArgs, deps: RunOffDeps): Promise c.files); + // `--keep-injected` only means anything if there's something to keep. + const keepInjected = args.keepInjected === true && injectedComponents.length > 0; + + // Stage 1: drift preflight (non-force only). Collect ALL hand-edited files — + // up-applied AND injected — in one pass so the user sees the full list before + // deciding how to recover. A drifted injected file reads the same as a + // drifted up file. Under --keep-injected we skip injected files (we're + // leaving them on disk, so their on-disk state is none of our business). if (!args.force) { const drifted: string[] = []; - for (const f of lock.files) { - const full = path.join(args.projectDir, f.path); - let exists = false; - try { - await fs.stat(full); - exists = true; - } catch { - exists = false; - } - if (!exists) continue; // already gone, no drift to detect - - if (f.mode === 'additive') { - // For additive entries the recorded sha is the marker-block hash, not - // the whole-file hash. Find the block; absent or mutated → drift. - const fileContent = await fs.readFile(full, 'utf8'); - const blockFull = extractSuitBlockFull(fileContent); - if (blockFull === null) continue; // already stripped — nothing to do - const blockSha = sha256OfBuffer(blockFull); - if (blockSha !== f.sha256) drifted.push(f.path); - } else { - const currentSha = await sha256OfFile(full); - if (currentSha !== f.sha256) drifted.push(f.path); - } + const scanTargets: LockEntry[] = keepInjected + ? [...lock.files] + : [...lock.files, ...injectedFiles]; + for (const f of scanTargets) { + const drift = await detectDrift(args.projectDir, f); + if (drift !== null) drifted.push(drift); } if (drifted.length > 0) { for (const p of drifted) { @@ -111,107 +248,89 @@ export async function runOff(args: RunOffArgs, deps: RunOffDeps): Promise(); - let removed = 0; - let skippedMissing = 0; - const forcedDrift: string[] = []; + // Stage 2: remove tracked files via the shared per-file path. up-applied + // files always; injected files unless --keep-injected. + const state: RemovalState = { + removedDirs: new Set(), + removed: 0, + skippedMissing: 0, + forcedDrift: [], + }; for (const f of lock.files) { - const full = path.join(args.projectDir, f.path); - let exists = false; - try { - await fs.stat(full); - exists = true; - } catch { - exists = false; - } - if (!exists) { - skippedMissing++; - continue; - } - - if (f.mode === 'additive') { - // Strip just the marker block from the file. If the file becomes empty - // (only whitespace) after the strip, delete it; otherwise write the - // remaining user content back. Drift report happens above; under - // --force we strip regardless. - const before = await fs.readFile(full, 'utf8'); - const blockFull = extractSuitBlockFull(before); - if (args.force && blockFull !== null) { - const blockSha = sha256OfBuffer(blockFull); - if (blockSha !== f.sha256) forcedDrift.push(f.path); - } - const after = stripSuitBlocks(before); - if (after.trim().length === 0) { - await fs.unlink(full); - const parentRel = path.posix.dirname(f.path); - if (parentRel && parentRel !== '.' && parentRel !== '/') { - removedDirs.add(parentRel); - } - } else { - await fs.writeFile(full, after); - } - removed++; - continue; - } - - if (args.force) { - // Re-check sha for the report. A mismatch under --force is informational, - // not fatal — we delete anyway. - try { - const currentSha = await sha256OfFile(full); - if (currentSha !== f.sha256) forcedDrift.push(f.path); - } catch { - // best-effort — proceed with delete - } - } + await removeTrackedFile(args.projectDir, f, args.force, state); + } - try { - await fs.unlink(full); - removed++; - const parentRel = path.posix.dirname(f.path); - if (parentRel && parentRel !== '.' && parentRel !== '/') { - removedDirs.add(parentRel); - } - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'ENOENT') { - // raced with something else removing it; treat as missing - skippedMissing++; - continue; + let injectedRemovedFiles = 0; + let injectedRemovedComponents = 0; + if (!keepInjected) { + for (const c of injectedComponents) { + const before = state.removed; + for (const f of c.files) { + await removeTrackedFile(args.projectDir, f, args.force, state); } - throw err; + const thisComponentRemoved = state.removed - before; + injectedRemovedFiles += thisComponentRemoved; + if (thisComponentRemoved > 0) injectedRemovedComponents++; } } // Stage 3: prune empty parent directories. Sort by depth (deepest first) so // each climb starts at a leaf and walks up cleanly. - const dirsByDepth = Array.from(removedDirs).sort( + const dirsByDepth = Array.from(state.removedDirs).sort( (a, b) => b.split('/').length - a.split('/').length, ); for (const dir of dirsByDepth) { await pruneEmptyAncestors(args.projectDir, dir); } - // Stage 4: delete the lockfile and (if empty) the `.suit/` dir. - await deleteLockfile(args.projectDir); + // Stage 4: lockfile teardown. Default removes everything then deletes the + // lockfile. --keep-injected (with injected entries present) rewrites it with + // an empty up-resolution but the injected list intact. + if (keepInjected) { + const rewritten = { + ...lock, + files: [], + resolution: { + outfit: null, + ...(lock.resolution.fit !== undefined ? { fit: null } : {}), + cut: null, + accessories: [], + }, + injected: injectedComponents, + }; + await writeLockfile(args.projectDir, rewritten); + } else { + await deleteLockfile(args.projectDir); + } // Stage 5: report. - deps.stdout(`Removed ${removed} file${removed === 1 ? '' : 's'} from ${args.projectDir}\n`); - if (skippedMissing > 0) { + deps.stdout(`Removed ${state.removed} file${state.removed === 1 ? '' : 's'} from ${args.projectDir}\n`); + if (state.skippedMissing > 0) { + deps.stdout( + `Skipped ${state.skippedMissing} already-missing file${state.skippedMissing === 1 ? '' : 's'}\n`, + ); + } + if (injectedRemovedFiles > 0) { deps.stdout( - `Skipped ${skippedMissing} already-missing file${skippedMissing === 1 ? '' : 's'}\n`, + `Removed ${injectedRemovedFiles} injected file(s) (${injectedRemovedComponents} component(s))\n`, ); } - if (forcedDrift.length > 0) { + if (keepInjected) { + const keptFiles = injectedFiles.length; deps.stdout( - `Force-deleted ${forcedDrift.length} hand-edited file${forcedDrift.length === 1 ? '' : 's'}:\n`, + `Kept ${keptFiles} injected file(s) (${injectedComponents.length} component(s)); lockfile retained\n`, ); - for (const p of forcedDrift) deps.stdout(` ${p}\n`); } - deps.stdout(`Removed lockfile: ${path.join(args.projectDir, LOCKFILE_PATH)}\n`); + if (state.forcedDrift.length > 0) { + deps.stdout( + `Force-deleted ${state.forcedDrift.length} hand-edited file${state.forcedDrift.length === 1 ? '' : 's'}:\n`, + ); + for (const p of state.forcedDrift) deps.stdout(` ${p}\n`); + } + if (!keepInjected) { + deps.stdout(`Removed lockfile: ${path.join(args.projectDir, LOCKFILE_PATH)}\n`); + } return 0; } diff --git a/src/tests/ac/off.test.ts b/src/tests/ac/off.test.ts index ed0637d..b47277e 100644 --- a/src/tests/ac/off.test.ts +++ b/src/tests/ac/off.test.ts @@ -317,3 +317,288 @@ describe('runOff — .suit/ dir cleanup', () => { expect(await fileExists(path.join(proj, '.suit', 'project-overlay.md'))).toBe(true); }); }); + +// --- injected component removal (slice e/4) ------------------------------- + +/** Marker-wrapped additive block, mirroring writer.ts's SUIT_BLOCK shape. */ +function additiveBlock(name: string, body: string): string { + return `\n${body}\n`; +} + +describe('runOff — injected file removal (both up + injected)', () => { + it('removes up files AND injected files, then deletes the lockfile', async () => { + const proj = await mkProject(); + const up1 = await plant(proj, '.claude/CLAUDE.md', '# up\n'); + const inj1 = await plant(proj, '.claude/skills/phil/SKILL.md', 'phil body\n', 'skills/phil'); + const inj2 = await plant(proj, '.claude/skills/norm/SKILL.md', 'norm body\n', 'skills/norm'); + const lock: Lockfile = { + schemaVersion: 1, + appliedAt: new Date().toISOString(), + resolution: { outfit: 'backend', cut: null, accessories: [] }, + files: [up1], + injected: [ + { + component: 'philosophy', + injectedAt: new Date().toISOString(), + files: [inj1, inj2], + }, + ], + }; + await writeLockfile(proj, lock); + + const cap = capture(); + const code = await runOff( + { projectDir: proj, force: false }, + { stdout: cap.push, stderr: cap.pushE }, + ); + expect(cap.err.join('')).toBe(''); + expect(code).toBe(0); + + expect(await fileExists(path.join(proj, up1.path))).toBe(false); + expect(await fileExists(path.join(proj, inj1.path))).toBe(false); + expect(await fileExists(path.join(proj, inj2.path))).toBe(false); + expect(await readLockfile(proj)).toBeNull(); + + const out = cap.out.join(''); + expect(out).toMatch(/Removed 3 files/); + expect(out).toMatch(/Removed 2 injected file\(s\) \(1 component\(s\)\)/); + expect(out).toMatch(/Removed lockfile:/); + }); +}); + +describe('runOff — inject-only lockfile (the orphan bug)', () => { + it('removes injected files and deletes the lockfile when files:[]', async () => { + const proj = await mkProject(); + const inj1 = await plant(proj, '.claude/skills/a/SKILL.md', 'a\n', 'skills/a'); + const inj2 = await plant(proj, '.claude/skills/b/SKILL.md', 'b\n', 'skills/b'); + const lock: Lockfile = { + schemaVersion: 1, + appliedAt: new Date().toISOString(), + resolution: { outfit: null, cut: null, accessories: [] }, + files: [], + injected: [ + { + component: 'philosophy', + injectedAt: new Date().toISOString(), + files: [inj1, inj2], + }, + ], + }; + await writeLockfile(proj, lock); + + const cap = capture(); + const code = await runOff( + { projectDir: proj, force: false }, + { stdout: cap.push, stderr: cap.pushE }, + ); + expect(cap.err.join('')).toBe(''); + expect(code).toBe(0); + + // Injected files gone (no longer orphaned). + expect(await fileExists(path.join(proj, inj1.path))).toBe(false); + expect(await fileExists(path.join(proj, inj2.path))).toBe(false); + // Lockfile gone. + expect(await readLockfile(proj)).toBeNull(); + expect(await fileExists(path.join(proj, '.suit'))).toBe(false); + + const out = cap.out.join(''); + expect(out).toMatch(/Removed 2 injected file\(s\) \(1 component\(s\)\)/); + }); +}); + +describe('runOff — drift on an injected file', () => { + it('refuses without --force, lists the injected drift, then removes under --force', async () => { + const proj = await mkProject(); + const inj1 = await plant(proj, '.claude/skills/a/SKILL.md', 'a-original\n', 'skills/a'); + const lock: Lockfile = { + schemaVersion: 1, + appliedAt: new Date().toISOString(), + resolution: { outfit: null, cut: null, accessories: [] }, + files: [], + injected: [ + { + component: 'philosophy', + injectedAt: new Date().toISOString(), + files: [inj1], + }, + ], + }; + await writeLockfile(proj, lock); + + // Hand-edit the injected file. + await fs.writeFile(path.join(proj, inj1.path), 'a-edited\n'); + + // Non-force: refuse, list it the same as an up drift. + const cap1 = capture(); + const code1 = await runOff( + { projectDir: proj, force: false }, + { stdout: cap1.push, stderr: cap1.pushE }, + ); + expect(code1).toBe(1); + const err = cap1.err.join(''); + expect(err).toMatch(/hand-edited since suit applied it: \.claude\/skills\/a\/SKILL\.md/); + expect(err).toMatch(/refusing to delete 1 hand-edited file/); + expect(await fileExists(path.join(proj, inj1.path))).toBe(true); + expect(await readLockfile(proj)).not.toBeNull(); + + // Force: remove anyway, report as forcedDrift. + const cap2 = capture(); + const code2 = await runOff( + { projectDir: proj, force: true }, + { stdout: cap2.push, stderr: cap2.pushE }, + ); + expect(code2).toBe(0); + expect(await fileExists(path.join(proj, inj1.path))).toBe(false); + expect(await readLockfile(proj)).toBeNull(); + const out = cap2.out.join(''); + expect(out).toMatch(/Force-deleted 1 hand-edited file:/); + expect(out).toMatch(/\.claude\/skills\/a\/SKILL\.md/); + }); +}); + +describe('runOff — additive injected file', () => { + it('strips just the marker block, preserving surrounding user content', async () => { + const proj = await mkProject(); + const block = additiveBlock('philosophy', 'INJECTED LINE'); + const userBefore = '# my notes\n\n'; + const userAfter = '\n\n# more notes\n'; + const full = path.join(proj, '.claude/CLAUDE.md'); + await fs.mkdir(path.dirname(full), { recursive: true }); + await fs.writeFile(full, userBefore + block + userAfter); + + const inj1 = { + path: '.claude/CLAUDE.md', + sha256: sha256OfBuffer(block), + sourceComponent: 'accessories/philosophy', + mode: 'additive' as const, + }; + const lock: Lockfile = { + schemaVersion: 1, + appliedAt: new Date().toISOString(), + resolution: { outfit: null, cut: null, accessories: [] }, + files: [], + injected: [ + { component: 'philosophy', injectedAt: new Date().toISOString(), files: [inj1] }, + ], + }; + await writeLockfile(proj, lock); + + const cap = capture(); + const code = await runOff( + { projectDir: proj, force: false }, + { stdout: cap.push, stderr: cap.pushE }, + ); + expect(code).toBe(0); + expect(cap.err.join('')).toBe(''); + + // File survives, block gone, user content kept. + expect(await fileExists(full)).toBe(true); + const remaining = await fs.readFile(full, 'utf8'); + expect(remaining).not.toMatch(/INJECTED LINE/); + expect(remaining).toMatch(/my notes/); + expect(remaining).toMatch(/more notes/); + }); + + it('deletes the host file when the additive injected block was its only content', async () => { + const proj = await mkProject(); + const block = additiveBlock('philosophy', 'ONLY CONTENT'); + const full = path.join(proj, '.claude/skills/x/SKILL.md'); + await fs.mkdir(path.dirname(full), { recursive: true }); + await fs.writeFile(full, block); + + const inj1 = { + path: '.claude/skills/x/SKILL.md', + sha256: sha256OfBuffer(block), + sourceComponent: 'skills/x', + mode: 'additive' as const, + }; + const lock: Lockfile = { + schemaVersion: 1, + appliedAt: new Date().toISOString(), + resolution: { outfit: null, cut: null, accessories: [] }, + files: [], + injected: [ + { component: 'philosophy', injectedAt: new Date().toISOString(), files: [inj1] }, + ], + }; + await writeLockfile(proj, lock); + + const cap = capture(); + const code = await runOff( + { projectDir: proj, force: false }, + { stdout: cap.push, stderr: cap.pushE }, + ); + expect(code).toBe(0); + expect(await fileExists(full)).toBe(false); + expect(await fileExists(path.join(proj, '.claude/skills/x'))).toBe(false); + }); +}); + +describe('runOff --keep-injected', () => { + it('removes up files, keeps injected files, and rewrites the lockfile', async () => { + const proj = await mkProject(); + const up1 = await plant(proj, '.claude/CLAUDE.md', '# up\n'); + const inj1 = await plant(proj, '.claude/skills/a/SKILL.md', 'a\n', 'skills/a'); + const inj2 = await plant(proj, '.claude/skills/b/SKILL.md', 'b\n', 'skills/b'); + const lock: Lockfile = { + schemaVersion: 1, + appliedAt: new Date().toISOString(), + resolution: { outfit: 'backend', fit: 'go', cut: 'executing', accessories: ['pr-policy'] }, + files: [up1], + injected: [ + { component: 'philosophy', injectedAt: new Date().toISOString(), files: [inj1, inj2] }, + ], + }; + await writeLockfile(proj, lock); + + const cap = capture(); + const code = await runOff( + { projectDir: proj, force: false, keepInjected: true }, + { stdout: cap.push, stderr: cap.pushE }, + ); + expect(cap.err.join('')).toBe(''); + expect(code).toBe(0); + + // Up file gone. + expect(await fileExists(path.join(proj, up1.path))).toBe(false); + // Injected files survive. + expect(await fileExists(path.join(proj, inj1.path))).toBe(true); + expect(await fileExists(path.join(proj, inj2.path))).toBe(true); + + // Lockfile retained, rewritten: files cleared, resolution reset, injected kept. + const after = await readLockfile(proj); + expect(after).not.toBeNull(); + expect(after!.files).toEqual([]); + expect(after!.resolution.outfit).toBeNull(); + expect(after!.resolution.cut).toBeNull(); + expect(after!.resolution.accessories).toEqual([]); + expect(after!.injected).toHaveLength(1); + expect(after!.injected![0].files).toHaveLength(2); + + const out = cap.out.join(''); + expect(out).toMatch(/Kept 2 injected file\(s\) \(1 component\(s\)\); lockfile retained/); + expect(out).not.toMatch(/Removed lockfile:/); + }); + + it('behaves like a full off (deletes lockfile) when there are no injected entries', async () => { + const proj = await mkProject(); + const up1 = await plant(proj, '.claude/CLAUDE.md', '# up\n'); + const lock: Lockfile = { + schemaVersion: 1, + appliedAt: new Date().toISOString(), + resolution: { outfit: 'backend', cut: null, accessories: [] }, + files: [up1], + }; + await writeLockfile(proj, lock); + + const cap = capture(); + const code = await runOff( + { projectDir: proj, force: false, keepInjected: true }, + { stdout: cap.push, stderr: cap.pushE }, + ); + expect(code).toBe(0); + expect(await fileExists(path.join(proj, up1.path))).toBe(false); + expect(await readLockfile(proj)).toBeNull(); + expect(cap.out.join('')).toMatch(/Removed lockfile:/); + }); +});