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
525 lines
19 KiB
JavaScript
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,
|
|
};
|