mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-14 04:01:30 +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:
@@ -219,7 +219,7 @@ function mergeServers(serverRecords) {
|
|||||||
|
|
||||||
return Array.from(byName.values()).map(server => {
|
return Array.from(byName.values()).map(server => {
|
||||||
const uniqueSignatures = Array.from(new Set(server.signatures));
|
const uniqueSignatures = Array.from(new Set(server.signatures));
|
||||||
const { signatures, ...rest } = server;
|
const { signatures: _signatures, ...rest } = server;
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
harnessCount: server.sources.length,
|
harnessCount: server.sources.length,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ function loadTomlParser(parseTomlImpl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line global-require
|
|
||||||
return require('@iarna/toml').parse;
|
return require('@iarna/toml').parse;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -257,9 +257,16 @@ function resolveGitBranch(cwd, resolveBranchImpl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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'], {
|
const branch = execFileSync('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
encoding: 'utf8'
|
encoding: 'utf8',
|
||||||
|
env: gitEnv
|
||||||
}).trim();
|
}).trim();
|
||||||
|
|
||||||
return branch.length > 0 ? branch : null;
|
return branch.length > 0 ? branch : null;
|
||||||
|
|||||||
@@ -213,9 +213,16 @@ function resolveGitBranch(cwd, resolveBranchImpl) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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'], {
|
const branch = execFileSync('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
encoding: 'utf8'
|
encoding: 'utf8',
|
||||||
|
env: gitEnv
|
||||||
}).trim();
|
}).trim();
|
||||||
|
|
||||||
return branch.length > 0 ? branch : null;
|
return branch.length > 0 ? branch : null;
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
Executable
+164
@@ -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
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
+11
-1
@@ -73,9 +73,19 @@ for (const testFile of testFiles) {
|
|||||||
|
|
||||||
console.log(`\n━━━ Running ${displayPath} ━━━`);
|
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 <dir>` 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], {
|
const result = spawnSync('node', [testPath], {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: ['pipe', 'pipe', 'pipe']
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
env: childEnv
|
||||||
});
|
});
|
||||||
|
|
||||||
const stdout = result.stdout || '';
|
const stdout = result.stdout || '';
|
||||||
|
|||||||
Reference in New Issue
Block a user