Skip to content
Merged
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
67 changes: 67 additions & 0 deletions apps/server/src/git/Layers/GitCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,24 @@ function initRepoWithCommit(
});
}

function initRepoWithCommitOnBranch(
cwd: string,
initialBranch: string,
): Effect.Effect<
void,
GitCommandError | PlatformError.PlatformError,
FileSystem.FileSystem | GitCore
> {
return Effect.gen(function* () {
yield* git(cwd, ["init", `--initial-branch=${initialBranch}`]);
yield* git(cwd, ["config", "user.email", "test@test.com"]);
yield* git(cwd, ["config", "user.name", "Test"]);
yield* writeTextFile(path.join(cwd, "README.md"), "# test\n");
yield* git(cwd, ["add", "."]);
yield* git(cwd, ["commit", "-m", "initial commit"]);
});
}

function commitWithDate(
cwd: string,
fileName: string,
Expand Down Expand Up @@ -883,6 +901,55 @@ it.layer(TestLayer)("git integration", (it) => {
// ── createGitWorktree + removeGitWorktree ──

describe("createGitWorktree", () => {
it.effect("auto-detects the current branch when the requested base branch is missing", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
yield* initRepoWithCommitOnBranch(tmp, "master");
const core = yield* GitCore;

const wtPath = path.join(tmp, "worktree-missing-base");
const result = yield* core.createWorktree({
cwd: tmp,
branch: "main",
newBranch: "wt-missing-base",
path: wtPath,
});

expect(result.worktree.path).toBe(wtPath);
expect(result.worktree.branch).toBe("wt-missing-base");
expect(result.worktree.baseBranch).toBe("master");
expect(existsSync(wtPath)).toBe(true);
expect(existsSync(path.join(wtPath, "README.md"))).toBe(true);
expect(yield* git(wtPath, ["branch", "--show-current"])).toBe("wt-missing-base");

yield* core.removeWorktree({ cwd: tmp, path: wtPath });
}),
);

it.effect("rejects an unborn base branch with a helpful solution", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
const core = yield* GitCore;
yield* core.initRepo({ cwd: tmp });

const wtPath = path.join(tmp, "worktree-unborn-base");
const error = yield* Effect.flip(
core.createWorktree({
cwd: tmp,
branch: "main",
newBranch: "wt-unborn-base",
path: wtPath,
}),
);

expect(error).toBeInstanceOf(GitCommandError);
expect(error.message).toContain("Base branch 'main' does not resolve to a commit yet.");
expect(error.message).toContain(
"Create the first commit or switch to Local mode before starting a worktree thread.",
);
}),
);

it.effect("creates a worktree with a new branch from the base branch", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
Expand Down
100 changes: 97 additions & 3 deletions apps/server/src/git/Layers/GitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,45 @@ function createGitCommandError(
});
}

function buildCreateWorktreeBaseBranchDetail(input: {
branch: string;
branches: ReadonlyArray<{ name: string }>;
isRepo: boolean;
}): string {
const branchDetail = `Base branch '${input.branch}' does not resolve to a commit yet.`;
if (!input.isRepo) {
return `${branchDetail} This directory is not a git repository. Open a git repository or switch to Local mode before starting a worktree thread.`;
}

const availableBranches = input.branches.map((branch) => branch.name).slice(0, 5);
if (availableBranches.length === 0) {
return `${branchDetail} This repository has no committed branches yet. Create the first commit or switch to Local mode before starting a worktree thread.`;
}

return `${branchDetail} Available branches: ${availableBranches.join(", ")}. Create the first commit or select a different branch before starting a worktree thread.`;
}

function resolveCreateWorktreeFallbackBranch(input: {
branches: ReadonlyArray<{
current: boolean;
isDefault: boolean;
isRemote: boolean;
name: string;
}>;
}): string | null {
const localBranches = input.branches.filter((branch) => !branch.isRemote);
if (localBranches.length === 0) {
return null;
}

return (
localBranches.find((branch) => branch.current)?.name ??
localBranches.find((branch) => branch.isDefault)?.name ??
localBranches[0]?.name ??
null
);
}

