diff --git a/CLAUDE.md b/CLAUDE.md index 6c909a1d..69779557 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.30 (2026-10-28) — spend quotas per user (server mode): EQUOTA at start, prompts refused by the hook's local flag file._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. +_Last synced: v2.8.30 (2026-10-28) — spend quotas per user; the branch switcher lists and tracks remote-only branches._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -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; `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; `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-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; `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. - **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/LaneGitBar.tsx b/client/src/components/lanes/LaneGitBar.tsx index ba81f888..38439c89 100644 --- a/client/src/components/lanes/LaneGitBar.tsx +++ b/client/src/components/lanes/LaneGitBar.tsx @@ -1,7 +1,9 @@ /** * @file The lane's git action row: ahead/behind against the upstream, and the * writes `POST /api/lanes/:id/git/:action` offers that are not per-file — - * fetch, pull, push, switch branch, create/delete a branch, publish a repo + * fetch, pull, push, switch branch (a local one, or one only a remote has — + * listed under "On the remote" and checked out as a new tracking branch; the + * list is re-read after every fetch and pull), create/delete a branch, publish a repo * that has no remote yet (a URL, then `origin` + `push -u`), and start a merge * (the picked branch, or the upstream once diverged; resolving it is the * Changes panel's job). Committing is @@ -114,6 +116,8 @@ export function LaneGitBar({ const [error, setError] = useState(null); const [switching, setSwitching] = useState(false); const [branches, setBranches] = useState(null); + // Fetched remote branches with no local one yet (`origin/feature`). + const [remoteBranches, setRemoteBranches] = useState([]); const [worktrees, setWorktrees] = useState([]); const [picked, setPicked] = useState(""); const [newBranch, setNewBranch] = useState(""); @@ -138,6 +142,7 @@ export function LaneGitBar({ setError(null); setSwitching(false); setBranches(null); + setRemoteBranches([]); setWorktrees([]); setPicked(""); setNewBranch(""); @@ -152,6 +157,9 @@ export function LaneGitBar({ if (!git.available) return null; const dirty = git.dirty + git.untracked; + // A remote branch: switching creates its local tracking branch; it cannot + // be deleted here or given a sibling worktree before that. + const pickedRemote = remoteBranches.includes(picked); const merging = !!git.merging; const run = async (action: Action, body?: RunBody) => { @@ -184,6 +192,9 @@ export function LaneGitBar({ setPicked(""); 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(); } onChanged(); } catch (err) { @@ -217,6 +228,7 @@ export function LaneGitBar({ const r = await api.lanes.gitBranches(gitTarget(laneId, session, source)); if (laneNow.current !== laneId) return; setBranches(r.branches); + setRemoteBranches(r.remotes || []); setWorktrees(r.worktrees || []); } catch { if (laneNow.current === laneId) setBranches([]); @@ -446,6 +458,15 @@ export function LaneGitBar({ ); })} + {remoteBranches.length > 0 && ( + + {remoteBranches.map((b) => ( + + ))} + + )} onNewLane(picked)} @@ -486,7 +507,7 @@ export function LaneGitBar({ label={t("gitBar.branchDelete")} icon={} busy={busy === "branch-delete"} - disabled={!picked || picked === git.branch || !!heldBy.get(picked)} + disabled={!picked || picked === git.branch || !!heldBy.get(picked) || pickedRemote} title={t("gitBar.branchDeleteHint")} onClick={() => void confirmRun( diff --git a/client/src/components/lanes/__tests__/LaneGitBar.test.tsx b/client/src/components/lanes/__tests__/LaneGitBar.test.tsx index 556a5673..f0b7c28a 100644 --- a/client/src/components/lanes/__tests__/LaneGitBar.test.tsx +++ b/client/src/components/lanes/__tests__/LaneGitBar.test.tsx @@ -4,7 +4,8 @@ * 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), and that a failed action shows git's own words rather than + * refuses it too), 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ĩ */ @@ -236,6 +237,43 @@ describe("LaneGitBar", () => { await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2)); }); + it("lists branches only the remote has, switches to one, and re-reads the list after a fetch", async () => { + vi.mocked(api.lanes.gitBranches).mockResolvedValue({ + available: true, + branches: ["development", "feat/thing"], + remotes: ["origin/feat/new"], + current: "feat/thing", + worktrees: [], + }); + const user = userEvent.setup(); + render( + + ); + await user.click(screen.getByRole("button", { name: /Branch/ })); + const select = await screen.findByLabelText("Switch branch…"); + expect( + screen.getByRole("group", { name: "On the remote — switching creates the local branch" }) + ).toBeInTheDocument(); + await user.selectOptions(select, "origin/feat/new"); + // Not a local branch yet: nothing to delete, no worktree to start on it. + expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled(); + expect(screen.getByRole("button", { name: /New workspace here/ })).toBeDisabled(); + await user.click(screen.getByRole("button", { name: "Switch branch" })); + await waitFor(() => + expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", { branch: "origin/feat/new" }) + ); + + vi.mocked(api.lanes.gitBranches).mockClear(); + await user.click(screen.getByRole("button", { name: /Fetch/ })); + await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "fetch", {})); + await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(1)); + }); + it("creates a branch without switching, then offers a sibling lane on it", async () => { const user = userEvent.setup(); const onNewLane = vi.fn(); diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index f5ca0b11..7d477ecd 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -195,6 +195,7 @@ "gitBar.switch": "Branch", "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.switchHint": "Switch this worktree to another local branch", "gitBar.switchLabel": "Switch branch…", diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index 3191f7b3..95727b71 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -195,6 +195,7 @@ "gitBar.switch": "Nhánh", "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.switchHint": "Đổi worktree này sang nhánh local khác", "gitBar.switchLabel": "Đổi nhánh…", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 3272be34..7a0a79b1 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -4320,6 +4320,8 @@ export const api = { request<{ available: boolean; branches: string[]; + /** Fetched remote branches with no local one yet (`origin/feature`); absent from an older server. */ + remotes?: string[]; current: string | null; worktrees: LaneWorktree[]; }>(gitUrl(id, "/branches")), diff --git a/docs/API.md b/docs/API.md index d90832fe..8d822c4d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -942,8 +942,11 @@ read the disk instead for a folder that is not a git repo (below). GET /api/lanes/:id/git/branches ``` -`{"available": true, "branches": [...], "current": "feat/x", "worktrees": [...]}` -— the local branches of the workspace's own repo, for the branch switcher. The repo +`{"available": true, "branches": [...], "remotes": ["origin/feat/y"], "current": "feat/x", "worktrees": [...]}` +— the local branches of the workspace's own repo, for the branch switcher, and +`remotes`: the fetched remote-tracking branches that have no local branch of +that name yet (the remotes' `HEAD` left out). The switcher lists them under +"On the remote" and re-reads the list after every fetch and pull. The repo comes from the workspace row, never from the query (that is what `GET /api/lanes/branches` is for, which takes a candidate path for a workspace that does not exist yet; in server mode with `AGENTHUB_USERS_ROOT` set, a developer may name @@ -1059,7 +1062,7 @@ 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] ` | `{"branch": "...", "create": false}` | +| `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": "..."}` | | `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"}` | diff --git a/openapi.yaml b/openapi.yaml index fd0f01db..12894c7a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -12276,8 +12276,8 @@ paths: get: tags: - Lanes - summary: The lane repo's own local branches - description: Feeds the lane's branch switcher. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard. + summary: The lane repo's own local branches, and the remote ones not checked out yet + description: Feeds the lane's branch switcher. `branches` are the local branches; `remotes` the fetched remote-tracking branches with no local branch of that name yet (`origin/feature`, the remotes' HEAD left out) — `switch` to one creates its local tracking branch. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard. operationId: listLaneOwnBranches parameters: - name: id @@ -12287,7 +12287,7 @@ paths: type: integer responses: '200': - description: '{available, branches, current, worktrees}.' + description: '{available, branches, remotes, current, worktrees}.' '404': description: Lane not found. /api/lanes/{id}/git/commit-message: diff --git a/server/__tests__/git-diff-ops.test.js b/server/__tests__/git-diff-ops.test.js index 5491b6ec..3338815e 100644 --- a/server/__tests__/git-diff-ops.test.js +++ b/server/__tests__/git-diff-ops.test.js @@ -138,7 +138,10 @@ describe("collectChanges — work range", () => { const out = await collectChanges(LANE, { range: "work" }); // `--untracked-files=normal` folded these into one "docs/" row that // opened nothing; the panel needs the files, like JetBrains and VS Code. - assert.deepEqual(out.files.map((f) => f.path).sort(), ["docs/deep/notes.txt", "docs/report.md"]); + assert.deepEqual(out.files.map((f) => f.path).sort(), [ + "docs/deep/notes.txt", + "docs/report.md", + ]); assert.ok(out.files.every((f) => f.untracked && f.added === 1)); assert.equal((await gitFacts(LANE)).untracked, 2); }); @@ -244,10 +247,7 @@ describe("collectChanges — branch range", () => { it("falls back to the default branch without a base, and refuses a base that is gone", async () => { // No recorded base (an adopted folder): what origin/HEAD names. - assert.equal( - (await collectChanges(LANE, { range: "branch", base: null })).base, - "development" - ); + assert.equal((await collectChanges(LANE, { range: "branch", base: null })).base, "development"); await assert.rejects(() => collectChanges(LANE, { range: "branch", base: "nope" }), { code: "ENOBASE", }); @@ -374,6 +374,48 @@ describe("runGitAction", () => { }); }); + it("lists a branch only the remote has after a fetch, and switching tracks it", async () => { + const { listRemoteBranches } = require("../lib/worktree"); + // Someone else pushes a new branch; this clone has never seen it. + gc(OTHER, "checkout", "-qb", "feat/remote-only"); + write(OTHER, "r.txt", "remote\n"); + gc(OTHER, "add", "-A"); + gc(OTHER, "commit", "-qm", "remote work"); + gc(OTHER, "push", "-q", "origin", "feat/remote-only"); + assert.equal( + (await listRemoteBranches(LANE)).some((r) => r.name === "origin/feat/remote-only"), + false + ); + await runGitAction(LANE, "fetch"); + const remote = await listRemoteBranches(LANE); + assert.ok(remote.some((r) => r.name === "origin/feat/remote-only")); + // origin/HEAD and branches that already have a local one are left out. + assert.ok(!remote.some((r) => r.branch === "HEAD" || r.branch === "development")); + + const result = await runGitAction(LANE, "switch", { branch: "origin/feat/remote-only" }); + assert.equal(result.branch, "feat/remote-only"); + assert.equal(g(LANE, "rev-parse", "--abbrev-ref", "HEAD").trim(), "feat/remote-only"); + assert.equal( + g(LANE, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").trim(), + "origin/feat/remote-only" + ); + assert.ok(!(await listRemoteBranches(LANE)).some((r) => r.branch === "feat/remote-only")); + gc(LANE, "checkout", "-q", "development"); + }); + + it("switches to a bare remote branch name when exactly one remote has it", async () => { + gc(OTHER, "checkout", "-qb", "feat/bare"); + gc(OTHER, "push", "-q", "origin", "feat/bare"); + await runGitAction(LANE, "fetch"); + const result = await runGitAction(LANE, "switch", { branch: "feat/bare" }); + assert.equal(result.branch, "feat/bare"); + assert.equal( + g(LANE, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").trim(), + "origin/feat/bare" + ); + gc(LANE, "checkout", "-q", "development"); + }); + it("refuses a branch another worktree holds", async () => { const wt = path.join(ROOT, "sibling"); g(LANE, "worktree", "add", "-q", wt, "development"); diff --git a/server/__tests__/lanes-api.test.js b/server/__tests__/lanes-api.test.js index f23b3d90..b5fdee90 100644 --- a/server/__tests__/lanes-api.test.js +++ b/server/__tests__/lanes-api.test.js @@ -1122,6 +1122,7 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => { const r = await request("GET", `/api/lanes/${laneId}/git/branches`); assert.equal(r.status, 200); assert.deepEqual(r.body.branches, ["feat/api-changes"]); + assert.deepEqual(r.body.remotes, []); // no remote at all assert.equal(r.body.current, "feat/api-changes"); }); diff --git a/server/lib/git-ops.js b/server/lib/git-ops.js index b260e52a..88148527 100644 --- a/server/lib/git-ops.js +++ b/server/lib/git-ops.js @@ -55,6 +55,7 @@ const { git: rawGit, isValidBranchName, listBranches, + listRemoteBranches, branchCheckedOutAt, statusCounts, operationInProgress, @@ -532,7 +533,31 @@ async function runGitAction(dir, action, body = {}) { if (exists) throw bad("EBRANCHEXISTS", `branch ${branch} already exists`); return done(action, await git(dir, ["switch", "-c", branch]), branch); } - if (!exists) throw bad("ENOBRANCH", `branch ${branch} does not exist locally`); + if (!exists) { + // A branch only a remote has (fetched, never checked out here): its local + // tracking branch is created and switched to. Named either as the remote + // ref (`origin/feature`) or bare (`feature`, when exactly one remote has + // it). The remote ref is looked up among the fetched ones, never passed + // through from the request. + const remote = (await listRemoteBranches(dir)).filter( + (r) => r.name === branch || r.branch === branch + ); + const pick = remote.find((r) => r.name === branch) || (remote.length === 1 ? remote[0] : null); + if (!pick) { + if (remote.length > 1) { + throw bad( + "EAMBIGUOUS", + `branch ${branch} is on several remotes (${remote.map((r) => r.name).join(", ")}): pick one` + ); + } + 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 + ); + } 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); diff --git a/server/lib/worktree.js b/server/lib/worktree.js index 60c5eb88..2ecefea1 100644 --- a/server/lib/worktree.js +++ b/server/lib/worktree.js @@ -228,6 +228,39 @@ async function listBranches(sourceRepo) { return { branches, current }; } +/** + * A repo's remote-tracking branches that have NO local branch of the same + * name yet — what a fetch brought in that `listBranches` cannot show. + * `[{name: "origin/feature", remote: "origin", branch: "feature"}]`, the + * remotes' symbolic `HEAD`s left out. The remote's name is matched against + * `git remote` (longest first), so a remote whose name holds a `/` splits right. + */ +async function listRemoteBranches(dir) { + const remotes = (await git(dir, ["remote"])).stdout + .split("\n") + .map((r) => r.trim()) + .filter(Boolean) + .sort((a, b) => b.length - a.length); + if (!remotes.length) return []; + const refs = ( + await git(dir, ["for-each-ref", "--format=%(refname:short)%00%(symref)", "refs/remotes"]) + ).stdout + .split("\n") + .filter(Boolean) + .map((l) => l.split("\0")); + const local = new Set((await listBranches(dir)).branches); + const out = []; + for (const [name, symref] of refs) { + if (symref) continue; // origin/HEAD → origin/main + const remote = remotes.find((r) => name.startsWith(`${r}/`)); + if (!remote) continue; + const branch = name.slice(remote.length + 1); + if (!branch || branch === "HEAD" || local.has(branch)) continue; + out.push({ name, remote, branch }); + } + return out; +} + /** * Resolve the base branch: try origin/, then , then HEAD. */ @@ -978,6 +1011,7 @@ module.exports = { isGitRepo, isValidBranchName, listBranches, + listRemoteBranches, resolveBase, slugify, listWorktrees, diff --git a/server/openapi-extra/lanes.js b/server/openapi-extra/lanes.js index 46ad8a12..c2b147b9 100644 --- a/server/openapi-extra/lanes.js +++ b/server/openapi-extra/lanes.js @@ -854,13 +854,13 @@ const paths = { "/api/lanes/{id}/git/branches": { get: { tags: ["Lanes"], - summary: "The lane repo's own local branches", + summary: "The lane repo's own local branches, and the remote ones not checked out yet", description: - "Feeds the lane's branch switcher. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.", + "Feeds the lane's branch switcher. `branches` are the local branches; `remotes` the fetched remote-tracking branches with no local branch of that name yet (`origin/feature`, the remotes' HEAD left out) — `switch` to one creates its local tracking branch. The repository comes from the lane row, never from the query — that is what GET /api/lanes/branches is for. Read-only, no same-origin guard.", operationId: "listLaneOwnBranches", parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }], responses: { - 200: { description: "{available, branches, current, worktrees}." }, + 200: { description: "{available, branches, remotes, current, worktrees}." }, 404: { description: "Lane not found." }, }, }, diff --git a/server/routes/lanes.js b/server/routes/lanes.js index 1927ff20..2aa8f160 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -48,6 +48,7 @@ const { isGitRepo, isValidBranchName, listBranches, + listRemoteBranches, listWorktrees, resetWorktree, resolveBase, @@ -1913,7 +1914,9 @@ router.get("/:id/git/tree", async (req, res) => { }); /** - * The lane's own local branches, for the branch switcher. Separate from + * The lane's own local branches, for the branch switcher, plus `remotes`: the + * fetched remote-tracking branches with no local branch yet (`origin/feature` + * — switching to one creates its tracking branch). Separate from * `GET /branches` (which takes a candidate repo PATH from the query, for a * lane that does not exist yet); this one resolves the repo from the lane row, * so no path ever comes from the request. @@ -1925,9 +1928,10 @@ router.get("/:id/git/branches", async (req, res) => { try { const { branches, current } = await listBranches(cwd); const worktrees = await listWorktrees(cwd).catch(() => []); - res.json({ available: true, branches, current, worktrees }); + const remotes = (await listRemoteBranches(cwd).catch(() => [])).map((r) => r.name); + res.json({ available: true, branches, remotes, current, worktrees }); } catch { - res.json({ available: false, branches: [], current: null, worktrees: [] }); + res.json({ available: false, branches: [], remotes: [], current: null, worktrees: [] }); } }); @@ -1987,6 +1991,7 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => { "EBADMESSAGE", "EBADBRANCH", "ENOBRANCH", + "EAMBIGUOUS", "EBRANCHEXISTS", "EBRANCHBUSY", "EDIRTY",