Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -158,7 +164,7 @@ error instead of guessing.
gw list
gw switch [--ignore-prefix] [branch]
gw pr <number>
gw remove|rm [--force] [--remote] [--ignore-prefix] <branch>
gw remove|rm [--force] [--remote] [-w|--worktree] [--ignore-prefix] <branch>
gw clone [--branch-prefix <prefix>] <project-name> <repo-url>
gw init [--branch-prefix <prefix>]
gw shell-init [--shell bash|zsh|fish|nu]
Expand All @@ -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 <number>` requires `gh`, reads the PR head branch and owner, creates or
reuses a fork remote, checks out local branch `pr_<number>`, and switches to
the `pr_<number>` 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`,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
15 changes: 14 additions & 1 deletion src/commands/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface RemoveOptions {
force?: boolean;
remote?: boolean;
ignorePrefix?: boolean;
worktree?: boolean;
}

export function registerRemoveCommand(program: Command): void {
Expand All @@ -30,9 +31,15 @@ export function registerRemoveCommand(program: Command): void {
.argument('<branch>')
.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,
Expand Down Expand Up @@ -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)) {
Expand All @@ -81,7 +94,7 @@ export function registerRemoveCommand(program: Command): void {
);
}

if (localBranchExists) {
if (localBranchExists && !worktreeOnly) {
await deleteBranch(
context.anchorRepo,
resolvedBranch,
Expand Down
88 changes: 85 additions & 3 deletions src/commands/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { basename, join, resolve } from 'node:path';

import {
resolveBranchName,
resolveSwitchBranchName,
type BranchResolutionCandidate,
type SwitchBranchResolutionCandidate,
} from '@/core/branches';
import {
getBranchPrefix,
Expand All @@ -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;
Expand Down Expand Up @@ -82,6 +84,25 @@ export function isInteractiveTerminal(): boolean {
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
}

export function getSelectedChoiceValue<T>(
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<boolean> {
try {
await access(path, constants.F_OK);
Expand Down Expand Up @@ -150,7 +171,7 @@ async function selectValue<T>(
}

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({
Expand All @@ -166,7 +187,10 @@ async function selectValue<T>(
})),
});

return answer.selected;
return getSelectedChoiceValue(
choices.map((choice) => choice.value),
answer.selected
);
} catch (error) {
if (error == null || error === '') {
return null;
Expand All @@ -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,
Expand Down Expand Up @@ -218,6 +259,47 @@ export async function resolveBranchWithPrompt(
);
}

export async function resolveSwitchBranchWithPrompt(
context: ProjectContext,
rawBranch: string,
ignorePrefix: boolean,
remoteName: string,
worktrees: WorktreeEntry[]
): Promise<SwitchBranchResolutionCandidate | null> {
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<string[]> {
Expand Down
66 changes: 31 additions & 35 deletions src/commands/switch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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)) {
Expand All @@ -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,
Expand Down
Loading