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:
+54
-2
@@ -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
|
||||
|
||||
|
||||
+26
-3
@@ -552,12 +552,22 @@ the Changes panel carries the per-repo git **writes** the dashboard offers (`POS
|
||||
| **Create** | `git branch <name>` — never switches | the branch already exists |
|
||||
| **Delete** | `git branch -d <name>` | it is protected (`main`, `master`, `development`, `develop`), current, held by another worktree, or has unmerged commits |
|
||||
| **New lane here** | opens **Add lane** prefilled with this repo and the selected branch | — |
|
||||
| **Merge into current** | `git merge --no-edit <branch>` (after a confirm) | the tree is dirty, the lane is a subdirectory of its repo, or a merge is already in progress |
|
||||
| **Merge `<upstream>`** | the same, offered when the branch has diverged (ahead AND behind) | as above |
|
||||
|
||||
These sit in the panel rather than in the lane card's ⋯ menu deliberately: that menu holds
|
||||
the rare and dangerous (`reset worktree`), and these are the ordinary moves a
|
||||
lane needs several times an hour. The boring half of git is the whole list on
|
||||
purpose — there is no merge, rebase, reset or stash button, because a dashboard
|
||||
click is the wrong place to start an operation whose recovery needs a terminal.
|
||||
lane needs several times an hour. There is no rebase, reset or stash button,
|
||||
because a dashboard click is the wrong place to start an operation whose
|
||||
recovery needs a terminal. **Merge** is the one exception (issue #45), because
|
||||
its recovery is a button too: a merge that stops on conflicts puts a banner at
|
||||
the top of the Changes panel — **Complete merge** (enabled once nothing is
|
||||
conflicted) and **Abort merge** (two clicks; back to where the branch was) —
|
||||
and lists the conflicted files in their own **Merge conflicts** group with
|
||||
**Keep mine**, **Take theirs** and **Mark resolved** (after editing the
|
||||
`<<<<<<<`/`>>>>>>>` markers out in the file editor; refused while any are
|
||||
left). Pull and branch switch are disabled until the merge is finished or
|
||||
aborted.
|
||||
`pull --ff-only` and the dirty-tree guard on `switch` are what keep either from
|
||||
inventing a merge commit or a conflict underneath a running agent. **Create**
|
||||
deliberately does not switch: the usual reason to make a branch from a lane is
|
||||
@@ -571,6 +581,19 @@ what was staged.
|
||||
A failed action prints git's own `stderr` verbatim — `! [rejected] … (non-fast-forward)`
|
||||
says more than any sentence the dashboard could write.
|
||||
|
||||
The exception is a remote that wants a **login** (issue #45). The dashboard
|
||||
runs git without a terminal, so where a shell would prompt, the bar opens a
|
||||
form instead — "Git needs a login for `<host>`": username, password or access
|
||||
token (on Gitea: Settings → Applications → Generate token), and **Remember for
|
||||
everyone using this AgentHub** (on by default) — and resends the same action
|
||||
with it. A saved login lives in AgentHub's own store, one per host, shared by
|
||||
every user of the install, and is managed from Settings → **Git credentials**;
|
||||
it is saved only after git accepted it. While AgentHub holds a login for a
|
||||
repo's host, that login is the only one git is given for the command — the
|
||||
machine's own credential helpers are left out, so a stale token there cannot
|
||||
win. Over plain `http://` on a LAN address the form warns that the token
|
||||
crosses the network unencrypted. See `docs/API.md` § Git credentials.
|
||||
|
||||
### The Changes panel
|
||||
|
||||
To the right of the lane detail sits a collapsible **Changes** card (its state
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Git credentials for dashboard fetch/pull/push (issue #45, part A)
|
||||
|
||||
## Problem
|
||||
|
||||
The dashboard runs git with no terminal and `GIT_TERMINAL_PROMPT=0`. When no
|
||||
credential helper on the machine holds a credential for the remote's host, git
|
||||
wants to prompt, cannot, and fails:
|
||||
`fatal: could not read Username for 'https://git.smartgift.io.vn': terminal prompts disabled`.
|
||||
Reproduced with `git -c credential.helper= push --dry-run`.
|
||||
|
||||
## Decisions (approved 2026-09-25)
|
||||
|
||||
- AgentHub keeps its OWN credentials, shared by everyone using the AgentHub
|
||||
install (multi-user login is planned; accounts and settings are shared).
|
||||
- The dashboard asks for a credential when git needs one, then retries.
|
||||
- Merge and conflict resolution is part B and gets its own design.
|
||||
|
||||
## Design
|
||||
|
||||
**Store** — `server/lib/git-credentials.js`, one file
|
||||
`<CLAUDE_HOME>/agenthub-git-credentials/credentials.json` (dir 0700, file 0600,
|
||||
written through a temp file), one record per `protocol + host`:
|
||||
`{protocol, host, username, password, savedAt}`. No route returns `password`;
|
||||
the list reports `hasToken`. Routes: `GET /api/git-credentials`,
|
||||
`DELETE /api/git-credentials/:protocol/:host`.
|
||||
|
||||
**Delivery** — `server/lib/git-credential-helper.js`, a git credential helper
|
||||
speaking git's own protocol. For a network action, when AgentHub holds a
|
||||
credential for one of the repo's remote hosts, or the request carries one, git
|
||||
runs with `-c credential.helper= -c credential.helper=<ours>`: ONLY our helper.
|
||||
The machine's helpers are left out on purpose — a stale token there would win
|
||||
over the one just typed, and git's post-success `store` would copy a
|
||||
"use once" token into the OS user's own helper. With nothing from AgentHub for
|
||||
the host, the command is unchanged. The secret never reaches argv: the helper
|
||||
reads the store file, or `AGENTHUB_GIT_ONCE` in that one git process's env.
|
||||
|
||||
**Flow** — an auth failure (`could not read Username|Password`,
|
||||
`Authentication failed`, `terminal prompts disabled`, `Invalid username or
|
||||
password`) answers `409 EGITAUTH` with `{protocol, host, username?}`. The git
|
||||
bar shows a form (username, password/token, "remember for everyone on this
|
||||
AgentHub", on by default) and resends the action with
|
||||
`credentials: {protocol, host, username, password, remember}`. The server
|
||||
accepts it only for a host that is one of the repo's remotes, runs the action,
|
||||
and saves the credential only after git succeeded and `remember` is set.
|
||||
|
||||
**Limits** — one credential per host, so everyone on one AgentHub pushes as
|
||||
the same account. `fetch --all` over remotes on several hosts: a host with no
|
||||
AgentHub credential gets no machine helper either while one does. Over plain
|
||||
HTTP on a LAN the token crosses the network unencrypted; the form says so.
|
||||
|
||||
## Testing
|
||||
|
||||
Store: save/list/remove, 0600, list never carries the password, bad host
|
||||
refused. Helper: real `git credential fill` through our helper, stored and
|
||||
once. Route: a local HTTP remote answering 401 → `EGITAUTH` with the host; a
|
||||
resend with credentials reaches that server as Basic auth; nothing saved on
|
||||
failure. Client: the form appears on `EGITAUTH`, resends, never renders a token.
|
||||
Reference in New Issue
Block a user