Skip to content

Commit 02a9d4b

Browse files
committed
Implement conflict resolution features in GitActionsControl
- Add functionality to open conflicted files in the editor when a resolve conflicts action is triggered. - Update the UI to display a warning message and an option to open conflicted files when merge conflicts are detected. - Enhance tests to cover conflict resolution scenarios and ensure proper handling of Git status with conflicts.
1 parent caf3c15 commit 02a9d4b

3 files changed

Lines changed: 138 additions & 1 deletion

File tree

apps/web/src/components/GitActionsControl.logic.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {
1414
return {
1515
branch: "feature/test",
1616
hasWorkingTreeChanges: false,
17+
hasConflicts: false,
18+
conflictedFiles: [],
1719
workingTree: {
1820
files: [],
1921
insertions: 0,
@@ -87,6 +89,44 @@ describe("when: branch is clean and has an open PR", () => {
8789
});
8890
});
8991

92+
describe("when: repository has merge conflicts", () => {
93+
it("resolveQuickAction surfaces a resolve conflicts action", () => {
94+
const quick = resolveQuickAction(
95+
status({
96+
hasConflicts: true,
97+
conflictedFiles: ["src/app.tsx", "README.md"],
98+
hasWorkingTreeChanges: true,
99+
}),
100+
false,
101+
);
102+
assert.deepInclude(quick, {
103+
kind: "resolve_conflicts",
104+
label: "Resolve conflicts",
105+
disabled: false,
106+
});
107+
});
108+
109+
it("buildMenuItems disables commit, push, and pr actions", () => {
110+
const items = buildMenuItems(
111+
status({
112+
hasConflicts: true,
113+
conflictedFiles: ["src/app.tsx"],
114+
hasWorkingTreeChanges: true,
115+
aheadCount: 1,
116+
}),
117+
false,
118+
);
119+
assert.deepEqual(
120+
items.map((item) => ({ id: item.id, disabled: item.disabled })),
121+
[
122+
{ id: "commit", disabled: true },
123+
{ id: "push", disabled: true },
124+
{ id: "pr", disabled: true },
125+
],
126+
);
127+
});
128+
});
129+
90130
describe("when: actions are busy", () => {
91131
it("resolveQuickAction returns running disabled state", () => {
92132
const quick = resolveQuickAction(status(), true);

apps/web/src/components/GitActionsControl.tsx

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
677677
void promise.catch(() => undefined);
678678
return;
679679
}
680+
if (quickAction.kind === "resolve_conflicts") {
681+
openConflictedFilesInEditor();
682+
return;
683+
}
680684
if (quickAction.kind === "show_hint") {
681685
toastManager.add({
682686
type: "info",
@@ -689,7 +693,13 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
689693
if (quickAction.action) {
690694
void runGitActionWithToast({ action: quickAction.action });
691695
}
692-
}, [openExistingPr, pullMutation, quickAction, threadToastData]);
696+
}, [
697+
openConflictedFilesInEditor,
698+
openExistingPr,
699+
pullMutation,
700+
quickAction,
701+
threadToastData,
702+
]);
693703

694704
const openDialogForMenuItem = useCallback(
695705
(item: GitActionMenuItem) => {
@@ -758,6 +768,56 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
758768
[gitCwd, threadToastData],
759769
);
760770

771+
const openConflictedFilesInEditor = useCallback(() => {
772+
const conflictedFiles = gitStatusForActions?.conflictedFiles ?? [];
773+
if (!gitCwd || conflictedFiles.length === 0) {
774+
toastManager.add({
775+
type: "info",
776+
title: "No conflicted files",
777+
description: "Refresh git status if you recently resolved conflicts.",
778+
data: threadToastData,
779+
});
780+
return;
781+
}
782+
783+
const api = readNativeApi();
784+
if (!api) {
785+
toastManager.add({
786+
type: "error",
787+
title: "Editor opening is unavailable.",
788+
data: threadToastData,
789+
});
790+
return;
791+
}
792+
793+
const openPromise = (async () => {
794+
for (const filePath of conflictedFiles) {
795+
const target = resolvePathLinkTarget(filePath, gitCwd);
796+
await openInPreferredEditor(api, target);
797+
}
798+
return conflictedFiles.length;
799+
})();
800+
801+
toastManager.promise(openPromise, {
802+
loading: { title: "Opening conflicted files...", data: threadToastData },
803+
success: (count) => ({
804+
title: count === 1 ? "Opened conflicted file" : "Opened conflicted files",
805+
description:
806+
count === 1
807+
? conflictedFiles[0] ?? undefined
808+
: `${count} files opened in your editor.`,
809+
data: threadToastData,
810+
}),
811+
error: (error) => ({
812+
title: "Unable to open conflicted files",
813+
description: error instanceof Error ? error.message : "An error occurred.",
814+
data: threadToastData,
815+
}),
816+
});
817+
818+
void openPromise.catch(() => undefined);
819+
}, [gitCwd, gitStatusForActions?.conflictedFiles, threadToastData]);
820+
761821
if (!gitCwd) return null;
762822

763823
return (
@@ -866,6 +926,18 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
866926
Detached HEAD: create and checkout a branch to enable push and PR actions.
867927
</p>
868928
)}
929+
{gitStatusForActions?.hasConflicts && (
930+
<div className="space-y-2 px-2 py-2">
931+
<p className="text-warning text-xs">
932+
Resolve merge conflicts before committing, pulling, pushing, or opening a PR.
933+
</p>
934+
{gitStatusForActions.conflictedFiles.length > 0 ? (
935+
<Button size="xs" variant="outline" onClick={openConflictedFilesInEditor}>
936+
Open conflicted files
937+
</Button>
938+
) : null}
939+
</div>
940+
)}
869941
{gitStatusForActions &&
870942
gitStatusForActions.branch !== null &&
871943
!gitStatusForActions.hasWorkingTreeChanges &&

packages/contracts/src/git.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
GitPreparePullRequestThreadInput,
77
GitRunStackedActionInput,
88
GitResolvePullRequestResult,
9+
GitStatusResult,
910
} from "./git";
1011

1112
const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInput);
@@ -14,6 +15,7 @@ const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync(
1415
);
1516
const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedActionInput);
1617
const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult);
18+
const decodeGitStatusResult = Schema.decodeUnknownSync(GitStatusResult);
1719

1820
describe("GitCreateWorktreeInput", () => {
1921
it("accepts omitted newBranch for existing-branch worktrees", () => {
@@ -71,3 +73,26 @@ describe("GitRunStackedActionInput", () => {
7173
expect(parsed.action).toBe("commit");
7274
});
7375
});
76+
77+
describe("GitStatusResult", () => {
78+
it("decodes conflict metadata", () => {
79+
const parsed = decodeGitStatusResult({
80+
branch: "feature/conflicts",
81+
hasWorkingTreeChanges: true,
82+
hasConflicts: true,
83+
conflictedFiles: ["src/app.tsx"],
84+
workingTree: {
85+
files: [{ path: "src/app.tsx", insertions: 0, deletions: 0 }],
86+
insertions: 0,
87+
deletions: 0,
88+
},
89+
hasUpstream: true,
90+
aheadCount: 0,
91+
behindCount: 1,
92+
pr: null,
93+
});
94+
95+
expect(parsed.hasConflicts).toBe(true);
96+
expect(parsed.conflictedFiles).toEqual(["src/app.tsx"]);
97+
});
98+
});

0 commit comments

Comments
 (0)