Skip to content

Commit bf5100a

Browse files
committed
feat: branch switcher
1 parent 5271711 commit bf5100a

14 files changed

Lines changed: 349 additions & 59 deletions

File tree

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,15 @@ export const createBranchInput = z.object({
142142
branchName: z.string(),
143143
});
144144

145+
export const checkoutBranchInput = z.object({
146+
directoryPath: z.string(),
147+
branchName: z.string(),
148+
});
149+
export const checkoutBranchOutput = z.object({
150+
previousBranch: z.string(),
151+
currentBranch: z.string(),
152+
});
153+
145154
// discardFileChanges schemas
146155
export const discardFileChangesInput = z.object({
147156
directoryPath: z.string(),

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
fetch as gitFetch,
2121
isGitRepository,
2222
} from "@twig/git/queries";
23-
import { CreateBranchSaga } from "@twig/git/sagas/branch";
23+
import { CreateBranchSaga, SwitchBranchSaga } from "@twig/git/sagas/branch";
2424
import { CloneSaga } from "@twig/git/sagas/clone";
2525
import { CommitSaga } from "@twig/git/sagas/commit";
2626
import { DiscardFileChangesSaga } from "@twig/git/sagas/discard";
@@ -241,6 +241,16 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
241241
if (!result.success) throw new Error(result.error);
242242
}
243243

