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:
+86
-18
@@ -9,7 +9,11 @@
|
||||
*
|
||||
* Four rules hold for every action:
|
||||
* - argv only, via worktree.js's `git()` — never a shell string.
|
||||
* - the lane's own working directory is the only cwd.
|
||||
* - the lane's own working directory is the only cwd, and its boundary:
|
||||
* in a lane that is a subdirectory of its repo, paths are cwd-relative
|
||||
* and nothing outside the cwd can be named or swept into a commit.
|
||||
* - every path is a LITERAL file name, never a pathspec glob — `new*.js`
|
||||
* must not also discard new1.js (`literal: true` on every call).
|
||||
* - a caller-supplied path is never trusted as a path: it must be one
|
||||
* `git status` is reporting as changed right now (`resolvePaths`), so no
|
||||
* `..`, no absolute path and no untouched file is reachable.
|
||||
@@ -26,12 +30,15 @@
|
||||
*/
|
||||
|
||||
const {
|
||||
git,
|
||||
git: rawGit,
|
||||
isValidBranchName,
|
||||
listBranches,
|
||||
branchCheckedOutAt,
|
||||
statusCounts,
|
||||
} = require("./worktree");
|
||||
const { laneStatus } = require("./git-diff");
|
||||
|
||||
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
|
||||
|
||||
/** How long a fetch/pull/push may run before it is killed. */
|
||||
const NETWORK_TIMEOUT_MS = 60_000;
|
||||
@@ -68,7 +75,9 @@ async function isDirty(dir) {
|
||||
|
||||
/**
|
||||
* The paths a per-file action may name — exactly the ones `git status` is
|
||||
* reporting as changed right now, keyed by their porcelain code.
|
||||
* reporting as changed right now, keyed by path → `{code, from}` (`from` is a
|
||||
* rename's original path). `-z`, so a path git would C-quote (non-ASCII, a
|
||||
* space, a quote) matches the literal path the panel sends.
|
||||
*
|
||||
* This is the whole authorization story for `stage`/`unstage`/`discard`: the
|
||||
* request cannot reach a path git is not already talking about, so no `..`,
|
||||
@@ -76,18 +85,56 @@ async function isDirty(dir) {
|
||||
* that is not in this map is a 400, never a silent no-op.
|
||||
*/
|
||||
async function changedPaths(dir) {
|
||||
const result = await git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]);
|
||||
const map = new Map();
|
||||
for (const line of result.stdout.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
const code = line.slice(0, 2);
|
||||
const raw = line.slice(3).trimEnd();
|
||||
const file = raw.split(" -> ").pop().replace(/^"|"$/g, "");
|
||||
map.set(file, code);
|
||||
}
|
||||
for (const { code, path, from } of await laneStatus(dir)) map.set(path, { code, from });
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* The remote a push goes to — never a name from the request: the current
|
||||
* branch's configured upstream remote, else the ONLY remote, else `origin`.
|
||||
* Anything else is ambiguous, so ENOREMOTE rather than a guess.
|
||||
*/
|
||||
async function pushRemote(dir) {
|
||||
const remotes = (await git(dir, ["remote"])).stdout.split("\n").filter(Boolean);
|
||||
const branch = (
|
||||
await git(dir, ["symbolic-ref", "--short", "-q", "HEAD"]).catch(() => ({ stdout: "" }))
|
||||
).stdout.trim();
|
||||
if (branch) {
|
||||
const upstream = (
|
||||
await git(dir, ["config", "--get", `branch.${branch}.remote`]).catch(() => ({ stdout: "" }))
|
||||
).stdout.trim();
|
||||
if (remotes.includes(upstream)) return upstream;
|
||||
}
|
||||
if (remotes.length === 1) return remotes[0];
|
||||
if (remotes.includes("origin")) return "origin";
|
||||
throw bad(
|
||||
"ENOREMOTE",
|
||||
remotes.length
|
||||
? "several remotes and no upstream or origin: set this branch's upstream first"
|
||||
: "this repository has no remote to push to"
|
||||
);
|
||||
}
|
||||
|
||||
/** Throw ESTAGEDOUTSIDE when the index holds a change outside the lane's cwd. */
|
||||
async function assertNothingStagedOutside(dir) {
|
||||
const prefix = (await git(dir, ["rev-parse", "--show-prefix"])).stdout.trim();
|
||||
if (!prefix) return; // the lane IS the repo root: nothing is outside it
|
||||
// No --relative: root-relative names, so outside entries are visible.
|
||||
// --no-renames: a staged move INTO the lane is listed as its new path only
|
||||
// otherwise, and the deletion of the old path outside would slip through.
|
||||
const staged = (await git(dir, ["diff", "--cached", "--name-only", "--no-renames", "-z"])).stdout
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
const outside = staged.find((p) => !p.startsWith(prefix));
|
||||
if (outside) {
|
||||
throw bad(
|
||||
"ESTAGEDOUTSIDE",
|
||||
`refusing to commit: ${outside} is staged outside this lane's directory`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate `body.paths` against what git reports as changed. */
|
||||
async function resolvePaths(dir, body) {
|
||||
const wanted = Array.isArray(body.paths)
|
||||
@@ -104,7 +151,7 @@ async function resolvePaths(dir, body) {
|
||||
if (unknown.length) {
|
||||
throw bad("EBADPATH", `not a changed path: ${unknown[0]}`);
|
||||
}
|
||||
return wanted.map((p) => ({ path: p, code: changed.get(p) }));
|
||||
return wanted.map((p) => ({ path: p, ...changed.get(p) }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,9 +185,10 @@ async function runGitAction(dir, action, body = {}) {
|
||||
// HEAD, not a branch name from the request: the lane pushes the branch it
|
||||
// is actually on, and -u makes the first push set the upstream the
|
||||
// ahead/behind counts then read.
|
||||
const remote = await pushRemote(dir);
|
||||
return done(
|
||||
action,
|
||||
await git(dir, ["push", "-u", "origin", "HEAD"], { timeout: NETWORK_TIMEOUT_MS })
|
||||
await git(dir, ["push", "-u", remote, "HEAD"], { timeout: NETWORK_TIMEOUT_MS })
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,7 +207,14 @@ async function runGitAction(dir, action, body = {}) {
|
||||
const args =
|
||||
untracked.length === paths.length && !(await hasHead(dir))
|
||||
? ["rm", "--cached", "-r", "--", ...paths.map((p) => p.path)]
|
||||
: ["restore", "--staged", "--", ...paths.map((p) => p.path)];
|
||||
: // A staged rename's ORIGINAL path rides along, as in `discard`:
|
||||
// restoring only the new one leaves the old path's deletion staged.
|
||||
[
|
||||
"restore",
|
||||
"--staged",
|
||||
"--",
|
||||
...paths.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path])),
|
||||
];
|
||||
return done(action, await git(dir, args));
|
||||
}
|
||||
|
||||
@@ -167,7 +222,11 @@ async function runGitAction(dir, action, body = {}) {
|
||||
if (body.confirm !== true) throw bad("ECONFIRM", "discard requires confirm: true");
|
||||
const paths = await resolvePaths(dir, body);
|
||||
const untracked = paths.filter((p) => p.code === "??").map((p) => p.path);
|
||||
const tracked = paths.filter((p) => p.code !== "??").map((p) => p.path);
|
||||
// A rename's ORIGINAL path rides along: restoring only the new one would
|
||||
// drop it from the index and the tree, and leave the old one deleted.
|
||||
const tracked = paths
|
||||
.filter((p) => p.code !== "??")
|
||||
.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]));
|
||||
const out = [];
|
||||
// Untracked files are deleted (git has no copy to restore); tracked ones
|
||||
// go back to HEAD in both gutters, so a staged-then-edited file lands in
|
||||
@@ -185,16 +244,25 @@ async function runGitAction(dir, action, body = {}) {
|
||||
const message = typeof body.message === "string" ? body.message.trim() : "";
|
||||
if (!message) throw bad("EBADMESSAGE", "commit message is required");
|
||||
if (message.length > MAX_MESSAGE_LEN) throw bad("EBADMESSAGE", "commit message is too long");
|
||||
// `git commit` takes the WHOLE index, whatever the cwd, and a pathspec
|
||||
// would switch it to committing the working-tree copy (unstaged edits
|
||||
// included) — so a subdirectory lane refuses rather than sweep another
|
||||
// actor's staged work outside it into this lane's commit.
|
||||
await assertNothingStagedOutside(dir);
|
||||
if (body.only === "staged") {
|
||||
// The panel's per-file staging only means something if a commit can
|
||||
// take the index as it stands — no `add -A` sweeping in the rest.
|
||||
const staged = [...(await changedPaths(dir)).values()].some(
|
||||
(code) => code[0] !== " " && code[0] !== "?"
|
||||
({ code }) => code[0] !== " " && code[0] !== "?"
|
||||
);
|
||||
if (!staged) throw bad("ENOCHANGES", "nothing staged to commit");
|
||||
} else {
|
||||
if (!(await isDirty(dir))) throw bad("ENOCHANGES", "nothing to commit");
|
||||
await git(dir, ["add", "-A"]);
|
||||
// The LANE's status, not the repo's: `add -A -- .` below only takes the
|
||||
// lane's files, so a clean subdirectory lane in a repo dirty elsewhere
|
||||
// has nothing to commit — not a git failure after the fact.
|
||||
if (!(await laneStatus(dir)).length) throw bad("ENOCHANGES", "nothing to commit");
|
||||
// `-- .`: the lane's own directory, not the whole repo it sits in.
|
||||
await git(dir, ["add", "-A", "--", "."]);
|
||||
}
|
||||
// -m takes the message as one argv element, so a message containing
|
||||
// newlines, quotes or a leading dash is content, never syntax.
|
||||
|
||||
Reference in New Issue
Block a user