mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-11 10:43:10 +08:00
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:
151
scripts/lib/worktree-lifecycle/git.js
Normal file
151
scripts/lib/worktree-lifecycle/git.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user