@git.zone/tsdisk 1.2.1 → 1.3.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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/index.d.ts +4 -1
- package/dist_ts/index.js +4 -2
- package/dist_ts/tsdisk.cli.d.ts +13 -1
- package/dist_ts/tsdisk.cli.js +89 -47
- package/dist_ts/tsdisk.progress.d.ts +31 -0
- package/dist_ts/tsdisk.progress.js +64 -0
- package/dist_ts/tsdisk.scan.d.ts +3 -0
- package/dist_ts/tsdisk.scan.js +48 -19
- package/dist_ts/tsdisk.usage.d.ts +50 -0
- package/dist_ts/tsdisk.usage.js +194 -0
- package/package.json +1 -1
- package/readme.md +10 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +4 -0
- package/ts/tsdisk.cli.ts +81 -50
- package/ts/tsdisk.progress.ts +78 -0
- package/ts/tsdisk.scan.ts +45 -18
- package/ts/tsdisk.usage.ts +199 -0
package/ts/tsdisk.cli.ts
CHANGED
|
@@ -2,6 +2,9 @@ import * as plugins from './tsdisk.plugins.js';
|
|
|
2
2
|
import { commitinfo } from './00_commitinfo_data.js';
|
|
3
3
|
import { scanWorkspace, type IWorkspaceScanOptions, type IScanIssue } from './tsdisk.scan.js';
|
|
4
4
|
import { parseCliOptions, createCleanupPlan, type ICliOptions } from './tsdisk.cleanup.js';
|
|
5
|
+
import { measureDiskUsage, normalizeScanRoots, type IUsageReport } from './tsdisk.usage.js';
|
|
6
|
+
import { DiskProgressReporter, estimateReclaimableSpace, formatBytes, type IDiskProgress } from './tsdisk.progress.js';
|
|
7
|
+
export { formatBytes } from './tsdisk.progress.js';
|
|
5
8
|
export type { IWorkspaceScanOptions } from './tsdisk.scan.js';
|
|
6
9
|
import {
|
|
7
10
|
classifyToolCachePath,
|
|
@@ -23,6 +26,7 @@ interface IRunOptions {
|
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
export interface IRuntimeContext {
|
|
29
|
+
onProgress?: (progress: Partial<IDiskProgress>) => void;
|
|
26
30
|
runningAsRoot: boolean;
|
|
27
31
|
sudoAvailable: boolean;
|
|
28
32
|
sudoChecked: boolean;
|
|
@@ -89,24 +93,6 @@ function firstLines(text: string, maxLines = 8): string {
|
|
|
89
93
|
return text.trim().split(/\r?\n/).slice(0, maxLines).join('\n');
|
|
90
94
|
}
|
|
91
95
|
|
|
92
|
-
export function formatBytes(bytes: number): string {
|
|
93
|
-
if (!Number.isFinite(bytes) || bytes < 0) {
|
|
94
|
-
return 'n/a';
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
|
98
|
-
let value = bytes;
|
|
99
|
-
let unitIndex = 0;
|
|
100
|
-
|
|
101
|
-
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
102
|
-
value = value / 1024;
|
|
103
|
-
unitIndex++;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const decimals = unitIndex === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
|
107
|
-
return `${value.toFixed(decimals)} ${units[unitIndex]}`;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
96
|
function printSizeLine(label: string, bytes: number) {
|
|
111
97
|
console.log(`${formatBytes(bytes).padStart(10)} ${label}`);
|
|
112
98
|
}
|
|
@@ -523,6 +509,7 @@ async function runRootInteractive(
|
|
|
523
509
|
args: string[],
|
|
524
510
|
timeoutSeconds?: number,
|
|
525
511
|
): Promise<number> {
|
|
512
|
+
ctx.onProgress?.({ stage: `${command} ${args.slice(0, 3).join(' ')}` });
|
|
526
513
|
let executable = command;
|
|
527
514
|
let finalArgs = args;
|
|
528
515
|
|
|
@@ -561,6 +548,7 @@ async function runCommand(
|
|
|
561
548
|
args: string[],
|
|
562
549
|
options: IRunOptions = {},
|
|
563
550
|
): Promise<ICommandResult> {
|
|
551
|
+
ctx.onProgress?.({ stage: `${command} ${args.slice(0, 3).join(' ')}` });
|
|
564
552
|
let executable = command;
|
|
565
553
|
let finalArgs = [...args];
|
|
566
554
|
const env: NodeJS.ProcessEnv = {};
|
|
@@ -691,17 +679,23 @@ async function getWorkspaceRoots(): Promise<string[]> {
|
|
|
691
679
|
if (cwd && cwd !== '/') {
|
|
692
680
|
roots.add(cwd);
|
|
693
681
|
}
|
|
694
|
-
return [...roots];
|
|
682
|
+
return await normalizeScanRoots([...roots]);
|
|
695
683
|
}
|
|
696
684
|
|
|
697
685
|
export async function collectProjectToolCacheReport(ctx: IRuntimeContext, roots?: string[]) {
|
|
698
686
|
const findings: IToolCacheFinding[] = [];
|
|
699
687
|
const issues: IScanIssue[] = [];
|
|
700
|
-
const
|
|
688
|
+
const issueCounts: Partial<Record<IScanIssue['reason'], number>> = {};
|
|
689
|
+
const usagePriorityPaths: string[] = [];
|
|
690
|
+
const scanRoots = await normalizeScanRoots(roots?.length ? roots : await getWorkspaceRoots());
|
|
701
691
|
for (const root of scanRoots) {
|
|
702
|
-
const result = await scanWorkspace(root, { maxDepth: 8, timeoutMs: 45_000, maxEntries: 50_000
|
|
692
|
+
const result = await scanWorkspace(root, { maxDepth: 8, timeoutMs: 45_000, maxEntries: 50_000,
|
|
693
|
+
onProgress: (partial) => ctx.onProgress?.({ stage: `discovering ${root}`, cacheCount: findings.length + partial.findings.length, reclaim: estimateReclaimableSpace([...findings, ...partial.findings]) }),
|
|
694
|
+
});
|
|
703
695
|
findings.push(...result.findings);
|
|
696
|
+
usagePriorityPaths.push(...result.usagePriorityPaths);
|
|
704
697
|
issues.push(...result.issues);
|
|
698
|
+
for (const [reason, count] of Object.entries(result.issueCounts)) issueCounts[reason] = (issueCounts[reason] ?? 0) + count;
|
|
705
699
|
}
|
|
706
700
|
const unique = [...new Map(findings.map((finding) => [plugins.path.resolve(finding.path), finding])).values()];
|
|
707
701
|
const deadline = Date.now() + toolCacheSizingTimeoutMs;
|
|
@@ -716,36 +710,17 @@ export async function collectProjectToolCacheReport(ctx: IRuntimeContext, roots?
|
|
|
716
710
|
const size = result.code === 0 ? parseDuOutput(result.stdout)[0] : undefined;
|
|
717
711
|
if (size) finding.bytes = size.bytes;
|
|
718
712
|
else issues.push({ path: finding.path, reason: 'size-unavailable', detail: result.stderr.trim() || 'du failed or returned no size.' });
|
|
713
|
+
ctx.onProgress?.({ cacheCount: unique.length, reclaim: estimateReclaimableSpace(unique) });
|
|
719
714
|
}
|
|
720
|
-
|
|
715
|
+
issueCounts['size-unavailable'] = issues.filter((issue) => issue.reason === 'size-unavailable').length;
|
|
716
|
+
ctx.onProgress?.({ cacheCount: unique.length, reclaim: estimateReclaimableSpace(unique) });
|
|
717
|
+
return { roots: scanRoots, toolCaches: unique.sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0)), usagePriorityPaths, issues, issueCounts, complete: issues.length === 0 };
|
|
721
718
|
}
|
|
722
719
|
|
|
723
720
|
export async function collectProjectToolCacheDiagnostics(ctx: IRuntimeContext): Promise<IToolCacheFinding[]> {
|
|
724
721
|
return (await collectProjectToolCacheReport(ctx)).toolCaches;
|
|
725
722
|
}
|
|
726
723
|
|
|
727
|
-
async function printProjectToolCacheDiagnostics(ctx: IRuntimeContext) {
|
|
728
|
-
section('Project Tool Cache Diagnostics');
|
|
729
|
-
const roots = await getWorkspaceRoots();
|
|
730
|
-
if (roots.length === 0) {
|
|
731
|
-
console.log('No workspace roots found.');
|
|
732
|
-
return;
|
|
733
|
-
}
|
|
734
|
-
progress(`Scanning workspace roots: ${roots.join(', ')}`);
|
|
735
|
-
const report = await collectProjectToolCacheReport(ctx);
|
|
736
|
-
for (const issue of report.issues) console.log(`Incomplete scan: ${issue.reason}: ${issue.path}: ${issue.detail}`);
|
|
737
|
-
const findings = report.toolCaches;
|
|
738
|
-
if (findings.length === 0) {
|
|
739
|
-
console.log('No known project-local tool caches found.');
|
|
740
|
-
return;
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
for (const finding of findings) {
|
|
744
|
-
console.log(`${finding.bytes === undefined ? "unknown" : formatBytes(finding.bytes)} ${finding.ownerGuess} ${finding.kind} [${finding.risk}] ${finding.path}`);
|
|
745
|
-
console.log(` suggested: ${finding.suggestedCleanupCommand}`);
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
|
|
749
724
|
async function pathExistsForDu(ctx: IRuntimeContext, path: string, asRoot: boolean): Promise<boolean> {
|
|
750
725
|
if (!asRoot) {
|
|
751
726
|
return await pathExists(path);
|
|
@@ -1503,6 +1478,7 @@ function printHelp() {
|
|
|
1503
1478
|
console.log(' Cleanup uses project-installed tsrust/tsdocker and pnpm store prune, without sudo or raw cache deletion.');
|
|
1504
1479
|
console.log(' --days applies only to Rust targets. Docker uses its owner checks; pnpm uses reference tracking.');
|
|
1505
1480
|
console.log(' scan exit codes: 0 complete, 2 incomplete, 1 invalid input/failure.');
|
|
1481
|
+
console.log(' Live progress updates every 5 seconds (stderr with --json). Potential cleanup requires owner review.');
|
|
1506
1482
|
console.log(' tsdisk --help Show this help');
|
|
1507
1483
|
console.log(' tsdisk --version Show the version');
|
|
1508
1484
|
console.log('');
|
|
@@ -1517,8 +1493,19 @@ function printHelp() {
|
|
|
1517
1493
|
|
|
1518
1494
|
export async function main() {
|
|
1519
1495
|
const ctx = await initContext();
|
|
1496
|
+
const reporter = new DiskProgressReporter((line) => console.log(line));
|
|
1497
|
+
ctx.onProgress = (update) => reporter.update(update);
|
|
1498
|
+
reporter.start();
|
|
1499
|
+
try { await runHumanReport(ctx); }
|
|
1500
|
+
finally { reporter.stop(); }
|
|
1501
|
+
}
|
|
1520
1502
|
|
|
1503
|
+
async function runHumanReport(ctx: IRuntimeContext) {
|
|
1521
1504
|
await printDiskOverview(ctx);
|
|
1505
|
+
const caches = await collectProjectToolCacheReport(ctx);
|
|
1506
|
+
const usage = await collectWorkspaceUsage(ctx, caches);
|
|
1507
|
+
printWorkspaceUsage(usage);
|
|
1508
|
+
console.log(`Potential cleanup: ${formatBytes(estimateReclaimableSpace(caches.toolCaches).candidateBytes)} of cache candidates; owner review required. Confirmed reclaimable: unknown.`);
|
|
1522
1509
|
await ensureSudo(ctx);
|
|
1523
1510
|
await printNcduInsights(ctx);
|
|
1524
1511
|
|
|
@@ -1536,7 +1523,9 @@ export async function main() {
|
|
|
1536
1523
|
await printDuTree(ctx, 'Target Local Share Breakdown', `${ctx.targetHome}/.local/share`, true);
|
|
1537
1524
|
await printDuTree(ctx, 'Target Cache Breakdown', `${ctx.targetHome}/.cache`, true);
|
|
1538
1525
|
await printConfiguredToolPaths(ctx);
|
|
1539
|
-
|
|
1526
|
+
section('Project Tool Cache Diagnostics');
|
|
1527
|
+
for (const cache of caches.toolCaches) console.log(`${cache.bytes === undefined ? 'unknown' : formatBytes(cache.bytes)} ${JSON.stringify(cache.path)}\n ${cache.suggestedCleanupCommand}`);
|
|
1528
|
+
if (!caches.complete) console.log(`Discovery incomplete: ${JSON.stringify(caches.issueCounts)}`);
|
|
1540
1529
|
await printKnownSizes(ctx, 'Known User Caches', [
|
|
1541
1530
|
{ label: 'rootless Docker data', path: `${ctx.targetHome}/.local/share/docker` },
|
|
1542
1531
|
{ label: 'pnpm store', path: `${ctx.targetHome}/.local/share/pnpm` },
|
|
@@ -1587,27 +1576,61 @@ export async function main() {
|
|
|
1587
1576
|
|
|
1588
1577
|
export interface IDiagnosticSection {
|
|
1589
1578
|
name: string;
|
|
1579
|
+
kind?: 'command' | 'derived';
|
|
1580
|
+
derivedFrom?: 'usage.entries';
|
|
1590
1581
|
command: string;
|
|
1591
1582
|
args: string[];
|
|
1592
1583
|
result: ICommandResult;
|
|
1593
1584
|
complete: boolean;
|
|
1594
1585
|
}
|
|
1595
1586
|
|
|
1587
|
+
export function diagnosticCommandComplete(command: string, result: ICommandResult): boolean {
|
|
1588
|
+
return !result.truncated && !result.stderr.trim() && (result.code === 0 || (command === 'lsof' && result.code === 1));
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
async function collectWorkspaceUsage(ctx: IRuntimeContext, caches: Awaited<ReturnType<typeof collectProjectToolCacheReport>>) {
|
|
1592
|
+
ctx.onProgress?.({ stage: 'measuring workspace files and directories' });
|
|
1593
|
+
const usage = await measureDiskUsage(caches.roots, {
|
|
1594
|
+
timeoutSeconds: ctx.duTimeoutSeconds,
|
|
1595
|
+
trackedPaths: caches.toolCaches.map((finding) => finding.path),
|
|
1596
|
+
priorityPaths: caches.usagePriorityPaths,
|
|
1597
|
+
onProgress: (partial) => ctx.onProgress?.({
|
|
1598
|
+
stage: 'measuring workspace files and directories', measuredBytes: partial.measuredBytes,
|
|
1599
|
+
largest: partial.snapshot().filter((entry) => !caches.roots.includes(entry.path)).slice(0, 3),
|
|
1600
|
+
}),
|
|
1601
|
+
});
|
|
1602
|
+
return usage;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
function printWorkspaceUsage(usage: IUsageReport) {
|
|
1606
|
+
section(`Workspace Disk Usage (${usage.complete ? 'complete' : 'partial; + means a lower bound'})`);
|
|
1607
|
+
for (const entry of usage.entries.slice(0, 30)) console.log(`${formatBytes(entry.bytes)}${entry.complete ? '' : '+'} ${JSON.stringify(entry.path)}`);
|
|
1608
|
+
if (usage.stderr) console.log(firstLines(usage.stderr));
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1596
1611
|
export async function collectDiskReport(ctx: IRuntimeContext, roots?: string[]) {
|
|
1597
1612
|
const caches = await collectProjectToolCacheReport(ctx, roots);
|
|
1613
|
+
const usage = await collectWorkspaceUsage(ctx, caches);
|
|
1614
|
+
const reclaim = estimateReclaimableSpace(caches.toolCaches);
|
|
1598
1615
|
const sections: IDiagnosticSection[] = [];
|
|
1599
1616
|
const deadline = Date.now() + 120_000;
|
|
1600
1617
|
const inspect = async (name: string, command: string, args: string[]) => {
|
|
1601
1618
|
const result = Date.now() >= deadline
|
|
1602
1619
|
? { code: 124, stdout: '', stderr: 'Diagnostic command budget exhausted.' }
|
|
1603
1620
|
: await runCommand(ctx, command, args, { timeoutSeconds: Math.max(1, Math.min(15, Math.floor((deadline - Date.now()) / 1000))) });
|
|
1604
|
-
const complete =
|
|
1605
|
-
sections.push({ name, command, args, result, complete });
|
|
1621
|
+
const complete = diagnosticCommandComplete(command, result);
|
|
1622
|
+
sections.push({ name, kind: 'command', command, args, result, complete });
|
|
1606
1623
|
return result;
|
|
1607
1624
|
};
|
|
1608
1625
|
await inspect('filesystems', 'df', ['-hT']);
|
|
1609
1626
|
await inspect('mounts', 'findmnt', ['--json']);
|
|
1610
|
-
for (const root of caches.roots)
|
|
1627
|
+
for (const root of caches.roots) {
|
|
1628
|
+
const entries = usage.entries.filter((entry) => entry.path === root || entry.path.startsWith(root === '/' ? '/' : `${root}/`));
|
|
1629
|
+
const truncated = entries.length > 100;
|
|
1630
|
+
sections.push({ name: `workspace:${root}`, kind: 'derived', derivedFrom: 'usage.entries', command: 'usage-summary', args: [], complete: usage.complete && !truncated,
|
|
1631
|
+
result: { code: usage.code, stderr: usage.stderr, truncated, stdout: entries.slice(0, 100).map((entry) => `${entry.bytes}\t${entry.path}\n`).join('') },
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1611
1634
|
for (const path of ['/', ctx.targetHome, `${ctx.targetHome}/.cache`, `${ctx.targetHome}/.local/share`, '/var', '/var/lib']) {
|
|
1612
1635
|
await inspect(`usage:${path}`, 'du', ['-x', '--max-depth=1', '--block-size=1', '--', path]);
|
|
1613
1636
|
}
|
|
@@ -1620,7 +1643,7 @@ export async function collectDiskReport(ctx: IRuntimeContext, roots?: string[])
|
|
|
1620
1643
|
await inspect(`docker:${context}`, 'docker', ['--context', context, 'system', 'df', '-v']);
|
|
1621
1644
|
}
|
|
1622
1645
|
}
|
|
1623
|
-
return { schemaVersion: 1, generatedAt: new Date().toISOString(), ...caches, sections, complete: caches.complete && sections.every((section) => section.complete) };
|
|
1646
|
+
return { schemaVersion: 1, generatedAt: new Date().toISOString(), ...caches, usage, reclaim, sections, complete: caches.complete && usage.complete && sections.every((section) => section.complete) };
|
|
1624
1647
|
}
|
|
1625
1648
|
|
|
1626
1649
|
async function runCleanup(options: ICliOptions) {
|
|
@@ -1686,10 +1709,18 @@ export async function runCli(args = process.argv.slice(2)) {
|
|
|
1686
1709
|
if (options.command === 'version') { console.log(commitinfo.version); return; }
|
|
1687
1710
|
if (options.command === 'cleanup') { await runCleanup(options); return; }
|
|
1688
1711
|
if (options.command === 'scan') {
|
|
1689
|
-
const
|
|
1712
|
+
const reporter = new DiskProgressReporter((line) => options.json ? process.stderr.write(`${line}\n`) : console.log(line));
|
|
1713
|
+
const ctx = await initContext();
|
|
1714
|
+
ctx.onProgress = (update) => reporter.update(update);
|
|
1715
|
+
reporter.start();
|
|
1716
|
+
let report: Awaited<ReturnType<typeof collectDiskReport>>;
|
|
1717
|
+
try { report = await collectDiskReport(ctx, options.roots); }
|
|
1718
|
+
finally { reporter.stop(); }
|
|
1690
1719
|
if (options.json) console.log(JSON.stringify(report, null, 2));
|
|
1691
1720
|
else {
|
|
1692
1721
|
console.log(`Scan ${report.complete ? 'complete' : 'incomplete'}: ${report.toolCaches.length} tool caches`);
|
|
1722
|
+
printWorkspaceUsage(report.usage);
|
|
1723
|
+
console.log(`Potential cleanup: ${formatBytes(report.reclaim.candidateBytes)} (${report.reclaim.unknownSizeCount} caches unsized); owner review required. Confirmed reclaimable: unknown.`);
|
|
1693
1724
|
for (const finding of report.toolCaches) console.log(`${finding.bytes === undefined ? 'unknown' : formatBytes(finding.bytes)} [${finding.risk}] ${finding.path}\n ${finding.suggestedCleanupCommand}`);
|
|
1694
1725
|
for (const issue of report.issues) console.log(`${issue.reason}: ${issue.path}: ${issue.detail}`);
|
|
1695
1726
|
for (const section of report.sections) console.log(`\n${section.name} [${section.complete ? 'complete' : 'incomplete'}]\n${section.result.stdout}${section.result.stderr}`);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { containsPath, type IUsageEntry } from './tsdisk.usage.js';
|
|
2
|
+
import type { IToolCacheFinding } from './tsdisk.toolcaches.js';
|
|
3
|
+
|
|
4
|
+
export interface IReclaimEstimate {
|
|
5
|
+
candidateBytes: number;
|
|
6
|
+
unknownSizeCount: number;
|
|
7
|
+
protectedCount: number;
|
|
8
|
+
confirmedReclaimableBytes: null;
|
|
9
|
+
status: 'owner-review-required';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Do not sum parents and children, or treat a protected descendant as removable. */
|
|
13
|
+
export function estimateReclaimableSpace(findings: IToolCacheFinding[]): IReclaimEstimate {
|
|
14
|
+
const protectedPaths = findings.filter((finding) => finding.marker?.safeToPrune === false).map((finding) => finding.path);
|
|
15
|
+
const candidates = findings.filter((finding) => !protectedPaths.some((path) => containsPath(path, finding.path) || containsPath(finding.path, path)));
|
|
16
|
+
const selected: IToolCacheFinding[] = [];
|
|
17
|
+
for (const finding of [...candidates].sort((a, b) => a.path.length - b.path.length)) {
|
|
18
|
+
if (finding.bytes === undefined || selected.some((parent) => containsPath(parent.path, finding.path))) continue;
|
|
19
|
+
selected.push(finding);
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
candidateBytes: selected.reduce((sum, finding) => sum + (finding.bytes ?? 0), 0),
|
|
23
|
+
unknownSizeCount: candidates.filter((finding) => finding.bytes === undefined).length,
|
|
24
|
+
protectedCount: protectedPaths.length,
|
|
25
|
+
confirmedReclaimableBytes: null,
|
|
26
|
+
status: 'owner-review-required',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface IDiskProgress {
|
|
31
|
+
stage: string;
|
|
32
|
+
cacheCount: number;
|
|
33
|
+
measuredBytes: number;
|
|
34
|
+
reclaim: IReclaimEstimate;
|
|
35
|
+
largest: IUsageEntry[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function formatBytes(bytes: number): string {
|
|
39
|
+
if (!Number.isFinite(bytes) || bytes < 0) return 'n/a';
|
|
40
|
+
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
|
41
|
+
let value = bytes;
|
|
42
|
+
let index = 0;
|
|
43
|
+
while (value >= 1024 && index < units.length - 1) { value /= 1024; index++; }
|
|
44
|
+
return `${value.toFixed(index === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[index]}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Timer ownership is explicit; callers stop it in finally even on failure. */
|
|
48
|
+
export class DiskProgressReporter {
|
|
49
|
+
private timer?: ReturnType<typeof setInterval>;
|
|
50
|
+
private startedAt = Date.now();
|
|
51
|
+
private state: IDiskProgress = { stage: 'starting', cacheCount: 0, measuredBytes: 0, reclaim: estimateReclaimableSpace([]), largest: [] };
|
|
52
|
+
|
|
53
|
+
constructor(private write: (line: string) => void) {}
|
|
54
|
+
|
|
55
|
+
public update(update: Partial<IDiskProgress>) { Object.assign(this.state, update); }
|
|
56
|
+
|
|
57
|
+
public start() {
|
|
58
|
+
if (this.timer) return;
|
|
59
|
+
this.startedAt = Date.now();
|
|
60
|
+
this.print();
|
|
61
|
+
this.timer = setInterval(() => this.print(), 5000);
|
|
62
|
+
this.timer.unref();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private print() {
|
|
66
|
+
const state = this.state;
|
|
67
|
+
const top = state.largest.slice(0, 3).map((entry) => `${formatBytes(entry.bytes)}${entry.complete ? '' : '+'} ${JSON.stringify(entry.path)}`).join('; ');
|
|
68
|
+
this.write(`[tsdisk ${Math.floor((Date.now() - this.startedAt) / 1000)}s] ${state.stage} | measured ${formatBytes(state.measuredBytes)} | ${state.cacheCount} caches | potential cleanup ${formatBytes(state.reclaim.candidateBytes)} (owner review; ${state.reclaim.unknownSizeCount} unsized) | confirmed reclaimable: unknown${top ? ` | largest: ${top}` : ''}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public stop(stage = 'finished') {
|
|
72
|
+
if (!this.timer) return;
|
|
73
|
+
clearInterval(this.timer);
|
|
74
|
+
this.timer = undefined;
|
|
75
|
+
this.state.stage = stage;
|
|
76
|
+
this.print();
|
|
77
|
+
}
|
|
78
|
+
}
|
package/ts/tsdisk.scan.ts
CHANGED
|
@@ -5,6 +5,7 @@ export interface IWorkspaceScanOptions {
|
|
|
5
5
|
maxDepth: number;
|
|
6
6
|
timeoutMs: number;
|
|
7
7
|
maxEntries: number;
|
|
8
|
+
onProgress?: (result: IWorkspaceScanResult) => void;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export interface IScanIssue {
|
|
@@ -19,6 +20,8 @@ export interface IWorkspaceScanResult {
|
|
|
19
20
|
issues: IScanIssue[];
|
|
20
21
|
complete: boolean;
|
|
21
22
|
visitedEntries: number;
|
|
23
|
+
issueCounts: Partial<Record<IScanIssue['reason'], number>>;
|
|
24
|
+
usagePriorityPaths: string[];
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
const excludedDirectories = new Set(['.git', 'node_modules', 'dist_ts', 'dist_ts_web', 'coverage', '.pnpm-store']);
|
|
@@ -30,12 +33,13 @@ export async function scanWorkspace(root: string, options: IWorkspaceScanOptions
|
|
|
30
33
|
throw new Error(`Invalid scan option ${name}: ${value}`);
|
|
31
34
|
}
|
|
32
35
|
}
|
|
33
|
-
const result: IWorkspaceScanResult = { root: plugins.path.resolve(root), findings: [], issues: [], complete: true, visitedEntries: 0 };
|
|
36
|
+
const result: IWorkspaceScanResult = { root: plugins.path.resolve(root), findings: [], issues: [], issueCounts: {}, usagePriorityPaths: [], complete: true, visitedEntries: 0 };
|
|
34
37
|
const deadline = Date.now() + options.timeoutMs;
|
|
35
38
|
let stopped = false;
|
|
36
39
|
const issue = (path: string, reason: IScanIssue['reason'], detail: string) => {
|
|
37
40
|
result.complete = false;
|
|
38
|
-
result.
|
|
41
|
+
result.issueCounts[reason] = (result.issueCounts[reason] ?? 0) + 1;
|
|
42
|
+
if (result.issues.length < 200) result.issues.push({ path, reason, detail });
|
|
39
43
|
};
|
|
40
44
|
const limited = (path: string) => {
|
|
41
45
|
if (stopped) return true;
|
|
@@ -58,6 +62,8 @@ export async function scanWorkspace(root: string, options: IWorkspaceScanOptions
|
|
|
58
62
|
issue(result.root, 'read-error', error instanceof Error ? error.message : String(error));
|
|
59
63
|
return result;
|
|
60
64
|
}
|
|
65
|
+
const queue: Array<{ directory: string; depth: number }> = [{ directory: result.root, depth: 0 }];
|
|
66
|
+
let queueLimited = false;
|
|
61
67
|
const visit = async (directory: string, depth: number): Promise<void> => {
|
|
62
68
|
if (limited(directory)) return;
|
|
63
69
|
result.visitedEntries++;
|
|
@@ -71,11 +77,14 @@ export async function scanWorkspace(root: string, options: IWorkspaceScanOptions
|
|
|
71
77
|
issue(directory, 'mount-boundary', 'Different filesystem skipped.');
|
|
72
78
|
return;
|
|
73
79
|
}
|
|
74
|
-
|
|
80
|
+
if (plugins.path.basename(directory) === '.nogit') result.usagePriorityPaths.push(directory);
|
|
75
81
|
let marker;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
82
|
+
const markerPath = plugins.path.join(directory, toolCacheMarkerFile);
|
|
83
|
+
const markerStat = await plugins.fs.lstat(markerPath).catch((error: NodeJS.ErrnoException) => {
|
|
84
|
+
if (error.code === 'ENOENT') return undefined;
|
|
85
|
+
throw error;
|
|
86
|
+
});
|
|
87
|
+
if (markerStat) {
|
|
79
88
|
if (!markerStat.isFile() || markerStat.size > 65536) {
|
|
80
89
|
issue(markerPath, 'invalid-marker', 'Marker must be a regular file of at most 64 KiB.');
|
|
81
90
|
} else {
|
|
@@ -88,18 +97,22 @@ export async function scanWorkspace(root: string, options: IWorkspaceScanOptions
|
|
|
88
97
|
if (classification) result.findings.push({ path: directory, ...classification });
|
|
89
98
|
// Cache contents are not workspaces. Registry session directories can carry their own markers.
|
|
90
99
|
if (classification && heuristic?.kind !== 'docker-registry-cache') return;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
100
|
+
let enumerated = 0;
|
|
101
|
+
const children = await plugins.fs.opendir(directory);
|
|
102
|
+
for await (const child of children) {
|
|
103
|
+
// A huge flat directory must not consume the budget of sibling projects.
|
|
104
|
+
if (++enumerated > 4096) {
|
|
105
|
+
issue(directory, 'entry-limit', 'Directory listing stopped at 4096 entries; sibling directories are still scanned.');
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
if (excludedDirectories.has(child.name)) continue;
|
|
109
|
+
if (!child.isDirectory() && !child.isSymbolicLink()) continue;
|
|
110
|
+
if (depth >= options.maxDepth) {
|
|
111
|
+
issue(directory, 'depth-limit', `Maximum scan depth ${options.maxDepth} reached.`);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
97
114
|
const childPath = plugins.path.join(directory, child.name);
|
|
98
115
|
if (limited(childPath)) return;
|
|
99
|
-
if (!child.isDirectory() && !child.isSymbolicLink()) {
|
|
100
|
-
result.visitedEntries++;
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
116
|
if (child.isSymbolicLink()) {
|
|
104
117
|
result.visitedEntries++;
|
|
105
118
|
issue(childPath, 'symlink', 'Symbolic link skipped.');
|
|
@@ -113,12 +126,26 @@ export async function scanWorkspace(root: string, options: IWorkspaceScanOptions
|
|
|
113
126
|
});
|
|
114
127
|
if (!sessionMarker) { result.visitedEntries++; continue; }
|
|
115
128
|
}
|
|
116
|
-
|
|
129
|
+
if (queue.length >= options.maxEntries) {
|
|
130
|
+
if (!queueLimited) issue(directory, 'entry-limit', 'Directory queue budget exhausted; already queued siblings are still scanned.');
|
|
131
|
+
queueLimited = true;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
queue.push({ directory: childPath, depth: depth + 1 });
|
|
117
135
|
}
|
|
118
136
|
} catch (error) {
|
|
119
137
|
issue(directory, 'read-error', error instanceof Error ? error.message : String(error));
|
|
120
138
|
}
|
|
121
139
|
};
|
|
122
|
-
|
|
140
|
+
// Breadth first: canonical projects precede deeply nested retained worktrees.
|
|
141
|
+
let lastProgress = 0;
|
|
142
|
+
for (let index = 0; index < queue.length && !stopped; index++) {
|
|
143
|
+
await visit(queue[index].directory, queue[index].depth);
|
|
144
|
+
if (Date.now() - lastProgress >= 250) {
|
|
145
|
+
options.onProgress?.(result);
|
|
146
|
+
lastProgress = Date.now();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
options.onProgress?.(result);
|
|
123
150
|
return result;
|
|
124
151
|
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import * as plugins from './tsdisk.plugins.js';
|
|
2
|
+
|
|
3
|
+
export interface IUsageEntry {
|
|
4
|
+
path: string;
|
|
5
|
+
bytes: number;
|
|
6
|
+
complete: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface IUsageReport {
|
|
10
|
+
roots: string[];
|
|
11
|
+
measuredBytes: number;
|
|
12
|
+
entries: IUsageEntry[];
|
|
13
|
+
records: number;
|
|
14
|
+
complete: boolean;
|
|
15
|
+
code: number;
|
|
16
|
+
stderr: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function containsPath(parent: string, child: string): boolean {
|
|
20
|
+
const relative = plugins.path.relative(parent, child);
|
|
21
|
+
return relative === '' || (!relative.startsWith(`..${plugins.path.sep}`) && relative !== '..' && !plugins.path.isAbsolute(relative));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Preserve explicitly selected nested mounts; subsume overlapping roots on the same device. */
|
|
25
|
+
export async function normalizeScanRoots(roots: string[]): Promise<string[]> {
|
|
26
|
+
const unique = [...new Set(roots.map((root) => plugins.path.resolve(root)))].sort((a, b) => a.length - b.length || a.localeCompare(b));
|
|
27
|
+
const accepted: Array<{ path: string; dev?: number }> = [];
|
|
28
|
+
for (const path of unique) {
|
|
29
|
+
const stat = await plugins.fs.lstat(path).catch(() => undefined);
|
|
30
|
+
const real = await plugins.fs.realpath(path).catch(() => undefined);
|
|
31
|
+
const dev = stat?.isDirectory() && real === path ? stat.dev : undefined;
|
|
32
|
+
if (dev !== undefined && accepted.some((parent) => parent.dev === dev && containsPath(parent.path, path))) continue;
|
|
33
|
+
accepted.push({ path, dev });
|
|
34
|
+
}
|
|
35
|
+
return accepted.map((root) => root.path);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** GNU du emits files immediately and inclusive directory totals on completion.
|
|
39
|
+
* Reconcile each completed directory with its observed children, rather than
|
|
40
|
+
* adding inclusive totals twice. The active ancestor chain survives a timeout.
|
|
41
|
+
*/
|
|
42
|
+
export class UsageAccumulator {
|
|
43
|
+
private pending = '';
|
|
44
|
+
private active = new Map<string, number>();
|
|
45
|
+
private largest = new Map<string, IUsageEntry>();
|
|
46
|
+
private tracked = new Map<string, IUsageEntry>();
|
|
47
|
+
private minimumLargestBytes = 0;
|
|
48
|
+
private operandBytes = new Map<string, number>();
|
|
49
|
+
private accountingRoots: string[];
|
|
50
|
+
private currentRoot?: string;
|
|
51
|
+
public measuredBytes = 0;
|
|
52
|
+
public records = 0;
|
|
53
|
+
public currentPath = '';
|
|
54
|
+
|
|
55
|
+
constructor(private roots: string[], private trackedPaths: Set<string> = new Set(), private priorityPaths: string[] = []) {
|
|
56
|
+
this.roots = [...roots].sort((a, b) => b.length - a.length);
|
|
57
|
+
if (roots.some((root) => plugins.path.resolve(root) !== root) || priorityPaths.some((path) => !roots.some((root) => root !== path && containsPath(root, path)))) {
|
|
58
|
+
throw new Error('Usage accounting requires normalized roots and strict descendant priority paths.');
|
|
59
|
+
}
|
|
60
|
+
this.accountingRoots = [...priorityPaths, ...roots].sort((a, b) => b.length - a.length);
|
|
61
|
+
this.trackedPaths = new Set([...trackedPaths, ...roots, ...priorityPaths]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public consume(chunk: string) {
|
|
65
|
+
this.pending += chunk;
|
|
66
|
+
let start = 0;
|
|
67
|
+
for (;;) {
|
|
68
|
+
const end = this.pending.indexOf('\0', start);
|
|
69
|
+
if (end < 0) break;
|
|
70
|
+
this.record(this.pending.slice(start, end));
|
|
71
|
+
start = end + 1;
|
|
72
|
+
}
|
|
73
|
+
this.pending = this.pending.slice(start);
|
|
74
|
+
if (this.pending.length > 1024 * 1024) throw new Error('Invalid du record exceeds 1 MiB.');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private record(record: string) {
|
|
78
|
+
const separator = record.indexOf('\t');
|
|
79
|
+
const bytes = Number(record.slice(0, separator));
|
|
80
|
+
const path = record.slice(separator + 1);
|
|
81
|
+
const matches = (candidate: string) => path === candidate || path.startsWith(candidate === '/' ? '/' : `${candidate}/`);
|
|
82
|
+
const root = this.currentRoot && matches(this.currentRoot) ? this.currentRoot : this.accountingRoots.find(matches);
|
|
83
|
+
if (separator < 1 || !Number.isSafeInteger(bytes) || bytes < 0 || !plugins.path.isAbsolute(path) || !root) throw new Error('Invalid du output.');
|
|
84
|
+
this.records++;
|
|
85
|
+
this.currentPath = path;
|
|
86
|
+
this.currentRoot = path === root ? undefined : root;
|
|
87
|
+
const delta = bytes - (this.active.get(path) ?? 0);
|
|
88
|
+
this.active.delete(path);
|
|
89
|
+
this.measuredBytes += delta;
|
|
90
|
+
this.operandBytes.set(root, (this.operandBytes.get(root) ?? 0) + delta);
|
|
91
|
+
if (path !== root) {
|
|
92
|
+
for (let parent = plugins.path.dirname(path); ; parent = plugins.path.dirname(parent)) {
|
|
93
|
+
this.active.set(parent, (this.active.get(parent) ?? 0) + delta);
|
|
94
|
+
if (parent === root) break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const entry = { path, bytes, complete: true };
|
|
98
|
+
if (this.trackedPaths.has(path)) this.tracked.set(path, entry);
|
|
99
|
+
// Retain only the largest completed paths, not millions of file records.
|
|
100
|
+
if (this.largest.size < 100 || bytes > this.minimumLargestBytes) {
|
|
101
|
+
this.largest.set(path, entry);
|
|
102
|
+
if (this.largest.size > 100) {
|
|
103
|
+
const sorted = [...this.largest.values()].sort((a, b) => b.bytes - a.bytes);
|
|
104
|
+
this.largest = new Map(sorted.slice(0, 100).map((item) => [item.path, item]));
|
|
105
|
+
}
|
|
106
|
+
this.minimumLargestBytes = Math.min(...[...this.largest.values()].map((item) => item.bytes));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
public snapshot(): IUsageEntry[] {
|
|
111
|
+
const entries = new Map([...this.largest, ...this.tracked].map(([path, entry]) => [path, { ...entry }]));
|
|
112
|
+
for (const [path, bytes] of this.active) entries.set(path, { path, bytes, complete: false });
|
|
113
|
+
// du charges an overlapping subtree to its first operand and omits it from
|
|
114
|
+
// later ancestor totals. Restore those inclusive totals for display only;
|
|
115
|
+
// measuredBytes remains the native, deduplicated sum of operand contributions.
|
|
116
|
+
for (const priority of this.priorityPaths) {
|
|
117
|
+
const bytes = this.operandBytes.get(priority);
|
|
118
|
+
const root = this.roots.find((candidate) => containsPath(candidate, priority));
|
|
119
|
+
if (bytes === undefined || !root) continue;
|
|
120
|
+
for (let parent = plugins.path.dirname(priority); ; parent = plugins.path.dirname(parent)) {
|
|
121
|
+
const entry = entries.get(parent) ?? { path: parent, bytes: 0, complete: false };
|
|
122
|
+
entry.bytes += bytes;
|
|
123
|
+
entries.set(parent, entry);
|
|
124
|
+
if (parent === root) break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return [...entries.values()].sort((a, b) => b.bytes - a.bytes);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
public get endedOnRecordBoundary() { return this.pending.length === 0; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface IUsageOptions {
|
|
134
|
+
timeoutSeconds: number;
|
|
135
|
+
trackedPaths?: string[];
|
|
136
|
+
priorityPaths?: string[];
|
|
137
|
+
onProgress?: (usage: UsageAccumulator) => void;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One streaming native pass; no output export, no symlink traversal, no raw file content reads. */
|
|
141
|
+
export async function measureDiskUsage(roots: string[], options: IUsageOptions): Promise<IUsageReport> {
|
|
142
|
+
if (!Number.isFinite(options.timeoutSeconds) || options.timeoutSeconds <= 0) throw new Error('Invalid usage timeout.');
|
|
143
|
+
const valid: string[] = [];
|
|
144
|
+
const devices = new Map<string, number>();
|
|
145
|
+
const errors: string[] = [];
|
|
146
|
+
for (const root of await normalizeScanRoots(roots)) {
|
|
147
|
+
try {
|
|
148
|
+
const stat = await plugins.fs.lstat(root);
|
|
149
|
+
if (!stat.isDirectory() || await plugins.fs.realpath(root) !== root) throw new Error('Usage roots must be real directories without symlink ancestors.');
|
|
150
|
+
valid.push(root);
|
|
151
|
+
devices.set(root, stat.dev);
|
|
152
|
+
} catch (error) { errors.push(`${root}: ${error instanceof Error ? error.message : String(error)}`); }
|
|
153
|
+
}
|
|
154
|
+
const priorities: string[] = [];
|
|
155
|
+
for (const path of await normalizeScanRoots(options.priorityPaths ?? [])) {
|
|
156
|
+
const root = [...valid].reverse().find((root) => root !== path && containsPath(root, path));
|
|
157
|
+
if (!root) continue;
|
|
158
|
+
const real = await plugins.fs.realpath(path).catch(() => undefined);
|
|
159
|
+
const stat = await plugins.fs.lstat(path).catch(() => undefined);
|
|
160
|
+
if (real === path && stat?.isDirectory() && stat.dev === devices.get(root)) priorities.push(path);
|
|
161
|
+
}
|
|
162
|
+
// Measure retained project artifacts before broad dependency/source trees.
|
|
163
|
+
// This changes order only: remaining paths are visited by the final roots.
|
|
164
|
+
priorities.sort((a, b) => b.split(plugins.path.sep).length - a.split(plugins.path.sep).length || a.localeCompare(b));
|
|
165
|
+
let argumentBytes = valid.reduce((sum, path) => sum + Buffer.byteLength(path) + 1, 0);
|
|
166
|
+
const argumentLimit = priorities.findIndex((path) => (argumentBytes += Buffer.byteLength(path) + 1) > 128 * 1024);
|
|
167
|
+
if (argumentLimit >= 0) priorities.splice(argumentLimit);
|
|
168
|
+
const usage = new UsageAccumulator(valid, new Set(options.trackedPaths), priorities);
|
|
169
|
+
let stderr = errors.join('\n');
|
|
170
|
+
let malformed = false;
|
|
171
|
+
const code = valid.length ? await new Promise<number>((resolve) => {
|
|
172
|
+
const child = plugins.spawn('timeout', ['--kill-after=5s', `${options.timeoutSeconds}s`, 'du', '-a', '-0', '-x', '-B1', '--', ...priorities, ...valid], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, LC_ALL: 'C' } });
|
|
173
|
+
child.stdout.setEncoding('utf8');
|
|
174
|
+
let lastNotification = 0;
|
|
175
|
+
child.stdout.on('data', (chunk: string) => {
|
|
176
|
+
if (malformed) return;
|
|
177
|
+
try {
|
|
178
|
+
usage.consume(chunk);
|
|
179
|
+
if (Date.now() - lastNotification >= 250) {
|
|
180
|
+
options.onProgress?.(usage);
|
|
181
|
+
lastNotification = Date.now();
|
|
182
|
+
}
|
|
183
|
+
} catch (error) {
|
|
184
|
+
malformed = true;
|
|
185
|
+
stderr += `\n${error instanceof Error ? error.message : String(error)}`;
|
|
186
|
+
// Signal timeout, which forwards termination to its managed process group.
|
|
187
|
+
child.kill('SIGTERM');
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
child.stderr.setEncoding('utf8');
|
|
191
|
+
child.stderr.on('data', (chunk: string) => { if (stderr.length < 65536) stderr += chunk.slice(0, 65536 - stderr.length); });
|
|
192
|
+
child.on('error', (error) => { stderr += `\n${error.message}`; });
|
|
193
|
+
child.on('close', (exitCode) => resolve(exitCode ?? 1));
|
|
194
|
+
}) : 1;
|
|
195
|
+
options.onProgress?.(usage);
|
|
196
|
+
const complete = code === 0 && !stderr.trim() && !malformed && usage.endedOnRecordBoundary;
|
|
197
|
+
const entries = usage.snapshot().map((entry) => ({ ...entry, complete: entry.complete && complete }));
|
|
198
|
+
return { roots: valid, measuredBytes: usage.measuredBytes, entries, records: usage.records, complete, code, stderr };
|
|
199
|
+
}
|