/** * @file Tests for server/lib/git-diff.js and server/lib/git-ops.js against a * REAL git fixture (a bare origin, a clone, a second clone that pushes behind * the first one's back). Diff parsing, --ff-only's refusal, the dirty-tree * guard on `switch` and the one-worktree-per-branch rule are all git's own * behavior — a mocked git would only test our idea of it. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, 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 { collectChanges, listCommits, listPaths, listTree, listTreeAll, parseUnifiedDiff, readFileAt, } = require("../lib/git-diff"); const { runGitAction, validateRemoteUrl } = require("../lib/git-ops"); const { gitFacts } = require("../lib/worktree"); const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-gitdiff-")); const ORIGIN = path.join(ROOT, "origin.git"); const LANE = path.join(ROOT, "lane"); const OTHER = path.join(ROOT, "other"); const g = (cwd, ...args) => { const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" }; delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; return execFileSync("git", args, { cwd, encoding: "utf8", env }); }; const gc = (cwd, ...args) => g(cwd, "-c", "user.email=t@h", "-c", "user.name=t", ...args); /** `git status --porcelain` as lines — the XY columns matter, so never trim. */ function porcelain(dir) { return g(dir, "status", "--porcelain").split("\n").filter(Boolean); } function write(dir, file, body) { fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); fs.writeFileSync(path.join(dir, file), body); } function freshFixture() { fs.rmSync(ROOT, { recursive: true, force: true }); fs.mkdirSync(ROOT, { recursive: true }); g(ROOT, "init", "-q", "--bare", ORIGIN); const seed = path.join(ROOT, "seed"); g(ROOT, "init", "-q", "-b", "development", seed); write(seed, "a.txt", "one\ntwo\nthree\n"); write(seed, "keep.txt", "unchanged\n"); gc(seed, "add", "-A"); gc(seed, "commit", "-qm", "init"); gc(seed, "remote", "add", "origin", ORIGIN); gc(seed, "push", "-q", "origin", "development"); g(ORIGIN, "symbolic-ref", "HEAD", "refs/heads/development"); g(ROOT, "clone", "-q", ORIGIN, LANE); g(ROOT, "clone", "-q", ORIGIN, OTHER); // runGitAction's `commit` uses the repo's own identity (it never injects // one), so the fixture sets a local one — a CI box may have no global user. for (const dir of [LANE, OTHER]) { g(dir, "config", "user.email", "t@h"); g(dir, "config", "user.name", "t"); } gc(LANE, "checkout", "-qb", "feat/thing"); } before(freshFixture); describe("parseUnifiedDiff", () => { it("resolves both gutters across a hunk", () => { const hunks = parseUnifiedDiff( "@@ -10,3 +10,4 @@ function f()\n ctx\n-gone\n+new1\n+new2\n ctx2\n" ); assert.equal(hunks.length, 1); assert.equal(hunks[0].context, "function f()"); assert.deepEqual( hunks[0].lines.map((l) => [l.type, l.oldLine, l.newLine, l.text]), [ ["ctx", 10, 10, "ctx"], ["del", 11, null, "gone"], ["add", null, 11, "new1"], ["add", null, 12, "new2"], ["ctx", 12, 13, "ctx2"], ] ); }); it("ignores the preamble and the no-newline marker", () => { const hunks = parseUnifiedDiff( "diff --git a/x b/x\nindex 1..2 100644\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-a\n+b\n\\ No newline at end of file\n" ); assert.equal(hunks.length, 1); assert.equal(hunks[0].lines.length, 2); }); it("returns no hunks for an empty patch", () => { assert.deepEqual(parseUnifiedDiff(""), []); }); }); describe("collectChanges — work range", () => { beforeEach(freshFixture); it("summarizes tracked edits, deletions and untracked files without hunks", async () => { write(LANE, "a.txt", "one\nTWO\nthree\nfour\n"); fs.rmSync(path.join(LANE, "keep.txt")); write(LANE, "new.txt", "fresh\nlines\n"); const out = await collectChanges(LANE, { range: "work" }); assert.equal(out.range, "work"); assert.equal(out.truncated, false); const byPath = Object.fromEntries(out.files.map((f) => [f.path, f])); assert.deepEqual(Object.keys(byPath).sort(), ["a.txt", "keep.txt", "new.txt"]); assert.equal(byPath["a.txt"].added, 2); assert.equal(byPath["a.txt"].deleted, 1); assert.equal(byPath["keep.txt"].deleted, 1); assert.equal(byPath["new.txt"].untracked, true); assert.equal(byPath["new.txt"].added, 2); // Overview mode is a summary: no file carries hunks. assert.ok(out.files.every((f) => f.hunks === undefined)); }); it("lists each file inside a wholly untracked folder, not the folder (#64)", async () => { write(LANE, "docs/report.md", "# report\n"); write(LANE, "docs/deep/notes.txt", "n\n"); const out = await collectChanges(LANE, { range: "work" }); // `--untracked-files=normal` folded these into one "docs/" row that // opened nothing; the panel needs the files, like JetBrains and VS Code. assert.deepEqual(out.files.map((f) => f.path).sort(), ["docs/deep/notes.txt", "docs/report.md"]); assert.ok(out.files.every((f) => f.untracked && f.added === 1)); assert.equal((await gitFacts(LANE)).untracked, 2); }); // #72: a change staged and then undone in the working tree is `MM` in // `git status` while the file on disk equals HEAD — JetBrains' Changes view // (GitChangesCollector) drops it after a `git diff HEAD`, and so does this. it("leaves out a staged change the working tree has reverted (#72)", async () => { write(LANE, "a.txt", "one\nTWO\nthree\n"); g(LANE, "add", "a.txt"); write(LANE, "a.txt", "one\ntwo\nthree\n"); // back to HEAD's content write(LANE, "keep.txt", "staged\n"); g(LANE, "add", "keep.txt"); write(LANE, "keep.txt", "staged, then edited\n"); // MM and still a change assert.deepEqual(porcelain(LANE).sort(), ["MM a.txt", "MM keep.txt"]); const out = await collectChanges(LANE, { range: "work" }); assert.deepEqual( out.files.map((f) => f.path), ["keep.txt"] ); }); it("still lists a file whose only change is its mode, as JetBrains does", async () => { fs.chmodSync(path.join(LANE, "a.txt"), 0o755); const out = await collectChanges(LANE, { range: "work" }); assert.deepEqual( out.files.map((f) => [f.path, f.added, f.deleted]), [["a.txt", 0, 0]] ); }); it("returns hunks for every file in full mode", async () => { write(LANE, "a.txt", "one\nTWO\nthree\n"); const out = await collectChanges(LANE, { range: "work", full: true }); assert.equal(out.files.length, 1); assert.equal(out.files[0].hunks.length, 1); const kinds = out.files[0].hunks[0].lines.map((l) => l.type); assert.ok(kinds.includes("del") && kinds.includes("add")); }); it("synthesizes an all-added patch for an untracked file", async () => { write(LANE, "new.txt", "alpha\nbeta\n"); const out = await collectChanges(LANE, { range: "work", file: "new.txt" }); assert.equal(out.files.length, 1); const lines = out.files[0].hunks[0].lines; assert.deepEqual( lines.map((l) => [l.type, l.newLine, l.text]), [ ["add", 1, "alpha"], ["add", 2, "beta"], ] ); }); it("rejects a path with no changes in the range", async () => { await assert.rejects(() => collectChanges(LANE, { range: "work", file: "keep.txt" }), { code: "ENOFILECHANGE", }); }); it("still returns content when a huge file has no newline to cut on", async () => { // A minified bundle is ONE line. Cutting the patch at the last newline // inside the cap would land at index -1 here and hand back an empty // patch — a file changed by a megabyte rendering as "no textual change". const { MAX_FILE_BYTES } = require("../lib/git-diff"); write(LANE, "min.js", `${"x".repeat(MAX_FILE_BYTES + 5000)}\n`); const out = await collectChanges(LANE, { range: "work", file: "min.js" }); const entry = out.files[0]; assert.equal(entry.truncated, true); assert.equal(out.truncated, true); assert.ok(entry.hunks.length > 0, "a truncated patch still carries its hunk"); assert.ok(entry.hunks[0].lines[0].text.startsWith("xxx")); }); it("reports a binary file without trying to diff it", async () => { fs.writeFileSync(path.join(LANE, "blob.bin"), Buffer.from([0, 1, 2, 0, 3])); const out = await collectChanges(LANE, { range: "work", full: true }); const blob = out.files.find((f) => f.path === "blob.bin"); assert.equal(blob.binary, true); assert.deepEqual(blob.hunks, []); }); }); describe("collectChanges — branch range", () => { beforeEach(freshFixture); it("diffs the branch against its base, ignoring uncommitted work", async () => { write(LANE, "a.txt", "one\ntwo\nthree\nfour\n"); gc(LANE, "add", "-A"); gc(LANE, "commit", "-qm", "add four"); write(LANE, "scratch.txt", "not committed\n"); const out = await collectChanges(LANE, { range: "branch", base: "development", full: true }); assert.equal(out.baseRef, "origin/development"); assert.equal(out.mergeBase, g(LANE, "merge-base", "origin/development", "HEAD").trim()); assert.deepEqual( out.files.map((f) => f.path), ["a.txt"] ); assert.equal(out.files[0].added, 1); }); it("falls back to the default branch without a base, and refuses a base that is gone", async () => { // No recorded base (an adopted folder): what origin/HEAD names. assert.equal( (await collectChanges(LANE, { range: "branch", base: null })).base, "development" ); await assert.rejects(() => collectChanges(LANE, { range: "branch", base: "nope" }), { code: "ENOBASE", }); }); }); describe("gitFacts tracking counts", () => { beforeEach(freshFixture); it("reports null ahead/behind for a branch with no upstream", async () => { const facts = await gitFacts(LANE); assert.equal(facts.branch, "feat/thing"); assert.equal(facts.hasRemote, true); assert.equal(facts.upstream, null); assert.equal(facts.ahead, null); }); it("counts both sides once an upstream exists", async () => { gc(LANE, "checkout", "-q", "development"); write(LANE, "a.txt", "one\ntwo\nthree\nlocal\n"); gc(LANE, "add", "-A"); gc(LANE, "commit", "-qm", "local"); write(OTHER, "b.txt", "remote\n"); gc(OTHER, "add", "-A"); gc(OTHER, "commit", "-qm", "remote"); gc(OTHER, "push", "-q", "origin", "development"); g(LANE, "fetch", "-q", "origin"); const facts = await gitFacts(LANE); assert.equal(facts.upstream, "origin/development"); assert.equal(facts.ahead, 1); assert.equal(facts.behind, 1); }); }); describe("runGitAction", () => { beforeEach(freshFixture); it("rejects an unknown action", async () => { await assert.rejects(() => runGitAction(LANE, "rebase"), { code: "EBADACTION" }); }); it("commits everything with the given message", async () => { write(LANE, "a.txt", "changed\n"); write(LANE, "added.txt", "new\n"); const result = await runGitAction(LANE, "commit", { message: "feat: both files" }); assert.equal(result.ok, true); assert.equal(g(LANE, "log", "-1", "--format=%s").trim(), "feat: both files"); assert.equal(g(LANE, "status", "--porcelain").trim(), ""); }); it("refuses a commit with no message and one with nothing to commit", async () => { await assert.rejects(() => runGitAction(LANE, "commit", { message: " " }), { code: "EBADMESSAGE", }); await assert.rejects(() => runGitAction(LANE, "commit", { message: "empty" }), { code: "ENOCHANGES", }); }); it("pulls fast-forward only and refuses a diverged branch", async () => { gc(LANE, "checkout", "-q", "development"); write(OTHER, "b.txt", "remote\n"); gc(OTHER, "add", "-A"); gc(OTHER, "commit", "-qm", "remote"); gc(OTHER, "push", "-q", "origin", "development"); const ff = await runGitAction(LANE, "pull"); assert.equal(ff.ok, true); assert.ok(fs.existsSync(path.join(LANE, "b.txt"))); // Diverge: a local commit here, a different one pushed there. write(LANE, "c.txt", "local\n"); gc(LANE, "add", "-A"); gc(LANE, "commit", "-qm", "local"); write(OTHER, "d.txt", "remote2\n"); gc(OTHER, "add", "-A"); gc(OTHER, "commit", "-qm", "remote2"); gc(OTHER, "push", "-q", "origin", "development"); await assert.rejects(() => runGitAction(LANE, "pull")); // The refusal left no merge in progress. assert.ok(!fs.existsSync(path.join(LANE, ".git", "MERGE_HEAD"))); }); it("pushes the current branch and sets its upstream", async () => { write(LANE, "a.txt", "changed\n"); await runGitAction(LANE, "commit", { message: "wip" }); const result = await runGitAction(LANE, "push"); assert.equal(result.ok, true); assert.equal( g(LANE, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").trim(), "origin/feat/thing" ); }); it("switches branches only with a clean tree", async () => { write(LANE, "a.txt", "dirty\n"); await assert.rejects(() => runGitAction(LANE, "switch", { branch: "development" }), { code: "EDIRTY", }); gc(LANE, "checkout", "-q", "--", "a.txt"); const result = await runGitAction(LANE, "switch", { branch: "development" }); assert.equal(result.branch, "development"); assert.equal(g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim(), "development"); }); it("creates a branch only when it does not already exist", async () => { await runGitAction(LANE, "switch", { branch: "feat/second", create: true }); assert.equal(g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim(), "feat/second"); await assert.rejects( () => runGitAction(LANE, "switch", { branch: "feat/thing", create: true }), { code: "EBRANCHEXISTS", } ); await assert.rejects(() => runGitAction(LANE, "switch", { branch: "nope/missing" }), { code: "ENOBRANCH", }); await assert.rejects(() => runGitAction(LANE, "switch", { branch: "..bad" }), { code: "EBADBRANCH", }); }); it("refuses a branch another worktree holds", async () => { const wt = path.join(ROOT, "sibling"); g(LANE, "worktree", "add", "-q", wt, "development"); await assert.rejects(() => runGitAction(LANE, "switch", { branch: "development" }), { code: "EBRANCHBUSY", }); }); }); describe("collectChanges — commit range", () => { beforeEach(freshFixture); it("reads one commit's files and hunks", async () => { write(LANE, "a.txt", "one\nTWO\nthree\n"); gc(LANE, "commit", "-qam", "tweak a"); const sha = g(LANE, "rev-parse", "HEAD").trim(); const out = await collectChanges(LANE, { range: "commit", sha, full: true }); assert.equal(out.sha, sha); assert.deepEqual( out.files.map((f) => f.path), ["a.txt"] ); assert.ok(out.files[0].hunks.some((h) => h.lines.some((l) => l.type === "add"))); }); it("rejects a sha that is not a commit here", async () => { await assert.rejects(() => collectChanges(LANE, { range: "commit", sha: "nope" }), { code: "EBADREV", }); await assert.rejects(() => collectChanges(LANE, { range: "commit", sha: "a b;rm" }), { code: "EBADREV", }); }); it("widens a hunk when context is raised", async () => { write(LANE, "long.txt", Array.from({ length: 40 }, (_, i) => `line${i}`).join("\n") + "\n"); gc(LANE, "add", "-A"); gc(LANE, "commit", "-qm", "long"); write( LANE, "long.txt", Array.from({ length: 40 }, (_, i) => (i === 20 ? "CHANGED" : `line${i}`)).join("\n") + "\n" ); const narrow = await collectChanges(LANE, { range: "work", file: "long.txt" }); const wide = await collectChanges(LANE, { range: "work", file: "long.txt", context: 20 }); const count = (o) => o.files[0].hunks.reduce((n, h) => n + h.lines.length, 0); assert.equal(narrow.context, 3); assert.equal(wide.context, 20); assert.ok(count(wide) > count(narrow)); }); }); describe("listCommits / readFileAt / listPaths / listTree", () => { beforeEach(freshFixture); it("lists commits, newest first, and narrows to one file", async () => { write(LANE, "a.txt", "two\n"); gc(LANE, "commit", "-qam", "touch a"); write(LANE, "keep.txt", "edited\n"); gc(LANE, "commit", "-qam", "touch keep"); const all = await listCommits(LANE, { limit: 10 }); assert.deepEqual( all.map((c) => c.subject), ["touch keep", "touch a", "init"] ); assert.ok(all[0].refs.includes("HEAD -> feat/thing")); assert.deepEqual(all[0].parents, [all[1].sha]); assert.deepEqual(all[2].parents, []); const forA = await listCommits(LANE, { file: "a.txt" }); assert.deepEqual( forA.map((c) => c.subject), ["touch a", "init"] ); }); it("reads a file at work and at a revision, and refuses to escape the lane", async () => { write(LANE, "a.txt", "one\nEDITED\n"); const work = await readFileAt(LANE, "a.txt", "work"); assert.deepEqual(work.lines, ["one", "EDITED"]); const head = await readFileAt(LANE, "a.txt", "HEAD"); assert.deepEqual(head.lines, ["one", "two", "three"]); await assert.rejects(() => readFileAt(LANE, "../../etc/passwd", "work"), { code: "EBADPATH" }); await assert.rejects(() => readFileAt(LANE, "/etc/passwd", "work"), { code: "EBADPATH" }); await assert.rejects(() => readFileAt(LANE, "a.txt", "no such rev"), { code: "EBADREV" }); await assert.rejects(() => readFileAt(LANE, "ghost.txt", "work"), { code: "ENOFILE" }); }); it("quick-opens by substring, basename hits first, ignoring untracked files", async () => { write(LANE, "src/api.ts", "x\n"); write(LANE, "src/api/helper.ts", "x\n"); gc(LANE, "add", "-A"); gc(LANE, "commit", "-qm", "src"); write(LANE, "untracked-api.ts", "x\n"); const out = await listPaths(LANE, { q: "api" }); assert.equal(out.paths[0], "src/api.ts"); assert.ok(out.paths.includes("src/api/helper.ts")); assert.ok(!out.paths.includes("untracked-api.ts")); }); it("folds the tree one directory level at a time", async () => { write(LANE, "src/api/helper.ts", "x\n"); write(LANE, "src/main.ts", "x\n"); gc(LANE, "add", "-A"); gc(LANE, "commit", "-qm", "src"); const root = await listTree(LANE, {}); assert.deepEqual( root.entries.map((e) => [e.name, e.dir]), [ ["src", true], ["a.txt", false], ["keep.txt", false], ] ); const src = await listTree(LANE, { subdir: "src" }); assert.deepEqual( src.entries.map((e) => e.path), ["src/api", "src/main.ts"] ); await assert.rejects(() => listTree(LANE, { subdir: "../.." }), { code: "EBADPATH" }); }); it("lists untracked-but-not-ignored files and marks what changed, folders included", async () => { write(LANE, "src/main.ts", "m\n"); write(LANE, ".gitignore", "*.log\nvendor/\n"); gc(LANE, "add", "-A"); gc(LANE, "commit", "-qm", "src"); write(LANE, "a.txt", "modified\n"); write(LANE, "new.txt", "fresh\n"); write(LANE, "gen/deep/out.txt", "wholly untracked folder\n"); write(LANE, "noise.log", "ignored\n"); write(LANE, "vendor/lib/x.js", "ignored folder\n"); const root = await listTree(LANE, {}); const by = Object.fromEntries(root.entries.map((e) => [e.name, e])); assert.equal(by["a.txt"].status, " M"); assert.equal(by["new.txt"].status, "??"); assert.equal(by["keep.txt"].status, undefined); assert.equal(by["noise.log"].ignored, true, "an ignored file is listed, marked"); assert.equal(by.vendor.ignored, true); assert.equal(by.vendor.dir, true); assert.equal(by[".git"], undefined, "git's own dir is never listed"); assert.equal(by["a.txt"].ignored, undefined); assert.equal(by.src.ignored, undefined); // Inside an ignored folder, which `ls-files --ignored` cannot list. const lib = await listTree(LANE, { subdir: "vendor/lib" }); assert.deepEqual( lib.entries.map((e) => [e.name, e.ignored]), [["x.js", true]] ); assert.equal(by.gen.changed, true); assert.equal(by.src.changed, undefined); // Inside a folder git reports only as "gen/": still untracked, still marked. const deep = await listTree(LANE, { subdir: "gen/deep" }); assert.equal(deep.entries[0].status, "??"); const all = await listTreeAll(LANE); assert.equal(all.levels["gen"][0].changed, true); assert.deepEqual(all.levels[""], root.entries); }); it("reads every level of the tree in one pass, grouped like listTree", async () => { write(LANE, "src/api/x.ts", "x\n"); write(LANE, "src/main.ts", "m\n"); gc(LANE, "add", "-A"); gc(LANE, "commit", "-qm", "src"); const all = await listTreeAll(LANE); assert.equal(all.truncated, false); // Each level equals what the one-level read returns for it. for (const level of ["", "src", "src/api"]) { const one = await listTree(LANE, { subdir: level }); assert.deepEqual(all.levels[level], one.entries, level); } assert.deepEqual(Object.keys(all.levels).sort(), ["", "src", "src/api"]); }); }); describe("runGitAction — per-file staging, discard and branches", () => { beforeEach(freshFixture); it("stages and unstages one file, leaving the other alone", async () => { write(LANE, "a.txt", "edited\n"); write(LANE, "keep.txt", "also edited\n"); await runGitAction(LANE, "stage", { paths: ["a.txt"] }); assert.deepEqual(porcelain(LANE), ["M a.txt", " M keep.txt"]); await runGitAction(LANE, "unstage", { path: "a.txt" }); assert.deepEqual(porcelain(LANE), [" M a.txt", " M keep.txt"]); }); it("refuses a path git is not reporting as changed", async () => { await assert.rejects(() => runGitAction(LANE, "stage", { paths: ["keep.txt"] }), { code: "EBADPATH", }); await assert.rejects(() => runGitAction(LANE, "stage", { paths: ["../../etc/passwd"] }), { code: "EBADPATH", }); await assert.rejects(() => runGitAction(LANE, "stage", {}), { code: "EBADPATH" }); }); it("commits only the index when asked", async () => { write(LANE, "a.txt", "staged\n"); write(LANE, "keep.txt", "left behind\n"); await runGitAction(LANE, "stage", { paths: ["a.txt"] }); const out = await runGitAction(LANE, "commit", { message: "only a", only: "staged" }); assert.equal(out.ok, true); assert.deepEqual(porcelain(LANE), [" M keep.txt"]); }); it("refuses a staged-only commit with an empty index", async () => { write(LANE, "a.txt", "unstaged\n"); await assert.rejects(() => runGitAction(LANE, "commit", { message: "nope", only: "staged" }), { code: "ENOCHANGES", }); }); it("discards a tracked file and deletes an untracked one, but only on confirm", async () => { write(LANE, "a.txt", "edited\n"); write(LANE, "junk.txt", "throwaway\n"); await assert.rejects(() => runGitAction(LANE, "discard", { paths: ["a.txt"] }), { code: "ECONFIRM", }); assert.equal(fs.readFileSync(path.join(LANE, "a.txt"), "utf8"), "edited\n"); await runGitAction(LANE, "discard", { paths: ["a.txt", "junk.txt"], confirm: true }); assert.equal(fs.readFileSync(path.join(LANE, "a.txt"), "utf8"), "one\ntwo\nthree\n"); assert.ok(!fs.existsSync(path.join(LANE, "junk.txt"))); }); it("discards a staged edit in both gutters", async () => { write(LANE, "a.txt", "edited\n"); await runGitAction(LANE, "stage", { paths: ["a.txt"] }); await runGitAction(LANE, "discard", { paths: ["a.txt"], confirm: true }); assert.equal(g(LANE, "status", "--porcelain").trim(), ""); }); it("creates a branch without moving HEAD, and deletes it again", async () => { const before = g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim(); await runGitAction(LANE, "branch-create", { branch: "feat/sibling" }); assert.equal(g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim(), before); assert.ok(g(LANE, "branch", "--list", "feat/sibling").trim()); await assert.rejects(() => runGitAction(LANE, "branch-create", { branch: "feat/sibling" }), { code: "EBRANCHEXISTS", }); await runGitAction(LANE, "branch-delete", { branch: "feat/sibling" }); assert.equal(g(LANE, "branch", "--list", "feat/sibling").trim(), ""); }); it("refuses to delete a protected, current, missing or busy branch", async () => { await assert.rejects(() => runGitAction(LANE, "branch-delete", { branch: "development" }), { code: "EPROTECTED", }); await assert.rejects(() => runGitAction(LANE, "branch-delete", { branch: "feat/thing" }), { code: "EBRANCHBUSY", }); await assert.rejects(() => runGitAction(LANE, "branch-delete", { branch: "nope" }), { code: "ENOBRANCH", }); await runGitAction(LANE, "branch-create", { branch: "feat/held" }); g(LANE, "worktree", "add", "-q", path.join(ROOT, "held"), "feat/held"); await assert.rejects(() => runGitAction(LANE, "branch-delete", { branch: "feat/held" }), { code: "EBRANCHBUSY", }); }); }); describe("runGitAction — one hunk at a time", () => { beforeEach(freshFixture); /** A 20-line file edited at both ends: two hunks at the default context. */ function twoHunks() { const lines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`); write(LANE, "big.txt", lines.join("\n") + "\n"); gc(LANE, "add", "big.txt"); gc(LANE, "commit", "-qm", "big"); lines[0] = "FIRST"; lines[19] = "LAST"; write(LANE, "big.txt", lines.join("\n") + "\n"); } const headers = async () => (await collectChanges(LANE, { range: "work", file: "big.txt" })).files[0].hunks.map( (h) => h.header ); it("stages ONE hunk, leaving the other in the working tree", async () => { twoHunks(); const [first] = await headers(); await runGitAction(LANE, "stage-hunk", { path: "big.txt", hunk: first }); const staged = g(LANE, "diff", "--cached", "--", "big.txt"); const unstaged = g(LANE, "diff", "--", "big.txt"); assert.match(staged, /\+FIRST/); assert.doesNotMatch(staged, /LAST/); assert.match(unstaged, /\+LAST/); assert.doesNotMatch(unstaged, /FIRST/); }); it("undoes ONE hunk in the working tree, only with confirm", async () => { twoHunks(); const [, second] = await headers(); await assert.rejects( () => runGitAction(LANE, "discard-hunk", { path: "big.txt", hunk: second }), { code: "ECONFIRM" } ); await runGitAction(LANE, "discard-hunk", { path: "big.txt", hunk: second, confirm: true }); const text = fs.readFileSync(path.join(LANE, "big.txt"), "utf8"); assert.match(text, /^FIRST\n/); assert.match(text, /line 20\n$/); }); it("refuses a hunk that is no longer in the diff, and a path that is not changed", async () => { twoHunks(); await assert.rejects( () => runGitAction(LANE, "stage-hunk", { path: "big.txt", hunk: "@@ -1,9 +1,9 @@" }), { code: "EHUNKGONE" } ); await assert.rejects( () => runGitAction(LANE, "stage-hunk", { path: "keep.txt", hunk: "@@ -1 +1 @@" }), { code: "EBADPATH" } ); }); it("sends an untracked file to the per-file actions", async () => { write(LANE, "new.txt", "x\n"); await assert.rejects( () => runGitAction(LANE, "stage-hunk", { path: "new.txt", hunk: "@@ -0,0 +1,1 @@" }), { code: "EUNTRACKED" } ); }); }); describe("runGitAction — one change block inside a hunk (issue #50)", () => { beforeEach(freshFixture); const base = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`); /** Three change blocks close enough for git to merge into ONE hunk: lines * 4-5 replaced, a line inserted after 8, line 11 deleted. */ function threeBlocks() { write(LANE, "near.txt", base.join("\n") + "\n"); gc(LANE, "add", "near.txt"); gc(LANE, "commit", "-qm", "near"); const next = [...base]; next.splice(3, 2, "FOUR", "FIVE"); next.splice(8, 0, "INSERTED"); next.splice(11, 1); // "line 11" write(LANE, "near.txt", next.join("\n") + "\n"); return next; } const hunkOf = async () => { const [file] = (await collectChanges(LANE, { range: "work", file: "near.txt" })).files; return file.hunks; }; const read = () => fs.readFileSync(path.join(LANE, "near.txt"), "utf8").split("\n"); it("sees the three blocks as one git hunk (the shape the issue reports)", async () => { threeBlocks(); assert.equal((await hunkOf()).length, 1); }); it("stages ONE block, leaving the other two in the working tree", async () => { threeBlocks(); const [hunk] = await hunkOf(); await runGitAction(LANE, "stage-hunk", { path: "near.txt", hunk: hunk.header, block: 1 }); const staged = g(LANE, "diff", "--cached", "--", "near.txt"); assert.match(staged, /^\+INSERTED$/m); assert.doesNotMatch(staged, /FOUR|FIVE|^-line 11$/m); const unstaged = g(LANE, "diff", "--", "near.txt"); assert.match(unstaged, /^\+FOUR$/m); assert.match(unstaged, /^-line 11$/m); assert.doesNotMatch(unstaged, /^\+INSERTED$/m); // staged: only context now }); it("stages the LAST block after earlier ones shifted the line count", async () => { threeBlocks(); const [hunk] = await hunkOf(); await runGitAction(LANE, "stage-hunk", { path: "near.txt", hunk: hunk.header, block: 2 }); const staged = g(LANE, "diff", "--cached", "--", "near.txt"); assert.match(staged, /^-line 11$/m); assert.doesNotMatch(staged, /FOUR|INSERTED/); }); it("undoes ONE block in the working tree, keeping the blocks around it", async () => { const edited = threeBlocks(); const [hunk] = await hunkOf(); await runGitAction(LANE, "discard-hunk", { path: "near.txt", hunk: hunk.header, block: 0, confirm: true, }); const want = [...edited]; want.splice(3, 2, "line 4", "line 5"); assert.deepEqual(read(), [...want, ""]); }); it("splits a hunk whose last block only drops the final newline", async () => { // One hunk: line 2 replaced, and line 6 losing its final newline — git // writes that second block with a "\\ No newline" marker after its "+". write(LANE, "tail.txt", "l1\nl2\nl3\nl4\nl5\nl6\n"); gc(LANE, "add", "tail.txt"); gc(LANE, "commit", "-qm", "tail"); write(LANE, "tail.txt", "l1\nTWO\nl3\nl4\nl5\nl6"); const [file] = (await collectChanges(LANE, { range: "work", file: "tail.txt" })).files; assert.equal(file.hunks.length, 1); const header = file.hunks[0].header; await runGitAction(LANE, "stage-hunk", { path: "tail.txt", hunk: header, block: 1 }); const staged = g(LANE, "diff", "--cached", "--", "tail.txt"); assert.match(staged, /No newline at end of file/); assert.doesNotMatch(staged, /TWO/); await runGitAction(LANE, "discard-hunk", { path: "tail.txt", hunk: header, block: 0, confirm: true, }); assert.equal(fs.readFileSync(path.join(LANE, "tail.txt"), "utf8"), "l1\nl2\nl3\nl4\nl5\nl6"); }); it("refuses a block the hunk does not have", async () => { threeBlocks(); const [hunk] = await hunkOf(); for (const block of [3, -1, 1.5, "0"]) { await assert.rejects( () => runGitAction(LANE, "stage-hunk", { path: "near.txt", hunk: hunk.header, block }), { code: "EHUNKGONE" } ); } }); }); describe("an unborn repo and `publish`", () => { const EMPTY = path.join(ROOT, "empty"); const URL_OK = "https://example.test/owner/r.git"; /** A `git init`ed repo with no commit and no remote. */ function unborn() { fs.rmSync(EMPTY, { recursive: true, force: true }); g(ROOT, "init", "-q", "-b", "main", EMPTY); g(EMPTY, "config", "user.email", "t@h"); g(EMPTY, "config", "user.name", "t"); } function firstCommit() { write(EMPTY, "x.txt", "x\n"); gc(EMPTY, "add", "-A"); gc(EMPTY, "commit", "-qm", "first"); } beforeEach(() => { freshFixture(); unborn(); }); it("reports an unborn repo instead of failing to read it", async () => { write(EMPTY, "x.txt", "x\n"); const facts = await gitFacts(EMPTY); assert.equal(facts.unborn, true); assert.equal(facts.head, null); assert.equal(facts.branch, "main"); assert.equal(facts.subject, ""); assert.equal(facts.untracked, 1); assert.equal(facts.hasRemote, false); assert.equal((await gitFacts(LANE)).unborn, false); }); it("makes the first commit of an unborn repo", async () => { write(EMPTY, "x.txt", "x\n"); const out = await runGitAction(EMPTY, "commit", { message: "first" }); assert.equal(out.ok, true); assert.equal((await gitFacts(EMPTY)).unborn, false); }); it("refuses to publish before the first commit", async () => { await assert.rejects(() => runGitAction(EMPTY, "publish", { url: URL_OK }), { code: "ENOCOMMITS", }); }); it("refuses to publish a repo that already has a remote", async () => { await assert.rejects(() => runGitAction(LANE, "publish", { url: URL_OK }), { code: "EREMOTEEXISTS", }); assert.equal(g(LANE, "remote", "get-url", "origin").trim(), ORIGIN); }); it("refuses every URL outside https-without-userinfo, ssh:// and scp-like", async () => { firstCommit(); for (const url of [ ORIGIN, // a local path "/tmp/x", "../sibling.git", "file:///tmp/x.git", "ext::sh -c id", "ext::sh", "fd::3", "git://host/r.git", "http://host/r.git", "-oProxyCommand=x", "--upload-pack=x", "https://u:p@host/r.git", "https://token@host/r.git", "https://host/r .git", "https://host/r.git\n", "ssh://-oProxyCommand=x/r.git", // WHATWG parses these as https://host/x, but git sees no "://" and reads // them as scp-like ssh to a host named "https" — the checked string and // the used string must be the same string. "https:/host/r.git", "https:host/r.git", "https:\\\\host\\r.git", // Percent-encoded userinfo/host that git decodes into a leading "-". "ssh://%2DoProxyCommand=x@host/r.git", "ssh://git@%2Dhost/r.git", "-git@host:r.git", "", 42, undefined, ]) { await assert.rejects( () => runGitAction(EMPTY, "publish", { url }), { code: "EBADURL" }, `accepted ${JSON.stringify(url)}` ); } assert.equal(g(EMPTY, "remote").trim(), ""); }); it("accepts https, ssh:// and scp-like URLs", () => { for (const url of [ URL_OK, "https://host:8443/owner/r", "ssh://git@host:2222/owner/r.git", "ssh://host/r.git", "git@github.com:owner/r.git", ]) { assert.equal(validateRemoteUrl(url), url); } }); it("adds origin and pushes HEAD with an upstream", async () => { firstCommit(); const bare = path.join(ROOT, "published.git"); g(ROOT, "init", "-q", "--bare", bare); // Repo-local insteadOf maps the https URL onto the bare fixture, so the // real https branch of the allowlist is exercised without a network. g(EMPTY, "config", `url.${bare}.insteadOf`, URL_OK); const out = await runGitAction(EMPTY, "publish", { url: URL_OK }); assert.equal(out.ok, true); assert.equal(out.remote, "origin"); // `remote add origin -- ` stores the URL itself, not "--". assert.equal(g(EMPTY, "config", "remote.origin.url").trim(), URL_OK); assert.equal(g(bare, "rev-parse", "main").trim(), g(EMPTY, "rev-parse", "HEAD").trim()); const facts = await gitFacts(EMPTY); assert.equal(facts.upstream, "origin/main"); assert.equal(facts.ahead, 0); }); it("removes the remote again when the push fails", async () => { firstCommit(); g(EMPTY, "config", `url.${path.join(ROOT, "nowhere.git")}.insteadOf`, URL_OK); await assert.rejects(() => runGitAction(EMPTY, "publish", { url: URL_OK })); assert.equal(g(EMPTY, "remote").trim(), ""); }); });