disk-space-saviour 0.17.0 → 0.19.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - fcaba94: Clean application downloads that are fetched again on demand: Chrome bundle
8
+ clones left by exited browsers, superseded Chrome on-device models, Google
9
+ Updater installers, Chrome extension archives, ChatGPT Atlas and Codex app
10
+ caches, Electron and electron-builder archives, the Bun transpiler cache,
11
+ Docker Desktop and Warp staged updates, Zed language servers, tree-sitter
12
+ toolchains, Codex plugin staging, the Clang module cache, VS Code webview
13
+ cache storage and processed Crashpad reports.
14
+
15
+ ## 0.18.0
16
+
17
+ ### Minor Changes
18
+
19
+ - 2c706f1: Treat every regenerable project directory as `safe` regardless of how recently
20
+ the project was used, and block it only while something uses it. Detect
21
+ processes that keep a directory on their `PATH`, stop a long-running Bun script
22
+ or Codex daemon from blocking caches they do not use, prune old Codex daemon
23
+ releases, resolve partial nvm pins to the newest matching install, keep Node.js
24
+ versions that hold user-installed global packages, inspect Git safety in linked
25
+ worktrees and submodules, and report per-phase scan timings.
26
+
3
27
  ## 0.17.0
4
28
 
5
29
  ### Minor Changes
package/README.md CHANGED
@@ -30,17 +30,17 @@ dss emergency --free 20G --yes # escalate tiers until 20 GiB is free
30
30
  dss docker scan --recursive # containers and nested daemons
31
31
  ```
32
32
 
33
- Example (`dss scan ~/work` on two projects untouched for 40 days):
33
+ Example (`dss scan ~/work` on two projects):
34
34
 
35
35
  ```
36
36
  disk-space-saviour scan of host (build-01) in 3.7s (report only, nothing was deleted)
37
37
  /: 75.8 GiB free of 193 GiB (61% used)
38
38
 
39
- MODERATE 2 items, 7.6 MiB
39
+ SAFE 2 items, 7.6 MiB
40
40
  4.8 MiB cargo-target /home/me/work/api/target
41
41
  2.9 MiB node-modules /home/me/work/web/node_modules
42
42
 
43
- Reclaimable (cumulative): safe 0 B · moderate 7.6 MiB · aggressive 7.6 MiB; blocked 0 B
43
+ Reclaimable (cumulative): safe 7.6 MiB · moderate 7.6 MiB · aggressive 7.6 MiB; blocked 0 B
44
44
  Audit log: /home/me/.local/state/disk-space-saviour/audit/dss-scan-2026-09-26T00-31-05-432Z-232874.json
45
45
  Run `dss clean --tier safe` to see the plan, add --yes to delete.
