AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
A disk space screener for Windows in one exe. Honest numbers, the space a scan cannot see, and the vendor's own cleanup command
Developer toolsC# · TypeScriptGitHub @egorb2302OnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
pathmemo: Disk Scan & Cleaner for Windows systems on C#
Translation pending
Taken from the repository README
Loading the full README. The saved excerpt is shown below.
Disk space screener for Windows. One .exe, no installer. Finds where the space went, and frees it safely.
Double-click it for a window; type it for a command line. The same one file does both.
Site: pathmemo.pages.dev — the treemap of a real scan, the reclaim rules with their commands, and the download.
Spec version: 3.0
Target: Windows 10 1809+ / Windows 11 (x64 and arm64, separate binaries)
Language: English throughout — CLI, TUI, logs, exports, and this document.Read the full README ↗Disk space screener for Windows. One .exe, no installer. Finds where the space went, and frees it safely.
Double-click it for a window; type it for a command line. The same one file does both.
Site: pathmemo.pages.dev — the treemap of a real scan, the reclaim rules with their commands, and the download.
Spec version: 3.0 Target: Windows 10 1809+ / Windows 11 (x64 and arm64, separate binaries) Language: English throughout — CLI, TUI, logs, exports, and this document.
Download the zip for your architecture from Releases, unpack it anywhere, run pathmemo.exe. Nothing is installed, and pathmemo writes nothing outside %LOCALAPPDATA%\pathmemo; a winget install keeps the exe itself in winget's own packages folder. The executable is not code-signed, so Windows warns about an unknown publisher: More info → Run anyway, or check the published SHA-256 first. What each release contains — and what it cannot do yet — is in CHANGELOG.md.
pathmemo # double-click: the window (§24). From a terminal: help
pathmemo --tui # the terminal screens instead, in any terminal
pathmemo scan # scan every fixed volume
pathmemo top --min 1GB # largest files
pathmemo audit # where the invisible space went
pathmemo reclaim # what is worth deleting, by rule, with risk and recovery
pathmemo diff # what changed since the previous scan
pathmemo rm <path> # delete, into quarantine by default (restore, purge, ops)
Administrator rights are optional but change what the tool can see: with them the scanner reads $MFT directly — roughly 40x faster, and it sees paths a directory walk is denied (§4.1).
| Phase | Contents | State |
|---|---|---|
| P0 | Skeleton, manifest, single-file publish, doctor | done — 16.1 MB exe |
| P1 | Walk scanner, .pmsnap format, scan / tree / top / history | done — 1.22M files in 41 s |
| P1+ | Double-click session: volume status, menu, line-by-line navigation | done — stand-in until P5 |
| P2 | Space Audit: 21 probes, audit / --id / --copy / --json, [E] restart elevated | done — 4.1 s unelevated (16 probes answer without rights) |
| P3 | MFT scanner, hard-link dedup in both scanners, ADS, volume reconciliation, --scanner | done — 1.27M files in 9.6 s, 1.4% unaccounted |
| P4 | SQLite (schema §11, WAL), history, diff, --format json|csv, --data-dir | done — diff of two 1.6M-node snapshots in 105 ms |
| P5 | TUI: Overview + Tree, own renderer, status | done — 0.1–0.6 ms per frame, 56 ms to search 1.58M nodes |
| P6 | Deletion: PathGuard, HandleTreeDeleter, quarantine, journal, rm / restore / purge / ops, audit --apply | done — canary intact after 10k junction-swap races (19,478 swaps, 114 s) |
| P7 | Reclaim rules: 31 rules as data, two axes, reclaim / --rule / --apply, keep-list, TUI screen 4 and badges | done — 1.2M nodes matched and 1,800 matches guard-checked in 2.5 s |
| P8 | Duplicates: five stages, hash cache, dupes / --group / --apply, TUI screen 5 | done — stage 0 cut 1.23M files to 8,508 candidates in 0.44 s |
| P9 | USN incremental scan, scheduling | done — 337 tests green; the rescan is checked against a full scan of the same real tree, and the elevated journal read is not verified on hardware (§4.5.1) |
408 tests green — and green on a CI runner, which is a different claim and was not true until the elevation seam of §22.5. Ten of them drive the window's Reclaim tab through the shell, from the frame drawn while the rules are still running to the way back out of a rule, and two more hold the scan card's lines clear of its button, which a screenshot found and no test had looked for (§24.3, §24.6). Four pin the rules 0.2.3 added (§7.2), one of them that a Rust target\deploy and its program keypairs stay out of every rule; two from 0.2.4 keep the Cargo registry and VS Code's extension packages in rules of their own, and keep cargo clean, which would take target\deploy with it, out of every rule's advice. Two are new to 0.2.2 and exist because the one test that compared the two scanners had never run; when it finally did, it found the walk reporting logical sizes as allocated ones — 3 GB on this machine's C:, and the syscall that produced the wrong number was costing a scan three quarters of its time (§22.5). Six more came with 0.2.1, because v0.2.0 shipped a window that could not be navigated on a machine with nothing scanned yet: clicking Tree, or a volume card, sent two methods calling each other until the stack ran out (§24.6). Everything around that code was tested; those five lines were the only ones that needed a window, so they were the only ones that were not.
Known limitations. Unelevated, the walk scanner runs: some paths are unreadable, hard-link dedup covers only files ≥ 1 MB (WinSxS overstated by ~1.5 GB), ADS are not counted. Elevation removes all three. An incremental rescan needs elevation too, because reading the change journal does — unelevated, every scan is a full one, and doctor says so (§4.5.1). diff needs two full snapshots and warns when they came from different scanners, because part of the difference is then the scanners, not the disk. reclaim counts a multiply-linked file as shared rather than reclaimable, because .pmsnap v1 carries no file identity — the number understates rather than overstates (§7.5). dupes reads the candidate files themselves, so it is minutes where everything else is seconds; --estimate says how many before committing to it, and the hash cache makes the second run cheap (§8.6). The window shows the volumes, the tree and the reclaim report; it reports and does not delete, so reclaim --apply and the terminal's screen 4 are still where space is actually freed, and audit and duplicates are still the commands and the terminal screens — each of those tabs says so rather than pretending otherwise (§24.3). The published executable is still a framework-trimmed single file rather than NativeAOT: the code compiles to native with no warnings, but the machine that built this has no platform linker, so the 12 MB / 15 ms figure of §19.4 is a compilation that was never linked.
pathmemo answers two questions: where did the space go — including what a filesystem walk cannot see — and what can be freed safely, with an honest estimate of how many bytes come back.
| № | Principle | Consequence |
|---|---|---|
| P1 | Honest numbers. The scan total must reconcile with the volume's used space, and any gap must be explained. | Allocated size, hard links, sparse files and clusters all count; the "unaccounted" delta is shown with its causes. |
| P2 | Scan everything, protect selectively. | Excluded from accounting ≠ protected from deletion. Two independent lists; the default exclusion list is nearly empty. |
| P3 | Freed space is measured, not assumed. | Free space is read before and after every operation, and the real delta shown. |
| P4 | The right way to clean beats deleting files. | For caches and system stores, print the vendor's own command (DISM, git gc, docker prune) rather than hand a path to rm. |
| P5 | Nothing is deleted without an explicit user action. | No auto-clean, no one-click "optimisation". |
| P6 | Deletion is auditable and reversible where possible. | Quarantine, journal, dry-run. The Recycle Bin only where it actually helps. |
| P7 | Do not harm the disk being cleaned. | The tool's own data is bounded and never exceeds 500 MB. |
| P8 | Scriptable on equal terms with interactive. | Every action is reachable from the CLI, with stable JSON and meaningful exit codes. |
| P9 | Works in any terminal. | No bindings a terminal intercepts. Minimum 80×24. |
| P10 | Untrusted data is data. | Names, config and paths are input, never commands: sanitised on render, nothing executed from config, found files not executed by default. |
Explicitly out of scope: a web app, a browser-hosted interface, or any front end that needs a runtime installed alongside the exe; deletion without confirmation, "system speed-up", registry cleaning, anything of the optimiser genre; managing security settings (Defender exclusions, UAC, policy) — the tool suggests a command, the user runs it; a server, multi-user mode or cloud sync; perceptual hashing and similar-file search; network and removable drives as a primary scenario (supported degraded, not optimised); Linux and macOS — abstractions for them are deliberately not built in advance (§17.1).
| Condition | Behaviour |
|---|---|
| Double-clicked from Explorer (the console was created for us) | the window (§24); the console Explorer made is hidden |
gui / --gui | the window, from any terminal |
--interactive / -i / --tui | the terminal screens, from any terminal |
| No window can be created (no desktop, a service account, SSH) | the terminal screens, then the line-based menu |
| No arguments, from a terminal | help |
| Arguments present | that command, non-interactive |
| stdout redirected, no arguments | status — a text summary |
| stdout redirected | always non-interactive, UTF-8 output |
| Terminal cannot do ANSI | line-based menu instead of the TUI |
Explorer launches are told apart from shell launches through GetConsoleProcessList: exactly one process attached to the console means the window was created for us and dies with us. Without that check, double-clicking gives a window that flashes for a fraction of a second.
What double-clicking gets (P5). A window created by Explorer has nobody to inherit sensible settings from, so we set them: the title (pathmemo <version> rather than the full exe path), the size (up to 110×32, and only when the window is ours), quick-edit off, and a glyph set matched to the console font. The exe carries an icon (§19.1). A fatal error in our own window waits for Enter instead of taking the message down with it.
Quick-edit is the one item that is not cosmetic: with it on, a click inside the window starts a selection and the next console write blocks until the selection is cleared. The screen freezes and the user has no idea why. The mode is cleared while the screens are up and restored on exit.
From P5 an interactive session is the real screens (§14): TuiHost enables VT processing, switches to the alternate buffer and draws frames. If VT cannot be enabled or the stream is redirected, the older line-based session runs instead — in a host without VT a frame would appear as literal escape sequences, which is worse than no TUI.
Line-based fallback menu: [A] audit, [B] browse, [S] scan C:, [V] scan all volumes, [L] largest files, [C] compare the last two scans, [H] history, [D] diagnostics, [E] restart as administrator (only when unelevated), [Q] quit. [E] relaunches the same exe through ShellExecute with the runas verb: the manifest stays asInvoker, so rights are requested only when asked for. Elevation has no dedicated TUI key — the scan itself offers it (§14.7).
Without this section the numbers do not add up and the tool is useless.
| Term | What it is | Source |
|---|---|---|
| Logical size | The data stream's length (what FileInfo.Length shows). | MFT DataSize / FILE_STANDARD_INFO.EndOfFile |
| Allocated size | Bytes actually occupied: cluster rounding, NTFS compression, sparse holes. | MFT run lists / logical size rounded up to the cluster, GetCompressedFileSize for compressed and sparse files only |
| Unique allocated | Allocated with hard-link dedup: each physical file counted once. | Dedup by (VolumeSerial, FileReferenceNumber) |
| Reclaimable | What deleting this particular set of paths frees. | Unique allocated, but only for files whose links are all inside the set |
Every size in the dashboard, tree and reports is unique allocated by default — the only quantity that adds up to the volume's used space. File details show logical, allocated and the link count. The delete dialog shows reclaimable, not the sum of sizes, and explains a difference ("12 GB selected, 3.1 GB reclaimable: 8.9 GB is shared via hard links with files outside the selection"). Mode switch: m in the TUI, --size logical|allocated|unique in the CLI.
C:\Windows\WinSxS consists almost entirely of hard links to files in System32. Without dedup, C:\Windows shows 2–3x its real size.
Every file gets the key (VolumeSerialNumber, FileReferenceNumber). The first occurrence in traversal order owns the bytes, the rest are aliases (NodeFlags.HardlinkAlias), and unique allocated aggregates over owners only. The link count is stored per node, which answers "will deleting this free anything" instantly; aliases are marked in the UI and their size greyed: 1.2 GB (link).
Degraded mode (walk scanner): FileReferenceNumber needs an open handle, so it is read only for files over 1 MB — a few percent of the count, and hard-linked duplicates below that size contribute negligible error. SnapshotFlags.PartialHardlinkResolution tells the UI to warn.
Implemented (P3): the handle is opened with FILE_READ_ATTRIBUTES + FILE_FLAG_OPEN_REPARSE_POINT; FILE_STANDARD_INFO.NumberOfLinks is read first and FILE_ID_INFO only when it is > 1. The ids live in a side list per directory rather than in RawEntry (16 bytes × 1.5M entries). C: has 16k files ≥ 1 MB, costing about 3 s out of 44. The owner is the first occurrence in BFS order, so a file present as System32\foo.dll and WinSxS\amd64_…\foo.dll is owned by the shorter path. The MFT scanner follows the same rule.
Sparse files have AllocatedSize < LogicalSize — typical for ext4.vhdx (WSL), databases and images. This matters: an 80 GB logical WSL disk may occupy 12 GB, or all 80. NTFS compression is the same effect for a different reason, handled identically through AllocatedSize. When allocated / logical < 0.9 the UI adds a sparse or compressed badge.
An MFT scan sums every $DATA attribute of a file automatically. The walk scanner does not see ADS — a documented limitation, under 0.1% error on a typical system.
After every scan:
Volume C: total 476.1 GB free 21.3 GB used 454.8 GB
Scanned files 381.2 GB
Inaccessible paths (147) ~? GB
Shadow copies (VSS) 38.4 GB
Recycle Bin 4.1 GB
NTFS metadata ($MFT, $LogFile, ...) 2.9 GB
Reserved for system 1.1 GB
─────────────────────────────────────────────────
Unaccounted 27.1 GB [?] explain
[?] explain lists the possible causes with commands to check each, which turns "the numbers don't add up" from a bug into a feature. Sources: GetDiskFreeSpaceExW, FSCTL_GET_NTFS_VOLUME_DATA, SHQueryRecycleBinW, WMI Win32_ShadowStorage.
| MFT scanner (primary) | Walk scanner (fallback) | |
|---|---|---|
| Mechanism | \\.\C: + FSCTL_GET_NTFS_VOLUME_DATA + direct $MFT parsing (§4.3.1) | FileSystemEnumerator<T> |
| Needs admin | yes | no |
| Filesystem | NTFS only | any |
| 1M files | 3–8 s | ~12 s on an SSD, minutes on an HDD |
| Allocated size | yes, free | cluster rounding, free; GetCompressedFileSize (+1 syscall) for compressed and sparse files |
| FileReferenceNumber / LinkCount | yes, free | files > 1 MB only |
| ADS | yes | no |
| Sees ACL-denied paths | yes | no |
The MFT path is the primary one: an order of magnitude better numbers, two orders of magnitude more speed, and it sees what an ordinary walk is denied. Core of the product, not a later optimisation.
for each target volume:
NTFS and elevated -> MFT scanner
NTFS and not elevated -> offer to relaunch elevated;
if declined, Walk scanner with the Degraded flag
otherwise -> Walk scanner
Relaunch is ShellExecuteEx with lpVerb = "runas" and the same command line plus --elevated-relaunch; from the CLI only when there is a TTY, and in script mode a warning is printed and the degraded path used.
Running without administrator rights.
· Fast MFT scan unavailable (falling back to directory walk: ~40x slower)
· Files in other user profiles and protected folders will be missed
· Hard link detection limited to files over 1 MB
[R] Restart as administrator [C] Continue anyway [Q] Quit
Open the volume, read FSCTL_GET_NTFS_VOLUME_DATA for cluster size, $MFT size and record count, then parse $MFT records directly: $STANDARD_INFORMATION and $DATA (resident and non-resident, AllocatedSize, RealSize, run totals for sparse). One sequential pass yields names, parents, sizes, attributes, times and link counts. parentId → id forms a graph rooted at record 5; one bottom-up pass produces full paths without string concatenation. Deleted and extension records are dropped. Any parse failure — a non-standard volume, a locked BitLocker volume, corruption — falls back to the walk scanner, with the reason in scan_errors.
FSCTL_ENUM_USN_DATA is not used at all. It gives one name per file, while hard-link dedup needs every $FILE_NAME. Full $MFT parsing gives everything in one pass — MftParser (a pure function over bytes, covered by synthetic records) and MftScanner.$MFT: record 0 is read at MftStartLcn, and its $DATA runs give the extents. Under heavy fragmentation the continuation runs live in extension records pointed at by record 0's $ATTRIBUTE_LIST, which are read too. FSCTL_GET_RETRIEVAL_POINTERS is not needed.\\.\C: is opened with GENERIC_READ (the only thing requiring elevation); reads are positional via ReadFile with OVERLAPPED.Offset, in 8 MB chunks, double-buffered so the next chunk arrives while the current one is parsed. One or two GB of sequential I/O, not a single file opened.scan_errors. Never-written slots are normal.BaseRecord, with a sequence-number check so a stale reference to a reused slot is dropped.RealSize from the first $DATA part; allocated is the sum of the real data runs of every part, holes excluded. That is the only rule equally correct for ordinary, sparse and compressed streams and for $BadClus:$Bad — a stream the size of the whole volume with no sparse flag, which produced "−220 GB unaccounted" on the first run. All $DATA streams are summed, so ADS are counted. Resident data costs 0 clusters: it lives inside the $MFT record, which the ntfs.metadata probe already counts.$STANDARD_INFORMATION, bit 0x40000 means "has extended attributes" (every file from a WIM image, and C:\Windows itself), while Win32 uses the same value for RECALL_ON_OPEN. Unmasked, half of Windows showed up as [cloud]. Rule: recall bits count only on a reparse point; OFFLINE always counts.HardlinkAlias; a directory met twice (corruption) is not walked again. Records whose parent is deleted or reused are orphans: counted and reported, not attached.A custom FileSystemEnumerator<RawEntry> rather than Directory.EnumerateFileSystemEntries, which allocates a full path per entry and needs a separate stat for the size:
protected override RawEntry TransformEntry(ref FileSystemEntry entry) => new()
{
Name = entry.FileName, // ReadOnlySpan<char>, copied into a shared blob
Logical = entry.Length, // from FILE_FULL_DIR_INFO, no syscall
Attributes = entry.Attributes,
MTime = entry.LastWriteTimeUtc,
IsDir = entry.IsDirectory,
};
protected override bool ShouldRecurseIntoEntry(ref FileSystemEntry entry)
=> entry.IsDirectory
&& (entry.Attributes & FileAttributes.ReparsePoint) == 0; // count the point, never enter it
EnumerationOptions: RecurseSubdirectories = false (recursion is ours), IgnoreInaccessible = false (errors belong in the report, not in silence), AttributesToSkip = 0 (nothing hidden — not Hidden, not System), BufferSize = 64 * 1024, MatchType.Win32.
Allocated size is cluster rounding, not a syscall. FILE_FULL_DIR_INFO carries the logical size and the attributes. For a file that is neither Compressed nor SparseFile the allocation is that size rounded up to the volume's cluster, and only for those two is GetCompressedFileSize asked — they are the only files it has a different answer for; for any other it returns the logical size. The walk used to ask it for every file, which cost 1.2M syscalls a scan and produced the wrong number anyway (§22.5): dropping the call took an unelevated walk of C: from 41–61 s to 13.5 s. The audit's DirectoryMeasure measured the same way from the start (§6.4).
Parallelism is work-stealing, not partitioning by level. A ConcurrentQueue<DirTask>, N workers, each taking a directory and pushing its subdirectories back. Partitioning "by second-level folder" was rejected: C:\Windows\WinSxS is ~40% of all files on the disk, and one worker would grind through it alone.
N follows the medium, via IOCTL_STORAGE_QUERY_PROPERTY → StorageDeviceSeekPenaltyProperty: HDD → 2, SSD → min(8, CPU), unknown or network → 4. MaxDegreeOfParallelism = CPU count makes an HDD scan 2–3x slower than single-threaded, so the config default of 0 means auto-detect, not "one per core".
The snapshot stores (VolumeSerial, UsnJournalId, NextUsn). On a later scan, FSCTL_QUERY_USN_JOURNAL confirms the journal was not reset; FSCTL_READ_USN_JOURNAL from the saved USN lists the changed records; those are re-read and aggregates recomputed up the tree. Otherwise a full scan runs. Result: 0.2–2 s instead of 5, which is what makes history, diff and hourly scheduled scans practical. If the journal is off, the tool suggests fsutil usn createjournal m=32000000 a=8000000 C: for the user to run.
Reason is read and ignored, and only ParentFileReferenceNumber is collected. UsnRecords parses V2 and V3 records (the 128-bit ids of V3 move every offset after them by sixteen bytes; on NTFS the upper half is zero, so both collapse to the same number)..pmsnap v1 stores no file identity — the same gap that shaped the duplicate search (§5.3, §8.6) — so a reference number cannot be looked up in the tree. OpenFileById plus GetFinalPathNameByHandle, two syscalls per distinct changed directory, answers it; results are cached, so a rescan pays them a few hundred times rather than a few hundred thousand. A reference number that will not open is a directory that has since been deleted, and that is not an error: its removal was recorded against its parent, which will be read. The sequence number in the high bits is what makes this safe — a record slot reused by another file fails to open instead of resolving to the wrong path.FSCTL_QUERY_USN_JOURNAL at any access level and fails FSCTL_READ_USN_JOURNAL with ERROR_ACCESS_DENIED; \\.\C: opened with no access or FILE_READ_ATTRIBUTES fails the query with ERROR_INVALID_FUNCTION, because such a handle never reaches NTFS at all (the same trap the ntfs.metadata probe documents); \\.\C: with GENERIC_READ cannot be opened. So the query goes through a directory handle and the read through the volume, and an incremental scan is an elevated path — which pairs it exactly with the MFT scanner. An ordinary user still gets the journal's state from doctor, and a plain reason why the rescan will take nine seconds.FSCTL_CREATE_USN_JOURNAL is deliberately absent. Creating a journal changes the volume, and a tool whose promise is that looking costs nothing does not quietly enable a kernel facility. The command is printed for the user to run, like every other remedy (§6.3).| Entity | Recurse | Size counted | Why |
|---|---|---|---|
| Reparse point (junction, symlink, mount point) | no | the point itself = 0 bytes, flagged Reparse | Prevents infinite recursion and double counting; the target is shown in details. |
Cloud placeholder (RECALL_ON_*, OFFLINE) | — | allocated (usually ~0), flagged CloudOnly | A cloud-only file occupies nothing, and saying so matters: "50 GB in cloud, 0 GB local". |
| Hidden / System files | yes | yes | Skipping them hides hiberfil.sys, pagefile.sys and AppData — the most important things. |
$Recycle.Bin | yes | yes, as its own category | This is reclaimable space. |
System Volume Information | no access even as admin | covered by the VSS probe | |
| pathmemo's own data | yes | yes, flagged SelfData | Honesty: the tool shows itself too. |
| Mount point of another volume | no | 0 | Otherwise the disk is counted twice; that volume is scanned separately. |
Refreshed every 250 ms from a renderer thread reading volatile counters, so the hot path takes no locks.
Scanning C:\ · MFT mode
1,284,391 records · 412.8 GB · 198k rec/s · 6.4 s elapsed
$MFT: 78% ████████████████████░░░░░
Walk mode shows the current directory and files/s, and no ETA: the file count is unknown in advance, and a fake percentage is worse than none. The MFT percentage is honest — the record count is known up front.
Ctrl+C or Esc sets a CancellationToken. The partial result is saved, with status cancelled and the Partial flag: fit for browsing, not for diff or reclaim advice. A second Ctrl+C within 3 s exits immediately without saving, in case a save hangs on a network drive. The handler only sets the token; the main thread writes, with a 10 s timeout.
Every failure becomes a ScanError { Path, Kind, Win32Code, Message }, grouped by kind:
AccessDenied 104 paths (~? GB)
SharingViolation 12 paths
PathTooLong 3 paths
NameInvalid 1 path (created by WSL: contains ':')
IoError 0 paths
In MFT mode the size behind AccessDenied is known — metadata is read past the ACL — so the exact figure is shown.
P10: pathmemo errors [<scan-id>] prints that table for a stored scan, and then the paths themselves, --limit per kind and --kind for one of them. Three details decide whether such a list is usable:
Access to the path '...' is denied, which is the kind and the path again; under 527 entries it doubles the length of the output and adds nothing. A Win32 code, or a message that says something else, is printed.--sizes opens the tree too and says how many of the refused paths are in it and what they hold: after an unelevated walk the honest answer is "all of them, holding 0 B", because a directory that would not open has nothing under it; after an MFT scan the bytes are real.A million files as SQLite rows is 150–250 MB per scan, and with history that is tens of gigabytes: the tool for freeing space would eat the disk (violating P7). Inserting a million rows takes seconds, and reading them back for the tree takes seconds again. The answer: a binary snapshot per scan, with SQLite for metadata and journals only.
%LOCALAPPDATA%\pathmemo\
├── pathmemo.db # SQLite (WAL): history, deletion journal, rules, hash cache
├── config.json
├── snapshots\0000000042.pmsnap # ~10–25 MB per 1M files
├── quarantine\0000000043\ # §9.4
├── logs\
└── exports\
.pmsnap v1 structureHEADER (64 bytes, uncompressed)
magic u8[8] "PMSNAP\x01\x00"
formatVersion u16 1
toolVersion u32 packed semver
createdAtUtc i64 unix seconds
nodeCount u32
volumeCount u16
flags u32 Partial | Degraded | PartialHardlinkResolution | Elevated | Incremental
sectionCount u16
SECTION TABLE (sectionCount x 16 bytes)
kind u32, compression u32 (0=raw, 1=deflate), rawLen u64, storedLen u64, offset u64
SECTIONS (each compressed independently)
NAMES : length-prefixed UTF-8 name segments, deduplicated (not full paths)
NAME_IDX : u32[] offsets into NAMES
NODES : struct-of-arrays, below
VOLUMES : letter, label, fs, serial, clusterSize, total, free, mftSize,
metadataSize, usnJournalId, nextUsn
ERRORS : ScanError[]
AGGREGATES: precomputed tops, optional cache
As built: the journal watermarks are their own section (USN, kind 6) rather than three more fields on each VOLUMES entry. That is what keeps the format at version 1 across P9: a reader looks the section up instead of indexing it, so a snapshot written before the section existed still opens and simply cannot be the base of an incremental rescan (§4.5.1). Appending fields to VOLUMES would have made every earlier snapshot unreadable — and there were five real ones on the author's disk at the time, which is exactly the situation a format version is supposed to survive. Verified against a 27 MB pre-P9 snapshot: it imports, history and tree read it, and the next scan over it is a full one with the reason printed.
Nodes are ordered so that each directory's children occupy a contiguous range (emission order is BFS), which gives O(1) navigation and linear reads while drawing.
Sort-by-size is deliberately not baked in: subtree sums are known only after a bottom-up aggregation pass, so ordering the stored arrays would need a second permuting pass that rewrites every child pointer — a fragile trade for something that costs milliseconds at render time. TreeQuery.ChildrenBySize sorts on display.
| Array | Type | Bytes | Purpose |
|---|---|---|---|
parent | i32[] | 4 | parent index, -1 at a volume root |
nameIdx | i32[] | 4 | index into NAME_IDX |
firstChild | i32[] | 4 | first child index, -1 if none |
childCount | i32[] | 4 | direct children |
allocated | i64[] | 8 | own size for a file, unique subtree sum for a directory |
logical | i64[] | 8 | the same in logical bytes |
fileCount | i32[] | 4 | files in the subtree |
mtime | u32[] | 4 | seconds since 2000-01-01 UTC (good to 2136) |
attributes | u32[] | 4 | Win32 FileAttributes |
flags | u8[] | 1 | IsDir, Reparse, HardlinkAlias, CloudOnly, Sparse, SelfData, Encrypted, HasAds |
linkCount | u8[] | 1 | hard links, 255 means "255 or more" |
| 46 |
1M nodes = 46 MB of arrays plus ~14 MB of name blob (segments are deduplicated: Microsoft, bin, node_modules recur tens of thousands of times) ≈ 60 MB in memory, 12–20 MB on disk after deflate. Loading is a MemoryMappedFile with sections decompressed on demand; the tree needs only parent/nameIdx/firstChild/childCount/allocated/flags.
Deliberately absent: createdAt (almost never decides whether to delete something); accessedAt — dead data on Windows, since NtfsDisableLastAccessUpdate has been on by default since Vista, and "last opened 3 years ago" for a file opened yesterday misleads; full paths, rebuilt by walking parent in microseconds.
Keep: the last 20 snapshots
+ one per calendar month for the last 12 months
Hard cap: snapshots\ <= 400 MB; over that, drop the oldest monthlies first,
then the oldest of the last 20 (never the 3 newest)
Scan metadata (a row in scans) lives forever — ~200 bytes, and it is what draws the used-space graph years back. The snapshot may already be gone: the row is then marked snapshot_available = 0 and the scan is viewable as aggregates only.
Implemented in P4: a snapshot dropped by retention clears snapshot_path, history prints deleted instead of a size, and diff refuses to open it with an explanation. The numbers remain.
§5.2 promised sections that are "compressed and indexed independently so a reader can load only what it needs". Until P10 the reader loaded all of them, and it did it the expensive way: each section was read into a byte[], inflated into a second byte[], and the node section was then copied out of that into twelve arrays. A 1.58M-node tree is 68 MB of arrays, so the peak held 68 MB of arrays plus a 68 MB packed copy plus the compressed bytes — which is exactly how an open snapshot came to hold 200 MB against a 150 MB budget (§20).
Two changes, both in SnapshotFile.Read:
ReadExactly fills each node array in turn, in the order the writer produced them. There is no packed intermediate at all. Object initialisers are evaluated in source order, which is what makes twelve positional reads correct — the same property the writer already relied on.SnapshotParts flag says what the caller will actually read. Tree (NODES, NAMES and ROOTS, which are one thing in practice), Volumes, Errors, Usn, or Meta for the header alone. A part that was not asked for is empty rather than absent — the tree is a shared NodeStore.Empty — so a caller that reads it gets nothing instead of an exception, and SnapshotContents.Parts says which of the two happened.Measured on a real 27 MB snapshot of C: (1,583,976 nodes, a 24 MB name blob), in-process, before and after:
| before | after | |
|---|---|---|
| Time to open, everything | 226 ms | 181 ms |
| Working set the open snapshot costs | +165.6 MB | +94.1 MB — the tree itself, and nothing else |
| Peak vs final working set | equal | equal — there is no transient left to peak on |
| The ERRORS section alone | 211 ms (the whole file) | 0.8 ms |
Whole process, errors | 206 ms | 12 ms |
Whole process, RSS with the tree open (tree) | — | 121 MB peak |
| The TUI, tree open, real console | 182–201 MB | 134.9 MB peak |
The write path was left alone. Its transient is real too, but the budget it lives under (400 MB during a walk scan, 373 MB measured) is met, and a format writer that works is not worth rebuilding for a number that already passes.
A filesystem walk physically cannot see half of what fills a disk. This is a separate subsystem of probes, and for the "the disk is filling up hard" scenario it is worth more than the scan.
Each probe returns an AuditFinding:
record AuditFinding(
string Id, // "vss.shadow-storage"
string Title, // "Volume Shadow Copies"
string Volume,
long UsedBytes,
long ReclaimableBytes, // what actually comes back
Risk Risk, // Safe | Caution | Danger
Recoverability Recoverability,
string Explanation, // 1-3 sentences
Remedy[] Remedies);
record Remedy(
RemedyKind Kind, // RunCommand | DeletePaths | OpenSettings | Manual
string Display, // "dism /Online /Cleanup-Image /StartComponentCleanup"
bool NeedsElevation,
bool NeedsReboot,
string? Caveat); // "Removes ability to uninstall installed updates"
| ID | What it measures | Typical | Cleanup |
|---|---|---|---|
vss.shadow-storage | Shadow copies / restore points, via CIM (powershell.exe -NoProfile from System32, JSON out so byte counts are integers in any locale); elevated only | 5–60 GB | vssadmin delete shadows /for=C: /oldest, or shrink the quota |
winsxs.component-store | The genuinely removable part of the component store: DISM /English /Online /Cleanup-Image /AnalyzeComponentStore, parsed by label : value position rather than by words; elevated only | 2–12 GB | DISM /Online /Cleanup-Image /StartComponentCleanup /ResetBase ⚠️ |
windows.old | Previous Windows installation | 10–30 GB | cleanmgr handler Previous Installations |
wu.softwaredistribution | Windows Update cache | 1–20 GB | stop wuauserv + bits → clear → start |
wu.delivery-optimization | P2P update delivery cache | 1–10 GB | Delete-DeliveryOptimizationCache |
windows.installer-orphans | Orphaned MSI/MSP in %WINDIR%\Installer, cross-checked with the registry | 2–15 GB | ⚠️ report only; auto-deletion breaks uninstall |
hiberfil | Hibernation file: size from the root listing (no file opened) plus registry HiberFileType | 0.4 × RAM | powercfg /h /type reduced or /h off |
pagefile | Page file, plus Win32_PageFileSetting | 1–32 GB | System Properties → Virtual Memory |
swapfile | UWP swapfile | 256 MB | with the pagefile |
recyclebin | Recycle Bin per volume, via SHQueryRecycleBinW (SHQUERYRBINFO is 24 bytes with natural alignment; with Pack=1 the Shell answers E_INVALIDARG) | 0–50 GB |
$ pathmemo audit
Volume C: 476.1 GB total · 21.3 GB free · 95.5% used
RECLAIMABLE 38.4 GB
──────────────────────────────────────────────────────────────
Volume Shadow Copies 38.4 GB safe
12 restore points, oldest 2026-02-11. Windows keeps these for
System Restore and Previous Versions.
→ vssadmin delete shadows /for=C: /oldest [admin]
WSL2 virtual disks 31.2 GB caution
Ubuntu-22.04: 44.1 GB on disk, 12.9 GB used inside. WSL disks
never shrink automatically.
→ wsl --manage Ubuntu-22.04 --set-sparse true
⚠ Shut down WSL first: wsl --shutdown
Component store (WinSxS) 6.8 GB caution
Hibernation file 6.4 GB caution
Recycle Bin 4.1 GB safe
Windows Update cache 2.9 GB safe
Crash dumps 1.2 GB safe
NOT RECLAIMABLE 3.9 GB
──────────────────────────────────────────────────────────────
NTFS metadata ($MFT 2.4 GB, $LogFile 0.1 GB, other 1.4 GB)
Run `pathmemo audit --id vss.shadow-storage` for details.
DISM /Analyze..., vssadmin list, powercfg /a) — Process.Start with ArgumentList, no shell, 30 s timeout, by absolute path from %WINDIR%\System32 (PATH-hijacking defence).DISM and vssadmin output is locale-dependent, so: invariant culture in the process environment, parsing by numbers and structure rather than English words, and Unknown rather than zero when parsing fails.Remedy is by default displayed and copied to the clipboard, nothing more. Execution needs an explicit pathmemo audit --apply <id> or a TUI key, always confirmed, always logged.AuditRunner runs probes in parallel (≤ 8 threads), each capped at 45 s; an exception or timeout becomes a finding with status=error, so one probe cannot take the report down. A probe that cannot measure returns NeedsElevation / NoSnapshot / Unknown / NotApplicable — never "0 bytes".UsedBytes and not ReclaimableBytes (free space inside a vhdx is visible only from inside, and booting a distro to ask is not a read-only act), so they land in a WORTH A LOOK section rather than the RECLAIMABLE total. pagefile is special: reclaimable equals its size if another fixed volume has > 20 GB free, and the probe suggests moving it there; otherwise 0.DirectoryMeasure): size on disk is the logical size rounded up to the cluster, and GetCompressedFileSize is called only for files flagged Compressed or SparseFile. The attributes are already in the enumeration buffer, so this is free: measuring a 175k-file %TEMP% took 20 s with a call per file and ~3 s without. Reparse points are neither counted nor entered; access errors are counted, not thrown — "a lower bound plus a note" beats an empty result.ExternalTool): %WINDIR%\System32\* by absolute path only, no window, no __COMPAT_LAYER, timeout with Kill(entireProcessTree), output decoded in the console OEM code page. Two invocations in the whole audit, both elevated-only: DISM (/English pins the format) and powershell.exe for CIM.--apply arrived with P6, once there was an operations journal (§9.7) to put it in. It is confirmed, it refuses rather than degrades when it lacks the rights a remedy declares, and it records what the free space actually did. --copy / --copy-index <n> (§15.1) and c<n> interactively remain the way to take a command elsewhere.Minidump, Defender\Scans\History and part of Windows\Logs cannot answer; 16 of 21 probes still do, in 4.1 s.audit_findings (§11) tied to the scan the probe used — 17 rows out of 21 on a real machine, since not applicable is not recorded. A single probe () writes nothing: a spot check, not a point of history.A single safe/caution/danger axis mixes incompatible things: deleting node_modules ("I lose 10 minutes") and deleting .git\objects ("I lose the work") end up in one bucket.
Axis 1 — Risk: what breaks. Safe — nothing; the application recreates it. Caution — functionality or a way back is lost (update uninstall, hibernate, history); reversible, but it takes work. Danger — may break the system or an application; requires typing a confirmation word.
Axis 2 — Recoverability: what getting it back costs. Instant — recreated automatically on next use (browser, thumbnail, shader caches). Redownload — fetched from the network (node_modules, .nuget\packages, Docker images). Rebuild — rebuilt locally, costs CPU time (obj/, Unity Library/, .venv). Irreversible — personal files, the only copy of an installer, .git objects.
The UI shows both: safe · redownload means go ahead; caution · irreversible means think.
Patterns are globs, not regex (§12.2). Every rule is data, not code.
| Rule | Pattern | Risk | Recov. | Right way |
|---|---|---|---|---|
dev.node_modules | **\node_modules | Safe | Redownload | delete; npm ci restores |
dev.npm_cache | %LOCALAPPDATA%\npm-cache, ~\.npm\_cacache | Safe | Redownload | npm cache clean --force |
dev.pnpm_store | %LOCALAPPDATA%\pnpm\store | Safe | Redownload | pnpm store prune |
dev.yarn_cache | %LOCALAPPDATA%\Yarn\Cache | Safe | Redownload | yarn cache clean |
dev.bun_cache | ~\.bun\install\cache | Safe | Redownload | bun pm cache rm |
dev.next | **\.next with a sibling package.json | Safe | Rebuild | delete; next build restores |
dev.playwright_browsers | %LOCALAPPDATA%\ms-playwright | Safe | Redownload | npx playwright uninstall --all |
dev.nuget | ~\.nuget\packages, %LOCALAPPDATA%\NuGet\v3-cache | Safe | Redownload | dotnet nuget locals all --clear |
dev.dotnet_artifacts | **\bin\{Debug,Release}, **\obj | Safe | Rebuild | delete |
dev.gradle | ~\.gradle\caches | Safe | Redownload | gradle --stop, then delete |
dev.maven | ~\.m2\repository | Safe | Redownload | delete |
dev.pip_cache | %LOCALAPPDATA%\pip\Cache |
* Redownload with the caveat "unless it's a license-bound installer".
Deliberately not rules: hiberfil.sys, pagefile.sys, swapfile.sys — in use and undeletable; they are an AuditFinding with a powercfg command. WinSxS — deleting it by hand breaks the system irreversibly; DISM only. C:\Windows\Installer — breaks uninstall and updates; report only. System Volume Information — VSS API only. .git\objects — destroys the repository; git gc only. A Rust target\deploy — holds the Solana program keypairs, which are the addresses the code declares, and a deleted one is an address nobody can deploy to again; only the SBF build directories beside it are rules.
Every match is scored by reclaimable (§3.1), not by the sum of sizes:
$ pathmemo reclaim --scan 43
RULE MATCHES SIZE RECLAIM RISK RECOVERY NOTE
──────────────────────────────────────────────────────────────────────────────────────────────
dev.node_modules 47 18.2 GB 18.2 GB safe redownload npm ci
dev.unity_library 3 12.4 GB 12.4 GB safe rebuild
app.browser_cache 6 4.1 GB 4.1 GB safe instant
dev.dotnet_artifacts 212 3.8 GB 3.8 GB safe rebuild
sys.temp 4 1.9 GB 1.9 GB safe instant contents only
dev.pnpm_store 1 7.6 GB 1.2 GB safe redownload 6.4 GB shared via hard links
dev.git_gc 8 2.2 GB 2.2 GB caution irreversible use git gc --prune=now
user.old_installers 23 6.7 GB 6.7 GB caution redownload
──────────────────────────────────────────────────────────────────────────────────────────────
safe only 273 43.5 GB 37.1 GB
including caution 304 52.4 GB 46.0 GB
pathmemo reclaim --scan 43 --dry-run preview what would be deleted
pathmemo reclaim --scan 43 --apply quarantine it (37.1 GB)
pathmemo reclaim --rule <id> the paths behind one row
--scan <id> which stored scan to read --dry-run, -n the plan and a token
--risk <l> safe (default) | caution | danger --apply delete through `rm`
--rule <id> the paths behind one rule --mode <mode> quarantine | recycle | permanent
--list the rule set, no scan needed --yes, -y skip the y/n question
--min <size> ignore matches below this --force required for --risk danger
--limit <n> paths shown with --rule --json machine-readable (§13.6)
--keep <path> add to protect.keep --disable / --enable <rule>
SIZE is what the rule found; RECLAIM is what deleting it gives back. The note column carries the one fact the two numbers cannot: a command that does the job better, bytes held by hard links, or how many paths the guard has already refused. Every match is opened and put to the guard while the report is built, so a row never promises space that rm would then decline to free (§9.3).
Everything lives in config.json, the single source of truth; the TUI and CLI edit that file.
{
"rules": {
"disabled": ["dev.venv", "user.old_installers"],
"custom": [
{ "id": "my.render_output", "patterns": ["D:\\renders\\**\\frames"],
"risk": "safe", "recoverability": "rebuild", "minSizeBytes": 104857600 }
]
},
"keep": ["C:\\Users\\me\\projects\\important\\node_modules", "D:\\archive\\**"]
}
keep holds paths and globs that never appear in recommendations and cannot be deleted through pathmemo. K in the TUI adds one.
A custom rule accepts every condition a built-in one uses — there is no private back door in the rule language: id, patterns, risk, recoverability, what, command, kind (directory | file | any), minSizeBytes, olderThan, requiresChild, requiresSibling, notUnder, contentsOnly. A custom rule whose id is a built-in one replaces it, which is how one threshold is changed without retyping the table. An entry that will not parse is skipped with a warning naming it, and the rest of the file still applies.
The live paths into the same file: pathmemo reclaim --keep <path>, --disable <rule>, --enable <rule>, and in the TUI K — on a rule it disables the rule, on a path it adds that path to protect.keep. Edits go through JsonNode, so keys pathmemo knows nothing about survive, and the file is written through a temporary and moved into place: a half-written config.json is a tool that will not start.
Risk and Recoverability already existed in Audit/ from P2 (§6.1); the rules use those enums. Two identical enums in two namespaces is how a safe that means different things in two screens gets born.git gc, docker system prune), or a person must decide (iPhone backups, disk images). That is ReclaimAction, and Command and Manual rules are never acted on, whatever the flags say — --apply --risk danger --force still leaves .git alone. Risk says how much it hurts if you are wrong; the action says who is allowed to do it at all.node_modules, obj), an extension (*.pyc, *.log), or a wildcard (Cache*, thumbcache_*.db), and even the last is screened by its literal prefix and suffix before a glob runs. A node's full path is built only for a candidate — 1.2M nodes against 31 rules in a few hundred milliseconds, against several seconds for the obvious implementation (§12.2).**\*.pyc under a rule that says kind: any does not claim a directory somebody named weird.pyc. A rule that really means such a directory says kind: directory.{Debug,Release} of §7.2 is written as separate patterns. One more metacharacter buys one line of table and costs every reader of every pattern.dev.unity_library needs both an ArtifactDB inside and an Assets beside it, or every directory called Library on the disk is a Unity project. dev.venv needs a pyvenv.cfg, or a folder of holiday videos called venv is a virtual environment. sys.old_logs has a 1 MB floor: without one it matches tens of thousands of two-kilobyte files, and a report nobody can read is the same as no report.Stage 0 filter
· size >= minSize (1 MB by default), size != 0
· not CloudOnly, not Reparse, not Encrypted, not in `keep`
· group by size; unique sizes dropped
Stage 1 hard-link collapse
· files sharing (VolumeSerial, FileReferenceNumber) are ONE file:
grouped as a `HardlinkSet`, saving from deletion = 0
Stage 2 partial hash - XxHash128 of the first and last 64 KB
(or the whole file when <= 128 KB); singletons dropped
Stage 3 full hash - XxHash128 streamed, 1 MB buffer, ArrayPool
Stage 4 byte-for-byte verification of the finalists
· pairwise inside the group, 1 MB blocks
· a MATHEMATICAL guarantee instead of a probabilistic one, for the same IO
· few groups survive stage 3, so it is cheap
The bottleneck is IO, not hashing: 100 MB/s on an HDD, 500–3000 MB/s on NVMe, against 10+ GB/s for XxHash3 and 2–5 GB/s for BLAKE3. System.IO.Hashing.XxHash128 is in the box, managed, with zero native dependencies, while BLAKE3.NET means another native DLL in the self-extract, +200 ms of startup, trimming and AOT trouble and a separate arm64 build. Cryptographic strength is not needed: there is no adversary here, and stage 4 makes the guarantee absolute. MD5 was dropped — slower, weaker, and it creates the false impression of being a "crypto hash". --hash sha256 stays for comparing with another tool, but not as the default.
The key is not the path — renaming would throw the cache away:
PRIMARY KEY (volume_serial, file_id_low, file_id_high, size_bytes, mtime_unix)
Eviction is LRU by last_used_at, hard-capped at 200k rows (~30 MB). Changing hash_algo invalidates everything.
EX_UNSAFE.keep match always wins; then a multiply-linked file; then not in Temp/Downloads/caches; then the shallower path; older mtime last. "Oldest file" alone was rejected — the oldest is usually the one in Downloads\tmp.FileShare.ReadWrite | FileShare.Delete (otherwise half of AppData is unreadable) and FILE_FLAG_SEQUENTIAL_SCAN.FILE_FLAG_OPEN_NO_RECALL is the second barrier.A full read pass makes Defender scan everything read. Before starting:
Hashing will read 84.2 GB from disk. Real-time antivirus scanning may
slow this down 2-5x and use significant CPU.
You can exclude pathmemo from Defender yourself (run as admin):
Add-MpPreference -ExclusionProcess pathmemo.exe
pathmemo will not change your security settings.
[Enter] Continue [S] Skip files over 1 GB [Esc] Cancel
pathmemo dupes --estimate asks the same question and answers it without reading anything: stage 0 works from the snapshot, so the size of the job is knowable before the job starts.
FILE_ID_INFO call, before a single byte is read. So the order on the page is the order in the code, and hard links cost a pass over nothing.GetPath calls would cost more than the hashing.CloudOnly flag is the check before opening; FILE_FLAG_OPEN_NO_RECALL plus a FILE_ATTRIBUTE_RECALL_ON_* test on the handle is the check for a file that became a placeholder since (threat T10).dupe_runs keeps the last search and no more: a scan is worth keeping because it answers "what changed", and twelve duplicate runs answer nothing. Saving one deletes the one before it. A reloaded group carries the total it measured rather than recomputing it, because per-file allocated size is not a column (§11).The most dangerous module. Designed so that nothing is deleted until three independent checks agree.
$Recycle.Bin lives on the same volume: moving 40 GB into it frees 0 bytes. On top of that, the bin has a quota (~5% of the volume by default) and the Shell silently deletes forever anything larger — so the "safe" path is more dangerous than the direct one; moving 200k small files into it takes minutes and writes an $I record for each; and there is no bin on network drives, often none on removable ones, and different behaviour on ReFS.
| Mode | When | Frees space now | Undo |
|---|---|---|---|
Recycle | ≤ 100 files, ≤ 500 MB total, the volume has a bin, Recoverability != Instant | no | Explorer → Restore |
Quarantine | default for everything else | no (until purge) | pathmemo restore <op-id> |
Permanent | explicit --permanent / Shift+D, or Recoverability == Instant | yes | none |
The UI always says what will happen, without euphemism:
Delete 47 items · 18.2 GB
Mode: quarantine (moved aside, disk space freed after purge)
Frees now: 0 bytes
Frees on purge: 18.2 GB
Undo: pathmemo restore op-118 (until purged)
[Tab] change mode: quarantine / permanent
[Enter] proceed [L] list items [Esc] cancel
Permanent over 1 GB, or at Risk ≥ Caution, requires typing delete 47 to confirm.
String comparison of paths was rejected as unsafe. A list of literal strings can be walked around at least twelve ways:
c:\windows case
C:\Windows\ trailing slash
C:\WINDOW~1 8.3 short name
\\?\C:\Windows Win32 prefix
\\.\C:\Windows device prefix
\\localhost\c$\Windows UNC to self
\\127.0.0.1\c$\Windows the same
C:\Users\..\Windows traversal
C:\Documents and Settings\... junction -> C:\Users
C:\Users\All Users\... junction -> C:\ProgramData
D:\mnt\sys\... mount point onto the system volume
and a hard-coded C:\ is wrong to begin with — the system volume need not be C:.
1. Open a handle:
CreateFileW(path, 0 /* query only */,
FILE_SHARE_READ|WRITE|DELETE, NULL, OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL)
Cannot open -> refuse. We never delete what we could not open.
2. canonical = GetFinalPathNameByHandleW(h, VOLUME_NAME_GUID)
-> \\?\Volume{GUID}\Windows\System32, which eliminates every line above.
3. Check canonical against ProtectedSet (canonicalised at startup):
· KnownFolders via SHGetKnownFolderPath, not hard-coded paths:
Windows, System, SystemX86, ProgramFiles(x86), ProgramFilesCommon,
ProgramData, UserProfiles, Profile, Public, Fonts, Startup,
StartMenu, RoamingAppData
· any volume root, any path at depth <= 1 from one, any mount point
· System Volume Information, $Recycle.Bin (except the "empty" operation)
· `keep` paths from the config
· pathmemo's own data directory (except `purge`)
A match, or canonical being a PREFIX of a protected path -> refuse.
Being inside a protected path -> refuse, EXCEPT for explicitly allowed
subpaths (C:\Windows\Temp, SoftwareDistribution\Download, Logs,
CrashDumps): a whitelist inside the blacklist, defined by rules.
4. Check that canonical's volume is the volume the operation asked for.
5. Re-read size and mtime through FILE_BASIC_INFO / FILE_STANDARD_INFO on
THIS handle. Disagreement with the snapshot -> refuse, rescan needed.
6. Delete through that same handle (§9.5), never by path.
P7: step 5 applies to a file as written. A directory is judged by its total instead, which the plan has just measured live, with a tenth of slack: a directory's modification time moves whenever anything inside it is written, and that is every cache the reclaim rules exist to find (§7.5).
%LOCALAPPDATA%\pathmemo\quarantine\op-000118\
├── manifest.json # op id, time, mode, orig path -> stored name, sizes, hashes
└── data\000001\ # original tree under a numeric name (path-length headroom)
MoveFileWithProgressW with MOVEFILE_WRITE_THROUGH and without MOVEFILE_COPY_ALLOWED: within a volume that is a rename — instant and atomic. If the quarantine is on another volume the operation is refused (copying 18 GB instead of renaming is not acceptable) and Permanent, or a same-volume quarantine, offered instead.D:\pathmemo-quarantine\ (hidden, system) for files from D:.pathmemo restore op-118 puts everything back from the manifest, checking that the target paths are free.pathmemo purge deletes for real. Automatic purge is by age (quarantineRetentionDays, 7) at startup, journalled, and announced on first run so it is never a surprise.Quarantine: 18.2 GB in 2 operations — purge to reclaim.The classic vulnerability: between the check and the delete, a subdirectory is swapped for a junction to C:\Windows\System32, and the recursive deleter walks into it.
DeleteTree(parentHandle):
for each entry in NtQueryDirectoryFile(parentHandle):
childHandle = NtCreateFile(name,
RootDirectory = parentHandle, // relative open!
FILE_OPEN_REPARSE_POINT) // never follow
if childHandle is a reparse point:
delete the point itself, do NOT enter it
else if directory:
DeleteTree(childHandle) // recursion by handle
delete the directory by handle
else:
SetFileInformationByHandle(childHandle, FileDispositionInfoEx,
FILE_DISPOSITION_DELETE | POSIX_SEMANTICS)
RootDirectory = parentHandle resolves the name relative to an already-open directory, so swapping a path mid-operation physically cannot redirect us elsewhere. FILE_DISPOSITION_POSIX_SEMANTICS (Win10 1709+) deletes a file another process holds open — the name disappears at once, the data when the last handle closes — which matters for caches inside running applications.
IFileOperationSHFileOperation is deprecated, swallows errors and reports success after doing nothing. IFileOperation only:
op.SetOperationFlags(FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT
| FOFX_RECYCLEONDELETE | FOF_WANTNUKEWARNING);
op.SetOwnerWindow(IntPtr.Zero);
op.Advise(new ProgressSink()); // REQUIRED: otherwise we never learn what failed
COM needs STA, so this runs on a dedicated thread. Rather than letting FOF_WANTNUKEWARNING raise a dialog, we check the size against the bin's quota ourselves (SHQueryRecycleBinW plus the registry) and switch to Quarantine before silent destruction can happen. PostDeleteItem gives a per-item HRESULT, and every item is journalled.
op-000118 2026-09-17T14:22:08Z quarantine 47 items 18.2 GB reclaimed 0 B
status: completed · restorable until 2026-09-24
47 succeeded, 0 failed
Each item is a row in delete_items (§11). Dry runs go to a separate table, dryrun_log: mixing real and rehearsed deletions in one journal makes the audit untrustworthy.
GetDiskFreeSpaceExW is read before the operation and after it (with a 500 ms delay for deferred filesystem work):
Done in 1.4 s
47 items moved to quarantine
Predicted: 18.2 GB Actual free space change: 0 bytes (as expected)
pathmemo purge op-118 to reclaim 18.2 GB
and after the purge, Predicted: 18.2 GB → Actual: 18.4 GB, C: free 21.3 → 39.7 GB. A divergence over 10% is logged and shown: the only way to notice that the size model is lying.
%LOCALAPPDATA% and a 44 GB WSL disk becomes undeletable; unseal C:\Users and so does nothing. So Windows, System32, Program Files, ProgramData, Fonts, Startup and the Start menu are sealed — nothing inside them goes without an allowInsideProtected rule — while the profile, AppData\Roaming, AppData\Local, C:\Users and Public are anchors: the directory itself is refused, its contents are ordinary. Volume roots and everything directly under them are refused separately, so D:\games is safe from a typo while D:\games\old is not.%WINDIR%\Temp\** makes what is in Temp deletable and leaves Temp itself protected; Windows expects that directory to exist. The rule lives in the protected set rather than in the glob syntax, so ** keeps one meaning everywhere (§12.2).C:\Users\All Users opened with FILE_FLAG_OPEN_REPARSE_POINT canonicalises to itself, which no rule would catch; so a reparse point is opened a second time following the link and the target is checked too. That is what refuses the compatibility junctions in §9.3's table rather than a list of their names.NtSetInformationFile / FileRenameInformation and the destination directory's handle in RootDirectory — not MoveFileWithProgressW, which takes paths and would resolve the source again after the guard has checked it. The Win32 wrapper SetFileInformationByHandle cannot be used here: it rejects a non-null RootDirectory with ERROR_INVALID_PARAMETER, measured on every buffer shape. A rename cannot cross volumes, so a quarantine on the wrong disk fails with ERROR_NOT_SAME_DEVICE instead of quietly becoming an 18 GB copy.Without this the history is dead — nobody scans by hand twice a week.
$ pathmemo schedule --weekly --time 03:00
Registered scheduled task "pathmemo weekly scan".
Runs: every Monday at 03:00, only when the computer is idle and on AC power.
Command: pathmemo scan --all-volumes --quiet
Remove with: pathmemo schedule --off
Implemented over ITaskService (COM) or schtasks.exe with ArgumentList: RunOnlyIfIdle, StartWhenAvailable, DisallowStartIfOnBatteries, priority BELOW_NORMAL. Registering in the current user's context (no password) means the scan runs unelevated and degraded; when installed elevated, RunLevel = Highest is offered for MFT scans.
schtasks.exe, not ITaskService. The four settings that make the task polite — idle only, start when available, not on batteries, below-normal priority — are only expressible in the XML, and the XML costs no COM interop, which is one fewer thing to keep working in a trimmed single-file build (§19.4). Written as UTF-16 with a byte order mark, because the scheduler insists the encoding match its own declaration.schtasks /Query /FO LIST /V is translated — its labels and dates are in the display language — and parsing a translation is exactly what §6.3 warns against. /XML ONE is not: element names are fixed, so schedule --status parses the same shape it writes. What comes back is not byte-identical: the scheduler drops every element that equals a schema default, Priority 7 among them. They are written anyway, because a document that states what it wants does not change meaning when a default does.As: S-1-5-21-2390494320-…. It is translated back to DESKTOP-…\MIX PC for display, and a SID that no longer resolves is printed as it stands.InteractiveToken, so nothing holds a password. Registered from an ordinary prompt the task asks for LeastPrivilege and the scheduled scan walks the disk; registered from an elevated one it asks for HighestAvailable and can use the MFT and the change journal. Both cases say which, and how to change it, rather than leaving the user to find out at three in the morning.dotnet PathMemo.dll refuses to register. Environment.ProcessPath is then dotnet.exe, and a task pointing at the runtime with no arguments is a task that fails every night. Better to say so.--off is idempotent. Nothing registered is the state that was asked for, so it is a success with a different sentence.XDocument to read nine elements added 8.04 MB to the trimmed single-file build — 33.61 MB with it against 25.57 MB without, measured both ways — which is a third of the application for one parse of forty lines. Forty lines of IndexOf replaced it, scoped to blocks because element names repeat (Enabled appears in the trigger and again in the settings, and only the second one means "switched off"). It is not a general XML reader and does not pretend to be: it reads a document this same file wrote, and anything else is no schedule rather than an exception.$ pathmemo diff 41 43
C: +18.4 GB 2026-09-10 03:00 → 2026-09-17 03:00
GREW +24.1 GB
C:\Users\me\AppData\Local\Docker +9.8 GB 12.1 → 21.9
C:\Users\me\projects\bigapp\node_modules +4.2 GB 0.1 → 4.3
C:\Program Files\Epic Games\Fortnite +3.9 GB
... 42 more
SHRANK -5.7 GB
C:\Users\me\Downloads -4.1 GB
... 8 more
APPEARED +6.2 GB (118 paths)
DISAPPEARED -6.2 GB (204 paths)
Largest single new file:
C:\Users\me\Downloads\Win11_24H2.iso 5.8 GB
Both snapshots are loaded and walked in step over sorted child names (a merge join per directory), with depth limited to the first level that explains the change — node_modules\.bin\x is not shown when all of node_modules grew. Both snapshots must be available and neither may be Partial.
max(64 MB, 1% of this level's change): on a disk that grew by 18 GB the top-level threshold is 180 MB, and on one that moved by 200 MB it is still 64 MB. The threshold narrows on the way down by itself.residual row for the directory itself (C:\mix +6.0 GB other entries here) — an honest way to say "a thousand small files here". When no child clears the threshold the directory is shown whole and the descent stops, which is how C:\Windows\SoftwareDistribution becomes one line instead of 400.UNEXPLAINED closes the arithmetic. A remainder below its level's threshold does not become a row, so GREW + SHRANK need not match the volume's change. The difference is printed as its own line rather than left as a mystery: on a real pair of scans it was 92 MB out of 917 MB. A reader who adds two columns, gets a third number and stops trusting the tool is right to.GREW/SHRANK tagged new/gone — otherwise a new 10 GB directory would have no path in the report — while the summary over everything stays a separate block. That count is gathered only where the report descended, and the line underneath says so: a number without its limits is exactly why disk tools are not believed.! row.+1.71 GB C:\$MFT (new) and -1.6 GB C:\Windows\WinSxS, which is visibility, not disk change.Partial snapshot — each with its own message and EX_NO_DATA. Argument order does not matter: snapshots are sorted by time so growth never reads as shrinkage.SQLite holds only what has to be queryable and long-lived. The file tree lives in snapshots (§5).
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
PRAGMA temp_store = MEMORY;
PRAGMA busy_timeout = 5000;
PRAGMA cache_size = -16384; -- 16 MB, not 64: the database is small
-- Versioned by PRAGMA user_version, no DbUp.
-- integrity_check runs ONLY after an unclean exit (a .clean marker file is
-- removed at startup and written on a clean shutdown).
CREATE TABLE scans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL, -- ISO-8601 UTC
finished_at TEXT,
status TEXT NOT NULL, -- running|completed|cancelled|failed
scanner TEXT NOT NULL, -- mft|walk|incremental
flags INTEGER NOT NULL DEFAULT 0,
roots TEXT NOT NULL, -- JSON: ["C:\\","D:\\"]
total_files INTEGER NOT NULL DEFAULT 0,
total_dirs INTEGER NOT NULL DEFAULT 0,
allocated_bytes INTEGER NOT NULL DEFAULT 0, -- unique allocated
logical_bytes INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER,
error_count INTEGER NOT NULL DEFAULT 0,
tool_version TEXT NOT NULL,
snapshot_path TEXT, -- NULL once retention drops it
snapshot_bytes INTEGER,
note TEXT
);
CREATE INDEX idx_scans_started ON scans(started_at DESC);
CREATE TABLE scan_volumes (
scan_id INTEGER NOT NULL REFERENCES scans(id) ON DELETE CASCADE,
letter TEXT NOT NULL,
label TEXT,
filesystem TEXT,
volume_serial INTEGER NOT NULL,
volume_guid TEXT,
cluster_bytes INTEGER NOT NULL,
total_bytes INTEGER NOT NULL,
free_bytes INTEGER NOT NULL,
scanned_bytes INTEGER NOT NULL,
metadata_bytes INTEGER,
unaccounted_bytes INTEGER,
usn_journal_id INTEGER,
next_usn INTEGER,
PRIMARY KEY (scan_id, letter)
);
-- Aggregates for multi-year graphs; they outlive the snapshot.
-- Same shape for both: (scan_id, key, allocated_bytes, file_count).
CREATE TABLE scan_category_totals (
scan_id INTEGER NOT NULL REFERENCES scans(id) ON DELETE CASCADE,
category TEXT NOT NULL, -- media|archive|cache|source|document|app|system|other
allocated_bytes INTEGER NOT NULL,
file_count INTEGER NOT NULL,
PRIMARY KEY (scan_id, category)
);
CREATE TABLE scan_extension_totals ( /* ... extension TEXT ... */ );
CREATE TABLE audit_findings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scan_id INTEGER REFERENCES scans(id) ON DELETE CASCADE,
probed_at TEXT NOT NULL,
finding_id TEXT NOT NULL, -- "vss.shadow-storage"
volume TEXT,
used_bytes INTEGER,
reclaimable_bytes INTEGER,
risk TEXT NOT NULL,
recoverability TEXT NOT NULL,
status TEXT NOT NULL, -- ok|unknown|probe_failed
detail_json TEXT
);
CREATE INDEX idx_audit_scan ON audit_findings(scan_id, reclaimable_bytes DESC);
CREATE TABLE delete_ops (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL,
finished_at TEXT,
mode TEXT NOT NULL, -- recycle|quarantine|permanent
source TEXT NOT NULL, -- tree|reclaim|dupes|audit|cli
scan_id INTEGER REFERENCES scans(id) ON DELETE SET NULL,
item_count INTEGER NOT NULL,
predicted_bytes INTEGER NOT NULL,
actual_freed_bytes INTEGER,
status TEXT NOT NULL, -- completed|partial|failed|restored|purged
quarantine_path TEXT,
purge_after TEXT,
reason TEXT
);
CREATE INDEX idx_delete_ops_date ON delete_ops(started_at DESC);
CREATE TABLE delete_items (
op_id INTEGER NOT NULL REFERENCES delete_ops(id) ON DELETE CASCADE,
seq INTEGER NOT NULL,
original_path TEXT NOT NULL,
stored_name TEXT, -- name inside the quarantine
size_bytes INTEGER NOT NULL,
content_hash TEXT, -- checked on restore
result TEXT NOT NULL, -- ok|skipped|failed
hresult INTEGER,
message TEXT,
PRIMARY KEY (op_id, seq)
);
-- Dry runs, SEPARATE from real operations.
CREATE TABLE dryrun_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, ran_at TEXT NOT NULL,
source TEXT NOT NULL, item_count INTEGER NOT NULL,
total_bytes INTEGER NOT NULL, detail_json TEXT
);
-- Hash cache keyed by file id, never by path.
CREATE TABLE file_hashes (
volume_serial INTEGER NOT NULL,
file_id_low INTEGER NOT NULL,
file_id_high INTEGER NOT NULL,
size_bytes INTEGER NOT NULL,
mtime_unix INTEGER NOT NULL,
hash_algo TEXT NOT NULL,
full_hash TEXT NOT NULL,
computed_at TEXT NOT NULL,
last_used_at TEXT NOT NULL,
PRIMARY KEY (volume_serial, file_id_low, file_id_high, size_bytes, mtime_unix, hash_algo)
);
CREATE INDEX idx_file_hashes_hash ON file_hashes(hash_algo, full_hash);
CREATE INDEX idx_file_hashes_lru ON file_hashes(last_used_at);
-- Duplicate groups from the last run: a cache, not history.
-- dupe_runs (id, ran_at, scan_id, min_size, hash_algo, group_count, wasted_bytes)
-- dupe_groups (id, run_id, size_bytes, file_count, wasted_bytes,
-- kind = duplicate|hardlink_set, full_hash)
-- dupe_files (group_id, seq, path, mtime_unix, link_count, suggested_keep)
Snapshot writing batches 20k rows for aggregates and runs PRAGMA wal_checkpoint(PASSIVE) after each scan operation: one transaction around a million inserts would inflate the WAL to the size of the data.
user_version = 1, Storage/Schema.sql as an embedded resource, applied in one transaction. Tables for later phases are created now and sit empty at a few hundred bytes: one schema version is simpler than six, and P6 will not open with a migration.scans.id is the snapshot's file number. The id is allocated before the scan starts, by one AUTOINCREMENT insert, and the snapshot written under it — otherwise history and tree --scan 42 would be talking about different scans. An insert rather than "max plus one" because of concurrency: two processes started together get different numbers. Deleting the database breaks nothing: snapshots on disk are imported back under their own numbers (4 real snapshots in 1.6 s). Only when the database is entirely unavailable does the number come from the snapshot directory — the one case where numbering is not atomic.snapshot_path but keeps its numbers. That makes snapshot_available = 0 an observable state rather than a promise.EX_LOCKED comes only from commands that require it. In WAL mode a reader is never blocked, so a second process can browse history while the first writes.integrity_check only after a dirty exit, via the .clean marker; on a real database it takes milliseconds and never runs in a normal cycle.--no-save writes not one row. Otherwise a scripted JSON export would quietly fill up the history.(rest) row — a full disk has tens of thousands of distinct suffixes. The category is inherited from the directory (C:\Windows → system, node_modules → cache) and only then taken from the extension: C:\Windows is full of .wav files that are not the user's media. NTFS metafiles at a volume root are system too, but only at the root — a user file is entitled to be called $draft. Computed once per scan and sent to the database, the By category block and the JSON alike. On a real C: (walk, 1.22M files, 197 GB): system 62.5 GB, other 49.8, cache 34.5 (613k files), app 34.2, archive 12, source 2.6, media 1.7, document 0.2.%LOCALAPPDATA%\pathmemo\config.json. Keys are camelCase, sizes accept a human form ("1GB", 1048576), durations look like "30d".
{
"scan": {
"preferMftScanner": true, "promptForElevation": true, "parallelism": "auto",
"excludeFromScan": [], "doNotRecurse": ["\\\\*"], "includeCloudOnlyFiles": true,
"progressIntervalMs": 250, "useUsnIncremental": true
},
"protect": {
"keep": [],
"allowInsideProtected": [
"%WINDIR%\\Temp\\**", "%WINDIR%\\SoftwareDistribution\\Download\\**",
"%WINDIR%\\Logs\\**", "%WINDIR%\\Prefetch\\**"
]
},
"delete": {
"defaultMode": "quarantine", "recycleMaxItems": 100, "recycleMaxBytes": "500MB",
"quarantineRetentionDays": 7, "autoPurgeExpired": true,
"requireTypedConfirmationOverBytes": "1GB", "verifyBeforeDelete": true
},
"duplicates": {
"minSize": "1MB", "hashAlgorithm": "xxh128", "bufferSize": "1MB",
"partialHashBytes": "64KB", "byteForByteVerify": true, "crossVolume": true,
"skipCloudOnly": true, "hashCacheMaxEntries": 200000
},
"rules": { "disabled": [], "custom": [] },
"storage": {
"dataDirectory": "%LOCALAPPDATA%\\pathmemo", "keepRecentSnapshots": 20,
"keepMonthlySnapshots": 12, "snapshotsMaxBytes": "400MB"
},
"logging": { "level": "Information", "retentionDays": 14, "logFilePaths": false },
"ui": { "sizeMode": "unique", "minWidth": 80, "minHeight": 24, "mouse": true,
"confirmOpenExecutables": true },
"export": { "redactPaths": false }
}
P9: the scan section is read, for useUsnIncremental alone. The other keys in it belong to behaviour that is not yet configurable, and a key that is read but ignored is worse than one that is not read at all.
P10: the export section is read, for redactPaths, which is the default export --redact overrides per run. pathmemo config now answers the three questions this file raises without opening it — where it is, whether it is being obeyed, and which values are actually in force — and lists only the keys the tool reads, for the same reason: printing the whole document above would imply that all of it is honoured. --edit writes a documented template first if there is nothing to open, and every value in that template is already the default, so a fresh file changes nothing until something in it is changed. --reset moves the old file to config.json.bak rather than overwriting it: it may hold the only copy of a keep list somebody built up over months, and "reset" is a word people type faster than they read it. Both refuse to run elevated — an administrator process writing into a path derived from the user profile is the shape of the problem §12.1 is about, and an elevated run ignores most of the file anyway, so editing it from there would produce a document the writer would not obey.
The config lives in %LOCALAPPDATA%, writable by an ordinary user, while an MFT scan runs as administrator. A logging.filePath or database.path taken from that config would be arbitrary file write as administrator — a local privilege escalation. So whenever IsProcessElevated():
storage.dataDirectory and the log path do not come from the config, only from the calling user's fixed %LOCALAPPDATA% (via WTSQueryUserToken / SHGetKnownFolderPath with the session token);Administrators / SYSTEM / the owner can write it, a warning is printed and rules.custom, protect.* and everything else affecting deletion is ignored;Implemented in P4: the same rule covers --data-dir. When elevated it prints a warning and is ignored: a command-line argument from an administrator is the same arbitrary-write primitive as a config key.
P6: config.json is read, for the protect and delete sections. While elevated its DACL is checked first, and if anything outside Administrators / SYSTEM / the owner can write the file it is ignored with one warning — an unprivileged user must not be able to stage "allowInsideProtected": ["**"] for an elevated process to obey. A file that will not parse yields the defaults and a warning rather than a failure to start; an unusable single value warns about that key alone and keeps its default.
One syntax, not two. Matching is FileSystemName.MatchesSimpleExpression (built in, zero allocations) extended with ** for "any number of segments". Variables (%WINDIR%, %TEMP%, %APPDATA%, %LOCALAPPDATA%, %USERPROFILE%, %ProgramData%, %ProgramFiles%, ~) expand through SHGetKnownFolderPath, never a hard-coded C:\. Matching runs against canonicalised paths.
Regex is opt-in as { "regex": "..." }, mandatorily with RegexOptions.NonBacktracking | CultureInvariant and matchTimeout = 50 ms: without NonBacktracking one bad pattern would hang a million-path scan on catastrophic backtracking. Where possible a rule matches on the name and path suffix rather than the whole path — a hash-set prefilter on the last segment rejects 99% of nodes in O(1).
Readable output by default, --json for scripts.
pathmemo TUI when attached to a TTY, else `status`
pathmemo scan [<path>...] [options] scan; no path means every fixed volume
pathmemo status last scan plus free space
pathmemo tree [<path>] [--scan <id>] tree, largest first, non-interactive
pathmemo top [options] largest files and folders, with filters
pathmemo audit [--id <finding>] [--apply <finding>]
pathmemo reclaim [options] cleanup recommendations (options: §7.3, §7.4)
pathmemo dupes [options] duplicate search (options: §8.6)
pathmemo rm <path>... [options] deletion (quarantine by default)
pathmemo restore <op-id> restore from quarantine
pathmemo purge [<op-id>|--expired|--bin] free what a quarantine (or the bin) holds
pathmemo ops [<op-id>] [--limit N] deletion journal
pathmemo history [--limit N] [--since <date>]
pathmemo diff <id-a> <id-b>
pathmemo errors [<scan-id>] [--kind <kind>] [--limit N] [--sizes]
pathmemo export [<scan-id>] [--format json|csv] [--output <file>] [--redact]
pathmemo schedule [--weekly|--daily] [--time HH:MM] | --off | --status
pathmemo config [--path | --edit | --reset]
pathmemo doctor rights, USN, filesystem, database, version
--json stable machine-readable stdout, logs to stderr
--quiet errors only
--no-color no ANSI (NO_COLOR and a non-TTY are honoured too)
--size logical|allocated|unique default: unique
--yes skip confirmations (NOT for permanent delete or Risk=Danger,
which always need --force plus a typed confirmation)
--data-dir <path> alternative data directory (ignored when elevated)
--verbose / -v
P4: --data-dir is stripped from the arguments before the command is parsed, so it works with any of them, and when elevated it warns and is ignored (§12.1). --json exists on scan, diff, history and audit; --size on tree, top and diff.
P6: --yes works on rm, purge and audit --apply, and never answers the typed confirmation that a large permanent deletion demands - that is what --confirm-token is for. --json covers rm, as a plan before the fact or a result after it.
P7: --force stops being a synonym for --yes. Anything a rule calls Risk = Danger — however the path arrived, typed at a prompt or marked in the tree — needs --force and the typed confirmation, and --yes answers neither. --json covers reclaim too.
P8: --json covers dupes, and --yes there answers the read notice of §8.5 as well as the deletion question. --paths-only is on dupes too, so dupes --paths-only | rm --from-stdin is the same pipe top offers.
P9: scan --full turns off the incremental path for one run. There is no --incremental: a rescan is what a scan already is when it can be, and a flag asking for something the journal cannot deliver would only ever produce an error (§4.5.1).
P5: status prints the Overview screen's data as text — volumes with free space and unaccounted bytes, the last scan, the size of the store — and is what a bare pathmemo prints with stdout redirected (§14.5). NO_COLOR in the environment is honoured: no palette, selection still inverted.
P10: --no-color is a real option now, and a global one: it is taken out of the argument list before the verb sees it, exactly as --data-dir is, so it works on every command instead of being an unknown option on nine of them. One switch decides, read in one place, so the flag and the environment variable cannot disagree. --redact on export is the only other new global-shaped flag, and it is deliberately not global: it means something only where an export happens.
scanpathmemo scan [<path>...]
--all-volumes every local fixed volume
--scanner mft|walk|auto default: auto
--no-elevate no relaunch prompt, go degraded immediately
--full ignore USN, scan from scratch
--exclude <glob> repeatable; this scan only
--parallelism <n|auto> --note <text> --no-audit
--format console|json|csv --output <file>
P3: --scanner, --no-elevate, --parallelism, --note, --top, --no-save, --quiet, plus --pause, which the tool passes to itself when relaunching elevated so the new console window does not close before the user has seen the result.
P4: --format and --output; --json is a synonym for --format json. The JSON carries schemaVersion, scan metadata, volumes with reconciliation, categories, tops and a limitations list — the machine-readable version of the accuracy warning the console prints. CSV is a table of the largest entries per --top. In a non-console format no progress is printed and the write confirmation goes to stderr: stdout carries data only (§13.6).
topThe main semi-CLI scenario: find the fat things, with filters.
pathmemo top
--scan <id> default: the latest --files | --dirs default: --files
--limit <n> default: 40 --under <path>
--min <size> e.g. 1GB --ext iso,vhdx,zip
--older-than 180d / --newer-than <dur> --category <c>
--sort size|mtime|count --paths-only one path per line, for a pipe
$ pathmemo top --min 1GB --ext iso,vhdx --older-than 90d
SIZE MODIFIED PATH
───────────────────────────────────────────────────────────────────────
44.1 GB 2026-01-04 C:\Users\me\AppData\Local\Packages\…\ext4.vhdx
5.8 GB 2026-03-11 C:\Users\me\Downloads\Win11_24H2.iso
4.2 GB 2025-11-28 D:\vm\win10-test.vhdx
───────────────────────────────────────────────────────────────────────
3 files · 54.1 GB
rmpathmemo rm <path>...
--mode quarantine|recycle|permanent default: from the config
--dry-run --reason <text> --from-stdin --scan <id>
--force required for Risk >= Caution
--confirm-token <token> non-interactive stand-in for the typed
confirmation; printed by --dry-run
--confirm-token answers "how do you automate a dangerous operation without making --yes a skeleton key": the dry run prints a token derived from the exact list of paths and sizes, and if anything changed the token is invalid.
P6: all of the above except --force, which was accepted as a synonym for --yes until the risk axis it belongs to arrived in P7 (§13.1). The token is ten base32 characters over the canonical paths, sizes and modification times, sorted - so the same list in another order is the same token, and one extra byte in one file is not. --permanent is a spelling of --mode permanent, and pathmemo delete of pathmemo rm.
| Code | Constant | Meaning |
|---|---|---|
| 0 | EX_OK | success |
| 1 | EX_FAILURE | general failure |
| 2 | EX_USAGE | bad arguments |
| 3 | EX_PARTIAL | partially done (some paths unreachable) |
| 4 | EX_CANCELLED | cancelled by the user |
| 5 | EX_NEEDS_ELEVATION | administrator rights required |
| 6 | EX_UNSAFE | refused by a guard (protected path, last copy, verify failed) |
| 7 | EX_NO_DATA | no snapshot or scan for the query |
| 8 | EX_LOCKED | database held by another process; only from commands that require it (§11.1) |
--json emits an object with "schemaVersion": 1. Fields are only ever added within a major version. Anything that is not data goes to stderr. Numbers are bytes as integers, not strings; times are ISO-8601 UTC with Z.
P6: rm --json emits the plan (with its token and every refusal and its reason) when nothing is to be done or --dry-run is given, and the per-item result otherwise.
P8: dupes --json emits every group with its files, each carrying keep, protected, linkCount and the reason the survivor was chosen, plus a totals object that separates duplicates from hard-link sets.
P9: an incremental scan adds changedDirectories to scan --json and one more entry to limitations; scanner reads incremental and the flags array carries incremental. Both are additions within schema 1, which is what “only ever added” means in practice.
P4: the contract is pinned by tests on scan --format json and on CSV quoting. The writer is a hand-written Utf8JsonWriter with no serializer: reflection is what makes a trimmed build fail at runtime instead of at build time (§18).
P10: errors --json is schema 1 as well — count, byKind, and a paths array whose entries carry path, kind, win32Code and message, plus allocatedBytes when --sizes opened the tree. export --format json is the only document that is not a report: a header, the volumes, then one object per node in entries, and entryCount at the end so a truncated file is detectable without counting. It is written unindented and flushed every 4,096 entries, which is what keeps a 394 MB document at a few hundred kilobytes of buffer.
export: the whole tree (P10)pathmemo export [<scan-id>] [--format json|csv] [--output <file>] [--redact] [--yes]
Every other command answers a question. This one hands over the data and lets somebody else ask — one row per node, 1.58M of them for a real disk. That shape decides the rest of the design:
--output defaults to %LOCALAPPDATA%\pathmemo\exports\scan-NNNN.csv so a file that size cannot land in the working directory by accident. An existing file is not overwritten without --yes.NodeStore.GetPath per node would rebuild every ancestor 1.6M times; this way a whole-disk CSV takes 3.1 s and the process peaks at 130 MB — the tree, and buffers.--redact hashes each name and keeps everything else (threat T13). The hash is of the lowercased name, so the same name is the same token everywhere: the shape of the disk, the sizes, the repetition and the depth all survive, which is what makes a redacted export still worth reading. A volume root stays as it is — C: is not private, and an export with no roots cannot be parsed at all. A file keeps its extension, because "which kind of file filled the disk" is the question exports exist for; a directory does not, because $Recycle.Bin has no extension in any useful sense and inventing .Bin for it would say something about the name while saying nothing about the contents. That distinction was a bug first: the first run redacted C:\$Recycle.Bin to 448edc267520.bin.flags column the summary CSV does not: reparse, hardlink, cloud, sparse, self, encrypted, incomplete. A reparse point counted as 0 and a hard link counted once are facts about the number in the row next to them, and a full export that omitted them would be quietly wrong.=, +, - or @ is defused with a quote. §13.2's CSV can do without it because every field there is a path starting with a drive letter; here the same is true, but this is the file somebody opens in Excel, and a name like =cmd.txt is a real name.The export turned out to be the cheapest way to check several claims of §21 against a real disk, by counting flags over 1.58M rows: 3,750 sparse or compressed, 1,882 reparse points — every one of them 0 bytes with no children, which is the "junction causes no recursion" criterion — 1,394 hard-linked names, 527 incomplete directories, and 10 nodes of pathmemo's own store carrying self.
From P11 this is one of two faces, not the only one. A double-click opens the window of §24; -i and --tui ask for these screens, and they are what answers over SSH, on a machine with no desktop, and whenever a window cannot be created. Neither is a port of the other: they share the scanner, the snapshot, the analysis and the guard, and each draws them the way its medium allows.
| # | Screen | Why |
|---|---|---|
| 1 | Overview | Volumes, free/used, unaccounted, quarantine, last scan, links to the rest |
| 2 | Tree | The main screen. Top-down navigation, like ncdu. 80% of the product's value. |
| 3 | Audit | Findings from §6 with their remedies |
| 4 | Reclaim | Recommendations from §7, grouped by rule, bulk selection |
| 5 | Duplicates | Groups, choosing the original |
1…5 switch. Modals: Details, Confirm delete, Search, Help, Sort.
Implemented in P5: screens 1 and 2, the Details, Search, Help and Sort modals, plus a shared Confirm. 3 opens the line-based audit view from P2; 4 and 5 say honestly which phase they arrive in. P7: 4 is the reclaim screen. P8: 5 is the duplicates screen, and all five exist. It is the only screen that shows something no snapshot contains, so it shows the last stored run and starts one only on r, in the ordinary console - opening a screen must never begin reading the disk (§8.6).
P6: the delete modal is its own view rather than a Confirm, because it has state: Tab cycles the mode and every line - what it frees now, what it frees on purge, what undo costs - is recomputed from a fresh plan, not patched. L lists the items and the guard's refusals. It hands off to the ordinary console to run, where the progress line, the confirmation and the free-space report belong (§14.6).
Settings is not a screen (a JSON editor in a terminal is days of work for nothing; c opens the config in an external editor and F5 reloads), nor is Errors (a counter on Overview plus pathmemo errors), nor History/Diff (CLI, with a sparkline on Overview).
pathmemo C:\Users\me\AppData\Local unique · scan 43 · 03:00
─────────────────────────────────────────────────────────────────────────────────────
Total 84.2 GB ../ C:\Users\me
─────────────────────────────────────────────────────────────────────────────────────
44.1 GB ████████████████████░░░░░░ 52.4% Packages/ 12,481
21.9 GB ██████████░░░░░░░░░░░░░░░░ 26.0% Docker/ 8,102
6.2 GB ███░░░░░░░░░░░░░░░░░░░░░░░ 7.4% Temp/ 41,209
4.1 GB ██░░░░░░░░░░░░░░░░░░░░░░░░ 4.9% Google/ 19,884
1.9 GB ░░░░░░░░░░░░░░░░░░░░░░░░░░ 2.3% npm-cache/ redownload 9,441
1.2 GB ░░░░░░░░░░░░░░░░░░░░░░░░░░ 1.4% CrashDumps/ safe 14
▸ 940.2 MB ░░░░░░░░░░░░░░░░░░░░░░░░░░ 1.1% NVIDIA/ safe 2,014
512.0 MB ░░░░░░░░░░░░░░░░░░░░░░░░░░ 0.6% thumbcache_1024.db link sparse
─────────────────────────────────────────────────────────────────────────────────────
j/k move l/Enter in h out y copy e explorer x mark d delete / search ? help
The bar and percentage are relative to the current directory. Badges on the right: the reclaim rule (safe, redownload), link (hard-link alias), sparse, cloud, reparse. Directories and files share one list sorted by size. Virtualisation is mandatory — only visible rows are rendered, and a directory with 200k children must not stutter.
Implemented in P5: link, sparse, cloud, reparse, self and partial; P7 adds the reclaim badge, which reads safe redownload rather than one word, because the two axes are the point (§7.1) and it comes first on the row - it answers "can this go?", the others answer "what is this?". The dirs/files/all filter is on t, not Tab: Tab moves focus in a terminal and the host takes it. The badge column is sized from the visible rows, so where there are no links or sparse files every column goes to names.
In the spirit of ncdu and lazygit: single characters, working everywhere.
NAVIGATION ACTIONS
j / ↓ down y copy path
k / ↑ up Y copy full details
l / → / ⏎ enter dir e reveal in Explorer
h / ← parent dir o open file (confirm required)
g / G top / bottom x / ␣ mark
Ctrl+D/U page down/up a mark all in view
~ volume root X clear marks
1 … 5 switch screen d delete marked (or current)
Shift+D delete permanently
VIEW K add to keep-list
s sort menu i / ⏎ details (on file)
m size mode r recompute (dupes/audit)
t filter dirs/files
/ search MISC
n / N next / prev match ? help
F5 rescan c open config in editor
q/Esc back
Q quit
Ctrl+C cancel / quit (standard behaviour, NOT hijacked)
P8: on screen 5, t shows or hides the hard-link sets and r runs a search; x marks a copy for deletion and refuses on the last unmarked file of a group, and K puts a path on the keep list so it is never offered again.
P10: c no longer says "create a file yourself" when there is nothing to open. It writes the same documented template pathmemo config --edit writes, then opens that — one door, one behaviour, and the message says which of the two happened.
Bindings that were rejected: Ctrl+C for "copy path" — it is SIGINT, so a user with a hung network scan could not get out, and Windows Terminal intercepts it when there is a selection (y copies instead, like vim's yank); Ctrl+Shift+C — intercepted by the terminal in Windows Terminal, VS Code and ConEmu (Y); Ctrl+1..6 for sorting — a digit with Ctrl has no VT sequence and cmd.exe does not deliver it (s opens a menu); Ctrl+/ — delivered as 0x1F only sometimes (?); Ctrl+D for delete — it is EOF and "page down" in most TUIs, a dangerous binding for a destructive act (d plus a dialog); F10 to quit — conhost takes it as a menu, and tmux takes F-keys (Q).
File names are input from an untrusted source. Before drawing:
U+202A..U+202E, U+2066..U+2069, U+200E/200F). Otherwise annexe[U+202E]txt.exe displays as annexe.txt — classic spoofing, shown to a user with an "open" key right there.·. WSL and Samba create such names and they wreck ANSI layout.string.Length: CJK and emoji take two columns, and without this the table falls apart on the first Chinese file name.C:\Users\me\…\node_modules\.bin beats C:\Users\me\projects\bigap….Implemented in P5: Sanitizer (1, 2, 5) and TextWidth (3) are pure functions with unit tests, including that annexe\u202Etxt.exe. Width comes from a binary-searched table of East Asian Width W/F ranges plus emoji blocks; combining marks and U+FE0F are zero. ZWJ sequences are measured per part: whether the terminal ligates them cannot be known from here — Windows Terminal does, conhost does not — and choosing conhost keeps alignment where it breaks more visibly. The sanitised name goes to the screen; the real one to the clipboard and Explorer.
Minimum 80×24 (80 columns is the default of half the windows out there). Under 100 columns the bar and file counter disappear, leaving size, percentage and name. The SIGWINCH equivalent (polling the width every 200 ms) triggers a redraw. With output redirected the TUI does not start at all, and a bare pathmemo behaves as pathmemo status.
Logs go to a file only, never to stdout or stderr while the TUI is up. With --json, data goes to stdout, diagnostics to stderr, and the TUI is off.
The renderer is a frame buffer with row-level diffing. Screen keeps two copies of the frame and sends only the rows that changed. Redrawing everything on each keystroke means flicker in conhost and unusability over SSH. Diffing by row rather than by cell: per-cell rendering needs an attribute buffer and run coalescing, for a gain nobody sees across 24 rows.
"Erase first, then write" is not cosmetic. The first version wrote the row and appended ESC[K; on a live terminal every row filled to the right edge lost its last character. Writing into the final column leaves the cursor there in the pending-wrap state, and an erase-to-end-of-line then wipes the cell just written. Found in a screenshot of a real window (937,09 instead of 937,094), closed by a test on the exact byte sequence.
Line truncates by columns, not characters, so no caller can skew a table. The selected row is drawn with reverse video: reverse is not colour, so it survives NO_COLOR, which disables only the palette.
Input is polled Console.ReadKey(intercept: true), not ReadConsoleInputW. The BCL already folds Windows Terminal's VT sequences and conhost's virtual-key records into one ConsoleKeyInfo; rewriting that is a week of edge cases. Polling KeyAvailable in short slices is needed because the loop must also notice a resize. TreatControlCAsInput is left alone — Ctrl+C stays a standard cancel through CancelKeyPress.
The terminal is restored three ways: a finally in the host, ProcessExit (a second Ctrl+C, a crash) and an explicit Restore. The alternate buffer preserves the user's scrollback: the tree never enters their history, and exiting returns the shell exactly as it was.
Anything that prints leaves the alternate buffer. An F5 rescan, the audit screen, opening the config — the host hands the terminal back, does the work in an ordinary console with its progress and questions (including the elevation offer), waits for Enter and returns. That is how §14.6 is honoured without threading a logger through every call. Afterwards the snapshot and summary are reloaded: a new scan may have appeared while we were away.
The Tree screen keeps no stack of levels. Its state is the directory, the selection and the scroll offset; going up rebuilds the parent's list and puts the cursor on the node we came out of. A per-level cache would have to be invalidated on every change of sort, filter and size mode — exactly where a tree starts showing yesterday's order. Re-sorting 26k children costs single-digit milliseconds.
Search covers the whole snapshot, not the current directory — otherwise it is useless, because what is being looked for is five levels down. Names are decoded into a stackalloc buffer rather than materialised as strings: 1.58M short strings would cost more in collections than the search itself (§17.3). Up to 2000 matches sorted by size; n/N jump between them, clearing the filter if it hides a hit.
Marks (x, a, X) are stored as node indices and cleared on any snapshot change: an index pointing into a different tree is the worst kind of bug for a deletion list. P6: d and Shift+D open the delete dialog for the marks, or for the row under the cursor when there are none. The paths come out of a snapshot that may be days old, and the guard opens and re-checks every one of them - so a stale tree costs a refusal, never the wrong file. P7: K writes the path straight into protect.keep in config.json, because that file is the single source of truth and the guard reads it on the next run (§7.4); on the reclaim screen the same key disables the rule when the cursor is on a rule.
o refuses to launch executables (§15.3) before any dialog, not "with a warning". Everything else gets a confirmation showing the sanitised name, the real extension and a mark-of-the-web note. y confirms, not Enter: a dialog that appears under a finger already travelling towards Enter is not a confirmation. The delete dialog inherits the spirit of it - Enter proceeds there only after Tab and L have had the chance to change what proceeding means, and a large permanent deletion still has to be typed out in the console. e opens Explorer through SHParseDisplayName + SHOpenFolderAndSelectItems (§15.2).
Overview and pathmemo status collect the same data (StatusReport): free space comes from the volume and is always current, everything else comes from the last scan and is dated, so every stored number carries its scan id. A locked database costs the history block and nothing else. The sparkline scales between its minimum and maximum rather than from zero: a disk that went from 401 to 409 GB would otherwise be a flat line, which is precisely what such a row must not do.
Glyphs follow the console font. Frames, bars and the sparkline are box-drawing and block characters, which only a TrueType font has, and an old console profile hands a double-click the raster Terminal font. GetCurrentConsoleFontEx checks TMPF_TRUETYPE once at startup and falls back to ASCII: # and . in bars, +/-/| in frames, > for the cursor. Not a crippled mode — it is how disk utilities looked for twenty years. PATHMEMO_ASCII=1 forces it, and tests it.
Deviations from the letter of §14: the filter is on t rather than Tab; the audit screen is the line-based one from P2; Enter does not confirm in dialogs. P7: the reclaim screen has two levels - rules, then the paths behind one rule - rather than a tree, because that is the shape of the decision: a person agrees to "all the node_modules" or picks three of them, never to something in between. t there is the risk ceiling rather than the row filter, and marks survive moving between the levels, so marking inside two rules and pressing d once is one operation and one journal entry (§9.7).
Verified live (conhost, 118×30): screen switching, descending, help, search ("402 matches across 1.58M nodes"), Details, quitting with Q; Ctrl+C inside the TUI returns the shell with its scrollback untouched; a window shrunk to 60×17 shows pathmemo needs 80x24; this window is 60x17 and redraws in full when enlarged. Launched through the shell (the same as a double-click): title pathmemo 0.1.0, a 110×32 window, the icon in the title bar and on the taskbar; clicking and dragging inside the window breaks nothing; PATHMEMO_ASCII=1 gives a fully ASCII frame.
Three paths, in order: P/Invoke OpenClipboard / SetClipboardData(CF_UNICODETEXT) on a dedicated STA thread (~40 lines, no dependencies); OSC 52, which works in Windows Terminal, WezTerm, kitty and, crucially, over SSH, enabled when WT_SESSION/TERM_PROGRAM are known or --clipboard osc52 is given; and finally a refusal that prints the path to stdout.
System.Windows.Forms.Clipboard was rejected: <UseWindowsForms>, +10 MB of binary, and a running message pump inside a console app. clip.exe was rejected as the primary path: piping encodings is finicky (UTF-16LE with a BOM) and it costs a process.
Not through a command line. Process.Start("explorer.exe", $"/select,\"{path}\"") is exploitable: a quote in the path breaks the parsing, such names do exist (created through \\?\ by WSL, Cygwin and Samba), and ArgumentList does not help because explorer /select demands the concatenated form.
SHParseDisplayName(path, null, out pidl, 0, out _)
SHOpenFolderAndSelectItems(parentPidl, 1, &childPidl, 0)
Process.Start(UseShellExecute = true) on an arbitrary found file is running untrusted code with one keystroke. The tool scans the whole disk, Downloads included, where invoice.pdf.exe and update.hta live.
o, deliberately not next to the destructive ones.exe com scr bat cmd ps1 psm1 vbs vbe js jse wsf wsh hta msi msp msc reg lnk url jar appx cpl pif inf) is refused: "Refusing to launch executable files. Press e to open the containing folder instead."ui.confirmOpenExecutables: false does not lift the ban on executables; it only drops the dialog for documents.Read-only (§6.3): an absolute path under %WINDIR%\System32 against PATH hijacking; ArgumentList, never a joined string; UseShellExecute = false, CreateNoWindow = true; a 30 s timeout with a process-tree kill; __COMPAT_LAYER not inherited.
<longPathAware>true</longPathAware> in the manifest, legacy path handling off, and a \\?\ prefix on every Win32 call past 250 characters. The MFT scanner has no path limit at all: it builds no strings while walking.
Every row is a real scenario, not a theoretical one.
| # | Threat | Countermeasure |
|---|---|---|
| T1 | Deleting system files — a string-based protected list is bypassable twelve ways (case, 8.3, \\?\, UNC c$, junction, mount point, ..), and C:\ is hard-coded to begin with | Canonicalisation through GetFinalPathNameByHandle(VOLUME_NAME_GUID) plus SHGetKnownFolderPath (§9.3) |
| T2 | Recursive deletion escaping the tree — a subdirectory swapped for a junction between check and delete (TOCTOU) | Handle-relative traversal with RootDirectory and FILE_OPEN_REPARSE_POINT (§9.5) |
| T3 | Deleting something other than what was shown — hours passed since the scan | Size and mtime re-read on the same handle; a mismatch refuses (§9.3) |
| T4 | Running malware — "open file" on invoice.pdf.exe from Downloads | Executable extensions refused, confirmation for the rest, MotW warning (§15.3) |
| T5 | File-name spoofing with an RTL override U+202E | Bidi, control and zero-width characters stripped on render (§14.4) |
| T6 | Command-line injection — a quote in a path reaching explorer.exe /select,"..." | SHOpenFolderAndSelectItems; no command line is built (§15.2) |
| T7 | Local privilege escalation — a user-writable config read by an elevated process turns logFilePath into arbitrary write as admin | Elevated mode takes no paths from the config; DACL check; no "run a command" keys (§12.1) |
| T8 | PATH hijacking — vssadmin or dism replaced on PATH | Absolute paths under %WINDIR%\System32, UseShellExecute=false (§15.4) |
| T9 | ReDoS — a config regex against a million deep paths hangs the scan | Globs by default; regex only with NonBacktracking and a 50 ms timeout (§12.2) |
| T10 | Hydrating cloud files — hashing a OneDrive placeholder downloads it, pulling 200 GB and filling the disk | RECALL_ON_*/OFFLINE checked before opening, plus FILE_FLAG_OPEN_NO_RECALL (§8.4). P8: two barriers, the scan's flag and the handle's attributes, and a test that asserts such a file is never opened |
pathmemo/
├── pathmemo.sln · Directory.Build.props · global.json (pins the SDK to .NET 9)
├── CHANGELOG.md ← what each release is; the notes are built from it (§19.3)
├── src/PathMemo/ ← all the code, separated by folders
│ ├── Program.cs · app.manifest (longPathAware, asInvoker) · Resources/app.ico
│ │
│ ├── Cli/
│ │ ├── ArgParse.cs value parsers: sizes, durations, dates, modes
│ │ ├── Commands/ Scan, Tree, Top, Audit (+ AuditApply), History, Diff,
│ │ │ Doctor, Status (+ StatusReport, shared with Overview),
│ │ │ Rm, Quarantine (restore / purge / ops), Reclaim, Dupes,
│ │ │ Schedule, Errors, Export (whole tree, hashed on
│ │ │ request), Config (where, honoured, in force)
│ │ ├── Interactive/ Launcher, Browser, AuditView, ElevationPrompt
│ │ │ (line-based fallback for terminals without VT)
│ │ └── Output/ SizeFormat, PathDisplay, ScanExport (json/csv),
│ │ DeleteReport (plan, outcome, journal),
│ │ ReclaimTable (rules, one rule's paths, json),
│ │ DupeTable (groups, one group, paths, json),
│ │ Colors (--no-color and NO_COLOR, one switch),
│ │ Diagnostics (PATHMEMO_DIAG=1, the numbers of §20)
│ │
│ ├── Gui/ ← P11, the window (§24)
│ │ ├── GuiHost.cs opens the window, or declines so the TUI can
│ │ ├── GuiWindow.cs window class, message loop, DPI, dark title bar
│ │ ├── GuiShell.cs chrome, which view is up, where events go
│ │ ├── Render/ Rect, Colour (COLORREF byte order), Theme,
│ │ │ FontSet, Canvas (one DIB), IPainter +
│ │ │ GdiPainter, Draw (bar, tab, badge, scrollbar)
│ │ ├── Controls/ HitMap (what is under the pointer, recorded
│ │ │ while painting), RowList (virtualised rows)
│ │ ├── Views/ OverviewView, TreeView, Treemap (squarified,
│ │ │ pure), ReclaimView (rules, then one rule's
│ │ │ paths; reports, never deletes), ScanView
│ │ └── Work/ ScanJob (a scan on a worker; posts, never
│ │ touches the window), ReclaimJob (the rules
│ │ over a snapshot, the same way)
│ │
│ ├── Text/ Sanitizer - untrusted names (§14.4), shared by
│ │ both renderers since P11
│ │
│ ├── Tui/
│ │ ├── TuiHost.cs input loop and render, suspended during a scan
│ │ ├── TuiSession.cs snapshot, size mode, marks, ITuiView
│ │ ├── Terminal/ Screen (frame buffer + row diff), Line, KeyReader,
│ │ │ VirtualTerminal, TextWidth, Sanitizer, Draw
│ │ ├── Screens/ OverviewScreen, TreeScreen, ReclaimScreen, DupesScreen
│ │ └── Dialogs/ Details, Confirm, Delete, SortMenu, Help
│ │ (search is TreeScreen state, not its own file)
│ │
│ ├── Scanning/
│ │ ├── IScanner.cs ← one of the few justified interfaces
│ │ ├── Mft/ MftScanner (BFS, hard-link owners), MftParser
│ │ │ (fixup, attributes, data runs), MftVolume (\\.\C:,
│ │ │ $MFT extents from record 0, positional reads)
│ │ ├── WalkScanner.cs · FastEnumerator.cs
│ │ ├── UsnJournal.cs FSCTL query/read, record parsing, file id -> path
│ │ ├── UsnIncrementalScanner.cs what changed, and whether the journal may be used
│ │ ├── IncrementalTree.cs rebuild: copy the untouched, re-read the rest
│ │ ├── DirectoryWorkQueue.cs work-stealing
│ │ └── MediaTypeDetector.cs HDD/SSD → parallelism
│ │
│ ├── Snapshots/ SnapshotBuilder, SnapshotFile (.pmsnap sections and
│ │ header; SnapshotParts, and sections inflated
│ │ straight into the node arrays - §5.5),
│ │ SnapshotStore (numbering, retention),
│ │ NodeStore (SoA), NameBlob, TreeAssembly
│ │
│ ├── Analysis/ TreeQuery, SnapshotDiff (merge join), FileCategory,
│ │ ScanAggregates, Reconciler; ReclaimModels (two axes
│ │ plus the action), RuleEngine (prefilter, then match),
│ │ ReclaimPlanner (nesting, keep, the guard),
│ │ ReclaimIndex (node -> rule, for the screens)
│ │
│ ├── Audit/ IAuditProbe ← justified: ~20 implementations;
│ │ AuditRunner; Probes/ (Vss, WinSxS, Wsl, Docker,
│ │ Hibernation, RecycleBin, Dumps, …)
│ │
│ ├── Duplicates/ DuplicateModels (groups, the survivor rules),
│ │ DuplicateFinder (the five stages), HashPipeline (open,
│ │ identify, hash), ByteComparer (stage 4), HashCache
│ │
│ ├── Deletion/
│ │ ├── PathGuard.cs CRITICAL: open first, judge the handle
│ │ ├── ProtectedSet.cs CRITICAL: sealed vs anchor folders, keep, allow rules
│ │ ├── Canonical.cs volume-GUID names and segment-aware comparison
│ │ ├── HandleTreeDeleter.cs CRITICAL: handle-relative recursion, POSIX unlink
│ │ ├── Quarantine.cs store layout, manifest, rename by handle, restore
│ │ ├── RecycleBin.cs IFileOperation on an STA thread, quota check
│ │ └── DeleteEngine.cs plan, re-check, execute, free-space delta
│ │ (no IDeleteBackend: the three modes do not share
│ │ a contract - see §9.9)
│ │
│ ├── Platform/ Native/ (P/Invoke by DLL), Clipboard, ShellReveal,
│ │ FileLaunch, Elevation, KnownFolders, VolumeInfo,
│ │ ScheduledScan (task XML through schtasks.exe)
│ │
│ ├── Storage/ Database (PRAGMA, user_version, integrity_check),
│ │ Schema.sql (embedded, all of §11), ScanCatalog
│ │ (file-vs-row reconciliation, id allocation),
│ │ ScanRecords, ScanRepository, AuditRepository,
│ │ DeleteRepository (ops, items, dry runs),
│ │ DupeRepository (the last duplicate run)
│ │
│ └── Config/ AppPaths, AppConfig (+ DACL check when elevated),
│ PathGlob (one syntax, ** and %VARS%), DefaultRules
│ (the table of §7.2 as data), ConfigFile (the edits
│ K and --keep make, through JsonNode)
└── tests/PathMemo.Tests/
Why not six projects. For a tool written by one person that will never be a library, Cli / Core / Data / Platform / Reporting is friction without benefit: slower builds, InternalsVisibleTo ceremony, DI for the sake of DI, and no way to just call a function in the next folder. Folders and code review hold the boundaries. Split when there is a second consumer.
// JUSTIFIED: real polymorphism
interface IScanner { ... } // MftScanner, WalkScanner, UsnIncrementalScanner
interface IAuditProbe { ... } // ~20 probes
interface IDeleteBackend { ... } // recycle, quarantine, permanent, no-op (tests)
// REJECTED: one implementation, so the interface is pure overhead
// IClipboardService, IShellService, IReportWriter, IScanRepository
// -> static classes / concrete types, covered by integration tests.
A scanner knows nothing about persistence, so the two are separate and explicit: IScanner.ScanAsync(request, progress, ct) returns a ScanResult, and ScanStore.Save(result) writes the .pmsnap and the database rows, returning the id.
RSS under 250 MB at a million files is reachable only if:
List<FileEntry> of reference types, no full-path string per entry. Struct-of-arrays only (§5.3), with names in one shared byte[] blob and an offset per node.ArrayPool<byte>.Shared, always returned in a finally.ReadOnlySpan<char> without materialising.Interlocked/volatile fields, not an event per file.FSCTL_GET_NTFS_VOLUME_DATA) rather than grown by doubling. When the estimate is short, chunked arrays of 1M elements — never Array.Resize.ServerGarbageCollection=false, ConcurrentGarbageCollection=false, TieredPGO=true: for a short-lived CLI, workstation GC gives the lowest RSS.| Component | Choice | Why |
|---|---|---|
| Runtime | .NET 9, pinned by global.json | FileSystemEnumerator, System.IO.Hashing, NonBacktracking regex, the best single-file story. The pin stops a machine with a newer SDK compiling the code under a different language version |
| CLI parser | Not needed. Ten commands with flat options are a 40-line switch plus ArgParse; the package would generate help that is written by hand here and more accurate, and would remain the main NativeAOT blocker (§19.4) | |
| Static output | Not needed. Our tables are three or four row formats with fixed columns, static output uses no colour at all, and progress is one rewritten line | |
| TUI | own renderer (§18.1, §14.7) | ReadConsoleInputW was not needed either: the BCL parses both VT sequences and virtual-key records |
| GUI | Measured, not assumed. UseWindowsForms=true fails the build outright - NETSDK1175, trimming is not supported with Windows Forms - and untrimmed the same application is 126.10 MB against 25.70 MB trimmed; suppressing the error instead produces IL2104 on six framework assemblies, which TreatWarningsAsErrors turns into a failure. WPF costs the same, because an untrimmed self-contained build ships the whole 87 MB WindowsDesktop pack either way. WinUI 3 needs the Windows App SDK, which ends "no installer" on the first line of this document. Avalonia is ten packages against the two of §18, and a cross-platform surface §2 deliberately refuses. WebView2's own process is 80-150 MB against a total budget of 150 MB (§20). A window drawn by hand cost 0.27 MB | |
| SQLite | Microsoft.Data.Sqlite, hand-written mapping | Dapper is reflection, hostile to trimming and AOT. One of the application's two PackageReferences; the trimmed single file grew from 19.5 to 22.5 MB |
| Migrations |
Considered and dropped: Terminal.Gui, Dapper, DbUp, Blake3.NET, System.Windows.Forms, FluentAssertions, and System.Xml.Linq — which is in the box and still cost 8 MB.
v1 is legacy and v2 spent a long time in prerelease with a moving API. TableView is limited, and large lists need manual virtualisation anyway. Its heavy reflection costs trimming, and with it the difference between 20 MB and 70; NativeAOT becomes unreachable. Its own focus and event model fights a single-screen application that needs full control over how the tree is drawn.
This project needs one complex screen (a virtualised tree), four simple ones and some modals — 600–900 lines of own renderer with full control, no dependency, and correct CJK and emoji widths, which Terminal.Gui does not have either. Tui/Terminal/ is designed so the render backend can be swapped if that judgement turns out wrong.
| File | Size | Note |
|---|---|---|
pathmemo.exe (x64) | 16–30 MB | measured: 16.1 MB on the P0 skeleton (77.3 MB untrimmed), 19.5 MB at P3, 22.5 MB at P4 (Sqlite with native e_sqlite3 added 3 MB), 23.0 MB at P5 — the whole TUI fit in 0.3 MB because it has no dependencies — 25.3 MB at P8, of which System.IO.Hashing is under 0.1 MB: managed, trimmable, and the reason §8.2 chose it — and 25.57 MB at P9, all of the change journal and the scheduler for 0.27 MB, because XDocument was taken back out again (§18) — and 25.70 MB at P10: three commands, the lazy section reader and the diagnostics hook for 0.12 MB, which is what a codebase with no new dependencies costs - and 25.96 MB at P11, the entire window for 0.27 MB, which is the number the whole of §24.2 exists to earn |
pathmemo.exe (arm64) | 16–30 MB | separate binary |
pathmemo-<version>-win-x64.zip, pathmemo-<version>-win-arm64.zip | 11.9 MB at v0.2.0 | what a release publishes: the exe + README + LICENSE + CHANGELOG, built by build\publish.ps1 -Zip, and SHA256SUMS.txt beside them; the arm64 zip is 11.5 MB |
The icon (Resources\app.ico) is drawn by build\make-icon.ps1 rather than committed as an unreadable binary: the shape is twelve lines of code, and regenerating it is cheaper than explaining what is inside a blob. Sizes 16–64 are stored as classic DIBs and 128/256 as PNG: the Windows shell understands PNG inside an ICO but GDI+ does not, while everything reads the small sizes.
"One exe, x64 and arm64" is a contradiction — that is two files. The honest phrasing: one file per architecture, no installer, no dependencies.
dotnet publish src/PathMemo/PathMemo.csproj \
-c Release -r win-x64 --self-contained true \
-p:PublishSingleFile=true -p:PublishTrimmed=true -p:TrimMode=partial \
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=false \
-p:PublishReadyToRun=true -p:DebugType=embedded \
-o dist/win-x64
EnableCompressionInSingleFile=false is deliberate. Compression saves 40% of the size and adds 200–400 ms to every launch, and pathmemo is invoked from a console dozens of times a day. 28 MB without compression beats 17 MB with a delay.
e_sqlite3.dll is the only native dependency, which makes the host self-extract into %TEMP% once per version. Measured in P4: 1.72 MB extracted in 15 ms, once — pathmemo --version runs in 77 ms on the first launch after clearing the cache and 62 ms afterwards.
GitHub Packages is the wrong mechanism. It hosts package registries — npm, NuGet, Maven, containers — with no place for a plain .exe, and its NuGet feed demands a personal access token even for public packages. Asking someone to create a token before they can download a disk cleanup tool is exactly what should not happen.
GitHub Releases is the mechanism for standalone binaries: no account and no token to download, a download counter, and the release URL is what winget, Scoop and Chocolatey point at later.
.github/workflows/release.yml, on a v* tag:
CHANGELOG.md has a section for the tag and that <Version> in Directory.Build.props is the same number — a second of work, before the builds, against the two ways a tag goes wrong: a release page with nothing on it, and a binary whose --version is not the release it was downloaded from;dotnet test -c Release — a release that fails its own tests should not exist;win-x64 and win-arm64 (the arm64 cross-build from an x64 host is verified: 24.6 MB, 30 s);v0.2.0 → pathmemo --version = 0.2.0), so the title bar and the release cannot disagree;SHA256SUMS.txt beside them, with LF endings — a self-contained exe from an unknown author should be verifiable, and sha256sum -c is how that is done; v0.2.0 shipped the file with CRLF, so every line came back FAILED because the filename it looked for ended in a carriage return. Correct hashes that cannot be checked by the one command anybody would use are not a checksum file;CHANGELOG.md section with build\release-notes.ps1, which prepends what a first-time downloader needs (which file, how to start it, why Windows is about to warn them) and rewrites the changelog's relative links to absolute ones at that tag, so a reader lands on the document this build was cut from;gh release create --notes-file (gh is preinstalled on the runner), keeping third-party actions out of the supply chain.Every push is built and tested by .github/workflows/ci.yml, separately from releasing. The reason is the same failure: the compile error below sat on master and was found by the tag push — the one moment when red means there is nothing to download. CI runs the tests and a win-x64 publish, because trimming, ReadyToRun and the single-file host only run at publish time and an IL2xxx warning is a build failure here (§18). The release job keeps the arm64 cross-build, the zips, the hashes and the notes: five minutes that say nothing extra about whether the code is sound.
The toolchain is pinned to one feature band, not just to .NET 9. The first run this pipeline ever had failed, and not on anything to do with releasing: SnapshotFile.cs would not compile on the runner — cannot convert from ReadOnlySpan<byte> to Span<byte> — against code that built clean locally. MemoryMarshal.AsBytes(T[]) binds to both the Span<T> and the ReadOnlySpan<T> overload, an array converts to either, and a newer Roslyn in a newer feature band picked the other one. The call site is explicit now (AsBytes(array.AsSpan())), but the general lesson is about the toolchain rather than that line: rollForward: latestFeature let the band float, which is the very drift the global.json comment claims to prevent. So global.json names an exact band and setup-dotnet is given that exact version instead of 9.0.x — CI compiles with the compiler the tests were run against, and raising it is a commit. The Toolchain step prints dotnet --version first so a mismatch is the first line of the log rather than an error 40 seconds in.
The notes are generated, not written in the workflow. Notes typed into a YAML here-string drift from the changelog, and the drift only becomes visible on the download page after the tag is pushed. At P11 the inline notes still read "the tool never deletes anything on its own; deletion arrives in a later version" — three phases after rm, quarantine and reclaim --apply shipped. One source, checked before the build, is the fix. --notes-file rather than --notes for the same reason a painter sanitises every string (§14.4): markdown full of backticks and fenced blocks should not travel through a command line.
git tag v0.2.0
git push origin v0.2.0
workflow_dispatch builds the same archives without creating a release, for testing the pipeline.
winget points at the release, it does not host anything. packaging\winget\manifests\ mirrors the layout of microsoft/winget-pkgs, one folder per version, and a release reaches winget as a pull request that copies that folder there. The zip is a portable package: winget unpacks it, links pathmemo onto the PATH, and removes both on uninstall. The hashes in the installer manifest are the ones in that release's SHA256SUMS.txt, and winget validate --manifest <folder> checks the files before the pull request does. A new release needs a new folder; the old one is the template.
The site is in site\ and is not part of the build. A static React page, published to Cloudflare Pages as the project pathmemo at pathmemo.pages.dev. Its figures come from real runs of this tool, recorded in site\PRODUCT.md, so a release that changes them — the rule count, a measurement — means updating site\src\data\ too. Publishing is bun install --frozen-lockfile and bun run build, then wrangler pages deploy dist --project-name pathmemo --branch master; nothing in the release workflow touches it.
There is no code signature. An unsigned exe downloaded from the internet carries a mark of the web, and SmartScreen shows "Windows protected your PC" with More info → Run anyway. The release notes say so plainly. An OV/EV certificate costs money and a reputation built from downloads; until then, the checksum is the answer.
With Dapper, DbUp, Terminal.Gui and Blake3 out of the picture, the AOT road is open: PublishAot=true should give ~12 MB and a 15 ms start instead of 120. At P5 the System.CommandLine blocker is gone — it was never added — and the TUI is dependency-free and ports as is. What remains is Microsoft.Data.Sqlite (AOT-compatible via SQLitePCLRaw, needs verifying). At P6 the COM interop landed without closing that road: IFileOperation is declared with GeneratedComInterface and the sink with GeneratedComClass, so the marshalling is source-generated rather than reflected.
The three analysers are on permanently, in an ordinary build rather than only at publish time:
<EnableAotAnalyzer>true</EnableAotAnalyzer>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer>
With TreatWarningsAsErrors that turns every AOT hazard into a compile error, which is the only way the road stays open while the code grows: reflection over a type trimming will remove (IL2026), a path that needs runtime code generation (IL3050), an Assembly.Location that a single file does not have (IL3000). The whole codebase passes with zero warnings. That they are live and not silently disabled was checked the only way worth trusting — by writing the violations on purpose and watching the build fail with those three numbers, then deleting them again.
The AOT compiler itself runs clean over the whole dependency closure. Microsoft.Data.Sqlite and SQLitePCLRaw were the open question of §19.4, and ILC compiled the application and everything it references to a 36.96 MB object file without a single trim or AOT warning. That is the answer to "needs verifying", as far as compilation goes.
What is not verified: the link, and therefore every number. NativeAOT on Windows needs MSVC's link.exe, from the Desktop Development with C++ workload; this machine has the Windows SDK but no C++ toolchain, and installing several gigabytes of Visual Studio was not something to do to somebody's machine unasked. So there is no native executable, no size and no startup measurement — the "~12 MB and 15 ms" above remains a projection. On a machine that has the workload:
dotnet publish src/PathMemo/PathMemo.csproj -c Release -r win-x64 \
--self-contained true -p:PublishAot=true -o dist/aot
And one design consequence, which matters more than the size. AOT has no self-extracting host, so e_sqlite3.dll stops being a resource inside one file and becomes a second file beside the exe. Today's single file pays 15 ms once per version to extract it (§19.2); AOT would pay nothing and cost the promise on the first line of this document. That trade — 25 MB and one file against ~12 MB and two — is the decision P11 has to make, not a detail of the build.
Measured on: Ryzen 7, 32 GB, NVMe, Windows 11, 1.2M files / 420 GB on C:, Defender on.
P10: PATHMEMO_DIAG=1 is how these numbers are obtained, with the shipped executable and no profiler. A budget nobody else can measure is a wish, so the hook is part of the tool: it prints the load and aggregate timings to stderr — never stdout, which --json promises carries nothing but the document — and a memory block with the live managed heap, the private bytes and the peak working set. It is wired into scan, diff, tree, export and the TUI, which prints it after giving the screen back, because a diagnostic line inside a frame is what §14.6 forbids.
| Operation | Budget | Measured |
|---|---|---|
| Cold start to first frame | < 250 ms | 62 ms (--version), 77 ms when e_sqlite3 is extracted, once per version |
| MFT scan of C:, 1.2M records | < 10 s | 9.1–9.6 s — 1.79M $MFT records, 1.27M files / 399k dirs / 209 GB, 175–184k rec/s. The 5 s target has headroom in parallel chunk parsing |
| Walk scan of C:, 1.2M files | < 150 s | 40–51 s with hard-link dedup for files ≥ 1 MB (16k handles ≈ 3 s); 41 s without it at P3. The spread is filesystem cache state. Adding a 932 GB HDD costs fifteen minutes more, so every figure here is C: only |
| Incremental USN scan | < 2 s | not measured on hardware. Reading the journal is administrator-only and the session that built this had no way to elevate (§4.5.1). What is measured is the part after the read: rebuilding a real tree from its changed directories agrees with a full walk scan of the same tree path by path and byte by byte, and copies the untouched nodes without re-interning a single name |
| Writing a snapshot | < 1.5 s | 26 MB for 1.58M nodes |
| Opening an existing snapshot | < 300 ms | 181 ms for 1.58M nodes, everything decompressed - 226 ms before the sections were inflated straight into the node arrays (§5.5). The error list on its own is 0.8 ms, and the header alone is a 40-byte read |
| Tree navigation, one frame | < 16 ms | 0.08 ms in an ordinary directory, 0.6 ms scrolling a 26k-entry one where every row changes; 4–7 ms for the first frame after a load |
| Entering a directory with 200k children | < 50 ms | 10–15 ms on the widest real directory (WinSxS\Manifests, 26,205 entries); a synthetic 200k one stays in budget. Children are contiguous, so only the sort is paid for |
| Searching names across a snapshot | — | 56–69 ms over 1.58M nodes, 402 matches for node_modules |
Overview / status data | — | 48–70 ms: volumes, last scan, a used-bytes series over 40 scans |
| Space Audit, all probes | < 8 s |
Correct numbers
C:\Windows and C:\ match WizTree within 1% — not compared yet; MFT gives 42.3 GB (WinSxS 6.94) against the walk's 43.4 GB (WinSxS 8.47).ext4.vhdx shows allocated, not logical, with a sparse badge — the mechanism works and is counted (3,750 sparse or compressed nodes on this disk, the largest 72 MB allocated against 235 MB logical), but there is no WSL disk on this machine, so the ext4.vhdx case itself is untested.cloud badge, its logical size in details — no OneDrive placeholder exists on this machine: a whole-disk export at P10 found zero cloud nodes, so the flag has never been seen set outside a test.unique/allocated/logical changes the numbers predictably — on this disk: a compressed file is 72 MB unique and allocated against 235 MB logical (C:\Windows\servicing\Sessions\Sessions.back.xml), and WinSxS is 8.47 GB unique against 8.52 GB logical, the difference being the hard links held out. Checked through top --size on a real scan at P10.Scanning
[R]/[C]/[Q] only with a TTY; scripts get stderr.scan_errors, grouped; in MFT mode their size is known — MFT sees 53k more files and 38k more directories than an unelevated walk.Ctrl+C saves a partial result as cancelled; a second exits immediately — verified at P10 by driving a real console: one Ctrl+C during a scan of C:\Windows printed stopping - press Ctrl+C again to abort immediately, finished the summary with CANCELLED: totals are incomplete, saved a 4.37 MB snapshot of 138,115 files, and left the row as cancelled with the partial flag. The second Ctrl+C was not exercised: the first one stopped the scan in well under a second, so there was no window to press it in.reparse and size 0 — counted over a whole-disk export at P10: 1,882 reparse points, every one of them 0 bytes with 0 files under it, and C:\Documents and Settings (the junction to C:\Users) appears exactly once in 1.58M rows, with nothing beneath it.self badge — the export carries self on the store directory, the database and its WAL, the snapshots directory and every .pmsnap in it: 10 nodes, 132 MB, shown and never a deletion target.Space Audit
[admin] / [reboot], copyable.DISM parse yields unknown, never 0 bytes.Deletion
c:\windows, C:\WINDOW~1, \\?\C:\Windows, \\localhost\c$\Windows, C:\Users\All Users\..., C:\Users\..\Windows — twelve spellings, each opened and refused on the real machine.ERROR_NOT_SAME_DEVICE becomes that sentence. The 18 GB timing is not measured yet; a rename does not depend on the size.restore returns the tree to its original paths and refuses when something else has taken the name; purge frees the space, measured against the volume rather than assumed.recycle mode and is never destroyed silently — the quota is read before the mode is chosen, and the mode falls back to quarantine with the reason on screen.--dry-run moves nothing and writes to dryrun_log, not delete_ops.--yes cannot bypass; a script uses the token from a dry run of that exact list.Reclaim
hiberfil.sys, pagefile.sys, WinSxS, C:\Windows\Installer, System Volume Information or .git\objects — the "deliberately not rules" list, asserted as a test.git gc and docker system prune survive --apply --risk danger --force.K in the TUI writes it into config.json without disturbing the rest of the file.--apply goes through rm: the guard, the confirmation, the journal and the free-space measurement are the ones from §9 — verified end to end, 7.23 MB predicted and 7.23 MB freed.danger needs --force plus a typed confirmation, wherever the path came from; exit code 6 without it.Duplicates
Keeper.Survives in the core, exit code 6 from the command, a refusal with a reason on the screen.--apply (exit code 6).--same-volume stops it — asserted over a two-volume snapshot, and run for real with one copy on C: and one on D:: found together, and not found at all under --same-volume.CLI
status.--json emits valid JSON and nothing else, warnings on stderr — tests on the §13.6 contract.EX_UNSAFE is what a guard refusal and a stale token return.top --paths-only | rm --from-stdin --dry-run works as a pipe.errors, export and config were the last three, and the help text no longer has a "not implemented yet" list. P10: run against a real 27 MB snapshot: 527 refused paths grouped by kind in 12 ms, 1,583,976 rows exported to CSV and JSON, and a config template that parses back to exactly the defaults (asserted, so a typo in it cannot ship).--no-color is accepted by every command — stripped before the verb, like --data-dir; checked on top, history, errors and status, and the environment variable still means the same thing.doctor reports elevation, filesystems, USN state, integrity, version, free space — the CHANGE JOURNAL block names each NTFS volume's journal, its size, its next USN and whether the next scan can be incremental, with the reason when it cannot. Verified unelevated on a two-volume machine.TUI
cmd.exe; a host without VT falls back to the line menu, a raster font to ASCII glyphs.pathmemo <version>, 110×32 and quick-edit off; clicking inside does not freeze rendering.Ctrl+C is a standard cancel — the TUI exits, the scrollback is intact, the next command runs.Ctrl+D/Ctrl+U, which have PageDown/PageUp aliases.U+202E does not reverse the text, and CJK or emoji names do not break alignment — asserted by a test over every frame row.o refuses .exe, .lnk and .hta — before the dialog, tested on invoice.pdf.exe.GUI (P11)
C:\, in Users, and one level further, including a Cyrillic directory name and a reparse point with nothing under it.cancelled, and the overview says the totals are incomplete above them (principle P1).Hygiene
BEGIN EXCLUSIVE; in WAL mode reading is never blocked, and a write refuses with one warning while still saving the snapshot. Losing a 10-second scan over a busy history row would be worse.integrity_check on the next start, a clean one does not — unit-tested both ways..pmsnap does not break the application: history prints deleted, diff refuses with an explanation.config.json gives a warning and defaults, not a crash — and one bad value warns about that key alone.--data-dir always, and the whole file when its permissions let anyone else write it (§12.1).Pure logic, no filesystem: RuleEngine (glob matching, variable expansion, keep precedence); PathGuard.IsProtected over pre-canonicalised strings, all twelve bypasses; NodeStore / SnapshotFormat round trips, boundaries and corrupted data; DiffEngine and HardlinkResolver over synthetic data; DISM and vssadmin parsers over fixed samples including non-English locales; size formatting, path truncation, CJK width, bidi sanitisation.
P4: SnapshotDiff — 13 tests over synthetic trees (TestTree builds a NodeStore from a list of paths): the explaining level, splitting across children, residual rows, the threshold, appeared/disappeared, a name changing type, hard-link aliases, and rows plus remainder equalling the volume's change. ScanAggregates — category inherited from the directory, the extension cut-off. ScanExport — the JSON contract and CSV quoting. Storage runs against a real SQLite file in %TEMP% rather than in-memory: WAL, the .clean marker and file-versus-row reconciliation exist only on disk, and they are the subject. The suite runs serially because the data directory is one static path per process.
P6: the protected set over canonical strings — the sealed-versus-anchor rule, the whitelist inside the blacklist and its anchor, the keep list, $Recycle.Bin and System Volume Information, the store and the one operation allowed into it. PathGlob per segment, **, name-only patterns and variable expansion. The confirmation token: same list in another order gives the same token, one byte or one mode different gives another. AppConfig — a good file, a broken one, and a file with one nonsense value. The filesystem half is in §22.2.
P7: the rules over synthetic trees — 26 tests: a rule matching anywhere, a match inside another match (within a rule and across two), the sibling and child conditions that separate a Unity project from any other Library and a virtual environment from a folder of videos, the age and size floors, a veto pattern, a hard-linked file counted as shared, an alias adding a name and no bytes, the keep list, a reparse point never matched, the store never recommended, an extension pattern not claiming a directory, a wildcard inside a segment, which rule wins when two claim a node, the risk ceiling, RiskOf, disabled and replaced rules, a custom rule out of config.json and a broken one skipped, and two properties of the whole built-in set: every Command rule names its command, and none of them claims anything on the "deliberately not rules" list. The reclaim screen — 9 tests that press keys and read the frame, including that the first frame is drawn before the background pass has finished. The filesystem half is in §22.2.
P8: the survivor rules over paths alone — a keep match beating everything, a hard-linked file beating an ordinary one, Downloads losing to anywhere else, then depth, then date; and the invariant, which refuses both a group with every copy marked and a marked path the keep list claims. The duplicates screen — 11 tests that press keys and read the frame: the order, the marks the screen opens with, marking the last copy being refused, a hard-link set hidden until t, K writing the configuration and unmarking, and d re-checking before it offers a dialog. The rest of the module needs real files and is in §22.2.
P9: the journal's records over synthetic buffers — a V2 record, a V3 record with its 128-bit ids, several in one buffer, a record reaching past the returned bytes (the earlier ones kept, the watermark still returned), a version this build does not know stepped over by its own length, a record claiming no length stopping the walk rather than spinning, and a buffer holding only the header. The refusal rules: no previous scan, a cancelled one, a snapshot with no watermark, a base that covered other roots. The rebuild over a fake disk that records which directories were opened — an untouched one is never opened at all, a file added, deleted or grown changes every total above it, a subtree moved in is walked in full although only its parent was named, a directory that cannot be read is unknown rather than empty, a reparse point is counted and not entered, children stay one contiguous range, and the untouched part keeps the name offsets it had. The name blob's seeding constructor, the watermark's snapshot round trip, and the scheduled task: the four settings that make it polite, a weekly schedule naming its day, the document read back as what was written, an ampersand in the install path, and something that is not a task document being no schedule rather than a crash. --time on a 24-hour clock whatever the locale, and --day in any case.
P10: the snapshot's sections, which is where the memory win could most easily have become a corrupt tree: the error list read without the tree (and the tree still there when asked for), the header alone answering what kind of scan it was, an unrequested part being the shared empty tree rather than null, and a full read compared array by array against the original - all twelve of them, the roots, the name blob, and every name read back through its offset, because a byte-identical blob does not by itself prove the offsets still point into it. The export: one row per node plus a header, a name holding a comma and a quote, a name starting with = defused, the JSON document parsing as one object whose entryCount matches what was written, and redaction asserted from both directions - the original names appear nowhere, while the row count, the sizes, the depth and the repeated-name identity are unchanged, a file keeps its extension and a directory does not. The error list's wording: a message that only repeats its path is suppressed, a Win32 code with no message still prints, and the bytes behind a refused path are null when no tree is loaded rather than zero. The config template parsed back into AppConfig and compared with the defaults value by value, so a typo in the document the tool writes cannot reach a user; and --no-color leaving the argument list exactly as the verb expects it.
P5: the terminal layer — 15 tests: CJK, emoji and combining-mark widths, Fit landing on exactly N columns, sanitisation, frame row diffing (an identical frame writes nothing, a changed row writes only itself), the erase-before-write order, colour suppression, sparkline scaling. The Tree screen — 14 tests that press keys and read the frame: row order, descending and returning to the same row, the size mode on a hard-link alias, the filter, search jumping into the match's directory, marks, refusing .exe, details, alignment under U+202E and CJK, a 200k-entry directory drawing exactly one page, g/G, narrowing to 80 columns. Frames are asserted as uncoloured text: assertions about escape sequences would test the colour scheme, not the behaviour.
An IFileSystem abstraction is deliberately not introduced. All the value is in Win32 edge cases — reparse points, hard links, sparse files, long paths, ACLs, sharing violations, POSIX delete — which a fake filesystem cannot reproduce. A fake would produce green tests and a red production build.
The harness builds a real tree in %TEMP%:
CreateJunction, CreateSymbolicLink, CreateHardLink
FSCTL_SET_SPARSE + FSCTL_SET_ZERO_DATA -> a sparse file
a 400-character path through \\?\
a file opened with FileShare.None -> sharing violation
a directory with a DENY ACE -> access denied
names with U+202E, CJK, emoji and control characters
a recursive junction loop, a zero-byte file
two links to one file in different directories
It checks traversal, sizes, dedup, errors, and above all that deletion never leaves the tree.
P6: the twelve spellings of a protected path, each opened against the real machine, plus the property they rest on — the aliases of one directory canonicalise to one name. A tree deleted around a junction, with the junction's target untouched afterwards. A read-only file. A file another process holds open, unlinked while that handle still reads its data. Quarantine, restore, a restore refused because something took the name back, and purge. A dry run that moves nothing and lands in the right table. The scan-verification refusal, and its opposite. A file whose size is not a whole number of clusters — a regression that once rejected most files. One real item into the Recycle Bin through IFileOperation, because the apartment, the sink and the copy engine's own success codes cannot be faked.
P7: the three things a synthetic tree cannot show. That the report never promises what the guard refuses: one rule pointed at a name the real system directory also has, with one match it may offer, one the guard refuses and one that has gone since the scan. That a contents-only rule hands over the children and not the directory. That config.json survives being edited by K and --keep: an unknown section is still there afterwards, a second K on the same path is not an error, and a rule disabled and enabled again leaves the file as it was.
P9: the rebuild against the real thing, which is the test that decides whether the feature is trustworthy — the claim an incremental scan makes is not "it is fast" but "it says what a full scan would say". A real tree is scanned in full, changed (a file added, one deleted, one grown, and a 7 MB subtree moved in from outside), then rebuilt from the directories a journal record would have named, and the two trees are compared path by path, byte by byte and file by file. Plus: an untouched directory keeping the size the full scan gave it, an empty directory that filled up, the allocated size of a re-read file matching a full scan's for a one-byte file as well as a large one — not "logical rounded up to a cluster", because a one-byte file's data lives inside its own MFT record and occupies no clusters at all — and the contiguous-children invariant holding across a rebuild. None of it needs administrator rights, which is the point: the journal read is the only part that does.
P10: not a test file but a measurement, and it belongs here because it is the only way the claims of §5.5 and §20 can be checked: a real 27 MB snapshot of this machine's C: (1,583,976 nodes) opened before and after the change, in the same process, with the working set read from the OS rather than from the GC. And three things driven through a real console, because that is where they live: the TUI opened on the same snapshot with its peak working set sampled from outside (134.9 MB), one Ctrl+C into a running scan (a cancelled row and a partial snapshot), and a whole-disk export whose 1.58M rows were then counted by flag - which is how §21's reparse, self and size-mode criteria stopped being assertions about the code and became counts from a disk. The keystroke scripts refuse to type anything unless the console they started is the foreground window; a stray Ctrl+C in somebody else's terminal is not an acceptable cost of a measurement.
P8: the whole funnel against real files — three copies of one file becoming one group that gives back two; two files of one size that differ; a size nothing shares never being opened at all; a hard-link set through CreateHardLink, apart from the duplicates and freeing nothing; a real copy beside a hard-link set still being a duplicate; the byte-for-byte stage splitting a group a hash had agreed about, including the counter that says it happened; the re-check refusing the whole operation after one copy is edited, and refusing a name out of a hard-link set; the hash cache answering a second run without reading the files, and the same groups coming out of it; a run stored and read back with its totals, and a second run replacing the first; and sha256 finding what xxh128 finds. Cloud placeholders are tested through a snapshot that claims them, because a real one needs a cloud provider and would be a test of OneDrive.
Thread A deletes a tree recursively while thread B repeatedly swaps a subdirectory for a junction pointing at a guarded canary. After 10k iterations the canary must be intact. Without this test HandleTreeDeleter cannot be considered done.
P6: implemented and run. Junctions are created through FSCTL_SET_REPARSE_POINT rather than mklink, or the race would be a contest between the deleter and the process loader. The full run: 10,000 rounds, 19,478 junctions actually swapped in, canary intact, 114 s. The suite runs 600 rounds under a 20-second budget; PATHMEMO_RACE_ITERATIONS asks for the long version. The test also asserts that at least one swap landed — a round where the attacker never won the race would pass while testing nothing.
A script compares a WizTree CSV export over the 50 largest directories, tolerance 1%. Run by hand before a release on two or three real machines — synthetic data is useless here.
Actually invoking vssadmin delete shadows, DISM /StartComponentCleanup or powercfg /h off — by hand, on a VM with a snapshot. Such tests have no place in CI.
The suite declares whether it is elevated; it does not inherit it. TestStore.Init fixes the answer to no for the whole assembly, and TestStore.Use verifies the store actually moved to the temporary directory before a test writes anything. This is not tidiness. --data-dir is deliberately ignored while elevated (§12.1), so run as an administrator the same setup code silently leaves the store pointing at the real one: the suite then wrote its fixtures into a developer's own snapshots and destroyed one of them, because Imports_a_snapshot_that_has_no_row saves a fixture as scan 7 and scan 7 already existed. A GitHub Windows runner is an administrator by default, which is how one push found it. The check in Use is the half that matters — it fails on the test's first line instead of quietly operating on real data, and it holds even if the declaration is later removed. §12.1's refusal has its own test now, which it never had, because until this seam existed the rule could only be observed by running the suite in the one configuration that broke it.
The MFT scanner has now been checked against the walk — and it was the walk that was wrong. Mft_scanner_agrees_with_the_walk_when_elevated needs $MFT, and for a long time it never ran: unelevated it returns at its first line, and once the suite declared itself unelevated (above) it returned there elevated too, because it asked the declaration rather than the token. It now asks Windows directly and claims elevation for its own duration only. Run at last, it failed on four files: file counts, directory counts and logical bytes agreed, allocated bytes did not, the MFT saying 10,054 more. That is precisely the cluster rounding of the three ordinary files (3,104 + 3,232 + 3,728) less the 10 bytes of the one resident file — the walk was reporting logical sizes as allocated ones. GetCompressedFileSize, which it asked for every file, answers with the logical size for any file that is neither compressed nor sparse; the audit's DirectoryMeasure had known that since P2 (§6.4), the scanner had not. Over the 1.2M files of an unelevated walk of this machine's C: that is roughly 2.4 GB understated, booked by §3.5 to unaccounted. The walk now rounds up to the cluster and asks only for compressed and sparse files, which also removes 1.2M syscalls from a scan (§4.4); WalkSizeTests pins both branches against real files, unelevated, on every run, and the elevated comparison passes with the one-cluster slack it always allowed for.
P10: three more, each for the same reason — the machine, not the code, is what is missing. The native link, because NativeAOT needs a C++ toolchain the build machine does not have (§19.4.1); the compilation is checked, the executable does not exist. A cloud-only file, because there is no OneDrive placeholder on this disk: the flag is asserted over synthetic trees and has never been seen set by a real scan. Reading the change journal, still, from P9. All three are stated where they matter rather than left to be discovered: in the status table, in §20, and as unticked boxes in §21.
The views are tested through a recording IPainter, so what a frame would say is asserted without a window, a message loop or a screenshot (§24.6). The treemap and the row virtualiser are pure and tested against their properties rather than against golden output: area proportional to value, no block outside the frame, no two blocks overlapping, and ten rows drawn out of two hundred thousand.
The navigation is tested the same way, and was not until after v0.2.0. IShellWindow is to the shell what IPainter is to a view: a recording implementation, so which view a click selects and what the status line ends up saying can be asserted with no handle and no message loop. It was added for a defect, not for symmetry — see §24.6. The tests there assert what the shell did afterwards, but the assertion that matters is that they return at all: run against the old code they do not fail, they take the test host down with them, which is exactly what they would have done to the release had they existed.
What still needs a real window is driven by posting messages to its handle and photographed with PrintWindow. Both address the window directly rather than through the foreground, which is what makes this safe to run on a machine somebody is using: in P7 the same job done with SendKeys and a screen capture typed into the user's browser and photographed it (§22.5).
| Term | Meaning |
|---|---|
| MFT | Master File Table — the metadata of every file on an NTFS volume, in one file. Reading it directly lists the disk two orders of magnitude faster than walking it. |
| USN Journal | The NTFS change journal: what changed since last time, without a walk. |
| Allocated size | What is really occupied on the volume (clusters, compression, sparse holes). The only quantity that adds up to "used". |
| Logical size | The length of the data stream — what Explorer shows in a file's properties. |
| Unique allocated | Allocated, with hard links deduplicated. |
| Reclaimable | How many bytes deleting a given set actually frees. |
| Hard link | Several names for one physical file. WinSxS is almost entirely made of them. |
| Reparse point | Junction, symlink, mount point, cloud placeholder. Needs care when walking and when deleting. |
| Sparse file | A file with holes: logical > allocated. Typical for .vhdx. |
| Cloud placeholder | A OneDrive or Dropbox file whose data is in the cloud. Reading it downloads it. |
| VSS / shadow copy | A shadow copy of the volume; restore points. Invisible to a filesystem walk. |
| WinSxS | The Windows component store. Cleaned only through DISM. |
Snapshot (.pmsnap) | A binary image of one scan's file tree. |
| Quarantine | Staging for deleted items on the same volume. Frees space only after a purge. |
| Purge | Physically deleting the quarantine — the moment space is actually freed. |
| Risk | What breaks if this is deleted: Safe / Caution / Danger. |
| Recoverability | What getting it back costs: Instant / Redownload / Rebuild / Irreversible. |
| Finding | The result of one Space Audit probe. |
P11. §2 used to list a GUI among the non-goals. That changed deliberately, and it changed on one condition: the window is a second face, not a replacement. Every command still exists, still prints the same JSON and still returns the same exit codes (principle P8), the terminal screens of §14 still answer where there is no desktop, and the single file on the first line of this document is still a single file. What the window adds is that somebody who does not want a command line no longer has to use one.
| Condition | Behaviour |
|---|---|
| Double-clicked from Explorer | the window; the console Explorer created for us is hidden |
pathmemo gui / --gui | the window, from any terminal, which keeps its own console |
-i / --interactive / --tui | the terminal screens of §14 |
CreateWindowEx fails | the terminal screens, then the line-based menu — the same ladder §2.1 already had, one rung longer |
The executable stays a console application. A windowed subsystem would mean pathmemo scan printing nothing into the terminal it was typed into, and that is the whole of principle P8. So a double-click still gets a console — Explorer makes one — and the window hides it. It is hidden only when it is ours, the question ConsoleOwnership already answers for §2.1: run from a terminal, hiding the console would hide the user's own shell window, which is about the most destructive thing a GUI can do by accident. A console that was hidden is shown again before the process exits, because a fatal message still needs somewhere to go.
Elevation is a button rather than a prompt. The manifest stays asInvoker (§4.2); As administrator relaunches through ShellExecute runas and tells the new copy to open a window, because answering a request for a window with a console would be a strange way to grant it.
The window is a registered Win32 class, a message loop, and one bitmap. There is no UI framework, and the reason is a measurement rather than a preference:
| Trimmed single file | Untrimmed | |
|---|---|---|
| The application at P10 | 25.70 MB | 83.50 MB |
With UseWindowsForms=true | build fails: NETSDK1175 — trimming is not supported with Windows Forms | 126.10 MB |
| Suppressing that error | IL2104 on System.Windows.Forms, System.Windows.Forms.Primitives, System.Windows.Forms.Design, Microsoft.VisualBasic.Forms, System.CodeDom and System.Configuration.ConfigurationManager, which TreatWarningsAsErrors turns into a failed build | — |
| The window written by hand (P11) | 25.96 MB | — |
So a framework costs either the trimming this project is built around or five times the size, and in both cases the NativeAOT road of §19.4 — because neither Windows Forms nor WPF is AOT-compatible. Enabling WinForms also injects global using System.Windows.Forms and System.Drawing, which collide with this codebase's own Screen and Timer: the SDK offers no property to turn that off. WPF is not cheaper; an untrimmed self-contained build ships the entire 87 MB Microsoft.WindowsDesktop.App pack regardless of which of the two is used. WinUI 3 needs the Windows App SDK installed beside the exe. Avalonia is ten package references against the two of §18. WebView2 puts a browser process of 80–150 MB against a total budget of 150 MB (§20).
The hand-written window cost 0.27 MB. That is the same conclusion §18.1 reached about Terminal.Gui, reached again with numbers instead of a judgement.
GDI, not Direct2D, because every shape this application draws is a rectangle, a line or a run of text: a table of bars, a tab strip, a treemap. GDI needs no factory, no device-loss handling and no extra assembly, and it renders text with the same ClearType as the rest of the desktop.
One bitmap per frame. Everything is composed into a single CreateDIBSection surface and copied across in one BitBlt, and the class has no background brush so Windows never erases first. A window that paints its parts straight to the screen flickers; this is the same discipline the TUI's frame buffer follows for the same reason (§14.7). The surface only ever grows, so dragging a window larger does not reallocate several megabytes per WM_SIZE.
The window procedure is a function pointer, [UnmanagedCallersOnly], not a marshalled delegate: no reverse stub to generate, nothing for the garbage collector to take away while Windows still holds it, and the AOT road stays open. The managed object behind a handle is found through a GCHandle in the window's user data, recovered from CREATESTRUCT on WM_NCCREATE — the first message arrives before CreateWindowEx has returned, so there is no later moment at which the association could be made. Nothing is allowed to throw out of the procedure: an exception crossing into native code kills the process silently, so everything is caught, kept, and re-thrown on the thread that owns the loop once the loop has ended.
Per-monitor-v2 DPI is requested before the first window exists, and every measurement in every view is written as the number it would be at 96 dpi and passed through IPainter.Scale. Fonts are rebuilt on WM_DPICHANGED, and their heights come from GetTextMetrics rather than from the point size, because the row height has to hold what the font mapper actually chose.
Light and dark follow the system's own AppsUseLightTheme, including the title bar through DWMWA_USE_IMMERSIVE_DARK_MODE. The palette is one background, one surface, one border, three text weights and one accent; hovered, selected and the treemap's shading are all Colour.Mix between two of those, because a palette with an entry per state drifts. PATHMEMO_THEME=light|dark forces one, the way PATHMEMO_ASCII=1 forces the glyph fallback (§14.7): checking how the other mode looks should not require changing a setting on the machine doing the checking.
A Colour is a type rather than a uint because GDI's COLORREF is 0x00BBGGRR — the bytes are the reverse of the #RRGGBB every palette is written in, and getting it backwards produces a window that is wrong in a way that looks deliberate.
A toolbar with the view tabs and the actions, the view, and a status line that says which scan the numbers came from and how old it is.
Overview is the volumes, the last scan and this tool's own footprint against the 500 MB bound of principle P7. Free space comes from the volume and is current; everything else comes from a scan and is dated, so every stored number is shown with its scan id — the rule that StatusReport exists to enforce (§14.7), and it matters more in a window, where a number on a card looks live in a way a printed line does not. A scan that was stopped says so above its own totals.
Tree is the main screen and the same shape as §14.2: children by size, a bar and a percentage relative to the current directory, the badges of §14.2, and the file count. Navigation is what a window can do that a terminal cannot — a double-click descends, the path above the list is clickable segment by segment, and the arrows, Page Up/Down, Home and End work as well. m cycles the size mode on the same key the TUI uses, because someone who knows one should not have to learn the other.
The map is a treemap panel beside the table (§24.5), not instead of it. It is the first thing dropped when the window is narrow: the table answers every question the map does, and a map 200 pixels wide answers none.
Reclaim is the report of §7.3 in the two levels of the terminal's screen 4 (§14.7): the rules that matched, largest first, and behind each one its paths. A row carries what deleting it gives back, a bar and a share of what is listed, the rule, both axes as one badge — safe redownload, caution irreversible, because the two together are the point (§7.1) — the table's note column, and how many places matched. A double-click or Enter lists the paths; the word Reclaim above the list is the way back, in plain sight like the tree's breadcrumb, and the cursor returns to the rule it left. The risk ceiling is three segments rather than a key that cycles, because a window can show every choice at once and a key hides two of them; t still cycles it and y still copies the paths, on the terminal screen's letters.
Four decisions in it are about honesty rather than layout. The headline counts only what pathmemo can delete itself: a disk image that needs a person and a repository that needs git gc are listed with their sizes, dimmed, and named beside the total — 28.5 GB reclaimable · 8.45 GB for another tool or a decision on this machine — because a total that includes bytes no command of this tool will ever free is the number that never arrives (§7.3). It reports and does not delete. What it lists is the unverified index the terminal screens draw their badges from (§7.5) — the rules over the snapshot, without a handle opened per match to ask the guard — and the footer says where deleting, and the guard's check that precedes it, actually happen. A delete button belongs with a delete dialog that shows the plan, the mode and the refusals (§14.1), and that dialog is not in the window yet. It shares the tree's snapshot rather than loading its own: one 128 MB snapshot in memory instead of two (§24.7), and one scan behind the tree and the recommendations instead of two. And it names that scan, and says when it was cut short. The headline ends in the scan id, because these are stored numbers (§14.7) and because the status line describes the last completed scan while the views open the newest snapshot: after a scan that was stopped those are two different scans, and the live check of this tab had both on screen at once. A partial snapshot is fit for browsing, not for reclaim advice (§4.8): a cache the scan never reached is simply absent and one it was half way through is listed at half its size, and both look exactly like a complete scan's rows. So above them the tab says the scan was stopped early and that every number is a floor.
Audit and Duplicates are not in the window yet, and each tab says which command does that job today. That is the same promise the TUI's screens 4 and 5 made before P7 and P8 filled them in (§14.1) — an unfinished screen that names its replacement is not a dead end.
Hit testing is recorded while painting. Each view registers the rectangles it drew into, and a click is resolved against what the last frame registered; there is no control tree and no second layout pass. This is not laziness, it is the property worth having in an application that deletes files: a retained hierarchy can disagree with the picture, and a row whose bounds were not updated after a resize is a click that hits the wrong thing. Here the only bounds that exist belong to something that was actually drawn, so what you clicked and what you saw cannot come apart. A modal seals the layers beneath it, so a click aimed at a dialog cannot reach the list behind.
A walk of C: takes 40 seconds (§20), which is the difference between a window and a hung window. This is the one problem the terminal interface never had to solve: the TUI hands the console back and lets the scan print to it (§14.7), and a window has nothing to hand back.
So a scan runs on a worker thread and reports through IProgress<ScanProgress> — the same ScanCommand.RunAsync the command line calls, given a different sink. The elevation offer, the reserved scan id, the snapshot, the history row and the bookkeeping when it fails are one implementation, not two; a second orchestration would be a second set of rules about what happens when the database is locked.
Nothing on the worker touches the window. Progress is left in a field and a message is posted; the window collects the latest value when it arrives. The posts are coalesced — while one is outstanding no other is sent — so what the window reads is always the newest progress and never a backlog of thousands, which is what a progress display wants anyway.
The reclaim rules run the same way, for a smaller reason: from the key to the list is between 0.6 and 0.85 s in the published build on a 1.2-million-node snapshot, of which loading the snapshot is about 0.2 (§20) and the thirty-one rules are the rest — forty frames. The tab draws Applying 31 rules to scan 8… at once and the list when the worker posts that it is ready — one post, because there is no progress to report, only a result. The index is tagged with the scan it was built from and the shell drops its reference when a scan replaces the snapshot, so a worker that finishes late has nobody to deliver to and cannot put one scan's recommendations over another scan's tree. When a scan finishes while the Tree or the Reclaim tab is showing, that tab reloads: before this, the tree said run a scan first one second after a scan had finished.
Stopping is the graceful cancellation of §4.8: what has been read is kept, the snapshot is written, the row says cancelled and the result carries partial. The window reports that as stopped, totals incomplete rather than finished, because exit code 3 and exit code 0 mean different things and a complete-looking total over an incomplete scan is exactly what principle P1 forbids. Closing the window during a scan stops it first and closes when it has finished, rather than killing it mid-write.
Squarified (Bruls, Huizing and van Wijk), and a pure function: a list of sizes and a rectangle in, a list of rectangles out. The naive layout cuts the frame into parallel strips, which turns a directory of a hundred items into a hundred one-pixel slivers — visible, unclickable and unreadable. Squarifying fills the short side with a row whose worst aspect ratio stops improving, which produces blocks that can be aimed at with a mouse. That is the whole reason a treemap belongs in a window rather than in a terminal.
Colour is the category (§6), because colour carries the one fact a size view cannot: 40 GB of media is a different decision from 40 GB of cache. Blocks below a few pixels are dropped rather than drawn — on a 1.2 million file disk the tail is most of the list and none of the area, and drawing it would make the map look detailed while showing nothing. The table beside it remains the complete answer.
IPainter has two implementations, which is what §17.2 requires before an interface is allowed to exist: one draws with GDI, and one records. A view is therefore a function from state to a list of fills and text runs, and a test can assert what would appear — that a row carries the right size, that a directory is drawn with its separator and a file is not, that going into a directory and back leaves the cursor where it was, that nothing is drawn outside its clip — with no message loop, no desktop and no screenshot to compare. It is the same trick that made the TUI's frames testable (§22).
The treemap is tested against its two properties directly: every block's area is its share of the space, and no block leaves the frame or overlaps another. Virtualisation is tested by asking a list of 200,000 rows to paint and counting how many were drawn. The COLORREF byte order has a test of its own, because it is the one mistake in this layer that produces a result which looks intentional.
What that left uncovered, and what it cost. IPainter makes a view testable; it does nothing for the shell — which view is showing, what a tab click does, what happens when the thing a click asks for cannot be loaded. That was the only part of the window with no tests, because it was the only part that needed a window, and it is the part that shipped a crash in v0.2.0. With nothing scanned yet there is no snapshot to browse, and Show handled that by calling OpenTree, which handled the failure by calling Show: the pair recursed until the stack ran out and Windows killed the process with 0xC00000FD, two frames short of the message that says to run a scan. Clicking Tree did it; so did clicking a volume card. It is a first-run bug — the one state every new user is in — and no amount of testing the treemap was going to find it.
So IShellWindow now exists, with a recording implementation beside RecordingPainter, and the two methods are split into one that decides and one that only acts: a decision that cannot re-enter itself cannot loop. Both reproduction and fix were checked outside the test suite too, by driving the real window on a store with no snapshots in it.
The Reclaim tab is tested through that seam rather than as a view alone, because the part that can go wrong is between the two: the snapshot the tab has to load, the worker's post, and the frame drawn in the meantime. The recording window does not pump its own posts, so each test decides when the rules "arrive" — which makes the frame before they do a deterministic thing to assert instead of a race. The first test is the Tree tab's crash asked of this tab: nothing scanned, the tab selected three times over, and the process still there.
Driving the real window for this tab found two things no test had, which is the argument for doing it every time. The scan card's height was a constant one line short of what is drawn into it, so the directory being read ran underneath Stop — the same mistake as the overview's card, which was right for one machine's fonts (§24.3); it is now added up from its contents, and a test holds every line on the card above the button's row, with the MFT scanner's bar and without it. And a scan stopped from this tab left it showing a partial snapshot as if it were a whole one, which is where the warning above came from.
Everything else needs a real window, and P11 used one: the window is driven by posting input to its handle and photographed with PrintWindow, which copies a window's own pixels without bringing it to the front. Neither step needs the foreground, so nothing can be typed into the user's browser and no screenshot can be of something else — the hazard that actually happened in P7 (§22.5).
| Published size | 25.96 MB, up 0.27 MB from P10 |
| Window on screen from a cold start | 222–261 ms, including reading the volumes and the last scan |
| Working set, window open, no snapshot | 31.6 MB |
| Working set, 1.58M-node snapshot open | 128.2 MB against the 150 MB budget of §20 — the terminal screens are 134.9 MB for the same snapshot |
--version from the same binary | 61–63 ms, unchanged |
| Tests | 381, of which 29 are the window's |
Verified live, at 1180×760 on this machine: the volumes and their bars against the disk's own used space; the tree at C:\ and two levels down, with the breadcrumb, the scrollbar, the reparse and partial badges and a Cyrillic directory name; drilling in by double-click; the treemap; a scan started from the toolbar, reporting 13,673 entries and 95.5 GB after six seconds while the window stayed responsive; stopping it from the toolbar, and the partial scan recorded as cancelled with the overview updating to it; and both palettes, forced with PATHMEMO_THEME.
Not verified. The window has only been seen at 96 dpi on one monitor: the per-monitor-v2 path, WM_DPICHANGED and dragging between displays of different scale are written but untested, and that is the most likely place for the first real bug. The same goes for an elevated window, which inherits P9's unverified elevated journal read (§4.5.1). Nothing has been tested on arm64.
MIT. See LICENSE.
| P10 | Polish, NativeAOT | done — lazy snapshot sections brought the TUI to 135 MB against a 150 MB budget; errors / export / config finish §13; NativeAOT compiles clean but cannot be linked on the build machine (§19.4) |
| P11 | GUI: own Win32 window, treemap, background scan | done — 126 MB and no trimming is what a framework would have cost, so the window is drawn by the same hand as the TUI (§24) |
$MFT, including $MFT, $LogFile, $Bitmap and $Extend\$UsnJrnl — real volume bytes, shown the same way by WizTree. Reconciliation therefore does not add metadata twice in MFT mode.TreeAssembly.Concat with index offsets.NodeStore; C:'s 1.8M records fit the 250 MB budget.Aggregate fills it in. The first version carried the old subtree totals across and then added the children on top, which double-counted every untouched file — caught by the first test that compared a rebuild against a full scan.Degraded, NoAdsAccounting and PartialHardlinkResolution, because the changed directories were read by the walk lister. Hard links are the real cost: the lister finds a link count but the owner may sit in a directory this rescan never opened, and a decision made over part of the tree would be worse than the previous scan's, so none is made. The summary says all of it, and --full rebuilds from scratch.%TEMP% is C:\Users\MIXPC~1\AppData\Local\Temp on the machine this was written on, and the kernel answers a file id with the long form — two spellings that would never match, so nothing would ever look changed. GetLongPathName at the point the root is resolved fixes it for the snapshot too, which no longer stores paths no other tool can follow.…\projects\ and the kernel answers …\projects; without one key for both, a file created directly in the scan root would never mark it as changed.SHEmptyRecycleBin (in-app, P6) |
wsl.vhdx | WSL2 disks from registry Lxss, logical vs allocated | 10–100 GB | wsl --manage <d> --set-sparse true, or compact vdisk |
docker.vhdx | Docker Desktop disks | 10–80 GB | docker system prune -a --volumes, then compact |
hyperv.vhdx | VHD/VHDX outside Docker and WSL | varies | Optimize-VHD, report |
dumps | MEMORY.DMP, CrashDumps, LiveKernelReports, Minidump | 1–20 GB | delete (safe) |
onedrive.local | Locally materialised cloud files | 10–200 GB | attrib +U -P per folder |
fastboot.reserved | Reserved storage (Win10 1903+) | 4–7 GB | DISM /Online /Set-ReservedStorageState /State:Disabled |
logs.cbs-panther | CBS.log, Panther, %WINDIR%\Logs | 0.5–5 GB | delete (safe) |
browser.caches | Chrome / Edge / Firefox / Brave | 1–15 GB | delete with the browser closed |
store.temp | %WINDIR%\Temp, %TEMP%, %LOCALAPPDATA%\Temp | 0.5–20 GB | delete, skipping locked files |
defender.history | Windows Defender\Scans\History | 0.1–3 GB | delete (safe) |
ntfs.metadata | $MFT plus reserved clusters, through FSCTL_GET_NTFS_VOLUME_DATA on a handle to the root directory (C:\ + FILE_FLAG_BACKUP_SEMANTICS), not \\.\C:: a volume opened without GENERIC_READ goes straight to the device, bypassing NTFS, and the FSCTL returns ERROR_INVALID_FUNCTION — while GENERIC_READ on a volume needs rights and on a directory does not | 1–5 GB | not reclaimable, explanation only |
--id| Safe |
| Redownload |
pip cache purge |
dev.pycache | **\__pycache__, **\*.pyc | Safe | Rebuild | delete |
dev.venv | **\.venv, **\venv with a pyvenv.cfg inside | Safe | Rebuild | delete; pip install -r restores |
dev.cargo | **\target\{debug,release,sbpf-solana-solana,sbf-solana-solana} | Safe | Rebuild | delete; never a bare cargo clean in a Solana project, it takes target\deploy too |
dev.cargo_registry | ~\.cargo\registry | Safe | Redownload | delete; the next build downloads what it needs |
dev.rustup_toolchains | ~\.rustup\toolchains | Caution | Redownload | rustup toolchain uninstall <old> — never delete by hand |
dev.solana_tools | ~\.cache\solana | Safe | Redownload | delete; cargo build-sbf --force-tools-install restores |
dev.go_modcache | ~\go\pkg\mod | Safe | Redownload | go clean -modcache |
dev.conda_pkgs | **\{anaconda3,miniconda3}\pkgs | Safe | Redownload | conda clean --all |
dev.unity_library | **\Library with an ArtifactDB inside and a sibling Assets\ | Safe | Rebuild | delete (slow reimport) |
dev.unreal_ddc | **\DerivedDataCache, **\Intermediate, **\Saved\Autosaves | Safe | Rebuild | delete |
dev.git_gc | **\.git where objects is 500 MB or more | Caution | Irreversible | git gc --prune=now — never delete objects directly |
dev.docker | %LOCALAPPDATA%\Docker\wsl\**\*.vhdx, **\DockerDesktopWSL\**\*.vhdx | Caution | Redownload | docker system prune -a --volumes |
dev.vs_artifacts | **\.vs | Safe | Instant | delete |
dev.vscode_vsix_cache | **\CachedExtensionVSIXs | Safe | Redownload | delete |
app.browser_cache | Chromium **\User Data\*\Cache*, **\GPUCache, **\Code Cache; Firefox **\cache2 | Safe | Instant | delete |
app.electron_cache | %APPDATA%\{Slack,discord,Teams,Signal,Code,Telegram Desktop}\Cache | Safe | Instant | delete |
app.shader_cache | %LOCALAPPDATA%\NVIDIA\{DXCache,GLCache}, %LOCALAPPDATA%\AMD\DxCache, %LOCALAPPDATA%\D3DSCache, **\ShaderCache (Steam's too) | Safe | Instant | delete |
app.steam_downloading | **\steamapps\{downloading,temp} | Safe | Redownload | delete |
app.adobe_media_cache | %APPDATA%\Adobe\Common\Media Cache* | Safe | Rebuild | delete |
app.apple_backups | %APPDATA%\Apple Computer\MobileSync\Backup | Caution | Irreversible | manual |
sys.temp | %TEMP%, %WINDIR%\Temp, %LOCALAPPDATA%\Temp | Safe | Instant | delete the contents; the folders stay |
sys.thumbnails | **\Explorer\{thumbcache,iconcache}_*.db | Safe | Instant | delete |
sys.old_logs | *.log, *.etl of 1 MB or more and older than 30 days, outside %ProgramData% | Safe | Irreversible | delete |
sys.dumps | **\CrashDumps, MEMORY.DMP, **\Minidump | Safe | Irreversible | delete |
user.old_installers | %USERPROFILE%\Downloads\**\*.{msi,exe,iso} of 1 MB or more and older than 90 days | Caution | Redownload* | delete |
user.large_media | *.{iso,vhd,vhdx,img,bak,vmdk} of 1 GB or more | Caution | Irreversible | manual |
sys.temp%TEMP%contentsOnly.pmsnap v1 does not carry (§5.3). Every multiply-linked file therefore counts as shared, which understates the gain — an underestimate disappoints, an overestimate is a promise of space that never arrives. It is also right far more often than it looks: a pnpm store hard-linked into live projects genuinely frees nothing.--apply is rm with a list. The confirmation, the journal, the mode, the re-check at execution time and the free-space measurement are all the ones from §9, so there is no second deletion path with its own mistakes in it. The journal entry records the rule ids in its reason.Risk = Danger now means something outside reclaim. Any deletion — typed at a prompt, marked in the tree — whose path matches a danger rule needs --force and the typed confirmation, which --yes cannot answer (§13.1). This closes the P6 note that --force was only a synonym for --yes.5 must not begin reading two hundred gigabytes. r starts a search in the ordinary console, where the notice of §8.5 and its progress line belong (§14.6).Keeper.Survives enforces it again in the core before anything reaches a deletion.--min --max --under --ext --hash --limit --group --all --paths-only --cached --estimate --no-verify --no-cache --same-volume --dry-run --apply --mode --yes --json. --apply hands the copies to the same rm a user would type, so the guard, the confirmation, the journal and the free-space measurement are the ones from §9.--no-verify is where it is refused.--dry-run and the confirmation token all describe. Execution reopens every item with delete access and re-verifies that the canonical name still matches and the size has not moved — the plan may be minutes old by the time a human answers. The size is compared against the stream length, not the cluster-rounded on-disk figure the plan carries; conflating the two rejected every file that was not cluster-aligned, which is most of them.running rather than silence.audit --apply at last. A remedy that deletes paths goes through this same engine and removes the contents of the directories a finding names, never the directories. A remedy that runs a command needs the rights it declares or refuses, needs a typed apply <id> when its risk is above safe, and is journalled with the free-space change. The Recycle Bin finding uses SHEmptyRecycleBin rather than shelling out to PowerShell to do the same thing one layer further away.IFileOperation parses a path and offers no handle-taking entry point, so that mode carries a weaker guarantee than the other two — one more reason quarantine is the default. Success there is any HRESULT with the severity bit clear, not S_OK: the copy engine answers with its own codes (COPYENGINE_S_DONT_PROCESS_CHILDREN, 0x00270008) and reading those as failures reports working deletions as failed.RunOnlyIfIdle, StartWhenAvailable, DisallowStartIfOnBatteries, IgnoreNew and the Monday 03:00 trigger), removed, and removed again.--limit, --min, --size, --json.PATHMEMO_DIAG=1 prints the breakdown to stderr.usn_journal_id and next_usn are written for every NTFS volume a scan touches, which is what makes the next one incremental (§4.5); metadata_bytes is the $MFT's valid data length — recorded, never subtracted, because in MFT mode $MFT is already a node in the tree, and the column exists so a walk scan's unaccounted gap can still be given a name years later when the snapshot is gone. It is the valid data length alone and not the MFT zone: counting the clusters NTFS reserves for the $MFT to grow into put 5.05 GB in the column on a volume whose $MFT is 1.71 GB, and reserved clusters are free space. P6: delete_ops, delete_items and dryrun_log are written, and a deletion refuses to run at all when the database cannot be opened (§9.9). P8: file_hashes and the dupe_* tables are written. The hash table is capped at duplicates.hashCacheMaxEntries and trimmed oldest-use-first after every run; dupe_runs holds one row, because the last duplicate search is a cache and not history (§8.6).| T11 | Losing the only copy — every file in a duplicate group unmarked | "At least one survives" enforced in the core, not the UI (§8.4). P8: Keeper.Survives is consulted by the screen and by the command, and the command exits 6 rather than deleting |
| T12 | Silent permanent deletion — a file over the Recycle Bin quota destroyed silently by the Shell | Our own quota check switches to Quarantine (§9.6) |
| T13 | Leaking private data — an export is a full map of the disk: project names, people's names | --redact (hashed names, structure and sizes preserved); logFilePaths: false by default. P10: implemented, plus export.redactPaths for machines where every export should be hashed by default. Each segment becomes 12 hex digits of its lowercased name; a file keeps its extension, a directory does not, and the volume root stays readable. Asserted by a test that the original names appear nowhere in the output while the sizes, the depth and the repeated names all still do |
| T14 | Corrupting our own database on power loss | WAL, synchronous=NORMAL, integrity_check only after an unclean exit |
| T15 | The tool filling the disk | Binary snapshots of 10–25 MB and a hard 400 MB cap (§5.4) |
| T16 | Recursive growth — pathmemo scans its own data, grows, scans again | Flagged SelfData, excluded from reclaim, undeletable except by purge. P7: the rule engine skips a SelfData node before it looks at its name, so no pattern can reach the store |
| T17 | The process killed mid-save — Console.CancelKeyPress is time-limited | The handler only sets a token; the main thread writes with a timeout; a second Ctrl+C is a hard exit (§4.8) |
| T18 | Symlink loop — AppData\Local\Application Data pointing at itself | Reparse points are never entered, plus a depth limit (256 levels; a real node_modules chain runs to about 40) |
| T19 | Deleting in-use files breaks an application | NumberOfLinks and sharing checked; FILE_DISPOSITION_POSIX_SEMANTICS; a "close first" warning on known rules |
| T20 | Concurrent pathmemo processes writing to one database | WAL plus busy_timeout, and a refusal to write with a warning while still saving the snapshot. P4: no mutex needed — WAL serialises writers, and snapshots use different numbers |
| T21 | A scheduled task registered with RunLevel = HighestAvailable names an executable by path, so whoever can replace that file decides what runs elevated every night | The task is written with the path of the running pathmemo.exe and nothing else, so the exe the user chose is the exe that runs; schedule --status prints that path in full, and the only way to point it elsewhere is to register again from there. P9: a run under the dotnet host refuses to register at all, rather than scheduling the runtime |
PRAGMA user_version + embedded .sql| DbUp is overkill (25 lines of own code) and breaks trimming |
| Hashing | XxHash128 (System.IO.Hashing) plus BCL SHA256 | The second and last PackageReference, and the argument for it over BLAKE3: pure managed code, nothing native, nothing for trimming or AOT to trip over (§8.2) |
| Snapshot compression | DeflateStream | in the box; Zstd would be 30% smaller and a native DLL |
| Logging | own FileLogger (~80 lines) | Serilog pulls four packages and reflection for logs we send nowhere |
| Clipboard | P/Invoke + OSC 52 | no WinForms (§15.1) |
| Recycle Bin | IFileOperation, hand-written COM interop | without Microsoft.WindowsAPICodePack |
| Scheduler | task XML through schtasks.exe | P9: the XML is the only place the four settings that matter can be set, and reading it back is the only language-independent way to report the task (§10.1.1). Parsed by hand: XDocument for nine elements cost 8.04 MB of the trimmed build, measured — 33.61 MB with it, 25.57 MB without |
| Tests | xUnit plus integration tests on a real filesystem | §22 |
| Publishing | self-contained, single-file, trimmed | §19 |
| 4.1 s unelevated (21 probes, 175k files in temp); elevated adds DISM, 3–6 s |
| Diff of two snapshots | < 500 ms | 105 ms for the merge join on 1.58M and 1.67M node snapshots, 5 ms where one directory changed, plus 405 ms to load both - 341 ms at P10, and 3 ms to compare two consecutive scans of C: |
| Importing snapshots into a fresh database | — | 1.6 s for 4 snapshots (106 MB, 6.5M nodes) |
| Reclaim rules over a snapshot | < 3 s | 2.5 s end to end on 1.22M nodes: the process, loading the snapshot, matching 31 rules, and opening a handle per match to ask the guard about all 1,800 of them. The TUI runs the matching alone, on a background thread, so no frame waits for it (§7.5) |
| Category and extension aggregates | < 500 ms | 330 ms over 1.58M nodes. The first version took 600 ms and +80 MB of peak: a linear scan of 200 extensions per file and a string per name. Now a hash lookup over a span and stack buffers (§17.3) |
| RSS during an MFT scan | < 250 MB | an elevated peak measurement is still outstanding |
| RSS during a walk scan | < 400 MB | 373 MB peak working set (380 MB at P3, 478 MB with the first aggregates, 535 MB before file ids moved out of RawEntry). The live snapshot is 94 MB of that; the rest is transient GC heap |
| RSS in the TUI with a snapshot open | < 150 MB | met at P10: 134.9 MB peak working set with a 1.58M-node snapshot open, measured from outside the process in a real console (was 182–201 MB). The tree is 94 MB and there is no longer a copy of it: sections inflate into the node arrays rather than into a byte[] first (§5.5). pathmemo tree, which loads the same snapshot without the screens, peaks at 121 MB. The early 103 MB estimate used a snapshot half the size and ignored the transient |
| Exporting a whole scan | — | 3.1 s for 1,583,976 CSV rows (262 MB) and 4.2 s for JSON (394 MB), at a 130 MB peak: the tree, one string builder pushed and popped per segment, and a writer flushed every 4,096 entries (§13.7) |
| Window on screen from a cold start | < 500 ms | 222-261 ms, process start plus the volumes and the last scan. The status report is collected before the window exists, so the first frame has real numbers in it rather than filling in afterwards (§24.7) |
| RSS with the window open, no snapshot | - | 31.6 MB |
| RSS with the window open and a snapshot | < 150 MB | 128.2 MB with 1.58M nodes, measured from outside the process - below the terminal screens' 134.9 MB, because the window holds no frame buffer and opens only the sections it reads (§5.5) |
| Data directory size | < 500 MB always | hard limit |
| Duplicate search, 1.2M files / 187 GB on C: | IO-bound | 5m18s: stage 0 turned 1,228,950 files into 8,508 candidates in 0.44 s, and the disk did the rest — 39.6 GB read at ~125 MB/s with Defender on, not the 53.3 GB the candidates add up to, because the partial hash and the hard-link collapse take their share first. 1,347 duplicate groups (3,431 files, 9.94 GB) and 1,249 hard-link sets held out of the total. Second run, same disk: 1m29s and 21.3 GB, 3,458 hashes from the cache |
| Remedy | A way to act on a finding: a command, deleting paths, a system setting. |
| Degraded scan | A scan without administrator rights, or on non-NTFS: slower and incomplete. |
| Unaccounted | The gap between the volume's used space and everything pathmemo could explain. |