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
+2 -2
View File
File diff suppressed because one or more lines are too long
+89 -9
View File
@@ -9,10 +9,13 @@
* touching every one of those call sites to get a designed bubble, and the
* next `title=` someone writes would be back to the browser's grey box.
* {@link GlobalTooltip} instead listens once at the document: on hover or
* keyboard focus it takes the element's `title`, suppresses the native tooltip
* keyboard focus (`:focus-visible`, not the focus a click gives) it takes the element's `title`, suppresses the native tooltip
* by removing the attribute for as long as the bubble is up, and restores it
* on the way out. Nothing else in the app has to know it exists, and a control
* keeps its native tooltip (and its accessible name) when JS has not run.
* While the title is parked, a control with no other name (icon-only) carries
* it as a temporary `aria-label`, so focusing it never leaves it nameless; the
* bubble goes away with its control if that is removed from the page.
*
* ## The look
* Anchored to the CONTROL, not the cursor — a bubble that chases the mouse is
@@ -39,8 +42,10 @@ const SHOW_DELAY_MS = 350;
/** Gap between the control and the bubble, and the minimum viewport margin. */
const GAP = 8;
/** Opt an element (or a subtree) out: `data-tip-off` keeps the native title. */
const SELECTOR = "[title]:not([data-tip-off])";
/** Opt an element (or a subtree) out: `data-tip-off` keeps the native title.
* `[data-tip]` matches the control whose title is parked, so moving between
* its own children still resolves to it instead of re-opening the bubble. */
const SELECTOR = "[title]:not([data-tip-off]), [data-tip]";
type Anchored = { text: string; rect: DOMRect };
type Placed = { left: number; top: number; arrow: number; above: boolean };
@@ -58,16 +63,29 @@ export function GlobalTooltip() {
// that unmounts it, Escape).
const parked = useRef<HTMLElement | null>(null);
const timer = useRef<number | undefined>(undefined);
// The element whose delay is running, so re-entering it does not restart it.
const pending = useRef<HTMLElement | null>(null);
const restore = useCallback(() => {
window.clearTimeout(timer.current);
pending.current = null;
const el = parked.current;
if (el) {
// Drop the instance shim `open` installed; the prototype method is back.
delete (el as { removeAttribute?: unknown }).removeAttribute;
const text = el.dataset.tip;
if (text !== undefined) {
el.setAttribute("title", text);
// React re-set `title` while it was parked (a poll flipped the hint):
// that value is current, the parked copy is stale. React never
// re-applies an unchanged prop, so writing the old one back would win
// for good.
if (!el.hasAttribute("title")) el.setAttribute("title", text);
delete el.dataset.tip;
}
if (el.dataset.tipAria !== undefined) {
el.removeAttribute("aria-label");
delete el.dataset.tipAria;
}
parked.current = null;
}
setTip(null);
@@ -79,7 +97,9 @@ export function GlobalTooltip() {
const text = (el.getAttribute("title") || "").trim();
if (!text) return;
restore();
pending.current = el;
timer.current = window.setTimeout(() => {
pending.current = null;
// Re-read at fire time: the control may have been re-rendered, removed
// or had its title changed while the delay was running.
if (!el.isConnected) return;
@@ -87,6 +107,29 @@ export function GlobalTooltip() {
if (!current) return;
el.dataset.tip = current;
el.removeAttribute("title");
// React removing `title` while it is parked calls removeAttribute on an
// attribute that is already gone: no MutationRecord, nothing to see —
// and `restore` would write the stale text back for good. So, on THIS
// element and only while parked, that call is taken as React's intent:
// the parked copy is dropped and the bubble hides. (React DOM calls
// `node.removeAttribute`, the instance method; `restore` deletes the
// shim.) No global prototype is touched.
el.removeAttribute = (name: string) => {
if (name.toLowerCase() === "title" && el.dataset.tip !== undefined) {
delete el.dataset.tip;
restore();
}
Element.prototype.removeAttribute.call(el, name);
};
// The title was an icon-only control's ONLY name; keep it named.
if (
!el.hasAttribute("aria-label") &&
!el.hasAttribute("aria-labelledby") &&
!el.textContent?.trim()
) {
el.setAttribute("aria-label", current);
el.dataset.tipAria = "";
}
parked.current = el;
setTip({ text: current, rect: el.getBoundingClientRect() });
}, delay);
@@ -101,7 +144,10 @@ export function GlobalTooltip() {
};
const onOver = (e: PointerEvent) => {
const el = target(e);
if (el) open(el, SHOW_DELAY_MS);
// Already showing or already counting down for this control: a pointer
// crossing its children must neither hide nor restart it.
if (!el || el === parked.current || el === pending.current) return;
open(el, SHOW_DELAY_MS);
};
const onOut = (e: PointerEvent) => {
// Moving between children of the same control must not flicker.
@@ -112,9 +158,14 @@ export function GlobalTooltip() {
};
// Keyboard users get the same tooltip, immediately — a focus ring that
// reveals nothing is the whole reason icon-only buttons are hard to learn.
// Only KEYBOARD focus, though: a click focuses too, and without the
// `:focus-visible` check it reopened the bubble the pointerdown just
// closed, with no delay. Hover keeps its own path.
const onFocus = (e: FocusEvent) => {
const el = target(e);
if (el) open(el, 0);
if (!el || el === parked.current) return;
if (!(e.target instanceof Element) || !e.target.matches(":focus-visible")) return;
open(el, 0);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") restore();
@@ -126,9 +177,17 @@ export function GlobalTooltip() {
document.addEventListener("focusin", onFocus);
document.addEventListener("focusout", restore);
document.addEventListener("keydown", onKey);
// A bubble anchored to a rect is wrong the moment anything moves.
window.addEventListener("scroll", restore, true);
// A bubble anchored to a rect is wrong the moment its anchor moves — but
// only a scroll of the document or of a box containing the anchor moves
// it. A log tailing in another panel must not close the bubble.
const onScroll = (e: Event) => {
const anchor = parked.current || pending.current;
const t = e.target;
if (!anchor || !(t instanceof Node) || t === document || t.contains(anchor)) restore();
};
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", restore);
window.addEventListener("blur", restore);
return () => {
document.removeEventListener("pointerover", onOver);
document.removeEventListener("pointerout", onOut);
@@ -136,12 +195,33 @@ export function GlobalTooltip() {
document.removeEventListener("focusin", onFocus);
document.removeEventListener("focusout", restore);
document.removeEventListener("keydown", onKey);
window.removeEventListener("scroll", restore, true);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", restore);
window.removeEventListener("blur", restore);
restore();
};
}, [open, restore]);
// A control unmounted under a still cursor fires no pointerout: without this
// its bubble stays up over whatever replaced it. The same goes for a title
// re-rendered under it — the bubble would keep saying the old text, so it
// hides and `restore` keeps the new attribute.
useEffect(() => {
if (!tip) return;
const watch = new MutationObserver((records) => {
const el = parked.current;
if (!el) return;
if (
!el.isConnected ||
records.some((r) => r.type === "attributes" && el.hasAttribute("title"))
)
restore();
});
watch.observe(document.body, { childList: true, subtree: true });
if (parked.current) watch.observe(parked.current, { attributeFilter: ["title"] });
return () => watch.disconnect();
}, [tip, restore]);
// Placed after the first paint, when the bubble's real size is known —
// measuring it is the only way to flip and clamp without guessing.
useEffect(() => {
@@ -10,6 +10,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GlobalTooltip } from "../Tooltip";
function setup(extra?: React.ReactNode) {
@@ -24,6 +25,24 @@ function setup(extra?: React.ReactNode) {
const hover = (el: Element) => fireEvent.pointerOver(el, { bubbles: true });
const unhover = (el: Element) => fireEvent.pointerOut(el, { bubbles: true });
// jsdom's :focus-visible is a heuristic over the previous events and leaks
// between tests, so the tests say which kind of focus they mean: a spy answers
// `:focus-visible` from this flag and passes every other selector through.
let keyboardFocus = false;
const realMatches = Element.prototype.matches;
beforeEach(() => {
keyboardFocus = false;
vi.spyOn(Element.prototype, "matches").mockImplementation(function (this: Element, sel) {
return sel === ":focus-visible"
? keyboardFocus && document.activeElement === this
: realMatches.call(this, sel);
});
});
afterEach(() => vi.restoreAllMocks());
const tabTo = (el: HTMLElement) => {
keyboardFocus = true;
act(() => el.focus());
};
beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true }));
afterEach(() => vi.useRealTimers());
@@ -63,7 +82,7 @@ describe("GlobalTooltip", () => {
setup();
const button = screen.getByRole("button", { name: "refresh" });
fireEvent.focusIn(button);
tabTo(button);
await act(async () => {
vi.advanceTimersByTime(0);
});
@@ -105,4 +124,165 @@ describe("GlobalTooltip", () => {
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
});
it("keeps an icon-only control's accessible name while its title is parked", async () => {
setup(
<button title="Stage this file">
<svg aria-hidden="true" />
</button>
);
const icon = screen.getByRole("button", { name: "Stage this file" });
tabTo(icon);
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
expect(icon).not.toHaveAttribute("title");
expect(screen.getByRole("button", { name: "Stage this file" })).toBe(icon);
fireEvent.keyDown(document, { key: "Escape" });
// Put back exactly as found: no aria-label left behind.
expect(icon).toHaveAttribute("title", "Stage this file");
expect(icon).not.toHaveAttribute("aria-label");
});
it("does not flicker while the pointer crosses the control's own children", async () => {
setup(
<button title="Two parts">
<span data-testid="part-a">a</span>
<span data-testid="part-b">b</span>
</button>
);
const a = screen.getByTestId("part-a");
const b = screen.getByTestId("part-b");
hover(a);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
fireEvent.pointerOut(a, { bubbles: true, relatedTarget: b });
fireEvent.pointerOver(b, { bubbles: true, relatedTarget: a });
// Still up, with no fresh delay.
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
});
it("hides when its control is removed from the page", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<button title="Going away">x</button>
</>
);
hover(screen.getByRole("button", { name: "x" }));
await settle();
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
rerender(
<>
<GlobalTooltip />
</>
);
await settle();
expect(screen.queryByTestId("global-tooltip")).toBeNull();
});
it("keeps a title React changed while the bubble was up, instead of writing the stale one back", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<button title="Pull 2 commits">pull</button>
</>
);
const button = screen.getByRole("button", { name: "pull" });
hover(button);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
// A poll flips the hint while the pointer rests on the control.
rerender(
<>
<GlobalTooltip />
<button title="Up to date">pull</button>
</>
);
await settle();
// The bubble must not keep advertising the old text.
expect(screen.queryByText("Pull 2 commits")).toBeNull();
unhover(button);
expect(button).toHaveAttribute("title", "Up to date");
});
it("leaves no title behind when React removed it while the bubble was up", async () => {
const { rerender } = render(
<>
<GlobalTooltip />
<button title="Pull 2 commits">pull</button>
</>
);
const button = screen.getByRole("button", { name: "pull" });
hover(button);
await settle();
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
// React "removes" an attribute that is already parked: no MutationRecord.
rerender(
<>
<GlobalTooltip />
<button>pull</button>
</>
);
await settle();
unhover(button);
expect(button).not.toHaveAttribute("title");
expect(button).not.toHaveAttribute("data-tip");
expect(screen.queryByTestId("global-tooltip")).toBeNull();
});
it("does not open on the focus a mouse click gives, only on keyboard focus", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
setup(<button title="Second control">second</button>);
// A click is pointer focus: :focus-visible does not match.
const button = screen.getByRole("button", { name: "refresh" });
await user.click(button);
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.queryByTestId("global-tooltip")).toBeNull();
tabTo(screen.getByRole("button", { name: "second" }));
await act(async () => {
vi.advanceTimersByTime(0);
});
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Second control");
});
it("stays up when an unrelated panel scrolls, and hides when the anchor's own scroller moves", async () => {
render(
<>
<GlobalTooltip />
<div data-testid="scroller">
<button title="Inside the scroller">inside</button>
</div>
<div data-testid="elsewhere" />
</>
);
const button = screen.getByRole("button", { name: "inside" });
hover(button);
await settle();
fireEvent.scroll(screen.getByTestId("elsewhere"));
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Inside the scroller");
fireEvent.scroll(screen.getByTestId("scroller"));
expect(screen.queryByTestId("global-tooltip")).toBeNull();
expect(button).toHaveAttribute("title", "Inside the scroller");
});
it("hides when the document itself scrolls", async () => {
setup();
hover(screen.getByRole("button", { name: "refresh" }));
await settle();
fireEvent.scroll(document);
expect(screen.queryByTestId("global-tooltip")).toBeNull();
});
});
+4 -18
View File
@@ -27,18 +27,6 @@ import { api } from "../../lib/api";
import type { AiRouter, CwdSuggestion } from "../../lib/api";
import type { Lane, LaneTool, LaneWorktree } from "../../lib/types";
/** Mirrors server/lib/worktree.js `slugify`, for the branch-name PREVIEW only.
* Nothing is sent from it: an empty branch field means "let the server name
* it", which is how `LANE_BRANCH_PREFIX` keeps working. */
function slugPreview(title: string) {
return title
.toLowerCase()
.normalize("NFKD")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
}
/** Polls the lane until the background `git worktree add` finishes (status
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
* lane record, or `null` on timeout. */
@@ -589,12 +577,10 @@ export function AddLaneModal({
id="add-lane-branch"
value={branch}
onChange={(e) => setBranch(e.target.value)}
// The placeholder is what the SERVER will name the branch
// if this is left empty, so the default is visible without
// the form having to duplicate LANE_BRANCH_PREFIX.
placeholder={
title.trim() ? `feat/${slugPreview(title)}` : t("addLaneBranchPlaceholder")
}
// Neutral on purpose: an empty field lets the SERVER name
// the branch, under a LANE_BRANCH_PREFIX this form cannot
// see, so any `feat/…` preview here could be wrong.
placeholder={t("addLaneBranchPlaceholder")}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHintAuto")}</p>
+8 -1
View File
@@ -55,6 +55,13 @@ function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => void] {
// Bumped by the git action bar: a commit or a pull changes every fact on
// this row, and waiting out the 30s poll makes the button look inert.
const [nonce, setNonce] = useState(0);
// A lane switch drops the previous lane's facts during render, so nothing
// (the git bar's "current branch" included) shows them until the new read.
const [factsLane, setFactsLane] = useState(laneId);
if (factsLane !== laneId) {
setFactsLane(laneId);
setFacts(null);
}
useEffect(() => {
let alive = true;
@@ -854,7 +861,7 @@ export default function LaneCard({
<button
type="button"
onClick={() => onSelectLane?.(w.id)}
className="truncate text-left text-blue-400 hover:underline"
className="truncate text-left text-blue-700 dark:text-blue-400 hover:underline"
title={w.cwd}
>
#{w.id} {w.title || w.cwd} · {w.status}
+212 -92
View File
@@ -59,6 +59,8 @@ const OVERVIEW_REFRESH_MS = 15_000;
const CONTEXT_STEPS: readonly [number, ...number[]] = [3, 12, 40, 5000];
const LAST_CONTEXT = CONTEXT_STEPS[CONTEXT_STEPS.length - 1]!;
/** Tree reads "expand all" keeps in flight at once. */
const TREE_CONCURRENCY = 4;
type Mode = "overview" | "review" | "file" | "history" | "browse";
@@ -191,6 +193,94 @@ function IconButton({
);
}
/** One overview row. At module scope on purpose: declared inside the panel it
* was a new component type every render, so each poll and each keystroke in
* the commit box remounted every row (focus dropped to <body>). */
function ChangeRow({
f,
range,
busy,
armed,
onOpen,
onAct,
onArm,
}: {
f: LaneChangeFile;
range: LaneChangeRange;
busy: boolean;
/** This row's discard has been clicked once and awaits the second click. */
armed: boolean;
onOpen: (path: string) => void;
onAct: (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => void;
onArm: (path: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
return (
<li className="group flex items-baseline gap-1">
<button
type="button"
onClick={() => onOpen(f.path)}
className="flex min-w-0 flex-1 items-baseline gap-1.5 rounded px-1 py-0.5 text-left hover:bg-surface-2"
>
<span
className={`w-3 shrink-0 font-mono text-[11px] font-semibold ${statusClass(
f.status,
f.untracked
)}`}
>
{f.untracked ? "A" : f.status.trim()[0] || "M"}
</span>
<span
className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary"
title={f.path}
>
{f.path}
</span>
{f.conflicted && (
<span className="shrink-0 text-[10px] text-status-danger">{t("changes.conflicted")}</span>
)}
<Delta added={f.added} deleted={f.deleted} binary={f.binary} />
</button>
{range === "work" && (
<span className="flex shrink-0 items-center gap-0.5">
{f.staged ? (
<IconButton
label={t("changes.unstage")}
disabled={busy}
onClick={() => onAct("unstage", f.path)}
>
<Minus className="h-3 w-3" />
</IconButton>
) : null}
{f.unstaged || f.untracked ? (
<IconButton
label={t("changes.stage")}
disabled={busy}
onClick={() => onAct("stage", f.path)}
>
<Plus className="h-3 w-3" />
</IconButton>
) : null}
<IconButton
label={armed ? t("changes.discardConfirm") : t("changes.discard")}
disabled={busy}
danger
onClick={() => {
// Two clicks, always: the first arms the row, the second is the
// only one that reaches the server (which itself refuses the
// call without `confirm: true`).
if (armed) onAct("discard", f.path, true);
else onArm(f.path);
}}
>
<Trash2 className={`h-3 w-3 ${armed ? "text-status-danger" : ""}`} />
</IconButton>
</span>
)}
</li>
);
}
export function LaneChangesPanel({ laneId }: { laneId: number }) {
const { t } = useTranslation(["lanes"]);
const [range, setRange] = useState<LaneChangeRange>("work");
@@ -227,7 +317,21 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
const [message, setMessage] = useState("");
const [error, setError] = useState<string | null>(null);
// Every diff read gets a ticket; only the newest may land. A lane switch (or
// a mode/range change) re-issues the read, and without this a slow reply for
// the previous lane painted its files — and its discard buttons — over this one.
const loadSeq = useRef(0);
// The same guard for the side reads (history, tree, whole file), which only
// race across a lane switch: a reply for a lane no longer shown is dropped.
const laneNow = useRef(laneId);
laneNow.current = laneId;
const forLane =
<T,>(id: number, fn: (v: T) => void) =>
(v: T) => {
if (laneNow.current === id) fn(v);
};
const load = useCallback(async () => {
const seq = ++loadSeq.current;
setLoading(true);
try {
const result = await api.lanes.changes(laneId, {
@@ -236,12 +340,30 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
...(mode === "file" && openFile ? { path: openFile, context } : {}),
...(mode === "review" ? { full: true } : {}),
});
if (seq !== loadSeq.current) return;
setData(result);
setError(null);
} catch (err) {
if (seq !== loadSeq.current) return;
// A tracked file opened from Files that has nothing to diff: a normal
// state, not a fault. The whole-file view is the answer; no banner.
if (mode === "file" && (err as { code?: string })?.code === "ENOFILECHANGE") {
setData({
available: true,
range,
base: null,
baseRef: null,
sha,
context,
files: [],
truncated: false,
});
setError(null);
return;
}
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
if (seq === loadSeq.current) setLoading(false);
}
}, [laneId, range, sha, mode, openFile, context]);
@@ -260,7 +382,7 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
if (mode !== "history") return;
void api.lanes
.gitLog(laneId, { limit: 50 })
.then((r) => setCommits(r.commits))
.then(forLane(laneId, (r) => setCommits(r.commits)))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, laneId]);
@@ -268,7 +390,7 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
if (mode !== "browse" || query.trim() || "" in treeChildren) return;
void api.lanes
.gitTree(laneId, "")
.then((r) => setTreeChildren((prev) => ({ ...prev, "": r.entries })))
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, "": r.entries }))))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
// `treeChildren` is the cache this fills — reading it as a dep would loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -284,36 +406,56 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
if (path in treeChildren) return;
void api.lanes
.gitTree(laneId, path)
.then((r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries })))
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
};
const [treeBusy, setTreeBusy] = useState(false);
// Which lane `treeChildren` was filled for; the reset effect moves it.
const treeLane = useRef(laneId);
// Fetches whatever level isn't cached yet, depth-first, so "expand all"
// works even on a tree nothing has been opened in.
const expandAllDirs = async () => {
// A walk is many reads long; a lane switch mid-walk must not land lane A's
// tree in lane B, nor start from a cache the switch has not cleared yet.
const lane = laneId;
setTreeBusy(true);
const cache = { ...treeChildren };
const cache = treeLane.current === lane ? { ...treeChildren } : {};
const toExpand = new Set(expandedDirs);
const walk = async (path: string) => {
// Level by level, at most TREE_CONCURRENCY reads in flight: one request
// per folder all at once is thousands on a monorepo.
const visit = async (path: string, next: string[]) => {
let entries = cache[path];
if (!entries) {
entries = (await api.lanes.gitTree(laneId, path)).entries;
entries = (await api.lanes.gitTree(lane, path)).entries;
cache[path] = entries;
}
const dirs = entries.filter((e) => e.dir);
for (const d of dirs) toExpand.add(d.path);
await Promise.all(dirs.map((d) => walk(d.path)));
for (const e of entries) {
if (!e.dir) continue;
toExpand.add(e.path);
next.push(e.path);
}
};
try {
await walk("");
for (let level = [""]; level.length && laneNow.current === lane; ) {
const next: string[] = [];
// Workers share one iterator, so each path is taken exactly once.
const it = level.values();
await Promise.all(
Array.from({ length: TREE_CONCURRENCY }, async () => {
for (const path of it) await visit(path, next);
})
);
level = next;
}
if (laneNow.current !== lane) return;
setTreeChildren(cache);
setExpandedDirs(toExpand);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
setTreeBusy(false);
if (laneNow.current === lane) setTreeBusy(false);
}
};
@@ -323,13 +465,19 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
setHits(null);
return;
}
// `live` drops an answer that a newer query, a mode change or a lane
// switch has overtaken — a slow "main" must not overwrite "mainx".
let live = true;
const timer = window.setTimeout(() => {
void api.lanes
.gitLs(laneId, q)
.then((r) => setHits(r.paths))
.catch(() => setHits([]));
.then((r) => live && setHits(r.paths))
.catch(() => live && setHits([]));
}, 150);
return () => window.clearTimeout(timer);
return () => {
live = false;
window.clearTimeout(timer);
};
}, [mode, laneId, query]);
// The whole-file read is a different endpoint from the diff, so it is its
@@ -338,8 +486,10 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
if (mode !== "file" || !whole || !openFile) return;
void api.lanes
.gitFile(laneId, openFile, range === "commit" && sha ? sha : "work")
.then((r) =>
setText(r.available ? { path: r.path, lines: r.lines, truncated: r.truncated } : null)
.then(
forLane(laneId, (r) =>
setText(r.available ? { path: r.path, lines: r.lines, truncated: r.truncated } : null)
)
)
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, [mode, whole, openFile, laneId, range, sha]);
@@ -352,9 +502,19 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
setSha(null);
setRange("work");
setTreeChildren({});
treeLane.current = laneId;
setExpandedDirs(new Set());
setTreeBusy(false);
setBusy(false);
setError(null);
setCommits(null);
setData(null);
setCollapsed(new Set());
// An armed discard names a PATH, not a file in a lane — carried over, one
// click would discard the same path in the lane just switched to.
setConfirmDiscard(null);
// Same for a half-typed commit message: it describes the OTHER lane's work.
setMessage("");
returnTo.current = null;
pendingScroll.current = null;
}, [laneId]);
@@ -384,34 +544,41 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
setMode("file");
};
// An action that resolves after a lane switch belongs to the lane left: its
// `load` is the pre-switch one and would take a fresh ticket and paint the
// old lane's files here, so everything after the await is dropped.
const act = async (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => {
const lane = laneId;
setBusy(true);
setError(null);
try {
await api.lanes.gitAction(laneId, action, { paths: [path], ...(confirm ? { confirm } : {}) });
await api.lanes.gitAction(lane, action, { paths: [path], ...(confirm ? { confirm } : {}) });
if (laneNow.current !== lane) return;
setConfirmDiscard(null);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
if (laneNow.current === lane) setBusy(false);
}
};
const commitStaged = async () => {
const lane = laneId;
setBusy(true);
setError(null);
try {
// `only: "staged"` — the whole point of staging file by file is that the
// commit takes the index as it stands, with no `add -A` sweeping the
// rest of the tree in behind it.
await api.lanes.gitAction(laneId, "commit", { message: message.trim(), only: "staged" });
await api.lanes.gitAction(lane, "commit", { message: message.trim(), only: "staged" });
if (laneNow.current !== lane) return;
setMessage("");
await load();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
if (laneNow.current === lane) setBusy(false);
}
};
@@ -427,72 +594,13 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
const stagedCount = range === "work" ? files.filter((f) => f.staged).length : 0;
const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined;
const Row = ({ f }: { f: LaneChangeFile }) => (
<li className="group flex items-baseline gap-1">
<button
type="button"
onClick={() => show(f.path)}
className="flex min-w-0 flex-1 items-baseline gap-1.5 rounded px-1 py-0.5 text-left hover:bg-surface-2"
>
<span
className={`w-3 shrink-0 font-mono text-[11px] font-semibold ${statusClass(
f.status,
f.untracked
)}`}
>
{f.untracked ? "A" : f.status.trim()[0] || "M"}
</span>
<span
className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary"
title={f.path}
>
{f.path}
</span>
{f.conflicted && (
<span className="shrink-0 text-[10px] text-status-danger">{t("changes.conflicted")}</span>
)}
<Delta added={f.added} deleted={f.deleted} binary={f.binary} />
</button>
{range === "work" && (
<span className="flex shrink-0 items-center gap-0.5">
{f.staged ? (
<IconButton
label={t("changes.unstage")}
disabled={busy}
onClick={() => void act("unstage", f.path)}
>
<Minus className="h-3 w-3" />
</IconButton>
) : null}
{f.unstaged || f.untracked ? (
<IconButton
label={t("changes.stage")}
disabled={busy}
onClick={() => void act("stage", f.path)}
>
<Plus className="h-3 w-3" />
</IconButton>
) : null}
<IconButton
label={confirmDiscard === f.path ? t("changes.discardConfirm") : t("changes.discard")}
disabled={busy}
danger
onClick={() => {
// Two clicks, always: the first arms the row, the second is the
// only one that reaches the server (which itself refuses the
// call without `confirm: true`).
if (confirmDiscard === f.path) void act("discard", f.path, true);
else setConfirmDiscard(f.path);
}}
>
<Trash2
className={`h-3 w-3 ${confirmDiscard === f.path ? "text-status-danger" : ""}`}
/>
</IconButton>
</span>
)}
</li>
);
// The armed path left the list (committed, reverted, lane switched): disarm,
// so a later reappearance starts from the first click again.
useEffect(() => {
if (confirmDiscard && data && !files.some((f) => f.path === confirmDiscard)) {
setConfirmDiscard(null);
}
}, [data, files, confirmDiscard]);
return (
<div data-testid="lane-changes" className="flex min-h-0 flex-1 flex-col">
@@ -770,13 +878,22 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
) : !data.available ? (
<p className="text-[11px] text-fg-muted">{t("changes.unavailable")}</p>
) : files.length === 0 ? (
) : files.length === 0 && mode !== "file" ? (
<p className="text-[11px] text-fg-muted">{t(`changes.empty.${range}`)}</p>
) : mode === "overview" ? (
<>
<ul data-testid="lane-changes-overview" className="space-y-0.5">
{files.map((f) => (
<Row key={f.path} f={f} />
<ChangeRow
key={f.path}
f={f}
range={range}
busy={busy}
armed={confirmDiscard === f.path}
onOpen={show}
onAct={(...a) => void act(...a)}
onArm={setConfirmDiscard}
/>
))}
</ul>
{stagedCount > 0 && (
@@ -849,6 +966,9 @@ export function LaneChangesPanel({ laneId }: { laneId: number }) {
tabIndex={0}
onClick={() => toggleCollapsed(f.path)}
onKeyDown={(e) => {
// Only the header's own keys: Enter on the path button
// inside bubbles here and must open, not fold.
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleCollapsed(f.path);
+378 -69
View File
@@ -13,10 +13,15 @@
* - Line pairing for split view happens here, on the rows the server already
* numbered. The server stays the single source of what changed; this file
* only decides how it looks.
* Past `WINDOW_THRESHOLD` rows a view renders only the rows on screen (plus an
* overscan) between two spacers. Rows never wrap — the box scrolls sideways —
* so every windowed row is pinned to `ROW_PX` and the range is pure arithmetic
* (`visibleRange`); the content's min-width comes from the longest line, so
* the horizontal extent does not jump as rows come and go.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useRef } from "react";
import { useMemo, useRef, useState, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
@@ -32,6 +37,100 @@ function boxHeight(fill: boolean) {
return fill ? "min-h-0 flex-1" : "max-h-[60vh]";
}
/** Rows above which a view windows; at or below it renders exactly as before. */
export const WINDOW_THRESHOLD = 500;
/** A windowed row's pinned height — the rows' own 11px × 1.45 line box, rounded. */
export const ROW_PX = 16;
const OVERSCAN = 20;
const FIXED: CSSProperties = { height: ROW_PX };
/**
* The `[start, end)` item range to render for a scroll position. `scrollTop`
* is clamped to the content first, so a stale offset (content that shrank, a
* layout switch) still lands on real rows instead of an empty window.
*/
export function visibleRange(
scrollTop: number,
viewport: number,
rowHeight: number,
count: number,
overscan: number
) {
const top = Math.min(Math.max(0, scrollTop), Math.max(0, count * rowHeight - viewport));
const start = Math.max(0, Math.floor(top / rowHeight) - overscan);
const end = Math.min(count, Math.ceil((top + viewport) / rowHeight) + overscan);
return { start: Math.min(start, end), end };
}
/**
* Windowing state for one scroller of `count` items. Off (the whole range)
* at or below the threshold.
*/
function useWindow(count: number) {
const [top, setTop] = useState(0);
const on = count > WINDOW_THRESHOLD;
// The offset is tracked even while windowing is off, so a diff that GROWS
// past the threshold (expanded context) windows where the reader is, not at
// row 0. A ref, so an unwindowed diff does not re-render on every scroll.
const lastTop = useRef(0);
// count 0 = no scroller here (split layout): the browser drops its offset,
// so coming back to unified must start from 0, not the pre-split row.
if (count === 0) lastTop.current = 0;
const [wasOn, setWasOn] = useState(on);
if (wasOn !== on) {
setWasOn(on);
setTop(on ? lastTop.current : 0);
}
// ponytail: the viewport is taken as the window height — never smaller than
// a box that lives inside the window, so it only over-renders a few dozen
// rows, and it needs no ResizeObserver to follow a panel resize.
const range = on
? visibleRange(top, window.innerHeight, ROW_PX, count, OVERSCAN)
: { start: 0, end: count };
const onScroll = (el: HTMLElement) => {
lastTop.current = el.scrollTop;
if (on) setTop(el.scrollTop);
};
return { on, range, onScroll };
}
/** Columns a line occupies under `whitespace-pre` (a tab counted at its widest). */
function longest(texts: Iterable<string>) {
let max = 0;
for (const t of texts) {
let n = t.length;
for (let i = t.indexOf("\t"); i !== -1; i = t.indexOf("\t", i + 1)) n += 7;
if (n > max) max = n;
}
return max;
}
/** Stands in for `rows` off-screen rows. */
function Spacer({ rows }: { rows: number }) {
return rows > 0 ? <div data-diff-spacer aria-hidden style={{ height: rows * ROW_PX }} /> : null;
}
/** A windowed list: spacer, the rendered slice, spacer, at a stable width. */
function Windowed({
count,
range,
minWidth,
children,
}: {
count: number;
range: { start: number; end: number };
minWidth: string;
children: React.ReactNode;
}) {
return (
<div data-diff-content style={{ minWidth }}>
<Spacer rows={range.start} />
{children}
<Spacer rows={count - range.end} />
</div>
);
}
/** Language tag for a path, for the tokenizer. Unknown ones render plain. */
function langFor(filePath: string) {
const ext = filePath.split(".").pop() || "";
@@ -86,26 +185,51 @@ function emphasis(
};
}
/** Pair each run of removed lines with the run of added lines following it. */
function pairs(
lines: LaneDiffLine[]
): Array<{ left: LaneDiffLine | null; right: LaneDiffLine | null }> {
const out: Array<{ left: LaneDiffLine | null; right: LaneDiffLine | null }> = [];
type PairRow = { left: LaneDiffLine | null; right: LaneDiffLine | null; emph: EmphPair | null };
type EmphPair = { del: [number, number]; add: [number, number] };
/** A hunk's split-view rows plus each paired line's emphasis, keyed by its
* index in `hunk.lines` (what the unified view renders by). */
type Analysed = { rows: PairRow[]; emph: Map<number, [number, number]> };
// Keyed by the hunk object: a hunk is immutable once fetched, so both split
// columns and every re-render of the same data share one pass. A refetch
// brings new objects and the old entries go with them.
const analysed = new WeakMap<LaneDiffHunk, Analysed>();
/** Pair each run of removed lines with the run of added lines following it,
* carrying indices so emphasis lands without searching the hunk again. */
export function analyseHunk(hunk: LaneDiffHunk): Analysed {
const hit = analysed.get(hunk);
if (hit) return hit;
const lines = hunk.lines;
const rows: PairRow[] = [];
const emph = new Map<number, [number, number]>();
for (let i = 0; i < lines.length; ) {
const line = lines[i]!;
if (line.type === "ctx") {
out.push({ left: line, right: line });
rows.push({ left: line, right: line, emph: null });
i += 1;
continue;
}
const dels: LaneDiffLine[] = [];
const adds: LaneDiffLine[] = [];
while (i < lines.length && lines[i]!.type === "del") dels.push(lines[i++]!);
while (i < lines.length && lines[i]!.type === "add") adds.push(lines[i++]!);
for (let k = 0; k < Math.max(dels.length, adds.length); k += 1) {
out.push({ left: dels[k] ?? null, right: adds[k] ?? null });
const delStart = i;
while (i < lines.length && lines[i]!.type === "del") i += 1;
const addStart = i;
while (i < lines.length && lines[i]!.type === "add") i += 1;
const dels = addStart - delStart;
const adds = i - addStart;
for (let k = 0; k < Math.max(dels, adds); k += 1) {
const left = k < dels ? lines[delStart + k]! : null;
const right = k < adds ? lines[addStart + k]! : null;
const e = left && right ? emphasis(left.text, right.text) : null;
if (e) {
emph.set(delStart + k, e.del);
emph.set(addStart + k, e.add);
}
rows.push({ left, right, emph: e });
}
}
const out = { rows, emph };
analysed.set(hunk, out);
return out;
}
@@ -115,9 +239,26 @@ function Gutter({ n }: { n: number | null }) {
);
}
/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row. */
function Row({ tint, children }: { tint: string; children: React.ReactNode }) {
return <div className={`flex w-max min-w-full ${tint}`}>{children}</div>;
/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row.
* `style` pins a windowed row's height; unwindowed rows carry none. */
function Row({
tint,
style,
children,
}: {
tint: string;
style?: CSSProperties;
children: React.ReactNode;
}) {
return (
<div
className={`flex w-max min-w-full ${tint}`}
style={style}
data-diff-item={style ? "" : undefined}
>
{children}
</div>
);
}
/** A /20 fill plus a 3px status bar at the row's left edge, so +/- rows still
@@ -135,44 +276,110 @@ function markerClass(type: LaneDiffLine["type"]) {
return "text-fg-muted";
}
function HunkHeader({ hunk }: { hunk: LaneDiffHunk }) {
function HunkHeader({ hunk, fixed }: { hunk: LaneDiffHunk; fixed?: boolean }) {
const at = hunk.header.split("@@")[1];
// Windowed, the header is one ROW_PX row like the lines around it.
return (
<div className="bg-surface-2 px-2 py-0.5 text-[10px] text-fg-muted">
<div
className={`bg-surface-2 px-2 text-[10px] text-fg-muted ${
fixed ? "overflow-hidden whitespace-nowrap leading-4" : "py-0.5"
}`}
style={fixed ? FIXED : undefined}
data-diff-item={fixed ? "" : undefined}
>
{at ? `@@${at}@@` : hunk.header}
{hunk.context ? ` ${hunk.context}` : ""}
</div>
);
}
function UnifiedLine({
line,
lang,
emph,
style,
}: {
line: LaneDiffLine;
lang: string;
emph?: [number, number];
style?: CSSProperties;
}) {
return (
<Row tint={rowTint(line.type)} style={style}>
<Gutter n={line.oldLine} />
<Gutter n={line.newLine} />
<span className="whitespace-pre px-2">
<span className={`select-none ${markerClass(line.type)}`}>
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
</span>
<Code text={line.text} lang={lang} emph={emph} />
</span>
</Row>
);
}
function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) {
const emph = new Map<number, [number, number]>();
for (const { left, right } of pairs(hunk.lines)) {
if (!left || !right || left.type !== "del" || right.type !== "add") continue;
const e = emphasis(left.text, right.text);
if (!e) continue;
emph.set(hunk.lines.indexOf(left), e.del);
emph.set(hunk.lines.indexOf(right), e.add);
}
const { emph } = analyseHunk(hunk);
return (
<div>
<HunkHeader hunk={hunk} />
{hunk.lines.map((line, i) => (
<Row key={i} tint={rowTint(line.type)}>
<Gutter n={line.oldLine} />
<Gutter n={line.newLine} />
<span className="whitespace-pre px-2">
<span className={`select-none ${markerClass(line.type)}`}>
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
</span>
<Code text={line.text} lang={lang} emph={emph.get(i)} />
</span>
</Row>
<UnifiedLine key={i} line={line} lang={lang} emph={emph.get(i)} />
))}
</div>
);
}
/** One windowable item: a hunk's header (`i < 0`) or its i-th row — a line in
* unified, a paired row in split. */
type Item = { hunk: LaneDiffHunk; i: number };
function flatten(hunks: LaneDiffHunk[], layout: DiffLayout): Item[] {
const out: Item[] = [];
for (const hunk of hunks) {
out.push({ hunk, i: -1 });
const n = layout === "split" ? analyseHunk(hunk).rows.length : hunk.lines.length;
for (let i = 0; i < n; i += 1) out.push({ hunk, i });
}
return out;
}
function longestInHunks(hunks: LaneDiffHunk[]) {
return longest(
(function* () {
for (const h of hunks) for (const l of h.lines) yield l.text;
})()
);
}
function SplitLine({
row,
lang,
side,
style,
}: {
row: PairRow;
lang: string;
side: "left" | "right";
style?: CSSProperties;
}) {
const line = side === "left" ? row.left : row.right;
return (
<Row tint={rowTint(line?.type ?? null)} style={style}>
<Gutter n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} />
<span className="whitespace-pre px-1">
{line ? (
<Code
text={line.text}
lang={lang}
emph={side === "left" ? row.emph?.del : row.emph?.add}
/>
) : null}
</span>
</Row>
);
}
/** One side of the split view: a hunk header, then that side's rows. */
function SplitColumn({
hunks,
@@ -188,42 +395,103 @@ function SplitColumn({
{hunks.map((hunk, h) => (
<div key={`${hunk.header}:${h}`}>
<HunkHeader hunk={hunk} />
{pairs(hunk.lines).map(({ left, right }, i) => {
const e =
left && right && left.type === "del" && right.type === "add"
? emphasis(left.text, right.text)
: null;
const line = side === "left" ? left : right;
return (
<Row key={i} tint={rowTint(line?.type ?? null)}>
<Gutter n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} />
<span className="whitespace-pre px-1">
{line ? (
<Code text={line.text} lang={lang} emph={side === "left" ? e?.del : e?.add} />
) : null}
</span>
</Row>
);
})}
{analyseHunk(hunk).rows.map((row, i) => (
<SplitLine key={i} row={row} lang={lang} side={side} />
))}
</div>
))}
</>
);
}
/** Both sides, each scrolling horizontally on its own but kept in step. */
/** A windowed split column: the shared range of items, one side of each. */
function SplitSlice({
items,
range,
lang,
side,
}: {
items: Item[];
range: { start: number; end: number };
lang: string;
side: "left" | "right";
}) {
return (
<>
{items
.slice(range.start, range.end)
.map(({ hunk, i }, k) =>
i < 0 ? (
<HunkHeader key={range.start + k} hunk={hunk} fixed />
) : (
<SplitLine
key={range.start + k}
row={analyseHunk(hunk).rows[i]!}
lang={lang}
side={side}
style={FIXED}
/>
)
)}
</>
);
}
/** `v` held inside a scroller's own extent. */
export function clampScroll(v: number, scrollSize: number, clientSize: number) {
return Math.min(v, Math.max(0, scrollSize - clientSize));
}
type Axis =
| ["scrollLeft", "scrollWidth", "clientWidth"]
| ["scrollTop", "scrollHeight", "clientHeight"];
const AXES: Axis[] = [
["scrollLeft", "scrollWidth", "clientWidth"],
["scrollTop", "scrollHeight", "clientHeight"],
];
/** Mirror `src`'s offset onto `dst`, clamped to what `dst` can show. */
function follow(src: HTMLElement, dst: HTMLElement) {
for (const [pos, size, client] of AXES) {
// `src` pinned at its own end while `dst` sits past it: `src` is the
// narrower column echoing a clamp we just caused. Pulling `dst` back to
// it would snap the wide column home every time it scrolls past the
// narrow one's longest line.
if (src[pos] >= clampScroll(Infinity, src[size], src[client]) && dst[pos] > src[pos]) continue;
const target = clampScroll(src[pos], dst[size], dst[client]);
if (dst[pos] !== target) dst[pos] = target;
}
}
/** Both sides, each its own scroller but kept in step on both axes. */
function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) {
const left = useRef<HTMLDivElement>(null);
const right = useRef<HTMLDivElement>(null);
const items = useMemo(() => flatten(hunks, "split"), [hunks]);
const minWidth = useMemo(() => `calc(${longestInHunks(hunks)}ch + 3rem)`, [hunks]);
const win = useWindow(items.length);
// Mirroring fires the other pane's own scroll handler, which mirrors back —
// the equality check is what ends that, so no re-entry flag is needed.
const sync = useCallback((from: "left" | "right") => {
// the equality check (and `follow`'s edge rule) is what ends that, so no
// re-entry flag is needed. Both axes: the columns hold paired rows, so a
// vertical drift puts a deletion beside some unrelated line. Both columns
// window off the same offset, so they always hold the same row range.
const sync = (from: "left" | "right") => {
const src = from === "left" ? left.current : right.current;
const dst = from === "left" ? right.current : left.current;
if (!src || !dst || dst.scrollLeft === src.scrollLeft) return;
dst.scrollLeft = src.scrollLeft;
}, []);
if (!src || !dst) return;
follow(src, dst);
win.onScroll(src);
};
const column = (side: "left" | "right") =>
win.on ? (
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
<SplitSlice items={items} range={win.range} lang={lang} side={side} />
</Windowed>
) : (
<SplitColumn hunks={hunks} lang={lang} side={side} />
);
return (
<div className={`flex ${boxHeight(fill)}`}>
@@ -233,7 +501,7 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
onScroll={() => sync("left")}
className="w-1/2 min-w-0 overflow-auto"
>
<SplitColumn hunks={hunks} lang={lang} side="left" />
{column("left")}
</div>
<div
ref={right}
@@ -241,7 +509,7 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
onScroll={() => sync("right")}
className="w-1/2 min-w-0 overflow-auto border-l border-border-light"
>
<SplitColumn hunks={hunks} lang={lang} side="right" />
{column("right")}
</div>
</div>
);
@@ -260,6 +528,15 @@ export function LaneDiffView({
}) {
const { t } = useTranslation(["lanes"]);
const lang = langFor(file.path);
const hunks = file.hunks;
const items = useMemo(() => (hunks ? flatten(hunks, "unified") : []), [hunks]);
const minWidth = useMemo(
// two gutters + the row's px-2, plus the marker column
() => `calc(${hunks ? longestInHunks(hunks) + 1 : 0}ch + 6rem)`,
[hunks]
);
// Unified only; split windows inside its own two scrollers.
const win = useWindow(layout === "unified" ? items.length : 0);
if (file.binary)
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
@@ -270,12 +547,31 @@ export function LaneDiffView({
<div
data-testid="lane-diff-view"
data-layout={layout}
onScroll={(e) => win.onScroll(e.currentTarget)}
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
layout === "split" ? boxHeight(fill) : `overflow-auto ${boxHeight(fill)}`
}`}
>
{layout === "split" ? (
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
) : win.on ? (
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
{items
.slice(win.range.start, win.range.end)
.map(({ hunk, i }, k) =>
i < 0 ? (
<HunkHeader key={win.range.start + k} hunk={hunk} fixed />
) : (
<UnifiedLine
key={win.range.start + k}
line={hunk.lines[i]!}
lang={lang}
emph={analyseHunk(hunk).emph.get(i)}
style={FIXED}
/>
)
)}
</Windowed>
) : (
file.hunks.map((hunk, i) => (
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
@@ -303,19 +599,32 @@ export function LaneFileView({
}) {
const { t } = useTranslation(["lanes"]);
const lang = langFor(path);
const win = useWindow(lines.length);
// gutter + the row's px-2
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
const row = (text: string, i: number, style?: CSSProperties) => (
<Row key={i} tint="" style={style}>
<Gutter n={i + 1} />
<span className="whitespace-pre px-2">
<Code text={text} lang={lang} />
</span>
</Row>
);
return (
<div
data-testid="lane-file-view"
onScroll={(e) => win.onScroll(e.currentTarget)}
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill)}`}
>
{lines.map((text, i) => (
<Row key={i} tint="">
<Gutter n={i + 1} />
<span className="whitespace-pre px-2">
<Code text={text} lang={lang} />
</span>
</Row>
))}
{win.on ? (
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
{lines
.slice(win.range.start, win.range.end)
.map((text, k) => row(text, win.range.start + k, FIXED))}
</Windowed>
) : (
lines.map((text, i) => row(text, i))
)}
{truncated && (
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
)}
+73 -16
View File
@@ -15,7 +15,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowDown,
@@ -86,6 +86,29 @@ export function LaneGitBar({
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
const [picked, setPicked] = useState("");
const [newBranch, setNewBranch] = useState("");
// LaneCard is not keyed by lane, so this bar outlives a lane switch. All of
// the state above describes ONE lane — a message typed for lane A, Entered
// after switching, would commit B's whole tree under it — so it is reset
// during render the moment the id changes, before anything can act on it.
const [shownLane, setShownLane] = useState(laneId);
// One action at a time, checked synchronously: `busy` is state, and a second
// Enter can land before the render that disables anything.
const inFlight = useRef(false);
if (shownLane !== laneId) {
setShownLane(laneId);
setBusy(null);
setError(null);
setCommitting(false);
setMessage("");
setSwitching(false);
setBranches(null);
setWorktrees([]);
setPicked("");
setNewBranch("");
inFlight.current = false;
}
const laneNow = useRef(laneId);
laneNow.current = laneId;
if (!git.available) return null;
const dirty = git.dirty + git.untracked;
@@ -94,17 +117,27 @@ export function LaneGitBar({
action: Action,
body?: { message?: string; only?: "staged"; branch?: string }
) => {
if (inFlight.current) return;
inFlight.current = true;
const lane = laneId;
setBusy(action);
setError(null);
try {
await api.lanes.gitAction(laneId, action, body || {});
setCommitting(false);
setMessage("");
await api.lanes.gitAction(lane, action, body || {});
// Landed after a lane switch: every write below belongs to the lane
// no longer shown.
if (laneNow.current !== lane) return;
// Only a landed commit consumes the message; a fetch or pull mid-typing
// must leave it where it was.
if (action === "commit") {
setCommitting(false);
setMessage("");
}
// The branch list this bar is showing is stale the moment a branch is
// created, deleted or switched to, so it is re-read rather than patched.
if (action === "switch") setSwitching(false);
else if (action.startsWith("branch-")) {
setNewBranch("");
if (action === "switch" || action.startsWith("branch-")) {
if (action === "switch") setSwitching(false);
else setNewBranch("");
setPicked("");
setBranches(null);
await refreshBranches();
@@ -113,19 +146,32 @@ export function LaneGitBar({
} catch (err) {
// The server appends git's own stderr to the message, so this is the
// rejection text verbatim — hence the <pre> below rather than a line.
setError(err instanceof Error ? err.message : String(err));
// ENOREMOTE is the one refusal with no git text to show: the server's
// sentence is English and says less than a translated one.
if (laneNow.current === lane)
setError(
(err as { code?: string })?.code === "ENOREMOTE"
? t("gitBar.errNoRemote")
: err instanceof Error
? err.message
: String(err)
);
} finally {
setBusy(null);
if (laneNow.current === lane) {
inFlight.current = false;
setBusy(null);
}
}
};
const refreshBranches = async () => {
try {
const r = await api.lanes.gitBranches(laneId);
if (laneNow.current !== laneId) return;
setBranches(r.branches);
setWorktrees(r.worktrees || []);
} catch {
setBranches([]);
if (laneNow.current === laneId) setBranches([]);
}
};
@@ -145,7 +191,10 @@ export function LaneGitBar({
<div className="flex flex-wrap items-center gap-1.5">
{/* Ahead/behind first: it is the fact that decides which of the
buttons beside it the user actually wants. */}
{git.upstream ? (
{/* Null counts with an upstream named means git could not compare
the two (the upstream ref is gone): claiming "in sync" there
would be a lie, so it reads as not pushed. */}
{git.upstream && git.ahead != null && git.behind != null ? (
<span
data-testid="lane-git-tracking"
title={git.upstream}
@@ -223,7 +272,7 @@ export function LaneGitBar({
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && message.trim()) void run("commit", { message });
if (e.key === "Enter" && message.trim() && !busy) void run("commit", { message });
if (e.key === "Escape") setCommitting(false);
}}
placeholder={t("gitBar.commitPlaceholder")}
@@ -233,7 +282,7 @@ export function LaneGitBar({
<Btn
label={t("gitBar.commitAll", { count: dirty })}
busy={busy === "commit"}
disabled={!message.trim()}
disabled={!message.trim() || !!busy}
onClick={() => void run("commit", { message })}
/>
{/* The Changes panel's per-file staging only means something if a
@@ -243,7 +292,7 @@ export function LaneGitBar({
label={t("gitBar.commitStaged")}
title={t("gitBar.commitStagedHint")}
busy={busy === "commit"}
disabled={!message.trim()}
disabled={!message.trim() || !!busy}
onClick={() => void run("commit", { message, only: "staged" })}
/>
<button
@@ -289,8 +338,16 @@ export function LaneGitBar({
{onNewLane && (
<Btn
label={t("gitBar.newLane")}
title={t("gitBar.newLaneHint")}
disabled={!picked}
// A branch any worktree holds — this lane's own included — is
// one `addWorktree` refuses with EBRANCHBUSY; say so up front.
title={
picked && (heldBy.has(picked) || picked === git.branch)
? t("gitBar.newLaneHeld", {
path: heldBy.get(picked) ?? t("gitBar.switchCurrent"),
})
: t("gitBar.newLaneHint")
}
disabled={!picked || heldBy.has(picked) || picked === git.branch}
// The sibling lane is created through the normal Add-lane flow,
// prefilled — one provisioning path, not a second one here.
onClick={() => onNewLane(picked)}
@@ -245,10 +245,11 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), "Auto named");
// The placeholder previews the name the server will pick.
// No `feat/…` guess: the prefix is the server's LANE_BRANCH_PREFIX, which
// the form cannot see, so the placeholder only says the field is optional.
expect(screen.getByLabelText("New branch name")).toHaveAttribute(
"placeholder",
"feat/auto-named"
"Automatic — named from the title"
);
await user.click(screen.getByRole("button", { name: "Add lane" }));
@@ -237,6 +237,17 @@ describe("LaneCard git block", () => {
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
});
it("drops the previous lane's facts the moment the lane changes", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
const { rerender } = render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
await screen.findByText(/feat\/rename-metric/);
// The next lane's read never lands: nothing of lane 1's git may linger.
vi.mocked(api.lanes.git).mockReturnValueOnce(new Promise(() => {}));
rerender(<LaneCard lane={makeLane({ id: 2, branch: "lane/other" })} onAction={vi.fn()} />);
expect(screen.queryByText(/feat\/rename-metric/)).toBeNull();
});
it("names the files behind the counts on hover, with each one's line delta", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
@@ -11,7 +11,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LaneChangesPanel } from "../LaneChangesPanel";
import { api } from "../../../lib/api";
@@ -94,12 +94,14 @@ beforeEach(() => {
},
],
});
vi.mocked(api.lanes.gitLs).mockResolvedValue({
available: true,
paths: ["src/main.ts"],
truncated: false,
total: 1,
});
vi.mocked(api.lanes.gitLs)
.mockReset()
.mockResolvedValue({
available: true,
paths: ["src/main.ts"],
truncated: false,
total: 1,
});
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
path: "",
@@ -229,6 +231,76 @@ describe("LaneChangesPanel", () => {
});
});
it("disarms a pending discard when the lane changes, so one click cannot discard in another lane", async () => {
const user = userEvent.setup();
const { rerender } = render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" });
await user.click(discard!);
// Lane 8 has a file at the SAME path.
rerender(<LaneChangesPanel laneId={8} />);
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(8, expect.anything()));
expect(
screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" })
).toBeNull();
const [again] = await screen.findAllByRole("button", { name: "Discard this file's changes" });
await user.click(again!);
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("disarms a pending discard once that path leaves the file list", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" });
await user.click(discard!);
// The file was committed/reverted elsewhere; a later reappearance must not be pre-armed.
const base = changes();
const rest = base.available ? base.files.slice(1) : [];
vi.mocked(api.lanes.changes).mockResolvedValueOnce(changes({ files: rest }));
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
// A plain refresh (the poll's twin), not a git action — an action already disarms.
await user.click(screen.getByRole("button", { name: "Refresh" }));
await waitFor(() => expect(screen.queryByText("server/lib/git-ops.js")).toBeNull());
await user.click(screen.getByRole("button", { name: "Refresh" }));
await screen.findByText("server/lib/git-ops.js");
expect(
screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" })
).toBeNull();
});
it("ignores a late response for the lane it has already left", async () => {
let resolveOld: (v: LaneChanges) => void = () => {};
vi.mocked(api.lanes.changes).mockImplementation((id) =>
id === 7
? new Promise<LaneChanges>((r) => (resolveOld = r))
: Promise.resolve(
changes({
files: [
{
path: "lane8.txt",
status: "??",
untracked: true,
added: 1,
deleted: 0,
binary: false,
},
],
})
)
);
const { rerender } = render(<LaneChangesPanel laneId={7} />);
rerender(<LaneChangesPanel laneId={8} />);
expect(await screen.findByText("lane8.txt")).toBeInTheDocument();
resolveOld(changes());
await new Promise((r) => setTimeout(r, 20));
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
});
it("opens a commit from history as a commit-range review", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -411,6 +483,16 @@ describe("LaneChangesPanel", () => {
});
});
it("drops a half-typed commit message on a lane switch", async () => {
const user = userEvent.setup();
const { rerender } = render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-commit");
await user.type(screen.getByLabelText("Commit message"), "fix: meant for lane 7");
rerender(<LaneChangesPanel laneId={8} />);
await screen.findByTestId("lane-changes-commit");
expect(screen.getByLabelText("Commit message")).toHaveValue("");
});
it("hides the commit box when nothing is staged", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue(
changes({
@@ -515,6 +597,73 @@ describe("LaneChangesPanel", () => {
);
});
it("opens, not folds, when Enter is pressed on the path", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Review" }));
await screen.findByTestId("lane-changes-review");
screen.getByRole("button", { name: "server/lib/git-ops.js" }).focus();
await user.keyboard("{Enter}");
await waitFor(() =>
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
range: "work",
path: "server/lib/git-ops.js",
context: 3,
})
);
});
it("drops a quick-open answer for the lane it has already left", async () => {
const user = userEvent.setup();
let answer: (v: { paths: string[] }) => void = () => {};
vi.mocked(api.lanes.gitLs).mockReturnValueOnce(
new Promise((r) => {
answer = r as typeof answer;
}) as ReturnType<typeof api.lanes.gitLs>
);
const { rerender } = render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await waitFor(() => expect(api.lanes.gitLs).toHaveBeenCalledWith(7, "main"));
rerender(<LaneChangesPanel laneId={8} />);
await act(async () => answer({ paths: ["from/lane7.ts"] }));
// Back in Files on lane 8, before its own search lands: nothing of lane 7.
await user.click(screen.getByRole("button", { name: "Files" }));
expect(screen.queryByText("from/lane7.ts")).toBeNull();
});
it("keeps the newest quick-open answer when an older one lands after it", async () => {
const user = userEvent.setup();
let answer: (v: { paths: string[] }) => void = () => {};
vi.mocked(api.lanes.gitLs)
.mockReturnValueOnce(
new Promise((r) => {
answer = r as typeof answer;
}) as ReturnType<typeof api.lanes.gitLs>
)
.mockResolvedValueOnce({
available: true,
paths: ["new/mainx.ts"],
truncated: false,
total: 1,
});
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
const box = await screen.findByLabelText("Find a file by path…");
await user.type(box, "main");
await waitFor(() => expect(api.lanes.gitLs).toHaveBeenCalledWith(7, "main"));
await user.type(box, "x");
expect(await screen.findByText("new/mainx.ts")).toBeInTheDocument();
await act(async () => answer({ paths: ["old/main-only.ts"] }));
expect(screen.queryByText("old/main-only.ts")).toBeNull();
});
it("goes back to the list it came from, at the offset it was left at", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -537,4 +686,152 @@ describe("LaneChangesPanel", () => {
expect(await screen.findByTestId("lane-changes-review")).toBeInTheDocument();
expect(screen.getByTestId("lane-changes-body").scrollTop).toBe(640);
});
it("opens an unchanged tracked file from Files with no error banner", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(api.lanes.changes).mockRejectedValue(
Object.assign(new Error("file has no changes in this range"), {
code: "ENOFILECHANGE",
status: 400,
})
);
await user.click(screen.getByRole("button", { name: "Files" }));
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
await user.click(screen.getByRole("button", { name: "Refresh" }));
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledTimes(3));
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.getByTestId("lane-file-view")).toBeInTheDocument();
});
it("keeps the overview rows mounted across re-renders (typing in the commit box)", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-commit");
const before = screen.getByText("server/lib/git-ops.js");
await user.type(screen.getByLabelText("Commit message"), "x");
expect(screen.getByText("server/lib/git-ops.js")).toBe(before);
});
it("expands all folders with at most four tree reads in flight", async () => {
const user = userEvent.setup();
let inFlight = 0;
let peak = 0;
vi.mocked(api.lanes.gitTree).mockClear();
vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path = "") => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await new Promise((r) => setTimeout(r, 5));
inFlight -= 1;
const depth = path ? path.split("/").length : 0;
const entries =
depth >= 2
? [{ name: "leaf.ts", path: `${path}/leaf.ts`, dir: false }]
: Array.from({ length: 5 }, (_, i) => ({
name: `d${i}`,
path: path ? `${path}/d${i}` : `d${i}`,
dir: true,
}));
return { available: true, path, entries, truncated: false };
});
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await screen.findByText("d0");
await user.click(screen.getByRole("button", { name: "Expand all folders" }));
await waitFor(() => expect(screen.getAllByText("leaf.ts")).toHaveLength(25));
// Root + 5 + 25 folders, each fetched exactly once.
expect(api.lanes.gitTree).toHaveBeenCalledTimes(31);
expect(peak).toBeLessThanOrEqual(4);
});
it("does not repaint the previous lane after its git action resolves post-switch", async () => {
const user = userEvent.setup();
let resolveAction: (v: { action: string; ok: true; output: string }) => void = () => {};
vi.mocked(api.lanes.gitAction).mockReturnValue(
new Promise((r) => (resolveAction = r)) as ReturnType<typeof api.lanes.gitAction>
);
vi.mocked(api.lanes.changes).mockImplementation(async (id) =>
id === 7
? changes()
: changes({
files: [
{
path: "lane8.txt",
status: "??",
untracked: true,
added: 1,
deleted: 0,
binary: false,
},
],
})
);
const { rerender } = render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
await user.click(stage!);
rerender(<LaneChangesPanel laneId={8} />);
expect(await screen.findByText("lane8.txt")).toBeInTheDocument();
const lane7Reads = vi.mocked(api.lanes.changes).mock.calls.filter(([id]) => id === 7).length;
resolveAction({ action: "stage", ok: true, output: "" });
await new Promise((r) => setTimeout(r, 20));
expect(vi.mocked(api.lanes.changes).mock.calls.filter(([id]) => id === 7)).toHaveLength(
lane7Reads
);
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
});
it("does not paint the previous lane's tree when an expand-all walk outlives a lane switch", async () => {
const user = userEvent.setup();
let resolveSrc: () => void = () => {};
vi.mocked(api.lanes.gitTree).mockImplementation((id, path) => {
if (id === 7 && path === "src")
return new Promise(
(r) =>
(resolveSrc = () =>
r({
available: true,
path: "src",
entries: [{ name: "main.ts", path: "src/main.ts", dir: false }],
truncated: false,
}))
);
return Promise.resolve({
available: true,
path: "",
entries: [
id === 7
? { name: "src", path: "src", dir: true }
: { name: "lib8", path: "lib8", dir: false },
],
truncated: false,
});
});
const { rerender } = render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await screen.findByText("src");
await user.click(screen.getByRole("button", { name: "Expand all folders" }));
rerender(<LaneChangesPanel laneId={8} />);
await user.click(await screen.findByRole("button", { name: "Files" }));
expect(await screen.findByText("lib8")).toBeInTheDocument();
resolveSrc();
await new Promise((r) => setTimeout(r, 20));
expect(screen.getByText("lib8")).toBeInTheDocument();
expect(screen.queryByText("src")).toBeNull();
expect(screen.queryByText("main.ts")).toBeNull();
expect(screen.getByRole("button", { name: "Expand all folders" })).toBeEnabled();
});
});
@@ -3,13 +3,21 @@
* @description Pins the side-by-side renderer: two independently scrolling
* columns (a long line must be reachable, not painted over its neighbour)
* whose horizontal scroll is mirrored, so the two halves of a line stay
* opposite each other.
* opposite each other. Also pins the clamped mirroring (a narrow column may
* not drag the wide one back) and the row windowing of large diffs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { LaneDiffView } from "../LaneDiffView";
import {
LaneDiffView,
LaneFileView,
ROW_PX,
WINDOW_THRESHOLD,
analyseHunk,
visibleRange,
} from "../LaneDiffView";
import type { LaneChangeFile } from "../../../lib/types";
const FILE: LaneChangeFile = {
@@ -33,6 +41,17 @@ const FILE: LaneChangeFile = {
],
};
/** jsdom has no layout: give a scroller the extents a browser would. */
function layout(
el: HTMLElement,
g: { scrollWidth?: number; clientWidth?: number; scrollHeight?: number; clientHeight?: number }
) {
for (const [k, v] of Object.entries(g))
Object.defineProperty(el, k, { value: v, configurable: true });
}
const ROOMY = { scrollWidth: 2000, clientWidth: 200, scrollHeight: 2000, clientHeight: 200 };
describe("LaneDiffView — side by side", () => {
it("gives each column its own scroller", () => {
render(<LaneDiffView file={FILE} layout="split" />);
@@ -56,6 +75,8 @@ describe("LaneDiffView — side by side", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, ROOMY);
layout(right, ROOMY);
left.scrollLeft = 320;
fireEvent.scroll(left);
@@ -66,6 +87,55 @@ describe("LaneDiffView — side by side", () => {
expect(left.scrollLeft).toBe(0);
});
it("keeps the split columns on the same row vertically too", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, ROOMY);
layout(right, ROOMY);
left.scrollTop = 480;
fireEvent.scroll(left);
expect(right.scrollTop).toBe(480);
right.scrollTop = 40;
fireEvent.scroll(right);
expect(left.scrollTop).toBe(40);
});
it("clamps the mirrored offset to the other column's own extent", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, { scrollWidth: 500, clientWidth: 200, scrollHeight: 300, clientHeight: 200 });
layout(right, { scrollWidth: 1000, clientWidth: 200, scrollHeight: 300, clientHeight: 200 });
right.scrollLeft = 600;
right.scrollTop = 250;
fireEvent.scroll(right);
expect(left.scrollLeft).toBe(300);
expect(left.scrollTop).toBe(100);
});
it("does not let the narrow column's clamped echo drag the wide one back", () => {
render(<LaneDiffView file={FILE} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, { scrollWidth: 500, clientWidth: 200 });
layout(right, { scrollWidth: 1000, clientWidth: 200 });
right.scrollLeft = 600;
fireEvent.scroll(right);
// A browser fires the narrow column's scroll for the clamped write.
fireEvent.scroll(left);
expect(right.scrollLeft).toBe(600);
// A real move of the narrow column still carries the wide one.
left.scrollLeft = 100;
fireEvent.scroll(left);
expect(right.scrollLeft).toBe(100);
});
it("keeps the unified layout on one scroller", () => {
render(<LaneDiffView file={FILE} layout="unified" />);
expect(screen.queryByTestId("lane-diff-split-left")).toBeNull();
@@ -158,3 +228,154 @@ describe("LaneDiffView — row styling", () => {
expect(left!.className).not.toMatch(/status-(success|danger)/);
});
});
describe("analyseHunk", () => {
const hunk = {
header: "@@ -1,4 +1,4 @@",
context: "",
oldStart: 1,
newStart: 1,
lines: [
{ type: "ctx" as const, oldLine: 1, newLine: 1, text: "keep" },
{ type: "del" as const, oldLine: 2, newLine: null, text: "foo = 1" },
{ type: "add" as const, oldLine: null, newLine: 2, text: "foo = 2" },
{ type: "del" as const, oldLine: 3, newLine: null, text: "bar = 1" },
{ type: "add" as const, oldLine: null, newLine: 3, text: "bar = 2" },
],
};
it("pairs each del/add with its emphasis, keyed by line index", () => {
const a = analyseHunk(hunk);
expect(a.rows).toHaveLength(3);
expect(a.emph.get(1)).toEqual([6, 7]);
expect(a.emph.get(2)).toEqual([6, 7]);
expect(a.emph.get(3)).toEqual([6, 7]);
expect(a.emph.get(4)).toEqual([6, 7]);
expect(a.emph.has(0)).toBe(false);
});
it("computes once per hunk, so both split columns and re-renders share it", () => {
expect(analyseHunk(hunk)).toBe(analyseHunk(hunk));
});
});
describe("visibleRange", () => {
it("covers the viewport plus overscan on both sides", () => {
expect(visibleRange(0, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 });
expect(visibleRange(1600, 160, 16, 1000, 5)).toEqual({ start: 95, end: 115 });
});
it("clamps a scrollTop past the content to the last full viewport", () => {
expect(visibleRange(99999, 160, 16, 1000, 5)).toEqual({ start: 985, end: 1000 });
expect(visibleRange(-50, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 });
});
it("never runs past a short list", () => {
expect(visibleRange(0, 800, 16, 3, 5)).toEqual({ start: 0, end: 3 });
expect(visibleRange(0, 800, 16, 0, 5)).toEqual({ start: 0, end: 0 });
});
});
function bigFile(n: number): LaneChangeFile {
const lines = Array.from({ length: n }, (_, i) =>
i % 2
? { type: "add" as const, oldLine: null, newLine: i + 1, text: `line ${i};` }
: { type: "ctx" as const, oldLine: i + 1, newLine: i + 1, text: `line ${i};` }
);
lines[7] = { type: "ctx", oldLine: 8, newLine: 8, text: `line 7; ${"w".repeat(300)}` };
return fileWith(lines);
}
const rowsIn = (el: HTMLElement) => [...el.querySelectorAll<HTMLElement>("div.flex.w-max")];
describe("LaneDiffView — windowing", () => {
it("renders every row below the threshold, with no spacers", () => {
const { container } = render(
<LaneDiffView file={bigFile(WINDOW_THRESHOLD - 10)} layout="unified" />
);
expect(rowsIn(container)).toHaveLength(WINDOW_THRESHOLD - 10);
expect(container.querySelector("[data-diff-spacer]")).toBeNull();
});
it("renders far fewer rows than a 5000-line diff holds, keeping the full scroll height", () => {
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const rows = rowsIn(container);
expect(rows.length).toBeGreaterThan(0);
expect(rows.length).toBeLessThan(300);
const spacers = [...container.querySelectorAll<HTMLElement>("[data-diff-spacer]")];
const spaced = spacers.reduce((sum, s) => sum + parseFloat(s.style.height), 0);
// header + 5000 lines, every item one fixed row tall
const rendered = container.querySelectorAll("[data-diff-item]").length;
expect(spaced + rendered * ROW_PX).toBe(5001 * ROW_PX);
});
it("moves the window with the scroll", () => {
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
expect(container.textContent).not.toContain("line 3000;");
view.scrollTop = 3000 * ROW_PX;
fireEvent.scroll(view);
expect(container.textContent).toContain("line 3000;");
expect(container.textContent).not.toContain("line 10;");
});
it("starts windowing where the reader is when a diff grows past the threshold", () => {
const { container, rerender } = render(<LaneDiffView file={bigFile(400)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
view.scrollTop = 350 * ROW_PX;
fireEvent.scroll(view);
// Expanding context replaces the file in place, same mounted view.
rerender(<LaneDiffView file={bigFile(600)} layout="unified" />);
expect(container.textContent).toContain("line 350;");
});
it("starts from the top again after a round trip through split", () => {
const { container, rerender } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
view.scrollTop = 3000 * ROW_PX;
fireEvent.scroll(view);
rerender(<LaneDiffView file={bigFile(5000)} layout="split" />);
// In split the outer box is no scroller; the browser drops its offset.
screen.getByTestId("lane-diff-view").scrollTop = 0;
rerender(<LaneDiffView file={bigFile(5000)} layout="unified" />);
expect(container.textContent).toContain("line 5;");
});
it("holds the horizontal extent at the longest line, rendered or not", () => {
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
const view = screen.getByTestId("lane-diff-view");
view.scrollTop = 3000 * ROW_PX;
fireEvent.scroll(view);
const content = container.querySelector<HTMLElement>("[data-diff-content]")!;
// the 300-w line 7 is off screen now, and still sets the width
expect(content.style.minWidth).toContain(`${"line 7; ".length + 300 + 1}ch`);
});
it("windows both split columns over the same row range", () => {
render(<LaneDiffView file={bigFile(5000)} layout="split" />);
const left = screen.getByTestId("lane-diff-split-left");
const right = screen.getByTestId("lane-diff-split-right");
layout(left, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 });
layout(right, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 });
right.scrollTop = 2000 * ROW_PX;
fireEvent.scroll(right);
expect(left.scrollTop).toBe(2000 * ROW_PX);
const l = rowsIn(left);
const r = rowsIn(right);
expect(l.length).toBeLessThan(300);
expect(l).toHaveLength(r.length);
expect(right.textContent).toContain("line 2001;");
// same rows: the right column's add has its filler row on the left
expect(left.textContent).toContain("line 2000;");
});
it("windows a whole-file read too", () => {
const lines = Array.from({ length: 5000 }, (_, i) => `row ${i}`);
const { container } = render(<LaneFileView path="a.ts" lines={lines} />);
expect(rowsIn(container).length).toBeLessThan(300);
const view = screen.getByTestId("lane-file-view");
view.scrollTop = 4000 * ROW_PX;
fireEvent.scroll(view);
expect(container.textContent).toContain("row 4000");
});
});
@@ -120,6 +120,15 @@ describe("LaneGitBar", () => {
);
});
it("re-reads the branch list after a switch, so the branch just left is not held", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
});
it("creates a branch without switching, then offers a sibling lane on it", async () => {
const user = userEvent.setup();
const onNewLane = vi.fn();
@@ -146,6 +155,34 @@ describe("LaneGitBar", () => {
expect(onNewLane).toHaveBeenCalledWith("development");
});
it("offers a sibling lane only on a branch no worktree holds — the server refuses the rest", async () => {
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
available: true,
branches: ["development", "feat/thing", "feat/free"],
current: "feat/thing",
worktrees: [
{ path: "/repo/lane3", branch: "feat/thing" },
{ path: "/repo/dev", branch: "development" },
],
});
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
const newLane = () => screen.getByRole("button", { name: "New lane here" });
// This lane's own branch: its worktree is this lane.
await user.selectOptions(select, "feat/thing");
expect(newLane()).toBeDisabled();
// Held by another worktree: git allows one worktree per branch (EBRANCHBUSY).
await user.selectOptions(select, "development");
expect(newLane()).toBeDisabled();
expect(newLane()).toHaveAttribute("title", expect.stringContaining("/repo/dev"));
await user.selectOptions(select, "feat/free");
expect(newLane()).toBeEnabled();
});
it("commits only the index when asked", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
@@ -172,4 +209,115 @@ describe("LaneGitBar", () => {
await user.click(screen.getByRole("button", { name: /Push/ }));
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent("non-fast-forward");
});
it("keeps a half-typed commit message through a fetch", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
await user.type(screen.getByLabelText("Commit message"), "fix: half");
await user.click(screen.getByRole("button", { name: /Fetch/ }));
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "fetch", {}));
expect(screen.getByLabelText("Commit message")).toHaveValue("fix: half");
});
it("clears and closes the commit box once the commit lands", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
await user.type(screen.getByLabelText("Commit message"), "fix: done");
await user.click(screen.getByRole("button", { name: /Commit 3 files/ }));
await waitFor(() => expect(screen.queryByLabelText("Commit message")).toBeNull());
});
it("drops the commit box and message on a lane switch, so Enter cannot commit lane B under lane A's message", async () => {
const user = userEvent.setup();
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
await user.type(screen.getByLabelText("Commit message"), "fix: lane three");
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
expect(screen.queryByLabelText("Commit message")).toBeNull();
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
expect(screen.getByLabelText("Commit message")).toHaveValue("");
await user.keyboard("{Enter}");
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("re-reads the branch list for the new lane instead of showing the old one's", async () => {
const user = userEvent.setup();
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
expect(screen.queryByLabelText("Switch branch…")).toBeNull();
await user.click(screen.getByRole("button", { name: /Branch/ }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
expect(await screen.findByLabelText("Switch branch…")).toHaveValue("");
});
it("sends one commit for a double Enter", async () => {
vi.mocked(api.lanes.gitAction).mockReturnValue(new Promise(() => {}));
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
await user.type(screen.getByLabelText("Commit message"), "fix: once{Enter}{Enter}");
expect(api.lanes.gitAction).toHaveBeenCalledTimes(1);
expect(screen.getByRole("button", { name: "Commit staged" })).toBeDisabled();
});
it("forgets which branches the previous lane's worktrees held once the lane changes", async () => {
let resolveOld: (v: Awaited<ReturnType<typeof api.lanes.gitBranches>>) => void = () => {};
vi.mocked(api.lanes.gitBranches).mockReturnValueOnce(
new Promise((r) => {
resolveOld = r;
})
);
const user = userEvent.setup();
const { rerender } = render(
<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />
);
await user.click(screen.getByRole("button", { name: /Branch/ }));
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
// Lane 3's answer lands late: its worktree held `development`.
resolveOld({
available: true,
branches: ["development", "feat/thing"],
current: "feat/thing",
worktrees: [{ path: "/repo/dev", branch: "development" }],
});
await user.selectOptions(select, "development");
const newLane = screen.getByRole("button", { name: "New lane here" });
expect(newLane).toBeEnabled();
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
});
it("claims no sync state when the upstream comparison could not be counted", () => {
render(
<LaneGitBar
laneId={3}
git={facts({ upstream: "origin/gone", ahead: null, behind: null })}
onChanged={vi.fn()}
/>
);
expect(screen.queryByText("in sync")).toBeNull();
expect(screen.queryByTestId("lane-git-tracking")).toBeNull();
});
it("explains a push with nowhere to go instead of the server's generic text", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.gitAction).mockRejectedValue(
Object.assign(new Error("this repository has no remote to push to"), { code: "ENOREMOTE" })
);
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Push/ }));
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent(
"No remote to push to or pull from"
);
});
});
+4 -2
View File
@@ -25,13 +25,13 @@
"addLaneBranchBusy": "{{branch}} — already checked out at {{path}}",
"addLaneBranchExistingHint": "The worktree checks this branch out as it is — no new branch, no base.",
"addLaneBranchExistingLabel": "Branch to check out",
"addLaneBranchHintAuto": "Leave empty to use the default name shown above.",
"addLaneBranchHintAuto": "Leave empty to let the server name the branch from the title.",
"addLaneBranchLabel": "New branch name",
"addLaneBranchModeCurrent": "Current branch",
"addLaneBranchModeCurrentHint": "Work straight on this repo's checked-out branch (main) — no worktree, no fork. Switches to Repo mode.",
"addLaneBranchModeExisting": "Existing branch",
"addLaneBranchModeNew": "New branch",
"addLaneBranchPlaceholder": "feat/my-feature",
"addLaneBranchPlaceholder": "Automatic — named from the title",
"addLaneNoBranches": "This repo has no commits yet — the worktree will start empty.",
"addLaneNoFreeBranches": "Every branch in this repo is already checked out in a worktree. Create a new branch instead.",
"addLaneExists": "This folder already has a lane: #{{id}} {{title}}. It is selected; pick another folder to add a new one.",
@@ -180,8 +180,10 @@
"gitBar.commitStagedHint": "Commit only what is staged in the Changes panel",
"gitBar.fetch": "Fetch",
"gitBar.fetchHint": "git fetch --all --prune",
"gitBar.errNoRemote": "No remote to push to or pull from. Add one, or set this branch's upstream, in a terminal.",
"gitBar.inSync": "in sync",
"gitBar.newLane": "New lane here",
"gitBar.newLaneHeld": "Already checked out at {{path}} — git allows one worktree per branch",
"gitBar.newLaneHint": "Open Add lane for a sibling worktree on the selected branch",
"gitBar.noUpstream": "not pushed",
"gitBar.pull": "Pull",
+4 -2
View File
@@ -25,13 +25,13 @@
"addLaneBranchBusy": "{{branch}} — đang được checkout tại {{path}}",
"addLaneBranchExistingHint": "Worktree checkout đúng nhánh này — không tạo nhánh mới, không cần nhánh gốc.",
"addLaneBranchExistingLabel": "Nhánh cần checkout",
"addLaneBranchHintAuto": "Để trống để dùng tên mặc định hiển thị ở trên.",
"addLaneBranchHintAuto": "Để trống để server tự đặt tên nhánh theo tiêu đề.",
"addLaneBranchLabel": "Tên nhánh mới",
"addLaneBranchModeCurrent": "Nhánh hiện tại",
"addLaneBranchModeCurrentHint": "Làm thẳng trên nhánh repo đang mở (main) — không tạo worktree, không fork nhánh. Chuyển sang chế độ Repo.",
"addLaneBranchModeExisting": "Nhánh có sẵn",
"addLaneBranchModeNew": "Nhánh mới",
"addLaneBranchPlaceholder": "feat/tinh-nang-cua-toi",
"addLaneBranchPlaceholder": "Tự động — đặt theo tiêu đề",
"addLaneNoBranches": "Repo này chưa có commit nào — worktree sẽ bắt đầu trống.",
"addLaneNoFreeBranches": "Mọi nhánh của repo này đều đang nằm trong một worktree. Hãy tạo nhánh mới.",
"addLaneExists": "Thư mục này đã có lane: #{{id}} {{title}}. Lane đó đã được chọn; hãy chọn thư mục khác để thêm lane mới.",
@@ -180,8 +180,10 @@
"gitBar.commitStagedHint": "Chỉ commit những file đã stage ở thẻ Changes",
"gitBar.fetch": "Fetch",
"gitBar.fetchHint": "git fetch --all --prune",
"gitBar.errNoRemote": "Không có remote để push hoặc pull. Hãy thêm remote, hoặc đặt upstream cho nhánh này, trong terminal.",
"gitBar.inSync": "đã đồng bộ",
"gitBar.newLane": "Tạo lane mới",
"gitBar.newLaneHeld": "Nhánh đã được checkout tại {{path}} — git chỉ cho một worktree trên mỗi nhánh",
"gitBar.newLaneHint": "Mở Add lane để tạo worktree anh em trên nhánh đang chọn",
"gitBar.noUpstream": "chưa push",
"gitBar.pull": "Pull",
+44 -10
View File
@@ -392,6 +392,9 @@ What the lane's directory looks like to git right now:
}
```
- `dirty`, `untracked` and `files` cover only the lane's own directory, with
cwd-relative, unquoted paths — a lane that is a subdirectory of its repo does
not count its siblings' files (the same scoping as `/git/changes`).
- A `cwd` that is missing, is not a git repository, or makes git fail returns
`{"available": false}` with HTTP **200** — a normal state, not a fault.
- `files` names the files the counts came from, **capped at 20** (the counts
@@ -432,6 +435,12 @@ unresolvable one is `400 EBADREV`, never an empty diff).
range the diff has effectively become the whole file, which is what "expand
all" means in the panel.
Every path on the lane git surface (reads and writes) is **relative to the
lane's `cwd`**, even when that cwd is a subdirectory of its repo: changes
outside the cwd are not listed and cannot be named. Paths are literal file
names, never pathspec globs — `new*.js` or `app/[id]/page.js` names exactly
that file.
```json
{
"available": true,
@@ -497,13 +506,26 @@ and file explorer. All four are read-only and unguarded, and all four answer
- **`/git/log`** → `{available, commits:[{sha, short, subject, author, date,
refs}]}`, newest first, `limit` clamped to 200. `file=` narrows it to the
commits touching that path. Reading one commit's diff is
commits touching that path — relative, no `..` segment, else
`400 EBADPATH`. Reading one commit's diff is
`/git/changes?range=commit&sha=`.
- **`/git/file`** → `{available, path, rev, binary, truncated, lines}`.
`rev=work` (default) is the file on disk — the one branch that reads the
filesystem, and it is realpath-confined to the lane directory; anything else
goes through `git show <rev>:<path>`. Binary content is reported, never
returned. Capped at 1 MB. `400 EBADPATH`, `EBADREV` or `ENOFILE`.
filesystem, and it is realpath-confined to the lane directory (a symlink
resolving outside it is `EBADPATH`); anything else goes through
`git show <rev>:./<path>` (cwd-relative). At EVERY rev the path must be
relative with no `..` segment (else `400 EBADPATH`), and it is normalised
(`./a//b` → `a/b`, also the `path` echoed back). Binary content is reported,
never returned. Capped at 1 MB — a larger file is `truncated: true`, never
`ENOFILE` (a working-tree file is read only up to the cap, never whole).
`400 EBADPATH`, `EBADREV` or `ENOFILE`. `.claude/settings.local.json`
(where a lane's AI Router key lives) is `403 ESECRET` at EVERY rev and at
ANY depth (`sub/.claude/settings.local.json`, a subdirectory lane's key, as
seen from a lane at the repo root), and so is any path that RESOLVES to it
(a symlinked `.claude` → `conf/` makes it `conf/settings.local.json`). In
fact ANY file named `settings.local.json`, in any directory and at any rev,
is refused — a symlink target's name is all a lane above it, or a commit of
it, ever sees. Its hunks are never returned by `/git/changes` either.
- **`/git/ls`** → `{available, paths, truncated, total}`, capped at 500. It
reads `git ls-files`, so ignored files never appear and no skip-list has to
be maintained; basename hits rank first.
@@ -530,15 +552,17 @@ POST /api/lanes/:id/git/:action
```
The git **writes** the dashboard offers. Guarded (same-origin) and serialized
per lane under the lane lock.
per git index (the lane lock keyed on the worktree's git dir), so a claude and
a codex lane on one cwd — or a root and a subdirectory lane of one repo —
cannot collide on `index.lock`.
| Action | Runs | Body |
| --- | --- | --- |
| `fetch` | `git fetch --all --prune` | — |
| `pull` | `git pull --ff-only` | — |
| `push` | `git push -u origin HEAD` | — |
| `push` | `git push -u <remote> HEAD` — the branch's upstream remote, else the only remote, else `origin`, else `400 ENOREMOTE` | — |
| `stage` | `git add -- <paths>` | `{"paths": ["..."]}` |
| `unstage` | `git restore --staged -- <paths>` | `{"paths": ["..."]}` |
| `unstage` | `git restore --staged -- <paths>` (a staged rename also passes its original path, so both halves unstage) | `{"paths": ["..."]}` |
| `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked) | `{"paths": ["..."], "confirm": true}` |
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"` | `{"message": "...", "only": "staged"}` |
| `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
@@ -558,6 +582,9 @@ per lane under the lane lock.
- A **path** in the body is never trusted as a path: `stage`, `unstage` and
`discard` accept only paths `git status` is reporting as changed right now,
so `..`, an absolute path and an untouched file are all `400 EBADPATH`.
A `commit` without `only: "staged"` sweeps in (`git add -A -- .`) only the
lane cwd's own changes, and is `400 ENOCHANGES` when the lane has none —
even if the repo is dirty elsewhere.
- `discard` is the one irreversible action here and it **requires**
`confirm: true` (`400 ECONFIRM` without it). The UI additionally asks twice.
- `branch-create` never switches — the usual reason to create one from a lane
@@ -567,9 +594,16 @@ per lane under the lane lock.
branch and a branch another worktree holds (`400 EBRANCHBUSY`).
- Bad input is **400** (`EBADACTION`, `EBADMESSAGE`, `EBADBRANCH`, `EBADPATH`,
`ECONFIRM`, `EPROTECTED`, `ENOBRANCH`, `EBRANCHEXISTS`, `EBRANCHBUSY`,
`EDIRTY`, `ENOCHANGES`); a git failure is
**500** with git's own `stderr` appended to `error.message` — a rejection or a
credential failure reads better in git's words.
`EDIRTY`, `ENOCHANGES`, `ESTAGEDOUTSIDE`, `ENOREMOTE`); git itself exiting
non-zero (a rejected push, a credential failure, a diverged pull) is
**409 `EGIT`** with git's own `stderr` appended to `error.message` — a
rejection reads better in git's words. Anything else (git not installed, a
timeout kill) is **500**, still under a string code (`EGIT` when there is
none) — `error.code` is never git's numeric exit status.
- In a lane whose cwd is a subdirectory of its repo, `commit` refuses with
`400 ESTAGEDOUTSIDE` while the index holds a change outside that directory:
`git commit` takes the whole index whatever the cwd, and scoping it with a
pathspec would commit the working-tree copy (unstaged edits included).
- Network actions (`fetch`/`pull`/`push`) are killed after 60s so an unreachable
remote fails instead of holding the request open.
+11 -5
View File
@@ -11043,7 +11043,7 @@ paths:
tags:
- Lanes
summary: The lane's recent commits
description: Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path. Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.
description: Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path (relative, no `..`, else 400 EBADPATH). Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.
operationId: listLaneCommits
parameters:
- name: id
@@ -11064,6 +11064,8 @@ paths:
responses:
'200':
description: '{available, commits:[{sha, short, subject, author, date, refs}]}.'
'400':
description: EBADPATH (file= is absolute or has a `..` segment).
'404':
description: Lane not found.
/api/lanes/{id}/git/file:
@@ -11093,7 +11095,9 @@ paths:
'200':
description: '{available, path, rev, binary, truncated, lines}.'
'400':
description: EBADPATH, EBADREV or ENOFILE.
description: EBADPATH (incl. a symlink resolving outside the lane), EBADREV or ENOFILE.
'403':
description: ESECRET — `.claude/settings.local.json` (the AI Router key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`).
'404':
description: Lane not found.
/api/lanes/{id}/git/ls:
@@ -11166,7 +11170,7 @@ paths:
tags:
- Lanes
summary: One git write on the lane's working copy
description: 'fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u origin HEAD, so only the branch the lane is on), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), commit (git add -A then commit -m <message>, or only:"staged" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent''s half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per lane under the lane lock. Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.'
description: 'fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u <remote> HEAD, so only the branch the lane is on; remote = the branch''s upstream remote, else the only remote, else origin, else 400 ENOREMOTE), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), commit (git add -A then commit -m <message>, or only:"staged" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent''s half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per git index (the lane lock keyed on the worktree''s git dir, so two lanes sharing one index cannot collide on index.lock). Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.'
operationId: runLaneGitAction
parameters:
- name: id
@@ -11226,11 +11230,13 @@ paths:
'200':
description: '{action, ok:true, output} — git''s own stdout+stderr.'
'400':
description: EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY or ENOCHANGES.
description: EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY, ENOCHANGES, ESTAGEDOUTSIDE or ENOREMOTE.
'404':
description: Lane not found.
'409':
description: EGIT — git ran and refused (a rejected push, a credential failure, a diverged --ff-only pull); its stderr is appended to error.message.
'500':
description: git failed; its stderr is appended to error.message.
description: Unexpected server-side failure.
/api/lanes/{id}/{action}:
post:
tags:
@@ -0,0 +1,639 @@
/**
* @file Regression tests for the lane git surface's hardening: the file viewer
* and untracked-file diff never read a secret file or follow a symlink out of
* the lane, `git status` paths survive C-quoting (non-ASCII, spaces, quotes),
* discarding a staged rename restores the original path, and untracked reads
* are async (no fs.*Sync on the poll path). Real git fixture, like
* git-diff-ops.test.js.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const { collectChanges, parseNumstat, readFileAt } = require("../lib/git-diff");
const { runGitAction } = require("../lib/git-ops");
const { gitFacts } = require("../lib/worktree");
let ROOT;
let LANE;
const g = (cwd, ...args) => {
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
delete env.GIT_DIR;
delete env.GIT_WORK_TREE;
delete env.GIT_INDEX_FILE;
return execFileSync("git", args, { cwd, encoding: "utf8", env });
};
function write(dir, file, body) {
fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
fs.writeFileSync(path.join(dir, file), body);
}
beforeEach(() => {
ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-githarden-"));
LANE = path.join(ROOT, "lane");
g(ROOT, "init", "-q", "-b", "main", LANE);
g(LANE, "config", "user.email", "t@h");
g(LANE, "config", "user.name", "t");
write(LANE, "a.txt", "one\ntwo\n");
write(LANE, "old name.txt", "keep me\nplease\n");
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "init");
write(ROOT, "outside.txt", "TOP SECRET OUTSIDE\n");
});
afterEach(() => fs.rmSync(ROOT, { recursive: true, force: true }));
describe("readFileAt: secret files and symlink escapes", () => {
it("refuses .claude/settings.local.json at rev=work", async () => {
write(LANE, ".claude/settings.local.json", '{"env":{"ANTHROPIC_API_KEY":"sk-x"}}');
for (const p of [".claude/settings.local.json", "./.claude//settings.local.json"]) {
await assert.rejects(readFileAt(LANE, p, "work"), { code: "ESECRET" });
}
});
it("refuses the secret file at a committed revision too", async () => {
write(LANE, ".claude/settings.local.json", '{"env":{"ANTHROPIC_API_KEY":"sk-x"}}');
g(LANE, "add", "-f", ".claude/settings.local.json");
g(LANE, "commit", "-qm", "oops");
await assert.rejects(readFileAt(LANE, ".claude/settings.local.json", "HEAD"), {
code: "ESECRET",
});
});
it("refuses a symlink whose target is outside the lane", async () => {
fs.symlinkSync(path.join(ROOT, "outside.txt"), path.join(LANE, "link.txt"));
await assert.rejects(readFileAt(LANE, "link.txt", "work"), { code: "EBADPATH" });
});
it("refuses a symlink that points at the secret file", async () => {
write(LANE, ".claude/settings.local.json", '{"k":"sk-x"}');
fs.symlinkSync(".claude/settings.local.json", path.join(LANE, "innocent.json"));
await assert.rejects(readFileAt(LANE, "innocent.json", "work"), { code: "ESECRET" });
});
it("still reads a symlink that stays inside the lane", async () => {
fs.symlinkSync("a.txt", path.join(LANE, "alias.txt"));
const out = await readFileAt(LANE, "alias.txt", "work");
assert.deepEqual(out.lines, ["one", "two"]);
});
});
describe("collectChanges: untracked content reads", () => {
it("never shows the content of an untracked symlink pointing outside the lane", async () => {
fs.symlinkSync(path.join(ROOT, "outside.txt"), path.join(LANE, "escape.txt"));
const out = await collectChanges(LANE, { range: "work", full: true });
const entry = out.files.find((f) => f.path === "escape.txt");
assert.ok(entry, "the symlink is still listed");
assert.equal(JSON.stringify(entry).includes("TOP SECRET"), false);
});
it("never shows the secret file's content in a diff", async () => {
write(LANE, ".claude/settings.local.json", '{"k":"sk-old"}');
g(LANE, "add", "-f", ".claude/settings.local.json");
g(LANE, "commit", "-qm", "tracked by mistake");
write(LANE, ".claude/settings.local.json", '{"k":"sk-leak"}');
const out = await collectChanges(LANE, { range: "work", full: true });
assert.equal(JSON.stringify(out).includes("sk-leak"), false);
});
it("does not use synchronous fs reads", async () => {
write(LANE, "new.txt", "x\ny\n");
const saved = { readFileSync: fs.readFileSync, statSync: fs.statSync };
fs.readFileSync = () => {
throw new Error("sync read");
};
fs.statSync = () => {
throw new Error("sync stat");
};
let out;
try {
out = await collectChanges(LANE, { range: "work", full: true });
} finally {
Object.assign(fs, saved);
}
const entry = out.files.find((f) => f.path === "new.txt");
assert.equal(entry.added, 2);
assert.equal(entry.hunks[0].lines.length, 2);
});
});
describe("C-quoted status paths", () => {
const NAME = 'tài liệu "mới".txt';
it("collectChanges reports a non-ASCII/quoted path verbatim", async () => {
write(LANE, NAME, "xin chào\n");
write(LANE, "a.txt", "one\ntwo\nthree\n");
g(LANE, "mv", "old name.txt", "mới tên.txt");
const out = await collectChanges(LANE, { range: "work" });
const paths = out.files.map((f) => f.path);
assert.ok(paths.includes(NAME), paths.join("|"));
assert.ok(paths.includes("mới tên.txt"), paths.join("|"));
assert.equal(out.files.find((f) => f.path === NAME).added, 1);
assert.equal(out.files.find((f) => f.path === "a.txt").added, 1);
});
it("stage accepts a non-ASCII/quoted path", async () => {
write(LANE, NAME, "xin chào\n");
await runGitAction(LANE, "stage", { paths: [NAME] });
assert.match(g(LANE, "diff", "--cached", "--name-only", "-z"), /mới/);
});
it("branch range and commit range report renamed non-ASCII paths", async () => {
g(LANE, "checkout", "-qb", "feat");
g(LANE, "mv", "old name.txt", "mới tên.txt");
g(LANE, "commit", "-qm", "rename");
const branch = await collectChanges(LANE, { range: "branch", base: "main" });
assert.deepEqual(
branch.files.map((f) => f.path),
["mới tên.txt"]
);
const commit = await collectChanges(LANE, { range: "commit", sha: "HEAD" });
assert.deepEqual(
commit.files.map((f) => f.path),
["mới tên.txt"]
);
});
});
describe("discard of a staged rename", () => {
it("restores the original path and removes the new one", async () => {
g(LANE, "mv", "old name.txt", "new name.txt");
await runGitAction(LANE, "discard", { paths: ["new name.txt"], confirm: true });
assert.equal(fs.readFileSync(path.join(LANE, "old name.txt"), "utf8"), "keep me\nplease\n");
assert.equal(fs.existsSync(path.join(LANE, "new name.txt")), false);
assert.equal(g(LANE, "status", "--porcelain"), "");
});
it("still requires confirm: true", async () => {
g(LANE, "mv", "old name.txt", "new name.txt");
await assert.rejects(runGitAction(LANE, "discard", { paths: ["new name.txt"] }), {
code: "ECONFIRM",
});
});
});
describe("GET /api/lanes/:id/git/file maps a secret to 403 ESECRET", () => {
it("answers 403 for the router's settings file", async () => {
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "api.db");
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const server = await startServer(createApp(), 0);
const base = `http://127.0.0.1:${server.address().port}`;
try {
write(LANE, ".claude/settings.local.json", '{"k":"sk-x"}');
const created = await fetch(`${base}/api/lanes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cwd: LANE, title: "secret" }),
}).then((r) => r.json());
const id = created.lane.id;
const r = await fetch(
`${base}/api/lanes/${id}/git/file?path=.claude/settings.local.json&rev=work`
);
assert.equal(r.status, 403);
const body = await r.json();
assert.equal(body.error.code, "ESECRET");
assert.equal(JSON.stringify(body).includes("sk-x"), false);
await fetch(`${base}/api/lanes/${id}`, { method: "DELETE" });
} finally {
server.close();
}
});
});
describe("unstage of a staged rename", () => {
it("unstages both halves: nothing staged, the new file left untracked, the old one deleted in the tree only", async () => {
g(LANE, "mv", "old name.txt", "new name.txt");
await runGitAction(LANE, "unstage", { paths: ["new name.txt"] });
assert.equal(g(LANE, "diff", "--cached", "--name-only"), "");
assert.equal(fs.readFileSync(path.join(LANE, "new name.txt"), "utf8"), "keep me\nplease\n");
});
});
describe("readFileAt: one lexical guard for every rev", () => {
let SUB;
beforeEach(() => {
write(LANE, ".claude/settings.local.json", '{"k":"sk-parent"}');
write(LANE, "root-only.txt", "ROOT CONTENT\n");
write(LANE, "pkg/in.txt", "inside\n");
write(LANE, "pkg/.claude/settings.local.json", '{"k":"sk-sub"}');
g(LANE, "add", "-f", "-A");
g(LANE, "commit", "-qm", "sub");
SUB = path.join(LANE, "pkg");
});
it("refuses ../ and absolute paths at a committed rev in a subdirectory lane", async () => {
for (const p of [
"../.claude/settings.local.json",
"./../root-only.txt",
"x/../../root-only.txt",
"/etc/passwd",
]) {
await assert.rejects(readFileAt(SUB, p, "HEAD"), { code: "EBADPATH" }, p);
}
});
it("reads committed paths relative to the lane, not the repo root", async () => {
const out = await readFileAt(SUB, "./in.txt", "HEAD");
assert.deepEqual(out.lines, ["inside"]);
assert.equal(out.path, "in.txt");
// A repo-root path is NOT reachable by naming it without ../
await assert.rejects(readFileAt(SUB, "root-only.txt", "HEAD"), { code: "ENOFILE" });
});
it("refuses the lane's own secret file in its normalised spellings", async () => {
for (const p of ["./.claude/settings.local.json", ".claude//settings.local.json"]) {
await assert.rejects(readFileAt(SUB, p, "HEAD"), { code: "ESECRET" }, p);
}
});
});
describe("paths are literal, never pathspec patterns", () => {
it("discarding an untracked 'new*.js' deletes only that file", async () => {
for (const f of ["new*.js", "new1.js", "new2.js"]) write(LANE, f, "x\n");
await runGitAction(LANE, "discard", { paths: ["new*.js"], confirm: true });
assert.equal(fs.existsSync(path.join(LANE, "new*.js")), false);
assert.equal(fs.existsSync(path.join(LANE, "new1.js")), true);
assert.equal(fs.existsSync(path.join(LANE, "new2.js")), true);
});
it("staging 'app/[id]/page.js' does not stage app/i/page.js", async () => {
write(LANE, "app/[id]/page.js", "a\n");
write(LANE, "app/i/page.js", "b\n");
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "app");
write(LANE, "app/[id]/page.js", "a2\n");
write(LANE, "app/i/page.js", "b2\n");
await runGitAction(LANE, "stage", { paths: ["app/[id]/page.js"] });
assert.equal(g(LANE, "diff", "--cached", "--name-only"), "app/[id]/page.js\n");
});
it("a single-file diff of 'a[b].txt' carries only that file's hunks", async () => {
write(LANE, "a[b].txt", "one\n");
write(LANE, "ab.txt", "one\n");
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "brackets");
write(LANE, "a[b].txt", "one\nmine\n");
write(LANE, "ab.txt", "one\nOTHER\n");
const out = await collectChanges(LANE, { range: "work", file: "a[b].txt" });
assert.equal(JSON.stringify(out.files[0].hunks).includes("OTHER"), false);
assert.equal(out.files[0].hunks.length, 1);
});
});
describe("a lane whose cwd is a subdirectory of its repo", () => {
let SUB;
beforeEach(() => {
write(LANE, "pkg/in.txt", "one\n");
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "pkg");
SUB = path.join(LANE, "pkg");
write(LANE, "a.txt", "one\ntwo\nOUTSIDE EDIT\n");
write(LANE, "outside-new.txt", "no\n");
write(SUB, "in.txt", "one\ntwo\n");
write(SUB, "fresh.txt", "a\nb\nc\n");
});
it("lists only its own changes, lane-relative, with hunks", async () => {
const out = await collectChanges(SUB, { range: "work", full: true });
assert.deepEqual(
out.files.map((f) => f.path),
["fresh.txt", "in.txt"]
);
const tracked = out.files.find((f) => f.path === "in.txt");
assert.equal(tracked.added, 1);
assert.equal(tracked.hunks.length, 1);
const fresh = out.files.find((f) => f.path === "fresh.txt");
assert.equal(fresh.added, 3);
assert.equal(fresh.hunks[0].lines.length, 3);
});
it("branch and commit ranges are lane-relative and lane-scoped too", async () => {
g(LANE, "checkout", "-qb", "feat");
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "both");
const branch = await collectChanges(SUB, { range: "branch", base: "main", full: true });
assert.deepEqual(
branch.files.map((f) => f.path),
["fresh.txt", "in.txt"]
);
assert.equal(branch.files.find((f) => f.path === "in.txt").added, 1);
const commit = await collectChanges(SUB, { range: "commit", sha: "HEAD", file: "in.txt" });
assert.equal(commit.files[0].hunks.length, 1);
});
it("stage, unstage and discard work on lane-relative paths", async () => {
await runGitAction(SUB, "stage", { paths: ["in.txt", "fresh.txt"] });
assert.equal(g(LANE, "diff", "--cached", "--name-only"), "pkg/fresh.txt\npkg/in.txt\n");
await runGitAction(SUB, "unstage", { paths: ["in.txt"] });
assert.equal(g(LANE, "diff", "--cached", "--name-only"), "pkg/fresh.txt\n");
await runGitAction(SUB, "discard", { paths: ["in.txt"], confirm: true });
assert.equal(fs.readFileSync(path.join(SUB, "in.txt"), "utf8"), "one\n");
});
it("cannot name a change outside its cwd", async () => {
for (const p of ["../a.txt", "a.txt", "../outside-new.txt"]) {
await assert.rejects(
runGitAction(SUB, "discard", { paths: [p], confirm: true }),
{ code: "EBADPATH" },
p
);
}
assert.equal(fs.existsSync(path.join(LANE, "outside-new.txt")), true);
});
it("gitFacts (the lane card) counts and lists only the lane's own files, cwd-relative", async () => {
write(SUB, "mới tên.txt", "x\n");
const facts = await gitFacts(SUB);
const paths = facts.files.map((f) => f.path).sort();
assert.deepEqual(paths, ["fresh.txt", "in.txt", "mới tên.txt"]);
assert.equal(facts.dirty, 1);
assert.equal(facts.untracked, 2);
assert.equal(facts.files.find((f) => f.path === "in.txt").added, 1);
});
it("commit sweeps in only the lane's own files", async () => {
await runGitAction(SUB, "commit", { message: "lane only" });
assert.equal(
g(LANE, "show", "--name-only", "--format=", "HEAD"),
"pkg/fresh.txt\npkg/in.txt\n"
);
assert.match(g(LANE, "status", "--porcelain"), /a\.txt/);
});
it("refuses to commit while something OUTSIDE the lane is staged, and leaves it staged", async () => {
const before = g(LANE, "rev-parse", "HEAD");
g(LANE, "add", "a.txt");
for (const only of [undefined, "staged"]) {
g(SUB, "add", "in.txt");
await assert.rejects(runGitAction(SUB, "commit", { message: "lane only", only }), {
code: "ESTAGEDOUTSIDE",
});
}
assert.match(g(LANE, "diff", "--cached", "--name-only"), /^a\.txt$/m);
assert.equal(g(LANE, "rev-parse", "HEAD"), before);
});
});
describe("reads larger than the git() buffer report truncated, not missing", () => {
const BIG = `${"x".repeat(99)}\n`.repeat(100_000); // 10 MB
it("readFileAt at a rev", async () => {
write(LANE, "big.txt", BIG);
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "big");
const out = await readFileAt(LANE, "big.txt", "HEAD");
assert.equal(out.truncated, true);
assert.ok(out.lines.length > 1000);
});
it("a single-file diff", async () => {
write(LANE, "big.txt", "seed\n");
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "seed");
write(LANE, "big.txt", BIG);
const out = await collectChanges(LANE, { range: "work", file: "big.txt" });
assert.equal(out.files[0].truncated, true);
assert.ok(out.files[0].hunks[0].lines.length > 100);
});
});
describe("loop 2: secret file at any depth, symlinked .claude", () => {
const KEY = '{"env":{"ANTHROPIC_API_KEY":"sk-deep"}}';
it("a root lane cannot read a subdir lane's settings.local.json (work, rev, hunks)", async () => {
write(LANE, "sub/.claude/settings.local.json", KEY);
for (const p of ["sub/.claude/settings.local.json", "SUB/.Claude/Settings.Local.json"]) {
await assert.rejects(readFileAt(LANE, p, "work"), { code: "ESECRET" }, p);
}
const untracked = await collectChanges(LANE, { range: "work", full: true });
assert.equal(JSON.stringify(untracked).includes("sk-deep"), false);
g(LANE, "add", "-f", "sub/.claude/settings.local.json");
g(LANE, "commit", "-qm", "oops");
await assert.rejects(readFileAt(LANE, "sub/.claude/settings.local.json", "HEAD"), {
code: "ESECRET",
});
write(LANE, "sub/.claude/settings.local.json", KEY.replace("deep", "deep2"));
const tracked = await collectChanges(LANE, { range: "work", full: true });
assert.equal(JSON.stringify(tracked).includes("sk-deep"), false);
const commit = await collectChanges(LANE, { range: "commit", sha: "HEAD", full: true });
assert.equal(JSON.stringify(commit).includes("sk-deep"), false);
});
it("a symlinked .claude does not expose the key under its target's name", async () => {
write(LANE, "conf/settings.local.json", KEY);
fs.symlinkSync("conf", path.join(LANE, ".claude"));
await assert.rejects(readFileAt(LANE, "conf/settings.local.json", "work"), {
code: "ESECRET",
});
const untracked = await collectChanges(LANE, { range: "work", full: true });
assert.equal(JSON.stringify(untracked).includes("sk-deep"), false);
g(LANE, "add", "conf/settings.local.json");
g(LANE, "commit", "-qm", "conf");
await assert.rejects(readFileAt(LANE, "conf/settings.local.json", "HEAD"), {
code: "ESECRET",
});
write(LANE, "conf/settings.local.json", KEY.replace("deep", "deep2"));
const tracked = await collectChanges(LANE, { range: "work", full: true });
assert.equal(JSON.stringify(tracked).includes("sk-deep"), false);
});
});
describe("loop 2: subdir lane boundaries", () => {
let SUB;
beforeEach(() => {
write(LANE, "pkg/in.txt", "one\n");
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "pkg");
SUB = path.join(LANE, "pkg");
});
it("listCommits refuses a ?file= outside the lane", async () => {
const { listCommits } = require("../lib/git-diff");
for (const p of ["../a.txt", "/etc/passwd"]) {
await assert.rejects(listCommits(SUB, { file: p }), { code: "EBADPATH" }, p);
}
assert.equal((await listCommits(SUB, { file: "in.txt" })).length, 1);
});
it("commit (all) in a clean subdir lane of a dirty repo is ENOCHANGES", async () => {
write(LANE, "a.txt", "dirty outside\n");
await assert.rejects(runGitAction(SUB, "commit", { message: "x" }), { code: "ENOCHANGES" });
});
});
describe("loop 2: readFileAt rev=work reads at most the cap", () => {
it("never loads a whole huge file, still reports truncated", async () => {
write(LANE, "huge.txt", `${"y".repeat(99)}\n`.repeat(30_000)); // 3 MB
const orig = fs.promises.readFile;
fs.promises.readFile = async () => {
throw new Error("whole-file read");
};
try {
const out = await readFileAt(LANE, "huge.txt", "work");
assert.equal(out.truncated, true);
assert.ok(out.lines.length > 1000);
assert.ok(out.lines.join("\n").length <= 1_000_000);
} finally {
fs.promises.readFile = orig;
}
const small = await readFileAt(LANE, "a.txt", "work");
assert.deepEqual(small.lines, ["one", "two"]);
assert.equal(small.truncated, false);
});
});
describe("loop 2: push picks the right remote", () => {
const bare = (name) => {
const dir = path.join(ROOT, `${name}.git`);
g(ROOT, "init", "-q", "--bare", dir);
return dir;
};
const head = () => g(LANE, "rev-parse", "HEAD").trim();
it("pushes to the sole remote even when it is not origin", async () => {
const dir = bare("only");
g(LANE, "remote", "add", "backup", dir);
await runGitAction(LANE, "push");
assert.equal(g(dir, "rev-parse", "main").trim(), head());
});
it("pushes to the branch's upstream remote over origin", async () => {
const o = bare("o");
const u = bare("u");
g(LANE, "remote", "add", "origin", o);
g(LANE, "remote", "add", "fork", u);
g(LANE, "push", "-q", "-u", "fork", "HEAD");
write(LANE, "a.txt", "new\n");
g(LANE, "commit", "-qam", "next");
await runGitAction(LANE, "push");
assert.equal(g(u, "rev-parse", "main").trim(), head());
assert.throws(() => g(o, "rev-parse", "--verify", "-q", "main"));
});
it("falls back to origin among several remotes", async () => {
const o = bare("o");
g(LANE, "remote", "add", "fork", bare("f"));
g(LANE, "remote", "add", "origin", o);
await runGitAction(LANE, "push");
assert.equal(g(o, "rev-parse", "main").trim(), head());
});
it("ENOREMOTE with no remote, or several and no origin", async () => {
await assert.rejects(runGitAction(LANE, "push"), { code: "ENOREMOTE" });
g(LANE, "remote", "add", "a", bare("a"));
g(LANE, "remote", "add", "b", bare("b"));
await assert.rejects(runGitAction(LANE, "push"), { code: "ENOREMOTE" });
});
});
describe("loop 2: a claude and a codex lane on one cwd share one git-write lock", () => {
it("concurrent writes from both lanes never collide on index.lock", async () => {
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "api2.db");
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const server = await startServer(createApp(), 0);
const base = `http://127.0.0.1:${server.address().port}`;
const post = (url, body) =>
fetch(`${base}${url}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
try {
const ids = [];
for (const tool of ["claude", "codex"]) {
const r = await post("/api/lanes/ensure", { cwd: LANE, tool, title: tool }).then((x) =>
x.json()
);
ids.push(r.lane.id);
}
assert.notEqual(ids[0], ids[1]);
// A clean filter runs while `git add` HOLDS index.lock, so a write
// from the sibling lane in that window fails unless serialized.
g(LANE, "config", "filter.slow.clean", "sleep 1; cat");
write(LANE, ".gitattributes", "slow.txt filter=slow\n");
write(LANE, "slow.txt", "s\n");
write(LANE, "b.txt", "new\n");
const slow = post(`/api/lanes/${ids[0]}/git/stage`, { paths: ["slow.txt"] });
await new Promise((r) => setTimeout(r, 400));
const other = await post(`/api/lanes/${ids[1]}/git/stage`, { paths: ["b.txt"] });
const first = await slow;
assert.equal(first.status, 200, await first.text());
assert.equal(other.status, 200, await other.text());
} finally {
server.close();
}
});
});
describe("loop 3: a subdirectory lane's key behind its own symlinked .claude", () => {
it("is refused to a root lane by its target name, on disk and at a committed rev", async () => {
write(LANE, "sub/conf/settings.local.json", '{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-SUB"}}');
fs.symlinkSync("conf", path.join(LANE, "sub/.claude"));
await assert.rejects(readFileAt(LANE, "sub/conf/settings.local.json", "work"), {
code: "ESECRET",
});
g(LANE, "add", "-f", "sub/conf/settings.local.json");
g(LANE, "commit", "-qm", "oops");
fs.rmSync(path.join(LANE, "sub"), { recursive: true });
await assert.rejects(readFileAt(LANE, "sub/conf/settings.local.json", "HEAD"), {
code: "ESECRET",
});
});
});
describe("loop 3: a staged rename INTO a subdirectory lane", () => {
it("is refused by ESTAGEDOUTSIDE, since it also deletes the old path outside the lane", async () => {
write(LANE, "shared/x.js", "export const x = 1;\n");
write(LANE, "pkg/keep.txt", "k\n");
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "base");
g(LANE, "mv", "shared/x.js", "pkg/x.js");
await assert.rejects(
runGitAction(path.join(LANE, "pkg"), "commit", { message: "move", only: "staged" }),
{ code: "ESTAGEDOUTSIDE" }
);
});
});
describe("one numstat parser for the Changes panel and the lane card", () => {
it("keys a rename by its new path and a binary file with null counts", () => {
// Real `git diff --numstat -z` shapes: a rename is "a\td\t\0old\0new\0",
// a binary file is "-\t-\tpath\0". `git show --format=` adds a leading \n.
const stats = parseNumstat("\n1\t2\t\0old name.txt\0new name.txt\0-\t-\tlogo.png\0");
assert.deepEqual([...stats.keys()], ["new name.txt", "logo.png"]);
assert.deepEqual(stats.get("new name.txt"), { added: 1, deleted: 2, binary: false });
assert.deepEqual(stats.get("logo.png"), { added: null, deleted: null, binary: true });
});
it("gitFacts lists a staged rename and a binary file with the same counts, no extra field", async () => {
g(LANE, "mv", "old name.txt", "new name.txt");
write(LANE, "new name.txt", "keep me\nplease\nmore\n");
fs.writeFileSync(path.join(LANE, "logo.png"), Buffer.from([0, 1, 2, 0, 255]));
g(LANE, "add", "-A");
const { files } = await gitFacts(LANE);
const byPath = Object.fromEntries(files.map((f) => [f.path, f]));
assert.deepEqual(byPath["new name.txt"], {
path: "new name.txt",
status: "R ",
added: 1,
deleted: 0,
});
assert.deepEqual(byPath["logo.png"], {
path: "logo.png",
status: "A ",
added: null,
deleted: null,
});
});
});
+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",
+274 -69
View File
@@ -16,13 +16,39 @@
* total) because this rides an HTTP response a browser has to render: a lane
* mid-`npm install` or holding a committed lockfile would otherwise ship
* megabytes of patch to a React list. A cap that bites sets `truncated`.
*
* The lane's cwd is the boundary, even when it is a SUBDIRECTORY of its repo:
* every path in and out is cwd-relative, status entries outside the cwd are
* dropped, and every git call treats a path as a literal file name, never a
* pathspec glob (`GIT_LITERAL_PATHSPECS`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const path = require("node:path");
const { git } = require("./worktree");
const worktree = require("./worktree");
/** Every git call here: argv only, and a path argument is a literal name. */
const git = (dir, args, opts) => worktree.git(dir, args, { literal: true, ...opts });
/**
* A git READ whose output this module caps itself: the buffer is sized past
* the cap (x4, a UTF-8 char is at most 4 bytes, plus headroom) so git's own
* buffer can never be what fails first, and an overflow comes back as the
* partial output — longer than `capChars`, so the caller's cap marks it
* truncated — instead of an error read as "missing".
*/
async function readCapped(dir, args, capChars) {
try {
return (await git(dir, args, { maxBuffer: capChars * 4 + 64 * 1024 })).stdout;
} catch (err) {
if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" && err.git?.stdout) {
return String(err.git.stdout);
}
throw err;
}
}
/** Most files one response names. Beyond this the list is `truncated`. */
const MAX_FILES = 300;
@@ -36,6 +62,8 @@ const MAX_UNTRACKED_BYTES = 1_000_000;
const MAX_FILE_VIEW_BYTES = 1_000_000;
/** Most paths `listPaths`/`listTree` answer with. */
const MAX_PATHS = 500;
/** Characters of `ls-files -z` read before the listing counts as truncated. */
const MAX_LS_CHARS = 16 * 1024 * 1024;
/** Lines of unchanged context around a hunk, and the ceiling "expand all"
* is clamped to — a whole large file rendered as context is the one way this
* panel can be made to ship more bytes than the caps above allow. */
@@ -47,6 +75,104 @@ const VALID_RANGES = new Set(["work", "branch", "commit"]);
* ref name without the characters that would let it read as an option or a
* pathspec. `git` is called with an argv array, so this is belt-and-braces. */
const SAFE_REV = /^[A-Za-z0-9][A-Za-z0-9._\/-]{0,200}$/;
/** The file whose CONTENT this module never returns, at any rev and at ANY
* depth: `settings.local.json` is where router-apply.js writes a lane's AI
* Router API key, and key material never leaves the server. Any depth,
* because a lane at the repo root sees a subdirectory lane's key as
* `sub/.claude/settings.local.json`. Lower-cased: a case-insensitive
* filesystem (macOS) opens it under any casing. */
const SECRET_FILE = ".claude/settings.local.json";
const SECRET_BASENAME = path.posix.basename(SECRET_FILE);
/** Whether a lane-relative path names a secret file, however it is spelled. */
function isSecret(rel) {
const norm = path.posix
.normalize(String(rel).replace(/\\/g, "/"))
.replace(/^\.\//, "")
.toLowerCase();
// By BASENAME, in any directory: a `.claude` symlinked to `conf/` stores the
// key as `conf/settings.local.json`, and a lane above that one (or a commit
// of that target) never spells it through `.claude/`. A file with this name
// is Claude Code's local settings in practice, so nothing legitimate is lost.
return norm === SECRET_FILE || path.posix.basename(norm) === SECRET_BASENAME;
}
/**
* Whether `file` in the lane is a secret by what it RESOLVES to, not by how
* it is spelled: a symlinked `.claude` (→ `conf/`) makes the key file
* `conf/settings.local.json`, which no name check catches. Secret when the
* real path, relative to the lane's real root, is one by `isSecret`, or when
* it is the very file `<lane>/.claude/settings.local.json` resolves to. A
* path that does not exist on disk is judged by its name alone.
*/
async function isSecretOnDisk(dir, file) {
if (isSecret(file)) return true;
let root;
let real;
try {
root = await fs.promises.realpath(dir);
real = await fs.promises.realpath(path.resolve(root, file));
} catch {
return false;
}
if (isSecret(path.relative(root, real))) return true;
try {
return real === (await fs.promises.realpath(path.join(root, SECRET_FILE)));
} catch {
return false;
}
}
/**
* The ONE lexical guard for a caller-supplied path, at every rev: a relative
* path with no `..` segment, normalised (`./a//b` → `a/b`) so the secret
* check and git both see the lane-relative spelling. Throws EBADPATH.
*/
function lanePath(file) {
if (typeof file !== "string" || !file || file.includes("\0")) {
throw badRequest("EBADPATH", "path must be a relative path inside the lane");
}
// A backslash is an ordinary file-name byte to git on POSIX, not a separator.
if (file.startsWith("/") || file.split("/").includes("..")) {
throw badRequest("EBADPATH", "path must be a relative path inside the lane");
}
const norm = path.posix
.normalize(file)
.replace(/^(\.\/)+/, "")
.replace(/\/+$/, "");
if (!norm || norm === ".") throw badRequest("EBADPATH", "path must name a file");
return norm;
}
/**
* Resolve `file` inside the lane for a CONTENT read: realpath-confined to the
* lane's own realpath, so neither `..` nor a symlink can walk out of it, and
* refused when either the requested or the resolved path is a secret file.
* Throws EBADPATH / ESECRET / ENOFILE.
*/
async function resolveInLane(dir, file) {
if (isSecret(file))
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
const escapes = (base, target) => {
const r = path.relative(base, target);
return !r || r === ".." || r.startsWith(`..${path.sep}`) || path.isAbsolute(r);
};
// Lexically first, so `../x` is EBADPATH whether or not x exists.
if (escapes(path.resolve(dir), path.resolve(dir, file))) {
throw badRequest("EBADPATH", "path escapes the lane directory");
}
const root = await fs.promises.realpath(dir);
let real;
try {
real = await fs.promises.realpath(path.resolve(root, file));
} catch {
throw badRequest("ENOFILE", "no such file in the working tree");
}
if (escapes(root, real)) throw badRequest("EBADPATH", "path escapes the lane directory");
if (await isSecretOnDisk(root, file))
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
return real;
}
/** Throw a coded error the routes map onto a 400. */
function badRequest(code, message) {
@@ -131,15 +257,26 @@ async function resolveBaseRef(dir, base) {
return null;
}
/** `git diff --numstat` output → Map<path, {added, deleted, binary}>. */
/**
* `git diff --numstat -z` output → Map<path, {added, deleted, binary}>. `-z`
* because without it git C-quotes any path with a space, a quote or a
* non-ASCII byte (`"t\303\240i.txt"`), which then keys nothing. A rename is
* `a\td\t\0old\0new\0`; the path that exists now is the key. Shared with
* worktree.js's `laneFiles` (the lane card), so the card and the Changes panel
* count a file the same way.
*/
function parseNumstat(stdout) {
const stats = new Map();
for (const line of stdout.split("\n")) {
const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
const tokens = stdout.split("\0");
for (let i = 0; i < tokens.length; i++) {
const m = tokens[i].replace(/^\n/, "").match(/^(\d+|-)\t(\d+|-)\t(.*)$/s);
if (!m) continue;
// A rename reports "old => new" (or "dir/{a => b}/file"); the path that
// exists now is what every other source here is keyed by.
const file = m[3].includes(" => ") ? renameTarget(m[3]) : m[3];
let file = m[3];
if (!file) {
file = tokens[i + 2];
i += 2;
}
if (!file) continue;
stats.set(file, {
added: m[1] === "-" ? null : Number(m[1]),
deleted: m[2] === "-" ? null : Number(m[2]),
@@ -149,11 +286,48 @@ function parseNumstat(stdout) {
return stats;
}
/** `dir/{old => new}/f.js` or `old.js => new.js` → the post-rename path. */
function renameTarget(spec) {
const braced = spec.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
if (braced) return `${braced[1]}${braced[3]}${braced[4]}`.replace(/\/\//g, "/");
return spec.split(" => ").pop();
/**
* `git status --porcelain=v1 -z` → [{code, path, from}]. Each record is
* `XY path\0`; a rename/copy carries its ORIGINAL path as the next record.
* Shared with git-ops.js so the paths a write may name are the paths shown.
*/
function parsePorcelainZ(stdout) {
const entries = [];
const tokens = stdout.split("\0");
for (let i = 0; i < tokens.length; i++) {
const rec = tokens[i];
if (rec.length < 4) continue;
const code = rec.slice(0, 2);
const entry = { code, path: rec.slice(3), from: null };
if (code[0] === "R" || code[0] === "C" || code[1] === "R" || code[1] === "C") {
entry.from = tokens[++i] || null;
}
entries.push(entry);
}
return entries;
}
/**
* `git status` for the LANE: porcelain paths are always repo-ROOT-relative,
* while every pathspec and every fs read here resolves from the lane's cwd.
* So entries are cut down to the ones inside the cwd and re-spelled relative
* to it — a subdirectory lane neither shows nor can name a change outside
* itself. A rename whose original path is outside keeps `from: null`.
* Shared with git-ops.js so the paths a write may name are the paths shown.
*/
async function laneStatus(dir) {
const [status, prefix] = await Promise.all([
git(dir, ["status", "--porcelain=v1", "-z", "--untracked-files=normal"]),
git(dir, ["rev-parse", "--show-prefix"]),
]);
const pre = prefix.stdout.trim();
const inside = (p) => (p && p.startsWith(pre) ? p.slice(pre.length) || null : null);
const entries = [];
for (const e of parsePorcelainZ(status.stdout)) {
const rel = inside(e.path);
if (rel) entries.push({ code: e.code, path: rel, from: inside(e.from) });
}
return entries;
}
/**
@@ -165,22 +339,20 @@ const CONFLICT_CODES = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
/** Working-tree summary: porcelain status, plus numstat deltas for tracked files. */
async function summarizeWork(dir) {
const [status, numstat] = await Promise.all([
git(dir, ["status", "--porcelain=v1", "--untracked-files=normal"]),
git(dir, ["diff", "--numstat", "HEAD"]).catch(() => ({ stdout: "" })),
const [entries, numstat] = await Promise.all([
laneStatus(dir),
// --relative: cwd-scoped, cwd-relative paths, so they key the status ones.
git(dir, ["diff", "--numstat", "-z", "--relative", "HEAD"]).catch(() => ({ stdout: "" })),
]);
const stats = parseNumstat(numstat.stdout);
const files = [];
for (const line of status.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, "");
for (const { code, path: file } of entries) {
const untracked = code === "??";
const stat = stats.get(file);
// One read per untracked file, not two: its line count and its
// binary-ness are the same buffer answered twice.
const fresh = untracked ? untrackedFacts(dir, file) : null;
// Sequential on purpose: `normal` folds untracked dirs, so this is short.
const fresh = untracked ? await untrackedFacts(dir, file) : null;
files.push({
path: file,
status: code,
@@ -208,11 +380,12 @@ async function summarizeWork(dir) {
* everywhere else in this response. A file over the cap counts as binary so
* nothing downstream tries to render it.
*/
function untrackedFacts(dir, file) {
async function untrackedFacts(dir, file) {
try {
const abs = path.join(dir, file);
if (fs.statSync(abs).size > MAX_UNTRACKED_BYTES) return { lines: null, binary: true };
const buf = fs.readFileSync(abs);
const abs = await resolveInLane(dir, file);
if ((await fs.promises.stat(abs)).size > MAX_UNTRACKED_BYTES)
return { lines: null, binary: true };
const buf = await fs.promises.readFile(abs);
if (looksBinary(buf)) return { lines: null, binary: true };
const text = buf.toString("utf8");
if (!text) return { lines: 0, binary: false };
@@ -220,7 +393,8 @@ function untrackedFacts(dir, file) {
return { lines, binary: false };
} catch {
// A directory entry (git reports untracked DIRS with a trailing slash), a
// dangling symlink, a file deleted between the status call and this read.
// dangling symlink, a file deleted between the status call and this read,
// or one resolveInLane refuses (a secret, a symlink out of the lane).
return { lines: null, binary: false };
}
}
@@ -229,8 +403,8 @@ function untrackedFacts(dir, file) {
async function summarizeBranch(dir, baseRef) {
const spec = `${baseRef}...HEAD`;
const [nameStatus, numstat] = await Promise.all([
git(dir, ["diff", "--name-status", spec]),
git(dir, ["diff", "--numstat", spec]),
git(dir, ["diff", "--name-status", "-z", "--relative", spec]),
git(dir, ["diff", "--numstat", "-z", "--relative", spec]),
]);
return parseNameStatus(nameStatus.stdout, parseNumstat(numstat.stdout));
}
@@ -242,24 +416,26 @@ async function summarizeBranch(dir, baseRef) {
* a reader scrolling a branch's history.
*/
async function summarizeCommit(dir, sha) {
const base = ["show", "--format=", "-m", "--first-parent", sha];
const base = ["show", "--format=", "-m", "--first-parent", "--relative", sha];
const [nameStatus, numstat] = await Promise.all([
git(dir, [...base, "--name-status"]),
git(dir, [...base, "--numstat"]),
git(dir, [...base, "--name-status", "-z"]),
git(dir, [...base, "--numstat", "-z"]),
]);
return parseNameStatus(nameStatus.stdout, parseNumstat(numstat.stdout));
}
/** Shared `--name-status` reader for the ranges that have no working tree. */
/** Shared `--name-status -z` reader for the ranges that have no working tree. */
function parseNameStatus(stdout, stats) {
const files = [];
const seen = new Set();
for (const line of stdout.split("\n")) {
if (!line.trim()) continue;
const parts = line.split("\t");
const letter = parts[0][0];
// A rename/copy line is "R100\told\tnew" — the new path is the live one.
const file = parts.length > 2 ? parts[2] : parts[1];
const tokens = stdout.split("\0");
for (let i = 0; i < tokens.length; i++) {
const status = tokens[i].replace(/^\n/, "");
if (!/^[A-Z]/.test(status)) continue;
const letter = status[0];
// A rename/copy is "R100\0old\0new" — the new path is the live one.
const file = letter === "R" || letter === "C" ? tokens[(i += 2)] : tokens[++i];
if (!file) continue;
// `-m` prints one diff per parent for a merge; --first-parent keeps it to
// one, but a defensive de-dup costs nothing and a doubled row is a lie.
if (seen.has(file)) continue;
@@ -282,6 +458,9 @@ function parseNameStatus(stdout, stats) {
/** One file's patch text, for any range. Empty string when there is none. */
async function patchFor(dir, { range, baseRef, sha, file, untracked, context }) {
// A secret file may be tracked by mistake; its hunks would carry the key —
// under its own name, or under a symlinked `.claude`'s target name.
if (await isSecretOnDisk(dir, file)) return "";
if (untracked) return synthesizeAddedPatch(dir, file);
const u = `-U${context}`;
const args =
@@ -290,10 +469,7 @@ async function patchFor(dir, { range, baseRef, sha, file, untracked, context })
: range === "branch"
? ["diff", u, `${baseRef}...HEAD`, "--", file]
: ["diff", u, "HEAD", "--", file];
const result = await git(dir, [...args, "--no-color", "--no-ext-diff"]).catch(() => ({
stdout: "",
}));
return result.stdout;
return readCapped(dir, [...args, "--no-color", "--no-ext-diff"], MAX_FILE_BYTES).catch(() => "");
}
/**
@@ -302,10 +478,10 @@ async function patchFor(dir, { range, baseRef, sha, file, untracked, context })
* --no-index /dev/null <file>` would print, without the non-zero exit that
* wrapper treats as a failure.
*/
function synthesizeAddedPatch(dir, file) {
async function synthesizeAddedPatch(dir, file) {
let buf;
try {
buf = fs.readFileSync(path.join(dir, file));
buf = await fs.promises.readFile(await resolveInLane(dir, file));
} catch {
return "";
}
@@ -440,7 +616,9 @@ async function listCommits(dir, { limit = 30, file = null } = {}) {
const n = Math.min(Math.max(Number(limit) || 30, 1), 200);
const format = ["%H", "%h", "%s", "%an", "%aI", "%D"].join("%x1f");
const args = ["log", `-${n}`, `--format=${format}%x1e`];
if (file) args.push("--", file);
// Through the same lexical guard as every other path: in a subdirectory
// lane `../x` would list commit metadata for a path outside the lane.
if (file) args.push("--", lanePath(file));
const result = await git(dir, args);
const commits = [];
for (const record of result.stdout.split("\x1e")) {
@@ -471,42 +649,60 @@ async function listCommits(dir, { limit = 30, file = null } = {}) {
* `rev` is `work` (the file on disk, including an untracked one), `HEAD`, or a
* commit-ish. The path is resolved against git's own index/tree rather than
* trusted: for `work` it is realpath-confined to the lane directory, because
* that is the one branch that reads the filesystem directly.
* that is the one branch that reads the filesystem directly. A SECRET_PATHS
* file is refused (ESECRET) at every rev.
*/
async function readFileAt(dir, file, rev = "work") {
if (typeof file !== "string" || !file || file.startsWith("/") || file.includes("\0")) {
throw badRequest("EBADPATH", "path must be a relative path inside the lane");
}
async function readFileAt(dir, requested, rev = "work") {
// Lexical guard FIRST and for every rev: `git show <rev>:<path>` reads the
// whole repo, so a `../` here is a committed file outside the lane.
const file = lanePath(requested);
if (rev !== "work" && !SAFE_REV.test(rev)) {
throw badRequest("EBADREV", "rev is not a valid revision");
}
// By resolved path too, at every rev: the key committed under a symlinked
// `.claude`'s target name is still the key.
if (await isSecretOnDisk(dir, file)) {
throw badRequest("ESECRET", "that file holds secrets and is not readable here");
}
let text;
let binary = false;
if (rev === "work") {
const abs = path.resolve(dir, file);
// `..` in the request must not walk out of the lane. Compare resolved
// paths, not the string the caller sent.
const root = path.resolve(dir);
if (abs !== root && !abs.startsWith(root + path.sep)) {
throw badRequest("EBADPATH", "path escapes the lane directory");
}
// Neither `..` nor a symlink may walk out of the lane: resolveInLane
// compares REAL paths, not the string the caller sent.
const abs = await resolveInLane(dir, file);
let buf;
try {
buf = fs.readFileSync(abs);
// Only cap+1 bytes: a multi-gigabyte log in the lane must not be read
// whole to answer with its first megabyte. The extra byte is what
// tells the cap below the file was longer.
const fh = await fs.promises.open(abs, "r");
try {
const size = Math.min((await fh.stat()).size, MAX_FILE_VIEW_BYTES + 1);
buf = Buffer.alloc(size);
let off = 0;
while (off < size) {
const { bytesRead } = await fh.read(buf, off, size - off, off);
if (!bytesRead) break;
off += bytesRead;
}
buf = buf.subarray(0, off);
} finally {
await fh.close();
}
} catch {
throw badRequest("ENOFILE", "no such file in the working tree");
}
binary = looksBinary(buf);
text = binary ? "" : buf.toString("utf8");
} else {
let out;
try {
out = await git(dir, ["show", `${rev}:${file}`]);
// `./`: resolve from the lane's cwd, not the repo root (a bare
// `<rev>:<path>` is root-relative) — `file` has no `..`, so it stays in.
text = await readCapped(dir, ["show", `${rev}:./${file}`], MAX_FILE_VIEW_BYTES);
} catch {
throw badRequest("ENOFILE", "no such file at that revision");
}
text = out.stdout;
binary = text.includes("\0");
if (binary) text = "";
}
@@ -530,12 +726,11 @@ async function readFileAt(dir, file, rev = "work") {
* skip-list has to be maintained here.
*/
async function listPaths(dir, { q = "", limit = MAX_PATHS } = {}) {
const result = await git(dir, ["ls-files", "-z"]);
const { all, cut } = await lsFiles(dir, []);
const needle = String(q || "")
.toLowerCase()
.trim();
const cap = Math.min(Math.max(Number(limit) || MAX_PATHS, 1), MAX_PATHS);
const all = result.stdout.split("\0").filter(Boolean);
const hits = needle ? all.filter((p) => p.toLowerCase().includes(needle)) : all;
// A basename hit beats a directory hit: typing "api" wants `api.ts`, not
// every file under `src/api/`.
@@ -546,7 +741,16 @@ async function listPaths(dir, { q = "", limit = MAX_PATHS } = {}) {
return ab - bb || a.length - b.length || a.localeCompare(b);
});
}
return { paths: hits.slice(0, cap), truncated: hits.length > cap, total: hits.length };
return { paths: hits.slice(0, cap), truncated: cut || hits.length > cap, total: hits.length };
}
/** `git ls-files -z [args]` → paths, and whether a huge listing was cut short. */
async function lsFiles(dir, args) {
const out = await readCapped(dir, ["ls-files", "-z", ...args], MAX_LS_CHARS);
const all = out.split("\0");
// A complete listing ends in NUL (last token ""); a cut one ends mid-path.
const cut = all.pop() !== "";
return { all: all.filter(Boolean), cut };
}
/**
@@ -560,13 +764,11 @@ async function listTree(dir, { subdir = "" } = {}) {
.replace(/^\/+|\/+$/g, "")
.trim();
if (clean.split("/").includes("..")) throw badRequest("EBADPATH", "path escapes the lane");
const args = ["ls-files", "-z"];
if (clean) args.push("--", `${clean}/`);
const result = await git(dir, args);
const { all, cut } = await lsFiles(dir, clean ? ["--", `${clean}/`] : []);
const prefix = clean ? `${clean}/` : "";
const dirs = new Set();
const files = [];
for (const p of result.stdout.split("\0").filter(Boolean)) {
for (const p of all) {
const rest = p.slice(prefix.length);
const slash = rest.indexOf("/");
if (slash === -1) files.push({ name: rest, path: p, dir: false });
@@ -579,15 +781,18 @@ async function listTree(dir, { subdir = "" } = {}) {
return {
path: clean,
entries: entries.slice(0, MAX_PATHS),
truncated: entries.length > MAX_PATHS,
truncated: cut || entries.length > MAX_PATHS,
};
}
module.exports = {
collectChanges,
laneStatus,
listCommits,
listPaths,
listTree,
parseNumstat,
parsePorcelainZ,
parseUnifiedDiff,
readFileAt,
resolveBaseRef,
+86 -18
View File
@@ -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.
+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)) };
+16 -5
View File
@@ -511,7 +511,7 @@ const paths = {
tags: ["Lanes"],
summary: "The lane's recent commits",
description:
"Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path. Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.",
"Newest first, up to limit (default 30, max 200). file= narrows it to the commits touching one path (relative, no `..`, else 400 EBADPATH). Reading a commit's diff is GET /api/lanes/{id}/git/changes?range=commit&sha=. Read-only, no same-origin guard.",
operationId: "listLaneCommits",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
@@ -520,6 +520,7 @@ const paths = {
],
responses: {
200: { description: "{available, commits:[{sha, short, subject, author, date, refs}]}." },
400: { description: "EBADPATH (file= is absolute or has a `..` segment)." },
404: { description: "Lane not found." },
},
},
@@ -538,7 +539,13 @@ const paths = {
],
responses: {
200: { description: "{available, path, rev, binary, truncated, lines}." },
400: { description: "EBADPATH, EBADREV or ENOFILE." },
400: {
description: "EBADPATH (incl. a symlink resolving outside the lane), EBADREV or ENOFILE.",
},
403: {
description:
"ESECRET — `.claude/settings.local.json` (the AI Router key) is refused at every rev, at any depth, and under any path that resolves to it (a symlinked `.claude`).",
},
404: { description: "Lane not found." },
},
},
@@ -597,7 +604,7 @@ const paths = {
tags: ["Lanes"],
summary: "One git write on the lane's working copy",
description:
'fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u origin HEAD, so only the branch the lane is on), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), commit (git add -A then commit -m <message>, or only:"staged" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent\'s half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per lane under the lane lock. Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.',
"fetch (--all --prune), pull (ALWAYS --ff-only — a diverged branch is reported, never merged), push (-u <remote> HEAD, so only the branch the lane is on; remote = the branch's upstream remote, else the only remote, else origin, else 400 ENOREMOTE), stage/unstage (per file), discard (per file; irreversible, so it REQUIRES confirm:true), commit (git add -A then commit -m <message>, or only:\"staged\" to take the index as it stands), switch (git switch, -c to create; refuses a dirty tree so an agent's half-finished work is never carried onto another branch), and branch-create / branch-delete (create never switches; delete is `git branch -d`, refuses main/master/development/develop, the current branch and one held by another worktree). A path in the body must be one `git status` is reporting as changed, so no path outside the lane is reachable. Guarded and serialized per git index (the lane lock keyed on the worktree's git dir, so two lanes sharing one index cannot collide on index.lock). Deliberately no merge, rebase, reset or stash: reset stays behind the destroy guard on POST /api/lanes/{id}/reset. Network actions are killed after 60s.",
operationId: "runLaneGitAction",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } },
@@ -659,10 +666,14 @@ const paths = {
200: { description: "{action, ok:true, output} — git's own stdout+stderr." },
400: {
description:
"EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY or ENOCHANGES.",
"EBADACTION, EBADMESSAGE, EBADBRANCH, EBADPATH, ECONFIRM, EPROTECTED, ENOBRANCH, EBRANCHEXISTS, EBRANCHBUSY, EDIRTY, ENOCHANGES, ESTAGEDOUTSIDE or ENOREMOTE.",
},
404: { description: "Lane not found." },
500: { description: "git failed; its stderr is appended to error.message." },
409: {
description:
"EGIT — git ran and refused (a rejected push, a credential failure, a diverged --ff-only pull); its stderr is appended to error.message.",
},
500: { description: "Unexpected server-side failure." },
},
},
},
+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);