native macOS codings agent orchestrator
6
fork

Configure Feed

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

Run archive script in a terminal tab instead of a headless process (#176)

Replace the `bash -lc` headless process for archive scripts with a
real Ghostty terminal tab. The script runs in the user's full shell
environment with visible output, and completion is detected via
COMMAND_FINISHED (shell integration) and SHOW_CHILD_EXITED callbacks.

Ctrl+C reports the signal exit code, Ctrl+D and manual tab close are
treated as cancellation. Success auto-closes the tab; failure leaves
it open for inspection.

The terminal layer is generic via BlockingScriptKind so future script
types (e.g. delete) require only adding an enum case and reducer
handling — zero changes to the terminal infrastructure.

authored by

Stefano Bertagno and committed by
GitHub
901ddf60 c4e101f8

+804 -201
+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) 14 15 case closeFocusedTab(Worktree) 15 16 case closeFocusedSurface(Worktree) 16 17 case performBindingAction(Worktree, action: String) ··· 32 33 case focusChanged(worktreeID: Worktree.ID, surfaceID: UUID) 33 34 case taskStatusChanged(worktreeID: Worktree.ID, status: WorktreeTaskStatus) 34 35 case runScriptStatusChanged(worktreeID: Worktree.ID, isRunning: Bool) 36 + case blockingScriptCompleted(worktreeID: Worktree.ID, kind: BlockingScriptKind, exitCode: Int?) 35 37 case commandPaletteToggleRequested(worktreeID: Worktree.ID) 36 38 case setupScriptConsumed(worktreeID: Worktree.ID) 37 39 }
-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 + 230 235 case .settings(.setSelection(let selection)): 231 236 let resolvedSelection = selection ?? .general 232 237 switch resolvedSelection { ··· 682 687 ) 683 688 case .terminalEvent(.setupScriptConsumed(let worktreeID)): 684 689 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 + } 685 696 686 697 case .terminalEvent: 687 698 return .none
+36 -77
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 - } 18 15 static func delayedPRRefresh(_ worktreeID: Worktree.ID) -> String { 19 16 "repositories.delayedPRRefresh.\(worktreeID)" 20 17 } 21 18 } 22 19 20 + private nonisolated let repositoriesLogger = SupaLogger("Repositories") 23 21 private nonisolated let githubIntegrationRecoveryInterval: Duration = .seconds(15) 24 22 private nonisolated let worktreeCreationProgressLineLimit = 200 25 23 private nonisolated let worktreeCreationProgressUpdateStride = 20 26 - private nonisolated let archiveScriptProgressLineLimit = 200 27 24 28 25 nonisolated struct WorktreeCreationProgressUpdateThrottle { 29 26 private let stride: Int ··· 75 72 var pendingSetupScriptWorktreeIDs: Set<Worktree.ID> = [] 76 73 var pendingTerminalFocusWorktreeIDs: Set<Worktree.ID> = [] 77 74 var archivingWorktreeIDs: Set<Worktree.ID> = [] 78 - var archiveScriptProgressByWorktreeID: [Worktree.ID: ArchiveScriptProgress] = [:] 79 75 var deletingWorktreeIDs: Set<Worktree.ID> = [] 80 76 var removingRepositoryIDs: Set<Repository.ID> = [] 81 77 var pinnedWorktreeIDs: [Worktree.ID] = [] ··· 189 185 case requestArchiveWorktree(Worktree.ID, Repository.ID) 190 186 case requestArchiveWorktrees([ArchiveWorktreeTarget]) 191 187 case archiveWorktreeConfirmed(Worktree.ID, Repository.ID) 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) 188 + case archiveScriptCompleted(worktreeID: Worktree.ID, exitCode: Int?) 195 189 case archiveWorktreeApply(Worktree.ID, Repository.ID) 196 190 case unarchiveWorktree(Worktree.ID) 197 191 case requestDeleteWorktree(Worktree.ID, Repository.ID) ··· 289 283 case repositoriesChanged(IdentifiedArrayOf<Repository>) 290 284 case openRepositorySettings(Repository.ID) 291 285 case worktreeCreated(Worktree) 286 + case runBlockingScript(Worktree, repositoryID: Repository.ID, kind: BlockingScriptKind, script: String) 292 287 } 293 288 294 289 @Dependency(AnalyticsClient.self) private var analyticsClient ··· 1273 1268 state.alert = nil 1274 1269 @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings 1275 1270 let script = repositorySettings.archiveScript 1276 - let commandText = archiveScriptCommand(script) 1277 1271 let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) 1278 1272 if trimmed.isEmpty { 1279 1273 return .send(.archiveWorktreeApply(worktreeID, repositoryID)) 1280 1274 } 1281 1275 state.archivingWorktreeIDs.insert(worktreeID) 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) 1276 + return .send( 1277 + .delegate(.runBlockingScript(worktree, repositoryID: repositoryID, kind: .archive, script: script))) 1318 1278 1319 - case .archiveScriptProgressUpdated(let worktreeID, let progress): 1279 + case .archiveScriptCompleted(let worktreeID, let exitCode): 1320 1280 guard state.archivingWorktreeIDs.contains(worktreeID) else { 1321 1281 return .none 1322 1282 } 1323 - state.archiveScriptProgressByWorktreeID[worktreeID] = progress 1324 - return .none 1325 - 1326 - case .archiveScriptSucceeded(let worktreeID, let repositoryID): 1327 - guard state.archivingWorktreeIDs.contains(worktreeID) else { 1283 + 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. 1328 1300 return .none 1329 - } 1330 - state.archivingWorktreeIDs.remove(worktreeID) 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 { 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 + ) 1336 1306 return .none 1337 1307 } 1338 - state.archivingWorktreeIDs.remove(worktreeID) 1339 - state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) 1340 - state.alert = messageAlert(title: "Archive script failed", message: message) 1341 - return .none 1342 1308 1343 1309 case .archiveWorktreeApply(let worktreeID, let repositoryID): 1344 1310 guard let repository = state.repositories[id: repositoryID], ··· 1584 1550 state.pendingWorktrees.removeAll { $0.id == worktreeID } 1585 1551 state.pendingSetupScriptWorktreeIDs.remove(worktreeID) 1586 1552 state.pendingTerminalFocusWorktreeIDs.remove(worktreeID) 1587 - state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) 1588 1553 state.worktreeInfoByID.removeValue(forKey: worktreeID) 1589 1554 state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } 1590 1555 state.archivedWorktreeIDs.removeAll { $0 == worktreeID } ··· 2672 2637 availableWorktreeIDs.contains($0) 2673 2638 } 2674 2639 let filteredArchivingIDs = state.archivingWorktreeIDs 2675 - let filteredArchiveScriptProgress = state.archiveScriptProgressByWorktreeID.filter { 2676 - availableWorktreeIDs.contains($0.key) || filteredArchivingIDs.contains($0.key) 2677 - } 2678 2640 let filteredWorktreeInfo = state.worktreeInfoByID.filter { 2679 2641 availableWorktreeIDs.contains($0.key) 2680 2642 } ··· 2687 2649 state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs 2688 2650 state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs 2689 2651 state.archivingWorktreeIDs = filteredArchivingIDs 2690 - state.archiveScriptProgressByWorktreeID = filteredArchiveScriptProgress 2691 2652 state.worktreeInfoByID = filteredWorktreeInfo 2692 2653 } 2693 2654 } else { ··· 2697 2658 state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs 2698 2659 state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs 2699 2660 state.archivingWorktreeIDs = filteredArchivingIDs 2700 - state.archiveScriptProgressByWorktreeID = filteredArchiveScriptProgress 2701 2661 state.worktreeInfoByID = filteredWorktreeInfo 2702 2662 } 2703 2663 let didPrunePinned = prunePinnedWorktreeIDs(state: &state) ··· 2869 2829 func pendingWorktree(for id: Worktree.ID?) -> PendingWorktree? { 2870 2830 guard let id else { return nil } 2871 2831 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] 2877 2832 } 2878 2833 2879 2834 func shouldFocusTerminal(for worktreeID: Worktree.ID) -> Bool { ··· 3323 3278 state.pendingSetupScriptWorktreeIDs.remove(worktreeID) 3324 3279 state.pendingTerminalFocusWorktreeIDs.remove(worktreeID) 3325 3280 state.archivingWorktreeIDs.remove(worktreeID) 3326 - state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) 3327 3281 state.deletingWorktreeIDs.remove(worktreeID) 3328 3282 state.worktreeInfoByID.removeValue(forKey: worktreeID) 3329 3283 let didUpdatePinned = state.pinnedWorktreeIDs.contains(worktreeID) ··· 3350 3304 ) 3351 3305 } 3352 3306 3353 - private nonisolated func archiveScriptCommand(_ script: String) -> String { 3354 - let normalized = script.replacing("\n", with: "\\n") 3355 - return "bash -lc \(shellQuote(normalized))" 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 + } 3356 3315 } 3357 3316 3358 3317 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 } 90 + .filter { $0.isRemovable && !$0.isMainWorktree && !$0.isDeleting && !$0.isArchiving } 91 91 .map { 92 92 RepositoriesFeature.ArchiveWorktreeTarget( 93 93 worktreeID: $0.id,
+3 -10
supacode/Features/Repositories/Views/WorktreeDetailView.swift
··· 403 403 ) 404 404 } 405 405 if selectedRow.isArchiving { 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 - ) 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 416 409 } 417 410 if selectedRow.isPending { 418 411 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 112 + canShowRowActions && !row.isMainWorktree && !row.isArchiving 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 } 227 + .filter { !$0.isMainWorktree && !$0.isArchiving } 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) 49 51 case .closeFocusedTab(let worktree): 50 52 _ = closeFocusedTab(in: worktree) 51 53 case .closeFocusedSurface(let worktree): ··· 161 163 } 162 164 state.onRunScriptStatusChanged = { [weak self] isRunning in 163 165 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)) 164 169 } 165 170 state.onCommandPaletteToggle = { [weak self] in 166 171 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 + 8 10 @MainActor 9 11 @Observable 10 12 final class WorktreeTerminalState { ··· 23 25 private var focusedSurfaceIdByTab: [TerminalTabID: UUID] = [:] 24 26 var tabIsRunningById: [TerminalTabID: Bool] = [:] 25 27 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] = [:] 26 32 private var pendingSetupScript: Bool 27 33 private var isEnsuringInitialTab = false 28 34 private var lastReportedTaskStatus: WorktreeTaskStatus? ··· 40 46 var onFocusChanged: ((UUID) -> Void)? 41 47 var onTaskStatusChanged: ((WorktreeTaskStatus) -> Void)? 42 48 var onRunScriptStatusChanged: ((Bool) -> Void)? 49 + var onBlockingScriptCompleted: ((BlockingScriptKind, Int?) -> Void)? 43 50 var onCommandPaletteToggle: (() -> Void)? 44 51 var onSetupScriptConsumed: (() -> Void)? 45 52 ··· 157 164 return true 158 165 } 159 166 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 + 160 204 private struct TabCreation: Equatable { 161 205 let title: String 162 206 let icon: String? ··· 302 346 303 347 func closeTab(_ tabId: TerminalTabID) { 304 348 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 + } 305 354 removeTree(for: tabId) 306 355 tabManager.closeTab(tabId) 307 356 if let selected = tabManager.selectedTabId { ··· 312 361 emitTaskStatusIfChanged() 313 362 if wasRunScriptTab { 314 363 setRunScriptTabId(nil) 364 + } 365 + if let closedBlockingKind { 366 + blockingScriptCommandFinished.remove(tabId) 367 + blockingScriptLastCommandExitCode.removeValue(forKey: tabId) 368 + onBlockingScriptCompleted?(closedBlockingKind, nil) 315 369 } 316 370 onTabClosed?() 317 371 } ··· 481 535 focusedSurfaceIdByTab.removeAll() 482 536 tabIsRunningById.removeAll() 483 537 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 + } 484 546 tabManager.closeAll() 485 547 } 486 548 ··· 553 615 return worktree.scriptEnvironmentExportPrefix + trimmed + "\n" 554 616 } 555 617 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 + 556 684 private func setRunScriptTabId(_ tabId: TerminalTabID?) { 557 685 let wasRunning = runScriptTabId != nil 558 686 runScriptTabId = tabId ··· 607 735 view.bridge.onProgressReport = { [weak self] _ in 608 736 guard let self else { return } 609 737 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) 610 746 } 611 747 view.bridge.onDesktopNotification = { [weak self, weak view] title, body in 612 748 guard let self, let view else { return } ··· 852 988 tabManager.closeTab(tabId) 853 989 if tabId == runScriptTabId { 854 990 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 + } 855 1001 } 856 1002 return 857 1003 }
+9 -1
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)? 19 24 var onDesktopNotification: ((String, String) -> Void)? 20 25 private var progressResetTask: Task<Void, Never>? 21 26 ··· 229 234 230 235 case GHOSTTY_ACTION_COMMAND_FINISHED: 231 236 let info = action.action.command_finished 232 - state.commandExitCode = info.exit_code == -1 ? nil : Int(info.exit_code) 237 + let exitCode = info.exit_code == -1 ? nil : Int(info.exit_code) 238 + state.commandExitCode = exitCode 233 239 state.commandDuration = info.duration 240 + onCommandFinished?(exitCode) 234 241 return true 235 242 236 243 case GHOSTTY_ACTION_SHOW_CHILD_EXITED: 237 244 let info = action.action.child_exited 238 245 state.childExitCode = info.exit_code 239 246 state.childExitTimeMs = info.timetime_ms 247 + onChildExited?(info.exit_code) 240 248 return true 241 249 242 250 case GHOSTTY_ACTION_READONLY:
+304 -82
supacodeTests/RepositoriesFeatureTests.swift
··· 946 946 await store.receive(\.delegate.selectedWorktreeChanged) 947 947 } 948 948 949 - @Test(.dependencies) func archiveWorktreeConfirmedRunsArchiveScriptAndShowsProgress() async { 949 + @Test(.dependencies) func archiveWorktreeConfirmedDelegatesArchiveScript() 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 - } 975 966 } 976 967 977 968 await store.send(.archiveWorktreeConfirmed(featureWorktree.id, repository.id)) { 978 969 $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 - ) 984 970 } 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 - ) 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() 1000 987 } 1001 - await store.receive(\.archiveScriptSucceeded) { 988 + 989 + await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0)) { 1002 990 $0.archivingWorktreeIDs = [] 1003 - $0.archiveScriptProgressByWorktreeID = [:] 1004 991 } 1005 992 await store.receive(\.archiveWorktreeApply) { 1006 993 $0.archivedWorktreeIDs = [featureWorktree.id] ··· 1008 995 await store.receive(\.delegate.repositoriesChanged) 1009 996 } 1010 997 1011 - @Test(.dependencies) func archiveWorktreeConfirmedScriptFailureBlocksArchive() async { 998 + @Test(.dependencies) func archiveScriptCompletedFailureShowsAlert() async { 1012 999 let repoRoot = "/tmp/repo" 1013 1000 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1014 1001 let featureWorktree = makeWorktree( ··· 1017 1004 repoRoot: repoRoot 1018 1005 ) 1019 1006 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1020 - @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 1021 - $repositorySettings.withLock { 1022 - $0.archiveScript = "exit 7" 1023 - } 1024 - let store = TestStore(initialState: makeState(repositories: [repository])) { 1007 + var state = makeState(repositories: [repository]) 1008 + state.archivingWorktreeIDs = [featureWorktree.id] 1009 + let store = TestStore(initialState: state) { 1025 1010 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 - } 1039 1011 } 1040 1012 1041 1013 let expectedAlert = AlertState<RepositoriesFeature.Alert> { ··· 1045 1017 TextState("OK") 1046 1018 } 1047 1019 } message: { 1048 - TextState("Command failed: bash -lc exit 7\nstderr:\nfail") 1020 + TextState("Script exited with code 7.\nCheck the ARCHIVE SCRIPT tab for details.") 1049 1021 } 1050 1022 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) { 1023 + await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 7)) { 1060 1024 $0.archivingWorktreeIDs = [] 1061 - $0.archiveScriptProgressByWorktreeID = [:] 1062 1025 $0.alert = expectedAlert 1063 1026 } 1064 1027 #expect(store.state.archivedWorktreeIDs.isEmpty) 1065 1028 } 1066 1029 1067 - @Test func archiveScriptSucceededIgnoredWhenNotArchiving() async { 1030 + @Test func archiveScriptCompletedCancellationClearsState() async { 1068 1031 let repoRoot = "/tmp/repo" 1069 1032 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1070 1033 let featureWorktree = makeWorktree( ··· 1073 1036 repoRoot: repoRoot 1074 1037 ) 1075 1038 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 1076 - let store = TestStore(initialState: makeState(repositories: [repository])) { 1039 + var state = makeState(repositories: [repository]) 1040 + state.archivingWorktreeIDs = [featureWorktree.id] 1041 + let store = TestStore(initialState: state) { 1077 1042 RepositoriesFeature() 1078 1043 } 1079 1044 1080 - await store.send(.archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) 1045 + await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: nil)) { 1046 + $0.archivingWorktreeIDs = [] 1047 + } 1048 + #expect(store.state.alert == nil) 1081 1049 #expect(store.state.archivedWorktreeIDs.isEmpty) 1082 1050 } 1083 1051 1084 - @Test func archiveScriptFailedIgnoredWhenNotArchiving() async { 1052 + @Test func archiveScriptCompletedIgnoredWhenNotArchiving() async { 1085 1053 let repoRoot = "/tmp/repo" 1086 1054 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1087 1055 let featureWorktree = makeWorktree( ··· 1094 1062 RepositoriesFeature() 1095 1063 } 1096 1064 1097 - await store.send(.archiveScriptFailed(worktreeID: featureWorktree.id, message: "late failure")) 1098 - #expect(store.state.alert == nil) 1065 + await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0)) 1099 1066 #expect(store.state.archivedWorktreeIDs.isEmpty) 1100 1067 } 1101 1068 ··· 1111 1078 let reloadedRepository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1112 1079 var state = makeState(repositories: [repository]) 1113 1080 state.archivingWorktreeIDs = [featureWorktree.id] 1114 - state.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( 1115 - titleText: "Running archive script", 1116 - detailText: "still running" 1117 - ) 1118 1081 let store = TestStore(initialState: state) { 1119 1082 RepositoriesFeature() 1120 1083 } ··· 1129 1092 ) 1130 1093 ) 1131 1094 #expect(store.state.archivingWorktreeIDs.contains(featureWorktree.id)) 1132 - #expect(store.state.archiveScriptProgressByWorktreeID[featureWorktree.id] != nil) 1133 1095 1134 - await store.send(.archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) 1096 + await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 0)) 1135 1097 #expect(store.state.archivingWorktreeIDs.isEmpty) 1136 - #expect(store.state.archiveScriptProgressByWorktreeID.isEmpty) 1137 1098 } 1138 1099 1139 1100 @Test func repositoriesLoadedKeepsArchiveInFlightUntilFailureCompletion() async { ··· 1148 1109 let reloadedRepository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1149 1110 var state = makeState(repositories: [repository]) 1150 1111 state.archivingWorktreeIDs = [featureWorktree.id] 1151 - state.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( 1152 - titleText: "Running archive script", 1153 - detailText: "still running" 1154 - ) 1155 1112 let store = TestStore(initialState: state) { 1156 1113 RepositoriesFeature() 1157 1114 } ··· 1166 1123 ) 1167 1124 ) 1168 1125 #expect(store.state.archivingWorktreeIDs.contains(featureWorktree.id)) 1169 - #expect(store.state.archiveScriptProgressByWorktreeID[featureWorktree.id] != nil) 1170 1126 1171 - await store.send(.archiveScriptFailed(worktreeID: featureWorktree.id, message: "script failed")) 1127 + await store.send(.archiveScriptCompleted(worktreeID: featureWorktree.id, exitCode: 1)) 1172 1128 #expect(store.state.archivingWorktreeIDs.isEmpty) 1173 - #expect(store.state.archiveScriptProgressByWorktreeID.isEmpty) 1174 1129 #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 + } 1175 1397 } 1176 1398 1177 1399 @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 + 205 472 private func makeWorktree() -> Worktree { 206 473 Worktree( 207 474 id: "/tmp/repo/wt-1",