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..3cc917b0 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,23 +514,116 @@ 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.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() + return + } + + if !batch.workspacePaths.isEmpty { + if batch.gitStateMayHaveChanged { pendingGitRefresh = true } + schedulePendingExternalRefresh() + } 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() + } 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() + } + } + private func scheduleExternalRefresh(paths: [String]) { 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 @@ -499,10 +643,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 +713,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/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..ded38569 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,233 @@ 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 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 { + 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 +3000,71 @@ struct EditorDocumentTests { } } +@MainActor +private func makeWorkspaceObservationUnitModel( + operations: any WorkspaceOperations = EmptyWorkspaceOperations(), + fileOperations: any WorkspaceFileOperations = EmptyWorkspaceFileOperations(), + 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: fileOperations, + 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) @@ -3104,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 } @@ -3117,9 +3431,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 +3441,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/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| { 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()); +} 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 }`