diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 5ef87b7718c..6d01c6a4080 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -36,6 +36,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, private static let olderThreadPageUserTurnLimit = 20 private static let projectFaviconRefreshInterval: TimeInterval = 15 * 60 private static let projectFaviconFallbackMarker = "project-favicon-missing" + private static let sourceControlStatusStreamTimeoutSeconds: TimeInterval = 30 private let runtime: EnvironmentRuntime let t3ConnectController: T3ConnectController @@ -2087,6 +2088,85 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, ) } + func sourceControlStatuses( + threadID: String + ) async throws -> AsyncThrowingStream { + let route = try threadRoute(for: threadID) + let context = try workspaceContext(route: route) + let client = route.client + let environmentID = route.environmentID + let generation = environmentGeneration + let events = await client.vcsStatusEvents(cwd: context.cwd) + // Each element is a whole status, so only the newest one is ever useful. + let (statuses, continuation) = AsyncThrowingStream.makeStream( + of: FeatureSourceControlStatus.self, + bufferingPolicy: .bufferingNewest(1) + ) + let task = Task { [weak self] in + // The server publishes `remoteUpdated` only when the remote + // fingerprint changes, and backs off silently when a remote refresh + // fails, so the remote half may never arrive. Bound the wait rather + // than leaving the screen loading forever, and say so instead of + // leaving the status quietly half-known. + let deadline = Task { + try? await Task.sleep( + for: .seconds(Self.sourceControlStatusStreamTimeoutSeconds) + ) + guard !Task.isCancelled else { return } + continuation.finish(throwing: NativeFeatureClientError.remoteStatusUnavailable) + } + defer { deadline.cancel() } + + var accumulator = NativeSourceControlStatusAccumulator() + do { + for try await event in events { + // Superseded by cancellation or an environment switch: the + // stream is over, but nothing about it was malformed, so it + // must not run the end-of-stream validation below. + guard !Task.isCancelled else { + continuation.finish() + return + } + guard let self else { + continuation.finish() + return + } + guard self.isKnownClient( + client, + environmentID: environmentID, + generation: generation + ) else { + continuation.finish() + return + } + if let status = accumulator.consume(event) { + continuation.yield(status) + } + if accumulator.isComplete { + continuation.finish() + return + } + } + if Task.isCancelled { + continuation.finish() + } else { + try accumulator.validateEnd() + continuation.finish() + } + } catch is CancellationError { + continuation.finish() + } catch { + if Task.isCancelled { + continuation.finish() + } else { + continuation.finish(throwing: error) + } + } + } + continuation.onTermination = { @Sendable _ in task.cancel() } + return statuses + } + func sourceControlStatusEvents(threadID: String) -> AsyncStream { let stream = AsyncStream.makeStream( bufferingPolicy: .bufferingNewest(1) @@ -2142,8 +2222,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, monitorID: UUID ) async { let events = await client.vcsStatusEvents(cwd: key.workingDirectory) - var local: VCSLocalStatus? - var remote: VCSRemoteStatus? + var accumulator = NativeSourceControlStatusAccumulator() do { for try await event in events { @@ -2152,24 +2231,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, break } - switch event { - case let .snapshot(nextLocal, nextRemote): - local = nextLocal - remote = nextRemote - case let .localUpdated(nextLocal): - if local?.refName != nextLocal.refName { - remote = nil - } - local = nextLocal - case let .remoteUpdated(nextRemote): - remote = nextRemote - } - - guard let local else { continue } - let status = NativeWorkspaceMapper.sourceControl( - local: local, - remote: remote - ) + guard let status = accumulator.consume(event) else { continue } guard let monitor = sourceControlMonitors[key], monitor.id == monitorID, monitor.latestStatus != status else { @@ -6372,6 +6434,7 @@ private enum NativeFeatureClientError: LocalizedError { case currentDeviceUnknown case missingScope(String) case tooManyAttachments + case remoteStatusUnavailable var errorDescription: String? { switch self { @@ -6388,6 +6451,8 @@ private enum NativeFeatureClientError: LocalizedError { case .currentDeviceUnknown: "This installation has not registered for device access yet." case .missingScope: "This connection does not have permission to manage devices." case .tooManyAttachments: "You can attach up to 8 images per message." + case .remoteStatusUnavailable: + "Couldn't check the remote status. Try reloading." } } } diff --git a/apps/swift-ios/App/NativeWorkspaceMapper.swift b/apps/swift-ios/App/NativeWorkspaceMapper.swift index 966fc582fb4..8b059aeddc1 100644 --- a/apps/swift-ios/App/NativeWorkspaceMapper.swift +++ b/apps/swift-ios/App/NativeWorkspaceMapper.swift @@ -97,13 +97,15 @@ enum NativeWorkspaceMapper { files: [VCSWorkingTreeFile], aheadCount: Int, behindCount: Int, - pullRequest: VCSChangeRequest? + pullRequest: VCSChangeRequest?, + isRemoteKnown: Bool = true ) -> FeatureSourceControlStatus { FeatureSourceControlStatus( isRepository: isRepository, branch: branch, aheadCount: aheadCount, behindCount: behindCount, + isRemoteKnown: isRemoteKnown, files: files.map { FeatureSourceControlFile( path: $0.path, @@ -122,6 +124,24 @@ enum NativeWorkspaceMapper { ) } + /// The streamed counterpart, where the remote half arrives separately and + /// may still be pending. + static func sourceControl( + local: VCSLocalStatus, + remote: VCSRemoteStatus?, + isRemoteKnown: Bool + ) -> FeatureSourceControlStatus { + sourceControl( + isRepository: local.isRepo, + branch: local.refName, + files: local.workingTree.files, + aheadCount: remote?.aheadCount ?? 0, + behindCount: remote?.behindCount ?? 0, + pullRequest: remote?.pr, + isRemoteKnown: isRemoteKnown + ) + } + static func gitAction(_ action: FeatureSourceControlAction) -> GitStackedAction { switch action { case .commit: .commit @@ -374,3 +394,54 @@ enum NativeWorkspaceMapper { ) } } + +/// Folds `vcs.subscribeStatus` events into successive UI statuses. Modelled on +/// `applyGitStatusStreamEvent` in packages/shared — a snapshot replaces both +/// halves, while the last known remote half is carried across same-branch local +/// updates and discarded when the branch changes. The explicit pending state is +/// needed because this client presents partial status. +struct NativeSourceControlStatusAccumulator { + private var local: VCSLocalStatus? + private var remote: VCSRemoteStatus? + /// Tracked separately from `remote` because the remote half can legitimately + /// resolve to nil — "known to be absent" is not the same as "still pending". + private var isRemoteResolved = false + private(set) var isComplete = false + + mutating func consume(_ event: VCSStatusEvent) -> FeatureSourceControlStatus? { + switch event { + case let .snapshot(nextLocal, nextRemote): + local = nextLocal + remote = nextRemote + isRemoteResolved = nextRemote != nil + case let .localUpdated(nextLocal): + if let previousLocal = local, previousLocal.refName != nextLocal.refName { + remote = nil + isRemoteResolved = false + } + local = nextLocal + case let .remoteUpdated(nextRemote): + remote = nextRemote + isRemoteResolved = true + } + + guard let local else { return nil } + // A workspace with no repository or no primary remote never receives a + // remote half, so nothing is pending in those cases. + let isRemoteKnown = isRemoteResolved || !local.isRepo || !local.hasPrimaryRemote + isComplete = isRemoteKnown + return NativeWorkspaceMapper.sourceControl( + local: local, + remote: remote, + isRemoteKnown: isRemoteKnown + ) + } + + func validateEnd() throws { + guard isComplete else { + throw RPCError.protocolViolation( + "The source-control status stream ended before completion." + ) + } + } +} diff --git a/apps/swift-ios/Features/Shared/FeatureClient.swift b/apps/swift-ios/Features/Shared/FeatureClient.swift index 63e020073ab..58a1e1638cb 100644 --- a/apps/swift-ios/Features/Shared/FeatureClient.swift +++ b/apps/swift-ios/Features/Shared/FeatureClient.swift @@ -170,6 +170,9 @@ public protocol FeatureClient: AnyObject { ) async throws -> FeatureReviewFileContents? func sourceControlStatus(threadID: String) async throws -> FeatureSourceControlStatus + func sourceControlStatuses( + threadID: String + ) async throws -> AsyncThrowingStream func sourceControlStatusEvents(threadID: String) -> AsyncStream func performSourceControlAction( threadID: String, @@ -460,6 +463,18 @@ public extension FeatureClient { throw FeatureCapabilityUnavailable("Source control") } + func sourceControlStatuses( + threadID: String + ) async throws -> AsyncThrowingStream { + let status = try await sourceControlStatus(threadID: threadID) + let (stream, continuation) = AsyncThrowingStream.makeStream( + of: FeatureSourceControlStatus.self + ) + continuation.yield(status) + continuation.finish() + return stream + } + func sourceControlStatusEvents(threadID: String) -> AsyncStream { AsyncStream { $0.finish() } } diff --git a/apps/swift-ios/Features/Shared/FeatureToolModels.swift b/apps/swift-ios/Features/Shared/FeatureToolModels.swift index a5390d8a742..b8d39a68b6d 100644 --- a/apps/swift-ios/Features/Shared/FeatureToolModels.swift +++ b/apps/swift-ios/Features/Shared/FeatureToolModels.swift @@ -847,6 +847,9 @@ public struct FeatureSourceControlStatus: Sendable, Equatable, Codable { public var upstream: String? public var aheadCount: Int public var behindCount: Int + /// `false` while the remote half of a streamed status is still pending, so + /// ahead/behind/pull-request fields are "not yet known" rather than zero. + public var isRemoteKnown: Bool public var files: [FeatureSourceControlFile] public var pullRequest: FeaturePullRequest? public var isBusy: Bool @@ -857,6 +860,7 @@ public struct FeatureSourceControlStatus: Sendable, Equatable, Codable { upstream: String? = nil, aheadCount: Int = 0, behindCount: Int = 0, + isRemoteKnown: Bool = true, files: [FeatureSourceControlFile] = [], pullRequest: FeaturePullRequest? = nil, isBusy: Bool = false @@ -866,6 +870,7 @@ public struct FeatureSourceControlStatus: Sendable, Equatable, Codable { self.upstream = upstream self.aheadCount = aheadCount self.behindCount = behindCount + self.isRemoteKnown = isRemoteKnown self.files = files self.pullRequest = pullRequest self.isBusy = isBusy @@ -877,13 +882,15 @@ public struct FeatureSourceControlStatus: Sendable, Equatable, Codable { if !files.isEmpty { actions.append(.commit) actions.append(.commitAndPush) - if pullRequest == nil { + if isRemoteKnown, pullRequest == nil { actions.append(.commitPushAndCreatePullRequest) } } if aheadCount > 0 { actions.append(.push) } if behindCount > 0 { actions.append(.pull) } - if pullRequest == nil { actions.append(.createPullRequest) } + // Withheld until the remote half lands: offering it against an unknown + // remote can propose a second PR for a branch that already has one. + if isRemoteKnown, pullRequest == nil { actions.append(.createPullRequest) } return actions } } diff --git a/apps/swift-ios/Features/SourceControl/FeatureSourceControlView.swift b/apps/swift-ios/Features/SourceControl/FeatureSourceControlView.swift index 1182485c2b0..205dfd77843 100644 --- a/apps/swift-ios/Features/SourceControl/FeatureSourceControlView.swift +++ b/apps/swift-ios/Features/SourceControl/FeatureSourceControlView.swift @@ -1,5 +1,20 @@ import SwiftUI +@MainActor +func runFeatureSourceControlAction( + setRunning: (Bool) -> Void, + operation: () async throws -> Value +) async -> Result { + setRunning(true) + defer { setRunning(false) } + + do { + return .success(try await operation()) + } catch { + return .failure(error) + } +} + public struct FeatureSourceControlView: View { let client: any FeatureClient let threadID: String @@ -8,8 +23,13 @@ public struct FeatureSourceControlView: View { @State private var isLoading = true @State private var isRunningAction = false @State private var errorMessage: String? + @State private var actionErrorMessage: String? @State private var commitMessage = "" @State private var pendingCommitAction: FeatureSourceControlAction? + /// Owns the loading indicator; only a newer load supersedes it. + @State private var loadGeneration = 0 + /// Invalidates status writes; a running action supersedes them too. + @State private var statusGeneration = 0 public init(client: any FeatureClient, threadID: String) { self.client = client @@ -41,9 +61,17 @@ public struct FeatureSourceControlView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { - Button { Task { await load() } } label: { Image(systemName: "arrow.clockwise") } - .disabled(isLoading || isRunningAction) - .accessibilityLabel("Reload source control") + Button { Task { await reload() } } label: { + // Never disabled on a populated screen, so the spinner is + // the only signal that a reload is under way. + if isLoading { + ProgressView() + } else { + Image(systemName: "arrow.clockwise") + } + } + .disabled(isRunningAction) + .accessibilityLabel("Reload source control") } } .alert("Commit changes", isPresented: Binding( @@ -61,30 +89,52 @@ public struct FeatureSourceControlView: View { .disabled(commitMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } .alert("Source control failed", isPresented: Binding( - get: { status != nil && errorMessage != nil }, - set: { if !$0 { errorMessage = nil } } + get: { actionErrorMessage != nil }, + set: { if !$0 { actionErrorMessage = nil } } )) { - Button("OK") { errorMessage = nil } + Button("OK") { actionErrorMessage = nil } } message: { - Text(errorMessage ?? "The source control action could not be completed.") + Text(actionErrorMessage ?? "The source control action could not be completed.") } .task { await load() } } private func statusList(_ status: FeatureSourceControlStatus) -> some View { List { + // Once a status is on screen the unavailable-state view is + // unreachable, so a later failure needs its own inline surface. + if let errorMessage { + Section { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(T3Typography.supporting) + .foregroundStyle(.orange) + } + } + Section("Repository") { LabeledContent("Branch", value: status.branch ?? "Detached HEAD") if let upstream = status.upstream { LabeledContent("Upstream", value: upstream) } - HStack { - Label("\(status.aheadCount) ahead", systemImage: "arrow.up") - Spacer() - Label("\(status.behindCount) behind", systemImage: "arrow.down") + if status.isRemoteKnown { + HStack { + Label("\(status.aheadCount) ahead", systemImage: "arrow.up") + Spacer() + Label("\(status.behindCount) behind", systemImage: "arrow.down") + } + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) + } else { + // Only claim to be checking while something actually is. + Label( + isLoading ? "Checking remote…" : "Remote status unavailable", + systemImage: isLoading + ? "arrow.triangle.2.circlepath" + : "exclamationmark.triangle" + ) + .font(T3Typography.supporting) + .foregroundStyle(T3Colors.textSecondary) } - .font(T3Typography.supporting) - .foregroundStyle(T3Colors.textSecondary) if let pullRequest = status.pullRequest { if let url = pullRequest.url { Link(destination: url) { @@ -139,7 +189,7 @@ public struct FeatureSourceControlView: View { } .listStyle(.insetGrouped) .scrollContentBackground(.hidden) - .refreshable { await load() } + .refreshable { await reload() } .overlay { if isRunningAction { ProgressView() @@ -158,29 +208,104 @@ public struct FeatureSourceControlView: View { } } + /// Streams the cached status first so the screen fills immediately. private func load() async { + await load(force: false) + } + + /// Explicit refresh affordances bypass the server-side status cache: the + /// streaming path is cache-first, so without this a reload would only + /// replay what the server already had. + private func reload() async { + guard !isRunningAction else { return } + await load(force: true) + } + + /// Action failures use `actionErrorMessage`; a successful recovery should + /// clear any stale load error from the inline status banner. + private func load(force: Bool, clearsError: Bool = true) async { + loadGeneration += 1 + statusGeneration += 1 + let loadID = loadGeneration + let statusID = statusGeneration isLoading = true - defer { isLoading = false } + // Only a newer *load* takes over the indicator. An action supersedes + // this load's writes without taking ownership of the indicator, so + // this must still be the one to clear it — at the cost of a superseded + // stream holding the toolbar spinner until its bound expires. + defer { if loadID == loadGeneration { isLoading = false } } do { - status = try await client.sourceControlStatus(threadID: threadID) - errorMessage = nil + if force { + let refreshed = try await client.sourceControlStatus(threadID: threadID) + guard statusID == statusGeneration else { return } + status = refreshed + if clearsError { errorMessage = nil } + } else { + let statuses = try await client.sourceControlStatuses(threadID: threadID) + for try await nextStatus in statuses { + guard statusID == statusGeneration else { return } + status = nextStatus + if clearsError { errorMessage = nil } + } + } + } catch is CancellationError { + return } catch { - errorMessage = error.localizedDescription + guard statusID == statusGeneration else { return } + if clearsError { errorMessage = error.localizedDescription } + guard !force, status?.isRemoteKnown == false else { return } + + // The bounded presentation stream reports that remote data is + // unavailable after 30 seconds, but the shared monitor keeps + // listening. Stop the spinner and accept a later remote result so + // a slow `gh` lookup does not leave this screen stale forever. + if loadID == loadGeneration { isLoading = false } + for await recoveredStatus in client.sourceControlStatusEvents(threadID: threadID) { + guard statusID == statusGeneration else { return } + status = recoveredStatus + if recoveredStatus.isRemoteKnown { + errorMessage = nil + return + } + } } } private func perform(_ action: FeatureSourceControlAction, message: String?) async { - isRunningAction = true - defer { isRunningAction = false } - do { - status = try await client.performSourceControlAction( + // Supersede any open stream. Its accumulator still holds the local half + // from before this action, so a late event would fold that stale half + // into a status that overwrites this action's result — and a late + // stream error would mask this action's failure message. + loadGeneration += 1 + statusGeneration += 1 + isLoading = false + let statusID = statusGeneration + let actionResult = await runFeatureSourceControlAction( + setRunning: { isRunningAction = $0 } + ) { + try await client.performSourceControlAction( threadID: threadID, action: action, message: message?.trimmingCharacters(in: .whitespacesAndNewlines) ) + } + + switch actionResult { + case let .success(result): + // Guarded like a load's: pull-to-refresh is not gated on a running + // action, so a refresh started after this one must win. + guard statusID == statusGeneration else { return } + status = result errorMessage = nil - } catch { - errorMessage = error.localizedDescription + actionErrorMessage = nil + case let .failure(error): + guard statusID == statusGeneration else { return } + actionErrorMessage = error.localizedDescription + // This action superseded an in-flight stream, so the remote half it + // was still waiting on would otherwise never arrive: the screen + // would keep withholding the pull-request actions until the user + // reloaded by hand. Recover, without losing the reason above. + await load(force: false) } } } diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureToolStateTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureToolStateTests.swift index 871f08d926b..7d045059852 100644 --- a/apps/swift-ios/Tests/FeatureTests/FeatureToolStateTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/FeatureToolStateTests.swift @@ -3,6 +3,305 @@ import Testing @Suite("Thread tool state") struct FeatureToolStateTests { + @MainActor + @Test("A failed source-control action stops progress before recovery") + func failedSourceControlActionStopsProgressBeforeRecovery() async { + var phases: [String] = [] + + let result: Result = await runFeatureSourceControlAction( + setRunning: { phases.append($0 ? "running" : "stopped") } + ) { + throw FeatureCapabilityUnavailable("Source control") + } + + if case .failure = result { + phases.append("recovery") + } + + #expect(phases == ["running", "stopped", "recovery"]) + } + + private static func vcsLocal( + isRepo: Bool = true, + hasPrimaryRemote: Bool = true, + refName: String? = "feature/cached", + files: [String] = [] + ) -> VCSLocalStatus { + VCSLocalStatus( + isRepo: isRepo, + sourceControlProvider: nil, + hasPrimaryRemote: hasPrimaryRemote, + isDefaultRef: false, + refName: refName, + hasWorkingTreeChanges: files.isEmpty == false, + workingTree: VCSWorkingTree( + files: files.map { + VCSWorkingTreeFile(path: $0, insertions: 1, deletions: 0) + }, + insertions: files.count, + deletions: 0 + ) + ) + } + + private static func vcsRemote( + aheadCount: Int, + behindCount: Int = 0, + pullRequest: VCSChangeRequest? = nil + ) -> VCSRemoteStatus { + VCSRemoteStatus( + hasUpstream: true, + aheadCount: aheadCount, + behindCount: behindCount, + aheadOfDefaultCount: nil, + pr: pullRequest + ) + } + + @Test("Cached local status is available before remote status") + func cachedLocalStatusArrivesFirst() throws { + var accumulator = NativeSourceControlStatusAccumulator() + + let consumedLocal = accumulator.consume( + .snapshot( + local: Self.vcsLocal(files: ["App/NativeFeatureClient.swift"]), + remote: nil + ) + ) + let localOnly = try #require(consumedLocal) + + #expect(localOnly.branch == "feature/cached") + #expect(localOnly.files.map(\.path) == ["App/NativeFeatureClient.swift"]) + #expect(localOnly.aheadCount == 0) + #expect(localOnly.pullRequest == nil) + #expect(accumulator.isComplete == false) + // Ahead/behind are unknown rather than zero, so remote-dependent + // actions stay withheld until the remote half lands. + #expect(localOnly.isRemoteKnown == false) + #expect(localOnly.availableActions.contains(.createPullRequest) == false) + #expect(localOnly.availableActions.contains(.commitPushAndCreatePullRequest) == false) + #expect(localOnly.availableActions.contains(.commit)) + } + + @Test("Remote status combines with the latest local status") + func remoteStatusUsesLatestLocalState() throws { + var accumulator = NativeSourceControlStatusAccumulator() + _ = accumulator.consume( + .snapshot(local: Self.vcsLocal(refName: "feature/old"), remote: nil) + ) + _ = accumulator.consume( + .localUpdated(Self.vcsLocal(refName: "feature/new", files: ["new.swift"])) + ) + let pullRequest = VCSChangeRequest( + number: 42, + title: "Cached status", + url: "https://example.com/pull/42", + baseRef: "main", + headRef: "feature/new", + state: "OPEN" + ) + + let consumedRemote = accumulator.consume( + .remoteUpdated( + Self.vcsRemote( + aheadCount: 2, + behindCount: 1, + pullRequest: pullRequest + ) + ) + ) + let combined = try #require(consumedRemote) + + #expect(combined.branch == "feature/new") + #expect(combined.files.map(\.path) == ["new.swift"]) + #expect(combined.aheadCount == 2) + #expect(combined.behindCount == 1) + #expect(combined.pullRequest?.number == 42) + #expect(combined.isRemoteKnown) + #expect(accumulator.isComplete) + } + + @Test("Remote status is retained across a later local-only update") + func localUpdateKeepsKnownRemoteStatus() throws { + var accumulator = NativeSourceControlStatusAccumulator() + _ = accumulator.consume( + .snapshot( + local: Self.vcsLocal(refName: "feature/one"), + remote: Self.vcsRemote(aheadCount: 4, behindCount: 2) + ) + ) + #expect(accumulator.isComplete) + + let consumed = accumulator.consume( + .localUpdated(Self.vcsLocal(refName: "feature/one", files: ["later.swift"])) + ) + let updated = try #require(consumed) + + #expect(updated.files.map(\.path) == ["later.swift"]) + #expect(updated.aheadCount == 4) + #expect(updated.behindCount == 2) + #expect(updated.isRemoteKnown) + // Completion latches: a local-only update must not reopen the stream. + #expect(accumulator.isComplete) + } + + @Test("Remote-before-local ordering retains the remote status") + func remoteBeforeLocalIsRetained() throws { + var accumulator = NativeSourceControlStatusAccumulator() + + let remoteBeforeLocal = accumulator.consume( + .remoteUpdated(Self.vcsRemote(aheadCount: 9)) + ) + #expect(remoteBeforeLocal == nil) + #expect(accumulator.isComplete == false) + + let consumedLocal = accumulator.consume( + .localUpdated(Self.vcsLocal(refName: "feature/local")) + ) + let withRetainedRemote = try #require(consumedLocal) + let consumedRemote = accumulator.consume( + .remoteUpdated(Self.vcsRemote(aheadCount: 3)) + ) + let combined = try #require(consumedRemote) + + #expect(withRetainedRemote.aheadCount == 9) + #expect(withRetainedRemote.isRemoteKnown) + #expect(combined.branch == "feature/local") + #expect(combined.aheadCount == 3) + #expect(accumulator.isComplete) + } + + @Test( + "Terminal local states do not wait for remote status", + arguments: [ + Self.vcsLocal(isRepo: false, hasPrimaryRemote: false, refName: nil), + Self.vcsLocal(hasPrimaryRemote: false, refName: "local-only"), + ] + ) + func terminalLocalStatesAreExplicit(local: VCSLocalStatus) throws { + var accumulator = NativeSourceControlStatusAccumulator() + + let consumed = accumulator.consume(.snapshot(local: local, remote: nil)) + let status = try #require(consumed) + + #expect(status.isRepository == local.isRepo) + #expect(status.branch == local.refName) + #expect(status.pullRequest == nil) + // No remote will ever arrive, so nothing is left pending. + #expect(status.isRemoteKnown) + #expect(accumulator.isComplete) + } + + @Test("A stream ending after only a cached local status is a protocol error") + func prematureStreamEndIsExplicit() throws { + var accumulator = NativeSourceControlStatusAccumulator() + _ = accumulator.consume( + .snapshot(local: Self.vcsLocal(files: ["pending.swift"]), remote: nil) + ) + #expect(accumulator.isComplete == false) + + #expect(throws: RPCError.self) { try accumulator.validateEnd() } + } + + @Test("An absent remote half resolves the status instead of leaving it pending") + func nilRemotePayloadResolvesTheRemoteHalf() throws { + var accumulator = NativeSourceControlStatusAccumulator() + _ = accumulator.consume(.snapshot(local: Self.vcsLocal(), remote: nil)) + #expect(accumulator.isComplete == false) + + let consumed = accumulator.consume(.remoteUpdated(nil)) + let resolved = try #require(consumed) + + // "Known to be absent" is not "still pending": the stream is finished + // and the screen must stop claiming it is checking. + #expect(resolved.isRemoteKnown) + #expect(resolved.aheadCount == 0) + #expect(resolved.behindCount == 0) + #expect(resolved.pullRequest == nil) + #expect(accumulator.isComplete) + } + + @Test("A later snapshot replaces the remote half rather than merging into it") + func snapshotReplacesKnownRemoteStatus() throws { + var accumulator = NativeSourceControlStatusAccumulator() + _ = accumulator.consume( + .snapshot( + local: Self.vcsLocal(refName: "feature/one"), + remote: Self.vcsRemote(aheadCount: 7) + ) + ) + + // Resubscribe after a reconnect: the server prepends a fresh snapshot + // carrying whatever its cache holds, so a stale ahead count must not + // survive and must not be reported as known. + let consumed = accumulator.consume( + .snapshot(local: Self.vcsLocal(refName: "feature/one"), remote: nil) + ) + let replaced = try #require(consumed) + + #expect(replaced.aheadCount == 0) + #expect(replaced.isRemoteKnown == false) + #expect(accumulator.isComplete == false) + } + + @Test("A fresh snapshot can return a reused monitor to pending") + func completionTracksTheCurrentSequence() { + var accumulator = NativeSourceControlStatusAccumulator() + let events: [VCSStatusEvent] = [ + .snapshot(local: Self.vcsLocal(refName: "feature/seq"), remote: nil), + .localUpdated(Self.vcsLocal(refName: "feature/seq", files: ["a.swift"])), + .remoteUpdated(Self.vcsRemote(aheadCount: 1)), + .localUpdated(Self.vcsLocal(refName: "feature/seq", files: ["a.swift", "b.swift"])), + .remoteUpdated(nil), + // A reused monitor can begin a fresh cached-local-first sequence. + .snapshot(local: Self.vcsLocal(refName: "feature/seq"), remote: nil), + .localUpdated(Self.vcsLocal(refName: "feature/seq")), + ] + + var completionStates: [Bool] = [] + for event in events { + _ = accumulator.consume(event) + completionStates.append(accumulator.isComplete) + } + + #expect(completionStates == [false, false, true, true, true, false, false]) + } + + @Test("Changing branches discards the prior branch remote status") + func branchChangeReturnsRemoteStatusToPending() throws { + var accumulator = NativeSourceControlStatusAccumulator() + _ = accumulator.consume( + .snapshot( + local: Self.vcsLocal(refName: "feature/one"), + remote: Self.vcsRemote(aheadCount: 4) + ) + ) + + let consumed = accumulator.consume( + .localUpdated(Self.vcsLocal(refName: "feature/two")) + ) + let changedBranch = try #require(consumed) + + #expect(changedBranch.branch == "feature/two") + #expect(changedBranch.aheadCount == 0) + #expect(changedBranch.pullRequest == nil) + #expect(changedBranch.isRemoteKnown == false) + #expect(accumulator.isComplete == false) + } + + @Test("A completed stream ends without error") + func completedStreamEndIsAccepted() throws { + var accumulator = NativeSourceControlStatusAccumulator() + _ = accumulator.consume( + .snapshot( + local: Self.vcsLocal(), + remote: Self.vcsRemote(aheadCount: 1) + ) + ) + + try accumulator.validateEnd() + } + @Test func fileFilteringKeepsDirectoriesFirstAndHonorsHiddenFiles() { let entries = [