native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Auto-delete archived worktrees after a configurable period (#214)

Migrate archived worktree tracking from a flat ID list to a
[Worktree.ID: Date] dictionary so each archive timestamp is preserved.
Add a new AutoDeletePeriod enum (1/3/7/14/30 days) to replace the raw
Int? setting, making illegal values unrepresentable. Expired worktrees
are automatically deleted on repository load or when the setting is
changed. Shortening the window shows a confirmation alert when existing
worktrees would be immediately affected.

authored by

Stefano Bertagno and committed by
GitHub
666d440d 98690fd4

+848 -73
+29 -11
supacode/Clients/Repositories/RepositoryPersistenceClient.swift
··· 2 2 import Foundation 3 3 import Sharing 4 4 5 + nonisolated let archivedWorktreeDatesStorageKey = "archivedWorktreeDates" 6 + nonisolated let secondsPerDay: TimeInterval = 86400 7 + 5 8 struct RepositoryPersistenceClient { 6 9 var loadRoots: @Sendable () async -> [String] 7 10 var saveRoots: @Sendable ([String]) async -> Void 8 11 var loadPinnedWorktreeIDs: @Sendable () async -> [Worktree.ID] 9 12 var savePinnedWorktreeIDs: @Sendable ([Worktree.ID]) async -> Void 10 - var loadArchivedWorktreeIDs: @Sendable () async -> [Worktree.ID] 11 - var saveArchivedWorktreeIDs: @Sendable ([Worktree.ID]) async -> Void 13 + var loadArchivedWorktreeDates: @Sendable () async -> [Worktree.ID: Date] 14 + var saveArchivedWorktreeDates: @Sendable ([Worktree.ID: Date]) async -> Void 12 15 var loadRepositoryOrderIDs: @Sendable () async -> [Repository.ID] 13 16 var saveRepositoryOrderIDs: @Sendable ([Repository.ID]) async -> Void 14 17 var loadWorktreeOrderByRepository: @Sendable () async -> [Repository.ID: [Worktree.ID]] ··· 40 43 $0 = ids 41 44 } 42 45 }, 43 - loadArchivedWorktreeIDs: { 44 - @Shared(.appStorage("archivedWorktreeIDs")) var archived: [Worktree.ID] = [] 45 - return RepositoryPathNormalizer.normalize(archived) 46 + loadArchivedWorktreeDates: { 47 + let logger = SupaLogger("RepositoryPersistence") 48 + @Shared(.appStorage(archivedWorktreeDatesStorageKey)) var dates: [Worktree.ID: Date] = [:] 49 + guard dates.isEmpty else { 50 + return RepositoryPathNormalizer.normalizeDictionaryKeys(dates) 51 + } 52 + // Migrate from legacy key. 53 + @Shared(.appStorage("archivedWorktreeIDs")) var legacyIDs: [Worktree.ID] = [] 54 + guard !legacyIDs.isEmpty else { return [:] } 55 + let now = Date() 56 + var migrated: [Worktree.ID: Date] = [:] 57 + for id in RepositoryPathNormalizer.normalize(legacyIDs) { 58 + migrated[id] = now 59 + } 60 + logger.info("Migrating \(migrated.count) archived worktree(s) from legacy key.") 61 + $dates.withLock { $0 = migrated } 62 + $legacyIDs.withLock { $0 = [] } 63 + return migrated 46 64 }, 47 - saveArchivedWorktreeIDs: { ids in 48 - @Shared(.appStorage("archivedWorktreeIDs")) var sharedArchived: [Worktree.ID] = [] 49 - let normalized = RepositoryPathNormalizer.normalize(ids) 50 - $sharedArchived.withLock { 65 + saveArchivedWorktreeDates: { dates in 66 + @Shared(.appStorage(archivedWorktreeDatesStorageKey)) var sharedDates: [Worktree.ID: Date] = [:] 67 + let normalized = RepositoryPathNormalizer.normalizeDictionaryKeys(dates) 68 + $sharedDates.withLock { 51 69 $0 = normalized 52 70 } 53 71 }, ··· 90 108 saveRoots: { _ in }, 91 109 loadPinnedWorktreeIDs: { [] }, 92 110 savePinnedWorktreeIDs: { _ in }, 93 - loadArchivedWorktreeIDs: { [] }, 94 - saveArchivedWorktreeIDs: { _ in }, 111 + loadArchivedWorktreeDates: { [:] }, 112 + saveArchivedWorktreeDates: { _ in }, 95 113 loadRepositoryOrderIDs: { [] }, 96 114 saveRepositoryOrderIDs: { _ in }, 97 115 loadWorktreeOrderByRepository: { [:] },
+7
supacode/Features/App/Reducer/AppFeature.swift
··· 288 288 ) 289 289 ), 290 290 .send( 291 + .repositories( 292 + .setAutoDeleteArchivedWorktreesAfterDays( 293 + settings.autoDeleteArchivedWorktreesAfterDays 294 + ) 295 + ) 296 + ), 297 + .send( 291 298 .updates( 292 299 .applySettings( 293 300 updateChannel: settings.updateChannel,
+83 -30
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 82 82 var deletingWorktreeIDs: Set<Worktree.ID> = [] 83 83 var removingRepositoryIDs: Set<Repository.ID> = [] 84 84 var pinnedWorktreeIDs: [Worktree.ID] = [] 85 - var archivedWorktreeIDs: [Worktree.ID] = [] 85 + var archivedWorktreeDates: [Worktree.ID: Date] = [:] 86 + var autoDeleteArchivedWorktreesAfterDays: AutoDeletePeriod? 86 87 var mergedWorktreeAction: MergedWorktreeAction? 87 88 var moveNotifiedWorktreeToTop = true 88 89 var lastFocusedWorktreeID: Worktree.ID? ··· 130 131 case setOpenPanelPresented(Bool) 131 132 case loadPersistedRepositories 132 133 case pinnedWorktreeIDsLoaded([Worktree.ID]) 133 - case archivedWorktreeIDsLoaded([Worktree.ID]) 134 + case archivedWorktreeDatesLoaded([Worktree.ID: Date]) 134 135 case repositoryOrderIDsLoaded([Repository.ID]) 135 136 case worktreeOrderByRepositoryLoaded([Repository.ID: [Worktree.ID]]) 136 137 case lastFocusedWorktreeIDLoaded(Worktree.ID?) ··· 239 240 ) 240 241 case setGithubIntegrationEnabled(Bool) 241 242 case setMergedWorktreeAction(MergedWorktreeAction?) 243 + case setAutoDeleteArchivedWorktreesAfterDays(AutoDeletePeriod?) 244 + case autoDeleteExpiredArchivedWorktrees 242 245 case setMoveNotifiedWorktreeToTop(Bool) 243 246 case pullRequestAction(Worktree.ID, PullRequestAction) 244 247 case showToast(StatusToast) ··· 315 318 @Dependency(GithubIntegrationClient.self) private var githubIntegration 316 319 @Dependency(RepositoryPersistenceClient.self) private var repositoryPersistence 317 320 @Dependency(ShellClient.self) private var shellClient 321 + @Dependency(\.date.now) private var now 318 322 @Dependency(\.uuid) private var uuid 319 323 320 324 var body: some Reducer<State, Action> { ··· 323 327 case .task: 324 328 return .run { send in 325 329 let pinned = await repositoryPersistence.loadPinnedWorktreeIDs() 326 - let archived = await repositoryPersistence.loadArchivedWorktreeIDs() 330 + let archived = await repositoryPersistence.loadArchivedWorktreeDates() 327 331 let lastFocused = await repositoryPersistence.loadLastFocusedWorktreeID() 328 332 let repositoryOrderIDs = await repositoryPersistence.loadRepositoryOrderIDs() 329 333 let worktreeOrderByRepository = 330 334 await repositoryPersistence.loadWorktreeOrderByRepository() 331 335 await send(.pinnedWorktreeIDsLoaded(pinned)) 332 - await send(.archivedWorktreeIDsLoaded(archived)) 336 + await send(.archivedWorktreeDatesLoaded(archived)) 333 337 await send(.repositoryOrderIDsLoaded(repositoryOrderIDs)) 334 338 await send(.worktreeOrderByRepositoryLoaded(worktreeOrderByRepository)) 335 339 await send(.lastFocusedWorktreeIDLoaded(lastFocused)) ··· 340 344 state.pinnedWorktreeIDs = pinnedWorktreeIDs 341 345 return .none 342 346 343 - case .archivedWorktreeIDsLoaded(let archivedWorktreeIDs): 344 - state.archivedWorktreeIDs = archivedWorktreeIDs 347 + case .archivedWorktreeDatesLoaded(let archivedWorktreeDates): 348 + state.archivedWorktreeDates = archivedWorktreeDates 345 349 return .none 346 350 347 351 case .repositoryOrderIDsLoaded(let repositoryOrderIDs): ··· 447 451 }) 448 452 } 449 453 if applyResult.didPruneArchivedWorktreeIDs { 450 - let archivedWorktreeIDs = state.archivedWorktreeIDs 454 + let archivedWorktreeDates = state.archivedWorktreeDates 451 455 allEffects.append( 452 456 .run { _ in 453 - await repositoryPersistence.saveArchivedWorktreeIDs(archivedWorktreeIDs) 457 + await repositoryPersistence.saveArchivedWorktreeDates(archivedWorktreeDates) 454 458 } 455 459 ) 456 460 } 461 + if state.autoDeleteArchivedWorktreesAfterDays != nil { 462 + allEffects.append(.send(.autoDeleteExpiredArchivedWorktrees)) 463 + } 457 464 return .merge(allEffects) 458 465 459 466 case .openRepositories(let urls): ··· 548 555 }) 549 556 } 550 557 if applyResult.didPruneArchivedWorktreeIDs { 551 - let archivedWorktreeIDs = state.archivedWorktreeIDs 558 + let archivedWorktreeDates = state.archivedWorktreeDates 552 559 allEffects.append( 553 560 .run { _ in 554 - await repositoryPersistence.saveArchivedWorktreeIDs(archivedWorktreeIDs) 561 + await repositoryPersistence.saveArchivedWorktreeDates(archivedWorktreeDates) 555 562 } 556 563 ) 557 564 } 565 + if state.autoDeleteArchivedWorktreesAfterDays != nil { 566 + allEffects.append(.send(.autoDeleteExpiredArchivedWorktrees)) 567 + } 558 568 return .merge(allEffects) 559 569 560 570 case .selectionChanged(let selections, let focusTerminal): ··· 1477 1487 } 1478 1488 didUpdateWorktreeOrder = true 1479 1489 } 1480 - state.archivedWorktreeIDs.append(worktreeID) 1490 + state.archivedWorktreeDates[worktreeID] = now 1481 1491 if selectionWasRemoved { 1482 1492 let nextWorktreeID = nextSelection ?? firstAvailableWorktreeID(in: repositoryID, state: state) 1483 1493 state.selection = nextWorktreeID.map(SidebarSelection.worktree) 1484 1494 } 1485 1495 } 1486 - let archivedWorktreeIDs = state.archivedWorktreeIDs 1496 + let archivedWorktreeDates = state.archivedWorktreeDates 1487 1497 let repositories = state.repositories 1488 1498 let selectedWorktree = state.worktree(for: state.selectedWorktreeID) 1489 1499 let selectionChanged = selectionDidChange( ··· 1495 1505 var effects: [Effect<Action>] = [ 1496 1506 .send(.delegate(.repositoriesChanged(repositories))), 1497 1507 .run { _ in 1498 - await repositoryPersistence.saveArchivedWorktreeIDs(archivedWorktreeIDs) 1508 + await repositoryPersistence.saveArchivedWorktreeDates(archivedWorktreeDates) 1499 1509 }, 1500 1510 ] 1501 1511 if wasPinned { ··· 1523 1533 if !state.isWorktreeArchived(worktreeID) { 1524 1534 return .none 1525 1535 } 1526 - withAnimation { 1527 - state.archivedWorktreeIDs.removeAll { $0 == worktreeID } 1536 + _ = withAnimation { 1537 + state.archivedWorktreeDates.removeValue(forKey: worktreeID) 1528 1538 } 1529 - let archivedWorktreeIDs = state.archivedWorktreeIDs 1539 + let archivedWorktreeDates = state.archivedWorktreeDates 1530 1540 let repositories = state.repositories 1531 1541 return .merge( 1532 1542 .send(.delegate(.repositoriesChanged(repositories))), 1533 1543 .run { _ in 1534 - await repositoryPersistence.saveArchivedWorktreeIDs(archivedWorktreeIDs) 1544 + await repositoryPersistence.saveArchivedWorktreeDates(archivedWorktreeDates) 1535 1545 } 1536 1546 ) 1537 1547 ··· 1639 1649 guard let repository = state.repositories[id: repositoryID], 1640 1650 let worktree = repository.worktrees[id: worktreeID] 1641 1651 else { 1652 + repositoriesLogger.debug( 1653 + "deleteWorktreeConfirmed: worktree \(worktreeID) not found in repository \(repositoryID)." 1654 + ) 1642 1655 return .none 1643 1656 } 1644 1657 if state.archivingWorktreeIDs.contains(worktree.id) { ··· 1751 1764 state.pendingTerminalFocusWorktreeIDs.remove(worktreeID) 1752 1765 state.worktreeInfoByID.removeValue(forKey: worktreeID) 1753 1766 state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } 1754 - state.archivedWorktreeIDs.removeAll { $0 == worktreeID } 1767 + state.archivedWorktreeDates.removeValue(forKey: worktreeID) 1755 1768 if var order = state.worktreeOrderByRepository[repositoryID] { 1756 1769 order.removeAll { $0 == worktreeID } 1757 1770 if order.isEmpty { ··· 1795 1808 ) 1796 1809 } 1797 1810 if wasArchived { 1798 - let archivedWorktreeIDs = state.archivedWorktreeIDs 1811 + let archivedWorktreeDates = state.archivedWorktreeDates 1799 1812 followupEffects.append( 1800 1813 .run { _ in 1801 - await repositoryPersistence.saveArchivedWorktreeIDs(archivedWorktreeIDs) 1814 + await repositoryPersistence.saveArchivedWorktreeDates(archivedWorktreeDates) 1802 1815 } 1803 1816 ) 1804 1817 } ··· 1990 2003 var effects: [Effect<Action>] = [ 1991 2004 .run { _ in 1992 2005 await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 1993 - } 2006 + }, 1994 2007 ] 1995 2008 if didUpdateWorktreeOrder { 1996 2009 let worktreeOrderByRepository = state.worktreeOrderByRepository ··· 2017 2030 var effects: [Effect<Action>] = [ 2018 2031 .run { _ in 2019 2032 await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 2020 - } 2033 + }, 2021 2034 ] 2022 2035 if didUpdateWorktreeOrder { 2023 2036 let worktreeOrderByRepository = state.worktreeOrderByRepository ··· 2668 2681 state.mergedWorktreeAction = action 2669 2682 return .none 2670 2683 2684 + case .setAutoDeleteArchivedWorktreesAfterDays(let days): 2685 + state.autoDeleteArchivedWorktreesAfterDays = days 2686 + guard days != nil else { return .none } 2687 + return .send(.autoDeleteExpiredArchivedWorktrees) 2688 + 2689 + case .autoDeleteExpiredArchivedWorktrees: 2690 + guard let period = state.autoDeleteArchivedWorktreesAfterDays else { return .none } 2691 + let cutoff = now.addingTimeInterval(-Double(period.rawValue) * secondsPerDay) 2692 + var targets: [(Worktree.ID, Repository.ID)] = [] 2693 + for (worktreeID, archivedDate) in state.archivedWorktreeDates { 2694 + guard archivedDate <= cutoff else { continue } 2695 + guard !state.deletingWorktreeIDs.contains(worktreeID), 2696 + !state.deleteScriptWorktreeIDs.contains(worktreeID), 2697 + !state.archivingWorktreeIDs.contains(worktreeID) 2698 + else { continue } 2699 + guard let repository = state.repositories.first(where: { $0.worktrees[id: worktreeID] != nil }), 2700 + let worktree = repository.worktrees[id: worktreeID] 2701 + else { 2702 + repositoriesLogger.debug( 2703 + "Auto-delete skipping expired worktree \(worktreeID): not found in loaded repositories." 2704 + ) 2705 + continue 2706 + } 2707 + guard !state.isMainWorktree(worktree) else { 2708 + repositoriesLogger.debug( 2709 + "Auto-delete skipping expired worktree \(worktreeID): main worktree cannot be deleted." 2710 + ) 2711 + continue 2712 + } 2713 + targets.append((worktreeID, repository.id)) 2714 + } 2715 + guard !targets.isEmpty else { return .none } 2716 + repositoriesLogger.info("Auto-deleting \(targets.count) expired archived worktree(s).") 2717 + return .merge( 2718 + targets.map { worktreeID, repositoryID in 2719 + .send(.deleteWorktreeConfirmed(worktreeID, repositoryID)) 2720 + } 2721 + ) 2722 + 2671 2723 case .setMoveNotifiedWorktreeToTop(let isEnabled): 2672 2724 state.moveNotifiedWorktreeToTop = isEnabled 2673 2725 return .none ··· 3027 3079 selection == .archivedWorktrees 3028 3080 } 3029 3081 3082 + var archivedWorktreeIDs: [Worktree.ID] { 3083 + Array(archivedWorktreeDates.keys) 3084 + } 3085 + 3030 3086 var archivedWorktreeIDSet: Set<Worktree.ID> { 3031 - Set(archivedWorktreeIDs) 3087 + Set(archivedWorktreeDates.keys) 3032 3088 } 3033 3089 3034 3090 func isWorktreeArchived(_ id: Worktree.ID) -> Bool { 3035 - archivedWorktreeIDSet.contains(id) 3091 + archivedWorktreeDates[id] != nil 3036 3092 } 3037 3093 3038 3094 func worktreeInfo(for worktreeID: Worktree.ID) -> WorktreeInfoEntry? { ··· 3940 3996 availableWorktreeIDs: Set<Worktree.ID>, 3941 3997 state: inout RepositoriesFeature.State 3942 3998 ) -> Bool { 3943 - let pruned = state.archivedWorktreeIDs.filter { availableWorktreeIDs.contains($0) } 3944 - if pruned != state.archivedWorktreeIDs { 3945 - state.archivedWorktreeIDs = pruned 3946 - return true 3947 - } 3948 - return false 3999 + let before = state.archivedWorktreeDates.count 4000 + state.archivedWorktreeDates = state.archivedWorktreeDates.filter { availableWorktreeIDs.contains($0.key) } 4001 + return state.archivedWorktreeDates.count != before 3949 4002 } 3950 4003 3951 4004 private func firstAvailableWorktreeID(
+19
supacode/Features/Settings/BusinessLogic/RepositoryPersistenceKeys.swift
··· 117 117 return normalized 118 118 } 119 119 120 + static func normalizeDictionaryKeys( 121 + _ dictionary: [String: Date] 122 + ) -> [String: Date] { 123 + var normalized: [String: Date] = [:] 124 + normalized.reserveCapacity(dictionary.count) 125 + for (key, value) in dictionary { 126 + let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) 127 + guard !trimmed.isEmpty else { continue } 128 + let resolved = URL(fileURLWithPath: trimmed) 129 + .standardizedFileURL 130 + .path(percentEncoded: false) 131 + // On collision, keep the more recent (greater) date. 132 + if let existing = normalized[resolved], existing > value { 133 + continue 134 + } 135 + normalized[resolved] = value 136 + } 137 + return normalized 138 + } 120 139 }
+37
supacode/Features/Settings/Models/GlobalSettings.swift
··· 1 + nonisolated enum AutoDeletePeriod: Int, Codable, CaseIterable, Comparable, Sendable { 2 + #if DEBUG 3 + case immediately = 0 4 + #endif 5 + case oneDay = 1 6 + case threeDays = 3 7 + case sevenDays = 7 8 + case fourteenDays = 14 9 + case thirtyDays = 30 10 + 11 + var label: String { 12 + switch self { 13 + #if DEBUG 14 + case .immediately: "Immediately (debug)" 15 + #endif 16 + case .oneDay: "After 1 day" 17 + case .threeDays: "After 3 days" 18 + case .sevenDays: "After 7 days" 19 + case .fourteenDays: "After 14 days" 20 + case .thirtyDays: "After 30 days" 21 + } 22 + } 23 + 24 + static func < (lhs: Self, rhs: Self) -> Bool { 25 + lhs.rawValue < rhs.rawValue 26 + } 27 + } 28 + 1 29 nonisolated struct GlobalSettings: Codable, Equatable, Sendable { 2 30 var appearanceMode: AppearanceMode 3 31 var defaultEditorID: String ··· 22 50 var pullRequestMergeStrategy: PullRequestMergeStrategy 23 51 var terminalThemeSyncEnabled: Bool 24 52 var restoreTerminalLayoutEnabled: Bool 53 + var autoDeleteArchivedWorktreesAfterDays: AutoDeletePeriod? 25 54 var shortcutOverrides: [AppShortcutID: AppShortcutOverride] 26 55 27 56 static let `default` = GlobalSettings( ··· 48 77 terminalThemeSyncEnabled: false, 49 78 restoreTerminalLayoutEnabled: false, 50 79 defaultWorktreeBaseDirectoryPath: nil, 80 + autoDeleteArchivedWorktreesAfterDays: nil, 51 81 shortcutOverrides: [:] 52 82 ) 53 83 ··· 75 105 terminalThemeSyncEnabled: Bool = false, 76 106 restoreTerminalLayoutEnabled: Bool = false, 77 107 defaultWorktreeBaseDirectoryPath: String? = nil, 108 + autoDeleteArchivedWorktreesAfterDays: AutoDeletePeriod? = nil, 78 109 shortcutOverrides: [AppShortcutID: AppShortcutOverride] = [:] 79 110 ) { 80 111 self.appearanceMode = appearanceMode ··· 100 131 self.terminalThemeSyncEnabled = terminalThemeSyncEnabled 101 132 self.restoreTerminalLayoutEnabled = restoreTerminalLayoutEnabled 102 133 self.defaultWorktreeBaseDirectoryPath = defaultWorktreeBaseDirectoryPath 134 + self.autoDeleteArchivedWorktreesAfterDays = autoDeleteArchivedWorktreesAfterDays 103 135 self.shortcutOverrides = shortcutOverrides 104 136 } 105 137 ··· 189 221 defaultWorktreeBaseDirectoryPath = 190 222 try container.decodeIfPresent(String.self, forKey: .defaultWorktreeBaseDirectoryPath) 191 223 ?? Self.default.defaultWorktreeBaseDirectoryPath 224 + // Reject unrecognized values from corrupted or hand-edited settings files. 225 + autoDeleteArchivedWorktreesAfterDays = 226 + (try container.decodeIfPresent(Int.self, forKey: .autoDeleteArchivedWorktreesAfterDays)) 227 + .flatMap(AutoDeletePeriod.init(rawValue:)) 228 + ?? Self.default.autoDeleteArchivedWorktreesAfterDays 192 229 shortcutOverrides = 193 230 try container.decodeIfPresent([AppShortcutID: AppShortcutOverride].self, forKey: .shortcutOverrides) 194 231 ?? Self.default.shortcutOverrides
+58
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 29 29 var terminalThemeSyncEnabled: Bool 30 30 var restoreTerminalLayoutEnabled: Bool 31 31 var defaultWorktreeBaseDirectoryPath: String 32 + var autoDeleteArchivedWorktreesAfterDays: AutoDeletePeriod? 32 33 var shortcutOverrides: [AppShortcutID: AppShortcutOverride] 33 34 // nil = settings window closed, non-nil = open to this section. 34 35 // The view layer opens the settings window when this becomes non-nil. ··· 61 62 pullRequestMergeStrategy = settings.pullRequestMergeStrategy 62 63 terminalThemeSyncEnabled = settings.terminalThemeSyncEnabled 63 64 restoreTerminalLayoutEnabled = settings.restoreTerminalLayoutEnabled 65 + autoDeleteArchivedWorktreesAfterDays = settings.autoDeleteArchivedWorktreesAfterDays 64 66 shortcutOverrides = settings.shortcutOverrides 65 67 defaultWorktreeBaseDirectoryPath = 66 68 SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) ?? "" ··· 93 95 defaultWorktreeBaseDirectoryPath: SupacodePaths.normalizedWorktreeBaseDirectoryPath( 94 96 defaultWorktreeBaseDirectoryPath 95 97 ), 98 + autoDeleteArchivedWorktreesAfterDays: autoDeleteArchivedWorktreesAfterDays, 96 99 shortcutOverrides: shortcutOverrides 97 100 ) 98 101 } ··· 108 111 case updateShortcut(id: AppShortcutID, override: AppShortcutOverride?) 109 112 case toggleShortcutEnabled(id: AppShortcutID, enabled: Bool) 110 113 case resetAllShortcuts 114 + case requestAutoDeleteDaysChange(AutoDeletePeriod?) 115 + case resolvedAutoDeleteAffectedCount(AutoDeletePeriod, affectedCount: Int) 111 116 case repositorySettings(RepositorySettingsFeature.Action) 112 117 case alert(PresentationAction<Alert>) 113 118 case delegate(Delegate) ··· 117 122 enum Alert: Equatable { 118 123 case dismiss 119 124 case openSystemNotificationSettings 125 + case confirmAutoDeleteDaysChange(AutoDeletePeriod) 120 126 } 121 127 122 128 @CasePathable ··· 125 131 } 126 132 127 133 @Dependency(AnalyticsClient.self) private var analyticsClient 134 + @Dependency(RepositoryPersistenceClient.self) private var repositoryPersistence 128 135 @Dependency(SystemNotificationClient.self) private var systemNotificationClient 136 + @Dependency(\.date.now) private var now 129 137 130 138 var body: some Reducer<State, Action> { 131 139 BindingReducer() ··· 174 182 state.pullRequestMergeStrategy = normalizedSettings.pullRequestMergeStrategy 175 183 state.terminalThemeSyncEnabled = normalizedSettings.terminalThemeSyncEnabled 176 184 state.restoreTerminalLayoutEnabled = normalizedSettings.restoreTerminalLayoutEnabled 185 + state.autoDeleteArchivedWorktreesAfterDays = normalizedSettings.autoDeleteArchivedWorktreesAfterDays 177 186 state.shortcutOverrides = normalizedSettings.shortcutOverrides 178 187 state.defaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath ?? "" 179 188 state.syncGlobalDefaults(from: normalizedSettings) ··· 243 252 244 253 case .resetAllShortcuts: 245 254 state.shortcutOverrides = [:] 255 + return persist(state) 256 + 257 + case .requestAutoDeleteDaysChange(let newPeriod): 258 + // Apply immediately when safe (disabling or widening the window). 259 + // Otherwise, check if the new period would auto-delete existing worktrees. 260 + guard let newPeriod else { 261 + state.autoDeleteArchivedWorktreesAfterDays = nil 262 + return persist(state) 263 + } 264 + if let current = state.autoDeleteArchivedWorktreesAfterDays, newPeriod >= current { 265 + state.autoDeleteArchivedWorktreesAfterDays = newPeriod 266 + return persist(state) 267 + } 268 + // Check how many archived worktrees would be auto-deleted under the new period. 269 + return .run { [now] send in 270 + let archivedDates = await repositoryPersistence.loadArchivedWorktreeDates() 271 + let cutoff = now.addingTimeInterval(-Double(newPeriod.rawValue) * secondsPerDay) 272 + let affectedCount = archivedDates.values.filter { $0 <= cutoff }.count 273 + await send(.resolvedAutoDeleteAffectedCount(newPeriod, affectedCount: affectedCount)) 274 + } 275 + 276 + case .resolvedAutoDeleteAffectedCount(let newPeriod, let affectedCount): 277 + guard affectedCount > 0 else { 278 + state.autoDeleteArchivedWorktreesAfterDays = newPeriod 279 + return persist(state) 280 + } 281 + let worktreeWord = affectedCount == 1 ? "worktree" : "worktrees" 282 + let pronoun = affectedCount == 1 ? "it was" : "they were" 283 + let dayWord = newPeriod == .oneDay ? "day" : "days" 284 + state.alert = AlertState { 285 + TextState("Delete \(affectedCount) archived \(worktreeWord)?") 286 + } actions: { 287 + ButtonState(role: .destructive, action: .confirmAutoDeleteDaysChange(newPeriod)) { 288 + TextState("Delete") 289 + } 290 + ButtonState(role: .cancel, action: .dismiss) { 291 + TextState("Cancel") 292 + } 293 + } message: { 294 + TextState( 295 + "\(affectedCount) archived \(worktreeWord) will be deleted immediately because " 296 + + "\(pronoun) archived more than \(newPeriod.rawValue) \(dayWord) ago." 297 + ) 298 + } 299 + return .none 300 + 301 + case .alert(.presented(.confirmAutoDeleteDaysChange(let days))): 302 + state.alert = nil 303 + state.autoDeleteArchivedWorktreesAfterDays = days 246 304 return persist(state) 247 305 248 306 case .repositoriesChanged(let repositories):
+16
supacode/Features/Settings/Views/GithubSettingsView.swift
··· 156 156 Text("Merge strategy") 157 157 Text("Default strategy when merging PRs from the command palette.") 158 158 } 159 + Picker(selection: $store.mergedWorktreeAction) { 160 + Text("Do nothing").tag(MergedWorktreeAction?.none) 161 + ForEach(MergedWorktreeAction.allCases) { action in 162 + Text(action.title).tag(MergedWorktreeAction?.some(action)) 163 + } 164 + } label: { 165 + Text("When a pull request is merged") 166 + switch store.mergedWorktreeAction { 167 + case .archive: 168 + Text("Archives the worktree when its pull request is merged.") 169 + case .delete: 170 + Text("Follows the \"Delete local branch with worktree\" option in Worktrees settings.") 171 + case nil: 172 + EmptyView() 173 + } 174 + } 159 175 } 160 176 } 161 177 .formStyle(.grouped)
+12 -14
supacode/Features/Settings/Views/WorktreeSettingsView.swift
··· 42 42 } 43 43 } 44 44 Section("Clean-up") { 45 - Picker(selection: $store.mergedWorktreeAction) { 46 - Text("Do nothing").tag(MergedWorktreeAction?.none) 47 - ForEach(MergedWorktreeAction.allCases) { action in 48 - Text(action.title).tag(MergedWorktreeAction?.some(action)) 49 - } 50 - } label: { 51 - Text("When a pull request is merged") 52 - switch store.mergedWorktreeAction { 53 - case .archive: 54 - Text("Archives worktrees when their pull requests are merged.") 55 - case .delete: 56 - Text("Follows the \"Delete local branch with worktree\" option below.") 57 - case nil: 58 - EmptyView() 45 + Picker( 46 + "Auto-delete archived worktrees", 47 + selection: Binding( 48 + get: { store.autoDeleteArchivedWorktreesAfterDays }, 49 + set: { store.send(.requestAutoDeleteDaysChange($0)) } 50 + ) 51 + ) { 52 + Text("Never").tag(AutoDeletePeriod?.none) 53 + ForEach(AutoDeletePeriod.allCases, id: \.rawValue) { period in 54 + Text(period.label).tag(AutoDeletePeriod?.some(period)) 59 55 } 60 56 } 57 + } 58 + Section { 61 59 Toggle(isOn: $store.deleteBranchOnDeleteWorktree) { 62 60 Text("Delete local branch with worktree") 63 61 Text("Removes the local branch along with the worktree. Remote branches must be deleted on GitHub.")
+1 -1
supacodeTests/AppFeatureArchivedSelectionTests.swift
··· 73 73 ) 74 74 var repositoriesState = RepositoriesFeature.State(repositories: [repository]) 75 75 repositoriesState.selection = .worktree(activeWorktree.id) 76 - repositoriesState.archivedWorktreeIDs = [archivedWorktree.id] 76 + repositoriesState.archivedWorktreeDates[archivedWorktree.id] = Date(timeIntervalSince1970: 1_000_000) 77 77 var appState = AppFeature.State( 78 78 repositories: repositoriesState, 79 79 settings: SettingsFeature.State()
+1
supacodeTests/AppFeatureSettingsChangedTests.swift
··· 25 25 await store.receive(\.repositories.setMoveNotifiedWorktreeToTop) { 26 26 $0.repositories.moveNotifiedWorktreeToTop = false 27 27 } 28 + await store.receive(\.repositories.setAutoDeleteArchivedWorktreesAfterDays) 28 29 await store.receive(\.updates.applySettings) { 29 30 $0.updates.didConfigureUpdates = true 30 31 }
+5 -5
supacodeTests/RepositoriesFeaturePersistenceTests.swift
··· 26 26 return pinned 27 27 }, 28 28 savePinnedWorktreeIDs: { _ in }, 29 - loadArchivedWorktreeIDs: { 30 - calls.withValue { $0.append("loadArchivedWorktreeIDs") } 31 - return [] 29 + loadArchivedWorktreeDates: { 30 + calls.withValue { $0.append("loadArchivedWorktreeDates") } 31 + return [:] 32 32 }, 33 - saveArchivedWorktreeIDs: { _ in }, 33 + saveArchivedWorktreeDates: { _ in }, 34 34 loadRepositoryOrderIDs: { 35 35 calls.withValue { $0.append("loadRepositoryOrderIDs") } 36 36 return repositoryOrder ··· 55 55 #expect( 56 56 calls.value == [ 57 57 "loadPinnedWorktreeIDs", 58 - "loadArchivedWorktreeIDs", 58 + "loadArchivedWorktreeDates", 59 59 "loadLastFocusedWorktreeID", 60 60 "loadRepositoryOrderIDs", 61 61 "loadWorktreeOrderByRepository",
+296 -11
supacodeTests/RepositoriesFeatureTests.swift
··· 1436 1436 id: pendingID, 1437 1437 repositoryID: repository.id, 1438 1438 progress: WorktreeCreationProgress(stage: .loadingLocalBranches) 1439 - ) 1439 + ), 1440 1440 ] 1441 1441 let store = TestStore(initialState: state) { 1442 1442 RepositoriesFeature() ··· 1473 1473 stage: .checkingRepositoryMode, 1474 1474 worktreeName: "swift-otter" 1475 1475 ) 1476 - ) 1476 + ), 1477 1477 ] 1478 1478 let store = TestStore(initialState: state) { 1479 1479 RepositoriesFeature() ··· 1670 1670 addedLines: nil, 1671 1671 removedLines: nil, 1672 1672 pullRequest: makePullRequest(state: "MERGED") 1673 - ) 1673 + ), 1674 1674 ] 1675 + let fixedDate = Date(timeIntervalSince1970: 1_000_000) 1675 1676 let store = TestStore(initialState: state) { 1676 1677 RepositoriesFeature() 1677 1678 } 1679 + store.dependencies.date = .constant(fixedDate) 1678 1680 1679 1681 await store.send(.requestArchiveWorktree(featureWorktree.id, repository.id)) 1680 1682 await store.receive(\.archiveWorktreeConfirmed) 1681 1683 await store.receive(\.archiveWorktreeApply) { 1682 - $0.archivedWorktreeIDs = [featureWorktree.id] 1684 + $0.archivedWorktreeDates[featureWorktree.id] = fixedDate 1683 1685 $0.pinnedWorktreeIDs = [] 1684 1686 $0.worktreeOrderByRepository = [:] 1685 1687 $0.selection = .worktree(mainWorktree.id) ··· 1869 1871 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1870 1872 var state = makeState(repositories: [repository]) 1871 1873 state.archivingWorktreeIDs = [featureWorktree.id] 1874 + let fixedDate = Date(timeIntervalSince1970: 1_000_000) 1872 1875 let store = TestStore(initialState: state) { 1873 1876 RepositoriesFeature() 1874 1877 } 1878 + store.dependencies.date = .constant(fixedDate) 1875 1879 1876 1880 await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0, tabId: nil)) { 1877 1881 $0.archivingWorktreeIDs = [] 1878 1882 } 1879 1883 await store.receive(\.archiveWorktreeApply) { 1880 - $0.archivedWorktreeIDs = [featureWorktree.id] 1884 + $0.archivedWorktreeDates[featureWorktree.id] = fixedDate 1881 1885 } 1882 1886 await store.receive(\.delegate.repositoriesChanged) 1883 1887 } ··· 2064 2068 $repositorySettings.withLock { 2065 2069 $0.archiveScript = " \n " 2066 2070 } 2071 + let fixedDate = Date(timeIntervalSince1970: 1_000_000) 2067 2072 let store = TestStore(initialState: makeState(repositories: [repository])) { 2068 2073 RepositoriesFeature() 2069 2074 } 2075 + store.dependencies.date = .constant(fixedDate) 2070 2076 store.exhaustivity = .off 2071 2077 2072 2078 await store.send(.archiveWorktreeConfirmed(featureWorktree.id, repository.id)) 2073 2079 await store.receive(\.archiveWorktreeApply) { 2074 - $0.archivedWorktreeIDs = [featureWorktree.id] 2080 + $0.archivedWorktreeDates[featureWorktree.id] = fixedDate 2075 2081 } 2076 2082 } 2077 2083 ··· 2775 2781 let worktree = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: repoRoot) 2776 2782 let repository = makeRepository(id: repoRoot, worktrees: [worktree]) 2777 2783 var initialState = makeState(repositories: [repository]) 2778 - initialState.archivedWorktreeIDs = [worktree.id] 2784 + initialState.archivedWorktreeDates[worktree.id] = Date(timeIntervalSince1970: 1_000_000) 2779 2785 let store = TestStore(initialState: initialState) { 2780 2786 RepositoriesFeature() 2781 2787 } ··· 2868 2874 id: removedWorktree.id, 2869 2875 repositoryID: repository.id, 2870 2876 progress: WorktreeCreationProgress(stage: .choosingWorktreeName) 2871 - ) 2877 + ), 2872 2878 ] 2873 2879 initialState.pinnedWorktreeIDs = [removedWorktree.id] 2874 2880 initialState.worktreeInfoByID = [ ··· 2951 2957 id: pendingID, 2952 2958 repositoryID: repository.id, 2953 2959 progress: WorktreeCreationProgress(stage: .loadingLocalBranches) 2954 - ) 2960 + ), 2955 2961 ] 2956 2962 initialState.selection = .worktree(pendingID) 2957 2963 initialState.sidebarSelectedWorktreeIDs = [existingWorktree.id, pendingID] ··· 2996 3002 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 2997 3003 var state = makeState(repositories: [repository]) 2998 3004 state.mergedWorktreeAction = .archive 3005 + let fixedDate = Date(timeIntervalSince1970: 1_000_000) 2999 3006 let store = TestStore(initialState: state) { 3000 3007 RepositoriesFeature() 3001 3008 } 3009 + store.dependencies.date = .constant(fixedDate) 3002 3010 let mergedPullRequest = makePullRequest(state: "MERGED", headRefName: featureWorktree.name) 3003 3011 3004 3012 await store.send( ··· 3015 3023 } 3016 3024 await store.receive(\.archiveWorktreeConfirmed) 3017 3025 await store.receive(\.archiveWorktreeApply) { 3018 - $0.archivedWorktreeIDs = [featureWorktree.id] 3026 + $0.archivedWorktreeDates[featureWorktree.id] = fixedDate 3019 3027 } 3020 3028 await store.receive(\.delegate.repositoriesChanged) 3021 3029 } ··· 3122 3130 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 3123 3131 var state = makeState(repositories: [repository]) 3124 3132 state.mergedWorktreeAction = .delete 3125 - state.archivedWorktreeIDs = [featureWorktree.id] 3133 + state.archivedWorktreeDates[featureWorktree.id] = Date(timeIntervalSince1970: 1_000_000) 3126 3134 let store = TestStore(initialState: state) { 3127 3135 RepositoriesFeature() 3128 3136 } ··· 3673 3681 3674 3682 await store.send(.unarchiveWorktree(worktree.id)) 3675 3683 expectNoDifference(store.state.archivedWorktreeIDs, []) 3684 + } 3685 + 3686 + // MARK: - Auto-Delete Expired Archived Worktrees 3687 + 3688 + @Test func autoDeleteExpiredArchivedWorktreesDeletesExpiredWorktrees() async { 3689 + let repoRoot = "/tmp/repo" 3690 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 3691 + let featureWorktree = makeWorktree( 3692 + id: "\(repoRoot)/feature", 3693 + name: "feature", 3694 + repoRoot: repoRoot, 3695 + ) 3696 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 3697 + let fixedDate = Date(timeIntervalSince1970: 1_000_000) 3698 + let eightDaysAgo = fixedDate.addingTimeInterval(-8 * 86400) 3699 + var state = makeState(repositories: [repository]) 3700 + state.autoDeleteArchivedWorktreesAfterDays = .sevenDays 3701 + state.archivedWorktreeDates[featureWorktree.id] = eightDaysAgo 3702 + let store = TestStore(initialState: state) { 3703 + RepositoriesFeature() 3704 + } 3705 + store.dependencies.date = .constant(fixedDate) 3706 + store.exhaustivity = .off 3707 + 3708 + await store.send(.autoDeleteExpiredArchivedWorktrees) 3709 + await store.receive(\.deleteWorktreeConfirmed) 3710 + } 3711 + 3712 + @Test func autoDeleteExpiredArchivedWorktreesSkipsNonExpired() async { 3713 + let repoRoot = "/tmp/repo" 3714 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 3715 + let featureWorktree = makeWorktree( 3716 + id: "\(repoRoot)/feature", 3717 + name: "feature", 3718 + repoRoot: repoRoot, 3719 + ) 3720 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 3721 + let fixedDate = Date(timeIntervalSince1970: 1_000_000) 3722 + let threeDaysAgo = fixedDate.addingTimeInterval(-3 * 86400) 3723 + var state = makeState(repositories: [repository]) 3724 + state.autoDeleteArchivedWorktreesAfterDays = .sevenDays 3725 + state.archivedWorktreeDates[featureWorktree.id] = threeDaysAgo 3726 + let store = TestStore(initialState: state) { 3727 + RepositoriesFeature() 3728 + } 3729 + store.dependencies.date = .constant(fixedDate) 3730 + 3731 + await store.send(.autoDeleteExpiredArchivedWorktrees) 3732 + } 3733 + 3734 + @Test func autoDeleteExpiredArchivedWorktreesSkipsMainWorktree() async { 3735 + let repoRoot = "/tmp/repo" 3736 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 3737 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 3738 + let fixedDate = Date(timeIntervalSince1970: 1_000_000) 3739 + let eightDaysAgo = fixedDate.addingTimeInterval(-8 * 86400) 3740 + var state = makeState(repositories: [repository]) 3741 + state.autoDeleteArchivedWorktreesAfterDays = .sevenDays 3742 + state.archivedWorktreeDates[mainWorktree.id] = eightDaysAgo 3743 + let store = TestStore(initialState: state) { 3744 + RepositoriesFeature() 3745 + } 3746 + store.dependencies.date = .constant(fixedDate) 3747 + 3748 + await store.send(.autoDeleteExpiredArchivedWorktrees) 3749 + } 3750 + 3751 + @Test func autoDeleteExpiredArchivedWorktreesSkipsAlreadyDeleting() async { 3752 + let repoRoot = "/tmp/repo" 3753 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 3754 + let featureWorktree = makeWorktree( 3755 + id: "\(repoRoot)/feature", 3756 + name: "feature", 3757 + repoRoot: repoRoot, 3758 + ) 3759 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 3760 + let fixedDate = Date(timeIntervalSince1970: 1_000_000) 3761 + let eightDaysAgo = fixedDate.addingTimeInterval(-8 * 86400) 3762 + var state = makeState(repositories: [repository]) 3763 + state.autoDeleteArchivedWorktreesAfterDays = .sevenDays 3764 + state.archivedWorktreeDates[featureWorktree.id] = eightDaysAgo 3765 + state.deletingWorktreeIDs = [featureWorktree.id] 3766 + let store = TestStore(initialState: state) { 3767 + RepositoriesFeature() 3768 + } 3769 + store.dependencies.date = .constant(fixedDate) 3770 + 3771 + await store.send(.autoDeleteExpiredArchivedWorktrees) 3772 + } 3773 + 3774 + @Test func autoDeleteExpiredArchivedWorktreesNoopsWhenDisabled() async { 3775 + let repoRoot = "/tmp/repo" 3776 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 3777 + let featureWorktree = makeWorktree( 3778 + id: "\(repoRoot)/feature", 3779 + name: "feature", 3780 + repoRoot: repoRoot, 3781 + ) 3782 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 3783 + let fixedDate = Date(timeIntervalSince1970: 1_000_000) 3784 + let eightDaysAgo = fixedDate.addingTimeInterval(-8 * 86400) 3785 + var state = makeState(repositories: [repository]) 3786 + state.archivedWorktreeDates[featureWorktree.id] = eightDaysAgo 3787 + let store = TestStore(initialState: state) { 3788 + RepositoriesFeature() 3789 + } 3790 + store.dependencies.date = .constant(fixedDate) 3791 + 3792 + await store.send(.autoDeleteExpiredArchivedWorktrees) 3793 + } 3794 + 3795 + @Test func setAutoDeleteDaysTriggersAutoDelete() async { 3796 + let repoRoot = "/tmp/repo" 3797 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 3798 + let featureWorktree = makeWorktree( 3799 + id: "\(repoRoot)/feature", 3800 + name: "feature", 3801 + repoRoot: repoRoot, 3802 + ) 3803 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 3804 + let fixedDate = Date(timeIntervalSince1970: 1_000_000) 3805 + let eightDaysAgo = fixedDate.addingTimeInterval(-8 * 86400) 3806 + var state = makeState(repositories: [repository]) 3807 + state.archivedWorktreeDates[featureWorktree.id] = eightDaysAgo 3808 + let store = TestStore(initialState: state) { 3809 + RepositoriesFeature() 3810 + } 3811 + store.dependencies.date = .constant(fixedDate) 3812 + store.exhaustivity = .off 3813 + 3814 + await store.send(.setAutoDeleteArchivedWorktreesAfterDays(.sevenDays)) { 3815 + $0.autoDeleteArchivedWorktreesAfterDays = .sevenDays 3816 + } 3817 + await store.receive(\.autoDeleteExpiredArchivedWorktrees) 3818 + await store.receive(\.deleteWorktreeConfirmed) 3819 + } 3820 + 3821 + @Test func autoDeleteExpiredArchivedWorktreesSkipsDeleteScriptInProgress() async { 3822 + let repoRoot = "/tmp/repo" 3823 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 3824 + let featureWorktree = makeWorktree( 3825 + id: "\(repoRoot)/feature", 3826 + name: "feature", 3827 + repoRoot: repoRoot, 3828 + ) 3829 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 3830 + let fixedDate = Date(timeIntervalSince1970: 1_000_000) 3831 + let eightDaysAgo = fixedDate.addingTimeInterval(-8 * 86400) 3832 + var state = makeState(repositories: [repository]) 3833 + state.autoDeleteArchivedWorktreesAfterDays = .sevenDays 3834 + state.archivedWorktreeDates[featureWorktree.id] = eightDaysAgo 3835 + state.deleteScriptWorktreeIDs = [featureWorktree.id] 3836 + let store = TestStore(initialState: state) { 3837 + RepositoriesFeature() 3838 + } 3839 + store.dependencies.date = .constant(fixedDate) 3840 + 3841 + await store.send(.autoDeleteExpiredArchivedWorktrees) 3842 + } 3843 + 3844 + @Test func autoDeleteExpiredArchivedWorktreesSkipsArchivingInProgress() async { 3845 + let repoRoot = "/tmp/repo" 3846 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 3847 + let featureWorktree = makeWorktree( 3848 + id: "\(repoRoot)/feature", 3849 + name: "feature", 3850 + repoRoot: repoRoot, 3851 + ) 3852 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 3853 + let fixedDate = Date(timeIntervalSince1970: 1_000_000) 3854 + let eightDaysAgo = fixedDate.addingTimeInterval(-8 * 86400) 3855 + var state = makeState(repositories: [repository]) 3856 + state.autoDeleteArchivedWorktreesAfterDays = .sevenDays 3857 + state.archivedWorktreeDates[featureWorktree.id] = eightDaysAgo 3858 + state.archivingWorktreeIDs = [featureWorktree.id] 3859 + let store = TestStore(initialState: state) { 3860 + RepositoriesFeature() 3861 + } 3862 + store.dependencies.date = .constant(fixedDate) 3863 + 3864 + await store.send(.autoDeleteExpiredArchivedWorktrees) 3865 + } 3866 + 3867 + @Test func autoDeleteExpiredArchivedWorktreesDeletesAtExactCutoff() async { 3868 + let repoRoot = "/tmp/repo" 3869 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 3870 + let featureWorktree = makeWorktree( 3871 + id: "\(repoRoot)/feature", 3872 + name: "feature", 3873 + repoRoot: repoRoot, 3874 + ) 3875 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 3876 + let fixedDate = Date(timeIntervalSince1970: 1_000_000) 3877 + let exactlySevenDaysAgo = fixedDate.addingTimeInterval(-7 * 86400) 3878 + var state = makeState(repositories: [repository]) 3879 + state.autoDeleteArchivedWorktreesAfterDays = .sevenDays 3880 + state.archivedWorktreeDates[featureWorktree.id] = exactlySevenDaysAgo 3881 + let store = TestStore(initialState: state) { 3882 + RepositoriesFeature() 3883 + } 3884 + store.dependencies.date = .constant(fixedDate) 3885 + store.exhaustivity = .off 3886 + 3887 + await store.send(.autoDeleteExpiredArchivedWorktrees) 3888 + await store.receive(\.deleteWorktreeConfirmed) 3889 + } 3890 + 3891 + @Test func repositoriesLoadedTriggersAutoDeleteWhenEnabled() async { 3892 + let repoRoot = "/tmp/repo" 3893 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 3894 + let featureWorktree = makeWorktree( 3895 + id: "\(repoRoot)/feature", 3896 + name: "feature", 3897 + repoRoot: repoRoot, 3898 + ) 3899 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 3900 + let fixedDate = Date(timeIntervalSince1970: 1_000_000) 3901 + let eightDaysAgo = fixedDate.addingTimeInterval(-8 * 86400) 3902 + var state = makeState(repositories: [repository]) 3903 + state.autoDeleteArchivedWorktreesAfterDays = .sevenDays 3904 + state.archivedWorktreeDates[featureWorktree.id] = eightDaysAgo 3905 + let store = TestStore(initialState: state) { 3906 + RepositoriesFeature() 3907 + } 3908 + store.dependencies.date = .constant(fixedDate) 3909 + store.exhaustivity = .off 3910 + 3911 + await store.send( 3912 + .repositoriesLoaded( 3913 + [repository], 3914 + failures: [], 3915 + roots: [repository.rootURL], 3916 + animated: false 3917 + ) 3918 + ) 3919 + await store.receive(\.autoDeleteExpiredArchivedWorktrees) 3920 + await store.receive(\.deleteWorktreeConfirmed) 3921 + } 3922 + 3923 + @Test func setAutoDeleteDaysNilDoesNotTriggerAutoDelete() async { 3924 + let store = TestStore(initialState: makeState(repositories: [])) { 3925 + RepositoriesFeature() 3926 + } 3927 + 3928 + await store.send(.setAutoDeleteArchivedWorktreesAfterDays(nil)) 3929 + } 3930 + 3931 + @Test func openRepositoriesFinishedTriggersAutoDeleteWhenEnabled() async { 3932 + let repoRoot = "/tmp/repo" 3933 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 3934 + let featureWorktree = makeWorktree( 3935 + id: "\(repoRoot)/feature", 3936 + name: "feature", 3937 + repoRoot: repoRoot, 3938 + ) 3939 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 3940 + let fixedDate = Date(timeIntervalSince1970: 1_000_000) 3941 + let eightDaysAgo = fixedDate.addingTimeInterval(-8 * 86400) 3942 + var state = makeState(repositories: [repository]) 3943 + state.autoDeleteArchivedWorktreesAfterDays = .sevenDays 3944 + state.archivedWorktreeDates[featureWorktree.id] = eightDaysAgo 3945 + let store = TestStore(initialState: state) { 3946 + RepositoriesFeature() 3947 + } 3948 + store.dependencies.date = .constant(fixedDate) 3949 + store.exhaustivity = .off 3950 + 3951 + await store.send( 3952 + .openRepositoriesFinished( 3953 + [repository], 3954 + failures: [], 3955 + invalidRoots: [], 3956 + roots: [repository.rootURL] 3957 + ) 3958 + ) 3959 + await store.receive(\.autoDeleteExpiredArchivedWorktrees) 3960 + await store.receive(\.deleteWorktreeConfirmed) 3676 3961 } 3677 3962 3678 3963 // MARK: - Select Next/Previous Worktree
+56
supacodeTests/RepositoryPersistenceClientTests.swift
··· 7 7 @testable import supacode 8 8 9 9 struct RepositoryPersistenceClientTests { 10 + // MARK: - normalizeDictionaryKeys 11 + 12 + @Test func normalizeDictionaryKeysResolvesPaths() { 13 + let date = Date(timeIntervalSince1970: 1_000_000) 14 + let result = RepositoryPathNormalizer.normalizeDictionaryKeys([ 15 + "/tmp/repo/../repo/feature": date 16 + ]) 17 + #expect(result == ["/tmp/repo/feature": date]) 18 + } 19 + 20 + @Test func normalizeDictionaryKeysDropsEmptyKeys() { 21 + let date = Date(timeIntervalSince1970: 1_000_000) 22 + let result = RepositoryPathNormalizer.normalizeDictionaryKeys([ 23 + "": date, 24 + " ": date, 25 + "/tmp/repo/feature": date, 26 + ]) 27 + #expect(result.count == 1) 28 + #expect(result["/tmp/repo/feature"] == date) 29 + } 30 + 31 + @Test func normalizeDictionaryKeysKeepsMoreRecentDateOnCollision() { 32 + let older = Date(timeIntervalSince1970: 1_000_000) 33 + let newer = Date(timeIntervalSince1970: 2_000_000) 34 + let result = RepositoryPathNormalizer.normalizeDictionaryKeys([ 35 + "/tmp/repo/feature": older, 36 + "/tmp/repo/../repo/feature": newer, 37 + ]) 38 + #expect(result.count == 1) 39 + #expect(result["/tmp/repo/feature"] == newer) 40 + } 41 + 42 + @Test func normalizeDictionaryKeysReturnsEmptyForEmptyInput() { 43 + let result = RepositoryPathNormalizer.normalizeDictionaryKeys([:]) 44 + #expect(result.isEmpty) 45 + } 46 + 47 + // MARK: - Legacy Migration 48 + 49 + @Test(.dependencies) func loadArchivedWorktreeDatesMigratesLegacyKey() async { 50 + let client = RepositoryPersistenceClient.liveValue 51 + @Shared(.appStorage("archivedWorktreeIDs")) var legacyIDs: [Worktree.ID] = [] 52 + @Shared(.appStorage(archivedWorktreeDatesStorageKey)) var dates: [Worktree.ID: Date] = [:] 53 + $legacyIDs.withLock { $0 = ["/tmp/repo/feature", "/tmp/repo/bugfix"] } 54 + $dates.withLock { $0 = [:] } 55 + 56 + let result = await client.loadArchivedWorktreeDates() 57 + #expect(result.count == 2) 58 + #expect(result["/tmp/repo/feature"] != nil) 59 + #expect(result["/tmp/repo/bugfix"] != nil) 60 + // Legacy key should be cleared after migration. 61 + #expect(legacyIDs.isEmpty) 62 + } 63 + 64 + // MARK: - Roots and Pins 65 + 10 66 @Test(.dependencies) func savesAndLoadsRootsAndPins() async throws { 11 67 let storage = SettingsTestStorage() 12 68
+227
supacodeTests/SettingsFeatureTests.swift
··· 502 502 } 503 503 await store.receive(\.delegate.settingsChanged) 504 504 } 505 + 506 + // MARK: - Auto-Delete Archived Worktrees Setting 507 + 508 + @Test(.dependencies) func requestAutoDeleteDaysChangeDisablingAppliesImmediately() async { 509 + var settings = GlobalSettings.default 510 + settings.autoDeleteArchivedWorktreesAfterDays = .sevenDays 511 + let store = TestStore(initialState: SettingsFeature.State(settings: settings)) { 512 + SettingsFeature() 513 + } 514 + 515 + await store.send(.requestAutoDeleteDaysChange(nil)) { 516 + $0.autoDeleteArchivedWorktreesAfterDays = nil 517 + } 518 + await store.receive(\.delegate.settingsChanged) 519 + } 520 + 521 + @Test(.dependencies) func requestAutoDeleteDaysChangeIncreasingAppliesImmediately() async { 522 + var settings = GlobalSettings.default 523 + settings.autoDeleteArchivedWorktreesAfterDays = .sevenDays 524 + let store = TestStore(initialState: SettingsFeature.State(settings: settings)) { 525 + SettingsFeature() 526 + } 527 + 528 + await store.send(.requestAutoDeleteDaysChange(.fourteenDays)) { 529 + $0.autoDeleteArchivedWorktreesAfterDays = .fourteenDays 530 + } 531 + await store.receive(\.delegate.settingsChanged) 532 + } 533 + 534 + @Test(.dependencies) func requestAutoDeleteDaysChangeShorteningShowsAlertWhenAffected() async { 535 + var settings = GlobalSettings.default 536 + settings.autoDeleteArchivedWorktreesAfterDays = .fourteenDays 537 + let fixedDate = Date(timeIntervalSince1970: 1_000_000) 538 + let tenDaysAgo = fixedDate.addingTimeInterval(-10 * 86400) 539 + let store = TestStore(initialState: SettingsFeature.State(settings: settings)) { 540 + SettingsFeature() 541 + } 542 + store.dependencies.date = .constant(fixedDate) 543 + store.dependencies.repositoryPersistence.loadArchivedWorktreeDates = { 544 + ["/tmp/repo/feature": tenDaysAgo] 545 + } 546 + 547 + await store.send(.requestAutoDeleteDaysChange(.sevenDays)) 548 + await store.receive(\.resolvedAutoDeleteAffectedCount) { 549 + $0.alert = AlertState { 550 + TextState("Delete 1 archived worktree?") 551 + } actions: { 552 + ButtonState(role: .destructive, action: .confirmAutoDeleteDaysChange(.sevenDays)) { 553 + TextState("Delete") 554 + } 555 + ButtonState(role: .cancel, action: .dismiss) { 556 + TextState("Cancel") 557 + } 558 + } message: { 559 + TextState( 560 + "1 archived worktree will be deleted immediately because " 561 + + "it was archived more than 7 days ago." 562 + ) 563 + } 564 + } 565 + } 566 + 567 + @Test(.dependencies) func requestAutoDeleteDaysChangeShorteningNoAffectedAppliesImmediately() async { 568 + var settings = GlobalSettings.default 569 + settings.autoDeleteArchivedWorktreesAfterDays = .fourteenDays 570 + let fixedDate = Date(timeIntervalSince1970: 1_000_000) 571 + let oneDayAgo = fixedDate.addingTimeInterval(-1 * 86400) 572 + let store = TestStore(initialState: SettingsFeature.State(settings: settings)) { 573 + SettingsFeature() 574 + } 575 + store.dependencies.date = .constant(fixedDate) 576 + store.dependencies.repositoryPersistence.loadArchivedWorktreeDates = { 577 + ["/tmp/repo/feature": oneDayAgo] 578 + } 579 + 580 + await store.send(.requestAutoDeleteDaysChange(.sevenDays)) 581 + await store.receive(\.resolvedAutoDeleteAffectedCount) { 582 + $0.autoDeleteArchivedWorktreesAfterDays = .sevenDays 583 + } 584 + await store.receive(\.delegate.settingsChanged) 585 + } 586 + 587 + @Test(.dependencies) func confirmAutoDeleteDaysChangeAppliesSetting() async { 588 + var settings = GlobalSettings.default 589 + settings.autoDeleteArchivedWorktreesAfterDays = .fourteenDays 590 + var state = SettingsFeature.State(settings: settings) 591 + state.alert = AlertState { 592 + TextState("Delete 1 archived worktree?") 593 + } actions: { 594 + ButtonState(role: .destructive, action: .confirmAutoDeleteDaysChange(.threeDays)) { 595 + TextState("Delete") 596 + } 597 + ButtonState(role: .cancel, action: .dismiss) { 598 + TextState("Cancel") 599 + } 600 + } message: { 601 + TextState("placeholder") 602 + } 603 + let store = TestStore(initialState: state) { 604 + SettingsFeature() 605 + } 606 + 607 + await store.send(.alert(.presented(.confirmAutoDeleteDaysChange(.threeDays)))) { 608 + $0.alert = nil 609 + $0.autoDeleteArchivedWorktreesAfterDays = .threeDays 610 + } 611 + await store.receive(\.delegate.settingsChanged) 612 + } 613 + 614 + @Test(.dependencies) func requestAutoDeleteDaysChangeShorteningShowsAlertWithPluralWording() async { 615 + var settings = GlobalSettings.default 616 + settings.autoDeleteArchivedWorktreesAfterDays = .fourteenDays 617 + let fixedDate = Date(timeIntervalSince1970: 1_000_000) 618 + let tenDaysAgo = fixedDate.addingTimeInterval(-10 * 86400) 619 + let twelveDaysAgo = fixedDate.addingTimeInterval(-12 * 86400) 620 + let store = TestStore(initialState: SettingsFeature.State(settings: settings)) { 621 + SettingsFeature() 622 + } 623 + store.dependencies.date = .constant(fixedDate) 624 + store.dependencies.repositoryPersistence.loadArchivedWorktreeDates = { 625 + ["/tmp/repo/feature": tenDaysAgo, "/tmp/repo/bugfix": twelveDaysAgo] 626 + } 627 + 628 + await store.send(.requestAutoDeleteDaysChange(.sevenDays)) 629 + await store.receive(\.resolvedAutoDeleteAffectedCount) { 630 + $0.alert = AlertState { 631 + TextState("Delete 2 archived worktrees?") 632 + } actions: { 633 + ButtonState(role: .destructive, action: .confirmAutoDeleteDaysChange(.sevenDays)) { 634 + TextState("Delete") 635 + } 636 + ButtonState(role: .cancel, action: .dismiss) { 637 + TextState("Cancel") 638 + } 639 + } message: { 640 + TextState( 641 + "2 archived worktrees will be deleted immediately because " 642 + + "they were archived more than 7 days ago." 643 + ) 644 + } 645 + } 646 + } 647 + 648 + @Test(.dependencies) func requestAutoDeleteDaysChangeCancelKeepsPreviousSetting() async { 649 + var settings = GlobalSettings.default 650 + settings.autoDeleteArchivedWorktreesAfterDays = .fourteenDays 651 + var state = SettingsFeature.State(settings: settings) 652 + state.alert = AlertState { 653 + TextState("Delete 1 archived worktree?") 654 + } actions: { 655 + ButtonState(role: .destructive, action: .confirmAutoDeleteDaysChange(.sevenDays)) { 656 + TextState("Delete") 657 + } 658 + ButtonState(role: .cancel, action: .dismiss) { 659 + TextState("Cancel") 660 + } 661 + } message: { 662 + TextState("placeholder") 663 + } 664 + let store = TestStore(initialState: state) { 665 + SettingsFeature() 666 + } 667 + 668 + await store.send(.alert(.dismiss)) { 669 + $0.alert = nil 670 + } 671 + // No settingsChanged delegate emitted — setting stays at .fourteenDays. 672 + } 673 + 674 + @Test(.dependencies) func requestAutoDeleteDaysChangeEnablingChecksAffectedCount() async { 675 + // Use a very old date so that leaked @Shared state from other tests 676 + // (with normal-range dates) falls outside the cutoff and is not counted. 677 + let fixedDate = Date.distantPast 678 + let store = TestStore(initialState: SettingsFeature.State()) { 679 + SettingsFeature() 680 + } 681 + store.dependencies.date = .constant(fixedDate) 682 + store.dependencies.repositoryPersistence.loadArchivedWorktreeDates = { [:] } 683 + 684 + await store.send(.requestAutoDeleteDaysChange(.sevenDays)) 685 + await store.receive(\.resolvedAutoDeleteAffectedCount) { 686 + $0.autoDeleteArchivedWorktreesAfterDays = .sevenDays 687 + } 688 + await store.receive(\.delegate.settingsChanged) 689 + } 690 + 691 + // MARK: - GlobalSettings Decoding Validation 692 + 693 + #if DEBUG 694 + @Test func decodingAutoDeleteAcceptsZeroInDebug() throws { 695 + let json = try makeGlobalSettingsJSON(autoDeleteDays: 0) 696 + let settings = try JSONDecoder().decode(GlobalSettings.self, from: json) 697 + #expect(settings.autoDeleteArchivedWorktreesAfterDays == .immediately) 698 + } 699 + #else 700 + @Test func decodingAutoDeleteRejectsZero() throws { 701 + let json = try makeGlobalSettingsJSON(autoDeleteDays: 0) 702 + let settings = try JSONDecoder().decode(GlobalSettings.self, from: json) 703 + #expect(settings.autoDeleteArchivedWorktreesAfterDays == nil) 704 + } 705 + #endif 706 + 707 + @Test func decodingAutoDeleteRejectsNegative() throws { 708 + let json = try makeGlobalSettingsJSON(autoDeleteDays: -5) 709 + let settings = try JSONDecoder().decode(GlobalSettings.self, from: json) 710 + #expect(settings.autoDeleteArchivedWorktreesAfterDays == nil) 711 + } 712 + 713 + @Test func decodingAutoDeleteRejectsUnrecognizedPositive() throws { 714 + let json = try makeGlobalSettingsJSON(autoDeleteDays: 99) 715 + let settings = try JSONDecoder().decode(GlobalSettings.self, from: json) 716 + #expect(settings.autoDeleteArchivedWorktreesAfterDays == nil) 717 + } 718 + 719 + @Test func decodingAutoDeleteAcceptsValidPeriod() throws { 720 + let json = try makeGlobalSettingsJSON(autoDeleteDays: 7) 721 + let settings = try JSONDecoder().decode(GlobalSettings.self, from: json) 722 + #expect(settings.autoDeleteArchivedWorktreesAfterDays == .sevenDays) 723 + } 724 + 725 + private func makeGlobalSettingsJSON(autoDeleteDays: Int) throws -> Data { 726 + let base = GlobalSettings.default 727 + let encoded = try JSONEncoder().encode(base) 728 + var dict = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] ?? [:] 729 + dict["autoDeleteArchivedWorktreesAfterDays"] = autoDeleteDays 730 + return try JSONSerialization.data(withJSONObject: dict) 731 + } 505 732 }
+1 -1
supacodeTests/ToolbarNotificationGroupingTests.swift
··· 58 58 59 59 var state = RepositoriesFeature.State(repositories: [repoA, repoB]) 60 60 state.repositoryRoots = [repoA.rootURL, repoB.rootURL] 61 - state.archivedWorktreeIDs = [repoAArchived.id] 61 + state.archivedWorktreeDates[repoAArchived.id] = Date(timeIntervalSince1970: 1_000_000) 62 62 63 63 let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 64 64 manager.state(for: repoAArchived).notifications = [