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 or worktree name.
19# If no argument is provided, show interactive worktree selector.
20gw-cd() {
21    local target=""
22
23    # Parse arguments
24    while [ $# -gt 0 ]; do
25        case "$1" in
26            -*)
27                echo "Error: Unknown option '$1'" >&2
28                echo "Usage: gw-cd [branch|worktree-name]" >&2
29                return 1
30                ;;
31            *)
32                target="$1"
33                shift
34                ;;
35        esac
36    done
37
38    local worktree_path
39
40    if [ -z "$target" ]; then
41        # No argument — interactive selector
42        worktree_path=$(gw _path --interactive)
43        if [ $? -ne 0 ]; then return 1; fi
44    else
45        # Resolve via git worktree list (branch name or worktree name)
46        worktree_path=$(git worktree list --porcelain 2>/dev/null | awk -v t="$target" '
47            /^worktree / { path=$2; name=path; sub(".*/", "", name) }
48            /^branch / && $2 == "refs/heads/"t { print path; exit }
49            /^worktree / && name == t { found_path=path }
50            END { if (found_path != "") print found_path }
51        ')
52    fi
53
54    if [ -z "$worktree_path" ]; then
55        echo "Error: No worktree found for '$target'" >&2
56        return 1
57    fi
58
59    if [ -d "$worktree_path" ]; then
60        cd "$worktree_path" || return 1
61        echo "Switched to worktree: $worktree_path"
62    else
63        echo "Error: Worktree directory not found: $worktree_path" >&2
64        return 1
65    fi
66}
67
68# Tab completion for gw-cd (bash)
69_gw_cd_completion() {
70    local cur="${COMP_WORDS[COMP_CWORD]}"
71
72    local targets
73    targets=$(gw _complete-targets 2>/dev/null)
74    COMPREPLY=($(compgen -W "$targets" -- "$cur"))
75}
76
77# Register completion for bash
78if [ -n "$BASH_VERSION" ]; then
79    complete -F _gw_cd_completion gw-cd
80    complete -F _gw_cd_completion cw-cd
81    eval "$(gw --generate-completion bash 2>/dev/null || true)"
82
83    # Wrap _gw to add dynamic completion for positional-target subcommands
84    _gw_with_config() {
85        local cur="${COMP_WORDS[COMP_CWORD]}"
86        local subcmd="${COMP_WORDS[1]}"
87
88        if [[ "$cur" != -* ]]; then
89            local pos_count=0
90            local start_idx=2
91            local max_pos=0
92
93            case "$subcmd" in
94                rm|resume|spawn|exec)
95                    max_pos=1
96                    ;;
97            esac
98
99            if [[ $max_pos -gt 0 ]]; then
100                local i
101                for ((i=start_idx; i<COMP_CWORD; i++)); do
102                    [[ ${COMP_WORDS[i]} != -* ]] && ((pos_count++))
103                done
104                if [[ $pos_count -lt $max_pos ]]; then
105                    local targets
106                    targets=$(gw _complete-targets 2>/dev/null)
107                    COMPREPLY=($(compgen -W "$targets" -- "$cur"))
108                    return
109                fi
110            fi
111        fi
112
113        _gw "$@"
114    }
115    complete -F _gw_with_config -o bashdefault -o default gw
116    complete -F _gw_with_config -o bashdefault -o default cw
117fi
118
119# Tab completion for zsh
120if [ -n "$ZSH_VERSION" ]; then
121    # Register clap completion for gw/cw CLI inline
122    eval "$(gw --generate-completion zsh 2>/dev/null)"
123
124    # Wrap _gw to add dynamic completion (targets)
125    _gw_with_config() {
126        local subcmd="${words[2]}"
127
128        if [[ "${words[CURRENT]}" != -* ]]; then
129            local -i pos_count=0
130            local -i start_idx=3
131            local -i max_pos=0
132
133            case "$subcmd" in
134                rm|resume|spawn|exec)
135                    max_pos=1
136                    ;;
137            esac
138
139            if [[ $max_pos -gt 0 ]]; then
140                local -i i
141                for ((i=start_idx; i<CURRENT; i++)); do
142                    [[ ${words[i]} != -* ]] && ((pos_count++))
143                done
144                if [[ $pos_count -lt $max_pos ]]; then
145                    local -a targets
146                    targets=(${(f)"$(gw _complete-targets 2>/dev/null)"})
147                    compadd -a targets
148                    return
149                fi
150            fi
151        fi
152
153        _gw "$@"
154    }
155    compdef _gw_with_config gw
156    compdef _gw_with_config cw
157
158    _gw_cd_zsh() {
159        local -a targets
160        targets=(${(f)"$(gw _complete-targets 2>/dev/null)"})
161        compadd -a targets
162    }
163    compdef _gw_cd_zsh gw-cd
164fi
165
166# Backward compatibility: cw-cd alias
167cw-cd() { gw-cd "$@"; }
168if [ -n "$BASH_VERSION" ]; then
169    complete -F _gw_cd_completion cw-cd
170fi
171if [ -n "$ZSH_VERSION" ]; then
172    compdef _gw_cd_zsh cw-cd
173fi
174"#;
175
176const FISH_FUNCTION: &str = r#"# git-worktree-manager shell functions for fish
177# Source this file to enable shell functions:
178#   gw _shell-function fish | source
179
180# Navigate to a worktree by branch name or worktree name.
181# If no argument is provided, show interactive worktree selector.
182function gw-cd
183    set -l target ""
184
185    # Parse arguments
186    for arg in $argv
187        switch $arg
188            case '-*'
189                echo "Error: Unknown option '$arg'" >&2
190                echo "Usage: gw-cd [branch|worktree-name]" >&2
191                return 1
192            case '*'
193                set target $arg
194        end
195    end
196
197    set -l worktree_path
198
199    if test -z "$target"
200        # No argument — interactive selector
201        set worktree_path (gw _path --interactive)
202        if test $status -ne 0
203            return 1
204        end
205    else
206        # Resolve via git worktree list (branch name or worktree name)
207        set worktree_path (git worktree list --porcelain 2>/dev/null | awk -v t="$target" '
208            /^worktree / { path=$2; name=path; sub(".*/", "", name) }
209            /^branch / && $2 == "refs/heads/"t { print path; exit }
210            /^worktree / && name == t { found_path=path }
211            END { if (found_path != "") print found_path }
212        ')
213    end
214
215    if test -z "$worktree_path"
216        echo "Error: No worktree found for '$target'" >&2
217        return 1
218    end
219
220    if test -d "$worktree_path"
221        cd "$worktree_path"; or return 1
222        echo "Switched to worktree: $worktree_path"
223    else
224        echo "Error: Worktree directory not found: $worktree_path" >&2
225        return 1
226    end
227end
228
229# Backward compatibility: cw-cd alias
230function cw-cd; gw-cd $argv; end
231complete -c cw-cd -w gw-cd
232
233# Tab completion for gw-cd
234complete -c gw-cd -f -a '(gw _complete-targets 2>/dev/null)'
235
236# Tab completion for gw/cw CLI (clap-generated)
237gw --generate-completion fish 2>/dev/null | source
238
239# Target completion for subcommands with positional target args
240for cmd in rm resume spawn exec
241    complete -c gw -f -n "__fish_seen_subcommand_from $cmd" -a '(gw _complete-targets 2>/dev/null)'
242    complete -c cw -f -n "__fish_seen_subcommand_from $cmd" -a '(gw _complete-targets 2>/dev/null)'
243end
244
245"#;
246
247const POWERSHELL_FUNCTION: &str = r#"# git-worktree-manager shell functions for PowerShell
248# Source this file to enable shell functions:
249#   gw _shell-function powershell | Out-String | Invoke-Expression
250
251# Navigate to a worktree by branch name or worktree name.
252# If no argument is provided, show interactive worktree selector.
253function gw-cd {
254    param(
255        [Parameter(Mandatory=$false, Position=0)]
256        [string]$Target
257    )
258
259    $worktreePath = $null
260
261    if (-not $Target) {
262        # No argument — interactive selector
263        $worktreePath = gw _path --interactive
264        if ($LASTEXITCODE -ne 0) {
265            return
266        }
267    } else {
268        # Resolve via git worktree list (branch name or worktree name)
269        $lines = git worktree list --porcelain 2>&1 | Where-Object { $_ -is [string] }
270        $currentPath = $null
271        foreach ($line in $lines) {
272            if ($line -match '^worktree (.+)$') {
273                $currentPath = $Matches[1]
274            } elseif ($line -match "^branch refs/heads/$([regex]::Escape($Target))$") {
275                $worktreePath = $currentPath
276                break
277            }
278        }
279        if (-not $worktreePath) {
280            # Try matching by worktree directory name
281            foreach ($line in $lines) {
282                if ($line -match '^worktree (.+)$') {
283                    $p = $Matches[1]
284                    if ([System.IO.Path]::GetFileName($p) -eq $Target) {
285                        $worktreePath = $p
286                        break
287                    }
288                }
289            }
290        }
291    }
292
293    if (-not $worktreePath) {
294        Write-Error "Error: No worktree found for '$Target'"
295        return
296    }
297
298    if (Test-Path -Path $worktreePath -PathType Container) {
299        Set-Location -Path $worktreePath
300        Write-Host "Switched to worktree: $worktreePath"
301    } else {
302        Write-Error "Error: Worktree directory not found: $worktreePath"
303        return
304    }
305}
306
307# Backward compatibility: cw-cd alias
308Set-Alias -Name cw-cd -Value gw-cd
309
310# Tab completion for gw-cd
311Register-ArgumentCompleter -CommandName gw-cd -ParameterName Target -ScriptBlock {
312    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
313
314    $targets = gw _complete-targets 2>&1 |
315        Where-Object { $_ -is [string] -and $_.Trim() } |
316        Sort-Object -Unique
317
318    $targets | Where-Object { $_ -like "$wordToComplete*" } |
319        ForEach-Object {
320            [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
321        }
322}
323
324# Tab completion for cw-cd (backward compat)
325Register-ArgumentCompleter -CommandName cw-cd -ParameterName Target -ScriptBlock {
326    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
327
328    $targets = gw _complete-targets 2>&1 |
329        Where-Object { $_ -is [string] -and $_.Trim() } |
330        Sort-Object -Unique
331
332    $targets | Where-Object { $_ -like "$wordToComplete*" } |
333        ForEach-Object {
334            [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
335        }
336}
337
338# Native target completion for `gw` / `cw` subcommands: rm, resume, spawn, exec
339Register-ArgumentCompleter -CommandName gw, cw -Native -ScriptBlock {
340    param($wordToComplete, $commandAst, $cursorPosition)
341
342    # Find the subcommand (first non-flag element after the command name)
343    $elements = $commandAst.CommandElements
344    if ($elements.Count -lt 2) { return }
345    $subcmd = $elements[1].Value
346
347    # Only complete positional targets for these subcommands
348    if ($subcmd -notin @('rm', 'resume', 'spawn', 'exec')) { return }
349
350    # Get completion targets from gw
351    $targets = & gw _complete-targets 2>$null
352
353    $targets | Where-Object { $_ -like "$wordToComplete*" } |
354        ForEach-Object {
355            [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
356        }
357}
358"#;
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn test_generate_bash() {
366        let result = generate("bash");
367        assert!(result.is_some());
368        let script = result.unwrap();
369        assert!(script.contains("gw-cd()"));
370        assert!(script.contains("_gw_cd_completion"));
371        assert!(script.contains("cw-cd"));
372        assert!(script.contains("BASH_VERSION"));
373        assert!(script.contains("ZSH_VERSION"));
374        assert!(script.contains("_gw_cd_zsh"));
375    }
376
377    #[test]
378    fn test_generate_zsh() {
379        let result = generate("zsh");
380        assert!(result.is_some());
381        let script = result.unwrap();
382        assert!(script.contains("compdef _gw_cd_zsh gw-cd"));
383        assert!(script.contains("compdef _gw_cd_zsh cw-cd"));
384    }
385
386    #[test]
387    fn test_generate_fish() {
388        let result = generate("fish");
389        assert!(result.is_some());
390        let script = result.unwrap();
391        assert!(script.contains("function gw-cd"));
392        assert!(script.contains("complete -c gw-cd"));
393        assert!(script.contains("function cw-cd"));
394        assert!(script.contains("complete -c cw-cd -w gw-cd"));
395    }
396
397    #[test]
398    fn test_generate_powershell() {
399        let result = generate("powershell");
400        assert!(result.is_some());
401        let script = result.unwrap();
402        assert!(script.contains("function gw-cd"));
403        assert!(script.contains("Register-ArgumentCompleter"));
404        assert!(script.contains("Set-Alias -Name cw-cd -Value gw-cd"));
405    }
406
407    #[test]
408    fn test_powershell_uses_complete_targets() {
409        let script = generate("powershell").unwrap();
410        assert!(
411            script.contains("gw _complete-targets"),
412            "PowerShell script should call gw _complete-targets"
413        );
414        assert!(
415            script.contains("Register-ArgumentCompleter -CommandName gw"),
416            "PowerShell script should register a completer for `gw`"
417        );
418    }
419
420    #[test]
421    fn test_generate_pwsh_alias() {
422        let result = generate("pwsh");
423        assert!(result.is_some());
424        // pwsh should return the same as powershell
425        assert_eq!(result, generate("powershell"));
426    }
427
428    #[test]
429    fn test_generate_unknown() {
430        assert!(generate("unknown").is_none());
431        assert!(generate("").is_none());
432    }
433
434    /// Verify bash/zsh script uses _complete-targets as the completion source.
435    #[test]
436    fn test_bash_uses_complete_targets() {
437        let script = generate("bash").unwrap();
438        assert!(
439            script.contains("gw _complete-targets"),
440            "bash script should use gw _complete-targets for completions"
441        );
442        assert!(
443            !script.contains("_path --list-branches"),
444            "bash script should not reference the removed _path --list-branches"
445        );
446    }
447
448    /// Verify bash/zsh script has valid syntax using `bash -n`.
449    #[test]
450    #[cfg(not(windows))]
451    fn test_bash_script_syntax() {
452        let script = generate("bash").unwrap();
453
454        // bash -n: check syntax without executing
455        let output = std::process::Command::new("bash")
456            .arg("-n")
457            .stdin(std::process::Stdio::piped())
458            .stdout(std::process::Stdio::piped())
459            .stderr(std::process::Stdio::piped())
460            .spawn()
461            .and_then(|mut child| {
462                use std::io::Write;
463                child.stdin.take().unwrap().write_all(script.as_bytes())?;
464                child.wait_with_output()
465            });
466
467        match output {
468            Ok(out) => {
469                let stderr = String::from_utf8_lossy(&out.stderr);
470                assert!(
471                    out.status.success(),
472                    "bash -n failed for generated bash/zsh script:\n{}",
473                    stderr
474                );
475            }
476            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
477                eprintln!("bash not found, skipping syntax check");
478            }
479            Err(e) => panic!("failed to run bash -n: {}", e),
480        }
481    }
482
483    /// Verify fish script has valid syntax using `fish --no-execute`.
484    #[test]
485    fn test_fish_script_syntax() {
486        let script = generate("fish").unwrap();
487
488        let output = std::process::Command::new("fish")
489            .arg("--no-execute")
490            .stdin(std::process::Stdio::piped())
491            .stdout(std::process::Stdio::piped())
492            .stderr(std::process::Stdio::piped())
493            .spawn()
494            .and_then(|mut child| {
495                use std::io::Write;
496                child.stdin.take().unwrap().write_all(script.as_bytes())?;
497                child.wait_with_output()
498            });
499
500        match output {
501            Ok(out) => {
502                let stderr = String::from_utf8_lossy(&out.stderr);
503                assert!(
504                    out.status.success(),
505                    "fish --no-execute failed for generated fish script:\n{}",
506                    stderr
507                );
508            }
509            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
510                eprintln!("fish not found, skipping syntax check");
511            }
512            Err(e) => panic!("failed to run fish --no-execute: {}", e),
513        }
514    }
515}