Files
upstream-atomic-crm/.claude/hooks/lib/git.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

89 lines
3.4 KiB
JavaScript

// Git repo and worktree state queries.
import { REPO } from "./paths.mjs";
import { exec } from "./process.mjs";
export function git(args = [], opts = {}) {
return exec("git", ["-C", REPO, ...args], opts);
}
export function getBaseBranch() {
const r = git(["symbolic-ref", "--short", "HEAD"]);
return (r.status === 0 ? r.stdout.trim() : "") || "main";
}
const WORKTREE_PREFIX = "worktree ";
const BRANCH_PREFIX = "branch refs/heads/";
// Value of the first line carrying `prefix`, stripped of it; "" when absent.
const lineValue = (lines, prefix) => {
const line = lines.find((l) => l.startsWith(prefix));
return line ? line.slice(prefix.length) : "";
};
// `--porcelain` emits one blank-line-separated block per worktree, each opening
// with a `worktree <path>` line and (unless detached/bare) a `branch refs/...`
// line. Parse each block independently; drop blocks with no path (trailing blank).
export function getWorktreeEntries() {
return git(["worktree", "list", "--porcelain"]).stdout
.split("\n\n")
.map((block) => block.split("\n"))
.map((lines) => ({
path: lineValue(lines, WORKTREE_PREFIX),
branch: lineValue(lines, BRANCH_PREFIX),
}))
.filter((entry) => entry.path);
}
export const getWorktreePaths = () => getWorktreeEntries().map((e) => e.path);
// Combined per-worktree change summary in a single `git status --porcelain`
// (working tree) + one `git log` (commits ahead of base) + one `git diff
// --name-only` (committed file names). Returns { dirty, changedFiles } so callers
// (validate-on-stop) get both signals without spawning `git status` twice.
export function getWorktreeChangeSummary(wt, base) {
const nonEmpty = (lines) => lines.split("\n").filter((l) => l.trim());
const statusPath = (line) => line.trim().split(/\s+/).pop();
const working = exec("git", ["-C", wt, "status", "--porcelain"]).stdout;
const ahead = exec("git", ["-C", wt, "log", "--oneline", `${base}..HEAD`]).stdout.trim();
const committed = exec("git", ["-C", wt, "diff", "--name-only", `${base}..HEAD`]).stdout;
const changedFiles = [
...new Set([
...nonEmpty(committed).map((l) => l.trim()),
...nonEmpty(working).map(statusPath),
]),
];
return { dirty: Boolean(working.trim() || ahead), changedFiles };
}
// Task branches under refs/heads/<short>/ that still have commits NOT merged into
// `sessionRef`. The single source of truth for the promotion-safety check
// (block-promote-unmerged.mjs); the orchestrator's STATE B reconcile Bash
// mirrors it and must stay aligned. Fails CLOSED: a failed/unparseable rev-list
// count is reported as unmerged (count: null) rather than silently treated as
// merged. `exclude` simple branches that legitimately never merge into the session
// branch (e.g. <short>/simple, which promotes straight to main).
export function getUnmergedTaskBranches(short, sessionRef, exclude = []) {
const branches = git([
"for-each-ref",
"--format=%(refname:short)",
`refs/heads/${short}`,
])
.stdout.split("\n")
.map((s) => s.trim())
.filter(Boolean)
.filter((b) => !exclude.includes(b));
const unmerged = [];
for (const br of branches) {
const r = git(["rev-list", "--count", `${sessionRef}..${br}`]);
const n = Number(r.stdout.trim());
if (r.status !== 0 || !Number.isFinite(n)) {
unmerged.push({ branch: br, count: null }); // fail closed
} else if (n > 0) {
unmerged.push({ branch: br, count: n });
}
}
return unmerged;
}