fix: sync shell working directory for suggestions (#78)

## Summary
- synchronize the shell current working directory with Iris over the
existing IPC pipe
- use the synchronized directory for file, workspace, Git, and history
suggestions on macOS
- preserve the Linux `/proc/<pid>/cwd` and process-CWD fallbacks
- update zsh, bash, and fish integrations while preserving command exit
status

## Problem
On macOS, `/proc/<pid>/cwd` is unavailable, so `GetCWD` fell back to the
directory where Iris was launched, usually `$HOME`. After changing
directories in the shell, file suggestions could therefore continue to
come from `$HOME`.

## Tests
- `go test -race ./spec`
- `go test ./root -run ^'$'`
- `go test ./... -run ^'$'`
- generated zsh, bash, and fish hook syntax checks

Co-authored-by: Pavel Naumov <Pavel.Naumov@berekebank.kz>
This commit is contained in:
Pavel Naumov
2026-07-31 10:01:05 +07:00
committed by GitHub
co-authored by Pavel Naumov
parent cee756e2c1
commit 9f43fcb352
4 changed files with 103 additions and 10 deletions
+18 -4
View File
@@ -42,8 +42,14 @@ if [ -n "$IRIS_PID" ] && [ -n "$IRIS_FD" ]; then
print -u $IRIS_FD -N -r -- "$LBUFFER" 2>/dev/null print -u $IRIS_FD -N -r -- "$LBUFFER" 2>/dev/null
} }
_iris_sync_cwd() {
print -u $IRIS_FD -N -r -- "IRIS_CWD:$PWD" 2>/dev/null
}
_iris_precmd() { _iris_precmd() {
print -u $IRIS_FD -N -r -- "IRIS_CMD_STOP" 2>/dev/null local iris_exit_code=$?
_iris_sync_cwd
print -u $IRIS_FD -N -r -- "IRIS_CMD_STOP:$iris_exit_code" 2>/dev/null
} }
_iris_preexec() { _iris_preexec() {
@@ -56,6 +62,7 @@ if [ -n "$IRIS_PID" ] && [ -n "$IRIS_FD" ]; then
add-zle-hook-widget line-pre-redraw _iris_send_lbuffer add-zle-hook-widget line-pre-redraw _iris_send_lbuffer
add-zsh-hook precmd _iris_precmd add-zsh-hook precmd _iris_precmd
add-zsh-hook preexec _iris_preexec add-zsh-hook preexec _iris_preexec
add-zsh-hook chpwd _iris_sync_cwd
fi fi
`) `)
case "bash": case "bash":
@@ -75,7 +82,9 @@ fi
# Iris Autocomplete Hook # Iris Autocomplete Hook
if [ -n "$IRIS_PID" ] && [ -n "$IRIS_FD" ]; then if [ -n "$IRIS_PID" ] && [ -n "$IRIS_FD" ]; then
_iris_bash_precmd() { _iris_bash_precmd() {
printf "IRIS_CMD_STOP\x00" >&$IRIS_FD 2>/dev/null local iris_exit_code=$?
printf "IRIS_CWD:%%s\x00" "$PWD" >&$IRIS_FD 2>/dev/null
printf "IRIS_CMD_STOP:%%s\x00" "$iris_exit_code" >&$IRIS_FD 2>/dev/null
} }
if [[ ";$PROMPT_COMMAND;" != *";_iris_bash_precmd;"* ]]; then if [[ ";$PROMPT_COMMAND;" != *";_iris_bash_precmd;"* ]]; then
@@ -103,10 +112,15 @@ end
# Iris Autocomplete Hook # Iris Autocomplete Hook
if set -q IRIS_PID; and set -q IRIS_FD if set -q IRIS_PID; and set -q IRIS_FD
function _iris_fish_postexec --on-event fish_postexec function _iris_fish_postexec --on-event fish_postexec
echo -n -e "IRIS_CMD_STOP\x00" >&$IRIS_FD 2>/dev/null set -l iris_exit_code $status
printf "IRIS_CWD:%%s\x00" "$PWD" >&$IRIS_FD 2>/dev/null
printf "IRIS_CMD_STOP:%%s\x00" "$iris_exit_code" >&$IRIS_FD 2>/dev/null
end
function _iris_fish_prompt --on-event fish_prompt
printf "IRIS_CWD:%%s\x00" "$PWD" >&$IRIS_FD 2>/dev/null
end end
function _iris_fish_preexec --on-event fish_preexec function _iris_fish_preexec --on-event fish_preexec
echo -n -e "IRIS_CMD_START\x00" >&$IRIS_FD 2>/dev/null printf "IRIS_CMD_START\x00" >&$IRIS_FD 2>/dev/null
end end
end end
`) `)
+5
View File
@@ -386,6 +386,11 @@ func runWrapper() {
for scanner.Scan() { for scanner.Scan() {
query := scanner.Text() query := scanner.Text()
if cwd, ok := strings.CutPrefix(query, "IRIS_CWD:"); ok {
spec.SetCWD(cwd)
continue
}
if query == "IRIS_CMD_START" { if query == "IRIS_CMD_START" {
isCommandActive.Store(true) isCommandActive.Store(true)
bufferMu.Lock() bufferMu.Lock()
+31 -6
View File
@@ -6,24 +6,49 @@ import (
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"sync"
"github.com/versenilvis/iris/internal/config" "github.com/versenilvis/iris/internal/config"
) )
// for tracking current working dir var (
var ShellPID int // ShellPID tracks the underlying shell process.
ShellPID int
shellCWDMu sync.RWMutex
shellCWD string
)
// SetCWD updates the working directory reported by the underlying shell.
// Shell integrations call this whenever the prompt directory changes.
func SetCWD(cwd string) {
if cwd != "" && !filepath.IsAbs(cwd) {
return
}
shellCWDMu.Lock()
shellCWD = cwd
shellCWDMu.Unlock()
}
// GetCWD returns the current working directory of the underlying shell // GetCWD returns the current working directory of the underlying shell
// on Linux, it reads it from /proc/[pid]/cwd // reported by shell integration. Linux can also read it from /proc/[pid]/cwd.
func GetCWD() string { func GetCWD() string {
shellCWDMu.RLock()
cwd := shellCWD
shellCWDMu.RUnlock()
if cwd != "" {
return cwd
}
if ShellPID > 0 { if ShellPID > 0 {
path := fmt.Sprintf("/proc/%d/cwd", ShellPID) path := fmt.Sprintf("/proc/%d/cwd", ShellPID)
cwd, err := os.Readlink(path) procCWD, err := os.Readlink(path)
if err == nil { if err == nil {
return cwd return procCWD
} }
} }
cwd, _ := os.Getwd() cwd, _ = os.Getwd()
return cwd return cwd
} }
+49
View File
@@ -8,6 +8,8 @@ import (
) )
func TestFileGenerator(t *testing.T) { func TestFileGenerator(t *testing.T) {
SetCWD("")
// Setup mock files // Setup mock files
tmp := t.TempDir() tmp := t.TempDir()
_ = os.MkdirAll(filepath.Join(tmp, "src"), 0755) _ = os.MkdirAll(filepath.Join(tmp, "src"), 0755)
@@ -92,3 +94,50 @@ func TestFileGenerator(t *testing.T) {
} }
}) })
} }
func TestGetCWDUsesShellReportedDirectory(t *testing.T) {
launcherDir := t.TempDir()
shellDir := t.TempDir()
if err := os.WriteFile(filepath.Join(shellDir, "from-shell.txt"), nil, 0644); err != nil {
t.Fatal(err)
}
oldWd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(launcherDir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
SetCWD("")
_ = os.Chdir(oldWd)
})
SetCWD(shellDir)
if got := GetCWD(); got != shellDir {
t.Fatalf("GetCWD() = %q, want shell-reported directory %q", got, shellDir)
}
results := FileGenerator()([]string{"cat", ""}, "cat ", "")
found := false
for _, result := range results {
if result.Cmd == "from-shell.txt" {
found = true
break
}
}
if !found {
t.Fatalf("FileGenerator() did not read suggestions from shell directory %q", shellDir)
}
}
func TestSetCWDRejectsRelativePaths(t *testing.T) {
SetCWD("")
t.Cleanup(func() { SetCWD("") })
SetCWD("relative/path")
if got := GetCWD(); got == "relative/path" {
t.Fatalf("GetCWD() accepted relative shell directory %q", got)
}
}