Files
upstream-atomic-crm/.claude/scripts/harness-revert.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

291 lines
11 KiB
JavaScript
Executable File

#!/usr/bin/env node
//
// Per-session revert for the agent harness.
//
// Undoes ONE harness session's promoted work and removes its artifacts, leaving
// every other (possibly parallel) session untouched. Replaces a global hard-reset:
// instead of resetting the branch to a single fork anchor (which would wipe a
// parallel session that landed on the same branch), it reverts only THIS session's
// promotion merge commits, keyed by SESSION_SHORT_ID.
//
// Driven by the `/harness-revert` slash command (portable across CLI / VS Code
// extension / desktop), which resolves the short id and confirms before calling
// this. It can also be run directly.
//
// How a session lands on the base branch: the merger promotes with
// git merge --no-ff session/<short> -m "merge(session): <short>"
// (see .claude/agents/merger.md, Stage B). So this session's footprint on the base
// branch is exactly the merge commits whose subject is "merge(session): <short>".
//
// Usage:
// .claude/scripts/harness-revert.mjs <SESSION_SHORT_ID> # revert + clean up
// .claude/scripts/harness-revert.mjs <SESSION_SHORT_ID> --dry-run # print, change nothing
//
import { spawnSync } from "node:child_process";
import { existsSync, readdirSync, rmSync } from "node:fs";
import { join } from "node:path";
const argv = process.argv.slice(2);
const DRY_RUN = argv.includes("--dry-run") || argv.includes("-n");
const HARD = argv.includes("--hard");
const FORCE = argv.includes("--force");
const SHORT = argv.find((a) => !a.startsWith("-"));
const log = (msg) => process.stdout.write(`${msg}\n`);
const err = (msg) => process.stderr.write(`${msg}\n`);
if (!SHORT) {
err(
"usage: harness-revert.mjs <SESSION_SHORT_ID> [--hard] [--force] [--dry-run]",
);
process.exit(2);
}
// Run git, capturing output. Returns { code, out, err }.
function git(args) {
const res = spawnSync("git", args, { encoding: "utf8" });
return {
code: res.status ?? (res.error ? 1 : 0),
out: (res.stdout || "").trim(),
err: (res.stderr || "").trim(),
};
}
// Run a mutating git command, honouring --dry-run.
function gitRun(args) {
if (DRY_RUN) {
log(`[dry-run] git ${args.join(" ")}`);
return { code: 0, out: "", err: "" };
}
return git(args);
}
// Repo root (authoritative via git; falls back to env/cwd).
const top = git(["rev-parse", "--show-toplevel"]);
const REPO_ROOT =
(top.code === 0 && top.out) ||
process.env.CLAUDE_PROJECT_DIR ||
process.cwd();
process.chdir(REPO_ROOT);
const CURRENT_BRANCH = git(["rev-parse", "--abbrev-ref", "HEAD"]).out;
const BASE_BRANCH = git([
"config",
"--local",
"--get",
`sessionbase.${SHORT}.branch`,
]).out;
log(`==> Session: ${SHORT}`);
log(`==> Base branch: ${BASE_BRANCH || "(unknown — cleanup only)"}`);
if (DRY_RUN) log("==> Mode: DRY-RUN (no changes)");
if (HARD)
log("==> Mode: HARD (reset to fork — drops the session's commits)");
// Guard: undoing commits requires being ON the base branch the session promoted to.
const requireOnBase = () => {
if (BASE_BRANCH && CURRENT_BRANCH !== BASE_BRANCH) {
err(
`!! You are on '${CURRENT_BRANCH}' but session ${SHORT} promoted onto '${BASE_BRANCH}'.`,
);
err(` Switch to it first: git checkout ${BASE_BRANCH}`);
process.exit(1);
}
};
// Never let an undo clobber uncommitted work.
const stashDirty = () => {
if (git(["status", "--porcelain", "--untracked-files=no"]).out) {
log("==> Stashing uncommitted changes (recover with: git stash pop)");
gitRun(["stash", "push", "-m", `harness-revert ${SHORT} safety stash`]);
}
};
// Undo the session's promoted work. Two strategies:
// default → `git revert` the promotion merge commit(s): keeps history, adds a
// revert commit, SAFE even if other sessions landed on the same branch
// afterwards (it only backs out THIS session's changes).
// --hard → `git reset --hard` to the session's fork point (session-base/<short>):
// drops the session's commits ENTIRELY (clean history — no merge, no
// revert commit), but ALSO drops anything else committed after the fork.
// Use it when this session is the only thing on the branch (e.g. demos).
if (HARD) {
// Resolve the fork anchor NOW, before the cleanup section deletes it below.
const anchor = `session-base/${SHORT}`;
const fork = git(["rev-parse", "--verify", `refs/heads/${anchor}`]);
if (fork.code !== 0 || !fork.out) {
err(
`!! No fork anchor '${anchor}' — can't hard-reset. Run without --hard to revert instead.`,
);
process.exit(1);
}
const forkSha = fork.out;
requireOnBase();
if (git(["merge-base", "--is-ancestor", forkSha, "HEAD"]).code !== 0) {
err(
`!! ${anchor} (${forkSha.slice(0, 12)}) is not an ancestor of HEAD — history diverged, refusing to hard-reset. Use the default (revert) mode.`,
);
process.exit(1);
}
// Safety: `reset --hard` drops EVERYTHING in forkSha..HEAD, not just the
// session. The session's own footprint here is its promotion merge(s) plus the
// commits reachable from session/<short>; anything else is UNRELATED work (e.g.
// commits you made on the base branch after the session forked) that --hard
// would silently destroy. Refuse when such collateral exists, unless --force.
const sessionRef = `session/${SHORT}`;
const hasSessionBranch =
git(["show-ref", "--verify", "--quiet", `refs/heads/${sessionRef}`])
.code === 0;
const collateral = git(["rev-list", `${forkSha}..HEAD`])
.out.split("\n")
.filter(Boolean)
.filter((sha) => {
if (
git(["show", "-s", "--format=%s", sha]).out ===
`merge(session): ${SHORT}`
)
return false; // this session's promotion merge
if (
hasSessionBranch &&
git(["merge-base", "--is-ancestor", sha, sessionRef]).code === 0
)
return false; // brought in by the session
return true; // unrelated commit
});
if (collateral.length && !FORCE) {
err(
`!! reset --hard to ${anchor} would ALSO drop ${collateral.length} commit(s) NOT part of session ${SHORT}:`,
);
for (const sha of collateral.slice(0, 20))
err(` ${git(["show", "-s", "--format=%h %s", sha]).out}`);
if (collateral.length > 20)
err(` ... and ${collateral.length - 20} more`);
err(
` These would be permanently removed from ${CURRENT_BRANCH}. Use the default (revert) mode (drop --hard) - it never touches unrelated commits - or re-run with --force if you really mean to reset to the fork point.`,
);
if (!DRY_RUN) process.exit(1);
}
stashDirty();
log(
`==> Hard-resetting ${CURRENT_BRANCH} to ${anchor} (${forkSha.slice(0, 12)}) — the session disappears from history`,
);
const r = gitRun(["reset", "--hard", forkSha]);
if (r.code !== 0) {
err(`!! reset --hard failed: ${r.err}`);
process.exit(1);
}
} else {
// Find this session's promotion merge commits on the base branch (newest first,
// so reverting in order unwinds the stack cleanly).
let merges = [];
if (
BASE_BRANCH &&
git(["show-ref", "--verify", "--quiet", `refs/heads/${BASE_BRANCH}`])
.code === 0
) {
const subject = `merge(session): ${SHORT}`;
merges = git(["log", "--merges", "--format=%H%x09%s", BASE_BRANCH])
.out.split("\n")
.filter(Boolean)
.map((line) => line.split("\t"))
.filter(([, subj]) => subj === subject)
.map(([sha]) => sha);
}
if (merges.length === 0) {
log(
`==> No promotion merge found for session ${SHORT} — nothing to revert (cleanup only).`,
);
} else {
requireOnBase();
stashDirty();
// All-or-nothing: capture HEAD before the first revert so that if a later
// merge conflicts we can roll the already-applied reverts back. Otherwise a
// partial run leaves some reverts committed while the original merges still
// match the subject filter, and a naive "resolve and re-run" would re-revert
// the already-reverted merges (revert-of-revert re-applies the session's
// changes). Resetting to preRevertHead keeps re-run idempotent.
const preRevertHead = DRY_RUN ? "" : git(["rev-parse", "HEAD"]).out;
for (const sha of merges) {
log(`==> Reverting promotion merge ${sha.slice(0, 12)}`);
const r = gitRun(["revert", "--no-edit", "-m", "1", sha]);
if (r.code !== 0) {
gitRun(["revert", "--abort"]);
if (!DRY_RUN && preRevertHead)
gitRun(["reset", "--hard", preRevertHead]);
err(
`!! Revert hit a conflict on ${sha.slice(0, 12)} (another session may have touched the same files).`,
);
err(
" Rolled back to the pre-revert state — nothing was reverted or removed. Resolve the conflict source, then re-run.",
);
process.exit(1);
}
}
}
}
// 3. Remove this session's worktrees (matched by their checked-out branch, since
// the worktree path carries the full session id we don't have here).
const worktrees = [];
{
const lines = git(["worktree", "list", "--porcelain"]).out.split("\n");
let wt = "";
for (const line of lines) {
if (line.startsWith("worktree ")) wt = line.slice("worktree ".length);
else if (line.startsWith("branch ")) {
const br = line.slice("branch refs/heads/".length);
if (br === `session/${SHORT}` || br.startsWith(`${SHORT}/`))
worktrees.push(wt);
}
}
}
for (const wt of worktrees) {
log(`==> Removing worktree ${wt}`);
gitRun(["worktree", "remove", "--force", wt]);
}
gitRun(["worktree", "prune"]);
// 4. Delete this session's branches (task/simple, integration, fork anchor).
const branches = git([
"for-each-ref",
"--format=%(refname:short)",
`refs/heads/${SHORT}/*`,
`refs/heads/session/${SHORT}`,
`refs/heads/session-base/${SHORT}`,
])
.out.split("\n")
.filter(Boolean);
for (const br of branches) {
if (br === CURRENT_BRANCH) {
log(` ! skip current branch: ${br}`);
continue;
}
log(`==> Deleting branch ${br}`);
gitRun(["branch", "-D", br]);
}
// 5. Remove the fork-base config subsection.
log(`==> Removing config sessionbase.${SHORT}`);
gitRun(["config", "--local", "--remove-section", `sessionbase.${SHORT}`]);
// 6. Remove this session's /tmp dir(s). Worktree base is /tmp/<slug>/<full-id>;
// the full id is <short>-<rest>, so match on the short id at the UUID-segment
// boundary (exact, or followed by "-"). A bare startsWith(SHORT) would also
// swallow another session whose id merely shares the leading hex block.
const slug = REPO_ROOT.replace(/\//g, "_");
const tmpBase = `/tmp/${slug}`;
if (existsSync(tmpBase)) {
for (const name of readdirSync(tmpBase)) {
if (name !== SHORT && !name.startsWith(`${SHORT}-`)) continue;
const dir = join(tmpBase, name);
log(`==> Removing session dir ${dir}`);
if (DRY_RUN) log(`[dry-run] rm -rf ${dir}`);
else rmSync(dir, { recursive: true, force: true });
}
}
// NOTE: .promote.lock is intentionally left in place — it is shared across
// sessions and removing it could break a concurrent promotion's mutual exclusion.
log("==> Done.");