disk-space-saviour 0.0.0-stage → 0.12.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/src/git.js ADDED
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Git safety checks. Deleting inside a work tree is only allowed when it
3
+ * cannot lose work:
4
+ * - regenerable directories must not contain tracked files;
5
+ * - removing a whole work tree (abandoned solver checkouts, containers)
6
+ * additionally requires no uncommitted changes, no commits missing from
7
+ * every remote (`git log --branches --not --remotes`), and no stashes.
8
+ *
9
+ * When Git itself is unavailable but a `.git` exists, the check fails
10
+ * closed: the item is blocked.
11
+ */
12
+
13
+ import { promises as fsp } from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+
17
+ import { trace } from './exec.js';
18
+
19
+ const GIT_FLAGS = [
20
+ '--no-optional-locks',
21
+ '-c',
22
+ 'core.fsmonitor=false',
23
+ '-c',
24
+ 'core.hooksPath=/dev/null',
25
+ ];
26
+
27
+ const SAFE_GIT_ENV = {
28
+ GIT_CONFIG_NOSYSTEM: '1',
29
+ // Git for Windows understands this MSYS path; Node's \\.\nul does not.
30
+ GIT_CONFIG_GLOBAL: '/dev/null',
31
+ GIT_CONFIG_SYSTEM: '/dev/null',
32
+ GIT_CONFIG_COUNT: '0',
33
+ GIT_CONFIG_PARAMETERS: '',
34
+ GIT_ATTR_NOSYSTEM: '1',
35
+ GIT_TERMINAL_PROMPT: '0',
36
+ };
37
+
38
+ /** A metadata-only Git directory, with no repository-supplied config or hooks. */
39
+ async function safeGitDirectory(root, callback) {
40
+ const source = path.join(root, '.git');
41
+ const stat = await fsp.lstat(source);
42
+ if (!stat.isDirectory()) {
43
+ throw new Error('cannot safely inspect a linked Git worktree');
44
+ }
45
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dss-safe-git-'));
46
+ try {
47
+ for (const name of [
48
+ 'HEAD',
49
+ 'index',
50
+ 'packed-refs',
51
+ 'shallow',
52
+ 'refs',
53
+ 'logs',
54
+ ]) {
55
+ await fsp
56
+ .cp(path.join(source, name), path.join(dir, name), {
57
+ recursive: true,
58
+ force: false,
59
+ errorOnExist: true,
60
+ })
61
+ .catch((error) => {
62
+ if (error.code !== 'ENOENT') {
63
+ throw error;
64
+ }
65
+ });
66
+ }
67
+ await fsp.symlink(path.join(source, 'objects'), path.join(dir, 'objects'));
68
+ await fsp.writeFile(
69
+ path.join(dir, 'config'),
70
+ '[core]\n\trepositoryformatversion = 0\n\tbare = false\n'
71
+ );
72
+ return await callback(dir);
73
+ } finally {
74
+ await fsp.rm(dir, { recursive: true, force: true });
75
+ }
76
+ }
77
+
78
+ function countLines(text) {
79
+ return text.split('\n').filter((line) => line.trim() !== '').length;
80
+ }
81
+
82
+ function plural(count, word, many = `${word}s`) {
83
+ return `${count} ${count === 1 ? word : many}`;
84
+ }
85
+
86
+ /**
87
+ * Reasons a repository with `state` (see `GitInspector#repoState`) must not
88
+ * be deleted, naming it `label`.
89
+ * @returns {string[]}
90
+ */
91
+ export function stateBlockers(label, state) {
92
+ if (state.error) {
93
+ return [`${label}: ${state.error}`];
94
+ }
95
+ const problems = [];
96
+ if (state.dirty > 0) {
97
+ problems.push(plural(state.dirty, 'uncommitted change'));
98
+ }
99
+ if (state.unpushed > 0) {
100
+ problems.push(plural(state.unpushed, 'unpushed commit'));
101
+ }
102
+ if (state.stashes > 0) {
103
+ problems.push(plural(state.stashes, 'stash entry', 'stash entries'));
104
+ }
105
+ return problems.length > 0
106
+ ? [`Git repository ${label} has ${problems.join(', ')}`]
107
+ : [];
108
+ }
109
+
110
+ export class GitInspector {
111
+ /**
112
+ * @param {object} env environment adapter
113
+ */
114
+ constructor(env) {
115
+ this.env = env;
116
+ this.states = new Map();
117
+ this.roots = new Map();
118
+ this.available = null;
119
+ }
120
+
121
+ async #git(root, args, safeDir = null) {
122
+ const result = await this.env.run(
123
+ [
124
+ 'git',
125
+ ...GIT_FLAGS,
126
+ '-C',
127
+ root,
128
+ ...(safeDir ? ['--git-dir', safeDir, '--work-tree', root] : []),
129
+ ...args,
130
+ ],
131
+ { env: SAFE_GIT_ENV }
132
+ );
133
+ trace('git', root, args.join(' '), '->', result.code);
134
+ return result;
135
+ }
136
+
137
+ async isAvailable() {
138
+ this.available ??= await this.env.which('git');
139
+ return this.available;
140
+ }
141
+
142
+ /**
143
+ * Closest directory at or above `target` that contains `.git`, or null.
144
+ * @param {string} target
145
+ * @param {string} [stopAt] do not look above this directory
146
+ */
147
+ async findRepoRoot(target, stopAt) {
148
+ const pathApi = this.env.path;
149
+ let dir = target;
150
+ const visited = [];
151
+ for (;;) {
152
+ if (this.roots.has(dir)) {
153
+ const root = this.roots.get(dir);
154
+ visited.forEach((entry) => this.roots.set(entry, root));
155
+ return root;
156
+ }
157
+ visited.push(dir);
158
+ if (await this.env.exists(pathApi.join(dir, '.git'))) {
159
+ visited.forEach((entry) => this.roots.set(entry, dir));
160
+ return dir;
161
+ }
162
+ const parent = pathApi.dirname(dir);
163
+ if (parent === dir || dir === stopAt) {
164
+ visited.forEach((entry) => this.roots.set(entry, null));
165
+ return null;
166
+ }
167
+ dir = parent;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Uncommitted, unpushed and stashed work of a repository (cached).
173
+ * @param {string} root
174
+ * @returns {Promise<{root: string, error: string|null, dirty: number,
175
+ * unpushed: number, stashes: number, lastCommitMs: number}>}
176
+ */
177
+ repoState(root) {
178
+ if (!this.states.has(root)) {
179
+ this.states.set(root, this.#loadState(root));
180
+ }
181
+ return this.states.get(root);
182
+ }
183
+
184
+ /** Discard scan-time Git state before a deletion decision. */
185
+ invalidate() {
186
+ this.states.clear();
187
+ this.roots.clear();
188
+ }
189
+
190
+ async #loadState(root) {
191
+ const state = {
192
+ root,
193
+ error: null,
194
+ dirty: 0,
195
+ unpushed: 0,
196
+ stashes: 0,
197
+ lastCommitMs: 0,
198
+ };
199
+ if (!(await this.isAvailable())) {
200
+ state.error = 'git is not installed, cannot verify the repository';
201
+ return state;
202
+ }
203
+ try {
204
+ return this.env.kind === 'host'
205
+ ? await safeGitDirectory(root, (safeDir) =>
206
+ this.#inspectState(root, state, safeDir)
207
+ )
208
+ : await this.#inspectState(root, state);
209
+ } catch (error) {
210
+ state.error = `cannot safely inspect Git repository: ${error.message}`;
211
+ return state;
212
+ }
213
+ }
214
+
215
+ async #inspectState(root, state, safeDir = null) {
216
+ const git = (args) => this.#git(root, args, safeDir);
217
+ const status = await git([
218
+ 'status',
219
+ '--porcelain',
220
+ '--untracked-files=normal',
221
+ ]);
222
+ if (status.code !== 0) {
223
+ state.error = `git status failed: ${status.stderr.trim() || `exit ${status.code}`}`;
224
+ return state;
225
+ }
226
+ state.dirty = countLines(status.stdout);
227
+ const unpushed = await git([
228
+ 'log',
229
+ 'HEAD',
230
+ '--branches',
231
+ '--not',
232
+ '--remotes',
233
+ '--oneline',
234
+ ]);
235
+ if (unpushed.code !== 0) {
236
+ state.error = `git log failed: ${unpushed.stderr.trim() || `exit ${unpushed.code}`}`;
237
+ return state;
238
+ }
239
+ state.unpushed = countLines(unpushed.stdout);
240
+ const stashes = await git(['stash', 'list']);
241
+ if (stashes.code !== 0) {
242
+ state.error = `git stash list failed: ${stashes.stderr.trim() || `exit ${stashes.code}`}`;
243
+ return state;
244
+ }
245
+ state.stashes = countLines(stashes.stdout);
246
+ const last = await git(['log', '-1', '--format=%ct']);
247
+ const seconds = Number(last.stdout.trim());
248
+ state.lastCommitMs = Number.isFinite(seconds) ? seconds * 1000 : 0;
249
+ return state;
250
+ }
251
+
252
+ /**
253
+ * Reasons a whole repository must not be deleted.
254
+ * @param {string} root
255
+ * @returns {Promise<string[]>}
256
+ */
257
+ async repoBlockers(root) {
258
+ return stateBlockers(root, await this.repoState(root));
259
+ }
260
+
261
+ /** Protect an entire temporary work directory, including nested clones. */
262
+ async treeBlockers(target) {
263
+ const dirs = await this.env.findDirs([target], {
264
+ globs: ['.git'],
265
+ maxDepth: 32,
266
+ skipNames: [],
267
+ });
268
+ const files = await this.env.findFiles([target], {
269
+ names: ['.git'],
270
+ maxDepth: 32,
271
+ skipNames: [],
272
+ });
273
+ const roots = new Set([
274
+ ...((await this.env.exists(this.env.path.join(target, '.git')))
275
+ ? [target]
276
+ : []),
277
+ ...dirs.map((entry) => entry.parent),
278
+ ...files.map((file) => this.env.path.dirname(file)),
279
+ ]);
280
+ if (roots.size === 0) {
281
+ return [`${target}: no Git repository found; cannot verify work`];
282
+ }
283
+ if (roots.size > 50) {
284
+ return [`${target}: too many repositories to verify safely`];
285
+ }
286
+ const blockers = [];
287
+ for (const root of roots) {
288
+ blockers.push(...(await this.repoBlockers(root)));
289
+ }
290
+ return blockers;
291
+ }
292
+
293
+ /**
294
+ * Number of tracked files inside `target` (a path within `root`).
295
+ * Returns -1 when Git cannot answer.
296
+ */
297
+ async trackedCount(root, target) {
298
+ if (!(await this.isAvailable())) {
299
+ return -1;
300
+ }
301
+ const relative = this.env.path.relative(root, target) || '.';
302
+ try {
303
+ const result =
304
+ this.env.kind === 'host'
305
+ ? await safeGitDirectory(root, (safeDir) =>
306
+ this.#git(root, ['ls-files', '--', relative], safeDir)
307
+ )
308
+ : await this.#git(root, ['ls-files', '--', relative]);
309
+ return result.code === 0 ? countLines(result.stdout) : -1;
310
+ } catch {
311
+ return -1;
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Reasons a regenerable directory inside a work tree must be kept:
317
+ * tracked files inside it, or (with `requireClean`) repository state.
318
+ * @param {string} target
319
+ * @param {{requireClean?: boolean, stopAt?: string}} [options]
320
+ */
321
+ async pathBlockers(target, { requireClean = false, stopAt } = {}) {
322
+ const root = await this.findRepoRoot(target, stopAt);
323
+ if (!root) {
324
+ return [];
325
+ }
326
+ if (root === target) {
327
+ return this.repoBlockers(root);
328
+ }
329
+ const tracked = await this.trackedCount(root, target);
330
+ if (tracked === -1) {
331
+ return [`cannot verify Git tracking of ${target}`];
332
+ }
333
+ if (tracked > 0) {
334
+ return [`${plural(tracked, 'file')} inside ${target} tracked by Git`];
335
+ }
336
+ return requireClean ? this.repoBlockers(root) : [];
337
+ }
338
+ }
package/src/health.js ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Task health check around a cleanup: the long-running processes of an
3
+ * environment must survive it.
4
+ *
5
+ * Before the first deletion in an environment the watcher records PID 1,
6
+ * AI agents (`claude`, `codex`, `solve`, ...) and build tools (`cargo`,
7
+ * `gradle`, ...) with their start times. After every executed item it lists
8
+ * the processes again. A watched process that is gone, or whose PID now
9
+ * belongs to a process with another start time, stops all further cleaning
10
+ * in that environment. A build that simply finishes also counts as gone:
11
+ * a failing build exits the same way, so the check cannot tell them apart
12
+ * and stays on the safe side.
13
+ */
14
+
15
+ import { matchingName } from './liveness.js';
16
+
17
+ /** Agent and task runner process names. */
18
+ export const AGENT_NAMES = [
19
+ 'claude',
20
+ 'codex',
21
+ 'solve',
22
+ 'hive',
23
+ 'gemini',
24
+ 'opencode',
25
+ 'aider',
26
+ 'qwen',
27
+ 'start-command',
28
+ ];
29
+
30
+ /** Long-running build tools. */
31
+ export const BUILD_TOOL_NAMES = [
32
+ 'cargo',
33
+ 'gradle',
34
+ 'gradlew',
35
+ 'mvn',
36
+ 'bazel',
37
+ 'sbt',
38
+ 'dotnet',
39
+ ];
40
+
41
+ const WATCHED_NAMES = [...AGENT_NAMES, ...BUILD_TOOL_NAMES];
42
+
43
+ function identity(proc) {
44
+ return {
45
+ pid: proc.pid,
46
+ name: proc.name,
47
+ startTime: proc.startTime ?? null,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Processes worth watching: PID 1 and every agent or build tool.
53
+ * @param {object[]} processes output of `env.processes()`
54
+ */
55
+ export function watchedProcesses(processes) {
56
+ return processes
57
+ .filter((proc) => proc.pid === 1 || matchingName(proc, WATCHED_NAMES))
58
+ .map(identity);
59
+ }
60
+
61
+ /**
62
+ * Watched processes that are missing from `processes` or were replaced by
63
+ * another process with the same PID.
64
+ * @returns {Array<{pid: number, name: string, startTime: string|null,
65
+ * reason: string}>}
66
+ */
67
+ export function lostProcesses(watched, processes) {
68
+ const current = new Map(processes.map((proc) => [proc.pid, proc]));
69
+ const lost = [];
70
+ for (const proc of watched) {
71
+ const now = current.get(proc.pid);
72
+ if (!now) {
73
+ lost.push({ ...proc, reason: 'exited' });
74
+ } else if (
75
+ proc.startTime !== null &&
76
+ (now.startTime ?? null) !== null &&
77
+ now.startTime !== proc.startTime
78
+ ) {
79
+ lost.push({ ...proc, reason: 'restarted with a new start time' });
80
+ }
81
+ }
82
+ return lost;
83
+ }
84
+
85
+ /** Processes of `env`, or null when they cannot be listed. */
86
+ async function listProcesses(env) {
87
+ try {
88
+ return (await env.processes?.()) ?? null;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ function describe(lost) {
95
+ return lost
96
+ .map((proc) => `${proc.name} (pid ${proc.pid}) ${proc.reason}`)
97
+ .join(', ');
98
+ }
99
+
100
+ /**
101
+ * Per-environment snapshots and re-checks. `records` is the audit view.
102
+ */
103
+ export class HealthWatch {
104
+ constructor() {
105
+ this.records = [];
106
+ this.byEnv = new Map();
107
+ }
108
+
109
+ /**
110
+ * Takes the snapshot of an environment once, before its first deletion.
111
+ */
112
+ async baseline(env, item) {
113
+ if (this.byEnv.has(item.env)) {
114
+ return;
115
+ }
116
+ const processes = await listProcesses(env);
117
+ const record = {
118
+ env: item.env,
119
+ envLabel: item.envLabel,
120
+ checks: 0,
121
+ watched: processes ? watchedProcesses(processes) : [],
122
+ available: Boolean(processes),
123
+ lost: [],
124
+ stopped: null,
125
+ };
126
+ this.byEnv.set(item.env, record);
127
+ this.records.push(record);
128
+ }
129
+
130
+ /**
131
+ * Why cleaning in the item's environment has stopped, or null.
132
+ */
133
+ stopReason(item) {
134
+ const record = this.byEnv.get(item.env);
135
+ return record?.stopped
136
+ ? `cleaning stopped in this environment: ${record.stopped}`
137
+ : null;
138
+ }
139
+
140
+ /**
141
+ * Compares `processes` (null when they cannot be listed) with the
142
+ * snapshot and stops the environment when a watched process is gone.
143
+ * @returns {string|null} the stop reason, when the environment is stopped
144
+ */
145
+ verify(item, processes) {
146
+ const record = this.byEnv.get(item.env);
147
+ if (!record || record.stopped || record.watched.length === 0) {
148
+ return record?.stopped ?? null;
149
+ }
150
+ record.checks += 1;
151
+ if (!processes) {
152
+ record.stopped = 'cannot list processes to verify running tasks';
153
+ return record.stopped;
154
+ }
155
+ const lost = lostProcesses(record.watched, processes);
156
+ if (lost.length === 0) {
157
+ return null;
158
+ }
159
+ record.lost = lost;
160
+ record.stopped = `watched process ${describe(lost)} while cleaning ${item.id}`;
161
+ return record.stopped;
162
+ }
163
+
164
+ /**
165
+ * Lists the processes of `env` and verifies them.
166
+ */
167
+ async check(env, item) {
168
+ const record = this.byEnv.get(item.env);
169
+ if (!record || record.stopped || record.watched.length === 0) {
170
+ return record?.stopped ?? null;
171
+ }
172
+ return this.verify(item, await listProcesses(env));
173
+ }
174
+ }