2cc492bb88
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
294 lines
12 KiB
JavaScript
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" }
|
|
);
|
|
});
|
|
});
|