Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch> --expect-head <SHA> --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
Expand Down
29 changes: 29 additions & 0 deletions docs/worktree-hygiene.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
107 changes: 107 additions & 0 deletions scripts/lib/worktree-maintenance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <restored checkout>`;
}
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 };
});
}
}
63 changes: 63 additions & 0 deletions scripts/test/worktree-maintenance.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
}
13 changes: 13 additions & 0 deletions scripts/worktree-maintenance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const usage = `Usage: scripts/worktree.sh <command>
prune [--apply] [--branch <name>] [--json]
archive and prune eligible local work
remove <name> release, archive, and prune one merged worktree
retire <branch> --expect-head <full-SHA> [--apply] [--by <who>] [--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.`;

Expand Down Expand Up @@ -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') {
Expand Down
Loading