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
+29 -6
View File
@@ -530,7 +530,8 @@ the Changes panel carries the per-repo git **writes** the dashboard offers (`POS
| **Pull** | `git pull --ff-only` | the branch has diverged (it reports, never merges) |
| **Push** (labelled **Publish branch** while the branch has no upstream) | `git push -u origin HEAD` | the remote rejects it |
| **Publish…** (only when the repo has NO remote) | `git remote add origin -- <url>`, then `git push -u origin HEAD`; `origin` is removed again if the push fails | the repo has no commit yet, already has a remote, or the URL is not `https://` (no `user:token@`), `ssh://` or `user@host:path` |
| **Branch** | `git switch …` | the tree is dirty, or another worktree holds that branch |
| **Branch** → **Switch branch** | `git switch …`; with uncommitted changes it first asks: **Bring my changes** (`changes: "carry"`, a plain `git switch`) or **Shelve and switch** (`changes: "shelve"`) | another worktree holds that branch; **Bring my changes** when git would overwrite a carried file (the files are listed and **Shelve and switch** offered instead) |
| **Create & switch** | `git switch -c <name>` — uncommitted changes come along (the default on Enter while the tree is dirty) | the branch already exists |
| **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 workspace here** | opens **Add workspace** prefilled with this repo and the selected branch | — |
@@ -539,10 +540,31 @@ 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, reset or stash button,
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. **Merge** is the one exception (issue #45), because
its recovery is a button too: a merge that stops on conflicts puts a banner at
recovery needs a terminal. **Shelves** and **Merge** are the exceptions,
because their recovery is a button too.
**Shelves** (JetBrains' name; VS Code calls it stash) are `git stash` entries
AgentHub made, recognised by the message prefix `agenthub-shelf:` — a stash
you made in a terminal is never shown or touched. **Shelve (n)** beside
**Discard** in the overview sets the ticked files aside (whole files, untracked
ones included) with an optional one-line note; **Shelve and switch** does the
same for every change before a switch, and puts the shelf straight back if the
switch then fails. The **Shelves (n)** list above the files shows each shelf's
branch, note, time and files, with **Restore** (on a clean tree only; a clean
restore deletes the shelf, a conflicted one keeps it and lists the files under
**Merge conflicts** — *Keep mine* is the branch, *Take theirs* the shelf — and
you delete the shelf yourself once they are settled) and **Delete** (two
clicks). Nothing else ever drops a shelf. After a switch onto a branch that
has shelves, the bar asks whether to restore the newest — it never restores on
its own. Shelves are repo-wide, so only a workspace at the repo root has them,
and none can be made or restored while a merge, cherry-pick, revert or rebase
is open. While the workspace's agent is **working**, shelving, restoring and a
switch that moves uncommitted changes ask once more (the server answers
`EAGENTRUNNING` until you confirm) — they move files under it.
**Merge** (issue #45): 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
@@ -559,8 +581,9 @@ 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.
`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**
`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**
deliberately does not switch: the usual reason to make a branch from a workspace is
to spawn a sibling worktree workspace on it, and moving this pane's HEAD under a
working agent is not. **New workspace here** goes through the normal Add-workspace flow