function quoteGitCommand(args: ReadonlyArray<string>): string {
return `git ${args.join(" ")}`;
}
Expand Down Expand Up @@ -1638,10 +1677,64 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
const sanitizedBranch = targetBranch.replace(/\//g, "-");
const repoName = path.basename(input.cwd);
const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch);
const args = input.newBranch
? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch]
: ["worktree", "add", worktreePath, input.branch];
let baseBranch = input.branch;

const baseRefCheck = yield* executeGit(
"GitCore.createWorktree.baseRefCheck",
input.cwd,
["rev-parse", "--verify", "--quiet", `${input.branch}^{commit}`],
{
allowNonZeroExit: true,
timeoutMs: 5_000,
},
);
if (baseRefCheck.code !== 0) {
const branchesResult = yield* Effect.result(listBranches({ cwd: input.cwd }));
if (branchesResult._tag === "Success") {
const fallbackBranch = resolveCreateWorktreeFallbackBranch({
branches: branchesResult.success.branches.map((branch) => ({
current: branch.current,
isDefault: branch.isDefault,
isRemote: Boolean(branch.isRemote),
name: branch.name,
})),
});
if (fallbackBranch && fallbackBranch !== input.branch) {
baseBranch = fallbackBranch;
} else {
const detail = buildCreateWorktreeBaseBranchDetail({
branch: input.branch,
branches: branchesResult.success.branches.map((branch) => ({
name: branch.name,
})),
isRepo: branchesResult.success.isRepo,
});
const args = input.newBranch
? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch]
: ["worktree", "add", worktreePath, input.branch];
return yield* createGitCommandError(
"GitCore.createWorktree",
input.cwd,
args,
detail,
);
}
} else {
const args = input.newBranch
? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch]
: ["worktree", "add", worktreePath, input.branch];
return yield* createGitCommandError(
"GitCore.createWorktree",
input.cwd,
args,
`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.`,
);
}
}

const args = input.newBranch
? ["worktree", "add", "-b", input.newBranch, worktreePath, baseBranch]
: ["worktree", "add", worktreePath, baseBranch];
yield* executeGit("GitCore.createWorktree", input.cwd, args, {
fallbackErrorMessage: "git worktree add failed",
});
Expand All @@ -1650,6 +1743,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
worktree: {
path: worktreePath,
branch: targetBranch,
baseBranch,
},
};
});
Expand Down
48 changes: 36 additions & 12 deletions apps/web/src/components/BranchToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import {
resolveDraftEnvModeAfterBranchChange,
resolveEffectiveEnvMode,
} from "./BranchToolbar.logic";
import { Badge } from "./ui/badge";
import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector";
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";

