Skip to content

Commit 8a86b6d

Browse files
authored
Merge pull request #107 from yangboxuan726/feat/windows-git-merge-rebase-ui
feat(windows): add merge, rebase, and conflict resolution UI
2 parents 480bb26 + 1eb1616 commit 8a86b6d

13 files changed

Lines changed: 825 additions & 40 deletions

windows/tauri/src-tauri/src/platform.rs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,41 @@ fn translate(command: &str, args: Value) -> Result<(String, Value), String> {
148148
);
149149
"git.checkoutPreflight"
150150
}
151+
"git_merge" | "git_rebase" => {
152+
let branch = take_text(&mut payload, "branchName")?;
153+
payload.insert(
154+
"reference".into(),
155+
json!(local_branch_reference(&branch)),
156+
);
157+
payload.insert(
158+
"operation".into(),
159+
json!(if command == "git_merge" {
160+
"merge"
161+
} else {
162+
"rebase"
163+
}),
164+
);
165+
"git.write"
166+
}
167+
"git_integration_preflight" => {
168+
let branch = take_text(&mut payload, "branchName")?;
169+
payload.insert(
170+
"reference".into(),
171+
json!(local_branch_reference(&branch)),
172+
);
173+
"git.integrationPreflight"
174+
}
175+
"git_operation_state" => "git.operationState",
176+
"git_operation_continue" | "git_operation_abort" | "git_operation_skip" => {
177+
let operation = match command {
178+
"git_operation_continue" => "operationContinue",
179+
"git_operation_abort" => "operationAbort",
180+
_ => "operationSkip",
181+
};
182+
payload.insert("operation".into(), json!(operation));
183+
"git.write"
184+
}
185+
"git_conflict_markers" => "git.conflictMarkers",
151186
"git_create_stash" => {
152187
payload.insert("operation".into(), json!("stashPush"));
153188
"git.write"
@@ -500,6 +535,89 @@ mod tests {
500535
);
501536
}
502537

