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:
+47
-9
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user