native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089import 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), 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 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 }}