Skip to content

Commit a04e274

Browse files
committed
fix: sync data setting
1 parent ba8a7ef commit a04e274

6 files changed

Lines changed: 243 additions & 1 deletion
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
import Foundation
3+
import Combine
4+
5+
@MainActor
6+
final class RepositoryCommitRuleSyncController: ObservableObject {
7+
private let defaults: UserDefaults
8+
private let localStore: RepoSettingsStore
9+
private let resolver: any RepositoryRemoteIdentityResolving
10+
private let pendingKey = "dev.thanhtran.macgit.repositoryCommitRules.pending"
11+
private var sessionID = UUID()
12+
private var activeUID: String?
13+
private var activePath: String?
14+
private var runningSessions: Set<UUID> = []
15+
16+
init(defaults: UserDefaults = .standard, localStore: RepoSettingsStore = .shared,
17+
resolver: any RepositoryRemoteIdentityResolving = RepositoryRemoteIdentityResolver()) {
18+
self.defaults = defaults
19+
self.localStore = localStore
20+
self.resolver = resolver
21+
}
22+
23+
func setSession(uid: String?, repositoryURL: URL) {
24+
guard activeUID != uid || activePath != repositoryURL.path else { return }
25+
activeUID = uid
26+
activePath = repositoryURL.path
27+
sessionID = UUID()
28+
}
29+
30+
func markChanged(_ value: Bool, uid: String?, repositoryURL: URL) {
31+
guard let uid else { return }
32+
var pending = pendingValues
33+
pending[key(uid: uid, path: repositoryURL.path)] = value
34+
defaults.set(pending, forKey: pendingKey)
35+
}
36+
37+
func reconcile(repositoryURL: URL, uid: String?, cloud: any RepositoryCommitRuleCloudStore,
38+
onApplied: (Bool) -> Void) async -> String? {
39+
setSession(uid: uid, repositoryURL: repositoryURL)
40+
guard let uid else { return nil }
41+
let session = sessionID
42+
guard runningSessions.insert(session).inserted else { return nil }
43+
defer { runningSessions.remove(session) }
44+
let pendingID = key(uid: uid, path: repositoryURL.path)
45+
guard let identity = await resolver.identity(in: repositoryURL), session == sessionID else { return nil }
46+
do {
47+
let initial = localValue(repositoryURL)
48+
let remote = try await cloud.load(identity: identity, uid: uid)
49+
guard session == sessionID else { return nil }
50+
// Edits made while offline or while loading always win over an older download.
51+
if pendingValues[pendingID] == nil, let remote {
52+
if localValue(repositoryURL) == initial {
53+
var settings = localStore.settings(for: repositoryURL.path, currentBranch: nil, remotes: [])
54+
settings.skipProtectedBranchCommitWarnings = remote
55+
localStore.update(for: repositoryURL.path, settings: settings)
56+
onApplied(remote)
57+
}
58+
} else if pendingValues[pendingID] == nil {
59+
markChanged(initial, uid: uid, repositoryURL: repositoryURL)
60+
}
61+
while session == sessionID, let value = pendingValues[pendingID] {
62+
try await cloud.save(value, identity: identity, uid: uid)
63+
guard session == sessionID else { return nil }
64+
var pending = pendingValues
65+
if pending[pendingID] == value {
66+
pending.removeValue(forKey: pendingID)
67+
defaults.set(pending, forKey: pendingKey)
68+
}
69+
}
70+
return nil
71+
} catch {
72+
guard session == sessionID else { return nil }
73+
return "Commit warning preferences are saved locally, but could not sync: \(error.localizedDescription)"
74+
}
75+
}
76+
77+
private var pendingValues: [String: Bool] {
78+
defaults.dictionary(forKey: pendingKey) as? [String: Bool] ?? [:]
79+
}
80+
81+
private func key(uid: String, path: String) -> String { "\(uid)|\(path)" }
82+
private func localValue(_ url: URL) -> Bool {
83+
localStore.settings(for: url.path, currentBranch: nil, remotes: []).skipProtectedBranchCommitWarnings
84+
}
85+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
import Foundation
3+
import FirebaseFirestore
4+
5+
@MainActor
6+
protocol RepositoryCommitRuleCloudStore {
7+
func load(identity: RepositoryBookmarkIdentity, uid: String) async throws -> Bool?
8+
func save(_ skipWarnings: Bool, identity: RepositoryBookmarkIdentity, uid: String) async throws
9+
}
10+
11+
@MainActor
12+
final class FirestoreRepositoryCommitRuleStore: RepositoryCommitRuleCloudStore {
13+
private let firestore: Firestore
14+
15+
init(firestore: Firestore = Firestore.firestore()) {
16+
self.firestore = firestore
17+
}
18+
19+
func load(identity: RepositoryBookmarkIdentity, uid: String) async throws -> Bool? {
20+
let snapshot = try await document(identity: identity, uid: uid).getDocument(source: .server)
21+
guard snapshot.exists else { return nil }
22+
guard let data = snapshot.data(),
23+
data["schemaVersion"] as? Int == 1,
24+
data["canonicalKey"] as? String == identity.canonicalKey,
25+
let value = data["skipProtectedBranchCommitWarnings"] as? Bool else {
26+
throw CloudSettingsError.invalidDocument
27+
}
28+
return value
29+
}
30+
31+
func save(_ skipWarnings: Bool, identity: RepositoryBookmarkIdentity, uid: String) async throws {
32+
try await document(identity: identity, uid: uid).setData([
33+
"schemaVersion": 1,
34+
"canonicalKey": identity.canonicalKey,
35+
"skipProtectedBranchCommitWarnings": skipWarnings,
36+
"updatedAt": FieldValue.serverTimestamp()
37+
])
38+
}
39+
40+
private func document(identity: RepositoryBookmarkIdentity, uid: String) -> DocumentReference {
41+
firestore.collection("users").document(uid)
42+
.collection("repositoryCommitRules").document(identity.documentID)
43+
}
44+
}

‎macgit/Views/MainWindow/MainWindowView+ProtectedBranchCommit.swift‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,34 @@
11
// SPDX-License-Identifier: AGPL-3.0-or-later
22
import SwiftUI
3+
import FirebaseCore
34

45
extension MainWindowView {
6+
func commitRulePreferenceChanged() {
7+
commitRuleSyncController.markChanged(
8+
repoSettings.skipProtectedBranchCommitWarnings,
9+
uid: accountController.account?.uid,
10+
repositoryURL: repositoryURL
11+
)
12+
Task { await reconcileCommitRulePreference() }
13+
}
14+
15+
func reconcileCommitRulePreference() async {
16+
let uid = accountController.account?.uid
17+
commitRuleSyncController.setSession(uid: uid, repositoryURL: repositoryURL)
18+
guard didPerformInitialLoad, uid != nil, FirebaseApp.app() != nil else { return }
19+
let warning = await commitRuleSyncController.reconcile(
20+
repositoryURL: repositoryURL,
21+
uid: uid,
22+
cloud: FirestoreRepositoryCommitRuleStore()
23+
) { value in
24+
guard accountController.account?.uid == uid else { return }
25+
repoSettings.skipProtectedBranchCommitWarnings = value
26+
}
27+
if let warning, accountController.account?.uid == uid {
28+
syncState.showInfo(warning)
29+
}
30+
}
31+
532
func authorizeProtectedBranchCommit() async -> Bool {
633
await protectedBranchCommitController.authorize(
734
repositoryURL: repositoryURL,

‎macgit/Views/MainWindow/MainWindowView+Sheets.swift‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,8 +326,10 @@ extension MainWindowView {
326326
),
327327
providerAccountPreferences: providerAccountPreferenceStore.preferences,
328328
onSave: { newSettings in
329+
let commitRuleChanged = repoSettings.skipProtectedBranchCommitWarnings != newSettings.skipProtectedBranchCommitWarnings
329330
repoSettings = newSettings
330331
repoSettingsStore.update(for: repositoryURL.path, settings: newSettings)
332+
if commitRuleChanged { commitRulePreferenceChanged() }
331333
Task {
332334
try? await GitStatusService.shared.updateGitUserConfiguration(
333335
useGlobalSettings: newSettings.useGlobalUserSettings,

‎macgit/Views/MainWindow/MainWindowView.swift‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ struct MainWindowView: View {
160160
@State var showingRepositorySettings = false
161161
@State var initiallySelectGitFlowSettings = false
162162
@State var pendingSearchFileOpenRequest: SearchFileOpenRequest?
163+
@StateObject var commitRuleSyncController = RepositoryCommitRuleSyncController()
163164
@StateObject var protectedBranchCommitController = ProtectedBranchCommitController()
164165
@State var toolbarCommitMessage = ""
165166
@State var pendingToolbarCommit: (message: String, commitAllChanges: Bool)?
@@ -168,7 +169,7 @@ struct MainWindowView: View {
168169
@State var gitFlowFinishCheckpoint: GitFlowFinishCheckpoint?
169170
@State var gitFlowRecoveryIssue: GitFlowLocalStateIssue?
170171
@State var gitFlowConfigurationIssue: GitFlowLocalStateIssue?
171-
@State private var didPerformInitialLoad = false
172+
@State var didPerformInitialLoad = false
172173
@State var pendingGitFlowTopicKind: GitFlowTopicKind?
173174
@State var pendingGitFlowFinishPlan: GitFlowFinishPlan?
174175
@State var gitFlowCurrentBranch = ""
@@ -367,6 +368,7 @@ struct MainWindowView: View {
367368
}
368369
.onChange(of: repoSettings.skipProtectedBranchCommitWarnings) { _, _ in
369370
repoSettingsStore.update(for: repositoryURL.path, settings: repoSettings)
371+
commitRulePreferenceChanged()
370372
}
371373
}
372374
.sheet(isPresented: $showingPullSheet) { pullSheet }
@@ -506,6 +508,7 @@ struct MainWindowView: View {
506508
}
507509
}
508510
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
511+
Task { await reconcileCommitRulePreference() }
509512
guard repoSettings.resolvedRefreshOnAppActive(
510513
globalValue: appState.refreshOnAppActive
511514
) else { return }
@@ -650,6 +653,9 @@ struct MainWindowView: View {
650653
.task {
651654
releaseNotesPresentation = await ReleaseNotesPresentationStore.shared.claimPresentation()
652655
}
656+
.task(id: gitFlowConfigurationSyncTaskID) {
657+
await reconcileCommitRulePreference()
658+
}
653659
.task(id: gitFlowConfigurationSyncTaskID) {
654660
await reconcileGitFlowConfigurationWithCloud()
655661
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
import XCTest
3+
@testable import macgit
4+
5+
@MainActor
6+
final class RepositoryCommitRuleSyncControllerTests: XCTestCase {
7+
func testCloudPreferenceAppliesWithoutReplacingOtherRepoSettings() async throws {
8+
let suite = "commit-rule-sync-\(UUID())"
9+
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
10+
defer { defaults.removePersistentDomain(forName: suite) }
11+
let local = RepoSettingsStore(userDefaults: defaults)
12+
let url = URL(fileURLWithPath: "/tmp/repo")
13+
var settings = RepoSettings.defaults(currentBranch: "main", remotes: ["origin"])
14+
settings.userName = "Local author"
15+
local.update(for: url.path, settings: settings)
16+
let controller = RepositoryCommitRuleSyncController(defaults: defaults, localStore: local, resolver: CommitRuleTestIdentity())
17+
let cloud = CommitRuleTestCloud(value: true)
18+
var applied: Bool?
19+
let warning = await controller.reconcile(repositoryURL: url, uid: "user-a", cloud: cloud) { applied = $0 }
20+
XCTAssertNil(warning)
21+
XCTAssertEqual(applied, true)
22+
let loaded = local.settings(for: url.path, currentBranch: nil, remotes: [])
23+
XCTAssertTrue(loaded.skipProtectedBranchCommitWarnings)
24+
XCTAssertEqual(loaded.userName, "Local author")
25+
XCTAssertEqual(cloud.saves, [])
26+
}
27+
28+
func testPendingOfflineChoiceSurvivesControllerRecreationAndWinsOverCloud() async throws {
29+
let suite = "commit-rule-sync-\(UUID())"
30+
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
31+
defer { defaults.removePersistentDomain(forName: suite) }
32+
let local = RepoSettingsStore(userDefaults: defaults)
33+
let url = URL(fileURLWithPath: "/tmp/repo")
34+
let first = RepositoryCommitRuleSyncController(defaults: defaults, localStore: local, resolver: CommitRuleTestIdentity())
35+
first.markChanged(false, uid: "user-a", repositoryURL: url)
36+
let cloud = CommitRuleTestCloud(value: true)
37+
cloud.failSave = true
38+
let failure = await first.reconcile(repositoryURL: url, uid: "user-a", cloud: cloud) { _ in XCTFail("Must not overwrite local edit") }
39+
XCTAssertNotNil(failure)
40+
cloud.failSave = false
41+
let second = RepositoryCommitRuleSyncController(defaults: defaults, localStore: local, resolver: CommitRuleTestIdentity())
42+
let warning = await second.reconcile(repositoryURL: url, uid: "user-a", cloud: cloud) { _ in XCTFail("Must upload pending edit") }
43+
XCTAssertNil(warning)
44+
XCTAssertEqual(cloud.value, false)
45+
}
46+
47+
func testSignedOutDoesNotAccessCloud() async {
48+
let cloud = CommitRuleTestCloud(value: true)
49+
let controller = RepositoryCommitRuleSyncController(resolver: CommitRuleTestIdentity())
50+
_ = await controller.reconcile(repositoryURL: URL(fileURLWithPath: "/tmp/repo"), uid: nil, cloud: cloud) { _ in XCTFail() }
51+
XCTAssertEqual(cloud.loads, 0)
52+
XCTAssertTrue(cloud.saves.isEmpty)
53+
}
54+
}
55+
56+
private struct CommitRuleTestIdentity: RepositoryRemoteIdentityResolving {
57+
func identity(in repositoryURL: URL) async -> RepositoryBookmarkIdentity? {
58+
RepositoryBookmarkIdentity.resolve(remoteURLString: "git@github.com:team/repo.git")
59+
}
60+
}
61+
62+
@MainActor
63+
private final class CommitRuleTestCloud: RepositoryCommitRuleCloudStore {
64+
var value: Bool?
65+
var saves: [Bool] = []
66+
var loads = 0
67+
var failSave = false
68+
init(value: Bool?) { self.value = value }
69+
func load(identity: RepositoryBookmarkIdentity, uid: String) async throws -> Bool? {
70+
loads += 1
71+
return value
72+
}
73+
func save(_ skipWarnings: Bool, identity: RepositoryBookmarkIdentity, uid: String) async throws {
74+
if failSave { throw CloudSettingsError.invalidDocument }
75+
saves.append(skipWarnings)
76+
value = skipWarnings
77+
}
78+
}

0 commit comments

Comments
 (0)