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
+47 -9
View File
@@ -31,6 +31,7 @@ const { preflight } = require("../lib/lane-preflight");
const {
LANES_ROOT,
addWorktree,
git,
gitFacts,
isGitRepo,
isValidBranchName,
@@ -293,7 +294,9 @@ router.get("/branches", async (req, res) => {
const worktrees = await listWorktrees(sourceRepo).catch(() => []);
res.json({ branches, current, worktrees });
} catch (err) {
res.status(500).json({ error: { code: err.code, message: err.message, git: err.git } });
res
.status(500)
.json({ error: { code: gitErrorCode(err), message: err.message, git: err.git } });
}
});
@@ -808,7 +811,10 @@ router.get("/:id/git/log", async (req, res) => {
file: typeof req.query.file === "string" && req.query.file ? req.query.file : null,
});
res.json({ available: true, commits });
} catch {
} catch (err) {
if (err.code === "EBADPATH") {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
res.json({ available: false, commits: [] });
}
});
@@ -817,7 +823,8 @@ router.get("/:id/git/log", async (req, res) => {
* One file's whole content at a revision (`?rev=work|HEAD|<sha>`), for the
* viewer's "show the entire file" mode. Path validation lives in
* git-diff.js's `readFileAt` — the `work` revision is the one branch that
* touches the filesystem, and it is realpath-confined to the lane.
* touches the filesystem, and it is realpath-confined to the lane (symlinks
* included). `.claude/settings.local.json` is 403 ESECRET at every rev.
*/
router.get("/:id/git/file", async (req, res) => {
const lane = laneOr404(req, res);
@@ -830,6 +837,10 @@ router.get("/:id/git/file", async (req, res) => {
);
res.json({ available: true, ...file });
} catch (err) {
// A secret file (the router's API key) is refused at every rev.
if (err.code === "ESECRET") {
return res.status(403).json({ error: { code: err.code, message: err.message } });
}
if (["EBADPATH", "EBADREV", "ENOFILE"].includes(err.code)) {
return res.status(400).json({ error: { code: err.code, message: err.message } });
}
@@ -884,8 +895,10 @@ router.get("/:id/git/branches", async (req, res) => {
/**
* The git WRITES the dashboard offers: fetch, pull, push, stage, unstage,
* discard, commit, switch, branch-create and branch-delete.
* Guarded (they mutate), serialized per lane (`withLaneLock`) so a push and a
* commit fired from two tabs cannot interleave on one index, and thin — every
* Guarded (they mutate), serialized per git index (`withLaneLock` keyed on
* the worktree's git dir, so a claude and a codex lane on one directory share
* it) so a push and a commit fired from two tabs or two lanes cannot
* interleave on one index, and thin — every
* rule about what each action may do lives in server/lib/git-ops.js, not here.
* `git pull` is --ff-only and `switch` refuses a dirty tree, by design there.
*/
@@ -893,7 +906,15 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
const lane = laneOr404(req, res);
if (!lane) return;
try {
const result = await withLaneLock(lane.id, () =>
// Keyed on the INDEX, not the lane id: a claude and a codex lane on one
// cwd — or a root lane and a subdirectory lane of one repo — share one
// index, and their writes would otherwise race on index.lock. The git dir
// is per worktree, so sibling worktree lanes still run in parallel; a
// cwd that is not a repo falls back to its real path.
const gitDir = await git(lane.cwd, ["rev-parse", "--absolute-git-dir"])
.then((r) => r.stdout.trim())
.catch(() => fs.promises.realpath(lane.cwd).catch(() => lane.cwd));
const result = await withLaneLock(`git:${gitDir}`, () =>
runGitAction(lane.cwd, req.params.action, req.body || {})
);
res.json(result);
@@ -910,8 +931,15 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
"EBADPATH",
"ECONFIRM",
"EPROTECTED",
"ESTAGEDOUTSIDE",
"ENOREMOTE",
];
const status = badRequest.includes(err.code) ? 400 : 500;
// A numeric code is git's own exit status (execFile reports one that way):
// git ran and refused — a rejected push, a failed credential, a diverged
// pull. That is the repository's state answering a valid request, not a
// server fault, so it is a 409 under the string code the client expects.
const exited = typeof err.code === "number";
const status = badRequest.includes(err.code) ? 400 : exited ? 409 : 500;
// git's own stderr is the useful half of a failed push or pull (a
// rejection, a missing credential, a diverged branch), so it joins the
// message rather than riding a side field the client would have to know
@@ -919,7 +947,7 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
const detail = err.git ? (err.git.stderr || "").trim() : "";
res.status(status).json({
error: {
code: err.code || "EGIT",
code: gitErrorCode(err),
message: detail ? `${err.message}\n${detail}` : err.message,
},
});
@@ -1154,7 +1182,7 @@ function sendLifecycleError(res, err) {
}
if (err.git) {
return res.status(500).json({
error: { code: err.code, message: err.message, stderr: err.git.stderr },
error: { code: gitErrorCode(err), message: err.message, stderr: err.git.stderr },
});
}
return res.status(500).json({ error: { code: err.code, message: err.message } });
@@ -1207,6 +1235,16 @@ function sendRuntimeError(res, err) {
});
}
/**
* The string `error.code` for an error that may have come out of `git()`.
* When git exits non-zero, execFile sets `err.code` to the exit STATUS (a
* number, e.g. 128); the client branches on string codes, so that becomes
* `EGIT`. A missing code (a timeout kill) is `EGIT` too; string codes pass.
*/
function gitErrorCode(err) {
return typeof err.code === "string" && err.code ? err.code : "EGIT";
}
/** Resolve `:id` or answer 404. Returns null once the response has been sent. */
function laneOr404(req, res) {
const lane = lanesLib.getLane(req.params.id);