From 699e712a6880a866d7b6deb6f2d413e1f042bcc8 Mon Sep 17 00:00:00 2001 From: onevcat Date: Wed, 17 Jun 2026 22:56:06 +0900 Subject: [PATCH] Refresh PR state when git remotes change --- docs/components/github-pull-requests.md | 5 + .../WorktreeInfoWatcherClient.swift | 1 + .../BusinessLogic/WorktreeInfoMonitors.swift | 220 +++++++++++++----- .../WorktreeInfoWatcherManager.swift | 63 +++++ .../RepositoriesFeature+CoreReducer.swift | 26 +++ ...epositoriesFeature+GithubIntegration.swift | 11 + ...epositoriesFeature+RepositoryLoading.swift | 7 +- supacodeTests/RepositoriesFeatureTests.swift | 57 ++++- .../WorktreeInfoWatcherManagerTests.swift | 81 +++++++ 9 files changed, 396 insertions(+), 75 deletions(-) diff --git a/docs/components/github-pull-requests.md b/docs/components/github-pull-requests.md index 166c03b8..a1c4081c 100644 --- a/docs/components/github-pull-requests.md +++ b/docs/components/github-pull-requests.md @@ -19,6 +19,11 @@ the worktree branch. `origin` is preferred, `upstream` comes next, and other named remotes are used alphabetically, so fork-based worktrees can show upstream PRs without changing `origin` or restarting the app. +Prowl also watches the repository's git config while the app is running. When +remote URLs are added, removed, or changed, it refreshes the repository's PR +state and code-host label automatically; if the repository no longer has a +GitHub remote, stale PR badges are cleared. + ## What it shows - PR number, title, state (open/closed/merged), draft status. diff --git a/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift b/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift index bc3f4038..7a22db16 100644 --- a/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift +++ b/supacode/Clients/WorktreeInfoWatcher/WorktreeInfoWatcherClient.swift @@ -18,6 +18,7 @@ struct WorktreeInfoWatcherClient { case branchChanged(worktreeID: Worktree.ID) case filesChanged(worktreeID: Worktree.ID) case repositoryWorktreesChanged(repositoryRootURL: URL) + case repositoryRemoteConfigurationChanged(repositoryRootURL: URL) case repositoryPullRequestRefresh(repositoryRootURL: URL, worktreeIDs: [Worktree.ID]) } } diff --git a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoMonitors.swift b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoMonitors.swift index 5ccf6553..460d982e 100644 --- a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoMonitors.swift +++ b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoMonitors.swift @@ -13,6 +13,11 @@ protocol WorktreeRegistryMonitoring: AnyObject { func cancel() } +@MainActor +protocol RemoteConfigMonitoring: AnyObject { + func cancel() +} + final class FSEventsWorktreeFileEventMonitor: WorktreeFileEventMonitoring { private let onEvent: @MainActor @Sendable () -> Void private var stream: FSEventStreamRef? @@ -76,9 +81,71 @@ final class FSEventsWorktreeFileEventMonitor: WorktreeFileEventMonitoring { } } +private enum GitCommonDirectory { + static func url(for repositoryRootURL: URL, fileManager: FileManager) -> URL? { + let repositoryRootURL = repositoryRootURL.standardizedFileURL + let dotGitURL = repositoryRootURL.appending(path: ".git") + var isDirectory = ObjCBool(false) + if fileManager.fileExists(atPath: dotGitURL.path(percentEncoded: false), isDirectory: &isDirectory) { + if isDirectory.boolValue { + return dotGitURL.standardizedFileURL + } + guard let gitdirURL = gitdirURL(from: dotGitURL, relativeTo: repositoryRootURL) else { + return nil + } + return commonDirectoryURL(from: gitdirURL) + } + if fileManager.fileExists(atPath: repositoryRootURL.appending(path: "HEAD").path(percentEncoded: false)), + fileManager.fileExists(atPath: repositoryRootURL.appending(path: "config").path(percentEncoded: false)) + { + return repositoryRootURL + } + return nil + } + + static func isDirectory(_ url: URL, fileManager: FileManager) -> Bool { + var isDirectory = ObjCBool(false) + return fileManager.fileExists(atPath: url.path(percentEncoded: false), isDirectory: &isDirectory) + && isDirectory.boolValue + } + + private static func gitdirURL(from dotGitURL: URL, relativeTo repositoryRootURL: URL) -> URL? { + guard let contents = try? String(contentsOf: dotGitURL, encoding: .utf8), + let line = contents.split(whereSeparator: \.isNewline).first + else { + return nil + } + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + let prefix = "gitdir:" + guard trimmed.hasPrefix(prefix) else { + return nil + } + let pathPart = trimmed.dropFirst(prefix.count).trimmingCharacters(in: .whitespacesAndNewlines) + guard !pathPart.isEmpty else { + return nil + } + return URL(fileURLWithPath: String(pathPart), relativeTo: repositoryRootURL) + .standardizedFileURL + } + + private static func commonDirectoryURL(from gitdirURL: URL) -> URL { + let commondirURL = gitdirURL.appending(path: "commondir") + guard let contents = try? String(contentsOf: commondirURL, encoding: .utf8), + let line = contents.split(whereSeparator: \.isNewline).first + else { + return gitdirURL.standardizedFileURL + } + let pathPart = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !pathPart.isEmpty else { + return gitdirURL.standardizedFileURL + } + return URL(fileURLWithPath: pathPart, relativeTo: gitdirURL) + .standardizedFileURL + } +} + @MainActor final class GitWorktreeRegistryMonitor: WorktreeRegistryMonitoring { - private let commonGitDirectoryURL: URL private let worktreesDirectoryURL: URL private let onEvent: @MainActor @Sendable () -> Void private var commonDirectorySource: DispatchSourceFileSystemObject? @@ -90,18 +157,12 @@ final class GitWorktreeRegistryMonitor: WorktreeRegistryMonitoring { onEvent: @escaping @MainActor @Sendable () -> Void, fileManager: FileManager = .default ) { - guard - let commonGitDirectoryURL = Self.commonGitDirectoryURL( - for: repositoryRootURL, - fileManager: fileManager - ) - else { + guard let commonGitDirectoryURL = GitCommonDirectory.url(for: repositoryRootURL, fileManager: fileManager) else { return nil } - self.commonGitDirectoryURL = commonGitDirectoryURL worktreesDirectoryURL = commonGitDirectoryURL.appending(path: "worktrees") self.onEvent = onEvent - isWorktreesDirectoryPresent = Self.isDirectory(worktreesDirectoryURL, fileManager: fileManager) + isWorktreesDirectoryPresent = GitCommonDirectory.isDirectory(worktreesDirectoryURL, fileManager: fileManager) commonDirectorySource = Self.makeDirectorySource(url: commonGitDirectoryURL) { [weak self] in self?.handleCommonDirectoryEvent() } @@ -121,7 +182,7 @@ final class GitWorktreeRegistryMonitor: WorktreeRegistryMonitoring { } private func handleCommonDirectoryEvent() { - let isPresent = Self.isDirectory(worktreesDirectoryURL, fileManager: .default) + let isPresent = GitCommonDirectory.isDirectory(worktreesDirectoryURL, fileManager: .default) guard isPresent != isWorktreesDirectoryPresent else { return } @@ -136,7 +197,7 @@ final class GitWorktreeRegistryMonitor: WorktreeRegistryMonitoring { } private func handleWorktreesDirectoryEvent() { - guard Self.isDirectory(worktreesDirectoryURL, fileManager: .default) else { + guard GitCommonDirectory.isDirectory(worktreesDirectoryURL, fileManager: .default) else { if isWorktreesDirectoryPresent { isWorktreesDirectoryPresent = false worktreesDirectorySource?.cancel() @@ -179,68 +240,105 @@ final class GitWorktreeRegistryMonitor: WorktreeRegistryMonitoring { source.resume() return source } +} - private static func commonGitDirectoryURL( - for repositoryRootURL: URL, - fileManager: FileManager - ) -> URL? { - let repositoryRootURL = repositoryRootURL.standardizedFileURL - let dotGitURL = repositoryRootURL.appending(path: ".git") - var isDirectory = ObjCBool(false) - if fileManager.fileExists(atPath: dotGitURL.path(percentEncoded: false), isDirectory: &isDirectory) { - if isDirectory.boolValue { - return dotGitURL.standardizedFileURL - } - guard let gitdirURL = gitdirURL(from: dotGitURL, relativeTo: repositoryRootURL) else { - return nil - } - return commonDirectoryURL(from: gitdirURL, fileManager: fileManager) - } - if fileManager.fileExists(atPath: repositoryRootURL.appending(path: "HEAD").path(percentEncoded: false)), - fileManager.fileExists(atPath: repositoryRootURL.appending(path: "config").path(percentEncoded: false)) - { - return repositoryRootURL - } - return nil - } +@MainActor +final class GitRemoteConfigMonitor: RemoteConfigMonitoring { + private let configURL: URL + private let onEvent: @MainActor @Sendable () -> Void + private var commonDirectorySource: DispatchSourceFileSystemObject? + private var configSource: DispatchSourceFileSystemObject? + private var configFingerprint: Data? - private static func gitdirURL(from dotGitURL: URL, relativeTo repositoryRootURL: URL) -> URL? { - guard let contents = try? String(contentsOf: dotGitURL, encoding: .utf8), - let line = contents.split(whereSeparator: \.isNewline).first - else { + init?( + repositoryRootURL: URL, + onEvent: @escaping @MainActor @Sendable () -> Void, + fileManager: FileManager = .default + ) { + guard let commonGitDirectoryURL = GitCommonDirectory.url(for: repositoryRootURL, fileManager: fileManager) else { return nil } - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - let prefix = "gitdir:" - guard trimmed.hasPrefix(prefix) else { - return nil + configURL = commonGitDirectoryURL.appending(path: "config") + self.onEvent = onEvent + configFingerprint = Self.configFingerprint(configURL) + commonDirectorySource = Self.makeFileSource( + url: commonGitDirectoryURL, eventMask: [.write, .rename, .delete, .attrib] + ) { + [weak self] in + self?.handleCommonDirectoryEvent() } - let pathPart = trimmed.dropFirst(prefix.count).trimmingCharacters(in: .whitespacesAndNewlines) - guard !pathPart.isEmpty else { + startConfigSourceIfNeeded() + guard commonDirectorySource != nil || configSource != nil else { return nil } - return URL(fileURLWithPath: String(pathPart), relativeTo: repositoryRootURL) - .standardizedFileURL } - private static func commonDirectoryURL(from gitdirURL: URL, fileManager: FileManager) -> URL { - let commondirURL = gitdirURL.appending(path: "commondir") - guard let contents = try? String(contentsOf: commondirURL, encoding: .utf8), - let line = contents.split(whereSeparator: \.isNewline).first - else { - return gitdirURL.standardizedFileURL + func cancel() { + commonDirectorySource?.cancel() + configSource?.cancel() + commonDirectorySource = nil + configSource = nil + } + + private func handleCommonDirectoryEvent() { + restartConfigSource() + emitIfConfigChanged() + } + + private func handleConfigEvent() { + emitIfConfigChanged() + restartConfigSource() + } + + private func startConfigSourceIfNeeded() { + guard configSource == nil else { + return } - let pathPart = line.trimmingCharacters(in: .whitespacesAndNewlines) - guard !pathPart.isEmpty else { - return gitdirURL.standardizedFileURL + configSource = Self.makeFileSource(url: configURL, eventMask: [.write, .rename, .delete, .attrib]) { [weak self] in + self?.handleConfigEvent() } - return URL(fileURLWithPath: pathPart, relativeTo: gitdirURL) - .standardizedFileURL } - private static func isDirectory(_ url: URL, fileManager: FileManager) -> Bool { - var isDirectory = ObjCBool(false) - return fileManager.fileExists(atPath: url.path(percentEncoded: false), isDirectory: &isDirectory) - && isDirectory.boolValue + private func restartConfigSource() { + configSource?.cancel() + configSource = nil + startConfigSourceIfNeeded() + } + + private func emitIfConfigChanged() { + let latestFingerprint = Self.configFingerprint(configURL) + guard latestFingerprint != configFingerprint else { + return + } + configFingerprint = latestFingerprint + onEvent() + } + + private static func configFingerprint(_ url: URL) -> Data? { + try? Data(contentsOf: url) + } + + private static func makeFileSource( + url: URL, + eventMask: DispatchSource.FileSystemEvent, + onEvent: @escaping @MainActor @Sendable () -> Void + ) -> DispatchSourceFileSystemObject? { + let fileDescriptor = open(url.path(percentEncoded: false), O_EVTONLY) + guard fileDescriptor >= 0 else { + return nil + } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fileDescriptor, + eventMask: eventMask, + queue: .main + ) + source.setEventHandler { + onEvent() + } + source.setCancelHandler { + close(fileDescriptor) + } + source.resume() + return source } } diff --git a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift index 4172d9ee..c8bda1f5 100644 --- a/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift +++ b/supacode/Features/Repositories/BusinessLogic/WorktreeInfoWatcherManager.swift @@ -16,6 +16,11 @@ final class WorktreeInfoWatcherManager { _ repositoryRootURL: URL, _ onEvent: @escaping @MainActor @Sendable () -> Void ) -> WorktreeRegistryMonitoring? + typealias RemoteConfigMonitorFactory = + @MainActor @Sendable ( + _ repositoryRootURL: URL, + _ onEvent: @escaping @MainActor @Sendable () -> Void + ) -> RemoteConfigMonitoring? private struct HeadWatcher { let headURL: URL @@ -48,6 +53,7 @@ final class WorktreeInfoWatcherManager { private let filesChangedDebounceInterval: Duration private let repositoryWorktreesEventDebounceInterval: Duration + private let remoteConfigEventDebounceInterval: Duration private let lineChangesEventDebounceInterval: Duration private let lineChangesSafetyRefreshInterval: Duration private let pullRequestSelectionRefreshCooldown: Duration @@ -56,14 +62,17 @@ final class WorktreeInfoWatcherManager { private let pullRequestPhaseOffset: RepositoryPhaseOffset private let worktreeFileEventMonitorFactory: WorktreeFileEventMonitorFactory private let worktreeRegistryMonitorFactory: WorktreeRegistryMonitorFactory + private let remoteConfigMonitorFactory: RemoteConfigMonitorFactory private let sleep: @Sendable (Duration) async throws -> Void private var worktrees: [Worktree.ID: Worktree] = [:] private var headWatchers: [Worktree.ID: HeadWatcher] = [:] private var worktreeFileEventMonitors: [Worktree.ID: WorktreeFileEventMonitoring] = [:] private var worktreeRegistryMonitors: [URL: WorktreeRegistryMonitoring] = [:] + private var remoteConfigMonitors: [URL: RemoteConfigMonitoring] = [:] private var branchDebounceTasks: [Worktree.ID: Task] = [:] private var filesDebounceTasks: [Worktree.ID: Task] = [:] private var repositoryWorktreesDebounceTasks: [URL: Task] = [:] + private var repositoryRemoteConfigDebounceTasks: [URL: Task] = [:] private var restartTasks: [Worktree.ID: Task] = [:] private var pullRequestTasks: [URL: RefreshTask] = [:] private var lineChangeSafetyTasks: [Worktree.ID: RefreshTask] = [:] @@ -81,6 +90,7 @@ final class WorktreeInfoWatcherManager { unfocusedInterval: Duration = .seconds(60), filesChangedDebounceInterval: Duration = .seconds(5), repositoryWorktreesEventDebounceInterval: Duration = .seconds(2), + remoteConfigEventDebounceInterval: Duration = .seconds(2), lineChangesEventDebounceInterval: Duration = .seconds(30), lineChangesSafetyRefreshInterval: Duration = .seconds(300), pullRequestSelectionRefreshCooldown: Duration = .seconds(5), @@ -90,11 +100,14 @@ final class WorktreeInfoWatcherManager { WorktreeInfoWatcherManager.defaultWorktreeFileEventMonitorFactory, worktreeRegistryMonitorFactory: @escaping WorktreeRegistryMonitorFactory = WorktreeInfoWatcherManager.defaultWorktreeRegistryMonitorFactory, + remoteConfigMonitorFactory: @escaping RemoteConfigMonitorFactory = + WorktreeInfoWatcherManager.defaultRemoteConfigMonitorFactory, clock: C = ContinuousClock() ) { refreshTiming = RefreshTiming(focused: focusedInterval, unfocused: unfocusedInterval) self.filesChangedDebounceInterval = filesChangedDebounceInterval self.repositoryWorktreesEventDebounceInterval = repositoryWorktreesEventDebounceInterval + self.remoteConfigEventDebounceInterval = remoteConfigEventDebounceInterval self.lineChangesEventDebounceInterval = lineChangesEventDebounceInterval self.lineChangesSafetyRefreshInterval = lineChangesSafetyRefreshInterval self.pullRequestSelectionRefreshCooldown = pullRequestSelectionRefreshCooldown @@ -102,6 +115,7 @@ final class WorktreeInfoWatcherManager { self.pullRequestPhaseOffset = pullRequestPhaseOffset self.worktreeFileEventMonitorFactory = worktreeFileEventMonitorFactory self.worktreeRegistryMonitorFactory = worktreeRegistryMonitorFactory + self.remoteConfigMonitorFactory = remoteConfigMonitorFactory self.sleep = { duration in try await clock.sleep(for: duration) } @@ -163,6 +177,7 @@ final class WorktreeInfoWatcherManager { } let repositoryRoots = Set(worktrees.map(\.repositoryRootURL)) syncWorktreeRegistryMonitors(repositoryRoots: repositoryRoots) + syncRemoteConfigMonitors(repositoryRoots: repositoryRoots) for repositoryRootURL in repositoryRoots { updatePullRequestSchedule(repositoryRootURL: repositoryRootURL, immediate: true) } @@ -373,15 +388,23 @@ final class WorktreeInfoWatcherManager { for monitor in worktreeRegistryMonitors.values { monitor.cancel() } + for monitor in remoteConfigMonitors.values { + monitor.cancel() + } for task in repositoryWorktreesDebounceTasks.values { task.cancel() } + for task in repositoryRemoteConfigDebounceTasks.values { + task.cancel() + } headWatchers.removeAll() worktreeFileEventMonitors.removeAll() worktreeRegistryMonitors.removeAll() + remoteConfigMonitors.removeAll() branchDebounceTasks.removeAll() filesDebounceTasks.removeAll() repositoryWorktreesDebounceTasks.removeAll() + repositoryRemoteConfigDebounceTasks.removeAll() restartTasks.removeAll() pullRequestTasks.removeAll() lineChangeSafetyTasks.removeAll() @@ -592,6 +615,20 @@ final class WorktreeInfoWatcherManager { } } + private func syncRemoteConfigMonitors(repositoryRoots: Set) { + let normalizedRoots = Set(repositoryRoots.map { $0.standardizedFileURL }) + let obsoleteRoots = remoteConfigMonitors.keys.filter { !normalizedRoots.contains($0) } + for repositoryRootURL in obsoleteRoots { + remoteConfigMonitors.removeValue(forKey: repositoryRootURL)?.cancel() + repositoryRemoteConfigDebounceTasks.removeValue(forKey: repositoryRootURL)?.cancel() + } + for repositoryRootURL in normalizedRoots where remoteConfigMonitors[repositoryRootURL] == nil { + remoteConfigMonitors[repositoryRootURL] = remoteConfigMonitorFactory(repositoryRootURL) { [weak self] in + self?.scheduleRepositoryRemoteConfigurationChanged(repositoryRootURL: repositoryRootURL) + } + } + } + private func scheduleRepositoryWorktreesChanged(repositoryRootURL: URL) { let normalizedRootURL = repositoryRootURL.standardizedFileURL repositoryWorktreesDebounceTasks[normalizedRootURL]?.cancel() @@ -611,6 +648,25 @@ final class WorktreeInfoWatcherManager { repositoryWorktreesDebounceTasks[normalizedRootURL] = task } + private func scheduleRepositoryRemoteConfigurationChanged(repositoryRootURL: URL) { + let normalizedRootURL = repositoryRootURL.standardizedFileURL + repositoryRemoteConfigDebounceTasks[normalizedRootURL]?.cancel() + let debounceInterval = remoteConfigEventDebounceInterval + let sleep = self.sleep + let task = Task { [weak self, sleep] in + do { + try await sleep(debounceInterval) + } catch { + return + } + await MainActor.run { + self?.repositoryRemoteConfigDebounceTasks.removeValue(forKey: normalizedRootURL) + self?.emit(.repositoryRemoteConfigurationChanged(repositoryRootURL: normalizedRootURL)) + } + } + repositoryRemoteConfigDebounceTasks[normalizedRootURL] = task + } + private func updateRepeatingTask( _ request: RepeatingTaskRequest, tasks: inout [Worktree.ID: RefreshTask] @@ -686,6 +742,13 @@ final class WorktreeInfoWatcherManager { GitWorktreeRegistryMonitor(repositoryRootURL: repositoryRootURL, onEvent: onEvent) } + private static func defaultRemoteConfigMonitorFactory( + repositoryRootURL: URL, + onEvent: @escaping @MainActor @Sendable () -> Void + ) -> RemoteConfigMonitoring? { + GitRemoteConfigMonitor(repositoryRootURL: repositoryRootURL, onEvent: onEvent) + } + nonisolated private static func stablePhaseOffset(seed: String, interval: Duration) -> Duration { let intervalMilliseconds = durationMilliseconds(interval) guard intervalMilliseconds > 0 else { diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift index 63b04ec6..2586c897 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+CoreReducer.swift @@ -808,6 +808,32 @@ extension RepositoriesFeature { } case .repositoryWorktreesChanged: return .send(.reloadRepositories(animated: true)) + case .repositoryRemoteConfigurationChanged(let repositoryRootURL): + guard + let repository = state.repositories.first(where: { + $0.rootURL.standardizedFileURL == repositoryRootURL.standardizedFileURL + }) + else { + return .none + } + let repositories = IdentifiedArrayOf(uniqueElements: [repository]) + var effects: [Effect] = [] + let worktreeIDs = repository.worktrees.map(\.id) + if repository.capabilities.supportsPullRequests, !worktreeIDs.isEmpty { + effects.append( + .send( + .githubIntegration( + .repositoryPullRequestRefreshRequested( + repositoryRootURL: repository.rootURL, + worktreeIDs: worktreeIDs + ) + )) + ) + } + if let effect = detectCodeHostsEffect(for: repositories, includeUnknown: true) { + effects.append(effect) + } + return effects.isEmpty ? .none : .concatenate(effects) case .repositoryPullRequestRefresh(let repositoryRootURL, let worktreeIDs): return .send( .githubIntegration( diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift index 01fe76f7..11c7afa0 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift @@ -886,6 +886,17 @@ extension RepositoriesFeature { gitClient: gitClient ) guard !remoteInfos.isEmpty else { + let clearedPullRequestsByWorktreeID = Dictionary( + uniqueKeysWithValues: worktreeIDs.map { ($0, Optional.none) } + ) + await send( + .githubIntegration( + .repositoryPullRequestsLoaded( + repositoryID: repositoryID, + pullRequestsByWorktreeID: clearedPullRequestsByWorktreeID + ) + ) + ) await send(.githubIntegration(.repositoryPullRequestRefreshCompleted(repositoryID))) return } diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift index 31d85b71..3f9ad3fe 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+RepositoryLoading.swift @@ -4,7 +4,10 @@ import IdentifiedCollections import SwiftUI extension RepositoriesFeature { - func detectCodeHostsEffect(for repositories: IdentifiedArrayOf) -> Effect? { + func detectCodeHostsEffect( + for repositories: IdentifiedArrayOf, + includeUnknown: Bool = false + ) -> Effect? { let targets = repositories .filter { $0.capabilities.supportsCodeHost } @@ -26,7 +29,7 @@ extension RepositoriesFeature { } // `codeHost(for:)` defaults to `.unknown`, so storing `.unknown` // explicitly is a no-op. Skip the round trip when nothing is known. - let meaningful = detected.filter { $0.value != .unknown } + let meaningful = includeUnknown ? detected : detected.filter { $0.value != .unknown } guard !meaningful.isEmpty else { return } await send(.codeHostsDetected(meaningful)) } diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 640f6b49..ed3ab6de 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -346,6 +346,36 @@ struct RepositoriesFeatureTests { await store.receive(\.delegate.repositoriesChanged) } + @Test func repositoryRemoteConfigurationChangedRefreshesPullRequestsAndCodeHost() async { + 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 = .unavailable + initialState.codeHostByRepositoryID[repository.id] = .github + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.gitClient.repositoryWebURL = { _ in nil } + } + + await store.send( + .worktreeInfoEvent( + .repositoryRemoteConfigurationChanged(repositoryRootURL: repository.rootURL) + ) + ) + await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { + $0.pendingPullRequestRefreshByRepositoryID[repository.id] = RepositoriesFeature.PendingPullRequestRefresh( + repositoryRootURL: repository.rootURL, + worktreeIDs: [mainWorktree.id, featureWorktree.id] + ) + } + await store.receive(\.codeHostsDetected) { + $0.codeHostByRepositoryID[repository.id] = .unknown + } + } + @Test func repositoriesLoadedEmitsChangedDelegateWhenTransitioningFromRestoring() async { let worktree = makeWorktree(id: "/tmp/repo/main", name: "main") let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) @@ -4411,10 +4441,10 @@ struct RepositoriesFeatureTests { let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { - $0.gitClient.remoteInfo = { _ in nil } - $0.githubCLI.batchPullRequests = { _, _, _, _, _ in - Issue.record("batchPullRequests should not run when remoteInfo is unavailable") - return [:] + $0.gitClient.githubRemoteInfos = { _ in [] } + $0.githubCLI.resolveRemoteInfo = { _ in nil } + $0.pullRequestRefreshCoordinator.enqueue = { _ in + Issue.record("Coordinator should not enqueue when GitHub remotes are unavailable") } } @@ -4429,6 +4459,7 @@ struct RepositoriesFeatureTests { await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.inFlightPullRequestRefreshRepositoryIDs = [repository.id] } + await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { $0.inFlightPullRequestRefreshRepositoryIDs = [] } @@ -4539,10 +4570,10 @@ struct RepositoriesFeatureTests { let store = TestStore(initialState: initialState) { RepositoriesFeature() } withDependencies: { - $0.gitClient.remoteInfo = { _ in nil } - $0.githubCLI.batchPullRequests = { _, _, _, _, _ in - Issue.record("batchPullRequests should not run when remoteInfo is unavailable") - return [:] + $0.gitClient.githubRemoteInfos = { _ in [] } + $0.githubCLI.resolveRemoteInfo = { _ in nil } + $0.pullRequestRefreshCoordinator.enqueue = { _ in + Issue.record("Coordinator should not enqueue when GitHub remotes are unavailable") } } @@ -4554,6 +4585,7 @@ struct RepositoriesFeatureTests { await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.inFlightPullRequestRefreshRepositoryIDs = [repository.id] } + await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { $0.inFlightPullRequestRefreshRepositoryIDs = [] } @@ -4637,10 +4669,10 @@ struct RepositoriesFeatureTests { let store = TestStore(initialState: state) { RepositoriesFeature() } withDependencies: { - $0.gitClient.remoteInfo = { _ in nil } - $0.githubCLI.batchPullRequests = { _, _, _, _, _ in - Issue.record("batchPullRequests should not run when remoteInfo is unavailable") - return [:] + $0.gitClient.githubRemoteInfos = { _ in [] } + $0.githubCLI.resolveRemoteInfo = { _ in nil } + $0.pullRequestRefreshCoordinator.enqueue = { _ in + Issue.record("Coordinator should not enqueue when GitHub remotes are unavailable") } } @@ -4654,6 +4686,7 @@ struct RepositoriesFeatureTests { await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) { $0.inFlightPullRequestRefreshRepositoryIDs = [repository.id] } + await store.receive(\.githubIntegration.repositoryPullRequestsLoaded) await store.receive(\.githubIntegration.repositoryPullRequestRefreshCompleted) { $0.inFlightPullRequestRefreshRepositoryIDs = [] } diff --git a/supacodeTests/WorktreeInfoWatcherManagerTests.swift b/supacodeTests/WorktreeInfoWatcherManagerTests.swift index dcda4906..02efdd3a 100644 --- a/supacodeTests/WorktreeInfoWatcherManagerTests.swift +++ b/supacodeTests/WorktreeInfoWatcherManagerTests.swift @@ -242,6 +242,40 @@ struct WorktreeInfoWatcherManagerTests { try FileManager.default.removeItem(at: tempRepository.tempRoot) } + @Test func repositoryRemoteConfigEventEmitsChangedEventAfterDebounce() async throws { + let clock = TestClock() + let tempRepository = try makeTempRepository(worktreeNames: ["sparrow"]) + let remoteConfigMonitorStore = TestRemoteConfigMonitorStore() + let manager = WorktreeInfoWatcherManager( + focusedInterval: .seconds(3_600), + unfocusedInterval: .seconds(3_600), + remoteConfigEventDebounceInterval: .milliseconds(80), + lineChangePhaseOffset: { _, _ in .zero }, + pullRequestPhaseOffset: { _, _ in .zero }, + remoteConfigMonitorFactory: remoteConfigMonitorStore.makeMonitor, + clock: clock + ) + let (collector, task) = startCollecting(manager.eventStream()) + + manager.handleCommand(.setWorktrees(tempRepository.worktrees)) + await drainAsyncEvents(120) + #expect(await collector.repositoryRemoteConfigurationChangedCount(repositoryRootURL: tempRepository.tempRoot) == 0) + + let monitor = try #require(remoteConfigMonitorStore.monitor(for: tempRepository.tempRoot)) + monitor.emit() + await clock.advance(by: .milliseconds(79)) + await drainAsyncEvents(120) + #expect(await collector.repositoryRemoteConfigurationChangedCount(repositoryRootURL: tempRepository.tempRoot) == 0) + + await clock.advance(by: .milliseconds(1)) + await drainAsyncEvents(120) + #expect(await collector.repositoryRemoteConfigurationChangedCount(repositoryRootURL: tempRepository.tempRoot) == 1) + + manager.handleCommand(.stop) + await task.value + try FileManager.default.removeItem(at: tempRepository.tempRoot) + } + @Test func removedRepositoryCancelsRegistryMonitor() async throws { let firstRepository = try makeTempRepository(worktreeNames: ["sparrow"]) let secondRepository = try makeTempRepository(worktreeNames: ["swift"]) @@ -445,6 +479,17 @@ actor EventCollector { } } } + + func repositoryRemoteConfigurationChangedCount(repositoryRootURL: URL) -> Int { + let expectedURL = repositoryRootURL.standardizedFileURL + return events.reduce(into: 0) { result, event in + if case .repositoryRemoteConfigurationChanged(let rootURL) = event, + rootURL.standardizedFileURL == expectedURL + { + result += 1 + } + } + } } private struct TempWorktree { @@ -590,3 +635,39 @@ private final class TestWorktreeRegistryMonitor: WorktreeRegistryMonitoring { isCanceled = true } } + +@MainActor +private final class TestRemoteConfigMonitorStore { + private var monitors: [URL: TestRemoteConfigMonitor] = [:] + + func makeMonitor( + repositoryRootURL: URL, + onEvent: @escaping @MainActor @Sendable () -> Void + ) -> RemoteConfigMonitoring? { + let monitor = TestRemoteConfigMonitor(onEvent: onEvent) + monitors[repositoryRootURL.standardizedFileURL] = monitor + return monitor + } + + func monitor(for repositoryRootURL: URL) -> TestRemoteConfigMonitor? { + monitors[repositoryRootURL.standardizedFileURL] + } +} + +@MainActor +private final class TestRemoteConfigMonitor: RemoteConfigMonitoring { + private let onEvent: @MainActor @Sendable () -> Void + private(set) var isCanceled = false + + init(onEvent: @escaping @MainActor @Sendable () -> Void) { + self.onEvent = onEvent + } + + func emit() { + onEvent() + } + + func cancel() { + isCanceled = true + } +} -- 2.51.2