Skip to content

Commit 92a64d3

Browse files
committed
fix: make shell completions work for git wt subcommand
When invoked as `git wt <TAB>`, each shell's git completion system dispatches to a shell-specific handler. However, cobra's generated completions assume the binary name is the first word (`git-wt`), so the completion request is malformed when the command line starts with `git wt`. **zsh**: `_git` uses `(-)*:: :->option-or-argument` which shifts words so that `words[1]` is the subcommand name (`wt`), not `git`. Inject a shim at the top of `_git-wt()` that rewrites `words[1]` from `wt` to `git-wt`. **bash**: `_git` calls `_git_wt()` (underscore form) which does not exist in cobra's output. Append a bridge function that rewrites COMP_WORDS from `(git wt ...)` to `(git-wt ...)` and delegates to `__start_git-wt`. **fish**: Not addressed here as fish uses a fundamentally different completion model (`complete -c`) with no automatic subcommand discovery mechanism for external tools. Closes #20 Change-Id: Id7c03ae859130c997b775c5da23f9ea26a6a6964
1 parent e01cb40 commit 92a64d3

2 files changed

Lines changed: 156 additions & 2 deletions

File tree

internal/cmd/completion.go

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package cmd
22

33
import (
4+
"bytes"
5+
"io"
46
"os"
7+
"strings"
58

69
"github.com/spf13/cobra"
710
)
@@ -14,9 +17,9 @@ var completionCmd = &cobra.Command{
1417
RunE: func(cmd *cobra.Command, args []string) error {
1518
switch args[0] {
1619
case "bash":
17-
return rootCmd.GenBashCompletion(os.Stdout)
20+
return genBashCompletion(rootCmd, os.Stdout)
1821
case "zsh":
19-
return rootCmd.GenZshCompletion(os.Stdout)
22+
return genZshCompletion(rootCmd, os.Stdout)
2023
case "fish":
2124
return rootCmd.GenFishCompletion(os.Stdout, true)
2225
default:
@@ -25,6 +28,68 @@ var completionCmd = &cobra.Command{
2528
},
2629
}
2730

31+
// bashGitSubcommandShim is appended to the bash completion so that "git wt"
32+
// completions work in addition to standalone "git-wt".
33+
//
34+
// Bash's git completion calls _git_wt (underscore form) when completing
35+
// "git wt ...". This bridge rewrites COMP_WORDS from (git wt ...) to
36+
// (git-wt ...) and delegates to cobra's generated entry point.
37+
const bashGitSubcommandShim = `
38+
# Bridge for "git wt" subcommand completion in bash.
39+
# Bash's git completion calls _git_wt() when completing "git wt ...".
40+
_git_wt() {
41+
COMP_WORDS=(git-wt "${COMP_WORDS[@]:2}")
42+
(( COMP_CWORD -= 1 ))
43+
__start_git-wt
44+
}
45+
`
46+
47+
// zshGitSubcommandShim is injected into the zsh completion function so that
48+
// completions work when invoked as "git wt" (a git subcommand) in addition
49+
// to the standalone "git-wt" command.
50+
//
51+
// zsh's _git uses '(-)*:: :->option-or-argument' which shifts words so that
52+
// words[1] is the subcommand name ("wt"), not "git". Cobra's __complete
53+
// mechanism needs words[1] to be the binary name ("git-wt"), so we rewrite it.
54+
const zshGitSubcommandShim = ` # Normalize "wt" to "git-wt" so completions work as a git subcommand.
55+
# When zsh's _git dispatches here, words[1] is already "wt" (not "git").
56+
if [[ "${words[1]}" = "wt" ]]; then
57+
words[1]="git-wt"
58+
fi
59+
60+
`
61+
62+
// genBashCompletion generates bash completions with a _git_wt bridge so that
63+
// completions work for both "git-wt" (standalone) and "git wt" (subcommand).
64+
func genBashCompletion(cmd *cobra.Command, w io.Writer) error {
65+
var buf bytes.Buffer
66+
if err := cmd.GenBashCompletion(&buf); err != nil {
67+
return err
68+
}
69+
70+
output := buf.String() + bashGitSubcommandShim
71+
72+
_, err := io.WriteString(w, output)
73+
return err
74+
}
75+
76+
// genZshCompletion generates zsh completions with an injected shim so that
77+
// completions work for both "git-wt" (standalone) and "git wt" (subcommand).
78+
func genZshCompletion(cmd *cobra.Command, w io.Writer) error {
79+
var buf bytes.Buffer
80+
if err := cmd.GenZshCompletion(&buf); err != nil {
81+
return err
82+
}
83+
84+
output := buf.String()
85+
86+
const marker = "_git-wt()\n{\n"
87+
output = strings.Replace(output, marker, marker+zshGitSubcommandShim, 1)
88+
89+
_, err := io.WriteString(w, output)
90+
return err
91+
}
92+
2893
func init() {
2994
rootCmd.AddCommand(completionCmd)
3095
}

internal/cmd/completion_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestGenBashCompletion_containsSubcommandBridge(t *testing.T) {
10+
var buf bytes.Buffer
11+
if err := genBashCompletion(rootCmd, &buf); err != nil {
12+
t.Fatalf("genBashCompletion returned error: %v", err)
13+
}
14+
15+
output := buf.String()
16+
17+
// The _git_wt bridge function definition should appear exactly once.
18+
if count := strings.Count(output, "_git_wt() {"); count != 1 {
19+
t.Errorf("expected _git_wt bridge to appear once, got %d", count)
20+
}
21+
22+
// It should rewrite COMP_WORDS and delegate to __start_git-wt.
23+
if !strings.Contains(output, `COMP_WORDS=(git-wt "${COMP_WORDS[@]:2}")`) {
24+
t.Error("missing COMP_WORDS rewrite in _git_wt bridge")
25+
}
26+
27+
if !strings.Contains(output, "__start_git-wt") {
28+
t.Error("missing __start_git-wt delegation in _git_wt bridge")
29+
}
30+
}
31+
32+
func TestGenBashCompletion_preservesOriginal(t *testing.T) {
33+
var buf bytes.Buffer
34+
if err := genBashCompletion(rootCmd, &buf); err != nil {
35+
t.Fatalf("genBashCompletion returned error: %v", err)
36+
}
37+
38+
output := buf.String()
39+
40+
if !strings.Contains(output, "complete -o default -F __start_git-wt git-wt") &&
41+
!strings.Contains(output, "complete -o default -o nospace -F __start_git-wt git-wt") {
42+
t.Error("missing original complete registration for standalone git-wt")
43+
}
44+
}
45+
46+
func TestGenZshCompletion_containsSubcommandShim(t *testing.T) {
47+
var buf bytes.Buffer
48+
if err := genZshCompletion(rootCmd, &buf); err != nil {
49+
t.Fatalf("genZshCompletion returned error: %v", err)
50+
}
51+
52+
output := buf.String()
53+
54+
// The shim should appear exactly once inside the _git-wt() function.
55+
if count := strings.Count(output, `words[1]="git-wt"`); count != 1 {
56+
t.Errorf("expected subcommand normalization shim to appear once, got %d", count)
57+
}
58+
59+
// Verify the shim comes after the function definition and before the
60+
// first local variable declaration.
61+
shimIdx := strings.Index(output, `Normalize "wt"`)
62+
funcIdx := strings.Index(output, "_git-wt()\n{")
63+
localIdx := strings.Index(output, "local shellCompDirectiveError")
64+
65+
if shimIdx == -1 || funcIdx == -1 || localIdx == -1 {
66+
t.Fatal("missing expected sections in zsh completion output")
67+
}
68+
69+
if !(funcIdx < shimIdx && shimIdx < localIdx) {
70+
t.Error("subcommand shim is not in the expected position (after function opening, before locals)")
71+
}
72+
}
73+
74+
func TestGenZshCompletion_preservesCompdef(t *testing.T) {
75+
var buf bytes.Buffer
76+
if err := genZshCompletion(rootCmd, &buf); err != nil {
77+
t.Fatalf("genZshCompletion returned error: %v", err)
78+
}
79+
80+
output := buf.String()
81+
82+
if !strings.Contains(output, "#compdef git-wt") {
83+
t.Error("missing #compdef header")
84+
}
85+
86+
if !strings.Contains(output, "compdef _git-wt git-wt") {
87+
t.Error("missing compdef registration")
88+
}
89+
}

0 commit comments

Comments
 (0)