From 0dcffd33e1da679f16bbaea091c9b31fc96fb2e8 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 27 Aug 2026 02:36:33 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(macos):=20=E6=B7=BB=E5=8A=A0=20Git=20?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- macos/Resources/en.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../Lithe/Core/Rust/RustCoreBridge.swift | 1 + .../Lithe/Core/Rust/RustGitOperations.swift | 1 + .../AppModel/AppModel+FeatureState.swift | 2 + .../Sources/Lithe/Views/Git/GitLogView.swift | 342 ++++++++++++------ .../Application/GitFeatureModel.swift | 110 ++++-- .../Models/GitConsoleModels.swift | 71 ++++ .../LitheGitModule/Ports/GitPorts.swift | 9 +- .../LitheGitModule/Services/GitService.swift | 78 ++-- .../LitheGitModuleTests/GitModuleTests.swift | 51 ++- rust/lithe-core/src/git/mod.rs | 24 +- shared/contracts/rust-core-api.md | 11 +- 13 files changed, 533 insertions(+), 171 deletions(-) create mode 100644 macos/Sources/LitheGitModule/Models/GitConsoleModels.swift diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 2f5c29b2c..bacef1b58 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -107,3 +107,5 @@ "Notifications" = "Notifications"; "Clear All" = "Clear All"; "No notifications" = "No notifications"; +"Log: %@" = "Log: %@"; +"Console" = "Console"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index f1e989dc5..db44d392d 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -1211,3 +1211,5 @@ "Notifications" = "通知"; "Clear All" = "全部清除"; "No notifications" = "暂无通知"; +"Log: %@" = "日志:%@"; +"Console" = "控制台"; diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index dca7e4607..fab9d6735 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -787,6 +787,7 @@ struct RustCoreBridge: Sendable { let conflictedPaths: [String] } + let arguments: [String]? let output: String let exitCode: Int32 let stashRestore: StashRestore? diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index 194fb2027..3a82f55a7 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -11,6 +11,7 @@ struct RustGitOperations: GitOperations, Sendable { private func makeProcessResult(_ response: RustCoreBridge.GitCommandPayload) -> GitProcessResult { GitProcessResult( + arguments: response.arguments ?? [], output: response.output, exitCode: response.exitCode, stashRestoreConflict: response.stashRestore.map { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 97eb3ad59..b70b0b955 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -156,7 +156,9 @@ extension AppModel { var isPerformingStashOperation: Bool { gitFeatureIfActive?.isPerformingStashOperation ?? false } var isPerformingShelfOperation: Bool { gitFeatureIfActive?.isPerformingShelfOperation ?? false } var gitOperationState: GitOperationState? { gitFeatureIfActive?.gitOperationState } + var gitConsoleEntries: [GitConsoleEntry] { gitFeatureIfActive?.gitConsoleEntries ?? [] } var isResolvingGitOperation: Bool { gitFeatureIfActive?.isResolvingGitOperation ?? false } + func clearGitConsole() { gitFeatureIfActive?.clearGitConsole() } var gitRepositoryRoot: URL? { gitFeatureIfActive?.gitRepositoryRoot } var currentBranch: String { gitFeatureIfActive?.currentBranch ?? "No Git" } var selectedChange: GitChange? { diff --git a/macos/Sources/Lithe/Views/Git/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift index 3ca83ef35..34c4899f6 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import LitheGitModule @@ -21,6 +22,8 @@ struct GitLogView: View { @State private var pendingBranchOperation: GitBranchOperationRequest? @State private var comparisonSourceReference: GitReference? @State private var showCommitDecorations = true + @State private var selectedGitToolTab = GitToolTab.log + @State private var gitConsoleAutoScrolls = true @State private var graphLayout = GitGraphLayout( rows: [], laneCount: 0, @@ -44,90 +47,99 @@ struct GitLogView: View { static let toolbarHeight: CGFloat = 38 } + private enum GitToolTab { + case log + case console + } + var body: some View { VStack(spacing: 0) { toolWindowHeader - primaryActionBar - - GeometryReader { geometry in - let minimumReferencePaneWidth: CGFloat = 220 - let minimumCommitPaneWidth: CGFloat = 340 - let minimumDetailPaneWidth: CGFloat = 280 - let availablePaneWidth = max( - 0, - geometry.size.width - (SplitHandleView.thickness * 2) - ) - let maximumDetailPaneWidth = max( - minimumDetailPaneWidth, - min(520, availablePaneWidth - minimumReferencePaneWidth - minimumCommitPaneWidth) - ) - let resolvedDetailPaneWidth = constrained( - detailPaneWidth, - minimum: minimumDetailPaneWidth, - maximum: maximumDetailPaneWidth - ) - let maximumReferencePaneWidth = max( - minimumReferencePaneWidth, - min(480, availablePaneWidth - resolvedDetailPaneWidth - minimumCommitPaneWidth) - ) - let resolvedReferencePaneWidth = constrained( - referencePaneWidth, - minimum: minimumReferencePaneWidth, - maximum: maximumReferencePaneWidth - ) + if selectedGitToolTab == .log { + primaryActionBar - HStack(spacing: 0) { - referencePane - .frame(width: resolvedReferencePaneWidth) - - SplitHandleView( - axis: .horizontal, - onDragStarted: { - referencePaneDragStart = resolvedReferencePaneWidth - }, - onDragChanged: { translation in - referencePaneWidth = constrained( - referencePaneDragStart + translation, - minimum: minimumReferencePaneWidth, - maximum: maximumReferencePaneWidth - ) - }, - onDragEnded: { translation in - referencePaneWidth = constrained( - referencePaneDragStart + translation, - minimum: minimumReferencePaneWidth, - maximum: maximumReferencePaneWidth - ) - } + GeometryReader { geometry in + let minimumReferencePaneWidth: CGFloat = 220 + let minimumCommitPaneWidth: CGFloat = 340 + let minimumDetailPaneWidth: CGFloat = 280 + let availablePaneWidth = max( + 0, + geometry.size.width - (SplitHandleView.thickness * 2) ) - - commitPane - .frame(minWidth: minimumCommitPaneWidth, maxWidth: .infinity) - - SplitHandleView( - axis: .horizontal, - onDragStarted: { - detailPaneDragStart = resolvedDetailPaneWidth - }, - onDragChanged: { translation in - detailPaneWidth = constrained( - detailPaneDragStart - translation, - minimum: minimumDetailPaneWidth, - maximum: maximumDetailPaneWidth - ) - }, - onDragEnded: { translation in - detailPaneWidth = constrained( - detailPaneDragStart - translation, - minimum: minimumDetailPaneWidth, - maximum: maximumDetailPaneWidth - ) - } + let maximumDetailPaneWidth = max( + minimumDetailPaneWidth, + min(520, availablePaneWidth - minimumReferencePaneWidth - minimumCommitPaneWidth) + ) + let resolvedDetailPaneWidth = constrained( + detailPaneWidth, + minimum: minimumDetailPaneWidth, + maximum: maximumDetailPaneWidth ) + let maximumReferencePaneWidth = max( + minimumReferencePaneWidth, + min(480, availablePaneWidth - resolvedDetailPaneWidth - minimumCommitPaneWidth) + ) + let resolvedReferencePaneWidth = constrained( + referencePaneWidth, + minimum: minimumReferencePaneWidth, + maximum: maximumReferencePaneWidth + ) + + HStack(spacing: 0) { + referencePane + .frame(width: resolvedReferencePaneWidth) + + SplitHandleView( + axis: .horizontal, + onDragStarted: { + referencePaneDragStart = resolvedReferencePaneWidth + }, + onDragChanged: { translation in + referencePaneWidth = constrained( + referencePaneDragStart + translation, + minimum: minimumReferencePaneWidth, + maximum: maximumReferencePaneWidth + ) + }, + onDragEnded: { translation in + referencePaneWidth = constrained( + referencePaneDragStart + translation, + minimum: minimumReferencePaneWidth, + maximum: maximumReferencePaneWidth + ) + } + ) - detailPane - .frame(width: resolvedDetailPaneWidth) + commitPane + .frame(minWidth: minimumCommitPaneWidth, maxWidth: .infinity) + + SplitHandleView( + axis: .horizontal, + onDragStarted: { + detailPaneDragStart = resolvedDetailPaneWidth + }, + onDragChanged: { translation in + detailPaneWidth = constrained( + detailPaneDragStart - translation, + minimum: minimumDetailPaneWidth, + maximum: maximumDetailPaneWidth + ) + }, + onDragEnded: { translation in + detailPaneWidth = constrained( + detailPaneDragStart - translation, + minimum: minimumDetailPaneWidth, + maximum: maximumDetailPaneWidth + ) + } + ) + + detailPane + .frame(width: resolvedDetailPaneWidth) + } } + } else { + gitConsolePane } } .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.sidebar) @@ -147,6 +159,10 @@ struct GitLogView: View { } await model.applyGitLogFilter(model.gitLogSearchQuery) } + .onChange(of: model.gitConsoleEntries.last?.id) { _ in + guard model.gitConsoleEntries.last?.succeeded == false else { return } + selectedGitToolTab = .console + } .sheet(item: $branchDialogRequest) { request in GitBranchNameDialog(request: request) { name, checkout in Task { @@ -255,7 +271,7 @@ struct GitLogView: View { } private var toolWindowHeader: some View { - HStack(spacing: 8) { + HStack(spacing: 4) { LitheIDEAIcon( resourcePath: "toolwindows/toolWindowVcs.svg", size: 14, @@ -266,34 +282,16 @@ struct GitLogView: View { Text("Git") .font(GitVisual.title) .foregroundStyle(LitheTheme.primaryText) + .padding(.trailing, 4) - Button { - Task { await model.selectGitReference(nil) } - } label: { - HStack(spacing: 6) { - Text("Log: \(model.selectedGitReference?.shortName ?? model.currentBranch)") - .font(GitVisual.title) - .foregroundStyle(LitheTheme.primaryText) - .lineLimit(1) - Image(systemName: "chevron.down") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(LitheTheme.secondaryText) - } - .padding(.horizontal, 8) - .frame(height: 28) - .background(LitheTheme.inputBackground) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay { - RoundedRectangle(cornerRadius: 5) - .stroke(LitheTheme.inputFocusBorder.opacity(0.72), lineWidth: 1) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .lithePointer() - .help("Show all references") + gitToolTabButton( + .log, + title: "Log: \(model.selectedGitReference?.shortName ?? model.currentBranch)" + ) + gitToolTabButton(.console, title: "Console") Button { + selectedGitToolTab = .log Task { await model.selectGitReference(nil) } } label: { Image(systemName: "plus") @@ -338,6 +336,146 @@ struct GitLogView: View { } } + private func gitToolTabButton(_ tab: GitToolTab, title: LocalizedStringKey) -> some View { + let isSelected = selectedGitToolTab == tab + return Button { + selectedGitToolTab = tab + } label: { + Text(title) + .font(GitVisual.toolbar) + .foregroundStyle(isSelected ? LitheTheme.primaryText : LitheTheme.secondaryText) + .lineLimit(1) + .padding(.horizontal, 9) + .frame(height: 27) + .background(isSelected ? LitheTheme.subtleSelection : .clear) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay { + RoundedRectangle(cornerRadius: 5) + .stroke(isSelected ? LitheTheme.inputFocusBorder.opacity(0.72) : .clear, lineWidth: 1) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .lithePointer() + } + + private var gitConsolePane: some View { + HStack(spacing: 0) { + VStack(spacing: 2) { + Button { + gitConsoleAutoScrolls.toggle() + } label: { + Image(systemName: gitConsoleAutoScrolls ? "arrow.down.to.line.compact" : "arrow.down.to.line") + } + .litheIconButton() + .foregroundStyle(gitConsoleAutoScrolls ? LitheTheme.accent : LitheTheme.secondaryText) + .help(gitConsoleAutoScrolls ? "Disable automatic scrolling" : "Scroll to new Git output") + + Button(action: copyGitConsole) { + Image(systemName: "doc.on.doc") + } + .litheIconButton() + .foregroundStyle(LitheTheme.secondaryText) + .disabled(model.gitConsoleEntries.isEmpty) + .help("Copy Git console") + + Button(action: model.clearGitConsole) { + Image(systemName: "trash") + } + .litheIconButton() + .foregroundStyle(LitheTheme.secondaryText) + .disabled(model.gitConsoleEntries.isEmpty) + .help("Clear Git console") + + Spacer(minLength: 0) + } + .padding(.top, 6) + .frame(width: 34) + .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.toolHeader) + + Rectangle() + .fill(LitheTheme.divider) + .frame(width: 1) + + ScrollViewReader { proxy in + ScrollView([.horizontal, .vertical]) { + LazyVStack(alignment: .leading, spacing: 0) { + if model.gitConsoleEntries.isEmpty { + Text("Git command output will appear here.") + .font(GitVisual.monoMeta) + .foregroundStyle(LitheTheme.secondaryText) + .padding(.top, 8) + } else { + ForEach(model.gitConsoleEntries) { entry in + gitConsoleEntry(entry) + .id(entry.id) + } + } + + Color.clear + .frame(width: 1, height: 1) + .id("git-console-bottom") + } + .padding(.horizontal, 12) + .padding(.vertical, 5) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .litheScrollViewChrome() + .onAppear { + guard gitConsoleAutoScrolls else { return } + proxy.scrollTo("git-console-bottom", anchor: .bottom) + } + .onChange(of: model.gitConsoleEntries.last?.id) { _ in + guard gitConsoleAutoScrolls else { return } + proxy.scrollTo("git-console-bottom", anchor: .bottom) + } + } + } + .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.editor) + } + + private func gitConsoleEntry(_ entry: GitConsoleEntry) -> some View { + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 5) { + Text("\(gitConsoleTimestamp(entry.timestamp)):") + .foregroundStyle(LitheTheme.accent) + Text("[\(entry.workingDirectory.path)]") + .foregroundStyle(LitheTheme.accent) + Text(entry.commandLine) + .foregroundStyle(LitheTheme.primaryText) + } + .fixedSize(horizontal: true, vertical: false) + + if entry.output.isEmpty { + if !entry.succeeded { + Text("Git exited with code \(entry.exitCode)") + .foregroundStyle(LitheTheme.warning) + } + } else { + Text(entry.output.trimmingCharacters(in: .newlines)) + .foregroundStyle(entry.succeeded ? LitheTheme.primaryText : LitheTheme.warning) + .fixedSize(horizontal: true, vertical: false) + } + } + .font(.system(size: 12.5, weight: .regular, design: .monospaced)) + .textSelection(.enabled) + .padding(.vertical, 2) + } + + private func gitConsoleTimestamp(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "HH:mm:ss.SSS" + return formatter.string(from: date) + } + + private func copyGitConsole() { + let text = model.gitConsoleEntries.map(\.copyText).joined(separator: "\n") + guard !text.isEmpty else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + private var primaryActionBar: some View { HStack(spacing: 7) { Button { diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 14a60faa4..4dc8dd001 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -53,6 +53,7 @@ package final class GitFeatureModel: ObservableObject { @Published package private(set) var isLoadingGitHistory = false @Published package private(set) var isLoadingMoreGitHistory = false @Published package private(set) var canLoadMoreGitHistory = false + @Published package private(set) var gitConsoleEntries: [GitConsoleEntry] = [] @Published package private(set) var branchComparison: GitBranchComparison? @Published package var selectedBranchComparisonFile: GitBranchComparisonFile? @Published package private(set) var branchComparisonRows: [DiffRow] = [] @@ -190,6 +191,7 @@ package final class GitFeatureModel: ObservableObject { isLoadingGitHistory = false isLoadingMoreGitHistory = false canLoadMoreGitHistory = false + gitConsoleEntries = [] selectedGitReference = nil selectedGitCommit = nil selectedGitCommitFiles = [] @@ -513,10 +515,40 @@ package final class GitFeatureModel: ObservableObject { defer { lease?.release() } onGitOperationBegan?() let result = await operation() + if let commandResult = result as? GitService.CommandResult { + recordGitConsoleEntry(commandResult) + } await onGitOperationEnded?() return result } + private func recordingGitCommand( + _ operation: () async -> GitService.CommandResult + ) async -> GitService.CommandResult { + let result = await operation() + recordGitConsoleEntry(result) + return result + } + + package func clearGitConsole() { + gitConsoleEntries = [] + } + + private func recordGitConsoleEntry(_ result: GitService.CommandResult) { + guard let workingDirectory = result.workingDirectory ?? gitRepositoryRoot else { return } + gitConsoleEntries.append( + GitConsoleEntry( + workingDirectory: workingDirectory, + arguments: result.arguments, + output: result.output, + exitCode: result.exitCode + ) + ) + if gitConsoleEntries.count > 500 { + gitConsoleEntries.removeFirst(gitConsoleEntries.count - 500) + } + } + package func setGitConflictFilter(_ paths: [String]) { gitConflictFilterPaths = Set(paths) } @@ -858,9 +890,11 @@ package final class GitFeatureModel: ObservableObject { var failedResult: GitService.CommandResult? await withGitOperation { for change in pendingChanges { - let result = staged - ? await service.stage(change) - : await service.unstage(change) + let result = await recordingGitCommand { + staged + ? await service.stage(change) + : await service.unstage(change) + } guard result.succeeded else { failedChange = change failedResult = result @@ -1002,7 +1036,9 @@ package final class GitFeatureModel: ObservableObject { } for change in changes { - let discarded = await service.discardAll(change) + let discarded = await recordingGitCommand { + await service.discardAll(change) + } guard discarded.succeeded else { await refreshGit() return .failed( @@ -1018,11 +1054,13 @@ package final class GitFeatureModel: ObservableObject { private func restoreShelf(_ shelf: GitShelfEntry, at repositoryRoot: URL) async -> Bool { guard let shelveService else { return false } if !shelf.stagedPatch.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - let result = await service.applyPatch( - shelf.stagedPatch, - at: repositoryRoot, - mode: "restoreIndex" - ) + let result = await recordingGitCommand { + await service.applyPatch( + shelf.stagedPatch, + at: repositoryRoot, + mode: "restoreIndex" + ) + } if !result.succeeded { let alreadyApplied = await service.patchIsAlreadyApplied( shelf.stagedPatch, @@ -1036,11 +1074,13 @@ package final class GitFeatureModel: ObservableObject { } } if !shelf.workingPatch.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - let result = await service.applyPatch( - shelf.workingPatch, - at: repositoryRoot, - mode: "worktree" - ) + let result = await recordingGitCommand { + await service.applyPatch( + shelf.workingPatch, + at: repositoryRoot, + mode: "worktree" + ) + } if !result.succeeded { let alreadyApplied = await service.patchIsAlreadyApplied( shelf.workingPatch, @@ -1501,7 +1541,9 @@ package final class GitFeatureModel: ObservableObject { } isPerformingBranchOperation = true - let restored = await service.popStash(stash, at: gitRepositoryRoot) + let restored = await recordingGitCommand { + await service.popStash(stash, at: gitRepositoryRoot) + } isPerformingBranchOperation = false if let conflict = restored.stashRestoreConflict { presentStashRestoreConflict( @@ -1586,11 +1628,13 @@ package final class GitFeatureModel: ObservableObject { ) async { isPerformingBranchOperation = true let stashMessage = "Lithe auto-stash before \(request.operation.rawValue)" - let stashed = await service.stash( - message: stashMessage, - includeUntracked: true, - at: repositoryRoot - ) + let stashed = await recordingGitCommand { + await service.stash( + message: stashMessage, + includeUntracked: true, + at: repositoryRoot + ) + } guard stashed.succeeded else { isPerformingBranchOperation = false notify?(trimmedMessage(stashed)) @@ -1615,7 +1659,9 @@ package final class GitFeatureModel: ObservableObject { return } isPerformingBranchOperation = true - let restored = await service.popStash(entry, at: repositoryRoot) + let restored = await recordingGitCommand { + await service.popStash(entry, at: repositoryRoot) + } isPerformingBranchOperation = false if let conflict = restored.stashRestoreConflict { presentStashRestoreConflict( @@ -1677,19 +1723,27 @@ package final class GitFeatureModel: ObservableObject { let name = target.displayName switch operation { case .merge: - result = await service.mergeBranch(reference(from: target), at: gitRepositoryRoot) + result = await recordingGitCommand { + await service.mergeBranch(reference(from: target), at: gitRepositoryRoot) + } success = "Merged \(name)" case .rebase: - result = await service.rebaseCurrentBranch( - onto: reference(from: target), - at: gitRepositoryRoot - ) + result = await recordingGitCommand { + await service.rebaseCurrentBranch( + onto: reference(from: target), + at: gitRepositoryRoot + ) + } success = "Rebased onto \(name)" case .cherryPick: - result = await service.cherryPick(target.revision, at: gitRepositoryRoot) + result = await recordingGitCommand { + await service.cherryPick(target.revision, at: gitRepositoryRoot) + } success = "Cherry-picked \(name)" case .revert: - result = await service.revert(target.revision, at: gitRepositoryRoot) + result = await recordingGitCommand { + await service.revert(target.revision, at: gitRepositoryRoot) + } success = "Reverted \(name)" } return (result, success) diff --git a/macos/Sources/LitheGitModule/Models/GitConsoleModels.swift b/macos/Sources/LitheGitModule/Models/GitConsoleModels.swift new file mode 100644 index 000000000..19535a0bb --- /dev/null +++ b/macos/Sources/LitheGitModule/Models/GitConsoleModels.swift @@ -0,0 +1,71 @@ +import Foundation + +/// One user-initiated Git process invocation shown in the Git console. +package struct GitConsoleEntry: Identifiable, Equatable, Sendable { + package let id: UUID + package let timestamp: Date + package let workingDirectory: URL + package let arguments: [String] + package let output: String + package let exitCode: Int32 + + package init( + id: UUID = UUID(), + timestamp: Date = Date(), + workingDirectory: URL, + arguments: [String], + output: String, + exitCode: Int32 + ) { + self.id = id + self.timestamp = timestamp + self.workingDirectory = workingDirectory + self.arguments = arguments + self.output = output + self.exitCode = exitCode + } + + package var succeeded: Bool { exitCode == 0 } + + package var commandLine: String { + GitConsoleCommandFormatter.commandLine(arguments: arguments) + } + + package var copyText: String { + let header = "[\(workingDirectory.path)] \(commandLine)" + guard !output.isEmpty else { return header } + return "\(header)\n\(output)" + } +} + +/// Produces readable shell-like diagnostics without ever executing a shell. +package enum GitConsoleCommandFormatter { + package static func commandLine(arguments: [String]) -> String { + (["git"] + arguments.map(sanitizedArgument)).joined(separator: " ") + } + + private static func sanitizedArgument(_ rawValue: String) -> String { + let redacted = redactingURLCredentials(in: rawValue) + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\n", with: "\\n") + guard !redacted.isEmpty else { return "''" } + if redacted.unicodeScalars.allSatisfy({ safeShellScalars.contains($0) }) { + return redacted + } + return "'\(redacted.replacingOccurrences(of: "'", with: "'\\''"))'" + } + + private static func redactingURLCredentials(in value: String) -> String { + guard value.contains("://"), var components = URLComponents(string: value) else { + return value + } + guard components.user != nil || components.password != nil else { return value } + components.user = "" + components.password = nil + return components.string ?? value + } + + private static let safeShellScalars = CharacterSet( + charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_@%+=:,./-" + ) +} diff --git a/macos/Sources/LitheGitModule/Ports/GitPorts.swift b/macos/Sources/LitheGitModule/Ports/GitPorts.swift index f7596deb9..bcc8de7b3 100644 --- a/macos/Sources/LitheGitModule/Ports/GitPorts.swift +++ b/macos/Sources/LitheGitModule/Ports/GitPorts.swift @@ -1,10 +1,17 @@ import Foundation public struct GitProcessResult: Sendable { + public let arguments: [String] public let output: String public let exitCode: Int32 public let stashRestoreConflict: GitStashRestoreConflict? - public init(output: String, exitCode: Int32, stashRestoreConflict: GitStashRestoreConflict? = nil) { + public init( + arguments: [String] = [], + output: String, + exitCode: Int32, + stashRestoreConflict: GitStashRestoreConflict? = nil + ) { + self.arguments = arguments self.output = output self.exitCode = exitCode self.stashRestoreConflict = stashRestoreConflict diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index 640b51a43..e83416d0f 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -109,15 +109,21 @@ package struct GitService: Sendable { } package struct CommandResult: Sendable { + package let workingDirectory: URL? + package let arguments: [String] package let output: String package let exitCode: Int32 package let stashRestoreConflict: GitStashRestoreConflict? package init( + workingDirectory: URL? = nil, + arguments: [String] = [], output: String, exitCode: Int32, stashRestoreConflict: GitStashRestoreConflict? = nil ) { + self.workingDirectory = workingDirectory + self.arguments = arguments self.output = output self.exitCode = exitCode self.stashRestoreConflict = stashRestoreConflict @@ -203,7 +209,7 @@ package struct GitService: Sendable { /// Shelve uses `restoreIndex` to restore the index and worktree together, /// then `worktree` for the unstaged part. func applyPatch(_ patch: String, at repositoryRoot: URL, mode: String) async -> CommandResult { - await command { $0.applyPatch(patch, at: repositoryRoot, mode: mode) } + await command(at: repositoryRoot) { $0.applyPatch(patch, at: repositoryRoot, mode: mode) } } /// A failed restore can leave one half of a Shelf already applied. Check @@ -236,49 +242,49 @@ package struct GitService: Sendable { } func stage(_ change: GitChange) async -> CommandResult { - await command { $0.stage(change) } + await command(at: change.repositoryRoot) { $0.stage(change) } } func unstage(_ change: GitChange) async -> CommandResult { - await command { $0.unstage(change) } + await command(at: change.repositoryRoot) { $0.unstage(change) } } func discard(_ change: GitChange) async -> CommandResult { - return await command { $0.discard(change) } + return await command(at: change.repositoryRoot) { $0.discard(change) } } func discardAll(_ change: GitChange) async -> CommandResult { - await command { $0.discardAll(change) } + await command(at: change.repositoryRoot) { $0.discardAll(change) } } func stage(hunk: DiffHunk, of change: GitChange) async -> CommandResult { - await command { + await command(at: change.repositoryRoot, fallbackArguments: ["apply", "--cached", "-"]) { $0.applyPatch(hunk.patch, at: change.repositoryRoot, mode: "stage") } } func unstage(hunk: DiffHunk, of change: GitChange) async -> CommandResult { - await command { + await command(at: change.repositoryRoot, fallbackArguments: ["apply", "--cached", "--reverse", "-"]) { $0.applyPatch(hunk.patch, at: change.repositoryRoot, mode: "unstage") } } func discard(hunk: DiffHunk, of change: GitChange) async -> CommandResult { - await command { + await command(at: change.repositoryRoot, fallbackArguments: ["apply", "--reverse", "-"]) { $0.applyPatch(hunk.patch, at: change.repositoryRoot, mode: "discard") } } func commit(at repositoryRoot: URL, message: String, amend: Bool = false) async -> CommandResult { - await command { $0.commit(at: repositoryRoot, message: message, amend: amend) } + await command(at: repositoryRoot) { $0.commit(at: repositoryRoot, message: message, amend: amend) } } func cherryPick(_ hash: String, at repositoryRoot: URL) async -> CommandResult { - await command { $0.cherryPick(hash, at: repositoryRoot) } + await command(at: repositoryRoot) { $0.cherryPick(hash, at: repositoryRoot) } } func revert(_ hash: String, at repositoryRoot: URL) async -> CommandResult { - await command { $0.revert(hash, at: repositoryRoot) } + await command(at: repositoryRoot) { $0.revert(hash, at: repositoryRoot) } } func resetCurrentBranch( @@ -286,7 +292,7 @@ package struct GitService: Sendable { at repositoryRoot: URL, mode: String = "--mixed" ) async -> CommandResult { - await command { $0.resetCurrentBranch(to: hash, mode: mode, at: repositoryRoot) } + await command(at: repositoryRoot) { $0.resetCurrentBranch(to: hash, mode: mode, at: repositoryRoot) } } func history( @@ -446,7 +452,9 @@ package struct GitService: Sendable { checkout: Bool, at repositoryRoot: URL ) async -> CommandResult { - await command { $0.createBranch(named: name, from: reference, checkout: checkout, at: repositoryRoot) } + await command(at: repositoryRoot) { + $0.createBranch(named: name, from: reference, checkout: checkout, at: repositoryRoot) + } } func renameBranch( @@ -454,26 +462,26 @@ package struct GitService: Sendable { to newName: String, at repositoryRoot: URL ) async -> CommandResult { - await command { $0.renameBranch(reference, to: newName, at: repositoryRoot) } + await command(at: repositoryRoot) { $0.renameBranch(reference, to: newName, at: repositoryRoot) } } func deleteBranch(_ reference: GitReference, at repositoryRoot: URL) async -> CommandResult { - await command { $0.deleteBranch(reference, at: repositoryRoot) } + await command(at: repositoryRoot) { $0.deleteBranch(reference, at: repositoryRoot) } } func mergeBranch(_ reference: GitReference, at repositoryRoot: URL) async -> CommandResult { - await command { $0.mergeBranch(reference, at: repositoryRoot) } + await command(at: repositoryRoot) { $0.mergeBranch(reference, at: repositoryRoot) } } func rebaseCurrentBranch(onto reference: GitReference, at repositoryRoot: URL) async -> CommandResult { - await command { $0.rebaseCurrentBranch(onto: reference, at: repositoryRoot) } + await command(at: repositoryRoot) { $0.rebaseCurrentBranch(onto: reference, at: repositoryRoot) } } func updateCurrentBranch( at repositoryRoot: URL, strategy: GitPullStrategy = .ffOnly ) async -> CommandResult { - await command { $0.updateCurrentBranch(at: repositoryRoot, strategy: strategy) } + await command(at: repositoryRoot) { $0.updateCurrentBranch(at: repositoryRoot, strategy: strategy) } } func pullPreflight(at repositoryRoot: URL) async -> GitPullPreflightState? { @@ -495,7 +503,7 @@ package struct GitService: Sendable { } func fetch(at repositoryRoot: URL) async -> CommandResult { - await command { $0.fetch(at: repositoryRoot) } + await command(at: repositoryRoot) { $0.fetch(at: repositoryRoot) } } func checkout( @@ -504,7 +512,7 @@ package struct GitService: Sendable { force: Bool = false, autoStash: Bool = false ) async -> CommandResult { - await command { + await command(at: repositoryRoot) { $0.checkout(reference, at: repositoryRoot, force: force, autoStash: autoStash) } } @@ -521,27 +529,29 @@ package struct GitService: Sendable { } func continueOperation(at repositoryRoot: URL) async -> CommandResult { - await command { $0.continueOperation(at: repositoryRoot) } + await command(at: repositoryRoot) { $0.continueOperation(at: repositoryRoot) } } func abortOperation(at repositoryRoot: URL) async -> CommandResult { - await command { $0.abortOperation(at: repositoryRoot) } + await command(at: repositoryRoot) { $0.abortOperation(at: repositoryRoot) } } func skipOperationStep(at repositoryRoot: URL) async -> CommandResult { - await command { $0.skipOperationStep(at: repositoryRoot) } + await command(at: repositoryRoot) { $0.skipOperationStep(at: repositoryRoot) } } func checkoutRevision(_ revision: String, at repositoryRoot: URL) async -> CommandResult { - await command { $0.checkoutRevision(revision, at: repositoryRoot) } + await command(at: repositoryRoot) { $0.checkoutRevision(revision, at: repositoryRoot) } } func push(_ reference: GitReference, at repositoryRoot: URL) async -> CommandResult { - await command { $0.push(reference, at: repositoryRoot) } + await command(at: repositoryRoot) { $0.push(reference, at: repositoryRoot) } } func cloneRepository(from remote: String, to destination: URL) async -> CommandResult { - await command { $0.cloneRepository(from: remote, to: destination) } + await command(at: destination.deletingLastPathComponent()) { + $0.cloneRepository(from: remote, to: destination) + } } func stashes(at repositoryRoot: URL) async -> [GitStash] { @@ -553,34 +563,40 @@ package struct GitService: Sendable { includeUntracked: Bool, at repositoryRoot: URL ) async -> CommandResult { - await command { + await command(at: repositoryRoot) { $0.stash(message: message, includeUntracked: includeUntracked, at: repositoryRoot) } } func applyStash(_ stash: GitStash, at repositoryRoot: URL) async -> CommandResult { - await command { $0.applyStash(stash, at: repositoryRoot) } + await command(at: repositoryRoot) { $0.applyStash(stash, at: repositoryRoot) } } func popStash(_ stash: GitStash, at repositoryRoot: URL) async -> CommandResult { - await command { $0.popStash(stash, at: repositoryRoot) } + await command(at: repositoryRoot) { $0.popStash(stash, at: repositoryRoot) } } func dropStash(_ stash: GitStash, at repositoryRoot: URL) async -> CommandResult { - await command { $0.dropStash(stash, at: repositoryRoot) } + await command(at: repositoryRoot) { $0.dropStash(stash, at: repositoryRoot) } } func stageAll(at repositoryRoot: URL) async -> CommandResult { - await command { $0.stageAll(at: repositoryRoot) } + await command(at: repositoryRoot) { $0.stageAll(at: repositoryRoot) } } private func command( + at workingDirectory: URL? = nil, + fallbackArguments: [String] = [], _ operation: @escaping @Sendable (any GitOperations) -> GitProcessResult? ) async -> CommandResult { let operations = self.operations return await Task.detached(priority: .userInitiated) { let result = operation(operations) return CommandResult( + workingDirectory: workingDirectory, + arguments: result?.arguments.isEmpty == false + ? result?.arguments ?? fallbackArguments + : fallbackArguments, output: result?.output ?? "Rust Core Git operation failed", exitCode: result?.exitCode ?? 1, stashRestoreConflict: result?.stashRestoreConflict diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 04766a71d..b3a377fd9 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -126,6 +126,50 @@ struct GitModuleTests { )) } + @Test + func gitConsoleCommandFormatterQuotesArgumentsAndRedactsURLCredentials() { + let commandLine = GitConsoleCommandFormatter.commandLine(arguments: [ + "push", + "feature branch", + "John's change\nnext line", + "https://alice:secret@example.com/org/repository.git", + "" + ]) + + #expect(commandLine.hasPrefix("git push 'feature branch'")) + #expect(commandLine.contains(#"'John'\''s change\nnext line'"#)) + #expect(commandLine.contains("redacted")) + #expect(!commandLine.contains("alice")) + #expect(!commandLine.contains("secret")) + #expect(commandLine.hasSuffix(" ''")) + } + + @Test + func gitServicePreservesExecutedArgumentsAndWorkingDirectory() async { + let root = URL(fileURLWithPath: "/workspace") + let change = GitChange( + repositoryRoot: root, + path: "README.md", + originalPath: nil, + indexStatus: " ", + workTreeStatus: "M" + ) + let service = GitService(operations: TestGitOperations( + stageResult: GitProcessResult( + arguments: ["add", "--", "README.md"], + output: "staged", + exitCode: 0 + ) + )) + + let result = await service.stage(change) + + #expect(result.workingDirectory == root) + #expect(result.arguments == ["add", "--", "README.md"]) + #expect(result.output == "staged") + #expect(result.succeeded) + } + @Test func workingTreeComparisonMergesTrackedAndUntrackedFiles() async { let root = URL(fileURLWithPath: "/workspace") @@ -357,17 +401,20 @@ private struct TestGitOperations: GitOperations { private let comparisonValue: GitBranchComparison? private let untrackedDiffDocumentValue: DiffDocument? private let comparisonDiffDocumentValue: DiffDocument? + private let stageResult: GitProcessResult? init( snapshotValue: GitSnapshot? = nil, comparisonValue: GitBranchComparison? = nil, untrackedDiffDocumentValue: DiffDocument? = nil, - comparisonDiffDocumentValue: DiffDocument? = nil + comparisonDiffDocumentValue: DiffDocument? = nil, + stageResult: GitProcessResult? = nil ) { self.snapshotValue = snapshotValue self.comparisonValue = comparisonValue self.untrackedDiffDocumentValue = untrackedDiffDocumentValue self.comparisonDiffDocumentValue = comparisonDiffDocumentValue + self.stageResult = stageResult } func snapshot(at rootURL: URL) -> GitSnapshot? { snapshotValue } @@ -385,7 +432,7 @@ private struct TestGitOperations: GitOperations { func comparison(for reference: GitReference, at rootURL: URL) -> GitBranchComparison? { comparisonValue } func stashes(at rootURL: URL) -> [GitStash]? { nil } func blame(at rootURL: URL, relativePath: String) -> [GitBlameLine]? { nil } - func stage(_ change: GitChange) -> GitProcessResult? { nil } + func stage(_ change: GitChange) -> GitProcessResult? { stageResult } func unstage(_ change: GitChange) -> GitProcessResult? { nil } func discard(_ change: GitChange) -> GitProcessResult? { nil } func discardAll(_ change: GitChange) -> GitProcessResult? { nil } diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 7079cb9e2..aee2de30f 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -77,6 +77,8 @@ pub struct GitCommandRequest { #[serde(rename_all = "camelCase")] /// Stable output returned by argument-based Git execution. pub struct GitCommandResponse { + /// Exact arguments passed to the Git executable, excluding the executable name. + pub arguments: Vec, pub output: String, pub exit_code: i32, /// Present when a stash restore kept its entry because the working tree @@ -95,10 +97,11 @@ struct GitProcessOutput { } impl GitProcessOutput { - fn into_command_response(self) -> GitCommandResponse { + fn into_command_response(self, arguments: &[String]) -> GitCommandResponse { let mut output = String::from_utf8_lossy(&self.stdout).to_string(); output.push_str(&String::from_utf8_lossy(&self.stderr)); GitCommandResponse { + arguments: arguments.to_vec(), output, exit_code: self.exit_code, stash_restore: None, @@ -547,7 +550,7 @@ fn execute_git_with_options( disable_optional_locks: bool, ) -> Result { capture_git_with_options(root, arguments, input, disable_optional_locks) - .map(GitProcessOutput::into_command_response) + .map(|output| output.into_command_response(arguments)) } fn capture_git_with_options( @@ -1734,6 +1737,7 @@ fn is_current_reference(root: &str, reference: &str) -> Result fn failed_git_result(message: impl Into) -> GitCommandResponse { GitCommandResponse { + arguments: Vec::new(), output: message.into(), exit_code: 1, stash_restore: None, @@ -1788,6 +1792,7 @@ fn discard_all(root: &str, paths: &[String]) -> Result Vec { texts .iter() diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index c6ef85573..2fce6dd2a 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -216,8 +216,10 @@ standard error envelope. The core validates pathspecs, revisions, branch names, references, reset modes, stash references, and operation-specific required fields before invoking Git. -Successful process launch returns `{ "output": string, "exitCode": number }` -even when Git exits non-zero. Invalid arguments use the standard +Successful process launch returns `{ "arguments": string[], "output": string, +"exitCode": number }` even when Git exits non-zero. `arguments` is the exact +argument vector passed to the Git executable, excluding the executable name. +Invalid arguments use the standard `invalid_request` error envelope. `checkout` uses `referenceKind` values `local`, `remote`, or `tag`; `clone` uses `remote` as its source and `destination` as its target path. `publishBranch` validates `name`, creates @@ -229,7 +231,7 @@ the user can fix credentials or connectivity and retry without losing commits. to select the active merge, rebase, cherry-pick, or revert instead of accepting an operation kind from the caller. Continue is rejected while conflicted paths remain, and skip is supported only for a rebase. All three return the normal -`{ "output": string, "exitCode": number }` process result when Git is invoked; +Git process result when Git is invoked; an absent or unsupported operation state uses the `invalid_request` envelope. `git.checkoutPreflight` accepts `{ "root": string, "reference": string }` and @@ -277,8 +279,7 @@ clients group `rows` by `hunkID` instead. `unstage`, `discard`, `restoreIndex`, `worktree`, `restoreIndexCheck`, and `worktreeCheck`. The two `*Check` modes only test whether the reverse patch already applies, so Shelf restoration can be retried after a partial failure. -It returns -`{ "output": string, "exitCode": number }`. `restoreIndex` applies a saved +It returns the normal Git process result. `restoreIndex` applies a saved index patch to both the index and worktree; `worktree` applies only to the worktree. Pathspecs must be workspace-relative and must not contain absolute paths or `..` components. From 38f7ef571f8d6a87c25432220ba068374bcc1c98 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 27 Aug 2026 08:56:08 +0800 Subject: [PATCH 2/6] feat(git): add console output display --- .../Lithe/Core/Rust/RustCoreBridge.swift | 2 + .../Lithe/Core/Rust/RustGitOperations.swift | 8 +- .../AppModel/AppModel+FeatureState.swift | 1 + .../Sources/Lithe/Views/Git/GitLogView.swift | 229 +++++++++++------- .../Application/GitFeatureModel.swift | 33 +++ .../Models/GitConsoleModels.swift | 49 +++- .../LitheGitModule/Ports/GitPorts.swift | 6 + .../LitheGitModule/Services/GitService.swift | 24 ++ .../LitheGitModuleTests/GitModuleTests.swift | 76 ++++++ rust/lithe-core/src/git/mod.rs | 22 +- rust/lithe-core/src/tests/git.rs | 31 ++- shared/contracts/rust-core-api.md | 6 +- 12 files changed, 398 insertions(+), 89 deletions(-) diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index fab9d6735..19304b4d8 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -789,6 +789,8 @@ struct RustCoreBridge: Sendable { let arguments: [String]? let output: String + let stdout: String? + let stderr: String? let exitCode: Int32 let stashRestore: StashRestore? } diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index 3a82f55a7..067cf1bf0 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -13,6 +13,8 @@ struct RustGitOperations: GitOperations, Sendable { GitProcessResult( arguments: response.arguments ?? [], output: response.output, + standardOutput: response.stdout, + standardError: response.stderr, exitCode: response.exitCode, stashRestoreConflict: response.stashRestore.map { GitStashRestoreConflict( @@ -36,7 +38,11 @@ struct RustGitOperations: GitOperations, Sendable { case .success(let response): return makeProcessResult(response) case .failure(let error): - return GitProcessResult(output: error.userMessage, exitCode: 1) + return GitProcessResult( + output: error.userMessage, + standardError: error.userMessage, + exitCode: 1 + ) } } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index b70b0b955..2b0a9c53b 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -159,6 +159,7 @@ extension AppModel { var gitConsoleEntries: [GitConsoleEntry] { gitFeatureIfActive?.gitConsoleEntries ?? [] } var isResolvingGitOperation: Bool { gitFeatureIfActive?.isResolvingGitOperation ?? false } func clearGitConsole() { gitFeatureIfActive?.clearGitConsole() } + func loadGitConsoleIfNeeded() async { await gitFeatureIfActive?.loadGitConsoleIfNeeded() } var gitRepositoryRoot: URL? { gitFeatureIfActive?.gitRepositoryRoot } var currentBranch: String { gitFeatureIfActive?.currentBranch ?? "No Git" } var selectedChange: GitChange? { diff --git a/macos/Sources/Lithe/Views/Git/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift index 34c4899f6..31bdd994d 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -5,6 +5,7 @@ import LitheGitModule struct GitLogView: View { @EnvironmentObject private var model: AppModel @EnvironmentObject private var settings: AppSettings + @Environment(\.colorScheme) private var colorScheme @State private var localExpanded = true @State private var remoteExpanded = true @State private var tagsExpanded = true @@ -24,6 +25,7 @@ struct GitLogView: View { @State private var showCommitDecorations = true @State private var selectedGitToolTab = GitToolTab.log @State private var gitConsoleAutoScrolls = true + @State private var gitConsoleWrapsLines = false @State private var graphLayout = GitGraphLayout( rows: [], laneCount: 0, @@ -45,6 +47,8 @@ struct GitLogView: View { static let rowHeight: CGFloat = 38 static let treeRowHeight: CGFloat = 28 static let toolbarHeight: CGFloat = 38 + static let darkConsoleText = Color(red: 0.76, green: 0.77, blue: 0.79) + static let darkConsoleMetadata = Color(red: 0.69, green: 0.70, blue: 0.72) } private enum GitToolTab { @@ -338,30 +342,67 @@ struct GitLogView: View { private func gitToolTabButton(_ tab: GitToolTab, title: LocalizedStringKey) -> some View { let isSelected = selectedGitToolTab == tab - return Button { - selectedGitToolTab = tab - } label: { - Text(title) - .font(GitVisual.toolbar) - .foregroundStyle(isSelected ? LitheTheme.primaryText : LitheTheme.secondaryText) - .lineLimit(1) - .padding(.horizontal, 9) - .frame(height: 27) - .background(isSelected ? LitheTheme.subtleSelection : .clear) - .clipShape(RoundedRectangle(cornerRadius: 5)) - .overlay { - RoundedRectangle(cornerRadius: 5) - .stroke(isSelected ? LitheTheme.inputFocusBorder.opacity(0.72) : .clear, lineWidth: 1) + let showsCloseButton = isSelected && tab == .console + return HStack(spacing: 0) { + Button { + selectedGitToolTab = tab + if tab == .console { + Task { await model.loadGitConsoleIfNeeded() } } - .contentShape(Rectangle()) + } label: { + Text(title) + .font(GitVisual.toolbar) + .foregroundStyle(isSelected ? LitheTheme.primaryText : LitheTheme.secondaryText) + .lineLimit(1) + .padding(.leading, 9) + .padding(.trailing, showsCloseButton ? 4 : 9) + .frame(height: 27) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .lithePointer() + + if showsCloseButton { + Button { + selectedGitToolTab = .log + } label: { + Image(systemName: "xmark") + .font(.system(size: 8.5, weight: .semibold)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(width: 20, height: 27) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .lithePointer() + .help("Close Git console") + } + } + .background(isSelected ? LitheTheme.subtleSelection : .clear) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay { + RoundedRectangle(cornerRadius: 5) + .stroke(isSelected ? LitheTheme.inputFocusBorder.opacity(0.72) : .clear, lineWidth: 1) } - .buttonStyle(.plain) - .lithePointer() } private var gitConsolePane: some View { HStack(spacing: 0) { - VStack(spacing: 2) { + VStack(spacing: 3) { + Button { + gitConsoleWrapsLines.toggle() + } label: { + ZStack(alignment: .bottomTrailing) { + Image(systemName: "text.justify.leading") + .font(.system(size: 12, weight: .regular)) + Image(systemName: "arrow.turn.down.left") + .font(.system(size: 6.5, weight: .semibold)) + .offset(x: 2, y: 1) + } + } + .litheIconButton() + .foregroundStyle(gitConsoleWrapsLines ? LitheTheme.accent : LitheTheme.secondaryText) + .help(gitConsoleWrapsLines ? "Disable soft wraps" : "Use soft wraps") + Button { gitConsoleAutoScrolls.toggle() } label: { @@ -371,14 +412,6 @@ struct GitLogView: View { .foregroundStyle(gitConsoleAutoScrolls ? LitheTheme.accent : LitheTheme.secondaryText) .help(gitConsoleAutoScrolls ? "Disable automatic scrolling" : "Scroll to new Git output") - Button(action: copyGitConsole) { - Image(systemName: "doc.on.doc") - } - .litheIconButton() - .foregroundStyle(LitheTheme.secondaryText) - .disabled(model.gitConsoleEntries.isEmpty) - .help("Copy Git console") - Button(action: model.clearGitConsole) { Image(systemName: "trash") } @@ -390,44 +423,52 @@ struct GitLogView: View { Spacer(minLength: 0) } .padding(.top, 6) - .frame(width: 34) - .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.toolHeader) + .frame(width: 28) + .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.editor) Rectangle() .fill(LitheTheme.divider) .frame(width: 1) - ScrollViewReader { proxy in - ScrollView([.horizontal, .vertical]) { - LazyVStack(alignment: .leading, spacing: 0) { - if model.gitConsoleEntries.isEmpty { - Text("Git command output will appear here.") - .font(GitVisual.monoMeta) - .foregroundStyle(LitheTheme.secondaryText) - .padding(.top, 8) - } else { - ForEach(model.gitConsoleEntries) { entry in - gitConsoleEntry(entry) - .id(entry.id) + GeometryReader { geometry in + ScrollViewReader { proxy in + ScrollView(gitConsoleWrapsLines ? .vertical : [.horizontal, .vertical]) { + LazyVStack(alignment: .leading, spacing: 0) { + if model.gitConsoleEntries.isEmpty { + Text("Git command output will appear here.") + .font(GitVisual.monoMeta) + .foregroundStyle(LitheTheme.secondaryText) + .frame(height: 20, alignment: .leading) + } else { + ForEach(model.gitConsoleEntries) { entry in + gitConsoleEntry(entry) + .id(entry.id) + } } - } - Color.clear - .frame(width: 1, height: 1) - .id("git-console-bottom") + Color.clear + .frame(width: 1, height: 1) + .id("git-console-bottom") + } + .padding(.leading, 18) + .padding(.trailing, 8) + .padding(.top, 4) + .padding(.bottom, 8) + .frame( + minWidth: max(0, geometry.size.width), + minHeight: max(0, geometry.size.height), + alignment: .topLeading + ) + } + .litheScrollViewChrome() + .onAppear { + guard gitConsoleAutoScrolls else { return } + proxy.scrollTo("git-console-bottom", anchor: .bottom) + } + .onChange(of: model.gitConsoleEntries.last?.id) { _ in + guard gitConsoleAutoScrolls else { return } + proxy.scrollTo("git-console-bottom", anchor: .bottom) } - .padding(.horizontal, 12) - .padding(.vertical, 5) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - .litheScrollViewChrome() - .onAppear { - guard gitConsoleAutoScrolls else { return } - proxy.scrollTo("git-console-bottom", anchor: .bottom) - } - .onChange(of: model.gitConsoleEntries.last?.id) { _ in - guard gitConsoleAutoScrolls else { return } - proxy.scrollTo("git-console-bottom", anchor: .bottom) } } } @@ -435,31 +476,64 @@ struct GitLogView: View { } private func gitConsoleEntry(_ entry: GitConsoleEntry) -> some View { - VStack(alignment: .leading, spacing: 1) { - HStack(spacing: 5) { - Text("\(gitConsoleTimestamp(entry.timestamp)):") - .foregroundStyle(LitheTheme.accent) - Text("[\(entry.workingDirectory.path)]") - .foregroundStyle(LitheTheme.accent) - Text(entry.commandLine) - .foregroundStyle(LitheTheme.primaryText) - } - .fixedSize(horizontal: true, vertical: false) + VStack(alignment: .leading, spacing: 0) { + gitConsoleLine(gitConsoleCommandText(entry)) - if entry.output.isEmpty { + if entry.outputLines.isEmpty { if !entry.succeeded { - Text("Git exited with code \(entry.exitCode)") - .foregroundStyle(LitheTheme.warning) + gitConsoleLine( + Text("Git exited with code \(entry.exitCode)") + .foregroundColor(LitheTheme.error) + ) } } else { - Text(entry.output.trimmingCharacters(in: .newlines)) - .foregroundStyle(entry.succeeded ? LitheTheme.primaryText : LitheTheme.warning) - .fixedSize(horizontal: true, vertical: false) + ForEach(Array(entry.outputLines.enumerated()), id: \.offset) { _, line in + gitConsoleLine( + Text(line.text.isEmpty ? " " : line.text) + .foregroundColor( + line.stream == .standardError + ? LitheTheme.error + : gitConsoleTextColor + ) + ) + } } } - .font(.system(size: 12.5, weight: .regular, design: .monospaced)) + .font(.system(size: 13, weight: .regular, design: .monospaced)) .textSelection(.enabled) - .padding(.vertical, 2) + } + + private func gitConsoleLine(_ text: Text) -> some View { + text + .frame( + maxWidth: gitConsoleWrapsLines ? .infinity : nil, + minHeight: 20, + alignment: .leading + ) + .fixedSize(horizontal: !gitConsoleWrapsLines, vertical: true) + } + + private func gitConsoleCommandText(_ entry: GitConsoleEntry) -> Text { + let location = Text("\(gitConsoleTimestamp(entry.timestamp)): [\(entry.workingDirectory.path)]") + .foregroundColor(gitConsoleMetadataColor) + let executable = Text(" git") + .foregroundColor(gitConsoleTextColor) + guard !entry.formattedArguments.isEmpty else { return location + executable } + let arguments = Text(" \(entry.formattedArguments)") + .foregroundColor(gitConsoleArgumentColor) + return location + executable + arguments + } + + private var gitConsoleTextColor: Color { + colorScheme == .dark ? GitVisual.darkConsoleText : LitheTheme.primaryText + } + + private var gitConsoleMetadataColor: Color { + colorScheme == .dark ? GitVisual.darkConsoleMetadata : LitheTheme.link + } + + private var gitConsoleArgumentColor: Color { + colorScheme == .dark ? GitVisual.darkConsoleText : LitheTheme.link } private func gitConsoleTimestamp(_ date: Date) -> String { @@ -469,13 +543,6 @@ struct GitLogView: View { return formatter.string(from: date) } - private func copyGitConsole() { - let text = model.gitConsoleEntries.map(\.copyText).joined(separator: "\n") - guard !text.isEmpty else { return } - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - } - private var primaryActionBar: some View { HStack(spacing: 7) { Button { diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 4dc8dd001..fcc403e89 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -84,6 +84,9 @@ package final class GitFeatureModel: ObservableObject { private var activeRefreshRequestIDs: Set = [] private var completedRefreshRequestID: UInt64 = 0 private var refreshCompletionWaiters: [UUID: CheckedContinuation] = [:] + private var isLoadingInitialGitConsoleEntry = false + private var hasLoadedInitialGitConsoleEntry = false + private var gitConsoleRepositoryGeneration: UInt64 = 0 private var loadingLineChangeURLs: Set = [] private var lineChangeHunks: [URL: [String: DiffHunk]] = [:] @@ -192,6 +195,9 @@ package final class GitFeatureModel: ObservableObject { isLoadingMoreGitHistory = false canLoadMoreGitHistory = false gitConsoleEntries = [] + isLoadingInitialGitConsoleEntry = false + hasLoadedInitialGitConsoleEntry = false + gitConsoleRepositoryGeneration &+= 1 selectedGitReference = nil selectedGitCommit = nil selectedGitCommitFiles = [] @@ -284,6 +290,9 @@ package final class GitFeatureModel: ObservableObject { let changesChanged = gitChanges != snapshot.changes if gitRepositoryRoot != snapshot.repositoryRoot { gitRepositoryRoot = snapshot.repositoryRoot + gitConsoleRepositoryGeneration &+= 1 + isLoadingInitialGitConsoleEntry = false + hasLoadedInitialGitConsoleEntry = false didChange = true } if currentBranch != snapshot.branch { @@ -532,6 +541,28 @@ package final class GitFeatureModel: ObservableObject { package func clearGitConsole() { gitConsoleEntries = [] + hasLoadedInitialGitConsoleEntry = true + } + + package func loadGitConsoleIfNeeded() async { + guard !hasLoadedInitialGitConsoleEntry, + let gitRepositoryRoot, + !isLoadingInitialGitConsoleEntry else { return } + let requestedRoot = gitRepositoryRoot + let requestedGeneration = gitConsoleRepositoryGeneration + isLoadingInitialGitConsoleEntry = true + defer { + if requestedGeneration == gitConsoleRepositoryGeneration { + isLoadingInitialGitConsoleEntry = false + } + } + let result = await service.consoleVersion(at: requestedRoot) + guard requestedGeneration == gitConsoleRepositoryGeneration, + gitRepositoryRoot == requestedRoot, + !hasLoadedInitialGitConsoleEntry else { return } + hasLoadedInitialGitConsoleEntry = true + guard gitConsoleEntries.isEmpty else { return } + recordGitConsoleEntry(result) } private func recordGitConsoleEntry(_ result: GitService.CommandResult) { @@ -541,6 +572,8 @@ package final class GitFeatureModel: ObservableObject { workingDirectory: workingDirectory, arguments: result.arguments, output: result.output, + standardOutput: result.standardOutput, + standardError: result.standardError, exitCode: result.exitCode ) ) diff --git a/macos/Sources/LitheGitModule/Models/GitConsoleModels.swift b/macos/Sources/LitheGitModule/Models/GitConsoleModels.swift index 19535a0bb..aa26def3c 100644 --- a/macos/Sources/LitheGitModule/Models/GitConsoleModels.swift +++ b/macos/Sources/LitheGitModule/Models/GitConsoleModels.swift @@ -1,5 +1,15 @@ import Foundation +package enum GitConsoleOutputStream: Equatable, Sendable { + case standardOutput + case standardError +} + +package struct GitConsoleOutputLine: Equatable, Sendable { + package let stream: GitConsoleOutputStream + package let text: String +} + /// One user-initiated Git process invocation shown in the Git console. package struct GitConsoleEntry: Identifiable, Equatable, Sendable { package let id: UUID @@ -7,6 +17,8 @@ package struct GitConsoleEntry: Identifiable, Equatable, Sendable { package let workingDirectory: URL package let arguments: [String] package let output: String + package let standardOutput: String? + package let standardError: String? package let exitCode: Int32 package init( @@ -15,6 +27,8 @@ package struct GitConsoleEntry: Identifiable, Equatable, Sendable { workingDirectory: URL, arguments: [String], output: String, + standardOutput: String? = nil, + standardError: String? = nil, exitCode: Int32 ) { self.id = id @@ -22,6 +36,8 @@ package struct GitConsoleEntry: Identifiable, Equatable, Sendable { self.workingDirectory = workingDirectory self.arguments = arguments self.output = output + self.standardOutput = standardOutput + self.standardError = standardError self.exitCode = exitCode } @@ -31,6 +47,21 @@ package struct GitConsoleEntry: Identifiable, Equatable, Sendable { GitConsoleCommandFormatter.commandLine(arguments: arguments) } + package var formattedArguments: String { + GitConsoleCommandFormatter.argumentLine(arguments: arguments) + } + + package var outputLines: [GitConsoleOutputLine] { + if standardOutput != nil || standardError != nil { + return GitConsoleOutputLine.lines(from: standardOutput, stream: .standardOutput) + + GitConsoleOutputLine.lines(from: standardError, stream: .standardError) + } + return GitConsoleOutputLine.lines( + from: output, + stream: succeeded ? .standardOutput : .standardError + ) + } + package var copyText: String { let header = "[\(workingDirectory.path)] \(commandLine)" guard !output.isEmpty else { return header } @@ -38,10 +69,26 @@ package struct GitConsoleEntry: Identifiable, Equatable, Sendable { } } +private extension GitConsoleOutputLine { + static func lines(from output: String?, stream: GitConsoleOutputStream) -> [Self] { + guard let output else { return [] } + let trimmedOutput = output.trimmingCharacters(in: .newlines) + guard !trimmedOutput.isEmpty else { return [] } + return trimmedOutput + .split(separator: "\n", omittingEmptySubsequences: false) + .map { Self(stream: stream, text: String($0)) } + } +} + /// Produces readable shell-like diagnostics without ever executing a shell. package enum GitConsoleCommandFormatter { package static func commandLine(arguments: [String]) -> String { - (["git"] + arguments.map(sanitizedArgument)).joined(separator: " ") + let argumentLine = argumentLine(arguments: arguments) + return argumentLine.isEmpty ? "git" : "git \(argumentLine)" + } + + package static func argumentLine(arguments: [String]) -> String { + arguments.map(sanitizedArgument).joined(separator: " ") } private static func sanitizedArgument(_ rawValue: String) -> String { diff --git a/macos/Sources/LitheGitModule/Ports/GitPorts.swift b/macos/Sources/LitheGitModule/Ports/GitPorts.swift index bcc8de7b3..d4d936789 100644 --- a/macos/Sources/LitheGitModule/Ports/GitPorts.swift +++ b/macos/Sources/LitheGitModule/Ports/GitPorts.swift @@ -3,16 +3,22 @@ import Foundation public struct GitProcessResult: Sendable { public let arguments: [String] public let output: String + public let standardOutput: String? + public let standardError: String? public let exitCode: Int32 public let stashRestoreConflict: GitStashRestoreConflict? public init( arguments: [String] = [], output: String, + standardOutput: String? = nil, + standardError: String? = nil, exitCode: Int32, stashRestoreConflict: GitStashRestoreConflict? = nil ) { self.arguments = arguments self.output = output + self.standardOutput = standardOutput + self.standardError = standardError self.exitCode = exitCode self.stashRestoreConflict = stashRestoreConflict } diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index e83416d0f..520a37b6f 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -2,6 +2,12 @@ import Foundation import LitheCoreContracts package protocol GitOperations: Sendable { + func run( + arguments: [String], + workingDirectory: String, + input: String? + ) -> GitProcessResult + func snapshot(at rootURL: URL) -> GitSnapshot? func watchContext(at rootURL: URL) -> GitWatchContext? @@ -112,6 +118,8 @@ package struct GitService: Sendable { package let workingDirectory: URL? package let arguments: [String] package let output: String + package let standardOutput: String? + package let standardError: String? package let exitCode: Int32 package let stashRestoreConflict: GitStashRestoreConflict? @@ -119,12 +127,16 @@ package struct GitService: Sendable { workingDirectory: URL? = nil, arguments: [String] = [], output: String, + standardOutput: String? = nil, + standardError: String? = nil, exitCode: Int32, stashRestoreConflict: GitStashRestoreConflict? = nil ) { self.workingDirectory = workingDirectory self.arguments = arguments self.output = output + self.standardOutput = standardOutput + self.standardError = standardError self.exitCode = exitCode self.stashRestoreConflict = stashRestoreConflict } @@ -136,6 +148,16 @@ package struct GitService: Sendable { await read(priority: .utility) { $0.snapshot(at: workspace) } } + func consoleVersion(at repositoryRoot: URL) async -> CommandResult { + await command(at: repositoryRoot, fallbackArguments: ["version"]) { + $0.run( + arguments: ["version"], + workingDirectory: repositoryRoot.path, + input: nil + ) + } + } + func diff(for change: GitChange) async -> [DiffRow] { (await diffDocument(for: change)).rows } @@ -598,6 +620,8 @@ package struct GitService: Sendable { ? result?.arguments ?? fallbackArguments : fallbackArguments, output: result?.output ?? "Rust Core Git operation failed", + standardOutput: result?.standardOutput, + standardError: result?.standardError, exitCode: result?.exitCode ?? 1, stashRestoreConflict: result?.stashRestoreConflict ) diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index b3a377fd9..534874247 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -170,6 +170,72 @@ struct GitModuleTests { #expect(result.succeeded) } + @Test + func gitConsoleLoadsGitVersionOnlyOnce() async { + let root = URL(fileURLWithPath: "/workspace") + let service = GitService(operations: TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: root, branch: "main", changes: []) + )) + let feature = GitFeatureModel(service: service) + feature.configure( + workspaceURLProvider: { root }, + isGitLogVisibleProvider: { false }, + notify: { _ in }, + onStateRefreshed: {} + ) + + await feature.refreshGit() + await feature.loadGitConsoleIfNeeded() + await feature.loadGitConsoleIfNeeded() + + #expect(feature.gitConsoleEntries.count == 1) + #expect(feature.gitConsoleEntries.first?.workingDirectory == root) + #expect(feature.gitConsoleEntries.first?.arguments == ["version"]) + #expect(feature.gitConsoleEntries.first?.output == "git version 2.55.0\n") + } + + @Test + func clearingGitConsoleDoesNotTriggerInitialLoadAgain() async { + let root = URL(fileURLWithPath: "/workspace") + let service = GitService(operations: TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: root, branch: "main", changes: []) + )) + let feature = GitFeatureModel(service: service) + feature.configure( + workspaceURLProvider: { root }, + isGitLogVisibleProvider: { false }, + notify: { _ in }, + onStateRefreshed: {} + ) + + await feature.refreshGit() + await feature.loadGitConsoleIfNeeded() + feature.clearGitConsole() + await feature.loadGitConsoleIfNeeded() + + #expect(feature.gitConsoleEntries.isEmpty) + } + + @Test + func gitConsolePreservesStandardErrorColorForSuccessfulCommands() { + let entry = GitConsoleEntry( + workingDirectory: URL(fileURLWithPath: "/workspace"), + arguments: ["checkout", "-b", "feature"], + output: "Switched to a new branch 'feature'\n", + standardOutput: "", + standardError: "Switched to a new branch 'feature'\n", + exitCode: 0 + ) + + #expect(entry.succeeded) + #expect(entry.outputLines == [ + GitConsoleOutputLine( + stream: .standardError, + text: "Switched to a new branch 'feature'" + ) + ]) + } + @Test func workingTreeComparisonMergesTrackedAndUntrackedFiles() async { let root = URL(fileURLWithPath: "/workspace") @@ -417,6 +483,16 @@ private struct TestGitOperations: GitOperations { self.stageResult = stageResult } + func run(arguments: [String], workingDirectory: String, input: String?) -> GitProcessResult { + GitProcessResult( + arguments: arguments, + output: "git version 2.55.0\n", + standardOutput: "git version 2.55.0\n", + standardError: "", + exitCode: 0 + ) + } + func snapshot(at rootURL: URL) -> GitSnapshot? { snapshotValue } func watchContext(at rootURL: URL) -> GitWatchContext? { nil } func diffDocument(at rootURL: URL, pathspecs: [String], staged: Bool, untracked: Bool, whitespace: GitDiffWhitespaceMode) -> DiffDocument? { diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index aee2de30f..0c01a1de7 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -79,7 +79,13 @@ pub struct GitCommandRequest { pub struct GitCommandResponse { /// Exact arguments passed to the Git executable, excluding the executable name. pub arguments: Vec, + /// Backward-compatible concatenation of standard output followed by standard error. pub output: String, + /// Text captured from the Git process standard output stream. + pub stdout: String, + /// Text captured from the Git process standard error stream. + pub stderr: String, + /// Exit status returned by the Git process. pub exit_code: i32, /// Present when a stash restore kept its entry because the working tree /// contains an unresolved merge. Keeping this out of the prose response @@ -98,11 +104,14 @@ struct GitProcessOutput { impl GitProcessOutput { fn into_command_response(self, arguments: &[String]) -> GitCommandResponse { - let mut output = String::from_utf8_lossy(&self.stdout).to_string(); - output.push_str(&String::from_utf8_lossy(&self.stderr)); + let stdout = String::from_utf8_lossy(&self.stdout).to_string(); + let stderr = String::from_utf8_lossy(&self.stderr).to_string(); + let output = format!("{stdout}{stderr}"); GitCommandResponse { arguments: arguments.to_vec(), output, + stdout, + stderr, exit_code: self.exit_code, stash_restore: None, } @@ -1736,9 +1745,12 @@ fn is_current_reference(root: &str, reference: &str) -> Result } fn failed_git_result(message: impl Into) -> GitCommandResponse { + let stderr = message.into(); GitCommandResponse { arguments: Vec::new(), - output: message.into(), + output: stderr.clone(), + stdout: String::new(), + stderr, exit_code: 1, stash_restore: None, } @@ -1794,6 +1806,8 @@ fn discard_all(root: &str, paths: &[String]) -> Result Date: Thu, 27 Aug 2026 09:14:38 +0800 Subject: [PATCH 3/6] test(git): cover stale console initialization --- .../LitheGitModuleTests/GitModuleTests.swift | 80 ++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 534874247..00e76e53f 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -216,6 +216,42 @@ struct GitModuleTests { #expect(feature.gitConsoleEntries.isEmpty) } + @Test + func switchingRepositoriesDiscardsStaleInitialGitConsoleOutput() async { + let firstRoot = URL(fileURLWithPath: "/first-workspace") + let secondRoot = URL(fileURLWithPath: "/second-workspace") + let runGate = TestGitRunGate() + let service = GitService(operations: TestGitOperations(runGate: runGate)) + let feature = GitFeatureModel( + service: service, + snapshotProvider: { root in + GitSnapshot(repositoryRoot: root, branch: "main", changes: []) + } + ) + var workspaceURL = firstRoot + feature.configure( + workspaceURLProvider: { workspaceURL }, + isGitLogVisibleProvider: { false }, + notify: { _ in }, + onStateRefreshed: {} + ) + + await feature.refreshGit() + let initialLoad = Task { await feature.loadGitConsoleIfNeeded() } + await runGate.waitUntilFirstRunStarts() + workspaceURL = secondRoot + await feature.refreshGit() + runGate.releaseFirstRun() + await initialLoad.value + + #expect(feature.gitConsoleEntries.isEmpty) + + await feature.loadGitConsoleIfNeeded() + + #expect(feature.gitConsoleEntries.count == 1) + #expect(feature.gitConsoleEntries.first?.workingDirectory == secondRoot) + } + @Test func gitConsolePreservesStandardErrorColorForSuccessfulCommands() { let entry = GitConsoleEntry( @@ -468,23 +504,27 @@ private struct TestGitOperations: GitOperations { private let untrackedDiffDocumentValue: DiffDocument? private let comparisonDiffDocumentValue: DiffDocument? private let stageResult: GitProcessResult? + private let runGate: TestGitRunGate? init( snapshotValue: GitSnapshot? = nil, comparisonValue: GitBranchComparison? = nil, untrackedDiffDocumentValue: DiffDocument? = nil, comparisonDiffDocumentValue: DiffDocument? = nil, - stageResult: GitProcessResult? = nil + stageResult: GitProcessResult? = nil, + runGate: TestGitRunGate? = nil ) { self.snapshotValue = snapshotValue self.comparisonValue = comparisonValue self.untrackedDiffDocumentValue = untrackedDiffDocumentValue self.comparisonDiffDocumentValue = comparisonDiffDocumentValue self.stageResult = stageResult + self.runGate = runGate } func run(arguments: [String], workingDirectory: String, input: String?) -> GitProcessResult { - GitProcessResult( + runGate?.blockFirstRun() + return GitProcessResult( arguments: arguments, output: "git version 2.55.0\n", standardOutput: "git version 2.55.0\n", @@ -541,3 +581,39 @@ private struct TestGitOperations: GitOperations { func dropStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? { nil } func stageAll(at rootURL: URL) -> GitProcessResult? { nil } } + +private final class TestGitRunGate: @unchecked Sendable { + private let lock = NSLock() + private let firstRunRelease = DispatchSemaphore(value: 0) + private var hasBlockedFirstRun = false + private var firstRunWaiter: CheckedContinuation? + + func blockFirstRun() { + lock.lock() + let shouldBlock = !hasBlockedFirstRun + hasBlockedFirstRun = true + let waiter = shouldBlock ? firstRunWaiter : nil + firstRunWaiter = nil + lock.unlock() + guard shouldBlock else { return } + waiter?.resume() + firstRunRelease.wait() + } + + func waitUntilFirstRunStarts() async { + await withCheckedContinuation { continuation in + lock.lock() + if hasBlockedFirstRun { + lock.unlock() + continuation.resume() + } else { + firstRunWaiter = continuation + lock.unlock() + } + } + } + + func releaseFirstRun() { + firstRunRelease.signal() + } +} From a8200bec26c9010ff97f936b90882615be110571 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 27 Aug 2026 21:11:26 +0800 Subject: [PATCH 4/6] fix(git): preserve command invocations securely --- .../Lithe/Core/Rust/RustCoreBridge.swift | 8 + .../Lithe/Core/Rust/RustGitOperations.swift | 8 + .../Application/GitFeatureModel.swift | 31 ++-- .../Models/GitConsoleModels.swift | 72 +++++++-- .../LitheGitModule/Ports/GitPorts.swift | 24 +++ .../LitheGitModule/Services/GitService.swift | 4 + macos/Tests/LitheCoreVerifier/main.swift | 41 +++++ .../LitheGitModuleTests/GitModuleTests.swift | 79 +++++++++ rust/lithe-core/src/git/mod.rs | 151 +++++++++++++++--- rust/lithe-core/src/tests/git.rs | 21 +++ shared/contracts/rust-core-api.md | 24 ++- shared/fixtures/git/command-response-v1.json | 21 +++ 12 files changed, 432 insertions(+), 52 deletions(-) create mode 100644 shared/fixtures/git/command-response-v1.json diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 19304b4d8..c3be06803 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -782,6 +782,13 @@ struct RustCoreBridge: Sendable { } struct GitCommandPayload: Decodable, Sendable { + struct Invocation: Decodable, Sendable { + let arguments: [String] + let stdout: String + let stderr: String + let exitCode: Int32 + } + struct StashRestore: Decodable, Sendable { let stashReference: String let conflictedPaths: [String] @@ -792,6 +799,7 @@ struct RustCoreBridge: Sendable { let stdout: String? let stderr: String? let exitCode: Int32 + let invocations: [Invocation]? let stashRestore: StashRestore? } diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index 067cf1bf0..3f55f8a23 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -16,6 +16,14 @@ struct RustGitOperations: GitOperations, Sendable { standardOutput: response.stdout, standardError: response.stderr, exitCode: response.exitCode, + invocations: response.invocations?.map { + GitProcessInvocation( + arguments: $0.arguments, + standardOutput: $0.stdout, + standardError: $0.stderr, + exitCode: $0.exitCode + ) + } ?? [], stashRestoreConflict: response.stashRestore.map { GitStashRestoreConflict( stashReference: $0.stashReference, diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index fcc403e89..9cba90e75 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -567,16 +567,29 @@ package final class GitFeatureModel: ObservableObject { private func recordGitConsoleEntry(_ result: GitService.CommandResult) { guard let workingDirectory = result.workingDirectory ?? gitRepositoryRoot else { return } - gitConsoleEntries.append( - GitConsoleEntry( - workingDirectory: workingDirectory, - arguments: result.arguments, - output: result.output, - standardOutput: result.standardOutput, - standardError: result.standardError, - exitCode: result.exitCode + if result.invocations.isEmpty { + gitConsoleEntries.append( + GitConsoleEntry( + workingDirectory: workingDirectory, + arguments: result.arguments, + output: result.output, + standardOutput: result.standardOutput, + standardError: result.standardError, + exitCode: result.exitCode + ) ) - ) + } else { + gitConsoleEntries.append(contentsOf: result.invocations.map { invocation in + GitConsoleEntry( + workingDirectory: workingDirectory, + arguments: invocation.arguments, + output: invocation.output, + standardOutput: invocation.standardOutput, + standardError: invocation.standardError, + exitCode: invocation.exitCode + ) + }) + } if gitConsoleEntries.count > 500 { gitConsoleEntries.removeFirst(gitConsoleEntries.count - 500) } diff --git a/macos/Sources/LitheGitModule/Models/GitConsoleModels.swift b/macos/Sources/LitheGitModule/Models/GitConsoleModels.swift index aa26def3c..190b5a666 100644 --- a/macos/Sources/LitheGitModule/Models/GitConsoleModels.swift +++ b/macos/Sources/LitheGitModule/Models/GitConsoleModels.swift @@ -34,10 +34,10 @@ package struct GitConsoleEntry: Identifiable, Equatable, Sendable { self.id = id self.timestamp = timestamp self.workingDirectory = workingDirectory - self.arguments = arguments - self.output = output - self.standardOutput = standardOutput - self.standardError = standardError + self.arguments = arguments.map(GitConsoleRedactor.redact) + self.output = GitConsoleRedactor.redact(output) + self.standardOutput = standardOutput.map(GitConsoleRedactor.redact) + self.standardError = standardError.map(GitConsoleRedactor.redact) self.exitCode = exitCode } @@ -80,6 +80,58 @@ private extension GitConsoleOutputLine { } } + +/// Removes credentials from every console value before it can be displayed or copied. +package enum GitConsoleRedactor { + package static func redact(_ value: String) -> String { + guard let urlPattern else { return value } + let source = value as NSString + var redacted = value + let range = NSRange(location: 0, length: source.length) + for match in urlPattern.matches(in: value, range: range).reversed() { + let rawURL = source.substring(with: match.range) + let sanitizedURL = sanitizeURL(rawURL) + redacted = (redacted as NSString).replacingCharacters( + in: match.range, + with: sanitizedURL + ) + } + return redacted + } + + private static func sanitizeURL(_ rawValue: String) -> String { + guard var components = URLComponents(string: rawValue) else { return rawValue } + if components.user != nil || components.password != nil { + components.user = "redacted" + components.password = nil + } + if let queryItems = components.queryItems { + components.queryItems = queryItems.map { item in + guard sensitiveQueryNames.contains(item.name.lowercased()) else { return item } + return URLQueryItem(name: item.name, value: "redacted") + } + } + return components.string ?? rawValue + } + + private static let sensitiveQueryNames: Set = [ + "access_token", + "api_key", + "apikey", + "auth", + "authorization", + "client_secret", + "password", + "passwd", + "secret", + "token" + ] + + private static let urlPattern = try? NSRegularExpression( + pattern: #"(?i)\b(?:https?|ssh)://[^\s<>"']+"# + ) +} + /// Produces readable shell-like diagnostics without ever executing a shell. package enum GitConsoleCommandFormatter { package static func commandLine(arguments: [String]) -> String { @@ -92,7 +144,7 @@ package enum GitConsoleCommandFormatter { } private static func sanitizedArgument(_ rawValue: String) -> String { - let redacted = redactingURLCredentials(in: rawValue) + let redacted = GitConsoleRedactor.redact(rawValue) .replacingOccurrences(of: "\r", with: "\\r") .replacingOccurrences(of: "\n", with: "\\n") guard !redacted.isEmpty else { return "''" } @@ -102,16 +154,6 @@ package enum GitConsoleCommandFormatter { return "'\(redacted.replacingOccurrences(of: "'", with: "'\\''"))'" } - private static func redactingURLCredentials(in value: String) -> String { - guard value.contains("://"), var components = URLComponents(string: value) else { - return value - } - guard components.user != nil || components.password != nil else { return value } - components.user = "" - components.password = nil - return components.string ?? value - } - private static let safeShellScalars = CharacterSet( charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_@%+=:,./-" ) diff --git a/macos/Sources/LitheGitModule/Ports/GitPorts.swift b/macos/Sources/LitheGitModule/Ports/GitPorts.swift index d4d936789..cf6086bfc 100644 --- a/macos/Sources/LitheGitModule/Ports/GitPorts.swift +++ b/macos/Sources/LitheGitModule/Ports/GitPorts.swift @@ -1,11 +1,33 @@ import Foundation +public struct GitProcessInvocation: Equatable, Sendable { + public let arguments: [String] + public let standardOutput: String + public let standardError: String + public let exitCode: Int32 + + public init( + arguments: [String], + standardOutput: String, + standardError: String, + exitCode: Int32 + ) { + self.arguments = arguments + self.standardOutput = standardOutput + self.standardError = standardError + self.exitCode = exitCode + } + + public var output: String { standardOutput + standardError } +} + public struct GitProcessResult: Sendable { public let arguments: [String] public let output: String public let standardOutput: String? public let standardError: String? public let exitCode: Int32 + public let invocations: [GitProcessInvocation] public let stashRestoreConflict: GitStashRestoreConflict? public init( arguments: [String] = [], @@ -13,6 +35,7 @@ public struct GitProcessResult: Sendable { standardOutput: String? = nil, standardError: String? = nil, exitCode: Int32, + invocations: [GitProcessInvocation] = [], stashRestoreConflict: GitStashRestoreConflict? = nil ) { self.arguments = arguments @@ -20,6 +43,7 @@ public struct GitProcessResult: Sendable { self.standardOutput = standardOutput self.standardError = standardError self.exitCode = exitCode + self.invocations = invocations self.stashRestoreConflict = stashRestoreConflict } } diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index 520a37b6f..c92225486 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -121,6 +121,7 @@ package struct GitService: Sendable { package let standardOutput: String? package let standardError: String? package let exitCode: Int32 + package let invocations: [GitProcessInvocation] package let stashRestoreConflict: GitStashRestoreConflict? package init( @@ -130,6 +131,7 @@ package struct GitService: Sendable { standardOutput: String? = nil, standardError: String? = nil, exitCode: Int32, + invocations: [GitProcessInvocation] = [], stashRestoreConflict: GitStashRestoreConflict? = nil ) { self.workingDirectory = workingDirectory @@ -138,6 +140,7 @@ package struct GitService: Sendable { self.standardOutput = standardOutput self.standardError = standardError self.exitCode = exitCode + self.invocations = invocations self.stashRestoreConflict = stashRestoreConflict } @@ -623,6 +626,7 @@ package struct GitService: Sendable { standardOutput: result?.standardOutput, standardError: result?.standardError, exitCode: result?.exitCode ?? 1, + invocations: result?.invocations ?? [], stashRestoreConflict: result?.stashRestoreConflict ) }.value diff --git a/macos/Tests/LitheCoreVerifier/main.swift b/macos/Tests/LitheCoreVerifier/main.swift index ecdc16f25..52be4b0ad 100644 --- a/macos/Tests/LitheCoreVerifier/main.swift +++ b/macos/Tests/LitheCoreVerifier/main.swift @@ -45,6 +45,23 @@ struct CoreVerification { let cases: [Case] } + + private struct GitCommandFixture: Decodable { + struct Invocation: Decodable { + let arguments: [String] + let stdout: String + let stderr: String + let exitCode: Int32 + } + + let arguments: [String] + let output: String + let stdout: String + let stderr: String + let exitCode: Int32 + let invocations: [Invocation] + } + private struct GitFixture: Decodable { struct Commit: Decodable { let hash: String @@ -140,6 +157,30 @@ struct CoreVerification { require(mergeRow.parentEdges.count == gitFixture.expected.mergeParentCount, "Git fixture merge edge count changed") require(layout.hasMissingParents == gitFixture.expected.hasMissingParents, "Git fixture missing-parent state changed") require(mergeRow.labels.contains { $0.title == gitFixture.expected.headLabel }, "Git fixture HEAD label changed") + + let commandURL = URL(fileURLWithPath: "shared/fixtures/git/command-response-v1.json") + guard let commandData = try? Data(contentsOf: commandURL), + let commandFixture = try? JSONDecoder().decode(GitCommandFixture.self, from: commandData) else { + require(false, "Git command response fixture could not be decoded") + return + } + let invocations = commandFixture.invocations.map { invocation in + GitProcessInvocation( + arguments: invocation.arguments, + standardOutput: invocation.stdout, + standardError: invocation.stderr, + exitCode: invocation.exitCode + ) + } + require(invocations.count == 2, "Git command fixture invocation count changed") + require(invocations.first?.arguments.first == "status", "Git command fixture lost its first invocation") + require(invocations.last?.arguments.first == "checkout", "Git command fixture lost its final invocation") + require( + commandFixture.output == commandFixture.stdout + commandFixture.stderr, + "Git command fixture compatibility output changed" + ) + require(commandFixture.arguments == invocations.last?.arguments, "Git command fixture final arguments changed") + require(commandFixture.exitCode == invocations.last?.exitCode, "Git command fixture final exit code changed") } private static func verifyDiffParser() { diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 00e76e53f..5b8074288 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -144,6 +144,85 @@ struct GitModuleTests { #expect(commandLine.hasSuffix(" ''")) } + @Test + func gitConsoleRedactsCredentialsFromArgumentsAndProcessStreams() { + let secret = "FAKE_SUPER_SECRET_TOKEN" + let credentialURL = "https://alice:password@example.com/repository.git?access_token=\(secret)&mode=test" + let tokenURL = "https://example.com/repository.git?token=\(secret)" + let entry = GitConsoleEntry( + workingDirectory: URL(fileURLWithPath: "/workspace"), + arguments: ["fetch", credentialURL], + output: "warning: request failed for \(tokenURL)\nfatal: unable to access '\(credentialURL)'\n", + standardOutput: "warning: request failed for \(tokenURL)\n", + standardError: "fatal: unable to access '\(credentialURL)'\n", + exitCode: 1 + ) + + let visibleText = [ + entry.arguments.joined(separator: " "), + entry.output, + entry.standardOutput ?? "", + entry.standardError ?? "", + entry.commandLine, + entry.copyText, + entry.outputLines.map(\.text).joined(separator: "\n") + ].joined(separator: "\n") + #expect(visibleText.contains("redacted")) + #expect(!visibleText.contains(secret)) + #expect(!visibleText.contains("alice")) + #expect(!visibleText.contains("password")) + } + + @Test + func gitConsoleRecordsEveryInvocationFromCompositeOperations() async { + let root = URL(fileURLWithPath: "/workspace") + let change = GitChange( + repositoryRoot: root, + path: "README.md", + originalPath: nil, + indexStatus: " ", + workTreeStatus: "M" + ) + let service = GitService(operations: TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: root, branch: "main", changes: [change]), + stageResult: GitProcessResult( + arguments: ["checkout", "HEAD", "--", "README.md"], + output: "", + exitCode: 0, + invocations: [ + GitProcessInvocation( + arguments: ["status", "--porcelain", "--", "README.md"], + standardOutput: " M README.md\n", + standardError: "", + exitCode: 0 + ), + GitProcessInvocation( + arguments: ["checkout", "HEAD", "--", "README.md"], + standardOutput: "", + standardError: "", + exitCode: 0 + ) + ] + ) + )) + let feature = GitFeatureModel(service: service) + feature.configure( + workspaceURLProvider: { root }, + isGitLogVisibleProvider: { false }, + notify: { _ in }, + onStateRefreshed: {} + ) + + await feature.refreshGit() + await feature.selectChange(change) + await feature.stageSelectedChange() + + #expect(feature.gitConsoleEntries.map(\.arguments) == [ + ["status", "--porcelain", "--", "README.md"], + ["checkout", "HEAD", "--", "README.md"] + ]) + } + @Test func gitServicePreservesExecutedArgumentsAndWorkingDirectory() async { let root = URL(fileURLWithPath: "/workspace") diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 0c01a1de7..2181c5127 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -10,6 +10,7 @@ use crate::protocol::{ GitStatusResponse, GitWatchContextResponse, }; use serde::{Deserialize, Serialize}; +use std::cell::RefCell; use std::io::Read; use std::io::Write; #[cfg(target_os = "windows")] @@ -73,20 +74,36 @@ pub struct GitCommandRequest { pub input: Option, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One Git subprocess executed while fulfilling a shared Git command. +pub struct GitCommandInvocation { + /// Exact arguments passed to the Git executable, excluding the executable name. + pub arguments: Vec, + /// Text captured from the Git process standard output stream. + pub stdout: String, + /// Text captured from the Git process standard error stream. + pub stderr: String, + /// Exit status returned by the Git process. + pub exit_code: i32, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] /// Stable output returned by argument-based Git execution. pub struct GitCommandResponse { - /// Exact arguments passed to the Git executable, excluding the executable name. + /// Exact arguments for the final Git subprocess, retained for compatibility. pub arguments: Vec, /// Backward-compatible concatenation of standard output followed by standard error. pub output: String, - /// Text captured from the Git process standard output stream. + /// Text captured from the final Git process standard output stream. pub stdout: String, - /// Text captured from the Git process standard error stream. + /// Text captured from the final Git process standard error stream. pub stderr: String, - /// Exit status returned by the Git process. + /// Exit status returned by the final Git process. pub exit_code: i32, + /// Every Git subprocess executed for the operation, in execution order. + pub invocations: Vec, /// Present when a stash restore kept its entry because the working tree /// contains an unresolved merge. Keeping this out of the prose response /// lets bindings offer recovery actions without matching localized Git @@ -106,6 +123,12 @@ impl GitProcessOutput { fn into_command_response(self, arguments: &[String]) -> GitCommandResponse { let stdout = String::from_utf8_lossy(&self.stdout).to_string(); let stderr = String::from_utf8_lossy(&self.stderr).to_string(); + let invocation = GitCommandInvocation { + arguments: arguments.to_vec(), + stdout: stdout.clone(), + stderr: stderr.clone(), + exit_code: self.exit_code, + }; let output = format!("{stdout}{stderr}"); GitCommandResponse { arguments: arguments.to_vec(), @@ -113,11 +136,43 @@ impl GitProcessOutput { stdout, stderr, exit_code: self.exit_code, + invocations: vec![invocation], stash_restore: None, } } } +thread_local! { + static GIT_INVOCATION_TRACE: RefCell>> = const { + RefCell::new(None) + }; +} + +fn with_git_invocation_trace( + operation: impl FnOnce() -> Result, +) -> Result { + let previous = GIT_INVOCATION_TRACE.with(|trace| trace.replace(Some(Vec::new()))); + let result = operation(); + let invocations = GIT_INVOCATION_TRACE + .with(|trace| trace.replace(previous)) + .unwrap_or_default(); + result.map(|mut response| { + response.invocations = invocations; + response + }) +} + +fn record_git_invocation(response: &GitCommandResponse) { + let Some(invocation) = response.invocations.first().cloned() else { + return; + }; + GIT_INVOCATION_TRACE.with(|trace| { + if let Some(invocations) = trace.borrow_mut().as_mut() { + invocations.push(invocation); + } + }); +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Recovery context when restoring a stash produces conflicts. @@ -294,8 +349,10 @@ fn default_history_limit() -> usize { /// Executes an argument-based Git command after validating the workspace root. pub fn command(request: GitCommandRequest) -> Result { - let root = validate_root(&request.root)?; - execute_git(&root, &request.arguments, request.input) + with_git_invocation_trace(|| { + let root = validate_root(&request.root)?; + execute_git(&root, &request.arguments, request.input) + }) } fn readonly_command(request: GitCommandRequest) -> Result { @@ -305,6 +362,10 @@ fn readonly_command(request: GitCommandRequest) -> Result Result { + with_git_invocation_trace(|| write_with_trace(request)) +} + +fn write_with_trace(request: GitWriteRequest) -> Result { let root = validate_root(&request.root)?; let mut arguments: Vec; @@ -558,8 +619,11 @@ fn execute_git_with_options( input: Option, disable_optional_locks: bool, ) -> Result { - capture_git_with_options(root, arguments, input, disable_optional_locks) - .map(|output| output.into_command_response(arguments)) + capture_git_with_options(root, arguments, input, disable_optional_locks).map(|output| { + let response = output.into_command_response(arguments); + record_git_invocation(&response); + response + }) } fn capture_git_with_options( @@ -1752,6 +1816,7 @@ fn failed_git_result(message: impl Into) -> GitCommandResponse { stdout: String::new(), stderr, exit_code: 1, + invocations: Vec::new(), stash_restore: None, } } @@ -1785,12 +1850,13 @@ fn discard_all(root: &str, paths: &[String]) -> Result Result Result { @@ -2883,7 +2942,7 @@ fn relative_or_absolute(path: &Path, root: &Path) -> String { mod tests { use super::{ line_similarity, pair_diff_entries, parse_diff, structured_diff_from_output, DiffEntry, - GitProcessOutput, MAX_ALIGNMENT_CELLS, + GitCommandInvocation, GitCommandResponse, GitProcessOutput, MAX_ALIGNMENT_CELLS, }; use serde_json::Value; @@ -3006,6 +3065,58 @@ mod tests { assert_eq!(line_similarity("abc", ""), 0.0); } + #[test] + fn command_response_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/git/command-response-v1.json" + ))) + .expect("Git command response fixture should be valid JSON"); + let response = GitCommandResponse { + arguments: vec![ + "checkout".into(), + "HEAD".into(), + "--".into(), + "README.md".into(), + ], + output: String::new(), + stdout: String::new(), + stderr: String::new(), + exit_code: 0, + invocations: vec![ + GitCommandInvocation { + arguments: vec![ + "status".into(), + "--porcelain".into(), + "--untracked-files=all".into(), + "--".into(), + "README.md".into(), + ], + stdout: " M README.md\n".into(), + stderr: String::new(), + exit_code: 0, + }, + GitCommandInvocation { + arguments: vec![ + "checkout".into(), + "HEAD".into(), + "--".into(), + "README.md".into(), + ], + stdout: String::new(), + stderr: String::new(), + exit_code: 0, + }, + ], + stash_restore: None, + }; + + assert_eq!( + serde_json::to_value(response).expect("Git response should serialize"), + fixture + ); + } + #[test] fn structured_diff_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index eff9a0900..f4cd7d039 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -135,6 +135,14 @@ fn git_command_returns_separate_process_streams_and_combined_output() { .expect("Git command response should be JSON"); assert_eq!(response["ok"], true); assert_eq!(response["data"]["exitCode"], 0); + assert_eq!( + response["data"]["invocations"].as_array().map(Vec::len), + Some(1) + ); + assert_eq!( + response["data"]["invocations"][0]["arguments"], + serde_json::json!(["--version"]) + ); assert_eq!(response["data"]["stderr"], ""); assert!(response["data"]["stdout"] .as_str() @@ -261,6 +269,19 @@ fn git_write_validates_and_executes_shared_mutations() { fs::write(root.join("example.txt"), "working\n").expect("file should be writable"); let discard_all = request("discardAll", serde_json::json!({"paths": ["example.txt"]})); assert_eq!(discard_all["ok"], true, "{discard_all:?}"); + assert_eq!( + discard_all["data"]["arguments"], + serde_json::json!(["checkout", "HEAD", "--", "example.txt"]) + ); + assert_eq!( + discard_all["data"]["invocations"] + .as_array() + .expect("discardAll invocations should be an array") + .iter() + .map(|invocation| invocation["arguments"][0].as_str().unwrap_or_default()) + .collect::>(), + vec!["status", "checkout"] + ); assert_eq!( fs::read_to_string(root.join("example.txt")).expect("file should be readable"), "initial\n" diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 98bbee529..7f931e0cb 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -121,7 +121,7 @@ stable error code and a user-facing message: | `git.status` | Resolve the repository, current branch, and working-tree changes | | `git.watchContext` | Resolve the repository and absolute Git metadata roots needed by native file watchers | | `git.pullRequestContext` | Resolve worktree-aware PR branch defaults, publication state, and uncommitted-change state | -| `git.command` | Execute one argument-based Git operation and return combined output plus exit code | +| `git.command` | Execute one argument-based Git operation and return its arguments, streams, exit code, and ordered subprocess invocations | | `git.write` | Validate and execute shared Git mutations such as stage, commit, branch, checkout, remote sync, clone, and stash | | `git.diff` | Produce a structured working-tree, index, reference, or commit patch | | `git.apply` | Apply or check a patch in `stage`, `unstage`, `discard`, or Shelf restore mode | @@ -201,9 +201,13 @@ resolved from `origin`. `git.command` accepts `{ "root": string, "arguments": string[], "input": string? }`. Arguments are passed directly to the Git executable without a shell. A -successful process launch returns `{ "output": string, "exitCode": number }` -even when Git exits non-zero; process-start and workspace failures use the -standard error envelope. +successful process launch returns `{ "arguments": string[], "output": string, +"stdout": string, "stderr": string, "exitCode": number, "invocations": +GitCommandInvocation[] }` even when Git exits non-zero; process-start and +workspace failures use the standard error envelope. `GitCommandInvocation` is +`{ "arguments": string[], "stdout": string, "stderr": string, "exitCode": +number }`. The top-level fields retain the final invocation for compatibility, +while `invocations` records every subprocess in execution order. `git.write` accepts a typed mutation request. Its required `operation` values are `stage`, `unstage`, `discard`, `discardAll`, `stageAll`, `commit`, `cherryPick`, `revert`, @@ -217,10 +221,14 @@ standard error envelope. The core validates pathspecs, revisions, branch names, references, reset modes, stash references, and operation-specific required fields before invoking Git. Successful process launch returns `{ "arguments": string[], "output": string, -"stdout": string, "stderr": string, "exitCode": number }` even when Git exits -non-zero. `output` remains the backward-compatible concatenation of `stdout` -followed by `stderr`. `arguments` is the exact argument vector passed to the Git -executable, excluding the executable name. +"stdout": string, "stderr": string, "exitCode": number, "invocations": +GitCommandInvocation[] }` even when Git exits non-zero. `output` remains the +backward-compatible concatenation of `stdout` followed by `stderr`, and the +other top-level process fields describe the final subprocess. `invocations` +records every Git subprocess for composite operations such as `discardAll` and +Smart Checkout in execution order; each item contains the exact argument vector +(excluding the executable name), separate streams, and exit code. The shared +compatibility fixture is `shared/fixtures/git/command-response-v1.json`. Invalid arguments use the standard `invalid_request` error envelope. `checkout` uses `referenceKind` values `local`, `remote`, or `tag`; `clone` uses `remote` as its source and diff --git a/shared/fixtures/git/command-response-v1.json b/shared/fixtures/git/command-response-v1.json new file mode 100644 index 000000000..691383c9f --- /dev/null +++ b/shared/fixtures/git/command-response-v1.json @@ -0,0 +1,21 @@ +{ + "arguments": ["checkout", "HEAD", "--", "README.md"], + "output": "", + "stdout": "", + "stderr": "", + "exitCode": 0, + "invocations": [ + { + "arguments": ["status", "--porcelain", "--untracked-files=all", "--", "README.md"], + "stdout": " M README.md\n", + "stderr": "", + "exitCode": 0 + }, + { + "arguments": ["checkout", "HEAD", "--", "README.md"], + "stdout": "", + "stderr": "", + "exitCode": 0 + } + ] +} From 78ad0c8cfd4030699b4746f21fbff3b83c94b026 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 27 Aug 2026 23:16:02 +0800 Subject: [PATCH 5/6] fix(git): preserve composite command failures --- .../Lithe/Core/Rust/RustCoreBridge.swift | 14 ++ .../Lithe/Core/Rust/RustGitOperations.swift | 3 +- .../LitheGitModule/Ports/GitPorts.swift | 3 + .../LitheGitModule/Services/GitService.swift | 8 +- macos/Tests/LitheCoreVerifier/main.swift | 26 +++ .../LitheGitModuleTests/GitModuleTests.swift | 52 ++++++ rust/lithe-core/src/git/mod.rs | 171 ++++++++++++++++-- rust/lithe-core/src/tests/git.rs | 142 ++++++++++++++- shared/contracts/rust-core-api.md | 40 ++-- .../git/command-error-response-v1.json | 19 ++ windows/tauri/src-tauri/src/platform.rs | 90 ++++++++- 11 files changed, 518 insertions(+), 50 deletions(-) create mode 100644 shared/fixtures/git/command-error-response-v1.json diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index e3e42036f..c9d915571 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -777,6 +777,19 @@ struct RustCoreBridge: Sendable { let exitCode: Int32 } + struct OperationError: Decodable, Sendable { + let code: String + let message: String + let details: String? + + var userMessage: String { + if let details, !details.isEmpty { + return message + ": " + details + } + return message + } + } + struct StashRestore: Decodable, Sendable { let stashReference: String let conflictedPaths: [String] @@ -788,6 +801,7 @@ struct RustCoreBridge: Sendable { let stderr: String? let exitCode: Int32 let invocations: [Invocation]? + let operationError: OperationError? let stashRestore: StashRestore? } diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index 3f55f8a23..1fa72270a 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -12,7 +12,7 @@ struct RustGitOperations: GitOperations, Sendable { private func makeProcessResult(_ response: RustCoreBridge.GitCommandPayload) -> GitProcessResult { GitProcessResult( arguments: response.arguments ?? [], - output: response.output, + output: response.operationError?.userMessage ?? response.output, standardOutput: response.stdout, standardError: response.stderr, exitCode: response.exitCode, @@ -24,6 +24,7 @@ struct RustGitOperations: GitOperations, Sendable { exitCode: $0.exitCode ) } ?? [], + operationErrorMessage: response.operationError?.userMessage, stashRestoreConflict: response.stashRestore.map { GitStashRestoreConflict( stashReference: $0.stashReference, diff --git a/macos/Sources/LitheGitModule/Ports/GitPorts.swift b/macos/Sources/LitheGitModule/Ports/GitPorts.swift index cf6086bfc..6b07db2aa 100644 --- a/macos/Sources/LitheGitModule/Ports/GitPorts.swift +++ b/macos/Sources/LitheGitModule/Ports/GitPorts.swift @@ -28,6 +28,7 @@ public struct GitProcessResult: Sendable { public let standardError: String? public let exitCode: Int32 public let invocations: [GitProcessInvocation] + public let operationErrorMessage: String? public let stashRestoreConflict: GitStashRestoreConflict? public init( arguments: [String] = [], @@ -36,6 +37,7 @@ public struct GitProcessResult: Sendable { standardError: String? = nil, exitCode: Int32, invocations: [GitProcessInvocation] = [], + operationErrorMessage: String? = nil, stashRestoreConflict: GitStashRestoreConflict? = nil ) { self.arguments = arguments @@ -44,6 +46,7 @@ public struct GitProcessResult: Sendable { self.standardError = standardError self.exitCode = exitCode self.invocations = invocations + self.operationErrorMessage = operationErrorMessage self.stashRestoreConflict = stashRestoreConflict } } diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index c92225486..eb1ff3d70 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -122,6 +122,7 @@ package struct GitService: Sendable { package let standardError: String? package let exitCode: Int32 package let invocations: [GitProcessInvocation] + package let operationErrorMessage: String? package let stashRestoreConflict: GitStashRestoreConflict? package init( @@ -132,6 +133,7 @@ package struct GitService: Sendable { standardError: String? = nil, exitCode: Int32, invocations: [GitProcessInvocation] = [], + operationErrorMessage: String? = nil, stashRestoreConflict: GitStashRestoreConflict? = nil ) { self.workingDirectory = workingDirectory @@ -141,10 +143,13 @@ package struct GitService: Sendable { self.standardError = standardError self.exitCode = exitCode self.invocations = invocations + self.operationErrorMessage = operationErrorMessage self.stashRestoreConflict = stashRestoreConflict } - package var succeeded: Bool { exitCode == 0 } + package var succeeded: Bool { + exitCode == 0 && operationErrorMessage == nil && stashRestoreConflict == nil + } } func snapshot(for workspace: URL) async -> GitSnapshot? { @@ -627,6 +632,7 @@ package struct GitService: Sendable { standardError: result?.standardError, exitCode: result?.exitCode ?? 1, invocations: result?.invocations ?? [], + operationErrorMessage: result?.operationErrorMessage, stashRestoreConflict: result?.stashRestoreConflict ) }.value diff --git a/macos/Tests/LitheCoreVerifier/main.swift b/macos/Tests/LitheCoreVerifier/main.swift index 52be4b0ad..25ac0f8a2 100644 --- a/macos/Tests/LitheCoreVerifier/main.swift +++ b/macos/Tests/LitheCoreVerifier/main.swift @@ -54,12 +54,19 @@ struct CoreVerification { let exitCode: Int32 } + struct OperationError: Decodable { + let code: String + let message: String + let details: String? + } + let arguments: [String] let output: String let stdout: String let stderr: String let exitCode: Int32 let invocations: [Invocation] + let operationError: OperationError? } private struct GitFixture: Decodable { @@ -181,6 +188,25 @@ struct CoreVerification { ) require(commandFixture.arguments == invocations.last?.arguments, "Git command fixture final arguments changed") require(commandFixture.exitCode == invocations.last?.exitCode, "Git command fixture final exit code changed") + + let commandErrorURL = URL(fileURLWithPath: "shared/fixtures/git/command-error-response-v1.json") + guard let commandErrorData = try? Data(contentsOf: commandErrorURL), + let commandErrorFixture = try? JSONDecoder().decode(GitCommandFixture.self, from: commandErrorData), + let finalErrorInvocation = commandErrorFixture.invocations.last else { + require(false, "Git command error response fixture could not be decoded") + return + } + require(commandErrorFixture.operationError?.code == "invalid_request", "Git command error fixture code changed") + require(commandErrorFixture.operationError?.message == "Invalid Git reference", "Git command error fixture message changed") + require(commandErrorFixture.operationError?.details == nil, "Git command error fixture details changed") + require(commandErrorFixture.arguments == finalErrorInvocation.arguments, "Git command error fixture final arguments changed") + require(commandErrorFixture.stdout == finalErrorInvocation.stdout, "Git command error fixture final stdout changed") + require(commandErrorFixture.stderr == finalErrorInvocation.stderr, "Git command error fixture final stderr changed") + require(commandErrorFixture.exitCode == finalErrorInvocation.exitCode, "Git command error fixture final exit code changed") + require( + commandErrorFixture.output == finalErrorInvocation.stdout + finalErrorInvocation.stderr, + "Git command error fixture compatibility output changed" + ) } private static func verifyDiffParser() { diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 5b8074288..10d18168f 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -223,6 +223,58 @@ struct GitModuleTests { ]) } + @Test + func postInvocationOperationErrorFailsWhileKeepingConsoleTrace() async { + let root = URL(fileURLWithPath: "/workspace") + let change = GitChange( + repositoryRoot: root, + path: "README.md", + originalPath: nil, + indexStatus: " ", + workTreeStatus: "M" + ) + let operationError = "Invalid Git reference" + let service = GitService(operations: TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: root, branch: "main", changes: [change]), + stageResult: GitProcessResult( + arguments: ["stash", "push", "--include-untracked"], + output: operationError, + standardOutput: "No local changes to save\n", + standardError: "", + exitCode: 0, + invocations: [ + GitProcessInvocation( + arguments: ["stash", "push", "--include-untracked"], + standardOutput: "No local changes to save\n", + standardError: "", + exitCode: 0 + ) + ], + operationErrorMessage: operationError + ) + )) + let feature = GitFeatureModel(service: service) + var notifications: [String] = [] + feature.configure( + workspaceURLProvider: { root }, + isGitLogVisibleProvider: { false }, + notify: { notifications.append($0) }, + onStateRefreshed: {} + ) + + await feature.refreshGit() + await feature.selectChange(change) + await feature.stageSelectedChange() + + let result = await service.stage(change) + #expect(!result.succeeded) + #expect(notifications == [operationError]) + #expect(feature.gitConsoleEntries.map(\.arguments) == [ + ["stash", "push", "--include-untracked"] + ]) + #expect(feature.gitConsoleEntries.first?.succeeded == true) + } + @Test func gitServicePreservesExecutedArgumentsAndWorkingDirectory() async { let root = URL(fileURLWithPath: "/workspace") diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 2181c5127..5d6109c6e 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -104,6 +104,9 @@ pub struct GitCommandResponse { pub exit_code: i32, /// Every Git subprocess executed for the operation, in execution order. pub invocations: Vec, + /// Failure discovered after one or more subprocesses were recorded. + #[serde(skip_serializing_if = "Option::is_none")] + pub operation_error: Option, /// Present when a stash restore kept its entry because the working tree /// contains an unresolved merge. Keeping this out of the prose response /// lets bindings offer recovery actions without matching localized Git @@ -137,6 +140,7 @@ impl GitProcessOutput { stderr, exit_code: self.exit_code, invocations: vec![invocation], + operation_error: None, stash_restore: None, } } @@ -156,10 +160,35 @@ fn with_git_invocation_trace( let invocations = GIT_INVOCATION_TRACE .with(|trace| trace.replace(previous)) .unwrap_or_default(); - result.map(|mut response| { - response.invocations = invocations; - response - }) + + match result { + Ok(mut response) => { + response.invocations = invocations; + synchronize_final_invocation(&mut response); + Ok(response) + } + Err(error) if !invocations.is_empty() => { + // A composite Git operation may complete subprocesses before a + // follow-up probe fails. Preserve those diagnostics as command data + // instead of replacing them with an empty error result. + let mut response = failed_git_result(error); + response.invocations = invocations; + synchronize_final_invocation(&mut response); + Ok(response) + } + Err(error) => Err(error), + } +} + +fn synchronize_final_invocation(response: &mut GitCommandResponse) { + let Some(final_invocation) = response.invocations.last() else { + return; + }; + response.arguments = final_invocation.arguments.clone(); + response.stdout = final_invocation.stdout.clone(); + response.stderr = final_invocation.stderr.clone(); + response.output = format!("{}{}", response.stdout, response.stderr); + response.exit_code = final_invocation.exit_code; } fn record_git_invocation(response: &GitCommandResponse) { @@ -705,7 +734,7 @@ fn capture_git_with_options( } fn git_process() -> Command { - let mut process = Command::new("git"); + let process = Command::new("git"); #[cfg(target_os = "windows")] process.creation_flags(git_process_creation_flags()); process @@ -1808,15 +1837,15 @@ fn is_current_reference(root: &str, reference: &str) -> Result Ok(reference == current || reference == format!("refs/heads/{current}")) } -fn failed_git_result(message: impl Into) -> GitCommandResponse { - let stderr = message.into(); +fn failed_git_result(error: CoreError) -> GitCommandResponse { GitCommandResponse { arguments: Vec::new(), - output: stderr.clone(), + output: String::new(), stdout: String::new(), - stderr, + stderr: String::new(), exit_code: 1, invocations: Vec::new(), + operation_error: Some(error), stash_restore: None, } } @@ -2021,7 +2050,10 @@ fn push(root: &str, reference: Option<&str>) -> Result Ok(failed_git_result("No Git remote is configured")), + None => Err(CoreError::new( + ErrorCode::InvalidRequest, + "No Git remote is configured", + )), } } @@ -2101,7 +2133,8 @@ fn checkout_with_auto_stash( } let Some(stash_reference) = find_stash_reference(root, AUTO_STASH_MESSAGE)? else { - return Ok(failed_git_result( + return Err(CoreError::new( + ErrorCode::ProcessFailed, "Smart Checkout created a stash but could not locate it for restore.", )); }; @@ -2118,11 +2151,6 @@ fn checkout_with_auto_stash( stash_reference, conflicted_paths, }); - if !restored.output.contains("kept in the stash") { - restored.output.push_str( - "\nThe stashed changes conflict with the checked out branch and were kept in the stash.", - ); - } } Ok(restored) } @@ -2944,6 +2972,7 @@ mod tests { line_similarity, pair_diff_entries, parse_diff, structured_diff_from_output, DiffEntry, GitCommandInvocation, GitCommandResponse, GitProcessOutput, MAX_ALIGNMENT_CELLS, }; + use crate::protocol::{CoreError, ErrorCode}; use serde_json::Value; #[cfg(target_os = "windows")] @@ -2973,6 +3002,71 @@ mod tests { .is_some_and(|line| line.contains("warning:")))); } + #[test] + fn traced_response_uses_the_final_invocation_for_compatibility_fields() { + let mut response = GitCommandResponse { + arguments: vec!["status".into()], + output: "stale".into(), + stdout: "stale".into(), + stderr: String::new(), + exit_code: 0, + invocations: vec![ + GitCommandInvocation { + arguments: vec!["status".into()], + stdout: "status\n".into(), + stderr: String::new(), + exit_code: 0, + }, + GitCommandInvocation { + arguments: vec!["stash".into(), "list".into()], + stdout: "stash@{0}\n".into(), + stderr: String::new(), + exit_code: 0, + }, + ], + operation_error: None, + stash_restore: None, + }; + + super::synchronize_final_invocation(&mut response); + + assert_eq!(response.arguments, vec!["stash", "list"]); + assert_eq!(response.stdout, "stash@{0}\n"); + assert_eq!(response.stderr, ""); + assert_eq!(response.output, "stash@{0}\n"); + assert_eq!(response.exit_code, 0); + } + + #[test] + fn traced_core_error_becomes_a_failed_response_with_invocations() { + let result = super::with_git_invocation_trace(|| { + let response = GitProcessOutput { + stdout: b"prepared\n".to_vec(), + stderr: Vec::new(), + exit_code: 0, + } + .into_command_response(&["stash".into(), "push".into()]); + super::record_git_invocation(&response); + Err(CoreError::new( + ErrorCode::ProcessFailed, + "Follow-up probe failed", + )) + }); + + let response = result.expect("a partial Git failure should retain command data"); + assert_eq!(response.exit_code, 0); + assert_eq!(response.arguments, vec!["stash", "push"]); + assert_eq!(response.output, "prepared\n"); + assert_eq!(response.invocations.len(), 1); + assert_eq!( + response + .operation_error + .as_ref() + .map(|error| &error.message), + Some(&"Follow-up probe failed".to_string()) + ); + } + #[test] fn git_process_response_preserves_executed_arguments() { let arguments = vec!["status".to_string(), "--short".to_string()]; @@ -3108,6 +3202,7 @@ mod tests { exit_code: 0, }, ], + operation_error: None, stash_restore: None, }; @@ -3117,6 +3212,50 @@ mod tests { ); } + #[test] + fn command_error_response_matches_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/git/command-error-response-v1.json" + ))) + .expect("Git command error response fixture should be valid JSON"); + let response = GitCommandResponse { + arguments: vec![ + "stash".into(), + "push".into(), + "--include-untracked".into(), + "--message".into(), + "Lithe Smart Checkout".into(), + ], + output: "No local changes to save\n".into(), + stdout: "No local changes to save\n".into(), + stderr: String::new(), + exit_code: 0, + invocations: vec![GitCommandInvocation { + arguments: vec![ + "stash".into(), + "push".into(), + "--include-untracked".into(), + "--message".into(), + "Lithe Smart Checkout".into(), + ], + stdout: "No local changes to save\n".into(), + stderr: String::new(), + exit_code: 0, + }], + operation_error: Some(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git reference", + )), + stash_restore: None, + }; + + assert_eq!( + serde_json::to_value(response).expect("Git error response should serialize"), + fixture + ); + } + #[test] fn structured_diff_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index f4cd7d039..ac96c912b 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -291,6 +291,45 @@ fn git_write_validates_and_executes_shared_mutations() { "" ); + // An invalid checkout reference is discovered after smart checkout has + // already started; the executed stash command must remain visible. + let partial_failure = request( + "checkout", + serde_json::json!({ + "reference": "invalid branch", + "referenceKind": "local", + "autoStash": true + }), + ); + assert_eq!(partial_failure["ok"], true, "{partial_failure:?}"); + assert_eq!( + partial_failure["data"]["operationError"]["code"], "invalid_request", + "{partial_failure:?}" + ); + assert_eq!( + partial_failure["data"]["invocations"][0]["arguments"][0], "stash", + "{partial_failure:?}" + ); + let final_invocation = partial_failure["data"]["invocations"] + .as_array() + .and_then(|invocations| invocations.last()) + .expect("partial failure should retain its final Git invocation"); + for field in ["arguments", "stdout", "stderr", "exitCode"] { + assert_eq!( + partial_failure["data"][field], final_invocation[field], + "partial failure compatibility field {field} should match the final invocation" + ); + } + assert_eq!( + partial_failure["data"]["output"], + format!( + "{}{}", + final_invocation["stdout"].as_str().unwrap(), + final_invocation["stderr"].as_str().unwrap() + ), + "partial failure compatibility output should match the final invocation" + ); + fs::write(root.join("untracked.txt"), "discard me\n") .expect("untracked file should be writable"); assert_eq!( @@ -671,7 +710,6 @@ fn stash_restore_conflicts_return_structured_recovery_data() { let applied = write("stashApply"); assert_eq!(applied["ok"], true, "{applied:?}"); - assert_eq!(applied["data"]["exitCode"], 1, "{applied:?}"); assert_eq!( applied["data"]["stashRestore"]["stashReference"], stash_reference, "{applied:?}" @@ -681,13 +719,48 @@ fn stash_restore_conflicts_return_structured_recovery_data() { serde_json::json!(["shared.txt"]), "{applied:?}" ); + assert_eq!( + applied["data"]["arguments"], + applied["data"]["invocations"] + .as_array() + .unwrap() + .last() + .unwrap()["arguments"], + "composite response should expose the final invocation arguments" + ); + assert_eq!( + applied["data"]["stdout"], + applied["data"]["invocations"] + .as_array() + .unwrap() + .last() + .unwrap()["stdout"], + "composite response should expose the final invocation stdout" + ); + assert_eq!( + applied["data"]["stderr"], + applied["data"]["invocations"] + .as_array() + .unwrap() + .last() + .unwrap()["stderr"], + "composite response should expose the final invocation stderr" + ); + assert_eq!( + applied["data"]["exitCode"], + applied["data"]["invocations"] + .as_array() + .unwrap() + .last() + .unwrap()["exitCode"], + "composite response should expose the final invocation exit code" + ); // Clear the index conflict without dropping the saved entry, then verify // `pop` reports the same structured recovery data. assert!(run(&["reset", "--hard", "HEAD"]).status.success()); let popped = write("stashPop"); assert_eq!(popped["ok"], true, "{popped:?}"); - assert_eq!(popped["data"]["exitCode"], 1, "{popped:?}"); assert_eq!( popped["data"]["stashRestore"]["stashReference"], stash_reference, "{popped:?}" @@ -697,6 +770,42 @@ fn stash_restore_conflicts_return_structured_recovery_data() { serde_json::json!(["shared.txt"]), "{popped:?}" ); + assert_eq!( + popped["data"]["arguments"], + popped["data"]["invocations"] + .as_array() + .unwrap() + .last() + .unwrap()["arguments"], + "composite response should expose the final invocation arguments" + ); + assert_eq!( + popped["data"]["stdout"], + popped["data"]["invocations"] + .as_array() + .unwrap() + .last() + .unwrap()["stdout"], + "composite response should expose the final invocation stdout" + ); + assert_eq!( + popped["data"]["stderr"], + popped["data"]["invocations"] + .as_array() + .unwrap() + .last() + .unwrap()["stderr"], + "composite response should expose the final invocation stderr" + ); + assert_eq!( + popped["data"]["exitCode"], + popped["data"]["invocations"] + .as_array() + .unwrap() + .last() + .unwrap()["exitCode"], + "composite response should expose the final invocation exit code" + ); assert!(run(&["reset", "--hard", "HEAD"]).status.success()); assert!(run(&["stash", "drop", &stash_reference]).status.success()); @@ -757,10 +866,18 @@ fn git_operation_state_reports_and_resolves_a_merge_conflict() { assert_eq!(idle["data"]["kind"], "", "{idle:?}"); assert_eq!(idle["data"]["conflictedPaths"], serde_json::json!([])); - // Continuing when nothing is in progress is rejected rather than run blindly. + // Resolving the operation state invokes Git before discovering that + // there is nothing to continue, so the trace and logical error coexist. let nothing = write("operationContinue"); - assert_eq!(nothing["ok"], false, "{nothing:?}"); - assert_eq!(nothing["error"]["code"], "invalid_request"); + assert_eq!(nothing["ok"], true, "{nothing:?}"); + assert_eq!( + nothing["data"]["operationError"]["code"], "invalid_request", + "{nothing:?}" + ); + assert!(!nothing["data"]["invocations"] + .as_array() + .unwrap() + .is_empty()); // Build two branches that edit the same line, so merging must conflict. assert!(run(&["switch", "-qc", "feature/conflict"]).status.success()); @@ -787,12 +904,19 @@ fn git_operation_state_reports_and_resolves_a_merge_conflict() { // Continuing with the conflict unresolved is refused, so the user cannot // commit conflict markers by clicking through the banner. let premature = write("operationContinue"); - assert_eq!(premature["ok"], false, "{premature:?}"); - assert_eq!(premature["error"]["code"], "invalid_request"); + assert_eq!(premature["ok"], true, "{premature:?}"); + assert_eq!( + premature["data"]["operationError"]["code"], "invalid_request", + "{premature:?}" + ); - // A merge has no skip step. + // A merge has no skip step, but the state probes remain visible. let skip = write("operationSkip"); - assert_eq!(skip["ok"], false, "{skip:?}"); + assert_eq!(skip["ok"], true, "{skip:?}"); + assert_eq!( + skip["data"]["operationError"]["code"], "invalid_request", + "{skip:?}" + ); // Resolving the file and continuing completes the merge without opening an editor. fs::write(root.join("shared.txt"), "resolved\n").expect("file should be writable"); diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 7389a2101..52aebc062 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -203,11 +203,16 @@ resolved from `origin`. Arguments are passed directly to the Git executable without a shell. A successful process launch returns `{ "arguments": string[], "output": string, "stdout": string, "stderr": string, "exitCode": number, "invocations": -GitCommandInvocation[] }` even when Git exits non-zero; process-start and -workspace failures use the standard error envelope. `GitCommandInvocation` is -`{ "arguments": string[], "stdout": string, "stderr": string, "exitCode": -number }`. The top-level fields retain the final invocation for compatibility, -while `invocations` records every subprocess in execution order. +GitCommandInvocation[], "operationError": CoreError? }` even when Git exits +non-zero. `GitCommandInvocation` is `{ "arguments": string[], "stdout": string, +"stderr": string, "exitCode": number }`. The top-level `arguments`, streams, +and exit code always equal the final invocation for compatibility, and `output` +is that invocation's `stdout` followed by `stderr`; `invocations` records every +subprocess in execution order. Validation, process-start, and workspace failures +that occur before Git starts use the standard error envelope. If a follow-up +validation or probe fails after at least one subprocess was recorded, the +response retains the invocation trace and includes the failure as +`operationError`. `git.write` accepts a typed mutation request. Its required `operation` values are `stage`, `unstage`, `discard`, `discardAll`, `stageAll`, `commit`, `cherryPick`, `revert`, @@ -222,15 +227,22 @@ The core validates pathspecs, revisions, branch names, references, reset modes, stash references, and operation-specific required fields before invoking Git. Successful process launch returns `{ "arguments": string[], "output": string, "stdout": string, "stderr": string, "exitCode": number, "invocations": -GitCommandInvocation[] }` even when Git exits non-zero. `output` remains the -backward-compatible concatenation of `stdout` followed by `stderr`, and the -other top-level process fields describe the final subprocess. `invocations` -records every Git subprocess for composite operations such as `discardAll` and -Smart Checkout in execution order; each item contains the exact argument vector -(excluding the executable name), separate streams, and exit code. The shared -compatibility fixture is `shared/fixtures/git/command-response-v1.json`. -Invalid arguments use the standard -`invalid_request` error envelope. `checkout` uses `referenceKind` values +GitCommandInvocation[], "operationError": CoreError?, "stashRestore": +GitStashRestore? }` even when Git exits non-zero. The top-level process fields +always describe the final subprocess, and `output` is that subprocess's +`stdout` followed by `stderr`. `invocations` records every Git subprocess for +composite operations such as `discardAll` and Smart Checkout in execution +order; each item contains the exact argument vector (excluding the executable +name), separate streams, and exit code. A follow-up validation or probe failure +after Git has started is returned in `operationError` alongside the retained +trace. A stash restore conflict is a logical operation failure represented by +`stashRestore`, even when a later diagnostic invocation exits successfully. +Consumers must therefore consider `operationError` and `stashRestore` in +addition to the compatibility `exitCode`. The shared compatibility fixtures are +`shared/fixtures/git/command-response-v1.json` and +`shared/fixtures/git/command-error-response-v1.json`. Invalid arguments found +before any Git subprocess use the standard `invalid_request` error envelope. +`checkout` uses `referenceKind` values `local`, `remote`, or `tag`; `clone` uses `remote` as its source and `destination` as its target path. `publishBranch` validates `name`, creates and checks out that branch at a detached HEAD when needed, then pushes it with diff --git a/shared/fixtures/git/command-error-response-v1.json b/shared/fixtures/git/command-error-response-v1.json new file mode 100644 index 000000000..de3fbe435 --- /dev/null +++ b/shared/fixtures/git/command-error-response-v1.json @@ -0,0 +1,19 @@ +{ + "arguments": ["stash", "push", "--include-untracked", "--message", "Lithe Smart Checkout"], + "output": "No local changes to save\n", + "stdout": "No local changes to save\n", + "stderr": "", + "exitCode": 0, + "invocations": [ + { + "arguments": ["stash", "push", "--include-untracked", "--message", "Lithe Smart Checkout"], + "stdout": "No local changes to save\n", + "stderr": "", + "exitCode": 0 + } + ], + "operationError": { + "code": "invalid_request", + "message": "Invalid Git reference" + } +} diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index ed4c86500..2588249e3 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -30,14 +30,8 @@ pub async fn platform_invoke(command: String, args: Value) -> Result Result Option { + if let Some(error) = data.get("operationError") { + let message = error + .get("message") + .and_then(Value::as_str) + .filter(|message| !message.trim().is_empty()) + .unwrap_or("Git operation failed") + .trim(); + let details = error + .get("details") + .and_then(Value::as_str) + .filter(|details| !details.trim().is_empty()) + .map(str::trim); + return Some(match details { + Some(details) => format!("{message}: {details}"), + None => message.to_string(), + }); + } + + if let Some(stash_restore) = data.get("stashRestore") { + let paths = stash_restore + .get("conflictedPaths") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>(); + return Some(if paths.is_empty() { + "Git stash restore has conflicts".to_string() + } else { + format!("Git stash restore has conflicts: {}", paths.join(", ")) + }); + } + + if data.get("exitCode").and_then(Value::as_i64).unwrap_or(0) != 0 { + return Some( + data.get("output") + .and_then(Value::as_str) + .filter(|output| !output.trim().is_empty()) + .unwrap_or("Git operation failed") + .trim() + .to_string(), + ); + } + + None +} + fn translate(command: &str, args: Value) -> Result<(String, Value), String> { let mut payload = args.as_object().cloned().unwrap_or_default(); move_field(&mut payload, "repoPath", "root"); @@ -462,9 +506,37 @@ fn take_text(payload: &mut Map, field: &str) -> Result Date: Fri, 28 Aug 2026 00:06:42 +0800 Subject: [PATCH 6/6] fix(git): configure Windows Git process mutably --- rust/lithe-core/src/git/mod.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 5d6109c6e..a3d28dc38 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -734,10 +734,17 @@ fn capture_git_with_options( } fn git_process() -> Command { - let process = Command::new("git"); #[cfg(target_os = "windows")] - process.creation_flags(git_process_creation_flags()); - process + { + let mut process = Command::new("git"); + process.creation_flags(git_process_creation_flags()); + process + } + + #[cfg(not(target_os = "windows"))] + { + Command::new("git") + } } #[cfg(target_os = "windows")]