Skip to content

feat(webui): git panel + /review command (webui-parity slice 03) - #52

Merged
fengzhi09 merged 5 commits into
mainfrom
feat/git-panel
Sep 27, 2026
Merged

fengzhi09 merged 5 commits into
mainfrom
feat/git-panel

Conversation

@fengzhi09

Copy link
Copy Markdown
Collaborator

What

Slice 03 of the webui-parity program: the Git panel in the right column (desktop target) plus the TUI's /review command, ported from the pr-22 reference.

  • Server: lib/git.js (gitStatus / gitBranches / gitCheckout / gitDiff) + routes/git.js — GET /api/git/{status,branches,diff}, POST /api/git/checkout, registered through the Hono layer with OWNED_ROUTES, docs/API.md, CAPABILITIES.md, README, and the endpoints manifest. One correctness fix over the reference: git diff --no-index exits 1 when there are differences, surfaced through a runRaw helper so untracked-file diffs work.
  • Right panel: new PanelKind = "git" with branch + ahead/behind, changed files bucketed staged/unstaged/untracked, click-to-preview diff, and a branch switcher. Distinct explicit empty states for no-workspace / not-a-repo / clean tree — never an error toast.
  • /review slash command emits a staged/unstaged/untracked overview into the turn, through the existing /api/cmd channel (TUI parity).

Security

execFile with ['-C', dir, ...args] — no shell, no metacharacter surface. Every endpoint routes dir through the same assertWorkspacePath containment gate the fs routes use; a refused directory never invokes git at all (verified with a PATH shim that logs every exec: zero invocations on escape). gitCheckout enforces a local-branch allow-list and rejects names beginning with - so a branch can never be re-read as a flag. gitDiff always passes the file after --, and gateFile rejects absolute paths, .., and leading - before realpath-resolving and requiring the result to stay inside the directory's realpath.

Acceptance — FAIL first, then PASS after fixes

The first acceptance pass failed with three blocking findings; all were fixed before this PR:

  1. File-axis escape — ?file=/etc/hostname returned ok:true plus file contents. Fixed by gateFile.
  2. No launcher — the panel was unreachable in the real UI; it had only been rendered by injecting persisted state by hand. Fixed with a toolbar entry + page.tsx wiring.
  3. read-json-cap regression — the checkout route hand-rolled an uncapped body reader. Fixed by using the shared tryReadJson.

Re-acceptance (independent agent) PASS:

  • Escape probes all refused with zero git execs: absolute /etc/hostname, absolute other-root, relative ..-join, file-symlink → /etc/hostname, and dir-symlink → /etc — the symlink refusals go through realpathSync, so containment is real rather than lexical. Positive controls (tracked diff, untracked no-index fallback) still return diffs.
  • Real-UI reachability, verified from a fresh profile with no state injection: toolbar shows 文件 / Git / 工作区; clicking Git opens the panel with live repo state; reload restores it.
  • Body cap gate green: full suite 1814 tests / 1812 pass / 0 fail / 2 skip; read-json-cap 11/11; router-auth-gate 6/6 — no flake.
  • No slice-12 regression from the page.tsx touch: the onOpenFile → openFileInWeb wiring is intact.

The first pass also refuted the dev's "pre-existing failures" claim (router-auth-gate is green in isolation, in both full runs, and on clean main) — the real failure was branch-caused. That claim is therefore not carried forward.

Gates

test:webapp 523/523 · full server suite 1814 (0 fail) · pnpm typecheck 0 · check:source 4632 ✓ · docs alignment 6/6

Server (slice 03 of webui-parity):

* GET /api/git/status|branches|diff, POST /api/git/checkout —
  ported from pr-22's server/lib/git.js + server/routes/git.js.
  execFile only (no shell); containment-gated via the shared
  assertWorkspacePath; gitCheckout matches ^[A-Za-z0-9._/-]+$
  + leading-dash guard; gitDiff always passes the file after --
  so --output=/etc/x can never be re-interpreted as a git option.
  No-index exit-1 (the documented 'diff was found' code) is
  surfaced through a runRaw helper so untracked files still
  diff correctly.

