import { assert, assertEquals, assertRejects, assertStringIncludes } from "@std/assert"; import { chmod, readFile, symlink, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { localEnvironment } from "@berry/api/environment.ts"; import { testExtension } from "@berry/api/testing.ts"; import diff from "./mod.ts"; import type { Reply, Shown } from "./types.ts"; import type { OpenFile, Save, Saved } from "./edit-types.ts"; import { onPath, tempDir } from "@berry/util"; const IDENTITY = { JJ_USER: "Test", JJ_EMAIL: "test@example.com", GIT_AUTHOR_NAME: "Test", GIT_AUTHOR_EMAIL: "test@example.com", GIT_COMMITTER_NAME: "Test", GIT_COMMITTER_EMAIL: "test@example.com", GIT_CONFIG_GLOBAL: "/dev/null", JJ_CONFIG: "/dev/null", }; /** For setting repositories up; the extension reads them with whatever configuration it finds. */ async function run(cwd: string, program: string, ...args: string[]) { const result = await new Deno.Command(program, { args, cwd, env: IDENTITY, stdout: "piped", stderr: "piped", }).output(); if (!result.success) throw new Error(new TextDecoder().decode(result.stderr)); return new TextDecoder().decode(result.stdout).trim(); } async function repository(test: (root: string) => Promise) { await using rootDir = await tempDir({ prefix: "diff-test-" }); const root = rootDir.path; await test(root); } /** What a view that had nothing would be shown. */ const shown = async (root: string, route = ""): Promise => { const reply = await testExtension(diff, { env: localEnvironment(root) }) .request("show", { route }) as Reply; return { ...reply, files: reply.files.map(({ diff }) => diff!) }; }; /** A save from the session "review" of `content` over what `opened` read. */ const saving = (opened: OpenFile, content: string, path = opened.file.path): Save => ({ session: "review", id: "edit", working: opened.working, path, version: opened.version, content, }); const summary = ({ files }: Shown) => files.map(({ path, status, hunks }) => ({ path, status, lines: hunks.flatMap((h) => h.lines) })); Deno.test({ name: "jj: the working copy's changes come whole, its history is browsable, and edits show at once", ignore: !onPath("jj"), fn: () => repository(async (root) => { await run(root, "jj", "git", "init"); await writeFile(join(root, "a.txt"), "one\ntwo\nthree\n"); await run(root, "jj", "commit", "-m", "first\n\nwith a body"); await writeFile(join(root, "a.txt"), "one\n2\nthree\n"); const working = await shown(root); assertEquals(working.vcs, "jj"); assertEquals(working.root, root); assertEquals(working.revision.workingCopy, true); assertEquals( working.revision.change!.full, await run( root, "jj", "log", "--ignore-working-copy", "--no-graph", "-r", "@", "-T", "change_id", ), ); assertEquals( working.revision.commit!.full, await run( root, "jj", "log", "--ignore-working-copy", "--no-graph", "-r", "@", "-T", "commit_id", ), ); assertEquals(summary(working), [ { path: "a.txt", status: "modified", lines: [" one", "-two", "+2", " three"] }, ]); // the root commit, which never has changes, is left out assertEquals(working.log.map((r) => [r.route === "", r.description]), [ [true, ""], [false, "first\n\nwith a body\n"], ]); const first = await shown(root, working.log[1].route); assertEquals(first.revision.description, "first\n\nwith a body\n"); assertEquals(summary(first), [ { path: "a.txt", status: "added", lines: ["+one", "+two", "+three"] }, ]); await writeFile(join(root, "b.txt"), "new\n"); assertEquals((await shown(root)).files.map(({ path }) => path), ["a.txt", "b.txt"]); }), }); Deno.test({ name: "git: the working tree includes untracked files, and each commit shows against its parent", ignore: !onPath("git"), fn: () => repository(async (root) => { await run(root, "git", "init", "--quiet"); const before = await shown(root); assertEquals(before.vcs, "git"); assertEquals(before.log.length, 1); await writeFile(join(root, "a.txt"), "one\ntwo\n"); await writeFile(join(root, ".gitignore"), "ignored\n"); await run(root, "git", "add", "-A"); await run(root, "git", "commit", "--quiet", "-m", "first"); await run(root, "git", "config", "core.abbrev", "12"); await writeFile(join(root, "a.txt"), "one\n2\n"); await writeFile(join(root, "new file.txt"), "hello\n"); await writeFile(join(root, "ignored"), "secret\n"); const working = await shown(root); assertEquals(summary(working), [ { path: "a.txt", status: "modified", lines: [" one", "-two", "+2"] }, { path: "new file.txt", status: "added", lines: ["+hello"] }, ]); assertEquals(working.log.map((r) => [r.route === "", r.description.trim()]), [ [true, ""], [false, "first"], ]); const first = await shown(root, working.log[1].route); assertEquals(first.revision.refs.some((ref) => ref.includes("HEAD")), true); assertEquals(first.revision.commit, { full: await run(root, "git", "rev-parse", "HEAD"), prefix: "", rest: await run(root, "git", "rev-parse", "--short", "HEAD"), }); assertEquals(first.files.map(({ path, status }) => [path, status]), [ [".gitignore", "added"], ["a.txt", "added"], ]); }), }); Deno.test({ name: "a view is sent only the keys of the changes it has already, until they change", ignore: !onPath("git"), fn: () => repository(async (root) => { await run(root, "git", "init", "--quiet"); await writeFile(join(root, "a.txt"), "one\n"); await writeFile(join(root, "b.txt"), "two\n"); const view = testExtension(diff, { env: localEnvironment(root) }); const ask = async (known: string[]) => (await view.request("show", { route: "", known }) as Reply).files; const first = await ask([]); assertEquals(first.map(({ diff }) => diff?.path), ["a.txt", "b.txt"]); const known = first.map(({ key }) => key); assertEquals(await ask(known), known.map((key) => ({ key, diff: null }))); await writeFile(join(root, "b.txt"), "three\n"); assertEquals((await ask(known)).map(({ diff }) => diff?.path ?? null), [null, "b.txt"]); }), }); Deno.test("outside a repository, or given a route that names no revision, it says so", () => repository(async (root) => { await assertRejects(() => shown(root), Error, "neither a jj nor a git repository"); if (!onPath("git")) return; await run(root, "git", "init", "--quiet"); for (const route of ["--output=/tmp/x", "HEAD~1", "main"]) { await assertRejects(() => shown(root, route), Error, "Not a commit id"); } })); Deno.test({ name: "human saves report only their changes on exit, and retries do not repeat saves or reports", ignore: !onPath("git"), }, () => repository(async (root) => { await run(root, "git", "init", "--quiet"); await writeFile(join(root, "a.txt"), "base\nshared\nend\n"); await run(root, "git", "add", "-A"); await run(root, "git", "commit", "--quiet", "-m", "first"); await writeFile(join(root, "a.txt"), "agent\nshared\nend\n"); const view = testExtension(diff, { env: localEnvironment(root) }); const opened = await view.request("open", { root, path: "a.txt" }) as OpenFile; const save = saving(opened, "agent\nhuman\nend\n"); const [saved, duplicate] = await Promise.all([ view.request("save", save), view.request("save", save), ]) as Saved[]; assertEquals(saved, duplicate); assertEquals(await view.prompt(), ""); assertEquals(view.messages, []); assertEquals(await readFile(join(root, "a.txt"), "utf8"), save.content); await writeFile(join(root, "other.txt"), "later agent edit\n"); await view.command( "diff-finish", JSON.stringify({ session: "review", saves: [saved] }), ); const messages = view.messages; assertEquals(messages.length, 1); const report = messages[0].content; assert(typeof report === "string"); assertStringIncludes(report, "+human"); assert( !report.includes("-base") && !report.includes("+agent") && !report.includes("other.txt"), ); assertEquals(await view.request("save", save), saved); await view.command("diff-finish", "review"); assertEquals(await view.prompt(), ""); assertEquals(view.messages, messages); const next = await view.request("open", { root, path: "a.txt" }) as OpenFile; const secondSave = await view.request("save", { ...saving(next, "agent\nhuman again\nend\n"), session: "second", }) as Saved; await view.command( "diff-finish", JSON.stringify({ session: "second", saves: [secondSave] }), ); assertEquals(view.messages.slice(0, messages.length), messages); const second = view.messages[1].content; assert(typeof second === "string"); assertStringIncludes(second, "+human again"); assertEquals(await view.prompt(), ""); const unchanged = await view.request("open", { root, path: "a.txt" }) as OpenFile; const unchangedSave = await view.request("save", { ...saving(unchanged, unchanged.content), session: "unchanged", }) as Saved; await view.command( "diff-finish", JSON.stringify({ session: "unchanged", saves: [unchangedSave] }), ); assertEquals(view.messages.length, 2); })); Deno.test({ name: "jj edits use complete files even when the displayed patch omits lines, preserving executable CRLF text", ignore: !onPath("jj"), }, () => repository(async (root) => { await run(root, "jj", "git", "init"); const path = "script.txt"; const baseline = "\ufeff" + Array.from({ length: 21_000 }, (_, i) => `line ${i}`).join("\r\n"); await writeFile(join(root, path), baseline); await chmod(join(root, path), 0o755); await run(root, "jj", "commit", "-m", "first"); const content = baseline.replace("line 10000\r", "agent\r"); await writeFile(join(root, path), content); const view = testExtension(diff, { env: localEnvironment(root) }); const display = await view.request("show", {}) as Reply; assert(display.files[0].diff!.hunks.flatMap((h) => h.lines).length < 30); const opened = await view.request("open", { root, path }) as OpenFile; assertEquals(opened.original, baseline); assertEquals(opened.content, content); const edited = content.replace("line 20000\r", "human\r"); await view.request("save", saving(opened, edited)); assertEquals(await readFile(join(root, path), "utf8"), edited); assertEquals((await Deno.stat(join(root, path))).mode! & 0o777, 0o755); })); Deno.test({ name: "a stale file or a different jj working-copy change refuses a save without overwriting", ignore: !onPath("jj"), }, () => repository(async (root) => { await run(root, "jj", "git", "init"); await writeFile(join(root, "a.txt"), "initial\n"); const view = testExtension(diff, { env: localEnvironment(root) }); const opened = await view.request("open", { root, path: "a.txt" }) as OpenFile; const save = saving(opened, "human\n"); await writeFile(join(root, "a.txt"), "external\n"); await assertRejects( () => view.request("save", save), Error, "changed on disk", ); assertEquals(await readFile(join(root, "a.txt"), "utf8"), "external\n"); await writeFile(join(root, "a.txt"), opened.content); await run(root, "jj", "new"); await assertRejects( () => view.request("save", save), Error, "working copy changed", ); assertEquals(await readFile(join(root, "a.txt"), "utf8"), opened.content); assertEquals(await view.prompt(), ""); })); Deno.test({ name: "diff saves cannot escape the repository, edit metadata, or follow a replaced symbolic link", ignore: !onPath("git"), }, () => repository(async (root) => { await run(root, "git", "init", "--quiet"); await writeFile(join(root, "a.txt"), "initial\n"); const view = testExtension(diff, { env: localEnvironment(root) }); const opened = await view.request("open", { root, path: "a.txt" }) as OpenFile; const save = saving(opened, "human\n", "../outside"); await assertRejects( () => view.request("save", save), Error, "repository path", ); await assertRejects( () => view.request("save", { ...save, path: ".git/config" }), Error, "repository path", ); await using outside = await tempDir(); const target = join(outside.path, "target.txt"); await writeFile(target, opened.content); await unlink(join(root, "a.txt")); await symlink(target, join(root, "a.txt")); await assertRejects( () => view.request("save", { ...save, path: "a.txt" }), Error, "symbolic links", ); assertEquals(await readFile(target, "utf8"), opened.content); })); Deno.test({ name: "git merge conflicts are editable, and a save resolves the markers on disk", ignore: !onPath("git"), }, () => repository(async (root) => { await run(root, "git", "init", "--initial-branch=main", "--quiet"); await writeFile(join(root, "a.txt"), "base\n"); await run(root, "git", "add", "-A"); await run(root, "git", "commit", "--quiet", "-m", "base"); await run(root, "git", "checkout", "-qb", "branch"); await writeFile(join(root, "a.txt"), "branch\n"); await run(root, "git", "commit", "-am", "branch", "--quiet"); await run(root, "git", "checkout", "main", "--quiet"); await writeFile(join(root, "a.txt"), "main\n"); await run(root, "git", "commit", "-am", "main", "--quiet"); await assertRejects(() => run(root, "git", "merge", "branch")); const view = testExtension(diff, { env: localEnvironment(root) }); const current = await view.request("show", {}) as Reply; assertEquals(current.revision.conflict, true); const opened = await view.request("open", { root, path: "a.txt" }) as OpenFile; assertStringIncludes(opened.content, "<<<<<<<"); assertEquals(opened.original, "main\n"); await view.request("save", saving(opened, "resolved\n")); assertEquals(await readFile(join(root, "a.txt"), "utf8"), "resolved\n"); })); /** A jj repository whose commit "theirs" changes a.txt the way "ours" does, rebased onto it to conflict. */ async function conflicting(root: string) { await run(root, "jj", "git", "init"); await writeFile(join(root, "a.txt"), "one\ntwo\nthree\n"); await run(root, "jj", "describe", "-m", "base"); await run(root, "jj", "new"); await writeFile(join(root, "a.txt"), "ONE\ntwo\nthree\n"); await run(root, "jj", "describe", "-m", "ours"); const ours = await run(root, "jj", "log", "--no-graph", "-r", "@", "-T", "change_id"); await run(root, "jj", "new", "root()"); await writeFile(join(root, "a.txt"), "one\ntwo\nTHREE\n"); await run(root, "jj", "describe", "-m", "theirs"); const theirs = await run(root, "jj", "log", "--no-graph", "-r", "@", "-T", "change_id"); await run(root, "jj", "new", ours); await run(root, "jj", "rebase", "-s", theirs, "-d", ours); return { ours, theirs }; } Deno.test({ name: "jj: a conflicted commit's file can be edited, resolving its conflict", ignore: !onPath("jj"), }, () => repository(async (root) => { const { theirs } = await conflicting(root); const revision = (await shown(root, theirs)).revision; assertEquals([revision.route, revision.conflict], [theirs, true]); const view = testExtension(diff, { env: localEnvironment(root) }); const opened = await view.request("open", { root, path: "a.txt", route: theirs, }) as OpenFile; assertStringIncludes(opened.content, "<<<<<<<"); assertEquals(opened.original, "ONE\ntwo\nthree\n"); const saved = await view.request("save", saving(opened, "ONE\ntwo\nTHREE\n")) as Saved; assert(saved.working.identity !== opened.working.identity); assertEquals( await run(root, "jj", "file", "show", "-r", theirs, "--", "a.txt"), "ONE\ntwo\nTHREE", ); assertEquals(await run(root, "jj", "log", "--no-graph", "-r", "conflicts()"), ""); })); Deno.test({ name: "jj: a conflicted working copy's file can be edited, resolving its conflict", ignore: !onPath("jj"), }, () => repository(async (root) => { const { theirs } = await conflicting(root); await run(root, "jj", "edit", theirs); const view = testExtension(diff, { env: localEnvironment(root) }); const current = await shown(root); assertEquals([current.revision.workingCopy, current.revision.conflict], [true, true]); const opened = await view.request("open", { root, path: "a.txt" }) as OpenFile; assertStringIncludes(opened.content, "<<<<<<<"); await view.request("save", saving(opened, "ONE\ntwo\nTHREE\n")); assertEquals(await readFile(join(root, "a.txt"), "utf8"), "ONE\ntwo\nTHREE\n"); assertEquals(await run(root, "jj", "log", "--no-graph", "-r", "conflicts()"), ""); })); Deno.test({ name: "an exit waits for an accepted save even when its receipt was not in the exit's starting state", ignore: !onPath("git"), }, () => repository(async (root) => { await run(root, "git", "init", "--quiet"); await writeFile(join(root, "a.txt"), "initial\n"); const env = localEnvironment(root); const entered = Promise.withResolvers(); const release = Promise.withResolvers(); const write = env.writeFile.bind(env); env.writeFile = async (...args) => { entered.resolve(); await release.promise; return await write(...args); }; const view = testExtension(diff, { env }); const opened = await view.request("open", { root, path: "a.txt" }) as OpenFile; const saved = view.request("save", saving(opened, "human\n")); try { await entered.promise; const finishing = view.command("diff-finish", "review"); let finished = false; void finishing.then(() => { finished = true; }); await Promise.resolve(); await Promise.resolve(); assertEquals(finished, false); release.resolve(); await saved; await finishing; const report = view.messages[0]?.content; assert(typeof report === "string"); assertStringIncludes(report, "+human"); } finally { release.resolve(); await saved; } })); Deno.test({ name: "a jj commit's file can be edited, rebasing its descendants, and a save that would conflict them is undone unless forced", ignore: !onPath("jj"), }, () => repository(async (root) => { await run(root, "jj", "git", "init"); await writeFile(join(root, "a.txt"), "one\ntwo\nthree\nfour\nfive\n"); await run(root, "jj", "commit", "-m", "first"); await writeFile(join(root, "a.txt"), "one\ntwo\nthree\nfour\nFIVE\n"); await run(root, "jj", "commit", "-m", "second"); await writeFile(join(root, "b.txt"), "working\n"); const view = testExtension(diff, { env: localEnvironment(root) }); const first = (await shown(root)).log.find((r) => r.description.startsWith("first"))!; const opened = await view.request("open", { root, path: "a.txt", route: first.route, }) as OpenFile; assertEquals(opened.content, "one\ntwo\nthree\nfour\nfive\n"); const saved = await view.request( "save", saving(opened, "ONE\ntwo\nthree\nfour\nfive\n"), ) as Saved; assertEquals(await readFile(join(root, "a.txt"), "utf8"), "ONE\ntwo\nthree\nfour\nFIVE\n"); assertEquals(await readFile(join(root, "b.txt"), "utf8"), "working\n"); assertEquals(await run(root, "jj", "log", "--no-graph", "-r", "conflicts()"), ""); await assertRejects( () => view.request("save", { ...saving(opened, "x\n"), id: "stale" }), Error, "changed since you opened it", ); const again = { ...saving(opened, "ONE\ntwo\nthree\nfour\nfive!\n"), id: "conflicting" }; again.working = saved.working; const operation = await run(root, "jj", "op", "log", "--no-graph", "--limit=1", "-T", "id"); await assertRejects( () => view.request("save", again), Error, "would cause conflicts in 2 later commits", ); assertEquals(await readFile(join(root, "a.txt"), "utf8"), "ONE\ntwo\nthree\nfour\nFIVE\n"); const restored = await run(root, "jj", "op", "log", "--no-graph", "--limit=1", "-T", "id"); assert(restored !== operation); assertEquals(await run(root, "jj", "log", "--no-graph", "-r", "conflicts()"), ""); await view.request("save", { ...again, force: true }); assertStringIncludes(await readFile(join(root, "a.txt"), "utf8"), "<<<<<<<"); await view.command("diff-finish", "review"); const report = view.messages[0]?.content; assert(typeof report === "string"); assertStringIncludes(report, `"commit":"${first.route}"`); await assertRejects( () => view.request("open", { root, path: "a.txt", route: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" }), ); })); Deno.test({ name: "jj: rewording targets the opened change and preserves its files and dirty descendants", ignore: !onPath("jj"), fn: () => repository(async (root) => { await run(root, "jj", "git", "init"); await writeFile(join(root, "a.txt"), "base\n"); await run(root, "jj", "describe", "-m", "original\n\nbody"); const opened = await shown(root); const view = testExtension(diff, { env: localEnvironment(root) }); await run(root, "jj", "new", "-m", "descendant"); await writeFile(join(root, "a.txt"), "dirty descendant\n"); const change = opened.revision.change!.full; await view.request("reword", { root, change, before: opened.revision.description, description: "--new subject\n\nnew body", }); const updated = await shown(root, change); assertEquals(updated.revision.description, "--new subject\n\nnew body\n"); assertEquals(updated.revision.change, opened.revision.change); assertEquals(summary(updated), summary(opened)); const working = await shown(root); assertEquals(working.revision.description, "descendant\n"); assertEquals(working.log[1].change!.full, change); assertEquals(await readFile(join(root, "a.txt"), "utf8"), "dirty descendant\n"); await view.request("reword", { root, change: working.revision.change!.full, before: working.revision.description, description: "", }); assertEquals((await shown(root)).revision.description, ""); }), }); Deno.test({ name: "jj: rewording rejects stale descriptions, another repository, and immutable commits", ignore: !onPath("jj"), fn: () => repository(async (root) => { await run(root, "jj", "git", "init"); await run(root, "jj", "describe", "-m", "original"); const opened = await shown(root); const view = testExtension(diff, { env: localEnvironment(root) }); const args = { root, change: opened.revision.change!.full, before: opened.revision.description, description: "replacement", }; await assertRejects( () => view.request("reword", { ...args, root: `${root}/elsewhere` }), Error, "another repository", ); await run(root, "jj", "describe", "-m", "someone else's edit"); await assertRejects(() => view.request("reword", args), Error, "description changed"); assertEquals((await shown(root)).revision.description, "someone else's edit\n"); await run(root, "jj", "bookmark", "create", "frozen"); await run(root, "jj", "new"); await run( root, "jj", "config", "set", "--repo", 'revset-aliases."immutable_heads()"', "frozen", ); await assertRejects(() => view.request("reword", args), Error, "immutable"); }), });