diff --git a/apps/code/src/main/services/git/schemas.ts b/apps/code/src/main/services/git/schemas.ts index 32ce72be7..0398400f4 100644 --- a/apps/code/src/main/services/git/schemas.ts +++ b/apps/code/src/main/services/git/schemas.ts @@ -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(), diff --git a/apps/code/src/main/services/git/service.ts b/apps/code/src/main/services/git/service.ts index e69bb3d9d..04783bd03 100644 --- a/apps/code/src/main/services/git/service.ts +++ b/apps/code/src/main/services/git/service.ts @@ -19,6 +19,7 @@ import { getDiffHead, getDiffStats, getFileAtHead, + getGitBusyState, getLatestCommit, getRemoteUrl, getStagedDiff, @@ -57,6 +58,7 @@ import type { GetPrTemplateOutput, GhAuthTokenOutput, GhStatusOutput, + GitBusyState, GitCommitInfo, GitFileStatus, GithubRef, @@ -285,6 +287,10 @@ export class GitService extends TypedEventEmitter { return getAllBranches(directoryPath); } + public async getGitBusyState(directoryPath: string): Promise { + return getGitBusyState(directoryPath); + } + public async createBranch( directoryPath: string, branchName: string, diff --git a/apps/code/src/main/trpc/routers/git.ts b/apps/code/src/main/trpc/routers/git.ts index 25665cbd1..5137ed435 100644 --- a/apps/code/src/main/trpc/routers/git.ts +++ b/apps/code/src/main/trpc/routers/git.ts @@ -35,6 +35,8 @@ import { getDiffStatsOutput, getFileAtHeadInput, getFileAtHeadOutput, + getGitBusyStateInput, + getGitBusyStateOutput, getGithubIssueInput, getGithubIssueOutput, getGithubPullRequestInput, @@ -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 }) => diff --git a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx index ab6615bb1..8941a775e 100644 --- a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx +++ b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx @@ -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"; @@ -60,8 +61,21 @@ interface BranchSelectorProps { isRefreshing?: boolean; taskId?: string; anchor?: RefObject; + /** + * 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 = { + rebase: "Rebasing", + merge: "Merging", + "cherry-pick": "Cherry-picking", + revert: "Reverting", +}; + export function BranchSelector({ repoPath, currentBranch, @@ -85,6 +99,7 @@ export function BranchSelector({ isRefreshing = false, taskId, anchor, + busyState, }: BranchSelectorProps) { const [open, setOpen] = useState(false); const [hovered, setHovered] = useState(false); @@ -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 ( @@ -188,7 +223,7 @@ export function BranchSelector({ filter={isCloudMode ? null : undefined} > diff --git a/apps/code/src/renderer/features/git-interaction/hooks/useGitQueries.ts b/apps/code/src/renderer/features/git-interaction/hooks/useGitQueries.ts index c2cae954f..91775be30 100644 --- a/apps/code/src/renderer/features/git-interaction/hooks/useGitQueries.ts +++ b/apps/code/src/renderer/features/git-interaction/hooks/useGitQueries.ts @@ -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 }, @@ -150,6 +162,7 @@ export function useGitQueries(repoPath?: string) { currentBranch, branchLoading, defaultBranch, + busyState, isLoading: isRepoLoading || changesLoading || syncLoading, }; } diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 7f7713805..5222e4a3c 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -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 @@ -667,6 +667,7 @@ export function TaskInput({ workspaceMode={workspaceMode} selectedBranch={selectedBranch} onBranchSelect={setSelectedBranch} + busyState={busyState} cloudBranches={cloudBranches} cloudBranchesLoading={cloudBranchesLoading} isRefreshing={cloudBranchesRefreshing} diff --git a/apps/code/src/shared/types.ts b/apps/code/src/shared/types.ts index dc5cd119f..ce7f5bec5 100644 --- a/apps/code/src/shared/types.ts +++ b/apps/code/src/shared/types.ts @@ -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; diff --git a/packages/git/src/queries.test.ts b/packages/git/src/queries.test.ts index 6eab2e7b4..47f88245a 100644 --- a/packages/git/src/queries.test.ts +++ b/packages/git/src/queries.test.ts @@ -5,7 +5,9 @@ import { afterEach, describe, expect, it } from "vitest"; import { createGitClient } from "./client"; import { detectDefaultBranch, + getAllBranches, getBranchDiffPatchesByPath, + getGitBusyState, splitUnifiedDiffByFile, } from "./queries"; @@ -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 { + 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", + }); + }); +}); diff --git a/packages/git/src/queries.ts b/packages/git/src/queries.ts index f64fbd513..c43f15382 100644 --- a/packages/git/src/queries.ts +++ b/packages/git/src/queries.ts @@ -366,8 +366,16 @@ export async function getAllBranches( baseDir, async (git) => { try { - const summary = await git.branchLocal(); - return summary.all; + // Use `for-each-ref` rather than `branch --list` (via simple-git's + // branchLocal()): during a rebase or cherry-pick git surfaces a + // pseudo-branch like `(no branch, rebasing main)` which simple-git's + // parser mistakenly returns as a branch named `(no`. + const output = await git.raw([ + "for-each-ref", + "--format=%(refname:short)", + "refs/heads/", + ]); + return output.split("\n").filter(Boolean); } catch { return []; } @@ -376,6 +384,77 @@ export async function getAllBranches( ); } +export type GitBusyOperation = "rebase" | "merge" | "cherry-pick" | "revert"; + +export type GitBusyState = + | { busy: false } + | { busy: true; operation: GitBusyOperation }; + +export async function inspectGitBusyState(git: GitLike): Promise { + const toplevel = (await git.raw(["rev-parse", "--show-toplevel"])).trim(); + + const resolveGitPath = async (gitPath: string): Promise => { + const relative = ( + await git.raw(["rev-parse", "--git-path", gitPath]) + ).trim(); + return path.isAbsolute(relative) + ? relative + : path.resolve(toplevel, relative); + }; + + const pathExists = async (gitPath: string): Promise => { + const resolved = await resolveGitPath(gitPath); + try { + await fs.access(resolved); + return true; + } catch { + return false; + } + }; + + const dirExists = async (gitPath: string): Promise => { + const resolved = await resolveGitPath(gitPath); + try { + const stat = await fs.stat(resolved); + return stat.isDirectory(); + } catch { + return false; + } + }; + + if ((await dirExists("rebase-merge")) || (await dirExists("rebase-apply"))) { + return { busy: true, operation: "rebase" }; + } + if (await pathExists("MERGE_HEAD")) { + return { busy: true, operation: "merge" }; + } + if (await pathExists("CHERRY_PICK_HEAD")) { + return { busy: true, operation: "cherry-pick" }; + } + if (await pathExists("REVERT_HEAD")) { + return { busy: true, operation: "revert" }; + } + return { busy: false }; +} + +export async function getGitBusyState( + baseDir: string, + options?: CreateGitClientOptions, +): Promise { + const manager = getGitOperationManager(); + return manager.executeRead( + baseDir, + async (git) => { + try { + return await inspectGitBusyState(git); + } catch { + return { busy: false }; + } + }, + { signal: options?.abortSignal }, + ); +} + export type GitFileStatus = | "modified" | "added" diff --git a/packages/git/src/sagas/checkpoint.ts b/packages/git/src/sagas/checkpoint.ts index b95660c7b..a55540531 100644 --- a/packages/git/src/sagas/checkpoint.ts +++ b/packages/git/src/sagas/checkpoint.ts @@ -3,6 +3,9 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { createGitClient, type GitClient } from "../client"; import { GitSaga, type GitSagaInput } from "../git-saga"; +import { type GitBusyState, inspectGitBusyState } from "../queries"; + +export type { GitBusyState }; const CHECKPOINT_REF_PREFIX = "refs/posthog-code-checkpoint/"; const CHECKPOINT_VERSION = "v1"; @@ -33,10 +36,6 @@ interface CheckpointMetadata { timestamp: string | null; } -export type GitBusyState = - | { busy: false } - | { busy: true; operation: "rebase" | "merge" | "cherry-pick" | "revert" }; - export interface CaptureCheckpointInput extends GitSagaInput { checkpointId?: string; } @@ -390,54 +389,7 @@ async function hasUnmergedEntries(git: GitClient): Promise { } export async function getGitBusyState(git: GitClient): Promise { - const toplevel = (await git.raw(["rev-parse", "--show-toplevel"])).trim(); - - const resolveGitPath = async (gitPath: string): Promise => { - const relative = ( - await git.raw(["rev-parse", "--git-path", gitPath]) - ).trim(); - return path.isAbsolute(relative) - ? relative - : path.resolve(toplevel, relative); - }; - - const pathExists = async (gitPath: string): Promise => { - const resolved = await resolveGitPath(gitPath); - try { - await fs.access(resolved); - return true; - } catch { - return false; - } - }; - - const dirExists = async (gitPath: string): Promise => { - const resolved = await resolveGitPath(gitPath); - try { - const stat = await fs.stat(resolved); - return stat.isDirectory(); - } catch { - return false; - } - }; - - if ((await dirExists("rebase-merge")) || (await dirExists("rebase-apply"))) { - return { busy: true, operation: "rebase" }; - } - - if (await pathExists("MERGE_HEAD")) { - return { busy: true, operation: "merge" }; - } - - if (await pathExists("CHERRY_PICK_HEAD")) { - return { busy: true, operation: "cherry-pick" }; - } - - if (await pathExists("REVERT_HEAD")) { - return { busy: true, operation: "revert" }; - } - - return { busy: false }; + return inspectGitBusyState(git); } async function createWorktreeTree(