// A conversation's repository through its environment: jj's when there is one, else git's. import type { Context } from "@earendil-works/chord"; import { getOrThrow } from "@earendil-works/pi-durable/env"; import type { Environment } from "@berry/api"; import { type FileDiff, narrow, parsePatch } from "./patch.ts"; import type { Id, Revision, Shown } from "./types.ts"; /** Enough context that each file's one hunk is the whole file, so the view can highlight and unfold all of it. */ const WHOLE = 1_000_000; /** Files longer than this keep only some context around their changes. */ const LONG = 20_000; const CONTEXT = 5; /** How many untracked files git's working tree shows in full. */ const UNTRACKED = 100; type Repository = { vcs: "jj" | "git"; root: string }; async function run( env: Environment, argv: string[], context: Context, { ok = [0], cwd = env.cwd }: { ok?: number[]; cwd?: string } = {}, ) { const streams = { stdout: "", stderr: "" }; const result = getOrThrow( await env.exec(argv, { cwd, onOutput: (text, _, { stream }) => void (streams[stream] += text), }, context), ); if (!ok.includes(result.exitCode ?? -1)) { throw new Error(streams.stderr.trim() || `${argv[0]} exited with ${result.exitCode}`); } return streams.stdout; } const jj = (env: Environment, repo: Repository, args: string[], context: Context) => run(env, ["jj", "--no-pager", "--color=never", ...args], context, { cwd: repo.root }); const git = ( env: Environment, repo: Repository, args: string[], context: Context, ok?: number[], ) => run( env, ["git", "--no-pager", "-c", "core.quotePath=false", "-c", "color.ui=false", ...args], context, { cwd: repo.root, ok }, ); /** The repository around the environment's working directory: jj's first, so a colocated one reads as jj. */ async function repository(env: Environment, context: Context): Promise { const attempt = async (vcs: Repository["vcs"], argv: string[]) => { try { return { vcs, root: (await run(env, argv, context)).trim() }; } catch { return null; } }; const found = await attempt("jj", ["jj", "--ignore-working-copy", "root"]) ?? await attempt("git", ["git", "rev-parse", "--show-toplevel"]); if (found === null) throw new Error(`${env.cwd} is in neither a jj nor a git repository`); return found; } const id = (full: string, prefix: string, rest: string): Id => ({ full, prefix, rest }); const JJ_REVISION = [ "json(self)", "change_id.shortest(8).prefix()", "change_id.shortest(8).rest()", "commit_id.shortest(8).prefix()", "commit_id.shortest(8).rest()", 'if(current_working_copy, "@") ++ if(empty, "e") ++ if(immutable, "i") ++ if(conflict, "c")', 'separate(" ", bookmarks, tags)', ].join(' ++ "\\x1f" ++ ') + ' ++ "\\n"'; async function jjRevisions( env: Environment, repo: Repository, revset: string, limit: number, context: Context, snapshot: boolean, ): Promise { const out = await jj(env, repo, [ "log", ...(snapshot ? [] : ["--ignore-working-copy"]), "--no-graph", `--revisions=${revset}`, `--limit=${limit}`, `--template=${JJ_REVISION}`, ], context); return out.split("\n").filter(Boolean).map((line) => { const [json, changePrefix, changeRest, commitPrefix, commitRest, flags, refs] = line.split( "\x1f", ); const commit = JSON.parse(json) as { change_id: string; commit_id: string; description: string; author: { name: string; email: string; timestamp: string }; }; return { route: flags.includes("@") ? "" : commit.change_id, change: id(commit.change_id, changePrefix, changeRest), commit: id(commit.commit_id, commitPrefix, commitRest), description: commit.description, author: commit.author, refs: refs ? refs.split(" ") : [], workingCopy: flags.includes("@"), empty: flags.includes("e"), immutable: flags.includes("i"), conflict: flags.includes("c"), }; }); } const GIT_REVISION = "%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%B%x1e"; async function gitRevisions( env: Environment, repo: Repository, args: string[], context: Context, ): Promise { const out = await git(env, repo, ["log", `--format=${GIT_REVISION}`, ...args, "--"], context); return out.split("\x1e").map((record) => record.replace(/^\n/, "")).filter(Boolean).map( (record) => { const [hash, short, name, email, timestamp, refs, description] = record.split("\x1f"); return { route: hash, change: null, commit: id(hash, "", short), description, author: { name, email, timestamp }, refs: refs ? refs.split(", ") : [], workingCopy: false, empty: false, immutable: false, conflict: false, }; }, ); } const WORKING_TREE: Revision = { route: "", change: null, commit: null, description: "", author: null, refs: [], workingCopy: true, empty: false, immutable: false, conflict: false, }; /** jj in the repository at `root`, for edits to its history. */ export const jjAt = (env: Environment, root: string, args: string[], context: Context) => jj(env, { vcs: "jj", root }, args, context); /** A jj change as it is now; `snapshot` takes in the working copy's edits first. */ export async function jjRevision( env: Environment, root: string, change: string, context: Context, snapshot: boolean, ): Promise { return (await jjRevisions(env, { vcs: "jj", root }, checked(change, "jj"), 1, context, snapshot))[ 0 ]; } /** Routes name a jj change or a commit by its id, never by a revset or an option. */ function checked(route: string, vcs: Repository["vcs"]) { const valid = vcs === "jj" ? /^(?:[k-z]{1,64}|[0-9a-f]{4,64})$/ : /^[0-9a-f]{4,64}$/; if (!valid.test(route)) { throw new Error(`Not a ${vcs === "jj" ? "change or commit" : "commit"} id: ${route}`); } return route; } export const bounded = (files: FileDiff[]) => files.map((file) => file.hunks.reduce((sum, hunk) => sum + hunk.lines.length, 0) > LONG ? narrow(file, CONTEXT) : file ); /** * The repository's recent history and one revision's changes: `route` names it, or the working copy when empty. jj * snapshots the working copy once, first, so that the log and the changes agree. */ export async function show( env: Environment, route: string, limit: number, context: Context, whole = false, ): Promise { const repo = await repository(env, context); if (repo.vcs === "jj") { const log = await jjRevisions(env, repo, "ancestors(@) ~ root()", limit, context, true); const revset = route === "" ? "@" : checked(route, "jj"); const revision = log.find((revision) => revision.route === route) ?? (await jjRevisions(env, repo, revset, 1, context, false))[0]; if (revision === undefined) throw new Error(`No revision ${route}`); const patch = await jj(env, repo, [ "diff", "--ignore-working-copy", "--git", `--context=${WHOLE}`, `--revisions=${revset}`, ], context); const files = parsePatch(patch); return { ...repo, log, revision, files: whole ? files : bounded(files), omitted: 0 }; } const head = await git(env, repo, ["rev-parse", "--verify", "--quiet", "HEAD"], context, [0, 1]); const log = [ { ...WORKING_TREE, conflict: !!(await git(env, repo, ["ls-files", "--unmerged", "-z"], context)), }, ...(head.trim() ? await gitRevisions(env, repo, [`--max-count=${limit - 1}`, "HEAD"], context) : []), ]; const options = [ "-M", "--no-ext-diff", "--no-relative", "--src-prefix=a/", "--dst-prefix=b/", `--unified=${WHOLE}`, ]; if (route !== "") { const hash = checked(route, "git"); const revision = log.find((revision) => revision.route === hash) ?? (await gitRevisions(env, repo, ["--max-count=1", hash], context))[0]; const patch = await git(env, repo, [ "show", "--format=", "--diff-merges=first-parent", ...options, revision.route, "--", ], context); const files = parsePatch(patch); return { ...repo, log, revision, files: whole ? files : bounded(files), omitted: 0 }; } // the working tree against HEAD, or against nothing before the first commit const base = head.trim() || "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; const tracked = parsePatch(await git(env, repo, ["diff", ...options, base, "--"], context)); const untracked = (await git(env, repo, ["ls-files", "--others", "--exclude-standard", "-z"], context)) .split("\0").filter(Boolean); const added = await Promise.all( untracked.slice(0, UNTRACKED).map(async (path) => parsePatch( await git(env, repo, ["diff", "--no-index", ...options, "--", "/dev/null", path], context, [ 0, 1, ]), ) ), ); const files = [...tracked, ...added.flat()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0 ); return { ...repo, log, revision: log[0], files: whole ? files : bounded(files), omitted: Math.max(0, untracked.length - UNTRACKED), }; }