Skip to content

Commit 4156c90

Browse files
authored
Fallback to available git branches for new worktrees (#108)
- Auto-select a local branch when the requested worktree base is missing - Surface the chosen base branch in the UI and explain worktree errors more clearly - Track worktree base branch in thread state and contracts
1 parent 69d1e14 commit 4156c90

13 files changed

Lines changed: 346 additions & 19 deletions

File tree

apps/server/src/git/Layers/GitCore.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,24 @@ function initRepoWithCommit(
111111
});
112112
}
113113

114+
function initRepoWithCommitOnBranch(
115+
cwd: string,
116+
initialBranch: string,
117+
): Effect.Effect<
118+
void,
119+
GitCommandError | PlatformError.PlatformError,
120+
FileSystem.FileSystem | GitCore
121+
> {
122+
return Effect.gen(function* () {
123+
yield* git(cwd, ["init", `--initial-branch=${initialBranch}`]);
124+
yield* git(cwd, ["config", "user.email", "test@test.com"]);
125+
yield* git(cwd, ["config", "user.name", "Test"]);
126+
yield* writeTextFile(path.join(cwd, "README.md"), "# test\n");
127+
yield* git(cwd, ["add", "."]);
128+
yield* git(cwd, ["commit", "-m", "initial commit"]);
129+
});
130+
}
131+
114132
function commitWithDate(
115133
cwd: string,
116134
fileName: string,
@@ -883,6 +901,55 @@ it.layer(TestLayer)("git integration", (it) => {
883901
// ── createGitWorktree + removeGitWorktree ──
884902

885903
describe("createGitWorktree", () => {
904+
it.effect("auto-detects the current branch when the requested base branch is missing", () =>
905+
Effect.gen(function* () {
906+
const tmp = yield* makeTmpDir();
907+
yield* initRepoWithCommitOnBranch(tmp, "master");
908+
const core = yield* GitCore;
909+
910+
const wtPath = path.join(tmp, "worktree-missing-base");
911+
const result = yield* core.createWorktree({
912+
cwd: tmp,
913+
branch: "main",
914+
newBranch: "wt-missing-base",
915+
path: wtPath,
916+
});
917+
918+
expect(result.worktree.path).toBe(wtPath);
919+
expect(result.worktree.branch).toBe("wt-missing-base");
920+
expect(result.worktree.baseBranch).toBe("master");
921+
expect(existsSync(wtPath)).toBe(true);
922+
expect(existsSync(path.join(wtPath, "README.md"))).toBe(true);
923+
expect(yield* git(wtPath, ["branch", "--show-current"])).toBe("wt-missing-base");
924+
925+
yield* core.removeWorktree({ cwd: tmp, path: wtPath });
926+
}),
927+
);
928+
929+
it.effect("rejects an unborn base branch with a helpful solution", () =>
930+
Effect.gen(function* () {
931+
const tmp = yield* makeTmpDir();
932+
const core = yield* GitCore;
933+
yield* core.initRepo({ cwd: tmp });
934+
935+
const wtPath = path.join(tmp, "worktree-unborn-base");
936+
const error = yield* Effect.flip(
937+
core.createWorktree({
938+
cwd: tmp,
939+
branch: "main",
940+
newBranch: "wt-unborn-base",
941+
path: wtPath,
942+
}),
943+
);
944+
945+
expect(error).toBeInstanceOf(GitCommandError);
946+
expect(error.message).toContain("Base branch 'main' does not resolve to a commit yet.");
947+
expect(error.message).toContain(
948+
"Create the first commit or switch to Local mode before starting a worktree thread.",
949+
);
950+
}),
951+
);
952+
886953
it.effect("creates a worktree with a new branch from the base branch", () =>
887954
Effect.gen(function* () {
888955
const tmp = yield* makeTmpDir();

apps/server/src/git/Layers/GitCore.ts

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,45 @@ function createGitCommandError(
278278
});
279279
}
280280

281+
function buildCreateWorktreeBaseBranchDetail(input: {
282+
branch: string;
283+
branches: ReadonlyArray<{ name: string }>;
284+
isRepo: boolean;
285+
}): string {
286+
const branchDetail = `Base branch '${input.branch}' does not resolve to a commit yet.`;
287+
if (!input.isRepo) {
288+
return `${branchDetail} This directory is not a git repository. Open a git repository or switch to Local mode before starting a worktree thread.`;
289+
}
290+
291+
const availableBranches = input.branches.map((branch) => branch.name).slice(0, 5);
292+
if (availableBranches.length === 0) {
293+
return `${branchDetail} This repository has no committed branches yet. Create the first commit or switch to Local mode before starting a worktree thread.`;
294+
}
295+
296+
return `${branchDetail} Available branches: ${availableBranches.join(", ")}. Create the first commit or select a different branch before starting a worktree thread.`;
297+
}
298+
299+
function resolveCreateWorktreeFallbackBranch(input: {
300+
branches: ReadonlyArray<{
301+
current: boolean;
302+
isDefault: boolean;
303+
isRemote: boolean;
304+
name: string;
305+
}>;
306+
}): string | null {
307+
const localBranches = input.branches.filter((branch) => !branch.isRemote);
308+
if (localBranches.length === 0) {
309+
return null;
310+
}
311+
312+
return (
313+
localBranches.find((branch) => branch.current)?.name ??
314+
localBranches.find((branch) => branch.isDefault)?.name ??
315+
localBranches[0]?.name ??
316+
null
317+
);
318+
}
319+
281320
function quoteGitCommand(args: ReadonlyArray<string>): string {
282321
return `git ${args.join(" ")}`;
283322
}
@@ -1638,10 +1677,64 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
16381677
const sanitizedBranch = targetBranch.replace(/\//g, "-");
16391678
const repoName = path.basename(input.cwd);
16401679
const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch);
1641-
const args = input.newBranch
1642-
? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch]
1643-
: ["worktree", "add", worktreePath, input.branch];
1680+
let baseBranch = input.branch;
16441681

1682+
const baseRefCheck = yield* executeGit(
1683+
"GitCore.createWorktree.baseRefCheck",
1684+
input.cwd,
1685+
["rev-parse", "--verify", "--quiet", `${input.branch}^{commit}`],
1686+
{
1687+
allowNonZeroExit: true,
1688+
timeoutMs: 5_000,
1689+
},
1690+
);
1691+
if (baseRefCheck.code !== 0) {
1692+
const branchesResult = yield* Effect.result(listBranches({ cwd: input.cwd }));
1693+
if (branchesResult._tag === "Success") {
1694+
const fallbackBranch = resolveCreateWorktreeFallbackBranch({
1695+
branches: branchesResult.success.branches.map((branch) => ({
1696+
current: branch.current,
1697+
isDefault: branch.isDefault,
1698+
isRemote: Boolean(branch.isRemote),
1699+
name: branch.name,
1700+
})),
1701+
});
1702+
if (fallbackBranch && fallbackBranch !== input.branch) {
1703+
baseBranch = fallbackBranch;
1704+
} else {
1705+
const detail = buildCreateWorktreeBaseBranchDetail({
1706+
branch: input.branch,
1707+
branches: branchesResult.success.branches.map((branch) => ({
1708+
name: branch.name,
1709+
})),
1710+
isRepo: branchesResult.success.isRepo,
1711+
});
1712+
const args = input.newBranch
1713+
? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch]
1714+
: ["worktree", "add", worktreePath, input.branch];
1715+
return yield* createGitCommandError(
1716+
"GitCore.createWorktree",
1717+
input.cwd,
1718+
args,
1719+
detail,
1720+
);
1721+
}
1722+
} else {
1723+
const args = input.newBranch
1724+
? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch]
1725+
: ["worktree", "add", worktreePath, input.branch];
1726+
return yield* createGitCommandError(
1727+
"GitCore.createWorktree",
1728+
input.cwd,
1729+
args,
1730+
`Base branch '${input.branch}' does not resolve to a commit yet. Create the first commit or switch to Local mode before starting a worktree thread.`,
1731+
);
1732+
}
1733+
}
1734+
1735+
const args = input.newBranch
1736+
? ["worktree", "add", "-b", input.newBranch, worktreePath, baseBranch]
1737+
: ["worktree", "add", worktreePath, baseBranch];
16451738
yield* executeGit("GitCore.createWorktree", input.cwd, args, {
16461739
fallbackErrorMessage: "git worktree add failed",
16471740
});
@@ -1650,6 +1743,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
16501743
worktree: {
16511744
path: worktreePath,
16521745
branch: targetBranch,
1746+
baseBranch,
16531747
},
16541748
};
16551749
});

