diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index e9c69f306..0fb33b769 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -894,6 +894,7 @@ "Commit message replaced" = "Commit message replaced"; "Commit message: %@" = "Commit message: %@"; "Commit messages use" = "Commit messages use"; +"Commit or stash local changes before pulling." = "Commit or stash local changes before pulling."; "Commit title" = "Commit title"; "Commit version" = "Commit version"; "Commits" = "Commits"; @@ -4247,6 +4248,7 @@ "The active repository changed. Inspect the original repository before retrying." = "The active repository changed. Inspect the original repository before retrying."; "The address is invalid." = "The address is invalid."; "The application closed before the debug terminal could start." = "The application closed before the debug terminal could start."; +"The branch changed while choosing a pull strategy. Review it and try again." = "The branch changed while choosing a pull strategy. Review it and try again."; "The branch could not be published" = "The branch could not be published"; "The branch now contains the completed plan. Its original history remains available through the recovery reference." = "The branch now contains the completed plan. Its original history remains available through the recovery reference."; "The breakdown needs Model call statistics (on by default). After turning it on, reconnect and send a message." = "The breakdown needs Model call statistics (on by default). After turning it on, reconnect and send a message."; @@ -4749,6 +4751,7 @@ "UNFILED CONNECTIONS" = "UNFILED CONNECTIONS"; "URL" = "URL"; "Unable to create the Maven dependency plan." = "Unable to create the Maven dependency plan."; +"Unable to inspect the branch." = "Unable to inspect the branch."; "Unable to parse Maven dependencies for this module." = "Unable to parse Maven dependencies for this module."; "Unable to read settings" = "Unable to read settings"; "Unable to resolve Maven dependencies." = "Unable to resolve Maven dependencies."; @@ -5260,6 +5263,9 @@ "git.log.branchNameEmpty" = "Specify name for the new branch"; "git.log.branchNameHead" = "HEAD is a reserved keyword"; "git.log.olderCommitsFailed" = "Could not load older commits. Refresh history"; +"git.pullResult.dirty" = "Commit or stash local changes before pulling."; +"git.pullResult.inspectBranchFailed" = "Unable to inspect the branch."; +"git.pullResult.stateChanged" = "The branch changed while choosing a pull strategy. Review it and try again."; "git.rebasePlan.issue.emptyReword" = "Every Reword step needs a commit message."; "git.rebasePlan.issue.emptySquash" = "Enter a Squash message or use the default combined messages."; "git.rebasePlan.issue.invalidMessage" = "The commit messages in this plan are not valid."; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 088a1f17f..23283eced 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -894,6 +894,7 @@ "Commit message replaced" = "提交信息已替换"; "Commit message: %@" = "提交说明:%@"; "Commit messages use" = "提交信息使用"; +"Commit or stash local changes before pulling." = "拉取前请先提交或贮藏本地更改。"; "Commit title" = "提交标题"; "Commit version" = "提交版本"; "Commits" = "提交"; @@ -4247,6 +4248,7 @@ "The active repository changed. Inspect the original repository before retrying." = "当前仓库已更改。重试前请检查原仓库。"; "The address is invalid." = "地址无效。"; "The application closed before the debug terminal could start." = "调试终端启动前应用已关闭。"; +"The branch changed while choosing a pull strategy. Review it and try again." = "选择拉取策略期间分支状态已变化,请检查后重试。"; "The branch could not be published" = "无法发布该分支"; "The branch now contains the completed plan. Its original history remains available through the recovery reference." = "操作计划已执行完成。仍可通过恢复引用查看分支的原始历史。"; "The breakdown needs Model call statistics (on by default). After turning it on, reconnect and send a message." = "分项需要开启“模型调用统计”(默认开启)。开启后请重新连接并发送消息。"; @@ -4749,6 +4751,7 @@ "UNFILED CONNECTIONS" = "未归档连接"; "URL" = "URL"; "Unable to create the Maven dependency plan." = "无法创建 Maven 依赖解析计划。"; +"Unable to inspect the branch." = "无法检查当前分支。"; "Unable to parse Maven dependencies for this module." = "无法解析此模块的 Maven 依赖。"; "Unable to read settings" = "无法读取设置"; "Unable to resolve Maven dependencies." = "无法解析 Maven 依赖。"; @@ -5260,6 +5263,9 @@ "git.log.branchNameEmpty" = "请指定新分支的名称"; "git.log.branchNameHead" = "HEAD 是保留关键字"; "git.log.olderCommitsFailed" = "无法加载更早的提交,点击刷新历史"; +"git.pullResult.dirty" = "拉取前请先提交或贮藏本地更改。"; +"git.pullResult.inspectBranchFailed" = "无法检查当前分支。"; +"git.pullResult.stateChanged" = "选择拉取策略期间分支状态已变化,请检查后重试。"; "git.rebasePlan.issue.emptyReword" = "每个 Reword 步骤都需要提交信息。"; "git.rebasePlan.issue.emptySquash" = "请输入 Squash 信息,或使用默认的合并信息。"; "git.rebasePlan.issue.invalidMessage" = "此计划中的提交信息无效。"; diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index f81e50b7e..1cadb1c7b 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -1323,6 +1323,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let behind: Int let diverged: Bool let hasLocalChanges: Bool + let configuredStrategy: String? } struct GitOperationStatePayload: Decodable, Sendable { @@ -2372,6 +2373,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { /// Destination confirmed for a divergent upstream; omitted so routine /// pushes keep their existing behaviour. let pushTarget: String? + /// Complete local reference a guarded pull must still find checked out. + let expectedBranch: String? } private struct GitDiffRequest: Encodable { @@ -3282,7 +3285,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { amend: Bool = false, force: Bool = false, autoStash: Bool = false, - pushTarget: String? = nil + pushTarget: String? = nil, + expectedBranch: String? = nil ) -> GitCommandPayload? { execute( command: "git.write", @@ -3304,7 +3308,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { amend: amend, force: force, autoStash: autoStash, - pushTarget: pushTarget + pushTarget: pushTarget, + expectedBranch: expectedBranch ) ) } @@ -3381,7 +3386,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { amend: Bool = false, force: Bool = false, autoStash: Bool = false, - pushTarget: String? = nil + pushTarget: String? = nil, + expectedBranch: String? = nil ) -> Result { executeResult( command: "git.write", @@ -3403,7 +3409,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { amend: amend, force: force, autoStash: autoStash, - pushTarget: pushTarget + pushTarget: pushTarget, + expectedBranch: expectedBranch ) ) } diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index 28e73b989..591e890ef 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -98,7 +98,8 @@ struct RustGitOperations: GitOperations, Sendable { amend: Bool = false, force: Bool = false, autoStash: Bool = false, - pushTarget: String? = nil + pushTarget: String? = nil, + expectedBranch: String? = nil ) -> GitProcessResult? { switch core.gitWriteResult( at: rootURL, @@ -118,7 +119,8 @@ struct RustGitOperations: GitOperations, Sendable { amend: amend, force: force, autoStash: autoStash, - pushTarget: pushTarget + pushTarget: pushTarget, + expectedBranch: expectedBranch ) { case .success(let response): return makeProcessResult(response) @@ -390,8 +392,12 @@ struct RustGitOperations: GitOperations, Sendable { ) } - func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy = .ffOnly) -> GitProcessResult? { - write(at: rootURL, operation: "pull", mode: strategy.rawValue) + func updateCurrentBranch( + at rootURL: URL, + strategy: GitPullStrategy, + expectedBranch: String? + ) -> GitProcessResult? { + write(at: rootURL, operation: "pull", mode: strategy.rawValue, expectedBranch: expectedBranch) } /// Staged files still containing conflict markers. @@ -435,7 +441,8 @@ struct RustGitOperations: GitOperations, Sendable { ahead: payload.ahead, behind: payload.behind, diverged: payload.diverged, - hasLocalChanges: payload.hasLocalChanges + hasLocalChanges: payload.hasLocalChanges, + configuredStrategy: payload.configuredStrategy.flatMap(GitPullStrategy.init(rawValue:)) ) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 3a5df491f..9ac0edf25 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -1616,9 +1616,9 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { await gitFeature.continueGitOperation() } - func resolvePullStrategy(_ strategy: GitPullStrategy) async { + func resolvePullStrategy(_ strategy: GitPullStrategy, for request: GitPullStrategyRequest) async { guard let gitFeature = await activateGitModule() else { return } - await gitFeature.resolvePullStrategy(strategy) + await gitFeature.resolvePullStrategy(strategy, for: request) } func cancelPullStrategy() { diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 7d1c03491..5530f4065 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -433,7 +433,7 @@ struct WorkbenchView: View { } .sheet(item: $model.pendingPullStrategy) { request in GitPullStrategyDialog(request: request) { strategy in - Task { await model.resolvePullStrategy(strategy) } + Task { await model.resolvePullStrategy(strategy, for: request) } } .onDisappear { model.cancelPullStrategy() } } diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 74be963d2..96025d0db 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -2508,11 +2508,31 @@ package final class GitFeatureModel: ObservableObject { ) } + /// Core's `git.historyWindow` plan for one window operation; `nil` means Core rejected it. + private func historyWindowPlan(_ request: GitHistoryWindowRequest) async -> GitHistoryWindowPlan? { + await service.corePlan(.historyWindow, request) + } + + /// Appends `page` to `window` through Core so both products dedupe and bound a + /// history window alike. `hasMore` says whether history may continue after it. + private func mergeHistoryPage( + window: [GitCommit], page: [GitCommit], pageHasMore: Bool + ) async -> (commits: [GitCommit], appended: Int, hasMore: Bool)? { + guard let plan = await historyWindowPlan(.mergePage( + window: window.map(\.hash), page: page.map(\.hash), pageHasMore: pageHasMore + )), let append = plan.append, let hasMore = plan.hasMore, + append.allSatisfy({ page.indices.contains($0) }) else { return nil } + return (window + append.map { page[$0] }, append.count, hasMore) + } + package func refreshGitHistory(preservingLoadedHistory: Bool = false) async { guard let gitRepositoryRoot else { return } - let pageLimit = preservingLoadedHistory - ? max(Self.gitHistoryPageSize, gitCommits.count) - : Self.gitHistoryPageSize + // A refresh that keeps the loaded depth reads that many commits at once. + // Core owns the size; the window is replaced atomically below. + let pageLimit = await historyWindowPlan(.refreshPlan( + loadedCount: gitCommits.count, pageSize: Self.gitHistoryPageSize, + preserveLoaded: preservingLoadedHistory + ))?.limit ?? Self.gitHistoryPageSize cancelGitHistoryLoading() let generation = gitHistoryGeneration let selectedReference = selectedGitReference @@ -2582,10 +2602,22 @@ package final class GitFeatureModel: ObservableObject { service.closeHistoryCursor(at: gitRepositoryRoot, cursor: cursor) } + // Core keeps the selected commits, focus, and range anchor that are still + // loaded and falls back to the first row; pixel scroll anchors stay in the view. let retainedCommitHash = preservingLoadedHistory ? selectedGitCommit?.hash : previousCommitHash - let nextCommit = historyPage.commits.first(where: { $0.hash == retainedCommitHash }) - ?? historyPage.commits.first - historyEditing.retainSelection(in: historyPage.commits, fallback: nextCommit?.hash) + let selection = historyEditing.selection + let reconciled = await historyWindowPlan(.reconcileSelection( + window: historyPage.commits.map(\.hash), selected: selection.hashes.sorted(), + focused: retainedCommitHash ?? selection.focusedHash, anchor: selection.anchorHash + )) + guard gitHistoryGeneration == generation, self.gitRepositoryRoot == gitRepositoryRoot else { return } + guard let reconciled, let selected = reconciled.selected else { + await repositoryGraph + await repositoryReferences + return + } + let nextCommit = historyPage.commits.first(where: { $0.hash == reconciled.focused }) + historyEditing.applySelection(selected: selected, focused: nextCommit?.hash, anchor: reconciled.anchor) if let nextCommit { if retainedCommitHash == nextCommit.hash { selectedGitCommit = nextCommit @@ -2610,25 +2642,20 @@ package final class GitFeatureModel: ObservableObject { guard let page = await service.historyPage( at: root, reference: reference, cursor: nil, limit: limit, operationID: operationID ) else { return nil } - guard let previousTail = previousCommits.last, - !page.commits.contains(where: { $0.hash == previousTail.hash }), - page.hasMore, let cursor = page.nextCursor, + guard !previousCommits.isEmpty, page.hasMore, let cursor = page.nextCursor, gitHistoryGeneration == generation, gitRepositoryRoot == root, !Task.isCancelled else { return page } - // New heads must not evict the old loaded tail (or its selection). Only - // extend by the observed displacement, not another page on every watch event. - let previousIndices = Dictionary(previousCommits.enumerated().map { ($1.hash, $0) }, uniquingKeysWith: min) - guard let overlap = page.commits.enumerated().first(where: { previousIndices[$0.element.hash] != nil }), - let previousIndex = previousIndices[overlap.element.hash] else { return page } - let extensionLimit = min(overlap.offset - previousIndex, Self.gitGraphRepositoryLimit - page.commits.count) - guard extensionLimit > 0 else { return page } + // New heads must not evict the old loaded tail (or its selection). Core says + // how far to read past the page: only the displacement, not a page per event. + guard let extensionLimit = await historyWindowPlan(.extendPlan( + previous: previousCommits.map(\.hash), page: page.commits.map(\.hash), canContinue: true + ))?.extensionLimit, extensionLimit > 0 else { return page } guard let extensionPage = await service.historyPage( at: root, reference: reference, cursor: cursor, limit: extensionLimit, operationID: operationID + ), let merged = await mergeHistoryPage( + window: page.commits, page: extensionPage.commits, pageHasMore: extensionPage.hasMore ) else { return nil } - var hashes = Set(page.commits.map(\.hash)) - let fresh = extensionPage.commits.filter { hashes.insert($0.hash).inserted } - return GitHistoryPage(commits: page.commits + fresh, nextCursor: extensionPage.nextCursor, - hasMore: extensionPage.hasMore && !fresh.isEmpty) + return GitHistoryPage(commits: merged.commits, nextCursor: extensionPage.nextCursor, hasMore: merged.hasMore) } private func refreshGitRepositoryGraph(at root: URL, generation: UUID) async { @@ -2824,10 +2851,14 @@ package final class GitFeatureModel: ObservableObject { return } - var loadedHashes = Set(gitCommits.map(\.hash)) - let fresh = page.commits.filter { loadedHashes.insert($0.hash).inserted } - gitCommits.append(contentsOf: fresh) - canLoadMoreGitHistory = page.hasMore && page.nextCursor != nil && !fresh.isEmpty + guard let merged = await mergeHistoryPage(window: gitCommits, page: page.commits, pageHasMore: page.hasMore), + gitHistoryGeneration == generation else { + if let cursor = page.nextCursor { service.closeHistoryCursor(at: gitRepositoryRoot, cursor: cursor) } + if gitHistoryGeneration == generation { gitHistoryPageFailed = true } + return + } + gitCommits = merged.commits + canLoadMoreGitHistory = merged.hasMore && page.nextCursor != nil gitHistoryCursor = canLoadMoreGitHistory ? page.nextCursor : nil if !canLoadMoreGitHistory, let cursor = page.nextCursor { service.closeHistoryCursor(at: gitRepositoryRoot, cursor: cursor) @@ -3063,10 +3094,14 @@ package final class GitFeatureModel: ObservableObject { return } - var loadedHashes = Set(gitCommits.map(\.hash)) - let fresh = result.commits.filter { loadedHashes.insert($0.hash).inserted } - gitCommits.append(contentsOf: fresh) - canLoadMoreGitHistory = result.hasMore && result.nextCursor != nil && !fresh.isEmpty + guard let merged = await mergeHistoryPage(window: gitCommits, page: result.commits, pageHasMore: result.hasMore), + gitHistoryGeneration == generation else { + if let cursor = result.nextCursor { service.closeHistoryCursor(at: gitRepositoryRoot, cursor: cursor) } + if gitHistoryGeneration == generation { gitHistoryPageFailed = true } + return + } + gitCommits = merged.commits + canLoadMoreGitHistory = merged.hasMore && result.nextCursor != nil gitHistoryCursor = canLoadMoreGitHistory ? result.nextCursor : nil if !canLoadMoreGitHistory, let nextCursor = result.nextCursor { service.closeHistoryCursor(at: gitRepositoryRoot, cursor: nextCursor) @@ -4426,25 +4461,65 @@ package final class GitFeatureModel: ObservableObject { } } - /// IDEA's branch Update: the checked-out branch pulls into the working tree, - /// any other local branch fast-forwards from its upstream in place. Core runs - /// one atomic fetch for the latter, so Git itself refuses a diverged branch or - /// one checked out by another worktree instead of rewriting it. + /// IDEA's branch Update. Core's `git.updatePlan` decides every rule: a branch + /// checked out in a worktree pulls inside that worktree, any other local branch + /// fast-forwards from its upstream in place (one atomic fetch, so Git itself + /// refuses a diverged branch), and a diverged current branch asks for a strategy. package func updateBranch(_ reference: GitReference) async { - if reference.isCurrent { return await updateCurrentBranch(reference) } guard let gitRepositoryRoot, reference.kind == .local else { return } - guard let upstream = reference.upstreamShortName else { - notifyWithReference("\(reference.shortName) tracks no remote branch") + // The published worktree list only loads with the Worktrees tab; query it fresh. + let worktrees = await worktreesProvider(gitRepositoryRoot) ?? gitWorktrees + let route: GitUpdatePlan? = await service.corePlan( + .updatePlan, + GitUpdatePlanRequest.route( + fullName: reference.fullName, + upstream: reference.upstreamShortName, + isCurrent: reference.isCurrent, + worktrees: worktrees + ) + ) + guard let route else { + notifyWithReference(L10n.string("Unable to inspect the branch.")) return } - // Git refuses to fetch into a branch checked out elsewhere and prints - // that refusal in the user's locale, so check the worktree list first. - // The published list only loads with the Worktrees tab; query it fresh. - let worktrees = await worktreesProvider(gitRepositoryRoot) ?? gitWorktrees - if worktrees.contains(where: { !$0.isCurrent && $0.branch == reference.fullName }) { - notifyWithReference("\(reference.shortName) is checked out in another worktree. Update it there.") + switch route.step { + case "fastForwardReference": + await fastForwardBranchInPlace(reference) + case "inspect": + await updateCheckedOutBranch(reference, route: route) + default: + notifyUpdateBlocker(route.blocker, reference: reference, upstream: reference.upstreamShortName) + } + } + + package func updateCurrentBranch(_ reference: GitReference) async { + guard reference.isCurrent else { + notifyWithReference("Only the current branch can be updated") return } + await updateBranch(reference) + } + + private func notifyUpdateBlocker(_ blocker: String?, reference: GitReference, upstream: String?) { + switch blocker { + case "noUpstream": + notifyWithReference("\(reference.shortName) tracks no remote branch") + case "upToDate": + notifyWithReference("\(reference.shortName) is already up to date") + case "localChanges": + notifyWithReference(L10n.string("Commit or stash local changes before pulling.")) + case "stateChanged": + notifyWithReference(L10n.string("The branch changed while choosing a pull strategy. Review it and try again.")) + case "notFastForward": + notifyWithReference("\(reference.shortName) has diverged from \(upstream ?? ""). Check it out to merge or rebase.") + default: + notifyWithReference(L10n.string("Unable to inspect the branch.")) + } + } + + /// A branch no worktree has checked out: Core fetches into it with `--ff-only`. + private func fastForwardBranchInPlace(_ reference: GitReference) async { + guard let gitRepositoryRoot, let upstream = reference.upstreamShortName else { return } isPerformingBranchOperation = true let result = await withGitOperation { await service.updateBranch(reference, at: gitRepositoryRoot) } isPerformingBranchOperation = false @@ -4461,65 +4536,92 @@ package final class GitFeatureModel: ObservableObject { await reportBranchOperation(result, success: "Updated \(reference.shortName)") } - package func updateCurrentBranch(_ reference: GitReference) async { - guard let gitRepositoryRoot, reference.isCurrent else { - notifyWithReference("Only the current branch can be updated") - return - } + /// Fetch, inspect, and let Core decide whether to pull, ask, or stop. + private func updateCheckedOutBranch(_ reference: GitReference, route: GitUpdatePlan) async { + guard let gitRepositoryRoot, let expectedBranch = route.expectedBranch else { return } + let interactive = route.interactive ?? false + // The active worktree is addressed by the model's own root, not by the path + // Git listed, which may spell the same directory differently. + let checkoutRoot = interactive ? gitRepositoryRoot : (route.checkout.map { URL(fileURLWithPath: $0) } ?? gitRepositoryRoot) // Fetch first so the divergence check reflects the remote as it is now; // otherwise a stale ref would send a pull down the wrong path. isPerformingBranchOperation = true - let fetched = await withGitOperation { await service.fetch(at: gitRepositoryRoot) } + let fetched = await withGitOperation { await service.fetch(at: checkoutRoot) } guard fetched.succeeded else { isPerformingBranchOperation = false notifyWithReference(trimmedMessage(fetched)) return } + await inspectAndUpdate( + reference, checkoutRoot: checkoutRoot, expectedBranch: expectedBranch, + interactive: interactive, confirmation: nil + ) + } - let preflight = await service.pullPreflight(at: gitRepositoryRoot) - if let preflight, preflight.upstream == nil { + /// Reads the pull preflight, asks Core for the next step, and runs it. The + /// caller already holds `isPerformingBranchOperation`. + private func inspectAndUpdate( + _ reference: GitReference, + checkoutRoot: URL, + expectedBranch: String, + interactive: Bool, + confirmation: GitUpdatePlanRequest.Confirmation? + ) async { + // A failed inspection must stop the update: pulling blind would skip every gate. + guard let preflight = await service.pullPreflight(at: checkoutRoot), + let plan: GitUpdatePlan = await service.corePlan( + .updatePlan, + GitUpdatePlanRequest.inspect(preflight: preflight, interactive: interactive, confirmation: confirmation) + ) else { isPerformingBranchOperation = false - notifyWithReference("\(reference.shortName) tracks no remote branch") + notifyWithReference(L10n.string("Unable to inspect the branch.")) await refreshGit() return } - if let preflight, preflight.isUpToDate { + switch plan.step { + case "pull" where plan.strategy != nil: + let strategy = plan.strategy ?? .ffOnly + let result = await withGitOperation { + await service.updateCurrentBranch(at: checkoutRoot, strategy: strategy, expectedBranch: expectedBranch) + } isPerformingBranchOperation = false - notifyWithReference("\(reference.shortName) is already up to date") - await refreshGit() - return - } - // Only a divergent history needs a decision. Git would refuse it with a - // localized hint block, so we ask before running anything. - if let preflight, preflight.diverged { + let success = confirmation == nil + ? "Updated \(reference.shortName)" + : (strategy == .rebase ? "Rebased onto upstream" : "Merged upstream") + await reportBranchOperation(result, success: success) + case "chooseStrategy": isPerformingBranchOperation = false pendingPullStrategy = GitPullStrategyRequest( upstream: preflight.upstream ?? "", ahead: preflight.ahead, behind: preflight.behind, - hasLocalChanges: preflight.hasLocalChanges + hasLocalChanges: preflight.hasLocalChanges, + reference: reference, + checkoutRoot: checkoutRoot, + expectedBranch: expectedBranch, + preflight: preflight ) - return - } - - let result = await withGitOperation { - await service.updateCurrentBranch(at: gitRepositoryRoot) + case "blocked": + isPerformingBranchOperation = false + notifyUpdateBlocker(plan.blocker, reference: reference, upstream: preflight.upstream) + await refreshGit() + default: + isPerformingBranchOperation = false + notifyWithReference(L10n.string("Unable to inspect the branch.")) + await refreshGit() } - isPerformingBranchOperation = false - await reportBranchOperation(result, success: "Updated \(reference.shortName)") } - /// Runs the pull the user chose from the divergence dialog. - package func resolvePullStrategy(_ strategy: GitPullStrategy) async { + /// Runs the pull the user chose from the divergence dialog. Core re-checks the + /// history the user saw, so a branch that moved meanwhile is refused. + package func resolvePullStrategy(_ strategy: GitPullStrategy, for request: GitPullStrategyRequest) async { pendingPullStrategy = nil - guard let gitRepositoryRoot else { return } isPerformingBranchOperation = true - let result = await withGitOperation { - await service.updateCurrentBranch(at: gitRepositoryRoot, strategy: strategy) - } - isPerformingBranchOperation = false - let verb = strategy == .rebase ? "Rebased onto upstream" : "Merged upstream" - await reportBranchOperation(result, success: verb) + await inspectAndUpdate( + request.reference, checkoutRoot: request.checkoutRoot, expectedBranch: request.expectedBranch, + interactive: true, + confirmation: .init(strategy: strategy, preflight: request.preflight) + ) } package func cancelPullStrategy() { diff --git a/macos/Sources/LitheGitModule/Application/GitHistoryEditingFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitHistoryEditingFeatureModel.swift index c02092d68..2f18a1d67 100644 --- a/macos/Sources/LitheGitModule/Application/GitHistoryEditingFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitHistoryEditingFeatureModel.swift @@ -71,10 +71,10 @@ package final class GitHistoryEditingFeatureModel: ObservableObject { onChange?() } - package func retainSelection(in commits: [GitCommit], fallback: String?) { + package func applySelection(selected: [String], focused: String?, anchor: String?) { let context = actionCapabilitiesContext cancelActionCapabilities() - selection.retain(Set(commits.map(\.hash)), fallback: fallback) + selection.apply(selected: selected, focused: focused, anchor: anchor) if let context { context.onChange() loadActionCapabilities(at: context.root, onReady: context.onChange) diff --git a/macos/Sources/LitheGitModule/Models/GitCorePlans.swift b/macos/Sources/LitheGitModule/Models/GitCorePlans.swift index dc60735ab..782579a09 100644 --- a/macos/Sources/LitheGitModule/Models/GitCorePlans.swift +++ b/macos/Sources/LitheGitModule/Models/GitCorePlans.swift @@ -5,6 +5,8 @@ import Foundation package enum GitCorePlanCommand: String, Sendable { case normalizeChangeTargets = "git.normalizeChangeTargets" case rebasePlanPreview = "git.rebasePlanPreview" + case updatePlan = "git.updatePlan" + case historyWindow = "git.historyWindow" } // MARK: - git.normalizeChangeTargets @@ -58,3 +60,153 @@ package struct GitRebasePlanPreview: Decodable, Sendable, Equatable { package let startSteps: [GitRebaseStep] package let outputCommitCount: Int } + +// MARK: - git.updatePlan + +/// Facts for `git.updatePlan`; Core owns every Update and Pull rule. +package enum GitUpdatePlanRequest: Encodable, Sendable { + case route(fullName: String, upstream: String?, isCurrent: Bool, worktrees: [GitWorktree]) + case inspect( + preflight: GitPullPreflightState, + interactive: Bool, + confirmation: Confirmation? + ) + + /// The user's answer to a `chooseStrategy` step, with the inspection they saw. + package struct Confirmation: Sendable { + package let strategy: GitPullStrategy + package let preflight: GitPullPreflightState + package init(strategy: GitPullStrategy, preflight: GitPullPreflightState) { + self.strategy = strategy + self.preflight = preflight + } + } + + private struct Facts: Encodable { + let upstream: String? + let ahead: Int + let behind: Int + let diverged: Bool + let hasLocalChanges: Bool + let configuredStrategy: GitPullStrategy? + init(_ state: GitPullPreflightState) { + upstream = state.upstream + ahead = state.ahead + behind = state.behind + diverged = state.diverged + hasLocalChanges = state.hasLocalChanges + configuredStrategy = state.configuredStrategy + } + } + + private struct WorktreeFacts: Encodable { + let path: String + let branch: String? + let isCurrent: Bool + } + + private struct BranchFacts: Encodable { + let fullName: String + let upstream: String? + let isCurrent: Bool + } + + private struct ConfirmationFacts: Encodable { + let strategy: GitPullStrategy + let preflight: Facts + } + + private enum Keys: String, CodingKey { + case stage, branch, worktrees, preflight, interactive, confirmation + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: Keys.self) + switch self { + case .route(let fullName, let upstream, let isCurrent, let worktrees): + try container.encode("route", forKey: .stage) + try container.encode(BranchFacts(fullName: fullName, upstream: upstream, isCurrent: isCurrent), forKey: .branch) + try container.encode( + worktrees.map { WorktreeFacts(path: $0.path, branch: $0.isDetached ? nil : $0.branch, isCurrent: $0.isCurrent) }, + forKey: .worktrees + ) + case .inspect(let preflight, let interactive, let confirmation): + try container.encode("inspect", forKey: .stage) + try container.encode(Facts(preflight), forKey: .preflight) + try container.encode(interactive, forKey: .interactive) + if let confirmation { + try container.encode( + ConfirmationFacts(strategy: confirmation.strategy, preflight: Facts(confirmation.preflight)), + forKey: .confirmation + ) + } + } + } +} + +/// The next step of a branch Update. Hosts localize blockers and execute steps. +package struct GitUpdatePlan: Decodable, Sendable, Equatable { + /// `blocked`, `fastForwardReference`, `inspect`, `chooseStrategy`, or `pull`. + package let step: String + /// `noUpstream`, `localChanges`, `upToDate`, `notFastForward`, or `stateChanged`. + package let blocker: String? + /// Worktree path to update in; `nil` means the repository root of the request. + package let checkout: String? + package let expectedBranch: String? + package let upstream: String? + package let interactive: Bool? + package let strategy: GitPullStrategy? +} + +// MARK: - git.historyWindow + +/// One `git.historyWindow` operation over commit identities in display order. +package enum GitHistoryWindowRequest: Encodable, Sendable { + case refreshPlan(loadedCount: Int, pageSize: Int, preserveLoaded: Bool) + case extendPlan(previous: [String], page: [String], canContinue: Bool) + case mergePage(window: [String], page: [String], pageHasMore: Bool) + case reconcileSelection(window: [String], selected: [String], focused: String?, anchor: String?) + + private enum Keys: String, CodingKey { + case operation, loadedCount, pageSize, preserveLoaded, previous, page, canContinue + case window, pageHasMore, selected, focused, anchor + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: Keys.self) + switch self { + case .refreshPlan(let loadedCount, let pageSize, let preserveLoaded): + try container.encode("refreshPlan", forKey: .operation) + try container.encode(loadedCount, forKey: .loadedCount) + try container.encode(pageSize, forKey: .pageSize) + try container.encode(preserveLoaded, forKey: .preserveLoaded) + case .extendPlan(let previous, let page, let canContinue): + try container.encode("extendPlan", forKey: .operation) + try container.encode(previous, forKey: .previous) + try container.encode(page, forKey: .page) + try container.encode(canContinue, forKey: .canContinue) + case .mergePage(let window, let page, let pageHasMore): + try container.encode("mergePage", forKey: .operation) + try container.encode(window, forKey: .window) + try container.encode(page, forKey: .page) + try container.encode(pageHasMore, forKey: .pageHasMore) + case .reconcileSelection(let window, let selected, let focused, let anchor): + try container.encode("reconcileSelection", forKey: .operation) + try container.encode(window, forKey: .window) + try container.encode(selected, forKey: .selected) + try container.encodeIfPresent(focused, forKey: .focused) + try container.encodeIfPresent(anchor, forKey: .anchor) + } + } +} + +/// Union of the `git.historyWindow` answers; each operation fills its own fields. +package struct GitHistoryWindowPlan: Decodable, Sendable, Equatable { + package let limit: Int? + package let extensionLimit: Int? + package let append: [Int]? + package let hasMore: Bool? + package let selected: [String]? + package let focused: String? + package let anchor: String? +} diff --git a/macos/Sources/LitheGitModule/Models/GitHistoryRewriteModels.swift b/macos/Sources/LitheGitModule/Models/GitHistoryRewriteModels.swift index d125227ad..7c0f09249 100644 --- a/macos/Sources/LitheGitModule/Models/GitHistoryRewriteModels.swift +++ b/macos/Sources/LitheGitModule/Models/GitHistoryRewriteModels.swift @@ -115,11 +115,11 @@ package struct GitHistorySelection: Equatable, Sendable { focusedHash = hash } - /// Only a complete history replacement prunes missing identities; paging leaves them intact. - package mutating func retain(_ loadedHashes: Set, fallback: String?) { - hashes.formIntersection(loadedHashes) - if hashes.isEmpty, let fallback, loadedHashes.contains(fallback) { hashes = [fallback] } - if focusedHash.map(loadedHashes.contains) != true { focusedHash = fallback } - if anchorHash.map(loadedHashes.contains) != true { anchorHash = focusedHash } + /// Applies Core's `git.historyWindow` reconciliation after a complete history + /// replacement: only identities still loaded survive, paging leaves them intact. + package mutating func apply(selected: [String], focused: String?, anchor: String?) { + hashes = Set(selected) + focusedHash = focused + anchorHash = anchor } } diff --git a/macos/Sources/LitheGitModule/Models/GitModels.swift b/macos/Sources/LitheGitModule/Models/GitModels.swift index 81be79762..fa2c7c0ca 100644 --- a/macos/Sources/LitheGitModule/Models/GitModels.swift +++ b/macos/Sources/LitheGitModule/Models/GitModels.swift @@ -1360,23 +1360,29 @@ package struct GitPullPreflightState: Sendable { package let behind: Int package let diverged: Bool package let hasLocalChanges: Bool - package init(upstream: String?, ahead: Int, behind: Int, diverged: Bool, hasLocalChanges: Bool) { self.upstream = upstream; self.ahead = ahead; self.behind = behind; self.diverged = diverged; self.hasLocalChanges = hasLocalChanges } - - /// Nothing to pull, so the network call can be skipped entirely. - package var isUpToDate: Bool { behind == 0 && !diverged } + /// Strategy `pull.rebase` or `pull.ff` selects; `nil` when Git is not configured. + package let configuredStrategy: GitPullStrategy? + package init(upstream: String?, ahead: Int, behind: Int, diverged: Bool, hasLocalChanges: Bool, configuredStrategy: GitPullStrategy? = nil) { self.upstream = upstream; self.ahead = ahead; self.behind = behind; self.diverged = diverged; self.hasLocalChanges = hasLocalChanges; self.configuredStrategy = configuredStrategy } } /// A pull that cannot fast-forward, awaiting the user's choice of strategy. +/// It keeps the inspection the user saw, so Core can refuse a stale answer. package struct GitPullStrategyRequest: Identifiable { package let id = UUID() package let upstream: String package let ahead: Int package let behind: Int package let hasLocalChanges: Bool + /// The branch being updated, the checkout the pull runs in, and the local + /// reference that checkout must still have. + package let reference: GitReference + package let checkoutRoot: URL + package let expectedBranch: String + package let preflight: GitPullPreflightState } /// How to reconcile a divergent history when pulling. -package enum GitPullStrategy: String, Sendable { +package enum GitPullStrategy: String, Sendable, Codable { /// Refuse unless the pull can fast-forward. The safe default. case ffOnly /// Join the two histories with a merge commit. diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index 48ebce816..1f8221d4d 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -185,7 +185,7 @@ package protocol GitOperations: Sendable { func mergeBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? func rebaseCurrentBranch(onto reference: GitReference, at rootURL: URL) -> GitProcessResult? func checkoutAndRebase(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? - func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy) -> GitProcessResult? + func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy, expectedBranch: String?) -> GitProcessResult? /// Fast-forwards a non-current local branch from its upstream without switching HEAD. func updateBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? func pullRemoteReference( @@ -1288,9 +1288,12 @@ package struct GitService: Sendable { func updateCurrentBranch( at repositoryRoot: URL, - strategy: GitPullStrategy = .ffOnly + strategy: GitPullStrategy, + expectedBranch: String? ) async -> CommandResult { - await command(at: repositoryRoot) { $0.updateCurrentBranch(at: repositoryRoot, strategy: strategy) } + await command(at: repositoryRoot) { + $0.updateCurrentBranch(at: repositoryRoot, strategy: strategy, expectedBranch: expectedBranch) + } } func updateBranch(_ reference: GitReference, at repositoryRoot: URL) async -> CommandResult { diff --git a/macos/Tests/LitheGitModuleTests/GitCorePlanTestSupport.swift b/macos/Tests/LitheGitModuleTests/GitCorePlanTestSupport.swift new file mode 100644 index 000000000..f74298ba4 --- /dev/null +++ b/macos/Tests/LitheGitModuleTests/GitCorePlanTestSupport.swift @@ -0,0 +1,27 @@ +import Foundation +import LitheRustCore +@testable import LitheGitModule + +/// Runs a pure Core planner in the real linked Core, so lifecycle tests with +/// scripted Git I/O never mirror the shared Update or history-window rules. +enum GitCorePlanTestSupport { + private struct Envelope: Encodable { + let id = "plan" + let command: String + let payload: Payload + } + private struct Reply: Decodable { let ok: Bool; let data: Data? } + + static func run( + _ command: GitCorePlanCommand, + _ payload: Request + ) -> Response? { + guard let data = try? JSONEncoder().encode(Envelope(command: command.rawValue, payload: payload)), + let json = String(data: data, encoding: .utf8), + let result = json.withCString({ lithe_bridge_execute_json($0) }) else { return nil } + defer { lithe_bridge_free_string(result) } + guard let reply = try? JSONDecoder().decode(Reply.self, from: Data(String(cString: result).utf8)), + reply.ok else { return nil } + return reply.data + } +} diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 27c2fca1a..adebe63ab 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -4686,33 +4686,160 @@ struct GitModuleTests { #expect(!feature.isPerformingBranchOperation) } - @Test - func nonCurrentBranchCheckedOutInAnotherWorktreeIsNotFetchedInto() async { - var notifications: [String] = [] - let recorder = BranchCallRecorder() + /// Calls the scripted Update double saw, in order, as `kind:path[:detail]`. + private final class UpdateCallLog: @unchecked Sendable { + private let lock = NSLock() + private var entries: [String] = [] + func add(_ entry: String) { lock.lock(); entries.append(entry); lock.unlock() } + var all: [String] { lock.lock(); defer { lock.unlock() }; return entries } + } + + private func makeUpdateFeature( + log: UpdateCallLog, + preflight: GitPullPreflightState?, + worktrees: [GitWorktree] = [], + onNotify: @escaping @MainActor (String) -> Void + ) -> GitFeatureModel { let root = URL(fileURLWithPath: "/workspace") let feature = GitFeatureModel( service: GitService(operations: TestGitOperations( snapshotValue: GitSnapshot(repositoryRoot: root, branch: "main", changes: []), - branchCallRecorder: recorder + pullPreflightHandler: { url in log.add("preflight:\(url.path)"); return preflight }, + updateCurrentBranchHandler: { url, strategy, expected in + log.add("pull:\(url.path):\(strategy.rawValue):\(expected ?? "-")") + return GitProcessResult(arguments: ["pull"], output: "", exitCode: 0) + }, + fetchAtRootHandler: { url in + log.add("fetch:\(url.path)") + return GitProcessResult(arguments: ["fetch"], output: "", exitCode: 0) + } )), - worktreesProvider: { _ in [makeTestWorktree(path: "/workspace-feature", branch: "feature/a")] } + worktreesProvider: { _ in worktrees } ) feature.configure( workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, - notify: { notifications.append($0) }, + notify: onNotify, onStateRefreshed: {} ) + return feature + } + + private func updateReference(current: Bool, name: String = "main") -> GitReference { + GitReference(fullName: "refs/heads/\(name)", shortName: name, kind: .local, peelsToCommit: true, + isCurrent: current, upstreamShortName: "origin/\(name)", behind: 2) + } + + private func preflightState( + ahead: Int = 0, behind: Int = 2, diverged: Bool = false, dirty: Bool = false, + configured: GitPullStrategy? = nil + ) -> GitPullPreflightState { + GitPullPreflightState(upstream: "origin/main", ahead: ahead, behind: behind, diverged: diverged, + hasLocalChanges: dirty, configuredStrategy: configured) + } + + @Test + func nonCurrentBranchCheckedOutInAnotherWorktreeIsUpdatedThere() async { + var notifications: [String] = [] + let log = UpdateCallLog() + let feature = makeUpdateFeature( + log: log, preflight: preflightState(), + worktrees: [makeTestWorktree(path: "/workspace-feature", branch: "feature/a")], + onNotify: { notifications.append($0) } + ) await feature.refreshGit() - // The Worktrees tab never loaded, so the published list is empty. - #expect(feature.gitWorktrees.isEmpty) - await feature.updateBranch(GitReference(fullName: "refs/heads/feature/a", shortName: "feature/a", kind: .local, - peelsToCommit: true, - isCurrent: false, upstreamShortName: "origin/feature/a", behind: 2)) + await feature.updateBranch(updateReference(current: false, name: "feature/a")) - #expect(recorder.recorded.isEmpty) - #expect(notifications == ["feature/a is checked out in another worktree. Update it there."]) + // Core routes the pull into the worktree that owns the branch, guarded by its identity. + #expect(log.all == [ + "fetch:/workspace-feature", "preflight:/workspace-feature", + "pull:/workspace-feature:ffOnly:refs/heads/feature/a" + ]) + #expect(feature.pendingPullStrategy == nil) + #expect(!feature.isPerformingBranchOperation) + } + + @Test + func divergedCurrentBranchAsksForAStrategyAndRunsTheChosenOne() async { + let log = UpdateCallLog() + let feature = makeUpdateFeature( + log: log, preflight: preflightState(ahead: 1, diverged: true), onNotify: { _ in } + ) + await feature.refreshGit() + await feature.updateBranch(updateReference(current: true)) + + #expect(log.all == ["fetch:/workspace", "preflight:/workspace"]) + guard let request = feature.pendingPullStrategy else { + Issue.record("A diverged history must ask for a strategy") + return + } + #expect(request.expectedBranch == "refs/heads/main") + await feature.resolvePullStrategy(.rebase, for: request) + + #expect(log.all.suffix(2) == ["preflight:/workspace", "pull:/workspace:rebase:refs/heads/main"]) + #expect(feature.pendingPullStrategy == nil) + } + + @Test + func chosenStrategyIsRefusedWhenTheHistoryMovedMeanwhile() async { + var notifications: [String] = [] + let log = UpdateCallLog() + let seen = preflightState(ahead: 1, diverged: true) + let feature = makeUpdateFeature(log: log, preflight: seen, onNotify: { notifications.append($0) }) + await feature.refreshGit() + await feature.updateBranch(updateReference(current: true)) + guard let request = feature.pendingPullStrategy else { + Issue.record("A diverged history must ask for a strategy") + return + } + let moved = makeUpdateFeature( + log: log, preflight: preflightState(ahead: 1, behind: 3, diverged: true), onNotify: { notifications.append($0) } + ) + await moved.refreshGit() + await moved.resolvePullStrategy(.merge, for: request) + + #expect(!log.all.contains { $0.hasPrefix("pull:") }) + #expect(notifications.last == "The branch changed while choosing a pull strategy. Review it and try again.") + } + + @Test + func gitConfiguredStrategyUpdatesWithoutAsking() async { + let log = UpdateCallLog() + let feature = makeUpdateFeature( + log: log, preflight: preflightState(ahead: 1, diverged: true, configured: .rebase), onNotify: { _ in } + ) + await feature.refreshGit() + await feature.updateBranch(updateReference(current: true)) + + #expect(log.all.last == "pull:/workspace:rebase:refs/heads/main") + #expect(feature.pendingPullStrategy == nil) + } + + @Test + func localChangesBlockTheUpdateBeforeAnyPull() async { + var notifications: [String] = [] + let log = UpdateCallLog() + let feature = makeUpdateFeature( + log: log, preflight: preflightState(dirty: true), onNotify: { notifications.append($0) } + ) + await feature.refreshGit() + await feature.updateBranch(updateReference(current: true)) + + #expect(!log.all.contains { $0.hasPrefix("pull:") }) + #expect(notifications.last == "Commit or stash local changes before pulling.") + } + + @Test + func failedInspectionStopsTheUpdateInsteadOfPullingBlind() async { + var notifications: [String] = [] + let log = UpdateCallLog() + let feature = makeUpdateFeature(log: log, preflight: nil, onNotify: { notifications.append($0) }) + await feature.refreshGit() + await feature.updateBranch(updateReference(current: true)) + + #expect(log.all == ["fetch:/workspace", "preflight:/workspace"]) + #expect(notifications.last == "Unable to inspect the branch.") + #expect(!feature.isPerformingBranchOperation) } @Test @@ -6888,13 +7015,14 @@ private struct TestGitOperations: GitOperations { }, confirmedRoots: roots.filter { succeeded.contains($0) }, failedRoots: roots.filter { !succeeded.contains($0) }) } /// Test double for `git.normalizeChangeTargets`; Core's own tests and the shared - /// fixture (`change-targets-v1.json`) define the real rule. + /// fixture (`change-targets-v1.json`) define the real rule. Every other planner + /// runs in the real linked Core, so these tests never mirror its rules. func corePlan( _ command: GitCorePlanCommand, _ request: Request ) -> Response? { - guard command == .normalizeChangeTargets, - let data = try? JSONEncoder().encode(request), + if command != .normalizeChangeTargets { return GitCorePlanTestSupport.run(command, request) } + guard let data = try? JSONEncoder().encode(request), let targets = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["targets"] as? [[String: Any]] else { return nil } let indices = targets.indices.filter { index in @@ -6971,6 +7099,9 @@ private struct TestGitOperations: GitOperations { private let deleteBranchResult: GitProcessResult? private let deleteBranchResults: GitProcessResultQueue? private let updateBranchResult: GitProcessResult? + private let pullPreflightHandler: (@Sendable (URL) -> GitPullPreflightState?)? + private let updateCurrentBranchHandler: (@Sendable (URL, GitPullStrategy, String?) -> GitProcessResult?)? + private let fetchAtRootHandler: (@Sendable (URL) -> GitProcessResult?)? private let branchCallRecorder: BranchCallRecorder? private let exportPatchHandler: (@Sendable ([String], Bool) -> Result)? private let removeWorktreeResult: GitProcessResult? @@ -7036,6 +7167,9 @@ private struct TestGitOperations: GitOperations { deleteBranchResult: GitProcessResult? = nil, deleteBranchResults: GitProcessResultQueue? = nil, updateBranchResult: GitProcessResult? = nil, + pullPreflightHandler: (@Sendable (URL) -> GitPullPreflightState?)? = nil, + updateCurrentBranchHandler: (@Sendable (URL, GitPullStrategy, String?) -> GitProcessResult?)? = nil, + fetchAtRootHandler: (@Sendable (URL) -> GitProcessResult?)? = nil, branchCallRecorder: BranchCallRecorder? = nil, exportPatchHandler: (@Sendable ([String], Bool) -> Result)? = nil, removeWorktreeResult: GitProcessResult? = nil, @@ -7106,6 +7240,9 @@ private struct TestGitOperations: GitOperations { self.deleteBranchResult = deleteBranchResult self.deleteBranchResults = deleteBranchResults self.updateBranchResult = updateBranchResult + self.pullPreflightHandler = pullPreflightHandler + self.updateCurrentBranchHandler = updateCurrentBranchHandler + self.fetchAtRootHandler = fetchAtRootHandler self.branchCallRecorder = branchCallRecorder self.exportPatchHandler = exportPatchHandler self.removeWorktreeResult = removeWorktreeResult @@ -7343,7 +7480,9 @@ private struct TestGitOperations: GitOperations { exitCode: 0 ) } - func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy) -> GitProcessResult? { nil } + func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy, expectedBranch: String?) -> GitProcessResult? { + updateCurrentBranchHandler?(rootURL, strategy, expectedBranch) + } func updateBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { branchCallRecorder?.record(BranchCallRecorder.Call(name: "update", reference: reference.fullName, checkout: false)) return updateBranchResult @@ -7359,10 +7498,13 @@ private struct TestGitOperations: GitOperations { exitCode: 0 ) } - func pullPreflight(at rootURL: URL) -> GitPullPreflightState? { nil } + func pullPreflight(at rootURL: URL) -> GitPullPreflightState? { pullPreflightHandler?(rootURL) } func conflictMarkerPaths(at rootURL: URL) -> [String] { [] } func integrationPreflight(for target: GitIntegrationTarget, operation: GitIntegrationOperation, at rootURL: URL) -> GitIntegrationPreflightState? { nil } - func fetch(at rootURL: URL) -> GitProcessResult? { fetchHandler?(fetchPlanValue?.options ?? GitFetchOptions(), "default-fetch-fixture") } + func fetch(at rootURL: URL) -> GitProcessResult? { + if let fetchAtRootHandler { return fetchAtRootHandler(rootURL) } + return fetchHandler?(fetchPlanValue?.options ?? GitFetchOptions(), "default-fetch-fixture") + } func executionSettings(_ request: GitConfigurationEdit, save: Bool) -> Result { .success(GitExecutionSettingsSnapshot(executable: nil, version: "git fixture", scope: "local", entries: [], fields: [], temporaryConfig: [], fetchOptions: fetchPlanValue?.options ?? GitFetchOptions(), fetchError: nil, credentialHelperEnabled: true, interactiveAuthentication: false)) diff --git a/macos/Tests/LitheTests/DiffScrollSynchronizationTests.swift b/macos/Tests/LitheTests/DiffScrollSynchronizationTests.swift index 453d89874..31428edc3 100644 --- a/macos/Tests/LitheTests/DiffScrollSynchronizationTests.swift +++ b/macos/Tests/LitheTests/DiffScrollSynchronizationTests.swift @@ -592,7 +592,7 @@ struct DiffToolbarGitOperations: GitOperations { func mergeBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } func rebaseCurrentBranch(onto reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } func checkoutAndRebase(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } - func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy) -> GitProcessResult? { nil } + func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy, expectedBranch: String?) -> GitProcessResult? { nil } func pullRemoteReference(_ reference: GitReference, strategy: GitPullStrategy, at rootURL: URL) -> GitProcessResult? { nil } func pullPreflight(at rootURL: URL) -> GitPullPreflightState? { nil } func conflictMarkerPaths(at rootURL: URL) -> [String] { [] } diff --git a/macos/Tests/LitheTests/GitStatusObservationTests.swift b/macos/Tests/LitheTests/GitStatusObservationTests.swift index 6779ddc76..4b61a4616 100644 --- a/macos/Tests/LitheTests/GitStatusObservationTests.swift +++ b/macos/Tests/LitheTests/GitStatusObservationTests.swift @@ -120,7 +120,7 @@ struct GitStatusObservationTests { #expect(headActions.canUndo) #expect(!headActions.canCherryPick) let history = try #require(operations.history(at: root, reference: nil, limit: 10)) - editor.retainSelection(in: history.commits, fallback: head) + editor.applySelection(selected: [head], focused: head, anchor: head) #expect(editor.capabilities(at: root) == nil) try #require(await refreshed.waitUntilOpen()) #expect(editor.capabilities(at: root)?.canUndo == true) diff --git a/rust/lithe-core/src/git/history_window.rs b/rust/lithe-core/src/git/history_window.rs new file mode 100644 index 000000000..5b18938de --- /dev/null +++ b/rust/lithe-core/src/git/history_window.rs @@ -0,0 +1,274 @@ +//! Plans how a loaded Git history window survives a refresh. +//! +//! A metadata change reloads the history while the user may be deep in it. The +//! window must keep its loaded depth, absorb commits that appeared at the head +//! without evicting its old tail, and keep the selection, focus, and range +//! anchor when those commits are still loaded. Hosts own the commits, cursors, +//! and pixel scroll anchors; they send commit identities and apply the plan. + +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; + +/// Largest window either Git Log keeps; matches the graph context bound. +pub(crate) const MAX_WINDOW_COMMITS: usize = 5_000; +/// Upper bound for identities in one request (a window plus one extension). +const MAX_IDENTITIES: usize = 2 * MAX_WINDOW_COMMITS; + +#[derive(Debug, Deserialize)] +#[serde( + tag = "operation", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +enum Request { + /// Size of the first page a refresh reads. + RefreshPlan { + /// Commits in the window before the refresh; `0` for a fresh history. + loaded_count: usize, + /// The host's normal page size. + page_size: usize, + /// Keep the loaded depth (watcher refresh or retry) instead of resetting. + preserve_loaded: bool, + }, + /// Whether the refreshed first page must be extended to keep the old tail. + ExtendPlan { + /// Window before the refresh, in display order. + previous: Vec, + /// Refreshed first page, in display order. + page: Vec, + /// The refreshed page has a continuation cursor. + can_continue: bool, + }, + /// Appends a continuation page to a window without repeating commits. + MergePage { + window: Vec, + page: Vec, + /// The continuation reported more history after `page`. + page_has_more: bool, + }, + /// Retains the selection after the window changed. + ReconcileSelection { + /// Window after the refresh, in display order. + window: Vec, + selected: Vec, + #[serde(default)] + focused: Option, + #[serde(default)] + anchor: Option, + }, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase", untagged)] +enum Response { + #[serde(rename_all = "camelCase")] + RefreshPlan { limit: usize }, + #[serde(rename_all = "camelCase")] + ExtendPlan { + /// Commits to read after the refreshed page; `0` keeps the page as is. + extension_limit: usize, + }, + #[serde(rename_all = "camelCase")] + MergePage { + /// Positions in `page` to append, ascending. + append: Vec, + /// More history may follow; a page that added nothing ends the window. + has_more: bool, + }, + #[serde(rename_all = "camelCase")] + ReconcileSelection { + /// Retained selection in window order. + selected: Vec, + focused: Option, + anchor: Option, + }, +} + +fn invalid(message: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +/// Handles `git.historyWindow`. +pub(crate) fn command(payload: Value) -> Result { + let request: Request = serde_json::from_value(payload).map_err(|error| { + invalid("Invalid Git history window request").with_details(error.to_string()) + })?; + let response = match request { + Request::RefreshPlan { + loaded_count, + page_size, + preserve_loaded, + } => { + if page_size == 0 || page_size > MAX_WINDOW_COMMITS { + return Err(invalid("History page size must be between 1 and 5000")); + } + let limit = if preserve_loaded { + page_size.max(loaded_count) + } else { + page_size + }; + Response::RefreshPlan { + limit: limit.min(MAX_WINDOW_COMMITS), + } + } + Request::ExtendPlan { + previous, + page, + can_continue, + } => { + bounded(&[&previous, &page])?; + Response::ExtendPlan { + extension_limit: extension_limit(&previous, &page, can_continue), + } + } + Request::MergePage { + window, + page, + page_has_more, + } => { + bounded(&[&window, &page])?; + let mut loaded = window.iter().map(String::as_str).collect::>(); + let room = MAX_WINDOW_COMMITS.saturating_sub(window.len()); + let append = page + .iter() + .enumerate() + .filter(|(_, hash)| loaded.insert(hash.as_str())) + .map(|(index, _)| index) + .take(room) + .collect::>(); + let has_more = page_has_more + && !append.is_empty() + && window.len() + append.len() < MAX_WINDOW_COMMITS; + Response::MergePage { append, has_more } + } + Request::ReconcileSelection { + window, + selected, + focused, + anchor, + } => { + bounded(&[&window, &selected])?; + reconcile(&window, &selected, focused, anchor) + } + }; + Ok(serde_json::to_value(response).expect("history window plan encodes")) +} + +fn bounded(lists: &[&Vec]) -> Result<(), CoreError> { + if lists.iter().map(|list| list.len()).sum::() > MAX_IDENTITIES { + return Err(invalid("Too many commit identities for one history window")); + } + Ok(()) +} + +/// New heads displace the old window downwards. Extending by exactly that +/// displacement keeps the old tail loaded without reading another full page on +/// every watcher event. +fn extension_limit(previous: &[String], page: &[String], can_continue: bool) -> usize { + let Some(tail) = previous.last() else { + return 0; + }; + if !can_continue || page.contains(tail) { + return 0; + } + let mut positions = HashMap::new(); + for (index, hash) in previous.iter().enumerate() { + positions.entry(hash.as_str()).or_insert(index); + } + let Some((offset, previous_index)) = page + .iter() + .enumerate() + .find_map(|(offset, hash)| positions.get(hash.as_str()).map(|index| (offset, *index))) + else { + // A rewritten history shares nothing with the old window; its depth + // cannot be inferred, so the refreshed page stands on its own. + return 0; + }; + offset + .saturating_sub(previous_index) + .min(MAX_WINDOW_COMMITS.saturating_sub(page.len())) +} + +/// Keeps loaded identities. An emptied selection falls back to the focused +/// commit, which itself falls back to the first loaded commit, and the range +/// anchor falls back to the focus so Shift-selection extends from a visible row. +fn reconcile( + window: &[String], + selected: &[String], + focused: Option, + anchor: Option, +) -> Response { + let loaded = window.iter().map(String::as_str).collect::>(); + let is_loaded = + |hash: &Option| hash.as_deref().is_some_and(|hash| loaded.contains(hash)); + let focused = if is_loaded(&focused) { + focused + } else { + window.first().cloned() + }; + let wanted = selected.iter().map(String::as_str).collect::>(); + let mut retained = window + .iter() + .filter(|hash| wanted.contains(hash.as_str())) + .cloned() + .collect::>(); + if retained.is_empty() { + retained.extend(focused.clone()); + } + let anchor = if is_loaded(&anchor) { + anchor + } else { + focused.clone() + }; + Response::ReconcileSelection { + selected: retained, + focused, + anchor, + } +} + +#[cfg(test)] +mod tests { + use serde_json::{json, Value}; + + fn call(payload: Value) -> Value { + serde_json::from_str(&crate::execute_json( + &json!({"id":"window", "command":"git.historyWindow", "payload":payload}).to_string(), + )) + .unwrap() + } + + #[test] + fn shared_fixture_windows_keep_depth_and_selection() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/git/history-window-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response = call(case["request"].clone()); + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], case["response"], "{}", case["name"]); + } + for case in fixture["invalid"].as_array().unwrap() { + assert_eq!( + call(case["request"].clone())["ok"], + false, + "{}", + case["name"] + ); + } + } + + #[test] + fn merge_never_grows_past_the_window_bound() { + let window = (0..4_999) + .map(|index| format!("w{index}")) + .collect::>(); + let response = call(json!({"operation":"mergePage", "window":window, + "page":["a","b","c"], "pageHasMore":true})); + assert_eq!(response["data"], json!({"append":[0], "hasMore":false})); + } +} diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 21e2d197c..1d79d3880 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -28,6 +28,7 @@ mod github_checkout; pub(crate) mod graph; mod history; mod history_query; +mod history_window; mod metadata_watch; mod mutations; mod patch_exchange; @@ -42,6 +43,7 @@ mod rollback; mod setup; mod shelf; mod untracked_directories; +mod update_plan; // The response and reference types stay owned by the module; callers consume // them through the command contract instead of naming more shared types. @@ -78,6 +80,7 @@ pub use history::{ GitHistoryRequest, GitReferencesRequest, }; pub(crate) use history_query::query_command as history_query_command; +pub(crate) use history_window::command as history_window_command; pub(crate) use metadata_watch::classify_command as metadata_classification_command; pub use metadata_watch::{ classify_git_metadata_change, GitMetadataChangeKind, GitMetadataEventClassification, @@ -97,6 +100,7 @@ pub(crate) use reference_tree::command as reference_tree_command; pub(crate) use repository_colors::assign_command as repository_color_assignments_command; pub(crate) use rollback::{rollback_plan_command, rollback_prepare_command}; pub(crate) use shelf::shelf_command; +pub(crate) use update_plan::command as update_plan_command; pub(crate) use rewrite::history_action_capabilities_command; pub use rewrite::{ @@ -3681,6 +3685,7 @@ pub fn pull_preflight( behind: 0, diverged: false, has_local_changes: false, + configured_strategy: None, }); } let upstream = upstream.output.trim().to_string(); @@ -3707,7 +3712,7 @@ pub fn pull_preflight( let ahead = fields.next().and_then(|v| v.parse().ok()).unwrap_or(0); let dirty = readonly_command(GitCommandRequest { - root, + root: root.clone(), arguments: vec![ "status".to_string(), "--porcelain".to_string(), @@ -3728,9 +3733,39 @@ pub fn pull_preflight( behind, diverged: ahead > 0 && behind > 0, has_local_changes: !dirty.output.trim().is_empty(), + configured_strategy: configured_pull_strategy(&root)?, }) } +/// The pull strategy Git is explicitly configured for, or `None` when neither +/// `pull.rebase` nor `pull.ff` says anything. `git.updatePlan` falls back to +/// fast-forward only in that case. +fn configured_pull_strategy(root: &str) -> Result, CoreError> { + let rebase = read_git_config_value(root, "pull.rebase")?; + let fast_forward = read_git_config_value(root, "pull.ff")?; + let enabled = |value: &Option, accepted: &[&str]| { + value + .as_deref() + .is_some_and(|value| accepted.contains(&value.to_ascii_lowercase().as_str())) + }; + Ok( + if enabled( + &rebase, + &["true", "yes", "on", "1", "interactive", "merges"], + ) { + Some("rebase") + } else if enabled(&fast_forward, &["only"]) { + Some("ffOnly") + } else if enabled(&fast_forward, &["false", "no", "off", "0"]) + || enabled(&rebase, &["false", "no", "off", "0"]) + { + Some("merge") + } else { + None + }, + ) +} + /// Resolves the Git directory once, honoring worktrees and submodules where /// `.git` is a file pointing elsewhere rather than a directory. fn git_directory(root: &str) -> Result, CoreError> { diff --git a/rust/lithe-core/src/git/update_plan.rs b/rust/lithe-core/src/git/update_plan.rs new file mode 100644 index 000000000..ee8256570 --- /dev/null +++ b/rust/lithe-core/src/git/update_plan.rs @@ -0,0 +1,340 @@ +//! Plans branch Update and Pull from facts the host collected. +//! +//! IDEA's branch Update pulls a branch inside the worktree that has it checked +//! out and fast-forwards any other local branch in place. Both products used +//! to encode the gates in their own way: one merged a diverged current branch +//! without asking, the other continued after a failed inspection. This module +//! owns every decision; hosts run Fetch, read worktrees and `git.pullPreflight`, +//! present the result, and execute the returned step. +//! +//! Strategy precedence is: what the user chose, then what Git is configured to +//! do (`pull.rebase`, `pull.ff`, reported by `git.pullPreflight`), then +//! fast-forward only. A fast-forward never invents a merge commit or replays +//! local history, so it is the default when nobody expressed a preference. + +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Upper bound for worktree facts in one request. +const MAX_WORKTREES: usize = 10_000; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Local branch the user asked to update. +struct Branch { + /// Complete local reference, `refs/heads/`. + full_name: String, + /// Configured upstream short name; `null` when none is configured. + #[serde(default)] + upstream: Option, + /// Checked out in the worktree the request came from. + is_current: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One worktree of the repository as the host listed it. +struct Worktree { + path: String, + /// Complete checked-out reference; `null` when detached. + #[serde(default)] + branch: Option, + is_current: bool, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// `git.pullPreflight` for the checkout being updated, read after Fetch. +struct Preflight { + #[serde(default)] + upstream: Option, + ahead: usize, + behind: usize, + diverged: bool, + has_local_changes: bool, + /// Strategy Git is explicitly configured for; `null` when unset. + #[serde(default)] + configured_strategy: Option, +} + +impl Preflight { + /// The history the user reviewed; a dirty flag alone never changes it. + fn same_history(&self, other: &Self) -> bool { + self.upstream == other.upstream + && self.ahead == other.ahead + && self.behind == other.behind + && self.diverged == other.diverged + } +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +/// `git.write` pull mode. +enum Strategy { + FfOnly, + Merge, + Rebase, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// The user's answer to a `chooseStrategy` step. +struct Confirmation { + strategy: Strategy, + /// Inspection shown while the user chose. + preflight: Preflight, +} + +#[derive(Debug, Deserialize)] +#[serde( + tag = "stage", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +enum Request { + /// Chooses where and how a branch Update runs. + Route { + branch: Branch, + worktrees: Vec, + }, + /// Gates a pull in one checkout after Fetch and inspection. + Inspect { + preflight: Preflight, + /// The user can be asked to choose a strategy for a diverged history. + interactive: bool, + /// The pull names its source instead of using the configured upstream, + /// so upstream presence and "up to date" do not apply. + #[serde(default)] + explicit_source: bool, + /// Strategy already chosen by the user, for example in a Pull dialog. + #[serde(default)] + strategy: Option, + #[serde(default)] + confirmation: Option, + }, + /// Picks the strategy a Pull dialog preselects before the user decides. + DefaultStrategy { + preflight: Preflight, + /// The selected source can be fast-forwarded into the current branch. + fast_forward_possible: bool, + }, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +/// Stable reason a step stops; hosts localize it. +enum Blocker { + /// The branch or checkout has no configured upstream. + NoUpstream, + /// Tracked local changes would be mixed into the integration. + LocalChanges, + /// The upstream has nothing new. + UpToDate, + /// The history diverged and no one can be asked for a strategy. + NotFastForward, + /// The inspected history changed after the user confirmed a strategy. + StateChanged, +} + +#[derive(Debug, Serialize)] +#[serde( + tag = "step", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +enum Plan { + Blocked { + blocker: Blocker, + }, + /// Fast-forward the unchecked-out branch from its upstream with `updateBranch`. + FastForwardReference { + expected_branch: String, + }, + /// Fetch in `checkout`, read `git.pullPreflight` there, then plan `inspect`. + Inspect { + /// Worktree path; `null` means the repository root of the request. + checkout: Option, + expected_branch: String, + upstream: String, + /// Only the current worktree may ask the user for a strategy. + interactive: bool, + }, + /// Ask merge or rebase, then plan `inspect` again with a fresh preflight. + ChooseStrategy, + /// Run `pull` with this mode; Update passes its `expectedBranch` as well. + Pull { + strategy: Strategy, + }, + /// The strategy a dialog preselects. + DefaultStrategy { + strategy: Strategy, + }, +} + +fn invalid(message: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +/// Handles `git.updatePlan`. +pub(crate) fn command(payload: Value) -> Result { + let request: Request = serde_json::from_value(payload).map_err(|error| { + invalid("Invalid Git update plan request").with_details(error.to_string()) + })?; + let plan = match request { + Request::Route { branch, worktrees } => route(branch, &worktrees)?, + Request::Inspect { + preflight, + interactive, + explicit_source, + strategy, + confirmation, + } => inspect( + &preflight, + interactive, + explicit_source, + strategy, + confirmation, + )?, + Request::DefaultStrategy { + preflight, + fast_forward_possible, + } => Plan::DefaultStrategy { + strategy: default_strategy(&preflight, fast_forward_possible), + }, + }; + Ok(serde_json::to_value(plan).expect("Git update plan encodes")) +} + +fn route(branch: Branch, worktrees: &[Worktree]) -> Result { + if !branch.full_name.starts_with("refs/heads/") || branch.full_name == "refs/heads/" { + return Err(invalid("Branch Update requires a complete local reference")); + } + if worktrees.len() > MAX_WORKTREES || worktrees.iter().any(|worktree| worktree.path.is_empty()) + { + return Err(invalid("Invalid worktree facts")); + } + let Some(upstream) = branch.upstream.filter(|upstream| !upstream.is_empty()) else { + return Ok(Plan::Blocked { + blocker: Blocker::NoUpstream, + }); + }; + let owner = worktrees + .iter() + .find(|worktree| worktree.branch.as_deref() == Some(branch.full_name.as_str())); + // Git refuses to fetch into a branch any worktree has checked out, so such + // a branch is pulled inside its own worktree instead. + let plan = match owner { + Some(worktree) => Plan::Inspect { + checkout: Some(worktree.path.clone()), + expected_branch: branch.full_name, + upstream, + interactive: worktree.is_current, + }, + None if branch.is_current => Plan::Inspect { + checkout: None, + expected_branch: branch.full_name, + upstream, + interactive: true, + }, + None => Plan::FastForwardReference { + expected_branch: branch.full_name, + }, + }; + Ok(plan) +} + +fn inspect( + preflight: &Preflight, + interactive: bool, + explicit_source: bool, + strategy: Option, + confirmation: Option, +) -> Result { + if confirmation.is_some() && (!interactive || strategy.is_some()) { + return Err(invalid( + "Only an interactive pull without a chosen strategy can be confirmed", + )); + } + if confirmation + .as_ref() + .is_some_and(|confirmation| confirmation.strategy == Strategy::FfOnly) + { + return Err(invalid( + "A diverged history is confirmed with merge or rebase", + )); + } + let blocked = |blocker| Ok(Plan::Blocked { blocker }); + if !explicit_source && preflight.upstream.as_deref().is_none_or(str::is_empty) { + return blocked(Blocker::NoUpstream); + } + if let Some(confirmation) = &confirmation { + if !confirmation.preflight.same_history(preflight) { + return blocked(Blocker::StateChanged); + } + } + if preflight.has_local_changes { + return blocked(Blocker::LocalChanges); + } + if !explicit_source && preflight.behind == 0 && !preflight.diverged { + return blocked(Blocker::UpToDate); + } + if let Some(confirmation) = confirmation { + return Ok(Plan::Pull { + strategy: confirmation.strategy, + }); + } + // The user's choice wins over Git's configuration, which wins over the + // fast-forward default. + let resolved = strategy + .or(preflight.configured_strategy) + .unwrap_or(Strategy::FfOnly); + match resolved { + Strategy::Merge | Strategy::Rebase => Ok(Plan::Pull { strategy: resolved }), + Strategy::FfOnly if !preflight.diverged => Ok(Plan::Pull { + strategy: Strategy::FfOnly, + }), + Strategy::FfOnly if interactive => Ok(Plan::ChooseStrategy), + Strategy::FfOnly => blocked(Blocker::NotFastForward), + } +} + +/// A dialog preselects the configured strategy, then fast-forward when the +/// source allows it, then merge, which never rewrites local commits. +fn default_strategy(preflight: &Preflight, fast_forward_possible: bool) -> Strategy { + match preflight.configured_strategy { + Some(Strategy::FfOnly) | None if fast_forward_possible => Strategy::FfOnly, + Some(Strategy::FfOnly) | None => Strategy::Merge, + Some(configured) => configured, + } +} + +#[cfg(test)] +mod tests { + use serde_json::{json, Value}; + + fn call(payload: Value) -> Value { + serde_json::from_str(&crate::execute_json( + &json!({"id":"update", "command":"git.updatePlan", "payload":payload}).to_string(), + )) + .unwrap() + } + + #[test] + fn shared_fixture_steps_are_the_only_update_rules() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/git/update-plan-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response = call(case["request"].clone()); + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], case["plan"], "{}", case["name"]); + } + for case in fixture["invalid"].as_array().unwrap() { + let response = call(case["request"].clone()); + assert_eq!(response["ok"], false, "{}", case["name"]); + } + } +} diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 40aa1c1d0..49a1bbfcb 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -738,6 +738,10 @@ pub enum CoreCommand { GitBranchNamePreview, /// Assigns palette slots to a page of ordered repository identities. GitRepositoryColorAssignments, + /// Plans a branch Update or Pull from collected facts (`git.updatePlan`). + GitUpdatePlan, + /// Plans history refresh windows, page merges, and selection retention (`git.historyWindow`). + GitHistoryWindow, /// Drops targets already covered by a selected untracked summary (`git.normalizeChangeTargets`). GitNormalizeChangeTargets, /// Validates an interactive rebase draft and derives its messages (`git.rebasePlanPreview`). @@ -1173,6 +1177,8 @@ impl CoreCommand { "git.historyLocate" => Some(Self::GitHistoryLocate), "git.branchNamePreview" => Some(Self::GitBranchNamePreview), "git.repositoryColorAssignments" => Some(Self::GitRepositoryColorAssignments), + "git.updatePlan" => Some(Self::GitUpdatePlan), + "git.historyWindow" => Some(Self::GitHistoryWindow), "git.normalizeChangeTargets" => Some(Self::GitNormalizeChangeTargets), "git.rebasePlanPreview" => Some(Self::GitRebasePlanPreview), "git.referenceTree" => Some(Self::GitReferenceTree), diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index 43a363dc2..6ad336f75 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -911,6 +911,9 @@ pub struct GitPullPreflightResponse { pub behind: usize, pub diverged: bool, pub has_local_changes: bool, + /// `rebase`, `merge`, or `ffOnly` when `pull.rebase` or `pull.ff` says so; + /// absent when Git is not configured for a strategy. + pub configured_strategy: Option<&'static str>, } /// A sequential operation Git left half-finished, usually because of a conflict. diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index d7bb15009..93a0f83b2 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -3388,6 +3388,12 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { parsed.payload, crate::git::repository_color_assignments_command, ), + CoreCommand::GitUpdatePlan => { + plugin_response(id, parsed.payload, crate::git::update_plan_command) + } + CoreCommand::GitHistoryWindow => { + plugin_response(id, parsed.payload, crate::git::history_window_command) + } CoreCommand::GitNormalizeChangeTargets => plugin_response( id, parsed.payload, diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index fb884d095..f3489d91f 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -4548,6 +4548,26 @@ fn git_pull_preflight_reports_divergence_and_strategies_resolve_it() { assert_eq!(clean["data"]["diverged"], false); assert_eq!(clean["data"]["ahead"], 0); assert_eq!(clean["data"]["behind"], 0); + // Without pull.rebase or pull.ff nothing is configured, so Core defaults. + assert!(clean["data"]["configuredStrategy"].is_null()); + for (key, value, expected) in [ + ("pull.rebase", "true", "rebase"), + ("pull.rebase", "false", "merge"), + ("pull.ff", "only", "ffOnly"), + ("pull.ff", "false", "merge"), + ] { + // Each case starts from a clean configuration. + let _ = git(&work, &["config", "--unset-all", "pull.rebase"]); + let _ = git(&work, &["config", "--unset-all", "pull.ff"]); + assert!(git(&work, &["config", key, value]).status.success()); + assert_eq!( + preflight()["data"]["configuredStrategy"], + expected, + "{key}={value}" + ); + } + let _ = git(&work, &["config", "--unset-all", "pull.rebase"]); + let _ = git(&work, &["config", "--unset-all", "pull.ff"]); // Commit on both sides so neither can fast-forward past the other. fs::write(upstream.join("remote.txt"), "remote\n").expect("file should be writable"); diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index cbfff37c8..1751abf58 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1491,7 +1491,9 @@ package manager owns the download and Lithe does not infer bytes from logs. | `git.comparison` | Return files changed between a reference and the working tree | | `git.stashes` | Return structured stash references and messages | | `git.checkoutPreflight` | Return local paths that would block switching to a reference | -| `git.pullPreflight` | Report the configured upstream, ahead/behind counts, divergence, and tracked local changes without fetching | +| `git.pullPreflight` | Report the configured upstream, ahead/behind counts, divergence, tracked local changes, and the strategy Git is configured for, without fetching | +| `git.updatePlan` | Route a branch Update and gate a Pull from facts the host collected: blockers, strategy default, whether to ask, and the expected checkout | +| `git.historyWindow` | Plan a history refresh window, page merge, and selection retention over commit identities | | `git.integrationPreflight` | Return local paths that block a merge, rebase, cherry-pick, or revert | | `git.conflictMarkers` | Return staged text files that still contain conflict markers | | `git.operationState` | Report an interrupted merge, rebase, cherry-pick, or revert and its conflicted paths | @@ -2421,6 +2423,33 @@ string or `null`, numeric `ahead` and `behind` counts, `diverged`, and `diverged` is true only when both counts are non-zero. `hasLocalChanges` checks tracked changes and excludes untracked files. A branch with no configured upstream returns `null`, zero counts, and false for both booleans. +`configuredStrategy` is `"rebase"` when `pull.rebase` is true, `"ffOnly"` when +`pull.ff` is `only`, `"merge"` when `pull.ff` or `pull.rebase` is false, and +`null` when Git is not configured for a strategy. + +`git.updatePlan` is a pure planner; it never starts Git. `{ "stage": "route", +"branch": { "fullName", "upstream", "isCurrent" }, "worktrees": [{ "path", +"branch", "isCurrent" }] }` returns `blocked`, `fastForwardReference`, or +`inspect` with the `checkout` worktree (or `null` for the request root), the +`expectedBranch`, and whether the user may be asked. `{ "stage": "inspect", +"preflight", "interactive", "explicitSource"?, "strategy"?, "confirmation"? }` +returns `blocked` (`noUpstream`, `localChanges`, `upToDate`, `notFastForward`, +`stateChanged`), `chooseStrategy`, or `pull` with the mode for `git.write`. +The strategy is the user's choice, else the preflight's `configuredStrategy`, +else `ffOnly`; a diverged history that would need `ffOnly` asks when +`interactive` and is refused otherwise. `{ "stage": "defaultStrategy", +"preflight", "fastForwardPossible" }` returns the strategy a Pull dialog +preselects. `shared/fixtures/git/update-plan-v1.json` defines the rules. + +`git.historyWindow` is a pure planner over commit identities in display order. +`refreshPlan` returns the first-page `limit`: with `preserveLoaded` it is at +least the loaded count, so a refresh never shrinks a deep window. `extendPlan` +returns the `extensionLimit` that keeps the previous tail loaded after new +commits appeared at the head. `mergePage` returns the `append` positions of a +page that are not yet in the window and whether `hasMore`, bounded to 5000 +commits. `reconcileSelection` returns the retained `selected` identities in +window order plus `focused` and `anchor`, falling back to the first loaded +commit. `shared/fixtures/git/history-window-v1.json` defines the rules. `git.integrationPreflight` accepts either `reference` or `gitReference` with `root` and `operation`, where `operation` is `merge`, `rebase`, `cherryPick`, diff --git a/shared/fixtures/git/history-window-v1.json b/shared/fixtures/git/history-window-v1.json new file mode 100644 index 000000000..5a7501e62 --- /dev/null +++ b/shared/fixtures/git/history-window-v1.json @@ -0,0 +1,280 @@ +{ + "description": "git.historyWindow keeps a refreshed Git Log window at its loaded depth, extends it by commits that appeared at the head, and retains selection, focus, and range anchor. Hosts own commits, cursors, and pixel scroll anchors.", + "cases": [ + { + "name": "a watcher refresh keeps the loaded depth", + "request": { + "operation": "refreshPlan", + "loadedCount": 320, + "pageSize": 50, + "preserveLoaded": true + }, + "response": { + "limit": 320 + } + }, + { + "name": "a shallow window still reads a full page", + "request": { + "operation": "refreshPlan", + "loadedCount": 12, + "pageSize": 50, + "preserveLoaded": true + }, + "response": { + "limit": 50 + } + }, + { + "name": "a fresh history starts at one page", + "request": { + "operation": "refreshPlan", + "loadedCount": 320, + "pageSize": 100, + "preserveLoaded": false + }, + "response": { + "limit": 100 + } + }, + { + "name": "the refresh never exceeds the window bound", + "request": { + "operation": "refreshPlan", + "loadedCount": 9000, + "pageSize": 100, + "preserveLoaded": true + }, + "response": { + "limit": 5000 + } + }, + { + "name": "new heads extend the page by their displacement", + "request": { + "operation": "extendPlan", + "previous": [ + "c1", + "c2", + "c3", + "c4" + ], + "page": [ + "n1", + "n2", + "c1", + "c2" + ], + "canContinue": true + }, + "response": { + "extensionLimit": 2 + } + }, + { + "name": "a page that still contains the old tail is kept", + "request": { + "operation": "extendPlan", + "previous": [ + "c1", + "c2" + ], + "page": [ + "n1", + "c1", + "c2" + ], + "canContinue": true + }, + "response": { + "extensionLimit": 0 + } + }, + { + "name": "an exhausted history is not extended", + "request": { + "operation": "extendPlan", + "previous": [ + "c1", + "c2", + "c3" + ], + "page": [ + "n1", + "c1" + ], + "canContinue": false + }, + "response": { + "extensionLimit": 0 + } + }, + { + "name": "a rewritten history shares nothing and is not extended", + "request": { + "operation": "extendPlan", + "previous": [ + "c1", + "c2" + ], + "page": [ + "r1", + "r2" + ], + "canContinue": true + }, + "response": { + "extensionLimit": 0 + } + }, + { + "name": "merging skips commits already in the window", + "request": { + "operation": "mergePage", + "window": [ + "n1", + "n2", + "c1", + "c2" + ], + "page": [ + "c2", + "c3", + "c4" + ], + "pageHasMore": true + }, + "response": { + "append": [ + 1, + 2 + ], + "hasMore": true + } + }, + { + "name": "a continuation that adds nothing ends the window", + "request": { + "operation": "mergePage", + "window": [ + "c1", + "c2" + ], + "page": [ + "c2" + ], + "pageHasMore": true + }, + "response": { + "append": [], + "hasMore": false + } + }, + { + "name": "selection, focus, and anchor survive when still loaded", + "request": { + "operation": "reconcileSelection", + "window": [ + "n1", + "c1", + "c2", + "c3" + ], + "selected": [ + "c3", + "c1" + ], + "focused": "c3", + "anchor": "c1" + }, + "response": { + "selected": [ + "c1", + "c3" + ], + "focused": "c3", + "anchor": "c1" + } + }, + { + "name": "missing commits fall back to the first row", + "request": { + "operation": "reconcileSelection", + "window": [ + "r1", + "r2" + ], + "selected": [ + "c1", + "c2" + ], + "focused": "c2", + "anchor": "c1" + }, + "response": { + "selected": [ + "r1" + ], + "focused": "r1", + "anchor": "r1" + } + }, + { + "name": "a missing anchor falls back to the retained focus", + "request": { + "operation": "reconcileSelection", + "window": [ + "c1", + "c2" + ], + "selected": [ + "c2", + "gone" + ], + "focused": "c2", + "anchor": "gone" + }, + "response": { + "selected": [ + "c2" + ], + "focused": "c2", + "anchor": "c2" + } + }, + { + "name": "an empty window clears the selection", + "request": { + "operation": "reconcileSelection", + "window": [], + "selected": [ + "c1" + ], + "focused": "c1", + "anchor": "c1" + }, + "response": { + "selected": [], + "focused": null, + "anchor": null + } + } + ], + "invalid": [ + { + "name": "zero page size", + "request": { + "operation": "refreshPlan", + "loadedCount": 0, + "pageSize": 0, + "preserveLoaded": false + } + }, + { + "name": "unknown operation", + "request": { + "operation": "scroll" + } + } + ] +} diff --git a/shared/fixtures/git/update-plan-v1.json b/shared/fixtures/git/update-plan-v1.json new file mode 100644 index 000000000..93dd578ec --- /dev/null +++ b/shared/fixtures/git/update-plan-v1.json @@ -0,0 +1,625 @@ +{ + "description": "git.updatePlan routes a branch Update and gates every pull. Hosts fetch and run git.pullPreflight for the returned checkout, ask only on chooseStrategy, and re-inspect before sending the confirmation.", + "cases": [ + { + "name": "current branch is inspected in its own worktree and may ask", + "request": { + "stage": "route", + "branch": { + "fullName": "refs/heads/main", + "upstream": "origin/main", + "isCurrent": true + }, + "worktrees": [ + { + "path": "/repo", + "branch": "refs/heads/main", + "isCurrent": true + }, + { + "path": "/repo-feature", + "branch": "refs/heads/feature", + "isCurrent": false + }, + { + "path": "/repo-detached", + "branch": null, + "isCurrent": false + } + ] + }, + "plan": { + "step": "inspect", + "checkout": "/repo", + "expectedBranch": "refs/heads/main", + "upstream": "origin/main", + "interactive": true + } + }, + { + "name": "current branch without listed worktrees uses the request root", + "request": { + "stage": "route", + "branch": { + "fullName": "refs/heads/main", + "upstream": "origin/main", + "isCurrent": true + }, + "worktrees": [] + }, + "plan": { + "step": "inspect", + "checkout": null, + "expectedBranch": "refs/heads/main", + "upstream": "origin/main", + "interactive": true + } + }, + { + "name": "a branch checked out in another worktree is pulled there without asking", + "request": { + "stage": "route", + "branch": { + "fullName": "refs/heads/feature", + "upstream": "origin/feature", + "isCurrent": false + }, + "worktrees": [ + { + "path": "/repo", + "branch": "refs/heads/main", + "isCurrent": true + }, + { + "path": "/repo-feature", + "branch": "refs/heads/feature", + "isCurrent": false + }, + { + "path": "/repo-detached", + "branch": null, + "isCurrent": false + } + ] + }, + "plan": { + "step": "inspect", + "checkout": "/repo-feature", + "expectedBranch": "refs/heads/feature", + "upstream": "origin/feature", + "interactive": false + } + }, + { + "name": "an unchecked-out branch fast-forwards in place", + "request": { + "stage": "route", + "branch": { + "fullName": "refs/heads/release", + "upstream": "origin/release", + "isCurrent": false + }, + "worktrees": [ + { + "path": "/repo", + "branch": "refs/heads/main", + "isCurrent": true + }, + { + "path": "/repo-feature", + "branch": "refs/heads/feature", + "isCurrent": false + }, + { + "path": "/repo-detached", + "branch": null, + "isCurrent": false + } + ] + }, + "plan": { + "step": "fastForwardReference", + "expectedBranch": "refs/heads/release" + } + }, + { + "name": "no upstream blocks before any network call", + "request": { + "stage": "route", + "branch": { + "fullName": "refs/heads/main", + "upstream": null, + "isCurrent": true + }, + "worktrees": [ + { + "path": "/repo", + "branch": "refs/heads/main", + "isCurrent": true + }, + { + "path": "/repo-feature", + "branch": "refs/heads/feature", + "isCurrent": false + }, + { + "path": "/repo-detached", + "branch": null, + "isCurrent": false + } + ] + }, + "plan": { + "step": "blocked", + "blocker": "noUpstream" + } + }, + { + "name": "behind only fast-forwards by default", + "request": { + "stage": "inspect", + "preflight": { + "upstream": "origin/main", + "ahead": 0, + "behind": 2, + "diverged": false, + "hasLocalChanges": false + }, + "interactive": true + }, + "plan": { + "step": "pull", + "strategy": "ffOnly" + } + }, + { + "name": "diverged current branch asks for a strategy", + "request": { + "stage": "inspect", + "preflight": { + "upstream": "origin/main", + "ahead": 1, + "behind": 2, + "diverged": true, + "hasLocalChanges": false + }, + "interactive": true + }, + "plan": { + "step": "chooseStrategy" + } + }, + { + "name": "diverged branch in another worktree cannot fast-forward", + "request": { + "stage": "inspect", + "preflight": { + "upstream": "origin/main", + "ahead": 1, + "behind": 2, + "diverged": true, + "hasLocalChanges": false + }, + "interactive": false + }, + "plan": { + "step": "blocked", + "blocker": "notFastForward" + } + }, + { + "name": "tracked local changes block the pull", + "request": { + "stage": "inspect", + "preflight": { + "upstream": "origin/main", + "ahead": 0, + "behind": 2, + "diverged": false, + "hasLocalChanges": true + }, + "interactive": true + }, + "plan": { + "step": "blocked", + "blocker": "localChanges" + } + }, + { + "name": "nothing new upstream", + "request": { + "stage": "inspect", + "preflight": { + "upstream": "origin/main", + "ahead": 3, + "behind": 0, + "diverged": false, + "hasLocalChanges": false + }, + "interactive": true + }, + "plan": { + "step": "blocked", + "blocker": "upToDate" + } + }, + { + "name": "inspected checkout without upstream", + "request": { + "stage": "inspect", + "preflight": { + "upstream": null, + "ahead": 0, + "behind": 0, + "diverged": false, + "hasLocalChanges": false + }, + "interactive": true + }, + "plan": { + "step": "blocked", + "blocker": "noUpstream" + } + }, + { + "name": "confirmed strategy runs when the history is unchanged", + "request": { + "stage": "inspect", + "preflight": { + "upstream": "origin/main", + "ahead": 1, + "behind": 2, + "diverged": true, + "hasLocalChanges": false + }, + "interactive": true, + "confirmation": { + "strategy": "rebase", + "preflight": { + "upstream": "origin/main", + "ahead": 1, + "behind": 2, + "diverged": true, + "hasLocalChanges": false + } + } + }, + "plan": { + "step": "pull", + "strategy": "rebase" + } + }, + { + "name": "confirmed strategy stops when the history moved", + "request": { + "stage": "inspect", + "preflight": { + "upstream": "origin/main", + "ahead": 1, + "behind": 3, + "diverged": true, + "hasLocalChanges": false + }, + "interactive": true, + "confirmation": { + "strategy": "merge", + "preflight": { + "upstream": "origin/main", + "ahead": 1, + "behind": 2, + "diverged": true, + "hasLocalChanges": false + } + } + }, + "plan": { + "step": "blocked", + "blocker": "stateChanged" + } + }, + { + "name": "confirmed strategy still refuses new local changes", + "request": { + "stage": "inspect", + "preflight": { + "upstream": "origin/main", + "ahead": 1, + "behind": 2, + "diverged": true, + "hasLocalChanges": true + }, + "interactive": true, + "confirmation": { + "strategy": "merge", + "preflight": { + "upstream": "origin/main", + "ahead": 1, + "behind": 2, + "diverged": true, + "hasLocalChanges": false + } + } + }, + "plan": { + "step": "blocked", + "blocker": "localChanges" + } + }, + { + "name": "a chosen merge does not ask again", + "request": { + "stage": "inspect", + "preflight": { + "upstream": "origin/main", + "ahead": 1, + "behind": 2, + "diverged": true, + "hasLocalChanges": false + }, + "interactive": true, + "strategy": "merge" + }, + "plan": { + "step": "pull", + "strategy": "merge" + } + }, + { + "name": "an explicit source ignores upstream and up-to-date gates", + "request": { + "stage": "inspect", + "preflight": { + "upstream": null, + "ahead": 0, + "behind": 0, + "diverged": false, + "hasLocalChanges": false + }, + "interactive": true, + "explicitSource": true, + "strategy": "rebase" + }, + "plan": { + "step": "pull", + "strategy": "rebase" + } + }, + { + "name": "Git configured for rebase pulls with rebase without asking", + "request": { + "stage": "inspect", + "preflight": { + "upstream": "origin/main", + "ahead": 1, + "behind": 2, + "diverged": true, + "hasLocalChanges": false, + "configuredStrategy": "rebase" + }, + "interactive": true + }, + "plan": { + "step": "pull", + "strategy": "rebase" + } + }, + { + "name": "Git configured for merge pulls with merge without asking", + "request": { + "stage": "inspect", + "preflight": { + "upstream": "origin/main", + "ahead": 1, + "behind": 2, + "diverged": true, + "hasLocalChanges": false, + "configuredStrategy": "merge" + }, + "interactive": false + }, + "plan": { + "step": "pull", + "strategy": "merge" + } + }, + { + "name": "Git configured for fast-forward only still asks when diverged", + "request": { + "stage": "inspect", + "preflight": { + "upstream": "origin/main", + "ahead": 1, + "behind": 2, + "diverged": true, + "hasLocalChanges": false, + "configuredStrategy": "ffOnly" + }, + "interactive": true + }, + "plan": { + "step": "chooseStrategy" + } + }, + { + "name": "Git configured for fast-forward only fast-forwards a linear history", + "request": { + "stage": "inspect", + "preflight": { + "upstream": "origin/main", + "ahead": 0, + "behind": 2, + "diverged": false, + "hasLocalChanges": false, + "configuredStrategy": "ffOnly" + }, + "interactive": true + }, + "plan": { + "step": "pull", + "strategy": "ffOnly" + } + }, + { + "name": "the user's choice wins over Git configuration", + "request": { + "stage": "inspect", + "preflight": { + "upstream": "origin/main", + "ahead": 1, + "behind": 2, + "diverged": true, + "hasLocalChanges": false, + "configuredStrategy": "rebase" + }, + "interactive": true, + "strategy": "merge" + }, + "plan": { + "step": "pull", + "strategy": "merge" + } + }, + { + "name": "configured strategy still honors local changes", + "request": { + "stage": "inspect", + "preflight": { + "upstream": "origin/main", + "ahead": 1, + "behind": 2, + "diverged": true, + "hasLocalChanges": true, + "configuredStrategy": "rebase" + }, + "interactive": true + }, + "plan": { + "step": "blocked", + "blocker": "localChanges" + } + }, + { + "name": "dialog default is fast-forward when the source allows it", + "request": { + "stage": "defaultStrategy", + "preflight": { + "upstream": "origin/main", + "ahead": 0, + "behind": 2, + "diverged": false, + "hasLocalChanges": false + }, + "fastForwardPossible": true + }, + "plan": { + "step": "defaultStrategy", + "strategy": "ffOnly" + } + }, + { + "name": "dialog default falls back to merge when fast-forward is impossible", + "request": { + "stage": "defaultStrategy", + "preflight": { + "upstream": "origin/main", + "ahead": 1, + "behind": 2, + "diverged": true, + "hasLocalChanges": false + }, + "fastForwardPossible": false + }, + "plan": { + "step": "defaultStrategy", + "strategy": "merge" + } + }, + { + "name": "dialog default follows Git configuration", + "request": { + "stage": "defaultStrategy", + "preflight": { + "upstream": "origin/main", + "ahead": 0, + "behind": 2, + "diverged": false, + "hasLocalChanges": false, + "configuredStrategy": "rebase" + }, + "fastForwardPossible": true + }, + "plan": { + "step": "defaultStrategy", + "strategy": "rebase" + } + } + ], + "invalid": [ + { + "name": "short branch name", + "request": { + "stage": "route", + "branch": { + "fullName": "main", + "upstream": "origin/main", + "isCurrent": true + }, + "worktrees": [] + } + }, + { + "name": "fast-forward is not a confirmation", + "request": { + "stage": "inspect", + "preflight": { + "upstream": "origin/main", + "ahead": 1, + "behind": 2, + "diverged": true, + "hasLocalChanges": false + }, + "interactive": true, + "confirmation": { + "strategy": "ffOnly", + "preflight": { + "upstream": "origin/main", + "ahead": 1, + "behind": 2, + "diverged": true, + "hasLocalChanges": false + } + } + } + }, + { + "name": "background updates cannot be confirmed", + "request": { + "stage": "inspect", + "preflight": { + "upstream": "origin/main", + "ahead": 1, + "behind": 2, + "diverged": true, + "hasLocalChanges": false + }, + "interactive": false, + "confirmation": { + "strategy": "merge", + "preflight": { + "upstream": "origin/main", + "ahead": 1, + "behind": 2, + "diverged": true, + "hasLocalChanges": false + } + } + } + }, + { + "name": "unknown stage", + "request": { + "stage": "execute" + } + } + ] +} diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 61a6892d1..ab48915e6 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -19135,10 +19135,15 @@ }, { "id": "git.pullResult.dirty", - "description": "Windows UI: git.pullResult.dirty", + "description": "Branch Update and Pull stop reason shown by macOS and Windows", "platforms": [ + "macos", "windows" - ] + ], + "macos": { + "key": "Commit or stash local changes before pulling.", + "arguments": [] + } }, { "id": "git.pullResult.fetchFailed", @@ -19159,10 +19164,15 @@ }, { "id": "git.pullResult.inspectBranchFailed", - "description": "Windows UI: git.pullResult.inspectBranchFailed", + "description": "Branch Update and Pull stop reason shown by macOS and Windows", "platforms": [ + "macos", "windows" - ] + ], + "macos": { + "key": "Unable to inspect the branch.", + "arguments": [] + } }, { "id": "git.pullResult.mergeConflict", @@ -19235,10 +19245,15 @@ }, { "id": "git.pullResult.stateChanged", - "description": "Windows UI: git.pullResult.stateChanged", + "description": "Branch Update and Pull stop reason shown by macOS and Windows", "platforms": [ + "macos", "windows" - ] + ], + "macos": { + "key": "The branch changed while choosing a pull strategy. Review it and try again.", + "arguments": [] + } }, { "id": "git.pullResult.strategyRequired", diff --git a/shared/platform-feature-matrix/features/git-branches-remotes.json b/shared/platform-feature-matrix/features/git-branches-remotes.json index 40898b43e..e18661494 100644 --- a/shared/platform-feature-matrix/features/git-branches-remotes.json +++ b/shared/platform-feature-matrix/features/git-branches-remotes.json @@ -46,7 +46,10 @@ "rust/lithe-core/src/tests/git_workspace_commit.rs", ".agents/notes/implemented/bug-fix/2026-10-17-git-push-target-and-upstream-tracking.md", "shared/fixtures/git/comparison-untracked-v1.json", - "macos/Tests/LitheTests/GitCoreContractBridgeTests.swift" + "macos/Tests/LitheTests/GitCoreContractBridgeTests.swift", + "shared/fixtures/git/update-plan-v1.json", + "rust/lithe-core/src/git/update_plan.rs", + "macos/Sources/LitheGitModule/Models/GitCorePlans.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -113,12 +116,16 @@ "rust/lithe-core/tests/git_push.rs", "rust/lithe-core/src/tests/git_workspace_commit.rs", "shared/fixtures/git/remote-tag-catalog-v1.json", - "rust/lithe-core/src/git/catalog.rs" + "rust/lithe-core/src/git/catalog.rs", + "shared/fixtures/git/update-plan-v1.json", + "rust/lithe-core/src/git/update_plan.rs", + "windows/tauri/src/features/git/api/git-update-plan-api.ts", + "windows/tauri/src/features/git/hooks/git-pull-workflow.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Git", - "verification": "macOS 引用列表和最近引用须保留 Core 的 peelsToCommit:提交标签可删除,指向树或 blob 的标签不可删除;历史与引用解码均覆盖,未知的合成分支引用不得假定提交资格。创建、切换、合并分支并查看标签和远程,确认冲突与认证失败可恢复。 macOS 悬停分支弹窗内被截断的行,确认沿原行位置展开完整分支名与 upstream,延续选中底色和行高;完整可见的行不展开,离开或打开动作菜单后展开层消失。滚动列表且鼠标位置不动时,高亮跟随当前鼠标下的行,离开的行清除 hover,不出现多行残留选中底色;已打开动作菜单的行仍保持高亮。顶部项目及分支按钮保持箭头光标,顶部当前分支不显示帮助浮层。 macOS 检查右侧真实上游列靠右显示、末尾省略且选中后沿用行文字颜色及普通字重;悬停直接显示沿原行展开的非激活子窗口,保留原行位置、选择底色、普通字重与高度,转交点击及滚轮,父弹窗关闭时清理,显示完整名称,不额外插入分组前缀,领先/落后提交数与 Git 一致(大于 99 显示 99+),无上游时不猜测远端。确认搜索框固定,其下操作项和全部分支分组共用一个滚动区,缩小弹窗时操作项也能滚出视口;拖动分支弹窗右边缘调整宽度、右上角/右下角无需先点击即分别使用东北—西南/西北—东南的斜向调整光标并调整宽高,固定另一侧边界,连续事件合并且不逐事件同步重绘,检查鼠标光标、内容刷新保留尺寸、关闭重开及重启后按项目恢复,切换项目互不覆盖,并检查屏幕边界及最小尺寸,普通菜单不出现拖动入口。 macOS 分支弹窗或 Git 已保存更改列表打开且鼠标悬停在被截断的行上时,反复切换到其他应用再切回、让窗口失焦/激活,应用不崩溃(issue #1277),悬停展开层仍只有一个且在布局完成后刷新;长分支列表快速滚动保持流畅,行内容、选中底色与展开层不丢失。 两端在仅抓取 preview、其他分支已有 remote/merge 配置和远端引用的仓库中确认上游名称与领先/落后计数仍可见,Recent 与 Local 一致,查询不修改 fetch 配置;同名远端但未设置跟踪时仍不显示上游。 Windows Git Log 普通 Fetch 和 Fetch 选项执行后保持当前页签,底部状态栏显示等待或真实阶段进度;连接等待、认证、多个远程切换不沿用旧百分比,成功、失败、取消和预检查失败均收起;Console 仍保留命令与输出。核对中英文、明暗主题与窄窗口。 Windows Git Log 普通 Checkout 不再二次确认,仍阻止覆盖本地修改;Update Selected 按跟踪分支配置启用,不依赖缓存 behind。当前分支后台 Merge,未检出分支原地快进,其他 worktree 内仅快进;不打开 Pull 弹窗,分叉/脏工作区保留本地内容并报告失败,仓库或工作区切换后迟到结果不得刷新或解锁新请求。 Windows Update Selected 在 Fetch 等待期间及预检后切换目标 worktree 的分支,或分离 HEAD,应返回 state-changed,不执行新分支的 Pull;Core 在执行前校验完整 expectedBranch,省略时保留兼容行为。 带 expectedBranch 的后台更新整合已 Fetch 的固定 upstream 提交,不再次执行 pull/fetch;验证 Merge、仅快进和 Rebase 策略,普通 Pull 省略字段时保持原行为。 Windows 顶部、Commit 和 Project 文件树由同一个仓库状态表生成展示;关闭 Commit 后外部 pull、checkout 和暂存仍刷新分支名、领先/落后及文件状态,已同步分支的 incoming 箭头消失。覆盖多仓库选择、根目录文件路径、快速切仓库/工作区与旧请求迟到,查询失败保留成功快照,草稿不丢失;元数据监听覆盖已发现仓库并在卸载时全部释放。真实 Windows 交互验收 pending。 两端 Fetch 后从其他分支及已处于目标本地分支时 Checkout Remote,确认 HEAD、本地分支和文件内容更新到所选已 Fetch 节点,重复操作成功且不生成合并提交。缺少本地分支时创建并配置跟踪;本地独有提交在切换或自动 stash 前被拒绝,Force 不丢弃提交;阻塞的工作区、暂存区、未跟踪文件和其他工作树占用保持原状态。Smart Checkout 更新后恢复本地修改,失败后界面刷新真实仓库状态;普通本地 Checkout 与 Checkout and Rebase 保持原行为。 Windows Git Log 的本地及远程 Checkout 成功后不再弹出成功提示(与 IDEA 一致,分支标签直接切换),失败时仍显示错误原因。 Windows Git Log 与分支切换器 Checkout 失败(例如目标分支已被其他 worktree 占用)时,右下角显示需手动关闭的 IDEA BalloonError 红色错误气泡,标题为“无法签出 {分支}”/“Could not checkout {branch}”,正文为去掉 fatal:/error: 前缀的 Git 输出;中英文、明暗主题下核对图标与换行。 macOS 分支弹窗和 Git Log 分支右键菜单中,有上游的未检出本地分支启用“更新”,原地快进且不切换 HEAD;本地有上游没有的提交时提示分叉、不强制更新;被其他 worktree 检出时提示到该 worktree 更新且不执行 Fetch;无上游的分支保持禁用;当前分支仍走原有拉取流程。在中文 Git 输出环境下核对提示。 macOS 分支弹窗按 Community 的 8pt 四边和角落沿边 16pt 规则命中;四边仅调整对应轴,四角同时调整两轴,反向边固定。水平/垂直使用 AppKit 双向光标,对角线直接使用 SDK JavaRuntimeSupport 的 javaResizeNE/NW/SE/SW,与 JBR 映射同源;检查原始图像、尺寸和热点一致。检查中央的搜索输入、滚动与分支点击不被边缘层拦截,移回中央释放调整光标,拖动中合并更新、松开才保存,内容刷新保持释放位置。 Windows Git Log 右键分支选择“新建分支”,弹窗对齐 IDEA 的 Create Branch from 对话框:标题为“从 {引用} 创建分支”,单行“分支名称”输入、创建/取消按钮,无 Checkout 与 Overwrite 选项,创建后固定检出新分支;初始名称本地分支沿用原名、远程分支去掉远程前缀,打开即全选,按一次退格即可清空;输入时空格转为连字符并丢弃非法字符且光标位置保持。打开及输入期间不做重名检测,点击创建后若名称为空,或与现有本地分支、远程分支、本地分支目录或 HEAD 冲突,才在输入框下方显示错误并禁用创建,此后错误随输入实时更新;创建按钮在前、取消按钮在后,创建为实心主色;Git 失败时错误显示在弹窗内且可重试,成功后不弹出提示,直接刷新历史。核对中英文与明暗主题。 Windows Git Log 右键本地分支选择“删除分支”不再弹出确认框,直接删除并在右下角弹出 IDEA 样式的通知(信息图标、粗体“已删除分支:”加分支名、下一行蓝色“恢复”文字链接、右上角关闭按钮,约 15 秒内有效,核对明暗主题与长分支名换行);点击后在原提交上重建该分支且不切换,原上游仍存在时恢复跟踪;分支重建成功但上游跟踪恢复失败时提示“分支已恢复,但上游跟踪恢复失败”,不得误报为整体失败,仍刷新历史。工作区切换后迟到的恢复不得刷新新工作区。分支未完全合并、被其他工作树占用等 Core 拒绝删除的情况显示 Git 给出的原因,不弹出恢复提示。远程分支和标签的删除保持原有确认。 Windows 所有右键菜单的弹出位置对齐 IDEA(Swing JPopupMenu):菜单左上角落在点击点,靠下或靠右放不下时整体平移回窗口内,不翻到光标上方或左侧;子菜单首行与父行对齐,右侧放不下时翻到左侧,纵向只平移。覆盖 Git Log 分支树、提交表、侧边栏、标签页、终端、文件树及通知/扩展/GitHub 等自绘菜单,并检查窗口很矮时菜单可滚动。 分支跟踪名称不同的远端分支(例如从 origin/main 新建分支后手动设置 upstream)时:Windows 推送弹窗必须先让用户在“发布到同名分支”和“推送到 upstream”之间选择,选择后弹窗展示的目标分支和提交列表与最终远端一致;“提交并推送”在写入提交前一次性询问所有异名仓库,取消后不产生提交;普通同名推送不出现任何询问。macOS 每次推送(分支列表与顶部分支弹窗)都先打开推送弹窗,顶部显示“本地分支 → 远端/分支”,左侧列出将推送的提交、右侧显示所选提交的改动文件,点击“推送”前不写远端;异名上游时“推送”保持禁用,直到在“发布到同名分支”和“推送到 upstream”之间选择,选择后目的地与提交列表随之更新,远端 main 不变;弹窗打开后仓库又有新提交时推送被拒绝并给出原因。macOS “提交并推送”始终先出审阅单列出每个仓库的目的地,异名仓库在审阅单内选择,未选择时“继续”不可用,取消后不产生提交。核对中英文、明暗主题与窄窗口。 macOS 指针停在被截断的分支行上时,用触控板双指滚动,确认列表持续跟随手势(含抬指后的惯性滚动),展开层在滚动时隐藏且手势结束后不残留;手指只放在触控板上不滚动时展开层保持显示。滚动中途手指停顿超过 1 秒再继续移动,确认列表仍跟随手势;原行滚出列表后手势不中断;滚动结束后点击同一位置,确认没有隐藏层拦截后续操作。 macOS 触控板滚动隐藏名称展开层后,立即在原位置左键/右键并拖动、松开,确认当前可见行收到完整操作,修饰键与点击次数不变,不触发滚走的旧行;滚动中按 Esc 关闭父弹窗后立即重开,确认隐藏层和回收任务已清理、无残留点击拦截。 新增(#1662 GIT-13/GIT-16):标签创建与删除统一走 Core 类型化 `git.write`,Windows 不再由宿主拼 `git tag`,标签目标必须是可解析的提交,删除产生 Core 的可恢复记录;显式 lightweight 标签不受 `tag.gpgSign` 影响,`signed` 生成签名标签,非法组合在启动 Git 前返回 `invalid_request`。新建分支名的 IDEA 清洗与冲突预检由 `git.branchNamePreview` 提供:两端输入时使用同一份 `typingCleanedName`、提交时使用 `cleanedName`,冲突码 `empty/head/clashesWithRemote/clashesWithDirectory/alreadyExists/clashesWithBranchPrefix` 与 IDEA 顺序一致;macOS 新建分支对话框显示 Core 清理后的名字和本地化冲突提示,Windows 对话框初始名来自 Core `initialName`。验证:在同一仓库分别用两端创建带空格、`..`、尾部 `.`、`.lock` 的分支名,确认最终分支名相同且冲突提示一致;在两端创建轻量/附注/签名标签并删除,确认轻量标签不受 `tag.gpgSign` 影响、删除后可恢复;用非提交对象作为标签目标时两端都在创建前得到稳定错误。 工作区与引用比较由 Core 合并未跟踪文件、去重并标注身份;两引用比较不包含工作区文件。共享 fixture、真实临时 Git 仓库及 macOS 服务回归覆盖,完整产品验收仍 pending。 Windows 的远程与标签列表改用 Core 结构化结果,附注标签的对象 ID 与解引用目标分别保留;同名分支不改变标签短名。真实仓库与类型适配回归覆盖,完整原生 UI 验收仍 pending。", + "verification": "macOS 引用列表和最近引用须保留 Core 的 peelsToCommit:提交标签可删除,指向树或 blob 的标签不可删除;历史与引用解码均覆盖,未知的合成分支引用不得假定提交资格。创建、切换、合并分支并查看标签和远程,确认冲突与认证失败可恢复。 macOS 悬停分支弹窗内被截断的行,确认沿原行位置展开完整分支名与 upstream,延续选中底色和行高;完整可见的行不展开,离开或打开动作菜单后展开层消失。滚动列表且鼠标位置不动时,高亮跟随当前鼠标下的行,离开的行清除 hover,不出现多行残留选中底色;已打开动作菜单的行仍保持高亮。顶部项目及分支按钮保持箭头光标,顶部当前分支不显示帮助浮层。 macOS 检查右侧真实上游列靠右显示、末尾省略且选中后沿用行文字颜色及普通字重;悬停直接显示沿原行展开的非激活子窗口,保留原行位置、选择底色、普通字重与高度,转交点击及滚轮,父弹窗关闭时清理,显示完整名称,不额外插入分组前缀,领先/落后提交数与 Git 一致(大于 99 显示 99+),无上游时不猜测远端。确认搜索框固定,其下操作项和全部分支分组共用一个滚动区,缩小弹窗时操作项也能滚出视口;拖动分支弹窗右边缘调整宽度、右上角/右下角无需先点击即分别使用东北—西南/西北—东南的斜向调整光标并调整宽高,固定另一侧边界,连续事件合并且不逐事件同步重绘,检查鼠标光标、内容刷新保留尺寸、关闭重开及重启后按项目恢复,切换项目互不覆盖,并检查屏幕边界及最小尺寸,普通菜单不出现拖动入口。 macOS 分支弹窗或 Git 已保存更改列表打开且鼠标悬停在被截断的行上时,反复切换到其他应用再切回、让窗口失焦/激活,应用不崩溃(issue #1277),悬停展开层仍只有一个且在布局完成后刷新;长分支列表快速滚动保持流畅,行内容、选中底色与展开层不丢失。 两端在仅抓取 preview、其他分支已有 remote/merge 配置和远端引用的仓库中确认上游名称与领先/落后计数仍可见,Recent 与 Local 一致,查询不修改 fetch 配置;同名远端但未设置跟踪时仍不显示上游。 Windows Git Log 普通 Fetch 和 Fetch 选项执行后保持当前页签,底部状态栏显示等待或真实阶段进度;连接等待、认证、多个远程切换不沿用旧百分比,成功、失败、取消和预检查失败均收起;Console 仍保留命令与输出。核对中英文、明暗主题与窄窗口。 Windows Git Log 普通 Checkout 不再二次确认,仍阻止覆盖本地修改;Update Selected 按跟踪分支配置启用,不依赖缓存 behind。当前分支后台 Merge,未检出分支原地快进,其他 worktree 内仅快进;不打开 Pull 弹窗,分叉/脏工作区保留本地内容并报告失败,仓库或工作区切换后迟到结果不得刷新或解锁新请求。 Windows Update Selected 在 Fetch 等待期间及预检后切换目标 worktree 的分支,或分离 HEAD,应返回 state-changed,不执行新分支的 Pull;Core 在执行前校验完整 expectedBranch,省略时保留兼容行为。 带 expectedBranch 的后台更新整合已 Fetch 的固定 upstream 提交,不再次执行 pull/fetch;验证 Merge、仅快进和 Rebase 策略,普通 Pull 省略字段时保持原行为。 Windows 顶部、Commit 和 Project 文件树由同一个仓库状态表生成展示;关闭 Commit 后外部 pull、checkout 和暂存仍刷新分支名、领先/落后及文件状态,已同步分支的 incoming 箭头消失。覆盖多仓库选择、根目录文件路径、快速切仓库/工作区与旧请求迟到,查询失败保留成功快照,草稿不丢失;元数据监听覆盖已发现仓库并在卸载时全部释放。真实 Windows 交互验收 pending。 两端 Fetch 后从其他分支及已处于目标本地分支时 Checkout Remote,确认 HEAD、本地分支和文件内容更新到所选已 Fetch 节点,重复操作成功且不生成合并提交。缺少本地分支时创建并配置跟踪;本地独有提交在切换或自动 stash 前被拒绝,Force 不丢弃提交;阻塞的工作区、暂存区、未跟踪文件和其他工作树占用保持原状态。Smart Checkout 更新后恢复本地修改,失败后界面刷新真实仓库状态;普通本地 Checkout 与 Checkout and Rebase 保持原行为。 Windows Git Log 的本地及远程 Checkout 成功后不再弹出成功提示(与 IDEA 一致,分支标签直接切换),失败时仍显示错误原因。 Windows Git Log 与分支切换器 Checkout 失败(例如目标分支已被其他 worktree 占用)时,右下角显示需手动关闭的 IDEA BalloonError 红色错误气泡,标题为“无法签出 {分支}”/“Could not checkout {branch}”,正文为去掉 fatal:/error: 前缀的 Git 输出;中英文、明暗主题下核对图标与换行。 macOS 分支弹窗和 Git Log 分支右键菜单中,有上游的未检出本地分支启用“更新”,原地快进且不切换 HEAD;本地有上游没有的提交时提示分叉、不强制更新;被其他 worktree 检出时提示到该 worktree 更新且不执行 Fetch;无上游的分支保持禁用;当前分支仍走原有拉取流程。在中文 Git 输出环境下核对提示。 macOS 分支弹窗按 Community 的 8pt 四边和角落沿边 16pt 规则命中;四边仅调整对应轴,四角同时调整两轴,反向边固定。水平/垂直使用 AppKit 双向光标,对角线直接使用 SDK JavaRuntimeSupport 的 javaResizeNE/NW/SE/SW,与 JBR 映射同源;检查原始图像、尺寸和热点一致。检查中央的搜索输入、滚动与分支点击不被边缘层拦截,移回中央释放调整光标,拖动中合并更新、松开才保存,内容刷新保持释放位置。 Windows Git Log 右键分支选择“新建分支”,弹窗对齐 IDEA 的 Create Branch from 对话框:标题为“从 {引用} 创建分支”,单行“分支名称”输入、创建/取消按钮,无 Checkout 与 Overwrite 选项,创建后固定检出新分支;初始名称本地分支沿用原名、远程分支去掉远程前缀,打开即全选,按一次退格即可清空;输入时空格转为连字符并丢弃非法字符且光标位置保持。打开及输入期间不做重名检测,点击创建后若名称为空,或与现有本地分支、远程分支、本地分支目录或 HEAD 冲突,才在输入框下方显示错误并禁用创建,此后错误随输入实时更新;创建按钮在前、取消按钮在后,创建为实心主色;Git 失败时错误显示在弹窗内且可重试,成功后不弹出提示,直接刷新历史。核对中英文与明暗主题。 Windows Git Log 右键本地分支选择“删除分支”不再弹出确认框,直接删除并在右下角弹出 IDEA 样式的通知(信息图标、粗体“已删除分支:”加分支名、下一行蓝色“恢复”文字链接、右上角关闭按钮,约 15 秒内有效,核对明暗主题与长分支名换行);点击后在原提交上重建该分支且不切换,原上游仍存在时恢复跟踪;分支重建成功但上游跟踪恢复失败时提示“分支已恢复,但上游跟踪恢复失败”,不得误报为整体失败,仍刷新历史。工作区切换后迟到的恢复不得刷新新工作区。分支未完全合并、被其他工作树占用等 Core 拒绝删除的情况显示 Git 给出的原因,不弹出恢复提示。远程分支和标签的删除保持原有确认。 Windows 所有右键菜单的弹出位置对齐 IDEA(Swing JPopupMenu):菜单左上角落在点击点,靠下或靠右放不下时整体平移回窗口内,不翻到光标上方或左侧;子菜单首行与父行对齐,右侧放不下时翻到左侧,纵向只平移。覆盖 Git Log 分支树、提交表、侧边栏、标签页、终端、文件树及通知/扩展/GitHub 等自绘菜单,并检查窗口很矮时菜单可滚动。 分支跟踪名称不同的远端分支(例如从 origin/main 新建分支后手动设置 upstream)时:Windows 推送弹窗必须先让用户在“发布到同名分支”和“推送到 upstream”之间选择,选择后弹窗展示的目标分支和提交列表与最终远端一致;“提交并推送”在写入提交前一次性询问所有异名仓库,取消后不产生提交;普通同名推送不出现任何询问。macOS 每次推送(分支列表与顶部分支弹窗)都先打开推送弹窗,顶部显示“本地分支 → 远端/分支”,左侧列出将推送的提交、右侧显示所选提交的改动文件,点击“推送”前不写远端;异名上游时“推送”保持禁用,直到在“发布到同名分支”和“推送到 upstream”之间选择,选择后目的地与提交列表随之更新,远端 main 不变;弹窗打开后仓库又有新提交时推送被拒绝并给出原因。macOS “提交并推送”始终先出审阅单列出每个仓库的目的地,异名仓库在审阅单内选择,未选择时“继续”不可用,取消后不产生提交。核对中英文、明暗主题与窄窗口。 macOS 指针停在被截断的分支行上时,用触控板双指滚动,确认列表持续跟随手势(含抬指后的惯性滚动),展开层在滚动时隐藏且手势结束后不残留;手指只放在触控板上不滚动时展开层保持显示。滚动中途手指停顿超过 1 秒再继续移动,确认列表仍跟随手势;原行滚出列表后手势不中断;滚动结束后点击同一位置,确认没有隐藏层拦截后续操作。 macOS 触控板滚动隐藏名称展开层后,立即在原位置左键/右键并拖动、松开,确认当前可见行收到完整操作,修饰键与点击次数不变,不触发滚走的旧行;滚动中按 Esc 关闭父弹窗后立即重开,确认隐藏层和回收任务已清理、无残留点击拦截。 新增(#1662 GIT-13/GIT-16):标签创建与删除统一走 Core 类型化 `git.write`,Windows 不再由宿主拼 `git tag`,标签目标必须是可解析的提交,删除产生 Core 的可恢复记录;显式 lightweight 标签不受 `tag.gpgSign` 影响,`signed` 生成签名标签,非法组合在启动 Git 前返回 `invalid_request`。新建分支名的 IDEA 清洗与冲突预检由 `git.branchNamePreview` 提供:两端输入时使用同一份 `typingCleanedName`、提交时使用 `cleanedName`,冲突码 `empty/head/clashesWithRemote/clashesWithDirectory/alreadyExists/clashesWithBranchPrefix` 与 IDEA 顺序一致;macOS 新建分支对话框显示 Core 清理后的名字和本地化冲突提示,Windows 对话框初始名来自 Core `initialName`。验证:在同一仓库分别用两端创建带空格、`..`、尾部 `.`、`.lock` 的分支名,确认最终分支名相同且冲突提示一致;在两端创建轻量/附注/签名标签并删除,确认轻量标签不受 `tag.gpgSign` 影响、删除后可恢复;用非提交对象作为标签目标时两端都在创建前得到稳定错误。 工作区与引用比较由 Core 合并未跟踪文件、去重并标注身份;两引用比较不包含工作区文件。共享 fixture、真实临时 Git 仓库及 macOS 服务回归覆盖,完整产品验收仍 pending。 Windows 的远程与标签列表改用 Core 结构化结果,附注标签的对象 ID 与解引用目标分别保留;同名分支不改变标签短名。真实仓库与类型适配回归覆盖,完整原生 UI 验收仍 pending。 分支 Update 与 Pull 的路由、本地改动/无上游/已最新拦截、分叉时是否询问以及默认策略统一由 Core `git.updatePlan` 决定:用户选择优先,其次 Git 的 `pull.rebase`/`pull.ff`,都没有时只允许 fast-forward;分叉历史两端都让用户选 Merge 或 Rebase(Windows 在 Pull 对话框里选),不再直接 merge;存在本地改动时两端都拒绝更新,前检失败时 macOS 不再继续执行。共享 fixture、Core 真实仓库配置回归及两端测试替身对拍覆盖,两端原生界面验收 pending。", "notes": "2026-10-06:用户确认 PR #1083 涉及的 Windows 功能改动均已完成实机测试,未发现问题;实机验收依据为用户反馈。 2026-10-08:用户确认本次远程 Checkout 更新和 Windows 成功提示修复的本地测试没有问题;作为本次 Windows 改动的用户验收依据,未宣称完成其他矩阵场景或 macOS 验收。 2026-10-17:issue #1272 推送目标修复。Core 与 Windows 本地自动化已覆盖:从 origin/main 新建分支不继承 upstream;异名目标未确认时拒绝推送;预览与推送共用同一个按选择解析的目标,确认后 upstream 被改指其他分支时已审阅的推送被判过期;--force-with-lease 作用在所选 upstream 分支;提交并推送在写入提交前由 Core 按仓库提问,重试时恢复并重新校验;省略 targetKind 的旧调用方仍可推送。 2026-10-22:macOS 补上界面。每次推送先打开 IDEA 式推送弹窗(目的地、将推送的提交、所选提交的改动文件),异名上游需先选择;“提交并推送”先出审阅单并就地选择。原生层与真实 Core 的衔接由 GitPushReviewBridgeTests 覆盖,相关套件经 scripts/macos/test/test-macos.sh 串行运行通过;完整套件在本机并行运行时有与本变更无关的超时类失败。SwiftUI 窗口本身尚未在真机上目视验证。两端真实 Windows/macOS 交互验收仍为 pending。 2026-10-31:issue #1621 修复悬停展开层关闭后丢失触控板手势后续事件。自动化测试已覆盖手势全程转发、行中途被移除和鼠标滚轮路径;尚未用真实触控板完成 macOS 实机验收,验证状态保持 pending。 2026-10-31:PR #1625 后续修复将隐藏展开层的完整鼠标操作交给当前父窗口命中控件,并由移交对象在父窗口关闭时立即清理;不依赖旧行存活或兜底计时,真实触控板验收仍为 pending。" } diff --git a/shared/platform-feature-matrix/features/git-history.json b/shared/platform-feature-matrix/features/git-history.json index d93c82646..134d8486d 100644 --- a/shared/platform-feature-matrix/features/git-history.json +++ b/shared/platform-feature-matrix/features/git-history.json @@ -41,7 +41,10 @@ "macos/Tests/LitheTests/DiffScrollSynchronizationTests.swift", "macos/Tests/LitheTests/ContextMenuCoverageTests.swift", "macos/Sources/LitheGitModule/Models/GitRepositoryColors.swift", - "macos/Tests/LitheTests/GitCoreContractBridgeTests.swift" + "macos/Tests/LitheTests/GitCoreContractBridgeTests.swift", + "shared/fixtures/git/history-window-v1.json", + "rust/lithe-core/src/git/history_window.rs", + "macos/Sources/LitheGitModule/Models/GitCorePlans.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -132,12 +135,16 @@ "windows/tauri/src/features/git/hooks/use-git-history-action-capabilities.test.tsx", "windows/tauri/src/features/git/utils/git-repository-colors.ts", "windows/tauri/src/features/git/utils/git-repository-colors.test.ts", - "shared/fixtures/git/repository-colors-v1.json" + "shared/fixtures/git/repository-colors-v1.json", + "shared/fixtures/git/history-window-v1.json", + "rust/lithe-core/src/git/history_window.rs", + "windows/tauri/src/features/git/api/git-history-window-api.ts", + "windows/tauri/src/features/git/hooks/use-git-log-controller.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Git", - "verification": "双端多仓库打开 Git Log:隐藏中间工作树后其余仓库颜色保持,切换工作区不得显示旧颜色;共享 Core fixture、跨页桥接和异步界面测试覆盖,完整原生界面及滚动性能验收 pending。 提交历史由 Core 保留作者时区及绝对时间,macOS 与 Windows 日期展示使用该时间;macOS 日期范围通过 Core 按绝对边界比较,系统日历计算夏令时边界。真实 Git、跨时区、重复小时、23/25 小时日及原生缓存回归覆盖;完整双平台界面人工验收仍 pending。 Windows cherry-pick 资格根据当前 HEAD 标识判断,不能把筛选结果第一行当作 HEAD;真实菜单回归覆盖其他分支首行可操作和非首行 HEAD 禁用。分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;macOS Git Log 工具窗口标签悬停有反馈,点击窗口内外时选中标签背景在活动色与非活动色之间切换,标签文字保持相同颜色与常规字重,按下时不变暗;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端使用 Community 完整颜色 ID 及明暗主题 RGB 规则;macOS Git Log 不再显示单独的 Fetch/Compare/Checkout/Cherry-pick 横向操作栏,分支树与竖向工具栏分离;检查 Fetch、分支比较、分支右键 Checkout、提交右键 Cherry-pick 和 Fetch 设置仍可用,分支搜索可筛选嵌套路径,并隐藏无匹配项的分组、显示无结果提示;定位按钮滚动到当前所选分支的首个可见提交而不重新加载历史;Git Log 的分支和提交搜索框复用 全局唯一的 LitheSearchFieldStyle,对照 IDEA SearchFieldWithExtension,核对 13pt 常规提示文字、Islands 明暗控件底色与边框色、36pt 外框内的 30pt 可见高度、3pt 外层留白、1pt 常态内描边、向外扩 1pt 的 2pt 聚焦描边及 4pt 圆角;图标与文字间距为 2pt,输入框清除按钮复用已有 16pt IDEA SVG 和 20pt 按钮区域,按上游预留清除图标宽度,移除分支搜索框重复外层水平内边距,提交搜索框按 15 列字体度量和 150pt 最小宽度布局,Search 与 Database 搜索输入框使用相同共享样式;竖栏更新按钮采用 IDEA ExpUI 的明暗 update 图标,按钮与侧边栏图标悬停使用共用工具提示样式,提示的明暗背景、文字色、1pt 内描边、圆角及内边距对齐 IDEA HelpTooltip;隐藏与显示分支使用 IDEA ExpUI 的 chevronLeft/chevronRight 明暗图标;竖栏宽 36pt,展开态按钮为 22pt 且四周保留工具栏内边距,展开态分支树左边框复用与 Commit 标题下边框同源的 1pt 分隔线;收缩态竖栏常态复用展开态侧栏背景,展开与收缩保留提交图谱和详情视图实例、滚动状态及分支宽度,仅切换分支面板与分隔条的占用宽度,按钮占满竖栏,深色 hover 使用 Islands Dark 的 #FFFFFF17,浅色 hover 使用 ExpUI Gray12,不在右侧显示 Show Branches 浮层提示,图标在顶部留 8pt 并使用常规小字号文字;展开后恢复分支树宽度。 使用原生 NSHostingView 渲染验证共享 LitheSearchTextField 的占位文字在深浅主题均为 IDEA Component.infoForeground 对应的 #73767C、13pt 常规字重;空输入框单独绘制占位文字,避免 SwiftUI 原生 prompt 替换其颜色与字重,有内容时占位文字消失并保留原生编辑和主文字色。 Git Log 分支树按 IDEA Community Islands 普通密度核对 24pt 行高、无行间额外间距、13pt 常规文字、12pt 次要计数与 4/12pt 树边距;展开箭头、路径文件夹、普通分支、当前分支和标签使用上游 16pt 明暗 SVG,Local/Remote/Tags 标题不附加图标,当前分支不显示额外蓝色勾选;HEAD 与当前分支是两个互斥选中节点,点击分支、HEAD、清除筛选和切换仓库时核对选中状态;深浅主题核对树行焦点/失焦选中色及 hover,保留多仓库名称、圆点、数量和只读菜单,移除分支行仓库色条;Windows 多仓库分支树的仓库行与 macOS 一致,仓库行使用中性色文件夹图标,并只在图标与名称之间显示 8px 仓库色点,仓库名保持普通文字色,不出现整组着色卡片底色、左侧色条或彩色名称,截断名称通过共享工具提示显示全名,行悬停为箭头光标;确认搜索、分组折叠、右键操作、缓存行比较和滚动状态正常。 Git Log 分支树右侧分隔线与 Commit 标题下边框共用 LitheTheme.toolWindowBorder 和 1pt 厚度,深色为 #26282C、浅色为 #E9EAEE;悬停、拖动开始/移动/结束均保持同色,不高亮,保留原生调整光标、命中区域、拖动尺寸约束及提交宽度行为。 Git Log 中间栏按 IDEA Community FilterComponent、VcsLogGraphTable 和 PaintParameters 核对四个筛选器的 13pt 常规文字、4pt 内边距、16pt 箭头和仅文字悬停反馈;工具栏使用 16pt IDEA 明暗 SVG、22pt 点击区域和共享提示,保留动作及禁用条件;提交列表为 26pt 连续行,图谱尺寸同比缩放,作者与日期同为 13pt 主文字色,日期列按打包字体度量,核对深浅主题行 hover、焦点/失焦选中色及选中覆盖 hover,筛选、分页、箭头命中、上下键导航和右键操作不回归。 分支竖栏设置齿轮复用 LitheIconButtonStyle 的 22pt 点击区域、4pt 圆角和主题 hover/按下背景;点击使用 LitheContextMenuPresenter 的共享操作菜单背景、1pt 边框、行 hover 与键盘导航,保留 Fetch Options 对话框、Show Worktree Repositories 勾选和无工作树时禁用行为,点击外部或按 Esc 关闭菜单。 macOS 下拉菜单仅保留 Project 同源的一套全局共享样式,移除 settingsStyle 切换;Project、Git Log 设置、Terminal、欢迎页及右键子菜单统一使用 8pt 外框圆角、1pt 内描边、24pt 行高、12.5pt 文字、6pt 外留白和 8pt 行内边距,设置选择下拉框共享同一外框与尺寸定义; Git Log 顶部按 IDEA 恢复 Branch: 已选分支名称,保留 Branch 筛选、清除和菜单内勾选;Branch、User、Date、Paths 按 IDEA FilterComponent 保留 4pt 内边距、3pt 箭头间距和 13pt 常规文字,四项文字 hover 为白色且不增加底色。Date 使用共享菜单呈现器,Branch/User 的可搜索列表与分组子菜单使用共享 LitheDropdownRowStyle、24pt 行高和共享菜单外框,Paths 输入面板复用共享菜单外框及搜索输入框样式,保留搜索、选项选中、路径 Apply/Cancel/Clear 和时间范围计算。 Branch/User/Paths 原生弹窗锚点不参与鼠标命中,核对按钮与文字收到 hover/点击、菜单正确弹出且关闭后可再次打开;Date 保持共享菜单呈现器。共享下拉外框裁剪内部内容,明暗主题下 Branch/User 搜索条不能覆盖圆角;Paths 输入框复用共享输入样式并显示正确焦点边框,Clear/Cancel/Apply 复用共享次按钮/主按钮,保留路径应用和清除行为。Branch、User、Paths 的可搜索内容同样使用 LitheContextMenuPresenter 的透明无边框面板,去掉 NSPopover 系统外框,复用 Project 共享背景、1pt 边框和圆角;分组展开与搜索动态调整弹窗尺寸,Esc、点击外部和切换菜单正确关闭。选择分支后清除筛选,确认 Branch 恢复默认标题且关闭按钮消失;关闭按钮保留 16pt closeSmall 明暗 SVG、22pt hover 区域及 4pt 圆角。展开分支树左侧固定线、中间分隔线和右侧详情拖动线均使用 toolWindowBorder 的 1pt 边框,右侧 hover 与拖动不高亮。右侧详情展示图标使用 IDEA previewVertically 明暗 SVG,装饰开关使用 show 眼睛而非 hide 工具窗口减号;所有工具栏展示图标复用 22pt、4pt 圆角的共享 hover 背景,保留原动作与提示。深浅主题渲染核对背景和边框,保留可选图标、勾选、快捷键、禁用、子菜单和键盘导航。 Git Log 提交标题、作者和日期使用 Inter 13 Normal,引用标签 12pt;核对至少 26pt 的字体度量行高、含 leading 的整数基线、长标题与中文/emoji 截断,以及选中合并提交的文字颜色。 所选分支存在时 Log 标签显示 Log: 分支短名(包含 codex/ 路径),选择另一分支立即更新;清除分支筛选后恢复 Log,保留本地化标题、原始分支名和原标签选择/焦点行为。 macOS 标题起点按缩放图谱列距、图文间距和 2pt 文字内边距计算,为对齐参考截图至少预留六列,密集行继续扩宽,不改变图谱节点、连线和箭头命中;在 SwiftUI/AppKit 两条渲染路径核对未选中的合并提交标题、作者、日期统一使用合并灰色,选中后恢复普通文字色,合并判定仅依赖至少两个父节点。 Git Log 右侧文件树使用共享 24pt 行高、19pt 层级缩进、常规字重、文件类型图标和 IDEA FileStatus 文件名颜色,无逐行横线及工具栏重复总数;核对文件夹折叠、文件打开 diff、长名称 hover 展开与横向滚动,拖动宽度不重建行内容。提交详情使用 14pt 外边距、10pt 内间距、等宽 bold 标题、同段作者邮箱时间与引用标签,长内容可滚动;文件树/详情之间分隔线复用共享边框,hover/拖动不高亮。左侧分支树从 180pt 专用下限改为共享 30pt 下限,独立 36pt 竖栏保持原宽,窄树裁剪后可拖回原宽,收缩/展开不重建图谱。 图谱圆点、连线、箭头按 SimpleGraphCellPainter/ PaintUtil 的 FLOOR/ODD 物理像素规则对齐;在 1×/2× 原生位图验证圆点与线宽,在真实 NSHostingView 检查对齐后的上下斜向箭头尖端及内侧描边可导航,切换屏幕只重绘且绘制/命中坐标一致。 时间列复用 Inter 13 常规字体的等宽数字与固定 AM/PM 区域,按应用语言使用英文 12h AM/PM 或中文 24h 格式;核对午夜、正午、未知日期回退、行起点一致及切换语言后的原生日期缓存失效,列表两条渲染路径和提交详情使用相同格式。 连续上下拖动 Git Log,包含上方长 Diff:原生列表只调整可见区域,保持文档身份、滚动位置、Shift/Command 多选、焦点色、键盘与箭头导航、分页和辅助功能;收缩再恢复后列表宽高正确。GitGraphInteractionTests 使用 300 条提交、80 次原生尺寸变化和共享分隔条事件验证,局部新旧路径计时作为诊断,不代替目标工作区帧率验证。 共享分隔容器按已有宽高直接分配两个面板与分隔条的确定矩形,避免 HStack/VStack 最小及理想尺寸探测;splitPaneBounds 覆盖横纵方向、leading/trailing、折叠、子视图身份和有限尺寸,达到尺寸限制后的相同结果不重复写状态。 通过键盘或程序化切换焦点时 Git 标签活动态双向更新;挂载或重新显示 Git Log 不主动抢走编辑器焦点。 在包含展开分支树的 Git Log 中连续拖动外层高度和内部宽度:共享分隔器只在拖动开始/结束传播环境状态,嵌套容器继承祖先状态;拖动期间共享行悬停不更新、提示不登记几何锚点、右键覆盖层暂停,松开后恢复菜单和提示,行内容及滚动状态不重挂载。69 行原生分隔条事件回归检查覆盖层暂停/恢复及内容身份;未悬停控件在平时同样不登记提示锚点。用目标工作区 Time Profiler/Animation Hitches 对照 Terminal 核对持续更新成本及起止开销,split.drag 整次手势时长不能当作单帧延迟;本次仅前端共享交互装饰调整,不修改后端及默认详情高度行为。 中文输入法组词期间,即使绑定文本仍为空,共享 LitheSearchTextField 占位文字立即隐藏;取消组词或删空后恢复,确认中文后仍隐藏。原生 setMarkedText 回归覆盖明暗主题和两个输入框互不影响;保留焦点、键盘提交、搜索/过滤语义及占位字号颜色。原生背景观察视图卸载和移出窗口时清理通知,匹配当前窗口的字段编辑器及所属输入区域,不修改业务绑定。 四个筛选弹窗均以各自按钮左下角为定位锚点,Date 不依赖鼠标点击坐标;空间不足时限制在屏幕可见范围内。原生面板回归核对操作菜单/可搜索内容相同锚点下的左上角、无展开动画和 Esc 关闭回调。共享搜索字段在未聚焦时悬停也显示文本编辑光标,离开或禁用时恢复箭头。 Git Console 左侧六个现有动作使用 Community 原始 16pt 明暗 SVG;22pt 背景、竖向 2/1 按钮留白、5/7 外边距和默认箭头光标。核对查找、软换行、跟随、停止、清空、复制的原有动作与禁用状态,组件资源检查不替代完整应用验收。 按 Community CurrentBranchHighlighter 标出当前本地分支可达的所有父提交,包含合并父节点;深色 #1D2336、浅色 #EDF3FF,选中背景优先,仅已加载图谱可证明的祖先被标记,当前分支/HEAD 单独筛选时不重复染色。引用默认显示并按 GitRefManager/SimpleRefGroup 紧凑合组:本地与上游同提交显示 remote & branch,HEAD/本地/远端/标签使用官方黄/绿/紫/灰轮廓及叠层,标签单独存在时默认只显示图标;长名称按列宽和 22 字符下限缩写,原生可见区 tooltip 保留所有完整名称,滚动后按当前鼠标位置解析。明暗原生渲染检查颜色、宽度、选择优先级及完整名称;GitLogReferenceResizeTests 在真实 GitLogView 中加载 1000 个分支并拖动外层高度,检查可见右键覆盖层数量和挂载生命周期,计时仅作诊断。最新正式预览已进入该工作树,观察全部引用下的深色背景、叠层缩写标签和多次上边缘拖动后的布局恢复;用户随后在本机明确确认“现在不卡顿了”,本次报告的高度拖动卡顿通过用户实机验收;组件计时和混合工作区采样不用于推断唯一根因或量化通用帧延迟,其他主题/悬浮等视觉项继续按各自范围验收。 在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端使用 Community 完整颜色 ID 及明暗主题 RGB 规则。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。 Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Commit files 树中过长的文件或目录名不折叠为省略号,区域随最宽行横向展开,可通过横向滚动条或触控板侧滑左右滑动查看完整名称(与源代码管理变更树一致);Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列将有 UTC 偏移的提交转换到本机时区后显示相对时间(刚刚、N 分钟前、今天/昨天);英文绝对日期和时间通过 Windows 原生接口遵循系统短日期/时间及用户自定义格式,中文使用中文地区格式,未知偏移仍显示绝对日期;验证美式月/日/年、英式日/月/年、自定义分隔符、12/24 小时制、跨时区相对时间和迟到原生结果保护,真实 WebView2 产品验收为 pending;鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 和提交标识,可通过文件导航分别查看;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。 Windows 端提交历史仅通过 Git Log 工具窗浏览:源代码管理侧栏入口已更名为 Commit 面板,其 History 标签已移除,面板固定显示变更列表与提交区。命令面板中的 Git: Show History 入口同步移除。 Windows Commit Files 单文件预览使用独立页面:上下箭头导航差异,下箭头在当前文件最后一处后继续进入下一文件并在比较完成后定位第一处差异,整个文件集合末尾禁用且不循环;图片、二进制和无文本差异文件也允许下箭头继续,比较未就绪时禁用以防连续点击跳过文件。左右箭头直接切换当前比较集合的上一文件/下一文件;标签使用 IDEA 的 Repository Diff: 文件名并跟随导航更新,不附加提交 hash。Jump to Source 按最后聚焦侧的补丁位置打开预览所属仓库的文件,重命名使用新路径、删除行映射邻近保留行、已删除文件禁用源跳转;模型更新期间禁用差异动作。保留并排/统一视图、空白显示、Monaco 查找、图片和二进制呈现,普通全量提交比较仍使用多文件页面。真实 WebView2 内核及源文件打开还需目标产品运行验收。 Windows 单文件历史 Diff 外观验证:并排视图使用独立外侧 12px 滚动轨道、8px 滑块和概览,通常的统一视图保留 Monaco 原生 Diff 组件,整文件新增使用真实新行流的独立只读控件,深浅主题检查常态/悬停/拖动色、圆角及边框;实际拖动任一并排滑块后按差异边界同步对应行且模型身份保持。工具栏使用现有 Button,核对 40px 高度和 22px 图标按钮;保留用户字体与字号,默认行距按 14px 字号对应 22px 并随缩放,自定义行距不覆盖。词级高亮开关复用现有 Monaco 装饰能力,关闭只隐藏字符差异、保留整行差异,切换文件保留词级和空白开关。旧/新版本标题显示真实第一父提交、范围基线/目标或非连续提交各自身份,根提交旧侧为空树,重命名显示旧/新路径;短 hash 和截断路径的 tooltip 保留全文。并排标题列宽由独立布局所有者更新 CSS,拖动分隔条时标题跟随且不重建模型;统一视图标题纵向排列。以上浏览器真实组件验证不替代目标 WebView2 产品验收。 Windows 单文件 Diff 滚动条回归:两侧横纵滚动条默认显示,鼠标移到工具栏后仍可见,不依赖悬停;差异概览不绘制重复的宽矩形视口指示,并排由独立外侧轨道负责点击、拖动和滚轮,统一视图保留原生委托。明暗主题、并排/统一视图及长文本双向溢出下检查唯一可见滑块、并排横向同像素偏移同步、纵向对应行同步和模型身份。 历史 Diff 两侧滚动轨道与代码编辑器概览画布背景透明,鼠标不悬停时滑块仍显示;关闭代码编辑器重复概览通道,但保留 Diff 专用差异色条。横向轨道不额外增加内容高度,检查代码能显示在滑块后、底部内容与滚动位置正确,拖动命中保留,并排两侧横向同像素偏移同步、纵向对应行同步。 Windows 并排单文件 Diff 使用两个独立只读 Monaco 代码控件,取消 DiffEditor 的补空白对齐。左右横向滚动按同一像素偏移双向同步,较短侧由 Monaco 钳制到真实内容边界且不反向拉回主动侧;纵向原生代码区、外侧滑块拖动/点击、轨道键盘、滚轮和查找以视口三分之一处为锚点,按 Git 差异块起止边界同步对应行并保留行内偏移,短侧在不等长块内钳制、共同上下文恢复对齐。同步有重入与微小方向保护;双模型替换、布局、字体配置和显式上下导航期间暂停同步。新增旧侧/删除新侧画横跨可见代码区的双像素零行边界,修改区画上下单像素边界;两列行号背景为直边,曲线只在两列之间弯曲,实际偏移改变端点形状。检查文件开头/末尾零行、三态、明暗主题和窄列,代码不补空白、不扩展横向范围;连接面覆盖两列历史行号背景,行号选择和源映射正确。左右外侧各显示透明常显纵向滑块与增删改概览,横向保留原生滚动条,滚动范围截止真实最长代码行。验证深浅主题、折叠展开、Ctrl/Cmd+F 搜索折叠文本、词级开关和预算、统一视图及跨文件首处定位、连续拖动和窄窗口非负尺寸。高频帧二分筛选可见差异,缓存像素边界及概览,不扫描全文、不更新 React;卸载和迟到语言准备清理所有帧、监听、观察器、拖动、两个编辑器和模型。浏览器生产组件验证使用平台记录器,真实 WebView2 产品验收仍为 pending。 外侧轨道预留独立空间,不显示代码;左轨道距面板边缘、右轨道距代码视口各留 4px 间隙,窄列随可用宽度缩减,右侧文字与轨道不重叠;并排和统一预览关闭缩进及括号引导线,保留语法颜色;中央行号缩小边距和曲线间隙,整数布局边界及上下半像素描边消除与代码区的垂直接缝。滚轮在代码、行号和轨道统一加快到两倍,拖动仍映射各侧真实进度,纵向同步对应行;方向键按用户行高移动。有词级结果时修改正文底色按 40% 修改色与 60% 页面背景混合,完整修改色突出替换词的两侧,纯新增和纯删除词分别使用完整增删色,语法前景色和文字透明度保持;关闭词级或超限保留整行底色。验证三态与开头末尾、双向对应行同步、明暗主题、轨道无遮挡、宽窄字体布局和预算回退。 单文件并排及统一视图隐藏补丁 @@ 标题,但保留真正以 @@ 开头的源码、行 ID、源行映射和原始补丁;默认多文件与暂存投影不变。Islands Dark 继承 Darcula 背景,Islands Light 使用 Light 方案,独立差异轨道标记与连接、代码边界共用对应差异背景色。底部保留五个用户实际行高的 padding,模型不增加假行;从任意侧滚到底部验证共同末行对齐与五行留白,字号/行距变化同步更新。 Windows 单文件 Diff 首次打开、左右切文件和同路径新比较均在模型/比较准备完成后定位第一处改动;定位只消费一次,调整词级、空白、字号或主题保持用户当前位置。统一视图的原生 Monaco 增删行、字符与行号栏使用该预览相同的 Islands 背景变量,移除默认字符边框;新增整文件应为深色 #294436 或浅色 #BEE6BE,删除为 #484A4A 或 #D6D6D6,语法前景色不变且其他编辑器主题不被覆盖。 整文件新增采用单流只读代码控件,隐藏不存在的旧侧与连接带,新增背景覆盖行号、代码和底部留白,避免空旧模型的灰色删除行及外圈;新增仅显示新版本、删除仅显示旧版本的一组 hash 与路径。并排两侧及新增单流轨道的增删改标记与对应内容背景共用颜色;原生统一预览关闭 renderIndicators,历史行号旁不显示 +/-。这组新增显示修复按用户要求延后运行验证,提交或 PR 前核对新增文件、普通修改、删除、深浅主题、查找、源跳转与底部留白。 Windows Git Log 单文件和 Commit 文件 Diff 的词级高亮、显示空白符、统一/并排切换均在左侧,最右侧统计当前文件差异,区分无差异/单数/复数;Commit 勾选、部分包含、取消及刷新后 included 数同步变化,全部包含仍显示数量;加载、切换文件及图片/二进制不显示文本差异统计。核对中英文、明暗主题、窄窗口和按钮选中/禁用状态。 Windows 单文件 Diff 上箭头在首处继续进入上一文件最后一处,下箭头在末处进入下一文件首处,集合首尾不循环、加载中禁用;Commit 文件列表随跨文件导航更新选中行并展开祖先、滚动到可见位置,保留编辑器焦点和 include 状态;同文件刷新、历史 Diff、无关仓库和迟到读取不扰动列表。 Windows Git Log 右下角 Commit files 列表同样跟随 Diff 的上一/下一文件导航:选中行移到当前文件,被折叠的父目录自动展开,行滚动到可见位置;预览属于其他提交或范围时不改变选中,点击行不会改变横向滚动。 Windows Git Log 筛选后基于完整已加载历史投影可见祖先:隐藏中间提交以虚线连接,直接父子保持实线,无关分支不误连;合并路径去重并保留永久颜色,未加载父提交经隐藏节点传播,分页补齐后解除缺失标记;清空筛选恢复原图,核对文本、作者、分支名称三种范围、空结果与选择行为。算法和真实表格组件回归通过不代替 WebView2 原生验收,原生验证保持 pending。 Windows 未筛选图与筛选图共用上下半边打印元素:普通浏览的跨行泳道换列必须在共同中点连续连接,合流入边保留各自颜色到提交圆点;核对原始 IDEA 200 条历史输出的列号、方向、终止和颜色身份;截图/原生交互验收仍为 pending,组件输出和 golden 对照不代替 WebView2 验收。 Windows Git Graph 外观:核对明暗主题真实 IDEA RGB、实心节点、直线与筛选虚线、终止箭头及至少六列标题预留;默认 26px 行高,用户字号变化时虚拟行与 SVG 同步扩高;在 100%/125%/150%/200%/300% 缩放及跨屏 DPI 变化下核对奇数物理像素和相邻半边连续性,普通视口调整不重建历史图。箭头能跳到已加载且可见的父/子提交,未加载父节点没有可点击目标;浏览器生产组件验证不代替目标 WebView2 运行验收,原生验证保持 pending。 Windows 历史排序和图导航:在交错分支仓库核对首批/分页/回退及全引用上下文均使用日期顺序,切换分支保持可证明的永久颜色和基础顺序;上下文最多 5000 条,覆盖不完整或哈希重复时保留可见页并回退。核对 upstream/backup 等非 origin 远程引用分类及本地含斜杠分支;背景上下文不阻塞 50 条可见页,分页不取消或重读上下文,切仓库/刷新/卸载取消旧操作且关闭迟到游标。长边开关展开/收起不改变提交、筛选、选择和游标;验证 30/1000 行边界。点击或用 Enter/空格激活上下箭头,选择并滚动到屏幕外端点,挂载后键盘焦点随目标;不误选源行、不误开源行 Diff,保持普通选择/多选/双击/右键和原预览入口。明暗主题及 1x/1.25x/2x 检查箭头尖端与命中范围,真实 WebView2 运行验收保持 pending。 图谱颜色必须跟随 ThemeRegistry 根 data-theme-type 切换;不依赖页面存在 dark class。复用真实 Lithe 明暗主题检查同一已挂载节点与连线的颜色变化、布局不因主题重算,结合主题回归和浏览器真实切换验证。 Windows 分页稳定性:选中顶部提交后向下滚动触发连续分页,确认滚动位置不跳回已选提交,Commit Files 不清空或重读,已选文件及树折叠保持;更新引用、背景上下文和相同 hash 的元数据同样不导航。定位分支头通过独立请求仍能定位已经选中的头提交一次;新选择或切仓库仍读取新文件并丢弃旧结果。组件回归与真实浏览器虚拟列表/IntersectionObserver 验证通过,目标 WebView2 实机验收保持 pending。 Windows Git Log 标题栏不显示固定 Read-only/只读文字,关闭按钮保持靠右,刷新、设置及隐藏操作正常。 Windows Git Log 单个提交右键 New Tag… 输入非空且无空白的标签名,创建指向右键提交完整 hash 的轻量标签并刷新引用和历史;取消不写入,失败保留名称可重试,双击/Enter 不重复创建,多选和写操作期间禁用。仓库或工作区切换关闭旧弹窗,旧请求迟到不得刷新或关闭新弹窗。 Windows 提交区域点击后上下箭头更新可见提交选择和详情,焦点不随虚拟行移除而丢失;筛选、首尾边界、Home/End、Shift 范围选择和 Enter 正常,菜单与输入框保留自己的方向键。 Windows Git Log 鼠标或上下键选择提交后,异步 Diff 自动定位第一处改动但保持提交列表键盘焦点;并排与统一预览均不调用初始 focus。双击/Enter 打开完整 Diff、主动差异跳转及跨文件导航仍进入编辑器。 Windows Git Log 提交右键 New Tag 使用与 Tags 节点同源的 TagIcon(沿用现有标签轮廓图案),核对 16px 图标、菜单对齐和禁用反馈;新补图标的实机视觉确认待完成。 Windows 提交列表菜单键和 Shift+F10 打开当前选中提交的真实菜单;关闭后列表继续上下键导航,菜单操作仍针对所选提交。New Tag 在 tag.gpgSign=true 下生成轻量标签,不运行签名器或消息编辑器,不修改配置。 Windows 提交行高亮和紧凑引用组:在全部引用和其他分支视图核对当前检出分支全父祖先背景,当前分支/HEAD 视图关闭重复高亮;合并提交的说明、作者、日期未选中时弱化、选中后恢复,选中背景优先于分支并区分列表/文件树/窗口焦点。核对明暗主题固定基准色、真实 upstream 合并名称、游离 HEAD、每种最多两个叠加图标、仅标签图标和完整汇总悬停/无障碍提示。宽窄列及字号变化时按至少 22 字符缩略规则布局,只有一个视口尺寸观察器,列拖动不逐行读取宽度,卸载清理;分页不丢滚动/选择,成功后不重读 Commit Files;文件读取失败时 Commit Files 面板显示重试入口,重新选择同一提交或刷新历史也会重试一次,长边导航和新建标签原入口保持。组件与浏览器验证通过,目标 WebView2 实机验收 pending。 Windows Git Log 分支树和提交列表的普通滚轮使用 Community Windows 默认 200ms 和 (0.5,0.505,0.5,1) 曲线;连续同向累积、反向从当前位置重启。核对两处列表的中间帧、Shift 横向滚动、分页保持、顶底边界、键盘定位和滑块拖动取消惯性、关闭工具窗清理帧以及减少动态效果偏好。确定性动画与 DOM 回归通过,真实 WebView2 设备手感验收保持 pending。 Windows graph 标题不再固定预留六列:建议宽度取可见图边数的加权均值与标准差,最多以六列作统一基线,每行按节点与斜边中点扩展,密集行不把其他简单行扩成最大列宽。验证单线/少分支、罕见密集合并、超过六列、筛选、长边开关、引用标签可用宽度及字号/DPI,保持图谱颜色/连线/导航不变。 Windows Git Log 的 Commit Files 文件树沿用 IDEA Windows 默认 200ms 滚轮动画;在文件行、竖向/横向滚动条区域连续滚动、反向及 Shift 横向滚动,确认共用真实视口的动画目标,长文件名保持自然宽度;按下滚动条取消未完成惯性,减少动态效果时直接滚动,切换提交及卸载不残留帧。其它 ScrollArea 默认行为不变。真实 WebView2 交互验收 pending。 Windows Git Log 右键 New Tag 打开弹窗后焦点直接位于标签名输入框,无需点击即可输入,Enter 提交、Esc 关闭。 macOS Git Log 空状态:在非 Git 项目检查明暗主题、窄窗口和键盘操作:13pt 常规文字按 IDEA 左对齐,整组位于内容区上方三分之一处;显示 To track changes to code、GitHub/GitLab 官网链接、Initialize Git repository…、Open Git Settings 和带原始 ContextHelp 图标的 Version control integration(打开 Git 官方文档)。初始化仍先确认,不自动暂存或提交;设置打开既有 Git 设置。未提交仓库、无匹配提交、加载失败及重试保持原动作,所有空状态操作采用文字按钮。 非 Git 项目空状态隐藏 Log/Worktrees/Console 标签、添加按钮、分支树与动作条、筛选/搜索工具栏及提交详情,提示占满工具窗内容区;GitHub、GitLab、初始化、设置及帮助链接悬停仅给当前文字加主题强调色下划线并显示手形,离开后恢复;初始化或切换至真实仓库后恢复原有三栏布局,真实仓库无匹配提交仍保留筛选入口。 空状态字体与间距对照 Community c7f91397:按钮标签显式使用 Inter 13pt Regular;行首破折号按首行基线对齐,21pt 行高加 2pt 间隔,帮助图标间隔 2pt;灰字与链接分别沿 Component.infoForeground 和 Islands Link.activeForeground 核对明暗主题,悬停下划线仍使用当前主题强调色。 macOS 右侧提交详情的分支标签按可见区域懒加载,全部引用仍可滚动查看。重开面板初始化时恢复匹配的提交图和分支树,第一次布局即可读取全部已缓存数据,不依赖出现后的异步任务回填。重开面板复用未变化的分支树,单仓库不重复构建分组副本,多仓库活动根复用已有三类行;引用、仓库、工作树开关、折叠状态改变后拒绝旧树。原生绘制与无障碍日期文本复用缓存,语言改变后刷新。 macOS 大量引用首屏:同提交放置 1,500 个本地分支和 1,500 个远程引用,核对真实 upstream 优先、同名远程自然排序回退、完整提示及每类图标上限;反复切换 Terminal/Git Log,数据版本相同应复用已准备图,改变分支/筛选/图显示选项后不得复用旧图。运行 GitGraphInteractionTests 的 manyReferenceGroups 和 presentationCache;组件耗时仅诊断,整机卡顿以相同数据实测为准。 macOS Git Log 及终端按需创建后由工作台持有实际原生宿主;隐藏再重开及移动停靠位置复用控件。运行 GitLogReferenceResizeTests,验证同一工作台隐藏再重开时,固定原生表面上的提交文档和滚动容器对象身份不变,恢复可见性且尺寸正确,工作台释放后宿主释放;核对语言、主题、搜索、分支操作和终端会话。 Git Log 与终端同时停靠时,从外层横向和纵向分隔条中央及向面板两侧偏移 4pt 处,经窗口发送按下、拖动、松开,确认完整 10pt 命中区仍能提交尺寸、宿主身份保持,面板中央正常交互。;macOS 保留宿主圆角需在明暗主题、侧栏顶部与移动后、项目辉光改变、渐变开关及背景图切换时,与原工作台树四角像素一致(WorkbenchSurfaceBackgroundTests.retainedPaneCornersMatchWorkbenchGradient)。 Windows HEAD 按 Community HeadNodePainter 核对外圈、背景间隔和中心实点:26px 行高、100% 缩放时三层直径为 13/9/5px,125%/150%/200%/300% 按物理像素 FLOOR/ODD 对齐;深浅主题、悬停、当前分支及活动/非活动选择均透出真实行底色,穿过 HEAD 的连线不侵入间隔。普通节点及分叉、筛选、长边展开保持;同边双向终止元素键唯一。目标 WebView2 实机验收保持 pending。 macOS 在明暗主题的窄 Git Log 工具栏中选择长用户名、分支和路径,确认名称与选中值以同一行尾部截断,文字不重叠,清除按钮和重新选择仍可用。 macOS Git Log 接近底部时自动追加较早提交,正常浏览不出现必须点击的加载更多按钮;单一页面最多发起一次边缘请求,追加保留选择和原生视口偏移。无新提交或缺失后续游标时停止并释放游标;失败暂停自动读取,显式刷新重开历史而不重用失败游标。运行 GitGraphInteractionTests.automaticHistoryPaging 与 GitModuleTests.continuousHistoryPagingStopsOnDuplicatePagesAndRefreshesAfterFailure,真实大仓库连续滚动和 Windows WebView2 验收仍 pending。 macOS 深层历史后台刷新:文件状态变化不重开首批,元数据刷新保留已加载窗口和后续页占位,未变结果不重建提交图;外部新提交按可见 hash 与行内偏移保持原生视口,按新行位移补齐旧尾部,保留尾部选择。当前仓库引用只读取一次,多仓库仍独立加载。后台刷新打断自动分页后,即使历史相同也能继续读取新游标;失败仍暂停自动重试。运行 GitModuleTests.backgroundRefreshRetainsDeepHistoryAndSelectionWhileExternalCommitsStillAppear、引用聚合回归及 GitGraphInteractionTests.backgroundHistoryRefreshAnchorsVisibleCommitInsteadOfSelectedCommit/automaticHistoryPaging;真实大仓库长时间滚动仍待用户验收,原生进程 cleanup 超时未独立复现,不将减少重复查询等同于修复所有进程失败。 macOS #1542:显示保留的 Git Log/终端时,横纵分隔条中央及两侧偏移 4pt 仍经窗口事件完成拖动且只提交一次,宿主身份不变;根命中查询深度始终为 1,面板内部连续 100 次查询不产生额外根查询,鼠标移动不回查祖先 SwiftUI。验证禁用、收起(含嵌套分隔条)、恢复、卸载、跨窗口与登记表隔离、实时移动/裁剪、隐藏/透明和弱引用释放;保留宿主内部仍正常命中,外层边缘优先。Diff 两端收起后在普通与保留侧栏下均能经窗口拖回,不触发外层拖动。运行 GitLogReferenceResizeTests、SplitHandleHitTestTests 和 DiffScrollSynchronizationTests;旧实现先出现嵌套命中断言失败,新实现通过。较新 macOS 原生组件回归不替代 macOS 13 Intel 原报告崩溃验收,目标环境验证保持 pending。 历史菜单资格使用 Core 读取真实 HEAD 与第一父链;在筛选/分页、旁支首行、跳选、切换仓库、HEAD 变化和关闭菜单时验证动作状态及迟到结果隔离;两端原生界面验收 pending。 Git 控制台执行日志的记录状态(completed / unconfirmed)、单条与总量保留预算和脱敏由 Core 事件携带,两端不再各自归约或二次脱敏,单条输出预算统一为 32768 字符。", + "verification": "双端多仓库打开 Git Log:隐藏中间工作树后其余仓库颜色保持,切换工作区不得显示旧颜色;共享 Core fixture、跨页桥接和异步界面测试覆盖,完整原生界面及滚动性能验收 pending。 提交历史由 Core 保留作者时区及绝对时间,macOS 与 Windows 日期展示使用该时间;macOS 日期范围通过 Core 按绝对边界比较,系统日历计算夏令时边界。真实 Git、跨时区、重复小时、23/25 小时日及原生缓存回归覆盖;完整双平台界面人工验收仍 pending。 Windows cherry-pick 资格根据当前 HEAD 标识判断,不能把筛选结果第一行当作 HEAD;真实菜单回归覆盖其他分支首行可操作和非首行 HEAD 禁用。分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;macOS Git Log 工具窗口标签悬停有反馈,点击窗口内外时选中标签背景在活动色与非活动色之间切换,标签文字保持相同颜色与常规字重,按下时不变暗;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端使用 Community 完整颜色 ID 及明暗主题 RGB 规则;macOS Git Log 不再显示单独的 Fetch/Compare/Checkout/Cherry-pick 横向操作栏,分支树与竖向工具栏分离;检查 Fetch、分支比较、分支右键 Checkout、提交右键 Cherry-pick 和 Fetch 设置仍可用,分支搜索可筛选嵌套路径,并隐藏无匹配项的分组、显示无结果提示;定位按钮滚动到当前所选分支的首个可见提交而不重新加载历史;Git Log 的分支和提交搜索框复用 全局唯一的 LitheSearchFieldStyle,对照 IDEA SearchFieldWithExtension,核对 13pt 常规提示文字、Islands 明暗控件底色与边框色、36pt 外框内的 30pt 可见高度、3pt 外层留白、1pt 常态内描边、向外扩 1pt 的 2pt 聚焦描边及 4pt 圆角;图标与文字间距为 2pt,输入框清除按钮复用已有 16pt IDEA SVG 和 20pt 按钮区域,按上游预留清除图标宽度,移除分支搜索框重复外层水平内边距,提交搜索框按 15 列字体度量和 150pt 最小宽度布局,Search 与 Database 搜索输入框使用相同共享样式;竖栏更新按钮采用 IDEA ExpUI 的明暗 update 图标,按钮与侧边栏图标悬停使用共用工具提示样式,提示的明暗背景、文字色、1pt 内描边、圆角及内边距对齐 IDEA HelpTooltip;隐藏与显示分支使用 IDEA ExpUI 的 chevronLeft/chevronRight 明暗图标;竖栏宽 36pt,展开态按钮为 22pt 且四周保留工具栏内边距,展开态分支树左边框复用与 Commit 标题下边框同源的 1pt 分隔线;收缩态竖栏常态复用展开态侧栏背景,展开与收缩保留提交图谱和详情视图实例、滚动状态及分支宽度,仅切换分支面板与分隔条的占用宽度,按钮占满竖栏,深色 hover 使用 Islands Dark 的 #FFFFFF17,浅色 hover 使用 ExpUI Gray12,不在右侧显示 Show Branches 浮层提示,图标在顶部留 8pt 并使用常规小字号文字;展开后恢复分支树宽度。 使用原生 NSHostingView 渲染验证共享 LitheSearchTextField 的占位文字在深浅主题均为 IDEA Component.infoForeground 对应的 #73767C、13pt 常规字重;空输入框单独绘制占位文字,避免 SwiftUI 原生 prompt 替换其颜色与字重,有内容时占位文字消失并保留原生编辑和主文字色。 Git Log 分支树按 IDEA Community Islands 普通密度核对 24pt 行高、无行间额外间距、13pt 常规文字、12pt 次要计数与 4/12pt 树边距;展开箭头、路径文件夹、普通分支、当前分支和标签使用上游 16pt 明暗 SVG,Local/Remote/Tags 标题不附加图标,当前分支不显示额外蓝色勾选;HEAD 与当前分支是两个互斥选中节点,点击分支、HEAD、清除筛选和切换仓库时核对选中状态;深浅主题核对树行焦点/失焦选中色及 hover,保留多仓库名称、圆点、数量和只读菜单,移除分支行仓库色条;Windows 多仓库分支树的仓库行与 macOS 一致,仓库行使用中性色文件夹图标,并只在图标与名称之间显示 8px 仓库色点,仓库名保持普通文字色,不出现整组着色卡片底色、左侧色条或彩色名称,截断名称通过共享工具提示显示全名,行悬停为箭头光标;确认搜索、分组折叠、右键操作、缓存行比较和滚动状态正常。 Git Log 分支树右侧分隔线与 Commit 标题下边框共用 LitheTheme.toolWindowBorder 和 1pt 厚度,深色为 #26282C、浅色为 #E9EAEE;悬停、拖动开始/移动/结束均保持同色,不高亮,保留原生调整光标、命中区域、拖动尺寸约束及提交宽度行为。 Git Log 中间栏按 IDEA Community FilterComponent、VcsLogGraphTable 和 PaintParameters 核对四个筛选器的 13pt 常规文字、4pt 内边距、16pt 箭头和仅文字悬停反馈;工具栏使用 16pt IDEA 明暗 SVG、22pt 点击区域和共享提示,保留动作及禁用条件;提交列表为 26pt 连续行,图谱尺寸同比缩放,作者与日期同为 13pt 主文字色,日期列按打包字体度量,核对深浅主题行 hover、焦点/失焦选中色及选中覆盖 hover,筛选、分页、箭头命中、上下键导航和右键操作不回归。 分支竖栏设置齿轮复用 LitheIconButtonStyle 的 22pt 点击区域、4pt 圆角和主题 hover/按下背景;点击使用 LitheContextMenuPresenter 的共享操作菜单背景、1pt 边框、行 hover 与键盘导航,保留 Fetch Options 对话框、Show Worktree Repositories 勾选和无工作树时禁用行为,点击外部或按 Esc 关闭菜单。 macOS 下拉菜单仅保留 Project 同源的一套全局共享样式,移除 settingsStyle 切换;Project、Git Log 设置、Terminal、欢迎页及右键子菜单统一使用 8pt 外框圆角、1pt 内描边、24pt 行高、12.5pt 文字、6pt 外留白和 8pt 行内边距,设置选择下拉框共享同一外框与尺寸定义; Git Log 顶部按 IDEA 恢复 Branch: 已选分支名称,保留 Branch 筛选、清除和菜单内勾选;Branch、User、Date、Paths 按 IDEA FilterComponent 保留 4pt 内边距、3pt 箭头间距和 13pt 常规文字,四项文字 hover 为白色且不增加底色。Date 使用共享菜单呈现器,Branch/User 的可搜索列表与分组子菜单使用共享 LitheDropdownRowStyle、24pt 行高和共享菜单外框,Paths 输入面板复用共享菜单外框及搜索输入框样式,保留搜索、选项选中、路径 Apply/Cancel/Clear 和时间范围计算。 Branch/User/Paths 原生弹窗锚点不参与鼠标命中,核对按钮与文字收到 hover/点击、菜单正确弹出且关闭后可再次打开;Date 保持共享菜单呈现器。共享下拉外框裁剪内部内容,明暗主题下 Branch/User 搜索条不能覆盖圆角;Paths 输入框复用共享输入样式并显示正确焦点边框,Clear/Cancel/Apply 复用共享次按钮/主按钮,保留路径应用和清除行为。Branch、User、Paths 的可搜索内容同样使用 LitheContextMenuPresenter 的透明无边框面板,去掉 NSPopover 系统外框,复用 Project 共享背景、1pt 边框和圆角;分组展开与搜索动态调整弹窗尺寸,Esc、点击外部和切换菜单正确关闭。选择分支后清除筛选,确认 Branch 恢复默认标题且关闭按钮消失;关闭按钮保留 16pt closeSmall 明暗 SVG、22pt hover 区域及 4pt 圆角。展开分支树左侧固定线、中间分隔线和右侧详情拖动线均使用 toolWindowBorder 的 1pt 边框,右侧 hover 与拖动不高亮。右侧详情展示图标使用 IDEA previewVertically 明暗 SVG,装饰开关使用 show 眼睛而非 hide 工具窗口减号;所有工具栏展示图标复用 22pt、4pt 圆角的共享 hover 背景,保留原动作与提示。深浅主题渲染核对背景和边框,保留可选图标、勾选、快捷键、禁用、子菜单和键盘导航。 Git Log 提交标题、作者和日期使用 Inter 13 Normal,引用标签 12pt;核对至少 26pt 的字体度量行高、含 leading 的整数基线、长标题与中文/emoji 截断,以及选中合并提交的文字颜色。 所选分支存在时 Log 标签显示 Log: 分支短名(包含 codex/ 路径),选择另一分支立即更新;清除分支筛选后恢复 Log,保留本地化标题、原始分支名和原标签选择/焦点行为。 macOS 标题起点按缩放图谱列距、图文间距和 2pt 文字内边距计算,为对齐参考截图至少预留六列,密集行继续扩宽,不改变图谱节点、连线和箭头命中;在 SwiftUI/AppKit 两条渲染路径核对未选中的合并提交标题、作者、日期统一使用合并灰色,选中后恢复普通文字色,合并判定仅依赖至少两个父节点。 Git Log 右侧文件树使用共享 24pt 行高、19pt 层级缩进、常规字重、文件类型图标和 IDEA FileStatus 文件名颜色,无逐行横线及工具栏重复总数;核对文件夹折叠、文件打开 diff、长名称 hover 展开与横向滚动,拖动宽度不重建行内容。提交详情使用 14pt 外边距、10pt 内间距、等宽 bold 标题、同段作者邮箱时间与引用标签,长内容可滚动;文件树/详情之间分隔线复用共享边框,hover/拖动不高亮。左侧分支树从 180pt 专用下限改为共享 30pt 下限,独立 36pt 竖栏保持原宽,窄树裁剪后可拖回原宽,收缩/展开不重建图谱。 图谱圆点、连线、箭头按 SimpleGraphCellPainter/ PaintUtil 的 FLOOR/ODD 物理像素规则对齐;在 1×/2× 原生位图验证圆点与线宽,在真实 NSHostingView 检查对齐后的上下斜向箭头尖端及内侧描边可导航,切换屏幕只重绘且绘制/命中坐标一致。 时间列复用 Inter 13 常规字体的等宽数字与固定 AM/PM 区域,按应用语言使用英文 12h AM/PM 或中文 24h 格式;核对午夜、正午、未知日期回退、行起点一致及切换语言后的原生日期缓存失效,列表两条渲染路径和提交详情使用相同格式。 连续上下拖动 Git Log,包含上方长 Diff:原生列表只调整可见区域,保持文档身份、滚动位置、Shift/Command 多选、焦点色、键盘与箭头导航、分页和辅助功能;收缩再恢复后列表宽高正确。GitGraphInteractionTests 使用 300 条提交、80 次原生尺寸变化和共享分隔条事件验证,局部新旧路径计时作为诊断,不代替目标工作区帧率验证。 共享分隔容器按已有宽高直接分配两个面板与分隔条的确定矩形,避免 HStack/VStack 最小及理想尺寸探测;splitPaneBounds 覆盖横纵方向、leading/trailing、折叠、子视图身份和有限尺寸,达到尺寸限制后的相同结果不重复写状态。 通过键盘或程序化切换焦点时 Git 标签活动态双向更新;挂载或重新显示 Git Log 不主动抢走编辑器焦点。 在包含展开分支树的 Git Log 中连续拖动外层高度和内部宽度:共享分隔器只在拖动开始/结束传播环境状态,嵌套容器继承祖先状态;拖动期间共享行悬停不更新、提示不登记几何锚点、右键覆盖层暂停,松开后恢复菜单和提示,行内容及滚动状态不重挂载。69 行原生分隔条事件回归检查覆盖层暂停/恢复及内容身份;未悬停控件在平时同样不登记提示锚点。用目标工作区 Time Profiler/Animation Hitches 对照 Terminal 核对持续更新成本及起止开销,split.drag 整次手势时长不能当作单帧延迟;本次仅前端共享交互装饰调整,不修改后端及默认详情高度行为。 中文输入法组词期间,即使绑定文本仍为空,共享 LitheSearchTextField 占位文字立即隐藏;取消组词或删空后恢复,确认中文后仍隐藏。原生 setMarkedText 回归覆盖明暗主题和两个输入框互不影响;保留焦点、键盘提交、搜索/过滤语义及占位字号颜色。原生背景观察视图卸载和移出窗口时清理通知,匹配当前窗口的字段编辑器及所属输入区域,不修改业务绑定。 四个筛选弹窗均以各自按钮左下角为定位锚点,Date 不依赖鼠标点击坐标;空间不足时限制在屏幕可见范围内。原生面板回归核对操作菜单/可搜索内容相同锚点下的左上角、无展开动画和 Esc 关闭回调。共享搜索字段在未聚焦时悬停也显示文本编辑光标,离开或禁用时恢复箭头。 Git Console 左侧六个现有动作使用 Community 原始 16pt 明暗 SVG;22pt 背景、竖向 2/1 按钮留白、5/7 外边距和默认箭头光标。核对查找、软换行、跟随、停止、清空、复制的原有动作与禁用状态,组件资源检查不替代完整应用验收。 按 Community CurrentBranchHighlighter 标出当前本地分支可达的所有父提交,包含合并父节点;深色 #1D2336、浅色 #EDF3FF,选中背景优先,仅已加载图谱可证明的祖先被标记,当前分支/HEAD 单独筛选时不重复染色。引用默认显示并按 GitRefManager/SimpleRefGroup 紧凑合组:本地与上游同提交显示 remote & branch,HEAD/本地/远端/标签使用官方黄/绿/紫/灰轮廓及叠层,标签单独存在时默认只显示图标;长名称按列宽和 22 字符下限缩写,原生可见区 tooltip 保留所有完整名称,滚动后按当前鼠标位置解析。明暗原生渲染检查颜色、宽度、选择优先级及完整名称;GitLogReferenceResizeTests 在真实 GitLogView 中加载 1000 个分支并拖动外层高度,检查可见右键覆盖层数量和挂载生命周期,计时仅作诊断。最新正式预览已进入该工作树,观察全部引用下的深色背景、叠层缩写标签和多次上边缘拖动后的布局恢复;用户随后在本机明确确认“现在不卡顿了”,本次报告的高度拖动卡顿通过用户实机验收;组件计时和混合工作区采样不用于推断唯一根因或量化通用帧延迟,其他主题/悬浮等视觉项继续按各自范围验收。 在 Windows Git Log 选中带多行正文的提交,确认右侧详情在标题下方完整显示正文(保留空行与缩进),复制提交信息和打开提交 Diff 时也包含正文;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端使用 Community 完整颜色 ID 及明暗主题 RGB 规则。 读取提交正文期间切换仓库、重新打开另一提交或关闭面板,确认迟到结果不会打开旧 Diff 或覆盖新请求的加载状态。 Windows 另验证:单击提交后编辑区预览该提交第一个文件的 diff;单击或双击 Commit files 中的文件只显示该文件的 diff(不含提交说明,也不显示文件列表),并复用同一个预览标签;Commit files 树中过长的文件或目录名不折叠为省略号,区域随最宽行横向展开,可通过横向滚动条或触控板侧滑左右滑动查看完整名称(与源代码管理变更树一致);Git Log 刚打开、尚未点击提交时不会自动打开 diff;日期列将有 UTC 偏移的提交转换到本机时区后显示相对时间(刚刚、N 分钟前、今天/昨天);英文绝对日期和时间通过 Windows 原生接口遵循系统短日期/时间及用户自定义格式,中文使用中文地区格式,未知偏移仍显示绝对日期;验证美式月/日/年、英式日/月/年、自定义分隔符、12/24 小时制、跨时区相对时间和迟到原生结果保护,真实 WebView2 产品验收为 pending;鼠标移入行时刷新相对时间;作者/日期列可在行内列边缘拖动调整宽度并在重启后保留。 非连续多选提交修改同一路径时,预览保留每个提交的 diff 和提交标识,可通过文件导航分别查看;延迟请求返回前取消选择、隐藏 Git Log、切换仓库或卸载工具窗,确认旧请求不会重新打开或激活预览标签。 Windows 端提交历史仅通过 Git Log 工具窗浏览:源代码管理侧栏入口已更名为 Commit 面板,其 History 标签已移除,面板固定显示变更列表与提交区。命令面板中的 Git: Show History 入口同步移除。 Windows Commit Files 单文件预览使用独立页面:上下箭头导航差异,下箭头在当前文件最后一处后继续进入下一文件并在比较完成后定位第一处差异,整个文件集合末尾禁用且不循环;图片、二进制和无文本差异文件也允许下箭头继续,比较未就绪时禁用以防连续点击跳过文件。左右箭头直接切换当前比较集合的上一文件/下一文件;标签使用 IDEA 的 Repository Diff: 文件名并跟随导航更新,不附加提交 hash。Jump to Source 按最后聚焦侧的补丁位置打开预览所属仓库的文件,重命名使用新路径、删除行映射邻近保留行、已删除文件禁用源跳转;模型更新期间禁用差异动作。保留并排/统一视图、空白显示、Monaco 查找、图片和二进制呈现,普通全量提交比较仍使用多文件页面。真实 WebView2 内核及源文件打开还需目标产品运行验收。 Windows 单文件历史 Diff 外观验证:并排视图使用独立外侧 12px 滚动轨道、8px 滑块和概览,通常的统一视图保留 Monaco 原生 Diff 组件,整文件新增使用真实新行流的独立只读控件,深浅主题检查常态/悬停/拖动色、圆角及边框;实际拖动任一并排滑块后按差异边界同步对应行且模型身份保持。工具栏使用现有 Button,核对 40px 高度和 22px 图标按钮;保留用户字体与字号,默认行距按 14px 字号对应 22px 并随缩放,自定义行距不覆盖。词级高亮开关复用现有 Monaco 装饰能力,关闭只隐藏字符差异、保留整行差异,切换文件保留词级和空白开关。旧/新版本标题显示真实第一父提交、范围基线/目标或非连续提交各自身份,根提交旧侧为空树,重命名显示旧/新路径;短 hash 和截断路径的 tooltip 保留全文。并排标题列宽由独立布局所有者更新 CSS,拖动分隔条时标题跟随且不重建模型;统一视图标题纵向排列。以上浏览器真实组件验证不替代目标 WebView2 产品验收。 Windows 单文件 Diff 滚动条回归:两侧横纵滚动条默认显示,鼠标移到工具栏后仍可见,不依赖悬停;差异概览不绘制重复的宽矩形视口指示,并排由独立外侧轨道负责点击、拖动和滚轮,统一视图保留原生委托。明暗主题、并排/统一视图及长文本双向溢出下检查唯一可见滑块、并排横向同像素偏移同步、纵向对应行同步和模型身份。 历史 Diff 两侧滚动轨道与代码编辑器概览画布背景透明,鼠标不悬停时滑块仍显示;关闭代码编辑器重复概览通道,但保留 Diff 专用差异色条。横向轨道不额外增加内容高度,检查代码能显示在滑块后、底部内容与滚动位置正确,拖动命中保留,并排两侧横向同像素偏移同步、纵向对应行同步。 Windows 并排单文件 Diff 使用两个独立只读 Monaco 代码控件,取消 DiffEditor 的补空白对齐。左右横向滚动按同一像素偏移双向同步,较短侧由 Monaco 钳制到真实内容边界且不反向拉回主动侧;纵向原生代码区、外侧滑块拖动/点击、轨道键盘、滚轮和查找以视口三分之一处为锚点,按 Git 差异块起止边界同步对应行并保留行内偏移,短侧在不等长块内钳制、共同上下文恢复对齐。同步有重入与微小方向保护;双模型替换、布局、字体配置和显式上下导航期间暂停同步。新增旧侧/删除新侧画横跨可见代码区的双像素零行边界,修改区画上下单像素边界;两列行号背景为直边,曲线只在两列之间弯曲,实际偏移改变端点形状。检查文件开头/末尾零行、三态、明暗主题和窄列,代码不补空白、不扩展横向范围;连接面覆盖两列历史行号背景,行号选择和源映射正确。左右外侧各显示透明常显纵向滑块与增删改概览,横向保留原生滚动条,滚动范围截止真实最长代码行。验证深浅主题、折叠展开、Ctrl/Cmd+F 搜索折叠文本、词级开关和预算、统一视图及跨文件首处定位、连续拖动和窄窗口非负尺寸。高频帧二分筛选可见差异,缓存像素边界及概览,不扫描全文、不更新 React;卸载和迟到语言准备清理所有帧、监听、观察器、拖动、两个编辑器和模型。浏览器生产组件验证使用平台记录器,真实 WebView2 产品验收仍为 pending。 外侧轨道预留独立空间,不显示代码;左轨道距面板边缘、右轨道距代码视口各留 4px 间隙,窄列随可用宽度缩减,右侧文字与轨道不重叠;并排和统一预览关闭缩进及括号引导线,保留语法颜色;中央行号缩小边距和曲线间隙,整数布局边界及上下半像素描边消除与代码区的垂直接缝。滚轮在代码、行号和轨道统一加快到两倍,拖动仍映射各侧真实进度,纵向同步对应行;方向键按用户行高移动。有词级结果时修改正文底色按 40% 修改色与 60% 页面背景混合,完整修改色突出替换词的两侧,纯新增和纯删除词分别使用完整增删色,语法前景色和文字透明度保持;关闭词级或超限保留整行底色。验证三态与开头末尾、双向对应行同步、明暗主题、轨道无遮挡、宽窄字体布局和预算回退。 单文件并排及统一视图隐藏补丁 @@ 标题,但保留真正以 @@ 开头的源码、行 ID、源行映射和原始补丁;默认多文件与暂存投影不变。Islands Dark 继承 Darcula 背景,Islands Light 使用 Light 方案,独立差异轨道标记与连接、代码边界共用对应差异背景色。底部保留五个用户实际行高的 padding,模型不增加假行;从任意侧滚到底部验证共同末行对齐与五行留白,字号/行距变化同步更新。 Windows 单文件 Diff 首次打开、左右切文件和同路径新比较均在模型/比较准备完成后定位第一处改动;定位只消费一次,调整词级、空白、字号或主题保持用户当前位置。统一视图的原生 Monaco 增删行、字符与行号栏使用该预览相同的 Islands 背景变量,移除默认字符边框;新增整文件应为深色 #294436 或浅色 #BEE6BE,删除为 #484A4A 或 #D6D6D6,语法前景色不变且其他编辑器主题不被覆盖。 整文件新增采用单流只读代码控件,隐藏不存在的旧侧与连接带,新增背景覆盖行号、代码和底部留白,避免空旧模型的灰色删除行及外圈;新增仅显示新版本、删除仅显示旧版本的一组 hash 与路径。并排两侧及新增单流轨道的增删改标记与对应内容背景共用颜色;原生统一预览关闭 renderIndicators,历史行号旁不显示 +/-。这组新增显示修复按用户要求延后运行验证,提交或 PR 前核对新增文件、普通修改、删除、深浅主题、查找、源跳转与底部留白。 Windows Git Log 单文件和 Commit 文件 Diff 的词级高亮、显示空白符、统一/并排切换均在左侧,最右侧统计当前文件差异,区分无差异/单数/复数;Commit 勾选、部分包含、取消及刷新后 included 数同步变化,全部包含仍显示数量;加载、切换文件及图片/二进制不显示文本差异统计。核对中英文、明暗主题、窄窗口和按钮选中/禁用状态。 Windows 单文件 Diff 上箭头在首处继续进入上一文件最后一处,下箭头在末处进入下一文件首处,集合首尾不循环、加载中禁用;Commit 文件列表随跨文件导航更新选中行并展开祖先、滚动到可见位置,保留编辑器焦点和 include 状态;同文件刷新、历史 Diff、无关仓库和迟到读取不扰动列表。 Windows Git Log 右下角 Commit files 列表同样跟随 Diff 的上一/下一文件导航:选中行移到当前文件,被折叠的父目录自动展开,行滚动到可见位置;预览属于其他提交或范围时不改变选中,点击行不会改变横向滚动。 Windows Git Log 筛选后基于完整已加载历史投影可见祖先:隐藏中间提交以虚线连接,直接父子保持实线,无关分支不误连;合并路径去重并保留永久颜色,未加载父提交经隐藏节点传播,分页补齐后解除缺失标记;清空筛选恢复原图,核对文本、作者、分支名称三种范围、空结果与选择行为。算法和真实表格组件回归通过不代替 WebView2 原生验收,原生验证保持 pending。 Windows 未筛选图与筛选图共用上下半边打印元素:普通浏览的跨行泳道换列必须在共同中点连续连接,合流入边保留各自颜色到提交圆点;核对原始 IDEA 200 条历史输出的列号、方向、终止和颜色身份;截图/原生交互验收仍为 pending,组件输出和 golden 对照不代替 WebView2 验收。 Windows Git Graph 外观:核对明暗主题真实 IDEA RGB、实心节点、直线与筛选虚线、终止箭头及至少六列标题预留;默认 26px 行高,用户字号变化时虚拟行与 SVG 同步扩高;在 100%/125%/150%/200%/300% 缩放及跨屏 DPI 变化下核对奇数物理像素和相邻半边连续性,普通视口调整不重建历史图。箭头能跳到已加载且可见的父/子提交,未加载父节点没有可点击目标;浏览器生产组件验证不代替目标 WebView2 运行验收,原生验证保持 pending。 Windows 历史排序和图导航:在交错分支仓库核对首批/分页/回退及全引用上下文均使用日期顺序,切换分支保持可证明的永久颜色和基础顺序;上下文最多 5000 条,覆盖不完整或哈希重复时保留可见页并回退。核对 upstream/backup 等非 origin 远程引用分类及本地含斜杠分支;背景上下文不阻塞 50 条可见页,分页不取消或重读上下文,切仓库/刷新/卸载取消旧操作且关闭迟到游标。长边开关展开/收起不改变提交、筛选、选择和游标;验证 30/1000 行边界。点击或用 Enter/空格激活上下箭头,选择并滚动到屏幕外端点,挂载后键盘焦点随目标;不误选源行、不误开源行 Diff,保持普通选择/多选/双击/右键和原预览入口。明暗主题及 1x/1.25x/2x 检查箭头尖端与命中范围,真实 WebView2 运行验收保持 pending。 图谱颜色必须跟随 ThemeRegistry 根 data-theme-type 切换;不依赖页面存在 dark class。复用真实 Lithe 明暗主题检查同一已挂载节点与连线的颜色变化、布局不因主题重算,结合主题回归和浏览器真实切换验证。 Windows 分页稳定性:选中顶部提交后向下滚动触发连续分页,确认滚动位置不跳回已选提交,Commit Files 不清空或重读,已选文件及树折叠保持;更新引用、背景上下文和相同 hash 的元数据同样不导航。定位分支头通过独立请求仍能定位已经选中的头提交一次;新选择或切仓库仍读取新文件并丢弃旧结果。组件回归与真实浏览器虚拟列表/IntersectionObserver 验证通过,目标 WebView2 实机验收保持 pending。 Windows Git Log 标题栏不显示固定 Read-only/只读文字,关闭按钮保持靠右,刷新、设置及隐藏操作正常。 Windows Git Log 单个提交右键 New Tag… 输入非空且无空白的标签名,创建指向右键提交完整 hash 的轻量标签并刷新引用和历史;取消不写入,失败保留名称可重试,双击/Enter 不重复创建,多选和写操作期间禁用。仓库或工作区切换关闭旧弹窗,旧请求迟到不得刷新或关闭新弹窗。 Windows 提交区域点击后上下箭头更新可见提交选择和详情,焦点不随虚拟行移除而丢失;筛选、首尾边界、Home/End、Shift 范围选择和 Enter 正常,菜单与输入框保留自己的方向键。 Windows Git Log 鼠标或上下键选择提交后,异步 Diff 自动定位第一处改动但保持提交列表键盘焦点;并排与统一预览均不调用初始 focus。双击/Enter 打开完整 Diff、主动差异跳转及跨文件导航仍进入编辑器。 Windows Git Log 提交右键 New Tag 使用与 Tags 节点同源的 TagIcon(沿用现有标签轮廓图案),核对 16px 图标、菜单对齐和禁用反馈;新补图标的实机视觉确认待完成。 Windows 提交列表菜单键和 Shift+F10 打开当前选中提交的真实菜单;关闭后列表继续上下键导航,菜单操作仍针对所选提交。New Tag 在 tag.gpgSign=true 下生成轻量标签,不运行签名器或消息编辑器,不修改配置。 Windows 提交行高亮和紧凑引用组:在全部引用和其他分支视图核对当前检出分支全父祖先背景,当前分支/HEAD 视图关闭重复高亮;合并提交的说明、作者、日期未选中时弱化、选中后恢复,选中背景优先于分支并区分列表/文件树/窗口焦点。核对明暗主题固定基准色、真实 upstream 合并名称、游离 HEAD、每种最多两个叠加图标、仅标签图标和完整汇总悬停/无障碍提示。宽窄列及字号变化时按至少 22 字符缩略规则布局,只有一个视口尺寸观察器,列拖动不逐行读取宽度,卸载清理;分页不丢滚动/选择,成功后不重读 Commit Files;文件读取失败时 Commit Files 面板显示重试入口,重新选择同一提交或刷新历史也会重试一次,长边导航和新建标签原入口保持。组件与浏览器验证通过,目标 WebView2 实机验收 pending。 Windows Git Log 分支树和提交列表的普通滚轮使用 Community Windows 默认 200ms 和 (0.5,0.505,0.5,1) 曲线;连续同向累积、反向从当前位置重启。核对两处列表的中间帧、Shift 横向滚动、分页保持、顶底边界、键盘定位和滑块拖动取消惯性、关闭工具窗清理帧以及减少动态效果偏好。确定性动画与 DOM 回归通过,真实 WebView2 设备手感验收保持 pending。 Windows graph 标题不再固定预留六列:建议宽度取可见图边数的加权均值与标准差,最多以六列作统一基线,每行按节点与斜边中点扩展,密集行不把其他简单行扩成最大列宽。验证单线/少分支、罕见密集合并、超过六列、筛选、长边开关、引用标签可用宽度及字号/DPI,保持图谱颜色/连线/导航不变。 Windows Git Log 的 Commit Files 文件树沿用 IDEA Windows 默认 200ms 滚轮动画;在文件行、竖向/横向滚动条区域连续滚动、反向及 Shift 横向滚动,确认共用真实视口的动画目标,长文件名保持自然宽度;按下滚动条取消未完成惯性,减少动态效果时直接滚动,切换提交及卸载不残留帧。其它 ScrollArea 默认行为不变。真实 WebView2 交互验收 pending。 Windows Git Log 右键 New Tag 打开弹窗后焦点直接位于标签名输入框,无需点击即可输入,Enter 提交、Esc 关闭。 macOS Git Log 空状态:在非 Git 项目检查明暗主题、窄窗口和键盘操作:13pt 常规文字按 IDEA 左对齐,整组位于内容区上方三分之一处;显示 To track changes to code、GitHub/GitLab 官网链接、Initialize Git repository…、Open Git Settings 和带原始 ContextHelp 图标的 Version control integration(打开 Git 官方文档)。初始化仍先确认,不自动暂存或提交;设置打开既有 Git 设置。未提交仓库、无匹配提交、加载失败及重试保持原动作,所有空状态操作采用文字按钮。 非 Git 项目空状态隐藏 Log/Worktrees/Console 标签、添加按钮、分支树与动作条、筛选/搜索工具栏及提交详情,提示占满工具窗内容区;GitHub、GitLab、初始化、设置及帮助链接悬停仅给当前文字加主题强调色下划线并显示手形,离开后恢复;初始化或切换至真实仓库后恢复原有三栏布局,真实仓库无匹配提交仍保留筛选入口。 空状态字体与间距对照 Community c7f91397:按钮标签显式使用 Inter 13pt Regular;行首破折号按首行基线对齐,21pt 行高加 2pt 间隔,帮助图标间隔 2pt;灰字与链接分别沿 Component.infoForeground 和 Islands Link.activeForeground 核对明暗主题,悬停下划线仍使用当前主题强调色。 macOS 右侧提交详情的分支标签按可见区域懒加载,全部引用仍可滚动查看。重开面板初始化时恢复匹配的提交图和分支树,第一次布局即可读取全部已缓存数据,不依赖出现后的异步任务回填。重开面板复用未变化的分支树,单仓库不重复构建分组副本,多仓库活动根复用已有三类行;引用、仓库、工作树开关、折叠状态改变后拒绝旧树。原生绘制与无障碍日期文本复用缓存,语言改变后刷新。 macOS 大量引用首屏:同提交放置 1,500 个本地分支和 1,500 个远程引用,核对真实 upstream 优先、同名远程自然排序回退、完整提示及每类图标上限;反复切换 Terminal/Git Log,数据版本相同应复用已准备图,改变分支/筛选/图显示选项后不得复用旧图。运行 GitGraphInteractionTests 的 manyReferenceGroups 和 presentationCache;组件耗时仅诊断,整机卡顿以相同数据实测为准。 macOS Git Log 及终端按需创建后由工作台持有实际原生宿主;隐藏再重开及移动停靠位置复用控件。运行 GitLogReferenceResizeTests,验证同一工作台隐藏再重开时,固定原生表面上的提交文档和滚动容器对象身份不变,恢复可见性且尺寸正确,工作台释放后宿主释放;核对语言、主题、搜索、分支操作和终端会话。 Git Log 与终端同时停靠时,从外层横向和纵向分隔条中央及向面板两侧偏移 4pt 处,经窗口发送按下、拖动、松开,确认完整 10pt 命中区仍能提交尺寸、宿主身份保持,面板中央正常交互。;macOS 保留宿主圆角需在明暗主题、侧栏顶部与移动后、项目辉光改变、渐变开关及背景图切换时,与原工作台树四角像素一致(WorkbenchSurfaceBackgroundTests.retainedPaneCornersMatchWorkbenchGradient)。 Windows HEAD 按 Community HeadNodePainter 核对外圈、背景间隔和中心实点:26px 行高、100% 缩放时三层直径为 13/9/5px,125%/150%/200%/300% 按物理像素 FLOOR/ODD 对齐;深浅主题、悬停、当前分支及活动/非活动选择均透出真实行底色,穿过 HEAD 的连线不侵入间隔。普通节点及分叉、筛选、长边展开保持;同边双向终止元素键唯一。目标 WebView2 实机验收保持 pending。 macOS 在明暗主题的窄 Git Log 工具栏中选择长用户名、分支和路径,确认名称与选中值以同一行尾部截断,文字不重叠,清除按钮和重新选择仍可用。 macOS Git Log 接近底部时自动追加较早提交,正常浏览不出现必须点击的加载更多按钮;单一页面最多发起一次边缘请求,追加保留选择和原生视口偏移。无新提交或缺失后续游标时停止并释放游标;失败暂停自动读取,显式刷新重开历史而不重用失败游标。运行 GitGraphInteractionTests.automaticHistoryPaging 与 GitModuleTests.continuousHistoryPagingStopsOnDuplicatePagesAndRefreshesAfterFailure,真实大仓库连续滚动和 Windows WebView2 验收仍 pending。 macOS 深层历史后台刷新:文件状态变化不重开首批,元数据刷新保留已加载窗口和后续页占位,未变结果不重建提交图;外部新提交按可见 hash 与行内偏移保持原生视口,按新行位移补齐旧尾部,保留尾部选择。当前仓库引用只读取一次,多仓库仍独立加载。后台刷新打断自动分页后,即使历史相同也能继续读取新游标;失败仍暂停自动重试。运行 GitModuleTests.backgroundRefreshRetainsDeepHistoryAndSelectionWhileExternalCommitsStillAppear、引用聚合回归及 GitGraphInteractionTests.backgroundHistoryRefreshAnchorsVisibleCommitInsteadOfSelectedCommit/automaticHistoryPaging;真实大仓库长时间滚动仍待用户验收,原生进程 cleanup 超时未独立复现,不将减少重复查询等同于修复所有进程失败。 macOS #1542:显示保留的 Git Log/终端时,横纵分隔条中央及两侧偏移 4pt 仍经窗口事件完成拖动且只提交一次,宿主身份不变;根命中查询深度始终为 1,面板内部连续 100 次查询不产生额外根查询,鼠标移动不回查祖先 SwiftUI。验证禁用、收起(含嵌套分隔条)、恢复、卸载、跨窗口与登记表隔离、实时移动/裁剪、隐藏/透明和弱引用释放;保留宿主内部仍正常命中,外层边缘优先。Diff 两端收起后在普通与保留侧栏下均能经窗口拖回,不触发外层拖动。运行 GitLogReferenceResizeTests、SplitHandleHitTestTests 和 DiffScrollSynchronizationTests;旧实现先出现嵌套命中断言失败,新实现通过。较新 macOS 原生组件回归不替代 macOS 13 Intel 原报告崩溃验收,目标环境验证保持 pending。 历史菜单资格使用 Core 读取真实 HEAD 与第一父链;在筛选/分页、旁支首行、跳选、切换仓库、HEAD 变化和关闭菜单时验证动作状态及迟到结果隔离;两端原生界面验收 pending。 Git 控制台执行日志的记录状态(completed / unconfirmed)、单条与总量保留预算和脱敏由 Core 事件携带,两端不再各自归约或二次脱敏,单条输出预算统一为 32768 字符。 历史刷新的窗口大小、头部新增后保留旧尾部的延伸读取、翻页合并去重与上限、选中提交/焦点/范围锚点的保留统一由 Core `git.historyWindow` 决定;Windows 监听刷新不再把深层历史缩回第一页,选中与锚点在仍已加载时保留,像素级滚动锚点仍留在视图。滚动到深处后触发后台刷新,确认窗口深度、选中行与 Shift 范围起点不变;两端原生界面验收 pending。", "notes": "多仓库颜色槽位由 Core 按完整顺序分页计算;两端只映射色板,隐藏工作树不改变其他仓库颜色,迟到的结果被丢弃。 Windows 两处单文件 Diff 共用工具栏:显示选项移到左侧,右端显示 IDEA 差异统计及 Commit included 数;英文文案、单复数和空白符稳定开关名参考 Community 源码。组件回归覆盖统计及开关行为,原生视觉验收 pending。 Windows 上下差异箭头补齐双向跨文件导航;Commit 列表从活动工作区预览的仓库/路径同步行选择和一次性虚拟滚动,沿用原有加载代次保护。组件回归覆盖双向边界、嵌套仓库、折叠展开、刷新及焦点/include 保持,完整原生验收 pending。 Windows 筛选图沿用 macOS/Community 双向最近可见祖先算法和隐藏边界分组;通过统一打印元素使用保留边身份的 SVG 半边绘制,未筛选和筛选共用同一绘制入口。 Windows 移除未筛选 SVG 的泳道数组推断路径,统一消费 printElements;图宽计入斜向边界中点;外观采用 macOS/Community 完整颜色生成、实心点、直线、方向箭头和字号/DPI 绘制尺寸。 Windows Git Log 显式请求 Core 既有 date 顺序,并独立加载有界仓库上下文,在唯一且完整覆盖当前页时复用永久布局;远程引用身份来自真实引用集合。长边默认紧凑,可展开并保留安全上限,箭头透明按钮复用物理像素几何和既有选择/虚拟滚动。焦点由目标 DOM 挂载消费,拒绝替换提交或筛选的旧请求,不轮询。 Windows 将图谱数据重绘与选择/明确导航区分,分页不再触发选择定位;Inspector 按仓库与实际比较身份读取文件,保留文件树和预览代次保护;只有失败的读取可重试(失败身份与当前选择一致时才重试,避免旧失败让新选择重复加载)。 Windows 按 macOS/Community 补齐当前分支范围、合并文字和活动/非活动选择;引用由独立纯分组模型准备,共享视口字体/宽度度量绘制原始 TagPainter 图标,分组不改变真实引用。 Windows Git Log 两处滚轮显式启用共享动画绑定;每帧只更新视口,保留虚拟列表和既有事件换算,其他滚动区默认行为保持。 Windows 移除固定六列最小标题留白,按 Community PrintElementGeneratorImpl 和 GraphCommitCellUtil 的建议宽度及逐行占用混排图形与说明;真实 IDEA oracle 的 Width 输出也参与回归。macOS 保留已有标题留白,本次只调整 Windows。 Windows 连线改用单一连续 SVG,沿完整边身份拼接相邻半边;HEAD 采用 Community 独立尺寸和外圈加实点,裁掉圆内连线使背景间隔不依赖行状态副本。 连续图层只为虚拟列表及上下各一行生成节点、路径和 HEAD 裁剪,SVG 高度随窗口而非全部历史增长,按全局行偏移定位且边身份稳定;5,000 条历史、四行视口及换列分叉滚动边界有确定性回归,空虚拟范围不绘制。真实 WebView2 滚动性能仍待验收。 Git Log 工具栏按 IDEA 调整:文本筛选框缩短为固定宽度,输入后按 Enter 才搜索(清除按钮立即清空,输入法组词期间的 Enter 不触发);在文本筛选右侧新增 Branch 下拉,可搜索并按 Local/远程分组选择分支(不含 Tags),选中分支后右侧出现 × 一键回到 All;展开的下拉在鼠标位于菜单中部时也能用滚轮上下滚动(WebView2 滚轮粘连到被裁剪子元素的问题,由通用 Dropdown 把滚轮应用到自身滚动容器);所选分支按仓库保存,重新打开项目后恢复,分支已被删除时回退为 All。组件回归覆盖 Enter 搜索、下拉选择、按仓库持久化和分组排序,WebView2 实机验收 pending。 Windows 提交详情双栏 diff 的正文首列与中央 tearline 竖线保留 1px 间距(lineDecorationsWidth: 1),文字不再贴线;行号列与连接带几何不变。 macOS 修复 #1342:Git Log Branch/User/Date/Paths 的名称与选中值共用单段文字布局,保留各自颜色,窄宽度统一尾部截断,避免独立 Text 压缩导致重叠。 macOS Git 主日志新增原生边缘自动加载,保留有界后端分页和可见区域绘制;Windows 已有 IntersectionObserver 自动加载入口,本次未改动。 macOS 后台状态与历史上下文刷新分开,元数据检查原子替换已加载窗口,原生视口按可见提交锚定;复用当前仓库引用结果,避免重复读取。" } diff --git a/windows/tauri/src/features/git/api/git-history-window-api.ts b/windows/tauri/src/features/git/api/git-history-window-api.ts new file mode 100644 index 000000000..0bc679285 --- /dev/null +++ b/windows/tauri/src/features/git/api/git-history-window-api.ts @@ -0,0 +1,94 @@ +import { invoke as tauriInvoke } from "@/platform/tauri-core"; +import type { GitCommit } from "../types/git.types"; + +// Note: shared/fixtures/git/history-window-v1.json defines every rule below. + +const plan = (request: Record) => + tauriInvoke("git.historyWindow", request); + +const hashes = (commits: readonly Pick[]) => commits.map((commit) => commit.hash); + +/** + * Size of the first page a refresh reads (`git.historyWindow` refreshPlan). + * A refresh that keeps the loaded depth reads at least as many commits as the + * window holds, so a deep history never shrinks back to the first page. + */ +export async function planGitHistoryRefreshLimit( + loadedCount: number, + pageSize: number, + preserveLoaded: boolean, +): Promise { + const result = await plan<{ limit: number }>({ + operation: "refreshPlan", + loadedCount, + pageSize, + preserveLoaded, + }); + return result.limit; +} + +/** + * Commits to read after the refreshed first page so the previous window's tail + * stays loaded when new commits appeared at the head; `0` keeps the page. + */ +export async function planGitHistoryExtension( + previous: readonly Pick[], + page: readonly Pick[], + canContinue: boolean, +): Promise { + if (previous.length === 0 || !canContinue) return 0; + const result = await plan<{ extensionLimit: number }>({ + operation: "extendPlan", + previous: hashes(previous), + page: hashes(page), + canContinue, + }); + return result.extensionLimit; +} + +/** Appends a page to a window without repeating commits and bounds its size. */ +export async function mergeGitHistoryPage( + window: readonly GitCommit[], + page: readonly GitCommit[], + pageHasMore: boolean, +): Promise<{ commits: GitCommit[]; appended: number; hasMore: boolean }> { + const result = await plan<{ append: number[]; hasMore: boolean }>({ + operation: "mergePage", + window: hashes(window), + page: hashes(page), + pageHasMore, + }); + const appended = result.append.map((index) => { + const commit = page[index]; + if (!commit) throw new Error("Invalid Git history window plan"); + return commit; + }); + return { + commits: [...window, ...appended], + appended: appended.length, + hasMore: result.hasMore, + }; +} + +export interface GitHistorySelectionState { + selected: string[]; + focused: string | null; + anchor: string | null; +} + +/** + * Keeps the selected commits, focus, and range anchor that are still loaded + * after the window changed; falls back to the first loaded commit. + */ +export function reconcileGitHistorySelection( + window: readonly Pick[], + state: GitHistorySelectionState, +): Promise { + return plan({ + operation: "reconcileSelection", + window: hashes(window), + selected: state.selected, + focused: state.focused, + anchor: state.anchor, + }); +} diff --git a/windows/tauri/src/features/git/api/git-remotes-api.test.ts b/windows/tauri/src/features/git/api/git-remotes-api.test.ts index a1e0437ef..1381f5121 100644 --- a/windows/tauri/src/features/git/api/git-remotes-api.test.ts +++ b/windows/tauri/src/features/git/api/git-remotes-api.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { stubGitUpdatePlan } from "@/test-utils/git-update-plan-stub"; import * as gitEvents from "../events/git-events"; import type { GitPullPreflight, GitReference } from "../types/git.types"; @@ -65,9 +66,10 @@ describe("Git remote Pull API", () => { expect(invoke).toHaveBeenCalledWith("git_fetch", { repoPath: "C:/repo" }); }); - test("keeps a headless divergent Pull on the safe Cancel default", async () => { - invoke.mockImplementation(async (command: string) => { + test("a headless divergent Pull reports that a strategy is needed instead of pulling", async () => { + invoke.mockImplementation(async (command: string, args?: unknown) => { if (command === "git_discover_repo") return "C:/repo"; + if (command === "git.updatePlan") return stubGitUpdatePlan(args as Record); if (command === "git.pullPreflight") { return { upstream: "origin/main", @@ -90,7 +92,7 @@ describe("Git remote Pull API", () => { await expect(pullChanges("C:/repo")).resolves.toEqual({ success: false, - pullResult: { status: "cancelled" }, + pullResult: { status: "needs-strategy" }, }); expect(invoke).not.toHaveBeenCalledWith("git_pull", expect.anything()); expect(invoke).toHaveBeenCalledWith( diff --git a/windows/tauri/src/features/git/api/git-remotes-api.ts b/windows/tauri/src/features/git/api/git-remotes-api.ts index 2727557f0..d19c801c1 100644 --- a/windows/tauri/src/features/git/api/git-remotes-api.ts +++ b/windows/tauri/src/features/git/api/git-remotes-api.ts @@ -8,6 +8,7 @@ import type { } from "../types/git.types"; import { emitGitChanged } from "../events/git-events"; import { GitPullWorkflow } from "../hooks/git-pull-workflow"; +import { planGitPullInspection } from "./git-update-plan-api"; import { runGitRead } from "../runtime/git-read-coordinator"; import { getBranches } from "./git-branches-api"; import { getGitHistory } from "./git-commits-api"; @@ -206,6 +207,7 @@ export const getGitPullWorkflow = (repoPath: string): GitPullWorkflow => { ? pullReferenceIntoCurrent(repoPath, strategy, reference) : executePullChanges(repoPath, strategy, expectedBranch), operationState: getOperationState, + plan: planGitPullInspection, }); pullWorkflows.set(repoPath, workflow); return workflow; @@ -213,15 +215,11 @@ export const getGitPullWorkflow = (repoPath: string): GitPullWorkflow => { /** * Safe compatibility entry point for non-Source-Control callers. Divergence - * cancels because only the Source Control workflow can present the choice UI. + * reports `needs-strategy` without pulling because only the Pull dialog can + * present the choice UI. */ export const pullChanges = async (repoPath: string): Promise => { const workflow = getGitPullWorkflow(repoPath); - const unsubscribe = workflow.subscribe(() => { - if (workflow.getSnapshot().pendingPreflight) { - workflow.chooseStrategy(null); - } - }); const resultPromise = workflow.run(repoPath, { refresh: async () => { const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); @@ -240,16 +238,12 @@ export const pullChanges = async (repoPath: string): Promise) => + tauriInvoke("git.updatePlan", request); + +/** + * Routes the Update of one local branch: which worktree pulls it, or whether + * it fast-forwards in place. Worktree branches are short names. + */ +export function planGitBranchUpdate( + branch: { fullName: string; upstream?: string | null; isCurrent: boolean }, + worktrees: readonly GitWorktree[], +): Promise { + return plan({ + stage: "route", + branch: { + fullName: branch.fullName, + upstream: branch.upstream ?? null, + isCurrent: branch.isCurrent, + }, + worktrees: worktrees.map((worktree) => ({ + path: worktree.path, + branch: worktree.is_detached || !worktree.branch ? null : `refs/heads/${worktree.branch}`, + isCurrent: worktree.is_current, + })), + }); +} + +/** Gates a pull in one checkout after Fetch and `git.pullPreflight`. */ +export function planGitPullInspection(input: { + preflight: GitPullPreflight; + interactive: boolean; + explicitSource: boolean; + strategy?: PullStrategy; +}): Promise { + return plan({ + stage: "inspect", + preflight: input.preflight, + interactive: input.interactive, + explicitSource: input.explicitSource, + ...(input.strategy ? { strategy: input.strategy } : {}), + }); +} + +/** The strategy the Pull dialog preselects. */ +export async function defaultGitPullStrategy( + preflight: GitPullPreflight, + fastForwardPossible: boolean, +): Promise { + const result = await plan({ stage: "defaultStrategy", preflight, fastForwardPossible }); + if (result.step !== "defaultStrategy") throw new Error("Invalid Git update plan"); + return result.strategy; +} diff --git a/windows/tauri/src/features/git/components/git-pull-dialog.test.tsx b/windows/tauri/src/features/git/components/git-pull-dialog.test.tsx index a9158cda2..f10ca07f1 100644 --- a/windows/tauri/src/features/git/components/git-pull-dialog.test.tsx +++ b/windows/tauri/src/features/git/components/git-pull-dialog.test.tsx @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import { act, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; +import * as native from "@/platform/tauri-core"; +import { stubGitUpdatePlan } from "@/test-utils/git-update-plan-stub"; import { LocaleProvider } from "@/i18n/locale-provider"; import { installHappyDom } from "@/test-utils/happy-dom"; import type { GitPullPreflight, GitPullResult, GitReference } from "../types/git.types"; @@ -61,6 +63,11 @@ beforeEach(() => { actGlobal.IS_REACT_ACT_ENVIRONMENT = true; run.mockClear(); spies.push( + // Core's `git.updatePlan` picks the preselected strategy; the stub replays its fixture. + spyOn(native, "invoke").mockImplementation((async (command: string, payload?: unknown) => { + if (command !== "git.updatePlan") throw new Error(`unexpected Core command ${command}`); + return stubGitUpdatePlan(payload as Record); + }) as typeof native.invoke), spyOn(remotesApi, "getPullPreflight").mockResolvedValue(preflight), spyOn(commitsApi, "getGitReferencesAtRoot").mockResolvedValue({ references: [reference("origin/main"), reference("origin/release")], diff --git a/windows/tauri/src/features/git/components/git-pull-dialog.tsx b/windows/tauri/src/features/git/components/git-pull-dialog.tsx index d5725a811..6049a16a6 100644 --- a/windows/tauri/src/features/git/components/git-pull-dialog.tsx +++ b/windows/tauri/src/features/git/components/git-pull-dialog.tsx @@ -18,6 +18,7 @@ import { CaretDownIcon, CaretRightIcon, WarningCircleIcon as WarningCircle } fro import { cn } from "@/utils/cn"; import { getGitReferencesAtRoot } from "../api/git-commits-api"; import { getGitPullWorkflow, getPullPreflight } from "../api/git-remotes-api"; +import { defaultGitPullStrategy } from "../api/git-update-plan-api"; import { attachGitPullDialogHost, type GitPullDialogRequest, @@ -28,7 +29,6 @@ import { getGitPullResultPresentation } from "../utils/git-pull-result-presentat import { canUseFastForward, defaultPullReference, - defaultPullStrategy, isUpstreamPullTarget, } from "../utils/git-pull-selection"; @@ -88,7 +88,10 @@ function GitPullDialog({ setReferences(branches); const initial = defaultPullReference(branches, nextPreflight); setSelectedFullName(initial?.fullName ?? null); - setStrategy(defaultPullStrategy()); + // Core picks the preselection: Git's configuration, else fast-forward when possible. + setStrategy( + await defaultGitPullStrategy(nextPreflight, canUseFastForward(nextPreflight, initial)), + ); setLoadState("ready"); } catch (error) { setLoadError(error instanceof Error ? error.message : String(error)); diff --git a/windows/tauri/src/features/git/components/log/git-log-branch-actions.test.tsx b/windows/tauri/src/features/git/components/log/git-log-branch-actions.test.tsx index 063196255..a915d0af7 100644 --- a/windows/tauri/src/features/git/components/log/git-log-branch-actions.test.tsx +++ b/windows/tauri/src/features/git/components/log/git-log-branch-actions.test.tsx @@ -3,6 +3,7 @@ import { act, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { toast } from "sonner"; import { LocaleProvider } from "@/i18n/locale-provider"; +import { installGitHistoryWindowStub } from "@/test-utils/git-history-window-stub"; import { installHappyDom } from "@/test-utils/happy-dom"; import { useRepositoryStore } from "../../stores/git-repository.store"; import { useGitLogPreferencesStore } from "../../stores/git-log-preferences.store"; @@ -143,6 +144,7 @@ beforeEach(() => { useGitLogRevealStore.setState({ pending: null }); // Keep the real action owner; stub unrelated data loading and rendering, so // this test exercises checkout confirmation and update dispatch without Git or layout timers. + spies.push(installGitHistoryWindowStub()); spies.push( controllerSpy, spyOn(workspaceRefs, "useGitWorkspaceReferences").mockImplementation(() => ({ @@ -163,7 +165,6 @@ beforeEach(() => { spyOn(pullWorkflow, "useGitPullWorkflow").mockImplementation(() => ({ isPulling: false, isPullLocked: false, - pendingPreflight: null, pull, })), spyOn(dialogs, "showConfirmDialog").mockImplementation(confirm), diff --git a/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx b/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx index 85cff10f7..367d7bf5c 100644 --- a/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx +++ b/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx @@ -57,6 +57,7 @@ import { } from "../../utils/git-reference-actions"; import { selectedReferenceAfterRename } from "../../utils/git-log-refresh"; import { showGitPushDialog } from "../../services/git-push-dialog-service"; +import { reconcileGitHistorySelection } from "../../api/git-history-window-api"; import { updateGitLogBranch } from "../../services/git-log-branch-update"; import { getGitPullResultPresentation } from "../../utils/git-pull-result-presentation"; import { showCheckoutFailure } from "../../utils/git-checkout-failure-presentation"; @@ -707,6 +708,9 @@ export function GitLogToolWindow() { if (!isCurrent()) return; if (result.status === "branch-updated") { toast.success(t("git.actionCompleted", { action })); + } else if (result.status === "needs-strategy") { + // A diverged branch needs merge or rebase; the Pull dialog is where that is chosen. + void pullWorkflow.pull(); } else { const presentation = getGitPullResultPresentation(result, t); if (presentation) toast[presentation.tone](presentation.message); @@ -815,20 +819,43 @@ export function GitLogToolWindow() { } }; + // Core keeps the selection, focus, and range anchor that are still loaded + // after the window changed (refresh, page load); the view only applies it. + const selectedCommitRef = useRef(selectedCommit); + const selectedHashesRef = useRef(selectedCommitHashes); + selectedCommitRef.current = selectedCommit; + selectedHashesRef.current = selectedCommitHashes; useEffect(() => { - setSelectedCommit((current) => { - if (current && commitByHash.has(current.hash)) return commitByHash.get(current.hash) ?? null; - return history.commits[0] ?? null; - }); - - const availableHashes = new Set(history.commits.map((commit) => commit.hash)); - setSelectedCommitHashes((current) => { - const next = new Set([...current].filter((hash) => availableHashes.has(hash))); - return next.size === current.size ? current : next; - }); - if (selectionAnchorRef.current && !availableHashes.has(selectionAnchorRef.current)) { - selectionAnchorRef.current = null; - } + let cancelled = false; + const sent = { + selected: [...selectedHashesRef.current], + focused: selectedCommitRef.current?.hash ?? null, + anchor: selectionAnchorRef.current, + }; + void reconcileGitHistorySelection(history.commits, sent) + .then((next) => { + // A click that landed while Core was answering already holds the newer selection. + if ( + cancelled || + selectedCommitRef.current?.hash !== sent.focused || + selectionAnchorRef.current !== sent.anchor || + selectedHashesRef.current.size !== sent.selected.length || + sent.selected.some((hash) => !selectedHashesRef.current.has(hash)) + ) { + return; + } + setSelectedCommit(next.focused ? (commitByHash.get(next.focused) ?? null) : null); + setSelectedCommitHashes((current) => + current.size === next.selected.length && next.selected.every((hash) => current.has(hash)) + ? current + : new Set(next.selected), + ); + selectionAnchorRef.current = next.anchor; + }) + .catch((error) => console.error("Failed to reconcile Git history selection:", error)); + return () => { + cancelled = true; + }; }, [commitByHash, history.commits]); const openDiff = (commit: GitCommit, filePath?: string) => { diff --git a/windows/tauri/src/features/git/hooks/git-pull-workflow.test.ts b/windows/tauri/src/features/git/hooks/git-pull-workflow.test.ts index 2f7fcaf02..1b7ca8007 100644 --- a/windows/tauri/src/features/git/hooks/git-pull-workflow.test.ts +++ b/windows/tauri/src/features/git/hooks/git-pull-workflow.test.ts @@ -5,6 +5,7 @@ import type { GitReference, PullStrategy, } from "../types/git.types"; +import { stubGitUpdatePlan } from "@/test-utils/git-update-plan-stub"; import { GitPullWorkflow, type GitPullWorkflowDependencies } from "./git-pull-workflow"; const remoteReference = (shortName: string): GitReference => ({ @@ -53,6 +54,7 @@ const createHarness = (overrides: Partial = {}) => return { success: true }; }, operationState: async () => null, + plan: async (input) => stubGitUpdatePlan({ stage: "inspect", ...input }), ...overrides, }; const workflow = new GitPullWorkflow(dependencies); @@ -70,14 +72,6 @@ const createHarness = (overrides: Partial = {}) => }; }; -const waitForStrategyDialog = async (workflow: GitPullWorkflow) => { - for (let attempt = 0; attempt < 20; attempt += 1) { - if (workflow.getSnapshot().pendingPreflight) return; - await Promise.resolve(); - } - throw new Error("Strategy dialog was not requested"); -}; - describe("GitPullWorkflow", () => { test("stops when Fetch fails and still refreshes", async () => { let preflightCalls = 0; @@ -173,38 +167,47 @@ describe("GitPullWorkflow", () => { }); expect(result).toMatchObject({ status: "pulled", strategy: "rebase" }); - expect(harness.workflow.getSnapshot().pendingPreflight).toBeNull(); expect(harness.pullStrategies).toEqual(["rebase"]); }); - test("still prompts when a fast-forward strategy meets divergent history", async () => { + test("asks for a strategy when a fast-forward meets divergent history", async () => { const harness = createHarness({ preflight: async () => cleanPreflight({ ahead: 1, behind: 1, diverged: true }), }); - const resultPromise = harness.workflow.run("C:/repo", { + const result = await harness.workflow.run("C:/repo", { ...harness.options, strategy: "ffOnly", }); - await waitForStrategyDialog(harness.workflow); - harness.workflow.chooseStrategy("merge"); - const result = await resultPromise; - expect(result).toMatchObject({ status: "pulled", strategy: "merge" }); - expect(harness.pullStrategies).toEqual(["merge"]); + expect(result).toEqual({ status: "needs-strategy" }); + expect(harness.pullStrategies).toEqual([]); + expect(harness.refreshCount()).toBe(1); }); - test("background fast-forward update fails on divergence without leaving a pending dialog", async () => { + test("a non-interactive update fails on divergence instead of asking", async () => { const harness = createHarness({ preflight: async () => cleanPreflight({ ahead: 1, behind: 1, diverged: true }) }); const result = await harness.workflow.run("C:/worktree", { - ...harness.options, strategy: "ffOnly", allowStrategyPrompt: false, + ...harness.options, interactive: false, }); expect(result).toMatchObject({ status: "failed", stage: "pull" }); expect(harness.pullStrategies).toEqual([]); - expect(harness.workflow.getSnapshot()).toEqual({ isPulling: false, isPullLocked: false, pendingPreflight: null }); + expect(harness.workflow.getSnapshot()).toEqual({ isPulling: false, isPullLocked: false }); expect(harness.refreshCount()).toBe(1); }); + test("Git configuration chooses the strategy when the user chose none", async () => { + const harness = createHarness({ + preflight: async () => + cleanPreflight({ ahead: 1, behind: 1, diverged: true, configuredStrategy: "rebase" }), + }); + + const result = await harness.workflow.run("C:/repo", { ...harness.options, interactive: false }); + + expect(result).toMatchObject({ status: "pulled", strategy: "rebase" }); + expect(harness.pullStrategies).toEqual(["rebase"]); + }); + test("pulls a chosen remote branch into the current branch with the selected strategy", async () => { const reference = remoteReference("origin/release"); const harness = createHarness({ @@ -239,97 +242,6 @@ describe("GitPullWorkflow", () => { expect(harness.pullStrategies).toEqual([]); }); - test("uses Merge only after an explicit divergent-history choice", async () => { - const harness = createHarness({ - preflight: async () => cleanPreflight({ ahead: 2, behind: 3, diverged: true }), - }); - - const resultPromise = harness.workflow.run("C:/repo", harness.options); - await waitForStrategyDialog(harness.workflow); - harness.workflow.chooseStrategy("merge"); - const result = await resultPromise; - - expect(result).toMatchObject({ status: "pulled", strategy: "merge" }); - expect(harness.pullStrategies).toEqual(["merge"]); - }); - - test("uses Rebase only after an explicit divergent-history choice", async () => { - const harness = createHarness({ - preflight: async () => cleanPreflight({ ahead: 1, behind: 4, diverged: true }), - }); - - const resultPromise = harness.workflow.run("C:/repo", harness.options); - await waitForStrategyDialog(harness.workflow); - harness.workflow.chooseStrategy("rebase"); - const result = await resultPromise; - - expect(result).toMatchObject({ status: "pulled", strategy: "rebase" }); - expect(harness.pullStrategies).toEqual(["rebase"]); - }); - - test("blocks when the working tree becomes dirty while choosing a strategy", async () => { - let preflightCalls = 0; - const harness = createHarness({ - preflight: async () => { - preflightCalls += 1; - return cleanPreflight({ - ahead: 1, - behind: 1, - diverged: true, - hasLocalChanges: preflightCalls > 1, - }); - }, - }); - - const resultPromise = harness.workflow.run("C:/repo", harness.options); - await waitForStrategyDialog(harness.workflow); - harness.workflow.chooseStrategy("merge"); - const result = await resultPromise; - - expect(result).toMatchObject({ status: "blocked", reason: "dirty" }); - expect(preflightCalls).toBe(2); - expect(harness.pullStrategies).toEqual([]); - }); - - test("blocks when the upstream state changes while choosing a strategy", async () => { - let preflightCalls = 0; - const harness = createHarness({ - preflight: async () => { - preflightCalls += 1; - return cleanPreflight({ - upstream: preflightCalls === 1 ? "origin/main" : "origin/release", - ahead: 1, - behind: 1, - diverged: true, - }); - }, - }); - - const resultPromise = harness.workflow.run("C:/repo", harness.options); - await waitForStrategyDialog(harness.workflow); - harness.workflow.chooseStrategy("rebase"); - const result = await resultPromise; - - expect(result).toMatchObject({ status: "blocked", reason: "state-changed" }); - expect(preflightCalls).toBe(2); - expect(harness.pullStrategies).toEqual([]); - }); - - test("Cancel is the safe divergent-history default", async () => { - const harness = createHarness({ - preflight: async () => cleanPreflight({ ahead: 1, behind: 1, diverged: true }), - }); - - const resultPromise = harness.workflow.run("C:/repo", harness.options); - await waitForStrategyDialog(harness.workflow); - harness.workflow.chooseStrategy(null); - const result = await resultPromise; - - expect(result.status).toBe("cancelled"); - expect(harness.pullStrategies).toEqual([]); - expect(harness.refreshCount()).toBe(1); - }); - test("reports an ordinary Pull failure after confirming no operation remains", async () => { let operationStateCalls = 0; const harness = createHarness({ @@ -414,7 +326,6 @@ describe("GitPullWorkflow", () => { expect(harness.workflow.getSnapshot()).toEqual({ isPulling: false, isPullLocked: true, - pendingPreflight: null, }); await expect(harness.workflow.run("C:/repo", harness.options)).resolves.toEqual({ status: "duplicate", @@ -433,7 +344,6 @@ describe("GitPullWorkflow", () => { expect(harness.workflow.getSnapshot()).toEqual({ isPulling: false, isPullLocked: false, - pendingPreflight: null, }); }, 2_000); }); diff --git a/windows/tauri/src/features/git/hooks/git-pull-workflow.ts b/windows/tauri/src/features/git/hooks/git-pull-workflow.ts index 51dc6f1cc..a96a8c393 100644 --- a/windows/tauri/src/features/git/hooks/git-pull-workflow.ts +++ b/windows/tauri/src/features/git/hooks/git-pull-workflow.ts @@ -1,3 +1,4 @@ +import type { GitUpdateBlocker, GitUpdatePlan } from "../api/git-update-plan-api"; import type { GitOperationState, GitPullPreflight, @@ -22,19 +23,26 @@ export interface GitPullWorkflowDependencies { expectedBranch?: string, ) => Promise; operationState: (repoPath: string) => Promise; + /** Core's `git.updatePlan` inspection of the preflight (the only pull gate). */ + plan: (input: { + preflight: GitPullPreflight; + interactive: boolean; + explicitSource: boolean; + strategy?: PullStrategy; + }) => Promise; } export interface GitPullWorkflowOptions { refresh: () => Promise; - /** - * Strategy confirmed in Pull or selected by a background update. Merge/rebase - * skips the divergent-history prompt; ffOnly may prompt unless explicitly disabled. - */ + /** Strategy the user confirmed in the Pull dialog; Core resolves it otherwise. */ strategy?: PullStrategy; /** Remote branch to pull into the current branch instead of the configured upstream. */ reference?: GitReference; - /** Background fast-forward updates must fail rather than wait for a dialog host. */ - allowStrategyPrompt?: boolean; + /** + * The user can be asked to choose a strategy for a diverged history. A + * non-interactive run (another worktree) refuses it instead. + */ + interactive?: boolean; /** Complete local ref selected by a background update, not a new Pull source. */ expectedBranch?: string; /** Revalidate that the worktree still has the selected branch checked out. */ @@ -44,19 +52,35 @@ export interface GitPullWorkflowOptions { export interface GitPullWorkflowSnapshot { isPulling: boolean; isPullLocked: boolean; - pendingPreflight: GitPullPreflight | null; } const IDLE_SNAPSHOT: GitPullWorkflowSnapshot = { isPulling: false, isPullLocked: false, - pendingPreflight: null, }; const REFRESHING_SNAPSHOT: GitPullWorkflowSnapshot = { isPulling: false, isPullLocked: true, - pendingPreflight: null, +}; + +const blockedResult = (blocker: GitUpdateBlocker): GitPullResult => { + switch (blocker) { + case "noUpstream": + return { status: "blocked", reason: "no-upstream" }; + case "localChanges": + return { status: "blocked", reason: "dirty" }; + case "upToDate": + return { status: "blocked", reason: "up-to-date" }; + case "stateChanged": + return { status: "blocked", reason: "state-changed" }; + case "notFastForward": + return { + status: "failed", + stage: "pull", + error: "The selected branch cannot be fast-forwarded", + }; + } }; const errorText = (error: unknown): string | undefined => { @@ -64,11 +88,10 @@ const errorText = (error: unknown): string | undefined => { return message.trim() || undefined; }; -/** Coordinates one safe pull path, including an explicit divergent-history choice. */ +/** Runs one pull: hosts fetch and inspect, Core decides, and this executes the returned step. */ export class GitPullWorkflow { private snapshot: GitPullWorkflowSnapshot = IDLE_SNAPSHOT; private readonly listeners = new Set<() => void>(); - private resolveStrategy: ((strategy: PullStrategy | null) => void) | null = null; constructor(private readonly dependencies: GitPullWorkflowDependencies) {} @@ -79,16 +102,12 @@ export class GitPullWorkflow { readonly getSnapshot = () => this.snapshot; - chooseStrategy(strategy: Exclude | null) { - this.resolveStrategy?.(strategy); - } - async run(repoPath: string, options: GitPullWorkflowOptions): Promise { if (this.snapshot.isPullLocked) { return { status: "duplicate" }; } - this.update({ isPulling: true, isPullLocked: true, pendingPreflight: null }); + this.update({ isPulling: true, isPullLocked: true }); try { const fetched = await this.dependencies.fetch(repoPath); if (!fetched.success) { @@ -114,78 +133,26 @@ export class GitPullWorkflow { }; } - // A dialog-selected branch is pulled explicitly, so the current branch's - // upstream must not gate it; Git rejects an incompatible target itself. - if (!options.reference && !preflight.upstream) { - return { - status: "blocked", - reason: "no-upstream", - }; - } - - if (preflight.hasLocalChanges) { - return { - status: "blocked", - reason: "dirty", - }; - } - - // "Up to date" is a property of the configured upstream, not of an - // arbitrary branch the user chose to pull from. - if (!options.reference && preflight.behind === 0 && !preflight.diverged) { - return { - status: "blocked", - reason: "up-to-date", - }; + // Core owns every gate: upstream, local changes, "up to date", and the + // strategy default. A dialog-selected branch is pulled explicitly, so the + // current branch's upstream does not gate it. + let decision: GitUpdatePlan; + try { + decision = await this.dependencies.plan({ + preflight, + interactive: options.interactive ?? true, + explicitSource: !!options.reference, + strategy: options.strategy, + }); + } catch (error) { + return { status: "failed", stage: "preflight", error: errorText(error) }; } - - let strategy: PullStrategy = options.strategy ?? "ffOnly"; - // Merge/rebase has a selected policy. Background fast-forward updates fail - // on divergence; interactive Pull can still ask the user to choose. - if (preflight.diverged && (!options.strategy || options.strategy === "ffOnly")) { - if (options.allowStrategyPrompt === false) { - return { - status: "failed", - stage: "pull", - error: "The selected branch cannot be fast-forwarded", - }; - } - const selectedStrategy = await this.waitForStrategy(preflight); - if (!selectedStrategy) { - return { status: "cancelled" }; - } - - let currentPreflight: GitPullPreflight; - try { - currentPreflight = await this.dependencies.preflight(repoPath); - } catch (error) { - return { - status: "failed", - stage: "preflight", - error: errorText(error), - }; - } - - if (currentPreflight.hasLocalChanges) { - return { - status: "blocked", - reason: "dirty", - }; - } - - if ( - currentPreflight.upstream !== preflight.upstream || - currentPreflight.ahead !== preflight.ahead || - currentPreflight.behind !== preflight.behind || - currentPreflight.diverged !== preflight.diverged - ) { - return { - status: "blocked", - reason: "state-changed", - }; - } - strategy = selectedStrategy; + if (decision.step === "blocked") return blockedResult(decision.blocker); + if (decision.step === "chooseStrategy") return { status: "needs-strategy" }; + if (decision.step !== "pull") { + return { status: "failed", stage: "preflight", error: "Invalid Git update plan" }; } + const strategy = decision.strategy; if (options.validateBranch && !(await options.validateBranch())) { return { status: "blocked", reason: "state-changed" }; @@ -213,8 +180,6 @@ export class GitPullWorkflow { error: pulled.error, }; } finally { - this.resolveStrategy = null; - // Keep duplicate Pull attempts blocked while allowing unrelated Git // actions after the command and conflict inspection have completed. this.update(REFRESHING_SNAPSHOT); @@ -228,17 +193,6 @@ export class GitPullWorkflow { } } - private waitForStrategy(preflight: GitPullPreflight): Promise<"merge" | "rebase" | null> { - return new Promise((resolve) => { - this.resolveStrategy = (strategy) => { - this.resolveStrategy = null; - this.update({ isPulling: true, isPullLocked: true, pendingPreflight: null }); - resolve(strategy === "merge" || strategy === "rebase" ? strategy : null); - }; - this.update({ isPulling: true, isPullLocked: true, pendingPreflight: preflight }); - }); - } - private update(snapshot: GitPullWorkflowSnapshot) { this.snapshot = snapshot; for (const listener of this.listeners) listener(); diff --git a/windows/tauri/src/features/git/hooks/use-git-log-controller.integration.test.tsx b/windows/tauri/src/features/git/hooks/use-git-log-controller.integration.test.tsx index fcc3ed904..7621e1b26 100644 --- a/windows/tauri/src/features/git/hooks/use-git-log-controller.integration.test.tsx +++ b/windows/tauri/src/features/git/hooks/use-git-log-controller.integration.test.tsx @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun: import { act, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { LocaleProvider } from "@/i18n/locale-provider"; +import { installGitHistoryWindowStub } from "@/test-utils/git-history-window-stub"; import { installHappyDom } from "@/test-utils/happy-dom"; import * as historyApi from "../api/git-commits-api"; import type { @@ -95,6 +96,7 @@ beforeEach(() => { value: window.CustomEvent, }); actGlobal.IS_REACT_ACT_ENVIRONMENT = true; + spies.push(installGitHistoryWindowStub()); spies.push( spyOn(historyApi, "cancelGitHistoryOperation").mockImplementation(cancelGitHistoryOperation), spyOn(historyApi, "closeGitHistoryCursor").mockImplementation(closeGitHistoryCursor), @@ -284,6 +286,57 @@ describe("Git Log controller repository lifecycle", () => { } }); + test("a refresh keeps the loaded depth and the old tail when new commits arrive", async () => { + const commits = (from: number, to: number) => + Array.from({ length: to - from }, (_, index) => historyCommit(`c${from + index}`, `c${from + index}`)); + let refreshed = false; + getGitHistoryPage.mockImplementation((...args) => { + const [, cursor, limit] = args; + if (limit === 5_000) return Promise.resolve({ commits: [], hasMore: false }); + if (cursor === "more") { + return Promise.resolve({ commits: commits(50, 60), hasMore: true, nextCursor: "deep" }); + } + if (cursor === "refreshed") { + // The extension reads only the one commit the new head displaced. + return Promise.resolve({ commits: commits(59, 60), hasMore: true, nextCursor: "deep-2" }); + } + return Promise.resolve( + refreshed + ? { + commits: [historyCommit("new", "new"), ...commits(0, (limit ?? 1) - 1)], + hasMore: true, + nextCursor: "refreshed", + } + : { commits: commits(0, 50), hasMore: true, nextCursor: "more" }, + ); + }); + const harness = mountController(); + try { + await harness.render("C:/repo-a"); + await act(async () => { + await harness.read().loadMore(); + }); + expect(harness.read().history.commits).toHaveLength(60); + + refreshed = true; + await act(async () => { + await harness.read().refresh(); + }); + + // The refresh read the whole loaded window (60), not the first page (50). + expect(visiblePageCalls().some((call) => call[1] === undefined && call[2] === 60)).toBe(true); + const hashes = harness.read().history.commits.map((commit) => commit.hash); + expect(hashes).toHaveLength(61); + expect(hashes[0]).toBe("new"); + expect(hashes[60]).toBe("c59"); + expect(harness.read().history.hasMore).toBe(true); + } finally { + await act(async () => { + harness.root.unmount(); + }); + } + }); + test("locating a commit appends the pages read while searching and returns the hit", async () => { const tip = commitFor("C:/repo-a"); const older = historyCommit("older", "older page"); 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 b5f4f6879..00538aef5 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 @@ -7,6 +7,11 @@ import { getGitReferences, locateGitCommit, } from "../api/git-commits-api"; +import { + mergeGitHistoryPage, + planGitHistoryExtension, + planGitHistoryRefreshLimit, +} from "../api/git-history-window-api"; import { subscribeToGitChanges } from "../events/git-events"; import type { GitCommit, GitHistorySnapshot, GitReference } from "../types/git.types"; import { @@ -112,11 +117,14 @@ export function useGitLogController( cursor, loadingMore = false, refreshReferences = true, + preserveLoaded = false, }: { reference: GitReference | null; cursor?: string; loadingMore?: boolean; refreshReferences?: boolean; + /** Rebuild the already loaded window instead of resetting to the first page. */ + preserveLoaded?: boolean; }) => { if (!repoPath || repoPathRef.current !== repoPath) return false; @@ -134,7 +142,21 @@ export function useGitLogController( if (loadingMore) setIsLoadingMore(true); else setLoadState("loading"); + // A refresh that keeps the loaded depth reads that many commits at once. + // Core owns the size; the window is replaced atomically below. + const previousCommits = preserveLoaded && !cursor ? historyRef.current.commits : []; try { + const pageLimit = cursor + ? COMMITS_PER_PAGE + : await planGitHistoryRefreshLimit( + previousCommits.length, + COMMITS_PER_PAGE, + preserveLoaded, + ); + if (isStaleRequest()) { + if (cursor) void closeGitHistoryCursor(repoPath, cursor); + return false; + } const pendingPage = Promise.all([ refreshReferences ? getGitReferences(repoPath, referencesOperationId) @@ -142,7 +164,7 @@ export function useGitLogController( getGitHistoryPage( repoPath, cursor, - COMMITS_PER_PAGE, + pageLimit, pageOperationId, reference?.fullName, ), @@ -196,31 +218,65 @@ export function useGitLogController( setSelectedReferenceState(resolvedReference); } + // New heads displace the old window downwards. Core says how far to read + // past the refreshed page so the old tail (and its selection) stays loaded. + let nextCursor = page.nextCursor ?? null; + let windowPage = page; + const extensionLimit = await planGitHistoryExtension( + previousCommits, + page.commits, + !!page.hasMore && !!page.nextCursor, + ); + if (isStaleRequest()) { + if (page.nextCursor) void closeGitHistoryCursor(repoPath, page.nextCursor); + return false; + } + let extension: typeof page | null = null; + if (extensionLimit > 0 && page.nextCursor) { + const extensionOperationId = `${operationPrefix}-extension`; + activeOperationIdsRef.current.add(extensionOperationId); + try { + extension = await getGitHistoryPage( + repoPath, + page.nextCursor, + extensionLimit, + extensionOperationId, + resolvedReference?.fullName, + ); + } finally { + activeOperationIdsRef.current.delete(extensionOperationId); + } + if (isStaleRequest()) { + if (extension?.nextCursor) void closeGitHistoryCursor(repoPath, extension.nextCursor); + return false; + } + if (!extension) { + setLoadState("failed"); + setError(t("git.historyLoadFailed")); + return false; + } + windowPage = extension; + nextCursor = extension.nextCursor ?? null; + } + // The caller needs to know whether a page advanced the visible history: // a reveal that keeps asking for older pages must stop when nothing new // arrives, instead of waiting for a later refresh to jump unexpectedly. - const loadedHashes = loadingMore - ? new Set(historyRef.current.commits.map((commit) => commit.hash)) - : new Set(); - const pageAddedCommits = page.commits.some((commit) => !loadedHashes.has(commit.hash)); - 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; + // Core composes every window, so both products dedupe and bound it alike. + const base = loadingMore ? historyRef.current.commits : extension ? page.commits : []; + const merged = await mergeGitHistoryPage(base, windowPage.commits, windowPage.hasMore); + if (isStaleRequest()) { + if (nextCursor) void closeGitHistoryCursor(repoPath, nextCursor); + return false; + } + const pageAddedCommits = merged.appended > 0; + setHistory((current) => ({ + references: references?.references ?? current.references, + recentReferences: references?.recentReferences ?? current.recentReferences, + commits: merged.commits, + hasMore: merged.hasMore, + })); + activeCursorRef.current = nextCursor; setLoadState("ready"); return pageAddedCommits; } catch (loadError) { @@ -323,7 +379,8 @@ export function useGitLogController( if (repoPathRef.current !== repoPath) return; cancelScheduledRefresh(); closeActiveCursor(); - await load({ reference: selectedReferenceRef.current }); + // A watcher or manual refresh keeps the depth the user scrolled to. + await load({ reference: selectedReferenceRef.current, preserveLoaded: true }); }, [cancelScheduledRefresh, closeActiveCursor, load, repoPath]); useEffect(() => { @@ -402,14 +459,16 @@ export function useGitLogController( setError(t("git.historyLoadFailed")); return null; } - setHistory((current) => { - const loadedHashes = new Set(current.commits.map((commit) => commit.hash)); - const commits = [ - ...current.commits, - ...result.commits.filter((commit) => !loadedHashes.has(commit.hash)), - ]; - return { ...current, commits, hasMore: result.hasMore && commits.length < MAX_COMMITS }; - }); + const merged = await mergeGitHistoryPage( + historyRef.current.commits, + result.commits, + result.hasMore, + ); + if (isStaleRequest()) { + if (result.nextCursor) void closeGitHistoryCursor(repoPath, result.nextCursor); + return null; + } + setHistory((current) => ({ ...current, commits: merged.commits, hasMore: merged.hasMore })); activeCursorRef.current = result.nextCursor ?? null; // Core folds hex case when it matches the target; match the same way so a // commit reported found is always returned to the caller. diff --git a/windows/tauri/src/features/git/services/git-log-branch-update.test.ts b/windows/tauri/src/features/git/services/git-log-branch-update.test.ts index a287e36fc..aaf0f8a78 100644 --- a/windows/tauri/src/features/git/services/git-log-branch-update.test.ts +++ b/windows/tauri/src/features/git/services/git-log-branch-update.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; +import * as native from "@/platform/tauri-core"; +import { stubGitUpdatePlan } from "@/test-utils/git-update-plan-stub"; import { GitPullWorkflow } from "../hooks/git-pull-workflow"; import type { GitReference, GitWorktree, PullStrategy } from "../types/git.types"; import { updateGitLogBranch } from "./git-log-branch-update"; @@ -12,6 +14,7 @@ const spies: Array<{ mockRestore: () => void }> = []; let entries: GitWorktree[]; let diverged: boolean; let hasLocalChanges: boolean; +let configuredStrategy: PullStrategy | null; const calls: string[] = []; const refresh = mock(async () => {}); const readWorktrees = mock(async (repoPath: string) => entries.map((entry) => ({ @@ -41,12 +44,18 @@ beforeEach(() => { entries = []; diverged = false; hasLocalChanges = false; + configuredStrategy = null; calls.length = 0; refresh.mockClear(); updateBranch.mockClear(); showPull.mockClear(); readWorktrees.mockClear(); spies.push( + // Core owns the Update rules; the stub replays the shared fixture's wire format. + spyOn(native, "invoke").mockImplementation((async (command: string, payload?: unknown) => { + if (command !== "git.updatePlan") throw new Error(`unexpected Core command ${command}`); + return stubGitUpdatePlan(payload as Record); + }) as typeof native.invoke), spyOn(worktrees, "readWorktrees").mockImplementation(readWorktrees), spyOn(branches, "updateBranch").mockImplementation(updateBranch), spyOn(dialogs, "showGitPullDialog").mockImplementation(showPull), @@ -66,8 +75,10 @@ beforeEach(() => { behind: 2, diverged, hasLocalChanges, + configuredStrategy, }; }, + plan: async (input) => stubGitUpdatePlan({ stage: "inspect", ...input }), pull: async (path, strategy: PullStrategy, _reference, expectedBranch) => { expect(expectedBranch).toBe(reference.fullName); calls.push(`pull:${path}:${strategy}`); @@ -82,13 +93,25 @@ afterEach(() => { for (const spy of spies.splice(0)) spy.mockRestore(); }); -test("Current selected branch updates in the background with the existing Merge default", async () => { +test("Diverged current branch asks for a strategy instead of merging", async () => { entries = [tree("C:/repo", true)]; diverged = true; await expect( updateGitLogBranch("C:/repo", { ...reference, isCurrent: true }, refresh), - ).resolves.toEqual({ status: "pulled", strategy: "merge" }); - expect(calls).toEqual(["fetch:C:/repo", "preflight:C:/repo", "pull:C:/repo:merge"]); + ).resolves.toEqual({ status: "needs-strategy" }); + expect(calls).toEqual(["fetch:C:/repo", "preflight:C:/repo"]); + expect(updateBranch).not.toHaveBeenCalled(); + expect(refresh).toHaveBeenCalledTimes(1); +}); + +test("Diverged current branch follows the strategy Git is configured for", async () => { + entries = [tree("C:/repo", true)]; + diverged = true; + configuredStrategy = "rebase"; + await expect( + updateGitLogBranch("C:/repo", { ...reference, isCurrent: true }, refresh), + ).resolves.toEqual({ status: "pulled", strategy: "rebase" }); + expect(calls).toEqual(["fetch:C:/repo", "preflight:C:/repo", "pull:C:/repo:rebase"]); expect(showPull).not.toHaveBeenCalled(); expect(updateBranch).not.toHaveBeenCalled(); expect(refresh).toHaveBeenCalledTimes(1); @@ -143,9 +166,9 @@ test("Dirty checked-out worktree keeps changes and does not start Pull", async ( test("Current worktree is resolved from fresh metadata rather than the stale isCurrent flag", async () => { entries = [tree("C:/repo", true)]; + diverged = true; await expect(updateGitLogBranch("C:/repo", reference, refresh)).resolves.toEqual({ - status: "pulled", - strategy: "merge", + status: "needs-strategy", }); }); @@ -198,6 +221,7 @@ for (const changeAt of ["fetch", "preflight"] as const) { return { upstream: "origin/other", ahead: 0, behind: 2, diverged: false, hasLocalChanges: false }; }, pull, operationState: async () => null, + plan: async (input) => stubGitUpdatePlan({ stage: "inspect", ...input }), }))); await expect(updateGitLogBranch("C:/repo", reference, refresh)).resolves.toEqual({ status: "blocked", reason: "state-changed", diff --git a/windows/tauri/src/features/git/services/git-log-branch-update.ts b/windows/tauri/src/features/git/services/git-log-branch-update.ts index 6f4ca0169..9b800b90c 100644 --- a/windows/tauri/src/features/git/services/git-log-branch-update.ts +++ b/windows/tauri/src/features/git/services/git-log-branch-update.ts @@ -1,11 +1,14 @@ import { updateBranch } from "../api/git-branches-api"; import { getGitPullWorkflow } from "../api/git-remotes-api"; +import { planGitBranchUpdate } from "../api/git-update-plan-api"; import { readWorktrees } from "../api/git-worktrees-api"; import { emitGitChanged } from "../events/git-events"; import type { GitPullResult, GitReference } from "../types/git.types"; -import { defaultPullStrategy } from "../utils/git-pull-selection"; -/** IDEA updates checked-out branches in their worktree; other refs fast-forward in place. */ +/** + * IDEA updates checked-out branches in their worktree; other refs fast-forward in place. + * Core's `git.updatePlan` decides where each branch is updated and which gates apply. + */ export async function updateGitLogBranch( repoPath: string, reference: GitReference, @@ -18,21 +21,39 @@ export async function updateGitLogBranch( const worktrees = await readWorktrees(repoPath); // Repository discovery can finish after the Log has changed workspace or closed. if (!isActive()) return { status: "cancelled" }; - const target = worktrees.find((worktree) => worktree.branch === reference.shortName); - if (target) { - return getGitPullWorkflow(target.path).run(target.path, { - // Lithe's existing default is Merge. Another worktree only fast-forwards, - // matching IDEA without switching the active repository or rewriting history. - strategy: target.is_current ? defaultPullStrategy() : "ffOnly", - allowStrategyPrompt: false, - expectedBranch: reference.fullName, + const route = await planGitBranchUpdate( + { + fullName: reference.fullName, + upstream: reference.upstreamShortName, + isCurrent: reference.isCurrent, + }, + worktrees, + ); + if (route.step === "blocked") { + throw new Error("The selected local branch has no upstream"); + } + if (route.step === "inspect") { + const checkout = route.checkout ?? repoPath; + const isCurrentCheckout = route.checkout === null || worktrees.some( + (worktree) => worktree.path === route.checkout && worktree.is_current, + ); + return getGitPullWorkflow(checkout).run(checkout, { + // Only the active worktree may ask for a strategy; another worktree + // never switches the active repository or rewrites history. + interactive: route.interactive, + expectedBranch: route.expectedBranch, validateBranch: async () => { if (!isActive()) return false; - const current = (await readWorktrees(target.path)).find((worktree) => worktree.is_current); - return isActive() && !!current && !current.is_detached && current.branch === reference.shortName; + const current = (await readWorktrees(checkout)).find((worktree) => worktree.is_current); + return ( + isActive() && + !!current && + !current.is_detached && + `refs/heads/${current.branch}` === route.expectedBranch + ); }, refresh: async () => { - if (!target.is_current) + if (!isCurrentCheckout) emitGitChanged({ repoPath, scopes: ["history", "refs", "remotes"], @@ -42,6 +63,7 @@ export async function updateGitLogBranch( }, }); } + if (route.step !== "fastForwardReference") throw new Error("Invalid Git update plan"); try { await updateBranch(repoPath, reference); return { status: "branch-updated" }; diff --git a/windows/tauri/src/features/git/types/git.types.ts b/windows/tauri/src/features/git/types/git.types.ts index 2e516f670..f42711dd7 100644 --- a/windows/tauri/src/features/git/types/git.types.ts +++ b/windows/tauri/src/features/git/types/git.types.ts @@ -209,6 +209,8 @@ export interface GitPullPreflight { behind: number; diverged: boolean; hasLocalChanges: boolean; + /** Strategy `pull.rebase` or `pull.ff` selects; null when Git is not configured. */ + configuredStrategy?: PullStrategy | null; } /** A pull policy accepted by the shared Rust Core. */ @@ -217,6 +219,8 @@ export type PullStrategy = "ffOnly" | "merge" | "rebase"; export type GitPullResult = | { status: "pulled"; strategy: PullStrategy } | { status: "cancelled" } + /** The history diverged and the user must pick merge or rebase (Pull dialog). */ + | { status: "needs-strategy" } | { status: "blocked"; reason: "no-upstream" | "up-to-date" | "dirty" | "state-changed"; diff --git a/windows/tauri/src/features/git/utils/git-pull-result-presentation.ts b/windows/tauri/src/features/git/utils/git-pull-result-presentation.ts index fb307229f..ca2c4f891 100644 --- a/windows/tauri/src/features/git/utils/git-pull-result-presentation.ts +++ b/windows/tauri/src/features/git/utils/git-pull-result-presentation.ts @@ -14,6 +14,7 @@ export function getGitPullResultPresentation( switch (result.status) { case "duplicate": case "cancelled": + case "needs-strategy": return null; case "pulled": return { diff --git a/windows/tauri/src/features/git/utils/git-pull-selection.test.ts b/windows/tauri/src/features/git/utils/git-pull-selection.test.ts index 427218738..2259effb5 100644 --- a/windows/tauri/src/features/git/utils/git-pull-selection.test.ts +++ b/windows/tauri/src/features/git/utils/git-pull-selection.test.ts @@ -3,7 +3,6 @@ import type { GitPullPreflight, GitReference } from "../types/git.types"; import { canUseFastForward, defaultPullReference, - defaultPullStrategy, isUpstreamPullTarget, } from "./git-pull-selection"; @@ -41,9 +40,7 @@ describe("Pull dialog selection defaults", () => { expect(defaultPullReference([remote("origin/release")], preflight())).toBeNull(); }); - test("defaults to merge and never preselects fast-forward only", () => { - // Even a linear upstream keeps the safe merge default; ff-only is opt-in. - expect(defaultPullStrategy()).toBe("merge"); + test("offers fast-forward only where the Core can still fast-forward safely", () => { expect(canUseFastForward(preflight(), remote("origin/main"))).toBe(true); // Diverged history or another branch cannot be safely fast-forwarded. expect(canUseFastForward(preflight({ diverged: true }), remote("origin/main"))).toBe(false); diff --git a/windows/tauri/src/features/git/utils/git-pull-selection.ts b/windows/tauri/src/features/git/utils/git-pull-selection.ts index b49d1a2bc..74424c9fb 100644 --- a/windows/tauri/src/features/git/utils/git-pull-selection.ts +++ b/windows/tauri/src/features/git/utils/git-pull-selection.ts @@ -1,4 +1,4 @@ -import type { GitPullPreflight, GitReference, PullStrategy } from "../types/git.types"; +import type { GitPullPreflight, GitReference } from "../types/git.types"; /** True when the selected branch is the current branch's configured upstream. */ export const isUpstreamPullTarget = ( @@ -17,13 +17,6 @@ export const defaultPullReference = ( ): GitReference | null => references.find((reference) => isUpstreamPullTarget(reference, preflight)) ?? null; -/** - * Merge is the safe default: pulling should never mutate local history just - * because the upstream happens to be linear. Fast-forward stays an explicit, - * advanced choice the user can opt into. - */ -export const defaultPullStrategy = (): PullStrategy => "merge"; - /** Fast-forward is offered only where the Core can still fast-forward safely. */ export const canUseFastForward = ( preflight: GitPullPreflight, diff --git a/windows/tauri/src/test-utils/git-history-window-stub.test.ts b/windows/tauri/src/test-utils/git-history-window-stub.test.ts new file mode 100644 index 000000000..e75bf1df4 --- /dev/null +++ b/windows/tauri/src/test-utils/git-history-window-stub.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test"; +import fixture from "../../../../shared/fixtures/git/history-window-v1.json"; +import { stubGitHistoryWindow } from "./git-history-window-stub"; + +describe("git.historyWindow test stub", () => { + for (const testCase of fixture.cases) { + test(testCase.name, () => { + expect(stubGitHistoryWindow(testCase.request as Record)).toEqual( + testCase.response, + ); + }); + } +}); diff --git a/windows/tauri/src/test-utils/git-history-window-stub.ts b/windows/tauri/src/test-utils/git-history-window-stub.ts new file mode 100644 index 000000000..defe265b8 --- /dev/null +++ b/windows/tauri/src/test-utils/git-history-window-stub.ts @@ -0,0 +1,68 @@ +import * as native from "@/platform/tauri-core"; +import { spyOn } from "bun:test"; + +const MAX_WINDOW_COMMITS = 5_000; + +/** + * Stand-in for Core's `git.historyWindow` in Git Log tests. The rules belong to + * Core (`git/history_window.rs`); `git-history-window-stub.test.ts` replays the + * shared fixture `history-window-v1.json` through this stub so it cannot drift. + */ +export function stubGitHistoryWindow(request: Record): unknown { + switch (request.operation) { + case "refreshPlan": { + const limit = request.preserveLoaded + ? Math.max(request.pageSize, request.loadedCount) + : request.pageSize; + return { limit: Math.min(limit, MAX_WINDOW_COMMITS) }; + } + case "extendPlan": { + const { previous, page, canContinue } = request as Record; + const tail = previous[previous.length - 1]; + if (tail === undefined || !canContinue || page.includes(tail)) return { extensionLimit: 0 }; + const offset = page.findIndex((hash: string) => previous.includes(hash)); + if (offset < 0) return { extensionLimit: 0 }; + const previousIndex = previous.indexOf(page[offset]!); + return { + extensionLimit: Math.max( + 0, + Math.min(offset - previousIndex, MAX_WINDOW_COMMITS - page.length), + ), + }; + } + case "mergePage": { + const loaded = new Set(request.window); + const append: number[] = []; + request.page.forEach((hash: string, index: number) => { + if (!loaded.has(hash) && loaded.size < MAX_WINDOW_COMMITS) { + loaded.add(hash); + append.push(index); + } + }); + return { + append, + hasMore: + request.pageHasMore && + append.length > 0 && + request.window.length + append.length < MAX_WINDOW_COMMITS, + }; + } + default: { + const window: string[] = request.window; + const isLoaded = (hash: string | null) => !!hash && window.includes(hash); + const focused = isLoaded(request.focused) ? request.focused : (window[0] ?? null); + let selected = window.filter((hash) => request.selected.includes(hash)); + if (selected.length === 0 && focused) selected = [focused]; + return { selected, focused, anchor: isLoaded(request.anchor) ? request.anchor : focused }; + } + } +} + +/** Routes `git.historyWindow` to the stub; other Core commands keep their behavior. */ +export function installGitHistoryWindowStub() { + const original = native.invoke; + return spyOn(native, "invoke").mockImplementation((async (command: string, payload?: unknown) => + command === "git.historyWindow" + ? stubGitHistoryWindow(payload as Record) + : original(command, payload as Record)) as typeof native.invoke); +} diff --git a/windows/tauri/src/test-utils/git-update-plan-stub.test.ts b/windows/tauri/src/test-utils/git-update-plan-stub.test.ts new file mode 100644 index 000000000..90d830798 --- /dev/null +++ b/windows/tauri/src/test-utils/git-update-plan-stub.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test"; +import fixture from "../../../../shared/fixtures/git/update-plan-v1.json"; +import { stubGitUpdatePlan } from "./git-update-plan-stub"; + +describe("git.updatePlan test stub", () => { + for (const testCase of fixture.cases) { + test(testCase.name, () => { + expect(stubGitUpdatePlan(testCase.request as Record)).toEqual( + testCase.plan as never, + ); + }); + } +}); diff --git a/windows/tauri/src/test-utils/git-update-plan-stub.ts b/windows/tauri/src/test-utils/git-update-plan-stub.ts new file mode 100644 index 000000000..aea065de0 --- /dev/null +++ b/windows/tauri/src/test-utils/git-update-plan-stub.ts @@ -0,0 +1,84 @@ +import type { GitUpdatePlan } from "@/features/git/api/git-update-plan-api"; + +type Strategy = "ffOnly" | "merge" | "rebase"; + +interface Preflight { + upstream: string | null; + ahead: number; + behind: number; + diverged: boolean; + hasLocalChanges: boolean; + configuredStrategy?: Strategy | null; +} + +/** + * Stand-in for Core's `git.updatePlan` in workflow tests. The rules belong to + * Core (`git/update_plan.rs`); `git-update-plan-stub.test.ts` replays the shared + * fixture `update-plan-v1.json` through this stub so it cannot drift from them. + */ +export function stubGitUpdatePlan(request: Record): GitUpdatePlan { + if (request.stage === "route") return route(request); + if (request.stage === "defaultStrategy") { + return { + step: "defaultStrategy", + strategy: defaultStrategy(request.preflight, request.fastForwardPossible), + }; + } + return inspect(request); +} + +function defaultStrategy(preflight: Preflight, fastForwardPossible: boolean): Strategy { + const configured = preflight.configuredStrategy; + if (configured === "merge" || configured === "rebase") return configured; + return fastForwardPossible ? "ffOnly" : "merge"; +} + +function route({ branch, worktrees }: Record): GitUpdatePlan { + if (!branch.upstream) return { step: "blocked", blocker: "noUpstream" }; + const owner = worktrees.find((worktree: any) => worktree.branch === branch.fullName); + if (owner) { + return { + step: "inspect", + checkout: owner.path, + expectedBranch: branch.fullName, + upstream: branch.upstream, + interactive: owner.isCurrent, + }; + } + if (branch.isCurrent) { + return { + step: "inspect", + checkout: null, + expectedBranch: branch.fullName, + upstream: branch.upstream, + interactive: true, + }; + } + return { step: "fastForwardReference", expectedBranch: branch.fullName }; +} + +function inspect(request: Record): GitUpdatePlan { + const preflight: Preflight = request.preflight; + const blocked = (blocker: any): GitUpdatePlan => ({ step: "blocked", blocker }); + const confirmation = request.confirmation; + if (!request.explicitSource && !preflight.upstream) return blocked("noUpstream"); + if (confirmation) { + const seen = confirmation.preflight; + if ( + seen.upstream !== preflight.upstream || + seen.ahead !== preflight.ahead || + seen.behind !== preflight.behind || + seen.diverged !== preflight.diverged + ) { + return blocked("stateChanged"); + } + } + if (preflight.hasLocalChanges) return blocked("localChanges"); + if (!request.explicitSource && preflight.behind === 0 && !preflight.diverged) { + return blocked("upToDate"); + } + if (confirmation) return { step: "pull", strategy: confirmation.strategy }; + const resolved: Strategy = request.strategy ?? preflight.configuredStrategy ?? "ffOnly"; + if (resolved !== "ffOnly" || !preflight.diverged) return { step: "pull", strategy: resolved }; + return request.interactive ? { step: "chooseStrategy" } : blocked("notFastForward"); +}