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.
This commit is contained in:
+181
-20
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* @file The git WRITES the dashboard offers on a lane: fetch, pull, push,
|
||||
* stage/unstage a file, commit, discard a file, switch branch, and create or
|
||||
* delete a branch. Everything here is deliberately the boring half of git —
|
||||
* there is no merge, no rebase, no reset, no stash and no conflict
|
||||
* resolution, 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).
|
||||
* 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.
|
||||
@@ -18,11 +20,13 @@
|
||||
* `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.
|
||||
* 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` is the ONE irreversible action here — it throws away work git has
|
||||
* no copy of — so it additionally demands an explicit `confirm: true` in the
|
||||
* body. Do not add a second destructive action without the same gate.
|
||||
* `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.
|
||||
@@ -39,7 +43,8 @@ const {
|
||||
branchCheckedOutAt,
|
||||
statusCounts,
|
||||
} = require("./worktree");
|
||||
const { hunkPatch, laneStatus } = require("./git-diff");
|
||||
const { hunkPatch, laneStatus, CONFLICT_CODES } = require("./git-diff");
|
||||
const gitCredentials = require("./git-credentials");
|
||||
|
||||
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
|
||||
|
||||
@@ -61,8 +66,18 @@ const ACTIONS = new Set([
|
||||
"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"]);
|
||||
|
||||
@@ -159,6 +174,49 @@ async function resolvePaths(dir, body) {
|
||||
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.
|
||||
*
|
||||
@@ -172,18 +230,19 @@ async function resolvePaths(dir, body) {
|
||||
*/
|
||||
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 git(dir, ["fetch", "--all", "--prune"], { timeout: NETWORK_TIMEOUT_MS })
|
||||
);
|
||||
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 git(dir, ["pull", "--ff-only"], { timeout: NETWORK_TIMEOUT_MS }));
|
||||
return done(action, await networkGit(dir, ["pull", "--ff-only"], body));
|
||||
}
|
||||
|
||||
if (action === "push") {
|
||||
@@ -191,14 +250,19 @@ async function runGitAction(dir, action, body = {}) {
|
||||
// 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 git(dir, ["push", "-u", remote, "HEAD"], { timeout: NETWORK_TIMEOUT_MS })
|
||||
);
|
||||
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)]));
|
||||
@@ -336,6 +400,103 @@ async function runGitAction(dir, action, body = {}) {
|
||||
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() : "";
|
||||
|
||||
Reference in New Issue
Block a user