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:
@@ -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
|
||||
* path ("" is the root), each level shaped and capped exactly like
|
||||
@@ -824,6 +855,7 @@ async function listTreeAll(dir) {
|
||||
|
||||
module.exports = {
|
||||
collectChanges,
|
||||
hunkPatch,
|
||||
laneStatus,
|
||||
listCommits,
|
||||
listPaths,
|
||||
|
||||
+40
-1
@@ -29,6 +29,9 @@
|
||||
* @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 {
|
||||
git: rawGit,
|
||||
isValidBranchName,
|
||||
@@ -36,7 +39,7 @@ const {
|
||||
branchCheckedOutAt,
|
||||
statusCounts,
|
||||
} = require("./worktree");
|
||||
const { laneStatus } = require("./git-diff");
|
||||
const { hunkPatch, laneStatus } = require("./git-diff");
|
||||
|
||||
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
|
||||
|
||||
@@ -54,6 +57,8 @@ const ACTIONS = new Set([
|
||||
"stage",
|
||||
"unstage",
|
||||
"discard",
|
||||
"stage-hunk",
|
||||
"discard-hunk",
|
||||
"branch-create",
|
||||
"branch-delete",
|
||||
]);
|
||||
@@ -240,6 +245,40 @@ async function runGitAction(dir, action, body = {}) {
|
||||
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") {
|
||||
const message = typeof body.message === "string" ? body.message.trim() : "";
|
||||
if (!message) throw bad("EBADMESSAGE", "commit message is required");
|
||||
|
||||
Reference in New Issue
Block a user