Skip to content
146 changes: 144 additions & 2 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { spawnSync } from "node:child_process";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { it } from "@effect/vitest";
import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
import { Effect, FileSystem, Layer, Option, PlatformError, Scope } from "effect";
import { ChildProcessSpawner } from "effect/unstable/process";
import { expect } from "vitest";
import type {
Expand All @@ -27,7 +27,7 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import { makeGitManager } from "./GitManager.ts";
import { makeGitManager, matchesBranchHeadContext } from "./GitManager.ts";
import { ServerConfig } from "../config.ts";
import { ServerSettingsService } from "../serverSettings.ts";
import {
Expand Down Expand Up @@ -2133,6 +2133,148 @@ 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": [
JSON.stringify([
{
number: 41,
title: "Ambiguous fork PR",
url: "https://github.com/pingdotgg/codething-mvp/pull/41",
baseRefName: "main",
headRefName: "statemachine",
state: "OPEN",
},
]),
JSON.stringify([
{
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": [JSON.stringify([])],
statemachine: [JSON.stringify([])],
},
},
});

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);
}),
12_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(
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(
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(
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 @@ -250,7 +250,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 @@ -261,44 +292,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 ((expectedHeadRepository || expectedHeadOwner) && !prHeadRepository && !prHeadOwner) {
if (expectedHead.ownerLogin && pullRequestHead.ownerLogin) {
if (expectedHead.ownerLogin !== pullRequestHead.ownerLogin) {
return false;
}
}
}

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;
}
}

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,
} from "./BranchToolbar.logic";

Expand Down Expand Up @@ -84,6 +85,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 @@ -97,6 +97,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
Loading
Loading