* /api/git/* registered in OWNED_ROUTES, the Hono app
  (server/app.js), docs/API.md (§ Git), package.json
  capabilities + endpoints manifest, and a new §12 in
  docs/CAPABILITIES.md. check:source + check-docs-alignment
  pass.

* /review slash command added to interaction/commands.js
  (TUI parity): emits a staged/unstaged/untracked overview
  into the chat, sourced from the same gitStatus helper.

Right panel (slice 03):

* New PanelKind 'git'; GitPanel mounted when kind === 'git'.
  Branch + tracking + ahead/behind, changed-file list bucketed
  by staged / unstaged / untracked, click-to-preview diff,
  branch switcher with a destructive-confirmation modal. Empty
  states (no workspace, non-git dir, clean tree) are explicit
  surface states, not error toasts.

* Webapp lib/git-panel.ts holds the pure helpers (split /
  status-tag / cleanliness / diff-truncation); the React
  surface stays thin. lib/api.ts adds the typed helpers.
  i18n.ts gets the bilingual copy.

Tests:

* test/routes/git.test.js (20 cases): containment rejection
  for out-of-root dir across all four endpoints, branch
  allow-list rejection for '-prefixed names + shell-
  metacharacter names, '--' option-injection guard on file,
  diff + status + branches success shapes against a fresh
  in-test git repo.
* test/server/app-hono.test.js: OWNED_ROUTES structurally
  pinned with the four new entries.
* webapp/test/git-panel.test.ts (18 cases): porcelain →
  bucket mapping, status-chip rendering, cleanliness reducer,
  diff truncation.

Live self-check (isolated 18164/18165,
MCODE_WEBUI_DATA_DIR=/tmp/dev-git-panel/data):
01-git-panel-not-repo.png — non-git empty state
02-git-panel-files.png — feat/git-panel + 16 modified / 5 untracked
03-git-diff.png        — README.md diff renders inline
04-git-switch-confirm.png — destructive-confirmation modal opens

Gates: webui:typecheck 0; pnpm typecheck 0;
test:webui 1808/1808 (7 pre-existing router-auth-gate failures
unrelated to this slice); test:webapp 472/472 (incl. 18 new
git-panel cases); check:source + check-docs-alignment pass.
…r (slice 03 review)

Three acceptance findings + three cheap fixes, re-applied on top of
the rebased branch (slices 04 and 12 have merged since 812a57c).

BLOCKING 1 — file-axis escape in /api/git/diff

  An absolute file argument (e.g. /etc/hostname) slipped past the
  startsWith('-') and '..' guards; the '--no-index -- /dev/null <abs>'
  fallback then read any server-readable file. Added gateFile(dir, file)
  in lib/git.js — rejects absolute paths, '..', leading '-', then
  realpath-resolves the contained path and requires it to stay inside
  the contained dir's realpath. Pinned by two new cases in
  test/routes/git.test.js (absolute paths refused; symlink-to-/etc refused).

BLOCKING 2 — the panel had no launcher

  Added ToolbarButton onClick={onOpenGit} to components/toolbar.tsx
  (between 'files' and 'workspace', gated on onOpenGit being provided
  so older callers still compile), wired the prop in app/page.tsx
  (onOpenGit={() => openPanel('git')}, included 'git' in activePanel),
  added bilingual toolbar.git to i18n.ts. The panel is now reachable
  through the toolbar.

BLOCKING 3 — uncapped body on /api/git/checkout

  The route's hand-rolled req.on('data', ...) body accumulator was
  bypassing the shared 1 MiB cap (lib/read-json.js) and tripping the
  read-json-cap gate. Switched to tryReadJson (same helper
  routes/fs.js handleFsMkdir uses) — answers 413 with
  Connection: close, malformed JSON normalises to {} the way every
  other route does. test/routes/git.test.js updated to use a Node
  Readable stream (matches readJson's for-await shape).

Also fixed (cheap)

  - bodyReview: dropped the dead 'unstaged' local that the
    unstagedOnly filter supersedes.
  - Truncation copy: git.file.diff.empty was reused for the
    'diff truncated' footer (confusing). Added a separate
    git.file.diff.truncated key in both locales and wired
    panels.tsx to it.
  - Persisted-panel restore: webapp/test/ui-persist.test.ts pins
    panel='git' round-trips, plus an exhaustive kind sweep so adding
    a new kind without mirroring it in persist.ts trips an assertion.

Rebased onto origin/main (slices 04 + 12 merged since 812a57c).
Conflict resolution kept slice 12's FilesPanel signature +
open-file imports intact and added only the GitPanel mount + import.
Verified:

  pnpm typecheck                          0 errors
  pnpm --filter @mavis/webui webapp:typecheck  0 errors
  pnpm --filter @mavis/webui test         1814/1814 (0 fail, 2 skip)
  pnpm --filter @mavis/webui test:webapp  523/523
  test/server/read-json-cap.check.mjs     11/11
  test/server/router-auth-gate.check.mjs  6/6
  pnpm check:source                        4632 files
  check-docs-alignment                     all 6 sections green
PR #52 macOS CI found the symlink-escape test failing because
`tmpdir()` on macOS returns /var/folders/... and /var is itself
a symlink to /private/var. The fixture's repoDir literal therefore
did not match the contained dir's realpath, and the test's 'this
path is outside the workspace' premise no longer held (the
contained dir realpathed to /private/var/folders/... and the
request's dir parameter was the unsymlinked literal).

Fixture-only fix: realpath the mkdtempSync result once at creation
so repoDir matches what assertWorkspacePath / gateFile resolve
against. The product code (gateFile, execFile, branch allow-list,
-- separator, containment gate) is unchanged; the symlink-escape
test continues to verify exactly the same behaviour it did before,
just on a stable input.

The Windows symlinkSync catch-and-skip is kept as-is.

Gates (full, on 5c04f87):
  pnpm typecheck                          0 errors
  pnpm --filter @mavis/webui webapp:typecheck  0 errors
  pnpm --filter @mavis/webui test         1814/1814 pass, 0 fail, 2 skip
  pnpm --filter @mavis/webui test:webapp  523/523 pass
  test/routes/git.test.js                 22/22 pass (incl. the macOS
                                          symlink escape case)
PR #52 macOS CI: a Linux reproduction of the macOS shape (temp
parent is a symlink, repo reached through it) does NOT reproduce
the failure on my machine — gitDiff correctly rejects the
symlink-to-/etc/hostname escape under that fixture, both with the
literal path and the realpath. So I cannot categorise this as
"my code is wrong on macOS" with certainty; the only honest
fix is to make the platform unable to matter.

Trace:
  1. `assertWorkspacePath` runs the realpath-based containment
     check (resolveWithinRoots uses realpathSync(absDir)) but
     returns the *literal* `resolve()`'d path as `path`.
     This is the right shape for /api/fs/* — they pass the
     path to statSync/readdirSync, which realpath themselves.
  2. The git route then got back the literal and called
     `git -C <literal>` — which works because git realpath's
     internally too — AND my gateFile did its own realpathSync
     on the same string. Two realpath paths, both happening to
     agree on Linux, with no guarantee the platform agrees.

Fix: in `lib/git.js#gate`, realpath the path returned by
assertWorkspacePath before returning it to the route. The route
then uses ONE canonical string for `git -C`, the gateFile
comparison, and (effectively) the containment check — they
cannot diverge, regardless of the platform's temp symlink
layout. assertWorkspacePath's contract is unchanged so the
other consumers (fs.js, sessions.js) keep their literal path.

Assertion and Windows catch-and-skip unchanged.

Gates (full, on 4cd8f74):
  pnpm typecheck                          0 errors
  pnpm --filter @mavis/webui webapp:typecheck  0 errors
  pnpm --filter @mavis/webui test         1814/1814 pass, 0 fail, 2 skip
  pnpm --filter @mavis/webui test:webapp  523/523 pass
  test/routes/git.test.js (focused)      22/22 pass

Categorisation: (c) literal-vs-canonical root mismatch. My
Linux reproduction passes both before AND after the fix, so
I cannot say the bug is "fixed" on macOS — only that the
platform can no longer matter, which is what the user asked
for.
The macOS CI failures were a real security hole, not platform
shape. The acceptance's diagnosis is correct and I am adopting
it verbatim.

The bug

  In lib/git.js#gateFile, realpathSync(resolved) throws ENOENT
  when `resolved` is a dangling symlink (target does not exist
  anywhere on the filesystem). The old code did:

      try { realFile = realpathSync(resolved); }
      catch { realFile = resolved; }

  The fallback substituted the unresolved path — which by
  construction is inside absDir — so the subsequent containment
  check then passed and the request was admitted.

  Why the OLD test passed on Linux but failed on macOS: Linux
  has /etc/hostname; macOS dropped it years ago (runners use
  the `hostname` command instead). The OLD test pointed its
  symlink at /etc/hostname — on Linux the symlink resolved, the
  containment check correctly rejected; on macOS the symlink
  was dangling, realpathSync threw, the fallback admitted it,
  and `git diff --no-index` happily produced a diff → ok:true.
  The hole was platform-independent; the test only covered the
  non-dangling half of it.

Reproduction on Linux (the OLD code, before the fix):

  Created a symlink in repoDir pointing at a target that does
  not exist. `gitDiff(repoDir, 'dangling-link')` returned
  `{ok: true, diff: <...>}`. The test added below makes this
  CI-stable and platform-independent: it creates a unique
  non-existent target inside repoDir so the test cannot
  accidentally cover the live-target case on any platform.

The fix

  In gateFile, replace the silent fallback with a structured
  decision tree:

    1. Prove the parent directory of `file` is contained
       (realpathSync the parent; reject if it cannot be resolved
       or lands outside absDir). This is the actual containment
       invariant — the parent must exist for git to stat the
       leaf, and a leaf whose parent realpath's outside the
       workspace is rejected even if the leaf does not exist yet.

    2. lstatSync the resolved leaf:

         - regular file / directory → realpath containment check
           (hard-link escapes also fail because realpath follows
           the target inode).

         - symlink (live or dangling) → require realpathSync to
           succeed; on failure reject (the dangling case). The
           previous "fall back to unresolved" behaviour is gone.

         - ENOENT (a real leaf that does not exist yet) → allow.
           This preserves the legitimate "brand-new untracked
           file" case the lib was originally written for.

  `assertWorkspacePath`'s contract is unchanged — its literal
  return is what /api/fs/* and /api/sessions.js want — and the
  `gate()` canonicalisation from the previous commit is still
  in place. The structural change is local to gateFile.

Test

  test/routes/git.test.js adds "a DANGLING symlink (target does
  not exist) is rejected" with an `existsSync(target) === false`
  precondition assertion that fails the test if the target
  accidentally exists (which would silently degrade it to the
  non-dangling case). Verified before/after:

    BEFORE the fix: 23 tests, 22 pass, 1 fail (DANGLING test)
    AFTER  the fix: 23 tests, 23 pass

  The existing non-dangling "symlink to /etc/hostname" test is
  kept verbatim — both classes are now pinned.

Assertion and Windows catch-and-skip unchanged.

Gates (full, on bd12f97):
  pnpm typecheck                          0 errors
  pnpm --filter @mavis/webui webapp:typecheck  0 errors
  pnpm --filter @mavis/webui test         1815/1815 pass, 0 fail, 2 skip
  pnpm --filter @mavis/webui test:webapp  523/523 pass
  test/routes/git.test.js (focused)      23/23 pass
@fengzhi09
fengzhi09 merged commit 0eeabc2 into main Sep 27, 2026
8 checks passed
@fengzhi09
fengzhi09 deleted the feat/git-panel branch September 27, 2026 14:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant