From e3167eabc754e1dca87ccef21b737867dc720a9b Mon Sep 17 00:00:00 2001 From: Matt McKenna Date: Tue, 10 Feb 2026 17:54:27 -0500 Subject: [PATCH 1/4] Add git status detection when navigating into git repos When Claude Code starts in a non-git directory (e.g., ~/Development) and navigates into a subdirectory that is a git repo, the git plugin and linesChanged section now detect and display git information by falling back to CurrentDir when ProjectDir is not a git repo. Co-Authored-By: Claude Opus 4.6 --- internal/plugins/git.go | 67 ++++++++- internal/statusline/statusline.go | 46 +++++- internal/statusline/statusline_test.go | 198 +++++++++++++++++++++++++ 3 files changed, 303 insertions(+), 8 deletions(-) diff --git a/internal/plugins/git.go b/internal/plugins/git.go index 78c1235..fd7c49f 100644 --- a/internal/plugins/git.go +++ b/internal/plugins/git.go @@ -34,12 +34,12 @@ func (p *GitPlugin) OnHook(ctx context.Context, hookType HookType, hookCtx HookC } func (p *GitPlugin) Execute(ctx context.Context, input plugin.Input) (string, error) { - projectDir := input.Prism.ProjectDir - if projectDir == "" { + gitDir := p.getEffectiveGitDir(ctx, input) + if gitDir == "" { return "", nil } - cacheKey := fmt.Sprintf("git:%s", projectDir) + cacheKey := fmt.Sprintf("git:%s", gitDir) // Check cache first if p.cache != nil { @@ -49,21 +49,21 @@ func (p *GitPlugin) Execute(ctx context.Context, input plugin.Input) (string, er } // Check if this is a git repo - if !isGitRepo(ctx, projectDir) { + if !isGitRepo(ctx, gitDir) { return "", nil } // Get branch name - branch := getGitBranch(ctx, projectDir) + branch := getGitBranch(ctx, gitDir) if branch == "" { return "", nil } // Get dirty status - dirty := getGitDirty(ctx, projectDir) + dirty := getGitDirty(ctx, gitDir) // Get upstream status - behind, ahead := getUpstreamStatus(ctx, projectDir) + behind, ahead := getUpstreamStatus(ctx, gitDir) // Format output yellow := input.Colors["yellow"] @@ -95,6 +95,59 @@ func (p *GitPlugin) Execute(ctx context.Context, input plugin.Input) (string, er return output, nil } +// getEffectiveGitDir returns the best directory to use for git operations. +// Tries ProjectDir first (fast path), falls back to finding git root from CurrentDir. +func (p *GitPlugin) getEffectiveGitDir(ctx context.Context, input plugin.Input) string { + projectDir := input.Prism.ProjectDir + currentDir := input.Prism.CurrentDir + + // Fast path: ProjectDir is a git repo + if projectDir != "" && isGitRepo(ctx, projectDir) { + return projectDir + } + + // Fallback: find git root from CurrentDir + if currentDir == "" { + return "" + } + + // Check cache for effective dir resolution + if p.cache != nil { + cacheKey := fmt.Sprintf("git:effective:%s", currentDir) + if cached, ok := p.cache.Get(cacheKey); ok { + if cached == "" { + return "" + } + return cached + } + } + + gitRoot := findGitRootFromDir(ctx, currentDir) + + // Cache the result (even empty, to avoid repeated lookups) + if p.cache != nil { + cacheKey := fmt.Sprintf("git:effective:%s", currentDir) + p.cache.Set(cacheKey, gitRoot, cache.GitTTL) + } + + return gitRoot +} + +// findGitRootFromDir runs git rev-parse --show-toplevel from the given directory +// to find the git root. Returns empty string if not in a git repo. +func findGitRootFromDir(ctx context.Context, dir string) string { + cmd := exec.CommandContext(ctx, "git", "--no-optional-locks", "rev-parse", "--show-toplevel") + cmd.Dir = dir + var out bytes.Buffer + cmd.Stdout = &out + + if err := cmd.Run(); err != nil { + return "" + } + + return strings.TrimSpace(out.String()) +} + func isGitRepo(ctx context.Context, dir string) bool { cmd := exec.CommandContext(ctx, "git", "--no-optional-locks", "rev-parse", "--git-dir") cmd.Dir = dir diff --git a/internal/statusline/statusline.go b/internal/statusline/statusline.go index 89c8467..97810e9 100644 --- a/internal/statusline/statusline.go +++ b/internal/statusline/statusline.go @@ -401,13 +401,57 @@ func renderContextBar(pct int, colorPct int, showBuffer bool) string { func (sl *StatusLine) renderLinesChanged() string { // ALWAYS use git diff stats - never use Claude's session stats // This shows actual uncommitted changes in the working tree - added, removed := getGitDiffStats(sl.input.Workspace.ProjectDir) + gitDir := sl.getEffectiveGitDir() + added, removed := getGitDiffStats(gitDir) return fmt.Sprintf("%s+%d%s %s-%d%s", colors.Green, added, colors.Reset, colors.Red, removed, colors.Reset) } +// getEffectiveGitDir returns the best directory for git operations. +// Tries ProjectDir first, falls back to finding git root from CurrentDir. +func (sl *StatusLine) getEffectiveGitDir() string { + projectDir := sl.input.Workspace.ProjectDir + currentDir := sl.input.Workspace.CurrentDir + + // Fast path: ProjectDir is set and is a git repo + if projectDir != "" { + if isGitDir(projectDir) { + return projectDir + } + } + + // Fallback: find git root from CurrentDir + if currentDir == "" { + return "" + } + + cacheKey := "git:effective:" + currentDir + if cached, ok := statusCache.Get(cacheKey); ok { + return cached // empty string cached means "not a git repo" + } + + cmd := exec.Command("git", "--no-optional-locks", "rev-parse", "--show-toplevel") + cmd.Dir = currentDir + output, err := cmd.Output() + if err != nil { + statusCache.Set(cacheKey, "", cache.GitTTL) + return "" + } + + gitRoot := strings.TrimSpace(string(output)) + statusCache.Set(cacheKey, gitRoot, cache.GitTTL) + return gitRoot +} + +// isGitDir checks if a directory is inside a git repository +func isGitDir(dir string) bool { + cmd := exec.Command("git", "--no-optional-locks", "rev-parse", "--git-dir") + cmd.Dir = dir + return cmd.Run() == nil +} + func getGitDiffStats(projectDir string) (int, int) { if projectDir == "" { return 0, 0 diff --git a/internal/statusline/statusline_test.go b/internal/statusline/statusline_test.go index b6add0a..c73cf1b 100644 --- a/internal/statusline/statusline_test.go +++ b/internal/statusline/statusline_test.go @@ -865,3 +865,201 @@ func TestRenderContext_60PctShowsYellowWithBuffer(t *testing.T) { t.Errorf("60%% with 22.5%% buffer should be YELLOW (proximity ~77%%), got: %s", result) } } + +// TestGetEffectiveGitDir_ProjectDirIsGitRepo returns ProjectDir when it is a git repo +func TestGetEffectiveGitDir_ProjectDirIsGitRepo(t *testing.T) { + tmpDir := setupTestGitRepo(t) + defer os.RemoveAll(tmpDir) + + sl := &StatusLine{ + input: Input{ + Workspace: WorkspaceInfo{ + ProjectDir: tmpDir, + CurrentDir: tmpDir, + }, + }, + } + + result := sl.getEffectiveGitDir() + if result != tmpDir { + t.Errorf("expected ProjectDir %s, got %s", tmpDir, result) + } +} + +// TestGetEffectiveGitDir_NonGitProjectDir_GitCurrentDir falls back to CurrentDir's git root +func TestGetEffectiveGitDir_NonGitProjectDir_GitCurrentDir(t *testing.T) { + // Create a non-git parent directory + parentDir, err := os.MkdirTemp("", "prism-test-parent-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(parentDir) + + // Create a git repo as a subdirectory + gitRepoDir := filepath.Join(parentDir, "my-repo") + if err := os.MkdirAll(gitRepoDir, 0755); err != nil { + t.Fatal(err) + } + + cmds := [][]string{ + {"git", "init"}, + {"git", "config", "user.email", "test@test.com"}, + {"git", "config", "user.name", "Test"}, + } + for _, args := range cmds { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = gitRepoDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to run %v: %v", args, err) + } + } + + // Create initial commit + readmeFile := filepath.Join(gitRepoDir, "README.md") + os.WriteFile(readmeFile, []byte("# Test\n"), 0644) + cmd := exec.Command("git", "add", "README.md") + cmd.Dir = gitRepoDir + cmd.Run() + cmd = exec.Command("git", "commit", "-m", "Initial commit") + cmd.Dir = gitRepoDir + cmd.Run() + + sl := &StatusLine{ + input: Input{ + Workspace: WorkspaceInfo{ + ProjectDir: parentDir, // Non-git directory + CurrentDir: gitRepoDir, // Git repo + }, + }, + } + + result := sl.getEffectiveGitDir() + // Resolve symlinks for comparison (macOS /var -> /private/var) + expectedDir, _ := filepath.EvalSymlinks(gitRepoDir) + if result != expectedDir { + t.Errorf("expected git root %s, got %s", expectedDir, result) + } +} + +// TestGetEffectiveGitDir_NonGitProjectDir_GitCurrentDirSubdir finds git root from subdirectory +func TestGetEffectiveGitDir_NonGitProjectDir_GitCurrentDirSubdir(t *testing.T) { + // Create a non-git parent directory + parentDir, err := os.MkdirTemp("", "prism-test-parent-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(parentDir) + + // Create a git repo as a subdirectory + gitRepoDir := filepath.Join(parentDir, "my-repo") + if err := os.MkdirAll(gitRepoDir, 0755); err != nil { + t.Fatal(err) + } + + cmds := [][]string{ + {"git", "init"}, + {"git", "config", "user.email", "test@test.com"}, + {"git", "config", "user.name", "Test"}, + } + for _, args := range cmds { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = gitRepoDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to run %v: %v", args, err) + } + } + + // Create initial commit + readmeFile := filepath.Join(gitRepoDir, "README.md") + os.WriteFile(readmeFile, []byte("# Test\n"), 0644) + cmd := exec.Command("git", "add", "README.md") + cmd.Dir = gitRepoDir + cmd.Run() + cmd = exec.Command("git", "commit", "-m", "Initial commit") + cmd.Dir = gitRepoDir + cmd.Run() + + // Create a subdirectory inside the git repo + subDir := filepath.Join(gitRepoDir, "src", "components") + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatal(err) + } + + sl := &StatusLine{ + input: Input{ + Workspace: WorkspaceInfo{ + ProjectDir: parentDir, // Non-git directory + CurrentDir: subDir, // Subdirectory inside a git repo + }, + }, + } + + result := sl.getEffectiveGitDir() + // Resolve symlinks for comparison (macOS /var -> /private/var) + expectedDir, _ := filepath.EvalSymlinks(gitRepoDir) + if result != expectedDir { + t.Errorf("expected git root %s, got %s", expectedDir, result) + } +} + +// TestRenderLinesChanged_NonGitProjectDir_GitCurrentDir shows diff stats from CurrentDir's repo +func TestRenderLinesChanged_NonGitProjectDir_GitCurrentDir(t *testing.T) { + // Create a non-git parent directory + parentDir, err := os.MkdirTemp("", "prism-test-parent-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(parentDir) + + // Create a git repo as a subdirectory + gitRepoDir := filepath.Join(parentDir, "my-repo") + if err := os.MkdirAll(gitRepoDir, 0755); err != nil { + t.Fatal(err) + } + + cmds := [][]string{ + {"git", "init"}, + {"git", "config", "user.email", "test@test.com"}, + {"git", "config", "user.name", "Test"}, + } + for _, args := range cmds { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = gitRepoDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to run %v: %v", args, err) + } + } + + // Create initial commit + readmeFile := filepath.Join(gitRepoDir, "README.md") + os.WriteFile(readmeFile, []byte("# Test\n"), 0644) + cmd := exec.Command("git", "add", "README.md") + cmd.Dir = gitRepoDir + cmd.Run() + cmd = exec.Command("git", "commit", "-m", "Initial commit") + cmd.Dir = gitRepoDir + cmd.Run() + + // Create a staged change so diff stats are non-zero + newFile := filepath.Join(gitRepoDir, "new.txt") + os.WriteFile(newFile, []byte("line1\nline2\nline3\n"), 0644) + cmd = exec.Command("git", "add", "new.txt") + cmd.Dir = gitRepoDir + cmd.Run() + + sl := &StatusLine{ + input: Input{ + Workspace: WorkspaceInfo{ + ProjectDir: parentDir, // Non-git directory + CurrentDir: gitRepoDir, // Git repo with changes + }, + }, + } + + result := sl.renderLinesChanged() + + // Should show +3 -0 from the git repo, not +0 -0 + if !strings.Contains(result, "+3") { + t.Errorf("expected +3 for staged lines in CurrentDir repo, got: %s", result) + } +} From 984b1fd07becc9c10008e7767bfb8040f3107b82 Mon Sep 17 00:00:00 2001 From: Matt McKenna Date: Wed, 11 Feb 2026 14:57:38 -0500 Subject: [PATCH 2/4] Fix git status detection: add timeouts, rename helpers, and harden tests - Add 500ms timeout to git commands in getEffectiveGitDir and isGitRepo - Rename isGitDir to isGitRepo for clarity - Remove redundant isGitRepo check in git plugin Execute() - Add error checks in test setup (os.WriteFile, git add/commit, filepath.EvalSymlinks) - Add cache cleanup for unbounded growth (CleanExpired on Set when >50 entries) Co-Authored-By: Claude Opus 4.6 --- internal/cache/cache.go | 21 ++++++++++ internal/plugins/git.go | 5 --- internal/statusline/statusline.go | 14 ++++--- internal/statusline/statusline_test.go | 54 +++++++++++++++++++------- 4 files changed, 71 insertions(+), 23 deletions(-) diff --git a/internal/cache/cache.go b/internal/cache/cache.go index e4c3eb1..b8bd0e6 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -46,12 +46,33 @@ func (c *Cache) Set(key, value string, ttl time.Duration) { c.mu.Lock() defer c.mu.Unlock() + if len(c.items) > 50 { + c.cleanExpired() + } + c.items[key] = cacheItem{ value: value, expiresAt: time.Now().Add(ttl), } } +// CleanExpired removes all expired entries from the cache +func (c *Cache) CleanExpired() { + c.mu.Lock() + defer c.mu.Unlock() + c.cleanExpired() +} + +// cleanExpired removes expired entries (must be called with lock held) +func (c *Cache) cleanExpired() { + now := time.Now() + for k, v := range c.items { + if now.After(v.expiresAt) { + delete(c.items, k) + } + } +} + // IsStale returns true if the key is missing or expired func (c *Cache) IsStale(key string, ttl time.Duration) bool { c.mu.RLock() diff --git a/internal/plugins/git.go b/internal/plugins/git.go index fd7c49f..5c7f410 100644 --- a/internal/plugins/git.go +++ b/internal/plugins/git.go @@ -48,11 +48,6 @@ func (p *GitPlugin) Execute(ctx context.Context, input plugin.Input) (string, er } } - // Check if this is a git repo - if !isGitRepo(ctx, gitDir) { - return "", nil - } - // Get branch name branch := getGitBranch(ctx, gitDir) if branch == "" { diff --git a/internal/statusline/statusline.go b/internal/statusline/statusline.go index 97810e9..46e135a 100644 --- a/internal/statusline/statusline.go +++ b/internal/statusline/statusline.go @@ -417,7 +417,7 @@ func (sl *StatusLine) getEffectiveGitDir() string { // Fast path: ProjectDir is set and is a git repo if projectDir != "" { - if isGitDir(projectDir) { + if isGitRepo(projectDir) { return projectDir } } @@ -432,7 +432,9 @@ func (sl *StatusLine) getEffectiveGitDir() string { return cached // empty string cached means "not a git repo" } - cmd := exec.Command("git", "--no-optional-locks", "rev-parse", "--show-toplevel") + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "--no-optional-locks", "rev-parse", "--show-toplevel") cmd.Dir = currentDir output, err := cmd.Output() if err != nil { @@ -445,9 +447,11 @@ func (sl *StatusLine) getEffectiveGitDir() string { return gitRoot } -// isGitDir checks if a directory is inside a git repository -func isGitDir(dir string) bool { - cmd := exec.Command("git", "--no-optional-locks", "rev-parse", "--git-dir") +// isGitRepo checks if a directory is inside a git repository +func isGitRepo(dir string) bool { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "--no-optional-locks", "rev-parse", "--git-dir") cmd.Dir = dir return cmd.Run() == nil } diff --git a/internal/statusline/statusline_test.go b/internal/statusline/statusline_test.go index c73cf1b..e14ffd0 100644 --- a/internal/statusline/statusline_test.go +++ b/internal/statusline/statusline_test.go @@ -916,13 +916,19 @@ func TestGetEffectiveGitDir_NonGitProjectDir_GitCurrentDir(t *testing.T) { // Create initial commit readmeFile := filepath.Join(gitRepoDir, "README.md") - os.WriteFile(readmeFile, []byte("# Test\n"), 0644) + if err := os.WriteFile(readmeFile, []byte("# Test\n"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } cmd := exec.Command("git", "add", "README.md") cmd.Dir = gitRepoDir - cmd.Run() + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git add: %v", err) + } cmd = exec.Command("git", "commit", "-m", "Initial commit") cmd.Dir = gitRepoDir - cmd.Run() + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git commit: %v", err) + } sl := &StatusLine{ input: Input{ @@ -935,7 +941,10 @@ func TestGetEffectiveGitDir_NonGitProjectDir_GitCurrentDir(t *testing.T) { result := sl.getEffectiveGitDir() // Resolve symlinks for comparison (macOS /var -> /private/var) - expectedDir, _ := filepath.EvalSymlinks(gitRepoDir) + expectedDir, err := filepath.EvalSymlinks(gitRepoDir) + if err != nil { + t.Fatalf("failed to eval symlinks: %v", err) + } if result != expectedDir { t.Errorf("expected git root %s, got %s", expectedDir, result) } @@ -971,13 +980,19 @@ func TestGetEffectiveGitDir_NonGitProjectDir_GitCurrentDirSubdir(t *testing.T) { // Create initial commit readmeFile := filepath.Join(gitRepoDir, "README.md") - os.WriteFile(readmeFile, []byte("# Test\n"), 0644) + if err := os.WriteFile(readmeFile, []byte("# Test\n"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } cmd := exec.Command("git", "add", "README.md") cmd.Dir = gitRepoDir - cmd.Run() + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git add: %v", err) + } cmd = exec.Command("git", "commit", "-m", "Initial commit") cmd.Dir = gitRepoDir - cmd.Run() + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git commit: %v", err) + } // Create a subdirectory inside the git repo subDir := filepath.Join(gitRepoDir, "src", "components") @@ -996,7 +1011,10 @@ func TestGetEffectiveGitDir_NonGitProjectDir_GitCurrentDirSubdir(t *testing.T) { result := sl.getEffectiveGitDir() // Resolve symlinks for comparison (macOS /var -> /private/var) - expectedDir, _ := filepath.EvalSymlinks(gitRepoDir) + expectedDir, err := filepath.EvalSymlinks(gitRepoDir) + if err != nil { + t.Fatalf("failed to eval symlinks: %v", err) + } if result != expectedDir { t.Errorf("expected git root %s, got %s", expectedDir, result) } @@ -1032,20 +1050,30 @@ func TestRenderLinesChanged_NonGitProjectDir_GitCurrentDir(t *testing.T) { // Create initial commit readmeFile := filepath.Join(gitRepoDir, "README.md") - os.WriteFile(readmeFile, []byte("# Test\n"), 0644) + if err := os.WriteFile(readmeFile, []byte("# Test\n"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } cmd := exec.Command("git", "add", "README.md") cmd.Dir = gitRepoDir - cmd.Run() + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git add: %v", err) + } cmd = exec.Command("git", "commit", "-m", "Initial commit") cmd.Dir = gitRepoDir - cmd.Run() + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git commit: %v", err) + } // Create a staged change so diff stats are non-zero newFile := filepath.Join(gitRepoDir, "new.txt") - os.WriteFile(newFile, []byte("line1\nline2\nline3\n"), 0644) + if err := os.WriteFile(newFile, []byte("line1\nline2\nline3\n"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } cmd = exec.Command("git", "add", "new.txt") cmd.Dir = gitRepoDir - cmd.Run() + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git add: %v", err) + } sl := &StatusLine{ input: Input{ From f65bc81667ceef29abe95246981ffe717020421a Mon Sep 17 00:00:00 2001 From: Matt McKenna Date: Wed, 11 Feb 2026 15:54:59 -0500 Subject: [PATCH 3/4] Extract shared git helper package and add git plugin tests - Extract internal/git package with EffectiveDir, IsRepo, and FindRoot to eliminate duplicate getEffectiveGitDir implementations - Refactor git plugin and statusline to use shared git helpers - Add setupTestGitRepoAt helper and deduplicate git repo setup in tests - Add git plugin test suite for effective-dir fallback behavior Co-Authored-By: Claude Opus 4.6 --- internal/git/git.go | 71 ++++++++++ internal/plugins/git.go | 56 +------- internal/plugins/git_test.go | 184 +++++++++++++++++++++++++ internal/statusline/statusline.go | 49 +------ internal/statusline/statusline_test.go | 155 +++++---------------- 5 files changed, 298 insertions(+), 217 deletions(-) create mode 100644 internal/git/git.go create mode 100644 internal/plugins/git_test.go diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..e44c9f8 --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,71 @@ +package git + +import ( + "bytes" + "context" + "os/exec" + "strings" + "time" + + "github.com/himattm/prism/internal/cache" +) + +const gitTimeout = 500 * time.Millisecond + +// IsRepo checks if a directory is inside a git repository. +func IsRepo(ctx context.Context, dir string) bool { + ctx, cancel := context.WithTimeout(ctx, gitTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "--no-optional-locks", "rev-parse", "--git-dir") + cmd.Dir = dir + return cmd.Run() == nil +} + +// FindRoot runs git rev-parse --show-toplevel from the given directory +// to find the git root. Returns empty string if not in a git repo. +func FindRoot(ctx context.Context, dir string) string { + ctx, cancel := context.WithTimeout(ctx, gitTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "git", "--no-optional-locks", "rev-parse", "--show-toplevel") + cmd.Dir = dir + var out bytes.Buffer + cmd.Stdout = &out + + if err := cmd.Run(); err != nil { + return "" + } + + return strings.TrimSpace(out.String()) +} + +// EffectiveDir returns the best directory for git operations. +// Tries projectDir first, falls back to finding git root from currentDir. +// Results are cached using the provided cache (if non-nil). +func EffectiveDir(ctx context.Context, projectDir, currentDir string, c *cache.Cache) string { + // Fast path: ProjectDir is set and is a git repo + if projectDir != "" { + if IsRepo(ctx, projectDir) { + return projectDir + } + } + + // Fallback: find git root from CurrentDir + if currentDir == "" { + return "" + } + + cacheKey := "git:effective:" + currentDir + if c != nil { + if cached, ok := c.Get(cacheKey); ok { + return cached + } + } + + gitRoot := FindRoot(ctx, currentDir) + + if c != nil { + c.Set(cacheKey, gitRoot, cache.GitTTL) + } + + return gitRoot +} diff --git a/internal/plugins/git.go b/internal/plugins/git.go index 5c7f410..dc0d550 100644 --- a/internal/plugins/git.go +++ b/internal/plugins/git.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/himattm/prism/internal/cache" + "github.com/himattm/prism/internal/git" "github.com/himattm/prism/internal/plugin" ) @@ -93,60 +94,7 @@ func (p *GitPlugin) Execute(ctx context.Context, input plugin.Input) (string, er // getEffectiveGitDir returns the best directory to use for git operations. // Tries ProjectDir first (fast path), falls back to finding git root from CurrentDir. func (p *GitPlugin) getEffectiveGitDir(ctx context.Context, input plugin.Input) string { - projectDir := input.Prism.ProjectDir - currentDir := input.Prism.CurrentDir - - // Fast path: ProjectDir is a git repo - if projectDir != "" && isGitRepo(ctx, projectDir) { - return projectDir - } - - // Fallback: find git root from CurrentDir - if currentDir == "" { - return "" - } - - // Check cache for effective dir resolution - if p.cache != nil { - cacheKey := fmt.Sprintf("git:effective:%s", currentDir) - if cached, ok := p.cache.Get(cacheKey); ok { - if cached == "" { - return "" - } - return cached - } - } - - gitRoot := findGitRootFromDir(ctx, currentDir) - - // Cache the result (even empty, to avoid repeated lookups) - if p.cache != nil { - cacheKey := fmt.Sprintf("git:effective:%s", currentDir) - p.cache.Set(cacheKey, gitRoot, cache.GitTTL) - } - - return gitRoot -} - -// findGitRootFromDir runs git rev-parse --show-toplevel from the given directory -// to find the git root. Returns empty string if not in a git repo. -func findGitRootFromDir(ctx context.Context, dir string) string { - cmd := exec.CommandContext(ctx, "git", "--no-optional-locks", "rev-parse", "--show-toplevel") - cmd.Dir = dir - var out bytes.Buffer - cmd.Stdout = &out - - if err := cmd.Run(); err != nil { - return "" - } - - return strings.TrimSpace(out.String()) -} - -func isGitRepo(ctx context.Context, dir string) bool { - cmd := exec.CommandContext(ctx, "git", "--no-optional-locks", "rev-parse", "--git-dir") - cmd.Dir = dir - return cmd.Run() == nil + return git.EffectiveDir(ctx, input.Prism.ProjectDir, input.Prism.CurrentDir, p.cache) } func getGitBranch(ctx context.Context, dir string) string { diff --git a/internal/plugins/git_test.go b/internal/plugins/git_test.go new file mode 100644 index 0000000..eff336b --- /dev/null +++ b/internal/plugins/git_test.go @@ -0,0 +1,184 @@ +package plugins + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/himattm/prism/internal/cache" + "github.com/himattm/prism/internal/plugin" +) + +// setupGitRepo initializes a git repo in the given directory with a single commit. +func setupGitRepo(t *testing.T, dir string) { + t.Helper() + + commands := [][]string{ + {"git", "init"}, + {"git", "config", "user.email", "test@test.com"}, + {"git", "config", "user.name", "Test"}, + {"git", "checkout", "-b", "main"}, + {"git", "commit", "--allow-empty", "-m", "initial"}, + } + + for _, args := range commands { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("setup command %v failed: %v\n%s", args, err, out) + } + } +} + +func TestGitPlugin_Execute_NonGitProjectDir_GitCurrentDir(t *testing.T) { + // Parent directory is NOT a git repo + parentDir := t.TempDir() + + // Create a subdirectory that IS a git repo + gitRepoDir := filepath.Join(parentDir, "repo") + if err := os.Mkdir(gitRepoDir, 0o755); err != nil { + t.Fatal(err) + } + setupGitRepo(t, gitRepoDir) + + p := &GitPlugin{} + p.SetCache(cache.New()) + + ctx := context.Background() + input := plugin.Input{ + Prism: plugin.PrismContext{ + ProjectDir: parentDir, + CurrentDir: gitRepoDir, + }, + Colors: map[string]string{ + "yellow": "", + "reset": "", + }, + } + + result, err := p.Execute(ctx, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should fall back to CurrentDir and find the branch + if result == "" { + t.Fatal("expected non-empty result when CurrentDir is a git repo, got empty") + } + + if !strings.Contains(result, "main") && !strings.Contains(result, "master") { + t.Errorf("expected result to contain branch name (main or master), got %q", result) + } +} + +func TestGitPlugin_Execute_NonGitProjectDir_NonGitCurrentDir(t *testing.T) { + // Neither directory is a git repo + tempDir := t.TempDir() + + p := &GitPlugin{} + p.SetCache(cache.New()) + + ctx := context.Background() + input := plugin.Input{ + Prism: plugin.PrismContext{ + ProjectDir: tempDir, + CurrentDir: tempDir, + }, + Colors: map[string]string{ + "yellow": "", + "reset": "", + }, + } + + result, err := p.Execute(ctx, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result != "" { + t.Errorf("expected empty result when neither dir is a git repo, got %q", result) + } +} + +func TestGitPlugin_Execute_GitProjectDir(t *testing.T) { + // ProjectDir itself is a git repo + gitRepoDir := t.TempDir() + setupGitRepo(t, gitRepoDir) + + p := &GitPlugin{} + p.SetCache(cache.New()) + + ctx := context.Background() + input := plugin.Input{ + Prism: plugin.PrismContext{ + ProjectDir: gitRepoDir, + CurrentDir: gitRepoDir, + }, + Colors: map[string]string{ + "yellow": "", + "reset": "", + }, + } + + result, err := p.Execute(ctx, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result == "" { + t.Fatal("expected non-empty result when ProjectDir is a git repo, got empty") + } + + if !strings.Contains(result, "main") && !strings.Contains(result, "master") { + t.Errorf("expected result to contain branch name (main or master), got %q", result) + } +} + +func TestGitPlugin_Execute_SubdirOfGitRepo(t *testing.T) { + // Parent is NOT a git repo, but CurrentDir is a subdirectory of one + parentDir := t.TempDir() + + gitRepoDir := filepath.Join(parentDir, "repo") + if err := os.Mkdir(gitRepoDir, 0o755); err != nil { + t.Fatal(err) + } + setupGitRepo(t, gitRepoDir) + + subDir := filepath.Join(gitRepoDir, "subdir") + if err := os.Mkdir(subDir, 0o755); err != nil { + t.Fatal(err) + } + + p := &GitPlugin{} + p.SetCache(cache.New()) + + ctx := context.Background() + input := plugin.Input{ + Prism: plugin.PrismContext{ + ProjectDir: parentDir, + CurrentDir: subDir, + }, + Colors: map[string]string{ + "yellow": "", + "reset": "", + }, + } + + result, err := p.Execute(ctx, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should find the git repo via rev-parse from the subdirectory + if result == "" { + t.Fatal("expected non-empty result when CurrentDir is inside a git repo, got empty") + } + + if !strings.Contains(result, "main") && !strings.Contains(result, "master") { + t.Errorf("expected result to contain branch name (main or master), got %q", result) + } +} diff --git a/internal/statusline/statusline.go b/internal/statusline/statusline.go index 46e135a..ade067e 100644 --- a/internal/statusline/statusline.go +++ b/internal/statusline/statusline.go @@ -13,6 +13,7 @@ import ( "github.com/himattm/prism/internal/cache" "github.com/himattm/prism/internal/colors" "github.com/himattm/prism/internal/config" + "github.com/himattm/prism/internal/git" "github.com/himattm/prism/internal/plugin" "github.com/himattm/prism/internal/plugins" "github.com/himattm/prism/internal/version" @@ -412,48 +413,12 @@ func (sl *StatusLine) renderLinesChanged() string { // getEffectiveGitDir returns the best directory for git operations. // Tries ProjectDir first, falls back to finding git root from CurrentDir. func (sl *StatusLine) getEffectiveGitDir() string { - projectDir := sl.input.Workspace.ProjectDir - currentDir := sl.input.Workspace.CurrentDir - - // Fast path: ProjectDir is set and is a git repo - if projectDir != "" { - if isGitRepo(projectDir) { - return projectDir - } - } - - // Fallback: find git root from CurrentDir - if currentDir == "" { - return "" - } - - cacheKey := "git:effective:" + currentDir - if cached, ok := statusCache.Get(cacheKey); ok { - return cached // empty string cached means "not a git repo" - } - - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - cmd := exec.CommandContext(ctx, "git", "--no-optional-locks", "rev-parse", "--show-toplevel") - cmd.Dir = currentDir - output, err := cmd.Output() - if err != nil { - statusCache.Set(cacheKey, "", cache.GitTTL) - return "" - } - - gitRoot := strings.TrimSpace(string(output)) - statusCache.Set(cacheKey, gitRoot, cache.GitTTL) - return gitRoot -} - -// isGitRepo checks if a directory is inside a git repository -func isGitRepo(dir string) bool { - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() - cmd := exec.CommandContext(ctx, "git", "--no-optional-locks", "rev-parse", "--git-dir") - cmd.Dir = dir - return cmd.Run() == nil + return git.EffectiveDir( + context.Background(), + sl.input.Workspace.ProjectDir, + sl.input.Workspace.CurrentDir, + statusCache, + ) } func getGitDiffStats(projectDir string) (int, int) { diff --git a/internal/statusline/statusline_test.go b/internal/statusline/statusline_test.go index e14ffd0..fa940cb 100644 --- a/internal/statusline/statusline_test.go +++ b/internal/statusline/statusline_test.go @@ -229,50 +229,55 @@ func TestRenderLinesChanged_OutputFormat(t *testing.T) { } } -// setupTestGitRepo creates a temporary git repository for testing -func setupTestGitRepo(t *testing.T) string { +// setupTestGitRepoAt initializes a git repository at the given directory. +// The directory must already exist. Returns the directory path. +func setupTestGitRepoAt(t *testing.T, dir string) string { t.Helper() - tmpDir, err := os.MkdirTemp("", "prism-test-git-*") - if err != nil { - t.Fatal(err) - } - - // Initialize git repo cmds := [][]string{ {"git", "init"}, {"git", "config", "user.email", "test@test.com"}, {"git", "config", "user.name", "Test"}, } - for _, args := range cmds { cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = tmpDir + cmd.Dir = dir if err := cmd.Run(); err != nil { - os.RemoveAll(tmpDir) t.Fatalf("failed to run %v: %v", args, err) } } // Create initial commit - readmeFile := filepath.Join(tmpDir, "README.md") + readmeFile := filepath.Join(dir, "README.md") if err := os.WriteFile(readmeFile, []byte("# Test\n"), 0644); err != nil { - os.RemoveAll(tmpDir) - t.Fatal(err) + t.Fatalf("failed to write file: %v", err) } cmd := exec.Command("git", "add", "README.md") - cmd.Dir = tmpDir - cmd.Run() + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git add: %v", err) + } cmd = exec.Command("git", "commit", "-m", "Initial commit") - cmd.Dir = tmpDir + cmd.Dir = dir if err := cmd.Run(); err != nil { - os.RemoveAll(tmpDir) t.Fatalf("failed to create initial commit: %v", err) } - return tmpDir + return dir +} + +// setupTestGitRepo creates a temporary git repository for testing +func setupTestGitRepo(t *testing.T) string { + t.Helper() + + tmpDir, err := os.MkdirTemp("", "prism-test-git-*") + if err != nil { + t.Fatal(err) + } + + return setupTestGitRepoAt(t, tmpDir) } // TestNew_CreatesStatusLine verifies the constructor works @@ -888,59 +893,28 @@ func TestGetEffectiveGitDir_ProjectDirIsGitRepo(t *testing.T) { // TestGetEffectiveGitDir_NonGitProjectDir_GitCurrentDir falls back to CurrentDir's git root func TestGetEffectiveGitDir_NonGitProjectDir_GitCurrentDir(t *testing.T) { - // Create a non-git parent directory parentDir, err := os.MkdirTemp("", "prism-test-parent-*") if err != nil { t.Fatal(err) } defer os.RemoveAll(parentDir) - // Create a git repo as a subdirectory gitRepoDir := filepath.Join(parentDir, "my-repo") if err := os.MkdirAll(gitRepoDir, 0755); err != nil { t.Fatal(err) } - - cmds := [][]string{ - {"git", "init"}, - {"git", "config", "user.email", "test@test.com"}, - {"git", "config", "user.name", "Test"}, - } - for _, args := range cmds { - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = gitRepoDir - if err := cmd.Run(); err != nil { - t.Fatalf("failed to run %v: %v", args, err) - } - } - - // Create initial commit - readmeFile := filepath.Join(gitRepoDir, "README.md") - if err := os.WriteFile(readmeFile, []byte("# Test\n"), 0644); err != nil { - t.Fatalf("failed to write file: %v", err) - } - cmd := exec.Command("git", "add", "README.md") - cmd.Dir = gitRepoDir - if err := cmd.Run(); err != nil { - t.Fatalf("failed to git add: %v", err) - } - cmd = exec.Command("git", "commit", "-m", "Initial commit") - cmd.Dir = gitRepoDir - if err := cmd.Run(); err != nil { - t.Fatalf("failed to git commit: %v", err) - } + setupTestGitRepoAt(t, gitRepoDir) sl := &StatusLine{ input: Input{ Workspace: WorkspaceInfo{ - ProjectDir: parentDir, // Non-git directory - CurrentDir: gitRepoDir, // Git repo + ProjectDir: parentDir, + CurrentDir: gitRepoDir, }, }, } result := sl.getEffectiveGitDir() - // Resolve symlinks for comparison (macOS /var -> /private/var) expectedDir, err := filepath.EvalSymlinks(gitRepoDir) if err != nil { t.Fatalf("failed to eval symlinks: %v", err) @@ -952,47 +926,17 @@ func TestGetEffectiveGitDir_NonGitProjectDir_GitCurrentDir(t *testing.T) { // TestGetEffectiveGitDir_NonGitProjectDir_GitCurrentDirSubdir finds git root from subdirectory func TestGetEffectiveGitDir_NonGitProjectDir_GitCurrentDirSubdir(t *testing.T) { - // Create a non-git parent directory parentDir, err := os.MkdirTemp("", "prism-test-parent-*") if err != nil { t.Fatal(err) } defer os.RemoveAll(parentDir) - // Create a git repo as a subdirectory gitRepoDir := filepath.Join(parentDir, "my-repo") if err := os.MkdirAll(gitRepoDir, 0755); err != nil { t.Fatal(err) } - - cmds := [][]string{ - {"git", "init"}, - {"git", "config", "user.email", "test@test.com"}, - {"git", "config", "user.name", "Test"}, - } - for _, args := range cmds { - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = gitRepoDir - if err := cmd.Run(); err != nil { - t.Fatalf("failed to run %v: %v", args, err) - } - } - - // Create initial commit - readmeFile := filepath.Join(gitRepoDir, "README.md") - if err := os.WriteFile(readmeFile, []byte("# Test\n"), 0644); err != nil { - t.Fatalf("failed to write file: %v", err) - } - cmd := exec.Command("git", "add", "README.md") - cmd.Dir = gitRepoDir - if err := cmd.Run(); err != nil { - t.Fatalf("failed to git add: %v", err) - } - cmd = exec.Command("git", "commit", "-m", "Initial commit") - cmd.Dir = gitRepoDir - if err := cmd.Run(); err != nil { - t.Fatalf("failed to git commit: %v", err) - } + setupTestGitRepoAt(t, gitRepoDir) // Create a subdirectory inside the git repo subDir := filepath.Join(gitRepoDir, "src", "components") @@ -1003,14 +947,13 @@ func TestGetEffectiveGitDir_NonGitProjectDir_GitCurrentDirSubdir(t *testing.T) { sl := &StatusLine{ input: Input{ Workspace: WorkspaceInfo{ - ProjectDir: parentDir, // Non-git directory - CurrentDir: subDir, // Subdirectory inside a git repo + ProjectDir: parentDir, + CurrentDir: subDir, }, }, } result := sl.getEffectiveGitDir() - // Resolve symlinks for comparison (macOS /var -> /private/var) expectedDir, err := filepath.EvalSymlinks(gitRepoDir) if err != nil { t.Fatalf("failed to eval symlinks: %v", err) @@ -1022,54 +965,24 @@ func TestGetEffectiveGitDir_NonGitProjectDir_GitCurrentDirSubdir(t *testing.T) { // TestRenderLinesChanged_NonGitProjectDir_GitCurrentDir shows diff stats from CurrentDir's repo func TestRenderLinesChanged_NonGitProjectDir_GitCurrentDir(t *testing.T) { - // Create a non-git parent directory parentDir, err := os.MkdirTemp("", "prism-test-parent-*") if err != nil { t.Fatal(err) } defer os.RemoveAll(parentDir) - // Create a git repo as a subdirectory gitRepoDir := filepath.Join(parentDir, "my-repo") if err := os.MkdirAll(gitRepoDir, 0755); err != nil { t.Fatal(err) } - - cmds := [][]string{ - {"git", "init"}, - {"git", "config", "user.email", "test@test.com"}, - {"git", "config", "user.name", "Test"}, - } - for _, args := range cmds { - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = gitRepoDir - if err := cmd.Run(); err != nil { - t.Fatalf("failed to run %v: %v", args, err) - } - } - - // Create initial commit - readmeFile := filepath.Join(gitRepoDir, "README.md") - if err := os.WriteFile(readmeFile, []byte("# Test\n"), 0644); err != nil { - t.Fatalf("failed to write file: %v", err) - } - cmd := exec.Command("git", "add", "README.md") - cmd.Dir = gitRepoDir - if err := cmd.Run(); err != nil { - t.Fatalf("failed to git add: %v", err) - } - cmd = exec.Command("git", "commit", "-m", "Initial commit") - cmd.Dir = gitRepoDir - if err := cmd.Run(); err != nil { - t.Fatalf("failed to git commit: %v", err) - } + setupTestGitRepoAt(t, gitRepoDir) // Create a staged change so diff stats are non-zero newFile := filepath.Join(gitRepoDir, "new.txt") if err := os.WriteFile(newFile, []byte("line1\nline2\nline3\n"), 0644); err != nil { t.Fatalf("failed to write file: %v", err) } - cmd = exec.Command("git", "add", "new.txt") + cmd := exec.Command("git", "add", "new.txt") cmd.Dir = gitRepoDir if err := cmd.Run(); err != nil { t.Fatalf("failed to git add: %v", err) @@ -1078,8 +991,8 @@ func TestRenderLinesChanged_NonGitProjectDir_GitCurrentDir(t *testing.T) { sl := &StatusLine{ input: Input{ Workspace: WorkspaceInfo{ - ProjectDir: parentDir, // Non-git directory - CurrentDir: gitRepoDir, // Git repo with changes + ProjectDir: parentDir, + CurrentDir: gitRepoDir, }, }, } From 7d5fdc53c743d4d5ea8a6077bd9f220bf8d6ddfc Mon Sep 17 00:00:00 2001 From: Matt McKenna Date: Wed, 11 Feb 2026 18:30:59 -0500 Subject: [PATCH 4/4] Fix test flakiness and file mode consistency in git_test.go Use -B instead of -b for git checkout in setupGitRepo to avoid failure when the main branch already exists. Normalize file mode literals from 0o755 to 0755 for consistency with the rest of the codebase. Co-Authored-By: Claude Opus 4.6 --- internal/plugins/git_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/plugins/git_test.go b/internal/plugins/git_test.go index eff336b..324556f 100644 --- a/internal/plugins/git_test.go +++ b/internal/plugins/git_test.go @@ -20,7 +20,7 @@ func setupGitRepo(t *testing.T, dir string) { {"git", "init"}, {"git", "config", "user.email", "test@test.com"}, {"git", "config", "user.name", "Test"}, - {"git", "checkout", "-b", "main"}, + {"git", "checkout", "-B", "main"}, {"git", "commit", "--allow-empty", "-m", "initial"}, } @@ -40,7 +40,7 @@ func TestGitPlugin_Execute_NonGitProjectDir_GitCurrentDir(t *testing.T) { // Create a subdirectory that IS a git repo gitRepoDir := filepath.Join(parentDir, "repo") - if err := os.Mkdir(gitRepoDir, 0o755); err != nil { + if err := os.Mkdir(gitRepoDir, 0755); err != nil { t.Fatal(err) } setupGitRepo(t, gitRepoDir) @@ -143,13 +143,13 @@ func TestGitPlugin_Execute_SubdirOfGitRepo(t *testing.T) { parentDir := t.TempDir() gitRepoDir := filepath.Join(parentDir, "repo") - if err := os.Mkdir(gitRepoDir, 0o755); err != nil { + if err := os.Mkdir(gitRepoDir, 0755); err != nil { t.Fatal(err) } setupGitRepo(t, gitRepoDir) subDir := filepath.Join(gitRepoDir, "subdir") - if err := os.Mkdir(subDir, 0o755); err != nil { + if err := os.Mkdir(subDir, 0755); err != nil { t.Fatal(err) }