Files
AgentHub/server/__tests__/git-refs.test.js
T
nntrivi2001 2cc492bb88 feat(git): Branches & tags panel — rename, branch from any ref, upstream, tags
GET /git/refs lists local branches (upstream, ahead/behind, last commit,
merged, held by a worktree, protected), remote branches grouped by remote
and tags (target, annotated message/tagger/date). New writes in
server/lib/git-refs.js, dispatched from git-ops.js: branch-rename,
branch-upstream (set/unset), tag-create (light/annotated), tag-delete
(confirm), tag-push (one tag to origin, no --tags, no force), and
branch-create with a looked-up from + switch on a clean tree. Every ref is
looked up, never passed through; repo-wide writes are repo-root only
(ESUBDIR) and refused during any operation (EMERGING).

Client: git/BranchManager + TagManager dialog opened from the git bar's
tags icon, en+vi (gitRefs namespace), keyboard navigable.

# Conflicts:
#	CLAUDE.md
#	client/src/components/lanes/LaneGitBar.tsx
#	docs/API.md
#	server/__tests__/lanes-api.test.js
#	server/routes/lanes.js
2026-11-02 12:00:55 +07:00

294 lines
12 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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" }
);
});
});