Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions cmd/navigate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
99 changes: 99 additions & 0 deletions cmd/navigate_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
59 changes: 29 additions & 30 deletions cmd/shell_hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
`
Expand All @@ -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)"`,
Expand Down