Skip to content
Draft
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
21 changes: 21 additions & 0 deletions apps/code/src/main/services/git/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,27 @@ export const getCurrentBranchOutput = z.string().nullable();
export const getAllBranchesInput = directoryPathInput;
export const getAllBranchesOutput = z.array(z.string());

// getGitBusyState schemas
export const gitBusyOperationSchema = z.enum([
"rebase",
"merge",
"cherry-pick",
"revert",
]);

export const gitBusyStateSchema = z.union([
z.object({ busy: z.literal(false) }),
z.object({
busy: z.literal(true),
operation: gitBusyOperationSchema,
}),
]);

export type { GitBusyOperation, GitBusyState } from "../../../shared/types";

export const getGitBusyStateInput = directoryPathInput;
export const getGitBusyStateOutput = gitBusyStateSchema;

// createBranch schemas
export const createBranchInput = z.object({
directoryPath: z.string(),
Expand Down
6 changes: 6 additions & 0 deletions apps/code/src/main/services/git/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
getDiffHead,
getDiffStats,
getFileAtHead,
getGitBusyState,
getLatestCommit,
getRemoteUrl,
getStagedDiff,
Expand Down Expand Up @@ -57,6 +58,7 @@ import type {
GetPrTemplateOutput,
GhAuthTokenOutput,
GhStatusOutput,
GitBusyState,
GitCommitInfo,
GitFileStatus,
GithubRef,
Expand Down Expand Up @@ -285,6 +287,10 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
return getAllBranches(directoryPath);
}

public async getGitBusyState(directoryPath: string): Promise<GitBusyState> {
return getGitBusyState(directoryPath);
}

public async createBranch(
directoryPath: string,
branchName: string,
Expand Down
7 changes: 7 additions & 0 deletions apps/code/src/main/trpc/routers/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import {
getDiffStatsOutput,
getFileAtHeadInput,
getFileAtHeadOutput,
getGitBusyStateInput,
getGitBusyStateOutput,
getGithubIssueInput,
getGithubIssueOutput,
getGithubPullRequestInput,
Expand Down Expand Up @@ -130,6 +132,11 @@ export const gitRouter = router({
.output(getAllBranchesOutput)
.query(({ input }) => getService().getAllBranches(input.directoryPath)),

getGitBusyState: publicProcedure
.input(getGitBusyStateInput)
.output(getGitBusyStateOutput)
.query(({ input }) => getService().getGitBusyState(input.directoryPath)),

createBranch: publicProcedure
.input(createBranchInput)
.mutation(({ input }) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from "@posthog/quill";
import { useTRPC } from "@renderer/trpc";
import { toast } from "@renderer/utils/toast";
import type { GitBusyOperation, GitBusyState } from "@shared/types";
import { useMutation, useQuery } from "@tanstack/react-query";
import { type RefObject, useEffect, useRef, useState } from "react";

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

const BUSY_OPERATION_LABEL: Record<GitBusyOperation, string> = {
rebase: "Rebasing",
merge: "Merging",
"cherry-pick": "Cherry-picking",
revert: "Reverting",
};

export function BranchSelector({
repoPath,
currentBranch,
Expand All @@ -85,6 +99,7 @@ export function BranchSelector({
isRefreshing = false,
taskId,
anchor,
busyState,
}: BranchSelectorProps) {
const [open, setOpen] = useState(false);
const [hovered, setHovered] = useState(false);
Expand Down Expand Up @@ -159,14 +174,34 @@ export function BranchSelector({
}
};

// In local mode, surface in-progress git operations (rebase/merge/etc.) so the
// user understands why there's no current branch and why we won't let them
// checkout a different one — checkout would fail with a hard-to-read git error.
const localBusy = !isSelectionOnly && busyState?.busy === true;
const busyOperationLabel =
localBusy && busyState?.busy
? BUSY_OPERATION_LABEL[busyState.operation]
: null;

const displayText = effectiveLoading
? "Loading..."
: (displayedBranch ?? "No branch");
: busyOperationLabel && !displayedBranch
? busyOperationLabel
: (displayedBranch ?? "No branch");

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

const isDisabled = !!(disabled || !repoPath || cloudStillLoading);
const isDisabled = !!(
disabled ||
!repoPath ||
cloudStillLoading ||
localBusy
);
const disabledReason =
localBusy && busyOperationLabel
? `${busyOperationLabel} in progress — finish or abort it to switch branches.`
: null;
const inputValue = isCloudMode ? (cloudSearchQuery ?? "") : searchQuery;

return (
Expand All @@ -188,7 +223,7 @@ export function BranchSelector({
filter={isCloudMode ? null : undefined}
>
<Tooltip
content={displayedBranch ?? "Switch branch"}
content={disabledReason ?? displayedBranch ?? "Switch branch"}
side="bottom"
open={hovered && !open && !effectiveLoading}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ export function useGitQueries(repoPath?: string) {
),
);

const { data: busyState } = useQuery(
trpc.git.getGitBusyState.queryOptions(
{ directoryPath: repoPath as string },
{
enabled: repoEnabled,
staleTime: 5_000,
refetchInterval: 30_000,
placeholderData: (prev) => prev,
},
),
);

const { data: syncStatus, isLoading: syncLoading } = useQuery(
trpc.git.getGitSyncStatus.queryOptions(
{ directoryPath: repoPath as string },
Expand Down Expand Up @@ -150,6 +162,7 @@ export function useGitQueries(repoPath?: string) {
currentBranch,
branchLoading,
defaultBranch,
busyState,
isLoading: isRepoLoading || changesLoading || syncLoading,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ export function TaskInput({
const lower = selectedRepository.toLowerCase();
return repositories.includes(lower) ? lower : null;
}, [selectedRepository, repositories]);
const { currentBranch, branchLoading, defaultBranch } =
const { currentBranch, branchLoading, defaultBranch, busyState } =
useGitQueries(selectedDirectory);

const selectedGithubUserIntegrationId = selectedCloudRepository
Expand Down Expand Up @@ -667,6 +667,7 @@ export function TaskInput({
workspaceMode={workspaceMode}
selectedBranch={selectedBranch}
onBranchSelect={setSelectedBranch}
busyState={busyState}
cloudBranches={cloudBranches}
cloudBranchesLoading={cloudBranchesLoading}
isRefreshing={cloudBranchesRefreshing}
Expand Down
6 changes: 6 additions & 0 deletions apps/code/src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,12 @@ export type GitFileStatus =
| "renamed"
| "untracked";

export type GitBusyOperation = "rebase" | "merge" | "cherry-pick" | "revert";

export type GitBusyState =
| { busy: false }
| { busy: true; operation: GitBusyOperation };

export interface ChangedFile {
path: string;
status: GitFileStatus;
Expand Down
87 changes: 87 additions & 0 deletions packages/git/src/queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { afterEach, describe, expect, it } from "vitest";
import { createGitClient } from "./client";
import {
detectDefaultBranch,
getAllBranches,
getBranchDiffPatchesByPath,
getGitBusyState,
splitUnifiedDiffByFile,
} from "./queries";

Expand Down Expand Up @@ -242,3 +244,88 @@ describe("getBranchDiffPatchesByPath", () => {
}
});
});

describe("getAllBranches", () => {
let repoDir: string | undefined;

afterEach(async () => {
if (repoDir) {
await rm(repoDir, { recursive: true, force: true });
repoDir = undefined;
}
});

async function setupRebaseConflict(dir: string): Promise<void> {
const git = createGitClient(dir);
// Branch from the initial commit, edit file on feature
await git.checkoutLocalBranch("feature");
await writeFile(path.join(dir, "file.txt"), "feature change\n");
await git.add(["file.txt"]);
await git.commit("on feature");
// Diverge main with a conflicting edit
await git.checkout("main");
await writeFile(path.join(dir, "file.txt"), "main change\n");
await git.add(["file.txt"]);
await git.commit("on main");
await git.checkout("feature");
// Force `--no-ff` rebase so it doesn't fast-forward; expect a conflict.
try {
await git.rebase(["main"]);
} catch {
// expected: rebase pauses on conflict, leaving HEAD on a pseudo-branch
}
}

it("returns only real branches, not the rebase pseudo-branch", async () => {
repoDir = await setupRepo("main");
await setupRebaseConflict(repoDir);

const branches = await getAllBranches(repoDir);
expect(branches).toEqual(expect.arrayContaining(["main", "feature"]));
expect(branches).not.toContain("(no");
expect(branches.every((b) => !b.startsWith("("))).toBe(true);
});
});

describe("getGitBusyState", () => {
let repoDir: string | undefined;

afterEach(async () => {
if (repoDir) {
await rm(repoDir, { recursive: true, force: true });
repoDir = undefined;
}
});

it("reports busy=false in a clean repo", async () => {
repoDir = await setupRepo("main");
expect(await getGitBusyState(repoDir)).toEqual({ busy: false });
});

it("detects an in-progress rebase", async () => {
repoDir = await setupRepo("main");
const git = createGitClient(repoDir);

await git.checkoutLocalBranch("feature");
await writeFile(path.join(repoDir, "file.txt"), "feature change\n");
await git.add(["file.txt"]);
await git.commit("on feature");

await git.checkout("main");
await writeFile(path.join(repoDir, "file.txt"), "main change\n");
await git.add(["file.txt"]);
await git.commit("on main");

await git.checkout("feature");
try {
await git.rebase(["main"]);
} catch {
// expected: conflict
}

expect(await getGitBusyState(repoDir)).toEqual({
busy: true,
operation: "rebase",
});
});
});
Loading
Loading