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.
This commit is contained in:
paseo-bot[bot]
2026-10-13 22:37:37 +02:00
parent 1947acf276
commit 6136647791
2 changed files with 80 additions and 5 deletions
@@ -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",
+22 -5
View File
@@ -1778,13 +1778,30 @@ async function localBranchExists(cwd: string, branchName: string): Promise<boole
}
async function resolveUniqueLocalBranchName(cwd: string, candidateBranch: string): Promise<string> {
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<boolean> {