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
1 change: 1 addition & 0 deletions packages/core/src/task-detail/taskCreationHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface CreateWorkspaceArgs {
mode: WorkspaceMode;
branch?: string;
allowRemoteBranchCheckout?: boolean;
reuseExistingWorktree?: boolean;
}

export interface CreatedWorkspaceInfo {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/task-detail/taskCreationSaga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export class TaskCreationSaga extends Saga<
mode: workspaceMode,
branch: branch ?? undefined,
allowRemoteBranchCheckout: input.allowRemoteBranchCheckout,
reuseExistingWorktree: input.reuseExistingWorktree,
});
},
rollback: async () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/task-detail/taskInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface PrepareTaskInputOptions {
workspaceMode: WorkspaceMode;
branch?: string | null;
allowRemoteBranchCheckout?: boolean;
reuseExistingWorktree?: boolean;
executionMode?: ExecutionMode;
adapter?: "claude" | "codex";
model?: string;
Expand Down Expand Up @@ -41,6 +42,7 @@ export function prepareTaskInput(
workspaceMode: options.workspaceMode,
branch: options.branch,
allowRemoteBranchCheckout: options.allowRemoteBranchCheckout,
reuseExistingWorktree: options.reuseExistingWorktree,
executionMode: options.executionMode,
adapter: options.adapter,
model: options.model,
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/task-creation-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export interface TaskCreationInput {
// When the branch exists only on the remote, opt in to fetching and checking
// it out locally into the worktree (set after the user confirms).
allowRemoteBranchCheckout?: boolean;
// When a worktree is already checked out on the branch, opt in to reusing it
// for this task instead of creating a new one (set after the user confirms).
reuseExistingWorktree?: boolean;
githubIntegrationId?: number;
githubUserIntegrationId?: string;
executionMode?: ExecutionMode;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { FolderOpen, Warning } from "@phosphor-icons/react";
import { AlertDialog, Button, Code, Flex, Text } from "@radix-ui/themes";
import { useExistingWorktreeConfirmStore } from "../stores/existingWorktreeConfirmStore";

/**
* Globally-mounted confirmation shown when a user starts a worktree task on a
* branch that already has a worktree checked out. Confirming reuses that
* worktree for the task instead of creating a new one.
*/
export function ExistingWorktreeDialog() {
const isOpen = useExistingWorktreeConfirmStore((s) => s.isOpen);
const branch = useExistingWorktreeConfirmStore((s) => s.branch);
const worktreePath = useExistingWorktreeConfirmStore((s) => s.worktreePath);
const accept = useExistingWorktreeConfirmStore((s) => s.accept);
const cancel = useExistingWorktreeConfirmStore((s) => s.cancel);

return (
<AlertDialog.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) cancel();
}}
>
<AlertDialog.Content maxWidth="460px" size="2">
<AlertDialog.Title className="text-base">
<Flex align="center" gap="2">
<FolderOpen size={18} weight="bold" color="var(--accent-9)" />
Worktree already exists
</Flex>
</AlertDialog.Title>
<AlertDialog.Description className="text-sm">
A worktree is already checked out on{" "}
{branch ? <Code>{branch}</Code> : "this branch"}
{worktreePath ? (
<>
{" "}
at <Code>{worktreePath}</Code>
</>
) : null}
. Continue and use that worktree for this task?
</AlertDialog.Description>

<Flex align="start" gap="2" mt="3">
<Warning
size={16}
weight="bold"
color="var(--amber-9)"
className="mt-px shrink-0"
/>
<Text size="1" color="gray">
Deleting this task later removes the worktree and any uncommitted
work in it, even though it existed beforehand.
</Text>
</Flex>

<Flex justify="end" gap="2" mt="4">
<AlertDialog.Cancel>
<Button variant="soft" color="gray" size="1">
Cancel
</Button>
</AlertDialog.Cancel>
<AlertDialog.Action>
<Button variant="solid" size="1" onClick={accept}>
Use existing worktree
</Button>
</AlertDialog.Action>
</Flex>
</AlertDialog.Content>
</AlertDialog.Root>
);
}
39 changes: 34 additions & 5 deletions packages/ui/src/features/task-detail/hooks/useTaskCreation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@ import { useTaskInputHistoryStore } from "../../message-editor/taskInputHistoryS
import type { EditorHandle } from "../../message-editor/types";
import { useSettingsStore } from "../../settings/settingsStore";
import { useCreateTask } from "../../tasks/useTaskCrudMutations";
import { useTasks } from "../../tasks/useTasks";
import { useTourStore } from "../../tour/tourStore";
import { createFirstTaskTour } from "../../tour/tours/createFirstTaskTour";
import { useExistingWorktreeConfirmStore } from "../stores/existingWorktreeConfirmStore";
import { useRemoteBranchConfirmStore } from "../stores/remoteBranchConfirmStore";