apps/web/src/components/BranchToolbar.tsx

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import {
1111
resolveDraftEnvModeAfterBranchChange,
1212
resolveEffectiveEnvMode,
1313
} from "./BranchToolbar.logic";
14+
import { Badge } from "./ui/badge";
1415
import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector";
1516
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
17+
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
1618

1719
const envModeItems = [
1820
{ value: "local", label: "Local" },
@@ -46,6 +48,8 @@ export default function BranchToolbar({
4648
const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined);
4749
const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null;
4850
const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
51+
const activeWorktreeBaseBranch =
52+
serverThread?.worktreeBaseBranch ?? draftThread?.worktreeBaseBranch ?? null;
4953
const branchCwd = activeWorktreePath ?? activeProject?.cwd ?? null;
5054
const hasServerThread = serverThread !== undefined;
5155
const effectiveEnvMode = resolveEffectiveEnvMode({
@@ -109,7 +113,7 @@ export default function BranchToolbar({
109113
if (!activeThreadId || !activeProject) return null;
110114

111115
return (
112-
<div className="mx-auto flex w-full max-w-7xl items-center justify-between px-5 pb-3 pt-1">
116+
<div className="mx-auto flex w-full max-w-7xl items-end justify-between px-5 pb-3 pt-1">
113117
{envLocked || activeWorktreePath ? (
114118
<span className="inline-flex items-center gap-1 border border-transparent px-[calc(--spacing(3)-1px)] text-sm font-medium text-muted-foreground/70 sm:text-xs">
115119
{activeWorktreePath ? (
@@ -155,17 +159,37 @@ export default function BranchToolbar({
155159
</Select>
156160
)}
157161

158-
<BranchToolbarBranchSelector
159-
activeProjectCwd={activeProject.cwd}
160-
activeThreadBranch={activeThreadBranch}
161-
activeWorktreePath={activeWorktreePath}
162-
branchCwd={branchCwd}
163-
effectiveEnvMode={effectiveEnvMode}
164-
envLocked={envLocked}
165-
onSetThreadBranch={setThreadBranch}
166-
{...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})}
167-
{...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}
168-
/>
162+
<div className="flex flex-col items-end gap-1">
163+
<BranchToolbarBranchSelector
164+
activeProjectCwd={activeProject.cwd}
165+
activeThreadBranch={activeThreadBranch}
166+
activeWorktreePath={activeWorktreePath}
167+
branchCwd={branchCwd}
168+
effectiveEnvMode={effectiveEnvMode}
169+
envLocked={envLocked}
170+
onSetThreadBranch={setThreadBranch}
171+
{...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})}
172+
{...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}
173+
/>
174+
{activeWorktreePath && activeWorktreeBaseBranch ? (
175+
<Tooltip>
176+
<TooltipTrigger
177+
render={
178+
<Badge
179+
variant="outline"
180+
size="sm"
181+
className="max-w-56 truncate px-2 text-[11px] text-muted-foreground"
182+
>
183+
Base: {activeWorktreeBaseBranch}
184+
</Badge>
185+
}
186+
/>
187+
<TooltipPopup side="bottom" align="end">
188+
OK Code created this worktree from {activeWorktreeBaseBranch}.
189+
</TooltipPopup>
190+
</Tooltip>
191+
) : null}
192+
</div>
169193
</div>
170194
);
171195
}

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { ThreadId } from "@okcode/contracts";
22
import { describe, expect, it } from "vitest";
33

4-
import { buildExpiredTerminalContextToastCopy, deriveComposerSendState } from "./ChatView.logic";
4+
import {
5+
buildAutoSelectedWorktreeBaseBranchToastCopy,
6+
buildExpiredTerminalContextToastCopy,
7+
deriveComposerSendState,
8+
} from "./ChatView.logic";
59

610
describe("deriveComposerSendState", () => {
711
it("treats expired terminal pills as non-sendable content", () => {
@@ -67,3 +71,18 @@ describe("buildExpiredTerminalContextToastCopy", () => {
6771
});
6872
});
6973
});
74+
75+
describe("buildAutoSelectedWorktreeBaseBranchToastCopy", () => {
76+
it("explains the branch fallback clearly", () => {
77+
expect(
78+
buildAutoSelectedWorktreeBaseBranchToastCopy({
79+
requestedBranch: "main",
80+
selectedBranch: "master",
81+
}),
82+
).toEqual({
83+
title: "Using master instead of main",
84+
description:
85+
"The requested base branch main was unavailable, so OK Code created this worktree from master.",
86+
});
87+
});
88+
});

apps/web/src/components/ChatView.logic.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export function buildLocalDraftThread(
3636
lastVisitedAt: draftThread.createdAt,
3737
branch: draftThread.branch,
3838
worktreePath: draftThread.worktreePath,
39+
worktreeBaseBranch: null,
3940
turnDiffSummaries: [],
4041
activities: [],
4142
proposedPlans: [],
@@ -169,3 +170,16 @@ export function buildExpiredTerminalContextToastCopy(
169170
description: "Re-add it if you want that terminal output included.",
170171
};
171172
}
173+
174+
export function buildAutoSelectedWorktreeBaseBranchToastCopy(input: {
175+
requestedBranch: string;
176+
selectedBranch: string;
177+
}): {
178+
title: string;
179+
description: string;
180+
} {
181+
return {
182+
title: `Using ${input.selectedBranch} instead of ${input.requestedBranch}`,
183+
description: `The requested base branch ${input.requestedBranch} was unavailable, so OK Code created this worktree from ${input.selectedBranch}.`,
184+
};
185+
}

apps/web/src/components/ChatView.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ import { CompanionConnectionBanner } from "./chat/CompanionConnectionBanner";
191191
import { MobileThreadAttentionBar } from "./chat/MobileThreadAttentionBar";
192192
import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
193193
import {
194+
buildAutoSelectedWorktreeBaseBranchToastCopy,
194195
buildExpiredTerminalContextToastCopy,
195196
buildLocalDraftThread,
196197
buildTemporaryWorktreeBranchName,
@@ -290,6 +291,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
290291
const syncServerReadModel = useStore((store) => store.syncServerReadModel);
291292
const setStoreThreadError = useStore((store) => store.setError);
292293
const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
294+
const setStoreThreadWorktreeBaseBranch = useStore((store) => store.setThreadWorktreeBaseBranch);
293295
const { settings } = useAppSettings();
294296
const setStickyComposerModel = useComposerDraftStore((store) => store.setStickyModel);
295297
const timestampFormat = settings.timestampFormat;
@@ -3080,6 +3082,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
30803082
branch: baseBranchForWorktree,
30813083
newBranch,
30823084
});
3085+
if (result.worktree.baseBranch !== baseBranchForWorktree) {
3086+
const toastCopy = buildAutoSelectedWorktreeBaseBranchToastCopy({
3087+
requestedBranch: baseBranchForWorktree,
3088+
selectedBranch: result.worktree.baseBranch,
3089+
});
3090+
toastManager.add({
3091+
type: "warning",
3092+
title: toastCopy.title,
3093+
description: toastCopy.description,
3094+
});
3095+
}
3096+
setStoreThreadWorktreeBaseBranch(threadIdForSend, result.worktree.baseBranch);
30833097
nextThreadBranch = result.worktree.branch;
30843098
nextThreadWorktreePath = result.worktree.path;
30853099
if (isServerThread) {

0 commit comments

Comments
 (0)