From a325c88a25b7b1f61e787ef7b8ab91227c748c6f Mon Sep 17 00:00:00 2001 From: ybx <3046829330@qq.com> Date: Sat, 15 Aug 2026 22:41:48 +0800 Subject: [PATCH] fix(windows): align local branch checkout and deletion refs - qualify git_checkout, git_checkout_preflight, and git_delete_branch references with refs/heads/ and pass referenceKind: local, matching the shared core contract that rejects short branch names - translate git_checkout_preflight to git.checkoutPreflight and adapt its blocking paths plus git_checkout results for the branch manager UI - run checkout preflight from checkoutBranch so blocked switches surface hasChanges and reuse the existing stash-and-retry flow, and propagate real git error output on failure --- windows/tauri/src-tauri/src/platform.rs | 104 +++++++++++++++++- .../src/features/git/api/git-branches-api.ts | 32 +++++- .../src/platform/core-result-adapter.test.ts | 63 +++++++++++ .../tauri/src/platform/core-result-adapter.ts | 11 ++ 4 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 windows/tauri/src/platform/core-result-adapter.test.ts diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index 5bfccd200..2491034ea 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -117,15 +117,32 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> { "git.write" } "git_delete_branch" => { - move_field(&mut payload, "branchName", "reference"); + let branch = take_text(&mut payload, "branchName")?; + payload.insert( + "reference".into(), + json!(local_branch_reference(&branch)), + ); payload.insert("operation".into(), json!("deleteBranch")); "git.write" } "git_checkout" => { - move_field(&mut payload, "branchName", "reference"); + let branch = take_text(&mut payload, "branchName")?; + payload.insert( + "reference".into(), + json!(local_branch_reference(&branch)), + ); payload.insert("operation".into(), json!("checkout")); + payload.insert("referenceKind".into(), json!("local")); "git.write" } + "git_checkout_preflight" => { + let branch = take_text(&mut payload, "branchName")?; + payload.insert( + "reference".into(), + json!(local_branch_reference(&branch)), + ); + "git.checkoutPreflight" + } "git_create_stash" => { payload.insert("operation".into(), json!("stashPush")); "git.write" @@ -384,6 +401,19 @@ fn move_field(payload: &mut Map, from: &str, to: &str) { } } +/// Windows callers name local branches by their short form, while the shared +/// core requires fully qualified references so branch and tag names cannot +/// collide. Only `refs/heads/` counts as already qualified; any other ref +/// namespace is treated as a branch name instead of silently targeting a +/// different namespace. +fn local_branch_reference(branch: &str) -> String { + if branch.starts_with("refs/heads/") { + branch.to_string() + } else { + format!("refs/heads/{branch}") + } +} + fn paths_from_file(payload: &mut Map) { if let Some(path) = payload.remove("filePath") { payload.insert("paths".into(), Value::Array(vec![path])); @@ -400,7 +430,7 @@ fn take_text(payload: &mut Map, field: &str) -> Result { + const message = error instanceof Error ? error.message : String(error); + return message.trim() || "Failed to checkout branch"; +}; + +const blockingChangesMessage = (blockingPaths: string[]): string => { + const listed = blockingPaths.slice(0, 3).join(", "); + const remaining = blockingPaths.length - Math.min(blockingPaths.length, 3); + const suffix = remaining > 0 ? ` (+${remaining} more)` : ""; + return `Local changes would be overwritten by switching branches: ${listed}${suffix}`; +}; + export const getBranches = async (repoPath: string): Promise => { try { const resolvedRepoPath = await resolveRepositoryPath(repoPath); @@ -37,6 +54,19 @@ export const checkoutBranch = async ( ): Promise => { try { const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + + const preflight = await tauriInvoke("git_checkout_preflight", { + repoPath: resolvedRepoPath, + branchName, + }); + if (preflight.blocked) { + return { + success: false, + hasChanges: true, + message: blockingChangesMessage(preflight.blockingPaths), + }; + } + const result = await tauriInvoke("git_checkout", { repoPath: resolvedRepoPath, branchName, @@ -54,7 +84,7 @@ export const checkoutBranch = async ( return { success: false, hasChanges: false, - message: "Failed to checkout branch", + message: checkoutErrorMessage(error), }; } }; diff --git a/windows/tauri/src/platform/core-result-adapter.test.ts b/windows/tauri/src/platform/core-result-adapter.test.ts new file mode 100644 index 000000000..503dbc1ed --- /dev/null +++ b/windows/tauri/src/platform/core-result-adapter.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import { adaptCoreResult } from "./core-result-adapter"; + +describe("git checkout result adaptation", () => { + test("maps a successful core checkout to the UI checkout result", () => { + const result = adaptCoreResult( + "git_checkout", + { repoPath: "C:/work", branchName: "main" }, + { output: "Switched to branch 'main'\n", exitCode: 0 }, + ); + + expect(result).toEqual({ + success: true, + hasChanges: false, + message: "Switched to branch 'main'", + }); + }); + + test("reports failure when the core checkout exited non-zero", () => { + const result = adaptCoreResult( + "git_checkout", + { repoPath: "C:/work", branchName: "main" }, + { output: "error: pathspec 'main' did not match", exitCode: 1 }, + ); + + expect(result).toEqual({ + success: false, + hasChanges: false, + message: "error: pathspec 'main' did not match", + }); + }); + + test("keeps an empty message for a silent successful checkout", () => { + const result = adaptCoreResult("git_checkout", undefined, { output: "", exitCode: 0 }); + + expect(result).toEqual({ success: true, hasChanges: false, message: "" }); + }); +}); + +describe("git checkout preflight adaptation", () => { + test("reports blocked paths returned by the shared core", () => { + const result = adaptCoreResult( + "git_checkout_preflight", + { repoPath: "C:/work", branchName: "main" }, + { blockingPaths: ["src/main.rs", "README.md"] }, + ); + + expect(result).toEqual({ + blocked: true, + blockingPaths: ["src/main.rs", "README.md"], + }); + }); + + test("reports no blockage when the core returns no blocking paths", () => { + const result = adaptCoreResult( + "git_checkout_preflight", + { repoPath: "C:/work", branchName: "main" }, + { blockingPaths: [] }, + ); + + expect(result).toEqual({ blocked: false, blockingPaths: [] }); + }); +}); diff --git a/windows/tauri/src/platform/core-result-adapter.ts b/windows/tauri/src/platform/core-result-adapter.ts index dc94de2c9..3d53aae1e 100644 --- a/windows/tauri/src/platform/core-result-adapter.ts +++ b/windows/tauri/src/platform/core-result-adapter.ts @@ -146,6 +146,17 @@ export function adaptCoreResult( }; }) as T; } + case "git_checkout": { + const exitCode = typeof data.exitCode === "number" ? data.exitCode : 0; + const output = typeof data.output === "string" ? data.output.trim() : ""; + return { success: exitCode === 0, hasChanges: false, message: output } as T; + } + case "git_checkout_preflight": { + const blockingPaths = Array.isArray(data.blockingPaths) + ? data.blockingPaths.map((path: unknown) => String(path)) + : []; + return { blocked: blockingPaths.length > 0, blockingPaths } as T; + } case "git_checkout_tag": return { success: true, hasChanges: false, message: "" } as T; default: