fix(worktree): detect case and Unicode aliases of checked-out branches (#18462)

On case-insensitive or normalization-insensitive filesystems (macOS), Git
resolves `feature/TEST` or an NFD-composed branch name to the stored ref of
an already checked-out branch. The busy-branch check only compared exact
names, so creating a worktree for such an alias passed the check and then
failed in git. Fold case/Unicode for the comparison and only treat the alias
as busy when the filesystem does not store both spellings as distinct refs.

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
dongwook.kim
2026-11-12 04:04:28 -08:00
committed by GitHub
co-authored by Neil
parent c1982f960f
commit e9fa32e08c
2 changed files with 37 additions and 2 deletions
@@ -70,3 +70,35 @@ describe('explicit local branch reuse on the execution host', () => {
expect(worktrees).not.toHaveBeenCalled()
})
})
describe('filesystem aliases of checked-out branches', () => {
const nfcBranch = 'feature/수신'.normalize('NFC')
const nfdBranch = nfcBranch.normalize('NFD')
function mockGit(checkedOutBranch: string, listRef: (ref: string) => string): void {
git.mockImplementation(async (args: string[]) =>
args[0] === 'for-each-ref' ? { stdout: listRef(args[2]) } : { stdout: 'abc123\n' }
)
worktrees.mockResolvedValue([{ path: '/busy', branch: `refs/heads/${checkedOutBranch}` }])
}
it('rejects an NFD name that macOS Git resolves to a checked-out NFC branch (#18447)', async () => {
mockGit(nfcBranch, () => `refs/heads/${nfcBranch}\n`)
await expect(canCheckoutExistingLocalBranch('/repo', nfdBranch, nfdBranch)).resolves.toBe(false)
})
it('rejects a case alias of a checked-out branch on a case-insensitive filesystem', async () => {
mockGit('feature/test', () => '')
await expect(
canCheckoutExistingLocalBranch('/repo', 'feature/TEST', 'feature/TEST')
).resolves.toBe(false)
})
it('allows a distinct alias on filesystems that store both spellings', async () => {
mockGit(nfcBranch, (ref) => `${ref}\n`)
await expect(canCheckoutExistingLocalBranch('/repo', nfdBranch, nfdBranch)).resolves.toBe(true)
})
})
@@ -96,12 +96,15 @@ export async function canCheckoutExistingLocalBranch(
}
const worktrees = await listWorktrees(repoPath, gitOptions)
let busy = worktrees.find((worktree) => normalizeLocalBranchName(worktree.branch) === branchName)
if (explicitReuse && !busy) {
if (!busy) {
// Why: macOS Git resolves case and NFC/NFD aliases to one stored ref; Linux keeps them distinct.
// Explicit reuse already verified that the requested spelling is stored exactly.
for (const worktree of worktrees) {
const checkedOutBranch = normalizeLocalBranchName(worktree.branch)
if (
foldGitRefNameCase(checkedOutBranch) === foldGitRefNameCase(branchName) &&
!(await hasExactBranchRef(checkedOutBranch))
((!explicitReuse && !(await hasExactBranchRef(branchName))) ||
!(await hasExactBranchRef(checkedOutBranch)))
) {
busy = worktree
break