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
1 change: 0 additions & 1 deletion src/mobile/views/GitView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,6 @@ export function GitView(props: {
<div className="m-ws-pane">
<GitReviewSidebar
project={effectiveProject}
mergeSyncLocation={project.location}
gitStatus={gitStatus}
selectedFile={selectedFile}
selectedStaged={selectedStaged}
Expand Down
17 changes: 16 additions & 1 deletion src/renderer/actions/gitCommandRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const bridgeMock = vi.hoisted(() => ({
const setWorktreeStatusMock = vi.hoisted(() =>
vi.fn<(worktreePath: string, status: GitStatusResult) => void>(),
);
const setStatusMock = vi.hoisted(() =>
vi.fn<(projectId: string, status: GitStatusResult) => void>(),
);

const toastMock = vi.hoisted(() => ({
danger: vi.fn<(message: string, options?: Record<string, unknown>) => void>(),
Expand All @@ -40,7 +43,7 @@ vi.mock("@/renderer/diagnostics/sentry", () => ({

vi.mock("@/renderer/state/gitStore", () => ({
useGitStore: {
getState: () => ({ setWorktreeStatus: setWorktreeStatusMock }),
getState: () => ({ setStatus: setStatusMock, setWorktreeStatus: setWorktreeStatusMock }),
},
}));

Expand Down Expand Up @@ -166,6 +169,18 @@ describe("gitCommandRunner", () => {
});
});

it("refreshes the project status after pulling the merged base", async () => {
const refreshedStatus = { ...cleanMainStatus, headSha: "updated" };
bridgeMock.getGitStatus
.mockResolvedValueOnce({ ...cleanMainStatus, behind: 1 })
.mockResolvedValueOnce(refreshedStatus);
bridgeMock.gitPull.mockResolvedValueOnce(undefined);

await pullMergedPrBaseIfPossible(projectLocation, "main", "project-1");

expect(setStatusMock).toHaveBeenCalledWith("project-1", refreshedStatus);
});

it.each<[string, Partial<GitStatusResult>]>([
["another branch", { branch: "feature" }],
["local commits", { ahead: 1 }],
Expand Down
12 changes: 12 additions & 0 deletions src/renderer/actions/gitCommandRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export async function runGitPullFromSource(
export async function pullMergedPrBaseIfPossible(
projectLocation: ProjectLocation,
baseBranch: string,
projectId?: string,
): Promise<void> {
try {
const status = await readBridge().getGitStatus({ projectLocation, detail: "summary" });
Expand All @@ -139,6 +140,17 @@ export async function pullMergedPrBaseIfPossible(
});
} catch (error) {
console.warn("[git] post-merge pull skipped", error);
return;
}
if (!projectId) return;
try {
const refreshedStatus = await readBridge().getGitStatus({
projectLocation,
detail: "summary",
});
useGitStore.getState().setStatus(projectId, refreshedStatus);
} catch (error) {
console.warn("[git] post-merge status refresh failed", error);
}
}

Expand Down
47 changes: 35 additions & 12 deletions src/renderer/components/thread/ThreadDraftComposerArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -456,11 +456,26 @@ export function ThreadDraftComposerArea(props: {
const projectStatus = useGitStore((s) => s.statuses[props.project.id]);
const hasUncommittedChanges =
!!projectStatus && projectStatus.staged.length + projectStatus.unstaged.length > 0;
const worktreeBase = branchSelection?.baseBranch ?? branchSelection?.branch ?? props.gitBranch;
const trackingWorktreeBase =
projectStatus &&
props.gitBranch &&
projectStatus.branch === props.gitBranch &&
projectStatus.behind > 0 &&
projectStatus.ahead === 0 &&
projectStatus.tracking
? projectStatus.tracking
: undefined;
const defaultWorktreeBase = trackingWorktreeBase ?? props.gitBranch;
const selectedWorktreeBase = branchSelection?.baseBranch ?? branchSelection?.branch;
const worktreeBase = selectedWorktreeBase ?? defaultWorktreeBase;
// The worktree dropdown's "+ changes" choice is offered whenever the current
// (dirty) checkout would be the worktree's fork point — independent of whether
// worktree mode is already on, since selecting it also turns worktree mode on.
const canBringChanges = hasUncommittedChanges && worktreeBase === props.gitBranch;
const canBringChanges =
hasUncommittedChanges &&
(selectedWorktreeBase === undefined ||
selectedWorktreeBase === props.gitBranch ||
selectedWorktreeBase === trackingWorktreeBase);
// Transferring is only meaningful once worktree mode is actually on.
const canTransferUncommitted = props.worktreeMode && canBringChanges;
const shouldTransferUncommitted =
Expand All @@ -474,7 +489,7 @@ export function ThreadDraftComposerArea(props: {
: "new";

function selectNewWorktree(overrides?: Partial<BranchSelection>) {
const base = worktreeBase ?? props.gitBranch ?? "";
const base = overrides?.baseBranch ?? worktreeBase ?? props.gitBranch ?? "";
setBranchSelection({ branch: base, baseBranch: base, isWorktree: true, ...overrides });
}

Expand All @@ -488,7 +503,11 @@ export function ThreadDraftComposerArea(props: {
// Keep an existing worktree selection (e.g. a worktreePath from "New thread
// in worktree") intact rather than rebuilding it into a brand-new branch.
if (branchSelection?.worktreePath) return;
selectNewWorktree({ transferUncommitted: mode === "new-with-changes" });
const baseBranch = mode === "new-with-changes" ? props.gitBranch : defaultWorktreeBase;
selectNewWorktree({
...(baseBranch ? { baseBranch } : {}),
transferUncommitted: mode === "new-with-changes",
});
}

const computerUseScope =
Expand Down Expand Up @@ -680,8 +699,8 @@ export function ThreadDraftComposerArea(props: {
}
: {
worktreeBranch: generateWorktreeBranch(),
...(branchSelection?.baseBranch
? { worktreeBaseBranch: branchSelection.baseBranch }
...((branchSelection?.baseBranch ?? trackingWorktreeBase)
? { worktreeBaseBranch: branchSelection?.baseBranch ?? trackingWorktreeBase }
: {}),
worktreeIsNewBranch: true,
...(shouldTransferUncommitted ? { worktreeTransferUncommitted: true } : {}),
Expand Down Expand Up @@ -740,7 +759,7 @@ export function ThreadDraftComposerArea(props: {

async function runExperiment(allSegments: PromptSegment[], fallbackPrompt = "") {
const input = resolveExperimentInput(allSegments, fallbackPrompt);
const baseBranch = experimentBaseBranch ?? props.gitBranch;
const baseBranch = experimentBaseBranch ?? defaultWorktreeBase;
if (!input || !baseBranch || experimentCandidates.length < 2 || isSubmitting) return;
setIsSubmitting(true);
const experimentId = await launchExperiment({
Expand Down Expand Up @@ -1124,7 +1143,7 @@ export function ThreadDraftComposerArea(props: {
setExperimentBaseBranch(
branchSelection?.baseBranch ??
branchSelection?.branch ??
props.gitBranch ??
defaultWorktreeBase ??
null,
);
} else {
Expand Down Expand Up @@ -1159,14 +1178,18 @@ export function ThreadDraftComposerArea(props: {
currentBranch={props.gitBranch}
value={
experimentMode
? (experimentBaseBranch ?? props.gitBranch)
: (branchSelection?.branch ?? props.gitBranch)
? (experimentBaseBranch ?? defaultWorktreeBase ?? props.gitBranch)
: worktreeSelected
? (worktreeBase ?? props.gitBranch)
: (branchSelection?.branch ?? props.gitBranch)
}
isWorktree={experimentMode ? true : branchSelection?.isWorktree}
baseBranch={
experimentMode
? (experimentBaseBranch ?? props.gitBranch)
: branchSelection?.baseBranch
? (experimentBaseBranch ?? defaultWorktreeBase ?? props.gitBranch)
: worktreeSelected
? worktreeBase
: branchSelection?.baseBranch
}
worktreeMode={experimentMode || props.worktreeMode}
{...(!experimentMode ? { onWorktreeModeChange: props.onWorktreeModeChange } : {})}
Expand Down
131 changes: 130 additions & 1 deletion src/renderer/components/thread/ThreadDraftView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import { useGitStore } from "@/renderer/state/gitStore";
import { useSharedSettings } from "@/renderer/state/sharedSettingsStore";
import { useRemoteServersStore } from "@/renderer/state/remoteServersStore";

const { composerSpy } = vi.hoisted(() => ({
const { composerSpy, launchExperimentMock } = vi.hoisted(() => ({
composerSpy: vi.fn<(props: unknown) => void>(),
launchExperimentMock: vi.fn<(input: unknown) => Promise<string | null>>(),
}));

vi.mock("./ThreadComposer", () => ({
Expand All @@ -34,6 +35,10 @@ vi.mock("./ThreadComposer", () => ({
},
}));

vi.mock("@/renderer/actions/experimentActions", () => ({
launchExperiment: launchExperimentMock,
}));

import "@/renderer/components/providers/bootstrap";
import { ThreadDraftView } from "./ThreadDraftView";

Expand Down Expand Up @@ -409,6 +414,8 @@ const singleEffortMultiContextCursorStatus: AgentStatus = {
describe("ThreadDraftView", () => {
beforeEach(() => {
composerSpy.mockClear();
launchExperimentMock.mockReset();
launchExperimentMock.mockResolvedValue("experiment-1");
delete (window as unknown as { poracode?: unknown }).poracode;
useAgentStatusesStore.setState({
agentStatuses: [],
Expand Down Expand Up @@ -524,6 +531,128 @@ describe("ThreadDraftView", () => {
expect(container.querySelector("[data-draft-worktree-row]")).toBeInTheDocument();
});

it("defaults a new worktree to the tracking branch when the local branch is behind", () => {
const onStart = vi.fn<(input: unknown) => void>();
useGitStore.setState({
statuses: {
[project.id]: {
isRepo: true,
branch: "main",
tracking: "origin/main",
hasRemote: true,
remoteInfo: null,
ahead: 0,
behind: 4,
staged: [],
unstaged: [],
totalInsertions: 0,
totalDeletions: 0,
},
},
});

render(<ThreadDraftView project={project} agentStatuses={[codexStatus]} onStart={onStart} />);

fireEvent.click(screen.getByRole("button", { name: "Worktree mode" }));
expect(screen.getByRole("button", { name: "Select branch" })).toHaveTextContent("origin/main");

fireEvent.click(screen.getByText("set-prompt"));
fireEvent.click(screen.getByText("submit"));

expect(onStart).toHaveBeenCalledWith(
expect.objectContaining({
worktreeBaseBranch: "origin/main",
worktreeIsNewBranch: true,
}),
);
});

it("keeps the uncommitted-changes worktree option after selecting a tracking branch", async () => {
const onStart = vi.fn<(input: unknown) => void>();
useGitStore.setState({
statuses: {
[project.id]: {
isRepo: true,
branch: "main",
tracking: "origin/main",
hasRemote: true,
remoteInfo: null,
ahead: 0,
behind: 4,
staged: [],
unstaged: [
{ path: "src/file.ts", status: "M", staged: false, insertions: 1, deletions: 0 },
],
totalInsertions: 1,
totalDeletions: 0,
},
},
});

render(<ThreadDraftView project={project} agentStatuses={[codexStatus]} onStart={onStart} />);

fireEvent.click(screen.getByRole("button", { name: "Worktree mode" }));
fireEvent.click(await screen.findByRole("option", { name: /Run in a separate worktree/ }));
expect(screen.getByRole("button", { name: "Select branch" })).toHaveTextContent("origin/main");

fireEvent.click(screen.getByRole("button", { name: "Worktree mode" }));
fireEvent.click(await screen.findByRole("option", { name: /Worktree \+ changes/ }));
expect(screen.getByRole("button", { name: "Select branch" })).toHaveTextContent("main");

fireEvent.click(screen.getByText("set-prompt"));
fireEvent.click(screen.getByText("submit"));

expect(onStart).toHaveBeenCalledWith(
expect.objectContaining({
worktreeBaseBranch: "main",
worktreeIsNewBranch: true,
worktreeTransferUncommitted: true,
}),
);
});

it("defaults experiment worktrees to the tracking branch when the local branch is behind", async () => {
useGitStore.setState({
statuses: {
[project.id]: {
isRepo: true,
branch: "main",
tracking: "origin/main",
hasRemote: true,
remoteInfo: null,
ahead: 0,
behind: 4,
staged: [],
unstaged: [],
totalInsertions: 0,
totalDeletions: 0,
},
},
});
render(<ThreadDraftView project={project} agentStatuses={[codexStatus]} onStart={() => {}} />);

const initialComposer = composerSpy.mock.lastCall?.[0] as {
afterControls: ReactElement<{ experiment?: { onToggle: (enabled: boolean) => void } }>;
};
act(() => initialComposer.afterControls.props.experiment?.onToggle(true));
expect(screen.getByRole("button", { name: "Select branch" })).toHaveTextContent("origin/main");

for (let index = 0; index < 2; index += 1) {
const composer = composerSpy.mock.lastCall?.[0] as { fixedContent: ReactNode };
const targets = findElementByTypeName(composer.fixedContent, "ExperimentDraftTargets");
if (!targets) throw new Error("Expected experiment targets");
act(targets.props.onAdd as () => void);
}
fireEvent.click(screen.getByText("set-prompt"));
fireEvent.click(screen.getByText("submit"));

await waitFor(() =>
expect(launchExperimentMock).toHaveBeenCalledWith(
expect.objectContaining({ baseBranch: "origin/main" }),
),
);
});

it("reserves the worktree control row for Home drafts", () => {
const { container } = render(
<ThreadDraftView project={homeProject} agentStatuses={[codexStatus]} onStart={() => {}} />,
Expand Down
Loading