Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ vi.mock("./components/BranchNamingSettings", () => ({
return null;
},
}));
vi.mock("./components/WorktreeBaseBranchSetting", () => ({
WorktreeBaseBranchSetting: () => null,
}));
vi.mock("./settings-environment-filter", () => ({
useSettingsEnvironmentFilter: () => ({
selectedTargets: mocks.targets,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { SettingsChoiceRow } from "./components/SettingsChoiceRow";
import { SettingsSection } from "./components/SettingsSection";
import { SettingsSwitchRow } from "./components/SettingsSwitchRow";
import { SettingsProjectOverridesSection } from "./components/SettingsProjectOverridesSection";
import { WorktreeBaseBranchSetting } from "./components/WorktreeBaseBranchSetting";
import { useSettingsEnvironmentFilter } from "./settings-environment-filter";
import { useSettingsWriter } from "./useSettingsWriter";
import {
Expand Down Expand Up @@ -62,6 +63,7 @@ const PAGE_PROJECT_KEYS: Record<SettingsPage, readonly ProjectScopedServerSettin
],
"source-control": ["defaultAutoPull", "removeAgentCreditsOnMerge"],
worktrees: [
"newWorktreesBaseBranch",
"newWorktreesStartFromOrigin",
"worktreeSubmodules",
"branchNamingMode",
Expand Down Expand Up @@ -421,6 +423,12 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
{props.page === "worktrees" ? (
<>
<SettingsSection title="New worktrees">
<WorktreeBaseBranchSetting
key={targetsKey}
value={uniform("newWorktreesBaseBranch")}
disabled={disabledFor("newWorktreesBaseBranch")}
onChange={write}
/>
<SettingsSwitchRow
icon="arrow.triangle.branch"
label="Start from remote"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { ServerSettingsPatch } from "@t3tools/contracts";
import { useEffect, useEffectEvent, useRef } from "react";
import { View } from "react-native";

import { AppText as Text, AppTextInput } from "../../../components/ui/AppText";

export function WorktreeBaseBranchSetting(props: {
value: string | null;
disabled: boolean;
onChange: (patch: ServerSettingsPatch) => void;
}) {
// Save on exit too: Android Back hides the keyboard without ending editing.
const draft = useRef<{ text: string; over: string | null } | null>(null);
const save = () => {
const current = draft.current;
draft.current = null;
// Drop a draft typed over an older saved value: the updated value has already remounted the input.
if (current === null || current.over !== props.value) return;
const value = current.text.trim();
if (props.value === null || value !== props.value) {
props.onChange({ newWorktreesBaseBranch: value });
}
};
const saveOnExit = useEffectEvent(save);
// oxlint-disable-next-line t3code/react-effect-budget -- leaving the screen must flush an unsaved edit
useEffect(() => () => saveOnExit(), []);
return (
<View className="gap-2 px-4 py-3">
<Text className="text-sm text-foreground">Base branch</Text>
<Text className="text-sm text-foreground-muted">
The branch, tag, or commit new worktrees start from, such as dev. Leave empty to use the
repository’s default branch.
</Text>
<AppTextInput
key={props.value}
accessibilityLabel="Base branch for new worktrees"
onChangeText={(text) => {
draft.current = { text, over: props.value };
}}
defaultValue={props.value ?? ""}
placeholder={props.value === null ? "Mixed" : "Repository default"}
editable={!props.disabled}
autoCapitalize="none"
autoCorrect={false}
className="min-h-10 rounded-xl px-3 py-2 text-base text-foreground"
onEndEditing={save}
/>
</View>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";

import {
fillNewTaskWorkspaceBranch,
resolveWorktreeSwitchBranch,
resolveNewTaskBranchLabel,
resolveNewTaskBranchWorktreePath,
resolveNewTaskLocalWorkspaceSelection,
Expand Down Expand Up @@ -116,6 +117,7 @@ describe("fillNewTaskWorkspaceBranch", () => {
branches,
projectCwd: "/repo",
worktreeDefaultSettled: true,
worktreeBaseBranch: "",
};

it("bases a new worktree on the default branch, even when only the remote has it", () => {
Expand All @@ -127,6 +129,32 @@ describe("fillNewTaskWorkspaceBranch", () => {
).toEqual({ mode: "worktree", branch: "origin/main", worktreePath: null });
});

it("bases a new worktree on the configured branch, even when refs have not listed it", () => {
expect(
fillNewTaskWorkspaceBranch({
...base,
branches: [],
worktreeBaseBranch: "dev",
selection: { mode: "worktree", branch: null, worktreePath: null },
}),
).toEqual({ mode: "worktree", branch: "dev", worktreePath: null });
});

it("keeps a configured base out of local selections", () => {
expect(
fillNewTaskWorkspaceBranch({
...base,
worktreeBaseBranch: "dev",
selection: {
mode: "local",
branch: null,
worktreePath: null,
followsCurrentCheckout: true,
},
}),
).toMatchObject({ branch: "feature" });
});

it("waits for the workspace default to settle before picking a base", () => {
const selection = { mode: "worktree", branch: null, worktreePath: null } as const;
expect(fillNewTaskWorkspaceBranch({ ...base, selection, worktreeDefaultSettled: false })).toBe(
Expand Down Expand Up @@ -167,5 +195,22 @@ describe("fillNewTaskWorkspaceBranch", () => {
it("keeps an explicit branch", () => {
const selection = { mode: "worktree", branch: "release", worktreePath: null } as const;
expect(fillNewTaskWorkspaceBranch({ ...base, selection })).toBe(selection);
expect(fillNewTaskWorkspaceBranch({ ...base, worktreeBaseBranch: "dev", selection })).toBe(
selection,
);
});
});

describe("resolveWorktreeSwitchBranch", () => {
it("starts a new worktree from the configured base, not the checkout's branch", () => {
expect(
resolveWorktreeSwitchBranch({ selectedBranch: "release", worktreeBaseBranch: "dev" }),
).toBeNull();
});

it("keeps today's behavior without a configured base", () => {
expect(resolveWorktreeSwitchBranch({ selectedBranch: "release", worktreeBaseBranch: "" })).toBe(
"release",
);
});
});
26 changes: 18 additions & 8 deletions apps/mobile/src/features/threads/new-task-context-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ export function resolveNewTaskLocalWorkspaceSelection(input: {
};
}

/** A checkout's branch is not a worktree base, so leaving it for a new worktree starts from the configured base. */
export function resolveWorktreeSwitchBranch(input: {
readonly selectedBranch: string | null;
readonly worktreeBaseBranch: string;
}): string | null {
return input.worktreeBaseBranch === "" ? input.selectedBranch : null;
}

export function resolveNewTaskBranchLabel(input: {
readonly branchName: string | null;
readonly startFromOrigin: boolean;
Expand Down Expand Up @@ -113,10 +121,9 @@ export function filterNewTaskBranches<T extends { readonly name: string }>(
}

/**
* A worktree with no chosen base starts from the default branch, and a local
* selection the user just switched to follows the current checkout until refs
* load. Both are derived, never written back, so a late ref list or t3.json
* default still wins.
* A worktree with no chosen base starts from the configured base, else the default branch,
* and a local selection the user just switched to follows the current checkout until refs
* load. Both are derived, never written back, so a late ref list or t3.json default still wins.
*/
export function fillNewTaskWorkspaceBranch(input: {
readonly selection: ComposerDraftWorkspaceSelection;
Expand All @@ -129,17 +136,18 @@ export function fillNewTaskWorkspaceBranch(input: {
}>;
readonly projectCwd: string | null;
readonly worktreeDefaultSettled: boolean;
readonly worktreeBaseBranch: string;
}): ComposerDraftWorkspaceSelection {
const { selection } = input;
if (selection.branch !== null) return selection;
if (selection.mode === "worktree") {
// The default may only exist as origin/<default>, so search remote refs too.
const base =
const fallback =
input.branches.find((branch) => branch.isDefault) ??
input.branches.find((branch) => !branch.isRemote && branch.current) ??
null;
input.branches.find((branch) => !branch.isRemote && branch.current);
const base = input.worktreeBaseBranch || fallback?.name;
return input.worktreeDefaultSettled && base
? { ...selection, branch: base.name, worktreePath: null }
? { ...selection, branch: base, worktreePath: null }
: selection;
}
if (!selection.followsCurrentCheckout || input.projectCwd === null) return selection;
Expand All @@ -166,6 +174,7 @@ export function resolveNewTaskDraftWorkspace(input: {
readonly isGitRepo: boolean;
readonly branches: Parameters<typeof fillNewTaskWorkspaceBranch>[0]["branches"];
readonly projectCwd: string | null;
readonly worktreeBaseBranch: string;
}): ComposerDraftWorkspaceSelection {
const selection = input.canChooseWorkspace ? input.selection : undefined;
return fillNewTaskWorkspaceBranch({
Expand All @@ -177,5 +186,6 @@ export function resolveNewTaskDraftWorkspace(input: {
branches: input.branches,
projectCwd: input.projectCwd,
worktreeDefaultSettled: selection?.mode !== undefined || input.defaultModeSettled,
worktreeBaseBranch: input.worktreeBaseBranch,
});
}
12 changes: 11 additions & 1 deletion apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import {
import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled";
import {
resolveNewTaskDraftWorkspace,
resolveWorktreeSwitchBranch,
resolveNewTaskBranchWorktreePath,
resolveNewTaskLocalWorkspaceSelection,
} from "./new-task-context-presentation";
Expand Down Expand Up @@ -460,6 +461,7 @@ export function NewTaskFlowProvider(
isGitRepo,
branches: allBranchRefs,
projectCwd: selectedProject?.workspaceRoot ?? null,
worktreeBaseBranch: projectSettings.settings.newWorktreesBaseBranch,
});
const workspaceSelection = resolveWorkspaceSelection(selectedProjectDraft.workspaceSelection);
const workspaceMode = workspaceSelection.mode;
Expand Down Expand Up @@ -687,7 +689,15 @@ export function NewTaskFlowProvider(
updateComposerDraftSettings(selectedProjectDraftKey, {
workspaceSelection: {
mode,
branch: mode === "local" ? localSelection.branch : selectedBranchName,
branch:
mode === "local"
? localSelection.branch
: workspaceMode === "local"
? resolveWorktreeSwitchBranch({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High threads/new-task-flow-provider.tsx:696

Switching to New worktree loses a manually selected branch when it matches the current checkout, so the task starts from the configured base (for example, dev) instead of the selected branch (release). The Current checkout picker checks out the chosen branch before selectBranch persists it, making selectedBranchName equal localSelection.branch; resolveWorktreeSwitchBranch mistakes that equality for an automatic selection and clears it. Preserve explicit draft selections and clear only selections that follow the checkout.

Also found in 1 other location(s)

apps/mobile/src/features/threads/new-task-context-presentation.ts:81

resolveWorktreeSwitchBranch treats branch-name equality as proof that the selection was automatic, so it also discards explicit user choices. In mobile's Current checkout picker, NewTaskContextPickerScreens.selectBranch checks out the chosen branch and then calls flow.selectBranch; consequently a manually picked release becomes both selectedBranch and checkoutBranch. With base dev configured, switching to New worktree now returns null here and fillNewTaskWorkspaceBranch replaces the user's release with dev, creating the task from the wrong base. Track whether the selection follows the checkout instead of inferring it from equal names.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/new-task-flow-provider.tsx around line 696:

Switching to New worktree loses a manually selected branch when it matches the current checkout, so the task starts from the configured base (for example, `dev`) instead of the selected branch (`release`). The Current checkout picker checks out the chosen branch before `selectBranch` persists it, making `selectedBranchName` equal `localSelection.branch`; `resolveWorktreeSwitchBranch` mistakes that equality for an automatic selection and clears it. Preserve explicit draft selections and clear only selections that follow the checkout.

Also found in 1 other location(s):
- apps/mobile/src/features/threads/new-task-context-presentation.ts:81 -- `resolveWorktreeSwitchBranch` treats branch-name equality as proof that the selection was automatic, so it also discards explicit user choices. In mobile's Current checkout picker, `NewTaskContextPickerScreens.selectBranch` checks out the chosen branch and then calls `flow.selectBranch`; consequently a manually picked `release` becomes both `selectedBranch` and `checkoutBranch`. With base `dev` configured, switching to New worktree now returns null here and `fillNewTaskWorkspaceBranch` replaces the user's `release` with `dev`, creating the task from the wrong base. Track whether the selection follows the checkout instead of inferring it from equal names.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, comparing branch names can't tell a checked-out pick from the synced checkout branch. Fixed in 097cde0 by dropping the guess: in Current checkout the picker checks a branch out rather than choosing a worktree base, so with a base configured, switching to New worktree now always starts from that base, and the picker shows it before sending. The user can still pick release there. With no base configured, the switch behaves as before.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

selectedBranch: selectedBranchName,
worktreeBaseBranch: projectSettings.settings.newWorktreesBaseBranch,
})
: selectedBranchName,
worktreePath: mode === "local" ? localSelection.worktreePath : selectedWorktreePath,
...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}),
// Refs have not named the current branch yet; the selection follows it.
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
canChooseWorkspace: !projectDefaults.isScratch,
defaultMode: projectDefaults.defaultWorkspaceMode,
defaultModeSettled: projectDefaults.defaultWorkspaceModeSettled,
worktreeBaseBranch: projectDefaults.projectSettings.settings.newWorktreesBaseBranch,
};
const draftBranchRefs = useBranches({
environmentId: pendingTask.environmentId,
Expand Down
9 changes: 9 additions & 0 deletions apps/mobile/src/state/pending-new-tasks-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const localDefault: PendingDraftProjectDefaults = {
canChooseWorkspace: true,
defaultMode: "local",
defaultModeSettled: true,
worktreeBaseBranch: "",
};
const worktreeDefault: PendingDraftProjectDefaults = { ...localDefault, defaultMode: "worktree" };
const resolve = (
Expand Down Expand Up @@ -63,6 +64,14 @@ describe("resolvePendingDraftBranch", () => {
const worktree = draft({ mode: "worktree", branch: null, worktreePath: null });
expect(pendingDraftBranchNeedsRefs(worktree, localDefault)).toBe(true);
expect(resolve({ draft: worktree, branches, projectCwd })).toBe("origin/main");
expect(
resolve({
draft: worktree,
defaults: { ...localDefault, worktreeBaseBranch: "dev" },
branches,
projectCwd,
}),
).toBe("dev");
});

it("shows the current branch for a local draft that follows the checkout", () => {
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/state/pending-new-tasks-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export interface PendingDraftProjectDefaults {
readonly canChooseWorkspace: boolean;
readonly defaultMode: "local" | "worktree";
readonly defaultModeSettled: boolean;
readonly worktreeBaseBranch: string;
}

/**
Expand Down Expand Up @@ -82,6 +83,7 @@ export function resolvePendingDraftBranch(input: {
isGitRepo: input.isGitRepo,
branches: input.branches,
projectCwd: input.projectCwd,
worktreeBaseBranch: input.defaults.worktreeBaseBranch,
}).branch;
}

Expand Down
39 changes: 39 additions & 0 deletions apps/web/src/components/BranchToolbar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
resolveEffectiveEnvMode,
resolveBranchToolbarPrBranch,
resolveLocalCheckoutBranchMismatch,
resolveWorktreeBaseBranchCandidate,
sanitizeNewRefName,
shouldIncludeBranchPickerItem,
} from "./BranchToolbar.logic";
Expand Down Expand Up @@ -45,6 +46,44 @@ describe("resolveBranchToolbarPrBranch", () => {
});
});

describe("resolveWorktreeBaseBranchCandidate", () => {
const input = {
configuredBaseBranch: "",
isRepo: true,
defaultBranchName: "main",
currentGitBranch: "feature/open-thread",
};

it("uses the repository default, not the open thread's branch, when no base is configured", () => {
expect(resolveWorktreeBaseBranchCandidate(input)).toBe("main");
expect(resolveWorktreeBaseBranchCandidate({ ...input, defaultBranchName: null })).toBe(
"feature/open-thread",
);
});

it("uses the configured base even when the loaded refs do not include it", () => {
expect(
resolveWorktreeBaseBranchCandidate({
...input,
configuredBaseBranch: "dev",
defaultBranchName: null,
currentGitBranch: null,
}),
).toBe("dev");
});

it("ignores a configured base outside a Git repository", () => {
expect(
resolveWorktreeBaseBranchCandidate({
configuredBaseBranch: "dev",
isRepo: false,
defaultBranchName: null,
currentGitBranch: null,
}),
).toBeNull();
});
});

describe("resolveLocalCheckoutBranchMismatch", () => {
it("detects when a local thread is associated with a different branch than the checkout", () => {
expect(
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/components/BranchToolbar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,17 @@ export function resolveBranchToolbarValue(input: {
return currentGitBranch ?? activeThreadBranch;
}

/** A configured base is used as typed; it may sit outside the loaded ref page or exist only on a remote. */
export function resolveWorktreeBaseBranchCandidate(input: {
configuredBaseBranch: string;
isRepo: boolean;
defaultBranchName: string | null;
currentGitBranch: string | null;
}): string | null {
if (input.isRepo && input.configuredBaseBranch !== "") return input.configuredBaseBranch;
return input.defaultBranchName ?? input.currentGitBranch;
}

export function resolveBranchTriggerLabel(input: {
activeWorktreePath: string | null;
effectiveEnvMode: EnvMode;
Expand Down
Loading
Loading