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
118 changes: 118 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as PlatformError from "effect/PlatformError";
import * as Scope from "effect/Scope";
import { ChildProcessSpawner } from "effect/unstable/process";
Expand Down Expand Up @@ -2413,6 +2414,123 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
12_000,
);

it.effect(
"does not reuse a cross-repo PR when GitHub omits head identity metadata",
() =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
const forkDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
yield* runGit(repoDir, [
"config",
"remote.fork-seed.url",
"git@github.com:octocat/codething-mvp.git",
]);

const { manager, ghCalls } = yield* makeManager({
ghScenario: {
prListSequenceByHeadSelector: {
"octocat:statemachine": [
`[{"number":41,"title":"Ambiguous fork PR","url":"https://github.com/pingdotgg/codething-mvp/pull/41","baseRefName":"main","headRefName":"statemachine","state":"OPEN"}]`,
`[{"number":142,"title":"Add stacked git actions","url":"https://github.com/pingdotgg/codething-mvp/pull/142","baseRefName":"main","headRefName":"statemachine","state":"OPEN","isCrossRepository":true,"headRepository":{"nameWithOwner":"octocat/codething-mvp"},"headRepositoryOwner":{"login":"octocat"}}]`,
],
"fork-seed:statemachine": ["[]"],
statemachine: ["[]"],
},
},
});

const result = yield* runStackedAction(manager, {
cwd: repoDir,
action: "commit_push_pr",
});

expect(result.pr.status).toBe("created");
expect(result.pr.number).toBe(142);
expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(true);
}),
20_000,
);

it.effect("rejects same-repo PR metadata when matching a cross-repo head context", () =>
Effect.sync(() => {
const headContext = {
headBranch: "statemachine",
headRepositoryNameWithOwner: "pingdotgg/codething-mvp",
headRepositoryOwnerLogin: "pingdotgg",
isCrossRepository: true,
};

expect(
GitManager.matchesBranchHeadContext(
{
number: 41,
title: "Same-repo PR",
url: "https://github.com/pingdotgg/codething-mvp/pull/41",
baseRefName: "main",
headRefName: "statemachine",
state: "open",
updatedAt: Option.none(),
isCrossRepository: false,
headRepositoryNameWithOwner: "pingdotgg/codething-mvp",
headRepositoryOwnerLogin: "pingdotgg",
},
headContext,
),
).toBe(false);

expect(
GitManager.matchesBranchHeadContext(
{
number: 142,
title: "Fork PR",
url: "https://github.com/pingdotgg/codething-mvp/pull/142",
baseRefName: "main",
headRefName: "statemachine",
state: "open",
updatedAt: Option.none(),
isCrossRepository: true,
headRepositoryNameWithOwner: "pingdotgg/codething-mvp",
headRepositoryOwnerLogin: "pingdotgg",
},
headContext,
),
).toBe(true);
}),
);

it.effect("accepts fork PR metadata when origin is the fork checkout remote", () =>
Effect.sync(() => {
const headContext = {
headBranch: "t3code/git-audit-stability",
headRepositoryNameWithOwner: "justsomelegs/t3code",
headRepositoryOwnerLogin: "justsomelegs",
isCrossRepository: false,
};

expect(
GitManager.matchesBranchHeadContext(
{
number: 2284,
title: "Improve branch mismatch warnings",
url: "https://github.com/pingdotgg/t3code/pull/2284",
baseRefName: "main",
headRefName: "t3code/git-audit-stability",
state: "open",
updatedAt: Option.none(),
isCrossRepository: true,
headRepositoryNameWithOwner: "justsomelegs/t3code",
headRepositoryOwnerLogin: "justsomelegs",
},
headContext,
),
).toBe(true);
}),
);

