From 6fd18947153297b6bfd4d3f8f827ca3719199095 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Apr 2026 11:08:55 +0000 Subject: [PATCH] fix(macOS): avoid git date cache race on vault open Call setupGit before refreshAllFiles so the file scan's refreshGitDateCache sees gitService. Invalidate generation and clear cache when switching vaults to drop stale paths. Add regression test for vault switch. Co-authored-by: Danny Peck --- macOS/SynapseNotes/AppState.swift | 23 +++++--- .../AppStateGitDateFilteringTests.swift | 55 ++++++++++++++++++- 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/macOS/SynapseNotes/AppState.swift b/macOS/SynapseNotes/AppState.swift index 71bc5ba..f0cc989 100644 --- a/macOS/SynapseNotes/AppState.swift +++ b/macOS/SynapseNotes/AppState.swift @@ -1551,7 +1551,18 @@ class AppState: ObservableObject { // Reload settings for the new vault reloadSettingsForVault(rootURL) - + + // Drop any in-flight git date work from a prior vault and clear stale keys so a failed + // refresh cannot merge paths from the previous workspace. + gitDateCacheGeneration += 1 + gitDateCache = [:] + + // Wire git before `refreshAllFiles()` so the scan's `refreshGitDateCache()` is not a no-op + // (was: scan scheduled first, then setupGit — the scan callback could run before gitService + // existed, bumping the generation and leaving the cache empty after setupGit's refresh was + // discarded as stale). + setupGit(for: url) + selectedFile = nil fileContent = "" isDirty = false @@ -1562,14 +1573,12 @@ class AppState: ObservableObject { historyIndex = -1 updateHistoryState() refreshAllFiles() - + // Handle launch behavior only on initial vault open (not when switching) if !hadPriorVault { handleLaunchBehavior() } - setupGit(for: url) - // Write runtime state file on vault open scheduleStateFileWrite() } @@ -1742,10 +1751,8 @@ class AppState: ObservableObject { gitBranch = git.currentBranch() gitAheadCount = git.aheadCount() gitSyncStatus = .idle - // Populate the file-date cache now that gitService is available — the initial - // file scan may have committed before this ran, in which case its - // refreshGitDateCache() call was a no-op. This second call guarantees population. - refreshGitDateCache() + // `openFolder` calls `setupGit` before `refreshAllFiles`, so the scan's + // `refreshGitDateCache()` always sees a live `gitService`. startPushTimer() startPullTimer() startAutoSaveTimer() diff --git a/macOS/SynapseNotesTests/AppStateGitDateFilteringTests.swift b/macOS/SynapseNotesTests/AppStateGitDateFilteringTests.swift index acb885d..71dc5f1 100644 --- a/macOS/SynapseNotesTests/AppStateGitDateFilteringTests.swift +++ b/macOS/SynapseNotesTests/AppStateGitDateFilteringTests.swift @@ -31,11 +31,16 @@ final class AppStateGitDateFilteringTests: XCTestCase { @discardableResult private func runGit(_ args: [String], env: [String: String] = [:]) -> String { + runGit(at: tempDir, args, env: env) + } + + @discardableResult + private func runGit(at directory: URL, _ args: [String], env: [String: String] = [:]) -> String { guard let gitPath = GitService.findGit() else { return "" } let p = Process() p.executableURL = URL(fileURLWithPath: gitPath) p.arguments = args - p.currentDirectoryURL = tempDir + p.currentDirectoryURL = directory var combined = ProcessInfo.processInfo.environment for (k, v) in env { combined[k] = v } p.environment = combined @@ -135,4 +140,52 @@ final class AppStateGitDateFilteringTests: XCTestCase { XCTAssertEqual(modified.count, 1) XCTAssertEqual(modified.first?.lastPathComponent, "note.md") } + + /// Opening a new vault must clear the prior `gitDateCache` so date views cannot show + /// stale paths from another workspace. + func test_openFolder_switchingVaults_clearsGitDateCacheBeforeRepopulating() throws { + try initRepo() + commitFile("first.md", on: "2026-04-10T10:00:00-04:00") + + sut.openFolder(tempDir) + let deadline1 = Date().addingTimeInterval(10) + while sut.gitDateCache.isEmpty && Date() < deadline1 { + RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05)) + } + XCTAssertFalse(sut.gitDateCache.isEmpty) + + let otherDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: otherDir) } + try FileManager.default.createDirectory(at: otherDir, withIntermediateDirectories: true) + guard GitService.findGit() != nil else { throw XCTSkip("git not available") } + runGit(at: otherDir, ["init"]) + runGit(at: otherDir, ["config", "user.email", "test@example.com"]) + runGit(at: otherDir, ["config", "user.name", "Test"]) + runGit(at: otherDir, ["config", "commit.gpgsign", "false"]) + let secondFile = otherDir.appendingPathComponent("second.md") + try "only in second vault".write(to: secondFile, atomically: true, encoding: .utf8) + runGit(at: otherDir, ["add", "second.md"]) + runGit( + at: otherDir, + ["commit", "-m", "second"], + env: [ + "GIT_AUTHOR_DATE": "2026-04-11T10:00:00-04:00", + "GIT_COMMITTER_DATE": "2026-04-11T10:00:00-04:00", + ] + ) + + sut.openFolder(otherDir) + let deadline2 = Date().addingTimeInterval(10) + while sut.gitDateCache.isEmpty && Date() < deadline2 { + RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05)) + } + XCTAssertFalse(sut.gitDateCache.isEmpty, "second vault should populate gitDateCache") + XCTAssertFalse( + sut.gitDateCache.keys.contains { $0.path.hasPrefix(tempDir.path) }, + "After switching vaults, gitDateCache must not retain URLs from the previous root" + ) + XCTAssertEqual(sut.gitDateCache.count, 1) + XCTAssertTrue(sut.gitDateCache.keys.contains { $0.lastPathComponent == "second.md" }) + } }