native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #123 from supabitapp/teardown

Add pre-archive script with live loading progress

authored by

khoi and committed by
GitHub
d0ec4897 e094c768

+532 -9
+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 + }
+3
supacode/Domain/WorktreeCreationProgress.swift
··· 6 6 var copyUntracked: Bool? 7 7 var ignoredFilesToCopyCount: Int? 8 8 var untrackedFilesToCopyCount: Int? 9 + var commandText: String? 9 10 var latestOutputLine: String? 10 11 var outputLines: [String] 11 12 ··· 17 18 copyUntracked: Bool? = nil, 18 19 ignoredFilesToCopyCount: Int? = nil, 19 20 untrackedFilesToCopyCount: Int? = nil, 21 + commandText: String? = nil, 20 22 latestOutputLine: String? = nil, 21 23 outputLines: [String] = [] 22 24 ) { ··· 27 29 self.copyUntracked = copyUntracked 28 30 self.ignoredFilesToCopyCount = ignoredFilesToCopyCount 29 31 self.untrackedFilesToCopyCount = untrackedFilesToCopyCount 32 + self.commandText = commandText 30 33 self.latestOutputLine = latestOutputLine 31 34 self.outputLines = outputLines 32 35 }
+1
supacode/Domain/WorktreeLoadingInfo.swift
··· 4 4 let state: WorktreeLoadingState 5 5 let statusTitle: String? 6 6 let statusDetail: String? 7 + let statusCommand: String? 7 8 let statusLines: [String] 8 9 }
+1
supacode/Domain/WorktreeLoadingState.swift
··· 1 1 enum WorktreeLoadingState { 2 2 case creating 3 + case archiving 3 4 case removing 4 5 }
+1
supacode/Domain/WorktreeRowModel.swift
··· 9 9 let isPinned: Bool 10 10 let isMainWorktree: Bool 11 11 let isPending: Bool 12 + let isArchiving: Bool 12 13 let isDeleting: Bool 13 14 let isRemovable: Bool 14 15 }
+173 -2
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 12 12 static let githubIntegrationRecovery = "repositories.githubIntegrationRecovery" 13 13 static let worktreePromptLoad = "repositories.worktreePromptLoad" 14 14 static let worktreePromptValidation = "repositories.worktreePromptValidation" 15 + static func archiveScript(_ worktreeID: Worktree.ID) -> String { 16 + "repositories.archiveScript.\(worktreeID)" 17 + } 15 18 static func delayedPRRefresh(_ worktreeID: Worktree.ID) -> String { 16 19 "repositories.delayedPRRefresh.\(worktreeID)" 17 20 } ··· 20 23 private nonisolated let githubIntegrationRecoveryInterval: Duration = .seconds(15) 21 24 private nonisolated let worktreeCreationProgressLineLimit = 200 22 25 private nonisolated let worktreeCreationProgressUpdateStride = 20 26 + private nonisolated let archiveScriptProgressLineLimit = 200 23 27 24 28 nonisolated struct WorktreeCreationProgressUpdateThrottle { 25 29 private let stride: Int ··· 70 74 var pendingWorktrees: [PendingWorktree] = [] 71 75 var pendingSetupScriptWorktreeIDs: Set<Worktree.ID> = [] 72 76 var pendingTerminalFocusWorktreeIDs: Set<Worktree.ID> = [] 77 + var archivingWorktreeIDs: Set<Worktree.ID> = [] 78 + var archiveScriptProgressByWorktreeID: [Worktree.ID: ArchiveScriptProgress] = [:] 73 79 var deletingWorktreeIDs: Set<Worktree.ID> = [] 74 80 var removingRepositoryIDs: Set<Repository.ID> = [] 75 81 var pinnedWorktreeIDs: [Worktree.ID] = [] ··· 182 188 case requestArchiveWorktree(Worktree.ID, Repository.ID) 183 189 case requestArchiveWorktrees([ArchiveWorktreeTarget]) 184 190 case archiveWorktreeConfirmed(Worktree.ID, Repository.ID) 191 + case archiveScriptProgressUpdated(worktreeID: Worktree.ID, progress: ArchiveScriptProgress) 192 + case archiveScriptSucceeded(worktreeID: Worktree.ID, repositoryID: Repository.ID) 193 + case archiveScriptFailed(worktreeID: Worktree.ID, message: String) 194 + case archiveWorktreeApply(Worktree.ID, Repository.ID) 185 195 case unarchiveWorktree(Worktree.ID) 186 196 case requestDeleteWorktree(Worktree.ID, Repository.ID) 187 197 case requestDeleteWorktrees([DeleteWorktreeTarget]) ··· 285 295 @Dependency(GithubCLIClient.self) private var githubCLI 286 296 @Dependency(GithubIntegrationClient.self) private var githubIntegration 287 297 @Dependency(RepositoryPersistenceClient.self) private var repositoryPersistence 298 + @Dependency(ShellClient.self) private var shellClient 288 299 @Dependency(\.uuid) private var uuid 289 300 290 301 var body: some Reducer<State, Action> { ··· 959 970 progress.untrackedFilesToCopyCount = 960 971 copyUntracked ? ((try? await gitClient.untrackedFileCount(repository.rootURL)) ?? 0) : 0 961 972 progress.stage = .creatingWorktree 973 + progress.commandText = worktreeCreateCommand( 974 + repositoryRootURL: repository.rootURL, 975 + name: name, 976 + copyIgnored: copyIgnored, 977 + copyUntracked: copyUntracked, 978 + baseRef: resolvedBaseRef 979 + ) 962 980 await send( 963 981 .pendingWorktreeProgressUpdated( 964 982 id: pendingID, ··· 1149 1167 if state.deletingWorktreeIDs.contains(worktree.id) { 1150 1168 return .none 1151 1169 } 1170 + if state.archivingWorktreeIDs.contains(worktree.id) { 1171 + return .none 1172 + } 1152 1173 if state.isWorktreeArchived(worktree.id) { 1153 1174 return .none 1154 1175 } ··· 1184 1205 } 1185 1206 if state.isMainWorktree(worktree) 1186 1207 || state.deletingWorktreeIDs.contains(worktree.id) 1208 + || state.archivingWorktreeIDs.contains(worktree.id) 1187 1209 || state.isWorktreeArchived(worktree.id) 1188 1210 { 1189 1211 continue ··· 1227 1249 else { 1228 1250 return .none 1229 1251 } 1252 + if state.isWorktreeArchived(worktreeID) || state.archivingWorktreeIDs.contains(worktreeID) { 1253 + state.alert = nil 1254 + return .none 1255 + } 1256 + state.alert = nil 1257 + @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings 1258 + let script = repositorySettings.archiveScript 1259 + let commandText = archiveScriptCommand(script) 1260 + let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) 1261 + if trimmed.isEmpty { 1262 + return .send(.archiveWorktreeApply(worktreeID, repositoryID)) 1263 + } 1264 + state.archivingWorktreeIDs.insert(worktreeID) 1265 + state.archiveScriptProgressByWorktreeID[worktreeID] = ArchiveScriptProgress( 1266 + titleText: "Running archive script", 1267 + detailText: "Preparing archive script", 1268 + commandText: commandText 1269 + ) 1270 + let shellClient = shellClient 1271 + return .run { send in 1272 + let envURL = URL(fileURLWithPath: "/usr/bin/env") 1273 + var progress = ArchiveScriptProgress( 1274 + titleText: "Running archive script", 1275 + detailText: "Running archive script", 1276 + commandText: commandText 1277 + ) 1278 + do { 1279 + for try await event in shellClient.runLoginStream( 1280 + envURL, 1281 + ["bash", "-lc", script], 1282 + worktree.workingDirectory, 1283 + log: false 1284 + ) { 1285 + switch event { 1286 + case .line(let line): 1287 + let text = line.text.trimmingCharacters(in: .whitespacesAndNewlines) 1288 + guard !text.isEmpty else { continue } 1289 + progress.appendOutputLine(text, maxLines: archiveScriptProgressLineLimit) 1290 + await send(.archiveScriptProgressUpdated(worktreeID: worktreeID, progress: progress)) 1291 + case .finished: 1292 + await send(.archiveScriptSucceeded(worktreeID: worktreeID, repositoryID: repositoryID)) 1293 + } 1294 + } 1295 + } catch { 1296 + await send(.archiveScriptFailed(worktreeID: worktreeID, message: error.localizedDescription)) 1297 + } 1298 + } 1299 + .cancellable(id: CancelID.archiveScript(worktreeID), cancelInFlight: true) 1300 + 1301 + case .archiveScriptProgressUpdated(let worktreeID, let progress): 1302 + guard state.archivingWorktreeIDs.contains(worktreeID) else { 1303 + return .none 1304 + } 1305 + state.archiveScriptProgressByWorktreeID[worktreeID] = progress 1306 + return .none 1307 + 1308 + case .archiveScriptSucceeded(let worktreeID, let repositoryID): 1309 + guard state.archivingWorktreeIDs.contains(worktreeID) else { 1310 + return .none 1311 + } 1312 + state.archivingWorktreeIDs.remove(worktreeID) 1313 + state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) 1314 + return .send(.archiveWorktreeApply(worktreeID, repositoryID)) 1315 + 1316 + case .archiveScriptFailed(let worktreeID, let message): 1317 + guard state.archivingWorktreeIDs.contains(worktreeID) else { 1318 + return .none 1319 + } 1320 + state.archivingWorktreeIDs.remove(worktreeID) 1321 + state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) 1322 + state.alert = messageAlert(title: "Archive script failed", message: message) 1323 + return .none 1324 + 1325 + case .archiveWorktreeApply(let worktreeID, let repositoryID): 1326 + guard let repository = state.repositories[id: repositoryID], 1327 + let worktree = repository.worktrees[id: worktreeID] 1328 + else { 1329 + return .none 1330 + } 1230 1331 if state.isWorktreeArchived(worktreeID) { 1231 1332 state.alert = nil 1232 1333 return .none ··· 1326 1427 ) 1327 1428 return .none 1328 1429 } 1430 + if state.archivingWorktreeIDs.contains(worktree.id) { 1431 + return .none 1432 + } 1329 1433 if state.deletingWorktreeIDs.contains(worktree.id) { 1330 1434 return .none 1331 1435 } ··· 1362 1466 else { 1363 1467 continue 1364 1468 } 1365 - if state.isMainWorktree(worktree) || state.deletingWorktreeIDs.contains(worktree.id) { 1469 + if state.isMainWorktree(worktree) 1470 + || state.deletingWorktreeIDs.contains(worktree.id) 1471 + || state.archivingWorktreeIDs.contains(worktree.id) 1472 + { 1366 1473 continue 1367 1474 } 1368 1475 validTargets.append(target) ··· 1407 1514 else { 1408 1515 return .none 1409 1516 } 1517 + if state.archivingWorktreeIDs.contains(worktree.id) { 1518 + return .none 1519 + } 1410 1520 if state.deletingWorktreeIDs.contains(worktree.id) { 1411 1521 return .none 1412 1522 } ··· 1452 1562 let wasArchived = state.isWorktreeArchived(worktreeID) 1453 1563 withAnimation(.easeOut(duration: 0.2)) { 1454 1564 state.deletingWorktreeIDs.remove(worktreeID) 1565 + state.archivingWorktreeIDs.remove(worktreeID) 1455 1566 state.pendingWorktrees.removeAll { $0.id == worktreeID } 1456 1567 state.pendingSetupScriptWorktreeIDs.remove(worktreeID) 1457 1568 state.pendingTerminalFocusWorktreeIDs.remove(worktreeID) 1569 + state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) 1458 1570 state.worktreeInfoByID.removeValue(forKey: worktreeID) 1459 1571 state.pinnedWorktreeIDs.removeAll { $0 == worktreeID } 1460 1572 state.archivedWorktreeIDs.removeAll { $0 == worktreeID } ··· 2505 2617 let filteredFocusIDs = state.pendingTerminalFocusWorktreeIDs.filter { 2506 2618 availableWorktreeIDs.contains($0) 2507 2619 } 2620 + let filteredArchivingIDs = state.archivingWorktreeIDs 2621 + let filteredArchiveScriptProgress = state.archiveScriptProgressByWorktreeID.filter { 2622 + availableWorktreeIDs.contains($0.key) || filteredArchivingIDs.contains($0.key) 2623 + } 2508 2624 let filteredWorktreeInfo = state.worktreeInfoByID.filter { 2509 2625 availableWorktreeIDs.contains($0.key) 2510 2626 } ··· 2516 2632 state.deletingWorktreeIDs = filteredDeletingIDs 2517 2633 state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs 2518 2634 state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs 2635 + state.archivingWorktreeIDs = filteredArchivingIDs 2636 + state.archiveScriptProgressByWorktreeID = filteredArchiveScriptProgress 2519 2637 state.worktreeInfoByID = filteredWorktreeInfo 2520 2638 } 2521 2639 } else { ··· 2524 2642 state.deletingWorktreeIDs = filteredDeletingIDs 2525 2643 state.pendingSetupScriptWorktreeIDs = filteredSetupScriptIDs 2526 2644 state.pendingTerminalFocusWorktreeIDs = filteredFocusIDs 2645 + state.archivingWorktreeIDs = filteredArchivingIDs 2646 + state.archiveScriptProgressByWorktreeID = filteredArchiveScriptProgress 2527 2647 state.worktreeInfoByID = filteredWorktreeInfo 2528 2648 } 2529 2649 let didPrunePinned = prunePinnedWorktreeIDs(state: &state) ··· 2697 2817 return pendingWorktrees.first(where: { $0.id == id }) 2698 2818 } 2699 2819 2820 + func archiveScriptProgress(for id: Worktree.ID?) -> ArchiveScriptProgress? { 2821 + guard let id else { return nil } 2822 + return archiveScriptProgressByWorktreeID[id] 2823 + } 2824 + 2700 2825 func shouldFocusTerminal(for worktreeID: Worktree.ID) -> Bool { 2701 2826 pendingTerminalFocusWorktreeIDs.contains(worktreeID) 2702 2827 } ··· 2712 2837 isPinned: false, 2713 2838 isMainWorktree: false, 2714 2839 isPending: true, 2840 + isArchiving: false, 2715 2841 isDeleting: isDeleting, 2716 2842 isRemovable: false 2717 2843 ) ··· 2726 2852 let isDeleting = 2727 2853 removingRepositoryIDs.contains(repositoryID) 2728 2854 || deletingWorktreeIDs.contains(worktree.id) 2855 + let isArchiving = archivingWorktreeIDs.contains(worktree.id) 2729 2856 return WorktreeRowModel( 2730 2857 id: worktree.id, 2731 2858 repositoryID: repositoryID, ··· 2735 2862 isPinned: isPinned, 2736 2863 isMainWorktree: isMainWorktree, 2737 2864 isPending: false, 2865 + isArchiving: isArchiving, 2738 2866 isDeleting: isDeleting, 2739 - isRemovable: !isDeleting 2867 + isRemovable: !isDeleting && !isArchiving 2740 2868 ) 2741 2869 } 2742 2870 ··· 3139 3267 state.pendingWorktrees.removeAll { $0.id == worktreeID } 3140 3268 state.pendingSetupScriptWorktreeIDs.remove(worktreeID) 3141 3269 state.pendingTerminalFocusWorktreeIDs.remove(worktreeID) 3270 + state.archivingWorktreeIDs.remove(worktreeID) 3271 + state.archiveScriptProgressByWorktreeID.removeValue(forKey: worktreeID) 3142 3272 state.deletingWorktreeIDs.remove(worktreeID) 3143 3273 state.worktreeInfoByID.removeValue(forKey: worktreeID) 3144 3274 let didUpdatePinned = state.pinnedWorktreeIDs.contains(worktreeID) ··· 3163 3293 didUpdatePinned: didUpdatePinned, 3164 3294 didUpdateOrder: didUpdateOrder 3165 3295 ) 3296 + } 3297 + 3298 + private nonisolated func archiveScriptCommand(_ script: String) -> String { 3299 + let normalized = script.replacing("\n", with: "\\n") 3300 + return "bash -lc \(shellQuote(normalized))" 3301 + } 3302 + 3303 + private nonisolated func worktreeCreateCommand( 3304 + repositoryRootURL: URL, 3305 + name: String, 3306 + copyIgnored: Bool, 3307 + copyUntracked: Bool, 3308 + baseRef: String 3309 + ) -> String { 3310 + let baseDir = SupacodePaths.repositoryDirectory(for: repositoryRootURL).path(percentEncoded: false) 3311 + var parts = ["wt", "--base-dir", baseDir, "sw"] 3312 + if copyIgnored { 3313 + parts.append("--copy-ignored") 3314 + } 3315 + if copyUntracked { 3316 + parts.append("--copy-untracked") 3317 + } 3318 + if !baseRef.isEmpty { 3319 + parts.append("--from") 3320 + parts.append(baseRef) 3321 + } 3322 + if copyIgnored || copyUntracked { 3323 + parts.append("--verbose") 3324 + } 3325 + parts.append(name) 3326 + return parts.map(shellQuote).joined(separator: " ") 3327 + } 3328 + 3329 + private nonisolated func shellQuote(_ value: String) -> String { 3330 + let needsQuoting = value.contains { character in 3331 + character.isWhitespace || character == "\"" || character == "'" || character == "\\" 3332 + } 3333 + guard needsQuoting else { 3334 + return value 3335 + } 3336 + return "'\(value.replacing("'", with: "'\"'\"'"))'" 3166 3337 } 3167 3338 3168 3339 private func updateWorktreeName(
+14
supacode/Features/Repositories/Views/WorktreeDetailView.swift
··· 389 389 state: .removing, 390 390 statusTitle: nil, 391 391 statusDetail: nil, 392 + statusCommand: nil, 392 393 statusLines: [] 393 394 ) 394 395 } 396 + if selectedRow.isArchiving { 397 + let progress = repositories.archiveScriptProgress(for: selectedWorktreeID) 398 + return WorktreeLoadingInfo( 399 + name: selectedRow.name, 400 + repositoryName: repositoryName, 401 + state: .archiving, 402 + statusTitle: progress?.titleText ?? selectedRow.name, 403 + statusDetail: progress?.detailText ?? selectedRow.detail, 404 + statusCommand: progress?.commandText, 405 + statusLines: progress?.outputLines ?? [] 406 + ) 407 + } 395 408 if selectedRow.isPending { 396 409 let pending = repositories.pendingWorktree(for: selectedWorktreeID) 397 410 let progress = pending?.progress ··· 402 415 state: .creating, 403 416 statusTitle: progress?.titleText ?? selectedRow.name, 404 417 statusDetail: progress?.detailText ?? selectedRow.detail, 418 + statusCommand: progress?.commandText, 405 419 statusLines: progress?.liveOutputLines ?? [] 406 420 ) 407 421 }
+24 -1
supacode/Features/Repositories/Views/WorktreeLoadingView.swift
··· 6 6 private let bottomAnchorID = "worktree-loading-bottom" 7 7 8 8 var body: some View { 9 - let actionLabel = info.state == .creating ? "Creating" : "Removing" 9 + let actionLabel = 10 + if info.state == .creating { 11 + "Creating" 12 + } else if info.state == .archiving { 13 + "Archiving" 14 + } else { 15 + "Removing" 16 + } 10 17 let fallbackStatus = 11 18 if let repositoryName = info.repositoryName { 12 19 "\(actionLabel) worktree in \(repositoryName)" ··· 14 21 "\(actionLabel) worktree..." 15 22 } 16 23 let statusLine = info.statusDetail ?? info.statusTitle ?? fallbackStatus 24 + let statusCommand = info.statusCommand 17 25 VStack(spacing: 10) { 18 26 ProgressView() 19 27 Text(info.name) ··· 24 32 .font(.subheadline) 25 33 .foregroundStyle(.secondary) 26 34 .multilineTextAlignment(.center) 35 + if let statusCommand { 36 + Text(statusCommand) 37 + .font(.caption) 38 + .monospaced() 39 + .foregroundStyle(.secondary) 40 + .multilineTextAlignment(.center) 41 + } 27 42 } else { 28 43 ScrollViewReader { scrollProxy in 29 44 ScrollView { 30 45 VStack(alignment: .leading, spacing: 4) { 46 + if let statusCommand { 47 + Text(statusCommand) 48 + .font(.caption) 49 + .monospaced() 50 + .foregroundStyle(.secondary) 51 + .multilineTextAlignment(.leading) 52 + .frame(maxWidth: .infinity, alignment: .leading) 53 + } 31 54 ForEach(Array(info.statusLines.enumerated()), id: \.offset) { _, line in 32 55 Text(line) 33 56 .font(.caption)
+11 -4
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 58 58 rowView( 59 59 row, 60 60 isRepositoryRemoving: isRepositoryRemoving, 61 - moveDisabled: isRepositoryRemoving || row.isDeleting, 61 + moveDisabled: isRepositoryRemoving || row.isDeleting || row.isArchiving, 62 62 shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil 63 63 ) 64 64 } ··· 77 77 rowView( 78 78 row, 79 79 isRepositoryRemoving: isRepositoryRemoving, 80 - moveDisabled: isRepositoryRemoving || row.isDeleting, 80 + moveDisabled: isRepositoryRemoving || row.isDeleting || row.isArchiving, 81 81 shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil 82 82 ) 83 83 } ··· 94 94 shortcutHint: String? 95 95 ) -> some View { 96 96 let showsNotificationIndicator = terminalManager.hasUnseenNotifications(for: row.id) 97 - let displayName = row.isDeleting ? "\(row.name) (deleting...)" : row.name 97 + let displayName = 98 + if row.isDeleting { 99 + "\(row.name) (deleting...)" 100 + } else if row.isArchiving { 101 + "\(row.name) (archiving...)" 102 + } else { 103 + row.name 104 + } 98 105 let canShowRowActions = row.isRemovable && !isRepositoryRemoving 99 106 let pinAction: (() -> Void)? = 100 107 canShowRowActions && !row.isMainWorktree ··· 189 196 isHovered: config.isHovered, 190 197 isPinned: row.isPinned, 191 198 isMainWorktree: row.isMainWorktree, 192 - isLoading: row.isPending || row.isDeleting, 199 + isLoading: row.isPending || row.isArchiving || row.isDeleting, 193 200 taskStatus: taskStatus, 194 201 isRunScriptRunning: isRunScriptRunning, 195 202 showsNotificationIndicator: config.showsNotificationIndicator,
+8
supacode/Features/Settings/Models/RepositorySettings.swift
··· 2 2 3 3 nonisolated struct RepositorySettings: Codable, Equatable, Sendable { 4 4 var setupScript: String 5 + var archiveScript: String 5 6 var runScript: String 6 7 var openActionID: String 7 8 var worktreeBaseRef: String? ··· 11 12 12 13 private enum CodingKeys: String, CodingKey { 13 14 case setupScript 15 + case archiveScript 14 16 case runScript 15 17 case openActionID 16 18 case worktreeBaseRef ··· 21 23 22 24 static let `default` = RepositorySettings( 23 25 setupScript: "", 26 + archiveScript: "", 24 27 runScript: "", 25 28 openActionID: OpenWorktreeAction.automaticSettingsID, 26 29 worktreeBaseRef: nil, ··· 31 34 32 35 init( 33 36 setupScript: String, 37 + archiveScript: String, 34 38 runScript: String, 35 39 openActionID: String, 36 40 worktreeBaseRef: String?, ··· 39 43 pullRequestMergeStrategy: PullRequestMergeStrategy 40 44 ) { 41 45 self.setupScript = setupScript 46 + self.archiveScript = archiveScript 42 47 self.runScript = runScript 43 48 self.openActionID = openActionID 44 49 self.worktreeBaseRef = worktreeBaseRef ··· 52 57 setupScript = 53 58 try container.decodeIfPresent(String.self, forKey: .setupScript) 54 59 ?? Self.default.setupScript 60 + archiveScript = 61 + try container.decodeIfPresent(String.self, forKey: .archiveScript) 62 + ?? Self.default.archiveScript 55 63 runScript = 56 64 try container.decodeIfPresent(String.self, forKey: .runScript) 57 65 ?? Self.default.runScript
+21
supacode/Features/Settings/Views/RepositorySettingsView.swift
··· 116 116 Section { 117 117 ZStack(alignment: .topLeading) { 118 118 PlainTextEditor( 119 + text: settings.archiveScript 120 + ) 121 + .frame(minHeight: 120) 122 + if store.settings.archiveScript.isEmpty { 123 + Text("docker compose down") 124 + .foregroundStyle(.secondary) 125 + .padding(.leading, 6) 126 + .font(.body) 127 + .allowsHitTesting(false) 128 + } 129 + } 130 + } header: { 131 + VStack(alignment: .leading, spacing: 4) { 132 + Text("Archive Script") 133 + Text("Archive script that runs before a worktree is archived") 134 + .foregroundStyle(.secondary) 135 + } 136 + } 137 + Section { 138 + ZStack(alignment: .topLeading) { 139 + PlainTextEditor( 119 140 text: settings.runScript 120 141 ) 121 142 .frame(minHeight: 120)
+232 -2
supacodeTests/RepositoriesFeatureTests.swift
··· 759 759 } 760 760 761 761 await store.send(.requestArchiveWorktree(featureWorktree.id, repository.id)) 762 - await store.receive(\.archiveWorktreeConfirmed) { 762 + await store.receive(\.archiveWorktreeConfirmed) 763 + await store.receive(\.archiveWorktreeApply) { 763 764 $0.archivedWorktreeIDs = [featureWorktree.id] 764 765 $0.pinnedWorktreeIDs = [] 765 766 $0.worktreeOrderByRepository = [:] ··· 767 768 } 768 769 await store.receive(\.delegate.repositoriesChanged) 769 770 await store.receive(\.delegate.selectedWorktreeChanged) 771 + } 772 + 773 + @Test(.dependencies) func archiveWorktreeConfirmedRunsArchiveScriptAndShowsProgress() async { 774 + let repoRoot = "/tmp/repo" 775 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 776 + let featureWorktree = makeWorktree( 777 + id: "\(repoRoot)/feature", 778 + name: "feature", 779 + repoRoot: repoRoot 780 + ) 781 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 782 + var state = makeState(repositories: [repository]) 783 + state.selection = .worktree(mainWorktree.id) 784 + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 785 + $repositorySettings.withLock { 786 + $0.archiveScript = "echo syncing\necho done" 787 + } 788 + let store = TestStore(initialState: state) { 789 + RepositoriesFeature() 790 + } withDependencies: { 791 + $0.shellClient.runLoginStreamImpl = { _, _, _, _ in 792 + AsyncThrowingStream { continuation in 793 + continuation.yield(.line(ShellStreamLine(source: .stdout, text: "syncing"))) 794 + continuation.yield(.line(ShellStreamLine(source: .stdout, text: "done"))) 795 + continuation.yield(.finished(ShellOutput(stdout: "syncing\ndone", stderr: "", exitCode: 0))) 796 + continuation.finish() 797 + } 798 + } 799 + } 800 + 801 + await store.send(.archiveWorktreeConfirmed(featureWorktree.id, repository.id)) { 802 + $0.archivingWorktreeIDs = [featureWorktree.id] 803 + $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( 804 + titleText: "Running archive script", 805 + detailText: "Preparing archive script", 806 + commandText: "bash -lc 'echo syncing\\necho done'" 807 + ) 808 + } 809 + await store.receive(\.archiveScriptProgressUpdated) { 810 + $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( 811 + titleText: "Running archive script", 812 + detailText: "syncing", 813 + commandText: "bash -lc 'echo syncing\\necho done'", 814 + outputLines: ["syncing"] 815 + ) 816 + } 817 + await store.receive(\.archiveScriptProgressUpdated) { 818 + $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( 819 + titleText: "Running archive script", 820 + detailText: "done", 821 + commandText: "bash -lc 'echo syncing\\necho done'", 822 + outputLines: ["syncing", "done"] 823 + ) 824 + } 825 + await store.receive(\.archiveScriptSucceeded) { 826 + $0.archivingWorktreeIDs = [] 827 + $0.archiveScriptProgressByWorktreeID = [:] 828 + } 829 + await store.receive(\.archiveWorktreeApply) { 830 + $0.archivedWorktreeIDs = [featureWorktree.id] 831 + } 832 + await store.receive(\.delegate.repositoriesChanged) 833 + } 834 + 835 + @Test(.dependencies) func archiveWorktreeConfirmedScriptFailureBlocksArchive() async { 836 + let repoRoot = "/tmp/repo" 837 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 838 + let featureWorktree = makeWorktree( 839 + id: "\(repoRoot)/feature", 840 + name: "feature", 841 + repoRoot: repoRoot 842 + ) 843 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 844 + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 845 + $repositorySettings.withLock { 846 + $0.archiveScript = "exit 7" 847 + } 848 + let store = TestStore(initialState: makeState(repositories: [repository])) { 849 + RepositoriesFeature() 850 + } withDependencies: { 851 + $0.shellClient.runLoginStreamImpl = { _, _, _, _ in 852 + AsyncThrowingStream { continuation in 853 + continuation.finish( 854 + throwing: ShellClientError( 855 + command: "bash -lc exit 7", 856 + stdout: "", 857 + stderr: "fail", 858 + exitCode: 7 859 + ) 860 + ) 861 + } 862 + } 863 + } 864 + 865 + let expectedAlert = AlertState<RepositoriesFeature.Alert> { 866 + TextState("Archive script failed") 867 + } actions: { 868 + ButtonState(role: .cancel) { 869 + TextState("OK") 870 + } 871 + } message: { 872 + TextState("Command failed: bash -lc exit 7\nstderr:\nfail") 873 + } 874 + 875 + await store.send(.archiveWorktreeConfirmed(featureWorktree.id, repository.id)) { 876 + $0.archivingWorktreeIDs = [featureWorktree.id] 877 + $0.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( 878 + titleText: "Running archive script", 879 + detailText: "Preparing archive script", 880 + commandText: "bash -lc 'exit 7'" 881 + ) 882 + } 883 + await store.receive(\.archiveScriptFailed) { 884 + $0.archivingWorktreeIDs = [] 885 + $0.archiveScriptProgressByWorktreeID = [:] 886 + $0.alert = expectedAlert 887 + } 888 + #expect(store.state.archivedWorktreeIDs.isEmpty) 889 + } 890 + 891 + @Test func archiveScriptSucceededIgnoredWhenNotArchiving() async { 892 + let repoRoot = "/tmp/repo" 893 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 894 + let featureWorktree = makeWorktree( 895 + id: "\(repoRoot)/feature", 896 + name: "feature", 897 + repoRoot: repoRoot 898 + ) 899 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 900 + let store = TestStore(initialState: makeState(repositories: [repository])) { 901 + RepositoriesFeature() 902 + } 903 + 904 + await store.send(.archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) 905 + #expect(store.state.archivedWorktreeIDs.isEmpty) 906 + } 907 + 908 + @Test func archiveScriptFailedIgnoredWhenNotArchiving() async { 909 + let repoRoot = "/tmp/repo" 910 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 911 + let featureWorktree = makeWorktree( 912 + id: "\(repoRoot)/feature", 913 + name: "feature", 914 + repoRoot: repoRoot 915 + ) 916 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 917 + let store = TestStore(initialState: makeState(repositories: [repository])) { 918 + RepositoriesFeature() 919 + } 920 + 921 + await store.send(.archiveScriptFailed(worktreeID: featureWorktree.id, message: "late failure")) 922 + #expect(store.state.alert == nil) 923 + #expect(store.state.archivedWorktreeIDs.isEmpty) 924 + } 925 + 926 + @Test func repositoriesLoadedKeepsArchiveInFlightUntilSuccessCompletion() async { 927 + let repoRoot = "/tmp/repo" 928 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 929 + let featureWorktree = makeWorktree( 930 + id: "\(repoRoot)/feature", 931 + name: "feature", 932 + repoRoot: repoRoot 933 + ) 934 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 935 + let reloadedRepository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 936 + var state = makeState(repositories: [repository]) 937 + state.archivingWorktreeIDs = [featureWorktree.id] 938 + state.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( 939 + titleText: "Running archive script", 940 + detailText: "still running" 941 + ) 942 + let store = TestStore(initialState: state) { 943 + RepositoriesFeature() 944 + } 945 + store.exhaustivity = .off 946 + 947 + await store.send( 948 + .repositoriesLoaded( 949 + [reloadedRepository], 950 + failures: [], 951 + roots: [repository.rootURL], 952 + animated: false 953 + ) 954 + ) 955 + #expect(store.state.archivingWorktreeIDs.contains(featureWorktree.id)) 956 + #expect(store.state.archiveScriptProgressByWorktreeID[featureWorktree.id] != nil) 957 + 958 + await store.send(.archiveScriptSucceeded(worktreeID: featureWorktree.id, repositoryID: repository.id)) 959 + #expect(store.state.archivingWorktreeIDs.isEmpty) 960 + #expect(store.state.archiveScriptProgressByWorktreeID.isEmpty) 961 + } 962 + 963 + @Test func repositoriesLoadedKeepsArchiveInFlightUntilFailureCompletion() async { 964 + let repoRoot = "/tmp/repo" 965 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 966 + let featureWorktree = makeWorktree( 967 + id: "\(repoRoot)/feature", 968 + name: "feature", 969 + repoRoot: repoRoot 970 + ) 971 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 972 + let reloadedRepository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 973 + var state = makeState(repositories: [repository]) 974 + state.archivingWorktreeIDs = [featureWorktree.id] 975 + state.archiveScriptProgressByWorktreeID[featureWorktree.id] = ArchiveScriptProgress( 976 + titleText: "Running archive script", 977 + detailText: "still running" 978 + ) 979 + let store = TestStore(initialState: state) { 980 + RepositoriesFeature() 981 + } 982 + store.exhaustivity = .off 983 + 984 + await store.send( 985 + .repositoriesLoaded( 986 + [reloadedRepository], 987 + failures: [], 988 + roots: [repository.rootURL], 989 + animated: false 990 + ) 991 + ) 992 + #expect(store.state.archivingWorktreeIDs.contains(featureWorktree.id)) 993 + #expect(store.state.archiveScriptProgressByWorktreeID[featureWorktree.id] != nil) 994 + 995 + await store.send(.archiveScriptFailed(worktreeID: featureWorktree.id, message: "script failed")) 996 + #expect(store.state.archivingWorktreeIDs.isEmpty) 997 + #expect(store.state.archiveScriptProgressByWorktreeID.isEmpty) 998 + #expect(store.state.alert != nil) 770 999 } 771 1000 772 1001 @Test func requestRenameBranchWithEmptyNameShowsAlert() async { ··· 1350 1579 pullRequest: mergedPullRequest 1351 1580 ) 1352 1581 } 1353 - await store.receive(\.archiveWorktreeConfirmed) { 1582 + await store.receive(\.archiveWorktreeConfirmed) 1583 + await store.receive(\.archiveWorktreeApply) { 1354 1584 $0.archivedWorktreeIDs = [featureWorktree.id] 1355 1585 } 1356 1586 await store.receive(\.delegate.repositoriesChanged)
+15
supacodeTests/RepositorySettingsKeyTests.swift
··· 63 63 64 64 #expect(reloaded.repositories[rootURL.path(percentEncoded: false)] == settings) 65 65 } 66 + 67 + @Test func decodeMissingArchiveScriptDefaultsToEmpty() throws { 68 + let data = Data( 69 + """ 70 + { 71 + "setupScript": "echo setup", 72 + "runScript": "echo run", 73 + "openActionID": "automatic" 74 + } 75 + """.utf8 76 + ) 77 + let settings = try JSONDecoder().decode(RepositorySettings.self, from: data) 78 + 79 + #expect(settings.archiveScript.isEmpty) 80 + } 66 81 }