snapshot of https://github.com/marmelab/atomic-crm.git (main, 2026-09-18; blobs > 131072B omitted)
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
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
This commit is contained in:
172
.claude/hooks/cleanup-worktree.mjs
Executable file
172
.claude/hooks/cleanup-worktree.mjs
Executable file
@@ -0,0 +1,172 @@
|
||||
#!/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}`,
|
||||
);
|
||||
Reference in New Issue
Block a user