disk-space-saviour 0.17.0 → 0.18.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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2c706f1: Treat every regenerable project directory as `safe` regardless of how recently
8
+ the project was used, and block it only while something uses it. Detect
9
+ processes that keep a directory on their `PATH`, stop a long-running Bun script
10
+ or Codex daemon from blocking caches they do not use, prune old Codex daemon
11
+ releases, resolve partial nvm pins to the newest matching install, keep Node.js
12
+ versions that hold user-installed global packages, inspect Git safety in linked
13
+ worktrees and submodules, and report per-phase scan timings.
14
+
3
15
  ## 0.17.0
4
16
 
5
17
  ### 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,21 +87,23 @@ 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,
@@ -124,7 +126,19 @@ is needed. Agent scratch cleanup requires inactivity and Git safety checks.
124
126
  that a process holds open (`/proc/*/fd`, `cwd`, `exe`, or `lsof` on
125
127
  macOS) are skipped. So are projects with a running `cargo`, `gradle`,
126
128
  `npm`, `node` or similar tool, and anything modified within
127
- `--older-than` (1h).
129
+ `--older-than` (1h). How long ago a project was last used does not
130
+ matter. A tool running elsewhere does not block a runtime cache it is not
131
+ using: a script of an installed project keeps no lock on the Bun install
132
+ cache, while `bun install`, `bunx` or an auto-installing script does.
133
+ A directory on the `PATH` of a running process is kept too: the process
134
+ may still start programs from it.
135
+ - **Toolchain versions are kept while anything may need them.** The
136
+ manager's default and aliases, versions pinned by a project (`.nvmrc`,
137
+ `rust-toolchain.toml`, ...) and the newest install stay. A partial nvm pin
138
+ such as `20` keeps only the newest matching 20.x, as nvm resolves it. A
139
+ version holding globally installed npm packages or pyenv virtual
140
+ environments is blocked; bundled `npm`/`corepack` and use-m caches
141
+ (`<name>-v-<version>`) are reinstalled on demand and do not count.
128
142
  Container paths are checked individually before each bounded deletion batch.
129
143
  - **Rust builds are pruned in two parts.** `cargo-superseded` removes
130
144
  older hashes of a unit (`.rlib`, `.rmeta`, `.so`/`.dylib`/`.a`,
@@ -343,7 +357,7 @@ example and `examples/universal-app` for a report viewer.
343
357
  | `--audit-dir`, `DSS_AUDIT_DIR` | `$XDG_STATE_HOME/disk-space-saviour/audit` | Where audit logs go |
344
358
  | `--backup-dir`, `DSS_BACKUP_DIR` | `…/disk-space-saviour/backups` | Container log backups before `docker rm` |
345
359
  | `--older-than` | `1h` | Activity window; newer files are kept |
346
- | `--inactive` | `30d` | Project inactivity before `moderate` |
360
+ | `--inactive` | `30d` | Idle time before agent scratch is abandoned |
347
361
  | `--min-size` | `1M` | Smaller items are not reported |
348
362
  | `--depth` | `3` | Docker nesting depth |
349
363
  | `--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.18.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;