From 61366477919502bda6d0832ece9e2adff761d755 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:37:37 +0200 Subject: [PATCH] fix(worktrees): avoid branch namespace conflicts in fork checkouts Fork owner prefixes can name an existing branch as a parent, which Git rejects even when the full proposed name is unused. Allocate names against parent and child refs, suffixing the conflicting segment while preserving existing branches and fetched commits. --- packages/server/src/utils/worktree.test.ts | 58 ++++++++++++++++++++++ packages/server/src/utils/worktree.ts | 27 ++++++++-- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 479d4cfeab..bc14b0257b 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -15,6 +15,7 @@ import { mkdirSync, rmSync, existsSync, + readFileSync, realpathSync, symlinkSync, writeFileSync, @@ -93,6 +94,63 @@ describe("paseo worktree manager", () => { rmSync(tempDir, { recursive: true, force: true }); }); + it.each([ + { existing: [], preferred: "main/patch-1", expected: "main-1/patch-1" }, + { existing: ["main-1"], preferred: "main/patch-1", expected: "main-2/patch-1" }, + { + existing: ["main-1/patch-1"], + preferred: "main/patch-1", + expected: "main-1/patch-1-2", + }, + { + existing: ["bob/patch-1/child"], + preferred: "bob/patch-1", + expected: "bob/patch-1-1", + }, + ])( + "checks out a fork when $preferred conflicts with existing branch paths", + async ({ existing, preferred, expected }) => { + const git = (...args: string[]) => + execFileSync("git", args, { cwd: repoDir, encoding: "utf8", stdio: "pipe" }).trim(); + const baseHead = git("rev-parse", "main"); + for (const branch of existing) git("branch", branch); + git("checkout", "-b", "fixture-pr"); + writeFileSync(join(repoDir, "file.txt"), "from-pr\n"); + git("add", "file.txt"); + git("-c", "commit.gpgsign=false", "commit", "-m", "fork patch"); + const prHead = git("rev-parse", "HEAD"); + git("checkout", "main"); + const remote = join(tempDir, "origin.git"); + git("clone", "--bare", repoDir, remote); + git("remote", "add", "origin", remote); + git(`--git-dir=${remote}`, "update-ref", "refs/pull/1/head", prHead); + git("pack-refs", "--all"); + + const result = await createWorktreePrimitive({ + cwd: repoDir, + paseoHome, + worktreeSlug: "fork-review", + runSetup: false, + source: { + kind: "checkout-change-request", + forge: "gitea", + changeRequestNumber: 1, + headRef: "patch-1", + localBranchName: preferred, + baseRefName: "main", + checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/1/head" }], + }, + }); + + expect(result.branchName).toBe(expected); + expect(git("rev-parse", result.branchName)).toBe(prHead); + expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-pr\n"); + for (const branch of ["main", ...existing]) { + expect(git("rev-parse", branch)).toBe(baseHead); + } + }, + ); + it("treats a worktree as paseo-owned even when its .git admin is missing", async () => { const created = await createLegacyWorktreeForTest({ branchName: "orphan-admin-branch", diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index f454a7225a..39b64d8137 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1778,13 +1778,30 @@ async function localBranchExists(cwd: string, branchName: string): Promise { - let newBranchName = candidateBranch; + const { stdout } = await runGitCommand( + ["for-each-ref", "--format=%(refname:strip=2)", "refs/heads/"], + { cwd, envOverlay: READ_ONLY_GIT_ENV }, + ); + const existingBranches = new Set(stdout.trim().split("\n").filter(Boolean)); + const candidateSegments = candidateBranch.split("/"); + const segments = [...candidateSegments]; let suffix = 1; - while (await localBranchExists(cwd, newBranchName)) { - newBranchName = `${candidateBranch}-${suffix}`; - suffix++; + while (true) { + const branchName = segments.join("/"); + const parentConflict = segments.findIndex((_, index) => + existingBranches.has(segments.slice(0, index + 1).join("/")), + ); + const childConflict = [...existingBranches].some((branch) => + branch.startsWith(`${branchName}/`), + ); + if (parentConflict === -1 && !childConflict) { + return branchName; + } + // Git refs form a namespace: a branch cannot also be another branch's + // parent directory. Rename the conflicting segment, preserving the rest. + const conflictIndex = parentConflict === -1 ? segments.length - 1 : parentConflict; + segments[conflictIndex] = `${candidateSegments[conflictIndex]}-${suffix++}`; } - return newBranchName; } async function isBranchCheckedOut(cwd: string, branchName: string): Promise {