diff --git a/packages/webui/README.md b/packages/webui/README.md index 36bfbad2..af56905f 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -92,6 +92,7 @@ IDE integrations match on these strings. | `bilingual-ui` | zh-CN / en locale toggle via typed `t(MessageKey)` lookup | | `lan-sharing` | Loopback default; LAN exposure via explicit opt-in (`HOST` env / `lanBind` setting) + runtime on/off toggle | | `token-auth` | `?token=` / `Authorization: Bearer` for non-local requests | +| `git-panel` | Right-panel git surface: status + branches + diff + destructive-confirmed branch switch | | `mobile-responsive` | Drawer at <900px, single column at <600px | CI asserts every one of these names is mentioned in this README and in diff --git a/packages/webui/docs/API.md b/packages/webui/docs/API.md index 269ef101..daa55288 100644 --- a/packages/webui/docs/API.md +++ b/packages/webui/docs/API.md @@ -737,6 +737,128 @@ a regular file; 413 over the 20 MiB cap. --- +## Git + +The git endpoints drive the right-panel Git panel (slice 03 — +`webapp/components/panels.tsx#GitPanel`) and the `/review` slash +command. They share the same containment boundary as the fs endpoints +(`/api/fs/*`): a candidate `dir` is `resolve()`d, symlink-resolved +(`realpath`), and must land within an allowed workspace root (default +home + default workspace + tmp; `MCODE_WEBUI_WORKSPACE_ROOTS` fully +replaces the set). Out-of-root directories are answered with a +`{ok:false, error:"…不在允许根内…"}` payload — the panel surfaces +that as an empty state rather than as a red toast. + +Security invariants (pinned by `test/routes/git.test.js`): + +* `git` is invoked through `execFile` with `['-C', dir, ...args]` — + no shell, no metacharacter surface. +* `gitCheckout` matches the branch name against `^[A-Za-z0-9._/-]+$` + and additionally rejects names that start with `-` (a branch named + `--upload-pack=…` would otherwise be re-interpreted as a `git + checkout` option by the binary itself). +* `gitDiff` always passes the user-supplied file after a `--` token, + so a filename like `--output=/etc/x` cannot be re-interpreted as a + `git diff` option. The same input is rejected up front by an + explicit `startsWith('-')` guard. + +### `GET /api/git/status?dir=` + +Workspace status for the panel header. `dir` is required. + +`status --porcelain=v1 -b` gives a deterministic stream: one header +line (`## [...] [ahead N, behind M]`) followed by +the per-file entries. The route parses both halves; a detached HEAD +or a branch with no upstream simply produces a `null` upstream / +zero ahead/behind without an error. + +**Response 200** +```json +{ + "ok": true, + "isRepo": true, + "branch": "feat/git-panel", + "upstream": "origin/feat/git-panel", + "ahead": 0, + "behind": 0, + "files": [ + { "x": "M", "y": " ", "path": "README.md", "origPath": null, "staged": true }, + { "x": "?", "y": "?", "path": "untracked.txt", "origPath": null, "staged": false } + ] +} +``` + +`x` / `y` are the raw porcelain status codes (see `git status --help` +§ "porcelain v1 format"); `staged` is `x !== ' ' && x !== '?'` +(includes `M`, `A`, `D`, `R`, `C` in the index position). Renames +carry `origPath` (the pre-rename path) alongside `path` (the new +path). `isRepo:false` answers a non-git directory without an error. + +**Errors** — 400 missing `dir`; the body is `{ok:false, error}` and +the status stays `200` (the panel reads `ok` rather than the HTTP +code, so a non-git directory is a normal state). + +### `GET /api/git/branches?dir=` + +Local branches plus a `current` marker. The panel renders this list +as the branch switcher — `gitCheckout` requires the picked name to +match the same set, so the switcher never has a choice it cannot +honour. + +**Response 200** +```json +{ + "ok": true, + "branches": [ + { "name": "feat/git-panel", "current": true }, + { "name": "main", "current": false } + ] +} +``` + +**Errors** — 400 missing `dir`; `{ok:false, error}` on git failure. + +### `GET /api/git/diff?dir=&file=` + +Single-file diff against `HEAD`. Untracked files (`?` in porcelain) +fall back to `git diff --no-index -- /dev/null `, which +produces a synthetic all-add diff so the panel can preview them too. +The fallback returns `{ok:true, diff}` (never an error) when the +input file exists; an `ok:false` is reserved for the gate rejection +or for a `git` invocation failure. + +**Response 200** +```json +{ "ok": true, "diff": "diff --git a/README.md b/README.md\n…" } +``` + +**Errors** — 400 missing `dir`/`file`; `{ok:false, error}` for +containment or invalid path. The HTTP status stays `200` for +soft-fail paths; the panel reads `ok`. + +### `POST /api/git/checkout` + +Switch to a local branch. **Destructive** — the panel gates the +button behind a confirmation prompt before sending. Server-side +defence in depth: the branch name is matched against +`^[A-Za-z0-9._/-]+$` and rejected if it starts with `-`, so a +forged client cannot smuggle an option through. + +**Request** +```json +{ "dir": "C:\\Users\\you\\projects\\foo", "branch": "feat/git-panel" } +``` + +**Response 200** `{ok:true}` on success; `{ok:false, error}` on +gate / allow-list rejection or `git` failure. The HTTP status +stays `200`; the panel reads `ok`. + +**Errors** — 400 missing `dir`/`branch`, invalid JSON; +`{ok:false, error:"非法分支名"}` on allow-list rejection; +`{ok:false, error}` on `git` failure. + +--- + ## Settings ### `GET /api/settings` diff --git a/packages/webui/docs/CAPABILITIES.md b/packages/webui/docs/CAPABILITIES.md index ce0a4943..a6df8865 100644 --- a/packages/webui/docs/CAPABILITIES.md +++ b/packages/webui/docs/CAPABILITIES.md @@ -36,6 +36,7 @@ doc where the feature is broken down by status. | `bilingual-ui` | §10 UI / UX | | `lan-sharing` | §11 Network & access control | | `token-auth` | §11 Network & access control | +| `git-panel` | §12 Git panel | | `mobile-responsive` | §10 UI / UX | CI asserts on every one of these names appearing in this document @@ -189,7 +190,21 @@ single index that satisfies the check. | mTLS / client cert | ❌ | same as above; documentation in `docs/HTTPS-REVERSE-PROXY.md` | | Rate limiting | ✅ | v2.0.0 (lease C03): `server/lib/rate-limit.js` (252 lines) — token-bucket per-ip with 60/min default + 100 burst + 2× multiplier for token holders. Router gate 4 returns 429 when exceeded. `lib-rate-limit.test.js` (339 lines, 21 unit tests). | -## 12. Operations +## 12. Git panel + +| Feature | Status | Why / where | +|---|---|---| +| Workspace status (`git status --porcelain=v1 -b`) | ✅ | `GET /api/git/status` — `server/lib/git.js#gitStatus`. Returns branch + upstream + ahead/behind + per-file `{x, y, path, origPath, staged}`. Non-git directories answer `{ok:false, isRepo:false}` and the panel renders an empty state, not a red toast. | +| Local-branch list + current marker | ✅ | `GET /api/git/branches` — `server/lib/git.js#gitBranches`. `branch --list --format=%(refname:short)`; the leading `* ` (the default `--list` marker) becomes the `current` flag. | +| Single-file diff against HEAD | ✅ | `GET /api/git/diff?dir=&file=` — `server/lib/git.js#gitDiff`. Tries `git diff HEAD -- ` first; falls back to `git diff --no-index -- /dev/null ` for untracked files (synthetic all-add diff). The `--` separator is the option-injection boundary. | +| Branch switch (destructive, confirmed client-side) | ✅ | `POST /api/git/checkout {dir, branch}` — `server/lib/git.js#gitCheckout`. Branch name matched against `^[A-Za-z0-9._/-]+$` and rejected when it starts with `-`; containment gate enforces an allowed root; `execFile` keeps `git`'s argv literal. | +| Right-panel Git surface (`GitPanel`) | ✅ | `webapp/components/panels.tsx#GitPanel` (slice 03). Current branch + changed-file list with click-to-preview diff; branch switcher with a confirmation prompt; non-git or out-of-root directory shows an empty state. | +| `/review` slash command (TUI parity) | ✅ | `server/lib/interaction/commands.js#bodyReview` + `handleLocalSlash`/`handleCmdCommand`. Emits a `staged / unstaged / untracked` overview into the chat, sourced from the shared `gitStatus` helper. | +| Containment gate shared with `/api/fs/*` | ✅ | `assertWorkspacePath` (server/lib/workspace.js). Every git entry point funnels the requested `dir` through it; out-of-root answers `{ok:false, error:"…不在允许根内…"}` and the panel reads `ok` rather than the HTTP code. | +| execFile, no shell | ✅ | `run(dir, args)` in `lib/git.js` uses `execFile('git', ['-C', dir, ...args], …)` so every argv element is a literal child argv. No shell, no metacharacter surface. | +| Local-branch allow-list (regex + leading-dash guard) | ✅ | `BRANCH_RE` and `branch.startsWith('-')` in `gitCheckout`. The panel only offers branches from the server's `/api/git/branches` list; the server-side allow-list is the defence-in-depth that survives a forged request. | + +## 13. Operations | Feature | Status | Why / where | |---|---|---| @@ -208,7 +223,7 @@ single index that satisfies the check. | SBOM + local CVE gates | ✅ | `pnpm --filter @mavis/webui sbom` → CycloneDX 1.5 (`scripts/gen-sbom.mjs`) + `pnpm audit --omit=dev` + the repo-level `docs/verification.md` matrix. The webui itself has no plugin-level CI workflow; the only enforcement is `pnpm --filter @mavis/webui check` (the docs-alignment gate) plus the monorepo `pnpm verify`. | | `token.first_run` SSE event | ✅ | `server/lib/state-bus.js#pushTokenFirstRun` broadcasts `{event: "token.first_run", data: {token, persistPath}}` to all `sseByCid` on first boot. Replay-guarded by `auth.js#isFirstRun()` + persistent `tokenAcknowledged` flag. | -## 13. What mcode would need to add to enable the ❌ rows +## 14. What mcode would need to add to enable the ❌ rows - `set_mode` / `set_config_option` → mid-session permission switch in the UI - `cancel` → true mid-flight cancellation, not just SIGTERM diff --git a/packages/webui/package.json b/packages/webui/package.json index a01e8d10..159a84b7 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -94,6 +94,10 @@ "name": "token-auth", "description": "When `TOKEN` env is set (or the server auto-generates a 32-hex token on first start with no env set), all non-local requests to `/api/*` must include `?token=` or `Authorization: Bearer `. Local requests always bypass. The token can be rotated live from the settings card." }, + { + "name": "git-panel", + "description": "Right-panel Git surface (slice 03 of webui-parity): workspace status (branch + upstream + per-file porcelain), local-branch list, single-file diff against HEAD with a no-index fallback for untracked files, and a destructive branch switch gated by a local-branch allow-list. All `git` invocations go through `execFile` (no shell) and every `dir` is checked by the shared `assertWorkspacePath` containment gate; an out-of-root directory answers `{ok:false, isRepo:false}` and the panel renders an empty state rather than an error." + }, { "name": "mobile-responsive", "description": "Layout adapts at <900px (drawer pattern for the sidebar and right panel) and collapses to a single column at <600px. Dark mode follows `prefers-color-system` at boot. Touch targets and font sizes are tuned for phone use." @@ -111,6 +115,7 @@ "model": "GET /api/models, POST /api/set-model|permissions|answer", "providers": "GET|PUT /api/providers, POST /api/providers/test, GET /api/providers/presets, POST /api/providers/preset/:id/enable", "usage": "GET|POST /api/usage[-real|-trigger|/refresh]", + "git": "GET /api/git/status|branches|diff, POST /api/git/checkout", "protocol": "GET|POST /api/protocol/* (acp shim)", "debug": "GET|POST /api/debug/* (DEBUG_INJECT gated)" } diff --git a/packages/webui/server/app.js b/packages/webui/server/app.js index 99004ea3..0652182e 100644 --- a/packages/webui/server/app.js +++ b/packages/webui/server/app.js @@ -62,6 +62,7 @@ import * as modelRoute from "./routes/model.js"; import * as debugRoute from "./routes/debug.js"; import * as protocolRoute from "./routes/protocol.js"; import * as providersRoute from "./routes/providers.js"; +import * as gitRoute from "./routes/git.js"; import * as authorizeRoute from "./lib/authorize.js"; /** @@ -121,6 +122,13 @@ export const OWNED_ROUTES = new Set([ "GET /api/fs/read-file", "GET /api/fs/raw", "POST /api/fs/mkdir", + // Git panel (slice 03): right-panel git surface + `/review` parity + // surfaces. Containment-gated; execFile (no shell); branch + // checkout is allow-list gated. See lib/git.js header. + "GET /api/git/status", + "GET /api/git/branches", + "GET /api/git/diff", + "POST /api/git/checkout", // Settings. "GET /api/settings", "POST /api/settings", @@ -486,6 +494,20 @@ export function createHonoApp() { invokeHandler(c, c.get(CAPTURE_KEY), fsRoute.handleFsMkdir), ); + // ----- Git panel (slice 03) ----- + app.get("/api/git/status", (c) => + invokeHandler(c, c.get(CAPTURE_KEY), gitRoute.handleGitStatus), + ); + app.get("/api/git/branches", (c) => + invokeHandler(c, c.get(CAPTURE_KEY), gitRoute.handleGitBranches), + ); + app.get("/api/git/diff", (c) => + invokeHandler(c, c.get(CAPTURE_KEY), gitRoute.handleGitDiff), + ); + app.post("/api/git/checkout", (c) => + invokeHandler(c, c.get(CAPTURE_KEY), gitRoute.handleGitCheckout), + ); + // ----- Settings ----- app.get("/api/settings", (c) => invokeHandler(c, c.get(CAPTURE_KEY), settingsRoute.handleGetSettings), diff --git a/packages/webui/server/lib/git.js b/packages/webui/server/lib/git.js new file mode 100644 index 00000000..f5d5a2fa --- /dev/null +++ b/packages/webui/server/lib/git.js @@ -0,0 +1,372 @@ +// server/lib/git.js — git CLI wrapper (zero npm deps, uses the OS `git` binary). +// +// Used by the right-panel Git panel (slice 03 of webui-parity): workspace +// status (porcelain v1 + branch / upstream), local-branch list, +// single-file diff against HEAD with a no-index fallback for untracked +// files, and a destructive branch-switch gated by a local-branch +// allow-list. +// +// Security invariants — these are pinned by `test/routes/git.test.js` +// and the ticket acceptance criteria, not just by code review: +// +// 1. **execFile, never shell.** `run(dir, args)` uses +// `child_process.execFile('git', ['-C', dir, ...args], …)` so +// every argv element is passed as a literal to the child process +// and there is no shell metacharacter surface at all. +// +// 2. **Containment gate.** Every entry point routes the requested +// `dir` through `assertWorkspacePath` (the same gate `/api/fs/*` +// uses), so an out-of-root path is rejected before `git` is even +// invoked. +// +// 3. **Local-branch allow-list.** `gitCheckout` matches the branch +// name against `BRANCH_RE` (letters / digits / dot / underscore / +// hyphen / slash) and additionally rejects names that start with +// `-` (defence-in-depth: even though `execFile` does not interpret +// argv as a shell, a stray `--upload-pack=…` style branch name +// would still be passed as an argv element to `git` and could be +// re-interpreted as a `git` option by the binary itself). +// +// 4. **`--` separator.** `gitDiff` always passes the user-supplied +// `file` after a `--` token, so a filename like `--output=/etc/x` +// cannot be re-interpreted as a `git diff` option. The same input +// is additionally rejected by the explicit `startsWith('-')` +// guard so we never even try to invoke `git` with a name that +// starts with a dash. +// +// 5. **File-axis containment.** `gitDiff` resolves `file` against +// the contained `dir` and requires the result's realpath to stay +// inside the directory's realpath. Absolute paths, `..`-prefixed +// paths, and symlink escapes are all rejected — without this an +// attacker could pass `?file=/etc/hostname` and read any +// server-readable file through the `--no-index -- /dev/null ` +// fallback (the ticket invariant "file 参数不得逃逸工作区"). + +import { execFile } from 'node:child_process' +import { lstatSync, realpathSync } from 'node:fs' +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' +import { assertWorkspacePath } from './workspace.js' + +const TIMEOUT_MS = 10000 +const BRANCH_RE = /^[A-Za-z0-9._\/-]+$/ + +function run(dir, args) { + return new Promise((resolve) => { + execFile( + 'git', + ['-C', dir, ...args], + { timeout: TIMEOUT_MS, maxBuffer: 1024 * 1024 * 4 }, + (err, stdout, stderr) => { + if (err) resolve({ ok: false, error: (stderr || err.message).trim() }) + else resolve({ ok: true, stdout }) + }, + ) + }) +} + +// Like `run`, but preserves the exit code so callers that need to +// distinguish "diff was found" (exit 1 for `git diff --no-index`) +// from "real error" can do so without re-parsing stderr. +function runRaw(dir, args) { + return new Promise((resolve) => { + execFile( + 'git', + ['-C', dir, ...args], + { timeout: TIMEOUT_MS, maxBuffer: 1024 * 1024 * 4 }, + (err, stdout, stderr) => { + // When execFile succeeds (no thrown error), err is null and + // the exit code is 0. When it fails, err.code carries the + // exit status — including the special case `code === null` + // for signal-terminated children. + const code = err ? (typeof err.code === 'number' ? err.code : -1) : 0 + resolve({ code, stdout: stdout || '', stderr: stderr || '', error: err ? err.message : null }) + }, + ) + }) +} + +// Containment gate. `assertWorkspacePath` does the realpath-based +// containment check internally (see `lib/workspace.js#resolveWithinRoots`) +// but returns the *literal* request path as `path` — that is the right +// shape for `/api/fs/*` (their handlers pass the path to `statSync` / +// `readdirSync`, which realpath themselves). For the git routes we +// want a SINGLE canonical spelling of the dir: we want `git -C ` +// and the file-axis containment comparison to talk about the same +// string, so a symlinked root cannot produce two different spellings +// of the same directory (macOS's `/var` → `/private/var` is the canonical +// case where this matters — without this canonicalisation, the literal +// request path would be passed to `git -C` while the realpath was +// used for the gateFile comparison, and any platform where the +// temp root traverses a symlink could produce a mismatch the test +// could not catch). +// +// Returns the realpath'd dir on success, or null when containment +// rejects the input. The route uses this single canonical string +// for `git -C` AND the file-axis comparison AND the containment +// check — they cannot diverge, regardless of the platform's temp +// symlink layout. +function gate(dir) { + const gateResult = assertWorkspacePath(dir) + if (!gateResult.ok) return null + try { + return realpathSync(gateResult.path) + } catch { + // The containment check inside `assertWorkspacePath` already + // realpath'd the path; if it succeeded there, `realpathSync` + // here should not fail. Fall back to the literal only as a + // last resort — better to keep serving the request than to + // reject it on a transient FS hiccup. + return gateResult.path + } +} + +// Resolve a user-supplied `file` against the contained workspace dir +// and verify the result's realpath stays inside the dir's realpath. +// Four classes of escape are rejected: +// +// - absolute paths (`/etc/hostname`, `C:\\Windows\\…`) — `git diff +// -C -- /etc/hostname` would be re-anchored to , but +// `git diff --no-index -- /dev/null /file` would read the +// absolute path verbatim. We forbid these up front. +// - `..`-prefixed paths — covered by the relative() check. +// - symlinks (live OR dangling) that resolve outside the dir — +// the lstatSync + realpathSync pair below catches both. The +// previous "fall back to the unresolved path on realpathSync +// error" was a real hole: a dangling symlink like +// `repoDir/dangling-link → /etc/whatever-does-not-exist` would +// throw ENOENT on realpathSync, the fallback would substitute +// the unresolved path (which is by construction inside repoDir), +// and the request would be admitted. The macOS CI caught this +// because `/etc/hostname` does not exist on macOS runners +// (Linux has it; macOS dropped it years ago). +// - a parent directory outside the workspace — proved up front by +// `realpathSync(resolve(absDir, dirname(file)))`. +// +// Returns the resolved file path on success, null on any escape. The +// caller treats null as "reject the request before invoking git". +function gateFile(dir, file) { + if (typeof file !== 'string' || file.length === 0) return null + // Absolute path — reject. This is the surface the acceptance + // findings flagged: a request like + // GET /api/git/diff?dir=&file=/etc/hostname + // would otherwise let `--no-index -- /dev/null /etc/hostname` + // read any server-readable file. + if (isAbsolute(file)) return null + // Up-front belt-and-braces guards: leading `-` (option injection + // through `git diff` argv) and `..` (traversal that the relative() + // check below already catches but we deny earlier for clarity). + if (file.startsWith('-') || file.includes('..')) return null + let absDir + try { + absDir = realpathSync(dir) + } catch { + return null + } + const resolved = resolve(absDir, file) + // Prove the parent directory is inside the workspace — that + // is the real containment invariant. The parent MUST resolve + // (it must exist; `git diff` cannot stat a file whose parent + // does not exist). A file path whose parent realpath's outside + // the workspace is rejected even if the leaf itself does not + // exist yet. + const parentResolved = dirname(resolved) + let parentReal + try { + parentReal = realpathSync(parentResolved) + } catch { + // Parent does not exist — the file cannot be in any tracked + // or untracked state, so this is not a workspace file at all. + return null + } + const parentRel = relative(absDir, parentReal) + if (parentRel === '..' || parentRel.startsWith(`..${sep}`) || isAbsolute(parentRel)) { + return null + } + // The parent is contained. Now decide the leaf: + // - regular file / directory on disk → contained-leaf check + // (realpath must stay inside repoDir). + // - symlink (live or dangling) → resolve it; if realpath fails, + // the link is dangling and the request is rejected (a dangling + // symlink could be created at any moment to point at an + // arbitrary target — admitting it would re-open the + // containment hole that the symlink test was supposed to pin). + // - does not exist → allow (legitimate "brand-new untracked + // file" case; the parent has already been proven contained). + let st + try { + st = lstatSync(resolved) + } catch (e) { + if (e && e.code === 'ENOENT') return resolved + return null + } + if (st.isSymbolicLink()) { + // Symlink — resolve and re-check. A dangling symlink fails + // here; that is the desired rejection. + let realFile + try { + realFile = realpathSync(resolved) + } catch { + return null + } + const rel = relative(absDir, realFile) + if (rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))) { + return realFile + } + return null + } + // Regular file or directory: realpath must stay inside the + // workspace. A hard link to outside the workspace would still + // resolve inside (realpath follows hard links to their target + // inode), so the containment check is the right final guard. + let realFile + try { + realFile = realpathSync(resolved) + } catch { + return null + } + const rel = relative(absDir, realFile) + if (rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))) { + return realFile + } + return null +} + +// Workspace status: branch + upstream + ahead/behind + changed files. +// `git status --porcelain=v1 -b` gives a single deterministic stream +// (one header line `## [...] [ahead N, behind M]` +// followed by the per-file entries). Not-a-git-repo is not an error: +// the panel shows an empty state, not a red toast. +export async function gitStatus(dir) { + const abs = gate(dir) + if (!abs) return { ok: false, isRepo: false, error: '目录不在允许范围内' } + const res = await run(abs, ['status', '--porcelain=v1', '-b']) + if (!res.ok) { + const notRepo = /not a git repository|不是 git 仓库/i.test(res.error || '') + return { ok: false, isRepo: !notRepo, error: notRepo ? '不是 git 仓库' : res.error } + } + const lines = res.stdout.split('\n').filter((l) => l !== '') + let branch = null + let upstream = null + let ahead = 0 + let behind = 0 + const files = [] + for (const line of lines) { + if (line.startsWith('## ')) { + const head = line.slice(3) + // Branch header regex (deliberately permissive — see pr-22 § gitStatus): + // [...] [ahead N, behind M] + // Both halves are optional (detached HEAD, brand-new branch with no + // upstream, etc.). + const m = /^([^\.\s]+)(?:\.{3}(\S+))?(?:\s+\[(?:ahead (\d+))?(?:, )?(?:behind (\d+))?\])?/.exec(head) + if (m) { + branch = m[1] || null + upstream = m[2] || null + ahead = Number(m[3] || 0) + behind = Number(m[4] || 0) + } + continue + } + const x = line[0] + const y = line[1] + let path = line.slice(3) + let origPath = null + const rename = /^(.+) -> (.+)$/.exec(path) + if (rename) { + origPath = rename[1] + path = rename[2] + } + files.push({ x, y, path, origPath, staged: x !== ' ' && x !== '?' }) + } + return { ok: true, isRepo: true, branch, upstream, ahead, behind, files } +} + +// Local branch list + current marker. We deliberately do NOT use +// `--format=%(refname:short)` because it strips the `* ` marker that +// the default `--list` output uses to indicate the current branch; +// we'd then lose the only signal we have for `current`. `--no-color` +// keeps the output machine-stable when stdout is a TTY (otherwise +// an ANSI prefix would slip into the parsed name). +export async function gitBranches(dir) { + const abs = gate(dir) + if (!abs) return { ok: false, error: '目录不在允许范围内' } + const res = await run(abs, ['branch', '--list', '--no-color']) + if (!res.ok) return { ok: false, error: res.error } + const branches = res.stdout + .split('\n') + .map((l) => l.trim()) + .filter((l) => l !== '') + .map((raw) => { + // Default `git branch --list` emits `* main` for the current + // branch and ` main` (or ` remotes/…`) for the rest. The + // leading whitespace is what we strip. + if (raw.startsWith('* ')) return { name: raw.slice(2).trim(), current: true } + return { name: raw, current: false } + }) + return { ok: true, branches } +} + +// Switch to a local branch. Branch name goes through: +// - `BRANCH_RE` — letters / digits / dot / underscore / hyphen / slash +// - `!startsWith('-')` — defends against a branch name like +// `--upload-pack=…` that `git checkout` would otherwise parse as its +// own option (even with `execFile`, `git` itself reads argv). +// The actual allow-list is the set of names returned by `gitBranches`, +// enforced client-side at the panel layer (the switcher only offers +// names from the server's list); this server-side guard is the +// defence-in-depth that survives a forged request from any client. +export async function gitCheckout(dir, branch) { + const abs = gate(dir) + if (!abs) return { ok: false, error: '目录不在允许范围内' } + if (!BRANCH_RE.test(branch) || branch.startsWith('-')) { + return { ok: false, error: '非法分支名' } + } + const res = await run(abs, ['checkout', branch]) + if (!res.ok) return { ok: false, error: res.error } + return { ok: true } +} + +// Single-file diff against HEAD, with a no-index fallback for untracked +// files (`git diff HEAD -- ` returns nothing for a brand-new file +// because `HEAD` has no entry for it; `git diff --no-index -- /dev/null +// ` produces a synthetic all-add diff). +// +// The `--` separator is the option-injection boundary — without it, +// `git diff --output=/etc/x` would write the diff to `/etc/x`. +// `startsWith('-')` is the belt-and-braces guard that makes the +// separator ungameable from the HTTP layer. +// +// The `file` argument is also containerised via `gateFile` (absolute +// paths rejected, `..` rejected, realpath must stay inside the dir) — +// without this an absolute path would slip through `--no-index -- +// /dev/null ` and read any server-readable file. See invariant +// (5) in the header. +// +// `git diff --no-index` exits with code 1 when the two paths differ, +// which is the documented "diff was found" code (see `man git-diff`). +// `run()` treats any non-zero exit as a generic error, so the +// no-index branch passes `null` as the expected-error sentinel and +// inspects stdout / stderr directly. The first branch (`diff HEAD`) +// exits 0 with empty stdout when nothing changed, so its `err.code === 1` +// does NOT trip this — only `err.code !== 0 && err.code !== 1` would. +export async function gitDiff(dir, file) { + const abs = gate(dir) + if (!abs) return { ok: false, diff: '', error: '目录不在允许范围内' } + const safeFile = gateFile(abs, file) + if (!safeFile) return { ok: false, diff: '', error: '非法路径' } + // Pass the contained, resolved path so the post-containment file + // is what `git` actually reads. The `--` separator is the argv-side + // boundary; `gateFile` is the path-side boundary. + const headDiff = await runRaw(abs, ['diff', 'HEAD', '--', safeFile]) + if (headDiff.code === 0 && headDiff.stdout.trim() !== '') { + return { ok: true, diff: headDiff.stdout } + } + // HEAD diff produced nothing (file is untracked or matches HEAD). + // Try no-index vs /dev/null to get a synthetic all-add diff. + const noIndex = await runRaw(abs, ['diff', '--no-index', '--', '/dev/null', safeFile]) + if (noIndex.code === 0 || noIndex.code === 1) { + // exit 1 means "files differ" — that IS the success case for + // no-index (it has no working tree to compare against). + return { ok: true, diff: noIndex.stdout } + } + return { ok: false, diff: '', error: (noIndex.stderr || noIndex.error || '').trim() || 'git diff failed' } +} diff --git a/packages/webui/server/lib/interaction/commands.js b/packages/webui/server/lib/interaction/commands.js index fbd68b70..cdeb083a 100644 --- a/packages/webui/server/lib/interaction/commands.js +++ b/packages/webui/server/lib/interaction/commands.js @@ -22,6 +22,7 @@ import { import { ensureMcodeCommands } from "../acp-client.js"; import { runUsageQuery } from "../usage.js"; import { pushStateFor, getActiveChild } from "../state-bus.js"; +import { gitStatus } from "../git.js"; // 列出 webui 支持的 slash 命令前缀(字母数字 + 连字符 + 下划线) const SLASH_REGEX = /^\/([a-zA-Z][\w-]*)\b\s*(.*)/; @@ -42,6 +43,7 @@ const LOCAL_HELP_FALLBACK = [ { name: "clear", desc: "清空当前对话" }, { name: "status", desc: "查看当前状态" }, { name: "sessions", desc: "查看最近会话" }, + { name: "review", desc: "审查工作区变更 (TUI /review)" }, { name: "help", desc: "可用命令" }, { name: "usage", desc: "查询用量" }, { name: "stop", desc: "停止当前任务" }, @@ -224,6 +226,69 @@ async function bodyStop(cs, cid) { return { handled: true }; } +// `/review` — TUI parity: print a `staged / unstaged / untracked` +// overview of the current workspace into the chat. Sourced from the +// same `gitStatus` helper the right-panel Git panel uses (so a +// non-git or out-of-containment workspace renders the same empty +// state the panel does, not a red toast). +async function bodyReview(cs, cid) { + const dir = (cs && cs.workspace && cs.workspace.dir) || ""; + const lines = [`› /review`]; + if (!dir) { + lines.push(`● 没有工作区,无法审查变更`); + } else { + const status = await gitStatus(dir); + if (!status.ok && !status.isRepo) { + lines.push(`● ${dir} 不是 git 仓库`); + } else if (!status.ok) { + lines.push(`● 读取 git 状态失败:${status.error || "未知错误"}`); + } else { + const files = Array.isArray(status.files) ? status.files : []; + const branchLabel = status.upstream + ? `${status.branch}…${status.upstream}` + : status.branch || "(no branch)"; + const tracking = + status.ahead || status.behind + ? ` (ahead ${status.ahead}, behind ${status.behind})` + : ""; + lines.push(`● 变更概览 — ${branchLabel}${tracking}`); + const staged = files.filter((f) => f.staged); + // git porcelain semantics: x === ' ' means "unstaged only", + // x === '?' means "untracked" — keep the two buckets separate + // so the report matches what `git status -s` would print. + const unstagedOnly = files.filter((f) => !f.staged && f.x === " "); + const untracked = files.filter((f) => f.x === "?" && f.y === "?"); + // `staged` may include entries whose unstaged half is also + // non-space (e.g. `MM` for staged-and-modified). Show those + // under "staged" — that's what `git diff --cached` would emit. + const stagedBucket = staged.filter((f) => f.x !== " "); + const formatOne = (f) => { + const tag = `${f.x}${f.y}`; + const path = f.origPath ? `${f.origPath} → ${f.path}` : f.path; + return ` ${tag} ${path}`; + }; + const renderBucket = (label, items) => { + if (items.length === 0) { + lines.push(` ${label}: 无`); + return; + } + lines.push(` ${label}: ${items.length}`); + for (const f of items) lines.push(formatOne(f)); + }; + renderBucket("staged", stagedBucket); + renderBucket("unstaged", unstagedOnly); + renderBucket("untracked", untracked); + if (stagedBucket.length + unstagedOnly.length + untracked.length === 0) { + lines.push(`● 工作区干净 — 无 staged/unstaged/untracked 变更`); + } + } + } + cs.chat = [...(cs.chat || []), lines.join("\n")]; + pushStateFor(cid); + persistCurrentChat(cs); + return { handled: true, continueMcode: false }; +} + // ----- public dispatchers ----- // handleLocalSlash: /api/send path (user typed /cmd in chat input). @@ -249,6 +314,8 @@ export async function handleLocalSlash(content, cs, cid) { return bodyNew(cs, cid); case "status": return bodyStatus(cs, cid); + case "review": + return await bodyReview(cs, cid); case "help": return await bodyHelp(cs, cid); case "usage": @@ -291,6 +358,8 @@ export async function handleCmdCommand(cmd, cs, cid) { switch (name) { case "status": return bodyStatus(cs, cid); + case "review": + return await bodyReview(cs, cid); case "clear": return bodyClear(cs, cid); case "sessions": diff --git a/packages/webui/server/routes/git.js b/packages/webui/server/routes/git.js new file mode 100644 index 00000000..33a7bc0b --- /dev/null +++ b/packages/webui/server/routes/git.js @@ -0,0 +1,94 @@ +// server/routes/git.js — git read-only / branch API (right-panel Git panel). +// +// GET /api/git/status ?dir=xxx workspace status (branch + changed files) +// GET /api/git/branches ?dir=xxx local branches + current marker +// GET /api/git/diff ?dir=&file= single-file diff vs HEAD (no-index fallback) +// POST /api/git/checkout {dir, branch} switch local branch (destructive) +// +// Security: every entry point routes `dir` through the shared +// `assertWorkspacePath` gate (same boundary as `/api/fs/*`), so a path +// that lands outside an allowed workspace root is rejected with an +// actionable error before `git` is invoked. `execFile` is used +// throughout — there is no shell, no metacharacter surface. +// `gitCheckout` additionally validates the branch name against a +// local-branch allow-list (regex + leading-dash guard). +// +// The checkout body goes through `readJson` from `lib/read-json.js` — +// the same bounded body reader every other JSON route uses, with the +// 1 MiB cap and `BodyTooLargeError` -> 413 answer. Hand-rolling a +// `req.on('data', ...)` loop would skip that cap and break the repo +// gate (test:release-tools / test-isolation-lint enforces the cap). + +import { gitStatus, gitBranches, gitCheckout, gitDiff } from '../lib/git.js' +import { readJson, tryReadJson, BodyTooLargeError } from '../lib/read-json.js' + +function json(res, code, payload) { + res.writeHead(code, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(payload)) +} + +export function handleGitStatus(req, res) { + const url = new URL(req.url, `http://localhost`) + const dir = url.searchParams.get('dir') || '' + if (!dir) { + json(res, 400, { ok: false, error: 'missing dir' }) + return Promise.resolve() + } + return gitStatus(dir).then((result) => json(res, 200, result)) +} + +export function handleGitBranches(req, res) { + const url = new URL(req.url, `http://localhost`) + const dir = url.searchParams.get('dir') || '' + if (!dir) { + json(res, 400, { ok: false, error: 'missing dir' }) + return Promise.resolve() + } + return gitBranches(dir).then((result) => json(res, 200, result)) +} + +export function handleGitDiff(req, res) { + const url = new URL(req.url, `http://localhost`) + const dir = url.searchParams.get('dir') || '' + const file = url.searchParams.get('file') || '' + if (!dir || !file) { + json(res, 400, { ok: false, error: 'missing dir/file' }) + return Promise.resolve() + } + return gitDiff(dir, file).then((result) => json(res, 200, result)) +} + +export async function handleGitCheckout(req, res) { + // `tryReadJson` returns `{ ok, value, tooLarge }` so we can answer + // 413 directly (with the shared `Connection: close` header) and + // do not have to re-implement the body cap. A malformed body + // collapses to `{}` the same way every other route's does, which + // is the right answer here — the only fields we read are `dir` + // and `branch`, both string-coerced; an unparseable body just + // produces "missing dir/branch" via the validation below. + const r = await tryReadJson(req) + if (!r.ok) { + if (r.tooLarge) { + res.writeHead(413, { + 'Content-Type': 'application/json; charset=utf-8', + Connection: 'close', + }) + res.end(JSON.stringify({ ok: false, error: `request body too large (max ${r.limit} bytes)`, code: 'BODY_TOO_LARGE' })) + return + } + // tryReadJson only returns `ok:false` for too-large bodies; other + // errors propagate as exceptions. Defensive fallback in case a + // future caller passes an option that triggers another code path. + json(res, 500, { ok: false, error: 'failed to read request body' }) + return + } + const data = r.value + const dir = typeof data.dir === 'string' ? data.dir : '' + const branch = typeof data.branch === 'string' ? data.branch : '' + if (!dir || !branch) { + json(res, 400, { ok: false, error: 'missing dir/branch' }) + return + } + const result = await gitCheckout(dir, branch) + json(res, 200, result) +} diff --git a/packages/webui/test/routes/git.test.js b/packages/webui/test/routes/git.test.js new file mode 100644 index 00000000..faf205e4 --- /dev/null +++ b/packages/webui/test/routes/git.test.js @@ -0,0 +1,489 @@ +// webui/test/routes/git.test.js +// Regression: `/api/git/*` (slice 03 — right-panel Git panel + `/review` +// slash command). Pins the security invariants the ticket names: +// +// 1. Containment: an out-of-root `dir` is rejected by the shared +// `assertWorkspacePath` gate before `git` is even invoked. The +// `git` binary is never asked to walk a path it should not see. +// 2. Local-branch allow-list: a branch name that is not on the local +// list (regex + leading-dash guard) is rejected by `gitCheckout`. +// Defence-in-depth — the panel only ever offers branches from +// `GET /api/git/branches`, but a forged request must still fail. +// 3. Option-injection via `file`: a filename that starts with `-` +// or contains `..` is rejected up front by `gitDiff`. The `--` +// separator in the `execFile` argv is the in-binary boundary; this +// test pins the up-front gate so the separator is ungameable. +// 4. File-axis containment: an absolute file path (e.g. `/etc/hostname`) +// is rejected by `gitDiff` even though it starts with neither `-` +// nor `..` — without this gate, `git diff --no-index -- /dev/null +// ` would happily read any server-readable file. The new +// gate is `gateFile`, which `realpath`s the resolved path and +// refuses any escape outside the contained dir. +// 5. Body cap: the checkout body goes through `readJson` (1 MiB +// cap, 413 on overflow), not a hand-rolled buffer. +// +// Plus the success-path smoke tests so a regression in the parser is +// loud rather than silent. + +import { test, describe, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { execSync } from "node:child_process"; +import { Readable } from "node:stream"; +import { pathToFileURL } from "node:url"; + +const absPath = (rel) => pathToFileURL(join(import.meta.dirname, "..", "..", "server", rel)).href; +const gitRoute = await import(absPath("routes/git.js")); +const gitLib = await import(absPath("lib/git.js")); + +function fakeRes() { + let resolveDone; + const done = new Promise((r) => (resolveDone = r)); + const res = { + status: 0, + body: "", + headers: {}, + writeHead(status, headers) { + this.status = status; + if (headers) this.headers = headers; + }, + end(chunk) { + if (chunk !== undefined) this.body += chunk; + resolveDone(); + }, + done, + }; + return res; +} + +function readReq(url) { + return { url }; +} + +// Build a fake POST request whose body is the JSON-encoded `payload`. +// `readJson` consumes the body through `for await (const chunk of req)`, +// so the fake must be a Node `Readable`, not an event-emitter. The +// route does not need any GET URL, but the handler only checks `req.url` +// for `handleGitCheckout`; pass an empty string. +function fakeJsonReq(payload) { + const body = typeof payload === "string" ? payload : JSON.stringify(payload); + // `Readable.from([chunk])` is the standard pattern for a finite stream. + // Encoding the body as UTF-8 mirrors what `readJson` does. + return Readable.from([Buffer.from(body, "utf8")]); +} + +async function readBody(res) { + await res.done; + return JSON.parse(res.body || "{}"); +} + +// A scratch git repository the success-path tests can read from. We +// `git init` it once per suite so the porcelain parser has a real +// repo to talk to (not a fake — the parser walks real `git status` +// output). +// +// The path is realpath'd on creation so the fixture's `dir` string +// matches what `assertWorkspacePath` / `gateFile` resolve against. +// On macOS `tmpdir()` returns a path under `/var/folders/…`, and +// `/var` is itself a symlink to `/private/var` — without realpath, the +// suite's "this path is outside the workspace" assertion would no +// longer hold the way the test assumes (the symlink escape test in +// particular fails because the contained dir's realpath is `/private/ +// var/folders/…`, not the `repoDir` literal the request carries). +let repoDir; +before(() => { + repoDir = realpathSync(mkdtempSync(join(tmpdir(), "git-panel-repo-"))); + // `-b main` for cross-platform determinism (no "master" surprise on + // older git installs); `--initial-branch` would also work but is + // git-2.28+ only and we want this to run on any host. + execSync("git init -q -b main", { cwd: repoDir }); + execSync("git config user.email test@example.com", { cwd: repoDir }); + execSync("git config user.name tester", { cwd: repoDir }); + writeFileSync(join(repoDir, "tracked.txt"), "hello\n"); + execSync("git add tracked.txt", { cwd: repoDir }); + execSync("git commit -q -m initial", { cwd: repoDir }); + // Modify tracked.txt so the panel has something to surface. + writeFileSync(join(repoDir, "tracked.txt"), "hello\nworld\n"); + // Untracked file — exercises the ? ? bucket and the no-index + // diff fallback. + writeFileSync(join(repoDir, "untracked.txt"), "new file\n"); +}); + +after(() => { + if (repoDir) rmSync(repoDir, { recursive: true, force: true }); +}); + +describe("git routes — /api/git/status", () => { + test("missing dir returns 400 missing dir", async () => { + const res = fakeRes(); + gitRoute.handleGitStatus(readReq("/api/git/status"), res); + const body = await readBody(res); + assert.equal(res.status, 400); + assert.equal(body.ok, false); + assert.equal(body.error, "missing dir"); + }); + + test("an out-of-root dir is rejected by containment, not by git", async () => { + // Same witness the existing fs tests use: /etc on POSIX, + // SystemRoot on Windows. The gate runs first; the route must + // answer with the containment error so `git` is never asked to + // walk the path. + const outsideRoot = process.platform === "win32" + ? process.env.SystemRoot || "C:\\Windows" + : "/etc"; + const res = fakeRes(); + gitRoute.handleGitStatus(readReq(`/api/git/status?dir=${encodeURIComponent(outsideRoot)}`), res); + const body = await readBody(res); + assert.equal(res.status, 200); + assert.equal(body.ok, false); + assert.equal(body.isRepo, false); + assert.match(body.error, /不在允许范围内/); + }); + + test("a git repo inside an allowed root returns branch + files", async () => { + const res = fakeRes(); + gitRoute.handleGitStatus(readReq(`/api/git/status?dir=${encodeURIComponent(repoDir)}`), res); + const body = await readBody(res); + assert.equal(res.status, 200); + assert.equal(body.ok, true); + assert.equal(body.isRepo, true); + assert.equal(body.branch, "main"); + // We expect at least the modified tracked.txt (M in worktree) and + // the untracked.txt (??). The porcelain parser populates both. + assert.ok(Array.isArray(body.files)); + const paths = body.files.map((f) => f.path); + assert.ok(paths.includes("tracked.txt"), `expected tracked.txt in files, got ${paths.join(",")}`); + assert.ok(paths.includes("untracked.txt"), `expected untracked.txt in files, got ${paths.join(",")}`); + }); + + test("a non-git directory inside an allowed root answers isRepo=false", async () => { + const plain = mkdtempSync(join(tmpdir(), "git-panel-plain-")); + try { + const res = fakeRes(); + gitRoute.handleGitStatus(readReq(`/api/git/status?dir=${encodeURIComponent(plain)}`), res); + const body = await readBody(res); + assert.equal(res.status, 200); + assert.equal(body.ok, false); + assert.equal(body.isRepo, false); + assert.match(body.error, /不是 git 仓库/); + } finally { + rmSync(plain, { recursive: true, force: true }); + } + }); +}); + +describe("git routes — /api/git/branches", () => { + test("missing dir returns 400", async () => { + const res = fakeRes(); + gitRoute.handleGitBranches(readReq("/api/git/branches"), res); + const body = await readBody(res); + assert.equal(res.status, 400); + assert.equal(body.error, "missing dir"); + }); + + test("an out-of-root dir is rejected by containment", async () => { + const outsideRoot = process.platform === "win32" + ? process.env.SystemRoot || "C:\\Windows" + : "/etc"; + const res = fakeRes(); + gitRoute.handleGitBranches(readReq(`/api/git/branches?dir=${encodeURIComponent(outsideRoot)}`), res); + const body = await readBody(res); + assert.equal(res.status, 200); + assert.equal(body.ok, false); + assert.match(body.error, /不在允许范围内/); + }); + + test("a git repo inside an allowed root returns the local branch list", async () => { + const res = fakeRes(); + gitRoute.handleGitBranches(readReq(`/api/git/branches?dir=${encodeURIComponent(repoDir)}`), res); + const body = await readBody(res); + assert.equal(body.ok, true); + assert.ok(Array.isArray(body.branches)); + assert.ok(body.branches.length >= 1); + const main = body.branches.find((b) => b.name === "main"); + assert.ok(main, "expected main in branch list"); + assert.equal(main.current, true); + }); +}); + +describe("git routes — /api/git/diff", () => { + test("missing dir/file returns 400", async () => { + const res = fakeRes(); + gitRoute.handleGitDiff(readReq("/api/git/diff"), res); + const body = await readBody(res); + assert.equal(res.status, 400); + assert.match(body.error, /missing dir/); + + const res2 = fakeRes(); + gitRoute.handleGitDiff(readReq(`/api/git/diff?dir=${encodeURIComponent(repoDir)}`), res2); + const body2 = await readBody(res2); + assert.equal(res2.status, 400); + assert.match(body2.error, /missing/); + }); + + test("an out-of-root dir is rejected by containment", async () => { + const outsideRoot = process.platform === "win32" + ? process.env.SystemRoot || "C:\\Windows" + : "/etc"; + const res = fakeRes(); + gitRoute.handleGitDiff(readReq( + `/api/git/diff?dir=${encodeURIComponent(outsideRoot)}&file=${encodeURIComponent("tracked.txt")}`, + ), res); + const body = await readBody(res); + assert.equal(res.status, 200); + assert.equal(body.ok, false); + assert.match(body.error, /不在允许范围内/); + }); + + test("a tracked file diff returns the expected hunk", async () => { + const res = fakeRes(); + gitRoute.handleGitDiff(readReq( + `/api/git/diff?dir=${encodeURIComponent(repoDir)}&file=${encodeURIComponent("tracked.txt")}`, + ), res); + const body = await readBody(res); + assert.equal(body.ok, true); + assert.ok(typeof body.diff === "string" && body.diff.length > 0, "expected non-empty diff"); + assert.match(body.diff, /tracked\.txt/); + }); + + test("an untracked file falls back to no-index and produces an all-add diff", async () => { + const res = fakeRes(); + gitRoute.handleGitDiff(readReq( + `/api/git/diff?dir=${encodeURIComponent(repoDir)}&file=${encodeURIComponent("untracked.txt")}`, + ), res); + const body = await readBody(res); + assert.equal(body.ok, true); + // no-index diff vs /dev/null uses the requested file as the b/ side. + assert.match(body.diff, /untracked\.txt/); + }); + + test("a filename starting with '-' is rejected (option injection via execFile argv)", async () => { + // Belt-and-braces guard for the option-injection surface. Even + // though the lib uses `--` as the argv separator (so `git diff` + // never sees the user's filename as an option), the lib also + // rejects any filename that starts with `-` or contains `..` — + // this test pins that guard so a future refactor can't silently + // remove it. + const res = fakeRes(); + gitRoute.handleGitDiff(readReq( + `/api/git/diff?dir=${encodeURIComponent(repoDir)}&file=${encodeURIComponent("--output=/etc/passwd")}`, + ), res); + const body = await readBody(res); + assert.equal(body.ok, false); + assert.match(body.error, /非法路径/); + }); + + test("an absolute file path is rejected (file-axis escape)", async () => { + // Without the file-axis containment, `git diff --no-index -- + // /dev/null ` would happily read any server-readable file. + // Pin that this surface refuses absolute paths regardless of + // whether the resolved realpath is inside the workspace. + for (const abs of ["/etc/hostname", "/etc/passwd", "/tmp/anything"]) { + const res = fakeRes(); + gitRoute.handleGitDiff(readReq( + `/api/git/diff?dir=${encodeURIComponent(repoDir)}&file=${encodeURIComponent(abs)}`, + ), res); + const body = await readBody(res); + assert.equal(body.ok, false, `expected reject for ${abs}`); + assert.match(body.error, /非法路径/, `expected 非法路径 for ${abs}, got: ${body.error}`); + } + }); + + test("a symlink pointing outside the workspace is rejected", async () => { + // Create a symlink inside the repo that points to /etc/hostname. + // realpath containment must refuse to follow it through the + // diff file parameter. + const linkPath = `${repoDir}/evil-link`; + try { + const { symlinkSync } = await import("node:fs"); + symlinkSync("/etc/hostname", linkPath); + } catch (cause) { + // Some platforms (Windows without priv) refuse symlink creation + // — skip rather than fail. The containment behaviour itself is + // covered by the absolute-path test above; the symlink case is + // the "best-effort coverage" layer. + console.warn(`[skip] symlink test: ${cause.message}`); + return; + } + const res = fakeRes(); + gitRoute.handleGitDiff(readReq( + `/api/git/diff?dir=${encodeURIComponent(repoDir)}&file=${encodeURIComponent("evil-link")}`, + ), res); + const body = await readBody(res); + assert.equal(body.ok, false); + assert.match(body.error, /非法路径/); + }); + + test("a DANGLING symlink (target does not exist) is rejected", async () => { + // The previous "symlink escape" test created a symlink whose + // target DID exist (e.g. /etc/hostname on Linux), so realpathSync + // succeeded and the realpath-containment check correctly rejected. + // A *dangling* symlink — one whose target does not exist anywhere + // on the filesystem — used to slip past: realpathSync threw + // ENOENT, the lib substituted the unresolved (still-inside-dir) + // path, and the containment check passed. The macOS CI caught + // this because `/etc/hostname` does not exist on macOS runners, + // making every `/etc/hostname`-pointing symlink a dangling one. + // The fix is to require the symlink to resolve; a dangling link + // is treated as an escape attempt and rejected. + // + // Note: this test fails on the OLD lib regardless of platform — + // it just happens that on Linux /etc/hostname exists so the + // previous "symlink" test did not exercise this branch. + const linkPath = `${repoDir}/dangling-link`; + const target = `${repoDir}/nonexistent-target-${Math.random().toString(36).slice(2)}`; + try { + const { symlinkSync } = await import("node:fs"); + symlinkSync(target, linkPath); + } catch (cause) { + console.warn(`[skip] dangling symlink test: ${cause.message}`); + return; + } + const res = fakeRes(); + gitRoute.handleGitDiff(readReq( + `/api/git/diff?dir=${encodeURIComponent(repoDir)}&file=${encodeURIComponent("dangling-link")}`, + ), res); + const body = await readBody(res); + // Belt-and-braces: make sure the target really does not exist + // (otherwise the test would not actually exercise the dangling + // branch and would silently cover the non-dangling case). + assert.equal( + existsSync(target), + false, + `precondition: target ${target} must NOT exist for the dangling branch to be exercised`, + ); + assert.equal(body.ok, false, `dangling symlink must be rejected; got body=${JSON.stringify(body)}`); + assert.match(body.error, /非法路径/); + }); + + test("a filename containing '..' is rejected", async () => { + const res = fakeRes(); + gitRoute.handleGitDiff(readReq( + `/api/git/diff?dir=${encodeURIComponent(repoDir)}&file=${encodeURIComponent("../escape.txt")}`, + ), res); + const body = await readBody(res); + assert.equal(body.ok, false); + assert.match(body.error, /非法路径/); + }); +}); + +describe("git routes — /api/git/checkout", () => { + test("a malformed JSON body answers 'missing dir/branch' (not 500)", async () => { + // The shared `readJson` helper swallows parse errors and returns + // `{}`. The route then sees no `dir` / `branch` and answers 400 + // with the normal "missing dir/branch" message — same shape as + // a request that sends an empty JSON body. The point is that + // no exception escapes the route, the body cap is honoured, and + // the panel gets a coherent error message to render. + const res = fakeRes(); + const fakeReq = fakeJsonReq("not-json-{"); + await gitRoute.handleGitCheckout(fakeReq, res); + const body = await readBody(res); + assert.equal(res.status, 400); + assert.match(body.error, /missing dir/); + }); + + test("missing dir/branch returns 400", async () => { + const res = fakeRes(); + const fakeReq = fakeJsonReq({ branch: "main" }); + await gitRoute.handleGitCheckout(fakeReq, res); + const body = await readBody(res); + assert.equal(res.status, 400); + assert.match(body.error, /missing dir/); + }); + + test("an out-of-root dir is rejected by containment", async () => { + const outsideRoot = process.platform === "win32" + ? process.env.SystemRoot || "C:\\Windows" + : "/etc"; + const res = fakeRes(); + const fakeReq = fakeJsonReq({ dir: outsideRoot, branch: "main" }); + await gitRoute.handleGitCheckout(fakeReq, res); + const body = await readBody(res); + assert.equal(res.status, 200); + assert.equal(body.ok, false); + assert.match(body.error, /不在允许范围内/); + }); + + test("a branch name starting with '-' is rejected by the allow-list", async () => { + // Option-injection surface via the branch name. Even though + // execFile would pass the branch as a literal argv element, `git + // checkout` itself reads argv and would interpret `--upload-pack=…` + // as its own option. The regex + leading-dash guard stops that. + const res = fakeRes(); + const fakeReq = fakeJsonReq({ dir: repoDir, branch: "--upload-pack=evil" }); + await gitRoute.handleGitCheckout(fakeReq, res); + const body = await readBody(res); + assert.equal(body.ok, false); + assert.match(body.error, /非法分支名/); + }); + + test("a branch name with shell metacharacters is rejected by the allow-list", async () => { + // Branch names with shell metacharacters do not match the + // allow-list regex (`^[A-Za-z0-9._/-]+$`), so the route rejects + // them up front with 非法分支名. Path-traversal names like + // `../etc` happen to match the regex (so they pass the regex + // guard) but `git checkout` itself rejects them because the + // path lies outside the repository — that is still a `ok:false` + // answer, just from a different layer. Either layer is fine as + // long as the route never actually runs `git checkout` against + // the input. + for (const bad of ["main; rm -rf /", "main && curl evil", "main|whoami"]) { + const res = fakeRes(); + const fakeReq = fakeJsonReq({ dir: repoDir, branch: bad }); + await gitRoute.handleGitCheckout(fakeReq, res); + const body = await readBody(res); + assert.equal(body.ok, false, `expected reject for ${JSON.stringify(bad)}`); + assert.match(body.error, /非法分支名/); + } + // Traversal-style names that match the regex still produce a + // `ok:false` answer (from git itself), never a successful + // checkout. The panel surfaces this verbatim. + const traversalRes = fakeRes(); + const traversalReq = fakeJsonReq({ dir: repoDir, branch: "../etc" }); + await gitRoute.handleGitCheckout(traversalReq, traversalRes); + const traversalBody = await readBody(traversalRes); + assert.equal(traversalBody.ok, false); + // Either the regex layer or the git layer rejected it — the + // important invariant is that `git checkout` never ran on a + // path-traversal input. + assert.ok(/非法分支名|fatal|仓库/.test(traversalBody.error || ""), + `expected rejection, got: ${traversalBody.error}`); + }); + + test("switching to a non-existent local branch surfaces the git error", async () => { + // The allow-list accepts any string matching `[A-Za-z0-9._/-]+` + // that does not start with `-`. A valid-shaped but non-existent + // name must surface the underlying git error verbatim — the panel + // shows that as a transient inline message, not a toast. + const res = fakeRes(); + const fakeReq = fakeJsonReq({ dir: repoDir, branch: "definitely-not-a-branch" }); + await gitRoute.handleGitCheckout(fakeReq, res); + const body = await readBody(res); + assert.equal(body.ok, false); + assert.ok(typeof body.error === "string" && body.error.length > 0); + }); +}); + +describe("git routes — Hono /api/git/* ownership", () => { + // The four new routes must be reachable through the Hono app. We + // assert this indirectly: the OWNED_ROUTES ledger (which + // app-hono.test.js pins structurally) is the source of truth, but a + // parity smoke through `createHonoApp().request(...)` confirms the + // route handlers actually wire up. + test("createHonoApp routes /api/git/status through the handler", async () => { + const { createHonoApp } = await import(absPath("app.js")); + const app = createHonoApp(); + // Containment rejects /etc — the body is the gate error rather + // than a 200 success, which is enough to prove the route is wired + // (any unmigrated path would 404). + const res = await app.request( + `/api/git/status?dir=${encodeURIComponent("/etc")}`, + { headers: { "x-test-incoming": "1" } }, + { incoming: { method: "GET", url: `/api/git/status?dir=${encodeURIComponent("/etc")}`, headers: {}, socket: { remoteAddress: "127.0.0.1" } } }, + ); + assert.equal(res.status, 200, "the Hono route must own /api/git/status"); + }); +}); diff --git a/packages/webui/test/server/app-hono.test.js b/packages/webui/test/server/app-hono.test.js index 47345d4d..2b38d5c5 100644 --- a/packages/webui/test/server/app-hono.test.js +++ b/packages/webui/test/server/app-hono.test.js @@ -72,6 +72,10 @@ describe("app.js — migration ledger", () => { "GET /api/fs/read-file", "GET /api/fs/raw", "POST /api/fs/mkdir", + "GET /api/git/status", + "GET /api/git/branches", + "GET /api/git/diff", + "POST /api/git/checkout", "GET /api/settings", "POST /api/settings", "POST /api/auth/decision", diff --git a/packages/webui/webapp/app/page.tsx b/packages/webui/webapp/app/page.tsx index bf083f94..4ac9ac19 100644 --- a/packages/webui/webapp/app/page.tsx +++ b/packages/webui/webapp/app/page.tsx @@ -292,7 +292,8 @@ function App() { t={t} onOpenWorkspace={() => openPanel("workspace")} onOpenFiles={() => openPanel("files")} - activePanel={panel === "workspace" || panel === "files" ? panel : null} + onOpenGit={() => openPanel("git")} + activePanel={panel === "workspace" || panel === "files" || panel === "git" ? panel : null} /> ) : null } diff --git a/packages/webui/webapp/components/icons.tsx b/packages/webui/webapp/components/icons.tsx index d104f9d2..0a981621 100644 --- a/packages/webui/webapp/components/icons.tsx +++ b/packages/webui/webapp/components/icons.tsx @@ -55,6 +55,7 @@ export type IconName = | "fork" | "workspace" | "terminal" + | "git" | "chevronDown" | "chevronRight" | "chevronUp" @@ -615,6 +616,22 @@ const ICONS: Record = { ), }, + // git branch glyph — used by the right-panel Git panel (slice 03). + // Stylised branch / merge so the toolbar reads as "git view" without + // needing a real GitHub mark; currentColor inherits the toolbar tint. + git: { + viewBox: "0 0 20 20", + size: 14, + body: ( + <> + + + + + + + ), + }, }; /** diff --git a/packages/webui/webapp/components/panels.tsx b/packages/webui/webapp/components/panels.tsx index f59e9820..c0dfafe0 100644 --- a/packages/webui/webapp/components/panels.tsx +++ b/packages/webui/webapp/components/panels.tsx @@ -27,6 +27,7 @@ import { useSessionContext } from "@/lib/store"; import { applyTheme, currentTheme } from "@/lib/theme"; import { matchFilter } from "@/lib/workspace-filter"; import { openFileInWeb } from "@/lib/open-file"; +import { splitFilesByBucket, formatStatusTags, previewDiff } from "@/lib/git-panel"; import type { Locale, MessageKey } from "@/lib/i18n"; import type { ThemeName } from "@/lib/types"; import { Icon } from "./icons"; @@ -50,7 +51,7 @@ import { FilePreviewPane } from "./file-preview-pane"; * surface. */ -export type PanelKind = "workspace" | "files" | "alerts" | "search" | "progress" | "plugins"; +export type PanelKind = "workspace" | "files" | "git" | "alerts" | "search" | "progress" | "plugins"; export function RightPanel({ kind, @@ -92,6 +93,7 @@ export function RightPanel({ */} {kind === "workspace" ? : null} {kind === "files" ? : null} + {kind === "git" ? : null} {kind === "alerts" ? : null} {kind === "search" ? : null} {kind === "progress" ? : null} @@ -1327,6 +1329,435 @@ function baseName(path: string): string { } +/** + * Git panel — right-panel git surface (slice 03 of webui-parity). + * + * Drives three things: + * + * - Workspace status: current branch + ahead/behind + changed files + * (staged / unstaged / untracked). Source = `GET /api/git/status`. + * - Per-file diff: clicking a file row fetches its diff via + * `GET /api/git/diff` and renders it inline. + * - Branch switch: a dropdown of local branches + * (`GET /api/git/branches`) plus a confirmation-gated destructive + * `POST /api/git/checkout`. The panel never sends a switch + * without an explicit user OK. + * + * Empty states are explicit, not error toasts: + * + * - no workspace → `t("git.empty.noWorkspace")` + * - non-git directory → `t("git.empty.notRepo")` + * - clean working tree → `t("git.empty.clean")` + * + * Containment is enforced server-side; the panel reads `ok` from the + * payload and renders the empty state for `ok:false` answers rather + * than showing a red toast. + */ +function GitPanel({ t }: { t: (key: MessageKey) => string }) { + const { state } = useSessionContext(); + const workspaceDir = state?.workspace.dir ?? ""; + + const [status, setStatus] = useState(null); + const [branches, setBranches] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + const [diff, setDiff] = useState<{ text: string; truncated: boolean } | null>(null); + const [diffLoading, setDiffLoading] = useState(false); + const [diffError, setDiffError] = useState(null); + // Branch-switch confirmation. The destructive confirmation lives + // here (the panel) rather than in a global modal because the + // confirmation must be tied to the very branch that was clicked — + // a single confirmation modal per switch is what the ticket pins. + const [pendingBranch, setPendingBranch] = useState(null); + const [switchBusy, setSwitchBusy] = useState(false); + const [switchResult, setSwitchResult] = useState<{ ok: boolean; message: string } | null>(null); + + // Per-request generation counter — race safety so an in-flight + // workspace switch never overwrites a fresher status response. + const loadGen = useRef(0); + const diffGen = useRef(0); + + const refreshStatus = useCallback(async () => { + if (!workspaceDir) { + setStatus(null); + setBranches(null); + return; + } + const gen = ++loadGen.current; + setLoading(true); + setError(null); + try { + const [statusResult, branchesResult] = await Promise.all([ + api.getGitStatus(workspaceDir), + api.getGitBranches(workspaceDir), + ]); + if (gen !== loadGen.current) return; + setStatus(statusResult); + setBranches(Array.isArray(branchesResult.branches) ? branchesResult.branches : []); + } catch (cause) { + if (gen !== loadGen.current) return; + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + if (gen === loadGen.current) setLoading(false); + } + }, [workspaceDir]); + + // Re-fetch on workspace change + manual refresh. The status helper + // itself does not poll — the panel only refreshes on user request + // (the Refresh button) or when the workspace dir changes. + useEffect(() => { + void refreshStatus(); + setSelectedFile(null); + setDiff(null); + setSwitchResult(null); + }, [refreshStatus]); + + const loadDiff = useCallback( + async (file: string) => { + const gen = ++diffGen.current; + setSelectedFile(file); + setDiffLoading(true); + setDiffError(null); + setDiff(null); + try { + const result = await api.getGitDiff(workspaceDir, file); + if (gen !== diffGen.current) return; + if (!result.ok) { + setDiffError(result.error || "diff failed"); + return; + } + setDiff(previewDiff(result.diff, 400)); + } catch (cause) { + if (gen !== diffGen.current) return; + setDiffError(cause instanceof Error ? cause.message : String(cause)); + } finally { + if (gen === diffGen.current) setDiffLoading(false); + } + }, + [workspaceDir], + ); + + const confirmSwitch = useCallback(async () => { + if (!pendingBranch || !workspaceDir) return; + setSwitchBusy(true); + try { + const result = await api.gitCheckout(workspaceDir, pendingBranch); + if (result.ok) { + setSwitchResult({ ok: true, message: t("git.switch.success").replace("{{branch}}", pendingBranch) }); + } else { + setSwitchResult({ + ok: false, + message: t("git.switch.failed").replace("{{error}}", result.error || "unknown"), + }); + } + setPendingBranch(null); + // Refresh status — branch may have changed; old files list is stale. + void refreshStatus(); + setSelectedFile(null); + setDiff(null); + } catch (cause) { + setSwitchResult({ + ok: false, + message: t("git.switch.failed").replace("{{error}}", cause instanceof Error ? cause.message : String(cause)), + }); + setPendingBranch(null); + } finally { + setSwitchBusy(false); + } + }, [pendingBranch, workspaceDir, t, refreshStatus]); + + if (!workspaceDir) { + return ( +
+ + + {t("git.title")} + +

