import type { Context } from "@earendil-works/chord"; import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; import { getOrThrow } from "@earendil-works/pi-durable/env"; import type { Environment } from "@berry/api"; import { createPatch } from "diff"; import type { OpenFile, Save, Saved, WorkingCopy } from "./edit-types.ts"; import { linesOf } from "./model.ts"; import type { Shown } from "./types.ts"; import { editable } from "./eligibility.ts"; import { bounded, jjAt, jjRevision, show } from "./vcs.ts"; const MAX_BYTES = 4 * 1024 * 1024; const mutations = new Map>(); function working(env: Environment, shown: Shown): WorkingCopy { return { machine: env.id, root: shown.root, vcs: shown.vcs, identity: shown.vcs === "jj" ? shown.revision.change!.full : shown.log[1]?.commit?.full ?? "", }; } function validPath(path: string) { if ( !path || path.includes("\\") || path.includes("\0") || /^[a-z]:/i.test(path) || path.split("/").some((part) => !part || [".", "..", ".git", ".jj"].includes(part.toLowerCase())) ) { throw new Error("Not an editable repository path."); } } async function checkedPath(env: Environment, root: string, path: string, context: Context) { validPath(path); const canonicalRoot = getOrThrow(await env.canonicalPath(root, context)); const absolute = getOrThrow(await env.joinPath([canonicalRoot, path], context)); if (getOrThrow(await env.canonicalPath(absolute, context)) !== absolute) { throw new Error("Editing through symbolic links is not supported."); } return absolute; } async function read(env: Environment, path: string, context: Context) { const reader = getOrThrow(await env.openBinaryReader(path, { noFollow: true }, context)); let bytes: Uint8Array; try { const info = getOrThrow(await reader.info(context)); if (info.size > MAX_BYTES) throw new Error("Diff editing is limited to 4 MiB text files."); bytes = getOrThrow(await reader.read(0, info.size, context)); if (bytes.length !== info.size) throw new Error("File changed while reading; try again."); } finally { await reader.close(context); } return await text(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes)); } async function text(content: string) { if (content.includes("\0")) throw new Error("Binary files cannot be edited."); if (content.includes("\r") && /\r(?!\n)|(? MAX_BYTES) throw new Error("Diff editing is limited to 4 MiB text files."); return { content, version: await version(bytes) }; } async function version(bytes: Uint8Array): Promise { return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes.slice()))) .map((byte) => byte.toString(16).padStart(2, "0")).join(""); } export async function openFile( env: Environment, root: string, path: string, context: Context, route = "", ): Promise { if (route !== "") return await openCommit(env, root, path, route, context); const shown = await show(env, "", 2, context, true); if (shown.root !== root) throw new Error("The conversation moved to another repository."); const copy = working(env, shown); const file = shown.files.find((file) => file.path === path); if (!file || !editable(file)) throw new Error("This is not an editable working-copy file."); const absolute = await checkedPath(env, root, path, context); const current = await read(env, absolute, context); const lines = linesOf(file).hunks.flatMap((hunk) => hunk.lines); const side = (which: "old" | "new") => lines.filter((line) => line[which] !== null) .map((line) => line.text + (line.newline ? "\n" : "")).join(""); if ( lines.length && side("new").replace(/\r\n/g, "\n") !== current.content.replace(/\r\n/g, "\n") ) { throw new Error("File changed while opening; refresh and try again."); } const original = file.status === "added" ? "" : lines.length ? side("old") : current.content; if (new TextEncoder().encode(original).length > MAX_BYTES) { throw new Error("Diff editing is limited to 4 MiB on each side."); } return { working: copy, file: bounded([file])[0], original, ...current, }; } /** Run `task` after the ones before it under `key`. */ async function serialized(key: string, task: () => Promise): Promise { const result = (mutations.get(key) ?? Promise.resolve()).then(task); const settled = result.catch(() => {}); mutations.set(key, settled); void settled.then(() => mutations.get(key) === settled && mutations.delete(key)); return await result; } function report(path: string, before: string, after: string) { const patch = before === after ? "" : createPatch(path, before, after, "", "", { context: 3, timeout: 1000 }); if (patch === undefined) throw new Error("Edit is too large to report; save a smaller change."); return patch; } export async function saveFile(env: Environment, save: Save): Promise { const bytes = new TextEncoder().encode(save.content); if (bytes.length > MAX_BYTES || save.content.includes("\0")) { throw new Error("Diff editing requires a text file no larger than 4 MiB."); } if (save.working.change !== undefined) return await saveCommit(env, save); return await serialized(`${env.id}\0${save.working.root}\0${save.path}`, async () => { const shown = await show(env, "", 2, BACKGROUND_CONTEXT); const copy = working(env, shown); if ( (Object.keys(copy) as (keyof WorkingCopy)[]).some((key) => copy[key] !== save.working[key]) ) { throw new Error("The working copy changed; reopen the file before saving."); } const absolute = await checkedPath(env, shown.root, save.path, BACKGROUND_CONTEXT); const before = await read(env, absolute, BACKGROUND_CONTEXT); if (before.version !== save.version) { throw new Error("This file changed on disk since you opened it."); } const patch = report(save.path, before.content, save.content); // writing the existing file preserves its permissions; external writers still need conflict checks if (patch) getOrThrow(await env.writeFile(absolute, bytes, BACKGROUND_CONTEXT)); return { id: save.id, working: save.working, path: save.path, version: await version(bytes), patch, }; }); } /** Reword one mutable jj change without overwriting a description edited elsewhere. */ export async function reword( env: Environment, root: string, change: string, before: string, description: string, ): Promise { await serialized(`${env.id}\0${root}`, async () => { const context = BACKGROUND_CONTEXT; const current = await show(env, change, 1, context); if (current.root !== root) throw new Error("The conversation moved to another repository."); if (current.vcs !== "jj") throw new Error("Only jj descriptions can be edited."); const { revision } = current; if (revision.immutable) throw new Error("This commit is immutable."); if (revision.description !== before) { throw new Error("This description changed since you opened it. Reopen it before saving."); } if (description === before) return; await jjAt(env, root, [ "describe", revision.commit!.full, `--message=${description}`, ], context); }); } /** The last commit time a rewrite was given. */ let stamped = 0; /** A jj fileset naming exactly `path`. */ const fileset = (path: string) => `root-file:${JSON.stringify(path)}`; async function openCommit( env: Environment, root: string, path: string, route: string, context: Context, ): Promise { validPath(path); const shown = await show(env, route, 2, context, true); if (shown.root !== root) throw new Error("The conversation moved to another repository."); const { revision } = shown; if (shown.vcs !== "jj" || revision.workingCopy) throw new Error("Only jj commits can be edited."); if (revision.immutable) throw new Error("This commit is immutable."); const file = shown.files.find((file) => file.path === path); if (!file || !editable(file)) throw new Error("This is not an editable file in this commit."); const current = await text( await jjAt(env, root, [ "file", "show", "--ignore-working-copy", `--revision=${revision.commit!.full}`, "--", fileset(path), ], context), ); const lines = linesOf(file).hunks.flatMap((hunk) => hunk.lines); const original = file.status === "added" ? "" : lines.filter((line) => line.old !== null) .map((line) => line.text + (line.newline ? "\n" : "")).join(""); return { working: { machine: env.id, root, vcs: "jj", identity: revision.commit!.full, change: revision.change!.full, }, file: bounded([file])[0], original, ...current, }; } /** * Rewrite a jj commit's file through `jj diffedit`, its descendants rebasing onto it. The rewrite leaves the working * copy alone until it stands, so that one undone for the conflicts it would cause never touches the files on disk. */ async function saveCommit(env: Environment, save: Save): Promise { validPath(save.path); const { root, change } = save.working; // a rewrite moves every descendant, so one at a time per repository return await serialized(`${env.id}\0${root}`, async () => { const context = BACKGROUND_CONTEXT; const jj = (args: string[]) => jjAt(env, root, ["--ignore-working-copy", ...args], context); const revision = await jjRevision(env, root, change!, context, true); if (!revision || revision.commit!.full !== save.working.identity) { throw new Error("This commit changed since you opened it."); } if (revision.immutable) throw new Error("This commit is immutable."); const before = await text( await jj(["file", "show", `--revision=${revision.commit!.full}`, "--", fileset(save.path)]), ); const patch = report(save.path, before.content, save.content); if (!patch) { return { id: save.id, working: save.working, path: save.path, version: before.version, patch, }; } const conflicted = async () => new Set( (await jj([ "log", "--no-graph", `--revisions=descendants(${change}) & conflicts()`, '--template=change_id.short() ++ "\\n"', ])).split("\n").filter(Boolean), ); const already = await conflicted(); const operation = (await jj(["op", "log", "--no-graph", "--limit=1", "--template=id"])).trim(); const staged = getOrThrow( await env.joinPath([root, ".jj", `berry-edit-${crypto.randomUUID()}`], context), ); getOrThrow(await env.writeFile(staged, new TextEncoder().encode(save.content), context)); try { await jj([ "diffedit", `--revision=${revision.commit!.full}`, "--tool=berry-write", // commits keep whole seconds, so a save redone after the undo would make the same commit, which jj refuses `--config=debug.commit-timestamp=${ new Date(stamped = Math.max(Math.floor(Date.now() / 1000) * 1000, stamped + 1000)) .toISOString() }`, '--config=merge-tools.berry-write.program="sh"', `--config=merge-tools.berry-write.edit-args=${ JSON.stringify(["-c", 'cp "$0" "$1/$2"', staged, "$right", save.path]) }`, ]); } finally { await env.exec(["rm", "-f", staged], { cwd: root }, context); } const caused = [...await conflicted()].filter((id) => !already.has(id)); if (caused.length > 0 && !save.force) { await jj(["op", "restore", operation]); throw new Error( `Saving would cause conflicts in ${caused.length} later commit${ caused.length === 1 ? "" : "s" } (${caused.join(", ")}).`, ); } // the working copy, if a descendant, moves onto the rewrite; this checks it out await jjAt(env, root, ["workspace", "update-stale"], context); const rewritten = await jjRevision(env, root, change!, context, false); return { id: save.id, working: { ...save.working, identity: rewritten!.commit!.full }, path: save.path, version: (await text(save.content)).version, patch, }; }); }