diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 17081163..50830a2d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -388,7 +388,9 @@ graph TD | `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected | | `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list; in a folder that is not a git repo they, and `listTreeAll`, read the disk instead through `walkFolder` — realpath-confined, symlinks never followed, `.git` hidden, `node_modules` never entered, 20 000 entries at most). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` | | `lib/lane-file-transfer.js` | (#78) The Files tab's download and upload. `openDownload` streams one working file of any type (up to 512 MB) behind the file read's guards (`lanePath`, `resolveInLane` realpath confinement, `isSecretOnDisk`, `openSafe` for a member's file); the route serves it as an `application/octet-stream` attachment under a `sandbox` CSP with `nosniff`. `saveUpload` only CREATES (`EEXISTS` for a taken name, a symlink included), into an existing folder, never under `.git`, never a secret name, at most 25 MB — under S3 as the folder's member through `write-as`, else with an exclusive `wx` create. Works in a folder that is not a git repo | -| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff — or one `block`, a run of +/- lines inside it, the others folded into context by `onlyBlock` in `git-diff.js` (#50); `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- ` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`), `cherry-pick`/`revert` (a non-merge commit by sha, same guards and conflict flow — `merge-commit`/`merge-abort` finish whichever of the three is open, `operationInProgress` in worktree.js), and `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) / `amend`, both refused once a remote-tracking branch holds HEAD (`EPUSHED`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. No rebase, hard reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (a hard `reset` stays behind worktree.js's three-check destroy guard; `undo-commit`'s soft reset moves only an unpushed branch tip and keeps every change staged). `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. Network actions carry a 60s timeout | +| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff — or one `block`, a run of +/- lines inside it, the others folded into context by `onlyBlock` in `git-diff.js` (#50); `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- ` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`), `cherry-pick`/`revert` (a non-merge commit by sha, same guards and conflict flow — `merge-commit`/`merge-abort` finish whichever of the three is open, `operationInProgress` in worktree.js), and `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) / `amend`, both refused once a remote-tracking branch holds HEAD (`EPUSHED`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. History rewrites and remote writes are dispatched to `lib/git-rewrite.js` (below); a stopped rebase lets through only `ALLOWED_WHILE_REBASING`. No stash beyond shelves. (`undo-commit`'s soft reset moves only an unpushed branch tip and keeps every change staged.) `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. Network actions carry a 60s timeout | +| `lib/git-rewrite.js` | History rewrites and remote writes, each allowed because its recovery is a button too: `rebase` (+ `rebase-continue`/`-skip`/`-abort`; `merge-commit`/`merge-abort` map onto an open rebase), `rebase-interactive` (todo + messages as 0600 files in the repo's git dir, `cp` as sequence editor, `true` as editor, squash = `fixup` + `exec git commit --amend -F `), `reset` soft/mixed/keep/hard (hard: clean tree + branch name typed back), `undo-rewrite` (`reset --keep ` while HEAD is still `after`), `force-push` (`--force-with-lease` on the sha the plan showed, HEAD only, protected names refused unless `AGENTHUB_GIT_FORCE_PUSH_PROTECTED=1`), `remote-branch-delete` (never the default or a protected branch), `remote-add`/`-rename`/`-remove` (URL through `validateRemoteUrl`). Every one: repo root (`ESUBDIR`), no open operation (`EMERGING`), the planned HEAD (`ESTALE`), pushed commits `EPUSHED` unless `force: true`. Plus the reads `rewritePlan` and `listRemotes` (passwords stripped) | +| `lib/git-conflicts.js` | The 3-way merge editor's read (`readConflict`: stages :1/:2/:3 by blob id for a path `ls-files -u` lists, the working file and its sha) and save (`conflict-save`: `lane-file-write.js` then `git add`, `EMARKERS` before writing), and the read-only reflog (`readReflog`) | | `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `/.playwright-mcp/proof///`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `agenthub lanes proof-link` | | `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `agenthub lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive | diff --git a/CLAUDE.md b/CLAUDE.md index 62de2e84..4fafd95d 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 thirty, and the guards are IN `git-ops.js` (and the `git-refs.js` it dispatches to), 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`, and the Branches panel's `branch-rename`, `branch-upstream`, `tag-create`, `tag-delete`, `tag-push` — nothing else. **Branch & tag tools (`server/lib/git-refs.js`) hand git only a LOOKED-UP ref** — a `from`/`upstream`/tag found under `refs/heads`/`refs/remotes`/`refs/tags` (passed as its full refname) or a hex sha `rev-parse --verify ^{commit}` confirmed, never the request's text (`EBADREF`, `EAMBIGUOUS`); a new name passes `check-ref-format` (a tag may not start with `-`); rename/upstream/tag writes are repo-root only (`ESUBDIR`) and `EMERGING` during any operation; rename refuses the protected names like delete; a `branch-create` naming a `from` is git-refs.js's and its `switch: true` needs a clean tree (`EDIRTY`), while a plain `{branch, switch: true}` stays git-ops.js's Create & switch below; `tag-delete` needs `confirm: true`; `tag-push` pushes ONE tag `refs/tags/:refs/tags/` to `origin` (else the only remote), never `--tags`, never forced. Deleting a remote tag is not offered. `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`, `/git/refs`) 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 forty-three, and the guards are IN `git-ops.js` (and the `git-refs.js`, `git-rewrite.js` and `git-conflicts.js` it dispatches to), 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`, the Branches panel's `branch-rename`, `branch-upstream`, `tag-create`, `tag-delete`, `tag-push` (`git-refs.js`), and the History tools' `conflict-save`, `rebase`, `rebase-continue`, `rebase-skip`, `rebase-abort`, `rebase-interactive`, `reset`, `undo-rewrite`, `force-push`, `remote-branch-delete`, `remote-add`, `remote-rename`, `remote-remove` (`git-rewrite.js`; `conflict-save` is `git-conflicts.js`'s) — nothing else. **Branch & tag tools (`server/lib/git-refs.js`) hand git only a LOOKED-UP ref** — a `from`/`upstream`/tag found under `refs/heads`/`refs/remotes`/`refs/tags` (passed as its full refname) or a hex sha `rev-parse --verify ^{commit}` confirmed, never the request's text (`EBADREF`, `EAMBIGUOUS`); a new name passes `check-ref-format` (a tag may not start with `-`); rename/upstream/tag writes are repo-root only (`ESUBDIR`) and `EMERGING` during any operation; rename refuses the protected names like delete; a `branch-create` naming a `from` is git-refs.js's and its `switch: true` needs a clean tree (`EDIRTY`), while a plain `{branch, switch: true}` stays git-ops.js's Create & switch below; `tag-delete` needs `confirm: true`; `tag-push` pushes ONE tag `refs/tags/:refs/tags/` to `origin` (else the only remote), never `--tags`, never forced. Deleting a remote tag is not offered. **A history rewrite or remote write is allowed only because its recovery is a button too, and the server refuses what the UI only discourages:** every one runs from a repo-root lane (`ESUBDIR`), never while a merge/cherry-pick/revert/rebase is open (`EMERGING`), names the full HEAD sha its plan showed (`ESTALE`), and refuses to rewrite a commit any remote-tracking branch holds unless the body says `force: true` (`EPUSHED` — the UI shows that a force-push will be needed); each answers `before`/`after`, and `undo-rewrite` (`reset --keep `, `confirm: true`, only while HEAD is still `after`) is its Undo. `rebase` (a ref looked up by `branchRef`) and `rebase-interactive` need a clean tree; a stopped rebase is finished by `rebase-continue`/`-skip`/`-abort` (`confirm: true`) — `merge-commit`/`merge-abort` map onto it — and lets through only `ALLOWED_WHILE_REBASING` (fetch, stage, unstage, discard, ignore, resolve, conflict-save and its own steps); `operationInProgress` reports `rebase` first. `rebase-interactive` takes a todo that must list exactly `base..HEAD`'s commits by full sha (`EBADTODO`, no merge commit); git is run with `GIT_SEQUENCE_EDITOR` = `cp -- ` and `GIT_EDITOR=true`, the todo and the reword/squash messages are 0600 files AgentHub writes in the repo's own git dir (as the folder's account), and the only `exec` line is `git commit --amend -F ` — never put request text into a todo line or any shell string. `reset` is `--soft|--mixed|--keep|--hard` to an ancestor or descendant of HEAD (`ENOTRELATED`), `confirm: true`; `--hard` only on a CLEAN tree and with `confirmBranch` = the current branch (so nothing uncommitted can be lost) — the lane-destroying `reset` of `worktree.js`'s three-check guard is a different thing and stays there. `force-push` pushes HEAD only, to its upstream branch, with `--force-with-lease=:` (the sha the UI showed; a moved remote is `ESTALE`), `confirm: true`, and refuses `main`/`master`/`development`/`develop` unless the server sets `AGENTHUB_GIT_FORCE_PUSH_PROTECTED=1`. `remote-branch-delete` takes a fetched `/`, `confirm: true`, never a protected name nor the remote's default (`refs/remotes//HEAD`, `EDEFAULTBRANCH`). `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` and `remote-add` are the only writes that take a destination from the request** (`{url}`), and both pass it through `validateRemoteUrl`'s allowlist; `remote-add` names a remote of letters, digits, `.`, `_`, `-` (`EBADREMOTE`), never re-points an existing one (`EREMOTEEXISTS`), and `remote-rename`/`remote-remove` (`confirm: true`) touch only a remote that exists. `publish` 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 `publish` touch a repo that already has a remote; a stored credential still goes only to its own host (the git-credentials rule below) whatever remote a user adds. **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`, `shelf-drop`, `tag-delete`, `rebase-abort`, `rebase-interactive`, `reset`, `undo-rewrite`, `force-push`, `remote-branch-delete` and `remote-remove` 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 any stash use beyond shelves, nor a write whose recovery is not a button: every rewrite above keeps its `before` for `undo-rewrite`, and a stopped rebase its `rebase-abort`. `undo-commit` = `reset --soft HEAD~1` (`confirm: true`, repo root, never the root or a merge commit) 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`, no override); a rebase open in a terminal blocks every history action, shelves and the ref tools (`EMERGING`) but may be continued/skipped/aborted from the banner. Rewriting a pushed commit needs the request's explicit `force: true` and then a `force-push` with lease — do not relax either check. The 3-way merge editor reads `GET /git/conflict` (stages by blob id, only a path `ls-files -u` lists) and saves through `conflict-save` = `lane-file-write.js`'s guards then `git add`, `EMARKERS` before anything is written. 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`, `/git/refs`, `/git/conflict`, `/git/reflog`, `/git/remotes` — URLs with any password stripped —, `/git/rewrite-plan`) 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 e16430b5..68e17554 100644 --- a/client/src/components/lanes/LaneChangesPanel.tsx +++ b/client/src/components/lanes/LaneChangesPanel.tsx @@ -150,6 +150,7 @@ import { HtmlViewerModal } from "../HtmlViewerModal"; import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader"; import { LaneShelves, withAgentConfirm } from "./LaneShelves"; import { useGitChanged } from "../../lib/gitChanged"; +import { GitTools, GitToolsButton, RebaseSkipButton, type GitTool } from "./git/GitTools"; import { changeTree, treeFiles, type ChangeTreeDir } from "../../lib/changeTree"; import { dragProjectPath } from "../../lib/promptAttach"; import { commitGraph, type GraphRow } from "../../lib/commitGraph"; @@ -869,6 +870,8 @@ export function LaneChangesPanel({ const opOpen = !!(git?.available && git.merging); // Every git read and write below goes to the same tree as those facts. const target = gitTarget(laneId, session, source); + // The open history-rewrite / remote / merge-editor tool (./git/GitTools). + const [gitTool, setGitTool] = useState(null); // A diff line's comment goes into this workspace's pane, as a paste. const comment = useCallback( (text: string) => { @@ -1881,10 +1884,38 @@ export function LaneChangesPanel({ {t("changes.undoCommit")} )} + {/* History rewrites (./git): both open a dialog that asks first. */} + + setGitTool({ kind: "reset", commit: commitCtx.menu!.item }) + )} + > + {t("gitTools:menu.resetHere")} + + + setGitTool({ kind: "interactive", base: commitCtx.menu!.item.sha }) + )} + > + {t("gitTools:menu.interactiveHere")} + )} )} + setGitTool(null)} + onOpen={setGitTool} + onChanged={() => void rereadAll()} + onViewCommit={viewCommit} + /> ); @@ -2484,6 +2515,12 @@ export function LaneChangesPanel({ )} + {!notRepo && ( + + )} + {git.operation === "rebase" && ( + void rereadAll()} + /> + )} + {onMergeEditor && ( + + )} {error && ( diff --git a/client/src/components/lanes/git/ForcePushDialog.tsx b/client/src/components/lanes/git/ForcePushDialog.tsx new file mode 100644 index 00000000..233ee054 --- /dev/null +++ b/client/src/components/lanes/git/ForcePushDialog.tsx @@ -0,0 +1,119 @@ +/** + * @file ForcePushDialog.tsx + * @description "Force push (with lease)" for the CURRENT branch only: shows + * where it goes (remote and branch), the remote commit it will replace (the + * lease — the server refuses with ESTALE if the remote moved since the last + * fetch), how far ahead/behind HEAD is, and refuses a protected branch unless + * the server allows it. Push is a two-click (`useArmed`). + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { GitTarget } from "../../../lib/api"; +import { errCode, errText, gitRewrite, type PushPlan } from "../../../lib/gitRewrite"; +import { toast } from "../../../lib/toast"; +import { useArmed } from "../../../hooks/useArmed"; +import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal"; + +export function ForcePushDialog({ + target, + onClose, + onChanged, +}: { + target: GitTarget; + onClose: () => void; + onChanged: () => void; +}) { + const { t } = useTranslation(["gitTools"]); + const [plan, setPlan] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const { isArmed, arm, disarm } = useArmed(); + const key = JSON.stringify(target); + + useEffect(() => { + let live = true; + gitRewrite + .planPush(target) + .then((p) => live && setPlan(p)) + .catch((e) => live && setError(errText(e))); + return () => { + live = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]); + + const run = async () => { + if (!plan) return; + if (!isArmed("push")) return arm("push"); + disarm(); + setBusy(true); + setError(null); + try { + await gitRewrite.run(target, "force-push", { expect: plan.remoteSha ?? "", confirm: true }); + toast.success(t("forcePush.done", { remote: plan.remote, branch: plan.remoteBranch })); + onChanged(); + onClose(); + } catch (e) { + setError(errCode(e) === "ESTALE" ? t("forcePush.stale") : errText(e)); + } finally { + setBusy(false); + } + }; + + return ( + + + + + } + > + {!plan ? ( + error ? ( + + ) : ( +

{t("loading")}

+ ) + ) : ( +
+

+ {t("forcePush.where", { + branch: plan.branch, + remote: plan.remote, + remoteBranch: plan.remoteBranch, + })} +

+

+ {plan.remoteSha + ? t("forcePush.replaces", { + sha: plan.remoteSha.slice(0, 7), + ahead: plan.ahead, + behind: plan.behind, + }) + : t("forcePush.newBranch")} +

+ {plan.behind > 0 && {t("forcePush.dropsRemote", { count: plan.behind })}} + {!plan.allowed && {t("forcePush.protected")}} +

{t("forcePush.lease")}

+ +
+ )} +
+ ); +} diff --git a/client/src/components/lanes/git/GitModal.tsx b/client/src/components/lanes/git/GitModal.tsx new file mode 100644 index 00000000..77fd8c33 --- /dev/null +++ b/client/src/components/lanes/git/GitModal.tsx @@ -0,0 +1,111 @@ +/** + * @file GitModal.tsx + * @description The shared modal shell of the Changes panel's git tools (merge + * editor, reflog, rebase, interactive rebase, reset, force push, remotes): a + * portal over the screen with `useDialog`'s focus contract, a title row with a + * close button, a scrolling body and an optional footer. `wide` widens it for + * the three-pane merge editor; on a phone every size is the full width. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useId, useRef, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { X } from "lucide-react"; +import { useDialog } from "../../../hooks/useDialog"; + +export const BTN = + "rounded border border-border-light px-2 py-1 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"; +export const BTN_PRIMARY = + "rounded border border-accent-solid bg-accent-solid px-2 py-1 text-[11px] font-medium text-white hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"; +export const BTN_DANGER = + "rounded border border-status-danger/60 px-2 py-1 text-[11px] font-medium text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"; + +export function GitModal({ + title, + onClose, + testId, + wide = false, + footer, + children, +}: { + title: ReactNode; + onClose: () => void; + testId?: string; + wide?: boolean; + footer?: ReactNode; + children: ReactNode; +}) { + const { t } = useTranslation(["gitTools"]); + const panel = useRef(null); + const titleId = useId(); + useDialog(true, panel, onClose); + return createPortal( +
e.stopPropagation()} + role="presentation" + > +
e.stopPropagation()} + className={`flex max-h-[92vh] w-full flex-col rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50 ${ + wide ? "max-w-6xl" : "max-w-lg" + }`} + > +
+

+ {title} +

+ +
+
+ {children} +
+ {footer && ( +
+ {footer} +
+ )} +
+
, + document.body + ); +} + +/** A red inline error line, or nothing. */ +export function ErrorLine({ error }: { error: string | null }) { + if (!error) return null; + return ( +

+ {error} +

+ ); +} + +/** An amber explanation box shown before a rewrite runs. */ +export function Warn({ children, testId }: { children: ReactNode; testId?: string }) { + return ( +
+ {children} +
+ ); +} diff --git a/client/src/components/lanes/git/GitTools.tsx b/client/src/components/lanes/git/GitTools.tsx new file mode 100644 index 00000000..980de47e --- /dev/null +++ b/client/src/components/lanes/git/GitTools.tsx @@ -0,0 +1,210 @@ +/** + * @file GitTools.tsx + * @description The Changes panel's entry points to the history-rewrite and + * remote tools: `GitToolsButton` (a toolbar menu — Rebase onto…, Interactive + * rebase…, Reflog, Force push…, Remotes…), `GitTools` (renders whichever tool + * is open, so the panel holds one `GitTool | null` state), and + * `RebaseSkipButton` (the rebase banner's Skip — Continue and Abort are the + * banner's own Complete/Abort, which the server maps onto the rebase). + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { History } from "lucide-react"; +import type { GitTarget } from "../../../lib/api"; +import { errText, gitRewrite } from "../../../lib/gitRewrite"; +import { toast } from "../../../lib/toast"; +import { ContextMenu, MenuItem, MenuSeparator } from "../../ContextMenu"; +import { ForcePushDialog } from "./ForcePushDialog"; +import { InteractiveRebase } from "./InteractiveRebase"; +import { MergeEditor } from "./MergeEditor"; +import { RebaseDialog } from "./RebaseDialog"; +import { ReflogView } from "./ReflogView"; +import { RemotesDialog } from "./RemotesDialog"; +import { ResetDialog } from "./ResetDialog"; + +export type GitTool = + | { kind: "rebase" } + | { kind: "interactive"; base?: string } + | { kind: "reset"; commit: { sha: string; short?: string; subject?: string } } + | { kind: "reflog" } + | { kind: "force-push" } + | { kind: "remotes" } + | { kind: "merge"; path: string; onResolved?: () => void }; + +export function GitToolsButton({ + onPick, + disabled, + hasRemote, +}: { + onPick: (tool: GitTool) => void; + disabled?: boolean; + hasRemote?: boolean; +}) { + const { t } = useTranslation(["gitTools"]); + const ref = useRef(null); + const [at, setAt] = useState<{ x: number; y: number } | null>(null); + const pick = (tool: GitTool) => () => { + setAt(null); + onPick(tool); + }; + return ( + <> + + {at && ( + setAt(null)} testId="git-tools-menu"> + + {t("menu.rebase")} + + + {t("menu.interactive")} + + + {t("menu.reflog")} + + + + {t("menu.forcePush")} + + + {t("menu.remotes")} + + + )} + + ); +} + +export function GitTools({ + tool, + target, + branch, + onClose, + onOpen, + onChanged, + onViewCommit, +}: { + tool: GitTool | null; + target: GitTarget; + /** The current branch, for titles. */ + branch: string | null; + onClose: () => void; + /** Open another tool (the reflog's "Reset here"). */ + onOpen: (tool: GitTool) => void; + /** Re-read the panel's git facts after a write. */ + onChanged: () => void; + onViewCommit?: (sha: string) => void; +}) { + if (!tool) return null; + switch (tool.kind) { + case "rebase": + return ( + + ); + case "interactive": + return ( + + ); + case "reset": + return ( + + ); + case "reflog": + return ( + { + onClose(); + onViewCommit(sha); + }) + } + onReset={(e) => + onOpen({ kind: "reset", commit: { sha: e.sha, short: e.short, subject: e.subject } }) + } + /> + ); + case "force-push": + return ; + case "remotes": + return ; + case "merge": + return ( + { + onChanged(); + tool.onResolved?.(); + }} + /> + ); + } +} + +/** The rebase banner's Skip: drops the commit the rebase stopped on. */ +export function RebaseSkipButton({ + target, + disabled, + onChanged, +}: { + target: GitTarget; + disabled?: boolean; + onChanged: () => void; +}) { + const { t } = useTranslation(["gitTools"]); + const [busy, setBusy] = useState(false); + return ( + + ); +} diff --git a/client/src/components/lanes/git/InteractiveRebase.tsx b/client/src/components/lanes/git/InteractiveRebase.tsx new file mode 100644 index 00000000..accb5d92 --- /dev/null +++ b/client/src/components/lanes/git/InteractiveRebase.tsx @@ -0,0 +1,295 @@ +/** + * @file InteractiveRebase.tsx + * @description Interactive rebase as a list (JetBrains' "Interactively Rebase + * from Here"): the branch's commits since a base — by default the ones no + * remote has yet, oldest first — each with Pick / Reword / Squash / Fixup / + * Drop, moved up and down, a new message for a reword or a squash. Before it + * runs it says what it rewrites ("This rewrites 4 commits; 2 are already + * pushed, so a force-push will be needed") and pushed commits need an explicit + * tick; Start is a two-click (`useArmed`). The server builds the todo and runs + * `git rebase -i` with no editor; a clean run stays open with **Undo**. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ArrowDown, ArrowUp } from "lucide-react"; +import type { GitTarget } from "../../../lib/api"; +import { + errText, + gitRewrite, + type InteractivePlan, + type PlanCommit, + type RewriteResult, + type TodoAction, +} from "../../../lib/gitRewrite"; +import { toast } from "../../../lib/toast"; +import { useArmed } from "../../../hooks/useArmed"; +import { BTN, BTN_DANGER, ErrorLine, GitModal, Warn } from "./GitModal"; +import { RewriteDone } from "./RewriteDone"; +import { blockedReason } from "./RebaseDialog"; + +const ACTIONS: TodoAction[] = ["pick", "reword", "squash", "fixup", "drop"]; + +interface Row { + commit: PlanCommit; + action: TodoAction; + message: string; +} + +/** Why the list cannot run as it stands, or null. */ +export function todoProblem(rows: Row[]): "first" | "allDropped" | "unchanged" | "reword" | null { + const kept = rows.filter((r) => r.action !== "drop"); + if (!kept.length) return "allDropped"; + if (kept[0]!.action === "squash" || kept[0]!.action === "fixup") return "first"; + if (rows.some((r) => r.action === "reword" && !r.message.trim())) return "reword"; + return null; +} + +export function InteractiveRebase({ + target, + base, + onClose, + onChanged, +}: { + target: GitTarget; + /** Rewrite the commits after this one; absent = the unpushed ones. */ + base?: string; + onClose: () => void; + onChanged: () => void; +}) { + const { t } = useTranslation(["gitTools"]); + const [plan, setPlan] = useState(null); + const [rows, setRows] = useState([]); + const [force, setForce] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const { isArmed, arm, disarm } = useArmed(); + const key = JSON.stringify(target); + + useEffect(() => { + let live = true; + gitRewrite + .planInteractive(target, base) + .then((p) => { + if (!live) return; + setPlan(p); + setRows(p.commits.map((c) => ({ commit: c, action: "pick", message: c.message }))); + }) + .catch((e) => live && setError(errText(e))); + return () => { + live = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, base]); + + const set = (i: number, patch: Partial) => + setRows((prev) => prev.map((r, n) => (n === i ? { ...r, ...patch } : r))); + const move = (i: number, step: number) => + setRows((prev) => { + const j = i + step; + if (j < 0 || j >= prev.length) return prev; + const next = [...prev]; + [next[i], next[j]] = [next[j]!, next[i]!]; + return next; + }); + + const blocked = plan ? blockedReason(t, plan) : null; + const problem = todoProblem(rows); + const unchanged = + !!plan && rows.every((r, i) => r.action === "pick" && r.commit.sha === plan.commits[i]?.sha); + const pushed = plan?.pushed ?? 0; + + const run = async () => { + if (!plan) return; + if (!isArmed("run")) return arm("run"); + disarm(); + setBusy(true); + setError(null); + try { + const r = await gitRewrite.run(target, "rebase-interactive", { + head: plan.head, + ...(base ? { base: plan.base } : {}), + confirm: true, + ...(pushed ? { force: true } : {}), + todo: rows.map((row) => ({ + sha: row.commit.sha, + action: row.action, + ...((row.action === "reword" || row.action === "squash") && + row.message.trim() && + row.message.trim() !== row.commit.message + ? { message: row.message } + : row.action === "reword" + ? { message: row.message } + : {}), + })), + }); + onChanged(); + if (r.conflicts?.length || r.stopped) { + toast.error(t("rebase.stopped", { count: r.conflicts?.length ?? 0 })); + onClose(); + return; + } + toast.success(t("interactive.done")); + setResult(r); + } catch (e) { + setError(errText(e)); + } finally { + setBusy(false); + } + }; + + return ( + + {t("close")} + + ) : ( + <> + + + + ) + } + > + {result ? ( + + ) : !plan ? ( + error ? ( + + ) : ( +

{t("loading")}

+ ) + ) : ( + <> +

{t("interactive.explain")}

+ {blocked && {blocked}} + {plan.merges && {t("interactive.merges")}} + {!rows.length ? ( +

{t("interactive.nothing")}

+ ) : ( + <> + + {pushed + ? t("rewritesPushed", { count: rows.length, pushed }) + : t("rewrites", { count: rows.length })} + {pushed > 0 && ( + + )} + +
    + {rows.map((r, i) => ( +
  1. +
    + + + {r.commit.short} + + + {r.commit.subject} + + {r.commit.pushed && ( + + {t("pushed")} + + )} + + +
    + {(r.action === "reword" || r.action === "squash") && ( +