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
25 changes: 18 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ Shell integration is required for `gw clone`, `gw switch`, and `gw pr` to change
the current shell directory. Without it, the Node CLI can only print the target
path.

For `bash`, `zsh`, and `fish`, shell integration also registers tab completion
for `gw switch`, `gw remove`, and `gw rm`. Completion only suggests existing
worktree branch names such as `feature/login`; it does not suggest local-only
branches, remote branches, or `flat-tilde` folder names such as `feature~login`.

For a current-session setup:

```bash
Expand Down Expand Up @@ -70,8 +75,9 @@ gw setup --install --shell nu
Persistent setup writes generated integration to the user's config directory.
For `bash`, `zsh`, and `nu`, it adds a small managed source block to the shell
rc file. For `fish`, it installs an autoloaded `gw` function file under
`$XDG_CONFIG_HOME/fish/functions`. If shell integration is already active in the
current session, `gw setup --install` also sources the generated integration
`$XDG_CONFIG_HOME/fish/functions` and completion definitions under
`$XDG_CONFIG_HOME/fish/completions`. If shell integration is already active in
the current session, `gw setup --install` also sources the generated integration
immediately. If shell integration is not active yet, `gw setup --install` prints
the exact `source` command to run once in the current shell. New shell sessions
load the persistent integration automatically.
Expand Down Expand Up @@ -179,24 +185,29 @@ gw help
- `version` is the config format version.
- `primary` is the primary/default branch directory, usually `main` or
`master`.
- `remote` is the Git remote used for remote branch lookups and deletes,
usually `origin`.
- `remote` is the Git remote used for cached remote-tracking refs, PR
checkout, and remote branch deletes, usually `origin`.
- `path_style` controls worktree folder naming. The current supported value is
`flat-tilde`.
- `branch-prefix` is an optional branch prefix that `gw switch` can apply
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` 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.
branch prefix is configured and an unprefixed name is provided, it checks the
prefixed and raw branch names against local branches, attached worktrees, and
cached remote-tracking refs. If both variants exist, it prompts; if neither
exists, it creates the prefixed variant.
- `gw switch` does not fetch or probe remotes. Run `git fetch` yourself before
switching to branches that were created remotely after your last fetch.
- `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. Use `--worktree` or `-w` to
remove only the worktree while keeping the local branch.
- Tab completion for `gw switch`, `gw remove`, and `gw rm` is intentionally
limited to existing worktrees, so creating a new worktree remains deliberate.
- 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: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Command } from 'commander';

import { registerCloneCommand } from '@/commands/clone';
import { registerCompletionCommand } from '@/commands/completion';
import { registerHelpCommand } from '@/commands/help';
import { registerInitCommand } from '@/commands/init';
import { registerListCommand } from '@/commands/list';
Expand All @@ -26,6 +27,7 @@ registerPrCommand(program);
registerRemoveCommand(program);
registerCloneCommand(program);
registerInitCommand(program);
registerCompletionCommand(program);
registerShellInitCommand(program);
registerSetupCommand(program);
registerHelpCommand(program);
Expand Down
79 changes: 79 additions & 0 deletions src/commands/completion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { Command } from 'commander';

import { loadProjectContext } from '@/commands/shared';
import {
buildCompletionCandidates,
needsWorktreeCompletion,
normalizeCompletionWords,
type CompletionCandidate,
} from '@/core/completion';
import { getBranchPrefix, getPrimaryBranch } from '@/core/config';
import { listWorktrees } from '@/core/worktrees';

function normalizeVariadicWords(
words: string[] | string | undefined
): string[] {
if (Array.isArray(words)) {
return words;
}

if (typeof words === 'string') {
return [words];
}

return [];
}

function sanitizeCompletionField(value: string): string {
return value.replace(/[\t\r\n]/gu, ' ');
}

function formatCompletionCandidate(candidate: CompletionCandidate): string {
const value = sanitizeCompletionField(candidate.value);
const description = candidate.description
? sanitizeCompletionField(candidate.description)
: '';

return description ? `${value}\t${description}` : value;
}

export function registerCompletionCommand(program: Command): void {
program
.command('__complete', { hidden: true })
.allowUnknownOption()
.allowExcessArguments()
.argument('[words...]')
.action(async (words: string[] | string | undefined) => {
try {
const completedWords = normalizeCompletionWords(
normalizeVariadicWords(words)
);
const request = {
completedWords,
current: process.env.GW_COMPLETE_CURRENT || '',
};
const data = needsWorktreeCompletion(request)
? await (async () => {
const context = await loadProjectContext();
return {
branchPrefix: getBranchPrefix(context.config),
primaryBranch: getPrimaryBranch(
context.config,
context.projectRoot
),
worktrees: await listWorktrees(context.anchorRepo),
};
})()
: undefined;
const candidates = buildCompletionCandidates(request, data);

if (candidates.length > 0) {
process.stdout.write(
`${candidates.map(formatCompletionCandidate).join('\n')}\n`
);
}
} catch {
// Completion should never interrupt the user's prompt.
}
});
}
16 changes: 11 additions & 5 deletions src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,20 @@ export function registerSetupCommand(program: Command): void {
process.stdout.write(` init file: ${installResult.initFilePath}\n`);
}

if (installResult.updatedRcFile) {
if (installResult.completionFilePath) {
process.stdout.write(
` status: ${installResult.rcFileLabel} updated\n`
` completion file: ${installResult.completionFilePath}\n`
);
}

const statusLabel = installResult.completionFilePath
? 'integration files'
: installResult.rcFileLabel;

if (installResult.updatedRcFile) {
process.stdout.write(` status: ${statusLabel} updated\n`);
} else {
process.stdout.write(
` status: ${installResult.rcFileLabel} already up to date\n`
);
process.stdout.write(` status: ${statusLabel} already up to date\n`);
}

if (!didRequestShellSource) {
Expand Down
31 changes: 22 additions & 9 deletions src/commands/switch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import {
requestDirectoryChange,
resolveSwitchBranchWithPrompt,
} from '@/commands/shared';
import { getSwitchTargetFolderName } from '@/core/branches';
import {
getSwitchBranchCandidates,
getSwitchTargetFolderName,
} from '@/core/branches';
import { getBranchPrefix, getRemoteName } from '@/core/config';
import {
addWorktree,
branchExists,
fetchRemoteBranchRef,
setBranchUpstream,
syncRelativeHooksPath,
} from '@/core/git';
Expand Down Expand Up @@ -75,6 +76,23 @@ export function registerSwitchCommand(program: Command): void {

const remoteName = getRemoteName(context.config);
const branchPrefix = getBranchPrefix(context.config);
const branchCandidates = getSwitchBranchCandidates(
rawBranch,
branchPrefix,
ignorePrefix
);

if (branchCandidates.length === 1) {
const candidateWorktree = findWorktreeForBranch(
worktrees,
branchCandidates[0]
);
if (candidateWorktree) {
await requestDirectoryChange(candidateWorktree);
return;
}
}

const branchChoice = await resolveSwitchBranchWithPrompt(
context,
rawBranch,
Expand Down Expand Up @@ -110,17 +128,12 @@ export function registerSwitchCommand(program: Command): void {
throw new Error(`target path already exists: ${targetPath}`);
}

if (await branchExists(context.anchorRepo, resolvedBranch)) {
if (branchChoice.local) {
await addWorktree(context.anchorRepo, targetPath, {
branchName: resolvedBranch,
});
} else if (branchChoice.remote) {
const remoteStartRef = `${remoteName}/${resolvedBranch}`;
await fetchRemoteBranchRef(
context.anchorRepo,
remoteName,
resolvedBranch
);
await addWorktree(context.anchorRepo, targetPath, {
branchName: resolvedBranch,
createBranch: true,
Expand Down
13 changes: 7 additions & 6 deletions src/core/branches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { basename } from 'node:path';
import {
branchExists,
listLocalBranches,
remoteBranchExists,
remoteBranchRefExists,
} from '@/core/git';
import {
findWorktreeForBranch,
Expand Down Expand Up @@ -158,7 +158,7 @@ async function buildAmbiguousCandidates(
});
}

function getSwitchBranchCandidates(
export function getSwitchBranchCandidates(
rawBranch: string,
branchPrefix: string,
ignorePrefix: boolean
Expand All @@ -179,16 +179,17 @@ async function buildSwitchBranchCandidate(
branchName: string,
worktrees: WorktreeEntry[]
): Promise<SwitchBranchResolutionCandidate> {
const local = await branchExists(options.repoPath, branchName);
const worktreePath =
findWorktreeForBranch(worktrees, branchName) || undefined;
const local =
worktreePath != null || (await branchExists(options.repoPath, branchName));
const remote = local
? false
: await remoteBranchExists(
: await remoteBranchRefExists(
options.repoPath,
options.remoteName,
branchName
);
const worktreePath =
findWorktreeForBranch(worktrees, branchName) || undefined;

return {
branchName,
Expand Down
Loading