/** * @file Branch & tag tools against a REAL repo (server/lib/git-refs.js via * git-ops.js's runGitAction): branch rename (current or not; protected, * existing and bad names refused), branch-create from a tag, a sha or a remote * branch (and switch only on a clean tree), set/unset upstream, the refs read * (ahead/behind, merged, held by a worktree, remotes grouped, tags light and * annotated), tag create/delete (confirm)/push to a local bare origin, and the * guards: injection-shaped names, ESUBDIR from a subdirectory lane, EMERGING * during a merge. * @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 { listRefs } = require("../lib/git-refs"); const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-refs-")); const REPO = path.join(ROOT, "repo"); const REMOTE = path.join(ROOT, "remote.git"); const WT = path.join(ROOT, "wt"); const env = () => { const e = { ...process.env, GIT_TERMINAL_PROMPT: "0" }; delete e.GIT_DIR; delete e.GIT_WORK_TREE; delete e.GIT_INDEX_FILE; return e; }; const gIn = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8", env: env() }); const g = (...args) => gIn(REPO, ...args); const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body); const sha = (ref) => g("rev-parse", ref).trim(); const heads = () => g("for-each-ref", "--format=%(refname:short)", "refs/heads").split("\n").filter(Boolean); const tags = () => g("tag", "--list").split("\n").filter(Boolean); const current = () => g("branch", "--show-current").trim(); // main: c1 -> c2 (pushed to origin/main); feat from c1 with one commit; tag v1 on c1. beforeEach(() => { for (const d of [REPO, REMOTE, WT]) fs.rmSync(d, { recursive: true, force: true }); fs.mkdirSync(path.join(REPO, "sub"), { recursive: true }); g("init", "-q", "-b", "main"); g("config", "user.email", "t@h"); g("config", "user.name", "t"); write("a.txt", "1\n"); write("sub/s.txt", "s\n"); g("add", "-A"); g("commit", "-qm", "c1"); g("tag", "v1"); write("a.txt", "2\n"); g("commit", "-qam", "c2"); g("switch", "-qc", "feat", "v1"); write("f.txt", "f\n"); g("add", "-A"); g("commit", "-qm", "feat-1"); g("switch", "-q", "main"); gIn(ROOT, "init", "-q", "--bare", "remote.git"); g("remote", "add", "origin", REMOTE); g("push", "-q", "-u", "origin", "main"); g("push", "-q", "origin", "feat:refs/heads/remote-only"); g("fetch", "-q", "origin"); }); describe("branch-rename", () => { it("renames a branch that is not current", async () => { const r = await runGitAction(REPO, "branch-rename", { branch: "feat", name: "feature/x" }); assert.equal(r.branch, "feature/x"); assert.ok(heads().includes("feature/x")); assert.ok(!heads().includes("feat")); }); it("renames the current branch", async () => { g("switch", "-q", "feat"); await runGitAction(REPO, "branch-rename", { branch: "feat", name: "renamed" }); assert.equal(current(), "renamed"); }); it("refuses a protected branch, an existing name and a bad name", async () => { await assert.rejects(runGitAction(REPO, "branch-rename", { branch: "main", name: "trunk" }), { code: "EPROTECTED", }); await assert.rejects(runGitAction(REPO, "branch-rename", { branch: "feat", name: "main" }), { code: "EBRANCHEXISTS", }); for (const name of ["-x", "a b", "a..b", "refs/heads/../x", "x~1", ""]) { await assert.rejects(runGitAction(REPO, "branch-rename", { branch: "feat", name }), { code: "EBADBRANCH", }); } await assert.rejects(runGitAction(REPO, "branch-rename", { branch: "nope", name: "y" }), { code: "ENOBRANCH", }); assert.ok(heads().includes("feat")); }); }); describe("branch-create from", () => { it("creates from a tag, a sha and a remote branch, without switching", async () => { await runGitAction(REPO, "branch-create", { branch: "from-tag", from: "v1" }); assert.equal(sha("from-tag"), sha("v1^{commit}")); const short = sha("feat").slice(0, 8); await runGitAction(REPO, "branch-create", { branch: "from-sha", from: short }); assert.equal(sha("from-sha"), sha("feat")); await runGitAction(REPO, "branch-create", { branch: "from-remote", from: "origin/remote-only", }); assert.equal(sha("from-remote"), sha("origin/remote-only")); // A remote start point becomes its upstream. assert.equal(g("config", "--get", "branch.from-remote.merge").trim(), "refs/heads/remote-only"); assert.equal(current(), "main"); }); it("switches only on a clean tree", async () => { write("a.txt", "dirty\n"); await assert.rejects( runGitAction(REPO, "branch-create", { branch: "sw", from: "v1", switch: true }), { code: "EDIRTY" } ); assert.ok(!heads().includes("sw")); g("checkout", "--", "a.txt"); const r = await runGitAction(REPO, "branch-create", { branch: "sw", from: "v1", switch: true }); assert.equal(r.switched, true); assert.equal(current(), "sw"); }); it("refuses a start point that is not a looked-up ref or commit", async () => { for (const from of ["-x", "main..feat", "HEAD~1", "refs/heads/../main", "a b", "deadbeef"]) { await assert.rejects(runGitAction(REPO, "branch-create", { branch: "z", from }), { code: "EBADREF", }); } await assert.rejects(runGitAction(REPO, "branch-create", { branch: "feat", from: "v1" }), { code: "EBRANCHEXISTS", }); assert.ok(!heads().includes("z")); }); it("asks for the full refname when a branch and a tag share a name", async () => { g("branch", "v1", "feat"); await assert.rejects(runGitAction(REPO, "branch-create", { branch: "z", from: "v1" }), { code: "EAMBIGUOUS", }); await runGitAction(REPO, "branch-create", { branch: "z", from: "refs/tags/v1" }); assert.equal(sha("z"), sha("v1^{commit}")); }); it("keeps the plain branch-create untouched", async () => { await runGitAction(REPO, "branch-create", { branch: "plain" }); assert.equal(sha("plain"), sha("HEAD")); }); }); describe("branch-upstream", () => { it("sets and unsets the upstream", async () => { await runGitAction(REPO, "branch-upstream", { branch: "feat", upstream: "origin/remote-only" }); assert.equal(g("rev-parse", "--abbrev-ref", "feat@{upstream}").trim(), "origin/remote-only"); await runGitAction(REPO, "branch-upstream", { branch: "feat" }); assert.throws(() => g("rev-parse", "--abbrev-ref", "feat@{upstream}")); await assert.rejects(runGitAction(REPO, "branch-upstream", { branch: "feat" }), { code: "ENOUPSTREAM", }); }); it("refuses an upstream that is not a remote branch", async () => { for (const upstream of ["feat", "origin/nope", "-x", "origin/HEAD"]) { await assert.rejects(runGitAction(REPO, "branch-upstream", { branch: "feat", upstream }), { code: "ENOBRANCH", }); } }); }); describe("listRefs", () => { it("reports ahead/behind, merged, worktrees, remotes and tags", async () => { write("a.txt", "3\n"); g("commit", "-qam", "c3"); g("worktree", "add", "-q", WT, "feat"); g("tag", "-a", "-m", "release one", "v2", "HEAD~1"); const r = await listRefs(REPO, { protectedNames: new Set(["main"]) }); assert.equal(r.current, "main"); const main = r.local.find((b) => b.name === "main"); assert.equal(main.upstream, "origin/main"); assert.equal(main.ahead, 1); assert.equal(main.behind, 0); assert.equal(main.current, true); assert.equal(main.protected, true); assert.equal(main.subject, "c3"); const feat = r.local.find((b) => b.name === "feat"); assert.equal(feat.upstream, null); assert.equal(feat.ahead, null); assert.equal(feat.merged, false); assert.equal(fs.realpathSync(feat.worktree), fs.realpathSync(WT)); const origin = r.remotes.find((x) => x.remote === "origin"); assert.deepEqual(origin.branches.map((b) => b.branch).sort(), ["main", "remote-only"]); const v1 = r.tags.find((t) => t.name === "v1"); assert.equal(v1.annotated, false); assert.equal(v1.sha, sha("v1")); const v2 = r.tags.find((t) => t.name === "v2"); assert.equal(v2.annotated, true); assert.equal(v2.message, "release one"); assert.equal(v2.tagger, "t"); assert.equal(v2.sha, sha("HEAD~1")); }); it("reports behind after the remote moves", async () => { g("push", "-q", "origin", "feat:main", "--force"); g("fetch", "-q", "origin"); const main = (await listRefs(REPO)).local.find((b) => b.name === "main"); assert.equal(main.ahead, 1); assert.equal(main.behind, 1); }); }); describe("tags", () => { it("creates a lightweight tag on HEAD and an annotated one on a commit", async () => { await runGitAction(REPO, "tag-create", { tag: "light" }); assert.equal(g("cat-file", "-t", "light").trim(), "commit"); assert.equal(sha("light"), sha("HEAD")); await runGitAction(REPO, "tag-create", { tag: "ann", message: "notes\n\nbody", from: "feat" }); assert.equal(g("cat-file", "-t", "ann").trim(), "tag"); assert.equal(sha("ann^{commit}"), sha("feat")); await assert.rejects(runGitAction(REPO, "tag-create", { tag: "light" }), { code: "ETAGEXISTS", }); for (const tag of ["-x", "a b", "a..b", "x:y", "", "--force"]) { await assert.rejects(runGitAction(REPO, "tag-create", { tag }), { code: "EBADTAG" }); } await assert.rejects(runGitAction(REPO, "tag-create", { tag: "t2", from: "HEAD~1" }), { code: "EBADREF", }); }); it("deletes a tag only with confirm", async () => { await assert.rejects(runGitAction(REPO, "tag-delete", { tag: "v1" }), { code: "ECONFIRM" }); assert.ok(tags().includes("v1")); await runGitAction(REPO, "tag-delete", { tag: "v1", confirm: true }); assert.ok(!tags().includes("v1")); await assert.rejects(runGitAction(REPO, "tag-delete", { tag: "v1", confirm: true }), { code: "ENOTAG", }); }); it("pushes one tag to origin, never others", async () => { g("tag", "other"); const r = await runGitAction(REPO, "tag-push", { tag: "v1" }); assert.equal(r.remote, "origin"); const remoteTags = gIn(REMOTE, "tag", "--list").split("\n").filter(Boolean); assert.deepEqual(remoteTags, ["v1"]); await assert.rejects(runGitAction(REPO, "tag-push", { tag: "nope" }), { code: "ENOTAG" }); }); it("refuses a tag push that would move the remote's tag", async () => { await runGitAction(REPO, "tag-push", { tag: "v1" }); g("tag", "-d", "v1"); g("tag", "v1", "feat"); await assert.rejects(runGitAction(REPO, "tag-push", { tag: "v1" })); assert.equal(gIn(REMOTE, "rev-parse", "v1").trim(), sha("v1^{commit}~1")); }); }); describe("guards", () => { it("refuses repo-wide writes from a subdirectory lane", async () => { const sub = path.join(REPO, "sub"); await assert.rejects(runGitAction(sub, "branch-rename", { branch: "feat", name: "x" }), { code: "ESUBDIR", }); await assert.rejects(runGitAction(sub, "tag-create", { tag: "t" }), { code: "ESUBDIR" }); await assert.rejects(runGitAction(sub, "tag-push", { tag: "v1" }), { code: "ESUBDIR" }); }); it("refuses during a merge", async () => { g("switch", "-qc", "side", "v1"); write("a.txt", "side\n"); g("commit", "-qam", "side"); g("switch", "-q", "main"); assert.throws(() => g("merge", "-q", "side")); await assert.rejects(runGitAction(REPO, "tag-create", { tag: "t" }), { code: "EMERGING" }); await assert.rejects(runGitAction(REPO, "branch-rename", { branch: "feat", name: "y" }), { code: "EMERGING", }); await assert.rejects( runGitAction(REPO, "branch-create", { branch: "q", from: "v1", switch: true }), { code: "EMERGING" } ); }); });