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:
2026-09-25 10:44:40 +07:00
parent 605ca9d65e
commit f514602697
30 changed files with 2005 additions and 59 deletions
+54 -2
View File
@@ -585,6 +585,10 @@ cannot collide on `index.lock`.
| `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
| `merge` | `git merge --no-edit <ref>` — a local or remote-tracking branch that exists | `{"branch": "origin/main"}` |
| `resolve` | a conflicted file: `checkout --ours\|--theirs` + `add`, or `add` once hand-edited (`done`) | `{"path": "...", "side": "ours\|theirs\|done"}` |
| `merge-commit` | `git commit --no-edit` — concludes the merge | — |
| `merge-abort` | `git merge --abort` | `{"confirm": true}` |
```json
{ "action": "commit", "ok": true, "output": "[feat/x 9b3e74a] fix: the thing" }
@@ -592,8 +596,22 @@ cannot collide on `index.lock`.
- `pull` is **always** `--ff-only` and `switch` refuses a dirty tree
(`400 EDIRTY`): neither can invent a merge commit or a conflict underneath a
running agent. There is deliberately no merge, rebase, reset or stash here —
running agent. There is deliberately no rebase, reset or stash here —
`reset` stays behind the destroy guard on `POST /api/lanes/:id/reset`.
- **Merge** (issue #45) happens only when asked for by name: on a clean tree
(`400 EDIRTY`), from a lane at the repo root (`400 ESUBDIR` — a merge is
repo-wide), for a branch listed under `refs/heads` or `refs/remotes`
(`400 ENOBRANCH`). A merge that stops on conflicts is still a **200**, with
`conflicts: ["path", …]`; a clean one answers `conflicts: []`. While a merge
is in progress `pull`, `switch`, `merge`, `stage-hunk` and `discard-hunk` are
`409 EMERGING`, and `GET /api/lanes/:id/git` reports `merging: true` plus
`conflicts` (unmerged files, repo-wide). `resolve` accepts only a path git
reports as conflicted (`400 EBADPATH`) and a `side` of `ours`/`theirs`/`done`
(`400 EBADSIDE`); `done` and a plain `stage` of a conflicted file both refuse
while a conflict marker is left in it (`400 EMARKERS`). A side that deleted
the file resolves by removing it. `merge-commit` is `409 ECONFLICTS` while
anything is unmerged, and it and `merge-abort` are `409 ENOMERGE` with no
merge in progress. `merge-abort` needs `confirm: true` (`400 ECONFIRM`).
- `push` pushes `HEAD`, never a branch name from the request, so a lane can
only push the branch it is on.
- A **path** in the body is never trusted as a path: `stage`, `unstage` and
@@ -620,7 +638,7 @@ cannot collide on `index.lock`.
- Bad input is **400** (`EBADACTION`, `EBADMESSAGE`, `EBADBRANCH`, `EBADPATH`,
`ECONFIRM`, `EUNTRACKED`, `EPROTECTED`, `ENOBRANCH`, `EBRANCHEXISTS`, `EBRANCHBUSY`,
`EDIRTY`, `ENOCHANGES`, `ESTAGEDOUTSIDE`, `ENOREMOTE`); git itself exiting
non-zero (a rejected push, a credential failure, a diverged pull) is
non-zero (a rejected push, a diverged pull) is
**409 `EGIT`** with git's own `stderr` appended to `error.message` — a
rejection reads better in git's words. Anything else (git not installed, a
timeout kill) is **500**, still under a string code (`EGIT` when there is
@@ -631,6 +649,40 @@ cannot collide on `index.lock`.
pathspec would commit the working-tree copy (unstaged edits included).
- Network actions (`fetch`/`pull`/`push`) are killed after 60s so an unreachable
remote fails instead of holding the request open.
- **A remote that wants a login** (issue #45) is **409 `EGITAUTH`**, not
`EGIT`: the dashboard's git has no terminal to prompt in. The error carries
`auth: { protocol, host, username, rejected }` — `rejected: true` when a
login WAS sent and refused. Resend the same action with
`credentials: { protocol, host, username, password, remember }`: accepted
only for a host that is one of the repo's own remotes (`400 EBADHOST`
otherwise), used for that one command, and saved to AgentHub's store (below)
only after git succeeded AND `remember` is true. Empty or multi-line values
are `400 EBADCRED`.
#### Git credentials
```
GET /api/git-credentials Saved logins: { items: [{ protocol, host, username, savedAt, hasToken }] }
POST /api/git-credentials Save or replace — Body: { protocol, host, username, password }
DELETE /api/git-credentials/:protocol/:host Delete one (404 ENOTFOUND)
```
AgentHub's OWN git logins, one per `protocol + host`, shared by everyone using
this install; the **Git credentials** tab of Settings
(`/settings?tool=git-credentials`) and the git bar's login form both write
here. Stored in `<CLAUDE_HOME>/agenthub-git-credentials/credentials.json`
(dir `0700`, file `0600`). **No response ever carries a password.** Writes are
same-origin guarded; a bad protocol/host is `400 EBADHOST`, an empty login
`400 EBADCRED`.
A network action reaches git with `-c credential.helper= -c
credential.helper=<AgentHub's helper>` whenever AgentHub holds a login for one
of the repo's remote hosts (or the request carries one) — ONLY AgentHub's
helper, so a stale token in the machine's helper cannot win over the one just
typed, and git's post-success `store` cannot copy a use-once token into the
OS user's helper. Otherwise the command is unchanged and the machine's own
helpers answer as before. The secret never reaches argv: the helper reads the
store file, or a use-once login from its own process env.
#### Read a lane's runtime