Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 59 additions & 29 deletions macgit/App/GitFlowConfigurationSyncController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,24 +34,54 @@ final class GitFlowConfigurationSyncController: ObservableObject {
private let cloudStore: GitFlowConfigurationCloudStore?
private let localStore: GitFlowConfigurationStore
private let identityResolver: any RepositoryRemoteIdentityResolving
private let userDefaults: UserDefaults
private let pendingUploadsKey: String
private let dataStore: LocalDataStore
private var operations: [URL: (UUID, Task<Void, Never>)] = [:]

init(
cloudStore: GitFlowConfigurationCloudStore?,
localStore: GitFlowConfigurationStore = GitFlowConfigurationStore(),
identityResolver: any RepositoryRemoteIdentityResolving = RepositoryRemoteIdentityResolver(),
userDefaults: UserDefaults = .standard,
pendingUploadsKey: String = "dev.thanhtran.macgit.gitFlowConfiguration.pendingUploads"
dataStore: LocalDataStore? = nil
) {
self.cloudStore = cloudStore
self.localStore = localStore
self.identityResolver = identityResolver
self.userDefaults = userDefaults
self.pendingUploadsKey = pendingUploadsKey
self.dataStore = dataStore ?? .shared
}

func reconcile(
// File-backed configuration and its SQLite outbox cannot share a transaction.
// Serialize their workflows per repository so a download cannot interleave a save.
private func serialized<T>(in repositoryURL: URL, operation: @escaping () async throws -> T) async throws -> T {
let previous = operations[repositoryURL]?.1
let id = UUID()
let task = Task {
await previous?.value
try await dataStore.prepare()
return try await operation()
}
operations[repositoryURL] = (id, Task { _ = try? await task.value })
defer { if operations[repositoryURL]?.0 == id { operations[repositoryURL] = nil } }
return try await task.value
}

func reconcile(repositoryURL: URL, fallbackConfiguration: GitFlowConfiguration, uid: String?) async -> GitFlowConfigurationSyncOutcome {
guard uid != nil, cloudStore != nil else { return .unchanged }
do {
return try await serialized(in: repositoryURL) {
await self.reconcileNow(repositoryURL: repositoryURL, fallbackConfiguration: fallbackConfiguration, uid: uid)
}
} catch {
return GitFlowConfigurationSyncOutcome(configuration: nil, warningMessage: error.localizedDescription)
}
}

func save(_ configuration: GitFlowConfiguration, repositoryURL: URL, uid: String?) async throws -> String? {
try await serialized(in: repositoryURL) {
try await self.saveNow(configuration, repositoryURL: repositoryURL, uid: uid)
}
}

private func reconcileNow(
repositoryURL: URL,
fallbackConfiguration: GitFlowConfiguration,
uid: String?
Expand All @@ -68,26 +98,27 @@ final class GitFlowConfigurationSyncController: ObservableObject {
let uploadID = pendingUploadID(uid: uid, repositoryID: identity.documentID)

do {
if isPendingUpload(uploadID) {
if let pendingVersion = pendingVersion(uploadID) {
if case .value(let localConfiguration) = localResult {
try await upload(
localConfiguration,
identity: identity,
uid: uid,
cloudStore: cloudStore
)
clearPendingUpload(uploadID)
try await clearPendingUpload(uploadID, version: pendingVersion)
return .unchanged
}
clearPendingUpload(uploadID)
try await clearPendingUpload(uploadID, version: pendingVersion)
}

if let cloudConfiguration = try await cloudStore.configuration(
repositoryID: identity.documentID,
uid: uid
) {
let latestLocalResult = await localStore.loadResult(in: repositoryURL)
if isPendingUpload(uploadID) || localConfigurationChanged(
let pendingVersion = pendingVersion(uploadID)
if pendingVersion != nil || localConfigurationChanged(
from: localResult,
to: latestLocalResult
) {
Expand All @@ -98,7 +129,7 @@ final class GitFlowConfigurationSyncController: ObservableObject {
uid: uid,
cloudStore: cloudStore
)
clearPendingUpload(uploadID)
if let pendingVersion { try await clearPendingUpload(uploadID, version: pendingVersion) }
}
return .unchanged
}
Expand Down Expand Up @@ -140,7 +171,7 @@ final class GitFlowConfigurationSyncController: ObservableObject {
}
}

func save(
private func saveNow(
_ configuration: GitFlowConfiguration,
repositoryURL: URL,
uid: String?
Expand All @@ -152,7 +183,7 @@ final class GitFlowConfigurationSyncController: ObservableObject {
return nil
}
let uploadID = pendingUploadID(uid: uid, repositoryID: identity.documentID)
markPendingUpload(uploadID)
let pendingVersion = try await markPendingUpload(uploadID)

do {
try await upload(
Expand All @@ -161,7 +192,7 @@ final class GitFlowConfigurationSyncController: ObservableObject {
uid: uid,
cloudStore: cloudStore
)
clearPendingUpload(uploadID)
try await clearPendingUpload(uploadID, version: pendingVersion)
return nil
} catch {
return "Git Flow was saved locally, but its configuration could not sync: \(error.localizedDescription)"
Expand Down Expand Up @@ -202,23 +233,22 @@ final class GitFlowConfigurationSyncController: ObservableObject {
"\(uid)|\(repositoryID)"
}

private func isPendingUpload(_ id: String) -> Bool {
pendingUploadIDs.contains(id)
private func pendingVersion(_ id: String) -> String? {
try? dataStore.value(String.self, in: "gitFlowPending", id: id)
}

private func markPendingUpload(_ id: String) {
var ids = pendingUploadIDs
ids.insert(id)
userDefaults.set(Array(ids), forKey: pendingUploadsKey)
}

private func clearPendingUpload(_ id: String) {
var ids = pendingUploadIDs
ids.remove(id)
userDefaults.set(Array(ids), forKey: pendingUploadsKey)
private func markPendingUpload(_ id: String) async throws -> String {
let version = UUID().uuidString
try await dataStore.transaction { transaction in
try transaction.set(version, in: "gitFlowPending", id: id)
}
return version
}

private var pendingUploadIDs: Set<String> {
Set(userDefaults.stringArray(forKey: pendingUploadsKey) ?? [])
private func clearPendingUpload(_ id: String, version: String) async throws {
try await dataStore.transaction { transaction in
guard try transaction.value(String.self, in: "gitFlowPending", id: id) == version else { return }
transaction.remove(in: "gitFlowPending", id: id)
}
}
}
16 changes: 8 additions & 8 deletions macgit/App/GitProviderAccountController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ final class GitProviderAccountController: ObservableObject {
init(
store: GitProviderAccountStore,
tokenVault: GitProviderTokenVault,
sshKeyStore: GitProviderSSHKeyStore = UserDefaultsGitProviderSSHKeyStore(),
sshKeyStore: GitProviderSSHKeyStore? = nil,
sshAuthService: GitProviderSSHAuthenticating = GitProviderSSHAuthService(),
authService: GitProviderAuthenticating? = nil,
configuration: GitHubProviderAuthConfiguration? = nil,
Expand All @@ -57,7 +57,7 @@ final class GitProviderAccountController: ObservableObject {
) {
self.store = store
self.tokenVault = tokenVault
self.sshKeyStore = sshKeyStore
self.sshKeyStore = sshKeyStore ?? SQLiteGitProviderSSHKeyStore()
self.sshAuthService = sshAuthService
self.authService = authService
self.configuration = configuration
Expand Down Expand Up @@ -90,7 +90,7 @@ final class GitProviderAccountController: ObservableObject {
hasSameProviderIdentity($0, previousAccount)
}) {
try? tokenVault.deleteToken(for: previousAccount)
try? sshKeyStore.deleteKey(for: previousAccount)
try? await sshKeyStore.deleteKey(for: previousAccount)
}
}

Expand Down Expand Up @@ -210,7 +210,7 @@ final class GitProviderAccountController: ObservableObject {
errorMessage = nil
do {
try tokenVault.deleteToken(for: account)
try sshKeyStore.deleteKey(for: account)
try await sshKeyStore.deleteKey(for: account)
try await store.delete(accountID: account.id)
accounts.removeAll { $0.id == account.id }
} catch {
Expand Down Expand Up @@ -290,9 +290,9 @@ final class GitProviderAccountController: ObservableObject {

do {
if transportProtocol == .ssh, let sshKey {
try sshKeyStore.saveKey(sshKey, for: updatedAccount)
try await sshKeyStore.saveKey(sshKey, for: updatedAccount)
} else {
try sshKeyStore.deleteKey(for: updatedAccount)
try await sshKeyStore.deleteKey(for: updatedAccount)
}
try await store.save(updatedAccount)
publish(updatedAccount)
Expand Down Expand Up @@ -348,11 +348,11 @@ final class GitProviderAccountController: ObservableObject {
)

try validateAccountCreation(for: account)
try sshKeyStore.saveKey(key, for: account)
try await sshKeyStore.saveKey(key, for: account)
do {
try await store.save(account)
} catch {
try? sshKeyStore.deleteKey(for: account)
try? await sshKeyStore.deleteKey(for: account)
throw error
}

Expand Down
Loading
Loading