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:
@@ -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" }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user