fix(changes): Discard takes only the ticked files and blocks (#87)

The Changes panel's "Discard all" threw away every changed file, ticked or
not. It is now "Discard (n)": the same selection Commit sends — whole ticked
files as `paths`, a partly ticked file's ticked blocks as `hunks` — and is
disabled with nothing ticked. The server's `discard` accepts `hunks`
(`selectionPatch` in reverse, `git apply -R`), building every patch before
applying any so a stale selection undoes nothing.

Closes #87
This commit is contained in:
2026-10-30 13:05:13 +07:00
parent efe21c79e9
commit da7b86d5c8
9 changed files with 178 additions and 50 deletions
+45
View File
@@ -851,6 +851,51 @@ describe("runGitAction — one change block inside a hunk (issue #50)", () => {
assert.equal(fs.readFileSync(path.join(LANE, "tail.txt"), "utf8"), "l1\nl2\nl3\nl4\nl5\nl6");
});
it("discards only the ticked blocks and whole files, nothing unticked (issue #87)", async () => {
const edited = threeBlocks();
write(LANE, "keep.txt", "untouched by discard\n");
write(LANE, "drop.txt", "gone\n");
const [hunk] = await hunkOf();
await assert.rejects(
() =>
runGitAction(LANE, "discard", {
paths: ["drop.txt"],
hunks: { "near.txt": [{ hunk: hunk.header, blocks: [0, 2] }] },
}),
{ code: "ECONFIRM" }
);
await runGitAction(LANE, "discard", {
paths: ["drop.txt"],
hunks: { "near.txt": [{ hunk: hunk.header, blocks: [0, 2] }] },
confirm: true,
});
const want = [...edited];
want.splice(11, 0, "line 11");
want.splice(3, 2, "line 4", "line 5");
assert.deepEqual(read(), [...want, ""]);
assert.equal(fs.existsSync(path.join(LANE, "drop.txt")), false);
assert.equal(fs.readFileSync(path.join(LANE, "keep.txt"), "utf8"), "untouched by discard\n");
});
it("discards ticked blocks alone, and refuses a stale one with nothing undone", async () => {
const edited = threeBlocks();
const [hunk] = await hunkOf();
await assert.rejects(
() =>
runGitAction(LANE, "discard", {
hunks: { "near.txt": [{ hunk: "@@ -1,2 +1,2 @@", blocks: [0] }] },
confirm: true,
}),
{ code: "EHUNKGONE" }
);
assert.deepEqual(read(), [...edited, ""]);
await runGitAction(LANE, "discard", {
hunks: { "near.txt": [{ hunk: hunk.header, blocks: [1] }] },
confirm: true,
});
assert.deepEqual(read(), [...edited.filter((l) => l !== "INSERTED"), ""]);
});
it("refuses a block the hunk does not have", async () => {
threeBlocks();
const [hunk] = await hunkOf();
+5 -3
View File
@@ -1241,16 +1241,18 @@ async function diffHunks(dir, file, context) {
* in file order, to be applied FORWARD onto an index holding HEAD. Every
* header is matched against a fresh diff like `hunkPatch`'s; null when one is
* gone or names a block the hunk lacks. Each hunk's new-side start is left as
* git wrote it — `git apply` locates a hunk by its old side.
* git wrote it — `git apply` locates a hunk by its old side. `reverse` cuts
* it for `apply -R` off the working tree instead (the partial discard, #87),
* where the NEW side is the one checked and every start stays git's own.
*/
async function selectionPatch(dir, file, context, picks) {
async function selectionPatch(dir, file, context, picks, reverse = false) {
const diff = await diffHunks(dir, file, context);
if (!diff || !picks.length) return null;
const at = (header) => diff.hunks.findIndex((h) => h.slice(0, h.indexOf("\n")) === header);
if (picks.some((p) => at(p.hunk) < 0)) return null;
const bodies = [];
for (const p of [...picks].sort((x, y) => at(x.hunk) - at(y.hunk))) {
const body = onlyBlock(diff.hunks[at(p.hunk)], p.blocks, false);
const body = onlyBlock(diff.hunks[at(p.hunk)], p.blocks, reverse);
if (!body) return null;
bodies.push(body);
}
+56 -14
View File
@@ -424,6 +424,13 @@ async function runGitAction(dir, action, body = {}) {
if (action === "discard") {
if (body.confirm !== true) throw bad("ECONFIRM", "discard requires confirm: true");
// Ticked change blocks of partly ticked files (#87) go first: each patch
// is built before any is applied, so a stale one refuses before the
// irreversible whole-file part below has run.
const partial = hasHunks(body) ? await discardSelection(dir, body) : null;
if (partial && !(Array.isArray(body.paths) && body.paths.length)) {
return done(action, partial);
}
const paths = await resolvePaths(dir, body);
const untracked = paths.filter((p) => p.code === "??").map((p) => p.path);
// A rename's ORIGINAL path rides along: restoring only the new one would
@@ -431,7 +438,7 @@ async function runGitAction(dir, action, body = {}) {
const tracked = paths
.filter((p) => p.code !== "??")
.flatMap((p) => (p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]));
const out = [];
const out = partial ? [partial] : [];
// 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
// one state rather than half-reverted.
@@ -825,18 +832,11 @@ function hasHunks(body) {
const MAX_PARTIAL_FILES = 300;
/**
* JetBrains' partial commit: whole files (`paths`) plus, per file in `hunks`,
* only the ticked change blocks — `{"<path>": [{hunk: "@@ … @@", blocks: [0]}]}`,
* the hunks named as `stage-hunk` names them, at `context`. Built in a
* TEMPORARY index (HEAD + the whole files + one patch per partial file from
* `selectionPatch`, git's own fresh text, never the request's) and committed
* from it, so the real index and the working tree are never in the way; after
* the commit the real index entries of exactly those paths are reset to the
* new HEAD, the rest of each partial file left as an unstaged change. Same
* path rules as `commitPaths`; a partial file must be tracked (EUNTRACKED)
* and not a rename, and a hunk the tree no longer has is EHUNKGONE.
* `body.hunks` checked for shape, and its files resolved as changed paths
* that may be taken in part: tracked (EUNTRACKED), not a rename, not in
* conflict, and not also named whole in `body.paths`.
*/
async function commitSelection(dir, args, body) {
async function partialPicks(dir, body, verb) {
const picks = Object.entries(body.hunks);
if (picks.length > MAX_PARTIAL_FILES) throw bad("EBADPATH", "too many files");
for (const [, list] of picks) {
@@ -858,12 +858,54 @@ async function commitSelection(dir, args, body) {
const conflicted = [...whole, ...part].find((p) => CONFLICT_CODES.has(p.code));
if (conflicted) throw bad("ECONFLICTS", `${conflicted.path} is in conflict: resolve it first`);
for (const p of part) {
if (p.code === "??") throw bad("EUNTRACKED", `${p.path} is untracked: commit it whole`);
if (p.code.includes("R")) throw bad("EBADPATH", `${p.path} is a rename: commit it whole`);
if (p.code === "??") throw bad("EUNTRACKED", `${p.path} is untracked: ${verb} it whole`);
if (p.code.includes("R")) throw bad("EBADPATH", `${p.path} is a rename: ${verb} it whole`);
if (whole.some((w) => w.path === p.path)) {
throw bad("EBADPATH", `${p.path} is named both whole and in part`);
}
}
return { picks, whole, part };
}
/**
* The partial discard (#87): per file in `hunks`, only the ticked change
* blocks are undone off the working tree — `selectionPatch` in reverse, git's
* own fresh text, never the request's — exactly what one `discard-hunk` per
* block would do, in one step. Every patch is built before the first is
* applied, so a stale selection refuses (EHUNKGONE) with nothing undone.
*/
async function discardSelection(dir, body) {
const { picks } = await partialPicks(dir, body, "discard");
const patches = [];
for (const [p, list] of picks) {
const patch = await selectionPatch(dir, p, body.context, list, true);
if (!patch)
throw bad("EHUNKGONE", `${p}: that change is no longer in the diff; refresh and retry`);
patches.push(patch);
}
// 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 zero = Number(body.context) === 0 ? ["--unidiff-zero"] : [];
const out = [];
for (const patch of patches)
out.push(await git(top, ["apply", "-R", ...zero, "-"], { input: patch }));
return mergeResults(out);
}
/**
* JetBrains' partial commit: whole files (`paths`) plus, per file in `hunks`,
* only the ticked change blocks — `{"<path>": [{hunk: "@@ … @@", blocks: [0]}]}`,
* the hunks named as `stage-hunk` names them, at `context`. Built in a
* TEMPORARY index (HEAD + the whole files + one patch per partial file from
* `selectionPatch`, git's own fresh text, never the request's) and committed
* from it, so the real index and the working tree are never in the way; after
* the commit the real index entries of exactly those paths are reset to the
* new HEAD, the rest of each partial file left as an unstaged change. Same
* path rules as `commitPaths`; a partial file must be tracked (EUNTRACKED)
* and not a rename, and a hunk the tree no longer has is EHUNKGONE.
*/
async function commitSelection(dir, args, body) {
const { picks, whole, part } = await partialPicks(dir, body, "commit");
const wholePaths = whole.flatMap((p) =>
p.from && p.code.includes("R") ? [p.path, p.from] : [p.path]
);