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>
This commit is contained in:
Affaan Mustafa
2026-06-07 13:00:08 +08:00
committed by GitHub
parent 7113b5bf63
commit c8caf193c4
9 changed files with 806 additions and 5 deletions

View File

@@ -219,7 +219,7 @@ function mergeServers(serverRecords) {
return Array.from(byName.values()).map(server => {
const uniqueSignatures = Array.from(new Set(server.signatures));
const { signatures, ...rest } = server;
const { signatures: _signatures, ...rest } = server;
return {
...rest,
harnessCount: server.sources.length,

View File

@@ -19,7 +19,6 @@ function loadTomlParser(parseTomlImpl) {
}
try {
// eslint-disable-next-line global-require
return require('@iarna/toml').parse;
} catch {
return null;

View File

@@ -257,9 +257,16 @@ function resolveGitBranch(cwd, resolveBranchImpl) {
}
try {
// Strip inherited git env (GIT_DIR etc., set when running inside a git
// hook) so the -C target is honored instead of the host repo.
const gitEnv = { ...process.env };
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_PREFIX']) {
delete gitEnv[key];
}
const branch = execFileSync('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], {
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8'
encoding: 'utf8',
env: gitEnv
}).trim();
return branch.length > 0 ? branch : null;

View File

@@ -213,9 +213,16 @@ function resolveGitBranch(cwd, resolveBranchImpl) {
}
try {
// Strip inherited git env (GIT_DIR etc., set when running inside a git
// hook) so the -C target is honored instead of the host repo.
const gitEnv = { ...process.env };
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_PREFIX']) {
delete gitEnv[key];
}
const branch = execFileSync('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], {
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8'
encoding: 'utf8',
env: gitEnv
}).trim();
return branch.length > 0 ? branch : null;

View File

@@ -0,0 +1,151 @@
'use strict';
const { spawnSync } = require('child_process');
// Thin, injectable git command layer. All git interaction in the lifecycle
// service goes through a runner so tests can feed canned output without a real
// repository. The default runner shells out with spawnSync.
// Git env vars that, if inherited (e.g. when this service runs inside a git
// hook), would override the -C/cwd target and point git at the host repo.
const INHERITED_GIT_ENV = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_PREFIX'];
function hermeticGitEnv() {
const env = { ...process.env };
for (const key of INHERITED_GIT_ENV) {
delete env[key];
}
return env;
}
function defaultRunImpl(args, options = {}) {
const result = spawnSync('git', args, {
cwd: options.cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 32 * 1024 * 1024,
env: hermeticGitEnv()
});
return {
status: typeof result.status === 'number' ? result.status : 1,
stdout: result.stdout || '',
stderr: result.stderr || ''
};
}
function createGitRunner(repoRoot, runImpl = defaultRunImpl) {
function run(args) {
return runImpl(args, { cwd: repoRoot });
}
function succeeds(args) {
return run(args).status === 0;
}
function stdoutOf(args) {
const result = run(args);
return result.status === 0 ? (result.stdout || '').trim() : '';
}
return {
repoRoot,
run,
succeeds,
isGitRepo() {
return succeeds(['rev-parse', '--is-inside-work-tree']);
},
branchExists(branch) {
return succeeds(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]);
},
// Porcelain worktree list -> [{ path, branch, head, detached, bare }]
listWorktrees() {
const result = run(['worktree', 'list', '--porcelain']);
if (result.status !== 0) {
return [];
}
const worktrees = [];
let current = null;
for (const rawLine of (result.stdout || '').split('\n')) {
const line = rawLine.trim();
if (line.startsWith('worktree ')) {
if (current) {
worktrees.push(current);
}
current = { path: line.slice('worktree '.length).trim(), branch: null, head: null, detached: false, bare: false };
} else if (current && line.startsWith('branch ')) {
current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '').trim();
} else if (current && line.startsWith('HEAD ')) {
current.head = line.slice('HEAD '.length).trim();
} else if (current && line === 'detached') {
current.detached = true;
} else if (current && line === 'bare') {
current.bare = true;
}
}
if (current) {
worktrees.push(current);
}
return worktrees;
},
// Uncommitted changes in a worktree (status --porcelain over its own path).
isDirty(worktreePath) {
const result = runImpl(['status', '--porcelain'], { cwd: worktreePath });
return result.status === 0 ? (result.stdout || '').trim().length > 0 : false;
},
// Commits a branch is ahead of / behind its base. Returns null if either
// ref is missing (e.g. branch already merged-and-deleted).
aheadBehind(branch, baseBranch) {
const out = stdoutOf(['rev-list', '--left-right', '--count', `${baseBranch}...${branch}`]);
const match = out.match(/^(\d+)\s+(\d+)$/);
if (!match) {
return null;
}
return { behind: Number(match[1]), ahead: Number(match[2]) };
},
// Last commit time (epoch ms) on a branch.
lastCommitMs(branch) {
const out = stdoutOf(['log', '-1', '--format=%ct', branch]);
const seconds = Number(out);
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null;
},
// Predict merge conflicts WITHOUT touching the working tree using
// `git merge-tree`. Prefers the modern --write-tree form (Git 2.38+),
// which exits non-zero and lists conflicted paths on conflict.
predictMergeConflicts(branch, baseBranch) {
const modern = run(['merge-tree', '--write-tree', '--name-only', baseBranch, branch]);
if (modern.status === 0) {
return { conflicted: false, files: [], method: 'merge-tree' };
}
// Non-zero with parseable output => real conflict (modern form).
if (modern.stdout && /\S/.test(modern.stdout)) {
const lines = modern.stdout.split('\n').map(l => l.trim()).filter(Boolean);
// First line is the tree oid; subsequent lines are conflicted paths.
const files = lines.slice(1).filter(line => !/^[0-9a-f]{40}$/i.test(line));
return { conflicted: true, files, method: 'merge-tree' };
}
// Fallback to the legacy form (older Git): conflict markers in output.
const legacy = run(['merge-tree', baseBranch, baseBranch, branch]);
const hasMarkers = /^<{7}|^={7}|^>{7}/m.test(legacy.stdout || '')
|| /\+<{7}|changed in both/m.test(legacy.stdout || '');
return { conflicted: hasMarkers, files: [], method: 'merge-tree-legacy' };
}
};
}
module.exports = {
createGitRunner,
defaultRunImpl
};

View File

@@ -0,0 +1,192 @@
'use strict';
const { createGitRunner } = require('./git');
const DEFAULT_STALE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
// Lifecycle states for a worktree, derived deterministically from git facts.
// main - the primary worktree (== baseBranch / repo root)
// detached - no branch checked out
// dirty - has uncommitted changes (work in progress, never auto-GC)
// conflict - would conflict if merged into base (queued for resolution)
// merge-ready - ahead of base, clean, no predicted conflicts
// merged - fully merged into base (ahead == 0), safe to clean
// stale - clean + no recent activity past the stale threshold
// idle - clean, behind/even with base, nothing to merge
const STATES = Object.freeze({
MAIN: 'main',
DETACHED: 'detached',
DIRTY: 'dirty',
CONFLICT: 'conflict',
MERGE_READY: 'merge-ready',
MERGED: 'merged',
STALE: 'stale',
IDLE: 'idle'
});
function classifyWorktree(facts, { staleThresholdMs, nowMs }) {
if (facts.isMain) {
return STATES.MAIN;
}
if (facts.detached || !facts.branch) {
return STATES.DETACHED;
}
if (facts.dirty) {
return STATES.DIRTY;
}
// Clean from here on.
const ahead = facts.aheadBehind ? facts.aheadBehind.ahead : 0;
// No unique commits => branch work is already in base => safe to garbage-collect.
if (facts.aheadBehind && ahead === 0) {
return STATES.MERGED;
}
if (facts.conflict) {
return STATES.CONFLICT;
}
// Has unmerged commits (ahead > 0, or unknown merge-base). Stale = unmerged
// work that has gone quiet past the threshold: a salvage candidate, never a
// blind delete. Recent unmerged work is merge-ready.
const isOld = facts.lastCommitMs !== null && (nowMs - facts.lastCommitMs) > staleThresholdMs;
if (ahead > 0 || facts.aheadBehind === null) {
return isOld ? STATES.STALE : STATES.MERGE_READY;
}
return STATES.IDLE;
}
function analyzeWorktree(worktree, options, git) {
const { baseBranch, staleThresholdMs, nowMs } = options;
const isMain = worktree.canonicalRepoRoot === git.repoRoot
|| worktree.path === git.repoRoot
|| worktree.branch === baseBranch;
const branch = worktree.branch;
const dirty = git.isDirty(worktree.path);
const aheadBehind = (!isMain && branch) ? git.aheadBehind(branch, baseBranch) : null;
const lastCommitMs = branch ? git.lastCommitMs(branch) : null;
// Only run conflict prediction when it matters: a clean, ahead branch.
const ahead = aheadBehind ? aheadBehind.ahead : 0;
let conflictResult = { conflicted: false, files: [], method: null };
if (!isMain && !dirty && branch && ahead > 0) {
conflictResult = git.predictMergeConflicts(branch, baseBranch);
}
const facts = {
isMain,
branch,
detached: Boolean(worktree.detached),
dirty,
aheadBehind,
lastCommitMs,
conflict: conflictResult.conflicted
};
const state = classifyWorktree(facts, { staleThresholdMs, nowMs });
const ageMs = lastCommitMs !== null ? Math.max(0, nowMs - lastCommitMs) : null;
return {
path: worktree.path,
branch: branch || null,
state,
head: worktree.head || null,
dirty,
ahead: aheadBehind ? aheadBehind.ahead : null,
behind: aheadBehind ? aheadBehind.behind : null,
lastCommitMs,
ageMs,
conflictFiles: conflictResult.files,
conflictMethod: conflictResult.method
};
}
function buildLifecycleReport(repoRoot, options = {}, deps = {}) {
const git = deps.git || createGitRunner(repoRoot, deps.runImpl);
const baseBranch = options.baseBranch || 'main';
const staleThresholdMs = Number.isFinite(options.staleThresholdMs)
? options.staleThresholdMs
: DEFAULT_STALE_MS;
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
const worktrees = git.listWorktrees().map(worktree =>
analyzeWorktree(worktree, { baseBranch, staleThresholdMs, nowMs }, git)
);
const conflictQueue = worktrees.filter(w => w.state === STATES.CONFLICT);
const staleQueue = worktrees.filter(w => w.state === STATES.STALE);
const mergeReady = worktrees.filter(w => w.state === STATES.MERGE_READY);
const states = worktrees.reduce((acc, w) => {
acc[w.state] = (acc[w.state] || 0) + 1;
return acc;
}, {});
return {
schemaVersion: 'ecc.worktree-lifecycle.v1',
repoRoot,
baseBranch,
staleThresholdMs,
worktrees,
conflictQueue,
staleQueue,
mergeReady,
aggregates: {
worktreeCount: worktrees.length,
states,
conflictCount: conflictQueue.length,
staleCount: staleQueue.length,
mergeReadyCount: mergeReady.length
}
};
}
// Plan which worktrees are SAFE to garbage-collect. Safety rule: only fully
// merged trees, or stale trees that are clean AND have nothing unmerged
// (ahead == 0 or no tracked branch). Dirty or merge-ready/conflict trees are
// never proposed for removal so in-progress or unmerged work is preserved
// (mirrors the reference-arch salvage safeguard).
function planCleanup(report) {
const remove = [];
const salvage = [];
const keep = [];
for (const w of report.worktrees) {
if (w.state === 'main') {
continue;
}
const unmergedWork = (w.ahead || 0) > 0;
if (w.state === 'merged') {
// Safe to remove: nothing unique to lose.
remove.push({ path: w.path, branch: w.branch, reason: 'fully merged into base' });
} else if (w.dirty) {
keep.push({ path: w.path, branch: w.branch, reason: 'has uncommitted changes' });
} else if (w.state === 'stale') {
// Unmerged + inactive: preserve first (push/bundle), then remove. Never
// a blind delete of unmerged work.
salvage.push({ path: w.path, branch: w.branch, reason: `unmerged + stale (age ${Math.round((w.ageMs || 0) / 86400000)}d, ${w.ahead} ahead) - push/bundle before removing` });
} else if (unmergedWork) {
keep.push({ path: w.path, branch: w.branch, reason: `unmerged work (${w.ahead} commits ahead)` });
} else {
keep.push({ path: w.path, branch: w.branch, reason: `state ${w.state}` });
}
}
return { remove, salvage, keep };
}
module.exports = {
STATES,
DEFAULT_STALE_MS,
classifyWorktree,
analyzeWorktree,
buildLifecycleReport,
planCleanup
};

164
scripts/worktree-lifecycle.js Executable file
View File

@@ -0,0 +1,164 @@
#!/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
};