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..58ad0a1 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,11 +74,39 @@ 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 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. @@ -84,3 +144,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/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/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..9230009 --- /dev/null +++ b/Sources/SpaceMatters/Scanner/CleanupDiscovery.swift @@ -0,0 +1,445 @@ +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] + /// 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 + /// 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"]), + // 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 + /// 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 { ["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", + 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 { + // 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 } + + // 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)) + } + } + 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 } + } + + /// 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 + /// 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) }) { + 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 " + + "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..1f1ba59 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.", @@ -91,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.", @@ -121,6 +240,37 @@ 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"]), + // 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.", @@ -128,6 +278,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 @@ -137,8 +295,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 +366,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 +395,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 +433,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 +464,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..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 { @@ -46,6 +141,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 +214,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] { @@ -115,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/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/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/Util/ToolActivity.swift b/Sources/SpaceMatters/Util/ToolActivity.swift index 719e6f0..65cc564 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,31 @@ 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"], + // 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"], + // 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"], - "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..650af95 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 @@ -24,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. @@ -40,7 +64,8 @@ final class ContainerController { engineName = engine.displayName state = .loading df = []; images = []; containers = []; volumes = [] - expandedImages = []; layerCache = [:] + machineDisk = nil; lastTrimFreed = nil + expandedImages = []; expandedGroups = []; layerCache = [:] actionError = nil runningAction = nil loadID += 1 @@ -68,6 +93,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 } @@ -82,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) { @@ -105,17 +139,69 @@ 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 + /// 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/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/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..267bfef 100644 --- a/Sources/SpaceMatters/Views/ContainerResultView.swift +++ b/Sources/SpaceMatters/Views/ContainerResultView.swift @@ -10,8 +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? + /// 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) { @@ -33,18 +34,69 @@ struct ContainerResultView: View { } } .background(theme.windowBackground) - .alert(item: $confirmPrune) { kind in pruneAlert(kind) } - .alert(item: $confirmRemove) { image in removeAlert(image) } - .alert("Action failed", isPresented: actionErrorShown) { - Button("OK", role: .cancel) { controller.clearActionError() } - } message: { - Text(controller.actionError ?? "") + // 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)" + } } } - private var actionErrorShown: Binding { - Binding(get: { controller.actionError != nil }, - set: { if !$0 { controller.clearActionError() } }) + /// 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"))) + } + } + + /// 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 @@ -90,12 +142,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 { pending = .trim } 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 @@ -111,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) @@ -122,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)) } } @@ -140,26 +254,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) } } } @@ -178,10 +295,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: { pending = .remove(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) @@ -193,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() ) } @@ -224,9 +392,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 @@ -244,8 +484,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) @@ -264,15 +518,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") @@ -280,6 +547,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/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 new file mode 100644 index 0000000..d5acdf0 --- /dev/null +++ b/Tests/SpaceMattersTests/CleanupDiscoveryTests.swift @@ -0,0 +1,440 @@ +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: 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 + /// 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..35c1ddf 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. @@ -693,3 +774,50 @@ 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")) + #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 + /// 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/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") + } } diff --git a/Tests/SpaceMattersTests/ToolActivityTests.swift b/Tests/SpaceMattersTests/ToolActivityTests.swift index af1f03c..4593622 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,41 @@ 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", + // 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", "cold-node-modules"]) + let uncovered = offered.subtracting(ToolActivity.coveredTargets).subtracting(exempt) + #expect(uncovered.isEmpty, "no active-tool warning for: \(uncovered.sorted())") + } }