d4c-pool 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +199 -19
  2. package/package.json +4 -1
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
4
4
 
5
5
  // ../core/src/reap/index.ts
6
6
  import { lstat as lstat2, stat as stat2, rm } from "node:fs/promises";
7
- import { basename, join as join3 } from "node:path";
7
+ import { basename, join as join4 } from "node:path";
8
8
 
9
9
  // ../core/src/fs/trees.ts
10
10
  import { readdir, lstat, stat, readFile } from "node:fs/promises";
@@ -352,6 +352,80 @@ function restoreCommand(lockfile) {
352
352
  }
353
353
  }
354
354
 
355
+ // ../core/src/fs/native.ts
356
+ import { execFile } from "node:child_process";
357
+ import { existsSync } from "node:fs";
358
+ import { join as join3, dirname as dirname3 } from "node:path";
359
+ import { fileURLToPath } from "node:url";
360
+ import { createRequire as createRequire2 } from "node:module";
361
+ function findBinary(explicit) {
362
+ if (explicit !== undefined)
363
+ return existsSync(explicit) ? explicit : null;
364
+ if (process.env.D4C_FS_BIN !== undefined) {
365
+ return existsSync(process.env.D4C_FS_BIN) ? process.env.D4C_FS_BIN : null;
366
+ }
367
+ const pkg = `@voidmatcha/d4c-fs-${process.platform}-${process.arch}`;
368
+ try {
369
+ const req = createRequire2(import.meta.url);
370
+ return req.resolve(`${pkg}/d4c-fs`);
371
+ } catch {}
372
+ const here = dirname3(fileURLToPath(import.meta.url));
373
+ const candidates = [
374
+ join3(here, "../../../../crates/d4c-fs/target/release/d4c-fs"),
375
+ join3(here, `../../../fs-${process.platform}-${process.arch}/d4c-fs`)
376
+ ];
377
+ return candidates.find((c) => existsSync(c)) ?? null;
378
+ }
379
+ async function nativeDiskUsage(paths, opts = {}) {
380
+ const bin = findBinary(opts.binPath);
381
+ if (bin === null || paths.length === 0)
382
+ return null;
383
+ const args = ["disk-usage"];
384
+ for (const p of paths)
385
+ args.push("--path", p);
386
+ return new Promise((resolve) => {
387
+ execFile(bin, args, { maxBuffer: 64 * 1024 * 1024, timeout: opts.timeoutMs ?? 300000 }, (err, stdout) => {
388
+ if (err !== null || stdout === "") {
389
+ resolve(null);
390
+ return;
391
+ }
392
+ try {
393
+ resolve(JSON.parse(stdout));
394
+ } catch {
395
+ resolve(null);
396
+ }
397
+ });
398
+ });
399
+ }
400
+ var shareBin;
401
+ async function fileShareInfoBatch(paths, opts = {}) {
402
+ if (paths.length === 0)
403
+ return new Map;
404
+ if (opts.binPath !== undefined)
405
+ shareBin = findBinary(opts.binPath);
406
+ else if (shareBin === undefined)
407
+ shareBin = findBinary();
408
+ if (shareBin === null)
409
+ return null;
410
+ const args = ["file-info"];
411
+ for (const p of paths)
412
+ args.push("--path", p);
413
+ return new Promise((resolve) => {
414
+ execFile(shareBin, args, { maxBuffer: 64 * 1024 * 1024, timeout: opts.timeoutMs ?? 120000 }, (err, stdout) => {
415
+ if (err !== null || stdout === "") {
416
+ resolve(null);
417
+ return;
418
+ }
419
+ try {
420
+ const j = JSON.parse(stdout);
421
+ resolve(new Map(j.files.map((f) => [f.path, f])));
422
+ } catch {
423
+ resolve(null);
424
+ }
425
+ });
426
+ });
427
+ }
428
+
355
429
  // ../core/src/reap/index.ts
