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
173 lines
5.4 KiB
JavaScript
Executable File
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}`,
|
|
);
|