native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #189 from onevcat/feat/issue-176-fetch-before-create

feat: optional git fetch before worktree creation

authored by

Wei Wang and committed by
GitHub
7f27455d c9dcfbbb

+375 -19
+31
supacode/Clients/Git/GitClient.swift
··· 22 22 case untrackedFilePaths = "untracked_file_paths" 23 23 case showFile = "show_file" 24 24 case remoteInfo = "remote_info" 25 + case remoteList = "remote_list" 26 + case fetchRemote = "fetch_remote" 25 27 } 26 28 27 29 enum GitClientError: LocalizedError { ··· 41 43 enum GitWorktreeCreateEvent: Equatable, Sendable { 42 44 case outputLine(ShellStreamLine) 43 45 case finished(Worktree) 46 + } 47 + 48 + nonisolated enum GitRemoteMatcher { 49 + static func matchingRemote(for ref: String, from remotes: [String]) -> String? { 50 + remotes 51 + .sorted { $0.count > $1.count } 52 + .first { ref.hasPrefix("\($0)/") } 53 + } 44 54 } 45 55 46 56 struct GitClient { ··· 528 538 } 529 539 } 530 540 return nil 541 + } 542 + 543 + nonisolated func remoteNames(for repoRoot: URL) async throws -> [String] { 544 + let path = repoRoot.path(percentEncoded: false) 545 + let output = try await runGit( 546 + operation: .remoteList, 547 + arguments: ["-C", path, "remote"] 548 + ) 549 + return 550 + output 551 + .split(whereSeparator: \.isNewline) 552 + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } 553 + .filter { !$0.isEmpty } 554 + } 555 + 556 + nonisolated func fetchRemote(_ remote: String, for repoRoot: URL) async throws { 557 + let path = repoRoot.path(percentEncoded: false) 558 + _ = try await runGit( 559 + operation: .fetchRemote, 560 + arguments: ["-C", path, "fetch", remote] 561 + ) 531 562 } 532 563 533 564 nonisolated func removeWorktree(_ worktree: Worktree, deleteBranch: Bool) async throws -> URL {
+8
supacode/Clients/Repositories/GitClientDependency.swift
··· 37 37 var lineChanges: @Sendable (URL) async -> (added: Int, removed: Int)? 38 38 var renameBranch: @Sendable (_ worktreeURL: URL, _ branchName: String) async throws -> Void 39 39 var remoteInfo: @Sendable (_ repositoryRoot: URL) async -> GithubRemoteInfo? 40 + var remoteNames: @Sendable (_ repoRoot: URL) async throws -> [String] 41 + var fetchRemote: @Sendable (_ remote: String, _ repoRoot: URL) async throws -> Void 40 42 } 41 43 42 44 extension GitClientDependency: DependencyKey { ··· 84 86 }, 85 87 remoteInfo: { repositoryRoot in 86 88 await GitClient().remoteInfo(for: repositoryRoot) 89 + }, 90 + remoteNames: { repoRoot in 91 + try await GitClient().remoteNames(for: repoRoot) 92 + }, 93 + fetchRemote: { remote, repoRoot in 94 + try await GitClient().fetchRemote(remote, for: repoRoot) 87 95 } 88 96 ) 89 97 static let testValue = liveValue
+9
supacode/Domain/WorktreeCreationProgress.swift
··· 2 2 var stage: WorktreeCreationStage 3 3 var worktreeName: String? 4 4 var baseRef: String? 5 + var fetchRemoteName: String? 5 6 var copyIgnored: Bool? 6 7 var copyUntracked: Bool? 7 8 var ignoredFilesToCopyCount: Int? ··· 14 15 stage: WorktreeCreationStage, 15 16 worktreeName: String? = nil, 16 17 baseRef: String? = nil, 18 + fetchRemoteName: String? = nil, 17 19 copyIgnored: Bool? = nil, 18 20 copyUntracked: Bool? = nil, 19 21 ignoredFilesToCopyCount: Int? = nil, ··· 25 27 self.stage = stage 26 28 self.worktreeName = worktreeName 27 29 self.baseRef = baseRef 30 + self.fetchRemoteName = fetchRemoteName 28 31 self.copyIgnored = copyIgnored 29 32 self.copyUntracked = copyUntracked 30 33 self.ignoredFilesToCopyCount = ignoredFilesToCopyCount ··· 51 54 return "Checking repository mode" 52 55 case .resolvingBaseReference: 53 56 return "Resolving base reference (\(baseRefDisplay))" 57 + case .fetchingRemote: 58 + if let fetchRemoteName, !fetchRemoteName.isEmpty { 59 + return "Fetching \(fetchRemoteName)" 60 + } 61 + return "Fetching remote" 54 62 case .creatingWorktree: 55 63 if let outputLine = outputLines.last, !outputLine.isEmpty { 56 64 return outputLine ··· 121 129 case choosingWorktreeName 122 130 case checkingRepositoryMode 123 131 case resolvingBaseReference 132 + case fetchingRemote 124 133 case creatingWorktree 125 134 }
+39 -8
supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift
··· 70 70 .createWorktreeInRepository( 71 71 repositoryID: repository.id, 72 72 nameSource: .random, 73 - baseRefSource: .repositorySetting 73 + baseRefSource: .repositorySetting, 74 + fetchRemote: settingsFile.global.fetchOriginBeforeWorktreeCreation 74 75 ) 75 76 ) 76 77 ) ··· 115 116 guard !Task.isCancelled else { 116 117 return 117 118 } 118 - let automaticBaseRefLabel = 119 - automaticBaseRef.isEmpty ? "Automatic" : "Automatic (\(automaticBaseRef))" 120 119 await send( 121 120 .worktreeCreation( 122 121 .promptedWorktreeCreationDataLoaded( 123 122 repositoryID: repositoryID, 124 123 baseRefOptions: baseRefOptions, 125 - automaticBaseRefLabel: automaticBaseRefLabel, 124 + automaticBaseRef: automaticBaseRef, 126 125 selectedBaseRef: selectedBaseRef 127 126 ) 128 127 ) ··· 133 132 case .promptedWorktreeCreationDataLoaded( 134 133 let repositoryID, 135 134 let baseRefOptions, 136 - let automaticBaseRefLabel, 135 + let automaticBaseRef, 137 136 let selectedBaseRef 138 137 ): 139 138 guard let repository = state.repositories[id: repositoryID] else { 140 139 return .none 141 140 } 141 + @Shared(.settingsFile) var settingsFile 142 142 state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 143 143 repositoryID: repository.id, 144 144 repositoryName: repository.name, 145 - automaticBaseRefLabel: automaticBaseRefLabel, 145 + automaticBaseRef: automaticBaseRef, 146 146 baseRefOptions: baseRefOptions, 147 147 branchName: "", 148 148 selectedBaseRef: selectedBaseRef, 149 + fetchRemote: settingsFile.global.fetchOriginBeforeWorktreeCreation, 149 150 validationMessage: nil 150 151 ) 151 152 return .none ··· 159 160 ) 160 161 return .none 161 162 } 163 + @Shared(.settingsFile) var settingsFile 164 + let fetchRemote = 165 + state.worktreeCreationPrompt?.fetchRemote ?? settingsFile.global.fetchOriginBeforeWorktreeCreation 162 166 state.worktreeCreationPrompt?.validationMessage = nil 163 167 state.worktreeCreationPrompt?.isValidating = true 164 168 let normalizedBranchName = branchName.lowercased() ··· 181 185 repositoryID: repositoryID, 182 186 branchName: branchName, 183 187 baseRef: baseRef, 188 + fetchRemote: fetchRemote, 184 189 duplicateMessage: duplicateMessage 185 190 ) 186 191 ) ··· 192 197 let repositoryID, 193 198 let branchName, 194 199 let baseRef, 200 + let fetchRemote, 195 201 let duplicateMessage 196 202 ): 197 203 guard let prompt = state.worktreeCreationPrompt, prompt.repositoryID == repositoryID else { ··· 208 214 .createWorktreeInRepository( 209 215 repositoryID: repositoryID, 210 216 nameSource: .explicit(branchName), 211 - baseRefSource: .explicit(baseRef) 217 + baseRefSource: .explicit(baseRef), 218 + fetchRemote: fetchRemote 212 219 ) 213 220 ) 214 221 ) 215 222 216 - case .createWorktreeInRepository(let repositoryID, let nameSource, let baseRefSource): 223 + case .createWorktreeInRepository(let repositoryID, let nameSource, let baseRefSource, let fetchRemote): 217 224 guard let repository = state.repositories[id: repositoryID] else { 218 225 state.alert = messageAlert( 219 226 title: "Unable to create worktree", ··· 411 418 } 412 419 } 413 420 progress.baseRef = resolvedBaseRef 421 + if fetchRemote, !resolvedBaseRef.isEmpty { 422 + do { 423 + let remotes = try await gitClient.remoteNames(repository.rootURL) 424 + if let matchedRemote = GitRemoteMatcher.matchingRemote(for: resolvedBaseRef, from: remotes) { 425 + progress.fetchRemoteName = matchedRemote 426 + progress.stage = .fetchingRemote 427 + await send( 428 + .worktreeCreation( 429 + .pendingWorktreeProgressUpdated( 430 + id: pendingID, 431 + progress: progress 432 + ) 433 + ) 434 + ) 435 + try await gitClient.fetchRemote(matchedRemote, repository.rootURL) 436 + } 437 + } catch { 438 + let errorMessage = "git fetch failed: \(error.localizedDescription)" 439 + worktreeCreationLogger.warning(errorMessage) 440 + progress.appendOutputLine(errorMessage, maxLines: worktreeCreationProgressLineLimit) 441 + } 442 + } 414 443 progress.copyIgnored = copyIgnored 415 444 progress.copyUntracked = copyUntracked 416 445 progress.ignoredFilesToCopyCount = ··· 617 646 } 618 647 } 619 648 } 649 + 650 + private nonisolated let worktreeCreationLogger = SupaLogger("WorktreeCreation")
+4 -2
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 75 75 case createWorktreeInRepository( 76 76 repositoryID: Repository.ID, 77 77 nameSource: WorktreeCreationNameSource, 78 - baseRefSource: WorktreeCreationBaseRefSource 78 + baseRefSource: WorktreeCreationBaseRefSource, 79 + fetchRemote: Bool 79 80 ) 80 81 case promptedWorktreeCreationDataLoaded( 81 82 repositoryID: Repository.ID, 82 83 baseRefOptions: [String], 83 - automaticBaseRefLabel: String, 84 + automaticBaseRef: String, 84 85 selectedBaseRef: String? 85 86 ) 86 87 case startPromptedWorktreeCreation( ··· 92 93 repositoryID: Repository.ID, 93 94 branchName: String, 94 95 baseRef: String?, 96 + fetchRemote: Bool, 95 97 duplicateMessage: String? 96 98 ) 97 99 case pendingWorktreeProgressUpdated(id: Worktree.ID, progress: WorktreeCreationProgress)
+6 -1
supacode/Features/Repositories/Reducer/WorktreeCreationPromptFeature.swift
··· 7 7 struct State: Equatable { 8 8 let repositoryID: Repository.ID 9 9 let repositoryName: String 10 - let automaticBaseRefLabel: String 10 + let automaticBaseRef: String 11 11 let baseRefOptions: [String] 12 12 var branchName: String 13 13 var selectedBaseRef: String? 14 + var fetchRemote: Bool 14 15 var validationMessage: String? 15 16 var isValidating = false 17 + 18 + var automaticBaseRefLabel: String { 19 + automaticBaseRef.isEmpty ? "Automatic" : "Automatic (\(automaticBaseRef))" 20 + } 16 21 } 17 22 18 23 enum Action: BindableAction, Equatable {
+11
supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift
··· 34 34 } 35 35 } 36 36 37 + VStack(alignment: .leading) { 38 + Toggle( 39 + "Fetch remote before creating worktree", 40 + isOn: $store.fetchRemote 41 + ) 42 + .help("Runs git fetch <remote> before creating the worktree.") 43 + Text("Keeps remote-tracking base branches current. Fetch failures are logged and creation continues.") 44 + .font(.footnote) 45 + .foregroundStyle(.secondary) 46 + } 47 + 37 48 if let validationMessage = store.validationMessage, !validationMessage.isEmpty { 38 49 Text(validationMessage) 39 50 .font(.footnote)
+7
supacode/Features/Settings/Models/GlobalSettings.swift
··· 17 17 var deleteBranchOnDeleteWorktree: Bool 18 18 var automaticallyArchiveMergedWorktrees: Bool 19 19 var promptForWorktreeCreation: Bool 20 + var fetchOriginBeforeWorktreeCreation: Bool 20 21 var defaultWorktreeBaseDirectoryPath: String? 21 22 var restoreTerminalLayoutOnLaunch: Bool 22 23 var terminalFontSize: Float32? ··· 42 43 deleteBranchOnDeleteWorktree: true, 43 44 automaticallyArchiveMergedWorktrees: false, 44 45 promptForWorktreeCreation: true, 46 + fetchOriginBeforeWorktreeCreation: true, 45 47 defaultWorktreeBaseDirectoryPath: nil, 46 48 restoreTerminalLayoutOnLaunch: false, 47 49 archivedAutoDeletePeriod: nil, ··· 68 70 deleteBranchOnDeleteWorktree: Bool, 69 71 automaticallyArchiveMergedWorktrees: Bool, 70 72 promptForWorktreeCreation: Bool, 73 + fetchOriginBeforeWorktreeCreation: Bool = true, 71 74 defaultWorktreeBaseDirectoryPath: String? = nil, 72 75 restoreTerminalLayoutOnLaunch: Bool = false, 73 76 archivedAutoDeletePeriod: AutoDeletePeriod? = nil, ··· 92 95 self.deleteBranchOnDeleteWorktree = deleteBranchOnDeleteWorktree 93 96 self.automaticallyArchiveMergedWorktrees = automaticallyArchiveMergedWorktrees 94 97 self.promptForWorktreeCreation = promptForWorktreeCreation 98 + self.fetchOriginBeforeWorktreeCreation = fetchOriginBeforeWorktreeCreation 95 99 self.defaultWorktreeBaseDirectoryPath = defaultWorktreeBaseDirectoryPath 96 100 self.restoreTerminalLayoutOnLaunch = restoreTerminalLayoutOnLaunch 97 101 self.archivedAutoDeletePeriod = archivedAutoDeletePeriod ··· 149 153 promptForWorktreeCreation = 150 154 try container.decodeIfPresent(Bool.self, forKey: .promptForWorktreeCreation) 151 155 ?? Self.default.promptForWorktreeCreation 156 + fetchOriginBeforeWorktreeCreation = 157 + try container.decodeIfPresent(Bool.self, forKey: .fetchOriginBeforeWorktreeCreation) 158 + ?? Self.default.fetchOriginBeforeWorktreeCreation 152 159 defaultWorktreeBaseDirectoryPath = 153 160 try container.decodeIfPresent(String.self, forKey: .defaultWorktreeBaseDirectoryPath) 154 161 ?? Self.default.defaultWorktreeBaseDirectoryPath
+4
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 24 24 var automaticallyArchiveMergedWorktrees: Bool 25 25 var archivedAutoDeletePeriod: AutoDeletePeriod? 26 26 var promptForWorktreeCreation: Bool 27 + var fetchRemoteBeforeWorktreeCreation: Bool 27 28 var defaultWorktreeBaseDirectoryPath: String 28 29 var restoreTerminalLayoutOnLaunch: Bool 29 30 var terminalFontSize: Float32? ··· 55 56 automaticallyArchiveMergedWorktrees = settings.automaticallyArchiveMergedWorktrees 56 57 archivedAutoDeletePeriod = settings.archivedAutoDeletePeriod 57 58 promptForWorktreeCreation = settings.promptForWorktreeCreation 59 + fetchRemoteBeforeWorktreeCreation = settings.fetchOriginBeforeWorktreeCreation 58 60 defaultWorktreeBaseDirectoryPath = 59 61 SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) ?? "" 60 62 restoreTerminalLayoutOnLaunch = settings.restoreTerminalLayoutOnLaunch ··· 82 84 deleteBranchOnDeleteWorktree: deleteBranchOnDeleteWorktree, 83 85 automaticallyArchiveMergedWorktrees: automaticallyArchiveMergedWorktrees, 84 86 promptForWorktreeCreation: promptForWorktreeCreation, 87 + fetchOriginBeforeWorktreeCreation: fetchRemoteBeforeWorktreeCreation, 85 88 defaultWorktreeBaseDirectoryPath: SupacodePaths.normalizedWorktreeBaseDirectoryPath( 86 89 defaultWorktreeBaseDirectoryPath 87 90 ), ··· 180 183 state.automaticallyArchiveMergedWorktrees = normalizedSettings.automaticallyArchiveMergedWorktrees 181 184 state.archivedAutoDeletePeriod = normalizedSettings.archivedAutoDeletePeriod 182 185 state.promptForWorktreeCreation = normalizedSettings.promptForWorktreeCreation 186 + state.fetchRemoteBeforeWorktreeCreation = normalizedSettings.fetchOriginBeforeWorktreeCreation 183 187 state.defaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath ?? "" 184 188 state.restoreTerminalLayoutOnLaunch = normalizedSettings.restoreTerminalLayoutOnLaunch 185 189 state.terminalFontSize = normalizedSettings.terminalFontSize
+9
supacode/Features/Settings/Views/WorktreeSettingsView.swift
··· 65 65 Text("When enabled, you choose the branch name and where it branches from before creating the worktree.") 66 66 .foregroundStyle(.secondary) 67 67 } 68 + VStack(alignment: .leading) { 69 + Toggle( 70 + "Fetch remote before creating worktree", 71 + isOn: $store.fetchRemoteBeforeWorktreeCreation 72 + ) 73 + .help("Runs git fetch <remote> before creating a worktree.") 74 + Text("Keeps remote-tracking base branches current. Fetch failures are logged and creation continues.") 75 + .foregroundStyle(.secondary) 76 + } 68 77 } 69 78 } 70 79 .formStyle(.grouped)
+27
supacodeTests/GitClientBranchRefsTests.swift
··· 12 12 } 13 13 14 14 struct GitClientBranchRefsTests { 15 + @Test func remoteMatcherUsesRemotePrefix() { 16 + let remote = GitRemoteMatcher.matchingRemote( 17 + for: "origin/main", 18 + from: ["origin", "upstream"] 19 + ) 20 + 21 + #expect(remote == "origin") 22 + } 23 + 24 + @Test func remoteMatcherUsesLongestRemotePrefix() { 25 + let remote = GitRemoteMatcher.matchingRemote( 26 + for: "origin-fork/main", 27 + from: ["origin", "origin-fork"] 28 + ) 29 + 30 + #expect(remote == "origin-fork") 31 + } 32 + 33 + @Test func remoteMatcherReturnsNilForLocalBranch() { 34 + let remote = GitRemoteMatcher.matchingRemote( 35 + for: "local-branch", 36 + from: ["origin"] 37 + ) 38 + 39 + #expect(remote == nil) 40 + } 41 + 15 42 @Test func branchRefsIncludesLocalAndUpstreamRefs() async throws { 16 43 let store = ShellCallStore() 17 44 let output = """
+209 -8
supacodeTests/RepositoriesFeatureTests.swift
··· 958 958 $0.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 959 959 repositoryID: repository.id, 960 960 repositoryName: repository.name, 961 - automaticBaseRefLabel: "Automatic (origin/main)", 961 + automaticBaseRef: "origin/main", 962 962 baseRefOptions: ["origin/dev", "origin/main"], 963 963 branchName: "", 964 964 selectedBaseRef: nil, 965 + fetchRemote: true, 965 966 validationMessage: nil 966 967 ) 967 968 } ··· 975 976 state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 976 977 repositoryID: repository.id, 977 978 repositoryName: repository.name, 978 - automaticBaseRefLabel: "Automatic (origin/main)", 979 + automaticBaseRef: "origin/main", 979 980 baseRefOptions: ["origin/main"], 980 981 branchName: "feature/new-branch", 981 982 selectedBaseRef: nil, 983 + fetchRemote: true, 982 984 validationMessage: nil 983 985 ) 984 986 let store = TestStore(initialState: state) { ··· 999 1001 state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 1000 1002 repositoryID: repository.id, 1001 1003 repositoryName: repository.name, 1002 - automaticBaseRefLabel: "Automatic (origin/main)", 1004 + automaticBaseRef: "origin/main", 1003 1005 baseRefOptions: ["origin/main"], 1004 1006 branchName: "feature/existing", 1005 1007 selectedBaseRef: nil, 1008 + fetchRemote: true, 1006 1009 validationMessage: nil 1007 1010 ) 1008 1011 let store = TestStore(initialState: state) { ··· 1081 1084 $0.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 1082 1085 repositoryID: repoB.id, 1083 1086 repositoryName: repoB.name, 1084 - automaticBaseRefLabel: "Automatic (origin/main)", 1087 + automaticBaseRef: "origin/main", 1085 1088 baseRefOptions: ["origin/main"], 1086 1089 branchName: "", 1087 1090 selectedBaseRef: nil, 1091 + fetchRemote: true, 1088 1092 validationMessage: nil 1089 1093 ) 1090 1094 } ··· 1100 1104 state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 1101 1105 repositoryID: repository.id, 1102 1106 repositoryName: repository.name, 1103 - automaticBaseRefLabel: "Automatic (origin/main)", 1107 + automaticBaseRef: "origin/main", 1104 1108 baseRefOptions: ["origin/main"], 1105 1109 branchName: "feature/new-branch", 1106 1110 selectedBaseRef: nil, 1111 + fetchRemote: true, 1107 1112 validationMessage: nil 1108 1113 ) 1109 1114 let store = TestStore(initialState: state) { ··· 1162 1167 .createWorktreeInRepository( 1163 1168 repositoryID: repository.id, 1164 1169 nameSource: .explicit("../../Desktop"), 1165 - baseRefSource: .repositorySetting 1170 + baseRefSource: .repositorySetting, 1171 + fetchRemote: true 1166 1172 )) 1167 1173 ) 1168 1174 await store.receive(\.worktreeCreation.createRandomWorktreeFailed) { ··· 1226 1232 repoRoot: repoRoot 1227 1233 ) 1228 1234 @Shared(.settingsFile) var settingsFile 1229 - $settingsFile.withLock { $0.global.promptForWorktreeCreation = false } 1235 + $settingsFile.withLock { 1236 + $0.global.promptForWorktreeCreation = false 1237 + $0.global.fetchOriginBeforeWorktreeCreation = false 1238 + } 1230 1239 let store = TestStore(initialState: makeState(repositories: [repository])) { 1231 1240 RepositoriesFeature() 1232 1241 } withDependencies: { ··· 1261 1270 #expect(store.state.alert == nil) 1262 1271 } 1263 1272 1273 + @Test(.dependencies) func createWorktreeFetchesMatchedRemoteBeforeCreatingWorktree() async { 1274 + let repoRoot = "/tmp/\(UUID().uuidString)-repo" 1275 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1276 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1277 + let createdWorktree = makeWorktree( 1278 + id: "\(repoRoot)/swift-otter", 1279 + name: "swift-otter", 1280 + repoRoot: repoRoot 1281 + ) 1282 + let events = LockIsolated<[String]>([]) 1283 + let store = TestStore(initialState: makeState(repositories: [repository])) { 1284 + RepositoriesFeature() 1285 + } withDependencies: { 1286 + $0.uuid = .incrementing 1287 + $0.gitClient.localBranchNames = { _ in [] } 1288 + $0.gitClient.isBareRepository = { _ in false } 1289 + $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 1290 + $0.gitClient.remoteNames = { _ in ["origin", "upstream"] } 1291 + $0.gitClient.fetchRemote = { remote, _ in 1292 + events.withValue { $0.append("fetch:\(remote)") } 1293 + } 1294 + $0.gitClient.ignoredFileCount = { _ in 0 } 1295 + $0.gitClient.untrackedFileCount = { _ in 0 } 1296 + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in 1297 + events.withValue { $0.append("create") } 1298 + return AsyncThrowingStream { continuation in 1299 + continuation.yield(.finished(createdWorktree)) 1300 + continuation.finish() 1301 + } 1302 + } 1303 + $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } 1304 + } 1305 + store.exhaustivity = .off 1306 + 1307 + await store.send( 1308 + .worktreeCreation( 1309 + .createWorktreeInRepository( 1310 + repositoryID: repository.id, 1311 + nameSource: .random, 1312 + baseRefSource: .repositorySetting, 1313 + fetchRemote: true 1314 + )) 1315 + ) 1316 + await store.receive(\.worktreeCreation.createRandomWorktreeSucceeded) 1317 + await store.finish() 1318 + 1319 + #expect(events.value == ["fetch:origin", "create"]) 1320 + } 1321 + 1322 + @Test(.dependencies) func createWorktreeSkipsFetchWhenDisabled() async { 1323 + let repoRoot = "/tmp/\(UUID().uuidString)-repo" 1324 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1325 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1326 + let createdWorktree = makeWorktree( 1327 + id: "\(repoRoot)/swift-otter", 1328 + name: "swift-otter", 1329 + repoRoot: repoRoot 1330 + ) 1331 + let store = TestStore(initialState: makeState(repositories: [repository])) { 1332 + RepositoriesFeature() 1333 + } withDependencies: { 1334 + $0.uuid = .incrementing 1335 + $0.gitClient.localBranchNames = { _ in [] } 1336 + $0.gitClient.isBareRepository = { _ in false } 1337 + $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 1338 + $0.gitClient.remoteNames = { _ in 1339 + Issue.record("remoteNames should not be requested when fetch is disabled") 1340 + return ["origin"] 1341 + } 1342 + $0.gitClient.fetchRemote = { _, _ in 1343 + Issue.record("fetchRemote should not run when fetch is disabled") 1344 + } 1345 + $0.gitClient.ignoredFileCount = { _ in 0 } 1346 + $0.gitClient.untrackedFileCount = { _ in 0 } 1347 + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in 1348 + AsyncThrowingStream { continuation in 1349 + continuation.yield(.finished(createdWorktree)) 1350 + continuation.finish() 1351 + } 1352 + } 1353 + $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } 1354 + } 1355 + store.exhaustivity = .off 1356 + 1357 + await store.send( 1358 + .worktreeCreation( 1359 + .createWorktreeInRepository( 1360 + repositoryID: repository.id, 1361 + nameSource: .random, 1362 + baseRefSource: .repositorySetting, 1363 + fetchRemote: false 1364 + )) 1365 + ) 1366 + await store.receive(\.worktreeCreation.createRandomWorktreeSucceeded) 1367 + await store.finish() 1368 + } 1369 + 1370 + @Test(.dependencies) func createWorktreeContinuesWhenFetchFails() async { 1371 + let repoRoot = "/tmp/\(UUID().uuidString)-repo" 1372 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1373 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1374 + let createdWorktree = makeWorktree( 1375 + id: "\(repoRoot)/swift-otter", 1376 + name: "swift-otter", 1377 + repoRoot: repoRoot 1378 + ) 1379 + let store = TestStore(initialState: makeState(repositories: [repository])) { 1380 + RepositoriesFeature() 1381 + } withDependencies: { 1382 + $0.uuid = .incrementing 1383 + $0.gitClient.localBranchNames = { _ in [] } 1384 + $0.gitClient.isBareRepository = { _ in false } 1385 + $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 1386 + $0.gitClient.remoteNames = { _ in ["origin"] } 1387 + $0.gitClient.fetchRemote = { _, _ in 1388 + throw NSError(domain: "git", code: 128, userInfo: [NSLocalizedDescriptionKey: "network unreachable"]) 1389 + } 1390 + $0.gitClient.ignoredFileCount = { _ in 0 } 1391 + $0.gitClient.untrackedFileCount = { _ in 0 } 1392 + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in 1393 + AsyncThrowingStream { continuation in 1394 + continuation.yield(.finished(createdWorktree)) 1395 + continuation.finish() 1396 + } 1397 + } 1398 + $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } 1399 + } 1400 + store.exhaustivity = .off 1401 + 1402 + await store.send( 1403 + .worktreeCreation( 1404 + .createWorktreeInRepository( 1405 + repositoryID: repository.id, 1406 + nameSource: .random, 1407 + baseRefSource: .repositorySetting, 1408 + fetchRemote: true 1409 + )) 1410 + ) 1411 + await store.receive(\.worktreeCreation.createRandomWorktreeSucceeded) 1412 + await store.finish() 1413 + } 1414 + 1415 + @Test(.dependencies) func createWorktreeSkipsFetchWhenNoMatchedRemote() async { 1416 + let repoRoot = "/tmp/\(UUID().uuidString)-repo" 1417 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1418 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1419 + let createdWorktree = makeWorktree( 1420 + id: "\(repoRoot)/swift-otter", 1421 + name: "swift-otter", 1422 + repoRoot: repoRoot 1423 + ) 1424 + let store = TestStore(initialState: makeState(repositories: [repository])) { 1425 + RepositoriesFeature() 1426 + } withDependencies: { 1427 + $0.uuid = .incrementing 1428 + $0.gitClient.localBranchNames = { _ in [] } 1429 + $0.gitClient.isBareRepository = { _ in false } 1430 + $0.gitClient.automaticWorktreeBaseRef = { _ in "local-branch" } 1431 + $0.gitClient.remoteNames = { _ in ["origin", "upstream"] } 1432 + $0.gitClient.fetchRemote = { _, _ in 1433 + Issue.record("fetchRemote should not run when no remote matches the base ref") 1434 + } 1435 + $0.gitClient.ignoredFileCount = { _ in 0 } 1436 + $0.gitClient.untrackedFileCount = { _ in 0 } 1437 + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in 1438 + AsyncThrowingStream { continuation in 1439 + continuation.yield(.finished(createdWorktree)) 1440 + continuation.finish() 1441 + } 1442 + } 1443 + $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } 1444 + } 1445 + store.exhaustivity = .off 1446 + 1447 + await store.send( 1448 + .worktreeCreation( 1449 + .createWorktreeInRepository( 1450 + repositoryID: repository.id, 1451 + nameSource: .random, 1452 + baseRefSource: .repositorySetting, 1453 + fetchRemote: true 1454 + )) 1455 + ) 1456 + await store.receive(\.worktreeCreation.createRandomWorktreeSucceeded) 1457 + await store.finish() 1458 + } 1459 + 1264 1460 @Test(.dependencies) func createRandomWorktreeUsesRepositoryWorktreeBaseDirectoryOverride() async { 1265 1461 let repoRoot = "/tmp/repo" 1266 1462 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) ··· 1274 1470 @Shared(.settingsFile) var settingsFile 1275 1471 $settingsFile.withLock { 1276 1472 $0.global.promptForWorktreeCreation = false 1473 + $0.global.fetchOriginBeforeWorktreeCreation = false 1277 1474 $0.global.defaultWorktreeBaseDirectoryPath = "/tmp/global-worktrees" 1278 1475 } 1279 1476 @Shared(.repositorySettings(repository.rootURL)) var repositorySettings ··· 1325 1522 @Shared(.settingsFile) var settingsFile 1326 1523 $settingsFile.withLock { 1327 1524 $0.global.promptForWorktreeCreation = false 1525 + $0.global.fetchOriginBeforeWorktreeCreation = false 1328 1526 $0.global.defaultWorktreeBaseDirectoryPath = "/tmp/global-worktrees" 1329 1527 } 1330 1528 @Shared(.repositorySettings(repository.rootURL)) var repositorySettings ··· 1368 1566 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1369 1567 let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1370 1568 @Shared(.settingsFile) var settingsFile 1371 - $settingsFile.withLock { $0.global.promptForWorktreeCreation = false } 1569 + $settingsFile.withLock { 1570 + $0.global.promptForWorktreeCreation = false 1571 + $0.global.fetchOriginBeforeWorktreeCreation = false 1572 + } 1372 1573 let store = TestStore(initialState: makeState(repositories: [repository])) { 1373 1574 RepositoriesFeature() 1374 1575 } withDependencies: {
+11
supacodeTests/WorktreeCreationProgressTests.swift
··· 13 13 #expect(progress.detailText == "Resolving base reference (HEAD)") 14 14 } 15 15 16 + @Test func fetchingRemoteIncludesRemoteName() { 17 + let progress = WorktreeCreationProgress( 18 + stage: .fetchingRemote, 19 + worktreeName: "swift-otter", 20 + fetchRemoteName: "origin" 21 + ) 22 + 23 + #expect(progress.titleText == "Creating swift-otter") 24 + #expect(progress.detailText == "Fetching origin") 25 + } 26 + 16 27 @Test func creatingWorktreeIncludesBaseRefAndCopyFlags() { 17 28 let progress = WorktreeCreationProgress( 18 29 stage: .creatingWorktree,