native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #119 from onevcat/feature/upstream-cherry-picks

Cherry-pick upstream bug fixes and bump dependencies

authored by

Wei Wang and committed by
GitHub
dd183cd9 ff063e99

+351 -37
+4 -4
supacode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
··· 69 69 "kind" : "remoteSourceControl", 70 70 "location" : "https://github.com/pointfreeco/swift-composable-architecture", 71 71 "state" : { 72 - "revision" : "5b0890fabfd68a2d375d68502bc3f54a8548c494", 73 - "version" : "1.23.1" 72 + "revision" : "df934d9c5a274a6f6a7bdcec73fbcb330149ff8b", 73 + "version" : "1.23.2" 74 74 } 75 75 }, 76 76 { ··· 114 114 "kind" : "remoteSourceControl", 115 115 "location" : "https://github.com/pointfreeco/swift-navigation", 116 116 "state" : { 117 - "revision" : "bf498690e1f6b4af790260f542e8428a4ba10d78", 118 - "version" : "2.6.0" 117 + "revision" : "e7441dc4dfec6a4ae929e614e3c1e67c6639d164", 118 + "version" : "2.7.0" 119 119 } 120 120 }, 121 121 {
+18
supacode/Domain/Worktree.swift
··· 24 24 self.createdAt = createdAt 25 25 } 26 26 } 27 + 28 + extension Worktree { 29 + /// Environment variables exposed to all Prowl scripts. 30 + var scriptEnvironment: [String: String] { 31 + [ 32 + "PROWL_WORKTREE_PATH": workingDirectory.path(percentEncoded: false), 33 + "PROWL_ROOT_PATH": repositoryRootURL.path(percentEncoded: false), 34 + ] 35 + } 36 + 37 + /// Shell export statements for prepending to scripts. 38 + var scriptEnvironmentExportPrefix: String { 39 + scriptEnvironment 40 + .sorted(by: { $0.key < $1.key }) 41 + .map { "export \($0.key)='\($0.value.replacing("'", with: "'\"'\"'"))'" } 42 + .joined(separator: "\n") + "\n" 43 + } 44 + }
+2 -1
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 1448 1448 commandText: commandText 1449 1449 ) 1450 1450 let shellClient = shellClient 1451 + let scriptWithEnv = worktree.scriptEnvironmentExportPrefix + script 1451 1452 return .run { send in 1452 1453 let envURL = URL(fileURLWithPath: "/usr/bin/env") 1453 1454 var progress = ArchiveScriptProgress( ··· 1458 1459 do { 1459 1460 for try await event in shellClient.runLoginStream( 1460 1461 envURL, 1461 - ["bash", "-lc", script], 1462 + ["bash", "-lc", scriptWithEnv], 1462 1463 worktree.workingDirectory, 1463 1464 log: false 1464 1465 ) {
+37
supacode/Features/Settings/Views/RepositorySettingsView.swift
··· 170 170 } 171 171 } 172 172 } 173 + Section { 174 + ScriptEnvironmentRow( 175 + name: "PROWL_WORKTREE_PATH", 176 + description: "Path to the active worktree." 177 + ) 178 + ScriptEnvironmentRow( 179 + name: "PROWL_ROOT_PATH", 180 + value: store.rootURL.path(percentEncoded: false), 181 + description: "Path to the repository root." 182 + ) 183 + } header: { 184 + VStack(alignment: .leading, spacing: 4) { 185 + Text("Environment Variables") 186 + Text("Exported in all scripts below") 187 + .foregroundStyle(.secondary) 188 + } 189 + } 173 190 174 191 if store.showsSetupScriptSettings { 175 192 Section { ··· 1357 1374 let commandID: UserCustomCommand.ID 1358 1375 let shortcut: UserCustomShortcut 1359 1376 } 1377 + 1378 + private struct ScriptEnvironmentRow: View { 1379 + let name: String 1380 + var value: String? 1381 + let description: String 1382 + 1383 + var body: some View { 1384 + VStack(alignment: .leading, spacing: 2) { 1385 + Text(name) 1386 + .monospaced() 1387 + if let value { 1388 + Text(value) 1389 + .foregroundStyle(.secondary) 1390 + .monospaced() 1391 + } 1392 + Text(description) 1393 + .foregroundStyle(.tertiary) 1394 + } 1395 + } 1396 + }
+8
supacode/Features/Terminal/Models/SplitTree.swift
··· 74 74 if case .split = root { true } else { false } 75 75 } 76 76 77 + var visibleNode: Node? { 78 + zoomed ?? root 79 + } 80 + 77 81 init() { 78 82 self.init(root: nil, zoomed: nil) 79 83 } ··· 265 269 266 270 func leaves() -> [ViewType] { 267 271 root?.leaves() ?? [] 272 + } 273 + 274 + func visibleLeaves() -> [ViewType] { 275 + visibleNode?.leaves() ?? [] 268 276 } 269 277 270 278 var structuralIdentity: StructuralIdentity {
+77 -28
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 31 31 private var isEnsuringInitialTab = false 32 32 private var lastReportedTaskStatus: WorktreeTaskStatus? 33 33 private var lastEmittedFocusSurfaceId: UUID? 34 + private var lastWindowIsKey: Bool? 35 + private var lastWindowIsVisible: Bool? 34 36 var notifications: [WorktreeTerminalNotification] = [] 35 37 var notificationsEnabled = true 36 38 private var commandFinishedNotificationEnabled = true ··· 278 280 } 279 281 280 282 func syncFocus(windowIsKey: Bool, windowIsVisible: Bool) { 283 + lastWindowIsKey = windowIsKey 284 + lastWindowIsVisible = windowIsVisible 285 + applySurfaceActivity() 286 + } 287 + 288 + private func applySurfaceActivity() { 281 289 let selectedTabId = tabManager.selectedTabId 282 290 var surfaceToFocus: GhosttySurfaceView? 283 291 for (tabId, tree) in trees { 284 292 let focusedId = focusedSurfaceIdByTab[tabId] 285 293 let isSelectedTab = (tabId == selectedTabId) 294 + let visibleSurfaceIDs = Set(tree.visibleLeaves().map(\.id)) 286 295 for surface in tree.leaves() { 287 296 let activity = Self.surfaceActivity( 297 + isSurfaceVisibleInTree: visibleSurfaceIDs.contains(surface.id), 288 298 isSelectedTab: isSelectedTab, 289 - windowIsVisible: windowIsVisible, 290 - windowIsKey: windowIsKey, 299 + windowIsVisible: lastWindowIsVisible == true, 300 + windowIsKey: lastWindowIsKey == true, 291 301 focusedSurfaceID: focusedId, 292 302 surfaceID: surface.id 293 303 ) ··· 304 314 } 305 315 306 316 static func surfaceActivity( 317 + isSurfaceVisibleInTree: Bool = true, 307 318 isSelectedTab: Bool, 308 319 windowIsVisible: Bool, 309 320 windowIsKey: Bool, 310 321 focusedSurfaceID: UUID?, 311 322 surfaceID: UUID 312 323 ) -> SurfaceActivity { 313 - let isVisible = isSelectedTab && windowIsVisible 324 + let isVisible = isSurfaceVisibleInTree && isSelectedTab && windowIsVisible 314 325 let isFocused = isVisible && windowIsKey && focusedSurfaceID == surfaceID 315 326 return SurfaceActivity(isVisible: isVisible, isFocused: isFocused) 316 327 } ··· 450 461 at: targetSurface, 451 462 direction: mapSplitDirection(direction) 452 463 ) 453 - trees[tabId] = newTree 464 + updateTree(newTree, for: tabId) 454 465 focusSurface(newSurface, in: tabId) 455 466 return true 456 467 } catch { ··· 470 481 trees[tabId] = tree 471 482 } 472 483 focusSurface(nextSurface, in: tabId) 484 + syncFocusIfNeeded() 473 485 return true 474 486 475 487 case .resizeSplit(let direction, let amount): ··· 481 493 in: spatialDirection, 482 494 with: CGRect(origin: .zero, size: tree.viewBounds()) 483 495 ) 484 - trees[tabId] = newTree 496 + updateTree(newTree, for: tabId) 485 497 return true 486 498 } catch { 487 499 return false 488 500 } 489 501 490 502 case .equalizeSplits: 491 - trees[tabId] = tree.equalized() 503 + updateTree(tree.equalized(), for: tabId) 492 504 return true 493 505 494 506 case .toggleSplitZoom: 495 507 guard tree.isSplit else { return false } 496 508 let newZoomed = (tree.zoomed == targetNode) ? nil : targetNode 497 - trees[tabId] = tree.settingZoomed(newZoomed) 509 + updateTree(tree.settingZoomed(newZoomed), for: tabId) 510 + focusSurface(targetSurface, in: tabId) 498 511 return true 499 512 } 500 513 } ··· 507 520 let resizedNode = node.resizing(to: ratio) 508 521 do { 509 522 tree = try tree.replacing(node: node, with: resizedNode) 510 - trees[tabId] = tree 523 + updateTree(tree, for: tabId) 511 524 } catch { 512 525 return 513 526 } ··· 525 538 at: destination, 526 539 direction: mapDropZone(zone) 527 540 ) 528 - trees[tabId] = newTree 541 + updateTree(newTree, for: tabId) 529 542 focusSurface(payload, in: tabId) 530 543 } catch { 531 544 return 532 545 } 533 546 534 547 case .equalize: 535 - trees[tabId] = tree.equalized() 548 + updateTree(tree.equalized(), for: tabId) 536 549 } 537 550 } 538 551 ··· 757 770 758 771 private func setupScriptInput(setupScript: String?) -> String? { 759 772 guard pendingSetupScript, let script = setupScript else { return nil } 760 - let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) 761 - if trimmed.isEmpty { 762 - return nil 763 - } 764 - if script.hasSuffix("\n") { 765 - return script 766 - } 767 - return "\(script)\n" 773 + return formatCommandInput(script) 774 + } 775 + 776 + private func formatCommandInput(_ script: String) -> String? { 777 + makeCommandInput( 778 + script: script, 779 + environmentExportPrefix: worktree.scriptEnvironmentExportPrefix 780 + ) 768 781 } 769 782 770 783 private func runScriptInput(_ script: String) -> String? { 771 - let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) 772 - if trimmed.isEmpty { 773 - return nil 774 - } 775 - if script.hasSuffix("\n") { 776 - return script 777 - } 778 - return "\(script)\n" 784 + formatCommandInput(script) 785 + } 786 + 787 + // Appends a bare `exit`, which preserves the most recent command status in 788 + // bash, zsh, and fish while remaining portable across those shells. 789 + // Without this, the interactive shell stays alive after the script finishes 790 + // and GHOSTTY_ACTION_SHOW_CHILD_EXITED never fires for completion detection. 791 + private func blockingScriptInput(_ script: String) -> String? { 792 + makeBlockingScriptInput( 793 + script: script, 794 + environmentExportPrefix: worktree.scriptEnvironmentExportPrefix 795 + ) 779 796 } 780 797 781 798 private func setRunScriptTabId(_ tabId: TerminalTabID?) { ··· 1035 1052 return 1036 1053 } 1037 1054 let tree = splitTree(for: tabId) 1038 - if let surface = tree.root?.leftmostLeaf() { 1055 + if let surface = tree.visibleLeaves().first { 1039 1056 focusSurface(surface, in: tabId) 1040 1057 } 1041 1058 } ··· 1271 1288 } 1272 1289 } 1273 1290 1291 + private func syncFocusIfNeeded() { 1292 + guard lastWindowIsKey != nil, lastWindowIsVisible != nil else { return } 1293 + applySurfaceActivity() 1294 + } 1295 + 1296 + private func updateTree(_ tree: SplitTree<GhosttySurfaceView>, for tabId: TerminalTabID) { 1297 + trees[tabId] = tree 1298 + syncFocusIfNeeded() 1299 + } 1300 + 1274 1301 private func isRunningProgressState(_ state: ghostty_action_progress_report_state_e?) -> Bool { 1275 1302 switch state { 1276 1303 case .some(GHOSTTY_PROGRESS_STATE_SET), ··· 1363 1390 } 1364 1391 return 1365 1392 } 1366 - trees[tabId] = newTree 1393 + updateTree(newTree, for: tabId) 1367 1394 updateRunningState(for: tabId) 1368 1395 if focusedSurfaceIdByTab[tabId] == view.id { 1369 1396 if let nextSurface { ··· 1429 1456 return maxIndex + 1 1430 1457 } 1431 1458 } 1459 + 1460 + nonisolated func makeCommandInput( 1461 + script: String, 1462 + environmentExportPrefix: String 1463 + ) -> String? { 1464 + let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) 1465 + guard !trimmed.isEmpty else { return nil } 1466 + return environmentExportPrefix + trimmed + "\n" 1467 + } 1468 + 1469 + nonisolated func makeBlockingScriptInput( 1470 + script: String, 1471 + environmentExportPrefix: String 1472 + ) -> String? { 1473 + guard let input = makeCommandInput( 1474 + script: script, 1475 + environmentExportPrefix: environmentExportPrefix 1476 + ) else { 1477 + return nil 1478 + } 1479 + return input + "exit\n" 1480 + }
+2 -4
supacode/Features/Terminal/Views/TerminalSplitTreeView.swift
··· 22 22 } 23 23 24 24 var body: some View { 25 - if let node = tree.zoomed ?? tree.root { 25 + if let node = tree.visibleNode { 26 26 SubtreeView(node: node, isRoot: node == tree.root, pinnedSize: pinnedSize, action: action) 27 27 .id(node.structuralIdentity) 28 28 } ··· 306 306 } 307 307 308 308 func updateNSView(_ nsView: TerminalSplitAXContainerView, context: Context) { 309 - let visibleNode = tree.zoomed ?? tree.root 310 - let visiblePanes = visibleNode?.leaves() ?? [] 311 309 nsView.update( 312 310 rootView: AnyView(TerminalSplitTreeView(tree: tree, action: action)), 313 - panes: visiblePanes 311 + panes: tree.visibleLeaves() 314 312 ) 315 313 } 316 314 }
+44
supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
··· 34 34 let length: UInt64 35 35 } 36 36 37 + struct KeyboardLayoutChangeKeyUpSuppression: Equatable { 38 + static let lifetime: TimeInterval = 1 39 + 40 + let keyCode: UInt16 41 + let expiresAt: TimeInterval 42 + 43 + init(keyCode: UInt16, timestamp: TimeInterval) { 44 + self.keyCode = keyCode 45 + expiresAt = timestamp + Self.lifetime 46 + } 47 + 48 + func suppresses(keyCode: UInt16, timestamp: TimeInterval) -> Bool { 49 + timestamp <= expiresAt && self.keyCode == keyCode 50 + } 51 + 52 + func isExpired(at timestamp: TimeInterval) -> Bool { 53 + timestamp > expiresAt 54 + } 55 + } 56 + 37 57 private final class CachedValue<T> { 38 58 private var value: T? 39 59 private let fetch: () -> T ··· 83 103 private var currentCursor: NSCursor = .iBeam 84 104 private var focused = false 85 105 private var markedText = NSMutableAttributedString() 106 + private var keyboardLayoutChangeKeyUpSuppression: KeyboardLayoutChangeKeyUpSuppression? 86 107 private var keyTextAccumulator: [String]? 87 108 private var cellSize: CGSize = .zero 88 109 private var lastScrollbar: ScrollbarState? ··· 340 361 341 362 override func viewDidMoveToWindow() { 342 363 super.viewDidMoveToWindow() 364 + if window == nil { 365 + // SwiftUI can temporarily detach a pane while rebuilding split/zoom layout. 366 + // If we keep the stale local focus bit, detached panes still intercept bindings. 367 + focusDidChange(false) 368 + } 343 369 updateScreenObservers() 344 370 updateContentScale() 345 371 updateSurfaceSize() ··· 578 604 lastPerformKeyEvent = nil 579 605 interpretKeyEvents([translationEvent]) 580 606 if !markedTextBefore, keyboardIdBefore != keyboardLayoutId() { 607 + keyboardLayoutChangeKeyUpSuppression = KeyboardLayoutChangeKeyUpSuppression( 608 + keyCode: event.keyCode, 609 + timestamp: event.timestamp 610 + ) 581 611 return 582 612 } 583 613 syncPreedit(clearIfNeeded: markedTextBefore) ··· 605 635 } 606 636 607 637 override func keyUp(with event: NSEvent) { 638 + if suppressKeyboardLayoutChangeKeyUp(event) { return } 608 639 sendKey(action: GHOSTTY_ACTION_RELEASE, event: event) 609 640 } 610 641 ··· 1352 1383 } 1353 1384 } 1354 1385 return keyEvent 1386 + } 1387 + 1388 + private func suppressKeyboardLayoutChangeKeyUp(_ event: NSEvent) -> Bool { 1389 + guard let suppression = keyboardLayoutChangeKeyUpSuppression else { return false } 1390 + if suppression.isExpired(at: event.timestamp) { 1391 + keyboardLayoutChangeKeyUpSuppression = nil 1392 + return false 1393 + } 1394 + if suppression.suppresses(keyCode: event.keyCode, timestamp: event.timestamp) { 1395 + keyboardLayoutChangeKeyUpSuppression = nil 1396 + return true 1397 + } 1398 + return false 1355 1399 } 1356 1400 1357 1401 private func ghosttyCharacters(_ event: NSEvent) -> String? {
+31
supacodeTests/GhosttySurfaceViewTests.swift
··· 72 72 ) == nil 73 73 ) 74 74 } 75 + 76 + @Test func keyboardLayoutChangeKeyUpSuppressionSuppressesMatchingKeyUp() { 77 + let suppression = GhosttySurfaceView.KeyboardLayoutChangeKeyUpSuppression( 78 + keyCode: 49, 79 + timestamp: 10 80 + ) 81 + 82 + #expect(suppression.suppresses(keyCode: 49, timestamp: 10.1)) 83 + #expect(!suppression.isExpired(at: 10.1)) 84 + } 85 + 86 + @Test func keyboardLayoutChangeKeyUpSuppressionIgnoresDifferentKeyUp() { 87 + let suppression = GhosttySurfaceView.KeyboardLayoutChangeKeyUpSuppression( 88 + keyCode: 49, 89 + timestamp: 10 90 + ) 91 + 92 + #expect(!suppression.suppresses(keyCode: 50, timestamp: 10.1)) 93 + #expect(suppression.suppresses(keyCode: 49, timestamp: 10.2)) 94 + #expect(!suppression.isExpired(at: 10.1)) 95 + } 96 + 97 + @Test func keyboardLayoutChangeKeyUpSuppressionExpires() { 98 + let suppression = GhosttySurfaceView.KeyboardLayoutChangeKeyUpSuppression( 99 + keyCode: 49, 100 + timestamp: 10 101 + ) 102 + 103 + #expect(!suppression.suppresses(keyCode: 49, timestamp: 11.1)) 104 + #expect(suppression.isExpired(at: 11.1)) 105 + } 75 106 }
+14
supacodeTests/SplitTreeTests.swift
··· 30 30 let node = try #require(tree.find(id: third.id)) 31 31 #expect(tree.focusTargetAfterClosing(node) === second) 32 32 } 33 + 34 + @Test func visibleLeavesOnlyReturnZoomedPane() throws { 35 + let first = SplitTreeTestView() 36 + let second = SplitTreeTestView() 37 + 38 + let tree = try SplitTree(view: first) 39 + .inserting(view: second, at: first, direction: .right) 40 + 41 + let zoomed = tree.settingZoomed(try #require(tree.find(id: second.id))) 42 + let visibleLeaves = zoomed.visibleLeaves() 43 + 44 + #expect(visibleLeaves.count == 1) 45 + #expect(visibleLeaves.first === second) 46 + } 33 47 } 34 48 35 49 private final class SplitTreeTestView: NSView, Identifiable {
+19
supacodeTests/TerminalRenderingPolicyTests.swift
··· 8 8 @Test func surfaceActivityForSelectedVisibleFocusedSurfaceIsFocused() { 9 9 let focusedID = UUID() 10 10 let activity = WorktreeTerminalState.surfaceActivity( 11 + isSurfaceVisibleInTree: true, 11 12 isSelectedTab: true, 12 13 windowIsVisible: true, 13 14 windowIsKey: true, ··· 20 21 21 22 @Test func surfaceActivityForSelectedVisibleUnfocusedSurfaceIsNotFocused() { 22 23 let activity = WorktreeTerminalState.surfaceActivity( 24 + isSurfaceVisibleInTree: true, 23 25 isSelectedTab: true, 24 26 windowIsVisible: true, 25 27 windowIsKey: true, ··· 33 35 @Test func surfaceActivityForSelectedTabInBackgroundWindowIsVisibleButNotFocused() { 34 36 let surfaceID = UUID() 35 37 let activity = WorktreeTerminalState.surfaceActivity( 38 + isSurfaceVisibleInTree: true, 36 39 isSelectedTab: true, 37 40 windowIsVisible: true, 38 41 windowIsKey: false, ··· 46 49 @Test func surfaceActivityForOccludedWindowIsHiddenAndUnfocused() { 47 50 let surfaceID = UUID() 48 51 let activity = WorktreeTerminalState.surfaceActivity( 52 + isSurfaceVisibleInTree: true, 49 53 isSelectedTab: true, 50 54 windowIsVisible: false, 51 55 windowIsKey: true, ··· 59 63 @Test func surfaceActivityForUnselectedTabIsHiddenAndUnfocused() { 60 64 let surfaceID = UUID() 61 65 let activity = WorktreeTerminalState.surfaceActivity( 66 + isSurfaceVisibleInTree: true, 62 67 isSelectedTab: false, 68 + windowIsVisible: true, 69 + windowIsKey: true, 70 + focusedSurfaceID: surfaceID, 71 + surfaceID: surfaceID 72 + ) 73 + #expect(!activity.isVisible) 74 + #expect(!activity.isFocused) 75 + } 76 + 77 + @Test func surfaceActivityForZoomHiddenSurfaceIsHiddenAndUnfocused() { 78 + let surfaceID = UUID() 79 + let activity = WorktreeTerminalState.surfaceActivity( 80 + isSurfaceVisibleInTree: false, 81 + isSelectedTab: true, 63 82 windowIsVisible: true, 64 83 windowIsKey: true, 65 84 focusedSurfaceID: surfaceID,
+95
supacodeTests/WorktreeEnvironmentTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + @MainActor 7 + struct WorktreeEnvironmentTests { 8 + @Test func scriptEnvironmentContainsExpectedKeys() { 9 + let worktree = Worktree( 10 + id: "/tmp/repo/wt-1", 11 + name: "feature-branch", 12 + detail: "detail", 13 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), 14 + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), 15 + ) 16 + let env = worktree.scriptEnvironment 17 + #expect(env["PROWL_WORKTREE_PATH"] == "/tmp/repo/wt-1") 18 + #expect(env["PROWL_ROOT_PATH"] == "/tmp/repo") 19 + #expect(env.count == 2) 20 + } 21 + 22 + @Test func exportPrefixFormatsCorrectly() { 23 + let worktree = Worktree( 24 + id: "/tmp/repo/wt-1", 25 + name: "feature-branch", 26 + detail: "detail", 27 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), 28 + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo/.bare"), 29 + ) 30 + let exports = worktree.scriptEnvironmentExportPrefix 31 + #expect(exports.contains("export PROWL_WORKTREE_PATH='/tmp/repo/wt-1'")) 32 + #expect(exports.contains("export PROWL_ROOT_PATH='/tmp/repo/.bare'")) 33 + #expect(exports.hasSuffix("\n")) 34 + } 35 + 36 + @Test func exportPrefixIsSortedByKey() { 37 + let worktree = Worktree( 38 + id: "/tmp/repo/wt-1", 39 + name: "feature-branch", 40 + detail: "detail", 41 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), 42 + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo/.bare"), 43 + ) 44 + let lines = worktree.scriptEnvironmentExportPrefix 45 + .trimmingCharacters(in: .newlines) 46 + .components(separatedBy: "\n") 47 + #expect(lines.count == 2) 48 + #expect(lines[0].contains("PROWL_ROOT_PATH")) 49 + #expect(lines[1].contains("PROWL_WORKTREE_PATH")) 50 + } 51 + 52 + @Test func exportPrefixQuotesPathsWithSpaces() { 53 + let worktree = Worktree( 54 + id: "/tmp/my repo/wt 1", 55 + name: "feature-branch", 56 + detail: "detail", 57 + workingDirectory: URL(fileURLWithPath: "/tmp/my repo/wt 1"), 58 + repositoryRootURL: URL(fileURLWithPath: "/tmp/my repo/.bare"), 59 + ) 60 + let exports = worktree.scriptEnvironmentExportPrefix 61 + #expect(exports.contains("export PROWL_WORKTREE_PATH='/tmp/my repo/wt 1'")) 62 + #expect(exports.contains("export PROWL_ROOT_PATH='/tmp/my repo/.bare'")) 63 + } 64 + 65 + @Test func blockingScriptInputUsesPortableBareExit() { 66 + let worktree = Worktree( 67 + id: "/tmp/repo/wt-1", 68 + name: "feature-branch", 69 + detail: "detail", 70 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), 71 + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), 72 + ) 73 + 74 + let input = makeBlockingScriptInput( 75 + script: """ 76 + docker compose down 77 + codex exec "test" 78 + """, 79 + environmentExportPrefix: worktree.scriptEnvironmentExportPrefix 80 + ) 81 + 82 + #expect(input?.contains("docker compose down\ncodex exec \"test\"\nexit\n") == true) 83 + #expect(input?.contains("(\n") == false) 84 + #expect(input?.contains("exit $?") == false) 85 + } 86 + 87 + @Test func blockingScriptInputReturnsNilForWhitespaceOnlyScripts() { 88 + #expect( 89 + makeBlockingScriptInput( 90 + script: " \n ", 91 + environmentExportPrefix: "export PROWL_ROOT_PATH='/tmp/repo'\n" 92 + ) == nil 93 + ) 94 + } 95 + }