const envModeItems = [
{ value: "local", label: "Local" },
Expand Down Expand Up @@ -46,6 +48,8 @@ export default function BranchToolbar({
const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined);
const activeThreadBranch = serverThread?.branch ?? draftThread?.branch ?? null;
const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null;
const activeWorktreeBaseBranch =
serverThread?.worktreeBaseBranch ?? draftThread?.worktreeBaseBranch ?? null;
const branchCwd = activeWorktreePath ?? activeProject?.cwd ?? null;
const hasServerThread = serverThread !== undefined;
const effectiveEnvMode = resolveEffectiveEnvMode({
Expand Down Expand Up @@ -109,7 +113,7 @@ export default function BranchToolbar({
if (!activeThreadId || !activeProject) return null;

return (
<div className="mx-auto flex w-full max-w-7xl items-center justify-between px-5 pb-3 pt-1">
<div className="mx-auto flex w-full max-w-7xl items-end justify-between px-5 pb-3 pt-1">
{envLocked || activeWorktreePath ? (
<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">
{activeWorktreePath ? (
Expand Down Expand Up @@ -155,17 +159,37 @@ export default function BranchToolbar({
</Select>
)}

<BranchToolbarBranchSelector
activeProjectCwd={activeProject.cwd}
activeThreadBranch={activeThreadBranch}
activeWorktreePath={activeWorktreePath}
branchCwd={branchCwd}
effectiveEnvMode={effectiveEnvMode}
envLocked={envLocked}
onSetThreadBranch={setThreadBranch}
{...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})}
{...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}
/>
<div className="flex flex-col items-end gap-1">
<BranchToolbarBranchSelector
activeProjectCwd={activeProject.cwd}
activeThreadBranch={activeThreadBranch}
activeWorktreePath={activeWorktreePath}
branchCwd={branchCwd}
effectiveEnvMode={effectiveEnvMode}
envLocked={envLocked}
onSetThreadBranch={setThreadBranch}
{...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})}
{...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}
/>
{activeWorktreePath && activeWorktreeBaseBranch ? (
<Tooltip>
<TooltipTrigger
render={
<Badge
variant="outline"
size="sm"
className="max-w-56 truncate px-2 text-[11px] text-muted-foreground"
>
Base: {activeWorktreeBaseBranch}
</Badge>
}
/>
<TooltipPopup side="bottom" align="end">
OK Code created this worktree from {activeWorktreeBaseBranch}.
</TooltipPopup>
</Tooltip>
) : null}
</div>
</div>
);
}
21 changes: 20 additions & 1 deletion apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { ThreadId } from "@okcode/contracts";
import { describe, expect, it } from "vitest";

import { buildExpiredTerminalContextToastCopy, deriveComposerSendState } from "./ChatView.logic";
import {
buildAutoSelectedWorktreeBaseBranchToastCopy,
buildExpiredTerminalContextToastCopy,
deriveComposerSendState,
} from "./ChatView.logic";

describe("deriveComposerSendState", () => {
it("treats expired terminal pills as non-sendable content", () => {
Expand Down Expand Up @@ -67,3 +71,18 @@ describe("buildExpiredTerminalContextToastCopy", () => {
});
});
});

describe("buildAutoSelectedWorktreeBaseBranchToastCopy", () => {
it("explains the branch fallback clearly", () => {
expect(
buildAutoSelectedWorktreeBaseBranchToastCopy({
requestedBranch: "main",
selectedBranch: "master",
}),
).toEqual({
title: "Using master instead of main",
description:
"The requested base branch main was unavailable, so OK Code created this worktree from master.",
});
});
});
14 changes: 14 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export function buildLocalDraftThread(
lastVisitedAt: draftThread.createdAt,
branch: draftThread.branch,
worktreePath: draftThread.worktreePath,
worktreeBaseBranch: null,
turnDiffSummaries: [],
activities: [],
proposedPlans: [],
Expand Down Expand Up @@ -169,3 +170,16 @@ export function buildExpiredTerminalContextToastCopy(
description: "Re-add it if you want that terminal output included.",
};
}

export function buildAutoSelectedWorktreeBaseBranchToastCopy(input: {
requestedBranch: string;
selectedBranch: string;
}): {
title: string;
description: string;
} {
return {
title: `Using ${input.selectedBranch} instead of ${input.requestedBranch}`,
description: `The requested base branch ${input.requestedBranch} was unavailable, so OK Code created this worktree from ${input.selectedBranch}.`,
};
}
14 changes: 14 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ import { CompanionConnectionBanner } from "./chat/CompanionConnectionBanner";
import { MobileThreadAttentionBar } from "./chat/MobileThreadAttentionBar";
import { ThreadErrorBanner } from "./chat/ThreadErrorBanner";
import {
buildAutoSelectedWorktreeBaseBranchToastCopy,
buildExpiredTerminalContextToastCopy,
buildLocalDraftThread,
buildTemporaryWorktreeBranchName,
Expand Down Expand Up @@ -288,6 +289,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
const syncServerReadModel = useStore((store) => store.syncServerReadModel);
const setStoreThreadError = useStore((store) => store.setError);
const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
const setStoreThreadWorktreeBaseBranch = useStore((store) => store.setThreadWorktreeBaseBranch);
const { settings } = useAppSettings();
const setStickyComposerModel = useComposerDraftStore((store) => store.setStickyModel);
const timestampFormat = settings.timestampFormat;
Expand Down Expand Up @@ -2946,6 +2948,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
branch: baseBranchForWorktree,
newBranch,
});
if (result.worktree.baseBranch !== baseBranchForWorktree) {
const toastCopy = buildAutoSelectedWorktreeBaseBranchToastCopy({
requestedBranch: baseBranchForWorktree,
selectedBranch: result.worktree.baseBranch,
});
toastManager.add({
type: "warning",
title: toastCopy.title,
description: toastCopy.description,
});
}
setStoreThreadWorktreeBaseBranch(threadIdForSend, result.worktree.baseBranch);
nextThreadBranch = result.worktree.branch;
nextThreadWorktreePath = result.worktree.path;
if (isServerThread) {
Expand Down
Loading
Loading