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:
@@ -865,6 +865,26 @@ const paths = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/shelves": {
|
||||
get: {
|
||||
tags: ["Lanes"],
|
||||
summary: "The workspace repo's shelves (changes set aside by AgentHub)",
|
||||
description:
|
||||
"The git stash entries AgentHub made (message prefix `agenthub-shelf: `), newest first, each {ref (stash@{n}), sha (the stash commit — what unshelve/shelf-drop take, since an index shifts), branch, note, createdAt, files:[{path, status}] (untracked files as `??`)}. Any other stash is the user's own and is never listed. The repository comes from the lane row (or ?session/?source/?repo), never from a path. Read-only, no same-origin guard; a folder that is not a repo answers {available:false, shelves:[]}.",
|
||||
operationId: "listLaneShelves",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
{ name: "session", in: "query", required: false, schema: { type: "string" } },
|
||||
{ name: "source", in: "query", required: false, schema: { type: "integer" } },
|
||||
{ name: "repo", in: "query", required: false, schema: { type: "string" } },
|
||||
],
|
||||
responses: {
|
||||
200: { description: "{available, shelves}." },
|
||||
400: { description: "ENOWORKTREE" },
|
||||
404: { description: "Lane not found, ENOSOURCE or ENOREPO." },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/lanes/{id}/git/commit-message": {
|
||||
post: {
|
||||
tags: ["Lanes"],
|
||||
@@ -903,7 +923,7 @@ const paths = {
|
||||
tags: ["Lanes"],
|
||||
summary: "One git write on the lane's working copy",
|
||||
description:
|
||||
"fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u <remote> HEAD, so only the branch the lane is on; remote = the branch's upstream remote, else the only remote, else origin, else 400 ENOREMOTE), stage/unstage (per file; staging a conflicted file that still has conflict markers is 400 EMARKERS), discard (per file; irreversible, so it REQUIRES confirm:true), stage-hunk / discard-hunk (ONE hunk of a tracked file's `git diff HEAD`, named by its `@@` line and re-read server-side at the given context — the applied patch is always git's own text, never one from the request; discard-hunk REQUIRES confirm:true; 400 EUNTRACKED for an untracked file, 409 EHUNKGONE when the hunk is no longer in the diff), commit (git add -A then commit -m <message>, or only:\"staged\" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent's half-finished work is never carried onto another branch), branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree), publish (the ONE write whose destination comes from the request: `git remote add origin -- <url>` then `git push -u origin HEAD`, only in a repo with a commit and NO remote — it never re-points one; the url must be https:// without userinfo, ssh:// or scp-like user@host:path, and origin is removed again if the push fails), and merge / resolve / merge-commit / merge-abort (issue #45: merge a local or remote-tracking branch that exists — clean tree and a repo-root lane only, 400 ESUBDIR otherwise — answering conflicts:[paths] when it stops on conflicts; resolve {path, side: ours|theirs|done} a conflicted path, done refusing leftover markers with 400 EMARKERS; merge-commit is 409 ECONFLICTS while anything is unmerged; merge-abort REQUIRES confirm:true; pull/switch/merge/hunk actions are 409 EMERGING mid-merge). fetch/pull/push/publish needing a login answer 409 EGITAUTH with auth:{protocol,host,username,rejected}; resend with credentials:{protocol,host,username,password,remember} (a repo remote's host only, else 400 EBADHOST). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per git index (the lane lock keyed on the worktree's git dir, so two lanes sharing one index cannot collide on index.lock). Deliberately no rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.",
|
||||
"fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u <remote> HEAD, so only the branch the lane is on; remote = the branch's upstream remote, else the only remote, else origin, else 400 ENOREMOTE), stage/unstage (per file; staging a conflicted file that still has conflict markers is 400 EMARKERS), discard (per file; irreversible, so it REQUIRES confirm:true), stage-hunk / discard-hunk (ONE hunk of a tracked file's `git diff HEAD`, named by its `@@` line and re-read server-side at the given context — the applied patch is always git's own text, never one from the request; discard-hunk REQUIRES confirm:true; 400 EUNTRACKED for an untracked file, 409 EHUNKGONE when the hunk is no longer in the diff), commit (git add -A then commit -m <message>, or only:\"staged\" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree (400 EDIRTY) unless changes names what happens to the changes: \"carry\" = a plain git switch that brings them along, 409 ECARRYCONFLICT with files:[...] when git would overwrite one; \"shelve\" = shelve them first, then switch, and the shelf is restored at once if the switch fails. Never --force/--merge/--discard-changes. A successful switch answers shelves:[...] — the TARGET branch's shelves — and shelved:{...} when it made one), branch-create / branch-delete (create never switches unless switch:true, which runs git switch -c <branch> [<base>] and carries the uncommitted changes; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree), publish (the ONE write whose destination comes from the request: `git remote add origin -- <url>` then `git push -u origin HEAD`, only in a repo with a commit and NO remote — it never re-points one; the url must be https:// without userinfo, ssh:// or scp-like user@host:path, and origin is removed again if the push fails), and merge / resolve / merge-commit / merge-abort (issue #45: merge a local or remote-tracking branch that exists — clean tree and a repo-root lane only, 400 ESUBDIR otherwise — answering conflicts:[paths] when it stops on conflicts; resolve {path, side: ours|theirs|done} a conflicted path, done refusing leftover markers with 400 EMARKERS; merge-commit is 409 ECONFLICTS while anything is unmerged; merge-abort REQUIRES confirm:true; pull/switch/merge/hunk actions are 409 EMERGING mid-merge). fetch/pull/push/publish needing a login answer 409 EGITAUTH with auth:{protocol,host,username,rejected}; resend with credentials:{protocol,host,username,password,remember} (a repo remote's host only, else 400 EBADHOST). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per git index (the lane lock keyed on the worktree's git dir, so two lanes sharing one index cannot collide on index.lock). shelve {note?, paths?} (git stash push --include-untracked of everything or of the ticked changed paths, under an `agenthub-shelf: <branch> · <ISO time> · <note>` message; 400 ENOCHANGES on a clean tree), unshelve {sha} (git stash apply --index, retried without --index when only the index cannot be rebuilt; refuses a dirty tree, 400 EDIRTY; a clean apply drops the entry, a conflicted one keeps it and answers conflicts:[...] with kept:true — resolve them with resolve), shelf-drop {sha, confirm:true} (irreversible, REQUIRES confirm:true). Shelves are repo-wide: repo-root lane only (400 ESUBDIR), 409 EMERGING mid-merge/cherry-pick/revert/rebase, 409 ENOSHELF for a sha no AgentHub shelf has. When an agent of the lane is working, shelve, unshelve and a switch moving uncommitted changes answer 409 EAGENTRUNNING until resent with confirm:true. Deliberately no rebase or reset: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.",
|
||||
operationId: "runLaneGitAction",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
|
||||
@@ -932,6 +952,13 @@ const paths = {
|
||||
"undo-commit",
|
||||
"amend",
|
||||
"ignore",
|
||||
"merge",
|
||||
"resolve",
|
||||
"merge-commit",
|
||||
"merge-abort",
|
||||
"shelve",
|
||||
"unshelve",
|
||||
"shelf-drop",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -956,7 +983,7 @@ const paths = {
|
||||
sha: {
|
||||
type: "string",
|
||||
description:
|
||||
"cherry-pick, revert; a hex commit id (4-40) of a non-merge commit, else 400 EBADSHA / EMERGECOMMIT. Clean tree and repo-root lane required (EDIRTY/ESUBDIR); a conflict answers 200 with conflicts[], finished by merge-commit or dropped by merge-abort; a no-op pick/revert is aborted and answers 409 EEMPTY. undo-commit (reset --soft HEAD~1, confirm:true) and amend REQUIRE sha = the HEAD the user confirmed (409 ESTALE once it moved) and refuse a HEAD any remote-tracking branch holds (409 EPUSHED) or a subdirectory lane (400 ESUBDIR).",
|
||||
"unshelve, shelf-drop: the shelf's stash commit sha (from GET /git/shelves; an abbreviation works). cherry-pick, revert; a hex commit id (4-40) of a non-merge commit, else 400 EBADSHA / EMERGECOMMIT. Clean tree and repo-root lane required (EDIRTY/ESUBDIR); a conflict answers 200 with conflicts[], finished by merge-commit or dropped by merge-abort; a no-op pick/revert is aborted and answers 409 EEMPTY. undo-commit (reset --soft HEAD~1, confirm:true) and amend REQUIRE sha = the HEAD the user confirmed (409 ESTALE once it moved) and refuse a HEAD any remote-tracking branch holds (409 EPUSHED) or a subdirectory lane (400 ESUBDIR).",
|
||||
},
|
||||
only: {
|
||||
type: "string",
|
||||
@@ -969,6 +996,21 @@ const paths = {
|
||||
},
|
||||
base: { type: "string", description: "branch-create only; defaults to HEAD." },
|
||||
create: { type: "boolean", description: "switch only; create the branch." },
|
||||
changes: {
|
||||
type: "string",
|
||||
enum: ["carry", "shelve"],
|
||||
description:
|
||||
"switch only; what happens to uncommitted changes (absent = a dirty tree is 400 EDIRTY). Any other value is 400 EBADCHANGES.",
|
||||
},
|
||||
switch: {
|
||||
type: "boolean",
|
||||
description: "branch-create only; also switch to the new branch (git switch -c).",
|
||||
},
|
||||
note: {
|
||||
type: "string",
|
||||
description:
|
||||
'shelve, and switch with changes:"shelve" (optional); one line, max 200 chars, shown beside the shelf.',
|
||||
},
|
||||
paths: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
@@ -1011,7 +1053,8 @@ const paths = {
|
||||
},
|
||||
confirm: {
|
||||
type: "boolean",
|
||||
description: "discard and discard-hunk; the call is refused without it.",
|
||||
description:
|
||||
"discard, discard-hunk, merge-abort, undo-commit and shelf-drop; the call is refused without it. Also the answer to 409 EAGENTRUNNING (shelve, unshelve, a switch moving uncommitted changes while an agent of the lane is working).",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user