fix(lanes,git): harden the lane git surface and fix client races in the Changes panel

Server (git-diff.js, git-ops.js, worktree.js, routes/lanes.js):
- Every lane git call runs with GIT_LITERAL_PATHSPECS=1 and -z porcelain, so a
  file name is a file name, never a glob, and odd names parse correctly.
- A lane that is a subdirectory of its repo sees and acts on ONLY its own
  paths (reads, stage/unstage/discard/commit, and gitFacts.files).
- Any file named settings.local.json (the lane's router API key) is refused as
  403 ESECRET on disk (symlinks resolved) and at every committed rev.
- commit refuses with 400 ESTAGEDOUTSIDE while anything outside the lane is
  staged (checked with --no-renames, so a move into the lane is caught).
- push resolves its remote (upstream → only remote → origin), else 400
  ENOREMOTE; unstaging a rename unstages both halves.
- Git writes serialize on the repo's git dir, not the lane id, so two lanes on
  one index cannot collide on index.lock.
- git refusing an action (a numeric exit status) answers 409 EGIT with git's
  stderr instead of a 500 carrying a numeric code; one shared parseNumstat.

Client:
- Changes panel: no stale answer from a previous lane or an older quick-open
  query repaints the panel; discard disarms on lane switch; Enter on a file
  path opens it instead of folding; bounded tree expand-all.
- Large diffs (> 500 rows) render only the visible rows, with stable width
  and in-sync split columns; mirrored split scroll is clamped.
- Git bar: no "in sync" without counts, a clear no-remote message, branch list
  re-read after a switch, and no previous-lane facts after a lane switch.
- Tooltips no longer flicker or close on unrelated scrolls.
This commit is contained in:
2026-09-24 09:54:51 +07:00
parent 6153d50fa8
commit 7bc4594b94
24 changed files with 2832 additions and 342 deletions
+44 -10
View File
@@ -392,6 +392,9 @@ What the lane's directory looks like to git right now:
}
```
- `dirty`, `untracked` and `files` cover only the lane's own directory, with
cwd-relative, unquoted paths — a lane that is a subdirectory of its repo does
not count its siblings' files (the same scoping as `/git/changes`).
- A `cwd` that is missing, is not a git repository, or makes git fail returns
`{"available": false}` with HTTP **200** — a normal state, not a fault.
- `files` names the files the counts came from, **capped at 20** (the counts
@@ -432,6 +435,12 @@ unresolvable one is `400 EBADREV`, never an empty diff).
range the diff has effectively become the whole file, which is what "expand
all" means in the panel.
Every path on the lane git surface (reads and writes) is **relative to the
lane's `cwd`**, even when that cwd is a subdirectory of its repo: changes
outside the cwd are not listed and cannot be named. Paths are literal file
names, never pathspec globs — `new*.js` or `app/[id]/page.js` names exactly
that file.
```json
{
"available": true,
@@ -497,13 +506,26 @@ and file explorer. All four are read-only and unguarded, and all four answer
- **`/git/log`** → `{available, commits:[{sha, short, subject, author, date,
refs}]}`, newest first, `limit` clamped to 200. `file=` narrows it to the
commits touching that path. Reading one commit's diff is
commits touching that path — relative, no `..` segment, else
`400 EBADPATH`. Reading one commit's diff is
`/git/changes?range=commit&sha=`.
- **`/git/file`** → `{available, path, rev, binary, truncated, lines}`.
`rev=work` (default) is the file on disk — the one branch that reads the
filesystem, and it is realpath-confined to the lane directory; anything else
goes through `git show <rev>:<path>`. Binary content is reported, never
returned. Capped at 1 MB. `400 EBADPATH`, `EBADREV` or `ENOFILE`.
filesystem, and it is realpath-confined to the lane directory (a symlink
resolving outside it is `EBADPATH`); anything else goes through
`git show <rev>:./<path>` (cwd-relative). At EVERY rev the path must be
relative with no `..` segment (else `400 EBADPATH`), and it is normalised
(`./a//b` → `a/b`, also the `path` echoed back). Binary content is reported,
never returned. Capped at 1 MB — a larger file is `truncated: true`, never
`ENOFILE` (a working-tree file is read only up to the cap, never whole).
`400 EBADPATH`, `EBADREV` or `ENOFILE`. `.claude/settings.local.json`
(where a lane's AI Router key lives) is `403 ESECRET` at EVERY rev and at
ANY depth (`sub/.claude/settings.local.json`, a subdirectory lane's key, as
seen from a lane at the repo root), and so is any path that RESOLVES to it
(a symlinked `.claude` → `conf/` makes it `conf/settings.local.json`). In
fact ANY file named `settings.local.json`, in any directory and at any rev,
is refused — a symlink target's name is all a lane above it, or a commit of
it, ever sees. Its hunks are never returned by `/git/changes` either.
- **`/git/ls`** → `{available, paths, truncated, total}`, capped at 500. It
reads `git ls-files`, so ignored files never appear and no skip-list has to
be maintained; basename hits rank first.
@@ -530,15 +552,17 @@ POST /api/lanes/:id/git/:action
```
The git **writes** the dashboard offers. Guarded (same-origin) and serialized
per lane under the lane lock.
per git index (the lane lock keyed on the worktree's git dir), so a claude and
a codex lane on one cwd — or a root and a subdirectory lane of one repo —
cannot collide on `index.lock`.
| Action | Runs | Body |
| --- | --- | --- |
| `fetch` | `git fetch --all --prune` | — |
| `pull` | `git pull --ff-only` | — |
| `push` | `git push -u origin HEAD` | — |
| `push` | `git push -u <remote> HEAD` — the branch's upstream remote, else the only remote, else `origin`, else `400 ENOREMOTE` | — |
| `stage` | `git add -- <paths>` | `{"paths": ["..."]}` |
| `unstage` | `git restore --staged -- <paths>` | `{"paths": ["..."]}` |
| `unstage` | `git restore --staged -- <paths>` (a staged rename also passes its original path, so both halves unstage) | `{"paths": ["..."]}` |
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked) | `{"paths": ["..."], "confirm": true}` |
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"` | `{"message": "...", "only": "staged"}` |
| `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
@@ -558,6 +582,9 @@ per lane under the lane lock.
- A **path** in the body is never trusted as a path: `stage`, `unstage` and
`discard` accept only paths `git status` is reporting as changed right now,
so `..`, an absolute path and an untouched file are all `400 EBADPATH`.
A `commit` without `only: "staged"` sweeps in (`git add -A -- .`) only the
lane cwd's own changes, and is `400 ENOCHANGES` when the lane has none —
even if the repo is dirty elsewhere.
- `discard` is the one irreversible action here and it **requires**
`confirm: true` (`400 ECONFIRM` without it). The UI additionally asks twice.
- `branch-create` never switches — the usual reason to create one from a lane
@@ -567,9 +594,16 @@ per lane under the lane lock.
branch and a branch another worktree holds (`400 EBRANCHBUSY`).
- Bad input is **400** (`EBADACTION`, `EBADMESSAGE`, `EBADBRANCH`, `EBADPATH`,
`ECONFIRM`, `EPROTECTED`, `ENOBRANCH`, `EBRANCHEXISTS`, `EBRANCHBUSY`,
`EDIRTY`, `ENOCHANGES`); a git failure is
**500** with git's own `stderr` appended to `error.message` — a rejection or a
credential failure reads better in git's words.
`EDIRTY`, `ENOCHANGES`, `ESTAGEDOUTSIDE`, `ENOREMOTE`); git itself exiting
non-zero (a rejected push, a credential failure, a diverged pull) is
**409 `EGIT`** with git's own `stderr` appended to `error.message` — a
rejection reads better in git's words. Anything else (git not installed, a
timeout kill) is **500**, still under a string code (`EGIT` when there is
none) — `error.code` is never git's numeric exit status.
- In a lane whose cwd is a subdirectory of its repo, `commit` refuses with
`400 ESTAGEDOUTSIDE` while the index holds a change outside that directory:
`git commit` takes the whole index whatever the cwd, and scoping it with a
pathspec would commit the working-tree copy (unstaged edits included).
- Network actions (`fetch`/`pull`/`push`) are killed after 60s so an unreachable
remote fails instead of holding the request open.