native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #192 from onevcat/feat/issue-178-global-defaults

feat: global defaults for copy flags and merge strategy

authored by

Wei Wang and committed by
GitHub
d35d4620 51a76169

+452 -60
+5 -1
supacode/Features/App/Reducer/AppFeature.swift
··· 377 377 } 378 378 @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 379 379 @Shared(.userRepositorySettings(repository.rootURL)) var userRepositorySettings 380 - state.settings.repositorySettings = RepositorySettingsFeature.State( 380 + var repoSettingsState = RepositorySettingsFeature.State( 381 381 rootURL: repository.rootURL, 382 382 repositoryKind: repository.kind, 383 383 settings: repositorySettings, 384 384 userSettings: userRepositorySettings 385 385 ) 386 + repoSettingsState.globalCopyIgnoredOnWorktreeCreate = state.settings.copyIgnoredOnWorktreeCreate 387 + repoSettingsState.globalCopyUntrackedOnWorktreeCreate = state.settings.copyUntrackedOnWorktreeCreate 388 + repoSettingsState.globalPullRequestMergeStrategy = state.settings.pullRequestMergeStrategy 389 + state.settings.repositorySettings = repoSettingsState 386 390 case .general, .notifications, .shortcuts, .worktree, .updates, .advanced, .github: 387 391 state.settings.repositorySettings = nil 388 392 }
+2 -1
supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift
··· 333 333 return 334 334 } 335 335 @Shared(.repositorySettings(repoRoot)) var repositorySettings 336 - let strategy = repositorySettings.pullRequestMergeStrategy 336 + @Shared(.settingsFile) var settingsFile 337 + let strategy = repositorySettings.pullRequestMergeStrategy ?? settingsFile.global.pullRequestMergeStrategy 337 338 await send(.showToast(.inProgress("Merging pull request…"))) 338 339 do { 339 340 try await githubCLI.mergePullRequest(worktreeRoot, pullRequest.number, strategy)
+4 -2
supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeCreation.swift
··· 246 246 repositoryOverridePath: repositorySettings.worktreeBaseDirectoryPath 247 247 ) 248 248 let selectedBaseRef = repositorySettings.worktreeBaseRef 249 - let copyIgnoredOnWorktreeCreate = repositorySettings.copyIgnoredOnWorktreeCreate 250 - let copyUntrackedOnWorktreeCreate = repositorySettings.copyUntrackedOnWorktreeCreate 249 + let copyIgnoredOnWorktreeCreate = 250 + repositorySettings.copyIgnoredOnWorktreeCreate ?? settingsFile.global.copyIgnoredOnWorktreeCreate 251 + let copyUntrackedOnWorktreeCreate = 252 + repositorySettings.copyUntrackedOnWorktreeCreate ?? settingsFile.global.copyUntrackedOnWorktreeCreate 251 253 state.pendingWorktrees.append( 252 254 PendingWorktree( 253 255 id: pendingID,
+28 -8
supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift
··· 10 10 var settings: RepositorySettings 11 11 var userSettings: UserRepositorySettings 12 12 var globalDefaultWorktreeBaseDirectoryPath: String? 13 + var globalCopyIgnoredOnWorktreeCreate: Bool = false 14 + var globalCopyUntrackedOnWorktreeCreate: Bool = false 15 + var globalPullRequestMergeStrategy: PullRequestMergeStrategy = .merge 13 16 var isBareRepository = false 14 17 var branchOptions: [String] = [] 15 18 var defaultWorktreeBaseRef = "origin/main" ··· 65 68 UserRepositorySettings, 66 69 isBareRepository: Bool, 67 70 globalDefaultWorktreeBaseDirectoryPath: String?, 71 + globalCopyIgnoredOnWorktreeCreate: Bool, 72 + globalCopyUntrackedOnWorktreeCreate: Bool, 73 + globalPullRequestMergeStrategy: PullRequestMergeStrategy, 68 74 keybindingUserOverrides: KeybindingUserOverrideStore 69 75 ) 70 76 case branchDataLoaded([String], defaultBaseRef: String) ··· 90 96 @Shared(.repositorySettings(rootURL)) var repositorySettings 91 97 @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings 92 98 @Shared(.settingsFile) var settingsFile 99 + let global = settingsFile.global 93 100 await send( 94 101 .settingsLoaded( 95 102 repositorySettings, 96 103 userRepositorySettings, 97 104 isBareRepository: false, 98 - globalDefaultWorktreeBaseDirectoryPath: settingsFile.global.defaultWorktreeBaseDirectoryPath, 99 - keybindingUserOverrides: settingsFile.global.keybindingUserOverrides 105 + globalDefaultWorktreeBaseDirectoryPath: global.defaultWorktreeBaseDirectoryPath, 106 + globalCopyIgnoredOnWorktreeCreate: global.copyIgnoredOnWorktreeCreate, 107 + globalCopyUntrackedOnWorktreeCreate: global.copyUntrackedOnWorktreeCreate, 108 + globalPullRequestMergeStrategy: global.pullRequestMergeStrategy, 109 + keybindingUserOverrides: global.keybindingUserOverrides 100 110 ) 101 111 ) 102 112 } ··· 107 117 @Shared(.repositorySettings(rootURL)) var repositorySettings 108 118 @Shared(.userRepositorySettings(rootURL)) var userRepositorySettings 109 119 @Shared(.settingsFile) var settingsFile 120 + let global = settingsFile.global 110 121 await send( 111 122 .settingsLoaded( 112 123 repositorySettings, 113 124 userRepositorySettings, 114 125 isBareRepository: isBareRepository, 115 - globalDefaultWorktreeBaseDirectoryPath: settingsFile.global.defaultWorktreeBaseDirectoryPath, 116 - keybindingUserOverrides: settingsFile.global.keybindingUserOverrides 126 + globalDefaultWorktreeBaseDirectoryPath: global.defaultWorktreeBaseDirectoryPath, 127 + globalCopyIgnoredOnWorktreeCreate: global.copyIgnoredOnWorktreeCreate, 128 + globalCopyUntrackedOnWorktreeCreate: global.copyUntrackedOnWorktreeCreate, 129 + globalPullRequestMergeStrategy: global.pullRequestMergeStrategy, 130 + keybindingUserOverrides: global.keybindingUserOverrides 117 131 ) 118 132 ) 119 133 let branches: [String] ··· 135 149 let userSettings, 136 150 let isBareRepository, 137 151 let globalDefaultWorktreeBaseDirectoryPath, 152 + let globalCopyIgnoredOnWorktreeCreate, 153 + let globalCopyUntrackedOnWorktreeCreate, 154 + let globalPullRequestMergeStrategy, 138 155 let keybindingUserOverrides 139 156 ): 140 157 var updatedSettings = settings ··· 143 160 repositoryRootURL: state.rootURL 144 161 ) 145 162 if isBareRepository { 146 - updatedSettings.copyIgnoredOnWorktreeCreate = false 147 - updatedSettings.copyUntrackedOnWorktreeCreate = false 163 + updatedSettings.copyIgnoredOnWorktreeCreate = nil 164 + updatedSettings.copyUntrackedOnWorktreeCreate = nil 148 165 } 149 166 state.settings = updatedSettings 150 167 state.userSettings = userSettings.normalized() 151 168 state.globalDefaultWorktreeBaseDirectoryPath = 152 169 SupacodePaths.normalizedWorktreeBaseDirectoryPath(globalDefaultWorktreeBaseDirectoryPath) 170 + state.globalCopyIgnoredOnWorktreeCreate = globalCopyIgnoredOnWorktreeCreate 171 + state.globalCopyUntrackedOnWorktreeCreate = globalCopyUntrackedOnWorktreeCreate 172 + state.globalPullRequestMergeStrategy = globalPullRequestMergeStrategy 153 173 state.isBareRepository = isBareRepository 154 174 state.keybindingUserOverrides = keybindingUserOverrides 155 175 guard updatedSettings != settings else { return .none } ··· 173 193 174 194 case .binding: 175 195 if state.isBareRepository { 176 - state.settings.copyIgnoredOnWorktreeCreate = false 177 - state.settings.copyUntrackedOnWorktreeCreate = false 196 + state.settings.copyIgnoredOnWorktreeCreate = nil 197 + state.settings.copyUntrackedOnWorktreeCreate = nil 178 198 } 179 199 state.userSettings = state.userSettings.normalized() 180 200 let rootURL = state.rootURL
+21
supacode/Features/Settings/Models/GlobalSettings.swift
··· 19 19 var promptForWorktreeCreation: Bool 20 20 var fetchOriginBeforeWorktreeCreation: Bool 21 21 var defaultWorktreeBaseDirectoryPath: String? 22 + var copyIgnoredOnWorktreeCreate: Bool 23 + var copyUntrackedOnWorktreeCreate: Bool 24 + var pullRequestMergeStrategy: PullRequestMergeStrategy 22 25 var restoreTerminalLayoutOnLaunch: Bool 23 26 var terminalFontSize: Float32? 24 27 var archivedAutoDeletePeriod: AutoDeletePeriod? ··· 45 48 promptForWorktreeCreation: true, 46 49 fetchOriginBeforeWorktreeCreation: true, 47 50 defaultWorktreeBaseDirectoryPath: nil, 51 + copyIgnoredOnWorktreeCreate: false, 52 + copyUntrackedOnWorktreeCreate: false, 53 + pullRequestMergeStrategy: .merge, 48 54 restoreTerminalLayoutOnLaunch: false, 49 55 archivedAutoDeletePeriod: nil, 50 56 terminalFontSize: nil, ··· 72 78 promptForWorktreeCreation: Bool, 73 79 fetchOriginBeforeWorktreeCreation: Bool = true, 74 80 defaultWorktreeBaseDirectoryPath: String? = nil, 81 + copyIgnoredOnWorktreeCreate: Bool = false, 82 + copyUntrackedOnWorktreeCreate: Bool = false, 83 + pullRequestMergeStrategy: PullRequestMergeStrategy = .merge, 75 84 restoreTerminalLayoutOnLaunch: Bool = false, 76 85 archivedAutoDeletePeriod: AutoDeletePeriod? = nil, 77 86 terminalFontSize: Float32? = nil, ··· 97 106 self.promptForWorktreeCreation = promptForWorktreeCreation 98 107 self.fetchOriginBeforeWorktreeCreation = fetchOriginBeforeWorktreeCreation 99 108 self.defaultWorktreeBaseDirectoryPath = defaultWorktreeBaseDirectoryPath 109 + self.copyIgnoredOnWorktreeCreate = copyIgnoredOnWorktreeCreate 110 + self.copyUntrackedOnWorktreeCreate = copyUntrackedOnWorktreeCreate 111 + self.pullRequestMergeStrategy = pullRequestMergeStrategy 100 112 self.restoreTerminalLayoutOnLaunch = restoreTerminalLayoutOnLaunch 101 113 self.archivedAutoDeletePeriod = archivedAutoDeletePeriod 102 114 self.terminalFontSize = terminalFontSize ··· 222 234 defaultWorktreeBaseDirectoryPath = 223 235 try container.decodeIfPresent(String.self, forKey: .defaultWorktreeBaseDirectoryPath) 224 236 ?? Self.default.defaultWorktreeBaseDirectoryPath 237 + copyIgnoredOnWorktreeCreate = 238 + try container.decodeIfPresent(Bool.self, forKey: .copyIgnoredOnWorktreeCreate) 239 + ?? Self.default.copyIgnoredOnWorktreeCreate 240 + copyUntrackedOnWorktreeCreate = 241 + try container.decodeIfPresent(Bool.self, forKey: .copyUntrackedOnWorktreeCreate) 242 + ?? Self.default.copyUntrackedOnWorktreeCreate 243 + pullRequestMergeStrategy = 244 + try container.decodeIfPresent(PullRequestMergeStrategy.self, forKey: .pullRequestMergeStrategy) 245 + ?? Self.default.pullRequestMergeStrategy 225 246 restoreTerminalLayoutOnLaunch = 226 247 try container.decodeIfPresent(Bool.self, forKey: .restoreTerminalLayoutOnLaunch) 227 248 ?? Self.default.restoreTerminalLayoutOnLaunch
+70 -24
supacode/Features/Settings/Models/RepositorySettings.swift
··· 1 1 import Foundation 2 2 3 3 nonisolated struct RepositorySettings: Codable, Equatable, Sendable { 4 + private static let currentSchemaVersion = 2 5 + private static let legacyCopyIgnoredDefault = false 6 + private static let legacyCopyUntrackedDefault = false 7 + private static let legacyMergeStrategyDefault: PullRequestMergeStrategy = .merge 8 + 4 9 var setupScript: String 5 10 var archiveScript: String 6 11 var runScript: String 7 12 var openActionID: String 8 13 var worktreeBaseRef: String? 9 14 var worktreeBaseDirectoryPath: String? 10 - var copyIgnoredOnWorktreeCreate: Bool 11 - var copyUntrackedOnWorktreeCreate: Bool 12 - var pullRequestMergeStrategy: PullRequestMergeStrategy 15 + var copyIgnoredOnWorktreeCreate: Bool? 16 + var copyUntrackedOnWorktreeCreate: Bool? 17 + var pullRequestMergeStrategy: PullRequestMergeStrategy? 18 + private var schemaVersion: Int 13 19 14 20 private enum CodingKeys: String, CodingKey { 21 + case schemaVersion 15 22 case setupScript 16 23 case archiveScript 17 24 case runScript ··· 30 37 openActionID: OpenWorktreeAction.automaticSettingsID, 31 38 worktreeBaseRef: nil, 32 39 worktreeBaseDirectoryPath: nil, 33 - copyIgnoredOnWorktreeCreate: false, 34 - copyUntrackedOnWorktreeCreate: false, 35 - pullRequestMergeStrategy: .merge 40 + copyIgnoredOnWorktreeCreate: nil, 41 + copyUntrackedOnWorktreeCreate: nil, 42 + pullRequestMergeStrategy: nil 36 43 ) 37 44 38 45 init( ··· 42 49 openActionID: String, 43 50 worktreeBaseRef: String?, 44 51 worktreeBaseDirectoryPath: String? = nil, 45 - copyIgnoredOnWorktreeCreate: Bool, 46 - copyUntrackedOnWorktreeCreate: Bool, 47 - pullRequestMergeStrategy: PullRequestMergeStrategy 52 + copyIgnoredOnWorktreeCreate: Bool? = nil, 53 + copyUntrackedOnWorktreeCreate: Bool? = nil, 54 + pullRequestMergeStrategy: PullRequestMergeStrategy? = nil 48 55 ) { 49 56 self.setupScript = setupScript 50 57 self.archiveScript = archiveScript ··· 55 62 self.copyIgnoredOnWorktreeCreate = copyIgnoredOnWorktreeCreate 56 63 self.copyUntrackedOnWorktreeCreate = copyUntrackedOnWorktreeCreate 57 64 self.pullRequestMergeStrategy = pullRequestMergeStrategy 65 + schemaVersion = Self.currentSchemaVersion 58 66 } 59 67 60 68 init(from decoder: Decoder) throws { 61 69 let container = try decoder.container(keyedBy: CodingKeys.self) 70 + let decodedSchemaVersion = 71 + try container.decodeIfPresent(Int.self, forKey: .schemaVersion) 72 + ?? 1 62 73 setupScript = 63 74 try container.decodeIfPresent(String.self, forKey: .setupScript) 64 75 ?? Self.default.setupScript ··· 75 86 try container.decodeIfPresent(String.self, forKey: .worktreeBaseRef) 76 87 worktreeBaseDirectoryPath = 77 88 try container.decodeIfPresent(String.self, forKey: .worktreeBaseDirectoryPath) 78 - copyIgnoredOnWorktreeCreate = 79 - try container.decodeIfPresent( 80 - Bool.self, 81 - forKey: .copyIgnoredOnWorktreeCreate 82 - ) ?? Self.default.copyIgnoredOnWorktreeCreate 83 - copyUntrackedOnWorktreeCreate = 84 - try container.decodeIfPresent( 85 - Bool.self, 86 - forKey: .copyUntrackedOnWorktreeCreate 87 - ) ?? Self.default.copyUntrackedOnWorktreeCreate 88 - pullRequestMergeStrategy = 89 - try container.decodeIfPresent( 90 - PullRequestMergeStrategy.self, 91 - forKey: .pullRequestMergeStrategy 92 - ) ?? Self.default.pullRequestMergeStrategy 89 + if decodedSchemaVersion >= Self.currentSchemaVersion { 90 + copyIgnoredOnWorktreeCreate = 91 + try container.decodeIfPresent( 92 + Bool.self, 93 + forKey: .copyIgnoredOnWorktreeCreate 94 + ) 95 + copyUntrackedOnWorktreeCreate = 96 + try container.decodeIfPresent( 97 + Bool.self, 98 + forKey: .copyUntrackedOnWorktreeCreate 99 + ) 100 + pullRequestMergeStrategy = 101 + try container.decodeIfPresent( 102 + PullRequestMergeStrategy.self, 103 + forKey: .pullRequestMergeStrategy 104 + ) 105 + } else { 106 + copyIgnoredOnWorktreeCreate = Self.normalizeLegacyOverride( 107 + try container.decodeIfPresent( 108 + Bool.self, 109 + forKey: .copyIgnoredOnWorktreeCreate 110 + ), 111 + legacyDefault: Self.legacyCopyIgnoredDefault 112 + ) 113 + copyUntrackedOnWorktreeCreate = Self.normalizeLegacyOverride( 114 + try container.decodeIfPresent( 115 + Bool.self, 116 + forKey: .copyUntrackedOnWorktreeCreate 117 + ), 118 + legacyDefault: Self.legacyCopyUntrackedDefault 119 + ) 120 + pullRequestMergeStrategy = Self.normalizeLegacyOverride( 121 + try container.decodeIfPresent( 122 + PullRequestMergeStrategy.self, 123 + forKey: .pullRequestMergeStrategy 124 + ), 125 + legacyDefault: Self.legacyMergeStrategyDefault 126 + ) 127 + } 128 + schemaVersion = Self.currentSchemaVersion 129 + } 130 + } 131 + 132 + extension RepositorySettings { 133 + nonisolated private static func normalizeLegacyOverride<Value: Equatable>( 134 + _ value: Value?, 135 + legacyDefault: Value 136 + ) -> Value? { 137 + guard let value else { return nil } 138 + return value == legacyDefault ? nil : value 93 139 } 94 140 }
+28 -8
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 26 26 var promptForWorktreeCreation: Bool 27 27 var fetchRemoteBeforeWorktreeCreation: Bool 28 28 var defaultWorktreeBaseDirectoryPath: String 29 + var copyIgnoredOnWorktreeCreate: Bool 30 + var copyUntrackedOnWorktreeCreate: Bool 31 + var pullRequestMergeStrategy: PullRequestMergeStrategy 29 32 var restoreTerminalLayoutOnLaunch: Bool 30 33 var terminalFontSize: Float32? 31 34 var keybindingUserOverrides: KeybindingUserOverrideStore ··· 59 62 fetchRemoteBeforeWorktreeCreation = settings.fetchOriginBeforeWorktreeCreation 60 63 defaultWorktreeBaseDirectoryPath = 61 64 SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) ?? "" 65 + copyIgnoredOnWorktreeCreate = settings.copyIgnoredOnWorktreeCreate 66 + copyUntrackedOnWorktreeCreate = settings.copyUntrackedOnWorktreeCreate 67 + pullRequestMergeStrategy = settings.pullRequestMergeStrategy 62 68 restoreTerminalLayoutOnLaunch = settings.restoreTerminalLayoutOnLaunch 63 69 terminalFontSize = settings.terminalFontSize 64 70 keybindingUserOverrides = settings.keybindingUserOverrides ··· 88 94 defaultWorktreeBaseDirectoryPath: SupacodePaths.normalizedWorktreeBaseDirectoryPath( 89 95 defaultWorktreeBaseDirectoryPath 90 96 ), 97 + copyIgnoredOnWorktreeCreate: copyIgnoredOnWorktreeCreate, 98 + copyUntrackedOnWorktreeCreate: copyUntrackedOnWorktreeCreate, 99 + pullRequestMergeStrategy: pullRequestMergeStrategy, 91 100 restoreTerminalLayoutOnLaunch: restoreTerminalLayoutOnLaunch, 92 101 archivedAutoDeletePeriod: archivedAutoDeletePeriod, 93 102 terminalFontSize: terminalFontSize, ··· 185 194 state.promptForWorktreeCreation = normalizedSettings.promptForWorktreeCreation 186 195 state.fetchRemoteBeforeWorktreeCreation = normalizedSettings.fetchOriginBeforeWorktreeCreation 187 196 state.defaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath ?? "" 197 + state.copyIgnoredOnWorktreeCreate = normalizedSettings.copyIgnoredOnWorktreeCreate 198 + state.copyUntrackedOnWorktreeCreate = normalizedSettings.copyUntrackedOnWorktreeCreate 199 + state.pullRequestMergeStrategy = normalizedSettings.pullRequestMergeStrategy 188 200 state.restoreTerminalLayoutOnLaunch = normalizedSettings.restoreTerminalLayoutOnLaunch 189 201 state.terminalFontSize = normalizedSettings.terminalFontSize 190 202 state.keybindingUserOverrides = normalizedSettings.keybindingUserOverrides 191 - state.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath = 192 - normalizedSettings.defaultWorktreeBaseDirectoryPath 203 + state.syncGlobalDefaults(from: normalizedSettings) 193 204 return .send(.delegate(.settingsChanged(normalizedSettings))) 194 205 195 206 case .binding: 196 207 state.commandFinishedNotificationThreshold = min(max(state.commandFinishedNotificationThreshold, 0), 600) 197 - let defaultWorktreeBaseDirectoryPath = state.globalSettings.defaultWorktreeBaseDirectoryPath 198 - state.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath = 199 - defaultWorktreeBaseDirectoryPath 208 + state.syncGlobalDefaults(from: state.globalSettings) 200 209 return persist(state) 201 210 202 211 case .setCommandFinishedNotificationThreshold(let text): ··· 209 218 210 219 case .setSystemNotificationsEnabled(let isEnabled): 211 220 state.systemNotificationsEnabled = isEnabled 212 - let defaultWorktreeBaseDirectoryPath = state.globalSettings.defaultWorktreeBaseDirectoryPath 213 - state.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath = 214 - defaultWorktreeBaseDirectoryPath 221 + state.syncGlobalDefaults(from: state.globalSettings) 215 222 return persist(state) 216 223 217 224 case .setTerminalFontSize(let fontSize): ··· 366 373 return .none 367 374 } 368 375 } 376 + 377 + extension SettingsFeature.State { 378 + mutating func syncGlobalDefaults(from settings: GlobalSettings) { 379 + repositorySettings?.globalDefaultWorktreeBaseDirectoryPath = 380 + settings.defaultWorktreeBaseDirectoryPath 381 + repositorySettings?.globalCopyIgnoredOnWorktreeCreate = 382 + settings.copyIgnoredOnWorktreeCreate 383 + repositorySettings?.globalCopyUntrackedOnWorktreeCreate = 384 + settings.copyUntrackedOnWorktreeCreate 385 + repositorySettings?.globalPullRequestMergeStrategy = 386 + settings.pullRequestMergeStrategy 387 + } 388 + }
+10
supacode/Features/Settings/Views/GithubSettingsView.swift
··· 120 120 } 121 121 } 122 122 } 123 + Section("Pull Requests") { 124 + Picker(selection: $store.pullRequestMergeStrategy) { 125 + ForEach(PullRequestMergeStrategy.allCases) { strategy in 126 + Text(strategy.title).tag(strategy) 127 + } 128 + } label: { 129 + Text("Merge strategy") 130 + Text("Default strategy when merging PRs from the command palette.") 131 + } 132 + } 123 133 } 124 134 .formStyle(.grouped) 125 135
+31 -15
supacode/Features/Settings/Views/RepositorySettingsView.swift
··· 125 125 } 126 126 .frame(maxWidth: .infinity, alignment: .leading) 127 127 128 - Toggle( 129 - "Copy ignored files to new worktrees", 130 - isOn: settings.copyIgnoredOnWorktreeCreate 131 - ) 128 + Picker(selection: settings.copyIgnoredOnWorktreeCreate) { 129 + Text( 130 + "Global \(Text(store.globalCopyIgnoredOnWorktreeCreate ? "Yes" : "No").foregroundStyle(.secondary))" 131 + ) 132 + .tag(Bool?.none) 133 + Text("Yes").tag(Bool?.some(true)) 134 + Text("No").tag(Bool?.some(false)) 135 + } label: { 136 + Text("Copy ignored files to new worktrees") 137 + Text("Copies gitignored files from the main worktree.") 138 + } 132 139 .disabled(store.isBareRepository) 133 140 134 - Toggle( 135 - "Copy untracked files to new worktrees", 136 - isOn: settings.copyUntrackedOnWorktreeCreate 137 - ) 141 + Picker(selection: settings.copyUntrackedOnWorktreeCreate) { 142 + Text( 143 + "Global \(Text(store.globalCopyUntrackedOnWorktreeCreate ? "Yes" : "No").foregroundStyle(.secondary))" 144 + ) 145 + .tag(Bool?.none) 146 + Text("Yes").tag(Bool?.some(true)) 147 + Text("No").tag(Bool?.some(false)) 148 + } label: { 149 + Text("Copy untracked files to new worktrees") 150 + Text("Copies untracked files from the main worktree.") 151 + } 138 152 .disabled(store.isBareRepository) 139 153 140 154 if store.isBareRepository { ··· 152 166 153 167 if store.showsPullRequestSettings { 154 168 Section { 155 - Picker( 156 - "Merge strategy", 157 - selection: settings.pullRequestMergeStrategy 158 - ) { 169 + Picker(selection: settings.pullRequestMergeStrategy) { 170 + Text( 171 + "Global \(Text(store.globalPullRequestMergeStrategy.title).foregroundStyle(.secondary))" 172 + ) 173 + .tag(PullRequestMergeStrategy?.none) 159 174 ForEach(PullRequestMergeStrategy.allCases) { strategy in 160 - Text(strategy.title) 161 - .tag(strategy) 175 + Text(strategy.title).tag(PullRequestMergeStrategy?.some(strategy)) 162 176 } 177 + } label: { 178 + Text("Merge strategy") 179 + Text("Used when merging PRs from the command palette.") 163 180 } 164 - .labelsHidden() 165 181 } header: { 166 182 VStack(alignment: .leading, spacing: 4) { 167 183 Text("Pull Requests")
+10
supacode/Features/Settings/Views/WorktreeSettingsView.swift
··· 88 88 } 89 89 } 90 90 } 91 + Section("Copy Defaults") { 92 + Toggle(isOn: $store.copyIgnoredOnWorktreeCreate) { 93 + Text("Copy ignored files to new worktrees") 94 + Text("Copies gitignored files from the main worktree.") 95 + } 96 + Toggle(isOn: $store.copyUntrackedOnWorktreeCreate) { 97 + Text("Copy untracked files to new worktrees") 98 + Text("Copies untracked files from the main worktree.") 99 + } 100 + } 91 101 } 92 102 .formStyle(.grouped) 93 103 }
+146
supacodeTests/RepositoriesFeatureTests.swift
··· 1561 1561 #expect(observedBaseDirectory.value == expectedBaseDirectory) 1562 1562 } 1563 1563 1564 + @Test(.dependencies) func createRandomWorktreeUsesGlobalCopyFlagsWhenRepositoryOverridesMissing() async { 1565 + let repoRoot = "/tmp/repo" 1566 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1567 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1568 + let createdWorktree = makeWorktree( 1569 + id: "/tmp/repo/swift-otter", 1570 + name: "swift-otter", 1571 + repoRoot: repoRoot 1572 + ) 1573 + let observedCopyFlags = LockIsolated<(Bool, Bool)?>(nil) 1574 + @Shared(.settingsFile) var settingsFile 1575 + $settingsFile.withLock { 1576 + $0.global.promptForWorktreeCreation = false 1577 + $0.global.copyIgnoredOnWorktreeCreate = true 1578 + $0.global.copyUntrackedOnWorktreeCreate = true 1579 + } 1580 + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 1581 + $repositorySettings.withLock { 1582 + $0.copyIgnoredOnWorktreeCreate = nil 1583 + $0.copyUntrackedOnWorktreeCreate = nil 1584 + } 1585 + let store = TestStore(initialState: makeState(repositories: [repository])) { 1586 + RepositoriesFeature() 1587 + } withDependencies: { 1588 + $0.uuid = .incrementing 1589 + $0.gitClient.localBranchNames = { _ in [] } 1590 + $0.gitClient.isBareRepository = { _ in false } 1591 + $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 1592 + $0.gitClient.ignoredFileCount = { _ in 0 } 1593 + $0.gitClient.untrackedFileCount = { _ in 0 } 1594 + $0.gitClient.createWorktreeStream = { _, _, _, copyIgnored, copyUntracked, _ in 1595 + observedCopyFlags.withValue { $0 = (copyIgnored, copyUntracked) } 1596 + return AsyncThrowingStream { continuation in 1597 + continuation.yield(.finished(createdWorktree)) 1598 + continuation.finish() 1599 + } 1600 + } 1601 + $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } 1602 + } 1603 + store.exhaustivity = .off 1604 + 1605 + await store.send(.worktreeCreation(.createRandomWorktreeInRepository(repository.id))) 1606 + await store.receive(\.worktreeCreation.createRandomWorktreeSucceeded) 1607 + await store.finish() 1608 + 1609 + #expect(observedCopyFlags.value?.0 == true) 1610 + #expect(observedCopyFlags.value?.1 == true) 1611 + } 1612 + 1613 + @Test(.dependencies) func createRandomWorktreeInBareRepositoryIgnoresGlobalCopyFlags() async { 1614 + let repoRoot = "/tmp/repo" 1615 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 1616 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 1617 + let createdWorktree = makeWorktree( 1618 + id: "/tmp/repo/swift-otter", 1619 + name: "swift-otter", 1620 + repoRoot: repoRoot 1621 + ) 1622 + let observedCopyFlags = LockIsolated<(Bool, Bool)?>(nil) 1623 + @Shared(.settingsFile) var settingsFile 1624 + $settingsFile.withLock { 1625 + $0.global.promptForWorktreeCreation = false 1626 + $0.global.copyIgnoredOnWorktreeCreate = true 1627 + $0.global.copyUntrackedOnWorktreeCreate = true 1628 + } 1629 + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 1630 + $repositorySettings.withLock { 1631 + $0.copyIgnoredOnWorktreeCreate = nil 1632 + $0.copyUntrackedOnWorktreeCreate = nil 1633 + } 1634 + let store = TestStore(initialState: makeState(repositories: [repository])) { 1635 + RepositoriesFeature() 1636 + } withDependencies: { 1637 + $0.uuid = .incrementing 1638 + $0.gitClient.localBranchNames = { _ in [] } 1639 + $0.gitClient.isBareRepository = { _ in true } 1640 + $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 1641 + $0.gitClient.ignoredFileCount = { _ in 0 } 1642 + $0.gitClient.untrackedFileCount = { _ in 0 } 1643 + $0.gitClient.createWorktreeStream = { _, _, _, copyIgnored, copyUntracked, _ in 1644 + observedCopyFlags.withValue { $0 = (copyIgnored, copyUntracked) } 1645 + return AsyncThrowingStream { continuation in 1646 + continuation.yield(.finished(createdWorktree)) 1647 + continuation.finish() 1648 + } 1649 + } 1650 + $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } 1651 + } 1652 + store.exhaustivity = .off 1653 + 1654 + await store.send(.worktreeCreation(.createRandomWorktreeInRepository(repository.id))) 1655 + await store.receive(\.worktreeCreation.createRandomWorktreeSucceeded) 1656 + await store.finish() 1657 + 1658 + #expect(observedCopyFlags.value?.0 == false) 1659 + #expect(observedCopyFlags.value?.1 == false) 1660 + } 1661 + 1564 1662 @Test(.dependencies) func createRandomWorktreeInRepositoryStreamFailureRemovesPendingWorktree() async { 1565 1663 let repoRoot = "/tmp/repo" 1566 1664 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) ··· 2915 3013 #expect(store.state.worktreeInfoByID[featureWorktree.id]?.pullRequest?.state == "OPEN") 2916 3014 #expect(store.state.archivedWorktrees.isEmpty) 2917 3015 #expect(mergedNumbers.value == [12]) 3016 + await store.finish() 3017 + } 3018 + 3019 + @Test func pullRequestActionMergeUsesGlobalStrategyWhenRepositoryOverrideMissing() async { 3020 + let repoRoot = "/tmp/repo" 3021 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 3022 + let featureWorktree = makeWorktree( 3023 + id: "\(repoRoot)/feature", 3024 + name: "feature", 3025 + repoRoot: repoRoot 3026 + ) 3027 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureWorktree]) 3028 + let openPullRequest = makePullRequest(state: "OPEN", headRefName: featureWorktree.name, number: 12) 3029 + var state = makeState(repositories: [repository]) 3030 + state.githubIntegrationAvailability = .disabled 3031 + state.worktreeInfoByID[featureWorktree.id] = WorktreeInfoEntry( 3032 + addedLines: nil, 3033 + removedLines: nil, 3034 + pullRequest: openPullRequest 3035 + ) 3036 + let mergedStrategies = LockIsolated<[PullRequestMergeStrategy]>([]) 3037 + @Shared(.settingsFile) var settingsFile 3038 + $settingsFile.withLock { 3039 + $0.global.pullRequestMergeStrategy = .squash 3040 + } 3041 + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 3042 + $repositorySettings.withLock { 3043 + $0.pullRequestMergeStrategy = nil 3044 + } 3045 + let store = TestStore(initialState: state) { 3046 + RepositoriesFeature() 3047 + } withDependencies: { 3048 + $0.githubIntegration.isAvailable = { true } 3049 + $0.githubCLI.mergePullRequest = { _, _, strategy in 3050 + mergedStrategies.withValue { $0.append(strategy) } 3051 + } 3052 + } 3053 + store.exhaustivity = .off 3054 + 3055 + await store.send(.githubIntegration(.pullRequestAction(featureWorktree.id, .merge))) 3056 + await store.receive(\.showToast) { 3057 + $0.statusToast = .inProgress("Merging pull request…") 3058 + } 3059 + await store.receive(\.showToast) { 3060 + $0.statusToast = .success("Pull request merged") 3061 + } 3062 + await store.receive(\.worktreeInfoEvent) 3063 + #expect(mergedStrategies.value == [.squash]) 2918 3064 await store.finish() 2919 3065 } 2920 3066
+1 -1
supacodeTests/RepositorySettingsFeatureTests.swift
··· 22 22 worktreeBaseRef: "origin/main", 23 23 copyIgnoredOnWorktreeCreate: true, 24 24 copyUntrackedOnWorktreeCreate: true, 25 - pullRequestMergeStrategy: .squash 25 + pullRequestMergeStrategy: .squash, 26 26 ) 27 27 let storedOnevcatSettings = UserRepositorySettings( 28 28 customCommands: [.default(index: 0)]
+55
supacodeTests/RepositorySettingsKeyTests.swift
··· 81 81 #expect(settings.archiveScript.isEmpty) 82 82 } 83 83 84 + @Test(.dependencies) func loadNormalizesLegacyDefaultOverridesToInheritedValues() throws { 85 + let globalStorage = SettingsTestStorage() 86 + let localStorage = RepositoryLocalSettingsTestStorage() 87 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 88 + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 89 + let localURL = SupacodePaths.repositorySettingsURL(for: rootURL) 90 + let legacyData = Data( 91 + """ 92 + { 93 + "setupScript": "", 94 + "archiveScript": "", 95 + "runScript": "echo run", 96 + "openActionID": "automatic", 97 + "copyIgnoredOnWorktreeCreate": false, 98 + "copyUntrackedOnWorktreeCreate": true, 99 + "pullRequestMergeStrategy": "merge" 100 + } 101 + """.utf8 102 + ) 103 + 104 + try localStorage.save(legacyData, at: localURL) 105 + 106 + let loaded = withDependencies { 107 + $0.settingsFileStorage = globalStorage.storage 108 + $0.settingsFileURL = settingsFileURL 109 + $0.repositoryLocalSettingsStorage = localStorage.storage 110 + } operation: { 111 + @Shared(.repositorySettings(rootURL)) var repositorySettings: RepositorySettings 112 + return repositorySettings 113 + } 114 + 115 + #expect(loaded.copyIgnoredOnWorktreeCreate == nil) 116 + #expect(loaded.copyUntrackedOnWorktreeCreate == true) 117 + #expect(loaded.pullRequestMergeStrategy == nil) 118 + } 119 + 120 + @Test func decodeCurrentSchemaPreservesExplicitDefaultOverrides() throws { 121 + let settings = RepositorySettings( 122 + setupScript: "", 123 + archiveScript: "", 124 + runScript: "echo run", 125 + openActionID: OpenWorktreeAction.automaticSettingsID, 126 + worktreeBaseRef: nil, 127 + copyIgnoredOnWorktreeCreate: false, 128 + copyUntrackedOnWorktreeCreate: false, 129 + pullRequestMergeStrategy: .merge, 130 + ) 131 + 132 + let decoded = try JSONDecoder().decode(RepositorySettings.self, from: encode(settings)) 133 + 134 + #expect(decoded.copyIgnoredOnWorktreeCreate == false) 135 + #expect(decoded.copyUntrackedOnWorktreeCreate == false) 136 + #expect(decoded.pullRequestMergeStrategy == .merge) 137 + } 138 + 84 139 @Test(.dependencies) func loadPrefersLocalSupacodeJSONOverGlobalEntry() throws { 85 140 let globalStorage = SettingsTestStorage() 86 141 let localStorage = RepositoryLocalSettingsTestStorage()
+41
supacodeTests/SettingsFeatureTests.swift
··· 248 248 #expect(settingsFile.global.defaultWorktreeBaseDirectoryPath == expectedPath) 249 249 } 250 250 251 + @Test(.dependencies) func changingGlobalOverrideDefaultsUpdatesRepositorySettingsState() async { 252 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 253 + @Shared(.settingsFile) var settingsFile 254 + $settingsFile.withLock { $0.global = .default } 255 + var state = SettingsFeature.State() 256 + state.repositorySettings = RepositorySettingsFeature.State( 257 + rootURL: rootURL, 258 + repositoryKind: .git, 259 + settings: .default, 260 + userSettings: .default 261 + ) 262 + let store = TestStore(initialState: state) { 263 + SettingsFeature() 264 + } 265 + 266 + await store.send(.binding(.set(\.copyIgnoredOnWorktreeCreate, true))) { 267 + $0.copyIgnoredOnWorktreeCreate = true 268 + $0.repositorySettings?.globalCopyIgnoredOnWorktreeCreate = true 269 + } 270 + await store.receive(\.delegate.settingsChanged) 271 + 272 + await store.send(.binding(.set(\.copyUntrackedOnWorktreeCreate, true))) { 273 + $0.copyUntrackedOnWorktreeCreate = true 274 + $0.repositorySettings?.globalCopyUntrackedOnWorktreeCreate = true 275 + } 276 + await store.receive(\.delegate.settingsChanged) 277 + 278 + await store.send(.binding(.set(\.pullRequestMergeStrategy, .squash))) { 279 + $0.pullRequestMergeStrategy = .squash 280 + $0.repositorySettings?.globalPullRequestMergeStrategy = .squash 281 + } 282 + await store.receive(\.delegate.settingsChanged) 283 + 284 + #expect(store.state.repositorySettings?.globalCopyIgnoredOnWorktreeCreate == true) 285 + #expect(store.state.repositorySettings?.globalCopyUntrackedOnWorktreeCreate == true) 286 + #expect(store.state.repositorySettings?.globalPullRequestMergeStrategy == .squash) 287 + #expect(settingsFile.global.copyIgnoredOnWorktreeCreate == true) 288 + #expect(settingsFile.global.copyUntrackedOnWorktreeCreate == true) 289 + #expect(settingsFile.global.pullRequestMergeStrategy == .squash) 290 + } 291 + 251 292 @Test(.dependencies) func setTerminalFontSizePersistsWithoutAnalyticsOrGlobalFanout() async { 252 293 var initialSettings = GlobalSettings.default 253 294 initialSettings.analyticsEnabled = true