Files
upstream-atomic-crm/.claude/hooks/cleanup-worktree.mjs
upstream-mirror 93ecc1a6f8
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
snapshot of https://github.com/marmelab/atomic-crm.git (main, 2026-09-18; blobs > 131072B omitted)
2026-09-18 01:48:50 +05:00

173 lines
5.4 KiB
JavaScript
Executable File

#!/usr/bin/env node
// SubagentStop — remove this session's task worktrees once their branch has been
// merged (--no-ff, the merger's contract) into the session integration branch and
// the worktree has no uncommitted changes. Session worktrees/branches (_session,
// simple, session*/), fresh worktrees, and unmerged work are preserved.
import {
existsSync,
readdirSync,
readFileSync,
rmSync,
rmdirSync,
} from "node:fs";
import { join } from "node:path";
import { createHookContext } from "./lib/context.mjs";
import { readAgentMeta } from "./lib/agent-meta.mjs";
import {
getBaseBranch,
getWorktreeEntries,
getWorktreePaths,
git,
} from "./lib/git.mjs";
import { exec } from "./lib/process.mjs";
import {
isInfraWorktreePath,
isProtectedBranch,
isTaskWorktreeDirName,
sessionBaseBranch,
sessionBranch,
} from "./lib/topology.mjs";
import { removeWorktree } from "./lib/worktree.mjs";
import { removeWorktreeFolders } from "./lib/workspace-folders.mjs";
const raw = readFileSync(0, "utf8");
const ctx = createHookContext(raw, "cleanup-worktree");
// SubagentStop fires this on EVERY subagent stop here (the matcher doesn't filter
// and agent_type is empty in the payload). cleanup is the MERGER's post-merge step
// — skip the worktree sweep when the sibling <agent>.meta.json shows the stopping
// agent is clearly NOT a merger (developer / reviewer / planner / …). The sweep is
// idempotent either way; this just avoids running it on every stop. Meta
// missing/ambiguous → proceed with the normal cleanup.
{
let payload = {};
try {
payload = JSON.parse(raw);
} catch {
/* proceed */
}
const meta = readAgentMeta(payload);
if (meta && meta.agentType && meta.agentType !== "merger") {
ctx.accept(`skip — ${meta.agentType} stop (cleanup runs on merger stops)`);
}
}
if (!existsSync(ctx.worktreeBase)) {
ctx.accept(`${ctx.worktreeBase} not found`);
}
const base = getBaseBranch();
ctx.log(
`START session=${ctx.sessionShort} base=${ctx.worktreeBase} branch=${base}`,
);
const isUnderBase = (p) =>
p === ctx.worktreeBase || p.startsWith(ctx.worktreeBase + "/");
const hasLocalBranch = (ref) =>
git(["show-ref", "--verify", "--quiet", `refs/heads/${ref}`]).status === 0;
// Tips that were merged with --no-ff into the session integration branch: the
// second-plus parents of its merge commits. Ancestry alone cannot tell a merged
// branch from a fresh one that never diverged (both tips are ancestors), and a
// fresh worktree may belong to a developer still working — so only branches
// whose tip is a recorded merge parent are removable.
const getMergedTips = () => {
const integration = sessionBranch(ctx);
if (!hasLocalBranch(integration)) return new Set();
const anchor = sessionBaseBranch(ctx);
const range = hasLocalBranch(anchor)
? `${anchor}..${integration}`
: integration;
return new Set(
git(["log", "--merges", "--format=%P", range])
.stdout.split("\n")
.filter(Boolean)
.flatMap((line) => line.split(" ").slice(1)),
);
};
const mergedTips = getMergedTips();
const shouldRemove = ({ path: wtPath, branch }) => {
if (isInfraWorktreePath(wtPath)) {
ctx.log(`SKIP-SESSION-WORKTREE ${wtPath}`);
return false;
}
if (!branch) {
ctx.log(`SKIP-DETACHED ${wtPath} (detached HEAD)`);
return false;
}
const tip = git(["rev-parse", "--verify", branch]).stdout.trim();
if (!tip || !mergedTips.has(tip)) {
ctx.log(`SKIP-UNMERGED ${wtPath} branch=${branch}`);
return false;
}
if (exec("git", ["-C", wtPath, "status", "--porcelain"]).stdout.trim()) {
ctx.log(`SKIP-DIRTY ${wtPath} (uncommitted changes)`);
return false;
}
return true;
};
const deleteBranch = (branch) => {
if (!branch) return;
if (isProtectedBranch(branch)) {
ctx.log(`SKIP-SESSION-BRANCH ${branch}`);
return;
}
if (git(["branch", "-d", branch]).status !== 0) {
git(["branch", "-D", branch]);
}
ctx.log(`BRANCH-DELETED ${branch}`);
};
const ourWorktrees = getWorktreeEntries().filter((e) => isUnderBase(e.path));
const toRemove = ourWorktrees.filter(shouldRemove);
toRemove.forEach((e) => {
removeWorktree(e.path);
ctx.log(`REMOVED ${e.path}`);
});
toRemove.map((e) => e.branch).forEach(deleteBranch);
// Drop the merged worktrees from the editor's workspace folders (setup-worktree
// added them on a technical run). No-op under a managed launcher or a mono-folder
// window (no `.code-workspace` to edit; `code --remove` does not exist).
if (!process.env.CHAT_SESSION_DIR && toRemove.length) {
const removed = new Set(toRemove.map((e) => e.path));
removeWorktreeFolders(ctx.repo, (p) => removed.has(p));
}
git(["worktree", "prune"]);
const registered = getWorktreePaths();
const sweepLeftover = (entry) => {
if (!entry.isDirectory()) return;
const dir = join(ctx.worktreeBase, entry.name);
if (dir.endsWith("/_session")) {
ctx.log(`SKIP-SESSION-LEFTOVER ${dir}`);
return;
}
if (!isTaskWorktreeDirName(entry.name)) {
ctx.log(`SKIP-NON-WORKTREE ${dir}`);
return;
}
if (registered.includes(dir)) return;
rmSync(dir, { recursive: true, force: true });
ctx.log(`LEFTOVER RM ${dir}`);
};
readdirSync(ctx.worktreeBase, { withFileTypes: true }).forEach(sweepLeftover);
try {
rmdirSync(ctx.worktreeBase);
} catch {
// not empty / already gone — fine
}
ctx.accept(
`removed=${toRemove.length} skipped=${ourWorktrees.length - toRemove.length} session=${ctx.sessionShort}`,
);