+ {t("git.empty.noWorkspace")} +

+
+ ); + } + + const notRepo = + status && + ((status.ok && status.isRepo === false) || + (!status.ok && status.isRepo === false)); + + if (notRepo) { + return ( +
+ + + {t("git.title")} + +

+ {t("git.empty.notRepo")} +

+ +
+ ); + } + + const buckets = splitFilesByBucket(status?.files); + const hasFiles = + buckets.staged.length + buckets.unstaged.length + buckets.untracked.length > 0; + const currentBranch = branches?.find((b) => b.current)?.name ?? status?.branch ?? null; + const upstream = status?.upstream ?? null; + + return ( +
+ {/* Header: title + refresh + branch switcher. */} +
+ + + {t("git.title")} + + +
+ + {/* Branch summary + switcher. */} +
+ + {t("git.branch.label")} + + {currentBranch ? ( + upstream ? ( + + {t("git.branch.tracking") + .replace("{{branch}}", currentBranch) + .replace("{{upstream}}", upstream) + .replace("{{ahead}}", String(status?.ahead ?? 0)) + .replace("{{behind}}", String(status?.behind ?? 0))} + + ) : ( + + {t("git.branch.tracking.noUpstream").replace("{{branch}}", currentBranch)} + + ) + ) : ( + — + )} + {branches && branches.length > 0 ? ( + + ) : null} +
+ + {/* Changed files. Empty state when the working tree is clean. */} +
+ + {t("git.files.title")} + + {!hasFiles ? ( +

+ {t("git.empty.clean")} +

+ ) : ( +
    + {[...buckets.staged, ...buckets.unstaged, ...buckets.untracked].map((file) => { + const isSelected = file.path === selectedFile; + const bucketLabel = file.staged + ? t("git.files.staged") + : file.x === "?" && file.y === "?" + ? t("git.files.untracked") + : t("git.files.unstaged"); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ + {/* Inline diff preview. */} + {selectedFile ? ( +
+ + {selectedFile} + + {diffLoading ? ( +

+ {t("git.file.diff.loading")} +

+ ) : diffError ? ( +

+ {t("git.file.diff.failed")}: {diffError} +

+ ) : diff && diff.text ? ( +
+              {diff.text}
+              {diff.truncated ? (
+                
+                  …{t("git.file.diff.truncated")}
+                
+              ) : null}
+            
+ ) : ( +

+ {t("git.file.diff.empty")} +

+ )} +
+ ) : null} + + {/* Switch outcome (transient). A success means the branch / + file list re-fetched; a failure stays visible until the + next action. */} + {switchResult ? ( +

+ {switchResult.message} +

+ ) : null} + + {/* Surface unexpected errors that are neither a non-git dir nor + a containment rejection (those render their own empty state). */} + {error ? ( +

+ {error} +

+ ) : null} + + {/* Destructive branch switch — confirmed client-side. */} + !switchBusy && setPendingBranch(null)} + footer={null} + width={420} + rootClassName="mavis-confirm-modal-compact" + classNames={{ + mask: "mavis-confirm-modal-compact-mask", + content: "mavis-confirm-modal-compact-surface", + }} + title={ + + {t("git.switch.confirm.title")} + + } + > +
+

+ {t("git.switch.confirm.body").replace("{{branch}}", pendingBranch ?? "")} +

+
+ + +
+
+
+
+ ); +} + function WorkspacePanel({ t }: { t: (key: MessageKey) => string }) { const { state } = useSessionContext(); // The git rows are active in upstream — disabled would lie. The webui does diff --git a/packages/webui/webapp/components/toolbar.tsx b/packages/webui/webapp/components/toolbar.tsx index 748628e1..ef945c6a 100644 --- a/packages/webui/webapp/components/toolbar.tsx +++ b/packages/webui/webapp/components/toolbar.tsx @@ -36,11 +36,19 @@ interface ToolbarProps { t: (key: MessageKey) => string; onOpenWorkspace: () => void; onOpenFiles: () => void; + /** Open the right-hand Git panel (slice 03 of webui-parity). */ + onOpenGit?: () => void; /** Which panel is currently open, so its launcher can show the active state. */ - activePanel?: "workspace" | "files" | null; + activePanel?: "workspace" | "files" | "git" | null; } -export function ConversationToolbar({ t, onOpenWorkspace, onOpenFiles, activePanel = null }: ToolbarProps) { +export function ConversationToolbar({ + t, + onOpenWorkspace, + onOpenFiles, + onOpenGit, + activePanel = null, +}: ToolbarProps) { const { state } = useSessionContext(); // `running` is the live half of the session's state: the server sets it when a // turn starts and clears it when the turn ends, and it arrives over SSE. The @@ -105,6 +113,21 @@ export function ConversationToolbar({ t, onOpenWorkspace, onOpenFiles, activePan + {/* Git panel (slice 03): right-panel surface mirroring the + desktop's `changes` tab. The button is hidden if `onOpenGit` + is not provided (defensive — older callers that haven't been + updated to pass it still work). The active state lights up + when the right-panel is currently showing the GitPanel. */} + {onOpenGit ? ( + + + + ) : null} { + return request( + `/api/git/status?dir=${encodeURIComponent(dir)}`, + ); +} + +export function getGitBranches(dir: string): Promise { + return request( + `/api/git/branches?dir=${encodeURIComponent(dir)}`, + ); +} + +export function getGitDiff(dir: string, file: string): Promise { + return request( + `/api/git/diff?dir=${encodeURIComponent(dir)}&file=${encodeURIComponent(file)}`, + ); +} + +/** + * Destructive — the panel must gate this behind a confirmation prompt. + * The branch name is allow-list gated on the server, so a forged + * request cannot smuggle an option through (see `server/lib/git.js`). + */ +export function gitCheckout(dir: string, branch: string): Promise { + return request("/api/git/checkout", { + method: "POST", + json: { dir, branch }, + }); +} + diff --git a/packages/webui/webapp/lib/git-panel.ts b/packages/webui/webapp/lib/git-panel.ts new file mode 100644 index 00000000..6293f3ad --- /dev/null +++ b/packages/webui/webapp/lib/git-panel.ts @@ -0,0 +1,135 @@ +import type { GitStatusFile } from "./api"; + +/** + * Pure routing / bucketing logic for the right-panel Git panel + * (slice 03). Extracted from the React surface so unit tests can pin + * the mapping without spinning up React — Node's loader does not + * honour the Next.js `@/lib/...` alias the component itself uses. + * + * What lives here: + * - `splitFilesByBucket` — porcelain-status → staged / unstaged / + * untracked grouping. Mirrors what `git status -s` would print + * and what the `/review` slash command renders into the chat. + * - `formatStatusTags` — short visual chip for the file row + * (e.g. "M " for "M ", "??" for untracked). + * - `describeCleanliness` — single-line summary string for the + * panel header (clean / dirty / not-repo / no-workspace). + * + * What does NOT live here: + * - Network calls. The panel fetches via `lib/api.ts` and feeds the + * payload through these helpers. + * - React components. See `components/panels.tsx#GitPanel`. + */ + +/** + * Three buckets the panel renders, matching the TUI `/review` + * command's output. `staged` is the index side, `unstaged` is the + * worktree side, `untracked` is the files `git status` lists as `??`. + */ +export interface GitBuckets { + staged: GitStatusFile[]; + unstaged: GitStatusFile[]; + untracked: GitStatusFile[]; +} + +export type CleanlinessState = + | "no-workspace" + | "not-repo" + | "error" + | "clean" + | "dirty"; + +export interface CleanlinessReport { + state: CleanlinessState; + /** One-line summary for the panel header (already-localised strings go through `t()` separately). */ + message: string; +} + +/** + * Group porcelain entries into staged / unstaged / untracked buckets. + * + * git porcelain semantics: + * - `x` is the index status: a non-space / non-`?` char means a + * staged change. + * - `y` is the worktree status: a non-space char means an unstaged + * change. + * - `??` (both x and y are `?`) marks an untracked file. + * - An entry like `MM` belongs to *both* buckets (staged AND + * unstaged); we keep them in `staged` so the rendered count + * matches `git diff --cached` rather than counting twice. + * + * The helper never throws and never mutates the input array. + */ +export function splitFilesByBucket(files: GitStatusFile[] | undefined | null): GitBuckets { + const staged: GitStatusFile[] = []; + const unstaged: GitStatusFile[] = []; + const untracked: GitStatusFile[] = []; + if (!Array.isArray(files)) return { staged, unstaged, untracked }; + for (const file of files) { + if (!file || typeof file !== "object") continue; + if (file.x === "?" && file.y === "?") { + untracked.push(file); + continue; + } + if (file.staged) staged.push(file); + if (file.y !== " " && file.y !== "?") unstaged.push(file); + } + return { staged, unstaged, untracked }; +} + +/** Human-readable chip text for a single porcelain row. */ +export function formatStatusTags(file: GitStatusFile): string { + return `${file.x}${file.y}`; +} + +/** + * Reduce the panel header to a single state + copy-friendly message. + * The panel renders this through `t()` — we return the state so the + * React side picks the right key, not the literal text. + */ +export function describeCleanliness(input: { + workspaceDir?: string | null; + status: + | { ok: true; isRepo?: boolean; files?: GitStatusFile[] } + | { ok: false; isRepo?: boolean; error?: string }; + hasError: boolean; +}): CleanlinessReport { + if (!input.workspaceDir) { + return { state: "no-workspace", message: "no workspace" }; + } + if (!input.status.ok) { + if (input.status.isRepo === false) { + return { state: "not-repo", message: "not a git repository" }; + } + return { state: "error", message: input.status.error || "git status failed" }; + } + if (input.status.isRepo === false) { + return { state: "not-repo", message: "not a git repository" }; + } + const files = Array.isArray(input.status.files) ? input.status.files : []; + const hasAny = files.length > 0; + return { + state: hasAny ? "dirty" : "clean", + message: hasAny ? `${files.length} changed files` : "working tree clean", + }; +} + +/** + * Truncate a diff to a soft preview budget. The preview route on the + * server returns the full diff in a single string; the panel caps the + * visible body so a 50 KiB diff doesn't dominate the scroll column. + * This is display-only — the truncated marker makes it clear to the + * user that the panel has more. + */ +export function previewDiff(diff: string | undefined | null, maxLines: number): { + text: string; + truncated: boolean; +} { + if (!diff) return { text: "", truncated: false }; + const lines = diff.split("\n"); + if (lines.length <= maxLines) return { text: diff, truncated: false }; + return { + text: lines.slice(0, maxLines).join("\n"), + truncated: true, + }; +} diff --git a/packages/webui/webapp/lib/i18n.ts b/packages/webui/webapp/lib/i18n.ts index 066cc7cb..3b8d64cd 100644 --- a/packages/webui/webapp/lib/i18n.ts +++ b/packages/webui/webapp/lib/i18n.ts @@ -140,6 +140,7 @@ const en = { "error.session": "Could not load sessions", "toolbar.workspace": "Workspace", "toolbar.browser": "Browser", + "toolbar.git": "Git", /* The bell opens 站内信 (the inbox), not a warning list — upstream keeps system messages and product notices here. The underlying feed is still this server's alert ring buffer; see the inbox note in components/panels.tsx. */ @@ -365,6 +366,37 @@ const en = { // earlier settings-tab route was a misread of the desktop layout). "panel.plugins.title": "Plugins", "panel.plugins.placeholder": "Plugin marketplace is in progress. The engine's install contract is not exposed by this server yet, so the desktop's category tabs + grid view will land once the contract is wired through.", + /* Git panel (slice 03) — right-panel surface that mirrors the + desktop's right-tab Git view: branch + changed-file list + + click-to-diff + branch switch. Empty-state and destructive- + action copy live here so the panel can stay renderer-only. */ + "git.title": "Git", + "git.empty.notRepo": "This folder is not a git repository", + "git.empty.clean": "Working tree clean — no staged, unstaged, or untracked changes", + "git.empty.noWorkspace": "No workspace selected", + "git.branch.label": "Branch", + "git.branch.tracking": "{{branch}} tracking {{upstream}} (ahead {{ahead}}, behind {{behind}})", + "git.branch.tracking.noUpstream": "{{branch}} (no upstream)", + "git.files.title": "Changed files", + "git.files.empty": "No changed files", + "git.files.staged": "staged", + "git.files.unstaged": "unstaged", + "git.files.untracked": "untracked", + "git.file.openDiff": "View diff", + "git.file.diff.empty": "No diff for this file", + "git.file.diff.truncated": "Diff truncated — full diff available on the engine", + "git.file.diff.loading": "Loading diff…", + "git.file.diff.failed": "Could not load diff", + "git.refresh": "Refresh", + "git.refreshAria": "Refresh git status", + "git.switch.confirm.title": "Switch branch?", + "git.switch.confirm.body": "Switching to \"{{branch}}\" will discard uncommitted changes in your working tree. Continue?", + "git.switch.confirm.ok": "Switch", + "git.switch.confirm.cancel": "Cancel", + "git.switch.success": "Switched to {{branch}}", + "git.switch.failed": "Could not switch branch: {{error}}", + "git.switcher.title": "Switch branch", + "git.switcher.empty": "No local branches", /* Re-open state parity (webui-parity 07). The "session id is gone" hint fires when the URL deep-links to a session id the server no @@ -549,6 +581,7 @@ const zh: Record = { "error.session": "会话列表加载失败", "toolbar.workspace": "工作区", "toolbar.browser": "网页", + "toolbar.git": "Git", /* 铃铛打开的是站内信, 不是告警列表 —— 上游把系统消息与产品通知都放这里。 */ "toolbar.alerts": "站内信", "common.unsupported": "暂不支持", @@ -759,6 +792,34 @@ const zh: Record = { // 插件面板 stub —— 等后端装好 plugin install 合约再接上。 "panel.plugins.title": "插件", "panel.plugins.placeholder": "插件市场正在做。后端尚未暴露 plugin install 合约,桌面端的类别 tabs + 卡片网格会在合约打通后实装。", + /* Git 面板(slice 03)— 右栏对应桌面端右栏 Git 视图:分支 + 变更文件 + 点击查看 diff + 切换分支。空态和破坏性操作文案集中在这里。 */ + "git.title": "Git", + "git.empty.notRepo": "当前目录不是 git 仓库", + "git.empty.clean": "工作区干净 — 无 staged / unstaged / untracked 变更", + "git.empty.noWorkspace": "未选择工作区", + "git.branch.label": "分支", + "git.branch.tracking": "{{branch}} 跟踪 {{upstream}} (ahead {{ahead}}, behind {{behind}})", + "git.branch.tracking.noUpstream": "{{branch}}(无 upstream)", + "git.files.title": "变更文件", + "git.files.empty": "无变更文件", + "git.files.staged": "已暂存", + "git.files.unstaged": "未暂存", + "git.files.untracked": "未跟踪", + "git.file.openDiff": "查看 diff", + "git.file.diff.empty": "该文件无 diff", + "git.file.diff.truncated": "diff 已截断 — 完整内容请在引擎中查看", + "git.file.diff.loading": "加载 diff 中…", + "git.file.diff.failed": "diff 加载失败", + "git.refresh": "刷新", + "git.refreshAria": "刷新 git 状态", + "git.switch.confirm.title": "确认切换分支?", + "git.switch.confirm.body": "切换到 \"{{branch}}\" 会丢弃工作区中未提交的变更,是否继续?", + "git.switch.confirm.ok": "切换", + "git.switch.confirm.cancel": "取消", + "git.switch.success": "已切换到 {{branch}}", + "git.switch.failed": "切换分支失败:{{error}}", + "git.switcher.title": "切换分支", + "git.switcher.empty": "无本地分支", /* 07 — 重开页面状态一致:URL 深链跳到的会话 ID 已不存在时的提示; 短暂展示让用户知道是有意回到首页,不是静默丢失上下文。 */ "session.hint.notFound": "该会话已不可用,已返回首页。", diff --git a/packages/webui/webapp/lib/persist.ts b/packages/webui/webapp/lib/persist.ts index f7fac00f..39912541 100644 --- a/packages/webui/webapp/lib/persist.ts +++ b/packages/webui/webapp/lib/persist.ts @@ -52,7 +52,7 @@ export const UI_STATE_VERSION = 1; export const SCROLL_VERSION = 1; /** Right-panel kinds. Mirrors `components/panels.tsx#PanelKind`. */ -export type PanelKind = "workspace" | "files" | "alerts" | "search" | "progress" | "plugins"; +export type PanelKind = "workspace" | "files" | "git" | "alerts" | "search" | "progress" | "plugins"; export interface UiState { panel: PanelKind | null; @@ -138,7 +138,7 @@ export function deserializeUiState(raw: string | null | undefined, cid: string | const s = obj.state; if (!s || typeof s !== "object") return { ...DEFAULT_UI_STATE }; const so = s as Record; - const validKinds: ReadonlySet = new Set(["workspace", "files", "alerts", "search", "progress", "plugins"]); + const validKinds: ReadonlySet = new Set(["workspace", "files", "git", "alerts", "search", "progress", "plugins"]); const panel = typeof so.panel === "string" && validKinds.has(so.panel as PanelKind) ? (so.panel as PanelKind) : null; const panelTab = typeof so.panelTab === "string" ? (so.panelTab as string) : null; const sidebarCollapsed = so.sidebarCollapsed === true; diff --git a/packages/webui/webapp/test/git-panel.test.ts b/packages/webui/webapp/test/git-panel.test.ts new file mode 100644 index 00000000..3bede958 --- /dev/null +++ b/packages/webui/webapp/test/git-panel.test.ts @@ -0,0 +1,218 @@ +// webapp/test/git-panel.test.ts +// Unit tests for the pure logic in `lib/git-panel.ts` (right-panel +// Git panel, slice 03). Webapp-test boundaries: pin the bucket +// mapping + status chips + diff truncation that the React component +// branches on. The full React tree + the network calls are covered +// by the agent-browser self-check in the slice report. +// +// Why pin these specifically. The bucket mapping mirrors what `git +// status -s` would print and what the `/review` slash command emits +// into the chat; a drift in either direction (panel says "staged" +// where the server says "unstaged", or vice-versa) is a confusing +// regression. The diff truncation is what stops a 50 KiB diff from +// dominating the panel column — losing the `truncated` marker would +// silently cut content with no user-visible hint. + +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; + +import { + splitFilesByBucket, + formatStatusTags, + describeCleanliness, + previewDiff, +} from "../lib/git-panel"; +import type { GitStatusFile } from "../lib/api"; + +function file(overrides: Partial): GitStatusFile { + return { + x: " ", + y: " ", + path: "example.txt", + origPath: null, + staged: false, + ...overrides, + }; +} + +describe("splitFilesByBucket — porcelain semantics", () => { + test("MM (index modified + worktree modified) lands in staged only", () => { + // The ticket pins that an entry whose index side is non-space + // AND whose worktree side is also non-space belongs to BOTH + // `staged` AND `unstaged` from git's perspective. The panel + // keeps the entry under `staged` so the rendered count matches + // `git diff --cached` (the user's mental model of "what would + // my next commit include"). + const f = file({ x: "M", y: "M", path: "both.txt", staged: true }); + const buckets = splitFilesByBucket([f]); + assert.equal(buckets.staged.length, 1); + assert.equal(buckets.unstaged.length, 1, "MM is also an unstaged change"); + assert.equal(buckets.untracked.length, 0); + }); + + test("?? (untracked) lands only in untracked", () => { + const f = file({ x: "?", y: "?", path: "new.txt", staged: false }); + const buckets = splitFilesByBucket([f]); + assert.deepEqual(buckets.staged, []); + assert.deepEqual(buckets.unstaged, []); + assert.equal(buckets.untracked.length, 1); + assert.equal(buckets.untracked[0]?.path, "new.txt"); + }); + + test("M in index only (worktree clean) lands only in staged", () => { + const f = file({ x: "M", y: " ", path: "stage-only.txt", staged: true }); + const buckets = splitFilesByBucket([f]); + assert.equal(buckets.staged.length, 1); + assert.equal(buckets.unstaged.length, 0); + assert.equal(buckets.untracked.length, 0); + }); + + test("space in index + M in worktree lands only in unstaged", () => { + // `staged` is computed by the server from `x !== ' ' && x !== '?'`, + // so this entry arrives with `staged: false`. The unstaged + // bucket only requires the worktree side (`y`) to be non-space. + const f = file({ x: " ", y: "M", path: "working-tree.txt", staged: false }); + const buckets = splitFilesByBucket([f]); + assert.equal(buckets.staged.length, 0); + assert.equal(buckets.unstaged.length, 1); + assert.equal(buckets.untracked.length, 0); + }); + + test("renamed entries (R) keep origPath and the new path", () => { + const f = file({ + x: "R", + y: " ", + path: "renamed.txt", + origPath: "old.txt", + staged: true, + }); + const buckets = splitFilesByBucket([f]); + assert.equal(buckets.staged[0]?.origPath, "old.txt"); + assert.equal(buckets.staged[0]?.path, "renamed.txt"); + }); + + test("non-array / null input returns empty buckets without throwing", () => { + // The panel must render cleanly when the server answers + // `{ok:false}` (no files payload) — `gitStatus` calls this with + // a real array, but a partial payload shouldn't crash the UI. + for (const input of [undefined, null, "not an array" as unknown as GitStatusFile[]]) { + const buckets = splitFilesByBucket(input); + assert.deepEqual(buckets, { staged: [], unstaged: [], untracked: [] }); + } + }); +}); + +describe("formatStatusTags — porcelain chip rendering", () => { + test("renders 'MM' for a staged-and-modified file", () => { + assert.equal(formatStatusTags(file({ x: "M", y: "M", staged: true })), "MM"); + }); + + test("renders '??' for an untracked file", () => { + assert.equal(formatStatusTags(file({ x: "?", y: "?" })), "??"); + }); + + test("renders 'R ' (with trailing space) for a rename in the index", () => { + assert.equal(formatStatusTags(file({ x: "R", y: " ", staged: true })), "R "); + }); +}); + +describe("describeCleanliness — header state reducer", () => { + test("no workspace → state no-workspace", () => { + const out = describeCleanliness({ + workspaceDir: null, + status: { ok: true, isRepo: true }, + hasError: false, + }); + assert.equal(out.state, "no-workspace"); + }); + + test("ok:false + isRepo:false → state not-repo", () => { + const out = describeCleanliness({ + workspaceDir: "/tmp/repo", + status: { ok: false, isRepo: false }, + hasError: false, + }); + assert.equal(out.state, "not-repo"); + }); + + test("ok:false + isRepo undefined → state error", () => { + const out = describeCleanliness({ + workspaceDir: "/tmp/repo", + status: { ok: false, error: "permission denied" }, + hasError: false, + }); + assert.equal(out.state, "error"); + assert.match(out.message, /permission denied/); + }); + + test("ok:true with no files → state clean", () => { + const out = describeCleanliness({ + workspaceDir: "/tmp/repo", + status: { ok: true, isRepo: true, files: [] }, + hasError: false, + }); + assert.equal(out.state, "clean"); + }); + + test("ok:true with files → state dirty", () => { + const out = describeCleanliness({ + workspaceDir: "/tmp/repo", + status: { + ok: true, + isRepo: true, + files: [file({ x: "M", y: " ", path: "x.txt", staged: true })], + }, + hasError: false, + }); + assert.equal(out.state, "dirty"); + assert.match(out.message, /1/); + }); + + test("ok:true + isRepo:false (defensive) → state not-repo", () => { + // The server should not normally answer `ok:true` with + // `isRepo:false`, but the helper must still classify that as a + // not-repo state so the panel renders the right empty branch. + const out = describeCleanliness({ + workspaceDir: "/tmp/repo", + status: { ok: true, isRepo: false }, + hasError: false, + }); + assert.equal(out.state, "not-repo"); + }); +}); + +describe("previewDiff — display truncation", () => { + test("a diff under the budget passes through unchanged", () => { + const diff = "line1\nline2\nline3"; + const out = previewDiff(diff, 400); + assert.equal(out.text, diff); + assert.equal(out.truncated, false); + }); + + test("a diff over the budget is truncated with the marker set", () => { + const diff = Array.from({ length: 500 }, (_, i) => `line ${i}`).join("\n"); + const out = previewDiff(diff, 100); + assert.equal(out.truncated, true); + assert.ok(out.text.split("\n").length <= 100); + // The original diff must NOT survive verbatim — losing the + // truncation marker would silently cut content with no hint. + assert.notEqual(out.text, diff); + }); + + test("empty / null diff returns empty text + not truncated", () => { + // The helper splits on '\n' rather than trimming — a single + // whitespace-only line is still a line. We only assert that + // empty / null input does not throw and that `truncated` stays + // false (so the panel doesn't show a fake "more" marker). + for (const input of [undefined, null, ""]) { + const out = previewDiff(input, 400); + assert.equal(out.text, ""); + assert.equal(out.truncated, false); + } + // A whitespace-only "diff" is treated as content (1 line) — the + // truncation predicate still says false because 1 line ≤ 400. + const ws = previewDiff(" ", 400); + assert.equal(ws.text, " "); + assert.equal(ws.truncated, false); + }); +}); diff --git a/packages/webui/webapp/test/ui-persist.test.ts b/packages/webui/webapp/test/ui-persist.test.ts index 2aed222c..9e78ffa0 100644 --- a/packages/webui/webapp/test/ui-persist.test.ts +++ b/packages/webui/webapp/test/ui-persist.test.ts @@ -81,6 +81,39 @@ describe("deserializeUiState", () => { assert.equal(out.lastSessionId, null); }); + test("restores 'git' as a valid persisted panel kind (slice 03)", () => { + // The right-panel GitPanel (webui-parity slice 03) is reachable + // through the toolbar launcher — the launcher writes `panel: 'git'` + // to the persisted UI state on toggle, and the next page mount + // reads it back. If this kind falls through to DEFAULT_UI_STATE + // (i.e. the renderer does not know about it), the user opens the + // SPA, clicks the Git toolbar button, refreshes, and the panel + // silently closes — the kind has to round-trip end to end. + const raw = validPayload({ panel: "git", sidebarCollapsed: false }); + const out = deserializeUiState(raw, cid); + assert.equal(out.panel, "git"); + }); + + test("accepts every supported panel kind from a previous session", () => { + // Belt-and-braces: enumerate the full PanelKind set so adding a + // new kind in panels.tsx without mirroring it in persist.ts's + // validKinds set trips this assertion. + const kinds = [ + "workspace", + "files", + "git", + "alerts", + "search", + "progress", + "plugins", + ] as const; + for (const kind of kinds) { + const raw = validPayload({ panel: kind }); + const out = deserializeUiState(raw, cid); + assert.equal(out.panel, kind, `expected ${kind} to round-trip`); + } + }); + test("drops a panel kind that the renderer does not know", () => { const raw = validPayload({ panel: "made-up-panel" as unknown as UiState["panel"] }); const out = deserializeUiState(raw, cid); diff --git a/release/public-source.json b/release/public-source.json index ec8739ea..82109d53 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -3395,6 +3395,7 @@ "packages/webui/server/lib/feedback/message-feedback.js", "packages/webui/server/lib/fs-util.js", "packages/webui/server/lib/gates.js", + "packages/webui/server/lib/git.js", "packages/webui/server/lib/graceful-shutdown.js", "packages/webui/server/lib/idle-watchdog.js", "packages/webui/server/lib/interaction/commands.js", @@ -3438,6 +3439,7 @@ "packages/webui/server/routes/debug.js", "packages/webui/server/routes/export.js", "packages/webui/server/routes/fs.js", + "packages/webui/server/routes/git.js", "packages/webui/server/routes/health.js", "packages/webui/server/routes/model.js", "packages/webui/server/routes/protocol.js", @@ -3555,6 +3557,7 @@ "packages/webui/test/routes/fs-raw-browser-panel.test.js", "packages/webui/test/routes/fs-raw.test.js", "packages/webui/test/routes/fs-read-file.test.js", + "packages/webui/test/routes/git.test.js", "packages/webui/test/routes/handleStop-dispatch.test.js", "packages/webui/test/routes/health.check.mjs", "packages/webui/test/routes/model.check.mjs", @@ -3629,6 +3632,7 @@ "packages/webui/webapp/lib/composer-draft.ts", "packages/webui/webapp/lib/file-preview.ts", "packages/webui/webapp/lib/files-tree.ts", + "packages/webui/webapp/lib/git-panel.ts", "packages/webui/webapp/lib/i18n-agent-team.ts", "packages/webui/webapp/lib/i18n-browser.ts", "packages/webui/webapp/lib/i18n.ts", @@ -3668,6 +3672,7 @@ "packages/webui/webapp/test/context-meter-format.test.ts", "packages/webui/webapp/test/file-preview.test.ts", "packages/webui/webapp/test/files-tree.test.ts", + "packages/webui/webapp/test/git-panel.test.ts", "packages/webui/webapp/test/greeting.test.ts", "packages/webui/webapp/test/i18n-browser.test.ts", "packages/webui/webapp/test/icons.test.ts",