From 3c8421d96f8890061636655c60ffcef058590250 Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Fri, 28 Aug 2026 14:44:50 +0800 Subject: [PATCH 01/16] fix(windows): add Git Log reference actions --- rust/lithe-core/src/git/mod.rs | 100 ++++++++++++ rust/lithe-core/src/tests/git.rs | 73 +++++++++ shared/contracts/rust-core-api.md | 12 ++ shared/fixtures/git/write.json | 24 +++ windows/tauri/src-tauri/src/platform.rs | 143 ++++++++++++++++-- .../src/features/git/api/git-branches-api.ts | 27 +++- .../src/features/git/api/git-diff-api.ts | 21 +++ .../git/api/git-integration-api.test.ts | 40 ++++- .../features/git/api/git-integration-api.ts | 83 ++++++++-- .../components/log/git-log-tool-window.tsx | 100 +++++++++++- .../components/log/git-reference-tree.test.ts | 50 ++++++ .../git/components/log/git-reference-tree.tsx | 107 ++++++++++++- .../git/hooks/use-git-diff-actions.ts | 40 ++++- windows/tauri/src/i18n/locale.ts | 36 +++++ 14 files changed, 822 insertions(+), 34 deletions(-) create mode 100644 windows/tauri/src/features/git/components/log/git-reference-tree.test.ts diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index a3d28dc38..0113696d4 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -552,6 +552,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result return 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 +569,17 @@ fn write_with_trace(request: GitWriteRequest) -> Result return push(&root, request.reference.as_deref()), "checkout" => return checkout(&root, request), @@ -2105,12 +2117,78 @@ 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. +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, + ) +} + /// 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. @@ -2225,6 +2303,28 @@ fn switch_reference( } } +fn remote_branch_components(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 (remote, branch) = remote_path + .split_once('/') + .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Invalid remote branch name"))?; + if remote.is_empty() + || 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())) +} + fn parse_reference(line: &str) -> Option { let columns = line.split('\t').collect::>(); if columns.len() < 4 || columns[1].ends_with("/HEAD") { diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index ac96c912b..3aafc3bc3 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -386,6 +386,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", diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 330967293..a916a7141 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -217,6 +217,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`, @@ -249,6 +250,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..68f2e6134 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -157,6 +157,12 @@ 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!(["."])); + "git.diff" + } "git_stash_diff" => { let index = payload .remove("stashIndex") @@ -168,6 +174,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 +195,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 +233,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 +519,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 +634,67 @@ 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": ["."] + }) + ); + } + #[test] fn translates_checkout_preflight_reference() { let (command, payload) = translate( @@ -630,6 +741,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..e4cd42e8e 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,27 @@ 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)) { + console.error("Failed to compare reference with working tree:", error); + } + return null; + } +}; + 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..8c8a30191 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,66 @@ 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); + 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/log/git-log-tool-window.tsx b/windows/tauri/src/features/git/components/log/git-log-tool-window.tsx index 71867bdf6..ff00c0eae 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,17 @@ 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 { + 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 +27,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 +46,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 +66,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 +81,82 @@ 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", + ); + 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 +254,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..2d2245ccd 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,37 @@ 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, + }), + ); + } finally { + setIsLoadingBranchDiff(false); + } + }, + [activeRepoPath, t], + ); + return { isLoadingCommitDiff, isLoadingBranchDiff, @@ -385,5 +422,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 4669292b5..8c230cefa 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -2679,6 +2679,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", @@ -6722,6 +6740,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 日志筛选字段", From 7520b1efdf10504e9f07f7e56c94ec8c1d533816 Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Fri, 28 Aug 2026 14:54:46 +0800 Subject: [PATCH 02/16] fix(macos): complete Git Log reference actions --- .../Lithe/Core/Rust/RustGitOperations.swift | 23 +++++ .../Lithe/Models/AppModel/AppModel.swift | 10 ++ .../Sources/Lithe/Views/Git/GitLogView.swift | 93 +++++++++++++++---- .../Application/GitFeatureModel.swift | 27 ++++++ .../LitheGitModule/Services/GitService.swift | 20 ++++ .../LitheGitModuleTests/GitModuleTests.swift | 51 ++++++++++ 6 files changed, 208 insertions(+), 16 deletions(-) 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 4581e8996..4f3d522f3 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -1715,6 +1715,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 c33c080f2..cd9614d15 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -268,6 +268,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) } } } @@ -805,6 +811,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 @@ -816,39 +828,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, @@ -1791,12 +1837,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?" } } @@ -1805,6 +1857,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" } } @@ -1816,6 +1871,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 ebbf13f33..f5c0551e3 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -1629,6 +1629,33 @@ 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 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 eb1ff3d70..6517cfc18 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 3f35a37a5..56c6e1618 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") @@ -823,7 +856,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 } From a88faf7676782aba6d83d1c49968638b5343d23a Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Fri, 28 Aug 2026 15:57:42 +0800 Subject: [PATCH 03/16] style(windows): clarify Git refresh icon --- windows/tauri/src/features/git/components/git-view.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"; From 2b7a04f056f679bcc5fac1a407e1b2ac212408ce Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Sun, 30 Aug 2026 12:47:07 +0800 Subject: [PATCH 04/16] fix(git): validate remote names and pull preflight --- rust/lithe-core/src/git/mod.rs | 43 +++++++++++++------ .../features/git/api/git-integration-api.ts | 18 ++++++++ 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 0113696d4..715e56a64 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -577,7 +577,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result { - 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) = remote_branch_components(root, &reference)?; + if !is_safe_pathspec(&local_name) { return Err(CoreError::new( ErrorCode::InvalidRequest, "Invalid remote branch name", @@ -2303,15 +2298,35 @@ fn switch_reference( } } -fn remote_branch_components(reference: &str) -> Result<(String, String), CoreError> { +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 (remote, branch) = remote_path - .split_once('/') - .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Invalid remote branch name"))?; - if remote.is_empty() - || branch.is_empty() + let remotes = execute_git(root, &["remote".into()], None)?; + if remotes.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git remote lookup failed") + .with_details(remotes.output), + ); + } + let mut matches = remotes + .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 remote = matches.first().copied(); + let Some(remote) = remote 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) 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 8c8a30191..d692d5700 100644 --- a/windows/tauri/src/features/git/api/git-integration-api.ts +++ b/windows/tauri/src/features/git/api/git-integration-api.ts @@ -158,6 +158,24 @@ export const pullRemoteReference = async ( 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, From d4f3029f2a0c6aa3941dc9c07dcba01452922604 Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Sun, 30 Aug 2026 12:49:30 +0800 Subject: [PATCH 05/16] fix(windows): surface reference diff failures --- windows/tauri/src/features/git/api/git-diff-api.ts | 7 +++---- .../tauri/src/features/git/hooks/use-git-diff-actions.ts | 5 +++++ 2 files changed, 8 insertions(+), 4 deletions(-) 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 e4cd42e8e..6be3f41dc 100644 --- a/windows/tauri/src/features/git/api/git-diff-api.ts +++ b/windows/tauri/src/features/git/api/git-diff-api.ts @@ -367,10 +367,9 @@ export const getReferenceWorkingTreeDiff = async ( }), ); } catch (error) { - if (!isNotGitRepositoryError(error)) { - console.error("Failed to compare reference with working tree:", error); - } - return null; + if (isNotGitRepositoryError(error)) return null; + console.error("Failed to compare reference with working tree:", error); + throw error; } }; 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 2d2245ccd..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 @@ -405,6 +405,11 @@ export function useGitDiffActions({ diffs, }), ); + } catch (error) { + await showAlertDialog( + t("git.diff.getWorkingTreeDiffFailed", { error: String(error) }), + t("git.diff.title"), + ); } finally { setIsLoadingBranchDiff(false); } From ba4e282846c2fc50ae219153edca0ed01fb173d1 Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Sun, 30 Aug 2026 12:57:04 +0800 Subject: [PATCH 06/16] fix(windows): include untracked files in reference diff --- rust/lithe-core/src/git/mod.rs | 44 +++++++++++++++++++++++-- windows/tauri/src-tauri/src/platform.rs | 1 + 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 715e56a64..51155b60c 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -784,6 +784,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![ @@ -822,13 +823,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)) } diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index 68f2e6134..f767309d6 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -161,6 +161,7 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> { 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" => { From 92aaee4dfd5da199e13345663c3a36c20d98d5f4 Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Sun, 30 Aug 2026 13:01:49 +0800 Subject: [PATCH 07/16] fix(windows): restore stashed changes after remote pull --- .../components/log/git-log-tool-window.tsx | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) 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 ff00c0eae..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 @@ -10,6 +10,7 @@ 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, @@ -143,13 +144,33 @@ export function GitLogToolWindow() { reference, action === "pullRebase" ? "rebase" : "merge", ); - reportIntegration( - outcome, - t("git.log.actionSucceeded", { - action: t(`git.log.action.${action}`), - reference: reference.shortName, - }), - ); + 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 { From cceb2b8f726d2ab1a495859735d7e748b8711cbe Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Sun, 30 Aug 2026 13:04:45 +0800 Subject: [PATCH 08/16] fix(macos): preflight remote pull changes --- .../LitheGitModule/Application/GitFeatureModel.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index f5c0551e3..0f4f08f24 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -1648,6 +1648,16 @@ package final class GitFeatureModel: ObservableObject { ) 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 { + isPerformingBranchOperation = false + notify?("本地有未提交改动,请先保存后再拉取远程分支") + return + } let result = await withGitOperation { await service.pullRemoteReference(reference, strategy: strategy, at: gitRepositoryRoot) } From a33b863f4f5aa9dbcfd1f7a566723418a701d28e Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Sun, 30 Aug 2026 13:06:15 +0800 Subject: [PATCH 09/16] fix(macos): preserve local changes during remote pull --- .../Application/GitFeatureModel.swift | 55 ++++++++++++++++++- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 0f4f08f24..656beaee9 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -1654,9 +1654,58 @@ package final class GitFeatureModel: ObservableObject { at: gitRepositoryRoot ) if let preflight, !preflight.isClear { - isPerformingBranchOperation = false - notify?("本地有未提交改动,请先保存后再拉取远程分支") - return + 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) From 587188cb7cce82941b6df86eb072104497c514cd Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Sun, 30 Aug 2026 13:09:03 +0800 Subject: [PATCH 10/16] refactor(core): isolate remote mutation parsing --- rust/lithe-core/src/git/mod.rs | 10 +++--- rust/lithe-core/src/git/mutations.rs | 48 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 rust/lithe-core/src/git/mutations.rs diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 51155b60c..b91ddcd58 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, @@ -577,7 +579,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result Result, @@ -2303,7 +2305,7 @@ fn switch_reference( execute_git(root, &base, None) } Some("remote") => { - let (_, local_name) = remote_branch_components(root, &reference)?; + let (_, local_name) = mutations::remote_branch_components(root, &reference)?; if !is_safe_pathspec(&local_name) { return Err(CoreError::new( ErrorCode::InvalidRequest, @@ -2481,7 +2483,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..61586e9c9 --- /dev/null +++ b/rust/lithe-core/src/git/mutations.rs @@ -0,0 +1,48 @@ +//! Shared Git mutation helpers kept outside the command facade. + +use super::{execute_git, is_safe_pathspec}; +use crate::protocol::{CoreError, ErrorCode}; + +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 = execute_git(root, &["remote".into()], None)?; + if remotes.exit_code != 0 { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git remote lookup failed") + .with_details(remotes.output), + ); + } + let mut matches = remotes + .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())) +} From f8a1dce9eb28b94dfac2ac942568c3e501e40de7 Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Sun, 30 Aug 2026 13:11:36 +0800 Subject: [PATCH 11/16] refactor(core): remove duplicate remote parser --- rust/lithe-core/src/git/mod.rs | 42 ---------------------------------- 1 file changed, 42 deletions(-) diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index b91ddcd58..216104899 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -2340,48 +2340,6 @@ fn switch_reference( } } -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 = execute_git(root, &["remote".into()], None)?; - if remotes.exit_code != 0 { - return Err( - CoreError::new(ErrorCode::ProcessFailed, "Git remote lookup failed") - .with_details(remotes.output), - ); - } - let mut matches = remotes - .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 remote = matches.first().copied(); - let Some(remote) = remote 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())) -} - fn parse_reference(line: &str) -> Option { let columns = line.split('\t').collect::>(); if columns.len() < 4 || columns[1].ends_with("/HEAD") { From 11952da7be5ff791f923b01896c6b25d4745e026 Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Sun, 30 Aug 2026 13:13:36 +0800 Subject: [PATCH 12/16] test(core): pull nested remote reference from bare repository --- rust/lithe-core/src/tests/git.rs | 79 ++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index 3aafc3bc3..1987652b4 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -1751,3 +1751,82 @@ 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()); + 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"); +} From 04454575f785838cdfaa520d62a07ffbea88f103 Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Sun, 30 Aug 2026 13:21:16 +0800 Subject: [PATCH 13/16] test(core): use nested remote in mutation fixture --- rust/lithe-core/src/git/mod.rs | 2 +- rust/lithe-core/src/git/mutations.rs | 10 +++++----- rust/lithe-core/src/tests/git.rs | 13 +++++++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 216104899..99d704df4 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -648,7 +648,7 @@ pub(super) 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, diff --git a/rust/lithe-core/src/git/mutations.rs b/rust/lithe-core/src/git/mutations.rs index 61586e9c9..dda7c02cb 100644 --- a/rust/lithe-core/src/git/mutations.rs +++ b/rust/lithe-core/src/git/mutations.rs @@ -1,6 +1,6 @@ //! Shared Git mutation helpers kept outside the command facade. -use super::{execute_git, is_safe_pathspec}; +use super::{capture_git_with_options, is_safe_pathspec}; use crate::protocol::{CoreError, ErrorCode}; pub(super) fn remote_branch_components( @@ -10,15 +10,15 @@ pub(super) fn remote_branch_components( let remote_path = reference .strip_prefix("refs/remotes/") .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Invalid remote branch name"))?; - let remotes = execute_git(root, &["remote".into()], None)?; + 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(remotes.output), + .with_details(String::from_utf8_lossy(&remotes.stderr).to_string()), ); } - let mut matches = remotes - .output + let mut matches = remote_output .lines() .map(str::trim) .filter(|remote| !remote.is_empty() && remote_path.starts_with(&format!("{remote}/"))) diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index 1987652b4..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 { @@ -1814,6 +1815,18 @@ fn explicit_pull_resolves_nested_remote_and_branch_names_against_bare_remote() { ) .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", From 60354db57f9f647302d11798b5bfde72a7728f2f Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Sun, 30 Aug 2026 13:52:22 +0800 Subject: [PATCH 14/16] refactor(core): move checkout rebase mutation --- rust/lithe-core/src/git/mod.rs | 57 ++-------------------------- rust/lithe-core/src/git/mutations.rs | 53 ++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 53 deletions(-) diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 99d704df4..52bc8ce8c 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -554,7 +554,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result return checkout_and_rebase(&root, request), + "checkoutAndRebase" => 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 @@ -1802,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( @@ -1859,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( @@ -2182,55 +2182,6 @@ fn checkout(root: &str, request: GitWriteRequest) -> Result 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, - ) -} - /// 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. @@ -2282,7 +2233,7 @@ fn checkout_with_auto_stash( Ok(restored) } -fn switch_reference( +pub(super) fn switch_reference( root: &str, request: &GitWriteRequest, ) -> Result { diff --git a/rust/lithe-core/src/git/mutations.rs b/rust/lithe-core/src/git/mutations.rs index dda7c02cb..f0d7e5063 100644 --- a/rust/lithe-core/src/git/mutations.rs +++ b/rust/lithe-core/src/git/mutations.rs @@ -3,6 +3,59 @@ 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, From 56ff7a0588724ba86979c65c875ff60bfe5af068 Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Sun, 30 Aug 2026 13:57:25 +0800 Subject: [PATCH 15/16] fix(macos): correct async shelf restore condition --- macos/Sources/LitheGitModule/Application/GitFeatureModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 656beaee9..25010dbbf 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -1700,7 +1700,7 @@ package final class GitFeatureModel: ObservableObject { notify?("拉取产生冲突,改动已保留在搁置中") return } - if !await restoreShelf(shelf, at: gitRepositoryRoot) { + if !(await restoreShelf(shelf, at: gitRepositoryRoot)) { notify?("恢复搁置改动失败") } await reportBranchOperation(result, success: strategy == .rebase ? "从远程分支变基拉取完成" : "从远程分支合并拉取完成") From 1fc447f52c23c7dea69190e1eb104eb7f5c1ef86 Mon Sep 17 00:00:00 2001 From: puppy_1 <13323021675@163.com> Date: Sun, 30 Aug 2026 14:39:28 +0800 Subject: [PATCH 16/16] test(windows): update reference diff translation expectation --- windows/tauri/src-tauri/src/platform.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index f767309d6..09bcb0105 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -691,7 +691,8 @@ mod tests { json!({ "root": "C:/work", "reference": "refs/remotes/origin/feature/demo", - "pathspecs": ["."] + "pathspecs": ["."], + "untracked": true }) ); }