538+
#[test]
539+
fn translates_merge_and_rebase_to_qualified_references() {
540+
let (merge_command, merge_payload) = translate(
541+
"git_merge",
542+
json!({ "repoPath": "C:/work", "branchName": "feature/demo" }),
543+
)
544+
.unwrap();
545+
assert_eq!(merge_command, "git.write");
546+
assert_eq!(
547+
merge_payload,
548+
json!({
549+
"root": "C:/work",
550+
"operation": "merge",
551+
"reference": "refs/heads/feature/demo"
552+
})
553+
);
554+
555+
let (rebase_command, rebase_payload) = translate(
556+
"git_rebase",
557+
json!({ "repoPath": "C:/work", "branchName": "main" }),
558+
)
559+
.unwrap();
560+
assert_eq!(rebase_command, "git.write");
561+
assert_eq!(
562+
rebase_payload,
563+
json!({
564+
"root": "C:/work",
565+
"operation": "rebase",
566+
"reference": "refs/heads/main"
567+
})
568+
);
569+
}
570+
571+
#[test]
572+
fn translates_integration_preflight_operation() {
573+
let (command, payload) = translate(
574+
"git_integration_preflight",
575+
json!({ "repoPath": "C:/work", "branchName": "main", "operation": "rebase" }),
576+
)
577+
.unwrap();
578+
579+
assert_eq!(command, "git.integrationPreflight");
580+
assert_eq!(
581+
payload,
582+
json!({
583+
"root": "C:/work",
584+
"reference": "refs/heads/main",
585+
"operation": "rebase"
586+
})
587+
);
588+
}
589+
590+
#[test]
591+
fn translates_operation_state_and_resolution_commands() {
592+
let (state_command, state_payload) =
593+
translate("git_operation_state", json!({ "repoPath": "C:/work" })).unwrap();
594+
assert_eq!(state_command, "git.operationState");
595+
assert_eq!(state_payload, json!({ "root": "C:/work" }));
596+
597+
for (compat, operation) in [
598+
("git_operation_continue", "operationContinue"),
599+
("git_operation_abort", "operationAbort"),
600+
("git_operation_skip", "operationSkip"),
601+
] {
602+
let (command, payload) =
603+
translate(compat, json!({ "repoPath": "C:/work" })).unwrap();
604+
assert_eq!(command, "git.write");
605+
assert_eq!(
606+
payload,
607+
json!({ "root": "C:/work", "operation": operation })
608+
);
609+
}
610+
}
611+
612+
#[test]
613+
fn translates_conflict_markers_request() {
614+
let (command, payload) =
615+
translate("git_conflict_markers", json!({ "repoPath": "C:/work" })).unwrap();
616+
617+
assert_eq!(command, "git.conflictMarkers");
618+
assert_eq!(payload, json!({ "root": "C:/work" }));
619+
}
620+
503621
#[test]
504622
fn translates_delete_branch_to_qualified_reference() {
505623
let (command, payload) = translate(
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { beforeEach, describe, expect, mock, test } from "bun:test";
2+
import type { GitOperationState } from "../types/git.types";
3+
4+
const invoke = mock(async (_command: string, _args?: unknown): Promise<unknown> => null);
5+
const emitGitChanged = mock((_change: unknown) => {});
6+
const resolveRepositoryPathOrThrow = mock(async (repoPath: string) => repoPath);
7+
8+
mock.module("@/platform/tauri-core", () => ({ invoke }));
9+
mock.module("../events/git-events", () => ({ emitGitChanged }));
10+
mock.module("./git-repo-api", () => ({ resolveRepositoryPathOrThrow }));
11+
12+
const { getConflictMarkerPaths, getOperationState, mergeBranch, rebaseOntoBranch } = await import(
13+
"./git-integration-api"
14+
);
15+
16+
const operationState = (
17+
kind: GitOperationState["kind"],
18+
conflictedPaths: string[] = [],
19+
): GitOperationState => ({
20+
kind,
21+
reference: null,
22+
conflictedPaths,
23+
step: null,
24+
total: null,
25+
});
26+
27+
beforeEach(() => {
28+
invoke.mockReset();
29+
emitGitChanged.mockReset();
30+
resolveRepositoryPathOrThrow.mockReset();
31+
resolveRepositoryPathOrThrow.mockImplementation(async (repoPath: string) => repoPath);
32+
});
33+
34+
describe("Git integration state", () => {
35+
test("reports a stopped rebase even when no conflicted paths remain", async () => {
36+
invoke.mockImplementation(async (command: string) => {
37+
if (command === "git_integration_preflight") {
38+
return { blockingPaths: [], blocksEntirely: false };
39+
}
40+
if (command === "git_rebase") throw new Error("rebase stopped");
41+
if (command === "git_operation_state") return operationState("rebase");
42+
return null;
43+
});
44+
45+
await expect(rebaseOntoBranch("C:/repo", "main")).resolves.toEqual({ status: "stopped" });
46+
expect(emitGitChanged).toHaveBeenCalledWith({
47+
repoPath: "C:/repo",
48+
scopes: ["working-tree", "history", "refs"],
49+
source: "rebase-rejected",
50+
});
51+
});
52+
53+
test("reports conflicted paths when a merge stops on conflicts", async () => {
54+
invoke.mockImplementation(async (command: string) => {
55+
if (command === "git_integration_preflight") {
56+
return { blockingPaths: [], blocksEntirely: false };
57+
}
58+
if (command === "git_merge") throw new Error("merge stopped");
59+
if (command === "git_operation_state") {
60+
return operationState("merge", ["src/app.ts"]);
61+
}
62+
return null;
63+
});
64+
65+
await expect(mergeBranch("C:/repo", "feature")).resolves.toEqual({
66+
status: "conflicts",
67+
conflictedPaths: ["src/app.ts"],
68+
});
69+
});
70+
71+
test("propagates operation state query failures", async () => {
72+
invoke.mockRejectedValue(new Error("Core unavailable"));
73+
74+
await expect(getOperationState("C:/repo")).rejects.toThrow("Core unavailable");
75+
});
76+
77+
test("propagates conflict marker query failures so commits fail closed", async () => {
78+
invoke.mockRejectedValue(new Error("Core unavailable"));
79+
80+
await expect(getConflictMarkerPaths("C:/repo")).rejects.toThrow("Core unavailable");
81+
});
82+
});
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { invoke as tauriInvoke } from "@/platform/tauri-core";
2+
import { emitGitChanged } from "../events/git-events";
3+
import { resolveRepositoryPathOrThrow } from "./git-repo-api";
4+
import type { GitOperationState } from "../types/git.types";
5+
6+
type IntegrationOperation = "merge" | "rebase";
7+
8+
interface IntegrationPreflightResult {
9+
blockingPaths: string[];
10+
blocksEntirely: boolean;
11+
}
12+
13+
export type IntegrationOutcome =
14+
| { status: "clean" }
15+
| { status: "conflicts"; conflictedPaths: string[] }
16+
| { status: "stopped" }
17+
| { status: "blocked"; blockingPaths: string[]; blocksEntirely: boolean }
18+
| { status: "error"; message: string };
19+
20+
export interface OperationResolution {
21+
ok: boolean;
22+
message: string;
23+
}
24+
25+
const errorMessage = (error: unknown): string => {
26+
const message = error instanceof Error ? error.message : String(error);
27+
return message.trim() || "Git operation failed";
28+
};
29+
30+
const notifyOperationChanged = (repoPath: string, source: string) => {
31+
emitGitChanged({
32+
repoPath,
33+
scopes: ["working-tree", "history", "refs"],
34+
source,
35+
});
36+
};
37+
38+
export const getOperationState = async (
39+
repoPath: string,
40+
): Promise<GitOperationState | null> => {
41+
const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath);
42+
return tauriInvoke<GitOperationState | null>("git_operation_state", {
43+
repoPath: resolvedRepoPath,
44+
});
45+
};
46+
47+
export const getConflictMarkerPaths = async (repoPath: string): Promise<string[]> => {
48+
const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath);
49+
const result = await tauriInvoke<{ paths: string[] }>("git_conflict_markers", {
50+
repoPath: resolvedRepoPath,
51+
});
52+
return result.paths;
53+
};
54+
55+
const runIntegration = async (
56+
repoPath: string,
57+
branchName: string,
58+
operation: IntegrationOperation,
59+
): Promise<IntegrationOutcome> => {
60+
const command = operation === "merge" ? "git_merge" : "git_rebase";
61+
try {
62+
await tauriInvoke(command, { repoPath, branchName });
63+
notifyOperationChanged(repoPath, `${operation}-completed`);
64+
return { status: "clean" };
65+
} catch (error) {
66+
// A conflict stop exits non-zero like a real failure; the authoritative
67+
// distinction is whether Git left an operation state behind.
68+
notifyOperationChanged(repoPath, `${operation}-rejected`);
69+
try {
70+
const state = await getOperationState(repoPath);
71+
if (state?.kind === operation) {
72+
if (state.conflictedPaths.length > 0) {
73+
return { status: "conflicts", conflictedPaths: state.conflictedPaths };
74+
}
75+
return { status: "stopped" };
76+
}
77+
} catch (stateError) {
78+
console.error(`Failed to read ${operation} state after Git rejected the operation:`, stateError);
79+
}
80+
return { status: "error", message: errorMessage(error) };
81+
}
82+
};
83+
84+
const startIntegration = async (
85+
repoPath: string,
86+
branchName: string,
87+
operation: IntegrationOperation,
88+
): Promise<IntegrationOutcome> => {
89+
const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath);
90+
91+
let preflight: IntegrationPreflightResult | null = null;
92+
try {
93+
preflight = await tauriInvoke<IntegrationPreflightResult>(
94+
"git_integration_preflight",
95+
{ repoPath: resolvedRepoPath, branchName, operation },
96+
);
97+
} catch {
98+
// A failed preflight must not block the operation itself; Git will still
99+
// refuse safely when the tree is dirty.
100+
}
101+
102+
if (preflight && preflight.blockingPaths.length > 0) {
103+
return {
104+
status: "blocked",
105+
blockingPaths: preflight.blockingPaths,
106+
blocksEntirely: preflight.blocksEntirely,
107+
};
108+
}
109+
110+
return runIntegration(resolvedRepoPath, branchName, operation);
111+
};
112+
113+
export const mergeBranch = (repoPath: string, branchName: string) =>
114+
startIntegration(repoPath, branchName, "merge");
115+
116+
export const rebaseOntoBranch = (repoPath: string, branchName: string) =>
117+
startIntegration(repoPath, branchName, "rebase");
118+
119+
const resolveOperation = async (
120+
repoPath: string,
121+
action: "git_operation_continue" | "git_operation_abort" | "git_operation_skip",
122+
): Promise<OperationResolution> => {
123+
const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath);
124+
try {
125+
await tauriInvoke(action, { repoPath: resolvedRepoPath });
126+
notifyOperationChanged(resolvedRepoPath, "operation-resolved");
127+
return { ok: true, message: "Git operation finished" };
128+
} catch (error) {
129+
// Even a rejected continue can change repository state, so refresh anyway.
130+
notifyOperationChanged(resolvedRepoPath, "operation-resolution-rejected");
131+
return { ok: false, message: errorMessage(error) };
132+
}
133+
};
134+
135+
export const continueOperation = (repoPath: string) =>
136+
resolveOperation(repoPath, "git_operation_continue");
137+
138+
export const abortOperation = (repoPath: string) =>
139+
resolveOperation(repoPath, "git_operation_abort");
140+
141+
export const skipOperationStep = (repoPath: string) =>
142+
resolveOperation(repoPath, "git_operation_skip");

0 commit comments

Comments
 (0)