chore: import upstream snapshot with attribution
CI / Test (ubuntu-latest, Node 18.x, bun) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, npm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, pnpm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, yarn) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 20.x, bun) (push) Failing after 17m13s
CI / Test (ubuntu-latest, Node 20.x, npm) (push) Failing after 18m42s
CI / Test (ubuntu-latest, Node 20.x, pnpm) (push) Failing after 15m0s
CI / Test (ubuntu-latest, Node 20.x, yarn) (push) Failing after 49m44s
CI / Test (ubuntu-latest, Node 22.x, bun) (push) Failing after 51m55s
CI / Test (ubuntu-latest, Node 22.x, pnpm) (push) Failing after 21m57s
CI / Test (ubuntu-latest, Node 22.x, npm) (push) Failing after 37m39s
CI / Test (ubuntu-latest, Node 22.x, yarn) (push) Failing after 34m7s
CI / Validate Components (push) Failing after 37m15s
CI / Python Tests (push) Failing after 10m1s
CI / Security Scan (push) Failing after 10m1s
CI / Lint (push) Failing after 17m12s
CI / Coverage (push) Failing after 20m19s
CI / Test (macos-latest, Node 18.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (ubuntu-latest, Node 18.x, bun) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, npm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, pnpm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, yarn) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 20.x, bun) (push) Failing after 17m13s
CI / Test (ubuntu-latest, Node 20.x, npm) (push) Failing after 18m42s
CI / Test (ubuntu-latest, Node 20.x, pnpm) (push) Failing after 15m0s
CI / Test (ubuntu-latest, Node 20.x, yarn) (push) Failing after 49m44s
CI / Test (ubuntu-latest, Node 22.x, bun) (push) Failing after 51m55s
CI / Test (ubuntu-latest, Node 22.x, pnpm) (push) Failing after 21m57s
CI / Test (ubuntu-latest, Node 22.x, npm) (push) Failing after 37m39s
CI / Test (ubuntu-latest, Node 22.x, yarn) (push) Failing after 34m7s
CI / Validate Components (push) Failing after 37m15s
CI / Python Tests (push) Failing after 10m1s
CI / Security Scan (push) Failing after 10m1s
CI / Lint (push) Failing after 17m12s
CI / Coverage (push) Failing after 20m19s
CI / Test (macos-latest, Node 18.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, yarn) (push) Has been cancelled
This commit is contained in:
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user