native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #233 from onevcat/fix/release-action-logging-hang

fix(sentry): cheapen release action breadcrumbs

authored by

Wei Wang and committed by
GitHub
a9db90f6 82e054a4

+476 -20
+69 -14
supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift
··· 4 4 5 5 @MainActor 6 6 final class WorktreeInfoWatcherManager { 7 + typealias WorktreePhaseOffset = @Sendable (Worktree.ID, Duration) -> Duration 8 + typealias RepositoryPhaseOffset = @Sendable (URL, Duration) -> Duration 9 + 7 10 private struct HeadWatcher { 8 11 let headURL: URL 9 12 let source: DispatchSourceFileSystemObject ··· 22 25 private struct RepeatingTaskRequest { 23 26 let worktreeID: Worktree.ID 24 27 let interval: Duration 28 + let initialDelay: Duration 25 29 let immediate: Bool 26 30 let forceReschedule: Bool 27 31 let makeEvent: (Worktree.ID) -> WorktreeInfoWatcherClient.Event ··· 35 39 private let filesChangedDebounceInterval: Duration 36 40 private let pullRequestSelectionRefreshCooldown: Duration 37 41 private let refreshTiming: RefreshTiming 42 + private let lineChangePhaseOffset: WorktreePhaseOffset 43 + private let pullRequestPhaseOffset: RepositoryPhaseOffset 38 44 private let sleep: @Sendable (Duration) async throws -> Void 39 45 private var worktrees: [Worktree.ID: Worktree] = [:] 40 46 private var headWatchers: [Worktree.ID: HeadWatcher] = [:] ··· 55 61 unfocusedInterval: Duration = .seconds(60), 56 62 filesChangedDebounceInterval: Duration = .seconds(5), 57 63 pullRequestSelectionRefreshCooldown: Duration = .seconds(5), 64 + lineChangePhaseOffset: @escaping WorktreePhaseOffset = WorktreeInfoWatcherManager.defaultLineChangePhaseOffset, 65 + pullRequestPhaseOffset: @escaping RepositoryPhaseOffset = WorktreeInfoWatcherManager.defaultPullRequestPhaseOffset, 58 66 clock: C = ContinuousClock() 59 67 ) { 60 68 refreshTiming = RefreshTiming(focused: focusedInterval, unfocused: unfocusedInterval) 61 69 self.filesChangedDebounceInterval = filesChangedDebounceInterval 62 70 self.pullRequestSelectionRefreshCooldown = pullRequestSelectionRefreshCooldown 71 + self.lineChangePhaseOffset = lineChangePhaseOffset 72 + self.pullRequestPhaseOffset = pullRequestPhaseOffset 63 73 self.sleep = { duration in 64 74 try await clock.sleep(for: duration) 65 75 } ··· 359 369 if immediate { 360 370 emitPullRequestRefresh(repositoryRootURL: repositoryRootURL) 361 371 } 372 + let initialDelay = interval + pullRequestPhaseOffset(repositoryRootURL, interval) 362 373 let sleep = self.sleep 363 374 let task = Task { [weak self, sleep] in 375 + do { 376 + try await sleep(initialDelay) 377 + } catch { 378 + return 379 + } 364 380 while !Task.isCancelled { 381 + await MainActor.run { 382 + self?.emitPullRequestRefresh(repositoryRootURL: repositoryRootURL) 383 + } 365 384 do { 366 385 try await sleep(interval) 367 386 } catch { 368 - break 369 - } 370 - guard !Task.isCancelled else { 371 - break 372 - } 373 - await MainActor.run { 374 - self?.emitPullRequestRefresh(repositoryRootURL: repositoryRootURL) 387 + return 375 388 } 376 389 } 377 390 } ··· 410 423 let request = RepeatingTaskRequest( 411 424 worktreeID: worktreeID, 412 425 interval: interval, 426 + initialDelay: interval + lineChangePhaseOffset(worktreeID, interval), 413 427 immediate: shouldEmit, 414 428 forceReschedule: forceReschedule, 415 429 makeEvent: { [weak self] worktreeID in ··· 437 451 } 438 452 let sleep = self.sleep 439 453 let task = Task { [weak self, sleep] in 454 + do { 455 + try await sleep(request.initialDelay) 456 + } catch { 457 + return 458 + } 440 459 while !Task.isCancelled { 460 + await MainActor.run { 461 + self?.emit(request.makeEvent(worktreeID)) 462 + } 441 463 do { 442 464 try await sleep(request.interval) 443 465 } catch { 444 - break 445 - } 446 - guard !Task.isCancelled else { 447 - break 448 - } 449 - await MainActor.run { 450 - self?.emit(request.makeEvent(worktreeID)) 466 + return 451 467 } 452 468 } 453 469 } 454 470 tasks[worktreeID] = RefreshTask(interval: request.interval, task: task) 471 + } 472 + 473 + nonisolated private static func defaultLineChangePhaseOffset( 474 + worktreeID: Worktree.ID, 475 + interval: Duration 476 + ) -> Duration { 477 + stablePhaseOffset(seed: worktreeID, interval: interval) 478 + } 479 + 480 + nonisolated private static func defaultPullRequestPhaseOffset( 481 + repositoryRootURL: URL, 482 + interval: Duration 483 + ) -> Duration { 484 + stablePhaseOffset(seed: repositoryRootURL.path(percentEncoded: false), interval: interval) 485 + } 486 + 487 + nonisolated private static func stablePhaseOffset(seed: String, interval: Duration) -> Duration { 488 + let intervalMilliseconds = durationMilliseconds(interval) 489 + guard intervalMilliseconds > 0 else { 490 + return .zero 491 + } 492 + let hash = stableHash(seed) 493 + return .milliseconds(Int64(hash % UInt64(intervalMilliseconds))) 494 + } 495 + 496 + nonisolated private static func durationMilliseconds(_ duration: Duration) -> Int64 { 497 + let components = duration.components 498 + let millisecondsFromSeconds = components.seconds * 1_000 499 + let millisecondsFromAttoseconds = Int64(components.attoseconds / 1_000_000_000_000_000) 500 + return millisecondsFromSeconds + millisecondsFromAttoseconds 501 + } 502 + 503 + nonisolated private static func stableHash(_ string: String) -> UInt64 { 504 + var hash: UInt64 = 14_695_981_039_346_656_037 505 + for byte in string.utf8 { 506 + hash ^= UInt64(byte) 507 + hash &*= 1_099_511_628_211 508 + } 509 + return hash 455 510 } 456 511 457 512 private func emit(_ event: WorktreeInfoWatcherClient.Event) {
+49 -4
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 985 985 } 986 986 let worktreeURL = worktree.workingDirectory 987 987 let gitClient = gitClient 988 + let previousLineChanges = normalizedLineChanges(state.worktreeInfoByID[worktreeID]) 988 989 return .run { send in 989 990 if let changes = await gitClient.lineChanges(worktreeURL) { 991 + let nextLineChanges = normalizedLineChanges(added: changes.added, removed: changes.removed) 992 + guard !lineChangesEqual(nextLineChanges, previousLineChanges) else { 993 + return 994 + } 990 995 await send( 991 996 .worktreeLineChangesLoaded( 992 997 worktreeID: worktreeID, ··· 2155 2160 } 2156 2161 } 2157 2162 2158 - private func updateWorktreeLineChanges( 2163 + @discardableResult 2164 + func updateWorktreeLineChanges( 2159 2165 worktreeID: Worktree.ID, 2160 2166 added: Int, 2161 2167 removed: Int, 2162 2168 state: inout RepositoriesFeature.State 2163 - ) { 2169 + ) -> Bool { 2164 2170 var entry = state.worktreeInfoByID[worktreeID] ?? WorktreeInfoEntry() 2165 2171 if added == 0 && removed == 0 { 2166 2172 entry.addedLines = nil ··· 2169 2175 entry.addedLines = added 2170 2176 entry.removedLines = removed 2171 2177 } 2178 + let previousEntry = state.worktreeInfoByID[worktreeID] 2172 2179 if entry.isEmpty { 2180 + guard previousEntry != nil else { 2181 + return false 2182 + } 2173 2183 state.worktreeInfoByID.removeValue(forKey: worktreeID) 2174 - } else { 2175 - state.worktreeInfoByID[worktreeID] = entry 2184 + return true 2176 2185 } 2186 + guard previousEntry != entry else { 2187 + return false 2188 + } 2189 + state.worktreeInfoByID[worktreeID] = entry 2190 + return true 2177 2191 } 2178 2192 2179 2193 func updateWorktreePullRequest( ··· 2187 2201 state.worktreeInfoByID.removeValue(forKey: worktreeID) 2188 2202 } else { 2189 2203 state.worktreeInfoByID[worktreeID] = entry 2204 + } 2205 + } 2206 + 2207 + nonisolated private func normalizedLineChanges(_ entry: WorktreeInfoEntry?) -> (added: Int, removed: Int)? { 2208 + guard let added = entry?.addedLines, let removed = entry?.removedLines else { 2209 + return nil 2210 + } 2211 + return normalizedLineChanges(added: added, removed: removed) 2212 + } 2213 + 2214 + nonisolated private func normalizedLineChanges( 2215 + added: Int, 2216 + removed: Int 2217 + ) -> (added: Int, removed: Int)? { 2218 + guard added != 0 || removed != 0 else { 2219 + return nil 2220 + } 2221 + return (added, removed) 2222 + } 2223 + 2224 + nonisolated private func lineChangesEqual( 2225 + _ lhs: (added: Int, removed: Int)?, 2226 + _ rhs: (added: Int, removed: Int)? 2227 + ) -> Bool { 2228 + switch (lhs, rhs) { 2229 + case (nil, nil): 2230 + return true 2231 + case (.some(let lhs), .some(let rhs)): 2232 + return lhs.added == rhs.added && lhs.removed == rhs.removed 2233 + default: 2234 + return false 2190 2235 } 2191 2236 } 2192 2237
+51 -2
supacode/Support/DebugCaseOutput.swift
··· 16 16 private let logger = SupaLogger("TCA") 17 17 18 18 func reduce(into state: inout Base.State, action: Base.Action) -> Effect<Base.Action> { 19 - let actionLabel = debugCaseOutput(action) 20 - logger.debug("Action: \(actionLabel)") 21 19 #if DEBUG 20 + let actionLabel = debugCaseOutput(action) 21 + logger.debug("Action: \(actionLabel)") 22 22 let previousState = state 23 23 let effects = base.reduce(into: &state, action: action) 24 24 if previousState != state, let diff = CustomDump.diff(previousState, state) { ··· 26 26 } 27 27 return effects 28 28 #else 29 + let actionLabel = releaseActionLabel(action) 30 + logger.debug("Action: \(actionLabel)") 29 31 SentrySDK.logger.info("Action: \(actionLabel)") 30 32 let breadcrumb = Breadcrumb(level: .debug, category: "action") 31 33 breadcrumb.message = actionLabel ··· 66 68 ?? "\(abbreviated ? "" : typeName(type(of: value)))\(debugCaseOutputHelp(value))" 67 69 } 68 70 71 + func releaseActionLabel(_ value: Any) -> String { 72 + let rootType = shortTypeName(type(of: value)) 73 + let casePath = releaseEnumCasePath(value) 74 + guard !casePath.isEmpty else { 75 + return rootType 76 + } 77 + return "\(rootType).\(casePath.joined(separator: "."))" 78 + } 79 + 69 80 private func isUnlabeledArgument(_ label: String) -> Bool { 70 81 label.firstIndex(where: { $0 != "." && !$0.isNumber }) == nil 82 + } 83 + 84 + private func releaseEnumCasePath(_ value: Any) -> [String] { 85 + var labels: [String] = [] 86 + var currentValue = value 87 + 88 + while true { 89 + let mirror = Mirror(reflecting: currentValue) 90 + guard mirror.displayStyle == .enum else { 91 + return labels 92 + } 93 + if let child = mirror.children.first, let label = child.label { 94 + labels.append(label) 95 + let childMirror = Mirror(reflecting: child.value) 96 + guard childMirror.displayStyle == .enum else { 97 + return labels 98 + } 99 + currentValue = child.value 100 + } else { 101 + labels.append(caseName(String(describing: currentValue))) 102 + return labels 103 + } 104 + } 105 + } 106 + 107 + private func caseName(_ description: String) -> String { 108 + if let parenIndex = description.firstIndex(of: "(") { 109 + return String(description[..<parenIndex]) 110 + } 111 + return description 112 + } 113 + 114 + private func shortTypeName(_ type: Any.Type) -> String { 115 + let components = String(reflecting: type) 116 + .split(separator: ".") 117 + .filter { !$0.hasPrefix("(unknown context at $") } 118 + .suffix(2) 119 + return components.isEmpty ? String(reflecting: type) : components.joined(separator: ".") 71 120 } 72 121 73 122 private func typeName(
+40
supacodeTests/ReleaseActionLabelTests.swift
··· 1 + import Testing 2 + 3 + @testable import supacode 4 + 5 + struct ReleaseActionLabelTests { 6 + private enum OuterAction { 7 + case idle 8 + case inner(InnerAction) 9 + case payload(id: Int, title: String) 10 + } 11 + 12 + private enum InnerAction { 13 + case ready 14 + case deep(DeepAction) 15 + } 16 + 17 + private enum DeepAction { 18 + case done(worktreeID: String, added: Int, removed: Int) 19 + } 20 + 21 + @Test func nestedEnumLabelUsesCasePathWithoutPayloads() { 22 + let label = releaseActionLabel( 23 + OuterAction.inner(.deep(.done(worktreeID: "wt-1", added: 3, removed: 1))) 24 + ) 25 + 26 + #expect(label == "ReleaseActionLabelTests.OuterAction.inner.deep.done") 27 + } 28 + 29 + @Test func payloadCaseDoesNotExpandAssociatedValues() { 30 + let label = releaseActionLabel(OuterAction.payload(id: 42, title: "repo")) 31 + 32 + #expect(label == "ReleaseActionLabelTests.OuterAction.payload") 33 + } 34 + 35 + @Test func payloadlessCaseKeepsTypeAndCase() { 36 + let label = releaseActionLabel(OuterAction.idle) 37 + 38 + #expect(label == "ReleaseActionLabelTests.OuterAction.idle") 39 + } 40 + }
+155
supacodeTests/RepositoriesFeatureTests.swift
··· 67 67 } 68 68 } 69 69 70 + @Test func updateWorktreeLineChangesReturnsFalseWhenCountsMatchExistingEntry() { 71 + let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") 72 + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 73 + var state = makeState(repositories: [repository]) 74 + state.worktreeInfoByID[worktree.id] = WorktreeInfoEntry( 75 + addedLines: 12, 76 + removedLines: 4, 77 + pullRequest: nil 78 + ) 79 + 80 + let changed = updateWorktreeLineChanges( 81 + worktreeID: worktree.id, 82 + added: 12, 83 + removed: 4, 84 + state: &state 85 + ) 86 + 87 + #expect(changed == false) 88 + #expect( 89 + state.worktreeInfoByID[worktree.id] 90 + == WorktreeInfoEntry(addedLines: 12, removedLines: 4, pullRequest: nil) 91 + ) 92 + } 93 + 94 + @Test func updateWorktreeLineChangesReturnsFalseWhenClearingAlreadyEmptyDiffs() { 95 + let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") 96 + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 97 + var state = makeState(repositories: [repository]) 98 + let pullRequest = makePullRequest(state: "OPEN", headRefName: worktree.name) 99 + state.worktreeInfoByID[worktree.id] = WorktreeInfoEntry( 100 + addedLines: nil, 101 + removedLines: nil, 102 + pullRequest: pullRequest 103 + ) 104 + 105 + let changed = updateWorktreeLineChanges( 106 + worktreeID: worktree.id, 107 + added: 0, 108 + removed: 0, 109 + state: &state 110 + ) 111 + 112 + #expect(changed == false) 113 + #expect( 114 + state.worktreeInfoByID[worktree.id] 115 + == WorktreeInfoEntry(addedLines: nil, removedLines: nil, pullRequest: pullRequest) 116 + ) 117 + } 118 + 119 + @Test func updateWorktreeLineChangesReturnsTrueWhenCountsChange() { 120 + let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") 121 + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 122 + var state = makeState(repositories: [repository]) 123 + 124 + let changed = updateWorktreeLineChanges( 125 + worktreeID: worktree.id, 126 + added: 12, 127 + removed: 4, 128 + state: &state 129 + ) 130 + 131 + #expect(changed == true) 132 + #expect( 133 + state.worktreeInfoByID[worktree.id] 134 + == WorktreeInfoEntry(addedLines: 12, removedLines: 4, pullRequest: nil) 135 + ) 136 + } 137 + 138 + @Test func filesChangedSkipsLineChangeActionWhenGitCountsMatchCurrentState() async { 139 + let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") 140 + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 141 + var state = makeState(repositories: [repository]) 142 + state.worktreeInfoByID[worktree.id] = WorktreeInfoEntry( 143 + addedLines: 12, 144 + removedLines: 4, 145 + pullRequest: nil 146 + ) 147 + 148 + let store = TestStore(initialState: state) { 149 + RepositoriesFeature() 150 + } withDependencies: { 151 + $0.gitClient.lineChanges = { _ in (12, 4) } 152 + } 153 + 154 + await store.send(.worktreeInfoEvent(.filesChanged(worktreeID: worktree.id))) 155 + await store.finish() 156 + } 157 + 158 + @Test func filesChangedSkipsLineChangeActionWhenGitReportsAlreadyEmptyDiff() async { 159 + let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") 160 + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 161 + let pullRequest = makePullRequest(state: "OPEN", headRefName: worktree.name) 162 + var state = makeState(repositories: [repository]) 163 + state.worktreeInfoByID[worktree.id] = WorktreeInfoEntry( 164 + addedLines: nil, 165 + removedLines: nil, 166 + pullRequest: pullRequest 167 + ) 168 + 169 + let store = TestStore(initialState: state) { 170 + RepositoriesFeature() 171 + } withDependencies: { 172 + $0.gitClient.lineChanges = { _ in (0, 0) } 173 + } 174 + 175 + await store.send(.worktreeInfoEvent(.filesChanged(worktreeID: worktree.id))) 176 + await store.finish() 177 + } 178 + 179 + @Test func filesChangedSkipsLineChangeActionWhenEntryExplicitlyHoldsZeros() async { 180 + let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") 181 + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 182 + var state = makeState(repositories: [repository]) 183 + state.worktreeInfoByID[worktree.id] = WorktreeInfoEntry( 184 + addedLines: 0, 185 + removedLines: 0, 186 + pullRequest: nil 187 + ) 188 + 189 + let store = TestStore(initialState: state) { 190 + RepositoriesFeature() 191 + } withDependencies: { 192 + $0.gitClient.lineChanges = { _ in (0, 0) } 193 + } 194 + 195 + await store.send(.worktreeInfoEvent(.filesChanged(worktreeID: worktree.id))) 196 + await store.finish() 197 + } 198 + 199 + @Test func filesChangedEmitsLineChangeActionWhenGitCountsDiffer() async { 200 + let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") 201 + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 202 + var state = makeState(repositories: [repository]) 203 + state.worktreeInfoByID[worktree.id] = WorktreeInfoEntry( 204 + addedLines: 12, 205 + removedLines: 4, 206 + pullRequest: nil 207 + ) 208 + 209 + let store = TestStore(initialState: state) { 210 + RepositoriesFeature() 211 + } withDependencies: { 212 + $0.gitClient.lineChanges = { _ in (15, 9) } 213 + } 214 + 215 + await store.send(.worktreeInfoEvent(.filesChanged(worktreeID: worktree.id))) 216 + await store.receive(\.worktreeLineChangesLoaded) { 217 + $0.worktreeInfoByID[worktree.id] = WorktreeInfoEntry( 218 + addedLines: 15, 219 + removedLines: 9, 220 + pullRequest: nil 221 + ) 222 + } 223 + } 224 + 70 225 @Test func repositoriesLoadedEmitsChangedDelegateWhenTransitioningFromRestoring() async { 71 226 let worktree = makeWorktree(id: "/tmp/repo/main", name: "main") 72 227 let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree])
+112
supacodeTests/WorktreeInfoWatcherManagerTests.swift
··· 33 33 let manager = WorktreeInfoWatcherManager( 34 34 focusedInterval: .milliseconds(80), 35 35 unfocusedInterval: .milliseconds(80), 36 + lineChangePhaseOffset: { _, _ in .zero }, 37 + pullRequestPhaseOffset: { _, _ in .zero }, 36 38 clock: clock 37 39 ) 38 40 let (collector, task) = startCollecting(manager.eventStream()) ··· 57 59 manager.handleCommand(.stop) 58 60 await task.value 59 61 try FileManager.default.removeItem(at: tempRepository.tempRoot) 62 + } 63 + 64 + @Test func staggersDeferredLineChangesAcrossWorktrees() async throws { 65 + let clock = TestClock() 66 + let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift", "eagle"]) 67 + let firstWorktree = try #require(tempRepository.worktrees.first) 68 + let secondWorktree = try #require(tempRepository.worktrees.dropFirst().first) 69 + let thirdWorktree = try #require(tempRepository.worktrees.dropFirst(2).first) 70 + let manager = WorktreeInfoWatcherManager( 71 + focusedInterval: .milliseconds(80), 72 + unfocusedInterval: .milliseconds(80), 73 + lineChangePhaseOffset: { worktreeID, _ in 74 + switch worktreeID { 75 + case secondWorktree.id: 76 + return .milliseconds(10) 77 + case thirdWorktree.id: 78 + return .milliseconds(40) 79 + default: 80 + return .zero 81 + } 82 + }, 83 + pullRequestPhaseOffset: { _, _ in .zero }, 84 + clock: clock 85 + ) 86 + let (collector, task) = startCollecting(manager.eventStream()) 87 + 88 + manager.handleCommand(.setPullRequestTrackingEnabled(false)) 89 + manager.handleCommand(.setWorktrees([firstWorktree])) 90 + await drainAsyncEvents(120) 91 + #expect(await collector.filesChangedCount(worktreeID: firstWorktree.id) == 1) 92 + 93 + manager.handleCommand(.setWorktrees([firstWorktree, secondWorktree, thirdWorktree])) 94 + await drainAsyncEvents(120) 95 + #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 0) 96 + #expect(await collector.filesChangedCount(worktreeID: thirdWorktree.id) == 0) 97 + 98 + await clock.advance(by: .milliseconds(89)) 99 + await drainAsyncEvents(120) 100 + #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 0) 101 + #expect(await collector.filesChangedCount(worktreeID: thirdWorktree.id) == 0) 102 + 103 + await clock.advance(by: .milliseconds(1)) 104 + await drainAsyncEvents(120) 105 + #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 1) 106 + #expect(await collector.filesChangedCount(worktreeID: thirdWorktree.id) == 0) 107 + 108 + await clock.advance(by: .milliseconds(29)) 109 + await drainAsyncEvents(120) 110 + #expect(await collector.filesChangedCount(worktreeID: thirdWorktree.id) == 0) 111 + 112 + await clock.advance(by: .milliseconds(1)) 113 + await drainAsyncEvents(120) 114 + #expect(await collector.filesChangedCount(worktreeID: thirdWorktree.id) == 1) 115 + 116 + manager.handleCommand(.stop) 117 + await task.value 118 + try FileManager.default.removeItem(at: tempRepository.tempRoot) 119 + } 120 + 121 + @Test func staggersPeriodicPullRequestRefreshAcrossRepositories() async throws { 122 + let clock = TestClock() 123 + let firstRepository = try makeTempRepository(worktreeNames: ["sparrow"]) 124 + let secondRepository = try makeTempRepository(worktreeNames: ["swift"]) 125 + let firstWorktree = try #require(firstRepository.worktrees.first) 126 + let secondWorktree = try #require(secondRepository.worktrees.first) 127 + let manager = WorktreeInfoWatcherManager( 128 + focusedInterval: .milliseconds(80), 129 + unfocusedInterval: .milliseconds(80), 130 + lineChangePhaseOffset: { _, _ in .zero }, 131 + pullRequestPhaseOffset: { repositoryRootURL, _ in 132 + switch repositoryRootURL { 133 + case firstRepository.tempRoot: 134 + return .milliseconds(10) 135 + case secondRepository.tempRoot: 136 + return .milliseconds(40) 137 + default: 138 + return .zero 139 + } 140 + }, 141 + clock: clock 142 + ) 143 + let (collector, task) = startCollecting(manager.eventStream()) 144 + 145 + manager.handleCommand(.setWorktrees([firstWorktree, secondWorktree])) 146 + await drainAsyncEvents(120) 147 + #expect(await collector.pullRequestRefreshCount(repositoryRootURL: firstRepository.tempRoot) == 1) 148 + #expect(await collector.pullRequestRefreshCount(repositoryRootURL: secondRepository.tempRoot) == 1) 149 + 150 + await clock.advance(by: .milliseconds(89)) 151 + await drainAsyncEvents(120) 152 + #expect(await collector.pullRequestRefreshCount(repositoryRootURL: firstRepository.tempRoot) == 1) 153 + #expect(await collector.pullRequestRefreshCount(repositoryRootURL: secondRepository.tempRoot) == 1) 154 + 155 + await clock.advance(by: .milliseconds(1)) 156 + await drainAsyncEvents(120) 157 + #expect(await collector.pullRequestRefreshCount(repositoryRootURL: firstRepository.tempRoot) == 2) 158 + #expect(await collector.pullRequestRefreshCount(repositoryRootURL: secondRepository.tempRoot) == 1) 159 + 160 + await clock.advance(by: .milliseconds(29)) 161 + await drainAsyncEvents(120) 162 + #expect(await collector.pullRequestRefreshCount(repositoryRootURL: secondRepository.tempRoot) == 1) 163 + 164 + await clock.advance(by: .milliseconds(1)) 165 + await drainAsyncEvents(120) 166 + #expect(await collector.pullRequestRefreshCount(repositoryRootURL: secondRepository.tempRoot) == 2) 167 + 168 + manager.handleCommand(.stop) 169 + await task.value 170 + try FileManager.default.removeItem(at: firstRepository.tempRoot) 171 + try FileManager.default.removeItem(at: secondRepository.tempRoot) 60 172 } 61 173 62 174 @Test func selectionRefreshUsesCooldownWithinRepository() async throws {