Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions ClaudeSync/App/AppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 27 additions & 5 deletions ClaudeSync/Persistence/Preferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,31 +36,42 @@ 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
self.launchAtLogin = launchAtLogin
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)
Expand All @@ -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)
}
}

Expand Down
35 changes: 32 additions & 3 deletions ClaudeSync/Sync/FileSyncActor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -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 }

Expand Down
26 changes: 23 additions & 3 deletions ClaudeSync/Sync/SyncTarget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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:
Expand Down
112 changes: 93 additions & 19 deletions ClaudeSync/Sync/TrashJanitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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<Void, Never>?

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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -96,30 +114,71 @@ 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: [
.contentModificationDateKey, .isDirectoryKey,
])
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
))
Comment on lines +131 to +138

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If directoryUsage returns nil (e.g., due to transient I/O or permission errors), treating it as (bytes: 0, files: 0) will set bucket.fileCount to 0. This causes isEmptyAndSettled to evaluate to true, leading to the premature deletion of the entire bucket and potential data loss. Skipping the bucket when usage cannot be determined is a much safer approach.

            guard let usage = directoryUsage(url) else {
                logger.warning("TrashJanitor: failed to get usage for \(url.path)", category: "trash")
                continue
            }
            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,
Expand All @@ -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)
}
Comment on lines +206 to 222

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The fm.enumerator returns all descendants, including directory entries. If a bucket contains empty subdirectories, files will be greater than 0, which prevents the bucket from being recognized as empty and cleaned up. Only counting regular files ensures that empty directory structures are correctly swept.

    private func directoryUsage(_ url: URL) -> (bytes: Int64, files: Int)? {
        guard let it = fm.enumerator(at: url,
                                     includingPropertiesForKeys: [.totalFileSizeKey, .isDirectoryKey],
                                     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, .isDirectoryKey])
            if values?.isDirectory == true {
                continue
            }
            if let bytes = values?.totalFileSize {
                total += Int64(bytes)
            }
            files += 1
        }
        return (total, files)
    }

}
Loading
Loading