Files
AgentHub/server/lib/git-ops.js
T
nntrivi2001 f514602697 feat(lanes): git login from the dashboard, merge and conflict resolution (#45)
Part A — a fetch/pull/push against a remote that needs a login no longer
dies on "terminal prompts disabled": the server answers 409 EGITAUTH with
the host, the git bar asks for a username + password/access token and
resends the same action. AgentHub keeps its OWN credential store
(<CLAUDE_HOME>/agenthub-git-credentials/, 0700/0600), one login per host,
shared by every user of the install, managed from Settings -> Git
credentials. A login is only sent to one of the repo's own remote hosts,
saved only after git accepted it, never returned by any route, and reaches
git through an exclusive credential helper (never argv).

Part B — merge a branch (or the diverged upstream) from the git bar; a
merge that stops on conflicts lists them in the Changes panel with Keep
mine / Take theirs / Mark resolved, then Complete merge or Abort merge
(confirm gate). Clean tree, repo-root lane and an existing ref only;
pull/switch/hunk actions are blocked mid-merge; staging a conflicted file
with markers left is refused. Rebase/reset/stash stay out.
2026-09-25 10:44:40 +07:00

555 lines
23 KiB
JavaScript

/**
* @file The git WRITES the dashboard offers on a lane: fetch, pull, push,
* stage/unstage a file, commit, discard a file, switch branch, create or
* delete a branch, and merge a branch with its conflict resolution (issue
* #45: merge, resolve ours/theirs/hand-edited, merge-commit, merge-abort).
* There is no rebase, no reset and no stash, because a dashboard button is
* the wrong place to start an operation whose recovery needs a terminal
* (`reset` stays where it was: behind the three-check destroy guard in
* worktree.js). A merge is the exception because its recovery is ONE button
* here too — `merge-abort` puts the branch back where it was.
*
* Four rules hold for every action:
* - argv only, via worktree.js's `git()` — never a shell string.
* - the lane's own working directory is the only cwd, and its boundary:
* in a lane that is a subdirectory of its repo, paths are cwd-relative
* and nothing outside the cwd can be named or swept into a commit.
* - every path is a LITERAL file name, never a pathspec glob — `new*.js`
* must not also discard new1.js (`literal: true` on every call).
* - a caller-supplied path is never trusted as a path: it must be one
* `git status` is reporting as changed right now (`resolvePaths`), so no
* `..`, no absolute path and no untouched file is reachable.
* - `pull` is `--ff-only` and `switch` demands a clean tree, so neither can
* invent a merge commit or a conflict underneath a running agent. A merge
* happens only when asked for by name (`merge`), on a clean tree, from a
* lane that IS the repo root (a merge is repo-wide), one at a time.
*
* `discard`, `discard-hunk` and `merge-abort` throw away work git has no copy
* of, so each demands an explicit `confirm: true` in the body. Do not add
* another destructive action without the same gate.
*
* Network actions carry a timeout: a lane pointed at an unreachable remote
* should fail in half a minute, not hold an HTTP request open.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
git: rawGit,
isValidBranchName,
listBranches,
branchCheckedOutAt,
statusCounts,
} = require("./worktree");
const { hunkPatch, laneStatus, CONFLICT_CODES } = require("./git-diff");
const gitCredentials = require("./git-credentials");
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
/** How long a fetch/pull/push may run before it is killed. */
const NETWORK_TIMEOUT_MS = 60_000;
/** Longest commit message accepted — a subject+body, not a pasted file. */
const MAX_MESSAGE_LEN = 4000;
const ACTIONS = new Set([
"fetch",
"pull",
"push",
"commit",
"switch",
"stage",
"unstage",
"discard",
"stage-hunk",
"discard-hunk",
"branch-create",
"branch-delete",
"merge",
"resolve",
"merge-commit",
"merge-abort",
]);
/** Actions that would tangle with a merge in progress. */
const BLOCKED_WHILE_MERGING = new Set(["pull", "switch", "merge", "stage-hunk", "discard-hunk"]);
/** A line git writes to open or close a conflict block. */
const MARKER_RE = /^(<{7} |>{7} |={7}$)/m;
/** Branches this module refuses to delete, whatever the request says. */
const PROTECTED = new Set(["main", "master", "development", "develop"]);
function bad(code, message) {
const err = new Error(message);
err.code = code;
return err;
}
/** Whether the working tree has anything uncommitted (tracked or not). */
async function isDirty(dir) {
const { dirty, untracked } = await statusCounts(dir);
return dirty + untracked > 0;
}
/**
* The paths a per-file action may name — exactly the ones `git status` is
* reporting as changed right now, keyed by path → `{code, from}` (`from` is a
* rename's original path). `-z`, so a path git would C-quote (non-ASCII, a
* space, a quote) matches the literal path the panel sends.
*
* This is the whole authorization story for `stage`/`unstage`/`discard`: the
* request cannot reach a path git is not already talking about, so no `..`,
* no absolute path, and no untouched file can be deleted by a typo. A path
* that is not in this map is a 400, never a silent no-op.
*/
async function changedPaths(dir) {
const map = new Map();
for (const { code, path, from } of await laneStatus(dir)) map.set(path, { code, from });
return map;
}
/**
* The remote a push goes to — never a name from the request: the current
* branch's configured upstream remote, else the ONLY remote, else `origin`.
* Anything else is ambiguous, so ENOREMOTE rather than a guess.
*/
async function pushRemote(dir) {
const remotes = (await git(dir, ["remote"])).stdout.split("\n").filter(Boolean);
const branch = (
await git(dir, ["symbolic-ref", "--short", "-q", "HEAD"]).catch(() => ({ stdout: "" }))
).stdout.trim();
if (branch) {
const upstream = (
await git(dir, ["config", "--get", `branch.${branch}.remote`]).catch(() => ({ stdout: "" }))
).stdout.trim();
if (remotes.includes(upstream)) return upstream;
}
if (remotes.length === 1) return remotes[0];
if (remotes.includes("origin")) return "origin";
throw bad(
"ENOREMOTE",
remotes.length
? "several remotes and no upstream or origin: set this branch's upstream first"
: "this repository has no remote to push to"
);
}
/** Throw ESTAGEDOUTSIDE when the index holds a change outside the lane's cwd. */
async function assertNothingStagedOutside(dir) {
const prefix = (await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim();
if (!prefix) return; // the lane IS the repo root: nothing is outside it
// No --relative: root-relative names, so outside entries are visible.
// --no-renames: a staged move INTO the lane is listed as its new path only
// otherwise, and the deletion of the old path outside would slip through.
const staged = (await git(dir, ["diff", "--cached", "--name-only", "--no-renames", "-z"])).stdout
.split("\0")
.filter(Boolean);
const outside = staged.find((p) => !p.startsWith(prefix));
if (outside) {
throw bad(
"ESTAGEDOUTSIDE",
`refusing to commit: ${outside} is staged outside this lane's directory`
);
}
}
/** Validate `body.paths` against what git reports as changed. */
async function resolvePaths(dir, body) {
const wanted = Array.isArray(body.paths)
? body.paths
: typeof body.path === "string"
? [body.path]
: [];
if (!wanted.length) throw bad("EBADPATH", "at least one path is required");
if (wanted.some((p) => typeof p !== "string" || !p)) {
throw bad("EBADPATH", "every path must be a non-empty string");
}
const changed = await changedPaths(dir);
const unknown = wanted.filter((p) => !changed.has(p));
if (unknown.length) {
throw bad("EBADPATH", `not a changed path: ${unknown[0]}`);
}
return wanted.map((p) => ({ path: p, ...changed.get(p) }));
}
/**
* A fetch/pull/push, authenticated with AgentHub's own git credentials
* (git-credentials.js): a stored one for the remote's host, or
* `body.credentials` typed for this one call. A login failure becomes
* EGITAUTH carrying the host, which the git bar turns into a login form; a
* typed credential is saved only after git accepted it and only if asked.
*/
async function networkGit(dir, args, body = {}) {
const typed = body.credentials && typeof body.credentials === "object" ? body.credentials : null;
const cfg = await gitCredentials.gitConfigFor(dir, typed);
try {
const out = await git(dir, [...cfg.args, ...args], {
timeout: NETWORK_TIMEOUT_MS,
env: cfg.env,
});
if (typed?.remember) gitCredentials.save(typed);
return out;
} catch (e) {
const auth = gitCredentials.authFailure(e.git?.stderr);
if (!auth) throw e;
const remote = (await gitCredentials.remoteHosts(dir)).find(
(h) => h.protocol === auth.protocol && h.host === auth.host
);
const stored = gitCredentials.find(auth.protocol, auth.host);
const wrong = Boolean(typed) || Boolean(stored);
throw Object.assign(
new Error(
wrong
? `${auth.host} rejected the username or token`
: `${auth.host} needs a username and password or access token`
),
{
code: "EGITAUTH",
auth: {
...auth,
username: typed?.username || stored?.username || remote?.username || null,
rejected: wrong,
},
}
);
}
}
/**
* Run one action against a lane's working directory.
*
* @param {string} dir The lane's cwd.
* @param {string} action One of ACTIONS.
* @param {object} [body] Action input: `{message}` for commit,
* `{branch, create}` for switch. Ignored otherwise.
* @returns {Promise<{action, ok, output, branch?}>} `output` is git's own
* stdout+stderr, shown verbatim — a push rejection or a credential failure
* says more in git's words than in ours.
*/
async function runGitAction(dir, action, body = {}) {
if (!ACTIONS.has(action)) throw bad("EBADACTION", `unknown git action ${action}`);
if (BLOCKED_WHILE_MERGING.has(action) && (await isMerging(dir))) {
throw bad("EMERGING", "a merge is in progress: finish or abort it first");
}
if (action.startsWith("merge") || action === "resolve") return mergeAction(dir, action, body);
if (action === "fetch") {
return done(action, await networkGit(dir, ["fetch", "--all", "--prune"], body));
}
if (action === "pull") {
// --ff-only on purpose: a divergent branch is reported, never merged. The
// user (or the agent in the pane) decides how to reconcile it.
return done(action, await networkGit(dir, ["pull", "--ff-only"], body));
}
if (action === "push") {
// HEAD, not a branch name from the request: the lane pushes the branch it
// is actually on, and -u makes the first push set the upstream the
// ahead/behind counts then read.
const remote = await pushRemote(dir);
return done(action, await networkGit(dir, ["push", "-u", remote, "HEAD"], body));
}
if (action === "stage") {
const paths = await resolvePaths(dir, body);
// Staging a conflicted file marks it resolved: not while git's markers
// are still in it (the same check `resolve` makes).
for (const p of paths.filter((p) => CONFLICT_CODES.has(p.code))) {
const file = path.join(dir, p.path);
if (fs.existsSync(file) && MARKER_RE.test(await fs.promises.readFile(file, "utf8"))) {
throw bad("EMARKERS", `${p.path} still has conflict markers`);
}
}
// `add` covers every case the panel can show: a modification, a new file
// and a deletion all stage with the same verb.
return done(action, await git(dir, ["add", "--", ...paths.map((p) => p.path)]));
}
if (action === "unstage") {
const paths = await resolvePaths(dir, body);
const untracked = paths.filter((p) => p.code === "A " || p.code === "AM");
// An intent-to-add/new file has nothing in HEAD to restore from, so
// `restore --staged` would fail on the very first commit of a repo.
const args =
untracked.length === paths.length && !(await hasHead(dir))
? ["rm", "--cached", "-r", "--", ...paths.map((p) => p.path)]
: // A staged rename's ORIGINAL path rides along, as in `discard`:
// restoring only the new one leaves the old path's deletion staged.
[
"restore",
"--staged",
"--",
...paths.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path])),
];
return done(action, await git(dir, args));
}
if (action === "discard") {
if (body.confirm !== true) throw bad("ECONFIRM", "discard requires confirm: true");
const paths = await resolvePaths(dir, body);
const untracked = paths.filter((p) => p.code === "??").map((p) => p.path);
// A rename's ORIGINAL path rides along: restoring only the new one would
// drop it from the index and the tree, and leave the old one deleted.
const tracked = paths
.filter((p) => p.code !== "??")
.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]));
const out = [];
// Untracked files are deleted (git has no copy to restore); tracked ones
// go back to HEAD in both gutters, so a staged-then-edited file lands in
// one state rather than half-reverted.
if (untracked.length) out.push(await git(dir, ["clean", "-fd", "--", ...untracked]));
if (tracked.length) {
out.push(
await git(dir, ["restore", "--source=HEAD", "--staged", "--worktree", "--", ...tracked])
);
}
return done(action, mergeResults(out));
}
if (action === "stage-hunk" || action === "discard-hunk") {
// Undoing a hunk is as irreversible as discarding a file: same gate.
if (action === "discard-hunk" && body.confirm !== true) {
throw bad("ECONFIRM", "discard-hunk requires confirm: true");
}
const [target] = await resolvePaths(dir, { path: body.path });
if (target.code === "??") {
throw bad("EUNTRACKED", "an untracked file has no hunks; stage or discard the whole file");
}
const patch = await hunkPatch(
dir,
target.path,
typeof body.hunk === "string" ? body.hunk : "",
body.context
);
if (!patch) throw bad("EHUNKGONE", "that change is no longer in the diff; refresh and retry");
// The patch names paths from the repo root, so it is applied there.
const top = (await git(dir, ["rev-parse", "--show-toplevel"])).stdout.trim();
const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), "agenthub-hunk-"));
const file = path.join(tmp, "hunk.patch");
try {
await fs.promises.writeFile(file, patch);
const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : [];
// stage: HEAD -> worktree hunk onto the index; undo: the same hunk,
// reversed, off the working tree. A hunk the index or tree no longer
// matches fails in git's own words (409), never half-applies.
const args =
action === "stage-hunk" ? ["apply", "--cached", ...zero] : ["apply", "-R", ...zero];
return done(action, await git(top, [...args, file]));
} finally {
await fs.promises.rm(tmp, { recursive: true, force: true });
}
}
if (action === "commit") {
const message = typeof body.message === "string" ? body.message.trim() : "";
if (!message) throw bad("EBADMESSAGE", "commit message is required");
if (message.length > MAX_MESSAGE_LEN) throw bad("EBADMESSAGE", "commit message is too long");
// `git commit` takes the WHOLE index, whatever the cwd, and a pathspec
// would switch it to committing the working-tree copy (unstaged edits
// included) — so a subdirectory lane refuses rather than sweep another
// actor's staged work outside it into this lane's commit.
await assertNothingStagedOutside(dir);
if (body.only === "staged") {
// The panel's per-file staging only means something if a commit can
// take the index as it stands — no `add -A` sweeping in the rest.
const staged = [...(await changedPaths(dir)).values()].some(
({ code }) => code[0] !== " " && code[0] !== "?"
);
if (!staged) throw bad("ENOCHANGES", "nothing staged to commit");
} else {
// The LANE's status, not the repo's: `add -A -- .` below only takes the
// lane's files, so a clean subdirectory lane in a repo dirty elsewhere
// has nothing to commit — not a git failure after the fact.
if (!(await laneStatus(dir)).length) throw bad("ENOCHANGES", "nothing to commit");
// `-- .`: the lane's own directory, not the whole repo it sits in.
await git(dir, ["add", "-A", "--", "."]);
}
// -m takes the message as one argv element, so a message containing
// newlines, quotes or a leading dash is content, never syntax.
return done(action, await git(dir, ["commit", "-m", message]));
}
if (action === "branch-create" || action === "branch-delete") {
return branchAction(dir, action, body);
}
// switch
const branch = typeof body.branch === "string" ? body.branch.trim() : "";
if (!branch) throw bad("EBADBRANCH", "branch is required");
if (!(await isValidBranchName(dir, branch))) {
throw bad("EBADBRANCH", "branch is not a valid git branch name");
}
if (await isDirty(dir)) {
// git would refuse anyway for conflicting files, but it happily carries
// unrelated edits across a switch — which silently moves an agent's
// half-finished work onto another branch. Refuse the whole case.
throw bad("EDIRTY", "working tree has uncommitted changes");
}
const { branches } = await listBranches(dir);
const exists = branches.includes(branch);
if (body.create) {
if (exists) throw bad("EBRANCHEXISTS", `branch ${branch} already exists`);
return done(action, await git(dir, ["switch", "-c", branch]), branch);
}
if (!exists) throw bad("ENOBRANCH", `branch ${branch} does not exist locally`);
const busyAt = await branchCheckedOutAt(dir, branch);
if (busyAt) throw bad("EBRANCHBUSY", `branch ${branch} is checked out at ${busyAt}`);
return done(action, await git(dir, ["switch", branch]), branch);
}
/** Whether a merge is in progress (MERGE_HEAD exists). */
async function isMerging(dir) {
try {
await git(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
return true;
} catch {
return false;
}
}
/** Paths git still holds as unmerged, repo-wide. */
async function unmergedPaths(dir) {
return (await git(dir, ["diff", "--name-only", "--diff-filter=U", "-z"])).stdout
.split("\0")
.filter(Boolean);
}
/** merge / resolve / merge-commit / merge-abort. */
async function mergeAction(dir, action, body) {
if (action === "merge") {
const branch = typeof body.branch === "string" ? body.branch.trim() : "";
if (!(await isValidBranchName(dir, branch))) {
throw bad("EBADBRANCH", "branch is not a valid git branch name");
}
// A merge moves the whole repo, so only a lane that IS the repo may start
// one — a subdirectory lane could not see, let alone resolve, a conflict
// outside its own folder.
if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) {
throw bad(
"ESUBDIR",
"a merge changes the whole repository; run it from a lane at the repo root"
);
}
if (await isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes");
// The ref is looked up, never passed through: git sees the full refname
// of a local or remote-tracking branch that exists, nothing else.
const refs = (
await git(dir, [
"for-each-ref",
"--format=%(refname:short)%00%(refname)",
"refs/heads",
"refs/remotes",
])
).stdout
.split("\n")
.filter(Boolean)
.map((l) => l.split("\0"));
const ref = refs.find(([short]) => short === branch)?.[1];
if (!ref) throw bad("ENOBRANCH", `no branch ${branch}`);
try {
const out = await git(dir, ["merge", "--no-edit", ref]);
return { ...done(action, out, branch), conflicts: [] };
} catch (e) {
// A conflict is a merge that STARTED: report the files, not a failure.
if (!(await isMerging(dir))) throw e;
return {
...done(action, e.git || { stdout: "", stderr: "" }, branch),
conflicts: await unmergedPaths(dir),
};
}
}
if (action === "resolve") {
const [target] = await resolvePaths(dir, { path: body.path });
if (!CONFLICT_CODES.has(target.code))
throw bad("EBADPATH", `not a conflicted path: ${target.path}`);
const side = body.side;
if (!["ours", "theirs", "done"].includes(side))
throw bad("EBADSIDE", "side must be ours, theirs or done");
const p = target.path;
const gone =
(side === "ours" && target.code[0] === "D") || (side === "theirs" && target.code[1] === "D");
if (side !== "done" && !gone) {
await git(dir, ["checkout", `--${side}`, "--", p]);
return done(action, await git(dir, ["add", "--", p]));
}
if (side === "done" && fs.existsSync(path.join(dir, p))) {
if (MARKER_RE.test(await fs.promises.readFile(path.join(dir, p), "utf8"))) {
throw bad("EMARKERS", `${p} still has conflict markers`);
}
return done(action, await git(dir, ["add", "--", p]));
}
// The chosen side deleted it (or the hand edit did): resolved as gone.
return done(action, await git(dir, ["rm", "-q", "--", p]));
}
if (action === "merge-abort" && body.confirm !== true) {
throw bad("ECONFIRM", "merge-abort requires confirm: true");
}
if (!(await isMerging(dir))) throw bad("ENOMERGE", "no merge in progress");
if (action === "merge-abort") return done(action, await git(dir, ["merge", "--abort"]));
// merge-commit
const left = await unmergedPaths(dir);
if (left.length) throw bad("ECONFLICTS", `${left.length} file(s) still in conflict: ${left[0]}`);
return done(action, await git(dir, ["commit", "--no-edit"]));
}
/** Create or delete a branch WITHOUT touching the working tree. */
async function branchAction(dir, action, body) {
const branch = typeof body.branch === "string" ? body.branch.trim() : "";
if (!branch) throw bad("EBADBRANCH", "branch is required");
if (!(await isValidBranchName(dir, branch))) {
throw bad("EBADBRANCH", "branch is not a valid git branch name");
}
const { branches, current } = await listBranches(dir);
const exists = branches.includes(branch);
if (action === "branch-create") {
if (exists) throw bad("EBRANCHEXISTS", `branch ${branch} already exists`);
const base = typeof body.base === "string" ? body.base.trim() : "";
if (base && !(await isValidBranchName(dir, base))) {
throw bad("EBADBRANCH", "base is not a valid git ref name");
}
// No switch: creating a branch to spawn a sibling worktree lane from must
// not move the branch the agent in this pane is standing on.
return done(action, await git(dir, ["branch", branch, ...(base ? [base] : [])]), branch);
}
if (!exists) throw bad("ENOBRANCH", `branch ${branch} does not exist locally`);
if (PROTECTED.has(branch)) throw bad("EPROTECTED", `branch ${branch} is protected`);
if (branch === current) throw bad("EBRANCHBUSY", `branch ${branch} is the current branch`);
const busyAt = await branchCheckedOutAt(dir, branch);
if (busyAt) throw bad("EBRANCHBUSY", `branch ${branch} is checked out at ${busyAt}`);
// -d, never -D: git refuses to drop unmerged commits, and that refusal is
// exactly the safety net a dashboard button needs. Force-deleting is a
// terminal operation.
return done(action, await git(dir, ["branch", "-d", branch]), branch);
}
/** Whether the repo has any commit yet. */
async function hasHead(dir) {
try {
await git(dir, ["rev-parse", "--verify", "HEAD"]);
return true;
} catch {
return false;
}
}
function mergeResults(results) {
return {
stdout: results.map((r) => r.stdout).join(""),
stderr: results.map((r) => r.stderr).join(""),
};
}
function done(action, result, branch) {
const output = [result.stdout, result.stderr].filter(Boolean).join("").trim();
return { action, ok: true, output, ...(branch ? { branch } : {}) };
}
module.exports = { runGitAction, ACTIONS, PROTECTED, NETWORK_TIMEOUT_MS };