Files
AgentHub/server/lib/git-conflicts.js
T
nntrivi2001 2b5d7268ed feat(git): 3-way merge editor, reflog, rebase/interactive rebase, reset with undo, force-with-lease push, remote branches and remotes
Server: git-rewrite.js (rebase + continue/skip/abort, rebase-interactive with
no editor, reset soft/mixed/keep/hard, undo-rewrite, force-push with lease,
remote-branch-delete, remote add/rename/remove) and git-conflicts.js
(conflict stages read + conflict-save, reflog), dispatched from runGitAction;
operationInProgress now reports a rebase and only ALLOWED_WHILE_REBASING
writes run during one. New reads: /git/conflict, /git/reflog, /git/remotes,
/git/rewrite-plan. Client: History tools menu, MergeEditor, ReflogView,
RebaseDialog, InteractiveRebase, ResetDialog, ForcePushDialog, RemotesDialog,
rebase banner Skip. CLAUDE.md git-writes rule rewritten for the new actions.
2026-11-02 12:12:45 +07:00

224 lines
8.5 KiB
JavaScript

/**
* @file Better conflicts and history reads for the Changes panel: the three
* git stages of ONE conflicted file for the 3-way merge editor (`readConflict`,
* base `:1`, current `:2`, incoming `:3`, plus the working file it saves over),
* the editor's save (`conflict-save`: the whole-file editor's own write path,
* lane-file-write.js, then `git add` exactly like `resolve done`, refused while
* a conflict marker is left), and the read-only reflog (`readReflog`).
*
* A path is never trusted as a path: the conflict read accepts only a file
* `git ls-files -u` lists as unmerged right now (cwd-relative, so a
* subdirectory lane cannot name a file outside itself), keeps the read path's
* guards (`lanePath`, ESECRET, a size cap) and reads each stage by its BLOB id,
* never by a revision expression from the request. A reflog's ref is `HEAD` or
* a local branch that exists, looked up — never passed through.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { git: rawGit, isValidBranchName, operationInProgress } = require("./worktree");
const {
contentSha,
isSecretOnDisk,
lanePath,
looksBinary,
resolveInLane,
MAX_FILE_VIEW_BYTES,
CONFLICT_CODES,
} = require("./git-diff");
const git = (dir, args, opts) => rawGit(dir, args, { literal: true, ...opts });
/** A line git writes to open or close a conflict block (git-ops.js's own). */
const MARKER_RE = /^(<{7} |>{7} |={7}$)/m;
/** Most reflog entries one page answers with. */
const MAX_REFLOG_PAGE = 200;
function bad(code, message) {
return Object.assign(new Error(message), { code });
}
/** `ls-files -u` as path → {1?: blob, 2?: blob, 3?: blob}; cwd-relative paths. */
async function unmergedStages(dir) {
const out = (await git(dir, ["ls-files", "-u", "-z"])).stdout;
const map = new Map();
for (const rec of out.split("\0").filter(Boolean)) {
const tab = rec.indexOf("\t");
const [, blob, stage] = rec.slice(0, tab).split(" ");
const p = rec.slice(tab + 1);
if (!map.has(p)) map.set(p, {});
map.get(p)[stage] = blob;
}
return map;
}
/** One stage's text, or `{missing}` / `{binary}` / `{tooBig}`. */
async function blobText(dir, blob) {
if (!blob) return { missing: true, text: null };
const size = Number((await git(dir, ["cat-file", "-s", blob])).stdout.trim());
if (size > MAX_FILE_VIEW_BYTES) return { tooBig: true, text: null };
const buf = (await git(dir, ["cat-file", "blob", blob], { encoding: "buffer" })).stdout;
if (looksBinary(buf)) return { binary: true, text: null };
return { text: buf.toString("utf8") };
}
/** The head file of the open operation, for "Incoming: <sha> <subject>". */
const INCOMING_HEAD = {
merge: "MERGE_HEAD",
"cherry-pick": "CHERRY_PICK_HEAD",
revert: "REVERT_HEAD",
rebase: "REBASE_HEAD",
};
/**
* The merge editor's input for `requested`: base/ours/theirs text, the working
* file (with its markers) and its `sha` — the `baseSha` a save must name.
* Throws EBADPATH (not unmerged), ESECRET, or answers a side as null when git
* has none (added on one side, deleted on the other).
*/
async function readConflict(dir, requested) {
const file = lanePath(requested);
if (await isSecretOnDisk(dir, file)) {
throw bad("ESECRET", "that file holds secrets and is not readable here");
}
const stages = (await unmergedStages(dir)).get(file);
if (!stages) throw bad("EBADPATH", `not a conflicted path: ${file}`);
const [base, ours, theirs] = await Promise.all(
["1", "2", "3"].map((s) => blobText(dir, stages[s]))
);
let working = { text: null, sha: null, binary: false, tooBig: false, missing: true };
try {
const abs = await resolveInLane(dir, file);
const fs = require("node:fs");
const st = await fs.promises.stat(abs);
if (st.isFile()) {
if (st.size > MAX_FILE_VIEW_BYTES) working = { ...working, missing: false, tooBig: true };
else {
const buf = await require("./run-as").readFileSafeAsync(abs);
working = looksBinary(buf)
? { ...working, missing: false, binary: true }
: { text: buf.toString("utf8"), sha: contentSha(buf), missing: false };
}
}
} catch (err) {
if (err.code === "ESECRET" || err.code === "EFOREIGN") throw err;
/* gone from the tree: one side deleted it */
}
const operation = await operationInProgress(dir);
let incoming = null;
const head = operation && INCOMING_HEAD[operation];
if (head) {
const line = await git(dir, ["log", "-1", "--format=%H%x00%h%x00%s", head, "--"]).then(
(r) => r.stdout.trim(),
() => ""
);
if (line) {
const [sha, short, subject] = line.split("\0");
incoming = { sha, short, subject };
}
}
const branch = (
await git(dir, ["branch", "--show-current"]).catch(() => ({ stdout: "" }))
).stdout.trim();
// Editable only when every side that exists is text under the cap and the
// working file is there to save over.
const sides = [base, ours, theirs];
const usable =
working.text != null && !working.binary && !sides.some((s) => s.binary || s.tooBig);
return {
path: file,
operation,
branch: branch || null,
incoming,
base: base.text,
ours: ours.text,
theirs: theirs.text,
working: working.text,
sha: working.sha,
usable,
binary: working.binary || sides.some((s) => s.binary),
tooBig: working.tooBig || sides.some((s) => s.tooBig),
};
}
/**
* `conflict-save {path, content, baseSha, resolve?}`: the merge editor's
* result over the working file — through lane-file-write.js (lanePath,
* resolveInLane, ESECRET, existing text file only, `baseSha` → ESTALE) — then
* `git add`, the same step `resolve done` takes. With `resolve` (default) a
* result that still holds a marker is refused BEFORE anything is written
* (EMARKERS); `resolve: false` saves the half-done result and leaves the file
* in conflict.
*/
async function saveConflict(dir, body) {
const { resolvePaths } = require("./git-ops")._internal;
const [target] = await resolvePaths(dir, { path: body.path });
if (!CONFLICT_CODES.has(target.code)) {
throw bad("EBADPATH", `not a conflicted path: ${target.path}`);
}
const resolve = body.resolve !== false;
if (typeof body.content !== "string") throw bad("EBADCONTENT", "content must be a string");
if (resolve && MARKER_RE.test(body.content)) {
throw bad("EMARKERS", `${target.path} still has conflict markers`);
}
await require("./lane-file-write").writeLaneFile(dir, target.path, body.content, body.baseSha);
const out = resolve ? await git(dir, ["add", "--", target.path]) : { stdout: "", stderr: "" };
const output = [out.stdout, out.stderr].filter(Boolean).join("").trim();
return { action: "conflict-save", ok: true, output, resolved: resolve };
}
/**
* One page of a reflog, newest first: `ref` is `HEAD` (default) or a local
* branch that exists. Each entry: `{index, selector, sha, short, action,
* message, time, subject}` — `action`/`message` split git's own reflog
* subject ("commit: fix"), `subject` is the commit's. `more` says another page
* exists. A ref with no reflog (never moved here) is an empty list.
*/
async function readReflog(dir, { ref, skip, limit } = {}) {
let name = "HEAD";
if (typeof ref === "string" && ref && ref !== "HEAD") {
if (!(await isValidBranchName(dir, ref))) throw bad("EBADBRANCH", "not a branch name");
const exists = await git(dir, ["show-ref", "--verify", "-q", `refs/heads/${ref}`]).then(
() => true,
() => false
);
if (!exists) throw bad("ENOBRANCH", `no local branch ${ref}`);
name = `refs/heads/${ref}`;
}
const from = Math.max(0, Number.parseInt(skip, 10) || 0);
const n = Math.min(MAX_REFLOG_PAGE, Math.max(1, Number.parseInt(limit, 10) || 50));
const out = await git(dir, [
"log",
"-g",
"--date=unix",
"--format=%H%x00%h%x00%gd%x00%gs%x00%s",
`--skip=${from}`,
"-n",
String(n + 1),
name,
"--",
]).then(
(r) => r.stdout,
() => ""
);
const rows = out.split("\n").filter(Boolean);
const label = name === "HEAD" ? "HEAD" : ref;
const entries = rows.slice(0, n).map((line, i) => {
const [sha, short, sel, gs, subject] = line.split("\0");
const time = Number((sel.match(/@\{(\d+)\}$/) || [])[1]) || null;
const colon = gs.indexOf(": ");
return {
index: from + i,
selector: `${label}@{${from + i}}`,
sha,
short,
action: colon > 0 ? gs.slice(0, colon) : gs,
message: colon > 0 ? gs.slice(colon + 2) : "",
time,
subject,
};
});
return { ref: label, entries, more: rows.length > n };
}
module.exports = { readConflict, saveConflict, readReflog, unmergedStages, MARKER_RE };