diff --git a/ClaudeSync/App/AppEnvironment.swift b/ClaudeSync/App/AppEnvironment.swift index d92dd84..f9bf817 100644 --- a/ClaudeSync/App/AppEnvironment.swift +++ b/ClaudeSync/App/AppEnvironment.swift @@ -131,11 +131,15 @@ final class AppEnvironment { self.batchAccumulator = batchAccumulator self.conflictResolver = resolver // SAFETY-001: construct the TrashJanitor with the user-configured - // retention window from preferences.json (default 30, clamped ≥1 + // retention window from preferences.json (default 7, clamped ≥1 // in Preferences.init). Without this, the preference field is // dead config — the coordinator's default `TrashJanitor()` would - // always hardcode 30 days regardless of what the user set. - let janitor = TrashJanitor(retentionDays: initialPrefs.trashRetentionDays) + // always hardcode its own defaults regardless of what the user set. + let janitor = TrashJanitor( + retentionDays: initialPrefs.trashRetentionDays, + maxBytes: initialPrefs.trashMaxBytes, + maxBucketCount: initialPrefs.trashMaxBuckets + ) self.coordinator = SyncCoordinator( watcher: watcher, syncActor: syncActor, diff --git a/ClaudeSync/Persistence/Preferences.swift b/ClaudeSync/Persistence/Preferences.swift index cfc14d0..10680dc 100644 --- a/ClaudeSync/Persistence/Preferences.swift +++ b/ClaudeSync/Persistence/Preferences.swift @@ -6,6 +6,10 @@ import Foundation /// default so deserializing an older file still works after an upgrade. public struct Preferences: Codable, Equatable, Sendable { + public static let defaultTrashRetentionDays = 7 + public static let defaultTrashMaxBytes: Int64 = 5 * 1024 * 1024 * 1024 + public static let defaultTrashMaxBuckets = 512 + /// Maximum bandwidth, in KiB/s, passed to rsync via `--bwlimit`. /// `0` means unlimited. public var bandwidthLimitKBps: Int @@ -32,16 +36,24 @@ public struct Preferences: Codable, Equatable, Sendable { /// v1.3 (SAFETY-001): retention window for the rsync `--backup-dir` /// quarantine at `~/.claudesync/trash/`. Files removed by `--delete` /// land there and are kept this many days before TrashJanitor sweeps - /// them. Default 30 days. Minimum 1. + /// them. Default 7 days. Minimum 1. public var trashRetentionDays: Int + /// Hard cap for `~/.claudesync/trash/`. `0` disables the byte cap. + public var trashMaxBytes: Int64 + + /// Hard cap for UUID-named trash buckets. `0` disables the bucket cap. + public var trashMaxBuckets: Int + public init( bandwidthLimitKBps: Int = 0, extraExcludes: [String: [String]] = [:], launchAtLogin: Bool = false, pairedPeer: PairedPeerRecord? = nil, autoPairSameAppleID: Bool = true, - trashRetentionDays: Int = 30 + trashRetentionDays: Int = Preferences.defaultTrashRetentionDays, + trashMaxBytes: Int64 = Preferences.defaultTrashMaxBytes, + trashMaxBuckets: Int = Preferences.defaultTrashMaxBuckets ) { self.bandwidthLimitKBps = bandwidthLimitKBps self.extraExcludes = extraExcludes @@ -49,14 +61,17 @@ public struct Preferences: Codable, Equatable, Sendable { self.pairedPeer = pairedPeer self.autoPairSameAppleID = autoPairSameAppleID self.trashRetentionDays = max(1, trashRetentionDays) + self.trashMaxBytes = max(0, trashMaxBytes) + self.trashMaxBuckets = max(0, trashMaxBuckets) } // Codable backwards compatibility — older preferences.json files don't - // have `pairedPeer` / `autoPairSameAppleID` / `trashRetentionDays`. + // have `pairedPeer`, auto-pair, or trash retention/cap fields. // Decode missing fields with current defaults instead of failing. private enum CodingKeys: String, CodingKey { case bandwidthLimitKBps, extraExcludes, launchAtLogin, pairedPeer, - autoPairSameAppleID, trashRetentionDays + autoPairSameAppleID, trashRetentionDays, trashMaxBytes, + trashMaxBuckets } public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) @@ -67,8 +82,15 @@ public struct Preferences: Codable, Equatable, Sendable { self.pairedPeer = try c.decodeIfPresent(PairedPeerRecord.self, forKey: .pairedPeer) self.autoPairSameAppleID = try c.decodeIfPresent(Bool.self, forKey: .autoPairSameAppleID) ?? true - let raw = try c.decodeIfPresent(Int.self, forKey: .trashRetentionDays) ?? 30 + let raw = try c.decodeIfPresent(Int.self, forKey: .trashRetentionDays) ?? + Preferences.defaultTrashRetentionDays self.trashRetentionDays = max(1, raw) + let rawMaxBytes = try c.decodeIfPresent(Int64.self, forKey: .trashMaxBytes) ?? + Preferences.defaultTrashMaxBytes + self.trashMaxBytes = max(0, rawMaxBytes) + let rawMaxBuckets = try c.decodeIfPresent(Int.self, forKey: .trashMaxBuckets) ?? + Preferences.defaultTrashMaxBuckets + self.trashMaxBuckets = max(0, rawMaxBuckets) } } diff --git a/ClaudeSync/Sync/FileSyncActor.swift b/ClaudeSync/Sync/FileSyncActor.swift index ba953cf..ab5ff7c 100644 --- a/ClaudeSync/Sync/FileSyncActor.swift +++ b/ClaudeSync/Sync/FileSyncActor.swift @@ -146,17 +146,38 @@ public actor FileSyncActor { // Only directories — files at top-level are handled by the // fallback path below (re-enqueued as a single full-sync). let fm = FileManager.default - let subdirs = children.filter { name in + let allSubdirs = children.filter { name in var isDir: ObjCBool = false let absolute = base + (base.hasSuffix("/") ? "" : "/") + name return fm.fileExists(atPath: absolute, isDirectory: &isDir) && isDir.boolValue } - if subdirs.isEmpty { + if allSubdirs.isEmpty { // Empty / nothing to split — keep original behaviour. queue.enqueue(job) scheduleNext() return } + let ignore = IgnorePatterns(userExtra: config.builder.userExtraExcludes) + let subdirs = allSubdirs.filter { name in + Self.shouldIncludeFullSyncSubdir( + name: name, + base: base, + target: job.target, + ignore: ignore + ) + } + if subdirs.isEmpty { + // All children are excluded; enqueue one top-level full-sync + // so top-level files still converge while rsync's own + // --exclude rules prevent descent into the ignored children. + queue.enqueue(job) + scheduleNext() + logger.info( + "full-sync of \(job.target.rawValue) kept top-level job because all \(allSubdirs.count) subdirs are excluded", + category: "sync" + ) + return + } for sub in subdirs { let chunk = SyncJob( target: job.target, @@ -171,7 +192,7 @@ public actor FileSyncActor { queue.enqueue(chunk) } logger.info( - "exploded top-level full-sync of \(job.target.rawValue) into \(subdirs.count) per-subdir chunks", + "exploded top-level full-sync of \(job.target.rawValue) into \(subdirs.count) per-subdir chunks (skipped \(allSubdirs.count - subdirs.count) excluded)", category: "sync" ) scheduleNext() @@ -247,6 +268,14 @@ public actor FileSyncActor { scheduleNext() } + static func shouldIncludeFullSyncSubdir(name: String, + base: String, + target: SyncTarget, + ignore: IgnorePatterns) -> Bool { + let absolute = base + (base.hasSuffix("/") ? "" : "/") + name + return !ignore.shouldIgnore(absolutePath: absolute, target: target) + } + public var pendingCount: Int { queue.count } public var runningCount: Int { runningIDs.count } diff --git a/ClaudeSync/Sync/SyncTarget.swift b/ClaudeSync/Sync/SyncTarget.swift index 7f7e7d7..668e716 100644 --- a/ClaudeSync/Sync/SyncTarget.swift +++ b/ClaudeSync/Sync/SyncTarget.swift @@ -165,9 +165,15 @@ public extension SyncTarget { basePath: "~/Library/Application Support/Claude", watchPaths: ["~/Library/Application Support/Claude"], excludePatterns: [ + // Claude Desktop local-agent VM images are regenerated + // locally and can be tens of GB. Syncing them creates + // huge rsync backup-dir trash churn with no portable + // value on the peer. + "vm_bundles/", "*.img", "*.raw", "*.zst", "Cache/", "GPUCache/", "blob_storage/", "Crashpad/", - "DawnCache/", "Local Storage/", "Session Storage/", - "Service Worker/", "WebStorage/", "*.log", + "Code Cache/", "DawnCache/", "DawnWebGPUCache/", + "Local Storage/", "Session Storage/", "Service Worker/", + "WebStorage/", "*.log", ], defaultTier: .realtime ) @@ -176,7 +182,21 @@ public extension SyncTarget { target: .codexConfig, basePath: "~/.codex", watchPaths: ["~/.codex"], - excludePatterns: ["*.log", "cache/", "*.tmp"], + excludePatterns: [ + // Codex target is for config, rules, skills, and other + // source-like customizations. Active sessions, SQLite + // stores, logs, package caches, and helper runtimes churn + // constantly while Codex is running and can grow far + // beyond the PRD's <10MB expectation for this target. + "sessions/", "session_index.jsonl", "history.jsonl", + "shell_snapshots/", "sqlite/", + "*.log", "log/", "logs/", + "*.sqlite", "*.sqlite-*", "*.sqlite*", + "cache/", ".cache/", "tmp/", ".tmp/", + "node_repl/", "ambient-suggestions/", "attachments/", + "computer-use/", "packages/", "process_manager/", + "memories/", ".remote-plugin-install-staging/", + ], defaultTier: .realtime ) case .projects: diff --git a/ClaudeSync/Sync/TrashJanitor.swift b/ClaudeSync/Sync/TrashJanitor.swift index 65d1fa6..a53cff7 100644 --- a/ClaudeSync/Sync/TrashJanitor.swift +++ b/ClaudeSync/Sync/TrashJanitor.swift @@ -7,7 +7,8 @@ import Foundation /// that a propagated cleanup or accidental `rm -rf` is recoverable. The /// quarantine would otherwise grow without bound; this actor sweeps /// top-level buckets whose mtime is older than `retentionDays` (default -/// 30) on a daily cadence. +/// 7) on a daily cadence, and also enforces size/count caps for high-churn +/// environments where many fresh buckets can exhaust disk before aging out. /// /// Janitor is intentionally conservative: /// - Only touches the top-level UUID-named buckets under `trashRoot`. @@ -28,17 +29,26 @@ public actor TrashJanitor { // to read from outside the actor without awaiting. public nonisolated let trashRoot: URL public nonisolated let retentionDays: Int + public nonisolated let maxBytes: Int64 + public nonisolated let maxBucketCount: Int + public nonisolated let capMinAgeSeconds: TimeInterval public nonisolated let sweepInterval: Duration private nonisolated let logger: AppLogger private let fm = FileManager.default private var loopTask: Task? public init(trashRoot: URL = TrashJanitor.defaultTrashRoot(), - retentionDays: Int = 30, + retentionDays: Int = Preferences.defaultTrashRetentionDays, + maxBytes: Int64 = Preferences.defaultTrashMaxBytes, + maxBucketCount: Int = Preferences.defaultTrashMaxBuckets, + capMinAgeSeconds: TimeInterval = 60 * 60, sweepInterval: Duration = .seconds(24 * 60 * 60), logger: AppLogger = .shared) { self.trashRoot = trashRoot self.retentionDays = max(1, retentionDays) + self.maxBytes = max(0, maxBytes) + self.maxBucketCount = max(0, maxBucketCount) + self.capMinAgeSeconds = max(0, capMinAgeSeconds) self.sweepInterval = sweepInterval self.logger = logger } @@ -70,6 +80,14 @@ public actor TrashJanitor { public let errors: Int } + private struct Bucket: Sendable { + let url: URL + let name: String + let mtime: Date + let size: Int64 + let fileCount: Int + } + /// Public for tests so they can call deterministically. @discardableResult public func sweepOnce() async -> SweepOutcome { @@ -96,10 +114,12 @@ public actor TrashJanitor { var removed = 0 var bytes: Int64 = 0 var errors = 0 + var buckets: [Bucket] = [] for url in entries { scanned += 1 // Defensive: only sweep UUID-named buckets we created. - guard UUID(uuidString: url.lastPathComponent) != nil else { + let name = url.lastPathComponent + guard UUID(uuidString: name) != nil else { continue } let values = try? url.resourceValues(forKeys: [ @@ -107,19 +127,58 @@ public actor TrashJanitor { ]) guard values?.isDirectory == true else { continue } guard let mtime = values?.contentModificationDate else { continue } - guard mtime < cutoff else { continue } - - let size = directorySize(url) ?? 0 - do { - try fm.removeItem(at: url) - removed += 1 - bytes += size - logger.info("TrashJanitor: removed \(url.lastPathComponent) (\(size) bytes, mtime \(mtime))", - category: "trash") - } catch { - errors += 1 - logger.warning("TrashJanitor: removeItem failed for \(url.path): \(error)", - category: "trash") + + let usage = directoryUsage(url) ?? (bytes: 0, files: 0) + buckets.append(Bucket( + url: url, + name: name, + mtime: mtime, + size: usage.bytes, + fileCount: usage.files + )) + } + + let capCutoff = Date().addingTimeInterval(-capMinAgeSeconds) + var remaining: [Bucket] = [] + for bucket in buckets { + let isExpired = bucket.mtime < cutoff + let isEmptyAndSettled = bucket.fileCount == 0 && bucket.mtime < capCutoff + if isExpired || isEmptyAndSettled { + let reason = isExpired ? "retention" : "empty" + if remove(bucket, reason: reason) { + removed += 1 + bytes += bucket.size + } else { + errors += 1 + } + } else { + remaining.append(bucket) + } + } + + var totalBytes = remaining.reduce(Int64(0)) { $0 + $1.size } + var totalBuckets = remaining.count + let exceedsBytes = maxBytes > 0 && totalBytes > maxBytes + let exceedsBuckets = maxBucketCount > 0 && totalBuckets > maxBucketCount + if exceedsBytes || exceedsBuckets { + let candidates = remaining + .filter { $0.mtime < capCutoff } + .sorted { lhs, rhs in + if lhs.mtime == rhs.mtime { return lhs.name < rhs.name } + return lhs.mtime < rhs.mtime + } + for bucket in candidates { + let overBytes = maxBytes > 0 && totalBytes > maxBytes + let overBuckets = maxBucketCount > 0 && totalBuckets > maxBucketCount + guard overBytes || overBuckets else { break } + if remove(bucket, reason: "cap") { + removed += 1 + bytes += bucket.size + totalBytes -= bucket.size + totalBuckets -= 1 + } else { + errors += 1 + } } } outcome = SweepOutcome(scanned: scanned, removed: removed, @@ -131,19 +190,34 @@ public actor TrashJanitor { return outcome } - private func directorySize(_ url: URL) -> Int64? { + private func remove(_ bucket: Bucket, reason: String) -> Bool { + do { + try fm.removeItem(at: bucket.url) + logger.info("TrashJanitor: removed \(bucket.name) (\(bucket.size) bytes, mtime \(bucket.mtime), reason=\(reason))", + category: "trash") + return true + } catch { + logger.warning("TrashJanitor: removeItem failed for \(bucket.url.path): \(error)", + category: "trash") + return false + } + } + + private func directoryUsage(_ url: URL) -> (bytes: Int64, files: Int)? { guard let it = fm.enumerator(at: url, includingPropertiesForKeys: [.totalFileSizeKey], - options: [.skipsHiddenFiles]) else { + options: []) else { return nil } var total: Int64 = 0 + var files = 0 for case let fileURL as URL in it { let values = try? fileURL.resourceValues(forKeys: [.totalFileSizeKey]) if let bytes = values?.totalFileSize { total += Int64(bytes) } + files += 1 } - return total + return (total, files) } } diff --git a/ClaudeSyncTests/FileSyncActorTests.swift b/ClaudeSyncTests/FileSyncActorTests.swift index 313ca4b..0249750 100644 --- a/ClaudeSyncTests/FileSyncActorTests.swift +++ b/ClaudeSyncTests/FileSyncActorTests.swift @@ -314,10 +314,18 @@ final class FileSyncActorTests: XCTestCase { let fm = FileManager.default let subdirs: [String] = { guard let entries = try? fm.contentsOfDirectory(atPath: base) else { return [] } + let ignore = IgnorePatterns() return entries.filter { name in var isDir: ObjCBool = false let abs = base + (base.hasSuffix("/") ? "" : "/") + name - return fm.fileExists(atPath: abs, isDirectory: &isDir) && isDir.boolValue + return fm.fileExists(atPath: abs, isDirectory: &isDir) && + isDir.boolValue && + FileSyncActor.shouldIncludeFullSyncSubdir( + name: name, + base: base, + target: .projects, + ignore: ignore + ) } }() guard subdirs.count >= 2 else { @@ -350,6 +358,58 @@ final class FileSyncActorTests: XCTestCase { await actor.close() } + func testFullSyncExplode_skipsExcludedSubdirs_v1_3_5() { + let base = "/Users/kim/Library/Application Support/Claude" + let ignore = IgnorePatterns() + + XCTAssertFalse(FileSyncActor.shouldIncludeFullSyncSubdir( + name: "vm_bundles", + base: base, + target: .claudeAppSupport, + ignore: ignore + )) + XCTAssertFalse(FileSyncActor.shouldIncludeFullSyncSubdir( + name: "Code Cache", + base: base, + target: .claudeAppSupport, + ignore: ignore + )) + XCTAssertFalse(FileSyncActor.shouldIncludeFullSyncSubdir( + name: "DawnWebGPUCache", + base: base, + target: .claudeAppSupport, + ignore: ignore + )) + XCTAssertTrue(FileSyncActor.shouldIncludeFullSyncSubdir( + name: "settings", + base: base, + target: .claudeAppSupport, + ignore: ignore + )) + } + + func testFullSyncExplode_skipsCodexRuntimeSubdirs_v1_3_5() { + let base = "/Users/kim/.codex" + let ignore = IgnorePatterns() + + for name in ["sessions", "packages", "node_repl", "ambient-suggestions", + "attachments", "computer-use", ".tmp", "cache", "sqlite", + "process_manager"] { + XCTAssertFalse(FileSyncActor.shouldIncludeFullSyncSubdir( + name: name, + base: base, + target: .codexConfig, + ignore: ignore + ), "Expected full-sync explode to skip .codex/\(name)") + } + XCTAssertTrue(FileSyncActor.shouldIncludeFullSyncSubdir( + name: "skills", + base: base, + target: .codexConfig, + ignore: ignore + )) + } + /// A *subpath-scoped* full-sync (chunk emitted by the explode above) /// must not recursively explode again — it enqueues as itself. Without /// this guard the explode would loop on every subdirectory level. diff --git a/ClaudeSyncTests/PreferencesTests.swift b/ClaudeSyncTests/PreferencesTests.swift index fbe3b1b..26a03db 100644 --- a/ClaudeSyncTests/PreferencesTests.swift +++ b/ClaudeSyncTests/PreferencesTests.swift @@ -10,6 +10,9 @@ final class PreferencesTests: XCTestCase { XCTAssertEqual(p.bandwidthLimitKBps, 0) XCTAssertTrue(p.extraExcludes.isEmpty) XCTAssertFalse(p.launchAtLogin) + XCTAssertEqual(p.trashRetentionDays, 7) + XCTAssertEqual(p.trashMaxBytes, 5 * 1024 * 1024 * 1024) + XCTAssertEqual(p.trashMaxBuckets, 512) } func testEncodeDecode_roundTrip_preservesAllFields() throws { @@ -43,6 +46,20 @@ final class PreferencesTests: XCTestCase { XCTAssertEqual(p.extraExcludes(for: .claudeConfig), []) } + func testDecodeLegacyPreferences_usesCurrentTrashDefaults() throws { + let data = Data(""" + { + "bandwidthLimitKBps": 256, + "extraExcludes": {}, + "launchAtLogin": true + } + """.utf8) + let decoded = try JSONDecoder().decode(Preferences.self, from: data) + XCTAssertEqual(decoded.trashRetentionDays, Preferences.defaultTrashRetentionDays) + XCTAssertEqual(decoded.trashMaxBytes, Preferences.defaultTrashMaxBytes) + XCTAssertEqual(decoded.trashMaxBuckets, Preferences.defaultTrashMaxBuckets) + } + // MARK: - PreferencesStore (file-backed) func testStore_returnsDefaults_whenFileMissing() async { diff --git a/ClaudeSyncTests/SyncTargetTests.swift b/ClaudeSyncTests/SyncTargetTests.swift index 246354e..54168f4 100644 --- a/ClaudeSyncTests/SyncTargetTests.swift +++ b/ClaudeSyncTests/SyncTargetTests.swift @@ -45,6 +45,16 @@ final class SyncTargetTests: XCTestCase { XCTAssertEqual(spec.tier(forRelativePath: "myproject/src/main.go"), .onDemand) } + func testCodexConfig_excludesRuntimeStateAndSessionChurn() { + let ex = SyncTarget.codexConfig.spec.excludePatterns + for pat in ["sessions/", "history.jsonl", "session_index.jsonl", + "shell_snapshots/", "sqlite/", "*.sqlite*", "packages/", + "node_repl/", "ambient-suggestions/", "attachments/", + "computer-use/", ".tmp/", ".remote-plugin-install-staging/"] { + XCTAssertTrue(ex.contains(pat), "codexConfig should exclude \(pat)") + } + } + func testTildeExpansion() { XCTAssertEqual( "~/.claude".expandingTildeInPath, @@ -120,6 +130,41 @@ final class IgnorePatternsTests: XCTestCase { ) } + func testClaudeAppSupport_excludesLocalAgentVMAndDerivedCaches() { + let ex = SyncTarget.claudeAppSupport.spec.excludePatterns + for pat in ["vm_bundles/", "*.img", "*.raw", "*.zst", + "Code Cache/", "DawnWebGPUCache/"] { + XCTAssertTrue(ex.contains(pat), "claudeAppSupport should exclude \(pat)") + } + XCTAssertTrue(patterns.shouldIgnore( + absolutePath: "/Users/kim/Library/Application Support/Claude/vm_bundles/claudevm.bundle/rootfs.img", + target: .claudeAppSupport + )) + XCTAssertTrue(patterns.shouldIgnore( + absolutePath: "/Users/kim/Library/Application Support/Claude/Code Cache/js/index", + target: .claudeAppSupport + )) + } + + func testCodexRuntimeState_isIgnored() { + for path in [ + "/Users/kim/.codex/sessions/2026/06/08/session.jsonl", + "/Users/kim/.codex/logs_2.sqlite-wal", + "/Users/kim/.codex/state_5.sqlite", + "/Users/kim/.codex/sqlite/codex-dev.db", + "/Users/kim/.codex/history.jsonl", + "/Users/kim/.codex/packages/standalone/codex", + "/Users/kim/.codex/plugins/.remote-plugin-install-staging/plugin.json", + "/Users/kim/.codex/shell_snapshots/abc.sh", + "/Users/kim/.codex/.tmp/plugins/cache", + ] { + XCTAssertTrue( + patterns.shouldIgnore(absolutePath: path, target: .codexConfig), + "Expected codexConfig to ignore \(path)" + ) + } + } + func testFnmatch_wildcards() { XCTAssertTrue(IgnorePatterns.fnmatch(pattern: "*.log", name: "build.log")) XCTAssertTrue(IgnorePatterns.fnmatch(pattern: "*.log", name: ".log")) diff --git a/ClaudeSyncTests/TrashJanitorTests.swift b/ClaudeSyncTests/TrashJanitorTests.swift index c2c3871..68efb42 100644 --- a/ClaudeSyncTests/TrashJanitorTests.swift +++ b/ClaudeSyncTests/TrashJanitorTests.swift @@ -21,11 +21,12 @@ final class TrashJanitorTests: XCTestCase { private func makeBucket(name: String = UUID().uuidString, ageDays: Double, - withFileBytes bytes: Int = 0) throws -> URL { + withFileBytes bytes: Int = 0, + fileName: String = "payload.bin") throws -> URL { let bucket = tmpRoot.appendingPathComponent(name, isDirectory: true) try fm.createDirectory(at: bucket, withIntermediateDirectories: true) if bytes > 0 { - let file = bucket.appendingPathComponent("payload.bin") + let file = bucket.appendingPathComponent(fileName) try Data(repeating: 0xAB, count: bytes).write(to: file) } let then = Date().addingTimeInterval(-ageDays * 86_400) @@ -35,7 +36,7 @@ final class TrashJanitorTests: XCTestCase { func testSweep_removesBucketsOlderThanRetention() async throws { let oldBucket = try makeBucket(ageDays: 45) - let freshBucket = try makeBucket(ageDays: 1) + let freshBucket = try makeBucket(ageDays: 1, withFileBytes: 1) let janitor = TrashJanitor(trashRoot: tmpRoot, retentionDays: 30, sweepInterval: .seconds(3600)) @@ -87,6 +88,58 @@ final class TrashJanitorTests: XCTestCase { "zero/negative retention must be clamped so the janitor never sweeps everything immediately") } + func testSweep_removesSettledEmptyBuckets() async throws { + let empty = try makeBucket(ageDays: 1, withFileBytes: 0) + let janitor = TrashJanitor(trashRoot: tmpRoot, + retentionDays: 30, + maxBytes: 0, + maxBucketCount: 0, + capMinAgeSeconds: 0, + sweepInterval: .seconds(3600)) + let outcome = await janitor.sweepOnce() + XCTAssertEqual(outcome.removed, 1) + XCTAssertFalse(fm.fileExists(atPath: empty.path)) + } + + func testSweep_enforcesBucketCountCap_oldestFirst() async throws { + let oldest = try makeBucket(ageDays: 4, withFileBytes: 10) + let middle = try makeBucket(ageDays: 3, withFileBytes: 10) + let newest = try makeBucket(ageDays: 2, withFileBytes: 10) + + let janitor = TrashJanitor(trashRoot: tmpRoot, + retentionDays: 30, + maxBytes: 0, + maxBucketCount: 2, + capMinAgeSeconds: 0, + sweepInterval: .seconds(3600)) + let outcome = await janitor.sweepOnce() + + XCTAssertEqual(outcome.removed, 1) + XCTAssertFalse(fm.fileExists(atPath: oldest.path)) + XCTAssertTrue(fm.fileExists(atPath: middle.path)) + XCTAssertTrue(fm.fileExists(atPath: newest.path)) + } + + func testSweep_enforcesByteCap_andCountsHiddenFiles() async throws { + let hiddenLarge = try makeBucket(ageDays: 4, + withFileBytes: 4096, + fileName: ".rootfs.img.hidden") + let small = try makeBucket(ageDays: 3, withFileBytes: 128) + + let janitor = TrashJanitor(trashRoot: tmpRoot, + retentionDays: 30, + maxBytes: 1024, + maxBucketCount: 0, + capMinAgeSeconds: 0, + sweepInterval: .seconds(3600)) + let outcome = await janitor.sweepOnce() + + XCTAssertEqual(outcome.removed, 1) + XCTAssertGreaterThanOrEqual(outcome.bytesReclaimed, 4096) + XCTAssertFalse(fm.fileExists(atPath: hiddenLarge.path)) + XCTAssertTrue(fm.fileExists(atPath: small.path)) + } + // Regression guard: Preferences.trashRetentionDays must actually flow // into the TrashJanitor used in production. The original v1.3 PR // defined the preference field + Codable plumbing but did not wire @@ -96,8 +149,12 @@ final class TrashJanitorTests: XCTestCase { // janitor built from a Preferences value carries that value through. func testPreferences_trashRetentionDays_isHonoredByJanitor() async throws { let prefs = Preferences(trashRetentionDays: 90) - let janitor = TrashJanitor(retentionDays: prefs.trashRetentionDays) + let janitor = TrashJanitor(retentionDays: prefs.trashRetentionDays, + maxBytes: prefs.trashMaxBytes, + maxBucketCount: prefs.trashMaxBuckets) XCTAssertEqual(janitor.retentionDays, 90, "TrashJanitor must adopt the retention window from Preferences instead of the hardcoded default") + XCTAssertEqual(janitor.maxBytes, prefs.trashMaxBytes) + XCTAssertEqual(janitor.maxBucketCount, prefs.trashMaxBuckets) } }