@teambit/isolator 1.0.1002 → 1.0.1003

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.
@@ -0,0 +1,733 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.CapsuleCache = exports.CAPSULE_TRASH_DIR = exports.CAPSULE_ORIGIN_FILE = void 0;
7
+ function _fsExtra() {
8
+ const data = _interopRequireDefault(require("fs-extra"));
9
+ _fsExtra = function () {
10
+ return data;
11
+ };
12
+ return data;
13
+ }
14
+ function _uuid() {
15
+ const data = require("uuid");
16
+ _uuid = function () {
17
+ return data;
18
+ };
19
+ return data;
20
+ }
21
+ function _path() {
22
+ const data = _interopRequireDefault(require("path"));
23
+ _path = function () {
24
+ return data;
25
+ };
26
+ return data;
27
+ }
28
+ function _child_process() {
29
+ const data = require("child_process");
30
+ _child_process = function () {
31
+ return data;
32
+ };
33
+ return data;
34
+ }
35
+ function _pMap() {
36
+ const data = _interopRequireDefault(require("p-map"));
37
+ _pMap = function () {
38
+ return data;
39
+ };
40
+ return data;
41
+ }
42
+ function _harmonyModules() {
43
+ const data = require("@teambit/harmony.modules.concurrency");
44
+ _harmonyModules = function () {
45
+ return data;
46
+ };
47
+ return data;
48
+ }
49
+ function _harmonyModules2() {
50
+ const data = require("@teambit/harmony.modules.feature-toggle");
51
+ _harmonyModules2 = function () {
52
+ return data;
53
+ };
54
+ return data;
55
+ }
56
+ function _legacy() {
57
+ const data = require("@teambit/legacy.constants");
58
+ _legacy = function () {
59
+ return data;
60
+ };
61
+ return data;
62
+ }
63
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
64
+ /**
65
+ * Manages the global capsules cache: origin markers, fast-delete + trash sweep, and the
66
+ * prune pipeline (manual `bit capsule prune` and the gated auto-prune trigger).
67
+ *
68
+ * Owned by `IsolatorMain` but operationally independent — the isolator constructs an
69
+ * instance, registers its hooks, and delegates the externally-visible cache-management
70
+ * methods (`deleteCapsules`, `pruneCapsules`, `listAllCapsuleRoots`) to it. Keeps
71
+ * `isolator.main.runtime.ts` focused on creating isolated component environments rather
72
+ * than also being responsible for evicting them.
73
+ */
74
+
75
+ /**
76
+ * Marker file written into every capsule dir we manage. Its presence tells the prune logic
77
+ * what kind of dir this is, where it came from, and (via its mtime) when it was last used.
78
+ */
79
+ const CAPSULE_ORIGIN_FILE = exports.CAPSULE_ORIGIN_FILE = '.bit-capsule-origin.json';
80
+ const CAPSULE_TRASH_DIR = exports.CAPSULE_TRASH_DIR = '.trash';
81
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
82
+ const VALID_CAPSULE_KINDS = new Set(['workspace', 'scope-aspects-root', 'scope-aspect', 'scope']);
83
+ function toFiniteNumber(value) {
84
+ if (value === undefined || value === null || value === '') return undefined;
85
+ const n = Number(value);
86
+ return Number.isFinite(n) ? n : undefined;
87
+ }
88
+ class CapsuleCache {
89
+ constructor(logger, cli, configStore, /** Thunk so the cache stays independent of `GlobalConfigMain` — IsolatorMain forwards it. */
90
+ getRootDir) {
91
+ this.logger = logger;
92
+ this.cli = cli;
93
+ this.configStore = configStore;
94
+ this.getRootDir = getRootDir;
95
+ }
96
+ async deleteCapsules(rootDir) {
97
+ const dirToDelete = rootDir || this.getRootDir();
98
+ const marker = await this.readOriginMarker(dirToDelete);
99
+ this.logger.debug(`[capsule-delete] removing ${dirToDelete}` + (marker?.originPath ? ` origin=${marker.originPath}` : ''));
100
+ await this.scheduleFastDelete(dirToDelete);
101
+ return dirToDelete;
102
+ }
103
+
104
+ /**
105
+ * Move a capsule dir into a sibling `.trash/<uuid>/` so it disappears from the cache
106
+ * immediately (same-filesystem rename is O(1)), then kick off a detached `rm -rf` so the
107
+ * actual byte-by-byte cleanup happens in the background. This avoids the multi-second
108
+ * stalls users see when deleting capsules with thousands of files.
109
+ */
110
+ async scheduleFastDelete(dir) {
111
+ const exists = await _fsExtra().default.pathExists(dir);
112
+ if (!exists) return;
113
+ const globalRoot = this.getRootDir();
114
+ // Edge case: deleting the global root itself. We can't move a dir into its own
115
+ // child (`.trash/...`), so just do a direct remove. This is rare — only `bit
116
+ // capsule delete --all` hits it.
117
+ if (_path().default.resolve(dir) === _path().default.resolve(globalRoot)) {
118
+ await _fsExtra().default.remove(dir);
119
+ return;
120
+ }
121
+ const trashRoot = _path().default.join(globalRoot, CAPSULE_TRASH_DIR);
122
+ await _fsExtra().default.ensureDir(trashRoot);
123
+ const trashTarget = _path().default.join(trashRoot, `${_path().default.basename(dir)}-${(0, _uuid().v4)()}`);
124
+ try {
125
+ await _fsExtra().default.move(dir, trashTarget, {
126
+ overwrite: true
127
+ });
128
+ } catch (err) {
129
+ // Likely cross-device — fall back to a synchronous remove.
130
+ this.logger.debug(`scheduleFastDelete: rename failed for ${dir}, falling back to fs.remove (${err.message})`);
131
+ await _fsExtra().default.remove(dir);
132
+ return;
133
+ }
134
+ // Run through the gated sweepTrashAsync path so we never have more than one sweep
135
+ // running concurrently — even if many bit processes are moving things to trash.
136
+ this.sweepTrashAsync();
137
+ }
138
+
139
+ /**
140
+ * Sweep the `.trash` dir in a detached background process. Gated by a PID-stamped
141
+ * lock so we never have more than one sweep running at a time across all concurrent
142
+ * bit processes — previously we spawned one per `bit` invocation and they piled up
143
+ * into the thousands, saturating disk I/O.
144
+ */
145
+ sweepTrashAsync() {
146
+ const trashRoot = _path().default.join(this.getRootDir(), CAPSULE_TRASH_DIR);
147
+ // No trash → nothing to do. Cheap synchronous check avoids spawning a process at all.
148
+ if (!_fsExtra().default.existsSync(trashRoot)) return;
149
+ const lockPath = _path().default.join(this.getRootDir(), '.trash-sweep.lock');
150
+ if (this.isSweepLockActive(lockPath)) {
151
+ this.logger.debug(`trash sweep already running (per ${lockPath}), skipping`);
152
+ return;
153
+ }
154
+ try {
155
+ _fsExtra().default.writeFileSync(lockPath, String(process.pid), {
156
+ flag: 'w'
157
+ });
158
+ } catch (err) {
159
+ this.logger.debug(`failed to write sweep lock at ${lockPath}: ${err.message}`);
160
+ return;
161
+ }
162
+ this.spawnDetachedSweep(trashRoot, lockPath);
163
+ }
164
+
165
+ /**
166
+ * A sweep lock is "active" if the PID it names is still running. If the PID file
167
+ * exists but the process is gone (e.g. crashed mid-sweep), we treat it as stale and
168
+ * allow a new sweep to claim it.
169
+ */
170
+ isSweepLockActive(lockPath) {
171
+ let pidStr;
172
+ try {
173
+ pidStr = _fsExtra().default.readFileSync(lockPath, 'utf8').trim();
174
+ } catch {
175
+ return false;
176
+ }
177
+ const pid = Number(pidStr);
178
+ if (!Number.isFinite(pid) || pid <= 0) return false;
179
+ try {
180
+ // Signal 0 = "is this PID alive?" — no actual signal sent.
181
+ process.kill(pid, 0);
182
+ return true;
183
+ } catch {
184
+ return false;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Register a process-exit hook that, at most once per ~24h, spawns a detached
190
+ * `bit capsule prune` child so the actual work runs out-of-process and never delays
191
+ * the parent's exit. Gated by the mtime of a stamp file under the capsules root so
192
+ * concurrent Bit invocations can't all trigger it at once, and behind the
193
+ * `capsule-auto-prune` feature flag while the behavior is being validated.
194
+ */
195
+ registerAutoPruneHook() {
196
+ this.cli.registerOnBeforeExit(async () => {
197
+ try {
198
+ await this.maybeAutoPrune();
199
+ } catch (err) {
200
+ this.logger.debug(`auto-prune skipped due to error: ${err?.message ?? err}`);
201
+ }
202
+ });
203
+ }
204
+ async maybeAutoPrune() {
205
+ // Experimental: the automatic prune only runs for users who opt in via the feature flag
206
+ // (`BIT_FEATURES=capsule-auto-prune` or `bit config set features=capsule-auto-prune`).
207
+ // Until it's promoted to GA, the default behavior is unchanged — capsules are never
208
+ // auto-deleted. The manual `bit capsule prune` command is always available regardless.
209
+ if (!(0, _harmonyModules2().isFeatureEnabled)(_harmonyModules2().CAPSULE_AUTO_PRUNE)) return;
210
+
211
+ // configStore may surface this as either string `'false'` (from `bit config set`)
212
+ // or boolean `false` (from a hand-edited JSON config) — accept both. This is a
213
+ // secondary escape hatch for once the feature is GA and the flag is removed.
214
+ const enabled = this.configStore.getConfig(_legacy().CFG_CAPSULES_AUTO_PRUNE);
215
+ if (enabled === 'false' || enabled === false) return;
216
+ const root = this.getRootDir();
217
+ if (!(await _fsExtra().default.pathExists(root))) return;
218
+ const stampPath = _path().default.join(root, '.last-capsule-prune');
219
+ const isStampFresh = async () => {
220
+ try {
221
+ const stat = await _fsExtra().default.stat(stampPath);
222
+ return Date.now() - stat.mtime.getTime() < ONE_DAY_MS;
223
+ } catch {
224
+ return false; // missing — needs a prune
225
+ }
226
+ };
227
+ // Fast path: stamp is recent, nothing to do (no contention here).
228
+ if (await isStampFresh()) return;
229
+
230
+ // Atomic claim: only one process across all concurrent bit invocations may win the
231
+ // daily slot. `wx` is O_CREAT|O_EXCL — it throws if the lock already exists, so the
232
+ // check-and-write below can't race. The lock is held only for the few ms it takes to
233
+ // re-check the stamp and spawn the detached child, then removed in `finally`.
234
+ const claimPath = `${stampPath}.claim`;
235
+ let claimed = false;
236
+ try {
237
+ await _fsExtra().default.close(await _fsExtra().default.open(claimPath, 'wx'));
238
+ claimed = true;
239
+ } catch {
240
+ // Another process is mid-claim, or a previous run leaked the lock. If it's stale
241
+ // (older than the daily window), reclaim it by removing + re-opening with O_EXCL —
242
+ // if two processes race the reclaim, only one's `wx` open succeeds and the other
243
+ // yields. Otherwise yield.
244
+ try {
245
+ const claimStat = await _fsExtra().default.stat(claimPath);
246
+ if (Date.now() - claimStat.mtime.getTime() < ONE_DAY_MS) return;
247
+ await _fsExtra().default.remove(claimPath);
248
+ await _fsExtra().default.close(await _fsExtra().default.open(claimPath, 'wx'));
249
+ claimed = true;
250
+ } catch {
251
+ return;
252
+ }
253
+ }
254
+ try {
255
+ // Re-check under the lock: a process that just held it may have refreshed the stamp.
256
+ if (await isStampFresh()) return;
257
+ await _fsExtra().default.outputFile(stampPath, '');
258
+
259
+ // Guard against non-numeric/empty config (NaN) and negative values (which would
260
+ // invert the age cutoff / size target and wipe the whole cache).
261
+ const olderThanDays = Math.max(0, toFiniteNumber(this.configStore.getConfig(_legacy().CFG_CAPSULES_MAX_AGE_DAYS)) ?? 30);
262
+ const sizeTargetGb = Math.max(0, toFiniteNumber(this.configStore.getConfig(_legacy().CFG_CAPSULES_MAX_SIZE_GB)) ?? 10);
263
+ this.logger.debug(`[auto-prune] spawning detached child. olderThanDays=${olderThanDays}, sizeTargetGb=${sizeTargetGb}`);
264
+ this.spawnDetachedAutoPrune(olderThanDays, sizeTargetGb);
265
+ } finally {
266
+ if (claimed) {
267
+ try {
268
+ await _fsExtra().default.remove(claimPath);
269
+ } catch {
270
+ // ignore — a stale claim lock is reclaimed by the age check above
271
+ }
272
+ }
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Fire-and-forget: spawn a detached child running `bit capsule prune`. Using the same
278
+ * bit binary that's currently running (via process.argv[0] + argv[1]) so we don't depend
279
+ * on PATH. stdio is ignored so nothing leaks to the user's terminal.
280
+ *
281
+ * Recursion guard: the child also runs onBeforeExit → maybeAutoPrune, but it reads the
282
+ * stamp file that we just wrote and bails out before re-spawning.
283
+ */
284
+ spawnDetachedAutoPrune(olderThanDays, sizeTargetGb) {
285
+ const bitEntry = process.argv[1];
286
+ if (!bitEntry) {
287
+ this.logger.debug('[auto-prune] cannot detach: process.argv[1] is empty');
288
+ return;
289
+ }
290
+ try {
291
+ const child = (0, _child_process().spawn)(process.execPath, [bitEntry, 'capsule', 'prune', '--older-than', String(olderThanDays), '--size-target', String(sizeTargetGb)], {
292
+ detached: true,
293
+ stdio: 'ignore',
294
+ windowsHide: true
295
+ });
296
+ child.unref();
297
+ } catch (err) {
298
+ this.logger.debug(`[auto-prune] failed to spawn detached child: ${err.message}`);
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Spawn one detached Node process that recursively removes `trashRoot`. Using
304
+ * `process.execPath` with an inline `fs.rmSync` keeps this portable across macOS,
305
+ * Linux, and Windows (where there's no `rm` binary). When `lockPath` is given, the
306
+ * child clears the lock on exit so the next bit invocation can claim a fresh sweep slot.
307
+ */
308
+ spawnDetachedSweep(trashRoot, lockPath) {
309
+ const script = lockPath ? `try { require('fs').rmSync(${JSON.stringify(trashRoot)}, { recursive: true, force: true }); } finally { try { require('fs').rmSync(${JSON.stringify(lockPath)}, { force: true }); } catch (_) {} }` : `require('fs').rmSync(${JSON.stringify(trashRoot)}, { recursive: true, force: true })`;
310
+ try {
311
+ const child = (0, _child_process().spawn)(process.execPath, ['-e', script], {
312
+ detached: true,
313
+ stdio: 'ignore',
314
+ windowsHide: true
315
+ });
316
+ child.unref();
317
+ } catch (err) {
318
+ this.logger.debug(`failed to spawn detached trash sweep: ${err.message}`);
319
+ // Don't leak the lock if the spawn itself failed. Use fs-extra's removeSync
320
+ // (rmSync isn't in the @types/fs-extra version pinned by this component).
321
+ if (lockPath) {
322
+ try {
323
+ _fsExtra().default.removeSync(lockPath);
324
+ } catch {
325
+ // ignore
326
+ }
327
+ }
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Write the origin marker if missing; otherwise just bump its mtime so it reflects
333
+ * "last used at". Failures are non-fatal — markers are best-effort metadata.
334
+ */
335
+ async ensureOriginMarker(dir, kind, originPath) {
336
+ const markerPath = _path().default.join(dir, CAPSULE_ORIGIN_FILE);
337
+ try {
338
+ if (await _fsExtra().default.pathExists(markerPath)) {
339
+ const now = new Date();
340
+ await _fsExtra().default.utimes(markerPath, now, now);
341
+ return;
342
+ }
343
+ const marker = {
344
+ originPath,
345
+ createdAt: new Date().toISOString(),
346
+ kind
347
+ };
348
+ await _fsExtra().default.outputJson(markerPath, marker);
349
+ } catch (err) {
350
+ this.logger.debug(`failed to write capsule origin marker at ${markerPath}: ${err.message}`);
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Mark all per-component capsule subdirs as scope-aspect kind, originated from the
356
+ * scope-aspects root. Used right after a scope-aspects isolation.
357
+ */
358
+ async ensureAspectCapsuleMarkers(capsuleList, rootOriginPath) {
359
+ await Promise.all(capsuleList.map(async capsule => {
360
+ if (!_fsExtra().default.existsSync(capsule.path)) return;
361
+ await this.ensureOriginMarker(capsule.path, 'scope-aspect', rootOriginPath);
362
+ }));
363
+ }
364
+
365
+ /**
366
+ * Single source of truth for the dated-capsules date-dir name (`YYYY-M-D`, no zero-pad).
367
+ * Used by `getCapsulesRootDir` when writing and `pruneDatedCapsulesChildren` when reading,
368
+ * so the two can never drift.
369
+ */
370
+ getDatedCapsuleDirName(date = new Date()) {
371
+ return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
372
+ }
373
+
374
+ /**
375
+ * Standard filter for "real" capsule subdirs we may walk or prune. Skips files, the trash
376
+ * dir (dot-prefixed), `node_modules`, and any other hidden/internal dir.
377
+ */
378
+ isPrunableSubdir(entry) {
379
+ return entry.isDirectory() && entry.name !== 'node_modules' && !entry.name.startsWith('.');
380
+ }
381
+
382
+ /**
383
+ * Combined marker read + last-used resolution for a capsule dir: one `readJson`, one
384
+ * fallback `stat`. Replaces the three-syscall (`readMarker` + `getOriginMarkerMtime` +
385
+ * dir `stat`) idiom that was copy-pasted across the prune walks.
386
+ */
387
+ async readMarkerInfo(dir) {
388
+ const marker = await this.readOriginMarker(dir);
389
+ if (marker) {
390
+ const mtime = await this.getOriginMarkerMtime(dir);
391
+ if (mtime) return {
392
+ marker,
393
+ lastUsedMs: mtime.getTime()
394
+ };
395
+ }
396
+ const stat = await _fsExtra().default.stat(dir).catch(() => undefined);
397
+ return {
398
+ marker,
399
+ lastUsedMs: (stat?.mtime ?? new Date(0)).getTime()
400
+ };
401
+ }
402
+ async readOriginMarker(dir) {
403
+ try {
404
+ const raw = await _fsExtra().default.readJson(_path().default.join(dir, CAPSULE_ORIGIN_FILE));
405
+ if (raw && typeof raw.originPath === 'string' &&
406
+ // Reject unknown kinds (corrupted markers or values from a future Bit) so they
407
+ // fall through to the 'unmarked' path in pruneCapsules rather than silently
408
+ // skipping deletion.
409
+ VALID_CAPSULE_KINDS.has(raw.kind)) {
410
+ return raw;
411
+ }
412
+ } catch {
413
+ // missing or malformed — treat as unmarked
414
+ }
415
+ return undefined;
416
+ }
417
+ async getOriginMarkerMtime(dir) {
418
+ try {
419
+ const stat = await _fsExtra().default.stat(_path().default.join(dir, CAPSULE_ORIGIN_FILE));
420
+ return stat.mtime;
421
+ } catch {
422
+ return undefined;
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Walk the global capsules root and return entries with their classification, size, and
428
+ * last-used time. Used by prune and by `bit capsule list`.
429
+ */
430
+ async listAllCapsuleRoots(opts = {}) {
431
+ const withSizes = opts.withSizes !== false;
432
+ const root = this.getRootDir();
433
+ if (!(await _fsExtra().default.pathExists(root))) return [];
434
+ const entries = await _fsExtra().default.readdir(root, {
435
+ withFileTypes: true
436
+ });
437
+ const subdirs = entries.filter(e => this.isPrunableSubdir(e));
438
+ // Bounded concurrency: on a multi-GB cache with hundreds of subdirs and tens of
439
+ // thousands of files per subdir, an unbounded Promise.all of recursive size walks
440
+ // can hit OS file-descriptor limits (EMFILE) and thrash disk.
441
+ return (0, _pMap().default)(subdirs, async entry => {
442
+ const subPath = _path().default.join(root, entry.name);
443
+ const {
444
+ marker,
445
+ lastUsedMs
446
+ } = await this.readMarkerInfo(subPath);
447
+ const sizeBytes = withSizes ? await this.computeDirSize(subPath) : 0;
448
+ return {
449
+ path: subPath,
450
+ kind: marker?.kind ?? 'unmarked',
451
+ originPath: marker?.originPath,
452
+ lastUsedMs,
453
+ sizeBytes
454
+ };
455
+ }, {
456
+ concurrency: (0, _harmonyModules().concurrentIOLimit)()
457
+ });
458
+ }
459
+
460
+ /**
461
+ * Sum sizes of all entries under `dir`. Tolerant of symlinks and permission errors —
462
+ * any failure returns the partial sum so we never throw from the prune path.
463
+ * Uses bounded concurrency to avoid EMFILE on deep trees.
464
+ */
465
+ async computeDirSize(dir) {
466
+ let total = 0;
467
+ const concurrency = (0, _harmonyModules().concurrentIOLimit)();
468
+ const walk = async current => {
469
+ let entries;
470
+ try {
471
+ entries = await _fsExtra().default.readdir(current, {
472
+ withFileTypes: true
473
+ });
474
+ } catch {
475
+ return;
476
+ }
477
+ await (0, _pMap().default)(entries, async entry => {
478
+ const p = _path().default.join(current, entry.name);
479
+ if (entry.isDirectory()) {
480
+ await walk(p);
481
+ } else if (entry.isFile()) {
482
+ try {
483
+ const st = await _fsExtra().default.lstat(p);
484
+ total += st.size;
485
+ } catch {
486
+ // ignore
487
+ }
488
+ }
489
+ }, {
490
+ concurrency
491
+ });
492
+ };
493
+ await walk(dir);
494
+ return total;
495
+ }
496
+
497
+ /**
498
+ * Apply the prune rules from the plan:
499
+ * - workspace caps: deleted unconditionally (unless keepWorkspaceCaps)
500
+ * - scope-aspects-root: never deleted as a whole; per-aspect-version children pruned by age
501
+ * - scope caps and unmarked dirs older than threshold: deleted
502
+ * - orphans (marker says originPath gone): deleted
503
+ * - after the above, if sizeTargetGb given and size still exceeds it, evict oldest-first
504
+ */
505
+ async pruneCapsules(opts = {}) {
506
+ // Clamp to >= 0: a negative age would put the cutoff in the future (everything looks
507
+ // "too old" → whole cache deleted); a negative size target would force evicting
508
+ // everything. Both are almost certainly user error, so floor them at 0.
509
+ const olderThanDays = Math.max(0, opts.olderThanDays ?? 30);
510
+ const sizeTargetGb = opts.sizeTargetGb === undefined ? undefined : Math.max(0, opts.sizeTargetGb);
511
+ const includeOrphans = opts.includeOrphans !== false;
512
+ const keepWorkspaceCaps = opts.keepWorkspaceCaps === true;
513
+ const dryRun = opts.dryRun === true;
514
+ // Size accounting requires an expensive recursive lstat across the whole cache. Skip
515
+ // it by default so the foreground command returns in ms (deletes are O(1) renames);
516
+ // force on for size-target enforcement and when the caller asks for byte accounting.
517
+ const computeSizes = opts.withSizes === true || sizeTargetGb !== undefined;
518
+ const ageCutoffMs = Date.now() - olderThanDays * ONE_DAY_MS;
519
+ const datedDirName = this.configStore.getConfig(_legacy().CFG_CAPSULES_SCOPES_ASPECTS_DATED_DIR) || 'dated-capsules';
520
+ const roots = await this.listAllCapsuleRoots({
521
+ withSizes: computeSizes
522
+ });
523
+ const totalSizeBefore = computeSizes ? roots.reduce((sum, r) => sum + r.sizeBytes, 0) : 0;
524
+ const removed = [];
525
+ const removeEntry = (p, kind, reason, sizeBytes, originPath) => this.recordRemoval(removed, {
526
+ path: p,
527
+ kind,
528
+ reason,
529
+ sizeBytes,
530
+ originPath
531
+ }, dryRun);
532
+ for (const root of roots) {
533
+ if (_path().default.basename(root.path) === datedDirName) {
534
+ await this.pruneDatedCapsulesChildren(root.path, dryRun, computeSizes, removed);
535
+ continue;
536
+ }
537
+ if (root.kind === 'workspace') {
538
+ if (keepWorkspaceCaps) continue;
539
+ await removeEntry(root.path, root.kind, 'workspace-cap', root.sizeBytes, root.originPath);
540
+ continue;
541
+ }
542
+ if (root.kind === 'scope' || root.kind === 'unmarked') {
543
+ const orphan = includeOrphans && root.originPath && !(await _fsExtra().default.pathExists(root.originPath));
544
+ const tooOld = root.lastUsedMs < ageCutoffMs;
545
+ if (orphan) {
546
+ await removeEntry(root.path, root.kind, 'orphan', root.sizeBytes, root.originPath);
547
+ } else if (tooOld) {
548
+ // For unmarked dirs, sniff content first to avoid nuking a legacy scope-aspects root.
549
+ if (root.kind === 'unmarked' && (await this.looksLikeAspectsRoot(root.path))) {
550
+ await this.pruneAspectsRootChildren(root.path, ageCutoffMs, dryRun, computeSizes, removed);
551
+ } else {
552
+ await removeEntry(root.path, root.kind, `older-than-${olderThanDays}d`, root.sizeBytes, root.originPath);
553
+ }
554
+ }
555
+ continue;
556
+ }
557
+ if (root.kind === 'scope-aspects-root') {
558
+ await this.pruneAspectsRootChildren(root.path, ageCutoffMs, dryRun, computeSizes, removed);
559
+ continue;
560
+ }
561
+ }
562
+ if (sizeTargetGb !== undefined) {
563
+ await this.applySizeTarget(sizeTargetGb, removed, dryRun);
564
+ }
565
+ const totalRemovedBytes = removed.reduce((sum, r) => sum + r.sizeBytes, 0);
566
+ // For dry-run, report the *projected* post-prune size so the CLI summary stays
567
+ // internally consistent (cache: X → X − freed). Real prune subtracts the same.
568
+ const totalSizeAfter = Math.max(0, totalSizeBefore - totalRemovedBytes);
569
+ return {
570
+ removed,
571
+ totalRemovedBytes,
572
+ totalSizeBeforeBytes: totalSizeBefore,
573
+ totalSizeAfterBytes: totalSizeAfter,
574
+ dryRun
575
+ };
576
+ }
577
+
578
+ /**
579
+ * Record a removal in the prune report and, unless this is a dry run, actually delete it
580
+ * (fast rename-to-trash). Keeps the "report and delete are gated by the same dryRun flag"
581
+ * invariant in one place so the per-kind prune helpers can't drift apart.
582
+ */
583
+ async recordRemoval(removed, entry, dryRun) {
584
+ removed.push(entry);
585
+ this.logger.debug(`[capsule-prune] ${dryRun ? 'would remove' : 'removing'} [${entry.kind} · ${entry.reason}] ${entry.path}` + (entry.originPath ? ` origin=${entry.originPath}` : ''));
586
+ if (!dryRun) await this.scheduleFastDelete(entry.path);
587
+ }
588
+
589
+ /**
590
+ * The `dated-capsules` dir holds per-date subdirs (`YYYY-M-D`) of in-flight isolation
591
+ * runs. These are recreated on every isolation, so anything that isn't *today*'s
592
+ * subdir is leftover from a previous run and safe to delete. Today's subdir is
593
+ * preserved to avoid racing a concurrent bit process that may still be writing to it.
594
+ */
595
+ async pruneDatedCapsulesChildren(rootPath, dryRun, computeSizes, removed) {
596
+ const todayDir = this.getDatedCapsuleDirName();
597
+ let entries;
598
+ try {
599
+ entries = await _fsExtra().default.readdir(rootPath, {
600
+ withFileTypes: true
601
+ });
602
+ } catch {
603
+ return;
604
+ }
605
+ for (const entry of entries) {
606
+ if (!this.isPrunableSubdir(entry)) continue;
607
+ if (entry.name === todayDir) continue;
608
+ const childPath = _path().default.join(rootPath, entry.name);
609
+ const sizeBytes = computeSizes ? await this.computeDirSize(childPath) : 0;
610
+ await this.recordRemoval(removed, {
611
+ path: childPath,
612
+ kind: 'unmarked',
613
+ reason: 'dated-capsules-not-today',
614
+ sizeBytes
615
+ }, dryRun);
616
+ }
617
+ }
618
+
619
+ /**
620
+ * Legacy unmarked dirs may still be a scope-aspects root. Heuristic: a child subdir whose
621
+ * name contains `@` (aspect-version pattern like `teambit.node_node@1.3.4`).
622
+ */
623
+ async looksLikeAspectsRoot(dir) {
624
+ try {
625
+ const entries = await _fsExtra().default.readdir(dir, {
626
+ withFileTypes: true
627
+ });
628
+ return entries.some(e => e.isDirectory() && e.name.includes('@'));
629
+ } catch {
630
+ return false;
631
+ }
632
+ }
633
+
634
+ /**
635
+ * Prune per-aspect-version children of a scope-aspects root purely by age (marker mtime,
636
+ * which is touched on every aspect load).
637
+ *
638
+ * Note there's deliberately no orphan check here: a scope-aspect child's `originPath` is
639
+ * the *logical* scope-aspects path (e.g. `<scope.path>-aspects`) used only to hash the
640
+ * capsule root dir name — it need not exist as a real directory, so treating a missing
641
+ * `originPath` as "orphan" would wrongly delete capsules of currently-used aspects.
642
+ * Orphan pruning is still honored elsewhere for `workspace`/`scope` kinds.
643
+ */
644
+ async pruneAspectsRootChildren(rootPath, ageCutoffMs, dryRun, computeSizes, removed) {
645
+ let entries;
646
+ try {
647
+ entries = await _fsExtra().default.readdir(rootPath, {
648
+ withFileTypes: true
649
+ });
650
+ } catch {
651
+ return;
652
+ }
653
+ for (const entry of entries) {
654
+ if (!this.isPrunableSubdir(entry)) continue;
655
+ const childPath = _path().default.join(rootPath, entry.name);
656
+ const {
657
+ marker,
658
+ lastUsedMs
659
+ } = await this.readMarkerInfo(childPath);
660
+ if (lastUsedMs < ageCutoffMs) {
661
+ const sizeBytes = computeSizes ? await this.computeDirSize(childPath) : 0;
662
+ await this.recordRemoval(removed, {
663
+ path: childPath,
664
+ kind: 'scope-aspect',
665
+ reason: 'aspect-older-than-cutoff',
666
+ sizeBytes,
667
+ originPath: marker?.originPath
668
+ }, dryRun);
669
+ }
670
+ }
671
+ }
672
+
673
+ /**
674
+ * After the standard prune, if total still exceeds the target, keep evicting the
675
+ * oldest remaining aspect-version subdirs until under the limit.
676
+ */
677
+ async applySizeTarget(sizeTargetGb, removed, dryRun) {
678
+ const targetBytes = sizeTargetGb * 1024 * 1024 * 1024;
679
+ const removedPaths = new Set(removed.map(r => r.path));
680
+ // Re-walk what's left (one pass, with sizes) to find both the current total and the
681
+ // oldest aspect-version children to evict.
682
+ const roots = await this.listAllCapsuleRoots();
683
+ const totalBytes = roots.reduce((sum, r) => sum + r.sizeBytes, 0);
684
+ const aspectChildren = [];
685
+ for (const root of roots) {
686
+ if (root.kind !== 'scope-aspects-root' && !(root.kind === 'unmarked' && (await this.looksLikeAspectsRoot(root.path)))) {
687
+ continue;
688
+ }
689
+ let entries = [];
690
+ try {
691
+ entries = await _fsExtra().default.readdir(root.path, {
692
+ withFileTypes: true
693
+ });
694
+ } catch {
695
+ continue;
696
+ }
697
+ for (const entry of entries) {
698
+ if (!this.isPrunableSubdir(entry)) continue;
699
+ const childPath = _path().default.join(root.path, entry.name);
700
+ if (removedPaths.has(childPath)) continue;
701
+ const {
702
+ marker,
703
+ lastUsedMs
704
+ } = await this.readMarkerInfo(childPath);
705
+ const sizeBytes = await this.computeDirSize(childPath);
706
+ aspectChildren.push({
707
+ path: childPath,
708
+ lastUsedMs,
709
+ sizeBytes,
710
+ originPath: marker?.originPath
711
+ });
712
+ }
713
+ }
714
+ aspectChildren.sort((a, b) => a.lastUsedMs - b.lastUsedMs);
715
+ // In dry-run the standard-prune entries are still on disk (counted in totalBytes), so
716
+ // subtract them; in a real run they were already moved to trash and excluded from the walk.
717
+ let remainingBytes = totalBytes - (dryRun ? removed.reduce((s, r) => s + r.sizeBytes, 0) : 0);
718
+ for (const child of aspectChildren) {
719
+ if (remainingBytes <= targetBytes) break;
720
+ await this.recordRemoval(removed, {
721
+ path: child.path,
722
+ kind: 'scope-aspect',
723
+ reason: `size-target-${sizeTargetGb}gb`,
724
+ sizeBytes: child.sizeBytes,
725
+ originPath: child.originPath
726
+ }, dryRun);
727
+ remainingBytes -= child.sizeBytes;
728
+ }
729
+ }
730
+ }
731
+ exports.CapsuleCache = CapsuleCache;
732
+
733
+ //# sourceMappingURL=capsule-cache.js.map