native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #198 from supabitapp/sbertix/fetch-default-origin

Fetch remote branch before worktree creation

authored by

khoi and committed by
GitHub
9a187df9 859df024

+568 -60
+25
supacode/Clients/Git/GitClient.swift
··· 19 19 case branchDelete = "branch_delete" 20 20 case lineChanges = "line_changes" 21 21 case remoteInfo = "remote_info" 22 + case remoteList = "remote_list" 23 + case fetchOrigin = "fetch_origin" 22 24 } 23 25 24 26 enum GitClientError: LocalizedError { ··· 206 208 return fallback 207 209 } 208 210 return nil 211 + } 212 + 213 + /// Returns the list of configured remote names for a repository. 214 + nonisolated func remoteNames(for repoRoot: URL) async throws -> [String] { 215 + let path = repoRoot.path(percentEncoded: false) 216 + let output = try await runGit( 217 + operation: .remoteList, 218 + arguments: ["-C", path, "remote"] 219 + ) 220 + return 221 + output 222 + .split(whereSeparator: \.isNewline) 223 + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } 224 + .filter { !$0.isEmpty } 225 + } 226 + 227 + /// Fetches updates from the given remote. 228 + nonisolated func fetchRemote(_ remote: String, for repoRoot: URL) async throws { 229 + let path = repoRoot.path(percentEncoded: false) 230 + _ = try await runGit( 231 + operation: .fetchOrigin, 232 + arguments: ["-C", path, "fetch", remote] 233 + ) 209 234 } 210 235 211 236 nonisolated func automaticWorktreeBaseRef(for repoRoot: URL) async -> String? {
+4
supacode/Clients/Repositories/GitClientDependency.swift
··· 36 36 var branchName: @Sendable (URL) async -> String? 37 37 var lineChanges: @Sendable (URL) async -> (added: Int, removed: Int)? 38 38 var renameBranch: @Sendable (_ worktreeURL: URL, _ branchName: String) async throws -> Void 39 + var remoteNames: @Sendable (_ repoRoot: URL) async throws -> [String] 40 + var fetchRemote: @Sendable (_ remote: String, _ repoRoot: URL) async throws -> Void 39 41 var remoteInfo: @Sendable (_ repositoryRoot: URL) async -> GithubRemoteInfo? 40 42 } 41 43 ··· 82 84 renameBranch: { worktreeURL, branchName in 83 85 try await GitClient().renameBranch(in: worktreeURL, to: branchName) 84 86 }, 87 + remoteNames: { try await GitClient().remoteNames(for: $0) }, 88 + fetchRemote: { remote, repoRoot in try await GitClient().fetchRemote(remote, for: repoRoot) }, 85 89 remoteInfo: { repositoryRoot in 86 90 await GitClient().remoteInfo(for: repositoryRoot) 87 91 }
+6
supacode/Domain/WorktreeCreationProgress.swift
··· 6 6 var copyUntracked: Bool? 7 7 var ignoredFilesToCopyCount: Int? 8 8 var untrackedFilesToCopyCount: Int? 9 + var fetchRemoteName: String? 9 10 var commandText: String? 10 11 var latestOutputLine: String? 11 12 var outputLines: [String] ··· 18 19 copyUntracked: Bool? = nil, 19 20 ignoredFilesToCopyCount: Int? = nil, 20 21 untrackedFilesToCopyCount: Int? = nil, 22 + fetchRemoteName: String? = nil, 21 23 commandText: String? = nil, 22 24 latestOutputLine: String? = nil, 23 25 outputLines: [String] = [] ··· 29 31 self.copyUntracked = copyUntracked 30 32 self.ignoredFilesToCopyCount = ignoredFilesToCopyCount 31 33 self.untrackedFilesToCopyCount = untrackedFilesToCopyCount 34 + self.fetchRemoteName = fetchRemoteName 32 35 self.commandText = commandText 33 36 self.latestOutputLine = latestOutputLine 34 37 self.outputLines = outputLines ··· 51 54 return "Checking repository mode" 52 55 case .resolvingBaseReference: 53 56 return "Resolving base reference (\(baseRefDisplay))" 57 + case .fetchingOrigin: 58 + return "Fetching latest from \(fetchRemoteName ?? "remote")" 54 59 case .creatingWorktree: 55 60 if let outputLine = outputLines.last, !outputLine.isEmpty { 56 61 return outputLine ··· 121 126 case choosingWorktreeName 122 127 case checkingRepositoryMode 123 128 case resolvingBaseReference 129 + case fetchingOrigin 124 130 case creatingWorktree 125 131 }
+75 -16
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 159 159 case createWorktreeInRepository( 160 160 repositoryID: Repository.ID, 161 161 nameSource: WorktreeCreationNameSource, 162 - baseRefSource: WorktreeCreationBaseRefSource 162 + baseRefSource: WorktreeCreationBaseRefSource, 163 + fetchOrigin: Bool 163 164 ) 164 165 case promptedWorktreeCreationDataLoaded( 165 166 repositoryID: Repository.ID, 166 167 baseRefOptions: [String], 167 - automaticBaseRefLabel: String, 168 + automaticBaseRef: String, 168 169 selectedBaseRef: String? 169 170 ) 170 171 case startPromptedWorktreeCreation( 171 172 repositoryID: Repository.ID, 172 173 branchName: String, 173 - baseRef: String? 174 + baseRef: String?, 175 + fetchOrigin: Bool 174 176 ) 175 177 case promptedWorktreeCreationChecked( 176 178 repositoryID: Repository.ID, 177 179 branchName: String, 178 180 baseRef: String?, 181 + fetchOrigin: Bool, 179 182 duplicateMessage: String? 180 183 ) 181 184 case pendingWorktreeProgressUpdated(id: Worktree.ID, progress: WorktreeCreationProgress) ··· 689 692 .createWorktreeInRepository( 690 693 repositoryID: repository.id, 691 694 nameSource: .random, 692 - baseRefSource: .repositorySetting 695 + baseRefSource: .repositorySetting, 696 + fetchOrigin: settingsFile.global.fetchOriginBeforeWorktreeCreation 693 697 ) 694 698 ) 695 699 ) ··· 733 737 guard !Task.isCancelled else { 734 738 return 735 739 } 736 - let automaticBaseRefLabel = 737 - automaticBaseRef.isEmpty ? "Automatic" : "Automatic (\(automaticBaseRef))" 738 740 await send( 739 741 .promptedWorktreeCreationDataLoaded( 740 742 repositoryID: repositoryID, 741 743 baseRefOptions: baseRefOptions, 742 - automaticBaseRefLabel: automaticBaseRefLabel, 744 + automaticBaseRef: automaticBaseRef, 743 745 selectedBaseRef: selectedBaseRef 744 746 ) 745 747 ) ··· 749 751 case .promptedWorktreeCreationDataLoaded( 750 752 let repositoryID, 751 753 let baseRefOptions, 752 - let automaticBaseRefLabel, 754 + let automaticBaseRef, 753 755 let selectedBaseRef 754 756 ): 755 757 guard let repository = state.repositories[id: repositoryID] else { 756 758 return .none 757 759 } 760 + @Shared(.settingsFile) var promptSettingsFile 758 761 state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 759 762 repositoryID: repository.id, 760 763 repositoryName: repository.name, 761 - automaticBaseRefLabel: automaticBaseRefLabel, 764 + automaticBaseRef: automaticBaseRef, 762 765 baseRefOptions: baseRefOptions, 763 766 branchName: "", 764 767 selectedBaseRef: selectedBaseRef, 768 + fetchOrigin: promptSettingsFile.global.fetchOriginBeforeWorktreeCreation, 765 769 validationMessage: nil 766 770 ) 767 771 return .none ··· 774 778 ) 775 779 776 780 case .worktreeCreationPrompt( 777 - .presented(.delegate(.submit(let repositoryID, let branchName, let baseRef))) 781 + .presented(.delegate(.submit(let repositoryID, let branchName, let baseRef, let fetchOrigin))) 778 782 ): 779 783 return .send( 780 784 .startPromptedWorktreeCreation( 781 785 repositoryID: repositoryID, 782 786 branchName: branchName, 783 - baseRef: baseRef 787 + baseRef: baseRef, 788 + fetchOrigin: fetchOrigin 784 789 ) 785 790 ) 786 791 787 - case .startPromptedWorktreeCreation(let repositoryID, let branchName, let baseRef): 792 + case .startPromptedWorktreeCreation(let repositoryID, let branchName, let baseRef, let fetchOrigin): 788 793 guard let repository = state.repositories[id: repositoryID] else { 789 794 state.worktreeCreationPrompt = nil 790 795 state.alert = messageAlert( ··· 814 819 repositoryID: repositoryID, 815 820 branchName: branchName, 816 821 baseRef: baseRef, 822 + fetchOrigin: fetchOrigin, 817 823 duplicateMessage: duplicateMessage 818 824 ) 819 825 ) ··· 824 830 let repositoryID, 825 831 let branchName, 826 832 let baseRef, 833 + let fetchOrigin, 827 834 let duplicateMessage 828 835 ): 829 836 guard let prompt = state.worktreeCreationPrompt, prompt.repositoryID == repositoryID else { ··· 839 846 .createWorktreeInRepository( 840 847 repositoryID: repositoryID, 841 848 nameSource: .explicit(branchName), 842 - baseRefSource: .explicit(baseRef) 849 + baseRefSource: .explicit(baseRef), 850 + fetchOrigin: fetchOrigin 843 851 ) 844 852 ) 845 853 846 - case .createWorktreeInRepository(let repositoryID, let nameSource, let baseRefSource): 854 + case .createWorktreeInRepository(let repositoryID, let nameSource, let baseRefSource, let fetchOrigin): 847 855 guard let repository = state.repositories[id: repositoryID] else { 848 856 state.alert = messageAlert( 849 857 title: "Unable to create worktree", ··· 1023 1031 } 1024 1032 } 1025 1033 progress.baseRef = resolvedBaseRef 1034 + if fetchOrigin { 1035 + let remotes: [String] 1036 + do { 1037 + remotes = try await gitClient.remoteNames(repository.rootURL) 1038 + } catch { 1039 + let repoPath = repository.rootURL.path(percentEncoded: false) 1040 + repositoriesLogger.warning( 1041 + "git remote listing failed for \(repoPath): \(error.localizedDescription)" 1042 + ) 1043 + remotes = [] 1044 + } 1045 + let matchedRemote = resolvedBaseRef.matchingRemote(from: remotes) 1046 + if let matchedRemote { 1047 + progress.fetchRemoteName = matchedRemote 1048 + progress.stage = .fetchingOrigin 1049 + await send( 1050 + .pendingWorktreeProgressUpdated( 1051 + id: pendingID, 1052 + progress: progress 1053 + ) 1054 + ) 1055 + do { 1056 + try await gitClient.fetchRemote(matchedRemote, repository.rootURL) 1057 + } catch { 1058 + repositoriesLogger.warning( 1059 + "git fetch \(matchedRemote) failed for \(repository.rootURL.path(percentEncoded: false)): \(error)" 1060 + ) 1061 + progress.appendOutputLine( 1062 + "Fetch failed: \(error.localizedDescription)", 1063 + maxLines: worktreeCreationProgressLineLimit 1064 + ) 1065 + await send( 1066 + .pendingWorktreeProgressUpdated(id: pendingID, progress: progress) 1067 + ) 1068 + } 1069 + } else { 1070 + repositoriesLogger.debug( 1071 + "Skipping fetch: no matching remote for base ref '\(resolvedBaseRef)'" 1072 + ) 1073 + } 1074 + } 1026 1075 progress.copyIgnored = copyIgnored 1027 1076 progress.copyUntracked = copyUntracked 1028 1077 progress.ignoredFilesToCopyCount = ··· 1934 1983 var effects: [Effect<Action>] = [ 1935 1984 .run { _ in 1936 1985 await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 1937 - } 1986 + }, 1938 1987 ] 1939 1988 if didUpdateWorktreeOrder { 1940 1989 let worktreeOrderByRepository = state.worktreeOrderByRepository ··· 1961 2010 var effects: [Effect<Action>] = [ 1962 2011 .run { _ in 1963 2012 await repositoryPersistence.savePinnedWorktreeIDs(pinnedWorktreeIDs) 1964 - } 2013 + }, 1965 2014 ] 1966 2015 if didUpdateWorktreeOrder { 1967 2016 let worktreeOrderByRepository = state.worktreeOrderByRepository ··· 3886 3935 } 3887 3936 return nil 3888 3937 } 3938 + 3939 + extension String { 3940 + /// Returns the remote name if this ref starts with `<remote>/`, matched against known remotes. 3941 + /// Matches the longest remote name first to handle ambiguous prefixes. 3942 + fileprivate nonisolated func matchingRemote(from remotes: [String]) -> String? { 3943 + remotes 3944 + .sorted { $0.count > $1.count } 3945 + .first { hasPrefix("\($0)/") } 3946 + } 3947 + }
+5 -3
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 fetchOrigin: Bool 14 15 var validationMessage: String? 15 16 var isValidating = false 16 17 } ··· 27 28 @CasePathable 28 29 enum Delegate: Equatable { 29 30 case cancel 30 - case submit(repositoryID: Repository.ID, branchName: String, baseRef: String?) 31 + case submit(repositoryID: Repository.ID, branchName: String, baseRef: String?, fetchOrigin: Bool) 31 32 } 32 33 33 34 var body: some Reducer<State, Action> { ··· 57 58 .submit( 58 59 repositoryID: state.repositoryID, 59 60 branchName: trimmed, 60 - baseRef: state.selectedBaseRef 61 + baseRef: state.selectedBaseRef, 62 + fetchOrigin: state.fetchOrigin 61 63 ) 62 64 ) 63 65 )
+44 -28
supacode/Features/Repositories/Views/WorktreeCreationPromptView.swift
··· 6 6 @FocusState private var isBranchFieldFocused: Bool 7 7 8 8 var body: some View { 9 - VStack(alignment: .leading, spacing: 16) { 10 - VStack(alignment: .leading, spacing: 4) { 11 - Text("New Worktree") 12 - .font(.title3) 13 - Text("Create a branch in \(store.repositoryName)") 14 - .foregroundStyle(.secondary) 15 - } 16 - 17 - VStack(alignment: .leading, spacing: 8) { 18 - Text("Branch name") 19 - .foregroundStyle(.secondary) 20 - TextField("feature/my-change", text: $store.branchName) 21 - .textFieldStyle(.roundedBorder) 9 + Form { 10 + Section { 11 + TextField("Branch name", text: $store.branchName) 22 12 .focused($isBranchFieldFocused) 23 13 .onSubmit { 24 14 store.send(.createButtonTapped) 25 15 } 16 + } header: { 17 + // `NavigationStack` with title and subtitle is bugged inside 18 + // sheets in macOS 26.*, and this is a nice enough fallback. 19 + Text("New Worktree") 20 + Text("Create a branch in `\(store.repositoryName)`.") 26 21 } 22 + .headerProminence(.increased) 27 23 28 - Picker("Branch from", selection: $store.selectedBaseRef) { 29 - Text(store.automaticBaseRefLabel) 30 - .tag(Optional<String>.none) 31 - ForEach(store.baseRefOptions, id: \.self) { ref in 32 - Text(ref) 33 - .tag(Optional(ref)) 24 + Section { 25 + Picker(selection: $store.selectedBaseRef) { 26 + automaticRefLabel 27 + .tag(Optional<String>.none) 28 + ForEach(store.baseRefOptions, id: \.self) { ref in 29 + Text(ref) 30 + .tag(Optional(ref)) 31 + } 32 + } label: { 33 + Text("Base ref") 34 + Text("The branch or ref the new worktree will be created from.") 34 35 } 35 - } 36 36 37 - if let validationMessage = store.validationMessage, !validationMessage.isEmpty { 38 - Text(validationMessage) 39 - .font(.footnote) 40 - .foregroundStyle(.red) 37 + Toggle(isOn: $store.fetchOrigin) { 38 + Text("Fetch remote branch") 39 + Text( 40 + "Runs `git fetch` to ensure the base branch is up to date before creating the worktree." 41 + ) 42 + } 43 + } footer: { 44 + if let validationMessage = store.validationMessage, !validationMessage.isEmpty { 45 + Text(validationMessage) 46 + .foregroundStyle(.red) 47 + } 41 48 } 42 49 50 + } 51 + .formStyle(.grouped) 52 + .scrollBounceBehavior(.basedOnSize) 53 + .safeAreaInset(edge: .bottom, spacing: 0) { 43 54 HStack { 44 55 if store.isValidating { 45 56 ProgressView() ··· 58 69 .help("Create (↩)") 59 70 .disabled(store.isValidating) 60 71 } 72 + .padding(.horizontal, 20) 73 + .padding(.bottom, 20) 61 74 } 62 - .padding(20) 63 75 .frame(minWidth: 420) 64 - .task { 65 - isBranchFieldFocused = true 66 - } 76 + .task { isBranchFieldFocused = true } 77 + } 78 + 79 + private var automaticRefLabel: Text { 80 + let ref = store.automaticBaseRef 81 + guard !ref.isEmpty else { return Text("Auto") } 82 + return Text("Auto \(Text(ref).foregroundStyle(.secondary))") 67 83 } 68 84 }
+7
supacode/Features/Settings/Models/GlobalSettings.swift
··· 15 15 var deleteBranchOnDeleteWorktree: Bool 16 16 var automaticallyArchiveMergedWorktrees: Bool 17 17 var promptForWorktreeCreation: Bool 18 + var fetchOriginBeforeWorktreeCreation: Bool 18 19 var defaultWorktreeBaseDirectoryPath: String? 19 20 var terminalThemeSyncEnabled: Bool 20 21 var shortcutOverrides: [AppShortcutID: AppShortcutOverride] ··· 36 37 deleteBranchOnDeleteWorktree: true, 37 38 automaticallyArchiveMergedWorktrees: false, 38 39 promptForWorktreeCreation: true, 40 + fetchOriginBeforeWorktreeCreation: true, 39 41 terminalThemeSyncEnabled: false, 40 42 defaultWorktreeBaseDirectoryPath: nil, 41 43 shortcutOverrides: [:] ··· 58 60 deleteBranchOnDeleteWorktree: Bool, 59 61 automaticallyArchiveMergedWorktrees: Bool, 60 62 promptForWorktreeCreation: Bool, 63 + fetchOriginBeforeWorktreeCreation: Bool = true, 61 64 terminalThemeSyncEnabled: Bool = false, 62 65 defaultWorktreeBaseDirectoryPath: String? = nil, 63 66 shortcutOverrides: [AppShortcutID: AppShortcutOverride] = [:] ··· 78 81 self.deleteBranchOnDeleteWorktree = deleteBranchOnDeleteWorktree 79 82 self.automaticallyArchiveMergedWorktrees = automaticallyArchiveMergedWorktrees 80 83 self.promptForWorktreeCreation = promptForWorktreeCreation 84 + self.fetchOriginBeforeWorktreeCreation = fetchOriginBeforeWorktreeCreation 81 85 self.terminalThemeSyncEnabled = terminalThemeSyncEnabled 82 86 self.defaultWorktreeBaseDirectoryPath = defaultWorktreeBaseDirectoryPath 83 87 self.shortcutOverrides = shortcutOverrides ··· 127 131 promptForWorktreeCreation = 128 132 try container.decodeIfPresent(Bool.self, forKey: .promptForWorktreeCreation) 129 133 ?? Self.default.promptForWorktreeCreation 134 + fetchOriginBeforeWorktreeCreation = 135 + try container.decodeIfPresent(Bool.self, forKey: .fetchOriginBeforeWorktreeCreation) 136 + ?? Self.default.fetchOriginBeforeWorktreeCreation 130 137 terminalThemeSyncEnabled = 131 138 try container.decodeIfPresent(Bool.self, forKey: .terminalThemeSyncEnabled) 132 139 ?? Self.default.terminalThemeSyncEnabled
+4
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 22 22 var deleteBranchOnDeleteWorktree: Bool 23 23 var automaticallyArchiveMergedWorktrees: Bool 24 24 var promptForWorktreeCreation: Bool 25 + var fetchOriginBeforeWorktreeCreation: Bool 25 26 var terminalThemeSyncEnabled: Bool 26 27 var defaultWorktreeBaseDirectoryPath: String 27 28 var shortcutOverrides: [AppShortcutID: AppShortcutOverride] ··· 50 51 deleteBranchOnDeleteWorktree = settings.deleteBranchOnDeleteWorktree 51 52 automaticallyArchiveMergedWorktrees = settings.automaticallyArchiveMergedWorktrees 52 53 promptForWorktreeCreation = settings.promptForWorktreeCreation 54 + fetchOriginBeforeWorktreeCreation = settings.fetchOriginBeforeWorktreeCreation 53 55 terminalThemeSyncEnabled = settings.terminalThemeSyncEnabled 54 56 shortcutOverrides = settings.shortcutOverrides 55 57 defaultWorktreeBaseDirectoryPath = ··· 74 76 deleteBranchOnDeleteWorktree: deleteBranchOnDeleteWorktree, 75 77 automaticallyArchiveMergedWorktrees: automaticallyArchiveMergedWorktrees, 76 78 promptForWorktreeCreation: promptForWorktreeCreation, 79 + fetchOriginBeforeWorktreeCreation: fetchOriginBeforeWorktreeCreation, 77 80 terminalThemeSyncEnabled: terminalThemeSyncEnabled, 78 81 defaultWorktreeBaseDirectoryPath: SupacodePaths.normalizedWorktreeBaseDirectoryPath( 79 82 defaultWorktreeBaseDirectoryPath ··· 153 156 state.deleteBranchOnDeleteWorktree = normalizedSettings.deleteBranchOnDeleteWorktree 154 157 state.automaticallyArchiveMergedWorktrees = normalizedSettings.automaticallyArchiveMergedWorktrees 155 158 state.promptForWorktreeCreation = normalizedSettings.promptForWorktreeCreation 159 + state.fetchOriginBeforeWorktreeCreation = normalizedSettings.fetchOriginBeforeWorktreeCreation 156 160 state.terminalThemeSyncEnabled = normalizedSettings.terminalThemeSyncEnabled 157 161 state.shortcutOverrides = normalizedSettings.shortcutOverrides 158 162 state.defaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath ?? ""
+4
supacode/Features/Settings/Views/WorktreeSettingsView.swift
··· 17 17 Text("Prompt for branch name on creation") 18 18 Text("Choose the branch name and base ref before creating the worktree.") 19 19 } 20 + Toggle(isOn: $store.fetchOriginBeforeWorktreeCreation) { 21 + Text("Fetch remote branch before creating worktree") 22 + Text("Runs git fetch to ensure the base branch is up to date.") 23 + } 20 24 TextField( 21 25 text: $store.defaultWorktreeBaseDirectoryPath, 22 26 prompt: Text(defaultPath)
+362 -13
supacodeTests/RepositoriesFeatureTests.swift
··· 558 558 $0.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 559 559 repositoryID: repository.id, 560 560 repositoryName: repository.name, 561 - automaticBaseRefLabel: "Automatic (origin/main)", 561 + automaticBaseRef: "origin/main", 562 562 baseRefOptions: ["origin/dev", "origin/main"], 563 563 branchName: "", 564 564 selectedBaseRef: nil, 565 + fetchOrigin: true, 565 566 validationMessage: nil 566 567 ) 567 568 } ··· 575 576 state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 576 577 repositoryID: repository.id, 577 578 repositoryName: repository.name, 578 - automaticBaseRefLabel: "Automatic (origin/main)", 579 + automaticBaseRef: "origin/main", 579 580 baseRefOptions: ["origin/main"], 580 581 branchName: "feature/new-branch", 581 582 selectedBaseRef: nil, 583 + fetchOrigin: true, 582 584 validationMessage: nil 583 585 ) 584 586 let store = TestStore(initialState: state) { ··· 590 592 } 591 593 } 592 594 595 + @Test(.dependencies) func promptedWorktreeCreationSubmitThreadsFetchOrigin() async { 596 + let repoRoot = "/tmp/repo" 597 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 598 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 599 + let createdWorktree = makeWorktree( 600 + id: "/tmp/repo/feature-new", 601 + name: "feature/new", 602 + repoRoot: repoRoot, 603 + ) 604 + let fetchedRemote = LockIsolated<String?>(nil) 605 + var state = makeState(repositories: [repository]) 606 + state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 607 + repositoryID: repository.id, 608 + repositoryName: repository.name, 609 + automaticBaseRef: "origin/main", 610 + baseRefOptions: ["origin/main"], 611 + branchName: "feature/new", 612 + selectedBaseRef: nil, 613 + fetchOrigin: true, 614 + validationMessage: nil 615 + ) 616 + let store = TestStore(initialState: state) { 617 + RepositoriesFeature() 618 + } withDependencies: { 619 + $0.uuid = .incrementing 620 + $0.gitClient.localBranchNames = { _ in [] } 621 + $0.gitClient.isValidBranchName = { _, _ in true } 622 + $0.gitClient.isBareRepository = { _ in false } 623 + $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 624 + $0.gitClient.ignoredFileCount = { _ in 0 } 625 + $0.gitClient.untrackedFileCount = { _ in 0 } 626 + $0.gitClient.remoteNames = { _ in ["origin"] } 627 + $0.gitClient.fetchRemote = { remote, _ in 628 + fetchedRemote.withValue { $0 = remote } 629 + } 630 + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in 631 + AsyncThrowingStream { continuation in 632 + continuation.yield(.finished(createdWorktree)) 633 + continuation.finish() 634 + } 635 + } 636 + $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } 637 + } 638 + store.exhaustivity = .off 639 + 640 + await store.send( 641 + .worktreeCreationPrompt( 642 + .presented( 643 + .delegate( 644 + .submit( 645 + repositoryID: repository.id, 646 + branchName: "feature/new", 647 + baseRef: nil, 648 + fetchOrigin: true 649 + ) 650 + ) 651 + ) 652 + ) 653 + ) 654 + await store.receive(\.createRandomWorktreeSucceeded) 655 + await store.finish() 656 + 657 + #expect(fetchedRemote.value == "origin") 658 + } 659 + 593 660 @Test func startPromptedWorktreeCreationWithDuplicateLocalBranchShowsValidation() async { 594 661 let repoRoot = "/tmp/repo" 595 662 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) ··· 598 665 state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 599 666 repositoryID: repository.id, 600 667 repositoryName: repository.name, 601 - automaticBaseRefLabel: "Automatic (origin/main)", 668 + automaticBaseRef: "origin/main", 602 669 baseRefOptions: ["origin/main"], 603 670 branchName: "feature/existing", 604 671 selectedBaseRef: nil, 672 + fetchOrigin: true, 605 673 validationMessage: nil 606 674 ) 607 675 let store = TestStore(initialState: state) { ··· 614 682 .startPromptedWorktreeCreation( 615 683 repositoryID: repository.id, 616 684 branchName: "feature/existing", 617 - baseRef: nil 685 + baseRef: nil, 686 + fetchOrigin: true 618 687 ) 619 688 ) { 620 689 $0.worktreeCreationPrompt?.validationMessage = nil ··· 679 748 $0.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 680 749 repositoryID: repoB.id, 681 750 repositoryName: repoB.name, 682 - automaticBaseRefLabel: "Automatic (origin/main)", 751 + automaticBaseRef: "origin/main", 683 752 baseRefOptions: ["origin/main"], 684 753 branchName: "", 685 754 selectedBaseRef: nil, 755 + fetchOrigin: true, 686 756 validationMessage: nil 687 757 ) 688 758 } ··· 698 768 state.worktreeCreationPrompt = WorktreeCreationPromptFeature.State( 699 769 repositoryID: repository.id, 700 770 repositoryName: repository.name, 701 - automaticBaseRefLabel: "Automatic (origin/main)", 771 + automaticBaseRef: "origin/main", 702 772 baseRefOptions: ["origin/main"], 703 773 branchName: "feature/new-branch", 704 774 selectedBaseRef: nil, 775 + fetchOrigin: true, 705 776 validationMessage: nil 706 777 ) 707 778 let store = TestStore(initialState: state) { ··· 717 788 .startPromptedWorktreeCreation( 718 789 repositoryID: repository.id, 719 790 branchName: "feature/new-branch", 720 - baseRef: nil 791 + baseRef: nil, 792 + fetchOrigin: true 721 793 ) 722 794 ) { 723 795 $0.worktreeCreationPrompt?.validationMessage = nil ··· 757 829 .createWorktreeInRepository( 758 830 repositoryID: repository.id, 759 831 nameSource: .explicit("../../Desktop"), 760 - baseRefSource: .repositorySetting 832 + baseRefSource: .repositorySetting, 833 + fetchOrigin: false 761 834 ) 762 835 ) 763 836 await store.receive(\.createRandomWorktreeFailed) { ··· 855 928 #expect(store.state.alert == nil) 856 929 } 857 930 931 + @Test(.dependencies) func createWorktreeFetchesRemoteWhenEnabled() async { 932 + let repoRoot = "/tmp/repo" 933 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 934 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 935 + let createdWorktree = makeWorktree( 936 + id: "/tmp/repo/swift-otter", 937 + name: "swift-otter", 938 + repoRoot: repoRoot, 939 + ) 940 + let fetchedRemote = LockIsolated<String?>(nil) 941 + @Shared(.settingsFile) var settingsFile 942 + $settingsFile.withLock { 943 + $0.global.promptForWorktreeCreation = false 944 + $0.global.fetchOriginBeforeWorktreeCreation = true 945 + } 946 + let store = TestStore(initialState: makeState(repositories: [repository])) { 947 + RepositoriesFeature() 948 + } withDependencies: { 949 + $0.uuid = .incrementing 950 + $0.gitClient.localBranchNames = { _ in [] } 951 + $0.gitClient.isBareRepository = { _ in false } 952 + $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 953 + $0.gitClient.ignoredFileCount = { _ in 0 } 954 + $0.gitClient.untrackedFileCount = { _ in 0 } 955 + $0.gitClient.remoteNames = { _ in ["origin"] } 956 + $0.gitClient.fetchRemote = { remote, _ in 957 + fetchedRemote.withValue { $0 = remote } 958 + } 959 + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in 960 + AsyncThrowingStream { continuation in 961 + continuation.yield(.finished(createdWorktree)) 962 + continuation.finish() 963 + } 964 + } 965 + $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } 966 + } 967 + store.exhaustivity = .off 968 + 969 + await store.send(.createRandomWorktreeInRepository(repository.id)) 970 + await store.receive(\.createRandomWorktreeSucceeded) 971 + await store.finish() 972 + 973 + #expect(fetchedRemote.value == "origin") 974 + } 975 + 976 + @Test(.dependencies) func createWorktreeSkipsFetchWhenDisabled() async { 977 + let repoRoot = "/tmp/repo" 978 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 979 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 980 + let createdWorktree = makeWorktree( 981 + id: "/tmp/repo/swift-otter", 982 + name: "swift-otter", 983 + repoRoot: repoRoot, 984 + ) 985 + let fetchCalled = LockIsolated(false) 986 + @Shared(.settingsFile) var settingsFile 987 + $settingsFile.withLock { 988 + $0.global.promptForWorktreeCreation = false 989 + $0.global.fetchOriginBeforeWorktreeCreation = false 990 + } 991 + let store = TestStore(initialState: makeState(repositories: [repository])) { 992 + RepositoriesFeature() 993 + } withDependencies: { 994 + $0.uuid = .incrementing 995 + $0.gitClient.localBranchNames = { _ in [] } 996 + $0.gitClient.isBareRepository = { _ in false } 997 + $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 998 + $0.gitClient.ignoredFileCount = { _ in 0 } 999 + $0.gitClient.untrackedFileCount = { _ in 0 } 1000 + $0.gitClient.remoteNames = { _ in ["origin"] } 1001 + $0.gitClient.fetchRemote = { _, _ in 1002 + fetchCalled.withValue { $0 = true } 1003 + } 1004 + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in 1005 + AsyncThrowingStream { continuation in 1006 + continuation.yield(.finished(createdWorktree)) 1007 + continuation.finish() 1008 + } 1009 + } 1010 + $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } 1011 + } 1012 + store.exhaustivity = .off 1013 + 1014 + await store.send(.createRandomWorktreeInRepository(repository.id)) 1015 + await store.receive(\.createRandomWorktreeSucceeded) 1016 + await store.finish() 1017 + 1018 + #expect(fetchCalled.value == false) 1019 + } 1020 + 1021 + @Test(.dependencies) func createWorktreeProceedsWhenFetchFails() async { 1022 + let repoRoot = "/tmp/repo" 1023 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1024 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1025 + let createdWorktree = makeWorktree( 1026 + id: "/tmp/repo/swift-otter", 1027 + name: "swift-otter", 1028 + repoRoot: repoRoot, 1029 + ) 1030 + @Shared(.settingsFile) var settingsFile 1031 + $settingsFile.withLock { 1032 + $0.global.promptForWorktreeCreation = false 1033 + $0.global.fetchOriginBeforeWorktreeCreation = true 1034 + } 1035 + let store = TestStore(initialState: makeState(repositories: [repository])) { 1036 + RepositoriesFeature() 1037 + } withDependencies: { 1038 + $0.uuid = .incrementing 1039 + $0.gitClient.localBranchNames = { _ in [] } 1040 + $0.gitClient.isBareRepository = { _ in false } 1041 + $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 1042 + $0.gitClient.ignoredFileCount = { _ in 0 } 1043 + $0.gitClient.untrackedFileCount = { _ in 0 } 1044 + $0.gitClient.remoteNames = { _ in ["origin"] } 1045 + $0.gitClient.fetchRemote = { _, _ in 1046 + throw GitClientError.commandFailed(command: "git fetch", message: "network error") 1047 + } 1048 + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in 1049 + AsyncThrowingStream { continuation in 1050 + continuation.yield(.finished(createdWorktree)) 1051 + continuation.finish() 1052 + } 1053 + } 1054 + $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } 1055 + } 1056 + store.exhaustivity = .off 1057 + 1058 + await store.send(.createRandomWorktreeInRepository(repository.id)) 1059 + await store.receive(\.createRandomWorktreeSucceeded) 1060 + await store.finish() 1061 + 1062 + #expect(store.state.alert == nil) 1063 + } 1064 + 1065 + @Test(.dependencies) func createWorktreeSkipsFetchForLocalRef() async { 1066 + let repoRoot = "/tmp/repo" 1067 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1068 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1069 + let createdWorktree = makeWorktree( 1070 + id: "/tmp/repo/swift-otter", 1071 + name: "swift-otter", 1072 + repoRoot: repoRoot, 1073 + ) 1074 + let fetchCalled = LockIsolated(false) 1075 + @Shared(.settingsFile) var settingsFile 1076 + $settingsFile.withLock { 1077 + $0.global.promptForWorktreeCreation = false 1078 + $0.global.fetchOriginBeforeWorktreeCreation = true 1079 + } 1080 + @Shared(.repositorySettings(URL(fileURLWithPath: repoRoot))) var repoSettings 1081 + $repoSettings.withLock { $0.worktreeBaseRef = "main" } 1082 + let store = TestStore(initialState: makeState(repositories: [repository])) { 1083 + RepositoriesFeature() 1084 + } withDependencies: { 1085 + $0.uuid = .incrementing 1086 + $0.gitClient.localBranchNames = { _ in [] } 1087 + $0.gitClient.isBareRepository = { _ in false } 1088 + $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 1089 + $0.gitClient.ignoredFileCount = { _ in 0 } 1090 + $0.gitClient.untrackedFileCount = { _ in 0 } 1091 + $0.gitClient.remoteNames = { _ in ["origin"] } 1092 + $0.gitClient.fetchRemote = { _, _ in 1093 + fetchCalled.withValue { $0 = true } 1094 + } 1095 + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in 1096 + AsyncThrowingStream { continuation in 1097 + continuation.yield(.finished(createdWorktree)) 1098 + continuation.finish() 1099 + } 1100 + } 1101 + $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } 1102 + } 1103 + store.exhaustivity = .off 1104 + 1105 + await store.send(.createRandomWorktreeInRepository(repository.id)) 1106 + await store.receive(\.createRandomWorktreeSucceeded) 1107 + await store.finish() 1108 + 1109 + #expect(fetchCalled.value == false) 1110 + } 1111 + 1112 + @Test(.dependencies) func createWorktreeFetchesCorrectRemoteWithAmbiguousPrefixes() async { 1113 + let repoRoot = "/tmp/repo" 1114 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1115 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1116 + let createdWorktree = makeWorktree( 1117 + id: "/tmp/repo/swift-otter", 1118 + name: "swift-otter", 1119 + repoRoot: repoRoot, 1120 + ) 1121 + let fetchedRemote = LockIsolated<String?>(nil) 1122 + @Shared(.settingsFile) var settingsFile 1123 + $settingsFile.withLock { 1124 + $0.global.promptForWorktreeCreation = false 1125 + $0.global.fetchOriginBeforeWorktreeCreation = true 1126 + } 1127 + @Shared(.repositorySettings(URL(fileURLWithPath: repoRoot))) var repoSettings 1128 + $repoSettings.withLock { $0.worktreeBaseRef = "origin-fork/main" } 1129 + let store = TestStore(initialState: makeState(repositories: [repository])) { 1130 + RepositoriesFeature() 1131 + } withDependencies: { 1132 + $0.uuid = .incrementing 1133 + $0.gitClient.localBranchNames = { _ in [] } 1134 + $0.gitClient.isBareRepository = { _ in false } 1135 + $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 1136 + $0.gitClient.ignoredFileCount = { _ in 0 } 1137 + $0.gitClient.untrackedFileCount = { _ in 0 } 1138 + $0.gitClient.remoteNames = { _ in ["origin", "origin-fork"] } 1139 + $0.gitClient.fetchRemote = { remote, _ in 1140 + fetchedRemote.withValue { $0 = remote } 1141 + } 1142 + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in 1143 + AsyncThrowingStream { continuation in 1144 + continuation.yield(.finished(createdWorktree)) 1145 + continuation.finish() 1146 + } 1147 + } 1148 + $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } 1149 + } 1150 + store.exhaustivity = .off 1151 + 1152 + await store.send(.createRandomWorktreeInRepository(repository.id)) 1153 + await store.receive(\.createRandomWorktreeSucceeded) 1154 + await store.finish() 1155 + 1156 + #expect(fetchedRemote.value == "origin-fork") 1157 + } 1158 + 1159 + @Test(.dependencies) func createWorktreeSkipsFetchWhenRemoteNamesThrows() async { 1160 + let repoRoot = "/tmp/repo" 1161 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1162 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1163 + let createdWorktree = makeWorktree( 1164 + id: "/tmp/repo/swift-otter", 1165 + name: "swift-otter", 1166 + repoRoot: repoRoot, 1167 + ) 1168 + let fetchCalled = LockIsolated(false) 1169 + @Shared(.settingsFile) var settingsFile 1170 + $settingsFile.withLock { 1171 + $0.global.promptForWorktreeCreation = false 1172 + $0.global.fetchOriginBeforeWorktreeCreation = true 1173 + } 1174 + let store = TestStore(initialState: makeState(repositories: [repository])) { 1175 + RepositoriesFeature() 1176 + } withDependencies: { 1177 + $0.uuid = .incrementing 1178 + $0.gitClient.localBranchNames = { _ in [] } 1179 + $0.gitClient.isBareRepository = { _ in false } 1180 + $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 1181 + $0.gitClient.ignoredFileCount = { _ in 0 } 1182 + $0.gitClient.untrackedFileCount = { _ in 0 } 1183 + $0.gitClient.remoteNames = { _ in 1184 + throw GitClientError.commandFailed(command: "git remote", message: "not a git repo") 1185 + } 1186 + $0.gitClient.fetchRemote = { _, _ in 1187 + fetchCalled.withValue { $0 = true } 1188 + } 1189 + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in 1190 + AsyncThrowingStream { continuation in 1191 + continuation.yield(.finished(createdWorktree)) 1192 + continuation.finish() 1193 + } 1194 + } 1195 + $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } 1196 + } 1197 + store.exhaustivity = .off 1198 + 1199 + await store.send(.createRandomWorktreeInRepository(repository.id)) 1200 + await store.receive(\.createRandomWorktreeSucceeded) 1201 + await store.finish() 1202 + 1203 + #expect(fetchCalled.value == false) 1204 + #expect(store.state.alert == nil) 1205 + } 1206 + 858 1207 @Test(.dependencies) func createRandomWorktreeUsesRepositoryWorktreeBaseDirectoryOverride() async { 859 1208 let repoRoot = "/tmp/repo" 860 1209 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) ··· 1087 1436 id: pendingID, 1088 1437 repositoryID: repository.id, 1089 1438 progress: WorktreeCreationProgress(stage: .loadingLocalBranches) 1090 - ) 1439 + ), 1091 1440 ] 1092 1441 let store = TestStore(initialState: state) { 1093 1442 RepositoriesFeature() ··· 1124 1473 stage: .checkingRepositoryMode, 1125 1474 worktreeName: "swift-otter" 1126 1475 ) 1127 - ) 1476 + ), 1128 1477 ] 1129 1478 let store = TestStore(initialState: state) { 1130 1479 RepositoriesFeature() ··· 1321 1670 addedLines: nil, 1322 1671 removedLines: nil, 1323 1672 pullRequest: makePullRequest(state: "MERGED") 1324 - ) 1673 + ), 1325 1674 ] 1326 1675 let store = TestStore(initialState: state) { 1327 1676 RepositoriesFeature() ··· 2503 2852 id: removedWorktree.id, 2504 2853 repositoryID: repository.id, 2505 2854 progress: WorktreeCreationProgress(stage: .choosingWorktreeName) 2506 - ) 2855 + ), 2507 2856 ] 2508 2857 initialState.pinnedWorktreeIDs = [removedWorktree.id] 2509 2858 initialState.worktreeInfoByID = [ ··· 2586 2935 id: pendingID, 2587 2936 repositoryID: repository.id, 2588 2937 progress: WorktreeCreationProgress(stage: .loadingLocalBranches) 2589 - ) 2938 + ), 2590 2939 ] 2591 2940 initialState.selection = .worktree(pendingID) 2592 2941 initialState.sidebarSelectedWorktreeIDs = [existingWorktree.id, pendingID]
+1
supacodeTests/SettingsFeatureTests.swift
··· 55 55 $0.deleteBranchOnDeleteWorktree = false 56 56 $0.automaticallyArchiveMergedWorktrees = true 57 57 $0.promptForWorktreeCreation = true 58 + $0.fetchOriginBeforeWorktreeCreation = true 58 59 $0.terminalThemeSyncEnabled = false 59 60 } 60 61 await store.receive(\.delegate.settingsChanged)
+31
supacodeTests/WorktreeCreationProgressTests.swift
··· 3 3 @testable import supacode 4 4 5 5 struct WorktreeCreationProgressTests { 6 + @Test func fetchingOriginShowsDetailTextWithRemoteName() { 7 + let progress = WorktreeCreationProgress( 8 + stage: .fetchingOrigin, 9 + worktreeName: "swift-otter", 10 + baseRef: "origin/main", 11 + fetchRemoteName: "origin", 12 + ) 13 + 14 + #expect(progress.titleText == "Creating swift-otter") 15 + #expect(progress.detailText == "Fetching latest from origin") 16 + } 17 + 18 + @Test func fetchingOriginShowsGenericTextWhenRemoteNameMissing() { 19 + let progress = WorktreeCreationProgress( 20 + stage: .fetchingOrigin, 21 + worktreeName: "swift-otter", 22 + ) 23 + 24 + #expect(progress.detailText == "Fetching latest from remote") 25 + } 26 + 27 + @Test func fetchingOriginShowsCustomRemoteName() { 28 + let progress = WorktreeCreationProgress( 29 + stage: .fetchingOrigin, 30 + worktreeName: "swift-otter", 31 + fetchRemoteName: "upstream", 32 + ) 33 + 34 + #expect(progress.detailText == "Fetching latest from upstream") 35 + } 36 + 6 37 @Test func resolvingBaseReferenceUsesHeadFallback() { 7 38 let progress = WorktreeCreationProgress( 8 39 stage: .resolvingBaseReference,