diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index 1fa72270a..cbc5f34f1 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -156,10 +156,33 @@ struct RustGitOperations: GitOperations, Sendable { write(at: rootURL, operation: "rebase", reference: reference.fullName) } + func checkoutAndRebase(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { + write( + at: rootURL, + operation: "checkoutAndRebase", + reference: reference.fullName, + referenceKind: reference.kind + ) + } + func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy = .ffOnly) -> GitProcessResult? { write(at: rootURL, operation: "pull", mode: strategy.rawValue) } + func pullRemoteReference( + _ reference: GitReference, + strategy: GitPullStrategy, + at rootURL: URL + ) -> GitProcessResult? { + write( + at: rootURL, + operation: "pull", + reference: reference.fullName, + referenceKind: reference.kind, + mode: strategy.rawValue + ) + } + /// Staged files still containing conflict markers. func conflictMarkerPaths(at rootURL: URL) -> [String] { core.gitConflictMarkerPaths(at: rootURL)?.paths ?? [] diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 0c107584d..213b898b0 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -1738,6 +1738,16 @@ final class AppModel: ObservableObject, Identifiable { await gitFeature.rebaseCurrentBranch(onto: reference) } + func checkoutAndRebase(_ reference: GitReference) async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.checkoutAndRebase(reference) + } + + func pullRemoteReference(_ reference: GitReference, strategy: GitPullStrategy) async { + guard let gitFeature = await activateGitModule() else { return } + await gitFeature.pullRemoteReference(reference, strategy: strategy) + } + func updateCurrentBranch(_ reference: GitReference) async { guard let gitFeature = await activateGitModule() else { return } await gitFeature.updateCurrentBranch(reference) diff --git a/macos/Sources/Lithe/Views/Git/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift index 464111c77..9b259de04 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -279,6 +279,12 @@ struct GitLogView: View { await model.mergeBranch(operation.reference) case .rebase: await model.rebaseCurrentBranch(onto: operation.reference) + case .checkoutAndRebase: + await model.checkoutAndRebase(operation.reference) + case .pullRebase: + await model.pullRemoteReference(operation.reference, strategy: .rebase) + case .pullMerge: + await model.pullRemoteReference(operation.reference, strategy: .merge) } } } @@ -824,6 +830,12 @@ struct GitLogView: View { Task { await model.showComparisonWithWorkingTree(for: reference) } } + if let currentReference, currentReference.id != reference.id { + Button("Compare with Current Branch") { + Task { await model.showComparison(from: reference, to: currentReference) } + } + } + if let source = comparisonSourceReference, source.id != reference.id { Button("Compare '\(source.shortName)' with '\(reference.shortName)'") { comparisonSourceReference = nil @@ -835,39 +847,73 @@ struct GitLogView: View { } } - if reference.kind == .local { + if !reference.isCurrent { Divider() - if !reference.isCurrent { - Button("Checkout") { - Task { await model.checkoutReference(reference) } - } - .disabled(model.isPerformingBranchOperation) - } - - Button("Update") { - Task { await model.updateCurrentBranch(reference) } - } - .disabled(!reference.isCurrent || model.isPerformingBranchOperation) - - Button("Push…") { - pendingPushReference = reference + Button("Checkout") { + Task { await model.checkoutReference(reference) } } .disabled(model.isPerformingBranchOperation) - if !reference.isCurrent { + if reference.kind != .tag { + Button("Checkout and Rebase onto Current Branch") { + pendingBranchOperation = GitBranchOperationRequest( + kind: .checkoutAndRebase, + reference: reference + ) + } + .disabled(model.isPerformingBranchOperation) + Button("Merge into Current Branch") { pendingBranchOperation = GitBranchOperationRequest( kind: .merge, reference: reference ) } + .disabled(model.isPerformingBranchOperation) Button("Rebase Current Branch onto…") { pendingBranchOperation = GitBranchOperationRequest( kind: .rebase, reference: reference ) } + .disabled(model.isPerformingBranchOperation) + } + } + + if reference.kind == .remote { + Divider() + + Button("Pull with Rebase") { + pendingBranchOperation = GitBranchOperationRequest( + kind: .pullRebase, + reference: reference + ) + } + .disabled(model.isPerformingBranchOperation) + Button("Pull with Merge") { + pendingBranchOperation = GitBranchOperationRequest( + kind: .pullMerge, + reference: reference + ) + } + .disabled(model.isPerformingBranchOperation) + } + + if reference.kind == .local { + Divider() + + Button("Update") { + Task { await model.updateCurrentBranch(reference) } + } + .disabled(!reference.isCurrent || model.isPerformingBranchOperation) + + Button("Push…") { + pendingPushReference = reference + } + .disabled(model.isPerformingBranchOperation) + + if !reference.isCurrent { Button("Delete Branch", role: .destructive) { pendingBranchOperation = GitBranchOperationRequest( kind: .delete, @@ -1899,12 +1945,18 @@ private enum GitBranchOperationKind { case delete case merge case rebase + case checkoutAndRebase + case pullRebase + case pullMerge var title: String { switch self { case .delete: "Delete branch?" case .merge: "Merge branch?" case .rebase: "Rebase branch?" + case .checkoutAndRebase: "Checkout and rebase branch?" + case .pullRebase: "Pull remote branch with rebase?" + case .pullMerge: "Pull remote branch with merge?" } } @@ -1913,6 +1965,9 @@ private enum GitBranchOperationKind { case .delete: "Delete" case .merge: "Merge" case .rebase: "Rebase" + case .checkoutAndRebase: "Checkout and Rebase" + case .pullRebase: "Pull with Rebase" + case .pullMerge: "Pull with Merge" } } @@ -1924,6 +1979,12 @@ private enum GitBranchOperationKind { return "Merge \(reference.shortName) into the current branch. Conflicts may require terminal resolution." case .rebase: return "Replay the current branch onto \(reference.shortName). Conflicts may require terminal resolution." + case .checkoutAndRebase: + return "Checkout \(reference.shortName), then replay it onto the branch that is current now." + case .pullRebase: + return "Pull \(reference.shortName) into the current branch and replay local commits." + case .pullMerge: + return "Pull \(reference.shortName) into the current branch with a merge." } } } diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index d9346ab59..3aa40ad3d 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -1727,6 +1727,92 @@ package final class GitFeatureModel: ObservableObject { await startIntegration(.reference(reference), operation: .rebase) } + package func checkoutAndRebase(_ reference: GitReference) async { + guard let gitRepositoryRoot, reference.kind != .tag, !reference.isCurrent else { return } + isPerformingBranchOperation = true + let result = await withGitOperation { + await service.checkoutAndRebase(reference, at: gitRepositoryRoot) + } + isPerformingBranchOperation = false + await reportBranchOperation( + result, + success: "Checked out \(reference.shortName) and rebased it onto the previous branch" + ) + } + + package func pullRemoteReference( + _ reference: GitReference, + strategy: GitPullStrategy + ) async { + guard let gitRepositoryRoot, reference.kind == .remote else { return } + isPerformingBranchOperation = true + let preflight = await service.integrationPreflight( + for: .reference(reference), + operation: strategy == .rebase ? .rebase : .merge, + at: gitRepositoryRoot + ) + if let preflight, !preflight.isClear { + switch selectedSaveChangesPolicy { + case .stash: + let message = "Lithe auto-stash before pull" + let stashed = await recordingGitCommand { + await service.stash(message: message, includeUntracked: true, at: gitRepositoryRoot) + } + guard stashed.succeeded else { + isPerformingBranchOperation = false + notify?(trimmedMessage(stashed)) + return + } + let result = await withGitOperation { + await service.pullRemoteReference(reference, strategy: strategy, at: gitRepositoryRoot) + } + isPerformingBranchOperation = false + await refreshGit() + if gitOperationState?.hasConflicts == true { + if let stash = gitStashes.first(where: { $0.message.contains(message) }) { + deferredSavedChanges = GitDeferredSavedChanges(stashReference: stash.reference, operationTitle: "pull") + } + notify?("拉取产生冲突,改动已保留在暂存中") + return + } + if let stash = gitStashes.first(where: { $0.message.contains(message) }) { + let restored = await service.popStash(stash, at: gitRepositoryRoot) + if !restored.succeeded { notify?("恢复本地改动失败:\(trimmedMessage(restored))") } + } + await reportBranchOperation(result, success: strategy == .rebase ? "从远程分支变基拉取完成" : "从远程分支合并拉取完成") + return + case .shelve: + let capture = await captureAndCleanShelf(message: "Lithe shelf before pull", at: gitRepositoryRoot) + guard case .saved(let shelf) = capture else { + isPerformingBranchOperation = false + if case .failed(let message) = capture { notify?(message) } + return + } + let result = await withGitOperation { + await service.pullRemoteReference(reference, strategy: strategy, at: gitRepositoryRoot) + } + isPerformingBranchOperation = false + await refreshGit() + if gitOperationState?.hasConflicts == true { + deferredSavedChanges = GitDeferredSavedChanges(shelfID: shelf.id, operationTitle: "pull") + notify?("拉取产生冲突,改动已保留在搁置中") + return + } + if !(await restoreShelf(shelf, at: gitRepositoryRoot)) { + notify?("恢复搁置改动失败") + } + await reportBranchOperation(result, success: strategy == .rebase ? "从远程分支变基拉取完成" : "从远程分支合并拉取完成") + return + } + } + let result = await withGitOperation { + await service.pullRemoteReference(reference, strategy: strategy, at: gitRepositoryRoot) + } + isPerformingBranchOperation = false + let verb = strategy == .rebase ? "Rebased from" : "Merged from" + await reportBranchOperation(result, success: "\(verb) \(reference.shortName)") + } + /// Checks whether uncommitted changes would stop the operation before running /// it, so the user gets a choice instead of Git's localized refusal. private func startIntegration( diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index d65555716..2c5d23bca 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -73,7 +73,13 @@ package protocol GitOperations: Sendable { func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? func mergeBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? func rebaseCurrentBranch(onto reference: GitReference, at rootURL: URL) -> GitProcessResult? + func checkoutAndRebase(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy) -> GitProcessResult? + func pullRemoteReference( + _ reference: GitReference, + strategy: GitPullStrategy, + at rootURL: URL + ) -> GitProcessResult? func pullPreflight(at rootURL: URL) -> GitPullPreflightState? func conflictMarkerPaths(at rootURL: URL) -> [String] func integrationPreflight( @@ -507,6 +513,10 @@ package struct GitService: Sendable { await command(at: repositoryRoot) { $0.rebaseCurrentBranch(onto: reference, at: repositoryRoot) } } + func checkoutAndRebase(_ reference: GitReference, at repositoryRoot: URL) async -> CommandResult { + await command(at: repositoryRoot) { $0.checkoutAndRebase(reference, at: repositoryRoot) } + } + func updateCurrentBranch( at repositoryRoot: URL, strategy: GitPullStrategy = .ffOnly @@ -514,6 +524,16 @@ package struct GitService: Sendable { await command(at: repositoryRoot) { $0.updateCurrentBranch(at: repositoryRoot, strategy: strategy) } } + func pullRemoteReference( + _ reference: GitReference, + strategy: GitPullStrategy, + at repositoryRoot: URL + ) async -> CommandResult { + await command(at: repositoryRoot) { + $0.pullRemoteReference(reference, strategy: strategy, at: repositoryRoot) + } + } + func pullPreflight(at repositoryRoot: URL) async -> GitPullPreflightState? { await read { $0.pullPreflight(at: repositoryRoot) } } diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index f4f4d7b48..08906b84b 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -317,6 +317,39 @@ struct GitModuleTests { ]) } + @Test + func remoteReferenceActionsPreserveIdentityAndPullStrategy() async { + let root = URL(fileURLWithPath: "/workspace") + let reference = GitReference( + fullName: "refs/remotes/origin/feature/demo", + shortName: "origin/feature/demo", + kind: .remote, + isCurrent: false, + upstreamShortName: nil + ) + 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.checkoutAndRebase(reference) + await feature.pullRemoteReference(reference, strategy: .rebase) + await feature.pullRemoteReference(reference, strategy: .merge) + + #expect(feature.gitConsoleEntries.map(\.arguments) == [ + ["checkoutAndRebase", reference.fullName], + ["pull", "rebase", reference.fullName], + ["pull", "merge", reference.fullName] + ]) + } + @Test func postInvocationOperationErrorFailsWhileKeepingConsoleTrace() async { let root = URL(fileURLWithPath: "/workspace") @@ -1451,7 +1484,25 @@ private struct TestGitOperations: GitOperations { func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } func mergeBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } func rebaseCurrentBranch(onto reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } + func checkoutAndRebase(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { + GitProcessResult( + arguments: ["checkoutAndRebase", reference.fullName], + output: "", + exitCode: 0 + ) + } func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy) -> GitProcessResult? { nil } + func pullRemoteReference( + _ reference: GitReference, + strategy: GitPullStrategy, + at rootURL: URL + ) -> GitProcessResult? { + GitProcessResult( + arguments: ["pull", strategy.rawValue, reference.fullName], + output: "", + exitCode: 0 + ) + } func pullPreflight(at rootURL: URL) -> GitPullPreflightState? { nil } func conflictMarkerPaths(at rootURL: URL) -> [String] { [] } func integrationPreflight(for target: GitIntegrationTarget, operation: GitIntegrationOperation, at rootURL: URL) -> GitIntegrationPreflightState? { nil } diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index a3d28dc38..52bc8ce8c 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -1,5 +1,7 @@ //! Deterministic Git inspection and mutation behind the shared command contract. +mod mutations; + use crate::protocol::{CoreError, ErrorCode}; use crate::protocol::{ GitBlameLineResponse, GitBlameResponse, GitChange, GitCheckoutPreflightResponse, @@ -552,6 +554,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result return mutations::checkout_and_rebase(&root, request), "fetch" => arguments = vec!["fetch".into(), "--all".into(), "--prune".into()], // Strategy comes from the caller because only the user can decide whether a // divergent history should be merged or replayed. Absent a choice we stay on @@ -568,6 +571,17 @@ fn write_with_trace(request: GitWriteRequest) -> Result return push(&root, request.reference.as_deref()), "checkout" => return checkout(&root, request), @@ -626,7 +640,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result, @@ -634,7 +648,7 @@ fn execute_git( execute_git_with_options(root, arguments, input, false) } -fn execute_git_readonly( +pub(super) fn execute_git_readonly( root: &str, arguments: &[String], input: Option, @@ -772,6 +786,7 @@ pub fn diff(request: GitDiffRequest) -> Result { )); } + let include_untracked_with_reference = request.reference.is_some() && request.untracked; let mut arguments = if let Some(commit) = request.commit { validate_revision(&commit)?; vec![ @@ -810,13 +825,52 @@ pub fn diff(request: GitDiffRequest) -> Result { arguments.push("--ignore-all-space".to_string()); } arguments.push("--".to_string()); - if request.untracked { + if request.untracked && !include_untracked_with_reference { arguments.push(null_device().to_string()); } arguments.extend(request.pathspecs); let root = validate_root(&request.root)?; - let output = capture_git_with_options(&root, &arguments, None, true)?; + let mut output = capture_git_with_options(&root, &arguments, None, true)?; + if include_untracked_with_reference { + let status = readonly_command(GitCommandRequest { + root: root.clone(), + arguments: vec![ + "status".into(), + "--porcelain".into(), + "--untracked-files=all".into(), + ], + input: None, + })?; + if status.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git status failed") + .with_details(status.output), + ); + } + for line in status.output.lines().filter(|line| line.starts_with("?? ")) { + let path = line[3..].trim(); + if path.is_empty() || !is_safe_pathspec(path) { + continue; + } + let untracked = capture_git_with_options( + &root, + &[ + "diff".into(), + "--no-ext-diff".into(), + "--binary".into(), + "--no-index".into(), + "--".into(), + null_device().into(), + path.into(), + ], + None, + true, + )?; + output.stdout.extend(untracked.stdout); + output.stderr.extend(untracked.stderr); + } + } Ok(structured_diff_from_output(output)) } @@ -1748,7 +1802,7 @@ fn validated_revision(value: Option<&str>) -> Result { Ok(value) } -fn validated_reference(value: Option<&str>) -> Result { +pub(super) fn validated_reference(value: Option<&str>) -> Result { let value = required_text(value, "reference")?; if value.starts_with('-') || value.chars().any(char::is_whitespace) { return Err(CoreError::new( @@ -1805,7 +1859,7 @@ fn local_branch_name(reference: &str) -> Result { Ok(branch.to_string()) } -fn current_branch(root: &str) -> Result { +pub(super) fn current_branch(root: &str) -> Result { let response = execute_git(root, &["branch".into(), "--show-current".into()], None)?; if response.exit_code != 0 { return Err(CoreError::new( @@ -2105,12 +2159,29 @@ fn publish_branch(root: &str, name: Option<&str>) -> Result Result { + if request.reference_kind.as_deref() == Some("local") { + if let Some(reference) = request + .reference + .as_deref() + .filter(|value| !value.starts_with('-') && !value.chars().any(char::is_whitespace)) + { + if is_current_reference(root, reference)? { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The current branch is already checked out", + )); + } + } + } if request.auto_stash { return checkout_with_auto_stash(root, request); } switch_reference(root, &request) } +/// Checks out a local or remote branch, then rebases it onto the branch that +/// was current before the switch. A dirty tree is rejected before checkout so +/// the composite operation cannot leave the repository half-switched. /// Stash, switch, restore. A failed switch leaves the stash untouched so the caller can /// recover it, and a conflicting restore is reported as a failure rather than silently /// leaving the entry behind. @@ -2162,7 +2233,7 @@ fn checkout_with_auto_stash( Ok(restored) } -fn switch_reference( +pub(super) fn switch_reference( root: &str, request: &GitWriteRequest, ) -> Result { @@ -2185,13 +2256,8 @@ fn switch_reference( execute_git(root, &base, None) } Some("remote") => { - let remote_path = reference.strip_prefix("refs/remotes/").ok_or_else(|| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid remote branch name") - })?; - let (_, local_name) = remote_path.split_once('/').ok_or_else(|| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid remote branch name") - })?; - if !is_safe_pathspec(local_name) { + let (_, local_name) = mutations::remote_branch_components(root, &reference)?; + if !is_safe_pathspec(&local_name) { return Err(CoreError::new( ErrorCode::InvalidRequest, "Invalid remote branch name", @@ -2326,7 +2392,7 @@ fn parse_stash(line: &str) -> Option { }) } -fn is_safe_pathspec(path: &str) -> bool { +pub(super) fn is_safe_pathspec(path: &str) -> bool { let normalized = path.replace('\\', "/"); !normalized.is_empty() && !normalized.starts_with('/') diff --git a/rust/lithe-core/src/git/mutations.rs b/rust/lithe-core/src/git/mutations.rs new file mode 100644 index 000000000..f0d7e5063 --- /dev/null +++ b/rust/lithe-core/src/git/mutations.rs @@ -0,0 +1,101 @@ +//! Shared Git mutation helpers kept outside the command facade. + +use super::{capture_git_with_options, is_safe_pathspec}; +use crate::protocol::{CoreError, ErrorCode}; + +use super::{ + current_branch, execute_git, switch_reference, validated_reference, GitCommandResponse, + GitWriteRequest, +}; + +/// Checks out a branch and rebases it onto the branch that was current before the switch. +pub(super) fn checkout_and_rebase( + root: &str, + request: GitWriteRequest, +) -> Result { + if !matches!(request.reference_kind.as_deref(), Some("local" | "remote")) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Checkout and rebase requires a local or remote branch", + )); + } + let original_branch = current_branch(root)?; + let reference = validated_reference(request.reference.as_deref())?; + if reference == original_branch || reference == format!("refs/heads/{original_branch}") { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The current branch cannot be checked out and rebased onto itself", + )); + } + let status = execute_git( + root, + &[ + "status".into(), + "--porcelain".into(), + "--untracked-files=normal".into(), + ], + None, + )?; + if status.exit_code != 0 { + return Ok(status); + } + if !status.output.trim().is_empty() { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Checkout and rebase requires a clean working tree", + )); + } + let switched = switch_reference(root, &request)?; + if switched.exit_code != 0 { + return Ok(switched); + } + execute_git( + root, + &["rebase".into(), format!("refs/heads/{original_branch}")], + None, + ) +} + +pub(super) fn remote_branch_components( + root: &str, + reference: &str, +) -> Result<(String, String), CoreError> { + let remote_path = reference + .strip_prefix("refs/remotes/") + .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Invalid remote branch name"))?; + let remotes = capture_git_with_options(root, &["remote".into()], None, true)?; + let remote_output = String::from_utf8_lossy(&remotes.stdout).to_string(); + if remotes.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git remote lookup failed") + .with_details(String::from_utf8_lossy(&remotes.stderr).to_string()), + ); + } + let mut matches = remote_output + .lines() + .map(str::trim) + .filter(|remote| !remote.is_empty() && remote_path.starts_with(&format!("{remote}/"))) + .collect::>(); + matches.sort_by_key(|remote| std::cmp::Reverse(remote.len())); + let Some(remote) = matches.first().copied() else { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid remote branch name", + )); + }; + let branch = remote_path + .strip_prefix(&format!("{remote}/")) + .unwrap_or_default(); + if branch.is_empty() + || remote.starts_with('-') + || branch.starts_with('-') + || !is_safe_pathspec(remote) + || !is_safe_pathspec(branch) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Invalid remote branch name", + )); + } + Ok((remote.to_string(), branch.to_string())) +} diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index ac96c912b..d1bcaf19f 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -197,6 +197,7 @@ fn git_write_validates_and_executes_shared_mutations() { .status .success()); assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + assert!(run(&["remote", "add", "origin", "."]).status.success()); fs::write(root.join("example.txt"), "initial\n").expect("file should be writable"); let request = |operation: &str, payload: Value| -> Value { @@ -386,6 +387,79 @@ fn git_write_validates_and_executes_shared_mutations() { ); assert!(run(&["switch", ¤t]).status.success()); + let repeated_checkout = request( + "checkout", + serde_json::json!({ + "reference": format!("refs/heads/{current}"), + "referenceKind": "local" + }), + ); + assert_eq!( + repeated_checkout["data"]["operationError"]["code"], "invalid_request", + "{repeated_checkout:?}" + ); + + assert!(run(&["branch", "feature/rebase"]).status.success()); + fs::write(root.join("rebase.txt"), "new base\n").expect("file should be writable"); + assert!(run(&["add", "rebase.txt"]).status.success()); + assert!(run(&["commit", "-qm", "new base"]).status.success()); + let checkout_and_rebase = request( + "checkoutAndRebase", + serde_json::json!({ + "reference": "refs/heads/feature/rebase", + "referenceKind": "local" + }), + ); + assert_eq!(checkout_and_rebase["ok"], true, "{checkout_and_rebase:?}"); + assert_eq!( + checkout_and_rebase["data"]["exitCode"], 0, + "{checkout_and_rebase:?}" + ); + assert_eq!( + String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), + "feature/rebase" + ); + assert!(run(&["merge-base", "--is-ancestor", ¤t, "HEAD"]) + .status + .success()); + assert!(run(&["switch", ¤t]).status.success()); + + fs::write(root.join("dirty.txt"), "keep me\n").expect("file should be writable"); + let dirty_checkout_and_rebase = request( + "checkoutAndRebase", + serde_json::json!({ + "reference": "refs/heads/feature/rebase", + "referenceKind": "local" + }), + ); + assert_eq!( + dirty_checkout_and_rebase["ok"], true, + "{dirty_checkout_and_rebase:?}" + ); + assert_eq!( + dirty_checkout_and_rebase["data"]["operationError"]["code"], "invalid_request", + "{dirty_checkout_and_rebase:?}" + ); + assert_eq!( + String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), + current + ); + fs::remove_file(root.join("dirty.txt")).expect("file should be removable"); + + let explicit_pull = request( + "pull", + serde_json::json!({ + "reference": "refs/remotes/origin/feature/core", + "referenceKind": "remote", + "mode": "rebase" + }), + ); + assert_eq!(explicit_pull["ok"], true, "{explicit_pull:?}"); + assert_eq!( + explicit_pull["data"]["arguments"], + serde_json::json!(["pull", "--rebase", "--", "origin", "feature/core"]) + ); + fs::write(root.join("example.txt"), "working tree\n").expect("file should be writable"); let stash = request( "stashPush", @@ -1678,3 +1752,94 @@ fn git_pull_preflight_reports_divergence_and_strategies_resolve_it() { fs::remove_dir_all(root).expect("Git fixture should be removable"); } + +#[test] +fn explicit_pull_resolves_nested_remote_and_branch_names_against_bare_remote() { + let root = temporary_root("git-pull-nested-ref"); + let source = root.join("source"); + let work = root.join("work"); + fs::create_dir_all(&source).expect("source should be creatable"); + let git = |directory: &Path, arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(directory) + .output() + .expect("git should be available") + }; + assert!(git(&source, &["init", "--bare", "-q"]).status.success()); + let seed = root.join("seed"); + fs::create_dir_all(&seed).expect("seed should be creatable"); + assert!(git(&seed, &["init", "-q", "-b", "main"]).status.success()); + assert!(git(&seed, &["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(git(&seed, &["config", "user.name", "Lithe Test"]) + .status + .success()); + fs::write(seed.join("base.txt"), "base\n").expect("file should be writable"); + assert!(git(&seed, &["add", "."]).status.success()); + assert!(git(&seed, &["commit", "-qm", "base"]).status.success()); + assert!(git( + &seed, + &[ + "remote", + "add", + "company/origin", + source.to_string_lossy().as_ref() + ] + ) + .status + .success()); + assert!(git(&seed, &["push", "-q", "company/origin", "main"]) + .status + .success()); + assert!(git(&seed, &["switch", "-c", "feature/core"]) + .status + .success()); + fs::write(seed.join("nested.txt"), "nested\n").expect("file should be writable"); + assert!(git(&seed, &["add", "."]).status.success()); + assert!(git(&seed, &["commit", "-qm", "nested"]).status.success()); + assert!( + git(&seed, &["push", "-q", "company/origin", "feature/core"]) + .status + .success() + ); + assert!(git( + &root, + &[ + "clone", + "-q", + seed.to_string_lossy().as_ref(), + work.to_string_lossy().as_ref() + ] + ) + .status + .success()); + assert!(git(&work, &["remote", "remove", "origin"]).status.success()); + assert!(git( + &work, + &[ + "remote", + "add", + "company/origin", + source.to_string_lossy().as_ref() + ] + ) + .status + .success()); + let response: Value = serde_json::from_str(&execute_json(&serde_json::to_string(&serde_json::json!({ + "id": "nested-pull", "command": "git.write", "payload": { + "root": work, "operation": "pull", "reference": "refs/remotes/company/origin/feature/core", + "referenceKind": "remote", "mode": "rebase" + } + })).expect("request should encode"))).expect("response should be JSON"); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["data"]["exitCode"], 0, "{response}"); + assert_eq!( + fs::read_to_string(work.join("nested.txt")) + .expect("file should exist") + .replace("\r\n", "\n"), + "nested\n" + ); + fs::remove_dir_all(root).expect("fixture should be removable"); +} diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 27a6da015..33dcef256 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -218,6 +218,7 @@ response retains the invocation trace and includes the failure as `git.write` accepts a typed mutation request. Its required `operation` values are `stage`, `unstage`, `discard`, `discardAll`, `stageAll`, `commit`, `cherryPick`, `revert`, `reset`, `createBranch`, `publishBranch`, `renameBranch`, `deleteBranch`, `merge`, `rebase`, +`checkoutAndRebase`, `fetch`, `pull`, `push`, `checkout`, `checkoutRevision`, `clone`, `stashPush`, `stashApply`, `stashPop`, `stashDrop`, `operationContinue`, `operationAbort`, and `operationSkip`. Optional fields are `paths`, `reference`, `referenceKind`, @@ -250,6 +251,17 @@ and checks out that branch at a detached HEAD when needed, then pushes it with an upstream. If the push fails, the local branch is intentionally retained so the user can fix credentials or connectivity and retry without losing commits. +`checkoutAndRebase` accepts a complete local or remote `reference` plus its +`referenceKind`. Core records the current local branch, rejects any dirty +working tree before switching, checks out the selected branch, and rebases it +onto the original branch. Tags and the current local branch are rejected. + +`pull` without a reference continues to use the current branch's configured +upstream. When `reference` is present, it must be a complete +`refs/remotes//` reference with `referenceKind: "remote"`; +Core safely splits it into structured remote and branch arguments and applies +the requested `ffOnly`, `merge`, or `rebase` strategy. + `operationContinue`, `operationAbort`, and `operationSkip` inspect Git metadata 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 diff --git a/shared/fixtures/git/write.json b/shared/fixtures/git/write.json index e15f0e99b..1740d6ba5 100644 --- a/shared/fixtures/git/write.json +++ b/shared/fixtures/git/write.json @@ -29,6 +29,21 @@ "referenceKind": "local" } }, + { + "operation": "checkoutAndRebase", + "payload": { + "reference": "refs/remotes/origin/feature/core", + "referenceKind": "remote" + } + }, + { + "operation": "pull", + "payload": { + "reference": "refs/remotes/origin/feature/core", + "referenceKind": "remote", + "mode": "rebase" + } + }, { "operation": "stashPush", "payload": { @@ -52,6 +67,15 @@ "paths": ["../outside.txt"] }, "errorCode": "invalid_request" + }, + { + "operation": "pull", + "payload": { + "reference": "refs/heads/main", + "referenceKind": "local", + "mode": "merge" + }, + "errorCode": "invalid_request" } ] } diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index 2588249e3..09bcb0105 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -157,6 +157,13 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> { payload.insert("pathspecs".into(), json!(["."])); "git.diff" } + "git_reference_worktree_diff" => { + let reference = take_reference(&mut payload)?; + payload.insert("reference".into(), json!(reference)); + payload.insert("pathspecs".into(), json!(["."])); + payload.insert("untracked".into(), json!(true)); + "git.diff" + } "git_stash_diff" => { let index = payload .remove("stashIndex") @@ -168,6 +175,16 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> { } "git_create_branch" => { move_field(&mut payload, "branchName", "name"); + if !payload.contains_key("reference") { + if let Some(from_branch) = payload.remove("fromBranch") { + let branch = from_branch + .as_str() + .filter(|value| !value.trim().is_empty()) + .map(local_branch_reference) + .unwrap_or_else(|| "HEAD".to_string()); + payload.insert("reference".into(), json!(branch)); + } + } payload.insert("operation".into(), json!("createBranch")); payload.entry("reference").or_insert_with(|| json!("HEAD")); "git.write" @@ -179,20 +196,33 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> { "git.write" } "git_checkout" => { - let branch = take_text(&mut payload, "branchName")?; - payload.insert("reference".into(), json!(local_branch_reference(&branch))); + let reference = take_reference(&mut payload)?; + let reference_kind = reference_kind(&reference); + payload.insert("reference".into(), json!(reference)); payload.insert("operation".into(), json!("checkout")); - payload.insert("referenceKind".into(), json!("local")); + payload + .entry("referenceKind") + .or_insert_with(|| json!(reference_kind)); + "git.write" + } + "git_checkout_and_rebase" => { + let reference = take_reference(&mut payload)?; + let reference_kind = reference_kind(&reference); + payload.insert("reference".into(), json!(reference)); + payload.insert("operation".into(), json!("checkoutAndRebase")); + payload + .entry("referenceKind") + .or_insert_with(|| json!(reference_kind)); "git.write" } "git_checkout_preflight" => { - let branch = take_text(&mut payload, "branchName")?; - payload.insert("reference".into(), json!(local_branch_reference(&branch))); + let reference = take_reference(&mut payload)?; + payload.insert("reference".into(), json!(reference)); "git.checkoutPreflight" } "git_merge" | "git_rebase" => { - let branch = take_text(&mut payload, "branchName")?; - payload.insert("reference".into(), json!(local_branch_reference(&branch))); + let reference = take_reference(&mut payload)?; + payload.insert("reference".into(), json!(reference)); payload.insert( "operation".into(), json!(if command == "git_merge" { @@ -204,8 +234,8 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> { "git.write" } "git_integration_preflight" => { - let branch = take_text(&mut payload, "branchName")?; - payload.insert("reference".into(), json!(local_branch_reference(&branch))); + let reference = take_reference(&mut payload)?; + payload.insert("reference".into(), json!(reference)); "git.integrationPreflight" } "git_operation_state" => "git.operationState", @@ -490,6 +520,27 @@ fn local_branch_reference(branch: &str) -> String { } } +fn take_reference(payload: &mut Map) -> Result { + if let Some(reference) = payload.remove("reference") { + return reference + .as_str() + .map(str::to_string) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "Windows platform command requires reference".to_string()); + } + take_text(payload, "branchName").map(|branch| local_branch_reference(&branch)) +} + +fn reference_kind(reference: &str) -> &'static str { + if reference.starts_with("refs/remotes/") { + "remote" + } else if reference.starts_with("refs/tags/") { + "tag" + } else { + "local" + } +} + fn paths_from_file(payload: &mut Map) { if let Some(path) = payload.remove("filePath") { payload.insert("paths".into(), Value::Array(vec![path])); @@ -584,6 +635,68 @@ mod tests { ); } + #[test] + fn preserves_complete_references_for_git_log_actions() { + let (checkout_command, checkout_payload) = translate( + "git_checkout", + json!({ + "repoPath": "C:/work", + "reference": "refs/remotes/origin/feature/demo", + "referenceKind": "remote" + }), + ) + .unwrap(); + assert_eq!(checkout_command, "git.write"); + assert_eq!( + checkout_payload, + json!({ + "root": "C:/work", + "operation": "checkout", + "reference": "refs/remotes/origin/feature/demo", + "referenceKind": "remote" + }) + ); + + let (rebase_command, rebase_payload) = translate( + "git_checkout_and_rebase", + json!({ + "repoPath": "C:/work", + "reference": "refs/remotes/origin/feature/demo", + "referenceKind": "remote" + }), + ) + .unwrap(); + assert_eq!(rebase_command, "git.write"); + assert_eq!( + rebase_payload, + json!({ + "root": "C:/work", + "operation": "checkoutAndRebase", + "reference": "refs/remotes/origin/feature/demo", + "referenceKind": "remote" + }) + ); + + let (diff_command, diff_payload) = translate( + "git_reference_worktree_diff", + json!({ + "repoPath": "C:/work", + "reference": "refs/remotes/origin/feature/demo" + }), + ) + .unwrap(); + assert_eq!(diff_command, "git.diff"); + assert_eq!( + diff_payload, + json!({ + "root": "C:/work", + "reference": "refs/remotes/origin/feature/demo", + "pathspecs": ["."], + "untracked": true + }) + ); + } + #[test] fn translates_checkout_preflight_reference() { let (command, payload) = translate( @@ -630,6 +743,20 @@ mod tests { "reference": "refs/heads/main" }) ); + + let (_, remote_merge_payload) = translate( + "git_merge", + json!({ + "repoPath": "C:/work", + "reference": "refs/remotes/origin/feature/demo", + "referenceKind": "remote" + }), + ) + .unwrap(); + assert_eq!( + remote_merge_payload["reference"], + "refs/remotes/origin/feature/demo" + ); } #[test] diff --git a/windows/tauri/src/features/git/api/git-branches-api.ts b/windows/tauri/src/features/git/api/git-branches-api.ts index 670fe7b67..deacd3f8e 100644 --- a/windows/tauri/src/features/git/api/git-branches-api.ts +++ b/windows/tauri/src/features/git/api/git-branches-api.ts @@ -6,6 +6,7 @@ import { resolveRepositoryPath, resolveRepositoryPathOrThrow, } from "./git-repo-api"; +import type { GitReference } from "../types/git.types"; interface CheckoutResult { success: boolean; @@ -51,13 +52,26 @@ export const getBranches = async (repoPath: string): Promise => { export const checkoutBranch = async ( repoPath: string, branchName: string, +): Promise => { + const shortName = branchName.replace(/^refs\/heads\//, ""); + return checkoutReference(repoPath, { + fullName: branchName.startsWith("refs/heads/") ? branchName : `refs/heads/${branchName}`, + shortName, + kind: "local", + isCurrent: false, + }); +}; + +export const checkoutReference = async ( + repoPath: string, + reference: GitReference, ): Promise => { try { const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); const preflight = await tauriInvoke("git_checkout_preflight", { repoPath: resolvedRepoPath, - branchName, + reference: reference.fullName, }); if (preflight.blocked) { return { @@ -69,7 +83,8 @@ export const checkoutBranch = async ( const result = await tauriInvoke("git_checkout", { repoPath: resolvedRepoPath, - branchName, + reference: reference.fullName, + referenceKind: reference.kind, }); if (result.success) { emitGitChanged({ @@ -92,14 +107,18 @@ export const checkoutBranch = async ( export const createBranch = async ( repoPath: string, branchName: string, - fromBranch?: string, + from?: string | GitReference, ): Promise => { try { const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); await tauriInvoke("git_create_branch", { repoPath: resolvedRepoPath, branchName, - fromBranch, + ...(typeof from === "string" + ? { fromBranch: from } + : from + ? { reference: from.fullName, referenceKind: from.kind } + : {}), }); emitGitChanged({ repoPath: resolvedRepoPath, diff --git a/windows/tauri/src/features/git/api/git-diff-api.ts b/windows/tauri/src/features/git/api/git-diff-api.ts index dc4e68ee5..6be3f41dc 100644 --- a/windows/tauri/src/features/git/api/git-diff-api.ts +++ b/windows/tauri/src/features/git/api/git-diff-api.ts @@ -353,6 +353,26 @@ export const getRefDiff = async ( } }; +export const getReferenceWorkingTreeDiff = async ( + repoPath: string, + reference: string, +): Promise => { + try { + const resolvedRepoPath = await resolveRepositoryPath(repoPath); + if (!resolvedRepoPath) return null; + return await runGitRead(resolvedRepoPath, `reference-worktree-diff:${reference}`, () => + tauriInvoke("git_reference_worktree_diff", { + repoPath: resolvedRepoPath, + reference, + }), + ); + } catch (error) { + if (isNotGitRepositoryError(error)) return null; + console.error("Failed to compare reference with working tree:", error); + throw error; + } +}; + export const getStashDiff = async ( repoPath: string, stashIndex: number, diff --git a/windows/tauri/src/features/git/api/git-integration-api.test.ts b/windows/tauri/src/features/git/api/git-integration-api.test.ts index 2fc94359c..c9b9ddbc3 100644 --- a/windows/tauri/src/features/git/api/git-integration-api.test.ts +++ b/windows/tauri/src/features/git/api/git-integration-api.test.ts @@ -7,9 +7,14 @@ const emitGitChanged = spyOn(gitEvents, "emitGitChanged"); mock.module("@/platform/tauri-core", () => ({ invoke })); -const { getConflictMarkerPaths, getOperationState, mergeBranch, rebaseOntoBranch } = await import( - "./git-integration-api" -); +const { + checkoutAndRebase, + getConflictMarkerPaths, + getOperationState, + mergeBranch, + pullRemoteReference, + rebaseOntoBranch, +} = await import("./git-integration-api"); const operationState = ( kind: GitOperationState["kind"], @@ -28,6 +33,35 @@ beforeEach(() => { }); describe("Git integration state", () => { + test("preserves complete remote references for composite operations", async () => { + invoke.mockImplementation(async (command: string) => { + if (command === "git_discover_repo") return "C:/repo"; + return null; + }); + const reference = { + fullName: "refs/remotes/origin/feature/demo", + shortName: "origin/feature/demo", + kind: "remote" as const, + isCurrent: false, + }; + + await expect(checkoutAndRebase("C:/repo", reference)).resolves.toEqual({ status: "clean" }); + await expect(pullRemoteReference("C:/repo", reference, "merge")).resolves.toEqual({ + status: "clean", + }); + expect(invoke).toHaveBeenCalledWith("git_checkout_and_rebase", { + repoPath: "C:/repo", + reference: reference.fullName, + referenceKind: "remote", + }); + expect(invoke).toHaveBeenCalledWith("git_pull", { + repoPath: "C:/repo", + reference: reference.fullName, + referenceKind: "remote", + mode: "merge", + }); + }); + test("reports a stopped rebase even when no conflicted paths remain", async () => { invoke.mockImplementation(async (command: string) => { if (command === "git_discover_repo") return "C:/repo"; diff --git a/windows/tauri/src/features/git/api/git-integration-api.ts b/windows/tauri/src/features/git/api/git-integration-api.ts index 2fea725ec..d692d5700 100644 --- a/windows/tauri/src/features/git/api/git-integration-api.ts +++ b/windows/tauri/src/features/git/api/git-integration-api.ts @@ -1,7 +1,7 @@ import { invoke as tauriInvoke } from "@/platform/tauri-core"; import { emitGitChanged } from "../events/git-events"; import { resolveRepositoryPathOrThrow } from "./git-repo-api"; -import type { GitOperationState } from "../types/git.types"; +import type { GitOperationState, GitReference, PullStrategy } from "../types/git.types"; type IntegrationOperation = "merge" | "rebase"; @@ -54,12 +54,17 @@ export const getConflictMarkerPaths = async (repoPath: string): Promise => { const command = operation === "merge" ? "git_merge" : "git_rebase"; try { - await tauriInvoke(command, { repoPath, branchName }); + await tauriInvoke(command, { + repoPath, + ...(typeof reference === "string" + ? { branchName: reference } + : { reference: reference.fullName, referenceKind: reference.kind }), + }); notifyOperationChanged(repoPath, `${operation}-completed`); return { status: "clean" }; } catch (error) { @@ -83,7 +88,7 @@ const runIntegration = async ( const startIntegration = async ( repoPath: string, - branchName: string, + reference: string | GitReference, operation: IntegrationOperation, ): Promise => { const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); @@ -92,7 +97,13 @@ const startIntegration = async ( try { preflight = await tauriInvoke( "git_integration_preflight", - { repoPath: resolvedRepoPath, branchName, operation }, + { + repoPath: resolvedRepoPath, + operation, + ...(typeof reference === "string" + ? { branchName: reference } + : { reference: reference.fullName, referenceKind: reference.kind }), + }, ); } catch { // A failed preflight must not block the operation itself; Git will still @@ -107,14 +118,84 @@ const startIntegration = async ( }; } - return runIntegration(resolvedRepoPath, branchName, operation); + return runIntegration(resolvedRepoPath, reference, operation); }; -export const mergeBranch = (repoPath: string, branchName: string) => - startIntegration(repoPath, branchName, "merge"); +export const mergeBranch = (repoPath: string, reference: string | GitReference) => + startIntegration(repoPath, reference, "merge"); -export const rebaseOntoBranch = (repoPath: string, branchName: string) => - startIntegration(repoPath, branchName, "rebase"); +export const rebaseOntoBranch = (repoPath: string, reference: string | GitReference) => + startIntegration(repoPath, reference, "rebase"); + +export const checkoutAndRebase = async ( + repoPath: string, + reference: GitReference, +): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + try { + await tauriInvoke("git_checkout_and_rebase", { + repoPath: resolvedRepoPath, + reference: reference.fullName, + referenceKind: reference.kind, + }); + notifyOperationChanged(resolvedRepoPath, "checkout-and-rebase-completed"); + return { status: "clean" }; + } catch (error) { + notifyOperationChanged(resolvedRepoPath, "checkout-and-rebase-rejected"); + const state = await getOperationState(resolvedRepoPath).catch(() => null); + if (state?.kind === "rebase") { + return state.conflictedPaths.length + ? { status: "conflicts", conflictedPaths: state.conflictedPaths } + : { status: "stopped" }; + } + return { status: "error", message: errorMessage(error) }; + } +}; + +export const pullRemoteReference = async ( + repoPath: string, + reference: GitReference, + strategy: Extract, +): Promise => { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + let preflight: IntegrationPreflightResult | null = null; + try { + preflight = await tauriInvoke("git_integration_preflight", { + repoPath: resolvedRepoPath, + operation: strategy, + reference: reference.fullName, + referenceKind: reference.kind, + }); + } catch { + // Git remains the final authority if a read-only preflight is unavailable. + } + if (preflight && preflight.blockingPaths.length > 0) { + return { + status: "blocked", + blockingPaths: preflight.blockingPaths, + blocksEntirely: preflight.blocksEntirely, + }; + } + try { + await tauriInvoke("git_pull", { + repoPath: resolvedRepoPath, + reference: reference.fullName, + referenceKind: reference.kind, + mode: strategy, + }); + notifyOperationChanged(resolvedRepoPath, `pull-${strategy}-completed`); + return { status: "clean" }; + } catch (error) { + notifyOperationChanged(resolvedRepoPath, `pull-${strategy}-rejected`); + const state = await getOperationState(resolvedRepoPath).catch(() => null); + if (state?.kind === strategy) { + return state.conflictedPaths.length + ? { status: "conflicts", conflictedPaths: state.conflictedPaths } + : { status: "stopped" }; + } + return { status: "error", message: errorMessage(error) }; + } +}; const resolveOperation = async ( repoPath: string, diff --git a/windows/tauri/src/features/git/components/git-view.tsx b/windows/tauri/src/features/git/components/git-view.tsx index e6e15aa3e..61503fb7b 100644 --- a/windows/tauri/src/features/git/components/git-view.tsx +++ b/windows/tauri/src/features/git/components/git-view.tsx @@ -7,7 +7,7 @@ import { DotsThreeIcon as MoreHorizontal, FolderSimpleStarIcon as FolderSimpleStar, GitBranchIcon as GitBranch, - ArrowClockwiseIcon as RefreshCw, + RefreshIcon as RefreshCw, TrashIcon as Trash2, UploadIcon as Upload, } from "@/ui/icons"; diff --git a/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx b/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx index 71867bdf6..74943aa47 100644 --- a/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx +++ b/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/ui/button"; +import { showConfirmDialog, showPromptDialog } from "@/ui/dialog"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/ui/resizable"; import { tryWriteClipboardText } from "@/utils/clipboard"; import { useTranslation } from "@/i18n/locale-provider"; @@ -8,9 +9,18 @@ import { useProjectStore } from "@/features/window/stores/project.store"; import { useUIState } from "@/features/window/stores/ui-state.store"; import { useGitLogController } from "../../hooks/use-git-log-controller"; import { useGitDiffActions } from "../../hooks/use-git-diff-actions"; +import { checkoutReference, createBranch } from "../../api/git-branches-api"; +import { createStash, getStashes, popStash } from "../../api/git-stash-api"; +import { + checkoutAndRebase, + mergeBranch, + pullRemoteReference, + rebaseOntoBranch, + type IntegrationOutcome, +} from "../../api/git-integration-api"; import { useGitLogPreferencesStore } from "../../stores/git-log-preferences.store"; import { useRepositoryStore } from "../../stores/git-repository.store"; -import type { GitCommit, GitFile } from "../../types/git.types"; +import type { GitCommit, GitFile, GitReference } from "../../types/git.types"; import type { WorkingTreeDiffEntry, WorkingTreeDiffScope, @@ -18,7 +28,7 @@ import type { import { GitCommitInspector } from "./git-commit-inspector"; import { GitCommitTable } from "./git-commit-table"; import { GitLogTitleBar } from "./git-log-title-bar"; -import { GitReferenceTree } from "./git-reference-tree"; +import { GitReferenceTree, type GitReferenceAction } from "./git-reference-tree"; export function GitLogToolWindow() { const { t } = useTranslation(); @@ -37,6 +47,7 @@ export function GitLogToolWindow() { loadMore, } = useGitLogController(repoPath); const [selectedCommit, setSelectedCommit] = useState(null); + const [isReferenceOperating, setIsReferenceOperating] = useState(false); const mainPanelLayout = useGitLogPreferencesStore.use.mainPanelLayout(); const { setMainPanelLayout } = useGitLogPreferencesStore.use.actions(); const currentReference = useMemo( @@ -56,7 +67,13 @@ export function GitLogToolWindow() { [], ); const emptyGitFileByPath = useMemo(() => new Map(), []); - const { isLoadingCommitDiff, isLoadingBranchDiff, viewCommitDiff, viewBranchDiff } = + const { + isLoadingCommitDiff, + isLoadingBranchDiff, + viewCommitDiff, + viewBranchDiff, + viewReferenceWorkingTreeDiff, + } = useGitDiffActions({ activeRepoPath: repoPath, gitFileByPath: emptyGitFileByPath, @@ -65,6 +82,102 @@ export function GitLogToolWindow() { currentBranch: currentReference?.shortName, }); + const reportIntegration = (outcome: IntegrationOutcome, success: string) => { + if (outcome.status === "clean") toast.success(success); + else if (outcome.status === "conflicts") { + toast.warning(t("git.log.operationConflicts", { count: outcome.conflictedPaths.length })); + } else if (outcome.status === "stopped") toast.warning(t("git.log.operationStopped")); + else if (outcome.status === "blocked") { + toast.error(t("git.log.operationBlocked", { paths: outcome.blockingPaths.join(", ") })); + } else toast.error(outcome.message); + }; + + const runReferenceAction = async ( + reference: GitReference, + action: GitReferenceAction, + ) => { + if (!repoPath || isReferenceOperating) return; + if (action === "compareWithCurrent") { + await viewBranchDiff(reference.fullName); + return; + } + if (action === "showWorkingTreeDiff") { + await viewReferenceWorkingTreeDiff(reference.fullName); + return; + } + if (action === "createBranch") { + const name = await showPromptDialog(t("git.log.branchNamePrompt"), { + title: t("git.log.createBranchFromTitle", { reference: reference.shortName }), + }); + if (!name?.trim()) return; + setIsReferenceOperating(true); + const created = await createBranch(repoPath, name.trim(), reference); + setIsReferenceOperating(false); + created ? toast.success(t("git.log.branchCreated", { name: name.trim() })) : toast.error(t("git.log.branchCreateFailed")); + if (created) await refresh(); + return; + } + + const confirmed = await showConfirmDialog( + t("git.log.confirmAction", { + action: t(`git.log.action.${action}`), + reference: reference.shortName, + }), + { title: t(`git.log.action.${action}`) }, + ); + if (!confirmed) return; + setIsReferenceOperating(true); + try { + if (action === "checkout") { + const result = await checkoutReference(repoPath, reference); + result.success ? toast.success(result.message) : toast.error(result.message); + } else { + const outcome = + action === "checkoutAndRebase" + ? await checkoutAndRebase(repoPath, reference) + : action === "rebaseCurrent" + ? await rebaseOntoBranch(repoPath, reference) + : action === "mergeCurrent" + ? await mergeBranch(repoPath, reference) + : await pullRemoteReference( + repoPath, + reference, + action === "pullRebase" ? "rebase" : "merge", + ); + if (outcome.status === "blocked" && (action === "pullRebase" || action === "pullMerge")) { + const save = await showConfirmDialog( + t("git.log.operationBlocked", { paths: outcome.blockingPaths.join(", ") }), + { title: t("git.stashChanges") }, + ); + if (save) { + const before = await getStashes(repoPath); + if (!await createStash(repoPath, "Lithe auto-stash before pull", true)) { + toast.error(t("git.stashFailed")); + } else { + const retry = await pullRemoteReference(repoPath, reference, action === "pullRebase" ? "rebase" : "merge"); + reportIntegration( + retry, + t("git.log.actionSucceeded", { + action: t(`git.log.action.${action}`), + reference: reference.shortName, + }), + ); + if (retry.status === "clean") { + const after = await getStashes(repoPath); + if (after.length > before.length) await popStash(repoPath, after[0].index); + } + } + } + } else { + reportIntegration(outcome, t("git.log.actionSucceeded", { action: t(`git.log.action.${action}`), reference: reference.shortName })); + } + } + await refresh(); + } finally { + setIsReferenceOperating(false); + } + }; + useEffect(() => { setSelectedCommit((current) => { if (current && commitByHash.has(current.hash)) return commitByHash.get(current.hash) ?? null; @@ -162,6 +275,8 @@ export function GitLogToolWindow() { setSelectedCommit(null); selectReference(reference); }} + onAction={(reference, action) => void runReferenceAction(reference, action)} + isOperating={isReferenceOperating} /> diff --git a/windows/tauri/src/features/git/components/log/git-reference-tree.test.ts b/windows/tauri/src/features/git/components/log/git-reference-tree.test.ts new file mode 100644 index 000000000..f1b92a59c --- /dev/null +++ b/windows/tauri/src/features/git/components/log/git-reference-tree.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; +import type { GitReference } from "../../types/git.types"; +import { getGitReferenceActions } from "./git-reference-tree"; + +const reference = ( + kind: GitReference["kind"], + isCurrent = false, +): GitReference => ({ + fullName: + kind === "local" + ? "refs/heads/main" + : kind === "remote" + ? "refs/remotes/origin/feature" + : "refs/tags/v1.0.0", + shortName: kind === "remote" ? "origin/feature" : kind === "tag" ? "v1.0.0" : "main", + kind, + isCurrent, +}); + +describe("Git reference context actions", () => { + test("offers remote integration and pull actions", () => { + expect(getGitReferenceActions(reference("remote"))).toEqual([ + "checkout", + "createBranch", + "showWorkingTreeDiff", + "compareWithCurrent", + "checkoutAndRebase", + "rebaseCurrent", + "mergeCurrent", + "pullRebase", + "pullMerge", + ]); + }); + + test("keeps tags to applicable non-integration actions", () => { + expect(getGitReferenceActions(reference("tag"))).toEqual([ + "checkout", + "createBranch", + "showWorkingTreeDiff", + "compareWithCurrent", + ]); + }); + + test("does not offer self operations for the current branch", () => { + expect(getGitReferenceActions(reference("local", true))).toEqual([ + "createBranch", + "showWorkingTreeDiff", + ]); + }); +}); diff --git a/windows/tauri/src/features/git/components/log/git-reference-tree.tsx b/windows/tauri/src/features/git/components/log/git-reference-tree.tsx index fc76b9f57..1813dd6a9 100644 --- a/windows/tauri/src/features/git/components/log/git-reference-tree.tsx +++ b/windows/tauri/src/features/git/components/log/git-reference-tree.tsx @@ -9,6 +9,13 @@ import { import { useMemo } from "react"; import { cn } from "@/utils/cn"; import { useTranslation } from "@/i18n/locale-provider"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/ui/context-menu"; import { useGitLogPreferencesStore } from "../../stores/git-log-preferences.store"; import type { GitReference, GitReferenceKind } from "../../types/git.types"; import { buildGitReferenceTree, type GitReferenceTreeNode } from "../../utils/git-reference-tree"; @@ -19,6 +26,28 @@ const SECTION_KEYS: Array<{ kind: GitReferenceKind; titleKey: string }> = [ { kind: "tag", titleKey: "git.log.tags" }, ]; +export type GitReferenceAction = + | "checkout" + | "createBranch" + | "checkoutAndRebase" + | "compareWithCurrent" + | "showWorkingTreeDiff" + | "rebaseCurrent" + | "mergeCurrent" + | "pullRebase" + | "pullMerge"; + +export function getGitReferenceActions(reference: GitReference): GitReferenceAction[] { + const actions: GitReferenceAction[] = ["createBranch", "showWorkingTreeDiff"]; + if (!reference.isCurrent) actions.unshift("checkout"); + if (!reference.isCurrent) actions.push("compareWithCurrent"); + if (reference.kind !== "tag" && !reference.isCurrent) { + actions.push("checkoutAndRebase", "rebaseCurrent", "mergeCurrent"); + } + if (reference.kind === "remote") actions.push("pullRebase", "pullMerge"); + return actions; +} + function ReferenceIcon({ kind }: { kind: GitReferenceKind }) { if (kind === "tag") return ; if (kind === "remote") return ; @@ -33,6 +62,8 @@ function ReferenceNode({ collapsedGroups, onToggleGroup, onSelect, + onAction, + isOperating, }: { node: GitReferenceTreeNode; kind: GitReferenceKind; @@ -41,15 +72,16 @@ function ReferenceNode({ collapsedGroups: Set; onToggleGroup: (id: string) => void; onSelect: (reference: GitReference) => void; + onAction: (reference: GitReference, action: GitReferenceAction) => void; + isOperating: boolean; }) { const { t } = useTranslation(); const isGroup = node.children.length > 0; const isCollapsed = collapsedGroups.has(node.id); const left = 10 + depth * 14; - return ( - <> -
{node.name} -
+ + ); + const reference = node.reference; + const actions = reference ? new Set(getGitReferenceActions(reference)) : null; + + return ( + <> + {reference ? ( + + onSelect(reference)}> + {row} + + + {actions?.has("checkout") ? ( + onAction(reference, "checkout")}> + {t("git.log.action.checkout")} + + ) : null} + onAction(reference, "createBranch")}> + {t("git.log.action.createBranch")} + + {actions?.has("checkoutAndRebase") ? ( + onAction(reference, "checkoutAndRebase")}> + {t("git.log.action.checkoutAndRebase")} + + ) : null} + + {actions?.has("compareWithCurrent") ? ( + onAction(reference, "compareWithCurrent")}> + {t("git.log.action.compareWithCurrent")} + + ) : null} + onAction(reference, "showWorkingTreeDiff")}> + {t("git.log.action.showWorkingTreeDiff")} + + {actions?.has("rebaseCurrent") ? ( + <> + + onAction(reference, "rebaseCurrent")}> + {t("git.log.action.rebaseCurrent")} + + onAction(reference, "mergeCurrent")}> + {t("git.log.action.mergeCurrent")} + + + ) : null} + {reference.kind === "remote" ? ( + <> + + onAction(reference, "pullRebase")}> + {t("git.log.action.pullRebase")} + + onAction(reference, "pullMerge")}> + {t("git.log.action.pullMerge")} + + + ) : null} + + + ) : row} {!isCollapsed && node.children.map((child) => ( ))} @@ -106,10 +199,14 @@ export function GitReferenceTree({ references, selectedReference, onSelect, + onAction, + isOperating = false, }: { references: GitReference[]; selectedReference: GitReference | null; onSelect: (reference: GitReference | null) => void; + onAction: (reference: GitReference, action: GitReferenceAction) => void; + isOperating?: boolean; }) { const { t } = useTranslation(); const collapsedSectionIds = useGitLogPreferencesStore.use.collapsedReferenceSections(); @@ -179,6 +276,8 @@ export function GitReferenceTree({ collapsedGroups={collapsedGroups} onToggleGroup={toggleReferenceGroup} onSelect={onSelect} + onAction={onAction} + isOperating={isOperating} /> )) ) : ( diff --git a/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts b/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts index 00e031392..79a384f90 100644 --- a/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts +++ b/windows/tauri/src/features/git/hooks/use-git-diff-actions.ts @@ -3,7 +3,13 @@ import { activateMainEditorPane } from "@/features/editor/stores/buffer-pane-syn import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { useTranslation } from "@/i18n/locale-provider"; import { showAlertDialog } from "@/ui/dialog"; -import { getCommitDiff, getFileDiff, getRefDiff, getStashDiff } from "../api/git-diff-api"; +import { + getCommitDiff, + getFileDiff, + getReferenceWorkingTreeDiff, + getRefDiff, + getStashDiff, +} from "../api/git-diff-api"; import { loadWorkingTreeDiffsProgressively, type WorkingTreeDiffEntry, @@ -375,6 +381,42 @@ export function useGitDiffActions({ [activeRepoPath, currentBranch, onBranchDiffOpened], ); + const viewReferenceWorkingTreeDiff = useCallback( + async (reference: string) => { + if (!activeRepoPath) return; + const title = `${reference}..WORKTREE`; + setIsLoadingBranchDiff(true); + try { + const diffs = await getReferenceWorkingTreeDiff(activeRepoPath, reference); + if (!diffs?.length) { + await showAlertDialog( + t("git.diff.noChangesBetween", { base: reference, target: "WORKTREE" }), + t("git.diff.title"), + ); + return; + } + openDiffBuffer( + `diff://reference/${encodeURIComponent(reference)}/working-tree`, + `${title} (${diffs.length} files)`, + createMultiFileDiff({ + title, + repoPath: activeRepoPath, + commitHash: title, + diffs, + }), + ); + } catch (error) { + await showAlertDialog( + t("git.diff.getWorkingTreeDiffFailed", { error: String(error) }), + t("git.diff.title"), + ); + } finally { + setIsLoadingBranchDiff(false); + } + }, + [activeRepoPath, t], + ); + return { isLoadingCommitDiff, isLoadingBranchDiff, @@ -385,5 +427,6 @@ export function useGitDiffActions({ viewStashDiff, viewTagComparison, viewBranchDiff, + viewReferenceWorkingTreeDiff, }; } diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index c3b8a024a..bf8788644 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -2418,6 +2418,24 @@ const catalogs = { "git.log.none": "None", "git.log.expand": "Expand {name}", "git.log.collapse": "Collapse {name}", + "git.log.action.checkout": "Checkout", + "git.log.action.createBranch": "New Branch from Reference…", + "git.log.action.checkoutAndRebase": "Checkout and Rebase onto Current Branch", + "git.log.action.compareWithCurrent": "Compare with Current Branch", + "git.log.action.showWorkingTreeDiff": "Show Diff with Working Tree", + "git.log.action.rebaseCurrent": "Rebase Current Branch onto Reference", + "git.log.action.mergeCurrent": "Merge Reference into Current Branch", + "git.log.action.pullRebase": "Pull Remote Branch with Rebase", + "git.log.action.pullMerge": "Pull Remote Branch with Merge", + "git.log.branchNamePrompt": "Enter the new branch name.", + "git.log.createBranchFromTitle": "New Branch from {reference}", + "git.log.branchCreated": "Created branch {name}", + "git.log.branchCreateFailed": "Failed to create branch", + "git.log.confirmAction": "{action} for {reference}?", + "git.log.actionSucceeded": "{action} completed for {reference}", + "git.log.operationConflicts": "Git stopped with {count} conflicted file(s).", + "git.log.operationStopped": "Git stopped before completing. Use the conflict controls to continue or abort.", + "git.log.operationBlocked": "Local changes block this operation: {paths}", "git.log.filter": "Filter Git log", "git.log.clearFilter": "Clear Git log filter", "git.log.filterField": "Git log filter field", @@ -6108,6 +6126,24 @@ const catalogs = { "git.log.none": "无", "git.log.expand": "展开 {name}", "git.log.collapse": "折叠 {name}", + "git.log.action.checkout": "检出", + "git.log.action.createBranch": "从引用新建分支…", + "git.log.action.checkoutAndRebase": "检出并变基到当前分支", + "git.log.action.compareWithCurrent": "与当前分支比较", + "git.log.action.showWorkingTreeDiff": "显示与工作树的差异", + "git.log.action.rebaseCurrent": "将当前分支变基到此引用", + "git.log.action.mergeCurrent": "将此引用合并到当前分支", + "git.log.action.pullRebase": "使用变基拉入远程分支", + "git.log.action.pullMerge": "使用合并拉入远程分支", + "git.log.branchNamePrompt": "输入新分支名称。", + "git.log.createBranchFromTitle": "从 {reference} 新建分支", + "git.log.branchCreated": "已创建分支 {name}", + "git.log.branchCreateFailed": "创建分支失败", + "git.log.confirmAction": "确定要对 {reference} 执行“{action}”吗?", + "git.log.actionSucceeded": "已对 {reference} 完成“{action}”", + "git.log.operationConflicts": "Git 已停止,有 {count} 个冲突文件。", + "git.log.operationStopped": "Git 在完成前停止,请使用冲突操作继续或中止。", + "git.log.operationBlocked": "本地更改阻止了此操作:{paths}", "git.log.filter": "筛选 Git 日志", "git.log.clearFilter": "清除 Git 日志筛选", "git.log.filterField": "Git 日志筛选字段",