native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #142 from supabitapp/pr-128-worktree-directory-settings

Add configurable global and per-repo worktree base directories

authored by

khoi and committed by
GitHub
d73d6cd7 5c9f7bbd

+534 -65
+14 -16
supacode/Clients/Git/GitClient.swift
··· 238 238 nonisolated func createWorktree( 239 239 named name: String, 240 240 in repoRoot: URL, 241 - copyIgnored: Bool, 242 - copyUntracked: Bool, 241 + baseDirectory: URL, 242 + copyFiles: (ignored: Bool, untracked: Bool), 243 243 baseRef: String 244 244 ) async throws -> Worktree { 245 245 var createdWorktree: Worktree? 246 246 for try await event in createWorktreeStream( 247 247 named: name, 248 248 in: repoRoot, 249 - copyIgnored: copyIgnored, 250 - copyUntracked: copyUntracked, 249 + baseDirectory: baseDirectory, 250 + copyFiles: copyFiles, 251 251 baseRef: baseRef 252 252 ) { 253 253 if case .finished(let worktree) = event { ··· 255 255 } 256 256 } 257 257 guard let createdWorktree else { 258 - let repositoryRootURL = repoRoot.standardizedFileURL 259 258 let wtURL = try wtScriptURL() 260 259 let command = 261 260 ([wtURL.lastPathComponent] 262 261 + createWorktreeArguments( 263 - repositoryRootURL: repositoryRootURL, 262 + baseDirectory: baseDirectory, 264 263 name: name, 265 - copyIgnored: copyIgnored, 266 - copyUntracked: copyUntracked, 264 + copyIgnored: copyFiles.ignored, 265 + copyUntracked: copyFiles.untracked, 267 266 baseRef: baseRef 268 267 )).joined(separator: " ") 269 268 throw GitClientError.commandFailed(command: command, message: "Empty output") ··· 274 273 nonisolated func createWorktreeStream( 275 274 named name: String, 276 275 in repoRoot: URL, 277 - copyIgnored: Bool, 278 - copyUntracked: Bool, 276 + baseDirectory: URL, 277 + copyFiles: (ignored: Bool, untracked: Bool), 279 278 baseRef: String 280 279 ) -> AsyncThrowingStream<GitWorktreeCreateEvent, Error> { 281 280 AsyncThrowingStream { continuation in ··· 284 283 do { 285 284 let wtURL = try wtScriptURL() 286 285 let arguments = createWorktreeArguments( 287 - repositoryRootURL: repositoryRootURL, 286 + baseDirectory: baseDirectory, 288 287 name: name, 289 - copyIgnored: copyIgnored, 290 - copyUntracked: copyUntracked, 288 + copyIgnored: copyFiles.ignored, 289 + copyUntracked: copyFiles.untracked, 291 290 baseRef: baseRef 292 291 ) 293 292 let envURL = URL(fileURLWithPath: "/usr/bin/env") ··· 355 354 } 356 355 357 356 nonisolated private func createWorktreeArguments( 358 - repositoryRootURL: URL, 357 + baseDirectory: URL, 359 358 name: String, 360 359 copyIgnored: Bool, 361 360 copyUntracked: Bool, 362 361 baseRef: String 363 362 ) -> [String] { 364 - let baseDir = SupacodePaths.repositoryDirectory(for: repositoryRootURL) 365 - var arguments = ["--base-dir", baseDir.path(percentEncoded: false), "sw"] 363 + var arguments = ["--base-dir", baseDirectory.path(percentEncoded: false), "sw"] 366 364 if copyIgnored { 367 365 arguments.append("--copy-ignored") 368 366 }
+8 -6
supacode/Clients/Repositories/GitClientDependency.swift
··· 16 16 @Sendable ( 17 17 _ name: String, 18 18 _ repoRoot: URL, 19 + _ baseDirectory: URL, 19 20 _ copyIgnored: Bool, 20 21 _ copyUntracked: Bool, 21 22 _ baseRef: String ··· 25 26 @Sendable ( 26 27 _ name: String, 27 28 _ repoRoot: URL, 29 + _ baseDirectory: URL, 28 30 _ copyIgnored: Bool, 29 31 _ copyUntracked: Bool, 30 32 _ baseRef: String ··· 51 53 automaticWorktreeBaseRef: { await GitClient().automaticWorktreeBaseRef(for: $0) }, 52 54 ignoredFileCount: { try await GitClient().ignoredFileCount(for: $0) }, 53 55 untrackedFileCount: { try await GitClient().untrackedFileCount(for: $0) }, 54 - createWorktree: { name, repoRoot, copyIgnored, copyUntracked, baseRef in 56 + createWorktree: { name, repoRoot, baseDirectory, copyIgnored, copyUntracked, baseRef in 55 57 try await GitClient().createWorktree( 56 58 named: name, 57 59 in: repoRoot, 58 - copyIgnored: copyIgnored, 59 - copyUntracked: copyUntracked, 60 + baseDirectory: baseDirectory, 61 + copyFiles: (ignored: copyIgnored, untracked: copyUntracked), 60 62 baseRef: baseRef 61 63 ) 62 64 }, 63 - createWorktreeStream: { name, repoRoot, copyIgnored, copyUntracked, baseRef in 65 + createWorktreeStream: { name, repoRoot, baseDirectory, copyIgnored, copyUntracked, baseRef in 64 66 GitClient().createWorktreeStream( 65 67 named: name, 66 68 in: repoRoot, 67 - copyIgnored: copyIgnored, 68 - copyUntracked: copyUntracked, 69 + baseDirectory: baseDirectory, 70 + copyFiles: (ignored: copyIgnored, untracked: copyUntracked), 69 71 baseRef: baseRef 70 72 ) 71 73 },
+32 -14
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 181 181 pendingID: Worktree.ID, 182 182 previousSelection: Worktree.ID?, 183 183 repositoryID: Repository.ID, 184 - name: String? 184 + name: String?, 185 + baseDirectory: URL 185 186 ) 186 187 case consumeSetupScript(Worktree.ID) 187 188 case consumeTerminalFocus(Worktree.ID) ··· 812 813 } 813 814 let previousSelection = state.selectedWorktreeID 814 815 let pendingID = "pending:\(uuid().uuidString)" 816 + @Shared(.settingsFile) var settingsFile 815 817 @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 818 + let globalDefaultWorktreeBaseDirectoryPath = settingsFile.global.defaultWorktreeBaseDirectoryPath 819 + let worktreeBaseDirectory = SupacodePaths.worktreeBaseDirectory( 820 + for: repository.rootURL, 821 + globalDefaultPath: globalDefaultWorktreeBaseDirectoryPath, 822 + repositoryOverridePath: repositorySettings.worktreeBaseDirectoryPath 823 + ) 816 824 let selectedBaseRef = repositorySettings.worktreeBaseRef 817 825 let copyIgnoredOnWorktreeCreate = repositorySettings.copyIgnoredOnWorktreeCreate 818 826 let copyUntrackedOnWorktreeCreate = repositorySettings.copyUntrackedOnWorktreeCreate ··· 866 874 pendingID: pendingID, 867 875 previousSelection: previousSelection, 868 876 repositoryID: repository.id, 869 - name: nil 877 + name: nil, 878 + baseDirectory: worktreeBaseDirectory 870 879 ) 871 880 ) 872 881 return ··· 882 891 pendingID: pendingID, 883 892 previousSelection: previousSelection, 884 893 repositoryID: repository.id, 885 - name: nil 894 + name: nil, 895 + baseDirectory: worktreeBaseDirectory 886 896 ) 887 897 ) 888 898 return ··· 895 905 pendingID: pendingID, 896 906 previousSelection: previousSelection, 897 907 repositoryID: repository.id, 898 - name: nil 908 + name: nil, 909 + baseDirectory: worktreeBaseDirectory 899 910 ) 900 911 ) 901 912 return ··· 908 919 pendingID: pendingID, 909 920 previousSelection: previousSelection, 910 921 repositoryID: repository.id, 911 - name: nil 922 + name: nil, 923 + baseDirectory: worktreeBaseDirectory 912 924 ) 913 925 ) 914 926 return ··· 921 933 pendingID: pendingID, 922 934 previousSelection: previousSelection, 923 935 repositoryID: repository.id, 924 - name: nil 936 + name: nil, 937 + baseDirectory: worktreeBaseDirectory 925 938 ) 926 939 ) 927 940 return ··· 971 984 copyUntracked ? ((try? await gitClient.untrackedFileCount(repository.rootURL)) ?? 0) : 0 972 985 progress.stage = .creatingWorktree 973 986 progress.commandText = worktreeCreateCommand( 974 - repositoryRootURL: repository.rootURL, 987 + baseDirectoryURL: worktreeBaseDirectory, 975 988 name: name, 976 989 copyIgnored: copyIgnored, 977 990 copyUntracked: copyUntracked, ··· 986 999 let stream = createWorktreeStream( 987 1000 name, 988 1001 repository.rootURL, 1002 + worktreeBaseDirectory, 989 1003 copyIgnored, 990 1004 copyUntracked, 991 1005 resolvedBaseRef ··· 1045 1059 pendingID: pendingID, 1046 1060 previousSelection: previousSelection, 1047 1061 repositoryID: repository.id, 1048 - name: newWorktreeName 1062 + name: newWorktreeName, 1063 + baseDirectory: worktreeBaseDirectory 1049 1064 ) 1050 1065 ) 1051 1066 } ··· 1091 1106 let pendingID, 1092 1107 let previousSelection, 1093 1108 let repositoryID, 1094 - let name 1109 + let name, 1110 + let baseDirectory 1095 1111 ): 1096 1112 let previousSelectedWorktree = state.worktree(for: previousSelection) 1097 1113 removePendingWorktree(pendingID, state: &state) ··· 1099 1115 let cleanup = cleanupFailedWorktree( 1100 1116 repositoryID: repositoryID, 1101 1117 name: name, 1118 + baseDirectory: baseDirectory, 1102 1119 state: &state 1103 1120 ) 1104 1121 state.alert = messageAlert(title: title, message: message) ··· 3196 3213 private func cleanupFailedWorktree( 3197 3214 repositoryID: Repository.ID, 3198 3215 name: String?, 3216 + baseDirectory: URL, 3199 3217 state: inout RepositoriesFeature.State 3200 3218 ) -> FailedWorktreeCleanup { 3201 3219 guard let name, !name.isEmpty else { ··· 3207 3225 ) 3208 3226 } 3209 3227 let repositoryRootURL = URL(fileURLWithPath: repositoryID).standardizedFileURL 3210 - let baseDirectory = SupacodePaths.repositoryDirectory(for: repositoryRootURL).standardizedFileURL 3228 + let normalizedBaseDirectory = baseDirectory.standardizedFileURL 3211 3229 let worktreeURL = 3212 - baseDirectory 3230 + normalizedBaseDirectory 3213 3231 .appending(path: name, directoryHint: .isDirectory) 3214 3232 .standardizedFileURL 3215 - guard isPathInsideBaseDirectory(worktreeURL, baseDirectory: baseDirectory) else { 3233 + guard isPathInsideBaseDirectory(worktreeURL, baseDirectory: normalizedBaseDirectory) else { 3216 3234 return FailedWorktreeCleanup( 3217 3235 didRemoveWorktree: false, 3218 3236 didUpdatePinned: false, ··· 3301 3319 } 3302 3320 3303 3321 private nonisolated func worktreeCreateCommand( 3304 - repositoryRootURL: URL, 3322 + baseDirectoryURL: URL, 3305 3323 name: String, 3306 3324 copyIgnored: Bool, 3307 3325 copyUntracked: Bool, 3308 3326 baseRef: String 3309 3327 ) -> String { 3310 - let baseDir = SupacodePaths.repositoryDirectory(for: repositoryRootURL).path(percentEncoded: false) 3328 + let baseDir = baseDirectoryURL.path(percentEncoded: false) 3311 3329 var parts = ["wt", "--base-dir", baseDir, "sw"] 3312 3330 if copyIgnored { 3313 3331 parts.append("--copy-ignored")
+38 -5
supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift
··· 7 7 struct State: Equatable { 8 8 var rootURL: URL 9 9 var settings: RepositorySettings 10 + var globalDefaultWorktreeBaseDirectoryPath: String? 10 11 var isBareRepository = false 11 12 var branchOptions: [String] = [] 12 13 var defaultWorktreeBaseRef = "origin/main" 13 14 var isBranchDataLoaded = false 15 + 16 + var exampleWorktreePath: String { 17 + SupacodePaths.exampleWorktreePath( 18 + for: rootURL, 19 + globalDefaultPath: globalDefaultWorktreeBaseDirectoryPath, 20 + repositoryOverridePath: settings.worktreeBaseDirectoryPath 21 + ) 22 + } 14 23 } 15 24 16 25 enum Action: BindableAction { 17 26 case task 18 - case settingsLoaded(RepositorySettings, isBareRepository: Bool) 27 + case settingsLoaded( 28 + RepositorySettings, 29 + isBareRepository: Bool, 30 + globalDefaultWorktreeBaseDirectoryPath: String? 31 + ) 19 32 case branchDataLoaded([String], defaultBaseRef: String) 20 33 case delegate(Delegate) 21 34 case binding(BindingAction<State>) ··· 35 48 case .task: 36 49 let rootURL = state.rootURL 37 50 @Shared(.repositorySettings(rootURL)) var repositorySettings 51 + @Shared(.settingsFile) var settingsFile 38 52 let settings = repositorySettings 53 + let globalDefaultWorktreeBaseDirectoryPath = 54 + settingsFile.global.defaultWorktreeBaseDirectoryPath 39 55 let gitClient = gitClient 40 56 return .run { send in 41 57 let isBareRepository = (try? await gitClient.isBareRepository(rootURL)) ?? false 42 - await send(.settingsLoaded(settings, isBareRepository: isBareRepository)) 58 + await send( 59 + .settingsLoaded( 60 + settings, 61 + isBareRepository: isBareRepository, 62 + globalDefaultWorktreeBaseDirectoryPath: globalDefaultWorktreeBaseDirectoryPath 63 + ) 64 + ) 43 65 let branches: [String] 44 66 do { 45 67 branches = try await gitClient.branchRefs(rootURL) ··· 54 76 await send(.branchDataLoaded(branches, defaultBaseRef: defaultBaseRef)) 55 77 } 56 78 57 - case .settingsLoaded(let settings, let isBareRepository): 79 + case .settingsLoaded(let settings, let isBareRepository, let globalDefaultWorktreeBaseDirectoryPath): 58 80 var updatedSettings = settings 81 + updatedSettings.worktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath( 82 + updatedSettings.worktreeBaseDirectoryPath, 83 + repositoryRootURL: state.rootURL 84 + ) 59 85 if isBareRepository { 60 86 updatedSettings.copyIgnoredOnWorktreeCreate = false 61 87 updatedSettings.copyUntrackedOnWorktreeCreate = false 62 88 } 63 89 state.settings = updatedSettings 90 + state.globalDefaultWorktreeBaseDirectoryPath = 91 + SupacodePaths.normalizedWorktreeBaseDirectoryPath(globalDefaultWorktreeBaseDirectoryPath) 64 92 state.isBareRepository = isBareRepository 65 - guard isBareRepository, updatedSettings != settings else { return .none } 93 + guard updatedSettings != settings else { return .none } 66 94 let rootURL = state.rootURL 67 95 @Shared(.repositorySettings(rootURL)) var repositorySettings 68 96 $repositorySettings.withLock { $0 = updatedSettings } ··· 87 115 state.settings.copyUntrackedOnWorktreeCreate = false 88 116 } 89 117 let rootURL = state.rootURL 118 + var normalizedSettings = state.settings 119 + normalizedSettings.worktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath( 120 + normalizedSettings.worktreeBaseDirectoryPath, 121 + repositoryRootURL: rootURL 122 + ) 90 123 @Shared(.repositorySettings(rootURL)) var repositorySettings 91 - $repositorySettings.withLock { $0 = state.settings } 124 + $repositorySettings.withLock { $0 = normalizedSettings } 92 125 return .send(.delegate(.settingsChanged(rootURL))) 93 126 94 127 case .delegate:
+9 -2
supacode/Features/Settings/Models/GlobalSettings.swift
··· 15 15 var deleteBranchOnDeleteWorktree: Bool 16 16 var automaticallyArchiveMergedWorktrees: Bool 17 17 var promptForWorktreeCreation: Bool 18 + var defaultWorktreeBaseDirectoryPath: String? 18 19 19 20 static let `default` = GlobalSettings( 20 21 appearanceMode: .dark, ··· 32 33 githubIntegrationEnabled: true, 33 34 deleteBranchOnDeleteWorktree: true, 34 35 automaticallyArchiveMergedWorktrees: false, 35 - promptForWorktreeCreation: true 36 + promptForWorktreeCreation: true, 37 + defaultWorktreeBaseDirectoryPath: nil 36 38 ) 37 39 38 40 init( ··· 51 53 githubIntegrationEnabled: Bool, 52 54 deleteBranchOnDeleteWorktree: Bool, 53 55 automaticallyArchiveMergedWorktrees: Bool, 54 - promptForWorktreeCreation: Bool 56 + promptForWorktreeCreation: Bool, 57 + defaultWorktreeBaseDirectoryPath: String? = nil 55 58 ) { 56 59 self.appearanceMode = appearanceMode 57 60 self.defaultEditorID = defaultEditorID ··· 69 72 self.deleteBranchOnDeleteWorktree = deleteBranchOnDeleteWorktree 70 73 self.automaticallyArchiveMergedWorktrees = automaticallyArchiveMergedWorktrees 71 74 self.promptForWorktreeCreation = promptForWorktreeCreation 75 + self.defaultWorktreeBaseDirectoryPath = defaultWorktreeBaseDirectoryPath 72 76 } 73 77 74 78 init(from decoder: any Decoder) throws { ··· 115 119 promptForWorktreeCreation = 116 120 try container.decodeIfPresent(Bool.self, forKey: .promptForWorktreeCreation) 117 121 ?? Self.default.promptForWorktreeCreation 122 + defaultWorktreeBaseDirectoryPath = 123 + try container.decodeIfPresent(String.self, forKey: .defaultWorktreeBaseDirectoryPath) 124 + ?? Self.default.defaultWorktreeBaseDirectoryPath 118 125 } 119 126 }
+7
supacode/Features/Settings/Models/RepositorySettings.swift
··· 6 6 var runScript: String 7 7 var openActionID: String 8 8 var worktreeBaseRef: String? 9 + var worktreeBaseDirectoryPath: String? 9 10 var copyIgnoredOnWorktreeCreate: Bool 10 11 var copyUntrackedOnWorktreeCreate: Bool 11 12 var pullRequestMergeStrategy: PullRequestMergeStrategy ··· 16 17 case runScript 17 18 case openActionID 18 19 case worktreeBaseRef 20 + case worktreeBaseDirectoryPath 19 21 case copyIgnoredOnWorktreeCreate 20 22 case copyUntrackedOnWorktreeCreate 21 23 case pullRequestMergeStrategy ··· 27 29 runScript: "", 28 30 openActionID: OpenWorktreeAction.automaticSettingsID, 29 31 worktreeBaseRef: nil, 32 + worktreeBaseDirectoryPath: nil, 30 33 copyIgnoredOnWorktreeCreate: false, 31 34 copyUntrackedOnWorktreeCreate: false, 32 35 pullRequestMergeStrategy: .merge ··· 38 41 runScript: String, 39 42 openActionID: String, 40 43 worktreeBaseRef: String?, 44 + worktreeBaseDirectoryPath: String? = nil, 41 45 copyIgnoredOnWorktreeCreate: Bool, 42 46 copyUntrackedOnWorktreeCreate: Bool, 43 47 pullRequestMergeStrategy: PullRequestMergeStrategy ··· 47 51 self.runScript = runScript 48 52 self.openActionID = openActionID 49 53 self.worktreeBaseRef = worktreeBaseRef 54 + self.worktreeBaseDirectoryPath = worktreeBaseDirectoryPath 50 55 self.copyIgnoredOnWorktreeCreate = copyIgnoredOnWorktreeCreate 51 56 self.copyUntrackedOnWorktreeCreate = copyUntrackedOnWorktreeCreate 52 57 self.pullRequestMergeStrategy = pullRequestMergeStrategy ··· 68 73 ?? Self.default.openActionID 69 74 worktreeBaseRef = 70 75 try container.decodeIfPresent(String.self, forKey: .worktreeBaseRef) 76 + worktreeBaseDirectoryPath = 77 + try container.decodeIfPresent(String.self, forKey: .worktreeBaseDirectoryPath) 71 78 copyIgnoredOnWorktreeCreate = 72 79 try container.decodeIfPresent( 73 80 Bool.self,
+22 -2
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 21 21 var deleteBranchOnDeleteWorktree: Bool 22 22 var automaticallyArchiveMergedWorktrees: Bool 23 23 var promptForWorktreeCreation: Bool 24 + var defaultWorktreeBaseDirectoryPath: String 24 25 var selection: SettingsSection? = .general 25 26 var repositorySettings: RepositorySettingsFeature.State? 26 27 ··· 42 43 deleteBranchOnDeleteWorktree = settings.deleteBranchOnDeleteWorktree 43 44 automaticallyArchiveMergedWorktrees = settings.automaticallyArchiveMergedWorktrees 44 45 promptForWorktreeCreation = settings.promptForWorktreeCreation 46 + defaultWorktreeBaseDirectoryPath = 47 + SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) ?? "" 45 48 } 46 49 47 50 var globalSettings: GlobalSettings { ··· 61 64 githubIntegrationEnabled: githubIntegrationEnabled, 62 65 deleteBranchOnDeleteWorktree: deleteBranchOnDeleteWorktree, 63 66 automaticallyArchiveMergedWorktrees: automaticallyArchiveMergedWorktrees, 64 - promptForWorktreeCreation: promptForWorktreeCreation 67 + promptForWorktreeCreation: promptForWorktreeCreation, 68 + defaultWorktreeBaseDirectoryPath: SupacodePaths.normalizedWorktreeBaseDirectoryPath( 69 + defaultWorktreeBaseDirectoryPath 70 + ) 65 71 ) 66 72 } 67 73 } ··· 93 99 94 100 case .settingsLoaded(let settings): 95 101 let normalizedDefaultEditorID = OpenWorktreeAction.normalizedDefaultEditorID(settings.defaultEditorID) 102 + let normalizedWorktreeBaseDirPath = 103 + SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) 96 104 let normalizedSettings: GlobalSettings 97 - if normalizedDefaultEditorID == settings.defaultEditorID { 105 + if normalizedDefaultEditorID == settings.defaultEditorID, 106 + normalizedWorktreeBaseDirPath == settings.defaultWorktreeBaseDirectoryPath 107 + { 98 108 normalizedSettings = settings 99 109 } else { 100 110 var updatedSettings = settings 101 111 updatedSettings.defaultEditorID = normalizedDefaultEditorID 112 + updatedSettings.defaultWorktreeBaseDirectoryPath = normalizedWorktreeBaseDirPath 102 113 normalizedSettings = updatedSettings 103 114 @Shared(.settingsFile) var settingsFile 104 115 $settingsFile.withLock { $0.global = normalizedSettings } ··· 119 130 state.deleteBranchOnDeleteWorktree = normalizedSettings.deleteBranchOnDeleteWorktree 120 131 state.automaticallyArchiveMergedWorktrees = normalizedSettings.automaticallyArchiveMergedWorktrees 121 132 state.promptForWorktreeCreation = normalizedSettings.promptForWorktreeCreation 133 + state.defaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath ?? "" 134 + state.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath = 135 + normalizedSettings.defaultWorktreeBaseDirectoryPath 122 136 return .send(.delegate(.settingsChanged(normalizedSettings))) 123 137 124 138 case .binding: 139 + let defaultWorktreeBaseDirectoryPath = state.globalSettings.defaultWorktreeBaseDirectoryPath 140 + state.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath = 141 + defaultWorktreeBaseDirectoryPath 125 142 return persist(state) 126 143 127 144 case .setSystemNotificationsEnabled(let isEnabled): 128 145 state.systemNotificationsEnabled = isEnabled 146 + let defaultWorktreeBaseDirectoryPath = state.globalSettings.defaultWorktreeBaseDirectoryPath 147 + state.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath = 148 + defaultWorktreeBaseDirectoryPath 129 149 return persist(state) 130 150 131 151 case .setSelection(let selection):
+18
supacode/Features/Settings/Views/RepositorySettingsView.swift
··· 10 10 let baseRefOptions = 11 11 store.branchOptions.isEmpty ? [store.defaultWorktreeBaseRef] : store.branchOptions 12 12 let settings = $store.settings 13 + let worktreeBaseDirectoryPath = Binding( 14 + get: { settings.worktreeBaseDirectoryPath.wrappedValue ?? "" }, 15 + set: { settings.worktreeBaseDirectoryPath.wrappedValue = $0 }, 16 + ) 17 + let exampleWorktreePath = store.exampleWorktreePath 13 18 Form { 14 19 Section { 15 20 if store.isBranchDataLoaded { ··· 53 58 } 54 59 } 55 60 Section { 61 + VStack(alignment: .leading) { 62 + TextField( 63 + "Inherit global default", 64 + text: worktreeBaseDirectoryPath 65 + ) 66 + .textFieldStyle(.roundedBorder) 67 + Text("Set a repository-specific worktree base directory. Leave empty to inherit the global setting.") 68 + .foregroundStyle(.secondary) 69 + Text("Example new worktree path: \(exampleWorktreePath)") 70 + .foregroundStyle(.secondary) 71 + .monospaced() 72 + } 73 + .frame(maxWidth: .infinity, alignment: .leading) 56 74 Toggle( 57 75 "Copy ignored files to new worktrees", 58 76 isOn: settings.copyIgnoredOnWorktreeCreate
+20
supacode/Features/Settings/Views/WorktreeSettingsView.swift
··· 5 5 @Bindable var store: StoreOf<SettingsFeature> 6 6 7 7 var body: some View { 8 + let exampleRepositoryRoot = FileManager.default.homeDirectoryForCurrentUser 9 + .appending(path: "code/my-repo", directoryHint: .isDirectory) 10 + let exampleWorktreePath = SupacodePaths.exampleWorktreePath( 11 + for: exampleRepositoryRoot, 12 + globalDefaultPath: store.defaultWorktreeBaseDirectoryPath, 13 + repositoryOverridePath: nil 14 + ) 8 15 VStack(alignment: .leading) { 9 16 Form { 10 17 Section("Worktree") { 18 + VStack(alignment: .leading) { 19 + TextField( 20 + "Default: current behavior", 21 + text: $store.defaultWorktreeBaseDirectoryPath 22 + ) 23 + .textFieldStyle(.roundedBorder) 24 + Text("Default directory for new worktrees across repositories. Leave empty to keep current behavior.") 25 + .foregroundStyle(.secondary) 26 + Text("Example new worktree path: \(exampleWorktreePath)") 27 + .foregroundStyle(.secondary) 28 + .monospaced() 29 + } 30 + .frame(maxWidth: .infinity, alignment: .leading) 11 31 VStack(alignment: .leading) { 12 32 Toggle( 13 33 "Also delete local branch when deleting a worktree",
+62
supacode/Support/SupacodePaths.swift
··· 15 15 return reposDirectory.appending(path: name, directoryHint: .isDirectory) 16 16 } 17 17 18 + static func normalizedWorktreeBaseDirectoryPath( 19 + _ rawPath: String?, 20 + repositoryRootURL: URL? = nil 21 + ) -> String? { 22 + guard let rawPath else { 23 + return nil 24 + } 25 + let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) 26 + guard !trimmed.isEmpty else { 27 + return nil 28 + } 29 + let expanded = NSString(string: trimmed).expandingTildeInPath 30 + let directoryURL: URL 31 + if expanded.hasPrefix("/") { 32 + directoryURL = URL(filePath: expanded, directoryHint: .isDirectory) 33 + } else if let repositoryRootURL { 34 + directoryURL = repositoryRootURL.standardizedFileURL 35 + .appending(path: expanded, directoryHint: .isDirectory) 36 + } else { 37 + directoryURL = FileManager.default.homeDirectoryForCurrentUser 38 + .appending(path: expanded, directoryHint: .isDirectory) 39 + } 40 + return directoryURL.standardizedFileURL.path(percentEncoded: false) 41 + } 42 + 43 + static func worktreeBaseDirectory( 44 + for repositoryRootURL: URL, 45 + globalDefaultPath: String?, 46 + repositoryOverridePath: String? 47 + ) -> URL { 48 + let rootURL = repositoryRootURL.standardizedFileURL 49 + if let repositoryOverridePath = normalizedWorktreeBaseDirectoryPath( 50 + repositoryOverridePath, 51 + repositoryRootURL: rootURL 52 + ) { 53 + return URL(filePath: repositoryOverridePath, directoryHint: .isDirectory).standardizedFileURL 54 + } 55 + if let globalDefaultPath = normalizedWorktreeBaseDirectoryPath(globalDefaultPath) { 56 + return URL(filePath: globalDefaultPath, directoryHint: .isDirectory) 57 + .standardizedFileURL 58 + .appending(path: repositoryDirectoryName(for: rootURL), directoryHint: .isDirectory) 59 + .standardizedFileURL 60 + } 61 + return repositoryDirectory(for: rootURL) 62 + } 63 + 64 + static func exampleWorktreePath( 65 + for repositoryRootURL: URL, 66 + globalDefaultPath: String?, 67 + repositoryOverridePath: String?, 68 + branchName: String = "swift-otter" 69 + ) -> String { 70 + worktreeBaseDirectory( 71 + for: repositoryRootURL, 72 + globalDefaultPath: globalDefaultPath, 73 + repositoryOverridePath: repositoryOverridePath 74 + ) 75 + .appending(path: branchName, directoryHint: .isDirectory) 76 + .standardizedFileURL 77 + .path(percentEncoded: false) 78 + } 79 + 18 80 static var settingsURL: URL { 19 81 baseDirectory.appending(path: "settings.json", directoryHint: .notDirectory) 20 82 }
+22 -16
supacodeTests/GitClientCreateWorktreeStreamTests.swift
··· 66 66 for try await _ in client.createWorktreeStream( 67 67 named: "swift-otter", 68 68 in: repoRoot, 69 - copyIgnored: true, 70 - copyUntracked: false, 69 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees"), 70 + copyFiles: (ignored: true, untracked: false), 71 71 baseRef: "origin/main" 72 72 ) {} 73 73 74 74 let snapshot = recorder.snapshot() 75 75 #expect(snapshot.currentDirectoryURL == repoRoot) 76 76 #expect(snapshot.arguments.contains("sw")) 77 + if let baseDirFlagIndex = snapshot.arguments.firstIndex(of: "--base-dir") { 78 + #expect(snapshot.arguments.count > baseDirFlagIndex + 1) 79 + #expect(snapshot.arguments[baseDirFlagIndex + 1] == "/tmp/repo/.worktrees") 80 + } else { 81 + Issue.record("Expected --base-dir in createWorktreeStream arguments") 82 + } 77 83 #expect(snapshot.arguments.contains("--copy-ignored")) 78 84 #expect(snapshot.arguments.contains("--verbose")) 79 85 #expect(snapshot.arguments.contains("--from")) ··· 108 114 for try await event in client.createWorktreeStream( 109 115 named: "swift-otter", 110 116 in: repoRoot, 111 - copyIgnored: true, 112 - copyUntracked: true, 117 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees"), 118 + copyFiles: (ignored: true, untracked: true), 113 119 baseRef: "" 114 120 ) { 115 121 switch event { ··· 153 159 for try await event in client.createWorktreeStream( 154 160 named: "new-wt", 155 161 in: repoRoot, 156 - copyIgnored: false, 157 - copyUntracked: false, 162 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees"), 163 + copyFiles: (ignored: false, untracked: false), 158 164 baseRef: "" 159 165 ) { 160 166 if case .finished(let worktree) = event { ··· 184 190 for try await event in client.createWorktreeStream( 185 191 named: "new-wt", 186 192 in: repoRoot, 187 - copyIgnored: false, 188 - copyUntracked: false, 193 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees"), 194 + copyFiles: (ignored: false, untracked: false), 189 195 baseRef: "" 190 196 ) { 191 197 switch event { ··· 226 232 for try await _ in client.createWorktreeStream( 227 233 named: "new-wt", 228 234 in: repoRoot, 229 - copyIgnored: false, 230 - copyUntracked: false, 235 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees"), 236 + copyFiles: (ignored: false, untracked: false), 231 237 baseRef: "" 232 238 ) {} 233 239 Issue.record("Expected createWorktreeStream to throw when stdout path is missing") ··· 266 272 _ = try await client.createWorktree( 267 273 named: "new-wt", 268 274 in: repoRoot, 269 - copyIgnored: false, 270 - copyUntracked: false, 275 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees"), 276 + copyFiles: (ignored: false, untracked: false), 271 277 baseRef: "" 272 278 ) 273 279 Issue.record("Expected createWorktree to throw") ··· 302 308 let worktree = try await client.createWorktree( 303 309 named: "new-wt", 304 310 in: repoRoot, 305 - copyIgnored: false, 306 - copyUntracked: false, 311 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees"), 312 + copyFiles: (ignored: false, untracked: false), 307 313 baseRef: "" 308 314 ) 309 315 ··· 329 335 let worktree = try await client.createWorktree( 330 336 named: "new-wt", 331 337 in: repoRoot, 332 - copyIgnored: false, 333 - copyUntracked: false, 338 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees"), 339 + copyFiles: (ignored: false, untracked: false), 334 340 baseRef: "" 335 341 ) 336 342
+180 -4
supacodeTests/RepositoriesFeatureTests.swift
··· 413 413 pendingID: "pending:1", 414 414 previousSelection: nil, 415 415 repositoryID: repository.id, 416 - name: "../../Desktop" 416 + name: "../../Desktop", 417 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees") 417 418 ) 418 419 ) { 419 420 $0.alert = expectedAlert ··· 442 443 $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 443 444 $0.gitClient.ignoredFileCount = { _ in 2 } 444 445 $0.gitClient.untrackedFileCount = { _ in 1 } 445 - $0.gitClient.createWorktreeStream = { _, _, _, _, _ in 446 + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in 446 447 AsyncThrowingStream { continuation in 447 448 continuation.yield(.outputLine(ShellStreamLine(source: .stderr, text: "[1/2] copy .env"))) 448 449 continuation.yield(.outputLine(ShellStreamLine(source: .stderr, text: "[2/2] copy .cache"))) ··· 467 468 #expect(store.state.alert == nil) 468 469 } 469 470 471 + @Test(.dependencies) func createRandomWorktreeUsesRepositoryWorktreeBaseDirectoryOverride() async { 472 + let repoRoot = "/tmp/repo" 473 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 474 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 475 + let createdWorktree = makeWorktree( 476 + id: "/tmp/repo/swift-otter", 477 + name: "swift-otter", 478 + repoRoot: repoRoot 479 + ) 480 + let observedBaseDirectory = LockIsolated<URL?>(nil) 481 + @Shared(.settingsFile) var settingsFile 482 + $settingsFile.withLock { 483 + $0.global.promptForWorktreeCreation = false 484 + $0.global.defaultWorktreeBaseDirectoryPath = "/tmp/global-worktrees" 485 + } 486 + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 487 + $repositorySettings.withLock { 488 + $0.worktreeBaseDirectoryPath = "/tmp/repo-override" 489 + } 490 + let store = TestStore(initialState: makeState(repositories: [repository])) { 491 + RepositoriesFeature() 492 + } withDependencies: { 493 + $0.uuid = .incrementing 494 + $0.gitClient.localBranchNames = { _ in [] } 495 + $0.gitClient.isBareRepository = { _ in false } 496 + $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 497 + $0.gitClient.ignoredFileCount = { _ in 0 } 498 + $0.gitClient.untrackedFileCount = { _ in 0 } 499 + $0.gitClient.createWorktreeStream = { _, _, baseDirectory, _, _, _ in 500 + observedBaseDirectory.withValue { $0 = baseDirectory } 501 + return AsyncThrowingStream { continuation in 502 + continuation.yield(.finished(createdWorktree)) 503 + continuation.finish() 504 + } 505 + } 506 + $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } 507 + } 508 + store.exhaustivity = .off 509 + 510 + await store.send(.createRandomWorktreeInRepository(repository.id)) 511 + await store.receive(\.createRandomWorktreeSucceeded) 512 + await store.finish() 513 + 514 + let expectedBaseDirectory = SupacodePaths.worktreeBaseDirectory( 515 + for: repository.rootURL, 516 + globalDefaultPath: "/tmp/global-worktrees", 517 + repositoryOverridePath: "/tmp/repo-override" 518 + ) 519 + #expect(observedBaseDirectory.value == expectedBaseDirectory) 520 + } 521 + 522 + @Test(.dependencies) func createRandomWorktreeUsesGlobalWorktreeBaseDirectoryWhenRepositoryOverrideMissing() async { 523 + let repoRoot = "/tmp/repo" 524 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 525 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 526 + let createdWorktree = makeWorktree( 527 + id: "/tmp/repo/swift-otter", 528 + name: "swift-otter", 529 + repoRoot: repoRoot 530 + ) 531 + let observedBaseDirectory = LockIsolated<URL?>(nil) 532 + @Shared(.settingsFile) var settingsFile 533 + $settingsFile.withLock { 534 + $0.global.promptForWorktreeCreation = false 535 + $0.global.defaultWorktreeBaseDirectoryPath = "/tmp/global-worktrees" 536 + } 537 + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings 538 + $repositorySettings.withLock { 539 + $0.worktreeBaseDirectoryPath = nil 540 + } 541 + let store = TestStore(initialState: makeState(repositories: [repository])) { 542 + RepositoriesFeature() 543 + } withDependencies: { 544 + $0.uuid = .incrementing 545 + $0.gitClient.localBranchNames = { _ in [] } 546 + $0.gitClient.isBareRepository = { _ in false } 547 + $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 548 + $0.gitClient.ignoredFileCount = { _ in 0 } 549 + $0.gitClient.untrackedFileCount = { _ in 0 } 550 + $0.gitClient.createWorktreeStream = { _, _, baseDirectory, _, _, _ in 551 + observedBaseDirectory.withValue { $0 = baseDirectory } 552 + return AsyncThrowingStream { continuation in 553 + continuation.yield(.finished(createdWorktree)) 554 + continuation.finish() 555 + } 556 + } 557 + $0.gitClient.worktrees = { _ in [createdWorktree, mainWorktree] } 558 + } 559 + store.exhaustivity = .off 560 + 561 + await store.send(.createRandomWorktreeInRepository(repository.id)) 562 + await store.receive(\.createRandomWorktreeSucceeded) 563 + await store.finish() 564 + 565 + let expectedBaseDirectory = SupacodePaths.worktreeBaseDirectory( 566 + for: repository.rootURL, 567 + globalDefaultPath: "/tmp/global-worktrees", 568 + repositoryOverridePath: nil 569 + ) 570 + #expect(observedBaseDirectory.value == expectedBaseDirectory) 571 + } 572 + 470 573 @Test(.dependencies) func createRandomWorktreeInRepositoryStreamFailureRemovesPendingWorktree() async { 471 574 let repoRoot = "/tmp/repo" 472 575 let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) ··· 482 585 $0.gitClient.automaticWorktreeBaseRef = { _ in "origin/main" } 483 586 $0.gitClient.ignoredFileCount = { _ in 2 } 484 587 $0.gitClient.untrackedFileCount = { _ in 1 } 485 - $0.gitClient.createWorktreeStream = { _, _, _, _, _ in 588 + $0.gitClient.createWorktreeStream = { _, _, _, _, _, _ in 486 589 AsyncThrowingStream { continuation in 487 590 continuation.yield(.outputLine(ShellStreamLine(source: .stderr, text: "[1/2] copy .env"))) 488 591 continuation.finish(throwing: GitClientError.commandFailed(command: "wt sw", message: "boom")) ··· 511 614 #expect(store.state.repositories[id: repository.id]?.worktrees[id: mainWorktree.id] != nil) 512 615 } 513 616 617 + @Test(.dependencies) func createRandomWorktreeFailureUsesProvidedBaseDirectoryForCleanup() async { 618 + let repoRoot = "/tmp/repo" 619 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 620 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree]) 621 + let createTimeBaseDirectory = SupacodePaths.worktreeBaseDirectory( 622 + for: repository.rootURL, 623 + globalDefaultPath: "/tmp/worktrees-original", 624 + repositoryOverridePath: nil 625 + ) 626 + let changedBaseDirectory = SupacodePaths.worktreeBaseDirectory( 627 + for: repository.rootURL, 628 + globalDefaultPath: "/tmp/worktrees-changed", 629 + repositoryOverridePath: nil 630 + ) 631 + let removedWorktreePath = LockIsolated<String?>(nil) 632 + @Shared(.settingsFile) var settingsFile 633 + $settingsFile.withLock { 634 + $0.global.defaultWorktreeBaseDirectoryPath = "/tmp/worktrees-changed" 635 + } 636 + let store = TestStore(initialState: makeState(repositories: [repository])) { 637 + RepositoriesFeature() 638 + } withDependencies: { 639 + $0.gitClient.removeWorktree = { worktree, _ in 640 + let workingDirectory = await MainActor.run { worktree.workingDirectory } 641 + removedWorktreePath.withValue { $0 = workingDirectory.path(percentEncoded: false) } 642 + return workingDirectory 643 + } 644 + $0.gitClient.pruneWorktrees = { _ in } 645 + } 646 + store.exhaustivity = .off 647 + 648 + let expectedAlert = AlertState<RepositoriesFeature.Alert> { 649 + TextState("Unable to create worktree") 650 + } actions: { 651 + ButtonState(role: .cancel) { 652 + TextState("OK") 653 + } 654 + } message: { 655 + TextState("boom") 656 + } 657 + 658 + await store.send( 659 + .createRandomWorktreeFailed( 660 + title: "Unable to create worktree", 661 + message: "boom", 662 + pendingID: "pending:test", 663 + previousSelection: nil, 664 + repositoryID: repository.id, 665 + name: "new-branch", 666 + baseDirectory: createTimeBaseDirectory 667 + ) 668 + ) { 669 + $0.alert = expectedAlert 670 + } 671 + await store.finish() 672 + 673 + #expect(changedBaseDirectory != createTimeBaseDirectory) 674 + #expect(removedWorktreePath.value != nil) 675 + #expect( 676 + removedWorktreePath.value 677 + == createTimeBaseDirectory 678 + .appending(path: "new-branch", directoryHint: .isDirectory) 679 + .path(percentEncoded: false) 680 + ) 681 + #expect( 682 + removedWorktreePath.value 683 + != changedBaseDirectory 684 + .appending(path: "new-branch", directoryHint: .isDirectory) 685 + .path(percentEncoded: false) 686 + ) 687 + } 688 + 514 689 @Test func pendingProgressUpdateUpdatesPendingWorktreeState() async { 515 690 let repoRoot = "/tmp/repo" 516 691 let repository = makeRepository( ··· 585 760 pendingID: pendingID, 586 761 previousSelection: nil, 587 762 repositoryID: repository.id, 588 - name: nil 763 + name: nil, 764 + baseDirectory: URL(fileURLWithPath: "/tmp/repo/.worktrees") 589 765 ) 590 766 ) { 591 767 $0.pendingWorktrees = []
+51
supacodeTests/RepositoryPathsTests.swift
··· 41 41 42 42 #expect(firstDirectory != secondDirectory) 43 43 } 44 + 45 + @Test func worktreeBaseDirectoryDefaultsToLegacyRepositoryDirectory() { 46 + let root = URL(fileURLWithPath: "/tmp/work/repo-alpha") 47 + let directory = SupacodePaths.worktreeBaseDirectory( 48 + for: root, 49 + globalDefaultPath: nil, 50 + repositoryOverridePath: nil 51 + ) 52 + 53 + #expect(directory == SupacodePaths.repositoryDirectory(for: root)) 54 + } 55 + 56 + @Test func worktreeBaseDirectoryUsesGlobalParentDirectory() { 57 + let root = URL(fileURLWithPath: "/tmp/work/repo-alpha") 58 + let directory = SupacodePaths.worktreeBaseDirectory( 59 + for: root, 60 + globalDefaultPath: "/tmp/worktrees", 61 + repositoryOverridePath: nil 62 + ) 63 + let expectedDirectory = URL(filePath: "/tmp/worktrees/repo-alpha", directoryHint: .isDirectory) 64 + .standardizedFileURL 65 + 66 + #expect(directory == expectedDirectory) 67 + } 68 + 69 + @Test func worktreeBaseDirectoryRepositoryOverrideTakesPrecedence() { 70 + let root = URL(fileURLWithPath: "/tmp/work/repo-alpha") 71 + let directory = SupacodePaths.worktreeBaseDirectory( 72 + for: root, 73 + globalDefaultPath: "/tmp/worktrees", 74 + repositoryOverridePath: "/tmp/repo-alpha-worktrees" 75 + ) 76 + let expectedDirectory = URL(filePath: "/tmp/repo-alpha-worktrees", directoryHint: .isDirectory) 77 + .standardizedFileURL 78 + 79 + #expect(directory == expectedDirectory) 80 + } 81 + 82 + @Test func exampleWorktreePathUsesResolvedBaseDirectory() { 83 + let root = URL(fileURLWithPath: "/tmp/work/repo-alpha") 84 + let path = SupacodePaths.exampleWorktreePath( 85 + for: root, 86 + globalDefaultPath: "/tmp/worktrees", 87 + repositoryOverridePath: nil 88 + ) 89 + let expectedPath = URL(filePath: "/tmp/worktrees/repo-alpha/swift-otter", directoryHint: .isDirectory) 90 + .standardizedFileURL 91 + .path(percentEncoded: false) 92 + 93 + #expect(path == expectedPath) 94 + } 44 95 }
+1
supacodeTests/RepositorySettingsKeyTests.swift
··· 12 12 let json = String(bytes: data, encoding: .utf8) ?? "" 13 13 14 14 #expect(!json.contains("worktreeBaseRef")) 15 + #expect(!json.contains("worktreeBaseDirectoryPath")) 15 16 } 16 17 17 18 @Test(.dependencies) func loadCreatesDefaultAndPersists() throws {
+49
supacodeTests/SettingsFeatureTests.swift
··· 198 198 } 199 199 await store.receive(\.delegate.settingsChanged) 200 200 } 201 + 202 + @Test(.dependencies) func settingsLoadedNormalizesDefaultWorktreeBaseDirectoryPath() async { 203 + var loaded = GlobalSettings.default 204 + loaded.defaultWorktreeBaseDirectoryPath = " ~/worktrees " 205 + let expectedPath = FileManager.default.homeDirectoryForCurrentUser 206 + .appending(path: "worktrees", directoryHint: .isDirectory) 207 + .standardizedFileURL 208 + .path(percentEncoded: false) 209 + let storage = SettingsTestStorage() 210 + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 211 + let store = TestStore(initialState: SettingsFeature.State()) { 212 + SettingsFeature() 213 + } withDependencies: { 214 + $0.settingsFileStorage = storage.storage 215 + $0.settingsFileURL = settingsFileURL 216 + } 217 + 218 + await store.send(.settingsLoaded(loaded)) { 219 + $0.defaultWorktreeBaseDirectoryPath = expectedPath 220 + } 221 + await store.receive(\.delegate.settingsChanged) 222 + #expect(store.state.defaultWorktreeBaseDirectoryPath == expectedPath) 223 + } 224 + 225 + @Test(.dependencies) func changingDefaultWorktreeBaseDirectoryUpdatesRepositorySettingsState() async { 226 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 227 + let expectedPath = FileManager.default.homeDirectoryForCurrentUser 228 + .appending(path: "worktrees", directoryHint: .isDirectory) 229 + .standardizedFileURL 230 + .path(percentEncoded: false) 231 + @Shared(.settingsFile) var settingsFile 232 + $settingsFile.withLock { $0.global = .default } 233 + var state = SettingsFeature.State() 234 + state.repositorySettings = RepositorySettingsFeature.State( 235 + rootURL: rootURL, 236 + settings: .default 237 + ) 238 + let store = TestStore(initialState: state) { 239 + SettingsFeature() 240 + } 241 + 242 + await store.send(.binding(.set(\.defaultWorktreeBaseDirectoryPath, " ~/worktrees "))) { 243 + $0.defaultWorktreeBaseDirectoryPath = " ~/worktrees " 244 + $0.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath = expectedPath 245 + } 246 + await store.receive(\.delegate.settingsChanged) 247 + #expect(store.state.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath == expectedPath) 248 + #expect(settingsFile.global.defaultWorktreeBaseDirectoryPath == expectedPath) 249 + } 201 250 }
+1
supacodeTests/SettingsFilePersistenceTests.swift
··· 110 110 #expect(settings.global.deleteBranchOnDeleteWorktree == true) 111 111 #expect(settings.global.automaticallyArchiveMergedWorktrees == false) 112 112 #expect(settings.global.promptForWorktreeCreation == true) 113 + #expect(settings.global.defaultWorktreeBaseDirectoryPath == nil) 113 114 #expect(settings.global.defaultEditorID == OpenWorktreeAction.automaticSettingsID) 114 115 #expect(settings.repositoryRoots.isEmpty) 115 116 #expect(settings.pinnedWorktreeIDs.isEmpty)