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 5448cfde8..c9d915571 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -770,13 +770,38 @@ struct RustCoreBridge: Sendable { } struct GitCommandPayload: Decodable, Sendable { + struct Invocation: Decodable, Sendable { + let arguments: [String] + let stdout: String + let stderr: String + 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] } + let arguments: [String]? let output: String + let stdout: String? + 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 194fb2027..1fa72270a 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -11,8 +11,20 @@ struct RustGitOperations: GitOperations, Sendable { private func makeProcessResult(_ response: RustCoreBridge.GitCommandPayload) -> GitProcessResult { GitProcessResult( - output: response.output, + arguments: response.arguments ?? [], + output: response.operationError?.userMessage ?? response.output, + 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 + ) + } ?? [], + operationErrorMessage: response.operationError?.userMessage, stashRestoreConflict: response.stashRestore.map { GitStashRestoreConflict( stashReference: $0.stashReference, @@ -35,7 +47,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 97eb3ad59..2b0a9c53b 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -156,7 +156,10 @@ 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() } + 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 ff7589604..c33c080f2 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -1,9 +1,11 @@ +import AppKit import SwiftUI 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 @@ -21,6 +23,9 @@ 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 gitConsoleWrapsLines = false @State private var selectedGitLogAuthor: GitLogAuthorSelection? @State private var selectedGitLogDatePreset = GitLogDatePreset.anyTime @State private var gitLogPathFilter = "" @@ -45,94 +50,105 @@ struct GitLogView: View { static let meta = Font.system(size: 12, weight: .regular) static let monoMeta = Font.system(size: 12, weight: .regular, design: .monospaced) static let rowHeight: CGFloat = 38 - static let treeRowHeight: CGFloat = 24 - static let toolbarHeight: CGFloat = 36 + 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 { + 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 + ) + } + ) + + 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) + detailPane + .frame(width: resolvedDetailPaneWidth) + } } + } else { + gitConsolePane } } .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.sidebar) @@ -158,6 +174,10 @@ struct GitLogView: View { gitLogPathFilter = "" gitLogPathDraft = "" } + .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 { @@ -266,7 +286,7 @@ struct GitLogView: View { } private var toolWindowHeader: some View { - HStack(spacing: 8) { + HStack(spacing: 4) { LitheIDEAIcon( resourcePath: "toolwindows/toolWindowVcs.svg", size: 14, @@ -277,34 +297,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") @@ -349,6 +351,209 @@ struct GitLogView: View { } } + private func gitToolTabButton(_ tab: GitToolTab, title: LocalizedStringKey) -> some View { + let isSelected = selectedGitToolTab == tab + let showsCloseButton = isSelected && tab == .console + return HStack(spacing: 0) { + Button { + selectedGitToolTab = tab + if tab == .console { + Task { await model.loadGitConsoleIfNeeded() } + } + } 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) + } + } + + private var gitConsolePane: some View { + HStack(spacing: 0) { + 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: { + 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: 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: 28) + .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.editor) + + Rectangle() + .fill(LitheTheme.divider) + .frame(width: 1) + + 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") + } + .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) + } + } + } + } + .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.editor) + } + + private func gitConsoleEntry(_ entry: GitConsoleEntry) -> some View { + VStack(alignment: .leading, spacing: 0) { + gitConsoleLine(gitConsoleCommandText(entry)) + + if entry.outputLines.isEmpty { + if !entry.succeeded { + gitConsoleLine( + Text("Git exited with code \(entry.exitCode)") + .foregroundColor(LitheTheme.error) + ) + } + } else { + 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: 13, weight: .regular, design: .monospaced)) + .textSelection(.enabled) + } + + 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 { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "HH:mm:ss.SSS" + return formatter.string(from: date) + } + 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 196f94717..ebbf13f33 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] = [] @@ -83,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]] = [:] @@ -190,6 +194,10 @@ package final class GitFeatureModel: ObservableObject { isLoadingGitHistory = false isLoadingMoreGitHistory = false canLoadMoreGitHistory = false + gitConsoleEntries = [] + isLoadingInitialGitConsoleEntry = false + hasLoadedInitialGitConsoleEntry = false + gitConsoleRepositoryGeneration &+= 1 selectedGitReference = nil selectedGitCommit = nil selectedGitCommitFiles = [] @@ -282,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 { @@ -513,10 +524,77 @@ 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 = [] + 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) { + guard let workingDirectory = result.workingDirectory ?? gitRepositoryRoot else { return } + 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) + } + } + package func setGitConflictFilter(_ paths: [String]) { gitConflictFilterPaths = Set(paths) } @@ -858,9 +936,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 +1082,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 +1100,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 +1120,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, @@ -1504,7 +1590,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( @@ -1589,11 +1677,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)) @@ -1618,7 +1708,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( @@ -1680,19 +1772,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..190b5a666 --- /dev/null +++ b/macos/Sources/LitheGitModule/Models/GitConsoleModels.swift @@ -0,0 +1,160 @@ +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 + package let timestamp: Date + 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( + id: UUID = UUID(), + timestamp: Date = Date(), + workingDirectory: URL, + arguments: [String], + output: String, + standardOutput: String? = nil, + standardError: String? = nil, + exitCode: Int32 + ) { + self.id = id + self.timestamp = timestamp + self.workingDirectory = workingDirectory + 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 + } + + package var succeeded: Bool { exitCode == 0 } + + package var commandLine: String { + 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 } + return "\(header)\n\(output)" + } +} + +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)) } + } +} + + +/// 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 { + 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 { + let redacted = GitConsoleRedactor.redact(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 let safeShellScalars = CharacterSet( + charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_@%+=:,./-" + ) +} diff --git a/macos/Sources/LitheGitModule/Ports/GitPorts.swift b/macos/Sources/LitheGitModule/Ports/GitPorts.swift index f7596deb9..6b07db2aa 100644 --- a/macos/Sources/LitheGitModule/Ports/GitPorts.swift +++ b/macos/Sources/LitheGitModule/Ports/GitPorts.swift @@ -1,12 +1,52 @@ 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 operationErrorMessage: String? public let stashRestoreConflict: GitStashRestoreConflict? - public init(output: String, exitCode: Int32, stashRestoreConflict: GitStashRestoreConflict? = nil) { + public init( + arguments: [String] = [], + output: String, + standardOutput: String? = nil, + standardError: String? = nil, + exitCode: Int32, + invocations: [GitProcessInvocation] = [], + operationErrorMessage: String? = nil, + stashRestoreConflict: GitStashRestoreConflict? = nil + ) { + self.arguments = arguments self.output = output + self.standardOutput = standardOutput + 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 640b51a43..eb1ff3d70 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? @@ -109,27 +115,57 @@ package struct GitService: Sendable { } package struct CommandResult: 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 invocations: [GitProcessInvocation] + package let operationErrorMessage: String? package let stashRestoreConflict: GitStashRestoreConflict? package init( + workingDirectory: URL? = nil, + arguments: [String] = [], output: String, + standardOutput: String? = nil, + standardError: String? = nil, exitCode: Int32, + invocations: [GitProcessInvocation] = [], + operationErrorMessage: String? = nil, stashRestoreConflict: GitStashRestoreConflict? = nil ) { + self.workingDirectory = workingDirectory + self.arguments = arguments self.output = output + self.standardOutput = standardOutput + 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? { 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 } @@ -203,7 +239,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 +272,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 +322,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 +482,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 +492,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 +533,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 +542,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 +559,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,36 +593,46 @@ 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", + standardOutput: result?.standardOutput, + 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 ecdc16f25..25ac0f8a2 100644 --- a/macos/Tests/LitheCoreVerifier/main.swift +++ b/macos/Tests/LitheCoreVerifier/main.swift @@ -45,6 +45,30 @@ struct CoreVerification { let cases: [Case] } + + private struct GitCommandFixture: Decodable { + struct Invocation: Decodable { + let arguments: [String] + let stdout: String + let stderr: String + 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 { struct Commit: Decodable { let hash: String @@ -140,6 +164,49 @@ 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") + + 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 fdecdca3a..3f35a37a5 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -220,6 +220,284 @@ struct GitModuleTests { #expect(!query.matchesMetadata(atExclusiveEnd, identity: nil)) } + @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 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 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") + 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 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 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( + 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") @@ -446,22 +724,75 @@ private struct TestShelfStorage: GitShelfStorage { func removeItem(at url: URL) throws {} } +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() + } +} + private struct TestGitOperations: GitOperations { private let snapshotValue: GitSnapshot? private let comparisonValue: GitBranchComparison? 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 + comparisonDiffDocumentValue: DiffDocument? = 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 { + runGate?.blockFirstRun() + return 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 } @@ -479,7 +810,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..a3d28dc38 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,12 +74,39 @@ 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 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 final Git process standard output stream. + pub stdout: String, + /// Text captured from the final Git process standard error stream. + pub stderr: String, + /// 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, + /// 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 @@ -95,17 +123,85 @@ struct GitProcessOutput { } impl GitProcessOutput { - fn into_command_response(self) -> GitCommandResponse { - let mut output = String::from_utf8_lossy(&self.stdout).to_string(); - output.push_str(&String::from_utf8_lossy(&self.stderr)); + 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(), output, + stdout, + stderr, exit_code: self.exit_code, + invocations: vec![invocation], + operation_error: None, 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(); + + 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) { + 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. @@ -282,8 +378,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 { @@ -293,6 +391,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; @@ -546,8 +648,11 @@ fn execute_git_with_options( input: Option, disable_optional_locks: bool, ) -> Result { - capture_git_with_options(root, arguments, input, disable_optional_locks) - .map(GitProcessOutput::into_command_response) + 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( @@ -629,10 +734,17 @@ fn capture_git_with_options( } fn git_process() -> Command { - let mut 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")] @@ -1732,10 +1844,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 { +fn failed_git_result(error: CoreError) -> GitCommandResponse { GitCommandResponse { - output: message.into(), + arguments: Vec::new(), + output: String::new(), + stdout: String::new(), + stderr: String::new(), exit_code: 1, + invocations: Vec::new(), + operation_error: Some(error), stash_restore: None, } } @@ -1769,12 +1886,13 @@ fn discard_all(root: &str, paths: &[String]) -> Result Result Result { @@ -1943,7 +2057,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", + )), } } @@ -2023,7 +2140,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.", )); }; @@ -2040,11 +2158,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) } @@ -2864,8 +2977,9 @@ 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 crate::protocol::{CoreError, ErrorCode}; use serde_json::Value; #[cfg(target_os = "windows")] @@ -2895,6 +3009,88 @@ 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()]; + let response = GitProcessOutput { + stdout: b" M README.md\n".to_vec(), + stderr: Vec::new(), + exit_code: 0, + } + .into_command_response(&arguments); + + assert_eq!(response.arguments, arguments); + assert_eq!(response.output, " M README.md\n"); + assert_eq!(response.stdout, " M README.md\n"); + assert_eq!(response.stderr, ""); + assert_eq!(response.exit_code, 0); + } + fn entries(texts: &[&str]) -> Vec { texts .iter() @@ -2970,6 +3166,103 @@ 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, + }, + ], + operation_error: None, + stash_restore: None, + }; + + assert_eq!( + serde_json::to_value(response).expect("Git response should serialize"), + fixture + ); + } + + #[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 c3be76b5a..ac96c912b 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -117,7 +117,7 @@ fn git_status_does_not_refresh_the_index() { } #[test] -fn git_command_returns_combined_output_and_exit_code() { +fn git_command_returns_separate_process_streams_and_combined_output() { let root = temporary_root("git-command"); fs::create_dir_all(&root).expect("temporary workspace should be creatable"); @@ -135,11 +135,48 @@ fn git_command_returns_combined_output_and_exit_code() { .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() + .expect("Git version stdout should be text") + .contains("git version")); assert!(response["data"]["output"] .as_str() .expect("Git version output should be text") .contains("git version")); + let failure_request = serde_json::json!({ + "id": "git-command-stderr", + "command": "git.command", + "payload": { + "root": root, + "arguments": ["rev-parse", "--verify", "refs/heads/missing"] + } + }); + let failure_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&failure_request).expect("Git failure request should encode"), + )) + .expect("Git failure response should be JSON"); + assert_eq!(failure_response["ok"], true); + assert_ne!(failure_response["data"]["exitCode"], 0); + assert_eq!(failure_response["data"]["stdout"], ""); + assert!(!failure_response["data"]["stderr"] + .as_str() + .expect("Git failure stderr should be text") + .is_empty()); + assert_eq!( + failure_response["data"]["output"], + failure_response["data"]["stderr"] + ); + fs::remove_dir_all(root).expect("temporary workspace should be removable"); } @@ -232,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" @@ -241,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!( @@ -621,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:?}" @@ -631,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:?}" @@ -647,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()); @@ -707,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()); @@ -737,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 be8090ba6..52aebc062 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,18 @@ 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[], "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`, @@ -216,9 +225,24 @@ 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 -`invalid_request` error envelope. `checkout` uses `referenceKind` values +Successful process launch returns `{ "arguments": string[], "output": string, +"stdout": string, "stderr": string, "exitCode": number, "invocations": +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 @@ -229,7 +253,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 +301,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. 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/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 + } + ] +} 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