@webpieces/rules-config 0.4.662 → 0.4.663
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/package.json +1 -1
- package/src/home-config.d.ts +28 -9
- package/src/home-config.js +62 -30
- package/src/home-config.js.map +1 -1
- package/src/index.d.ts +4 -1
- package/src/index.js +26 -6
- package/src/index.js.map +1 -1
- package/src/orphan-dir-archive.d.ts +109 -0
- package/src/orphan-dir-archive.js +256 -0
- package/src/orphan-dir-archive.js.map +1 -0
- package/src/orphan-dir-scan.d.ts +89 -0
- package/src/orphan-dir-scan.js +150 -0
- package/src/orphan-dir-scan.js.map +1 -0
- package/src/orphan-dir-sweep.d.ts +69 -0
- package/src/orphan-dir-sweep.js +142 -0
- package/src/orphan-dir-sweep.js.map +1 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { OrphanCandidate } from './orphan-dir-scan';
|
|
2
|
+
/** Where every sweep's archive lands, under the repo-wide `.webpieces/` — never under a worktree's own. */
|
|
3
|
+
export declare const TRASH_STATE_DIR = "trash";
|
|
4
|
+
/** The per-sweep manifest, holding what moved and the command that brings each one back. */
|
|
5
|
+
export declare const TRASH_MANIFEST_FILE = "manifest.json";
|
|
6
|
+
/** Sweeps older than this are reaped by the next sweep. Matches RETENTION_DAYS for the aged-tree sweep. */
|
|
7
|
+
export declare const TRASH_RETENTION_DAYS = 30;
|
|
8
|
+
/**
|
|
9
|
+
* Moves orphan directories into `.webpieces/trash/<sweepId>/` and records how to bring each one back.
|
|
10
|
+
*
|
|
11
|
+
* ─── WHY `mv` AND NEVER `rm -rf` ──────────────────────────────────────────────────────────────────────
|
|
12
|
+
* This runs unattended, on other people's machines, against a predicate that is very good but is still a
|
|
13
|
+
* predicate. A `mv` makes the worst possible outcome — a false positive on a directory somebody actually
|
|
14
|
+
* wanted — a thing they undo in one command, instead of a thing they restore from a backup or lose. That
|
|
15
|
+
* single decision is what makes it defensible to run this automatically at all, and it is the same trade
|
|
16
|
+
* `wp-cleanup` already makes when it archives a branch to a tag before deleting the ref.
|
|
17
|
+
*
|
|
18
|
+
* ─── WHY ONE DIRECTORY PER SWEEP, TIMESTAMP-NAMED ─────────────────────────────────────────────────────
|
|
19
|
+
* `<sweepId>` is a UTC timestamp with `:` swapped for `-` (path-safe everywhere, Windows included). That
|
|
20
|
+
* spelling sorts LEXICALLY in chronological order, so `ls` reads oldest-first and `ls -r` newest-first,
|
|
21
|
+
* with no flags to remember and no dates to parse. Grouping by sweep also answers the only question
|
|
22
|
+
* anybody actually asks of this directory — "what did the run I just did take?" — which a single flat
|
|
23
|
+
* pile of moved directories cannot answer at all.
|
|
24
|
+
*
|
|
25
|
+
* Each sweep directory reproduces the repo-relative path of what it holds, so the manifest's `recover=`
|
|
26
|
+
* is an ordinary `mv` of one path to another, with no bookkeeping needed to reconstruct the destination.
|
|
27
|
+
*/
|
|
28
|
+
export declare class OrphanDirArchiver {
|
|
29
|
+
/**
|
|
30
|
+
* Move every candidate into a fresh sweep directory. `now` is a parameter so specs pin the sweep id
|
|
31
|
+
* instead of racing the clock.
|
|
32
|
+
*/
|
|
33
|
+
archive(repoRoot: string, candidates: readonly OrphanCandidate[], now: Date): OrphanSweepResult;
|
|
34
|
+
/**
|
|
35
|
+
* Delete sweep directories older than TRASH_RETENTION_DAYS, and report how many went. Called by the
|
|
36
|
+
* sweeper AFTER a successful archive, so trash cannot grow without bound on a machine that has the
|
|
37
|
+
* flag on. This one really does delete — it is deleting the archive, which is the second copy.
|
|
38
|
+
*/
|
|
39
|
+
reapAged(repoRoot: string, now: Date): number;
|
|
40
|
+
/** `<repo>/.webpieces/trash` — the SHARED state dir, so a worktree's trash is not stranded with it. */
|
|
41
|
+
trashRoot(repoRoot: string): string;
|
|
42
|
+
/**
|
|
43
|
+
* `2026-08-19T14-32-05Z`. ISO order with `:` replaced, and milliseconds dropped — a sweep is a
|
|
44
|
+
* human-scale event and a second is resolution enough to name one.
|
|
45
|
+
*/
|
|
46
|
+
sweepId(now: Date): string;
|
|
47
|
+
/** The Date a sweep id names, or null when the entry is not one of ours (a stray file, say). */
|
|
48
|
+
private parseSweepId;
|
|
49
|
+
private listSweeps;
|
|
50
|
+
/**
|
|
51
|
+
* `mv` the directory, creating the destination's parent chain first. Returns null on success, or the
|
|
52
|
+
* failure's message — a directory we cannot move is REPORTED and skipped, never fatal.
|
|
53
|
+
*
|
|
54
|
+
* `fs.renameSync` first because it is atomic and instant within a filesystem; a cross-device rename
|
|
55
|
+
* (EXDEV — a repo whose `.webpieces` sits on a different mount) falls back to a recursive copy plus
|
|
56
|
+
* delete, which is what `mv` itself does in the same situation.
|
|
57
|
+
*/
|
|
58
|
+
private move;
|
|
59
|
+
private moveAcrossDevices;
|
|
60
|
+
private isCrossDevice;
|
|
61
|
+
private removeTree;
|
|
62
|
+
/**
|
|
63
|
+
* The sweep's own record, beside what it took. Written even when every move failed, because "this
|
|
64
|
+
* sweep tried and could not" is exactly the state somebody debugging needs to find on disk.
|
|
65
|
+
*
|
|
66
|
+
* Returns the failure message, or null on success. It is REPORTED rather than swallowed: the manifest
|
|
67
|
+
* is the durable copy of every `recover=` command, so losing it silently is the one failure here that
|
|
68
|
+
* costs somebody the ability to undo what just happened. Still never fatal — the directories are
|
|
69
|
+
* already safely moved by this point, and the recover lines are still printed to the terminal.
|
|
70
|
+
*/
|
|
71
|
+
private writeManifest;
|
|
72
|
+
}
|
|
73
|
+
/** One directory that was moved, and the exact command that undoes it. Data-only. */
|
|
74
|
+
export declare class ArchivedOrphan {
|
|
75
|
+
/** Where it used to live, repo-relative. */
|
|
76
|
+
relativePath: string;
|
|
77
|
+
/** Where it lives now, absolute. */
|
|
78
|
+
archivedAt: string;
|
|
79
|
+
/** The `mv` that puts it back — printed to the human and stored in the manifest verbatim. */
|
|
80
|
+
recoverCommand: string;
|
|
81
|
+
constructor(relativePath: string, archivedAt: string, recoverCommand: string);
|
|
82
|
+
}
|
|
83
|
+
/** One directory the sweep found but could not move, with the reason. Data-only. */
|
|
84
|
+
export declare class FailedOrphan {
|
|
85
|
+
relativePath: string;
|
|
86
|
+
reason: string;
|
|
87
|
+
constructor(relativePath: string, reason: string);
|
|
88
|
+
}
|
|
89
|
+
/** What one sweep did. Data-only. */
|
|
90
|
+
export declare class OrphanSweepResult {
|
|
91
|
+
sweepId: string;
|
|
92
|
+
sweepDir: string;
|
|
93
|
+
moved: readonly ArchivedOrphan[];
|
|
94
|
+
failed: readonly FailedOrphan[];
|
|
95
|
+
/**
|
|
96
|
+
* Why the manifest could not be written, or null when it was. Non-null means the printed `recover=`
|
|
97
|
+
* lines are the ONLY copy — see writeManifest().
|
|
98
|
+
*/
|
|
99
|
+
manifestError: string | null;
|
|
100
|
+
constructor(sweepId: string, sweepDir: string, moved: readonly ArchivedOrphan[], failed: readonly FailedOrphan[], manifestError: string | null);
|
|
101
|
+
}
|
|
102
|
+
/** The on-disk shape of `manifest.json`. Data-only. */
|
|
103
|
+
export declare class OrphanSweepManifest {
|
|
104
|
+
sweepId: string;
|
|
105
|
+
repoRoot: string;
|
|
106
|
+
moved: readonly ArchivedOrphan[];
|
|
107
|
+
failed: readonly FailedOrphan[];
|
|
108
|
+
constructor(sweepId: string, repoRoot: string, moved: readonly ArchivedOrphan[], failed: readonly FailedOrphan[]);
|
|
109
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OrphanSweepManifest = exports.OrphanSweepResult = exports.FailedOrphan = exports.ArchivedOrphan = exports.OrphanDirArchiver = exports.TRASH_RETENTION_DAYS = exports.TRASH_MANIFEST_FILE = exports.TRASH_STATE_DIR = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
6
|
+
const path = tslib_1.__importStar(require("path"));
|
|
7
|
+
const inversify_1 = require("inversify");
|
|
8
|
+
const state_dir_1 = require("./state-dir");
|
|
9
|
+
const to_error_1 = require("./to-error");
|
|
10
|
+
/** Where every sweep's archive lands, under the repo-wide `.webpieces/` — never under a worktree's own. */
|
|
11
|
+
exports.TRASH_STATE_DIR = 'trash';
|
|
12
|
+
/** The per-sweep manifest, holding what moved and the command that brings each one back. */
|
|
13
|
+
exports.TRASH_MANIFEST_FILE = 'manifest.json';
|
|
14
|
+
/** Sweeps older than this are reaped by the next sweep. Matches RETENTION_DAYS for the aged-tree sweep. */
|
|
15
|
+
exports.TRASH_RETENTION_DAYS = 30;
|
|
16
|
+
const MILLIS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
17
|
+
/**
|
|
18
|
+
* Moves orphan directories into `.webpieces/trash/<sweepId>/` and records how to bring each one back.
|
|
19
|
+
*
|
|
20
|
+
* ─── WHY `mv` AND NEVER `rm -rf` ──────────────────────────────────────────────────────────────────────
|
|
21
|
+
* This runs unattended, on other people's machines, against a predicate that is very good but is still a
|
|
22
|
+
* predicate. A `mv` makes the worst possible outcome — a false positive on a directory somebody actually
|
|
23
|
+
* wanted — a thing they undo in one command, instead of a thing they restore from a backup or lose. That
|
|
24
|
+
* single decision is what makes it defensible to run this automatically at all, and it is the same trade
|
|
25
|
+
* `wp-cleanup` already makes when it archives a branch to a tag before deleting the ref.
|
|
26
|
+
*
|
|
27
|
+
* ─── WHY ONE DIRECTORY PER SWEEP, TIMESTAMP-NAMED ─────────────────────────────────────────────────────
|
|
28
|
+
* `<sweepId>` is a UTC timestamp with `:` swapped for `-` (path-safe everywhere, Windows included). That
|
|
29
|
+
* spelling sorts LEXICALLY in chronological order, so `ls` reads oldest-first and `ls -r` newest-first,
|
|
30
|
+
* with no flags to remember and no dates to parse. Grouping by sweep also answers the only question
|
|
31
|
+
* anybody actually asks of this directory — "what did the run I just did take?" — which a single flat
|
|
32
|
+
* pile of moved directories cannot answer at all.
|
|
33
|
+
*
|
|
34
|
+
* Each sweep directory reproduces the repo-relative path of what it holds, so the manifest's `recover=`
|
|
35
|
+
* is an ordinary `mv` of one path to another, with no bookkeeping needed to reconstruct the destination.
|
|
36
|
+
*/
|
|
37
|
+
let OrphanDirArchiver = class OrphanDirArchiver {
|
|
38
|
+
/**
|
|
39
|
+
* Move every candidate into a fresh sweep directory. `now` is a parameter so specs pin the sweep id
|
|
40
|
+
* instead of racing the clock.
|
|
41
|
+
*/
|
|
42
|
+
archive(repoRoot, candidates, now) {
|
|
43
|
+
const sweepId = this.sweepId(now);
|
|
44
|
+
const sweepDir = path.join(this.trashRoot(repoRoot), sweepId);
|
|
45
|
+
const moved = [];
|
|
46
|
+
const failed = [];
|
|
47
|
+
for (const candidate of candidates) {
|
|
48
|
+
const destination = path.join(sweepDir, candidate.relativePath);
|
|
49
|
+
const failure = this.move(candidate.absolutePath, destination);
|
|
50
|
+
if (failure !== null) {
|
|
51
|
+
failed.push(new FailedOrphan(candidate.relativePath, failure));
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
moved.push(new ArchivedOrphan(candidate.relativePath, destination, `mv '${destination}' '${candidate.absolutePath}'`));
|
|
55
|
+
}
|
|
56
|
+
const manifestError = this.writeManifest(sweepDir, sweepId, repoRoot, moved, failed);
|
|
57
|
+
return new OrphanSweepResult(sweepId, sweepDir, moved, failed, manifestError);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Delete sweep directories older than TRASH_RETENTION_DAYS, and report how many went. Called by the
|
|
61
|
+
* sweeper AFTER a successful archive, so trash cannot grow without bound on a machine that has the
|
|
62
|
+
* flag on. This one really does delete — it is deleting the archive, which is the second copy.
|
|
63
|
+
*/
|
|
64
|
+
reapAged(repoRoot, now) {
|
|
65
|
+
const root = this.trashRoot(repoRoot);
|
|
66
|
+
const cutoff = now.getTime() - exports.TRASH_RETENTION_DAYS * MILLIS_PER_DAY;
|
|
67
|
+
let reaped = 0;
|
|
68
|
+
for (const entry of this.listSweeps(root)) {
|
|
69
|
+
const stamp = this.parseSweepId(entry);
|
|
70
|
+
if (stamp === null || stamp.getTime() >= cutoff)
|
|
71
|
+
continue;
|
|
72
|
+
if (this.removeTree(path.join(root, entry)))
|
|
73
|
+
reaped += 1;
|
|
74
|
+
}
|
|
75
|
+
return reaped;
|
|
76
|
+
}
|
|
77
|
+
/** `<repo>/.webpieces/trash` — the SHARED state dir, so a worktree's trash is not stranded with it. */
|
|
78
|
+
trashRoot(repoRoot) {
|
|
79
|
+
return path.join(state_dir_1.dotWebpieces.shared(repoRoot), exports.TRASH_STATE_DIR);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* `2026-08-19T14-32-05Z`. ISO order with `:` replaced, and milliseconds dropped — a sweep is a
|
|
83
|
+
* human-scale event and a second is resolution enough to name one.
|
|
84
|
+
*/
|
|
85
|
+
sweepId(now) {
|
|
86
|
+
return now.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/:/g, '-');
|
|
87
|
+
}
|
|
88
|
+
/** The Date a sweep id names, or null when the entry is not one of ours (a stray file, say). */
|
|
89
|
+
parseSweepId(entry) {
|
|
90
|
+
if (!SWEEP_ID_PATTERN.test(entry))
|
|
91
|
+
return null;
|
|
92
|
+
const iso = `${entry.slice(0, 10)}T${entry.slice(11, 19).replace(/-/g, ':')}Z`;
|
|
93
|
+
const parsed = new Date(iso);
|
|
94
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
95
|
+
}
|
|
96
|
+
listSweeps(root) {
|
|
97
|
+
// webpieces-disable no-unmanaged-exceptions -- chokepoint: no trash directory yet is the normal
|
|
98
|
+
// state on every machine that has never swept, and it is not a condition anybody needs told about
|
|
99
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
100
|
+
try {
|
|
101
|
+
return fs.readdirSync(root);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
const error = (0, to_error_1.toError)(err);
|
|
105
|
+
void error;
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* `mv` the directory, creating the destination's parent chain first. Returns null on success, or the
|
|
111
|
+
* failure's message — a directory we cannot move is REPORTED and skipped, never fatal.
|
|
112
|
+
*
|
|
113
|
+
* `fs.renameSync` first because it is atomic and instant within a filesystem; a cross-device rename
|
|
114
|
+
* (EXDEV — a repo whose `.webpieces` sits on a different mount) falls back to a recursive copy plus
|
|
115
|
+
* delete, which is what `mv` itself does in the same situation.
|
|
116
|
+
*/
|
|
117
|
+
move(from, to) {
|
|
118
|
+
// webpieces-disable no-unmanaged-exceptions -- chokepoint: converts a per-directory move failure
|
|
119
|
+
// into a reported line, because one unmovable directory may not abandon the rest of the sweep
|
|
120
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
121
|
+
try {
|
|
122
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
123
|
+
fs.renameSync(from, to);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
const error = (0, to_error_1.toError)(err);
|
|
128
|
+
return this.moveAcrossDevices(from, to, error);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
moveAcrossDevices(from, to, cause) {
|
|
132
|
+
// webpieces-disable no-unmanaged-exceptions -- chokepoint: the fallback's own failure becomes the
|
|
133
|
+
// reported message for this directory, the same contract move() has
|
|
134
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
135
|
+
try {
|
|
136
|
+
if (!this.isCrossDevice(cause))
|
|
137
|
+
return cause.message;
|
|
138
|
+
fs.cpSync(from, to, { recursive: true });
|
|
139
|
+
fs.rmSync(from, { recursive: true, force: true });
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
const error = (0, to_error_1.toError)(err);
|
|
144
|
+
return error.message;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
isCrossDevice(error) {
|
|
148
|
+
// webpieces-disable no-any-unknown -- node attaches `code` to fs errors without typing it on Error
|
|
149
|
+
const code = error['code'];
|
|
150
|
+
return code === 'EXDEV';
|
|
151
|
+
}
|
|
152
|
+
removeTree(target) {
|
|
153
|
+
// webpieces-disable no-unmanaged-exceptions -- chokepoint: failing to reap OLD trash is cosmetic
|
|
154
|
+
// and may never surface as an error on a command whose real work already succeeded
|
|
155
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
156
|
+
try {
|
|
157
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
const error = (0, to_error_1.toError)(err);
|
|
162
|
+
void error;
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* The sweep's own record, beside what it took. Written even when every move failed, because "this
|
|
168
|
+
* sweep tried and could not" is exactly the state somebody debugging needs to find on disk.
|
|
169
|
+
*
|
|
170
|
+
* Returns the failure message, or null on success. It is REPORTED rather than swallowed: the manifest
|
|
171
|
+
* is the durable copy of every `recover=` command, so losing it silently is the one failure here that
|
|
172
|
+
* costs somebody the ability to undo what just happened. Still never fatal — the directories are
|
|
173
|
+
* already safely moved by this point, and the recover lines are still printed to the terminal.
|
|
174
|
+
*/
|
|
175
|
+
writeManifest(sweepDir, sweepId, repoRoot, moved, failed) {
|
|
176
|
+
// webpieces-disable no-unmanaged-exceptions -- chokepoint: the directories are already safely
|
|
177
|
+
// moved by this point, and an unwritable manifest may not turn that success into a failure
|
|
178
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
179
|
+
try {
|
|
180
|
+
fs.mkdirSync(sweepDir, { recursive: true });
|
|
181
|
+
fs.writeFileSync(path.join(sweepDir, exports.TRASH_MANIFEST_FILE), `${JSON.stringify(new OrphanSweepManifest(sweepId, repoRoot, moved, failed), null, 2)}\n`, 'utf8');
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
const error = (0, to_error_1.toError)(err);
|
|
186
|
+
return error.message;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
exports.OrphanDirArchiver = OrphanDirArchiver;
|
|
191
|
+
exports.OrphanDirArchiver = OrphanDirArchiver = tslib_1.__decorate([
|
|
192
|
+
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
|
|
193
|
+
], OrphanDirArchiver);
|
|
194
|
+
/** One directory that was moved, and the exact command that undoes it. Data-only. */
|
|
195
|
+
class ArchivedOrphan {
|
|
196
|
+
/** Where it used to live, repo-relative. */
|
|
197
|
+
relativePath;
|
|
198
|
+
/** Where it lives now, absolute. */
|
|
199
|
+
archivedAt;
|
|
200
|
+
/** The `mv` that puts it back — printed to the human and stored in the manifest verbatim. */
|
|
201
|
+
recoverCommand;
|
|
202
|
+
constructor(relativePath, archivedAt, recoverCommand) {
|
|
203
|
+
this.relativePath = relativePath;
|
|
204
|
+
this.archivedAt = archivedAt;
|
|
205
|
+
this.recoverCommand = recoverCommand;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
exports.ArchivedOrphan = ArchivedOrphan;
|
|
209
|
+
/** One directory the sweep found but could not move, with the reason. Data-only. */
|
|
210
|
+
class FailedOrphan {
|
|
211
|
+
relativePath;
|
|
212
|
+
reason;
|
|
213
|
+
constructor(relativePath, reason) {
|
|
214
|
+
this.relativePath = relativePath;
|
|
215
|
+
this.reason = reason;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
exports.FailedOrphan = FailedOrphan;
|
|
219
|
+
/** What one sweep did. Data-only. */
|
|
220
|
+
class OrphanSweepResult {
|
|
221
|
+
sweepId;
|
|
222
|
+
sweepDir;
|
|
223
|
+
moved;
|
|
224
|
+
failed;
|
|
225
|
+
/**
|
|
226
|
+
* Why the manifest could not be written, or null when it was. Non-null means the printed `recover=`
|
|
227
|
+
* lines are the ONLY copy — see writeManifest().
|
|
228
|
+
*/
|
|
229
|
+
manifestError;
|
|
230
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
231
|
+
constructor(sweepId, sweepDir, moved, failed, manifestError) {
|
|
232
|
+
this.sweepId = sweepId;
|
|
233
|
+
this.sweepDir = sweepDir;
|
|
234
|
+
this.moved = moved;
|
|
235
|
+
this.failed = failed;
|
|
236
|
+
this.manifestError = manifestError;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
exports.OrphanSweepResult = OrphanSweepResult;
|
|
240
|
+
/** The on-disk shape of `manifest.json`. Data-only. */
|
|
241
|
+
class OrphanSweepManifest {
|
|
242
|
+
sweepId;
|
|
243
|
+
repoRoot;
|
|
244
|
+
moved;
|
|
245
|
+
failed;
|
|
246
|
+
constructor(sweepId, repoRoot, moved, failed) {
|
|
247
|
+
this.sweepId = sweepId;
|
|
248
|
+
this.repoRoot = repoRoot;
|
|
249
|
+
this.moved = moved;
|
|
250
|
+
this.failed = failed;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
exports.OrphanSweepManifest = OrphanSweepManifest;
|
|
254
|
+
// `2026-08-19T14-32-05Z` — the exact shape sweepId() writes, and the only shape reapAged() will delete.
|
|
255
|
+
const SWEEP_ID_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z$/;
|
|
256
|
+
//# sourceMappingURL=orphan-dir-archive.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"orphan-dir-archive.js","sourceRoot":"","sources":["../../../../../packages/tooling/rules-config/src/orphan-dir-archive.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { OrphanCandidate } from './orphan-dir-scan';\nimport { dotWebpieces } from './state-dir';\nimport { toError } from './to-error';\n\n/** Where every sweep's archive lands, under the repo-wide `.webpieces/` — never under a worktree's own. */\nexport const TRASH_STATE_DIR = 'trash';\n/** The per-sweep manifest, holding what moved and the command that brings each one back. */\nexport const TRASH_MANIFEST_FILE = 'manifest.json';\n/** Sweeps older than this are reaped by the next sweep. Matches RETENTION_DAYS for the aged-tree sweep. */\nexport const TRASH_RETENTION_DAYS = 30;\n\nconst MILLIS_PER_DAY = 24 * 60 * 60 * 1000;\n\n/**\n * Moves orphan directories into `.webpieces/trash/<sweepId>/` and records how to bring each one back.\n *\n * ─── WHY `mv` AND NEVER `rm -rf` ──────────────────────────────────────────────────────────────────────\n * This runs unattended, on other people's machines, against a predicate that is very good but is still a\n * predicate. A `mv` makes the worst possible outcome — a false positive on a directory somebody actually\n * wanted — a thing they undo in one command, instead of a thing they restore from a backup or lose. That\n * single decision is what makes it defensible to run this automatically at all, and it is the same trade\n * `wp-cleanup` already makes when it archives a branch to a tag before deleting the ref.\n *\n * ─── WHY ONE DIRECTORY PER SWEEP, TIMESTAMP-NAMED ─────────────────────────────────────────────────────\n * `<sweepId>` is a UTC timestamp with `:` swapped for `-` (path-safe everywhere, Windows included). That\n * spelling sorts LEXICALLY in chronological order, so `ls` reads oldest-first and `ls -r` newest-first,\n * with no flags to remember and no dates to parse. Grouping by sweep also answers the only question\n * anybody actually asks of this directory — \"what did the run I just did take?\" — which a single flat\n * pile of moved directories cannot answer at all.\n *\n * Each sweep directory reproduces the repo-relative path of what it holds, so the manifest's `recover=`\n * is an ordinary `mv` of one path to another, with no bookkeeping needed to reconstruct the destination.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class OrphanDirArchiver {\n /**\n * Move every candidate into a fresh sweep directory. `now` is a parameter so specs pin the sweep id\n * instead of racing the clock.\n */\n archive(repoRoot: string, candidates: readonly OrphanCandidate[], now: Date): OrphanSweepResult {\n const sweepId = this.sweepId(now);\n const sweepDir = path.join(this.trashRoot(repoRoot), sweepId);\n const moved: ArchivedOrphan[] = [];\n const failed: FailedOrphan[] = [];\n for (const candidate of candidates) {\n const destination = path.join(sweepDir, candidate.relativePath);\n const failure = this.move(candidate.absolutePath, destination);\n if (failure !== null) {\n failed.push(new FailedOrphan(candidate.relativePath, failure));\n continue;\n }\n moved.push(new ArchivedOrphan(candidate.relativePath, destination,\n `mv '${destination}' '${candidate.absolutePath}'`));\n }\n const manifestError = this.writeManifest(sweepDir, sweepId, repoRoot, moved, failed);\n return new OrphanSweepResult(sweepId, sweepDir, moved, failed, manifestError);\n }\n\n /**\n * Delete sweep directories older than TRASH_RETENTION_DAYS, and report how many went. Called by the\n * sweeper AFTER a successful archive, so trash cannot grow without bound on a machine that has the\n * flag on. This one really does delete — it is deleting the archive, which is the second copy.\n */\n reapAged(repoRoot: string, now: Date): number {\n const root = this.trashRoot(repoRoot);\n const cutoff = now.getTime() - TRASH_RETENTION_DAYS * MILLIS_PER_DAY;\n let reaped = 0;\n for (const entry of this.listSweeps(root)) {\n const stamp = this.parseSweepId(entry);\n if (stamp === null || stamp.getTime() >= cutoff) continue;\n if (this.removeTree(path.join(root, entry))) reaped += 1;\n }\n return reaped;\n }\n\n /** `<repo>/.webpieces/trash` — the SHARED state dir, so a worktree's trash is not stranded with it. */\n trashRoot(repoRoot: string): string {\n return path.join(dotWebpieces.shared(repoRoot), TRASH_STATE_DIR);\n }\n\n /**\n * `2026-08-19T14-32-05Z`. ISO order with `:` replaced, and milliseconds dropped — a sweep is a\n * human-scale event and a second is resolution enough to name one.\n */\n sweepId(now: Date): string {\n return now.toISOString().replace(/\\.\\d{3}Z$/, 'Z').replace(/:/g, '-');\n }\n\n /** The Date a sweep id names, or null when the entry is not one of ours (a stray file, say). */\n private parseSweepId(entry: string): Date | null {\n if (!SWEEP_ID_PATTERN.test(entry)) return null;\n const iso = `${entry.slice(0, 10)}T${entry.slice(11, 19).replace(/-/g, ':')}Z`;\n const parsed = new Date(iso);\n return Number.isNaN(parsed.getTime()) ? null : parsed;\n }\n\n private listSweeps(root: string): string[] {\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: no trash directory yet is the normal\n // state on every machine that has never swept, and it is not a condition anybody needs told about\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return fs.readdirSync(root);\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return [];\n }\n }\n\n /**\n * `mv` the directory, creating the destination's parent chain first. Returns null on success, or the\n * failure's message — a directory we cannot move is REPORTED and skipped, never fatal.\n *\n * `fs.renameSync` first because it is atomic and instant within a filesystem; a cross-device rename\n * (EXDEV — a repo whose `.webpieces` sits on a different mount) falls back to a recursive copy plus\n * delete, which is what `mv` itself does in the same situation.\n */\n private move(from: string, to: string): string | null {\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: converts a per-directory move failure\n // into a reported line, because one unmovable directory may not abandon the rest of the sweep\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.mkdirSync(path.dirname(to), { recursive: true });\n fs.renameSync(from, to);\n return null;\n } catch (err: unknown) {\n const error = toError(err);\n return this.moveAcrossDevices(from, to, error);\n }\n }\n\n private moveAcrossDevices(from: string, to: string, cause: Error): string | null {\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: the fallback's own failure becomes the\n // reported message for this directory, the same contract move() has\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (!this.isCrossDevice(cause)) return cause.message;\n fs.cpSync(from, to, { recursive: true });\n fs.rmSync(from, { recursive: true, force: true });\n return null;\n } catch (err: unknown) {\n const error = toError(err);\n return error.message;\n }\n }\n\n private isCrossDevice(error: Error): boolean {\n // webpieces-disable no-any-unknown -- node attaches `code` to fs errors without typing it on Error\n const code = (error as unknown as Record<string, unknown>)['code'];\n return code === 'EXDEV';\n }\n\n private removeTree(target: string): boolean {\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: failing to reap OLD trash is cosmetic\n // and may never surface as an error on a command whose real work already succeeded\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.rmSync(target, { recursive: true, force: true });\n return true;\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return false;\n }\n }\n\n /**\n * The sweep's own record, beside what it took. Written even when every move failed, because \"this\n * sweep tried and could not\" is exactly the state somebody debugging needs to find on disk.\n *\n * Returns the failure message, or null on success. It is REPORTED rather than swallowed: the manifest\n * is the durable copy of every `recover=` command, so losing it silently is the one failure here that\n * costs somebody the ability to undo what just happened. Still never fatal — the directories are\n * already safely moved by this point, and the recover lines are still printed to the terminal.\n */\n private writeManifest(sweepDir: string, sweepId: string, repoRoot: string,\n moved: readonly ArchivedOrphan[], failed: readonly FailedOrphan[]): string | null {\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: the directories are already safely\n // moved by this point, and an unwritable manifest may not turn that success into a failure\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.mkdirSync(sweepDir, { recursive: true });\n fs.writeFileSync(path.join(sweepDir, TRASH_MANIFEST_FILE),\n `${JSON.stringify(new OrphanSweepManifest(sweepId, repoRoot, moved, failed), null, 2)}\\n`, 'utf8');\n return null;\n } catch (err: unknown) {\n const error = toError(err);\n return error.message;\n }\n }\n}\n\n/** One directory that was moved, and the exact command that undoes it. Data-only. */\nexport class ArchivedOrphan {\n /** Where it used to live, repo-relative. */\n relativePath: string;\n /** Where it lives now, absolute. */\n archivedAt: string;\n /** The `mv` that puts it back — printed to the human and stored in the manifest verbatim. */\n recoverCommand: string;\n\n constructor(relativePath: string, archivedAt: string, recoverCommand: string) {\n this.relativePath = relativePath;\n this.archivedAt = archivedAt;\n this.recoverCommand = recoverCommand;\n }\n}\n\n/** One directory the sweep found but could not move, with the reason. Data-only. */\nexport class FailedOrphan {\n relativePath: string;\n reason: string;\n\n constructor(relativePath: string, reason: string) {\n this.relativePath = relativePath;\n this.reason = reason;\n }\n}\n\n/** What one sweep did. Data-only. */\nexport class OrphanSweepResult {\n sweepId: string;\n sweepDir: string;\n moved: readonly ArchivedOrphan[];\n failed: readonly FailedOrphan[];\n /**\n * Why the manifest could not be written, or null when it was. Non-null means the printed `recover=`\n * lines are the ONLY copy — see writeManifest().\n */\n manifestError: string | null;\n\n // eslint-disable-next-line @typescript-eslint/max-params\n constructor(sweepId: string, sweepDir: string, moved: readonly ArchivedOrphan[],\n failed: readonly FailedOrphan[], manifestError: string | null) {\n this.sweepId = sweepId;\n this.sweepDir = sweepDir;\n this.moved = moved;\n this.failed = failed;\n this.manifestError = manifestError;\n }\n}\n\n/** The on-disk shape of `manifest.json`. Data-only. */\nexport class OrphanSweepManifest {\n sweepId: string;\n repoRoot: string;\n moved: readonly ArchivedOrphan[];\n failed: readonly FailedOrphan[];\n\n constructor(sweepId: string, repoRoot: string, moved: readonly ArchivedOrphan[],\n failed: readonly FailedOrphan[]) {\n this.sweepId = sweepId;\n this.repoRoot = repoRoot;\n this.moved = moved;\n this.failed = failed;\n }\n}\n\n// `2026-08-19T14-32-05Z` — the exact shape sweepId() writes, and the only shape reapAged() will delete.\nconst SWEEP_ID_PATTERN = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}Z$/;\n"]}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds ORPHAN DIRECTORIES — the package directories an `nx g move` leaves behind on every clone, which
|
|
3
|
+
* git cannot remove and which then accumulate on every developer's machine forever.
|
|
4
|
+
*
|
|
5
|
+
* ─── WHY GIT LEAVES THEM ──────────────────────────────────────────────────────────────────────────────
|
|
6
|
+
* Git tracks files, not directories. When a move deletes every tracked file under `libraries/apis/foo/`,
|
|
7
|
+
* git removes that directory ONLY if it is then truly empty — and it never is, because the build already
|
|
8
|
+
* put an IGNORED `dist/` and `node_modules/` inside it. So the corpse stands, on every clone, and there
|
|
9
|
+
* is no git setting that changes this (`core.pruneIgnoredDirs` does not exist).
|
|
10
|
+
*
|
|
11
|
+
* ─── WHY THE PREDICATE IS GIT'S OWN ANSWER AND NOT A HAND-ROLLED WALK ─────────────────────────────────
|
|
12
|
+
* `git clean -Xdn` collapses a directory to a SINGLE entry exactly when everything beneath it is ignored,
|
|
13
|
+
* and it does that using the repo's real ignore engine — nested `.gitignore` files, `!` negations and
|
|
14
|
+
* all. Re-implementing that is how a sweeper ends up disagreeing with git about what is disposable.
|
|
15
|
+
*
|
|
16
|
+
* It also hands us the safety property for free, which is the reason this can run unattended: a directory
|
|
17
|
+
* holding even ONE untracked-but-not-ignored file is NOT reported, so a developer's uncommitted new
|
|
18
|
+
* package can never be a candidate. We do not check that ourselves; git's own output already encodes it.
|
|
19
|
+
*
|
|
20
|
+
* ─── THE ONE DISCRIMINATOR THAT NEEDS NO NAME LIST ────────────────────────────────────────────────────
|
|
21
|
+
* `clean -Xdn` reports two very different things the same way:
|
|
22
|
+
*
|
|
23
|
+
* libraries/apis/pg-dataaccess-api/ ← a corpse: nothing tracked survives under it
|
|
24
|
+
* libraries/apis/live-api/dist/ ← a LIVE project's build output, there on purpose
|
|
25
|
+
*
|
|
26
|
+
* They are separated by asking whether the directory ITSELF matches an ignore rule. `dist/` does
|
|
27
|
+
* (`.gitignore:19:dist/`); `pg-dataaccess-api` does not, which is precisely the statement that this
|
|
28
|
+
* directory was MEANT to hold tracked files — and none are left. No list of artifact directory names is
|
|
29
|
+
* needed, and none is maintained, so the rule adapts to whatever any given consumer repo ignores.
|
|
30
|
+
*
|
|
31
|
+
* That also yields the opt-out, and it is one a developer would reach for anyway: adding `my-sandbox/`
|
|
32
|
+
* to `.gitignore` makes the directory ignore-matched, which permanently protects it from this sweep.
|
|
33
|
+
*/
|
|
34
|
+
export declare class OrphanDirScanner {
|
|
35
|
+
/**
|
|
36
|
+
* Every orphan directory under `repoRoot`, as repo-RELATIVE paths with no trailing slash. Empty when
|
|
37
|
+
* the tree is clean, when `repoRoot` is not a git repository, or when git cannot be run at all — a
|
|
38
|
+
* sweeper is never important enough to fail somebody's checkout over.
|
|
39
|
+
*/
|
|
40
|
+
scan(repoRoot: string): OrphanCandidate[];
|
|
41
|
+
/**
|
|
42
|
+
* The DIRECTORY paths `git clean -Xdn` would remove, relative to `repoRoot` and slash-stripped.
|
|
43
|
+
*
|
|
44
|
+
* Only entries ending in `/` are directories; a bare path is a single ignored FILE (`.env`,
|
|
45
|
+
* `.claude/settings.local.json`) and is none of this sweep's business. `Would skip repository` lines
|
|
46
|
+
* are git telling us it found a nested repo — a linked worktree — which it refuses to descend into,
|
|
47
|
+
* and so do we.
|
|
48
|
+
*/
|
|
49
|
+
private reportedByGitClean;
|
|
50
|
+
/**
|
|
51
|
+
* True when git matches an ignore rule against the DIRECTORY ITSELF — i.e. it is a build artifact
|
|
52
|
+
* somebody declared disposable-but-expected, not a corpse. `check-ignore -q` exits 0 on a match and 1
|
|
53
|
+
* on no match, so a null answer here (any non-zero exit, including a git we could not run) means
|
|
54
|
+
* "cannot confirm", and the directory is SPARED. Every uncertainty in this class resolves that way.
|
|
55
|
+
*/
|
|
56
|
+
private isIgnoredItself;
|
|
57
|
+
/**
|
|
58
|
+
* The two structural exclusions, both about where a moved nx PROJECT can possibly live.
|
|
59
|
+
*
|
|
60
|
+
* DOT SEGMENTS: `.nx/`, `.idea/`, `.webpieces/` and friends are reported whole by `clean -Xdn` (their
|
|
61
|
+
* contents are ignored, the directories themselves are not) and would otherwise pass the discriminator
|
|
62
|
+
* above perfectly. They are tool state, they are supposed to be there, and no nx project is named with
|
|
63
|
+
* a leading dot. Excluding the whole class by shape beats naming today's four and meeting a fifth.
|
|
64
|
+
*
|
|
65
|
+
* DEPTH: a moved project lives at `libraries/x`, `apps/x`, `packages/y/z` — never at the top level.
|
|
66
|
+
* Requiring depth >= 2 costs nothing real and takes every top-level directory in the repo permanently
|
|
67
|
+
* out of reach of an automated `mv`.
|
|
68
|
+
*/
|
|
69
|
+
private isSweepable;
|
|
70
|
+
/**
|
|
71
|
+
* `git <args>` in `repoRoot`, or null when git exits non-zero or could not be run at all.
|
|
72
|
+
*
|
|
73
|
+
* spawnSync rather than execFileSync because a non-zero exit is ORDINARY here — `check-ignore` says
|
|
74
|
+
* "not ignored" that way — and a status code is a better answer to that question than an exception.
|
|
75
|
+
*
|
|
76
|
+
* An ARGUMENT ARRAY, never a shell string: these paths come from a filesystem scan and may hold
|
|
77
|
+
* spaces, quotes or `$`, and interpolating them into a shell would be building a command injection
|
|
78
|
+
* into the one tool whose entire job is moving directories around.
|
|
79
|
+
*/
|
|
80
|
+
private git;
|
|
81
|
+
}
|
|
82
|
+
/** One directory the sweep may archive. Data-only (per CLAUDE.md — classes, not interfaces, for data). */
|
|
83
|
+
export declare class OrphanCandidate {
|
|
84
|
+
/** Repo-relative, no trailing slash — e.g. `libraries/apis/pg-dataaccess-api`. */
|
|
85
|
+
relativePath: string;
|
|
86
|
+
/** The same directory as an absolute path, which is what any `mv` needs. */
|
|
87
|
+
absolutePath: string;
|
|
88
|
+
constructor(relativePath: string, absolutePath: string);
|
|
89
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OrphanCandidate = exports.OrphanDirScanner = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const path = tslib_1.__importStar(require("path"));
|
|
6
|
+
const child_process_1 = require("child_process");
|
|
7
|
+
const inversify_1 = require("inversify");
|
|
8
|
+
/**
|
|
9
|
+
* Finds ORPHAN DIRECTORIES — the package directories an `nx g move` leaves behind on every clone, which
|
|
10
|
+
* git cannot remove and which then accumulate on every developer's machine forever.
|
|
11
|
+
*
|
|
12
|
+
* ─── WHY GIT LEAVES THEM ──────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
* Git tracks files, not directories. When a move deletes every tracked file under `libraries/apis/foo/`,
|
|
14
|
+
* git removes that directory ONLY if it is then truly empty — and it never is, because the build already
|
|
15
|
+
* put an IGNORED `dist/` and `node_modules/` inside it. So the corpse stands, on every clone, and there
|
|
16
|
+
* is no git setting that changes this (`core.pruneIgnoredDirs` does not exist).
|
|
17
|
+
*
|
|
18
|
+
* ─── WHY THE PREDICATE IS GIT'S OWN ANSWER AND NOT A HAND-ROLLED WALK ─────────────────────────────────
|
|
19
|
+
* `git clean -Xdn` collapses a directory to a SINGLE entry exactly when everything beneath it is ignored,
|
|
20
|
+
* and it does that using the repo's real ignore engine — nested `.gitignore` files, `!` negations and
|
|
21
|
+
* all. Re-implementing that is how a sweeper ends up disagreeing with git about what is disposable.
|
|
22
|
+
*
|
|
23
|
+
* It also hands us the safety property for free, which is the reason this can run unattended: a directory
|
|
24
|
+
* holding even ONE untracked-but-not-ignored file is NOT reported, so a developer's uncommitted new
|
|
25
|
+
* package can never be a candidate. We do not check that ourselves; git's own output already encodes it.
|
|
26
|
+
*
|
|
27
|
+
* ─── THE ONE DISCRIMINATOR THAT NEEDS NO NAME LIST ────────────────────────────────────────────────────
|
|
28
|
+
* `clean -Xdn` reports two very different things the same way:
|
|
29
|
+
*
|
|
30
|
+
* libraries/apis/pg-dataaccess-api/ ← a corpse: nothing tracked survives under it
|
|
31
|
+
* libraries/apis/live-api/dist/ ← a LIVE project's build output, there on purpose
|
|
32
|
+
*
|
|
33
|
+
* They are separated by asking whether the directory ITSELF matches an ignore rule. `dist/` does
|
|
34
|
+
* (`.gitignore:19:dist/`); `pg-dataaccess-api` does not, which is precisely the statement that this
|
|
35
|
+
* directory was MEANT to hold tracked files — and none are left. No list of artifact directory names is
|
|
36
|
+
* needed, and none is maintained, so the rule adapts to whatever any given consumer repo ignores.
|
|
37
|
+
*
|
|
38
|
+
* That also yields the opt-out, and it is one a developer would reach for anyway: adding `my-sandbox/`
|
|
39
|
+
* to `.gitignore` makes the directory ignore-matched, which permanently protects it from this sweep.
|
|
40
|
+
*/
|
|
41
|
+
let OrphanDirScanner = class OrphanDirScanner {
|
|
42
|
+
/**
|
|
43
|
+
* Every orphan directory under `repoRoot`, as repo-RELATIVE paths with no trailing slash. Empty when
|
|
44
|
+
* the tree is clean, when `repoRoot` is not a git repository, or when git cannot be run at all — a
|
|
45
|
+
* sweeper is never important enough to fail somebody's checkout over.
|
|
46
|
+
*/
|
|
47
|
+
scan(repoRoot) {
|
|
48
|
+
const reported = this.reportedByGitClean(repoRoot);
|
|
49
|
+
const candidates = [];
|
|
50
|
+
for (const relative of reported) {
|
|
51
|
+
if (!this.isSweepable(relative))
|
|
52
|
+
continue;
|
|
53
|
+
if (this.isIgnoredItself(repoRoot, relative))
|
|
54
|
+
continue;
|
|
55
|
+
candidates.push(new OrphanCandidate(relative, path.join(repoRoot, relative)));
|
|
56
|
+
}
|
|
57
|
+
return candidates.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The DIRECTORY paths `git clean -Xdn` would remove, relative to `repoRoot` and slash-stripped.
|
|
61
|
+
*
|
|
62
|
+
* Only entries ending in `/` are directories; a bare path is a single ignored FILE (`.env`,
|
|
63
|
+
* `.claude/settings.local.json`) and is none of this sweep's business. `Would skip repository` lines
|
|
64
|
+
* are git telling us it found a nested repo — a linked worktree — which it refuses to descend into,
|
|
65
|
+
* and so do we.
|
|
66
|
+
*/
|
|
67
|
+
reportedByGitClean(repoRoot) {
|
|
68
|
+
const raw = this.git(repoRoot, ['clean', '-Xdn']);
|
|
69
|
+
if (raw === null)
|
|
70
|
+
return [];
|
|
71
|
+
const paths = [];
|
|
72
|
+
for (const line of raw.split('\n')) {
|
|
73
|
+
const trimmed = line.trim();
|
|
74
|
+
if (!trimmed.startsWith(WOULD_REMOVE))
|
|
75
|
+
continue;
|
|
76
|
+
const target = trimmed.slice(WOULD_REMOVE.length).trim();
|
|
77
|
+
if (!target.endsWith('/'))
|
|
78
|
+
continue;
|
|
79
|
+
paths.push(target.slice(0, -1));
|
|
80
|
+
}
|
|
81
|
+
return paths;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* True when git matches an ignore rule against the DIRECTORY ITSELF — i.e. it is a build artifact
|
|
85
|
+
* somebody declared disposable-but-expected, not a corpse. `check-ignore -q` exits 0 on a match and 1
|
|
86
|
+
* on no match, so a null answer here (any non-zero exit, including a git we could not run) means
|
|
87
|
+
* "cannot confirm", and the directory is SPARED. Every uncertainty in this class resolves that way.
|
|
88
|
+
*/
|
|
89
|
+
isIgnoredItself(repoRoot, relative) {
|
|
90
|
+
return this.git(repoRoot, ['check-ignore', '-q', '--', relative]) !== null;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The two structural exclusions, both about where a moved nx PROJECT can possibly live.
|
|
94
|
+
*
|
|
95
|
+
* DOT SEGMENTS: `.nx/`, `.idea/`, `.webpieces/` and friends are reported whole by `clean -Xdn` (their
|
|
96
|
+
* contents are ignored, the directories themselves are not) and would otherwise pass the discriminator
|
|
97
|
+
* above perfectly. They are tool state, they are supposed to be there, and no nx project is named with
|
|
98
|
+
* a leading dot. Excluding the whole class by shape beats naming today's four and meeting a fifth.
|
|
99
|
+
*
|
|
100
|
+
* DEPTH: a moved project lives at `libraries/x`, `apps/x`, `packages/y/z` — never at the top level.
|
|
101
|
+
* Requiring depth >= 2 costs nothing real and takes every top-level directory in the repo permanently
|
|
102
|
+
* out of reach of an automated `mv`.
|
|
103
|
+
*/
|
|
104
|
+
isSweepable(relative) {
|
|
105
|
+
const segments = relative.split('/');
|
|
106
|
+
if (segments.length < MIN_DEPTH)
|
|
107
|
+
return false;
|
|
108
|
+
return !segments.some((segment) => segment.startsWith('.'));
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* `git <args>` in `repoRoot`, or null when git exits non-zero or could not be run at all.
|
|
112
|
+
*
|
|
113
|
+
* spawnSync rather than execFileSync because a non-zero exit is ORDINARY here — `check-ignore` says
|
|
114
|
+
* "not ignored" that way — and a status code is a better answer to that question than an exception.
|
|
115
|
+
*
|
|
116
|
+
* An ARGUMENT ARRAY, never a shell string: these paths come from a filesystem scan and may hold
|
|
117
|
+
* spaces, quotes or `$`, and interpolating them into a shell would be building a command injection
|
|
118
|
+
* into the one tool whose entire job is moving directories around.
|
|
119
|
+
*/
|
|
120
|
+
git(repoRoot, args) {
|
|
121
|
+
const result = (0, child_process_1.spawnSync)('git', ['-C', repoRoot, ...args], {
|
|
122
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: GIT_OUTPUT_LIMIT,
|
|
123
|
+
});
|
|
124
|
+
if (result.error !== undefined || result.status !== 0)
|
|
125
|
+
return null;
|
|
126
|
+
return result.stdout;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
exports.OrphanDirScanner = OrphanDirScanner;
|
|
130
|
+
exports.OrphanDirScanner = OrphanDirScanner = tslib_1.__decorate([
|
|
131
|
+
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
|
|
132
|
+
], OrphanDirScanner);
|
|
133
|
+
/** One directory the sweep may archive. Data-only (per CLAUDE.md — classes, not interfaces, for data). */
|
|
134
|
+
class OrphanCandidate {
|
|
135
|
+
/** Repo-relative, no trailing slash — e.g. `libraries/apis/pg-dataaccess-api`. */
|
|
136
|
+
relativePath;
|
|
137
|
+
/** The same directory as an absolute path, which is what any `mv` needs. */
|
|
138
|
+
absolutePath;
|
|
139
|
+
constructor(relativePath, absolutePath) {
|
|
140
|
+
this.relativePath = relativePath;
|
|
141
|
+
this.absolutePath = absolutePath;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
exports.OrphanCandidate = OrphanCandidate;
|
|
145
|
+
const WOULD_REMOVE = 'Would remove ';
|
|
146
|
+
// `libraries/foo` is depth 2. Anything shallower is a top-level directory — see isSweepable().
|
|
147
|
+
const MIN_DEPTH = 2;
|
|
148
|
+
// `clean -Xdn` on a large monorepo prints a few hundred lines; 16MB is a ceiling no real repo approaches.
|
|
149
|
+
const GIT_OUTPUT_LIMIT = 16 * 1024 * 1024;
|
|
150
|
+
//# sourceMappingURL=orphan-dir-scan.js.map
|