46
46
  ```
@@ -87,27 +87,35 @@ Run `dss --help` for every option. The most useful ones:
87
87
 
88
88
  ## Tiers
89
89
 
90
- Tiers are cumulative: `moderate` includes `safe`, and `aggressive` includes
91
- both.
90
+ Tiers are cumulative: `moderate` includes `safe`. Regenerable data is never
91
+ ranked by how recently it was used: a cache, build output or dependency
92
+ directory is `safe` and is held back only while something uses it (see
93
+ [Safety](#safety)).
92
94
 
93
- | Tier | What it removes |
94
- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
95
- | `safe` | Download and application caches, idle package-runner entries, old browser revisions, inactive lockfile-backed `node_modules`, dangling Docker images and build cache. Includes writable caches inside running containers. |
96
- | `moderate` | Whole `target/`, `.venv`, `build/` and similar folders of inactive projects; old agent scratch and Telegram downloaded media (secret-chat media may be irreplaceable); unused toolchain versions and tagged images; stopped containers. Container and tagged-image removal requires explicit consent. |
97
- | `aggressive` | For emergencies: everything regenerable, including build outputs of projects in active use. Only `dss emergency` reaches it. |
95
+ | Tier | What it removes |
96
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
97
+ | `safe` | Everything regenerable: download, runtime and application caches, idle package-runner entries, old browser revisions, `node_modules`, `vendor`, `target/`, `.venv`, `build/` and similar folders of any project, dangling Docker images and build cache. |
98
+ | `moderate` | Data that is disposable but not regenerated by a tool: old agent scratch and Telegram downloaded media (secret-chat media may be irreplaceable), unused toolchain versions (never one holding globally installed packages or virtual environments), tagged images and stopped containers (both require explicit consent). |
99
+ | `aggressive` | Accepted by `dss emergency` for compatibility; currently the same as `moderate`. |
98
100
 
99
101
  Covered ecosystems: JavaScript/TypeScript (npm, yarn, pnpm, bun, deno,
100
102
  framework build caches), Python, Rust, JVM (Gradle, Maven, Kotlin, Android),
101
103
  Go, C/C++ (CMake, ccache, vcpkg, Conan), .NET, PHP, Ruby, Swift/Xcode, Dart,
102
104
  Haskell, Scala, Elixir, OCaml, Lean, Julia, R, Zig, and scripting languages.
103
105
  Also covered: version managers (nvm, pyenv, rbenv, SDKMAN!, rustup, elan,
104
- ghcup, opam, swiftly, VS Code Server, Claude Code and Copilot CLI), Playwright, playwright-go, Puppeteer
106
+ ghcup, opam, swiftly, VS Code Server, Claude Code, the Codex daemon and Copilot CLI), Playwright, playwright-go, Puppeteer
105
107
  and Cypress browsers, HTTP caches of Chrome, Chromium, Edge, Brave, Opera,
106
108
  Opera GX, Vivaldi, Arc, Yandex Browser, Naver Whale, 360, QQ Browser, Firefox,
107
109
  LibreWolf, Waterfox, Zen and Floorp, IDE caches (JetBrains, VS Code, Cursor,
108
110
  Windsurf, Qoder), AI agent caches and logs, the Discord cache, macOS app
109
111
  updates staged by Sparkle and Squirrel, package manager archives, trash, crash
110
- reports, core dumps and journald logs. Telegram pure caches, Safari/WebKit,
112
+ reports, core dumps and journald logs. Application downloads are covered too:
113
+ Chrome bundle clones left by exited browsers, superseded Chrome on-device
114
+ models, Google Updater installers, ChatGPT Atlas and Codex app caches,
115
+ Electron and electron-builder archives, the Bun transpiler cache, Docker
116
+ Desktop and Warp staged updates (kept while the app runs), Zed language
117
+ servers, tree-sitter toolchains, Codex plugin staging, the Clang module cache
118
+ and processed Crashpad reports. Telegram pure caches, Safari/WebKit,
111
119
  Chromium code/GPU/Dawn/service-worker caches and marker-validated Electron
112
120
  caches are included. Telegram databases, browser profiles and Playwright MCP
113
121
  profiles are preserved. macOS access failures explain when Full Disk Access
@@ -124,7 +132,19 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
124
132
  that a process holds open (`/proc/*/fd`, `cwd`, `exe`, or `lsof` on
125
133
  macOS) are skipped. So are projects with a running `cargo`, `gradle`,
126
134
  `npm`, `node` or similar tool, and anything modified within
127
- `--older-than` (1h).
135
+ `--older-than` (1h). How long ago a project was last used does not
136
+ matter. A tool running elsewhere does not block a runtime cache it is not
137
+ using: a script of an installed project keeps no lock on the Bun install
138
+ cache, while `bun install`, `bunx` or an auto-installing script does.
139
+ A directory on the `PATH` of a running process is kept too: the process
140
+ may still start programs from it.
141
+ - **Toolchain versions are kept while anything may need them.** The
142
+ manager's default and aliases, versions pinned by a project (`.nvmrc`,
143
+ `rust-toolchain.toml`, ...) and the newest install stay. A partial nvm pin
144
+ such as `20` keeps only the newest matching 20.x, as nvm resolves it. A
145
+ version holding globally installed npm packages or pyenv virtual
146
+ environments is blocked; bundled `npm`/`corepack` and use-m caches
147
+ (`<name>-v-<version>`) are reinstalled on demand and do not count.
128
148
  Container paths are checked individually before each bounded deletion batch.
129
149
  - **Rust builds are pruned in two parts.** `cargo-superseded` removes
130
150
  older hashes of a unit (`.rlib`, `.rmeta`, `.so`/`.dylib`/`.a`,
@@ -343,7 +363,7 @@ example and `examples/universal-app` for a report viewer.
343
363
  | `--audit-dir`, `DSS_AUDIT_DIR` | `$XDG_STATE_HOME/disk-space-saviour/audit` | Where audit logs go |
344
364
  | `--backup-dir`, `DSS_BACKUP_DIR` | `…/disk-space-saviour/backups` | Container log backups before `docker rm` |
345
365
  | `--older-than` | `1h` | Activity window; newer files are kept |
346
- | `--inactive` | `30d` | Project inactivity before `moderate` |
366
+ | `--inactive` | `30d` | Idle time before agent scratch is abandoned |
347
367
  | `--min-size` | `1M` | Smaller items are not reported |
348
368
  | `--depth` | `3` | Docker nesting depth |
349
369
  | `--journal-keep` | `512M` | journald size to keep |
package/bin/dss.js CHANGED
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { runCli } from '../src/cli.js';
3
+ // Scans are bound by file system calls, which run on libuv's thread pool
4
+ // (4 threads by default). It is sized on first use, so set it before any
5
+ // module that may touch the file system is loaded.
6
+ process.env.UV_THREADPOOL_SIZE ??= '32';
7
+ const { runCli } = await import('../src/cli.js');
4
8
 
5
9
  const controller = new AbortController();
6
10
  let received = null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "disk-space-saviour",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Safely reclaims disk space: caches and build outputs of 20+ ecosystems, forgotten projects, and Docker (running, stopped and nested containers), with an emergency mode",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/clean.js CHANGED
@@ -18,6 +18,7 @@ import path from 'node:path';
18
18
  import { finished } from 'node:stream/promises';
19
19
 
20
20
  import { backupDirectory, startAudit, writeAudit } from './audit.js';
21
+ import { PhaseTimer } from './timings.js';
21
22
  import { configureContainerHost } from './docker/writable.js';
22
23
  import { DockerCli, assertAllowed, parseDockerSize } from './docker/cli.js';
23
24
  import {
@@ -44,7 +45,6 @@ import { OTHER_RULES } from './rules/other.js';
44
45
  import { compareVersions, VERSION_RULES } from './rules/versions.js';
45
46
  import { scanVersions } from './scanners/versions.js';
46
47
  import { analyzeRustProfile } from './scanners/rust.js';
47
- import { currentProjectTier } from './scanners/projects.js';
48
48
  import {
49
49
  filterItems,
50
50
  revalidateReport,
@@ -163,6 +163,7 @@ export class Cleaner {
163
163
  this.options = options;
164
164
  this.contexts = new Map();
165
165
  this.health = new HealthWatch(options.priorHealth);
166
+ this.timings = options.timings ?? new PhaseTimer();
166
167
  this.descriptors = new Map(
167
168
  (report.environments ?? []).map((env) => [env.id, env])
168
169
  );
@@ -235,7 +236,9 @@ export class Cleaner {
235
236
  }
236
237
 
237
238
  async #processScoped(ctx, item, entry) {
238
- const fresh = await this.#recheck(ctx, item);
239
+ const fresh = await this.timings.measure('recheck', () =>
240
+ this.#recheck(ctx, item)
241
+ );
239
242
  if (fresh.reason) {
240
243
  entry.reason = fresh.reason;
241
244
  return;
@@ -248,9 +251,15 @@ export class Cleaner {
248
251
  entry.status = 'planned';
249
252
  return;
250
253
  }
251
- await this.health.baseline(ctx.env, item);
252
- await this.#execute(ctx, fresh.item, entry);
253
- const lost = await this.health.check(ctx.env, item);
254
+ await this.timings.measure('health', () =>
255
+ this.health.baseline(ctx.env, item)
256
+ );
257
+ await this.timings.measure(`execute:${fresh.item.action?.type}`, () =>
258
+ this.#execute(ctx, fresh.item, entry)
259
+ );
260
+ const lost = await this.timings.measure('health', () =>
261
+ this.health.check(ctx.env, item)
262
+ );
254
263
  if (lost && !entry.reason) {
255
264
  entry.reason = lost;
256
265
  }
@@ -372,15 +381,6 @@ export class Cleaner {
372
381
  }
373
382
 
374
383
  async #staticBlocker(ctx, item) {
375
- if (item.project && this.options.tier === 'safe') {
376
- const tier = await currentProjectTier(
377
- { ...ctx, options: this.options, now: this.options.now() },
378
- item
379
- );
380
- if (tierRank(tier) > tierRank(this.options.tier)) {
381
- return `project recently active; requires the ${tier} tier`;
382
- }
383
- }
384
384
  if (item.recheck?.type === 'version') {
385
385
  if (!ctx.cacheRootsConfigured) {
386
386
  await configureCacheRoots(ctx.env, await ctx.env.homeDirs());
@@ -922,8 +922,11 @@ export function cleanOptions(report, input) {
922
922
  * @returns {Promise<object>} audit log
923
923
  */
