Skip to content

Commit 070f312

Browse files
authored
feat: add diffBuffers, TreeBuilder, diffTreeToTree, findBlob and Diff.toPatch (#168)
* feat: add diffBuffers, TreeBuilder, diffTreeToTree, findBlob and Diff.toPatch Closes the feature request in #167: - diffBuffers(old, oldPath, new, newPath, options?) — repo-free diff of two raw buffers via git2::Patch::from_buffers, returning unified patch text, materialized hunks/lines and stats (git diff --no-index equivalent) - Diff.toPatch() — render any Diff as unified-diff text - repo.treebuilder() / TreeBuilder — insert/remove/get/write/clear/len/isEmpty - repo.diffTreeToTree(oldTree?, newTree?, options?) - repo.findBlob(oid) — plus BlobParent::Repository variant Refs #167 * fix: preserve absent-vs-empty semantics, byte-exact patches, and loader compat - diffBuffers routes through libgit2-sys git_patch_from_buffers so a null side passes a real NULL pointer: absent files now produce added/deleted deltas with /dev/null and 'new file mode'/'deleted file mode' headers (creating an empty file is a change, not a no-op) - diffBuffers result.patch and Diff.toPatch() return Buffer instead of lossy UTF-8 strings, preserving non-UTF-8 content exactly - index.js keeps main's Node >=10-compatible loader (no node:fs/optional chaining, exact-value NAPI_RS_FORCE_WASI check) with only the new exports - libgit2_sys::init() moved to #[module_init] at module load - rustfmt clean; regression tests for file creation/deletion and non-UTF-8 content
1 parent a4ac3d2 commit 070f312

10 files changed

Lines changed: 909 additions & 1 deletion

File tree

‎__test__/buffer-diff.spec.mjs‎

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { execSync } from "node:child_process";
2+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
6+
import test from "ava";
7+
8+
import { Repository, diffBuffers } from "../index.js";
9+
10+
const enc = (s) => new TextEncoder().encode(s);
11+
const text = (buf) => buf.toString();
12+
13+
// Mutating setup, so each test runs against a throwaway repo under os.tmpdir().
14+
function makeRepoWithModifiedFile() {
15+
const root = mkdtempSync(join(tmpdir(), "simple-git-buffer-diff-"));
16+
const work = join(root, "work");
17+
execSync(`git init -q -b main "${work}"`);
18+
const run = (args) => execSync(`git ${args}`, { cwd: work });
19+
run("config user.name tester");
20+
run("config user.email tester@example.com");
21+
run("config commit.gpgsign false");
22+
run("config core.autocrlf false");
23+
writeFileSync(join(work, "tracked.txt"), "stable\n");
24+
run("add tracked.txt");
25+
run('commit -q -m "initial commit"');
26+
// Unstaged workdir modification for diffTreeToWorkdir to pick up.
27+
writeFileSync(join(work, "tracked.txt"), "stable\nchanged\n");
28+
const repo = new Repository(work);
29+
return { root, repo };
30+
}
31+
32+
test("diffBuffers: identical buffers produce an empty patch", (t) => {
33+
const result = diffBuffers(enc("same\n"), "a.txt", enc("same\n"), "a.txt");
34+
35+
t.is(result.hunks.length, 0);
36+
t.is(result.stats.additions, 0);
37+
t.is(result.stats.deletions, 0);
38+
t.is(result.stats.filesChanged, 0);
39+
t.is(result.patch.length, 0);
40+
});
41+
42+
test("diffBuffers: both sides null produce an empty patch", (t) => {
43+
const result = diffBuffers(null, null, null, null);
44+
45+
t.is(result.hunks.length, 0);
46+
t.is(result.stats.additions, 0);
47+
t.is(result.stats.deletions, 0);
48+
t.is(result.stats.filesChanged, 0);
49+
t.is(result.patch.length, 0);
50+
});
51+
52+
test("diffBuffers: modified content yields expected +/- lines", (t) => {
53+
const result = diffBuffers(
54+
enc("one\ntwo\nthree\n"),
55+
"old.txt",
56+
enc("one\nTWO\nthree\n"),
57+
"new.txt",
58+
);
59+
60+
t.is(result.hunks.length, 1);
61+
62+
const hunk = result.hunks[0];
63+
t.true(hunk.header.includes("@@"));
64+
t.is(hunk.oldStart, 1);
65+
t.is(hunk.newStart, 1);
66+
67+
const origins = hunk.lines.map((l) => l.origin);
68+
t.true(origins.includes("-"));
69+
t.true(origins.includes("+"));
70+
71+
const deleted = hunk.lines.find((l) => l.origin === "-");
72+
const added = hunk.lines.find((l) => l.origin === "+");
73+
t.is(deleted.newLineno, null);
74+
t.is(added.oldLineno, null);
75+
t.is(deleted.content.toString(), "two\n");
76+
t.is(added.content.toString(), "TWO\n");
77+
78+
const patch = text(result.patch);
79+
t.true(patch.includes("-two"));
80+
t.true(patch.includes("+TWO"));
81+
t.true(patch.includes("@@"));
82+
t.is(result.stats.additions, 1);
83+
t.is(result.stats.deletions, 1);
84+
t.is(result.stats.filesChanged, 1);
85+
});
86+
87+
test("diffBuffers: null old side reports every line as added", (t) => {
88+
const result = diffBuffers(null, null, enc("one\ntwo\n"), "new.txt");
89+
90+
t.is(result.hunks.length, 1);
91+
const origins = result.hunks[0].lines.map((l) => l.origin);
92+
t.true(origins.length > 0);
93+
t.true(origins.every((o) => o === "+"));
94+
t.is(result.stats.additions, 2);
95+
t.is(result.stats.deletions, 0);
96+
t.is(result.stats.filesChanged, 1);
97+
});
98+
99+
test("diffBuffers: null new side reports every line as deleted", (t) => {
100+
const result = diffBuffers(enc("one\ntwo\n"), "old.txt", null, null);
101+
102+
t.is(result.hunks.length, 1);
103+
const origins = result.hunks[0].lines.map((l) => l.origin);
104+
t.true(origins.length > 0);
105+
t.true(origins.every((o) => o === "-"));
106+
t.is(result.stats.additions, 0);
107+
t.is(result.stats.deletions, 2);
108+
t.is(result.stats.filesChanged, 1);
109+
});
110+
111+
test("diffBuffers: absent old side renders a file-creation patch", (t) => {
112+
const result = diffBuffers(null, null, enc("new content\n"), "created.txt");
113+
114+
const patch = text(result.patch);
115+
// A creation patch diffs /dev/null against the new path and announces a
116+
// new file — it must apply cleanly, unlike a "modified from empty" diff.
117+
t.true(patch.includes("--- /dev/null"));
118+
t.true(patch.includes("+++ b/created.txt"));
119+
t.true(patch.includes("new file mode"));
120+
t.is(result.stats.filesChanged, 1);
121+
});
122+
123+
test("diffBuffers: absent new side renders a file-deletion patch", (t) => {
124+
const result = diffBuffers(enc("gone\n"), "removed.txt", null, null);
125+
126+
const patch = text(result.patch);
127+
t.true(patch.includes("--- a/removed.txt"));
128+
t.true(patch.includes("+++ /dev/null"));
129+
t.true(patch.includes("deleted file mode"));
130+
t.is(result.stats.filesChanged, 1);
131+
});
132+
133+
test("diffBuffers: absent vs empty buffer is a creation, not a no-op", (t) => {
134+
// null = the file does not exist; Buffer.alloc(0) = it exists but is empty.
135+
// Creating an empty file must still produce a (hunk-less) patch header.
136+
const created = diffBuffers(null, null, Buffer.alloc(0), "empty.txt");
137+
t.is(created.stats.filesChanged, 1);
138+
t.true(text(created.patch).includes("new file mode"));
139+
140+
const deleted = diffBuffers(Buffer.alloc(0), "empty.txt", null, null);
141+
t.is(deleted.stats.filesChanged, 1);
142+
t.true(text(deleted.patch).includes("deleted file mode"));
143+
});
144+
145+
test("diffBuffers: non-UTF-8 bytes survive in the patch", (t) => {
146+
// Invalid UTF-8 without NUL stays text-diffable; the patch must carry the
147+
// original bytes rather than U+FFFD replacements.
148+
const result = diffBuffers(
149+
Buffer.from([0x61, 0xff, 0x0a]),
150+
"a.bin",
151+
Buffer.from([0x61, 0xfe, 0x0a]),
152+
"a.bin",
153+
);
154+
155+
if (result.hunks.length === 0) {
156+
// libgit2 may still classify this as binary; then no hunks is acceptable
157+
// but the patch must still exist.
158+
t.true(result.patch.length > 0);
159+
return;
160+
}
161+
t.not(result.patch.indexOf(0xff), -1);
162+
t.not(result.patch.indexOf(0xfe), -1);
163+
});
164+
165+
test("Diff.toPatch returns unified diff bytes for a workdir change", (t) => {
166+
const { root, repo } = makeRepoWithModifiedFile();
167+
try {
168+
const headTree = repo.head().peelToTree();
169+
const patch = text(repo.diffTreeToWorkdir(headTree).toPatch());
170+
171+
t.true(patch.length > 0);
172+
t.true(patch.includes("diff --git"));
173+
t.true(patch.includes("@@"));
174+
t.true(patch.includes("+changed"));
175+
} finally {
176+
rmSync(root, { recursive: true, force: true });
177+
}
178+
});

‎__test__/tree-builder.spec.mjs‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { execSync } from "node:child_process";
2+
import { mkdtempSync, rmSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
6+
import test from "ava";
7+
8+
import { Delta, Repository } from "../index.js";
9+
10+
// No commits needed: blobs/trees are written straight into the ODB, so a
11+
// freshly-initialized throwaway repo under os.tmpdir() is enough.
12+
function makeRepo() {
13+
const dir = mkdtempSync(join(tmpdir(), "simple-git-tree-builder-"));
14+
execSync(`git init -q -b main "${dir}"`);
15+
return { dir, repo: new Repository(dir) };
16+
}
17+
18+
// Raw git file modes, passed as numbers to TreeBuilder.insert().
19+
const BLOB_MODE = 0o100644;
20+
21+
const deltaCount = (diff) => [...diff.deltas()].length;
22+
23+
test("treebuilder().insert().write() creates a real tree; findBlob round-trips content", (t) => {
24+
const { dir, repo } = makeRepo();
25+
try {
26+
const content = "hello tree builder\n";
27+
const blobOid = repo.blob(Buffer.from(content));
28+
29+
const builder = repo.treebuilder();
30+
t.true(builder.isEmpty());
31+
t.is(builder.len(), 0);
32+
builder.insert("greeting.txt", blobOid, BLOB_MODE);
33+
t.is(builder.len(), 1);
34+
t.false(builder.isEmpty());
35+
36+
const entry = builder.get("greeting.txt");
37+
t.is(entry.oid, blobOid);
38+
t.is(entry.filemode, BLOB_MODE);
39+
t.is(builder.get("missing.txt"), null);
40+
41+
// write() materializes the builder into a real tree object in the ODB.
42+
const treeOid = builder.write();
43+
const tree = repo.findTree(treeOid);
44+
t.truthy(tree);
45+
t.is(tree.getName("greeting.txt").id(), blobOid);
46+
47+
// findBlob reads the written blob back out of the ODB.
48+
t.is(repo.findBlob(blobOid).content().toString(), content);
49+
50+
// Mutators still work after write().
51+
builder.remove("greeting.txt");
52+
t.is(builder.len(), 0);
53+
builder.insert("other.txt", blobOid, BLOB_MODE);
54+
builder.clear();
55+
t.true(builder.isEmpty());
56+
} finally {
57+
rmSync(dir, { recursive: true, force: true });
58+
}
59+
});
60+
61+
// Issue #167: diff two arbitrary strings like `git diff --no-index`. Each
62+
// string becomes a blob, each blob a single-entry tree via TreeBuilder, and
63+
// diffTreeToTree compares them without touching any workdir.
64+
test("diffTreeToTree diffs two TreeBuilder-built single-entry trees (issue #167)", (t) => {
65+
const { dir, repo } = makeRepo();
66+
try {
67+
const contentA = "line one\nline two\n";
68+
const contentB = "line one\nline changed\n";
69+
const blobA = repo.blob(Buffer.from(contentA));
70+
const blobB = repo.blob(Buffer.from(contentB));
71+
72+
const builderA = repo.treebuilder();
73+
builderA.insert("file.txt", blobA, BLOB_MODE);
74+
const treeA = repo.findTree(builderA.write());
75+
76+
const builderB = repo.treebuilder();
77+
builderB.insert("file.txt", blobB, BLOB_MODE);
78+
const treeB = repo.findTree(builderB.write());
79+
80+
const deltas = [...repo.diffTreeToTree(treeA, treeB).deltas()];
81+
t.is(deltas.length, 1);
82+
t.is(deltas[0].status(), Delta.Modified);
83+
t.is(deltas[0].oldFile().id(), blobA);
84+
t.is(deltas[0].newFile().id(), blobB);
85+
86+
// The blob written by repo.blob() is readable back through findBlob.
87+
t.is(repo.findBlob(blobA).content().toString(), contentA);
88+
t.is(repo.findBlob(blobB).content().toString(), contentB);
89+
} finally {
90+
rmSync(dir, { recursive: true, force: true });
91+
}
92+
});
93+
94+
test("treebuilder(source) seeds the builder from an existing tree", (t) => {
95+
const { dir, repo } = makeRepo();
96+
try {
97+
const blobOid = repo.blob(Buffer.from("seed\n"));
98+
99+
const builder = repo.treebuilder();
100+
builder.insert("seed.txt", blobOid, BLOB_MODE);
101+
const tree = repo.findTree(builder.write());
102+
103+
const seeded = repo.treebuilder(tree);
104+
t.is(seeded.len(), 1);
105+
t.is(seeded.get("seed.txt").oid, blobOid);
106+
} finally {
107+
rmSync(dir, { recursive: true, force: true });
108+
}
109+
});
110+
111+
test("diffTreeToTree treats a null side as the empty tree", (t) => {
112+
const { dir, repo } = makeRepo();
113+
try {
114+
const blobOid = repo.blob(Buffer.from("gone\n"));
115+
const builder = repo.treebuilder();
116+
builder.insert("gone.txt", blobOid, BLOB_MODE);
117+
const tree = repo.findTree(builder.write());
118+
119+
const deleted = [...repo.diffTreeToTree(tree, null).deltas()];
120+
t.is(deleted.length, 1);
121+
t.is(deleted[0].status(), Delta.Deleted);
122+
t.is(deleted[0].oldFile().id(), blobOid);
123+
124+
const added = [...repo.diffTreeToTree(null, tree).deltas()];
125+
t.is(added.length, 1);
126+
t.is(added[0].status(), Delta.Added);
127+
t.is(added[0].newFile().id(), blobOid);
128+
129+
// Identical trees produce no deltas.
130+
t.is(deltaCount(repo.diffTreeToTree(tree, tree)), 0);
131+
} finally {
132+
rmSync(dir, { recursive: true, force: true });
133+
}
134+
});

0 commit comments

Comments
 (0)