From bfe25b97d2b562844fe68bd11a54b8c4f498779e Mon Sep 17 00:00:00 2001 From: yager-42 <331382125@qq.com> Date: Tue, 11 Aug 2026 10:58:55 +0800 Subject: [PATCH 1/4] feat: derive git watch context for status observation Add git.watchContext command to the Rust core that resolves the repository root, git directory, and git common directory as canonical absolute paths. The Swift bridge exposes these through GitService and the directory watcher now observes the repository and Git metadata roots in addition to the workspace root. DirectoryChangeBatch routes filesystem events to workspace snapshot refreshes or Git status refreshes so worktree changes refresh Git without rescanning the whole tree, and Git metadata changes do not reload the workspace. Recovery events rebuild the watcher and rescan when watched roots change, and Git refreshes are coalesced through a pending-flag state machine that keeps the final request when a refresh is already running. Projects now start observing the workspace immediately, resolve the Git watch context afterward, and refresh Git unconditionally once loaded, which also picks up a repository initialized after the project was opened. The app re-resolves the watch context for every open project when it becomes active again. --- Sources/Lithe/Application/AppServices.swift | 4 +- .../Lithe/Application/GitFeatureModel.swift | 17 +- .../Application/WorkspaceFeatureModel.swift | 198 ++++- .../Core/Ports/DirectoryChangeSource.swift | 94 +++ Sources/Lithe/Core/RustCoreBridge.swift | 42 ++ Sources/Lithe/Core/RustGitOperations.swift | 4 + Sources/Lithe/LitheApp.swift | 5 + Sources/Lithe/Models/AppModel.swift | 6 +- Sources/Lithe/Models/GitModels.swift | 6 + .../Lithe/Models/ProjectSessionManager.swift | 6 + .../FileWatching/MacDirectoryWatcher.swift | 119 ++- .../Platform/MacOS/MacServiceContainer.swift | 6 +- Sources/Lithe/Services/GitService.swift | 13 +- docs/architecture/git-status-observation.md | 683 ++++++++++++++++++ rust/lithe-core/src/command.rs | 2 + rust/lithe-core/src/git.rs | 59 +- rust/lithe-core/src/model.rs | 8 + rust/lithe-core/src/runtime.rs | 22 +- 18 files changed, 1245 insertions(+), 49 deletions(-) create mode 100644 docs/architecture/git-status-observation.md diff --git a/Sources/Lithe/Application/AppServices.swift b/Sources/Lithe/Application/AppServices.swift index d3c9233e..77a10458 100644 --- a/Sources/Lithe/Application/AppServices.swift +++ b/Sources/Lithe/Application/AppServices.swift @@ -2,9 +2,9 @@ import Foundation protocol DirectoryWatcherFactory { func make( - root: URL, + configuration: DirectoryWatchConfiguration, visibilityRules: FileVisibilityRules, - onChange: @escaping @Sendable ([String]) -> Void + onChange: @escaping @Sendable (DirectoryChangeBatch) -> Void ) -> any DirectoryChangeSource } diff --git a/Sources/Lithe/Application/GitFeatureModel.swift b/Sources/Lithe/Application/GitFeatureModel.swift index 91e3736d..82ffac1a 100644 --- a/Sources/Lithe/Application/GitFeatureModel.swift +++ b/Sources/Lithe/Application/GitFeatureModel.swift @@ -62,6 +62,8 @@ final class GitFeatureModel: ObservableObject { private var onGitOperationEnded: (@MainActor () async -> Void)? private var gitHistoryLimit = 300 private var deferredSavedChanges: GitDeferredSavedChanges? + private var refreshRequestedWhileRunning = false + init(service: GitService, shelveService: ShelveService? = nil) { self.service = service @@ -114,6 +116,7 @@ final class GitFeatureModel: ObservableObject { gitDiffWhitespaceMode = .doNotIgnore isLoadingDiff = false isRefreshingGit = false + refreshRequestedWhileRunning = false pendingDiscardChange = nil pendingDiscardHunk = nil isCommitting = false @@ -139,15 +142,25 @@ final class GitFeatureModel: ObservableObject { } func refreshGit() async { - guard let workspaceURLProvider, !isRefreshingGit else { return } + guard let workspaceURLProvider else { return } + if isRefreshingGit { + refreshRequestedWhileRunning = true + return + } guard let workspaceURL = workspaceURLProvider() else { reset() return } isRefreshingGit = true - defer { isRefreshingGit = false } + repeat { + refreshRequestedWhileRunning = false + await refreshGitState(at: workspaceURL) + } while refreshRequestedWhileRunning && workspaceURLProvider() == workspaceURL + isRefreshingGit = false + } + private func refreshGitState(at workspaceURL: URL) async { if let snapshot = await service.snapshot(for: workspaceURL) { gitRepositoryRoot = snapshot.repositoryRoot currentBranch = snapshot.branch diff --git a/Sources/Lithe/Application/WorkspaceFeatureModel.swift b/Sources/Lithe/Application/WorkspaceFeatureModel.swift index a5a7db3a..dea744b4 100644 --- a/Sources/Lithe/Application/WorkspaceFeatureModel.swift +++ b/Sources/Lithe/Application/WorkspaceFeatureModel.swift @@ -22,16 +22,25 @@ final class WorkspaceFeatureModel: ObservableObject { private let operations: any WorkspaceOperations private let fileOperations: any WorkspaceFileOperations + private let gitWatchContextProvider: any GitWatchContextProviding private let directoryWatcherFactory: any DirectoryWatcherFactory private let workspaceSessionStore: WorkspaceSessionStore private var workspaceURL: URL? private var visibilityRules = FileVisibilityRules.default + private var watchConfiguration: DirectoryWatchConfiguration? private var directoryWatcher: (any DirectoryChangeSource)? private var refreshTask: Task? + private var gitRefreshTask: Task? + private var recoveryTask: Task? private var visibilityRulesRefreshTask: Task? private var searchIndexTask: Task? private var pendingExternalPaths: Set = [] + private var pendingGitRefresh = false + private var pendingFullRescan = false + private var pendingWatchRootsChanged = false + private var isGitRefreshRunning = false private var externalRefreshGeneration = 0 + private var gitRefreshGeneration = 0 private var workspaceSessionPersistenceTask: Task? private var hasRestoredWorkspaceSession = false @@ -55,11 +64,13 @@ final class WorkspaceFeatureModel: ObservableObject { init( operations: any WorkspaceOperations, fileOperations: any WorkspaceFileOperations, + gitWatchContextProvider: any GitWatchContextProviding, directoryWatcherFactory: any DirectoryWatcherFactory, workspaceSessionStore: WorkspaceSessionStore ) { self.operations = operations self.fileOperations = fileOperations + self.gitWatchContextProvider = gitWatchContextProvider self.directoryWatcherFactory = directoryWatcherFactory self.workspaceSessionStore = workspaceSessionStore } @@ -110,11 +121,19 @@ final class WorkspaceFeatureModel: ObservableObject { } directoryWatcher?.stop() directoryWatcher = nil + watchConfiguration = nil refreshTask?.cancel() + gitRefreshTask?.cancel() + recoveryTask?.cancel() visibilityRulesRefreshTask?.cancel() workspaceSessionPersistenceTask?.cancel() pendingExternalPaths.removeAll() + pendingGitRefresh = false + pendingFullRescan = false + pendingWatchRootsChanged = false + isGitRefreshRunning = false externalRefreshGeneration += 1 + gitRefreshGeneration += 1 gitOperationFreezeDepth = 0 workspaceURL = nil hasRestoredWorkspaceSession = false @@ -131,6 +150,8 @@ final class WorkspaceFeatureModel: ObservableObject { deinit { directoryWatcher?.stop() refreshTask?.cancel() + gitRefreshTask?.cancel() + recoveryTask?.cancel() visibilityRulesRefreshTask?.cancel() workspaceSessionPersistenceTask?.cancel() searchIndexTask?.cancel() @@ -141,7 +162,15 @@ final class WorkspaceFeatureModel: ObservableObject { self.visibilityRules = visibilityRules hasRestoredWorkspaceSession = false pendingExternalPaths.removeAll() + pendingGitRefresh = false + pendingFullRescan = false + pendingWatchRootsChanged = false externalRefreshGeneration += 1 + gitRefreshGeneration += 1 + startWatching( + DirectoryWatchConfiguration(workspaceRoot: url, gitContext: nil), + visibilityRules: visibilityRules + ) } /// Temporarily prevents FSEvents callbacks from making the workspace observe @@ -151,23 +180,34 @@ final class WorkspaceFeatureModel: ObservableObject { gitOperationFreezeDepth += 1 refreshTask?.cancel() refreshTask = nil + gitRefreshTask?.cancel() + gitRefreshTask = nil + recoveryTask?.cancel() + recoveryTask = nil externalRefreshGeneration += 1 + gitRefreshGeneration += 1 } - /// Flushes all watcher paths once the outermost Git operation completes. - /// The flush is intentionally immediate: GitFeatureModel has already - /// refreshed its own status, so this is the one place where the editor, - /// workspace tree, history, and project services catch up together. + /// Flushes accumulated workspace and Git events after the outermost Git operation. func endGitOperationFreeze() async { guard gitOperationFreezeDepth > 0 else { return } gitOperationFreezeDepth -= 1 - guard gitOperationFreezeDepth == 0, - let workspaceURL, - !pendingExternalPaths.isEmpty else { return } - let changedPaths = Array(pendingExternalPaths) - pendingExternalPaths.removeAll() - externalRefreshGeneration += 1 - await applyExternalRefresh(changedPaths, at: workspaceURL) + guard gitOperationFreezeDepth == 0, let workspaceURL else { return } + + if pendingWatchRootsChanged || pendingFullRescan { + await applyPendingRecovery(at: workspaceURL) + return + } + if !pendingExternalPaths.isEmpty { + let changedPaths = Array(pendingExternalPaths) + pendingExternalPaths.removeAll() + externalRefreshGeneration += 1 + await applyExternalRefresh(changedPaths, at: workspaceURL) + return + } + if pendingGitRefresh { + await drainGitRefreshes() + } } func rebuild( @@ -226,9 +266,11 @@ final class WorkspaceFeatureModel: ObservableObject { } hasRestoredWorkspaceSession = true } + await updateWatchConfiguration() await onSnapshotLoaded?(snapshot, isInitialLoad) - if self.visibilityRules == rules { - startWatching(workspaceURL, visibilityRules: rules) + await requestGitRefreshNow() + if pendingFullRescan || pendingWatchRootsChanged { + scheduleRecovery() } return .loaded(snapshot) } @@ -247,7 +289,16 @@ final class WorkspaceFeatureModel: ObservableObject { func startWatchingCurrent() { guard let workspaceURL else { return } - startWatching(workspaceURL, visibilityRules: visibilityRules) + startWatching( + watchConfiguration ?? DirectoryWatchConfiguration(workspaceRoot: workspaceURL, gitContext: nil), + visibilityRules: visibilityRules + ) + } + + func resumeObservationAfterActivation() async { + guard workspaceURL != nil else { return } + await updateWatchConfiguration(forceRebuild: true) + await requestGitRefreshNow() } func contains(_ url: URL) -> Bool { @@ -463,19 +514,94 @@ final class WorkspaceFeatureModel: ObservableObject { }.value } - private func startWatching(_ url: URL, visibilityRules: FileVisibilityRules) { + private func startWatching( + _ configuration: DirectoryWatchConfiguration, + visibilityRules: FileVisibilityRules + ) { directoryWatcher?.stop() + watchConfiguration = configuration directoryWatcher = directoryWatcherFactory.make( - root: url, + configuration: configuration, visibilityRules: visibilityRules - ) { [weak self] paths in + ) { [weak self] batch in Task { @MainActor [weak self] in - self?.scheduleExternalRefresh(paths: paths) + self?.scheduleDirectoryChange(batch) } } directoryWatcher?.start() } + private func updateWatchConfiguration(forceRebuild: Bool = false) async { + guard let workspaceURL else { return } + let context = await gitWatchContextProvider.watchContext(for: workspaceURL) + guard self.workspaceURL == workspaceURL else { return } + let configuration = DirectoryWatchConfiguration( + workspaceRoot: workspaceURL, + gitContext: context + ) + guard forceRebuild || configuration != watchConfiguration || directoryWatcher == nil else { + return + } + startWatching(configuration, visibilityRules: visibilityRules) + } + + private func scheduleDirectoryChange(_ batch: DirectoryChangeBatch) { + guard !batch.isEmpty else { return } + if batch.watchRootsChanged || batch.requiresFullRescan { + pendingWatchRootsChanged = pendingWatchRootsChanged || batch.watchRootsChanged + pendingFullRescan = pendingFullRescan || batch.requiresFullRescan + pendingGitRefresh = true + gitRefreshTask?.cancel() + gitRefreshTask = nil + scheduleRecovery() + return + } + + if !batch.workspacePaths.isEmpty { + if batch.gitStateMayHaveChanged { pendingGitRefresh = true } + scheduleExternalRefresh(paths: batch.workspacePaths) + } else if batch.gitStateMayHaveChanged { + scheduleGitRefresh() + } + } + + private func scheduleRecovery() { + guard gitOperationFreezeDepth == 0 else { + recoveryTask?.cancel() + recoveryTask = nil + return + } + recoveryTask?.cancel() + recoveryTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(350)) + guard !Task.isCancelled, let self, let workspaceURL = self.workspaceURL else { return } + await self.applyPendingRecovery(at: workspaceURL) + } + } + + private func applyPendingRecovery(at workspaceURL: URL) async { + guard self.workspaceURL == workspaceURL else { return } + guard gitOperationFreezeDepth == 0 else { return } + if isLoadingWorkspace || isRefreshingWorkspace { + scheduleRecovery() + return + } + + let rootsChanged = pendingWatchRootsChanged + let fullRescan = pendingFullRescan + pendingWatchRootsChanged = false + pendingFullRescan = false + if rootsChanged { + await updateWatchConfiguration(forceRebuild: true) + } + if fullRescan { + await refreshCurrent() + } + if pendingGitRefresh { + await drainGitRefreshes() + } + } + private func scheduleExternalRefresh(paths: [String]) { guard !paths.isEmpty else { return } pendingExternalPaths.formUnion(paths) @@ -499,10 +625,44 @@ final class WorkspaceFeatureModel: ObservableObject { } } + private func scheduleGitRefresh() { + pendingGitRefresh = true + gitRefreshGeneration += 1 + guard gitOperationFreezeDepth == 0, !isGitRefreshRunning else { return } + let generation = gitRefreshGeneration + gitRefreshTask?.cancel() + gitRefreshTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(350)) + guard !Task.isCancelled, + let self, + self.gitRefreshGeneration == generation else { return } + await self.drainGitRefreshes() + } + } + + private func requestGitRefreshNow() async { + pendingGitRefresh = true + gitRefreshGeneration += 1 + gitRefreshTask?.cancel() + gitRefreshTask = nil + await drainGitRefreshes() + } + + private func drainGitRefreshes() async { + guard gitOperationFreezeDepth == 0, !isGitRefreshRunning else { return } + isGitRefreshRunning = true + while pendingGitRefresh, gitOperationFreezeDepth == 0 { + pendingGitRefresh = false + await refreshGit?() + } + isGitRefreshRunning = false + } + private func applyExternalRefresh(_ paths: [String], at workspaceURL: URL) async { guard self.workspaceURL == workspaceURL else { return } guard gitOperationFreezeDepth == 0 else { pendingExternalPaths.formUnion(paths) + pendingGitRefresh = true return } if isLoadingWorkspace || isRefreshingWorkspace { @@ -535,7 +695,7 @@ final class WorkspaceFeatureModel: ObservableObject { || url.pathExtension.lowercased() == "java" } if requiresProjectServiceReload { await reloadProjectServices?() } - await refreshGit?() + await requestGitRefreshNow() } private func scheduleSearchIndexWarm(at workspaceURL: URL, rules: FileVisibilityRules) { diff --git a/Sources/Lithe/Core/Ports/DirectoryChangeSource.swift b/Sources/Lithe/Core/Ports/DirectoryChangeSource.swift index 021602c6..e6b59baa 100644 --- a/Sources/Lithe/Core/Ports/DirectoryChangeSource.swift +++ b/Sources/Lithe/Core/Ports/DirectoryChangeSource.swift @@ -1,5 +1,99 @@ import Foundation + +struct DirectoryWatchConfiguration: Equatable, Sendable { + let workspaceRoot: URL + let repositoryRoot: URL? + let gitDirectory: URL? + let gitCommonDirectory: URL? + + init(workspaceRoot: URL, gitContext: GitWatchContext?) { + self.workspaceRoot = Self.normalize(workspaceRoot) + repositoryRoot = gitContext.map { Self.normalize($0.repositoryRoot) } + gitDirectory = gitContext.map { Self.normalize($0.gitDirectory) } + gitCommonDirectory = gitContext.map { Self.normalize($0.gitCommonDirectory) } + } + + var physicalRoots: [URL] { + let logicalRoots = [workspaceRoot, repositoryRoot, gitDirectory, gitCommonDirectory] + .compactMap { $0 } + var seen = Set() + let uniqueRoots = logicalRoots + .filter { seen.insert($0.path).inserted } + .sorted { + if $0.path.count == $1.path.count { return $0.path < $1.path } + return $0.path.count < $1.path.count + } + return uniqueRoots.filter { candidate in + !uniqueRoots.contains { root in + root.path != candidate.path && Self.contains(root, candidate) + } + } + } + + func containsWorkspacePath(_ url: URL) -> Bool { + Self.contains(workspaceRoot, Self.normalize(url)) + } + + func containsRepositoryPath(_ url: URL) -> Bool { + guard let repositoryRoot else { return false } + return Self.contains(repositoryRoot, Self.normalize(url)) + } + + func containsGitMetadataPath(_ url: URL) -> Bool { + let normalized = Self.normalize(url) + return [gitDirectory, gitCommonDirectory] + .compactMap { $0 } + .contains { Self.contains($0, normalized) } + } + + func isGitContextPointer(_ url: URL) -> Bool { + let normalized = Self.normalize(url) + let candidates = [workspaceRoot, repositoryRoot] + .compactMap { $0 } + .map { $0.appendingPathComponent(".git").standardizedFileURL.path } + return candidates.contains(normalized.path) + } + + func isLogicalRoot(_ url: URL) -> Bool { + let path = Self.normalize(url).path + return [workspaceRoot, repositoryRoot, gitDirectory, gitCommonDirectory] + .compactMap { $0 } + .contains { $0.path == path } + } + + private static func normalize(_ url: URL) -> URL { + url.standardizedFileURL.resolvingSymlinksInPath() + } + + private static func contains(_ parent: URL, _ child: URL) -> Bool { + child.path == parent.path || child.path.hasPrefix(parent.path + "/") + } +} + +struct DirectoryChangeBatch: Equatable, Sendable { + var workspacePaths: [String] + var gitStateMayHaveChanged: Bool + var requiresFullRescan: Bool + var watchRootsChanged: Bool + + init( + workspacePaths: [String] = [], + gitStateMayHaveChanged: Bool = false, + requiresFullRescan: Bool = false, + watchRootsChanged: Bool = false + ) { + self.workspacePaths = workspacePaths + self.gitStateMayHaveChanged = gitStateMayHaveChanged + self.requiresFullRescan = requiresFullRescan + self.watchRootsChanged = watchRootsChanged + } + + var isEmpty: Bool { + workspacePaths.isEmpty && !gitStateMayHaveChanged && !requiresFullRescan && !watchRootsChanged + } +} + protocol DirectoryChangeSource: AnyObject { func start() func stop() diff --git a/Sources/Lithe/Core/RustCoreBridge.swift b/Sources/Lithe/Core/RustCoreBridge.swift index cafae67f..c23897fb 100644 --- a/Sources/Lithe/Core/RustCoreBridge.swift +++ b/Sources/Lithe/Core/RustCoreBridge.swift @@ -19,6 +19,19 @@ struct RustCoreBridge: Sendable { let ok: Bool let data: Data? let error: ErrorPayload? + + private enum CodingKeys: String, CodingKey { + case ok + case data + case error + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + ok = try container.decode(Bool.self, forKey: .ok) + data = container.contains(.data) ? try container.decode(Data.self, forKey: .data) : nil + error = try container.decodeIfPresent(ErrorPayload.self, forKey: .error) + } } private struct ErrorPayload: Decodable { @@ -611,6 +624,21 @@ struct RustCoreBridge: Sendable { } } + struct GitWatchContextPayload: Decodable, Sendable { + let repositoryRoot: String + let gitDirectory: String + let gitCommonDirectory: String + + func makeContext() -> GitWatchContext { + GitWatchContext( + repositoryRoot: URL(fileURLWithPath: repositoryRoot).standardizedFileURL, + gitDirectory: URL(fileURLWithPath: gitDirectory).standardizedFileURL, + gitCommonDirectory: URL(fileURLWithPath: gitCommonDirectory).standardizedFileURL + ) + } + } + + private struct EmptyPayload: Encodable { let value = 0 } @@ -749,6 +777,11 @@ struct RustCoreBridge: Sendable { let root: String } + private struct GitWatchContextRequest: Encodable { + let root: String + } + + private struct GitCommandRequest: Encodable { let root: String let arguments: [String] @@ -1188,6 +1221,15 @@ struct RustCoreBridge: Sendable { ) } + func gitWatchContext(at rootURL: URL) -> GitWatchContextPayload? { + let result: Result = executeResult( + command: "git.watchContext", + payload: GitWatchContextRequest(root: rootURL.standardizedFileURL.path) + ) + return try? result.get() + } + + func gitCommand( at rootURL: URL, arguments: [String], diff --git a/Sources/Lithe/Core/RustGitOperations.swift b/Sources/Lithe/Core/RustGitOperations.swift index 59130cbb..e46d0bec 100644 --- a/Sources/Lithe/Core/RustGitOperations.swift +++ b/Sources/Lithe/Core/RustGitOperations.swift @@ -274,6 +274,10 @@ struct RustGitOperations: GitOperations, GitCommandRunner, Sendable { core.gitStatus(at: rootURL)?.makeSnapshot(at: rootURL) } + func watchContext(at rootURL: URL) -> GitWatchContext? { + core.gitWatchContext(at: rootURL)?.makeContext() + } + func diffPatch( at rootURL: URL, pathspecs: [String], diff --git a/Sources/Lithe/LitheApp.swift b/Sources/Lithe/LitheApp.swift index f5ae6295..7584f0ca 100644 --- a/Sources/Lithe/LitheApp.swift +++ b/Sources/Lithe/LitheApp.swift @@ -18,6 +18,11 @@ final class LitheAppDelegate: NSObject, NSApplicationDelegate { projectSessions?.stopAllSessions() } + func applicationDidBecomeActive(_ notification: Notification) { + guard let projectSessions else { return } + Task { await projectSessions.resumeGitObservationAfterActivation() } + } + static func confirmUnsavedDocuments(for projectSessions: ProjectSessionManager) -> Bool { guard projectSessions.hasUnsavedDocuments else { return true } diff --git a/Sources/Lithe/Models/AppModel.swift b/Sources/Lithe/Models/AppModel.swift index a4eccf50..d3345d45 100644 --- a/Sources/Lithe/Models/AppModel.swift +++ b/Sources/Lithe/Models/AppModel.swift @@ -137,6 +137,7 @@ final class AppModel: ObservableObject, Identifiable { workspaceFeature = WorkspaceFeatureModel( operations: services.workspaceOperations, fileOperations: services.fileOperations, + gitWatchContextProvider: services.gitService, directoryWatcherFactory: services.directoryWatcherFactory, workspaceSessionStore: services.workspaceSessionStore ) @@ -260,7 +261,6 @@ final class AppModel: ObservableObject, Identifiable { onSnapshotLoaded: { [weak self] snapshot, isInitialLoad in guard let self, let workspaceURL = self.workspaceURL else { return } self.javaFeature.prepareProject(at: workspaceURL, files: snapshot.files) - await self.refreshGit() await self.javaFeature.loadProject(at: workspaceURL, files: snapshot.files) if isInitialLoad { self.projectHistoryFeature.seed(files: snapshot.files) @@ -553,6 +553,10 @@ final class AppModel: ObservableObject, Identifiable { } } + func resumeGitObservationAfterActivation() async { + await workspaceFeature.resumeObservationAfterActivation() + } + func closeProject() { guard workspaceURL != nil else { return } guard documentFeature.beginProjectClose() else { diff --git a/Sources/Lithe/Models/GitModels.swift b/Sources/Lithe/Models/GitModels.swift index 976483af..fa743585 100644 --- a/Sources/Lithe/Models/GitModels.swift +++ b/Sources/Lithe/Models/GitModels.swift @@ -1,5 +1,11 @@ import Foundation +struct GitWatchContext: Equatable, Sendable { + let repositoryRoot: URL + let gitDirectory: URL + let gitCommonDirectory: URL +} + struct GitSnapshot: Sendable { let repositoryRoot: URL let branch: String diff --git a/Sources/Lithe/Models/ProjectSessionManager.swift b/Sources/Lithe/Models/ProjectSessionManager.swift index 8bc02678..ea0aa124 100644 --- a/Sources/Lithe/Models/ProjectSessionManager.swift +++ b/Sources/Lithe/Models/ProjectSessionManager.swift @@ -153,6 +153,12 @@ final class ProjectSessionManager: ObservableObject { } } + func resumeGitObservationAfterActivation() async { + for model in openProjects { + await model.resumeGitObservationAfterActivation() + } + } + private func openInThisWindow(_ url: URL) { let model: AppModel if activeModel.workspaceURL == nil { diff --git a/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift b/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift index e000a2f4..14cd08c7 100644 --- a/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift +++ b/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift @@ -2,44 +2,62 @@ import CoreServices import Foundation final class MacDirectoryWatcher: DirectoryChangeSource, @unchecked Sendable { - private let root: URL + private final class CallbackContext { + weak var watcher: MacDirectoryWatcher? + + init(watcher: MacDirectoryWatcher) { + self.watcher = watcher + } + } + + private let configuration: DirectoryWatchConfiguration private let visibilityRules: FileVisibilityRules private let queue = DispatchQueue(label: "app.lithe.file-events", qos: .utility) - private let onChange: @Sendable ([String]) -> Void + private let onChange: @Sendable (DirectoryChangeBatch) -> Void private var stream: FSEventStreamRef? init( - root: URL, + configuration: DirectoryWatchConfiguration, visibilityRules: FileVisibilityRules = .default, - onChange: @escaping @Sendable ([String]) -> Void + onChange: @escaping @Sendable (DirectoryChangeBatch) -> Void ) { - self.root = root + self.configuration = configuration self.visibilityRules = visibilityRules self.onChange = onChange } func start() { stop() + let callbackContext = CallbackContext(watcher: self) var context = FSEventStreamContext( version: 0, - info: Unmanaged.passUnretained(self).toOpaque(), - retain: nil, - release: nil, + info: Unmanaged.passUnretained(callbackContext).toOpaque(), + retain: { info in + guard let info else { return nil } + _ = Unmanaged.fromOpaque(info).retain() + return info + }, + release: { info in + guard let info else { return } + Unmanaged.fromOpaque(info).release() + }, copyDescription: nil ) - let callback: FSEventStreamCallback = { _, info, eventCount, eventPaths, _, _ in + let callback: FSEventStreamCallback = { _, info, eventCount, eventPaths, eventFlags, _ in guard let info, eventCount > 0 else { return } - let watcher = Unmanaged.fromOpaque(info).takeUnretainedValue() + let callbackContext = Unmanaged.fromOpaque(info).takeUnretainedValue() + guard let watcher = callbackContext.watcher else { return } let paths = unsafeBitCast(eventPaths, to: NSArray.self) as? [String] ?? [] - let visiblePaths = paths.filter { path in - !watcher.visibilityRules.isHiddenPath( - URL(fileURLWithPath: path), - relativeTo: watcher.root - ) - } - guard !visiblePaths.isEmpty else { return } - watcher.onChange(visiblePaths) + let count = min(Int(eventCount), paths.count) + guard count > 0 else { return } + let flags = Array(UnsafeBufferPointer(start: eventFlags, count: count)) + let batch = watcher.classify( + paths: Array(paths.prefix(count)), + eventFlags: flags + ) + guard !batch.isEmpty else { return } + watcher.onChange(batch) } let flags = UInt32( @@ -51,7 +69,7 @@ final class MacDirectoryWatcher: DirectoryChangeSource, @unchecked Sendable { kCFAllocatorDefault, callback, &context, - [root.path] as CFArray, + configuration.physicalRoots.map(\.path) as CFArray, FSEventStreamEventId(kFSEventStreamEventIdSinceNow), 0.25, flags @@ -69,6 +87,69 @@ final class MacDirectoryWatcher: DirectoryChangeSource, @unchecked Sendable { self.stream = nil } + func classify( + paths: [String], + eventFlags: [FSEventStreamEventFlags] + ) -> DirectoryChangeBatch { + var batch = DirectoryChangeBatch() + let recoveryMask = FSEventStreamEventFlags( + kFSEventStreamEventFlagMustScanSubDirs | + kFSEventStreamEventFlagEventIdsWrapped + ) + let rootsChangedMask = FSEventStreamEventFlags(kFSEventStreamEventFlagRootChanged) + let rootMutationMask = FSEventStreamEventFlags( + kFSEventStreamEventFlagItemRemoved | + kFSEventStreamEventFlagItemRenamed + ) + + + for flags in eventFlags { + if flags & recoveryMask != 0 { + batch.requiresFullRescan = true + batch.gitStateMayHaveChanged = true + } + if flags & rootsChangedMask != 0 { + batch.requiresFullRescan = true + batch.watchRootsChanged = true + batch.gitStateMayHaveChanged = true + } + } + for (path, flags) in zip(paths, eventFlags) { + let url = URL(fileURLWithPath: path).standardizedFileURL + if flags & rootMutationMask != 0, configuration.isLogicalRoot(url) { + batch.requiresFullRescan = true + batch.watchRootsChanged = true + batch.gitStateMayHaveChanged = true + } + } + + + guard !batch.requiresFullRescan else { return batch } + + var workspacePaths = Set() + for path in paths { + let url = URL(fileURLWithPath: path).standardizedFileURL + if configuration.isGitContextPointer(url) { + batch.gitStateMayHaveChanged = true + batch.watchRootsChanged = true + continue + } + if configuration.containsGitMetadataPath(url) { + batch.gitStateMayHaveChanged = true + continue + } + if configuration.containsRepositoryPath(url) { + batch.gitStateMayHaveChanged = true + } + if configuration.containsWorkspacePath(url), + !visibilityRules.isHiddenPath(url, relativeTo: configuration.workspaceRoot) { + workspacePaths.insert(url.path) + } + } + batch.workspacePaths = workspacePaths.sorted() + return batch + } + deinit { stop() } diff --git a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 20faf858..983c9246 100644 --- a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -2,12 +2,12 @@ import Foundation private struct MacDirectoryWatcherFactory: DirectoryWatcherFactory { func make( - root: URL, + configuration: DirectoryWatchConfiguration, visibilityRules: FileVisibilityRules, - onChange: @escaping @Sendable ([String]) -> Void + onChange: @escaping @Sendable (DirectoryChangeBatch) -> Void ) -> any DirectoryChangeSource { MacDirectoryWatcher( - root: root, + configuration: configuration, visibilityRules: visibilityRules, onChange: onChange ) diff --git a/Sources/Lithe/Services/GitService.swift b/Sources/Lithe/Services/GitService.swift index 3a756dac..166d778b 100644 --- a/Sources/Lithe/Services/GitService.swift +++ b/Sources/Lithe/Services/GitService.swift @@ -2,6 +2,8 @@ import Foundation protocol GitOperations: Sendable { func snapshot(at rootURL: URL) -> GitSnapshot? + func watchContext(at rootURL: URL) -> GitWatchContext? + func diffDocument( at rootURL: URL, @@ -94,9 +96,13 @@ protocol GitOperations: Sendable { func stageAll(at rootURL: URL) -> ProcessResult? } +protocol GitWatchContextProviding: Sendable { + func watchContext(for workspace: URL) async -> GitWatchContext? +} + /// UI-facing Git service. Git command construction, validation, parsing, and /// process execution live behind the shared Rust operations port. -struct GitService: Sendable { +struct GitService: GitWatchContextProviding, Sendable { private let operations: any GitOperations init(operations: any GitOperations) { @@ -125,6 +131,11 @@ struct GitService: Sendable { await read(priority: .utility) { $0.snapshot(at: workspace) } } + func watchContext(for workspace: URL) async -> GitWatchContext? { + await read(priority: .utility) { $0.watchContext(at: workspace) } + } + + func diff(for change: GitChange) async -> [DiffRow] { (await diffDocument(for: change)).rows } diff --git a/docs/architecture/git-status-observation.md b/docs/architecture/git-status-observation.md new file mode 100644 index 00000000..312c47dc --- /dev/null +++ b/docs/architecture/git-status-observation.md @@ -0,0 +1,683 @@ +# 单仓库 Git 状态监听与文件系统同步 + +本文记录 [Issue #22](https://github.com/1lck/Lithe-IDEA/issues/22) 暴露的 Git 状态同步问题、已验证的失效场景、架构方案、实现边界和验收要求。 + +**状态**:设计方案,尚未实现。 + +**目标读者**:实现 Rust Core Git 契约、macOS FSEvents adapter、`WorkspaceFeatureModel` 和 `GitFeatureModel` 的开发者。 + +--- + +## 1. 问题定义 + +Lithe 的 Changes、当前分支、Git Log、stash 和进行中的 merge/rebase 等状态来自单个本地 Git 仓库。只要本地文件系统中的一次变化会改变这些状态,Lithe 就必须最终重新读取仓库状态并使界面与 Git 的真实结果一致。 + +当前实现只监听用户打开的工作区目录,并在上报事件前应用项目文件隐藏规则。这会漏掉两类变化: + +1. 事件位于工作区内,但路径被隐藏规则过滤,例如普通仓库的 `.git/**`; +2. 事件发生在工作区外,例如 linked worktree、直接打开的 submodule,或者用户只打开了 repository root 的一个子目录。 + +此外,当前实现没有处理 FSEvents 丢事件、监听根变化、初始快照与 watcher 启动之间的竞态,以及 Git 刷新期间再次到达的事件。 + +本设计要建立以下核心不变量: + +> 对单个本地 Git 仓库,所有由 `workspaceRoot`、`repositoryRoot`、`gitDirectory`、`gitCommonDirectory` 及 FSEvents 恢复信号驱动的状态变化,都必须最终收敛到至少一次读取最终状态的 `refreshGit()`。 + +这里的“最终收敛”意味着不能因为事件过滤、监听范围、事件丢失、刷新并发或 watcher 生命周期而永久保留旧状态。短时间内的事件可以防抖合并,但最后一次变化不能被丢弃。 + +--- + +## 2. 当前实现 + +### 2.1 工作区 watcher + +`MacDirectoryWatcher` 使用启用了 file events 和 watch root 的 FSEventStream 监听一个工作区根目录。回调先通过 `FileVisibilityRules.isHiddenPath` 过滤路径,再把剩余路径上报给 `WorkspaceFeatureModel`。 + +`FileVisibilityRules.builtInHiddenDirectories` 包含 `.git`,因此普通仓库中的 Git 元数据事件在进入应用层前已经被丢弃。相同过滤还会影响 `build`、`dist` 等隐藏目录;如果这些目录包含被 Git 跟踪的文件,其内容变化仍可能改变 Changes,但当前 watcher 不会上报。 + +### 2.2 工作区刷新与 Git 刷新耦合 + +`WorkspaceFeatureModel` 收到可见文件路径后进行防抖,处理编辑器外部变化、项目树、项目服务和本地历史,最后调用 Git 刷新。没有可见文件路径时,这条链路不会启动。 + +因此,metadata-only Git 操作虽然改变了 Git 状态,但不会触发 `refreshGit()`: + +- `git add`、unstage、reset; +- commit、amend; +- branch、tag、refs 和 packed refs 变化; +- fetch、push 后的本地 refs 变化; +- stash; +- merge、rebase、cherry-pick、revert 的状态标记; +- worktree 或 submodule 的 HEAD/index 变化。 + +某些操作同时修改普通文件,例如 checkout 或 stash apply,当前实现可能因为普通文件事件而碰巧刷新。但这不是可靠契约,最终 Git 元数据事件仍可能被过滤或位于监听范围外。 + +### 2.3 Git 状态范围大于工作区监听范围 + +Rust Core 的 Git status 流程会从用户打开的目录执行 `git rev-parse --show-toplevel`,随后在完整 `repositoryRoot` 上执行 porcelain status。因此 Changes 展示的是整个 repository root,而 watcher 只覆盖 `workspaceRoot`。 + +当用户打开仓库子目录时,repository root 其他位置的文件变化会影响 Changes,却不在当前 watcher 范围内。 + +### 2.4 FSEvents 恢复信号被忽略 + +当前 FSEvents 回调忽略 `eventFlags`,没有处理: + +- `MustScanSubDirs`; +- `UserDropped`; +- `KernelDropped`; +- `EventIdsWrapped`; +- `RootChanged`。 + +`MustScanSubDirs` 表示客户端不能再依赖收到的单个路径,必须递归重新扫描监听层级。`RootChanged` 表示监听根或其父路径被移动、删除或替换,需要重新解析路径并重建 watcher。 + +### 2.5 初始化和刷新竞态 + +当前工作区流程先读取快照和 Git 状态,再启动 watcher。FSEventStream 使用 `SinceNow`,不会补发 watcher 启动前发生的事件。外部变化如果发生在初始状态读取之后、watcher 启动之前,就会永久漏掉,直到其他事件或手动刷新触发下一次读取。 + +`GitFeatureModel.refreshGit()` 还会在已有刷新进行时直接返回。若最终事件在一次较早的刷新期间到达,第二次请求可能被丢弃,使界面停留在中间状态。 + +--- + +## 3. 已验证的失效场景 + +以下结论均通过真实 Git 仓库和当前 `MacDirectoryWatcher` 的 FSEvents 实验确认。实验使用 `/Volumes` 下的临时目录,避免 `/tmp` 与 `/private/tmp` 路径别名影响判断;所有临时目录和监听程序均已清理。 + +### 3.1 普通仓库的外部 commit + +外部 commit 产生了以下原始 FSEvents: + +```text +.git/index +.git/logs/HEAD +.git/logs/refs/heads/main +.git/refs/heads/main +``` + +当前 `MacDirectoryWatcher` 上报为空,因为 `.git` 被隐藏规则过滤。 + +结果:commit 前的 staged changes 可能继续显示,当前分支引用和 Git Log 也不会自动更新。 + +### 3.2 Linked worktree + +linked worktree 的 `.git` 是地址文件: + +```text +linked-worktree/.git + → main-repo/.git/worktrees/linked-worktree +``` + +一次外部 commit 后: + +- worktree 根目录原始 FSEvents:空; +- 当前 `MacDirectoryWatcher`:空; +- worktree `gitDirectory`:收到 `index`、`logs/HEAD`; +- `gitCommonDirectory`:收到 `refs/heads/`、`logs/refs/heads/`。 + +这证明 linked worktree 的独立 HEAD/index 与共享 refs 可能位于两个不同的外部目录,必须分别解析并监听。 + +### 3.3 直接打开 submodule + +submodule 的 `.git` 同样是地址文件: + +```text +parent-repo/modules/child/.git + → parent-repo/.git/modules/modules/child +``` + +外部 commit 前 submodule 状态为: + +```text +M tracked.txt +``` + +提交后变为 clean,但: + +- submodule 工作区原始 FSEvents:空; +- submodule 当前 `MacDirectoryWatcher`:空; +- 真实 git-dir 收到 `index`、`logs/HEAD`、`refs/heads/main`。 + +因此直接把 submodule 作为 Lithe 项目打开时,单纯修改工作区内 `.git/**` 的过滤方式不能解决问题,必须监听解析后的真实 Git 目录。 + +### 3.4 打开包含 submodule 的父仓库 + +同一次 submodule commit 在父仓库根目录产生了: + +```text +.git/modules/modules/child/index +.git/modules/modules/child/logs/HEAD +.git/modules/modules/child/logs/refs/heads/main +.git/modules/modules/child/refs/heads/main +``` + +父仓库原始 FSEvents 能收到这些路径,但当前 `MacDirectoryWatcher` 因 `.git` 隐藏规则而上报为空。 + +父仓库模式只需要同步父仓库看到的 submodule gitlink 状态;它不把 Changes 扩展成多个独立仓库的聚合视图。 + +### 3.5 打开 repository root 的子目录 + +仓库结构: + +```text +repo/ +├── .git/ +├── outside.txt +└── apps/opened/ ← Lithe workspaceRoot +``` + +从外部修改 `repo/outside.txt` 后: + +```text +git status: M outside.txt +workspaceRoot 原始 FSEvents: 空 +当前 MacDirectoryWatcher: 空 +repositoryRoot 原始 FSEvents: outside.txt +``` + +随后执行 `git add outside.txt`: + +```text +git status: M outside.txt +gitDirectory 原始 FSEvents: index, index.lock +当前 MacDirectoryWatcher: 仍为空 +``` + +这证明只监听 `workspaceRoot` 和 Git 目录仍不足够:未暂存的 repository root 外部文件变化不会写 Git 元数据。必须把 `repositoryRoot` 纳入监听范围。 + +--- + +## 4. 范围 + +### 4.1 本设计包含 + +本设计覆盖一个 Lithe 项目对应的一个本地 Git 仓库: + +- 普通仓库; +- linked worktree; +- 直接打开的 submodule; +- 打开包含 submodule 的父仓库所看到的 gitlink 状态; +- `workspaceRoot` 是 `repositoryRoot` 子目录; +- `git init --separate-git-dir` 等真实 Git 目录位于工作区外的布局; +- 项目打开后才执行 `git init`; +- 本地普通文件、index、HEAD、refs、stash 和 Git operation state 变化; +- FSEvents 丢事件和监听根变化后的恢复; +- 应用从后台重新获得焦点后的最终状态恢复。 + +### 4.2 本设计不包含 + +以下内容不属于本设计: + +- 多个独立 Git 仓库的 Changes 聚合和跨仓库操作; +- 父仓库 Changes 中展开 submodule 内部文件; +- 远端仓库发生变化但本地没有 fetch; +- 自动 fetch、网络轮询或远端通知; +- `~/.gitconfig`、全局 excludes 等任意工作区外 Git 配置的实时监听; +- 外部进程使用不同 `GIT_DIR`、`GIT_WORK_TREE` 或 `GIT_INDEX_FILE` 环境后形成的另一套 Git 视图; +- bare repository 的工作区 Changes; +- Windows 文件监听实现; +- 多仓库数据模型改造。 + +如果另一台机器向远端 push,而本地 refs 和文件系统没有变化,FSEvents 不可能感知;必须先发生本地 fetch 或引入独立的网络同步能力。 + +--- + +## 5. Git 目录拓扑 + +应用层持有用户打开的 `workspaceRoot`。Rust Core 负责解析 Git 自身的三个规范化绝对路径。 + +| 路径 | 含义 | 主要变化 | +| --- | --- | --- | +| `workspaceRoot` | 用户在 Lithe 中打开的目录 | 项目树、编辑器文件和可能影响 Git status 的普通文件 | +| `repositoryRoot` | `git rev-parse --show-toplevel` | 完整 worktree;可能包含 workspace 外但会出现在 Changes 中的文件 | +| `gitDirectory` | 当前 worktree/submodule 的 Git 目录 | HEAD、index、rebase/merge 等当前 worktree 状态 | +| `gitCommonDirectory` | 多 worktree 共享的 Git 目录 | refs、packed refs、对象和共享日志 | + +### 5.1 普通仓库 + +```text +workspaceRoot == repositoryRoot +gitDirectory == gitCommonDirectory == repositoryRoot/.git +``` + +### 5.2 Linked worktree + +```text +workspaceRoot == repositoryRoot +gitDirectory != gitCommonDirectory +gitDirectory 和 gitCommonDirectory 可能都位于 workspaceRoot 外 +``` + +### 5.3 直接打开 submodule + +```text +workspaceRoot == repositoryRoot +gitDirectory == gitCommonDirectory +gitDirectory 位于父仓库的 .git/modules/** 中 +``` + +### 5.4 打开 repository root 子目录 + +```text +workspaceRoot != repositoryRoot +workspaceRoot 位于 repositoryRoot 内 +gitDirectory 通常是 repositoryRoot/.git +``` + +### 5.5 Separate git-dir + +```text +workspaceRoot == repositoryRoot +gitDirectory == gitCommonDirectory +gitDirectory 位于 repositoryRoot 外 +``` + +实现不得通过拼接 `repositoryRoot/.git` 推断 Git 目录。worktree、submodule 和 separate git-dir 都会使该假设失效。 + +--- + +## 6. Rust Core 契约 + +### 6.1 GitWatchContext + +Rust Core 应提供只负责路径解析的机器可读结果: + +```text +GitWatchContext +├── repositoryRoot +├── gitDirectory +└── gitCommonDirectory +``` + +建议字段语义: + +- 使用绝对路径; +- 对存在的路径进行规范化; +- 无 Git 仓库时返回空 context,而不是把工作区当作仓库; +- 不把 `.git` 是文件还是目录的判断交给 Swift; +- 不依赖 Git 的自然语言输出; +- 支持 worktree、submodule 和 separate git-dir。 + +路径应通过 Git 的机器可读命令解析,例如: + +```text +git rev-parse --show-toplevel +git rev-parse --absolute-git-dir +git rev-parse --path-format=absolute --git-common-dir +``` + +Swift/macOS adapter 不应直接解析 `.git` 地址文件,也不应自行构造 Git 命令。这符合现有边界:Git 路径语义属于 Rust Core,FSEvents 属于 macOS adapter。 + +### 6.2 Context 生命周期 + +Git watch context 不是只读一次的永久值。以下情况必须重新解析: + +- 首次打开工作区; +- 工作区中 `.git` 被创建、删除或替换; +- FSEvents 报告 `RootChanged`; +- worktree repair; +- submodule init、deinit、update 或 absorbgitdirs; +- 应用重新获得焦点; +- Git 刷新发现 repository root 与当前 context 不一致。 + +解析失败时保留工作区 watcher,并把 Git 状态显示为无仓库;后续 `.git` 变化或前台激活必须允许重试。 + +--- + +## 7. macOS 多根目录监听 + +### 7.1 逻辑监听根 + +macOS adapter 接收以下逻辑根: + +```text +workspaceRoot +repositoryRoot +GitDirectory +gitCommonDirectory +``` + +所有路径先规范化并去重。物理 FSEventStream 可以一次监听多个根,也可以在某个祖先根已经覆盖子目录时省略重复的物理根;即使物理监听路径被合并,仍必须保留每个逻辑根的角色,用于事件分类。 + +示例:普通仓库中 `repositoryRoot` 已包含 `repositoryRoot/.git`,物理上监听 repository root 即可,但 `.git/**` 事件必须在应用隐藏规则之前识别为 Git 事件。 + +### 7.2 路径角色 + +事件按逻辑范围处理: + +| 事件位置 | 工作区处理 | Git 处理 | +| --- | --- | --- | +| `workspaceRoot` 内可见普通路径 | 进入现有编辑器、项目树、历史和项目服务流程 | 请求 Git 刷新 | +| `workspaceRoot` 内隐藏普通路径 | 不进入项目树和编辑器流程 | 仍请求 Git 刷新,因为隐藏路径可能被 Git 跟踪 | +| `repositoryRoot` 内、`workspaceRoot` 外 | 不进入工作区流程 | 请求 Git 刷新 | +| `gitDirectory` 内 | 不进入工作区流程 | 请求 Git 刷新 | +| `gitCommonDirectory` 内 | 不进入工作区流程 | 请求 Git 刷新 | +| Git context 指针或监听根变化 | 不直接作为普通文件处理 | 重新解析 context、重建 watcher 并刷新 | + +Git 相关分类必须发生在 `FileVisibilityRules` 过滤之前。`.git` 对项目树保持隐藏,不等于 `.git` 对 Git 状态监听不可见。 + +### 7.3 Git 目录过滤策略 + +第一版以正确性优先:`gitDirectory` 或 `gitCommonDirectory` 下的任意事件都标记 Git 状态可能变化,再通过防抖合并刷新。 + +不建议第一版只允许 `index`、`HEAD` 和 `refs/**`,因为还存在: + +- `packed-refs`; +- reflog; +- stash refs; +- split index 的 `sharedindex.*`; +- merge/rebase/sequencer 状态; +- Git 后续版本的 ref 存储变化,例如 reftable; +- submodule 的 `.git/modules/**`。 + +objects 和 lock 文件会增加事件数量,但不会破坏正确性;防抖应把一次 Git 操作产生的 burst 合并为一次最终刷新。只有在真实性能数据证明必要后,才能收紧过滤,并且收紧后仍须保留所有会影响 Git 状态的最终事件。 + +--- + +## 8. 结构化事件 + +目录 watcher 不应再只返回 `[String]`。建议定义平台无关的批次: + +```text +DirectoryChangeBatch +├── workspacePaths +├── gitStateMayHaveChanged +├── requiresFullRescan +└── watchRootsChanged +``` + +字段语义: + +- `workspacePaths`:可进入编辑器、项目树、本地历史和项目服务流程的普通路径; +- `gitStateMayHaveChanged`:至少需要一次 Git 最终状态刷新; +- `requiresFullRescan`:不能信任单个路径列表,必须重建工作区快照并刷新 Git; +- `watchRootsChanged`:当前监听根可能失效,必须重新解析 Git context 并重建 watcher。 + +macOS 类型、CoreServices 标志和具体 watcher 不得泄漏到 Application、Services 或 Views。 + +--- + +## 9. 事件路由与刷新协调 + +### 9.1 普通工作区事件 + +`workspacePaths` 继续使用现有流程: + +```text +外部普通文件变化 +→ 编辑器外部修改检测 +→ 必要时刷新项目树 +→ 必要时重载 Java/Maven 项目服务 +→ 本地历史 +→ Git 状态刷新 +``` + +本设计不能改变普通文件自动重载、未保存冲突处理或项目树刷新行为。 + +### 9.2 Git-only 事件 + +只有 `gitStateMayHaveChanged` 时: + +```text +Git 元数据或 repositoryRoot 外部文件变化 +→ Git 独立防抖 +→ refreshGit() +``` + +禁止进入: + +- 项目树重建; +- 编辑器外部文件冲突检测; +- 本地历史; +- Java/Maven 服务重载; +- 普通文件扫描。 + +### 9.3 防抖与最终刷新 + +Git 刷新需要独立于工作区刷新的协调状态,至少包括: + +```text +pendingGitRefresh +gitRefreshTask +gitRefreshGeneration 或等价状态 +isGitRefreshRunning +``` + +期望语义: + +1. burst 中的多次事件合并; +2. 防抖时间建议与现有工作区刷新接近,约 300–350ms; +3. 刷新运行期间到达的新事件只标记 pending,不启动并发读取; +4. 当前刷新结束后,如果 pending 再次为 true,必须继续刷新; +5. 直到一次刷新期间没有新事件,任务才结束。 + +不能依赖 `isRefreshingGit` 直接返回来处理事件并发,因为直接返回会丢失最终状态请求。 + +### 9.4 Git operation freeze + +Lithe 内部 Git 写操作继续使用现有 freeze depth: + +- freeze 期间不读取 index/worktree 中间状态; +- 普通文件路径继续累计; +- Git 事件只标记 pending; +- 最外层 freeze 结束后合并为一次最终 Git 刷新; +- 与 GitFeatureModel 已有的显式刷新去重,但不得因此丢掉最终刷新。 + +外部终端或其他应用执行 Git 操作时不会进入 Lithe freeze,依靠事件防抖等待操作 burst 稳定。若长操作中途产生超过防抖间隔的停顿,可以读到中间状态;后续事件仍必须触发最终刷新。 + +--- + +## 10. FSEvents 恢复 + +### 10.1 MustScanSubDirs + +收到 `MustScanSubDirs` 时,单个事件路径不再可信。应用必须: + +1. 标记 `requiresFullRescan`; +2. 重建工作区快照; +3. 强制刷新 Git; +4. 不把当前批次路径当成完整变化集合。 + +`UserDropped` 和 `KernelDropped` 用于诊断事件在用户态还是内核态丢失;正确性处理统一由 `MustScanSubDirs` 驱动。 + +### 10.2 EventIdsWrapped + +虽然当前 stream 使用 `SinceNow`,收到 `EventIdsWrapped` 仍应按无法信任历史事件处理,执行完整扫描和 Git 刷新。 + +### 10.3 RootChanged + +收到 `RootChanged` 时: + +1. 标记 `watchRootsChanged`; +2. 重新解析 `GitWatchContext`; +3. 停止旧 stream; +4. 使用新的规范化根集合建立 stream; +5. 执行工作区和 Git 最终刷新。 + +不得只对旧路径调用 `refreshGit()`,因为旧 watcher 可能已经监听不存在的位置。 + +### 10.4 应用重新获得焦点 + +应用前台激活是最终兜底,不是实时监听的替代品。每个已打开项目应: + +1. 重新解析 Git context; +2. context 变化时重建 watcher; +3. 请求一次 Git 刷新。 + +该路径覆盖应用挂起期间的变化、外接卷短暂断连、未观察到的 Git 目录迁移和其他不可恢复的事件窗口。 + +--- + +## 11. 初始化顺序 + +FSEventStream 使用 `SinceNow` 时,必须遵循“先订阅,再读取最终状态”。建议工作区启动顺序: + +```text +确认 workspaceRoot +→ 启动 workspace-only watcher +→ Rust Core 解析 GitWatchContext +→ 扩展并重建为完整监听根集合 +→ 读取工作区快照 +→ refreshGit() +``` + +如果实现结构不适合在快照前启动完整 watcher,最低要求是: + +```text +解析 context +→ 启动 watcher +→ 无条件执行一次最终 refreshGit() +``` + +首次没有 Git 仓库时仍保留 workspace watcher,以便观察 `.git` 创建。`.git` 创建事件不得因隐藏规则而消失;它需要触发 context 重新解析和 watcher 重建。 + +--- + +## 12. 单仓库边界 + +本设计中的“单仓库”指一个 `GitFeatureModel` 对应一个 `GitWatchContext`。 + +### 12.1 Submodule + +- 直接打开 submodule:submodule 自身是当前单仓库,解析其真实 git-dir; +- 打开父仓库:父仓库仍是当前单仓库,只同步父仓库看到的 gitlink 状态; +- 不在父仓库 Changes 中聚合 submodule 内部的 staged/unstaged 文件。 + +### 12.2 嵌套独立仓库 + +repository root 下存在其他独立 `.git` 目录时,本设计不发现或聚合这些仓库。多仓库支持需要把单个 `gitRepositoryRoot` 和单个 Changes 状态改造成仓库集合,是独立架构工作。 + +监听父 repository root 可能接收到嵌套仓库事件;这些事件最多触发当前仓库一次无害刷新,不得据此创建第二套 Git 状态。 + +--- + +## 13. 预期代码边界 + +实现时预计涉及以下职责,不要求文件名完全固定,但不得破坏现有分层: + +### Rust Core + +- 新增 Git watch context 请求、响应和路径解析; +- 使用机器可读 Git 命令; +- 为普通仓库、worktree、submodule 和 separate git-dir 增加真实仓库测试; +- 更新共享 JSON 契约和 Swift bridge payload。 + +### Core Ports / Application + +- 定义平台无关的结构化目录事件; +- 让 watcher factory 接收逻辑监听根或 watch configuration; +- 在 `WorkspaceFeatureModel` 中路由普通工作区事件、Git-only 事件和恢复事件; +- 与 Git operation freeze 协调; +- 保证刷新期间到达的事件不会丢失。 + +### macOS Adapter + +- 使用 FSEvents 监听去重后的多根目录; +- 在隐藏过滤前识别 Git 和 repository root 事件; +- 解释 FSEvents flags; +- 上报结构化事件,不在 adapter 内直接刷新 UI 或执行 Git。 + +### App lifecycle + +- 应用重新获得焦点时重新解析 context 并请求 Git 刷新; +- 多个已打开项目分别刷新自己的单仓库状态。 + +### Views + +Views 不参与路径解析、文件监听、防抖或恢复。Changes、状态栏和 Git Log 继续只消费 `AppModel`/`GitFeatureModel` 暴露的状态。 + +--- + +## 14. 验收矩阵 + +| 场景 | 操作 | 预期结果 | 不应发生 | +| --- | --- | --- | --- | +| 普通仓库 | 外部修改普通文件 | Changes 自动出现 unstaged change | 丢失事件 | +| 普通仓库 | `git add` / unstage / reset | staged/unstaged 分组自动更新 | 项目树重建 | +| 普通仓库 | commit / amend | Changes 清空或更新,分支和 Git Log 更新 | 手动刷新 | +| 普通仓库 | fetch / push | 本地 refs 相关 UI 更新 | 工作区重扫 | +| 隐藏但被跟踪的目录 | 修改 tracked 文件 | Changes 自动更新 | 文件出现在项目树中 | +| Linked worktree | add / commit / refs 更新 | 当前 worktree Changes、分支和 Log 更新 | 依赖焦点切换 | +| 直接打开 submodule | add / commit | submodule 自身 Changes 更新 | 依赖父仓库 watcher | +| 父仓库含 submodule | submodule HEAD 前进 | 父仓库 gitlink 状态刷新 | 聚合 submodule 内部 Changes | +| 打开 repository 子目录 | 修改子目录外 tracked 文件 | Changes 自动更新 | 把该文件加入项目树 | +| Separate git-dir | index / refs 更新 | Changes 和分支更新 | 假设 `repositoryRoot/.git` 存在 | +| 项目打开后 `git init` | 创建仓库 | 从 No Git 自动切换为仓库状态 | 重开项目 | +| FSEvents 丢事件 | `MustScanSubDirs` | 完整扫描并恢复最终状态 | 信任不完整路径列表 | +| 监听根迁移 | `RootChanged` | 重新解析 context、重建 watcher、刷新 | 继续监听旧路径 | +| 刷新期间再次变化 | 连续两次外部操作 | 最终状态与第二次操作一致 | 第二次请求被丢弃 | +| Lithe 内部 Git 操作 | stage / commit / checkout | freeze 后一次最终刷新 | 展示持久中间状态 | +| 应用后台期间变化 | 返回前台 | context 和 Git 状态恢复 | 永久陈旧 | + +--- + +## 15. 测试与验证 + +### 15.1 Rust Core 测试 + +至少覆盖: + +- 普通仓库三个路径; +- linked worktree 中不同的 git-dir/common-dir; +- submodule 地址文件; +- separate git-dir; +- 从 repository 子目录解析根; +- 非 Git 目录返回空 context; +- 路径为绝对、规范化结果。 + +测试必须创建真实临时 Git 仓库并执行真实 Git 命令,不以手写 `.git` 目录结构代替 Git 行为。 + +### 15.2 Swift 单元测试 + +至少覆盖: + +- 多根目录规范化和去重; +- workspace、repository、git-dir、common-dir 的事件分类; +- 隐藏路径只触发 Git 刷新; +- Git-only 事件不进入项目树、编辑器和项目服务; +- burst 防抖只产生一次刷新; +- 刷新期间再次到达事件后会补刷新; +- freeze 嵌套和最外层 flush; +- full rescan 与 watch roots changed 路由; +- context 变化后替换 watcher。 + +### 15.3 macOS 真实场景验证 + +真实 FSEvents 验证必须至少重跑本文件第 3 节的四组实验,并在运行中的 Lithe 中观察: + +- Changes 分组; +- 当前分支; +- Git Log; +- merge/rebase operation state; +- 项目树没有因 Git-only 事件闪烁; +- Java/Maven 服务没有因 Git-only 事件重启; +- 编辑器没有收到 `.git` 路径的外部冲突提示。 + +### 15.4 仓库验证命令 + +提交前运行: + +```bash +swift test --disable-sandbox +./scripts/verify-core.sh +./scripts/verify-git-graph.sh +./scripts/verify-service-boundaries.sh +./scripts/verify-shared-contracts.sh +./scripts/verify-windows-boundaries.sh +./scripts/verify-rust-core.sh +``` + +如果修改 Rust Core,再运行对应 Cargo 测试,并确保格式检查通过。 + +--- + +## 16. 完成标准 + +本设计只有同时满足以下条件才算完成: + +1. 普通仓库、linked worktree、submodule、repository 子目录和 separate git-dir 都通过验收; +2. Git-only 事件不会触发项目树、编辑器、本地历史或 Java/Maven 刷新; +3. 普通文件外部变化行为没有回归; +4. FSEvents 丢事件和 RootChanged 有明确恢复路径; +5. 初始化期间没有“刷新完成后、watcher 启动前”的永久漏事件窗口; +6. Git 刷新期间的新事件不会被丢弃; +7. 应用重新获得焦点时可以重新解析路径并恢复最终状态; +8. 未引入多仓库聚合、远端轮询或全局 Git 配置监听; +9. 自动测试、真实 macOS 场景和仓库边界验证全部通过。 diff --git a/rust/lithe-core/src/command.rs b/rust/lithe-core/src/command.rs index 34f980b1..5ce65fb1 100644 --- a/rust/lithe-core/src/command.rs +++ b/rust/lithe-core/src/command.rs @@ -41,6 +41,7 @@ pub enum CoreCommand { JavaServerPort, JavaStructure, GitStatus, + GitWatchContext, GitCommand, GitWrite, GitDiff, @@ -85,6 +86,7 @@ impl CoreCommand { "java.serverPort" => Some(Self::JavaServerPort), "java.structure" => Some(Self::JavaStructure), "git.status" => Some(Self::GitStatus), + "git.watchContext" => Some(Self::GitWatchContext), "git.command" => Some(Self::GitCommand), "git.write" => Some(Self::GitWrite), "git.diff" => Some(Self::GitDiff), diff --git a/rust/lithe-core/src/git.rs b/rust/lithe-core/src/git.rs index bd7e94f8..cce4744f 100644 --- a/rust/lithe-core/src/git.rs +++ b/rust/lithe-core/src/git.rs @@ -5,7 +5,7 @@ use crate::model::{ GitDiffHunkResponse, GitDiffResponse, GitDiffRowResponse, GitFileResponse, GitFilesResponse, GitHistoryResponse, GitIntegrationPreflightResponse, GitOperationStateResponse, GitPullPreflightResponse, GitReferenceResponse, GitStashResponse, GitStashesResponse, - GitStatusResponse, + GitStatusResponse, GitWatchContextResponse, }; use serde::{Deserialize, Serialize}; use std::io::Read; @@ -21,6 +21,12 @@ pub struct GitStatusRequest { pub root: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitWatchContextRequest { + pub root: String, +} + /// Executes one Git operation without invoking a shell. /// /// The command boundary is intentionally argument-based. This keeps command @@ -2332,6 +2338,57 @@ fn parse_diff(patch: &str) -> (Vec, Vec (rows, hunks) } +pub fn watch_context( + request: GitWatchContextRequest, +) -> Result, CoreError> { + let root = PathBuf::from(&request.root) + .canonicalize() + .map_err(|_| CoreError::new(ErrorCode::WorkspaceNotFound, "Workspace does not exist"))?; + if !root.is_dir() { + return Err(CoreError::new( + ErrorCode::WorkspaceNotFound, + "Workspace does not exist", + )); + } + + let repository_root = run_git(&root, &["rev-parse", "--show-toplevel"])?; + if !repository_root.status.success() { + return Ok(None); + } + let git_directory = run_git(&root, &["rev-parse", "--absolute-git-dir"])?; + let git_common_directory = run_git( + &root, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + )?; + + Ok(Some(GitWatchContextResponse { + repository_root: canonical_git_output(repository_root, "repository root")?, + git_directory: canonical_git_output(git_directory, "Git directory")?, + git_common_directory: canonical_git_output(git_common_directory, "Git common directory")?, + })) +} + +fn canonical_git_output(output: std::process::Output, label: &str) -> Result { + if !output.status.success() { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not resolve {label}"), + ) + .with_details(String::from_utf8_lossy(&output.stderr))); + } + let raw_path = String::from_utf8_lossy(&output.stdout); + let path = PathBuf::from(raw_path.trim()); + path.canonicalize() + .map(|path| path.to_string_lossy().into_owned()) + .map_err(|error| { + CoreError::new( + ErrorCode::ProcessFailed, + format!("Could not resolve {label}"), + ) + .with_details(error.to_string()) + }) +} + pub fn status(request: GitStatusRequest) -> Result { let root = PathBuf::from(&request.root) .canonicalize() diff --git a/rust/lithe-core/src/model.rs b/rust/lithe-core/src/model.rs index 39b567f6..d8c9f6de 100644 --- a/rust/lithe-core/src/model.rs +++ b/rust/lithe-core/src/model.rs @@ -292,6 +292,14 @@ pub struct GitStatusResponse { pub changes: Vec, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitWatchContextResponse { + pub repository_root: String, + pub git_directory: String, + pub git_common_directory: String, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct GitReferenceResponse { diff --git a/rust/lithe-core/src/runtime.rs b/rust/lithe-core/src/runtime.rs index 3fce8a85..effaca46 100644 --- a/rust/lithe-core/src/runtime.rs +++ b/rust/lithe-core/src/runtime.rs @@ -4,7 +4,8 @@ use crate::git::{ self, GitApplyRequest, GitBlameRequest, GitCheckoutPreflightRequest, GitCommandRequest, GitCommitFilesRequest, GitCommitRequest, GitComparisonRequest, GitConflictMarkerRequest, GitDiffRequest, GitHistoryRequest, GitIntegrationPreflightRequest, GitOperationStateRequest, - GitPullPreflightRequest, GitStashesRequest, GitStatusRequest, GitWriteRequest, + GitPullPreflightRequest, GitStashesRequest, GitStatusRequest, GitWatchContextRequest, + GitWriteRequest, }; use crate::history::{ HistoryContentRequest, HistoryEntriesRequest, HistoryRecordRequest, HistoryRelocateRequest, @@ -422,6 +423,25 @@ fn execute(request: &str) -> CoreResponse { ), Err(error) => CoreResponse::failure(id, error), }, + CoreCommand::GitWatchContext => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git watch context request", + ) + .with_details(error.to_string()) + }) + .and_then(git::watch_context) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git watch context should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::GitCommand => { match serde_json::from_value::(parsed.payload) .map_err(|error| { From 2bb886c3c5dc39c893603e383daaff201c81cea8 Mon Sep 17 00:00:00 2001 From: yager-42 <331382125@qq.com> Date: Tue, 11 Aug 2026 12:15:27 +0800 Subject: [PATCH 2/4] test: cover git watch context and observation routing Add real-repository observation tests covering linked worktrees, submodules, separate git directories, git init discovery, and git-only event routing. Unit tests cover watch-root deduplication, event classification, recovery state machines, and refresh coalescing. Rust integration tests exercise the git.watchContext command contract across ordinary, separate-dir, worktree, and submodule repositories. --- .../GitStatusObservationTests.swift | 484 ++++++++++++++++++ Tests/LitheTests/LitheCoreLogicTests.swift | 264 +++++++++- rust/lithe-core/tests/git_watch_context.rs | 237 +++++++++ 3 files changed, 980 insertions(+), 5 deletions(-) create mode 100644 Tests/LitheTests/GitStatusObservationTests.swift create mode 100644 rust/lithe-core/tests/git_watch_context.rs diff --git a/Tests/LitheTests/GitStatusObservationTests.swift b/Tests/LitheTests/GitStatusObservationTests.swift new file mode 100644 index 00000000..93cf7bd5 --- /dev/null +++ b/Tests/LitheTests/GitStatusObservationTests.swift @@ -0,0 +1,484 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Git status observation", .serialized) +struct GitStatusObservationTests { + @Test + @MainActor + func visibleWorkspaceEditStillUsesTheWorkspacePipelineAndRefreshesGit() async throws { + let fixture = try GitObservationFixture(label: "visible-edit") + let repository = fixture.url.appendingPathComponent("repository", isDirectory: true) + try fixture.initializeRepository(at: repository) + let recorder = GitObservationRecorder() + let model = makeObservationModel(recorder: recorder) + defer { model.reset() } + try await startObservation(model, at: repository, recorder: recorder) + + let tracked = repository.appendingPathComponent("tracked.txt") + try Data("changed\n".utf8).write(to: tracked) + + let refreshed = await waitUntil { recorder.gitRefreshCount == 1 } + #expect(refreshed) + #expect(recorder.externalChangeBatches.flatMap { $0 }.contains(tracked.standardizedFileURL)) + } + + @Test + @MainActor + func externalCommitRefreshesGitWithoutEnteringTheWorkspacePipeline() async throws { + let fixture = try GitObservationFixture(label: "ordinary-commit") + let repository = fixture.url.appendingPathComponent("repository", isDirectory: true) + try fixture.initializeRepository(at: repository) + try Data("staged\n".utf8).write(to: repository.appendingPathComponent("tracked.txt")) + try fixture.git(["add", "tracked.txt"], at: repository) + let recorder = GitObservationRecorder() + let model = makeObservationModel(recorder: recorder) + defer { model.reset() } + try await startObservation(model, at: repository, recorder: recorder) + + try fixture.git(["commit", "-q", "-m", "external commit"], at: repository) + + let refreshed = await waitUntil { recorder.gitRefreshCount == 1 } + #expect(refreshed, "A metadata-only commit must request a Git refresh") + try await Task.sleep(for: .milliseconds(750)) + #expect(recorder.gitRefreshCount == 1, "A commit event burst should be coalesced") + #expect(recorder.externalChangeBatches.isEmpty) + #expect(recorder.projectServiceReloadCount == 0) + } + + @Test + @MainActor + func trackedFileInsideHiddenDirectoryRefreshesOnlyGit() async throws { + let fixture = try GitObservationFixture(label: "hidden-tracked-file") + let repository = fixture.url.appendingPathComponent("repository", isDirectory: true) + try fixture.initializeRepository(at: repository) + let hiddenDirectory = repository.appendingPathComponent("dist", isDirectory: true) + try FileManager.default.createDirectory(at: hiddenDirectory, withIntermediateDirectories: true) + let hiddenFile = hiddenDirectory.appendingPathComponent("bundle.js") + try Data("initial\n".utf8).write(to: hiddenFile) + try fixture.git(["add", "dist/bundle.js"], at: repository) + try fixture.git(["commit", "-q", "-m", "track hidden output"], at: repository) + let recorder = GitObservationRecorder() + let model = makeObservationModel(recorder: recorder) + defer { model.reset() } + try await startObservation(model, at: repository, recorder: recorder) + + try Data("changed\n".utf8).write(to: hiddenFile) + + let refreshed = await waitUntil { recorder.gitRefreshCount == 1 } + #expect(refreshed, "Tracked hidden paths still affect Git status") + #expect(recorder.externalChangeBatches.isEmpty) + #expect(recorder.projectServiceReloadCount == 0) + } + + @Test + @MainActor + func linkedWorktreeStageRefreshesGitFromItsExternalGitDirectory() async throws { + let fixture = try GitObservationFixture(label: "linked-worktree") + let repository = fixture.url.appendingPathComponent("repository", isDirectory: true) + let worktree = fixture.url.appendingPathComponent("linked-worktree", isDirectory: true) + try fixture.initializeRepository(at: repository) + try fixture.git( + ["worktree", "add", "-q", "-b", "observation-worktree", worktree.path], + at: repository + ) + try Data("changed\n".utf8).write(to: worktree.appendingPathComponent("tracked.txt")) + let recorder = GitObservationRecorder() + let model = makeObservationModel(recorder: recorder) + defer { model.reset() } + try await startObservation(model, at: worktree, recorder: recorder) + + try fixture.git(["add", "tracked.txt"], at: worktree) + + let refreshed = await waitUntil { recorder.gitRefreshCount == 1 } + #expect(refreshed, "A linked worktree index lives outside the workspace root") + #expect(recorder.externalChangeBatches.isEmpty) + } + + @Test + @MainActor + func separateGitDirectoryStageRefreshesGit() async throws { + let fixture = try GitObservationFixture(label: "separate-git-dir") + let workspace = fixture.url.appendingPathComponent("workspace", isDirectory: true) + let gitDirectory = fixture.url.appendingPathComponent("metadata/repository.git", isDirectory: true) + try FileManager.default.createDirectory( + at: gitDirectory.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try fixture.git( + ["init", "-q", "--separate-git-dir=\(gitDirectory.path)", workspace.path], + at: fixture.url + ) + try fixture.configureRepository(at: workspace) + try Data("initial\n".utf8).write(to: workspace.appendingPathComponent("tracked.txt")) + try fixture.git(["add", "tracked.txt"], at: workspace) + try fixture.git(["commit", "-q", "-m", "initial"], at: workspace) + try Data("changed\n".utf8).write(to: workspace.appendingPathComponent("tracked.txt")) + let recorder = GitObservationRecorder() + let model = makeObservationModel(recorder: recorder) + defer { model.reset() } + try await startObservation(model, at: workspace, recorder: recorder) + + try fixture.git(["add", "tracked.txt"], at: workspace) + + let refreshed = await waitUntil { recorder.gitRefreshCount == 1 } + #expect(refreshed, "A separate Git directory must be observed outside the workspace") + #expect(recorder.externalChangeBatches.isEmpty) + } + + @Test + @MainActor + func directlyOpenedSubmoduleStageRefreshesItsOwnGitState() async throws { + let fixture = try GitObservationFixture(label: "direct-submodule") + let source = fixture.url.appendingPathComponent("source", isDirectory: true) + let parent = fixture.url.appendingPathComponent("parent", isDirectory: true) + try fixture.initializeRepository(at: source) + try fixture.initializeRepository(at: parent) + try fixture.git( + [ + "-c", "protocol.file.allow=always", "submodule", "add", "-q", + source.path, "modules/child" + ], + at: parent + ) + try fixture.git(["commit", "-q", "-am", "add submodule"], at: parent) + let submodule = parent.appendingPathComponent("modules/child", isDirectory: true) + try fixture.configureRepository(at: submodule) + try Data("changed\n".utf8).write(to: submodule.appendingPathComponent("tracked.txt")) + let recorder = GitObservationRecorder() + let model = makeObservationModel(recorder: recorder) + defer { model.reset() } + try await startObservation(model, at: submodule, recorder: recorder) + + try fixture.git(["add", "tracked.txt"], at: submodule) + + let refreshed = await waitUntil { recorder.gitRefreshCount == 1 } + #expect(refreshed, "A submodule index lives in the parent repository metadata") + #expect(recorder.externalChangeBatches.isEmpty) + } + + @Test + @MainActor + func parentRepositoryRefreshesWhenSubmoduleHeadAdvances() async throws { + let fixture = try GitObservationFixture(label: "parent-submodule") + let source = fixture.url.appendingPathComponent("source", isDirectory: true) + let parent = fixture.url.appendingPathComponent("parent", isDirectory: true) + try fixture.initializeRepository(at: source) + try fixture.initializeRepository(at: parent) + try fixture.git( + [ + "-c", "protocol.file.allow=always", "submodule", "add", "-q", + source.path, "modules/child" + ], + at: parent + ) + try fixture.git(["commit", "-q", "-am", "add submodule"], at: parent) + let submodule = parent.appendingPathComponent("modules/child", isDirectory: true) + try fixture.configureRepository(at: submodule) + try Data("changed\n".utf8).write(to: submodule.appendingPathComponent("tracked.txt")) + try fixture.git(["add", "tracked.txt"], at: submodule) + let recorder = GitObservationRecorder() + let model = makeObservationModel(recorder: recorder) + defer { model.reset() } + try await startObservation(model, at: parent, recorder: recorder) + + try fixture.git(["commit", "-q", "-m", "advance submodule"], at: submodule) + + let refreshed = await waitUntil { recorder.gitRefreshCount == 1 } + #expect(refreshed, "The parent repository must refresh its submodule gitlink state") + #expect(recorder.externalChangeBatches.isEmpty) + } + + @Test + @MainActor + func editOutsideOpenedSubdirectoryRefreshesRepositoryGitStateOnly() async throws { + let fixture = try GitObservationFixture(label: "repository-subdirectory") + let repository = fixture.url.appendingPathComponent("repository", isDirectory: true) + try fixture.initializeRepository(at: repository) + let workspace = repository.appendingPathComponent("apps/opened", isDirectory: true) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + try Data("inside\n".utf8).write(to: workspace.appendingPathComponent("inside.txt")) + try Data("outside\n".utf8).write(to: repository.appendingPathComponent("outside.txt")) + try fixture.git(["add", "."], at: repository) + try fixture.git(["commit", "-q", "-m", "repository layout"], at: repository) + let recorder = GitObservationRecorder() + let model = makeObservationModel(recorder: recorder) + defer { model.reset() } + try await startObservation(model, at: workspace, recorder: recorder) + + try Data("changed outside workspace\n".utf8).write( + to: repository.appendingPathComponent("outside.txt") + ) + + let refreshed = await waitUntil { recorder.gitRefreshCount == 1 } + #expect(refreshed, "Git status covers the repository root, not only the opened subdirectory") + #expect(recorder.externalChangeBatches.isEmpty) + } + + @Test + @MainActor + func gitInitAfterWorkspaceOpenIsDiscoveredWithoutReopeningTheProject() async throws { + let fixture = try GitObservationFixture(label: "dynamic-git-init") + let workspace = fixture.url.appendingPathComponent("workspace", isDirectory: true) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + try Data("plain workspace\n".utf8).write(to: workspace.appendingPathComponent("file.txt")) + let recorder = GitObservationRecorder() + let model = makeObservationModel(recorder: recorder) + defer { model.reset() } + try await startObservation(model, at: workspace, recorder: recorder) + + try fixture.git(["init", "-q"], at: workspace) + + let refreshed = await waitUntil { recorder.gitRefreshCount == 1 } + #expect(refreshed, "Creating .git must re-resolve the watch context and refresh Git") + #expect(recorder.externalChangeBatches.isEmpty) + } +} + +@MainActor +private final class GitObservationRecorder { + var gitRefreshCount = 0 + var externalChangeBatches: [[URL]] = [] + var projectServiceReloadCount = 0 + + func reset() { + gitRefreshCount = 0 + externalChangeBatches = [] + projectServiceReloadCount = 0 + } +} + +private final class GitObservationFixture { + let url: URL + + init(label: String) throws { + let root = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) + .appendingPathComponent(".build/lithe-git-observation-tests", isDirectory: true) + .appendingPathComponent("\(label)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + url = root.standardizedFileURL + } + + deinit { + try? FileManager.default.removeItem(at: url) + } + + func configureRepository(at repository: URL) throws { + try git(["config", "user.email", "tests@lithe.local"], at: repository) + try git(["config", "user.name", "Lithe Tests"], at: repository) + try git(["config", "core.autocrlf", "false"], at: repository) + } + + func initializeRepository(at repository: URL) throws { + try FileManager.default.createDirectory(at: repository, withIntermediateDirectories: true) + try git(["init", "-q"], at: repository) + try configureRepository(at: repository) + try Data("initial\n".utf8).write(to: repository.appendingPathComponent("tracked.txt")) + try git(["add", "tracked.txt"], at: repository) + try git(["commit", "-q", "-m", "initial"], at: repository) + } + + @discardableResult + func git(_ arguments: [String], at directory: URL) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = arguments + process.currentDirectoryURL = directory + let output = Pipe() + let error = Pipe() + process.standardOutput = output + process.standardError = error + try process.run() + process.waitUntilExit() + let standardOutput = output.fileHandleForReading.readDataToEndOfFile() + let standardError = error.fileHandleForReading.readDataToEndOfFile() + guard process.terminationStatus == 0 else { + throw GitObservationTestError.gitFailed( + arguments: arguments, + output: String(decoding: standardError, as: UTF8.self) + ) + } + return String(decoding: standardOutput, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +private enum GitObservationTestError: Error { + case gitFailed(arguments: [String], output: String) + case workspaceUnavailable +} + +private struct GitObservationWatchContextProvider: GitWatchContextProviding { + func watchContext(for workspace: URL) async -> GitWatchContext? { + await Task.detached { + guard let repositoryRoot = Self.resolvePath( + at: workspace, + arguments: ["rev-parse", "--show-toplevel"] + ), + let gitDirectory = Self.resolvePath( + at: workspace, + arguments: ["rev-parse", "--absolute-git-dir"] + ), + let gitCommonDirectory = Self.resolvePath( + at: workspace, + arguments: ["rev-parse", "--path-format=absolute", "--git-common-dir"] + ) else { return nil } + return GitWatchContext( + repositoryRoot: repositoryRoot, + gitDirectory: gitDirectory, + gitCommonDirectory: gitCommonDirectory + ) + }.value + } + + private static func resolvePath(at workspace: URL, arguments: [String]) -> URL? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = arguments + process.currentDirectoryURL = workspace + let output = Pipe() + process.standardOutput = output + process.standardError = Pipe() + do { + try process.run() + } catch { + return nil + } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let path = String( + decoding: output.fileHandleForReading.readDataToEndOfFile(), + as: UTF8.self + ).trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty else { return nil } + return URL(fileURLWithPath: path).standardizedFileURL.resolvingSymlinksInPath() + } +} + +@MainActor +private func makeObservationModel(recorder: GitObservationRecorder) -> WorkspaceFeatureModel { + let model = WorkspaceFeatureModel( + operations: GitObservationWorkspaceOperations(), + fileOperations: MacWorkspaceFileOperations(), + gitWatchContextProvider: GitObservationWatchContextProvider(), + directoryWatcherFactory: GitObservationDirectoryWatcherFactory(), + workspaceSessionStore: WorkspaceSessionStore(store: GitObservationKeyValueStore()) + ) + model.configure( + documentsProvider: { [] }, + activeDocumentProvider: { nil }, + selectedSidebarProvider: { "project" }, + setSelectedSidebar: { _ in }, + restoreSession: { _, _ in }, + openFile: { _ in }, + notify: { _ in }, + recordHistory: { _, _ in }, + relocateHistory: { _, _ in }, + relocateOpenDocuments: { _, _ in }, + closeDocuments: { _ in }, + processExternalChanges: { urls in + recorder.externalChangeBatches.append(urls) + return false + }, + reloadProjectServices: { + recorder.projectServiceReloadCount += 1 + }, + refreshGit: { + recorder.gitRefreshCount += 1 + }, + updateHistoryVisibilityRules: { _ in }, + onSnapshotLoaded: { _, _ in } + ) + return model +} + +@MainActor +private func startObservation( + _ model: WorkspaceFeatureModel, + at workspace: URL, + recorder: GitObservationRecorder +) async throws { + model.beginWorkspace(at: workspace, visibilityRules: .default) + let result = await model.rebuild( + at: workspace, + rules: .default, + isCurrent: { true } + ) + guard case .loaded = result else { + throw GitObservationTestError.workspaceUnavailable + } + try await Task.sleep(for: .milliseconds(750)) + recorder.reset() +} + +@MainActor +private func waitUntil( + timeout: Duration = .seconds(2), + condition: @escaping @MainActor () -> Bool +) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(25)) + } + return condition() +} + +private struct GitObservationDirectoryWatcherFactory: DirectoryWatcherFactory { + func make( + configuration: DirectoryWatchConfiguration, + visibilityRules: FileVisibilityRules, + onChange: @escaping @Sendable (DirectoryChangeBatch) -> Void + ) -> any DirectoryChangeSource { + MacDirectoryWatcher( + configuration: configuration, + visibilityRules: visibilityRules, + onChange: onChange + ) + } +} + +private struct GitObservationWorkspaceOperations: WorkspaceOperations { + func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { + FileSystemWorkspaceSnapshotBuilder().snapshot( + at: rootURL, + visibilityRules: visibilityRules + ) + } + + func search( + at rootURL: URL, + query: String, + options: ProjectSearchOptions, + visibilityRules: FileVisibilityRules + ) -> [FileSearchResult]? { nil } + + func searchEverywhere( + at rootURL: URL, + query: String, + options: ProjectSearchOptions, + visibilityRules: FileVisibilityRules + ) -> SearchEverywhereResults? { nil } + + func previewReplacement( + at rootURL: URL, + query: String, + replacement: String, + options: ProjectSearchOptions, + paths: [String], + textOverrides: [String: String], + visibilityRules: FileVisibilityRules + ) -> [ProjectReplacementFile]? { nil } + + func readFile(at rootURL: URL, relativePath: String) -> String? { nil } + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { false } +} + +private struct GitObservationKeyValueStore: KeyValueStore { + func data(forKey key: String) -> Data? { nil } + func object(forKey key: String) -> Any? { nil } + func string(forKey key: String) -> String? { nil } + func stringArray(forKey key: String) -> [String]? { nil } + func set(_ value: Any?, forKey key: String) {} +} diff --git a/Tests/LitheTests/LitheCoreLogicTests.swift b/Tests/LitheTests/LitheCoreLogicTests.swift index bac5f785..689a16c3 100644 --- a/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/Tests/LitheTests/LitheCoreLogicTests.swift @@ -1,4 +1,5 @@ import AppKit +import CoreServices import Foundation import Testing @testable import Lithe @@ -2206,6 +2207,7 @@ private final class TestProjectWindowSessions: ProjectWindowSessionHandling { } private final class RecordingProcessRunner: ProcessRunner, @unchecked Sendable { + private let lock = NSLock() private let handler: (ProcessRequest) -> ProcessResult private let requestsLock = NSLock() private var recordedRequests: [ProcessRequest] = [] @@ -2538,6 +2540,7 @@ struct EditorDocumentTests { let model = WorkspaceFeatureModel( operations: operations, fileOperations: EmptyWorkspaceFileOperations(), + gitWatchContextProvider: GitService(operations: RustGitOperations(core: RustCoreBridge())), directoryWatcherFactory: TestDirectoryWatcherFactory(), workspaceSessionStore: WorkspaceSessionStore(store: EmptyKeyValueStore()) ) @@ -2623,6 +2626,7 @@ struct EditorDocumentTests { let model = WorkspaceFeatureModel( operations: EmptyWorkspaceOperations(), fileOperations: EmptyWorkspaceFileOperations(), + gitWatchContextProvider: GitService(operations: RustGitOperations(core: RustCoreBridge())), directoryWatcherFactory: watcherFactory, workspaceSessionStore: WorkspaceSessionStore(store: EmptyKeyValueStore()) ) @@ -2668,6 +2672,186 @@ struct EditorDocumentTests { #expect(refreshCount == 1) } + @Test + func directoryWatchConfigurationNormalizesAndDeduplicatesCoveredRoots() { + let repository = URL(fileURLWithPath: "/tmp/lithe-watch/repository") + let workspace = repository.appendingPathComponent("apps/opened") + let commonDirectory = URL(fileURLWithPath: "/tmp/lithe-watch/metadata/repository.git") + let context = GitWatchContext( + repositoryRoot: repository, + gitDirectory: commonDirectory.appendingPathComponent("worktrees/opened"), + gitCommonDirectory: commonDirectory + ) + + let configuration = DirectoryWatchConfiguration( + workspaceRoot: workspace, + gitContext: context + ) + + #expect(configuration.physicalRoots.map(\.path) == [repository.path, commonDirectory.path]) + } + + @Test + func macDirectoryWatcherClassifiesWorkspaceGitOnlyAndRecoveryEvents() { + let repository = URL(fileURLWithPath: "/tmp/lithe-classification/repository") + let workspace = repository.appendingPathComponent("apps/opened") + let gitDirectory = repository.appendingPathComponent(".git") + let configuration = DirectoryWatchConfiguration( + workspaceRoot: workspace, + gitContext: GitWatchContext( + repositoryRoot: repository, + gitDirectory: gitDirectory, + gitCommonDirectory: gitDirectory + ) + ) + let watcher = MacDirectoryWatcher(configuration: configuration) { _ in } + let visible = workspace.appendingPathComponent("Sources/App.swift").path + let hidden = workspace.appendingPathComponent("dist/bundle.js").path + let outsideWorkspace = repository.appendingPathComponent("outside.txt").path + let index = gitDirectory.appendingPathComponent("index").path + + let classified = watcher.classify( + paths: [visible, hidden, outsideWorkspace, index], + eventFlags: Array(repeating: FSEventStreamEventFlags(0), count: 4) + ) + + #expect(classified.workspacePaths == [visible]) + #expect(classified.gitStateMayHaveChanged) + #expect(!classified.requiresFullRescan) + #expect(!classified.watchRootsChanged) + + let workspaceOnly = DirectoryWatchConfiguration(workspaceRoot: workspace, gitContext: nil) + let workspaceWatcher = MacDirectoryWatcher(configuration: workspaceOnly) { _ in } + let gitCreated = workspaceWatcher.classify( + paths: [workspace.appendingPathComponent(".git").path], + eventFlags: [FSEventStreamEventFlags(0)] + ) + #expect(gitCreated.workspacePaths.isEmpty) + #expect(gitCreated.gitStateMayHaveChanged) + #expect(gitCreated.watchRootsChanged) + + let dropped = watcher.classify( + paths: [repository.path], + eventFlags: [FSEventStreamEventFlags(kFSEventStreamEventFlagMustScanSubDirs)] + ) + #expect(dropped.workspacePaths.isEmpty) + #expect(dropped.gitStateMayHaveChanged) + #expect(dropped.requiresFullRescan) + + let rootChanged = watcher.classify( + paths: [repository.path], + eventFlags: [FSEventStreamEventFlags(kFSEventStreamEventFlagRootChanged)] + ) + #expect(rootChanged.requiresFullRescan) + #expect(rootChanged.watchRootsChanged) + } + + @Test + @MainActor + func gitRefreshBurstCoalescesAndARequestDuringRefreshRunsAgain() async { + let watcherFactory = TestDirectoryWatcherFactory() + var refreshCount = 0 + let model = makeWorkspaceObservationUnitModel( + provider: SequencedGitWatchContextProvider([nil]), + watcherFactory: watcherFactory, + refreshGit: { + refreshCount += 1 + if refreshCount == 1 { + watcherFactory.source?.emit( + DirectoryChangeBatch(gitStateMayHaveChanged: true) + ) + await Task.yield() + } + } + ) + defer { model.reset() } + let workspace = URL(fileURLWithPath: "/tmp/lithe-git-refresh-state") + model.beginWorkspace(at: workspace, visibilityRules: .default) + let source = watcherFactory.source + + source?.emit(DirectoryChangeBatch(gitStateMayHaveChanged: true)) + source?.emit(DirectoryChangeBatch(gitStateMayHaveChanged: true)) + source?.emit(DirectoryChangeBatch(gitStateMayHaveChanged: true)) + let refreshed = await waitForWorkspaceObservation { refreshCount == 2 } + + #expect(refreshed) + #expect(refreshCount == 2) + } + + @Test + @MainActor + func recoveryBatchRebuildsSnapshotReplacesRootsAndRefreshesOnlyGit() async { + let repository = URL(fileURLWithPath: "/tmp/lithe-recovery/repository") + let gitDirectory = repository.appendingPathComponent(".git") + let context = GitWatchContext( + repositoryRoot: repository, + gitDirectory: gitDirectory, + gitCommonDirectory: gitDirectory + ) + let watcherFactory = TestDirectoryWatcherFactory() + var externalChangeCount = 0 + var projectReloadCount = 0 + var refreshCount = 0 + let model = makeWorkspaceObservationUnitModel( + operations: SequencedWorkspaceOperations(snapshotAvailability: [true]), + provider: SequencedGitWatchContextProvider([context]), + watcherFactory: watcherFactory, + refreshGit: { refreshCount += 1 }, + processExternalChanges: { paths in + externalChangeCount += paths.count + return false + }, + reloadProjectServices: { projectReloadCount += 1 } + ) + defer { model.reset() } + model.beginWorkspace(at: repository, visibilityRules: .default) + watcherFactory.source?.emit( + DirectoryChangeBatch( + gitStateMayHaveChanged: true, + requiresFullRescan: true, + watchRootsChanged: true + ) + ) + let recovered = await waitForWorkspaceObservation { + model.rootNode != nil && refreshCount == 1 + } + + #expect(recovered) + #expect(model.rootNode != nil) + #expect(watcherFactory.configurations.last?.repositoryRoot == repository) + #expect(refreshCount == 1) + #expect(externalChangeCount == 0) + #expect(projectReloadCount == 0) + } + + @Test + @MainActor + func foregroundRecoveryReparsesContextReplacesWatcherAndRefreshes() async { + let workspace = URL(fileURLWithPath: "/tmp/lithe-foreground/workspace") + let gitDirectory = URL(fileURLWithPath: "/tmp/lithe-foreground/metadata.git") + let context = GitWatchContext( + repositoryRoot: workspace, + gitDirectory: gitDirectory, + gitCommonDirectory: gitDirectory + ) + let watcherFactory = TestDirectoryWatcherFactory() + var refreshCount = 0 + let model = makeWorkspaceObservationUnitModel( + provider: SequencedGitWatchContextProvider([nil, context]), + watcherFactory: watcherFactory, + refreshGit: { refreshCount += 1 } + ) + defer { model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: .default) + + await model.resumeObservationAfterActivation() + await model.resumeObservationAfterActivation() + + #expect(watcherFactory.configurations.count == 3) + #expect(watcherFactory.configurations.last?.gitDirectory == gitDirectory) + #expect(refreshCount == 2) + } + @Test @MainActor func openDocumentOrderCanBeMovedAndRestored() async { @@ -2769,6 +2953,70 @@ struct EditorDocumentTests { } } +@MainActor +private func makeWorkspaceObservationUnitModel( + operations: any WorkspaceOperations = EmptyWorkspaceOperations(), + provider: any GitWatchContextProviding, + watcherFactory: TestDirectoryWatcherFactory, + refreshGit: @escaping @MainActor () async -> Void, + processExternalChanges: @escaping @MainActor ([URL]) -> Bool = { _ in false }, + reloadProjectServices: @escaping @MainActor () async -> Void = {} +) -> WorkspaceFeatureModel { + let model = WorkspaceFeatureModel( + operations: operations, + fileOperations: EmptyWorkspaceFileOperations(), + gitWatchContextProvider: provider, + directoryWatcherFactory: watcherFactory, + workspaceSessionStore: WorkspaceSessionStore(store: EmptyKeyValueStore()) + ) + model.configure( + documentsProvider: { [] }, + activeDocumentProvider: { nil }, + selectedSidebarProvider: { "project" }, + setSelectedSidebar: { _ in }, + restoreSession: { _, _ in }, + openFile: { _ in }, + notify: { _ in }, + recordHistory: { _, _ in }, + relocateHistory: { _, _ in }, + relocateOpenDocuments: { _, _ in }, + closeDocuments: { _ in }, + processExternalChanges: processExternalChanges, + reloadProjectServices: reloadProjectServices, + refreshGit: refreshGit, + updateHistoryVisibilityRules: { _ in }, + onSnapshotLoaded: { _, _ in } + ) + return model +} + +@MainActor +private func waitForWorkspaceObservation( + timeout: Duration = .seconds(3), + condition: @escaping @MainActor () -> Bool +) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(25)) + } + return condition() +} + +private actor SequencedGitWatchContextProvider: GitWatchContextProviding { + private var contexts: [GitWatchContext?] + + init(_ contexts: [GitWatchContext?]) { + self.contexts = contexts + } + + func watchContext(for workspace: URL) async -> GitWatchContext? { + guard contexts.count > 1 else { return contexts.first ?? nil } + return contexts.removeFirst() + } +} + @MainActor private final class TestTerminalTransport: TerminalTransport { let nativeView: AnyObject = NSView(frame: .zero) @@ -3117,9 +3365,9 @@ private struct EmptyWorkspaceFileOperations: WorkspaceFileOperations { } private final class TestDirectoryChangeSource: DirectoryChangeSource { - private let onChange: @Sendable ([String]) -> Void + private let onChange: @Sendable (DirectoryChangeBatch) -> Void - init(onChange: @escaping @Sendable ([String]) -> Void) { + init(onChange: @escaping @Sendable (DirectoryChangeBatch) -> Void) { self.onChange = onChange } @@ -3127,18 +3375,24 @@ private final class TestDirectoryChangeSource: DirectoryChangeSource { func stop() {} func emit(_ paths: [String]) { - onChange(paths) + emit(DirectoryChangeBatch(workspacePaths: paths, gitStateMayHaveChanged: true)) + } + + func emit(_ batch: DirectoryChangeBatch) { + onChange(batch) } } private final class TestDirectoryWatcherFactory: DirectoryWatcherFactory { private(set) var source: TestDirectoryChangeSource? + private(set) var configurations: [DirectoryWatchConfiguration] = [] func make( - root: URL, + configuration: DirectoryWatchConfiguration, visibilityRules: FileVisibilityRules, - onChange: @escaping @Sendable ([String]) -> Void + onChange: @escaping @Sendable (DirectoryChangeBatch) -> Void ) -> any DirectoryChangeSource { + configurations.append(configuration) let source = TestDirectoryChangeSource(onChange: onChange) self.source = source return source diff --git a/rust/lithe-core/tests/git_watch_context.rs b/rust/lithe-core/tests/git_watch_context.rs new file mode 100644 index 00000000..ff4ecc88 --- /dev/null +++ b/rust/lithe-core/tests/git_watch_context.rs @@ -0,0 +1,237 @@ +use lithe_core::execute_json; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::{SystemTime, UNIX_EPOCH}; + +struct GitFixture { + root: PathBuf, +} + +impl GitFixture { + fn new(label: &str) -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be valid") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "lithe-git-watch-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&root).expect("Git fixture root should be creatable"); + Self { root } + } +} + +impl Drop for GitFixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +fn git(directory: &Path, arguments: &[&str]) -> Output { + Command::new("git") + .args(arguments) + .current_dir(directory) + .output() + .expect("git should be available") +} + +fn require_git(directory: &Path, arguments: &[&str]) { + let output = git(directory, arguments); + assert!( + output.status.success(), + "git {} failed: {}", + arguments.join(" "), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn initialize_repository(root: &Path) { + fs::create_dir_all(root).expect("repository should be creatable"); + require_git(root, &["init", "-q"]); + require_git(root, &["config", "user.email", "tests@lithe.local"]); + require_git(root, &["config", "user.name", "Lithe Tests"]); + fs::write(root.join("tracked.txt"), "initial\n").expect("tracked fixture should be writable"); + require_git(root, &["add", "tracked.txt"]); + require_git(root, &["commit", "-q", "-m", "initial"]); +} + +fn absolute_git_path(root: &Path, arguments: &[&str]) -> String { + let output = git(root, arguments); + assert!( + output.status.success(), + "git {} failed: {}", + arguments.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + let path = String::from_utf8(output.stdout) + .expect("Git path should be UTF-8") + .trim() + .to_string(); + fs::canonicalize(path) + .expect("Git path should exist") + .to_string_lossy() + .into_owned() +} + +fn watch_context(root: &Path) -> Value { + let request = json!({ + "id": "git-watch-context", + "command": "git.watchContext", + "payload": { "root": root } + }); + serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("watch context request should encode"), + )) + .expect("watch context response should be JSON") +} + +fn assert_context( + response: &Value, + repository_root: &Path, + git_directory: &str, + git_common_directory: &str, +) { + assert_eq!(response["ok"], true, "response: {response}"); + assert_eq!( + response["data"]["repositoryRoot"], + fs::canonicalize(repository_root) + .expect("repository root should exist") + .to_string_lossy() + .as_ref() + ); + assert_eq!(response["data"]["gitDirectory"], git_directory); + assert_eq!(response["data"]["gitCommonDirectory"], git_common_directory); +} + +#[test] +fn watch_context_resolves_an_ordinary_repository_from_a_nested_workspace() { + let fixture = GitFixture::new("nested-workspace"); + let repository = fixture.root.join("repository"); + initialize_repository(&repository); + let workspace = repository.join("apps/editor"); + fs::create_dir_all(&workspace).expect("nested workspace should be creatable"); + + let git_directory = absolute_git_path(&workspace, &["rev-parse", "--absolute-git-dir"]); + let git_common_directory = absolute_git_path( + &workspace, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + ); + let response = watch_context(&workspace); + + assert_context( + &response, + &repository, + &git_directory, + &git_common_directory, + ); +} + +#[test] +fn watch_context_distinguishes_linked_worktree_git_and_common_directories() { + let fixture = GitFixture::new("worktree"); + let repository = fixture.root.join("repository"); + let worktree = fixture.root.join("linked-worktree"); + initialize_repository(&repository); + let worktree_path = worktree.to_string_lossy().into_owned(); + require_git( + &repository, + &[ + "worktree", + "add", + "-q", + "-b", + "watch-context", + &worktree_path, + ], + ); + + let git_directory = absolute_git_path(&worktree, &["rev-parse", "--absolute-git-dir"]); + let git_common_directory = absolute_git_path( + &worktree, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + ); + let response = watch_context(&worktree); + + assert_ne!(git_directory, git_common_directory); + assert_context(&response, &worktree, &git_directory, &git_common_directory); +} + +#[test] +fn watch_context_resolves_a_submodule_git_directory_outside_its_workspace() { + let fixture = GitFixture::new("submodule"); + let source = fixture.root.join("source"); + let parent = fixture.root.join("parent"); + initialize_repository(&source); + initialize_repository(&parent); + let source_path = source.to_string_lossy().into_owned(); + require_git( + &parent, + &[ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + "-q", + &source_path, + "modules/child", + ], + ); + require_git(&parent, &["commit", "-q", "-am", "add submodule"]); + let submodule = parent.join("modules/child"); + + let git_directory = absolute_git_path(&submodule, &["rev-parse", "--absolute-git-dir"]); + let git_common_directory = absolute_git_path( + &submodule, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + ); + let response = watch_context(&submodule); + + assert_eq!(git_directory, git_common_directory); + assert!(!Path::new(&git_directory).starts_with(&submodule)); + assert_context(&response, &submodule, &git_directory, &git_common_directory); +} + +#[test] +fn watch_context_resolves_a_separate_git_directory() { + let fixture = GitFixture::new("separate-git-dir"); + let workspace = fixture.root.join("workspace"); + let external_git_directory = fixture.root.join("metadata/repository.git"); + fs::create_dir_all( + external_git_directory + .parent() + .expect("external Git directory should have a parent"), + ) + .expect("metadata parent should be creatable"); + let separate_argument = format!( + "--separate-git-dir={}", + external_git_directory.to_string_lossy() + ); + let workspace_path = workspace.to_string_lossy().into_owned(); + require_git( + &fixture.root, + &["init", "-q", &separate_argument, &workspace_path], + ); + + let git_directory = absolute_git_path(&workspace, &["rev-parse", "--absolute-git-dir"]); + let git_common_directory = absolute_git_path( + &workspace, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + ); + let response = watch_context(&workspace); + + assert_eq!(git_directory, git_common_directory); + assert!(!Path::new(&git_directory).starts_with(&workspace)); + assert_context(&response, &workspace, &git_directory, &git_common_directory); +} + +#[test] +fn watch_context_returns_no_repository_for_a_plain_directory() { + let fixture = GitFixture::new("plain-directory"); + let response = watch_context(&fixture.root); + + assert_eq!(response["ok"], true, "response: {response}"); + assert!(response["data"].is_null()); +} From 2a89f3796121b74c5145bb86a94651a8ad071d55 Mon Sep 17 00:00:00 2001 From: yager-42 <331382125@qq.com> Date: Wed, 12 Aug 2026 19:37:54 +0800 Subject: [PATCH 3/4] fix: process workspace paths across watcher root recovery watchRootsChanged/requiresFullRescan batches returned early and dropped their workspacePaths, so a merged FSEvents batch could skip document and snapshot refresh after recovery. Merge batch paths first, process them after a non-full recovery, and cover the watchRootsChanged + workspacePaths combination with a regression test. --- .../Application/WorkspaceFeatureModel.swift | 20 +++++- Tests/LitheTests/LitheCoreLogicTests.swift | 68 ++++++++++++++++++- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/Sources/Lithe/Application/WorkspaceFeatureModel.swift b/Sources/Lithe/Application/WorkspaceFeatureModel.swift index dea744b4..3cc917b0 100644 --- a/Sources/Lithe/Application/WorkspaceFeatureModel.swift +++ b/Sources/Lithe/Application/WorkspaceFeatureModel.swift @@ -547,10 +547,16 @@ final class WorkspaceFeatureModel: ObservableObject { private func scheduleDirectoryChange(_ batch: DirectoryChangeBatch) { guard !batch.isEmpty else { return } + if !batch.workspacePaths.isEmpty { + pendingExternalPaths.formUnion(batch.workspacePaths) + externalRefreshGeneration += 1 + } if batch.watchRootsChanged || batch.requiresFullRescan { pendingWatchRootsChanged = pendingWatchRootsChanged || batch.watchRootsChanged pendingFullRescan = pendingFullRescan || batch.requiresFullRescan pendingGitRefresh = true + refreshTask?.cancel() + refreshTask = nil gitRefreshTask?.cancel() gitRefreshTask = nil scheduleRecovery() @@ -559,7 +565,7 @@ final class WorkspaceFeatureModel: ObservableObject { if !batch.workspacePaths.isEmpty { if batch.gitStateMayHaveChanged { pendingGitRefresh = true } - scheduleExternalRefresh(paths: batch.workspacePaths) + schedulePendingExternalRefresh() } else if batch.gitStateMayHaveChanged { scheduleGitRefresh() } @@ -596,6 +602,13 @@ final class WorkspaceFeatureModel: ObservableObject { } if fullRescan { await refreshCurrent() + } else if !pendingExternalPaths.isEmpty { + let changedPaths = Array(pendingExternalPaths) + pendingExternalPaths.removeAll() + externalRefreshGeneration += 1 + refreshTask?.cancel() + refreshTask = nil + await applyExternalRefresh(changedPaths, at: workspaceURL) } if pendingGitRefresh { await drainGitRefreshes() @@ -606,6 +619,11 @@ final class WorkspaceFeatureModel: ObservableObject { guard !paths.isEmpty else { return } pendingExternalPaths.formUnion(paths) externalRefreshGeneration += 1 + schedulePendingExternalRefresh() + } + + private func schedulePendingExternalRefresh() { + guard !pendingExternalPaths.isEmpty else { return } guard gitOperationFreezeDepth == 0 else { refreshTask?.cancel() refreshTask = nil diff --git a/Tests/LitheTests/LitheCoreLogicTests.swift b/Tests/LitheTests/LitheCoreLogicTests.swift index 689a16c3..ded38569 100644 --- a/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/Tests/LitheTests/LitheCoreLogicTests.swift @@ -2824,6 +2824,53 @@ struct EditorDocumentTests { #expect(projectReloadCount == 0) } + @Test + @MainActor + func watchRootsRecoveryRetainsWorkspacePathsAndRefreshesSnapshotAndDocuments() async { + let workspace = URL(fileURLWithPath: "/tmp/lithe-watch-roots-recovery/workspace") + let changedFile = workspace.appendingPathComponent("Sources/App.swift") + let gitDirectory = workspace.appendingPathComponent(".git") + let context = GitWatchContext( + repositoryRoot: workspace, + gitDirectory: gitDirectory, + gitCommonDirectory: gitDirectory + ) + let watcherFactory = TestDirectoryWatcherFactory() + var processedPaths: [URL] = [] + var refreshCount = 0 + let model = makeWorkspaceObservationUnitModel( + operations: SequencedWorkspaceOperations(snapshotAvailability: [true]), + fileOperations: ExistingWorkspaceFileOperations(paths: [changedFile.path]), + provider: SequencedGitWatchContextProvider([context]), + watcherFactory: watcherFactory, + refreshGit: { refreshCount += 1 }, + processExternalChanges: { paths in + processedPaths.append(contentsOf: paths) + return false + } + ) + defer { model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: .default) + watcherFactory.source?.emit( + DirectoryChangeBatch( + workspacePaths: [changedFile.path], + gitStateMayHaveChanged: true, + watchRootsChanged: true + ) + ) + + let recovered = await waitForWorkspaceObservation { + model.rootNode != nil && processedPaths.map(\.path) == [changedFile.path] + && refreshCount == 1 + } + + #expect(recovered) + #expect(model.rootNode != nil) + #expect(processedPaths.map(\.path) == [changedFile.path]) + #expect(watcherFactory.configurations.last?.repositoryRoot == workspace) + #expect(refreshCount == 1) + } + @Test @MainActor func foregroundRecoveryReparsesContextReplacesWatcherAndRefreshes() async { @@ -2956,6 +3003,7 @@ struct EditorDocumentTests { @MainActor private func makeWorkspaceObservationUnitModel( operations: any WorkspaceOperations = EmptyWorkspaceOperations(), + fileOperations: any WorkspaceFileOperations = EmptyWorkspaceFileOperations(), provider: any GitWatchContextProviding, watcherFactory: TestDirectoryWatcherFactory, refreshGit: @escaping @MainActor () async -> Void, @@ -2964,7 +3012,7 @@ private func makeWorkspaceObservationUnitModel( ) -> WorkspaceFeatureModel { let model = WorkspaceFeatureModel( operations: operations, - fileOperations: EmptyWorkspaceFileOperations(), + fileOperations: fileOperations, gitWatchContextProvider: provider, directoryWatcherFactory: watcherFactory, workspaceSessionStore: WorkspaceSessionStore(store: EmptyKeyValueStore()) @@ -3352,6 +3400,24 @@ private final class SequencedWorkspaceOperations: WorkspaceOperations, @unchecke func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { false } } +private struct ExistingWorkspaceFileOperations: WorkspaceFileOperations { + let paths: Set + + init(paths: [String]) { + self.paths = Set(paths) + } + + func fileExists(at url: URL) -> Bool { paths.contains(url.standardizedFileURL.path) } + func isDirectory(at url: URL) -> Bool { false } + func createFile(at url: URL) throws {} + func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws {} + func copyItem(at sourceURL: URL, to destinationURL: URL) throws {} + func moveItem(at sourceURL: URL, to destinationURL: URL) throws {} + func removeItem(at url: URL) throws {} + func trashItem(at url: URL) throws {} + func writeText(_ text: String, to url: URL) throws {} +} + private struct EmptyWorkspaceFileOperations: WorkspaceFileOperations { func fileExists(at url: URL) -> Bool { false } func isDirectory(at url: URL) -> Bool { false } From d22ffca9728ed914816592cbf122e4f6db2936b8 Mon Sep 17 00:00:00 2001 From: yager-42 <331382125@qq.com> Date: Wed, 12 Aug 2026 19:38:02 +0800 Subject: [PATCH 4/4] docs: record git.watchContext in the Rust core contract git.watchContext is public surface shared by both products. Document its { root } request, the null response outside a repository, and the absolute repositoryRoot, gitDirectory, and gitCommonDirectory fields. --- shared/contracts/rust-core-api.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 44c02091..1e46f47e 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -76,6 +76,7 @@ stable error code and a user-facing message: | `java.serverPort` | Parse Spring server port settings from properties or YAML text | | `java.structure` | Parse Java editor structure, implementation candidates, and inlay hints | | `git.status` | Resolve the repository, current branch, and working-tree changes | +| `git.watchContext` | Resolve the repository and absolute Git metadata roots needed by native file watchers | | `git.command` | Execute one argument-based Git operation and return combined output plus exit code | | `git.write` | Validate and execute shared Git mutations such as stage, commit, branch, checkout, remote sync, clone, and stash | | `git.diff` | Produce a structured working-tree, index, reference, or commit patch | @@ -97,6 +98,11 @@ Java processes, and runtime discovery remain platform adapters. The protocol version is currently `1`. Add a fixture under `shared/fixtures/` before changing a response shape or search rule. +`git.watchContext` accepts `{ "root": string }`. When `root` is not inside a +Git repository, it returns `null`. Otherwise it returns +`{ "repositoryRoot": string, "gitDirectory": string, "gitCommonDirectory": string }`; +all three fields are absolute filesystem paths. + `git.command` accepts `{ "root": string, "arguments": string[], "input": string? }`. Arguments are passed directly to the Git executable without a shell. A successful process launch returns `{ "output": string, "exitCode": number }`