/** * @file The dashboard's history-REWRITING and remote-changing git writes, kept * out of git-ops.js so they read as one policy: rebase onto a branch (with * Continue / Skip / Abort while it is open), an interactive rebase of a * branch's commits built from a list (reorder, squash, fixup, reword, drop), * reset the branch to a commit (soft / mixed / keep, and hard only on a clean * tree), the Undo those leave behind, push --force-with-lease, delete a remote * branch, and add / rename / remove a remote. Dispatched from git-ops.js's * `runGitAction`, so every action here rides the same route, lock and * same-origin guard as the rest. * * The rule these were allowed under: a dashboard action is fine when its * RECOVERY is a button too, and the server refuses what the UI only * discourages. * - A rebase is recoverable while open (`rebase-abort`), and every rewrite * answers `before`/`after`, which `undo-rewrite` (`reset --keep `, * only while HEAD is still `after` — ESTALE) turns back. * - Rewriting a commit any remote-tracking branch already holds is EPUSHED * unless the request says it knows (`force: true` — the UI shows that a * force-push will be needed); a force push is `--force-with-lease` against * the exact remote sha the UI showed (ESTALE when the remote moved), for * HEAD only, never onto main/master/development/develop unless the server * sets AGENTHUB_GIT_FORCE_PUSH_PROTECTED=1. * - `reset --hard` only on a CLEAN tree and with the branch name typed back * (`confirmBranch`), so nothing uncommitted can be lost; soft/mixed never * touch the working tree and `keep` refuses to overwrite a local change. * - Every rewrite runs from a lane at the repo ROOT (ESUBDIR), on a clean * tree where it moves files, never while another operation is open * (EMERGING), and names the HEAD it was planned against (ESTALE). * - A remote's URL passes git-ops.js's `validateRemoteUrl` allowlist, and a * ref is always looked up among the repo's own, never passed through. * - argv only. The interactive rebase's todo and reword messages are files * AgentHub writes into the repo's own git dir (as the folder's account, * 0600); git is handed `cp` as its sequence editor and `true` as its * editor, and the todo holds only full commit ids plus `exec git commit * --amend -F ` lines — no text from the request is ever shell. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { git: rawGit, isValidBranchName, branchRef, operationInProgress } = require("./worktree"); const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts }); /** The writes this module owns (git-ops.js dispatches them here). */ const ACTIONS = new Set([ "rebase", "rebase-continue", "rebase-skip", "rebase-abort", "rebase-interactive", "reset", "undo-rewrite", "force-push", "remote-branch-delete", "remote-add", "remote-rename", "remote-remove", "conflict-save", ]); /** What each new error code answers (the route asks here first). */ const STATUS = { EBADMODE: 400, EBADTODO: 400, EBADREMOTE: 400, ENOTRELATED: 400, EDETACHED: 400, EDEFAULTBRANCH: 400, ENOREBASE: 409, ENOCHANGES: 400, EBADCONTENT: 400, EBINARY: 400, ETOOBIG: 400, ENOFILE: 400, ESECRET: 403, EFOREIGN: 403, EUNSUPPORTED: 400, }; /** The flags every rebase here runs with: no autostash, no other branch moved. */ const REBASE_CFG = [ "-c", "rebase.autoStash=false", "-c", "rebase.updateRefs=false", "-c", "rebase.autoSquash=false", "-c", "rebase.missingCommitsCheck=ignore", ]; /** No editor may ever open under the dashboard. */ const NO_EDITOR = { GIT_EDITOR: "true" }; const FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; /** A remote name: what `git remote add` accepts, minus anything option-like. */ const REMOTE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/; const TODO_ACTIONS = new Set(["pick", "reword", "squash", "fixup", "drop"]); /** Most commits one interactive rebase may list. */ const MAX_TODO = 300; function ops() { return require("./git-ops")._internal; } function bad(code, message, extra) { return Object.assign(new Error(message), { code }, extra || {}); } const out = (r) => [r.stdout, r.stderr].filter(Boolean).join("").trim(); const done = (action, r, extra) => ({ action, ok: true, output: r ? out(r) : "", ...extra }); async function head(dir) { return (await git(dir, ["rev-parse", "HEAD"])).stdout.trim(); } async function currentBranch(dir) { return ( await git(dir, ["symbolic-ref", "--short", "-q", "HEAD"]).catch(() => ({ stdout: "" })) ).stdout.trim(); } async function lines(dir, args) { return (await git(dir, args).catch(() => ({ stdout: "" }))).stdout.split("\n").filter(Boolean); } /** Whether a rebase is open (merge or apply backend). */ async function rebaseOpen(dir) { return (await operationInProgress(dir)) === "rebase"; } async function assertRepoRoot(dir, what) { if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) { throw bad( "ESUBDIR", `${what} moves the whole branch; run it from a workspace at the repo root` ); } } async function assertNoOperation(dir) { const open = await operationInProgress(dir); if (open) throw bad("EMERGING", `a ${open} is in progress: finish or abort it first`); } /** HEAD must still be the commit the dialog was drawn for. */ async function assertHead(dir, seen) { if (typeof seen !== "string" || !FULL_SHA.test(seen)) { throw bad("EBADSHA", "head (the full HEAD sha the plan showed) is required"); } if (seen !== (await head(dir))) { throw bad("ESTALE", "the branch moved since you looked: refresh and retry"); } } /** Of `shas`, the ones some remote-tracking branch already contains. */ async function pushedOf(dir, range) { const all = await lines(dir, ["rev-list", ...range]); if (!all.length) return { all, pushed: [] }; const unpushed = new Set(await lines(dir, ["rev-list", ...range, "--not", "--remotes"])); return { all, pushed: all.filter((s) => !unpushed.has(s)) }; } /** `{sha, short, subject, message, pushed}` for each commit of `range`, oldest first. */ async function commitList(dir, range) { const raw = ( await git(dir, ["log", "--reverse", "--format=%H%x00%h%x00%s%x00%B%x1e", ...range, "--"]).catch( () => ({ stdout: "" }) ) ).stdout; const { pushed } = await pushedOf(dir, range); const isPushed = new Set(pushed); return raw .split("\x1e") .map((r) => r.replace(/^\n/, "")) .filter(Boolean) .map((r) => { const [sha, short, subject, message] = r.split("\0"); return { sha, short, subject, message: (message || "").trim(), pushed: isPushed.has(sha) }; }); } // ───────────────────────── interactive-rebase scratch files ───────────────────────── const SCRATCH_PREFIX = "agenthub-rebase-"; async function gitDir(dir) { return (await git(dir, ["rev-parse", "--absolute-git-dir"])).stdout.trim(); } /** Single-quote for git's `sh -c` — only ever AgentHub's own paths. */ function shq(p) { if (/['\n\r\0]/.test(p)) throw bad("EUNSUPPORTED", "the repository path cannot be quoted safely"); return `'${p}'`; } /** Write one scratch file as the folder's account (S3: the member, write-as). */ function writeScratch(file, content) { const runAs = require("./run-as"); const acct = runAs.accountForPath(file); if (!acct) { fs.writeFileSync(file, content, { mode: 0o600 }); fs.chmodSync(file, 0o600); return; } runAs.writeAs(acct, file, "", content); } /** Remove every scratch file a finished rebase left in the git dir. */ async function cleanScratch(dir) { const gd = await gitDir(dir).catch(() => null); if (!gd) return; let names = []; try { names = fs.readdirSync(gd).filter((n) => n.startsWith(SCRATCH_PREFIX)); } catch { return; } const runAs = require("./run-as"); for (const n of names) { const f = path.join(gd, n); try { const acct = runAs.accountForPath(f); if (acct) runAs.removeAs(acct, f); else fs.rmSync(f, { force: true }); } catch { /* best effort: a stray scratch file is harmless and overwritten next time */ } } } function cpPath() { return ["/bin/cp", "/usr/bin/cp"].find((f) => fs.existsSync(f)) || "cp"; } // ───────────────────────── rebase ───────────────────────── /** Run a rebase; a stop (conflict, failed exec) is an operation that STARTED. */ async function runRebase(dir, action, args, env, extra) { try { const r = await git(dir, [...REBASE_CFG, ...args], { env: { ...NO_EDITOR, ...env } }); const after = await head(dir); if (!(await rebaseOpen(dir))) await cleanScratch(dir); return done(action, r, { ...extra, after, conflicts: [], stopped: await rebaseOpen(dir) }); } catch (e) { if (!(await rebaseOpen(dir))) { await cleanScratch(dir); throw e; } const { unmergedPaths } = ops(); return done(action, e.git || null, { ...extra, after: null, conflicts: await unmergedPaths(dir), stopped: true, }); } } /** `rebase {onto, head, force?}`: replay this branch's commits onto a branch. */ async function rebaseOnto(dir, body) { const onto = typeof body.onto === "string" ? body.onto.trim() : ""; if (!(await isValidBranchName(dir, onto))) throw bad("EBADBRANCH", "onto must be a branch"); await assertRepoRoot(dir, "a rebase"); await assertNoOperation(dir); if (await ops().isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes"); await assertHead(dir, body.head); const ref = await branchRef(dir, onto); if (!ref) throw bad("ENOBRANCH", `no branch ${onto}`); const { all, pushed } = await pushedOf(dir, [`${ref}..HEAD`]); if (pushed.length && body.force !== true) { throw bad( "EPUSHED", `${pushed.length} of the ${all.length} commits to rebase are already pushed: rebasing them needs a force-push`, { pushed: pushed.length, total: all.length } ); } const before = await head(dir); return runRebase( dir, "rebase", ["rebase", ref], {}, { before, needsForcePush: pushed.length > 0 } ); } /** rebase-continue / rebase-skip / rebase-abort. */ async function rebaseStep(dir, action, body) { if (!(await rebaseOpen(dir))) throw bad("ENOREBASE", "no rebase is in progress"); if (action === "rebase-abort") { if (body.confirm !== true) throw bad("ECONFIRM", "rebase-abort requires confirm: true"); const r = await git(dir, ["rebase", "--abort"]); await cleanScratch(dir); return done(action, r, { after: await head(dir), conflicts: [], stopped: false }); } if (action === "rebase-continue") { const left = await ops().unmergedPaths(dir); if (left.length) { throw bad("ECONFLICTS", `${left.length} file(s) still in conflict: ${left[0]}`); } return runRebase(dir, action, ["rebase", "--continue"], {}, {}); } return runRebase(dir, action, ["rebase", "--skip"], {}, {}); } /** * The interactive plan's base: `body.base` (an ancestor of HEAD), else the * parent of the oldest commit no remote-tracking branch holds. */ async function interactiveBase(dir, base) { if (base) { const sha = await ops().commitSha(dir, base); const anc = await git(dir, ["merge-base", "--is-ancestor", sha, "HEAD"]).then( () => true, () => false ); if (!anc) throw bad("ENOTRELATED", "the base must be an ancestor of HEAD"); return sha; } const unpushed = await lines(dir, ["rev-list", "--reverse", "HEAD", "--not", "--remotes"]); if (!unpushed.length) return await head(dir); const parent = await git(dir, ["rev-parse", "-q", "--verify", `${unpushed[0]}^`]).then( (r) => r.stdout.trim(), () => "" ); if (!parent) throw bad("ENOPARENT", "the first commit cannot be rewritten here"); return parent; } /** * The todo for `entries` (oldest first): a squash becomes `fixup` plus one * `exec git commit --amend -F ` at the end of its group carrying the * combined message, a reword `pick` plus the same exec — so git never opens an * editor and no request text ever reaches a shell. */ function buildTodo(entries, byShas, msgFile) { const out = []; const messages = []; let group = null; const flush = () => { if (!group) return; let msg = null; if (group.squashed.length) { msg = group.squashMessage ?? [group.message ?? byShas.get(group.sha).message, ...group.squashed].join("\n\n"); } else if (group.message != null) msg = group.message; if (msg != null) { const file = msgFile(messages.length); messages.push({ file, text: `${msg.trim()}\n` }); out.push(`exec git commit --amend --allow-empty -q -F ${shq(file)}`); } group = null; }; for (const e of entries) { if (e.action === "drop") { out.push(`drop ${e.sha}`); continue; } if (e.action === "pick" || e.action === "reword") { flush(); out.push(`pick ${e.sha}`); group = { sha: e.sha, message: e.action === "reword" ? e.message : null, squashed: [] }; continue; } // squash / fixup fold into the group before them. out.push(`fixup ${e.sha}`); if (e.action === "squash") { group.squashed.push(byShas.get(e.sha).message); if (e.message) group.squashMessage = e.message; } } flush(); return { todo: `${out.join("\n")}\n`, messages }; } /** * `rebase-interactive {base?, head, todo: [{sha, action, message?}], force?, * confirm: true}`: the list UI's plan, run without an editor. */ async function rebaseInteractive(dir, body) { if (body.confirm !== true) throw bad("ECONFIRM", "rebase-interactive requires confirm: true"); await assertRepoRoot(dir, "an interactive rebase"); await assertNoOperation(dir); if (await ops().isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes"); await assertHead(dir, body.head); const base = await interactiveBase(dir, typeof body.base === "string" ? body.base : ""); const range = [`${base}..HEAD`]; if ((await lines(dir, ["rev-list", "--merges", ...range])).length) { throw bad("EMERGECOMMIT", "the range holds a merge commit: rewrite it in a terminal"); } const commits = await commitList(dir, range); if (!commits.length) throw bad("ENOCHANGES", "no commit to rewrite"); const todo = Array.isArray(body.todo) ? body.todo : null; if (!todo || todo.length > MAX_TODO) throw bad("EBADTODO", "todo must list the commits"); const byShas = new Map(commits.map((c) => [c.sha, c])); const seen = new Set(); for (const e of todo) { if (!e || typeof e.sha !== "string" || !byShas.has(e.sha) || seen.has(e.sha)) { throw bad("EBADTODO", "the list no longer matches the branch: refresh and retry"); } seen.add(e.sha); if (!TODO_ACTIONS.has(e.action)) throw bad("EBADTODO", `unknown action ${e.action}`); if (e.message != null) { const { MAX_MESSAGE_LEN } = ops(); if (typeof e.message !== "string" || e.message.length > MAX_MESSAGE_LEN) { throw bad("EBADMESSAGE", "a commit message must be a string of at most 4000 characters"); } e.message = e.message.trim() || null; } if (e.action === "reword" && !e.message) throw bad("EBADMESSAGE", "reword needs a message"); } if (seen.size !== commits.length) { throw bad("EBADTODO", "the list no longer matches the branch: refresh and retry"); } const kept = todo.filter((e) => e.action !== "drop"); if (!kept.length) throw bad("EBADTODO", "every commit is dropped: use Reset instead"); if (kept[0].action === "squash" || kept[0].action === "fixup") { throw bad("EBADTODO", "the first commit kept has nothing before it to fold into"); } const unchanged = todo.every((e) => e.action === "pick") && todo.every((e, i) => e.sha === commits[i].sha); if (unchanged) throw bad("ENOCHANGES", "the list changes nothing"); const pushed = commits.filter((c) => c.pushed).length; if (pushed && body.force !== true) { throw bad( "EPUSHED", `${pushed} of these ${commits.length} commits are already pushed: rewriting them needs a force-push`, { pushed, total: commits.length } ); } const gd = await gitDir(dir); await cleanScratch(dir); const todoFile = path.join(gd, `${SCRATCH_PREFIX}todo`); const built = buildTodo(todo, byShas, (i) => path.join(gd, `${SCRATCH_PREFIX}msg-${i}`)); writeScratch(todoFile, built.todo); for (const m of built.messages) writeScratch(m.file, m.text); const before = await head(dir); return runRebase( dir, "rebase-interactive", ["rebase", "-i", base], { GIT_SEQUENCE_EDITOR: `${shq(cpPath())} -- ${shq(todoFile)}` }, { before, needsForcePush: pushed > 0 } ); } // ───────────────────────── reset / undo ───────────────────────── /** `{related, dropped, added}` moving HEAD to `target`. */ async function resetFacts(dir, target) { const isAnc = (a, b) => git(dir, ["merge-base", "--is-ancestor", a, b]).then( () => true, () => false ); const related = (await isAnc(target, "HEAD")) || (await isAnc("HEAD", target)); const { all: dropped, pushed } = await pushedOf(dir, [`${target}..HEAD`]); const added = await lines(dir, ["rev-list", `HEAD..${target}`]); return { related, dropped, pushed, added }; } /** * `reset {target, mode, head, confirm: true, force?, confirmBranch?}` — modes * soft / mixed (working tree untouched), keep (git refuses to overwrite a * local change), hard (clean tree only, branch name typed back). */ async function resetTo(dir, body) { if (body.confirm !== true) throw bad("ECONFIRM", "reset requires confirm: true"); const mode = body.mode; if (!["soft", "mixed", "keep", "hard"].includes(mode)) { throw bad("EBADMODE", "mode must be soft, mixed, keep or hard"); } await assertRepoRoot(dir, "a reset"); await assertNoOperation(dir); await assertHead(dir, body.head); const target = await ops().commitSha(dir, body.target); const facts = await resetFacts(dir, target); if (!facts.related) { throw bad("ENOTRELATED", "the target is neither an ancestor nor a descendant of HEAD"); } if (facts.pushed.length && body.force !== true) { throw bad( "EPUSHED", `${facts.pushed.length} of the commits this drops are already pushed: a force-push will be needed`, { pushed: facts.pushed.length, total: facts.dropped.length } ); } if (mode === "hard") { if (await ops().isDirty(dir)) { throw bad("EDIRTY", "a hard reset runs only on a clean tree; use keep to carry changes"); } const branch = (await currentBranch(dir)) || "HEAD"; if (body.confirmBranch !== branch) { throw bad("ECONFIRM", `type the branch name (${branch}) to confirm a hard reset`); } } const before = await head(dir); const r = await git(dir, ["reset", `--${mode}`, target]); return done("reset", r, { before, after: await head(dir), mode, needsForcePush: facts.pushed.length > 0, }); } /** `undo-rewrite {to, head, confirm: true}`: `reset --keep ` while HEAD is still `head`. */ async function undoRewrite(dir, body) { if (body.confirm !== true) throw bad("ECONFIRM", "undo-rewrite requires confirm: true"); await assertRepoRoot(dir, "an undo"); await assertNoOperation(dir); await assertHead(dir, body.head); const to = await ops().commitSha(dir, body.to); const before = await head(dir); // --keep: refuses rather than overwrite a local change git cannot carry. const r = await git(dir, ["reset", "--keep", to]); return done("undo-rewrite", r, { before, after: await head(dir) }); } // ───────────────────────── remote writes ───────────────────────── function allowProtectedForcePush() { return process.env.AGENTHUB_GIT_FORCE_PUSH_PROTECTED === "1"; } /** Where a force push of the current branch goes: `{branch, remote, remoteBranch, remoteSha}`. */ async function pushTarget(dir) { const branch = await currentBranch(dir); if (!branch) throw bad("EDETACHED", "HEAD is detached: switch to a branch first"); const remote = await ops().pushRemote(dir); const cfgRemote = (await lines(dir, ["config", "--get", `branch.${branch}.remote`]))[0]; const merge = (await lines(dir, ["config", "--get", `branch.${branch}.merge`]))[0] || ""; const remoteBranch = cfgRemote === remote && merge.startsWith("refs/heads/") ? merge.slice("refs/heads/".length) : branch; if (!(await isValidBranchName(dir, remoteBranch))) { throw bad("EBADBRANCH", "the upstream branch name is not valid"); } const remoteSha = ( await git(dir, ["rev-parse", "-q", "--verify", `refs/remotes/${remote}/${remoteBranch}`]).catch( () => ({ stdout: "" }) ) ).stdout.trim(); return { branch, remote, remoteBranch, remoteSha: remoteSha || null }; } /** `force-push {expect, confirm: true}`: HEAD with a lease on the sha the UI showed. */ async function forcePush(dir, body) { if (body.confirm !== true) throw bad("ECONFIRM", "force-push requires confirm: true"); const { PROTECTED, networkGit } = ops(); const t = await pushTarget(dir); if ((PROTECTED.has(t.branch) || PROTECTED.has(t.remoteBranch)) && !allowProtectedForcePush()) { throw bad("EPROTECTED", `force-pushing ${t.remoteBranch} is not allowed on this server`); } const expect = body.expect; if (!(expect === "" || (typeof expect === "string" && FULL_SHA.test(expect)))) { throw bad("EBADSHA", "expect must be the remote's full sha the dialog showed (or empty)"); } try { const r = await networkGit( dir, [ "push", "-u", `--force-with-lease=refs/heads/${t.remoteBranch}:${expect}`, t.remote, `HEAD:refs/heads/${t.remoteBranch}`, ], body ); return done("force-push", r, { remote: t.remote, branch: t.remoteBranch }); } catch (e) { if (/stale info|\(fetch first\)/.test(e.git?.stderr || "")) { throw bad("ESTALE", "the remote branch moved since you looked: fetch, check, and retry"); } throw e; } } /** `{remote, branch}` of a remote-tracking ref named `origin/x`, looked up. */ async function remoteRef(dir, name) { if (typeof name !== "string" || !name || name.startsWith("-")) { throw bad("EBADBRANCH", "branch must be a remote branch like origin/feature"); } const remotes = (await lines(dir, ["remote"])).sort((a, b) => b.length - a.length); const remote = remotes.find((r) => name.startsWith(`${r}/`)); const branch = remote ? name.slice(remote.length + 1) : ""; if (!remote || !branch || branch === "HEAD") throw bad("ENOBRANCH", `no remote branch ${name}`); const exists = await git(dir, ["show-ref", "--verify", "-q", `refs/remotes/${name}`]).then( () => true, () => false ); if (!exists) throw bad("ENOBRANCH", `no remote branch ${name}`); return { remote, branch }; } async function remoteDefault(dir, remote) { const ref = ( await git(dir, ["symbolic-ref", "-q", `refs/remotes/${remote}/HEAD`]).catch(() => ({ stdout: "", })) ).stdout.trim(); return ref.startsWith(`refs/remotes/${remote}/`) ? ref.slice(`refs/remotes/${remote}/`.length) : null; } /** `remote-branch-delete {branch: "origin/x", confirm: true}`. */ async function deleteRemoteBranch(dir, body) { if (body.confirm !== true) throw bad("ECONFIRM", "remote-branch-delete requires confirm: true"); const { PROTECTED, networkGit } = ops(); const { remote, branch } = await remoteRef(dir, body.branch); if (PROTECTED.has(branch)) throw bad("EPROTECTED", `branch ${branch} is protected`); if ((await remoteDefault(dir, remote)) === branch) { throw bad("EDEFAULTBRANCH", `${branch} is ${remote}'s default branch`); } const r = await networkGit(dir, ["push", remote, "--delete", `refs/heads/${branch}`], body); return done("remote-branch-delete", r, { remote, branch }); } async function remoteNames(dir) { return lines(dir, ["remote"]); } async function validRemoteName(dir, name, field = "name") { if (typeof name !== "string" || !REMOTE_NAME.test(name) || name.endsWith(".lock")) { throw bad("EBADREMOTE", `${field} must be a remote name (letters, digits, . _ -)`); } const ok = await git(dir, ["check-ref-format", `refs/remotes/${name}/x`]).then( () => true, () => false ); if (!ok) throw bad("EBADREMOTE", `${field} is not a valid remote name`); return name; } /** remote-add {name, url} / remote-rename {name, to} / remote-remove {name, confirm}. */ async function remoteAction(dir, action, body) { const names = await remoteNames(dir); const name = await validRemoteName(dir, body.name); if (action === "remote-add") { if (names.includes(name)) throw bad("EREMOTEEXISTS", `remote ${name} already exists`); const url = ops().validateRemoteUrl(body.url); return done(action, await git(dir, ["remote", "add", "--", name, url]), { remote: name }); } if (!names.includes(name)) throw bad("EBADREMOTE", `no remote ${name}`); if (action === "remote-rename") { const to = await validRemoteName(dir, body.to, "to"); if (names.includes(to)) throw bad("EREMOTEEXISTS", `remote ${to} already exists`); return done(action, await git(dir, ["remote", "rename", "--", name, to]), { remote: to }); } if (body.confirm !== true) throw bad("ECONFIRM", "remote-remove requires confirm: true"); return done(action, await git(dir, ["remote", "remove", "--", name]), { remote: name }); } // ───────────────────────── reads ───────────────────────── /** * A URL with no secret in it: an http(s) URL loses its whole userinfo (a token * is often typed as the user name), any other keeps only the user name * (`ssh://git@host`). */ function redactUrl(url) { return url.replace(/^([a-z][a-z0-9+.-]*:\/\/)([^@/]*)@/i, (_, s, info) => { const user = /^https?:/i.test(s) ? "" : info.split(":")[0]; return user ? `${s}${user}@` : s; }); } /** The repo's remotes: `{name, url, pushUrl, defaultBranch}`, passwords redacted. */ async function listRemotes(dir) { const out = []; for (const name of await remoteNames(dir)) { const url = (await lines(dir, ["remote", "get-url", "--", name]))[0] || ""; const pushUrl = (await lines(dir, ["remote", "get-url", "--push", "--", name]))[0] || url; const prefix = `refs/remotes/${name}/`; const branches = (await lines(dir, ["for-each-ref", "--format=%(refname)%00%(symref)", prefix])) .map((l) => l.split("\0")) .filter(([, symref]) => !symref) .map(([ref]) => ref.slice(prefix.length)); out.push({ name, url: redactUrl(url), pushUrl: redactUrl(pushUrl), defaultBranch: await remoteDefault(dir, name), branches, }); } return out; } /** * What a rewrite would do, for the dialog that asks before it: * kind=rebase&onto= — the commits replayed, which are pushed * kind=interactive[&base=] — the commits the list may rewrite * kind=reset&target= — the commits dropped/added, which are pushed * kind=push — where a force push goes and the lease sha * Every answer carries `head` (the sha a write must name) and `branch`. */ async function rewritePlan(dir, q) { const kind = q.kind; const h = await head(dir); const branch = (await currentBranch(dir)) || null; const subdir = Boolean((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()); const common = { kind, head: h, branch, subdir, operation: await operationInProgress(dir), dirty: await ops().isDirty(dir), }; if (kind === "rebase") { const onto = typeof q.onto === "string" ? q.onto.trim() : ""; if (!(await isValidBranchName(dir, onto))) throw bad("EBADBRANCH", "onto must be a branch"); const ref = await branchRef(dir, onto); if (!ref) throw bad("ENOBRANCH", `no branch ${onto}`); const commits = await commitList(dir, [`${ref}..HEAD`]); const behind = (await lines(dir, ["rev-list", `HEAD..${ref}`])).length; return { ...common, onto, commits, behind, pushed: commits.filter((c) => c.pushed).length }; } if (kind === "interactive") { const base = await interactiveBase(dir, typeof q.base === "string" ? q.base : ""); const range = [`${base}..HEAD`]; const merges = (await lines(dir, ["rev-list", "--merges", ...range])).length > 0; const commits = await commitList(dir, range); return { ...common, base, merges, commits, pushed: commits.filter((c) => c.pushed).length }; } if (kind === "reset") { const target = await ops().commitSha(dir, q.target); const f = await resetFacts(dir, target); return { ...common, target, related: f.related, dropped: f.dropped.length, added: f.added.length, pushed: f.pushed.length, }; } if (kind === "push") { const t = await pushTarget(dir); const ahead = t.remoteSha ? (await lines(dir, ["rev-list", `${t.remoteSha}..HEAD`])).length : 0; const behind = t.remoteSha ? (await lines(dir, ["rev-list", `HEAD..${t.remoteSha}`])).length : 0; const prot = PROTECTED_NAMES(t); return { ...common, ...t, ahead, behind, protected: prot, allowed: !prot || allowProtectedForcePush(), }; } throw bad("EBADMODE", "kind must be rebase, interactive, reset or push"); } function PROTECTED_NAMES(t) { const { PROTECTED } = ops(); return PROTECTED.has(t.branch) || PROTECTED.has(t.remoteBranch); } /** Run one action of ACTIONS. `open` is the operation git-ops.js already found. */ async function run(dir, action, body = {}) { if (action === "conflict-save") return require("./git-conflicts").saveConflict(dir, body); if (action === "rebase") return rebaseOnto(dir, body); if (action.startsWith("rebase-") && action !== "rebase-interactive") { return rebaseStep(dir, action, body); } if (action === "rebase-interactive") return rebaseInteractive(dir, body); if (action === "reset") return resetTo(dir, body); if (action === "undo-rewrite") return undoRewrite(dir, body); if (action === "force-push") return forcePush(dir, body); if (action === "remote-branch-delete") return deleteRemoteBranch(dir, body); return remoteAction(dir, action, body); } module.exports = { ACTIONS, STATUS, run, rewritePlan, listRemotes, redactUrl, buildTodo };