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