it.effect("creates PR when one does not already exist", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down
90 changes: 64 additions & 26 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,38 @@ function resolvePullRequestHeadRepositoryNameWithOwner(
return `${ownerLogin}/${repositoryName}`;
}

function matchesBranchHeadContext(
interface PullRequestHeadIdentity {
readonly repositoryNameWithOwner: string | null;
readonly ownerLogin: string | null;
}

function resolveExpectedHeadIdentity(
headContext: Pick<BranchHeadContext, "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin">,
): PullRequestHeadIdentity {
const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner(
headContext.headRepositoryNameWithOwner,
);
return {
repositoryNameWithOwner,
ownerLogin:
normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ??
parseRepositoryOwnerLogin(repositoryNameWithOwner),
};
}

function resolvePullRequestHeadIdentity(pr: PullRequestInfo): PullRequestHeadIdentity {
const repositoryNameWithOwner = normalizeOptionalRepositoryNameWithOwner(
resolvePullRequestHeadRepositoryNameWithOwner(pr),
);
return {
repositoryNameWithOwner,
ownerLogin:
normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ??
parseRepositoryOwnerLogin(repositoryNameWithOwner),
};
}

export function matchesBranchHeadContext(
pr: PullRequestInfo,
headContext: Pick<
BranchHeadContext,
Expand All @@ -263,44 +294,51 @@ function matchesBranchHeadContext(
return false;
}

const expectedHeadRepository = normalizeOptionalRepositoryNameWithOwner(
headContext.headRepositoryNameWithOwner,
);
const expectedHeadOwner =
normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ??
parseRepositoryOwnerLogin(expectedHeadRepository);
const prHeadRepository = normalizeOptionalRepositoryNameWithOwner(
resolvePullRequestHeadRepositoryNameWithOwner(pr),
);
const prHeadOwner =
normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ??
parseRepositoryOwnerLogin(prHeadRepository);
const expectedHead = resolveExpectedHeadIdentity(headContext);
const pullRequestHead = resolvePullRequestHeadIdentity(pr);

if (headContext.isCrossRepository) {
if (pr.isCrossRepository === false) {
return false;
if (expectedHead.repositoryNameWithOwner) {
if (pullRequestHead.repositoryNameWithOwner) {
if (expectedHead.repositoryNameWithOwner !== pullRequestHead.repositoryNameWithOwner) {
return false;
}
}
if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) {
if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) {
return false;
}
}
if ((expectedHeadRepository || expectedHeadOwner) && !prHeadRepository && !prHeadOwner) {
}

if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) {
if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) {
return false;
}
if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) {
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

if (headContext.isCrossRepository) {
if (pr.isCrossRepository === false) {
return false;
}
if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) {
if (
(expectedHead.repositoryNameWithOwner || expectedHead.ownerLogin) &&
!pullRequestHead.repositoryNameWithOwner &&
!pullRequestHead.ownerLogin
) {
return false;
}
return true;
}

if (pr.isCrossRepository === true) {
return false;
}
if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) {
return false;
}
if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) {
return false;
if (
(!expectedHead.repositoryNameWithOwner && !expectedHead.ownerLogin) ||
(!pullRequestHead.repositoryNameWithOwner && !pullRequestHead.ownerLogin)
) {
return false;
}
}

Comment thread
cursor[bot] marked this conversation as resolved.
return true;
}

Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/components/BranchToolbar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
resolveEnvModeLabel,
resolveBranchToolbarValue,
resolveLockedWorkspaceLabel,
resolveLocalCheckoutBranchMismatch,
shouldIncludeBranchPickerItem,
shouldShowEnvironmentIndicator,
} from "./BranchToolbar.logic";
Expand Down Expand Up @@ -85,6 +86,55 @@ describe("resolveBranchToolbarValue", () => {
});
});

