mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user