| author | |
| committer | |
| log | 1031a226f8cf90d358d9768fc0842dcdae61a315 |
| tree | a253f3646da4cb26a7c91e228b7f39d107da5cc0 |
| parent | ddd98876b5fe3dcb789a36ab799e6e211a1bcb5a |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ); |
| 1411 | 1411 | ||
| 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 | ); |
| 1452 | 1490 | ||
| 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 | ); |
| 1488 | 1551 | ||
| 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 | } |
| 193 | 193 | ||
| 194 | function parseDefaultBranchFromRemoteHeadRef(value: string): string | null { | 194 | function 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 | }); |
| 419 | 419 | ||
| 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 | ); |
| 434 | 437 | ||
| 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 | }); |
| 475 | 479 | ||
| 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())); |
| 519 | 551 | ||
| 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 | } |
| 531 | 567 | ||
| 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 | } |
| 542 | 582 | ||
| 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 | } |
| 547 | 590 | ||
| ... | @@ -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 | } |
| 804 | 847 | ||
| 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 | } |
| 816 | 859 | ||
| 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 | } |
| 831 | 883 | ||
| 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"; |
| 22 | 22 | ||
| 23 | interface FakeGhScenario { | 23 | interface 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 | } |
| 222 | 223 | ||
| 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 | ); |
| 525 | 535 | ||
| 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 | ); |
| 966 | 1034 | ||
| 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 | ); |
| 1010 | 1244 | ||
| 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 | } |
| 44 | 44 | ||
| 45 | interface 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 | |||
| 45 | function parseRepositoryNameFromPullRequestUrl(url: string): string | null { | 56 | function 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 | } |
| 84 | 95 | ||
| 96 | function 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 | |||
| 110 | function 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 | |||
| 85 | function parsePullRequestList(raw: unknown): PullRequestInfo[] { | 120 | function parsePullRequestList(raw: unknown): PullRequestInfo[] { |
| 86 | if (!Array.isArray(raw)) return []; | 121 | if (!Array.isArray(raw)) return []; |
| 87 | 122 | ||
| ... | @@ -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 | } |
| 219 | 254 | ||
| 255 | function 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 | |||
| 220 | function toStatusPr(pr: PullRequestInfo): { | 263 | function 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* () { |
| 394 | 437 | ||
| 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"; |
| 396 | 439 | ||
| 397 | const findOpenPr = (cwd: string, branch: string) => | 440 | const readConfigValueNullable = (cwd: string, key: string) => |
| 398 | gitHubCli | 441 | 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 | } |
| 421 | 551 | ||
| 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* gitHubCli | 557 | 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 | } | ||
| 441 | 583 | ||
| 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 | }); | ||
| 446 | 589 | ||
| 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 | }); | ||
| 452 | 594 | ||
| 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 | }); |
| 465 | 607 | ||
| 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; |
| 470 | 617 | ||
| 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 | } |
| 573 | 720 | ||
| 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 | } |
| 585 | 737 | ||
| 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); |
| 588 | 740 | ||
| 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)))); |
| 615 | 767 | ||
| 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* () { |
| 638 | 790 | ||
| 639 | const pr = | 791 | const pr = |
| 640 | details.branch !== null | 792 | 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>; |
| 53 | 53 | ||
| ... | @@ -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 | }); |
| 151 | 151 | ||
| 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", () => { |
| 169 | 169 | ||
| 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 | }); |
| 174 | 175 | ||
| 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", () => { |
| 192 | 193 | ||
| 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 | } |
| 83 | 83 | ||
| 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?.(); |
| 295 | 295 | ||
| 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 ?? |