char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somdiff: edit mutable jj commits, warning before a save that conflicts their descendants15e7101

main
9.1 KiB286 linesraw
1// A conversation's repository through its environment: jj's when there is one, else git's.
2
3import type { Context } from "@earendil-works/chord";
4import { getOrThrow } from "@earendil-works/pi-durable/env";
5import type { Environment } from "@berry/api";
6import { type FileDiff, narrow, parsePatch } from "./patch.ts";
7import type { Id, Revision, Shown } from "./types.ts";
8
9/** Enough context that each file's one hunk is the whole file, so the view can highlight and unfold all of it. */
10const WHOLE = 1_000_000;
11/** Files longer than this keep only some context around their changes. */
12const LONG = 20_000;
13const CONTEXT = 5;
14/** How many untracked files git's working tree shows in full. */
15const UNTRACKED = 100;
16
17type Repository = { vcs: "jj" | "git"; root: string };
18
19async function run(
20  env: Environment,
21  argv: string[],
22  context: Context,
23  { ok = [0], cwd = env.cwd }: { ok?: number[]; cwd?: string } = {},
24) {
25  const streams = { stdout: "", stderr: "" };
26  const result = getOrThrow(
27    await env.exec(argv, {
28      cwd,
29      onOutput: (text, _, { stream }) => void (streams[stream] += text),
30    }, context),
31  );
32  if (!ok.includes(result.exitCode ?? -1)) {
33    throw new Error(streams.stderr.trim() || `${argv[0]} exited with ${result.exitCode}`);
34  }
35  return streams.stdout;
36}
37
38const jj = (env: Environment, repo: Repository, args: string[], context: Context) =>
39  run(env, ["jj", "--no-pager", "--color=never", ...args], context, { cwd: repo.root });
40
41const git = (
42  env: Environment,
43  repo: Repository,
44  args: string[],
45  context: Context,
46  ok?: number[],
47) =>
48  run(
49    env,
50    ["git", "--no-pager", "-c", "core.quotePath=false", "-c", "color.ui=false", ...args],
51    context,
52    { cwd: repo.root, ok },
53  );
54
55/** The repository around the environment's working directory: jj's first, so a colocated one reads as jj. */
56async function repository(env: Environment, context: Context): Promise<Repository> {
57  const attempt = async (vcs: Repository["vcs"], argv: string[]) => {
58    try {
59      return { vcs, root: (await run(env, argv, context)).trim() };
60    } catch {
61      return null;
62    }
63  };
64  const found = await attempt("jj", ["jj", "--ignore-working-copy", "root"]) ??
65    await attempt("git", ["git", "rev-parse", "--show-toplevel"]);
66  if (found === null) throw new Error(`${env.cwd} is in neither a jj nor a git repository`);
67  return found;
68}
69
70const id = (full: string, prefix: string, rest: string): Id => ({ full, prefix, rest });
71
72const JJ_REVISION = [
73  "json(self)",
74  "change_id.shortest(8).prefix()",
75  "change_id.shortest(8).rest()",
76  "commit_id.shortest(8).prefix()",
77  "commit_id.shortest(8).rest()",
78  'if(current_working_copy, "@") ++ if(empty, "e") ++ if(immutable, "i") ++ if(conflict, "c")',
79  'separate(" ", bookmarks, tags)',
80].join(' ++ "\\x1f" ++ ') + ' ++ "\\n"';
81
82async function jjRevisions(
83  env: Environment,
84  repo: Repository,
85  revset: string,
86  limit: number,
87  context: Context,
88  snapshot: boolean,
89): Promise<Revision[]> {
90  const out = await jj(env, repo, [
91    "log",
92    ...(snapshot ? [] : ["--ignore-working-copy"]),
93    "--no-graph",
94    `--revisions=${revset}`,
95    `--limit=${limit}`,
96    `--template=${JJ_REVISION}`,
97  ], context);
98  return out.split("\n").filter(Boolean).map((line) => {
99    const [json, changePrefix, changeRest, commitPrefix, commitRest, flags, refs] = line.split(
100      "\x1f",
101    );
102    const commit = JSON.parse(json) as {
103      change_id: string;
104      commit_id: string;
105      description: string;
106      author: { name: string; email: string; timestamp: string };
107    };
108    return {
109      route: flags.includes("@") ? "" : commit.change_id,
110      change: id(commit.change_id, changePrefix, changeRest),
111      commit: id(commit.commit_id, commitPrefix, commitRest),
112      description: commit.description,
113      author: commit.author,
114      refs: refs ? refs.split(" ") : [],
115      workingCopy: flags.includes("@"),
116      empty: flags.includes("e"),
117      immutable: flags.includes("i"),
118      conflict: flags.includes("c"),
119    };
120  });
121}
122
123const GIT_REVISION = "%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%B%x1e";
124
125async function gitRevisions(
126  env: Environment,
127  repo: Repository,
128  args: string[],
129  context: Context,
130): Promise<Revision[]> {
131  const out = await git(env, repo, ["log", `--format=${GIT_REVISION}`, ...args, "--"], context);
132  return out.split("\x1e").map((record) => record.replace(/^\n/, "")).filter(Boolean).map(
133    (record) => {
134      const [hash, short, name, email, timestamp, refs, description] = record.split("\x1f");
135      return {
136        route: hash,
137        change: null,
138        commit: id(hash, "", short),
139        description,
140        author: { name, email, timestamp },
141        refs: refs ? refs.split(", ") : [],
142        workingCopy: false,
143        empty: false,
144        immutable: false,
145        conflict: false,
146      };
147    },
148  );
149}
150
151const WORKING_TREE: Revision = {
152  route: "",
153  change: null,
154  commit: null,
155  description: "",
156  author: null,
157  refs: [],
158  workingCopy: true,
159  empty: false,
160  immutable: false,
161  conflict: false,
162};
163
164/** jj in the repository at `root`, for edits to its history. */
165export const jjAt = (env: Environment, root: string, args: string[], context: Context) =>
166  jj(env, { vcs: "jj", root }, args, context);
167
168/** A jj change as it is now; `snapshot` takes in the working copy's edits first. */
169export async function jjRevision(
170  env: Environment,
171  root: string,
172  change: string,
173  context: Context,
174  snapshot: boolean,
175): Promise<Revision | undefined> {
176  return (await jjRevisions(env, { vcs: "jj", root }, checked(change, "jj"), 1, context, snapshot))[
177    0
178  ];
179}
180
181/** Routes name a jj change or a commit by its id, never by a revset or an option. */
182function checked(route: string, vcs: Repository["vcs"]) {
183  const valid = vcs === "jj" ? /^(?:[k-z]{1,64}|[0-9a-f]{4,64})$/ : /^[0-9a-f]{4,64}$/;
184  if (!valid.test(route)) {
185    throw new Error(`Not a ${vcs === "jj" ? "change or commit" : "commit"} id: ${route}`);
186  }
187  return route;
188}
189
190export const bounded = (files: FileDiff[]) =>
191  files.map((file) =>
192    file.hunks.reduce((sum, hunk) => sum + hunk.lines.length, 0) > LONG
193      ? narrow(file, CONTEXT)
194      : file
195  );
196
197/**
198 * The repository's recent history and one revision's changes: `route` names it, or the working copy when empty. jj
199 * snapshots the working copy once, first, so that the log and the changes agree.
200 */
201export async function show(
202  env: Environment,
203  route: string,
204  limit: number,
205  context: Context,
206  whole = false,
207): Promise<Shown> {
208  const repo = await repository(env, context);
209  if (repo.vcs === "jj") {
210    const log = await jjRevisions(env, repo, "ancestors(@) ~ root()", limit, context, true);
211    const revset = route === "" ? "@" : checked(route, "jj");
212    const revision = log.find((revision) => revision.route === route) ??
213      (await jjRevisions(env, repo, revset, 1, context, false))[0];
214    if (revision === undefined) throw new Error(`No revision ${route}`);
215    const patch = await jj(env, repo, [
216      "diff",
217      "--ignore-working-copy",
218      "--git",
219      `--context=${WHOLE}`,
220      `--revisions=${revset}`,
221    ], context);
222    const files = parsePatch(patch);
223    return { ...repo, log, revision, files: whole ? files : bounded(files), omitted: 0 };
224  }
225
226  const head = await git(env, repo, ["rev-parse", "--verify", "--quiet", "HEAD"], context, [0, 1]);
227  const log = [
228    {
229      ...WORKING_TREE,
230      conflict: !!(await git(env, repo, ["ls-files", "--unmerged", "-z"], context)),
231    },
232    ...(head.trim()
233      ? await gitRevisions(env, repo, [`--max-count=${limit - 1}`, "HEAD"], context)
234      : []),
235  ];
236  const options = [
237    "-M",
238    "--no-ext-diff",
239    "--no-relative",
240    "--src-prefix=a/",
241    "--dst-prefix=b/",
242    `--unified=${WHOLE}`,
243  ];
244  if (route !== "") {
245    const hash = checked(route, "git");
246    const revision = log.find((revision) => revision.route === hash) ??
247      (await gitRevisions(env, repo, ["--max-count=1", hash], context))[0];
248    const patch = await git(env, repo, [
249      "show",
250      "--format=",
251      "--diff-merges=first-parent",
252      ...options,
253      revision.route,
254      "--",
255    ], context);
256    const files = parsePatch(patch);
257    return { ...repo, log, revision, files: whole ? files : bounded(files), omitted: 0 };
258  }
259
260  // the working tree against HEAD, or against nothing before the first commit
261  const base = head.trim() || "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
262  const tracked = parsePatch(await git(env, repo, ["diff", ...options, base, "--"], context));
263  const untracked =
264    (await git(env, repo, ["ls-files", "--others", "--exclude-standard", "-z"], context))
265      .split("\0").filter(Boolean);
266  const added = await Promise.all(
267    untracked.slice(0, UNTRACKED).map(async (path) =>
268      parsePatch(
269        await git(env, repo, ["diff", "--no-index", ...options, "--", "/dev/null", path], context, [
270          0,
271          1,
272        ]),
273      )
274    ),
275  );
276  const files = [...tracked, ...added.flat()].sort((a, b) =>
277    a.path < b.path ? -1 : a.path > b.path ? 1 : 0
278  );
279  return {
280    ...repo,
281    log,
282    revision: log[0],
283    files: whole ? files : bounded(files),
284    omitted: Math.max(0, untracked.length - UNTRACKED),
285  };
286}