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
+55 -3
View File
@@ -30,7 +30,7 @@ const STATUS_FILE_CAP = 20;
* these variables. This module's whole job is to run git safely against repos other
* than the one enclosing the current working directory.
*/
async function git(cwd, args, { timeout = 0 } = {}) {
async function git(cwd, args, { timeout = 0, maxBuffer = 8 * 1024 * 1024, literal = false } = {}) {
// Build a clean environment: copy process.env but scrub git hook variables
// that could point to the outer repo's git directory or index.
const env = { ...process.env };
@@ -52,12 +52,23 @@ async function git(cwd, args, { timeout = 0 } = {}) {
}
// Prevent credential prompts from hanging a background provisioning job
env.GIT_TERMINAL_PROMPT = "0";
// Pathspec behaviour is ours to choose, never the parent's. `literal` makes a
// path argument name exactly one file: without it `new*.js` or `app/[id]/`
// is a GLOB, and `git clean -- 'new*.js'` deletes every match.
delete env.GIT_GLOB_PATHSPECS;
delete env.GIT_NOGLOB_PATHSPECS;
delete env.GIT_ICASE_PATHSPECS;
if (literal) env.GIT_LITERAL_PATHSPECS = "1";
else delete env.GIT_LITERAL_PATHSPECS;
try {
const result = await execFileAsync("git", args, {
cwd,
env,
maxBuffer: 8 * 1024 * 1024,
// A read with its own size cap passes a bigger buffer and treats an
// overflow (err.code ERR_CHILD_PROCESS_STDIO_MAXBUFFER, partial output in
// err.git.stdout) as truncated rather than as a failure.
maxBuffer,
// 0 = no limit, node's own default. A network command (fetch/pull/push)
// passes one so an unreachable remote fails instead of pinning a request
// open forever; GIT_TERMINAL_PROMPT=0 only covers the prompting case.
@@ -631,6 +642,46 @@ async function statusCounts(dir) {
return { dirty, untracked, head, files };
}
/**
* The lane card's view of the working copy: only entries inside the lane's own
* cwd, cwd-relative and read with `-z` — the same scoping the Changes panel
* uses (`laneStatus`), so a subdirectory lane's card never counts or lists a
* sibling's files, and a non-ASCII name is not shown C-quoted. `statusCounts`
* stays whole-repo on purpose: `switch` and the preflight need to know about
* ANY dirt a branch change would carry across.
*/
async function laneFiles(dir) {
// Lazy: git-diff.js requires this module, so a top-level require would cycle.
const { laneStatus, parseNumstat } = require("./git-diff");
const entries = await laneStatus(dir);
let stats = new Map();
try {
// -z --relative: cwd-scoped, cwd-relative, unquoted — keys the entries.
stats = parseNumstat(
(await git(dir, ["diff", "--numstat", "-z", "--relative", "HEAD"])).stdout
);
} catch {
/* no HEAD to diff against -> no per-file deltas */
}
let dirty = 0;
let untracked = 0;
const files = [];
for (const e of entries) {
if (e.code === "??") untracked++;
else dirty++;
if (files.length < STATUS_FILE_CAP) {
files.push({
path: e.path,
status: e.code,
// Counts only: the card's file record has no `binary` field.
added: stats.get(e.path)?.added ?? null,
deleted: stats.get(e.path)?.deleted ?? null,
});
}
}
return { dirty, untracked, files };
}
/**
* What a lane's working copy looks like right now: which branch it is on, the
* short HEAD, that commit's subject, and how much is uncommitted.
@@ -641,7 +692,8 @@ async function statusCounts(dir) {
* caller shows what git says rather than inventing a nicer word for it.
*/
async function gitFacts(dir) {
const { dirty, untracked, head, files } = await statusCounts(dir);
const { head } = await statusCounts(dir);
const { dirty, untracked, files } = await laneFiles(dir);
const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
const subject = (await git(dir, ["log", "-1", "--format=%s"])).stdout.trim();
return { branch, head, subject, dirty, untracked, files, ...(await trackingFacts(dir)) };