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/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 78c1235..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" ) @@ -34,12 +35,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 { @@ -48,22 +49,17 @@ func (p *GitPlugin) Execute(ctx context.Context, input plugin.Input) (string, er } } - // Check if this is a git repo - if !isGitRepo(ctx, projectDir) { - 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,10 +91,10 @@ func (p *GitPlugin) Execute(ctx context.Context, input plugin.Input) (string, er return output, nil } -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 +// 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 { + 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..324556f --- /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, 0755); 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, 0755); err != nil { + t.Fatal(err) + } + setupGitRepo(t, gitRepoDir) + + subDir := filepath.Join(gitRepoDir, "subdir") + if err := os.Mkdir(subDir, 0755); 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 89c8467..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" @@ -401,13 +402,25 @@ 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 { + return git.EffectiveDir( + context.Background(), + sl.input.Workspace.ProjectDir, + sl.input.Workspace.CurrentDir, + statusCache, + ) +} + 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..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 @@ -865,3 +870,137 @@ 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) { + parentDir, err := os.MkdirTemp("", "prism-test-parent-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(parentDir) + + gitRepoDir := filepath.Join(parentDir, "my-repo") + if err := os.MkdirAll(gitRepoDir, 0755); err != nil { + t.Fatal(err) + } + setupTestGitRepoAt(t, gitRepoDir) + + sl := &StatusLine{ + input: Input{ + Workspace: WorkspaceInfo{ + ProjectDir: parentDir, + CurrentDir: gitRepoDir, + }, + }, + } + + result := sl.getEffectiveGitDir() + 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) + } +} + +// TestGetEffectiveGitDir_NonGitProjectDir_GitCurrentDirSubdir finds git root from subdirectory +func TestGetEffectiveGitDir_NonGitProjectDir_GitCurrentDirSubdir(t *testing.T) { + parentDir, err := os.MkdirTemp("", "prism-test-parent-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(parentDir) + + gitRepoDir := filepath.Join(parentDir, "my-repo") + if err := os.MkdirAll(gitRepoDir, 0755); err != nil { + t.Fatal(err) + } + setupTestGitRepoAt(t, gitRepoDir) + + // 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, + CurrentDir: subDir, + }, + }, + } + + result := sl.getEffectiveGitDir() + 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) + } +} + +// TestRenderLinesChanged_NonGitProjectDir_GitCurrentDir shows diff stats from CurrentDir's repo +func TestRenderLinesChanged_NonGitProjectDir_GitCurrentDir(t *testing.T) { + parentDir, err := os.MkdirTemp("", "prism-test-parent-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(parentDir) + + gitRepoDir := filepath.Join(parentDir, "my-repo") + if err := os.MkdirAll(gitRepoDir, 0755); err != nil { + t.Fatal(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.Dir = gitRepoDir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to git add: %v", err) + } + + sl := &StatusLine{ + input: Input{ + Workspace: WorkspaceInfo{ + ProjectDir: parentDir, + CurrentDir: gitRepoDir, + }, + }, + } + + 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) + } +}