diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c88941f..d57d180 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,26 +13,26 @@ concurrency: jobs: build: - runs-on: macos-26 + runs-on: xcode-27 timeout-minutes: 60 + # Debug signs with the maintainer's team (project.yml), which a runner + # doesn't have; the test bundles build ad-hoc instead. + env: + SIGNING_OVERRIDE: CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO DEVELOPMENT_TEAM= + steps: - name: Checkout uses: actions/checkout@v4 - - name: Install XcodeGen - run: brew install xcodegen + # The real-pipeline tests drive /opt/homebrew/bin/tmux. + - name: Install XcodeGen and tmux + run: brew install xcodegen tmux - # Flotilla.icon is an Icon Composer bundle (specular/refractivity - # features); actool on GitHub's Xcode 26.6 runners crashes compiling it - # (works fine locally on Xcode 27). Move it out of the way — both - # targets that reference it mark that source `optional: true` — and - # point the macOS app at the plain FlotillaFallback.appiconset instead, - # so CI validates a real build without needing Xcode 27. - - name: Use fallback app icon for CI - run: | - mv Flotilla.icon Flotilla.icon.disabled-for-ci - sed -i '' 's/ASSETCATALOG_COMPILER_APPICON_NAME: Flotilla$/ASSETCATALOG_COMPILER_APPICON_NAME: FlotillaFallback/' project.yml + # SwiftTerm compiles a Metal shader; the xcode-27 image ships without + # the Metal toolchain component. + - name: Install Metal toolchain + run: xcodebuild -downloadComponent MetalToolchain - name: Generate Xcode project run: xcodegen generate @@ -43,3 +43,21 @@ jobs: # Release only: it's what ships, and Xcode compiles Debug on every Run. - name: Build (Release) run: make build-release + + - name: Run app unit tests + # A hung test fails after 2 minutes instead of eating the job timeout. + run: > + make test SIGNING_OVERRIDE="$SIGNING_OVERRIDE" + TEST_FLAGS="-test-timeouts-enabled YES -maximum-test-execution-time-allowance 120" + + # The log only names failing tests; messages, attachments and test-host + # crash reports live in the result bundles. + - name: Upload test results + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: test-results + path: | + build/DerivedData/Logs/Test/*.xcresult + ~/Library/Logs/DiagnosticReports/Flotilla* + retention-days: 7 diff --git a/.github/workflows/release-dmg.yml b/.github/workflows/release-dmg.yml index 1d0f857..7c87bdf 100644 --- a/.github/workflows/release-dmg.yml +++ b/.github/workflows/release-dmg.yml @@ -20,7 +20,7 @@ jobs: # Secrets live in the protected `release` environment: the job waits for # the maintainer's approval and only runs for v* tags. environment: release - runs-on: macos-26 + runs-on: xcode-27 timeout-minutes: 45 # The `secrets` context isn't allowed in a step's `if:` condition, so @@ -36,13 +36,6 @@ jobs: - name: Install XcodeGen run: brew install xcodegen - # See build.yml — actool on GitHub's Xcode 26.6 runners crashes on - # Flotilla.icon; use the plain fallback appiconset instead. - - name: Use fallback app icon for CI - run: | - mv Flotilla.icon Flotilla.icon.disabled-for-ci - sed -i '' 's/ASSETCATALOG_COMPILER_APPICON_NAME: Flotilla$/ASSETCATALOG_COMPILER_APPICON_NAME: FlotillaFallback/' project.yml - - name: Generate Xcode project run: xcodegen generate diff --git a/AGENTS.md b/AGENTS.md index 934b2aa..3171491 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -521,8 +521,17 @@ xcodebuild -project Flotilla.xcodeproj -scheme Flotilla -configuration Debug \ ### CI/CD (GitHub Actions) Pipeline at `.github/workflows/build.yml`: -1. Checkout → use the CI fallback app icon → generate project +1. Checkout → install XcodeGen, tmux and the Metal toolchain → generate project 2. Run standalone `CompanionKit` and `TerminalKit` SwiftPM tests → build Release +3. `make test` (FlotillaUnitTests) with a 2-minute per-test timeout +4. Upload the `.xcresult` bundles and any Flotilla crash reports as `test-results` + +Both workflows run on GitHub's `xcode-27` image (a preview label), so CI and +releases use the same Xcode 27 as local builds and compile the real +`Flotilla.icon`. That image lacks the Metal toolchain SwiftTerm's shader needs, +and tmux, which the real-pipeline tests drive at `/opt/homebrew/bin/tmux`. +The log names failing tests but not their messages; read those from the +`test-results` artifact (`xcrun xcresulttool get test-results tests --path …`). A newer push to the same ref cancels the in-flight run. There is no Debug build (Xcode compiles it on every Run) and no CI archive or artifact; the @@ -534,16 +543,12 @@ Publishing a GitHub Release archives the app with `make archive`, wraps `Flotill (also uploaded as the `Flotilla-DMG` artifact). Notarization is not included yet — Gatekeeper will still warn on first open until that is added. -The GitHub-hosted workflow deliberately does **not** run `make test` or -`make test-ui`. App unit-test compilation hits a Swift 6 region-based -isolation-checker compiler error in GitHub's Xcode 26.6 (`Task.detached` in -`CompanionHostTests`), while the developer's Xcode 27 toolchain compiles it. -UI tests are intentionally local/Xcode-only: macOS UI automation needs -Accessibility permission from the process driving the test (see -"UI Vocabulary Screenshot Pipeline" above), which a hosted runner cannot be -given. Run `make test` and the targeted `make test-ui` locally before merging -changes that affect application behavior or UI/accessibility. `make test-packages` -keeps CI exercising the two local packages that have standalone SwiftPM tests. +CI does not run `make test-ui` yet. UI automation itself works on the hosted +runner, but its display boots at 1024x768, narrower than the main window's +minimum width. Switching it to 1920x1080 (`displayplacer`) still leaves the +window 692 pt tall, and 11 of 30 UI tests fail on content that doesn't fit. +Run the targeted `make test-ui` locally before merging changes that affect +UI/accessibility. Debug signs with the maintainer's own Apple Development team (`DEVELOPMENT_TEAM: UWAHVC4JTL` in `project.yml`) so that Screen Recording @@ -578,22 +583,9 @@ The certificate must belong to team `UWAHVC4JTL`, which the Makefile's `ARCHIVE_SIGNING_OVERRIDE` names. `Flotilla.icon` is an Icon Composer bundle using features (`specular-location`, -`refractivity`) that only Xcode 27 beta's `actool` parses correctly — the -maintainer's local Xcode. GitHub's hosted `macos-26` runners top out at Xcode -26.6 stable, whose `actool` crashes compiling it (`Could not open -"Flotilla.icon"` / `NSPlaceholderArray … nil object`), confirmed deterministic -across reruns. Both jobs' "Use fallback app icon for CI" step works around -this before generating the project: it moves `Flotilla.icon` aside (both -targets that reference it mark that source `optional: true` in `project.yml`, -so `xcodegen` tolerates its absence) and repoints -`ASSETCATALOG_COMPILER_APPICON_NAME` at the plain -`Flotilla/Resources/Assets.xcassets/FlotillaFallback.appiconset` instead — a -traditional multi-size PNG icon with no Icon Composer features, so it compiles -on any Xcode version. This only affects the ephemeral CI checkout; local -builds are untouched. XcodeGen does not support `${VAR:-default}`-style -templating in `project.yml` (it passes the literal string through unresolved), -which is why this is a plain `mv`/`sed` step in the workflow rather than an -env-var-driven source path. +`refractivity`) that Xcode 26.6's `actool` crashes on. CI used to swap in +`FlotillaFallback.appiconset` for that; on the `xcode-27` image it compiles +the real icon, so both workflows build it as-is. ## Design System diff --git a/Flotilla/Features/Session/SessionScreenReader.swift b/Flotilla/Features/Session/SessionScreenReader.swift index b8d66dd..98f04d0 100644 --- a/Flotilla/Features/Session/SessionScreenReader.swift +++ b/Flotilla/Features/Session/SessionScreenReader.swift @@ -62,18 +62,14 @@ struct ProcessTmuxPaneCapture: TmuxPaneCapturing { let outputPipe = Pipe() process.standardOutput = outputPipe process.standardError = FileHandle.nullDevice + let exited: DispatchSemaphore do { - try process.run() + exited = try process.runSignalingExit() } catch { return nil } - let done = DispatchSemaphore(value: 0) - DispatchQueue.global().async { - process.waitUntilExit() - done.signal() - } - guard done.wait(timeout: .now() + 2) == .success else { + guard exited.wait(timeout: .now() + 2) == .success else { process.terminate() return nil } diff --git a/Flotilla/Services/TmuxSessionWrapping.swift b/Flotilla/Services/TmuxSessionWrapping.swift index 28d8ff0..c01c430 100644 --- a/Flotilla/Services/TmuxSessionWrapping.swift +++ b/Flotilla/Services/TmuxSessionWrapping.swift @@ -462,8 +462,9 @@ struct ProcessTmuxServerProbe: TmuxServerProbing { let errorPipe = Pipe() process.standardOutput = FileHandle.nullDevice process.standardError = errorPipe + let exited: DispatchSemaphore do { - try process.run() + exited = try process.runSignalingExit() } catch { // The probe itself failed to run — that says nothing about the // server, so keep the existing behavior (try tmux). @@ -473,12 +474,7 @@ struct ProcessTmuxServerProbe: TmuxServerProbing { // A wedged server drops the client immediately, but a *hung* one // must not block the caller: bound the wait, and treat a timeout as // "not answering" too. - let done = DispatchSemaphore(value: 0) - DispatchQueue.global().async { - process.waitUntilExit() - done.signal() - } - guard done.wait(timeout: .now() + 2) == .success else { + guard exited.wait(timeout: .now() + 2) == .success else { process.terminate() return false } @@ -517,14 +513,8 @@ struct ProcessTmuxSessionTerminator: TmuxSessionTerminating { // `new-session -A` with the same name, and an in-flight kill landing // after that would kill the freshly created session. Only wait when // the process actually started, with a bounded wait to prevent hangs. - guard (try? process.run()) != nil else { return } - - let done = DispatchSemaphore(value: 0) - DispatchQueue.global(qos: .userInitiated).async { - process.waitUntilExit() - done.signal() - } - if done.wait(timeout: .now() + 2) != .success { + guard let exited = try? process.runSignalingExit() else { return } + if exited.wait(timeout: .now() + 2) != .success { process.terminate() } } @@ -539,8 +529,9 @@ struct ProcessTmuxSessionTerminator: TmuxSessionTerminating { let outputPipe = Pipe() process.standardOutput = outputPipe process.standardError = FileHandle.nullDevice + let exited: DispatchSemaphore do { - try process.run() + exited = try process.runSignalingExit() } catch { return [] } @@ -549,7 +540,7 @@ struct ProcessTmuxSessionTerminator: TmuxSessionTerminating { var data = Data() DispatchQueue.global(qos: .userInitiated).async { data = outputPipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() + exited.wait() done.signal() } guard done.wait(timeout: .now() + 2) == .success, process.terminationStatus == 0 else { @@ -684,18 +675,14 @@ struct ProcessTmuxGoalDeliverer: TmuxGoalDelivering { let outputPipe = Pipe() process.standardOutput = outputPipe process.standardError = FileHandle.nullDevice + let exited: DispatchSemaphore do { - try process.run() + exited = try process.runSignalingExit() } catch { return nil } - let done = DispatchSemaphore(value: 0) - DispatchQueue.global().async { - process.waitUntilExit() - done.signal() - } - guard done.wait(timeout: .now() + 2) == .success else { + guard exited.wait(timeout: .now() + 2) == .success else { process.terminate() return nil } @@ -729,3 +716,19 @@ private enum TmuxGoalDeliveryError: LocalizedError { } } } + +extension Process { + /// `run()`, returning a semaphore signalled once the process exits. + /// + /// Bounded waits must hang off `terminationHandler`: parking a GCD worker + /// in `waitUntilExit()` instead can return seconds after the process has + /// exited. Measured: `tmux ls` exiting in under 20 ms still overran the + /// server probe's 2-second bound about once in 15 calls, so the probe + /// reported a healthy server as wedged and sessions launched without tmux. + func runSignalingExit() throws -> DispatchSemaphore { + let exited = DispatchSemaphore(value: 0) + terminationHandler = { _ in exited.signal() } + try run() + return exited + } +} diff --git a/FlotillaUnitTests/AgentManagedWorktreeAndTitleTests.swift b/FlotillaUnitTests/AgentManagedWorktreeAndTitleTests.swift index ff6191c..d03b316 100644 --- a/FlotillaUnitTests/AgentManagedWorktreeAndTitleTests.swift +++ b/FlotillaUnitTests/AgentManagedWorktreeAndTitleTests.swift @@ -219,10 +219,7 @@ final class AgentManagedWorktreeAndTitleTests: XCTestCase { // Wait for the fallback to fire (it creates a stopgap worktree once // the shortened threshold elapses with no descriptor on disk). - for _ in 0..<40 { - if !gitService.createWorktreeCalls.isEmpty { break } - try await Task.sleep(for: .milliseconds(20)) - } + try await waitForWorktree(of: unwrappedID, in: store) XCTAssertEqual(gitService.createWorktreeCalls.count, 1) let stopgapSession = try XCTUnwrap(store.sessions.first(where: { $0.id == unwrappedID })) XCTAssertNotNil(stopgapSession.worktree) @@ -330,10 +327,7 @@ final class AgentManagedWorktreeAndTitleTests: XCTestCase { let unwrappedID = try XCTUnwrap(sessionID) // Wait for fallback worktree to be created - for _ in 0..<40 { - if !gitService.createWorktreeCalls.isEmpty { break } - try await Task.sleep(for: .milliseconds(20)) - } + try await waitForWorktree(of: unwrappedID, in: store) let fallbackWorktree = store.sessions.first(where: { $0.id == unwrappedID })?.worktree XCTAssertNotNil(fallbackWorktree, "Fallback worktree should exist") @@ -361,4 +355,14 @@ final class AgentManagedWorktreeAndTitleTests: XCTestCase { AgentSelfReportCoordinator.clearDescriptor(for: unwrappedID, supportDirectory: supportDir) } + + /// The mock records `createWorktree` before returning, and the store + /// assigns the worktree only after that await resumes — so wait for the + /// session itself, not the recorded call. + private func waitForWorktree(of sessionID: UUID, in store: AppStore) async throws { + for _ in 0..<100 { + if store.sessions.first(where: { $0.id == sessionID })?.worktree != nil { return } + try await Task.sleep(for: .milliseconds(20)) + } + } } diff --git a/FlotillaUnitTests/HooksKitTests.swift b/FlotillaUnitTests/HooksKitTests.swift index 6e91d41..bf54770 100644 --- a/FlotillaUnitTests/HooksKitTests.swift +++ b/FlotillaUnitTests/HooksKitTests.swift @@ -1079,6 +1079,15 @@ final class HookConfigurationWriterTests: XCTestCase { } } + /// Never the real `~/.config/opencode/plugins`. + private var openCodePluginsDirectory: URL { + workingDirectory.deletingLastPathComponent().appendingPathComponent("opencode-plugins", isDirectory: true) + } + + private var openCodeWriter: HookConfigurationWriter { + HookConfigurationWriter(openCodeGlobalPluginsDirectory: openCodePluginsDirectory) + } + /// Never the real `~/.gemini/config/hooks.json`. private var antigravityHooksFile: URL { workingDirectory.deletingLastPathComponent().appendingPathComponent("gemini-config/hooks.json") @@ -1353,7 +1362,7 @@ final class HookConfigurationWriterTests: XCTestCase { XCTAssertEqual(try Data(contentsOf: configFile), existing) } func testOpenCodeRelaunchOverwritesRatherThanAccumulating() throws { - let writer = HookConfigurationWriter() + let writer = openCodeWriter let sessionID = UUID() for _ in 0..<3 { _ = writer.configureHooks( @@ -1364,13 +1373,12 @@ final class HookConfigurationWriterTests: XCTestCase { ) } - let pluginsDirectory = workingDirectory.appendingPathComponent(".opencode/plugins", isDirectory: true) - let entries = try FileManager.default.contentsOfDirectory(atPath: pluginsDirectory.path) + let entries = try FileManager.default.contentsOfDirectory(atPath: openCodePluginsDirectory.path) XCTAssertEqual(entries.count, 1, "relaunching must overwrite, not accumulate, the stable plugin file") } func testOpenCodeTwoSessionsShareOneEnvironmentRoutedPlugin() throws { - let writer = HookConfigurationWriter() + let writer = openCodeWriter _ = writer.configureHooks( for: .openCode, sessionID: UUID(), @@ -1384,18 +1392,18 @@ final class HookConfigurationWriterTests: XCTestCase { supportDirectory: supportDirectory ) - let pluginsDirectory = workingDirectory.appendingPathComponent(".opencode/plugins", isDirectory: true) - let entries = try FileManager.default.contentsOfDirectory(atPath: pluginsDirectory.path) + let entries = try FileManager.default.contentsOfDirectory(atPath: openCodePluginsDirectory.path) XCTAssertEqual(entries, ["flotilla-status.js"], "one stable plugin routes each process to its own event file") } func testConcurrentOpenCodeConfigurationKeepsOneStablePlugin() async throws { let workingDirectory = try XCTUnwrap(workingDirectory) let supportDirectory = try XCTUnwrap(supportDirectory) + let pluginsDirectory = openCodePluginsDirectory let results = await withTaskGroup(of: Bool.self, returning: [Bool].self) { group in for _ in 0..<12 { group.addTask { - HookConfigurationWriter().configureHooks( + HookConfigurationWriter(openCodeGlobalPluginsDirectory: pluginsDirectory).configureHooks( for: .openCode, sessionID: UUID(), workingDirectory: workingDirectory, @@ -1408,8 +1416,6 @@ final class HookConfigurationWriterTests: XCTestCase { return values } XCTAssertTrue(results.allSatisfy { $0 }) - - let pluginsDirectory = workingDirectory.appendingPathComponent(".opencode/plugins", isDirectory: true) XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: pluginsDirectory.path), ["flotilla-status.js"]) } @@ -1421,7 +1427,7 @@ final class HookConfigurationWriterTests: XCTestCase { try Data().write(to: legacy) try Data().write(to: userPlugin) - XCTAssertTrue(HookConfigurationWriter().configureHooks( + XCTAssertTrue(openCodeWriter.configureHooks( for: .openCode, sessionID: UUID(), workingDirectory: workingDirectory, diff --git a/FlotillaUnitTests/RealPipelineReproTests.swift b/FlotillaUnitTests/RealPipelineReproTests.swift index 7821e4f..4138e6d 100644 --- a/FlotillaUnitTests/RealPipelineReproTests.swift +++ b/FlotillaUnitTests/RealPipelineReproTests.swift @@ -315,7 +315,10 @@ final class RealPipelineReproTests: XCTestCase { ) let created = try XCTUnwrap(store.sessions.first { $0.title == "Agent exit restart" }) let sessionName = TmuxSessionWrapping.sessionName(for: created.id) - let originalPID = try XCTUnwrap(waitForTmuxPanePID(sessionName)) + let originalPID = try XCTUnwrap( + waitForTmuxPanePID(sessionName), + "no tmux pane; store error: \(store.lastOperationError ?? "none"), sessions: \(tmuxListSessions())" + ) let process = try XCTUnwrap(store.process(for: created.id)) XCTAssertTrue(process.isRunning) @@ -454,7 +457,9 @@ final class RealPipelineReproTests: XCTestCase { let output = Pipe() process.standardOutput = output process.standardError = FileHandle.nullDevice - try? process.run() + // A launch failure leaves our end of the pipe open, so reading to + // EOF would block forever. + guard (try? process.run()) != nil else { return nil } let screen = String(decoding: output.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) process.waitUntilExit() if screen.contains("Agent exited") { return screen } @@ -463,6 +468,19 @@ final class RealPipelineReproTests: XCTestCase { return nil } + private func tmuxListSessions() -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/tmux") + process.arguments = TmuxSessionWrapping.socketArguments() + ["ls"] + let output = Pipe() + process.standardOutput = output + process.standardError = output + guard (try? process.run()) != nil else { return "tmux failed to launch" } + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return "\(TmuxSessionWrapping.socketArguments()) exit \(process.terminationStatus): \(String(decoding: data, as: UTF8.self))" + } + private func waitForTmuxPanePID(_ name: String, timeout: TimeInterval = 10.0) -> String? { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { @@ -560,7 +578,7 @@ final class RealPipelineReproTests: XCTestCase { let output = Pipe() process.standardOutput = output process.standardError = FileHandle.nullDevice - try? process.run() + guard (try? process.run()) != nil else { return nil } let data = output.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() return String(decoding: data, as: UTF8.self) @@ -573,7 +591,7 @@ final class RealPipelineReproTests: XCTestCase { let output = Pipe() process.standardOutput = output process.standardError = FileHandle.nullDevice - try? process.run() + guard (try? process.run()) != nil else { return nil } let data = output.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() let value = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Makefile b/Makefile index e300e7e..b08de47 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,8 @@ SCENARIO ?= # override signing on the command line, e.g.: # make build SIGNING_OVERRIDE="CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO DEVELOPMENT_TEAM=" SIGNING_OVERRIDE ?= +# Extra xcodebuild flags for `make test`, e.g. CI's per-test timeout. +TEST_FLAGS ?= # A local development team signs Ephemeral and the installed Release build # with a stable identity so TCC grants (Documents access, Handy approval) # survive rebuilds. Without the ignored local signing @@ -85,6 +87,7 @@ test: xcodegen -parallel-testing-enabled YES \ -maximum-parallel-testing-workers 4 \ $(SIGNING_OVERRIDE) \ + $(TEST_FLAGS) \ test -only-testing:FlotillaUnitTests # The two local packages with standalone SwiftPM test targets. Unlike the app diff --git a/Packages/GitKit/Sources/GitKit/MockGitService.swift b/Packages/GitKit/Sources/GitKit/MockGitService.swift index c7543b4..e832d37 100644 --- a/Packages/GitKit/Sources/GitKit/MockGitService.swift +++ b/Packages/GitKit/Sources/GitKit/MockGitService.swift @@ -28,25 +28,25 @@ public final class MockGitService: GitServiceProtocol, @unchecked Sendable { public var mergedBranchesToReturn: Set = [] public var errorToThrow: Error? - public private(set) var createWorktreeCalls: [(basePath: URL, branch: String, destination: URL)] = [] - public private(set) var removeWorktreeCalls: [(path: URL, repoPath: URL, branch: String, deleteBranch: Bool)] = [] - public private(set) var stageCalls: [(paths: [String], repoPath: URL)] = [] - public private(set) var unstageCalls: [(paths: [String], repoPath: URL)] = [] - public private(set) var discardCalls: [(paths: [String], repoPath: URL)] = [] - public private(set) var commitCalls: [(message: String, repoPath: URL)] = [] - public private(set) var pushCalls: [(branch: String, repoPath: URL)] = [] - public private(set) var fetchCalls: [URL] = [] - public private(set) var logCalls: [(repoPath: URL, ref: String?, skip: Int, maxCount: Int)] = [] - public private(set) var logGraphCalls: [(repoPath: URL, maxCount: Int)] = [] - public private(set) var branchesCalls: [URL] = [] - public private(set) var commitDetailCalls: [(sha: String, repoPath: URL)] = [] - public private(set) var commitsOnBranchCalls: [(branch: String, base: String, repoPath: URL)] = [] - public private(set) var comparisonCalls: [(base: String, repoPath: URL)] = [] - public private(set) var uncommittedChangeCalls: [URL] = [] - public private(set) var checkoutCalls: [(branch: String, repoPath: URL)] = [] - public private(set) var createBranchCalls: [(branch: String, repoPath: URL)] = [] - public private(set) var deleteBranchCalls: [(branch: String, force: Bool, repoPath: URL)] = [] - public private(set) var mergedBranchCalls: [(branch: String, base: String, repoPath: URL)] = [] + @Locked public private(set) var createWorktreeCalls: [(basePath: URL, branch: String, destination: URL)] = [] + @Locked public private(set) var removeWorktreeCalls: [(path: URL, repoPath: URL, branch: String, deleteBranch: Bool)] = [] + @Locked public private(set) var stageCalls: [(paths: [String], repoPath: URL)] = [] + @Locked public private(set) var unstageCalls: [(paths: [String], repoPath: URL)] = [] + @Locked public private(set) var discardCalls: [(paths: [String], repoPath: URL)] = [] + @Locked public private(set) var commitCalls: [(message: String, repoPath: URL)] = [] + @Locked public private(set) var pushCalls: [(branch: String, repoPath: URL)] = [] + @Locked public private(set) var fetchCalls: [URL] = [] + @Locked public private(set) var logCalls: [(repoPath: URL, ref: String?, skip: Int, maxCount: Int)] = [] + @Locked public private(set) var logGraphCalls: [(repoPath: URL, maxCount: Int)] = [] + @Locked public private(set) var branchesCalls: [URL] = [] + @Locked public private(set) var commitDetailCalls: [(sha: String, repoPath: URL)] = [] + @Locked public private(set) var commitsOnBranchCalls: [(branch: String, base: String, repoPath: URL)] = [] + @Locked public private(set) var comparisonCalls: [(base: String, repoPath: URL)] = [] + @Locked public private(set) var uncommittedChangeCalls: [URL] = [] + @Locked public private(set) var checkoutCalls: [(branch: String, repoPath: URL)] = [] + @Locked public private(set) var createBranchCalls: [(branch: String, repoPath: URL)] = [] + @Locked public private(set) var deleteBranchCalls: [(branch: String, force: Bool, repoPath: URL)] = [] + @Locked public private(set) var mergedBranchCalls: [(branch: String, base: String, repoPath: URL)] = [] public init() {} @@ -227,7 +227,7 @@ public final class MockGhService: GhServiceProtocol, @unchecked Sendable { public var urlToReturn = URL(string: "https://github.com/example/example/pull/1")! public var errorToThrow: Error? - public private(set) var createPullRequestCalls: [(title: String, body: String, base: String?, repoPath: URL)] = [] + @Locked public private(set) var createPullRequestCalls: [(title: String, body: String, base: String?, repoPath: URL)] = [] public init() {} @@ -240,7 +240,7 @@ public final class MockGhService: GhServiceProtocol, @unchecked Sendable { public var ciStatusByBranch: [String: CIStatus] = [:] public var ciErrorToThrow: Error? public var failedLogToReturn = "" - public private(set) var ciStatusCalls: [String] = [] + @Locked public private(set) var ciStatusCalls: [String] = [] public func ciStatus(forBranch branch: String, at repoPath: URL) async throws -> CIStatus? { ciStatusCalls.append(branch) @@ -270,3 +270,25 @@ public final class MockGhService: GhServiceProtocol, @unchecked Sendable { return issue } } + +/// Serializes a recorded-call array. The mocks' async methods run on the +/// cooperative pool, so overlapping calls (e.g. a view model starting a load +/// per selection change) would otherwise append concurrently and corrupt it. +@propertyWrapper +public final class Locked: @unchecked Sendable { + private let lock = NSLock() + private var value: Value + + public init(wrappedValue: Value) { + value = wrappedValue + } + + public var wrappedValue: Value { + get { lock.withLock { value } } + _modify { + lock.lock() + defer { lock.unlock() } + yield &value + } + } +}