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.
This commit is contained in:
2026-11-02 12:04:02 +07:00
parent 2cc492bb88
commit 2b5d7268ed
35 changed files with 5055 additions and 48 deletions
+95 -5
View File
@@ -1123,6 +1123,44 @@ each call spends the user's Claude quota. Answers `{"message": "..."}`;
for a path git does not report changed, `400 ENOTOOL` without `claude` on PATH,
`502 EAI` when the CLI fails, answers empty or runs past 120 s.
#### Conflicts, reflog, remotes and rewrite plans
```http
GET /api/lanes/:id/git/conflict?path=<path>
GET /api/lanes/:id/git/reflog?ref=HEAD|<branch>&skip=0&limit=50
GET /api/lanes/:id/git/remotes
GET /api/lanes/:id/git/rewrite-plan?kind=rebase&onto=<branch>
GET /api/lanes/:id/git/rewrite-plan?kind=interactive[&base=<sha>]
GET /api/lanes/:id/git/rewrite-plan?kind=reset&target=<sha>
GET /api/lanes/:id/git/rewrite-plan?kind=push
```
Reads (no same-origin guard), all taking `?session=`/`?source=`/`?repo=` like
the other git routes (`server/lib/git-conflicts.js`, `server/lib/git-rewrite.js`).
- **`/git/conflict`** — the 3-way merge editor's input for a path
`git ls-files -u` lists as unmerged right now (else `400 EBADPATH`), with
`/git/file`'s guards (`403 ESECRET`, 1 MB cap): `{operation, branch,
incoming: {sha, short, subject}|null, base, ours, theirs, working, sha,
usable, binary, tooBig}`. Each side is read by its blob id (stages 1/2/3); a
side git does not have is `null`. `sha` is the `baseSha` `conflict-save`
needs. In a rebase `ours` is the branch being rebased onto and `theirs` the
commit being replayed.
- **`/git/reflog`** — `git log -g` of HEAD or of a local branch that exists
(`400 EBADBRANCH`/`ENOBRANCH`), newest first: `{ref, more, entries:
[{index, selector, sha, short, action, message, time, subject}]}`.
- **`/git/remotes`** — `{remotes: [{name, url, pushUrl, defaultBranch,
branches}]}`; an http(s) URL loses its whole userinfo and any other keeps only
the user name, so no password or token is ever answered.
- **`/git/rewrite-plan`** — what a rewrite would do, for the dialog that asks
before it: every answer has `head` (the sha a write must name), `branch`,
`subdir`, `operation`, `dirty`; `rebase` adds `commits[]` (each with
`pushed`), `behind`, `pushed`; `interactive` adds `base`, `merges`,
`commits[]` (with full `message`), `pushed`; `reset` adds `target`,
`related`, `dropped`, `added`, `pushed`; `push` adds `remote`,
`remoteBranch`, `remoteSha` (the lease), `ahead`, `behind`, `protected`,
`allowed`. A bad `kind` is `400 EBADMODE`.
#### Act on a workspace's working copy
```http
@@ -1166,6 +1204,16 @@ cannot collide on `index.lock`.
| `unshelve` | `git stash apply --index <sha>` (retried without `--index` when only the index cannot be rebuilt), then `git stash drop` of that entry when it applied cleanly | `{"sha": "<shelf sha>"}` |
| `shelf-drop` | `git stash drop` of that entry | `{"sha": "<shelf sha>", "confirm": true}` |
| `ignore` | appends one anchored, escaped `/<path>` line per path to the WORKSPACE's own `.gitignore` (created if missing, never a line it already has) — untracked paths only (`400 ETRACKED`), never through a symlink or onto a non-regular file (`400 EBADPATH`) | `{"paths": ["..."]}` |
| `conflict-save` | the 3-way merge editor's result written over the conflicted file through `PUT /git/file`'s guards (`baseSha` → `409 ESTALE`, `403 ESECRET`), then `git add`; a result with a marker left is `400 EMARKERS` before anything is written, unless `resolve: false` (save progress, still conflicted) | `{"path": "...", "content": "...", "baseSha": "<sha from /git/conflict>", "resolve": true}` |
| `rebase` | `git rebase <ref>` (no editor, no autostash, no other branch moved) — a local or remote-tracking branch that exists | `{"onto": "main", "head": "<HEAD the plan showed>", "force": false}` |
| `rebase-continue` / `rebase-skip` | `git rebase --continue` (`409 ECONFLICTS` while anything is unmerged) / `--skip` | — |
| `rebase-abort` | `git rebase --abort` | `{"confirm": true}` |
| `rebase-interactive` | `git rebase -i <base>` with a todo AgentHub builds from the list — no editor opens | `{"head": "<HEAD>", "base": "<sha, optional>", "todo": [{"sha": "<full sha>", "action": "pick\|reword\|squash\|fixup\|drop", "message": "..."}], "force": false, "confirm": true}` |
| `reset` | `git reset --soft\|--mixed\|--keep\|--hard <target>` | `{"target": "<sha>", "mode": "keep", "head": "<HEAD>", "confirm": true, "force": false, "confirmBranch": "<branch, hard only>"}` |
| `undo-rewrite` | `git reset --keep <to>` — puts the branch back where a rewrite found it | `{"to": "<before>", "head": "<after>", "confirm": true}` |
| `force-push` | `git push -u --force-with-lease=refs/heads/<b>:<expect> <remote> HEAD:refs/heads/<b>` — `<b>` is the current branch's upstream branch | `{"expect": "<remote sha the plan showed, or \"\">", "confirm": true}` |
| `remote-branch-delete` | `git push <remote> --delete refs/heads/<branch>` — a fetched remote-tracking branch | `{"branch": "origin/feature", "confirm": true}` |
| `remote-add` / `remote-rename` / `remote-remove` | `git remote add -- <name> <url>` / `rename -- <name> <to>` / `remove -- <name>` | `{"name": "upstream", "url": "git@host:o/r.git"}` / `{"name": "...", "to": "..."}` / `{"name": "...", "confirm": true}` |
```json
{ "action": "commit", "ok": true, "output": "[feat/x 9b3e74a] fix: the thing" }
@@ -1184,9 +1232,8 @@ cannot collide on `index.lock`.
changed (`400 EBADPATH`).
- `pull` is **always** `--ff-only` and `switch` refuses a dirty tree
(`400 EDIRTY`) unless `changes` says what happens to the changes: neither
can invent a merge commit or a conflict underneath a running agent. There
is deliberately no rebase or hard reset here — a hard `reset` stays behind
the destroy guard on `POST /api/lanes/:id/reset`.
can invent a merge commit or a conflict underneath a running agent. A
rebase, a reset or a force push happens only as its own action (below).
- **Switch with changes.** `changes: "carry"` runs a plain `git switch`
(never `--force`, `--merge` or `--discard-changes`); when git refuses
because a carried file would be overwritten the answer is
@@ -1226,6 +1273,49 @@ cannot collide on `index.lock`.
`switch` that carries or shelves uncommitted changes answer
`409 EAGENTRUNNING` until resent with `confirm: true` — they move files
under it.
- **History rewrites** (`server/lib/git-rewrite.js`) are allowed because each
one's recovery is a button too. Every one runs only from a repo-root
workspace (`400 ESUBDIR`), never while a merge, cherry-pick, revert or rebase
is open (`409 EMERGING`), and names the full `head` sha the plan showed
(`409 ESTALE` once HEAD moved, `400 EBADSHA` without it). Rewriting a commit
any remote-tracking branch already holds is `409 EPUSHED` unless the body
says `force: true` (the UI shows that a force-push will be needed). Each
answers `before`/`after`; `undo-rewrite` (`reset --keep <before>`, only while
HEAD is still `after`) undoes it.
- `rebase` and `rebase-interactive` need a clean tree (`400 EDIRTY`). A stop
is a **200** with `conflicts[]` and `stopped: true`; while a rebase is open
`GET /git` reports `merging: true, operation: "rebase"`, every write except
`fetch`, `stage`, `unstage`, `discard`, `ignore`, `resolve`,
`conflict-save` and the rebase's own steps is `409 EMERGING`, and the
banner's `merge-commit` / `merge-abort` act as `rebase-continue` /
`rebase-abort` — a rebase started in a terminal included.
`rebase-continue`/`-skip`/`-abort` with no rebase open are `409 ENOREBASE`.
- `rebase-interactive`: `todo` lists EXACTLY the commits of `base..HEAD`
(default base: the parent of the oldest commit no remote has) by full sha,
each once, in the new order, else `400 EBADTODO`; a merge commit in the
range is `400 EMERGECOMMIT`; a list that changes nothing is
`400 ENOCHANGES`; a reword needs a message (`400 EBADMESSAGE`). AgentHub
writes the todo and the messages as 0600 files in the repo's own git dir
(as the folder's account), hands git `cp` as its sequence editor and `true`
as its editor; a squash becomes `fixup` + `exec git commit --amend -F <file>`
with the combined (or given) message, so no request text is ever shell.
- `reset`: `target` must be an ancestor or descendant of HEAD
(`400 ENOTRELATED`), `mode` one of soft/mixed/keep/hard (`400 EBADMODE`);
`hard` only on a clean tree (`400 EDIRTY`) and with `confirmBranch` equal to
the current branch name (`400 ECONFIRM`). `keep` refuses (git's own `409`)
rather than overwrite a local change.
- `force-push`: HEAD only, to its upstream branch, with a lease on `expect` —
`409 ESTALE` when the remote moved since the fetch the plan read;
`main`/`master`/`development`/`develop` are `400 EPROTECTED` unless the
server runs with `AGENTHUB_GIT_FORCE_PUSH_PROTECTED=1`; a detached HEAD is
`400 EDETACHED`.
- `remote-branch-delete` refuses a protected name (`400 EPROTECTED`) and the
remote's default branch (`refs/remotes/<r>/HEAD`, `400 EDEFAULTBRANCH`);
a branch not fetched is `400 ENOBRANCH`.
- `remote-add` takes its URL through `publish`'s allowlist (`400 EBADURL`)
and a name of letters, digits, `.`, `_`, `-` (`400 EBADREMOTE`); a taken
name is `400 EREMOTEEXISTS`; `remote-remove` needs `confirm: true`.
A stored git credential still goes only to its own host.
- **Cherry-pick and revert** follow merge: a commit named by hex sha that
exists (`400 EBADSHA`) and is not a merge commit (`400 EMERGECOMMIT`), a
clean tree, a repo-root workspace. A conflict is a **200** with
@@ -1235,8 +1325,8 @@ cannot collide on `index.lock`.
revert whose changes are already there is aborted again and answers
`409 EEMPTY`, never left open; one git could not commit (no identity, a
failing signer) is undone the same way and answers git's own error. A
rebase stopped in a terminal makes every history action, `merge-commit` and
`merge-abort` `409 EMERGING` — it is finished where it started.
rebase stopped in a terminal makes every history action `409 EMERGING`;
`merge-commit`/`merge-abort` then continue/abort that rebase.
- **Undo commit and amend** rewrite the last commit, so both are
`409 EPUSHED` once any remote-tracking branch contains HEAD, `409 ESTALE`
when HEAD is no longer the `sha` the request names (`400 EBADSHA` without
+44 -5
View File
@@ -540,10 +540,9 @@ the Changes panel carries the per-repo git **writes** the dashboard offers (`POS
These sit in the panel rather than in the workspace card's ⋯ menu deliberately: that menu holds
the rare and dangerous (`reset worktree`), and these are the ordinary moves a
workspace needs several times an hour. There is no rebase or reset button,
because a dashboard click is the wrong place to start an operation whose
recovery needs a terminal. **Shelves** and **Merge** are the exceptions,
because their recovery is a button too.
workspace needs several times an hour. An operation is offered here only when
its recovery is a button too: **Shelves**, **Merge** (with its 3-way merge
editor) and the **History tools** below.
**Shelves** (JetBrains' name; VS Code calls it stash) are `git stash` entries
AgentHub made, recognised by the message prefix `agenthub-shelf:` — a stash
@@ -580,7 +579,47 @@ opens the plain editor, for a block neither side gets right; marking resolved
is refused while any `<<<<<<<`/`>>>>>>>` marker is left. In the Files tree a
conflicted file is listed once (git indexes it once per side) and marked with
a red **!**. Pull and branch switch are disabled until the merge is finished or
aborted.
aborted. **Merge editor** in the conflict view opens the 3-way editor
(`client/src/components/lanes/git/MergeEditor.tsx`, for a merge, cherry-pick,
revert or rebase): **Incoming** | **Result** | **Current** side by side
(stacked on a phone), **Show base** for the common ancestor, and per conflict
block **Accept current**, **Accept incoming**, **Both** in either order or
**Edit**; ^/v jump to the next open block, "N of M conflicts left" counts
down, and **Edit whole result** turns the result into one text box. **Save
result** writes it over the version opened (`409 ESTALE` otherwise) and marks
the file resolved — refused while a marker is left. In a rebase *Current* is
the branch being rebased onto and *Incoming* the commit being replayed.
**History tools** (the clock button in the Changes panel's toolbar,
`client/src/components/lanes/git/GitTools.tsx`) — every rewrite shows what it
does before it runs ("This rewrites 4 commits; 2 are already pushed, so a
force-push will be needed" — pushed commits need an explicit tick), and a done
rewrite stays open with **Undo** (`reset --keep` back to where it started,
refused once the branch moved on):
- **Rebase onto…** — pick a local or fetched branch; a conflict stops the
rebase and the banner offers **Complete rebase** (= continue), **Skip
commit** and **Abort rebase** (two clicks). While a rebase is open every
other write but staging and resolving is refused — a rebase started in a
terminal included, which this banner can now finish too.
- **Edit unpushed commits…** (and **Edit commits after this one…** on a
History commit) — the interactive rebase as a list: arrows to reorder,
Keep / Reword / Squash into above / Fixup into above / Drop, a message box
for a reword or squash; Rewrite is two clicks. No editor ever opens: the
server writes the todo and messages itself.
- **Reset branch to here…** (a History commit, or a Reflog entry) — Soft,
Mixed (working tree untouched), Keep (refuses to overwrite a local change)
or Hard (only on a clean tree, with the branch name typed back).
- **Reflog** — read-only, HEAD or one branch, paged; a commit opens in the
panel and **Reset here…** brings a lost one back.
- **Force push (with lease)…** — the current branch to its upstream, only if
the remote is still at the sha last fetched; never `main`/`master`/
`development`/`develop` unless the server sets
`AGENTHUB_GIT_FORCE_PUSH_PROTECTED=1`.
- **Remotes…** — list (no password shown), add (the same URL allowlist as
**Publish…**), rename, remove (two clicks), and delete a branch on a remote
(two clicks; never its default branch nor a protected name).
`pull --ff-only` and the dirty-tree question on `switch` are what keep either
from inventing a merge commit or a conflict underneath a running agent, or
moving changes onto another branch nobody asked for. Plain **Create**