Skip to main content

git_worktree_manager/
shell_functions.rs

1/// Shell function generation for gw-cd.
2///
3/// Outputs shell-specific function definitions for bash/zsh/fish/powershell.
4/// Generate shell function for the specified shell.
5pub 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        // pwsh should return the same as powershell
578        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    /// Verify bash/zsh script has valid syntax using `bash -n`.
588    #[test]
589    #[cfg(not(windows))]
590    fn test_bash_script_syntax() {
591        let script = generate("bash").unwrap();
592
593        // bash -n: check syntax without executing
594        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    /// Verify fish script has valid syntax using `fish --no-execute`.
623    #[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}