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
+16
View File
@@ -1022,6 +1022,22 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
assert.equal(g(dir, "branch", "--list", "feat/sibling").trim(), "");
});
it("maps a git exit status to 409 EGIT with git's stderr in the message", async () => {
// A remote that is not a repository: `git push` exits 128, and execFile
// reports that as a NUMERIC err.code. It must never reach the client as
// `code: 128`, nor as a 500 — git refusing is not a server fault.
g(dir, "remote", "add", "origin", path.join(ROOT, "no-such-remote"));
try {
const r = await request("POST", `/api/lanes/${laneId}/git/push`, {});
assert.equal(r.status, 409, JSON.stringify(r.body));
assert.equal(r.body.error.code, "EGIT");
assert.match(r.body.error.message, /^git push failed\n/);
assert.match(r.body.error.message, /no-such-remote/);
} finally {
g(dir, "remote", "remove", "origin");
}
});
it("404s every git route for a lane that does not exist", async () => {
for (const url of [
"/api/lanes/999999/git/changes",