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
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { describe, expect, it } from "vitest";
import {
buildBranchMismatchAnalyticsEvent,
buildCheckoutBranchRequest,
decideBeforeSubmit,
resolveSwitchErrorMessage,
} from "./branchMismatchDialog";
buildLinkBranchRequest,
resolveBranchMismatchError,
} from "./branchMismatchBanner";

const context = {
taskId: "task-1",
Expand All @@ -14,16 +14,6 @@ const context = {
hasUncommittedChanges: true,
};

describe("decideBeforeSubmit", () => {
it("allows submit when not warning", () => {
expect(decideBeforeSubmit(false)).toBe(true);
});

it("blocks submit when warning", () => {
expect(decideBeforeSubmit(true)).toBe(false);
});
});

describe("buildBranchMismatchAnalyticsEvent", () => {
it("builds the warning-shown event", () => {
expect(buildBranchMismatchAnalyticsEvent("shown", context)).toEqual({
Expand All @@ -37,21 +27,24 @@ describe("buildBranchMismatchAnalyticsEvent", () => {
});
});

it("builds the action event", () => {
expect(buildBranchMismatchAnalyticsEvent("switch", context)).toEqual({
event: ANALYTICS_EVENTS.BRANCH_MISMATCH_ACTION,
properties: {
task_id: "task-1",
action: "switch",
linked_branch: "feat/foo",
current_branch: "main",
},
});
});
it.each(["switch", "relink", "dismiss"] as const)(
"builds the %s action event",
(action) => {
expect(buildBranchMismatchAnalyticsEvent(action, context)).toEqual({
event: ANALYTICS_EVENTS.BRANCH_MISMATCH_ACTION,
properties: {
task_id: "task-1",
action,
linked_branch: "feat/foo",
current_branch: "main",
},
});
},
);

it("returns null without both branches", () => {
expect(
buildBranchMismatchAnalyticsEvent("cancel", {
buildBranchMismatchAnalyticsEvent("dismiss", {
...context,
linkedBranch: null,
}),
Expand All @@ -76,14 +69,29 @@ describe("buildCheckoutBranchRequest", () => {
});
});

describe("resolveSwitchErrorMessage", () => {
it("uses error message", () => {
expect(resolveSwitchErrorMessage(new Error("dirty worktree"))).toBe(
"dirty worktree",
);
describe("buildLinkBranchRequest", () => {
it("builds the request", () => {
expect(buildLinkBranchRequest("task-1", "main")).toEqual({
taskId: "task-1",
branchName: "main",
});
});

it("falls back for non-errors", () => {
expect(resolveSwitchErrorMessage("oops")).toBe("Failed to switch branch");
it("returns null without a current branch", () => {
expect(buildLinkBranchRequest("task-1", null)).toBeNull();
});
});

describe("resolveBranchMismatchError", () => {
it("uses the error message", () => {
expect(
resolveBranchMismatchError(new Error("dirty worktree"), "nope"),
).toBe("dirty worktree");
});

it.each([["oops"], [new Error("")]])("falls back for %s", (error) => {
expect(resolveBranchMismatchError(error, "Failed to switch branch")).toBe(
"Failed to switch branch",
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import {
type BranchMismatchWarningShownProperties,
} from "@posthog/shared";

export type BranchMismatchDialogAction =
export type BranchMismatchBannerAction =
| "switch"
| "continue"
| "cancel"
| "relink"
| "dismiss"
| "shown";

export interface BranchMismatchContext {
Expand All @@ -22,6 +22,11 @@ export interface CheckoutBranchRequest {
branchName: string;
}

export interface LinkBranchRequest {
taskId: string;
branchName: string;
}

export type BranchMismatchAnalyticsEvent =
| {
event: typeof ANALYTICS_EVENTS.BRANCH_MISMATCH_WARNING_SHOWN;
Expand All @@ -32,12 +37,8 @@ export type BranchMismatchAnalyticsEvent =
properties: BranchMismatchActionProperties;
};

export function decideBeforeSubmit(shouldWarn: boolean): boolean {
return !shouldWarn;
}

export function buildBranchMismatchAnalyticsEvent(
action: BranchMismatchDialogAction,
action: BranchMismatchBannerAction,
context: BranchMismatchContext,
): BranchMismatchAnalyticsEvent | null {
const { taskId, linkedBranch, currentBranch, hasUncommittedChanges } =
Expand Down Expand Up @@ -79,6 +80,19 @@ export function buildCheckoutBranchRequest(
return { directoryPath: repoPath, branchName: linkedBranch };
}

export function resolveSwitchErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Failed to switch branch";
export function buildLinkBranchRequest(
taskId: string,
currentBranch: string | null,
): LinkBranchRequest | null {
if (!currentBranch) {
return null;
}
return { taskId, branchName: currentBranch };
}

export function resolveBranchMismatchError(
error: unknown,
fallback: string,
): string {
return error instanceof Error && error.message ? error.message : fallback;
}
38 changes: 37 additions & 1 deletion packages/git/src/queries.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { mkdtemp, rm, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { devNull, tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createGitClient } from "./client";
import {
anyBranchRefExists,
type ChangedFileInfo,
computeDiffStatsFromFiles,
detectDefaultBranch,
Expand Down Expand Up @@ -505,3 +506,38 @@ describe("remoteBranchExists", () => {
expect(await remoteBranchExists(repoDir, "main")).toBe(false);
});
});

describe("anyBranchRefExists", () => {
let repoDir: string;

// Builds refs via plumbing (commit-tree + update-ref) so the fixture also
// works in sandboxes where `git commit` is unavailable.
beforeEach(async () => {
repoDir = await mkdtemp(path.join(tmpdir(), "posthog-code-refs-"));
const git = createGitClient(repoDir);
await git.init(["--initial-branch", "main"]);
await git.addConfig("user.name", "Test");
await git.addConfig("user.email", "test@example.com");
const tree = (
await git.raw(["hash-object", "-w", "-t", "tree", devNull])
).trim();
const sha = (await git.raw(["commit-tree", tree, "-m", "seed"])).trim();
await git.raw(["update-ref", "refs/heads/feat/local", sha]);
await git.raw(["update-ref", "refs/remotes/upstream/feat/remote", sha]);
await git.raw(["update-ref", "refs/tags/feat/tag-only", sha]);
});

afterEach(async () => {
await rm(repoDir, { recursive: true, force: true });
});

it.each([
{ branch: "feat/local", expected: true },
{ branch: "feat/remote", expected: true },
{ branch: "feat/gone", expected: false },
// A tag with the name does not resurrect a deleted branch.
{ branch: "feat/tag-only", expected: false },
])("returns $expected for '$branch'", async ({ branch, expected }) => {
expect(await anyBranchRefExists(repoDir, branch)).toBe(expected);
});
});
28 changes: 28 additions & 0 deletions packages/git/src/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,34 @@ export async function branchExists(
);
}

/**
* True when the branch exists as a local branch or as a remote-tracking
* ref on any remote. Unlike `branchExists`, a tag or raw commit-ish with
* the same name does not count, and nothing reaches the network — a
* remote branch only counts once it has been fetched.
*/
export async function anyBranchRefExists(
baseDir: string,
branchName: string,
options?: CreateGitClientOptions,
): Promise<boolean> {
const manager = getGitOperationManager();
return manager.executeRead(
baseDir,
async (git) => {
const refs = await git.raw([
"for-each-ref",
"--count=1",
"--format=%(refname)",
`refs/heads/${branchName}`,
`refs/remotes/*/${branchName}`,
]);
return refs.trim().length > 0;
},
{ signal: options?.abortSignal },
);
}

/**
* Checks whether a branch exists on the remote without fetching it.
* Uses `git ls-remote --heads`, which is read-only and reaches the remote.
Expand Down
11 changes: 7 additions & 4 deletions packages/shared/src/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,9 @@ export interface AgentFileActivityProperties {
branch_name: string | null;
}

// Branch link events
type BranchLinkSource = "agent" | "user" | "unknown";
// Branch link events. "auto" marks self-healing unlinks of branches that no
// longer exist anywhere (e.g. deleted after a PR merge).
type BranchLinkSource = "agent" | "user" | "auto" | "unknown";

export interface BranchLinkedProperties {
task_id: string;
Expand Down Expand Up @@ -314,8 +315,10 @@ export interface TourEventProperties {
total_steps?: number;
}

// Branch mismatch events
type BranchMismatchAction = "switch" | "continue" | "cancel";
// Branch mismatch events. The banner replaced the blocking dialog: "relink"
// re-links the task to the current branch (was "continue"), "dismiss" hides
// the banner for the session (was "cancel").
type BranchMismatchAction = "switch" | "relink" | "dismiss";

export interface BranchMismatchWarningShownProperties {
task_id: string;
Expand Down
18 changes: 18 additions & 0 deletions packages/ui/src/features/sessions/components/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ interface SessionViewProps {
isActiveSession?: boolean;
/** Hide the message input and permission UI — log-only view. */
hideInput?: boolean;
/** Ambient notice rendered above the composer (e.g. branch mismatch). */
composerNotice?: React.ReactNode;
}

const DEFAULT_ERROR_MESSAGE =
Expand Down Expand Up @@ -218,6 +220,7 @@ export function SessionView({
compact = false,
isActiveSession = true,
hideInput = false,
composerNotice,
}: SessionViewProps) {
const sessionService = useService<SessionService>(SESSION_SERVICE);
useSessionEventsResidency(taskId);
Expand Down Expand Up @@ -623,6 +626,21 @@ export function SessionView({

<PlanStatusBar plan={latestPlan} />

{!hideInput && composerNotice && (
<Box
className={
compact
? "shrink-0 px-1 pt-1"
: "mx-auto w-full shrink-0 px-2 pt-2"
}
style={
compact ? undefined : { maxWidth: CHAT_CONTENT_MAX_WIDTH }
}
>
{composerNotice}
</Box>
)}

{hasError && !showInlineBanner ? (
<Flex
align="center"
Expand Down
67 changes: 67 additions & 0 deletions packages/ui/src/features/task-detail/BranchMismatchBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Theme } from "@radix-ui/themes";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { BranchMismatchBanner } from "./BranchMismatchBanner";

function renderBanner(
overrides?: Partial<Parameters<typeof BranchMismatchBanner>[0]>,
) {
const handlers = {
onSwitch: vi.fn(),
onUseCurrentBranch: vi.fn(),
onDismiss: vi.fn(),
};
render(
<Theme>
<BranchMismatchBanner
linkedBranch="feat/foo"
currentBranch="main"
actionError={null}
isSwitching={false}
isRelinking={false}
{...handlers}
{...overrides}
/>
</Theme>,
);
return handlers;
}

describe("BranchMismatchBanner", () => {
it("shows both branches", () => {
renderBanner();

expect(screen.getByText("feat/foo")).toBeInTheDocument();
expect(screen.getByText("main")).toBeInTheDocument();
});

it("wires each action to its handler", async () => {
const user = userEvent.setup();
const { onSwitch, onUseCurrentBranch, onDismiss } = renderBanner();

await user.click(screen.getByRole("button", { name: "Switch branch" }));
await user.click(
screen.getByRole("button", { name: "Use current branch" }),
);
await user.click(screen.getByRole("button", { name: "Dismiss" }));

expect(onSwitch).toHaveBeenCalledTimes(1);
expect(onUseCurrentBranch).toHaveBeenCalledTimes(1);
expect(onDismiss).toHaveBeenCalledTimes(1);
});

it("shows the action error", () => {
renderBanner({ actionError: "dirty worktree" });

expect(screen.getByText("dirty worktree")).toBeInTheDocument();
});

it("disables actions while a switch is in flight", () => {
renderBanner({ isSwitching: true });

expect(
screen.getByRole("button", { name: "Use current branch" }),
).toBeDisabled();
});
});
Loading
Loading