diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 8e3247df..a5650738 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -970,6 +970,59 @@ struct RustCoreBridge: Sendable { } } + struct GitReferencesPayload: Decodable, Sendable { + let references: [GitHistoryPayload.Reference] + let recentReferences: [GitHistoryPayload.Reference] + let userName: String? + let userEmail: String? + + func makeSnapshot() -> GitReferenceSnapshot { + GitReferenceSnapshot( + references: references.compactMap(makeReference), + recentReferences: recentReferences.compactMap(makeReference), + identity: (userName == nil && userEmail == nil) + ? nil + : GitIdentity(name: userName, email: userEmail) + ) + } + + private func makeReference(_ reference: GitHistoryPayload.Reference) -> GitReference? { + guard let kind = GitReferenceKind(rawValue: reference.kind) else { return nil } + return GitReference( + fullName: reference.fullName, + shortName: reference.shortName, + kind: kind, + isCurrent: reference.isCurrent, + upstreamShortName: reference.upstreamShortName + ) + } + } + + struct GitHistoryPagePayload: Decodable, Sendable { + let commits: [GitHistoryPayload.Commit] + let nextCursor: String? + let hasMore: Bool + + func makePage() -> GitHistoryPage { + GitHistoryPage( + commits: commits.map { commit in + GitCommit( + hash: commit.hash, + shortHash: commit.shortHash, + parentHashes: commit.parentHashes, + authorName: commit.authorName, + authorEmail: commit.authorEmail, + date: commit.date, + subject: commit.subject, + decorations: commit.decorations + ) + }, + nextCursor: nextCursor, + hasMore: hasMore + ) + } + } + struct GitCommitPayload: Decodable, Sendable { let commit: GitHistoryPayload.Commit @@ -1784,6 +1837,26 @@ struct RustCoreBridge: Sendable { let limit: Int } + private struct GitReferencesRequest: Encodable { + let root: String + } + + private struct GitHistoryPageRequest: Encodable { + let root: String + let reference: String? + let cursor: String? + let limit: Int + } + + private struct GitHistoryCursorCloseRequest: Encodable { + let root: String + let cursor: String + } + + private struct GitHistoryCursorClosePayload: Decodable { + let closed: Bool + } + private struct GitCommitRequest: Encodable { let root: String let commit: String @@ -2847,6 +2920,47 @@ struct RustCoreBridge: Sendable { ) } + func gitReferences( + at rootURL: URL, + operationID: String + ) -> GitReferencesPayload? { + execute( + command: "git.references", + payload: GitReferencesRequest(root: rootURL.standardizedFileURL.path), + operationID: operationID + ) + } + + func gitHistoryPage( + at rootURL: URL, + reference: String?, + cursor: String?, + limit: Int, + operationID: String + ) -> GitHistoryPagePayload? { + execute( + command: "git.historyPage", + payload: GitHistoryPageRequest( + root: rootURL.standardizedFileURL.path, + reference: reference, + cursor: cursor, + limit: limit + ), + operationID: operationID + ) + } + + func closeGitHistoryCursor(at rootURL: URL, cursor: String) -> Bool { + let payload: GitHistoryCursorClosePayload? = execute( + command: "git.historyCursorClose", + payload: GitHistoryCursorCloseRequest( + root: rootURL.standardizedFileURL.path, + cursor: cursor + ) + ) + return payload?.closed ?? false + } + func gitCommit(at rootURL: URL, commit: String) -> GitCommitPayload? { execute( command: "git.commit", @@ -3369,9 +3483,14 @@ struct RustCoreBridge: Sendable { private func execute( command: String, - payload: Payload + payload: Payload, + operationID: String? = nil ) -> Data? { - try? executeResult(command: command, payload: payload).get() + try? executeResult( + command: command, + payload: payload, + operationID: operationID + ).get() } /// Runs a command whose success carries no data. The core encodes those as a diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index 69cc7c35..f3cddb0b 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -434,6 +434,34 @@ struct RustGitOperations: GitOperations, Sendable { )?.makeSnapshot() } + func references(at rootURL: URL, operationID: String) -> GitReferenceSnapshot? { + core.gitReferences(at: rootURL, operationID: operationID)?.makeSnapshot() + } + + func historyPage( + at rootURL: URL, + reference: GitReference?, + cursor: String?, + limit: Int, + operationID: String + ) -> GitHistoryPage? { + core.gitHistoryPage( + at: rootURL, + reference: reference?.fullName, + cursor: cursor, + limit: limit, + operationID: operationID + )?.makePage() + } + + func closeHistoryCursor(at rootURL: URL, cursor: String) -> Bool { + core.closeGitHistoryCursor(at: rootURL, cursor: cursor) + } + + func cancel(operationID: String) -> Bool { + core.cancel(operationID: operationID) + } + func files(in commit: GitCommit, at rootURL: URL) -> [GitCommitFile]? { core.gitCommitFiles(at: rootURL, commit: commit.hash)?.files.map { file in GitCommitFile(status: file.status, path: file.path) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 3514c3a6..2689ab07 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -1542,11 +1542,14 @@ final class AppModel: ObservableObject, Identifiable { } if isGitLogVisible && gitCommits.isEmpty { await refreshGitHistory() + } else if !isGitLogVisible { + gitFeatureIfActive?.cancelGitHistoryLoading() } } func closeGitLog() { isGitLogVisible = false + gitFeatureIfActive?.cancelGitHistoryLoading() } func selectGitReference(_ reference: GitReference?) async { diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 9d4cd2c5..a10d82f4 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -103,7 +103,10 @@ package final class GitFeatureModel: ObservableObject { private var onGitOperationBegan: (@MainActor () -> Void)? private var onGitOperationEnded: (@MainActor () async -> Void)? private var acquireModuleLease: (@MainActor (String) -> ModuleLease)? - private var gitHistoryLimit = 300 + private static let gitHistoryPageSize = 100 + private var gitHistoryCursor: String? + private var gitHistoryGeneration = UUID() + private var activeGitHistoryOperationIDs: Set = [] private var deferredSavedChanges: GitDeferredSavedChanges? private var nextRefreshRequestID: UInt64 = 0 private var activeRefreshRequestIDs: Set = [] @@ -180,6 +183,7 @@ package final class GitFeatureModel: ObservableObject { } package func reset() { + cancelGitHistoryLoading() gitChanges = [] pendingStagingStates = [:] gitStashes = [] @@ -220,7 +224,7 @@ package final class GitFeatureModel: ObservableObject { commitPathsByHash = [:] clearGitCommitFilesCache() gitLogFilterGeneration = UUID() - gitHistoryLimit = 300 + gitHistoryCursor = nil isLoadingGitHistory = false isLoadingMoreGitHistory = false canLoadMoreGitHistory = false @@ -1257,29 +1261,55 @@ package final class GitFeatureModel: ObservableObject { package func selectGitReference(_ reference: GitReference?) async { selectedGitReference = reference - gitHistoryLimit = 300 canLoadMoreGitHistory = false await refreshGitHistory() } package func refreshGitHistory() async { - guard let gitRepositoryRoot, !isLoadingGitHistory else { return } + guard let gitRepositoryRoot else { return } + cancelGitHistoryLoading() + let generation = gitHistoryGeneration + let selectedReference = selectedGitReference + let referencesOperationID = gitHistoryOperationID(kind: "references", generation: generation) + let pageOperationID = gitHistoryOperationID(kind: "page", generation: generation) + activeGitHistoryOperationIDs.formUnion([referencesOperationID, pageOperationID]) isLoadingGitHistory = true let previousCommitHash = selectedGitCommit?.hash - let snapshot = await service.history( + async let references = service.references( + at: gitRepositoryRoot, + operationID: referencesOperationID + ) + async let page = service.historyPage( at: gitRepositoryRoot, - reference: selectedGitReference, - limit: gitHistoryLimit + reference: selectedReference, + cursor: nil, + limit: Self.gitHistoryPageSize, + operationID: pageOperationID ) - gitReferences = snapshot.references - recentGitReferences = snapshot.recentReferences - gitCommits = snapshot.commits - gitIdentity = snapshot.identity - canLoadMoreGitHistory = snapshot.hasMore - - let nextCommit = snapshot.commits.first(where: { $0.hash == previousCommitHash }) - ?? snapshot.commits.first + let (referenceSnapshot, historyPage) = await (references, page) + activeGitHistoryOperationIDs.subtract([referencesOperationID, pageOperationID]) + guard gitHistoryGeneration == generation, + self.gitRepositoryRoot == gitRepositoryRoot, + selectedGitReference == selectedReference else { + if let cursor = historyPage?.nextCursor { + service.closeHistoryCursor(at: gitRepositoryRoot, cursor: cursor) + } + return + } isLoadingGitHistory = false + guard let historyPage else { return } + + if let referenceSnapshot { + gitReferences = referenceSnapshot.references + recentGitReferences = referenceSnapshot.recentReferences + gitIdentity = referenceSnapshot.identity + } + gitCommits = historyPage.commits + gitHistoryCursor = historyPage.nextCursor + canLoadMoreGitHistory = historyPage.hasMore + + let nextCommit = historyPage.commits.first(where: { $0.hash == previousCommitHash }) + ?? historyPage.commits.first if let nextCommit { if previousCommitHash == nextCommit.hash { selectedGitCommit = nextCommit @@ -1365,11 +1395,61 @@ package final class GitFeatureModel: ObservableObject { } package func loadMoreGitHistory() async { - guard canLoadMoreGitHistory, !isLoadingGitHistory else { return } + guard let gitRepositoryRoot, + let cursor = gitHistoryCursor, + canLoadMoreGitHistory, + !isLoadingGitHistory, + !isLoadingMoreGitHistory else { return } + let generation = gitHistoryGeneration + let selectedReference = selectedGitReference + let operationID = gitHistoryOperationID(kind: "page-more", generation: generation) + activeGitHistoryOperationIDs.insert(operationID) isLoadingMoreGitHistory = true - defer { isLoadingMoreGitHistory = false } - gitHistoryLimit += 300 - await refreshGitHistory() + gitHistoryCursor = nil + let page = await service.historyPage( + at: gitRepositoryRoot, + reference: selectedReference, + cursor: cursor, + limit: Self.gitHistoryPageSize, + operationID: operationID + ) + activeGitHistoryOperationIDs.remove(operationID) + guard gitHistoryGeneration == generation, + self.gitRepositoryRoot == gitRepositoryRoot, + selectedGitReference == selectedReference else { + if let cursor = page?.nextCursor { + service.closeHistoryCursor(at: gitRepositoryRoot, cursor: cursor) + } + return + } + isLoadingMoreGitHistory = false + guard let page else { + canLoadMoreGitHistory = false + return + } + + let loadedHashes = Set(gitCommits.map(\.hash)) + gitCommits.append(contentsOf: page.commits.filter { !loadedHashes.contains($0.hash) }) + gitHistoryCursor = page.nextCursor + canLoadMoreGitHistory = page.hasMore + } + + package func cancelGitHistoryLoading() { + gitHistoryGeneration = UUID() + if let gitRepositoryRoot, let cursor = gitHistoryCursor { + service.closeHistoryCursor(at: gitRepositoryRoot, cursor: cursor) + } + gitHistoryCursor = nil + for operationID in activeGitHistoryOperationIDs { + service.cancel(operationID: operationID) + } + activeGitHistoryOperationIDs.removeAll() + isLoadingGitHistory = false + isLoadingMoreGitHistory = false + } + + private func gitHistoryOperationID(kind: String, generation: UUID) -> String { + "git-history-\(kind)-\(generation.uuidString)" } package func selectGitCommit(_ commit: GitCommit) async { diff --git a/macos/Sources/LitheGitModule/Models/GitModels.swift b/macos/Sources/LitheGitModule/Models/GitModels.swift index 236b2a4a..52e9c2b0 100644 --- a/macos/Sources/LitheGitModule/Models/GitModels.swift +++ b/macos/Sources/LitheGitModule/Models/GitModels.swift @@ -235,6 +235,34 @@ package struct GitHistorySnapshot: Sendable { } } +package struct GitReferenceSnapshot: Sendable { + package let references: [GitReference] + package let recentReferences: [GitReference] + package let identity: GitIdentity? + + package init( + references: [GitReference], + recentReferences: [GitReference] = [], + identity: GitIdentity? = nil + ) { + self.references = references + self.recentReferences = recentReferences + self.identity = identity + } +} + +package struct GitHistoryPage: Sendable { + package let commits: [GitCommit] + package let nextCursor: String? + package let hasMore: Bool + + package init(commits: [GitCommit], nextCursor: String?, hasMore: Bool) { + self.commits = commits + self.nextCursor = nextCursor + self.hasMore = hasMore + } +} + package struct GitIdentity: Hashable, Sendable { package let name: String? package let email: String? diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index 9c93abf2..61ef08e4 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -60,6 +60,16 @@ package protocol GitOperations: Sendable { reference: GitReference?, limit: Int ) -> GitHistorySnapshot? + func references(at rootURL: URL, operationID: String) -> GitReferenceSnapshot? + func historyPage( + at rootURL: URL, + reference: GitReference?, + cursor: String?, + limit: Int, + operationID: String + ) -> GitHistoryPage? + func closeHistoryCursor(at rootURL: URL, cursor: String) -> Bool + func cancel(operationID: String) -> Bool func files(in commit: GitCommit, at rootURL: URL) -> [GitCommitFile]? func commit(at rootURL: URL, hash: String) -> GitCommit? @@ -121,6 +131,42 @@ package protocol GitOperations: Sendable { func stageAll(at rootURL: URL) -> GitProcessResult? } +package extension GitOperations { + func references(at rootURL: URL, operationID: String) -> GitReferenceSnapshot? { + guard let snapshot = history(at: rootURL, reference: nil, limit: 1) else { return nil } + return GitReferenceSnapshot( + references: snapshot.references, + recentReferences: snapshot.recentReferences, + identity: snapshot.identity + ) + } + + func historyPage( + at rootURL: URL, + reference: GitReference?, + cursor: String?, + limit: Int, + operationID: String + ) -> GitHistoryPage? { + let offset = cursor.flatMap(Int.init) ?? 0 + guard let snapshot = history( + at: rootURL, + reference: reference, + limit: offset + limit + 1 + ) else { return nil } + let page = Array(snapshot.commits.dropFirst(offset).prefix(limit)) + let hasMore = snapshot.commits.count > offset + page.count || snapshot.hasMore + return GitHistoryPage( + commits: page, + nextCursor: hasMore ? String(offset + page.count) : nil, + hasMore: hasMore + ) + } + + func closeHistoryCursor(at rootURL: URL, cursor: String) -> Bool { false } + func cancel(operationID: String) -> Bool { false } +} + package typealias GitWatchContextProviding = LitheCoreContracts.GitWatchContextProviding /// UI-facing Git service. Git command construction, validation, parsing, and @@ -356,6 +402,43 @@ package struct GitService: Sendable { } ?? GitHistorySnapshot(references: [], commits: [], hasMore: false) } + func references( + at repositoryRoot: URL, + operationID: String + ) async -> GitReferenceSnapshot? { + await cancellableRead(operationID: operationID) { + $0.references(at: repositoryRoot, operationID: operationID) + } + } + + func historyPage( + at repositoryRoot: URL, + reference: GitReference?, + cursor: String?, + limit: Int, + operationID: String + ) async -> GitHistoryPage? { + await cancellableRead(operationID: operationID) { + $0.historyPage( + at: repositoryRoot, + reference: reference, + cursor: cursor, + limit: limit, + operationID: operationID + ) + } + } + + @discardableResult + package func closeHistoryCursor(at repositoryRoot: URL, cursor: String) -> Bool { + operations.closeHistoryCursor(at: repositoryRoot, cursor: cursor) + } + + @discardableResult + package func cancel(operationID: String) -> Bool { + operations.cancel(operationID: operationID) + } + func files(in commit: GitCommit, at repositoryRoot: URL) async -> [GitCommitFile]? { await read(priority: .utility) { $0.files(in: commit, at: repositoryRoot) } } @@ -669,4 +752,20 @@ package struct GitService: Sendable { operation(operations) }.value } + + private func cancellableRead( + priority: TaskPriority = .utility, + operationID: String, + _ operation: @escaping @Sendable (any GitOperations) -> T? + ) async -> T? { + let operations = self.operations + let task = Task.detached(priority: priority) { + operation(operations) + } + return await withTaskCancellationHandler { + await task.value + } onCancel: { + _ = operations.cancel(operationID: operationID) + } + } } diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 6ca0bbfc..d1c9eddc 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -356,6 +356,59 @@ struct GitModuleTests { #expect(feature.recentGitReferences.map(\.shortName) == ["main", "feature/recent"]) } + @Test + func gitHistoryAppendsTheNextPageWithoutReplacingEarlierCommits() async { + let root = URL(fileURLWithPath: "/workspace") + let commits = (0..<3).map { index in + GitCommit( + hash: "hash-\(index)", + shortHash: "short-\(index)", + parentHashes: [], + authorName: "Lithe Test", + authorEmail: "test@example.com", + date: "2026/09/01 10:0\(index)", + subject: "commit-\(index)", + decorations: "" + ) + } + let service = GitService(operations: TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: root, branch: "main", changes: []), + referencesValue: GitReferenceSnapshot( + references: [], + recentReferences: [], + identity: GitIdentity(name: "Lithe Test", email: "test@example.com") + ), + historyPageValues: [ + "": GitHistoryPage( + commits: Array(commits.prefix(2)), + nextCursor: "cursor-2", + hasMore: true + ), + "cursor-2": GitHistoryPage( + commits: [commits[2]], + nextCursor: nil, + hasMore: false + ) + ] + )) + let feature = GitFeatureModel(service: service) + feature.configure( + workspaceURLProvider: { root }, + isGitLogVisibleProvider: { true }, + notify: { _ in }, + onStateRefreshed: {} + ) + + await feature.refreshGit() + #expect(feature.gitCommits.map(\.hash) == ["hash-0", "hash-1"]) + #expect(feature.canLoadMoreGitHistory) + + await feature.loadMoreGitHistory() + + #expect(feature.gitCommits.map(\.hash) == ["hash-0", "hash-1", "hash-2"]) + #expect(!feature.canLoadMoreGitHistory) + } + @Test func remoteReferenceActionsPreserveIdentityAndPullStrategy() async { let root = URL(fileURLWithPath: "/workspace") @@ -1465,6 +1518,8 @@ private struct TestGitOperations: GitOperations { private let comparisonDiffDocumentValue: DiffDocument? private let typedComparisonDiffDocumentValue: DiffDocument? private let historyValue: GitHistorySnapshot? + private let referencesValue: GitReferenceSnapshot? + private let historyPageValues: [String: GitHistoryPage]? private let stageResult: GitProcessResult? private let runGate: TestGitRunGate? private let filesRecorder: GitFilesCallRecorder? @@ -1475,6 +1530,8 @@ private struct TestGitOperations: GitOperations { comparisonValue: GitBranchComparison? = nil, typedComparisonValue: GitBranchComparison? = nil, historyValue: GitHistorySnapshot? = nil, + referencesValue: GitReferenceSnapshot? = nil, + historyPageValues: [String: GitHistoryPage]? = nil, filesValue: [GitCommitFile]? = nil, untrackedDiffDocumentValue: DiffDocument? = nil, comparisonDiffDocumentValue: DiffDocument? = nil, @@ -1488,6 +1545,8 @@ private struct TestGitOperations: GitOperations { self.comparisonValue = comparisonValue self.typedComparisonValue = typedComparisonValue self.historyValue = historyValue + self.referencesValue = referencesValue + self.historyPageValues = historyPageValues self.filesValue = filesValue self.untrackedDiffDocumentValue = untrackedDiffDocumentValue self.comparisonDiffDocumentValue = comparisonDiffDocumentValue @@ -1520,6 +1579,34 @@ private struct TestGitOperations: GitOperations { func comparisonDiffDocument(at rootURL: URL, reference: GitReference, targetReference: GitReference?, pathspecs: [String], whitespace: GitDiffWhitespaceMode) -> DiffDocument? { typedComparisonDiffDocumentValue } func applyPatch(_ patch: String, at rootURL: URL, mode: String) -> GitProcessResult? { nil } func history(at rootURL: URL, reference: GitReference?, limit: Int) -> GitHistorySnapshot? { historyValue } + func references(at rootURL: URL, operationID: String) -> GitReferenceSnapshot? { + if let referencesValue { return referencesValue } + guard let historyValue else { return nil } + return GitReferenceSnapshot( + references: historyValue.references, + recentReferences: historyValue.recentReferences, + identity: historyValue.identity + ) + } + func historyPage( + at rootURL: URL, + reference: GitReference?, + cursor: String?, + limit: Int, + operationID: String + ) -> GitHistoryPage? { + if let historyPageValues { return historyPageValues[cursor ?? ""] } + guard let historyValue else { return nil } + let offset = cursor.flatMap(Int.init) ?? 0 + let commits = Array(historyValue.commits.dropFirst(offset).prefix(limit)) + let hasMore = historyValue.commits.count > offset + commits.count || historyValue.hasMore + return GitHistoryPage( + commits: commits, + nextCursor: hasMore ? String(offset + commits.count) : nil, + hasMore: hasMore + ) + } + func cancel(operationID: String) -> Bool { false } func files(in commit: GitCommit, at rootURL: URL) -> [GitCommitFile]? { filesRecorder?.recordCall() if let filesGate { diff --git a/rust/lithe-core/src/git/history.rs b/rust/lithe-core/src/git/history.rs new file mode 100644 index 00000000..afdd1578 --- /dev/null +++ b/rust/lithe-core/src/git/history.rs @@ -0,0 +1,881 @@ +//! Bounded Git reference snapshots and incrementally consumable history pages. + +use super::{ + command_value, execute_git_with_environment, git_process, parse_commit, parse_reference, + readonly_command, validate_root, GitCommandRequest, +}; +use crate::protocol::{ + cancellation, CoreError, ErrorCode, GitCommitResponse, GitHistoryCursorCloseResponse, + GitHistoryPageResponse, GitHistoryResponse, GitReferenceResponse, GitReferencesResponse, +}; +use serde::Deserialize; +use std::io::{BufRead, BufReader, Read}; +use std::process::{Child, ExitStatus, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender}; +use std::sync::{Mutex, OnceLock}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +const DEFAULT_HISTORY_LIMIT: usize = 300; +const MAX_HISTORY_COMMITS: usize = 5_000; +const RECENT_BRANCH_LIMIT: usize = 5; +const RECENT_BRANCH_REFLOG_LIMIT: &str = "100"; +const DEFAULT_BRANCH_FALLBACKS: [&str; 2] = ["main", "master"]; +const HISTORY_CURSOR_IDLE_TTL: Duration = Duration::from_secs(120); +const HISTORY_CURSOR_REAPER_INTERVAL: Duration = Duration::from_secs(1); +const HISTORY_PAGE_DEADLINE: Duration = Duration::from_secs(30); +const HISTORY_STREAM_POLL_INTERVAL: Duration = Duration::from_millis(10); +const MAX_HISTORY_SESSIONS: usize = 8; + +static HISTORY_SESSIONS: OnceLock> = OnceLock::new(); +static HISTORY_REAPER: OnceLock<()> = OnceLock::new(); +static NEXT_HISTORY_CURSOR: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Backward-compatible request for references and the first bounded history page. +pub struct GitHistoryRequest { + pub root: String, + #[serde(default)] + pub reference: Option, + #[serde(default = "default_history_limit")] + pub limit: usize, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request for repository references and identity metadata. +pub struct GitReferencesRequest { + pub root: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request for one bounded page of commit history from an optional reference. +pub struct GitHistoryPageRequest { + pub root: String, + #[serde(default)] + pub reference: Option, + #[serde(default)] + pub cursor: Option, + /// Deprecated compatibility field; new callers omit it and continue with `cursor`. + #[serde(default)] + pub offset: Option, + #[serde(default = "default_history_limit")] + pub limit: usize, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Request to release an incremental history cursor that will not be consumed further. +pub struct GitHistoryCursorCloseRequest { + pub root: String, + pub cursor: String, +} + +fn default_history_limit() -> usize { + DEFAULT_HISTORY_LIMIT +} + +/// Returns the legacy combined history snapshot while newer clients migrate to pages. +pub fn history(request: GitHistoryRequest) -> Result { + let references = references(GitReferencesRequest { + root: request.root.clone(), + })?; + let page = history_page(GitHistoryPageRequest { + root: request.root.clone(), + reference: request.reference, + cursor: None, + offset: None, + limit: request.limit, + })?; + if let Some(cursor) = page.next_cursor.as_deref() { + let _ = close_history_cursor(GitHistoryCursorCloseRequest { + root: request.root, + cursor: cursor.to_string(), + }); + } + Ok(GitHistoryResponse { + references: references.references, + recent_references: references.recent_references, + commits: page.commits, + has_more: page.has_more, + user_name: references.user_name, + user_email: references.user_email, + }) +} + +/// Returns references separately so paging and branch selection do not rescan them. +pub fn references(request: GitReferencesRequest) -> Result { + let root = validate_root(&request.root)?; + let user_name = git_config_value(&root, "user.name"); + let user_email = git_config_value(&root, "user.email"); + let reference_arguments = vec![ + "for-each-ref".to_string(), + "--sort=refname".to_string(), + "--format=%(refname)\t%(refname:short)\t%(HEAD)\t%(upstream:short)\t%(upstream)\t%(upstream:track,nobracket)" + .to_string(), + "refs/heads".to_string(), + ]; + // `upstream:track` is evaluated independently for each local branch. A fixed + // locale keeps its machine-parsed ahead/behind labels deterministic. + let reference_output = execute_git_with_environment( + &root, + &reference_arguments, + None, + true, + &[("LC_ALL".to_string(), "C".to_string())], + )?; + if reference_output.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git references failed") + .with_details(reference_output.output), + ); + } + + let mut references = reference_output + .output + .lines() + .filter_map(parse_reference) + .collect::>(); + let nonlocal_reference_output = readonly_command(GitCommandRequest { + root: root.clone(), + arguments: vec![ + "for-each-ref".to_string(), + "--sort=refname".to_string(), + "--format=%(refname)\t%(refname:short)\t%(HEAD)\t%(upstream:short)\t%(upstream)" + .to_string(), + "refs/remotes".to_string(), + "refs/tags".to_string(), + ], + input: None, + })?; + if nonlocal_reference_output.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git references failed") + .with_details(nonlocal_reference_output.output), + ); + } + references.extend( + nonlocal_reference_output + .output + .lines() + .filter_map(parse_reference), + ); + let recent_references = recent_local_references(&root, &references, RECENT_BRANCH_LIMIT); + Ok(GitReferencesResponse { + references, + recent_references, + user_name, + user_email, + }) +} + +/// Returns the next page from one bounded Git log stream. +pub fn history_page(request: GitHistoryPageRequest) -> Result { + if request.cursor.is_some() && request.offset.is_some() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git history page cannot combine cursor and offset", + )); + } + if let Some(offset) = request.offset { + return offset_history_page(request.root, request.reference, offset, request.limit); + } + let root = validate_root(&request.root)?; + validate_reference(request.reference.as_deref())?; + cleanup_expired_history_sessions(); + + let (mut session, lease) = if let Some(cursor) = request.cursor.as_deref() { + take_history_session(cursor)?.ok_or_else(|| invalid_history_cursor(cursor))? + } else { + let lease = reserve_history_session_slot()?; + let session = HistorySession::start(root.clone(), request.reference.clone())?; + (session, lease) + }; + if session.root != root || session.reference != request.reference { + let cursor = session.cursor.clone(); + lease.store(session)?; + return Err(invalid_history_cursor(&cursor)); + } + + let limit = request.limit.clamp( + 1, + MAX_HISTORY_COMMITS.saturating_sub(session.emitted).max(1), + ); + let page = session.read_page(limit); + match page { + Ok((commits, has_more)) => { + let next_cursor = has_more.then(|| session.cursor.clone()); + if has_more { + session.last_access = Instant::now(); + lease.store(session)?; + } else { + session.stop(); + } + Ok(GitHistoryPageResponse { + commits, + next_cursor, + next_offset: None, + has_more, + }) + } + Err(error) => { + session.stop(); + Err(error) + } + } +} + +/// Preserves the original offset contract for callers that have not migrated to cursors. +fn offset_history_page( + root: String, + reference: Option, + offset: usize, + requested_limit: usize, +) -> Result { + if offset >= MAX_HISTORY_COMMITS { + return Ok(GitHistoryPageResponse { + commits: Vec::new(), + next_cursor: None, + next_offset: None, + has_more: false, + }); + } + validate_reference(reference.as_deref())?; + let root = validate_root(&root)?; + let limit = requested_limit.clamp(1, MAX_HISTORY_COMMITS - offset); + let mut arguments = vec!["log".to_string()]; + if let Some(reference) = reference { + arguments.push(reference); + } else { + arguments.push("--all".to_string()); + } + arguments.extend([ + "--topo-order".to_string(), + "--decorate=short".to_string(), + "--skip".to_string(), + offset.to_string(), + "-n".to_string(), + limit.saturating_add(1).to_string(), + "--date=format:%Y/%m/%d %H:%M".to_string(), + "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D".to_string(), + ]); + let output = readonly_command(GitCommandRequest { + root, + arguments, + input: None, + })?; + if output.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git history failed") + .with_details(output.output), + ); + } + let mut commits = output + .output + .lines() + .filter_map(parse_commit) + .collect::>(); + let has_more = commits.len() > limit && offset.saturating_add(limit) < MAX_HISTORY_COMMITS; + commits.truncate(limit); + let next_offset = has_more.then_some(offset.saturating_add(commits.len())); + Ok(GitHistoryPageResponse { + commits, + next_cursor: None, + next_offset, + has_more, + }) +} + +/// Releases a cursor early so its Git child process and reader threads can terminate. +pub fn close_history_cursor( + request: GitHistoryCursorCloseRequest, +) -> Result { + let root = validate_root(&request.root)?; + cleanup_expired_history_sessions(); + let session = { + let mut registry = history_sessions() + .lock() + .map_err(history_session_lock_error)?; + if registry + .sessions + .get(&request.cursor) + .is_some_and(|session| session.root != root) + { + return Err(invalid_history_cursor(&request.cursor)); + } + registry.sessions.remove(&request.cursor) + }; + let closed = session.is_some(); + if let Some(session) = session { + session.stop(); + } + Ok(GitHistoryCursorCloseResponse { closed }) +} + +enum HistoryStreamMessage { + Line(String), + ReadFailed(String), + End, +} + +struct HistorySession { + cursor: String, + root: String, + reference: Option, + child: Option, + receiver: Option>, + stdout_reader: Option>, + stderr_reader: Option>>, + pending: Option, + emitted: usize, + last_access: Instant, + finished: bool, +} + +#[derive(Default)] +struct HistorySessionRegistry { + sessions: std::collections::HashMap, + /// Sessions that are starting or temporarily checked out for a page read. + in_flight: usize, +} + +/// Accounts for a live session while it is outside the registry map. +struct HistorySessionLease { + active: bool, +} + +impl HistorySessionLease { + fn new() -> Self { + Self { active: true } + } + + fn store(mut self, session: HistorySession) -> Result<(), CoreError> { + let mut registry = history_sessions() + .lock() + .map_err(history_session_lock_error)?; + registry.in_flight = registry.in_flight.saturating_sub(1); + registry.sessions.insert(session.cursor.clone(), session); + self.active = false; + Ok(()) + } +} + +impl Drop for HistorySessionLease { + fn drop(&mut self) { + if !self.active { + return; + } + if let Ok(mut registry) = history_sessions().lock() { + registry.in_flight = registry.in_flight.saturating_sub(1); + } + } +} + +impl HistorySession { + fn start(root: String, reference: Option) -> Result { + cancellation::check()?; + let mut arguments = vec!["log".to_string()]; + if let Some(reference) = reference.as_deref() { + arguments.push(reference.to_string()); + } else { + arguments.push("--all".to_string()); + } + arguments.extend([ + "--topo-order".to_string(), + "--decorate=short".to_string(), + "-n".to_string(), + MAX_HISTORY_COMMITS.to_string(), + "--date=format:%Y/%m/%d %H:%M".to_string(), + "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ae%x1f%ad%x1f%s%x1f%D".to_string(), + ]); + let mut process = git_process(); + let mut child = process + .args(&arguments) + .current_dir(&root) + .env("GIT_OPTIONAL_LOCKS", "0") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| { + CoreError::new(ErrorCode::ProcessStartFailed, "Could not start Git history") + .with_details(error.to_string()) + })?; + let stdout = child.stdout.take().ok_or_else(|| { + CoreError::new( + ErrorCode::ProcessFailed, + "Git history stdout was unavailable", + ) + })?; + let mut stderr = child.stderr.take().ok_or_else(|| { + CoreError::new( + ErrorCode::ProcessFailed, + "Git history stderr was unavailable", + ) + })?; + // A single-slot channel propagates backpressure to Git instead of buffering the + // entire repository history between page requests. + let (sender, receiver) = mpsc::sync_channel(1); + let stdout_reader = thread::spawn(move || read_history_stream(stdout, sender)); + let stderr_reader = thread::spawn(move || { + let mut bytes = Vec::new(); + let _ = stderr.read_to_end(&mut bytes); + bytes + }); + let cursor = format!( + "git-history-cursor-{}", + NEXT_HISTORY_CURSOR.fetch_add(1, Ordering::Relaxed) + ); + Ok(Self { + cursor, + root, + reference, + child: Some(child), + receiver: Some(receiver), + stdout_reader: Some(stdout_reader), + stderr_reader: Some(stderr_reader), + pending: None, + emitted: 0, + last_access: Instant::now(), + finished: false, + }) + } + + fn read_page(&mut self, limit: usize) -> Result<(Vec, bool), CoreError> { + let deadline = Instant::now() + HISTORY_PAGE_DEADLINE; + let mut commits = Vec::with_capacity(limit); + if let Some(commit) = self.pending.take() { + commits.push(commit); + } + while commits.len() < limit && self.emitted + commits.len() < MAX_HISTORY_COMMITS { + let Some(commit) = self.next_commit(deadline)? else { + break; + }; + commits.push(commit); + } + self.emitted += commits.len(); + let has_more = if self.emitted >= MAX_HISTORY_COMMITS || self.finished { + false + } else { + self.pending = self.next_commit(deadline)?; + self.pending.is_some() + }; + Ok((commits, has_more)) + } + + fn next_commit(&mut self, deadline: Instant) -> Result, CoreError> { + loop { + cancellation::check()?; + if Instant::now() >= deadline { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Git history page timed out", + )); + } + let message = self + .receiver + .as_ref() + .ok_or_else(|| { + CoreError::new( + ErrorCode::ProcessFailed, + "Git history stream was unavailable", + ) + })? + .recv_timeout(HISTORY_STREAM_POLL_INTERVAL); + match message { + Ok(HistoryStreamMessage::Line(line)) => { + if let Some(commit) = parse_commit(&line) { + return Ok(Some(commit)); + } + } + Ok(HistoryStreamMessage::ReadFailed(details)) => { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Could not read Git history", + ) + .with_details(details)); + } + Ok(HistoryStreamMessage::End) | Err(RecvTimeoutError::Disconnected) => { + self.finish_process(false)?; + return Ok(None); + } + Err(RecvTimeoutError::Timeout) => {} + } + } + } + + fn finish_process(&mut self, terminate: bool) -> Result<(), CoreError> { + if self.finished { + return Ok(()); + } + self.finished = true; + self.receiver.take(); + let status = if let Some(mut child) = self.child.take() { + if terminate { + let _ = child.kill(); + } + child.wait().map_err(|error| { + CoreError::new( + ErrorCode::ProcessFailed, + "Could not read Git history status", + ) + .with_details(error.to_string()) + })? + } else { + return Ok(()); + }; + if let Some(reader) = self.stdout_reader.take() { + let _ = reader.join(); + } + let stderr = self + .stderr_reader + .take() + .and_then(|reader| reader.join().ok()) + .unwrap_or_default(); + validate_history_exit(status, stderr, terminate) + } + + fn stop(mut self) { + let _ = self.finish_process(true); + } +} + +impl Drop for HistorySession { + fn drop(&mut self) { + let _ = self.finish_process(true); + } +} + +fn read_history_stream(stdout: impl Read, sender: SyncSender) { + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) => { + let _ = sender.send(HistoryStreamMessage::End); + break; + } + Ok(_) => { + let normalized = line.trim_end_matches(['\r', '\n']).to_string(); + if sender.send(HistoryStreamMessage::Line(normalized)).is_err() { + break; + } + } + Err(error) => { + let _ = sender.send(HistoryStreamMessage::ReadFailed(error.to_string())); + break; + } + } + } +} + +fn validate_history_exit( + status: ExitStatus, + stderr: Vec, + terminated: bool, +) -> Result<(), CoreError> { + if status.success() || terminated { + return Ok(()); + } + Err( + CoreError::new(ErrorCode::ProcessFailed, "Git history failed") + .with_details(String::from_utf8_lossy(&stderr).trim().to_string()), + ) +} + +fn validate_reference(reference: Option<&str>) -> Result<(), CoreError> { + if reference.is_some_and(|value| value.starts_with('-') || value.contains('\0')) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git reference", + )); + } + Ok(()) +} + +fn history_sessions() -> &'static Mutex { + let registry = HISTORY_SESSIONS.get_or_init(|| Mutex::new(HistorySessionRegistry::default())); + HISTORY_REAPER.get_or_init(|| { + thread::Builder::new() + .name("lithe-git-history-reaper".to_string()) + .spawn(|| loop { + thread::sleep(HISTORY_CURSOR_REAPER_INTERVAL); + cleanup_expired_history_sessions(); + }) + .expect("Git history reaper should start"); + }); + registry +} + +fn history_session_lock_error(error: std::sync::PoisonError) -> CoreError { + CoreError::new( + ErrorCode::ProcessFailed, + "Git history cursor state was unavailable", + ) + .with_details(error.to_string()) +} + +fn take_history_session( + cursor: &str, +) -> Result, CoreError> { + let mut registry = history_sessions() + .lock() + .map_err(history_session_lock_error)?; + let Some(session) = registry.sessions.remove(cursor) else { + return Ok(None); + }; + registry.in_flight += 1; + Ok(Some((session, HistorySessionLease::new()))) +} + +fn cleanup_expired_history_sessions() { + let expired = history_sessions().lock().ok().map(|mut registry| { + take_expired_history_sessions(&mut registry, Instant::now(), HISTORY_CURSOR_IDLE_TTL) + }); + for session in expired.unwrap_or_default() { + session.stop(); + } +} + +fn take_expired_history_sessions( + registry: &mut HistorySessionRegistry, + now: Instant, + ttl: Duration, +) -> Vec { + let cursors = registry + .sessions + .iter() + .filter(|(_, session)| now.saturating_duration_since(session.last_access) >= ttl) + .map(|(cursor, _)| cursor.clone()) + .collect::>(); + cursors + .into_iter() + .filter_map(|cursor| registry.sessions.remove(&cursor)) + .collect() +} + +fn reserve_history_session_slot() -> Result { + let evicted = { + let mut registry = history_sessions() + .lock() + .map_err(history_session_lock_error)?; + reserve_history_session_slot_from(&mut registry)? + }; + for session in evicted { + session.stop(); + } + Ok(HistorySessionLease::new()) +} + +fn reserve_history_session_slot_from( + registry: &mut HistorySessionRegistry, +) -> Result, CoreError> { + let mut evicted = Vec::new(); + while registry.sessions.len() + registry.in_flight >= MAX_HISTORY_SESSIONS { + let Some(cursor) = registry + .sessions + .iter() + .min_by_key(|(_, session)| session.last_access) + .map(|(cursor, _)| cursor.clone()) + else { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Too many Git history sessions are active", + )); + }; + if let Some(session) = registry.sessions.remove(&cursor) { + evicted.push(session); + } + } + registry.in_flight += 1; + Ok(evicted) +} + +fn invalid_history_cursor(cursor: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git history cursor") + .with_details(cursor.to_string()) +} + +/// Builds a bounded MRU list from Git's own checkout history. +fn recent_local_references( + root: &str, + references: &[GitReferenceResponse], + limit: usize, +) -> Vec { + let local_references = references + .iter() + .filter(|reference| reference.kind == "local") + .collect::>(); + let mut recent = Vec::with_capacity(limit.min(local_references.len())); + + if let Some(current) = local_references + .iter() + .find(|reference| reference.is_current) + { + append_recent_reference(&mut recent, &local_references, ¤t.short_name, limit); + } + + if let Some(reflog) = command_value( + root, + &[ + "reflog", + "show", + "-n", + RECENT_BRANCH_REFLOG_LIMIT, + "--format=%gs", + "HEAD", + ], + ) { + for line in reflog.lines() { + let Some(checkout) = line.strip_prefix("checkout: moving from ") else { + continue; + }; + let Some((source, destination)) = checkout.split_once(" to ") else { + continue; + }; + append_recent_reference(&mut recent, &local_references, destination, limit); + append_recent_reference(&mut recent, &local_references, source, limit); + if recent.len() >= limit { + break; + } + } + } + + if recent.len() < limit { + if let Some(remote_head) = command_value( + root, + &[ + "symbolic-ref", + "--quiet", + "--short", + "refs/remotes/origin/HEAD", + ], + ) { + append_recent_reference( + &mut recent, + &local_references, + remote_head + .split_once('/') + .map_or(remote_head.as_str(), |(_, branch)| branch), + limit, + ); + } + } + for branch in DEFAULT_BRANCH_FALLBACKS { + append_recent_reference(&mut recent, &local_references, branch, limit); + } + + for reference in &local_references { + append_recent_reference(&mut recent, &local_references, &reference.short_name, limit); + if recent.len() >= limit { + break; + } + } + + recent.into_iter().cloned().collect() +} + +fn append_recent_reference<'a>( + recent: &mut Vec<&'a GitReferenceResponse>, + references: &[&'a GitReferenceResponse], + raw_name: &str, + limit: usize, +) { + if recent.len() >= limit { + return; + } + let name = raw_name.trim().trim_start_matches("refs/heads/"); + let Some(reference) = references + .iter() + .find(|reference| reference.short_name == name) + else { + return; + }; + if !recent + .iter() + .any(|existing| existing.full_name == reference.full_name) + { + recent.push(*reference); + } +} + +/// Reads one optional repository configuration value without failing the snapshot. +fn git_config_value(root: &str, key: &str) -> Option { + let response = readonly_command(GitCommandRequest { + root: root.to_string(), + arguments: vec!["config".to_string(), "--get".to_string(), key.to_string()], + input: None, + }) + .ok()?; + if response.exit_code != 0 { + return None; + } + let value = response.output.trim(); + (!value.is_empty()).then(|| value.to_string()) +} + +#[cfg(test)] +mod tests { + use super::{ + reserve_history_session_slot_from, take_expired_history_sessions, HistorySession, + HistorySessionRegistry, MAX_HISTORY_SESSIONS, + }; + use crate::protocol::ErrorCode; + use std::time::{Duration, Instant}; + + fn finished_session(cursor: &str, last_access: Instant) -> HistorySession { + HistorySession { + cursor: cursor.to_string(), + root: "/test/repository".to_string(), + reference: None, + child: None, + receiver: None, + stdout_reader: None, + stderr_reader: None, + pending: None, + emitted: 0, + last_access, + finished: true, + } + } + + #[test] + fn expired_sessions_are_removed_without_touching_recent_sessions() { + let now = Instant::now(); + let mut registry = HistorySessionRegistry::default(); + registry.sessions.insert( + "expired".to_string(), + finished_session("expired", now - Duration::from_secs(121)), + ); + registry.sessions.insert( + "recent".to_string(), + finished_session("recent", now - Duration::from_secs(119)), + ); + + let expired = take_expired_history_sessions(&mut registry, now, Duration::from_secs(120)); + + assert_eq!(expired.len(), 1); + assert_eq!(expired[0].cursor, "expired"); + assert!(registry.sessions.contains_key("recent")); + } + + #[test] + fn session_limit_counts_requests_that_are_still_in_flight() { + let mut registry = HistorySessionRegistry { + sessions: Default::default(), + in_flight: MAX_HISTORY_SESSIONS, + }; + + let error = match reserve_history_session_slot_from(&mut registry) { + Ok(_) => panic!("a ninth in-flight session should be rejected"), + Err(error) => error, + }; + + assert!(matches!(error.code, ErrorCode::ProcessFailed)); + assert_eq!(registry.in_flight, MAX_HISTORY_SESSIONS); + } +} diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 4f5328cd..cf4034e7 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -1,15 +1,21 @@ //! Deterministic Git inspection and mutation behind the shared command contract. +mod history; mod mutations; +pub use history::{ + close_history_cursor, history, history_page, references, GitHistoryCursorCloseRequest, + GitHistoryPageRequest, GitHistoryRequest, GitReferencesRequest, +}; + use crate::protocol::{CoreError, ErrorCode}; use crate::protocol::{ GitBlameLineResponse, GitBlameResponse, GitChange, GitCheckoutPreflightResponse, GitCommitLookupResponse, GitCommitResponse, GitComparisonResponse, GitConflictMarkerResponse, GitDiffHunkResponse, GitDiffResponse, GitDiffRowResponse, GitFileResponse, GitFilesResponse, - GitHistoryResponse, GitIntegrationPreflightResponse, GitOperationStateResponse, - GitPullPreflightResponse, GitPushPreviewResponse, GitPushTagResponse, GitReferenceResponse, - GitStashResponse, GitStashesResponse, GitStatusResponse, GitWatchContextResponse, + GitIntegrationPreflightResponse, GitOperationStateResponse, GitPullPreflightResponse, + GitPushPreviewResponse, GitPushTagResponse, GitReferenceResponse, GitStashResponse, + GitStashesResponse, GitStatusResponse, GitWatchContextResponse, }; use serde::{Deserialize, Serialize}; use std::cell::RefCell; @@ -24,9 +30,6 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::thread; use std::time::Duration; -const RECENT_BRANCH_LIMIT: usize = 5; -const RECENT_BRANCH_REFLOG_LIMIT: &str = "100"; -const DEFAULT_BRANCH_FALLBACKS: [&str; 2] = ["main", "master"]; const DEFAULT_PUSH_PREVIEW_LIMIT: usize = 500; static TEMPORARY_INDEX_SEQUENCE: AtomicU64 = AtomicU64::new(0); static AUTO_STASH_SEQUENCE: AtomicU64 = AtomicU64::new(0); @@ -508,17 +511,6 @@ pub struct GitApplyRequest { pub mode: String, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -/// Request for bounded commit history from an optional reference. -pub struct GitHistoryRequest { - pub root: String, - #[serde(default)] - pub reference: Option, - #[serde(default = "default_history_limit")] - pub limit: usize, -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] /// Request for metadata and parent information about one commit. @@ -613,10 +605,6 @@ fn default_review_context_lines() -> usize { 80 } -fn default_history_limit() -> usize { - 300 -} - fn default_push_preview_limit() -> usize { DEFAULT_PUSH_PREVIEW_LIMIT } @@ -1125,7 +1113,7 @@ fn capture_git_with_environment( }) } -fn git_process() -> Command { +pub(super) fn git_process() -> Command { #[cfg(target_os = "windows")] { let mut process = Command::new("git"); @@ -1474,88 +1462,6 @@ pub fn apply(request: GitApplyRequest) -> Result }) } -/// Returns bounded commit history without relying on localized display output. -pub fn history(request: GitHistoryRequest) -> Result { - let limit = request.limit.clamp(1, 5_000); - let root = validate_root(&request.root)?; - let user_name = git_config_value(&root, "user.name"); - let user_email = git_config_value(&root, "user.email"); - let reference_arguments = vec![ - "for-each-ref".to_string(), - "--sort=refname".to_string(), - "--format=%(refname)\t%(refname:short)\t%(HEAD)\t%(upstream:short)\t%(upstream)\t%(upstream:track,nobracket)" - .to_string(), - "refs/heads".to_string(), - ]; - // `upstream:track` is evaluated independently for each enumerated branch. - // A fixed C locale keeps its machine-parsed labels deterministic. - let reference_output = execute_git_with_environment( - &root, - &reference_arguments, - None, - true, - &[("LC_ALL".to_string(), "C".to_string())], - )?; - if reference_output.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git references failed") - .with_details(reference_output.output), - ); - } - - let mut references = reference_output - .output - .lines() - .filter_map(parse_reference) - .collect::>(); - let nonlocal_reference_output = readonly_command(GitCommandRequest { - root: root.clone(), - arguments: vec![ - "for-each-ref".to_string(), - "--sort=refname".to_string(), - "--format=%(refname)\t%(refname:short)\t%(HEAD)\t%(upstream:short)\t%(upstream)" - .to_string(), - "refs/remotes".to_string(), - "refs/tags".to_string(), - ], - input: None, - })?; - if nonlocal_reference_output.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git references failed") - .with_details(nonlocal_reference_output.output), - ); - } - references.extend( - nonlocal_reference_output - .output - .lines() - .filter_map(parse_reference), - ); - let recent_references = recent_local_references(&root, &references, RECENT_BRANCH_LIMIT); - - let selectors = if let Some(reference) = request.reference { - if reference.starts_with('-') || reference.contains('\0') { - return Err(CoreError::new( - ErrorCode::InvalidRequest, - "Invalid Git reference", - )); - } - vec![reference] - } else { - vec!["--all".to_string()] - }; - let (commits, has_more) = read_commit_log(&root, selectors, limit, "Git history failed")?; - Ok(GitHistoryResponse { - references, - recent_references, - commits, - has_more, - user_name, - user_email, - }) -} - fn read_commit_log( root: &str, selectors: Vec, @@ -1591,129 +1497,6 @@ fn read_commit_log( Ok((all_commits.into_iter().take(limit).collect(), has_more)) } -/// Builds a bounded MRU list from Git's own checkout history. -/// -/// HEAD's reflog survives application restarts and also observes branch switches -/// made outside Lithe. Missing history is filled deterministically so a newly -/// opened repository still offers useful branch shortcuts. -fn recent_local_references( - root: &str, - references: &[GitReferenceResponse], - limit: usize, -) -> Vec { - let local_references = references - .iter() - .filter(|reference| reference.kind == "local") - .collect::>(); - let mut recent = Vec::with_capacity(limit.min(local_references.len())); - - if let Some(current) = local_references - .iter() - .find(|reference| reference.is_current) - { - append_recent_reference(&mut recent, &local_references, ¤t.short_name, limit); - } - - if let Some(reflog) = command_value( - root, - &[ - "reflog", - "show", - "-n", - RECENT_BRANCH_REFLOG_LIMIT, - "--format=%gs", - "HEAD", - ], - ) { - for line in reflog.lines() { - let Some(checkout) = line.strip_prefix("checkout: moving from ") else { - continue; - }; - let Some((source, destination)) = checkout.split_once(" to ") else { - continue; - }; - append_recent_reference(&mut recent, &local_references, destination, limit); - append_recent_reference(&mut recent, &local_references, source, limit); - if recent.len() >= limit { - break; - } - } - } - - if recent.len() < limit { - if let Some(remote_head) = command_value( - root, - &[ - "symbolic-ref", - "--quiet", - "--short", - "refs/remotes/origin/HEAD", - ], - ) { - append_recent_reference( - &mut recent, - &local_references, - remote_head - .split_once('/') - .map_or(remote_head.as_str(), |(_, branch)| branch), - limit, - ); - } - } - for branch in DEFAULT_BRANCH_FALLBACKS { - append_recent_reference(&mut recent, &local_references, branch, limit); - } - - for reference in &local_references { - append_recent_reference(&mut recent, &local_references, &reference.short_name, limit); - if recent.len() >= limit { - break; - } - } - - recent.into_iter().cloned().collect() -} - -fn append_recent_reference<'a>( - recent: &mut Vec<&'a GitReferenceResponse>, - references: &[&'a GitReferenceResponse], - raw_name: &str, - limit: usize, -) { - if recent.len() >= limit { - return; - } - let name = raw_name.trim().trim_start_matches("refs/heads/"); - let Some(reference) = references - .iter() - .find(|reference| reference.short_name == name) - else { - return; - }; - if !recent - .iter() - .any(|existing| existing.full_name == reference.full_name) - { - recent.push(*reference); - } -} - -/// Reads one effective repository configuration value without making a missing -/// optional value fail the surrounding history request. -fn git_config_value(root: &str, key: &str) -> Option { - let response = readonly_command(GitCommandRequest { - root: root.to_string(), - arguments: vec!["config".to_string(), "--get".to_string(), key.to_string()], - input: None, - }) - .ok()?; - if response.exit_code != 0 { - return None; - } - let value = response.output.trim(); - (!value.is_empty()).then(|| value.to_string()) -} - /// Resolves one commit and its parent metadata. pub fn commit(request: GitCommitRequest) -> Result { let root = validate_root(&request.root)?; @@ -5600,8 +5383,8 @@ mod tests { GitCommandInvocation, GitCommandResponse, GitProcessOutput, MAX_ALIGNMENT_CELLS, }; use crate::protocol::{ - CoreError, ErrorCode, GitCommitResponse, GitHistoryResponse, GitPushPreviewResponse, - GitPushTagResponse, GitReferenceResponse, + CoreError, ErrorCode, GitCommitResponse, GitHistoryPageResponse, GitHistoryResponse, + GitPushPreviewResponse, GitPushTagResponse, GitReferenceResponse, GitReferencesResponse, }; use serde_json::Value; @@ -5893,6 +5676,73 @@ mod tests { ); } + #[test] + fn references_response_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/git/references-response-v1.json" + ))) + .expect("Git references response fixture should be valid JSON"); + let feature = GitReferenceResponse { + full_name: "refs/heads/feature/recent".into(), + short_name: "feature/recent".into(), + kind: "local".into(), + is_current: true, + upstream_short_name: None, + ahead: 0, + behind: 0, + }; + let main = GitReferenceResponse { + full_name: "refs/heads/main".into(), + short_name: "main".into(), + kind: "local".into(), + is_current: false, + upstream_short_name: Some("origin/main".into()), + ahead: 2, + behind: 1, + }; + let response = GitReferencesResponse { + references: vec![feature.clone(), main.clone()], + recent_references: vec![feature, main], + user_name: Some("Lithe Test".into()), + user_email: Some("test@example.invalid".into()), + }; + + assert_eq!( + serde_json::to_value(response).expect("Git references response should serialize"), + fixture + ); + } + + #[test] + fn history_page_response_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/git/history-page-response-v1.json" + ))) + .expect("Git history page response fixture should be valid JSON"); + let response = GitHistoryPageResponse { + commits: vec![GitCommitResponse { + hash: "0123456789abcdef0123456789abcdef01234567".into(), + short_hash: "0123456".into(), + parent_hashes: Vec::new(), + author_name: "Lithe Test".into(), + author_email: "test@example.invalid".into(), + date: "2026/08/30 12:00".into(), + subject: "Initial commit".into(), + decorations: "HEAD -> feature/recent".into(), + }], + next_cursor: Some("git-history-cursor-fixture".into()), + next_offset: None, + has_more: true, + }; + + assert_eq!( + serde_json::to_value(response).expect("Git history page response should serialize"), + fixture + ); + } + #[test] fn push_preview_response_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 624fa88d..6563881a 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -207,6 +207,12 @@ pub enum CoreCommand { GitApply, /// Lists references and bounded commit history (`git.history`). GitHistory, + /// Lists Git references without repeating commit history (`git.references`). + GitReferences, + /// Returns one cursor-based commit page (`git.historyPage`). + GitHistoryPage, + /// Releases an incremental history cursor (`git.historyCursorClose`). + GitHistoryCursorClose, /// Resolves the destination and commits for a branch push (`git.pushPreview`). GitPushPreview, /// Resolves metadata for one commit (`git.commit`). @@ -331,6 +337,9 @@ impl CoreCommand { "git.diff" => Some(Self::GitDiff), "git.apply" => Some(Self::GitApply), "git.history" => Some(Self::GitHistory), + "git.references" => Some(Self::GitReferences), + "git.historyPage" => Some(Self::GitHistoryPage), + "git.historyCursorClose" => Some(Self::GitHistoryCursorClose), "git.pushPreview" => Some(Self::GitPushPreview), "git.commit" => Some(Self::GitCommit), "git.commitFiles" => Some(Self::GitCommitFiles), diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index eac88a01..215edde7 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -442,6 +442,39 @@ pub struct GitHistoryResponse { pub user_email: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Repository references and identity metadata loaded independently from commit pages. +pub struct GitReferencesResponse { + pub references: Vec, + /// Up to five local branches ordered from most to least recently checked out. + pub recent_references: Vec, + pub user_name: Option, + pub user_email: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One bounded page of commit history. +pub struct GitHistoryPageResponse { + pub commits: Vec, + /// Opaque cursor for the next page, or `None` when this page reaches the end. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + /// Deprecated offset emitted only for compatibility with offset-based requests. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_offset: Option, + pub has_more: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Result of explicitly releasing an incremental Git history cursor. +pub struct GitHistoryCursorCloseResponse { + /// Whether an active cursor was found and released. + pub closed: bool, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Resolved destination and bounded commits for one branch push. diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 537d247c..7b9ad2a3 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -8,9 +8,10 @@ use crate::community::{ use crate::git::{ self, GitApplyRequest, GitBlameRequest, GitCheckoutPreflightRequest, GitCommandRequest, GitCommitFilesRequest, GitCommitRequest, GitComparisonRequest, GitConflictMarkerRequest, - GitDiffRequest, GitHistoryRequest, GitIntegrationPreflightRequest, GitOperationStateRequest, - GitPullPreflightRequest, GitPullRequestContextRequest, GitPushPreviewRequest, - GitStashesRequest, GitStatusRequest, GitWatchContextRequest, GitWriteRequest, + GitDiffRequest, GitHistoryCursorCloseRequest, GitHistoryPageRequest, GitHistoryRequest, + GitIntegrationPreflightRequest, GitOperationStateRequest, GitPullPreflightRequest, + GitPullRequestContextRequest, GitPushPreviewRequest, GitReferencesRequest, GitStashesRequest, + GitStatusRequest, GitWatchContextRequest, GitWriteRequest, }; use crate::github::{NormalizeResponseRequest, ParseRemoteRequest, RequestPlanRequest}; use crate::languages::{ @@ -1519,6 +1520,58 @@ fn execute(request: &str) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::GitReferences => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid Git references request") + .with_details(error.to_string()) + }) + .and_then(git::references) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git references response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::GitHistoryPage => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git history page request", + ) + .with_details(error.to_string()) + }) + .and_then(git::history_page) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git history page response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::GitHistoryCursorClose => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git history cursor close request", + ) + .with_details(error.to_string()) + }) + .and_then(git::close_history_cursor) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Git history cursor close response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::GitPushPreview => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index f32fe04d..773db743 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -2589,6 +2589,129 @@ fn git_history_returns_bounded_recent_checkout_order_and_stable_fallback() { ); } +#[test] +fn git_history_page_returns_disjoint_incremental_pages() { + struct RemoveOnDrop(std::path::PathBuf); + + impl Drop for RemoveOnDrop { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + let root = temporary_root("git-history-pages"); + let _cleanup = RemoveOnDrop(root.clone()); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + for index in 0..4 { + fs::write(root.join("example.txt"), format!("{index}\n")) + .expect("test file should be writable"); + assert!(run(&["add", "example.txt"]).status.success()); + assert!(run(&["commit", "-qm", &format!("commit-{index}")]) + .status + .success()); + } + + let execute_page = |id: &str, cursor: Option<&str>| { + let request = serde_json::json!({ + "id": id, + "command": "git.historyPage", + "payload": { + "root": root, + "reference": "HEAD", + "cursor": cursor, + "limit": 2 + } + }); + serde_json::from_str::(&execute_json( + &serde_json::to_string(&request).expect("history page request should encode"), + )) + .expect("history page response should be JSON") + }; + + let first = execute_page("history-page-1", None); + let cursor = first["data"]["nextCursor"] + .as_str() + .expect("first page should return a cursor") + .to_string(); + let second = execute_page("history-page-2", Some(&cursor)); + let subjects = |response: &Value| { + response["data"]["commits"] + .as_array() + .expect("commits should be an array") + .iter() + .map(|commit| { + commit["subject"] + .as_str() + .expect("commit subject should be text") + .to_string() + }) + .collect::>() + }; + + assert_eq!(subjects(&first), ["commit-3", "commit-2"]); + assert_eq!(first["data"]["nextCursor"], cursor); + assert_eq!(first["data"]["hasMore"], true); + assert_eq!(subjects(&second), ["commit-1", "commit-0"]); + assert_eq!(second["data"]["nextCursor"], Value::Null); + assert_eq!(second["data"]["hasMore"], false); + + let abandoned = execute_page("history-page-abandoned", None); + let abandoned_cursor = abandoned["data"]["nextCursor"] + .as_str() + .expect("unfinished page should return a cursor"); + let close_request = serde_json::json!({ + "id": "history-cursor-close", + "command": "git.historyCursorClose", + "payload": {"root": root, "cursor": abandoned_cursor} + }); + let close: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&close_request).expect("cursor close request should encode"), + )) + .expect("cursor close response should be JSON"); + assert_eq!(close["data"]["closed"], true); + + let legacy_request = serde_json::json!({ + "id": "history-page-legacy", + "command": "git.historyPage", + "payload": {"root": root, "reference": "HEAD", "offset": 0, "limit": 2} + }); + let legacy: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&legacy_request).expect("legacy page request should encode"), + )) + .expect("legacy page response should be JSON"); + assert_eq!(legacy["data"]["nextOffset"], 2); + assert_eq!(legacy["data"].get("nextCursor"), None); + + let references_request = serde_json::json!({ + "id": "references", + "command": "git.references", + "payload": {"root": root} + }); + let references: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&references_request).expect("references request should encode"), + )) + .expect("references response should be JSON"); + assert_eq!(references["data"]["userName"], "Lithe Test"); + assert_eq!(references["data"]["userEmail"], "test@example.com"); + assert!(references["data"]["references"] + .as_array() + .expect("references should be an array") + .iter() + .any(|reference| reference["kind"] == "local")); +} + #[test] fn git_conflict_markers_ignore_markdown_headings() { let root = temporary_root("git-markers"); diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index 06986513..b09e4d1f 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -288,6 +288,13 @@ without leaking an unusable absolute path or macOS security-scoped bookmark. Search and Git examples are kept in `shared/fixtures/`. New behavior should add a fixture before adding a second platform implementation. +Git history clients load reference metadata independently from bounded commit +pages. The first page may load concurrently with references, but later pages +append through Core's opaque `nextCursor` while continuing the same bounded Git +log stream. Changing repositories or references and closing the history view +cancels the owning `operationID` and closes any retained cursor; a late result +cannot replace the active selection and its returned cursor is also closed. + Run configuration behavior is exposed through the `runConfig.*` commands. Platform clients coordinate inspection, generation, resolution, typed document edits, and launch planning, but must not implement a second JSON merger, diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 665fc1ff..f9357ca9 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -144,7 +144,10 @@ stable error code and a user-facing message: | `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 | | `git.apply` | Apply or check a patch in `stage`, `unstage`, `discard`, or Shelf restore mode | -| `git.history` | Return deterministic refs, recent local branches, commits, parent hashes, decorations, and pagination state | +| `git.history` | Return the legacy combined reference snapshot and first bounded commit page | +| `git.references` | Return deterministic refs, recent local branches, ahead/behind state, and effective Git identity without scanning commit history | +| `git.historyPage` | Return one bounded commit page, parent hashes, decorations, and an opaque continuation cursor | +| `git.historyCursorClose` | Release an unfinished incremental history cursor and its Git process | | `git.pushPreview` | Resolve a local branch push destination and the bounded commits not present on that remote base | | `git.commit` | Return one structured commit by revision | | `git.commitFiles` | Return files changed by one commit | @@ -437,14 +440,32 @@ worktree. Pathspecs must be workspace-relative and must not contain absolute paths or `..` components. `git.history` accepts `root`, an optional full `reference`, and `limit` (the -core clamps it to `1...5000`). It returns `references`, `commits`, `hasMore`, -and the optional effective `userName` and `userEmail` from repository Git -configuration. Commit parents are explicit so clients can render merge -topology without re-parsing Git output. The identity fields let clients -implement a stable `me` filter without guessing from recent commits. -Each local reference with an upstream also returns numeric `ahead` and `behind` -counts against that fetched remote-tracking reference. References without an -upstream, remote references, and tags return zero for both fields. +core clamps it to `1...5000`). It remains the compatibility command that +combines `git.references` with the first `git.historyPage`. New clients use +`git.references` with `{ "root": string }` and request commits separately with +`git.historyPage` using `root`, optional full `reference`, nullable opaque +`cursor`, and `limit`. The first request omits `cursor`; each later request +returns the prior page's `nextCursor`. Core keeps one bounded, backpressured +`git log` stream behind that cursor and clamps the stream to the first 5,000 +commits, so later pages continue traversal instead of replaying earlier commits. +A history page returns `commits`, nullable `nextCursor`, and `hasMore`. Clients +call `git.historyCursorClose` with `root` and `cursor` when abandoning an +unfinished stream, and discard and close a late page when its repository, +selected reference, or owning `operationId` is stale. Core also expires idle +cursors and caps the number of live streams. Commit parents are explicit so +clients can render merge topology without re-parsing Git output. The optional +effective `userName` and `userEmail` returned by `git.references` let clients +implement a stable `me` filter without guessing from recent commits. Each local +reference with an upstream also returns numeric `ahead` and `behind` counts +against that fetched remote-tracking reference. References without an upstream, +remote references, and tags return zero for both fields. Portable examples are +`shared/fixtures/git/references-response-v1.json` and +`shared/fixtures/git/history-page-response-v1.json`. + +For compatibility, a request that explicitly contains the deprecated numeric +`offset` field still uses the bounded offset implementation and returns +`nextOffset`. New clients must omit `offset`; repository size does not select +between the two protocols. `git.commit` accepts `root` and a revision, returning one `commit` object. `git.blame` accepts `root` and a workspace-relative `path`; its line numbers diff --git a/shared/fixtures/git/history-page-response-v1.json b/shared/fixtures/git/history-page-response-v1.json new file mode 100644 index 00000000..8d8f14bd --- /dev/null +++ b/shared/fixtures/git/history-page-response-v1.json @@ -0,0 +1,16 @@ +{ + "commits": [ + { + "hash": "0123456789abcdef0123456789abcdef01234567", + "shortHash": "0123456", + "parentHashes": [], + "authorName": "Lithe Test", + "authorEmail": "test@example.invalid", + "date": "2026/08/30 12:00", + "subject": "Initial commit", + "decorations": "HEAD -> feature/recent" + } + ], + "nextCursor": "git-history-cursor-fixture", + "hasMore": true +} diff --git a/shared/fixtures/git/references-response-v1.json b/shared/fixtures/git/references-response-v1.json new file mode 100644 index 00000000..981dfa94 --- /dev/null +++ b/shared/fixtures/git/references-response-v1.json @@ -0,0 +1,44 @@ +{ + "references": [ + { + "fullName": "refs/heads/feature/recent", + "shortName": "feature/recent", + "kind": "local", + "isCurrent": true, + "upstreamShortName": null, + "ahead": 0, + "behind": 0 + }, + { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local", + "isCurrent": false, + "upstreamShortName": "origin/main", + "ahead": 2, + "behind": 1 + } + ], + "recentReferences": [ + { + "fullName": "refs/heads/feature/recent", + "shortName": "feature/recent", + "kind": "local", + "isCurrent": true, + "upstreamShortName": null, + "ahead": 0, + "behind": 0 + }, + { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local", + "isCurrent": false, + "upstreamShortName": "origin/main", + "ahead": 2, + "behind": 1 + } + ], + "userName": "Lithe Test", + "userEmail": "test@example.invalid" +} diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index 43da8d4a..0b73a244 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -115,6 +115,9 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> { "git.blame" } "git_log" | "git_branches" => "git.history", + "git_references" => "git.references", + "git_history_page" => "git.historyPage", + "git_history_cursor_close" => "git.historyCursorClose", "git_get_stashes" => "git.stashes", "git_commit_diff" => { move_field(&mut payload, "commitHash", "commit"); @@ -674,6 +677,54 @@ mod tests { assert_eq!(payload, json!({ "root": "C:/work" })); } + #[test] + fn translates_incremental_git_history_commands() { + let (references_command, references_payload) = translate( + "git_references", + json!({ "repoPath": "C:/work", "operationId": "refs-1" }), + ) + .unwrap(); + assert_eq!(references_command, "git.references"); + assert_eq!( + references_payload, + json!({ "root": "C:/work", "operationId": "refs-1" }) + ); + + let (page_command, page_payload) = translate( + "git_history_page", + json!({ + "repoPath": "C:/work", + "reference": "refs/heads/main", + "cursor": "cursor-50", + "limit": 50, + "operationId": "page-2" + }), + ) + .unwrap(); + assert_eq!(page_command, "git.historyPage"); + assert_eq!( + page_payload, + json!({ + "root": "C:/work", + "reference": "refs/heads/main", + "cursor": "cursor-50", + "limit": 50, + "operationId": "page-2" + }) + ); + + let (close_command, close_payload) = translate( + "git_history_cursor_close", + json!({ "repoPath": "C:/work", "cursor": "cursor-50" }), + ) + .unwrap(); + assert_eq!(close_command, "git.historyCursorClose"); + assert_eq!( + close_payload, + json!({ "root": "C:/work", "cursor": "cursor-50" }) + ); + } + #[test] fn translates_stage_file_to_git_write() { let (command, payload) = translate( diff --git a/windows/tauri/src/features/git/api/git-commits-api.ts b/windows/tauri/src/features/git/api/git-commits-api.ts index f0f84286..460c5f4e 100644 --- a/windows/tauri/src/features/git/api/git-commits-api.ts +++ b/windows/tauri/src/features/git/api/git-commits-api.ts @@ -2,8 +2,10 @@ import { invoke as tauriInvoke } from "@/platform/tauri-core"; import type { GitCommit, GitCommitFile, + GitHistoryPage, GitHistorySnapshot, GitOperationWarning, + GitReferenceSnapshot, } from "../types/git.types"; import { emitGitChanged } from "../events/git-events"; import { runGitRead } from "../runtime/git-read-coordinator"; @@ -106,6 +108,81 @@ export const getGitHistory = async ( export const getGitLog = async (repoPath: string, limit = 50): Promise => (await getGitHistory(repoPath, limit))?.commits ?? []; +export const cancelGitHistoryOperation = async (operationId: string): Promise => { + try { + await tauriInvoke("core_cancel", { operationId }); + } catch (error) { + console.error("Failed to cancel git history operation:", error); + } +}; + +export const getGitReferences = async ( + repoPath: string, + operationId: string, +): Promise => { + try { + const resolvedRepoPath = await resolveRepositoryPath(repoPath); + if (!resolvedRepoPath) return null; + return await runGitRead(resolvedRepoPath, `references:${operationId}`, () => + tauriInvoke("git_references", { + repoPath: resolvedRepoPath, + operationId, + }), + ); + } catch (error) { + if (!isNotGitRepositoryError(error)) console.error("Failed to get git references:", error); + return null; + } +}; + +export const getGitHistoryPage = async ( + repoPath: string, + cursor: string | undefined, + limit: number, + operationId: string, + reference?: string, +): Promise => { + try { + const resolvedRepoPath = await resolveRepositoryPath(repoPath); + if (!resolvedRepoPath) return null; + return await runGitRead( + resolvedRepoPath, + `log:${reference ?? "all"}:${cursor ?? "first"}:${limit}:${operationId}`, + () => + tauriInvoke("git_history_page", { + repoPath: resolvedRepoPath, + limit, + operationId, + ...(cursor ? { cursor } : {}), + ...(reference ? { reference } : {}), + }), + // A cursor page consumes server-side state and cannot safely replay the same request. + { retryOnInvalidation: false }, + ); + } catch (error) { + if (!isNotGitRepositoryError(error)) console.error("Failed to get git history page:", error); + return null; + } +}; + +export const closeGitHistoryCursor = async ( + repoPath: string, + cursor: string, +): Promise => { + try { + const resolvedRepoPath = await resolveRepositoryPath(repoPath); + if (!resolvedRepoPath) return; + await tauriInvoke<{ closed: boolean }>("git_history_cursor_close", { + repoPath: resolvedRepoPath, + cursor, + }); + } catch (error) { + if (!isNotGitRepositoryError(error)) { + console.error("Failed to close git history cursor:", error); + } + } +}; + export const getCommitFiles = async ( repoPath: string, commitHash: string, diff --git a/windows/tauri/src/features/git/hooks/use-git-log-controller.ts b/windows/tauri/src/features/git/hooks/use-git-log-controller.ts index 8d03eb76..361ddde3 100644 --- a/windows/tauri/src/features/git/hooks/use-git-log-controller.ts +++ b/windows/tauri/src/features/git/hooks/use-git-log-controller.ts @@ -1,6 +1,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "@/i18n/locale-provider"; -import { getGitHistory } from "../api/git-commits-api"; +import { + cancelGitHistoryOperation, + closeGitHistoryCursor, + getGitHistoryPage, + getGitReferences, +} from "../api/git-commits-api"; import { subscribeToGitChanges } from "../events/git-events"; import type { GitHistorySnapshot, GitReference } from "../types/git.types"; import { shouldRefreshGitLogForChange } from "../utils/git-log-refresh"; @@ -9,6 +14,7 @@ type GitLogLoadState = "idle" | "loading" | "ready" | "failed"; const COMMITS_PER_PAGE = 50; const MAX_COMMITS = 5_000; +let nextControllerId = 0; const EMPTY_HISTORY: GitHistorySnapshot = { references: [], recentReferences: [], @@ -24,56 +30,114 @@ export function useGitLogController(repoPath: string | null) { const [selectedReference, setSelectedReferenceState] = useState(null); const [isLoadingMore, setIsLoadingMore] = useState(false); const requestIdRef = useRef(0); + const controllerIdRef = useRef(null); + const activeCursorRef = useRef(null); + const activeOperationIdsRef = useRef(new Set()); const historyRef = useRef(history); const selectedReferenceRef = useRef(selectedReference); historyRef.current = history; selectedReferenceRef.current = selectedReference; + if (controllerIdRef.current === null) controllerIdRef.current = ++nextControllerId; + + const cancelActiveOperations = useCallback(() => { + for (const operationId of activeOperationIdsRef.current) { + void cancelGitHistoryOperation(operationId); + } + activeOperationIdsRef.current.clear(); + }, []); + + const closeActiveCursor = useCallback(() => { + const cursor = activeCursorRef.current; + activeCursorRef.current = null; + if (repoPath && cursor) void closeGitHistoryCursor(repoPath, cursor); + }, [repoPath]); const load = useCallback( async ({ reference, - limit, + cursor, loadingMore = false, + refreshReferences = true, }: { reference: GitReference | null; - limit: number; + cursor?: string; loadingMore?: boolean; + refreshReferences?: boolean; }) => { if (!repoPath) return; const requestId = ++requestIdRef.current; + cancelActiveOperations(); + if (!loadingMore) closeActiveCursor(); + const operationPrefix = `git-log-${controllerIdRef.current}-${requestId}`; + const pageOperationId = `${operationPrefix}-page`; + const referencesOperationId = `${operationPrefix}-references`; + activeOperationIdsRef.current.add(pageOperationId); + if (refreshReferences) activeOperationIdsRef.current.add(referencesOperationId); setError(null); if (loadingMore) setIsLoadingMore(true); else setLoadState("loading"); try { - const snapshot = await getGitHistory(repoPath, limit, reference?.fullName); - if (requestId !== requestIdRef.current) return; - if (!snapshot) { + const [references, page] = await Promise.all([ + refreshReferences + ? getGitReferences(repoPath, referencesOperationId) + : Promise.resolve(null), + getGitHistoryPage( + repoPath, + cursor, + COMMITS_PER_PAGE, + pageOperationId, + reference?.fullName, + ), + ]); + if (requestId !== requestIdRef.current) { + if (page?.nextCursor) void closeGitHistoryCursor(repoPath, page.nextCursor); + return; + } + if (!page) { setLoadState("failed"); setError(t("git.historyLoadRepositoryFailed")); return; } - setHistory({ - ...snapshot, - hasMore: snapshot.hasMore && limit < MAX_COMMITS, + setHistory((current) => { + const existingHashes = loadingMore + ? new Set(current.commits.map((commit) => commit.hash)) + : new Set(); + const commits = loadingMore + ? [ + ...current.commits, + ...page.commits.filter((commit) => !existingHashes.has(commit.hash)), + ] + : page.commits; + return { + references: references?.references ?? current.references, + recentReferences: references?.recentReferences ?? current.recentReferences, + commits, + hasMore: page.hasMore && commits.length < MAX_COMMITS, + }; }); + activeCursorRef.current = page.nextCursor ?? null; setLoadState("ready"); } catch (loadError) { if (requestId !== requestIdRef.current) return; setLoadState("failed"); setError(loadError instanceof Error ? loadError.message : t("git.historyLoadFailed")); } finally { + activeOperationIdsRef.current.delete(pageOperationId); + activeOperationIdsRef.current.delete(referencesOperationId); if (requestId === requestIdRef.current) setIsLoadingMore(false); } }, - [repoPath, t], + [cancelActiveOperations, closeActiveCursor, repoPath, t], ); useEffect(() => { requestIdRef.current += 1; + cancelActiveOperations(); + closeActiveCursor(); setHistory(EMPTY_HISTORY); setSelectedReferenceState(null); setIsLoadingMore(false); @@ -83,26 +147,29 @@ export function useGitLogController(repoPath: string | null) { setLoadState("idle"); return; } - void load({ reference: null, limit: COMMITS_PER_PAGE }); + void load({ reference: null }); return () => { requestIdRef.current += 1; + cancelActiveOperations(); + closeActiveCursor(); }; - }, [load, repoPath]); + }, [cancelActiveOperations, closeActiveCursor, load, repoPath]); const selectReference = useCallback( (reference: GitReference | null) => { selectedReferenceRef.current = reference; setSelectedReferenceState(reference); - void load({ reference, limit: COMMITS_PER_PAGE }); + closeActiveCursor(); + void load({ reference }); }, - [load], + [closeActiveCursor, load], ); const refresh = useCallback(() => { - const limit = Math.max(COMMITS_PER_PAGE, historyRef.current.commits.length); - return load({ reference: selectedReferenceRef.current, limit }); - }, [load]); + closeActiveCursor(); + return load({ reference: selectedReferenceRef.current }); + }, [closeActiveCursor, load]); useEffect(() => { if (!repoPath) return; @@ -122,9 +189,15 @@ export function useGitLogController(repoPath: string | null) { const loadMore = useCallback(() => { const currentHistory = historyRef.current; - if (!currentHistory.hasMore || isLoadingMore) return Promise.resolve(); - const limit = Math.min(currentHistory.commits.length + COMMITS_PER_PAGE, MAX_COMMITS); - return load({ reference: selectedReferenceRef.current, limit, loadingMore: true }); + const cursor = activeCursorRef.current; + if (!currentHistory.hasMore || cursor === null || isLoadingMore) return Promise.resolve(); + activeCursorRef.current = null; + return load({ + reference: selectedReferenceRef.current, + cursor, + loadingMore: true, + refreshReferences: false, + }); }, [isLoadingMore, load]); return { diff --git a/windows/tauri/src/features/git/runtime/git-read-coordinator.test.ts b/windows/tauri/src/features/git/runtime/git-read-coordinator.test.ts new file mode 100644 index 00000000..43bce68f --- /dev/null +++ b/windows/tauri/src/features/git/runtime/git-read-coordinator.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test"; +import { invalidateGitCaches } from "./git-cache-registry"; +import { runGitRead } from "./git-read-coordinator"; + +test("does not replay a non-repeatable read after repository invalidation", async () => { + const repoPath = "C:/non-repeatable-history-page"; + let resolveRead: ((value: string) => void) | undefined; + let callCount = 0; + const request = runGitRead( + repoPath, + "history-page:cursor-1", + () => { + callCount += 1; + return new Promise((resolve) => { + resolveRead = resolve; + }); + }, + { retryOnInvalidation: false }, + ); + + invalidateGitCaches({ repoPath, scopes: ["history"] }); + resolveRead?.("page-2"); + + expect(await request).toBe("page-2"); + expect(callCount).toBe(1); +}); diff --git a/windows/tauri/src/features/git/runtime/git-read-coordinator.ts b/windows/tauri/src/features/git/runtime/git-read-coordinator.ts index 492ee0a0..837dc0ce 100644 --- a/windows/tauri/src/features/git/runtime/git-read-coordinator.ts +++ b/windows/tauri/src/features/git/runtime/git-read-coordinator.ts @@ -37,6 +37,7 @@ export function runGitRead( repoPath: string, queryKey: string, read: () => Promise, + options?: { retryOnInvalidation?: boolean }, ): Promise { const key = JSON.stringify([repoPath, queryKey]); const existing = inFlightReads.get(key) as Promise | undefined; @@ -45,8 +46,8 @@ export function runGitRead( const generation = getGeneration(repoPath); const request = read() .then((value) => { - if (generation !== getGeneration(repoPath)) { - return runGitRead(repoPath, queryKey, read); + if (generation !== getGeneration(repoPath) && options?.retryOnInvalidation !== false) { + return runGitRead(repoPath, queryKey, read, options); } return value; }) diff --git a/windows/tauri/src/features/git/types/git.types.ts b/windows/tauri/src/features/git/types/git.types.ts index ecea27d9..6e2077eb 100644 --- a/windows/tauri/src/features/git/types/git.types.ts +++ b/windows/tauri/src/features/git/types/git.types.ts @@ -75,6 +75,17 @@ export interface GitPushPreview extends GitPushExpectation { hasMore: boolean; } +export interface GitReferenceSnapshot { + references: GitReference[]; + recentReferences: GitReference[]; +} + +export interface GitHistoryPage { + commits: GitCommit[]; + nextCursor?: string; + hasMore: boolean; +} + export interface GitCommitFile { status: string; path: string; diff --git a/windows/tauri/src/platform/core-result-adapter.history.test.ts b/windows/tauri/src/platform/core-result-adapter.history.test.ts index 89804843..845b1299 100644 --- a/windows/tauri/src/platform/core-result-adapter.history.test.ts +++ b/windows/tauri/src/platform/core-result-adapter.history.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import type { GitHistorySnapshot } from "@/features/git/types/git.types"; +import type { + GitHistoryPage, + GitHistorySnapshot, + GitReferenceSnapshot, +} from "@/features/git/types/git.types"; import { adaptCoreResult } from "./core-result-adapter"; describe("git history result adaptation", () => { @@ -93,4 +97,71 @@ describe("git history result adaptation", () => { hasMore: false, }); }); + + test("adapts references independently from commit history", () => { + expect( + adaptCoreResult("git_references", undefined, { + references: [ + { + fullName: "refs/heads/main", + shortName: "main", + kind: "local", + isCurrent: true, + upstreamShortName: "origin/main", + ahead: 4, + behind: 2, + }, + ], + recentReferences: [], + }), + ).toEqual({ + references: [ + { + fullName: "refs/heads/main", + shortName: "main", + kind: "local", + isCurrent: true, + upstreamShortName: "origin/main", + ahead: 4, + behind: 2, + }, + ], + recentReferences: [], + }); + }); + + test("preserves the next cursor for an incremental history page", () => { + expect( + adaptCoreResult("git_history_page", undefined, { + commits: [ + { + hash: "abc1234", + parentHashes: [], + subject: "Page commit", + authorName: "Developer", + authorEmail: "developer@example.invalid", + date: "2026/08/16 10:00", + decorations: "", + }, + ], + nextCursor: "cursor-50", + hasMore: true, + }), + ).toEqual({ + commits: [ + { + hash: "abc1234", + shortHash: "abc1234", + parentHashes: [], + message: "Page commit", + author: "Developer", + email: "developer@example.invalid", + date: "2026/08/16 10:00", + decorations: "", + }, + ], + nextCursor: "cursor-50", + hasMore: true, + }); + }); }); diff --git a/windows/tauri/src/platform/core-result-adapter.ts b/windows/tauri/src/platform/core-result-adapter.ts index dc28c6af..fab12639 100644 --- a/windows/tauri/src/platform/core-result-adapter.ts +++ b/windows/tauri/src/platform/core-result-adapter.ts @@ -198,6 +198,48 @@ export function adaptCoreResult( : [], hasMore: Boolean(data.hasMore), } as T; + case "git_references": + return { + references: Array.isArray(data.references) + ? data.references.map((reference: JsonRecord) => ({ + fullName: reference.fullName, + shortName: reference.shortName, + kind: reference.kind, + isCurrent: Boolean(reference.isCurrent), + upstreamShortName: reference.upstreamShortName ?? undefined, + ahead: typeof reference.ahead === "number" ? reference.ahead : 0, + behind: typeof reference.behind === "number" ? reference.behind : 0, + })) + : [], + recentReferences: Array.isArray(data.recentReferences) + ? data.recentReferences.map((reference: JsonRecord) => ({ + fullName: reference.fullName, + shortName: reference.shortName, + kind: reference.kind, + isCurrent: Boolean(reference.isCurrent), + upstreamShortName: reference.upstreamShortName ?? undefined, + ahead: typeof reference.ahead === "number" ? reference.ahead : 0, + behind: typeof reference.behind === "number" ? reference.behind : 0, + })) + : [], + } as T; + case "git_history_page": + return { + commits: Array.isArray(data.commits) + ? data.commits.map((commit: JsonRecord) => ({ + hash: commit.hash, + shortHash: commit.shortHash ?? String(commit.hash ?? "").slice(0, 7), + parentHashes: Array.isArray(commit.parentHashes) ? commit.parentHashes : [], + message: commit.subject, + author: commit.authorName, + email: commit.authorEmail, + date: commit.date, + decorations: commit.decorations ?? "", + })) + : [], + nextCursor: typeof data.nextCursor === "string" ? data.nextCursor : undefined, + hasMore: Boolean(data.hasMore), + } as T; case "git_branches": return ( Array.isArray(data.references)