Files
AgentHub/server/lib/git-refs.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

525 lines
19 KiB
JavaScript

/**
* @file Branch and tag tools for a lane's git bar (the Branches panel): the
* read `listRefs` behind `GET /api/lanes/:id/git/refs` — local branches with
* their upstream, ahead/behind, last commit and why a delete would be
* refused; remote branches grouped by remote; tags with their target commit
* and, for an annotated one, its message, tagger and date — and the writes
* git-ops.js dispatches here: `branch-rename`, `branch-upstream` (set or
* unset), `branch-create` when it names a `from` start point or asks to
* `switch`, `tag-create`, `tag-delete` and `tag-push`.
*
* The rules are git-ops.js's own:
* - argv only, through worktree.js's `git()` (which already runs as the
* folder's member under S3) — never a shell string.
* - every ref the request names is LOOKED UP: a branch, remote branch or
* tag must exist under refs/heads, refs/remotes or refs/tags, and git is
* handed the full refname that lookup found (or a sha `rev-parse
* --verify <x>^{commit}` confirmed) — never the request's text, so no
* option, revision expression or `..` can ride in as a "ref".
* - a NEW name is checked by git itself (`check-ref-format`), and a tag
* name may not start with `-` (git accepts `refs/tags/-x`; argv must not).
* - branches and tags are repo-wide, so every write here but `branch-create`
* (which keeps git-ops.js's existing reach) runs only from a lane at the
* repo root (ESUBDIR), and none runs while a merge, cherry-pick, revert or
* rebase is open (EMERGING).
* - `tag-delete` is irreversible, so it needs `confirm: true`; renaming or
* re-pointing a protected branch (main/master/development/develop) is
* refused like deleting one.
* - `tag-push` pushes ONE looked-up tag (`refs/tags/<name>:refs/tags/<name>`)
* to `origin` (else the only remote), never `--tags`, never forced, with
* the same credentials flow as push. Deleting a remote tag is not here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const {
git: rawGit,
isValidBranchName,
listWorktrees,
operationInProgress,
statusCounts,
} = require("./worktree");
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
/** The writes this module answers (git-ops.js adds them to its ACTIONS). */
const REF_ACTIONS = ["branch-rename", "branch-upstream", "tag-create", "tag-delete", "tag-push"];
/** Error codes of this module that are the request's fault (HTTP 400). */
const BAD_REQUEST_CODES = ["EBADREF", "EBADTAG", "ETAGEXISTS", "ENOTAG", "ENOUPSTREAM"];
/** A commit as the request may name it: an abbreviated or full hex sha. */
const SHA_RE = /^[0-9a-f]{4,64}$/i;
/** Longest tag message accepted. */
const MAX_MESSAGE_LEN = 4000;
/** How many refs of each kind the read returns. */
const MAX_REFS = 1000;
function bad(code, message) {
return Object.assign(new Error(message), { code });
}
const str = (v) => (typeof v === "string" ? v.trim() : "");
/** Whether `dir` is a subdirectory of its repo (not the root). */
async function isSubdir(dir) {
return Boolean((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim());
}
async function assertRepoRoot(dir, what) {
if (await isSubdir(dir)) {
throw bad(
"ESUBDIR",
`${what} changes the whole repository; run it from a workspace at the repo root`
);
}
}
/** Throw EMERGING while a rebase, merge, cherry-pick or revert is open. */
async function assertNoOperation(dir) {
for (const name of ["rebase-merge", "rebase-apply"]) {
const rel = (await git(dir, ["rev-parse", "--git-path", name])).stdout.trim();
if (fs.existsSync(path.resolve(dir, rel))) {
throw bad("EMERGING", "a rebase is in progress: finish or abort it in a terminal first");
}
}
const open = await operationInProgress(dir);
if (open) throw bad("EMERGING", `a ${open} is in progress: finish or abort it first`);
}
/** `[full refname, short name, symref]` for every ref under `namespaces`. */
async function refsUnder(dir, namespaces) {
return (
await git(dir, [
"for-each-ref",
"--format=%(refname)%00%(refname:short)%00%(symref)",
...namespaces,
])
).stdout
.split("\n")
.filter(Boolean)
.map((l) => l.split("\0"));
}
/** The full refname of local branch `name`, or null. */
async function localBranchRef(dir, name) {
if (!name) return null;
const full = `refs/heads/${name}`;
return (await refsUnder(dir, [full])).some(([r]) => r === full) ? full : null;
}
/** The full refname of tag `name`, or null. */
async function tagRef(dir, name) {
if (!name) return null;
const full = `refs/tags/${name}`;
return (await refsUnder(dir, [full])).some(([r]) => r === full) ? full : null;
}
/** The full refname of remote-tracking branch `name` (`origin/x`), or null. */
async function remoteBranchRef(dir, name) {
if (!name) return null;
const full = `refs/remotes/${name}`;
const hit = (await refsUnder(dir, [full])).find(([r]) => r === full);
return hit && !hit[2] ? full : null; // a remote's symbolic HEAD is no branch
}
/** The full sha of the commit `rev` names, or "". `rev` is a looked-up ref or a hex sha. */
async function commitOf(dir, rev) {
return git(dir, ["rev-parse", "-q", "--verify", `${rev}^{commit}`]).then(
(r) => r.stdout.trim(),
() => ""
);
}
/**
* A start point from the request, LOOKED UP: a local branch, a remote
* branch, a tag (short or full refname — a name that is both a branch and a
* tag must be spelled out in full), or a hex sha of a commit that exists.
* Returns `{rev, kind}` — `rev` the full refname or full sha git is handed —
* or throws EBADREF / EAMBIGUOUS.
*/
async function resolveStart(dir, from) {
const name = str(from);
if (!name || name.length > 1024 || name.startsWith("-")) {
throw bad("EBADREF", "from must be a branch, tag or commit");
}
const refs = await refsUnder(dir, ["refs/heads", "refs/remotes", "refs/tags"]);
const hits = refs.filter(
([full, , symref]) =>
!symref &&
(full === name ||
full === `refs/heads/${name}` ||
full === `refs/remotes/${name}` ||
full === `refs/tags/${name}`)
);
if (hits.length > 1) {
throw bad("EAMBIGUOUS", `${name} names several refs (${hits.map((h) => h[0]).join(", ")})`);
}
if (hits.length === 1) {
const full = hits[0][0];
const sha = await commitOf(dir, full);
if (!sha) throw bad("EBADREF", `${name} does not point at a commit`);
const kind = full.startsWith("refs/heads/")
? "branch"
: full.startsWith("refs/remotes/")
? "remote"
: "tag";
return { rev: full, sha, kind };
}
if (SHA_RE.test(name)) {
const sha = await commitOf(dir, name);
if (sha) return { rev: sha, sha, kind: "commit" };
}
throw bad("EBADREF", `no branch, tag or commit ${name}`);
}
/** A new tag name, checked by git, or EBADTAG. */
async function validTagName(dir, raw) {
const name = str(raw);
if (!name || name.startsWith("-") || name.length > 255) {
throw bad("EBADTAG", "tag is not a valid git tag name");
}
const ok = await git(dir, ["check-ref-format", `refs/tags/${name}`]).then(
() => true,
() => false
);
if (!ok) throw bad("EBADTAG", "tag is not a valid git tag name");
return name;
}
/** A branch name from the request, checked by git, or EBADBRANCH. */
async function validBranchName(dir, raw, field = "branch") {
const name = str(raw);
if (!name) throw bad("EBADBRANCH", `${field} is required`);
if (!(await isValidBranchName(dir, name))) {
throw bad("EBADBRANCH", `${field} is not a valid git branch name`);
}
return name;
}
async function isDirty(dir) {
const { dirty, untracked } = await statusCounts(dir);
return dirty + untracked > 0;
}
function done(action, result, extra = {}) {
const output = [result.stdout, result.stderr].filter(Boolean).join("").trim();
return { action, ok: true, output, ...extra };
}
/**
* Whether git-ops.js should hand this action to `refsAction`: one of
* REF_ACTIONS, or a `branch-create` naming a `from` start point. A plain
* `{branch, switch: true}` (Create & switch from HEAD) stays git-ops.js's:
* `git switch -c` there carries uncommitted changes along, which is its
* whole point — here a switch needs a clean tree.
*/
function handles(action, body = {}) {
if (REF_ACTIONS.includes(action)) return true;
return action === "branch-create" && body.from !== undefined;
}
/**
* Run one branch/tag write.
* @param {string} dir The lane's cwd.
* @param {string} action See `handles`.
* @param {object} body The request body.
* @param {{networkGit: Function, PROTECTED: Set<string>}} helpers git-ops.js's
* authenticated network runner and protected-branch set (passed in, so the
* two modules do not require each other).
*/
async function refsAction(dir, action, body = {}, { networkGit, PROTECTED }) {
if (action === "branch-create") return createBranch(dir, body);
await assertNoOperation(dir);
await assertRepoRoot(dir, action.startsWith("tag") ? "a tag" : "a branch change");
if (action === "branch-rename") {
const branch = await validBranchName(dir, body.branch);
const name = await validBranchName(dir, body.name, "name");
if (!(await localBranchRef(dir, branch))) {
throw bad("ENOBRANCH", `branch ${branch} does not exist locally`);
}
if (PROTECTED.has(branch)) throw bad("EPROTECTED", `branch ${branch} is protected`);
if (name === branch) throw bad("EBADBRANCH", "the new name is the old one");
if (await localBranchRef(dir, name)) {
throw bad("EBRANCHEXISTS", `branch ${name} already exists`);
}
// -m, never -M: git refuses to overwrite an existing branch on its own.
return done(action, await git(dir, ["branch", "-m", branch, name]), { branch: name });
}
if (action === "branch-upstream") {
const branch = await validBranchName(dir, body.branch);
if (!(await localBranchRef(dir, branch))) {
throw bad("ENOBRANCH", `branch ${branch} does not exist locally`);
}
const wanted = str(body.upstream);
if (!wanted) {
const has = await git(dir, ["config", "--get", `branch.${branch}.merge`]).then(
(r) => Boolean(r.stdout.trim()),
() => false
);
if (!has) throw bad("ENOUPSTREAM", `branch ${branch} has no upstream`);
return done(action, await git(dir, ["branch", "--unset-upstream", branch]), { branch });
}
const ref = await remoteBranchRef(dir, wanted);
if (!ref) throw bad("ENOBRANCH", `no remote branch ${wanted}`);
return done(action, await git(dir, ["branch", `--set-upstream-to=${ref}`, branch]), {
branch,
upstream: wanted,
});
}
if (action === "tag-create") {
const name = await validTagName(dir, body.tag ?? body.name);
if (await tagRef(dir, name)) throw bad("ETAGEXISTS", `tag ${name} already exists`);
const message = typeof body.message === "string" ? body.message.trim() : "";
if (message.length > MAX_MESSAGE_LEN) throw bad("EBADMESSAGE", "tag message is too long");
let sha;
if (body.from !== undefined && body.from !== null && body.from !== "") {
sha = (await resolveStart(dir, body.from)).sha;
} else {
sha = await commitOf(dir, "HEAD");
if (!sha) throw bad("ENOCOMMITS", "there is no commit to tag yet");
}
// An annotated tag when a message is given (its tagger is the account's
// own git identity); `-m` carries the message as ONE argv element.
const args = message ? ["tag", "-a", "-m", message, name, sha] : ["tag", name, sha];
return done(action, await git(dir, args), { tag: name });
}
if (action === "tag-delete") {
if (body.confirm !== true) throw bad("ECONFIRM", "tag-delete requires confirm: true");
const name = await validTagName(dir, body.tag ?? body.name);
if (!(await tagRef(dir, name))) throw bad("ENOTAG", `no tag ${name}`);
return done(action, await git(dir, ["tag", "-d", name]), { tag: name });
}
// tag-push
const name = await validTagName(dir, body.tag ?? body.name);
const ref = await tagRef(dir, name);
if (!ref) throw bad("ENOTAG", `no tag ${name}`);
const remotes = (await git(dir, ["remote"])).stdout.split("\n").filter(Boolean);
const remote = remotes.includes("origin") ? "origin" : remotes.length === 1 ? remotes[0] : null;
if (!remote) {
throw bad(
"ENOREMOTE",
remotes.length
? "several remotes and no origin: push the tag from a terminal"
: "this repository has no remote to push to"
);
}
// ONE tag by its full refname on both sides — no --tags, no force: a tag
// the remote already holds at another commit is rejected in git's words.
return done(action, await networkGit(dir, ["push", remote, `${ref}:${ref}`], body), {
tag: name,
remote,
});
}
/**
* `branch-create` with a looked-up `from` and/or `switch: true`. Without
* `switch` the working tree is untouched (git-ops.js's rule); with it the
* tree must be clean (EDIRTY) and no operation open — carrying changes
* across is not this module's to decide.
*/
async function createBranch(dir, body) {
const branch = await validBranchName(dir, body.branch);
if (await localBranchRef(dir, branch)) {
throw bad("EBRANCHEXISTS", `branch ${branch} already exists`);
}
const start =
body.from === undefined || body.from === null || body.from === ""
? null
: await resolveStart(dir, body.from);
if (body.switch === true) {
await assertNoOperation(dir);
if (await isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes");
}
// A local or remote branch is passed by its full refname (a remote one so
// git sets it as the upstream, like JetBrains' "new branch from origin/x");
// a tag or a commit by its sha.
const rev = start
? start.kind === "branch" || start.kind === "remote"
? start.rev
: start.sha
: null;
const out = [await git(dir, ["branch", branch, ...(rev ? [rev] : [])])];
if (body.switch === true) out.push(await git(dir, ["switch", branch]));
return done(
"branch-create",
{ stdout: out.map((r) => r.stdout).join(""), stderr: out.map((r) => r.stderr).join("") },
{ branch, ...(body.switch === true ? { switched: true } : {}) }
);
}
/** "ahead 2, behind 1" / "gone" / "" → counts. */
function parseTrack(track, hasUpstream) {
if (!hasUpstream) return { ahead: null, behind: null, gone: false };
if (track === "gone") return { ahead: null, behind: null, gone: true };
const ahead = Number(/ahead (\d+)/.exec(track)?.[1] || 0);
const behind = Number(/behind (\d+)/.exec(track)?.[1] || 0);
return { ahead, behind, gone: false };
}
/**
* Every branch and tag of the repo at `dir`, for the Branches panel.
* @returns {Promise<{current: string|null, head: string|null, local: object[],
* remotes: {remote: string, branches: object[]}[], tags: object[], truncated: boolean}>}
*/
async function listRefs(dir, { protectedNames = new Set() } = {}) {
const SEP = "%00";
const head = await commitOf(dir, "HEAD");
const current =
(
await git(dir, ["symbolic-ref", "--short", "-q", "HEAD"]).catch(() => ({ stdout: "" }))
).stdout.trim() || null;
let truncated = false;
const cap = (list) => {
if (list.length > MAX_REFS) truncated = true;
return list.slice(0, MAX_REFS);
};
const merged = new Set(
head
? (
await git(dir, [
"for-each-ref",
"--merged",
"HEAD",
"--format=%(refname:short)",
"refs/heads",
])
).stdout
.split("\n")
.filter(Boolean)
: []
);
const here = await git(dir, ["rev-parse", "--show-toplevel"]).then(
(r) => r.stdout.trim(),
() => ""
);
const held = new Map();
for (const w of await listWorktrees(dir).catch(() => [])) {
if (w.branch && path.resolve(w.path) !== path.resolve(here)) held.set(w.branch, w.path);
}
const localFmt = [
"%(refname:short)",
"%(objectname)",
"%(upstream:short)",
"%(upstream:track,nobracket)",
"%(contents:subject)",
"%(committerdate:iso-strict)",
].join(SEP);
const local = cap(
(
await git(dir, [
"for-each-ref",
"--sort=-committerdate",
`--format=${localFmt}`,
"refs/heads",
])
).stdout
.split("\n")
.filter(Boolean)
.map((l) => {
const [name, sha, upstream, track, subject, date] = l.split("\0");
return {
name,
sha,
current: name === current,
upstream: upstream || null,
...parseTrack(track || "", Boolean(upstream)),
subject,
date: date || null,
merged: merged.has(name),
worktree: held.get(name) || null,
protected: protectedNames.has(name),
};
})
);
const remoteNames = (await git(dir, ["remote"])).stdout
.split("\n")
.filter(Boolean)
.sort((a, b) => b.length - a.length);
const remoteFmt = [
"%(refname:short)",
"%(objectname)",
"%(symref)",
"%(contents:subject)",
"%(committerdate:iso-strict)",
].join(SEP);
const groups = new Map();
const remoteRows = cap(
(
await git(dir, [
"for-each-ref",
"--sort=-committerdate",
`--format=${remoteFmt}`,
"refs/remotes",
])
).stdout
.split("\n")
.filter(Boolean)
.map((l) => l.split("\0"))
.filter(([, , symref]) => !symref)
);
for (const [name, sha, , subject, date] of remoteRows) {
const remote = remoteNames.find((r) => name.startsWith(`${r}/`));
if (!remote) continue;
const branch = name.slice(remote.length + 1);
if (!branch || branch === "HEAD") continue;
if (!groups.has(remote)) groups.set(remote, []);
groups.get(remote).push({ name, branch, sha, subject, date: date || null });
}
const remotes = [...remoteNames]
.sort()
.map((remote) => ({ remote, branches: groups.get(remote) || [] }));
const tagFmt = [
"%(refname:short)",
"%(objecttype)",
"%(objectname)",
"%(*objectname)",
"%(contents:subject)",
"%(taggername)",
"%(taggerdate:iso-strict)",
"%(committerdate:iso-strict)",
"%(*committerdate:iso-strict)",
].join(SEP);
const tags = cap(
(
await git(dir, ["for-each-ref", "--sort=-creatordate", `--format=${tagFmt}`, "refs/tags"])
).stdout
.split("\n")
.filter(Boolean)
.map((l) => {
const [name, type, obj, peeled, subject, tagger, taggerDate, cDate, peeledDate] =
l.split("\0");
const annotated = type === "tag";
return {
name,
sha: annotated ? peeled || null : type === "commit" ? obj : null,
annotated,
message: annotated ? subject : null,
tagger: annotated ? tagger || null : null,
date: (annotated ? taggerDate || peeledDate : cDate) || null,
};
})
);
return { current, head: head || null, local, remotes, tags, truncated };
}
module.exports = {
REF_ACTIONS,
BAD_REQUEST_CODES,
handles,
refsAction,
listRefs,
resolveStart,
};