mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
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:
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user