char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somallow editing conflicted commit diffs80904d5

main
12.2 KiB314 linesraw
1import type { Context } from "@earendil-works/chord";
2import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
3import { getOrThrow } from "@earendil-works/pi-durable/env";
4import type { Environment } from "@berry/api";
5import { createPatch } from "diff";
6import type { OpenFile, Save, Saved, WorkingCopy } from "./edit-types.ts";
7import { linesOf } from "./model.ts";
8import type { Shown } from "./types.ts";
9import { editable } from "./eligibility.ts";
10import { bounded, jjAt, jjRevision, show } from "./vcs.ts";
11
12const MAX_BYTES = 4 * 1024 * 1024;
13const mutations = new Map<string, Promise<unknown>>();
14
15function working(env: Environment, shown: Shown): WorkingCopy {
16  return {
17    machine: env.id,
18    root: shown.root,
19    vcs: shown.vcs,
20    identity: shown.vcs === "jj" ? shown.revision.change!.full : shown.log[1]?.commit?.full ?? "",
21  };
22}
23
24function validPath(path: string) {
25  if (
26    !path || path.includes("\\") || path.includes("\0") || /^[a-z]:/i.test(path) ||
27    path.split("/").some((part) => !part || [".", "..", ".git", ".jj"].includes(part.toLowerCase()))
28  ) {
29    throw new Error("Not an editable repository path.");
30  }
31}
32
33async function checkedPath(env: Environment, root: string, path: string, context: Context) {
34  validPath(path);
35  const canonicalRoot = getOrThrow(await env.canonicalPath(root, context));
36  const absolute = getOrThrow(await env.joinPath([canonicalRoot, path], context));
37  if (getOrThrow(await env.canonicalPath(absolute, context)) !== absolute) {
38    throw new Error("Editing through symbolic links is not supported.");
39  }
40  return absolute;
41}
42
43async function read(env: Environment, path: string, context: Context) {
44  const reader = getOrThrow(await env.openBinaryReader(path, { noFollow: true }, context));
45  let bytes: Uint8Array;
46  try {
47    const info = getOrThrow(await reader.info(context));
48    if (info.size > MAX_BYTES) throw new Error("Diff editing is limited to 4 MiB text files.");
49    bytes = getOrThrow(await reader.read(0, info.size, context));
50    if (bytes.length !== info.size) throw new Error("File changed while reading; try again.");
51  } finally {
52    await reader.close(context);
53  }
54  return await text(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes));
55}
56
57async function text(content: string) {
58  if (content.includes("\0")) throw new Error("Binary files cannot be edited.");
59  if (content.includes("\r") && /\r(?!\n)|(?<!\r)\n/.test(content)) {
60    throw new Error("Mixed or bare-CR line endings are not supported by the diff editor.");
61  }
62  const bytes = new TextEncoder().encode(content);
63  if (bytes.length > MAX_BYTES) throw new Error("Diff editing is limited to 4 MiB text files.");
64  return { content, version: await version(bytes) };
65}
66
67async function version(bytes: Uint8Array): Promise<string> {
68  return Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes.slice())))
69    .map((byte) => byte.toString(16).padStart(2, "0")).join("");
70}
71
72export async function openFile(
73  env: Environment,
74  root: string,
75  path: string,
76  context: Context,
77  route = "",
78): Promise<OpenFile> {
79  if (route !== "") return await openCommit(env, root, path, route, context);
80  const shown = await show(env, "", 2, context, true);
81  if (shown.root !== root) throw new Error("The conversation moved to another repository.");
82  const copy = working(env, shown);
83  const file = shown.files.find((file) => file.path === path);
84  if (!file || !editable(file)) throw new Error("This is not an editable working-copy file.");
85  const absolute = await checkedPath(env, root, path, context);
86  const current = await read(env, absolute, context);
87  const lines = linesOf(file).hunks.flatMap((hunk) => hunk.lines);
88  const side = (which: "old" | "new") =>
89    lines.filter((line) => line[which] !== null)
90      .map((line) => line.text + (line.newline ? "\n" : "")).join("");
91  if (
92    lines.length && side("new").replace(/\r\n/g, "\n") !== current.content.replace(/\r\n/g, "\n")
93  ) {
94    throw new Error("File changed while opening; refresh and try again.");
95  }
96  const original = file.status === "added" ? "" : lines.length ? side("old") : current.content;
97  if (new TextEncoder().encode(original).length > MAX_BYTES) {
98    throw new Error("Diff editing is limited to 4 MiB on each side.");
99  }
100  return {
101    working: copy,
102    file: bounded([file])[0],
103    original,
104    ...current,
105  };
106}
107
108/** Run `task` after the ones before it under `key`. */
109async function serialized<T>(key: string, task: () => Promise<T>): Promise<T> {
110  const result = (mutations.get(key) ?? Promise.resolve()).then(task);
111  const settled = result.catch(() => {});
112  mutations.set(key, settled);
113  void settled.then(() => mutations.get(key) === settled && mutations.delete(key));
114  return await result;
115}
116
117function report(path: string, before: string, after: string) {
118  const patch = before === after
119    ? ""
120    : createPatch(path, before, after, "", "", { context: 3, timeout: 1000 });
121  if (patch === undefined) throw new Error("Edit is too large to report; save a smaller change.");
122  return patch;
123}
124
125export async function saveFile(env: Environment, save: Save): Promise<Saved> {
126  const bytes = new TextEncoder().encode(save.content);
127  if (bytes.length > MAX_BYTES || save.content.includes("\0")) {
128    throw new Error("Diff editing requires a text file no larger than 4 MiB.");
129  }
130  if (save.working.change !== undefined) return await saveCommit(env, save);
131  return await serialized(`${env.id}\0${save.working.root}\0${save.path}`, async () => {
132    const shown = await show(env, "", 2, BACKGROUND_CONTEXT);
133    const copy = working(env, shown);
134    if (
135      (Object.keys(copy) as (keyof WorkingCopy)[]).some((key) => copy[key] !== save.working[key])
136    ) {
137      throw new Error("The working copy changed; reopen the file before saving.");
138    }
139    const absolute = await checkedPath(env, shown.root, save.path, BACKGROUND_CONTEXT);
140    const before = await read(env, absolute, BACKGROUND_CONTEXT);
141    if (before.version !== save.version) {
142      throw new Error("This file changed on disk since you opened it.");
143    }
144    const patch = report(save.path, before.content, save.content);
145    // writing the existing file preserves its permissions; external writers still need conflict checks
146    if (patch) getOrThrow(await env.writeFile(absolute, bytes, BACKGROUND_CONTEXT));
147    return {
148      id: save.id,
149      working: save.working,
150      path: save.path,
151      version: await version(bytes),
152      patch,
153    };
154  });
155}
156
157/** Reword one mutable jj change without overwriting a description edited elsewhere. */
158export async function reword(
159  env: Environment,
160  root: string,
161  change: string,
162  before: string,
163  description: string,
164): Promise<void> {
165  await serialized(`${env.id}\0${root}`, async () => {
166    const context = BACKGROUND_CONTEXT;
167    const current = await show(env, change, 1, context);
168    if (current.root !== root) throw new Error("The conversation moved to another repository.");
169    if (current.vcs !== "jj") throw new Error("Only jj descriptions can be edited.");
170    const { revision } = current;
171    if (revision.immutable) throw new Error("This commit is immutable.");
172    if (revision.description !== before) {
173      throw new Error("This description changed since you opened it. Reopen it before saving.");
174    }
175    if (description === before) return;
176    await jjAt(env, root, [
177      "describe",
178      revision.commit!.full,
179      `--message=${description}`,
180    ], context);
181  });
182}
183
184/** The last commit time a rewrite was given. */
185let stamped = 0;
186
187/** A jj fileset naming exactly `path`. */
188const fileset = (path: string) => `root-file:${JSON.stringify(path)}`;
189
190async function openCommit(
191  env: Environment,
192  root: string,
193  path: string,
194  route: string,
195  context: Context,
196): Promise<OpenFile> {
197  validPath(path);
198  const shown = await show(env, route, 2, context, true);
199  if (shown.root !== root) throw new Error("The conversation moved to another repository.");
200  const { revision } = shown;
201  if (shown.vcs !== "jj" || revision.workingCopy) throw new Error("Only jj commits can be edited.");
202  if (revision.immutable) throw new Error("This commit is immutable.");
203  const file = shown.files.find((file) => file.path === path);
204  if (!file || !editable(file)) throw new Error("This is not an editable file in this commit.");
205  const current = await text(
206    await jjAt(env, root, [
207      "file",
208      "show",
209      "--ignore-working-copy",
210      `--revision=${revision.commit!.full}`,
211      "--",
212      fileset(path),
213    ], context),
214  );
215  const lines = linesOf(file).hunks.flatMap((hunk) => hunk.lines);
216  const original = file.status === "added" ? "" : lines.filter((line) => line.old !== null)
217    .map((line) => line.text + (line.newline ? "\n" : "")).join("");
218  return {
219    working: {
220      machine: env.id,
221      root,
222      vcs: "jj",
223      identity: revision.commit!.full,
224      change: revision.change!.full,
225    },
226    file: bounded([file])[0],
227    original,
228    ...current,
229  };
230}
231
232/**
233 * Rewrite a jj commit's file through `jj diffedit`, its descendants rebasing onto it. The rewrite leaves the working
234 * copy alone until it stands, so that one undone for the conflicts it would cause never touches the files on disk.
235 */
236async function saveCommit(env: Environment, save: Save): Promise<Saved> {
237  validPath(save.path);
238  const { root, change } = save.working;
239  // a rewrite moves every descendant, so one at a time per repository
240  return await serialized(`${env.id}\0${root}`, async () => {
241    const context = BACKGROUND_CONTEXT;
242    const jj = (args: string[]) => jjAt(env, root, ["--ignore-working-copy", ...args], context);
243    const revision = await jjRevision(env, root, change!, context, true);
244    if (!revision || revision.commit!.full !== save.working.identity) {
245      throw new Error("This commit changed since you opened it.");
246    }
247    if (revision.immutable) throw new Error("This commit is immutable.");
248    const before = await text(
249      await jj(["file", "show", `--revision=${revision.commit!.full}`, "--", fileset(save.path)]),
250    );
251    const patch = report(save.path, before.content, save.content);
252    if (!patch) {
253      return {
254        id: save.id,
255        working: save.working,
256        path: save.path,
257        version: before.version,
258        patch,
259      };
260    }
261    const conflicted = async () =>
262      new Set(
263        (await jj([
264          "log",
265          "--no-graph",
266          `--revisions=descendants(${change}) & conflicts()`,
267          '--template=change_id.short() ++ "\\n"',
268        ])).split("\n").filter(Boolean),
269      );
270    const already = await conflicted();
271    const operation = (await jj(["op", "log", "--no-graph", "--limit=1", "--template=id"])).trim();
272    const staged = getOrThrow(
273      await env.joinPath([root, ".jj", `berry-edit-${crypto.randomUUID()}`], context),
274    );
275    getOrThrow(await env.writeFile(staged, new TextEncoder().encode(save.content), context));
276    try {
277      await jj([
278        "diffedit",
279        `--revision=${revision.commit!.full}`,
280        "--tool=berry-write",
281        // commits keep whole seconds, so a save redone after the undo would make the same commit, which jj refuses
282        `--config=debug.commit-timestamp=${
283          new Date(stamped = Math.max(Math.floor(Date.now() / 1000) * 1000, stamped + 1000))
284            .toISOString()
285        }`,
286        '--config=merge-tools.berry-write.program="sh"',
287        `--config=merge-tools.berry-write.edit-args=${
288          JSON.stringify(["-c", 'cp "$0" "$1/$2"', staged, "$right", save.path])
289        }`,
290      ]);
291    } finally {
292      await env.exec(["rm", "-f", staged], { cwd: root }, context);
293    }
294    const caused = [...await conflicted()].filter((id) => !already.has(id));
295    if (caused.length > 0 && !save.force) {
296      await jj(["op", "restore", operation]);
297      throw new Error(
298        `Saving would cause conflicts in ${caused.length} later commit${
299          caused.length === 1 ? "" : "s"
300        } (${caused.join(", ")}).`,
301      );
302    }
303    // the working copy, if a descendant, moves onto the rewrite; this checks it out
304    await jjAt(env, root, ["workspace", "update-stale"], context);
305    const rewritten = await jjRevision(env, root, change!, context, false);
306    return {
307      id: save.id,
308      working: { ...save.working, identity: rewritten!.commit!.full },
309      path: save.path,
310      version: (await text(save.content)).version,
311      patch,
312    };
313  });
314}