describe("resolveLocalCheckoutBranchMismatch", () => {
it("detects when a local thread is associated with a different branch than the checkout", () => {
expect(
resolveLocalCheckoutBranchMismatch({
effectiveEnvMode: "local",
activeWorktreePath: null,
activeThreadBranch: "feature/thread",
currentGitBranch: "feature/current",
}),
).toEqual({
threadBranch: "feature/thread",
currentBranch: "feature/current",
});
});

it("ignores matching local checkout state", () => {
expect(
resolveLocalCheckoutBranchMismatch({
effectiveEnvMode: "local",
activeWorktreePath: null,
activeThreadBranch: "feature/thread",
currentGitBranch: "feature/thread",
}),
).toBeNull();
});

it("ignores dedicated worktrees because their checkout is already thread-scoped", () => {
expect(
resolveLocalCheckoutBranchMismatch({
effectiveEnvMode: "worktree",
activeWorktreePath: "/repo/.t3/worktrees/feature-thread",
activeThreadBranch: "feature/thread",
currentGitBranch: "feature/current",
}),
).toBeNull();
});

it("ignores new-worktree base selection before a worktree exists", () => {
expect(
resolveLocalCheckoutBranchMismatch({
effectiveEnvMode: "worktree",
activeWorktreePath: null,
activeThreadBranch: "feature/base",
currentGitBranch: "main",
}),
).toBeNull();
});
});

describe("resolveEnvironmentOptionLabel", () => {
it("prefers the primary environment's machine label", () => {
expect(
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/components/BranchToolbar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,22 @@ export function resolveBranchToolbarValue(input: {
return currentGitBranch ?? activeThreadBranch;
}

export function resolveLocalCheckoutBranchMismatch(input: {
effectiveEnvMode: EnvMode;
activeWorktreePath: string | null;
activeThreadBranch: string | null;
currentGitBranch: string | null;
}): { threadBranch: string; currentBranch: string } | null {
const { effectiveEnvMode, activeWorktreePath, activeThreadBranch, currentGitBranch } = input;
if (effectiveEnvMode !== "local" || activeWorktreePath !== null) {
return null;
}
if (!activeThreadBranch || !currentGitBranch || activeThreadBranch === currentGitBranch) {
return null;
}
return { threadBranch: activeThreadBranch, currentBranch: currentGitBranch };
}

export function resolveBranchSelectionTarget(input: {
activeProjectCwd: string;
activeWorktreePath: string | null;
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,7 @@ export function BranchToolbarBranchSelector({
const worktreeBaseBranchCandidate = isInitialBranchesLoadPending
? null
: (defaultBranchName ?? currentGitBranch);

useEffect(() => {
if (
effectiveEnvMode !== "worktree" ||
Expand Down Expand Up @@ -589,7 +590,11 @@ export function BranchToolbarBranchSelector({
});

// PR pill shown next to the branch selector when the active branch has one.
const branchPr = resolveThreadPr(resolvedActiveBranch, branchStatusQuery.data ?? null);
const branchPr = resolveThreadPr({
threadBranch: resolvedActiveBranch,
gitStatus: branchStatusQuery.data ?? null,
hasDedicatedWorktree: activeWorktreePath !== null,
});
Comment thread
cursor[bot] marked this conversation as resolved.
const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider);
// Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's
// state-description tooltip.
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
hasServerAcknowledgedLocalDispatch,
reconcileMountedTerminalThreadIds,
reconcileRetainedMountedThreadIds,
resolveThreadMetadataUpdateForNextTurn,
resolveSendEnvMode,
shouldWriteThreadErrorToCurrentServerThread,
} from "./ChatView.logic";
Expand Down Expand Up @@ -79,6 +80,34 @@ const readySession = {
updatedAt: "2026-03-29T00:00:10.000Z",
};

describe("resolveThreadMetadataUpdateForNextTurn", () => {
const modelSelection = {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5.4",
};

it("updates a stale local thread branch to the active checkout", () => {
expect(
resolveThreadMetadataUpdateForNextTurn({
currentModelSelection: modelSelection,
currentBranch: "feature/thread",
nextBranch: "feature/checkout",
}),
).toEqual({ branch: "feature/checkout", worktreePath: null });
});

it("does not write metadata when the model and branch are unchanged", () => {
expect(
resolveThreadMetadataUpdateForNextTurn({
currentModelSelection: modelSelection,
nextModelSelection: modelSelection,
currentBranch: "feature/current",
nextBranch: "feature/current",
}),
).toBeNull();
});
});

describe("buildThreadTurnInterruptInput", () => {
it("targets the session's active running turn", () => {
const activeTurnId = TurnId.make("turn-running");
Expand Down
Loading
Loading