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 config key completion for bash
124    _gw_with_config() {
125        # If completing "config get <key>" or "config set <key>"
126        if [[ ${COMP_WORDS[1]} == "config" && ( ${COMP_WORDS[2]} == "get" || ${COMP_WORDS[2]} == "set" ) && $COMP_CWORD -eq 3 ]]; then
127            local keys
128            keys=$(gw _config-keys 2>/dev/null)
129            COMPREPLY=($(compgen -W "$keys" -- "${COMP_WORDS[COMP_CWORD]}"))
130            return
131        fi
132        _gw "$@"
133    }
134    complete -F _gw_with_config -o bashdefault -o default gw
135    complete -F _gw_with_config -o bashdefault -o default cw
136fi
137
138# Tab completion for zsh
139if [ -n "$ZSH_VERSION" ]; then
140    # Register clap completion for gw/cw CLI inline
141    eval "$(gw --generate-completion zsh 2>/dev/null)"
142
143    # Wrap _gw to add config key completion
144    _gw_with_config() {
145        # If completing "config get <key>" or "config set <key>", offer config keys
146        if [[ ${words[2]} == "config" && ( ${words[3]} == "get" || ${words[3]} == "set" ) && $CURRENT -eq 4 ]]; then
147            local -a keys
148            keys=(${(f)"$(gw _config-keys 2>/dev/null)"})
149            _describe 'config key' keys
150            return
151        fi
152        _gw "$@"
153    }
154    compdef _gw_with_config gw
155    compdef _gw_with_config cw
156
157    _gw_cd_zsh() {
158        local has_global=0
159        local i
160        for i in "${words[@]}"; do
161            case "$i" in -g|--global) has_global=1 ;; esac
162        done
163
164        # Complete flags
165        if [[ "$PREFIX" == -* ]]; then
166            local -a flags
167            flags=('-g:Search all registered repositories' '--global:Search all registered repositories')
168            _describe 'flags' flags
169            return
170        fi
171
172        local -a branches
173        if [ $has_global -eq 1 ]; then
174            branches=(${(f)"$(gw _path --list-branches -g 2>/dev/null)"})
175        else
176            branches=(${(f)"$(git worktree list --porcelain 2>/dev/null | grep '^branch ' | sed 's/^branch refs\/heads\///' | sort -u)"})
177        fi
178        compadd -a branches
179    }
180    compdef _gw_cd_zsh gw-cd
181fi
182
183# Backward compatibility: cw-cd alias
184cw-cd() { gw-cd "$@"; }
185if [ -n "$BASH_VERSION" ]; then
186    complete -F _gw_cd_completion cw-cd
187fi
188if [ -n "$ZSH_VERSION" ]; then
189    compdef _gw_cd_zsh cw-cd
190fi
191"#;
192
193const FISH_FUNCTION: &str = r#"# git-worktree-manager shell functions for fish
194# Source this file to enable shell functions:
195#   gw _shell-function fish | source
196
197# Navigate to a worktree by branch name
198# If no argument is provided, show interactive worktree selector
199# Use -g/--global to search across all registered repositories
200# Supports repo:branch notation (auto-enables global mode)
201function gw-cd
202    set -l global_mode 0
203    set -l branch ""
204
205    # Parse arguments
206    for arg in $argv
207        switch $arg
208            case -g --global
209                set global_mode 1
210            case '-*'
211                echo "Error: Unknown option '$arg'" >&2
212                echo "Usage: gw-cd [-g|--global] [branch|repo:branch]" >&2
213                return 1
214            case '*'
215                set branch $arg
216        end
217    end
218
219    # Auto-detect repo:branch notation → enable global mode
220    if test $global_mode -eq 0; and string match -q '*:*' -- "$branch"
221        set global_mode 1
222    end
223
224    set -l worktree_path
225
226    if test -z "$branch"
227        # No argument — interactive selector
228        if test $global_mode -eq 1
229            set worktree_path (gw _path -g --interactive)
230        else
231            set worktree_path (gw _path --interactive)
232        end
233        if test $status -ne 0
234            return 1
235        end
236    else if test $global_mode -eq 1
237        # Global mode: delegate to gw _path -g
238        set worktree_path (gw _path -g "$branch")
239        if test $status -ne 0
240            return 1
241        end
242    else
243        # Local mode: get worktree path from git directly
244        set worktree_path (git worktree list --porcelain 2>/dev/null | awk -v branch="$branch" '
245            /^worktree / { path=$2 }
246            /^branch / && $2 == "refs/heads/"branch { print path; exit }
247        ')
248    end
249
250    if test -z "$worktree_path"
251        if test -z "$branch"
252            echo "Error: No worktree found (not in a git repository?)" >&2
253        else
254            echo "Error: No worktree found for branch '$branch'" >&2
255        end
256        return 1
257    end
258
259    if test -d "$worktree_path"
260        cd "$worktree_path"; or return 1
261        echo "Switched to worktree: $worktree_path"
262    else
263        echo "Error: Worktree directory not found: $worktree_path" >&2
264        return 1
265    end
266end
267
268# Tab completion for gw-cd
269# Complete -g/--global flag
270complete -c gw-cd -s g -l global -d 'Search all registered repositories'
271
272# Complete branch names: global mode if -g is present, otherwise local git
273complete -c gw-cd -f -n '__fish_contains_opt -s g global' -a '(gw _path --list-branches -g 2>/dev/null)'
274complete -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)'
275
276# Backward compatibility: cw-cd alias
277function cw-cd; gw-cd $argv; end
278complete -c cw-cd -w gw-cd
279
280# Tab completion for gw/cw CLI (clap-generated)
281gw --generate-completion fish 2>/dev/null | source
282
283# Config key completion for gw config get/set
284complete -c gw -f -n '__fish_seen_subcommand_from config; and __fish_seen_subcommand_from get set' -a '(gw _config-keys 2>/dev/null)'
285complete -c cw -f -n '__fish_seen_subcommand_from config; and __fish_seen_subcommand_from get set' -a '(gw _config-keys 2>/dev/null)'
286"#;
287
288const POWERSHELL_FUNCTION: &str = r#"# git-worktree-manager shell functions for PowerShell
289# Source this file to enable shell functions:
290#   gw _shell-function powershell | Out-String | Invoke-Expression
291
292# Navigate to a worktree by branch name
293# If no argument is provided, show interactive worktree selector
294# Use -g to search across all registered repositories
295# Supports repo:branch notation (auto-enables global mode)
296function gw-cd {
297    param(
298        [Parameter(Mandatory=$false, Position=0)]
299        [string]$Branch,
300        [Alias('global')]
301        [switch]$g
302    )
303
304    # Auto-detect repo:branch notation → enable global mode
305    if (-not $g -and $Branch -match ':') {
306        $g = [switch]::Present
307    }
308
309    $worktreePath = $null
310
311    if (-not $Branch) {
312        # No argument — interactive selector
313        if ($g) {
314            $worktreePath = gw _path -g --interactive
315        } else {
316            $worktreePath = gw _path --interactive
317        }
318        if ($LASTEXITCODE -ne 0) {
319            return
320        }
321    } elseif ($g) {
322        # Global mode: delegate to gw _path -g
323        $worktreePath = gw _path -g $Branch
324        if ($LASTEXITCODE -ne 0) {
325            return
326        }
327    } else {
328        # Local mode: get worktree path from git directly
329        $worktreePath = git worktree list --porcelain 2>&1 |
330            Where-Object { $_ -is [string] } |
331            ForEach-Object {
332                if ($_ -match '^worktree (.+)$') { $path = $Matches[1] }
333                if ($_ -match "^branch refs/heads/$Branch$") { $path }
334            } | Select-Object -First 1
335    }
336
337    if (-not $worktreePath) {
338        if (-not $Branch) {
339            Write-Error "Error: No worktree found (not in a git repository?)"
340        } else {
341            Write-Error "Error: No worktree found for branch '$Branch'"
342        }
343        return
344    }
345
346    if (Test-Path -Path $worktreePath -PathType Container) {
347        Set-Location -Path $worktreePath
348        Write-Host "Switched to worktree: $worktreePath"
349    } else {
350        Write-Error "Error: Worktree directory not found: $worktreePath"
351        return
352    }
353}
354
355# Backward compatibility: cw-cd alias
356Set-Alias -Name cw-cd -Value gw-cd
357
358# Tab completion for gw-cd
359Register-ArgumentCompleter -CommandName gw-cd -ParameterName Branch -ScriptBlock {
360    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
361
362    $branches = $null
363    if ($fakeBoundParameters.ContainsKey('g')) {
364        # Global mode: get repo:branch from all registered repos
365        $branches = gw _path --list-branches -g 2>&1 |
366            Where-Object { $_ -is [string] -and $_.Trim() } |
367            Sort-Object -Unique
368    } else {
369        # Local mode: get branches from git
370        $branches = git worktree list --porcelain 2>&1 |
371            Where-Object { $_ -is [string] } |
372            Select-String -Pattern '^branch ' |
373            ForEach-Object { $_ -replace '^branch refs/heads/', '' } |
374            Sort-Object -Unique
375    }
376
377    # Filter branches that match the current word
378    $branches | Where-Object { $_ -like "$wordToComplete*" } |
379        ForEach-Object {
380            [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
381        }
382}
383
384# Tab completion for cw-cd (backward compat)
385Register-ArgumentCompleter -CommandName cw-cd -ParameterName Branch -ScriptBlock {
386    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
387
388    $branches = $null
389    if ($fakeBoundParameters.ContainsKey('g')) {
390        $branches = gw _path --list-branches -g 2>&1 |
391            Where-Object { $_ -is [string] -and $_.Trim() } |
392            Sort-Object -Unique
393    } else {
394        $branches = git worktree list --porcelain 2>&1 |
395            Where-Object { $_ -is [string] } |
396            Select-String -Pattern '^branch ' |
397            ForEach-Object { $_ -replace '^branch refs/heads/', '' } |
398            Sort-Object -Unique
399    }
400
401    $branches | Where-Object { $_ -like "$wordToComplete*" } |
402        ForEach-Object {
403            [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
404        }
405}
406"#;
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn test_generate_bash() {
414        let result = generate("bash");
415        assert!(result.is_some());
416        let script = result.unwrap();
417        assert!(script.contains("gw-cd()"));
418        assert!(script.contains("_gw_cd_completion"));
419        assert!(script.contains("cw-cd"));
420        assert!(script.contains("BASH_VERSION"));
421        assert!(script.contains("ZSH_VERSION"));
422        assert!(script.contains("_gw_cd_zsh"));
423    }
424
425    #[test]
426    fn test_generate_zsh() {
427        let result = generate("zsh");
428        assert!(result.is_some());
429        let script = result.unwrap();
430        assert!(script.contains("compdef _gw_cd_zsh gw-cd"));
431        assert!(script.contains("compdef _gw_cd_zsh cw-cd"));
432    }
433
434    #[test]
435    fn test_generate_fish() {
436        let result = generate("fish");
437        assert!(result.is_some());
438        let script = result.unwrap();
439        assert!(script.contains("function gw-cd"));
440        assert!(script.contains("complete -c gw-cd"));
441        assert!(script.contains("function cw-cd"));
442        assert!(script.contains("complete -c cw-cd -w gw-cd"));
443    }
444
445    #[test]
446    fn test_generate_powershell() {
447        let result = generate("powershell");
448        assert!(result.is_some());
449        let script = result.unwrap();
450        assert!(script.contains("function gw-cd"));
451        assert!(script.contains("Register-ArgumentCompleter"));
452        assert!(script.contains("Set-Alias -Name cw-cd -Value gw-cd"));
453    }
454
455    #[test]
456    fn test_generate_pwsh_alias() {
457        let result = generate("pwsh");
458        assert!(result.is_some());
459        // pwsh should return the same as powershell
460        assert_eq!(result, generate("powershell"));
461    }
462
463    #[test]
464    fn test_generate_unknown() {
465        assert!(generate("unknown").is_none());
466        assert!(generate("").is_none());
467    }
468
469    /// Verify bash/zsh script has valid syntax using `bash -n`.
470    #[test]
471    #[cfg(not(windows))]
472    fn test_bash_script_syntax() {
473        let script = generate("bash").unwrap();
474
475        // bash -n: check syntax without executing
476        let output = std::process::Command::new("bash")
477            .arg("-n")
478            .stdin(std::process::Stdio::piped())
479            .stdout(std::process::Stdio::piped())
480            .stderr(std::process::Stdio::piped())
481            .spawn()
482            .and_then(|mut child| {
483                use std::io::Write;
484                child.stdin.take().unwrap().write_all(script.as_bytes())?;
485                child.wait_with_output()
486            });
487
488        match output {
489            Ok(out) => {
490                let stderr = String::from_utf8_lossy(&out.stderr);
491                assert!(
492                    out.status.success(),
493                    "bash -n failed for generated bash/zsh script:\n{}",
494                    stderr
495                );
496            }
497            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
498                eprintln!("bash not found, skipping syntax check");
499            }
500            Err(e) => panic!("failed to run bash -n: {}", e),
501        }
502    }
503
504    /// Verify fish script has valid syntax using `fish --no-execute`.
505    #[test]
506    fn test_fish_script_syntax() {
507        let script = generate("fish").unwrap();
508
509        let output = std::process::Command::new("fish")
510            .arg("--no-execute")
511            .stdin(std::process::Stdio::piped())
512            .stdout(std::process::Stdio::piped())
513            .stderr(std::process::Stdio::piped())
514            .spawn()
515            .and_then(|mut child| {
516                use std::io::Write;
517                child.stdin.take().unwrap().write_all(script.as_bytes())?;
518                child.wait_with_output()
519            });
520
521        match output {
522            Ok(out) => {
523                let stderr = String::from_utf8_lossy(&out.stderr);
524                assert!(
525                    out.status.success(),
526                    "fish --no-execute failed for generated fish script:\n{}",
527                    stderr
528                );
529            }
530            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
531                eprintln!("fish not found, skipping syntax check");
532            }
533            Err(e) => panic!("failed to run fish --no-execute: {}", e),
534        }
535    }
536}