diff --git a/README.md b/README.md index 29cffed..9c86399 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,13 @@ working tree and your edits are live: ``` git clone https://github.com/vdayanand/gitdiff3d cd gitdiff3d +npm install # only --explain needs it make install # symlinks bin/gitdiff3d.js into ~/bin ``` -Node 18 or newer. No dependencies: three.js is vendored, everything else is the -standard library and the `git` already on your path. To remove it, `npm rm -g +Node 18 or newer. three.js is vendored, everything else is the standard library +and the `git` already on your path; the one dependency, the Anthropic SDK, is +loaded only when you ask for `--explain`. To remove it, `npm rm -g gitdiff3d`, or `make uninstall` for the checkout. ## Use @@ -58,6 +60,7 @@ It builds the scene, serves it on a random local port, and opens your browser. --max-hunks keep only the first n hunks (default: all) --order story or git (default: story) --context lines of unchanged code around each hunk (default: 8) +--explain ask Claude what each hunk does and why (see below) --json print the scene model, do not serve --no-open serve without opening a browser ``` @@ -93,6 +96,26 @@ that name, and failing that, schema before models before logic before callers before tests before prose. It is a topological sort over "who defines what", with the file's role as the tie breaker. `--order git` turns it off. +## Explanations + +The diff knows what changed, never why. `--explain` asks Claude: the whole +change goes in one request, hunks in the order you will see them, with the +commit messages of the range when there are any, and every hunk comes back with +a short group label and a sentence or two on what it does, why, and which other +hunks it leans on. The label and the note sit in a panel over the scene, and `v` +reads them out after the what. + +``` +ANTHROPIC_API_KEY=... gitdiff3d --explain HEAD~3..HEAD +``` + +This sends the diff and the commit messages to the Anthropic API, which is why +it is off unless asked for. The request is made once, before the scene is +served, and takes as long as the model does; the notes are pinned to the lines +they describe, so they follow along when `c` merges or splits hunks. If the call +fails the scene is served anyway and the header says why. It uses +`claude-opus-5`, with Anthropic's server side fallback should that model decline. + ## Overlay: it defeats change blindness Press `l` and the old version of the hunk sits behind the new one, both in diff --git a/bin/gitdiff3d.js b/bin/gitdiff3d.js index 3c402ad..f7cf53e 100755 --- a/bin/gitdiff3d.js +++ b/bin/gitdiff3d.js @@ -1,9 +1,10 @@ #!/usr/bin/env node const { execFile } = require("node:child_process"); -const { repoRoot, diffText, headLabel, newSideOf, sourceOf } = require("../src/git"); +const { repoRoot, diffText, headLabel, newSideOf, sourceOf, commitMessages } = require("../src/git"); const { parseDiff } = require("../src/parse-diff"); const { buildScene } = require("../src/layout"); const { orderHunks } = require("../src/order"); +const { explain, withNotes, MODEL } = require("../src/explain"); const { serve } = require("../src/server"); const USAGE = `usage: gitdiff3d [] [-- ...] [options] @@ -20,12 +21,15 @@ options: --order story (definitions before their callers, schema before docs) or git (the order git prints them in). default: story --context lines of unchanged code around each hunk (default: 8) + --explain ask Claude what each hunk does and why; sends the diff and + commit messages to the Anthropic API (needs ANTHROPIC_API_KEY) --json print the scene model, do not serve --no-open serve without opening a browser -h, --help this message`; function readArgs(argv) { - const flags = { port: 0, json: false, open: true, maxHunks: 0, order: "story", context: 8 }; + const flags = { port: 0, json: false, open: true, maxHunks: 0, order: "story", context: 8, + explain: false }; const revisions = []; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; @@ -34,6 +38,7 @@ function readArgs(argv) { else if (arg === "--order") flags.order = argv[(i += 1)]; else if (arg === "--context" || arg === "-U") flags.context = Number(argv[(i += 1)]); else if (arg === "--json") flags.json = true; + else if (arg === "--explain") flags.explain = true; else if (arg === "--no-open") flags.open = false; else if (arg === "-h" || arg === "--help") flags.help = true; else revisions.push(arg); @@ -71,6 +76,8 @@ async function main() { // The player can ask for a wider view of the surrounding code, which means // re-running the diff; each width is built once and kept. const built = new Map(); + let notes = null; + let narrationError = null; const sceneFor = (context) => { const lines = context === null ? flags.context : context; if (!built.has(lines)) { @@ -82,9 +89,22 @@ async function main() { flags.order, )); } - return built.get(lines); + // notes are pinned to changed lines, so they carry over to any width + const scene = withNotes(built.get(lines), notes); + return narrationError ? { ...scene, meta: { ...scene.meta, narrationError } } : scene; }; - const scene = sceneFor(null); + let scene = sceneFor(null); + + // Progress goes to stderr: with --json, stdout is the scene. + if (flags.explain && scene.hunks.length > 0) { + console.error("explaining " + scene.hunks.length + " hunks with " + MODEL + " ..."); + const answer = await explain(scene, commitMessages(revisions, root)); + notes = answer.notes || null; + narrationError = answer.error || null; + console.error(narrationError ? "no explanations: " + narrationError + : "explained " + notes.length + " of " + scene.hunks.length + " hunks"); + scene = sceneFor(null); + } if (flags.json) { console.log(JSON.stringify(scene, null, 2)); diff --git a/check.js b/check.js index 6f591b3..c2d6c02 100644 --- a/check.js +++ b/check.js @@ -54,7 +54,7 @@ function repoWith(files) { } function modulesLoad() { - ["parse-diff", "layout", "structure", "server", "git", "order", "replace"] + ["parse-diff", "layout", "structure", "server", "git", "order", "replace", "explain"] .forEach((name) => require("./src/" + name)); ok(true, "modules load"); execFileSync(process.execPath, [CLI, "--help"], { stdio: "ignore" }); @@ -151,6 +151,46 @@ function carriageReturnsAreDropped() { } } +// The explanation is asked for once, at one width; widening the context merges +// hunks and narrowing splits them, and the notes have to follow the lines. +async function notesFollowTheirLines() { + const { explain, withNotes } = require("./src/explain"); + const body = (n) => Array.from({ length: 12 }, (_, i) => "const v" + i + " = " + n + ";\n").join(""); + const edit = body(1).replace("v1 = 1", "v1 = 2").replace("v10 = 1", "v10 = 2"); + const root = repoWith({ before: { "d.js": body(1) }, after: { "d.js": edit } }); + try { + const apart = sceneOf(root, ["-U", "1"]); + const merged = sceneOf(root, ["-U", "8"]); + ok(apart.hunks.length === 2 && merged.hunks.length === 1, "context width merges two hunks"); + let asked = null; + const client = { beta: { messages: { stream: (params) => { + asked = params; + const hunks = apart.hunks.map((hunk) => ({ index: hunk.index, group: "Bump", note: "Note " + hunk.index + "." })) + .concat([{ index: 99, group: "Stray", note: "Not a hunk." }]); + return { finalMessage: async () => ({ stop_reason: "end_turn", + content: [{ type: "text", text: JSON.stringify({ hunks }) }] }) }; + } } } }; + const { notes, error } = await explain(apart, "", client); + ok(!error && notes.length === 2, "an index that names no hunk is dropped"); + ok(asked.messages[0].content.includes("No commit messages"), "a change without commits is said to be so"); + const one = withNotes(merged, notes).hunks[0]; + ok(one.group === "Bump" && one.note === "Note 1. Note 2.", "a merged hunk reads both notes in order"); + const refused = await explain(apart, "", { beta: { messages: { stream: () => ({ + finalMessage: async () => ({ stop_reason: "refusal", content: [] }) }) } } }); + ok(refused.error && !refused.notes, "a refusal is an error, not an empty answer"); + const answer = JSON.stringify({ hunks: [{ index: 1, group: "Bump", note: "Rescued." }] }); + const rescued = await explain(apart, "", { beta: { messages: { stream: () => ({ + finalMessage: async () => ({ stop_reason: "end_turn", content: [ + { type: "text", text: answer.slice(0, 9) }, + { type: "fallback", from: { model: "a" }, to: { model: "b" } }, + { type: "text", text: answer }, + ] }) }) } } }); + ok(rescued.notes && rescued.notes.length === 1, "a partial answer before a fallback is set aside"); + } finally { + fs.rmSync(root, { recursive: true, force: true, maxRetries: 5 }); + } +} + async function viewerIsServed() { const { serve } = require("./src/server"); const root = repoWith({ @@ -178,6 +218,7 @@ async function main() { scenesHaveSymbols(); spansFindTheChangedWords(); carriageReturnsAreDropped(); + await notesFollowTheirLines(); await viewerIsServed(); console.log("all checks passed on " + process.platform + " node " + process.versions.node); process.exit(0); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..43962a6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,93 @@ +{ + "name": "gitdiff3d", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gitdiff3d", + "version": "0.1.0", + "license": "GPL-3.0-or-later", + "dependencies": { + "@anthropic-ai/sdk": "^0.128.0" + }, + "bin": { + "gitdiff3d": "bin/gitdiff3d.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.128.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.128.0.tgz", + "integrity": "sha512-tl5cBFZC1jVFrTuQyvQObA4WmuNgcYVeXfriqOumgtLAHUm4gPUj18YnszBW60v0PrjDB8nBDOC6wd9CmHQFlA==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json index 879aa83..30c1d43 100644 --- a/package.json +++ b/package.json @@ -11,5 +11,6 @@ "type": "commonjs", "bin": { "gitdiff3d": "./bin/gitdiff3d.js" }, "files": ["bin", "src", "README.md", "LICENSE"], - "engines": { "node": ">=18" } + "engines": { "node": ">=18" }, + "dependencies": { "@anthropic-ai/sdk": "^0.128.0" } } diff --git a/src/explain.js b/src/explain.js new file mode 100644 index 0000000..78e6b57 --- /dev/null +++ b/src/explain.js @@ -0,0 +1,153 @@ +// Why a change exists is not in the diff, so it is asked of Claude: the whole +// change goes in one request, in story order, with whatever commit messages +// the range carries, and each hunk comes back with a group label and a note. +// The viewer already shows and speaks both. + +const MODEL = "claude-opus-5"; + +const SYSTEM = `You explain code changes to a reviewer who is stepping through a diff one hunk at a time, in the order given. + +For every hunk, write: +- group: a label of two to five words naming the part of the overall change this hunk belongs to. Hunks that serve the same purpose share the same label, spelled identically. +- note: one or two plain sentences saying what this hunk does and why it is needed, and how it connects to the rest of the change (for example, which earlier hunk it relies on or which later hunk relies on it). + +The notes are read aloud and shown in a small panel, so write plain prose: no markdown, no backticks, no code, no lists, no line numbers. Name functions and files in words as they appear. Stick to what the diff and the commit messages support; when the reason for a hunk is not evident, say what it does and leave the why out rather than guessing.`; + +const SCHEMA = { + type: "object", + properties: { + hunks: { + type: "array", + items: { + type: "object", + properties: { + index: { type: "integer" }, + group: { type: "string" }, + note: { type: "string" }, + }, + required: ["index", "group", "note"], + additionalProperties: false, + }, + }, + }, + required: ["hunks"], + additionalProperties: false, +}; + +const MARK = { add: "+", del: "-", ctx: " " }; + +const hunkText = (scene, hunk) => { + const file = scene.files[hunk.file]; + const where = file.path + (hunk.symbol ? " (in " + hunk.symbol + ")" : ""); + const body = hunk.lines.map((line) => MARK[line.kind] + line.text).join("\n"); + return "### hunk " + hunk.index + ": " + where + ", " + file.status + "\n" + body; +}; + +function promptOf(scene, messages) { + const history = messages.trim() + ? "Messages of the commits in this change:\n\n" + messages.trim() + : "No commit messages are available for this change."; + return history + "\n\nThe hunks, in the order the reviewer will see them:\n\n" + + scene.hunks.map((hunk) => hunkText(scene, hunk)).join("\n\n"); +} + +// Every changed line has one name that survives any context width: its side +// and its number on that side. A hunk's notes follow its lines. +const changedKeys = (scene, hunk) => { + const path = scene.files[hunk.file].path; + return hunk.lines + .filter((line) => line.kind !== "ctx") + .map((line) => path + (line.kind === "add" ? "|+" + line.newNo : "|-" + line.oldNo)); +}; + +// What came back, pinned to changed lines rather than to hunk numbers, which +// change the moment the context width does. +function notesOf(scene, answer) { + const byIndex = new Map(scene.hunks.map((hunk) => [hunk.index, hunk])); + const notes = []; + (answer.hunks || []).forEach(({ index, group, note }) => { + const hunk = byIndex.get(index); + if (!hunk || !note) return; + notes.push({ keys: new Set(changedKeys(scene, hunk)), group, note }); + }); + return notes; +} + +// A wider context can merge hunks, so one hunk may inherit several notes; they +// are read in the order they were written, and the first group names it. +function withNotes(scene, notes) { + if (!notes || notes.length === 0) return scene; + const hunks = scene.hunks.map((hunk) => { + const keys = changedKeys(scene, hunk); + const mine = notes.filter((entry) => keys.some((key) => entry.keys.has(key))); + if (mine.length === 0) return hunk; + const texts = [...new Set(mine.map((entry) => entry.note))]; + return { ...hunk, group: mine[0].group, note: texts.join(" ") }; + }); + return { ...scene, hunks }; +} + +const textOf = (blocks) => + blocks.filter((block) => block.type === "text").map((block) => block.text).join(""); + +// A model that declines partway leaves its partial answer in the stream, then a +// fallback block, then the other model's. Whether that one restarts or carries +// on is not ours to know, so the answer after the boundary is tried first and +// the whole of it second. +function answerOf(message) { + const boundary = message.content.map((block) => block.type).lastIndexOf("fallback"); + const tries = boundary === -1 ? [message.content] + : [message.content.slice(boundary + 1), message.content]; + for (const blocks of tries) { + try { + return JSON.parse(textOf(blocks)); + } catch {} + } + return null; +} + +// Resolves to { notes } or { error }, never throws: a scene without notes is +// still worth serving. +async function explain(scene, messages, client) { + if (scene.hunks.length === 0) return { notes: [] }; + let Anthropic = null; + try { + if (!client) { + Anthropic = require("@anthropic-ai/sdk"); + client = new Anthropic(); + } + const stream = client.beta.messages.stream({ + model: MODEL, + max_tokens: 64000, + betas: ["server-side-fallback-2026-07-01"], + fallbacks: "default", + thinking: { type: "adaptive" }, + output_config: { format: { type: "json_schema", schema: SCHEMA } }, + system: SYSTEM, + messages: [{ role: "user", content: promptOf(scene, messages) }], + }); + const message = await stream.finalMessage(); + if (message.stop_reason === "refusal") return { error: "Claude declined to explain this diff" }; + if (message.stop_reason === "max_tokens") return { error: "the explanation ran out of room" }; + const answer = answerOf(message); + if (!answer) return { error: "Claude's answer was not valid JSON" }; + return { notes: notesOf(scene, answer) }; + } catch (err) { + if (err && err.code === "MODULE_NOT_FOUND" && !Anthropic) { + return { error: "run npm install in the gitdiff3d folder first" }; + } + if (Anthropic && err instanceof Anthropic.AuthenticationError) { + return { error: "no valid Anthropic credentials" }; + } + if (Anthropic && err instanceof Anthropic.APIError) { + return { error: "Claude API " + (err.status || "error") + ": " + err.message }; + } + if (Anthropic && err instanceof Anthropic.AnthropicError) { + // raised before any request, which in practice means no credentials + return { error: err.message.split(". ")[0] + "; set ANTHROPIC_API_KEY" }; + } + return { error: err && err.message ? err.message : String(err) }; + } +} + +module.exports = { explain, withNotes, notesOf, promptOf, MODEL }; diff --git a/src/git.js b/src/git.js index c94460b..f84415a 100644 --- a/src/git.js +++ b/src/git.js @@ -48,6 +48,32 @@ const newSideOf = (revisions, cwd) => { return revs.length > 1 && isCommit(last, cwd) ? last : null; }; +// The commits a diff spans, when its revisions name any: "a..b", "a...b" and +// "a b" all show what b added, and "a..b" is how log says that ("a...b" to log +// would also list a's side). A lone "a" is the working tree against a, which +// holds whatever was committed since. The index has no messages. +const rangeOf = (revs, cwd) => { + const ranged = revs.find((r) => RANGE.test(r)); + if (ranged) return ranged.replace("...", ".."); + if (revs.length === 2 && revs.every((r) => isCommit(r, cwd))) return revs.join(".."); + if (revs.length === 1 && isCommit(revs[0], cwd)) return revs[0] + "..HEAD"; + return null; +}; + +const commitMessages = (revisions, cwd) => { + if (revisions.some((r) => r === "--staged" || r === "--cached")) return ""; + const revs = beforePathspec(revisions).filter((r) => !r.startsWith("-")); + const range = rangeOf(revs, cwd); + if (!range) return ""; + const cut = revisions.indexOf("--"); + const paths = cut === -1 ? [] : revisions.slice(cut); + try { + return git(["log", "--no-color", "--format=%B%n---", range, ...paths], cwd); + } catch { + return ""; + } +}; + // A diff header is text, and text can say "../../elsewhere"; the working tree // is the only place this is allowed to read from. Both sides are resolved // because git reports the root with forward slashes even on Windows. @@ -68,4 +94,4 @@ const sourceOf = (rev, filePath, root) => { } }; -module.exports = { repoRoot, diffText, headLabel, newSideOf, sourceOf }; +module.exports = { repoRoot, diffText, headLabel, newSideOf, sourceOf, commitMessages }; diff --git a/src/viewer.html b/src/viewer.html index 4fc5379..632d698 100644 --- a/src/viewer.html +++ b/src/viewer.html @@ -1101,7 +1101,7 @@ /* ---------- narration ---------- A scene may arrive with a note per hunk (why the change exists) and a group - label; gitdiff3d never writes these, a host such as gtweb fills them in. The + label, written by --explain or by a host such as gtweb. The what is ours either way: the diff already knows the file, the symbol and the size of the edit, so the voice always has something true to say. */ const VOICE_KEY = "gitdiff3d-voice";