Skip to content

Commit 9ebc7dd

Browse files
authored
Merge pull request #36 from wxy/codex/pnpm-owner-recipe
Show measured pnpm prune recipe and clarify watched storage
2 parents 7de984b + a31ea7d commit 9ebc7dd

49 files changed

Lines changed: 2989 additions & 98 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎PoolProblem/PoolProblem/AppService.swift‎

Lines changed: 225 additions & 12 deletions
Large diffs are not rendered by default.

‎PoolProblem/PoolProblem/Localizable.xcstrings‎

Lines changed: 408 additions & 0 deletions
Large diffs are not rendered by default.

‎PoolProblem/PoolProblem/Models/AppState.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ final class AppState: ObservableObject {
4747
/// 正在删除的条目剩余比例:1 → 0,用于列表里大小逐渐缩小到消失的动画。
4848
@Published var deletingProgress: Double = 1
4949
@Published var lastCleanSummary: String?
50+
@Published var lastOwnerCommandRecord: OwnerCommandRecord?
5051
@Published var detailItem: ScanItem?
5152
@Published var keptItemIDs: Set<String> = []
5253
@Published var availableHistory: [Int64] = []

‎PoolProblem/PoolProblem/Views/GrowthInsightsView.swift‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,9 +211,10 @@ struct GrowthInsightsView: View {
211211
/// Observed assets stay outside every cleanup entry point. The figure is
212212
/// allocated disk space, while reclaimable space remains zero.
213213
private var watchSection: some View {
214-
let watchItems = state.items
214+
let watchItems = service.visibleItems(state.items)
215215
.filter {
216216
$0.cleanability == .watchOnly
217+
&& $0.recipeID != OwnerCommandRecipe.pnpmStorePrune.id
217218
&& $0.paths.contains { GrowthPathStatus.probe($0) != .missing }
218219
}
219220
.sorted { $0.allocatedBytes > $1.allocatedBytes }

‎PoolProblem/PoolProblem/Views/MenuBarView.swift‎

Lines changed: 324 additions & 69 deletions
Large diffs are not rendered by default.

‎PoolProblem/PoolProblemTests/AppServiceTests.swift‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,40 @@ import Foundation
33
@testable import PoolProblem
44
import DiskReservoirCore
55

6+
@MainActor
7+
@Test func pnpmStoreCacheChildIsNeitherListedForCleaningNorMoved() async throws {
8+
let fixture = FileManager.default.temporaryDirectory
9+
.appendingPathComponent("pp-pnpm-child-e2e-\(UUID())", isDirectory: true)
10+
defer { try? FileManager.default.removeItem(at: fixture) }
11+
let home = fixture.appendingPathComponent("home")
12+
let caches = home.appendingPathComponent("Library/Caches")
13+
let store = caches.appendingPathComponent("pnpm")
14+
let data = fixture.appendingPathComponent("data")
15+
try FileManager.default.createDirectory(at: store, withIntermediateDirectories: true)
16+
try Data(repeating: 1, count: 11_000_000).write(to: store.appendingPathComponent("package"))
17+
let fakePnpm = fixture.appendingPathComponent("bin/pnpm")
18+
try FileManager.default.createDirectory(at: fakePnpm.deletingLastPathComponent(), withIntermediateDirectories: true)
19+
try ("#!/bin/sh\necho '\(store.path)'\n").write(to: fakePnpm, atomically: true, encoding: .utf8)
20+
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fakePnpm.path)
21+
let paths = StoragePaths(baseURL: data, homeDirectory: home.path)
22+
let state = AppState()
23+
let service = AppService(
24+
state: state, paths: paths, automationEnabled: false,
25+
pnpmRunner: OwnerCommandRunner(
26+
recipe: .pnpmStorePrune, executable: fakePnpm.path,
27+
environment: ["PATH": fakePnpm.deletingLastPathComponent().path], home: home.path
28+
)
29+
)
30+
await service.scanNow()
31+
let cacheItem = try #require(state.items.first { $0.recipeID == "library-caches" })
32+
let rawChild = try #require(ChildDirectoryExplorer().list(
33+
parentPath: caches.path, growthEntries: [], protectedChildNames: []
34+
).first { $0.path == store.path })
35+
#expect(!(await service.cacheChildren(for: cacheItem)).contains { $0.path == store.path })
36+
#expect(!(await service.cleanCacheChild(rawChild, in: cacheItem)))
37+
#expect(FileManager.default.fileExists(atPath: store.appendingPathComponent("package").path))
38+
}
39+
640
@MainActor
741
@Test func appServiceScanWritesSnapshot() async throws {
842
let dir = FileManager.default.temporaryDirectory

‎Sources/DiskReservoirCore/Cleaner/Cleaner.swift‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,25 +27,39 @@ public struct Cleaner: Sendable {
2727
private let deleter: FileDeleting
2828
private let inspector: ProcessInspecting
2929
private let logStore: CleanLogStore
30+
private let homeDirectory: String
3031
private let availableBytesReader: @Sendable (URL) -> Int64
3132
private let now: @Sendable () -> Date
33+
private let ownerStoreProbe: @Sendable () -> Result<OwnerCommandTarget, OwnerCommandFailure>
34+
private let knownOwnerStorePaths: [String]
35+
private let ownerHistoryOverflowed: Bool
3236

3337
public init(
3438
evaluator: RuleEvaluator,
3539
deleter: FileDeleting,
3640
inspector: ProcessInspecting,
3741
logStore: CleanLogStore,
42+
homeDirectory: String = NSHomeDirectory(),
3843
availableBytesReader: @escaping @Sendable (URL) -> Int64 = {
3944
VolumeReader.read(fileURL: $0).availableBytes
4045
},
41-
now: @escaping @Sendable () -> Date = { Date() }
46+
now: @escaping @Sendable () -> Date = { Date() },
47+
ownerStoreProbe: (@Sendable () -> Result<OwnerCommandTarget, OwnerCommandFailure>)? = nil,
48+
knownOwnerStorePaths: [String] = [],
49+
ownerHistoryOverflowed: Bool = false
4250
) {
4351
self.evaluator = evaluator
4452
self.deleter = deleter
4553
self.inspector = inspector
4654
self.logStore = logStore
55+
self.homeDirectory = homeDirectory
4756
self.availableBytesReader = availableBytesReader
4857
self.now = now
58+
self.ownerStoreProbe = ownerStoreProbe ?? {
59+
OwnerCommandRunner(recipe: .pnpmStorePrune, home: homeDirectory).probe()
60+
}
61+
self.knownOwnerStorePaths = knownOwnerStorePaths
62+
self.ownerHistoryOverflowed = ownerHistoryOverflowed
4963
}
5064

5165
/// 清理底线兜底:任何删除决定都必须经过可清理性校验。
@@ -82,6 +96,7 @@ public struct Cleaner: Sendable {
8296
}
8397
let availableBefore = availableBytesReader(scan.volumeURL)
8498
let candidates = scan.items
99+
.filter { $0.recipeID != OwnerCommandRecipe.pnpmStorePrune.id }
85100
.filter { !RuleEvaluator.isPathProtected(item: $0, whitelistPaths: config.whitelistPaths) }
86101
// 应用无法删除的手动项(Xcode/Finder)不进入自动/强制清理候选
87102
.filter { !CleanupRationale.make(for: $0).isManual }
@@ -126,6 +141,21 @@ public struct Cleaner: Sendable {
126141
}
127142
return left.id < right.id
128143
}
144+
let knownStorePaths = knownOwnerStorePaths + scan.items
145+
.filter { $0.recipeID == OwnerCommandRecipe.pnpmStorePrune.id }
146+
.map(\.path)
147+
let needsOwnerProbe = candidates.contains { item in
148+
item.category == .packageManager
149+
|| (item.paths.isEmpty ? [item.path] : item.paths).contains { path in
150+
OwnerManagedPathGuard.isRecognizablePnpmLocation(
151+
path, homeDirectory: homeDirectory
152+
) || knownStorePaths.contains {
153+
OwnerManagedPathGuard.overlaps(path, storePath: $0)
154+
}
155+
}
156+
}
157+
let ownerStore: Result<OwnerCommandTarget, OwnerCommandFailure> = needsOwnerProbe
158+
? ownerStoreProbe() : .failure(.unavailable)
129159
var entries: [CleanLogEntry] = []
130160
var freedTotal: Int64 = 0
131161
var below = true
@@ -167,6 +197,18 @@ public struct Cleaner: Sendable {
167197
let targetPaths = item.paths.isEmpty ? [item.path] : item.paths
168198
var itemFreed: Int64 = 0
169199
for target in targetPaths {
200+
guard OwnerManagedPathGuard.mayDelete(
201+
path: target, recipeID: item.recipeID, probe: ownerStore,
202+
homeDirectory: homeDirectory, knownStorePaths: knownStorePaths,
203+
historyOverflowed: ownerHistoryOverflowed
204+
) else { continue }
205+
if item.recipeID == PackageManagerRecipes.familyID,
206+
!PackageManagerRecipes.isApprovedDefaultCachePath(target, homeDirectory: homeDirectory) {
207+
continue
208+
}
209+
guard !PackageManagerRecipes.isLegacyPnpmPath(target, homeDirectory: homeDirectory) else {
210+
continue
211+
}
170212
// 单项失败(如 TCC 权限)不影响后续项:尽力而为,继续清理其他目标
171213
guard let deletion = try? deleter.deleteReturningResult(
172214
url: URL(fileURLWithPath: target),
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import Foundation
2+
3+
/// Durable safety history for owner-managed store paths. Snapshot retention
4+
/// must not erase a previously observed target while its files still exist.
5+
public struct OwnerCommandKnownTargetsStore: Sendable {
6+
public struct State: Codable, Sendable {
7+
public var paths: [String]
8+
/// Once capacity was exceeded, deleted history is unknown. Callers
9+
/// must fail closed for broad raw-path cleanup.
10+
public var overflowed: Bool
11+
12+
public init(paths: [String] = [], overflowed: Bool = false) {
13+
self.paths = paths
14+
self.overflowed = overflowed
15+
}
16+
}
17+
18+
public static let maximumPaths = 1_024
19+
private static let lock = NSLock()
20+
private let pathsConfig: StoragePaths
21+
private let store: JSONStoring
22+
23+
public init(paths: StoragePaths, store: JSONStoring = JSONStore()) {
24+
self.pathsConfig = paths
25+
self.store = store
26+
}
27+
28+
public func state() throws -> State {
29+
Self.lock.lock(); defer { Self.lock.unlock() }
30+
return try read()
31+
}
32+
33+
public func paths() throws -> [String] { try state().paths }
34+
35+
@discardableResult
36+
public func record(_ newPaths: [String]) throws -> State {
37+
Self.lock.lock(); defer { Self.lock.unlock() }
38+
var history = try read()
39+
for raw in newPaths {
40+
guard let canonical = canonicalStorePath(raw) else { continue }
41+
history.paths.removeAll { $0 == canonical }
42+
history.paths.append(canonical)
43+
}
44+
if history.paths.count > Self.maximumPaths {
45+
history.overflowed = true
46+
history.paths = Array(history.paths.suffix(Self.maximumPaths))
47+
}
48+
try store.save(history, to: fileURL)
49+
return history
50+
}
51+
52+
/// One-time or repeatable migration from the retained snapshots. `record`
53+
/// de-duplicates, so replaying startup history cannot grow the file.
54+
@discardableResult
55+
public func migrate(snapshots: [Snapshot]) throws -> State {
56+
try record(snapshots.flatMap { snapshot in
57+
snapshot.items.filter {
58+
$0.recipeID == OwnerCommandRecipe.pnpmStorePrune.id
59+
}.map(\.path)
60+
})
61+
}
62+
63+
private var fileURL: URL {
64+
pathsConfig.baseURL.appendingPathComponent("pnpm-known-store-paths.json")
65+
}
66+
67+
private func read() throws -> State {
68+
try store.load(State.self, from: fileURL) ?? State()
69+
}
70+
71+
private func canonicalStorePath(_ raw: String) -> String? {
72+
guard raw.hasPrefix("/"), !raw.contains("\0"), !raw.contains("\r"),
73+
!raw.contains("\n"), raw.utf8.count <= 4_096 else { return nil }
74+
let canonical = URL(fileURLWithPath: raw)
75+
.resolvingSymlinksInPath().standardizedFileURL.path
76+
let home = URL(fileURLWithPath: pathsConfig.homeDirectory)
77+
.resolvingSymlinksInPath().standardizedFileURL.path
78+
guard canonical != "/", canonical != home,
79+
URL(fileURLWithPath: canonical).pathComponents.count > 2 else { return nil }
80+
return canonical
81+
}
82+
}

0 commit comments

Comments
 (0)