fix(worktree): make generated workspace names unique across repos (#2857)

Auto-generated workspace names walked the marine-creature list in order
and deduped only within the active repo (the default when workspaces are
nested), so the first auto-named worktree in every repo landed on
"Nautilus". That guaranteed identical branch names across repos, which
appear flat and indistinguishable in the sidebar.

The in-order walk also produced "Nautilus-2", "Nautilus-3", ... within a
single repo: the suggester ignores branches left behind by deleted
worktrees, so it kept re-proposing "Nautilus", and the create-time retry
loop suffixed it to dodge the lingering branch. Random selection now
yields a fresh creature each time instead of marching the same name.

- Dedup against worktrees in every repo, not just the active one
- Pick randomly from the unused pool instead of the first list entry
- Lowercase the result to match branch-name convention (fix/seahorse)
- Expand the corpus 260 -> 552 (more marine species plus public-domain
  mythological sea creatures) so random picks rarely repeat

getSuggestedCreatureName drops the now-unused repoId/nestWorkspaces
params; the RNG is injectable for deterministic tests.

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Trevin Chow 2026-05-28 17:41:10 -07:00 committed by GitHub
parent a538431140
commit bf51593706
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 702 additions and 91 deletions

View File

@ -0,0 +1,16 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
function readCreatureNames(path) {
const source = readFileSync(path, 'utf8')
return Array.from(source.matchAll(/^ '([^']+)',?$/gm), (match) => match[1])
}
describe('marine creature corpus mirrors', () => {
it('keeps the mobile mirror in parity with the shared corpus', () => {
const sharedNames = readCreatureNames('src/shared/marine-creatures.ts')
const mobileNames = readCreatureNames('mobile/src/constants/marine-creatures.ts')
expect(mobileNames).toEqual(sharedNames)
})
})

View File

@ -23,20 +23,29 @@ function normalize(name: string): string {
return name.trim().toLowerCase()
}
export function getSuggestedCreatureName(existingPaths: readonly string[]): string {
function pickRandom<T>(items: readonly T[], random: () => number): T {
return items[Math.floor(random() * items.length)]
}
// Why: pick randomly from the unused pool (not the first in list order) so
// fresh worktrees don't all default to "Nautilus" and collide across repos.
export function getSuggestedCreatureName(
existingPaths: readonly string[],
random: () => number = Math.random
): string {
const used = new Set<string>()
for (const p of existingPaths) {
used.add(normalize(pathBasename(p)))
}
for (const candidate of MARINE_CREATURES) {
if (!used.has(normalize(candidate))) return candidate
}
// Lowercased to match branch-name convention (fix/seahorse, not fix/Seahorse).
const available = MARINE_CREATURES.map(normalize).filter((name) => !used.has(name))
if (available.length > 0) return pickRandom(available, random)
let suffix = 2
while (true) {
for (const candidate of MARINE_CREATURES) {
const numbered = `${candidate}-${suffix}`
if (!used.has(normalize(numbered))) return numbered
}
const numbered = MARINE_CREATURES.map((name) => `${normalize(name)}-${suffix}`).filter(
(name) => !used.has(name)
)
if (numbered.length > 0) return pickRandom(numbered, random)
suffix += 1
}
}

View File

@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Curated marine-creature name corpus; one entry per line is the readable format for a flat data list. */
export const MARINE_CREATURES = [
'Nautilus',
'Seahorse',
@ -258,5 +259,299 @@ export const MARINE_CREATURES = [
'Leviathan',
'Wolffish',
'Wreckfish',
'Horseshoe'
'Horseshoe',
'Orca',
'Cachalot',
'Rorqual',
'Grampus',
'Humpback',
'Bowhead',
'Finback',
'Sealion',
'Pinniped',
'Cetacean',
'Mako',
'Porbeagle',
'Dogfish',
'Spurdog',
'Tope',
'Bonnethead',
'Sixgill',
'Sevengill',
'Angelshark',
'Houndshark',
'Megamouth',
'Cookiecutter',
'Bramble',
'Ghostshark',
'Sandtiger',
'Galeocerdo',
'Nursehound',
'Cownose',
'Devilray',
'Eagleray',
'Butterflyray',
'Numbfish',
'Stingaree',
'Mantaray',
'Tuna',
'Albacore',
'Bonito',
'Skipjack',
'Wahoo',
'Kingfish',
'Mackerel',
'Cero',
'Dorado',
'Mahimahi',
'Escolar',
'Pomfret',
'Butterfish',
'Cod',
'Haddock',
'Pollock',
'Whiting',
'Hake',
'Ling',
'Cusk',
'Burbot',
'Saithe',
'Sablefish',
'Lingcod',
'Greenling',
'Rockling',
'Halibut',
'Flounder',
'Sole',
'Plaice',
'Turbot',
'Brill',
'Dab',
'Fluke',
'Megrim',
'Sanddab',
'Herring',
'Sardine',
'Anchovy',
'Sprat',
'Pilchard',
'Menhaden',
'Shad',
'Alewife',
'Salmon',
'Trout',
'Char',
'Grayling',
'Steelhead',
'Kokanee',
'Chinook',
'Coho',
'Sockeye',
'Taimen',
'Huchen',
'Cisco',
'Inconnu',
'Vendace',
'Whitefish',
'Perch',
'Bass',
'Snapper',
'Grouper',
'Seabass',
'Tilefish',
'Bream',
'Porgy',
'Sheepshead',
'Pinfish',
'Scup',
'Tautog',
'Sander',
'Zander',
'Walleye',
'Sauger',
'Ruffe',
'Comber',
'Hind',
'Coney',
'Graysby',
'Margate',
'Pompano',
'Amberjack',
'Yellowtail',
'Scad',
'Runner',
'Mullet',
'Goatfish',
'Threadfin',
'Snook',
'Barramundi',
'Milkfish',
'Mojarra',
'Weakfish',
'Corbina',
'Queenfish',
'Kahawai',
'Tilapia',
'Cunner',
'Tuskfish',
'Razorfish',
'Tang',
'Porcupinefish',
'Burrfish',
'Eel',
'Wolfeel',
'Gardeneel',
'Ribboneel',
'Cuskeel',
'Catfish',
'Cory',
'Ratfish',
'Elephantfish',
'Goosefish',
'Monkfish',
'Sargassum',
'Coffinfish',
'Seadevil',
'Scorpionfish',
'Rockfish',
'Cabezon',
'Rosefish',
'Redfish',
'Bocaccio',
'Thornyhead',
'Gurnard',
'Searobin',
'Fangtooth',
'Bristlemouth',
'Barreleye',
'Spookfish',
'Telescopefish',
'Lancetfish',
'Tripodfish',
'Spiderfish',
'Daggertooth',
'Pearleye',
'Ridgehead',
'Snaggletooth',
'Sturgeon',
'Sterlet',
'Kaluga',
'Cockle',
'Mussel',
'Clam',
'Oyster',
'Scallop',
'Abalone',
'Periwinkle',
'Cerith',
'Turban',
'Tellin',
'Geoduck',
'Quahog',
'Piddock',
'Shipworm',
'Lobster',
'Crayfish',
'Shrimp',
'Prawn',
'Langostino',
'Langouste',
'Yabby',
'Marron',
'Dungeness',
'Seafan',
'Staghorn',
'Elkhorn',
'Hydroid',
'Zoanthid',
'Gorgonian',
'Seanettle',
'Moonjelly',
'Sanddollar',
'Brittlestar',
'Basketstar',
'Sunstar',
'Seacucumber',
'Seabiscuit',
'Hearturchin',
'Bristleworm',
'Featherduster',
'Lugworm',
'Sandworm',
'Palolo',
'Arrowworm',
'Acornworm',
'Flatworm',
'Ribbonworm',
'Booby',
'Frigatebird',
'Tropicbird',
'Noddy',
'Kittiwake',
'Dovekie',
'Shag',
'Anhinga',
'Skimmer',
'Jaeger',
'Oystercatcher',
'Eider',
'Scoter',
'Merganser',
'Brant',
'Ridley',
'Flatback',
'Terrapin',
'Seasnake',
'Diatom',
'Plankton',
'Larvacean',
'Doliolid',
'Kelp',
'Seagrass',
'Eelgrass',
'Dulse',
'Bladderwrack',
'Mola',
// Mythological sea & water creatures from public-domain folklore — joining
// Kraken, Leviathan, Siren, Triton, Hydra, Medusa, Nereid, and Selkie above.
'Scylla',
'Charybdis',
'Cetus',
'Proteus',
'Glaucus',
'Hippocamp',
'Jormungandr',
'Hafgufa',
'Kelpie',
'Merrow',
'Nuckelavee',
'Afanc',
'Rusalka',
'Vodyanoy',
'Umibozu',
'Isonade',
'Ningyo',
'Mizuchi',
'Naga',
'Makara',
'Bunyip',
'Taniwha',
'Marakihau',
'Lusca',
'Undine',
'Nixie',
'Melusine',
'Ondine',
'Tiamat',
'Dagon',
'Aspidochelone',
'Capricorn',
'Merfolk',
'Merman',
'Mermaid',
'Timingila',
'Bakekujira',
'Jiaolong',
'Encantado',
'Hraesvelg'
] as const

View File

@ -7,97 +7,79 @@ import {
shouldApplySuggestedName
} from './worktree-name-suggestions'
// Always selects the first element of the unused pool, so assertions are exact.
const pickFirst = () => 0
// Suggestions are lowercased (branch-name convention), so expectations are too.
const lower = (index: number) => MARINE_CREATURES[index].toLowerCase()
describe('getSuggestedCreatureName', () => {
it('returns the first creature name when no repo is selected', () => {
expect(getSuggestedCreatureName('', {}, false)).toBe(MARINE_CREATURES[0])
it('picks the first unused name when the RNG selects index 0', () => {
expect(getSuggestedCreatureName({}, pickFirst)).toBe(lower(0))
})
it('skips names already used in the selected repo', () => {
it('dedupes against worktrees in EVERY repo, not just one', () => {
expect(
getSuggestedCreatureName(
'repo-1',
{
'repo-1': [{ path: '/tmp/worktrees/Nautilus' }, { path: '/tmp/worktrees/Seahorse' }]
'repo-1': [{ path: '/tmp/worktrees/Nautilus' }],
'repo-2': [{ path: '/tmp/worktrees/Seahorse' }]
},
true
pickFirst
)
).toBe('Starfish')
).toBe(lower(2))
})
it('checks all repos when nestWorkspaces is false', () => {
it('never reuses a name already used in another repo', () => {
// Regression guard: the old per-repo scoping would have returned Nautilus
// here because the active repo had no worktrees of its own.
expect(
getSuggestedCreatureName(
'repo-1',
{
'repo-1': [],
'repo-2': [{ path: '/tmp/worktrees/Nautilus' }]
'repo-2': [{ path: `/tmp/worktrees/${MARINE_CREATURES[0]}` }]
},
false
pickFirst
)
).toBe('Seahorse')
).toBe(lower(1))
})
it('only checks the selected repo when nestWorkspaces is true', () => {
expect(
getSuggestedCreatureName(
'repo-1',
{
'repo-1': [],
'repo-2': [{ path: '/tmp/worktrees/Nautilus' }]
},
true
)
).toBe('Nautilus')
it('selects randomly from the unused pool', () => {
// random() = i/N ⇒ pickRandom returns the pool's i-th entry.
const pickIndex = (index: number, poolSize: number) => () => index / poolSize
expect(getSuggestedCreatureName({}, pickIndex(2, MARINE_CREATURES.length))).toBe(lower(2))
expect(getSuggestedCreatureName({}, pickIndex(5, MARINE_CREATURES.length))).toBe(lower(5))
})
it('falls back to suffixed variants after the base list is exhausted', () => {
const usedWorktrees = MARINE_CREATURES.map((name) => ({ path: `/tmp/worktrees/${name}` }))
expect(
getSuggestedCreatureName(
'repo-1',
{
'repo-1': usedWorktrees
},
true
)
).toBe(`${MARINE_CREATURES[0]}-2`)
expect(getSuggestedCreatureName({ 'repo-1': usedWorktrees }, pickFirst)).toBe(`${lower(0)}-2`)
})
it('treats used names case-insensitively', () => {
expect(
getSuggestedCreatureName(
'repo-1',
{
'repo-1': [{ path: '/tmp/worktrees/nAuTiLuS' }]
},
true
)
).toBe('Seahorse')
getSuggestedCreatureName({ 'repo-1': [{ path: '/tmp/worktrees/nAuTiLuS' }] }, pickFirst)
).toBe(lower(1))
})
it('handles Windows-style worktree paths when deriving used basenames', () => {
expect(
getSuggestedCreatureName(
'repo-1',
{
'repo-1': [{ path: 'C:\\worktrees\\Nautilus' }]
},
true
)
).toBe('Seahorse')
getSuggestedCreatureName({ 'repo-1': [{ path: 'C:\\worktrees\\Nautilus' }] }, pickFirst)
).toBe(lower(1))
})
it('handles stored worktree paths with trailing separators', () => {
expect(
getSuggestedCreatureName(
'repo-1',
{
'repo-1': [{ path: 'C:\\worktrees\\Nautilus\\\\' }, { path: '/tmp/worktrees/Seahorse///' }]
'repo-1': [
{ path: 'C:\\worktrees\\Nautilus\\\\' },
{ path: '/tmp/worktrees/Seahorse///' }
]
},
true
pickFirst
)
).toBe('Starfish')
).toBe(lower(2))
})
})
@ -118,7 +100,7 @@ describe('shouldApplySuggestedName', () => {
describe('MARINE_CREATURES', () => {
it('is non-empty and unique after normalization and sanitization', () => {
expect(MARINE_CREATURES.length).toBeGreaterThanOrEqual(260)
expect(MARINE_CREATURES.length).toBeGreaterThanOrEqual(500)
const normalizedNames = MARINE_CREATURES.map(normalizeSuggestedName)
const sanitizedNames = MARINE_CREATURES.map((name) => sanitizeWorktreeName(name))
@ -136,7 +118,13 @@ describe('MARINE_CREATURES', () => {
'Hogchoker',
'Hogsucker',
'Mudsucker',
'Hardhead'
'Hardhead',
// Real marine organisms, but the bare word reads as a fruit, flower, or
// land insect rather than something from the sea.
'Olive',
'Tulip',
'Cone',
'Mantis'
]
for (const disallowedName of disallowedNames) {

View File

@ -5,39 +5,46 @@ type WorktreePathLike = {
path: string
}
export function getSuggestedCreatureName(
repoId: string,
worktreesByRepo: Record<string, WorktreePathLike[]>,
nestWorkspaces: boolean
): string {
if (!repoId) {
return MARINE_CREATURES[0]
}
// Why: dedup across every repo, not just the active one — branch names appear
// flat in the sidebar, so per-repo scoping let two repos collide on one name.
function collectUsedNames(worktreesByRepo: Record<string, WorktreePathLike[]>): Set<string> {
const usedNames = new Set<string>()
const relevantWorktrees = nestWorkspaces
? [worktreesByRepo[repoId] ?? []]
: Object.values(worktreesByRepo)
for (const worktrees of relevantWorktrees) {
for (const worktrees of Object.values(worktreesByRepo)) {
for (const worktree of worktrees) {
usedNames.add(normalizeSuggestedName(basename(worktree.path)))
}
}
return usedNames
}
for (const candidate of MARINE_CREATURES) {
if (!usedNames.has(normalizeSuggestedName(candidate))) {
return candidate
}
function pickRandom<T>(items: readonly T[], random: () => number): T {
return items[Math.floor(random() * items.length)]
}
export function getSuggestedCreatureName(
worktreesByRepo: Record<string, WorktreePathLike[]>,
random: () => number = Math.random
): string {
const usedNames = collectUsedNames(worktreesByRepo)
// Why: names are lowercased (branch names are conventionally lowercase, e.g.
// fix/seahorse), and a random pick keeps fresh worktrees from all starting at
// the same creature and marching down the list in lockstep.
const available = MARINE_CREATURES.map(normalizeSuggestedName).filter(
(name) => !usedNames.has(name)
)
if (available.length > 0) {
return pickRandom(available, random)
}
// Every base name is taken — fall back to numbered variants.
let suffix = 2
while (true) {
for (const candidate of MARINE_CREATURES) {
const numberedCandidate = `${candidate}-${suffix}`
if (!usedNames.has(normalizeSuggestedName(numberedCandidate))) {
return numberedCandidate
}
const numbered = MARINE_CREATURES.map(
(name) => `${normalizeSuggestedName(name)}-${suffix}`
).filter((name) => !usedNames.has(name))
if (numbered.length > 0) {
return pickRandom(numbered, random)
}
suffix += 1
}

View File

@ -654,12 +654,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const shouldWaitForSetupCheck = Boolean(selectedRepo) && selectedRepoIsGit && isSetupCheckPending
// Why: when the user leaves the workspace name blank and provides no other
// seed source (prompt, linked issue/PR), pick a repo-scoped unique marine
// seed source (prompt, linked issue/PR), pick a globally-unique marine
// creature name so the workspace gets a distinct, readable identifier
// instead of colliding on a literal "workspace" default.
// instead of colliding on a literal "workspace" default — or on the same
// creature already used in another repo.
const fallbackCreatureName = useMemo(
() => getSuggestedCreatureName(repoId, worktreesByRepo, settings?.nestWorkspaces ?? true),
[repoId, worktreesByRepo, settings?.nestWorkspaces]
() => getSuggestedCreatureName(worktreesByRepo),
[worktreesByRepo]
)
const workspaceSeedName = useMemo(
() =>

View File

@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Curated marine-creature name corpus; one entry per line is the readable format for a flat data list. */
// Why: the auto-generated workspace name pool lives in shared (not renderer)
// so the main process can recognize an Orca-generated branch name when deciding
// whether auto-rename-from-work is allowed to overwrite it.
@ -261,5 +262,299 @@ export const MARINE_CREATURES = [
'Leviathan',
'Wolffish',
'Wreckfish',
'Horseshoe'
'Horseshoe',
'Orca',
'Cachalot',
'Rorqual',
'Grampus',
'Humpback',
'Bowhead',
'Finback',
'Sealion',
'Pinniped',
'Cetacean',
'Mako',
'Porbeagle',
'Dogfish',
'Spurdog',
'Tope',
'Bonnethead',
'Sixgill',
'Sevengill',
'Angelshark',
'Houndshark',
'Megamouth',
'Cookiecutter',
'Bramble',
'Ghostshark',
'Sandtiger',
'Galeocerdo',
'Nursehound',
'Cownose',
'Devilray',
'Eagleray',
'Butterflyray',
'Numbfish',
'Stingaree',
'Mantaray',
'Tuna',
'Albacore',
'Bonito',
'Skipjack',
'Wahoo',
'Kingfish',
'Mackerel',
'Cero',
'Dorado',
'Mahimahi',
'Escolar',
'Pomfret',
'Butterfish',
'Cod',
'Haddock',
'Pollock',
'Whiting',
'Hake',
'Ling',
'Cusk',
'Burbot',
'Saithe',
'Sablefish',
'Lingcod',
'Greenling',
'Rockling',
'Halibut',
'Flounder',
'Sole',
'Plaice',
'Turbot',
'Brill',
'Dab',
'Fluke',
'Megrim',
'Sanddab',
'Herring',
'Sardine',
'Anchovy',
'Sprat',
'Pilchard',
'Menhaden',
'Shad',
'Alewife',
'Salmon',
'Trout',
'Char',
'Grayling',
'Steelhead',
'Kokanee',
'Chinook',
'Coho',
'Sockeye',
'Taimen',
'Huchen',
'Cisco',
'Inconnu',
'Vendace',
'Whitefish',
'Perch',
'Bass',
'Snapper',
'Grouper',
'Seabass',
'Tilefish',
'Bream',
'Porgy',
'Sheepshead',
'Pinfish',
'Scup',
'Tautog',
'Sander',
'Zander',
'Walleye',
'Sauger',
'Ruffe',
'Comber',
'Hind',
'Coney',
'Graysby',
'Margate',
'Pompano',
'Amberjack',
'Yellowtail',
'Scad',
'Runner',
'Mullet',
'Goatfish',
'Threadfin',
'Snook',
'Barramundi',
'Milkfish',
'Mojarra',
'Weakfish',
'Corbina',
'Queenfish',
'Kahawai',
'Tilapia',
'Cunner',
'Tuskfish',
'Razorfish',
'Tang',
'Porcupinefish',
'Burrfish',
'Eel',
'Wolfeel',
'Gardeneel',
'Ribboneel',
'Cuskeel',
'Catfish',
'Cory',
'Ratfish',
'Elephantfish',
'Goosefish',
'Monkfish',
'Sargassum',
'Coffinfish',
'Seadevil',
'Scorpionfish',
'Rockfish',
'Cabezon',
'Rosefish',
'Redfish',
'Bocaccio',
'Thornyhead',
'Gurnard',
'Searobin',
'Fangtooth',
'Bristlemouth',
'Barreleye',
'Spookfish',
'Telescopefish',
'Lancetfish',
'Tripodfish',
'Spiderfish',
'Daggertooth',
'Pearleye',
'Ridgehead',
'Snaggletooth',
'Sturgeon',
'Sterlet',
'Kaluga',
'Cockle',
'Mussel',
'Clam',
'Oyster',
'Scallop',
'Abalone',
'Periwinkle',
'Cerith',
'Turban',
'Tellin',
'Geoduck',
'Quahog',
'Piddock',
'Shipworm',
'Lobster',
'Crayfish',
'Shrimp',
'Prawn',
'Langostino',
'Langouste',
'Yabby',
'Marron',
'Dungeness',
'Seafan',
'Staghorn',
'Elkhorn',
'Hydroid',
'Zoanthid',
'Gorgonian',
'Seanettle',
'Moonjelly',
'Sanddollar',
'Brittlestar',
'Basketstar',
'Sunstar',
'Seacucumber',
'Seabiscuit',
'Hearturchin',
'Bristleworm',
'Featherduster',
'Lugworm',
'Sandworm',
'Palolo',
'Arrowworm',
'Acornworm',
'Flatworm',
'Ribbonworm',
'Booby',
'Frigatebird',
'Tropicbird',
'Noddy',
'Kittiwake',
'Dovekie',
'Shag',
'Anhinga',
'Skimmer',
'Jaeger',
'Oystercatcher',
'Eider',
'Scoter',
'Merganser',
'Brant',
'Ridley',
'Flatback',
'Terrapin',
'Seasnake',
'Diatom',
'Plankton',
'Larvacean',
'Doliolid',
'Kelp',
'Seagrass',
'Eelgrass',
'Dulse',
'Bladderwrack',
'Mola',
// Mythological sea & water creatures from public-domain folklore — joining
// Kraken, Leviathan, Siren, Triton, Hydra, Medusa, Nereid, and Selkie above.
'Scylla',
'Charybdis',
'Cetus',
'Proteus',
'Glaucus',
'Hippocamp',
'Jormungandr',
'Hafgufa',
'Kelpie',
'Merrow',
'Nuckelavee',
'Afanc',
'Rusalka',
'Vodyanoy',
'Umibozu',
'Isonade',
'Ningyo',
'Mizuchi',
'Naga',
'Makara',
'Bunyip',
'Taniwha',
'Marakihau',
'Lusca',
'Undine',
'Nixie',
'Melusine',
'Ondine',
'Tiamat',
'Dagon',
'Aspidochelone',
'Capricorn',
'Merfolk',
'Merman',
'Mermaid',
'Timingila',
'Bakekujira',
'Jiaolong',
'Encantado',
'Hraesvelg'
] as const