authorgravatar for adam.bogucki2018@gmail.comAdamulek123 <adam.bogucki2018@gmail.com> 2026-10-11 05:40:53+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 03:40:53+00:00
log6cf05264257093476a41f73439b1fa745427a007
treefc6dd84603e3a940d9e2a3e9962b260ff2909b93
parent75fca7d77cd4a5f72167606f248131edb633bb50
signature Signed by PGP key B5690EEEBB952194

fix(server): detect fork PRs for branches without upstreams (#13894)

Co-authored-by: maria-rcks <maria@kuuro.net>

3 files changed, 463 insertions(+), 26 deletions(-)

apps/server/src/git/GitManager.test.ts+333-7
...@@ -272,6 +272,11 @@ function initRepo(...@@ -272,6 +272,11 @@ function initRepo(
272 yield* runGit(cwd, ["init", "--initial-branch=main"]);272 yield* runGit(cwd, ["init", "--initial-branch=main"]);
273 yield* runGit(cwd, ["config", "user.email", "test@example.com"]);273 yield* runGit(cwd, ["config", "user.email", "test@example.com"]);
274 yield* runGit(cwd, ["config", "user.name", "Test User"]);274 yield* runGit(cwd, ["config", "user.name", "Test User"]);
275 // Hermetic line endings: machine-global `core.autocrlf=true` on Windows
276 // makes Git check out CRLF, which breaks exact-content assertions that
277 // write LF with Node. Local config overrides the global so the fixture
278 // (and its linked worktrees, which share config) always checks out LF.
279 yield* runGit(cwd, ["config", "core.autocrlf", "false"]);
275 yield* fs.writeFileString(NodePath.join(cwd, "README.md"), "hello\n");280 yield* fs.writeFileString(NodePath.join(cwd, "README.md"), "hello\n");
276 yield* runGit(cwd, ["add", "README.md"]);281 yield* runGit(cwd, ["add", "README.md"]);
277 yield* runGit(cwd, ["commit", "-m", "Initial commit"]);282 yield* runGit(cwd, ["commit", "-m", "Initial commit"]);
...@@ -1630,6 +1635,316 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -1630,6 +1635,316 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
1630 }),1635 }),
1631 );1636 );
16321637
1638 it.effect("finds a fork PR for a branch pushed without an upstream", () =>
1639 Effect.gen(function* () {
1640 const repoDir = yield* makeTempDir("t3code-git-manager-");
1641 yield* initRepo(repoDir);
1642 const originDir = yield* createBareRemote();
1643 const forkDir = yield* createBareRemote();
1644 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1645 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1646 yield* runGit(repoDir, ["remote", "add", "fork", forkDir]);
1647 yield* runGit(repoDir, ["checkout", "-b", "feature/no-upstream-fork"]);
1648 yield* runGit(repoDir, ["push", "fork", "feature/no-upstream-fork"]);
1649 yield* configureVisibleRemoteUrlWithLocalRewrite(
1650 repoDir,
1651 "origin",
1652 "git@github.com:pingdotgg/codething-mvp.git",
1653 originDir,
1654 );
1655 yield* configureVisibleRemoteUrlWithLocalRewrite(
1656 repoDir,
1657 "fork",
1658 "git@github.com:contributor/codething-mvp.git",
1659 forkDir,
1660 );
1661
1662 const ghScenario = {
1663 prListByHeadSelector: {
1664 "feature/no-upstream-fork": JSON.stringify([
1665 {
1666 number: 40,
1667 title: "Another fork's PR",
1668 url: "https://github.com/pingdotgg/codething-mvp/pull/40",
1669 baseRefName: "main",
1670 headRefName: "feature/no-upstream-fork",
1671 state: "OPEN",
1672 isCrossRepository: true,
1673 headRepository: { nameWithOwner: "someone-else/codething-mvp" },
1674 headRepositoryOwner: { login: "someone-else" },
1675 },
1676 {
1677 number: 41,
1678 title: "Our fork's PR",
1679 url: "https://github.com/pingdotgg/codething-mvp/pull/41",
1680 baseRefName: "main",
1681 headRefName: "feature/no-upstream-fork",
1682 state: "OPEN",
1683 isCrossRepository: true,
1684 headRepository: { nameWithOwner: "contributor/codething-mvp" },
1685 headRepositoryOwner: { login: "contributor" },
1686 },
1687 ]),
1688 },
1689 };
1690 const { manager } = yield* makeManager({ ghScenario });
1691
1692 const pullRequest = yield* manager.branchPullRequest({
1693 cwd: repoDir,
1694 branch: "feature/no-upstream-fork",
1695 });
1696 expect(pullRequest?.number).toBe(41);
1697 expect((yield* manager.status({ cwd: repoDir })).pr?.number).toBe(41);
1698
1699 yield* runGit(repoDir, ["commit", "--allow-empty", "-m", "Continue feature"]);
1700 const localHead = yield* runGit(repoDir, ["rev-parse", "HEAD"]);
1701 const pushedHead = yield* runGit(repoDir, [
1702 "rev-parse",
1703 "refs/remotes/fork/feature/no-upstream-fork",
1704 ]);
1705 expect(localHead.stdout.trim()).not.toBe(pushedHead.stdout.trim());
1706 const { manager: aheadManager } = yield* makeManager({ ghScenario });
1707 expect(
1708 (yield* aheadManager.branchPullRequest({
1709 cwd: repoDir,
1710 branch: "feature/no-upstream-fork",
1711 }))?.number,
1712 ).toBe(41);
1713 expect((yield* aheadManager.status({ cwd: repoDir })).pr?.number).toBe(41);
1714
1715 yield* runGit(repoDir, ["config", "branch.feature/no-upstream-fork.remote", "origin"]);
1716 const upstream = yield* runGit(repoDir, [
1717 "for-each-ref",
1718 "--format=%(upstream:short)",
1719 "refs/heads/feature/no-upstream-fork",
1720 ]);
1721 expect(upstream.stdout.trim()).toBe("");
1722 const { manager: staleRemoteManager } = yield* makeManager({ ghScenario });
1723 expect(
1724 (yield* staleRemoteManager.branchPullRequest({
1725 cwd: repoDir,
1726 branch: "feature/no-upstream-fork",
1727 }))?.number,
1728 ).toBe(41);
1729
1730 yield* runGit(repoDir, ["config", "--unset", "branch.feature/no-upstream-fork.remote"]);
1731 yield* runGit(repoDir, ["checkout", "-b", "diverged-push", "main"]);
1732 yield* runGit(repoDir, ["commit", "--allow-empty", "-m", "Rewrite fork head"]);
1733 yield* runGit(repoDir, [
1734 "push",
1735 "--force",
1736 "fork",
1737 "HEAD:refs/heads/feature/no-upstream-fork",
1738 ]);
1739 yield* runGit(repoDir, ["checkout", "feature/no-upstream-fork"]);
1740 const { manager: divergedManager } = yield* makeManager({ ghScenario });
1741 expect(
1742 yield* divergedManager.branchPullRequest({
1743 cwd: repoDir,
1744 branch: "feature/no-upstream-fork",
1745 }),
1746 ).toBeNull();
1747 }),
1748 );
1749
1750 it.effect("prefers an exact fork ref but does not guess between equal refs", () =>
1751 Effect.gen(function* () {
1752 const repoDir = yield* makeTempDir("t3code-git-manager-");
1753 yield* initRepo(repoDir);
1754 const originDir = yield* createBareRemote();
1755 const firstForkDir = yield* createBareRemote();
1756 const secondForkDir = yield* createBareRemote();
1757 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1758 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1759 yield* runGit(repoDir, ["remote", "add", "first-fork", firstForkDir]);
1760 yield* runGit(repoDir, ["remote", "add", "second-fork", secondForkDir]);
1761 yield* runGit(repoDir, ["checkout", "-b", "feature/ambiguous-fork"]);
1762 yield* runGit(repoDir, ["push", "first-fork", "feature/ambiguous-fork"]);
1763 yield* configureVisibleRemoteUrlWithLocalRewrite(
1764 repoDir,
1765 "origin",
1766 "git@github.com:pingdotgg/codething-mvp.git",
1767 originDir,
1768 );
1769 yield* configureVisibleRemoteUrlWithLocalRewrite(
1770 repoDir,
1771 "first-fork",
1772 "git@github.com:contributor/codething-mvp.git",
1773 firstForkDir,
1774 );
1775 yield* configureVisibleRemoteUrlWithLocalRewrite(
1776 repoDir,
1777 "second-fork",
1778 "git@github.com:someone-else/codething-mvp.git",
1779 secondForkDir,
1780 );
1781
1782 const ghScenario = {
1783 prListByHeadSelector: {
1784 "feature/ambiguous-fork": JSON.stringify([
1785 {
1786 number: 41,
1787 title: "One fork's PR",
1788 url: "https://github.com/pingdotgg/codething-mvp/pull/41",
1789 baseRefName: "main",
1790 headRefName: "feature/ambiguous-fork",
1791 state: "OPEN",
1792 isCrossRepository: true,
1793 headRepository: { nameWithOwner: "contributor/codething-mvp" },
1794 headRepositoryOwner: { login: "contributor" },
1795 },
1796 ]),
1797 },
1798 };
1799 const { manager: singleRemoteManager } = yield* makeManager({ ghScenario });
1800 expect(
1801 (yield* singleRemoteManager.branchPullRequest({
1802 cwd: repoDir,
1803 branch: "feature/ambiguous-fork",
1804 }))?.number,
1805 ).toBe(41);
1806
1807 yield* runGit(repoDir, ["push", "second-fork", "feature/ambiguous-fork"]);
1808 const { manager } = yield* makeManager({ ghScenario });
1809 expect(
1810 yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature/ambiguous-fork" }),
1811 ).toBeNull();
1812
1813 yield* runGit(repoDir, ["commit", "--allow-empty", "-m", "Continue feature"]);
1814 yield* runGit(repoDir, ["push", "first-fork", "feature/ambiguous-fork"]);
1815 const { manager: exactManager } = yield* makeManager({ ghScenario });
1816 expect(
1817 (yield* exactManager.branchPullRequest({
1818 cwd: repoDir,
1819 branch: "feature/ambiguous-fork",
1820 }))?.number,
1821 ).toBe(41);
1822 }),
1823 );
1824
1825 it.effect("does not guess a fork when an ancestry probe fails", () =>
1826 Effect.gen(function* () {
1827 const repoDir = yield* makeTempDir("t3code-git-manager-");
1828 yield* initRepo(repoDir);
1829 const originDir = yield* createBareRemote();
1830 const forkDir = yield* createBareRemote();
1831 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1832 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1833 yield* runGit(repoDir, ["remote", "add", "fork", forkDir]);
1834 yield* runGit(repoDir, ["checkout", "-b", "feature/failed-probe"]);
1835 yield* runGit(repoDir, ["push", "fork", "feature/failed-probe"]);
1836 yield* runGit(repoDir, ["push", "origin", "feature/failed-probe"]);
1837 // Local work past both tips, so no exact ref matches and the ancestor
1838 // tier decides.
1839 yield* runGit(repoDir, ["commit", "--allow-empty", "-m", "Local work"]);
1840 // Simulate a failed ancestry probe: point origin's tracking ref at a
1841 // non-commit object so `merge-base --is-ancestor` exits 128 — neither
1842 // a match (0) nor a negative result (1).
1843 const treeOid = (yield* runGit(repoDir, ["rev-parse", "HEAD^{tree}"])).stdout.trim();
1844 yield* runGit(repoDir, ["update-ref", "refs/remotes/origin/feature/failed-probe", treeOid]);
1845 const forkProbe = yield* runGit(
1846 repoDir,
1847 ["merge-base", "--is-ancestor", "refs/remotes/fork/feature/failed-probe", "HEAD"],
1848 true,
1849 );
1850 expect(forkProbe.exitCode).toBe(0);
1851 const originProbe = yield* runGit(
1852 repoDir,
1853 ["merge-base", "--is-ancestor", "refs/remotes/origin/feature/failed-probe", "HEAD"],
1854 true,
1855 );
1856 expect(originProbe.exitCode).not.toBe(0);
1857 expect(originProbe.exitCode).not.toBe(1);
1858 yield* configureVisibleRemoteUrlWithLocalRewrite(
1859 repoDir,
1860 "origin",
1861 "git@github.com:pingdotgg/codething-mvp.git",
1862 originDir,
1863 );
1864 yield* configureVisibleRemoteUrlWithLocalRewrite(
1865 repoDir,
1866 "fork",
1867 "git@github.com:contributor/codething-mvp.git",
1868 forkDir,
1869 );
1870
1871 const ghScenario = {
1872 prListByHeadSelector: {
1873 "feature/failed-probe": JSON.stringify([
1874 {
1875 number: 43,
1876 title: "Failed probe PR",
1877 url: "https://github.com/pingdotgg/codething-mvp/pull/43",
1878 baseRefName: "main",
1879 headRefName: "feature/failed-probe",
1880 state: "OPEN",
1881 isCrossRepository: true,
1882 headRepository: { nameWithOwner: "contributor/codething-mvp" },
1883 headRepositoryOwner: { login: "contributor" },
1884 },
1885 ]),
1886 },
1887 };
1888 const { manager } = yield* makeManager({ ghScenario });
1889 // The fork probe matches, but the failed origin probe rejects the
1890 // whole inference: unknown, invisible rather than wrong.
1891 expect(
1892 yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature/failed-probe" }),
1893 ).toBeNull();
1894 }),
1895 );
1896
1897 it.effect("finds a fork PR when the remote name contains a slash", () =>
1898 Effect.gen(function* () {
1899 const repoDir = yield* makeTempDir("t3code-git-manager-");
1900 yield* initRepo(repoDir);
1901 const originDir = yield* createBareRemote();
1902 const forkDir = yield* createBareRemote();
1903 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
1904 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1905 yield* runGit(repoDir, ["remote", "add", "team/fork", forkDir]);
1906 yield* runGit(repoDir, ["checkout", "-b", "feature/slash-remote"]);
1907 yield* runGit(repoDir, ["push", "team/fork", "feature/slash-remote"]);
1908 yield* configureVisibleRemoteUrlWithLocalRewrite(
1909 repoDir,
1910 "origin",
1911 "git@github.com:pingdotgg/codething-mvp.git",
1912 originDir,
1913 );
1914 yield* configureVisibleRemoteUrlWithLocalRewrite(
1915 repoDir,
1916 "team/fork",
1917 "git@github.com:contributor/codething-mvp.git",
1918 forkDir,
1919 );
1920
1921 const { manager } = yield* makeManager({
1922 ghScenario: {
1923 prListByHeadSelector: {
1924 "feature/slash-remote": JSON.stringify([
1925 {
1926 number: 42,
1927 title: "Slash remote PR",
1928 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
1929 baseRefName: "main",
1930 headRefName: "feature/slash-remote",
1931 state: "OPEN",
1932 isCrossRepository: true,
1933 headRepository: { nameWithOwner: "contributor/codething-mvp" },
1934 headRepositoryOwner: { login: "contributor" },
1935 },
1936 ]),
1937 },
1938 },
1939 });
1940
1941 expect(
1942 (yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature/slash-remote" }))
1943 ?.number,
1944 ).toBe(42);
1945 }),
1946 );
1947
1633 it.effect("branch PR lookup does not reuse a cached PR after the remote is repointed", () =>1948 it.effect("branch PR lookup does not reuse a cached PR after the remote is repointed", () =>
1634 Effect.gen(function* () {1949 Effect.gen(function* () {
1635 const repoDir = yield* makeTempDir("t3code-git-manager-");1950 const repoDir = yield* makeTempDir("t3code-git-manager-");
...@@ -2096,9 +2411,11 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2096,9 +2411,11 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
2096 );2411 );
20972412
2098 it.effect.each([2413 it.effect.each([
2099 "git@gitlab.com:Group/Subgroup/Fork.git",2414 { remoteUrl: "git@gitlab.com:Group/Subgroup/Fork.git", setUpstream: true },
2100 "https://gitlab.com/Group/Subgroup/Fork.git",2415 { remoteUrl: "git@gitlab.com:Group/Subgroup/Fork.git", setUpstream: false },
2101 ])("matches nested GitLab forks through the adapter for %s", (remoteUrl) =>2416 { remoteUrl: "https://gitlab.com/Group/Subgroup/Fork.git", setUpstream: true },
2417 { remoteUrl: "https://gitlab.com/Group/Subgroup/Fork.git", setUpstream: false },
2418 ] as const)("matches GitLab forks for $remoteUrl/$setUpstream", ({ remoteUrl, setUpstream }) =>
2102 Effect.gen(function* () {2419 Effect.gen(function* () {
2103 const repoDir = yield* makeTempDir("t3code-git-manager-");2420 const repoDir = yield* makeTempDir("t3code-git-manager-");
2104 yield* initRepo(repoDir);2421 yield* initRepo(repoDir);
...@@ -2109,7 +2426,13 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2109,7 +2426,13 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
2109 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);2426 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2110 yield* runGit(repoDir, ["remote", "add", "fork", forkDir]);2427 yield* runGit(repoDir, ["remote", "add", "fork", forkDir]);
2111 yield* runGit(repoDir, ["checkout", "-b", branch]);2428 yield* runGit(repoDir, ["checkout", "-b", branch]);
2112 yield* runGit(repoDir, ["push", "-u", "fork", branch]);2429 yield* runGit(repoDir, ["push", ...(setUpstream ? ["-u"] : []), "fork", branch]);
2430 const upstream = yield* runGit(repoDir, [
2431 "for-each-ref",
2432 "--format=%(upstream:short)",
2433 `refs/heads/${branch}`,
2434 ]);
2435 expect(upstream.stdout.trim()).toBe(setUpstream ? `fork/${branch}` : "");
2113 yield* configureVisibleRemoteUrlWithLocalRewrite(2436 yield* configureVisibleRemoteUrlWithLocalRewrite(
2114 repoDir,2437 repoDir,
2115 "origin",2438 "origin",
...@@ -5639,9 +5962,12 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -5639,9 +5962,12 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
5639 expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(5962 expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(
5640 rewrittenHead,5963 rewrittenHead,
5641 );5964 );
5642 expect(NodeFS.readFileSync(NodePath.join(worktreePath, "force-pushed.txt"), "utf8")).toBe(5965 expect(
5643 "rewritten\n",5966 NodeFS.readFileSync(NodePath.join(worktreePath, "force-pushed.txt"), "utf8").replace(
5644 );5967 /\r\n/g,
5968 "\n",
5969 ),
5970 ).toBe("rewritten\n");
5645 }),5971 }),
5646 );5972 );
56475973
apps/server/src/git/GitManager.ts+126-19
...@@ -1337,8 +1337,21 @@ export const make = Effect.gen(function* () {...@@ -1337,8 +1337,21 @@ export const make = Effect.gen(function* () {
1337 ),1337 ),
1338 ),1338 ),
1339 Effect.map(({ pr }) => pr),1339 Effect.map(({ pr }) => pr),
1340 Effect.catch((error) =>1340 Effect.catch((error) => {
1341 Effect.logWarning("PR lookup failed; keeping last known PR state.").pipe(1341 // No prior PR means there is no badge to preserve or remote to verify.
1342 const fallback = lastKnownPrByBranchKey.get(branchKey)?.pr
1343 ? resolveLookupHeadContext(cwd, details).pipe(
1344 Effect.map(({ headContext }) =>
1345 resolveLastKnownPr(branchKey, {
1346 upstreamRef: details.upstreamRef,
1347 headBranch: headContext.headBranch,
1348 remoteName: headContext.remoteName,
1349 headRemoteUrlKey: headContext.headRemoteUrlKey,
1350 }),
1351 ),
1352 )
1353 : Effect.succeed(null);
1354 return Effect.logWarning("PR lookup failed.").pipe(
1342 Effect.annotateLogs({1355 Effect.annotateLogs({
1343 operation: "lookupStatusPr",1356 operation: "lookupStatusPr",
1344 branch: details.branch,1357 branch: details.branch,
...@@ -1355,17 +1368,9 @@ export const make = Effect.gen(function* () {...@@ -1355,17 +1368,9 @@ export const make = Effect.gen(function* () {
1355 }1368 }
1356 : {}),1369 : {}),
1357 }),1370 }),
1358 Effect.andThen(resolveLookupHeadContext(cwd, details)),1371 Effect.andThen(fallback),
1359 Effect.map(({ headContext }) =>1372 );
1360 resolveLastKnownPr(branchKey, {1373 }),
1361 upstreamRef: details.upstreamRef,
1362 headBranch: headContext.headBranch,
1363 remoteName: headContext.remoteName,
1364 headRemoteUrlKey: headContext.headRemoteUrlKey,
1365 }),
1366 ),
1367 ),
1368 ),
1369 );1374 );
1370 });1375 });
1371 const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (1376 const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (
...@@ -1615,7 +1620,94 @@ export const make = Effect.gen(function* () {...@@ -1615,7 +1620,94 @@ export const make = Effect.gen(function* () {
1615 }1620 }
1616 if (matching.includes("origin")) return "origin";1621 if (matching.includes("origin")) return "origin";
1617 return matching[0] ?? null;1622 return matching[0] ?? null;
1618 }).pipe(Effect.orElseSucceed(() => null));1623 });
1624 });
1625
1626 // A no-upstream branch may have local commits after its last push. Prefer a
1627 // unique exact ref, then a unique remote tip in its history; never break a
1628 // fork tie by remote order.
1629 const findPublishedBranchRemote = Effect.fn("findPublishedBranchRemote")(function* (
1630 cwd: string,
1631 branch: string,
1632 ) {
1633 const localRef = `refs/heads/${branch}`;
1634 const remoteNames = (yield* gitCore.execute({
1635 operation: "GitManager.findPublishedBranchRemote.remotes",
1636 cwd,
1637 args: ["remote"],
1638 timeoutMs: 5_000,
1639 })).stdout
1640 .split("\n")
1641 .map((name) => name.trim())
1642 .filter((name) => name.length > 0);
1643 if (remoteNames.length === 0) return null;
1644
1645 const refs = yield* gitCore.execute({
1646 operation: "GitManager.findPublishedBranchRemote",
1647 cwd,
1648 args: [
1649 "for-each-ref",
1650 "--format=%(refname)%00%(objectname)",
1651 localRef,
1652 ...remoteNames.map((name) => `refs/remotes/${name}/${branch}`),
1653 ],
1654 timeoutMs: 5_000,
1655 });
1656 const oidByRef = new Map(
1657 refs.stdout
1658 .split("\n")
1659 .map((line) => line.trim().split("\u0000"))
1660 .filter((entry): entry is [string, string] => entry.length === 2),
1661 );
1662 const localOid = oidByRef.get(localRef);
1663 if (!localOid) return null;
1664
1665 const remoteRefs = remoteNames.flatMap((name) => {
1666 const oid = oidByRef.get(`refs/remotes/${name}/${branch}`);
1667 return oid ? [{ name, oid }] : [];
1668 });
1669 const exactRemotes = remoteRefs.filter((remote) => remote.oid === localOid);
1670 if (exactRemotes.length > 1) return null;
1671 let remoteName = exactRemotes[0]?.name;
1672 if (remoteName === undefined) {
1673 const ancestorRemotes = yield* Effect.forEach(
1674 remoteRefs,
1675 (remote) =>
1676 gitCore
1677 .execute({
1678 operation: "GitManager.findPublishedBranchRemote.ancestor",
1679 cwd,
1680 args: ["merge-base", "--is-ancestor", remote.oid, localOid],
1681 allowNonZeroExit: true,
1682 timeoutMs: 5_000,
1683 })
1684 .pipe(
1685 Effect.flatMap((result) => {
1686 // `merge-base --is-ancestor` reports 0 for ancestor and 1
1687 // for not-ancestor; any other non-zero exit is a failed
1688 // probe, not a negative result. A failed probe rejects the
1689 // whole inference instead of letting a surviving probe win
1690 // by default and attach the wrong fork's PR.
1691 if (result.exitCode === 0) return Effect.succeed(remote.name);
1692 if (result.exitCode === 1) return Effect.succeed(null);
1693 return new GitCommandError({
1694 operation: "GitManager.findPublishedBranchRemote.ancestor",
1695 command: "git merge-base --is-ancestor",
1696 cwd,
1697 exitCode: result.exitCode,
1698 detail: "Ancestry probe failed; rejecting ancestry inference.",
1699 });
1700 }),
1701 ),
1702 { concurrency: "unbounded" },
1703 );
1704 const matches = ancestorRemotes.filter((name) => name !== null);
1705 if (matches.length !== 1) return null;
1706 remoteName = matches[0];
1707 }
1708 if (remoteName === undefined) return null;
1709 const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`);
1710 return remoteUrl === null ? null : remoteName;
1619 });1711 });
16201712
1621 // `git worktree add -b feature origin/main` makes the new local branch track1713 // `git worktree add -b feature origin/main` makes the new local branch track
...@@ -1640,6 +1732,17 @@ export const make = Effect.gen(function* () {...@@ -1640,6 +1732,17 @@ export const make = Effect.gen(function* () {
1640 },1732 },
1641 ) {1733 ) {
1642 const headContext = yield* resolveBranchHeadContext(cwd, details);1734 const headContext = yield* resolveBranchHeadContext(cwd, details);
1735 if (details.upstreamRef === null) {
1736 const remoteName = yield* findPublishedBranchRemote(cwd, details.branch).pipe(
1737 Effect.orElseSucceed(() => null),
1738 );
1739 if (remoteName !== null && remoteName !== headContext.remoteName) {
1740 return {
1741 headContext: yield* resolveBranchHeadContext(cwd, { ...details, remoteName }),
1742 lookup: true,
1743 };
1744 }
1745 }
1643 const upstreamHeadIsDefault =1746 const upstreamHeadIsDefault =
1644 headContext.headBranch === details.defaultBranch ||1747 headContext.headBranch === details.defaultBranch ||
1645 (details.defaultBranch === null &&1748 (details.defaultBranch === null &&
...@@ -1651,7 +1754,11 @@ export const make = Effect.gen(function* () {...@@ -1651,7 +1754,11 @@ export const make = Effect.gen(function* () {
1651 ) {1754 ) {
1652 return { headContext, lookup: true };1755 return { headContext, lookup: true };
1653 }1756 }
1654 const remoteName = yield* findRemoteTrackingRemote(cwd, details.branch, headContext.remoteName);1757 const remoteName = yield* findRemoteTrackingRemote(
1758 cwd,
1759 details.branch,
1760 headContext.remoteName,
1761 ).pipe(Effect.orElseSucceed(() => null));
1655 if (remoteName === null) {1762 if (remoteName === null) {
1656 return { headContext, lookup: false };1763 return { headContext, lookup: false };
1657 }1764 }
...@@ -1671,7 +1778,7 @@ export const make = Effect.gen(function* () {...@@ -1671,7 +1778,7 @@ export const make = Effect.gen(function* () {
1671 * terminal and agent pushes land), and configured upstream metadata survives1778 * terminal and agent pushes land), and configured upstream metadata survives
1672 * when a merged change request's remote branch is deleted. Together they1779 * when a merged change request's remote branch is deleted. Together they
1673 * distinguish branches known to have reached a host from genuinely local1780 * distinguish branches known to have reached a host from genuinely local
1674 * branches. The ref glob spans every remote so a fork branch still counts. A1781 * branches. The remote ref check includes names containing slashes. A
1675 * repository that tracks no remotes at all cannot answer the question,1782 * repository that tracks no remotes at all cannot answer the question,
1676 * because then every branch looks unpublished; it, and any failed probe,1783 * because then every branch looks unpublished; it, and any failed probe,
1677 * keeps the lookup.1784 * keeps the lookup.
...@@ -1705,11 +1812,11 @@ export const make = Effect.gen(function* () {...@@ -1705,11 +1812,11 @@ export const make = Effect.gen(function* () {
1705 return false;1812 return false;
1706 }1813 }
17071814
1708 const [tracksAnyRemote, tracksThisBranch] = yield* Effect.all(1815 const [tracksAnyRemote, trackedRemote] = yield* Effect.all(
1709 [matchesRef("refs/remotes"), matchesRef(`refs/remotes/*/${headContext.headBranch}`)],1816 [matchesRef("refs/remotes"), findRemoteTrackingRemote(cwd, headContext.headBranch, null)],
1710 { concurrency: "unbounded" },1817 { concurrency: "unbounded" },
1711 );1818 );
1712 return tracksAnyRemote && !tracksThisBranch;1819 return tracksAnyRemote && trackedRemote === null;
1713 }).pipe(Effect.orElseSucceed(() => false));1820 }).pipe(Effect.orElseSucceed(() => false));
1714 });1821 });
17151822
docs/user/source-control.md+4
...@@ -140,6 +140,10 @@ make your first commit before pushing....@@ -140,6 +140,10 @@ make your first commit before pushing.
140Use a thread's Git actions to commit, push, and create a pull request. T3 Code can generate commit140Use a thread's Git actions to commit, push, and create a pull request. T3 Code can generate commit
141messages, review titles, and descriptions from your changes.141messages, review titles, and descriptions from your changes.
142142
143An existing fork PR can appear beside the branch even if you pushed without setting an upstream.
144If its badge is missing because several remotes hold that branch, set the branch's upstream to
145the intended remote with `git branch --set-upstream-to=<remote>/<branch>`.
146
143Choose the writing style and model in **Settings → Source Control**. **Repository conventions**147Choose the writing style and model in **Settings → Source Control**. **Repository conventions**
144uses the project's instructions and recent commit subjects.148uses the project's instructions and recent commit subjects.
145149