/** * @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 ^{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/:refs/tags/`) * 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ĩ */ 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}} 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, };