native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Revert "Run archive script in a terminal tab instead of a headless process" (#175)

This reverts commit 2e60a6900df9b9a5a78117572725e9bd00bd636e.

authored by

Stefano Bertagno and committed by
GitHub
75827ae2 2e60a690

+201 -804
-2
supacode/Clients/Terminal/TerminalClient.swift
··· 11 11 case ensureInitialTab(Worktree, runSetupScriptIfNew: Bool, focusing: Bool) 12 12 case runScript(Worktree, script: String) 13 13 case stopRunScript(Worktree) 14 - case runBlockingScript(Worktree, kind: BlockingScriptKind, script: String) 15 14 case closeFocusedTab(Worktree) 16 15 case closeFocusedSurface(Worktree) 17 16 case performBindingAction(Worktree, action: String) ··· 33 32 case focusChanged(worktreeID: Worktree.ID, surfaceID: UUID) 34 33 case taskStatusChanged(worktreeID: Worktree.ID, status: WorktreeTaskStatus) 35 34 case runScriptStatusChanged(worktreeID: Worktree.ID, isRunning: Bool) 36 - case blockingScriptCompleted(worktreeID: Worktree.ID, kind: BlockingScriptKind, exitCode: Int?) 37 35 case commandPaletteToggleRequested(worktreeID: Worktree.ID) 38 36 case setupScriptConsumed(worktreeID: Worktree.ID) 39 37 }
+28
supacode/Domain/ArchiveScriptProgress.swift
··· 1 + import Foundation 2 + 3 + nonisolated struct ArchiveScriptProgress: Hashable, Sendable { 4 + var titleText: String 5 + var detailText: String 6 + var commandText: String? 7 + var outputLines: [String] 8 + 9 + init( 10 + titleText: String, 11 + detailText: String, 12 + commandText: String? = nil, 13 + outputLines: [String] = [] 14 + ) { 15 + self.titleText = titleText 16 + self.detailText = detailText 17 + self.commandText = commandText 18 + self.outputLines = outputLines 19 + } 20 + 21 + mutating func appendOutputLine(_ line: String, maxLines: Int) { 22 + detailText = line 23 + outputLines.append(line) 24 + if outputLines.count > maxLines { 25 + outputLines.removeFirst(outputLines.count - maxLines) 26 + } 27 + } 28 + }
-11
supacode/Features/App/Reducer/AppFeature.swift
··· 227 227 } 228 228 ) 229 229 230 - case .repositories(.delegate(.runBlockingScript(let worktree, _, let kind, let script))): 231 - return .run { _ in 232 - await terminalClient.send(.runBlockingScript(worktree, kind: kind, script: script)) 233 - } 234 - 235 230 case .settings(.setSelection(let selection)): 236 231 let resolvedSelection = selection ?? .general 237 232 switch resolvedSelection { ··· 687 682 ) 688 683 case .terminalEvent(.setupScriptConsumed(let worktreeID)): 689 684 return .send(.repositories(.consumeSetupScript(worktreeID))) 690 - 691 - case .terminalEvent(.blockingScriptCompleted(let worktreeID, let kind, let exitCode)): 692 - switch kind { 693 - case .archive: 694 - return .send(.repositories(.archiveScriptCompleted(worktreeID: worktreeID, exitCode: exitCode))) 695 - } 696 685 697 686 case .terminalEvent: 698 687 return .none
+77 -36
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 12 12 static let githubIntegrationRecovery = "repositories.githubIntegrationRecovery" 13 13 static let worktreePromptLoad = "repositories.worktreePromptLoad" 14 14 static let worktreePromptValidation = "repositories.worktreePromptValidation" 15 + static func archiveScript(_ worktreeID: Worktree.ID) -> String { 16 + "repositories.archiveScript.\(worktreeID)" 17 + } 15 18 static func delayedPRRefresh(_ worktreeID: Worktree.ID) -> String { 16 19 "repositories.delayedPRRefresh.\(worktreeID)" 17 20 } 18 21 } 19 22 20 - private nonisolated let repositoriesLogger = SupaLogger("Repositories") 21 23 private nonisolated let githubIntegrationRecoveryInterval: Duration = .seconds(15) 22 24 private nonisolated let worktreeCreationProgressLineLimit = 200 23 25 private nonisolated let worktreeCreationProgressUpdateStride = 20 26 + private nonisolated let archiveScriptProgressLineLimit = 200 24 27 25 28 nonisolated struct WorktreeCreationProgressUpdateThrottle { 26 29 private let stride: Int ··· 72 75 var pendingSetupScriptWorktreeIDs: Set<Worktree.ID> = [] 73 76 var pendingTerminalFocusWorktreeIDs: Set<Worktree.ID> = [] 74 77 var archivingWorktreeIDs: Set<Worktree.ID> = [] 78 + var archiveScriptProgressByWorktreeID: [Worktree.ID: ArchiveScriptProgress] = [:] 75 79 var deletingWorktreeIDs: Set<Worktree.ID> = [] 76 80 var removingRepositoryIDs: Set<Repository.ID> = [] 77 81 var pinnedWorktreeIDs: [Worktree.ID] = [] ··· 185 189 case requestArchiveWorktree(Worktree.ID, Repository.ID) 186 190 case requestArchiveWorktrees([ArchiveWorktreeTarget]) 187 191 case archiveWorktreeConfirmed(Worktree.ID, Repository.ID) 188 - case archiveScriptCompleted(worktreeID: Worktree.ID, exitCode: Int?) 192 + case archiveScriptProgressUpdated(worktreeID: Worktree.ID, progress: ArchiveScriptProgress) 193 + case archiveScriptSucceeded(worktreeID: Worktree.ID, repositoryID: Repository.ID) 194 + case archiveScriptFailed(worktreeID: Worktree.ID, message: String) 189 195 case archiveWorktreeApply(Worktree.ID, Repository.ID) 190 196 case unarchiveWorktree(Worktree.ID) 191 197 case requestDeleteWorktree(Worktree.ID, Repository.ID) ··· 283 289 case repositoriesChanged(IdentifiedArrayOf<Repository>) 284 290 case openRepositorySettings(Repository.ID) 285 291 case worktreeCreated(Worktree) 286 - case runBlockingScript(Worktree, repositoryID: Repository.ID, kind: BlockingScriptKind, script: String) 287 292 } 288 293 289 294 @Dependency(AnalyticsClient.self) private var analyticsClient ··· 1268 1273 state.alert = nil 1269 1274 @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings 1270 1275 let script = repositorySettings.archiveScript 1276 + let commandText = archiveScriptCommand(script) 1271 1277 let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) 1272 1278 if trimmed.isEmpty { 1273 1279 return .send(.archiveWorktreeApply(worktreeID, repositoryID)) 1274 1280 } 1275 1281 state.archivingWorktreeIDs.insert(worktreeID) 1276 - return .send( 1277 - .delegate(.runBlockingScript(worktree, repositoryID: repositoryID, kind: .archive, script: script))) 1282 + state.archiveScriptProgressByWorktreeID[worktreeID] = ArchiveScriptProgress( 1283 + titleText: "Running archive script", 1284 + detailText: "Preparing archive script", 1285 + commandText: commandText 1286 + ) 1287 + let shellClient = shellClient 1288 + let scriptWithEnv = worktree.scriptEnvironmentExportPrefix + script 1289 + return .run { send in 1290 + let envURL = URL(fileURLWithPath: "/usr/bin/env") 1291 + var progress = ArchiveScriptProgress( 1292 + titleText: "Running archive script", 1293 + detailText: "Running archive script", 1294 + commandText: commandText 1295 + ) 1296 + do { 1297 + for try await event in shellClient.runLoginStream( 1298 + envURL, 1299 + ["bash", "-lc", scriptWithEnv], 1300 + worktree.workingDirectory, 1301 + log: false 1302 + ) { 1303 + switch event { 1304 + case .line(let line): 1305 + let text = line.text.trimmingCharacters(in: .whitespacesAndNewlines) 1306 + guard !text.isEmpty else { continue } 1307 + progress.appendOutputLine(text, maxLines: archiveScriptProgressLineLimit) 1308 + await send(.archiveScriptProgressUpdated(worktreeID: worktreeID, progress: progress)) 1309 + case .finished: 1310 + await send(.archiveScriptSucceeded(worktreeID: worktreeID, repositoryID: repositoryID)) 1311 + } 1312 + } 1313 + } catch { 1314 + await send(.archiveScriptFailed(worktreeID: worktreeID, message: error.localizedDescription)) 1315 + } 1316 + } 1317 + .cancellable(id: CancelID.archiveScript(worktreeID), cancelInFlight: true) 1318 + 1319 + case .archiveScriptProgressUpdated(let worktreeID, let progress): 1320 + guard state.archivingWorktreeIDs.contains(worktreeID) else { 1321 + return .none 1322 + } 1323 + state.archiveScriptProgressByWorktreeID[worktreeID] = progress 1324 + return .none 1278 1325 1279 - case .archiveScriptCompleted(let worktreeID, let exitCode): 1326 + case .archiveScriptSucceeded(let worktreeID, let repositoryID): 1280 1327 guard state.archivingWorktreeIDs.contains(worktreeID) else { 1281 1328 return .none 1282 1329 } 1283 1330 state.archivingWorktreeIDs.remove(worktreeID) 1284 - switch exitCode { 1285 - case 0: 1286 - guard let repositoryID = state.repositoryID(containing: worktreeID) else { 1287 - repositoriesLogger.warning( 1288 - "Archive script succeeded but repository not found for worktree \(worktreeID)" 1289 - ) 1290 - state.alert = messageAlert( 1291 - title: "Archive failed", 1292 - message: "The archive script completed successfully, but the worktree could not be found." 1293 - + " It may have been removed." 1294 - ) 1295 - return .none 1296 - } 1297 - return .send(.archiveWorktreeApply(worktreeID, repositoryID)) 1298 - case nil: 1299 - // User cancelled or tab was closed before script completed. 1300 - return .none 1301 - case let code?: 1302 - state.alert = messageAlert( 1303 - title: "Archive script failed", 1304 - message: "\(blockingScriptExitMessage(code))\nCheck the ARCHIVE SCRIPT tab for details." 1305 - ) 1331 + state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) 1332 + return .send(.archiveWorktreeApply(worktreeID, repositoryID)) 1333 + 1334 + case .archiveScriptFailed(let worktreeID, let message): 1335 + guard state.archivingWorktreeIDs.contains(worktreeID) else { 1306 1336 return .none 1307 1337 } 1338 + state.archivingWorktreeIDs.remove(worktreeID) 1339 + state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) 1340 + state.alert = messageAlert(title: "Archive script failed", message: message) 1341 + return .none 1308 1342 1309 1343 case .archiveWorktreeApply(let worktreeID, let repositoryID): 1310 1344 guard let repository = state.repositories[id: repositoryID], ··· 1550 1584 state.pendingWorktrees.removeAll { $0.id == worktreeID } 1551 1585 state.pendingSetupScriptWorktreeIDs.remove(worktreeID) 1552 1586 state.pendingTerminalFocusWorktreeIDs.remove(worktreeID) 1587 + state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) 1553 1588 state.worktreeInfoByID.removeValue(forKey: worktreeID) 1554 1589 state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } 1555 1590 state.archivedWorktreeIDs.removeAll { $0 == worktreeID } ··· 2637 2672 availableWorktreeIDs.contains($0) 2638 2673 } 2639 2674 let filteredArchivingIDs = state.archivingWorktreeIDs 2675 + let filteredArchiveScriptProgress = state.archiveScriptProgressByWorktreeID.filter { 2676 + availableWorktreeIDs.contains($0.key) || filteredArchivingIDs.contains($0.key) 2677 + } 2640 2678 let filteredWorktreeInfo = state.worktreeInfoByID.filter { 2641 2679 availableWorktreeIDs.contains($0.key) 2642 2680 } ··· 2649 2687 state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs 2650 2688 state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs 2651 2689 state.archivingWorktreeIDs = filteredArchivingIDs 2690 + state.archiveScriptProgressByWorktreeID = filteredArchiveScriptProgress 2652 2691 state.worktreeInfoByID = filteredWorktreeInfo 2653 2692 } 2654 2693 } else { ··· 2658 2697 state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs 2659 2698 state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs 2660 2699 state.archivingWorktreeIDs = filteredArchivingIDs 2700 + state.archiveScriptProgressByWorktreeID = filteredArchiveScriptProgress 2661 2701 state.worktreeInfoByID = filteredWorktreeInfo 2662 2702 } 2663 2703 let didPrunePinned = prunePinnedWorktreeIDs(state: &state) ··· 2829 2869 func pendingWorktree(for id: Worktree.ID?) -> PendingWorktree? { 2830 2870 guard let id else { return nil } 2831 2871 return pendingWorktrees.first(where: { $0.id == id }) 2872 + } 2873 + 2874 + func archiveScriptProgress(for id: Worktree.ID?) -> ArchiveScriptProgress? { 2875 + guard let id else { return nil } 2876 + return archiveScriptProgressByWorktreeID[id] 2832 2877 } 2833 2878 2834 2879 func shouldFocusTerminal(for worktreeID: Worktree.ID) -> Bool { ··· 3278 3323 state.pendingSetupScriptWorktreeIDs.remove(worktreeID) 3279 3324 state.pendingTerminalFocusWorktreeIDs.remove(worktreeID) 3280 3325 state.archivingWorktreeIDs.remove(worktreeID) 3326 + state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) 3281 3327 state.deletingWorktreeIDs.remove(worktreeID) 3282 3328 state.worktreeInfoByID.removeValue(forKey: worktreeID) 3283 3329 let didUpdatePinned = state.pinnedWorktreeIDs.contains(worktreeID) ··· 3304 3350 ) 3305 3351 } 3306 3352 3307 - private nonisolated func blockingScriptExitMessage(_ exitCode: Int) -> String { 3308 - switch exitCode { 3309 - case 1: return "Script failed (exit code 1)." 3310 - case 126: return "Permission denied (exit code 126)." 3311 - case 127: return "Command not found (exit code 127)." 3312 - case 129...: return "Script killed by signal \(exitCode - 128) (exit code \(exitCode))." 3313 - default: return "Script exited with code \(exitCode)." 3314 - } 3353 + private nonisolated func archiveScriptCommand(_ script: String) -> String { 3354 + let normalized = script.replacing("\n", with: "\\n") 3355 + return "bash -lc \(shellQuote(normalized))" 3315 3356 } 3316 3357 3317 3358 private nonisolated func worktreeCreateCommand(
+1 -1
supacode/Features/Repositories/Views/SidebarView.swift
··· 87 87 ) -> (() -> Void)? { 88 88 let targets = 89 89 rows 90 - .filter { $0.isRemovable && !$0.isMainWorktree && !$0.isDeleting && !$0.isArchiving } 90 + .filter { $0.isRemovable && !$0.isMainWorktree && !$0.isDeleting } 91 91 .map { 92 92 RepositoriesFeature.ArchiveWorktreeTarget( 93 93 worktreeID: $0.id,
+10 -3
supacode/Features/Repositories/Views/WorktreeDetailView.swift
··· 403 403 ) 404 404 } 405 405 if selectedRow.isArchiving { 406 - // The archive script runs in a terminal tab, so let the 407 - // terminal view show through instead of a loading overlay. 408 - return nil 406 + let progress = repositories.archiveScriptProgress(for: selectedWorktreeID) 407 + return WorktreeLoadingInfo( 408 + name: selectedRow.name, 409 + repositoryName: repositoryName, 410 + state: .archiving, 411 + statusTitle: progress?.titleText ?? selectedRow.name, 412 + statusDetail: progress?.detailText ?? selectedRow.detail, 413 + statusCommand: progress?.commandText, 414 + statusLines: progress?.outputLines ?? [] 415 + ) 409 416 } 410 417 if selectedRow.isPending { 411 418 let pending = repositories.pendingWorktree(for: selectedWorktreeID)
+2 -2
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 109 109 ? { togglePin(for: row.id, isPinned: row.isPinned) } 110 110 : nil 111 111 let archiveAction: (() -> Void)? = 112 - canShowRowActions && !row.isMainWorktree && !row.isArchiving 112 + canShowRowActions && !row.isMainWorktree 113 113 ? { archiveWorktree(row.id) } 114 114 : nil 115 115 let notifications = terminalManager.stateIfExists(for: row.id)?.notifications ?? [] ··· 224 224 let isBulkSelection = contextRows.count > 1 225 225 let archiveTargets = 226 226 contextRows 227 - .filter { !$0.isMainWorktree && !$0.isArchiving } 227 + .filter { !$0.isMainWorktree } 228 228 .map { 229 229 RepositoriesFeature.ArchiveWorktreeTarget( 230 230 worktreeID: $0.id,
-5
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 46 46 _ = state(for: worktree).runScript(script) 47 47 case .stopRunScript(let worktree): 48 48 _ = state(for: worktree).stopRunScript() 49 - case .runBlockingScript(let worktree, let kind, let script): 50 - _ = state(for: worktree).runBlockingScript(kind: kind, script) 51 49 case .closeFocusedTab(let worktree): 52 50 _ = closeFocusedTab(in: worktree) 53 51 case .closeFocusedSurface(let worktree): ··· 163 161 } 164 162 state.onRunScriptStatusChanged = { [weak self] isRunning in 165 163 self?.emit(.runScriptStatusChanged(worktreeID: worktree.id, isRunning: isRunning)) 166 - } 167 - state.onBlockingScriptCompleted = { [weak self] kind, exitCode in 168 - self?.emit(.blockingScriptCompleted(worktreeID: worktree.id, kind: kind, exitCode: exitCode)) 169 164 } 170 165 state.onCommandPaletteToggle = { [weak self] in 171 166 self?.emit(.commandPaletteToggleRequested(worktreeID: worktree.id))
-18
supacode/Features/Terminal/Models/BlockingScriptKind.swift
··· 1 - /// Identifies the kind of script that blocks a worktree state transition 2 - /// and runs in a dedicated terminal tab. Adding a new case requires handling 3 - /// in `AppFeature`'s `.blockingScriptCompleted` event router. 4 - enum BlockingScriptKind: Hashable, Sendable { 5 - case archive 6 - 7 - var tabTitle: String { 8 - switch self { 9 - case .archive: return "ARCHIVE SCRIPT" 10 - } 11 - } 12 - 13 - var tabIcon: String { 14 - switch self { 15 - case .archive: return "archivebox.fill" 16 - } 17 - } 18 - }
-146
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 5 5 import Observation 6 6 import Sharing 7 7 8 - private let blockingScriptLogger = SupaLogger("BlockingScript") 9 - 10 8 @MainActor 11 9 @Observable 12 10 final class WorktreeTerminalState { ··· 25 23 private var focusedSurfaceIdByTab: [TerminalTabID: UUID] = [:] 26 24 var tabIsRunningById: [TerminalTabID: Bool] = [:] 27 25 private var runScriptTabId: TerminalTabID? 28 - private var blockingScripts: [TerminalTabID: BlockingScriptKind] = [:] 29 - private var blockingScriptCommandFinished: Set<TerminalTabID> = [] 30 - private var blockingScriptLastCommandExitCode: [TerminalTabID: Int] = [:] 31 - private var lastBlockingScriptTabByKind: [BlockingScriptKind: TerminalTabID] = [:] 32 26 private var pendingSetupScript: Bool 33 27 private var isEnsuringInitialTab = false 34 28 private var lastReportedTaskStatus: WorktreeTaskStatus? ··· 46 40 var onFocusChanged: ((UUID) -> Void)? 47 41 var onTaskStatusChanged: ((WorktreeTaskStatus) -> Void)? 48 42 var onRunScriptStatusChanged: ((Bool) -> Void)? 49 - var onBlockingScriptCompleted: ((BlockingScriptKind, Int?) -> Void)? 50 43 var onCommandPaletteToggle: (() -> Void)? 51 44 var onSetupScriptConsumed: (() -> Void)? 52 45 ··· 164 157 return true 165 158 } 166 159 167 - @discardableResult 168 - func runBlockingScript(kind: BlockingScriptKind, _ script: String) -> TerminalTabID? { 169 - guard let input = blockingScriptInput(script) else { return nil } 170 - // Close any previous tab of the same kind (active or lingering 171 - // from a completed/cancelled run). Clear tracking state first 172 - // so closeTab doesn't fire a premature completion callback. 173 - if let active = blockingScripts.first(where: { $0.value == kind })?.key { 174 - blockingScripts.removeValue(forKey: active) 175 - blockingScriptCommandFinished.remove(active) 176 - blockingScriptLastCommandExitCode.removeValue(forKey: active) 177 - lastBlockingScriptTabByKind.removeValue(forKey: kind) 178 - closeTab(active) 179 - } else if let lingering = lastBlockingScriptTabByKind.removeValue(forKey: kind) { 180 - closeTab(lingering) 181 - } 182 - let tabId = createTab( 183 - TabCreation( 184 - title: kind.tabTitle, 185 - icon: kind.tabIcon, 186 - isTitleLocked: true, 187 - initialInput: input, 188 - focusing: true, 189 - inheritingFromSurfaceId: currentFocusedSurfaceId(), 190 - context: GHOSTTY_SURFACE_CONTEXT_TAB 191 - ) 192 - ) 193 - guard let tabId else { 194 - blockingScriptLogger.warning("Failed to create \(kind.tabTitle) tab for worktree \(worktree.id)") 195 - onBlockingScriptCompleted?(kind, nil) 196 - return nil 197 - } 198 - blockingScripts[tabId] = kind 199 - lastBlockingScriptTabByKind[kind] = tabId 200 - blockingScriptLogger.info("Started \(kind.tabTitle) for worktree \(worktree.id)") 201 - return tabId 202 - } 203 - 204 160 private struct TabCreation: Equatable { 205 161 let title: String 206 162 let icon: String? ··· 346 302 347 303 func closeTab(_ tabId: TerminalTabID) { 348 304 let wasRunScriptTab = tabId == runScriptTabId 349 - let closedBlockingKind = blockingScripts.removeValue(forKey: tabId) 350 - // Clear lingering tab tracking for completed or non-blocking tabs. 351 - for (kind, tracked) in lastBlockingScriptTabByKind where tracked == tabId { 352 - lastBlockingScriptTabByKind.removeValue(forKey: kind) 353 - } 354 305 removeTree(for: tabId) 355 306 tabManager.closeTab(tabId) 356 307 if let selected = tabManager.selectedTabId { ··· 361 312 emitTaskStatusIfChanged() 362 313 if wasRunScriptTab { 363 314 setRunScriptTabId(nil) 364 - } 365 - if let closedBlockingKind { 366 - blockingScriptCommandFinished.remove(tabId) 367 - blockingScriptLastCommandExitCode.removeValue(forKey: tabId) 368 - onBlockingScriptCompleted?(closedBlockingKind, nil) 369 315 } 370 316 onTabClosed?() 371 317 } ··· 535 481 focusedSurfaceIdByTab.removeAll() 536 482 tabIsRunningById.removeAll() 537 483 setRunScriptTabId(nil) 538 - let pendingKinds = Set(blockingScripts.values) 539 - blockingScripts.removeAll() 540 - blockingScriptCommandFinished.removeAll() 541 - blockingScriptLastCommandExitCode.removeAll() 542 - lastBlockingScriptTabByKind.removeAll() 543 - for kind in pendingKinds { 544 - onBlockingScriptCompleted?(kind, nil) 545 - } 546 484 tabManager.closeAll() 547 485 } 548 486 ··· 615 553 return worktree.scriptEnvironmentExportPrefix + trimmed + "\n" 616 554 } 617 555 618 - // Appends `exit $?` so the shell terminates with the script's exit code. 619 - // Without this, the interactive shell stays alive after the script finishes 620 - // and GHOSTTY_ACTION_SHOW_CHILD_EXITED never fires for completion detection. 621 - private func blockingScriptInput(_ script: String) -> String? { 622 - let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) 623 - guard !trimmed.isEmpty else { return nil } 624 - return worktree.scriptEnvironmentExportPrefix + "(\n" + trimmed + "\n)\nexit $?\n" 625 - } 626 - 627 - // Detects signal-based termination (e.g. Ctrl+C = exit code 130) 628 - // and reports failure immediately without waiting for SHOW_CHILD_EXITED, 629 - // since the exit code is already available from COMMAND_FINISHED. 630 - private func handleBlockingScriptCommandFinished(tabId: TerminalTabID, exitCode: Int?) { 631 - guard let kind = blockingScripts[tabId] else { return } 632 - blockingScriptCommandFinished.insert(tabId) 633 - if let exitCode { 634 - blockingScriptLastCommandExitCode[tabId] = exitCode 635 - } 636 - guard let exitCode, exitCode >= 128 else { return } 637 - blockingScriptLogger.info("\(kind.tabTitle) interrupted by signal (exit code \(exitCode))") 638 - blockingScripts.removeValue(forKey: tabId) 639 - blockingScriptCommandFinished.remove(tabId) 640 - blockingScriptLastCommandExitCode.removeValue(forKey: tabId) 641 - Task { @MainActor [weak self] in 642 - // Bail out if a new script of the same kind started before this ran. 643 - guard self?.blockingScripts.values.contains(kind) != true else { 644 - blockingScriptLogger.info("\(kind.tabTitle) completion superseded by new script of same kind") 645 - return 646 - } 647 - self?.onBlockingScriptCompleted?(kind, exitCode) 648 - } 649 - } 650 - 651 - // Fires when the shell process exits. The completion callback is dispatched 652 - // asynchronously to avoid reentrancy into Ghostty's callback during surface teardown. 653 - private func handleBlockingScriptChildExited(tabId: TerminalTabID, exitCode: UInt32) { 654 - guard let kind = blockingScripts.removeValue(forKey: tabId) else { return } 655 - guard blockingScriptCommandFinished.remove(tabId) != nil else { 656 - blockingScriptLastCommandExitCode.removeValue(forKey: tabId) 657 - // No command ran to completion — user pressed Ctrl+D or 658 - // the shell exited before the script ran. Treat as cancellation. 659 - blockingScriptLogger.info("\(kind.tabTitle) cancelled (no command finished before child exit)") 660 - Task { @MainActor [weak self] in 661 - guard self?.blockingScripts.values.contains(kind) != true else { 662 - blockingScriptLogger.info("\(kind.tabTitle) completion superseded by new script of same kind") 663 - return 664 - } 665 - self?.onBlockingScriptCompleted?(kind, nil) 666 - } 667 - return 668 - } 669 - let code = blockingScriptLastCommandExitCode.removeValue(forKey: tabId) ?? Int(exitCode) 670 - blockingScriptLogger.info("\(kind.tabTitle) completed with exit code \(code)") 671 - Task { @MainActor [weak self] in 672 - // Bail out if a new script of the same kind started before this ran. 673 - guard self?.blockingScripts.values.contains(kind) != true else { 674 - blockingScriptLogger.info("\(kind.tabTitle) completion superseded by new script of same kind") 675 - return 676 - } 677 - self?.onBlockingScriptCompleted?(kind, code) 678 - if code == 0, self?.trees[tabId] != nil { 679 - self?.closeTab(tabId) 680 - } 681 - } 682 - } 683 - 684 556 private func setRunScriptTabId(_ tabId: TerminalTabID?) { 685 557 let wasRunning = runScriptTabId != nil 686 558 runScriptTabId = tabId ··· 735 607 view.bridge.onProgressReport = { [weak self] _ in 736 608 guard let self else { return } 737 609 self.updateRunningState(for: tabId) 738 - } 739 - view.bridge.onCommandFinished = { [weak self] exitCode in 740 - guard let self else { return } 741 - self.handleBlockingScriptCommandFinished(tabId: tabId, exitCode: exitCode) 742 - } 743 - view.bridge.onChildExited = { [weak self] exitCode in 744 - guard let self else { return } 745 - self.handleBlockingScriptChildExited(tabId: tabId, exitCode: exitCode) 746 610 } 747 611 view.bridge.onDesktopNotification = { [weak self, weak view] title, body in 748 612 guard let self, let view else { return } ··· 988 852 tabManager.closeTab(tabId) 989 853 if tabId == runScriptTabId { 990 854 setRunScriptTabId(nil) 991 - } 992 - if let kind = blockingScripts.removeValue(forKey: tabId) { 993 - blockingScriptCommandFinished.remove(tabId) 994 - blockingScriptLastCommandExitCode.removeValue(forKey: tabId) 995 - lastBlockingScriptTabByKind.removeValue(forKey: kind) 996 - onBlockingScriptCompleted?(kind, nil) 997 - } else { 998 - for (kind, tracked) in lastBlockingScriptTabByKind where tracked == tabId { 999 - lastBlockingScriptTabByKind.removeValue(forKey: kind) 1000 - } 1001 855 } 1002 856 return 1003 857 }
+1 -9
supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift
··· 16 16 var onMoveTab: ((ghostty_action_move_tab_s) -> Bool)? 17 17 var onCommandPaletteToggle: (() -> Bool)? 18 18 var onProgressReport: ((ghostty_action_progress_report_state_e) -> Void)? 19 - // Used by blocking script completion detection in WorktreeTerminalState. 20 - // Both callbacks are set on every surface but guarded by the 21 - // blockingScripts dict in the handlers. 22 - var onCommandFinished: ((Int?) -> Void)? 23 - var onChildExited: ((UInt32) -> Void)? 24 19 var onDesktopNotification: ((String, String) -> Void)? 25 20 private var progressResetTask: Task<Void, Never>? 26 21 ··· 234 229 235 230 case GHOSTTY_ACTION_COMMAND_FINISHED: 236 231 let info = action.action.command_finished 237 - let exitCode = info.exit_code == -1 ? nil : Int(info.exit_code) 238 - state.commandExitCode = exitCode 232 + state.commandExitCode = info.exit_code == -1 ? nil : Int(info.exit_code) 239 233 state.commandDuration = info.duration 240 - onCommandFinished?(exitCode) 241 234 return true 242 235 243 236 case GHOSTTY_ACTION_SHOW_CHILD_EXITED: 244 237 let info = action.action.child_exited 245 238 state.childExitCode = info.exit_code 246 239 state.childExitTimeMs = info.timetime_ms 247 - onChildExited?(info.exit_code) 248 240 return true 249 241 250 242 case GHOSTTY_ACTION_READONLY:
+82 -304
supacodeTests/RepositoriesFeatureTests.swift
··· 946 946 await store.receive(\.delegate.selectedWorktreeChanged) 947 947 } 948 948 949 - @Test(.dependencies) func archiveWorktreeConfirmedDelegatesArchiveScript() async { 949 + @Test(.dependencies) func archiveWorktreeConfirmedRunsArchiveScriptAndShowsProgress() async { 950 950 let repoRoot = "/tmp/repo" 951 951 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 952 952 let featureWorktree = makeWorktree( ··· 963 963 } 964 964 let store = TestStore(initialState: state) { 965 965 RepositoriesFeature() 966 + } withDependencies: { 967 + $0.shellClient.runLoginStreamImpl = { _, _, _, _ in 968 + AsyncThrowingStream { continuation in 969 + continuation.yield(.line(ShellStreamLine(source: .stdout, text: "syncing"))) 970 + continuation.yield(.line(ShellStreamLine(source: .stdout, text: "done"))) 971 + continuation.yield(.finished(ShellOutput(stdout: "syncing\ndone", stderr: "", exitCode: 0))) 972 + continuation.finish() 973 + } 974 + } 966 975 } 967 976 968 977 await store.send(.archiveWorktreeConfirmed(featureWorktree.id, repository.id)) { 969 978 $0.archivingWorktreeIDs = [featureWorktree.id] 979 + $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( 980 + titleText: "Running archive script", 981 + detailText: "Preparing archive script", 982 + commandText: "bash -lc 'echo syncing\\necho done'" 983 + ) 970 984 } 971 - await store.receive(\.delegate.runBlockingScript) 972 - } 973 - 974 - @Test(.dependencies) func archiveScriptCompletedSuccessArchivesWorktree() async { 975 - let repoRoot = "/tmp/repo" 976 - let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 977 - let featureWorktree = makeWorktree( 978 - id: "\(repoRoot)/feature", 979 - name: "feature", 980 - repoRoot: repoRoot 981 - ) 982 - let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 983 - var state = makeState(repositories: [repository]) 984 - state.archivingWorktreeIDs = [featureWorktree.id] 985 - let store = TestStore(initialState: state) { 986 - RepositoriesFeature() 985 + await store.receive(\.archiveScriptProgressUpdated) { 986 + $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( 987 + titleText: "Running archive script", 988 + detailText: "syncing", 989 + commandText: "bash -lc 'echo syncing\\necho done'", 990 + outputLines: ["syncing"] 991 + ) 992 + } 993 + await store.receive(\.archiveScriptProgressUpdated) { 994 + $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( 995 + titleText: "Running archive script", 996 + detailText: "done", 997 + commandText: "bash -lc 'echo syncing\\necho done'", 998 + outputLines: ["syncing", "done"] 999 + ) 987 1000 } 988 - 989 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0)) { 1001 + await store.receive(\.archiveScriptSucceeded) { 990 1002 $0.archivingWorktreeIDs = [] 1003 + $0.archiveScriptProgressByWorktreeID = [:] 991 1004 } 992 1005 await store.receive(\.archiveWorktreeApply) { 993 1006 $0.archivedWorktreeIDs = [featureWorktree.id] ··· 995 1008 await store.receive(\.delegate.repositoriesChanged) 996 1009 } 997 1010 998 - @Test(.dependencies) func archiveScriptCompletedFailureShowsAlert() async { 1011 + @Test(.dependencies) func archiveWorktreeConfirmedScriptFailureBlocksArchive() async { 999 1012 let repoRoot = "/tmp/repo" 1000 1013 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1001 1014 let featureWorktree = makeWorktree( ··· 1004 1017 repoRoot: repoRoot 1005 1018 ) 1006 1019 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1007 - var state = makeState(repositories: [repository]) 1008 - state.archivingWorktreeIDs = [featureWorktree.id] 1009 - let store = TestStore(initialState: state) { 1020 + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 1021 + $repositorySettings.withLock { 1022 + $0.archiveScript = "exit 7" 1023 + } 1024 + let store = TestStore(initialState: makeState(repositories: [repository])) { 1010 1025 RepositoriesFeature() 1026 + } withDependencies: { 1027 + $0.shellClient.runLoginStreamImpl = { _, _, _, _ in 1028 + AsyncThrowingStream { continuation in 1029 + continuation.finish( 1030 + throwing: ShellClientError( 1031 + command: "bash -lc exit 7", 1032 + stdout: "", 1033 + stderr: "fail", 1034 + exitCode: 7 1035 + ) 1036 + ) 1037 + } 1038 + } 1011 1039 } 1012 1040 1013 1041 let expectedAlert = AlertState<RepositoriesFeature.Alert> { ··· 1017 1045 TextState("OK") 1018 1046 } 1019 1047 } message: { 1020 - TextState("Script exited with code 7.\nCheck the ARCHIVE SCRIPT tab for details.") 1048 + TextState("Command failed: bash -lc exit 7\nstderr:\nfail") 1021 1049 } 1022 1050 1023 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 7)) { 1051 + await store.send(.archiveWorktreeConfirmed(featureWorktree.id, repository.id)) { 1052 + $0.archivingWorktreeIDs = [featureWorktree.id] 1053 + $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( 1054 + titleText: "Running archive script", 1055 + detailText: "Preparing archive script", 1056 + commandText: "bash -lc 'exit 7'" 1057 + ) 1058 + } 1059 + await store.receive(\.archiveScriptFailed) { 1024 1060 $0.archivingWorktreeIDs = [] 1061 + $0.archiveScriptProgressByWorktreeID = [:] 1025 1062 $0.alert = expectedAlert 1026 1063 } 1027 1064 #expect(store.state.archivedWorktreeIDs.isEmpty) 1028 1065 } 1029 1066 1030 - @Test func archiveScriptCompletedCancellationClearsState() async { 1067 + @Test func archiveScriptSucceededIgnoredWhenNotArchiving() async { 1031 1068 let repoRoot = "/tmp/repo" 1032 1069 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1033 1070 let featureWorktree = makeWorktree( ··· 1036 1073 repoRoot: repoRoot 1037 1074 ) 1038 1075 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1039 - var state = makeState(repositories: [repository]) 1040 - state.archivingWorktreeIDs = [featureWorktree.id] 1041 - let store = TestStore(initialState: state) { 1076 + let store = TestStore(initialState: makeState(repositories: [repository])) { 1042 1077 RepositoriesFeature() 1043 1078 } 1044 1079 1045 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: nil)) { 1046 - $0.archivingWorktreeIDs = [] 1047 - } 1048 - #expect(store.state.alert == nil) 1080 + await store.send(.archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) 1049 1081 #expect(store.state.archivedWorktreeIDs.isEmpty) 1050 1082 } 1051 1083 1052 - @Test func archiveScriptCompletedIgnoredWhenNotArchiving() async { 1084 + @Test func archiveScriptFailedIgnoredWhenNotArchiving() async { 1053 1085 let repoRoot = "/tmp/repo" 1054 1086 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1055 1087 let featureWorktree = makeWorktree( ··· 1062 1094 RepositoriesFeature() 1063 1095 } 1064 1096 1065 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0)) 1097 + await store.send(.archiveScriptFailed(worktreeID: featureWorktree.id, message: "late failure")) 1098 + #expect(store.state.alert == nil) 1066 1099 #expect(store.state.archivedWorktreeIDs.isEmpty) 1067 1100 } 1068 1101 ··· 1078 1111 let reloadedRepository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1079 1112 var state = makeState(repositories: [repository]) 1080 1113 state.archivingWorktreeIDs = [featureWorktree.id] 1114 + state.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( 1115 + titleText: "Running archive script", 1116 + detailText: "still running" 1117 + ) 1081 1118 let store = TestStore(initialState: state) { 1082 1119 RepositoriesFeature() 1083 1120 } ··· 1092 1129 ) 1093 1130 ) 1094 1131 #expect(store.state.archivingWorktreeIDs.contains(featureWorktree.id)) 1132 + #expect(store.state.archiveScriptProgressByWorktreeID[featureWorktree.id] != nil) 1095 1133 1096 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0)) 1134 + await store.send(.archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) 1097 1135 #expect(store.state.archivingWorktreeIDs.isEmpty) 1136 + #expect(store.state.archiveScriptProgressByWorktreeID.isEmpty) 1098 1137 } 1099 1138 1100 1139 @Test func repositoriesLoadedKeepsArchiveInFlightUntilFailureCompletion() async { ··· 1109 1148 let reloadedRepository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1110 1149 var state = makeState(repositories: [repository]) 1111 1150 state.archivingWorktreeIDs = [featureWorktree.id] 1151 + state.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( 1152 + titleText: "Running archive script", 1153 + detailText: "still running" 1154 + ) 1112 1155 let store = TestStore(initialState: state) { 1113 1156 RepositoriesFeature() 1114 1157 } ··· 1123 1166 ) 1124 1167 ) 1125 1168 #expect(store.state.archivingWorktreeIDs.contains(featureWorktree.id)) 1169 + #expect(store.state.archiveScriptProgressByWorktreeID[featureWorktree.id] != nil) 1126 1170 1127 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 1)) 1171 + await store.send(.archiveScriptFailed(worktreeID: featureWorktree.id, message: "script failed")) 1128 1172 #expect(store.state.archivingWorktreeIDs.isEmpty) 1173 + #expect(store.state.archiveScriptProgressByWorktreeID.isEmpty) 1129 1174 #expect(store.state.alert != nil) 1130 - } 1131 - 1132 - // MARK: - Archive script exit code coverage 1133 - 1134 - @Test func archiveScriptCompletedExitCode1ShowsGenericFailure() async { 1135 - let repoRoot = "/tmp/repo" 1136 - let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1137 - let featureWorktree = makeWorktree( 1138 - id: "\(repoRoot)/feature", 1139 - name: "feature", 1140 - repoRoot: repoRoot 1141 - ) 1142 - let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1143 - var state = makeState(repositories: [repository]) 1144 - state.archivingWorktreeIDs = [featureWorktree.id] 1145 - let store = TestStore(initialState: state) { 1146 - RepositoriesFeature() 1147 - } 1148 - 1149 - let expectedAlert = AlertState<RepositoriesFeature.Alert> { 1150 - TextState("Archive script failed") 1151 - } actions: { 1152 - ButtonState(role: .cancel) { 1153 - TextState("OK") 1154 - } 1155 - } message: { 1156 - TextState("Script failed (exit code 1).\nCheck the ARCHIVE SCRIPT tab for details.") 1157 - } 1158 - 1159 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 1)) { 1160 - $0.archivingWorktreeIDs = [] 1161 - $0.alert = expectedAlert 1162 - } 1163 - #expect(store.state.archivedWorktreeIDs.isEmpty) 1164 - } 1165 - 1166 - @Test func archiveScriptCompletedExitCode126ShowsPermissionDenied() async { 1167 - let repoRoot = "/tmp/repo" 1168 - let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1169 - let featureWorktree = makeWorktree( 1170 - id: "\(repoRoot)/feature", 1171 - name: "feature", 1172 - repoRoot: repoRoot 1173 - ) 1174 - let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1175 - var state = makeState(repositories: [repository]) 1176 - state.archivingWorktreeIDs = [featureWorktree.id] 1177 - let store = TestStore(initialState: state) { 1178 - RepositoriesFeature() 1179 - } 1180 - 1181 - let expectedAlert = AlertState<RepositoriesFeature.Alert> { 1182 - TextState("Archive script failed") 1183 - } actions: { 1184 - ButtonState(role: .cancel) { 1185 - TextState("OK") 1186 - } 1187 - } message: { 1188 - TextState("Permission denied (exit code 126).\nCheck the ARCHIVE SCRIPT tab for details.") 1189 - } 1190 - 1191 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 126)) { 1192 - $0.archivingWorktreeIDs = [] 1193 - $0.alert = expectedAlert 1194 - } 1195 - } 1196 - 1197 - @Test func archiveScriptCompletedExitCode127ShowsCommandNotFound() async { 1198 - let repoRoot = "/tmp/repo" 1199 - let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1200 - let featureWorktree = makeWorktree( 1201 - id: "\(repoRoot)/feature", 1202 - name: "feature", 1203 - repoRoot: repoRoot 1204 - ) 1205 - let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1206 - var state = makeState(repositories: [repository]) 1207 - state.archivingWorktreeIDs = [featureWorktree.id] 1208 - let store = TestStore(initialState: state) { 1209 - RepositoriesFeature() 1210 - } 1211 - 1212 - let expectedAlert = AlertState<RepositoriesFeature.Alert> { 1213 - TextState("Archive script failed") 1214 - } actions: { 1215 - ButtonState(role: .cancel) { 1216 - TextState("OK") 1217 - } 1218 - } message: { 1219 - TextState("Command not found (exit code 127).\nCheck the ARCHIVE SCRIPT tab for details.") 1220 - } 1221 - 1222 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 127)) { 1223 - $0.archivingWorktreeIDs = [] 1224 - $0.alert = expectedAlert 1225 - } 1226 - } 1227 - 1228 - @Test func archiveScriptCompletedExitCode130ShowsSignalKilled() async { 1229 - let repoRoot = "/tmp/repo" 1230 - let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1231 - let featureWorktree = makeWorktree( 1232 - id: "\(repoRoot)/feature", 1233 - name: "feature", 1234 - repoRoot: repoRoot 1235 - ) 1236 - let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1237 - var state = makeState(repositories: [repository]) 1238 - state.archivingWorktreeIDs = [featureWorktree.id] 1239 - let store = TestStore(initialState: state) { 1240 - RepositoriesFeature() 1241 - } 1242 - 1243 - let expectedAlert = AlertState<RepositoriesFeature.Alert> { 1244 - TextState("Archive script failed") 1245 - } actions: { 1246 - ButtonState(role: .cancel) { 1247 - TextState("OK") 1248 - } 1249 - } message: { 1250 - TextState("Script killed by signal 2 (exit code 130).\nCheck the ARCHIVE SCRIPT tab for details.") 1251 - } 1252 - 1253 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 130)) { 1254 - $0.archivingWorktreeIDs = [] 1255 - $0.alert = expectedAlert 1256 - } 1257 - } 1258 - 1259 - @Test func archiveScriptCompletedExitCode137ShowsSIGKILL() async { 1260 - let repoRoot = "/tmp/repo" 1261 - let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1262 - let featureWorktree = makeWorktree( 1263 - id: "\(repoRoot)/feature", 1264 - name: "feature", 1265 - repoRoot: repoRoot 1266 - ) 1267 - let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1268 - var state = makeState(repositories: [repository]) 1269 - state.archivingWorktreeIDs = [featureWorktree.id] 1270 - let store = TestStore(initialState: state) { 1271 - RepositoriesFeature() 1272 - } 1273 - 1274 - let expectedAlert = AlertState<RepositoriesFeature.Alert> { 1275 - TextState("Archive script failed") 1276 - } actions: { 1277 - ButtonState(role: .cancel) { 1278 - TextState("OK") 1279 - } 1280 - } message: { 1281 - TextState("Script killed by signal 9 (exit code 137).\nCheck the ARCHIVE SCRIPT tab for details.") 1282 - } 1283 - 1284 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 137)) { 1285 - $0.archivingWorktreeIDs = [] 1286 - $0.alert = expectedAlert 1287 - } 1288 - } 1289 - 1290 - @Test(.dependencies) func archiveWorktreeConfirmedEmptyScriptSkipsToApply() async { 1291 - let repoRoot = "/tmp/repo" 1292 - let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1293 - let featureWorktree = makeWorktree( 1294 - id: "\(repoRoot)/feature", 1295 - name: "feature", 1296 - repoRoot: repoRoot 1297 - ) 1298 - let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1299 - @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 1300 - $repositorySettings.withLock { 1301 - $0.archiveScript = " \n " 1302 - } 1303 - let store = TestStore(initialState: makeState(repositories: [repository])) { 1304 - RepositoriesFeature() 1305 - } 1306 - store.exhaustivity = .off 1307 - 1308 - await store.send(.archiveWorktreeConfirmed(featureWorktree.id, repository.id)) 1309 - await store.receive(\.archiveWorktreeApply) { 1310 - $0.archivedWorktreeIDs = [featureWorktree.id] 1311 - } 1312 - } 1313 - 1314 - @Test func archiveScriptCompletedDoesNotArchiveOnNonZeroExit() async { 1315 - let repoRoot = "/tmp/repo" 1316 - let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1317 - let featureWorktree = makeWorktree( 1318 - id: "\(repoRoot)/feature", 1319 - name: "feature", 1320 - repoRoot: repoRoot 1321 - ) 1322 - let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1323 - var state = makeState(repositories: [repository]) 1324 - state.archivingWorktreeIDs = [featureWorktree.id] 1325 - let store = TestStore(initialState: state) { 1326 - RepositoriesFeature() 1327 - } 1328 - 1329 - // Exit code 1 must NOT trigger archiveWorktreeApply. 1330 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 1)) { 1331 - $0.archivingWorktreeIDs = [] 1332 - $0.alert = AlertState { 1333 - TextState("Archive script failed") 1334 - } actions: { 1335 - ButtonState(role: .cancel) { 1336 - TextState("OK") 1337 - } 1338 - } message: { 1339 - TextState("Script failed (exit code 1).\nCheck the ARCHIVE SCRIPT tab for details.") 1340 - } 1341 - } 1342 - #expect(store.state.archivedWorktreeIDs.isEmpty) 1343 - } 1344 - 1345 - @Test func archiveScriptCancellationDoesNotArchive() async { 1346 - let repoRoot = "/tmp/repo" 1347 - let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1348 - let featureWorktree = makeWorktree( 1349 - id: "\(repoRoot)/feature", 1350 - name: "feature", 1351 - repoRoot: repoRoot 1352 - ) 1353 - let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1354 - var state = makeState(repositories: [repository]) 1355 - state.archivingWorktreeIDs = [featureWorktree.id] 1356 - let store = TestStore(initialState: state) { 1357 - RepositoriesFeature() 1358 - } 1359 - 1360 - // Nil exit code (Ctrl+D, tab close) must NOT trigger archiveWorktreeApply. 1361 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: nil)) { 1362 - $0.archivingWorktreeIDs = [] 1363 - } 1364 - #expect(store.state.archivedWorktreeIDs.isEmpty) 1365 - #expect(store.state.alert == nil) 1366 - } 1367 - 1368 - @Test func archiveScriptCompletedSuccessOnlyWhenExitCodeZero() async { 1369 - let repoRoot = "/tmp/repo" 1370 - let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1371 - let featureWorktree = makeWorktree( 1372 - id: "\(repoRoot)/feature", 1373 - name: "feature", 1374 - repoRoot: repoRoot 1375 - ) 1376 - let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1377 - 1378 - // Test that ONLY exit code 0 leads to archival. 1379 - for exitCode in [1, 2, 126, 127, 128, 130, 137, 255] { 1380 - var state = makeState(repositories: [repository]) 1381 - state.archivingWorktreeIDs = [featureWorktree.id] 1382 - let store = TestStore(initialState: state) { 1383 - RepositoriesFeature() 1384 - } 1385 - store.exhaustivity = .off 1386 - 1387 - await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: exitCode)) 1388 - #expect( 1389 - store.state.archivedWorktreeIDs.isEmpty, 1390 - "Exit code \(exitCode) should NOT archive the worktree" 1391 - ) 1392 - #expect( 1393 - store.state.alert != nil, 1394 - "Exit code \(exitCode) should show an alert" 1395 - ) 1396 - } 1397 1175 } 1398 1176 1399 1177 @Test func requestRenameBranchWithEmptyNameShowsAlert() async {
-267
supacodeTests/WorktreeTerminalManagerTests.swift
··· 202 202 #expect(manager.hasUnseenNotifications(for: worktree.id) == false) 203 203 } 204 204 205 - @Test func blockingScriptCompletionPrefersCommandFinishedExitCode() async { 206 - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 207 - let worktree = makeWorktree() 208 - let stream = manager.eventStream() 209 - 210 - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "exit 1")) 211 - 212 - guard let state = manager.stateIfExists(for: worktree.id), 213 - let tabId = state.tabManager.selectedTabId, 214 - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() 215 - else { 216 - Issue.record("Expected blocking script tab and surface") 217 - return 218 - } 219 - 220 - surface.bridge.onCommandFinished?(1) 221 - surface.bridge.onChildExited?(0) 222 - 223 - let event = await nextEvent(stream) { event in 224 - if case .blockingScriptCompleted = event { 225 - return true 226 - } 227 - return false 228 - } 229 - 230 - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 1)) 231 - } 232 - 233 - @Test func blockingScriptCompletionUsesLatestCommandFinishedExitCode() async { 234 - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 235 - let worktree = makeWorktree() 236 - let stream = manager.eventStream() 237 - 238 - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) 239 - 240 - guard let state = manager.stateIfExists(for: worktree.id), 241 - let tabId = state.tabManager.selectedTabId, 242 - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() 243 - else { 244 - Issue.record("Expected blocking script tab and surface") 245 - return 246 - } 247 - 248 - surface.bridge.onCommandFinished?(0) 249 - surface.bridge.onCommandFinished?(1) 250 - surface.bridge.onChildExited?(0) 251 - 252 - let event = await nextEvent(stream) { event in 253 - if case .blockingScriptCompleted = event { 254 - return true 255 - } 256 - return false 257 - } 258 - 259 - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 1)) 260 - } 261 - 262 - @Test func blockingScriptCompletionFallsBackToChildExitCodeWhenCommandFinishedNil() async { 263 - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 264 - let worktree = makeWorktree() 265 - let stream = manager.eventStream() 266 - 267 - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) 268 - 269 - guard let state = manager.stateIfExists(for: worktree.id), 270 - let tabId = state.tabManager.selectedTabId, 271 - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() 272 - else { 273 - Issue.record("Expected blocking script tab and surface") 274 - return 275 - } 276 - 277 - surface.bridge.onCommandFinished?(nil) 278 - surface.bridge.onChildExited?(23) 279 - 280 - let event = await nextEvent(stream) { event in 281 - if case .blockingScriptCompleted = event { 282 - return true 283 - } 284 - return false 285 - } 286 - 287 - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 23)) 288 - } 289 - 290 - @Test func blockingScriptChildExitWithoutCommandFinishedIsCancellation() async { 291 - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 292 - let worktree = makeWorktree() 293 - let stream = manager.eventStream() 294 - 295 - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) 296 - 297 - guard let state = manager.stateIfExists(for: worktree.id), 298 - let tabId = state.tabManager.selectedTabId, 299 - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() 300 - else { 301 - Issue.record("Expected blocking script tab and surface") 302 - return 303 - } 304 - 305 - surface.bridge.onChildExited?(1) 306 - 307 - let event = await nextEvent(stream) { event in 308 - if case .blockingScriptCompleted = event { 309 - return true 310 - } 311 - return false 312 - } 313 - 314 - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: nil)) 315 - } 316 - 317 - @Test func blockingScriptSignalBasedTerminationReportsImmediately() async { 318 - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 319 - let worktree = makeWorktree() 320 - let stream = manager.eventStream() 321 - 322 - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "sleep 10")) 323 - 324 - guard let state = manager.stateIfExists(for: worktree.id), 325 - let tabId = state.tabManager.selectedTabId, 326 - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() 327 - else { 328 - Issue.record("Expected blocking script tab and surface") 329 - return 330 - } 331 - 332 - // Ctrl+C sends exit code 130 (128 + SIGINT=2) via COMMAND_FINISHED. 333 - // Completion should fire immediately without waiting for onChildExited. 334 - surface.bridge.onCommandFinished?(130) 335 - 336 - let event = await nextEvent(stream) { event in 337 - if case .blockingScriptCompleted = event { 338 - return true 339 - } 340 - return false 341 - } 342 - 343 - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 130)) 344 - } 345 - 346 - @Test func blockingScriptRerunClosesOldTabWithoutFiringCompletion() async { 347 - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 348 - let worktree = makeWorktree() 349 - let stream = manager.eventStream() 350 - 351 - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "sleep 10")) 352 - 353 - guard let state = manager.stateIfExists(for: worktree.id), 354 - let firstTabId = state.tabManager.selectedTabId 355 - else { 356 - Issue.record("Expected first blocking script tab") 357 - return 358 - } 359 - 360 - // Re-run the same kind — old tab should close silently. 361 - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) 362 - 363 - guard let secondTabId = state.tabManager.selectedTabId else { 364 - Issue.record("Expected second blocking script tab") 365 - return 366 - } 367 - 368 - #expect(firstTabId != secondTabId) 369 - #expect(!state.tabManager.tabs.map(\.id).contains(firstTabId)) 370 - 371 - // Complete the second script — only this one should fire. 372 - guard let surface = state.splitTree(for: secondTabId).root?.leftmostLeaf() else { 373 - Issue.record("Expected surface for second tab") 374 - return 375 - } 376 - surface.bridge.onCommandFinished?(0) 377 - surface.bridge.onChildExited?(0) 378 - 379 - let event = await nextEvent(stream) { event in 380 - if case .blockingScriptCompleted = event { 381 - return true 382 - } 383 - return false 384 - } 385 - 386 - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 0)) 387 - } 388 - 389 - @Test func blockingScriptTabClosedManuallyReportsCancellation() async { 390 - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 391 - let worktree = makeWorktree() 392 - let stream = manager.eventStream() 393 - 394 - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "sleep 10")) 395 - 396 - guard let state = manager.stateIfExists(for: worktree.id), 397 - let tabId = state.tabManager.selectedTabId 398 - else { 399 - Issue.record("Expected blocking script tab") 400 - return 401 - } 402 - 403 - // Simulate user closing the tab. 404 - state.closeTab(tabId) 405 - 406 - let event = await nextEvent(stream) { event in 407 - if case .blockingScriptCompleted = event { 408 - return true 409 - } 410 - return false 411 - } 412 - 413 - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: nil)) 414 - } 415 - 416 - @Test func closeAllSurfacesCancelsPendingBlockingScripts() async { 417 - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 418 - let worktree = makeWorktree() 419 - let stream = manager.eventStream() 420 - 421 - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "sleep 10")) 422 - 423 - guard let state = manager.stateIfExists(for: worktree.id) else { 424 - Issue.record("Expected worktree state") 425 - return 426 - } 427 - 428 - state.closeAllSurfaces() 429 - 430 - let event = await nextEvent(stream) { event in 431 - if case .blockingScriptCompleted = event { 432 - return true 433 - } 434 - return false 435 - } 436 - 437 - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: nil)) 438 - } 439 - 440 - @Test func blockingScriptSuccessAutoClosesTab() async { 441 - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 442 - let worktree = makeWorktree() 443 - let stream = manager.eventStream() 444 - 445 - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) 446 - 447 - guard let state = manager.stateIfExists(for: worktree.id), 448 - let tabId = state.tabManager.selectedTabId, 449 - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() 450 - else { 451 - Issue.record("Expected blocking script tab and surface") 452 - return 453 - } 454 - 455 - #expect(state.tabManager.tabs.map(\.id).contains(tabId)) 456 - 457 - surface.bridge.onCommandFinished?(0) 458 - surface.bridge.onChildExited?(0) 459 - 460 - let event = await nextEvent(stream) { event in 461 - if case .blockingScriptCompleted = event { 462 - return true 463 - } 464 - return false 465 - } 466 - 467 - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 0)) 468 - // Successful script should auto-close the tab. 469 - #expect(!state.tabManager.tabs.map(\.id).contains(tabId)) 470 - } 471 - 472 205 private func makeWorktree() -> Worktree { 473 206 Worktree( 474 207 id: "/tmp/repo/wt-1",