const log = logger.scope("task-creation");
Expand Down Expand Up @@ -165,6 +167,8 @@ export function useTaskCreation({
);
const { invalidateTasks } = useCreateTask();
const { isOnline } = useConnectivity();
// Used to name the task occupying a branch's worktree when reuse is blocked.
const { data: tasks } = useTasks();

const hasRequiredPath =
workspaceMode === "cloud" ? !!selectedRepository : !!selectedDirectory;
Expand All @@ -184,18 +188,41 @@ export function useTaskCreation({
return false;
}

// If the chosen worktree branch only exists on the remote, confirm before
// fetching and checking it out locally. Done before the pending view so
// the dialog (and a cancel) don't leave a half-started task on screen.
// Confirm a couple of worktree branch situations before starting the
// task. Done before the pending view so a dialog (and a cancel) don't
// leave a half-started task on screen. Reusing an existing worktree takes
// priority over checking out a remote branch.
let allowRemoteBranchCheckout = false;
let reuseExistingWorktree = false;
if (workspaceMode === "worktree" && branch && selectedDirectory) {
try {
const { status } =
const { status, existingWorktreePath, existingWorktreeTaskId } =
await hostClient.workspace.checkWorktreeBranch.query({
mainRepoPath: selectedDirectory,
branch,
});
if (status === "remote-only") {
if (existingWorktreeTaskId) {
// The branch's worktree already belongs to another task. Don't
// create a duplicate; point the user at the task using it.
const occupant = tasks?.find(
(t) => t.id === existingWorktreeTaskId,
);
toast.error("Worktree already in use", {
description: occupant
? `${branch} already has a worktree used by "${occupant.title}". Open that task to keep working there.`
: `${branch} already has a worktree used by another task.`,
});
return false;
}
if (existingWorktreePath) {
const confirmed = await useExistingWorktreeConfirmStore
.getState()
.confirm(branch, existingWorktreePath);
if (!confirmed) {
return false;
}
reuseExistingWorktree = true;
} else if (status === "remote-only") {
const confirmed = await useRemoteBranchConfirmStore
.getState()
.confirm(branch);
Expand Down Expand Up @@ -250,6 +277,7 @@ export function useTaskCreation({
workspaceMode,
branch,
allowRemoteBranchCheckout,
reuseExistingWorktree,
executionMode,
adapter,
model,
Expand Down Expand Up @@ -353,6 +381,7 @@ export function useTaskCreation({
onTaskCreated,
hostClient,
taskService,
tasks,
],
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useExistingWorktreeConfirmStore } from "./existingWorktreeConfirmStore";

describe("existingWorktreeConfirmStore", () => {
beforeEach(() => {
useExistingWorktreeConfirmStore.setState({
isOpen: false,
branch: null,
worktreePath: null,
resolve: null,
});
});

it("starts closed", () => {
const state = useExistingWorktreeConfirmStore.getState();
expect(state.isOpen).toBe(false);
expect(state.branch).toBeNull();
expect(state.worktreePath).toBeNull();
});

it("confirm opens the dialog with the branch and path", () => {
void useExistingWorktreeConfirmStore
.getState()
.confirm("feature/x", "/wt/feature-x");
const state = useExistingWorktreeConfirmStore.getState();
expect(state.isOpen).toBe(true);
expect(state.branch).toBe("feature/x");
expect(state.worktreePath).toBe("/wt/feature-x");
});

it("accept resolves the pending promise with true and closes", async () => {
const promise = useExistingWorktreeConfirmStore
.getState()
.confirm("feature/x", "/wt/feature-x");
useExistingWorktreeConfirmStore.getState().accept();
await expect(promise).resolves.toBe(true);
const state = useExistingWorktreeConfirmStore.getState();
expect(state.isOpen).toBe(false);
expect(state.branch).toBeNull();
expect(state.worktreePath).toBeNull();
expect(state.resolve).toBeNull();
});

it("cancel resolves the pending promise with false and closes", async () => {
const promise = useExistingWorktreeConfirmStore
.getState()
.confirm("feature/x", "/wt/feature-x");
useExistingWorktreeConfirmStore.getState().cancel();
await expect(promise).resolves.toBe(false);
expect(useExistingWorktreeConfirmStore.getState().isOpen).toBe(false);
});

it("opening a second dialog resolves the first as cancelled", async () => {
const first = useExistingWorktreeConfirmStore
.getState()
.confirm("first", "/wt/first");
const second = useExistingWorktreeConfirmStore
.getState()
.confirm("second", "/wt/second");
await expect(first).resolves.toBe(false);
expect(useExistingWorktreeConfirmStore.getState().worktreePath).toBe(
"/wt/second",
);
useExistingWorktreeConfirmStore.getState().accept();
await expect(second).resolves.toBe(true);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { create } from "zustand";

interface ExistingWorktreeConfirmState {
isOpen: boolean;
branch: string | null;
worktreePath: string | null;
resolve: ((confirmed: boolean) => void) | null;
}

interface ExistingWorktreeConfirmActions {
/**
* Opens the confirmation dialog for a branch that already has a worktree and
* resolves to the user's choice. `true` reuses the existing worktree for the
* task; `false` cancels.
*/
confirm: (branch: string, worktreePath: string) => Promise<boolean>;
accept: () => void;
cancel: () => void;
}

type ExistingWorktreeConfirmStore = ExistingWorktreeConfirmState &
ExistingWorktreeConfirmActions;

export const useExistingWorktreeConfirmStore =
create<ExistingWorktreeConfirmStore>()((set, get) => ({
isOpen: false,
branch: null,
worktreePath: null,
resolve: null,

confirm: (branch, worktreePath) =>
new Promise<boolean>((resolve) => {
// Resolve any dialog already waiting before replacing it.
get().resolve?.(false);
set({ isOpen: true, branch, worktreePath, resolve });
}),

accept: () => {
get().resolve?.(true);
set({ isOpen: false, branch: null, worktreePath: null, resolve: null });
},

cancel: () => {
get().resolve?.(false);
set({ isOpen: false, branch: null, worktreePath: null, resolve: null });
},
}));
4 changes: 4 additions & 0 deletions packages/ui/src/router/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { ProjectSwitcher } from "@posthog/ui/features/sidebar/components/Project
import { SidebarNavSection } from "@posthog/ui/features/sidebar/components/SidebarNavSection";
import { useSidebarData } from "@posthog/ui/features/sidebar/useSidebarData";
import { useVisualTaskOrder } from "@posthog/ui/features/sidebar/useVisualTaskOrder";
import { ExistingWorktreeDialog } from "@posthog/ui/features/task-detail/components/ExistingWorktreeDialog";
import { RemoteBranchCheckoutDialog } from "@posthog/ui/features/task-detail/components/RemoteBranchCheckoutDialog";
import { useTasks } from "@posthog/ui/features/tasks/useTasks";
import { TourOverlay } from "@posthog/ui/features/tour/components/TourOverlay";
Expand Down Expand Up @@ -252,6 +253,7 @@ function RootLayout() {
/>
{billingEnabled && <UsageLimitModal />}
<RemoteBranchCheckoutDialog />
<ExistingWorktreeDialog />
{import.meta.env.DEV && (
<Suspense fallback={null}>
<TanStackDevtools />
Expand All @@ -276,6 +278,7 @@ function RootLayout() {
/>
{billingEnabled && <UsageLimitModal />}
<RemoteBranchCheckoutDialog />
<ExistingWorktreeDialog />
{import.meta.env.DEV && (
<Suspense fallback={null}>
<TanStackDevtools />
Expand Down Expand Up @@ -319,6 +322,7 @@ function RootLayout() {
<TourOverlay />
{billingEnabled && <UsageLimitModal />}
<RemoteBranchCheckoutDialog />
<ExistingWorktreeDialog />
<HedgehogMode />
{import.meta.env.DEV && (
<Suspense fallback={null}>
Expand Down
12 changes: 12 additions & 0 deletions packages/workspace-server/src/services/workspace/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export const createWorkspaceInput = z
// When set, a worktree branch that exists only on the remote is fetched and
// checked out locally instead of failing. Gated behind a user confirmation.
allowRemoteBranchCheckout: z.boolean().optional(),
// When set, an existing worktree already checked out on the branch is reused
// for the task instead of creating a new one. Gated behind a confirmation.
reuseExistingWorktree: z.boolean().optional(),
})
.refine(
(data) =>
Expand All @@ -46,6 +49,15 @@ export const checkWorktreeBranchOutput = z.object({
// "local": branch exists locally. "remote-only": exists on the remote but not
// locally. "missing": found neither locally nor on the remote.
status: z.enum(["trunk", "local", "remote-only", "missing"]),
// Path of an *unused* worktree already checked out on this branch, if any.
// Set only when no task is associated with it; the renderer offers to reuse
// it. Null when there is no managed worktree, or when one exists but is
// already taken by a task (see existingWorktreeTaskId).
existingWorktreePath: z.string().nullable(),
// Id of the task already using the managed worktree on this branch, if any.
// When set, the renderer blocks reuse and points the user at that task.
// Mutually exclusive with existingWorktreePath.
existingWorktreeTaskId: z.string().nullable(),
});

export const reconcileCloudWorkspacesInput = z.object({
Expand Down
Loading
Loading