924
924
  export async function clean(report, input = {}) {
925
- report = await revalidateReport(report, input);
926
- const options = cleanOptions(report, input);
925
+ const timings = new PhaseTimer();
926
+ report = await timings.measure('revalidate', () =>
927
+ revalidateReport(report, input)
928
+ );
929
+ const options = { ...cleanOptions(report, input), timings };
927
930
  const audit = startAudit(input.command ?? 'clean', {
928
931
  dryRun: Boolean(options.dryRun),
929
932
  tier: options.tier,
@@ -935,7 +938,9 @@ export async function clean(report, input = {}) {
935
938
  audit.entries.push(entry);
936
939
  }
937
940
  if (options.audit !== false) {
938
- await writeAudit(audit, { ...options, inProgress: true });
941
+ await timings.measure('audit-write', () =>
942
+ writeAudit(audit, { ...options, inProgress: true })
943
+ );
939
944
  }
940
945
  };
941
946
  const cleaner = new Cleaner(report, {
@@ -944,6 +949,7 @@ export async function clean(report, input = {}) {
944
949
  onProgress: persistProgress,
945
950
  });
946
951
  audit.health = cleaner.health.records;
952
+ audit.timings = timings;
947
953
  if (options.audit !== false) {
948
954
  await writeAudit(audit, { ...options, inProgress: true });
949
955
  }
@@ -961,7 +967,9 @@ export async function clean(report, input = {}) {
961
967
  }
962
968
  options.onEntry?.(entry, item);
963
969
  if (options.audit !== false) {
964
- await writeAudit(audit, { ...options, inProgress: true });
970
+ await timings.measure('audit-write', () =>
971
+ writeAudit(audit, { ...options, inProgress: true })
972
+ );
965
973
  }
966
974
  }
967
975
  } catch (error) {
package/src/cli.js CHANGED
@@ -45,7 +45,7 @@ Scope:
45
45
  --scanner NAME projects, global, versions, agents, system
46
46
  --min-size SIZE ignore smaller items (default 1M)
47
47
  --older-than AGE activity window, newer files are kept (1h)
48
- --inactive AGE project inactivity for moderate tier (30d)
48
+ --inactive AGE idle time before agent scratch counts as abandoned (30d)
49
49
  --journal-keep SIZE journald size to keep (512M)
50
50
 
51
51
  Goals (emergency):
@@ -634,6 +634,7 @@ class DockerScan {
634
634
  scanStatus: env.scanStatus,
635
635
  hint: env.probeHint ?? null,
636
636
  layerSource: env.writableLayer?.source ?? null,
637
+ timings: env.timings?.toJSON() ?? [],
637
638
  });
638
639
  if (await env.which('docker')) {
639
640
  try {
package/src/emergency.js CHANGED
@@ -18,6 +18,7 @@ import { LocalEnv } from './env/local.js';
18
18
  import { TIERS, selectByTier, tierRank } from './items.js';
19
19
  import { resolveGoal, resolveOptions } from './options.js';
20
20
  import { revalidateReport, scan } from './scan.js';
21
+ import { PhaseTimer } from './timings.js';
21
22
 
22
23
  /**
23
24
  * Used percentage the way `df` prints it: used / (used + available).
@@ -86,6 +87,8 @@ export async function emergency(input = {}) {
86
87
  path: target,
87
88
  diskBefore,
88
89
  bytesNeeded: bytesNeeded(goal, diskBefore),
90
+ // Shared by every cleanup pass of a streamed scan.
91
+ timings: new PhaseTimer(),
89
92
  });
90
93
  const report = input.report
91
94
  ? await revalidateReport(input.report, { ...input, env })
@@ -150,6 +153,7 @@ async function escalate({ report, options, env, target, goal, audit }) {
150
153
  env,
151
154
  onProgress: persistProgress,
152
155
  priorHealth: audit.health,
156
+ timings: audit.timings,
153
157
  });
154
158
  audit.health = cleaner.health.records;
155
159
  const done = new Set(audit.entries.map((entry) => entry.id));
package/src/env/local.js CHANGED
@@ -48,6 +48,40 @@ export function parseLsofCwds(output) {
48
48
  return cwds;
49
49
  }
50
50
 
51
+ /**
52
+ * Absolute directories of a `PATH` value.
53
+ * @param {string} value
54
+ * @returns {string[]}
55
+ */
56
+ export function searchPathDirs(value) {
57
+ return (value ?? '').split(':').filter((dir) => dir.startsWith('/'));
58
+ }
59
+
60
+ /**
61
+ * `PATH` directories by pid from `ps -E -ww -axo pid=,command=` output,
62
+ * which appends the environment of the user's own processes to their
63
+ * arguments. A value ends at the first space, so a directory whose name
64
+ * contains one is not tracked.
65
+ * @param {string} output
66
+ * @returns {Map<number, string[]>}
67
+ */
68
+ export function parsePsSearchPaths(output) {
69
+ const paths = new Map();
70
+ for (const line of output.split('\n')) {
71
+ const match = /^\s*(\d+)\s/.exec(line);
72
+ if (!match) {
73
+ continue;
74
+ }
75
+ const dirs = [...line.matchAll(/(?:^|\s)PATH=(\S*)/g)].flatMap((found) =>
76
+ searchPathDirs(found[1])
77
+ );
78
+ if (dirs.length > 0) {
79
+ paths.set(Number(match[1]), [...new Set(dirs)]);
80
+ }
81
+ }
82
+ return paths;
83
+ }
84
+
51
85
  /**
52
86
  * Environment adapter for the machine running dss, backed by node:fs.
53
87
  *
@@ -609,35 +643,6 @@ export class LocalEnv {
609
643
  ).filter(Boolean);
610
644
  }
611
645
 
612
- /** Source activity excludes generated trees, and stops as soon as recent work is seen. */
613
- async projectActivity(project, globs, since) {
614
- let newest = 0;
615
- let frontier = [project];
616
- const device = await this.deviceId(project);
617
- while (frontier.length && newest < since) {
618
- const children = await mapLimit(frontier, 8, async (dir) => {
619
- checkSignal(this.signal);
620
- const next = [];
621
- for (const entry of await this.list(dir)) {
622
- if (entry.type === 'dir') {
623
- if (
624
- !this.skipDiscoveryDirectory(dir, entry.name) &&
625
- !globs.some((glob) => matchesGlob(entry.name, glob)) &&
626
- entry.device === device
627
- ) {
628
- next.push(entry.path);
629
- }
630
- } else if (entry.type === 'file') {
631
- newest = Math.max(newest, entry.mtimeMs);
632
- }
633
- }
634
- return next;
635
- });
636
- frontier = children.flat();
637
- }
638
- return newest;
639
- }
640
-
641
646
  async usageMany(targets, options) {
642
647
  const usages = await this.rawUsageMany(targets, options);
643
648
  return this.writableLayer
@@ -789,6 +794,15 @@ export class LocalEnv {
789
794
  ).stdout
790
795
  )
791
796
  : new Map();
797
+ // A process may start programs from its PATH at any time without holding
798
+ // them open (for example a release directory of an updated tool).
799
+ const searchPaths =
800
+ this.platform === 'darwin'
801
+ ? parsePsSearchPaths(
802
+ (await this.run(['ps', '-E', '-ww', '-axo', 'pid=,command=']))
803
+ .stdout
804
+ )
805
+ : new Map();
792
806
  return result.stdout
793
807
  .split('\n')
794
808
  .map((line) => /^\s*(\d+)\s+(.+)$/.exec(line))
@@ -798,6 +812,7 @@ export class LocalEnv {
798
812
  name: path.basename(m[2].trim()),
799
813
  cwd: cwds.get(Number(m[1])) ?? null,
800
814
  command: commands.get(Number(m[1])) ?? '',
815
+ searchPath: searchPaths.get(Number(m[1])) ?? [],
801
816
  }))
802
817
  .filter((proc) => !selfPids.has(proc.pid));
803
818
  }
