Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -58,6 +60,7 @@ It builds the scene, serves it on a random local port, and opens your browser.
--max-hunks <n> keep only the first n hunks (default: all)
--order <how> story or git (default: story)
--context <n> 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
```
Expand Down Expand Up @@ -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
Expand Down
28 changes: 24 additions & 4 deletions bin/gitdiff3d.js
Original file line number Diff line number Diff line change
@@ -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 [<revs>] [-- <path>...] [options]
Expand All @@ -20,12 +21,15 @@ options:
--order <how> story (definitions before their callers, schema before docs)
or git (the order git prints them in). default: story
--context <n> 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];
Expand All @@ -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);
Expand Down Expand Up @@ -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)) {
Expand All @@ -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));
Expand Down
43 changes: 42 additions & 1 deletion check.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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);
Expand Down
93 changes: 93 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
Loading