native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #124 from supabitapp/feat/repo-supacode-json-settings

Add repo-local supacode.json override for repository settings

authored by

khoi and committed by
GitHub
b7c92d47 e98ee2ef

+435 -39
+64
supacode/Features/Settings/BusinessLogic/RepositoryLocalSettingsPersistence.swift
··· 1 + import Dependencies 2 + import Foundation 3 + 4 + nonisolated struct RepositoryLocalSettingsStorage: Sendable { 5 + var load: @Sendable (URL) throws -> Data 6 + var save: @Sendable (Data, URL) throws -> Void 7 + } 8 + 9 + nonisolated enum RepositoryLocalSettingsStorageKey: DependencyKey { 10 + static var liveValue: RepositoryLocalSettingsStorage { 11 + RepositoryLocalSettingsStorage( 12 + load: { try Data(contentsOf: $0) }, 13 + save: { data, url in 14 + let directory = url.deletingLastPathComponent() 15 + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) 16 + try data.write(to: url, options: [.atomic]) 17 + } 18 + ) 19 + } 20 + 21 + static var previewValue: RepositoryLocalSettingsStorage { .inMemory() } 22 + static var testValue: RepositoryLocalSettingsStorage { .inMemory() } 23 + } 24 + 25 + extension DependencyValues { 26 + nonisolated var repositoryLocalSettingsStorage: RepositoryLocalSettingsStorage { 27 + get { self[RepositoryLocalSettingsStorageKey.self] } 28 + set { self[RepositoryLocalSettingsStorageKey.self] = newValue } 29 + } 30 + } 31 + 32 + extension RepositoryLocalSettingsStorage { 33 + nonisolated static func inMemory() -> RepositoryLocalSettingsStorage { 34 + let storage = InMemoryRepositoryLocalSettingsStorage() 35 + return RepositoryLocalSettingsStorage( 36 + load: { try storage.load($0) }, 37 + save: { try storage.save($0, $1) } 38 + ) 39 + } 40 + } 41 + 42 + nonisolated enum RepositoryLocalSettingsStorageError: Error { 43 + case missing 44 + } 45 + 46 + nonisolated final class InMemoryRepositoryLocalSettingsStorage: @unchecked Sendable { 47 + private let lock = NSLock() 48 + private var dataByURL: [URL: Data] = [:] 49 + 50 + func load(_ url: URL) throws -> Data { 51 + lock.lock() 52 + defer { lock.unlock() } 53 + guard let data = dataByURL[url] else { 54 + throw RepositoryLocalSettingsStorageError.missing 55 + } 56 + return data 57 + } 58 + 59 + func save(_ data: Data, _ url: URL) throws { 60 + lock.lock() 61 + defer { lock.unlock() } 62 + dataByURL[url] = data 63 + } 64 + }
+42 -13
supacode/Features/Settings/BusinessLogic/RepositorySettingsKey.swift
··· 1 + import Dependencies 1 2 import Foundation 2 3 import Sharing 3 4 ··· 7 8 8 9 nonisolated struct RepositorySettingsKey: SharedKey { 9 10 let repositoryID: String 11 + let rootURL: URL 10 12 11 13 init(rootURL: URL) { 12 - repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) 14 + self.rootURL = rootURL.standardizedFileURL 15 + repositoryID = self.rootURL.path(percentEncoded: false) 13 16 } 14 17 15 18 var id: RepositorySettingsKeyID { ··· 20 23 context: LoadContext<RepositorySettings>, 21 24 continuation: LoadContinuation<RepositorySettings> 22 25 ) { 26 + @Dependency(\.repositoryLocalSettingsStorage) var repositoryLocalSettingsStorage 27 + let repositorySettingsURL = SupacodePaths.repositorySettingsURL(for: rootURL) 28 + if let localData = try? repositoryLocalSettingsStorage.load(repositorySettingsURL) { 29 + let decoder = JSONDecoder() 30 + if let settings = try? decoder.decode(RepositorySettings.self, from: localData) { 31 + continuation.resume(returning: settings) 32 + return 33 + } 34 + let path = repositorySettingsURL.path(percentEncoded: false) 35 + SupaLogger("Settings").warning( 36 + "Unable to decode repository settings at \(path); migrating from global settings." 37 + ) 38 + } 39 + 23 40 @Shared(.settingsFile) var settingsFile: SettingsFile 24 - let settings = $settingsFile.withLock { settings in 25 - if let existing = settings.repositories[repositoryID] { 26 - return existing 27 - } 28 - let defaults = context.initialValue ?? .default 29 - settings.repositories[repositoryID] = defaults 30 - return defaults 41 + let migratedSettings = $settingsFile.withLock { settings in 42 + settings.repositories[repositoryID] ?? (context.initialValue ?? .default) 43 + } 44 + do { 45 + let encoder = JSONEncoder() 46 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 47 + let data = try encoder.encode(migratedSettings) 48 + try repositoryLocalSettingsStorage.save(data, repositorySettingsURL) 49 + } catch { 50 + let path = repositorySettingsURL.path(percentEncoded: false) 51 + SupaLogger("Settings").warning( 52 + "Unable to write migrated repository settings to \(path): \(error.localizedDescription)" 53 + ) 31 54 } 32 - continuation.resume(returning: settings) 55 + continuation.resume(returning: migratedSettings) 33 56 } 34 57 35 58 func subscribe( ··· 44 67 context _: SaveContext, 45 68 continuation: SaveContinuation 46 69 ) { 47 - @Shared(.settingsFile) var settingsFile: SettingsFile 48 - $settingsFile.withLock { 49 - $0.repositories[repositoryID] = value 70 + @Dependency(\.repositoryLocalSettingsStorage) var repositoryLocalSettingsStorage 71 + let repositorySettingsURL = SupacodePaths.repositorySettingsURL(for: rootURL) 72 + do { 73 + let encoder = JSONEncoder() 74 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 75 + let data = try encoder.encode(value) 76 + try repositoryLocalSettingsStorage.save(data, repositorySettingsURL) 77 + continuation.resume() 78 + } catch { 79 + continuation.resume(throwing: error) 50 80 } 51 - continuation.resume() 52 81 } 53 82 } 54 83
+4
supacode/Support/SupacodePaths.swift
··· 19 19 baseDirectory.appending(path: "settings.json", directoryHint: .notDirectory) 20 20 } 21 21 22 + static func repositorySettingsURL(for rootURL: URL) -> URL { 23 + rootURL.standardizedFileURL.appending(path: "supacode.json", directoryHint: .notDirectory) 24 + } 25 + 22 26 private static func repositoryDirectoryName(for rootURL: URL) -> String { 23 27 let repoName = rootURL.lastPathComponent 24 28 if repoName.isEmpty || repoName == ".bare" || repoName == ".git" {
+54
supacodeTests/AppFeatureDefaultEditorTests.swift
··· 39 39 await store.finish() 40 40 } 41 41 42 + @Test(.dependencies) func repositoryLocalSettingsOverrideGlobalRepositorySettings() async throws { 43 + let worktree = makeWorktree() 44 + let repositoriesState = makeRepositoriesState(worktree: worktree) 45 + let settingsStorage = SettingsTestStorage() 46 + let localStorage = RepositoryLocalSettingsTestStorage() 47 + let settingsFileURL = URL( 48 + fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json" 49 + ) 50 + let repositoryID = worktree.repositoryRootURL.standardizedFileURL.path(percentEncoded: false) 51 + var globalRepositorySettings = RepositorySettings.default 52 + globalRepositorySettings.openActionID = OpenWorktreeAction.finder.settingsID 53 + var localRepositorySettings = RepositorySettings.default 54 + localRepositorySettings.openActionID = OpenWorktreeAction.terminal.settingsID 55 + localRepositorySettings.runScript = "pnpm dev" 56 + 57 + withDependencies { 58 + $0.settingsFileStorage = settingsStorage.storage 59 + $0.settingsFileURL = settingsFileURL 60 + $0.repositoryLocalSettingsStorage = localStorage.storage 61 + } operation: { 62 + @Shared(.settingsFile) var settingsFile 63 + $settingsFile.withLock { 64 + $0.repositories[repositoryID] = globalRepositorySettings 65 + } 66 + } 67 + 68 + let encoder = JSONEncoder() 69 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 70 + try localStorage.save( 71 + encoder.encode(localRepositorySettings), 72 + at: SupacodePaths.repositorySettingsURL(for: worktree.repositoryRootURL) 73 + ) 74 + 75 + let store = TestStore( 76 + initialState: AppFeature.State( 77 + repositories: repositoriesState, 78 + settings: SettingsFeature.State() 79 + ) 80 + ) { 81 + AppFeature() 82 + } withDependencies: { 83 + $0.settingsFileStorage = settingsStorage.storage 84 + $0.settingsFileURL = settingsFileURL 85 + $0.repositoryLocalSettingsStorage = localStorage.storage 86 + } 87 + 88 + await store.send(.repositories(.delegate(.selectedWorktreeChanged(worktree)))) 89 + await store.receive(\.worktreeSettingsLoaded) { 90 + $0.openActionSelection = .terminal 91 + $0.selectedRunScript = "pnpm dev" 92 + } 93 + await store.finish() 94 + } 95 + 42 96 @Test(.dependencies) func selectedWorktreeChangedOnlyUpdatesWatcherSelection() async { 43 97 let worktree = makeWorktree() 44 98 let repositoriesState = makeRepositoriesState(worktree: worktree)
+271 -26
supacodeTests/RepositorySettingsKeyTests.swift
··· 14 14 #expect(!json.contains("worktreeBaseRef")) 15 15 } 16 16 17 - @Test(.dependencies) func loadCreatesDefaultAndPersists() throws { 18 - let storage = SettingsTestStorage() 17 + @Test(.dependencies) func loadCreatesDefaultAndMigratesToLocal() throws { 18 + let globalStorage = SettingsTestStorage() 19 + let localStorage = RepositoryLocalSettingsTestStorage() 19 20 let rootURL = URL(fileURLWithPath: "/tmp/repo") 21 + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 22 + let repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) 23 + let localURL = SupacodePaths.repositorySettingsURL(for: rootURL) 20 24 21 - let settings = withDependencies { 22 - $0.settingsFileStorage = storage.storage 25 + let loaded = withDependencies { 26 + $0.settingsFileStorage = globalStorage.storage 27 + $0.settingsFileURL = settingsFileURL 28 + $0.repositoryLocalSettingsStorage = localStorage.storage 23 29 } operation: { 24 30 @Shared(.repositorySettings(rootURL)) var repositorySettings: RepositorySettings 25 31 return repositorySettings 26 32 } 27 33 28 - #expect(settings == RepositorySettings.default) 34 + #expect(loaded == .default) 29 35 30 - let saved: SettingsFile = withDependencies { 31 - $0.settingsFileStorage = storage.storage 36 + let localData = try #require(localStorage.data(at: localURL)) 37 + let localDecoded = try JSONDecoder().decode(RepositorySettings.self, from: localData) 38 + #expect(localDecoded == .default) 39 + 40 + let globalSaved: SettingsFile = withDependencies { 41 + $0.settingsFileStorage = globalStorage.storage 42 + $0.settingsFileURL = settingsFileURL 43 + $0.repositoryLocalSettingsStorage = localStorage.storage 32 44 } operation: { 33 - @Shared(.settingsFile) var settings: SettingsFile 34 - return settings 45 + @Shared(.settingsFile) var settingsFile: SettingsFile 46 + return settingsFile 35 47 } 36 48 37 - #expect( 38 - saved.repositories[rootURL.path(percentEncoded: false)] == RepositorySettings.default 39 - ) 49 + #expect(globalSaved.repositories[repositoryID] == nil) 40 50 } 41 51 42 - @Test(.dependencies) func saveOverwritesExistingSettings() throws { 43 - let storage = SettingsTestStorage() 52 + @Test(.dependencies) func saveOverwritesExistingSettingsInLocalFile() throws { 53 + let globalStorage = SettingsTestStorage() 54 + let localStorage = RepositoryLocalSettingsTestStorage() 44 55 let rootURL = URL(fileURLWithPath: "/tmp/repo") 56 + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 57 + let localURL = SupacodePaths.repositorySettingsURL(for: rootURL) 45 58 46 - var settings = RepositorySettings.default 47 - settings.runScript = "echo updated" 59 + try localStorage.save(encode(.default), at: localURL) 60 + 61 + var updated = RepositorySettings.default 62 + updated.runScript = "echo updated" 63 + 48 64 withDependencies { 49 - $0.settingsFileStorage = storage.storage 65 + $0.settingsFileStorage = globalStorage.storage 66 + $0.settingsFileURL = settingsFileURL 67 + $0.repositoryLocalSettingsStorage = localStorage.storage 50 68 } operation: { 51 69 @Shared(.repositorySettings(rootURL)) var repositorySettings: RepositorySettings 52 70 $repositorySettings.withLock { 53 - $0 = settings 71 + $0 = updated 54 72 } 55 73 } 56 74 57 - let reloaded: SettingsFile = withDependencies { 58 - $0.settingsFileStorage = storage.storage 59 - } operation: { 60 - @Shared(.settingsFile) var settings: SettingsFile 61 - return settings 62 - } 63 - 64 - #expect(reloaded.repositories[rootURL.path(percentEncoded: false)] == settings) 75 + let localData = try #require(localStorage.data(at: localURL)) 76 + let localDecoded = try JSONDecoder().decode(RepositorySettings.self, from: localData) 77 + #expect(localDecoded == updated) 65 78 } 66 79 67 80 @Test func decodeMissingArchiveScriptDefaultsToEmpty() throws { ··· 77 90 let settings = try JSONDecoder().decode(RepositorySettings.self, from: data) 78 91 79 92 #expect(settings.archiveScript.isEmpty) 93 + } 94 + 95 + @Test(.dependencies) func loadPrefersLocalSupacodeJSONOverGlobalEntry() throws { 96 + let globalStorage = SettingsTestStorage() 97 + let localStorage = RepositoryLocalSettingsTestStorage() 98 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 99 + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 100 + let repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) 101 + var globalSettings = RepositorySettings.default 102 + globalSettings.runScript = "echo global" 103 + var localSettings = RepositorySettings.default 104 + localSettings.runScript = "echo local" 105 + 106 + withDependencies { 107 + $0.settingsFileStorage = globalStorage.storage 108 + $0.settingsFileURL = settingsFileURL 109 + $0.repositoryLocalSettingsStorage = localStorage.storage 110 + } operation: { 111 + @Shared(.settingsFile) var settingsFile: SettingsFile 112 + $settingsFile.withLock { 113 + $0.repositories[repositoryID] = globalSettings 114 + } 115 + } 116 + 117 + try localStorage.save( 118 + encode(localSettings), 119 + at: SupacodePaths.repositorySettingsURL(for: rootURL) 120 + ) 121 + 122 + let loaded = withDependencies { 123 + $0.settingsFileStorage = globalStorage.storage 124 + $0.settingsFileURL = settingsFileURL 125 + $0.repositoryLocalSettingsStorage = localStorage.storage 126 + } operation: { 127 + @Shared(.repositorySettings(rootURL)) var repositorySettings: RepositorySettings 128 + return repositorySettings 129 + } 130 + 131 + #expect(loaded == localSettings) 132 + } 133 + 134 + @Test(.dependencies) func loadMigratesGlobalWhenLocalMissing() throws { 135 + let globalStorage = SettingsTestStorage() 136 + let localStorage = RepositoryLocalSettingsTestStorage() 137 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 138 + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 139 + let repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) 140 + let localURL = SupacodePaths.repositorySettingsURL(for: rootURL) 141 + var globalSettings = RepositorySettings.default 142 + globalSettings.runScript = "echo global" 143 + 144 + withDependencies { 145 + $0.settingsFileStorage = globalStorage.storage 146 + $0.settingsFileURL = settingsFileURL 147 + $0.repositoryLocalSettingsStorage = localStorage.storage 148 + } operation: { 149 + @Shared(.settingsFile) var settingsFile: SettingsFile 150 + $settingsFile.withLock { 151 + $0.repositories[repositoryID] = globalSettings 152 + } 153 + } 154 + 155 + let loaded = withDependencies { 156 + $0.settingsFileStorage = globalStorage.storage 157 + $0.settingsFileURL = settingsFileURL 158 + $0.repositoryLocalSettingsStorage = localStorage.storage 159 + } operation: { 160 + @Shared(.repositorySettings(rootURL)) var repositorySettings: RepositorySettings 161 + return repositorySettings 162 + } 163 + 164 + #expect(loaded == globalSettings) 165 + 166 + let localData = try #require(localStorage.data(at: localURL)) 167 + let localDecoded = try JSONDecoder().decode(RepositorySettings.self, from: localData) 168 + #expect(localDecoded == globalSettings) 169 + } 170 + 171 + @Test(.dependencies) func loadMigratesGlobalWhenLocalInvalid() throws { 172 + let globalStorage = SettingsTestStorage() 173 + let localStorage = RepositoryLocalSettingsTestStorage() 174 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 175 + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 176 + let repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) 177 + let localURL = SupacodePaths.repositorySettingsURL(for: rootURL) 178 + var globalSettings = RepositorySettings.default 179 + globalSettings.runScript = "echo global" 180 + 181 + withDependencies { 182 + $0.settingsFileStorage = globalStorage.storage 183 + $0.settingsFileURL = settingsFileURL 184 + $0.repositoryLocalSettingsStorage = localStorage.storage 185 + } operation: { 186 + @Shared(.settingsFile) var settingsFile: SettingsFile 187 + $settingsFile.withLock { 188 + $0.repositories[repositoryID] = globalSettings 189 + } 190 + } 191 + 192 + try localStorage.save(Data("{".utf8), at: localURL) 193 + 194 + let loaded = withDependencies { 195 + $0.settingsFileStorage = globalStorage.storage 196 + $0.settingsFileURL = settingsFileURL 197 + $0.repositoryLocalSettingsStorage = localStorage.storage 198 + } operation: { 199 + @Shared(.repositorySettings(rootURL)) var repositorySettings: RepositorySettings 200 + return repositorySettings 201 + } 202 + 203 + #expect(loaded == globalSettings) 204 + 205 + let localData = try #require(localStorage.data(at: localURL)) 206 + let localDecoded = try JSONDecoder().decode(RepositorySettings.self, from: localData) 207 + #expect(localDecoded == globalSettings) 208 + } 209 + 210 + @Test(.dependencies) func saveWritesLocalWhenLocalFileExists() throws { 211 + let globalStorage = SettingsTestStorage() 212 + let localStorage = RepositoryLocalSettingsTestStorage() 213 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 214 + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 215 + let repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) 216 + let localURL = SupacodePaths.repositorySettingsURL(for: rootURL) 217 + 218 + try localStorage.save(encode(.default), at: localURL) 219 + 220 + var updated = RepositorySettings.default 221 + updated.runScript = "echo local" 222 + 223 + withDependencies { 224 + $0.settingsFileStorage = globalStorage.storage 225 + $0.settingsFileURL = settingsFileURL 226 + $0.repositoryLocalSettingsStorage = localStorage.storage 227 + } operation: { 228 + @Shared(.repositorySettings(rootURL)) var repositorySettings: RepositorySettings 229 + $repositorySettings.withLock { 230 + $0 = updated 231 + } 232 + } 233 + 234 + let localData = try #require(localStorage.data(at: localURL)) 235 + let localDecoded = try JSONDecoder().decode(RepositorySettings.self, from: localData) 236 + #expect(localDecoded == updated) 237 + 238 + let globalSaved: SettingsFile = withDependencies { 239 + $0.settingsFileStorage = globalStorage.storage 240 + $0.settingsFileURL = settingsFileURL 241 + $0.repositoryLocalSettingsStorage = localStorage.storage 242 + } operation: { 243 + @Shared(.settingsFile) var settingsFile: SettingsFile 244 + return settingsFile 245 + } 246 + 247 + #expect(globalSaved.repositories[repositoryID] == nil) 248 + } 249 + 250 + @Test(.dependencies) func saveWritesLocalWhenLocalFileMissing() throws { 251 + let globalStorage = SettingsTestStorage() 252 + let localStorage = RepositoryLocalSettingsTestStorage() 253 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 254 + let settingsFileURL = URL(fileURLWithPath: "/tmp/supacode-settings-\(UUID().uuidString).json") 255 + let repositoryID = rootURL.standardizedFileURL.path(percentEncoded: false) 256 + let localURL = SupacodePaths.repositorySettingsURL(for: rootURL) 257 + 258 + var updated = RepositorySettings.default 259 + updated.runScript = "echo local" 260 + 261 + withDependencies { 262 + $0.settingsFileStorage = globalStorage.storage 263 + $0.settingsFileURL = settingsFileURL 264 + $0.repositoryLocalSettingsStorage = localStorage.storage 265 + } operation: { 266 + @Shared(.repositorySettings(rootURL)) var repositorySettings: RepositorySettings 267 + $repositorySettings.withLock { 268 + $0 = updated 269 + } 270 + } 271 + 272 + let localData = try #require(localStorage.data(at: localURL)) 273 + let localDecoded = try JSONDecoder().decode(RepositorySettings.self, from: localData) 274 + #expect(localDecoded == updated) 275 + 276 + let globalSaved: SettingsFile = withDependencies { 277 + $0.settingsFileStorage = globalStorage.storage 278 + $0.settingsFileURL = settingsFileURL 279 + $0.repositoryLocalSettingsStorage = localStorage.storage 280 + } operation: { 281 + @Shared(.settingsFile) var settingsFile: SettingsFile 282 + return settingsFile 283 + } 284 + 285 + #expect(globalSaved.repositories[repositoryID] == nil) 286 + } 287 + 288 + private func encode(_ settings: RepositorySettings) throws -> Data { 289 + let encoder = JSONEncoder() 290 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 291 + return try encoder.encode(settings) 292 + } 293 + } 294 + 295 + nonisolated final class RepositoryLocalSettingsTestStorage: @unchecked Sendable { 296 + private let lock = NSLock() 297 + private var dataByURL: [URL: Data] = [:] 298 + 299 + var storage: RepositoryLocalSettingsStorage { 300 + RepositoryLocalSettingsStorage( 301 + load: { try self.load($0) }, 302 + save: { try self.save($0, at: $1) } 303 + ) 304 + } 305 + 306 + func data(at url: URL) -> Data? { 307 + lock.lock() 308 + defer { lock.unlock() } 309 + return dataByURL[url] 310 + } 311 + 312 + func save(_ data: Data, at url: URL) throws { 313 + lock.lock() 314 + defer { lock.unlock() } 315 + dataByURL[url] = data 316 + } 317 + 318 + private func load(_ url: URL) throws -> Data { 319 + lock.lock() 320 + defer { lock.unlock() } 321 + guard let data = dataByURL[url] else { 322 + throw RepositoryLocalSettingsStorageError.missing 323 + } 324 + return data 80 325 } 81 326 }