git-worktree-wrapper 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gww/utils/shell.py ADDED
@@ -0,0 +1,894 @@
1
+ """Shell autocompletion generation utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from gww.utils.xdg import APP_NAME
9
+
10
+
11
+ def get_completion_path(shell: str) -> Path:
12
+ """Get the default installation path for completion scripts.
13
+
14
+ Args:
15
+ shell: Shell name (bash, zsh, fish).
16
+
17
+ Returns:
18
+ Path where completion script should be installed.
19
+
20
+ Raises:
21
+ ValueError: If shell is not supported.
22
+ """
23
+ home = Path.home()
24
+
25
+ if shell == "bash":
26
+ return home / ".bash_completion.d" / APP_NAME
27
+ elif shell == "zsh":
28
+ return home / ".zsh" / "completions" / f"_{APP_NAME}"
29
+ elif shell == "fish":
30
+ return home / ".config" / "fish" / "completions" / f"{APP_NAME}.fish"
31
+ else:
32
+ raise ValueError(f"Unsupported shell: {shell}. Must be one of: bash, zsh, fish")
33
+
34
+
35
+ def get_aliases_path(shell: str) -> Path | dict[str, Path]:
36
+ """Get the default installation path for alias functions.
37
+
38
+ Args:
39
+ shell: Shell name (bash, zsh, fish).
40
+
41
+ Returns:
42
+ For bash/zsh: Path where aliases script should be installed.
43
+ For fish: Dictionary mapping function name to its file path.
44
+
45
+ Raises:
46
+ ValueError: If shell is not supported.
47
+ """
48
+ home = Path.home()
49
+
50
+ if shell == "bash":
51
+ return home / ".bash_completion.d" / f"{APP_NAME}-aliases"
52
+ elif shell == "zsh":
53
+ return home / ".zsh" / "functions" / f"{APP_NAME}-aliases"
54
+ elif shell == "fish":
55
+ functions_dir = home / ".config" / "fish" / "functions"
56
+ return {
57
+ "gwc": functions_dir / "gwc.fish",
58
+ "gwa": functions_dir / "gwa.fish",
59
+ "gwr": functions_dir / "gwr.fish",
60
+ }
61
+ else:
62
+ raise ValueError(f"Unsupported shell: {shell}. Must be one of: bash, zsh, fish")
63
+
64
+
65
+ _BASH_REMOVE_AWK = r'''
66
+ /^worktree / { path = substr($0, 10) }
67
+ /^HEAD / { head = substr($0, 6) }
68
+ /^branch refs\/heads\// { branch = substr($0, 19) }
69
+ /^$/ {
70
+ if (skip) {
71
+ skip = 0
72
+ path = ""; branch = ""; head = ""
73
+ } else if (path != "") {
74
+ if (branch != "") {
75
+ print path
76
+ print branch
77
+ } else {
78
+ print path
79
+ }
80
+ path = ""; branch = ""; head = ""
81
+ }
82
+ }
83
+ END {
84
+ if (path != "" && !skip) {
85
+ if (branch != "") {
86
+ print path
87
+ print branch
88
+ } else {
89
+ print path
90
+ }
91
+ }
92
+ }
93
+ '''.strip("\n")
94
+
95
+
96
+ def generate_bash_completion() -> str:
97
+ """Generate bash completion script.
98
+
99
+ Returns:
100
+ Bash completion script content.
101
+ """
102
+ return f'''# Bash completion for {APP_NAME}
103
+ # Generated by {APP_NAME} init shell bash
104
+
105
+ _{APP_NAME}_completions() {{
106
+ local cur prev opts
107
+ COMPREPLY=()
108
+ cur="${{COMP_WORDS[COMP_CWORD]}}"
109
+ prev="${{COMP_WORDS[COMP_CWORD-1]}}"
110
+
111
+ # Main commands
112
+ local commands="clone add remove pull migrate init"
113
+
114
+ # Options
115
+ local global_opts="--help --verbose --quiet"
116
+
117
+ case "${{COMP_CWORD}}" in
118
+ 1)
119
+ COMPREPLY=( $(compgen -W "${{commands}}" -- "${{cur}}") )
120
+ return 0
121
+ ;;
122
+ esac
123
+
124
+ case "${{prev}}" in
125
+ clone)
126
+ # No specific completions for clone URI
127
+ return 0
128
+ ;;
129
+ add)
130
+ # Complete with branch names
131
+ if git rev-parse --git-dir > /dev/null 2>&1; then
132
+ local branches=$(git branch --format='%(refname:short)' 2>/dev/null)
133
+ local remote_branches=$(git branch -r --format='%(refname:short)' 2>/dev/null | sed 's/origin\\///')
134
+ COMPREPLY=( $(compgen -W "${{branches}} ${{remote_branches}}" -- "${{cur}}") )
135
+ fi
136
+ return 0
137
+ ;;
138
+ remove)
139
+ # Complete with worktree paths and checked-out branches only (not all branches).
140
+ # `gwr` mirrors `gww remove` which accepts a branch or a path.
141
+ if git rev-parse --git-dir > /dev/null 2>&1; then
142
+ # First entry of `git worktree list --porcelain` is the source repo — git's invariant. Skip it.
143
+ local candidates=$(git worktree list --porcelain 2>/dev/null | awk -v skip=1 '{_BASH_REMOVE_AWK}')
144
+ COMPREPLY=( $(compgen -W "${{candidates}}" -- "${{cur}}") )
145
+ fi
146
+ return 0
147
+ ;;
148
+ -t|--tag)
149
+ # Tag value is a free-form key[=value]; no completion to offer.
150
+ return 0
151
+ ;;
152
+ init)
153
+ COMPREPLY=( $(compgen -W "config shell" -- "${{cur}}") )
154
+ return 0
155
+ ;;
156
+ shell)
157
+ COMPREPLY=( $(compgen -W "bash zsh fish" -- "${{cur}}") )
158
+ return 0
159
+ ;;
160
+ migrate)
161
+ # Complete with directories
162
+ COMPREPLY=( $(compgen -d -- "${{cur}}") )
163
+ return 0
164
+ ;;
165
+ -c|--create-branch)
166
+ return 0
167
+ ;;
168
+ -f|--force)
169
+ return 0
170
+ ;;
171
+ --dry-run|-n|--move)
172
+ return 0
173
+ ;;
174
+ esac
175
+
176
+ # Default to global options
177
+ COMPREPLY=( $(compgen -W "${{global_opts}}" -- "${{cur}}") )
178
+ return 0
179
+ }}
180
+
181
+ complete -F _{APP_NAME}_completions {APP_NAME}
182
+ '''
183
+
184
+
185
+ _ZSH_REMOVE_AWK = r'''
186
+ /^worktree / { path = substr($0, 10) }
187
+ /^HEAD / { head = substr($0, 6) }
188
+ /^branch refs\/heads\// { branch = substr($0, 19) }
189
+ /^$/ {
190
+ if (skip) {
191
+ skip = 0
192
+ path = ""; branch = ""; head = ""
193
+ } else if (path != "") {
194
+ if (branch != "") {
195
+ print path "\tpath (branch: " branch ")"
196
+ print branch "\tbranch (worktree at " path ")"
197
+ } else {
198
+ print path "\tpath (detached at " substr(head, 1, 7) ")"
199
+ }
200
+ path = ""; branch = ""; head = ""
201
+ }
202
+ }
203
+ END {
204
+ if (path != "" && !skip) {
205
+ if (branch != "") {
206
+ print path "\tpath (branch: " branch ")"
207
+ print branch "\tbranch (worktree at " path ")"
208
+ } else {
209
+ print path "\tpath (detached at " substr(head, 1, 7) ")"
210
+ }
211
+ }
212
+ }
213
+ '''.strip("\n")
214
+
215
+
216
+ def generate_zsh_completion() -> str:
217
+ """Generate zsh completion script.
218
+
219
+ Returns:
220
+ Zsh completion script content.
221
+ """
222
+ return f'''#compdef {APP_NAME}
223
+ # Zsh completion for {APP_NAME}
224
+ # Generated by {APP_NAME} init shell zsh
225
+
226
+ _{APP_NAME}() {{
227
+ local -a commands
228
+ commands=(
229
+ 'clone:Clone a repository to configured location'
230
+ 'add:Add a worktree for a branch'
231
+ 'remove:Remove a worktree'
232
+ 'pull:Update source repository'
233
+ 'migrate:Migrate repositories to new locations'
234
+ 'init:Initialize config or shell completion'
235
+ )
236
+
237
+ local -a init_commands
238
+ init_commands=(
239
+ 'config:Create default configuration file'
240
+ 'shell:Install shell completion'
241
+ )
242
+
243
+ local -a shells
244
+ shells=(bash zsh fish)
245
+
246
+ _arguments -C \\
247
+ '1: :->command' \\
248
+ '*: :->args' \\
249
+ && return 0
250
+
251
+ case $state in
252
+ command)
253
+ _describe -t commands '{APP_NAME} command' commands
254
+ ;;
255
+ args)
256
+ case $words[2] in
257
+ clone)
258
+ _message 'repository URI'
259
+ ;;
260
+ add)
261
+ _arguments \\
262
+ '-c[Create branch if not exists]' \\
263
+ '--create-branch[Create branch if not exists]' \\
264
+ '1:branch:_git_branch_names'
265
+ ;;
266
+ remove)
267
+ _arguments \\
268
+ '-f[Force removal]' \\
269
+ '--force[Force removal]' \\
270
+ '-t[Tag in key=value form]:tag:' \\
271
+ '--tag[Tag in key=value form]:tag:' \\
272
+ '1:branch or path:_{APP_NAME}_worktrees'
273
+ ;;
274
+ pull)
275
+ ;;
276
+ migrate)
277
+ _arguments \\
278
+ '-n[Dry run]' \\
279
+ '--dry-run[Dry run]' \\
280
+ '--move[Move instead of copy]' \\
281
+ '1:old repos directory:_files -/'
282
+ ;;
283
+ init)
284
+ case $words[3] in
285
+ shell)
286
+ _describe -t shells 'shell' shells
287
+ ;;
288
+ *)
289
+ _describe -t init_commands 'init command' init_commands
290
+ ;;
291
+ esac
292
+ ;;
293
+ esac
294
+ ;;
295
+ esac
296
+ }}
297
+
298
+ _{APP_NAME}_worktrees() {{
299
+ local -a candidates
300
+ if git rev-parse --git-dir > /dev/null 2>&1; then
301
+ # First entry of `git worktree list --porcelain` is the source repo — git's invariant. Skip it.
302
+ local raw=$(git worktree list --porcelain 2>/dev/null | awk -v skip=1 '{_ZSH_REMOVE_AWK}')
303
+ local IFS=$'\\n'
304
+ for entry in $raw; do
305
+ candidates+=("$entry")
306
+ done
307
+ fi
308
+ _describe -t worktrees 'worktree' candidates
309
+ }}
310
+
311
+ _git_branch_names() {{
312
+ local -a branches
313
+ if git rev-parse --git-dir > /dev/null 2>&1; then
314
+ branches=($(git branch --format='%(refname:short)' 2>/dev/null))
315
+ _describe -t branches 'branch' branches
316
+ fi
317
+ }}
318
+
319
+ _{APP_NAME} "$@"
320
+ '''
321
+
322
+
323
+ _FISH_REMOVE_AWK = r'''
324
+ /^worktree / { path = substr($0, 10) }
325
+ /^HEAD / { head = substr($0, 6) }
326
+ /^branch refs\/heads\// { branch = substr($0, 19) }
327
+ /^$/ {
328
+ if (skip) {
329
+ skip = 0
330
+ path = ""; branch = ""; head = ""
331
+ } else if (path != "") {
332
+ if (branch != "") {
333
+ print path "\tpath (branch: " branch ")"
334
+ print branch "\tbranch (worktree at " path ")"
335
+ } else {
336
+ print path "\tpath (detached at " substr(head, 1, 7) ")"
337
+ }
338
+ path = ""; branch = ""; head = ""
339
+ }
340
+ }
341
+ END {
342
+ if (path != "" && !skip) {
343
+ if (branch != "") {
344
+ print path "\tpath (branch: " branch ")"
345
+ print branch "\tbranch (worktree at " path ")"
346
+ } else {
347
+ print path "\tpath (detached at " substr(head, 1, 7) ")"
348
+ }
349
+ }
350
+ }
351
+ '''.strip("\n")
352
+
353
+
354
+ def generate_fish_completion() -> str:
355
+ """Generate fish completion script.
356
+
357
+ Returns:
358
+ Fish completion script content.
359
+ """
360
+ return f'''# Fish completion for {APP_NAME}
361
+ # Generated by {APP_NAME} init shell fish
362
+
363
+ # Source git.fish to import __fish_git_branches and other git completion functions
364
+ # This uses $__fish_data_dir which is set by fish at runtime
365
+ if test -f $__fish_data_dir/completions/git.fish
366
+ source $__fish_data_dir/completions/git.fish
367
+ end
368
+
369
+ # Disable file completions for all commands
370
+ complete -c {APP_NAME} -f
371
+
372
+ # Main commands
373
+ complete -c {APP_NAME} -n __fish_use_subcommand -a clone -d 'Clone a repository'
374
+ complete -c {APP_NAME} -n __fish_use_subcommand -a add -d 'Add a worktree'
375
+ complete -c {APP_NAME} -n __fish_use_subcommand -a remove -d 'Remove a worktree'
376
+ complete -c {APP_NAME} -n __fish_use_subcommand -a pull -d 'Update source repository'
377
+ complete -c {APP_NAME} -n __fish_use_subcommand -a migrate -d 'Migrate repositories'
378
+ complete -c {APP_NAME} -n __fish_use_subcommand -a init -d 'Initialize config or completion'
379
+
380
+ # clone completions
381
+ # (no specific completions for URI)
382
+
383
+ # add completions
384
+ complete -c {APP_NAME} -n '__fish_seen_subcommand_from add' -s c -l create-branch -d 'Create branch if not exists'
385
+ complete -c {APP_NAME} -n '__fish_seen_subcommand_from add' -a '(__fish_git_branches)'
386
+
387
+ # remove completions — only worktree paths and checked-out branches, not all branches.
388
+ # First entry of `git worktree list --porcelain` is the source repo — git's invariant. Skip it.
389
+ function __{APP_NAME}_remove_worktrees
390
+ git worktree list --porcelain 2>/dev/null | awk -v skip=1 '{_FISH_REMOVE_AWK}'
391
+ end
392
+ complete -c {APP_NAME} -n '__fish_seen_subcommand_from remove' -s f -l force -d 'Force removal'
393
+ complete -c {APP_NAME} -n '__fish_seen_subcommand_from remove' -s t -l tag -d 'Tag in key=value form'
394
+ complete -c {APP_NAME} -n '__fish_seen_subcommand_from remove' -a '(__{APP_NAME}_remove_worktrees)'
395
+
396
+ # migrate completions
397
+ complete -c {APP_NAME} -n '__fish_seen_subcommand_from migrate' -s n -l dry-run -d 'Show what would be migrated'
398
+ complete -c {APP_NAME} -n '__fish_seen_subcommand_from migrate' -l move -d 'Move instead of copy'
399
+ complete -c {APP_NAME} -n '__fish_seen_subcommand_from migrate' -a '(__fish_complete_directories)'
400
+
401
+ # init subcommands
402
+ complete -c {APP_NAME} -n '__fish_seen_subcommand_from init; and not __fish_seen_subcommand_from config shell' -a config -d 'Create default config file'
403
+ complete -c {APP_NAME} -n '__fish_seen_subcommand_from init; and not __fish_seen_subcommand_from config shell' -a shell -d 'Install shell completion'
404
+
405
+ # init shell completions
406
+ complete -c {APP_NAME} -n '__fish_seen_subcommand_from init; and __fish_seen_subcommand_from shell' -a 'bash zsh fish'
407
+
408
+ # Global options
409
+ complete -c {APP_NAME} -s h -l help -d 'Show help'
410
+ complete -c {APP_NAME} -s v -l verbose -d 'Increase verbosity'
411
+ complete -c {APP_NAME} -s q -l quiet -d 'Suppress output'
412
+ '''
413
+
414
+
415
+ def generate_bash_aliases() -> str:
416
+ """Generate bash alias functions for gwc, gwa, and gwr.
417
+
418
+ Returns:
419
+ Bash function definitions for gwc (clone), gwa (add), and gwr (remove).
420
+ """
421
+ return f'''# Alias functions for {APP_NAME}
422
+ # Generated by {APP_NAME} init shell bash
423
+
424
+ # gwc - Clone a repository and navigate to it
425
+ # Streams git's progress (stderr) to the terminal in real time.
426
+ # Only stdout's last line (the new path) is captured for navigation.
427
+ gwc() {{
428
+ local target_path
429
+ local exit_code
430
+ # `set -o pipefail` inside the subshell so $? reflects `gww clone`'s exit,
431
+ # not `tail`'s. Stderr from the subshell still streams to the terminal.
432
+ target_path=$(set -o pipefail; command {APP_NAME} clone "$@" | tail -n 1)
433
+ exit_code=$?
434
+ if [ $exit_code -eq 0 ]; then
435
+ if [ -n "$target_path" ] && [ -d "$target_path" ]; then
436
+ printf "Navigate to %s? [Y/n] " "$target_path"
437
+ read -r reply
438
+ if [ -z "$reply" ] || [ "$reply" = "y" ] || [ "$reply" = "Y" ]; then
439
+ cd "$target_path" || return 1
440
+ fi
441
+ fi
442
+ else
443
+ return $exit_code
444
+ fi
445
+ }}
446
+
447
+ # gwa - Add a worktree and navigate to it
448
+ # Streams git's progress (stderr) to the terminal in real time.
449
+ # Only stdout's last line (the new path) is captured for navigation.
450
+ gwa() {{
451
+ local target_path
452
+ local exit_code
453
+ target_path=$(set -o pipefail; command {APP_NAME} add "$@" | tail -n 1)
454
+ exit_code=$?
455
+ if [ $exit_code -eq 0 ]; then
456
+ if [ -n "$target_path" ] && [ -d "$target_path" ]; then
457
+ printf "Navigate to %s? [Y/n] " "$target_path"
458
+ read -r reply
459
+ if [ -z "$reply" ] || [ "$reply" = "y" ] || [ "$reply" = "Y" ]; then
460
+ cd "$target_path" || return 1
461
+ fi
462
+ fi
463
+ else
464
+ return $exit_code
465
+ fi
466
+ }}
467
+
468
+ # gwr - Remove a worktree (prompts for force if dirty)
469
+ # Streams stdout to the terminal; only stderr is captured so the dirty-state
470
+ # check (looking for "uncommitted changes" / "untracked files") still works.
471
+ gwr() {{
472
+ local errfile
473
+ local exit_code
474
+ errfile=$(mktemp)
475
+ command {APP_NAME} remove "$@" 2>"$errfile"
476
+ exit_code=$?
477
+ if [ $exit_code -eq 0 ]; then
478
+ rm -f "$errfile"
479
+ elif grep -q "uncommitted changes\\|untracked files" "$errfile"; then
480
+ cat "$errfile" >&2
481
+ rm -f "$errfile"
482
+ printf "Force removal? [y/N] "
483
+ read -r reply
484
+ if [ "$reply" = "y" ] || [ "$reply" = "Y" ]; then
485
+ command {APP_NAME} remove --force "$@"
486
+ else
487
+ return 1
488
+ fi
489
+ else
490
+ cat "$errfile" >&2
491
+ rm -f "$errfile"
492
+ return $exit_code
493
+ fi
494
+ }}
495
+
496
+ # Completion wrappers - reuse gww completions for aliases
497
+ _gwc_completions() {{
498
+ COMP_WORDS=({APP_NAME} clone "${{COMP_WORDS[@]:1}}")
499
+ COMP_CWORD=$((COMP_CWORD + 1))
500
+ _{APP_NAME}_completions
501
+ }}
502
+
503
+ _gwa_completions() {{
504
+ COMP_WORDS=({APP_NAME} add "${{COMP_WORDS[@]:1}}")
505
+ COMP_CWORD=$((COMP_CWORD + 1))
506
+ _{APP_NAME}_completions
507
+ }}
508
+
509
+ _gwr_completions() {{
510
+ COMP_WORDS=({APP_NAME} remove "${{COMP_WORDS[@]:1}}")
511
+ COMP_CWORD=$((COMP_CWORD + 1))
512
+ _{APP_NAME}_completions
513
+ }}
514
+
515
+ complete -F _gwc_completions gwc
516
+ complete -F _gwa_completions gwa
517
+ complete -F _gwr_completions gwr
518
+ '''
519
+
520
+
521
+ def generate_zsh_aliases() -> str:
522
+ """Generate zsh alias functions for gwc, gwa, and gwr.
523
+
524
+ Returns:
525
+ Zsh function definitions for gwc (clone), gwa (add), and gwr (remove).
526
+ """
527
+ return f'''# Alias functions for {APP_NAME}
528
+ # Generated by {APP_NAME} init shell zsh
529
+
530
+ # gwc - Clone a repository and navigate to it
531
+ # Streams git's progress (stderr) to the terminal in real time.
532
+ # Only stdout's last line (the new path) is captured for navigation.
533
+ gwc() {{
534
+ local target_path
535
+ local exit_code
536
+ # `setopt pipefail` inside the subshell so $? reflects `gww clone`'s exit,
537
+ # not `tail`'s. Stderr from the subshell still streams to the terminal.
538
+ target_path=$(setopt pipefail; command {APP_NAME} clone "$@" | tail -n 1)
539
+ exit_code=$?
540
+ if [[ $exit_code -eq 0 ]]; then
541
+ if [[ -n "$target_path" ]] && [[ -d "$target_path" ]]; then
542
+ printf "Navigate to %s? [Y/n] " "$target_path"
543
+ read -r reply
544
+ if [[ -z "$reply" ]] || [[ "$reply" == "y" ]] || [[ "$reply" == "Y" ]]; then
545
+ cd "$target_path" || return 1
546
+ fi
547
+ fi
548
+ else
549
+ return $exit_code
550
+ fi
551
+ }}
552
+
553
+ # gwa - Add a worktree and navigate to it
554
+ # Streams git's progress (stderr) to the terminal in real time.
555
+ # Only stdout's last line (the new path) is captured for navigation.
556
+ gwa() {{
557
+ local target_path
558
+ local exit_code
559
+ target_path=$(setopt pipefail; command {APP_NAME} add "$@" | tail -n 1)
560
+ exit_code=$?
561
+ if [[ $exit_code -eq 0 ]]; then
562
+ if [[ -n "$target_path" ]] && [[ -d "$target_path" ]]; then
563
+ printf "Navigate to %s? [Y/n] " "$target_path"
564
+ read -r reply
565
+ if [[ -z "$reply" ]] || [[ "$reply" == "y" ]] || [[ "$reply" == "Y" ]]; then
566
+ cd "$target_path" || return 1
567
+ fi
568
+ fi
569
+ else
570
+ return $exit_code
571
+ fi
572
+ }}
573
+
574
+ # gwr - Remove a worktree (prompts for force if dirty)
575
+ # Streams stdout to the terminal; only stderr is captured so the dirty-state
576
+ # check (looking for "uncommitted changes" / "untracked files") still works.
577
+ gwr() {{
578
+ local errfile
579
+ local exit_code
580
+ errfile=$(mktemp)
581
+ command {APP_NAME} remove "$@" 2>"$errfile"
582
+ exit_code=$?
583
+ if [[ $exit_code -eq 0 ]]; then
584
+ rm -f "$errfile"
585
+ elif grep -q "uncommitted changes\\|untracked files" "$errfile"; then
586
+ cat "$errfile" >&2
587
+ rm -f "$errfile"
588
+ printf "Force removal? [y/N] "
589
+ read -r reply
590
+ if [[ "$reply" == "y" ]] || [[ "$reply" == "Y" ]]; then
591
+ command {APP_NAME} remove --force "$@"
592
+ else
593
+ return 1
594
+ fi
595
+ else
596
+ cat "$errfile" >&2
597
+ rm -f "$errfile"
598
+ return $exit_code
599
+ fi
600
+ }}
601
+
602
+ # Completion wrappers - reuse gww completions for aliases
603
+ _gwc() {{
604
+ words[1]={APP_NAME}
605
+ words=(clone "${{words[@]:1}}")
606
+ ((CURRENT++))
607
+ _{APP_NAME}
608
+ }}
609
+
610
+ _gwa() {{
611
+ words[1]={APP_NAME}
612
+ words=(add "${{words[@]:1}}")
613
+ ((CURRENT++))
614
+ _{APP_NAME}
615
+ }}
616
+
617
+ _gwr() {{
618
+ words[1]={APP_NAME}
619
+ words=(remove "${{words[@]:1}}")
620
+ ((CURRENT++))
621
+ _{APP_NAME}
622
+ }}
623
+
624
+ compdef _gwc gwc
625
+ compdef _gwa gwa
626
+ compdef _gwr gwr
627
+ '''
628
+
629
+
630
+ def generate_fish_aliases() -> dict[str, str]:
631
+ """Generate fish alias functions for gwc, gwa, and gwr.
632
+
633
+ Returns:
634
+ Dictionary mapping function name to fish function content.
635
+ Fish convention: one function per file.
636
+ """
637
+ gwc_content = f'''# gwc - Clone a repository and navigate to it
638
+ # Generated by {APP_NAME} init shell fish
639
+ #
640
+ # Streams git's progress (stderr) to the terminal in real time.
641
+ # Only stdout's last line (the new path) is captured for navigation.
642
+
643
+ function gwc --wraps="{APP_NAME} clone" --description "Clone a repository and navigate to it"
644
+ set -l target_path (command {APP_NAME} clone $argv | tail -n 1)
645
+ set -l exit_code $pipestatus[1]
646
+ if test $exit_code -eq 0
647
+ if test -n "$target_path" -a -d "$target_path"
648
+ read -P "Navigate to $target_path? [Y/n] " reply
649
+ if test -z "$reply" -o "$reply" = "y" -o "$reply" = "Y"
650
+ cd "$target_path"
651
+ commandline -f repaint
652
+ end
653
+ end
654
+ else
655
+ return $exit_code
656
+ end
657
+ end
658
+ '''
659
+
660
+ gwa_content = f'''# gwa - Add a worktree and navigate to it
661
+ # Generated by {APP_NAME} init shell fish
662
+ #
663
+ # Streams git's progress (stderr) to the terminal in real time.
664
+ # Only stdout's last line (the new path) is captured for navigation.
665
+
666
+ function gwa --wraps="{APP_NAME} add" --description "Add a worktree and navigate to it"
667
+ set -l target_path (command {APP_NAME} add $argv | tail -n 1)
668
+ set -l exit_code $pipestatus[1]
669
+ if test $exit_code -eq 0
670
+ if test -n "$target_path" -a -d "$target_path"
671
+ read -P "Navigate to $target_path? [Y/n] " reply
672
+ if test -z "$reply" -o "$reply" = "y" -o "$reply" = "Y"
673
+ cd "$target_path"
674
+ commandline -f repaint
675
+ end
676
+ end
677
+ else
678
+ return $exit_code
679
+ end
680
+ end
681
+ '''
682
+
683
+ gwr_content = f'''# gwr - Remove a worktree (prompts for force if dirty)
684
+ # Generated by {APP_NAME} init shell fish
685
+ #
686
+ # Streams stdout to the terminal; only stderr is captured so the dirty-state
687
+ # check (looking for "uncommitted changes" / "untracked files") still works.
688
+
689
+ function gwr --wraps="{APP_NAME} remove" --description "Remove a worktree (prompts for force if dirty)"
690
+ set -l errfile (mktemp)
691
+ command {APP_NAME} remove $argv 2>$errfile
692
+ set -l exit_code $status
693
+ if test $exit_code -eq 0
694
+ rm -f $errfile
695
+ commandline -f repaint
696
+ else if grep -q -E "uncommitted changes|untracked files" $errfile
697
+ cat $errfile >&2
698
+ rm -f $errfile
699
+ read -P "Force removal? [y/N] " reply
700
+ if test "$reply" = "y" -o "$reply" = "Y"
701
+ command {APP_NAME} remove --force $argv
702
+ commandline -f repaint
703
+ else
704
+ commandline -f repaint
705
+ return 1
706
+ end
707
+ else
708
+ cat $errfile >&2
709
+ rm -f $errfile
710
+ commandline -f repaint
711
+ return $exit_code
712
+ end
713
+ end
714
+ '''
715
+
716
+ return {"gwc": gwc_content, "gwa": gwa_content, "gwr": gwr_content}
717
+
718
+
719
+ def generate_completion(shell: str) -> str:
720
+ """Generate completion script for specified shell.
721
+
722
+ Args:
723
+ shell: Shell name (bash, zsh, fish).
724
+
725
+ Returns:
726
+ Completion script content.
727
+
728
+ Raises:
729
+ ValueError: If shell is not supported.
730
+ """
731
+ if shell == "bash":
732
+ return generate_bash_completion()
733
+ elif shell == "zsh":
734
+ return generate_zsh_completion()
735
+ elif shell == "fish":
736
+ return generate_fish_completion()
737
+ else:
738
+ raise ValueError(f"Unsupported shell: {shell}. Must be one of: bash, zsh, fish")
739
+
740
+
741
+ def install_completion(shell: str, path: Optional[Path] = None) -> Path:
742
+ """Install completion script for specified shell.
743
+
744
+ Args:
745
+ shell: Shell name (bash, zsh, fish).
746
+ path: Custom installation path (optional).
747
+
748
+ Returns:
749
+ Path where script was installed.
750
+
751
+ Raises:
752
+ ValueError: If shell is not supported.
753
+ OSError: If installation fails.
754
+ """
755
+ if path is None:
756
+ path = get_completion_path(shell)
757
+
758
+ script = generate_completion(shell)
759
+
760
+ # Ensure parent directory exists
761
+ path.parent.mkdir(parents=True, exist_ok=True)
762
+
763
+ # Write script
764
+ path.write_text(script)
765
+
766
+ return path
767
+
768
+
769
+ def install_aliases(shell: str) -> Path | list[Path]:
770
+ """Install alias functions for specified shell.
771
+
772
+ Args:
773
+ shell: Shell name (bash, zsh, fish).
774
+
775
+ Returns:
776
+ For bash/zsh: Path where aliases script was installed.
777
+ For fish: List of paths where function files were installed.
778
+
779
+ Raises:
780
+ ValueError: If shell is not supported.
781
+ OSError: If installation fails.
782
+ """
783
+ if shell == "bash":
784
+ path = get_aliases_path(shell)
785
+ assert isinstance(path, Path)
786
+ script = generate_bash_aliases()
787
+ path.parent.mkdir(parents=True, exist_ok=True)
788
+ path.write_text(script)
789
+ return path
790
+ elif shell == "zsh":
791
+ path = get_aliases_path(shell)
792
+ assert isinstance(path, Path)
793
+ script = generate_zsh_aliases()
794
+ path.parent.mkdir(parents=True, exist_ok=True)
795
+ path.write_text(script)
796
+ return path
797
+ elif shell == "fish":
798
+ paths_dict = get_aliases_path(shell)
799
+ assert isinstance(paths_dict, dict)
800
+ functions = generate_fish_aliases()
801
+ installed_paths = []
802
+ for name, content in functions.items():
803
+ path = paths_dict[name]
804
+ path.parent.mkdir(parents=True, exist_ok=True)
805
+ path.write_text(content)
806
+ installed_paths.append(path)
807
+ return installed_paths
808
+ else:
809
+ raise ValueError(f"Unsupported shell: {shell}. Must be one of: bash, zsh, fish")
810
+
811
+
812
+ def get_installation_instructions(
813
+ shell: str,
814
+ completion_path: Path,
815
+ aliases_path: Path | list[Path] | None = None,
816
+ ) -> str:
817
+ """Get instructions for activating completion and aliases after installation.
818
+
819
+ Args:
820
+ shell: Shell name.
821
+ completion_path: Path where completion was installed.
822
+ aliases_path: Path(s) where aliases were installed (optional).
823
+
824
+ Returns:
825
+ Human-readable activation instructions.
826
+ """
827
+ if shell == "bash":
828
+ instructions = (
829
+ f"Installed bash completion script: {completion_path}\n"
830
+ )
831
+ if aliases_path:
832
+ assert isinstance(aliases_path, Path)
833
+ instructions += f"Installed bash alias functions: {aliases_path}\n"
834
+ instructions += (
835
+ f"\nTo activate, add to ~/.bashrc:\n"
836
+ f" source {completion_path}\n"
837
+ f" source {aliases_path}\n"
838
+ f"\nAlias functions installed:\n"
839
+ f" gwc - Clone a repository and navigate to it\n"
840
+ f" gwa - Add a worktree and navigate to it\n"
841
+ f" gwr - Remove a worktree (prompts for force if dirty)"
842
+ )
843
+ else:
844
+ instructions += (
845
+ f"To activate, run: source {completion_path}\n"
846
+ f"Or add to ~/.bashrc: source {completion_path}"
847
+ )
848
+ return instructions
849
+ elif shell == "zsh":
850
+ instructions = (
851
+ f"Installed zsh completion script: {completion_path}\n"
852
+ )
853
+ if aliases_path:
854
+ assert isinstance(aliases_path, Path)
855
+ instructions += f"Installed zsh alias functions: {aliases_path}\n"
856
+ instructions += (
857
+ f"\nTo activate, add to ~/.zshrc:\n"
858
+ f" fpath=(~/.zsh/completions $fpath)\n"
859
+ f" autoload -Uz compinit && compinit\n"
860
+ f" source {aliases_path}\n"
861
+ f"\nAlias functions installed:\n"
862
+ f" gwc - Clone a repository and navigate to it\n"
863
+ f" gwa - Add a worktree and navigate to it\n"
864
+ f" gwr - Remove a worktree (prompts for force if dirty)\n"
865
+ f"\nThen restart your shell."
866
+ )
867
+ else:
868
+ instructions += (
869
+ f"To activate, ensure your ~/.zshrc contains:\n"
870
+ f" fpath=(~/.zsh/completions $fpath)\n"
871
+ f" autoload -Uz compinit && compinit\n"
872
+ f"Then restart your shell."
873
+ )
874
+ return instructions
875
+ elif shell == "fish":
876
+ instructions = (
877
+ f"Installed fish completion script: {completion_path}\n"
878
+ )
879
+ if aliases_path:
880
+ assert isinstance(aliases_path, list)
881
+ for p in aliases_path:
882
+ instructions += f"Installed fish function: {p}\n"
883
+ instructions += (
884
+ f"\nAlias functions installed:\n"
885
+ f" gwc - Clone a repository and navigate to it\n"
886
+ f" gwa - Add a worktree and navigate to it\n"
887
+ f" gwr - Remove a worktree (prompts for force if dirty)\n"
888
+ f"\nRestart fish shell to activate."
889
+ )
890
+ else:
891
+ instructions += f"Restart fish shell or run: source {completion_path}"
892
+ return instructions
893
+ else:
894
+ return f"Installed completion script: {completion_path}"