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) => {...@@ -1409,6 +1409,44 @@ it.layer(TestLayer)("git integration", (it) => {
1409 }),1409 }),
1410 );1410 );
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
1412 it.effect("skips push when no upstream is configured and branch is not ahead of base", () =>1450 it.effect("skips push when no upstream is configured and branch is not ahead of base", () =>
1413 Effect.gen(function* () {1451 Effect.gen(function* () {
1414 const tmp = yield* makeTmpDir();1452 const tmp = yield* makeTmpDir();
...@@ -1450,6 +1488,31 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1450,6 +1488,31 @@ it.layer(TestLayer)("git integration", (it) => {
1450 }),1488 }),
1451 );1489 );
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
1453 it.effect(1516 it.effect(
1454 "pushes with upstream setup when comparable base exists but remote branch is missing",1517 "pushes with upstream setup when comparable base exists but remote branch is missing",
1455 () =>1518 () =>
...@@ -1486,6 +1549,90 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1486,6 +1549,90 @@ it.layer(TestLayer)("git integration", (it) => {
1486 }),1549 }),
1487 );1550 );
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
1489 it.effect("includes command context when worktree removal fails", () =>1636 it.effect("includes command context when worktree removal fails", () =>
1490 Effect.gen(function* () {1637 Effect.gen(function* () {
1491 const tmp = yield* makeTmpDir();1638 const tmp = yield* makeTmpDir();
apps/server/src/git/Layers/GitCore.ts+85-16
...@@ -191,9 +191,9 @@ function commandLabel(args: readonly string[]): string {...@@ -191,9 +191,9 @@ function commandLabel(args: readonly string[]): string {
191 return `git ${args.join(" ")}`;191 return `git ${args.join(" ")}`;
192}192}
193193
194function parseDefaultBranchFromRemoteHeadRef(value: string): string | null {194function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null {
195 const trimmed = value.trim();195 const trimmed = value.trim();
196 const prefix = "refs/remotes/origin/";196 const prefix = `refs/remotes/${remoteName}/`;
197 if (!trimmed.startsWith(prefix)) {197 if (!trimmed.startsWith(prefix)) {
198 return null;198 return null;
199 }199 }
...@@ -417,29 +417,33 @@ const makeGitCore = Effect.gen(function* () {...@@ -417,29 +417,33 @@ const makeGitCore = Effect.gen(function* () {
417 yield* fetchUpstreamRef(cwd, upstream);417 yield* fetchUpstreamRef(cwd, upstream);
418 });418 });
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> =>
421 executeGit(424 executeGit(
422 "GitCore.resolveDefaultBranchName",425 "GitCore.resolveDefaultBranchName",
423 cwd,426 cwd,
424 ["symbolic-ref", "refs/remotes/origin/HEAD"],427 ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`],
425 { allowNonZeroExit: true },428 { allowNonZeroExit: true },
426 ).pipe(429 ).pipe(
427 Effect.map((result) => {430 Effect.map((result) => {
428 if (result.code !== 0) {431 if (result.code !== 0) {
429 return null;432 return null;
430 }433 }
431 return parseDefaultBranchFromRemoteHeadRef(result.stdout);434 return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName);
432 }),435 }),
433 );436 );
434437
435 const remoteBranchExists = (438 const remoteBranchExists = (
436 cwd: string,439 cwd: string,
440 remoteName: string,
437 branch: string,441 branch: string,
438 ): Effect.Effect<boolean, GitCommandError> =>442 ): Effect.Effect<boolean, GitCommandError> =>
439 executeGit(443 executeGit(
440 "GitCore.remoteBranchExists",444 "GitCore.remoteBranchExists",
441 cwd,445 cwd,
442 ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branch}`],446 ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`],
443 {447 {
444 allowNonZeroExit: true,448 allowNonZeroExit: true,
445 },449 },
...@@ -473,6 +477,34 @@ const makeGitCore = Effect.gen(function* () {...@@ -473,6 +477,34 @@ const makeGitCore = Effect.gen(function* () {
473 );477 );
474 });478 });
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
476 const ensureRemote: GitCoreShape["ensureRemote"] = (input) =>508 const ensureRemote: GitCoreShape["ensureRemote"] = (input) =>
477 Effect.gen(function* () {509 Effect.gen(function* () {
478 const preferredName = sanitizeRemoteName(input.preferredName);510 const preferredName = sanitizeRemoteName(input.preferredName);
...@@ -517,7 +549,11 @@ const makeGitCore = Effect.gen(function* () {...@@ -517,7 +549,11 @@ const makeGitCore = Effect.gen(function* () {
517 true,549 true,
518 ).pipe(Effect.map((stdout) => stdout.trim()));550 ).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);
521 const candidates = [557 const candidates = [
522 configuredBaseBranch.length > 0 ? configuredBaseBranch : null,558 configuredBaseBranch.length > 0 ? configuredBaseBranch : null,
523 defaultBranch,559 defaultBranch,
...@@ -529,9 +565,13 @@ const makeGitCore = Effect.gen(function* () {...@@ -529,9 +565,13 @@ const makeGitCore = Effect.gen(function* () {
529 continue;565 continue;
530 }566 }
531567
568 const remotePrefix =
569 primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null;
532 const normalizedCandidate = candidate.startsWith("origin/")570 const normalizedCandidate = candidate.startsWith("origin/")
533 ? candidate.slice("origin/".length)571 ? candidate.slice("origin/".length)
534 : candidate;572 : remotePrefix && candidate.startsWith(remotePrefix)
573 ? candidate.slice(remotePrefix.length)
574 : candidate;
535 if (normalizedCandidate.length === 0 || normalizedCandidate === branch) {575 if (normalizedCandidate.length === 0 || normalizedCandidate === branch) {
536 continue;576 continue;
537 }577 }
...@@ -540,8 +580,11 @@ const makeGitCore = Effect.gen(function* () {...@@ -540,8 +580,11 @@ const makeGitCore = Effect.gen(function* () {
540 return normalizedCandidate;580 return normalizedCandidate;
541 }581 }
542582
543 if (yield* remoteBranchExists(cwd, normalizedCandidate)) {583 if (
544 return `origin/${normalizedCandidate}`;584 primaryRemoteName &&
585 (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate))
586 ) {
587 return `${primaryRemoteName}/${normalizedCandidate}`;
545 }588 }
546 }589 }
547590
...@@ -792,17 +835,17 @@ const makeGitCore = Effect.gen(function* () {...@@ -792,17 +835,17 @@ const makeGitCore = Effect.gen(function* () {
792 Effect.catch(() => Effect.succeed(null)),835 Effect.catch(() => Effect.succeed(null)),
793 );836 );
794 if (comparableBaseBranch) {837 if (comparableBaseBranch) {
795 const hasOriginRemote = yield* originRemoteExists(cwd).pipe(838 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe(
796 Effect.catch(() => Effect.succeed(false)),839 Effect.catch(() => Effect.succeed(null)),
797 );840 );
798 if (!hasOriginRemote) {841 if (!publishRemoteName) {
799 return {842 return {
800 status: "skipped_up_to_date" as const,843 status: "skipped_up_to_date" as const,
801 branch,844 branch,
802 };845 };
803 }846 }
804847
805 const hasRemoteBranch = yield* remoteBranchExists(cwd, branch).pipe(848 const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe(
806 Effect.catch(() => Effect.succeed(false)),849 Effect.catch(() => Effect.succeed(false)),
807 );850 );
808 if (hasRemoteBranch) {851 if (hasRemoteBranch) {
...@@ -815,20 +858,46 @@ const makeGitCore = Effect.gen(function* () {...@@ -815,20 +858,46 @@ const makeGitCore = Effect.gen(function* () {
815 }858 }
816859
817 if (!details.hasUpstream) {860 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 }
818 yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [870 yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [
819 "push",871 "push",
820 "-u",872 "-u",
821 "origin",873 publishRemoteName,
822 branch,874 branch,
823 ]);875 ]);
824 return {876 return {
825 status: "pushed" as const,877 status: "pushed" as const,
826 branch,878 branch,
827 upstreamBranch: `origin/${branch}`,879 upstreamBranch: `${publishRemoteName}/${branch}`,
828 setUpstream: true,880 setUpstream: true,
829 };881 };
830 }882 }
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
832 yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]);901 yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]);
833 return {902 return {
834 status: "pushed" as const,903 status: "pushed" as const,
apps/server/src/git/Layers/GitHubCli.ts+2-2
...@@ -181,7 +181,7 @@ const makeGitHubCli = Effect.sync(() => {...@@ -181,7 +181,7 @@ const makeGitHubCli = Effect.sync(() => {
181 "pr",181 "pr",
182 "list",182 "list",
183 "--head",183 "--head",
184 input.headBranch,184 input.headSelector,
185 "--state",185 "--state",
186 "open",186 "open",
187 "--limit",187 "--limit",
...@@ -250,7 +250,7 @@ const makeGitHubCli = Effect.sync(() => {...@@ -250,7 +250,7 @@ const makeGitHubCli = Effect.sync(() => {
250 "--base",250 "--base",
251 input.baseBranch,251 input.baseBranch,
252 "--head",252 "--head",
253 input.headBranch,253 input.headSelector,
254 "--title",254 "--title",
255 input.title,255 input.title,
256 "--body-file",256 "--body-file",
apps/server/src/git/Layers/GitManager.test.ts+296-3
...@@ -22,6 +22,7 @@ import { makeGitManager } from "./GitManager.ts";...@@ -22,6 +22,7 @@ import { makeGitManager } from "./GitManager.ts";
2222
23interface FakeGhScenario {23interface FakeGhScenario {
24 prListSequence?: string[];24 prListSequence?: string[];
25 prListByHeadSelector?: Record<string, string>;
25 createdPrUrl?: string;26 createdPrUrl?: string;
26 defaultBranch?: string;27 defaultBranch?: string;
27 pullRequest?: {28 pullRequest?: {
...@@ -221,7 +222,16 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -221,7 +222,16 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
221 }222 }
222223
223 if (args[0] === "pr" && args[1] === "list") {224 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";
225 return Effect.succeed({235 return Effect.succeed({
226 stdout,236 stdout,
227 stderr: "",237 stderr: "",
...@@ -369,7 +379,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -369,7 +379,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
369 "pr",379 "pr",
370 "list",380 "list",
371 "--head",381 "--head",
372 input.headBranch,382 input.headSelector,
373 "--state",383 "--state",
374 "open",384 "open",
375 "--limit",385 "--limit",
...@@ -391,7 +401,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -391,7 +401,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
391 "--base",401 "--base",
392 input.baseBranch,402 input.baseBranch,
393 "--head",403 "--head",
394 input.headBranch,404 input.headSelector,
395 "--title",405 "--title",
396 input.title,406 input.title,
397 "--body-file",407 "--body-file",
...@@ -523,6 +533,64 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -523,6 +533,64 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
523 }),533 }),
524 );534 );
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
526 it.effect("status returns merged PR state when latest PR was merged", () =>594 it.effect("status returns merged PR state when latest PR was merged", () =>
527 Effect.gen(function* () {595 Effect.gen(function* () {
528 const repoDir = yield* makeTempDir("t3code-git-manager-");596 const repoDir = yield* makeTempDir("t3code-git-manager-");
...@@ -964,6 +1032,172 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -964,6 +1032,172 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
964 }),1032 }),
965 );1033 );
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
967 it.effect("creates PR when one does not already exist", () =>1201 it.effect("creates PR when one does not already exist", () =>
968 Effect.gen(function* () {1202 Effect.gen(function* () {
969 const repoDir = yield* makeTempDir("t3code-git-manager-");1203 const repoDir = yield* makeTempDir("t3code-git-manager-");
...@@ -1008,6 +1242,65 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -1008,6 +1242,65 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
1008 }),1242 }),
1009 );1243 );
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
1011 it.effect("rejects push/pr actions from detached HEAD", () =>1304 it.effect("rejects push/pr actions from detached HEAD", () =>
1012 Effect.gen(function* () {1305 Effect.gen(function* () {
1013 const repoDir = yield* makeTempDir("t3code-git-manager-");1306 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/git/Layers/GitManager.ts+212-57
...@@ -42,6 +42,17 @@ interface PullRequestHeadRemoteInfo {...@@ -42,6 +42,17 @@ interface PullRequestHeadRemoteInfo {
42 headRepositoryOwnerLogin?: string | null;42 headRepositoryOwnerLogin?: string | null;
43}43}
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
45function parseRepositoryNameFromPullRequestUrl(url: string): string | null {56function parseRepositoryNameFromPullRequestUrl(url: string): string | null {
46 const trimmed = url.trim();57 const trimmed = url.trim();
47 const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed);58 const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed);
...@@ -82,6 +93,30 @@ function resolvePullRequestWorktreeLocalBranchName(...@@ -82,6 +93,30 @@ function resolvePullRequestWorktreeLocalBranchName(
82 return `t3code/pr-${pullRequest.number}/${suffix}`;93 return `t3code/pr-${pullRequest.number}/${suffix}`;
83}94}
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
85function parsePullRequestList(raw: unknown): PullRequestInfo[] {120function parsePullRequestList(raw: unknown): PullRequestInfo[] {
86 if (!Array.isArray(raw)) return [];121 if (!Array.isArray(raw)) return [];
87122
...@@ -217,6 +252,14 @@ function extractBranchFromRef(ref: string): string {...@@ -217,6 +252,14 @@ function extractBranchFromRef(ref: string): string {
217 return normalized.slice(firstSlash + 1).trim();252 return normalized.slice(firstSlash + 1).trim();
218}253}
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
220function toStatusPr(pr: PullRequestInfo): {263function toStatusPr(pr: PullRequestInfo): {
221 number: number;264 number: number;
222 title: string;265 title: string;
...@@ -394,63 +437,162 @@ export const makeGitManager = Effect.gen(function* () {...@@ -394,63 +437,162 @@ export const makeGitManager = Effect.gen(function* () {
394437
395 const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";438 const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
396439
397 const findOpenPr = (cwd: string, branch: string) =>440 const readConfigValueNullable = (cwd: string, key: string) =>
398 gitHubCli441 gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null)));
399 .listOpenPullRequests({442
400 cwd,443 const resolveRemoteRepositoryContext = (cwd: string, remoteName: string | null) =>
401 headBranch: branch,444 Effect.gen(function* () {
402 limit: 1,445 if (!remoteName) {
403 })446 return {
404 .pipe(447 repositoryNameWithOwner: null,
405 Effect.map((prs) => {448 ownerLogin: null,
406 const [first] = prs;449 };
407 if (!first) {450 }
408 return null;451
409 }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) {
410 return {540 return {
411 number: first.number,541 number: firstPullRequest.number,
412 title: first.title,542 title: firstPullRequest.title,
413 url: first.url,543 url: firstPullRequest.url,
414 baseRefName: first.baseRefName,544 baseRefName: firstPullRequest.baseRefName,
415 headRefName: first.headRefName,545 headRefName: firstPullRequest.headRefName,
416 state: "open",546 state: "open",
417 updatedAt: null,547 updatedAt: null,
418 } satisfies PullRequestInfo;548 } satisfies PullRequestInfo;
419 }),549 }
420 );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 }) =>
423 Effect.gen(function* () {556 Effect.gen(function* () {
424 const stdout = yield* gitHubCli557 const headContext = yield* resolveBranchHeadContext(cwd, details);
425 .execute({558 const parsedByNumber = new Map<number, PullRequestInfo>();
426 cwd,559
427 args: [560 for (const headSelector of headContext.headSelectors) {
428 "pr",561 const stdout = yield* gitHubCli
429 "list",562 .execute({
430 "--head",563 cwd,
431 branch,564 args: [
432 "--state",565 "pr",
433 "all",566 "list",
434 "--limit",567 "--head",
435 "20",568 headSelector,
436 "--json",569 "--state",
437 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt",570 "all",
438 ],571 "--limit",
439 })572 "20",
440 .pipe(Effect.map((result) => result.stdout));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();584 const parsedJson = yield* Effect.try({
443 if (raw.length === 0) {585 try: () => JSON.parse(raw) as unknown,
444 return null;586 catch: (cause) =>
445 }587 gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause),
588 });
446589
447 const parsedJson = yield* Effect.try({590 for (const pr of parsePullRequestList(parsedJson)) {
448 try: () => JSON.parse(raw) as unknown,591 parsedByNumber.set(pr.number, pr);
449 catch: (cause) =>592 }
450 gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause),593 }
451 });
452594
453 const parsed = parsePullRequestList(parsedJson).toSorted((a, b) => {595 const parsed = Array.from(parsedByNumber.values()).toSorted((a, b) => {
454 const left = a.updatedAt ? Date.parse(a.updatedAt) : 0;596 const left = a.updatedAt ? Date.parse(a.updatedAt) : 0;
455 const right = b.updatedAt ? Date.parse(b.updatedAt) : 0;597 const right = b.updatedAt ? Date.parse(b.updatedAt) : 0;
456 return right - left;598 return right - left;
...@@ -463,12 +605,17 @@ export const makeGitManager = Effect.gen(function* () {...@@ -463,12 +605,17 @@ export const makeGitManager = Effect.gen(function* () {
463 return parsed[0] ?? null;605 return parsed[0] ?? null;
464 });606 });
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 ) =>
467 Effect.gen(function* () {614 Effect.gen(function* () {
468 const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`);615 const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`);
469 if (configured) return configured;616 if (configured) return configured;
470617
471 if (upstreamRef) {618 if (upstreamRef && !headContext.isCrossRepository) {
472 const upstreamBranch = extractBranchFromRef(upstreamRef);619 const upstreamBranch = extractBranchFromRef(upstreamRef);
473 if (upstreamBranch.length > 0 && upstreamBranch !== branch) {620 if (upstreamBranch.length > 0 && upstreamBranch !== branch) {
474 return upstreamBranch;621 return upstreamBranch;
...@@ -571,7 +718,12 @@ export const makeGitManager = Effect.gen(function* () {...@@ -571,7 +718,12 @@ export const makeGitManager = Effect.gen(function* () {
571 );718 );
572 }719 }
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);
575 if (existing) {727 if (existing) {
576 return {728 return {
577 status: "opened_existing" as const,729 status: "opened_existing" as const,
...@@ -583,13 +735,13 @@ export const makeGitManager = Effect.gen(function* () {...@@ -583,13 +735,13 @@ export const makeGitManager = Effect.gen(function* () {
583 };735 };
584 }736 }
585737
586 const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef);738 const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext);
587 const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch);739 const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch);
588740
589 const generated = yield* textGeneration.generatePrContent({741 const generated = yield* textGeneration.generatePrContent({
590 cwd,742 cwd,
591 baseBranch,743 baseBranch,
592 headBranch: branch,744 headBranch: headContext.headBranch,
593 commitSummary: limitContext(rangeContext.commitSummary, 20_000),745 commitSummary: limitContext(rangeContext.commitSummary, 20_000),
594 diffSummary: limitContext(rangeContext.diffSummary, 20_000),746 diffSummary: limitContext(rangeContext.diffSummary, 20_000),
595 diffPatch: limitContext(rangeContext.diffPatch, 60_000),747 diffPatch: limitContext(rangeContext.diffPatch, 60_000),
...@@ -607,18 +759,18 @@ export const makeGitManager = Effect.gen(function* () {...@@ -607,18 +759,18 @@ export const makeGitManager = Effect.gen(function* () {
607 .createPullRequest({759 .createPullRequest({
608 cwd,760 cwd,
609 baseBranch,761 baseBranch,
610 headBranch: branch,762 headSelector: headContext.preferredHeadSelector,
611 title: generated.title,763 title: generated.title,
612 bodyFile,764 bodyFile,
613 })765 })
614 .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void))));766 .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);
617 if (!created) {769 if (!created) {
618 return {770 return {
619 status: "created" as const,771 status: "created" as const,
620 baseBranch,772 baseBranch,
621 headBranch: branch,773 headBranch: headContext.headBranch,
622 title: generated.title,774 title: generated.title,
623 };775 };
624 }776 }
...@@ -638,7 +790,10 @@ export const makeGitManager = Effect.gen(function* () {...@@ -638,7 +790,10 @@ export const makeGitManager = Effect.gen(function* () {
638790
639 const pr =791 const pr =
640 details.branch !== null792 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(
642 Effect.map((latest) => (latest ? toStatusPr(latest) : null)),797 Effect.map((latest) => (latest ? toStatusPr(latest) : null)),
643 Effect.catch(() => Effect.succeed(null)),798 Effect.catch(() => Effect.succeed(null)),
644 )799 )
apps/server/src/git/Services/GitHubCli.ts+2-2
...@@ -47,7 +47,7 @@ export interface GitHubCliShape {...@@ -47,7 +47,7 @@ export interface GitHubCliShape {
47 */47 */
48 readonly listOpenPullRequests: (input: {48 readonly listOpenPullRequests: (input: {
49 readonly cwd: string;49 readonly cwd: string;
50 readonly headBranch: string;50 readonly headSelector: string;
51 readonly limit?: number;51 readonly limit?: number;
52 }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>;52 }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>;
5353
...@@ -73,7 +73,7 @@ export interface GitHubCliShape {...@@ -73,7 +73,7 @@ export interface GitHubCliShape {
73 readonly createPullRequest: (input: {73 readonly createPullRequest: (input: {
74 readonly cwd: string;74 readonly cwd: string;
75 readonly baseBranch: string;75 readonly baseBranch: string;
76 readonly headBranch: string;76 readonly headSelector: string;
77 readonly title: string;77 readonly title: string;
78 readonly bodyFile: string;78 readonly bodyFile: string;
79 }) => Effect.Effect<void, GitHubCliError>;79 }) => Effect.Effect<void, GitHubCliError>;
apps/web/src/components/BranchToolbar.logic.test.ts+4-2
...@@ -149,7 +149,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => {...@@ -149,7 +149,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => {
149 ]);149 ]);
150 });150 });
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", () => {
153 const input: GitBranch[] = [153 const input: GitBranch[] = [
154 {154 {
155 name: "feature/demo",155 name: "feature/demo",
...@@ -169,10 +169,11 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => {...@@ -169,10 +169,11 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => {
169169
170 expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([170 expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([
171 "feature/demo",171 "feature/demo",
172 "my-org/upstream/feature/demo",
172 ]);173 ]);
173 });174 });
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", () => {
176 const input: GitBranch[] = [177 const input: GitBranch[] = [
177 {178 {
178 name: "upstream/feature",179 name: "upstream/feature",
...@@ -192,6 +193,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => {...@@ -192,6 +193,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => {
192193
193 expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([194 expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([
194 "upstream/feature",195 "upstream/feature",
196 "my-org/upstream/feature",
195 ]);197 ]);
196 });198 });
197});199});
apps/web/src/components/BranchToolbar.logic.ts+4
...@@ -81,6 +81,10 @@ export function dedupeRemoteBranchesWithLocalMatches(...@@ -81,6 +81,10 @@ export function dedupeRemoteBranchesWithLocalMatches(
81 return true;81 return true;
82 }82 }
8383
84 if (branch.remoteName !== "origin") {
85 return true;
86 }
87
84 const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef(88 const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef(
85 branch.name,89 branch.name,
86 branch.remoteName,90 branch.remoteName,
apps/web/src/components/GitActionsControl.tsx-2
...@@ -293,14 +293,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -293,14 +293,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
293 }293 }
294 onConfirmed?.();294 onConfirmed?.();
295295
296 const pushTarget = !featureBranch && actionBranch ? `origin/${actionBranch}` : undefined;
297 const progressStages = buildGitActionProgressStages({296 const progressStages = buildGitActionProgressStages({
298 action,297 action,
299 hasCustomCommitMessage: !!commitMessage?.trim(),298 hasCustomCommitMessage: !!commitMessage?.trim(),
300 hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges,299 hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges,
301 forcePushOnly: forcePushOnlyProgress,300 forcePushOnly: forcePushOnlyProgress,
302 featureBranch,301 featureBranch,
303 ...(pushTarget ? { pushTarget } : {}),
304 });302 });
305 const resolvedProgressToastId =303 const resolvedProgressToastId =
306 progressToastId ??304 progressToastId ??