Skip to content

Commit 36d941e

Browse files
authored
feat: folder hash (#16)
1 parent 19a8656 commit 36d941e

12 files changed

Lines changed: 820 additions & 186 deletions

File tree

.changeset/wet-hornets-study.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@webiny/stdlib": patch
3+
---
4+
5+
feat: add HashFolderTool — deterministic SHA-256 folder hashing with sync and async (parallel I/O) methods, replacing the unmaintained folder-hash library

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ The package is ESM-only and ships three subpath exports. Because each is a separ
4444
| `NdJsonReaderTool` / `NdJsonReaderToolFeature` | Parse NDJSON from files, streams, or in-memory lines with checkpoint support — [docs](src/node/features/NdJsonReaderTool/README.md) |
4545
| `ReadStreamFactory` / `ReadStreamFactoryFeature` | Disposable `node:fs` read streams via `AsyncDisposable`[docs](src/node/features/ReadStreamFactory/README.md) |
4646
| `PackageJsonFileTool` / `PackageJsonFileToolFeature` | Read, validate, mutate, and write `package.json` files — [docs](src/node/features/PackageJsonFileTool/README.md) |
47+
| `HashFolderTool` / `HashFolderToolFeature` | Deterministic SHA-256 hash of a folder's contents — [docs](src/node/features/HashFolderTool/README.md) |
4748

4849
---
4950

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
2+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3+
import { join } from "node:path";
4+
import { tmpdir } from "node:os";
5+
import { Container } from "@webiny/di";
6+
import {
7+
HashFolderTool,
8+
HashFolderToolFeature,
9+
createHashFolderTool,
10+
hashFolder,
11+
hashFolderAsync
12+
} from "../../src/node/features/HashFolderTool/index.js";
13+
14+
function makeContainer(): Container {
15+
const container = new Container();
16+
HashFolderToolFeature.register(container);
17+
return container;
18+
}
19+
20+
describe("HashFolderTool", () => {
21+
let tmpDir: string;
22+
let tool: HashFolderTool.Interface;
23+
24+
beforeEach(() => {
25+
tmpDir = join(tmpdir(), `wby-hash-test-${Date.now()}`);
26+
mkdirSync(tmpDir, { recursive: true });
27+
tool = makeContainer().resolve(HashFolderTool);
28+
});
29+
30+
afterEach(() => {
31+
rmSync(tmpDir, { recursive: true, force: true });
32+
});
33+
34+
describe("hash (sync)", () => {
35+
it("returns a result with a hex hash", () => {
36+
writeFileSync(join(tmpDir, "a.txt"), "hello");
37+
writeFileSync(join(tmpDir, "b.txt"), "world");
38+
39+
const result = tool.hash(tmpDir);
40+
expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) });
41+
});
42+
43+
it("returns a deterministic hash for the same content", () => {
44+
writeFileSync(join(tmpDir, "file.txt"), "content");
45+
46+
const result1 = tool.hash(tmpDir);
47+
const result2 = tool.hash(tmpDir);
48+
expect(result1).toEqual(result2);
49+
});
50+
51+
it("produces different hashes when file content changes", () => {
52+
writeFileSync(join(tmpDir, "file.txt"), "version1");
53+
const result1 = tool.hash(tmpDir);
54+
55+
writeFileSync(join(tmpDir, "file.txt"), "version2");
56+
const result2 = tool.hash(tmpDir);
57+
58+
expect(result1).not.toEqual(result2);
59+
});
60+
61+
it("includes files in nested subdirectories", () => {
62+
mkdirSync(join(tmpDir, "sub"), { recursive: true });
63+
writeFileSync(join(tmpDir, "sub", "nested.txt"), "deep");
64+
65+
const result1 = tool.hash(tmpDir);
66+
67+
writeFileSync(join(tmpDir, "sub", "nested.txt"), "changed");
68+
const result2 = tool.hash(tmpDir);
69+
70+
expect(result1).not.toEqual(result2);
71+
});
72+
73+
it("excludes specified folders", () => {
74+
writeFileSync(join(tmpDir, "keep.txt"), "kept");
75+
mkdirSync(join(tmpDir, "dist"), { recursive: true });
76+
writeFileSync(join(tmpDir, "dist", "bundle.js"), "compiled");
77+
78+
const result1 = tool.hash(tmpDir, { excludeFolders: ["dist"] });
79+
80+
writeFileSync(join(tmpDir, "dist", "bundle.js"), "recompiled");
81+
const result2 = tool.hash(tmpDir, { excludeFolders: ["dist"] });
82+
83+
expect(result1).toEqual(result2);
84+
});
85+
86+
it("excludes specified files", () => {
87+
writeFileSync(join(tmpDir, "source.ts"), "code");
88+
writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "info");
89+
90+
const result1 = tool.hash(tmpDir, {
91+
excludeFiles: ["tsconfig.build.tsbuildinfo"]
92+
});
93+
94+
writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "updated-info");
95+
const result2 = tool.hash(tmpDir, {
96+
excludeFiles: ["tsconfig.build.tsbuildinfo"]
97+
});
98+
99+
expect(result1).toEqual(result2);
100+
});
101+
102+
it("excludes multiple folders and files together", () => {
103+
writeFileSync(join(tmpDir, "source.ts"), "code");
104+
mkdirSync(join(tmpDir, "dist"), { recursive: true });
105+
mkdirSync(join(tmpDir, "node_modules"), { recursive: true });
106+
writeFileSync(join(tmpDir, "dist", "out.js"), "compiled");
107+
writeFileSync(join(tmpDir, "node_modules", "dep.js"), "dep");
108+
writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "info");
109+
110+
const result1 = tool.hash(tmpDir, {
111+
excludeFolders: ["dist", "node_modules"],
112+
excludeFiles: ["tsconfig.build.tsbuildinfo"]
113+
});
114+
115+
writeFileSync(join(tmpDir, "dist", "out.js"), "recompiled");
116+
writeFileSync(join(tmpDir, "node_modules", "dep.js"), "updated-dep");
117+
writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "new-info");
118+
119+
const result2 = tool.hash(tmpDir, {
120+
excludeFolders: ["dist", "node_modules"],
121+
excludeFiles: ["tsconfig.build.tsbuildinfo"]
122+
});
123+
124+
expect(result1).toEqual(result2);
125+
});
126+
127+
it("is order-independent — same files in different creation order produce same hash", () => {
128+
const dir1 = join(tmpDir, "dir1");
129+
const dir2 = join(tmpDir, "dir2");
130+
mkdirSync(dir1, { recursive: true });
131+
mkdirSync(dir2, { recursive: true });
132+
133+
writeFileSync(join(dir1, "a.txt"), "alpha");
134+
writeFileSync(join(dir1, "b.txt"), "beta");
135+
136+
writeFileSync(join(dir2, "b.txt"), "beta");
137+
writeFileSync(join(dir2, "a.txt"), "alpha");
138+
139+
const result1 = tool.hash(dir1);
140+
const result2 = tool.hash(dir2);
141+
expect(result1).toEqual(result2);
142+
});
143+
144+
it("returns a hash for an empty folder", () => {
145+
const result = tool.hash(tmpDir);
146+
expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) });
147+
});
148+
149+
it("includes relative path in the hash so renames are detected", () => {
150+
writeFileSync(join(tmpDir, "original.txt"), "content");
151+
const result1 = tool.hash(tmpDir);
152+
153+
rmSync(join(tmpDir, "original.txt"));
154+
writeFileSync(join(tmpDir, "renamed.txt"), "content");
155+
const result2 = tool.hash(tmpDir);
156+
157+
expect(result1).not.toEqual(result2);
158+
});
159+
});
160+
161+
describe("hashAsync (parallel)", () => {
162+
it("returns a result with a hex hash", async () => {
163+
writeFileSync(join(tmpDir, "a.txt"), "hello");
164+
writeFileSync(join(tmpDir, "b.txt"), "world");
165+
166+
const result = await tool.hashAsync(tmpDir);
167+
expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) });
168+
});
169+
170+
it("returns a deterministic hash for the same content", async () => {
171+
writeFileSync(join(tmpDir, "file.txt"), "content");
172+
173+
const result1 = await tool.hashAsync(tmpDir);
174+
const result2 = await tool.hashAsync(tmpDir);
175+
expect(result1).toEqual(result2);
176+
});
177+
178+
it("excludes specified folders and files", async () => {
179+
writeFileSync(join(tmpDir, "source.ts"), "code");
180+
mkdirSync(join(tmpDir, "dist"), { recursive: true });
181+
writeFileSync(join(tmpDir, "dist", "out.js"), "compiled");
182+
writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "info");
183+
184+
const result1 = await tool.hashAsync(tmpDir, {
185+
excludeFolders: ["dist"],
186+
excludeFiles: ["tsconfig.build.tsbuildinfo"]
187+
});
188+
189+
writeFileSync(join(tmpDir, "dist", "out.js"), "recompiled");
190+
writeFileSync(join(tmpDir, "tsconfig.build.tsbuildinfo"), "new-info");
191+
192+
const result2 = await tool.hashAsync(tmpDir, {
193+
excludeFolders: ["dist"],
194+
excludeFiles: ["tsconfig.build.tsbuildinfo"]
195+
});
196+
197+
expect(result1).toEqual(result2);
198+
});
199+
200+
it("produces the same result as the sync method", async () => {
201+
writeFileSync(join(tmpDir, "a.txt"), "alpha");
202+
mkdirSync(join(tmpDir, "sub"), { recursive: true });
203+
writeFileSync(join(tmpDir, "sub", "b.txt"), "beta");
204+
205+
const syncResult = tool.hash(tmpDir);
206+
const asyncResult = await tool.hashAsync(tmpDir);
207+
expect(asyncResult).toEqual(syncResult);
208+
});
209+
210+
it("returns a hash for an empty folder", async () => {
211+
const result = await tool.hashAsync(tmpDir);
212+
expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) });
213+
});
214+
});
215+
});
216+
217+
describe("createHashFolderTool", () => {
218+
let tmpDir: string;
219+
220+
beforeEach(() => {
221+
tmpDir = join(tmpdir(), `wby-hash-factory-test-${Date.now()}`);
222+
mkdirSync(tmpDir, { recursive: true });
223+
});
224+
225+
afterEach(() => {
226+
rmSync(tmpDir, { recursive: true, force: true });
227+
});
228+
229+
it("creates a working tool (sync)", () => {
230+
const tool = createHashFolderTool();
231+
writeFileSync(join(tmpDir, "file.txt"), "content");
232+
const result = tool.hash(tmpDir);
233+
expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) });
234+
});
235+
236+
it("creates a working tool (async)", async () => {
237+
const tool = createHashFolderTool();
238+
writeFileSync(join(tmpDir, "file.txt"), "content");
239+
const result = await tool.hashAsync(tmpDir);
240+
expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) });
241+
});
242+
});
243+
244+
describe("hashFolder (sync standalone)", () => {
245+
let tmpDir: string;
246+
247+
beforeEach(() => {
248+
tmpDir = join(tmpdir(), `wby-hash-standalone-test-${Date.now()}`);
249+
mkdirSync(tmpDir, { recursive: true });
250+
});
251+
252+
afterEach(() => {
253+
rmSync(tmpDir, { recursive: true, force: true });
254+
});
255+
256+
it("returns a result object", () => {
257+
writeFileSync(join(tmpDir, "file.txt"), "content");
258+
const result = hashFolder(tmpDir);
259+
expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) });
260+
});
261+
262+
it("supports exclude options", () => {
263+
writeFileSync(join(tmpDir, "source.ts"), "code");
264+
mkdirSync(join(tmpDir, "dist"), { recursive: true });
265+
writeFileSync(join(tmpDir, "dist", "out.js"), "compiled");
266+
267+
const result1 = hashFolder(tmpDir, { excludeFolders: ["dist"] });
268+
269+
writeFileSync(join(tmpDir, "dist", "out.js"), "recompiled");
270+
const result2 = hashFolder(tmpDir, { excludeFolders: ["dist"] });
271+
272+
expect(result1).toEqual(result2);
273+
});
274+
275+
it("produces the same result as the DI tool", () => {
276+
writeFileSync(join(tmpDir, "file.txt"), "content");
277+
278+
const tool = createHashFolderTool();
279+
const diResult = tool.hash(tmpDir);
280+
const standaloneResult = hashFolder(tmpDir);
281+
282+
expect(standaloneResult).toEqual(diResult);
283+
});
284+
});
285+
286+
describe("hashFolderAsync (async standalone)", () => {
287+
let tmpDir: string;
288+
289+
beforeEach(() => {
290+
tmpDir = join(tmpdir(), `wby-hash-async-standalone-test-${Date.now()}`);
291+
mkdirSync(tmpDir, { recursive: true });
292+
});
293+
294+
afterEach(() => {
295+
rmSync(tmpDir, { recursive: true, force: true });
296+
});
297+
298+
it("returns a result object", async () => {
299+
writeFileSync(join(tmpDir, "file.txt"), "content");
300+
const result = await hashFolderAsync(tmpDir);
301+
expect(result).toEqual({ hash: expect.stringMatching(/^[a-f0-9]{64}$/) });
302+
});
303+
304+
it("supports exclude options", async () => {
305+
writeFileSync(join(tmpDir, "source.ts"), "code");
306+
mkdirSync(join(tmpDir, "dist"), { recursive: true });
307+
writeFileSync(join(tmpDir, "dist", "out.js"), "compiled");
308+
309+
const result1 = await hashFolderAsync(tmpDir, { excludeFolders: ["dist"] });
310+
311+
writeFileSync(join(tmpDir, "dist", "out.js"), "recompiled");
312+
const result2 = await hashFolderAsync(tmpDir, { excludeFolders: ["dist"] });
313+
314+
expect(result1).toEqual(result2);
315+
});
316+
317+
it("produces the same result as sync standalone", async () => {
318+
writeFileSync(join(tmpDir, "file.txt"), "content");
319+
mkdirSync(join(tmpDir, "sub"), { recursive: true });
320+
writeFileSync(join(tmpDir, "sub", "nested.txt"), "nested");
321+
322+
const syncResult = hashFolder(tmpDir);
323+
const asyncResult = await hashFolderAsync(tmpDir);
324+
325+
expect(asyncResult).toEqual(syncResult);
326+
});
327+
});

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,13 @@
3535
"devDependencies": {
3636
"@changesets/cli": "^2.31.0",
3737
"@types/node": ">=24",
38-
"@typescript/native-preview": "^7.0.0-dev.20260519.1",
39-
"@vitest/coverage-v8": "^4.1.6",
38+
"@typescript/native-preview": "^7.0.0-dev.20260522.1",
39+
"@vitest/coverage-v8": "^4.1.7",
4040
"adio": "^3.0.0",
4141
"happy-dom": "^20.9.0",
4242
"oxfmt": "^0.51.0",
4343
"oxlint": "^1.66.0",
44-
"vitest": "^4.1.6"
44+
"vitest": "^4.1.7"
4545
},
4646
"scripts": {
4747
"clean": "rm -rf dist",

0 commit comments

Comments
 (0)