244+
public async checkoutBranch(
245+
directoryPath: string,
246+
branchName: string,
247+
): Promise<{ previousBranch: string; currentBranch: string }> {
248+
const saga = new SwitchBranchSaga();
249+
const result = await saga.run({ baseDir: directoryPath, branchName });
250+
if (!result.success) throw new Error(result.error);
251+
return result.data;
252+
}
253+
244254
public async getChangedFilesHead(
245255
directoryPath: string,
246256
): Promise<ChangedFile[]> {

apps/twig/src/main/services/workspace/service.ts

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import * as fsPromises from "node:fs/promises";
33
import path from "node:path";
44
import type { TaskFolderAssociation, WorktreeInfo } from "@shared/types";
55
import { createGitClient } from "@twig/git/client";
6-
import { getCurrentBranch, hasTrackedFiles } from "@twig/git/queries";
6+
import {
7+
getCurrentBranch,
8+
getDefaultBranch,
9+
hasTrackedFiles,
10+
} from "@twig/git/queries";
711
import { CreateOrSwitchBranchSaga } from "@twig/git/sagas/branch";
812
import { DetachHeadSaga } from "@twig/git/sagas/head";
913
import { WorktreeManager } from "@twig/git/worktree";
@@ -461,32 +465,53 @@ export class WorkspaceService extends TypedEventEmitter<WorkspaceServiceEvents>
461465
let worktree: WorktreeInfo;
462466

463467
try {
464-
if (useExistingBranch && branch) {
465-
const currentBranch = await getCurrentBranch(mainRepoPath);
466-
if (currentBranch === branch) {
467-
log.info(
468-
`Main repo is on target branch ${branch}, detaching before creating worktree`,
469-
);
470-
const detachSaga = new DetachHeadSaga();
471-
const detachResult = await detachSaga.run({ baseDir: mainRepoPath });
472-
if (!detachResult.success) {
473-
throw new Error(`Failed to detach HEAD: ${detachResult.error}`);
474-
}
475-
}
468+
const defaultBranch = await getDefaultBranch(mainRepoPath).catch(
469+
() => "main",
470+
);
471+
const selectedBranch = branch ?? defaultBranch;
472+
const isTrunkSelected = selectedBranch === defaultBranch;
476473

477-
worktree =
478-
await worktreeManager.createWorktreeForExistingBranch(branch);
474+
if (isTrunkSelected) {
479475
log.info(
480-
`Created worktree for existing branch: ${worktree.worktreeName} at ${worktree.worktreePath} (branch: ${branch})`,
476+
`Trunk branch selected (${defaultBranch}), creating detached worktree`,
481477
);
482-
} else {
483-
// Standard mode: create new twig/ branch
484478
worktree = await worktreeManager.createWorktree({
485-
baseBranch: branch ?? undefined,
479+
baseBranch: defaultBranch,
486480
});
487481
log.info(
488-
`Created worktree: ${worktree.worktreeName} at ${worktree.worktreePath}`,
482+
`Created detached worktree from trunk: ${worktree.worktreeName} at ${worktree.worktreePath}`,
489483
);
484+
} else {
485+
log.info(
486+
`Non-trunk branch selected (${selectedBranch}), attempting checkout`,
487+
);
488+
try {
489+
worktree =
490+
await worktreeManager.createWorktreeForExistingBranch(
491+
selectedBranch,
492+
);
493+
log.info(
494+
`Created worktree with branch checkout: ${worktree.worktreeName} at ${worktree.worktreePath} (branch: ${selectedBranch})`,
495+
);
496+
} catch (checkoutError) {
497+
const errorMessage =
498+
checkoutError instanceof Error
499+
? checkoutError.message
500+
: String(checkoutError);
501+
if (errorMessage.includes("is already used by worktree")) {
502+
log.info(
503+
`Branch ${selectedBranch} is occupied, falling back to detached worktree`,
504+
);
505+
worktree = await worktreeManager.createWorktree({
506+
baseBranch: selectedBranch,
507+
});
508+
log.info(
509+
`Created detached worktree from occupied branch: ${worktree.worktreeName} at ${worktree.worktreePath}`,
510+
);
511+
} else {
512+
throw checkoutError;
513+
}
514+
}
490515
}
491516

492517
// Warn if worktree is empty but main repo has files

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { z } from "zod";
22
import { container } from "../../di/container.js";
33
import { MAIN_TOKENS } from "../../di/tokens.js";
44
import {
5+
checkoutBranchInput,
6+
checkoutBranchOutput,
57
cloneRepositoryInput,
68
cloneRepositoryOutput,
79
commitInput,
@@ -120,6 +122,13 @@ export const gitRouter = router({
120122
getService().createBranch(input.directoryPath, input.branchName),
121123
),
122124

125+
checkoutBranch: publicProcedure
126+
.input(checkoutBranchInput)
127+
.output(checkoutBranchOutput)
128+
.mutation(({ input }) =>
129+
getService().checkoutBranch(input.directoryPath, input.branchName),
130+
),
131+
123132
// File change operations
124133
getChangedFilesHead: publicProcedure
125134
.input(getChangedFilesHeadInput)

apps/twig/src/main/window.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,9 @@ export function createWindow(): void {
4444
const isDev = !app.isPackaged;
4545

4646
mainWindow = new BrowserWindow({
47-
width: 900,
47+
width: 1200,
4848
height: 600,
49-
minWidth: 900,
49+
minWidth: 1200,
5050
minHeight: 600,
5151
backgroundColor: "#0a0a0a",
5252
titleBarStyle: "hiddenInset",
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { Combobox } from "@components/ui/combobox/Combobox";
2+
import { useGitInteractionStore } from "@features/git-interaction/state/gitInteractionStore";
3+
import { GitBranch, Plus } from "@phosphor-icons/react";
4+
import { Flex, Spinner } from "@radix-ui/themes";
5+
import { trpcVanilla } from "@renderer/trpc";
6+
import { toast } from "@renderer/utils/toast";
7+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
8+
import { useEffect, useState } from "react";
9+
10+
interface BranchSelectorProps {
11+
repoPath: string | null;
12+
currentBranch: string | null;
13+
defaultBranch?: string | null;
14+
disabled?: boolean;
15+
loading?: boolean;
16+
variant?: "outline" | "ghost";
17+
workspaceMode?: "worktree" | "local";
18+
selectedBranch?: string | null;
19+
onBranchSelect?: (branch: string | null) => void;
20+
}
21+
22+
export function BranchSelector({
23+
repoPath,
24+
currentBranch,
25+
defaultBranch,
26+
disabled,
27+
loading,
28+
variant = "outline",
29+
workspaceMode,
30+
selectedBranch,
31+
onBranchSelect,
32+
}: BranchSelectorProps) {
33+
const [open, setOpen] = useState(false);
34+
const queryClient = useQueryClient();
35+
const { actions } = useGitInteractionStore();
36+
37+
const isWorktreeMode = workspaceMode === "worktree";
38+
const displayedBranch = isWorktreeMode ? selectedBranch : currentBranch;
39+
40+
useEffect(() => {
41+
if (isWorktreeMode && defaultBranch && !selectedBranch && onBranchSelect) {
42+
onBranchSelect(defaultBranch);
43+
}
44+
}, [isWorktreeMode, defaultBranch, selectedBranch, onBranchSelect]);
45+
46+
const { data: branches = [] } = useQuery({
47+
queryKey: ["git-all-branches", repoPath],
48+
queryFn: () =>
49+
trpcVanilla.git.getAllBranches.query({
50+
directoryPath: repoPath as string,
51+
}),
52+
enabled: !!repoPath && open,
53+
staleTime: 10_000,
54+
});
55+
56+
const checkoutMutation = useMutation({
57+
mutationFn: (branchName: string) =>
58+
trpcVanilla.git.checkoutBranch.mutate({
59+
directoryPath: repoPath as string,
60+
branchName,
61+
}),
62+
onSuccess: () => {
63+
queryClient.invalidateQueries({ queryKey: ["git-current-branch"] });
64+
queryClient.invalidateQueries({ queryKey: ["git-sync-status"] });
65+
queryClient.invalidateQueries({ queryKey: ["git-all-branches"] });
66+
queryClient.invalidateQueries({ queryKey: ["changed-files-head"] });
67+
},
68+
onError: (error, branchName) => {
69+
const message =
70+
error instanceof Error ? error.message : "Unknown error occurred";
71+
toast.error(`Failed to checkout ${branchName}`, { description: message });
72+
},
73+
});
74+
75+
const handleBranchChange = (value: string) => {
76+
if (isWorktreeMode) {
77+
onBranchSelect?.(value || null);
78+
} else if (value && value !== currentBranch) {
79+
checkoutMutation.mutate(value);
80+
}
81+
setOpen(false);
82+
};
83+
84+
const displayText = loading ? "Loading..." : (displayedBranch ?? "No branch");
85+
86+
const triggerContent = (
87+
<Flex align="center" gap="1" style={{ minWidth: 0 }}>
88+
{loading ? (
89+
<Spinner size="1" />
90+
) : (
91+
<GitBranch size={16} weight="regular" style={{ flexShrink: 0 }} />
92+
)}
93+
<span className="combobox-trigger-text">{displayText}</span>
94+
</Flex>
95+
);
96+
97+
const combobox = (
98+
<Combobox.Root
99+
value={displayedBranch ?? ""}
100+
onValueChange={handleBranchChange}
101+
open={open}
102+
onOpenChange={setOpen}
103+
size="1"
104+
disabled={disabled || !repoPath}
105+
>
106+
<Combobox.Trigger variant={variant} placeholder="No branch">
107+
{triggerContent}
108+
</Combobox.Trigger>
109+
110+
<Combobox.Content>
111+
<Combobox.Input placeholder="Search branches" />
112+
<Combobox.Empty>No branches found.</Combobox.Empty>
113+
114+
<Combobox.Group heading="Local branches">
115+
{branches.map((branch) => (
116+
<Combobox.Item
117+
key={branch}
118+
value={branch}
119+
icon={<GitBranch size={11} weight="regular" />}
120+
>
121+
{branch}
122+
</Combobox.Item>
123+
))}
124+
</Combobox.Group>
125+
126+
<Combobox.Footer>
127+
<button
128+
type="button"
129+
className="combobox-footer-button"
130+
onClick={() => {
131+
setOpen(false);
132+
actions.openBranch();
133+
}}
134+
>
135+
<Flex align="center" gap="2" style={{ color: "var(--accent-11)" }}>
136+
<Plus size={11} weight="bold" />
137+
<span>Create new branch</span>
138+
</Flex>
139+
</button>
140+
</Combobox.Footer>
141+
</Combobox.Content>
142+
</Combobox.Root>
143+
);
144+
145+
return combobox;
146+
}

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,17 @@ export function useGitQueries(repoPath?: string) {
4444
placeholderData: (prev) => prev ?? EMPTY_DIFF_STATS,
4545
});
4646

47+
const { data: currentBranchData, isLoading: branchLoading } = useQuery({
48+
queryKey: ["git-current-branch", repoPath],
49+
queryFn: () =>
50+
trpcVanilla.git.getCurrentBranch.query({
51+
directoryPath: repoPath as string,
52+
}),
53+
enabled: repoEnabled,
54+
staleTime: 10_000,
55+
placeholderData: (prev) => prev,
56+
});
57+
4758
const { data: syncStatus, isLoading: syncLoading } = useQuery({
4859
queryKey: ["git-sync-status", repoPath],
4960
queryFn: () =>
@@ -74,7 +85,7 @@ export function useGitQueries(repoPath?: string) {
7485
staleTime: 60_000,
7586
});
7687

77-
const currentBranch = syncStatus?.currentBranch ?? null;
88+
const currentBranch = currentBranchData ?? syncStatus?.currentBranch ?? null;
7889

7990
const { data: prStatus } = useQuery({
8091
queryKey: ["git-pr-status", repoPath, currentBranch],
@@ -121,6 +132,7 @@ export function useGitQueries(repoPath?: string) {
121132
hasRemote,
122133
isFeatureBranch,
123134
currentBranch,
135+
branchLoading,
124136
defaultBranch,
125137
isLoading: isRepoLoading || changesLoading || syncLoading,
126138
};

apps/twig/src/renderer/features/message-editor/components/DiffStatsIndicator.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { Circle } from "@phosphor-icons/react";
21
import { Flex, Text } from "@radix-ui/themes";
32
import { trpcVanilla } from "@renderer/trpc";
43
import { useQuery } from "@tanstack/react-query";
@@ -26,7 +25,6 @@ export function DiffStatsIndicator({ repoPath }: DiffStatsIndicatorProps) {
2625

2726
return (
2827
<Flex align="center" gap="2">
29-
<Circle size={4} weight="fill" color="var(--gray-9)" />
3028
<Text
3129
size="1"
3230
style={{

0 commit comments

Comments
 (0)