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" }) + } }