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
+385 -23
View File
@@ -5,10 +5,24 @@
* .gitignore (`ignore`), switch branch, create or
* delete a branch, publish a repo that has no remote yet, and merge a branch
* with its conflict resolution (issue #45: merge, resolve ours/theirs/
* hand-edited, merge-commit, merge-abort). There is no rebase, no hard reset
* and no stash, because a dashboard button is the wrong place to start an
* hand-edited, merge-commit, merge-abort). There is no rebase and no hard
* reset, because a dashboard button is the wrong place to start an
* operation whose recovery needs a terminal (a hard `reset` stays where it
* was: behind the three-check destroy guard in worktree.js). A merge is the
* was: behind the three-check destroy guard in worktree.js).
*
* Shelves (`shelve`, `unshelve`, `shelf-drop`, and `switch` with
* `changes: "carry" | "shelve"`) are git stash under the hood, allowed
* because their recovery is a button too: every shelf is listed
* (`listShelves`, only entries whose message carries SHELF_PREFIX — any
* other stash is the user's and never shown or touched) with Restore and
* Delete, addressed by its commit sha (never `stash@{n}`, which a terminal
* stash would shift), and nothing drops one except a clean restore or a
* Delete with `confirm: true`. They are repo-wide, so repo root only
* (ESUBDIR), never during a merge/cherry-pick/revert/rebase (EMERGING), and
* a restore needs a clean tree (EDIRTY). A conflicted restore keeps its
* shelf and leaves the unmerged files to `resolve`. A write that moves
* files under an agent the route reports as working asks for
* `confirm: true` first (EAGENTRUNNING). A merge is the
* exception because its recovery is ONE button here too — `merge-abort` puts
* the branch back where it was — and `cherry-pick` and `revert` follow it:
* same clean-tree/repo-root guards, same conflict flow, and `merge-commit`/
@@ -30,13 +44,15 @@
* - a caller-supplied path is never trusted as a path: it must be one
* `git status` is reporting as changed right now (`resolvePaths`), so no
* `..`, no absolute path and no untouched file is reachable.
* - `pull` is `--ff-only` and `switch` demands a clean tree, so neither can
* invent a merge commit or a conflict underneath a running agent. A merge
* - `pull` is `--ff-only` and `switch` demands a clean tree unless the
* request names what happens to the changes (carry them, or shelve them),
* so neither can invent a merge commit or a conflict underneath a running
* agent. A merge
* happens only when asked for by name (`merge`), on a clean tree, from a
* lane that IS the repo root (a merge is repo-wide), one at a time.
*
* `discard`, `discard-hunk` and `merge-abort` throw away work git has no copy
* of, so each demands an explicit `confirm: true` in the body. Do not add
* `discard`, `discard-hunk`, `merge-abort` and `shelf-drop` throw away work
* git has no other copy of, so each demands an explicit `confirm: true` in the body. Do not add
* another destructive action without the same gate.
*
* `publish` is the ONE action whose destination comes from the request (a
@@ -94,6 +110,9 @@ const ACTIONS = new Set([
"undo-commit",
"amend",
"ignore",
"shelve",
"unshelve",
"shelf-drop",
]);
/** Actions that would tangle with a merge, cherry-pick or revert in progress. */
@@ -107,6 +126,8 @@ const BLOCKED_WHILE_MERGING = new Set([
"revert",
"undo-commit",
"amend",
"shelve",
"unshelve",
]);
/** A commit as the request may name it: an abbreviated or full hex sha
@@ -324,12 +345,17 @@ async function networkGit(dir, args, body = {}) {
* @param {string} dir The lane's cwd.
* @param {string} action One of ACTIONS.
* @param {object} [body] Action input: `{message}` for commit,
* `{branch, create}` for switch. Ignored otherwise.
* `{branch, create, changes}` for switch, `{note, paths}` for shelve,
* `{sha}` for unshelve/shelf-drop, and so on.
* @param {{agentWorking?: boolean}} [opts] What the route knows about the
* lane: `agentWorking` makes every write that moves files under a
* working agent (shelve, unshelve, a switch carrying or shelving changes)
* demand `confirm: true` (EAGENTRUNNING).
* @returns {Promise<{action, ok, output, branch?}>} `output` is git's own
* stdout+stderr, shown verbatim — a push rejection or a credential failure
* says more in git's words than in ours.
*/
async function runGitAction(dir, action, body = {}) {
async function runGitAction(dir, action, body = {}, opts = {}) {
if (!ACTIONS.has(action)) throw bad("EBADACTION", `unknown git action ${action}`);
if (BLOCKED_WHILE_MERGING.has(action) || action === "merge-commit" || action === "merge-abort") {
// A rebase blocks even Complete/Abort: `commit` mid-rebase would make a
@@ -516,29 +542,133 @@ async function runGitAction(dir, action, body = {}) {
return done(action, await git(dir, ["commit", "-m", message]));
}
if (action === "shelve") {
await assertRepoRootFor(dir, "a shelf");
if (!(await isDirty(dir))) throw bad("ENOCHANGES", "nothing to shelve");
const paths =
Array.isArray(body.paths) && body.paths.length ? await resolvePaths(dir, body) : null;
assertAgentConfirmed(opts, body, "shelving");
const shelf = await shelve(dir, {
note: body.note,
// A rename's original path rides along, as in `discard`.
paths:
paths &&
paths.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path])),
}).catch((e) => {
if (!e.shelf) throw e;
throw Object.assign(
bad(
"ESHELVED",
`git failed after saving your changes on the shelf ${e.shelf.sha.slice(0, 7)}`
),
{ git: e.git, shelf: e.shelf }
);
});
return { ...done(action, shelf.result), shelf: shelf.entry };
}
if (action === "unshelve" || action === "shelf-drop") {
await assertRepoRootFor(dir, "a shelf");
if (action === "shelf-drop") {
// A shelf is the only copy of the work in it: same gate as discard.
if (body.confirm !== true) throw bad("ECONFIRM", "shelf-drop requires confirm: true");
const entry = await findShelf(dir, body.sha);
return { ...done(action, await dropShelf(dir, entry.sha)), sha: entry.sha };
}
const entry = await findShelf(dir, body.sha);
// A restore never mixes with new work: on a clean tree its result is
// exactly the shelf, and a conflict can only be between it and HEAD.
if (await isDirty(dir)) throw bad("EDIRTY", "working tree has uncommitted changes");
assertAgentConfirmed(opts, body, "restoring a shelf");
return applyShelf(dir, entry);
}
if (action === "ignore") return ignorePaths(dir, body);
if (action === "branch-create" || action === "branch-delete") {
return branchAction(dir, action, body);
}
// switch
return switchAction(dir, body, opts);
}
/**
* `switch`. With no `changes` a dirty tree is refused, as it always was: git
* happily carries unrelated edits across a switch, which would silently move
* an agent's half-finished work onto another branch unless the user asked
* for exactly that. `changes: "carry"` asks for it (a plain `git switch`,
* which still refuses when a carried file would be overwritten —
* ECARRYCONFLICT with the files), `changes: "shelve"` puts the changes on a
* shelf first and restores it at once if the switch then fails. Never
* --force, --merge or --discard-changes. A successful switch lists the
* TARGET branch's shelves, so the UI can offer them back — asking, never
* applying one on its own.
*/
async function switchAction(dir, body, opts) {
const branch = typeof body.branch === "string" ? body.branch.trim() : "";
if (!branch) throw bad("EBADBRANCH", "branch is required");
if (!(await isValidBranchName(dir, branch))) {
throw bad("EBADBRANCH", "branch is not a valid git branch name");
}
if (await isDirty(dir)) {
// git would refuse anyway for conflicting files, but it happily carries
// unrelated edits across a switch — which silently moves an agent's
// half-finished work onto another branch. Refuse the whole case.
throw bad("EDIRTY", "working tree has uncommitted changes");
const changes = body.changes;
if (changes !== undefined && changes !== "carry" && changes !== "shelve") {
throw bad("EBADCHANGES", "changes must be carry or shelve");
}
const dirty = await isDirty(dir);
if (dirty && !changes) throw bad("EDIRTY", "working tree has uncommitted changes");
// Every refusal that needs no switch attempt comes BEFORE a shelf is made,
// so a refused switch never leaves the user's work moved aside.
const { argv, target } = await switchArgv(dir, branch, body);
if (dirty && changes === "shelve") await assertRepoRootFor(dir, "a shelf");
if (dirty) assertAgentConfirmed(opts, body, "switching with uncommitted changes");
let shelved = null;
if (dirty && changes === "shelve") {
const note = typeof body.note === "string" && body.note.trim() ? body.note : "";
try {
shelved = (await shelve(dir, { note })).entry;
} catch (e) {
// Saved but failed half way: put it back before reporting.
if (e.shelf) await restoreOrReport(dir, e.shelf, e);
throw e;
}
}
let result;
try {
result = await git(dir, argv);
} catch (e) {
// Back where the user was: the shelf goes straight back on.
if (shelved) await restoreOrReport(dir, shelved, e);
throw carryConflict(e) || e;
}
const shelves = (await listShelves(dir).catch(() => [])).filter((s) => s.branch === target);
return { ...done("switch", result, target), ...(shelved ? { shelved } : {}), shelves };
}
/**
* Re-apply a shelf a failed switch made, so the user is back where they
* were. If even that fails the work is still on the shelf, and the answer
* (ESHELVED, 409) says which one rather than leaving the user to find it.
*/
async function restoreOrReport(dir, shelf, cause) {
const back = await applyShelf(dir, shelf).catch((err) => err);
if (!(back instanceof Error) && !back.kept) return;
throw Object.assign(
bad(
"ESHELVED",
`git failed and your changes are on the shelf ${shelf.sha.slice(0, 7)}: restore them from the Shelves list`
),
{ git: cause.git, shelf }
);
}
/** The `git switch` argv for `branch`, after every check that needs no switch. */
async function switchArgv(dir, branch, body) {
const { branches } = await listBranches(dir);
const exists = branches.includes(branch);
if (body.create) {
if (exists) throw bad("EBRANCHEXISTS", `branch ${branch} already exists`);
return done(action, await git(dir, ["switch", "-c", branch]), branch);
return { argv: ["switch", "-c", branch], target: branch };
}
if (!exists) {
// A branch only a remote has (fetched, never checked out here): its local
@@ -559,15 +689,220 @@ async function runGitAction(dir, action, body = {}) {
}
throw bad("ENOBRANCH", `branch ${branch} does not exist locally or on a remote`);
}
return done(
action,
await git(dir, ["switch", "-c", pick.branch, "--track", `refs/remotes/${pick.name}`]),
pick.branch
);
return {
argv: ["switch", "-c", pick.branch, "--track", `refs/remotes/${pick.name}`],
target: pick.branch,
};
}
const busyAt = await branchCheckedOutAt(dir, branch);
if (busyAt) throw bad("EBRANCHBUSY", `branch ${branch} is checked out at ${busyAt}`);
return done(action, await git(dir, ["switch", branch]), branch);
return { argv: ["switch", branch], target: branch };
}
/**
* git's "would be overwritten by checkout" refusal as ECARRYCONFLICT carrying
* the files it named (tracked edits and untracked files alike), or null.
*/
function carryConflict(e) {
const stderr = e?.git?.stderr || "";
if (!/would be overwritten by checkout/.test(stderr)) return null;
const files = [];
let inList = false;
for (const line of stderr.split("\n")) {
if (/would be overwritten by checkout/.test(line)) inList = true;
else if (inList && /^\s/.test(line) && line.trim()) files.push(line.trim());
else inList = false;
}
return Object.assign(
bad("ECARRYCONFLICT", "your changes would be overwritten by the switch: shelve them instead"),
{ files, git: e.git }
);
}
/**
* Refuse a write that moves files under an agent the lane reports as working,
* unless the request says the user saw that and confirmed (`confirm: true`).
*/
function assertAgentConfirmed(opts, body, what) {
if (opts?.agentWorking && body.confirm !== true) {
throw bad(
"EAGENTRUNNING",
`an agent is working in this workspace: confirm ${what} under it (confirm: true)`
);
}
}
/** A stash is repo-wide, so only a lane at the repo root may shelve or restore. */
async function assertRepoRootFor(dir, what) {
if ((await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim()) {
throw bad(
"ESUBDIR",
`${what} holds changes of the whole repository; use a workspace at the repo root`
);
}
}
/** What marks a stash entry as one AgentHub made — the only ones it lists or touches. */
const SHELF_PREFIX = "agenthub-shelf: ";
const SHELF_SEP = " · ";
const MAX_NOTE_LEN = 200;
/**
* Every shelf in the repo, newest first: `{ref, sha, branch, note, createdAt,
* files: [{path, status}]}`. Only entries whose message carries SHELF_PREFIX
* are listed — any other stash is the user's own and never shown or touched.
* A write addresses an entry by `sha`, never by its `stash@{n}`, which a
* `git stash` run in a terminal meanwhile would shift.
*/
async function listShelves(dir) {
const out = await git(dir, ["stash", "list", "--format=%gd%x00%H%x00%gs"]).catch(() => ({
stdout: "",
}));
const shelves = [];
for (const line of out.stdout.split("\n")) {
if (!line) continue;
const [ref, sha, subject = ""] = line.split("\0");
const at = subject.indexOf(SHELF_PREFIX);
if (at < 0 || !SHA_RE.test(sha || "")) continue;
const [branch, createdAt, ...rest] = subject.slice(at + SHELF_PREFIX.length).split(SHELF_SEP);
shelves.push({
ref,
sha,
branch: branch || "",
note: rest.join(SHELF_SEP),
createdAt: createdAt || null,
files: await shelfFiles(dir, sha),
});
}
return shelves;
}
/** The files a shelf holds: its tracked changes, plus its untracked files as `??`. */
async function shelfFiles(dir, sha) {
const files = [];
const none = () => ({ stdout: "" });
const tracked = await git(dir, [
"diff",
"--no-renames",
"--name-status",
"-z",
`${sha}^1`,
sha,
]).catch(none);
const parts = tracked.stdout.split("\0").filter(Boolean);
for (let i = 0; i + 1 < parts.length; i += 2)
files.push({ path: parts[i + 1], status: parts[i] });
const untracked = await git(dir, ["ls-tree", "-r", "-z", "--name-only", `${sha}^3`]).catch(none);
for (const p of untracked.stdout.split("\0").filter(Boolean)) {
files.push({ path: p, status: "??" });
}
return files;
}
/** The shelf named by `sha` (full or abbreviated), or EBADSHA / ENOSHELF. */
async function findShelf(dir, sha) {
if (typeof sha !== "string" || !SHA_RE.test(sha)) throw bad("EBADSHA", "sha must be a shelf id");
const want = sha.toLowerCase();
const entry = (await listShelves(dir)).find((s) => s.sha.startsWith(want));
if (!entry) throw bad("ENOSHELF", `no shelf ${sha}`);
return entry;
}
/**
* `git stash push --include-untracked` under an AgentHub message — the
* current branch, the time and an optional one-line note — of everything or
* of exactly `paths` (already checked by `resolvePaths`).
*/
async function shelve(dir, { note, paths } = {}) {
const branch =
(
await git(dir, ["symbolic-ref", "--short", "-q", "HEAD"]).catch(() => ({ stdout: "" }))
).stdout.trim() || "(detached)";
const text =
typeof note === "string"
? note
.replace(/[\r\n\t]+/g, " ")
.trim()
.slice(0, MAX_NOTE_LEN)
: "";
const message = `${SHELF_PREFIX}${branch}${SHELF_SEP}${new Date().toISOString()}${
text ? `${SHELF_SEP}${text}` : ""
}`;
const before = await stashTop(dir);
// A stash can fail AFTER it saved the entry (its internal reset could not
// lock a ref): the work is then on a shelf, and the error says which one.
const saved = async (e) => {
const top = await stashTop(dir);
if (!top || top === before) return e;
const entry = (await listShelves(dir)).find((s) => s.sha === top);
return entry ? Object.assign(e, { shelf: entry }) : e;
};
// NOT the literal-pathspec `git` wrapper: under GIT_LITERAL_PATHSPECS a
// stash saves the untracked files but never cleans them off the tree (its
// internal clean uses a magic pathspec), so each path is made literal by
// its own `:(literal)` prefix instead.
const result = await rawGit(dir, [
"stash",
"push",
"--include-untracked",
"-m",
message,
...(paths && paths.length ? ["--", ...paths.map((p) => `:(literal)${p}`)] : []),
]).catch(async (e) => {
throw await saved(e);
});
const top = await stashTop(dir);
// "No local changes to save" exits 0 and makes nothing.
if (!top || top === before) throw bad("ENOCHANGES", "nothing to shelve");
const entry = (await listShelves(dir)).find((s) => s.sha === top);
return { result, entry };
}
/** The newest stash entry's commit, or "". */
async function stashTop(dir) {
return (
await git(dir, ["rev-parse", "-q", "--verify", "refs/stash"]).catch(() => ({ stdout: "" }))
).stdout.trim();
}
/** `git stash drop` of the entry whose commit is `sha`, its index looked up at drop time. */
async function dropShelf(dir, sha) {
const entry = (await listShelves(dir)).find((s) => s.sha === sha);
if (!entry) throw bad("ENOSHELF", `no shelf ${sha}`);
return git(dir, ["stash", "drop", "-q", entry.ref]);
}
/**
* Put a shelf back on a clean tree. `--index` first, so what was staged comes
* back staged; when only the index cannot be rebuilt git refuses before it
* touches the tree, and the shelf is applied without it. A clean apply drops
* the entry; a conflicted one KEEPS it and leaves the unmerged files to the
* `resolve` flow (ours = the branch, theirs = the shelf).
*/
async function applyShelf(dir, entry) {
const kept = (e, conflicts) => ({
...done("unshelve", e.git || { stdout: "", stderr: "" }),
sha: entry.sha,
conflicts,
kept: true,
});
let result;
try {
result = await git(dir, ["stash", "apply", "--index", entry.sha]);
} catch (e) {
const conflicts = await unmergedPaths(dir);
if (conflicts.length) return kept(e, conflicts);
if (await isDirty(dir)) throw e;
try {
result = await git(dir, ["stash", "apply", entry.sha]);
} catch (e2) {
const left = await unmergedPaths(dir);
if (!left.length) throw e2;
return kept(e2, left);
}
}
await dropShelf(dir, entry.sha);
return { ...done("unshelve", result), sha: entry.sha, conflicts: [], kept: false };
}
/** Paths git still holds as unmerged, repo-wide. */
@@ -986,6 +1321,25 @@ async function branchAction(dir, action, body) {
if (base && !(await isValidBranchName(dir, base))) {
throw bad("EBADBRANCH", "base is not a valid git ref name");
}
if (body.switch === true) {
// Create & switch: the uncommitted changes come along (git never
// refuses that from HEAD; from another base a clash is ECARRYCONFLICT).
// The working tree is left as it is, so no running-agent confirm.
if (await rebaseInProgress(dir)) {
throw bad("EMERGING", "a rebase is in progress: finish or abort it in a terminal first");
}
const open = await operationInProgress(dir);
if (open) throw bad("EMERGING", `a ${open} is in progress: finish or abort it first`);
try {
return done(
action,
await git(dir, ["switch", "-c", branch, ...(base ? [base] : [])]),
branch
);
} catch (e) {
throw carryConflict(e) || e;
}
}
// No switch: creating a branch to spawn a sibling worktree lane from must
// not move the branch the agent in this pane is standing on.
return done(action, await git(dir, ["branch", branch, ...(base ? [base] : [])]), branch);
@@ -1024,4 +1378,12 @@ function done(action, result, branch) {
return { action, ok: true, output, ...(branch ? { branch } : {}) };
}
module.exports = { runGitAction, validateRemoteUrl, ACTIONS, PROTECTED, NETWORK_TIMEOUT_MS };
module.exports = {
runGitAction,
listShelves,
validateRemoteUrl,
ACTIONS,
PROTECTED,
NETWORK_TIMEOUT_MS,
SHELF_PREFIX,
};