diff --git a/CLAUDE.md b/CLAUDE.md index 9cbb416d..d8a7e716 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which - **The native picker opens only for a browser on this machine, and nothing from the request but a start folder reaches its argv.** `POST /api/lanes/pick-path` refuses a non-loopback socket and any request a proxy forwarded (`EREMOTE`) — behind nginx every request comes from loopback, and the dialog would open on a screen nobody watches. Do not relax that check, and do not let the request name the dialog program, a title or any other argument. - **Never build a git command as a shell string.** Lane git operations use `execFile("git", [...args])` with an explicit argument array (see `server/lib/worktree.js`'s `git()` helper), never a concatenated/interpolated string passed to a shell. - **An AI Proxy (called AI Router before v2.5.5) is a SESSION's choice, and only its name comes from the request.** A new session names a profile (`proxy` on `POST /api/lanes/:id/start` / `POST /api/run` — the pre-rename `router` is still accepted —, the **AI Proxy** select beside the model picker); `resolveProxy` (`server/lib/proxy-apply.js`) checks it (`EBADPROXY`, `EUNSUPPORTED` off Claude/Codex) and the key and endpoint come from the store. A profile's `baseUrl` is the proxy's ROOT (a trailing `/v1` is dropped on save and on read): a **claude** pane gets `--settings=/proxy-settings/.json` with `ANTHROPIC_BASE_URL` = the root (0700/0600; plus `modelPicker` rows only when the profile sets a user-chosen `behavesAs` — never guess one, it sets the context window Claude Code compacts at; the command-line layer outranks project-local and user settings, so only the PATH is in argv), a **codex** pane gets `-p ` and `$CODEX_HOME/.config.toml` with `base_url` = root + `/v1` (its base `config.toml` is never touched); both are rewritten at each spawn, `PATCH /api/ai-proxies/:name` rewrites the existing ones and `DELETE` removes them. `dashboard_runs.proxy` (renamed in place from `router`) records it, and a resume naming none goes back to it. **Never write a proxy into the user's project again** — `.claude/settings.local.json` is shared by every session in the folder and is not in a worktree; `clearLaneProxies` takes the old keys out at start and `lanes.router_profile` stays NULL. Key material never leaves the server: profiles live at `/agenthub-proxies/` (0700/0600; the old `agenthub-routers/` is moved there on first read and left as a symlink for an older build), `GET /api/ai-proxies` reports only `hasKey`, and there is deliberately no route that returns one profile in full. `/api/ai-routers` is the same router under its pre-rename path. **`POST /api/ai-proxies/test` sends a stored key ONLY to the endpoint it was stored against** — a blank `apiKey` falls back to the profile named by `name`, but only while the submitted `baseUrl` still equals the saved one, else `EBADKEY`; it probes `/v1/messages` (Claude) and `/v1/responses` (Codex) and answers `{claude, codex}`. Relax that and the route becomes a way to make the server POST a saved key to any URL a browser names. **Codex's key reaches the pane through tmux's `-e` argv (`AGENTHUB_PROXY_KEY`) and is never typed into it** — `tmux.sendCommand` takes over an idle pane with `respawn-pane` for exactly this reason; a `KEY='sk-…' codex …` line typed with `send-keys` would be rendered to every attached browser, kept in the scrollback and appended to the user's shell history. Do not reintroduce a typed command line there, and do not put a Claude key in argv either. See `server/lib/ai-proxies.js`, `server/lib/proxy-apply.js`. -- **The dashboard's git writes are these twenty-two, and the guards are IN `git-ops.js`, not in the UI.** `POST /api/lanes/:id/git/:action` offers `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`, `discard-hunk`, `commit`, `switch`, `branch-create`, `branch-delete`, `publish`, `merge`, `resolve`, `merge-commit`, `merge-abort`, `cherry-pick`, `revert`, `undo-commit`, `amend`, `ignore` — nothing else. `commit`/`amend` with `paths` (the Changes panel's ticked files, JetBrains-style) run `git commit --only -- ` over `resolvePaths`-checked paths, never a conflicted one, and with `hunks` (ticked change blocks) commit from a TEMPORARY `GIT_INDEX_FILE` built from git's own fresh diff (`selectionPatch`, never request text), then reset only those paths' real index entries; `discard` takes the same `paths` + `hunks` selection (the Changes panel's **Discard (n)**, #87 — never every file) and undoes only the ticked blocks off the working tree; `ignore` appends an anchored `/path` line to the lane's OWN `.gitignore` for an untracked path only (`ETRACKED`), never through a symlink. **`publish` is the ONE write that takes a destination from the request** (`{url}`), so it can only give a remote to a repo that has NONE: it refuses before the first commit (`ENOCOMMITS`) and whenever `git remote` prints anything (`EREMOTEEXISTS` — it never re-points a remote, so it cannot redirect a repo that already publishes somewhere), the name is always `origin`, the URL passes `validateRemoteUrl`'s allowlist (`https://` with NO userinfo, `ssh://`, scp-like `user@host:path` — never a local path, `file://`, `ext::`, `fd::`, `git://`, `http://` or anything starting with `-`), it runs `remote add origin -- ` then the same `push -u origin HEAD` as `push`, and a failed push removes `origin` again. That is still weaker than the AI-proxy "a key goes only to its stored endpoint" rule — a browser on the dashboard's origin can publish a remote-less repo to any allowlisted host — so do not widen the allowlist or let it touch a repo that already has a remote. **A path in the body is never a path**: `resolvePaths` accepts only what `git status` is reporting as changed right now, which is the whole authorization story for the per-file actions (no `..`, no absolute path, no untouched file). `discard`, `discard-hunk` and `merge-abort` are the irreversible actions and all refuse to run without `confirm: true`; do not add another destructive action without the same gate. `branch-create` never switches (the reason to create one from a lane is a sibling worktree, not moving a working agent's HEAD) and `branch-delete` is `-d`, never `-D`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds. `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent (a `switch` to a branch only a remote has creates its tracking branch from a `refs/remotes` ref it LOOKED UP — `listRemoteBranches` — never one from the request); `push` pushes `HEAD`, never a branch name from the request; `commit` passes its message as one argv element. **The lane is the boundary, not the repo**: a subdir lane's reads and writes stay under its own cwd, and an extra folder (`?source=`, resolved by `sourceFolder` from a `folder` row of THIS lane's `lane_sources`, never a path — `404 ENOSOURCE` otherwise) and a repo inside the lane's folder (`?repo=`, accepted only when `server/lib/nested-repos.js`'s own disk walk found it — `404 ENOREPO` otherwise) are each their own boundary under every guard here; no read may return a `settings.local.json` (`ESECRET`) — do not add a git read that skips `lanePath`/`isSecret`. **A merge is a history-joining action allowed only because its recovery is a button too** (`merge-abort`) — and `cherry-pick`/`revert` ride the same rule (a non-merge commit by sha, `EMERGECOMMIT`/`EBADSHA`; same clean-tree, repo-root and conflict flow; `merge-commit`/`merge-abort` finish or abort whichever of the three `operationInProgress` finds open; a no-op is aborted again as `EEMPTY`, never left open): it starts only by name, on a clean tree, from a lane at the repo ROOT (`ESUBDIR` — a merge is repo-wide and a subdir lane could not resolve a conflict outside itself), one at a time (`pull`/`switch`/`merge`/hunk actions answer `EMERGING` meanwhile); the ref is looked up in `refs/heads`+`refs/remotes`, never passed through; a conflicted file is resolved only through `resolve` (ours/theirs, or `done` once no conflict marker is left — `stage` refuses a conflicted file that still has markers, `EMARKERS`), and `merge-commit` refuses while anything is unmerged (`ECONFLICTS`). Do not add rebase, a hard/mixed reset or stash here: a dashboard click is the wrong place to start an operation whose recovery needs a terminal, and a hard `reset` already lives behind `worktree.js`'s three-check destroy guard. The ONE reset is `undo-commit` = `reset --soft HEAD~1` (`confirm: true`, repo root, never the root or a merge commit), and it and `amend` act only on the HEAD the request names (`sha`, `ESTALE` once an agent committed past it) and refuse any HEAD a remote-tracking branch already holds (`EPUSHED`); a terminal-started rebase blocks every history action (`EMERGING`) — rewriting a pushed commit is a terminal decision; do not relax that check. A button that looks disabled in `LaneGitBar` is a courtesy; the refusal that matters is the server's. Reads (`/git`, `/git/changes`, `/git/log`, `/git/file`, `/git/raw` — an image's bytes for the viewer, extension-allowlisted and served under a `sandbox` CSP + `nosniff` so an SVG runs no script; keep both headers —, `/git/ls`, `/git/tree`, `/git/branches`) stay unguarded like `/git` always was, and resolve their repo from the LANE ROW (or its session / `lane_sources` row) — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet. +- **The dashboard's git writes are these twenty-five, and the guards are IN `git-ops.js`, not in the UI.** `POST /api/lanes/:id/git/:action` offers `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`, `discard-hunk`, `commit`, `switch`, `branch-create`, `branch-delete`, `publish`, `merge`, `resolve`, `merge-commit`, `merge-abort`, `cherry-pick`, `revert`, `undo-commit`, `amend`, `ignore`, `shelve`, `unshelve`, `shelf-drop` — nothing else. `commit`/`amend` with `paths` (the Changes panel's ticked files, JetBrains-style) run `git commit --only -- ` over `resolvePaths`-checked paths, never a conflicted one, and with `hunks` (ticked change blocks) commit from a TEMPORARY `GIT_INDEX_FILE` built from git's own fresh diff (`selectionPatch`, never request text), then reset only those paths' real index entries; `discard` takes the same `paths` + `hunks` selection (the Changes panel's **Discard (n)**, #87 — never every file) and undoes only the ticked blocks off the working tree; `ignore` appends an anchored `/path` line to the lane's OWN `.gitignore` for an untracked path only (`ETRACKED`), never through a symlink. **`publish` is the ONE write that takes a destination from the request** (`{url}`), so it can only give a remote to a repo that has NONE: it refuses before the first commit (`ENOCOMMITS`) and whenever `git remote` prints anything (`EREMOTEEXISTS` — it never re-points a remote, so it cannot redirect a repo that already publishes somewhere), the name is always `origin`, the URL passes `validateRemoteUrl`'s allowlist (`https://` with NO userinfo, `ssh://`, scp-like `user@host:path` — never a local path, `file://`, `ext::`, `fd::`, `git://`, `http://` or anything starting with `-`), it runs `remote add origin -- ` then the same `push -u origin HEAD` as `push`, and a failed push removes `origin` again. That is still weaker than the AI-proxy "a key goes only to its stored endpoint" rule — a browser on the dashboard's origin can publish a remote-less repo to any allowlisted host — so do not widen the allowlist or let it touch a repo that already has a remote. **A path in the body is never a path**: `resolvePaths` accepts only what `git status` is reporting as changed right now, which is the whole authorization story for the per-file actions (no `..`, no absolute path, no untouched file). `discard`, `discard-hunk`, `merge-abort` and `shelf-drop` are the irreversible actions and all refuse to run without `confirm: true`; do not add another destructive action without the same gate. `branch-create` never switches unless `switch: true` (Create & switch: `git switch -c`, the working tree untouched so the changes come along; otherwise the reason to create one from a lane is a sibling worktree, not moving a working agent's HEAD) and `branch-delete` is `-d`, never `-D`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds. `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree unless the request says `changes: "carry"` (a plain `git switch`; a file it would overwrite is `ECARRYCONFLICT` with the list, never `--force`/`--merge`/`--discard-changes`) or `changes: "shelve"` (shelved first, the shelf re-applied at once if the switch fails), so neither can invent a merge commit or a conflict underneath a running agent (a `switch` to a branch only a remote has creates its tracking branch from a `refs/remotes` ref it LOOKED UP — `listRemoteBranches` — never one from the request); `push` pushes `HEAD`, never a branch name from the request; `commit` passes its message as one argv element. **The lane is the boundary, not the repo**: a subdir lane's reads and writes stay under its own cwd, and an extra folder (`?source=`, resolved by `sourceFolder` from a `folder` row of THIS lane's `lane_sources`, never a path — `404 ENOSOURCE` otherwise) and a repo inside the lane's folder (`?repo=`, accepted only when `server/lib/nested-repos.js`'s own disk walk found it — `404 ENOREPO` otherwise) are each their own boundary under every guard here; no read may return a `settings.local.json` (`ESECRET`) — do not add a git read that skips `lanePath`/`isSecret`. **A merge is a history-joining action allowed only because its recovery is a button too** (`merge-abort`) — and `cherry-pick`/`revert` ride the same rule (a non-merge commit by sha, `EMERGECOMMIT`/`EBADSHA`; same clean-tree, repo-root and conflict flow; `merge-commit`/`merge-abort` finish or abort whichever of the three `operationInProgress` finds open; a no-op is aborted again as `EEMPTY`, never left open): it starts only by name, on a clean tree, from a lane at the repo ROOT (`ESUBDIR` — a merge is repo-wide and a subdir lane could not resolve a conflict outside itself), one at a time (`pull`/`switch`/`merge`/hunk actions answer `EMERGING` meanwhile); the ref is looked up in `refs/heads`+`refs/remotes`, never passed through; a conflicted file is resolved only through `resolve` (ours/theirs, or `done` once no conflict marker is left — `stage` refuses a conflicted file that still has markers, `EMARKERS`), and `merge-commit` refuses while anything is unmerged (`ECONFLICTS`). **Stash is allowed ONLY as shelves, because their recovery is a button too** (the repo owner's call): `shelve`/`unshelve`/`shelf-drop` touch only stash entries whose message carries `agenthub-shelf: ` (any other stash is the user's — never listed, applied or dropped), address an entry by its commit `sha` (never `stash@{n}`, which a terminal stash shifts), list every shelf with Restore and Delete (`GET /git/shelves`), drop one ONLY after a clean `unshelve` or a `shelf-drop` with `confirm: true` (a conflicted restore keeps it and leaves the files to `resolve`), refuse a dirty tree for a restore (`EDIRTY`), run from a repo-root lane only (`ESUBDIR`) and never mid-merge/cherry-pick/revert/rebase (`EMERGING`); `shelve`, `unshelve` and a `switch` that carries or shelves changes answer `409 EAGENTRUNNING` until `confirm: true` while the lane's agent is working (`agentWorking` in `routes/lanes.js`). Do not add rebase, a hard/mixed reset or any other stash use here: a dashboard click is the wrong place to start an operation whose recovery needs a terminal, and a hard `reset` already lives behind `worktree.js`'s three-check destroy guard. The ONE reset is `undo-commit` = `reset --soft HEAD~1` (`confirm: true`, repo root, never the root or a merge commit), and it and `amend` act only on the HEAD the request names (`sha`, `ESTALE` once an agent committed past it) and refuse any HEAD a remote-tracking branch already holds (`EPUSHED`); a terminal-started rebase blocks every history action (`EMERGING`) — rewriting a pushed commit is a terminal decision; do not relax that check. A button that looks disabled in `LaneGitBar` is a courtesy; the refusal that matters is the server's. Reads (`/git`, `/git/changes`, `/git/log`, `/git/file`, `/git/raw` — an image's bytes for the viewer, extension-allowlisted and served under a `sandbox` CSP + `nosniff` so an SVG runs no script; keep both headers —, `/git/ls`, `/git/tree`, `/git/branches`, `/git/shelves`) stay unguarded like `/git` always was, and resolve their repo from the LANE ROW (or its session / `lane_sources` row) — `GET /api/lanes/branches` is the only one that takes a path from the request, because its lane does not exist yet. - **A clone runs as its folder's account with THAT account's own identity, and only the public key ever leaves the server.** `POST /api/lanes/clone` takes a URL only through `validateRemoteUrl` (the `publish` allowlist) and a parent held to the user's own folder like New folder; `server/lib/git-identity.js` runs `git clone` as the folder's account (S3: the member) and writes the identity into the CLONE's config (`core.sshCommand` / `credential.helper=store --file=`), never the user's global config. A member's SSH key and HTTPS store live in their home under `~/.config/agenthub/`, written only through `write-as` (secrets on stdin, never sudo argv/env), the host's under `/agenthub-ssh/` + the shared store below; `/api/me/git` returns only `publicKey`/`hasToken`. The git bar's fetch/pull/push in a member's folder uses that same store (`memberHttpsConfig`; a typed login goes in through `write-as` stdin for the one call), and a store that cannot be read is never rewritten (`ESTOREUNREADABLE`); the private key is only `lstat`ed, never read. The commit author is the ACCOUNT's `git config --global` (`PUT /api/me/git/author`). Do not add a route that returns a private key or a token, or one that writes a git identity outside the clone's own config or the account's own `~/.gitconfig`. - **AgentHub's git credentials are its OWN store, shared by every user of the install, and git is given ONLY that store when it holds a login for the host.** `server/lib/git-credentials.js` keeps one record per `protocol + host` under `/agenthub-git-credentials/` (0700/0600) and no route ever returns a password (`hasToken` only). `gitConfigFor` runs a network action with `-c credential.helper= -c credential.helper=` whenever AgentHub holds a login for a repo remote's host or the request carries one — the machine's helpers are left out on purpose: a stale token there would win over the typed one, and git's post-success `store` would copy a use-once token into the OS user's helper. The secret travels in the helper's env or the store file, never argv. A typed credential is accepted only for one of the repo's OWN remote hosts (`EBADHOST`), so a login is never sent to a server it was not typed for, and it is saved only after git SUCCEEDED and `remember` is set. Do not add a route that returns a stored password or a path that forwards one to a host other than its own. - **A lane's `tool` is its DEFAULT, immutable; a session's tool is the session's choice — and only an AI Tools catalog id ever comes from a request.** `lanes.tool` (default `claude`) is any id `server/lib/ai-tools.js` knows; `createLane` refuses an unknown one (`EBADTOOL`) and one the user disabled (`EDISABLED` — only CREATION asks, an existing lane on a tool disabled since keeps working), and it is deliberately absent from `PATCHABLE`. A NEW session names its own with `tool` on `POST /api/lanes/:id/start` (the home's **AI** select, sent only when it differs from the lane's): `validateTool` again (`EBADTOOL`/`EDISABLED`, 400), and the id only picks a catalog file — the command, args and env still come from that file. A resume keeps its session's own tool (`sessions.tool`), never the request's. `POST /api/run` still takes the lane's tool. A lane whose tool left the catalog answers `ENOTOOL` at start. The sidebar's tool icon is per SESSION (`WorkspaceSessionList`), not per workspace row. A pane has no run form: `RunSetup` is gone from `LaneConsolePane`, and the workspace home's prompt box (`WorkspaceHome.tsx`: AI and ONE Model chip — `ModelMenu` in `RunSetup.tsx`, the model list plus Effort, a proxy's own models on a proxy — inside the box; AI Proxy, Permission and Local / New worktree in a row under it, like Claude Desktop) is the one place a session starts with a prompt (the session view's tab bar `+`, `ConsoleTabs.tsx`, starts a bare CLI or a plain shell — `shell: true`, nothing from the request but the slot; the home's AI menu has the same **Open terminal**). `claude`/`codex` keep their hand-written argv builders (`launcher` in their catalog file — a `DASHBOARD_AI_TOOLS_DIR` file may NOT claim one); every other tool is started by its detected absolute path as `[path, ...args, ...resume?, ...bypass?, ...prompt]` (`...resumeById, ` in place of `resume` for a named session, only when its catalog file declares `resumeById`), every piece but the prompt from its catalog file, the prompt ONE argv element or, for an `after-start` tool, a tmux paste buffer (`tmux.pasteText`) after `delayMs`. Codex-only fields (`sandbox`, `approval`) are validated against the fixed allowlists in `server/lib/pty-run.js`. **Bypass args are emitted ONLY when the run request carries `bypass: true`** — the home's red per-run switch, which starts from the account's own default (Settings → AI tools → **Session defaults**, `client/src/lib/sessionDefaults.ts`: Claude's default mode may be `bypassPermissions`, Codex's bypass may default on — set only behind a confirm, never by the first-run wizard, never for the pet chat; the server never remembers it) — and they come from the tool's catalog file (Codex: `--dangerously-bypass-approvals-and-sandbox`, which then replaces `-s`/`-a`); Claude has none (it has the `bypassPermissions` mode), and `--dangerously-bypass-hook-trust` is never emitted. Every pane gets the dashboard's hydrated `PATH` through tmux `-e`. Only `claude`/`codex` are `proxyCapable`; `resolveProxy` accepts only `native` for any other tool, else `EUNSUPPORTED`. `GET /api/run/binary?tool=` resolves through the catalog — the query string never names a command. Do not add a route that takes a command, args or env for a tool from a request. diff --git a/client/src/components/lanes/LaneChangesPanel.tsx b/client/src/components/lanes/LaneChangesPanel.tsx index ebf8794d..e16430b5 100644 --- a/client/src/components/lanes/LaneChangesPanel.tsx +++ b/client/src/components/lanes/LaneChangesPanel.tsx @@ -54,6 +54,11 @@ * .gitignore (`ignore`, untracked only). While a merge is open the box is * hidden: a partial commit cannot finish one, Complete does. * + * **Shelve (n)** beside Discard sets the ticked files aside (`shelve`, with + * an optional one-line note), and the Shelves list (`LaneShelves`) above the + * file list restores or deletes what was shelved — a git stash under the + * hood, its recovery always a button here. + * * History rewrites through its commit menu: Revert (any commit), Undo commit * (the top one; the server refuses a pushed one) and, while another branch's * history is shown, Cherry-pick onto this branch. The commit row's Amend box @@ -92,6 +97,7 @@ import { } from "react"; import { useTranslation } from "react-i18next"; import { + Archive, ChevronDown, ChevronLeft, ChevronRight, @@ -142,6 +148,8 @@ import { MarkdownPreview, isMarkdown } from "../MarkdownPreview"; import { fetchVia } from "../HtmlFrame"; import { HtmlViewerModal } from "../HtmlViewerModal"; import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader"; +import { LaneShelves, withAgentConfirm } from "./LaneShelves"; +import { useGitChanged } from "../../lib/gitChanged"; import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree"; import { dragProjectPath } from "../../lib/promptAttach"; import { commitGraph, type GraphRow } from "../../lib/commitGraph"; @@ -876,6 +884,11 @@ export function LaneChangesPanel({ // Bumped by Refresh and by a git bar action: a pull or a switch moves // history and the tree too, not just the diff `load` re-reads. const [reread, setReread] = useState(0); + // Any git write on this workspace — from this panel's bar, another repo's + // panel or the session view — re-reads everything here (`rereadAll`, set + // once it is defined below). + const rereadRef = useRef<(() => void) | null>(null); + useGitChanged(laneId, () => rereadRef.current?.()); const [range, setRange] = useState("work"); // Local mode: the files are already on this machine — no upload, download // or File Explorer (the desktop's file manager does all three). @@ -979,6 +992,9 @@ export function LaneChangesPanel({ const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null); const pendingScroll = useRef(null); const [message, setMessage] = useState(""); + // Shelve (n) opened its note row; the note typed there. + const [shelving, setShelving] = useState(false); + const [shelfNote, setShelfNote] = useState(""); // The commit row amends the last commit instead of making a new one. const [amend, setAmend] = useState(false); // The last commit is already on its upstream: the server refuses to amend @@ -1514,6 +1530,36 @@ export function LaneChangesPanel({ } }; + // Shelve (n): the ticked files (whole files — a stash has no hunks) set + // aside with the note; all of them when every file is ticked. + const shelveTicked = async () => { + if (!tickedPaths.length) return; + const lane = laneId; + setBusy(true); + setError(null); + try { + const all = tickedPaths.length >= files.length; + const res = await withAgentConfirm((confirm) => + api.lanes.gitAction(target, "shelve", { + ...(all ? {} : { paths: tickedPaths }), + ...(shelfNote.trim() ? { note: shelfNote.trim() } : {}), + ...(confirm ? { confirm } : {}), + }) + ); + if (laneNow.current !== lane || !res) return; + toast.success(t("shelves.shelved", { count: res.shelf?.files.length ?? tickedPaths.length })); + setShelving(false); + setShelfNote(""); + refreshGit(); + setReread((n) => n + 1); + await load(); + } catch (err) { + if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err)); + } finally { + if (laneNow.current === lane) setBusy(false); + } + }; + // resolve / merge-commit / merge-abort: same shape as `act`, other body. const mergeAct = async ( action: "resolve" | "merge-commit" | "merge-abort", @@ -2020,6 +2066,7 @@ export function LaneChangesPanel({ setReread((n) => n + 1); return load(); }; + rereadRef.current = () => void rereadAll(); const [rereading, runReread] = useSpin(rereadAll); // Files tab transfers (issue #78): a download is a plain attachment link; an @@ -2679,6 +2726,12 @@ export function LaneChangesPanel({ )} + {/* Changes set aside (git stash entries AgentHub made): Restore and + Delete, never dropped on their own. Absent while there are none. */} + {git?.available && mode !== "file" && ( + + )} + {/* In single-file mode the DIFF owns the scrolling, so its horizontal bar sits at the bottom of the visible box instead of at the bottom of a 2000-line container nobody has scrolled to yet. Every other mode is @@ -2893,7 +2946,54 @@ export function LaneChangesPanel({ {t("changes.discardTicked", { count: tickedPaths.length })} + + {shelving && ( +
+ setShelfNote(e.target.value)} + onKeyDown={(e) => { + if (isSubmitEnter(e)) void shelveTicked(); + if (e.key === "Escape") setShelving(false); + }} + placeholder={t("shelves.notePlaceholder")} + aria-label={t("shelves.notePlaceholder")} + className="min-w-[10rem] flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none" + /> + + +
+ )} )} {/* The work range splits like JetBrains' Commit window: tracked diff --git a/client/src/components/lanes/LaneGitBar.tsx b/client/src/components/lanes/LaneGitBar.tsx index 38439c89..ce25be83 100644 --- a/client/src/components/lanes/LaneGitBar.tsx +++ b/client/src/components/lanes/LaneGitBar.tsx @@ -15,7 +15,16 @@ * icons on the same row as the branch line, rather than in the lane card's ⋯ menu: that menu holds the rare and dangerous (`reset * worktree`), and these are the ordinary moves a lane needs several times an hour. * Nothing here decides what an action may do; the server does (pull is always - * --ff-only, switch refuses a dirty tree), and this only reports what it said. + * --ff-only, switch refuses a dirty tree unless told what to do with the + * changes), and this only reports what it said. With uncommitted changes, + * Switch branch asks first — **Bring my changes** (`changes: "carry"`; a + * file git would overwrite comes back as ECARRYCONFLICT, listed, with Shelve + * and switch offered instead) or **Shelve and switch** (`changes: "shelve"`) + * — and the new-branch row offers **Create & switch** (`switch: true`, the + * changes come along; the default on Enter while the tree is dirty) beside + * plain Create. A switch onto a branch that has shelves asks whether to + * restore the newest (never automatically), and a write the server answers + * EAGENTRUNNING is confirmed and resent (`withAgentConfirm`'s dialog). * Failures print git's own text, because "! [rejected] non-fast-forward" tells * the user more than any sentence this file could write. The one exception * is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt @@ -45,6 +54,7 @@ import { toast } from "../../lib/toast"; import { confirmDialog } from "../../lib/confirm"; import { isSubmitEnter } from "../../lib/keys"; import type { LaneGitFacts, LaneWorktree } from "../../lib/types"; +import { restoreShelf } from "./LaneShelves"; type Action = "fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish"; @@ -128,6 +138,9 @@ export function LaneGitBar({ const [remember, setRemember] = useState(true); const [publishing, setPublishing] = useState(false); const [url, setUrl] = useState(""); + // A switch with uncommitted changes asks how to take them: set to the + // branch picked (and the files git refused to carry, after ECARRYCONFLICT). + const [choice, setChoice] = useState<{ branch: string; conflict?: string[] } | null>(null); // The Changes panel is not keyed by lane, so this bar outlives a lane switch. // All of the state above describes ONE lane — a branch picked for lane A, // applied after switching, would move B's HEAD — so it is reset during @@ -150,6 +163,7 @@ export function LaneGitBar({ setPassword(""); setPublishing(false); setUrl(""); + setChoice(null); inFlight.current = false; } const laneNow = useRef(laneId); @@ -168,6 +182,9 @@ export function LaneGitBar({ const lane = laneId; setBusy(action); setError(null); + // EAGENTRUNNING: asked AFTER the request settles, so the resend is not + // refused as a second action in flight. + let askAgent = false; try { const res = await api.lanes.gitAction(gitTarget(lane, session, source), action, body || {}); // Landed after a lane switch: every write below belongs to the lane @@ -190,20 +207,50 @@ export function LaneGitBar({ if (action === "switch") setSwitching(false); else setNewBranch(""); setPicked(""); + setChoice(null); setBranches(null); await refreshBranches(); } else if ((action === "fetch" || action === "pull") && branches !== null) { // A fetch may have brought branches the list does not show yet. await refreshBranches(); } + if (res?.shelved) toast.success(t("gitBar.shelvedOn", { branch: res.shelved.branch })); onChanged(); + // The branch switched to has changes shelved on it: offer them back — + // asking, never restoring on its own. + const offer = action === "switch" ? res?.shelves?.[0] : undefined; + if (offer) { + const yes = await confirmDialog({ + title: t("gitBar.restoreOfferTitle"), + message: t("gitBar.restoreOffer", { + branch: offer.branch, + count: res?.shelves?.length ?? 1, + files: offer.files.length, + }), + confirmLabel: t("shelves.restore"), + destructive: false, + }); + if (yes && laneNow.current === lane) { + await restoreShelf(gitTarget(lane, session, source), offer); + if (laneNow.current === lane) onChanged(); + } + } } catch (err) { // The server appends git's own stderr to the message, so this is the // rejection text verbatim — hence the
 below rather than a line.
       // ENOREMOTE is the one refusal with no git text to show: the server's
       // sentence is English and says less than a translated one.
       const auth = (err as { code?: string; auth?: GitAuthNeeded })?.auth;
-      if (laneNow.current === lane && (err as { code?: string })?.code === "EGITAUTH" && auth) {
+      const code = (err as { code?: string })?.code;
+      if (laneNow.current === lane && code === "EAGENTRUNNING" && body?.confirm !== true) {
+        askAgent = true;
+      } else if (laneNow.current === lane && code === "ECARRYCONFLICT" && body?.branch) {
+        // git would overwrite these files: show them and offer the shelf.
+        setChoice({
+          branch: body.branch,
+          conflict: (err as { files?: string[] }).files ?? [],
+        });
+      } else if (laneNow.current === lane && code === "EGITAUTH" && auth) {
         setLogin({ action, auth });
         setUsername((u) => u || auth.username || "");
         setPassword("");
@@ -221,6 +268,15 @@ export function LaneGitBar({
         setBusy(null);
       }
     }
+    if (askAgent) {
+      const ok = await confirmDialog({
+        title: t("shelves.agentTitle"),
+        message: t("shelves.agentMessage"),
+        confirmLabel: t("shelves.agentConfirm"),
+        destructive: false,
+      });
+      if (ok && laneNow.current === lane) void run(action, { ...body, confirm: true });
+    }
   };
 
   const refreshBranches = async () => {
@@ -306,6 +362,20 @@ export function LaneGitBar({
     if (ok) void run("merge", { branch });
   };
 
+  // Switch branch: straight away on a clean tree; with changes, ask how.
+  const switchTo = (branch: string) => {
+    if (dirty > 0) {
+      setChoice({ branch });
+      setError(null);
+    } else void run("switch", { branch });
+  };
+  // Enter in the new-branch box: with changes, Create & switch (they come
+  // along); on a clean tree, plain Create as before.
+  const createBranch = (andSwitch: boolean) => {
+    const branch = newBranch.trim();
+    if (branch) void run("branch-create", andSwitch ? { branch, switch: true } : { branch });
+  };
+
   const heldBy = new Map(
     worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
   );
@@ -424,13 +494,13 @@ export function LaneGitBar({
               label={t("gitBar.switch")}
               icon={}
               disabled={!!busy || merging}
-              // A dirty tree is refused server-side; saying so before the click
-              // beats a red box after it.
+              // With changes, Switch branch asks whether to bring or shelve
+              // them; the hint says so before the click.
               title={
                 merging
                   ? t("gitBar.merging", { op: git.operation ?? "merge" })
                   : dirty > 0
-                    ? t("gitBar.switchDirty")
+                    ? t("gitBar.switchDirtyHint", { count: dirty })
                     : t("gitBar.switchHint")
               }
               onClick={openSwitch}
@@ -473,9 +543,11 @@ export function LaneGitBar({
             busy={busy === "switch"}
             // A branch git already has checked out somewhere else cannot be
             // switched to — git allows one worktree per branch.
-            disabled={!picked || picked === git.branch || !!heldBy.get(picked) || dirty > 0}
-            title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
-            onClick={() => void run("switch", { branch: picked })}
+            disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
+            title={
+              dirty > 0 ? t("gitBar.switchDirtyHint", { count: dirty }) : t("gitBar.switchHint")
+            }
+            onClick={() => switchTo(picked)}
           />
           
-          {dirty > 0 && (
-            {t("gitBar.switchDirty")}
+        
+      )}
+
+      {switching && choice && (
+        
+

+ {choice.conflict + ? t("gitBar.carryConflict", { branch: choice.branch }) + : t("gitBar.choiceMessage", { count: dirty, branch: choice.branch })} +

+ {choice.conflict && choice.conflict.length > 0 && ( +
    + {choice.conflict.map((f) => ( +
  • + {f} +
  • + ))} +
)} +
+ {!choice.conflict && ( + void run("switch", { branch: choice.branch, changes: "carry" })} + /> + )} + void run("switch", { branch: choice.branch, changes: "shelve" })} + /> + setChoice(null)} /> +
)} {switching && ( -
+
setNewBranch(e.target.value)} onKeyDown={(e) => { - if (isSubmitEnter(e) && newBranch.trim()) - void run("branch-create", { branch: newBranch.trim() }); + if (isSubmitEnter(e)) createBranch(dirty > 0); }} placeholder={t("gitBar.branchNewPlaceholder")} aria-label={t("gitBar.branchNewPlaceholder")} - className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none" + className="min-w-[8rem] flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none" + /> + } + busy={busy === "branch-create"} + disabled={!newBranch.trim()} + // The working tree stays as it is: uncommitted changes come along. + title={t("gitBar.branchCreateSwitchHint")} + onClick={() => createBranch(true)} /> } busy={busy === "branch-create"} disabled={!newBranch.trim()} - // Creating does not switch: the point is usually a sibling lane, - // and moving this pane's HEAD under a working agent is not. + // Plain Create does not switch: the point is usually a sibling + // lane, and moving this pane's HEAD under a working agent is not. title={t("gitBar.branchCreateHint")} - onClick={() => void run("branch-create", { branch: newBranch.trim() })} + onClick={() => createBranch(false)} />
)} diff --git a/client/src/components/lanes/LaneGitHeader.tsx b/client/src/components/lanes/LaneGitHeader.tsx index 58da28a1..3dbdcbbd 100644 --- a/client/src/components/lanes/LaneGitHeader.tsx +++ b/client/src/components/lanes/LaneGitHeader.tsx @@ -10,7 +10,9 @@ * It used to sit in the lane card, a column away from the diff it describes — * a pull there changed files the Changes panel only noticed on its next poll. * Here the two share one read of `GET /api/lanes/:id/git` (`useLaneGitFacts`, - * owned by the panel) and refresh each other after every write. + * owned by the panel) and refresh each other after every write. Every other + * reader of the facts (the rail counts, a second panel) hears a write through + * `agenthub:git-changed` (lib/gitChanged.ts), which `useLaneGitFacts` listens to. * @author Nguyễn Ngọc Trí Vĩ */ @@ -21,6 +23,7 @@ import { GitBranch } from "lucide-react"; import { LaneGitBar } from "./LaneGitBar"; import { api, gitTarget, type GitSource } from "../../lib/api"; import { keepIfSame } from "../../lib/keepIfSame"; +import { useGitChanged } from "../../lib/gitChanged"; import type { LaneGitFacts, LaneGitFile } from "../../lib/types"; /** How often the mounted panel re-reads its working-copy facts. Slow on @@ -76,6 +79,9 @@ export function useLaneGitFacts( clearInterval(timer); }; }, [laneId, session, source, nonce]); + // A git write from ANY view of this workspace (another panel, the session + // view, a second tab of the same page) moves these facts too. + useGitChanged(laneId, () => setNonce((n) => n + 1)); return [facts, () => setNonce((n) => n + 1)]; } diff --git a/client/src/components/lanes/LaneShelves.tsx b/client/src/components/lanes/LaneShelves.tsx new file mode 100644 index 00000000..eaacbd81 --- /dev/null +++ b/client/src/components/lanes/LaneShelves.tsx @@ -0,0 +1,241 @@ +/** + * @file The Changes panel's Shelves list: changes set aside by AgentHub (git + * stash entries it made — `GET /api/lanes/:id/git/shelves`), each with its + * branch, note, time and files, and the two buttons that make a shelf safe to + * offer from a dashboard: **Restore** (`unshelve`, which drops the shelf only + * when it applied cleanly; a conflicted restore keeps it and leaves the files + * to the conflict list) and **Delete** (`shelf-drop`, two clicks through + * `useArmed`, sent with `confirm: true`). Collapsed to one line until opened, + * and absent while there are none. Also exports `withAgentConfirm`, the + * one place a git write answered `EAGENTRUNNING` asks the user and is resent + * with `confirm: true`. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Archive, ChevronDown, ChevronRight, RotateCcw, Trash2 } from "lucide-react"; +import i18n from "../../i18n"; +import { api, type GitShelf, type GitTarget } from "../../lib/api"; +import { confirmDialog } from "../../lib/confirm"; +import { toast } from "../../lib/toast"; +import { useArmed } from "../../hooks/useArmed"; + +/** + * Run a git write; when the server answers `EAGENTRUNNING` (an agent of the + * workspace is working and the write would move files under it), ask, and + * resend it with `confirm: true`. Resolves null when the user declined. + */ +export async function withAgentConfirm( + send: (confirm: boolean) => Promise +): Promise { + try { + return await send(false); + } catch (err) { + if ((err as { code?: string })?.code !== "EAGENTRUNNING") throw err; + const ok = await confirmDialog({ + title: i18n.t("lanes:shelves.agentTitle"), + message: i18n.t("lanes:shelves.agentMessage"), + confirmLabel: i18n.t("lanes:shelves.agentConfirm"), + destructive: false, + }); + return ok ? send(true) : null; + } +} + +/** + * Restore one shelf (`unshelve`), reporting the outcome in a toast. Shared by + * the list below and the git bar's "restore what you shelved here" prompt. + * @returns true when anything changed in the working tree. + */ +export async function restoreShelf(target: GitTarget, shelf: GitShelf): Promise { + try { + const res = await withAgentConfirm((confirm) => + api.lanes.gitAction(target, "unshelve", { sha: shelf.sha, ...(confirm ? { confirm } : {}) }) + ); + if (!res) return false; + if (res.conflicts?.length) { + toast.info(i18n.t("lanes:shelves.restoredConflicts", { count: res.conflicts.length })); + } else { + toast.success(i18n.t("lanes:shelves.restored")); + } + return true; + } catch (err) { + toast.error( + (err as { code?: string })?.code === "EDIRTY" + ? i18n.t("lanes:shelves.restoreDirty") + : err instanceof Error + ? err.message + : String(err) + ); + return false; + } +} + +/** "M", "A", "D", "??" → a colour, matching the Changes list's letters. */ +function statusTone(status: string): string { + if (status === "??" || status.startsWith("A")) return "text-status-success"; + if (status.startsWith("D")) return "text-status-danger"; + return "text-status-warning"; +} + +export function LaneShelves({ + target, + reread, + onChanged, +}: { + target: GitTarget; + /** Bumped by the panel after every git write: the list is read again. */ + reread: number; + /** Called after a restore or a delete changed the tree or the list. */ + onChanged: () => void; +}) { + const { t } = useTranslation(["lanes"]); + const [shelves, setShelves] = useState([]); + const [open, setOpen] = useState(false); + const [filesOf, setFilesOf] = useState(null); + const [busy, setBusy] = useState(null); + const del = useArmed(); + const key = JSON.stringify(target); + const keyNow = useRef(key); + keyNow.current = key; + + const load = useCallback(async () => { + const asked = key; + try { + const r = await api.lanes.gitShelves(target); + if (keyNow.current === asked) setShelves(r.available ? r.shelves : []); + } catch { + if (keyNow.current === asked) setShelves([]); + } + // `target` is described by `key`; a new object with the same content is + // the same tree. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); + + useEffect(() => { + void load(); + }, [load, reread]); + + if (!shelves.length) return null; + + const restore = async (s: GitShelf) => { + setBusy(s.sha); + try { + if (await restoreShelf(target, s)) onChanged(); + await load(); + } finally { + setBusy(null); + } + }; + + const drop = async (s: GitShelf) => { + if (!del.isArmed(s.sha)) { + del.arm(s.sha); + return; + } + del.disarm(); + setBusy(s.sha); + try { + await api.lanes.gitAction(target, "shelf-drop", { sha: s.sha, confirm: true }); + toast.success(t("shelves.deleted")); + onChanged(); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(null); + await load(); + } + }; + + return ( +
+ + {open && ( +
    + {shelves.map((s) => { + const when = s.createdAt ? new Date(s.createdAt) : null; + const showFiles = filesOf === s.sha; + return ( +
  • +
    + + {s.branch} + + {when && !Number.isNaN(when.getTime()) && ( + + {when.toLocaleString()} + + )} + +
    + + +
    +
    + {s.note && ( +

    + {s.note} +

    + )} + {showFiles && ( +
      + {s.files.map((f) => ( +
    • + + {f.status === "??" ? "U" : f.status.slice(0, 1)} + + + {f.path} + +
    • + ))} +
    + )} +
  • + ); + })} +
+ )} +
+ ); +} + +export default LaneShelves; diff --git a/client/src/components/lanes/WorkspaceHome.tsx b/client/src/components/lanes/WorkspaceHome.tsx index 89a97bf4..9bfa55ce 100644 --- a/client/src/components/lanes/WorkspaceHome.tsx +++ b/client/src/components/lanes/WorkspaceHome.tsx @@ -22,6 +22,7 @@ import { isSubmitEnter } from "../../lib/keys"; import { confirmDialog } from "../../lib/confirm"; +import { useGitChanged } from "../../lib/gitChanged"; import { endRuns } from "../../lib/stopSession"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -238,6 +239,17 @@ function HomeBody({ live = false; }; }, [lane.id]); + // A branch moved from the git bar: the main worktree's branch shown here is + // the server's cached list, so it is refreshed rather than re-read. + useGitChanged(lane.id, (action) => { + if (!["switch", "branch-create", "branch-delete"].includes(action)) return; + api.projects + .refresh() + .then(({ projects }) => + setProject(projects.find((p) => p.workspaces.includes(lane.id)) ?? null) + ) + .catch(() => undefined); + }); // The AI menu's Open terminal: a plain shell in the first free slot, opened // at once — the same start the session view's tab bar `+` makes. diff --git a/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx b/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx index ac2086b2..60f66945 100644 --- a/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx +++ b/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx @@ -20,6 +20,7 @@ import userEvent from "@testing-library/user-event"; import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel"; import { confirmDialog, confirmDiscard } from "../../../lib/confirm"; import { api } from "../../../lib/api"; +import { emitGitChanged } from "../../../lib/gitChanged"; import type { LaneChanges } from "../../../lib/types"; // The themed confirm needs its host (App); answer it here. @@ -445,6 +446,84 @@ describe("LaneChangesPanel", () => { ); }); + it("shows the NEW branch everywhere after a switch from the git bar", async () => { + // The repo as the server sees it: which branch, which changes. + let branch = "feat/thing"; + vi.mocked(api.lanes.git).mockImplementation(async () => ({ + available: true, + branch, + head: "9b3e74a", + subject: "wip", + dirty: 0, + untracked: 0, + files: [], + hasRemote: false, + upstream: null, + ahead: null, + behind: null, + })); + vi.mocked(api.lanes.changes).mockImplementation(async () => + changes({ + files: [ + { + path: branch === "other" ? "only-on-other.ts" : "only-on-thing.ts", + status: " M", + untracked: false, + unstaged: true, + added: 1, + deleted: 0, + binary: false, + }, + ], + }) + ); + vi.mocked(api.lanes.gitAction).mockImplementation(async (_t, action, body) => { + if (action === "switch") branch = (body as { branch: string }).branch; + return { action, ok: true, output: "", shelves: [] }; + }); + const user = userEvent.setup(); + render(); + expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing"); + await screen.findByTitle("only-on-thing.ts"); + + await user.click(screen.getByRole("button", { name: /^Branch$/ })); + await user.selectOptions(await screen.findByLabelText("Switch branch…"), "other"); + await user.click(screen.getByRole("button", { name: "Switch branch" })); + + await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other")); + expect(await screen.findByTitle("only-on-other.ts")).toBeInTheDocument(); + expect(screen.queryByTitle("only-on-thing.ts")).toBeNull(); + }); + + it("re-reads when ANOTHER view of the lane changed its git state", async () => { + let branch = "feat/thing"; + vi.mocked(api.lanes.git).mockImplementation(async () => ({ + available: true, + branch, + head: "9b3e74a", + subject: "wip", + dirty: 0, + untracked: 0, + files: [], + hasRemote: false, + upstream: null, + ahead: null, + behind: null, + })); + render(); + expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing"); + const before = vi.mocked(api.lanes.changes).mock.calls.length; + branch = "other"; + act(() => emitGitChanged(7, "switch")); + await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other")); + expect(vi.mocked(api.lanes.changes).mock.calls.length).toBeGreaterThan(before); + // Another workspace's write is not this panel's business. + const now = vi.mocked(api.lanes.git).mock.calls.length; + act(() => emitGitChanged(8, "switch")); + await new Promise((r) => setTimeout(r, 20)); + expect(vi.mocked(api.lanes.git).mock.calls.length).toBe(now); + }); + it("re-reads history too when Refresh is pressed there", async () => { const user = userEvent.setup(); render(); diff --git a/client/src/components/lanes/__tests__/LaneGitBar.test.tsx b/client/src/components/lanes/__tests__/LaneGitBar.test.tsx index f0b7c28a..fb0e1381 100644 --- a/client/src/components/lanes/__tests__/LaneGitBar.test.tsx +++ b/client/src/components/lanes/__tests__/LaneGitBar.test.tsx @@ -3,8 +3,10 @@ * @description Pins the lane git action row: the ahead/behind readout, which * buttons a repo with no remote may press (and that it may publish to a typed URL), * what an unborn repo and a branch with no upstream show, that it offers no commit (the - * Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server - * refuses it too), that branches only the remote has are offered (and the list + * Changes panel's staged-only box owns that), that a dirty tree's switch asks + * whether to bring the changes or shelve them (and offers the shelf when git + * refuses to carry them), Create & switch, the offer to restore a branch's + * shelf after switching to it, the confirm-and-resend of EAGENTRUNNING, that branches only the remote has are offered (and the list * is re-read after a fetch), and that a failed action shows git's own words rather than * a generic sentence. * @author Nguyễn Ngọc Trí Vĩ @@ -202,16 +204,114 @@ describe("LaneGitBar", () => { await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "push", {})); }); - it("loads the branch list on demand and refuses to switch while dirty", async () => { + it("loads the branch list on demand and asks how to take the changes while dirty", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: /Branch/ })); await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3)); await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development"); - // Picking is harmless; applying is what a dirty tree blocks. - expect(screen.getByRole("button", { name: "Switch branch" })).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "Switch branch" })); + // Nothing is sent before the user picks what happens to the changes. expect(api.lanes.gitAction).not.toHaveBeenCalled(); + const choice = screen.getByTestId("lane-git-switch-choice"); + expect(choice).toHaveTextContent("3 uncommitted file(s)"); + await user.click(screen.getByRole("button", { name: "Bring my changes" })); + await waitFor(() => + expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", { + branch: "development", + changes: "carry", + }) + ); + }); + + it("offers Shelve and switch when git refuses to carry the changes", async () => { + vi.mocked(api.lanes.gitAction).mockRejectedValueOnce( + Object.assign(new Error("would be overwritten"), { + code: "ECARRYCONFLICT", + files: ["src/a-very-long-path/that/should/truncate.ts"], + }) + ); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /Branch/ })); + await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development"); + await user.click(screen.getByRole("button", { name: "Switch branch" })); + await user.click(screen.getByRole("button", { name: "Bring my changes" })); + expect(await screen.findByTestId("lane-git-carry-conflicts")).toHaveTextContent( + "src/a-very-long-path/that/should/truncate.ts" + ); + expect(screen.queryByRole("button", { name: "Bring my changes" })).toBeNull(); + await user.click(screen.getByRole("button", { name: "Shelve and switch" })); + await waitFor(() => + expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "switch", { + branch: "development", + changes: "shelve", + }) + ); + }); + + it("asks before moving files under a working agent, then resends with confirm", async () => { + vi.mocked(api.lanes.gitAction).mockRejectedValueOnce( + Object.assign(new Error("agent"), { code: "EAGENTRUNNING" }) + ); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /Branch/ })); + await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development"); + await user.click(screen.getByRole("button", { name: "Switch branch" })); + await user.click(screen.getByRole("button", { name: "Shelve and switch" })); + await waitFor(() => + expect(confirmDialog).toHaveBeenCalledWith( + expect.objectContaining({ title: "An agent is working here" }) + ) + ); + await waitFor(() => + expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "switch", { + branch: "development", + changes: "shelve", + confirm: true, + }) + ); + }); + + it("offers to restore the shelf of the branch switched to, and restores it on Yes", async () => { + const shelf = { + ref: "stash@{0}", + sha: "abc1234def", + branch: "development", + note: "", + createdAt: "2026-11-02T00:00:00.000Z", + files: [{ path: "a.txt", status: "M" }], + }; + vi.mocked(api.lanes.gitAction) + .mockResolvedValueOnce({ action: "switch", ok: true, output: "", shelves: [shelf] }) + .mockResolvedValueOnce({ action: "unshelve", ok: true, output: "", conflicts: [] }); + const user = userEvent.setup(); + const onChanged = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: /Branch/ })); + await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development"); + await user.click(screen.getByRole("button", { name: "Switch branch" })); + await waitFor(() => + expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "unshelve", { sha: "abc1234def" }) + ); + expect(confirmDialog).toHaveBeenCalledWith( + expect.objectContaining({ title: "Restore shelved changes?" }) + ); + }); + + it("creates and switches in one step — the default on Enter while dirty", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /Branch/ })); + await user.type(await screen.findByLabelText("New branch name…"), "fix/ac-515{Enter}"); + await waitFor(() => + expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "branch-create", { + branch: "fix/ac-515", + switch: true, + }) + ); }); it("switches branch on a clean tree", async () => { diff --git a/client/src/components/lanes/__tests__/LaneShelves.test.tsx b/client/src/components/lanes/__tests__/LaneShelves.test.tsx new file mode 100644 index 00000000..1b0e6ada --- /dev/null +++ b/client/src/components/lanes/__tests__/LaneShelves.test.tsx @@ -0,0 +1,106 @@ +/** + * @file LaneShelves.test.tsx + * @description Pins the Changes panel's Shelves list: hidden while there are + * none, one collapsed line otherwise, each shelf's branch, note and files, + * Restore sending `unshelve` by sha (and the conflict outcome told in a + * toast), Delete needing a second click before `shelf-drop` with + * `confirm: true`. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { LaneShelves } from "../LaneShelves"; +import { api } from "../../../lib/api"; +import { toast } from "../../../lib/toast"; + +vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) })); +vi.mock("../../../lib/toast", () => ({ + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, +})); +vi.mock("../../../lib/api", () => ({ + api: { lanes: { gitAction: vi.fn(), gitShelves: vi.fn() } }, +})); + +const shelf = { + ref: "stash@{0}", + sha: "abc1234def5678", + branch: "fix/ac-515-a-rather-long-branch-name", + note: "half way through the login fix", + createdAt: "2026-11-02T03:04:05.000Z", + files: [ + { path: "src/login.ts", status: "M" }, + { path: "src/new.ts", status: "??" }, + ], +}; + +beforeEach(() => { + vi.mocked(api.lanes.gitShelves) + .mockReset() + .mockResolvedValue({ + available: true, + shelves: [shelf], + }); + vi.mocked(api.lanes.gitAction).mockReset().mockResolvedValue({ + action: "unshelve", + ok: true, + output: "", + conflicts: [], + }); +}); + +describe("LaneShelves", () => { + it("renders nothing while there are no shelves", async () => { + vi.mocked(api.lanes.gitShelves).mockResolvedValue({ available: true, shelves: [] }); + const { container } = render(); + await waitFor(() => expect(api.lanes.gitShelves).toHaveBeenCalledWith(3)); + expect(container).toBeEmptyDOMElement(); + }); + + it("lists a shelf with its branch, note and files", async () => { + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole("button", { name: /Shelves \(1\)/ })); + const row = screen.getByTestId("lane-shelf"); + expect(row).toHaveTextContent(shelf.branch); + expect(row).toHaveTextContent(shelf.note); + await user.click(screen.getByRole("button", { name: "2 file(s)" })); + expect(screen.getByTestId("lane-shelf-files")).toHaveTextContent("src/new.ts"); + }); + + it("restores by sha and reports a conflicted restore", async () => { + vi.mocked(api.lanes.gitAction).mockResolvedValueOnce({ + action: "unshelve", + ok: true, + output: "", + conflicts: ["src/login.ts"], + kept: true, + }); + const user = userEvent.setup(); + const onChanged = vi.fn(); + render(); + await user.click(await screen.findByRole("button", { name: /Shelves/ })); + await user.click(screen.getByRole("button", { name: "Restore" })); + await waitFor(() => + expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "unshelve", { sha: shelf.sha }) + ); + expect(onChanged).toHaveBeenCalled(); + expect(toast.info).toHaveBeenCalledWith(expect.stringContaining("1 conflicted file")); + }); + + it("deletes only on the second click, with confirm", async () => { + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole("button", { name: /Shelves/ })); + await user.click(screen.getByRole("button", { name: "Delete" })); + expect(api.lanes.gitAction).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Delete for good?" })); + await waitFor(() => + expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "shelf-drop", { + sha: shelf.sha, + confirm: true, + }) + ); + }); +}); diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index 807db63e..9e39c403 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -197,7 +197,7 @@ "gitBar.switchApply": "Switch branch", "gitBar.switchCurrent": "current", "gitBar.remoteBranches": "On the remote — switching creates the local branch", - "gitBar.switchDirty": "Commit or discard the uncommitted changes first", + "gitBar.switchDirty": "Commit, discard or shelve the uncommitted changes first", "gitBar.switchHint": "Switch this worktree to another local branch", "gitBar.switchLabel": "Switch branch…", "gitBar.unbornHint": "Commit first, then publish this repo to a remote.", @@ -712,5 +712,38 @@ "menu.newGroupWith": "New group…", "tree.groupAddWorkspace": "New workspace in this group…", "tree.groupFoldAll": "Fold all groups", - "tree.groupUnfoldAll": "Unfold all groups" + "tree.groupUnfoldAll": "Unfold all groups", + "gitBar.switchDirtyHint": "{{count}} uncommitted file(s): you will be asked whether to bring them along or shelve them", + "gitBar.choiceTitle": "Switch to {{branch}}", + "gitBar.choiceMessage": "You have {{count}} uncommitted file(s). Switch to {{branch}} and…", + "gitBar.choiceCarry": "Bring my changes", + "gitBar.choiceCarryHint": "Switch and keep the uncommitted changes in the working tree (git refuses if a file would be overwritten)", + "gitBar.choiceShelve": "Shelve and switch", + "gitBar.choiceShelveHint": "Set the changes aside on a shelf, then switch; restore them later from Shelves", + "gitBar.carryConflict": "Your changes cannot come along to {{branch}}: git would overwrite these files. Shelve them instead?", + "gitBar.branchCreateSwitch": "Create & switch", + "gitBar.branchCreateSwitchHint": "Create this branch and switch to it; uncommitted changes come along", + "gitBar.shelvedOn": "Your changes on {{branch}} are on a shelf", + "gitBar.restoreOfferTitle": "Restore shelved changes?", + "gitBar.restoreOffer": "{{branch}} has {{count}} shelf/shelves. Restore the newest ({{files}} file(s)) now?", + "shelves.title": "Shelves ({{count}})", + "shelves.files": "{{count}} file(s)", + "shelves.restore": "Restore", + "shelves.restoreHint": "Put these changes back (needs a clean working tree); the shelf is deleted once it applies cleanly", + "shelves.delete": "Delete", + "shelves.deleteConfirm": "Delete for good?", + "shelves.deleteHint": "Delete this shelf and the changes in it (two clicks)", + "shelves.deleted": "Shelf deleted", + "shelves.restored": "Shelved changes restored", + "shelves.restoredConflicts": "Restored with {{count}} conflicted file(s): resolve them in the list. The shelf is kept until you delete it.", + "shelves.restoreDirty": "Commit, discard or shelve the current changes before restoring a shelf", + "shelves.shelveTicked": "Shelve ({{count}})", + "shelves.shelveHint": "Set the ticked files aside on a shelf and clean them from the working tree", + "shelves.shelveApply": "Shelve {{count}} file(s)", + "shelves.notePlaceholder": "Note for this shelf (optional)…", + "shelves.cancel": "Cancel", + "shelves.shelved": "{{count}} file(s) shelved", + "shelves.agentTitle": "An agent is working here", + "shelves.agentMessage": "An agent in this workspace is working right now. This moves files under it, and it may get confused or overwrite them. Continue anyway?", + "shelves.agentConfirm": "Continue" } diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index 8c8ed909..2c3d4644 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -197,7 +197,7 @@ "gitBar.switchApply": "Chuyển nhánh", "gitBar.switchCurrent": "hiện tại", "gitBar.remoteBranches": "Trên remote — switch sẽ tạo nhánh local", - "gitBar.switchDirty": "Hãy commit hoặc bỏ các thay đổi chưa commit trước", + "gitBar.switchDirty": "Hãy commit, bỏ hoặc cất (shelve) các thay đổi chưa commit trước", "gitBar.switchHint": "Đổi worktree này sang nhánh local khác", "gitBar.switchLabel": "Đổi nhánh…", "gitBar.unbornHint": "Hãy commit trước, rồi publish repo này lên remote.", @@ -712,5 +712,38 @@ "menu.newGroupWith": "Nhóm mới…", "tree.groupAddWorkspace": "Workspace mới trong nhóm này…", "tree.groupFoldAll": "Thu gọn mọi nhóm", - "tree.groupUnfoldAll": "Mở rộng mọi nhóm" + "tree.groupUnfoldAll": "Mở rộng mọi nhóm", + "gitBar.switchDirtyHint": "{{count}} file chưa commit: bạn sẽ được hỏi mang theo hay cất chúng lại", + "gitBar.choiceTitle": "Chuyển sang {{branch}}", + "gitBar.choiceMessage": "Bạn có {{count}} file chưa commit. Chuyển sang {{branch}} và…", + "gitBar.choiceCarry": "Mang thay đổi theo", + "gitBar.choiceCarryHint": "Chuyển nhánh và giữ nguyên thay đổi chưa commit trong thư mục (git từ chối nếu một file sẽ bị ghi đè)", + "gitBar.choiceShelve": "Cất rồi chuyển", + "gitBar.choiceShelveHint": "Cất thay đổi vào một shelf rồi chuyển nhánh; khôi phục lại sau ở mục Shelves", + "gitBar.carryConflict": "Không thể mang thay đổi sang {{branch}}: git sẽ ghi đè các file này. Cất chúng lại thay vì vậy?", + "gitBar.branchCreateSwitch": "Tạo & chuyển", + "gitBar.branchCreateSwitchHint": "Tạo nhánh này và chuyển sang; thay đổi chưa commit được mang theo", + "gitBar.shelvedOn": "Thay đổi trên {{branch}} đã được cất vào shelf", + "gitBar.restoreOfferTitle": "Khôi phục thay đổi đã cất?", + "gitBar.restoreOffer": "{{branch}} có {{count}} shelf. Khôi phục shelf mới nhất ({{files}} file) ngay?", + "shelves.title": "Shelves ({{count}})", + "shelves.files": "{{count}} file", + "shelves.restore": "Khôi phục", + "shelves.restoreHint": "Đưa các thay đổi này trở lại (cần thư mục sạch); shelf được xoá khi áp dụng không xung đột", + "shelves.delete": "Xoá", + "shelves.deleteConfirm": "Xoá hẳn?", + "shelves.deleteHint": "Xoá shelf này cùng các thay đổi trong nó (bấm hai lần)", + "shelves.deleted": "Đã xoá shelf", + "shelves.restored": "Đã khôi phục thay đổi đã cất", + "shelves.restoredConflicts": "Đã khôi phục với {{count}} file xung đột: hãy giải quyết trong danh sách. Shelf được giữ đến khi bạn xoá.", + "shelves.restoreDirty": "Hãy commit, bỏ hoặc cất thay đổi hiện tại trước khi khôi phục một shelf", + "shelves.shelveTicked": "Cất ({{count}})", + "shelves.shelveHint": "Cất các file đã tick vào một shelf và dọn chúng khỏi thư mục làm việc", + "shelves.shelveApply": "Cất {{count}} file", + "shelves.notePlaceholder": "Ghi chú cho shelf này (tuỳ chọn)…", + "shelves.cancel": "Huỷ", + "shelves.shelved": "Đã cất {{count}} file", + "shelves.agentTitle": "Một agent đang làm việc ở đây", + "shelves.agentMessage": "Một agent trong workspace này đang làm việc. Thao tác này di chuyển file ngay dưới nó, agent có thể bị nhầm hoặc ghi đè lên. Vẫn tiếp tục?", + "shelves.agentConfirm": "Tiếp tục" } diff --git a/client/src/lib/__tests__/gitWriteFreshness.test.ts b/client/src/lib/__tests__/gitWriteFreshness.test.ts new file mode 100644 index 00000000..fb4f1bc5 --- /dev/null +++ b/client/src/lib/__tests__/gitWriteFreshness.test.ts @@ -0,0 +1,90 @@ +/** + * @file Pins the cause of "after Switch branch the Changes panel still shows + * the old branch": `request()` shares one in-flight GET between callers, and + * the panel's re-read after a switch joined a poll that had STARTED before + * the switch finished — so it got the old branch's answer. A write now + * closes every in-flight GET to new joiners (before it is sent and once it + * settles), and `gitAction` announces itself (`agenthub:git-changed`) so the + * views that never hear the bar's callback read again too. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { api } from "../api"; +import { GIT_CHANGED_EVENT } from "../gitChanged"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function json(body: unknown) { + return { + ok: true, + status: 200, + headers: { get: () => "application/json" }, + json: async () => body, + }; +} + +describe("a git write and the reads around it", () => { + it("a read asked after a switch never joins a read started before it", async () => { + let releaseOld: (v: unknown) => void = () => {}; + const spy = vi.fn((_url: string, init?: RequestInit) => { + if (init?.method === "POST") return Promise.resolve(json({ ok: true, action: "switch" })); + // The first GET (a poll) hangs until released, answering the OLD branch. + if (spy.mock.calls.filter((c) => !c[1]?.method).length === 1) { + return new Promise((r) => { + releaseOld = () => r(json({ branch: "old" })); + }); + } + return Promise.resolve(json({ branch: "new" })); + }); + vi.stubGlobal("fetch", spy); + + const poll = api.lanes.git(3); + await api.lanes.gitAction(3, "switch", { branch: "new" }); + const after = api.lanes.git(3); + releaseOld(undefined); + expect(await poll).toEqual({ branch: "old" }); + expect(await after).toEqual({ branch: "new" }); + expect(spy.mock.calls.filter((c) => !c[1]?.method)).toHaveLength(2); + }); + + it("still shares a read between callers when no write came between them", async () => { + const spy = vi.fn(() => Promise.resolve(json({ branch: "x" }))); + vi.stubGlobal("fetch", spy); + await Promise.all([api.lanes.git(4), api.lanes.git(4)]); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("announces every git write for its workspace, a failed one included", async () => { + const seen: unknown[] = []; + const on = (e: Event) => seen.push((e as CustomEvent).detail); + window.addEventListener(GIT_CHANGED_EVENT, on); + try { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(json({ ok: true }))) + ); + await api.lanes.gitAction({ lane: 5, session: "s" }, "shelve", {}); + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve({ + ok: false, + status: 409, + headers: { get: () => "application/json" }, + json: async () => ({ error: { code: "ESHELVED", message: "x" } }), + }) + ) + ); + await expect(api.lanes.gitAction(5, "switch", { branch: "b" })).rejects.toThrow(); + } finally { + window.removeEventListener(GIT_CHANGED_EVENT, on); + } + expect(seen).toEqual([ + { laneId: 5, action: "shelve" }, + { laneId: 5, action: "switch" }, + ]); + }); +}); diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 6c71fea6..c82ca3a9 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -438,6 +438,7 @@ import type { import { activeSourcesParam } from "./dataScope"; import i18n from "i18next"; import { applyRange, type DateRange } from "./dateRange"; +import { emitGitChanged } from "./gitChanged"; // Root path all endpoint paths are appended to. Kept relative (no host) so the // same client bundle works behind the Vite dev proxy and in same-origin prod. @@ -995,7 +996,11 @@ export type UserPrefs = { prefs: Record; onboardedAt: string | n /** GETs in flight, by path: concurrent callers of the same read (several * components mounting, a WS event fanning out to many listeners) share one * fetch and one parsed result. Deleted as soon as it settles, so this is - * dedupe, never a cache. Callers must treat the result as read-only. */ + * dedupe, never a cache. Callers must treat the result as read-only. + * A WRITE closes every one of them to new joiners, before it is sent and + * again once it settles: a read asked after a write must see the write, and + * joining a poll that started before it handed back the old state (after a + * branch switch, the Changes panel kept showing the branch just left). */ const inflight = new Map>(); function request(path: string, options?: RequestInit): Promise { @@ -1004,7 +1009,10 @@ function request(path: string, options?: RequestInit): Promise { !options || (Object.keys(options).every((k) => k === "method") && (options.method ?? "GET").toUpperCase() === "GET"); - if (!plainGet) return send(path, options); + if (!plainGet) { + inflight.clear(); + return send(path, options).finally(() => inflight.clear()); + } const hit = inflight.get(path); if (hit) return hit as Promise; const p = send(path, options).finally(() => inflight.delete(path)); @@ -1048,6 +1056,8 @@ async function send(path: string, options?: RequestInit): Promise { auth: body?.error?.auth as GitAuthNeeded | undefined, // ESESSIONLIVE from a workspace start: the pane already holding it. runId: body?.error?.run_id as string | undefined, + // ECARRYCONFLICT from a lane git switch: the files it would overwrite. + files: body?.error?.files as string[] | undefined, // EEXISTS from the File Explorer: the names already taken. names: body?.error?.names as string[] | undefined, // ETOOBIG from a prompt drop: the server's own limit. @@ -1100,6 +1110,20 @@ export type GitTarget = number | { lane: number; session?: string; source?: numb export type GitSource = number | string; /** `/lanes//git` plus `q` and the target's `session=` / `source=`. */ +/** + * A git write, then `agenthub:git-changed` for its workspace — success or + * failure, since a refused shelve or a conflicted restore may still have + * moved files — so every view of that workspace reads again (gitChanged.ts). + */ +function requestAndAnnounce( + path: string, + options: RequestInit, + laneId: number, + action: string +): Promise { + return request(path, options).finally(() => emitGitChanged(laneId, action)); +} + function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string { const params = new URLSearchParams(q); const lane = typeof target === "number" ? target : target.lane; @@ -1228,6 +1252,21 @@ export function gitTarget( return session ? { lane: laneId, session } : laneId; } +/** A shelf: changes set aside by AgentHub (a git stash entry it made). */ +export interface GitShelf { + /** `stash@{n}` right now — informational; writes address `sha`. */ + ref: string; + /** The stash commit; what unshelve and shelf-drop take. */ + sha: string; + /** The branch it was shelved on. */ + branch: string; + note: string; + /** ISO time it was shelved. */ + createdAt: string | null; + /** Its files; `??` = an untracked file. */ + files: { path: string; status: string }[]; +} + /** A file the dashboard machine's file manager holds, by its own path. */ export interface FileManagerFile { path: string; @@ -4397,6 +4436,12 @@ export const api = { current: string | null; worktrees: LaneWorktree[]; }>(gitUrl(id, "/branches")), + /** + * GET /api/lanes/:id/git/shelves — the stash entries AgentHub made + * (shelves), newest first. Other stashes are never listed. + */ + gitShelves: (id: GitTarget) => + request<{ available: boolean; shelves: GitShelf[] }>(gitUrl(id, "/shelves")), /** * POST /api/lanes/:id/git/commit-message — a message for what is staged, * written by the user's `claude` CLI (no tools, nothing persisted). @@ -4411,7 +4456,7 @@ export const api = { * @param id The lane id. * @param action `fetch`, `pull` (always --ff-only), `push`, `stage`, * `unstage`, `discard`, `commit`, `switch`, `branch-create`, - * `branch-delete` or `publish`. + * `branch-delete`, `publish`, `shelve`, `unshelve` or `shelf-drop`. * @param body `{message, only}` for commit; `{branch, create}` for switch; * `{branch, base}` for branch-create; `{paths}` for stage/unstage; * `{paths, confirm: true}` for discard — which is irreversible, so the @@ -4443,8 +4488,17 @@ export const api = { | "revert" | "undo-commit" | "amend" - | "ignore", + | "ignore" + | "shelve" + | "unshelve" + | "shelf-drop", body: { + /** switch: what happens to uncommitted changes (absent = refuse a dirty tree). */ + changes?: "carry" | "shelve"; + /** branch-create: also switch to the new branch, carrying the changes. */ + switch?: boolean; + /** shelve, switch with changes: "shelve": a one-line note on the shelf. */ + note?: string; /** cherry-pick / revert: the commit. */ sha?: string; url?: string; @@ -4467,9 +4521,25 @@ export const api = { credentials?: GitCredentialInput & { remember: boolean }; } = {} ) => - request<{ action: string; ok: true; output: string; branch?: string; conflicts?: string[] }>( + requestAndAnnounce<{ + action: string; + ok: true; + output: string; + branch?: string; + conflicts?: string[]; + /** switch: the TARGET branch's shelves, to offer back. */ + shelves?: GitShelf[]; + /** switch with changes: "shelve": the shelf it made. */ + shelved?: GitShelf; + /** shelve: the shelf it made. */ + shelf?: GitShelf; + /** unshelve: true when conflicts kept the shelf. */ + kept?: boolean; + }>( gitUrl(id, `/${action}`), - { method: "POST", body: JSON.stringify(body) } + { method: "POST", body: JSON.stringify(body) }, + typeof id === "number" ? id : id.lane, + action ), /** * GET /api/lanes/:id/runtime — the lane's own application stack: slot, diff --git a/client/src/lib/gitChanged.ts b/client/src/lib/gitChanged.ts new file mode 100644 index 00000000..399aed88 --- /dev/null +++ b/client/src/lib/gitChanged.ts @@ -0,0 +1,48 @@ +/** + * @file gitChanged.ts + * @description One window event, `agenthub:git-changed` `{laneId, action}`, + * fired after EVERY git write the dashboard sends for a workspace + * (`api.lanes.gitAction` fires it, success or failure — a failed shelve or + * a conflicted restore still moved files). Every view that shows that + * workspace's git state — the Changes panel of each repo, the header's facts + * (`useLaneGitFacts`, the rail counts included), the workspace home's branch — + * listens and reads again, so a switch made in one of them is never left + * stale in another for a poll interval. The views do not share a callback; + * this is the bus between them. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useEffect, useRef } from "react"; + +export const GIT_CHANGED_EVENT = "agenthub:git-changed"; + +export interface GitChangedDetail { + laneId: number; + /** The git action that ran (`switch`, `commit`, `shelve`, …). */ + action: string; +} + +/** Tell every view of `laneId` that its git state may have moved. */ +export function emitGitChanged(laneId: number, action: string): void { + if (typeof window === "undefined") return; + window.dispatchEvent( + new CustomEvent(GIT_CHANGED_EVENT, { detail: { laneId, action } }) + ); +} + +/** + * Call `fn` after every git write on workspace `laneId`. The latest `fn` is + * used, so a caller may pass an inline closure without resubscribing. + */ +export function useGitChanged(laneId: number, fn: (action: string) => void): void { + const latest = useRef(fn); + latest.current = fn; + useEffect(() => { + const on = (e: Event) => { + const d = (e as CustomEvent).detail; + if (d && d.laneId === laneId) latest.current(d.action); + }; + window.addEventListener(GIT_CHANGED_EVENT, on); + return () => window.removeEventListener(GIT_CHANGED_EVENT, on); + }, [laneId]); +} diff --git a/docs/API.md b/docs/API.md index e1895362..7de966aa 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1126,8 +1126,8 @@ cannot collide on `index.lock`. | `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U HEAD -- ` — 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 `, or the index as it stands with `only: "staged"`, or — with `paths` — `git commit --only -- `: 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] `; 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 []` — never switches | `{"branch": "...", "base": "..."}` | +| `switch` | `git switch [-c] `; 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 []` — or, with `switch: true`, `git switch -c []`, the uncommitted changes coming along | `{"branch": "...", "base": "...", "switch": true}` | | `branch-delete` | `git branch -d ` | `{"branch": "..."}` | | `publish` | `git remote add origin -- ` 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 ''[ into ]" ` — 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 ` — 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": ""}` | | `amend` | `git commit --amend -m `, 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": "", "paths": ["..."]}` | +| `shelve` | `git stash push --include-untracked -m "agenthub-shelf: · [ · ]" [-- ]` — everything, or exactly the ticked changed `paths` | `{"note": "...", "paths": ["..."]}` | +| `unshelve` | `git stash apply --index ` (retried without `--index` when only the index cannot be rebuilt), then `git stash drop` of that entry when it applied cleanly | `{"sha": ""}` | +| `shelf-drop` | `git stash drop` of that entry | `{"sha": "", "confirm": true}` | | `ignore` | appends one anchored, escaped `/` 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 diff --git a/docs/LANES.md b/docs/LANES.md index d7eaef44..b7ac0cdb 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -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 -- `, 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 ` — uncommitted changes come along (the default on Enter while the tree is dirty) | the branch already exists | | **Create** | `git branch ` — never switches | the branch already exists | | **Delete** | `git branch -d ` | 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 diff --git a/server/README.md b/server/README.md index 2ea709d7..5e50a7f5 100644 --- a/server/README.md +++ b/server/README.md @@ -546,6 +546,7 @@ Request body shape: | `GET` | `/api/lanes/:id/git/download?path=` | One working file of any type as an `application/octet-stream` attachment (ASCII + `filename*=UTF-8''` name) under `Content-Security-Policy: default-src 'none'; sandbox`, `nosniff`, `no-store` (`server/lib/lane-file-transfer.js`). The file read's guards, 512 MB cap. `403 ESECRET`/`EFOREIGN`, `400 EBADPATH`/`ENOFILE`/`ETOOBIG`. | | `POST` | `/api/lanes/:id/git/upload?dir=` | Same-origin guarded. Raw body, name in the URI-encoded `X-File-Name` header, `dir` workspace-relative (`""` = root); `201 {path}`. Creates only (`409 EEXISTS`, a symlink at the name included), into an existing folder (`400 ENOFILE`), never into `.git` (`400 EBADPATH`), one plain name ≤ 255 bytes and not `.git` (`400 EBADNAME`), never a secret name (`403 ESECRET`), 25 MB cap (`413 ETOOBIG`), `403 EFOREIGN`. Under S3 written as the folder's member via `write-as`. | | `GET` | `/api/lanes/:id/git/branches` | The workspace repo's own local branches + worktrees, for the branch switcher. The repo comes from the workspace row, never the query. | +| `GET` | `/api/lanes/:id/git/shelves` | The workspace repo's shelves — the stash entries AgentHub made (`agenthub-shelf:` prefix), each `{ref, sha, branch, note, createdAt, files}`; other stashes are never listed. Read-only; `?session`/`?source`/`?repo` like the other git reads. | | `GET` | `/api/git-credentials` | AgentHub's own git logins (one per host, shared by every user), `hasToken` only — never a password. | | `POST` | `/api/git-credentials` | Same-origin guarded save/replace: `{protocol, host, username, password}`. `400 EBADHOST`/`EBADCRED`. | | `DELETE` | `/api/git-credentials/:protocol/:host` | Same-origin guarded delete. `404 ENOTFOUND`. | @@ -563,7 +564,7 @@ Request body shape: | `GET` | `/api/ai-tools/job` | The current or last job (in memory), or `null`. | | `GET` | `/api/ai-tools/versions` | npm-managed check and newest npm version per npm-installed tool, cached 24 h. The AI Tools modal no longer calls it: Update shows for every updater. | | `GET` | `/api/ai-tools/:id/icon` | The server-cached favicon (`server/lib/ai-tool-icons.js`), raster only; `204` when there is none (a normal state — no console error per letter avatar), `404` for an unknown id. | -| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the workspace lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root workspace, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`; both also finish/abort an open cherry-pick or revert); `cherry-pick`/`revert` (`{sha}`: a non-merge commit, same guards and conflict flow as `merge`, `409 EEMPTY` for a no-op), `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) and `amend` (`{message?}`) — the last two only on an UNPUSHED HEAD (`409 EPUSHED`) that is still the request's `sha` (`409 ESTALE`), from a repo-root workspace; a terminal-started rebase blocks all history actions (`EMERGING`); `EMERGING` blocks pull/switch/merge/hunk/history actions mid-operation. No rebase/hard reset/stash by design. A remote wanting a login is `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`; saved only after git succeeds and `remember` is set). | +| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the workspace lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree unless `changes: "carry"` — `409 ECARRYCONFLICT` with `files` when git would overwrite one — or `changes: "shelve"`; answers the target branch's `shelves`), `branch-create` (never switches unless `switch: true`) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root workspace, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`; both also finish/abort an open cherry-pick or revert); `cherry-pick`/`revert` (`{sha}`: a non-merge commit, same guards and conflict flow as `merge`, `409 EEMPTY` for a no-op), `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) and `amend` (`{message?}`) — the last two only on an UNPUSHED HEAD (`409 EPUSHED`) that is still the request's `sha` (`409 ESTALE`), from a repo-root workspace; a terminal-started rebase blocks all history actions (`EMERGING`); `EMERGING` blocks pull/switch/merge/hunk/history actions mid-operation. `shelve` (`{note?, paths?}`: `stash push --include-untracked` under an `agenthub-shelf:` message), `unshelve` (`{sha}`: clean tree only, `apply --index`, dropped only when clean, a conflict keeps it), `shelf-drop` (`{sha, confirm:true}`) — repo root only, never mid-operation, only entries AgentHub made (`GET /api/lanes/:id/git/shelves` lists them); `409 EAGENTRUNNING` until `confirm:true` while the workspace's agent is working. No rebase/hard reset by design. A remote wanting a login is `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`; saved only after git succeeds and `remember` is set). | The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be an existing absolute git repository. The response workspace starts as diff --git a/server/__tests__/git-ops-shelves.test.js b/server/__tests__/git-ops-shelves.test.js new file mode 100644 index 00000000..741a39f0 --- /dev/null +++ b/server/__tests__/git-ops-shelves.test.js @@ -0,0 +1,333 @@ +/** + * @file Shelves and switching with uncommitted changes (git-ops.js), against + * REAL temp repos: create & switch, switch carrying the changes (and the + * ECARRYCONFLICT refusal with git's file list), shelve & switch (and the + * shelf going straight back when the switch fails), shelve / unshelve / + * shelf-drop with their guards (repo root, no merge in progress, a clean tree + * for a restore, confirm for a delete, a working agent's confirm), sha + * addressing, and that a stash AgentHub did not make is never listed or + * touched. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { describe, it, beforeEach } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); + +const { runGitAction, listShelves } = require("../lib/git-ops"); + +const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-shelves-")); +const REPO = path.join(ROOT, "repo"); + +const g = (...args) => { + const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" }; + delete env.GIT_DIR; + delete env.GIT_WORK_TREE; + delete env.GIT_INDEX_FILE; + return execFileSync("git", args, { cwd: REPO, encoding: "utf8", env }); +}; +const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body); +const read = (file) => fs.readFileSync(path.join(REPO, file), "utf8"); +const exists = (file) => fs.existsSync(path.join(REPO, file)); +const branch = () => g("branch", "--show-current").trim(); +const status = () => g("status", "--porcelain").trim(); +const act = (action, body, opts) => runGitAction(REPO, action, body, opts); + +beforeEach(() => { + fs.rmSync(REPO, { recursive: true, force: true }); + fs.mkdirSync(REPO, { recursive: true }); + g("init", "-q", "-b", "main"); + g("config", "user.email", "t@h"); + g("config", "user.name", "t"); + fs.mkdirSync(path.join(REPO, "sub")); + write("a.txt", "base\n"); + write("shared.txt", "base\n"); + write("sub/s.txt", "s\n"); + g("add", "-A"); + g("commit", "-qm", "init"); + // `feat` changes shared.txt, so carrying an edit of it across is refused. + g("switch", "-qc", "feat"); + write("shared.txt", "feat\n"); + g("commit", "-qam", "feat"); + g("switch", "-q", "main"); +}); + +describe("branch-create with switch", () => { + it("creates the branch, moves to it and carries the uncommitted changes", async () => { + write("a.txt", "wip\n"); + write("new.txt", "n\n"); + const r = await act("branch-create", { branch: "fix/x", switch: true }); + assert.equal(r.branch, "fix/x"); + assert.equal(branch(), "fix/x"); + assert.equal(read("a.txt"), "wip\n"); + assert.equal(read("new.txt"), "n\n"); + }); + + it("plain create still never switches", async () => { + await act("branch-create", { branch: "side" }); + assert.equal(branch(), "main"); + }); + + it("is EMERGING while a merge is open", async () => { + write("shared.txt", "main\n"); + g("commit", "-qam", "main"); + await act("merge", { branch: "feat" }); + await assert.rejects(act("branch-create", { branch: "y", switch: true }), { + code: "EMERGING", + }); + }); +}); + +describe("switch with changes", () => { + it("still refuses a dirty tree without `changes`, and an unknown value", async () => { + write("a.txt", "wip\n"); + await assert.rejects(act("switch", { branch: "feat" }), { code: "EDIRTY" }); + await assert.rejects(act("switch", { branch: "feat", changes: "force" }), { + code: "EBADCHANGES", + }); + }); + + it("carry brings the changes along", async () => { + write("a.txt", "wip\n"); + const r = await act("switch", { branch: "feat", changes: "carry" }); + assert.equal(branch(), "feat"); + assert.equal(read("a.txt"), "wip\n"); + assert.deepEqual(r.shelves, []); + }); + + it("carry answers ECARRYCONFLICT with the files git would overwrite", async () => { + write("shared.txt", "mine\n"); + await assert.rejects(act("switch", { branch: "feat", changes: "carry" }), (e) => { + assert.equal(e.code, "ECARRYCONFLICT"); + assert.deepEqual(e.files, ["shared.txt"]); + return true; + }); + assert.equal(branch(), "main"); + assert.equal(read("shared.txt"), "mine\n"); + }); + + it("shelve sets the changes aside, switches, and the shelf comes back on the way home", async () => { + write("shared.txt", "mine\n"); + write("new.txt", "n\n"); + const r = await act("switch", { branch: "feat", changes: "shelve", note: "half done" }); + assert.equal(branch(), "feat"); + assert.equal(status(), ""); + assert.equal(r.shelved.branch, "main"); + assert.equal(r.shelved.note, "half done"); + assert.deepEqual(r.shelves, []); + const back = await act("switch", { branch: "main" }); + assert.equal(back.shelves.length, 1); + assert.equal(back.shelves[0].sha, r.shelved.sha); + const u = await act("unshelve", { sha: r.shelved.sha }); + assert.equal(u.kept, false); + assert.deepEqual(u.conflicts, []); + assert.equal(read("shared.txt"), "mine\n"); + assert.equal(read("new.txt"), "n\n"); + assert.deepEqual(await listShelves(REPO), []); + }); + + it("refuses before shelving when the target cannot be switched to", async () => { + write("a.txt", "wip\n"); + await assert.rejects(act("switch", { branch: "nope", changes: "shelve" }), { + code: "ENOBRANCH", + }); + assert.deepEqual(await listShelves(REPO), []); + assert.equal(read("a.txt"), "wip\n"); + }); + + it("leaves nothing moved when the shelf cannot even be made", async () => { + write("a.txt", "wip\n"); + fs.writeFileSync(path.join(REPO, ".git", "index.lock"), ""); + try { + await assert.rejects(act("switch", { branch: "feat", changes: "shelve" })); + } finally { + fs.rmSync(path.join(REPO, ".git", "index.lock"), { force: true }); + } + assert.equal(branch(), "main"); + assert.equal(read("a.txt"), "wip\n"); + assert.deepEqual(await listShelves(REPO), []); + }); + + it("puts the shelf straight back when the switch itself fails", async () => { + write("a.txt", "wip\n"); + write("new.txt", "n\n"); + // A branch only the remote has: the switch must create refs/heads/rfeat, + // and a stale lock on it makes git refuse AFTER the shelf was made. + g("remote", "add", "origin", path.join(ROOT, "nowhere.git")); + g("update-ref", "refs/remotes/origin/rfeat", "feat"); + fs.writeFileSync(path.join(REPO, ".git", "refs", "heads", "rfeat.lock"), ""); + try { + await assert.rejects(act("switch", { branch: "origin/rfeat", changes: "shelve" }), (e) => { + assert.notEqual(e.code, "ESHELVED"); + return true; + }); + } finally { + fs.rmSync(path.join(REPO, ".git", "refs", "heads", "rfeat.lock"), { force: true }); + } + assert.equal(branch(), "main"); + assert.equal(read("a.txt"), "wip\n"); + assert.equal(read("new.txt"), "n\n"); + assert.deepEqual(await listShelves(REPO), []); + }); + + it("restores a shelf whose stash failed half way (HEAD locked)", async () => { + write("a.txt", "wip\n"); + fs.writeFileSync(path.join(REPO, ".git", "HEAD.lock"), ""); + try { + await assert.rejects(act("switch", { branch: "feat", changes: "shelve" })); + } finally { + fs.rmSync(path.join(REPO, ".git", "HEAD.lock"), { force: true }); + } + assert.equal(branch(), "main"); + assert.equal(read("a.txt"), "wip\n"); + assert.deepEqual(await listShelves(REPO), []); + }); + + it("asks for confirm while the lane's agent is working", async () => { + write("a.txt", "wip\n"); + await assert.rejects( + act("switch", { branch: "feat", changes: "carry" }, { agentWorking: true }), + { code: "EAGENTRUNNING" } + ); + await act( + "switch", + { branch: "feat", changes: "carry", confirm: true }, + { agentWorking: true } + ); + assert.equal(branch(), "feat"); + }); +}); + +describe("shelves", () => { + it("shelves everything (untracked included) with a note, and lists it", async () => { + write("a.txt", "wip\n"); + write("new.txt", "n\n"); + const r = await act("shelve", { note: "try\nagain" }); + assert.equal(status(), ""); + assert.equal(r.shelf.note, "try again"); + const [s] = await listShelves(REPO); + assert.equal(s.sha, r.shelf.sha); + assert.equal(s.branch, "main"); + assert.match(s.ref, /^stash@\{0\}$/); + assert.ok(!Number.isNaN(Date.parse(s.createdAt))); + assert.deepEqual(s.files.map((f) => `${f.status} ${f.path}`).sort(), ["?? new.txt", "M a.txt"]); + }); + + it("shelves only the ticked paths", async () => { + write("a.txt", "wip\n"); + write("sub/s.txt", "changed\n"); + write("new*.txt", "glob-looking name\n"); + write("new1.txt", "untouched untracked\n"); + const r = await act("shelve", { paths: ["a.txt", "new*.txt"] }); + assert.deepEqual( + r.shelf.files.map((f) => f.path), + ["a.txt", "new*.txt"] + ); + assert.equal(read("a.txt"), "base\n"); + // Literal: `new*.txt` took itself only, never new1.txt. + assert.equal(exists("new*.txt"), false); + assert.equal(read("new1.txt"), "untouched untracked\n"); + assert.equal(read("sub/s.txt"), "changed\n"); + await assert.rejects(act("shelve", { paths: ["../x"] }), { code: "EBADPATH" }); + }); + + it("refuses a clean tree, a subdirectory lane, and a merge in progress", async () => { + await assert.rejects(act("shelve", {}), { code: "ENOCHANGES" }); + write("a.txt", "wip\n"); + await assert.rejects(runGitAction(path.join(REPO, "sub"), "shelve", {}), { + code: "ESUBDIR", + }); + g("checkout", "--", "a.txt"); + write("shared.txt", "main\n"); + g("commit", "-qam", "main"); + await act("merge", { branch: "feat" }); + await assert.rejects(act("shelve", {}), { code: "EMERGING" }); + }); + + it("restores staged changes staged, and refuses a dirty tree", async () => { + write("a.txt", "staged\n"); + g("add", "a.txt"); + const { shelf } = await act("shelve", {}); + write("sub/s.txt", "new work\n"); + await assert.rejects(act("unshelve", { sha: shelf.sha }), { code: "EDIRTY" }); + g("checkout", "--", "sub/s.txt"); + await act("unshelve", { sha: shelf.sha }); + assert.equal(g("diff", "--cached", "--name-only").trim(), "a.txt"); + }); + + it("keeps a conflicted shelf and lets `resolve` settle its files", async () => { + write("shared.txt", "mine\n"); + const { shelf } = await act("shelve", {}); + write("shared.txt", "theirs on main\n"); + g("commit", "-qam", "moved on"); + const r = await act("unshelve", { sha: shelf.sha }); + assert.equal(r.kept, true); + assert.deepEqual(r.conflicts, ["shared.txt"]); + assert.equal((await listShelves(REPO)).length, 1); + // `theirs` of a stash apply is the shelf. + await act("resolve", { path: "shared.txt", side: "theirs" }); + assert.equal(read("shared.txt"), "mine\n"); + assert.equal(g("diff", "--name-only", "--diff-filter=U").trim(), ""); + // The kept shelf is deleted by hand. + await act("shelf-drop", { sha: shelf.sha, confirm: true }); + assert.deepEqual(await listShelves(REPO), []); + }); + + it("shelf-drop needs confirm, and a sha no shelf has is ENOSHELF", async () => { + write("a.txt", "wip\n"); + const { shelf } = await act("shelve", {}); + await assert.rejects(act("shelf-drop", { sha: shelf.sha }), { code: "ECONFIRM" }); + await assert.rejects(act("shelf-drop", { sha: "deadbeef", confirm: true }), { + code: "ENOSHELF", + }); + await assert.rejects(act("unshelve", { sha: "not a sha" }), { code: "EBADSHA" }); + assert.equal((await listShelves(REPO)).length, 1); + }); + + it("addresses a shelf by sha even after another stash shifted its index", async () => { + write("a.txt", "first\n"); + const first = (await act("shelve", { note: "one" })).shelf; + write("sub/s.txt", "second\n"); + const second = (await act("shelve", { note: "two" })).shelf; + // A user's own stash pushed on top, from a terminal. + write("shared.txt", "user\n"); + g("stash", "push", "-q", "-m", "my own stash"); + const listed = await listShelves(REPO); + assert.deepEqual( + listed.map((s) => s.note), + ["two", "one"] + ); + assert.equal(listed[1].ref, "stash@{2}"); + await act("unshelve", { sha: first.sha.slice(0, 10) }); + assert.equal(read("a.txt"), "first\n"); + assert.equal(read("sub/s.txt"), "s\n"); + assert.deepEqual( + (await listShelves(REPO)).map((s) => s.sha), + [second.sha] + ); + // The user's stash is untouched and never listed. + assert.match(g("stash", "list"), /my own stash/); + await assert.rejects( + act("shelf-drop", { + sha: g("rev-parse", "stash@{0}").trim(), + confirm: true, + }), + { code: "ENOSHELF" } + ); + }); + + it("asks for confirm before shelving or restoring under a working agent", async () => { + write("a.txt", "wip\n"); + await assert.rejects(act("shelve", {}, { agentWorking: true }), { code: "EAGENTRUNNING" }); + const { shelf } = await act("shelve", { confirm: true }, { agentWorking: true }); + await assert.rejects(act("unshelve", { sha: shelf.sha }, { agentWorking: true }), { + code: "EAGENTRUNNING", + }); + await act("unshelve", { sha: shelf.sha, confirm: true }, { agentWorking: true }); + assert.equal(read("a.txt"), "wip\n"); + assert.equal(exists("a.txt"), true); + }); +}); diff --git a/server/__tests__/lanes-api.test.js b/server/__tests__/lanes-api.test.js index b5fdee90..5e3c88a3 100644 --- a/server/__tests__/lanes-api.test.js +++ b/server/__tests__/lanes-api.test.js @@ -1126,6 +1126,42 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => { assert.equal(r.body.current, "feat/api-changes"); }); + it("shelves and restores through the routes, asking first while the agent works", async () => { + const lanesLib = require("../lib/lanes"); + // No pane holds the lane: its hook-mirrored `running` IS an agent working. + lanesLib.updateLane(laneId, { status: "running", run_id: null }); + try { + const refused = await request("POST", `/api/lanes/${laneId}/git/shelve`, { note: "api" }); + assert.equal(refused.status, 409); + assert.equal(refused.body.error.code, "EAGENTRUNNING"); + const made = await request("POST", `/api/lanes/${laneId}/git/shelve`, { + note: "api", + confirm: true, + }); + assert.equal(made.status, 200, JSON.stringify(made.body)); + const list = await request("GET", `/api/lanes/${laneId}/git/shelves`); + assert.equal(list.status, 200); + assert.equal(list.body.shelves.length, 1); + assert.equal(list.body.shelves[0].note, "api"); + assert.equal(list.body.shelves[0].sha, made.body.shelf.sha); + const back = await request("POST", `/api/lanes/${laneId}/git/unshelve`, { + sha: made.body.shelf.sha, + confirm: true, + }); + assert.equal(back.status, 200, JSON.stringify(back.body)); + assert.equal(back.body.kept, false); + } finally { + lanesLib.updateLane(laneId, { status: "idle" }); + } + assert.equal(fs.readFileSync(path.join(dir, "fresh.txt"), "utf8"), "new file\n"); + const gone = await request("POST", `/api/lanes/${laneId}/git/shelf-drop`, { + sha: "deadbeef", + confirm: true, + }); + assert.equal(gone.status, 409); + assert.equal(gone.body.error.code, "ENOSHELF"); + }); + it("commits through the action route and leaves the tree clean", async () => { const r = await request("POST", `/api/lanes/${laneId}/git/commit`, { message: "feat: through the api", diff --git a/server/lib/git-ops.js b/server/lib/git-ops.js index 2ce6d5cd..efe2b1a1 100644 --- a/server/lib/git-ops.js +++ b/server/lib/git-ops.js @@ -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, +}; diff --git a/server/openapi-extra/lanes.js b/server/openapi-extra/lanes.js index c2b147b9..77ba7f33 100644 --- a/server/openapi-extra/lanes.js +++ b/server/openapi-extra/lanes.js @@ -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 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 , 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 -- ` 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 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 , 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 [] 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 -- ` 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: · · ` 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).", }, }, }, diff --git a/server/routes/lanes.js b/server/routes/lanes.js index 832f1716..b81555a9 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -65,7 +65,7 @@ const { blameFile, readImageAt, } = require("../lib/git-diff"); -const { runGitAction } = require("../lib/git-ops"); +const { runGitAction, listShelves } = require("../lib/git-ops"); const { withLaneLock } = require("../lib/lane-lock"); const { checkSync, mergeSync, continueSync } = require("../lib/lane-sync"); const { syncMcp } = require("../lib/lane-mcp"); @@ -2209,7 +2209,8 @@ router.get("/:id/git/branches", async (req, res) => { * it) so a push and a commit fired from two tabs or two lanes cannot * interleave on one index, and thin — every * rule about what each action may do lives in server/lib/git-ops.js, not here. - * `git pull` is --ff-only and `switch` refuses a dirty tree, by design there. + * `git pull` is --ff-only and `switch` refuses a dirty tree unless told to + * carry or shelve the changes, by design there. */ /** * A commit message suggested by the user's `claude` CLI for what is STAGED @@ -2233,6 +2234,46 @@ router.post("/:id/git/commit-message", sameOriginGuard, async (req, res) => { } }); +/** + * The workspace's shelves (git-ops.js `listShelves`): the stash entries + * AgentHub made, with their files, newest first. A read, unguarded like the + * other git reads; the repo comes from the lane row (or ?session/?source/ + * ?repo), never from a path in the request. + */ +router.get("/:id/git/shelves", async (req, res) => { + const t = await gitTargetOr404(req, res); + if (!t) return; + try { + res.json({ available: true, shelves: await listShelves(t.cwd) }); + } catch { + res.json({ available: false, shelves: [] }); + } +}); + +/** + * Whether an agent of this workspace is WORKING right now — what makes a + * shelve, a restore or a switch that moves uncommitted changes ask for + * `confirm: true`. A lane no dashboard pane holds mirrors its hooks into + * `status` (running between a prompt and its Stop); a lane a pane holds + * stays `running` while the pane lives, idle prompt included, so there the + * question is whether one of its own sessions has a main agent working. + */ +const agentWorkingStmt = db.prepare( + `SELECT 1 FROM agents a JOIN sessions s ON s.id = a.session_id + WHERE a.type = 'main' AND a.status = 'working' AND s.status = 'active' + AND (s.lane_id = ? OR s.id IN (SELECT session_id FROM dashboard_runs WHERE lane_id = ?)) + LIMIT 1` +); +function agentWorking(lane) { + if (lane.status !== "running") return false; + if (!lane.run_id) return true; + try { + return !!agentWorkingStmt.get(lane.id, lane.id); + } catch { + return true; + } +} + router.post("/:id/git/:action", sameOriginGuard, async (req, res) => { const t = await gitTargetOr404(req, res); if (!t) return; @@ -2247,7 +2288,7 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => { .then((r) => r.stdout.trim()) .catch(() => fs.promises.realpath(cwd).catch(() => cwd)); const result = await withLaneLock(`git:${gitDir}`, () => - runGitAction(cwd, req.params.action, req.body || {}) + runGitAction(cwd, req.params.action, req.body || {}, { agentWorking: agentWorking(lane) }) ); res.json(result); } catch (err) { @@ -2280,6 +2321,7 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => { "EMERGECOMMIT", "ENOPARENT", "ETRACKED", + "EBADCHANGES", ]; // A numeric code is git's own exit status (execFile reports one that way): // git ran and refused — a rejected push, a failed credential, a diverged @@ -2300,6 +2342,10 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => { "EEMPTY", "ESTALE", "EMEMBERGONE", + "ECARRYCONFLICT", + "EAGENTRUNNING", + "ENOSHELF", + "ESHELVED", ].includes(err.code) ? 409 : // Not the request's fault and not a server bug: the host lacks git. @@ -2318,6 +2364,8 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => { // EGITAUTH: which host wants a login, for the git bar's form. Never // a secret — just protocol, host, a username hint and `rejected`. ...(err.auth ? { auth: err.auth } : {}), + // ECARRYCONFLICT: the files a carried switch would overwrite. + ...(Array.isArray(err.files) ? { files: err.files } : {}), }, }); }