2b5d7268ed
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.
224 lines
8.5 KiB
JavaScript
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 };
|