Skip to content

Commit 508420d

Browse files
posthog[bot]PostHog Codejonathanlab
authored
Fix branch selector confusion during merge/rebase conflicts (#2017)
Co-authored-by: PostHog Code <code@posthog.com> Co-authored-by: JonathanLab <jonathanmieloo@gmail.com>
1 parent 34e369d commit 508420d

12 files changed

Lines changed: 268 additions & 58 deletions

File tree

apps/code/src/main/services/file-watcher/service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,11 @@ export class FileWatcherService extends TypedEventEmitter<FileWatcherEvents> {
214214
(e) =>
215215
e.path.endsWith("/HEAD") ||
216216
e.path.endsWith("/index") ||
217+
e.path.endsWith("/MERGE_HEAD") ||
218+
e.path.endsWith("/CHERRY_PICK_HEAD") ||
219+
e.path.endsWith("/REVERT_HEAD") ||
220+
e.path.includes("/rebase-merge") ||
221+
e.path.includes("/rebase-apply") ||
217222
e.path.includes("/refs/heads/"),
218223
);
219224
if (isRelevant) {

apps/code/src/main/services/git/schemas.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,27 @@ export const getCurrentBranchOutput = z.string().nullable();
147147
export const getAllBranchesInput = directoryPathInput;
148148
export const getAllBranchesOutput = z.array(z.string());
149149

150+
// getGitBusyState schemas
151+
export const gitBusyOperationSchema = z.enum([
152+
"rebase",
153+
"merge",
154+
"cherry-pick",
155+
"revert",
156+
]);
157+
158+
export const gitBusyStateSchema = z.union([
159+
z.object({ busy: z.literal(false) }),
160+
z.object({
161+
busy: z.literal(true),
162+
operation: gitBusyOperationSchema,
163+
}),
164+
]);
165+
166+
export type { GitBusyOperation, GitBusyState } from "../../../shared/types";
167+
168+
export const getGitBusyStateInput = directoryPathInput;
169+
export const getGitBusyStateOutput = gitBusyStateSchema;
170+
150171
// createBranch schemas
151172
export const createBranchInput = z.object({
152173
directoryPath: z.string(),

apps/code/src/main/services/git/service.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
getDiffHead,
2020
getDiffStats,
2121
getFileAtHead,
22+
getGitBusyState,
2223
getLatestCommit,
2324
getRemoteUrl,
2425
getStagedDiff,
@@ -57,6 +58,7 @@ import type {
5758
GetPrTemplateOutput,
5859
GhAuthTokenOutput,
5960
GhStatusOutput,
61+
GitBusyState,
6062
GitCommitInfo,
6163
GitFileStatus,
6264
GithubRef,
@@ -285,6 +287,10 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
285287
return getAllBranches(directoryPath);
286288
}
287289

290+
public async getGitBusyState(directoryPath: string): Promise<GitBusyState> {
291+
return getGitBusyState(directoryPath);
292+
}
293+
288294
public async createBranch(
289295
directoryPath: string,
290296
branchName: string,

apps/code/src/main/trpc/routers/git.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import {
3535
getDiffStatsOutput,
3636
getFileAtHeadInput,
3737
getFileAtHeadOutput,
38+
getGitBusyStateInput,
39+
getGitBusyStateOutput,
3840
getGithubIssueInput,
3941
getGithubIssueOutput,
4042
getGithubPullRequestInput,
@@ -130,6 +132,11 @@ export const gitRouter = router({
130132
.output(getAllBranchesOutput)
131133
.query(({ input }) => getService().getAllBranches(input.directoryPath)),
132134

135+
getGitBusyState: publicProcedure
136+
.input(getGitBusyStateInput)
137+
.output(getGitBusyStateOutput)
138+
.query(({ input }) => getService().getGitBusyState(input.directoryPath)),
139+
133140
createBranch: publicProcedure
134141
.input(createBranchInput)
135142
.mutation(({ input }) =>

apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from "@posthog/quill";
2424
import { useTRPC } from "@renderer/trpc";
2525
import { toast } from "@renderer/utils/toast";
26+
import type { GitBusyOperation, GitBusyState } from "@shared/types";
2627
import { useMutation, useQuery } from "@tanstack/react-query";
2728
import { type RefObject, useEffect, useRef, useState } from "react";
2829

@@ -61,8 +62,21 @@ interface BranchSelectorProps {
6162
isRefreshing?: boolean;
6263
taskId?: string;
6364
anchor?: RefObject<HTMLElement | null>;
65+
/**
66+
* Local-repo busy state (rebase, merge, cherry-pick, revert in progress).
67+
* Used to show a clearer label and prevent checkout attempts that would
68+
* fail while the working tree is mid-operation. Only applies in local mode.
69+
*/
70+
busyState?: GitBusyState;
6471
}
6572

73+
const BUSY_OPERATION_LABEL: Record<GitBusyOperation, string> = {
74+
rebase: "Rebasing",
75+
merge: "Merging",
76+
"cherry-pick": "Cherry-picking",
77+
revert: "Reverting",
78+
};
79+
6680
export function BranchSelector({
6781
repoPath,
6882
currentBranch,
@@ -86,6 +100,7 @@ export function BranchSelector({
86100
isRefreshing = false,
87101
taskId,
88102
anchor,
103+
busyState,
89104
}: BranchSelectorProps) {
90105
const [open, setOpen] = useState(false);
91106
const [hovered, setHovered] = useState(false);
@@ -167,14 +182,34 @@ export function BranchSelector({
167182
}
168183
};
169184

185+
// In local mode, surface in-progress git operations (rebase/merge/etc.) so the
186+
// user understands why there's no current branch and why we won't let them
187+
// checkout a different one — checkout would fail with a hard-to-read git error.
188+
const localBusy = !isSelectionOnly && busyState?.busy === true;
189+
const busyOperationLabel =
190+
localBusy && busyState?.busy
191+
? BUSY_OPERATION_LABEL[busyState.operation]
192+
: null;
193+
170194
const displayText = effectiveLoading
171195
? "Loading..."
172-
: (displayedBranch ?? "No branch");
196+
: busyOperationLabel && !displayedBranch
197+
? busyOperationLabel
198+
: (displayedBranch ?? "No branch");
173199

174200
const showSpinner =
175201
effectiveLoading || (isCloudMode && open && cloudBranchesFetchingMore);
176202

177-
const isDisabled = !!(disabled || !repoPath || cloudStillLoading);
203+
const isDisabled = !!(
204+
disabled ||
205+
!repoPath ||
206+
cloudStillLoading ||
207+
localBusy
208+
);
209+
const disabledReason =
210+
localBusy && busyOperationLabel
211+
? `${busyOperationLabel} in progress — finish or abort it to switch branches.`
212+
: null;
178213
const inputValue = isCloudMode ? (cloudSearchQuery ?? "") : searchQuery;
179214
const trimmedInputValue = inputValue.trim();
180215
const canUseInputBranch =
@@ -206,7 +241,7 @@ export function BranchSelector({
206241
filter={isCloudMode ? null : undefined}
207242
>
208243
<Tooltip
209-
content={displayedBranch ?? "Switch branch"}
244+
content={disabledReason ?? displayedBranch ?? "Switch branch"}
210245
side="bottom"
211246
open={hovered && !open && !effectiveLoading}
212247
>

apps/code/src/renderer/features/git-interaction/hooks/useGitQueries.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,18 @@ export function useGitQueries(
6565
),
6666
);
6767

68+
const { data: busyState } = useQuery(
69+
trpc.git.getGitBusyState.queryOptions(
70+
{ directoryPath: repoPath as string },
71+
{
72+
enabled: repoEnabled,
73+
staleTime: 5_000,
74+
refetchInterval: 30_000,
75+
placeholderData: (prev) => prev,
76+
},
77+
),
78+
);
79+
6880
const { data: syncStatus, isLoading: syncLoading } = useQuery(
6981
trpc.git.getGitSyncStatus.queryOptions(
7082
{ directoryPath: repoPath as string },
@@ -157,6 +169,7 @@ export function useGitQueries(
157169
currentBranch,
158170
branchLoading,
159171
defaultBranch,
172+
busyState,
160173
isLoading: isRepoLoading || changesLoading || syncLoading,
161174
};
162175
}

apps/code/src/renderer/features/git-interaction/utils/gitCacheKeys.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export function invalidateGitBranchQueries(repoPath: string) {
1515
const input = { directoryPath: repoPath };
1616
queryClient.invalidateQueries(trpc.git.getCurrentBranch.queryFilter(input));
1717
queryClient.invalidateQueries(trpc.git.getAllBranches.queryFilter(input));
18+
queryClient.invalidateQueries(trpc.git.getGitBusyState.queryFilter(input));
1819
queryClient.invalidateQueries(trpc.git.getGitSyncStatus.queryFilter(input));
1920
queryClient.invalidateQueries(
2021
trpc.git.getChangedFilesHead.queryFilter(input),

apps/code/src/renderer/features/task-detail/components/TaskInput.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ export function TaskInput({
221221
const lower = selectedRepository.toLowerCase();
222222
return repositories.includes(lower) ? lower : null;
223223
}, [selectedRepository, repositories]);
224-
const { currentBranch, branchLoading, defaultBranch } =
224+
const { currentBranch, branchLoading, defaultBranch, busyState } =
225225
useGitQueries(selectedDirectory);
226226

227227
const selectedGithubUserIntegrationId = selectedCloudRepository
@@ -703,6 +703,7 @@ export function TaskInput({
703703
workspaceMode={workspaceMode}
704704
selectedBranch={selectedBranch}
705705
onBranchSelect={setSelectedBranch}
706+
busyState={busyState}
706707
cloudBranches={cloudBranches}
707708
cloudBranchesLoading={cloudBranchesLoading}
708709
isRefreshing={cloudBranchesRefreshing}

apps/code/src/shared/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,12 @@ export type GitFileStatus =
218218
| "renamed"
219219
| "untracked";
220220

221+
export type GitBusyOperation = "rebase" | "merge" | "cherry-pick" | "revert";
222+
223+
export type GitBusyState =
224+
| { busy: false }
225+
| { busy: true; operation: GitBusyOperation };
226+
221227
export interface ChangedFile {
222228
path: string;
223229
status: GitFileStatus;

packages/git/src/queries.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { afterEach, describe, expect, it } from "vitest";
55
import { createGitClient } from "./client";
66
import {
77
detectDefaultBranch,
8+
getAllBranches,
89
getBranchDiffPatchesByPath,
910
getChangedFilesDetailed,
11+
getGitBusyState,
1012
splitUnifiedDiffByFile,
1113
} from "./queries";
1214

@@ -312,3 +314,85 @@ describe("getChangedFilesDetailed > untracked line counts", () => {
312314
});
313315
});
314316
});
317+
318+
describe("getAllBranches", () => {
319+
let repoDir: string | undefined;
320+
321+
afterEach(async () => {
322+
if (repoDir) {
323+
await rm(repoDir, { recursive: true, force: true });
324+
repoDir = undefined;
325+
}
326+
});
327+
328+
async function setupRebaseConflict(dir: string): Promise<void> {
329+
const git = createGitClient(dir);
330+
await git.checkoutLocalBranch("feature");
331+
await writeFile(path.join(dir, "file.txt"), "feature change\n");
332+
await git.add(["file.txt"]);
333+
await git.commit("on feature");
334+
await git.checkout("main");
335+
await writeFile(path.join(dir, "file.txt"), "main change\n");
336+
await git.add(["file.txt"]);
337+
await git.commit("on main");
338+
await git.checkout("feature");
339+
try {
340+
await git.rebase(["main"]);
341+
} catch {
342+
// expected: rebase pauses on conflict, leaving HEAD on a pseudo-branch
343+
}
344+
}
345+
346+
it("returns only real branches, not the rebase pseudo-branch", async () => {
347+
repoDir = await setupRepo("main");
348+
await setupRebaseConflict(repoDir);
349+
350+
const branches = await getAllBranches(repoDir);
351+
expect(branches).toEqual(expect.arrayContaining(["main", "feature"]));
352+
expect(branches).not.toContain("(no");
353+
expect(branches.every((b) => !b.startsWith("("))).toBe(true);
354+
});
355+
});
356+
357+
describe("getGitBusyState", () => {
358+
let repoDir: string | undefined;
359+
360+
afterEach(async () => {
361+
if (repoDir) {
362+
await rm(repoDir, { recursive: true, force: true });
363+
repoDir = undefined;
364+
}
365+
});
366+
367+
it("reports busy=false in a clean repo", async () => {
368+
repoDir = await setupRepo("main");
369+
expect(await getGitBusyState(repoDir)).toEqual({ busy: false });
370+
});
371+
372+
it("detects an in-progress rebase", async () => {
373+
repoDir = await setupRepo("main");
374+
const git = createGitClient(repoDir);
375+
376+
await git.checkoutLocalBranch("feature");
377+
await writeFile(path.join(repoDir, "file.txt"), "feature change\n");
378+
await git.add(["file.txt"]);
379+
await git.commit("on feature");
380+
381+
await git.checkout("main");
382+
await writeFile(path.join(repoDir, "file.txt"), "main change\n");
383+
await git.add(["file.txt"]);
384+
await git.commit("on main");
385+
386+
await git.checkout("feature");
387+
try {
388+
await git.rebase(["main"]);
389+
} catch {
390+
// expected: conflict
391+
}
392+
393+
expect(await getGitBusyState(repoDir)).toEqual({
394+
busy: true,
395+
operation: "rebase",
396+
});
397+
});
398+
});

0 commit comments

Comments
 (0)