diff --git a/AGENTS.md b/AGENTS.md index 1f4ffe72..349e5b99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,8 @@ itself driving one of them. - Use the [pruning procedure](docs/worktree-hygiene.md). `remove` and the daily installed job require fresh merge proof and a verified recovery bundle before deleting local work. Do not bypass this with force removal, branch force-deletion, or manual directory deletion. + Unmerged work you are giving up goes through `scripts/worktree.sh retire --expect-head --apply`, + which snapshots and archives it first. It is the only sanctioned way to drop unmerged work. `scripts/worktree.sh audit` reports eligibility; `schedule status` reports the local job. These tools never delete remote branches or recovery archives. GitHub deletes a PR's branch itself when the PR merges (`delete_branch_on_merge`, on since 2026-10-15) and moves diff --git a/docs/worktree-hygiene.md b/docs/worktree-hygiene.md index 012b6557..2a87d49e 100644 --- a/docs/worktree-hygiene.md +++ b/docs/worktree-hygiene.md @@ -93,6 +93,35 @@ The generated-data allowlist covers root `node_modules/` and `node_modules/`, `d Ignored `.env`, `.codex`, `.probes`, logs, release artifacts, and other unknown files preserve the checkout. Move evidence you want to keep to durable storage yourself. +## Retire unmerged work + +Work that will never merge (a closed PR, an abandoned experiment, a superseded stack) has no +merge proof, so `prune` keeps it forever. Its owner retires it explicitly instead: + +```sh +scripts/worktree.sh retire lm2-land --expect-head "$(git rev-parse lm2-land)" # report only +scripts/worktree.sh retire lm2-land --expect-head "$(git rev-parse lm2-land)" --apply --by val +``` + +Retirement swaps merge proof for a verified snapshot. It does not relax anything else: + +- Dirty tracked and untracked files go into a snapshot commit on top of the tip. The commit is + built in a temporary index, so the worktree's own index is untouched. Unknown ignored data + (`.env`, `.probes`, logs) goes into `ignored.tar` (mode 0600) next to the bundle. Secrets in that + tar stay local, inside `modex-maintenance/archives/`. +- The snapshot goes through the same bundle, empty-repository restore and `git fsck --full` as + pruning. The manifest records the original `tip`, the `snapshot`, who retired it, and a + `restoreIgnored` command when a tar exists. +- Retirement still refuses: the primary checkout and default branch, an open PR, an active process, + a Git operation in progress or a stale operation marker, Git locks, hidden index entries, + a locked or unmanaged worktree, and a tip that differs from `--expect-head`. +- Only after the archive is verified and every observation is repeated does it reset the checkout, + remove the archived ignored paths, and run Git's non-forced `worktree remove`. It then deletes the + branch by expected SHA and leaves a `retired` ownership record. + +Retire only work you own, or work whose owner has handed it to you. It never touches remote +branches. Delete those separately, once their tips are in an archive. + ## Daily automation On macOS, install the user LaunchAgent from reviewed code: diff --git a/scripts/lib/worktree-maintenance.mjs b/scripts/lib/worktree-maintenance.mjs index 49a95793..25f7cb37 100644 --- a/scripts/lib/worktree-maintenance.mjs +++ b/scripts/lib/worktree-maintenance.mjs @@ -463,4 +463,111 @@ export class WorktreeMaintenance { return report; }); } + + // Reasons retire accepts: it snapshots dirty files and tars unknown ignored data first. + static RETIRABLE = new Set(['dirty', 'unknown-ignored-data']); + + /** Inspect a branch for retirement. Never mutates anything. */ + async retireCheck(info, branch, expectedHead) { + this.git(['check-ref-format', `refs/heads/${branch}`]); + const reasons = []; + if (!this.directBranch(branch)) reasons.push('symbolic-branch'); + if (branch === 'main' || branch === info.defaultBranch) reasons.push('default-branch'); + const head = this.git(['rev-parse', '--verify', '-q', `refs/heads/${branch}^{commit}`], this.cwd, true).stdout.trim(); + if (!head) return { branch, reasons: [...reasons, 'missing-branch'] }; + if (!fullSha(expectedHead) || head !== expectedHead) reasons.push('head-mismatch'); + const trees = this.worktrees().filter((tree) => tree.branch === `refs/heads/${branch}`); + if (trees.length > 1) reasons.push('multiple-worktrees'); + const tree = trees[0]; + const accepted = []; + if (tree) { + if (tree.path === info.primary) reasons.push('primary'); + for (const reason of this.treeReasons(info, tree)) (WorktreeMaintenance.RETIRABLE.has(reason) ? accepted : reasons).push(reason); + } + if (!reasons.length) { + try { + await this.pullEvidence(info, { branch, head }); + if (tree) { + const active = await this.activity(tree.path); + if (typeof active !== 'boolean') throw new Error('unknown process activity'); + if (active) reasons.push('active-process'); + } + } catch (error) { reasons.push(error.message === 'open-pr' ? 'open-pr' : `observation-failed: ${error.message}`); } + } + const ignored = tree && accepted.includes('unknown-ignored-data') + ? this.git(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], tree.path).stdout.split('\0').filter((file) => file && !regenerable(file)) + : []; + return { branch, head, path: tree?.path ?? null, dirty: accepted.includes('dirty'), ignored, reasons, eligible: reasons.length === 0 }; + } + + /** + * Archive and remove unmerged work the owner explicitly gives up. Replaces merge proof with a + * verified snapshot: dirty tracked and untracked files become a commit on top of the tip, and + * unknown ignored data becomes ignored.tar beside the bundle. Open PRs, active processes, Git + * operations in progress, locks and hidden index entries still preserve the work. + */ + async retire(branch, { expectedHead, apply = false, by = null } = {}) { + return this.withLock(async () => { + const info = await this.discover(); + const check = await this.retireCheck(info, branch, expectedHead); + if (!apply || !check.eligible) return { ...check, status: check.eligible ? 'eligible' : 'preserved' }; + let snapshot = check.head; + if (check.dirty) { + const index = assertStatePath(info, path.join(info.stateDir, `retire-index-${randomUUID()}`)); + fs.mkdirSync(info.stateDir, { recursive: true, mode: 0o700 }); + const env = { ...gitEnvironment(), GIT_INDEX_FILE: index }; + const git = (args) => command(this.executables.git ?? 'git', ['--no-replace-objects', '-C', check.path, ...args], undefined, false, env).stdout.trim(); + try { + git(['read-tree', check.head]); + git(['add', '-A', '--', '.']); + const tree = git(['write-tree']); + snapshot = git(['-c', 'user.name=Modex worktree retire', '-c', 'user.email=retire@modex.invalid', 'commit-tree', tree, '-p', check.head, '-m', `Retire snapshot of ${branch} at ${check.head}`]); + } finally { fs.rmSync(index, { force: true }); } + } + const proof = { kind: 'retired', by, tip: check.head, snapshot, dirty: check.dirty, ignored: check.ignored }; + const run = this.archive(info, [{ branch, head: snapshot, path: check.path, proof }]); + const entry = run.entries[0]; + entry.tip = check.head; + if (check.ignored.length) { + const tar = path.join(path.dirname(run.manifest), 'ignored.tar'); + command('tar', ['-cf', tar, '-C', check.path, '--', ...check.ignored]); + fs.chmodSync(tar, 0o600); + const listed = command('tar', ['-tf', tar]).stdout.split('\n').filter(Boolean); + for (const file of check.ignored) if (!listed.some((name) => name === file || name === file.replace(/\/$/, '') || name.startsWith(file))) throw new Error(`ignored archive is missing ${file}`); + entry.ignoredTar = tar; + entry.ignoredTarSha256 = sha256(fs.readFileSync(tar)); + entry.restoreIgnored = `tar -xf ${quote(tar)} -C `; + } + const result = { branch, head: check.head, snapshot, status: 'preserved', reasons: [] }; + try { + // Everything is archived. Re-observe before the first destructive step. + const again = await this.retireCheck(await this.discover(), branch, expectedHead); + if (!again.eligible || again.path !== check.path) throw new Error(`revalidation: ${again.reasons.join(', ') || 'path changed'}`); + this.verifyArchive(run, entry); + if (check.path) { + if (check.dirty) { + this.git(['reset', '-q', '--hard', check.head], check.path); + this.git(['clean', '-q', '-f', '-d'], check.path); + } + for (const file of check.ignored) fs.rmSync(path.join(check.path, file), { recursive: true, force: true }); + const reasons = this.treeReasons(info, this.worktrees().find((tree) => tree.path === check.path) ?? { path: check.path }); + if (reasons.length) throw new Error(`not clean after archive: ${reasons.join(', ')}`); + this.git(['worktree', 'remove', check.path], info.primary); + result.worktreeRemoved = true; + if (fs.existsSync(check.path)) throw new Error('worktree removal not confirmed'); + } + if (this.worktrees().some((tree) => tree.branch === `refs/heads/${branch}`)) throw new Error('branch still checked out'); + if (!this.directBranch(branch) || branch === 'main' || branch === info.defaultBranch) throw new Error('default or symbolic branch protected'); + this.verifyArchive(run, entry); + this.git(['update-ref', '--no-deref', '-d', `refs/heads/${branch}`, check.head]); + if (this.git(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], this.cwd, true).status !== 1) throw new Error('branch removal not confirmed'); + const old = await this.getRecord(branch).catch(() => null); + this.writeRecord(info, { version: 1, status: 'retired', branch, head: check.head, snapshot, path: check.path, owner: old?.owner ?? null, by, archive: run.manifest, updatedAt: new Date().toISOString() }); + result.status = 'retired'; + } catch (error) { result.reasons.push(error.message); } + run.results.push(result); + this.persistManifest(run); + return { ...result, run }; + }); + } } diff --git a/scripts/test/worktree-maintenance.test.mjs b/scripts/test/worktree-maintenance.test.mjs index b8f8cfc2..59775226 100644 --- a/scripts/test/worktree-maintenance.test.mjs +++ b/scripts/test/worktree-maintenance.test.mjs @@ -533,3 +533,66 @@ for (const seam of ['state', 'archives', 'owners']) { assert.equal(f.exists(w), true); }); } + +test('retire dry run reports and changes nothing', async (t) => { + const f = fixture(t); const w = f.create('abandoned'); + fs.appendFileSync(path.join(w.location, 'code.txt'), 'unfinished\n'); + const value = await f.core.retire(w.name, { expectedHead: w.head }); + assert.equal(value.status, 'eligible'); + assert.equal(value.dirty, true); + assert.equal(f.exists(w), true); + assert.match(fs.readFileSync(path.join(w.location, 'code.txt'), 'utf8'), /unfinished/); +}); + +test('retire snapshots dirty, untracked and ignored data, then removes the unmerged worktree', async (t) => { + const f = fixture(t); const w = f.create('abandoned'); + fs.appendFileSync(path.join(w.location, 'code.txt'), 'unfinished\n'); + git(w.location, 'add', 'code.txt'); + fs.writeFileSync(path.join(w.location, 'notes.txt'), 'untracked note\n'); + fs.writeFileSync(path.join(w.location, '.env'), 'SECRET=1\n'); + fs.mkdirSync(path.join(w.location, '.probes')); fs.writeFileSync(path.join(w.location, '.probes', 'probe.mjs'), 'probe\n'); + const value = await f.core.retire(w.name, { expectedHead: w.head, apply: true, by: 'val' }); + assert.equal(value.status, 'retired', value.reasons.join(', ')); + assert.equal(fs.existsSync(w.location), false); + assert.equal(spawnSync('git', ['-C', f.primary, 'show-ref', '--verify', '--quiet', `refs/heads/${w.name}`]).status, 1); + const entry = value.run.entries[0]; + assert.equal(entry.tip, w.head); + assert.equal(entry.proof.kind, 'retired'); + const restored = path.join(f.dir, 'restored'); + git(f.dir, 'init', '-q', restored); + git(restored, 'fetch', '-q', value.run.bundle, `${entry.archiveRef}:refs/heads/restored`); + assert.equal(git(restored, 'rev-parse', 'restored^'), w.head); + assert.match(git(restored, 'show', 'restored:code.txt'), /unfinished/); + assert.equal(git(restored, 'show', 'restored:notes.txt'), 'untracked note'); + const listed = execFileSync('tar', ['-tf', entry.ignoredTar], { encoding: 'utf8' }); + assert.match(listed, /\.env/); assert.match(listed, /\.probes\//); + assert.equal(fs.statSync(entry.ignoredTar).mode & 0o777, 0o600); + assert.equal((await f.core.getRecord(w.name)).status, 'retired'); +}); + +test('retire removes a clean unmerged worktree with only the exact tip archived', async (t) => { + const f = fixture(t); const w = f.create('clean-abandoned'); + const value = await f.core.retire(w.name, { expectedHead: w.head, apply: true }); + assert.equal(value.status, 'retired', value.reasons.join(', ')); + assert.equal(value.snapshot, w.head); + assert.equal(value.run.entries[0].ignoredTar, undefined); +}); + +for (const kind of ['open-pr', 'head-mismatch', 'active', 'operation', 'locked', 'main']) { + test(`retire preserves work on ${kind}`, async (t) => { + const f = fixture(t); const w = f.create('kept'); + fs.appendFileSync(path.join(w.location, 'code.txt'), 'unfinished\n'); + let branch = w.name; let head = w.head; + if (kind === 'open-pr') f.pulls = [{ state: 'open', head: { ref: w.name, sha: w.head, repo: { full_name: 'example/modex' } }, base: { ref: 'main' } }]; + if (kind === 'head-mismatch') head = git(f.primary, 'rev-parse', 'main'); + if (kind === 'active') f.active = true; + if (kind === 'operation') fs.writeFileSync(git(w.location, 'rev-parse', '--path-format=absolute', '--git-path', 'MERGE_HEAD'), `${w.head}\n`); + if (kind === 'locked') git(f.primary, 'worktree', 'lock', w.location); + if (kind === 'main') { branch = 'main'; head = git(f.primary, 'rev-parse', 'main'); } + const value = await f.core.retire(branch, { expectedHead: head, apply: true }); + assert.equal(value.status, 'preserved'); + assert.ok(value.reasons.length > 0); + assert.equal(f.exists(w), true); + assert.match(fs.readFileSync(path.join(w.location, 'code.txt'), 'utf8'), /unfinished/); + }); +} diff --git a/scripts/worktree-maintenance.mjs b/scripts/worktree-maintenance.mjs index b5bd2ced..2ab2a9c3 100644 --- a/scripts/worktree-maintenance.mjs +++ b/scripts/worktree-maintenance.mjs @@ -17,6 +17,8 @@ const usage = `Usage: scripts/worktree.sh prune [--apply] [--branch ] [--json] archive and prune eligible local work remove release, archive, and prune one merged worktree + retire --expect-head [--apply] [--by ] [--json] + snapshot, archive, and remove unmerged work you give up schedule install|status|uninstall install a daily macOS user job or inspect it See docs/worktree-hygiene.md for preservation rules and recovery.`; @@ -111,6 +113,17 @@ export async function run(args = process.argv.slice(2), cwd = process.cwd()) { }); console.log(`removed worktree and archived local branch ${name}`); return 0; } + if (action === 'retire') { + const { positional, options } = parse(rest, { '--expect-head': true, '--apply': false, '--by': true, '--json': false }); count(positional, 1); + const value = await core.retire(positional[0], { expectedHead: options['--expect-head'], apply: !!options['--apply'], by: options['--by'] ?? process.env.CODEX_THREAD_ID ?? null }); + const { run: retireRun, ...row } = value; + if (options['--json']) console.log(JSON.stringify(value, null, 2)); + else { + console.log(`${row.status} ${row.branch}: ${row.reasons.length ? row.reasons.join(', ') : `dirty=${row.dirty ?? false} ignored=${(row.ignored ?? []).length}`}`); + if (retireRun) console.log(`Recovery manifest: ${retireRun.manifest}`); + } + return ['eligible', 'retired'].includes(row.status) ? 0 : 1; + } if (action === 'schedule') { count(rest, 1); if (rest[0] === 'install') {