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