Files
everything-claude-code/scripts/worktree-lifecycle.js
Affaan Mustafa c8caf193c4 feat: worktree-lifecycle service (deterministic conflict prediction + safe GC) (#2164)
* feat: add worktree-lifecycle service (ecc.worktree-lifecycle.v1)

The "unowned moat" from the orchestrator landscape research: no existing
tool ships deterministic merge-conflict prediction or a safe worktree GC.

- scripts/lib/worktree-lifecycle/git.js: injectable, hermetic git layer.
  Predicts merge conflicts WITHOUT touching the working tree via
  `git merge-tree`. Strips inherited GIT_* env so it is safe inside hooks.
- scripts/lib/worktree-lifecycle/lifecycle.js: deterministic state machine
  (main/dirty/conflict/merge-ready/merged/stale/idle) + planCleanup that
  buckets worktrees into remove / salvage / keep. Only fully-merged trees
  are auto-removable; stale (unmerged+inactive) => salvage, never deleted.
- scripts/worktree-lifecycle.js: CLI (--json/--conflicts/--stale/
  --cleanup-plan/--base/--stale-days/--repo).
- tests/lib/worktree-lifecycle.test.js: 11 tests (fake-git + real-git).

Safety model mirrors the reference-arch salvage rule, validated by the
2026-06-05 MacBook->Mac Mini consolidation. Tests: 11/0.

* fix: hermetic git env in session adapters + mcp-inventory lint

- session adapters (codex-worktree, opencode): resolveGitBranch stripped
  no git env, so the "outside a repo" path returned the host branch when
  run inside a git hook (GIT_DIR set). Strip GIT_* before rev-parse.
- mcp-inventory: fix eslint no-unused-vars (signatures) and a stale
  eslint-disable directive in the merged code.

* test: run each test with inherited git env stripped (hermetic runner)

When the suite runs inside a git hook (pre-push), git sets GIT_DIR/
GIT_WORK_TREE, which hijack 'git -C <dir>' calls in tests that exercise
real git, making them operate on the host repo. Strip GIT_* before
spawning each test so the suite is isolated from ambient git state.

---------

Co-authored-by: ECC Test <ecc@example.test>
2026-06-07 13:00:08 +08:00

165 lines
4.8 KiB
JavaScript
Executable File

#!/usr/bin/env node
'use strict';
const { buildLifecycleReport, planCleanup } = require('./lib/worktree-lifecycle/lifecycle');
function parseArgs(argv = process.argv) {
const args = argv.slice(2);
const options = {
json: false,
conflictsOnly: false,
staleOnly: false,
cleanupPlan: false,
help: false,
baseBranch: 'main',
staleDays: 7,
repoRoot: process.cwd()
};
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === '--json') {
options.json = true;
} else if (arg === '--conflicts') {
options.conflictsOnly = true;
} else if (arg === '--stale') {
options.staleOnly = true;
} else if (arg === '--cleanup-plan') {
options.cleanupPlan = true;
} else if (arg === '--help' || arg === '-h') {
options.help = true;
} else if (arg === '--base') {
options.baseBranch = args[i + 1] || options.baseBranch;
i += 1;
} else if (arg === '--stale-days') {
const value = Number(args[i + 1]);
if (Number.isFinite(value) && value >= 0) {
options.staleDays = value;
}
i += 1;
} else if (arg === '--repo') {
options.repoRoot = args[i + 1] || options.repoRoot;
i += 1;
}
}
return options;
}
function usage() {
return [
'Usage: worktree-lifecycle [options]',
'',
'Analyze every git worktree in a repo and classify its lifecycle state',
'(dirty, merge-ready, conflict, merged, stale, idle). Predicts merge',
'conflicts without touching the working tree (git merge-tree) and proposes',
'a safe cleanup plan that never removes dirty or unmerged worktrees.',
'',
'Options:',
' --json Print the full ecc.worktree-lifecycle.v1 report as JSON',
' --conflicts Only show worktrees that would conflict on merge',
' --stale Only show stale (clean, inactive) worktrees',
' --cleanup-plan Show which worktrees are safe to remove and why',
' --base <branch> Base branch to compare against (default: main)',
' --stale-days <n> Days of inactivity before a clean worktree is stale (default: 7)',
' --repo <path> Repository root (default: cwd)',
' -h, --help Show this help'
].join('\n');
}
function formatReport(report, options = {}) {
const lines = [];
const a = report.aggregates;
lines.push(`Worktree Lifecycle (${report.baseBranch})`);
lines.push(
` ${a.worktreeCount} worktrees | ${a.mergeReadyCount} merge-ready, `
+ `${a.conflictCount} conflict, ${a.staleCount} stale`
);
lines.push('');
if (options.conflictsOnly) {
if (report.conflictQueue.length === 0) {
lines.push('No worktrees would conflict on merge.');
} else {
lines.push('Conflict queue:');
for (const w of report.conflictQueue) {
const files = w.conflictFiles.length > 0 ? ` [${w.conflictFiles.slice(0, 5).join(', ')}]` : '';
lines.push(` ${w.branch} (${w.ahead} ahead)${files}`);
}
}
return lines.join('\n');
}
if (options.staleOnly) {
if (report.staleQueue.length === 0) {
lines.push('No stale worktrees.');
} else {
lines.push('Stale queue:');
for (const w of report.staleQueue) {
lines.push(` ${w.branch} (${Math.round((w.ageMs || 0) / 86400000)}d old) ${w.path}`);
}
}
return lines.join('\n');
}
if (options.cleanupPlan) {
const plan = planCleanup(report);
lines.push(`Safe to remove (${plan.remove.length}):`);
for (const item of plan.remove) {
lines.push(` ${item.branch || '(detached)'} - ${item.reason}`);
}
lines.push('');
lines.push(`Salvage first, then remove (${plan.salvage.length}):`);
for (const item of plan.salvage) {
lines.push(` ${item.branch || '(detached)'} - ${item.reason}`);
}
lines.push('');
lines.push(`Kept (${plan.keep.length}):`);
for (const item of plan.keep) {
lines.push(` ${item.branch || '(detached)'} - ${item.reason}`);
}
return lines.join('\n');
}
lines.push('Worktrees:');
for (const w of report.worktrees) {
const counts = w.ahead !== null ? ` +${w.ahead}/-${w.behind}` : '';
lines.push(` ${(w.branch || '(detached)').padEnd(28)} ${w.state}${counts}`);
}
return lines.join('\n');
}
function main(argv = process.argv) {
const options = parseArgs(argv);
if (options.help) {
console.log(usage());
return;
}
const report = buildLifecycleReport(options.repoRoot, {
baseBranch: options.baseBranch,
staleThresholdMs: options.staleDays * 24 * 60 * 60 * 1000
});
if (options.json) {
console.log(JSON.stringify(report, null, 2));
return;
}
console.log(formatReport(report, options));
}
if (require.main === module) {
main();
}
module.exports = {
parseArgs,
usage,
formatReport,
main
};