diff --git a/README.md b/README.md index df4ccb2..010248f 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,12 @@ Remove a worktree and its local branch: gw remove feature/login ``` +Remove only the worktree and keep the local branch: + +```bash +gw remove --worktree feature/login +``` + Remove with a remote branch delete: ```bash @@ -158,7 +164,7 @@ error instead of guessing. gw list gw switch [--ignore-prefix] [branch] gw pr -gw remove|rm [--force] [--remote] [--ignore-prefix] +gw remove|rm [--force] [--remote] [-w|--worktree] [--ignore-prefix] gw clone [--branch-prefix ] gw init [--branch-prefix ] gw shell-init [--shell bash|zsh|fish|nu] @@ -181,13 +187,16 @@ gw help during branch resolution and strip from folder names. - Worktree folders use the existing `flat-tilde` layout: branch slashes become `~`, so `feature/login` becomes `feature~login`. -- `gw switch` first accepts an existing worktree folder name, then checks local - branches, remote branches, and branch-prefix-aware fallbacks. +- `gw switch` treats explicit prefixed names as exact branch names. When a + branch prefix is configured and an unprefixed name is provided, it checks both + the prefixed and raw branch names locally and remotely, prompts if both exist, + and creates the prefixed variant if neither exists. - `gw pr ` requires `gh`, reads the PR head branch and owner, creates or reuses a fork remote, checks out local branch `pr_`, and switches to the `pr_` worktree. - `gw remove` refuses to remove the primary branch and refuses to remove the - current worktree while the shell is inside it. + current worktree while the shell is inside it. Use `--worktree` or `-w` to + remove only the worktree while keeping the local branch. - Relative `core.hooksPath` directories are copied from the primary worktree to newly created worktrees when possible. - The npm package ships a Node CLI plus shell wrappers for `bash`, `zsh`, diff --git a/package.json b/package.json index a103ed6..8f9f8ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@amadeusdemarzi/git-gw", - "version": "1.0.1", + "version": "1.0.2", "description": "Opinionated CLI for branch-like Git worktree workflows", "keywords": [ "cli", diff --git a/src/commands/remove.ts b/src/commands/remove.ts index 5d04d2a..93b6f38 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -20,6 +20,7 @@ interface RemoveOptions { force?: boolean; remote?: boolean; ignorePrefix?: boolean; + worktree?: boolean; } export function registerRemoveCommand(program: Command): void { @@ -30,9 +31,15 @@ export function registerRemoveCommand(program: Command): void { .argument('') .option('--force', 'Force worktree and branch removal') .option('--remote', 'Delete the remote branch after local removal') + .option('-w, --worktree', 'Only remove the worktree') .option('--ignore-prefix', 'Ignore branch prefix resolution') .action( commandAction(async (rawBranch: string, options: RemoveOptions) => { + const worktreeOnly = Boolean(options.worktree); + if (worktreeOnly && options.remote) { + throw new Error('--remote cannot be used with --worktree'); + } + const context = await loadProjectContext(); const resolvedBranch = await resolveBranchWithPrompt( context, @@ -66,6 +73,12 @@ export function registerRemoveCommand(program: Command): void { throw new Error(`branch does not exist: ${resolvedBranch}`); } + if (worktreeOnly && !existingWorktree) { + throw new Error( + `worktree does not exist for branch: ${resolvedBranch}` + ); + } + if (existingWorktree) { const currentDir = await getCurrentDirectory(); if (await isPathInside(currentDir, existingWorktree)) { @@ -81,7 +94,7 @@ export function registerRemoveCommand(program: Command): void { ); } - if (localBranchExists) { + if (localBranchExists && !worktreeOnly) { await deleteBranch( context.anchorRepo, resolvedBranch, diff --git a/src/commands/shared.ts b/src/commands/shared.ts index 4375649..4e87606 100644 --- a/src/commands/shared.ts +++ b/src/commands/shared.ts @@ -5,7 +5,9 @@ import { basename, join, resolve } from 'node:path'; import { resolveBranchName, + resolveSwitchBranchName, type BranchResolutionCandidate, + type SwitchBranchResolutionCandidate, } from '@/core/branches'; import { getBranchPrefix, @@ -19,7 +21,7 @@ import { isPathInside, resolvePath, } from '@/core/project'; -import { listWorktrees } from '@/core/worktrees'; +import { listWorktrees, type WorktreeEntry } from '@/core/worktrees'; export interface ProjectContext { projectRoot: string; @@ -82,6 +84,25 @@ export function isInteractiveTerminal(): boolean { return Boolean(process.stdin.isTTY && process.stdout.isTTY); } +export function getSelectedChoiceValue( + choiceValues: T[], + selected: unknown +): T { + if (typeof selected === 'string') { + const selectedIndex = Number(selected); + if ( + Number.isInteger(selectedIndex) && + String(selectedIndex) === selected && + selectedIndex >= 0 && + selectedIndex < choiceValues.length + ) { + return choiceValues[selectedIndex]; + } + } + + return selected as T; +} + export async function pathExists(path: string): Promise { try { await access(path, constants.F_OK); @@ -150,7 +171,7 @@ async function selectValue( } const initial = choices.findIndex((choice) => choice.initial); - const enquirer = new Enquirer<{ selected: T }>(); + const enquirer = new Enquirer<{ selected: unknown }>(); try { const answer = await enquirer.prompt({ @@ -166,7 +187,10 @@ async function selectValue( })), }); - return answer.selected; + return getSelectedChoiceValue( + choices.map((choice) => choice.value), + answer.selected + ); } catch (error) { if (error == null || error === '') { return null; @@ -180,6 +204,23 @@ function formatBranchCandidate(candidate: BranchResolutionCandidate): string { return `${candidate.branchName} -> ${candidate.worktreePath || 'no worktree'}`; } +function formatSwitchBranchCandidate( + candidate: SwitchBranchResolutionCandidate +): string { + const details: string[] = []; + if (candidate.local) { + details.push('local'); + } + if (candidate.remote) { + details.push('remote'); + } + if (candidate.worktreePath) { + details.push(`worktree: ${candidate.worktreePath}`); + } + + return `${candidate.branchName} -> ${details.join(', ') || 'new branch'}`; +} + export async function resolveBranchWithPrompt( context: ProjectContext, rawBranch: string, @@ -218,6 +259,47 @@ export async function resolveBranchWithPrompt( ); } +export async function resolveSwitchBranchWithPrompt( + context: ProjectContext, + rawBranch: string, + ignorePrefix: boolean, + remoteName: string, + worktrees: WorktreeEntry[] +): Promise { + const resolution = await resolveSwitchBranchName({ + repoPath: context.anchorRepo, + rawBranch, + remoteName, + branchPrefix: getBranchPrefix(context.config), + ignorePrefix, + worktrees, + }); + + if (resolution.status === 'resolved') { + return resolution.candidate; + } + + if (!isInteractiveTerminal()) { + printGwError('multiple matching branches found:'); + for (const candidate of resolution.candidates) { + process.stderr.write(` ${formatSwitchBranchCandidate(candidate)}\n`); + } + + throw new Error( + 'rerun with an explicit prefixed branch name or --ignore-prefix' + ); + } + + return selectValue( + 'Choose branch', + resolution.candidates.map((candidate) => ({ + label: formatSwitchBranchCandidate(candidate), + value: candidate, + initial: candidate.worktreePath != null, + })) + ); +} + export async function findChildRepoRoots( projectRoot: string ): Promise { diff --git a/src/commands/switch.ts b/src/commands/switch.ts index c0462c7..f311c93 100644 --- a/src/commands/switch.ts +++ b/src/commands/switch.ts @@ -8,15 +8,14 @@ import { pathExists, printGwError, requestDirectoryChange, - resolveBranchWithPrompt, + resolveSwitchBranchWithPrompt, } from '@/commands/shared'; -import { encodeBranchPath } from '@/core/branches'; +import { getSwitchTargetFolderName } from '@/core/branches'; import { getBranchPrefix, getRemoteName } from '@/core/config'; import { addWorktree, branchExists, fetchRemoteBranchRef, - remoteBranchExists, setBranchUpstream, syncRelativeHooksPath, } from '@/core/git'; @@ -63,41 +62,34 @@ export function registerSwitchCommand(program: Command): void { } const worktrees = await listWorktrees(context.anchorRepo); - const folderWorktree = findWorktreeForFolderName( - worktrees, - rawBranch - ); - if (folderWorktree) { - await requestDirectoryChange(folderWorktree); - return; - } - - const remoteName = getRemoteName(context.config); - let resolvedBranch: string; - let remoteStartRef: string | undefined; - - if (await branchExists(context.anchorRepo, rawBranch)) { - resolvedBranch = rawBranch; - } else if ( - await remoteBranchExists(context.anchorRepo, remoteName, rawBranch) - ) { - resolvedBranch = rawBranch; - remoteStartRef = `${remoteName}/${rawBranch}`; - } else { - const branchChoice = await resolveBranchWithPrompt( - context, - rawBranch, - ignorePrefix + if (rawBranch.includes('~')) { + const folderWorktree = findWorktreeForFolderName( + worktrees, + rawBranch ); - - if (!branchChoice) { - process.exitCode = 1; + if (folderWorktree) { + await requestDirectoryChange(folderWorktree); return; } + } - resolvedBranch = branchChoice; + const remoteName = getRemoteName(context.config); + const branchPrefix = getBranchPrefix(context.config); + const branchChoice = await resolveSwitchBranchWithPrompt( + context, + rawBranch, + ignorePrefix, + remoteName, + worktrees + ); + + if (!branchChoice) { + process.exitCode = 1; + return; } + const resolvedBranch = branchChoice.branchName; + const existingWorktree = findWorktreeForBranch( worktrees, resolvedBranch @@ -107,8 +99,11 @@ export function registerSwitchCommand(program: Command): void { return; } - const branchPrefix = getBranchPrefix(context.config); - const folderName = encodeBranchPath(resolvedBranch, branchPrefix); + const folderName = getSwitchTargetFolderName( + resolvedBranch, + branchPrefix, + worktrees + ); const targetPath = join(context.projectRoot, folderName); if (await pathExists(targetPath)) { @@ -119,7 +114,8 @@ export function registerSwitchCommand(program: Command): void { await addWorktree(context.anchorRepo, targetPath, { branchName: resolvedBranch, }); - } else if (remoteStartRef) { + } else if (branchChoice.remote) { + const remoteStartRef = `${remoteName}/${resolvedBranch}`; await fetchRemoteBranchRef( context.anchorRepo, remoteName, diff --git a/src/core/branches.ts b/src/core/branches.ts index de4b72b..d0c611c 100644 --- a/src/core/branches.ts +++ b/src/core/branches.ts @@ -1,4 +1,10 @@ -import { branchExists, listLocalBranches } from '@/core/git'; +import { basename } from 'node:path'; + +import { + branchExists, + listLocalBranches, + remoteBranchExists, +} from '@/core/git'; import { findWorktreeForBranch, listWorktrees, @@ -10,6 +16,13 @@ export interface BranchResolutionCandidate { worktreePath?: string; } +export interface SwitchBranchResolutionCandidate { + branchName: string; + local: boolean; + remote: boolean; + worktreePath?: string; +} + export type BranchResolution = | { status: 'resolved'; @@ -29,6 +42,25 @@ export interface ResolveBranchNameOptions { worktrees?: WorktreeEntry[]; } +export interface ResolveSwitchBranchNameOptions { + repoPath: string; + rawBranch: string; + remoteName: string; + branchPrefix?: string; + ignorePrefix?: boolean; + worktrees?: WorktreeEntry[]; +} + +export type SwitchBranchResolution = + | { + status: 'resolved'; + candidate: SwitchBranchResolutionCandidate; + } + | { + status: 'ambiguous'; + candidates: SwitchBranchResolutionCandidate[]; + }; + function uniqueBranches(branchNames: string[]): string[] { return [...new Set(branchNames)]; } @@ -55,6 +87,31 @@ export function encodeBranchPath( return stripBranchPrefix(branchName, branchPrefix).replaceAll('/', '~'); } +export function encodeFullBranchPath(branchName: string): string { + return branchName.replaceAll('/', '~'); +} + +export function getSwitchTargetFolderName( + branchName: string, + branchPrefix: string, + worktrees: WorktreeEntry[] +): string { + const folderName = encodeBranchPath(branchName, branchPrefix); + if (!hasBranchPrefix(branchName, branchPrefix)) { + return folderName; + } + + const collidingWorktree = worktrees.find( + (entry) => + basename(entry.path) === folderName && entry.branchName !== branchName + ); + if (!collidingWorktree) { + return folderName; + } + + return encodeFullBranchPath(branchName); +} + export function findSuffixBranchCandidates( branchNames: string[], rawBranch: string @@ -101,6 +158,100 @@ async function buildAmbiguousCandidates( }); } +function getSwitchBranchCandidates( + rawBranch: string, + branchPrefix: string, + ignorePrefix: boolean +): string[] { + if ( + ignorePrefix || + branchPrefix === '' || + hasBranchPrefix(rawBranch, branchPrefix) + ) { + return [rawBranch]; + } + + return uniqueBranches([branchPrefix + rawBranch, rawBranch]); +} + +async function buildSwitchBranchCandidate( + options: ResolveSwitchBranchNameOptions, + branchName: string, + worktrees: WorktreeEntry[] +): Promise { + const local = await branchExists(options.repoPath, branchName); + const remote = local + ? false + : await remoteBranchExists( + options.repoPath, + options.remoteName, + branchName + ); + const worktreePath = + findWorktreeForBranch(worktrees, branchName) || undefined; + + return { + branchName, + local, + remote, + worktreePath, + }; +} + +function switchCandidateExists( + candidate: SwitchBranchResolutionCandidate +): boolean { + return candidate.local || candidate.remote || candidate.worktreePath != null; +} + +export async function resolveSwitchBranchName( + options: ResolveSwitchBranchNameOptions +): Promise { + const branchPrefix = options.branchPrefix || ''; + const ignorePrefix = options.ignorePrefix || false; + const branchNames = getSwitchBranchCandidates( + options.rawBranch, + branchPrefix, + ignorePrefix + ); + const worktrees = + options.worktrees || (await listWorktrees(options.repoPath)); + const candidates: SwitchBranchResolutionCandidate[] = []; + + for (const branchName of branchNames) { + candidates.push( + await buildSwitchBranchCandidate(options, branchName, worktrees) + ); + } + + if (candidates.length === 1) { + return { + status: 'resolved', + candidate: candidates[0], + }; + } + + const existingCandidates = candidates.filter(switchCandidateExists); + if (existingCandidates.length === 1) { + return { + status: 'resolved', + candidate: existingCandidates[0], + }; + } + + if (existingCandidates.length > 1) { + return { + status: 'ambiguous', + candidates: existingCandidates, + }; + } + + return { + status: 'resolved', + candidate: candidates[0], + }; +} + export async function resolveBranchName( options: ResolveBranchNameOptions ): Promise { diff --git a/src/core/git.ts b/src/core/git.ts index a66a8f7..31229b3 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -90,7 +90,13 @@ export async function remoteBranchExists( } const result = await runGit( - ['ls-remote', '--exit-code', '--heads', remoteName, branchName], + [ + 'ls-remote', + '--exit-code', + '--heads', + remoteName, + `refs/heads/${branchName}`, + ], repoPath ); return result.exitCode === 0; diff --git a/src/tui/switch-picker.ts b/src/tui/switch-picker.ts index d8682f0..2a8f306 100644 --- a/src/tui/switch-picker.ts +++ b/src/tui/switch-picker.ts @@ -1,6 +1,10 @@ import Enquirer from 'enquirer'; -import { formatWorktreeRows, isInteractiveTerminal } from '@/commands/shared'; +import { + formatWorktreeRows, + getSelectedChoiceValue, + isInteractiveTerminal, +} from '@/commands/shared'; interface SwitchPickerChoice { name: string; @@ -124,7 +128,10 @@ export async function pickSwitchWorktreePath( try { const answer = await enquirer.prompt(promptOptions as never); - return answer.selected; + return getSelectedChoiceValue( + choices.map((choice) => choice.value), + answer.selected + ); } catch (error) { if (error == null || error === '') { return null; diff --git a/test/cli/commands.integration.test.ts b/test/cli/commands.integration.test.ts index a87cd9a..870bbc6 100644 --- a/test/cli/commands.integration.test.ts +++ b/test/cli/commands.integration.test.ts @@ -146,6 +146,165 @@ describe('CLI integration', () => { await expect(branchExists(mainPath, 'feature/test')).resolves.toBe(false); }, 60_000); + it('removes only the worktree with --worktree and trailing -w', async () => { + const fixture = await createRemoteFixture(['feature/test'], 'main'); + const workDir = await createWorkDir(fixture.rootDir); + + await runCliWithCwdCapture(['clone', 'demo', fixture.originPath], { + cwd: workDir, + }); + + const mainPath = join(workDir, 'demo', 'main'); + const featurePath = join(workDir, 'demo', 'feature~test'); + + await runCliWithCwdCapture(['switch', 'feature/test'], { cwd: mainPath }); + + const longResult = await runCli(['remove', '--worktree', 'feature/test'], { + cwd: mainPath, + }); + expect(longResult.exitCode).toBe(0); + expect(await pathExists(featurePath)).toBe(false); + await expect(branchExists(mainPath, 'feature/test')).resolves.toBe(true); + + await runCliWithCwdCapture(['switch', 'feature/test'], { cwd: mainPath }); + + const shortResult = await runCli(['remove', 'feature/test', '-w'], { + cwd: mainPath, + }); + expect(shortResult.exitCode).toBe(0); + expect(await pathExists(featurePath)).toBe(false); + await expect(branchExists(mainPath, 'feature/test')).resolves.toBe(true); + }, 60_000); + + it('rejects --worktree when no matching worktree exists', async () => { + const fixture = await createRemoteFixture(['feature/test'], 'main'); + const workDir = await createWorkDir(fixture.rootDir); + + await runCliWithCwdCapture(['clone', 'demo', fixture.originPath], { + cwd: workDir, + }); + + const mainPath = join(workDir, 'demo', 'main'); + await runGit(['branch', 'feature/test', 'origin/feature/test'], { + cwd: mainPath, + }); + + const result = await runCli(['remove', '--worktree', 'feature/test'], { + cwd: mainPath, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'worktree does not exist for branch: feature/test' + ); + }, 60_000); + + it('rejects --remote with --worktree', async () => { + const fixture = await createRemoteFixture([], 'main'); + const workDir = await createWorkDir(fixture.rootDir); + + await runCliWithCwdCapture(['clone', 'demo', fixture.originPath], { + cwd: workDir, + }); + + const result = await runCli( + ['remove', '--remote', '--worktree', 'feature/test'], + { cwd: join(workDir, 'demo', 'main') } + ); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('--remote cannot be used with --worktree'); + }, 60_000); + + it('switches to prefixed remote branches by unprefixed name', async () => { + const branchName = 'amadeus/diffs-improved-line-selection'; + const fixture = await createRemoteFixture([branchName], 'main'); + const workDir = await createWorkDir(fixture.rootDir); + + await runCliWithCwdCapture( + ['clone', '--branch-prefix', 'amadeus/', 'demo', fixture.originPath], + { cwd: workDir } + ); + + const mainPath = join(workDir, 'demo', 'main'); + const targetPath = join(workDir, 'demo', 'diffs-improved-line-selection'); + const switchResult = await runCliWithCwdCapture( + ['switch', 'diffs-improved-line-selection'], + { cwd: mainPath } + ); + + expect(switchResult.result.exitCode).toBe(0); + expect(switchResult.targetPath).toBe(await canonicalPath(targetPath)); + expect(await pathExists(targetPath)).toBe(true); + + const currentBranch = await runGit(['branch', '--show-current'], { + cwd: targetPath, + }); + expect(currentBranch.stdout).toBe(branchName); + + const upstream = await runGit( + ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], + { cwd: targetPath } + ); + expect(upstream.stdout).toBe(`origin/${branchName}`); + }, 60_000); + + it('uses a prefixed folder name when the stripped folder has another branch', async () => { + const prefixedBranch = 'amadeus/topic'; + const fixture = await createRemoteFixture([prefixedBranch], 'main'); + const workDir = await createWorkDir(fixture.rootDir); + + await runCliWithCwdCapture( + ['clone', '--branch-prefix', 'amadeus/', 'demo', fixture.originPath], + { cwd: workDir } + ); + + const mainPath = join(workDir, 'demo', 'main'); + const rawPath = join(workDir, 'demo', 'topic'); + const prefixedPath = join(workDir, 'demo', 'amadeus~topic'); + + const rawSwitch = await runCliWithCwdCapture( + ['switch', '--ignore-prefix', 'topic'], + { cwd: mainPath } + ); + expect(rawSwitch.result.exitCode).toBe(0); + expect(rawSwitch.targetPath).toBe(await canonicalPath(rawPath)); + + const prefixedSwitch = await runCliWithCwdCapture( + ['switch', prefixedBranch], + { cwd: mainPath } + ); + expect(prefixedSwitch.result.exitCode).toBe(0); + expect(prefixedSwitch.targetPath).toBe(await canonicalPath(prefixedPath)); + + const currentBranch = await runGit(['branch', '--show-current'], { + cwd: prefixedPath, + }); + expect(currentBranch.stdout).toBe(prefixedBranch); + }, 60_000); + + it('reports ambiguous prefixed and unprefixed switch matches', async () => { + const fixture = await createRemoteFixture( + ['amadeus/conflict', 'conflict'], + 'main' + ); + const workDir = await createWorkDir(fixture.rootDir); + + await runCliWithCwdCapture( + ['clone', '--branch-prefix', 'amadeus/', 'demo', fixture.originPath], + { cwd: workDir } + ); + + const result = await runCli(['switch', 'conflict'], { + cwd: join(workDir, 'demo', 'main'), + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('multiple matching branches found'); + expect(result.stderr).toContain('amadeus/conflict'); + expect(result.stderr).toContain('conflict'); + }, 60_000); + it('initializes a manually arranged child worktree layout', async () => { const fixture = await createRemoteFixture([], 'main'); const projectRoot = join(fixture.rootDir, 'manual-project'); diff --git a/test/commands/shared.test.ts b/test/commands/shared.test.ts new file mode 100644 index 0000000..33e3731 --- /dev/null +++ b/test/commands/shared.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { getSelectedChoiceValue } from '@/commands/shared'; + +describe('getSelectedChoiceValue', () => { + it('maps Enquirer choice names back to choice values', () => { + const prefixed = { branchName: 'amadeus/topic' }; + const raw = { branchName: 'topic' }; + + expect(getSelectedChoiceValue([prefixed, raw], '1')).toBe(raw); + }); + + it('keeps returned values when Enquirer returns the choice value directly', () => { + expect(getSelectedChoiceValue(['alpha', 'beta'], 'topic')).toBe('topic'); + }); +}); diff --git a/test/core/branches.test.ts b/test/core/branches.test.ts index d799441..a707229 100644 --- a/test/core/branches.test.ts +++ b/test/core/branches.test.ts @@ -1,13 +1,25 @@ import { createRemoteFixture, runGit } from '@test/helpers'; +import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { encodeBranchPath, findSuffixBranchCandidates, + getSwitchTargetFolderName, resolveBranchName, + resolveSwitchBranchName, stripBranchPrefix, } from '@/core/branches'; +async function cloneFixture( + originPath: string, + rootDir: string +): Promise { + const clonePath = join(rootDir, 'clone'); + await runGit(['clone', originPath, clonePath]); + return clonePath; +} + describe('branch helpers', () => { it('strips a configured branch prefix when present', () => { expect(stripBranchPrefix('users/alice', 'users/')).toBe('alice'); @@ -29,6 +41,176 @@ describe('branch helpers', () => { ) ).toEqual(['foo/test', 'bar/test']); }); + + it('uses the full prefixed branch name when the stripped folder is occupied', () => { + expect(getSwitchTargetFolderName('users/alice', 'users/', [])).toBe( + 'alice' + ); + expect( + getSwitchTargetFolderName('users/alice', 'users/', [ + { branchName: 'alice', path: '/tmp/alice' }, + ]) + ).toBe('users~alice'); + expect( + getSwitchTargetFolderName('alice', 'users/', [ + { branchName: 'alice', path: '/tmp/alice' }, + ]) + ).toBe('alice'); + }); +}); + +describe('resolveSwitchBranchName', () => { + it('only considers an explicit prefixed branch name', async () => { + const fixture = await createRemoteFixture(['users/alice', 'alice'], 'main'); + const clonePath = await cloneFixture(fixture.originPath, fixture.rootDir); + + const result = await resolveSwitchBranchName({ + repoPath: clonePath, + rawBranch: 'users/alice', + remoteName: 'origin', + branchPrefix: 'users/', + }); + + expect(result.status).toBe('resolved'); + if (result.status !== 'resolved') { + throw new Error('expected resolved branch'); + } + expect(result.candidate.branchName).toBe('users/alice'); + expect(result.candidate.local).toBe(false); + expect(result.candidate.remote).toBe(true); + }); + + it('does not use suffix matching when no prefix is configured', async () => { + const fixture = await createRemoteFixture(['feature/test'], 'main'); + + const result = await resolveSwitchBranchName({ + repoPath: fixture.seedPath, + rawBranch: 'test', + remoteName: 'origin', + }); + + expect(result.status).toBe('resolved'); + if (result.status !== 'resolved') { + throw new Error('expected resolved branch'); + } + expect(result.candidate.branchName).toBe('test'); + expect(result.candidate.local).toBe(false); + expect(result.candidate.remote).toBe(false); + }); + + it('uses the prefixed branch when it is the only matching existing branch', async () => { + const fixture = await createRemoteFixture(['users/alice'], 'main'); + const clonePath = await cloneFixture(fixture.originPath, fixture.rootDir); + + const result = await resolveSwitchBranchName({ + repoPath: clonePath, + rawBranch: 'alice', + remoteName: 'origin', + branchPrefix: 'users/', + }); + + expect(result.status).toBe('resolved'); + if (result.status !== 'resolved') { + throw new Error('expected resolved branch'); + } + expect(result.candidate.branchName).toBe('users/alice'); + expect(result.candidate.local).toBe(false); + expect(result.candidate.remote).toBe(true); + }); + + it('uses the unprefixed branch when it is the only matching existing branch', async () => { + const fixture = await createRemoteFixture(['alice'], 'main'); + const clonePath = await cloneFixture(fixture.originPath, fixture.rootDir); + + const result = await resolveSwitchBranchName({ + repoPath: clonePath, + rawBranch: 'alice', + remoteName: 'origin', + branchPrefix: 'users/', + }); + + expect(result.status).toBe('resolved'); + if (result.status !== 'resolved') { + throw new Error('expected resolved branch'); + } + expect(result.candidate.branchName).toBe('alice'); + expect(result.candidate.local).toBe(false); + expect(result.candidate.remote).toBe(true); + }); + + it('creates the prefixed branch when neither candidate exists', async () => { + const fixture = await createRemoteFixture([], 'main'); + + const result = await resolveSwitchBranchName({ + repoPath: fixture.seedPath, + rawBranch: 'alice', + remoteName: 'origin', + branchPrefix: 'users/', + }); + + expect(result.status).toBe('resolved'); + if (result.status !== 'resolved') { + throw new Error('expected resolved branch'); + } + expect(result.candidate.branchName).toBe('users/alice'); + expect(result.candidate.local).toBe(false); + expect(result.candidate.remote).toBe(false); + }); + + it('returns ambiguous candidates when both prefixed and raw branches exist', async () => { + const fixture = await createRemoteFixture(['users/alice', 'alice'], 'main'); + const clonePath = await cloneFixture(fixture.originPath, fixture.rootDir); + + const result = await resolveSwitchBranchName({ + repoPath: clonePath, + rawBranch: 'alice', + remoteName: 'origin', + branchPrefix: 'users/', + }); + + expect(result.status).toBe('ambiguous'); + if (result.status !== 'ambiguous') { + throw new Error('expected ambiguous branch resolution'); + } + expect(result.candidates.map((candidate) => candidate.branchName)).toEqual([ + 'users/alice', + 'alice', + ]); + }); + + it('keeps both existing candidates ambiguous even when one has a worktree', async () => { + const fixture = await createRemoteFixture(['users/alice', 'alice'], 'main'); + + const result = await resolveSwitchBranchName({ + repoPath: fixture.seedPath, + rawBranch: 'alice', + remoteName: 'origin', + branchPrefix: 'users/', + worktrees: [{ branchName: 'alice', path: '/tmp/alice' }], + }); + + expect(result.status).toBe('ambiguous'); + }); + + it('only considers the raw branch when ignoring the configured prefix', async () => { + const fixture = await createRemoteFixture(['users/alice'], 'main'); + + const result = await resolveSwitchBranchName({ + repoPath: fixture.seedPath, + rawBranch: 'alice', + remoteName: 'origin', + branchPrefix: 'users/', + ignorePrefix: true, + }); + + expect(result.status).toBe('resolved'); + if (result.status !== 'resolved') { + throw new Error('expected resolved branch'); + } + expect(result.candidate.branchName).toBe('alice'); + expect(result.candidate.local).toBe(false); + expect(result.candidate.remote).toBe(false); + }); }); describe('resolveBranchName', () => { diff --git a/test/core/git.test.ts b/test/core/git.test.ts index c48f292..ba25cc2 100644 --- a/test/core/git.test.ts +++ b/test/core/git.test.ts @@ -12,6 +12,7 @@ import { detectRemoteHeadFromRepo, detectRemoteHeadFromUrl, getPreferredRemote, + remoteBranchExists, syncRelativeHooksPath, } from '@/core/git'; @@ -47,6 +48,17 @@ describe('git helpers', () => { ); }); + it('checks remote branch refs exactly', async () => { + const fixture = await createRemoteFixture(['users/alice'], 'main'); + + await expect( + remoteBranchExists(fixture.seedPath, 'origin', 'users/alice') + ).resolves.toBe(true); + await expect( + remoteBranchExists(fixture.seedPath, 'origin', 'alice') + ).resolves.toBe(false); + }); + it('syncs a relative hooks path into a new worktree', async () => { const sourceRepo = await makeTempDir('gw-hooks-source-'); const targetRepo = await makeTempDir('gw-hooks-target-');