feat(git): switch with uncommitted changes (carry / shelve), Create & switch, and shelves with Restore/Delete

- switch takes changes: carry (plain git switch, 409 ECARRYCONFLICT with files) or shelve (shelf first, re-applied if the switch fails)
- branch-create switch: true runs git switch -c, the changes come along
- shelve / unshelve / shelf-drop + GET /git/shelves: only agenthub-shelf: stash entries, addressed by sha, repo root only, never mid-operation; a clean restore drops the shelf, a conflicted one keeps it
- 409 EAGENTRUNNING until confirm: true while the workspace's agent is working
- git bar: Bring my changes / Shelve and switch choice, Create & switch, restore offer after a switch; Changes panel: Shelve (n) and the Shelves list
- fix: after a switch the Changes panel showed the old branch — the post-write re-read joined an in-flight GET started before the write (request() dedupe); writes now close in-flight GETs, and every git write fires agenthub:git-changed so every view of the workspace re-reads
This commit is contained in:
2026-11-02 11:43:51 +07:00
parent 2b26fe1efa
commit f6eb39eb73
22 changed files with 2005 additions and 77 deletions
+50 -7
View File
@@ -1126,8 +1126,8 @@ cannot collide on `index.lock`.
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` — or, with `block`, of only that hunk's `block`-th run of +/- lines (the other runs folded into context, as an edited `git add -p` hunk) | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "block": 0, "context": 3}` |
| `discard-hunk` | `git apply -R` of that one hunk (or that one `block` of it), off the working tree | `{"path": "...", "hunk": "@@ ... @@", "block": 0, "context": 3, "confirm": true}` |
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"`, or — with `paths` — `git commit --only -- <paths>`: exactly those files' working-tree content (an untracked one is added first, and taken back out if the commit fails), every other change left as it was | `{"message": "...", "only": "staged"}` / `{"message": "...", "paths": ["..."]}` |
| `switch` | `git switch [-c] <branch>`; a branch only a remote has (`origin/feat/y`, or bare `feat/y` when exactly one remote has it — else `400 EAMBIGUOUS`) becomes `git switch -c feat/y --track refs/remotes/origin/feat/y`, the remote ref looked up among the fetched ones | `{"branch": "...", "create": false}` |
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
| `switch` | `git switch [-c] <branch>`; a branch only a remote has (`origin/feat/y`, or bare `feat/y` when exactly one remote has it — else `400 EAMBIGUOUS`) becomes `git switch -c feat/y --track refs/remotes/origin/feat/y`, the remote ref looked up among the fetched ones. A dirty tree needs `changes`: `carry` (a plain `git switch` that brings the changes along) or `shelve` (shelve them, then switch) | `{"branch": "...", "create": false, "changes": "carry\|shelve", "note": "..."}` |
| `branch-create` | `git branch <branch> [<base>]` — or, with `switch: true`, `git switch -c <branch> [<base>]`, the uncommitted changes coming along | `{"branch": "...", "base": "...", "switch": true}` |
| `branch-delete` | `git branch -d <branch>` | `{"branch": "..."}` |
| `publish` | `git remote add origin -- <url>` then `git push -u origin HEAD` — only when the repo has NO remote | `{"url": "https://host/owner/repo.git"}` |
| `merge` | `git merge --no-edit -m "Merge branch '<branch>'[ into <current>]" <ref>` — a local or remote-tracking branch that exists; the subject is git's own, never the full refname | `{"branch": "origin/main"}` |
@@ -1138,6 +1138,9 @@ cannot collide on `index.lock`.
| `revert` | `git revert --no-edit <sha>` — same guards as `cherry-pick` | `{"sha": "9b3e74a"}` |
| `undo-commit` | `git reset --soft HEAD~1` — only an UNPUSHED, non-merge, non-root HEAD that is still `sha`; its changes stay staged | `{"confirm": true, "sha": "<HEAD the user saw>"}` |
| `amend` | `git commit --amend -m <message>`, or `--no-edit` with no message — only an UNPUSHED HEAD that is still `sha`; takes the index as it stands, or with `paths` exactly those files (`--only`; an empty list rewords and needs a message) | `{"message": "...", "sha": "<HEAD>", "paths": ["..."]}` |
| `shelve` | `git stash push --include-untracked -m "agenthub-shelf: <branch> · <ISO time>[ · <note>]" [-- <paths>]` — everything, or exactly the ticked changed `paths` | `{"note": "...", "paths": ["..."]}` |
| `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": ["..."]}` |
```json
@@ -1156,9 +1159,49 @@ cannot collide on `index.lock`.
a merge is finished by `merge-commit` — and any path git does not report
changed (`400 EBADPATH`).
- `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 rebase, hard reset or stash here —
a hard `reset` stays behind the destroy guard on `POST /api/lanes/:id/reset`.
(`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`.
- **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
`409 ECARRYCONFLICT` with `files: ["…"]` (git's own list), and nothing has
moved. `changes: "shelve"` shelves the changes (as `shelve` below, with an
optional `note`) and then switches; if the switch fails the shelf is
restored at once and git's error is returned. Every check that needs no
switch attempt (`ENOBRANCH`, `EBRANCHBUSY`, …) runs before the shelf is
made. Any other `changes` value is `400 EBADCHANGES`. A successful switch
answers `shelves: [...]` — the TARGET branch's shelves, which the UI offers
back (asking, never applying one itself) — and `shelved: {...}` when it
made one. `branch-create` with `switch: true` is `409 EMERGING` while a
merge, cherry-pick, revert or rebase is open, and `409 ECARRYCONFLICT` if a
`base` other than HEAD clashes with the changes.
- **Shelves** are git stash entries AgentHub made, recognised by the message
prefix `agenthub-shelf: ` — any other stash is the user's and is never
listed or touched. They are listed by `GET /api/lanes/:id/git/shelves`
(`{available, shelves: [{ref, sha, branch, note, createdAt, files: [{path,
status}]}]}`, newest first; untracked files as `??`) and addressed in
writes by `sha`, the stash commit, never by `stash@{n}` (a terminal stash
would shift it); an abbreviation works, a sha no shelf has is
`409 ENOSHELF`. A stash is repo-wide, so `shelve`, `unshelve` and
`shelf-drop` need a workspace at the repo root (`400 ESUBDIR`), and
`shelve`/`unshelve` are `409 EMERGING` during a merge, cherry-pick, revert
or rebase. `shelve` on a clean tree is `400 ENOCHANGES`; its `paths` go
through the same `git status` check as `discard`. `unshelve` refuses a
dirty tree (`400 EDIRTY`) so a restore never mixes with new work; a clean
apply drops the entry (`kept: false`), a conflicted one KEEPS it and
answers `conflicts: [...]`, `kept: true` — the files are resolved with
`resolve` (`ours` = the branch, `theirs` = the shelf; there is no merge to
commit afterwards, the result is an uncommitted change) and the shelf is
deleted by hand. `shelf-drop` is irreversible and needs `confirm: true`
(`400 ECONFIRM`). Nothing else ever drops a shelf.
- **A working agent.** When an agent of the workspace is working (a lane no
pane holds: `status: running` from its hooks; a lane a pane holds: one of
its own sessions has a main agent `working`), `shelve`, `unshelve` and a
`switch` that carries or shelves uncommitted changes answer
`409 EAGENTRUNNING` until resent with `confirm: true` — they move files
under it.
- **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
@@ -1215,7 +1258,7 @@ cannot collide on `index.lock`.
`git status` check; an untracked file has no hunks (`400 EUNTRACKED`). A
hunk that no longer applies (e.g. part of it is already staged) is
`409 EGIT`.
- `branch-create` never switches — the usual reason to create one from a workspace
- `branch-create` never switches unless `switch: true` — the usual reason to create one from a workspace
is to spawn a sibling worktree workspace, and moving this pane's HEAD under a
working agent is not. `branch-delete` is `-d`, never `-D`, and refuses
`main`/`master`/`development`/`develop` (`400 EPROTECTED`), the current
@@ -1233,7 +1276,7 @@ cannot collide on `index.lock`.
- Bad input is **400** (`EBADACTION`, `EBADMESSAGE`, `EBADBRANCH`, `EBADPATH`,
`ECONFIRM`, `EUNTRACKED`, `EPROTECTED`, `ENOBRANCH`, `EBRANCHEXISTS`, `EBRANCHBUSY`,
`EDIRTY`, `ENOCHANGES`, `ESTAGEDOUTSIDE`, `ENOREMOTE`, `EBADURL`, `EREMOTEEXISTS`,
`ENOCOMMITS`); git itself exiting
`ENOCOMMITS`, `EBADCHANGES`); git itself exiting
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