356
430
  function relativeTo(path, root) {
357
431
  if (path === root)
@@ -414,6 +488,21 @@ async function planReap(root, opts = {}) {
414
488
  totalBytes += c.tree.sizeBytes ?? 0;
415
489
  opts.onMeasured?.(c.tree);
416
490
  }
491
+ let measurementSource = "approximate";
492
+ let nativeGroupBytes = null;
493
+ if (candidates.length > 0 && !interrupted) {
494
+ const native = await nativeDiskUsage(candidates.map((c) => c.tree.path), { binPath: opts.nativeBinPath });
495
+ if (native !== null && native.measured) {
496
+ measurementSource = "native";
497
+ const byPath = new Map(native.paths.map((p) => [p.path, p]));
498
+ for (const c of candidates) {
499
+ const m = byPath.get(c.tree.path);
500
+ if (m !== undefined)
501
+ c.tree.sizeBytes = m.privateBytes;
502
+ }
503
+ nativeGroupBytes = native.group.occupancyBytes;
504
+ }
505
+ }
417
506
  const tooSmall = candidates.filter((c) => (c.tree.sizeBytes ?? 0) < minSizeBytes);
418
507
  for (const c of tooSmall)
419
508
  skipped.push({ tree: c.tree, reason: "below-min-size" });
@@ -423,12 +512,13 @@ async function planReap(root, opts = {}) {
423
512
  candidates.sort((a, b) => (b.tree.sizeBytes ?? 0) - (a.tree.sizeBytes ?? 0));
424
513
  return {
425
514
  root,
515
+ measurementSource,
426
516
  idleThresholdDays,
427
517
  exclude,
428
518
  minSizeBytes,
429
519
  candidates,
430
520
  skipped,
431
- reclaimableBytes: candidates.reduce((s, c) => s + (c.tree.sizeBytes ?? 0), 0),
521
+ reclaimableBytes: nativeGroupBytes ?? candidates.reduce((s, c) => s + (c.tree.sizeBytes ?? 0), 0),
432
522
  totalBytes,
433
523
  treeCount: trees.length,
434
524
  diagnostics,
@@ -457,7 +547,7 @@ async function verifyStillReapable(c) {
457
547
  return "lockfile-vanished";
458
548
  for (const f of [tree.lockfile, "package.json"]) {
459
549
  try {
460
- const st2 = await stat2(join3(tree.lockfileDir, f));
550
+ const st2 = await stat2(join4(tree.lockfileDir, f));
461
551
  if (!st2.isFile())
462
552
  return "lockfile-vanished";
463
553
  } catch {
@@ -581,6 +671,7 @@ async function gc(args) {
581
671
  schemaVersion: 1,
582
672
  root: args.cwd,
583
673
  idleThresholdDays: plan.idleThresholdDays,
674
+ measurementSource: plan.measurementSource,
584
675
  exclude: plan.exclude,
585
676
  minSizeBytes: plan.minSizeBytes,
586
677
  dryRun: result.dryRun,
@@ -666,6 +757,13 @@ async function gc(args) {
666
757
  out.push("Interrupted. The trees listed above are what was handled before stopping;");
667
758
  out.push("anything else was left untouched.");
668
759
  }
760
+ if (plan.measurementSource === "approximate") {
761
+ out.push("");
762
+ out.push("Sizes are approximate. Files already sharing blocks through APFS");
763
+ out.push("clones are counted at full size, so the figure above can be much");
764
+ out.push("larger than what deleting would actually free. Build crates/d4c-fs");
765
+ out.push("for exact numbers.");
766
+ }
669
767
  out.push("");
670
768
  out.push("Idle age is measured from the last install or modification, not the");
671
769
  out.push("last time the tree was read. A project you still use can look idle.");
@@ -685,11 +783,11 @@ var DEFAULT_DAYS = DEFAULT_IDLE_THRESHOLD_DAYS;
685
783
 
686
784
  // ../core/src/dedupe/index.ts
687
785
  import { lstat as lstat5, mkdir as mkdir2, appendFile as appendFile2, realpath as realpath2 } from "node:fs/promises";
688
- import { dirname as dirname4 } from "node:path";
786
+ import { dirname as dirname5 } from "node:path";
689
787
 
690
788
  // ../core/src/dedupe/index-builder.ts
691
789
  import { readdir as readdir2, lstat as lstat3, open } from "node:fs/promises";
692
- import { join as join4 } from "node:path";
790
+ import { join as join5 } from "node:path";
693
791
  import { createHash } from "node:crypto";
694
792
  var DEFAULT_MIN_FILE_BYTES = 65536;
695
793
  async function hashFile(path) {
@@ -727,7 +825,7 @@ async function buildDuplicateIndex(treeRoots, opts = {}) {
727
825
  continue;
728
826
  }
729
827
  for (const e of entries) {
730
- const p = join4(dir, e.name);
828
+ const p = join5(dir, e.name);
731
829
  if (e.isDirectory()) {
732
830
  stack.push(p);
733
831
  continue;
@@ -767,7 +865,8 @@ async function buildDuplicateIndex(treeRoots, opts = {}) {
767
865
  ino: st.ino,
768
866
  mode: st.mode & 4095,
769
867
  mtimeMs: st.mtimeMs,
770
- cloneRefs: 0
868
+ cloneRefs: 0,
869
+ cloneId: null
771
870
  };
772
871
  const list = byHash.get(hash) ?? [];
773
872
  list.push(entry);
@@ -775,6 +874,26 @@ async function buildDuplicateIndex(treeRoots, opts = {}) {
775
874
  }
776
875
  }
777
876
  }
877
+ const candidatePaths = [];
878
+ for (const all of byHash.values()) {
879
+ if (all.length >= 2)
880
+ for (const f of all)
881
+ candidatePaths.push(f.path);
882
+ }
883
+ const share = await fileShareInfoBatch(candidatePaths, { binPath: opts.nativeBinPath });
884
+ if (share !== null) {
885
+ for (const all of byHash.values()) {
886
+ for (const f of all) {
887
+ const s = share.get(f.path);
888
+ if (s === undefined || !s.measured)
889
+ continue;
890
+ if (s.privateBytes !== null)
891
+ f.sizeBytes = s.privateBytes;
892
+ f.cloneRefs = s.cloneRefcnt ?? 0;
893
+ f.cloneId = s.cloneId;
894
+ }
895
+ }
896
+ }
778
897
  const groups = [];
779
898
  for (const [hash, all] of byHash) {
780
899
  const byMode = new Map;
@@ -796,8 +915,11 @@ async function buildDuplicateIndex(treeRoots, opts = {}) {
796
915
  if (same.length < 2)
797
916
  continue;
798
917
  const canonical = pickCanonical(same);
799
- const reclaimableBytes = same.filter((f) => f !== canonical).reduce((s, f) => s + f.sizeBytes, 0);
800
- groups.push({ hash, files: same, canonical, reclaimableBytes });
918
+ const targets = same.filter((f) => f !== canonical && !(f.cloneId !== null && f.cloneId === canonical.cloneId));
919
+ if (targets.length === 0)
920
+ continue;
921
+ const reclaimableBytes = targets.reduce((s, f) => s + f.sizeBytes, 0);
922
+ groups.push({ hash, files: [canonical, ...targets], canonical, reclaimableBytes });
801
923
  }
802
924
  }
803
925
  }
@@ -809,11 +931,14 @@ function pickCanonical(files) {
809
931
  }
810
932
 
811
933
  // ../core/src/dedupe/replace.ts
812
- import { lstat as lstat4, open as open2, rm as rm2, rename, chmod, utimes } from "node:fs/promises";
813
- import { dirname as dirname3, join as join5 } from "node:path";
814
- import { randomBytes } from "node:crypto";
934
+ import { lstat as lstat4, open as open2, rm as rm3, rename, chmod, utimes } from "node:fs/promises";
935
+ import { dirname as dirname4, join as join7 } from "node:path";
936
+ import { randomBytes as randomBytes2 } from "node:crypto";
815
937
 
816
938
  // ../core/src/clone/clonefile.ts
939
+ import { rm as rm2, stat as stat3 } from "node:fs/promises";
940
+ import { join as join6 } from "node:path";
941
+ import { randomBytes } from "node:crypto";
817
942
  var cachedFn;
818
943
  function loadClonefile() {
819
944
  if (cachedFn !== undefined)
@@ -847,6 +972,35 @@ async function cloneFile(src, dst) {
847
972
  throw new CloneFailedError(`clonefile failed: ${src} -> ${dst}`);
848
973
  }
849
974
  }
975
+ async function cloneSupported(dir) {
976
+ const tag = randomBytes(6).toString("hex");
977
+ const src = join6(dir, `.d4c-probe-${tag}`);
978
+ const dst = join6(dir, `.d4c-probe-${tag}-clone`);
979
+ try {
980
+ await Bun.write(src, "probe");
981
+ await cloneFile(src, dst);
982
+ return true;
983
+ } catch {
984
+ return false;
985
+ } finally {
986
+ await rm2(src, { force: true });
987
+ await rm2(dst, { force: true });
988
+ }
989
+ }
990
+ async function cloneDiagnostics(dir) {
991
+ if (process.platform !== "darwin") {
992
+ return { supported: false, reason: `clonefile is macOS-only (this is ${process.platform})` };
993
+ }
994
+ if (loadClonefile() === null) {
995
+ return { supported: false, reason: "koffi is not installed; run `npm i koffi` to enable dedupe" };
996
+ }
997
+ try {
998
+ await stat3(dir);
999
+ } catch {
1000
+ return { supported: false, reason: `cannot stat ${dir}` };
1001
+ }
1002
+ return await cloneSupported(dir) ? { supported: true, reason: "clonefile works here" } : { supported: false, reason: "clonefile refused on this filesystem" };
1003
+ }
850
1004
 
851
1005
  // ../core/src/dedupe/replace.ts
852
1006
  async function sameContent(a, b) {
@@ -897,11 +1051,11 @@ async function replaceWithClone(canonical, target) {
897
1051
  if (!await sameContent(canonical.path, target.path)) {
898
1052
  return { ok: false, reason: "content-changed" };
899
1053
  }
900
- const tmp = join5(dirname3(target.path), `.d4c-tmp-${randomBytes(6).toString("hex")}`);
1054
+ const tmp = join7(dirname4(target.path), `.d4c-tmp-${randomBytes2(6).toString("hex")}`);
901
1055
  try {
902
1056
  await cloneFile(canonical.path, tmp);
903
1057
  } catch (e) {
904
- await rm2(tmp, { force: true });
1058
+ await rm3(tmp, { force: true });
905
1059
  return { ok: false, reason: "clone-failed", detail: String(e) };
906
1060
  }
907
1061
  try {
@@ -909,7 +1063,7 @@ async function replaceWithClone(canonical, target) {
909
1063
  await utimes(tmp, ts.atime, ts.mtime);
910
1064
  await rename(tmp, target.path);
911
1065
  } catch (e) {
912
- await rm2(tmp, { force: true });
1066
+ await rm3(tmp, { force: true });
913
1067
  return { ok: false, reason: "clone-failed", detail: String(e) };
914
1068
  }
915
1069
  try {
@@ -923,7 +1077,7 @@ async function replaceWithClone(canonical, target) {
923
1077
  }
924
1078
 
925
1079
  // ../core/src/dedupe/open-files.ts
926
- import { execFile } from "node:child_process";
1080
+ import { execFile as execFile2 } from "node:child_process";
927
1081
  import { realpath } from "node:fs/promises";
928
1082
  async function openFilesUnder(roots, opts = {}) {
929
1083
  if (roots.length === 0)
@@ -931,7 +1085,7 @@ async function openFilesUnder(roots, opts = {}) {
931
1085
  const bin = opts.lsofPath ?? "lsof";
932
1086
  const realRoots = await Promise.all(roots.map((r) => realpath(r).catch(() => r)));
933
1087
  return new Promise((resolve) => {
934
- execFile(bin, ["-Fn", "-w"], { maxBuffer: 64 * 1024 * 1024, timeout: opts.timeoutMs ?? 60000 }, (err, stdout) => {
1088
+ execFile2(bin, ["-Fn", "-w"], { maxBuffer: 64 * 1024 * 1024, timeout: opts.timeoutMs ?? 60000 }, (err, stdout) => {
935
1089
  if (stdout === undefined || stdout === "") {
936
1090
  resolve(err ? null : new Set);
937
1091
  return;
@@ -970,17 +1124,24 @@ async function planDedupe(inputRoot, opts = {}) {
970
1124
  treeMtimes[t.path] = (await lstat5(t.path)).mtimeMs;
971
1125
  } catch {}
972
1126
  }
973
- const empty = (check2) => ({
1127
+ const empty = (check2, support = "supported", reason = "") => ({
974
1128
  root,
975
1129
  idleThresholdDays,
976
1130
  trees,
977
1131
  groups: [],
978
1132
  reclaimableBytes: 0,
979
1133
  openFileCheck: check2,
1134
+ cloneSupport: support,
1135
+ cloneReason: reason,
980
1136
  excludedOpenFiles: 0,
981
1137
  treeMtimes,
982
1138
  interrupted: false
983
1139
  });
1140
+ const probe = opts.cloneProbe ?? cloneSupported;
1141
+ if (!await probe(root)) {
1142
+ const d = await cloneDiagnostics(root);
1143
+ return empty(opts.skipOpenFileCheck === true ? "skipped" : "clean", "unsupported", d.reason);
1144
+ }
984
1145
  if (trees.length === 0)
985
1146
  return empty(opts.skipOpenFileCheck === true ? "skipped" : "clean");
986
1147
  let held = new Set;
@@ -1023,6 +1184,8 @@ async function planDedupe(inputRoot, opts = {}) {
1023
1184
  groups: filtered,
1024
1185
  reclaimableBytes: filtered.reduce((s, g) => s + g.reclaimableBytes, 0),
1025
1186
  openFileCheck: check,
1187
+ cloneSupport: "supported",
1188
+ cloneReason: "",
1026
1189
  excludedOpenFiles,
1027
1190
  treeMtimes,
1028
1191
  interrupted: opts.signal?.aborted === true
@@ -1081,7 +1244,7 @@ async function executeDedupe(plan, opts = {}) {
1081
1244
  freedBytes += r.freedBytes;
1082
1245
  if (logPath !== null) {
1083
1246
  try {
1084
- await mkdir2(dirname4(logPath), { recursive: true });
1247
+ await mkdir2(dirname5(logPath), { recursive: true });
1085
1248
  await appendFile2(logPath, JSON.stringify({
1086
1249
  event: "dedupe",
1087
1250
  at: new Date().toISOString(),
@@ -1149,6 +1312,8 @@ async function dedupe(args) {
1149
1312
  dryRun: result.dryRun,
1150
1313
  interrupted: plan.interrupted || result.interrupted,
1151
1314
  openFileCheck: plan.openFileCheck,
1315
+ cloneSupport: plan.cloneSupport,
1316
+ cloneReason: plan.cloneReason,
1152
1317
  excludedOpenFiles: plan.excludedOpenFiles,
1153
1318
  treeCount: plan.trees.length,
1154
1319
  groupCount: plan.groups.length,
@@ -1169,6 +1334,21 @@ async function dedupe(args) {
1169
1334
  return 0;
1170
1335
  }
1171
1336
  const out = ["D4C — deduplicate identical dependency files", ""];
1337
+ if (plan.cloneSupport === "unsupported") {
1338
+ out.push("Block cloning is not available here, so nothing was examined.");
1339
+ out.push(` ${plan.cloneReason}`);
1340
+ out.push("");
1341
+ out.push("dedupe needs a filesystem that can share blocks between files:");
1342
+ out.push(" macOS APFS");
1343
+ out.push(" Linux btrfs or XFS (ext4 cannot)");
1344
+ out.push(" Windows ReFS, including a Dev Drive (NTFS cannot)");
1345
+ out.push("");
1346
+ out.push("`d4c gc` works anywhere and does not need this.");
1347
+ process.stdout.write(out.join(`
1348
+ `) + `
1349
+ `);
1350
+ return 0;
1351
+ }
1172
1352
  if (plan.openFileCheck === "unavailable") {
1173
1353
  out.push("Could not run lsof, so it is unknown whether anything has these files open.");
1174
1354
  out.push("Replacing a file a process is writing to would lose that write silently,");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "d4c-pool",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Pool your scattered node_modules. Deduplicate identical files across project copies, and remove trees nothing has touched.",
5
5
  "keywords": [
6
6
  "node_modules",
@@ -35,5 +35,8 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "koffi": "^3.1.6"
38
+ },
39
+ "optionalDependencies": {
40
+ "@voidmatcha/d4c-fs-darwin-arm64": "^0.1.0"
38
41
  }
39
42
  }