From c27520ce71b4cd1ea29637dd73e419c270e32a93 Mon Sep 17 00:00:00 2001 From: arzafran Date: Wed, 4 Nov 2026 18:29:23 -0300 Subject: [PATCH 1/2] fix: keep worktrees with ignored non-build files out of automatic cleanup (INC-486) --- Sources/GitWorktreeManager.swift | 51 ++++- Sources/WorktreeStorage.swift | 158 +++++++++++----- docs/workspace-interface.md | 5 +- programaTests/WorktreeStorageTests.swift | 229 ++++++++++++++++++++++- 4 files changed, 379 insertions(+), 64 deletions(-) diff --git a/Sources/GitWorktreeManager.swift b/Sources/GitWorktreeManager.swift index ca2b9059..e023a220 100644 --- a/Sources/GitWorktreeManager.swift +++ b/Sources/GitWorktreeManager.swift @@ -347,6 +347,32 @@ struct GitWorktreeManager { return !stdout.isEmpty } + /// Ignored directories whose contents a build or package install recreates. Matched against + /// every directory component of an ignored path. + nonisolated static let rebuildableIgnoredDirectoryNames: Set = [ + "node_modules", ".build", "build", "dist", ".next", ".nuxt", ".turbo", ".cache", "target", + "Pods", "DerivedData", ".gradle", "coverage", "__pycache__", ".venv", "venv", + ] + + /// Whether the worktree at `path` holds ignored files outside the + /// `rebuildableIgnoredDirectoryNames` directories (`.dev.vars`, keys, local databases, a + /// nested clone), which `git worktree remove` would delete. Output over the stdout limit + /// counts as true; nil when git could not answer. + nonisolated static func ignoredFilesOutsideRebuildableDirectories(path: String) -> Bool? { + // The pathspecs only keep the output small. git still lists a nested repository inside an + // excluded directory (as `dir/`), so the component check below decides. + let exclusions = rebuildableIgnoredDirectoryNames.sorted().map { ":(exclude,glob)**/\($0)/**" } + guard let result = runGitCommand( + directory: path, + arguments: ["ls-files", "--others", "--ignored", "--exclude-standard", "-z", "--"] + exclusions + ) else { return nil } + if result.outcome == .stdoutLimitExceeded { return true } + guard result.outcome == .exited, result.exitStatus == 0, let stdout = result.stdout else { return nil } + return stdout.split(separator: "\0").contains { entry in + !entry.split(separator: "/").dropLast().contains { rebuildableIgnoredDirectoryNames.contains(String($0)) } + } + } + // MARK: - Process plumbing private nonisolated static func runGitCommand( @@ -408,13 +434,14 @@ enum ProgramaWorktreeSettings { /// `worktrees.autoRemoveMerged`: whether the background monitor removes merged, idle /// Programa-created worktrees. - static func autoRemoveMerged() -> Bool { - (readSettingsRoot()?["worktrees"] as? [String: Any])?["autoRemoveMerged"] as? Bool ?? defaultAutoRemoveMerged + static func autoRemoveMerged(settingsURL: URL = settingsFileURL) -> Bool { + (readSettingsRoot(settingsURL: settingsURL)?["worktrees"] as? [String: Any])?["autoRemoveMerged"] as? Bool + ?? defaultAutoRemoveMerged } /// `worktrees.autoRemoveAfterHours`, clamped to at least one hour. - static func autoRemoveAfterHours() -> Double { - guard let section = readSettingsRoot()?["worktrees"] as? [String: Any], + static func autoRemoveAfterHours(settingsURL: URL = settingsFileURL) -> Double { + guard let section = readSettingsRoot(settingsURL: settingsURL)?["worktrees"] as? [String: Any], let number = section["autoRemoveAfterHours"] as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(), number.doubleValue.isFinite else { @@ -423,10 +450,11 @@ enum ProgramaWorktreeSettings { return max(minimumAutoRemoveAfterHours, number.doubleValue) } - static func readSettingsRoot() -> [String: Any]? { - let home = FileManager.default.homeDirectoryForCurrentUser.path - let path = (home as NSString).appendingPathComponent(".config/programa/settings.json") - guard let data = FileManager.default.contents(atPath: path), !data.isEmpty else { return nil } + static let settingsFileURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config/programa/settings.json") + + static func readSettingsRoot(settingsURL: URL = settingsFileURL) -> [String: Any]? { + guard let data = FileManager.default.contents(atPath: settingsURL.path), !data.isEmpty else { return nil } guard let sanitized = try? JSONCParser.preprocess(data: data), let object = try? JSONSerialization.jsonObject(with: sanitized, options: []) else { return nil @@ -436,8 +464,11 @@ enum ProgramaWorktreeSettings { } enum ProgramaStorageSettings { - static func autoCleanCaches() -> Bool { - (ProgramaWorktreeSettings.readSettingsRoot()?["storage"] as? [String: Any])?["autoCleanCaches"] as? Bool ?? true + static let defaultAutoCleanCaches = true + + static func autoCleanCaches(settingsURL: URL = ProgramaWorktreeSettings.settingsFileURL) -> Bool { + (ProgramaWorktreeSettings.readSettingsRoot(settingsURL: settingsURL)?["storage"] as? [String: Any])?["autoCleanCaches"] + as? Bool ?? defaultAutoCleanCaches } static let defaultLowDiskWarningGB: Double = 20 diff --git a/Sources/WorktreeStorage.swift b/Sources/WorktreeStorage.swift index ec4485f6..a3a62a7a 100644 --- a/Sources/WorktreeStorage.swift +++ b/Sources/WorktreeStorage.swift @@ -18,6 +18,9 @@ struct WorktreeStorageEntry: Equatable, Sendable { case dirty /// Ignored `.env*` files, which `git worktree remove` would delete. case envFiles = "env_files" + /// Other ignored files outside rebuildable build and dependency directories. Only + /// policies with `keepsIgnoredFiles` (the background monitor) apply it. + case ignoredFiles = "ignored_files" case notMerged = "not_merged" case openInWorkspace = "open_in_workspace" case processRunning = "process_running" @@ -30,38 +33,53 @@ struct WorktreeStorageEntry: Equatable, Sendable { let path: String let branch: String? let headSHA: String? - let sizeBytes: Int64 + var sizeBytes: Int64 /// The size walk stopped early (time limit or unreadable entries), so `sizeBytes` is a floor. - let sizePartial: Bool + var sizePartial: Bool /// Newest mtime of the worktree's git `index`, `HEAD` and `logs/HEAD`. let lastModified: Date? let isProgramaCreated: Bool - let isDirty: Bool? + var isDirty: Bool? /// Ignored `.env*` files outside `node_modules/`. Always blocks, with no override. - let hasIgnoredEnvFiles: Bool? + var hasIgnoredEnvFiles: Bool? /// Informational: HEAD holds commits no remote-tracking ref contains. Not a blocker, because /// removing a worktree never deletes its branch. - let hasUnpushedOrUnmergedCommits: Bool? - let isMerged: Bool? + var hasUnpushedOrUnmergedCommits: Bool? + var isMerged: Bool? let isOpenInWorkspace: Bool let isProcessRunningInside: Bool + /// Ignored files outside `GitWorktreeManager.rebuildableIgnoredDirectoryNames` directories. + var hasIgnoredFiles: Bool? = nil /// The first reason to keep this worktree under `policy`, or nil when it may be removed. func removalBlocker(policy: WorktreeRemovalPolicy, now: Date) -> RemovalBlocker? { if !isProgramaCreated, !policy.includeExternal { return .external } - guard let isDirty, let hasIgnoredEnvFiles, let isMerged else { return .statusUnknown } + guard let isDirty, let hasIgnoredEnvFiles, let isMerged, + !policy.keepsIgnoredFiles || hasIgnoredFiles != nil else { return .statusUnknown } if isDirty { return .dirty } if hasIgnoredEnvFiles { return .envFiles } + if policy.keepsIgnoredFiles, hasIgnoredFiles == true { return .ignoredFiles } if !isMerged { return .notMerged } if isOpenInWorkspace { return .openInWorkspace } if isProcessRunningInside { return .processRunning } - if let gracePeriod = policy.gracePeriod { - guard let lastModified, now.timeIntervalSince(lastModified) >= gracePeriod else { - return .recentlyUsed - } - } + if isRecentlyUsed(policy: policy, now: now) { return .recentlyUsed } return nil } + + /// Whether `removalBlocker` is non-nil whatever the git probes report, so a pass that only + /// removes can skip them. + func isBlockedWithoutProbes(policy: WorktreeRemovalPolicy, now: Date) -> Bool { + (!isProgramaCreated && !policy.includeExternal) + || isOpenInWorkspace + || isProcessRunningInside + || isRecentlyUsed(policy: policy, now: now) + } + + private func isRecentlyUsed(policy: WorktreeRemovalPolicy, now: Date) -> Bool { + guard let gracePeriod = policy.gracePeriod else { return false } + guard let lastModified else { return true } + return now.timeIntervalSince(lastModified) < gracePeriod + } } /// Which conditions a removal pass waives. The background monitor uses `automatic`; only a @@ -70,9 +88,12 @@ struct WorktreeRemovalPolicy: Equatable, Sendable { /// nil skips the recent-use check. var gracePeriod: TimeInterval? var includeExternal: Bool + /// Keep worktrees holding ignored files outside rebuildable directories. A manual clean + /// previews and confirms what it removes, so only the unattended pass applies this. + var keepsIgnoredFiles = false static func automatic(gracePeriodHours: Double) -> WorktreeRemovalPolicy { - WorktreeRemovalPolicy(gracePeriod: gracePeriodHours * 3600, includeExternal: false) + WorktreeRemovalPolicy(gracePeriod: gracePeriodHours * 3600, includeExternal: false, keepsIgnoredFiles: true) } } @@ -123,11 +144,19 @@ enum WorktreeStorage { // MARK: Scan + /// Which worktrees a removal-only scan may leave out: the ones `policy` blocks at `now` + /// before any git probe or size walk runs. + struct RemovalFilter: Sendable { + let policy: WorktreeRemovalPolicy + let now: Date + } + /// Scans every repo reachable from `openDirectories` or `worktrees.directory`. static func scanAll( openDirectories: [String], programaDirectory: String = ProgramaWorktreeSettings.resolvedDirectory(), - sizeWalkTimeout: TimeInterval = defaultSizeWalkTimeout + sizeWalkTimeout: TimeInterval = defaultSizeWalkTimeout, + removalFilter: RemovalFilter? = nil ) -> [WorktreeStorageEntry] { let processCwds = processWorkingDirectories() let candidates = openDirectories + programaWorktreeCandidates(in: programaDirectory) @@ -136,18 +165,21 @@ enum WorktreeStorage { programaDirectory: programaDirectory, openDirectories: openDirectories, processCwds: processCwds, - sizeWalkTimeout: sizeWalkTimeout + sizeWalkTimeout: sizeWalkTimeout, + removalFilter: removalFilter ) } /// Classifies every linked worktree of `repositories`. The main worktree, bare entries and - /// worktrees whose directory is gone are skipped. + /// worktrees whose directory is gone are skipped, and so are the ones `removalFilter` + /// blocks without probing. static func scan( repositories: [Repository], programaDirectory: String, openDirectories: [String], processCwds: Set, - sizeWalkTimeout: TimeInterval = defaultSizeWalkTimeout + sizeWalkTimeout: TimeInterval = defaultSizeWalkTimeout, + removalFilter: RemovalFilter? = nil ) -> [WorktreeStorageEntry] { let programaRoot = resolvedPath(programaDirectory) let resolvedOpenDirectories = openDirectories.map(resolvedPath) @@ -163,15 +195,18 @@ enum WorktreeStorage { guard !linked.isEmpty else { continue } let defaultBranch = GitWorktreeManager.defaultRemoteBranch(repoRoot: repository.root) for worktree in linked { - entries.append(classify( + if let entry = classify( worktree, repoRoot: repository.root, defaultBranch: defaultBranch, programaRoot: programaRoot, openDirectories: resolvedOpenDirectories, processCwds: resolvedCwds, - sizeWalkTimeout: sizeWalkTimeout - )) + sizeWalkTimeout: sizeWalkTimeout, + removalFilter: removalFilter + ) { + entries.append(entry) + } } } return entries @@ -184,42 +219,26 @@ enum WorktreeStorage { programaRoot: String, openDirectories: [String], processCwds: Set, - sizeWalkTimeout: TimeInterval - ) -> WorktreeStorageEntry { + sizeWalkTimeout: TimeInterval, + removalFilter: RemovalFilter? + ) -> WorktreeStorageEntry? { let path = worktree.path let resolved = resolvedPath(path) // Read before any git call in the worktree so our own probes cannot refresh it. - let modified = Self.lastModified(worktreePath: path) - let isDirty = GitWorktreeManager.workingTreeIsDirty(path: path) - let hasIgnoredEnvFiles = GitWorktreeManager.ignoredEnvFiles(path: path) - - var isMerged: Bool? = false - if let head = worktree.headSHA, let defaultBranch { - isMerged = GitWorktreeManager.isAncestor(head, of: defaultBranch, repoRoot: repoRoot) - } - if isMerged != true, isDirty == false, let branch = worktree.branch, - GitWorktreeManager.upstreamIsGone(branch: branch, repoRoot: repoRoot) == true { - isMerged = true - } - let hasUnpushed = worktree.headSHA.flatMap { - GitWorktreeManager.hasCommitsMissingFromRemotes($0, repoRoot: repoRoot) - } - let size = directorySize(atPath: path, timeout: sizeWalkTimeout) - - return WorktreeStorageEntry( + var entry = WorktreeStorageEntry( repoRoot: repoRoot, path: path, branch: worktree.branch, headSHA: worktree.headSHA, - sizeBytes: size.bytes, - sizePartial: size.partial, - lastModified: modified, + sizeBytes: 0, + sizePartial: true, + lastModified: Self.lastModified(worktreePath: path), isProgramaCreated: isPath(resolved, equalToOrInside: programaRoot, caseInsensitive: false) && resolved != programaRoot, - isDirty: isDirty, - hasIgnoredEnvFiles: hasIgnoredEnvFiles, - hasUnpushedOrUnmergedCommits: hasUnpushed, - isMerged: isMerged, + isDirty: nil, + hasIgnoredEnvFiles: nil, + hasUnpushedOrUnmergedCommits: nil, + isMerged: nil, isOpenInWorkspace: openDirectories.contains { isPath($0, equalToOrInside: resolved, caseInsensitive: true) }, @@ -227,6 +246,29 @@ enum WorktreeStorage { isPath($0, equalToOrInside: resolved, caseInsensitive: true) } ) + if let removalFilter, entry.isBlockedWithoutProbes(policy: removalFilter.policy, now: removalFilter.now) { + return nil + } + + let isDirty = GitWorktreeManager.workingTreeIsDirty(path: path) + entry.isDirty = isDirty + entry.hasIgnoredEnvFiles = GitWorktreeManager.ignoredEnvFiles(path: path) + entry.hasIgnoredFiles = GitWorktreeManager.ignoredFilesOutsideRebuildableDirectories(path: path) + + var isMerged: Bool? = false + if let head = worktree.headSHA, let defaultBranch { + isMerged = GitWorktreeManager.isAncestor(head, of: defaultBranch, repoRoot: repoRoot) + } + if isMerged != true, isDirty == false, let branch = worktree.branch, + GitWorktreeManager.upstreamIsGone(branch: branch, repoRoot: repoRoot) == true { + isMerged = true + } + entry.isMerged = isMerged + entry.hasUnpushedOrUnmergedCommits = worktree.headSHA.flatMap { + GitWorktreeManager.hasCommitsMissingFromRemotes($0, repoRoot: repoRoot) + } + (entry.sizeBytes, entry.sizePartial) = directorySize(atPath: path, timeout: sizeWalkTimeout) + return entry } // MARK: Clean @@ -276,6 +318,18 @@ enum WorktreeStorage { outcome.kept.append(KeptWorktree(entry: entry, reason: .blocker(.envFiles))) continue } + if policy.keepsIgnoredFiles { + switch GitWorktreeManager.ignoredFilesOutsideRebuildableDirectories(path: entry.path) { + case false?: + break + case true?: + outcome.kept.append(KeptWorktree(entry: entry, reason: .blocker(.ignoredFiles))) + continue + case nil: + outcome.kept.append(KeptWorktree(entry: entry, reason: .blocker(.statusUnknown))) + continue + } + } switch GitWorktreeManager.remove(repoRoot: entry.repoRoot, path: entry.path, force: false) { case .success: outcome.removed.append(entry) @@ -498,11 +552,15 @@ final class WorktreeStorageMonitor: @unchecked Sendable { defer { WorktreeStorage.passLock.unlock() } if ProgramaWorktreeSettings.autoRemoveMerged() { let policy = WorktreeRemovalPolicy.automatic(gracePeriodHours: ProgramaWorktreeSettings.autoRemoveAfterHours()) - let entries = WorktreeStorage.scanAll(openDirectories: Self.openDirectoriesFromMain()) + let now = Date() + let entries = WorktreeStorage.scanAll( + openDirectories: Self.openDirectoriesFromMain(), + removalFilter: WorktreeStorage.RemovalFilter(policy: policy, now: now) + ) let outcome = WorktreeStorage.clean( entries: entries, policy: policy, - now: Date(), + now: now, dryRun: false, openDirectories: Self.openDirectoriesFromMain, processCwds: WorktreeStorage.processWorkingDirectories @@ -515,7 +573,7 @@ final class WorktreeStorageMonitor: @unchecked Sendable { logger.error("git refused to remove \(kept.entry.path, privacy: .private): \(message, privacy: .private)") } } - logger.info("Worktree cleanup scanned \(entries.count, privacy: .public), removed \(outcome.removed.count, privacy: .public), reclaimed \(outcome.reclaimedBytes, privacy: .public) bytes") + logger.info("Worktree cleanup probed \(entries.count, privacy: .public) unblocked candidates, removed \(outcome.removed.count, privacy: .public), reclaimed \(outcome.reclaimedBytes, privacy: .public) bytes") } if ProgramaStorageSettings.autoCleanCaches() { let items = StorageCategories.scan() diff --git a/docs/workspace-interface.md b/docs/workspace-interface.md index 1e9458f3..4f913a34 100644 --- a/docs/workspace-interface.md +++ b/docs/workspace-interface.md @@ -147,7 +147,10 @@ shown below each session; these measurements stay cached for two minutes and ski Each **Clean** button previews the items and total size before asking for confirmation. Worktree cleaning ignores the automatic cleanup grace period but keeps dirty, unmerged, -open, or busy worktrees. Cache rows list blocked items with the reason they are kept. +open, or busy worktrees. The automatic background cleanup also keeps any worktree that holds +git-ignored files outside build and dependency folders (`node_modules`, `build`, `dist`, `target`, +`.venv` and similar), such as `.dev.vars`, keys, local databases or a nested clone. +Cache rows list blocked items with the reason they are kept. After cleaning, the panel reports the removed items and reclaimed space. Programa checks available space 60 seconds after launch and every 15 minutes. When it falls diff --git a/programaTests/WorktreeStorageTests.swift b/programaTests/WorktreeStorageTests.swift index c4b52f71..12c91dce 100644 --- a/programaTests/WorktreeStorageTests.swift +++ b/programaTests/WorktreeStorageTests.swift @@ -40,7 +40,7 @@ final class WorktreeStorageTests: XCTestCase { try configFixtureGit(["init", "--bare", "-b", "main", "remote.git"], in: tempRoot) let seed = tempRoot.appendingPathComponent("seed", isDirectory: true) try configFixtureGit(["init", "-b", "main", "seed"], in: tempRoot) - try write(".env*\nbuild/\nnode_modules/\n", to: seed.appendingPathComponent(".gitignore")) + try write(".env*\nbuild/\nnode_modules/\n.dev.vars\nscratch/\n", to: seed.appendingPathComponent(".gitignore")) try write("hello\n", to: seed.appendingPathComponent("README.md")) try configFixtureGit(["add", "."], in: seed) try configFixtureGit(["commit", "-m", "initial"], in: seed) @@ -93,7 +93,8 @@ final class WorktreeStorageTests: XCTestCase { /// Discovery plus scan, the way `scanAll` composes them, with injected open state. private func scanEntries( openDirectories: [String] = [], - processCwds: Set = [] + processCwds: Set = [], + removalFilter: WorktreeStorage.RemovalFilter? = nil ) -> [WorktreeStorageEntry] { let candidates = [repo.path] + WorktreeStorage.programaWorktreeCandidates(in: programaDir.path) let repositories = WorktreeStorage.discoverRepositories(candidateDirectories: candidates) @@ -102,7 +103,24 @@ final class WorktreeStorageTests: XCTestCase { repositories: repositories, programaDirectory: programaDir.path, openDirectories: openDirectories, - processCwds: processCwds + processCwds: processCwds, + removalFilter: removalFilter + ) + } + + /// A clean, merged Programa worktree last used long ago, with every probe answered. + private func probedEntry( + isDirty: Bool? = false, + hasIgnoredEnvFiles: Bool? = false, + isMerged: Bool? = true, + hasIgnoredFiles: Bool? = false + ) -> WorktreeStorageEntry { + WorktreeStorageEntry( + repoRoot: "/fixtures/repo", path: "/fixtures/worktrees/repo/feature", branch: "feature", + headSHA: "abc123", sizeBytes: 10, sizePartial: false, lastModified: Date(timeIntervalSince1970: 0), + isProgramaCreated: true, isDirty: isDirty, hasIgnoredEnvFiles: hasIgnoredEnvFiles, + hasUnpushedOrUnmergedCommits: false, isMerged: isMerged, isOpenInWorkspace: false, + isProcessRunningInside: false, hasIgnoredFiles: hasIgnoredFiles ) } @@ -428,6 +446,146 @@ final class WorktreeStorageTests: XCTestCase { XCTAssertTrue(isListed(worktree), "dry run leaves git's worktree list") } + // MARK: - Ignored files outside build directories + + /// Build output and dependency folders are rebuilt on demand, so they never pin a + /// worktree, even when a dependency is a git checkout of its own. + func testOnlyRebuildableIgnoredDirectoriesDoNotBlockAutomaticRemoval() throws { + let worktree = try addWorktree("rebuildable") + try write("module.exports = 1\n", to: worktree.appendingPathComponent("node_modules/pkg/index.js")) + try write("obj\n", to: worktree.appendingPathComponent("build/out/main.o")) + let checkout = worktree.appendingPathComponent("node_modules/git-dep", isDirectory: true) + try FileManager.default.createDirectory(at: checkout, withIntermediateDirectories: true) + try configFixtureGit(["init", "-q"], in: checkout) + + let entries = scanEntries() + let scanned = try entry(worktree, in: entries) + XCTAssertEqual(scanned.isDirty, false) + XCTAssertEqual(scanned.hasIgnoredFiles, false) + XCTAssertNil(scanned.removalBlocker(policy: automatic, now: later)) + + let outcome = clean(entries, policy: automatic, now: later) + XCTAssertEqual(removedPaths(outcome), [resolved(worktree.path)]) + XCTAssertFalse(exists(worktree)) + } + + /// The background pass deletes without asking, so any ignored file a build would not + /// recreate (here a Cloudflare `.dev.vars`) keeps the worktree. A manual clean, which + /// previews and confirms, is not held back. + func testIgnoredLocalFileKeepsWorktreeFromAutomaticRemovalOnly() throws { + let worktree = try addWorktree("dev-vars") + let secrets = worktree.appendingPathComponent(".dev.vars") + try write("API_TOKEN=local\n", to: secrets) + + let entries = scanEntries() + let scanned = try entry(worktree, in: entries) + XCTAssertEqual(scanned.isDirty, false, "ignored files are not dirt") + XCTAssertEqual(scanned.hasIgnoredEnvFiles, false, ".dev.vars is not a .env* file") + XCTAssertEqual(scanned.hasIgnoredFiles, true) + XCTAssertEqual(scanned.removalBlocker(policy: automatic, now: later), .ignoredFiles) + XCTAssertNil(scanned.removalBlocker(policy: noGrace, now: later)) + + let outcome = clean(entries, policy: automatic, now: later) + XCTAssertTrue(outcome.removed.isEmpty, "removed: \(outcome.removed.map(\.path))") + XCTAssertEqual(keptReason(worktree, in: outcome), .blocker(.ignoredFiles)) + XCTAssertEqual(keptReason(worktree, in: outcome)?.code, "ignored_files") + XCTAssertTrue(exists(secrets), ".dev.vars survives the background pass") + } + + /// A clone inside an ignored folder can hold commits that exist nowhere else. + func testIgnoredNestedRepositoryKeepsWorktree() throws { + let worktree = try addWorktree("nested-clone") + let clone = worktree.appendingPathComponent("scratch/clone", isDirectory: true) + try FileManager.default.createDirectory(at: clone, withIntermediateDirectories: true) + try configFixtureGit(["init", "-q", "-b", "main"], in: clone) + try write("local work\n", to: clone.appendingPathComponent("notes.txt")) + try configFixtureGit(["add", "notes.txt"], in: clone) + try configFixtureGit(["commit", "-q", "-m", "unpushed"], in: clone) + + let entries = scanEntries() + let scanned = try entry(worktree, in: entries) + XCTAssertEqual(scanned.isDirty, false) + XCTAssertEqual(scanned.hasIgnoredFiles, true) + XCTAssertEqual(scanned.removalBlocker(policy: automatic, now: later), .ignoredFiles) + + let outcome = clean(entries, policy: automatic, now: later) + XCTAssertEqual(keptReason(worktree, in: outcome), .blocker(.ignoredFiles)) + XCTAssertTrue(exists(clone.appendingPathComponent(".git")), "the nested repository survives") + } + + /// An ignored file created after the scan must still stop the background removal. + func testCleanRechecksIgnoredFilesBeforeAutomaticRemoval() throws { + let worktree = try addWorktree("dev-vars-later") + let entries = scanEntries() + XCTAssertNil(try entry(worktree, in: entries).removalBlocker(policy: automatic, now: later)) + + let secrets = worktree.appendingPathComponent(".dev.vars") + try write("API_TOKEN=late\n", to: secrets) + + let outcome = clean(entries, policy: automatic, now: later) + XCTAssertTrue(outcome.removed.isEmpty, "removed: \(outcome.removed.map(\.path))") + XCTAssertEqual(keptReason(worktree, in: outcome), .blocker(.ignoredFiles)) + XCTAssertTrue(exists(secrets)) + XCTAssertTrue(isListed(worktree)) + } + + // MARK: - Unanswered probes + + /// A probe git could not answer must never read as "safe to delete". + func testUnansweredProbeBlocksAsStatusUnknown() { + XCTAssertNil(probedEntry().removalBlocker(policy: automatic, now: later)) + for (label, scanned) in [ + ("dirty", probedEntry(isDirty: nil)), + ("env files", probedEntry(hasIgnoredEnvFiles: nil)), + ("merged", probedEntry(isMerged: nil)), + ] { + XCTAssertEqual(scanned.removalBlocker(policy: automatic, now: later), .statusUnknown, label) + XCTAssertEqual(scanned.removalBlocker(policy: noGrace, now: later), .statusUnknown, label) + } + + let ignoredUnknown = probedEntry(hasIgnoredFiles: nil) + XCTAssertEqual(ignoredUnknown.removalBlocker(policy: automatic, now: later), .statusUnknown) + XCTAssertNil( + ignoredUnknown.removalBlocker(policy: noGrace, now: later), + "a manual clean does not apply the ignored-file rule, so its probe does not matter" + ) + } + + // MARK: - Removal-only scan + + /// The background pass skips git and the size walk for worktrees a cheap check already + /// keeps, and still removes exactly what a full scan would. + func testRemovalFilterSkipsWorktreesBlockedWithoutProbesAndRemovesTheSameSet() throws { + let opened = try addWorktree("filter-open") + let idle = try addWorktree("filter-idle") + let external = try addWorktree("filter-outside", parent: tempRoot.appendingPathComponent("external", isDirectory: true)) + let dirty = try addWorktree("filter-dirty") + try write("draft\n", to: dirty.appendingPathComponent("notes.txt")) + + let full = scanEntries(openDirectories: [opened.path]) + let filtered = scanEntries( + openDirectories: [opened.path], + removalFilter: WorktreeStorage.RemovalFilter(policy: automatic, now: later) + ) + + XCTAssertEqual( + Set(filtered.map { resolved($0.path) }), + [resolved(idle.path), resolved(dirty.path)], + "open and external worktrees are left out; the dirty one needs git to be judged" + ) + XCTAssertEqual(try entry(dirty, in: filtered).removalBlocker(policy: automatic, now: later), .dirty) + XCTAssertEqual(try entry(external, in: full).removalBlocker(policy: automatic, now: later), .external) + XCTAssertTrue( + scanEntries(removalFilter: WorktreeStorage.RemovalFilter(policy: automatic, now: Date())).isEmpty, + "every worktree was just created, so none is past the grace period" + ) + + let preview = clean(full, policy: automatic, now: later, dryRun: true) + let outcome = clean(filtered, policy: automatic, now: later, openDirectories: { [opened.path] }) + XCTAssertEqual(removedPaths(outcome), removedPaths(preview)) + XCTAssertEqual(removedPaths(outcome), [resolved(idle.path)]) + } + // MARK: - Path containment /// Containment decides whether a cwd or open directory protects a worktree. A prefix match @@ -447,3 +605,68 @@ final class WorktreeStorageTests: XCTestCase { XCTAssertFalse(WorktreeStorage.isPath("/A/FooBar", equalToOrInside: "/a/foo", caseInsensitive: true)) } } + +/// The settings that turn on unattended deletion. A flipped default, or a zero or boolean grace +/// period slipping through, would remove worktrees merged minutes ago. +final class WorktreeAutoRemoveSettingsTests: XCTestCase { + private var settingsURL: URL! + + override func setUpWithError() throws { + try super.setUpWithError() + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("worktree-settings-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + settingsURL = directory.appendingPathComponent("settings.json") + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: settingsURL.deletingLastPathComponent()) + settingsURL = nil + try super.tearDownWithError() + } + + private func writeSettings(_ contents: String) throws { + try contents.write(to: settingsURL, atomically: true, encoding: .utf8) + } + + func testMissingOrUnusableSettingsFallBackToDefaults() throws { + XCTAssertTrue(ProgramaWorktreeSettings.autoRemoveMerged(settingsURL: settingsURL)) + XCTAssertEqual(ProgramaWorktreeSettings.autoRemoveAfterHours(settingsURL: settingsURL), 24) + XCTAssertTrue(ProgramaStorageSettings.autoCleanCaches(settingsURL: settingsURL)) + + for contents in [ + "", "not JSON", "[]", "{}", "{\"worktrees\":{},\"storage\":{}}", + "{\"worktrees\":{\"autoRemoveMerged\":\"false\",\"autoRemoveAfterHours\":\"48\"}," + + "\"storage\":{\"autoCleanCaches\":\"false\"}}", + "{\"worktrees\":{\"autoRemoveMerged\":null,\"autoRemoveAfterHours\":null},\"storage\":{\"autoCleanCaches\":null}}", + ] { + try writeSettings(contents) + XCTAssertTrue(ProgramaWorktreeSettings.autoRemoveMerged(settingsURL: settingsURL), contents) + XCTAssertEqual(ProgramaWorktreeSettings.autoRemoveAfterHours(settingsURL: settingsURL), 24, contents) + XCTAssertTrue(ProgramaStorageSettings.autoCleanCaches(settingsURL: settingsURL), contents) + } + } + + func testExplicitFalseTurnsAutomaticCleanupOffInJSONC() throws { + try writeSettings(""" + { + // keep everything + "worktrees": { "autoRemoveMerged": false, }, + "storage": { "autoCleanCaches": false }, + } + """) + XCTAssertFalse(ProgramaWorktreeSettings.autoRemoveMerged(settingsURL: settingsURL)) + XCTAssertFalse(ProgramaStorageSettings.autoCleanCaches(settingsURL: settingsURL)) + } + + func testGracePeriodIsClampedToAtLeastOneHour() throws { + let cases: [(json: String, hours: Double)] = [ + ("0", 1), ("0.5", 1), ("-12", 1), ("1", 1), ("1.5", 1.5), ("48", 48), + ("true", 24), ("false", 24), ("1e999", 24), + ] + for (json, hours) in cases { + try writeSettings("{\"worktrees\":{\"autoRemoveAfterHours\":\(json)}}") + XCTAssertEqual(ProgramaWorktreeSettings.autoRemoveAfterHours(settingsURL: settingsURL), hours, json) + } + } +} From 3f336f9d931f5e0a4ca5a4bf9f9fe702ed71ed22 Mon Sep 17 00:00:00 2001 From: arzafran Date: Wed, 4 Nov 2026 20:08:37 -0300 Subject: [PATCH 2/2] fix: stop nested repositories from counting as .env files in worktree cleanup (INC-486) --- Sources/GitWorktreeManager.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/GitWorktreeManager.swift b/Sources/GitWorktreeManager.swift index e023a220..405fe725 100644 --- a/Sources/GitWorktreeManager.swift +++ b/Sources/GitWorktreeManager.swift @@ -344,7 +344,13 @@ struct GitWorktreeManager { ":(glob)**/.env*", ":(exclude,glob)**/node_modules/**", ] ), result.outcome == .exited, result.exitStatus == 0, let stdout = result.stdout else { return nil } - return !stdout.isEmpty + // git lists every nested repository as `dir/` whatever the pathspec says, so only + // entries that are themselves `.env*` files count. + return stdout.split(separator: "\0").contains { entry in + guard !entry.hasSuffix("/") else { return false } + let components = entry.split(separator: "/") + return components.last?.hasPrefix(".env") == true && !components.dropLast().contains("node_modules") + } } /// Ignored directories whose contents a build or package install recreates. Matched against