import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { appendFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test, { type TestContext } from "node:test";
import { commitSliceReport, commitSliceToolResult, commitTargetDirectory, isGitCommitCommand } from "../lib/commit-slice-signal.ts";
// Harness roadmap T4 (R1): the ~400-line slice rule lived in the prompt and the
// model had to estimate line counts. After each parent commit the runtime now
// reports the authored changed lines on the branch, so slicing reads a number.
function repo(t: TestContext): { dir: string; git: (...args: string[]) => string; commitFile: (path: string, lines: number) => void } {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "commit-slice-")));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const git = (...args: string[]) => execFileSync("git", ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.com", "-c", "commit.gpgsign=false", ...args], { cwd: dir, encoding: "utf8" });
const commitFile = (path: string, lines: number) => {
mkdirSync(join(dir, path, ".."), { recursive: true });
writeFileSync(join(dir, path), Array.from({ length: lines }, (_, i) => `line ${i}`).join("\n") + "\n");
git("add", "--", path);
git("commit", "-q", "-m", `add ${path}`);
};
git("init", "-q", "-b", "main");
commitFile("README.md", 3);
return { dir, git, commitFile };
}
test("isGitCommitCommand recognizes commits inside shell commands, nothing else", () => {
for (const command of ["git commit -m 'feat: x'", "git add -A && git commit -qm x", "cd pkg && git -C . commit --amend --no-edit", "git -c user.name=a commit -m x"]) {
assert.equal(isGitCommitCommand(command), true, command);
}
for (const command of ["git status", "git log --grep commit", "echo git commit", "npm test", "git commit-tree abc"]) {
assert.equal(isGitCommitCommand(command), false, command);
}
});
// R1 review advisory: any `echo ... git` segment hid a real commit elsewhere in
// the command, while text that only mentioned `git commit` counted as one.
test("isGitCommitCommand counts a git invocation that starts a shell segment, not text that mentions one", () => {
for (const command of ["echo 'staging git files' && git commit -m x", "echo git status; git commit -m x", "GIT_AUTHOR_DATE=now git commit -m x", "(cd pkg && git commit -m x)", "git add -A\ngit commit -m x", "git commit -m 'feat: git commit counting'"]) {
assert.equal(isGitCommitCommand(command), true, command);
}
for (const command of ["printf 'run git commit now'", "gh pr create --body \"then git commit -m x\"", "grep -rn 'git commit' docs", "echo \"git commit -m x\""]) {
assert.equal(isGitCommitCommand(command), false, command);
}
});
test("commitTargetDirectory follows git -C and a leading cd", () => {
assert.equal(commitTargetDirectory("git commit -m x", "/work/repo"), "/work/repo");
assert.equal(commitTargetDirectory("git -C ../other commit -m x", "/work/repo"), "/work/other");
assert.equal(commitTargetDirectory("cd /tmp/x && git commit -m x", "/work/repo"), "/tmp/x");
});
// PR #2290 review: only a leading `cd
&&` moved the count, so a commit after
// `cd ;` or a later `cd` counted the session cwd instead.
test("commitTargetDirectory follows a cd that precedes the commit through ;, && or ||", () => {
assert.equal(commitTargetDirectory("cd /tmp/x; git commit -m x", "/work/repo"), "/tmp/x");
assert.equal(commitTargetDirectory("git add -A && cd pkg && git commit -m x", "/work/repo"), "/work/repo/pkg");
assert.equal(commitTargetDirectory("cd /tmp/x || exit 1; git commit -m x", "/work/repo"), "/tmp/x");
assert.equal(commitTargetDirectory("cd a && cd 'b c'\ngit -C d commit -m x", "/work/repo"), "/work/repo/a/b c/d");
assert.equal(commitTargetDirectory("git commit -m x && cd /tmp/x", "/work/repo"), "/work/repo", "a cd after the commit does not move it");
});
test("a feature branch reports authored changed lines since the default branch, lock files excluded", async (t) => {
const { dir, git, commitFile } = repo(t);
git("switch", "-q", "-c", "feat/slice");
commitFile("src/a.ts", 120);
commitFile("pnpm-lock.yaml", 900);
commitFile("tests/a.test.ts", 30);
const report = await commitSliceReport(dir);
assert.match(report ?? "", /^Commit slice: 150 authored changed lines in 3 commits on feat\/slice since main/);
assert.match(report ?? "", /250 lines left before the ~400-line slice/);
});
// PR #2290 review: `--no-renames` counted a moved file as all of its lines deleted
// and added again; rename detection counts only what the move changed.
test("a move-only commit counts no authored lines", async (t) => {
const { dir, git, commitFile } = repo(t);
commitFile("src/old.ts", 120);
git("switch", "-q", "-c", "feat/move");
git("mv", "src/old.ts", "src/new.ts");
git("commit", "-q", "-m", "move src/old.ts");
assert.match(await commitSliceReport(dir) ?? "", /^Commit slice: 0 authored changed lines in 1 commit on feat\/move/);
git("mv", "src/new.ts", "src/newer.ts");
appendFileSync(join(dir, "src/newer.ts"), "line 120\n");
git("commit", "-qam", "move and extend src/new.ts");
assert.match(await commitSliceReport(dir) ?? "", /^Commit slice: 1 authored changed lines in 2 commits on feat\/move/, "a move that edits counts only the edit");
});
// PR #2290 review: Git quotes an unusual path in plain numstat output, so the
// trailing quote hid a lock file from the generated-file filter.
test("a lock file whose path Git quotes is still excluded", async (t) => {
const { dir, git, commitFile } = repo(t);
git("switch", "-q", "-c", "feat/quoted");
commitFile("packages/caf\u00e9/pnpm-lock.yaml", 900);
commitFile("src/a.ts", 10);
assert.match(git("-c", "core.quotePath=true", "diff", "--numstat", "main", "HEAD"), /"packages\/caf\\303\\251\/pnpm-lock\.yaml"/, "the fixture path needs quoting");
assert.match(await commitSliceReport(dir) ?? "", /^Commit slice: 10 authored changed lines in 2 commits on feat\/quoted/);
});
test("past ~400 lines the report says to apply the delivery strategy before the next commit", async (t) => {
const { dir, git, commitFile } = repo(t);
git("switch", "-q", "-c", "feat/big");
commitFile("src/big.ts", 450);
assert.match(await commitSliceReport(dir) ?? "", /450 authored changed lines in 1 commit on feat\/big since main\b.*Past the ~400-line slice: apply the feature's delivery strategy before the next commit/);
});
// R1 review advisory: the report ran git synchronously in the parent's
// tool_result handler, so the interface froze for as long as git took.
test("the parent keeps running while git computes the report", async (t) => {
const { dir, git, commitFile } = repo(t);
git("switch", "-q", "-c", "feat/async");
commitFile("src/a.ts", 10);
let ran = false;
setImmediate(() => { ran = true; });
const report = await commitSliceReport(dir);
assert.equal(ran, true, "the event loop ran while git answered");
assert.match(report ?? "", /^Commit slice: 10 authored changed lines in 1 commit on feat\/async/);
});
// R1 review advisory: a failed or timed-out `git diff` read as an empty diff
// and reported "0 authored changed lines"; no number beats a wrong one.
test("a failed diff reports nothing instead of zero lines", async () => {
const answers: Record = {
"symbolic-ref -q --short HEAD": "feat/slice",
"symbolic-ref -q --short refs/remotes/origin/HEAD": "origin/main",
"merge-base HEAD origin/main": "base",
"rev-list --count base..HEAD": "2",
"diff --numstat -z -M base HEAD": "12\t3\tsrc/a.ts\0",
};
const git = async (args: string[]) => answers[args.join(" ")];
assert.match(await commitSliceReport("/repo", git) ?? "", /^Commit slice: 15 authored changed lines in 2 commits/);
delete answers["diff --numstat -z -M base HEAD"];
assert.equal(await commitSliceReport("/repo", git), undefined);
});
// R1 review advisory: a background job's result arrives when the job starts,
// before its commit exists, so its slice line would describe the old HEAD.
test("only a foreground bash commit carries the slice line", async () => {
const git = async (args: string[]) => ({
"symbolic-ref -q --short HEAD": "feat/slice",
"symbolic-ref -q --short refs/remotes/origin/HEAD": "origin/main",
"merge-base HEAD origin/main": "base",
"rev-list --count base..HEAD": "1",
"diff --numstat -z -M base HEAD": "5\t0\tsrc/a.ts\0",
} as Record)[args.join(" ")];
const event = (toolName: string) => ({ toolName, input: { command: "git commit -m x" }, isError: false, content: [{ type: "text" as const, text: "ok" }], structuredContent: undefined });
assert.match(JSON.stringify((await commitSliceToolResult(event("bash"), "/repo", git))?.content), /Commit slice: 5 authored changed lines/);
assert.equal(await commitSliceToolResult(event("bash_background"), "/repo", git), undefined);
assert.equal(await commitSliceToolResult(event("monitor"), "/repo", git), undefined);
});
test("commits on the default branch, outside Git, or with nothing ahead report nothing", async (t) => {
const { dir, git, commitFile } = repo(t);
commitFile("src/direct.ts", 10);
assert.equal(await commitSliceReport(dir), undefined, "the default branch has no slice");
git("switch", "-q", "-c", "feat/empty");
assert.equal(await commitSliceReport(dir), undefined, "nothing ahead of the default branch");
const plain = realpathSync(mkdtempSync(join(tmpdir(), "commit-slice-plain-")));
t.after(() => rmSync(plain, { recursive: true, force: true }));
assert.equal(await commitSliceReport(plain), undefined, "outside Git");
});