@@ -831,6 +846,9 @@ export class LocalEnv {
831
846
  const cmdline = await io
832
847
  .readFile(`${base}/cmdline`, 'utf8')
833
848
  .catch(() => '');
849
+ const environ = await io
850
+ .readFile(`${base}/environ`, 'utf8')
851
+ .catch(() => '');
834
852
  processes.push({
835
853
  pid: Number(pid),
836
854
  ...(await procIdentity(base, io)),
@@ -841,6 +859,12 @@ export class LocalEnv {
841
859
  aliases: processAliases(exe, cmdline.split('\0')[0]),
842
860
  command: cmdline.replaceAll('\0', ' '),
843
861
  argv: cmdline.split('\0').filter(Boolean),
862
+ searchPath: searchPathDirs(
863
+ environ
864
+ .split('\0')
865
+ .find((entry) => entry.startsWith('PATH='))
866
+ ?.slice('PATH='.length)
867
+ ),
844
868
  });
845
869
  }
846
870
  return processes;
package/src/env/shell.js CHANGED
@@ -417,37 +417,6 @@ export class ShellEnv {
417
417
  ]);
418
418
  }
419
419
 
420
- async projectActivity(project, globs) {
421
- const prune = [
422
- '-type',
423
- 'd',
424
- '(',
425
- ...findNameExpression([...new Set([...this.skipNames, ...globs])]),
426
- ')',
427
- '-prune',
428
- '-o',
429
- ];
430
- const argv = [
431
- 'find',
432
- project,
433
- '-xdev',
434
- ...this.#prunePaths(),
435
- ...prune,
436
- '-type',
437
- 'f',
438
- '-exec',
439
- 'stat',
440
- '-c',
441
- '%Y',
442
- '{}',
443
- '+',
444
- ];
445
- const result = await this.sh(
446
- `${argv.map(shellQuote).join(' ')} | awk 'BEGIN{m=0} {if ($1>m) m=$1} END{print m}'`
447
- );
448
- return Number(result.stdout.trim()) * 1000 || 0;
449
- }
450
-
451
420
  /**
452
421
  * Runs one `find` that prints entries of `type` matching `globs` (without
453
422
  * descending into matched directories) and prunes `skipNames`.
package/src/git.js CHANGED
@@ -36,36 +36,77 @@ const SAFE_GIT_ENV = {
36
36
  GIT_TERMINAL_PROMPT: '0',
37
37
  };
38
38
 
39
- /** A metadata-only Git directory, with no repository-supplied config or hooks. */
40
- async function safeGitDirectory(root, callback) {
41
- const source = path.join(root, '.git');
42
- const stat = await fsp.lstat(source);
43
- if (!stat.isDirectory()) {
44
- throw new Error('cannot safely inspect a linked Git worktree');
39
+ /**
40
+ * The checkout's own Git directory (HEAD, index) and the common directory
41
+ * holding refs and objects. Both are `<root>/.git` in a plain repository;
42
+ * a linked worktree or a submodule has a `.git` file naming its directory,
43
+ * and a linked worktree names the common directory in `commondir`.
44
+ */
45
+ async function gitDirectories(root) {
46
+ const dotGit = path.join(root, '.git');
47
+ const stat = await fsp.lstat(dotGit);
48
+ let gitDir = dotGit;
49
+ if (stat.isFile()) {
50
+ const named = /^gitdir: (.+)$/m.exec(await fsp.readFile(dotGit, 'utf8'));
51
+ if (!named) {
52
+ throw new Error(`${dotGit} does not name a Git directory`);
53
+ }
54
+ gitDir = path.resolve(root, named[1].trim());
55
+ } else if (!stat.isDirectory()) {
56
+ throw new Error(`${dotGit} is neither a directory nor a gitdir file`);
57
+ }
58
+ const commonDir = await fsp
59
+ .readFile(path.join(gitDir, 'commondir'), 'utf8')
60
+ .then(
61
+ (text) => path.resolve(gitDir, text.trim()),
62
+ (error) => {
63
+ if (error.code === 'ENOENT') {
64
+ return gitDir;
65
+ }
66
+ throw error;
67
+ }
68
+ );
69
+ if (!(await fsp.stat(path.join(commonDir, 'objects'))).isDirectory()) {
70
+ throw new Error(`${commonDir} has no object database`);
71
+ }
72
+ return { gitDir, commonDir };
73
+ }
74
+
75
+ async function copyGitMetadata(source, dir, names, force) {
76
+ for (const name of names) {
77
+ await fsp
78
+ .cp(path.join(source, name), path.join(dir, name), {
79
+ recursive: true,
80
+ force,
81
+ errorOnExist: !force,
82
+ })
83
+ .catch((error) => {
84
+ if (error.code !== 'ENOENT') {
85
+ throw error;
86
+ }
87
+ });
45
88
  }
89
+ }
90
+
91
+ /**
92
+ * A metadata-only Git directory, with no repository-supplied config or hooks.
93
+ * With `indexOnly`, refs and reflogs are left out: enough for `ls-files`,
94
+ * which reads only HEAD, the index and the work tree.
95
+ */
96
+ async function safeGitDirectory(root, callback, { indexOnly = false } = {}) {
97
+ const { gitDir, commonDir } = await gitDirectories(root);
46
98
  const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dss-safe-git-'));
47
99
  try {
48
- for (const name of [
49
- 'HEAD',
50
- 'index',
51
- 'packed-refs',
52
- 'shallow',
53
- 'refs',
54
- 'logs',
55
- ]) {
56
- await fsp
57
- .cp(path.join(source, name), path.join(dir, name), {
58
- recursive: true,
59
- force: false,
60
- errorOnExist: true,
61
- })
62
- .catch((error) => {
63
- if (error.code !== 'ENOENT') {
64
- throw error;
65
- }
66
- });
100
+ if (indexOnly) {
101
+ await copyGitMetadata(gitDir, dir, ['HEAD', 'index'], false);
102
+ await fsp.mkdir(path.join(dir, 'refs'));
103
+ } else {
104
+ await copyIndexAndRefs(gitDir, commonDir, dir);
67
105
  }
68
- await fsp.symlink(path.join(source, 'objects'), path.join(dir, 'objects'));
106
+ await fsp.symlink(
107
+ path.join(commonDir, 'objects'),
108
+ path.join(dir, 'objects')
109
+ );
69
110
  await fsp.writeFile(
70
111
  path.join(dir, 'config'),
71
112
  '[core]\n\trepositoryformatversion = 0\n\tbare = false\n'
@@ -76,6 +117,24 @@ async function safeGitDirectory(root, callback) {
76
117
  }
77
118
  }
78
119
 
120
+ async function copyIndexAndRefs(gitDir, commonDir, dir) {
121
+ await copyGitMetadata(
122
+ commonDir,
123
+ dir,
124
+ ['packed-refs', 'shallow', 'refs', 'logs'],
125
+ false
126
+ );
127
+ // Per-worktree state overrides the shared refs and reflogs.
128
+ await copyGitMetadata(
129
+ gitDir,
130
+ dir,
131
+ gitDir === commonDir
132
+ ? ['HEAD', 'index']
133
+ : ['HEAD', 'index', 'refs', 'logs'],
134
+ true
135
+ );
136
+ }
137
+
79
138
  function countLines(text) {
80
139
  return text.split('\n').filter((line) => line.trim() !== '').length;
81
140
  }
@@ -310,19 +369,22 @@ export class GitInspector {
310
369
  try {
311
370
  const result =
312
371
  this.env.kind === 'host'
313
- ? await safeGitDirectory(root, (safeDir) =>
314
- this.#git(
315
- root,
316
- [
317
- 'ls-files',
318
- '--cached',
319
- '--others',
320
- '--exclude-standard',
321
- '--',
322
- relative,
323
- ],
324
- safeDir
325
- )
372
+ ? await safeGitDirectory(
373
+ root,
374
+ (safeDir) =>
375
+ this.#git(
376
+ root,
377
+ [
378
+ 'ls-files',
379
+ '--cached',
380
+ '--others',
381
+ '--exclude-standard',
382
+ '--',
383
+ relative,
384
+ ],
385
+ safeDir
386
+ ),
387
+ { indexOnly: true }
326
388
  )
327
389
  : await this.#git(root, [
328
390
  'ls-files',
package/src/index.d.ts CHANGED
@@ -34,7 +34,7 @@ export interface ScanOptions {
34
34
  staleAge?: Duration;
35
35
  /** Alias of `staleAge`. */
36
36
  olderThan?: Duration;
37
- /** Inactivity before lockfile-backed node_modules become `safe`, other dependency dirs `moderate` (`30d`). */
37
+ /** Idle time before agent scratch and similar leftovers count as abandoned (`30d`). Project build and dependency directories do not depend on it. */
38
38
  inactive?: Duration;
39
39
  /** Items smaller than this are dropped (`1M`). */
40
40
  minSize?: Size;