/** * @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ĩ */ 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: ". */ 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 };