From f2bef42a6e6ce9e55306324a08c806b10fcb0482 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Tue, 18 Aug 2026 22:10:13 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E6=97=B6=E5=BA=8F?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E9=94=99=E8=AF=AF=E2=80=94=E2=80=94selectedS?= =?UTF-8?q?idebar=E6=AF=8F=E6=AC=A1=E5=88=87=E6=8D=A2=E9=83=BD=E4=BC=9A?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E4=B8=8D=E5=8F=AF=E5=8F=96=E6=B6=88=E7=9A=84?= =?UTF-8?q?=E5=88=B7=E6=96=B0=20Task=EF=BC=8CPR=20=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E6=B2=A1=E6=9C=89=E5=90=88=E5=B9=B6=E6=9C=BA=E5=88=B6=EF=BC=8C?= =?UTF-8?q?Git=20=E5=88=B7=E6=96=B0=E8=99=BD=E7=84=B6=E6=9C=89=E2=80=9C?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E4=B8=AD=E5=86=8D=E6=AC=A1=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E2=80=9D=E6=A0=87=E8=AE=B0=EF=BC=8C=E4=BD=86=E4=BC=9A=E5=9C=A8?= =?UTF-8?q?=E7=A6=BB=E5=BC=80=E7=AA=97=E5=8F=A3=E5=90=8E=E7=BB=A7=E7=BB=AD?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=B9=B6=E8=A7=A6=E5=8F=91=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E6=B4=9E=E5=AF=9F/=E8=A1=8C=E5=8F=98=E6=9B=B4=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E3=80=82=E5=BF=AB=E9=80=9F=E5=9C=A8=20PR=E3=80=81Chan?= =?UTF-8?q?ges=E3=80=81Git=20Log=20=E9=97=B4=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/GitHubFeatureModel.swift | 7 ++++++ Sources/Lithe/Models/AppModel/AppModel.swift | 22 ++++++++++++++----- .../Application/GitFeatureModel.swift | 16 +++++++++++--- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/Sources/Lithe/Application/Features/GitHubFeatureModel.swift b/Sources/Lithe/Application/Features/GitHubFeatureModel.swift index a7c37be06..c64742304 100644 --- a/Sources/Lithe/Application/Features/GitHubFeatureModel.swift +++ b/Sources/Lithe/Application/Features/GitHubFeatureModel.swift @@ -56,6 +56,7 @@ final class GitHubFeatureModel: ObservableObject { private var authorizationTask: Task? private var branchLoadTask: (id: UUID, task: Task<[GitHubBranch], Error>)? private var branchesLoadedAt: Date? + private var refreshGeneration = UUID() init( service: GitHubService, @@ -147,14 +148,19 @@ final class GitHubFeatureModel: ObservableObject { func refresh(workspaceURL: URL?) async { guard case .connected = connectionState else { return } + let generation = UUID() + refreshGeneration = generation contentState = .loading do { let repository = try await service.resolveRepository(at: workspaceURL) + guard !Task.isCancelled, refreshGeneration == generation else { return } let branchDefaults = try await service.resolvePullRequestBranchDefaults(at: workspaceURL) + guard !Task.isCancelled, refreshGeneration == generation else { return } let pullRequests = try await service.listPullRequests( repository: repository, state: listState ) + guard !Task.isCancelled, refreshGeneration == generation else { return } if self.repository != repository { branches = [] branchContentState = .idle @@ -173,6 +179,7 @@ final class GitHubFeatureModel: ObservableObject { } contentState = .ready } catch { + guard !Task.isCancelled, refreshGeneration == generation else { return } contentState = .failed(error.localizedDescription) } } diff --git a/Sources/Lithe/Models/AppModel/AppModel.swift b/Sources/Lithe/Models/AppModel/AppModel.swift index 6aa139749..9932982b1 100644 --- a/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/Sources/Lithe/Models/AppModel/AppModel.swift @@ -43,13 +43,21 @@ final class AppModel: ObservableObject, Identifiable { @Published private(set) var standaloneFileURL: URL? @Published var selectedSidebar: SidebarDestination = .project { didSet { - if selectedSidebar == .changes, oldValue != .changes { - Task { [weak self] in await self?.refreshGit() } - } - if selectedSidebar == .pullRequests, oldValue != .pullRequests { - Task { [weak self] in - guard let self else { return } + sidebarRefreshTask?.cancel() + sidebarRefreshTask = nil + guard oldValue != selectedSidebar else { return } + // Sidebar changes can happen faster than Git or GitHub can respond. + // Keep only the refresh associated with the currently visible pane. + sidebarRefreshTask = Task { @MainActor [weak self] in + guard let self else { return } + guard !Task.isCancelled else { return } + switch self.selectedSidebar { + case .changes: + await self.refreshGit() + case .pullRequests: await self.githubFeature.refresh(workspaceURL: self.workspaceURL) + default: + break } } } @@ -136,6 +144,7 @@ final class AppModel: ObservableObject, Identifiable { @Published var blameVisibleURL: URL? @Published var gitLogSearchQuery = "" private var shortcutDetector: (any ShortcutDetector)? + private var sidebarRefreshTask: Task? private var shortcutSettingsObservation: AnyCancellable? private var shortcutRecordingObservation: AnyCancellable? private var isProjectSessionActive = true @@ -735,6 +744,7 @@ final class AppModel: ObservableObject, Identifiable { deinit { shortcutDetector?.stop() + sidebarRefreshTask?.cancel() } func configureProjectSession( diff --git a/Sources/LitheGitModule/Application/GitFeatureModel.swift b/Sources/LitheGitModule/Application/GitFeatureModel.swift index 7d75b0b72..d2e318c06 100644 --- a/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -204,8 +204,9 @@ package final class GitFeatureModel: ObservableObject { package func refreshGit() async { guard let workspaceURLProvider else { return } + guard !Task.isCancelled else { return } if isRefreshingGit { - refreshRequestedWhileRunning = true + if !Task.isCancelled { refreshRequestedWhileRunning = true } return } guard let workspaceURL = workspaceURLProvider() else { @@ -214,16 +215,19 @@ package final class GitFeatureModel: ObservableObject { } isRefreshingGit = true + defer { isRefreshingGit = false } repeat { + guard !Task.isCancelled else { return } refreshRequestedWhileRunning = false await refreshGitState(at: workspaceURL) - } while refreshRequestedWhileRunning && workspaceURLProvider() == workspaceURL - isRefreshingGit = false + } while !Task.isCancelled && refreshRequestedWhileRunning && workspaceURLProvider() == workspaceURL } private func refreshGitState(at workspaceURL: URL) async { + guard !Task.isCancelled else { return } var didChange = false if let snapshot = await snapshotProvider(workspaceURL) { + guard !Task.isCancelled else { return } let changesChanged = gitChanges != snapshot.changes if gitRepositoryRoot != snapshot.repositoryRoot { gitRepositoryRoot = snapshot.repositoryRoot @@ -246,16 +250,19 @@ package final class GitFeatureModel: ObservableObject { didChange = didChange || previousFilter != gitConflictFilterPaths } let stashes = await stashesProvider(snapshot.repositoryRoot) + guard !Task.isCancelled else { return } if gitStashes != stashes { gitStashes = stashes didChange = true } let shelves = await shelveService?.entries(for: snapshot.repositoryRoot) ?? [] + guard !Task.isCancelled else { return } if gitShelves != shelves { gitShelves = shelves didChange = true } let operationState = await operationStateProvider(snapshot.repositoryRoot) + guard !Task.isCancelled else { return } if gitOperationState != operationState { gitOperationState = operationState didChange = true @@ -275,6 +282,7 @@ package final class GitFeatureModel: ObservableObject { didChange = true } let document = await diffDocumentProvider(updated, gitDiffWhitespaceMode) + guard !Task.isCancelled else { return } if selectedDiffPatch != document.patch { selectedDiffPatch = document.patch diffRows = document.rows @@ -304,9 +312,11 @@ package final class GitFeatureModel: ObservableObject { } if didChange && isGitLogVisibleProvider?() == true { + guard !Task.isCancelled else { return } await refreshGitHistory() } if didChange { + guard !Task.isCancelled else { return } await onStateRefreshed?() } } From d24bd015c77ff0e4f2967ce4ff38fad2b6ade880 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Wed, 19 Aug 2026 10:40:40 +0800 Subject: [PATCH 2/2] fix(macOS): preserve latest sidebar refresh --- .../Features/GitHubFeatureModel.swift | 43 ++++++- Sources/Lithe/Models/AppModel/AppModel.swift | 2 +- .../Application/GitFeatureModel.swift | 63 +++++++++- Tests/LitheTests/GitHubServiceTests.swift | 116 ++++++++++++++++- .../GitStatusObservationTests.swift | 118 ++++++++++++++++++ 5 files changed, 328 insertions(+), 14 deletions(-) diff --git a/Sources/Lithe/Application/Features/GitHubFeatureModel.swift b/Sources/Lithe/Application/Features/GitHubFeatureModel.swift index c64742304..e40905545 100644 --- a/Sources/Lithe/Application/Features/GitHubFeatureModel.swift +++ b/Sources/Lithe/Application/Features/GitHubFeatureModel.swift @@ -8,6 +8,12 @@ final class GitHubFeatureModel: ObservableObject { static let branchCacheLifetime: TimeInterval = 60 } + private struct PullRequestDetails { + let request: GitHubPullRequest + let files: [GitHubPullRequestFile] + let comments: [GitHubComment] + } + enum ConnectionState: Equatable { case disconnected case restoring @@ -57,6 +63,7 @@ final class GitHubFeatureModel: ObservableObject { private var branchLoadTask: (id: UUID, task: Task<[GitHubBranch], Error>)? private var branchesLoadedAt: Date? private var refreshGeneration = UUID() + private var pullRequestSelectionGeneration = UUID() init( service: GitHubService, @@ -124,6 +131,8 @@ final class GitHubFeatureModel: ObservableObject { func disconnect() async { authorizationTask?.cancel() + refreshGeneration = UUID() + pullRequestSelectionGeneration = UUID() do { try await service.disconnect() connectionState = .disconnected @@ -150,6 +159,7 @@ final class GitHubFeatureModel: ObservableObject { guard case .connected = connectionState else { return } let generation = UUID() refreshGeneration = generation + pullRequestSelectionGeneration = UUID() contentState = .loading do { let repository = try await service.resolveRepository(at: workspaceURL) @@ -172,6 +182,7 @@ final class GitHubFeatureModel: ObservableObject { if let selectedNumber = selectedPullRequest?.number, pullRequests.contains(where: { $0.number == selectedNumber }) { await selectPullRequest(number: selectedNumber) + return } else { selectedPullRequest = nil files = [] @@ -291,21 +302,41 @@ final class GitHubFeatureModel: ObservableObject { func selectPullRequest(number: UInt64) async { guard let repository else { return } + let generation = UUID() + pullRequestSelectionGeneration = generation isCreatingPullRequest = false contentState = .loading do { - async let request = service.pullRequest(repository: repository, number: number) - async let files = service.files(repository: repository, number: number) - async let comments = service.comments(repository: repository, number: number) - selectedPullRequest = try await request - self.files = try await files - self.comments = try await comments + let details = try await pullRequestDetails(repository: repository, number: number) + guard !Task.isCancelled, + pullRequestSelectionGeneration == generation, + self.repository == repository else { return } + selectedPullRequest = details.request + files = details.files + comments = details.comments contentState = .ready } catch { + guard !Task.isCancelled, + pullRequestSelectionGeneration == generation, + self.repository == repository else { return } contentState = .failed(error.localizedDescription) } } + private func pullRequestDetails( + repository: GitHubRepository, + number: UInt64 + ) async throws -> PullRequestDetails { + async let request = service.pullRequest(repository: repository, number: number) + async let files = service.files(repository: repository, number: number) + async let comments = service.comments(repository: repository, number: number) + return try await PullRequestDetails( + request: request, + files: files, + comments: comments + ) + } + func createPullRequest( title: String, body: String, diff --git a/Sources/Lithe/Models/AppModel/AppModel.swift b/Sources/Lithe/Models/AppModel/AppModel.swift index 481f8e5db..524fcabfc 100644 --- a/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/Sources/Lithe/Models/AppModel/AppModel.swift @@ -43,9 +43,9 @@ final class AppModel: ObservableObject, Identifiable { @Published private(set) var standaloneFileURL: URL? @Published var selectedSidebar: SidebarDestination = .project { didSet { + guard oldValue != selectedSidebar else { return } sidebarRefreshTask?.cancel() sidebarRefreshTask = nil - guard oldValue != selectedSidebar else { return } // Sidebar changes can happen faster than Git or GitHub can respond. // Keep only the refresh associated with the currently visible pane. sidebarRefreshTask = Task { @MainActor [weak self] in diff --git a/Sources/LitheGitModule/Application/GitFeatureModel.swift b/Sources/LitheGitModule/Application/GitFeatureModel.swift index d2e318c06..14a60faa4 100644 --- a/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -79,7 +79,10 @@ package final class GitFeatureModel: ObservableObject { private var acquireModuleLease: (@MainActor (String) -> ModuleLease)? private var gitHistoryLimit = 300 private var deferredSavedChanges: GitDeferredSavedChanges? - private var refreshRequestedWhileRunning = false + private var nextRefreshRequestID: UInt64 = 0 + private var activeRefreshRequestIDs: Set = [] + private var completedRefreshRequestID: UInt64 = 0 + private var refreshCompletionWaiters: [UUID: CheckedContinuation] = [:] private var loadingLineChangeURLs: Set = [] private var lineChangeHunks: [URL: [String: DiffHunk]] = [:] @@ -169,7 +172,6 @@ package final class GitFeatureModel: ObservableObject { gitDiffWhitespaceMode = .doNotIgnore isLoadingDiff = false isRefreshingGit = false - refreshRequestedWhileRunning = false pendingDiscardChange = nil pendingDiscardHunk = nil isCommitting = false @@ -205,22 +207,71 @@ package final class GitFeatureModel: ObservableObject { package func refreshGit() async { guard let workspaceURLProvider else { return } guard !Task.isCancelled else { return } + nextRefreshRequestID &+= 1 + let requestID = nextRefreshRequestID + activeRefreshRequestIDs.insert(requestID) + defer { activeRefreshRequestIDs.remove(requestID) } + await fulfillGitRefreshRequest(requestID, workspaceURLProvider: workspaceURLProvider) + } + + private func fulfillGitRefreshRequest( + _ requestID: UInt64, + workspaceURLProvider: @MainActor () -> URL? + ) async { if isRefreshingGit { - if !Task.isCancelled { refreshRequestedWhileRunning = true } + await waitForCurrentGitRefresh() + guard !Task.isCancelled else { return } + if completedRefreshRequestID < requestID { + await fulfillGitRefreshRequest(requestID, workspaceURLProvider: workspaceURLProvider) + } return } guard let workspaceURL = workspaceURLProvider() else { reset() + completedRefreshRequestID = max(completedRefreshRequestID, requestID) return } isRefreshingGit = true - defer { isRefreshingGit = false } + defer { finishCurrentGitRefresh() } repeat { guard !Task.isCancelled else { return } - refreshRequestedWhileRunning = false + let processingRequestID = activeRefreshRequestIDs.max() ?? requestID await refreshGitState(at: workspaceURL) - } while !Task.isCancelled && refreshRequestedWhileRunning && workspaceURLProvider() == workspaceURL + guard !Task.isCancelled else { return } + completedRefreshRequestID = max(completedRefreshRequestID, processingRequestID) + } while activeRefreshRequestIDs.contains(where: { $0 > completedRefreshRequestID }) + && workspaceURLProvider() == workspaceURL + } + + private func waitForCurrentGitRefresh() async { + let waiterID = UUID() + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + guard isRefreshingGit, !Task.isCancelled else { + continuation.resume() + return + } + refreshCompletionWaiters[waiterID] = continuation + } + } onCancel: { + Task { @MainActor [weak self] in + self?.resumeGitRefreshWaiter(waiterID) + } + } + } + + private func finishCurrentGitRefresh() { + isRefreshingGit = false + let waiters = Array(refreshCompletionWaiters.values) + refreshCompletionWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } + + private func resumeGitRefreshWaiter(_ waiterID: UUID) { + refreshCompletionWaiters.removeValue(forKey: waiterID)?.resume() } private func refreshGitState(at workspaceURL: URL) async { diff --git a/Tests/LitheTests/GitHubServiceTests.swift b/Tests/LitheTests/GitHubServiceTests.swift index 761c18280..df4aca938 100644 --- a/Tests/LitheTests/GitHubServiceTests.swift +++ b/Tests/LitheTests/GitHubServiceTests.swift @@ -30,6 +30,17 @@ private struct GitHubCoreStub: GitHubCorePlanning { requiresAuthentication: true ) } + if ["getPullRequest", "listPullRequestFiles", "listPullRequestComments"] + .contains(request.operation), let pullNumber = request.pullNumber { + return GitHubRequestPlan( + host: .api, + method: "GET", + path: "/test/\(request.operation)/\(pullNumber)", + query: [:], + body: nil, + requiresAuthentication: true + ) + } return GitHubRequestPlan( host: .api, method: "GET", @@ -46,6 +57,22 @@ private struct GitHubCoreStub: GitHubCorePlanning { body: String ) throws -> GitHubNormalizedResponse { #expect(status == 200) + if operation == "getPullRequest" { + return .pullRequest(try Self.pullRequest(number: Self.pullNumber(from: body))) + } + if operation == "listPullRequestFiles" { + let number = Self.pullNumber(from: body) + return .files([GitHubPullRequestFile( + path: "pull-request-\(number).swift", + status: "modified", + additions: number, + deletions: 0, + patch: nil + )]) + } + if operation == "listPullRequestComments" { + return .comments([try Self.comment(number: Self.pullNumber(from: body))]) + } #expect(body == "user-response") if operation == "listBranches" { return .branches([ @@ -74,15 +101,67 @@ private struct GitHubCoreStub: GitHubCorePlanning { avatarURL: nil )) } + + private static func pullNumber(from body: String) -> UInt64 { + UInt64(body.split(separator: "/").last ?? "") ?? 0 + } + + private static func pullRequest(number: UInt64) throws -> GitHubPullRequest { + let data = Data(""" + { + "number": \(number), + "title": "Pull request \(number)", + "body": "", + "state": "open", + "isDraft": false, + "url": "https://github.com/openai/codex/pull/\(number)", + "author": { "login": "octocat", "url": "https://github.com/octocat", "avatarUrl": null }, + "headRef": "feature/\(number)", + "headRepository": "openai/codex", + "baseRef": "main", + "baseRepository": "openai/codex", + "createdAt": "2026-08-19T00:00:00Z", + "updatedAt": "2026-08-19T00:00:00Z", + "isMerged": false, + "isMergeable": true, + "additions": 1, + "deletions": 0, + "changedFiles": 1, + "commentsCount": 1, + "labels": [], + "assignees": [] + } + """.utf8) + return try JSONDecoder().decode(GitHubPullRequest.self, from: data) + } + + private static func comment(number: UInt64) throws -> GitHubComment { + let data = Data(""" + { + "id": \(number), + "author": { "login": "octocat", "url": "https://github.com/octocat", "avatarUrl": null }, + "body": "Comment \(number)", + "createdAt": "2026-08-19T00:00:00Z", + "updatedAt": "2026-08-19T00:00:00Z", + "url": "https://github.com/openai/codex/pull/\(number)#comment" + } + """.utf8) + return try JSONDecoder().decode(GitHubComment.self, from: data) + } } private actor GitHubTransportStub: GitHubHTTPTransport { private(set) var receivedToken: String? private(set) var branchRequestCount = 0 private let branchRequestDelay: Duration? + private let pullRequestDelays: [UInt64: Duration] - init(branchRequestDelay: Duration? = nil) { + init( + branchRequestDelay: Duration? = nil, + pullRequestDelays: [UInt64: Duration] = [:] + ) { self.branchRequestDelay = branchRequestDelay + self.pullRequestDelays = pullRequestDelays } func execute(plan: GitHubRequestPlan, token: String?) async throws -> GitHubHTTPResponse { @@ -93,6 +172,13 @@ private actor GitHubTransportStub: GitHubHTTPTransport { try await Task.sleep(for: branchRequestDelay) } } + if let pullNumber = UInt64(plan.path.split(separator: "/").last ?? ""), + let delay = pullRequestDelays[pullNumber] { + try await Task.sleep(for: delay) + } + if plan.path.hasPrefix("/test/") { + return GitHubHTTPResponse(status: 200, body: plan.path) + } return GitHubHTTPResponse(status: 200, body: "user-response") } } @@ -225,6 +311,34 @@ struct GitHubServiceTests { #expect(await transport.branchRequestCount == 1) } + @Test("A stale pull request selection cannot overwrite a newer selection") + @MainActor + func stalePullRequestSelectionIsDiscarded() async throws { + let transport = GitHubTransportStub(pullRequestDelays: [1: .milliseconds(100)]) + let service = GitHubService( + core: GitHubCoreStub(), + transport: transport, + configuration: GitHubConfigurationStub(), + secureStore: GitHubSecureStoreStub(), + git: GitHubGitStub() + ) + let model = GitHubFeatureModel(service: service) + await model.connect( + personalAccessToken: "fake-test-token", + workspaceURL: URL(fileURLWithPath: "/tmp/lithe-github-fixture") + ) + + let staleSelection = Task { @MainActor in await model.selectPullRequest(number: 1) } + try await Task.sleep(for: .milliseconds(10)) + let currentSelection = Task { @MainActor in await model.selectPullRequest(number: 2) } + _ = await (staleSelection.value, currentSelection.value) + + #expect(model.selectedPullRequest?.number == 2) + #expect(model.files.map(\.path) == ["pull-request-2.swift"]) + #expect(model.comments.map(\.body) == ["Comment 2"]) + #expect(model.contentState == .ready) + } + @Test("Creating a pull request uses the GitHub workspace instead of a modal") @MainActor func createWorkspacePresentationState() { diff --git a/Tests/LitheTests/GitStatusObservationTests.swift b/Tests/LitheTests/GitStatusObservationTests.swift index 578c69615..a9c84ffe8 100644 --- a/Tests/LitheTests/GitStatusObservationTests.swift +++ b/Tests/LitheTests/GitStatusObservationTests.swift @@ -45,6 +45,95 @@ struct GitStatusObservationTests { #expect(downstreamRefreshCount == 1) } + @Test + @MainActor + func pendingRefreshTakesOverAfterRunningRefreshIsCancelled() async { + let repository = URL(fileURLWithPath: "/tmp/lithe-cancelled-git-refresh") + let snapshotProvider = ControlledGitSnapshotProvider() + let model = GitFeatureModel( + service: GitService(operations: RustGitOperations(core: RustCoreBridge())), + snapshotProvider: { _ in await snapshotProvider.nextSnapshot() }, + stashesProvider: { _ in [] }, + operationStateProvider: { _ in nil }, + diffDocumentProvider: { _, _ in DiffDocument(rows: [], hunks: []) } + ) + model.configure( + workspaceURLProvider: { repository }, + isGitLogVisibleProvider: { false }, + notify: { _ in }, + onStateRefreshed: {} + ) + + let firstRefresh = Task { @MainActor in await model.refreshGit() } + #expect(await snapshotProvider.waitForRequestCount(1)) + + let pendingRefresh = Task { @MainActor in await model.refreshGit() } + await Task.yield() + firstRefresh.cancel() + await snapshotProvider.resumeNext( + with: GitSnapshot(repositoryRoot: repository, branch: "stale", changes: []) + ) + await firstRefresh.value + + let pendingRefreshTookOver = await snapshotProvider.waitForRequestCount(2) + #expect(pendingRefreshTookOver) + if pendingRefreshTookOver { + await snapshotProvider.resumeNext( + with: GitSnapshot(repositoryRoot: repository, branch: "current", changes: []) + ) + } else { + pendingRefresh.cancel() + } + await pendingRefresh.value + + #expect(model.currentBranch == "current") + } + + @Test + @MainActor + func cancelledPendingRefreshDoesNotScheduleAnotherPass() async { + let repository = URL(fileURLWithPath: "/tmp/lithe-cancelled-pending-git-refresh") + let snapshotProvider = ControlledGitSnapshotProvider() + let model = GitFeatureModel( + service: GitService(operations: RustGitOperations(core: RustCoreBridge())), + snapshotProvider: { _ in await snapshotProvider.nextSnapshot() }, + stashesProvider: { _ in [] }, + operationStateProvider: { _ in nil }, + diffDocumentProvider: { _, _ in DiffDocument(rows: [], hunks: []) } + ) + model.configure( + workspaceURLProvider: { repository }, + isGitLogVisibleProvider: { false }, + notify: { _ in }, + onStateRefreshed: {} + ) + + let runningRefresh = Task { @MainActor in await model.refreshGit() } + #expect(await snapshotProvider.waitForRequestCount(1)) + + let cancelledRefresh = Task { @MainActor in await model.refreshGit() } + await Task.yield() + cancelledRefresh.cancel() + await cancelledRefresh.value + await snapshotProvider.resumeNext( + with: GitSnapshot(repositoryRoot: repository, branch: "current", changes: []) + ) + + let scheduledAnotherPass = await snapshotProvider.waitForRequestCount( + 2, + timeout: .milliseconds(200) + ) + if scheduledAnotherPass { + await snapshotProvider.resumeNext( + with: GitSnapshot(repositoryRoot: repository, branch: "unexpected", changes: []) + ) + } + await runningRefresh.value + + #expect(!scheduledAnotherPass) + #expect(model.currentBranch == "current") + } + @Test @MainActor func visibleWorkspaceEditStillUsesTheWorkspacePipelineAndRefreshesGit() async throws { @@ -378,6 +467,35 @@ private final class GitObservationFixture { } } +private actor ControlledGitSnapshotProvider { + private var continuations: [CheckedContinuation] = [] + private var requestCount = 0 + + func nextSnapshot() async -> GitSnapshot? { + requestCount += 1 + return await withCheckedContinuation { continuation in + continuations.append(continuation) + } + } + + func waitForRequestCount( + _ expectedCount: Int, + timeout: Duration = .seconds(2) + ) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while requestCount < expectedCount, clock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } + return requestCount >= expectedCount + } + + func resumeNext(with snapshot: GitSnapshot?) { + guard !continuations.isEmpty else { return } + continuations.removeFirst().resume(returning: snapshot) + } +} + private enum GitObservationTestError: Error { case gitFailed(arguments: [String], output: String) case workspaceUnavailable