diff --git a/internal/timber/generate_test.go b/internal/timber/generate_test.go index ddece12..50d12dd 100644 --- a/internal/timber/generate_test.go +++ b/internal/timber/generate_test.go @@ -71,6 +71,23 @@ func TestGeneratedCompletionDelegatesToTimber(t *testing.T) { assert.NotContains(t, completion, "->repo_qualifiers") } +func TestGeneratedCompletionOffersHiddenSwitchSubcommand(t *testing.T) { + t.Parallel() + + outDir := resolvedTempDir(t) + require.NoError(t, runTimberCommand(t, "generate", "zsh", "--out", outDir).err) + + completionContents, err := os.ReadFile(filepath.Join(outDir, "_t")) + require.NoError(t, err) + completion := string(completionContents) + // 'switch' is hidden from 'timber --help' (and therefore from + // 'timber __complete'), but the 't' wrapper handles 'switch|sw' + // itself, so the generated completion must offer them when completing + // the subcommand name. + assert.Contains(t, completion, "switch:Resolve a managed worktree path") + assert.Contains(t, completion, "sw:Resolve a managed worktree path") +} + func TestListFlagCompletionOffersPrJsonSort(t *testing.T) { t.Parallel() testRepository := newTestRepository(t) @@ -389,6 +406,121 @@ sys.stdout.write(log.decode("utf-8", "replace")) assert.Contains(t, ambiguous, "t switch feature/login@") } +// TestGeneratedSwitchSubcommandCompletesInZsh proves the generated completion +// offers the hidden 'switch' subcommand under 't': 't swi' expands to +// 't switch' in a real zsh session even though 'timber __complete' hides it. +func TestGeneratedSwitchSubcommandCompletesInZsh(t *testing.T) { + t.Parallel() + if _, err := exec.LookPath("zsh"); err != nil { + t.Skip("zsh is not installed") + } + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 is not installed") + } + skipIfNoPty(t) + + home := resolvedTempDir(t) + dataHome := filepath.Join(home, ".local", "share") + worktreeRoot := filepath.Join(home, "worktrees") + + outDir := resolvedTempDir(t) + require.NoError(t, runTimberCommand(t, "generate", "zsh", "--out", outDir, "--force").err) + timberBin := buildTestTimberBinary(t) + + scriptPath := filepath.Join(resolvedTempDir(t), "complete.py") + script := `import os, pty, select, time, sys + +compdir, zdot, home, data_home, worktree_root, bindir, line = sys.argv[1:8] +wants = sys.argv[8:] +os.makedirs(zdot, exist_ok=True) +open(os.path.join(zdot, ".zshrc"), "w").write("") +os.environ["ZDOTDIR"] = zdot +os.environ["HOME"] = home +os.environ["XDG_DATA_HOME"] = data_home +os.environ["TIMBER_WORKTREE_ROOT"] = worktree_root +os.environ["PATH"] = bindir + ":" + os.environ["PATH"] + +pid, fd = pty.fork() +if pid == 0: + os.execvp("zsh", ["zsh", "-f", "-i"]) + +def recv(timeout=1.0): + buf = b"" + end = time.time() + timeout + while time.time() < end: + ready, _, _ = select.select([fd], [], [], max(0.05, end - time.time())) + if not ready: + continue + try: + chunk = os.read(fd, 8192) + except OSError: + break + if not chunk: + break + buf += chunk + end = time.time() + 0.2 + return buf + +def send(data): + os.write(fd, data.encode() if isinstance(data, str) else data) + +def wait_compinit(timeout=30.0): + out = b"" + end = time.time() + timeout + while time.time() < end and b"COMPINIT_DONE" not in out: + out += recv(1.0) + return out + +def wait_expansion(timeout=10.0): + out = b"" + end = time.time() + timeout + want = [w.encode() for w in wants] + while time.time() < end and not all(w in out for w in want): + out += recv(0.5) + return out + +recv(0.3) +log = b"" +send("fpath=(" + compdir + " $fpath); autoload -Uz compinit; compinit -u -D; echo COMPINIT_DONE:$SECONDS\n") +log += wait_compinit() +send("(( $+_comps[t] )) && echo HAVE_T_COMP || echo NO_T_COMP\n") +log += recv(1.0) +send("\x15") +recv(0.1) +send(line) +time.sleep(0.05) +send("\t") +log += wait_expansion() +send("exit\n") +recv(0.2) +sys.stdout.write(log.decode("utf-8", "replace")) +` + require.NoError(t, os.WriteFile(scriptPath, []byte(script), 0o644)) + + runComplete := func(line string, wants ...string) string { + t.Helper() + args := append([]string{ + "python3", + scriptPath, + outDir, + filepath.Join(resolvedTempDir(t), "zdot"), + home, + dataHome, + worktreeRoot, + timberBin, + line, + }, wants...) + command := testCommand(t, args[0], args[1:]...) + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) + return string(output) + } + + // 'swi' matches only 'switch', not the 'sw' alias, so zsh expands the line. + subcommand := runComplete("t swi", "switch") + assert.Contains(t, subcommand, "t switch") +} + // TestGeneratedDelegatedCompletionEndToEnd proves the generated completion // delegates to 'timber __complete': list flags, sort values, and the // wrapper-only create --no-cd flag all complete in a real zsh session. diff --git a/internal/timber/generate_zsh.go b/internal/timber/generate_zsh.go index 9b07be7..9442f8f 100644 --- a/internal/timber/generate_zsh.go +++ b/internal/timber/generate_zsh.go @@ -411,6 +411,19 @@ _%[1]s() fi fi + # The 'switch' subcommand is hidden from 'timber --help' (the wrapper + # handles 'switch|sw' itself so it can cd), which also hides it from + # 'timber __complete', so offer it alongside Cobra's completions when + # completing the subcommand name. + if (( CURRENT == 2 )); then + if [[ switch == "${lastParam}"* ]] && (( ${completions[(I)switch:*]:-0} == 0 )); then + completions+=('switch:Resolve a managed worktree path') + fi + if [[ sw == "${lastParam}"* ]] && (( ${completions[(I)sw:*]:-0} == 0 )); then + completions+=('sw:Resolve a managed worktree path') + fi + fi + # Add a delimiter after the activeHelp statements, but only if: # - there are completions following the activeHelp statements, or # - file completion will be performed (so there will be choices after the activeHelp)