From 55fc5c0dede65b4e492d258164611a18ecaf834a Mon Sep 17 00:00:00 2001 From: joker47 <87397+lmjiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:13:30 +0800 Subject: [PATCH 1/2] Make worktree refresh event-driven --- .../WorktreeInfoWatcherClient.swift | 1 + supacode/Commands/WorktreeCommands.swift | 2 +- .../App/Models/WorktreeMenuSnapshot.swift | 1 + .../Features/App/Reducer/AppFeature.swift | 11 +- .../WorktreeInfoWatcherManager.swift | 340 ++++++++++-------- .../Reducer/RepositoriesFeature.swift | 14 +- .../AppFeatureCommandPaletteTests.swift | 28 ++ supacodeTests/RepositoriesFeatureTests.swift | 41 ++- .../WorktreeInfoWatcherManagerTests.swift | 311 +++++++++++++++- 9 files changed, 579 insertions(+), 170 deletions(-) diff --git a/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift b/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift index 6a8e11bda..afb833923 100644 --- a/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift +++ b/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift @@ -9,6 +9,7 @@ struct WorktreeInfoWatcherClient { case setWorktrees([Worktree]) case setSelectedWorktreeID(Worktree.ID?) case setPullRequestTrackingEnabled(Bool) + case refresh case stop } diff --git a/supacode/Commands/WorktreeCommands.swift b/supacode/Commands/WorktreeCommands.swift index 0d8bb8f0f..000544507 100644 --- a/supacode/Commands/WorktreeCommands.swift +++ b/supacode/Commands/WorktreeCommands.swift @@ -88,7 +88,7 @@ private struct WorktreeMainMenu: Commands { .disabled(snapshot.selectedPullRequestURL == nil || !snapshot.githubIntegrationEnabled) Divider() Button("Refresh Worktrees", systemImage: "arrow.clockwise") { - store.send(.repositories(.refreshWorktrees)) + store.send(.refreshWorktreesRequested) } .appKeyboardShortcut(refresh) .help("Refresh (\(refresh?.display ?? "none"))") diff --git a/supacode/Features/App/Models/WorktreeMenuSnapshot.swift b/supacode/Features/App/Models/WorktreeMenuSnapshot.swift index 7690ea156..5d7ad89af 100644 --- a/supacode/Features/App/Models/WorktreeMenuSnapshot.swift +++ b/supacode/Features/App/Models/WorktreeMenuSnapshot.swift @@ -121,6 +121,7 @@ extension AppFeature.Action { case .applicationDidBecomeActive, .applicationDidResignActive, .appLaunched, .scenePhaseChanged, .openActionSelectionChanged, .refreshInstalledOpenActions, .installedOpenActionsResolved, + .refreshWorktreesRequested, .worktreeSettingsLoaded, .openSelectedWorktree, .revealInFinder, .openWorktree, .openWorktreeFailed, .openFile, .requestQuit, .requestTerminateAllTerminalSessions, .newTerminal, .renameSelectedTerminalTab, diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index a8629bec6..bb8485be0 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -284,6 +284,7 @@ struct AppFeature { case appLaunched case scenePhaseChanged(ScenePhase) case repositories(RepositoriesFeature.Action) + case refreshWorktreesRequested case settings(SettingsFeature.Action) case updates(UpdatesFeature.Action) case commandPalette(CommandPaletteFeature.Action) @@ -428,6 +429,14 @@ struct AppFeature { case .agentPresence: return .none + case .refreshWorktreesRequested: + return .merge( + .send(.repositories(.refreshWorktrees)), + .run { _ in + await worktreeInfoWatcher.send(.refresh) + } + ) + case .scenePhaseChanged(let phase): switch phase { case .active: @@ -1489,7 +1498,7 @@ struct AppFeature { return .send(.repositories(.selectArchivedWorktrees)) case .commandPalette(.delegate(.refreshWorktrees)): - return .send(.repositories(.refreshWorktrees)) + return .send(.refreshWorktreesRequested) case .commandPalette(.delegate(.ghosttyCommand(let action))): guard let worktree = state.repositories.worktree(for: state.repositories.selectedWorktreeID) else { diff --git a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift index 9b6bbc188..6087367be 100644 --- a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift +++ b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift @@ -1,3 +1,4 @@ +import CoreServices import Darwin import Dispatch import Foundation @@ -5,6 +6,79 @@ import SupacodeSettingsShared private let watcherLogger = SupaLogger("WorktreeInfoWatcher") +private final class WorktreeFileEventMonitor { + let rootURL: URL + private let onEvent: @MainActor @Sendable () -> Void + private nonisolated(unsafe) var stream: FSEventStreamRef? + + init?( + rootURL: URL, + onEvent: @escaping @MainActor @Sendable () -> Void + ) { + self.rootURL = rootURL + self.onEvent = onEvent + let path = rootURL.path(percentEncoded: false) + var context = FSEventStreamContext( + version: 0, + info: nil, + retain: nil, + release: nil, + copyDescription: nil + ) + context.info = Unmanaged.passUnretained(self).toOpaque() + let callback: FSEventStreamCallback = { _, callbackInfo, _, _, _, _ in + guard let callbackInfo else { return } + let monitor = Unmanaged + .fromOpaque(callbackInfo) + .takeUnretainedValue() + Task { @MainActor in + monitor.onEvent() + } + } + stream = FSEventStreamCreate( + nil, + callback, + &context, + [path] as CFArray, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + 1.0, + FSEventStreamCreateFlags( + kFSEventStreamCreateFlagFileEvents + | kFSEventStreamCreateFlagNoDefer + | kFSEventStreamCreateFlagWatchRoot + ) + ) + guard let stream else { + return nil + } + FSEventStreamSetDispatchQueue(stream, DispatchQueue.main) + guard FSEventStreamStart(stream) else { + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + self.stream = nil + return nil + } + } + + deinit { + Self.release(&stream) + } + + func cancel() { + Self.release(&stream) + } + + private nonisolated static func release(_ stream: inout FSEventStreamRef?) { + guard let streamRef = stream else { + return + } + FSEventStreamStop(streamRef) + FSEventStreamInvalidate(streamRef) + FSEventStreamRelease(streamRef) + stream = nil + } +} + @MainActor final class WorktreeInfoWatcherManager { /// Hard cap on the live event buffer. These events are refresh signals (not @@ -28,14 +102,6 @@ final class WorktreeInfoWatcherManager { let task: Task } - private struct RepeatingTaskRequest { - let worktreeID: Worktree.ID - let interval: Duration - let immediate: Bool - let forceReschedule: Bool - let makeEvent: (Worktree.ID) -> WorktreeInfoWatcherClient.Event - } - private struct RefreshTiming: Equatable { let focused: Duration let unfocused: Duration @@ -51,16 +117,16 @@ final class WorktreeInfoWatcherManager { private let pollRemoteBranch: @Sendable (Worktree) async -> String? private var worktrees: [Worktree.ID: Worktree] = [:] private var headWatchers: [Worktree.ID: HeadWatcher] = [:] + private var fileEventMonitors: [Worktree.ID: WorktreeFileEventMonitor] = [:] /// Remote worktrees can't kqueue their `.git/HEAD` (it lives on another /// host), so they poll `git rev-parse` over SSH on the same focused / - /// unfocused cadence as line-changes / PR refresh. + /// unfocused cadence. private var remoteHeadPollTasks: [Worktree.ID: RefreshTask] = [:] private var lastKnownRemoteBranch: [Worktree.ID: String] = [:] private var branchDebounceTasks: [Worktree.ID: Task] = [:] private var filesDebounceTasks: [Worktree.ID: Task] = [:] private var restartTasks: [Worktree.ID: Task] = [:] - private var pullRequestTasks: [URL: RefreshTask] = [:] - private var lineChangeTasks: [Worktree.ID: RefreshTask] = [:] + private var lineChangeRefreshTasks: [Worktree.ID: Task] = [:] private var deferredLineChangeIDs: Set = [] private var hasCompletedInitialWorktreeLoad = false private var selectedWorktreeID: Worktree.ID? @@ -96,6 +162,8 @@ final class WorktreeInfoWatcherManager { setSelectedWorktreeID(worktreeID) case .setPullRequestTrackingEnabled(let isEnabled): setPullRequestTrackingEnabled(isEnabled) + case .refresh: + refreshAll() case .stop: stopAll() } @@ -113,6 +181,7 @@ final class WorktreeInfoWatcherManager { private func setWorktrees(_ worktrees: [Worktree]) { let isInitialWorktreeLoad = !hasCompletedInitialWorktreeLoad && self.worktrees.isEmpty && !worktrees.isEmpty + let previousWorktrees = self.worktrees // Keep the first entry on a duplicate WorktreeID instead of trapping; a repo registered // under both its working dir and `.bare/` enumerates the same worktree twice. let worktreesByID = Dictionary(worktrees.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) @@ -133,23 +202,26 @@ final class WorktreeInfoWatcherManager { // Iterate the de-duplicated values so a duplicate WorktreeID doesn't configure // the same watcher or emit its immediate refresh twice. var repositoryRoots: Set = [] + var repositoryRootsToRefresh = Set(removedIDs.compactMap { previousWorktrees[$0]?.repositoryRootURL }) for worktree in worktreesByID.values { configureWatcher(for: worktree) - updateLineChangeSchedule( - worktreeID: worktree.id, - immediate: isInitialWorktreeLoad || !deferredLineChangeIDs.contains(worktree.id) - ) + let didWorktreeChange = previousWorktrees[worktree.id] != worktree + if isInitialWorktreeLoad || newIDs.contains(worktree.id) || didWorktreeChange { + repositoryRootsToRefresh.insert(worktree.repositoryRootURL) + let isDeferred = deferredLineChangeIDs.contains(worktree.id) + if isDeferred { + scheduleLineChangeRefresh(worktreeID: worktree.id, delay: refreshInterval(for: worktree.id)) + } else { + emitLineChangesChanged(worktreeID: worktree.id) + } + } repositoryRoots.insert(worktree.repositoryRootURL) } if isInitialWorktreeLoad { hasCompletedInitialWorktreeLoad = true } - for repositoryRootURL in repositoryRoots { - updatePullRequestSchedule(repositoryRootURL: repositoryRootURL, immediate: true) - } - let obsoleteRepositories = pullRequestTasks.keys.filter { !repositoryRoots.contains($0) } - for repositoryRootURL in obsoleteRepositories { - pullRequestTasks.removeValue(forKey: repositoryRootURL)?.task.cancel() + for repositoryRootURL in repositoryRootsToRefresh { + refreshPullRequests(repositoryRootURL: repositoryRootURL) } let obsoleteCooldownRepositories = pullRequestSelectionCooldownTasksByRepo.keys.filter { !repositoryRoots.contains($0) @@ -168,32 +240,24 @@ final class WorktreeInfoWatcherManager { selectedWorktreeID = worktreeID let nextRepository = worktreeID.flatMap { worktrees[$0]?.repositoryRootURL } if let previousWorktreeID { - updateLineChangeSchedule(worktreeID: previousWorktreeID, immediate: false) if let worktree = worktrees[previousWorktreeID] { configureRemoteHeadPoll(for: worktree) } } if let worktreeID { - updateLineChangeSchedule(worktreeID: worktreeID, immediate: true) + emitLineChangesChanged(worktreeID: worktreeID) if let worktree = worktrees[worktreeID] { configureRemoteHeadPoll(for: worktree) } } if let previousRepository, previousRepository == nextRepository { - updatePullRequestSchedule( - repositoryRootURL: previousRepository, - immediate: shouldImmediatelyRefreshPullRequests(repositoryRootURL: previousRepository) - ) + if shouldImmediatelyRefreshPullRequests(repositoryRootURL: previousRepository) { + refreshPullRequests(repositoryRootURL: previousRepository) + } return } - if let previousRepository { - updatePullRequestSchedule(repositoryRootURL: previousRepository, immediate: false) - } - if let nextRepository { - updatePullRequestSchedule( - repositoryRootURL: nextRepository, - immediate: shouldImmediatelyRefreshPullRequests(repositoryRootURL: nextRepository) - ) + if let nextRepository, shouldImmediatelyRefreshPullRequests(repositoryRootURL: nextRepository) { + refreshPullRequests(repositoryRootURL: nextRepository) } } @@ -202,6 +266,8 @@ final class WorktreeInfoWatcherManager { // route them to the SSH poll loop and skip the local head-file resolver // (which would return nil for a non-local path and silently drop the row). if worktree.host != nil { + stopHeadWatcher(for: worktree.id) + stopFileEventMonitor(for: worktree.id) configureRemoteHeadPoll(for: worktree) return } @@ -215,9 +281,11 @@ final class WorktreeInfoWatcherManager { return } if let existing = headWatchers[worktree.id], existing.headURL == headURL { + configureFileEventMonitor(for: worktree) return } stopWatcher(for: worktree.id) + configureFileEventMonitor(for: worktree) startWatcher(worktreeID: worktree.id, headURL: headURL) } @@ -247,6 +315,18 @@ final class WorktreeInfoWatcherManager { headWatchers[worktreeID] = HeadWatcher(headURL: headURL, source: source) } + private func configureFileEventMonitor(for worktree: Worktree) { + if let existing = fileEventMonitors[worktree.id], existing.rootURL == worktree.workingDirectory { + return + } + stopFileEventMonitor(for: worktree.id) + fileEventMonitors[worktree.id] = WorktreeFileEventMonitor( + rootURL: worktree.workingDirectory + ) { [weak self] in + self?.scheduleFilesChanged(worktreeID: worktree.id) + } + } + private func handleEvent( worktreeID: Worktree.ID, event: DispatchSource.FileSystemEvent @@ -266,8 +346,11 @@ final class WorktreeInfoWatcherManager { let sleep = self.sleep let task = Task { [weak self, sleep] in try? await sleep(.milliseconds(200)) + guard !Task.isCancelled else { + return + } await MainActor.run { - self?.emit(.branchChanged(worktreeID: worktreeID)) + self?.emitBranchChanged(worktreeID: worktreeID) } } branchDebounceTasks[worktreeID] = task @@ -279,16 +362,12 @@ final class WorktreeInfoWatcherManager { let sleep = self.sleep let task = Task { [weak self, sleep] in try? await sleep(debounceInterval) + guard !Task.isCancelled else { + return + } await MainActor.run { guard let self else { return } - self.emit(.filesChanged(worktreeID: worktreeID)) - if !self.deferredLineChangeIDs.contains(worktreeID) { - self.updateLineChangeSchedule( - worktreeID: worktreeID, - immediate: false, - forceReschedule: true - ) - } + self.emitLineChangesChanged(worktreeID: worktreeID) } } filesDebounceTasks[worktreeID] = task @@ -299,6 +378,9 @@ final class WorktreeInfoWatcherManager { let sleep = self.sleep let task = Task { [weak self, sleep] in try? await sleep(.seconds(5)) + guard !Task.isCancelled else { + return + } await MainActor.run { self?.restartWatcher(worktreeID: worktreeID) } @@ -326,7 +408,7 @@ final class WorktreeInfoWatcherManager { return } let worktreeID = worktree.id - let interval = worktreeID == selectedWorktreeID ? refreshTiming.focused : refreshTiming.unfocused + let interval = refreshInterval(for: worktreeID) if let existing = remoteHeadPollTasks[worktreeID], existing.interval == interval { return } @@ -374,19 +456,37 @@ final class WorktreeInfoWatcherManager { } } + private func stopFileEventMonitor(for worktreeID: Worktree.ID) { + fileEventMonitors.removeValue(forKey: worktreeID)?.cancel() + } + private func stopWatcher(for worktreeID: Worktree.ID) { stopHeadWatcher(for: worktreeID) + stopFileEventMonitor(for: worktreeID) stopRemoteHeadPoll(for: worktreeID) branchDebounceTasks.removeValue(forKey: worktreeID)?.cancel() filesDebounceTasks.removeValue(forKey: worktreeID)?.cancel() restartTasks.removeValue(forKey: worktreeID)?.cancel() - lineChangeTasks.removeValue(forKey: worktreeID)?.task.cancel() + lineChangeRefreshTasks.removeValue(forKey: worktreeID)?.cancel() } private func stopAll() { + stopBackgroundRefreshTasks() + deferredLineChangeIDs.removeAll() + hasCompletedInitialWorktreeLoad = false + worktrees.removeAll() + selectedWorktreeID = nil + pullRequestTrackingEnabled = true + eventContinuation?.finish() + } + + private func stopBackgroundRefreshTasks() { for watcher in headWatchers.values { watcher.source.cancel() } + for monitor in fileEventMonitors.values { + monitor.cancel() + } for task in branchDebounceTasks.values { task.cancel() } @@ -396,30 +496,21 @@ final class WorktreeInfoWatcherManager { for task in restartTasks.values { task.cancel() } - for task in pullRequestTasks.values { - task.task.cancel() - } - for task in lineChangeTasks.values { - task.task.cancel() + for task in lineChangeRefreshTasks.values { + task.cancel() } for task in remoteHeadPollTasks.values { task.task.cancel() } headWatchers.removeAll() + fileEventMonitors.removeAll() branchDebounceTasks.removeAll() filesDebounceTasks.removeAll() restartTasks.removeAll() - pullRequestTasks.removeAll() - lineChangeTasks.removeAll() + lineChangeRefreshTasks.removeAll() remoteHeadPollTasks.removeAll() lastKnownRemoteBranch.removeAll() - deferredLineChangeIDs.removeAll() - hasCompletedInitialWorktreeLoad = false cancelAllPullRequestSelectionCooldownTasks() - worktrees.removeAll() - selectedWorktreeID = nil - pullRequestTrackingEnabled = true - eventContinuation?.finish() } private func setPullRequestTrackingEnabled(_ enabled: Bool) { @@ -430,53 +521,35 @@ final class WorktreeInfoWatcherManager { if enabled { let repositoryRoots = Set(worktrees.values.map(\.repositoryRootURL)) for repositoryRootURL in repositoryRoots { - updatePullRequestSchedule(repositoryRootURL: repositoryRootURL, immediate: true) + refreshPullRequests(repositoryRootURL: repositoryRootURL) } return } - for task in pullRequestTasks.values { - task.task.cancel() - } - pullRequestTasks.removeAll() cancelAllPullRequestSelectionCooldownTasks() } - private func updatePullRequestSchedule(repositoryRootURL: URL, immediate: Bool) { + private func refreshPullRequests(repositoryRootURL: URL) { guard pullRequestTrackingEnabled else { - pullRequestTasks.removeValue(forKey: repositoryRootURL)?.task.cancel() return } let worktreeIDs = repositoryWorktreeIDs(for: repositoryRootURL) guard !worktreeIDs.isEmpty else { - pullRequestTasks.removeValue(forKey: repositoryRootURL)?.task.cancel() return } - let isFocused = selectedWorktreeID.map { worktreeIDs.contains($0) } ?? false - let interval = isFocused ? refreshTiming.focused : refreshTiming.unfocused - if let existing = pullRequestTasks[repositoryRootURL], existing.interval == interval, !immediate { - return + emit(.repositoryPullRequestRefresh(repositoryRootURL: repositoryRootURL, worktreeIDs: worktreeIDs)) + } + + private func refreshAll() { + let worktreesToRefresh = worktrees.values.sorted { $0.id.rawValue < $1.id.rawValue } + for worktree in worktreesToRefresh { + emitLineChangesChanged(worktreeID: worktree.id) } - pullRequestTasks[repositoryRootURL]?.task.cancel() - if immediate { - emitPullRequestRefresh(repositoryRootURL: repositoryRootURL) + let repositoryRoots = Set(worktrees.values.map(\.repositoryRootURL)).sorted { + $0.path(percentEncoded: false) < $1.path(percentEncoded: false) } - let sleep = self.sleep - let task = Task { [weak self, sleep] in - while !Task.isCancelled { - do { - try await sleep(interval) - } catch { - break - } - guard !Task.isCancelled else { - break - } - await MainActor.run { - self?.emitPullRequestRefresh(repositoryRootURL: repositoryRootURL) - } - } + for repositoryRootURL in repositoryRoots { + refreshPullRequests(repositoryRootURL: repositoryRootURL) } - pullRequestTasks[repositoryRootURL] = RefreshTask(interval: interval, task: task) } private func repositoryWorktreeIDs(for repositoryRootURL: URL) -> [Worktree.ID] { @@ -487,75 +560,42 @@ final class WorktreeInfoWatcherManager { .sorted { $0.rawValue < $1.rawValue } } - private func emitPullRequestRefresh(repositoryRootURL: URL) { - guard pullRequestTrackingEnabled else { + private func refreshInterval(for worktreeID: Worktree.ID) -> Duration { + worktreeID == selectedWorktreeID ? refreshTiming.focused : refreshTiming.unfocused + } + + private func scheduleLineChangeRefresh(worktreeID: Worktree.ID, delay: Duration) { + guard worktrees[worktreeID] != nil else { return } - let worktreeIDs = repositoryWorktreeIDs(for: repositoryRootURL) - guard !worktreeIDs.isEmpty else { - return + lineChangeRefreshTasks[worktreeID]?.cancel() + let sleep = self.sleep + let task = Task { [weak self, sleep] in + try? await sleep(delay) + guard !Task.isCancelled else { + return + } + await MainActor.run { + self?.lineChangeRefreshTasks.removeValue(forKey: worktreeID) + self?.emitLineChangesChanged(worktreeID: worktreeID) + } } - emit(.repositoryPullRequestRefresh(repositoryRootURL: repositoryRootURL, worktreeIDs: worktreeIDs)) + lineChangeRefreshTasks[worktreeID] = task } - private func updateLineChangeSchedule( - worktreeID: Worktree.ID, - immediate: Bool, - forceReschedule: Bool = false - ) { + private func emitLineChangesChanged(worktreeID: Worktree.ID) { guard worktrees[worktreeID] != nil else { return } - let interval = worktreeID == selectedWorktreeID ? refreshTiming.focused : refreshTiming.unfocused - let shouldEmit = immediate && !deferredLineChangeIDs.contains(worktreeID) - let request = RepeatingTaskRequest( - worktreeID: worktreeID, - interval: interval, - immediate: shouldEmit, - forceReschedule: forceReschedule, - makeEvent: { [weak self] worktreeID in - self?.deferredLineChangeIDs.remove(worktreeID) - return .filesChanged(worktreeID: worktreeID) - } - ) - updateRepeatingTask(request, tasks: &lineChangeTasks) + deferredLineChangeIDs.remove(worktreeID) + emit(.filesChanged(worktreeID: worktreeID)) } - private func updateRepeatingTask( - _ request: RepeatingTaskRequest, - tasks: inout [Worktree.ID: RefreshTask] - ) { - let worktreeID = request.worktreeID - if let existing = tasks[worktreeID], existing.interval == request.interval, !request.forceReschedule { - if request.immediate { - emit(request.makeEvent(worktreeID)) - } + private func emitBranchChanged(worktreeID: Worktree.ID) { + guard worktrees[worktreeID] != nil else { return } - tasks[worktreeID]?.task.cancel() - if request.immediate { - emit(request.makeEvent(worktreeID)) - } - let sleep = self.sleep - let task = Task { [weak self, sleep] in - while !Task.isCancelled { - do { - try await sleep(request.interval) - } catch { - if !(error is CancellationError) { - watcherLogger.error("Worktree refresh loop for \(worktreeID) ended: \(error).") - } - break - } - guard !Task.isCancelled else { - break - } - await MainActor.run { - self?.emit(request.makeEvent(worktreeID)) - } - } - } - tasks[worktreeID] = RefreshTask(interval: request.interval, task: task) + emit(.branchChanged(worktreeID: worktreeID)) } private func emit(_ event: WorktreeInfoWatcherClient.Event) { @@ -602,11 +642,7 @@ final class WorktreeInfoWatcherManager { let sleep = self.sleep let taskID = UUID() let task = Task { [weak self, sleep, taskID] in - do { - try await sleep(cooldown) - } catch { - return - } + try? await sleep(cooldown) await MainActor.run { guard let self, diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index a7e2e73ab..21a348eae 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -2405,7 +2405,19 @@ struct RepositoriesFeature { case .worktreeBranchNameLoaded(let worktreeID, let name): state.updateWorktreeName(worktreeID, name: name) Self.syncSidebar(&state) - return .none + guard let repositoryID = state.repositoryID(containing: worktreeID), + let repository = state.repositories[id: repositoryID] + else { + return .none + } + return .send( + .worktreeInfoEvent( + .repositoryPullRequestRefresh( + repositoryRootURL: repository.rootURL, + worktreeIDs: repository.worktrees.map(\.id) + ) + ) + ) case .worktreeLineChangesLoaded(let worktreeID, let added, let removed): return state.updateWorktreeLineChangesEffect( diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index 15b6d9e0f..ef8fe0e7d 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -56,13 +56,41 @@ struct AppFeatureCommandPaletteTests { } @Test(.dependencies) func refreshWorktreesDispatchesRefresh() async { + let watcherCommands = LockIsolated<[WorktreeInfoWatcherClient.Command]>([]) let store = TestStore(initialState: AppFeature.State()) { AppFeature() + } withDependencies: { + $0.worktreeInfoWatcher.send = { command in + watcherCommands.withValue { $0.append(command) } + } } store.exhaustivity = .off await store.send(.commandPalette(.delegate(.refreshWorktrees))) + await store.receive(\.refreshWorktreesRequested) await store.receive(\.repositories.refreshWorktrees) + await store.receive(\.repositories.reloadRepositories) + await store.finish() + + #expect(watcherCommands.value == [.refresh]) + } + + @Test(.dependencies) func repositoryRefreshDoesNotForceWorktreeInfoRefresh() async { + let watcherCommands = LockIsolated<[WorktreeInfoWatcherClient.Command]>([]) + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } withDependencies: { + $0.worktreeInfoWatcher.send = { command in + watcherCommands.withValue { $0.append(command) } + } + } + store.exhaustivity = .off + + await store.send(.repositories(.refreshWorktrees)) + await store.receive(\.repositories.reloadRepositories) + await store.finish() + + #expect(watcherCommands.value.isEmpty) } @Test(.dependencies) func viewArchivedWorktreesDispatchesSelectArchived() async { diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 72cc097fa..76f600aad 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -4874,7 +4874,9 @@ struct RepositoriesFeatureTests { let worktree = makeWorktree(id: "/tmp/wt", name: "eagle", createdAt: createdAt) let renamedWorktree = makeWorktree(id: "/tmp/wt", name: "falcon", createdAt: createdAt) let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) - let store = TestStore(initialState: makeState(repositories: [repository])) { + var initialState = makeState(repositories: [repository]) + initialState.githubIntegrationAvailability = .disabled + let store = TestStore(initialState: initialState) { RepositoriesFeature() } @@ -4891,6 +4893,7 @@ struct RepositoriesFeatureTests { $0.repositories[id: repository.id] = repository $0.reconcileSidebarForTesting() } + await store.receive(\.worktreeInfoEvent) #expect(store.state.repositories[id: repository.id]?.worktrees[id: worktree.id]?.name == "falcon") #expect(store.state.repositories[id: repository.id]?.worktrees[id: worktree.id]?.createdAt == createdAt) } @@ -6007,6 +6010,42 @@ struct RepositoriesFeatureTests { #expect(batchCalls.value == [GithubRemoteInfo(host: "github.com", owner: "fork", repo: "project")]) } + @Test func worktreeBranchNameLoadedRefreshesPullRequestsWithUpdatedBranchName() async { + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureWorktree = makeWorktree( + id: "\(repoRoot)/feature", + name: "old-feature", + repoRoot: repoRoot + ) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) + var initialState = makeState(repositories: [repository]) + initialState.githubIntegrationAvailability = .available + let queriedBranches = LockIsolated<[String]>([]) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.githubCLI.resolveRemoteInfo = { _ in + GithubRemoteInfo(host: "github.com", owner: "upstream", repo: "project") + } + $0.githubCLI.batchPullRequests = { _, _, _, branches in + queriedBranches.withValue { $0 = branches } + return [:] + } + } + store.exhaustivity = .off + + await store.send( + .worktreeBranchNameLoaded(worktreeID: featureWorktree.id, name: "new-feature") + ) + await store.receive(\.worktreeInfoEvent) + await store.receive(\.repositoryPullRequestsLoaded) + await store.receive(\.repositoryPullRequestRefreshCompleted) + await store.finish() + + #expect(queriedBranches.value == ["main", "new-feature"]) + } + @Test func pullRequestActionMergePassesResolvedRemoteToGh() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) diff --git a/supacodeTests/WorktreeInfoWatcherManagerTests.swift b/supacodeTests/WorktreeInfoWatcherManagerTests.swift index 29f9741bf..e09cfb4bb 100644 --- a/supacodeTests/WorktreeInfoWatcherManagerTests.swift +++ b/supacodeTests/WorktreeInfoWatcherManagerTests.swift @@ -11,15 +11,21 @@ struct WorktreeInfoWatcherManagerTests { let tempWorktree = try makeTempWorktree() let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), - unfocusedInterval: .seconds(3_600) + unfocusedInterval: .seconds(3_600), + filesChangedDebounceInterval: .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) + #expect( + await waitForFilesChangedCount( + collector, + worktreeID: tempWorktree.worktree.id, + atLeast: 1 + ) >= 1 + ) manager.handleCommand(.stop) await task.value @@ -40,8 +46,13 @@ struct WorktreeInfoWatcherManagerTests { manager.handleCommand(.setPullRequestTrackingEnabled(false)) manager.handleCommand(.setWorktrees([firstWorktree])) - await drainAsyncEvents(120) - #expect(await collector.filesChangedCount(worktreeID: firstWorktree.id) == 1) + #expect( + await waitForFilesChangedCount( + collector, + worktreeID: firstWorktree.id, + atLeast: 1 + ) == 1 + ) manager.handleCommand(.setWorktrees([firstWorktree, secondWorktree])) await drainAsyncEvents(120) @@ -52,14 +63,74 @@ struct WorktreeInfoWatcherManagerTests { #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) + #expect( + await waitForFilesChangedCount( + collector, + worktreeID: secondWorktree.id, + atLeast: 1 + ) == 1 + ) manager.handleCommand(.stop) await task.value try FileManager.default.removeItem(at: tempRepository.tempRoot) } + @Test func lineChangesDoNotRefreshWhileIdle() async throws { + let clock = TestClock() + let tempWorktree = try makeTempWorktree() + let manager = WorktreeInfoWatcherManager( + focusedInterval: .milliseconds(80), + unfocusedInterval: .milliseconds(80), + clock: clock + ) + let (collector, task) = startCollecting(manager.eventStream()) + + manager.handleCommand(.setPullRequestTrackingEnabled(false)) + manager.handleCommand(.setWorktrees([tempWorktree.worktree])) + #expect( + await waitForFilesChangedCount( + collector, + worktreeID: tempWorktree.worktree.id, + atLeast: 1 + ) == 1 + ) + + await clock.advance(by: .seconds(1)) + 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 unchangedWorktreesDoNotRefreshLineChanges() async throws { + let tempWorktree = try makeTempWorktree() + let manager = WorktreeInfoWatcherManager( + filesChangedDebounceInterval: .seconds(3_600) + ) + let (collector, task) = startCollecting(manager.eventStream()) + + manager.handleCommand(.setPullRequestTrackingEnabled(false)) + manager.handleCommand(.setWorktrees([tempWorktree.worktree])) + #expect( + await waitForFilesChangedCount( + collector, + worktreeID: tempWorktree.worktree.id, + atLeast: 1 + ) == 1 + ) + + 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 buildsWorktreeLookupWithoutTrappingOnDuplicateID() async throws { // Two entries sharing one WorktreeID must not trap; the first entry wins. let tempWorktree = try makeTempWorktree() @@ -72,16 +143,22 @@ struct WorktreeInfoWatcherManagerTests { ) let manager = WorktreeInfoWatcherManager( focusedInterval: .seconds(3_600), - unfocusedInterval: .seconds(3_600) + unfocusedInterval: .seconds(3_600), + filesChangedDebounceInterval: .seconds(3_600) ) let (collector, task) = startCollecting(manager.eventStream()) manager.handleCommand(.setPullRequestTrackingEnabled(false)) manager.handleCommand(.setWorktrees([tempWorktree.worktree, duplicate])) - await drainAsyncEvents(120) // The manager initialized and the single de-duplicated worktree is watched. - #expect(await collector.filesChangedCount(worktreeID: tempWorktree.worktree.id) == 1) + #expect( + await waitForFilesChangedCount( + collector, + worktreeID: tempWorktree.worktree.id, + atLeast: 1 + ) == 1 + ) manager.handleCommand(.stop) await task.value @@ -157,6 +234,117 @@ struct WorktreeInfoWatcherManagerTests { await task.value } + @Test func branchChangesDoNotRefreshPullRequestsBeforeBranchNameLoads() async throws { + let clock = TestClock() + let stub = RemoteBranchPollStub(responses: ["main", "feature"]) + let manager = WorktreeInfoWatcherManager( + focusedInterval: .milliseconds(500), + unfocusedInterval: .milliseconds(500), + clock: clock, + pollRemoteBranch: { _ in await stub.next() } + ) + let (collector, task) = startCollecting(manager.eventStream()) + let remote = makeRemoteWorktree(name: "remote-eagle") + + manager.handleCommand(.setWorktrees([remote])) + await drainAsyncEvents(200) + let baselineCount = await collector.pullRequestRefreshCount( + repositoryRootURL: remote.repositoryRootURL + ) + #expect(baselineCount == 1) + + // Branch changes only emit branchChanged here. The reducer refreshes PR + // state after loading the new branch name into repository state. + await clock.advance(by: .milliseconds(200)) + await drainAsyncEvents(200) + let afterInitialBranchObservationCount = await collector.pullRequestRefreshCount( + repositoryRootURL: remote.repositoryRootURL + ) + #expect(afterInitialBranchObservationCount == baselineCount) + #expect(await collector.branchChangedCount(worktreeID: remote.id) == 1) + + await clock.advance(by: .milliseconds(300)) + await drainAsyncEvents(200) + await clock.advance(by: .milliseconds(200)) + await drainAsyncEvents(200) + let afterBranchChangeCount = await collector.pullRequestRefreshCount( + repositoryRootURL: remote.repositoryRootURL + ) + #expect(afterBranchChangeCount == baselineCount) + #expect(await collector.branchChangedCount(worktreeID: remote.id) == 2) + + // Stable remote branch polls must not keep refreshing PR state. + await clock.advance(by: .milliseconds(500)) + await drainAsyncEvents(200) + await clock.advance(by: .milliseconds(200)) + await drainAsyncEvents(200) + let afterStableBranchPollCount = await collector.pullRequestRefreshCount( + repositoryRootURL: remote.repositoryRootURL + ) + #expect(afterStableBranchPollCount == afterBranchChangeCount) + + manager.handleCommand(.stop) + await task.value + } + + @Test func refreshCommandRefreshesLineChangesAndPullRequests() async throws { + let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) + let manager = WorktreeInfoWatcherManager( + filesChangedDebounceInterval: .seconds(3_600) + ) + let (collector, task) = startCollecting(manager.eventStream()) + let firstWorktree = try #require(tempRepository.worktrees.first) + let secondWorktree = try #require(tempRepository.worktrees.dropFirst().first) + + manager.handleCommand(.setWorktrees(tempRepository.worktrees)) + let firstBaselineCount = await waitForFilesChangedCount( + collector, + worktreeID: firstWorktree.id, + atLeast: 1 + ) + let secondBaselineCount = await waitForFilesChangedCount( + collector, + worktreeID: secondWorktree.id, + atLeast: 1 + ) + #expect(firstBaselineCount == 1) + #expect(secondBaselineCount == 1) + let baselinePullRequestCount = await waitForPullRequestRefreshCount( + collector, + repositoryRootURL: tempRepository.tempRoot, + atLeast: 1 + ) + #expect(baselinePullRequestCount == 1) + + manager.handleCommand(.refresh) + #expect( + await waitForFilesChangedCount( + collector, + worktreeID: firstWorktree.id, + atLeast: firstBaselineCount + 1 + ) == firstBaselineCount + 1 + ) + #expect( + await waitForFilesChangedCount( + collector, + worktreeID: secondWorktree.id, + atLeast: secondBaselineCount + 1 + ) == secondBaselineCount + 1 + ) + #expect( + await waitForPullRequestRefreshCount( + collector, + repositoryRootURL: tempRepository.tempRoot, + atLeast: baselinePullRequestCount + 1 + ) + == baselinePullRequestCount + 1 + ) + + manager.handleCommand(.stop) + await task.value + try FileManager.default.removeItem(at: tempRepository.tempRoot) + } + @Test func selectionRefreshUsesCooldownWithinRepository() async throws { let clock = TestClock() let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) @@ -198,6 +386,59 @@ struct WorktreeInfoWatcherManagerTests { try FileManager.default.removeItem(at: tempRepository.tempRoot) } + @Test func pullRequestsDoNotRefreshWhileIdle() async throws { + let clock = TestClock() + let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) + let manager = WorktreeInfoWatcherManager( + focusedInterval: .milliseconds(80), + unfocusedInterval: .milliseconds(80), + clock: clock + ) + let (collector, task) = startCollecting(manager.eventStream()) + + manager.handleCommand(.setWorktrees(tempRepository.worktrees)) + await drainAsyncEvents(120) + let baselineCount = await collector.pullRequestRefreshCount( + repositoryRootURL: tempRepository.tempRoot + ) + #expect(baselineCount == 1) + + await clock.advance(by: .seconds(1)) + await drainAsyncEvents(120) + let afterIdleCount = await collector.pullRequestRefreshCount( + repositoryRootURL: tempRepository.tempRoot + ) + #expect(afterIdleCount == baselineCount) + + manager.handleCommand(.stop) + await task.value + try FileManager.default.removeItem(at: tempRepository.tempRoot) + } + + @Test func unchangedWorktreesDoNotRefreshPullRequests() async throws { + let tempRepository = try makeTempRepository(worktreeNames: ["sparrow", "swift"]) + let manager = WorktreeInfoWatcherManager() + let (collector, task) = startCollecting(manager.eventStream()) + + manager.handleCommand(.setWorktrees(tempRepository.worktrees)) + await drainAsyncEvents(120) + let baselineCount = await collector.pullRequestRefreshCount( + repositoryRootURL: tempRepository.tempRoot + ) + #expect(baselineCount == 1) + + manager.handleCommand(.setWorktrees(tempRepository.worktrees)) + await drainAsyncEvents(120) + let afterUnchangedWorktreesCount = await collector.pullRequestRefreshCount( + repositoryRootURL: tempRepository.tempRoot + ) + #expect(afterUnchangedWorktreesCount == baselineCount) + + 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"]) @@ -251,11 +492,19 @@ struct WorktreeInfoWatcherManagerTests { manager.handleCommand(.setPullRequestTrackingEnabled(false)) let stream = manager.eventStream() - // Each setWorktrees re-emits an immediate filesChanged for the worktree; - // with nothing draining, the buffer must cap rather than grow unbounded. + // Metadata changes still emit refresh signals; with nothing draining, the + // stream must cap rather than grow unbounded. let overflow = WorktreeInfoWatcherManager.eventBufferCap + 50 - for _ in 0.. Int { + for _ in 0..= expectedCount { + return count + } + await Task.yield() + } + return await collector.filesChangedCount(worktreeID: worktreeID) +} + +private func waitForPullRequestRefreshCount( + _ collector: EventCollector, + repositoryRootURL: URL, + atLeast expectedCount: Int, + iterations: Int = 200 +) async -> Int { + for _ in 0..= expectedCount { + return count + } + await Task.yield() + } + return await collector.pullRequestRefreshCount(repositoryRootURL: repositoryRootURL) +} From bcdf726f52bc4196b97d96de049bec235a237f2c Mon Sep 17 00:00:00 2001 From: Stefano Bertagno Date: Sat, 8 Aug 2026 02:15:27 +0200 Subject: [PATCH 2/2] Add a self-healing backstop and a setting to disable background refresh Event-driven refresh alone drops edges that polling used to heal, so a dropped signal became permanent for the session. Add a rare, foreground-only reconcile sweep that rolls through worktrees one at a time: it re-arms each watcher (recovering a dead kqueue / file-event stream), re-emits remote line counts and pull-request refreshes, and so heals linked-worktree commits, external GitHub merges, and remote status with no local event. Watch the linked-worktree admin dir so commit-driven changes heal at event speed, coalesce overlapping diffs per worktree, and repair watchers on a manual refresh. Add an `automaticRepositoryRefreshEnabled` setting that stops background status polling (remote-branch SSH poll, reconcile sweep, and automatic pull-request checks) to avoid SSH passphrase prompts and GitHub rate limiting; worktree discovery stays live and user-initiated refreshes always run. The value is seeded at watcher construction so a disabled user never eats a launch-time burst. --- .../Reducer/SettingsFeature.swift | 6 +- .../Views/WorktreeSettingsView.swift | 7 + .../Models/GlobalSettings.swift | 11 +- supacode/App/supacodeApp.swift | 6 +- .../WorktreeInfoWatcherClient.swift | 16 +- .../Features/App/Reducer/AppFeature.swift | 22 +- .../WorktreeInfoWatcherManager.swift | 265 +++++++++++++-- .../Reducer/RepositoriesFeature.swift | 54 ++- .../AppFeatureCommandPaletteTests.swift | 21 ++ .../AppFeatureSettingsChangedTests.swift | 24 ++ .../RemoteRepositorySidebarTests.swift | 3 +- supacodeTests/RepositoriesFeatureTests.swift | 173 +++++++++- supacodeTests/SettingsFeatureTests.swift | 15 + .../SettingsFilePersistenceTests.swift | 23 ++ .../WorktreeInfoWatcherManagerTests.swift | 319 +++++++++++++++++- 15 files changed, 911 insertions(+), 54 deletions(-) diff --git a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift index f09d08cc3..390903174 100644 --- a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift @@ -82,6 +82,7 @@ public struct SettingsFeature { public var remoteSessionPersistenceEnabled: Bool public var appVisibility: AppVisibility public var terminalHibernationEnabled: Bool + public var automaticRepositoryRefreshEnabled: Bool public var cliInstallState = CLIInstallState.checking /// Installed editors in menu order, resolved once off the picker's body. public var installedOpenActions: [OpenWorktreeAction] @@ -162,6 +163,7 @@ public struct SettingsFeature { remoteSessionPersistenceEnabled = settings.remoteSessionPersistenceEnabled appVisibility = settings.appVisibility terminalHibernationEnabled = settings.terminalHibernationEnabled + automaticRepositoryRefreshEnabled = settings.automaticRepositoryRefreshEnabled defaultWorktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) ?? "" } @@ -204,7 +206,8 @@ public struct SettingsFeature { terminateSessionsOnQuit: terminateSessionsOnQuit, remoteSessionPersistenceEnabled: remoteSessionPersistenceEnabled, appVisibility: appVisibility, - terminalHibernationEnabled: terminalHibernationEnabled + terminalHibernationEnabled: terminalHibernationEnabled, + automaticRepositoryRefreshEnabled: automaticRepositoryRefreshEnabled ) } } @@ -358,6 +361,7 @@ public struct SettingsFeature { state.remoteSessionPersistenceEnabled = normalizedSettings.remoteSessionPersistenceEnabled state.appVisibility = normalizedSettings.appVisibility state.terminalHibernationEnabled = normalizedSettings.terminalHibernationEnabled + state.automaticRepositoryRefreshEnabled = normalizedSettings.automaticRepositoryRefreshEnabled state.defaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath ?? "" state.syncGlobalDefaults(from: normalizedSettings) synchronizeRepositorySelection(for: &state) diff --git a/SupacodeSettingsFeature/Views/WorktreeSettingsView.swift b/SupacodeSettingsFeature/Views/WorktreeSettingsView.swift index 636c32d0f..a1502b6c2 100644 --- a/SupacodeSettingsFeature/Views/WorktreeSettingsView.swift +++ b/SupacodeSettingsFeature/Views/WorktreeSettingsView.swift @@ -46,6 +46,13 @@ public struct WorktreeSettingsView: View { Text("Copies untracked files from the main worktree.") } } + Section { + Toggle(isOn: $store.automaticRepositoryRefreshEnabled) { + Text("Refresh repository status in the background") + Text("Keeps changed-line counts, branches, and pull-request status up to date.") + Text("Turn off if it triggers SSH passphrase prompts or GitHub rate limiting.") + } + } Section("Clean-up") { Picker( "Auto-delete archived worktrees", diff --git a/SupacodeSettingsShared/Models/GlobalSettings.swift b/SupacodeSettingsShared/Models/GlobalSettings.swift index bb665163a..b7c69d870 100644 --- a/SupacodeSettingsShared/Models/GlobalSettings.swift +++ b/SupacodeSettingsShared/Models/GlobalSettings.swift @@ -104,6 +104,9 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { /// Beta: hidden terminal tabs release their renderer after a few minutes of /// inactivity and reconnect when viewed. On by default. public var terminalHibernationEnabled: Bool + /// Gates all background repository polling (remote SSH, PR checks, reconcile). + /// On by default; disable to stop SSH passphrase prompts or GitHub rate limiting. + public var automaticRepositoryRefreshEnabled: Bool public static let `default` = GlobalSettings( appearanceMode: .dark, @@ -177,7 +180,8 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { terminateSessionsOnQuit: Bool = false, remoteSessionPersistenceEnabled: Bool = true, appVisibility: AppVisibility = .dockAndMenuBar, - terminalHibernationEnabled: Bool = true + terminalHibernationEnabled: Bool = true, + automaticRepositoryRefreshEnabled: Bool = true ) { self.appearanceMode = appearanceMode self.defaultEditorID = defaultEditorID @@ -214,6 +218,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.remoteSessionPersistenceEnabled = remoteSessionPersistenceEnabled self.appVisibility = appVisibility self.terminalHibernationEnabled = terminalHibernationEnabled + self.automaticRepositoryRefreshEnabled = automaticRepositoryRefreshEnabled } /// Keys for reading renamed settings fields that no longer @@ -388,5 +393,9 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { terminalHibernationEnabled = try container.decodeIfPresent(Bool.self, forKey: .terminalHibernationEnabled) ?? Self.default.terminalHibernationEnabled + // Pre-feature files omit this key; background refresh defaults on. + automaticRepositoryRefreshEnabled = + try container.decodeIfPresent(Bool.self, forKey: .automaticRepositoryRefreshEnabled) + ?? Self.default.automaticRepositoryRefreshEnabled } } diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 10e104011..e48c52443 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -184,7 +184,11 @@ struct SupacodeApp: App { _ghosttyShortcuts = State(initialValue: shortcuts) let terminalManager = Self.makeTerminalManager(runtime: runtime) _terminalManager = State(initialValue: terminalManager) - let worktreeInfoWatcher = WorktreeInfoWatcherManager() + // Seed the flag at construction so a user who disabled background refresh + // never eats a launch-time SSH / gh burst before the setting is applied. + let worktreeInfoWatcher = WorktreeInfoWatcherManager( + automaticRefreshEnabled: initialSettings.automaticRepositoryRefreshEnabled + ) _worktreeInfoWatcher = State(initialValue: worktreeInfoWatcher) let keyObserver = CommandKeyObserver() _commandKeyObserver = State(initialValue: keyObserver) diff --git a/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift b/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift index afb833923..4cb582c13 100644 --- a/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift +++ b/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift @@ -9,14 +9,28 @@ struct WorktreeInfoWatcherClient { case setWorktrees([Worktree]) case setSelectedWorktreeID(Worktree.ID?) case setPullRequestTrackingEnabled(Bool) + case setAutomaticRefreshEnabled(Bool) + case setActive(Bool) case refresh case stop } + /// Distinguishes a background/automatic refresh (suppressed when the user + /// disables automatic repository refresh) from a user-initiated one (always + /// honored). + enum RefreshTrigger: Equatable { + case automatic + case manual + } + enum Event: Equatable { case branchChanged(worktreeID: Worktree.ID) case filesChanged(worktreeID: Worktree.ID) - case repositoryPullRequestRefresh(repositoryRootURL: URL, worktreeIDs: [Worktree.ID]) + case repositoryPullRequestRefresh( + repositoryRootURL: URL, + worktreeIDs: [Worktree.ID], + trigger: RefreshTrigger + ) } } diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index bb8485be0..ce58d56f8 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -449,10 +449,16 @@ struct AppFeature { // card reflects external installs (e.g. `claude install`) // for users who keep the app open across days. .send(.settings(.refreshAgentIntegrationStates)), + // Resume background git polling only while foreground-active so an + // idle / backgrounded app stays quiet. + .run { _ in await worktreeInfoWatcher.send(.setActive(true)) }, .run { send in while !Task.isCancelled { try? await ContinuousClock().sleep(for: .seconds(30)) guard !Task.isCancelled else { return } + // Worktree discovery stays ungated so externally created / + // removed worktrees still sync; the setting gates status + // polling (line counts, branch, PR, remote SSH) in the watcher. await send(.repositories(.refreshWorktrees)) await send(.refreshInstalledOpenActions) } @@ -466,6 +472,7 @@ struct AppFeature { let agentsBySurface = state.agentPresence.agentsBySurface() return .merge( .cancel(id: CancelID.periodicRefresh), + .run { _ in await worktreeInfoWatcher.send(.setActive(false)) }, .run { [clock] _ in try await clock.sleep(for: .seconds(1)) await MainActor.run { @@ -475,9 +482,15 @@ struct AppFeature { .cancellable(id: CancelID.backgroundPersist, cancelInFlight: true) ) case .inactive: - return .cancel(id: CancelID.periodicRefresh) + return .merge( + .cancel(id: CancelID.periodicRefresh), + .run { _ in await worktreeInfoWatcher.send(.setActive(false)) } + ) @unknown default: - return .cancel(id: CancelID.periodicRefresh) + return .merge( + .cancel(id: CancelID.periodicRefresh), + .run { _ in await worktreeInfoWatcher.send(.setActive(false)) } + ) } case .repositories(.delegate(.selectedWorktreeChanged(let worktree))): @@ -713,6 +726,11 @@ struct AppFeature { .setPullRequestTrackingEnabled(settings.githubIntegrationEnabled) ) }, + .run { _ in + await worktreeInfoWatcher.send( + .setAutomaticRefreshEnabled(settings.automaticRepositoryRefreshEnabled) + ) + }, .run { send in guard shouldCheckSystemNotificationPermission else { return } let status = await systemNotificationClient.authorizationStatus() diff --git a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift index 6087367be..3174770cd 100644 --- a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift +++ b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift @@ -7,17 +7,20 @@ import SupacodeSettingsShared private let watcherLogger = SupaLogger("WorktreeInfoWatcher") private final class WorktreeFileEventMonitor { - let rootURL: URL + let rootURLs: [URL] private let onEvent: @MainActor @Sendable () -> Void private nonisolated(unsafe) var stream: FSEventStreamRef? init?( - rootURL: URL, + rootURLs: [URL], onEvent: @escaping @MainActor @Sendable () -> Void ) { - self.rootURL = rootURL + self.rootURLs = rootURLs self.onEvent = onEvent - let path = rootURL.path(percentEncoded: false) + guard !rootURLs.isEmpty else { + return nil + } + let paths = rootURLs.map { $0.path(percentEncoded: false) } var context = FSEventStreamContext( version: 0, info: nil, @@ -39,7 +42,7 @@ private final class WorktreeFileEventMonitor { nil, callback, &context, - [path] as CFArray, + paths as CFArray, FSEventStreamEventId(kFSEventStreamEventIdSinceNow), 1.0, FSEventStreamCreateFlags( @@ -110,6 +113,13 @@ final class WorktreeInfoWatcherManager { private let filesChangedDebounceInterval: Duration private let pullRequestSelectionRefreshCooldown: Duration private let refreshTiming: RefreshTiming + /// Gap between self-healing reconcile sweeps. Rare on purpose: the fast path + /// is event-driven, and this only backstops signals with no local event + /// (remote line counts, GitHub merges, a dead watcher). + private let reconcileInterval: Duration + /// Delay between reconciling successive worktrees / repos so a sweep rolls + /// through them one at a time instead of fanning a diff storm. + private let reconcileStep: Duration private let sleep: @Sendable (Duration) async throws -> Void /// Resolves a remote worktree's current branch over SSH. Injected so tests /// can drive the poll loop without a real connection (real-host SSH is @@ -127,10 +137,22 @@ final class WorktreeInfoWatcherManager { private var filesDebounceTasks: [Worktree.ID: Task] = [:] private var restartTasks: [Worktree.ID: Task] = [:] private var lineChangeRefreshTasks: [Worktree.ID: Task] = [:] + /// Worktrees whose file-event monitor / HEAD kqueue failed to start. Tracked + /// so the reconcile retry logs once per failure, not once per attempt. Cleared + /// on success or on worktree removal, never on the re-arm teardown. + private var fileEventMonitorFailedIDs: Set = [] + private var headWatcherFailedIDs: Set = [] private var deferredLineChangeIDs: Set = [] private var hasCompletedInitialWorktreeLoad = false private var selectedWorktreeID: Worktree.ID? private var pullRequestTrackingEnabled = true + /// User setting: when false, all background repository polling is off. + private var automaticRefreshEnabled: Bool + /// Whether the app is foreground-active. Background polling (remote SSH HEAD + /// poll + reconcile sweep) only runs while active, so an idle / backgrounded + /// app is quiet. + private var isActive = true + private var reconcileTask: Task? private var pullRequestSelectionCooldownTasksByRepo: [URL: PullRequestSelectionCooldownTask] = [:] private var eventContinuation: AsyncStream.Continuation? @@ -139,6 +161,9 @@ final class WorktreeInfoWatcherManager { unfocusedInterval: Duration = .seconds(60), filesChangedDebounceInterval: Duration = .seconds(5), pullRequestSelectionRefreshCooldown: Duration = .seconds(5), + reconcileInterval: Duration = .seconds(300), + reconcileStep: Duration = .seconds(2), + automaticRefreshEnabled: Bool = true, clock: C = ContinuousClock(), pollRemoteBranch: @escaping @Sendable (Worktree) async -> String? = { worktree in guard let host = worktree.host else { return nil } @@ -148,6 +173,9 @@ final class WorktreeInfoWatcherManager { refreshTiming = RefreshTiming(focused: focusedInterval, unfocused: unfocusedInterval) self.filesChangedDebounceInterval = filesChangedDebounceInterval self.pullRequestSelectionRefreshCooldown = pullRequestSelectionRefreshCooldown + self.reconcileInterval = reconcileInterval + self.reconcileStep = reconcileStep + self.automaticRefreshEnabled = automaticRefreshEnabled self.sleep = { duration in try await clock.sleep(for: duration) } @@ -162,6 +190,10 @@ final class WorktreeInfoWatcherManager { setSelectedWorktreeID(worktreeID) case .setPullRequestTrackingEnabled(let isEnabled): setPullRequestTrackingEnabled(isEnabled) + case .setAutomaticRefreshEnabled(let isEnabled): + setAutomaticRefreshEnabled(isEnabled) + case .setActive(let active): + setActive(active) case .refresh: refreshAll() case .stop: @@ -169,6 +201,12 @@ final class WorktreeInfoWatcherManager { } } + /// Gates only the timer-driven loops (reconcile sweep + remote SSH HEAD + /// poll), not the local file-event monitors, which stay live and cheap. + private var backgroundPollingAllowed: Bool { + isActive && automaticRefreshEnabled + } + func eventStream() -> AsyncStream { eventContinuation?.finish() let (stream, continuation) = AsyncStream.makeStream( @@ -193,6 +231,8 @@ final class WorktreeInfoWatcherManager { } if !removedIDs.isEmpty { deferredLineChangeIDs.subtract(removedIDs) + fileEventMonitorFailedIDs.subtract(removedIDs) + headWatcherFailedIDs.subtract(removedIDs) } let newIDs = desiredIDs.subtracting(currentIDs) if !newIDs.isEmpty && !isInitialWorktreeLoad { @@ -208,11 +248,16 @@ final class WorktreeInfoWatcherManager { let didWorktreeChange = previousWorktrees[worktree.id] != worktree if isInitialWorktreeLoad || newIDs.contains(worktree.id) || didWorktreeChange { repositoryRootsToRefresh.insert(worktree.repositoryRootURL) - let isDeferred = deferredLineChangeIDs.contains(worktree.id) - if isDeferred { - scheduleLineChangeRefresh(worktreeID: worktree.id, delay: refreshInterval(for: worktree.id)) - } else { - emitLineChangesChanged(worktreeID: worktree.id) + // A remote worktree's line count is read over SSH, so this roster-driven + // refresh honors the background-polling gate; local diffs are cheap, + // never SSH, and stay so a discovered worktree shows its counts. + if worktree.host == nil || backgroundPollingAllowed { + let isDeferred = deferredLineChangeIDs.contains(worktree.id) + if isDeferred { + scheduleLineChangeRefresh(worktreeID: worktree.id, delay: refreshInterval(for: worktree.id)) + } else { + emitLineChangesChanged(worktreeID: worktree.id) + } } } repositoryRoots.insert(worktree.repositoryRootURL) @@ -229,6 +274,7 @@ final class WorktreeInfoWatcherManager { for repositoryRootURL in obsoleteCooldownRepositories { cancelPullRequestSelectionCooldown(for: repositoryRootURL) } + startReconcileLoop() } private func setSelectedWorktreeID(_ worktreeID: Worktree.ID?) { @@ -239,10 +285,8 @@ final class WorktreeInfoWatcherManager { let previousRepository = previousWorktreeID.flatMap { worktrees[$0]?.repositoryRootURL } selectedWorktreeID = worktreeID let nextRepository = worktreeID.flatMap { worktrees[$0]?.repositoryRootURL } - if let previousWorktreeID { - if let worktree = worktrees[previousWorktreeID] { - configureRemoteHeadPoll(for: worktree) - } + if let previousWorktreeID, let worktree = worktrees[previousWorktreeID] { + configureRemoteHeadPoll(for: worktree) } if let worktreeID { emitLineChangesChanged(worktreeID: worktreeID) @@ -293,6 +337,12 @@ final class WorktreeInfoWatcherManager { let path = headURL.path(percentEncoded: false) let fileDescriptor = open(path, O_EVTONLY) guard fileDescriptor >= 0 else { + // A dead HEAD kqueue misses branch changes; the reconcile backstop's + // catch-up re-emits once it re-arms. Log once so a persistent failure + // doesn't spam a line every sweep. + if headWatcherFailedIDs.insert(worktreeID).inserted { + watcherLogger.error("Failed to open \(path) for HEAD watching (errno \(errno)).") + } return } let queue = DispatchQueue(label: "worktree-info-watcher.\(worktreeID)") @@ -313,18 +363,54 @@ final class WorktreeInfoWatcherManager { } source.resume() headWatchers[worktreeID] = HeadWatcher(headURL: headURL, source: source) + headWatcherFailedIDs.remove(worktreeID) + } + + /// File-event roots for a local worktree: its working tree, plus the + /// linked-worktree admin dir when that lives outside the tree. A commit on a + /// branch rewrites the admin dir's index / reflog without touching a watched + /// working-tree file, so watching it heals the line count at event speed. + func fileEventRoots(for worktree: Worktree) -> [URL] { + var roots = [worktree.workingDirectory] + guard + let headURL = GitWorktreeHeadResolver.headURL( + for: worktree.workingDirectory, + fileManager: .default + ) + else { + return roots + } + let adminDir = headURL.deletingLastPathComponent().standardizedFileURL + let embeddedGitDir = worktree.workingDirectory.appending(path: ".git").standardizedFileURL + if adminDir != embeddedGitDir { + roots.append(adminDir) + } + return roots } private func configureFileEventMonitor(for worktree: Worktree) { - if let existing = fileEventMonitors[worktree.id], existing.rootURL == worktree.workingDirectory { + let roots = fileEventRoots(for: worktree) + if let existing = fileEventMonitors[worktree.id], existing.rootURLs == roots { return } - stopFileEventMonitor(for: worktree.id) - fileEventMonitors[worktree.id] = WorktreeFileEventMonitor( - rootURL: worktree.workingDirectory + // Tear down only the monitor value; keep the failed-set so retries log once. + fileEventMonitors.removeValue(forKey: worktree.id)?.cancel() + let monitor = WorktreeFileEventMonitor( + rootURLs: roots ) { [weak self] in self?.scheduleFilesChanged(worktreeID: worktree.id) } + guard let monitor else { + if fileEventMonitorFailedIDs.insert(worktree.id).inserted { + let path = worktree.workingDirectory.path(percentEncoded: false) + watcherLogger.error( + "Failed to start filesystem monitor for \(path); relying on the reconcile backstop until it recovers." + ) + } + return + } + fileEventMonitorFailedIDs.remove(worktree.id) + fileEventMonitors[worktree.id] = monitor } private func handleEvent( @@ -407,6 +493,9 @@ final class WorktreeInfoWatcherManager { guard worktree.host != nil else { return } + guard backgroundPollingAllowed else { + return + } let worktreeID = worktree.id let interval = refreshInterval(for: worktreeID) if let existing = remoteHeadPollTasks[worktreeID], existing.interval == interval { @@ -457,6 +546,7 @@ final class WorktreeInfoWatcherManager { } private func stopFileEventMonitor(for worktreeID: Worktree.ID) { + // Runs on the re-arm teardown too, so it must not clear the failed-set. fileEventMonitors.removeValue(forKey: worktreeID)?.cancel() } @@ -477,10 +567,14 @@ final class WorktreeInfoWatcherManager { worktrees.removeAll() selectedWorktreeID = nil pullRequestTrackingEnabled = true + // `automaticRefreshEnabled` and `isActive` are user / session state, not + // task state, so a teardown must not silently re-enable polling the user + // turned off. eventContinuation?.finish() } private func stopBackgroundRefreshTasks() { + stopReconcileLoop() for watcher in headWatchers.values { watcher.source.cancel() } @@ -504,6 +598,8 @@ final class WorktreeInfoWatcherManager { } headWatchers.removeAll() fileEventMonitors.removeAll() + fileEventMonitorFailedIDs.removeAll() + headWatcherFailedIDs.removeAll() branchDebounceTasks.removeAll() filesDebounceTasks.removeAll() restartTasks.removeAll() @@ -528,7 +624,10 @@ final class WorktreeInfoWatcherManager { cancelAllPullRequestSelectionCooldownTasks() } - private func refreshPullRequests(repositoryRootURL: URL) { + private func refreshPullRequests( + repositoryRootURL: URL, + trigger: WorktreeInfoWatcherClient.RefreshTrigger = .automatic + ) { guard pullRequestTrackingEnabled else { return } @@ -536,19 +635,133 @@ final class WorktreeInfoWatcherManager { guard !worktreeIDs.isEmpty else { return } - emit(.repositoryPullRequestRefresh(repositoryRootURL: repositoryRootURL, worktreeIDs: worktreeIDs)) + emit( + .repositoryPullRequestRefresh( + repositoryRootURL: repositoryRootURL, + worktreeIDs: worktreeIDs, + trigger: trigger + ) + ) } private func refreshAll() { - let worktreesToRefresh = worktrees.values.sorted { $0.id.rawValue < $1.id.rawValue } - for worktree in worktreesToRefresh { + for worktree in sortedWorktrees() { + // Re-arm the watcher so a manual refresh repairs a dead kqueue / file + // event stream, not just re-emits from a still-broken one. + configureWatcher(for: worktree) emitLineChangesChanged(worktreeID: worktree.id) } - let repositoryRoots = Set(worktrees.values.map(\.repositoryRootURL)).sorted { + for repositoryRootURL in sortedRepositoryRoots() { + refreshPullRequests(repositoryRootURL: repositoryRootURL, trigger: .manual) + } + } + + private func setAutomaticRefreshEnabled(_ enabled: Bool) { + guard automaticRefreshEnabled != enabled else { + return + } + automaticRefreshEnabled = enabled + reconfigureBackgroundPolling() + } + + private func setActive(_ active: Bool) { + guard isActive != active else { + return + } + isActive = active + reconfigureBackgroundPolling() + } + + private func reconfigureBackgroundPolling() { + guard backgroundPollingAllowed else { + stopReconcileLoop() + for worktreeID in Array(remoteHeadPollTasks.keys) { + stopRemoteHeadPoll(for: worktreeID) + } + return + } + for worktree in worktrees.values where worktree.host != nil { + configureRemoteHeadPoll(for: worktree) + } + startReconcileLoop() + } + + /// Self-healing backstop for signals no local event catches (remote line + /// counts, external merges, a dead watcher). Rolls through worktrees one at a + /// time so a sweep never fans a diff storm. + private func startReconcileLoop() { + guard backgroundPollingAllowed, !worktrees.isEmpty, reconcileTask == nil else { + return + } + let sleep = self.sleep + let interval = reconcileInterval + let step = reconcileStep + reconcileTask = Task { [weak self] in + while !Task.isCancelled { + do { + try await sleep(interval) + } catch { + return + } + let worktrees = await MainActor.run { self?.sortedWorktrees() } ?? [] + for worktree in worktrees { + guard !Task.isCancelled else { + return + } + await MainActor.run { self?.reconcileWorktree(worktree) } + do { + try await sleep(step) + } catch { + return + } + } + let repositoryRoots = await MainActor.run { self?.sortedRepositoryRoots() } ?? [] + for repositoryRootURL in repositoryRoots { + guard !Task.isCancelled else { + return + } + await MainActor.run { self?.refreshPullRequests(repositoryRootURL: repositoryRootURL) } + do { + try await sleep(step) + } catch { + return + } + } + } + } + } + + private func stopReconcileLoop() { + reconcileTask?.cancel() + reconcileTask = nil + } + + private func sortedWorktrees() -> [Worktree] { + worktrees.values.sorted { $0.id.rawValue < $1.id.rawValue } + } + + private func sortedRepositoryRoots() -> [URL] { + Set(worktrees.values.map(\.repositoryRootURL)).sorted { $0.path(percentEncoded: false) < $1.path(percentEncoded: false) } - for repositoryRootURL in repositoryRoots { - refreshPullRequests(repositoryRootURL: repositoryRootURL) + } + + private func reconcileWorktree(_ worktree: Worktree) { + guard let current = worktrees[worktree.id] else { + return + } + // A dead local watcher misses events a since-now stream never replays, so + // catch up once when re-arming; a live local watcher stays event-driven and + // is never diffed on a timer. + let localWatcherWasDead = + current.host == nil + && (headWatchers[current.id] == nil || fileEventMonitorFailedIDs.contains(current.id)) + configureWatcher(for: current) + if current.host != nil { + emitLineChangesChanged(worktreeID: current.id) + } else if localWatcherWasDead { + emitBranchChanged(worktreeID: current.id) + emitLineChangesChanged(worktreeID: current.id) } } @@ -617,7 +830,7 @@ final class WorktreeInfoWatcherManager { /// the single-id signals carry small payloads and describe themselves. private static func label(for event: WorktreeInfoWatcherClient.Event) -> String { switch event { - case .repositoryPullRequestRefresh(let rootURL, let worktreeIDs): + case .repositoryPullRequestRefresh(let rootURL, let worktreeIDs, _): "repositoryPullRequestRefresh(\(rootURL.lastPathComponent), \(worktreeIDs.count) worktrees)" default: String(describing: event) } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 21a348eae..d33ec544d 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -20,6 +20,9 @@ private enum CancelID { static func delayedPRRefresh(_ worktreeID: Worktree.ID) -> String { "repositories.delayedPRRefresh.\(worktreeID)" } + static func worktreeLineChanges(_ worktreeID: Worktree.ID) -> String { + "repositories.worktreeLineChanges.\(worktreeID)" + } } nonisolated let repositoriesLogger = SupaLogger("Repositories") @@ -319,6 +322,9 @@ struct RepositoriesFeature { struct PendingPullRequestRefresh: Equatable { var repositoryRootURL: URL var worktreeIDs: [Worktree.ID] + // Preserved across the queue so a manual refresh deferred during startup or + // an in-flight request still bypasses the background-refresh gate on replay. + var trigger: WorktreeInfoWatcherClient.RefreshTrigger } enum WorktreeCreationNameSource: Equatable { @@ -2348,6 +2354,7 @@ struct RepositoriesFeature { repositoryID: repositoryID, repositoryRootURL: queued.repositoryRootURL, worktreeIDs: queued.worktreeIDs, + trigger: queued.trigger, ) } state.queuedPullRequestRefreshByRepositoryID.removeAll() @@ -2375,7 +2382,8 @@ struct RepositoriesFeature { .worktreeInfoEvent( .repositoryPullRequestRefresh( repositoryRootURL: pending.repositoryRootURL, - worktreeIDs: pending.worktreeIDs + worktreeIDs: pending.worktreeIDs, + trigger: pending.trigger ) ) ) @@ -2397,7 +2405,8 @@ struct RepositoriesFeature { .worktreeInfoEvent( .repositoryPullRequestRefresh( repositoryRootURL: pending.repositoryRootURL, - worktreeIDs: pending.worktreeIDs + worktreeIDs: pending.worktreeIDs, + trigger: pending.trigger ) ) ) @@ -2414,7 +2423,8 @@ struct RepositoriesFeature { .worktreeInfoEvent( .repositoryPullRequestRefresh( repositoryRootURL: repository.rootURL, - worktreeIDs: repository.worktrees.map(\.id) + worktreeIDs: repository.worktrees.map(\.id), + trigger: .automatic ) ) ) @@ -2494,9 +2504,12 @@ struct RepositoriesFeature { let repoRoot = worktree.repositoryRootURL let repoHost = worktree.host let worktreeRoot = worktree.workingDirectory + // Consequence of an explicit PR action (merge / close / open), so it + // must refresh even when background refresh is off. let pullRequestRefresh = WorktreeInfoWatcherClient.Event.repositoryPullRequestRefresh( repositoryRootURL: repoRoot, - worktreeIDs: repository.worktrees.map(\.id) + worktreeIDs: repository.worktrees.map(\.id), + trigger: .manual ) let branchName = pullRequest.headRefName ?? worktree.name let failingCheckDetailsURL = (pullRequest.statusCheckRollup?.checks ?? []).first { @@ -3073,6 +3086,8 @@ struct RepositoriesFeature { state.inspectorPresented = presented return .none + // Only scheduled after an explicit PR action, to catch the settled state + // once GitHub reflects the mutation, so it refreshes as a manual trigger. case .delayedPullRequestRefresh(let worktreeID): guard let worktree = state.worktree(for: worktreeID), let repositoryID = state.repositoryID(containing: worktreeID), @@ -3089,7 +3104,8 @@ struct RepositoriesFeature { .worktreeInfoEvent( .repositoryPullRequestRefresh( repositoryRootURL: repositoryRootURL, - worktreeIDs: worktreeIDs + worktreeIDs: worktreeIDs, + trigger: .manual ) ) ) @@ -3126,7 +3142,18 @@ struct RepositoriesFeature { ) } } - case .repositoryPullRequestRefresh(let repositoryRootURL, let worktreeIDs): + // Coalesce overlapping diffs for the same worktree: a burst of + // reconcile / FS events can't stack `git diff` processes. + .cancellable(id: CancelID.worktreeLineChanges(worktreeID), cancelInFlight: true) + case .repositoryPullRequestRefresh(let repositoryRootURL, let worktreeIDs, let trigger): + // An automatic refresh is suppressed while the user has background + // repository refresh off; a manual refresh always runs. + if trigger == .automatic { + @Shared(.settingsFile) var settingsFile + guard settingsFile.global.automaticRepositoryRefreshEnabled else { + return .none + } + } let worktrees = worktreeIDs.compactMap { state.worktree(for: $0) } guard let firstWorktree = worktrees.first, let repositoryID = state.repositoryID(containing: firstWorktree.id) @@ -3153,6 +3180,7 @@ struct RepositoriesFeature { repositoryID: repositoryID, repositoryRootURL: repositoryRootURL, worktreeIDs: worktreeIDs, + trigger: trigger, ) return .none } @@ -3188,6 +3216,7 @@ struct RepositoriesFeature { repositoryID: repositoryID, repositoryRootURL: repositoryRootURL, worktreeIDs: worktreeIDs, + trigger: trigger, ) return .send(.refreshGithubIntegrationAvailability) case .checking: @@ -3195,6 +3224,7 @@ struct RepositoriesFeature { repositoryID: repositoryID, repositoryRootURL: repositoryRootURL, worktreeIDs: worktreeIDs, + trigger: trigger, ) return .none case .unavailable: @@ -3202,6 +3232,7 @@ struct RepositoriesFeature { repositoryID: repositoryID, repositoryRootURL: repositoryRootURL, worktreeIDs: worktreeIDs, + trigger: trigger, ) return .none case .disabled: @@ -4346,12 +4377,14 @@ struct RepositoriesFeature { state.renameBranchPrompt = nil // Refresh only the renamed row's PR; siblings still point at their // own branches. The HEAD watcher re-emits the name authoritatively. + // User-initiated rename, so it must refresh even with background refresh off. guard let repository = state.repositories[id: repositoryID] else { return .none } return .send( .worktreeInfoEvent( .repositoryPullRequestRefresh( repositoryRootURL: repository.rootURL, - worktreeIDs: [worktreeID] + worktreeIDs: [worktreeID], + trigger: .manual ) ) ) @@ -6105,17 +6138,24 @@ extension Dictionary where Key == Repository.ID, Value == RepositoriesFeature.Pe repositoryID: Repository.ID, repositoryRootURL: URL, worktreeIDs: [Worktree.ID], + trigger: WorktreeInfoWatcherClient.RefreshTrigger, ) { if var pending = self[repositoryID] { var seenWorktreeIDs = Set(pending.worktreeIDs) for worktreeID in worktreeIDs where seenWorktreeIDs.insert(worktreeID).inserted { pending.worktreeIDs.append(worktreeID) } + // A manual request anywhere in the merge wins, so the coalesced replay is + // never suppressed by the background-refresh gate. + if trigger == .manual { + pending.trigger = .manual + } self[repositoryID] = pending } else { self[repositoryID] = RepositoriesFeature.PendingPullRequestRefresh( repositoryRootURL: repositoryRootURL, worktreeIDs: worktreeIDs, + trigger: trigger, ) } } diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index ef8fe0e7d..950da849b 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -2,6 +2,7 @@ import ComposableArchitecture import DependenciesTestSupport import Foundation import IdentifiedCollections +import SwiftUI import Testing @testable import SupacodeSettingsFeature @@ -75,6 +76,26 @@ struct AppFeatureCommandPaletteTests { #expect(watcherCommands.value == [.refresh]) } + @Test(.dependencies) func scenePhaseChangesToggleWatcherActiveState() async { + let watcherCommands = LockIsolated<[WorktreeInfoWatcherClient.Command]>([]) + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } withDependencies: { + $0.worktreeInfoWatcher.send = { command in + watcherCommands.withValue { $0.append(command) } + } + } + store.exhaustivity = .off + + // `.inactive` cancels the periodic-refresh loop `.active` starts. + await store.send(.scenePhaseChanged(.active)) + await store.send(.scenePhaseChanged(.inactive)) + await store.finish() + + #expect(watcherCommands.value.contains(.setActive(true))) + #expect(watcherCommands.value.contains(.setActive(false))) + } + @Test(.dependencies) func repositoryRefreshDoesNotForceWorktreeInfoRefresh() async { let watcherCommands = LockIsolated<[WorktreeInfoWatcherClient.Command]>([]) let store = TestStore(initialState: AppFeature.State()) { diff --git a/supacodeTests/AppFeatureSettingsChangedTests.swift b/supacodeTests/AppFeatureSettingsChangedTests.swift index 04bebf256..16f31ab77 100644 --- a/supacodeTests/AppFeatureSettingsChangedTests.swift +++ b/supacodeTests/AppFeatureSettingsChangedTests.swift @@ -253,6 +253,30 @@ struct AppFeatureSettingsChangedTests { #expect(store.state.lastKnownTerminalHibernationEnabled == false) } + @Test(.dependencies) func settingsChangedFansOutAutomaticRefreshToWatcher() async { + let commands = LockIsolated<[WorktreeInfoWatcherClient.Command]>([]) + let store = TestStore( + initialState: AppFeature.State( + repositories: RepositoriesFeature.State(), + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.worktreeInfoWatcher.send = { command in + commands.withValue { $0.append(command) } + } + } + store.exhaustivity = .off + + var settings = GlobalSettings.default + settings.automaticRepositoryRefreshEnabled = false + + await store.send(.settings(.delegate(.settingsChanged(settings)))) + await store.finish() + #expect(commands.value.contains(.setAutomaticRefreshEnabled(false))) + } + @Test(.dependencies) func focusingASurfaceClearsTheStatesParkedOnIt() async { let rootURL = URL(fileURLWithPath: "/tmp/repo") let worktree = Worktree( diff --git a/supacodeTests/RemoteRepositorySidebarTests.swift b/supacodeTests/RemoteRepositorySidebarTests.swift index 63e8ea95f..41fedfae7 100644 --- a/supacodeTests/RemoteRepositorySidebarTests.swift +++ b/supacodeTests/RemoteRepositorySidebarTests.swift @@ -487,7 +487,8 @@ struct RemoteWorktreeInfoTests { // work, so an exhaustive TestStore send with no trailing closure passes. await store.send( .worktreeInfoEvent( - .repositoryPullRequestRefresh(repositoryRootURL: repository.rootURL, worktreeIDs: [worktree.id]) + .repositoryPullRequestRefresh( + repositoryRootURL: repository.rootURL, worktreeIDs: [worktree.id], trigger: .automatic) ) ) } diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 76f600aad..99e8f55b8 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -5960,7 +5960,8 @@ struct RepositoriesFeatureTests { .worktreeInfoEvent( .repositoryPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] + worktreeIDs: [mainWorktree.id, featureWorktree.id], + trigger: .automatic ) ) ) @@ -5970,6 +5971,140 @@ struct RepositoriesFeatureTests { #expect(batchCalls.value == [GithubRemoteInfo(host: "github.com", owner: "upstream", repo: "project")]) } + @Test(.dependencies) func automaticPullRequestRefreshSuppressedWhenBackgroundRefreshDisabled() async { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.automaticRepositoryRefreshEnabled = false } + + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureWorktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) + var initialState = makeState(repositories: [repository]) + initialState.githubIntegrationAvailability = .available + let batchCalls = LockIsolated<[GithubRemoteInfo]>([]) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.githubCLI.resolveRemoteInfo = { _ in + GithubRemoteInfo(host: "github.com", owner: "upstream", repo: "project") + } + $0.githubCLI.batchPullRequests = { host, owner, repo, _ in + batchCalls.withValue { $0.append(GithubRemoteInfo(host: host, owner: owner, repo: repo)) } + return [:] + } + } + store.exhaustivity = .off + + // An automatic refresh is dropped while background refresh is off, so `gh` + // never runs. + await store.send( + .worktreeInfoEvent( + .repositoryPullRequestRefresh( + repositoryRootURL: URL(fileURLWithPath: repoRoot), + worktreeIDs: [mainWorktree.id, featureWorktree.id], + trigger: .automatic + ) + ) + ) + await store.finish() + #expect(batchCalls.value.isEmpty) + + // A manual refresh bypasses the gate and still runs `gh`. + await store.send( + .worktreeInfoEvent( + .repositoryPullRequestRefresh( + repositoryRootURL: URL(fileURLWithPath: repoRoot), + worktreeIDs: [mainWorktree.id, featureWorktree.id], + trigger: .manual + ) + ) + ) + await store.receive(\.repositoryPullRequestRefreshCompleted) + await store.finish() + #expect(batchCalls.value.count == 1) + } + + @Test(.dependencies) func manualRefreshQueuedWhileUnknownStillRunsWhenBackgroundRefreshDisabled() async { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.automaticRepositoryRefreshEnabled = false } + + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureWorktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) + var initialState = makeState(repositories: [repository]) + initialState.reconcileSidebarForTesting() + let batchCalls = LockIsolated<[GithubRemoteInfo]>([]) + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.continuousClock = TestClock() + $0.githubIntegration.isAvailable = { true } + $0.githubCLI.resolveRemoteInfo = { _ in + GithubRemoteInfo(host: "github.com", owner: "upstream", repo: "project") + } + $0.githubCLI.batchPullRequests = { host, owner, repo, _ in + batchCalls.withValue { $0.append(GithubRemoteInfo(host: host, owner: owner, repo: repo)) } + return [:] + } + } + store.exhaustivity = .off + + // A manual refresh defers while availability is unknown; once it resolves, + // the queued replay preserves the manual trigger and bypasses the gate. + await store.send( + .worktreeInfoEvent( + .repositoryPullRequestRefresh( + repositoryRootURL: URL(fileURLWithPath: repoRoot), + worktreeIDs: [mainWorktree.id, featureWorktree.id], + trigger: .manual + ) + ) + ) + await store.receive(\.repositoryPullRequestRefreshCompleted) + await store.finish() + #expect(batchCalls.value.count == 1) + } + + @Test(.dependencies) func userPrMergeRefreshesEvenWhenBackgroundRefreshDisabled() async { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.automaticRepositoryRefreshEnabled = false } + + let repoRoot = "/tmp/repo" + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) + let featureWorktree = makeWorktree(id: "\(repoRoot)/feature", name: "feature", repoRoot: repoRoot) + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) + let openPullRequest = makePullRequest(state: "OPEN", headRefName: featureWorktree.name, number: 12) + var state = makeState(repositories: [repository]) + state.githubIntegrationAvailability = .available + state.reconcileSidebarForTesting() + state.setWorktreeInfoForTesting( + id: featureWorktree.id, addedLines: nil, removedLines: nil, pullRequest: openPullRequest) + let batchCalls = LockIsolated<[Int]>([]) + let store = TestStore(initialState: state) { + RepositoriesFeature() + } withDependencies: { + $0.continuousClock = TestClock() + $0.githubIntegration.isAvailable = { true } + $0.githubCLI.mergePullRequest = { _, _, _, _ in } + $0.githubCLI.resolveRemoteInfo = { _ in + GithubRemoteInfo(host: "github.com", owner: "upstream", repo: "project") + } + $0.githubCLI.batchPullRequests = { _, _, _, _ in + batchCalls.withValue { $0.append(1) } + return [:] + } + } + store.exhaustivity = .off + + // The post-merge refresh is a manual trigger, so it runs gh despite the + // background-refresh opt-out. + await store.send(.pullRequestAction(featureWorktree.id, .merge)) + await store.receive(\.repositoryPullRequestRefreshCompleted) + await store.finish() + #expect(!batchCalls.value.isEmpty) + } + @Test func worktreeInfoEventRepositoryPullRequestRefreshFallsBackToGitRemote() async { let repoRoot = "/tmp/repo" let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) @@ -6000,7 +6135,8 @@ struct RepositoriesFeatureTests { .worktreeInfoEvent( .repositoryPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] + worktreeIDs: [mainWorktree.id, featureWorktree.id], + trigger: .automatic ) ) ) @@ -6157,7 +6293,8 @@ struct RepositoriesFeatureTests { .worktreeInfoEvent( .repositoryPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] + worktreeIDs: [mainWorktree.id, featureWorktree.id], + trigger: .automatic ) ) ) { @@ -6304,13 +6441,15 @@ struct RepositoriesFeatureTests { .worktreeInfoEvent( .repositoryPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] + worktreeIDs: [mainWorktree.id, featureWorktree.id], + trigger: .automatic ) ) ) { $0.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] + worktreeIDs: [mainWorktree.id, featureWorktree.id], + trigger: .automatic ) } await store.receive(\.refreshGithubIntegrationAvailability) { @@ -6404,13 +6543,15 @@ struct RepositoriesFeatureTests { .worktreeInfoEvent( .repositoryPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] + worktreeIDs: [mainWorktree.id, featureWorktree.id], + trigger: .automatic ) ) ) { $0.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] + worktreeIDs: [mainWorktree.id, featureWorktree.id], + trigger: .automatic ) } await store.finish() @@ -6429,7 +6570,8 @@ struct RepositoriesFeatureTests { initialState.githubIntegrationAvailability = .unavailable initialState.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] + worktreeIDs: [mainWorktree.id, featureWorktree.id], + trigger: .automatic ) let store = TestStore(initialState: initialState) { RepositoriesFeature() @@ -6474,7 +6616,8 @@ struct RepositoriesFeatureTests { initialState.inFlightPullRequestRefreshRepositoryIDs = [repository.id] initialState.queuedPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] + worktreeIDs: [mainWorktree.id, featureWorktree.id], + trigger: .automatic ) let store = TestStore(initialState: initialState) { RepositoriesFeature() @@ -6486,7 +6629,8 @@ struct RepositoriesFeatureTests { $0.githubIntegrationAvailability = .unavailable $0.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] + worktreeIDs: [mainWorktree.id, featureWorktree.id], + trigger: .automatic ) $0.queuedPullRequestRefreshByRepositoryID = [:] $0.inFlightPullRequestRefreshRepositoryIDs = [] @@ -6505,7 +6649,8 @@ struct RepositoriesFeatureTests { state.githubIntegrationAvailability = .disabled state.pendingPullRequestRefreshByRepositoryID["repo"] = RepositoriesFeature.PendingPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), - worktreeIDs: [] + worktreeIDs: [], + trigger: .automatic ) let expectedState = state state.reconcileSidebarForTesting() @@ -6535,7 +6680,8 @@ struct RepositoriesFeatureTests { RepositoriesFeature .PendingPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] + worktreeIDs: [mainWorktree.id, featureWorktree.id], + trigger: .automatic ) state.reconcileSidebarForTesting() let store = TestStore(initialState: state) { @@ -6662,7 +6808,8 @@ struct RepositoriesFeatureTests { .worktreeInfoEvent( .repositoryPullRequestRefresh( repositoryRootURL: URL(fileURLWithPath: repoRoot), - worktreeIDs: [mainWorktree.id, featureWorktree.id] + worktreeIDs: [mainWorktree.id, featureWorktree.id], + trigger: .automatic ) ) ) { diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index 83866ec23..d0415999e 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -130,6 +130,21 @@ struct SettingsFeatureTests { #expect(settingsFile.global.terminalHibernationEnabled == false) } + @Test(.dependencies) func togglingAutomaticRepositoryRefreshPersistsChanges() async { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = .default } + + let store = TestStore(initialState: SettingsFeature.State()) { + SettingsFeature() + } + + await store.send(.binding(.set(\.automaticRepositoryRefreshEnabled, false))) { + $0.automaticRepositoryRefreshEnabled = false + } + await store.receive(\.delegate.settingsChanged) + #expect(settingsFile.global.automaticRepositoryRefreshEnabled == false) + } + @Test(.dependencies) func confirmCloseSurfacePersistsChanges() async { var initialSettings = GlobalSettings.default initialSettings.confirmCloseSurface = true diff --git a/supacodeTests/SettingsFilePersistenceTests.swift b/supacodeTests/SettingsFilePersistenceTests.swift index 5531839cc..d68b8de46 100644 --- a/supacodeTests/SettingsFilePersistenceTests.swift +++ b/supacodeTests/SettingsFilePersistenceTests.swift @@ -475,6 +475,29 @@ struct SettingsFilePersistenceTests { #expect(settings.global.terminalHibernationEnabled == true) } + @Test(.dependencies) func decodesMissingAutomaticRepositoryRefreshEnabledAsTrue() throws { + let legacy = LegacySettingsFile( + global: LegacyGlobalSettings( + appearanceMode: .dark, + updatesAutomaticallyCheckForUpdates: false, + updatesAutomaticallyDownloadUpdates: true + ), + repositories: [:] + ) + let data = try JSONEncoder().encode(legacy) + let storage = MutableTestStorage(initialData: data) + + let settings: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + return settings + } + + // A pre-feature file omits the key; background refresh defaults on. + #expect(settings.global.automaticRepositoryRefreshEnabled == true) + } + @Test(.dependencies) func decodesMissingAppVisibilityAsDefault() throws { // A file predating the menu bar feature falls through to the default, which // now shows the menu bar too. diff --git a/supacodeTests/WorktreeInfoWatcherManagerTests.swift b/supacodeTests/WorktreeInfoWatcherManagerTests.swift index e09cfb4bb..e4412cd63 100644 --- a/supacodeTests/WorktreeInfoWatcherManagerTests.swift +++ b/supacodeTests/WorktreeInfoWatcherManagerTests.swift @@ -516,6 +516,277 @@ struct WorktreeInfoWatcherManagerTests { #expect(count == WorktreeInfoWatcherManager.eventBufferCap) try FileManager.default.removeItem(at: tempWorktree.tempRoot) } + + @Test func reconcileBackstopRefreshesRemoteLineChangesAndPullRequests() async throws { + let clock = TestClock() + let stub = RemoteBranchPollStub(responses: ["main"]) + let manager = WorktreeInfoWatcherManager( + focusedInterval: .seconds(3_600), + unfocusedInterval: .seconds(3_600), + filesChangedDebounceInterval: .seconds(3_600), + reconcileInterval: .seconds(1), + reconcileStep: .milliseconds(10), + clock: clock, + pollRemoteBranch: { _ in await stub.next() } + ) + let (collector, task) = startCollecting(manager.eventStream()) + let remote = makeRemoteWorktree(name: "remote-eagle") + + manager.handleCommand(.setWorktrees([remote])) + #expect(await waitForFilesChangedCount(collector, worktreeID: remote.id, atLeast: 1) == 1) + #expect( + await waitForPullRequestRefreshCount( + collector, repositoryRootURL: remote.repositoryRootURL, atLeast: 1 + ) == 1 + ) + + // One reconcile sweep re-emits the remote worktree's line changes (no local + // FS events) and re-refreshes its pull requests. + await clock.advance(by: .seconds(1)) + #expect(await waitForFilesChangedCount(collector, worktreeID: remote.id, atLeast: 2) == 2) + await clock.advance(by: .milliseconds(10)) + #expect( + await waitForPullRequestRefreshCount( + collector, repositoryRootURL: remote.repositoryRootURL, atLeast: 2 + ) == 2 + ) + + manager.handleCommand(.stop) + await task.value + } + + @Test func reconcileBackstopLeavesLocalLineChangesToFileEvents() async throws { + let clock = TestClock() + let tempWorktree = try makeTempWorktree() + let manager = WorktreeInfoWatcherManager( + filesChangedDebounceInterval: .seconds(3_600), + reconcileInterval: .seconds(1), + reconcileStep: .milliseconds(10), + clock: clock + ) + let (collector, task) = startCollecting(manager.eventStream()) + + manager.handleCommand(.setWorktrees([tempWorktree.worktree])) + #expect( + await waitForFilesChangedCount(collector, worktreeID: tempWorktree.worktree.id, atLeast: 1) == 1 + ) + #expect( + await waitForPullRequestRefreshCount( + collector, repositoryRootURL: tempWorktree.tempRoot, atLeast: 1 + ) == 1 + ) + + // The sweep re-refreshes pull requests but must not diff a local worktree on + // a timer; local line counts stay event-driven. + await clock.advance(by: .seconds(1)) + await clock.advance(by: .milliseconds(10)) + #expect( + await waitForPullRequestRefreshCount( + collector, repositoryRootURL: tempWorktree.tempRoot, atLeast: 2 + ) == 2 + ) + await drainAsyncEvents(200) + #expect(await collector.filesChangedCount(worktreeID: tempWorktree.worktree.id) == 1) + + manager.handleCommand(.stop) + await task.value + try FileManager.default.removeItem(at: tempWorktree.tempRoot) + } + + @Test func backgroundPollingPausesWhileInactiveAndResumesWhenActive() async throws { + let clock = TestClock() + let stub = RemoteBranchPollStub(responses: ["main"]) + let manager = WorktreeInfoWatcherManager( + focusedInterval: .seconds(3_600), + unfocusedInterval: .seconds(3_600), + filesChangedDebounceInterval: .seconds(3_600), + reconcileInterval: .seconds(1), + reconcileStep: .milliseconds(10), + clock: clock, + pollRemoteBranch: { _ in await stub.next() } + ) + let (collector, task) = startCollecting(manager.eventStream()) + let remote = makeRemoteWorktree(name: "remote-eagle") + + manager.handleCommand(.setWorktrees([remote])) + #expect(await waitForFilesChangedCount(collector, worktreeID: remote.id, atLeast: 1) == 1) + + // Going inactive stops the reconcile sweep and the remote SSH head poll. + manager.handleCommand(.setActive(false)) + await drainAsyncEvents(200) + let callsWhileInactive = await stub.callCount + await clock.advance(by: .seconds(5)) + await drainAsyncEvents(200) + #expect(await collector.filesChangedCount(worktreeID: remote.id) == 1) + #expect(await collector.pullRequestRefreshCount(repositoryRootURL: remote.repositoryRootURL) == 1) + #expect(await stub.callCount == callsWhileInactive) + + // Reactivating resumes both. + manager.handleCommand(.setActive(true)) + await drainAsyncEvents(200) + #expect(await stub.callCount > callsWhileInactive) + await clock.advance(by: .seconds(1)) + #expect(await waitForFilesChangedCount(collector, worktreeID: remote.id, atLeast: 2) == 2) + + manager.handleCommand(.stop) + await task.value + } + + @Test func disablingPollingStopsBackgroundWorkAndReenablingResumesIt() async throws { + let clock = TestClock() + let stub = RemoteBranchPollStub(responses: ["main"]) + let manager = WorktreeInfoWatcherManager( + focusedInterval: .seconds(3_600), + unfocusedInterval: .seconds(3_600), + filesChangedDebounceInterval: .seconds(3_600), + reconcileInterval: .seconds(1), + reconcileStep: .milliseconds(10), + clock: clock, + pollRemoteBranch: { _ in await stub.next() } + ) + let (collector, task) = startCollecting(manager.eventStream()) + let remote = makeRemoteWorktree(name: "remote-eagle") + + manager.handleCommand(.setWorktrees([remote])) + #expect(await waitForFilesChangedCount(collector, worktreeID: remote.id, atLeast: 1) == 1) + + manager.handleCommand(.setAutomaticRefreshEnabled(false)) + await drainAsyncEvents(200) + let callsWhileDisabled = await stub.callCount + await clock.advance(by: .seconds(5)) + await drainAsyncEvents(200) + #expect(await collector.filesChangedCount(worktreeID: remote.id) == 1) + #expect(await stub.callCount == callsWhileDisabled) + + manager.handleCommand(.setAutomaticRefreshEnabled(true)) + await drainAsyncEvents(200) + #expect(await stub.callCount > callsWhileDisabled) + await clock.advance(by: .seconds(1)) + #expect(await waitForFilesChangedCount(collector, worktreeID: remote.id, atLeast: 2) == 2) + + manager.handleCommand(.stop) + await task.value + } + + @Test func pollingDisabledAtConstructionSkipsRemoteStatusWorkButKeepsLocalDiscovery() async throws { + let clock = TestClock() + let stub = RemoteBranchPollStub(responses: ["main"]) + let tempWorktree = try makeTempWorktree() + let manager = WorktreeInfoWatcherManager( + focusedInterval: .seconds(3_600), + unfocusedInterval: .seconds(3_600), + filesChangedDebounceInterval: .seconds(3_600), + reconcileInterval: .seconds(1), + reconcileStep: .milliseconds(10), + automaticRefreshEnabled: false, + clock: clock, + pollRemoteBranch: { _ in await stub.next() } + ) + let (collector, task) = startCollecting(manager.eventStream()) + let remote = makeRemoteWorktree(name: "remote-eagle") + + manager.handleCommand(.setWorktrees([remote, tempWorktree.worktree])) + await drainAsyncEvents(200) + await clock.advance(by: .seconds(5)) + await drainAsyncEvents(200) + + // No recurring SSH head poll and no reconcile sweep while disabled, and the + // remote worktree's SSH line count is not read on discovery either. A local + // worktree still shows its counts, since that diff is cheap and never SSH. + #expect(await stub.callCount == 0) + #expect(await collector.filesChangedCount(worktreeID: remote.id) == 0) + #expect(await collector.filesChangedCount(worktreeID: tempWorktree.worktree.id) == 1) + + manager.handleCommand(.stop) + await task.value + try FileManager.default.removeItem(at: tempWorktree.tempRoot) + } + + @Test func fileEventRootsIncludesLinkedWorktreeAdminDir() throws { + let linked = try makeLinkedWorktree() + let manager = WorktreeInfoWatcherManager() + + let roots = manager.fileEventRoots(for: linked.worktree) + + // The working tree plus the admin dir outside it, so a commit that only + // rewrites the admin dir still produces a file event. + #expect(roots.contains(linked.worktree.workingDirectory)) + #expect(roots.contains(linked.adminDirectory)) + #expect(roots.count == 2) + + try FileManager.default.removeItem(at: linked.tempRoot) + } + + @Test func fileEventRootsIsJustTheWorkingTreeForAnEmbeddedGitDir() throws { + let tempWorktree = try makeTempWorktree() + let manager = WorktreeInfoWatcherManager() + + let roots = manager.fileEventRoots(for: tempWorktree.worktree) + + #expect(roots == [tempWorktree.worktree.workingDirectory]) + + try FileManager.default.removeItem(at: tempWorktree.tempRoot) + } + + @Test func reconcileCatchesUpALocalWorktreeWhoseWatcherIsDead() async throws { + let clock = TestClock() + // A worktree with no resolvable HEAD never arms a head watcher, so the + // reconcile backstop must emit a catch-up instead of leaving it stale. + let worktree = makeLocalWorktreeWithoutGitMetadata() + let manager = WorktreeInfoWatcherManager( + filesChangedDebounceInterval: .seconds(3_600), + reconcileInterval: .seconds(1), + reconcileStep: .milliseconds(10), + clock: clock + ) + let (collector, task) = startCollecting(manager.eventStream()) + + manager.handleCommand(.setPullRequestTrackingEnabled(false)) + manager.handleCommand(.setWorktrees([worktree])) + #expect(await waitForFilesChangedCount(collector, worktreeID: worktree.id, atLeast: 1) == 1) + #expect(await collector.branchChangedCount(worktreeID: worktree.id) == 0) + + await clock.advance(by: .seconds(1)) + #expect(await waitForFilesChangedCount(collector, worktreeID: worktree.id, atLeast: 2) == 2) + #expect(await collector.branchChangedCount(worktreeID: worktree.id) == 1) + + manager.handleCommand(.stop) + await task.value + } + + @Test func reconcileSweepStaggersWorktreesOneStepApartInOrder() async throws { + let clock = TestClock() + let manager = WorktreeInfoWatcherManager( + focusedInterval: .seconds(3_600), + unfocusedInterval: .seconds(3_600), + filesChangedDebounceInterval: .seconds(3_600), + reconcileInterval: .seconds(1), + reconcileStep: .seconds(1), + clock: clock, + pollRemoteBranch: { _ in "main" } + ) + let (collector, task) = startCollecting(manager.eventStream()) + let first = makeRemoteWorktree(name: "alpha") + let second = makeRemoteWorktree(name: "beta") + #expect(first.id.rawValue < second.id.rawValue) + + manager.handleCommand(.setPullRequestTrackingEnabled(false)) + manager.handleCommand(.setWorktrees([first, second])) + _ = await waitForFilesChangedCount(collector, worktreeID: first.id, atLeast: 1) + _ = await waitForFilesChangedCount(collector, worktreeID: second.id, atLeast: 1) + + // The sweep reconciles one worktree per step, in id order: alpha, then beta. + await clock.advance(by: .seconds(1)) + #expect(await waitForFilesChangedCount(collector, worktreeID: first.id, atLeast: 2) == 2) + await drainAsyncEvents(120) + #expect(await collector.filesChangedCount(worktreeID: second.id) == 1) + + await clock.advance(by: .seconds(1)) + #expect(await waitForFilesChangedCount(collector, worktreeID: second.id, atLeast: 2) == 2) + + manager.handleCommand(.stop) + await task.value + } } actor EventCollector { @@ -543,7 +814,7 @@ actor EventCollector { func pullRequestRefreshCount(repositoryRootURL: URL) -> Int { events.reduce(into: 0) { result, event in - if case .repositoryPullRequestRefresh(let rootURL, _) = event, rootURL == repositoryRootURL { + if case .repositoryPullRequestRefresh(let rootURL, _, _) = event, rootURL == repositoryRootURL { result += 1 } } @@ -581,6 +852,52 @@ private func makeRemoteWorktree(name: String) -> Worktree { ) } +private struct LinkedWorktree { + let worktree: Worktree + let adminDirectory: URL + let tempRoot: URL +} + +/// A linked worktree whose `.git` is a file pointing at an admin dir outside +/// the working tree, mirroring `git worktree add`. +private func makeLinkedWorktree() throws -> LinkedWorktree { + let fileManager = FileManager.default + let tempRoot = fileManager.temporaryDirectory.appending(path: UUID().uuidString) + let repositoryRoot = tempRoot.appending(path: "repo") + let adminDirectory = repositoryRoot.appending(path: ".git/worktrees/feature") + try fileManager.createDirectory(at: adminDirectory, withIntermediateDirectories: true) + try "ref: refs/heads/feature\n".write( + to: adminDirectory.appending(path: "HEAD"), atomically: true, encoding: .utf8) + let worktreeDirectory = tempRoot.appending(path: "wt-feature") + try fileManager.createDirectory(at: worktreeDirectory, withIntermediateDirectories: true) + try "gitdir: \(adminDirectory.path(percentEncoded: false))\n".write( + to: worktreeDirectory.appending(path: ".git"), atomically: true, encoding: .utf8) + let worktree = Worktree( + id: WorktreeID(worktreeDirectory.path(percentEncoded: false)), + name: "feature", + detail: "detail", + workingDirectory: worktreeDirectory, + repositoryRootURL: repositoryRoot + ) + return LinkedWorktree( + worktree: worktree, + adminDirectory: adminDirectory.standardizedFileURL, + tempRoot: tempRoot + ) +} + +/// A worktree pointing at a directory with no `.git`, so no head watcher can +/// ever arm for it. +private func makeLocalWorktreeWithoutGitMetadata() -> Worktree { + Worktree( + id: WorktreeID("/private/tmp/\(UUID().uuidString)/wt"), + name: "orphan", + detail: "detail", + workingDirectory: URL(fileURLWithPath: "/private/tmp/\(UUID().uuidString)/wt"), + repositoryRootURL: URL(fileURLWithPath: "/private/tmp/\(UUID().uuidString)") + ) +} + private struct TempWorktree { let worktree: Worktree let tempRoot: URL