feat(lanes): stage/undo one hunk at a time, sticky review file headers

Review and the single-file view gain per-hunk Stage and Undo
(stage-hunk / discard-hunk: the hunk is named by its @@ line and
re-matched against a fresh diff, then git apply --cached / -R; undo
needs confirm: true and two clicks). Each review file header stays
pinned while its diff scrolls and carries the file's own
stage/unstage/discard.
This commit is contained in:
2026-09-24 14:38:48 +07:00
parent 0737786faf
commit cfe832383c
17 changed files with 546 additions and 97 deletions
+1 -1
View File
@@ -369,7 +369,7 @@ graph TD
| `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — AgentHub stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and AgentHub can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything | | `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — AgentHub stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and AgentHub can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything |
| `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected | | `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected |
| `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`<base>...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` | | `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`<base>...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` |
| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. Deliberately the boring half of git — no merge, rebase, reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (`reset` stays behind worktree.js's three-check destroy guard). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout | | `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff; `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. Deliberately the boring half of git — no merge, rebase, reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (`reset` stays behind worktree.js's three-check destroy guard). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout |
| `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `agenthub lanes proof-link` | | `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `agenthub lanes proof-link` |
| `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `agenthub lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive | | `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `agenthub lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive |
+2 -2
View File
File diff suppressed because one or more lines are too long
+138 -38
View File
@@ -41,6 +41,7 @@ import {
RefreshCw, RefreshCw,
Rows3, Rows3,
Trash2, Trash2,
Undo2,
UnfoldVertical, UnfoldVertical,
} from "lucide-react"; } from "lucide-react";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
@@ -51,6 +52,7 @@ import type {
LaneChangeRange, LaneChangeRange,
LaneChanges, LaneChanges,
LaneCommit, LaneCommit,
LaneDiffHunk,
LaneTreeEntry, LaneTreeEntry,
} from "../../lib/types"; } from "../../lib/types";
@@ -192,6 +194,62 @@ function IconButton({
); );
} }
type FileAct = (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => void;
/** A work-range file's stage / unstage / discard buttons — the overview row's
* and the review header's. */
function FileActions({
f,
busy,
armed,
onAct,
onArm,
}: {
f: LaneChangeFile;
busy: boolean;
armed: boolean;
onAct: FileAct;
onArm: (path: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
return (
<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>
);
}
/** One overview row. At module scope on purpose: declared inside the panel it /** 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 * was a new component type every render, so each poll and each keystroke in
* the commit box remounted every row (focus dropped to <body>). */ * the commit box remounted every row (focus dropped to <body>). */
@@ -210,7 +268,7 @@ function ChangeRow({
/** This row's discard has been clicked once and awaits the second click. */ /** This row's discard has been clicked once and awaits the second click. */
armed: boolean; armed: boolean;
onOpen: (path: string) => void; onOpen: (path: string) => void;
onAct: (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => void; onAct: FileAct;
onArm: (path: string) => void; onArm: (path: string) => void;
}) { }) {
const { t } = useTranslation(["lanes"]); const { t } = useTranslation(["lanes"]);
@@ -241,40 +299,7 @@ function ChangeRow({
<Delta added={f.added} deleted={f.deleted} binary={f.binary} /> <Delta added={f.added} deleted={f.deleted} binary={f.binary} />
</button> </button>
{range === "work" && ( {range === "work" && (
<span className="flex shrink-0 items-center gap-0.5"> <FileActions f={f} busy={busy} armed={armed} onAct={onAct} onArm={onArm} />
{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> </li>
); );
@@ -316,6 +341,8 @@ export function LaneChangesPanel({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null); const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
// The hunk whose Undo was clicked once: `${path}\n${header}`.
const [armedHunk, setArmedHunk] = useState<string | null>(null);
// Files folded shut in review mode. A change-set is read file by file, and // Files folded shut in review mode. A change-set is read file by file, and
// a file already read is in the way of the next one — so the set is what is // a file already read is in the way of the next one — so the set is what is
// COLLAPSED, and a file arriving later is open by default. // COLLAPSED, and a file arriving later is open by default.
@@ -551,6 +578,62 @@ export function LaneChangesPanel({
} }
}; };
// One hunk, matched by its `@@` line against a fresh diff at the context
// this diff was read with — so the server never applies a stale patch.
const actHunk = async (action: "stage-hunk" | "discard-hunk", path: string, hunk: string) => {
const lane = laneId;
setBusy(true);
setError(null);
try {
await api.lanes.gitAction(lane, action, {
path,
hunk,
context: data?.available ? data.context : undefined,
...(action === "discard-hunk" ? { confirm: true } : {}),
});
if (laneNow.current !== lane) return;
setArmedHunk(null);
refreshGit();
await load();
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setBusy(false);
}
};
// Stage / Undo on each hunk of a file with unstaged changes. The diff is
// HEAD → working tree, so a staged-only file gets none: undoing its tree
// would leave the index holding the change.
const hunkActions = (f: LaneChangeFile) =>
range === "work" && f.unstaged && !f.untracked && !f.binary
? (hunk: LaneDiffHunk) => {
const key = `${f.path}\n${hunk.header}`;
const armed = armedHunk === key;
return (
<>
<IconButton
label={t("changes.stageHunk")}
disabled={busy}
onClick={() => void actHunk("stage-hunk", f.path, hunk.header)}
>
<Plus className="h-3 w-3" />
</IconButton>
<IconButton
label={armed ? t("changes.undoHunkConfirm") : t("changes.undoHunk")}
disabled={busy}
danger
onClick={() =>
armed ? void actHunk("discard-hunk", f.path, hunk.header) : setArmedHunk(key)
}
>
<Undo2 className={`h-3 w-3 ${armed ? "text-status-danger" : ""}`} />
</IconButton>
</>
);
}
: undefined;
const commitStaged = async () => { const commitStaged = async () => {
const lane = laneId; const lane = laneId;
setBusy(true); setBusy(true);
@@ -972,7 +1055,12 @@ export function LaneChangesPanel({
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p> <p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
) )
) : current ? ( ) : current ? (
<LaneDiffView file={current} layout={layout} fill /> <LaneDiffView
file={current}
layout={layout}
fill
hunkActions={whole ? undefined : hunkActions(current)}
/>
) : null} ) : null}
</div> </div>
) : ( ) : (
@@ -1006,7 +1094,7 @@ export function LaneChangesPanel({
? t("changes.expandFile", { path: f.path }) ? t("changes.expandFile", { path: f.path })
: t("changes.collapseFile", { path: f.path }) : t("changes.collapseFile", { path: f.path })
} }
className={`flex cursor-pointer items-baseline gap-1.5 bg-surface-1 px-2 py-1 hover:bg-surface-2 ${ className={`sticky top-0 z-10 flex cursor-pointer items-baseline gap-1.5 rounded-t bg-surface-1 px-2 py-1 hover:bg-surface-2 ${
shut ? "" : "border-b border-border-light" shut ? "" : "border-b border-border-light"
}`} }`}
> >
@@ -1032,8 +1120,20 @@ export function LaneChangesPanel({
{f.path} {f.path}
</button> </button>
<Delta added={f.added} deleted={f.deleted} binary={f.binary} /> <Delta added={f.added} deleted={f.deleted} binary={f.binary} />
{range === "work" && (
// The header folds on click; its buttons must not.
<span className="self-center" onClick={(e) => e.stopPropagation()}>
<FileActions
f={f}
busy={busy}
armed={confirmDiscard === f.path}
onAct={(...a) => void act(...a)}
onArm={setConfirmDiscard}
/>
</span>
)}
</div> </div>
{!shut && <LaneDiffView file={f} layout={layout} />} {!shut && <LaneDiffView file={f} layout={layout} hunkActions={hunkActions(f)} />}
</div> </div>
); );
})} })}
+76 -44
View File
@@ -21,7 +21,15 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
import { useMemo, useRef, useState, type CSSProperties } from "react"; import {
createContext,
useContext,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight"; import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types"; import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
@@ -276,19 +284,38 @@ function markerClass(type: LaneDiffLine["type"]) {
return "text-fg-muted"; return "text-fg-muted";
} }
function HunkHeader({ hunk, fixed }: { hunk: LaneDiffHunk; fixed?: boolean }) { /** Per-hunk controls (stage / undo) beside each `@@` line. A context rather
* than a prop because the header renders from four places. */
const HunkActions = createContext<((hunk: LaneDiffHunk) => ReactNode) | null>(null);
function HunkHeader({
hunk,
fixed,
noActions,
}: {
hunk: LaneDiffHunk;
fixed?: boolean;
/** The split view's left column: one set of controls per hunk, not two. */
noActions?: boolean;
}) {
const actions = useContext(HunkActions);
const at = hunk.header.split("@@")[1]; const at = hunk.header.split("@@")[1];
// Windowed, the header is one ROW_PX row like the lines around it. // Windowed, the header is one ROW_PX row like the lines around it.
return ( return (
<div <div
className={`bg-surface-2 px-2 text-[10px] text-fg-muted ${ className={`flex items-center gap-2 bg-surface-2 px-2 text-[10px] text-fg-muted ${
fixed ? "overflow-hidden whitespace-nowrap leading-4" : "py-0.5" fixed ? "overflow-hidden whitespace-nowrap leading-4" : "py-0.5"
}`} }`}
style={fixed ? FIXED : undefined} style={fixed ? FIXED : undefined}
data-diff-item={fixed ? "" : undefined} data-diff-item={fixed ? "" : undefined}
> >
{at ? `@@${at}@@` : hunk.header} <span className="min-w-0 flex-1 truncate">
{hunk.context ? ` ${hunk.context}` : ""} {at ? `@@${at}@@` : hunk.header}
{hunk.context ? ` ${hunk.context}` : ""}
</span>
{actions && !noActions && (
<span className="sticky right-2 flex shrink-0 items-center gap-0.5">{actions(hunk)}</span>
)}
</div> </div>
); );
} }
@@ -394,7 +421,7 @@ function SplitColumn({
<> <>
{hunks.map((hunk, h) => ( {hunks.map((hunk, h) => (
<div key={`${hunk.header}:${h}`}> <div key={`${hunk.header}:${h}`}>
<HunkHeader hunk={hunk} /> <HunkHeader hunk={hunk} noActions={side === "left"} />
{analyseHunk(hunk).rows.map((row, i) => ( {analyseHunk(hunk).rows.map((row, i) => (
<SplitLine key={i} row={row} lang={lang} side={side} /> <SplitLine key={i} row={row} lang={lang} side={side} />
))} ))}
@@ -422,7 +449,7 @@ function SplitSlice({
.slice(range.start, range.end) .slice(range.start, range.end)
.map(({ hunk, i }, k) => .map(({ hunk, i }, k) =>
i < 0 ? ( i < 0 ? (
<HunkHeader key={range.start + k} hunk={hunk} fixed /> <HunkHeader key={range.start + k} hunk={hunk} fixed noActions={side === "left"} />
) : ( ) : (
<SplitLine <SplitLine
key={range.start + k} key={range.start + k}
@@ -520,11 +547,14 @@ export function LaneDiffView({
file, file,
layout, layout,
fill = false, fill = false,
hunkActions,
}: { }: {
file: LaneChangeFile; file: LaneChangeFile;
layout: DiffLayout; layout: DiffLayout;
/** Take the parent's height (single-file view) instead of the 60vh cap. */ /** Take the parent's height (single-file view) instead of the 60vh cap. */
fill?: boolean; fill?: boolean;
/** Controls drawn on each hunk's header row. */
hunkActions?: (hunk: LaneDiffHunk) => ReactNode;
}) { }) {
const { t } = useTranslation(["lanes"]); const { t } = useTranslation(["lanes"]);
const lang = langFor(file.path); const lang = langFor(file.path);
@@ -544,43 +574,45 @@ export function LaneDiffView({
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.noHunks")}</p>; return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.noHunks")}</p>;
return ( return (
<div <HunkActions.Provider value={hunkActions ?? null}>
data-testid="lane-diff-view" <div
data-layout={layout} data-testid="lane-diff-view"
onScroll={(e) => win.onScroll(e.currentTarget)} data-layout={layout}
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${ onScroll={(e) => win.onScroll(e.currentTarget)}
layout === "split" ? boxHeight(fill) : `overflow-auto ${boxHeight(fill)}` 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} /> {layout === "split" ? (
) : win.on ? ( <SplitView hunks={file.hunks} lang={lang} fill={fill} />
<Windowed count={items.length} range={win.range} minWidth={minWidth}> ) : win.on ? (
{items <Windowed count={items.length} range={win.range} minWidth={minWidth}>
.slice(win.range.start, win.range.end) {items
.map(({ hunk, i }, k) => .slice(win.range.start, win.range.end)
i < 0 ? ( .map(({ hunk, i }, k) =>
<HunkHeader key={win.range.start + k} hunk={hunk} fixed /> i < 0 ? (
) : ( <HunkHeader key={win.range.start + k} hunk={hunk} fixed />
<UnifiedLine ) : (
key={win.range.start + k} <UnifiedLine
line={hunk.lines[i]!} key={win.range.start + k}
lang={lang} line={hunk.lines[i]!}
emph={analyseHunk(hunk).emph.get(i)} lang={lang}
style={FIXED} emph={analyseHunk(hunk).emph.get(i)}
/> style={FIXED}
) />
)} )
</Windowed> )}
) : ( </Windowed>
file.hunks.map((hunk, i) => ( ) : (
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} /> file.hunks.map((hunk, i) => (
)) <UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
)} ))
{file.truncated && ( )}
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p> {file.truncated && (
)} <p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
</div> )}
</div>
</HunkActions.Provider>
); );
} }
@@ -668,6 +668,97 @@ describe("LaneChangesPanel", () => {
expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(2); expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(2);
}); });
describe("review actions", () => {
const reviewed = () =>
changes({
files: [
{
path: "server/lib/git-ops.js",
status: " M",
untracked: false,
unstaged: true,
added: 1,
deleted: 1,
binary: false,
hunks: [HUNK, { ...HUNK, header: "@@ -9,2 +10,3 @@" }],
},
{
path: "notes.txt",
status: "??",
untracked: true,
added: 1,
deleted: 0,
binary: false,
hunks: [HUNK],
},
],
});
const openReview = async (user: ReturnType<typeof userEvent.setup>) => {
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(api.lanes.changes).mockResolvedValue(reviewed());
await user.click(screen.getByRole("button", { name: "Review" }));
await screen.findByTestId("lane-changes-review");
};
it("stages one hunk by its @@ line, at the context the diff was read with", async () => {
const user = userEvent.setup();
await openReview(user);
// Two hunks on the tracked file; the untracked one has none to act on.
const stage = screen.getAllByRole("button", { name: "Stage this hunk" });
expect(stage).toHaveLength(2);
await user.click(stage[1]!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
path: "server/lib/git-ops.js",
hunk: "@@ -9,2 +10,3 @@",
context: 3,
});
});
it("needs two clicks to undo a hunk, and arms only that hunk", async () => {
const user = userEvent.setup();
await openReview(user);
const [first] = screen.getAllByRole("button", { name: "Undo this hunk" });
await user.click(first!);
expect(api.lanes.gitAction).not.toHaveBeenCalled();
// The other hunk is still at its first click.
expect(screen.getAllByRole("button", { name: "Undo this hunk" })).toHaveLength(1);
await user.click(
screen.getByRole("button", {
name: "Click again to undo this hunk — this cannot be undone",
})
);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard-hunk", {
path: "server/lib/git-ops.js",
hunk: "@@ -1,2 +1,3 @@",
context: 3,
confirm: true,
});
});
it("stages a whole file from its review header without folding it", async () => {
const user = userEvent.setup();
await openReview(user);
const [stageFile] = screen.getAllByRole("button", { name: "Stage this file" });
await user.click(stageFile!);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
expect(
screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })
).toBeInTheDocument();
});
it("keeps each file's header pinned while its diff scrolls", async () => {
const user = userEvent.setup();
await openReview(user);
expect(screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })).toHaveClass(
"sticky",
"top-0"
);
});
});
it("opens a file only from its path, and folds it from anywhere else", async () => { it("opens a file only from its path, and folds it from anywhere else", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />); render(<LaneChangesPanel laneId={7} />);
+4 -1
View File
@@ -234,5 +234,8 @@
"title": "Lanes", "title": "Lanes",
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message", "tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
"worktrees.heading_one": "{{count}} worktree", "worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktrees" "worktrees.heading_other": "{{count}} worktrees",
"changes.stageHunk": "Stage this hunk",
"changes.undoHunk": "Undo this hunk",
"changes.undoHunkConfirm": "Click again to undo this hunk — this cannot be undone"
} }
+4 -1
View File
@@ -234,5 +234,8 @@
"title": "Làn đường", "title": "Làn đường",
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn", "tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn",
"worktrees.heading_one": "{{count}} worktree", "worktrees.heading_one": "{{count}} worktree",
"worktrees.heading_other": "{{count}} worktree" "worktrees.heading_other": "{{count}} worktree",
"changes.stageHunk": "Stage đoạn này",
"changes.undoHunk": "Hoàn tác đoạn này",
"changes.undoHunkConfirm": "Bấm lần nữa để hoàn tác đoạn này — không thể khôi phục"
} }
+5
View File
@@ -2514,6 +2514,8 @@ export const api = {
| "stage" | "stage"
| "unstage" | "unstage"
| "discard" | "discard"
| "stage-hunk"
| "discard-hunk"
| "branch-create" | "branch-create"
| "branch-delete", | "branch-delete",
body: { body: {
@@ -2523,6 +2525,9 @@ export const api = {
base?: string; base?: string;
create?: boolean; create?: boolean;
paths?: string[]; paths?: string[];
path?: string;
hunk?: string;
context?: number;
confirm?: boolean; confirm?: boolean;
} = {} } = {}
) => ) =>
+13 -3
View File
@@ -566,6 +566,8 @@ cannot collide on `index.lock`.
| `stage` | `git add -- <paths>` | `{"paths": ["..."]}` | | `stage` | `git add -- <paths>` | `{"paths": ["..."]}` |
| `unstage` | `git restore --staged -- <paths>` (a staged rename also passes its original path, so both halves unstage) | `{"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}` | | `discard` | `git restore --source=HEAD --staged --worktree` (tracked) / `git clean -fd` (untracked) | `{"paths": ["..."], "confirm": true}` |
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U<context> HEAD -- <path>` | `{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "context": 3}` |
| `discard-hunk` | `git apply -R` of that one hunk, off the working tree | `{"path": "...", "hunk": "@@ ... @@", "context": 3, "confirm": true}` |
| `commit` | `git add -A` then `git commit -m <message>`, or the index as it stands with `only: "staged"` | `{"message": "...", "only": "staged"}` | | `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}` | | `switch` | `git switch [-c] <branch>` | `{"branch": "...", "create": false}` |
| `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` | | `branch-create` | `git branch <branch> [<base>]` — never switches | `{"branch": "...", "base": "..."}` |
@@ -587,15 +589,23 @@ cannot collide on `index.lock`.
A `commit` without `only: "staged"` sweeps in (`git add -A -- .`) only the 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 — lane cwd's own changes, and is `400 ENOCHANGES` when the lane has none —
even if the repo is dirty elsewhere. even if the repo is dirty elsewhere.
- `discard` is the one irreversible action here and it **requires** - `discard` and `discard-hunk` are the irreversible actions here and both
`confirm: true` (`400 ECONFIRM` without it). The UI additionally asks twice. **require** `confirm: true` (`400 ECONFIRM` without it). The UI additionally
asks twice.
- A hunk is named by its `@@` line and matched against a FRESH diff at the
`context` the caller read it with (send the `context` the changes read
returned), so a hunk the file has moved past is `409 EHUNKGONE`, never a
stale patch applied somewhere else. The path goes through the same
`git status` check; an untracked file has no hunks (`400 EUNTRACKED`). A
hunk that no longer applies (e.g. part of it is already staged) is
`409 EGIT`.
- `branch-create` never switches — the usual reason to create one from a lane - `branch-create` never switches — the usual reason to create one from a lane
is to spawn a sibling worktree lane, and moving this pane's HEAD under a is to spawn a sibling worktree lane, and moving this pane's HEAD under a
working agent is not. `branch-delete` is `-d`, never `-D`, and refuses working agent is not. `branch-delete` is `-d`, never `-D`, and refuses
`main`/`master`/`development`/`develop` (`400 EPROTECTED`), the current `main`/`master`/`development`/`develop` (`400 EPROTECTED`), the current
branch and a branch another worktree holds (`400 EBRANCHBUSY`). branch and a branch another worktree holds (`400 EBRANCHBUSY`).
- Bad input is **400** (`EBADACTION`, `EBADMESSAGE`, `EBADBRANCH`, `EBADPATH`, - Bad input is **400** (`EBADACTION`, `EBADMESSAGE`, `EBADBRANCH`, `EBADPATH`,
`ECONFIRM`, `EPROTECTED`, `ENOBRANCH`, `EBRANCHEXISTS`, `EBRANCHBUSY`, `ECONFIRM`, `EUNTRACKED`, `EPROTECTED`, `ENOBRANCH`, `EBRANCHEXISTS`, `EBRANCHBUSY`,
`EDIRTY`, `ENOCHANGES`, `ESTAGEDOUTSIDE`, `ENOREMOTE`); git itself exiting `EDIRTY`, `ENOCHANGES`, `ESTAGEDOUTSIDE`, `ENOREMOTE`); git itself exiting
non-zero (a rejected push, a credential failure, a diverged pull) is non-zero (a rejected push, a credential failure, a diverged pull) is
**409 `EGIT`** with git's own `stderr` appended to `error.message` — a **409 `EGIT`** with git's own `stderr` appended to `error.message` — a
+5
View File
@@ -601,6 +601,11 @@ only one that answers "show me the code" rather than "show me the change"):
target-practice exercise against one chevron.) One toolbar button folds or target-practice exercise against one chevron.) One toolbar button folds or
unfolds all of them. What is remembered is the set of *collapsed* paths, so unfolds all of them. What is remembered is the set of *collapsed* paths, so
a file that appears later is open by default; a lane switch clears it. a file that appears later is open by default; a lane switch clears it.
Each file's header stays **pinned** at the top while its diff scrolls past,
and carries the same stage / unstage / discard buttons as its overview row.
On a file with unstaged changes every `@@` row adds **Stage** and **Undo**
for that hunk alone (`stage-hunk` / `discard-hunk`); Undo asks twice, like
discard. The single-file view offers the same hunk buttons.
- **One file** — clicking a file's path in any list opens just that file. - **One file** — clicking a file's path in any list opens just that file.
**All files** returns to the list it was opened from — the same mode, at the **All files** returns to the list it was opened from — the same mode, at the
same scroll offset — because reading a change-set is a walk down a long list same scroll offset — because reading a change-set is a walk down a long list
+31 -2
View File
@@ -11147,6 +11147,24 @@ paths:
description: EBADPATH (the path escapes the lane). description: EBADPATH (the path escapes the lane).
'404': '404':
description: Lane not found. description: Lane not found.
/api/lanes/{id}/git/tree-all:
get:
tags:
- Lanes
summary: Every level of the tracked tree in one read
description: 'The file explorer''s expand-all-folders read: ONE git ls-files, grouped into levels keyed by directory ("" is the root), each level shaped and capped like GET /api/lanes/{id}/git/tree''s entries. Read-only, no same-origin guard.'
operationId: listLaneTreeAll
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
'200':
description: '{available, levels:{<dir>:[{name, path, dir}]}, truncated}.'
'404':
description: Lane not found.
/api/lanes/{id}/git/branches: /api/lanes/{id}/git/branches:
get: get:
tags: tags:
@@ -11170,7 +11188,7 @@ paths:
tags: tags:
- Lanes - Lanes
summary: One git write on the lane's working copy 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 <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.' 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), stage-hunk / discard-hunk (ONE hunk of a tracked file''s `git diff HEAD`, named by its `@@` line and re-read server-side at the given context — the applied patch is always git''s own text, never one from the request; discard-hunk REQUIRES confirm:true; 400 EUNTRACKED for an untracked file, 409 EHUNKGONE when the hunk is no longer in the diff), 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 operationId: runLaneGitAction
parameters: parameters:
- name: id - name: id
@@ -11190,6 +11208,8 @@ paths:
- stage - stage
- unstage - unstage
- discard - discard
- stage-hunk
- discard-hunk
- commit - commit
- switch - switch
- branch-create - branch-create
@@ -11223,9 +11243,18 @@ paths:
items: items:
type: string type: string
description: stage, unstage, discard; each must be a path git reports changed. description: stage, unstage, discard; each must be a path git reports changed.
path:
type: string
description: stage-hunk, discard-hunk; a path git reports changed (not untracked).
hunk:
type: string
description: stage-hunk, discard-hunk; the hunk's `@@` line as the diff returned it. The server re-reads the diff and applies git's own text of that hunk; a hunk no longer present is 409 EHUNKGONE.
context:
type: integer
description: stage-hunk, discard-hunk; the context the hunk was read at.
confirm: confirm:
type: boolean type: boolean
description: discard only; the call is refused without it. description: discard and discard-hunk; the call is refused without it.
responses: responses:
'200': '200':
description: '{action, ok:true, output} — git''s own stdout+stderr.' description: '{action, ok:true, output} — git''s own stdout+stderr.'
+1 -1
View File
@@ -531,7 +531,7 @@ Request body shape:
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. | | `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. |
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked files, folded. `400 EBADPATH` for a path escaping the lane. | | `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked files, folded. `400 EBADPATH` for a path escaping the lane. |
| `GET` | `/api/lanes/:id/git/branches` | The lane repo's own local branches + worktrees, for the branch switcher. The repo comes from the lane row, never the query. | | `GET` | `/api/lanes/:id/git/branches` | The lane repo's own local branches + worktrees, for the branch switcher. The repo comes from the lane row, never the query. |
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) and `branch-delete` (`-d`, refuses protected/current/busy branches). No merge/rebase/reset/stash by design. | | `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the lane lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree), `branch-create` (never switches) and `branch-delete` (`-d`, refuses protected/current/busy branches). No merge/rebase/reset/stash by design. |
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
an existing absolute git repository. The response lane starts as an existing absolute git repository. The response lane starts as
+64
View File
@@ -569,3 +569,67 @@ describe("runGitAction — per-file staging, discard and branches", () => {
}); });
}); });
}); });
describe("runGitAction — one hunk at a time", () => {
beforeEach(freshFixture);
/** A 20-line file edited at both ends: two hunks at the default context. */
function twoHunks() {
const lines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`);
write(LANE, "big.txt", lines.join("\n") + "\n");
gc(LANE, "add", "big.txt");
gc(LANE, "commit", "-qm", "big");
lines[0] = "FIRST";
lines[19] = "LAST";
write(LANE, "big.txt", lines.join("\n") + "\n");
}
const headers = async () =>
(await collectChanges(LANE, { range: "work", file: "big.txt" })).files[0].hunks.map(
(h) => h.header
);
it("stages ONE hunk, leaving the other in the working tree", async () => {
twoHunks();
const [first] = await headers();
await runGitAction(LANE, "stage-hunk", { path: "big.txt", hunk: first });
const staged = g(LANE, "diff", "--cached", "--", "big.txt");
const unstaged = g(LANE, "diff", "--", "big.txt");
assert.match(staged, /\+FIRST/);
assert.doesNotMatch(staged, /LAST/);
assert.match(unstaged, /\+LAST/);
assert.doesNotMatch(unstaged, /FIRST/);
});
it("undoes ONE hunk in the working tree, only with confirm", async () => {
twoHunks();
const [, second] = await headers();
await assert.rejects(
() => runGitAction(LANE, "discard-hunk", { path: "big.txt", hunk: second }),
{ code: "ECONFIRM" }
);
await runGitAction(LANE, "discard-hunk", { path: "big.txt", hunk: second, confirm: true });
const text = fs.readFileSync(path.join(LANE, "big.txt"), "utf8");
assert.match(text, /^FIRST\n/);
assert.match(text, /line 20\n$/);
});
it("refuses a hunk that is no longer in the diff, and a path that is not changed", async () => {
twoHunks();
await assert.rejects(
() => runGitAction(LANE, "stage-hunk", { path: "big.txt", hunk: "@@ -1,9 +1,9 @@" }),
{ code: "EHUNKGONE" }
);
await assert.rejects(
() => runGitAction(LANE, "stage-hunk", { path: "keep.txt", hunk: "@@ -1 +1 @@" }),
{ code: "EBADPATH" }
);
});
it("sends an untracked file to the per-file actions", async () => {
write(LANE, "new.txt", "x\n");
await assert.rejects(
() => runGitAction(LANE, "stage-hunk", { path: "new.txt", hunk: "@@ -0,0 +1,1 @@" }),
{ code: "EUNTRACKED" }
);
});
});
+32
View File
@@ -785,6 +785,37 @@ async function listTree(dir, { subdir = "" } = {}) {
}; };
} }
/**
* ONE hunk of a tracked file's working-copy diff (`git diff HEAD`, the same
* read the Changes panel shows), as a standalone patch `git apply` accepts:
* the file's own header lines plus that hunk. The hunk is named by its `@@`
* line and matched against a FRESH diff at the caller's context, so what gets
* applied is always git's own text of the current tree — never a patch a
* request carried. Null when no hunk matches (the tree moved on, or the file
* is a secret, binary or too large to read whole).
*/
async function hunkPatch(dir, file, header, context) {
if (!header || (await isSecretOnDisk(dir, file))) return null;
const u = `-U${normalizeContext(context)}`;
const raw = await readCapped(
dir,
["diff", u, "HEAD", "--no-color", "--no-ext-diff", "--", file],
MAX_FILE_BYTES
).catch(() => "");
// A capped read may end mid-hunk; applying half a hunk is worse than none.
if (!raw || raw.length >= MAX_FILE_BYTES) return null;
const at = raw.indexOf("\n@@ ");
if (at < 0) return null;
const head = raw.slice(0, at + 1);
// A content line starts with " ", "+", "-" or "\\" — never "@@" — so every
// line that does is a hunk boundary.
const hunk = raw
.slice(at + 1)
.split(/^(?=@@ )/m)
.find((h) => h.slice(0, h.indexOf("\n")) === header);
return hunk ? head + (hunk.endsWith("\n") ? hunk : `${hunk}\n`) : null;
}
/** /**
* Every level of the tracked tree from ONE `git ls-files`, keyed by directory * Every level of the tracked tree from ONE `git ls-files`, keyed by directory
* path ("" is the root), each level shaped and capped exactly like * path ("" is the root), each level shaped and capped exactly like
@@ -824,6 +855,7 @@ async function listTreeAll(dir) {
module.exports = { module.exports = {
collectChanges, collectChanges,
hunkPatch,
laneStatus, laneStatus,
listCommits, listCommits,
listPaths, listPaths,
+40 -1
View File
@@ -29,6 +29,9 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { const {
git: rawGit, git: rawGit,
isValidBranchName, isValidBranchName,
@@ -36,7 +39,7 @@ const {
branchCheckedOutAt, branchCheckedOutAt,
statusCounts, statusCounts,
} = require("./worktree"); } = require("./worktree");
const { laneStatus } = require("./git-diff"); const { hunkPatch, laneStatus } = require("./git-diff");
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts }); const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
@@ -54,6 +57,8 @@ const ACTIONS = new Set([
"stage", "stage",
"unstage", "unstage",
"discard", "discard",
"stage-hunk",
"discard-hunk",
"branch-create", "branch-create",
"branch-delete", "branch-delete",
]); ]);
@@ -240,6 +245,40 @@ async function runGitAction(dir, action, body = {}) {
return done(action, mergeResults(out)); return done(action, mergeResults(out));
} }
if (action === "stage-hunk" || action === "discard-hunk") {
// Undoing a hunk is as irreversible as discarding a file: same gate.
if (action === "discard-hunk" && body.confirm !== true) {
throw bad("ECONFIRM", "discard-hunk requires confirm: true");
}
const [target] = await resolvePaths(dir, { path: body.path });
if (target.code === "??") {
throw bad("EUNTRACKED", "an untracked file has no hunks; stage or discard the whole file");
}
const patch = await hunkPatch(
dir,
target.path,
typeof body.hunk === "string" ? body.hunk : "",
body.context
);
if (!patch) throw bad("EHUNKGONE", "that change is no longer in the diff; refresh and retry");
// The patch names paths from the repo root, so it is applied there.
const top = (await git(dir, ["rev-parse", "--show-toplevel"])).stdout.trim();
const tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), "agenthub-hunk-"));
const file = path.join(tmp, "hunk.patch");
try {
await fs.promises.writeFile(file, patch);
const zero = Number(body.context) === 0 ? ["--unidiff-zero"] : [];
// stage: HEAD -> worktree hunk onto the index; undo: the same hunk,
// reversed, off the working tree. A hunk the index or tree no longer
// matches fails in git's own words (409), never half-applies.
const args =
action === "stage-hunk" ? ["apply", "--cached", ...zero] : ["apply", "-R", ...zero];
return done(action, await git(top, [...args, file]));
} finally {
await fs.promises.rm(tmp, { recursive: true, force: true });
}
}
if (action === "commit") { if (action === "commit") {
const message = typeof body.message === "string" ? body.message.trim() : ""; const message = typeof body.message === "string" ? body.message.trim() : "";
if (!message) throw bad("EBADMESSAGE", "commit message is required"); if (!message) throw bad("EBADMESSAGE", "commit message is required");
+32 -2
View File
@@ -585,6 +585,20 @@ const paths = {
}, },
}, },
}, },
"/api/lanes/{id}/git/tree-all": {
get: {
tags: ["Lanes"],
summary: "Every level of the tracked tree in one read",
description:
"The file explorer's expand-all-folders read: ONE git ls-files, grouped into levels keyed by directory (\"\" is the root), each level shaped and capped like GET /api/lanes/{id}/git/tree's entries. Read-only, no same-origin guard.",
operationId: "listLaneTreeAll",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
200: { description: "{available, levels:{<dir>:[{name, path, dir}]}, truncated}." },
404: { description: "Lane not found." },
},
},
},
"/api/lanes/{id}/git/branches": { "/api/lanes/{id}/git/branches": {
get: { get: {
tags: ["Lanes"], tags: ["Lanes"],
@@ -604,7 +618,7 @@ const paths = {
tags: ["Lanes"], tags: ["Lanes"],
summary: "One git write on the lane's working copy", summary: "One git write on the lane's working copy",
description: 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.", "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), stage-hunk / discard-hunk (ONE hunk of a tracked file's `git diff HEAD`, named by its `@@` line and re-read server-side at the given context — the applied patch is always git's own text, never one from the request; discard-hunk REQUIRES confirm:true; 400 EUNTRACKED for an untracked file, 409 EHUNKGONE when the hunk is no longer in the diff), 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", operationId: "runLaneGitAction",
parameters: [ parameters: [
{ name: "id", in: "path", required: true, schema: { type: "integer" } }, { name: "id", in: "path", required: true, schema: { type: "integer" } },
@@ -621,6 +635,8 @@ const paths = {
"stage", "stage",
"unstage", "unstage",
"discard", "discard",
"stage-hunk",
"discard-hunk",
"commit", "commit",
"switch", "switch",
"branch-create", "branch-create",
@@ -653,9 +669,23 @@ const paths = {
items: { type: "string" }, items: { type: "string" },
description: "stage, unstage, discard; each must be a path git reports changed.", description: "stage, unstage, discard; each must be a path git reports changed.",
}, },
path: {
type: "string",
description:
"stage-hunk, discard-hunk; a path git reports changed (not untracked).",
},
hunk: {
type: "string",
description:
"stage-hunk, discard-hunk; the hunk's `@@` line as the diff returned it. The server re-reads the diff and applies git's own text of that hunk; a hunk no longer present is 409 EHUNKGONE.",
},
context: {
type: "integer",
description: "stage-hunk, discard-hunk; the context the hunk was read at.",
},
confirm: { confirm: {
type: "boolean", type: "boolean",
description: "discard only; the call is refused without it.", description: "discard and discard-hunk; the call is refused without it.",
}, },
}, },
}, },
+7 -1
View File
@@ -951,13 +951,19 @@ router.post("/:id/git/:action", sameOriginGuard, async (req, res) => {
"EPROTECTED", "EPROTECTED",
"ESTAGEDOUTSIDE", "ESTAGEDOUTSIDE",
"ENOREMOTE", "ENOREMOTE",
"EUNTRACKED",
]; ];
// A numeric code is git's own exit status (execFile reports one that way): // 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 // 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 // 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. // server fault, so it is a 409 under the string code the client expects.
const exited = typeof err.code === "number"; const exited = typeof err.code === "number";
const status = badRequest.includes(err.code) ? 400 : exited ? 409 : 500; // EHUNKGONE is the same kind of answer: the tree moved under the click.
const status = badRequest.includes(err.code)
? 400
: exited || err.code === "EHUNKGONE"
? 409
: 500;
// git's own stderr is the useful half of a failed push or pull (a // 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 // rejection, a missing credential, a diverged branch), so it joins the
// message rather than riding a side field the client would have to know // message rather than riding a side field the client would have to know