authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-10 15:05:41-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-10 15:05:41-07:00
log1031a226f8cf90d358d9768fc0842dcdae61a315
treea253f3646da4cb26a7c91e228b7f39d107da5cc0
parentddd98876b5fe3dcb789a36ab799e6e211a1bcb5a
signature Signed by PGP key B5690EEEBB952194

Fix cross-repo PR detection and push remote selection (#788)


9 files changed, 752 insertions(+), 84 deletions(-)

apps/server/src/git/Layers/GitCore.test.ts+147
......@@ -1409,6 +1409,44 @@ it.layer(TestLayer)("git integration", (it) => {
14091409 }),
14101410 );
14111411
1412 it.effect(
1413 "computes ahead count against a non-origin remote-prefixed gh-merge-base candidate",
1414 () =>
1415 Effect.gen(function* () {
1416 const remote = yield* makeTmpDir();
1417 const source = yield* makeTmpDir();
1418 const remoteName = "fork-seed";
1419 yield* git(remote, ["init", "--bare"]);
1420
1421 yield* initRepoWithCommit(source);
1422 const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find(
1423 (branch) => branch.current,
1424 )!.name;
1425 yield* git(source, ["remote", "add", remoteName, remote]);
1426 yield* git(source, ["push", "-u", remoteName, initialBranch]);
1427 yield* git(source, ["checkout", "-b", "feature/non-origin-merge-base"]);
1428 yield* git(source, [
1429 "config",
1430 "branch.feature/non-origin-merge-base.gh-merge-base",
1431 `${remoteName}/${initialBranch}`,
1432 ]);
1433 yield* writeTextFile(
1434 path.join(source, "feature.txt"),
1435 `ahead of ${remoteName}/${initialBranch}\n`,
1436 );
1437 yield* git(source, ["add", "feature.txt"]);
1438 yield* git(source, ["commit", "-m", "feature commit"]);
1439 yield* git(source, ["branch", "-D", initialBranch]);
1440
1441 const core = yield* GitCore;
1442 const details = yield* core.statusDetails(source);
1443 expect(details.branch).toBe("feature/non-origin-merge-base");
1444 expect(details.hasUpstream).toBe(false);
1445 expect(details.aheadCount).toBe(1);
1446 expect(details.behindCount).toBe(0);
1447 }),
1448 );
1449
14121450 it.effect("skips push when no upstream is configured and branch is not ahead of base", () =>
14131451 Effect.gen(function* () {
14141452 const tmp = yield* makeTmpDir();
......@@ -1450,6 +1488,31 @@ it.layer(TestLayer)("git integration", (it) => {
14501488 }),
14511489 );
14521490
1491 it.effect("pushes with upstream setup to the only configured non-origin remote", () =>
1492 Effect.gen(function* () {
1493 const tmp = yield* makeTmpDir();
1494 const remote = yield* makeTmpDir();
1495 yield* git(tmp, ["init", "--initial-branch=main"]);
1496 yield* git(tmp, ["config", "user.email", "test@test.com"]);
1497 yield* git(tmp, ["config", "user.name", "Test"]);
1498 yield* writeTextFile(path.join(tmp, "README.md"), "hello\n");
1499 yield* git(tmp, ["add", "README.md"]);
1500 yield* git(tmp, ["commit", "-m", "initial"]);
1501 yield* git(remote, ["init", "--bare"]);
1502 yield* git(tmp, ["remote", "add", "fork", remote]);
1503 yield* git(tmp, ["checkout", "-b", "feature/fork-only"]);
1504
1505 const core = yield* GitCore;
1506 const pushed = yield* core.pushCurrentBranch(tmp, null);
1507 expect(pushed.status).toBe("pushed");
1508 expect(pushed.setUpstream).toBe(true);
1509 expect(pushed.upstreamBranch).toBe("fork/feature/fork-only");
1510 expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe(
1511 "fork/feature/fork-only",
1512 );
1513 }),
1514 );
1515
14531516 it.effect(
14541517 "pushes with upstream setup when comparable base exists but remote branch is missing",
14551518 () =>
......@@ -1486,6 +1549,90 @@ it.layer(TestLayer)("git integration", (it) => {
14861549 }),
14871550 );
14881551
1552 it.effect("prefers branch pushRemote over origin when setting upstream", () =>
1553 Effect.gen(function* () {
1554 const tmp = yield* makeTmpDir();
1555 const origin = yield* makeTmpDir();
1556 const fork = yield* makeTmpDir();
1557 yield* git(origin, ["init", "--bare"]);
1558 yield* git(fork, ["init", "--bare"]);
1559
1560 yield* initRepoWithCommit(tmp);
1561 const initialBranch = (yield* listGitBranches({ cwd: tmp })).branches.find(
1562 (branch) => branch.current,
1563 )!.name;
1564 yield* git(tmp, ["remote", "add", "origin", origin]);
1565 yield* git(tmp, ["remote", "add", "fork", fork]);
1566 yield* git(tmp, ["push", "-u", "origin", initialBranch]);
1567
1568 const featureBranch = "feature/push-remote";
1569 yield* git(tmp, ["checkout", "-b", featureBranch]);
1570 yield* git(tmp, ["config", `branch.${featureBranch}.pushRemote`, "fork"]);
1571 yield* writeTextFile(path.join(tmp, "feature.txt"), "push to fork\n");
1572 yield* git(tmp, ["add", "feature.txt"]);
1573 yield* git(tmp, ["commit", "-m", "feature commit"]);
1574
1575 const core = yield* GitCore;
1576 const pushed = yield* core.pushCurrentBranch(tmp, null);
1577 expect(pushed.status).toBe("pushed");
1578 expect(pushed.setUpstream).toBe(true);
1579 expect(pushed.upstreamBranch).toBe(`fork/${featureBranch}`);
1580 expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe(
1581 `fork/${featureBranch}`,
1582 );
1583 expect(yield* git(tmp, ["ls-remote", "--heads", "fork", featureBranch])).toContain(
1584 featureBranch,
1585 );
1586 }),
1587 );
1588
1589 it.effect(
1590 "pushes renamed PR worktree branches to their tracked upstream branch even when push.default is current",
1591 () =>
1592 Effect.gen(function* () {
1593 const tmp = yield* makeTmpDir();
1594 const fork = yield* makeTmpDir();
1595 yield* git(fork, ["init", "--bare"]);
1596
1597 const { initialBranch } = yield* initRepoWithCommit(tmp);
1598 yield* git(tmp, ["remote", "add", "jasonLaster", fork]);
1599 yield* git(tmp, ["checkout", "-b", "statemachine"]);
1600 yield* writeTextFile(path.join(tmp, "fork.txt"), "fork branch\n");
1601 yield* git(tmp, ["add", "fork.txt"]);
1602 yield* git(tmp, ["commit", "-m", "fork branch"]);
1603 yield* git(tmp, ["push", "-u", "jasonLaster", "statemachine"]);
1604 yield* git(tmp, ["checkout", initialBranch]);
1605 yield* git(tmp, ["branch", "-D", "statemachine"]);
1606 yield* git(tmp, [
1607 "checkout",
1608 "-b",
1609 "t3code/pr-488/statemachine",
1610 "--track",
1611 "jasonLaster/statemachine",
1612 ]);
1613 yield* git(tmp, ["config", "push.default", "current"]);
1614 yield* writeTextFile(path.join(tmp, "fork.txt"), "updated fork branch\n");
1615 yield* git(tmp, ["add", "fork.txt"]);
1616 yield* git(tmp, ["commit", "-m", "update reviewed PR branch"]);
1617
1618 const core = yield* GitCore;
1619 const pushed = yield* core.pushCurrentBranch(tmp, null);
1620
1621 expect(pushed.status).toBe("pushed");
1622 expect(pushed.setUpstream).toBe(false);
1623 expect(pushed.upstreamBranch).toBe("jasonLaster/statemachine");
1624 expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe(
1625 "jasonLaster/statemachine",
1626 );
1627 expect(
1628 yield* git(tmp, ["ls-remote", "--heads", "jasonLaster", "statemachine"]),
1629 ).toContain("statemachine");
1630 expect(
1631 yield* git(tmp, ["ls-remote", "--heads", "jasonLaster", "t3code/pr-488/statemachine"]),
1632 ).toBe("");
1633 }),
1634 );
1635
14891636 it.effect("includes command context when worktree removal fails", () =>
14901637 Effect.gen(function* () {
14911638 const tmp = yield* makeTmpDir();
apps/server/src/git/Layers/GitCore.ts+85-16
......@@ -191,9 +191,9 @@ function commandLabel(args: readonly string[]): string {
191191 return `git ${args.join(" ")}`;
192192}
193193
194function parseDefaultBranchFromRemoteHeadRef(value: string): string | null {
194function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null {
195195 const trimmed = value.trim();
196 const prefix = "refs/remotes/origin/";
196 const prefix = `refs/remotes/${remoteName}/`;
197197 if (!trimmed.startsWith(prefix)) {
198198 return null;
199199 }
......@@ -417,29 +417,33 @@ const makeGitCore = Effect.gen(function* () {
417417 yield* fetchUpstreamRef(cwd, upstream);
418418 });
419419
420 const resolveDefaultBranchName = (cwd: string): Effect.Effect<string | null, GitCommandError> =>
420 const resolveDefaultBranchName = (
421 cwd: string,
422 remoteName: string,
423 ): Effect.Effect<string | null, GitCommandError> =>
421424 executeGit(
422425 "GitCore.resolveDefaultBranchName",
423426 cwd,
424 ["symbolic-ref", "refs/remotes/origin/HEAD"],
427 ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`],
425428 { allowNonZeroExit: true },
426429 ).pipe(
427430 Effect.map((result) => {
428431 if (result.code !== 0) {
429432 return null;
430433 }
431 return parseDefaultBranchFromRemoteHeadRef(result.stdout);
434 return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName);
432435 }),
433436 );
434437
435438 const remoteBranchExists = (
436439 cwd: string,
440 remoteName: string,
437441 branch: string,
438442 ): Effect.Effect<boolean, GitCommandError> =>
439443 executeGit(
440444 "GitCore.remoteBranchExists",
441445 cwd,
442 ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branch}`],
446 ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`],
443447 {
444448 allowNonZeroExit: true,
445449 },
......@@ -473,6 +477,34 @@ const makeGitCore = Effect.gen(function* () {
473477 );
474478 });
475479
480 const resolvePushRemoteName = (
481 cwd: string,
482 branch: string,
483 ): Effect.Effect<string | null, GitCommandError> =>
484 Effect.gen(function* () {
485 const branchPushRemote = yield* runGitStdout(
486 "GitCore.resolvePushRemoteName.branchPushRemote",
487 cwd,
488 ["config", "--get", `branch.${branch}.pushRemote`],
489 true,
490 ).pipe(Effect.map((stdout) => stdout.trim()));
491 if (branchPushRemote.length > 0) {
492 return branchPushRemote;
493 }
494
495 const pushDefaultRemote = yield* runGitStdout(
496 "GitCore.resolvePushRemoteName.remotePushDefault",
497 cwd,
498 ["config", "--get", "remote.pushDefault"],
499 true,
500 ).pipe(Effect.map((stdout) => stdout.trim()));
501 if (pushDefaultRemote.length > 0) {
502 return pushDefaultRemote;
503 }
504
505 return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null)));
506 });
507
476508 const ensureRemote: GitCoreShape["ensureRemote"] = (input) =>
477509 Effect.gen(function* () {
478510 const preferredName = sanitizeRemoteName(input.preferredName);
......@@ -517,7 +549,11 @@ const makeGitCore = Effect.gen(function* () {
517549 true,
518550 ).pipe(Effect.map((stdout) => stdout.trim()));
519551
520 const defaultBranch = yield* resolveDefaultBranchName(cwd);
552 const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe(
553 Effect.catch(() => Effect.succeed(null)),
554 );
555 const defaultBranch =
556 primaryRemoteName === null ? null : yield* resolveDefaultBranchName(cwd, primaryRemoteName);
521557 const candidates = [
522558 configuredBaseBranch.length > 0 ? configuredBaseBranch : null,
523559 defaultBranch,
......@@ -529,9 +565,13 @@ const makeGitCore = Effect.gen(function* () {
529565 continue;
530566 }
531567
568 const remotePrefix =
569 primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null;
532570 const normalizedCandidate = candidate.startsWith("origin/")
533571 ? candidate.slice("origin/".length)
534 : candidate;
572 : remotePrefix && candidate.startsWith(remotePrefix)
573 ? candidate.slice(remotePrefix.length)
574 : candidate;
535575 if (normalizedCandidate.length === 0 || normalizedCandidate === branch) {
536576 continue;
537577 }
......@@ -540,8 +580,11 @@ const makeGitCore = Effect.gen(function* () {
540580 return normalizedCandidate;
541581 }
542582
543 if (yield* remoteBranchExists(cwd, normalizedCandidate)) {
544 return `origin/${normalizedCandidate}`;
583 if (
584 primaryRemoteName &&
585 (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate))
586 ) {
587 return `${primaryRemoteName}/${normalizedCandidate}`;
545588 }
546589 }
547590
......@@ -792,17 +835,17 @@ const makeGitCore = Effect.gen(function* () {
792835 Effect.catch(() => Effect.succeed(null)),
793836 );
794837 if (comparableBaseBranch) {
795 const hasOriginRemote = yield* originRemoteExists(cwd).pipe(
796 Effect.catch(() => Effect.succeed(false)),
838 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe(
839 Effect.catch(() => Effect.succeed(null)),
797840 );
798 if (!hasOriginRemote) {
841 if (!publishRemoteName) {
799842 return {
800843 status: "skipped_up_to_date" as const,
801844 branch,
802845 };
803846 }
804847
805 const hasRemoteBranch = yield* remoteBranchExists(cwd, branch).pipe(
848 const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe(
806849 Effect.catch(() => Effect.succeed(false)),
807850 );
808851 if (hasRemoteBranch) {
......@@ -815,20 +858,46 @@ const makeGitCore = Effect.gen(function* () {
815858 }
816859
817860 if (!details.hasUpstream) {
861 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch);
862 if (!publishRemoteName) {
863 return yield* createGitCommandError(
864 "GitCore.pushCurrentBranch",
865 cwd,
866 ["push"],
867 "Cannot push because no git remote is configured for this repository.",
868 );
869 }
818870 yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [
819871 "push",
820872 "-u",
821 "origin",
873 publishRemoteName,
822874 branch,
823875 ]);
824876 return {
825877 status: "pushed" as const,
826878 branch,
827 upstreamBranch: `origin/${branch}`,
879 upstreamBranch: `${publishRemoteName}/${branch}`,
828880 setUpstream: true,
829881 };
830882 }
831883
884 const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe(
885 Effect.catch(() => Effect.succeed(null)),
886 );
887 if (currentUpstream) {
888 yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [
889 "push",
890 currentUpstream.remoteName,
891 `HEAD:${currentUpstream.upstreamBranch}`,
892 ]);
893 return {
894 status: "pushed" as const,
895 branch,
896 upstreamBranch: currentUpstream.upstreamRef,
897 setUpstream: false,
898 };
899 }
900
832901 yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]);
833902 return {
834903 status: "pushed" as const,
apps/server/src/git/Layers/GitHubCli.ts+2-2
......@@ -181,7 +181,7 @@ const makeGitHubCli = Effect.sync(() => {
181181 "pr",
182182 "list",
183183 "--head",
184 input.headBranch,
184 input.headSelector,
185185 "--state",
186186 "open",
187187 "--limit",
......@@ -250,7 +250,7 @@ const makeGitHubCli = Effect.sync(() => {
250250 "--base",
251251 input.baseBranch,
252252 "--head",
253 input.headBranch,
253 input.headSelector,
254254 "--title",
255255 input.title,
256256 "--body-file",
apps/server/src/git/Layers/GitManager.test.ts+296-3
......@@ -22,6 +22,7 @@ import { makeGitManager } from "./GitManager.ts";
2222
2323interface FakeGhScenario {
2424 prListSequence?: string[];
25 prListByHeadSelector?: Record<string, string>;
2526 createdPrUrl?: string;
2627 defaultBranch?: string;
2728 pullRequest?: {
......@@ -221,7 +222,16 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
221222 }
222223
223224 if (args[0] === "pr" && args[1] === "list") {
224 const stdout = (prListQueue.shift() ?? "[]") + "\n";
225 const headSelectorIndex = args.findIndex((value) => value === "--head");
226 const headSelector =
227 headSelectorIndex >= 0 && headSelectorIndex < args.length - 1
228 ? args[headSelectorIndex + 1]
229 : undefined;
230 const mappedStdout =
231 typeof headSelector === "string"
232 ? scenario.prListByHeadSelector?.[headSelector]
233 : undefined;
234 const stdout = (mappedStdout ?? prListQueue.shift() ?? "[]") + "\n";
225235 return Effect.succeed({
226236 stdout,
227237 stderr: "",
......@@ -369,7 +379,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
369379 "pr",
370380 "list",
371381 "--head",
372 input.headBranch,
382 input.headSelector,
373383 "--state",
374384 "open",
375385 "--limit",
......@@ -391,7 +401,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
391401 "--base",
392402 input.baseBranch,
393403 "--head",
394 input.headBranch,
404 input.headSelector,
395405 "--title",
396406 input.title,
397407 "--body-file",
......@@ -523,6 +533,64 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
523533 }),
524534 );
525535
536 it.effect(
537 "status detects cross-repo PRs from the upstream remote URL owner",
538 () =>
539 Effect.gen(function* () {
540 const repoDir = yield* makeTempDir("t3code-git-manager-");
541 yield* initRepo(repoDir);
542 const forkDir = yield* createBareRemote();
543 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
544 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
545 fs.writeFileSync(path.join(repoDir, "fork-pr.txt"), "fork pr\n");
546 yield* runGit(repoDir, ["add", "fork-pr.txt"]);
547 yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]);
548 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
549 yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-488/statemachine"]);
550 yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]);
551 yield* runGit(repoDir, [
552 "config",
553 "remote.fork-seed.url",
554 "git@github.com:jasonLaster/codething-mvp.git",
555 ]);
556
557 const { manager, ghCalls } = yield* makeManager({
558 ghScenario: {
559 prListSequence: [
560 JSON.stringify([]),
561 JSON.stringify([]),
562 JSON.stringify([
563 {
564 number: 488,
565 title: "Rebase this PR on latest main",
566 url: "https://github.com/pingdotgg/codething-mvp/pull/488",
567 baseRefName: "main",
568 headRefName: "statemachine",
569 state: "OPEN",
570 updatedAt: "2026-03-10T07:00:00Z",
571 },
572 ]),
573 ],
574 },
575 });
576
577 const status = yield* manager.status({ cwd: repoDir });
578 expect(status.branch).toBe("t3code/pr-488/statemachine");
579 expect(status.pr).toEqual({
580 number: 488,
581 title: "Rebase this PR on latest main",
582 url: "https://github.com/pingdotgg/codething-mvp/pull/488",
583 baseBranch: "main",
584 headBranch: "statemachine",
585 state: "open",
586 });
587 expect(ghCalls).toContain(
588 "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt",
589 );
590 }),
591 12_000,
592 );
593
526594 it.effect("status returns merged PR state when latest PR was merged", () =>
527595 Effect.gen(function* () {
528596 const repoDir = yield* makeTempDir("t3code-git-manager-");
......@@ -964,6 +1032,172 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
9641032 }),
9651033 );
9661034
1035 it.effect(
1036 "returns existing cross-repo PR metadata using the fork owner selector",
1037 () =>
1038 Effect.gen(function* () {
1039 const repoDir = yield* makeTempDir("t3code-git-manager-");
1040 yield* initRepo(repoDir);
1041 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
1042 const forkDir = yield* createBareRemote();
1043 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
1044 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
1045 yield* runGit(repoDir, [
1046 "config",
1047 "remote.fork-seed.url",
1048 "git@github.com:octocat/codething-mvp.git",
1049 ]);
1050
1051 const { manager, ghCalls } = yield* makeManager({
1052 ghScenario: {
1053 prListSequence: [
1054 JSON.stringify([]),
1055 JSON.stringify([
1056 {
1057 number: 142,
1058 title: "Existing fork PR",
1059 url: "https://github.com/pingdotgg/codething-mvp/pull/142",
1060 baseRefName: "main",
1061 headRefName: "statemachine",
1062 },
1063 ]),
1064 ],
1065 },
1066 });
1067
1068 const result = yield* runStackedAction(manager, {
1069 cwd: repoDir,
1070 action: "commit_push_pr",
1071 });
1072
1073 expect(result.pr.status).toBe("opened_existing");
1074 expect(result.pr.number).toBe(142);
1075 expect(
1076 ghCalls.some((call) =>
1077 call.includes("pr list --head octocat:statemachine --state open --limit 1"),
1078 ),
1079 ).toBe(true);
1080 expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false);
1081 }),
1082 12_000,
1083 );
1084
1085 it.effect(
1086 "prefers owner-qualified selectors before bare branch names for cross-repo PRs",
1087 () =>
1088 Effect.gen(function* () {
1089 const repoDir = yield* makeTempDir("t3code-git-manager-");
1090 yield* initRepo(repoDir);
1091 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
1092 const forkDir = yield* createBareRemote();
1093 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
1094 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
1095 yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]);
1096 yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]);
1097 yield* runGit(repoDir, [
1098 "config",
1099 "remote.fork-seed.url",
1100 "git@github.com:octocat/codething-mvp.git",
1101 ]);
1102
1103 const { manager, ghCalls } = yield* makeManager({
1104 ghScenario: {
1105 prListByHeadSelector: {
1106 "t3code/pr-142/statemachine": JSON.stringify([]),
1107 statemachine: JSON.stringify([
1108 {
1109 number: 41,
1110 title: "Unrelated same-repo PR",
1111 url: "https://github.com/pingdotgg/codething-mvp/pull/41",
1112 baseRefName: "main",
1113 headRefName: "statemachine",
1114 },
1115 ]),
1116 "octocat:statemachine": JSON.stringify([
1117 {
1118 number: 142,
1119 title: "Existing fork PR",
1120 url: "https://github.com/pingdotgg/codething-mvp/pull/142",
1121 baseRefName: "main",
1122 headRefName: "statemachine",
1123 },
1124 ]),
1125 "fork-seed:statemachine": JSON.stringify([]),
1126 },
1127 },
1128 });
1129
1130 const result = yield* runStackedAction(manager, {
1131 cwd: repoDir,
1132 action: "commit_push_pr",
1133 });
1134
1135 expect(result.pr.status).toBe("opened_existing");
1136 expect(result.pr.number).toBe(142);
1137
1138 const ownerSelectorCallIndex = ghCalls.findIndex((call) =>
1139 call.includes("pr list --head octocat:statemachine --state open --limit 1"),
1140 );
1141 expect(ownerSelectorCallIndex).toBeGreaterThanOrEqual(0);
1142 expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false);
1143 }),
1144 12_000,
1145 );
1146
1147 it.effect(
1148 "stops probing head selectors after finding an existing PR",
1149 () =>
1150 Effect.gen(function* () {
1151 const repoDir = yield* makeTempDir("t3code-git-manager-");
1152 yield* initRepo(repoDir);
1153 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
1154 const forkDir = yield* createBareRemote();
1155 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
1156 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
1157 yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]);
1158 yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]);
1159 yield* runGit(repoDir, [
1160 "config",
1161 "remote.fork-seed.url",
1162 "git@github.com:octocat/codething-mvp.git",
1163 ]);
1164
1165 const { manager, ghCalls } = yield* makeManager({
1166 ghScenario: {
1167 prListByHeadSelector: {
1168 "octocat:statemachine": JSON.stringify([
1169 {
1170 number: 142,
1171 title: "Existing fork PR",
1172 url: "https://github.com/pingdotgg/codething-mvp/pull/142",
1173 baseRefName: "main",
1174 headRefName: "statemachine",
1175 },
1176 ]),
1177 "fork-seed:statemachine": JSON.stringify([]),
1178 "t3code/pr-142/statemachine": JSON.stringify([]),
1179 statemachine: JSON.stringify([]),
1180 },
1181 },
1182 });
1183
1184 const result = yield* runStackedAction(manager, {
1185 cwd: repoDir,
1186 action: "commit_push_pr",
1187 });
1188
1189 expect(result.pr.status).toBe("opened_existing");
1190 expect(result.pr.number).toBe(142);
1191
1192 const prListCalls = ghCalls.filter((call) => call.startsWith("pr list "));
1193 expect(prListCalls).toHaveLength(1);
1194 expect(prListCalls[0]).toContain(
1195 "pr list --head octocat:statemachine --state open --limit 1",
1196 );
1197 }),
1198 12_000,
1199 );
1200
9671201 it.effect("creates PR when one does not already exist", () =>
9681202 Effect.gen(function* () {
9691203 const repoDir = yield* makeTempDir("t3code-git-manager-");
......@@ -1008,6 +1242,65 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
10081242 }),
10091243 );
10101244
1245 it.effect("creates cross-repo PRs with the fork owner selector and default base branch", () =>
1246 Effect.gen(function* () {
1247 const repoDir = yield* makeTempDir("t3code-git-manager-");
1248 yield* initRepo(repoDir);
1249 const forkDir = yield* createBareRemote();
1250 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
1251 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
1252 fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n");
1253 yield* runGit(repoDir, ["add", "changes.txt"]);
1254 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
1255 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
1256 yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-91/statemachine"]);
1257 yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]);
1258 yield* runGit(repoDir, [
1259 "config",
1260 "remote.fork-seed.url",
1261 "git@github.com:octocat/codething-mvp.git",
1262 ]);
1263
1264 const { manager, ghCalls } = yield* makeManager({
1265 ghScenario: {
1266 prListSequence: [
1267 JSON.stringify([]),
1268 JSON.stringify([]),
1269 JSON.stringify([]),
1270 JSON.stringify([]),
1271 JSON.stringify([]),
1272 JSON.stringify([]),
1273 JSON.stringify([
1274 {
1275 number: 188,
1276 title: "Add stacked git actions",
1277 url: "https://github.com/pingdotgg/codething-mvp/pull/188",
1278 baseRefName: "main",
1279 headRefName: "statemachine",
1280 },
1281 ]),
1282 ],
1283 },
1284 });
1285
1286 const result = yield* runStackedAction(manager, {
1287 cwd: repoDir,
1288 action: "commit_push_pr",
1289 });
1290
1291 expect(result.pr.status).toBe("created");
1292 expect(result.pr.number).toBe(188);
1293 expect(
1294 ghCalls.some((call) => call.includes("pr create --base main --head octocat:statemachine")),
1295 ).toBe(true);
1296 expect(
1297 ghCalls.some((call) =>
1298 call.includes("pr create --base statemachine --head octocat:statemachine"),
1299 ),
1300 ).toBe(false);
1301 }),
1302 );
1303
10111304 it.effect("rejects push/pr actions from detached HEAD", () =>
10121305 Effect.gen(function* () {
10131306 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/git/Layers/GitManager.ts+212-57
......@@ -42,6 +42,17 @@ interface PullRequestHeadRemoteInfo {
4242 headRepositoryOwnerLogin?: string | null;
4343}
4444
45interface BranchHeadContext {
46 localBranch: string;
47 headBranch: string;
48 headSelectors: ReadonlyArray<string>;
49 preferredHeadSelector: string;
50 remoteName: string | null;
51 headRepositoryNameWithOwner: string | null;
52 headRepositoryOwnerLogin: string | null;
53 isCrossRepository: boolean;
54}
55
4556function parseRepositoryNameFromPullRequestUrl(url: string): string | null {
4657 const trimmed = url.trim();
4758 const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed);
......@@ -82,6 +93,30 @@ function resolvePullRequestWorktreeLocalBranchName(
8293 return `t3code/pr-${pullRequest.number}/${suffix}`;
8394}
8495
96function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null {
97 const trimmed = url?.trim() ?? "";
98 if (trimmed.length === 0) {
99 return null;
100 }
101
102 const match =
103 /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec(
104 trimmed,
105 );
106 const repositoryNameWithOwner = match?.[1]?.trim() ?? "";
107 return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null;
108}
109
110function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null {
111 const trimmed = nameWithOwner?.trim() ?? "";
112 if (trimmed.length === 0) {
113 return null;
114 }
115 const [ownerLogin] = trimmed.split("/");
116 const normalizedOwnerLogin = ownerLogin?.trim() ?? "";
117 return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null;
118}
119
85120function parsePullRequestList(raw: unknown): PullRequestInfo[] {
86121 if (!Array.isArray(raw)) return [];
87122
......@@ -217,6 +252,14 @@ function extractBranchFromRef(ref: string): string {
217252 return normalized.slice(firstSlash + 1).trim();
218253}
219254
255function appendUnique(values: string[], next: string | null | undefined): void {
256 const trimmed = next?.trim() ?? "";
257 if (trimmed.length === 0 || values.includes(trimmed)) {
258 return;
259 }
260 values.push(trimmed);
261}
262
220263function toStatusPr(pr: PullRequestInfo): {
221264 number: number;
222265 title: string;
......@@ -394,63 +437,162 @@ export const makeGitManager = Effect.gen(function* () {
394437
395438 const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
396439
397 const findOpenPr = (cwd: string, branch: string) =>
398 gitHubCli
399 .listOpenPullRequests({
400 cwd,
401 headBranch: branch,
402 limit: 1,
403 })
404 .pipe(
405 Effect.map((prs) => {
406 const [first] = prs;
407 if (!first) {
408 return null;
409 }
440 const readConfigValueNullable = (cwd: string, key: string) =>
441 gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null)));
442
443 const resolveRemoteRepositoryContext = (cwd: string, remoteName: string | null) =>
444 Effect.gen(function* () {
445 if (!remoteName) {
446 return {
447 repositoryNameWithOwner: null,
448 ownerLogin: null,
449 };
450 }
451
452 const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`);
453 const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl);
454 return {
455 repositoryNameWithOwner,
456 ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner),
457 };
458 });
459
460 const resolveBranchHeadContext = (
461 cwd: string,
462 details: { branch: string; upstreamRef: string | null },
463 ) =>
464 Effect.gen(function* () {
465 const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`);
466 const headBranchFromUpstream = details.upstreamRef
467 ? extractBranchFromRef(details.upstreamRef)
468 : "";
469 const headBranch =
470 headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch;
471
472 const [remoteRepository, originRepository] = yield* Effect.all(
473 [
474 resolveRemoteRepositoryContext(cwd, remoteName),
475 resolveRemoteRepositoryContext(cwd, "origin"),
476 ],
477 { concurrency: "unbounded" },
478 );
479
480 const isCrossRepository =
481 remoteRepository.repositoryNameWithOwner !== null &&
482 originRepository.repositoryNameWithOwner !== null
483 ? remoteRepository.repositoryNameWithOwner.toLowerCase() !==
484 originRepository.repositoryNameWithOwner.toLowerCase()
485 : remoteName !== null &&
486 remoteName !== "origin" &&
487 remoteRepository.repositoryNameWithOwner !== null;
488
489 const ownerHeadSelector =
490 remoteRepository.ownerLogin && headBranch.length > 0
491 ? `${remoteRepository.ownerLogin}:${headBranch}`
492 : null;
493 const remoteAliasHeadSelector =
494 remoteName && headBranch.length > 0 ? `${remoteName}:${headBranch}` : null;
495 const shouldProbeRemoteOwnedSelectors =
496 isCrossRepository || (remoteName !== null && remoteName !== "origin");
497
498 const headSelectors: string[] = [];
499 if (isCrossRepository && shouldProbeRemoteOwnedSelectors) {
500 appendUnique(headSelectors, ownerHeadSelector);
501 appendUnique(
502 headSelectors,
503 remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null,
504 );
505 }
506 appendUnique(headSelectors, details.branch);
507 appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null);
508 if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) {
509 appendUnique(headSelectors, ownerHeadSelector);
510 appendUnique(
511 headSelectors,
512 remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null,
513 );
514 }
515
516 return {
517 localBranch: details.branch,
518 headBranch,
519 headSelectors,
520 preferredHeadSelector:
521 ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch,
522 remoteName,
523 headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner,
524 headRepositoryOwnerLogin: remoteRepository.ownerLogin,
525 isCrossRepository,
526 } satisfies BranchHeadContext;
527 });
528
529 const findOpenPr = (cwd: string, headSelectors: ReadonlyArray<string>) =>
530 Effect.gen(function* () {
531 for (const headSelector of headSelectors) {
532 const pullRequests = yield* gitHubCli.listOpenPullRequests({
533 cwd,
534 headSelector,
535 limit: 1,
536 });
537
538 const [firstPullRequest] = pullRequests;
539 if (firstPullRequest) {
410540 return {
411 number: first.number,
412 title: first.title,
413 url: first.url,
414 baseRefName: first.baseRefName,
415 headRefName: first.headRefName,
541 number: firstPullRequest.number,
542 title: firstPullRequest.title,
543 url: firstPullRequest.url,
544 baseRefName: firstPullRequest.baseRefName,
545 headRefName: firstPullRequest.headRefName,
416546 state: "open",
417547 updatedAt: null,
418548 } satisfies PullRequestInfo;
419 }),
420 );
549 }
550 }
421551
422 const findLatestPr = (cwd: string, branch: string) =>
552 return null;
553 });
554
555 const findLatestPr = (cwd: string, details: { branch: string; upstreamRef: string | null }) =>
423556 Effect.gen(function* () {
424 const stdout = yield* gitHubCli
425 .execute({
426 cwd,
427 args: [
428 "pr",
429 "list",
430 "--head",
431 branch,
432 "--state",
433 "all",
434 "--limit",
435 "20",
436 "--json",
437 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt",
438 ],
439 })
440 .pipe(Effect.map((result) => result.stdout));
557 const headContext = yield* resolveBranchHeadContext(cwd, details);
558 const parsedByNumber = new Map<number, PullRequestInfo>();
559
560 for (const headSelector of headContext.headSelectors) {
561 const stdout = yield* gitHubCli
562 .execute({
563 cwd,
564 args: [
565 "pr",
566 "list",
567 "--head",
568 headSelector,
569 "--state",
570 "all",
571 "--limit",
572 "20",
573 "--json",
574 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt",
575 ],
576 })
577 .pipe(Effect.map((result) => result.stdout));
578
579 const raw = stdout.trim();
580 if (raw.length === 0) {
581 continue;
582 }
441583
442 const raw = stdout.trim();
443 if (raw.length === 0) {
444 return null;
445 }
584 const parsedJson = yield* Effect.try({
585 try: () => JSON.parse(raw) as unknown,
586 catch: (cause) =>
587 gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause),
588 });
446589
447 const parsedJson = yield* Effect.try({
448 try: () => JSON.parse(raw) as unknown,
449 catch: (cause) =>
450 gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause),
451 });
590 for (const pr of parsePullRequestList(parsedJson)) {
591 parsedByNumber.set(pr.number, pr);
592 }
593 }
452594
453 const parsed = parsePullRequestList(parsedJson).toSorted((a, b) => {
595 const parsed = Array.from(parsedByNumber.values()).toSorted((a, b) => {
454596 const left = a.updatedAt ? Date.parse(a.updatedAt) : 0;
455597 const right = b.updatedAt ? Date.parse(b.updatedAt) : 0;
456598 return right - left;
......@@ -463,12 +605,17 @@ export const makeGitManager = Effect.gen(function* () {
463605 return parsed[0] ?? null;
464606 });
465607
466 const resolveBaseBranch = (cwd: string, branch: string, upstreamRef: string | null) =>
608 const resolveBaseBranch = (
609 cwd: string,
610 branch: string,
611 upstreamRef: string | null,
612 headContext: Pick<BranchHeadContext, "isCrossRepository">,
613 ) =>
467614 Effect.gen(function* () {
468615 const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`);
469616 if (configured) return configured;
470617
471 if (upstreamRef) {
618 if (upstreamRef && !headContext.isCrossRepository) {
472619 const upstreamBranch = extractBranchFromRef(upstreamRef);
473620 if (upstreamBranch.length > 0 && upstreamBranch !== branch) {
474621 return upstreamBranch;
......@@ -571,7 +718,12 @@ export const makeGitManager = Effect.gen(function* () {
571718 );
572719 }
573720
574 const existing = yield* findOpenPr(cwd, branch);
721 const headContext = yield* resolveBranchHeadContext(cwd, {
722 branch,
723 upstreamRef: details.upstreamRef,
724 });
725
726 const existing = yield* findOpenPr(cwd, headContext.headSelectors);
575727 if (existing) {
576728 return {
577729 status: "opened_existing" as const,
......@@ -583,13 +735,13 @@ export const makeGitManager = Effect.gen(function* () {
583735 };
584736 }
585737
586 const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef);
738 const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext);
587739 const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch);
588740
589741 const generated = yield* textGeneration.generatePrContent({
590742 cwd,
591743 baseBranch,
592 headBranch: branch,
744 headBranch: headContext.headBranch,
593745 commitSummary: limitContext(rangeContext.commitSummary, 20_000),
594746 diffSummary: limitContext(rangeContext.diffSummary, 20_000),
595747 diffPatch: limitContext(rangeContext.diffPatch, 60_000),
......@@ -607,18 +759,18 @@ export const makeGitManager = Effect.gen(function* () {
607759 .createPullRequest({
608760 cwd,
609761 baseBranch,
610 headBranch: branch,
762 headSelector: headContext.preferredHeadSelector,
611763 title: generated.title,
612764 bodyFile,
613765 })
614766 .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void))));
615767
616 const created = yield* findOpenPr(cwd, branch);
768 const created = yield* findOpenPr(cwd, headContext.headSelectors);
617769 if (!created) {
618770 return {
619771 status: "created" as const,
620772 baseBranch,
621 headBranch: branch,
773 headBranch: headContext.headBranch,
622774 title: generated.title,
623775 };
624776 }
......@@ -638,7 +790,10 @@ export const makeGitManager = Effect.gen(function* () {
638790
639791 const pr =
640792 details.branch !== null
641 ? yield* findLatestPr(input.cwd, details.branch).pipe(
793 ? yield* findLatestPr(input.cwd, {
794 branch: details.branch,
795 upstreamRef: details.upstreamRef,
796 }).pipe(
642797 Effect.map((latest) => (latest ? toStatusPr(latest) : null)),
643798 Effect.catch(() => Effect.succeed(null)),
644799 )
apps/server/src/git/Services/GitHubCli.ts+2-2
......@@ -47,7 +47,7 @@ export interface GitHubCliShape {
4747 */
4848 readonly listOpenPullRequests: (input: {
4949 readonly cwd: string;
50 readonly headBranch: string;
50 readonly headSelector: string;
5151 readonly limit?: number;
5252 }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>;
5353
......@@ -73,7 +73,7 @@ export interface GitHubCliShape {
7373 readonly createPullRequest: (input: {
7474 readonly cwd: string;
7575 readonly baseBranch: string;
76 readonly headBranch: string;
76 readonly headSelector: string;
7777 readonly title: string;
7878 readonly bodyFile: string;
7979 }) => Effect.Effect<void, GitHubCliError>;
apps/web/src/components/BranchToolbar.logic.test.ts+4-2
......@@ -149,7 +149,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => {
149149 ]);
150150 });
151151
152 it("dedupes remote refs for remotes whose names contain slashes", () => {
152 it("keeps non-origin remote refs visible even when a matching local branch exists", () => {
153153 const input: GitBranch[] = [
154154 {
155155 name: "feature/demo",
......@@ -169,10 +169,11 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => {
169169
170170 expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([
171171 "feature/demo",
172 "my-org/upstream/feature/demo",
172173 ]);
173174 });
174175
175 it("dedupes remote refs when git tracks with first-slash local naming", () => {
176 it("keeps non-origin remote refs visible when git tracks with first-slash local naming", () => {
176177 const input: GitBranch[] = [
177178 {
178179 name: "upstream/feature",
......@@ -192,6 +193,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => {
192193
193194 expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([
194195 "upstream/feature",
196 "my-org/upstream/feature",
195197 ]);
196198 });
197199});
apps/web/src/components/BranchToolbar.logic.ts+4
......@@ -81,6 +81,10 @@ export function dedupeRemoteBranchesWithLocalMatches(
8181 return true;
8282 }
8383
84 if (branch.remoteName !== "origin") {
85 return true;
86 }
87
8488 const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef(
8589 branch.name,
8690 branch.remoteName,
apps/web/src/components/GitActionsControl.tsx-2
......@@ -293,14 +293,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
293293 }
294294 onConfirmed?.();
295295
296 const pushTarget = !featureBranch && actionBranch ? `origin/${actionBranch}` : undefined;
297296 const progressStages = buildGitActionProgressStages({
298297 action,
299298 hasCustomCommitMessage: !!commitMessage?.trim(),
300299 hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges,
301300 forcePushOnly: forcePushOnlyProgress,
302301 featureBranch,
303 ...(pushTarget ? { pushTarget } : {}),
304302 });
305303 const resolvedProgressToastId =
306304 progressToastId ??