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 +24 -0
- package/README.md +34 -14
- package/bin/dss.js +5 -1
- package/package.json +1 -1
- package/src/clean.js +26 -18
- package/src/cli.js +1 -1
- package/src/docker/scan.js +1 -0
- package/src/emergency.js +4 -0
- package/src/env/local.js +53 -29
- package/src/env/shell.js +0 -31
- package/src/git.js +101 -39
- package/src/index.d.ts +1 -1
- package/src/liveness.js +131 -9
- package/src/report.js +51 -0
- package/src/rules/apps.js +7 -3
- package/src/rules/downloads.js +175 -0
- package/src/rules/ecosystems.js +5 -4
- package/src/rules/other.js +7 -1
- package/src/rules/versions.js +23 -0
- package/src/scan.js +24 -5
- package/src/scanners/global.js +36 -15
- package/src/scanners/projects.js +67 -111
- package/src/scanners/versions.js +102 -3
- package/src/timings.js +54 -0
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
|
|
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
|
-
|
|
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
|
|
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
|
|
91
|
-
|
|
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` |
|
|
96
|
-
| `moderate` |
|
|
97
|
-
| `aggressive` |
|
|
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.
|
|
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` |
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
926
|
-
|
|
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
|
|
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
|
|
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
|
|
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):
|
package/src/docker/scan.js
CHANGED
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
|
-
/**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
'HEAD',
|
|
50
|
-
'
|
|
51
|
-
|
|
52
|
-
|
|
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(
|
|
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(
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
/**
|
|
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;
|