git_worktree_manager/
shell_functions.rs1pub fn generate(shell: &str) -> Option<String> {
6 match shell {
7 "bash" | "zsh" => Some(BASH_ZSH_FUNCTION.to_string()),
8 "fish" => Some(FISH_FUNCTION.to_string()),
9 "powershell" | "pwsh" => Some(POWERSHELL_FUNCTION.to_string()),
10 _ => None,
11 }
12}
13
14const BASH_ZSH_FUNCTION: &str = r#"# git-worktree-manager shell functions for bash/zsh
15# Source this file to enable shell functions:
16# source <(gw _shell-function bash)
17
18# Navigate to a worktree by branch name
19# If no argument is provided, show interactive worktree selector
20# Use -g/--global to search across all registered repositories
21# Supports repo:branch notation (auto-enables global mode)
22gw-cd() {
23 local branch=""
24 local global_mode=0
25
26 # Parse arguments
27 while [ $# -gt 0 ]; do
28 case "$1" in
29 -g|--global)
30 global_mode=1
31 shift
32 ;;
33 -*)
34 echo "Error: Unknown option '$1'" >&2
35 echo "Usage: gw-cd [-g|--global] [branch|repo:branch]" >&2
36 return 1
37 ;;
38 *)
39 branch="$1"
40 shift
41 ;;
42 esac
43 done
44
45 # Auto-detect repo:branch notation → enable global mode
46 if [ $global_mode -eq 0 ] && [[ "$branch" == *:* ]]; then
47 global_mode=1
48 fi
49
50 local worktree_path
51
52 if [ -z "$branch" ]; then
53 # No argument — interactive selector
54 if [ $global_mode -eq 1 ]; then
55 worktree_path=$(gw _path -g --interactive)
56 else
57 worktree_path=$(gw _path --interactive)
58 fi
59 if [ $? -ne 0 ]; then return 1; fi
60 elif [ $global_mode -eq 1 ]; then
61 # Global mode: delegate to gw _path -g
62 worktree_path=$(gw _path -g "$branch")
63 if [ $? -ne 0 ]; then return 1; fi
64 else
65 # Local mode: get worktree path from git directly
66 worktree_path=$(git worktree list --porcelain 2>/dev/null | awk -v branch="$branch" '
67 /^worktree / { path=$2 }
68 /^branch / && $2 == "refs/heads/"branch { print path; exit }
69 ')
70 fi
71
72 if [ -z "$worktree_path" ]; then
73 echo "Error: No worktree found for branch '$branch'" >&2
74 return 1
75 fi
76
77 if [ -d "$worktree_path" ]; then
78 cd "$worktree_path" || return 1
79 echo "Switched to worktree: $worktree_path"
80 else
81 echo "Error: Worktree directory not found: $worktree_path" >&2
82 return 1
83 fi
84}
85
86# Tab completion for gw-cd (bash)
87_gw_cd_completion() {
88 local cur="${COMP_WORDS[COMP_CWORD]}"
89 local has_global=0
90
91 # Remove colon from word break chars for repo:branch completion
92 COMP_WORDBREAKS=${COMP_WORDBREAKS//:}
93
94 # Check if -g or --global is already in the command
95 local i
96 for i in "${COMP_WORDS[@]}"; do
97 case "$i" in -g|--global) has_global=1 ;; esac
98 done
99
100 # If current word starts with -, complete flags
101 if [[ "$cur" == -* ]]; then
102 COMPREPLY=($(compgen -W "-g --global" -- "$cur"))
103 return
104 fi
105
106 local branches
107 if [ $has_global -eq 1 ]; then
108 # Global mode: get repo:branch from all registered repos
109 branches=$(gw _path --list-branches -g 2>/dev/null)
110 else
111 # Local mode: get branches directly from git
112 branches=$(git worktree list --porcelain 2>/dev/null | grep "^branch " | sed 's/^branch refs\/heads\///' | sort -u)
113 fi
114 COMPREPLY=($(compgen -W "$branches" -- "$cur"))
115}
116
117# Register completion for bash
118if [ -n "$BASH_VERSION" ]; then
119 complete -F _gw_cd_completion gw-cd
120 complete -F _gw_cd_completion cw-cd
121 eval "$(gw --generate-completion bash 2>/dev/null || true)"
122
123 # Wrap _gw to add dynamic completion (config keys + branch names)
124 _gw_with_config() {
125 local cur="${COMP_WORDS[COMP_CWORD]}"
126 local subcmd="${COMP_WORDS[1]}"
127
128 # Config key completion: "config get <key>" or "config set <key>"
129 if [[ $subcmd == "config" && ( ${COMP_WORDS[2]} == "get" || ${COMP_WORDS[2]} == "set" ) && $COMP_CWORD -eq 3 ]]; then
130 local keys
131 keys=$(gw _config-keys 2>/dev/null)
132 COMPREPLY=($(compgen -W "$keys" -- "$cur"))
133 return
134 fi
135
136 # Branch completion for subcommands with positional branch args
137 if [[ "$cur" != -* ]]; then
138 # Check for global mode
139 local gflag=""
140 local i
141 for i in "${COMP_WORDS[@]}"; do
142 case "$i" in -g|--global) gflag="-g" ;; esac
143 done
144
145 # Count non-flag positional args after subcommand (skip flag values)
146 local pos_count=0
147 local start_idx=2
148 local max_pos=1
149
150 case "$subcmd" in
151 pr|merge|resume|shell|delete|sync)
152 max_pos=1
153 ;;
154 diff|change-base)
155 max_pos=2
156 ;;
157 backup)
158 if [[ ${COMP_WORDS[2]} =~ ^(create|list|restore)$ ]]; then
159 start_idx=3; max_pos=1
160 else
161 max_pos=0
162 fi
163 ;;
164 stash)
165 if [[ ${COMP_WORDS[2]} == "apply" ]]; then
166 start_idx=3; max_pos=1
167 else
168 max_pos=0
169 fi
170 ;;
171 *)
172 max_pos=0
173 ;;
174 esac
175
176 if [[ $max_pos -gt 0 ]]; then
177 for ((i=start_idx; i<COMP_CWORD; i++)); do
178 [[ ${COMP_WORDS[i]} != -* ]] && ((pos_count++))
179 done
180 if [[ $pos_count -lt $max_pos ]]; then
181 local branches
182 branches=$(gw _path --list-branches $gflag 2>/dev/null)
183 COMPREPLY=($(compgen -W "$branches" -- "$cur"))
184 return
185 fi
186 fi
187 fi
188
189 _gw "$@"
190 }
191 complete -F _gw_with_config -o bashdefault -o default gw
192 complete -F _gw_with_config -o bashdefault -o default cw
193fi
194
195# Tab completion for zsh
196if [ -n "$ZSH_VERSION" ]; then
197 # Register clap completion for gw/cw CLI inline
198 eval "$(gw --generate-completion zsh 2>/dev/null)"
199
200 # Wrap _gw to add dynamic completion (config keys + branch names)
201 _gw_with_config() {
202 local subcmd="${words[2]}"
203
204 # Config key completion: "config get <key>" or "config set <key>"
205 if [[ $subcmd == "config" && ( ${words[3]} == "get" || ${words[3]} == "set" ) && $CURRENT -eq 4 ]]; then
206 local -a keys
207 keys=(${(f)"$(gw _config-keys 2>/dev/null)"})
208 _describe 'config key' keys
209 return
210 fi
211
212 # Branch completion for subcommands with positional branch args
213 if [[ "${words[CURRENT]}" != -* ]]; then
214 # Check for global mode
215 local gflag=""
216 local w
217 for w in "${words[@]}"; do
218 case "$w" in -g|--global) gflag="-g" ;; esac
219 done
220
221 # Count non-flag positional args after subcommand
222 local -i pos_count=0
223 local -i start_idx=3
224 local -i max_pos=0
225
226 case "$subcmd" in
227 pr|merge|resume|shell|delete|sync)
228 max_pos=1
229 ;;
230 diff|change-base)
231 max_pos=2
232 ;;
233 backup)
234 case "${words[3]}" in create|list|restore)
235 start_idx=4; max_pos=1 ;; esac
236 ;;
237 stash)
238 if [[ ${words[3]} == "apply" ]]; then
239 start_idx=4; max_pos=1
240 fi
241 ;;
242 esac
243
244 if [[ $max_pos -gt 0 ]]; then
245 local -i i
246 for ((i=start_idx; i<CURRENT; i++)); do
247 [[ ${words[i]} != -* ]] && ((pos_count++))
248 done
249 if [[ $pos_count -lt $max_pos ]]; then
250 local -a branches
251 branches=(${(f)"$(gw _path --list-branches $gflag 2>/dev/null)"})
252 compadd -a branches
253 return
254 fi
255 fi
256 fi
257
258 _gw "$@"
259 }
260 compdef _gw_with_config gw
261 compdef _gw_with_config cw
262
263 _gw_cd_zsh() {
264 local has_global=0
265 local i
266 for i in "${words[@]}"; do
267 case "$i" in -g|--global) has_global=1 ;; esac
268 done
269
270 # Complete flags
271 if [[ "$PREFIX" == -* ]]; then
272 local -a flags
273 flags=('-g:Search all registered repositories' '--global:Search all registered repositories')
274 _describe 'flags' flags
275 return
276 fi
277
278 local -a branches
279 if [ $has_global -eq 1 ]; then
280 branches=(${(f)"$(gw _path --list-branches -g 2>/dev/null)"})
281 else
282 branches=(${(f)"$(git worktree list --porcelain 2>/dev/null | grep '^branch ' | sed 's/^branch refs\/heads\///' | sort -u)"})
283 fi
284 compadd -a branches
285 }
286 compdef _gw_cd_zsh gw-cd
287fi
288
289# Backward compatibility: cw-cd alias
290cw-cd() { gw-cd "$@"; }
291if [ -n "$BASH_VERSION" ]; then
292 complete -F _gw_cd_completion cw-cd
293fi
294if [ -n "$ZSH_VERSION" ]; then
295 compdef _gw_cd_zsh cw-cd
296fi
297"#;
298
299const FISH_FUNCTION: &str = r#"# git-worktree-manager shell functions for fish
300# Source this file to enable shell functions:
301# gw _shell-function fish | source
302
303# Navigate to a worktree by branch name
304# If no argument is provided, show interactive worktree selector
305# Use -g/--global to search across all registered repositories
306# Supports repo:branch notation (auto-enables global mode)
307function gw-cd
308 set -l global_mode 0
309 set -l branch ""
310
311 # Parse arguments
312 for arg in $argv
313 switch $arg
314 case -g --global
315 set global_mode 1
316 case '-*'
317 echo "Error: Unknown option '$arg'" >&2
318 echo "Usage: gw-cd [-g|--global] [branch|repo:branch]" >&2
319 return 1
320 case '*'
321 set branch $arg
322 end
323 end
324
325 # Auto-detect repo:branch notation → enable global mode
326 if test $global_mode -eq 0; and string match -q '*:*' -- "$branch"
327 set global_mode 1
328 end
329
330 set -l worktree_path
331
332 if test -z "$branch"
333 # No argument — interactive selector
334 if test $global_mode -eq 1
335 set worktree_path (gw _path -g --interactive)
336 else
337 set worktree_path (gw _path --interactive)
338 end
339 if test $status -ne 0
340 return 1
341 end
342 else if test $global_mode -eq 1
343 # Global mode: delegate to gw _path -g
344 set worktree_path (gw _path -g "$branch")
345 if test $status -ne 0
346 return 1
347 end
348 else
349 # Local mode: get worktree path from git directly
350 set worktree_path (git worktree list --porcelain 2>/dev/null | awk -v branch="$branch" '
351 /^worktree / { path=$2 }
352 /^branch / && $2 == "refs/heads/"branch { print path; exit }
353 ')
354 end
355
356 if test -z "$worktree_path"
357 if test -z "$branch"
358 echo "Error: No worktree found (not in a git repository?)" >&2
359 else
360 echo "Error: No worktree found for branch '$branch'" >&2
361 end
362 return 1
363 end
364
365 if test -d "$worktree_path"
366 cd "$worktree_path"; or return 1
367 echo "Switched to worktree: $worktree_path"
368 else
369 echo "Error: Worktree directory not found: $worktree_path" >&2
370 return 1
371 end
372end
373
374# Tab completion for gw-cd
375# Complete -g/--global flag
376complete -c gw-cd -s g -l global -d 'Search all registered repositories'
377
378# Complete branch names: global mode if -g is present, otherwise local git
379complete -c gw-cd -f -n '__fish_contains_opt -s g global' -a '(gw _path --list-branches -g 2>/dev/null)'
380complete -c gw-cd -f -n 'not __fish_contains_opt -s g global' -a '(git worktree list --porcelain 2>/dev/null | grep "^branch " | sed "s|^branch refs/heads/||" | sort -u)'
381
382# Backward compatibility: cw-cd alias
383function cw-cd; gw-cd $argv; end
384complete -c cw-cd -w gw-cd
385
386# Tab completion for gw/cw CLI (clap-generated)
387gw --generate-completion fish 2>/dev/null | source
388
389# Config key completion for gw config get/set
390complete -c gw -f -n '__fish_seen_subcommand_from config; and __fish_seen_subcommand_from get set' -a '(gw _config-keys 2>/dev/null)'
391complete -c cw -f -n '__fish_seen_subcommand_from config; and __fish_seen_subcommand_from get set' -a '(gw _config-keys 2>/dev/null)'
392
393# Branch completion for subcommands with positional branch args
394for cmd in pr merge resume shell delete sync diff change-base
395 complete -c gw -f -n "__fish_seen_subcommand_from $cmd" -a '(gw _path --list-branches 2>/dev/null)'
396 complete -c cw -f -n "__fish_seen_subcommand_from $cmd" -a '(gw _path --list-branches 2>/dev/null)'
397end
398
399# Branch completion for nested subcommands: backup create/list/restore, stash apply
400complete -c gw -f -n '__fish_seen_subcommand_from backup; and __fish_seen_subcommand_from create list restore' -a '(gw _path --list-branches 2>/dev/null)'
401complete -c cw -f -n '__fish_seen_subcommand_from backup; and __fish_seen_subcommand_from create list restore' -a '(gw _path --list-branches 2>/dev/null)'
402complete -c gw -f -n '__fish_seen_subcommand_from stash; and __fish_seen_subcommand_from apply' -a '(gw _path --list-branches 2>/dev/null)'
403complete -c cw -f -n '__fish_seen_subcommand_from stash; and __fish_seen_subcommand_from apply' -a '(gw _path --list-branches 2>/dev/null)'
404"#;
405
406const POWERSHELL_FUNCTION: &str = r#"# git-worktree-manager shell functions for PowerShell
407# Source this file to enable shell functions:
408# gw _shell-function powershell | Out-String | Invoke-Expression
409
410# Navigate to a worktree by branch name
411# If no argument is provided, show interactive worktree selector
412# Use -g to search across all registered repositories
413# Supports repo:branch notation (auto-enables global mode)
414function gw-cd {
415 param(
416 [Parameter(Mandatory=$false, Position=0)]
417 [string]$Branch,
418 [Alias('global')]
419 [switch]$g
420 )
421
422 # Auto-detect repo:branch notation → enable global mode
423 if (-not $g -and $Branch -match ':') {
424 $g = [switch]::Present
425 }
426
427 $worktreePath = $null
428
429 if (-not $Branch) {
430 # No argument — interactive selector
431 if ($g) {
432 $worktreePath = gw _path -g --interactive
433 } else {
434 $worktreePath = gw _path --interactive
435 }
436 if ($LASTEXITCODE -ne 0) {
437 return
438 }
439 } elseif ($g) {
440 # Global mode: delegate to gw _path -g
441 $worktreePath = gw _path -g $Branch
442 if ($LASTEXITCODE -ne 0) {
443 return
444 }
445 } else {
446 # Local mode: get worktree path from git directly
447 $worktreePath = git worktree list --porcelain 2>&1 |
448 Where-Object { $_ -is [string] } |
449 ForEach-Object {
450 if ($_ -match '^worktree (.+)$') { $path = $Matches[1] }
451 if ($_ -match "^branch refs/heads/$Branch$") { $path }
452 } | Select-Object -First 1
453 }
454
455 if (-not $worktreePath) {
456 if (-not $Branch) {
457 Write-Error "Error: No worktree found (not in a git repository?)"
458 } else {
459 Write-Error "Error: No worktree found for branch '$Branch'"
460 }
461 return
462 }
463
464 if (Test-Path -Path $worktreePath -PathType Container) {
465 Set-Location -Path $worktreePath
466 Write-Host "Switched to worktree: $worktreePath"
467 } else {
468 Write-Error "Error: Worktree directory not found: $worktreePath"
469 return
470 }
471}
472
473# Backward compatibility: cw-cd alias
474Set-Alias -Name cw-cd -Value gw-cd
475
476# Tab completion for gw-cd
477Register-ArgumentCompleter -CommandName gw-cd -ParameterName Branch -ScriptBlock {
478 param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
479
480 $branches = $null
481 if ($fakeBoundParameters.ContainsKey('g')) {
482 # Global mode: get repo:branch from all registered repos
483 $branches = gw _path --list-branches -g 2>&1 |
484 Where-Object { $_ -is [string] -and $_.Trim() } |
485 Sort-Object -Unique
486 } else {
487 # Local mode: get branches from git
488 $branches = git worktree list --porcelain 2>&1 |
489 Where-Object { $_ -is [string] } |
490 Select-String -Pattern '^branch ' |
491 ForEach-Object { $_ -replace '^branch refs/heads/', '' } |
492 Sort-Object -Unique
493 }
494
495 # Filter branches that match the current word
496 $branches | Where-Object { $_ -like "$wordToComplete*" } |
497 ForEach-Object {
498 [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
499 }
500}
501
502# Tab completion for cw-cd (backward compat)
503Register-ArgumentCompleter -CommandName cw-cd -ParameterName Branch -ScriptBlock {
504 param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
505
506 $branches = $null
507 if ($fakeBoundParameters.ContainsKey('g')) {
508 $branches = gw _path --list-branches -g 2>&1 |
509 Where-Object { $_ -is [string] -and $_.Trim() } |
510 Sort-Object -Unique
511 } else {
512 $branches = git worktree list --porcelain 2>&1 |
513 Where-Object { $_ -is [string] } |
514 Select-String -Pattern '^branch ' |
515 ForEach-Object { $_ -replace '^branch refs/heads/', '' } |
516 Sort-Object -Unique
517 }
518
519 $branches | Where-Object { $_ -like "$wordToComplete*" } |
520 ForEach-Object {
521 [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
522 }
523}
524"#;
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 #[test]
531 fn test_generate_bash() {
532 let result = generate("bash");
533 assert!(result.is_some());
534 let script = result.unwrap();
535 assert!(script.contains("gw-cd()"));
536 assert!(script.contains("_gw_cd_completion"));
537 assert!(script.contains("cw-cd"));
538 assert!(script.contains("BASH_VERSION"));
539 assert!(script.contains("ZSH_VERSION"));
540 assert!(script.contains("_gw_cd_zsh"));
541 }
542
543 #[test]
544 fn test_generate_zsh() {
545 let result = generate("zsh");
546 assert!(result.is_some());
547 let script = result.unwrap();
548 assert!(script.contains("compdef _gw_cd_zsh gw-cd"));
549 assert!(script.contains("compdef _gw_cd_zsh cw-cd"));
550 }
551
552 #[test]
553 fn test_generate_fish() {
554 let result = generate("fish");
555 assert!(result.is_some());
556 let script = result.unwrap();
557 assert!(script.contains("function gw-cd"));
558 assert!(script.contains("complete -c gw-cd"));
559 assert!(script.contains("function cw-cd"));
560 assert!(script.contains("complete -c cw-cd -w gw-cd"));
561 }
562
563 #[test]
564 fn test_generate_powershell() {
565 let result = generate("powershell");
566 assert!(result.is_some());
567 let script = result.unwrap();
568 assert!(script.contains("function gw-cd"));
569 assert!(script.contains("Register-ArgumentCompleter"));
570 assert!(script.contains("Set-Alias -Name cw-cd -Value gw-cd"));
571 }
572
573 #[test]
574 fn test_generate_pwsh_alias() {
575 let result = generate("pwsh");
576 assert!(result.is_some());
577 assert_eq!(result, generate("powershell"));
579 }
580
581 #[test]
582 fn test_generate_unknown() {
583 assert!(generate("unknown").is_none());
584 assert!(generate("").is_none());
585 }
586
587 #[test]
589 #[cfg(not(windows))]
590 fn test_bash_script_syntax() {
591 let script = generate("bash").unwrap();
592
593 let output = std::process::Command::new("bash")
595 .arg("-n")
596 .stdin(std::process::Stdio::piped())
597 .stdout(std::process::Stdio::piped())
598 .stderr(std::process::Stdio::piped())
599 .spawn()
600 .and_then(|mut child| {
601 use std::io::Write;
602 child.stdin.take().unwrap().write_all(script.as_bytes())?;
603 child.wait_with_output()
604 });
605
606 match output {
607 Ok(out) => {
608 let stderr = String::from_utf8_lossy(&out.stderr);
609 assert!(
610 out.status.success(),
611 "bash -n failed for generated bash/zsh script:\n{}",
612 stderr
613 );
614 }
615 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
616 eprintln!("bash not found, skipping syntax check");
617 }
618 Err(e) => panic!("failed to run bash -n: {}", e),
619 }
620 }
621
622 #[test]
624 fn test_fish_script_syntax() {
625 let script = generate("fish").unwrap();
626
627 let output = std::process::Command::new("fish")
628 .arg("--no-execute")
629 .stdin(std::process::Stdio::piped())
630 .stdout(std::process::Stdio::piped())
631 .stderr(std::process::Stdio::piped())
632 .spawn()
633 .and_then(|mut child| {
634 use std::io::Write;
635 child.stdin.take().unwrap().write_all(script.as_bytes())?;
636 child.wait_with_output()
637 });
638
639 match output {
640 Ok(out) => {
641 let stderr = String::from_utf8_lossy(&out.stderr);
642 assert!(
643 out.status.success(),
644 "fish --no-execute failed for generated fish script:\n{}",
645 stderr
646 );
647 }
648 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
649 eprintln!("fish not found, skipping syntax check");
650 }
651 Err(e) => panic!("failed to run fish --no-execute: {}", e),
652 }
653 }
654}