From 48384cafdf68dcbb4ffde9bfb1c78e9d88e8db9b Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:03:55 +0200 Subject: [PATCH] Create new branches without an upstream so the first push cannot land on main (#5249) * fix(server): create new branches without an upstream A new branch Paseo creates in place ran `git checkout -b `, which lets git decide the upstream. Git sets `origin/` whenever the base is a remote-tracking ref (what the default-branch resolver returns when the local default branch is absent) or when `branch.autoSetupMerge` is `inherit`. Every later push reads that upstream, so the work lands on the default branch: `git push -u origin ` under `push.default=tracking` pushes to `main`, and Paseo's own push resolves `branch..merge` and pushes `HEAD:refs/heads/main`. Pass `--no-track`, the same promise the worktree path already makes with `git worktree add -b --no-track `. * test(server): read the upstream with for-each-ref so a git failure cannot read as absent * test(server): use a function declaration for the clone fixture git runner --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../git-mutation/git-mutation-service.test.ts | 71 +++++++++++++++++++ .../git-mutation/git-mutation-service.ts | 7 +- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/session/git-mutation/git-mutation-service.test.ts b/packages/server/src/server/session/git-mutation/git-mutation-service.test.ts index 1910722765..0a16cd2c8d 100644 --- a/packages/server/src/server/session/git-mutation/git-mutation-service.test.ts +++ b/packages/server/src/server/session/git-mutation/git-mutation-service.test.ts @@ -85,6 +85,43 @@ function headBranch(dir: string): string { return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir }).toString().trim(); } +// A clone whose local branch tracks origin/main. Upstream inheritance only shows up +// against a real remote, so these fixtures cross the git boundary like the happy paths above. +function initClonedRepo(): string { + const root = realpathSync(mkdtempSync(join(tmpdir(), "git-mutation-clone-"))); + tempRepos.push(root); + const remote = join(root, "remote.git"); + const seed = join(root, "seed"); + const work = join(root, "work"); + function run(cwd: string, ...args: string[]): void { + execFileSync("git", args, { cwd, stdio: "pipe" }); + } + execFileSync("git", ["init", "--bare", "-b", "main", remote], { cwd: root, stdio: "pipe" }); + execFileSync("git", ["clone", "--quiet", remote, seed], { cwd: root, stdio: "pipe" }); + run(seed, "config", "user.email", "test@example.com"); + run(seed, "config", "user.name", "Paseo Test"); + writeFileSync(join(seed, "README.md"), "hello\n"); + run(seed, "add", "-A"); + run(seed, "commit", "-m", "init"); + run(seed, "push", "--quiet", "-u", "origin", "main"); + execFileSync("git", ["clone", "--quiet", remote, work], { cwd: root, stdio: "pipe" }); + run(work, "config", "user.email", "test@example.com"); + run(work, "config", "user.name", "Paseo Test"); + return work; +} + +// Always exits 0 and prints an empty line when the branch has no upstream, so a git or +// process failure surfaces as a throw instead of being read as "no upstream". +function upstreamOf(dir: string, branch: string): string { + return execFileSync( + "git", + ["for-each-ref", "--format=%(upstream:short)", `refs/heads/${branch}`], + { cwd: dir, stdio: ["pipe", "pipe", "pipe"] }, + ) + .toString() + .trim(); +} + afterEach(() => { while (tempRepos.length > 0) { const dir = tempRepos.pop(); @@ -189,6 +226,40 @@ describe("createBranchFromBase", () => { expect(invalidateCalls).toEqual([]); expect(snapshotCalls).toContainEqual({ cwd: dir, force: true, reason: "create-branch" }); }); + + // https://github.com/getpaseo/paseo/issues/5213 — a branch that tracks origin/main sends the + // next push to main: `git push` under push.default=tracking and Paseo's own push both read + // branch..merge. A branch Paseo creates must carry no upstream, exactly as the worktree + // path already guarantees with `git worktree add -b --no-track `. + test("leaves no upstream when the base is a remote-tracking ref (real repo)", async () => { + const dir = initClonedRepo(); + const { service } = buildService({ + resolution: { kind: "remote-only", name: "main", remoteRef: "origin/main" }, + }); + await service.createBranchFromBase({ + cwd: dir, + baseBranch: "origin/main", + newBranchName: "feature-from-remote", + }); + expect(headBranch(dir)).toBe("feature-from-remote"); + expect(upstreamOf(dir, "feature-from-remote")).toBe(""); + }); + + test("leaves no upstream when git is configured to inherit tracking (real repo)", async () => { + const dir = initClonedRepo(); + execFileSync("git", ["config", "branch.autoSetupMerge", "inherit"], { + cwd: dir, + stdio: "pipe", + }); + const { service } = buildService({ resolution: { kind: "local", name: "main" } }); + await service.createBranchFromBase({ + cwd: dir, + baseBranch: "main", + newBranchName: "feature-inherited", + }); + expect(headBranch(dir)).toBe("feature-inherited"); + expect(upstreamOf(dir, "feature-inherited")).toBe(""); + }); }); describe("notifyGitMutation", () => { diff --git a/packages/server/src/server/session/git-mutation/git-mutation-service.ts b/packages/server/src/server/session/git-mutation/git-mutation-service.ts index 7611ba89bc..42c19668b5 100644 --- a/packages/server/src/server/session/git-mutation/git-mutation-service.ts +++ b/packages/server/src/server/session/git-mutation/git-mutation-service.ts @@ -118,7 +118,12 @@ export function createGitMutationService(deps: { } await ensureCleanWorkingTree(cwd); - await runGitCommand(["checkout", "-b", newBranchName, baseBranch], { + // --no-track: a branch we create is not a continuation of its base, so it gets no + // upstream. Without it git hands the new branch `origin/` whenever the base is a + // remote-tracking ref or branch.autoSetupMerge asks for inheritance, and every later + // push then reads that upstream and lands the work on the default branch. The worktree + // path makes the same promise with `git worktree add -b --no-track `. + await runGitCommand(["checkout", "-b", newBranchName, "--no-track", baseBranch], { cwd, timeout: 120_000, });