authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-25 15:23:22-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-25 15:23:22-07:00
log994372ba43810e64027c537231da200988faa7ca
treef5eebe3ae5b171241869e170dc395db636e33ef6
parent082e6ea521861fff37b90fcd789b5eaa5ef5d6a6
signature Signed by PGP key B5690EEEBB952194

fix(server): push no longer writes a feature branch's commits to its base branch (#8228)


2 files changed, 154 insertions(+), 0 deletions(-)

apps/server/src/vcs/GitVcsDriverCore.test.ts+105
......@@ -1837,6 +1837,111 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
18371837 }),
18381838 );
18391839
1840 it.effect("publishes a branch tracking its base under its own name, not the base", () =>
1841 Effect.gen(function* () {
1842 const cwd = yield* makeTmpDir();
1843 const remote = yield* makeTmpDir("git-remote-");
1844 yield* initRepoWithCommit(cwd);
1845 const driver = yield* GitVcsDriver.GitVcsDriver;
1846 yield* git(cwd, ["branch", "-M", "main"]);
1847 yield* git(remote, ["init", "--bare"]);
1848 yield* git(cwd, ["remote", "add", "origin", remote]);
1849 yield* git(cwd, ["push", "-u", "origin", "main"]);
1850 yield* git(cwd, ["checkout", "-b", "dev"]);
1851 yield* git(cwd, ["push", "-u", "origin", "dev"]);
1852 const devSha = yield* git(cwd, ["rev-parse", "HEAD"]);
1853 yield* git(cwd, ["checkout", "-b", "feature/x", "origin/dev"]);
1854 yield* writeTextFile(cwd, "feature.txt", "feature\n");
1855 yield* driver.prepareCommitContext(cwd);
1856 yield* driver.commit(cwd, "Add feature", "");
1857
1858 const pushed = yield* driver.pushCurrentBranch(cwd, null);
1859
1860 assert.deepInclude(pushed, {
1861 status: "pushed",
1862 branch: "feature/x",
1863 upstreamBranch: "origin/feature/x",
1864 setUpstream: true,
1865 });
1866 assert.equal(yield* git(remote, ["log", "-1", "--pretty=%s", "feature/x"]), "Add feature");
1867 assert.equal(yield* git(remote, ["rev-parse", "dev"]), devSha);
1868 assert.equal(
1869 yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]),
1870 "origin/feature/x",
1871 );
1872 assert.equal(yield* driver.readConfigValue(cwd, "branch.feature/x.gh-merge-base"), "dev");
1873 }),
1874 );
1875
1876 it.effect("keeps a recorded merge base when publishing a tracked branch", () =>
1877 Effect.gen(function* () {
1878 const cwd = yield* makeTmpDir();
1879 const remote = yield* makeTmpDir("git-remote-");
1880 yield* initRepoWithCommit(cwd);
1881 const driver = yield* GitVcsDriver.GitVcsDriver;
1882 yield* git(cwd, ["branch", "-M", "main"]);
1883 yield* git(remote, ["init", "--bare"]);
1884 yield* git(cwd, ["remote", "add", "origin", remote]);
1885 yield* git(cwd, ["push", "-u", "origin", "main"]);
1886 yield* git(cwd, ["checkout", "-b", "feature/y", "origin/main"]);
1887 yield* git(cwd, ["config", "branch.feature/y.gh-merge-base", "release/v2"]);
1888 yield* writeTextFile(cwd, "feature.txt", "feature\n");
1889 yield* driver.prepareCommitContext(cwd);
1890 yield* driver.commit(cwd, "Add feature", "");
1891
1892 const pushed = yield* driver.pushCurrentBranch(cwd, null);
1893
1894 assert.deepInclude(pushed, {
1895 status: "pushed",
1896 branch: "feature/y",
1897 upstreamBranch: "origin/feature/y",
1898 setUpstream: true,
1899 });
1900 assert.equal(
1901 yield* driver.readConfigValue(cwd, "branch.feature/y.gh-merge-base"),
1902 "release/v2",
1903 );
1904 }),
1905 );
1906
1907 it.effect("still pushes a git-mangled tracking alias to its upstream head", () =>
1908 Effect.gen(function* () {
1909 const cwd = yield* makeTmpDir();
1910 const remote = yield* makeTmpDir("git-remote-");
1911 yield* initRepoWithCommit(cwd);
1912 const driver = yield* GitVcsDriver.GitVcsDriver;
1913 yield* git(cwd, ["branch", "-M", "main"]);
1914 yield* git(remote, ["init", "--bare"]);
1915 yield* git(cwd, ["remote", "add", "my-org/upstream", remote]);
1916 yield* git(cwd, ["push", "my-org/upstream", "main:effect-atom"]);
1917 yield* git(cwd, ["fetch", "my-org/upstream"]);
1918 // `checkout --track my-org/upstream/effect-atom` cannot name the local
1919 // branch `effect-atom`, so git keeps `upstream/effect-atom`. Its
1920 // upstream is still its published head.
1921 yield* git(cwd, ["checkout", "--track", "my-org/upstream/effect-atom"]);
1922 assert.equal(
1923 yield* git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]),
1924 "upstream/effect-atom",
1925 );
1926 yield* writeTextFile(cwd, "alias.txt", "alias\n");
1927 yield* driver.prepareCommitContext(cwd);
1928 yield* driver.commit(cwd, "Add alias update", "");
1929
1930 const pushed = yield* driver.pushCurrentBranch(cwd, null);
1931
1932 assert.deepInclude(pushed, {
1933 status: "pushed",
1934 branch: "upstream/effect-atom",
1935 upstreamBranch: "my-org/upstream/effect-atom",
1936 setUpstream: false,
1937 });
1938 assert.equal(
1939 yield* git(remote, ["log", "-1", "--pretty=%s", "effect-atom"]),
1940 "Add alias update",
1941 );
1942 }),
1943 );
1944
18401945 it.effect("pushes to the requested remote instead of the primary remote", () =>
18411946 Effect.gen(function* () {
18421947 const cwd = yield* makeTmpDir();
apps/server/src/vcs/GitVcsDriverCore.ts+49
......@@ -2007,6 +2007,55 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
20072007 Effect.orElseSucceed(() => null),
20082008 );
20092009 if (currentUpstream) {
2010 // A branch tracking a differently named ref was cut from it, the way
2011 // `git checkout -b feature origin/dev` and our own worktree flow leave
2012 // it. That upstream is the branch's base, not its publish target, and
2013 // pushing HEAD onto it would write feature commits to a shared branch
2014 // (bare `git push` refuses this under push.default=simple). The one
2015 // same-repo tracking setup that legitimately differs is a git-mangled
2016 // alias such as local `upstream/effect-atom` for my-org/upstream's
2017 // `effect-atom`: the branch name ends in the upstream head while the
2018 // upstream ref ends in the branch name.
2019 const isAliasOfUpstreamHead =
2020 branch === currentUpstream.branchName ||
2021 (branch.endsWith(`/${currentUpstream.branchName}`) &&
2022 currentUpstream.upstreamRef.endsWith(`/${branch}`));
2023 if (!isAliasOfUpstreamHead) {
2024 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe(
2025 Effect.orElseSucceed(() => null),
2026 );
2027 const remoteName = publishRemoteName ?? currentUpstream.remoteName;
2028 const publishBranch = yield* resolvePublishBranchName(cwd, branch);
2029 // `-u` retargets the upstream to the published branch, so keep the
2030 // base recorded first; base resolution reads gh-merge-base before the
2031 // upstream ref.
2032 const configuredMergeBase = yield* runGitStdout(
2033 "GitVcsDriver.pushCurrentBranch.readMergeBase",
2034 cwd,
2035 ["config", "--get", `branch.${branch}.gh-merge-base`],
2036 true,
2037 ).pipe(Effect.map((stdout) => stdout.trim()));
2038 if (configuredMergeBase.length === 0) {
2039 yield* runGit("GitVcsDriver.pushCurrentBranch.recordMergeBase", cwd, [
2040 "config",
2041 `branch.${branch}.gh-merge-base`,
2042 currentUpstream.branchName,
2043 ]);
2044 }
2045 yield* runGit(
2046 "GitVcsDriver.pushCurrentBranch.pushOwnBranch",
2047 cwd,
2048 ["push", "-u", remoteName, `HEAD:refs/heads/${publishBranch}`],
2049 { timeoutMs: null },
2050 );
2051 return {
2052 status: "pushed" as const,
2053 branch,
2054 upstreamBranch: `${remoteName}/${publishBranch}`,
2055 setUpstream: true,
2056 };
2057 }
2058
20102059 yield* runGit(
20112060 "GitVcsDriver.pushCurrentBranch.pushUpstream",
20122061 cwd,