Skip to content
Merged
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
21 changes: 21 additions & 0 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
71 changes: 71 additions & 0 deletions internal/git/git.go
Original file line number Diff line number Diff line change
@@ -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
}
26 changes: 11 additions & 15 deletions internal/plugins/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"

"github.com/himattm/prism/internal/cache"
"github.com/himattm/prism/internal/git"
"github.com/himattm/prism/internal/plugin"
)

Expand All @@ -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 {
Expand All @@ -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"]
Expand Down Expand Up @@ -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 {
Expand Down
184 changes: 184 additions & 0 deletions internal/plugins/git_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
15 changes: 14 additions & 1 deletion internal/statusline/statusline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
)
Comment on lines +415 to +421
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getEffectiveGitDir uses context.Background() which means these git probes can take up to the full internal timeout(s) independent of the statusline render timeout, and getGitDiffStats still runs git without any context/timeout. Consider threading a single timeout context through getEffectiveGitDir (and switching getGitDiffStats to exec.CommandContext) so a slow/hung git call can't stall statusline rendering.

Copilot uses AI. Check for mistakes.
}

func getGitDiffStats(projectDir string) (int, int) {
if projectDir == "" {
return 0, 0
Expand Down
Loading
Loading