From 287ae1876721a3243f8c8752da4c38139c918a83 Mon Sep 17 00:00:00 2001 From: Adam Mustafa Date: Tue, 18 Aug 2026 14:28:03 -0400 Subject: [PATCH] fix(shell-hook): pass the cd target through a file so prompts keep the TTY --- cmd/navigate.go | 26 ++++++++---- cmd/navigate_test.go | 99 ++++++++++++++++++++++++++++++++++++++++++++ cmd/shell_hook.go | 59 +++++++++++++------------- 3 files changed, 147 insertions(+), 37 deletions(-) create mode 100644 cmd/navigate_test.go diff --git a/cmd/navigate.go b/cmd/navigate.go index 115e2de..6523937 100644 --- a/cmd/navigate.go +++ b/cmd/navigate.go @@ -2,19 +2,31 @@ package cmd import ( "fmt" + "os" "path/filepath" "github.com/amustafa/stackr/internal/engine" ) -// handleNavigateResult prints worktree cd markers or checkout confirmation. -// The shell hook (sr shell-hook) parses __sr_cd: lines to trigger cd. +// handleNavigateResult reports a worktree switch so the shell hook can cd. +// +// New hooks pass SR_CD_FILE, a scratch file to write the target into — a side +// channel that exists so the hook never has to capture stdout (capturing +// un-TTYs it, which disables every interactive prompt). The __sr_cd: sentinel +// on stdout remains as the fallback for hooks eval'd before this change. func handleNavigateResult(result engine.NavigateResult) { - if result.IsWorktree() { - absPath := result.WorktreePath - if !filepath.IsAbs(absPath) { - absPath = filepath.Join(ctx.Git.Dir, absPath) + if !result.IsWorktree() { + return + } + absPath := result.WorktreePath + if !filepath.IsAbs(absPath) { + absPath = filepath.Join(ctx.Git.Dir, absPath) + } + if cdFile := os.Getenv("SR_CD_FILE"); cdFile != "" { + if err := os.WriteFile(cdFile, []byte(absPath+"\n"), 0o600); err == nil { + return } - fmt.Printf("__sr_cd:%s\n", absPath) + // Couldn't write the file — fall through to the sentinel. } + fmt.Printf("__sr_cd:%s\n", absPath) } diff --git a/cmd/navigate_test.go b/cmd/navigate_test.go new file mode 100644 index 0000000..36850a0 --- /dev/null +++ b/cmd/navigate_test.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/amustafa/stackr/internal/engine" +) + +// captureStdout runs fn with os.Stdout redirected to a pipe and returns what +// it wrote. Fine for these tests' few bytes; a writer that filled the pipe +// buffer would deadlock. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + + fn() + + w.Close() + os.Stdout = old + out, err := io.ReadAll(r) + r.Close() + if err != nil { + t.Fatal(err) + } + return string(out) +} + +func TestHandleNavigateResult_WritesCdFileInsteadOfSentinel(t *testing.T) { + cdFile := filepath.Join(t.TempDir(), "cd-target") + t.Setenv("SR_CD_FILE", cdFile) + + target := t.TempDir() + out := captureStdout(t, func() { + handleNavigateResult(engine.NavigateResult{Branch: "b", WorktreePath: target}) + }) + + data, err := os.ReadFile(cdFile) + if err != nil { + t.Fatalf("cd file was not written: %v", err) + } + if got := strings.TrimSpace(string(data)); got != target { + t.Fatalf("cd file holds %q, want %q", got, target) + } + if strings.Contains(out, "__sr_cd:") { + t.Fatalf("sentinel must not be printed when SR_CD_FILE is honored; stdout: %q", out) + } +} + +func TestHandleNavigateResult_FallsBackToSentinelWithoutEnv(t *testing.T) { + t.Setenv("SR_CD_FILE", "") + os.Unsetenv("SR_CD_FILE") + + target := t.TempDir() + out := captureStdout(t, func() { + handleNavigateResult(engine.NavigateResult{Branch: "b", WorktreePath: target}) + }) + + if want := "__sr_cd:" + target; !strings.Contains(out, want) { + t.Fatalf("old hooks rely on the sentinel; stdout %q lacks %q", out, want) + } +} + +func TestHandleNavigateResult_FallsBackWhenCdFileUnwritable(t *testing.T) { + t.Setenv("SR_CD_FILE", filepath.Join(t.TempDir(), "no", "such", "dir", "f")) + + target := t.TempDir() + out := captureStdout(t, func() { + handleNavigateResult(engine.NavigateResult{Branch: "b", WorktreePath: target}) + }) + + if want := "__sr_cd:" + target; !strings.Contains(out, want) { + t.Fatalf("an unwritable cd file must fall back to the sentinel; stdout: %q", out) + } +} + +func TestHandleNavigateResult_PlainCheckoutPrintsNothing(t *testing.T) { + cdFile := filepath.Join(t.TempDir(), "cd-target") + t.Setenv("SR_CD_FILE", cdFile) + + out := captureStdout(t, func() { + handleNavigateResult(engine.NavigateResult{Branch: "b"}) + }) + + if out != "" { + t.Fatalf("non-worktree navigation should print nothing, got %q", out) + } + if _, err := os.Stat(cdFile); !os.IsNotExist(err) { + t.Fatalf("non-worktree navigation must not write the cd file") + } +} diff --git a/cmd/shell_hook.go b/cmd/shell_hook.go index 945bcf3..9e21c2f 100644 --- a/cmd/shell_hook.go +++ b/cmd/shell_hook.go @@ -6,39 +6,34 @@ import ( "github.com/spf13/cobra" ) +// The hook must not capture sr's stdout: command substitution turns stdout +// into a pipe, root's TTY check then classifies the run as non-interactive, +// and every prompt silently takes its scripted branch. The cd target instead +// travels through a scratch file named in SR_CD_FILE, leaving stdin/stdout +// attached to the terminal. const shellHookScript = ` sr() { - local output - output="$(command sr "$@")" - local exit_code=$? - - local cd_target="" - local rest="" - while IFS= read -r line; do - case "$line" in - __sr_cd:*) - cd_target="${line#__sr_cd:}" - ;; - *) - if [ -n "$rest" ]; then - rest="$rest -$line" - else - rest="$line" - fi - ;; - esac - done <<< "$output" - - if [ -n "$rest" ]; then - printf '%s\n' "$rest" + local cd_file exit_code + cd_file="$(mktemp "${TMPDIR:-/tmp}/sr-cd.XXXXXX")" || { + command sr "$@" + return $? + } + + SR_CD_FILE="$cd_file" command sr "$@" + exit_code=$? + + if [ -s "$cd_file" ]; then + local cd_target="" + IFS= read -r cd_target < "$cd_file" + if [ -n "$cd_target" ] && [ -d "$cd_target" ]; then + if cd "$cd_target"; then + printf 'Switched to worktree at %s\n' "$cd_target" + else + exit_code=1 + fi + fi fi - - if [ -n "$cd_target" ]; then - cd "$cd_target" || return 1 - printf 'Switched to worktree at %s\n' "$cd_target" - fi - + rm -f "$cd_file" return $exit_code } ` @@ -49,6 +44,10 @@ var shellHookCmd = &cobra.Command{ Long: `Print a shell function that wraps sr to enable automatic directory changes when navigating to branches in worktrees. +The function passes sr a scratch file (SR_CD_FILE) to receive the cd target, +so sr's stdin/stdout stay attached to the terminal and interactive prompts +keep working. + Add this to your shell rc file (.bashrc or .zshrc): eval "$(sr shell-hook)"`,