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 <new> <base>`,
which lets git decide the upstream. Git sets `origin/<base>` 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 <branch>` under
`push.default=tracking` pushes to `main`, and Paseo's own push resolves
`branch.<name>.merge` and pushes `HEAD:refs/heads/main`.

Pass `--no-track`, the same promise the worktree path already makes with
`git worktree add -b <branch> --no-track <base>`.

* 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>
This commit is contained in:
paseo-bot[bot]
2026-09-23 22:03:55 +02:00
committed by GitHub
parent 2c7b38bc7e
commit 48384cafdf
2 changed files with 77 additions and 1 deletions
@@ -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.<name>.merge. A branch Paseo creates must carry no upstream, exactly as the worktree
// path already guarantees with `git worktree add -b <branch> --no-track <base>`.
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", () => {
@@ -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/<base>` 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 <branch> --no-track <base>`.
await runGitCommand(["checkout", "-b", newBranchName, "--no-track", baseBranch], {
cwd,
timeout: 120_000,
});