Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 101 additions & 3 deletions windows/tauri/src-tauri/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -384,6 +401,19 @@ fn move_field(payload: &mut Map<String, Value>, 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<String, Value>) {
if let Some(path) = payload.remove("filePath") {
payload.insert("paths".into(), Value::Array(vec![path]));
Expand All @@ -400,7 +430,7 @@ fn take_text(payload: &mut Map<String, Value>, field: &str) -> Result<String, St

#[cfg(test)]
mod tests {
use super::translate;
use super::{local_branch_reference, translate};
use serde_json::json;

#[test]
Expand Down Expand Up @@ -430,6 +460,74 @@ mod tests {
);
}

#[test]
fn translates_checkout_branch_with_reference_kind() {
let (command, payload) = translate(
"git_checkout",
json!({ "repoPath": "C:/work", "branchName": "feature/checkout" }),
)
.unwrap();

assert_eq!(command, "git.write");
assert_eq!(
payload,
json!({
"root": "C:/work",
"operation": "checkout",
"reference": "refs/heads/feature/checkout",
"referenceKind": "local"
})
);
}

#[test]
fn translates_checkout_preflight_reference() {
let (command, payload) = translate(
"git_checkout_preflight",
json!({ "repoPath": "C:/work", "branchName": "main" }),
)
.unwrap();

assert_eq!(command, "git.checkoutPreflight");
assert_eq!(
payload,
json!({ "root": "C:/work", "reference": "refs/heads/main" })
);
}

#[test]
fn translates_delete_branch_to_qualified_reference() {
let (command, payload) = translate(
"git_delete_branch",
json!({ "repoPath": "C:/work", "branchName": "feature/old" }),
)
.unwrap();

assert_eq!(command, "git.write");
assert_eq!(
payload,
json!({
"root": "C:/work",
"operation": "deleteBranch",
"reference": "refs/heads/feature/old"
})
);
}

#[test]
fn qualifies_short_branch_names_only() {
assert_eq!(local_branch_reference("main"), "refs/heads/main");
assert_eq!(
local_branch_reference("feature/old"),
"refs/heads/feature/old"
);
assert_eq!(
local_branch_reference("refs/heads/main"),
"refs/heads/main"
);
assert_eq!(local_branch_reference("refs/foo"), "refs/heads/refs/foo");
}

#[test]
fn translates_diff_defaults() {
let (command, payload) =
Expand Down
32 changes: 31 additions & 1 deletion windows/tauri/src/features/git/api/git-branches-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,23 @@ interface CheckoutResult {
message: string;
}

interface CheckoutPreflightResult {
blocked: boolean;
blockingPaths: string[];
}

const checkoutErrorMessage = (error: unknown): string => {
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<string[]> => {
try {
const resolvedRepoPath = await resolveRepositoryPath(repoPath);
Expand All @@ -37,6 +54,19 @@ export const checkoutBranch = async (
): Promise<CheckoutResult> => {
try {
const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath);

const preflight = await tauriInvoke<CheckoutPreflightResult>("git_checkout_preflight", {
repoPath: resolvedRepoPath,
branchName,
});
if (preflight.blocked) {
return {
success: false,
hasChanges: true,
message: blockingChangesMessage(preflight.blockingPaths),
};
}

const result = await tauriInvoke<CheckoutResult>("git_checkout", {
repoPath: resolvedRepoPath,
branchName,
Expand All @@ -54,7 +84,7 @@ export const checkoutBranch = async (
return {
success: false,
hasChanges: false,
message: "Failed to checkout branch",
message: checkoutErrorMessage(error),
};
}
};
Expand Down
63 changes: 63 additions & 0 deletions windows/tauri/src/platform/core-result-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -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: [] });
});
});
11 changes: 11 additions & 0 deletions windows/tauri/src/platform/core-result-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,17 @@ export function adaptCoreResult<T>(
};
}) 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:
Expand Down
Loading