Files
AgentHub/server/lib/git-rewrite.js
T
nntrivi2001 2b5d7268ed feat(git): 3-way merge editor, reflog, rebase/interactive rebase, reset with undo, force-with-lease push, remote branches and remotes
Server: git-rewrite.js (rebase + continue/skip/abort, rebase-interactive with
no editor, reset soft/mixed/keep/hard, undo-rewrite, force-push with lease,
remote-branch-delete, remote add/rename/remove) and git-conflicts.js
(conflict stages read + conflict-save, reflog), dispatched from runGitAction;
operationInProgress now reports a rebase and only ALLOWED_WHILE_REBASING
writes run during one. New reads: /git/conflict, /git/reflog, /git/remotes,
/git/rewrite-plan. Client: History tools menu, MergeEditor, ReflogView,
RebaseDialog, InteractiveRebase, ResetDialog, ForcePushDialog, RemotesDialog,
rebase banner Skip. CLAUDE.md git-writes rule rewritten for the new actions.
2026-11-02 12:12:45 +07:00

773 lines
30 KiB
JavaScript

/**
* @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 <before>`,
* 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 <that file>` lines — no text from the request is ever shell.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
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 <file>` 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 <to>` 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=<branch> — the commits replayed, which are pushed
* kind=interactive[&base=<sha>] — the commits the list may rewrite
* kind=reset&target=<sha> — 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 };