From c8caf193c4a0b2d75df6587bfae892ad1ba1417f Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sun, 7 Jun 2026 13:00:08 +0800 Subject: [PATCH] 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 ' 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 --- scripts/lib/mcp-inventory/canonical-mcp.js | 2 +- scripts/lib/mcp-inventory/readers/codex.js | 1 - .../lib/session-adapters/codex-worktree.js | 9 +- scripts/lib/session-adapters/opencode.js | 9 +- scripts/lib/worktree-lifecycle/git.js | 151 ++++++++++ scripts/lib/worktree-lifecycle/lifecycle.js | 192 +++++++++++++ scripts/worktree-lifecycle.js | 164 +++++++++++ tests/lib/worktree-lifecycle.test.js | 271 ++++++++++++++++++ tests/run-all.js | 12 +- 9 files changed, 806 insertions(+), 5 deletions(-) create mode 100644 scripts/lib/worktree-lifecycle/git.js create mode 100644 scripts/lib/worktree-lifecycle/lifecycle.js create mode 100755 scripts/worktree-lifecycle.js create mode 100644 tests/lib/worktree-lifecycle.test.js diff --git a/scripts/lib/mcp-inventory/canonical-mcp.js b/scripts/lib/mcp-inventory/canonical-mcp.js index cfe1cc03..80d22552 100644 --- a/scripts/lib/mcp-inventory/canonical-mcp.js +++ b/scripts/lib/mcp-inventory/canonical-mcp.js @@ -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, diff --git a/scripts/lib/mcp-inventory/readers/codex.js b/scripts/lib/mcp-inventory/readers/codex.js index 39237c8b..84c9d3dd 100644 --- a/scripts/lib/mcp-inventory/readers/codex.js +++ b/scripts/lib/mcp-inventory/readers/codex.js @@ -19,7 +19,6 @@ function loadTomlParser(parseTomlImpl) { } try { - // eslint-disable-next-line global-require return require('@iarna/toml').parse; } catch { return null; diff --git a/scripts/lib/session-adapters/codex-worktree.js b/scripts/lib/session-adapters/codex-worktree.js index 8b66d4d2..f90db16c 100644 --- a/scripts/lib/session-adapters/codex-worktree.js +++ b/scripts/lib/session-adapters/codex-worktree.js @@ -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; diff --git a/scripts/lib/session-adapters/opencode.js b/scripts/lib/session-adapters/opencode.js index 58f19fb5..19f88ff4 100644 --- a/scripts/lib/session-adapters/opencode.js +++ b/scripts/lib/session-adapters/opencode.js @@ -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; diff --git a/scripts/lib/worktree-lifecycle/git.js b/scripts/lib/worktree-lifecycle/git.js new file mode 100644 index 00000000..86f09fa1 --- /dev/null +++ b/scripts/lib/worktree-lifecycle/git.js @@ -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 +}; diff --git a/scripts/lib/worktree-lifecycle/lifecycle.js b/scripts/lib/worktree-lifecycle/lifecycle.js new file mode 100644 index 00000000..56d08e2a --- /dev/null +++ b/scripts/lib/worktree-lifecycle/lifecycle.js @@ -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 +}; diff --git a/scripts/worktree-lifecycle.js b/scripts/worktree-lifecycle.js new file mode 100755 index 00000000..36d7d0ed --- /dev/null +++ b/scripts/worktree-lifecycle.js @@ -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 Base branch to compare against (default: main)', + ' --stale-days Days of inactivity before a clean worktree is stale (default: 7)', + ' --repo 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 +}; diff --git a/tests/lib/worktree-lifecycle.test.js b/tests/lib/worktree-lifecycle.test.js new file mode 100644 index 00000000..12cb2560 --- /dev/null +++ b/tests/lib/worktree-lifecycle.test.js @@ -0,0 +1,271 @@ +'use strict'; + +const assert = require('assert'); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); +const { createGitRunner } = require('../../scripts/lib/worktree-lifecycle/git'); +const { + STATES, + classifyWorktree, + buildLifecycleReport, + planCleanup +} = require('../../scripts/lib/worktree-lifecycle/lifecycle'); +const { parseArgs, usage, formatReport, main } = require('../../scripts/worktree-lifecycle'); + +console.log('=== Testing worktree-lifecycle ===\n'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + passed += 1; + console.log(` ok - ${name}`); + } catch (error) { + failed += 1; + console.log(` FAIL - ${name}`); + console.log(` ${error && error.message}`); + } +} + +function captureStdout(fn) { + const original = console.log; + const lines = []; + console.log = (...args) => lines.push(args.join(' ')); + try { + fn(); + } finally { + console.log = original; + } + return lines.join('\n'); +} + +const NOW = 1_750_000_000_000; +const DAY = 86_400_000; + +// Build a fake git runner from a scripted command table. Keys are the joined +// argv; values are { status, stdout }. Lets us drive the whole state machine +// deterministically with zero real git. +function fakeGit(repoRoot, table, perPathDirty = {}) { + const runImpl = (args, opts = {}) => { + // isDirty runs status --porcelain with cwd set to the worktree path. + if (args[0] === 'status' && args[1] === '--porcelain') { + const dirty = perPathDirty[opts.cwd] === true; + return { status: 0, stdout: dirty ? ' M file.js\n' : '', stderr: '' }; + } + const key = args.join(' '); + if (Object.prototype.hasOwnProperty.call(table, key)) { + return { stderr: '', stdout: '', status: 0, ...table[key] }; + } + return { status: 1, stdout: '', stderr: `unscripted: ${key}` }; + }; + return createGitRunner(repoRoot, runImpl); +} + +test('classifyWorktree maps git facts to lifecycle states', () => { + const opt = { staleThresholdMs: 7 * DAY, nowMs: NOW }; + assert.strictEqual(classifyWorktree({ isMain: true }, opt), STATES.MAIN); + assert.strictEqual(classifyWorktree({ branch: null, detached: true }, opt), STATES.DETACHED); + assert.strictEqual(classifyWorktree({ branch: 'x', dirty: true }, opt), STATES.DIRTY); + assert.strictEqual(classifyWorktree({ branch: 'x', aheadBehind: { ahead: 0, behind: 3 } }, opt), STATES.MERGED); + assert.strictEqual(classifyWorktree({ branch: 'x', aheadBehind: { ahead: 2, behind: 0 }, conflict: true }, opt), STATES.CONFLICT); + assert.strictEqual(classifyWorktree({ branch: 'x', aheadBehind: { ahead: 2, behind: 0 }, conflict: false }, opt), STATES.MERGE_READY); + assert.strictEqual(classifyWorktree({ branch: 'x', aheadBehind: { ahead: 0, behind: 0 }, lastCommitMs: NOW - 30 * DAY }, opt), STATES.MERGED); + // unmerged (ahead>0) + old + no conflict => STALE (salvage candidate) + assert.strictEqual(classifyWorktree({ branch: 'x', aheadBehind: { ahead: 2, behind: 0 }, conflict: false, lastCommitMs: NOW - 30 * DAY }, opt), STATES.STALE); + // unmerged + recent => MERGE_READY + assert.strictEqual(classifyWorktree({ branch: 'x', aheadBehind: { ahead: 2, behind: 0 }, conflict: false, lastCommitMs: NOW - 1 * DAY }, opt), STATES.MERGE_READY); +}); + +test('git.listWorktrees parses porcelain output', () => { + const git = fakeGit('/repo', { + 'worktree list --porcelain': { + stdout: [ + 'worktree /repo', 'HEAD abc', 'branch refs/heads/main', '', + 'worktree /repo/.wt/feature', 'HEAD def', 'branch refs/heads/feature', '', + 'worktree /repo/.wt/detached', 'HEAD 999', 'detached', '' + ].join('\n') + } + }); + const list = git.listWorktrees(); + assert.strictEqual(list.length, 3); + assert.strictEqual(list[1].branch, 'feature'); + assert.strictEqual(list[2].detached, true); +}); + +test('git.predictMergeConflicts: clean merge (exit 0) and conflict (exit !=0)', () => { + const clean = fakeGit('/repo', { + 'merge-tree --write-tree --name-only main feature': { status: 0, stdout: 'treeoid\n' } + }); + assert.strictEqual(clean.predictMergeConflicts('feature', 'main').conflicted, false); + + const conflicted = fakeGit('/repo', { + 'merge-tree --write-tree --name-only main feature': { + status: 1, + stdout: '0123456789012345678901234567890123456789\nsrc/app.js\nsrc/util.js\n' + } + }); + const result = conflicted.predictMergeConflicts('feature', 'main'); + assert.strictEqual(result.conflicted, true); + assert.deepStrictEqual(result.files, ['src/app.js', 'src/util.js']); +}); + +test('git.aheadBehind + lastCommitMs parse rev-list/log output', () => { + const git = fakeGit('/repo', { + 'rev-list --left-right --count main...feature': { stdout: '3\t5\n' }, + 'log -1 --format=%ct feature': { stdout: '1700000000\n' } + }); + assert.deepStrictEqual(git.aheadBehind('feature', 'main'), { behind: 3, ahead: 5 }); + assert.strictEqual(git.lastCommitMs('feature'), 1700000000 * 1000); + // missing branch => null + assert.strictEqual(git.aheadBehind('ghost', 'main'), null); +}); + +function fullRepoGit() { + const recent = Math.floor((NOW - 1 * DAY) / 1000); + const old = Math.floor((NOW - 30 * DAY) / 1000); + return fakeGit('/repo', { + 'worktree list --porcelain': { + stdout: [ + 'worktree /repo', 'HEAD a', 'branch refs/heads/main', '', + 'worktree /repo/.wt/ready', 'HEAD b', 'branch refs/heads/ready', '', + 'worktree /repo/.wt/conflict', 'HEAD c', 'branch refs/heads/conflict', '', + 'worktree /repo/.wt/merged', 'HEAD d', 'branch refs/heads/merged', '', + 'worktree /repo/.wt/stale', 'HEAD e', 'branch refs/heads/stale', '', + 'worktree /repo/.wt/dirty', 'HEAD f', 'branch refs/heads/dirty', '' + ].join('\n') + }, + // ready: 2 ahead, clean merge + 'rev-list --left-right --count main...ready': { stdout: '0\t2\n' }, + 'log -1 --format=%ct ready': { stdout: `${recent}\n` }, + 'merge-tree --write-tree --name-only main ready': { status: 0, stdout: 'tree\n' }, + // conflict: 2 ahead, conflicts + 'rev-list --left-right --count main...conflict': { stdout: '1\t2\n' }, + 'log -1 --format=%ct conflict': { stdout: `${recent}\n` }, + 'merge-tree --write-tree --name-only main conflict': { status: 1, stdout: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nsrc/x.js\n' }, + // merged: 0 ahead + 'rev-list --left-right --count main...merged': { stdout: '4\t0\n' }, + 'log -1 --format=%ct merged': { stdout: `${recent}\n` }, + // stale: unmerged (2 ahead), clean merge, but old/inactive => salvage candidate + 'rev-list --left-right --count main...stale': { stdout: '0\t2\n' }, + 'log -1 --format=%ct stale': { stdout: `${old}\n` }, + 'merge-tree --write-tree --name-only main stale': { status: 0, stdout: 'tree\n' }, + // dirty: ahead but dirty (no merge-tree should run) + 'rev-list --left-right --count main...dirty': { stdout: '0\t1\n' }, + 'log -1 --format=%ct dirty': { stdout: `${recent}\n` } + }, { '/repo/.wt/dirty': true }); +} + +test('buildLifecycleReport classifies a full repo and builds queues', () => { + const report = buildLifecycleReport('/repo', { baseBranch: 'main', staleThresholdMs: 7 * DAY, nowMs: NOW }, { git: fullRepoGit() }); + + const byBranch = Object.fromEntries(report.worktrees.map(w => [w.branch, w.state])); + assert.strictEqual(byBranch.main, 'main'); + assert.strictEqual(byBranch.ready, 'merge-ready'); + assert.strictEqual(byBranch.conflict, 'conflict'); + assert.strictEqual(byBranch.merged, 'merged'); + assert.strictEqual(byBranch.stale, 'stale'); + assert.strictEqual(byBranch.dirty, 'dirty'); + + assert.strictEqual(report.aggregates.conflictCount, 1); + assert.strictEqual(report.aggregates.staleCount, 1); + assert.strictEqual(report.aggregates.mergeReadyCount, 1); + assert.strictEqual(report.conflictQueue[0].conflictFiles[0], 'src/x.js'); +}); + +test('planCleanup removes merged + stale-clean, preserves dirty + unmerged', () => { + const report = buildLifecycleReport('/repo', { baseBranch: 'main', staleThresholdMs: 7 * DAY, nowMs: NOW }, { git: fullRepoGit() }); + const plan = planCleanup(report); + + const removeBranches = plan.remove.map(r => r.branch).sort(); + assert.deepStrictEqual(removeBranches, ['merged'], 'only fully-merged is auto-removable'); + + const salvageBranches = plan.salvage.map(s => s.branch); + assert.ok(salvageBranches.includes('stale'), 'stale (unmerged+old) goes to salvage, never blind delete'); + + const keptBranches = plan.keep.map(k => k.branch); + assert.ok(keptBranches.includes('dirty'), 'dirty preserved'); + assert.ok(keptBranches.includes('ready'), 'unmerged merge-ready preserved'); + assert.ok(keptBranches.includes('conflict'), 'conflict preserved'); +}); + +test('CLI parseArgs handles flags and valued options', () => { + const o = parseArgs(['node', 's', '--json', '--base', 'develop', '--stale-days', '14', '--repo', '/x']); + assert.strictEqual(o.json, true); + assert.strictEqual(o.baseBranch, 'develop'); + assert.strictEqual(o.staleDays, 14); + assert.strictEqual(o.repoRoot, '/x'); + assert.strictEqual(parseArgs(['node', 's', '-h']).help, true); + assert.strictEqual(parseArgs(['node', 's', '--conflicts']).conflictsOnly, true); + assert.strictEqual(parseArgs(['node', 's', '--stale']).staleOnly, true); + assert.strictEqual(parseArgs(['node', 's', '--cleanup-plan']).cleanupPlan, true); +}); + +test('formatReport renders default, conflicts, stale, and cleanup-plan views', () => { + const report = buildLifecycleReport('/repo', { baseBranch: 'main', staleThresholdMs: 7 * DAY, nowMs: NOW }, { git: fullRepoGit() }); + assert.ok(formatReport(report).includes('Worktree Lifecycle')); + assert.ok(formatReport(report, { conflictsOnly: true }).includes('Conflict queue')); + assert.ok(formatReport(report, { staleOnly: true }).includes('Stale queue')); + const cleanup = formatReport(report, { cleanupPlan: true }); + assert.ok(cleanup.includes('Safe to remove') && cleanup.includes('Kept')); +}); + +test('CLI usage() and main(--help) print help', () => { + assert.ok(usage().includes('Usage: worktree-lifecycle')); + const out = captureStdout(() => main(['node', 's', '--help'])); + assert.ok(out.includes('git merge-tree')); +}); + +test('empty-queue branches: no conflicts / no stale render friendly messages', () => { + const git = fakeGit('/repo', { + 'worktree list --porcelain': { stdout: 'worktree /repo\nHEAD a\nbranch refs/heads/main\n' } + }); + const report = buildLifecycleReport('/repo', { baseBranch: 'main', nowMs: NOW }, { git }); + assert.ok(formatReport(report, { conflictsOnly: true }).includes('No worktrees would conflict')); + assert.ok(formatReport(report, { staleOnly: true }).includes('No stale worktrees')); +}); + + +test('real git: createGitRunner drives an actual repo (covers default spawn path)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-wl-realgit-')); + const cleanEnv = { ...process.env }; + for (const k of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_PREFIX']) delete cleanEnv[k]; + const git = (args) => execFileSync('git', ['-C', dir, ...args], { stdio: ['ignore', 'pipe', 'ignore'], env: cleanEnv }); + try { + git(['init', '-q', '-b', 'main']); + git(['config', 'user.email', 't@e.st']); + git(['config', 'user.name', 'Test']); + fs.writeFileSync(path.join(dir, 'a.txt'), 'hello\n'); + git(['add', '-A']); + git(['commit', '-q', '-m', 'init']); + } catch (e) { + // git not available in this environment; skip without failing the suite. + console.log(' (skipped real-git: ' + (e && e.message ? e.message.split('\n')[0] : 'git unavailable') + ')'); + return; + } + + const runner = createGitRunner(dir); + assert.strictEqual(runner.isGitRepo(), true); + assert.strictEqual(runner.branchExists('main'), true); + assert.strictEqual(runner.branchExists('nope'), false); + assert.strictEqual(runner.isDirty(dir), false); + + const wts = runner.listWorktrees(); + assert.ok(wts.length >= 1 && wts[0].branch === 'main'); + + // dirty detection + fs.writeFileSync(path.join(dir, 'a.txt'), 'changed\n'); + assert.strictEqual(runner.isDirty(dir), true); + + // lastCommitMs returns a real epoch-ms + assert.ok(runner.lastCommitMs('main') > 0); + + fs.rmSync(dir, { recursive: true, force: true }); +}); + +console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); +if (failed > 0) process.exit(1); diff --git a/tests/run-all.js b/tests/run-all.js index 46a3eb19..fd79cb4a 100644 --- a/tests/run-all.js +++ b/tests/run-all.js @@ -73,9 +73,19 @@ for (const testFile of testFiles) { console.log(`\n━━━ Running ${displayPath} ━━━`); + // Run each test hermetically: strip inherited git env vars. When the suite + // runs inside a git hook (e.g. pre-push), git sets GIT_DIR/GIT_WORK_TREE, + // which would hijack `git -C ` calls in tests that exercise real git + // and make them operate on the host repo instead of their own fixtures. + const childEnv = { ...process.env }; + for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_PREFIX']) { + delete childEnv[key]; + } + const result = spawnSync('node', [testPath], { encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'] + stdio: ['pipe', 'pipe', 'pipe'], + env: childEnv }); const stdout = result.stdout || '';