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
120fi
121
122# Tab completion for zsh
123if [ -n "$ZSH_VERSION" ]; then
124    # Register clap completion for gw CLI inline
125    # (eliminates need for ~/.zfunc/_gw file and FPATH setup)
126    _gw_completion() {
127        eval $(env _GW_COMPLETE=complete_zsh COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) gw --generate-completion zsh 2>/dev/null)
128    }
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
254const POWERSHELL_FUNCTION: &str = r#"# git-worktree-manager shell functions for PowerShell
255# Source this file to enable shell functions:
256#   gw _shell-function powershell | Out-String | Invoke-Expression
257
258# Navigate to a worktree by branch name
259# If no argument is provided, show interactive worktree selector
260# Use -g to search across all registered repositories
261# Supports repo:branch notation (auto-enables global mode)
262function gw-cd {
263    param(
264        [Parameter(Mandatory=$false, Position=0)]
265        [string]$Branch,
266        [Alias('global')]
267        [switch]$g
268    )
269
270    # Auto-detect repo:branch notation → enable global mode
271    if (-not $g -and $Branch -match ':') {
272        $g = [switch]::Present
273    }
274
275    $worktreePath = $null
276
277    if (-not $Branch) {
278        # No argument — interactive selector
279        if ($g) {
280            $worktreePath = gw _path -g --interactive
281        } else {
282            $worktreePath = gw _path --interactive
283        }
284        if ($LASTEXITCODE -ne 0) {
285            return
286        }
287    } elseif ($g) {
288        # Global mode: delegate to gw _path -g
289        $worktreePath = gw _path -g $Branch
290        if ($LASTEXITCODE -ne 0) {
291            return
292        }
293    } else {
294        # Local mode: get worktree path from git directly
295        $worktreePath = git worktree list --porcelain 2>&1 |
296            Where-Object { $_ -is [string] } |
297            ForEach-Object {
298                if ($_ -match '^worktree (.+)$') { $path = $Matches[1] }
299                if ($_ -match "^branch refs/heads/$Branch$") { $path }
300            } | Select-Object -First 1
301    }
302
303    if (-not $worktreePath) {
304        if (-not $Branch) {
305            Write-Error "Error: No worktree found (not in a git repository?)"
306        } else {
307            Write-Error "Error: No worktree found for branch '$Branch'"
308        }
309        return
310    }
311
312    if (Test-Path -Path $worktreePath -PathType Container) {
313        Set-Location -Path $worktreePath
314        Write-Host "Switched to worktree: $worktreePath"
315    } else {
316        Write-Error "Error: Worktree directory not found: $worktreePath"
317        return
318    }
319}
320
321# Backward compatibility: cw-cd alias
322Set-Alias -Name cw-cd -Value gw-cd
323
324# Tab completion for gw-cd
325Register-ArgumentCompleter -CommandName gw-cd -ParameterName Branch -ScriptBlock {
326    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
327
328    $branches = $null
329    if ($fakeBoundParameters.ContainsKey('g')) {
330        # Global mode: get repo:branch from all registered repos
331        $branches = gw _path --list-branches -g 2>&1 |
332            Where-Object { $_ -is [string] -and $_.Trim() } |
333            Sort-Object -Unique
334    } else {
335        # Local mode: get branches from git
336        $branches = git worktree list --porcelain 2>&1 |
337            Where-Object { $_ -is [string] } |
338            Select-String -Pattern '^branch ' |
339            ForEach-Object { $_ -replace '^branch refs/heads/', '' } |
340            Sort-Object -Unique
341    }
342
343    # Filter branches that match the current word
344    $branches | Where-Object { $_ -like "$wordToComplete*" } |
345        ForEach-Object {
346            [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
347        }
348}
349
350# Tab completion for cw-cd (backward compat)
351Register-ArgumentCompleter -CommandName cw-cd -ParameterName Branch -ScriptBlock {
352    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
353
354    $branches = $null
355    if ($fakeBoundParameters.ContainsKey('g')) {
356        $branches = gw _path --list-branches -g 2>&1 |
357            Where-Object { $_ -is [string] -and $_.Trim() } |
358            Sort-Object -Unique
359    } else {
360        $branches = git worktree list --porcelain 2>&1 |
361            Where-Object { $_ -is [string] } |
362            Select-String -Pattern '^branch ' |
363            ForEach-Object { $_ -replace '^branch refs/heads/', '' } |
364            Sort-Object -Unique
365    }
366
367    $branches | Where-Object { $_ -like "$wordToComplete*" } |
368        ForEach-Object {
369            [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
370        }
371}
372"#;
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn test_generate_bash() {
380        let result = generate("bash");
381        assert!(result.is_some());
382        let script = result.unwrap();
383        assert!(script.contains("gw-cd()"));
384        assert!(script.contains("_gw_cd_completion"));
385        assert!(script.contains("cw-cd"));
386        assert!(script.contains("BASH_VERSION"));
387        assert!(script.contains("ZSH_VERSION"));
388        assert!(script.contains("_gw_cd_zsh"));
389    }
390
391    #[test]
392    fn test_generate_zsh() {
393        let result = generate("zsh");
394        assert!(result.is_some());
395        let script = result.unwrap();
396        assert!(script.contains("compdef _gw_cd_zsh gw-cd"));
397        assert!(script.contains("compdef _gw_cd_zsh cw-cd"));
398    }
399
400    #[test]
401    fn test_generate_fish() {
402        let result = generate("fish");
403        assert!(result.is_some());
404        let script = result.unwrap();
405        assert!(script.contains("function gw-cd"));
406        assert!(script.contains("complete -c gw-cd"));
407        assert!(script.contains("function cw-cd"));
408        assert!(script.contains("complete -c cw-cd -w gw-cd"));
409    }
410
411    #[test]
412    fn test_generate_powershell() {
413        let result = generate("powershell");
414        assert!(result.is_some());
415        let script = result.unwrap();
416        assert!(script.contains("function gw-cd"));
417        assert!(script.contains("Register-ArgumentCompleter"));
418        assert!(script.contains("Set-Alias -Name cw-cd -Value gw-cd"));
419    }
420
421    #[test]
422    fn test_generate_pwsh_alias() {
423        let result = generate("pwsh");
424        assert!(result.is_some());
425        // pwsh should return the same as powershell
426        assert_eq!(result, generate("powershell"));
427    }
428
429    #[test]
430    fn test_generate_unknown() {
431        assert!(generate("unknown").is_none());
432        assert!(generate("").is_none());
433    }
434
435    /// Verify bash/zsh script has valid syntax using `bash -n`.
436    #[test]
437    #[cfg(not(windows))]
438    fn test_bash_script_syntax() {
439        let script = generate("bash").unwrap();
440
441        // bash -n: check syntax without executing
442        let output = std::process::Command::new("bash")
443            .arg("-n")
444            .stdin(std::process::Stdio::piped())
445            .stdout(std::process::Stdio::piped())
446            .stderr(std::process::Stdio::piped())
447            .spawn()
448            .and_then(|mut child| {
449                use std::io::Write;
450                child.stdin.take().unwrap().write_all(script.as_bytes())?;
451                child.wait_with_output()
452            });
453
454        match output {
455            Ok(out) => {
456                let stderr = String::from_utf8_lossy(&out.stderr);
457                assert!(
458                    out.status.success(),
459                    "bash -n failed for generated bash/zsh script:\n{}",
460                    stderr
461                );
462            }
463            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
464                eprintln!("bash not found, skipping syntax check");
465            }
466            Err(e) => panic!("failed to run bash -n: {}", e),
467        }
468    }
469
470    /// Verify fish script has valid syntax using `fish --no-execute`.
471    #[test]
472    fn test_fish_script_syntax() {
473        let script = generate("fish").unwrap();
474
475        let output = std::process::Command::new("fish")
476            .arg("--no-execute")
477            .stdin(std::process::Stdio::piped())
478            .stdout(std::process::Stdio::piped())
479            .stderr(std::process::Stdio::piped())
480            .spawn()
481            .and_then(|mut child| {
482                use std::io::Write;
483                child.stdin.take().unwrap().write_all(script.as_bytes())?;
484                child.wait_with_output()
485            });
486
487        match output {
488            Ok(out) => {
489                let stderr = String::from_utf8_lossy(&out.stderr);
490                assert!(
491                    out.status.success(),
492                    "fish --no-execute failed for generated fish script:\n{}",
493                    stderr
494                );
495            }
496            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
497                eprintln!("fish not found, skipping syntax check");
498            }
499            Err(e) => panic!("failed to run fish --no-execute: {}", e),
500        }
501    }
502}