/** * @file The Changes panel's JetBrains-style commit against a REAL repo: * `commit`/`amend` with `paths` take exactly the ticked files' working-tree * content (an untracked one included) and leave every other change — staged * or not — where it was; a failed commit puts a ticked untracked file back * out of the index. And `ignore` (Add to .gitignore): an anchored, escaped * line in the workspace's own .gitignore, only for an untracked path, never * twice, never through a symlink. Then the hunk-level commit (`hunks`: only * the ticked change blocks, through a temporary index, the real index synced * afterwards and the working tree untouched), `blameFile` (Annotate) and a * file's history with its parents rewritten so the graph joins up. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, beforeEach } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { execFileSync } = require("node:child_process"); const { runGitAction } = require("../lib/git-ops"); const { blameFile, listCommits } = require("../lib/git-diff"); const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-commit-paths-")); const REPO = path.join(ROOT, "repo"); const g = (...args) => { const env = { ...process.env }; delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; return execFileSync("git", args, { cwd: REPO, encoding: "utf8", env }); }; const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body); const read = (file) => fs.readFileSync(path.join(REPO, file), "utf8"); const status = () => g("status", "--porcelain=v1", "--untracked-files=all"); const inHead = () => g("ls-tree", "-r", "--name-only", "HEAD").split("\n").filter(Boolean); beforeEach(() => { fs.rmSync(REPO, { recursive: true, force: true }); fs.mkdirSync(REPO, { recursive: true }); g("init", "-q", "-b", "main"); g("config", "user.email", "t@h"); g("config", "user.name", "t"); write("a.txt", "a\n"); write("b.txt", "b\n"); write("c.txt", "c\n"); g("add", "-A"); g("commit", "-qm", "init"); }); describe("commit with paths", () => { it("commits only the ticked files, untracked included, and leaves the rest", async () => { write("a.txt", "a2\n"); // ticked, unstaged write("b.txt", "b2\n"); g("add", "b.txt"); // staged but NOT ticked: must stay staged, uncommitted write("c.txt", "c2\n"); // not ticked write("new.txt", "n\n"); // ticked, untracked await runGitAction(REPO, "commit", { message: "pick", paths: ["a.txt", "new.txt"] }); assert.equal(g("log", "-1", "--format=%s").trim(), "pick"); assert.deepEqual( g("show", "--name-only", "--format=", "HEAD").split("\n").filter(Boolean).sort(), ["a.txt", "new.txt"] ); assert.equal(g("show", "HEAD:a.txt"), "a2\n"); assert.match(status(), /^M {2}b\.txt$/m); assert.match(status(), /^ M c\.txt$/m); }); it("commits a partly staged file's working-tree content, a deletion too", async () => { write("a.txt", "staged\n"); g("add", "a.txt"); write("a.txt", "staged\nand more\n"); fs.rmSync(path.join(REPO, "b.txt")); await runGitAction(REPO, "commit", { message: "m", paths: ["a.txt", "b.txt"] }); assert.equal(g("show", "HEAD:a.txt"), "staged\nand more\n"); assert.ok(!inHead().includes("b.txt")); assert.equal(status(), ""); }); it("refuses an empty list and a path git does not report changed", async () => { write("a.txt", "a2\n"); await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: [] }), { code: "EBADPATH", }); await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: ["b.txt"] }), { code: "EBADPATH", }); }); it("puts a ticked untracked file back out of the index when the commit fails", async () => { write("new.txt", "n\n"); const hook = path.join(REPO, ".git", "hooks", "pre-commit"); fs.writeFileSync(hook, "#!/bin/sh\nexit 1\n", { mode: 0o755 }); await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: ["new.txt"] })); assert.match(status(), /^\?\? new\.txt$/m); }); }); describe("amend with paths", () => { it("an empty list rewords only, leaving the staged change out", async () => { write("a.txt", "a2\n"); g("add", "a.txt"); const head = g("rev-parse", "HEAD").trim(); await runGitAction(REPO, "amend", { message: "reworded", sha: head, paths: [] }); assert.equal(g("log", "-1", "--format=%s").trim(), "reworded"); assert.equal(g("show", "HEAD:a.txt"), "a\n"); assert.match(status(), /^M {2}a\.txt$/m); }); it("folds the ticked files in, and refuses an empty list with no message", async () => { write("new.txt", "n\n"); const head = g("rev-parse", "HEAD").trim(); await assert.rejects(runGitAction(REPO, "amend", { sha: head, paths: [] }), { code: "ENOCHANGES", }); await runGitAction(REPO, "amend", { sha: head, paths: ["new.txt"] }); assert.equal(g("log", "-1", "--format=%s").trim(), "init"); assert.ok(inHead().includes("new.txt")); assert.equal(g("rev-list", "--count", "HEAD").trim(), "1"); }); }); describe("ignore", () => { it("appends an anchored, escaped line once, keeping the file's last line", async () => { write(".gitignore", "node_modules"); write("debug*.log", "x\n"); write("note.md", "x\n"); await runGitAction(REPO, "ignore", { paths: ["debug*.log", "note.md"] }); assert.equal(read(".gitignore"), "node_modules\n/debug\\*.log\n/note.md\n"); assert.doesNotMatch(status(), /debug|note/); }); it("creates the file when there is none, in a subdirectory too", async () => { fs.mkdirSync(path.join(REPO, "dir")); write("dir/x y.tmp", "x\n"); await runGitAction(REPO, "ignore", { path: "dir/x y.tmp" }); assert.equal(read(".gitignore"), "/dir/x y.tmp\n"); assert.doesNotMatch(status(), /x y\.tmp/); }); it("refuses a tracked file and a .gitignore that is a symlink", async () => { write("a.txt", "a2\n"); await assert.rejects(runGitAction(REPO, "ignore", { paths: ["a.txt"] }), { code: "ETRACKED" }); write("new.txt", "n\n"); const outside = path.join(ROOT, "outside-ignore"); fs.writeFileSync(outside, ""); fs.symlinkSync(outside, path.join(REPO, ".gitignore")); await assert.rejects(runGitAction(REPO, "ignore", { paths: ["new.txt"] }), { code: "EBADPATH", }); assert.equal(fs.readFileSync(outside, "utf8"), ""); }); }); /** The `@@` lines of `file`'s HEAD → working tree diff at -U3, in order. */ const headers = (file) => g("diff", "-U3", "HEAD", "--", file) .split("\n") .filter((l) => l.startsWith("@@ ")); describe("commit with hunks", () => { beforeEach(() => { write("long.txt", `${Array.from({ length: 30 }, (_, i) => i + 1).join("\n")}\n`); g("add", "long.txt"); g("commit", "-qm", "long"); }); const edit = (pairs) => { let text = read("long.txt"); for (const [from, to] of pairs) text = text.replace(new RegExp(`^${from}$`, "m"), to); write("long.txt", text); }; it("commits only the ticked hunk, leaving the rest in the working tree, unstaged", async () => { edit([ ["3", "three"], ["25", "twentyfive"], ]); const [first] = headers("long.txt"); await runGitAction(REPO, "commit", { message: "part", context: 3, hunks: { "long.txt": [{ hunk: first, blocks: [0] }] }, }); const committed = g("show", "HEAD:long.txt"); assert.match(committed, /^three$/m); assert.match(committed, /^25$/m); assert.match(read("long.txt"), /^twentyfive$/m); // The index followed the commit: the rest is an unstaged change only. assert.match(status(), /^ M long\.txt$/m); }); it("takes one block of a hunk that holds two, with whole files beside it", async () => { edit([ ["3", "three"], ["5", "five"], ]); write("a.txt", "a2\n"); const [only] = headers("long.txt"); await runGitAction(REPO, "commit", { message: "block", context: 3, paths: ["a.txt"], hunks: { "long.txt": [{ hunk: only, blocks: [1] }] }, }); const committed = g("show", "HEAD:long.txt"); assert.match(committed, /^3$/m); assert.match(committed, /^five$/m); assert.equal(g("show", "HEAD:a.txt"), "a2\n"); assert.match(status(), /^ M long\.txt$/m); assert.doesNotMatch(status(), /a\.txt/); }); it("refuses a hunk no longer in the diff, an untracked file, and leaves the index alone", async () => { edit([["3", "three"]]); write("new.txt", "n\n"); g("add", "b.txt"); await assert.rejects( runGitAction(REPO, "commit", { message: "m", hunks: { "long.txt": [{ hunk: "@@ -1,1 +1,1 @@", blocks: [0] }] }, }), { code: "EHUNKGONE" } ); await assert.rejects( runGitAction(REPO, "commit", { message: "m", hunks: { "new.txt": [{ hunk: "@@ -0,0 +1 @@", blocks: [0] }] }, }), { code: "EUNTRACKED" } ); await assert.rejects( runGitAction(REPO, "commit", { message: "m", hunks: { "long.txt": [{ hunk: headers("long.txt")[0], blocks: ["x"] }] }, }), { code: "EBADPATH" } ); assert.equal(g("rev-list", "--count", "HEAD").trim(), "2"); assert.match(status(), /^ M long\.txt$/m); }); it("amends with only the ticked hunk", async () => { edit([ ["3", "three"], ["25", "twentyfive"], ]); const head = g("rev-parse", "HEAD").trim(); const [, second] = headers("long.txt"); await runGitAction(REPO, "amend", { sha: head, context: 3, paths: [], hunks: { "long.txt": [{ hunk: second, blocks: [0] }] }, }); assert.equal(g("log", "-1", "--format=%s").trim(), "long"); assert.equal(g("rev-list", "--count", "HEAD").trim(), "2"); const committed = g("show", "HEAD:long.txt"); assert.match(committed, /^twentyfive$/m); assert.match(committed, /^3$/m); }); }); describe("blameFile", () => { it("names each line's commit, and none for a line not committed yet", async () => { write("a.txt", "a\nnew line\n"); const blame = await blameFile(REPO, "a.txt"); assert.equal(blame.lines.length, 2); const first = blame.commits[blame.lines[0]]; assert.equal(first.summary, "init"); assert.equal(first.author, "t"); assert.equal(blame.lines[1], null); }); it("refuses a path outside the lane and a secret file", async () => { await assert.rejects(blameFile(REPO, "../x"), { code: "EBADPATH" }); fs.mkdirSync(path.join(REPO, ".claude")); write(".claude/settings.local.json", "{}\n"); g("add", "-f", ".claude/settings.local.json"); g("commit", "-qm", "secret"); await assert.rejects(blameFile(REPO, ".claude/settings.local.json"), { code: "ESECRET" }); }); }); describe("a file's history", () => { it("rewrites parents to the file's own commits, so the graph joins up", async () => { write("a.txt", "a2\n"); g("commit", "-qam", "a2"); write("b.txt", "b2\n"); g("commit", "-qam", "b only"); write("a.txt", "a3\n"); g("commit", "-qam", "a3"); const commits = await listCommits(REPO, { file: "a.txt" }); assert.deepEqual( commits.map((c) => c.subject), ["a3", "a2", "init"] ); assert.deepEqual(commits[0].parents, [commits[1].sha]); }); });