@shieldfive/mcp 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +200 -0
- package/LICENSE +201 -0
- package/README.md +365 -0
- package/SECURITY.md +144 -0
- package/package.json +49 -0
- package/src/format.mjs +157 -0
- package/src/fsops.mjs +361 -0
- package/src/limits.mjs +55 -0
- package/src/plans.mjs +188 -0
- package/src/roots.mjs +336 -0
- package/src/scan.mjs +269 -0
- package/src/server.mjs +363 -0
- package/src/tools/mutate.mjs +780 -0
- package/src/tools/read.mjs +515 -0
- package/src/trash.mjs +264 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
// The read-only tools. Mutations live in mutate.mjs.
|
|
2
|
+
|
|
3
|
+
import { basename, dirname } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { daysAgo, formatBytes, formatDate, scanWarnings, toolResult } from '../format.mjs'
|
|
6
|
+
import { boundedInt, LIMITS } from '../limits.mjs'
|
|
7
|
+
import { isInside, resolveExisting, ToolError } from '../roots.mjs'
|
|
8
|
+
import { hashFile, walkRoots } from '../scan.mjs'
|
|
9
|
+
|
|
10
|
+
/** Resolve an optional `path` argument to the set of trees to walk. */
|
|
11
|
+
async function targets(ctx, path) {
|
|
12
|
+
// `path === ''` used to take the same branch as an omitted path and scan
|
|
13
|
+
// EVERY root. A model that builds the argument by concatenation and produces
|
|
14
|
+
// '' would get a whole-machine scan reported as the narrow one it asked for.
|
|
15
|
+
if (path === '') {
|
|
16
|
+
throw new ToolError(
|
|
17
|
+
'invalid_path',
|
|
18
|
+
'path was an empty string. Omit it to scan every configured root, or give ' +
|
|
19
|
+
'an absolute path; an empty string is not a request for either.',
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
if (!path) {
|
|
23
|
+
if (!ctx.roots.length) throw new ToolError('no_roots', ctx.noRootsMessage)
|
|
24
|
+
return ctx.roots
|
|
25
|
+
}
|
|
26
|
+
const { realPath } = await resolveExisting(ctx.roots, path, { what: 'path' })
|
|
27
|
+
return [{ realPath, configured: path }]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function scanOptions(args, ctx) {
|
|
31
|
+
return {
|
|
32
|
+
includeHidden: args.include_hidden ?? false,
|
|
33
|
+
maxFiles: boundedInt(args.max_files, { name: 'max_files', max: LIMITS.maxFiles, fallback: 200_000 }),
|
|
34
|
+
// Plumbed so a cancelled request stops the walk. Without it the SDK's abort
|
|
35
|
+
// signal was accepted and dropped, and a cancelled scan of a large tree ran
|
|
36
|
+
// to completion burning CPU nobody was waiting for.
|
|
37
|
+
signal: ctx?.signal,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The row limit a listing returns, defaulted and bounded before any work. */
|
|
42
|
+
function rowLimit(args, fallback) {
|
|
43
|
+
return boundedInt(args.limit, { name: 'limit', max: LIMITS.limit, fallback })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Where a scan went, for the payload.
|
|
48
|
+
*
|
|
49
|
+
* `scanned` is what was walked, not what was asked for: when the file budget
|
|
50
|
+
* runs out, later roots are never opened, and listing them as scanned made an
|
|
51
|
+
* empty result for them read as "nothing there".
|
|
52
|
+
*/
|
|
53
|
+
function coverage(perRoot, notScanned) {
|
|
54
|
+
return { scanned: perRoot.map((r) => r.root), not_scanned: notScanned }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function listLocal(ctx, args) {
|
|
58
|
+
const roots = await targets(ctx, args.path)
|
|
59
|
+
const limit = rowLimit(args, 200)
|
|
60
|
+
const { files, perRoot, notScanned } = await walkRoots(roots, scanOptions(args, ctx))
|
|
61
|
+
|
|
62
|
+
const sorted = files.sort((a, b) =>
|
|
63
|
+
args.sort_by === 'size'
|
|
64
|
+
? b.size - a.size
|
|
65
|
+
: args.sort_by === 'modified'
|
|
66
|
+
? b.mtimeMs - a.mtimeMs
|
|
67
|
+
: a.path.localeCompare(b.path),
|
|
68
|
+
)
|
|
69
|
+
const shown = sorted.slice(0, limit)
|
|
70
|
+
const totalBytes = files.reduce((n, f) => n + f.size, 0)
|
|
71
|
+
const warnings = scanWarnings(perRoot, notScanned)
|
|
72
|
+
|
|
73
|
+
return toolResult(
|
|
74
|
+
`${files.length.toLocaleString()} file(s), ${formatBytes(totalBytes)}. ` +
|
|
75
|
+
`Showing ${shown.length}${files.length > shown.length ? ` of ${files.length} (limit ${limit})` : ''}.` +
|
|
76
|
+
(warnings.length ? ` ${warnings.join(' ')}` : ''),
|
|
77
|
+
{
|
|
78
|
+
...coverage(perRoot, notScanned),
|
|
79
|
+
total_files: files.length,
|
|
80
|
+
total_bytes: totalBytes,
|
|
81
|
+
shown: shown.length,
|
|
82
|
+
omitted_by_limit: Math.max(0, files.length - shown.length),
|
|
83
|
+
warnings,
|
|
84
|
+
files: shown.map((f) => ({
|
|
85
|
+
path: f.path,
|
|
86
|
+
size: f.size,
|
|
87
|
+
size_human: formatBytes(f.size),
|
|
88
|
+
modified: formatDate(f.mtimeMs),
|
|
89
|
+
})),
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function findLargeFiles(ctx, args) {
|
|
95
|
+
const roots = await targets(ctx, args.path)
|
|
96
|
+
const limit = rowLimit(args, 100)
|
|
97
|
+
const threshold = args.min_bytes ?? 100_000_000
|
|
98
|
+
const { files, perRoot, notScanned } = await walkRoots(roots, scanOptions(args, ctx))
|
|
99
|
+
|
|
100
|
+
const big = files.filter((f) => f.size >= threshold).sort((a, b) => b.size - a.size)
|
|
101
|
+
const shown = big.slice(0, limit)
|
|
102
|
+
const warnings = scanWarnings(perRoot, notScanned)
|
|
103
|
+
|
|
104
|
+
return toolResult(
|
|
105
|
+
`${big.length} file(s) at or above ${formatBytes(threshold)}, ` +
|
|
106
|
+
`${formatBytes(big.reduce((n, f) => n + f.size, 0))} in total.` +
|
|
107
|
+
(warnings.length ? ` ${warnings.join(' ')}` : ''),
|
|
108
|
+
{
|
|
109
|
+
...coverage(perRoot, notScanned),
|
|
110
|
+
threshold_bytes: threshold,
|
|
111
|
+
match_count: big.length,
|
|
112
|
+
matched_bytes: big.reduce((n, f) => n + f.size, 0),
|
|
113
|
+
shown: shown.length,
|
|
114
|
+
omitted_by_limit: Math.max(0, big.length - shown.length),
|
|
115
|
+
warnings,
|
|
116
|
+
files: shown.map((f) => ({
|
|
117
|
+
path: f.path,
|
|
118
|
+
size: f.size,
|
|
119
|
+
size_human: formatBytes(f.size),
|
|
120
|
+
modified: formatDate(f.mtimeMs),
|
|
121
|
+
})),
|
|
122
|
+
},
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function findOldFiles(ctx, args) {
|
|
127
|
+
const roots = await targets(ctx, args.path)
|
|
128
|
+
const limit = rowLimit(args, 100)
|
|
129
|
+
const days = args.older_than_days ?? 365
|
|
130
|
+
const now = ctx.now()
|
|
131
|
+
const cutoff = now - days * 86_400_000
|
|
132
|
+
const { files, perRoot, notScanned } = await walkRoots(roots, scanOptions(args, ctx))
|
|
133
|
+
|
|
134
|
+
const old = files.filter((f) => f.mtimeMs < cutoff).sort((a, b) => a.mtimeMs - b.mtimeMs)
|
|
135
|
+
const shown = old.slice(0, limit)
|
|
136
|
+
const warnings = scanWarnings(perRoot, notScanned)
|
|
137
|
+
warnings.push(
|
|
138
|
+
'Modification time is not evidence a file is unwanted, and on some copy ' +
|
|
139
|
+
'operations it is reset to the copy date. Treat this as a shortlist to review.',
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
// Every other read tool appends its warnings to the summary line. This one
|
|
143
|
+
// did not, so a truncated scan AND its own "mtime is a weak signal" caveat
|
|
144
|
+
// reached only the JSON payload -- not the line a model actually quotes.
|
|
145
|
+
return toolResult(
|
|
146
|
+
`${old.length} file(s) unmodified for more than ${days} day(s), ` +
|
|
147
|
+
`${formatBytes(old.reduce((n, f) => n + f.size, 0))} in total.` +
|
|
148
|
+
(warnings.length ? ` ${warnings.join(' ')}` : ''),
|
|
149
|
+
{
|
|
150
|
+
...coverage(perRoot, notScanned),
|
|
151
|
+
older_than_days: days,
|
|
152
|
+
match_count: old.length,
|
|
153
|
+
matched_bytes: old.reduce((n, f) => n + f.size, 0),
|
|
154
|
+
shown: shown.length,
|
|
155
|
+
omitted_by_limit: Math.max(0, old.length - shown.length),
|
|
156
|
+
warnings,
|
|
157
|
+
files: shown.map((f) => ({
|
|
158
|
+
path: f.path,
|
|
159
|
+
size: f.size,
|
|
160
|
+
size_human: formatBytes(f.size),
|
|
161
|
+
modified: formatDate(f.mtimeMs),
|
|
162
|
+
days_since_modified: daysAgo(f.mtimeMs, now),
|
|
163
|
+
})),
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function storageSummary(ctx, args) {
|
|
169
|
+
const roots = await targets(ctx, args.path)
|
|
170
|
+
const limit = rowLimit(args, 15)
|
|
171
|
+
const { files, perRoot, notScanned } = await walkRoots(roots, scanOptions(args, ctx))
|
|
172
|
+
|
|
173
|
+
const byExtension = new Map()
|
|
174
|
+
const byDirectory = new Map()
|
|
175
|
+
for (const f of files) {
|
|
176
|
+
const ext = f.extension || '(no extension)'
|
|
177
|
+
const e = byExtension.get(ext) ?? { count: 0, bytes: 0 }
|
|
178
|
+
e.count++
|
|
179
|
+
e.bytes += f.size
|
|
180
|
+
byExtension.set(ext, e)
|
|
181
|
+
|
|
182
|
+
// Credit every ancestor up to the root, not just the immediate parent.
|
|
183
|
+
// Crediting only dirname() answers "which leaf folder holds the most bytes",
|
|
184
|
+
// so a 900 MB Movies/ split across nine subfolders never appeared while a
|
|
185
|
+
// single flat 200 MB folder ranked first -- the opposite of the question a
|
|
186
|
+
// storage summary is asked.
|
|
187
|
+
const root = f.root ?? roots[0].realPath
|
|
188
|
+
let dir = dirname(f.path)
|
|
189
|
+
for (;;) {
|
|
190
|
+
const d = byDirectory.get(dir) ?? { count: 0, bytes: 0 }
|
|
191
|
+
d.count++
|
|
192
|
+
d.bytes += f.size
|
|
193
|
+
byDirectory.set(dir, d)
|
|
194
|
+
if (dir === root || !isInside(dir, root)) break
|
|
195
|
+
const parent = dirname(dir)
|
|
196
|
+
if (parent === dir) break
|
|
197
|
+
dir = parent
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const top = (map, n) =>
|
|
202
|
+
[...map.entries()]
|
|
203
|
+
.sort((a, b) => b[1].bytes - a[1].bytes)
|
|
204
|
+
.slice(0, n)
|
|
205
|
+
.map(([key, v]) => ({
|
|
206
|
+
key,
|
|
207
|
+
count: v.count,
|
|
208
|
+
bytes: v.bytes,
|
|
209
|
+
bytes_human: formatBytes(v.bytes),
|
|
210
|
+
}))
|
|
211
|
+
|
|
212
|
+
const totalBytes = files.reduce((n, f) => n + f.size, 0)
|
|
213
|
+
const warnings = scanWarnings(perRoot, notScanned)
|
|
214
|
+
|
|
215
|
+
return toolResult(
|
|
216
|
+
`${files.length.toLocaleString()} file(s), ${formatBytes(totalBytes)} across ` +
|
|
217
|
+
`${perRoot.length} location(s).` + (warnings.length ? ` ${warnings.join(' ')}` : ''),
|
|
218
|
+
{
|
|
219
|
+
...coverage(perRoot, notScanned),
|
|
220
|
+
total_files: files.length,
|
|
221
|
+
total_bytes: totalBytes,
|
|
222
|
+
total_bytes_human: formatBytes(totalBytes),
|
|
223
|
+
warnings,
|
|
224
|
+
per_root: perRoot.map((r) => ({
|
|
225
|
+
root: r.root,
|
|
226
|
+
files: r.files,
|
|
227
|
+
directories: r.directories,
|
|
228
|
+
truncated: r.truncated,
|
|
229
|
+
})),
|
|
230
|
+
largest_by_extension: top(byExtension, limit),
|
|
231
|
+
largest_directories: top(byDirectory, limit),
|
|
232
|
+
note:
|
|
233
|
+
'Sizes are what the filesystem reports for file contents. They exclude ' +
|
|
234
|
+
'directory overhead and do not account for filesystem compression, ' +
|
|
235
|
+
'sparse files, hardlinks or APFS clones, so this will not match a disk ' +
|
|
236
|
+
'utility exactly. largest_directories counts each file against every ' +
|
|
237
|
+
'ancestor directory, so a parent and its child both appear and their ' +
|
|
238
|
+
'totals overlap by design.',
|
|
239
|
+
},
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The order in which copies of one file are nominated to keep, as a comparator.
|
|
245
|
+
*
|
|
246
|
+
* The earliest modification time first. A tie is the ordinary case rather than
|
|
247
|
+
* an edge -- Finder's Duplicate and `cp -p` both keep the original's mtime --
|
|
248
|
+
* and it used to fall back to whatever order the directory listed its entries
|
|
249
|
+
* in, so the same tree could nominate a different keeper on another filesystem.
|
|
250
|
+
* A tie goes to the shorter path, which keeps "report.pdf" over
|
|
251
|
+
* "report copy.pdf", and then to the path in code-unit order, which does not
|
|
252
|
+
* depend on the locale.
|
|
253
|
+
*/
|
|
254
|
+
export function compareKeeper(a, b) {
|
|
255
|
+
if (a.mtimeMs !== b.mtimeMs) return a.mtimeMs - b.mtimeMs
|
|
256
|
+
if (a.path.length !== b.path.length) return a.path.length - b.path.length
|
|
257
|
+
return a.path < b.path ? -1 : a.path > b.path ? 1 : 0
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* A key that two walk entries share only when they are names of one file.
|
|
262
|
+
*
|
|
263
|
+
* It is used to REDUCE what is reported as reclaimable, never to decide that
|
|
264
|
+
* two files hold the same bytes; that stays the job of the full SHA-256. A file
|
|
265
|
+
* with a single link gets a key of its own, and so does one whose device or
|
|
266
|
+
* inode number did not survive conversion to a JavaScript number, so a lossy
|
|
267
|
+
* number cannot fold two different files together.
|
|
268
|
+
*/
|
|
269
|
+
function sameFileKey(f) {
|
|
270
|
+
if (f.nlink > 1 && Number.isSafeInteger(f.dev) && Number.isSafeInteger(f.ino) && f.ino > 0) {
|
|
271
|
+
return `inode:${f.dev}:${f.ino}`
|
|
272
|
+
}
|
|
273
|
+
return `path:${f.path}`
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Duplicate detection.
|
|
278
|
+
*
|
|
279
|
+
* Narrowing passes: group by exact size, then — for files large enough for it
|
|
280
|
+
* to mean anything — by a hash of the first 64 KiB, then confirm every survivor
|
|
281
|
+
* with a full-content hash. Only the full hash decides. Name is never an input:
|
|
282
|
+
* two files with the same name and size are routinely different files, and
|
|
283
|
+
* acting on that guess deletes the wrong copy.
|
|
284
|
+
*
|
|
285
|
+
* The hashing budget counts EVERY read, head and full. It used to count only
|
|
286
|
+
* the head pass, so a budget of 300 permitted 300 head reads of 64 KiB plus 300
|
|
287
|
+
* unbounded whole-file reads — on 4 MB files, 61x the work the number implied.
|
|
288
|
+
*
|
|
289
|
+
* Groups are processed in descending order of what they could reclaim, and the
|
|
290
|
+
* budget is spent per file. A group whose worst case does not fit in what is
|
|
291
|
+
* left is hashed in part rather than skipped: the old all-or-nothing gate passed
|
|
292
|
+
* over the most valuable group and spent the budget on the smaller ones behind
|
|
293
|
+
* it, which is the opposite of largest-first.
|
|
294
|
+
*
|
|
295
|
+
* What is reclaimable is counted per file on disk, not per name. Two hardlinks
|
|
296
|
+
* to one file hash identically and are one copy; removing either frees nothing.
|
|
297
|
+
*/
|
|
298
|
+
export async function findDuplicates(ctx, args) {
|
|
299
|
+
const HEAD_WINDOW = 65_536
|
|
300
|
+
const roots = await targets(ctx, args.path)
|
|
301
|
+
const limit = rowLimit(args, 100)
|
|
302
|
+
const budget = boundedInt(args.max_files_hashed, {
|
|
303
|
+
name: 'max_files_hashed',
|
|
304
|
+
max: LIMITS.maxFilesHashed,
|
|
305
|
+
fallback: 20_000,
|
|
306
|
+
})
|
|
307
|
+
const minSize = args.min_bytes ?? 1
|
|
308
|
+
const { files, perRoot, notScanned } = await walkRoots(roots, scanOptions(args, ctx))
|
|
309
|
+
|
|
310
|
+
// A zero-byte file is identical to every other zero-byte file, which is true
|
|
311
|
+
// and useless. Excluded unless min_bytes: 0 asks for them explicitly, and
|
|
312
|
+
// said out loud either way rather than silently overridden.
|
|
313
|
+
const candidates = files.filter((f) => f.size >= minSize && (minSize === 0 || f.size > 0))
|
|
314
|
+
|
|
315
|
+
const bySize = new Map()
|
|
316
|
+
for (const f of candidates) {
|
|
317
|
+
const list = bySize.get(f.size) ?? []
|
|
318
|
+
list.push(f)
|
|
319
|
+
bySize.set(f.size, list)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const sizeGroups = [...bySize.values()]
|
|
323
|
+
.filter((g) => g.length > 1)
|
|
324
|
+
.sort(
|
|
325
|
+
(a, b) =>
|
|
326
|
+
b[0].size * (b.length - 1) - a[0].size * (a.length - 1) || b[0].size - a[0].size,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
let reads = 0
|
|
330
|
+
const shortfall = [] // { size, files, hashed } for every group not hashed whole
|
|
331
|
+
const unreadable = []
|
|
332
|
+
|
|
333
|
+
const hash = async (f, limit) => {
|
|
334
|
+
reads++
|
|
335
|
+
return hashFile(f.path, limit ? { limit, signal: ctx?.signal } : { signal: ctx?.signal })
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const byFull = new Map()
|
|
339
|
+
|
|
340
|
+
for (const group of sizeGroups) {
|
|
341
|
+
ctx?.signal?.throwIfAborted?.()
|
|
342
|
+
|
|
343
|
+
// Worst case per file: a head read and a full read. Small files skip the
|
|
344
|
+
// head pass because it would read the very same bytes.
|
|
345
|
+
const needsHead = group[0].size > HEAD_WINDOW
|
|
346
|
+
const affordable = Math.floor((budget - reads) / (needsHead ? 2 : 1))
|
|
347
|
+
|
|
348
|
+
// One file on its own proves nothing, so a group that cannot get two is
|
|
349
|
+
// left out whole, and what remains of the budget flows on to smaller groups.
|
|
350
|
+
if (affordable < 2) {
|
|
351
|
+
shortfall.push({ size: group[0].size, files: group.length, hashed: 0 })
|
|
352
|
+
continue
|
|
353
|
+
}
|
|
354
|
+
let members = group
|
|
355
|
+
if (affordable < group.length) {
|
|
356
|
+
members = [...group].sort(compareKeeper).slice(0, affordable)
|
|
357
|
+
shortfall.push({ size: group[0].size, files: group.length, hashed: members.length })
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
let survivors = members
|
|
361
|
+
if (needsHead) {
|
|
362
|
+
const byHead = new Map()
|
|
363
|
+
for (const f of members) {
|
|
364
|
+
let head
|
|
365
|
+
try {
|
|
366
|
+
head = await hash(f, HEAD_WINDOW)
|
|
367
|
+
} catch (err) {
|
|
368
|
+
unreadable.push({ path: f.path, code: err.code ?? 'EREAD' })
|
|
369
|
+
continue
|
|
370
|
+
}
|
|
371
|
+
const list = byHead.get(head) ?? []
|
|
372
|
+
list.push(f)
|
|
373
|
+
byHead.set(head, list)
|
|
374
|
+
}
|
|
375
|
+
survivors = [...byHead.values()].filter((g) => g.length > 1).flat()
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
for (const f of survivors) {
|
|
379
|
+
let full
|
|
380
|
+
try {
|
|
381
|
+
full = await hash(f)
|
|
382
|
+
} catch (err) {
|
|
383
|
+
unreadable.push({ path: f.path, code: err.code ?? 'EREAD' })
|
|
384
|
+
continue
|
|
385
|
+
}
|
|
386
|
+
const list = byFull.get(full) ?? []
|
|
387
|
+
list.push(f)
|
|
388
|
+
byFull.set(full, list)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const COPIES_SHOWN = 50
|
|
393
|
+
const duplicates = []
|
|
394
|
+
let extraNames = 0
|
|
395
|
+
let singleFileSets = 0
|
|
396
|
+
|
|
397
|
+
for (const [digest, group] of byFull) {
|
|
398
|
+
if (group.length < 2) continue
|
|
399
|
+
|
|
400
|
+
// One entry per file on disk. Its names are ordered like copies are, and
|
|
401
|
+
// the first one stands for it.
|
|
402
|
+
const byFile = new Map()
|
|
403
|
+
for (const f of group) {
|
|
404
|
+
const key = sameFileKey(f)
|
|
405
|
+
const names = byFile.get(key) ?? []
|
|
406
|
+
names.push(f)
|
|
407
|
+
byFile.set(key, names)
|
|
408
|
+
}
|
|
409
|
+
const copies = [...byFile.values()]
|
|
410
|
+
.map((names) => names.sort(compareKeeper))
|
|
411
|
+
.sort((a, b) => compareKeeper(a[0], b[0]))
|
|
412
|
+
|
|
413
|
+
if (copies.length < 2) {
|
|
414
|
+
singleFileSets++
|
|
415
|
+
continue
|
|
416
|
+
}
|
|
417
|
+
extraNames += group.length - copies.length
|
|
418
|
+
|
|
419
|
+
const entry = (names) => ({
|
|
420
|
+
path: names[0].path,
|
|
421
|
+
modified: formatDate(names[0].mtimeMs),
|
|
422
|
+
...(names.length > 1
|
|
423
|
+
? { hardlinked_names: names.slice(1, 1 + COPIES_SHOWN).map((n) => n.path) }
|
|
424
|
+
: {}),
|
|
425
|
+
})
|
|
426
|
+
const [keep, ...redundant] = copies
|
|
427
|
+
duplicates.push({
|
|
428
|
+
sha256: digest,
|
|
429
|
+
size: keep[0].size,
|
|
430
|
+
size_human: formatBytes(keep[0].size),
|
|
431
|
+
copies: copies.length,
|
|
432
|
+
names: group.length,
|
|
433
|
+
reclaimable_bytes: keep[0].size * redundant.length,
|
|
434
|
+
oldest_copy: entry(keep),
|
|
435
|
+
// Bounded. `limit` caps the number of GROUPS; without this a single
|
|
436
|
+
// 12,000-copy group produced a multi-megabyte payload regardless of it.
|
|
437
|
+
other_copies: redundant.slice(0, COPIES_SHOWN).map(entry),
|
|
438
|
+
other_copies_omitted: Math.max(0, redundant.length - COPIES_SHOWN),
|
|
439
|
+
names_differ: new Set(group.map((f) => basename(f.path))).size > 1,
|
|
440
|
+
})
|
|
441
|
+
}
|
|
442
|
+
duplicates.sort(
|
|
443
|
+
(a, b) =>
|
|
444
|
+
b.reclaimable_bytes - a.reclaimable_bytes || compareKeeper(a.oldest_copy, b.oldest_copy),
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
const reclaimable = duplicates.reduce((n, d) => n + d.reclaimable_bytes, 0)
|
|
448
|
+
const warnings = scanWarnings(perRoot, notScanned)
|
|
449
|
+
if (shortfall.length) {
|
|
450
|
+
const filesNotHashed = shortfall.reduce((n, g) => n + g.files - g.hashed, 0)
|
|
451
|
+
const partial = shortfall.filter((g) => g.hashed > 0).length
|
|
452
|
+
// Each file never hashed could duplicate a copy already found, except that
|
|
453
|
+
// in a group where nothing was hashed one of them would be the keeper.
|
|
454
|
+
const unchecked = shortfall.reduce((n, g) => n + g.size * (g.files - Math.max(g.hashed, 1)), 0)
|
|
455
|
+
warnings.push(
|
|
456
|
+
`The hashing budget of ${budget.toLocaleString()} reads was reached, so ` +
|
|
457
|
+
`${filesNotHashed.toLocaleString()} file(s) in ${shortfall.length} same-size group(s)` +
|
|
458
|
+
`${partial ? ` (${partial} of them hashed in part)` : ''} were never hashed, covering ` +
|
|
459
|
+
`up to ${formatBytes(unchecked)} that is absent below. This result is a lower bound — ` +
|
|
460
|
+
'raise max_files_hashed for a complete answer.',
|
|
461
|
+
)
|
|
462
|
+
}
|
|
463
|
+
if (extraNames) {
|
|
464
|
+
warnings.push(
|
|
465
|
+
`${extraNames} name(s) below are hardlinks to a copy already counted (hardlinked_names). ` +
|
|
466
|
+
'Removing one of those names frees no space; a copy’s space comes back only when all ' +
|
|
467
|
+
'of its names are gone.',
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
if (singleFileSets) {
|
|
471
|
+
warnings.push(
|
|
472
|
+
`${singleFileSets} set(s) of matching names are hardlinks to a single file and are not ` +
|
|
473
|
+
'listed: removing one of those names frees no space.',
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
if (unreadable.length) {
|
|
477
|
+
warnings.push(`${unreadable.length} file(s) could not be read and were not compared.`)
|
|
478
|
+
}
|
|
479
|
+
if (minSize === 0) {
|
|
480
|
+
warnings.push('min_bytes: 0 was given, so empty files are included; every empty file matches every other.')
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return toolResult(
|
|
484
|
+
`${duplicates.length} duplicate group(s), ${formatBytes(reclaimable)} reclaimable ` +
|
|
485
|
+
'by keeping one copy of each. Every match is confirmed by a full SHA-256 of the ' +
|
|
486
|
+
'file contents.' + (warnings.length ? ` ${warnings.join(' ')}` : ''),
|
|
487
|
+
{
|
|
488
|
+
...coverage(perRoot, notScanned),
|
|
489
|
+
files_considered: candidates.length,
|
|
490
|
+
hash_reads: reads,
|
|
491
|
+
hash_budget: budget,
|
|
492
|
+
groups_skipped_for_budget: shortfall.filter((g) => g.hashed === 0).length,
|
|
493
|
+
groups_partially_hashed: shortfall.filter((g) => g.hashed > 0).length,
|
|
494
|
+
files_not_hashed: shortfall.reduce((n, g) => n + g.files - g.hashed, 0),
|
|
495
|
+
duplicate_groups: duplicates.length,
|
|
496
|
+
reclaimable_bytes: reclaimable,
|
|
497
|
+
reclaimable_human: formatBytes(reclaimable),
|
|
498
|
+
shown: Math.min(limit, duplicates.length),
|
|
499
|
+
omitted_by_limit: Math.max(0, duplicates.length - limit),
|
|
500
|
+
warnings,
|
|
501
|
+
unreadable,
|
|
502
|
+
method:
|
|
503
|
+
'Grouped by exact byte size, then (for files over 64 KiB) by SHA-256 of the ' +
|
|
504
|
+
'first 64 KiB, then confirmed by SHA-256 of the entire file. Filenames are not ' +
|
|
505
|
+
'used to decide identity; `names_differ` is reported only so you can see when ' +
|
|
506
|
+
'copies were renamed. Names that are hardlinks to one file (same device and ' +
|
|
507
|
+
'inode) are one copy: they are listed under `hardlinked_names` and never counted ' +
|
|
508
|
+
'as reclaimable. APFS clones also share storage but cannot be told apart from ' +
|
|
509
|
+
'real copies, so for them reclaimable_bytes over-states what trashing frees. ' +
|
|
510
|
+
'`oldest_copy` is the copy modified earliest; a tie goes to the shorter path, ' +
|
|
511
|
+
'then to the path in code-unit order. Empty files are excluded unless min_bytes: 0.',
|
|
512
|
+
groups: duplicates.slice(0, limit),
|
|
513
|
+
},
|
|
514
|
+
)
|
|
515
|
+
}
|