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:
+95
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user