From e3986bc28402effa23a7eb063c1f14497a174afe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20DUCOM?= Date: Sun, 16 Aug 2026 21:47:22 +0200 Subject: [PATCH 1/5] feat(cleanup): targets the catalog cannot name, and the trim podman never runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three largest reclaimable findings on a developer's Mac cannot be written down as constants: per-project build output, per-workspace editor state, and per-profile browser caches. Their paths depend on what is on the disk, so the hand-picked catalog — safe because a human argued for each path in prose — has nothing to say about them. CleanupDiscovery substitutes a structural argument for the written one. Each candidate is paired with a fact on disk that explains it, and nothing is offered that cannot be paired: a bin/ only beside a project file, a workspace folder only once its workspace is gone, a browser cache only inside the browser's own cache tree. The refusals are the point — a bare `find -name bin` over a home directory destroys every Python virtualenv on it. Measured on a real disk: 8,360 folders, 20.8 GiB, zero virtualenvs. Not offered as `dotnet clean`. That command cleans one configuration of one project, leaves Release/ and project.assets.json, needs a restore and the SDK a global.json pins; measured, it frees 28% of bin+obj. This is the opposite of the go-mod case, where the vendor tool is the only thing that works at all. workspaceStorage is the finding this corrects rather than adds. The usual advice — empty it, it is only editor layout — is wrong twice: 85% of the bytes are AI chat transcripts that nothing regenerates, and most folders belong to repositories still on disk (6 of 193 here are actually orphaned). Each folder names its project in workspace.json, so orphan status is decidable. Only the orphans are offered, and `regenerable` now carries the Trash's protection to them instead of a second hardcoded id. Podman: pruning inside a VM frees guest blocks and leaves the host image allocated, which is where "podman disks only grow, recreate the machine" comes from. It is wrong — applehv passes discard through and CoreOS trims weekly, so the blocks return up to seven days late. A Reclaim host disk action collapses that to now. The freed figure is measured from the image's st_blocks, never taken from fstrim, which reported 39.5 GiB for 2.06 GB actually returned. Also: ownerBundleID generalises the hardcoded Notion gate to every app cache (Chrome and Firefox are usually several GB and usually open); _npx joins the npm target, routinely the larger of the two; explain now answers "NOT a cleanup target" with the reason, because the reference doc already said the right thing about workspaceStorage and that did not stop the wrong advice. Two defects surfaced by the new scale: the journal would have written an 8,360-path line per clean (capped at 32, count kept exact), and ToolActivity did not warn about builds writing the output they were about to lose — which exposed a pre-existing gap where `go` mapped to go-build but not go-mod. --- Packaging/skills/space/SKILL.md | 9 + .../skills/space/references/directories.md | 83 +++- Sources/SpaceMatters/App/MCP/MCPServer.swift | 41 +- .../Scanner/CleanupDiscovery.swift | 379 +++++++++++++++++ .../SpaceMatters/Scanner/CleanupEngine.swift | 225 ++++++++-- .../Scanner/ContainerEngine.swift | 70 +++- .../SpaceMatters/Util/CleanupJournal.swift | 23 ++ Sources/SpaceMatters/Util/ToolActivity.swift | 20 +- .../ViewModel/CleanupController.swift | 127 ++++-- .../ViewModel/ContainerController.swift | 52 +++ .../Views/CleanupResultView.swift | 40 +- .../Views/ContainerResultView.swift | 68 ++++ .../CleanupDiscoveryTests.swift | 383 ++++++++++++++++++ Tests/SpaceMattersTests/CleanupTests.swift | 91 ++++- .../SpaceMattersTests/ToolActivityTests.swift | 37 +- 15 files changed, 1543 insertions(+), 105 deletions(-) create mode 100644 Sources/SpaceMatters/Scanner/CleanupDiscovery.swift create mode 100644 Tests/SpaceMattersTests/CleanupDiscoveryTests.swift diff --git a/Packaging/skills/space/SKILL.md b/Packaging/skills/space/SKILL.md index 56ace79..bd5b9ae 100644 --- a/Packaging/skills/space/SKILL.md +++ b/Packaging/skills/space/SKILL.md @@ -58,7 +58,16 @@ on an external drive. That judgement is the entire value you add. - **Never propose `rm -rf` for a path `explain` reports as a cleanup target.** SpaceMatters empties those itself, fenced to the user's home, never following symlinks, and journalled. Point at Low-Hanging Fruits in the app instead. + This covers more than a fixed list: `bin`/`obj`/`target` beside a project + file, and orphaned editor workspace state, are recognised too. +- **`explain` also answers "NOT a cleanup target", and that answer outranks + your own reading.** It is how you learn that a `bin/` is a virtualenv's, or + that a `workspaceStorage` folder belongs to a project still on disk. A + directory that looks obviously disposable from its name and size is exactly + the case this exists for. - **Never call anything "safe" that you have not run `explain` on.** +- **Check whether the owning app is running** before recommending an app's + cache. Its files are held open; deleting them frees nothing until quit. - **Documents, photos, music and videos are never `safe`.** They are `review` at most, and the recommendation is to move them, not to delete them. - **Application Support is not a cache directory.** Some of it is state the app diff --git a/Packaging/skills/space/references/directories.md b/Packaging/skills/space/references/directories.md index 6e36ce3..3332403 100644 --- a/Packaging/skills/space/references/directories.md +++ b/Packaging/skills/space/references/directories.md @@ -19,17 +19,49 @@ that matters. | `~/Library/Caches/pip`, `~/Library/Caches/uv` | Python wheel caches | Re-download. | | `~/Library/Developer/Xcode/DerivedData` | Per-project build products, indexes | Rebuild, and the first build after is slow. | | `~/Library/Caches/org.swift.swiftpm`, `~/Library/Caches/CocoaPods` | Dependency caches | Re-download. | +| `~/.npm/_npx` | Throwaway install tree per `npx `, never evicted | Re-download. Routinely larger than `_cacache` — check both. | | `node_modules`, `.venv`, `target`, `build`, `bin`, `obj`, `__pycache__`, `.next`, `dist` | Per-project build/dependency trees | Reinstall or rebuild. Individually small, collectively often the single largest finding — always `find` them. | +### `bin/` and `obj/` are not safe by name + +On a .NET machine these are usually the largest single finding — 20 GiB across +1,722 folders on a real disk. They are also the most dangerous name to sweep: a +Python virtualenv keeps its interpreter, `pip` and `activate` in `bin/`, and Go +projects keep compiled binaries there. A bare +`find ~ -type d -name bin -exec rm -rf {} +` destroys every virtualenv on the +disk. + +What makes one safe is a **sibling project file** — `*.csproj`, `*.fsproj`, +`*.sln` next to the `bin/`, `Cargo.toml` next to a `target/`. Run `explain` on +any such folder: SpaceMatters applies exactly that rule and tells you which side +the folder falls on. It cleans the qualifying ones itself. + +Do **not** recommend `dotnet clean` as the tidy alternative. It cleans one +configuration of one project, leaves `Release/` and `project.assets.json` in +place (measured: 28% of `bin`+`obj` freed on a two-configuration build), needs a +restore, and fails outright when a `global.json` pins an SDK that is not +installed. Removing the directory is both more complete and faster. + ## Grows without bound, nobody notices - **`~/Library/Application Support/Code/User/workspaceStorage`** — one folder per - workspace VS Code has ever opened, keyed by a hash. Holds editor state, chat - sessions (`chatSessions`, `.jsonl`) and per-extension databases (`.vscdb`). - **It keeps folders for repositories deleted years ago** and never prunes them. - Frequently gigabytes. Deleting a hash folder loses that workspace's local - history and chat transcripts, not the code — that is the real trade-off to - present, not "it's a cache". + workspace VS Code has ever opened, keyed by a hash. Frequently gigabytes, and + the single most misjudged directory on this list. Two things are true at once + and both get missed: + + 1. **The bulk of it is AI chat transcripts.** On a real machine: 7.9 GiB + `chatSessions` + 1.2 GiB `chatEditingSessions` out of 10.7 GiB — 85%. + Nothing regenerates those. "It only costs editor layout and undo history" + is simply false; never write it. + 2. **Most folders are still live.** The folder does keep state for deleted + repositories, but far less than it looks: 9 of 192 on the same machine, + 0.11 GiB of the 10.7. "Most of these belong to repos you deleted long ago" + is an assumption, and measuring it takes one read per folder. + + Each hash folder holds a `workspace.json` naming the project it belongs to, so + orphan status is *decidable*, not a guess. Run `explain` on the directory — + SpaceMatters counts live vs orphaned workspaces for you — and never propose + emptying the whole thing. The app offers the orphaned folders only. - **`~/Library/Caches/JetBrains/`** — one tree per IDE version ever installed. Old versions are pure waste once uninstalled; the current one regenerates but reindexing is slow. `~/Library/Application Support/JetBrains` @@ -42,6 +74,25 @@ that matters. - **Electron app caches** (`Slack`, `Notion`, `Discord`, …) under `Application Support//{Cache, Code Cache, GPUCache, Service Worker}` — refetched. Their siblings are not: see below. +- **`~/Library/Caches` as a whole is not one thing.** It mixes three + populations: per-app caches keyed by bundle id (`us.zoom.xos`, + `com.tinyspeck.slackmacgap`), developer tool caches with plain names + (`colima`, `trivy`, `goimports`, `ms-playwright`), and system state that is + not an app cache at all (`CloudKit`, `com.apple.*`). Never recommend emptying + the directory; name the subdirectories. + + **Check whether the owning app is running before recommending its cache.** A + desktop app holds its cache open for hours, so unlinking underneath it frees + nothing until quit and can leave the app's own index pointing at files that no + longer exist. This is a real case, not a hypothetical: Chrome, Firefox, Slack + and Zoom caches are often several GB and those apps are usually open. + SpaceMatters blocks such a target while its app runs — recommend quitting + first, or point at the app's cleanup pass. + + `ms-playwright` deserves its own wording: it is browser *binaries*, not + fetched assets. Nothing re-downloads them on demand, and every e2e test fails + until `npx playwright install` is run by hand. Regenerable, but not + automatically — say so. ## Looks like a cache, holds state @@ -84,3 +135,23 @@ bundles, `.ext4`/`.raw`/`.qcow2` files. `explain` reports the gap. Deleting frees the on-disk figure, not the apparent one — and the tool's own reclaim command (`docker system prune`, `podman system prune`, `colima delete`) is almost always the better answer than removing files under it. + +**Pruning inside a VM does not shrink its disk image — but that does not mean +the space is lost.** The guest frees the blocks; the host file keeps them +allocated until the guest issues a discard for them. So a `podman system prune` +that reclaims 15 GB inside can leave the `.raw` byte-for-byte as large as +before. Do not conclude from this that "podman disks only grow, recreate the +machine": Podman's applehv backend passes discard through (`lsblk -D` in the +guest shows a non-zero `DISC-MAX`), and Fedora CoreOS runs `fstrim.timer` +weekly. The blocks come back — just up to a week late. + +The right sequence is prune, then trim: SpaceMatters' container mode has a +**Reclaim host disk** button that runs `fstrim` in the machine and reports the +measured change in the image file. By hand it is +`podman machine ssh sudo fstrim /var`. Two cautions: + +- Never quote `fstrim`'s own output as space recovered. It prints the size of + the free extents it walked — "39.5 GiB trimmed" for 2.06 GB actually returned, + measured. Only the image file's on-disk size before and after is the truth. +- `podman machine set --disk-size` grows a machine and cannot shrink one; there + is no resize path here, and none is needed, because the file is sparse. diff --git a/Sources/SpaceMatters/App/MCP/MCPServer.swift b/Sources/SpaceMatters/App/MCP/MCPServer.swift index 55b983c..7b989cc 100644 --- a/Sources/SpaceMatters/App/MCP/MCPServer.swift +++ b/Sources/SpaceMatters/App/MCP/MCPServer.swift @@ -36,6 +36,10 @@ final class MCPServer: @unchecked Sendable { private let source: any MCPScanSource private let detachedReason: DetachedReason? + /// Memoized `CleanupDiscovery.all()` — the walk costs seconds, and only + /// `cleanup_targets` needs it. Filled on first use, then reused for the + /// lifetime of the server. + private var discoveredTargets: [Cleanable]? init(source: any MCPScanSource, detachedReason: DetachedReason? = nil) { self.source = source @@ -328,24 +332,51 @@ final class MCPServer: @unchecked Sendable { + "SpaceMatters cleans this itself, fenced and journalled; do not propose a " + "shell command for it.\n" } + // Targets whose paths are discovered rather than listed. Both answers + // are worth printing: the ones the app will clean, and the ones it + // deliberately refuses — a `bin/` with no project beside it, a workspace + // whose folder is still there. Those refusals are the advice that a + // size table alone would get wrong. + switch CleanupDiscovery.classify(path) { + case .cleanable(let name, let note): + out += "Known cleanup target \"\(name)\" (discovered) — \(note) SpaceMatters cleans " + + "this itself, fenced and journalled; do not propose a shell command for it.\n" + case .protected(let reason): + out += "NOT a cleanup target — \(reason)\n" + case nil: + break + } return out } private func cleanupTool(_ index: TreeQuery.Index) -> String { - let detected = CleanupEngine.detect(CleanupEngine.catalog()) + // The discovery walk takes seconds, so it runs once per server rather + // than per call — this tool is the only caller, and the disk does not + // change shape between two calls in one conversation. + if discoveredTargets == nil { discoveredTargets = CleanupDiscovery.all() } + let detected = CleanupEngine.detect(CleanupEngine.catalog() + (discoveredTargets ?? [])) guard !detected.isEmpty else { return caveat(full: false) + "no known cleanup target exists on this machine." } var out = caveat(full: false) + """ - Locations SpaceMatters can safely empty itself (regenerable by design). Sizes come from \ - this scan and are blank for anything outside its root — they are not re-measured here. + Locations SpaceMatters can safely empty itself. Sizes come from this scan and are blank \ + for anything outside its root — they are not re-measured here. """ for item in detected { let sizes = item.paths.compactMap { index.node(at: $0)?.sizeOnDisk } let measured = sizes.isEmpty ? "—" : Format.bytes(sizes.reduce(0, +)) - out += "\(measured) [\(item.id)] \(item.name) · \(item.category) — \(item.note)\n" - for path in item.paths { out += " \(path)\n" } + let permanence = item.regenerable ? "" : " NOT REGENERABLE — this is state, not a cache." + out += "\(measured) [\(item.id)] \(item.name) · \(item.category) — " + + "\(item.note)\(permanence)\n" + // A discovered target can carry hundreds of paths; a token budget is + // better spent on the count and a sample than on the full list. + if item.paths.count > 6 { + out += " \(item.paths.count) locations, e.g.\n" + for path in item.paths.prefix(3) { out += " \(path)\n" } + } else { + for path in item.paths { out += " \(path)\n" } + } } return out } diff --git a/Sources/SpaceMatters/Scanner/CleanupDiscovery.swift b/Sources/SpaceMatters/Scanner/CleanupDiscovery.swift new file mode 100644 index 0000000..cef8e3c --- /dev/null +++ b/Sources/SpaceMatters/Scanner/CleanupDiscovery.swift @@ -0,0 +1,379 @@ +import Foundation + +/// Cleanup targets whose paths cannot be written down ahead of time. +/// +/// The hand-picked catalog in `CleanupEngine` works because every path is a +/// constant: the argument for deleting `~/.npm/_cacache` is made once, in prose, +/// next to the path. That does not scale to the three largest findings on a +/// developer's Mac — per-project build output, per-workspace editor state, and +/// per-profile browser caches — because their locations depend on what happens +/// to be on the disk. +/// +/// These targets replace the hand-written argument with a *structural* one. Each +/// discovery below pairs a candidate directory with a fact on disk that explains +/// it, and offers nothing it cannot pair: +/// +/// - a `bin/` is offered only next to a project file that produces one; +/// - a workspace-state folder only once the workspace it belongs to is gone; +/// - a browser cache only inside the browser's dedicated cache tree. +/// +/// The rejected candidates are the point. A `find -name bin` over a developer's +/// home hits Python virtualenv `bin/` directories (where `python`, `pip` and +/// `activate` live) and Go output directories holding compiled binaries. The +/// marker rule is what tells those apart from MSBuild output, and it is cheap: +/// one directory read of the parent. +/// +/// Discovery never widens the safety fence — `CleanupEngine.passesFence` still +/// gates every path, and cleaning still runs through `CleanupEngine.clean`. It +/// only decides which paths are put forward. +enum CleanupDiscovery { + + /// Every discovered target, or none when nothing qualifies. Runs a bounded + /// directory walk, so callers must keep it off the main thread. + static func all(home: String = NSHomeDirectory()) -> [Cleanable] { + [projectArtifacts(home: home), + orphanedWorkspaceStorage(home: home), + browserCaches(home: home)] + .flatMap { $0 } + } + + // MARK: Project build artifacts + + /// A build-output directory name, and the project files that would explain + /// finding one. A directory qualifies only when its *parent* contains at + /// least one matching marker. + struct ArtifactRule { + let directory: String + let markers: [String] + } + + /// `bin` and `obj` are the dangerous pair and the valuable one: together + /// they are routinely the single largest reclaimable finding on a .NET + /// developer's disk, and `bin` is also the name Python virtualenvs and Go + /// projects use for things that must not be deleted. Every rule here is + /// marker-gated for that reason — including the unambiguous ones, so the + /// invariant holds by construction rather than by which name we trusted. + static let artifactRules: [ArtifactRule] = [ + ArtifactRule(directory: "bin", markers: [".csproj", ".fsproj", ".vbproj", ".sln", ".slnx"]), + ArtifactRule(directory: "obj", markers: [".csproj", ".fsproj", ".vbproj", ".sln", ".slnx"]), + ArtifactRule(directory: "target", markers: ["Cargo.toml"]), + ] + + /// Directory names never descended into. `node_modules` and `.git` are the + /// two that dominate the cost of the walk; the rest are large trees that by + /// definition contain no project of the user's own. + static let pruned: Set = [ + "node_modules", ".git", ".svn", ".hg", "Library", ".Trash", + "DerivedData", ".build", ".venv", "venv", "vendor", "Pods", + ] + + /// Depth below `home` the walk is allowed to reach. Deep enough for the + /// nesting real repositories use (`~/sources/org/repo/src/Project/bin`), + /// shallow enough that a pathological tree cannot turn the mode's load into + /// a full-disk scan. + static let maxDepth = 8 + + /// `bin`/`obj`/`target` directories that sit next to a project file + /// explaining them, collapsed into one target per ecosystem. + /// + /// One row rather than one per directory: the user's decision is "drop .NET + /// build output", not seventeen hundred individual ones, and the row carries + /// the count so the scale is not hidden. Removal is `.directory` — MSBuild + /// and Cargo recreate their output directory without being asked, and + /// leaving a thousand empty `obj/` folders behind would be its own mess. + /// + /// Not offered as a native `dotnet clean`: that command cleans one + /// configuration of one project at a time, leaves `Release/` and + /// `project.assets.json` untouched, needs a restore and the exact SDK a + /// `global.json` pins. Measured on a two-configuration hello-world it freed + /// 28% of `bin`+`obj`. Removing the directory is both the more complete + /// answer and the faster one — the opposite of the `go clean -modcache` + /// case, where the vendor tool is the only thing that works at all. + static func projectArtifacts(home: String = NSHomeDirectory()) -> [Cleanable] { + let found = walkForArtifacts(home: home) + guard !found.isEmpty else { return [] } + + let dotnet = found.filter { $0.rule.directory != "target" }.map(\.path) + let rust = found.filter { $0.rule.directory == "target" }.map(\.path) + + var out: [Cleanable] = [] + if !dotnet.isEmpty { + out.append(Cleanable( + id: "dotnet-artifacts", name: ".NET build output", category: ".NET", + icon: "hammer.fill", + note: "bin/ and obj/ next to a project file. Recreated by the next build.", + paths: dotnet.sorted(), removal: .directory, + locationLabel: "\(dotnet.count) folders")) + } + if !rust.isEmpty { + out.append(Cleanable( + id: "cargo-artifacts", name: "Cargo build output", category: "Rust & Go", + icon: "wrench.and.screwdriver.fill", + note: "target/ next to a Cargo.toml. Recreated by the next build.", + paths: rust.sorted(), removal: .directory, + locationLabel: "\(rust.count) folders")) + } + return out + } + + struct ArtifactHit { + let path: String + let rule: ArtifactRule + } + + /// Iterative, depth-bounded walk of `home`. Deliberately not + /// `FileManager.enumerator`: a matched directory must not be descended into + /// (its own children are more build output, not more candidates), and + /// pruning `node_modules` is what keeps this to seconds rather than minutes. + static func walkForArtifacts(home: String, maxDepth: Int = maxDepth) -> [ArtifactHit] { + var hits: [ArtifactHit] = [] + var queue: [(path: String, depth: Int)] = [(home, 0)] + let rulesByName = Dictionary(uniqueKeysWithValues: artifactRules.map { ($0.directory, $0) }) + + while let (dir, depth) = queue.popLast() { + guard depth < maxDepth else { continue } + guard let entries = try? FileManager.default.contentsOfDirectory(atPath: dir) else { + continue // unreadable: not an error worth surfacing, just less coverage + } + // One pass to remember the names present, so the marker test for a + // child costs no extra directory read. + let names = Set(entries) + for entry in entries { + guard !pruned.contains(entry) else { continue } + // Hidden directories hold configuration and tool state, never + // the build output we are after; skipping them also keeps the + // walk out of caches that have their own catalog entry. + if entry.hasPrefix(".") { continue } + let child = dir + "/" + entry + var st = stat() + guard lstat(child, &st) == 0, (st.st_mode & S_IFMT) == S_IFDIR else { continue } + + if let rule = rulesByName[entry], names.contains(where: { matches($0, rule) }) { + hits.append(ArtifactHit(path: child, rule: rule)) + continue // its contents are the artifact; nothing to find inside + } + queue.append((child, depth + 1)) + } + } + return hits + } + + /// True when a sibling file name is one of the rule's markers — an exact + /// name (`Cargo.toml`) or an extension (`.csproj`). + static func matches(_ name: String, _ rule: ArtifactRule) -> Bool { + rule.markers.contains { $0.hasPrefix(".") ? name.hasSuffix($0) : name == $0 } + } + + // MARK: VS Code workspace storage + + /// Editors that use the VS Code workspace-storage layout, and the bundle id + /// that must not be running while it is cleaned (`state.vscdb` is an open + /// SQLite database). + static let workspaceStorageHosts: [(support: String, name: String, bundleID: String)] = [ + ("Code", "VS Code", "com.microsoft.VSCode"), + ("Code - Insiders", "VS Code Insiders", "com.microsoft.VSCodeInsiders"), + ("Cursor", "Cursor", "com.todesktop.230313mzl4w4u92"), + ("VSCodium", "VSCodium", "com.vscodium"), + ] + + /// Per-workspace editor state whose workspace no longer exists on disk. + /// + /// This folder is a standing trap for disk-space advice. It grows without + /// bound, it is full of hash-named directories, and it *looks* like a cache + /// — so the usual recommendation is to empty the whole thing, described as + /// costing "editor layout and undo history". Measured, that is wrong twice + /// over: the bulk of the bytes is `chatSessions` and `chatEditingSessions` + /// — AI conversation transcripts, which nothing regenerates — and most + /// folders belong to repositories that are still on the disk. + /// + /// So the target is not the folder. Each subdirectory carries a + /// `workspace.json` naming the project it belongs to; the ones whose project + /// is gone are unreachable state and genuinely safe, and they are the only + /// ones offered. A workspace still on disk is left alone however cold it + /// looks, because opening it again is what makes its transcripts matter. + static func orphanedWorkspaceStorage(home: String = NSHomeDirectory()) -> [Cleanable] { + var out: [Cleanable] = [] + for host in workspaceStorageHosts { + let root = home + "/Library/Application Support/\(host.support)/User/workspaceStorage" + guard let entries = try? FileManager.default.contentsOfDirectory(atPath: root) else { continue } + let orphans = entries.map { root + "/" + $0 }.filter { isOrphanedWorkspace($0) } + guard !orphans.isEmpty else { continue } + out.append(Cleanable( + id: "workspace-storage-\(host.support.replacingOccurrences(of: " ", with: "-").lowercased())", + name: "\(host.name) orphaned workspace state", category: "Editors", + icon: "curlybraces.square", + note: "State for \(orphans.count) workspace\(orphans.count == 1 ? "" : "s") " + + "whose folder no longer exists. Folders still on disk are left alone.", + paths: orphans.sorted(), removal: .directory, regenerable: false, + ownerBundleID: host.bundleID, + locationLabel: "\(orphans.count) of \(entries.count) workspaces")) + } + return out + } + + /// True when the directory is a workspace-storage entry whose target is + /// gone. Fails closed at every step: an entry without a readable + /// `workspace.json`, or one naming something this code cannot resolve, is + /// kept rather than offered. + static func isOrphanedWorkspace(_ directory: String) -> Bool { + guard let data = FileManager.default.contents(atPath: directory + "/workspace.json"), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return false } + // "folder" for a plain directory, "configPath" for a .code-workspace + // file. An entry with neither (a remote or virtual workspace) is not + // something local existence can decide — leave it. + guard let uri = (json["folder"] as? String) ?? (json["configPath"] as? String), + let path = localPath(fromFileURI: uri) + else { return false } + return !FileManager.default.fileExists(atPath: path) + } + + /// Local filesystem path behind a `file://` URI, or nil for any other + /// scheme (`vscode-remote://`, `vscode-vfs://`): a remote workspace missing + /// locally proves nothing about whether it still exists. + static func localPath(fromFileURI uri: String) -> String? { + guard uri.hasPrefix("file://"), let url = URL(string: uri), url.isFileURL else { return nil } + return url.path + } + + // MARK: Single-path classification (for `explain`) + + /// What a *single* path is, in discovery's terms. + enum Classification { + /// Discovery would offer this path, for the stated reason. + case cleanable(name: String, note: String) + /// Discovery deliberately would not, and saying so is the point: these + /// are the paths whose name or neighbourhood makes them look reclaimable + /// when they are not. + case protected(reason: String) + } + + /// Classify one path without walking anything. + /// + /// `explain` is called per path and must stay instant, so it cannot run the + /// discovery walk. Every rule discovery uses is local to the candidate + /// anyway — a sibling marker, a `workspace.json` — so the same decision can + /// be reached by looking only at the path itself. + /// + /// The `protected` answers matter as much as the `cleanable` ones. An + /// assistant reading a size table sees a thousand `bin/` folders and a + /// `workspaceStorage` full of hash-named directories and reaches for + /// `rm -rf`; this is where it finds out which of those are a virtualenv and + /// which workspace still exists. + static func classify(_ path: String, home: String = NSHomeDirectory()) -> Classification? { + let name = (path as NSString).lastPathComponent + let parent = (path as NSString).deletingLastPathComponent + + // Build output — or a directory that merely shares its name. + if let rule = artifactRules.first(where: { $0.directory == name }) { + let siblings = (try? FileManager.default.contentsOfDirectory(atPath: parent)) ?? [] + if siblings.contains(where: { matches($0, rule) }) { + return .cleanable( + name: rule.directory == "target" ? "Cargo build output" : ".NET build output", + note: "Build output, recreated by the next build. SpaceMatters removes these " + + "itself. Note `dotnet clean` is not equivalent — it cleans one " + + "configuration and leaves the rest.") + } + if siblings.contains("pyvenv.cfg") { + return .protected( + reason: "This is a Python virtualenv's bin/ — it holds the interpreter, pip " + + "and activate. Deleting it breaks the environment; remove the whole " + + "virtualenv instead, or leave it.") + } + return .protected( + reason: "No project file sits beside this \(name)/, so nothing explains it as " + + "build output — Go projects keep compiled binaries there, and virtualenvs " + + "keep their interpreter. Not safe to sweep by name.") + } + + // Per-workspace editor state. + for host in workspaceStorageHosts { + let storage = home + "/Library/Application Support/\(host.support)/User/workspaceStorage" + if path == storage { return .protected(reason: workspaceStorageSummary(storage, host: host.name)) } + guard parent == storage else { continue } + if isOrphanedWorkspace(path) { + return .cleanable( + name: "\(host.name) orphaned workspace state", + note: "The folder this state belongs to no longer exists, so nothing can " + + "reach it again. Not regenerable — it is state, not a cache.") + } + return .protected( + reason: "State for a workspace that is still on disk. Most of the bytes here are " + + "chat transcripts (chatSessions / chatEditingSessions), which nothing " + + "regenerates — this is not editor layout.") + } + return nil + } + + /// The measured shape of a `workspaceStorage` directory: how many of its + /// workspaces are actually dead, and how much of it is conversation history. + /// + /// Written because the usual advice for this folder — empty it, it is only + /// editor layout — is wrong in both halves, and prose in a reference file + /// did not stop it being given. A number measured at the moment of asking + /// does. + static func workspaceStorageSummary(_ storage: String, host: String) -> String { + let fm = FileManager.default + let entries = (try? fm.contentsOfDirectory(atPath: storage)) ?? [] + let orphans = entries.filter { isOrphanedWorkspace(storage + "/" + $0) }.count + let live = entries.count - orphans + return "\(host) workspace state: \(entries.count) workspaces, of which \(live) still " + + "exist on disk and \(orphans) are orphaned. Do NOT propose emptying this folder: " + + "the bulk of it is chat transcripts (chatSessions), which nothing regenerates, and " + + "only the \(orphans) orphaned folder\(orphans == 1 ? "" : "s") " + + "\(orphans == 1 ? "is" : "are") unreachable. SpaceMatters offers exactly those." + } + + // MARK: Browser caches + + /// Browsers keep their cache under a profile directory whose name is + /// generated (`xxg0zp7b.default-release`, `Profile 1`), so the paths have to + /// be found rather than written down. + /// + /// Both entries stay inside `~/Library/Caches`, which for these two browsers + /// is a genuinely separate tree from the profile: history, passwords, + /// cookies and extensions live under `Application Support` and are never + /// touched here. That separation is what makes the target safe — it is not a + /// claim about which subdirectory names look disposable. + static func browserCaches(home: String = NSHomeDirectory()) -> [Cleanable] { + var out: [Cleanable] = [] + let fm = FileManager.default + + // Chrome: ~/Library/Caches/Google/Chrome//{Cache, Code Cache, + // image_cache}. `Storage` is deliberately excluded — it holds + // per-extension state rather than fetched assets. + let chromeRoot = home + "/Library/Caches/Google/Chrome" + if let profiles = try? fm.contentsOfDirectory(atPath: chromeRoot) { + let paths = profiles.flatMap { profile in + ["Cache", "Code Cache", "image_cache"].map { "\(chromeRoot)/\(profile)/\($0)" } + }.filter { fm.fileExists(atPath: $0) } + if !paths.isEmpty { + out.append(Cleanable( + id: "chrome-cache", name: "Chrome cache", category: "Browsers", icon: "globe", + note: "Fetched web assets, re-downloaded — quit Chrome first. History and " + + "logins are elsewhere and untouched.", + paths: paths.sorted(), ownerBundleID: "com.google.Chrome", + locationLabel: "\(profiles.count) profile\(profiles.count == 1 ? "" : "s")")) + } + } + + // Firefox: ~/Library/Caches/Firefox/Profiles//{cache2, + // startupCache}. The sibling JSON files are small and are the new-tab + // page's state, so they are left in place. + let firefoxRoot = home + "/Library/Caches/Firefox/Profiles" + if let profiles = try? fm.contentsOfDirectory(atPath: firefoxRoot) { + let paths = profiles.flatMap { profile in + ["cache2", "startupCache"].map { "\(firefoxRoot)/\(profile)/\($0)" } + }.filter { fm.fileExists(atPath: $0) } + if !paths.isEmpty { + out.append(Cleanable( + id: "firefox-cache", name: "Firefox cache", category: "Browsers", icon: "globe", + note: "Fetched web assets, re-downloaded — quit Firefox first. The profile " + + "itself is elsewhere and untouched.", + paths: paths.sorted(), ownerBundleID: "org.mozilla.firefox", + locationLabel: "\(profiles.count) profile\(profiles.count == 1 ? "" : "s")")) + } + } + return out + } +} diff --git a/Sources/SpaceMatters/Scanner/CleanupEngine.swift b/Sources/SpaceMatters/Scanner/CleanupEngine.swift index 99f18a3..aca7cda 100644 --- a/Sources/SpaceMatters/Scanner/CleanupEngine.swift +++ b/Sources/SpaceMatters/Scanner/CleanupEngine.swift @@ -7,25 +7,79 @@ import AppKit /// contents are regenerable by design (package/build caches) or explicitly /// disposable (the Trash). Paths are absolute; only existing ones are shown. struct Cleanable: Identifiable, Equatable, Sendable { + + /// What deleting the target means at the filesystem level. + enum Removal: Equatable, Sendable { + /// Empty the directory, keep it — the shape every *cache* wants: tools + /// expect their cache root to exist, and recreating it is not their job. + case children + /// Remove the directory itself. Correct only where the directory is the + /// artifact rather than a container for one (a `bin/` MSBuild recreates, + /// a workspace-state folder whose workspace is gone) — a cache cleaned + /// this way would come back as a missing-directory error in some tool. + case directory + } + let id: String let name: String let category: String let icon: String /// What deleting costs the user ("re-downloaded on next install", …). let note: String - let paths: [String] + var paths: [String] + var removal: Removal = .children + /// False when the bytes do not come back on their own: the Trash, and + /// editor state for a workspace that no longer exists. Such a target is + /// still offered — it is safe in the sense that nothing breaks — but it is + /// never swept up by select-all, and the confirmation says what it is. + /// Everything else is a cache that a re-download or a rebuild restores. + var regenerable = true + /// Bundle identifier of the desktop app that owns these files, when one + /// does. While it runs the target is blocked: a running app holds its cache + /// open, so unlinking underneath it frees nothing until quit and can leave + /// the app's own index describing entries that no longer exist. See + /// `appIsRunning` — this is the generalisation of the original Notion case. + var ownerBundleID: String? + /// Overrides the path column when listing every path would be noise + /// ("1,722 folders under ~/sources"). Set by discovery, which is the only + /// producer of targets with more paths than a row can show. + var locationLabel: String? + + init(id: String, name: String, category: String, icon: String, note: String, + paths: [String], removal: Removal = .children, regenerable: Bool = true, + ownerBundleID: String? = nil, locationLabel: String? = nil) { + self.id = id + self.name = name + self.category = category + self.icon = icon + self.note = note + self.paths = paths + self.removal = removal + self.regenerable = regenerable + self.ownerBundleID = ownerBundleID + self.locationLabel = locationLabel + } } /// Catalog, sizing and cleaning for the Low-Hanging Fruits mode. /// /// Safety model (same spirit as `ScanController.remove`, J4.4): -/// - the catalog is hand-picked — nothing is discovered dynamically; /// - every operation is fenced inside `allowedRoot` (the user's home), so a /// mis-built `Cleanable` can never reach outside it; -/// - cleaning deletes the *children* of a cache directory, never the directory -/// itself, and never follows symlinks: a link inside a cache is removed as a -/// link, its target is left untouched; a cache root that *is* a symlink is -/// refused outright rather than resolved. +/// - cleaning a cache deletes its *children*, never the directory itself, and +/// never follows symlinks: a link inside a cache is removed as a link, its +/// target is left untouched; a cache root that *is* a symlink is refused +/// outright rather than resolved. +/// +/// Two kinds of target reach this engine, and they earn their safety +/// differently: +/// - the **catalog** below is hand-picked. Every path is a constant, so the +/// argument for each one is made once, here, in prose; +/// - **discovered** targets (`CleanupDiscovery`) cannot be: their paths depend +/// on what is on the disk. They substitute a *structural* proof for the +/// hand-written one — a `bin/` is only offered next to a project file that +/// explains it, a workspace-state folder only once its workspace is gone. +/// Discovery never widens the fence; it only decides which paths enter it. enum CleanupEngine { // MARK: Catalog @@ -37,7 +91,7 @@ enum CleanupEngine { Cleanable( id: "trash", name: "Trash", category: "System", icon: "trash.fill", note: "Files you already deleted. Emptying is permanent.", - paths: [home + "/.Trash"]), + paths: [home + "/.Trash"], regenerable: false), // Notion's service worker never evicts: it keeps one full asset // bucket per app release it has ever run (`notion-swv2-` // in CacheStorage/*/index.txt), so the directory grows by a few @@ -54,6 +108,33 @@ enum CleanupEngine { paths: [ home + "/Library/Application Support/Notion/Partitions/notion/Service Worker/CacheStorage", home + "/Library/Application Support/Notion/Partitions/notion/Cache", + ], + ownerBundleID: "notion.id"), + // `~/Library/Caches/` is macOS's own per-app cache + // container: URL caches, thumbnails, downloaded assets. Named app by + // app rather than swept, because the convention is a convention — + // plenty of apps park recoverable-only-by-re-login state in there, + // and `~/Library/Caches` also holds CloudKit and other system state + // that is not an app cache at all. + Cleanable( + id: "slack", name: "Slack cache", category: "Apps", icon: "bubble.left.and.bubble.right.fill", + note: "Downloaded files and images, re-fetched — quit Slack first.", + paths: [home + "/Library/Caches/com.tinyspeck.slackmacgap"], + ownerBundleID: "com.tinyspeck.slackmacgap"), + Cleanable( + id: "zoom", name: "Zoom cache", category: "Apps", icon: "video.fill", + note: "Web view and asset caches, re-fetched — quit Zoom first.", + paths: [home + "/Library/Caches/us.zoom.xos"], + ownerBundleID: "us.zoom.xos"), + // Updater downloads: installers kept after the update was applied. + // Nothing reads them again — the next update downloads its own. + Cleanable( + id: "app-updaters", name: "App updater downloads", category: "Apps", icon: "arrow.down.circle.fill", + note: "Installers kept after updating. Nothing reads them again.", + paths: [ + home + "/Library/Caches/bitwarden-updater", + home + "/Library/Caches/podman-desktop-updater", + home + "/Library/Application Support/Caches/bitwarden-updater", ]), Cleanable( id: "derived-data", name: "Xcode DerivedData", category: "Apple development", icon: "hammer.fill", @@ -67,10 +148,39 @@ enum CleanupEngine { id: "cocoapods", name: "CocoaPods cache", category: "Apple development", icon: "cube.fill", note: "Downloaded pod specs and archives, re-fetched on next install.", paths: [home + "/Library/Caches/CocoaPods"]), + // `_npx` is a sibling of `_cacache` and routinely the larger of the + // two: npm installs a throwaway tree per `npx ` invocation and + // never evicts one. Same regenerability, so it belongs to the same + // target rather than to one of its own. Cleanable( id: "npm", name: "npm cache", category: "JavaScript", icon: "shippingbox", - note: "Package tarballs, re-downloaded on next install.", - paths: [home + "/.npm/_cacache"]), + note: "Package tarballs and one-off npx installs, re-downloaded on demand.", + paths: [home + "/.npm/_cacache", home + "/.npm/_npx"]), + Cleanable( + id: "node-gyp", name: "node-gyp headers", category: "JavaScript", icon: "shippingbox", + note: "Node headers per version, re-downloaded when a native module builds.", + paths: [home + "/Library/Caches/node-gyp"]), + Cleanable( + id: "bun", name: "Bun cache", category: "JavaScript", icon: "shippingbox", + note: "Package cache, re-downloaded on next install.", + paths: [home + "/.bun/install/cache"]), + Cleanable( + id: "electron", name: "Electron downloads", category: "JavaScript", icon: "shippingbox", + note: "Prebuilt Electron zips, re-downloaded on next install.", + paths: [home + "/Library/Caches/electron"]), + Cleanable( + id: "typescript", name: "TypeScript type acquisition", category: "JavaScript", icon: "shippingbox", + note: "Auto-acquired @types packages, re-fetched by the editor.", + paths: [home + "/Library/Caches/typescript"]), + // Deliberately *not* worded like the caches above. These are browser + // binaries, not build artifacts: nothing re-downloads them on demand, + // and every e2e run fails until `npx playwright install` is run by + // hand. Regenerable, but not automatically — the note has to say so, + // or the row promises something the target cannot deliver. + Cleanable( + id: "playwright", name: "Playwright browsers", category: "JavaScript", icon: "theatermasks.fill", + note: "Browser binaries — needs an explicit `npx playwright install` before e2e tests run again.", + paths: [home + "/Library/Caches/ms-playwright"]), Cleanable( id: "yarn", name: "Yarn cache", category: "JavaScript", icon: "shippingbox", note: "Package tarballs, re-downloaded on next install.", @@ -137,8 +247,9 @@ enum CleanupEngine { catalog.compactMap { item in let existing = item.paths.filter { passesFence($0, allowedRoot: allowedRoot) } guard !existing.isEmpty else { return nil } - return Cleanable(id: item.id, name: item.name, category: item.category, - icon: item.icon, note: item.note, paths: existing) + var kept = item + kept.paths = existing + return kept } } @@ -207,12 +318,14 @@ enum CleanupEngine { var refused = 0 } - /// Delete the *children* of each of the item's paths. The paths themselves - /// survive (tools expect their cache directory to exist). Every path must - /// live strictly inside `allowedRoot` **once fully resolved** and be a real - /// directory — a symlinked root, a symlinked *intermediate* component - /// (`~/.gradle` → an external volume) or a `..` escape are all refused, so - /// a cache relocated elsewhere is never chased. + /// Delete each of the item's paths, or their children, per `item.removal`. + /// For a cache the paths themselves survive (tools expect their cache + /// directory to exist); for a `.directory` target the path is the artifact + /// and goes with it. Every path must live strictly inside `allowedRoot` + /// **once fully resolved** and be a real directory — a symlinked root, a + /// symlinked *intermediate* component (`~/.gradle` → an external volume) or + /// a `..` escape are all refused, so a cache relocated elsewhere is never + /// chased. /// /// Concurrency note: the checks and the removals are separate syscalls /// (TOCTOU). The fence defends against catalog bugs and relocated caches — @@ -234,6 +347,18 @@ enum CleanupEngine { result.refused += 1 continue } + if item.removal == .directory { + // The fence has already established that `root` is a real + // directory inside the home, reached without crossing a + // symlink; removeItem then never escapes it. + do { + try fm.removeItem(atPath: root) + result.removed += 1 + } catch { + result.failed += 1 + } + continue + } guard let children = try? fm.contentsOfDirectory(atPath: root) else { result.failed += 1 continue @@ -260,10 +385,12 @@ enum CleanupEngine { return result } - /// The full fence, shared by `detect` and `clean` so both always agree: - /// textual prefix (cheap, fail-closed), a real non-symlink directory, and a - /// fully-resolved form still strictly inside the resolved fence. - private static func passesFence(_ root: String, allowedRoot: String) -> Bool { + /// The full fence, shared by `detect`, `clean` and `CleanupDiscovery` so + /// they always agree: textual prefix (cheap, fail-closed), a real + /// non-symlink directory, and a fully-resolved form still strictly inside + /// the resolved fence. Discovery calls it too — a discovered path gets no + /// weaker a check than a hand-written one. + static func passesFence(_ root: String, allowedRoot: String) -> Bool { let fence = allowedRoot.hasSuffix("/") ? allowedRoot : allowedRoot + "/" return root.hasPrefix(fence) && root != fence && isRealDirectory(root) && staysInsideFence(root, allowedRoot: allowedRoot) @@ -289,26 +416,60 @@ enum CleanupEngine { return false } - /// Notion's bundle identifier, and the helpers it spawns under it. - static let notionBundleID = "notion.id" + /// Why this target must not be offered on this machine right now, or nil. + /// + /// The whole rule in one place, and injectable, because it is the only + /// thing standing between the user and a cache emptied underneath the app + /// that owns it. `CleanupController` uses this as its default; tests drive + /// it directly rather than needing Chrome to be running. + static func blockedReason( + for item: Cleanable, home: String = NSHomeDirectory(), + isRunning: (String) -> Bool = { appIsRunning($0) } + ) -> String? { + // A target whose vendor command is the only viable path is blocked when + // that command is missing, rather than offered a file removal that would + // fail on every entry (go-mod). Checked first: it is a property of the + // target, not of this machine's state. + if let missing = NativeCleaner.missingRequirement(for: item.id, home: home) { + return missing + } + // The app that owns the files is running: one rule for every app cache, + // declared on the target rather than special-cased per app. + if let bundleID = item.ownerBundleID, isRunning(bundleID) { + let app = item.name + .replacingOccurrences(of: " cache", with: "") + .replacingOccurrences(of: " orphaned workspace state", with: "") + return "\(app) is running — quit it first, or it keeps writing to these files" + } + if item.id == "uv", uvSymlinkMode(home: home) { + return "uv link-mode is \"symlink\" — cleaning would break your virtualenvs" + } + return nil + } - /// True while Notion is running — the target must not be offered then. + /// True while the app owning a target is running — the target must not be + /// offered then. /// /// This is the difference between an app cache and a package cache, and why /// the catalog names apps one by one instead of sweeping every Electron - /// directory: a package manager is invoked and exits, a desktop app holds - /// its cache open for hours. Unlinking underneath it is not a crash on - /// macOS (the inode outlives the last close), but the service worker keeps - /// writing into files nothing can reach any more — the space is not - /// actually freed until quit, and the cache index can be left describing - /// entries that no longer exist. Quitting first makes both go away. + /// directory or all of `~/Library/Caches`: a package manager is invoked and + /// exits, a desktop app holds its cache open for hours. Unlinking + /// underneath it is not a crash on macOS (the inode outlives the last + /// close), but the app keeps writing into files nothing can reach any more + /// — the space is not actually freed until quit, and the cache index can be + /// left describing entries that no longer exist. Quitting first makes both + /// go away. + /// + /// The counterpart for command-line tools is `ToolActivity`, which warns + /// rather than blocks: a build that fails can simply be re-run, where a + /// half-emptied browser cache is a corrupt one. /// /// Fails closed: when the running-app list cannot be consulted at all, the /// target is treated as busy rather than assumed idle. - static func notionIsRunning() -> Bool { + static func appIsRunning(_ bundleID: String) -> Bool { #if canImport(AppKit) return !NSRunningApplication.runningApplications( - withBundleIdentifier: notionBundleID).isEmpty + withBundleIdentifier: bundleID).isEmpty #else return true #endif diff --git a/Sources/SpaceMatters/Scanner/ContainerEngine.swift b/Sources/SpaceMatters/Scanner/ContainerEngine.swift index 9123556..b47662b 100644 --- a/Sources/SpaceMatters/Scanner/ContainerEngine.swift +++ b/Sources/SpaceMatters/Scanner/ContainerEngine.swift @@ -46,6 +46,31 @@ struct CVolume: Identifiable { var id: String { name } } +/// The host-side disk image backing a Podman machine, and what it actually +/// occupies. +/// +/// A Podman machine's disk is a sparse file: it declares its full configured +/// size and allocates blocks as the guest writes them. Deleting things inside +/// the guest frees guest space but leaves those host blocks allocated — which +/// is why a `system prune` that reclaims 15 GB inside can leave the `.raw` +/// exactly as large as before, and why the usual advice ("podman disks never +/// shrink, recreate the machine") gets written. +/// +/// It is wrong: applehv's virtio-blk advertises discard, and Fedora CoreOS runs +/// `fstrim.timer` weekly. The blocks do come back — just up to a week late. +/// `ContainerActions.trim` is that timer, on demand. +struct CMachineDisk { + let machine: String + let imagePath: String + /// Blocks actually allocated on the host (`st_blocks`) — what deleting the + /// file would free, and the only number that moves when the guest is + /// trimmed. + let onDisk: Int64 + /// The size the guest sees, and the file's apparent length. Always ≥ + /// `onDisk`; quoting it as reclaimable is the classic sparse-file error. + let apparent: Int64 +} + /// A row of `system df` (authoritative sizes, deduped across shared layers). struct CDFRow: Identifiable { let type: String @@ -94,10 +119,53 @@ enum ContainerQueries { var images: [CImage] = [] var containers: [CContainer] = [] var volumes: [CVolume] = [] + /// Podman only, and only when the image file can be located. + var machineDisk: CMachineDisk? } static func fetchAll(_ engine: ContainerEngine) -> Snapshot { - Snapshot(df: df(engine), images: images(engine), containers: containers(engine), volumes: volumes(engine)) + Snapshot(df: df(engine), images: images(engine), containers: containers(engine), + volumes: volumes(engine), machineDisk: machineDisk(engine)) + } + + /// The running machine's disk image, measured on the host. + /// + /// Podman does not report the image path, so it is derived from the one + /// machine path it does report — `SSHConfig.IdentityPath`, which sits in the + /// machine root next to the per-provider directories. The file is then found + /// by name rather than by guessing the provider and the architecture suffix. + /// Nothing here is fatal: no path found simply means no reclaim row. + static func machineDisk(_ engine: ContainerEngine) -> CMachineDisk? { + guard engine.kind == .podman, + let arr = jsonArray(engine, ["machine", "inspect"]), + let machine = arr.first(where: { ($0["State"] as? String) == "running" }), + let name = machine["Name"] as? String, + let ssh = machine["SSHConfig"] as? [String: Any], + let identity = ssh["IdentityPath"] as? String + else { return nil } + + let machineRoot = (identity as NSString).deletingLastPathComponent + guard let path = findDiskImage(machineRoot: machineRoot, name: name) else { return nil } + var st = stat() + guard stat(path, &st) == 0 else { return nil } + return CMachineDisk(machine: name, imagePath: path, + onDisk: Int64(st.st_blocks) * 512, apparent: Int64(st.st_size)) + } + + /// `//*.raw`. Internal for tests — the layout + /// is podman's, not ours, and a provider rename must fail loudly in CI + /// rather than quietly drop the reclaim row. + static func findDiskImage(machineRoot: String, name: String, + fm: FileManager = .default) -> String? { + guard let providers = try? fm.contentsOfDirectory(atPath: machineRoot) else { return nil } + for provider in providers { + let dir = machineRoot + "/" + provider + guard let entries = try? fm.contentsOfDirectory(atPath: dir) else { continue } + if let image = entries.first(where: { $0.hasPrefix(name) && $0.hasSuffix(".raw") }) { + return dir + "/" + image + } + } + return nil } static func df(_ engine: ContainerEngine) -> [CDFRow] { diff --git a/Sources/SpaceMatters/Util/CleanupJournal.swift b/Sources/SpaceMatters/Util/CleanupJournal.swift index 783ab69..fbffa8b 100644 --- a/Sources/SpaceMatters/Util/CleanupJournal.swift +++ b/Sources/SpaceMatters/Util/CleanupJournal.swift @@ -11,7 +11,12 @@ enum CleanupJournal { let targetID: String /// "file" for the built-in engine, the native label otherwise. var engine: String + /// The paths touched — capped, see `init`. `pathCount` is the true + /// figure whenever this is a sample. let paths: [String] + /// How many paths the target actually had. Equal to `paths.count` + /// except on a discovered target big enough to be sampled. + var pathCount: Int let bytesBefore: Int64 var bytesAfter: Int64 = 0 var removed = 0 @@ -21,6 +26,24 @@ enum CleanupJournal { var diagnostic: String? /// Tools detected running for this target when the clean started. var activeTools: [String] = [] + + /// At most this many paths are written per entry. + /// + /// A hand-picked cache target has one or two paths, but a discovered one + /// can have thousands — a real machine produced 8,360 `bin`/`obj` + /// folders in a single target. Writing them all would turn one journal + /// line into hundreds of kilobytes and make the file unreadable for the + /// thing it exists for: seeing at a glance what the app touched. The + /// count is kept exact; the list becomes a sample. + static let maxJournalledPaths = 32 + + init(targetID: String, engine: String, paths: [String], bytesBefore: Int64) { + self.targetID = targetID + self.engine = engine + self.paths = Array(paths.prefix(Self.maxJournalledPaths)) + self.pathCount = paths.count + self.bytesBefore = bytesBefore + } } static func append(_ entry: Entry, directory: URL = defaultDirectory) { diff --git a/Sources/SpaceMatters/Util/ToolActivity.swift b/Sources/SpaceMatters/Util/ToolActivity.swift index 719e6f0..fec752f 100644 --- a/Sources/SpaceMatters/Util/ToolActivity.swift +++ b/Sources/SpaceMatters/Util/ToolActivity.swift @@ -38,6 +38,13 @@ enum ToolActivity { return [] } + /// Every target id this can warn about — the set the confirmation dialog + /// asks for. Kept next to the table so a target added to one and forgotten + /// in the other shows up as a failing test rather than as a silent gap. + static var coveredTargets: Set { + Set(commTargets.values.flatMap { $0 } + ["gradle", "maven"]) + } + /// `p_comm` (16 chars, enough for every name here) → catalog target ids. /// `brew` is the bash wrapper script, alive for the whole run — the ruby /// child doesn't need matching. Xcode builds also touch the SwiftPM cache @@ -52,16 +59,21 @@ enum ToolActivity { "npx": ["npm"], "yarn": ["yarn"], "pnpm": ["pnpm"], - "dotnet": ["nuget"], - "msbuild": ["nuget"], + // A build both restores into the NuGet cache and writes the bin/obj it + // is about to be asked to delete — the second is the more disruptive of + // the two, so the artifacts target must be named here too. + "dotnet": ["nuget", "dotnet-artifacts"], + "msbuild": ["nuget", "dotnet-artifacts"], "pip": ["pip"], "pip3": ["pip"], "uv": ["uv"], "gradle": ["gradle"], "mvn": ["maven"], "mvnd": ["maven"], - "cargo": ["cargo"], - "go": ["go-build"], + "cargo": ["cargo", "cargo-artifacts"], + // A build fills both: compiled output in the build cache, downloaded + // module sources in the module cache. + "go": ["go-build", "go-mod"], "brew": ["homebrew"], ] diff --git a/Sources/SpaceMatters/ViewModel/CleanupController.swift b/Sources/SpaceMatters/ViewModel/CleanupController.swift index ec3e6fa..b169597 100644 --- a/Sources/SpaceMatters/ViewModel/CleanupController.swift +++ b/Sources/SpaceMatters/ViewModel/CleanupController.swift @@ -50,6 +50,10 @@ final class CleanupController { /// A reload asked for mid-clean is remembered and honoured when the batch /// lands, instead of being dropped (or worse, resetting rows under it). private var pendingReload = false + /// True while the disk walk is still running. Sizing the catalog can finish + /// long before it, and `.ready` must not be announced while rows are still + /// on their way — the Clean button becomes live at `.ready`. + private var awaitingDiscovery = false /// Which native cleaner (if any) handles a target, how one is executed, and /// which targets are blocked outright on this machine. All injectable: @@ -57,7 +61,14 @@ final class CleanupController { /// without spawning a real tool or depending on this machine's config. private let nativeLookup: @Sendable (String, String) -> NativeCleaner? private let nativeRunner: @Sendable (NativeCleaner) async -> ProcessResult - private let blockedReason: @Sendable (String, String) -> String? + /// Takes the whole target, not just its id: the app-liveness rule reads + /// `ownerBundleID`, so a target declares its own gate instead of every new + /// app needing a case added here. + private let blockedReason: @Sendable (Cleanable, String) -> String? + /// Targets whose paths depend on what is on the disk (build output, + /// orphaned workspace state, browser profiles). Injectable and run off the + /// main actor — it walks the home directory. + private let discover: @Sendable (String) -> [Cleanable] /// Called on the main actor with one entry per cleaned target — the /// forensic trail behind field reports. Injectable so tests can collect. private let journal: (CleanupJournal.Entry) -> Void @@ -72,29 +83,17 @@ final class CleanupController { await ProcessRunner.run(native.binary, native.arguments, timeout: native.timeout, environment: native.environment) }, - blockedReason: @escaping @Sendable (String, String) -> String? = { id, home in - // A target whose vendor command is the only viable path is blocked - // when that command is missing, rather than offered a file removal - // that would fail on every entry (go-mod). Checked first: it is a - // property of the target, not of this machine's state. - if let missing = NativeCleaner.missingRequirement(for: id, home: home) { - return missing - } - switch id { - case "uv" where CleanupEngine.uvSymlinkMode(home: home): - return "uv link-mode is \"symlink\" — cleaning would break your virtualenvs" - case "notion" where CleanupEngine.notionIsRunning(): - return "Notion is running — quit it first, or it keeps writing to the cache" - default: - return nil - } + blockedReason: @escaping @Sendable (Cleanable, String) -> String? = { + CleanupEngine.blockedReason(for: $0, home: $1) }, + discover: @escaping @Sendable (String) -> [Cleanable] = { CleanupDiscovery.all(home: $0) }, journal: @escaping (CleanupJournal.Entry) -> Void = { CleanupJournal.append($0) }) { self.catalog = catalog self.allowedRoot = allowedRoot self.nativeLookup = nativeLookup self.nativeRunner = nativeRunner self.blockedReason = blockedReason + self.discover = discover self.journal = journal } @@ -112,36 +111,71 @@ final class CleanupController { lastFailures = 0 lastRefused = 0 lastNativeIssues = [] - let detected = CleanupEngine.detect(catalog, allowedRoot: allowedRoot) - let blocked = Dictionary(uniqueKeysWithValues: detected.compactMap { item in - blockedReason(item.id, allowedRoot).map { (item.id, $0) } - }) // Keep existing selections across a refresh; drop ones that vanished // (or got blocked meanwhile). let previouslySelected = Set(rows.filter(\.selected).map(\.id)) - rows = detected.map { - Row(item: $0, - nativeLabel: nativeLookup($0.id, allowedRoot)?.label, - size: blocked[$0.id].map(SizeState.blocked) ?? .pending, - selected: previouslySelected.contains($0.id) && blocked[$0.id] == nil) - } - state = rows.contains { $0.size == .pending } ? .sizing : .ready - // One target at a time. Each sizing walk now drives the full scanner - // worker pool, so running the targets concurrently would multiply - // threads rather than work — and the slowest target used to set the - // whole mode's load time anyway. Rows still fill in live, in order. - let pending = detected.filter { blocked[$0.id] == nil } + // The hand-picked catalog is a pure list, so its rows can be shown and + // sized immediately. Discovery walks the disk — several seconds on a + // developer's home — and joins when it lands, rather than holding every + // other row hostage behind it. + rows = makeRows(CleanupEngine.detect(catalog, allowedRoot: allowedRoot), + selected: previouslySelected) + state = .sizing + awaitingDiscovery = true + let root = allowedRoot + let discover = self.discover Task { - for item in pending { - let measure = await Task.detached(priority: .userInitiated) { - CleanupEngine.size(of: item) - }.value - guard id == self.loadID else { return } - self.apply(measure, to: item.id) + // Started first, awaited last: the walk is readdir-only and + // single-threaded, so it costs nothing to run it alongside the + // sizing walks rather than before them. + let discovery = Task.detached(priority: .userInitiated) { + CleanupEngine.detect(discover(root), allowedRoot: root) + } + await self.size(self.rows.filter { $0.size == .pending }.map(\.item), id: id) + + let discovered = await discovery.value + guard id == self.loadID else { return } + let fresh = self.makeRows(discovered, selected: previouslySelected) + self.rows += fresh + self.awaitingDiscovery = false + await self.size(fresh.filter { $0.size == .pending }.map(\.item), id: id) + // Nothing left to measure and nothing still to arrive: the batch may + // have been all-blocked or empty, in which case no `apply` ran to + // make the transition. + if id == self.loadID, self.state == .sizing, + !self.rows.contains(where: { $0.size == .pending }) { + self.state = .ready } } } + /// Size targets one at a time. Each sizing walk drives the full scanner + /// worker pool, so running the targets concurrently would multiply threads + /// rather than work — and the slowest target sets the mode's load time + /// either way. Rows still fill in live, in order. + private func size(_ items: [Cleanable], id: Int) async { + for item in items { + let measure = await Task.detached(priority: .userInitiated) { + CleanupEngine.size(of: item) + }.value + guard id == loadID else { return } + apply(measure, to: item.id) + } + } + + /// Rows for a detected batch, with the blocked reasons resolved and the + /// pre-refresh selection carried over. Shared by the catalog and the + /// discovered batch so both get identical treatment. + private func makeRows(_ items: [Cleanable], selected: Set) -> [Row] { + items.map { item in + let blocked = blockedReason(item, allowedRoot) + return Row(item: item, + nativeLabel: nativeLookup(item.id, allowedRoot)?.label, + size: blocked.map(SizeState.blocked) ?? .pending, + selected: selected.contains(item.id) && blocked == nil) + } + } + func refresh() { load() } /// Leaving the mode detaches the UI from in-flight work (sizing results are /// dropped, a running batch stops publishing) — it never aborts deletions @@ -149,6 +183,7 @@ final class CleanupController { func stop() { loadID += 1 pendingReload = false + awaitingDiscovery = false } /// The model guards itself: a pending/denied/blocked row cannot be selected @@ -163,15 +198,17 @@ final class CleanupController { enum SelectAllState { case none, some, all } - /// Select-all drives the *cache* rows only. The Trash is the one target - /// that is user data, not a regenerable cache — it is selected row by row, - /// never swept up in momentum, and the header checkbox ignores it both ways. + /// Select-all drives the *regenerable* rows only. The Trash and orphaned + /// editor state are user data whose bytes never come back — they are + /// selected row by row, never swept up in momentum, and the header checkbox + /// ignores them both ways. private func isBulkSelectable(_ row: Row) -> Bool { - row.size.isSelectable && row.item.id != "trash" + row.size.isSelectable && row.item.regenerable } /// Select-all checkbox state over the bulk-selectable rows (denied/pending/ - /// blocked rows have no trustworthy size and never count; nor does Trash). + /// blocked rows have no trustworthy size and never count; nor do the + /// non-regenerable ones). var selectAllState: SelectAllState { let selectable = rows.filter(isBulkSelectable) let selected = selectable.filter(\.selected).count @@ -290,7 +327,7 @@ final class CleanupController { } case .denied: rows[idx].size = .denied } - if state == .sizing, !rows.contains(where: { $0.size == .pending }) { + if state == .sizing, !awaitingDiscovery, !rows.contains(where: { $0.size == .pending }) { state = .ready } } diff --git a/Sources/SpaceMatters/ViewModel/ContainerController.swift b/Sources/SpaceMatters/ViewModel/ContainerController.swift index 37d6843..b1ca122 100644 --- a/Sources/SpaceMatters/ViewModel/ContainerController.swift +++ b/Sources/SpaceMatters/ViewModel/ContainerController.swift @@ -15,6 +15,10 @@ final class ContainerController { private(set) var images: [CImage] = [] private(set) var containers: [CContainer] = [] private(set) var volumes: [CVolume] = [] + private(set) var machineDisk: CMachineDisk? + /// Host bytes the last trim actually returned, measured by re-`stat`ing the + /// image. Never taken from `fstrim`'s own output — see `trimMachineDisk`. + private(set) var lastTrimFreed: Int64? /// Failure of the last cleanup action (timeout, engine refusal…), surfaced /// as an alert — a prune that dies silently looks like a button that does @@ -40,6 +44,7 @@ final class ContainerController { engineName = engine.displayName state = .loading df = []; images = []; containers = []; volumes = [] + machineDisk = nil; lastTrimFreed = nil expandedImages = []; layerCache = [:] actionError = nil runningAction = nil @@ -68,6 +73,7 @@ final class ContainerController { images = snapshot.images.sorted { $0.size > $1.size } containers = snapshot.containers.sorted { $0.size > $1.size } volumes = snapshot.volumes.sorted { $0.size > $1.size } + machineDisk = snapshot.machineDisk state = .ready } @@ -110,12 +116,58 @@ final class ContainerController { func pruneVolumes() { run("Prune volumes", ["volume", "prune", "-f"]) } func removeContainer(_ container: CContainer) { run("Remove container", ["rm", "-f", container.id]) } + /// Return the guest's free blocks to the host, shrinking the machine's + /// sparse disk image. + /// + /// This is the step that makes every prune above visible on the Mac. Podman + /// frees space *inside* the VM; the host file keeps the blocks until the + /// guest filesystem discards them, and Fedora CoreOS only does that on a + /// weekly timer. Running `fstrim` on demand collapses that week to now. + /// + /// Reads as a nothing-happened when run on its own after a fresh trim, and + /// that is correct: there is nothing to give back until something inside has + /// been deleted. Prune first, then trim. + /// + /// The freed figure is measured, never reported. `fstrim` prints the size of + /// the free extents it walked, which on a mostly-empty filesystem is close + /// to the whole disk — it read "39.5 GiB trimmed" for 2.06 GB actually + /// returned in testing. Only the before/after `st_blocks` of the image is + /// the truth. + func trimMachineDisk() { + guard let engine, engine.kind == .podman, let disk = machineDisk, + runningAction == nil else { return } + runningAction = "Reclaim host disk" + actionError = nil + lastTrimFreed = nil + let id = loadID + Task { + let before = disk.onDisk + // `/` and `/var` are the same XFS filesystem on a CoreOS machine; + // trimming one covers both. + let result = await ProcessRunner.run( + engine.executable, ["machine", "ssh", disk.machine, "sudo", "fstrim", "/var"], + timeout: 600) + guard id == loadID else { return } + runningAction = nil + if result.ok { + let after = ContainerQueries.machineDisk(engine) + lastTrimFreed = max(0, before - (after?.onDisk ?? before)) + } else { + actionError = "Reclaim host disk failed: \(result.diagnostic)" + } + await reload(id) + } + } + func clearActionError() { actionError = nil } private func run(_ label: String, _ args: [String]) { guard let engine, runningAction == nil else { return } runningAction = label actionError = nil + // A prune frees guest space and leaves the host image untouched, so the + // previous trim's figure would now be describing a stale state. + lastTrimFreed = nil let id = loadID Task { let result = await ProcessRunner.run(engine.executable, args, timeout: 600) diff --git a/Sources/SpaceMatters/Views/CleanupResultView.swift b/Sources/SpaceMatters/Views/CleanupResultView.swift index 63533b0..b222230 100644 --- a/Sources/SpaceMatters/Views/CleanupResultView.swift +++ b/Sources/SpaceMatters/Views/CleanupResultView.swift @@ -58,10 +58,12 @@ struct CleanupResultView: View { let names = controller.selectedRows.map(\.item.name).joined(separator: ", ") var message = "\(names) — about \(Format.bytes(controller.totalSelected)) will be reclaimed. " + "Caches are re-downloaded or rebuilt on demand." - // The Trash is the one selected target that is user data, not a cache — - // its warning appears exactly when it applies, so it is never diluted. - if controller.selectedRows.contains(where: { $0.id == "trash" }) { - message += "\n\n⚠️ The Trash is not a cache: emptying it permanently deletes those files." + // Targets whose bytes do not come back are named one by one rather than + // covered by a blanket sentence: the warning appears exactly when it + // applies, so it is never diluted into background noise. + let permanent = controller.selectedRows.filter { !$0.item.regenerable } + for row in permanent { + message += "\n\n⚠️ \(row.item.name) is not a cache: \(row.item.note)" } let tools = activeWarnings.values.reduce(into: Set()) { $0.formUnion($1) } if !tools.isEmpty { @@ -225,7 +227,14 @@ struct CleanupResultView: View { Text("Cleaning…") .font(.system(size: 12)).foregroundStyle(theme.textSecondary) } else { - Text("Everything here is safe to remove: caches are re-downloaded or rebuilt when needed.") + // Not all of it is a cache any more: build output and orphaned + // editor state are offered too, and the footer must not promise + // that everything listed comes back on its own. + Text(controller.rows.allSatisfy(\.item.regenerable) + ? "Everything here is safe to remove: caches and build output are " + + "re-downloaded or rebuilt when needed." + : "Safe to remove — though the rows marked below are not caches and " + + "will not come back. Select those one by one.") .font(.system(size: 11)).foregroundStyle(theme.textSecondary) } Spacer() @@ -310,6 +319,20 @@ private struct CleanupRowView: View { .lineLimit(1) .layoutPriority(1) + // The one distinction the row must carry on its face: these bytes + // do not come back. It is also why select-all skips the row, so the + // badge explains an absence the user would otherwise have to notice. + if !row.item.regenerable { + Text("not a cache") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(Color(hex: 0xE0915A)) + .padding(.horizontal, 5).padding(.vertical, 1) + .background(Capsule().fill(Color(hex: 0xE0915A).opacity(0.14))) + .layoutPriority(1) + .help("Deleting this frees space permanently — nothing regenerates it, " + + "so it is never included in Select all.") + } + if let native = row.nativeLabel { Text("via \(native)") .font(.system(size: 9, weight: .medium)) @@ -321,11 +344,16 @@ private struct CleanupRowView: View { Spacer(minLength: 8) - Text(row.item.paths.map(abbreviate).joined(separator: " · ")) + // Discovered targets carry hundreds of paths; listing them would be + // a truncated smear, so they summarise instead. The full list is + // still in the tooltip and in the journal. + Text(row.item.locationLabel ?? row.item.paths.map(abbreviate).joined(separator: " · ")) .font(.system(size: 10)) .foregroundStyle(theme.textSecondary.opacity(0.7)) .lineLimit(1).truncationMode(.middle) .frame(maxWidth: 260, alignment: .trailing) + .help(row.item.paths.prefix(40).map(abbreviate).joined(separator: "\n") + + (row.item.paths.count > 40 ? "\n… and \(row.item.paths.count - 40) more" : "")) sizeLabel .frame(width: 80, alignment: .trailing) diff --git a/Sources/SpaceMatters/Views/ContainerResultView.swift b/Sources/SpaceMatters/Views/ContainerResultView.swift index 3ad1f3b..62138d0 100644 --- a/Sources/SpaceMatters/Views/ContainerResultView.swift +++ b/Sources/SpaceMatters/Views/ContainerResultView.swift @@ -12,6 +12,7 @@ struct ContainerResultView: View { private enum PruneKind: Identifiable { case images, containers, volumes; var id: Int { hashValue } } @State private var confirmPrune: PruneKind? @State private var confirmRemove: CImage? + @State private var confirmTrim = false var body: some View { VStack(spacing: 0) { @@ -34,6 +35,18 @@ struct ContainerResultView: View { } .background(theme.windowBackground) .alert(item: $confirmPrune) { kind in pruneAlert(kind) } + .alert("Return free space to the Mac?", isPresented: $confirmTrim) { + Button("Reclaim") { controller.trimMachineDisk() } + Button("Cancel", role: .cancel) {} + } message: { + // Non-destructive, and the message says so plainly: `fstrim` only + // tells the host which blocks the guest already considers free. + // Nothing inside the VM is removed — that is what the prune buttons + // are for, and doing them first is what makes this worth running. + Text("Nothing inside the VM is deleted. This tells the Mac which blocks the " + + "machine has already freed, so its disk image can shrink.\n\n" + + "Prune images and volumes first — this only hands back what is already free.") + } .alert(item: $confirmRemove) { image in removeAlert(image) } .alert("Action failed", isPresented: actionErrorShown) { Button("OK", role: .cancel) { controller.clearActionError() } @@ -90,12 +103,67 @@ struct ContainerResultView: View { summaryCard(controller.imagesRow, title: "Images", prune: .images) summaryCard(controller.containersRow, title: "Containers", prune: .containers) summaryCard(controller.volumesRow, title: "Volumes", prune: .volumes) + if let disk = controller.machineDisk { hostDiskCard(disk) } Spacer() } .padding(.horizontal, 14).padding(.vertical, 10) .background(theme.panelBackground) } + /// The fourth card is not a fourth kind of thing to prune — it is the other + /// side of the same bytes. The three cards to its left count space *inside* + /// the VM; this one counts what the VM's disk image occupies on the Mac, + /// which is what the user actually came to reclaim. Keeping them adjacent is + /// the point: it is the only place the app can show that pruning 15 GB of + /// images moved nothing on the host yet. + private func hostDiskCard(_ disk: CMachineDisk) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("ON THIS MAC") + .font(.system(size: 9, weight: .bold)).foregroundStyle(theme.textSecondary) + Spacer() + Text(disk.machine) + .font(.system(size: 9, weight: .bold)).foregroundStyle(theme.textSecondary) + .lineLimit(1).truncationMode(.middle) + } + Text(Format.bytes(disk.onDisk)) + .font(.system(size: 17, weight: .semibold).monospacedDigit()) + .foregroundStyle(theme.textPrimary) + Button { confirmTrim = true } label: { + Text(controller.runningAction == "Reclaim host disk" ? "Reclaiming…" : "Reclaim host disk") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 7).padding(.vertical, 3) + .background(Capsule().fill(Color(hex: 0xE0915A))) + } + .buttonStyle(.plain) + .disabled(controller.runningAction != nil) + .opacity(controller.runningAction != nil ? 0.5 : 1) + + Text(trimFootnote(disk)) + .font(.system(size: 9)).foregroundStyle(theme.textSecondary.opacity(0.8)) + .lineLimit(2).fixedSize(horizontal: false, vertical: true) + } + .frame(width: 170, alignment: .leading) + .padding(12) + .background(RoundedRectangle(cornerRadius: 10).fill(theme.windowBackground)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(theme.separator)) + .help("\(disk.imagePath)\nDeclared \(Format.bytes(disk.apparent)), allocated " + + "\(Format.bytes(disk.onDisk)). Only the allocated figure is real disk usage.") + } + + /// After a trim, the measured result — including zero, which is a real and + /// common answer (nothing was deleted inside since the last trim) and must + /// not be dressed up as a failure. Otherwise, the sparse gap. + private func trimFootnote(_ disk: CMachineDisk) -> String { + if let freed = controller.lastTrimFreed { + return freed > 0 + ? "returned \(Format.bytes(freed)) to the Mac" + : "already trimmed — prune inside first" + } + return "of \(Format.bytes(disk.apparent)) declared" + } + private func summaryCard(_ row: CDFRow?, title: String, prune: PruneKind) -> some View { let size = row?.size ?? 0 let reclaimable = row?.reclaimable ?? 0 diff --git a/Tests/SpaceMattersTests/CleanupDiscoveryTests.swift b/Tests/SpaceMattersTests/CleanupDiscoveryTests.swift new file mode 100644 index 0000000..67abd9e --- /dev/null +++ b/Tests/SpaceMattersTests/CleanupDiscoveryTests.swift @@ -0,0 +1,383 @@ +import Foundation +import Testing +@testable import SpaceMatters + +/// Safety invariants of discovered cleanup targets. +/// +/// The hand-picked catalog is safe because a human wrote each path down. +/// Discovery has no such guarantee, so what it *refuses* matters more than what +/// it finds: these tests pin the refusals first, and the marker rule that +/// produces them. +@Suite struct CleanupDiscoveryTests { + + private static func fixture() throws -> URL { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("discovery-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private static func makeDir(_ root: URL, _ relative: String) throws -> URL { + let url = root.appendingPathComponent(relative) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private static func makeFile(_ root: URL, _ relative: String, bytes: Int = 2048) throws { + let url = root.appendingPathComponent(relative) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(count: bytes).write(to: url) + } + + // MARK: The marker rule + + /// The whole feature in one test: `bin` and `obj` beside a project file are + /// build output; the same names anywhere else are not, and the difference is + /// a sibling marker rather than a name the code happens to trust. + @Test func binIsOnlyOfferedNextToAProjectFile() throws { + let root = try Self.fixture() + defer { try? FileManager.default.removeItem(at: root) } + try Self.makeFile(root, "App/App.csproj") + _ = try Self.makeDir(root, "App/bin/Debug") + _ = try Self.makeDir(root, "App/obj/Debug") + // Same names, no project file: a plain output directory of some other + // toolchain. Nothing here explains deleting it. + _ = try Self.makeDir(root, "Tool/bin") + _ = try Self.makeDir(root, "Tool/obj") + + let hits = Set(CleanupDiscovery.walkForArtifacts(home: root.path).map(\.path)) + + #expect(hits.contains(root.appendingPathComponent("App/bin").path)) + #expect(hits.contains(root.appendingPathComponent("App/obj").path)) + #expect(!hits.contains(root.appendingPathComponent("Tool/bin").path)) + #expect(!hits.contains(root.appendingPathComponent("Tool/obj").path)) + } + + /// The refusal that justifies the marker rule existing. A Python virtualenv + /// keeps `python`, `pip` and `activate` in `bin/` — a `find -name bin` sweep + /// over a developer's home destroys every virtualenv on the disk. Checked + /// for the un-hidden spellings too, so the guarantee does not rest on the + /// prune list catching `.venv`. + @Test func virtualenvBinIsNeverOffered() throws { + let root = try Self.fixture() + defer { try? FileManager.default.removeItem(at: root) } + for venv in [".venv", "venv", "env", "virtualenv"] { + try Self.makeFile(root, "Py/\(venv)/bin/activate") + try Self.makeFile(root, "Py/\(venv)/pyvenv.cfg") + } + // A real .NET project in the same tree, so the walk is definitely + // reaching this directory and choosing not to offer the venvs. + try Self.makeFile(root, "Py/Native/Native.csproj") + _ = try Self.makeDir(root, "Py/Native/bin") + + let hits = CleanupDiscovery.walkForArtifacts(home: root.path).map(\.path) + + #expect(hits.contains(root.appendingPathComponent("Py/Native/bin").path)) + #expect(!hits.contains { $0.contains("venv") || $0.contains("/env/") }) + } + + /// A `target/` is Cargo's only when a `Cargo.toml` sits beside it — the + /// name is far too common to trust on its own. + @Test func cargoTargetNeedsItsManifest() throws { + let root = try Self.fixture() + defer { try? FileManager.default.removeItem(at: root) } + try Self.makeFile(root, "Crate/Cargo.toml") + _ = try Self.makeDir(root, "Crate/target/debug") + _ = try Self.makeDir(root, "Deploy/target") // a build output of something else + + let hits = CleanupDiscovery.walkForArtifacts(home: root.path) + + #expect(hits.map(\.path) == [root.appendingPathComponent("Crate/target").path]) + #expect(hits.first?.rule.directory == "target") + } + + /// Matched directories are not descended into: a nested `bin/obj` inside + /// build output is part of the artifact already, and counting it twice would + /// double-count its bytes in the row's total. + @Test func matchedDirectoriesAreNotDescendedInto() throws { + let root = try Self.fixture() + defer { try? FileManager.default.removeItem(at: root) } + try Self.makeFile(root, "App/App.csproj") + try Self.makeFile(root, "App/bin/Debug/Nested/Nested.csproj") + _ = try Self.makeDir(root, "App/bin/Debug/Nested/bin") + + let hits = CleanupDiscovery.walkForArtifacts(home: root.path).map(\.path) + + #expect(hits == [root.appendingPathComponent("App/bin").path]) + } + + /// `node_modules` is pruned: it is the single biggest cost in the walk, and + /// a project vendored inside one is not the user's to clean here. + @Test func prunedDirectoriesAreNotEntered() throws { + let root = try Self.fixture() + defer { try? FileManager.default.removeItem(at: root) } + try Self.makeFile(root, "Web/node_modules/pkg/Vendored.csproj") + _ = try Self.makeDir(root, "Web/node_modules/pkg/bin") + + #expect(CleanupDiscovery.walkForArtifacts(home: root.path).isEmpty) + } + + /// The depth bound holds, so a pathological tree cannot turn the mode's load + /// into a full-disk scan. + @Test func walkStopsAtMaxDepth() throws { + let root = try Self.fixture() + defer { try? FileManager.default.removeItem(at: root) } + let deep = (0..<10).map { "d\($0)" }.joined(separator: "/") + try Self.makeFile(root, "\(deep)/App.csproj") + _ = try Self.makeDir(root, "\(deep)/bin") + + #expect(CleanupDiscovery.walkForArtifacts(home: root.path, maxDepth: 3).isEmpty) + #expect(CleanupDiscovery.walkForArtifacts(home: root.path, maxDepth: 12).count == 1) + } + + /// Discovered targets are removed directory-and-all (MSBuild recreates + /// them), unlike caches whose root must survive. + @Test func projectArtifactsRemoveTheDirectoryItself() throws { + let root = try Self.fixture() + defer { try? FileManager.default.removeItem(at: root) } + try Self.makeFile(root, "App/App.csproj") + try Self.makeFile(root, "App/bin/App.dll", bytes: 8192) + + let targets = CleanupDiscovery.projectArtifacts(home: root.path) + let dotnet = try #require(targets.first { $0.id == "dotnet-artifacts" }) + #expect(dotnet.removal == .directory) + + let result = CleanupEngine.clean(dotnet, allowedRoot: root.path) + #expect(result.removed == 1) + #expect(result.failed == 0 && result.refused == 0) + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("App/bin").path)) + // The project itself is untouched — only its output went. + #expect(FileManager.default.fileExists(atPath: root.appendingPathComponent("App/App.csproj").path)) + } + + /// Discovery gets no weaker a fence than the hand-written catalog: a + /// discovered path that resolves outside the allowed root is refused at + /// cleaning time, not chased. + @Test func discoveredPathsStillObeyTheFence() throws { + let root = try Self.fixture() + let outside = try Self.fixture() + defer { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.removeItem(at: outside) + } + try Self.makeFile(outside, "keep.bin") + let item = Cleanable(id: "dotnet-artifacts", name: "fixture", category: "t", icon: "x", + note: "n", paths: [outside.path], removal: .directory) + + let result = CleanupEngine.clean(item, allowedRoot: root.path) + + #expect(result.refused == 1) + #expect(result.removed == 0) + #expect(FileManager.default.fileExists(atPath: outside.appendingPathComponent("keep.bin").path)) + } + + // MARK: Workspace storage + + /// The finding this target exists to correct. Emptying `workspaceStorage` + /// wholesale is the usual advice and it is wrong: most folders belong to + /// projects still on the disk, and the bytes are chat transcripts nothing + /// regenerates. Only the folders whose project is gone are offered. + @Test func onlyWorkspacesWhoseFolderIsGoneAreOffered() throws { + let root = try Self.fixture() + defer { try? FileManager.default.removeItem(at: root) } + let live = try Self.makeDir(root, "sources/live-repo") + let storage = "Library/Application Support/Code/User/workspaceStorage" + + try Self.makeFile(root, "\(storage)/aaa/workspace.json", bytes: 0) + try #"{"folder":"file://\#(live.path)"}"# + .write(toFile: root.appendingPathComponent("\(storage)/aaa/workspace.json").path, + atomically: true, encoding: .utf8) + try Self.makeFile(root, "\(storage)/aaa/chatSessions/s.json", bytes: 4096) + + try Self.makeFile(root, "\(storage)/bbb/workspace.json", bytes: 0) + try #"{"folder":"file:///nope/deleted-repo"}"# + .write(toFile: root.appendingPathComponent("\(storage)/bbb/workspace.json").path, + atomically: true, encoding: .utf8) + + let targets = CleanupDiscovery.orphanedWorkspaceStorage(home: root.path) + let target = try #require(targets.first) + + #expect(target.paths == [root.appendingPathComponent("\(storage)/bbb").path]) + // Transcripts do not come back, so the row is never swept up by + // select-all even though deleting it breaks nothing. + #expect(target.regenerable == false) + #expect(target.removal == .directory) + #expect(target.ownerBundleID == "com.microsoft.VSCode") + } + + /// Fails closed on anything it cannot resolve. A malformed or absent + /// `workspace.json`, or a workspace that is not local, is kept — local + /// absence proves nothing about a remote workspace. + @Test func unresolvableWorkspacesAreKept() throws { + let root = try Self.fixture() + defer { try? FileManager.default.removeItem(at: root) } + let storage = "Library/Application Support/Code/User/workspaceStorage" + + _ = try Self.makeDir(root, "\(storage)/no-json") + try Self.makeFile(root, "\(storage)/bad-json/workspace.json", bytes: 0) + try "not json at all".write( + toFile: root.appendingPathComponent("\(storage)/bad-json/workspace.json").path, + atomically: true, encoding: .utf8) + try Self.makeFile(root, "\(storage)/remote/workspace.json", bytes: 0) + try #"{"folder":"vscode-remote://ssh-remote%2Bbox/home/me/repo"}"#.write( + toFile: root.appendingPathComponent("\(storage)/remote/workspace.json").path, + atomically: true, encoding: .utf8) + + #expect(CleanupDiscovery.orphanedWorkspaceStorage(home: root.path).isEmpty) + #expect(CleanupDiscovery.localPath(fromFileURI: "vscode-remote://x/y") == nil) + #expect(CleanupDiscovery.localPath(fromFileURI: "file:///tmp/a%20b") == "/tmp/a b") + } + + // MARK: Browser caches + + /// Profile directory names are generated, so the paths are found rather than + /// written down — but they stay inside the browser's cache tree, and only on + /// the subdirectories that hold fetched assets. + @Test func browserCachesFindProfilesWithoutLeavingTheCacheTree() throws { + let root = try Self.fixture() + defer { try? FileManager.default.removeItem(at: root) } + try Self.makeFile(root, "Library/Caches/Google/Chrome/Default/Cache/f") + try Self.makeFile(root, "Library/Caches/Google/Chrome/Default/Code Cache/f") + try Self.makeFile(root, "Library/Caches/Google/Chrome/Default/Storage/keep") + try Self.makeFile(root, "Library/Caches/Firefox/Profiles/ab12.default-release/cache2/f") + + let targets = CleanupDiscovery.browserCaches(home: root.path) + let chrome = try #require(targets.first { $0.id == "chrome-cache" }) + let firefox = try #require(targets.first { $0.id == "firefox-cache" }) + + #expect(chrome.ownerBundleID == "com.google.Chrome") + #expect(chrome.removal == .children) // the cache directory itself must survive + // `Storage` is per-extension state, not fetched assets. + #expect(!chrome.paths.contains { $0.hasSuffix("/Storage") }) + #expect(chrome.paths.allSatisfy { $0.contains("/Library/Caches/Google/Chrome/") }) + #expect(firefox.paths.allSatisfy { $0.contains("/Library/Caches/Firefox/Profiles/") }) + } + + // MARK: Single-path classification + + /// `explain` must reach the same verdict as the walk, without walking. The + /// refusals are the half that matters: they are the advice an assistant + /// reading a size table would otherwise get wrong. + @Test func classifyMatchesTheWalkWithoutWalking() throws { + let root = try Self.fixture() + defer { try? FileManager.default.removeItem(at: root) } + try Self.makeFile(root, "App/App.csproj") + _ = try Self.makeDir(root, "App/bin") + try Self.makeFile(root, "Crate/Cargo.toml") + _ = try Self.makeDir(root, "Crate/target") + try Self.makeFile(root, "Py/.venv/pyvenv.cfg") + _ = try Self.makeDir(root, "Py/.venv/bin") + _ = try Self.makeDir(root, "Go/bin") + + func classify(_ rel: String) -> CleanupDiscovery.Classification? { + CleanupDiscovery.classify(root.appendingPathComponent(rel).path, home: root.path) + } + + guard case .cleanable(let dotnet, _) = classify("App/bin") else { + Issue.record("App/bin should be cleanable"); return + } + #expect(dotnet == ".NET build output") + guard case .cleanable(let cargo, _) = classify("Crate/target") else { + Issue.record("Crate/target should be cleanable"); return + } + #expect(cargo == "Cargo build output") + + // A virtualenv is named as such, not just refused — the reason is what + // stops the next suggestion. + guard case .protected(let venv) = classify("Py/.venv/bin") else { + Issue.record("virtualenv bin should be protected"); return + } + #expect(venv.contains("virtualenv")) + guard case .protected = classify("Go/bin") else { + Issue.record("unmarked bin should be protected"); return + } + #expect(classify("App") == nil) // an ordinary directory is neither + } + + /// The workspace-storage root reports measured counts rather than prose, + /// because prose in a reference file did not stop the wrong advice being + /// given: the summary has to arrive with the numbers attached. + @Test func workspaceStorageRootReportsLiveVersusOrphaned() throws { + let root = try Self.fixture() + defer { try? FileManager.default.removeItem(at: root) } + let live = try Self.makeDir(root, "sources/live") + let storage = "Library/Application Support/Code/User/workspaceStorage" + for (hash, folder) in [("a", live.path), ("b", live.path), ("c", "/gone/repo")] { + try Self.makeFile(root, "\(storage)/\(hash)/workspace.json", bytes: 0) + try #"{"folder":"file://\#(folder)"}"#.write( + toFile: root.appendingPathComponent("\(storage)/\(hash)/workspace.json").path, + atomically: true, encoding: .utf8) + } + + guard case .protected(let summary) = + CleanupDiscovery.classify(root.appendingPathComponent(storage).path, home: root.path) + else { Issue.record("the storage root should be protected"); return } + + #expect(summary.contains("3 workspaces")) + #expect(summary.contains("2 still")) // live + #expect(summary.contains("1 are orphaned") || summary.contains("1 orphaned")) + #expect(summary.contains("chatSessions")) + + // A live workspace's own folder is protected; the dead one is offered. + guard case .protected = CleanupDiscovery.classify( + root.appendingPathComponent("\(storage)/a").path, home: root.path) + else { Issue.record("a live workspace must not be offered"); return } + guard case .cleanable = CleanupDiscovery.classify( + root.appendingPathComponent("\(storage)/c").path, home: root.path) + else { Issue.record("an orphaned workspace should be offered"); return } + } + + // MARK: The MCP surface + + /// End-to-end through the actual tool call, because the classifier being + /// right is only half of it — an assistant only benefits if `explain` says + /// so on the wire. This is the exact failure being fixed: a session that + /// reads a size table, sees gigabytes of `bin/`, and proposes `rm -rf`. + @Test func explainTellsTheModelWhichBinFoldersAreBuildOutput() throws { + let root = try Self.fixture() + defer { try? FileManager.default.removeItem(at: root) } + try Self.makeFile(root, "App/App.csproj") + try Self.makeFile(root, "App/bin/App.dll", bytes: 8192) + try Self.makeFile(root, "Py/pyvenv.cfg") + try Self.makeFile(root, "Py/bin/python", bytes: 8192) + + let server = MCPServer(source: DetachedScanSource(rootPath: root.path)) + func explain(_ relative: String) throws -> String { + let request = try #require(JSONRPC.parse(line: """ + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"explain",\ + "arguments":{"path":"\(root.appendingPathComponent(relative).path)"}}} + """)) + let response = try #require(server.respond(to: request)) + let result = try #require(response["result"] as? [String: Any]) + let content = try #require(result["content"] as? [[String: Any]]) + return try #require(content.first?["text"] as? String) + } + + let buildOutput = try explain("App/bin") + #expect(buildOutput.contains("Known cleanup target \".NET build output\"")) + #expect(buildOutput.contains("do not propose a shell command")) + + // The refusal is the load-bearing half. + let venv = try explain("Py/bin") + #expect(venv.contains("NOT a cleanup target")) + #expect(venv.contains("virtualenv")) + } + + // MARK: Podman machine disk + + /// The image path is derived from the one machine path podman reports. + /// Pinned because the layout is podman's, not ours. + @Test func machineDiskImageIsFoundByName() throws { + let root = try Self.fixture() + defer { try? FileManager.default.removeItem(at: root) } + try Self.makeFile(root, "applehv/podman-machine-default-arm64.raw") + try Self.makeFile(root, "applehv/efi-bl-podman-machine-default") + try Self.makeFile(root, "libkrun/other-machine-arm64.raw") + + #expect(CleanupDiscovery.localPath(fromFileURI: "file://\(root.path)") == root.path) + #expect(ContainerQueries.findDiskImage(machineRoot: root.path, name: "podman-machine-default") + == root.appendingPathComponent("applehv/podman-machine-default-arm64.raw").path) + #expect(ContainerQueries.findDiskImage(machineRoot: root.path, name: "absent") == nil) + } +} diff --git a/Tests/SpaceMattersTests/CleanupTests.swift b/Tests/SpaceMattersTests/CleanupTests.swift index 8a0b04f..590495c 100644 --- a/Tests/SpaceMattersTests/CleanupTests.swift +++ b/Tests/SpaceMattersTests/CleanupTests.swift @@ -85,8 +85,8 @@ import Foundation icon: "note.text", note: "fixture", paths: [cache.path]) let c = CleanupController( catalog: [notion], allowedRoot: root.path, - blockedReason: { id, _ in - id == "notion" ? "Notion is running — quit it first" : nil + blockedReason: { item, _ in + item.id == "notion" ? "Notion is running — quit it first" : nil }, journal: { _ in }) c.load() @@ -436,7 +436,9 @@ import Foundation } /// The Trash is user data, not a cache: the select-all header ignores it in - /// both directions — it is only ever selected row by row. + /// both directions — it is only ever selected row by row. The rule is the + /// target's `regenerable` flag, not its id, so orphaned editor state gets + /// the same protection without a second special case. @Test func selectAllNeverTouchesTheTrash() async throws { let (root, cache) = try Self.makeFixture() defer { try? FileManager.default.removeItem(at: root) } @@ -444,7 +446,8 @@ import Foundation try FileManager.default.createDirectory(at: trashDir, withIntermediateDirectories: true) try Data(count: 4096).write(to: trashDir.appendingPathComponent("t.bin")) let trash = Cleanable(id: "trash", name: "Trash", category: "System", - icon: "trash.fill", note: "fixture", paths: [trashDir.path]) + icon: "trash.fill", note: "fixture", paths: [trashDir.path], + regenerable: false) let c = CleanupController(catalog: [Self.cleanable([cache.path]), trash], allowedRoot: root.path, journal: { _ in }) c.load() @@ -467,7 +470,7 @@ import Foundation defer { try? FileManager.default.removeItem(at: root) } let c = CleanupController( catalog: [Self.cleanable([cache.path])], allowedRoot: root.path, - blockedReason: { id, _ in id == "test-cache" ? "would break venvs" : nil }, + blockedReason: { item, _ in item.id == "test-cache" ? "would break venvs" : nil }, journal: { _ in }) c.load() await Self.waitForReady(c) @@ -680,6 +683,84 @@ import Foundation #expect(CleanupEngine.size(of: Self.cleanable([link.path])) == .sized(0)) } + + // MARK: Discovered targets + + /// Discovery joins the catalog rather than replacing it, and the mode does + /// not announce `.ready` while the walk is still running — `.ready` is what + /// makes the Clean button live, so declaring it early would offer a clean of + /// a list still missing rows. + @Test func discoveredRowsJoinTheCatalogBeforeReady() async throws { + let (root, cache) = try Self.makeFixture() + defer { try? FileManager.default.removeItem(at: root) } + let extra = root.appendingPathComponent("discovered") + try FileManager.default.createDirectory(at: extra, withIntermediateDirectories: true) + try Data(count: 20_000).write(to: extra.appendingPathComponent("c.bin")) + + let c = CleanupController( + catalog: [Self.cleanable([cache.path])], allowedRoot: root.path, + discover: { _ in + [Cleanable(id: "found", name: "Found", category: "Discovered", icon: "x", + note: "n", paths: [extra.path], removal: .directory, + locationLabel: "1 folder")] + }, + journal: { _ in }) + c.load() + await Self.waitForReady(c) + + #expect(c.rows.map(\.id) == ["test-cache", "found"]) + #expect(c.rows.last?.size == .sized(20_480)) + // The discovered row's bytes count toward the mode's total like any + // other — the strip must not under-report a target it is offering. + #expect(c.totalFound == c.rows.reduce(0) { $0 + $1.size.bytes }) + #expect(c.totalFound > 20_480) + } + + /// A discovered target that is not regenerable (orphaned editor state) is + /// selectable, but never by select-all — same protection the Trash gets, and + /// from the same flag rather than a second hardcoded id. + @Test func nonRegenerableDiscoveredRowsAreNeverBulkSelected() async throws { + let (root, cache) = try Self.makeFixture() + defer { try? FileManager.default.removeItem(at: root) } + let state = root.appendingPathComponent("orphan") + try FileManager.default.createDirectory(at: state, withIntermediateDirectories: true) + try Data(count: 8192).write(to: state.appendingPathComponent("chat.json")) + + let c = CleanupController( + catalog: [Self.cleanable([cache.path])], allowedRoot: root.path, + discover: { _ in + [Cleanable(id: "orphan-state", name: "Orphaned state", category: "Editors", + icon: "x", note: "n", paths: [state.path], + removal: .directory, regenerable: false)] + }, + journal: { _ in }) + c.load() + await Self.waitForReady(c) + + c.toggleAll() + #expect(c.selectedRows.map(\.id) == ["test-cache"]) + #expect(c.selectAllState == .all) // "all" means all regenerable rows + + c.toggle("orphan-state") // explicit per-row opt-in still works + #expect(c.selectedRows.count == 2) + } + + /// The app-liveness gate is declared by the target, not by a case per app: + /// any `ownerBundleID` blocks while that app runs, and nothing blocks when + /// it doesn't. + @Test func ownerBundleIDBlocksWhileItsAppRuns() throws { + let item = Cleanable(id: "chrome-cache", name: "Chrome cache", category: "Browsers", + icon: "globe", note: "n", paths: ["/tmp/x"], + ownerBundleID: "com.google.Chrome") + + let blocked = CleanupEngine.blockedReason(for: item, home: "/tmp", isRunning: { _ in true }) + #expect(blocked == "Chrome is running — quit it first, or it keeps writing to these files") + #expect(CleanupEngine.blockedReason(for: item, home: "/tmp", isRunning: { _ in false }) == nil) + + // A target without an owner is unaffected by whatever is running. + let anonymous = Self.cleanable(["/tmp/x"]) + #expect(CleanupEngine.blockedReason(for: anonymous, home: "/tmp", isRunning: { _ in true }) == nil) + } } /// Serialized call counter usable from `@Sendable` closures in tests. diff --git a/Tests/SpaceMattersTests/ToolActivityTests.swift b/Tests/SpaceMattersTests/ToolActivityTests.swift index af1f03c..aacb281 100644 --- a/Tests/SpaceMattersTests/ToolActivityTests.swift +++ b/Tests/SpaceMattersTests/ToolActivityTests.swift @@ -10,7 +10,8 @@ struct ToolActivityTests { #expect(ToolActivity.classify(comm: "brew", argv: []).map(\.target) == ["homebrew"]) #expect(Set(ToolActivity.classify(comm: "Xcode", argv: []).map(\.target)) == Set(["derived-data", "swiftpm"])) - #expect(ToolActivity.classify(comm: "go", argv: []).map(\.target) == ["go-build"]) + #expect(Set(ToolActivity.classify(comm: "go", argv: []).map(\.target)) + == Set(["go-build", "go-mod"])) #expect(ToolActivity.classify(comm: "Safari", argv: []).isEmpty) } @@ -32,4 +33,38 @@ struct ToolActivityTests { @Test func activeToolsSmoke() { _ = ToolActivity.activeTools(for: ["homebrew", "gradle", "npm"]) } + + /// A build writes the very `bin`/`obj` the artifacts target deletes, so a + /// running `dotnet` has to warn about both the NuGet cache and the build + /// output. Pinned because the two are easy to add separately and the gap is + /// silent: the dialog would simply not mention the build it is about to + /// break. + @Test func buildsWarnAboutTheirOwnOutputNotJustTheirCache() { + #expect(Set(ToolActivity.classify(comm: "dotnet", argv: []).map(\.target)) + == Set(["nuget", "dotnet-artifacts"])) + #expect(Set(ToolActivity.classify(comm: "msbuild", argv: []).map(\.target)) + == Set(["nuget", "dotnet-artifacts"])) + #expect(Set(ToolActivity.classify(comm: "cargo", argv: []).map(\.target)) + == Set(["cargo", "cargo-artifacts"])) + } + + /// Every target that can be offered must be one the warning table knows + /// about, or the confirmation silently omits the tool it is about to + /// disrupt. Targets with no plausible command-line owner are listed as + /// deliberate exemptions, so adding a target forces a decision here. + @Test func everyOfferableTargetIsCoveredOrDeliberatelyExempt() { + // App caches gate on `ownerBundleID` instead (a running app blocks the + // row outright); the Trash and editor state have no build tool at all. + let exempt: Set = [ + "trash", "notion", "slack", "zoom", "app-updaters", + "chrome-cache", "firefox-cache", "playwright", "electron", + "node-gyp", "typescript", "bun", + "workspace-storage-code", "workspace-storage-code---insiders", + "workspace-storage-cursor", "workspace-storage-vscodium", + ] + let offered = Set(CleanupEngine.catalog().map(\.id)) + .union(["dotnet-artifacts", "cargo-artifacts"]) + let uncovered = offered.subtracting(ToolActivity.coveredTargets).subtracting(exempt) + #expect(uncovered.isEmpty, "no active-tool warning for: \(uncovered.sorted())") + } } From 196dbd64d6263cb2c32c4e823972bd3badf3884b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20DUCOM?= Date: Sun, 16 Aug 2026 22:08:19 +0200 Subject: [PATCH 2/5] feat(containers): say what an untagged image is, and stop overstating what it frees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image list was 227 rows on this machine, 200 of them called `` and most of them the same size. Nothing in it could be read, and the two numbers it did show were both wrong in the same direction. The engine knows more than the list was asking. When a build or a pull moves a tag, the displaced image keeps the tag it used to carry in its `History`, so 198 of those 200 resolve to a real name. The rest fall through a ladder — live tag, repository by digest, former tag, OCI labels — with `` reserved for the images nothing identifies. Each rung below the first is inference, and a badge says so on the row: a name presented bare would claim a tag that now points somewhere else. Buildah scratch names (`<64 hex>-tmp:latest`) become "build leftover" rather than a repository that never existed, and digest references lose 71 characters of hash to a badge. Resolved names then group: forty rebuilds of one tag arrive as one line saying so, collapsed, largest first, and open to the individual images — which now carry an age, the only thing telling two otherwise identical rebuilds apart. 227 rows become about a dozen. The sizes were the other half. An image's size counts every layer it holds, including those its neighbours hold too, so the rows summed to 100.8 GiB against 17.3 GiB of actual images. `podman system df -v` reports the bytes an image holds alone — it refuses `--format json`, hence the table parse, read from both ends because CREATED is a human duration of unpredictable width — and a row whose size mostly is not its own now says what deleting it really frees: 906 MiB listed, 11.6 KiB alone. No such figure is offered per group, deliberately. Measured here, 38 rebuilds of harness/runner-local:latest sum to 42.6 GiB of size and 0 B of per-image unique size, because every layer is shared with a sibling: one number is far too large, the other far too small, and neither is what removing the set frees. The group says the layers are shared and to remove them together; the section header points at the engine's own reclaimable for the total. Three wrong numbers would not have been better than two. --- .../Scanner/ContainerEngine.swift | 206 +++++++++++++++++- Sources/SpaceMatters/Util/Formatting.swift | 19 ++ .../ViewModel/ContainerController.swift | 30 ++- .../Views/ContainerResultView.swift | 199 ++++++++++++++--- .../EngineParsingTests.swift | 162 ++++++++++++++ 5 files changed, 581 insertions(+), 35 deletions(-) diff --git a/Sources/SpaceMatters/Scanner/ContainerEngine.swift b/Sources/SpaceMatters/Scanner/ContainerEngine.swift index b47662b..f83e6ef 100644 --- a/Sources/SpaceMatters/Scanner/ContainerEngine.swift +++ b/Sources/SpaceMatters/Scanner/ContainerEngine.swift @@ -13,13 +13,108 @@ struct ContainerEngine: Identifiable { // MARK: Domain model struct CImage: Identifiable { + + /// Where the row's name came from, and therefore what the image *is*. + /// + /// An untagged image listed as `` tells the user nothing, and on a + /// machine that rebuilds the same tags all day there are hundreds of them — + /// 200 of 227 here. But the engine does know what each one was: when a + /// build or a pull moves a tag to a new image, the old one keeps the tag it + /// used to carry in its `History`. Reading that turns an unreadable wall of + /// `` into "37 superseded builds of harness/runner-local:latest", + /// which is both legible and the actual finding. + enum Origin: Equatable { + /// Carries a repository and tag right now. + case tagged + /// Untagged because a newer image took its tag. The name is that tag. + case superseded + /// A buildah/BuildKit scratch image from an interrupted or multi-stage + /// build — never had a real tag and never will. + case buildIntermediate + /// No tag, but pulled by digest, so the repository is still known. + case digest + /// Identified only by its OCI labels (`image.title` / `image.source`). + case labelled + /// Nothing identifies it — the honest ``. + case anonymous + + /// Short badge shown beside the name, or nil when the name speaks for + /// itself. This is the *reason* the row is not a normal tagged image; + /// without it a resolved name would imply the tag still points here. + var badge: String? { + switch self { + case .tagged: return nil + case .superseded: return "superseded" + case .buildIntermediate: return "build leftover" + case .digest: return "untagged" + case .labelled: return "untagged" + case .anonymous: return "unidentified" + } + } + } + let id: String - let name: String // best repo:tag, or "" + /// Best available identity: the tag it carries, else the tag it used to + /// carry, else its repository or label. `` only when nothing is known. + let name: String let size: Int64 let inUse: Bool let created: Date? + var origin: Origin = .tagged + /// Bytes only this image holds — what removing *this* row would free. + /// + /// `size` is the image's full extent including every layer it shares with + /// others, so a list of them sums to far more than the disk holds: 100.8 GiB + /// of rows against 17.3 GiB of actual images on this machine. Shown when the + /// two diverge, because "265 MB" on a row that frees 6 KB is the single most + /// misleading number in this view. Nil when the engine does not report it. + var uniqueSize: Int64? + var shortID: String { String(id.prefix(12)) } - var dangling: Bool { name == "" || name.hasSuffix(":") } + var dangling: Bool { origin != .tagged } + /// True when `size` badly overstates what deleting this row frees. + var sharesMostOfItsBytes: Bool { + guard let uniqueSize, size > 0 else { return false } + return Double(uniqueSize) / Double(size) < 0.5 + } +} + +/// Images that resolve to the same identity — the tag they carry or used to. +/// +/// One row per image is unreadable when a tag has been rebuilt forty times; one +/// row per identity says what is actually on the disk, and the images stay one +/// disclosure away. +struct CImageGroup: Identifiable { + let name: String + let images: [CImage] + var id: String { name } + + /// Sum of the members' extents. Deliberately **not** presented as what + /// removing the group frees, and there is no property here that claims to + /// be: layers shared between the members belong to none of them + /// individually, so the per-image unique sizes sum to far too little (0 B + /// for a group of 38 rebuilds measured here) while this figure counts every + /// shared layer once per member and so is far too much. The engine's own + /// `system df` reclaimable, on the Images card, is the only honest total — + /// inventing a per-group one would just be a third wrong number. + var size: Int64 { images.reduce(0) { $0 + $1.size } } + + var unusedCount: Int { images.filter { !$0.inUse }.count } + var supersededCount: Int { images.filter { $0.origin == .superseded }.count } + /// A lone image needs no group wrapper — it renders as a plain row. + var isSingle: Bool { images.count == 1 } + + /// True when the members are near-identical rebuilds: each holds almost + /// nothing of its own, because they share their layers with each other. + /// + /// Worth saying out loud, because it changes the advice. Deleting old builds + /// one at a time here frees nothing at all — only removing the whole set + /// releases the layers underneath them. + var layersMostlyShared: Bool { + let measured = images.compactMap(\.uniqueSize) + guard measured.count == images.count, images.count > 1, size > 0 else { return false } + return Double(measured.reduce(0, +)) / Double(size) < 0.1 + } } struct CLayer: Identifiable { @@ -183,18 +278,117 @@ enum ContainerQueries { static func images(_ engine: ContainerEngine) -> [CImage] { guard let arr = jsonArray(engine, ["images", "--format", "json"]) else { return [] } + let unique = uniqueSizes(engine) return arr.map { m in - let names = (m["Names"] as? [String]) ?? (m["RepoTags"] as? [String]) ?? [] + let id = m["Id"] as? String ?? "" + let (name, origin) = identify(m) return CImage( - id: m["Id"] as? String ?? "", - name: names.first ?? "", + id: id, + name: name, size: int64(m["Size"]), inUse: (int(m["Containers"]) ?? 0) > 0, - created: date(m["Created"]) + created: date(m["Created"]), + origin: origin, + uniqueSize: unique[String(id.prefix(12))] ) } } + /// The best name for an image, and where that name came from. + /// + /// Tried in descending order of how firmly the name still applies: a live + /// tag, then a repository known by digest, then the tag the image used to + /// carry before something newer took it, then whatever its OCI labels + /// declare. Internal so the ladder can be pinned in tests against real + /// engine payloads — every rung below the first is inference, and inference + /// presented as an image's name has to be right. + static func identify(_ m: [String: Any]) -> (name: String, origin: CImage.Origin) { + let names = ((m["Names"] as? [String]) ?? (m["RepoTags"] as? [String]) ?? []) + .filter { !$0.hasSuffix(":") && $0 != "" } + if let tag = names.first { + // An image pulled by digest is named `repo@sha256:<64 hex>`. That is + // its real name, but 71 characters of hash crowds every other row + // off the line and distinguishes nothing — the repository plus a + // badge says the same thing legibly. + if let repo = tag.components(separatedBy: "@sha256:").first, repo != tag, !repo.isEmpty { + return (repo, .digest) + } + return (tag, .tagged) + } + + // Docker's flat form: separate Repository/Tag columns. + if let repo = m["Repository"] as? String, repo != "", !repo.isEmpty { + let tag = m["Tag"] as? String ?? "" + return (tag.isEmpty || tag == "" ? repo : "\(repo):\(tag)", .digest) + } + if let digests = m["RepoDigests"] as? [String], + let repo = digests.first?.components(separatedBy: "@").first, !repo.isEmpty { + return (repo, .digest) + } + // `History` holds the names this image has answered to. For an untagged + // image the last one is the tag it lost. + if let history = m["History"] as? [String], let previous = history.last, !previous.isEmpty { + return isBuildIntermediate(previous) + ? ("build leftover", .buildIntermediate) + : (previous, .superseded) + } + if let labels = m["Labels"] as? [String: String] { + for key in ["org.opencontainers.image.title", "org.opencontainers.image.source"] { + if let value = labels[key], !value.isEmpty { return (value, .labelled) } + } + } + return ("", .anonymous) + } + + /// A buildah scratch name: `docker.io/library/<64 hex>-tmp:latest`. It is a + /// former name like any other, but naming the row after it would invent a + /// repository that never existed. Internal for tests. + static func isBuildIntermediate(_ name: String) -> Bool { + let repo = name.components(separatedBy: ":").first ?? name + guard let last = repo.components(separatedBy: "/").last, last.hasSuffix("-tmp") else { + return false + } + let stem = last.dropLast(4) + return stem.count >= 12 && stem.allSatisfy(\.isHexDigit) + } + + /// Short image id → bytes only that image holds. + /// + /// `podman system df -v` is the only place the engine reports this, and it + /// refuses `--format json` alongside `--verbose`, so the table is parsed. + /// Read from both ends rather than by column: the first three fields + /// (repository, tag, id) and the last four (size, shared, unique, + /// containers) are single tokens, while CREATED in between is a human + /// duration of unpredictable width ("3 weeks", "About a minute"). Any line + /// that does not fit is skipped — a missing unique size costs a hint, a + /// misparsed one would misreport what a deletion frees. + static func uniqueSizes(_ engine: ContainerEngine) -> [String: Int64] { + guard engine.kind == .podman, + let out = VMProbe.capture(engine.executable, ["system", "df", "-v"], timeout: 30) + else { return [:] } + return parseUniqueSizes(out) + } + + /// Internal for tests — the format is podman's and can change under us. + static func parseUniqueSizes(_ output: String) -> [String: Int64] { + var result: [String: Int64] = [:] + var inImages = false + for line in output.split(whereSeparator: \.isNewline) { + let text = String(line) + if text.contains("space usage:") { + inImages = text.lowercased().hasPrefix("images") + continue + } + guard inImages else { continue } + let fields = text.split(whereSeparator: \.isWhitespace).map(String.init) + guard fields.count >= 8, fields[0] != "REPOSITORY" else { continue } + let id = fields[2] + guard id.count >= 12, id.allSatisfy(\.isHexDigit) else { continue } + result[id] = parseHumanSize(fields[fields.count - 2]) + } + return result + } + static func containers(_ engine: ContainerEngine) -> [CContainer] { guard let arr = jsonArray(engine, ["ps", "-a", "--size", "--format", "json"]) else { return [] } return arr.map { m in diff --git a/Sources/SpaceMatters/Util/Formatting.swift b/Sources/SpaceMatters/Util/Formatting.swift index 814827d..4872b37 100644 --- a/Sources/SpaceMatters/Util/Formatting.swift +++ b/Sources/SpaceMatters/Util/Formatting.swift @@ -30,6 +30,25 @@ enum Format { countFormatter.string(from: NSNumber(value: value)) ?? "\(value)" } + /// Coarse relative age ("3w ago", "5mo ago"). + /// + /// Coarse on purpose: it exists to separate a build from yesterday from one + /// from last spring in a list of otherwise identical rows, not to date it. + /// A fixed set of buckets also keeps the column from changing width as rows + /// scroll past. + static func age(_ date: Date, now: Date = Date()) -> String { + let seconds = max(0, now.timeIntervalSince(date)) + let day = 86_400.0 + switch seconds { + case ..<(2 * 3600): return "just now" + case .. String { if value >= 1000 { return String(format: "%.1fk/s", value / 1000) diff --git a/Sources/SpaceMatters/ViewModel/ContainerController.swift b/Sources/SpaceMatters/ViewModel/ContainerController.swift index b1ca122..044ab76 100644 --- a/Sources/SpaceMatters/ViewModel/ContainerController.swift +++ b/Sources/SpaceMatters/ViewModel/ContainerController.swift @@ -28,8 +28,28 @@ final class ContainerController { private(set) var runningAction: String? var expandedImages: Set = [] + /// Image groups the user has opened. Collapsed by default: the point of + /// grouping is that forty rebuilds of one tag arrive as one line. + var expandedGroups: Set = [] private(set) var layerCache: [String: [CLayer]] = [:] + /// Images collapsed onto the identity they resolve to, largest first. + /// + /// The engine's own ordering is one row per image, which on a machine that + /// rebuilds the same handful of tags is hundreds of indistinguishable lines. + /// Grouping restores the question the view is meant to answer: which *tag* + /// is holding the space. + var imageGroups: [CImageGroup] { + let grouped = Dictionary(grouping: images, by: \.name) + return grouped + .map { CImageGroup(name: $0.key, images: $0.value.sorted { lhs, rhs in + // Newest first inside a group: the current build is the one the + // user is looking for, its predecessors trail behind it. + (lhs.created ?? .distantPast) > (rhs.created ?? .distantPast) + }) } + .sorted { $0.size > $1.size } + } + private var engine: ContainerEngine? /// Superseded-load guard (same pattern as the Kubernetes and Cleanup modes): /// a slow stale snapshot must never overwrite a newer engine's data. @@ -45,7 +65,7 @@ final class ContainerController { state = .loading df = []; images = []; containers = []; volumes = [] machineDisk = nil; lastTrimFreed = nil - expandedImages = []; layerCache = [:] + expandedImages = []; expandedGroups = []; layerCache = [:] actionError = nil runningAction = nil loadID += 1 @@ -88,6 +108,14 @@ final class ContainerController { } } + func toggle(group: CImageGroup) { + if expandedGroups.contains(group.id) { + expandedGroups.remove(group.id) + } else { + expandedGroups.insert(group.id) + } + } + func layers(for image: CImage) -> [CLayer] { layerCache[image.id] ?? [] } private func loadLayersIfNeeded(_ image: CImage) { diff --git a/Sources/SpaceMatters/Views/ContainerResultView.swift b/Sources/SpaceMatters/Views/ContainerResultView.swift index 62138d0..eb1105e 100644 --- a/Sources/SpaceMatters/Views/ContainerResultView.swift +++ b/Sources/SpaceMatters/Views/ContainerResultView.swift @@ -208,26 +208,29 @@ struct ContainerResultView: View { // Regular VStack (not Lazy): container data is small, and LazyVStack // glitches when expanding a row near the bottom. VStack(alignment: .leading, spacing: 0) { - sectionHeader("Images", count: controller.images.count) - let maxImage = controller.images.first?.size ?? 1 - ForEach(controller.images) { image in - ImageRow( - image: image, - fraction: ratio(image.size, maxImage), - isExpanded: controller.expandedImages.contains(image.id), - onToggle: { controller.toggle(image) }, - onRemove: { confirmRemove = image } - ) - if controller.expandedImages.contains(image.id) { - let layers = controller.layers(for: image) - let maxLayer = layers.map(\.size).max() ?? 1 - if layers.isEmpty { - Text(" loading layers…") - .font(.system(size: 11)).foregroundStyle(theme.textSecondary) - .padding(.leading, 40).padding(.vertical, 4) - } - ForEach(layers) { layer in - LayerRow(layer: layer, fraction: ratio(layer.size, maxLayer)) + // The rows below sum to several times the Images card, and a + // reader who adds them up is entitled to know why before they + // conclude the app is wrong: an image's size counts every layer + // it holds, including the ones its neighbours hold too. + sectionHeader("Images", count: controller.images.count, + note: "sizes include shared layers — see the Images card for what pruning frees") + let groups = controller.imageGroups + let maxGroup = groups.first?.size ?? 1 + ForEach(groups) { group in + // A lone image needs no wrapper: it renders exactly as + // before, so grouping only ever adds a level where there is + // something to collapse. + if group.isSingle, let image = group.images.first { + imageRows(for: [image], max: maxGroup) + } else { + ImageGroupRow( + group: group, + fraction: ratio(group.size, maxGroup), + isExpanded: controller.expandedGroups.contains(group.id), + onToggle: { controller.toggle(group: group) }) + if controller.expandedGroups.contains(group.id) { + imageRows(for: group.images, + max: group.images.first?.size ?? 1, indent: 18) } } } @@ -246,10 +249,44 @@ struct ContainerResultView: View { .background(theme.panelBackground) } - private func sectionHeader(_ title: String, count: Int) -> some View { - HStack { + /// The image rows themselves, each still expanding to its layers. Shared by + /// the ungrouped and grouped paths so a nested row behaves identically to a + /// top-level one. + @ViewBuilder + private func imageRows(for images: [CImage], max: Int64, indent: CGFloat = 0) -> some View { + ForEach(images) { image in + ImageRow( + image: image, + fraction: ratio(image.size, max), + indent: indent, + isExpanded: controller.expandedImages.contains(image.id), + onToggle: { controller.toggle(image) }, + onRemove: { confirmRemove = image } + ) + if controller.expandedImages.contains(image.id) { + let layers = controller.layers(for: image) + let maxLayer = layers.map(\.size).max() ?? 1 + if layers.isEmpty { + Text(" loading layers…") + .font(.system(size: 11)).foregroundStyle(theme.textSecondary) + .padding(.leading, 40 + indent).padding(.vertical, 4) + } + ForEach(layers) { layer in + LayerRow(layer: layer, fraction: ratio(layer.size, maxLayer)) + } + } + } + } + + private func sectionHeader(_ title: String, count: Int, note: String? = nil) -> some View { + HStack(spacing: 6) { Text(title).font(.system(size: 11, weight: .semibold)).foregroundStyle(theme.textSecondary) Text("\(count)").font(.system(size: 10).monospacedDigit()).foregroundStyle(theme.textSecondary.opacity(0.7)) + if let note { + Text(note) + .font(.system(size: 9)).foregroundStyle(theme.textSecondary.opacity(0.6)) + .lineLimit(1).truncationMode(.tail) + } Spacer() } .padding(.horizontal, 12).padding(.top, 12).padding(.bottom, 4) @@ -292,9 +329,81 @@ struct ContainerResultView: View { } } +/// One identity — a tag and every image that still answers, or used to answer, +/// to it. The line the user actually wants: which tag is holding the space, and +/// how much of it is old builds. +private struct ImageGroupRow: View { + let group: CImageGroup + let fraction: Double + let isExpanded: Bool + let onToggle: () -> Void + @Environment(\.theme) private var theme + @State private var hovering = false + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "chevron.right") + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + .foregroundStyle(theme.textSecondary).font(.system(size: 9, weight: .bold)) + .frame(width: 12) + + Image(systemName: "square.stack.3d.up.fill") + .foregroundStyle(theme.accent).font(.system(size: 11)).frame(width: 14) + + VStack(alignment: .leading, spacing: 0) { + Text(group.name) + .font(.system(size: 12, weight: .medium)).foregroundStyle(theme.textPrimary) + .lineLimit(1).truncationMode(.middle) + Text(subtitle) + .font(.system(size: 9)).foregroundStyle(theme.textSecondary.opacity(0.8)) + } + + Spacer(minLength: 8) + + if group.unusedCount > 0 { + Text("\(group.unusedCount) unused") + .font(.system(size: 9, weight: .bold)).foregroundStyle(Color(hex: 0xE0915A)) + .padding(.horizontal, 5).padding(.vertical, 1) + .background(Capsule().fill(Color(hex: 0xE0915A).opacity(0.18))) + } + + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(theme.barTrack) + Capsule().fill(theme.color(forHashable: group.name)) + .frame(width: max(0, geo.size.width * fraction)) + } + } + .frame(width: 70, height: 5) + + Text(Format.bytes(group.size)) + .font(.system(size: 11, weight: .medium).monospacedDigit()) + .foregroundStyle(theme.textSecondary) + .frame(width: 66, alignment: .trailing) + } + .padding(.horizontal, 10).padding(.vertical, 5) + .background(hovering ? theme.rowHover : .clear) + .contentShape(Rectangle()) + .onTapGesture(perform: onToggle) + .onHover { hovering = $0 } + } + + /// Says what the group is made of, leading with the part that is reclaimable + /// — superseded builds are the reason these groups are large. + private var subtitle: String { + var parts = ["\(group.images.count) images"] + if group.supersededCount > 0 { parts.append("\(group.supersededCount) superseded") } + // Changes the advice, so it belongs on the collapsed row rather than in + // a tooltip: deleting these one by one frees nothing. + if group.layersMostlyShared { parts.append("layers shared — remove as a set") } + return parts.joined(separator: " · ") + } +} + private struct ImageRow: View { let image: CImage let fraction: Double + var indent: CGFloat = 0 let isExpanded: Bool let onToggle: () -> Void let onRemove: () -> Void @@ -312,8 +421,22 @@ private struct ImageRow: View { .foregroundStyle(image.inUse ? theme.accent : theme.textSecondary).font(.system(size: 11)).frame(width: 14) VStack(alignment: .leading, spacing: 0) { - Text(image.name).font(.system(size: 12)).foregroundStyle(theme.textPrimary).lineLimit(1).truncationMode(.middle) - Text(image.shortID).font(.system(size: 9).monospaced()).foregroundStyle(theme.textSecondary.opacity(0.7)) + HStack(spacing: 5) { + Text(image.name).font(.system(size: 12)).foregroundStyle(theme.textPrimary).lineLimit(1).truncationMode(.middle) + // The name of a superseded image is the tag it *lost*, so + // the badge is not decoration: without it the row claims a + // tag that now points somewhere else. + if let badge = image.origin.badge { + Text(badge) + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(theme.textSecondary) + .padding(.horizontal, 4).padding(.vertical, 1) + .background(Capsule().fill(theme.textSecondary.opacity(0.14))) + } + } + Text(caption) + .font(.system(size: 9).monospaced()).foregroundStyle(theme.textSecondary.opacity(0.7)) + .lineLimit(1) } Spacer(minLength: 8) @@ -332,15 +455,28 @@ private struct ImageRow: View { } .frame(width: 70, height: 5) - Text(Format.bytes(image.size)) - .font(.system(size: 11, weight: .medium).monospacedDigit()).foregroundStyle(theme.textSecondary) - .frame(width: 66, alignment: .trailing) + VStack(alignment: .trailing, spacing: 0) { + Text(Format.bytes(image.size)) + .font(.system(size: 11, weight: .medium).monospacedDigit()).foregroundStyle(theme.textSecondary) + // Almost all of this image's bytes are layers other images also + // hold: deleting it alone frees the small figure, not the large + // one. Shown only when the gap is big enough to mislead. + if image.sharesMostOfItsBytes, let unique = image.uniqueSize { + Text("\(Format.bytes(unique)) alone") + .font(.system(size: 8).monospacedDigit()) + .foregroundStyle(theme.textSecondary.opacity(0.7)) + } + } + .frame(width: 76, alignment: .trailing) } - .padding(.horizontal, 10).padding(.vertical, 4) + .padding(.leading, 10 + indent).padding(.trailing, 10).padding(.vertical, 4) .background(hovering ? theme.rowHover : .clear) .contentShape(Rectangle()) .onTapGesture(perform: onToggle) .onHover { hovering = $0 } + .help(image.origin == .superseded + ? "This image used to carry \(image.name). A newer build or pull took the tag." + : image.name) .contextMenu { Button { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(image.id, forType: .string) } label: { Label("Copy Image ID", systemImage: "doc.on.doc") @@ -348,6 +484,13 @@ private struct ImageRow: View { Button(role: .destructive, action: onRemove) { Label("Remove Image", systemImage: "trash") } } } + + /// The id, plus the age that tells two otherwise identical rebuilds apart — + /// the only thing distinguishing forty images of the same tag. + private var caption: String { + guard let created = image.created else { return image.shortID } + return "\(image.shortID) · \(Format.age(created))" + } } private struct LayerRow: View { diff --git a/Tests/SpaceMattersTests/EngineParsingTests.swift b/Tests/SpaceMattersTests/EngineParsingTests.swift index 302c68b..c0ce982 100644 --- a/Tests/SpaceMattersTests/EngineParsingTests.swift +++ b/Tests/SpaceMattersTests/EngineParsingTests.swift @@ -116,4 +116,166 @@ import Foundation #expect(elapsed < 10, "bounded reader wait must cap the straggler (took \(elapsed)s)") #expect(r.stdoutString.contains("done")) // partial output survives } + + // MARK: Image identity + + /// The ladder that turns a wall of `` into something readable. Each + /// rung is inference presented to the user as an image's name, so each one + /// is pinned against the shape podman and docker actually emit. + @Test func identifyResolvesUntaggedImages() { + // A live tag wins outright. + let (tagged, taggedOrigin) = ContainerQueries.identify( + ["Names": ["ghcr.io/n8n-io/n8n:2.33.4"], "History": ["something:old"]]) + #expect(tagged == "ghcr.io/n8n-io/n8n:2.33.4") + #expect(taggedOrigin == .tagged) + + // Untagged, but History remembers the tag a newer build took. + let (former, formerOrigin) = ContainerQueries.identify( + ["Names": [], "History": ["docker.io/harness/runner-local:latest"]]) + #expect(former == "docker.io/harness/runner-local:latest") + #expect(formerOrigin == .superseded) + + // A buildah scratch name must not be presented as a repository. + let (tmp, tmpOrigin) = ContainerQueries.identify( + ["Names": [], + "History": ["docker.io/library/7c3a32dc6babb20d4dd0388c45182701cbd93830849e957dbb5f8ae109f1ef58-tmp:latest"]]) + #expect(tmp == "build leftover") + #expect(tmpOrigin == .buildIntermediate) + + // Pulled by digest: the repository is still known. + let (digest, digestOrigin) = ContainerQueries.identify( + ["Names": [], "RepoDigests": ["docker.io/library/postgres@sha256:abc"]]) + #expect(digest == "docker.io/library/postgres") + #expect(digestOrigin == .digest) + + // Labels are the last real identity before giving up. + let (labelled, labelledOrigin) = ContainerQueries.identify( + ["Names": [], "Labels": ["org.opencontainers.image.title": "dockerfiles"]]) + #expect(labelled == "dockerfiles") + #expect(labelledOrigin == .labelled) + + // Nothing known stays honest rather than inventing a name. + let (none, noneOrigin) = ContainerQueries.identify(["Names": []]) + #expect(none == "") + #expect(noneOrigin == .anonymous) + + // Pulled by digest and named as such: the hash is not a tag and 71 + // characters of it would crowd out every other column. + let (byDigest, byDigestOrigin) = ContainerQueries.identify( + ["Names": ["docker.io/kindest/node@sha256:7416a61b42b1662ca6ca89f02028ac13"]]) + #expect(byDigest == "docker.io/kindest/node") + #expect(byDigestOrigin == .digest) + + // podman writes "repo:" for an untagged entry — not a real tag. + let (placeholder, placeholderOrigin) = ContainerQueries.identify( + ["Names": ["docker.io/library/x:"], "History": ["docker.io/library/x:1.0"]]) + #expect(placeholder == "docker.io/library/x:1.0") + #expect(placeholderOrigin == .superseded) + } + + @Test func buildIntermediateNeedsAHexStem() { + #expect(ContainerQueries.isBuildIntermediate( + "docker.io/library/7c3a32dc6babb20d4dd0388c45182701cbd93830849e957db-tmp:latest")) + // A real project could genuinely be called this; only the hex stem + // marks a buildah scratch name. + #expect(!ContainerQueries.isBuildIntermediate("docker.io/acme/build-tmp:latest")) + #expect(!ContainerQueries.isBuildIntermediate("docker.io/acme/app:latest")) + } + + /// `podman system df -v` is the only source of per-image unique size and + /// refuses `--format json`, so the table is parsed. CREATED is a human + /// duration of unpredictable width, which is why the parse reads from both + /// ends and never by column offset. + @Test func parsesUniqueSizesFromTheVerboseTable() { + let output = """ + Images space usage: + + REPOSITORY TAG IMAGE ID CREATED SIZE SHARED SIZE UNIQUE SIZE CONTAINERS + docker.io/library/postgres 17-alpine 5db836939fe3 2 months 293.8MB 8.94MB 284.9MB 1 + mcr.microsoft.com/dotnet/aspnet 10.0 1ba87edc1ba3 About a minute 265.8MB 265.8MB 7.367kB 0 + 18a22e769399 3 weeks 265.8MB 265.8MB 6.188kB 0 + + Containers space usage: + + CONTAINER ID IMAGE COMMAND LOCAL VOLUMES SIZE CREATED STATUS NAMES + abc123456789 postgres:17 postgres 0 1.5MB 2 days running db + """ + + let sizes = ContainerQueries.parseUniqueSizes(output) + + #expect(sizes.count == 3) + #expect(sizes["5db836939fe3"] == 284_900_000) + // A three-token CREATED must not shift the size columns. + #expect(sizes["1ba87edc1ba3"] == 7_367) + #expect(sizes["18a22e769399"] == 6_188) + // The containers section has an id-shaped first column too; parsing it + // as an image would attribute a wrong unique size. + #expect(sizes["abc123456789"] == nil) + } + + /// The number a row must not print unqualified: an image listed at 265.8 MB + /// that frees 6 KB when deleted, because every other byte is a layer other + /// images hold too. + @Test func imageKnowsWhenItsSizeOverstatesWhatDeletingFrees() { + let shared = CImage(id: "a", name: "x", size: 265_800_000, inUse: false, + created: nil, uniqueSize: 6_188) + #expect(shared.sharesMostOfItsBytes) + + let standalone = CImage(id: "b", name: "y", size: 293_800_000, inUse: false, + created: nil, uniqueSize: 284_900_000) + #expect(!standalone.sharesMostOfItsBytes) + + // Docker reports no unique size: claim nothing rather than guess. + let unknown = CImage(id: "c", name: "z", size: 100, inUse: false, created: nil) + #expect(!unknown.sharesMostOfItsBytes) + } + + /// A group of rebuilds of one tag is the common case and the trap. Measured + /// on a real machine, 38 rebuilds of `harness/runner-local:latest` summed to + /// 42.6 GiB of image size and **0 B** of per-image unique size: every layer + /// is shared with a sibling. Neither figure is what removing the group + /// frees, so the group must not offer one — it says the layers are shared + /// and points at the engine's own reclaimable instead. + @Test func aGroupOfRebuildsReportsSharedLayersRatherThanAFakeTotal() { + let rebuilds = (0..<38).map { + CImage(id: "img\($0)", name: "harness/runner-local:latest", size: 1_200_000_000, + inUse: false, created: nil, origin: $0 == 0 ? .tagged : .superseded, + uniqueSize: 8_000) + } + let group = CImageGroup(name: "harness/runner-local:latest", images: rebuilds) + + #expect(group.supersededCount == 37) + #expect(group.unusedCount == 38) + #expect(group.layersMostlyShared) + #expect(!group.isSingle) + + // Independent images that each hold their own bytes are not flagged — + // there the advice "remove as a set" would be wrong. + let independent = (0..<3).map { + CImage(id: "sep\($0)", name: "n", size: 1_000_000, inUse: false, + created: nil, uniqueSize: 900_000) + } + #expect(!CImageGroup(name: "n", images: independent).layersMostlyShared) + + // Without measured unique sizes (docker), no claim is made either way. + let unmeasured = (0..<3).map { + CImage(id: "u\($0)", name: "n", size: 1_000_000, inUse: false, created: nil) + } + #expect(!CImageGroup(name: "n", images: unmeasured).layersMostlyShared) + } + + @Test func ageBucketsSeparateRebuildsWithoutPretendingToDateThem() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + func age(_ secondsAgo: Double) -> String { + Format.age(now.addingTimeInterval(-secondsAgo), now: now) + } + #expect(age(600) == "just now") + #expect(age(5 * 3600) == "5h ago") + #expect(age(3 * 86_400) == "3d ago") + #expect(age(21 * 86_400) == "3w ago") + #expect(age(120 * 86_400) == "4mo ago") + #expect(age(800 * 86_400) == "2y ago") + // A clock skew must not produce "-3h ago". + #expect(age(-3600) == "just now") + } } From 70b7dddd8d55084f49350d19c4196a4b69604b04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20DUCOM?= Date: Sun, 16 Aug 2026 22:36:28 +0200 Subject: [PATCH 3/5] fix(containers): one alert modifier, so the reclaim buttons actually open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking "Reclaim" on the Images, Containers or Volumes card did nothing. Not slowly, not with an error — nothing. Verified against the pre-existing build at 8aa8fcd, so this predates the reclaim work rather than arriving with it: those prune buttons have never opened their confirmation, which means the container mode has shipped three destructive actions that were unreachable. SwiftUI presents one alert per view. The view had three `.alert` modifiers stacked on the same VStack — two of the deprecated `item:`-returning-`Alert` form, one of the modern `isPresented:` form — and only one of them survives. Adding the host-disk confirmation made it four and moved which one won, which is how the symptom surfaced. So the fix is not to reorder them. Every dialog this view can raise — three prunes, a single-image removal, the host-disk trim, and the action-failure report — now goes through one `Dialog` enum and one alert modifier, with the controller's error channel merged into the same binding. A failure supersedes a pending confirmation because it can only arrive after an action started, by which point the confirmation that launched it is gone. Each of the four was then opened by hand against the running app to confirm it presents, and cancelled rather than confirmed. --- .../ViewModel/ContainerController.swift | 12 +- .../Views/ContainerResultView.swift | 131 +++++++++++++----- 2 files changed, 106 insertions(+), 37 deletions(-) diff --git a/Sources/SpaceMatters/ViewModel/ContainerController.swift b/Sources/SpaceMatters/ViewModel/ContainerController.swift index 044ab76..650af95 100644 --- a/Sources/SpaceMatters/ViewModel/ContainerController.swift +++ b/Sources/SpaceMatters/ViewModel/ContainerController.swift @@ -139,9 +139,15 @@ final class ContainerController { /// No `-f`: a forced `rmi` also stops and deletes the containers using the /// image. If it's in use the engine refuses and the refusal is shown as-is. func removeImage(_ image: CImage) { run("Remove image", ["rmi", image.id]) } - func pruneImages() { run("Prune images", ["image", "prune", "-a", "-f"]) } - func pruneContainers() { run("Prune containers", ["container", "prune", "-f"]) } - func pruneVolumes() { run("Prune volumes", ["volume", "prune", "-f"]) } + /// All three prunes are scoped to what nothing references — that is the + /// engine's own definition of an unused image, container or volume, and the + /// one the "Reclaim unused" buttons promise. `-a` is what makes the images + /// case match it: without it the engine removes only *dangling* images and + /// leaves every tagged-but-unreferenced one behind, which would clear a + /// fraction of the reclaimable figure shown on the card. + func pruneImages() { run("Reclaim unused images", ["image", "prune", "-a", "-f"]) } + func pruneContainers() { run("Reclaim stopped containers", ["container", "prune", "-f"]) } + func pruneVolumes() { run("Reclaim unused volumes", ["volume", "prune", "-f"]) } func removeContainer(_ container: CContainer) { run("Remove container", ["rm", "-f", container.id]) } /// Return the guest's free blocks to the host, shrinking the machine's diff --git a/Sources/SpaceMatters/Views/ContainerResultView.swift b/Sources/SpaceMatters/Views/ContainerResultView.swift index eb1105e..267bfef 100644 --- a/Sources/SpaceMatters/Views/ContainerResultView.swift +++ b/Sources/SpaceMatters/Views/ContainerResultView.swift @@ -10,9 +10,9 @@ struct ContainerResultView: View { @Environment(\.theme) private var theme private enum PruneKind: Identifiable { case images, containers, volumes; var id: Int { hashValue } } - @State private var confirmPrune: PruneKind? - @State private var confirmRemove: CImage? - @State private var confirmTrim = false + /// The confirmation waiting for an answer, if any. Errors do not live here + /// — they come from the controller and are merged in by `dialog`. + @State private var pending: Dialog? var body: some View { VStack(spacing: 0) { @@ -34,30 +34,69 @@ struct ContainerResultView: View { } } .background(theme.windowBackground) - .alert(item: $confirmPrune) { kind in pruneAlert(kind) } - .alert("Return free space to the Mac?", isPresented: $confirmTrim) { - Button("Reclaim") { controller.trimMachineDisk() } - Button("Cancel", role: .cancel) {} - } message: { - // Non-destructive, and the message says so plainly: `fstrim` only - // tells the host which blocks the guest already considers free. - // Nothing inside the VM is removed — that is what the prune buttons - // are for, and doing them first is what makes this worth running. - Text("Nothing inside the VM is deleted. This tells the Mac which blocks the " - + "machine has already freed, so its disk image can shrink.\n\n" - + "Prune images and volumes first — this only hands back what is already free.") + // Exactly one alert modifier. SwiftUI presents a single alert per view, + // so stacking several silently disables all but one — a button that + // does nothing, with nothing in the log to explain it. Routing every + // dialog through one binding makes that failure impossible rather than + // merely fixed. + .alert(item: dialog) { alert(for: $0) } + } + + /// Everything this view can put in front of the user, confirmations and + /// failures alike, so they share the single alert slot. + private enum Dialog: Identifiable { + case prune(PruneKind) + case remove(CImage) + case trim + case failure(String) + + var id: String { + switch self { + case .prune(let kind): return "prune-\(kind.id)" + case .remove(let image): return "remove-\(image.id)" + case .trim: return "trim" + case .failure(let message): return "failure-\(message)" + } } - .alert(item: $confirmRemove) { image in removeAlert(image) } - .alert("Action failed", isPresented: actionErrorShown) { - Button("OK", role: .cancel) { controller.clearActionError() } - } message: { - Text(controller.actionError ?? "") + } + + /// Merges the pending confirmation with the controller's error channel. A + /// failure wins: it can only arrive after an action started, by which point + /// the confirmation that launched it is gone. + private var dialog: Binding { + Binding( + get: { controller.actionError.map(Dialog.failure) ?? pending }, + set: { newValue in + guard newValue == nil else { return } + pending = nil + controller.clearActionError() + }) + } + + private func alert(for dialog: Dialog) -> Alert { + switch dialog { + case .prune(let kind): return pruneAlert(kind) + case .remove(let image): return removeAlert(image) + case .trim: return trimAlert + case .failure(let message): + return Alert(title: Text("Action failed"), message: Text(message), + dismissButton: .cancel(Text("OK"))) } } - private var actionErrorShown: Binding { - Binding(get: { controller.actionError != nil }, - set: { if !$0 { controller.clearActionError() } }) + /// Non-destructive, and the message says so plainly: `fstrim` only tells the + /// host which blocks the guest already considers free. Nothing inside the VM + /// is removed — that is what the reclaim buttons are for, and doing them + /// first is what makes this worth running. + private var trimAlert: Alert { + Alert( + title: Text("Return free space to the Mac?"), + message: Text("Nothing inside the VM is deleted. This tells the Mac which blocks the " + + "machine has already freed, so its disk image can shrink.\n\n" + + "Reclaim unused images and volumes first — this only hands back what " + + "is already free."), + primaryButton: .default(Text("Reclaim")) { controller.trimMachineDisk() }, + secondaryButton: .cancel()) } // MARK: Toolbar @@ -129,7 +168,7 @@ struct ContainerResultView: View { Text(Format.bytes(disk.onDisk)) .font(.system(size: 17, weight: .semibold).monospacedDigit()) .foregroundStyle(theme.textPrimary) - Button { confirmTrim = true } label: { + Button { pending = .trim } label: { Text(controller.runningAction == "Reclaim host disk" ? "Reclaiming…" : "Reclaim host disk") .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.white) @@ -179,9 +218,14 @@ struct ContainerResultView: View { Text(Format.bytes(size)) .font(.system(size: 17, weight: .semibold).monospacedDigit()) .foregroundStyle(theme.textPrimary) + // The button names its *scope*, not a number. Every prune here + // touches only what nothing references — putting a size on the + // button made it read as a promise of bytes, when the promise that + // matters is which items are safe from it. The figure stays + // underneath, where it is a measurement rather than a label. if reclaimable > 0 { - Button { confirmPrune = prune } label: { - Text("Reclaim \(Format.bytes(reclaimable))") + Button { pending = .prune(prune) } label: { + Text("Reclaim unused") .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.white) .padding(.horizontal, 7).padding(.vertical, 3) @@ -190,8 +234,10 @@ struct ContainerResultView: View { .buttonStyle(.plain) .disabled(controller.runningAction != nil) .opacity(controller.runningAction != nil ? 0.5 : 1) + Text("\(Format.bytes(reclaimable)) reclaimable") + .font(.system(size: 9)).foregroundStyle(theme.textSecondary.opacity(0.8)) } else { - Text("nothing to reclaim") + Text("nothing unused to reclaim") .font(.system(size: 10)).foregroundStyle(theme.textSecondary.opacity(0.7)) } } @@ -261,7 +307,7 @@ struct ContainerResultView: View { indent: indent, isExpanded: controller.expandedImages.contains(image.id), onToggle: { controller.toggle(image) }, - onRemove: { confirmRemove = image } + onRemove: { pending = .remove(image) } ) if controller.expandedImages.contains(image.id) { let layers = controller.layers(for: image) @@ -298,18 +344,35 @@ struct ContainerResultView: View { // MARK: Alerts + /// "Unused" is the promise the button makes, so each dialog says what the + /// word means for that kind before anything is deleted. The images case + /// carries the one genuine surprise: nothing-references-it includes tagged + /// images the user pulled on purpose, which the list shows as `unused` too. private func pruneAlert(_ kind: PruneKind) -> Alert { - let (title, action): (String, () -> Void) = { + let (title, detail, action): (String, String, () -> Void) = { switch kind { - case .images: return ("Remove all unused images?", controller.pruneImages) - case .containers: return ("Remove all stopped containers?", controller.pruneContainers) - case .volumes: return ("Remove all unused volumes?", controller.pruneVolumes) + case .images: + return ("Remove every unused image?", + "Unused means no container references it — including tagged images you " + + "pulled deliberately. Anything a container uses, running or stopped, is " + + "left alone. Re-pulling needs network access.", + controller.pruneImages) + case .containers: + return ("Remove every stopped container?", + "Running containers are left alone. A stopped container's writable layer " + + "goes with it — named volumes do not.", + controller.pruneContainers) + case .volumes: + return ("Remove every unused volume?", + "Unused means no container references it. Volume contents are data, not a " + + "cache: nothing regenerates them.", + controller.pruneVolumes) } }() return Alert( title: Text(title), - message: Text("This frees the reclaimable space and can't be undone."), - primaryButton: .destructive(Text("Prune"), action: action), + message: Text(detail + "\n\nThis can't be undone."), + primaryButton: .destructive(Text("Remove unused"), action: action), secondaryButton: .cancel() ) } From 92c2484b8098d813c8c5ae9b5fc4e01e78196d65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20DUCOM?= Date: Sun, 16 Aug 2026 23:03:25 +0200 Subject: [PATCH 4/5] fix(cleanup): the caches that ignore ~/Library, cold node_modules, and a skill frozen at install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three misses, all found by pointing an assistant at the same disk again. **uv keeps its cache under XDG on macOS too.** The catalog only listed `~/Library/Caches/uv`, which does not exist here, so the largest single reclaimable location on the machine — 4.19 GiB in `~/.cache/uv` — was invisible. The Apple-shaped path stays for older uv builds; `detect` drops whichever is absent. `UV_CACHE_DIR` is deliberately not read: a GUI app inherits no shell environment, so it would be empty far more often than right, the same reasoning already written down for the Go module cache. Two more tools hiding in the same place join it: pre-commit/prek (477 MiB) and the GitHub CLI (82 MiB). **Cold node_modules.** The marker rule generalises as predicted, but not for free: build output is recreated in seconds from the source beside it, while a dependency tree only comes back over the network, and only while the registry still serves the versions the lockfile pins. So the rule gains an age gate — six months of no install, measured on the tree's own mtime, which package managers rewrite and nothing else does. That yields the same five projects and the same ~2 GiB a human reviewing this disk picked out. It is a separate row from the build-output ones, because it is a materially different bargain. **The skill was frozen at first install.** `plan()` treated "the directory exists" as "the skill is current", so a correction shipped in the app never reached the assistant reading it. That is not hypothetical: this machine's installed copy was 86 lines against the bundle's 157, and it is why a fresh analysis still reported that a podman disk image "never shrinks on its own" hours after that exact sentence was corrected — and why it recommended `podman machine reset` for a file that had already given back 17.7 GiB. The plan now compares the two trees by content and the panel says so, with an Update button, rather than reporting a green check over stale guidance. A content comparison also catches a hand-edited copy, which is a reason to ask before overwriting rather than a reason to overwrite. --- .../SpaceMatters/App/ClaudeIntegration.swift | 48 +++++++++++- .../Scanner/CleanupDiscovery.swift | 76 +++++++++++++++++-- .../SpaceMatters/Scanner/CleanupEngine.swift | 30 +++++++- Sources/SpaceMatters/Util/ToolActivity.swift | 5 ++ .../SpaceMatters/Views/AssistantPanel.swift | 24 +++++- .../ClaudeIntegrationTests.swift | 41 ++++++++++ .../CleanupDiscoveryTests.swift | 57 ++++++++++++++ Tests/SpaceMattersTests/CleanupTests.swift | 34 +++++++++ .../SpaceMattersTests/ToolActivityTests.swift | 5 +- 9 files changed, 306 insertions(+), 14 deletions(-) diff --git a/Sources/SpaceMatters/App/ClaudeIntegration.swift b/Sources/SpaceMatters/App/ClaudeIntegration.swift index d3065f0..8b15afa 100644 --- a/Sources/SpaceMatters/App/ClaudeIntegration.swift +++ b/Sources/SpaceMatters/App/ClaudeIntegration.swift @@ -23,6 +23,15 @@ enum ClaudeIntegration { var skillSource: URL? var skillDestination: URL var skillAlreadyInstalled: Bool + /// The installed skill differs from the one in this build. + /// + /// Treating "the directory exists" as "the skill is current" is how a + /// correction shipped in the app never reaches the assistant reading + /// it: the install runs once, and every later version of the guidance + /// stays in the bundle. The failure is silent and it is the worst kind + /// — the model keeps answering confidently from advice we already knew + /// was wrong. + var skillOutdated: Bool var executablePath: String var registerCommand: String { @@ -41,15 +50,48 @@ enum ClaudeIntegration { static func plan() -> Plan { let destination = URL(fileURLWithPath: NSHomeDirectory() + "/.claude/skills/space") + let source = Bundle.main.url(forResource: "space", withExtension: nil, + subdirectory: "skills") + let installed = FileManager.default.fileExists(atPath: destination.path) return Plan( cliPath: cliCandidates.first { FileManager.default.isExecutableFile(atPath: $0) }, - skillSource: Bundle.main.url(forResource: "space", withExtension: nil, - subdirectory: "skills"), + skillSource: source, skillDestination: destination, - skillAlreadyInstalled: FileManager.default.fileExists(atPath: destination.path), + skillAlreadyInstalled: installed, + skillOutdated: installed && source.map { + !treesMatch($0, destination) + } ?? false, executablePath: Bundle.main.executablePath ?? CommandLine.arguments[0]) } + /// Whether two skill directories hold the same files with the same bytes. + /// + /// A content comparison rather than a version number, because the thing that + /// matters is whether the assistant is reading what this build says — and + /// because it also catches a hand-edited copy, which is a reason to ask + /// before overwriting rather than a reason to overwrite. The tree is a + /// handful of small Markdown files, so reading it whole costs nothing. + /// Anything unreadable counts as a mismatch: fail towards offering the + /// update, never towards silently keeping stale guidance. + static func treesMatch(_ lhs: URL, _ rhs: URL) -> Bool { + func files(_ root: URL) -> [String: Data]? { + guard let walker = FileManager.default.enumerator( + at: root, includingPropertiesForKeys: [.isRegularFileKey]) else { return nil } + var out: [String: Data] = [:] + for case let url as URL in walker { + guard (try? url.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true + else { continue } + // Finder metadata is not content and must not force an update. + if url.lastPathComponent == ".DS_Store" { continue } + guard let data = try? Data(contentsOf: url) else { return nil } + out[url.path.replacingOccurrences(of: root.path, with: "")] = data + } + return out + } + guard let left = files(lhs), let right = files(rhs) else { return false } + return left == right + } + /// Is the server registered at user scope? /// /// Read from the config rather than asked of `claude mcp list`: that command diff --git a/Sources/SpaceMatters/Scanner/CleanupDiscovery.swift b/Sources/SpaceMatters/Scanner/CleanupDiscovery.swift index cef8e3c..9230009 100644 --- a/Sources/SpaceMatters/Scanner/CleanupDiscovery.swift +++ b/Sources/SpaceMatters/Scanner/CleanupDiscovery.swift @@ -45,6 +45,15 @@ enum CleanupDiscovery { struct ArtifactRule { let directory: String let markers: [String] + /// When set, the directory is only offered once it has gone this long + /// untouched. + /// + /// Build output needs no such gate — the next build recreates it in + /// seconds, from the source right beside it. A dependency tree does + /// not: restoring it needs the network, the registry to still serve + /// those versions, and minutes of waiting. That is regenerable enough + /// to offer, but only for a project nobody has touched in months. + var minimumAgeDays: Int? } /// `bin` and `obj` are the dangerous pair and the valuable one: together @@ -57,6 +66,10 @@ enum CleanupDiscovery { ArtifactRule(directory: "bin", markers: [".csproj", ".fsproj", ".vbproj", ".sln", ".slnx"]), ArtifactRule(directory: "obj", markers: [".csproj", ".fsproj", ".vbproj", ".sln", ".slnx"]), ArtifactRule(directory: "target", markers: ["Cargo.toml"]), + // Six months. Long enough that the project is genuinely dormant rather + // than merely between installs, and it is the threshold that matches + // what a human reviewing the same disk called cold. + ArtifactRule(directory: "node_modules", markers: ["package.json"], minimumAgeDays: 180), ] /// Directory names never descended into. `node_modules` and `.git` are the @@ -93,10 +106,26 @@ enum CleanupDiscovery { let found = walkForArtifacts(home: home) guard !found.isEmpty else { return [] } - let dotnet = found.filter { $0.rule.directory != "target" }.map(\.path) + let dotnet = found.filter { ["bin", "obj"].contains($0.rule.directory) }.map(\.path) let rust = found.filter { $0.rule.directory == "target" }.map(\.path) + let node = found.filter { $0.rule.directory == "node_modules" }.map(\.path) var out: [Cleanable] = [] + if !node.isEmpty { + // Kept apart from the build-output rows on purpose. Those come back + // from the source next to them; this one comes back from a registry + // over the network, and only if it still serves the versions the + // lockfile pins. Same verdict, materially different cost — so it + // says "cold", says how many, and does not hide inside a row about + // build artifacts. + out.append(Cleanable( + id: "cold-node-modules", name: "Cold node_modules", category: "JavaScript", + icon: "shippingbox.circle", + note: "\(node.count) project\(node.count == 1 ? "" : "s") with no install for " + + "6 months. `npm install` restores each — needs network.", + paths: node.sorted(), removal: .directory, + locationLabel: "\(node.count) projects")) + } if !dotnet.isEmpty { out.append(Cleanable( id: "dotnet-artifacts", name: ".NET build output", category: ".NET", @@ -139,7 +168,6 @@ enum CleanupDiscovery { // child costs no extra directory read. let names = Set(entries) for entry in entries { - guard !pruned.contains(entry) else { continue } // Hidden directories hold configuration and tool state, never // the build output we are after; skipping them also keeps the // walk out of caches that have their own catalog entry. @@ -148,10 +176,18 @@ enum CleanupDiscovery { var st = stat() guard lstat(child, &st) == 0, (st.st_mode & S_IFMT) == S_IFDIR else { continue } - if let rule = rulesByName[entry], names.contains(where: { matches($0, rule) }) { - hits.append(ArtifactHit(path: child, rule: rule)) - continue // its contents are the artifact; nothing to find inside + // Rules are tested before the prune list, because a name can be + // in both: `node_modules` is never *descended* into, but it is + // exactly what one rule is looking for. Either way the walk + // stops here — whether the directory is a hit, too warm to + // offer, or simply pruned, nothing inside it is a candidate. + if let rule = rulesByName[entry] { + if names.contains(where: { matches($0, rule) }), isOldEnough(st, for: rule) { + hits.append(ArtifactHit(path: child, rule: rule)) + } + continue } + if pruned.contains(entry) { continue } queue.append((child, depth + 1)) } } @@ -164,6 +200,19 @@ enum CleanupDiscovery { rule.markers.contains { $0.hasPrefix(".") ? name.hasSuffix($0) : name == $0 } } + /// Whether a candidate has been dormant long enough for its rule. + /// + /// A dependency tree's own mtime is the right clock here: package managers + /// rewrite it on every install, and nothing else does. Editing the source + /// beside it leaves it untouched, which is the intent — what matters is + /// when the tree was last *populated*, not when the project was last read. + /// Rules without a gate always pass. + static func isOldEnough(_ st: stat, for rule: ArtifactRule, now: Date = Date()) -> Bool { + guard let days = rule.minimumAgeDays else { return true } + let modified = Date(timeIntervalSince1970: TimeInterval(st.st_mtimespec.tv_sec)) + return now.timeIntervalSince(modified) >= TimeInterval(days) * 86_400 + } + // MARK: VS Code workspace storage /// Editors that use the VS Code workspace-storage layout, and the bundle id @@ -268,6 +317,23 @@ enum CleanupDiscovery { if let rule = artifactRules.first(where: { $0.directory == name }) { let siblings = (try? FileManager.default.contentsOfDirectory(atPath: parent)) ?? [] if siblings.contains(where: { matches($0, rule) }) { + var st = stat() + let warm = lstat(path, &st) == 0 && !isOldEnough(st, for: rule) + if warm { + // The refusal that keeps an assistant from proposing + // `rm -rf node_modules` across a working tree. + return .protected( + reason: "A dependency tree for a project installed within the last 6 " + + "months. It restores only from the network, so it is offered only " + + "once the project has gone dormant.") + } + if rule.directory == "node_modules" { + return .cleanable( + name: "Cold node_modules", + note: "No install here for 6 months. `npm install` restores it, but that " + + "needs network and the registry still serving the pinned versions. " + + "SpaceMatters removes these itself.") + } return .cleanable( name: rule.directory == "target" ? "Cargo build output" : ".NET build output", note: "Build output, recreated by the next build. SpaceMatters removes these " diff --git a/Sources/SpaceMatters/Scanner/CleanupEngine.swift b/Sources/SpaceMatters/Scanner/CleanupEngine.swift index aca7cda..002632f 100644 --- a/Sources/SpaceMatters/Scanner/CleanupEngine.swift +++ b/Sources/SpaceMatters/Scanner/CleanupEngine.swift @@ -201,10 +201,19 @@ enum CleanupEngine { id: "pip", name: "pip cache", category: "Python", icon: "archivebox", note: "Wheel downloads, re-fetched on next install.", paths: [home + "/Library/Caches/pip"]), + // uv puts its cache under XDG on *every* platform, so on a Mac the + // real location is `~/.cache/uv` — not the `~/Library/Caches` one + // the rest of this table uses. Listing only the Apple-shaped path + // missed 4 GiB on the machine this was written for. Both are kept: + // older uv builds did use Library/Caches, `detect` drops whichever + // is absent, and a relocated cache (`UV_CACHE_DIR`, `cache-dir` in + // uv.toml) is simply not found — a GUI app inherits no shell + // environment, so reading the variable would be false more often + // than true. Cleanable( id: "uv", name: "uv cache", category: "Python", icon: "archivebox", note: "Package cache, re-fetched on next sync.", - paths: [home + "/Library/Caches/uv"]), + paths: [xdgCache(home) + "/uv", home + "/Library/Caches/uv"]), Cleanable( id: "gradle", name: "Gradle caches", category: "JVM", icon: "gearshape.2.fill", note: "Dependency and build caches, re-downloaded on next build.", @@ -231,6 +240,17 @@ enum CleanupEngine { id: "go-mod", name: "Go module cache", category: "Rust & Go", icon: "shippingbox.circle.fill", note: "Module sources and zips, re-downloaded on next build (needs network).", paths: [home + "/go/pkg/mod"]), + // Cross-platform tools ignore `~/Library/Caches` and follow XDG, so + // they hide from anyone looking only in the Apple location. + Cleanable( + id: "pre-commit", name: "pre-commit hook environments", category: "Developer tools", + icon: "checkmark.seal.fill", + note: "Per-hook virtualenvs and clones, rebuilt on next run (needs network).", + paths: [xdgCache(home) + "/pre-commit", xdgCache(home) + "/prek"]), + Cleanable( + id: "gh", name: "GitHub CLI cache", category: "Developer tools", icon: "terminal.fill", + note: "Cached API responses, re-fetched on demand.", + paths: [xdgCache(home) + "/gh"]), Cleanable( id: "homebrew", name: "Homebrew downloads", category: "Homebrew", icon: "mug.fill", note: "Bottle, cask and API downloads, re-fetched on demand.", @@ -238,6 +258,14 @@ enum CleanupEngine { ] } + /// The XDG cache root. macOS has no XDG convention of its own, but the + /// tools that ignore `~/Library/Caches` all agree on this fallback, so it is + /// where several gigabytes hide from anyone who only looks the Apple way. + /// `XDG_CACHE_HOME` is deliberately not read: a GUI app inherits no shell + /// environment, so it would be empty here far more often than it would be + /// right (same reasoning as the Go module cache above). + static func xdgCache(_ home: String) -> String { home + "/.cache" } + /// The catalog restricted to entries with at least one path that passes the /// same fence as `clean` (existing real directory, no symlinked root, still /// inside `allowedRoot` once resolved). Deciding at detection time keeps the diff --git a/Sources/SpaceMatters/Util/ToolActivity.swift b/Sources/SpaceMatters/Util/ToolActivity.swift index fec752f..05ad3a4 100644 --- a/Sources/SpaceMatters/Util/ToolActivity.swift +++ b/Sources/SpaceMatters/Util/ToolActivity.swift @@ -67,6 +67,11 @@ enum ToolActivity { "pip": ["pip"], "pip3": ["pip"], "uv": ["uv"], + // A run builds its hook environments straight into that cache, so + // emptying it underneath one fails the hooks mid-commit. + "pre-commit": ["pre-commit"], + "prek": ["pre-commit"], + "gh": ["gh"], "gradle": ["gradle"], "mvn": ["maven"], "mvnd": ["maven"], diff --git a/Sources/SpaceMatters/Views/AssistantPanel.swift b/Sources/SpaceMatters/Views/AssistantPanel.swift index 7c5caf3..b6eb9f8 100644 --- a/Sources/SpaceMatters/Views/AssistantPanel.swift +++ b/Sources/SpaceMatters/Views/AssistantPanel.swift @@ -54,8 +54,9 @@ private struct AssistantPanel: View { private enum Status { case checking - /// Skill installed and the server registered — nothing left to do. - case ready + /// Skill installed and the server registered. `skillOutdated` still + /// matters here: the wiring is right, the guidance behind it is not. + case ready(skillOutdated: Bool) case notSetUp(skillInstalled: Bool, cliFound: Bool) } @@ -118,12 +119,27 @@ private struct AssistantPanel: View { .foregroundStyle(theme.textSecondary) } - case .ready: + case .ready(let skillOutdated): VStack(alignment: .leading, spacing: 6) { Label("Claude Code is connected", systemImage: "checkmark.circle.fill") .font(.system(size: 12, weight: .medium)) .foregroundStyle(theme.textPrimary) + // Connected but reading an older copy of the guidance. Worth + // interrupting for: the skill is where the app's judgement about + // what is safe to delete lives, so a stale one does not degrade + // the answers, it makes them confidently wrong about the exact + // things a newer version was written to correct. + if skillOutdated { + Text("The installed skill is older than this version of SpaceMatters — a " + + "session will be reading guidance this build has already corrected.") + .font(.system(size: 11)) + .foregroundStyle(Color(hex: 0xE0915A)) + .fixedSize(horizontal: false, vertical: true) + Button("Update skill") { Task { await install() } } + .disabled(busy || plan == nil) + } + // "It's connected" is a status, not an action. The three steps // and a ready-made prompt are what actually gets someone from // here to an answer. @@ -213,7 +229,7 @@ private struct AssistantPanel: View { ClaudeIntegration.isRegistered() }.value status = (registered && plan.skillAlreadyInstalled) - ? .ready + ? .ready(skillOutdated: plan.skillOutdated) : .notSetUp(skillInstalled: plan.skillAlreadyInstalled, cliFound: plan.cliPath != nil) } diff --git a/Tests/SpaceMattersTests/ClaudeIntegrationTests.swift b/Tests/SpaceMattersTests/ClaudeIntegrationTests.swift index 278532e..d963676 100644 --- a/Tests/SpaceMattersTests/ClaudeIntegrationTests.swift +++ b/Tests/SpaceMattersTests/ClaudeIntegrationTests.swift @@ -14,9 +14,50 @@ import Foundation skillSource: nil, skillDestination: URL(fileURLWithPath: "/tmp/skills/space"), skillAlreadyInstalled: false, + skillOutdated: false, executablePath: executable) } + /// A skill installed once and never refreshed is how a correction shipped + /// in the app fails to reach the assistant reading it — the failure this + /// comparison exists to catch, and one that already happened. + @Test func aDivergedSkillTreeIsDetected() throws { + let fm = FileManager.default + let base = fm.temporaryDirectory.appendingPathComponent("skill-\(UUID().uuidString)") + let shipped = base.appendingPathComponent("bundle/space") + let installed = base.appendingPathComponent("home/space") + for dir in [shipped, installed] { + try fm.createDirectory(at: dir.appendingPathComponent("references"), + withIntermediateDirectories: true) + try "procedure".write(to: dir.appendingPathComponent("SKILL.md"), + atomically: true, encoding: .utf8) + try "podman never shrinks".write( + to: dir.appendingPathComponent("references/directories.md"), + atomically: true, encoding: .utf8) + } + defer { try? fm.removeItem(at: base) } + + #expect(ClaudeIntegration.treesMatch(shipped, installed)) + + // The app ships a correction; the installed copy still says the old + // thing. That is exactly the drift that must be visible. + try "podman trims on a weekly timer".write( + to: shipped.appendingPathComponent("references/directories.md"), + atomically: true, encoding: .utf8) + #expect(!ClaudeIntegration.treesMatch(shipped, installed)) + + // A file present on one side only counts as drift too. + try "podman never shrinks".write( + to: installed.appendingPathComponent("references/directories.md"), + atomically: true, encoding: .utf8) + try "extra".write(to: shipped.appendingPathComponent("references/new.md"), + atomically: true, encoding: .utf8) + #expect(!ClaudeIntegration.treesMatch(shipped, installed)) + + // A missing tree is drift, not a match — fail towards offering the update. + #expect(!ClaudeIntegration.treesMatch(shipped, base.appendingPathComponent("absent"))) + } + @Test func theCommandRegistersAtUserScope() { // The CLI defaults to `local`, which is per-directory. A disk analyser // has to answer from wherever the session happens to start. diff --git a/Tests/SpaceMattersTests/CleanupDiscoveryTests.swift b/Tests/SpaceMattersTests/CleanupDiscoveryTests.swift index 67abd9e..d5acdf0 100644 --- a/Tests/SpaceMattersTests/CleanupDiscoveryTests.swift +++ b/Tests/SpaceMattersTests/CleanupDiscoveryTests.swift @@ -254,6 +254,63 @@ import Testing #expect(firefox.paths.allSatisfy { $0.contains("/Library/Caches/Firefox/Profiles/") }) } + // MARK: Cold dependency trees + + /// `node_modules` is regenerable but only from the network, so unlike build + /// output it is offered on age rather than on the marker alone. A tree + /// installed recently belongs to a project someone is working on. + @Test func nodeModulesIsOfferedOnlyOnceItHasGoneCold() throws { + let root = try Self.fixture() + defer { try? FileManager.default.removeItem(at: root) } + try Self.makeFile(root, "Warm/package.json") + try Self.makeFile(root, "Warm/node_modules/pkg/index.js") + try Self.makeFile(root, "Cold/package.json") + try Self.makeFile(root, "Cold/node_modules/pkg/index.js") + // Backdate the cold tree past the six-month gate. + let cold = root.appendingPathComponent("Cold/node_modules") + try FileManager.default.setAttributes( + [.modificationDate: Date().addingTimeInterval(-200 * 86_400)], ofItemAtPath: cold.path) + + let hits = CleanupDiscovery.walkForArtifacts(home: root.path).map(\.path) + #expect(hits == [cold.path]) + + let targets = CleanupDiscovery.projectArtifacts(home: root.path) + let node = try #require(targets.first { $0.id == "cold-node-modules" }) + #expect(node.removal == .directory) + #expect(node.regenerable) // it does come back, just slowly and online + #expect(node.note.contains("npm install")) + } + + /// A rule with no age gate is unaffected by mtime — build output is offered + /// the moment it exists. + @Test func buildOutputHasNoAgeGate() throws { + var fresh = stat() + fresh.st_mtimespec.tv_sec = Int(Date().timeIntervalSince1970) + let build = CleanupDiscovery.ArtifactRule(directory: "bin", markers: [".csproj"]) + let aged = CleanupDiscovery.ArtifactRule(directory: "node_modules", + markers: ["package.json"], minimumAgeDays: 180) + #expect(CleanupDiscovery.isOldEnough(fresh, for: build)) + #expect(!CleanupDiscovery.isOldEnough(fresh, for: aged)) + + var old = stat() + old.st_mtimespec.tv_sec = Int(Date().addingTimeInterval(-200 * 86_400).timeIntervalSince1970) + #expect(CleanupDiscovery.isOldEnough(old, for: aged)) + } + + /// The walk must not descend into a matched `node_modules` even when it is + /// too warm to offer — walking one is the single most expensive thing this + /// code could do, and there is nothing inside it to find. + @Test func warmNodeModulesIsSkippedNotEntered() throws { + let root = try Self.fixture() + defer { try? FileManager.default.removeItem(at: root) } + try Self.makeFile(root, "App/package.json") + // A nested project inside node_modules would be a hit if we descended. + try Self.makeFile(root, "App/node_modules/dep/dep.csproj") + _ = try Self.makeDir(root, "App/node_modules/dep/bin") + + #expect(CleanupDiscovery.walkForArtifacts(home: root.path).isEmpty) + } + // MARK: Single-path classification /// `explain` must reach the same verdict as the walk, without walking. The diff --git a/Tests/SpaceMattersTests/CleanupTests.swift b/Tests/SpaceMattersTests/CleanupTests.swift index 590495c..a72c4a7 100644 --- a/Tests/SpaceMattersTests/CleanupTests.swift +++ b/Tests/SpaceMattersTests/CleanupTests.swift @@ -774,3 +774,37 @@ private actor CallCounter { private final class JournalBox { var entries: [CleanupJournal.Entry] = [] } + +/// Catalog paths that are easy to get wrong because the tool ignores Apple's +/// convention. Each of these was a real miss on a real disk. +@Suite struct CatalogLocationTests { + + /// uv keeps its cache under XDG on macOS too, so the Apple-shaped path alone + /// found nothing while 4 GiB sat in `~/.cache/uv`. + @Test func toolsThatFollowXDGAreLookedUpThere() { + let catalog = CleanupEngine.catalog(home: "/home/x") + func paths(_ id: String) -> [String] { + catalog.first { $0.id == id }?.paths ?? [] + } + #expect(paths("uv").contains("/home/x/.cache/uv")) + // The legacy location stays: older uv builds used it, and `detect` + // drops whichever is absent. + #expect(paths("uv").contains("/home/x/Library/Caches/uv")) + #expect(paths("gh") == ["/home/x/.cache/gh"]) + #expect(paths("pre-commit").contains("/home/x/.cache/pre-commit")) + } + + /// npx installs a throwaway tree per invocation and never evicts one, so it + /// is routinely larger than the cache beside it. + @Test func npmCoversBothOfItsCaches() { + let npm = CleanupEngine.catalog(home: "/home/x").first { $0.id == "npm" } + #expect(npm?.paths == ["/home/x/.npm/_cacache", "/home/x/.npm/_npx"]) + } + + /// Every catalog id is unique, or two rows collide in the controller's + /// keyed lookups and one silently shadows the other. + @Test func catalogIdsAreUnique() { + let ids = CleanupEngine.catalog(home: "/home/x").map(\.id) + #expect(Set(ids).count == ids.count) + } +} diff --git a/Tests/SpaceMattersTests/ToolActivityTests.swift b/Tests/SpaceMattersTests/ToolActivityTests.swift index aacb281..4593622 100644 --- a/Tests/SpaceMattersTests/ToolActivityTests.swift +++ b/Tests/SpaceMattersTests/ToolActivityTests.swift @@ -61,9 +61,12 @@ struct ToolActivityTests { "node-gyp", "typescript", "bun", "workspace-storage-code", "workspace-storage-code---insiders", "workspace-storage-cursor", "workspace-storage-vscodium", + // npm/npx already map to the npm target; a cold tree by definition + // has no install running against it. + "cold-node-modules", ] let offered = Set(CleanupEngine.catalog().map(\.id)) - .union(["dotnet-artifacts", "cargo-artifacts"]) + .union(["dotnet-artifacts", "cargo-artifacts", "cold-node-modules"]) let uncovered = offered.subtracting(ToolActivity.coveredTargets).subtracting(exempt) #expect(uncovered.isEmpty, "no active-tool warning for: \(uncovered.sorted())") } From 7e2af2023a9a187e0f5eabe17f426ad424c8f867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20DUCOM?= Date: Sun, 16 Aug 2026 23:08:18 +0200 Subject: [PATCH 5/5] feat(cleanup): the opengrep engine and opencode's downloads, but not its sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were seen earlier and left out for want of knowing what was inside them. Opened, and they are different shapes: `~/.cache/opengrep` is one extracted tree per engine version ever run — a bundled Python runtime and the analyser, no rules and no results — so old versions are pure waste and the current one is re-downloaded on the next scan. `~/.cache/opencode` needed the check more than it looked. It holds downloaded helper binaries (a C# language server, ripgrep) and the model list, all re-fetched. But the tool keeps a second directory, `~/.local/share/opencode`, whose name is nearly the same and whose contents are the opposite: `auth.json` and `opencode.db` — the login token and every conversation. Only the cache half is in the catalog, and a test asserts no entry anywhere reaches the other one, because the failure mode is a plausible generalisation from a matching name rather than a typo. That split is worth more than the 368 MiB the two rows add, so it also goes in the skill's reference: for XDG-shaped tools `.cache` is disposable while `.local/share` and `.config` are not, and a verdict must never carry from one to the other on the strength of the name matching. Both tools run out of the very tree being emptied, so both join the active-tool warnings — which is why the coverage test accepted them without an exemption. --- .../skills/space/references/directories.md | 9 +++++++++ .../SpaceMatters/Scanner/CleanupEngine.swift | 20 +++++++++++++++++++ Sources/SpaceMatters/Util/ToolActivity.swift | 5 +++++ Tests/SpaceMattersTests/CleanupTests.swift | 13 ++++++++++++ 4 files changed, 47 insertions(+) diff --git a/Packaging/skills/space/references/directories.md b/Packaging/skills/space/references/directories.md index 3332403..58ad0a1 100644 --- a/Packaging/skills/space/references/directories.md +++ b/Packaging/skills/space/references/directories.md @@ -98,6 +98,15 @@ installed. Removing the directory is both more complete and faster. Deleting these loses data the app cannot get back. +- **A tool's `~/.cache/` and its `~/.local/share/` are opposites**, + and the matching names invite exactly the wrong conclusion. opencode is the + worked example: `~/.cache/opencode` is downloaded helper binaries and a model + list (disposable), while `~/.local/share/opencode` holds `auth.json` and + `opencode.db` — the login token and every conversation. Never generalise a + verdict from one to the other because the tool's name matches; check which of + the two you are looking at. The same split applies to most XDG-shaped tools: + `.cache` is disposable, `.local/share` and `.config` are not. + - `Application Support/*/IndexedDB`, `Local Storage`, `Session Storage`, `Databases` — app state, drafts, unsynced edits. - `Application Support/*/File System`, `blob_storage` — offline attachments. diff --git a/Sources/SpaceMatters/Scanner/CleanupEngine.swift b/Sources/SpaceMatters/Scanner/CleanupEngine.swift index 002632f..1f1ba59 100644 --- a/Sources/SpaceMatters/Scanner/CleanupEngine.swift +++ b/Sources/SpaceMatters/Scanner/CleanupEngine.swift @@ -251,6 +251,26 @@ enum CleanupEngine { id: "gh", name: "GitHub CLI cache", category: "Developer tools", icon: "terminal.fill", note: "Cached API responses, re-fetched on demand.", paths: [xdgCache(home) + "/gh"]), + // One extracted tree per engine version ever run — a bundled Python + // runtime and the analyser, not rules or results. Old versions are + // pure waste; the current one is re-downloaded on the next scan. + Cleanable( + id: "opengrep", name: "Opengrep engine", category: "Developer tools", + icon: "magnifyingglass", + note: "Extracted engine, one tree per version — re-downloaded on next scan.", + paths: [xdgCache(home) + "/opengrep", xdgCache(home) + "/semgrep"]), + // Narrow on purpose. `~/.cache/opencode` holds downloaded helper + // binaries (a language server, ripgrep) and the model list. Its + // sibling `~/.local/share/opencode` holds `auth.json` and + // `opencode.db` — the credentials and the conversation history — + // and must never be swept up with it. Same app, same-looking name, + // opposite verdict. + Cleanable( + id: "opencode", name: "opencode downloads", category: "Developer tools", + icon: "terminal", + note: "Helper binaries and the model list, re-fetched — sessions and login live " + + "elsewhere and are untouched.", + paths: [xdgCache(home) + "/opencode"]), Cleanable( id: "homebrew", name: "Homebrew downloads", category: "Homebrew", icon: "mug.fill", note: "Bottle, cask and API downloads, re-fetched on demand.", diff --git a/Sources/SpaceMatters/Util/ToolActivity.swift b/Sources/SpaceMatters/Util/ToolActivity.swift index 05ad3a4..65cc564 100644 --- a/Sources/SpaceMatters/Util/ToolActivity.swift +++ b/Sources/SpaceMatters/Util/ToolActivity.swift @@ -72,6 +72,11 @@ enum ToolActivity { "pre-commit": ["pre-commit"], "prek": ["pre-commit"], "gh": ["gh"], + // Both run *from* the tree being emptied — opengrep out of its + // extracted engine, opencode out of its downloaded helper binaries. + "opengrep": ["opengrep"], + "semgrep": ["opengrep"], + "opencode": ["opencode"], "gradle": ["gradle"], "mvn": ["maven"], "mvnd": ["maven"], diff --git a/Tests/SpaceMattersTests/CleanupTests.swift b/Tests/SpaceMattersTests/CleanupTests.swift index a72c4a7..35c1ddf 100644 --- a/Tests/SpaceMattersTests/CleanupTests.swift +++ b/Tests/SpaceMattersTests/CleanupTests.swift @@ -792,6 +792,19 @@ private final class JournalBox { #expect(paths("uv").contains("/home/x/Library/Caches/uv")) #expect(paths("gh") == ["/home/x/.cache/gh"]) #expect(paths("pre-commit").contains("/home/x/.cache/pre-commit")) + #expect(paths("opengrep").contains("/home/x/.cache/opengrep")) + } + + /// A tool's `.cache` is disposable and its `.local/share` is not, however + /// alike the two names look. opencode keeps helper binaries in one and its + /// login token plus every conversation in the other — the catalog must + /// reach for exactly one of them. + @Test func onlyTheCacheHalfOfATwoDirectoryToolIsOffered() { + let opencode = CleanupEngine.catalog(home: "/home/x").first { $0.id == "opencode" } + #expect(opencode?.paths == ["/home/x/.cache/opencode"]) + #expect(CleanupEngine.catalog(home: "/home/x").allSatisfy { item in + item.paths.allSatisfy { !$0.contains("/.local/share/opencode") } + }) } /// npx installs a throwaway tree per invocation and never evicts one, so it