native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226import Clocksimport Foundationimport Testing
@testable import Prowl
@MainActorstruct WorktreeInfoWatcherManagerTests { @Test func emitsLineChangesImmediatelyOnInitialWorktreeLoad() async throws { let tempWorktree = try makeTempWorktree() let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), unfocusedInterval: .seconds(3_600) ) let (collector, task) = startCollecting(manager.eventStream())
manager.handleCommand(.setPullRequestTrackingEnabled(false)) manager.handleCommand(.setWorktrees([tempWorktree.worktree]))
await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: tempWorktree.worktree.id) == 1)
manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempWorktree.tempRoot) }
@Test func worktreesAddedAfterInitialLoadRefreshLineChangesOnce() async throws { let clock = TestClock() let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) let firstWorktree = try #require(tempRepository.worktrees.first) let secondWorktree = try #require(tempRepository.worktrees.dropFirst().first) let manager = WorktreeInfoWatcherManager( focusedInterval: .milliseconds(80), unfocusedInterval: .milliseconds(80), lineChangePhaseOffset: { _, _ in .zero }, pullRequestPhaseOffset: { _, _ in .zero }, clock: clock ) let (collector, task) = startCollecting(manager.eventStream())
manager.handleCommand(.setPullRequestTrackingEnabled(false)) manager.handleCommand(.setWorktrees([firstWorktree])) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: firstWorktree.id) == 1)
manager.handleCommand(.setWorktrees([firstWorktree, secondWorktree])) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 0)
await clock.advance(by: .milliseconds(79)) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 0)
await clock.advance(by: .milliseconds(1)) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 1)
await clock.advance(by: .milliseconds(80)) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 1)
manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempRepository.tempRoot) }
@Test func buildsWorktreeLookupWithoutTrappingOnDuplicateID() async throws { let tempWorktree = try makeTempWorktree() let duplicate = Worktree( id: tempWorktree.worktree.id, name: "eagle-duplicate", detail: "duplicate", workingDirectory: tempWorktree.worktree.workingDirectory, repositoryRootURL: tempWorktree.worktree.repositoryRootURL ) let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), unfocusedInterval: .seconds(3_600) ) let (collector, task) = startCollecting(manager.eventStream())
manager.handleCommand(.setPullRequestTrackingEnabled(false)) manager.handleCommand(.setWorktrees([tempWorktree.worktree, duplicate]))
await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: tempWorktree.worktree.id) == 1)
manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempWorktree.tempRoot) }
@Test func staggersDeferredLineChangesAcrossWorktrees() async throws { let clock = TestClock() let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift", "eagle"]) let firstWorktree = try #require(tempRepository.worktrees.first) let secondWorktree = try #require(tempRepository.worktrees.dropFirst().first) let thirdWorktree = try #require(tempRepository.worktrees.dropFirst(2).first) let manager = WorktreeInfoWatcherManager( focusedInterval: .milliseconds(80), unfocusedInterval: .milliseconds(80), lineChangePhaseOffset: { worktreeID, _ in switch worktreeID { case secondWorktree.id: return .milliseconds(10) case thirdWorktree.id: return .milliseconds(40) default: return .zero } }, pullRequestPhaseOffset: { _, _ in .zero }, clock: clock ) let (collector, task) = startCollecting(manager.eventStream())
manager.handleCommand(.setPullRequestTrackingEnabled(false)) manager.handleCommand(.setWorktrees([firstWorktree])) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: firstWorktree.id) == 1)
manager.handleCommand(.setWorktrees([firstWorktree, secondWorktree, thirdWorktree])) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 0) #expect(await collector.filesChangedCount(worktreeID: thirdWorktree.id) == 0)
await clock.advance(by: .milliseconds(89)) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 0) #expect(await collector.filesChangedCount(worktreeID: thirdWorktree.id) == 0)
await clock.advance(by: .milliseconds(1)) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 1) #expect(await collector.filesChangedCount(worktreeID: thirdWorktree.id) == 0)
await clock.advance(by: .milliseconds(29)) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: thirdWorktree.id) == 0)
await clock.advance(by: .milliseconds(1)) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: thirdWorktree.id) == 1)
await clock.advance(by: .milliseconds(120)) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 1) #expect(await collector.filesChangedCount(worktreeID: thirdWorktree.id) == 1)
manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempRepository.tempRoot) }
@Test func activeWorktreeRefreshesLineChangesAfterFileEventDebounce() async throws { let clock = TestClock() let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) let activeWorktree = try #require(tempRepository.worktrees.first) let inactiveWorktree = try #require(tempRepository.worktrees.dropFirst().first) let monitorStore = TestWorktreeFileEventMonitorStore() let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), unfocusedInterval: .seconds(3_600), defaultLineChangesTiming: .init(filesChangedDebounce: .seconds(3_600), eventDebounce: .milliseconds(80)), lineChangesSafetyRefreshInterval: .seconds(3_600), lineChangePhaseOffset: { _, _ in .zero }, pullRequestPhaseOffset: { _, _ in .zero }, worktreeFileEventMonitorFactory: monitorStore.makeMonitor, clock: clock ) let (collector, task) = startCollecting(manager.eventStream())
manager.handleCommand(.setPullRequestTrackingEnabled(false)) manager.handleCommand(.setWorktrees([activeWorktree, inactiveWorktree])) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: activeWorktree.id) == 1) #expect(await collector.filesChangedCount(worktreeID: inactiveWorktree.id) == 1) #expect(monitorStore.monitor(for: activeWorktree.id) == nil) #expect(monitorStore.monitor(for: inactiveWorktree.id) == nil)
manager.handleCommand(.setOpenedWorktreeIDs([activeWorktree.id])) let monitor = try #require(monitorStore.monitor(for: activeWorktree.id)) #expect(monitorStore.monitor(for: inactiveWorktree.id) == nil)
monitor.emit() await clock.advance(by: .milliseconds(79)) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: activeWorktree.id) == 1)
await clock.advance(by: .milliseconds(1)) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: activeWorktree.id) == 2) #expect(await collector.filesChangedCount(worktreeID: inactiveWorktree.id) == 1)
manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempRepository.tempRoot) }
@Test func activeWorktreeUsesSlowSafetyLineChangesRefresh() async throws { let clock = TestClock() let tempRepository = try makeTempRepository(worktreeNames: ["sparrow"]) let worktree = try #require(tempRepository.worktrees.first) let monitorStore = TestWorktreeFileEventMonitorStore() let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), unfocusedInterval: .seconds(3_600), lineChangesSafetyRefreshInterval: .milliseconds(80), lineChangePhaseOffset: { _, _ in .zero }, pullRequestPhaseOffset: { _, _ in .zero }, worktreeFileEventMonitorFactory: monitorStore.makeMonitor, clock: clock ) let (collector, task) = startCollecting(manager.eventStream())
manager.handleCommand(.setPullRequestTrackingEnabled(false)) manager.handleCommand(.setWorktrees([worktree])) manager.handleCommand(.setOpenedWorktreeIDs([worktree.id])) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: worktree.id) == 1)
await clock.advance(by: .milliseconds(79)) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: worktree.id) == 1)
await clock.advance(by: .milliseconds(1)) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: worktree.id) == 2)
manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempRepository.tempRoot) }
@Test func repositoryRegistryEventRefreshesWorktreesAfterDebounce() async throws { let clock = TestClock() let tempRepository = try makeTempRepository(worktreeNames: ["sparrow"]) let registryMonitorStore = TestWorktreeRegistryMonitorStore() let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), unfocusedInterval: .seconds(3_600), repositoryWorktreesEventDebounceInterval: .milliseconds(80), lineChangePhaseOffset: { _, _ in .zero }, pullRequestPhaseOffset: { _, _ in .zero }, worktreeRegistryMonitorFactory: registryMonitorStore.makeMonitor, clock: clock ) let (collector, task) = startCollecting(manager.eventStream())
manager.handleCommand(.setPullRequestTrackingEnabled(false)) manager.handleCommand(.setWorktrees(tempRepository.worktrees)) await drainAsyncEvents(120) #expect(await collector.repositoryWorktreesChangedCount(repositoryRootURL: tempRepository.tempRoot) == 0)
let monitor = try #require(registryMonitorStore.monitor(for: tempRepository.tempRoot)) monitor.emit() await clock.advance(by: .milliseconds(79)) await drainAsyncEvents(120) #expect(await collector.repositoryWorktreesChangedCount(repositoryRootURL: tempRepository.tempRoot) == 0)
await clock.advance(by: .milliseconds(1)) await drainAsyncEvents(120) #expect(await collector.repositoryWorktreesChangedCount(repositoryRootURL: tempRepository.tempRoot) == 1)
manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempRepository.tempRoot) }
@Test func repositoryRemoteConfigEventEmitsChangedEventAfterDebounce() async throws { let clock = TestClock() let tempRepository = try makeTempRepository(worktreeNames: ["sparrow"]) let remoteConfigMonitorStore = TestRemoteConfigMonitorStore() let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), unfocusedInterval: .seconds(3_600), remoteConfigEventDebounceInterval: .milliseconds(80), lineChangePhaseOffset: { _, _ in .zero }, pullRequestPhaseOffset: { _, _ in .zero }, remoteConfigMonitorFactory: remoteConfigMonitorStore.makeMonitor, clock: clock ) let (collector, task) = startCollecting(manager.eventStream())
manager.handleCommand(.setWorktrees(tempRepository.worktrees)) await drainAsyncEvents(120) #expect(await collector.repositoryRemoteConfigurationChangedCount(repositoryRootURL: tempRepository.tempRoot) == 0)
let monitor = try #require(remoteConfigMonitorStore.monitor(for: tempRepository.tempRoot)) monitor.emit() await clock.advance(by: .milliseconds(79)) await drainAsyncEvents(120) #expect(await collector.repositoryRemoteConfigurationChangedCount(repositoryRootURL: tempRepository.tempRoot) == 0)
await clock.advance(by: .milliseconds(1)) await drainAsyncEvents(120) #expect(await collector.repositoryRemoteConfigurationChangedCount(repositoryRootURL: tempRepository.tempRoot) == 1)
manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempRepository.tempRoot) }
@Test func removedRepositoryCancelsRegistryMonitor() async throws { let firstRepository = try makeTempRepository(worktreeNames: ["sparrow"]) let secondRepository = try makeTempRepository(worktreeNames: ["swift"]) let registryMonitorStore = TestWorktreeRegistryMonitorStore() let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), unfocusedInterval: .seconds(3_600), worktreeRegistryMonitorFactory: registryMonitorStore.makeMonitor ) let (_, task) = startCollecting(manager.eventStream())
manager.handleCommand(.setPullRequestTrackingEnabled(false)) manager.handleCommand(.setWorktrees(firstRepository.worktrees + secondRepository.worktrees)) await drainAsyncEvents(120) let removedMonitor = try #require(registryMonitorStore.monitor(for: firstRepository.tempRoot)) #expect(!removedMonitor.isCanceled)
manager.handleCommand(.setWorktrees(secondRepository.worktrees)) await drainAsyncEvents(120) #expect(removedMonitor.isCanceled) #expect(registryMonitorStore.monitor(for: secondRepository.tempRoot)?.isCanceled == false)
manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: firstRepository.tempRoot) try FileManager.default.removeItem(at: secondRepository.tempRoot) }
@Test func staggersPeriodicPullRequestRefreshAcrossRepositories() async throws { let clock = TestClock() let firstRepository = try makeTempRepository(worktreeNames: ["sparrow"]) let secondRepository = try makeTempRepository(worktreeNames: ["swift"]) let firstWorktree = try #require(firstRepository.worktrees.first) let secondWorktree = try #require(secondRepository.worktrees.first) let manager = WorktreeInfoWatcherManager( focusedInterval: .milliseconds(80), unfocusedInterval: .milliseconds(80), unfocusedIntervalPerWorktree: .zero, lineChangePhaseOffset: { _, _ in .zero }, pullRequestPhaseOffset: { repositoryRootURL, _ in switch repositoryRootURL { case firstRepository.tempRoot: return .milliseconds(10) case secondRepository.tempRoot: return .milliseconds(40) default: return .zero } }, clock: clock ) let (collector, task) = startCollecting(manager.eventStream())
manager.handleCommand(.setWorktrees([firstWorktree, secondWorktree])) await drainAsyncEvents(120) #expect(await collector.pullRequestRefreshCount(repositoryRootURL: firstRepository.tempRoot) == 1) #expect(await collector.pullRequestRefreshCount(repositoryRootURL: secondRepository.tempRoot) == 1)
await clock.advance(by: .milliseconds(89)) await drainAsyncEvents(120) #expect(await collector.pullRequestRefreshCount(repositoryRootURL: firstRepository.tempRoot) == 1) #expect(await collector.pullRequestRefreshCount(repositoryRootURL: secondRepository.tempRoot) == 1)
await clock.advance(by: .milliseconds(1)) await drainAsyncEvents(120) #expect(await collector.pullRequestRefreshCount(repositoryRootURL: firstRepository.tempRoot) == 2) #expect(await collector.pullRequestRefreshCount(repositoryRootURL: secondRepository.tempRoot) == 1)
await clock.advance(by: .milliseconds(29)) await drainAsyncEvents(120) #expect(await collector.pullRequestRefreshCount(repositoryRootURL: secondRepository.tempRoot) == 1)
await clock.advance(by: .milliseconds(1)) await drainAsyncEvents(120) #expect(await collector.pullRequestRefreshCount(repositoryRootURL: secondRepository.tempRoot) == 2)
manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: firstRepository.tempRoot) try FileManager.default.removeItem(at: secondRepository.tempRoot) }
@Test func addingAWorktreeRefreshesOnlyItsRepository() async throws { let clock = TestClock() let changedRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) let otherRepository = try makeTempRepository(worktreeNames: ["finch"]) let firstWorktree = try #require(changedRepository.worktrees.first) let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), unfocusedInterval: .seconds(3_600), clock: clock ) let (collector, task) = startCollecting(manager.eventStream())
manager.handleCommand(.setWorktrees([firstWorktree] + otherRepository.worktrees)) await drainAsyncEvents() #expect(await collector.pullRequestRefreshCount(repositoryRootURL: changedRepository.tempRoot) == 1) #expect(await collector.pullRequestRefreshCount(repositoryRootURL: otherRepository.tempRoot) == 1)
manager.handleCommand(.setWorktrees(changedRepository.worktrees + otherRepository.worktrees)) await drainAsyncEvents() #expect(await collector.pullRequestRefreshCount(repositoryRootURL: changedRepository.tempRoot) == 2) #expect(await collector.pullRequestRefreshCount(repositoryRootURL: otherRepository.tempRoot) == 1)
// An unchanged worktree set refreshes nothing. manager.handleCommand(.setWorktrees(changedRepository.worktrees + otherRepository.worktrees)) await drainAsyncEvents() #expect(await collector.pullRequestRefreshCount(repositoryRootURL: changedRepository.tempRoot) == 2) #expect(await collector.pullRequestRefreshCount(repositoryRootURL: otherRepository.tempRoot) == 1)
manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: changedRepository.tempRoot) try FileManager.default.removeItem(at: otherRepository.tempRoot) }
@Test func backgroundRefreshSlowsWithTheNumberOfWorktrees() async throws { let clock = TestClock() let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift", "finch"]) let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), unfocusedInterval: .milliseconds(80), unfocusedIntervalPerWorktree: .milliseconds(100), clock: clock ) let (collector, task) = startCollecting(manager.eventStream())
manager.handleCommand(.setWorktrees(tempRepository.worktrees)) await drainAsyncEvents(120) #expect(await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) == 1)
// Three worktrees at 100 ms each: the sweep runs every 300 ms, not every 80 ms. await clock.advance(by: .milliseconds(299)) await drainAsyncEvents(120) #expect(await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) == 1)
await clock.advance(by: .milliseconds(1)) await drainAsyncEvents(120) #expect(await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) == 2)
manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempRepository.tempRoot) }
@Test func worktreeAddedElsewhereDoesNotPostponeAnUnchangedRepository() async throws { let clock = TestClock() let steadyRepository = try makeTempRepository(worktreeNames: ["sparrow"]) let growingRepository = try makeTempRepository(worktreeNames: ["finch"]) let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), unfocusedInterval: .milliseconds(50), unfocusedIntervalPerWorktree: .milliseconds(100), clock: clock ) let (collector, task) = startCollecting(manager.eventStream())
manager.handleCommand(.setWorktrees(steadyRepository.worktrees)) await drainAsyncEvents(120) #expect(await collector.pullRequestRefreshCount(repositoryRootURL: steadyRepository.tempRoot) == 1)
// A second worktree elsewhere raises the sweep interval from 100 ms to 200 ms; the steady // repository's refresh that was already due at 100 ms still runs then. await clock.advance(by: .milliseconds(60)) manager.handleCommand(.setWorktrees(steadyRepository.worktrees + growingRepository.worktrees)) await drainAsyncEvents(120) await clock.advance(by: .milliseconds(40)) await drainAsyncEvents(120) #expect(await collector.pullRequestRefreshCount(repositoryRootURL: steadyRepository.tempRoot) == 2)
// After that refresh, the new interval applies. await clock.advance(by: .milliseconds(199)) await drainAsyncEvents(120) #expect(await collector.pullRequestRefreshCount(repositoryRootURL: steadyRepository.tempRoot) == 2) await clock.advance(by: .milliseconds(1)) await drainAsyncEvents(120) #expect(await collector.pullRequestRefreshCount(repositoryRootURL: steadyRepository.tempRoot) == 3)
manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: steadyRepository.tempRoot) try FileManager.default.removeItem(at: growingRepository.tempRoot) }
@Test func removingWorktreesElsewhereBringsAnUnchangedRepositoryForward() async throws { let clock = TestClock() let steadyRepository = try makeTempRepository(worktreeNames: ["sparrow"]) let shrinkingRepository = try makeTempRepository(worktreeNames: ["finch", "wren"]) let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), unfocusedInterval: .milliseconds(50), unfocusedIntervalPerWorktree: .milliseconds(100), clock: clock ) let (collector, task) = startCollecting(manager.eventStream())
// Three worktrees: the sweep runs every 300 ms. manager.handleCommand(.setWorktrees(steadyRepository.worktrees + shrinkingRepository.worktrees)) await drainAsyncEvents(120) #expect(await collector.pullRequestRefreshCount(repositoryRootURL: steadyRepository.tempRoot) == 1)
// Down to one worktree at 40 ms: the steady repository's refresh moves from 300 ms to 100 ms. await clock.advance(by: .milliseconds(40)) manager.handleCommand(.setWorktrees(steadyRepository.worktrees)) await drainAsyncEvents(120) await clock.advance(by: .milliseconds(59)) await drainAsyncEvents(120) #expect(await collector.pullRequestRefreshCount(repositoryRootURL: steadyRepository.tempRoot) == 1) await clock.advance(by: .milliseconds(1)) await drainAsyncEvents(120) #expect(await collector.pullRequestRefreshCount(repositoryRootURL: steadyRepository.tempRoot) == 2)
manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: steadyRepository.tempRoot) try FileManager.default.removeItem(at: shrinkingRepository.tempRoot) }
@Test func selectionRefreshUsesCooldownWithinRepository() async throws { let clock = TestClock() let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), unfocusedInterval: .seconds(3_600), pullRequestSelectionRefreshCooldown: .milliseconds(500), clock: clock ) let (collector, task) = startCollecting(manager.eventStream())
manager.handleCommand(.setWorktrees(tempRepository.worktrees)) await drainAsyncEvents() let baselineCount = await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) #expect(baselineCount == 1) let firstWorktree = try #require(tempRepository.worktrees.first) let secondWorktree = try #require(tempRepository.worktrees.dropFirst().first)
await clock.advance(by: .milliseconds(500)) await drainAsyncEvents()
// Selecting first worktree triggers immediate refresh. manager.handleCommand(.setSelectedWorktreeID(firstWorktree.id)) await drainAsyncEvents() #expect(await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) == baselineCount + 1)
// Switching to a different worktree cancels cooldown and triggers immediate refresh. manager.handleCommand(.setSelectedWorktreeID(secondWorktree.id)) await drainAsyncEvents() #expect(await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) == baselineCount + 2)
// Switching back to first worktree also triggers immediate refresh (different worktree). manager.handleCommand(.setSelectedWorktreeID(firstWorktree.id)) await drainAsyncEvents() #expect(await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) == baselineCount + 3)
manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempRepository.tempRoot) }
@Test func reselectingSameWorktreeRespectsCooldown() async throws { let clock = TestClock() let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), unfocusedInterval: .seconds(3_600), pullRequestSelectionRefreshCooldown: .milliseconds(500), clock: clock ) let (collector, task) = startCollecting(manager.eventStream())
manager.handleCommand(.setWorktrees(tempRepository.worktrees)) await drainAsyncEvents() let baselineCount = await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) #expect(baselineCount == 1) let firstWorktree = try #require(tempRepository.worktrees.first)
await clock.advance(by: .milliseconds(500)) await drainAsyncEvents()
// Selecting worktree triggers immediate refresh and starts cooldown. manager.handleCommand(.setSelectedWorktreeID(firstWorktree.id)) await drainAsyncEvents() #expect(await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) == baselineCount + 1)
// Deselect then re-select the same worktree within cooldown — should NOT refresh. manager.handleCommand(.setSelectedWorktreeID(nil)) await drainAsyncEvents() manager.handleCommand(.setSelectedWorktreeID(firstWorktree.id)) await drainAsyncEvents() #expect(await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) == baselineCount + 1)
// After cooldown expires, re-selecting the same worktree should refresh. await clock.advance(by: .milliseconds(500)) await drainAsyncEvents() manager.handleCommand(.setSelectedWorktreeID(nil)) await drainAsyncEvents() manager.handleCommand(.setSelectedWorktreeID(firstWorktree.id)) await drainAsyncEvents() #expect(await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) == baselineCount + 2)
manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempRepository.tempRoot) }
@Test func lineChangesTimingTierSelectsCorrectTier() { let small = WorktreeInfoWatcherManager.LineChangesTiming.tier(forIndexEntryCount: 500) #expect(small == .small)
let smallBoundary = WorktreeInfoWatcherManager.LineChangesTiming.tier(forIndexEntryCount: 4_999) #expect(smallBoundary == .small)
let medium = WorktreeInfoWatcherManager.LineChangesTiming.tier(forIndexEntryCount: 5_000) #expect(medium == .medium)
let mediumBoundary = WorktreeInfoWatcherManager.LineChangesTiming.tier(forIndexEntryCount: 19_999) #expect(mediumBoundary == .medium)
let large = WorktreeInfoWatcherManager.LineChangesTiming.tier(forIndexEntryCount: 20_000) #expect(large == .large)
let veryLarge = WorktreeInfoWatcherManager.LineChangesTiming.tier(forIndexEntryCount: 100_000) #expect(veryLarge == .large) }
@Test func largeRepoUsesLongerEventDebounce() async throws { let clock = TestClock() let tempRepository = try makeTempRepository(worktreeNames: ["sparrow"]) let worktree = try #require(tempRepository.worktrees.first) let monitorStore = TestWorktreeFileEventMonitorStore() let largeTiming = WorktreeInfoWatcherManager.LineChangesTiming.large let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), unfocusedInterval: .seconds(3_600), defaultLineChangesTiming: largeTiming, lineChangesSafetyRefreshInterval: .seconds(3_600), lineChangePhaseOffset: { _, _ in .zero }, pullRequestPhaseOffset: { _, _ in .zero }, worktreeFileEventMonitorFactory: monitorStore.makeMonitor, indexEntryCountProvider: { _ in nil }, clock: clock ) let (collector, task) = startCollecting(manager.eventStream())
manager.handleCommand(.setPullRequestTrackingEnabled(false)) manager.handleCommand(.setWorktrees([worktree])) manager.handleCommand(.setOpenedWorktreeIDs([worktree.id])) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: worktree.id) == 1)
let monitor = try #require(monitorStore.monitor(for: worktree.id)) monitor.emit()
await clock.advance(by: largeTiming.eventDebounce - .milliseconds(1)) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: worktree.id) == 1)
await clock.advance(by: .milliseconds(1)) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: worktree.id) == 2)
manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempRepository.tempRoot) }
@Test func plainRepositoryMonitorFailuresDoNotSkipRemainingRoots() { let roots = (0..<3).map { index in URL(fileURLWithPath: "/tmp/plain-repository-\(index)", isDirectory: true) } let monitorStore = TestPlainRepositoryFileEventMonitorStore(failedRoots: Set(roots)) let manager = WorktreeInfoWatcherManager( plainRepositoryFileEventMonitorFactory: monitorStore.makeMonitor )
manager.handleCommand(.setPlainRepositoryRoots(roots))
#expect(Set(monitorStore.attemptedRoots) == Set(roots.map(\.standardizedFileURL))) manager.handleCommand(.stop) }
@Test func plainRepositoryMonitorsTrackRootChangesAndStop() throws { let firstRoot = FileManager.default.temporaryDirectory .appending(path: UUID().uuidString, directoryHint: .isDirectory) let secondRoot = FileManager.default.temporaryDirectory .appending(path: UUID().uuidString, directoryHint: .isDirectory) let monitorStore = TestPlainRepositoryFileEventMonitorStore() let manager = WorktreeInfoWatcherManager( plainRepositoryFileEventMonitorFactory: monitorStore.makeMonitor )
manager.handleCommand(.setPlainRepositoryRoots([firstRoot, secondRoot])) let firstMonitor = try #require(monitorStore.monitor(for: firstRoot)) let secondMonitor = try #require(monitorStore.monitor(for: secondRoot))
manager.handleCommand(.setPlainRepositoryRoots([secondRoot])) #expect(firstMonitor.isCanceled) #expect(!secondMonitor.isCanceled)
manager.handleCommand(.stop) #expect(secondMonitor.isCanceled) }
@Test func plainRepositoryGitEventEmitsOnceUntilDotGitDisappears() async throws { let clock = TestClock() let root = FileManager.default.temporaryDirectory .appending(path: UUID().uuidString, directoryHint: .isDirectory) let dotGitURL = root.appending(path: ".git", directoryHint: .isDirectory) defer { try? FileManager.default.removeItem(at: root) } try FileManager.default.createDirectory(at: dotGitURL, withIntermediateDirectories: true) let monitorStore = TestPlainRepositoryFileEventMonitorStore() let manager = WorktreeInfoWatcherManager( plainRepositoryFileEventMonitorFactory: monitorStore.makeMonitor, clock: clock ) let (collector, task) = startCollecting(manager.eventStream())
manager.handleCommand(.setPlainRepositoryRoots([root])) let monitor = try #require(monitorStore.monitor(for: root))
monitor.emit() await drainAsyncEvents() await clock.advance(by: .seconds(1)) await drainAsyncEvents(120) #expect(await collector.plainRepositoryGitEventCount(rootURL: root) == 1)
monitor.emit() await drainAsyncEvents() await clock.advance(by: .seconds(1)) await drainAsyncEvents(120) #expect(await collector.plainRepositoryGitEventCount(rootURL: root) == 1)
try FileManager.default.removeItem(at: dotGitURL) monitor.emit() await drainAsyncEvents() await clock.advance(by: .seconds(1)) await drainAsyncEvents(120) #expect(await collector.plainRepositoryGitEventCount(rootURL: root) == 1)
try FileManager.default.createDirectory(at: dotGitURL, withIntermediateDirectories: true) monitor.emit() await drainAsyncEvents() await clock.advance(by: .seconds(1)) await drainAsyncEvents(120) #expect(await collector.plainRepositoryGitEventCount(rootURL: root) == 2)
manager.handleCommand(.stop) await task.value }
@Test func adaptiveTimingResolvesFromIndexEntryCount() async throws { let clock = TestClock() let tempRepository = try makeTempRepository(worktreeNames: ["sparrow"]) let worktree = try #require(tempRepository.worktrees.first) let monitorStore = TestWorktreeFileEventMonitorStore() let mediumTiming = WorktreeInfoWatcherManager.LineChangesTiming.medium let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), unfocusedInterval: .seconds(3_600), lineChangesSafetyRefreshInterval: .seconds(3_600), lineChangePhaseOffset: { _, _ in .zero }, pullRequestPhaseOffset: { _, _ in .zero }, worktreeFileEventMonitorFactory: monitorStore.makeMonitor, indexEntryCountProvider: { _ in 10_000 }, clock: clock ) let (collector, task) = startCollecting(manager.eventStream())
manager.handleCommand(.setPullRequestTrackingEnabled(false)) manager.handleCommand(.setWorktrees([worktree])) manager.handleCommand(.setOpenedWorktreeIDs([worktree.id])) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: worktree.id) == 1)
let monitor = try #require(monitorStore.monitor(for: worktree.id)) monitor.emit()
await clock.advance(by: mediumTiming.eventDebounce - .milliseconds(1)) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: worktree.id) == 1)
await clock.advance(by: .milliseconds(1)) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: worktree.id) == 2)
manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempRepository.tempRoot) }
@Test func canceledSelectionCooldownDoesNotClearReplacementCooldown() async throws { let clock = TestClock() let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), unfocusedInterval: .seconds(3_600), pullRequestSelectionRefreshCooldown: .milliseconds(500), clock: clock ) let (collector, task) = startCollecting(manager.eventStream())
manager.handleCommand(.setWorktrees(tempRepository.worktrees)) await drainAsyncEvents() let baselineCount = await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) #expect(baselineCount == 1)
let firstWorktree = try #require(tempRepository.worktrees.first) let secondWorktree = try #require(tempRepository.worktrees.dropFirst().first)
manager.handleCommand(.setSelectedWorktreeID(firstWorktree.id)) await drainAsyncEvents() let afterFirstSelectionCount = await collector.pullRequestRefreshCount( repositoryRootURL: tempRepository.tempRoot ) #expect(afterFirstSelectionCount == baselineCount + 1)
manager.handleCommand(.setPullRequestTrackingEnabled(false)) manager.handleCommand(.setPullRequestTrackingEnabled(true)) manager.handleCommand(.setSelectedWorktreeID(secondWorktree.id)) await drainAsyncEvents() let afterReplacementCooldownCount = await collector.pullRequestRefreshCount( repositoryRootURL: tempRepository.tempRoot ) #expect(afterReplacementCooldownCount == afterFirstSelectionCount + 2)
// Switching back to first worktree triggers refresh (different worktree). manager.handleCommand(.setSelectedWorktreeID(firstWorktree.id)) await drainAsyncEvents() #expect( await collector.pullRequestRefreshCount(repositoryRootURL: tempRepository.tempRoot) == afterReplacementCooldownCount + 1 )
manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempRepository.tempRoot) } @Test func deferredWorktreeEmitsFilesChangedOnSelection() async throws { // Regression test: worktrees added after initial load go into deferredLineChangeIDs, // which previously blocked all .filesChanged events until the 5-minute safety refresh. // After the fix, selecting a deferred worktree calls emitLineChangesChanged which // clears the deferred flag and immediately emits .filesChanged. let clock = TestClock() let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) let firstWorktree = try #require(tempRepository.worktrees.first) let secondWorktree = try #require(tempRepository.worktrees.dropFirst().first) let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), unfocusedInterval: .seconds(3_600), lineChangePhaseOffset: { _, _ in .zero }, pullRequestPhaseOffset: { _, _ in .zero }, clock: clock ) let (collector, task) = startCollecting(manager.eventStream())
manager.handleCommand(.setPullRequestTrackingEnabled(false)) // Load only the first worktree initially — second will be deferred when added. manager.handleCommand(.setWorktrees([firstWorktree])) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: firstWorktree.id) == 1)
// Add second worktree — it goes into deferredLineChangeIDs, no filesChanged yet. manager.handleCommand(.setWorktrees([firstWorktree, secondWorktree])) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 0)
// Selecting the deferred worktree clears the deferred flag and emits immediately. manager.handleCommand(.setSelectedWorktreeID(secondWorktree.id)) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 1)
manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempRepository.tempRoot) }
@Test func headWatcherEventClearsDeferredLineChangesAfterFilesChangedDebounce() async throws { let clock = TestClock() let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) let firstWorktree = try #require(tempRepository.worktrees.first) let secondWorktree = try #require(tempRepository.worktrees.dropFirst().first) let headMonitorStore = TestWorktreeHeadEventMonitorStore() let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), unfocusedInterval: .seconds(3_600), defaultLineChangesTiming: .init(filesChangedDebounce: .milliseconds(80), eventDebounce: .seconds(3_600)), lineChangePhaseOffset: { _, _ in .zero }, pullRequestPhaseOffset: { _, _ in .zero }, worktreeHeadEventMonitorFactory: headMonitorStore.makeMonitor, clock: clock ) let (collector, task) = startCollecting(manager.eventStream())
manager.handleCommand(.setPullRequestTrackingEnabled(false)) manager.handleCommand(.setWorktrees([firstWorktree])) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: firstWorktree.id) == 1)
manager.handleCommand(.setWorktrees([firstWorktree, secondWorktree])) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 0)
let monitor = try #require(headMonitorStore.monitor(for: secondWorktree.id)) monitor.emit(.write) await drainAsyncEvents(120)
await clock.advance(by: .milliseconds(79)) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 0)
await clock.advance(by: .milliseconds(1)) await drainAsyncEvents(120) #expect(await collector.filesChangedCount(worktreeID: secondWorktree.id) == 1)
manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempRepository.tempRoot) }
}
actor EventCollector { private var events: [WorktreeInfoWatcherClient.Event] = []
func append(_ event: WorktreeInfoWatcherClient.Event) { events.append(event) }
func filesChangedCount(worktreeID: Worktree.ID) -> Int { events.reduce(into: 0) { result, event in if case .filesChanged(let id) = event, id == worktreeID { result += 1 } } }
func pullRequestRefreshCount(repositoryRootURL: URL) -> Int { events.reduce(into: 0) { result, event in if case .repositoryPullRequestRefresh(let rootURL, _) = event, rootURL == repositoryRootURL { result += 1 } } }
func repositoryWorktreesChangedCount(repositoryRootURL: URL) -> Int { let expectedURL = repositoryRootURL.standardizedFileURL return events.reduce(into: 0) { result, event in if case .repositoryWorktreesChanged(let rootURL) = event, rootURL.standardizedFileURL == expectedURL { result += 1 } } }
func repositoryRemoteConfigurationChangedCount(repositoryRootURL: URL) -> Int { let expectedURL = repositoryRootURL.standardizedFileURL return events.reduce(into: 0) { result, event in if case .repositoryRemoteConfigurationChanged(let rootURL) = event, rootURL.standardizedFileURL == expectedURL { result += 1 } } }
func plainRepositoryGitEventCount(rootURL: URL) -> Int { let expectedURL = rootURL.standardizedFileURL return events.reduce(into: 0) { result, event in if case .plainRepositoryBecameGitRepository(let emittedURL) = event, emittedURL.standardizedFileURL == expectedURL { result += 1 } } }}
private struct TempWorktree { let worktree: Worktree let tempRoot: URL let headURL: URL}
private struct TempRepository { let worktrees: [Worktree] let tempRoot: URL}
private func makeTempWorktree() throws -> TempWorktree { let fileManager = FileManager.default let tempRoot = fileManager.temporaryDirectory.appending(path: UUID().uuidString) let worktreeDirectory = tempRoot.appending(path: "wt") let gitDirectory = worktreeDirectory.appending(path: ".git") try fileManager.createDirectory(at: gitDirectory, withIntermediateDirectories: true) let headURL = gitDirectory.appending(path: "HEAD") try "ref: refs/heads/main\n".write(to: headURL, atomically: true, encoding: .utf8) let worktree = Worktree( id: worktreeDirectory.path(percentEncoded: false), name: "eagle", detail: "detail", workingDirectory: worktreeDirectory, repositoryRootURL: tempRoot ) return TempWorktree(worktree: worktree, tempRoot: tempRoot, headURL: headURL)}
private func makeTempRepository(worktreeNames: [String]) throws -> TempRepository { let fileManager = FileManager.default let tempRoot = fileManager.temporaryDirectory.appending(path: UUID().uuidString) var worktrees: [Worktree] = [] for name in worktreeNames { let worktreeDirectory = tempRoot.appending(path: name) let gitDirectory = worktreeDirectory.appending(path: ".git") try fileManager.createDirectory(at: gitDirectory, withIntermediateDirectories: true) let headURL = gitDirectory.appending(path: "HEAD") try "ref: refs/heads/\(name)\n".write(to: headURL, atomically: true, encoding: .utf8) let worktree = Worktree( id: worktreeDirectory.path(percentEncoded: false), name: name, detail: "detail", workingDirectory: worktreeDirectory, repositoryRootURL: tempRoot ) worktrees.append(worktree) } return TempRepository(worktrees: worktrees, tempRoot: tempRoot)}
private func startCollecting( _ stream: AsyncStream<WorktreeInfoWatcherClient.Event>) -> (EventCollector, Task<Void, Never>) { let collector = EventCollector() let task = Task { for await event in stream { if Task.isCancelled { break } await collector.append(event) } } return (collector, task)}
private func drainAsyncEvents(_ iterations: Int = 20) async { for _ in 0..<iterations { await Task.yield() }}
@MainActorprivate final class TestWorktreeHeadEventMonitorStore { private var monitors: [Worktree.ID: TestWorktreeHeadEventMonitor] = [:]
func makeMonitor( worktreeID: Worktree.ID, headURL _: URL, onEvent: @escaping @MainActor @Sendable (DispatchSource.FileSystemEvent) -> Void ) -> WorktreeHeadEventMonitoring? { let monitor = TestWorktreeHeadEventMonitor(onEvent: onEvent) monitors[worktreeID] = monitor return monitor }
func monitor(for worktreeID: Worktree.ID) -> TestWorktreeHeadEventMonitor? { monitors[worktreeID] }}
@MainActorprivate final class TestWorktreeHeadEventMonitor: WorktreeHeadEventMonitoring { private let onEvent: @MainActor @Sendable (DispatchSource.FileSystemEvent) -> Void private(set) var isCanceled = false
init(onEvent: @escaping @MainActor @Sendable (DispatchSource.FileSystemEvent) -> Void) { self.onEvent = onEvent }
func emit(_ event: DispatchSource.FileSystemEvent) { onEvent(event) }
func cancel() { isCanceled = true }}
@MainActorprivate final class TestWorktreeFileEventMonitorStore { private var monitors: [Worktree.ID: TestWorktreeFileEventMonitor] = [:]
func makeMonitor( worktree: Worktree, onEvent: @escaping @MainActor @Sendable () -> Void ) -> WorktreeFileEventMonitoring? { let monitor = TestWorktreeFileEventMonitor(onEvent: onEvent) monitors[worktree.id] = monitor return monitor }
func monitor(for worktreeID: Worktree.ID) -> TestWorktreeFileEventMonitor? { monitors[worktreeID] }}
@MainActorprivate final class TestWorktreeFileEventMonitor: WorktreeFileEventMonitoring { private let onEvent: @MainActor @Sendable () -> Void private(set) var isCanceled = false
init(onEvent: @escaping @MainActor @Sendable () -> Void) { self.onEvent = onEvent }
func emit() { onEvent() }
func cancel() { isCanceled = true }}
@MainActorprivate final class TestPlainRepositoryFileEventMonitorStore { private let failedRoots: Set<URL> private var monitors: [URL: TestWorktreeFileEventMonitor] = [:] private(set) var attemptedRoots: [URL] = []
init(failedRoots: Set<URL> = []) { self.failedRoots = Set(failedRoots.map(\.standardizedFileURL)) }
func makeMonitor( rootURL: URL, onEvent: @escaping @MainActor @Sendable () -> Void ) -> WorktreeFileEventMonitoring? { let root = rootURL.standardizedFileURL attemptedRoots.append(root) guard !failedRoots.contains(root) else { return nil } let monitor = TestWorktreeFileEventMonitor(onEvent: onEvent) monitors[root] = monitor return monitor }
func monitor(for rootURL: URL) -> TestWorktreeFileEventMonitor? { monitors[rootURL.standardizedFileURL] }}
@MainActorprivate final class TestWorktreeRegistryMonitorStore { private var monitors: [URL: TestWorktreeRegistryMonitor] = [:]
func makeMonitor( repositoryRootURL: URL, onEvent: @escaping @MainActor @Sendable () -> Void ) -> WorktreeRegistryMonitoring? { let monitor = TestWorktreeRegistryMonitor(onEvent: onEvent) monitors[repositoryRootURL.standardizedFileURL] = monitor return monitor }
func monitor(for repositoryRootURL: URL) -> TestWorktreeRegistryMonitor? { monitors[repositoryRootURL.standardizedFileURL] }}
@MainActorprivate final class TestWorktreeRegistryMonitor: WorktreeRegistryMonitoring { private let onEvent: @MainActor @Sendable () -> Void private(set) var isCanceled = false
init(onEvent: @escaping @MainActor @Sendable () -> Void) { self.onEvent = onEvent }
func emit() { onEvent() }
func cancel() { isCanceled = true }}
@MainActorprivate final class TestRemoteConfigMonitorStore { private var monitors: [URL: TestRemoteConfigMonitor] = [:]
func makeMonitor( repositoryRootURL: URL, onEvent: @escaping @MainActor @Sendable () -> Void ) -> RemoteConfigMonitoring? { let monitor = TestRemoteConfigMonitor(onEvent: onEvent) monitors[repositoryRootURL.standardizedFileURL] = monitor return monitor }
func monitor(for repositoryRootURL: URL) -> TestRemoteConfigMonitor? { monitors[repositoryRootURL.standardizedFileURL] }}
@MainActorprivate final class TestRemoteConfigMonitor: RemoteConfigMonitoring { private let onEvent: @MainActor @Sendable () -> Void private(set) var isCanceled = false
init(onEvent: @escaping @MainActor @Sendable () -> Void) { self.onEvent = onEvent }
func emit() { onEvent() }
func cancel() { isCanceled = true }}