native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #194 from yaroslavyaroslav/bugfix/force-up-setup-scripts-bash

Fix deletion/archive scripts in fish shells

authored by

Stefano Bertagno and committed by
GitHub
6d2e577f c0a6f163

+332 -26
+1 -1
supacode/Clients/Shell/ShellClient.swift
··· 261 261 } 262 262 } 263 263 264 - nonisolated private func defaultShellPath() -> String { 264 + nonisolated func defaultShellPath() -> String { 265 265 if let env = ProcessInfo.processInfo.environment["SHELL"], !env.isEmpty { 266 266 shellLogger.info("Using SHELL env: \(env)") 267 267 return env
+1 -1
supacode/Features/CommandPalette/Reducer/CommandPaletteFeature.swift
··· 333 333 subtitle: pullRequest.title, 334 334 kind: .openPullRequest(worktreeID), 335 335 priorityTier: 2 336 - ), 336 + ) 337 337 ] 338 338 339 339 if let readyItem = makeReadyItem() {
+2 -2
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 1934 1934 var effects: [Effect<Action>] = [ 1935 1935 .run { _ in 1936 1936 await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 1937 - }, 1937 + } 1938 1938 ] 1939 1939 if didUpdateWorktreeOrder { 1940 1940 let worktreeOrderByRepository = state.worktreeOrderByRepository ··· 1961 1961 var effects: [Effect<Action>] = [ 1962 1962 .run { _ in 1963 1963 await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 1964 - }, 1964 + } 1965 1965 ] 1966 1966 if didUpdateWorktreeOrder { 1967 1967 let worktreeOrderByRepository = state.worktreeOrderByRepository
+156 -12
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 25 25 private var focusedSurfaceIdByTab: [TerminalTabID: UUID] = [:] 26 26 var tabIsRunningById: [TerminalTabID: Bool] = [:] 27 27 private var blockingScripts: [TerminalTabID: BlockingScriptKind] = [:] 28 + private var blockingScriptLaunchDirectories: [TerminalTabID: URL] = [:] 28 29 private var blockingScriptCommandFinished: Set<TerminalTabID> = [] 29 30 private var blockingScriptLastCommandExitCode: [TerminalTabID: Int] = [:] 30 31 private var lastBlockingScriptTabByKind: [BlockingScriptKind: TerminalTabID] = [:] ··· 145 146 146 147 @discardableResult 147 148 func runBlockingScript(kind: BlockingScriptKind, _ script: String) -> TerminalTabID? { 148 - guard let input = blockingScriptInput(script) else { return nil } 149 + let launch: BlockingScriptLaunch 150 + do { 151 + guard let prepared = try blockingScriptLaunch(script) else { return nil } 152 + launch = prepared 153 + } catch { 154 + blockingScriptLogger.warning("Failed to prepare \(kind.tabTitle) for worktree \(worktree.id): \(error)") 155 + onBlockingScriptCompleted?(kind, nil) 156 + return nil 157 + } 149 158 // Close any previous tab of the same kind (active or lingering 150 159 // from a completed/cancelled run). Clear tracking state first 151 160 // so closeTab doesn't fire a premature completion callback. ··· 164 173 icon: kind.tabIcon, 165 174 isTitleLocked: true, 166 175 tintColor: kind.tabColor, 167 - initialInput: input, 176 + initialInput: launch.commandInput, 168 177 focusing: true, 169 178 inheritingFromSurfaceId: currentFocusedSurfaceId(), 170 179 context: GHOSTTY_SURFACE_CONTEXT_TAB 171 180 ) 172 181 ) 173 182 guard let tabId else { 183 + cleanupBlockingScriptLaunchDirectory(at: launch.directoryURL) 174 184 blockingScriptLogger.warning("Failed to create \(kind.tabTitle) tab for worktree \(worktree.id)") 175 185 onBlockingScriptCompleted?(kind, nil) 176 186 return nil 177 187 } 178 188 blockingScripts[tabId] = kind 189 + blockingScriptLaunchDirectories[tabId] = launch.directoryURL 179 190 lastBlockingScriptTabByKind[kind] = tabId 180 191 181 192 blockingScriptLogger.info("Started \(kind.tabTitle) for worktree \(worktree.id)") ··· 338 349 339 350 func closeTab(_ tabId: TerminalTabID) { 340 351 let closedBlockingKind = blockingScripts.removeValue(forKey: tabId) 352 + cleanupBlockingScriptLaunchDirectory(for: tabId) 341 353 // Clear lingering tab tracking for completed or non-blocking tabs. 342 354 for (kind, tracked) in lastBlockingScriptTabByKind where tracked == tabId { 343 355 lastBlockingScriptTabByKind.removeValue(forKey: kind) ··· 522 534 for surface in surfaces.values { 523 535 surface.closeSurface() 524 536 } 537 + cleanupBlockingScriptLaunchDirectories() 525 538 surfaces.removeAll() 526 539 trees.removeAll() 527 540 focusedSurfaceIdByTab.removeAll() ··· 596 609 } 597 610 598 611 private func formatCommandInput(_ script: String) -> String? { 599 - let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) 600 - guard !trimmed.isEmpty else { return nil } 601 - return worktree.scriptEnvironmentExportPrefix + trimmed + "\n" 612 + makeCommandInput( 613 + script: script, 614 + environmentExportPrefix: worktree.scriptEnvironmentExportPrefix 615 + ) 602 616 } 603 617 604 - // Appends `exit $?` so the shell terminates with the script's exit code. 605 - // Without this, the interactive shell stays alive after the script finishes 606 - // and GHOSTTY_ACTION_SHOW_CHILD_EXITED never fires for completion detection. 607 - private func blockingScriptInput(_ script: String) -> String? { 608 - let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) 609 - guard !trimmed.isEmpty else { return nil } 610 - return worktree.scriptEnvironmentExportPrefix + "(\n" + trimmed + "\n)\nexit $?\n" 618 + private func cleanupBlockingScriptLaunchDirectory(for tabId: TerminalTabID) { 619 + guard let directoryURL = blockingScriptLaunchDirectories.removeValue(forKey: tabId) else { return } 620 + cleanupBlockingScriptLaunchDirectory(at: directoryURL) 621 + } 622 + 623 + private func cleanupBlockingScriptLaunchDirectories() { 624 + let directoryURLs = blockingScriptLaunchDirectories.values 625 + blockingScriptLaunchDirectories.removeAll() 626 + for directoryURL in directoryURLs { 627 + cleanupBlockingScriptLaunchDirectory(at: directoryURL) 628 + } 629 + } 630 + 631 + private func cleanupBlockingScriptLaunchDirectory(at directoryURL: URL) { 632 + do { 633 + try FileManager.default.removeItem(at: directoryURL) 634 + } catch { 635 + blockingScriptLogger.warning( 636 + "Failed to remove blocking script launch directory \(directoryURL.path(percentEncoded: false)): \(error)" 637 + ) 638 + } 639 + } 640 + 641 + // The typed command stays shell-portable by invoking a generated wrapper file, 642 + // which reads env/script metadata from sibling files rather than serializing 643 + // the user script into a shell-escaped `-c` string. 644 + private func blockingScriptLaunch(_ script: String) throws -> BlockingScriptLaunch? { 645 + try makeBlockingScriptLaunch( 646 + script: script, 647 + environment: worktree.scriptEnvironment, 648 + shellPath: defaultShellPath() 649 + ) 611 650 } 612 651 613 652 // Detects signal-based termination (e.g. Ctrl+C = exit code 130) ··· 976 1015 if newTree.isEmpty { 977 1016 trees.removeValue(forKey: tabId) 978 1017 focusedSurfaceIdByTab.removeValue(forKey: tabId) 1018 + cleanupBlockingScriptLaunchDirectory(for: tabId) 979 1019 tabManager.closeTab(tabId) 980 1020 if let kind = blockingScripts.removeValue(forKey: tabId) { 981 1021 blockingScriptCommandFinished.remove(tabId) ··· 1056 1096 return maxIndex + 1 1057 1097 } 1058 1098 } 1099 + 1100 + nonisolated func makeCommandInput( 1101 + script: String, 1102 + environmentExportPrefix: String 1103 + ) -> String? { 1104 + let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) 1105 + guard !trimmed.isEmpty else { return nil } 1106 + return environmentExportPrefix + trimmed + "\n" 1107 + } 1108 + 1109 + nonisolated struct BlockingScriptLaunch { 1110 + let directoryURL: URL 1111 + let runnerURL: URL 1112 + let scriptURL: URL 1113 + let rootPathURL: URL 1114 + let worktreePathURL: URL 1115 + let shellPathURL: URL 1116 + let commandInput: String 1117 + } 1118 + 1119 + nonisolated func makeBlockingScriptLaunch( 1120 + script: String, 1121 + environment: [String: String], 1122 + shellPath: String, 1123 + baseDirectoryURL: URL = FileManager.default.temporaryDirectory 1124 + ) throws -> BlockingScriptLaunch? { 1125 + let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) 1126 + guard !trimmed.isEmpty, 1127 + let rootPath = environment["SUPACODE_ROOT_PATH"], 1128 + let worktreePath = environment["SUPACODE_WORKTREE_PATH"] 1129 + else { 1130 + return nil 1131 + } 1132 + 1133 + let fileManager = FileManager.default 1134 + let directoryURL = baseDirectoryURL.appending( 1135 + path: "supacode-blocking-script-\(UUID().uuidString.lowercased())", 1136 + directoryHint: .isDirectory 1137 + ) 1138 + let runnerURL = directoryURL.appending(path: "run", directoryHint: .notDirectory) 1139 + let scriptURL = directoryURL.appending(path: "script", directoryHint: .notDirectory) 1140 + let rootPathURL = directoryURL.appending(path: "root-path", directoryHint: .notDirectory) 1141 + let worktreePathURL = directoryURL.appending(path: "worktree-path", directoryHint: .notDirectory) 1142 + let shellPathURL = directoryURL.appending(path: "shell-path", directoryHint: .notDirectory) 1143 + 1144 + do { 1145 + try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) 1146 + try Data((trimmed + "\n").utf8).write(to: scriptURL, options: [.atomic]) 1147 + try Data((rootPath + "\n").utf8).write(to: rootPathURL, options: [.atomic]) 1148 + try Data((worktreePath + "\n").utf8).write(to: worktreePathURL, options: [.atomic]) 1149 + try Data((shellPath + "\n").utf8).write(to: shellPathURL, options: [.atomic]) 1150 + try Data( 1151 + blockingScriptRunnerContents( 1152 + scriptURL: scriptURL, 1153 + rootPathURL: rootPathURL, 1154 + worktreePathURL: worktreePathURL, 1155 + shellPathURL: shellPathURL 1156 + ).utf8 1157 + ).write(to: runnerURL, options: [.atomic]) 1158 + try fileManager.setAttributes( 1159 + [.posixPermissions: 0o700], 1160 + ofItemAtPath: runnerURL.path(percentEncoded: false) 1161 + ) 1162 + } catch { 1163 + try? fileManager.removeItem(at: directoryURL) 1164 + throw error 1165 + } 1166 + 1167 + return BlockingScriptLaunch( 1168 + directoryURL: directoryURL, 1169 + runnerURL: runnerURL, 1170 + scriptURL: scriptURL, 1171 + rootPathURL: rootPathURL, 1172 + worktreePathURL: worktreePathURL, 1173 + shellPathURL: shellPathURL, 1174 + commandInput: shellSingleQuoted(runnerURL.path(percentEncoded: false)) + "\nexit\n" 1175 + ) 1176 + } 1177 + 1178 + nonisolated func blockingScriptRunnerContents( 1179 + scriptURL: URL, 1180 + rootPathURL: URL, 1181 + worktreePathURL: URL, 1182 + shellPathURL: URL 1183 + ) -> String { 1184 + let quotedRootPath = shellSingleQuoted(rootPathURL.path(percentEncoded: false)) 1185 + let quotedWorktreePath = shellSingleQuoted(worktreePathURL.path(percentEncoded: false)) 1186 + let quotedShellPath = shellSingleQuoted(shellPathURL.path(percentEncoded: false)) 1187 + let quotedScriptPath = shellSingleQuoted(scriptURL.path(percentEncoded: false)) 1188 + 1189 + return """ 1190 + #!/bin/sh 1191 + set -eu 1192 + IFS= read -r SUPACODE_ROOT_PATH < \(quotedRootPath) 1193 + IFS= read -r SUPACODE_WORKTREE_PATH < \(quotedWorktreePath) 1194 + IFS= read -r SUPACODE_SHELL_PATH < \(quotedShellPath) 1195 + export SUPACODE_ROOT_PATH SUPACODE_WORKTREE_PATH 1196 + exec "$SUPACODE_SHELL_PATH" -l \(quotedScriptPath) 1197 + """ 1198 + } 1199 + 1200 + nonisolated func shellSingleQuoted(_ value: String) -> String { 1201 + "'\(value.replacing("'", with: "'\"'\"'"))'" 1202 + }
+3 -3
supacodeTests/CommandPaletteFeatureTests.swift
··· 49 49 copyIgnored: false, 50 50 copyUntracked: false 51 51 ) 52 - ), 52 + ) 53 53 ] 54 54 55 55 let items = CommandPaletteFeature.commandPaletteItems(from: state) ··· 74 74 description: "Focus the split to the right.", 75 75 action: "goto_split:right", 76 76 actionKey: "goto_split" 77 - ), 77 + ) 78 78 ] 79 79 ) 80 80 ··· 98 98 description: "", 99 99 action: "goto_split:right", 100 100 actionKey: "goto_split" 101 - ), 101 + ) 102 102 ] 103 103 ) 104 104
+5 -5
supacodeTests/RepositoriesFeatureTests.swift
··· 1087 1087 id: pendingID, 1088 1088 repositoryID: repository.id, 1089 1089 progress: WorktreeCreationProgress(stage: .loadingLocalBranches) 1090 - ), 1090 + ) 1091 1091 ] 1092 1092 let store = TestStore(initialState: state) { 1093 1093 RepositoriesFeature() ··· 1124 1124 stage: .checkingRepositoryMode, 1125 1125 worktreeName: "swift-otter" 1126 1126 ) 1127 - ), 1127 + ) 1128 1128 ] 1129 1129 let store = TestStore(initialState: state) { 1130 1130 RepositoriesFeature() ··· 1321 1321 addedLines: nil, 1322 1322 removedLines: nil, 1323 1323 pullRequest: makePullRequest(state: "MERGED") 1324 - ), 1324 + ) 1325 1325 ] 1326 1326 let store = TestStore(initialState: state) { 1327 1327 RepositoriesFeature() ··· 2503 2503 id: removedWorktree.id, 2504 2504 repositoryID: repository.id, 2505 2505 progress: WorktreeCreationProgress(stage: .choosingWorktreeName) 2506 - ), 2506 + ) 2507 2507 ] 2508 2508 initialState.pinnedWorktreeIDs = [removedWorktree.id] 2509 2509 initialState.worktreeInfoByID = [ ··· 2586 2586 id: pendingID, 2587 2587 repositoryID: repository.id, 2588 2588 progress: WorktreeCreationProgress(stage: .loadingLocalBranches) 2589 - ), 2589 + ) 2590 2590 ] 2591 2591 initialState.selection = .worktree(pendingID) 2592 2592 initialState.sidebarSelectedWorktreeIDs = [existingWorktree.id, pendingID]
+162
supacodeTests/WorktreeEnvironmentTests.swift
··· 61 61 #expect(exports.contains("export SUPACODE_WORKTREE_PATH='/tmp/my repo/wt 1'")) 62 62 #expect(exports.contains("export SUPACODE_ROOT_PATH='/tmp/my repo/.bare'")) 63 63 } 64 + 65 + @Test func blockingScriptLaunchWritesScriptAndMetadataFiles() throws { 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 launch = try #require( 75 + try makeBlockingScriptLaunch( 76 + script: """ 77 + docker compose down 78 + codex exec "test" 79 + """, 80 + environment: worktree.scriptEnvironment, 81 + shellPath: "/opt/homebrew/bin/fish" 82 + ) 83 + ) 84 + defer { 85 + try? FileManager.default.removeItem(at: launch.directoryURL) 86 + } 87 + 88 + let scriptContents = try String(contentsOf: launch.scriptURL, encoding: .utf8) 89 + let runnerContents = try String(contentsOf: launch.runnerURL, encoding: .utf8) 90 + let rootPathContents = try String(contentsOf: launch.rootPathURL, encoding: .utf8) 91 + let worktreePathContents = try String(contentsOf: launch.worktreePathURL, encoding: .utf8) 92 + let shellPathContents = try String(contentsOf: launch.shellPathURL, encoding: .utf8) 93 + 94 + #expect( 95 + launch.directoryURL.deletingLastPathComponent().path(percentEncoded: false) 96 + == FileManager.default.temporaryDirectory.path(percentEncoded: false) 97 + ) 98 + #expect(launch.commandInput == shellSingleQuoted(launch.runnerURL.path(percentEncoded: false)) + "\nexit\n") 99 + #expect(scriptContents == "docker compose down\ncodex exec \"test\"\n") 100 + #expect(rootPathContents == "/tmp/repo\n") 101 + #expect(worktreePathContents == "/tmp/repo/wt-1\n") 102 + #expect(shellPathContents == "/opt/homebrew/bin/fish\n") 103 + #expect( 104 + runnerContents.contains( 105 + "IFS= read -r SUPACODE_ROOT_PATH < \(shellSingleQuoted(launch.rootPathURL.path(percentEncoded: false)))" 106 + ) 107 + == true 108 + ) 109 + #expect( 110 + runnerContents.contains( 111 + "IFS= read -r SUPACODE_WORKTREE_PATH < \(shellSingleQuoted(launch.worktreePathURL.path(percentEncoded: false)))" 112 + ) 113 + == true 114 + ) 115 + #expect( 116 + runnerContents.contains( 117 + "IFS= read -r SUPACODE_SHELL_PATH < \(shellSingleQuoted(launch.shellPathURL.path(percentEncoded: false)))" 118 + ) 119 + == true 120 + ) 121 + #expect( 122 + runnerContents.contains( 123 + "exec \"$SUPACODE_SHELL_PATH\" -l \(shellSingleQuoted(launch.scriptURL.path(percentEncoded: false)))" 124 + ) 125 + == true 126 + ) 127 + #expect(runnerContents.contains("docker compose down") == false) 128 + #expect(runnerContents.contains("codex exec \"test\"") == false) 129 + } 130 + 131 + @Test func blockingScriptLaunchReturnsNilWhenRequiredEnvironmentIsMissing() throws { 132 + #expect( 133 + try makeBlockingScriptLaunch( 134 + script: "echo test", 135 + environment: ["SUPACODE_ROOT_PATH": "/tmp/repo"], 136 + shellPath: "/bin/zsh" 137 + ) == nil 138 + ) 139 + } 140 + 141 + @Test func blockingScriptLaunchReturnsNilForWhitespaceOnlyScripts() throws { 142 + #expect( 143 + try makeBlockingScriptLaunch( 144 + script: """ 145 + 146 + """, 147 + environment: [ 148 + "SUPACODE_ROOT_PATH": "/tmp/repo", 149 + "SUPACODE_WORKTREE_PATH": "/tmp/repo/wt-1", 150 + ], 151 + shellPath: "/bin/zsh" 152 + ) == nil 153 + ) 154 + } 155 + 156 + @Test func blockingScriptLaunchPropagatesNonZeroExitCodeInZsh() throws { 157 + let launch = try #require( 158 + try makeBlockingScriptLaunch( 159 + script: "exit 1", 160 + environment: [ 161 + "SUPACODE_ROOT_PATH": "/tmp/repo", 162 + "SUPACODE_WORKTREE_PATH": "/tmp/repo/wt-1", 163 + ], 164 + shellPath: "/bin/zsh" 165 + ) 166 + ) 167 + let tempHome = URL( 168 + fileURLWithPath: "/tmp/supacode-zsh-home-\(UUID().uuidString.lowercased())", 169 + isDirectory: true 170 + ) 171 + try FileManager.default.createDirectory(at: tempHome, withIntermediateDirectories: true) 172 + defer { 173 + try? FileManager.default.removeItem(at: launch.directoryURL) 174 + try? FileManager.default.removeItem(at: tempHome) 175 + } 176 + 177 + let process = Process() 178 + process.executableURL = launch.runnerURL 179 + process.environment = ["HOME": tempHome.path(percentEncoded: false)] 180 + 181 + try process.run() 182 + process.waitUntilExit() 183 + 184 + #expect(process.terminationStatus == 1) 185 + } 186 + 187 + @Test func blockingScriptCommandInputHandlesQuotedTempPathsInZsh() throws { 188 + let fileManager = FileManager.default 189 + let baseDirectoryURL = fileManager.temporaryDirectory.appending( 190 + path: "supacode temporary path's with spaces \(UUID().uuidString.lowercased())", 191 + directoryHint: .isDirectory 192 + ) 193 + let launch = try #require( 194 + try makeBlockingScriptLaunch( 195 + script: "exit 1", 196 + environment: [ 197 + "SUPACODE_ROOT_PATH": "/tmp/repo", 198 + "SUPACODE_WORKTREE_PATH": "/tmp/repo/wt-1", 199 + ], 200 + shellPath: "/bin/zsh", 201 + baseDirectoryURL: baseDirectoryURL 202 + ) 203 + ) 204 + let tempHome = fileManager.temporaryDirectory.appending( 205 + path: "supacode-zsh-home-\(UUID().uuidString.lowercased())", 206 + directoryHint: .isDirectory 207 + ) 208 + try fileManager.createDirectory(at: tempHome, withIntermediateDirectories: true) 209 + defer { 210 + try? fileManager.removeItem(at: launch.directoryURL) 211 + try? fileManager.removeItem(at: baseDirectoryURL) 212 + try? fileManager.removeItem(at: tempHome) 213 + } 214 + 215 + let process = Process() 216 + process.executableURL = URL(fileURLWithPath: "/bin/zsh") 217 + process.arguments = ["-lc", launch.commandInput] 218 + process.environment = ["HOME": tempHome.path(percentEncoded: false)] 219 + 220 + try process.run() 221 + process.waitUntilExit() 222 + 223 + #expect(launch.commandInput.starts(with: "'") == true) 224 + #expect(process.terminationStatus == 1) 225 + } 64 226 }
+2 -2
supacodeTests/WorktreeTerminalManagerTests.swift
··· 55 55 title: "Unread", 56 56 body: "body", 57 57 isRead: false 58 - ), 58 + ) 59 59 ] 60 60 state.onNotificationIndicatorChanged?() 61 61 state.notifications = [ ··· 64 64 title: "Read", 65 65 body: "body", 66 66 isRead: true 67 - ), 67 + ) 68 68 ] 69 69 70 70 let stream = manager.eventStream()