diff --git a/commands/cc_statusline.go b/commands/cc_statusline.go index 2871fda..9c551aa 100644 --- a/commands/cc_statusline.go +++ b/commands/cc_statusline.go @@ -22,6 +22,14 @@ var CCStatuslineCommand = &cli.Command{ Action: commandCCStatusline, } +// ccStatuslineResult combines daily stats with git info from daemon +type ccStatuslineResult struct { + Cost float64 + SessionSeconds int + GitBranch string + GitDirty bool +} + func commandCCStatusline(c *cli.Context) error { // Hard timeout for entire operation - statusline must be fast ctx, cancel := context.WithTimeout(c.Context, 100*time.Millisecond) @@ -44,15 +52,15 @@ func commandCCStatusline(c *cli.Context) error { // Calculate context percentage contextPercent := calculateContextPercent(data.ContextWindow) - // Get daily stats - try daemon first, fallback to direct API - var dailyStats model.CCStatuslineDailyStats + // Get daily stats and git info - try daemon first, fallback to direct API + var result ccStatuslineResult config, err := configService.ReadConfigFile(ctx) if err == nil { - dailyStats = getDailyStatsWithDaemonFallback(ctx, config) + result = getDaemonInfoWithFallback(ctx, config, data.WorkingDirectory) } // Format and output - output := formatStatuslineOutput(data.Model.DisplayName, data.Cost.TotalCostUSD, dailyStats.Cost, dailyStats.SessionSeconds, contextPercent) + output := formatStatuslineOutput(data.Model.DisplayName, data.Cost.TotalCostUSD, result.Cost, result.SessionSeconds, contextPercent, result.GitBranch, result.GitDirty) fmt.Println(output) return nil @@ -108,9 +116,20 @@ func calculateContextPercent(cw model.CCStatuslineContextWindow) float64 { return float64(currentTokens) / float64(cw.ContextWindowSize) * 100 } -func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, sessionSeconds int, contextPercent float64) string { +func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, sessionSeconds int, contextPercent float64, gitBranch string, gitDirty bool) string { var parts []string + // Git info FIRST (green) + if gitBranch != "" { + gitStr := gitBranch + if gitDirty { + gitStr += "*" + } + parts = append(parts, color.Green.Sprintf("🌿 %s", gitStr)) + } else { + parts = append(parts, color.Gray.Sprint("🌿 -")) + } + // Model name modelStr := fmt.Sprintf("🤖 %s", modelName) parts = append(parts, modelStr) @@ -151,7 +170,7 @@ func formatStatuslineOutput(modelName string, sessionCost, dailyCost float64, se } func outputFallback() { - fmt.Println(color.Gray.Sprint("🤖 - | 💰 - | 📊 - | ⏱️ - | 📈 -%")) + fmt.Println(color.Gray.Sprint("🌿 - | 🤖 - | 💰 - | 📊 - | ⏱️ - | 📈 -%")) } // formatSessionDuration formats seconds into a human-readable duration @@ -169,9 +188,9 @@ func formatSessionDuration(totalSeconds int) string { return fmt.Sprintf("%ds", seconds) } -// getDailyStatsWithDaemonFallback tries to get daily stats from daemon first, -// falls back to direct API if daemon is unavailable -func getDailyStatsWithDaemonFallback(ctx context.Context, config model.ShellTimeConfig) model.CCStatuslineDailyStats { +// getDaemonInfoWithFallback tries to get daily stats and git info from daemon first, +// falls back to direct API for stats if daemon is unavailable (git info only from daemon) +func getDaemonInfoWithFallback(ctx context.Context, config model.ShellTimeConfig, workingDir string) ccStatuslineResult { socketPath := config.SocketPath if socketPath == "" { socketPath = model.DefaultSocketPath @@ -179,15 +198,21 @@ func getDailyStatsWithDaemonFallback(ctx context.Context, config model.ShellTime // Try daemon first (50ms timeout for fast path) if daemon.IsSocketReady(ctx, socketPath) { - resp, err := daemon.RequestCCInfo(socketPath, daemon.CCInfoTimeRangeToday, 50*time.Millisecond) + resp, err := daemon.RequestCCInfo(socketPath, daemon.CCInfoTimeRangeToday, workingDir, 50*time.Millisecond) if err == nil && resp != nil { - return model.CCStatuslineDailyStats{ + return ccStatuslineResult{ Cost: resp.TotalCostUSD, SessionSeconds: resp.TotalSessionSeconds, + GitBranch: resp.GitBranch, + GitDirty: resp.GitDirty, } } } - // Fallback to direct API (existing behavior) - return model.FetchDailyStatsCached(ctx, config) + // Fallback to direct API for stats (no git info available without daemon) + stats := model.FetchDailyStatsCached(ctx, config) + return ccStatuslineResult{ + Cost: stats.Cost, + SessionSeconds: stats.SessionSeconds, + } } diff --git a/commands/cc_statusline_test.go b/commands/cc_statusline_test.go index 7df2cbd..2dc29c1 100644 --- a/commands/cc_statusline_test.go +++ b/commands/cc_statusline_test.go @@ -40,9 +40,9 @@ func (s *CCStatuslineTestSuite) TearDownTest() { os.Remove(s.socketPath) } -// getDailyCostWithDaemonFallback Tests +// getDaemonInfoWithFallback Tests -func (s *CCStatuslineTestSuite) TestGetDailyStats_UsesDaemonWhenAvailable() { +func (s *CCStatuslineTestSuite) TestGetDaemonInfo_UsesDaemonWhenAvailable() { // Start mock daemon socket listener, err := net.Listen("unix", s.socketPath) assert.NoError(s.T(), err) @@ -50,6 +50,8 @@ func (s *CCStatuslineTestSuite) TestGetDailyStats_UsesDaemonWhenAvailable() { expectedCost := 15.67 expectedSessionSeconds := 3600 + expectedBranch := "main" + expectedDirty := true go func() { conn, _ := listener.Accept() defer conn.Close() @@ -62,6 +64,8 @@ func (s *CCStatuslineTestSuite) TestGetDailyStats_UsesDaemonWhenAvailable() { TotalSessionSeconds: expectedSessionSeconds, TimeRange: "today", CachedAt: time.Now(), + GitBranch: expectedBranch, + GitDirty: expectedDirty, } json.NewEncoder(conn).Encode(response) }() @@ -72,27 +76,31 @@ func (s *CCStatuslineTestSuite) TestGetDailyStats_UsesDaemonWhenAvailable() { SocketPath: s.socketPath, } - stats := getDailyStatsWithDaemonFallback(context.Background(), config) + result := getDaemonInfoWithFallback(context.Background(), config, "/some/path") - assert.Equal(s.T(), expectedCost, stats.Cost) - assert.Equal(s.T(), expectedSessionSeconds, stats.SessionSeconds) + assert.Equal(s.T(), expectedCost, result.Cost) + assert.Equal(s.T(), expectedSessionSeconds, result.SessionSeconds) + assert.Equal(s.T(), expectedBranch, result.GitBranch) + assert.Equal(s.T(), expectedDirty, result.GitDirty) } -func (s *CCStatuslineTestSuite) TestGetDailyStats_FallbackWhenDaemonUnavailable() { +func (s *CCStatuslineTestSuite) TestGetDaemonInfo_FallbackWhenDaemonUnavailable() { // No socket exists, should fall back to cached API config := model.ShellTimeConfig{ SocketPath: "/nonexistent/socket.sock", Token: "", // No token means FetchDailyStatsCached returns zero values } - stats := getDailyStatsWithDaemonFallback(context.Background(), config) + result := getDaemonInfoWithFallback(context.Background(), config, "") // Should return zero values (from cache fallback with no token) - assert.Equal(s.T(), float64(0), stats.Cost) - assert.Equal(s.T(), 0, stats.SessionSeconds) + assert.Equal(s.T(), float64(0), result.Cost) + assert.Equal(s.T(), 0, result.SessionSeconds) + assert.Empty(s.T(), result.GitBranch) + assert.False(s.T(), result.GitDirty) } -func (s *CCStatuslineTestSuite) TestGetDailyStats_FallbackOnDaemonError() { +func (s *CCStatuslineTestSuite) TestGetDaemonInfo_FallbackOnDaemonError() { // Start mock daemon that returns error listener, err := net.Listen("unix", s.socketPath) assert.NoError(s.T(), err) @@ -111,14 +119,16 @@ func (s *CCStatuslineTestSuite) TestGetDailyStats_FallbackOnDaemonError() { Token: "", // No token } - stats := getDailyStatsWithDaemonFallback(context.Background(), config) + result := getDaemonInfoWithFallback(context.Background(), config, "") // Should fall back and return zero values - assert.Equal(s.T(), float64(0), stats.Cost) - assert.Equal(s.T(), 0, stats.SessionSeconds) + assert.Equal(s.T(), float64(0), result.Cost) + assert.Equal(s.T(), 0, result.SessionSeconds) + assert.Empty(s.T(), result.GitBranch) + assert.False(s.T(), result.GitDirty) } -func (s *CCStatuslineTestSuite) TestGetDailyStats_UsesDefaultSocketPath() { +func (s *CCStatuslineTestSuite) TestGetDaemonInfo_UsesDefaultSocketPath() { // Test that default socket path is used when config is empty config := model.ShellTimeConfig{ SocketPath: "", // Empty path - should use model.DefaultSocketPath @@ -127,22 +137,23 @@ func (s *CCStatuslineTestSuite) TestGetDailyStats_UsesDefaultSocketPath() { // This should use model.DefaultSocketPath internally // Since no daemon is running at the default path, it will fall back to cached API - // The function should not panic and should return a valid stats struct - stats := getDailyStatsWithDaemonFallback(context.Background(), config) + // The function should not panic and should return a valid result struct + result := getDaemonInfoWithFallback(context.Background(), config, "") // We can't assert on exact values since the global cache might have data // from previous tests. Just verify the function returns without error // and returns non-negative values - assert.GreaterOrEqual(s.T(), stats.Cost, float64(0)) - assert.GreaterOrEqual(s.T(), stats.SessionSeconds, 0) + assert.GreaterOrEqual(s.T(), result.Cost, float64(0)) + assert.GreaterOrEqual(s.T(), result.SessionSeconds, 0) } // formatStatuslineOutput Tests func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_AllValues() { - output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0) + output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "main", false) // Should contain all components + assert.Contains(s.T(), output, "🌿 main") assert.Contains(s.T(), output, "🤖 claude-opus-4") assert.Contains(s.T(), output, "$1.23") assert.Contains(s.T(), output, "$4.56") @@ -150,8 +161,24 @@ func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_AllValues() { assert.Contains(s.T(), output, "75%") // Context percentage } +func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_WithDirtyBranch() { + output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "feature/test", true) + + // Should contain branch with asterisk for dirty + assert.Contains(s.T(), output, "🌿 feature/test*") + assert.Contains(s.T(), output, "🤖 claude-opus-4") +} + +func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_NoBranch() { + output := formatStatuslineOutput("claude-opus-4", 1.23, 4.56, 3661, 75.0, "", false) + + // Should show "-" for no branch + assert.Contains(s.T(), output, "🌿 -") + assert.Contains(s.T(), output, "🤖 claude-opus-4") +} + func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_ZeroDailyCost() { - output := formatStatuslineOutput("claude-sonnet", 0.50, 0, 300, 50.0) + output := formatStatuslineOutput("claude-sonnet", 0.50, 0, 300, 50.0, "main", false) // Should show "-" for zero daily cost assert.Contains(s.T(), output, "📊 -") @@ -159,14 +186,14 @@ func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_ZeroDailyCost() { } func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_ZeroSessionSeconds() { - output := formatStatuslineOutput("claude-sonnet", 0.50, 1.0, 0, 50.0) + output := formatStatuslineOutput("claude-sonnet", 0.50, 1.0, 0, 50.0, "main", false) // Should show "-" for zero session seconds assert.Contains(s.T(), output, "⏱️ -") } func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_HighContextPercentage() { - output := formatStatuslineOutput("test-model", 1.0, 1.0, 60, 85.0) + output := formatStatuslineOutput("test-model", 1.0, 1.0, 60, 85.0, "main", false) // Should contain the percentage (color codes may vary) assert.Contains(s.T(), output, "85%") @@ -174,7 +201,7 @@ func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_HighContextPercentage } func (s *CCStatuslineTestSuite) TestFormatStatuslineOutput_LowContextPercentage() { - output := formatStatuslineOutput("test-model", 1.0, 1.0, 45, 25.0) + output := formatStatuslineOutput("test-model", 1.0, 1.0, 45, 25.0, "main", false) // Should contain the percentage assert.Contains(s.T(), output, "25%") diff --git a/daemon/cc_info_handler_test.go b/daemon/cc_info_handler_test.go index 1ac2ad1..a470fab 100644 --- a/daemon/cc_info_handler_test.go +++ b/daemon/cc_info_handler_test.go @@ -232,7 +232,7 @@ func (s *CCInfoClientTestSuite) TestRequestCCInfo_Success() { // Give server time to start time.Sleep(10 * time.Millisecond) - response, err := RequestCCInfo(s.socketPath, CCInfoTimeRangeToday, 1*time.Second) + response, err := RequestCCInfo(s.socketPath, CCInfoTimeRangeToday, "", 1*time.Second) assert.NoError(s.T(), err) assert.NotNil(s.T(), response) @@ -255,14 +255,14 @@ func (s *CCInfoClientTestSuite) TestRequestCCInfo_Timeout() { time.Sleep(10 * time.Millisecond) - response, err := RequestCCInfo(s.socketPath, CCInfoTimeRangeToday, 50*time.Millisecond) + response, err := RequestCCInfo(s.socketPath, CCInfoTimeRangeToday, "", 50*time.Millisecond) assert.Error(s.T(), err) assert.Nil(s.T(), response) } func (s *CCInfoClientTestSuite) TestRequestCCInfo_SocketNotFound() { - response, err := RequestCCInfo("/nonexistent/socket.sock", CCInfoTimeRangeToday, 100*time.Millisecond) + response, err := RequestCCInfo("/nonexistent/socket.sock", CCInfoTimeRangeToday, "", 100*time.Millisecond) assert.Error(s.T(), err) assert.Nil(s.T(), response) @@ -287,7 +287,7 @@ func (s *CCInfoClientTestSuite) TestRequestCCInfo_InvalidResponse() { time.Sleep(10 * time.Millisecond) - response, err := RequestCCInfo(s.socketPath, CCInfoTimeRangeToday, 1*time.Second) + response, err := RequestCCInfo(s.socketPath, CCInfoTimeRangeToday, "", 1*time.Second) assert.Error(s.T(), err) assert.Nil(s.T(), response) @@ -312,7 +312,7 @@ func (s *CCInfoClientTestSuite) TestRequestCCInfo_SendsCorrectMessage() { time.Sleep(10 * time.Millisecond) - RequestCCInfo(s.socketPath, CCInfoTimeRangeWeek, 1*time.Second) + RequestCCInfo(s.socketPath, CCInfoTimeRangeWeek, "", 1*time.Second) assert.Equal(s.T(), SocketMessageTypeCCInfo, receivedMsg.Type) diff --git a/daemon/client.go b/daemon/client.go index c053f1e..0b9d4cc 100644 --- a/daemon/client.go +++ b/daemon/client.go @@ -51,8 +51,8 @@ func SendLocalDataToSocket( return nil } -// RequestCCInfo requests CC info (cost data) from the daemon -func RequestCCInfo(socketPath string, timeRange CCInfoTimeRange, timeout time.Duration) (*CCInfoResponse, error) { +// RequestCCInfo requests CC info (cost data and git info) from the daemon +func RequestCCInfo(socketPath string, timeRange CCInfoTimeRange, workingDir string, timeout time.Duration) (*CCInfoResponse, error) { conn, err := net.DialTimeout("unix", socketPath, timeout) if err != nil { return nil, err @@ -66,7 +66,8 @@ func RequestCCInfo(socketPath string, timeRange CCInfoTimeRange, timeout time.Du msg := SocketMessage{ Type: SocketMessageTypeCCInfo, Payload: CCInfoRequest{ - TimeRange: timeRange, + TimeRange: timeRange, + WorkingDirectory: workingDir, }, } diff --git a/daemon/client_test.go b/daemon/client_test.go index ca3bea4..78c6add 100644 --- a/daemon/client_test.go +++ b/daemon/client_test.go @@ -157,7 +157,7 @@ func TestRequestCCInfo(t *testing.T) { // Give server time to start time.Sleep(50 * time.Millisecond) - response, err := RequestCCInfo(socketPath, CCInfoTimeRangeToday, 5*time.Second) + response, err := RequestCCInfo(socketPath, CCInfoTimeRangeToday, "", 5*time.Second) if err != nil { t.Fatalf("RequestCCInfo failed: %v", err) } @@ -200,14 +200,14 @@ func TestRequestCCInfo_Timeout(t *testing.T) { // Give server time to start time.Sleep(50 * time.Millisecond) - _, err = RequestCCInfo(socketPath, CCInfoTimeRangeToday, 100*time.Millisecond) + _, err = RequestCCInfo(socketPath, CCInfoTimeRangeToday, "", 100*time.Millisecond) if err == nil { t.Error("Expected timeout error") } } func TestRequestCCInfo_SocketNotExists(t *testing.T) { - _, err := RequestCCInfo("/nonexistent/socket.sock", CCInfoTimeRangeToday, 1*time.Second) + _, err := RequestCCInfo("/nonexistent/socket.sock", CCInfoTimeRangeToday, "", 1*time.Second) if err == nil { t.Error("Expected error when socket doesn't exist") } @@ -261,7 +261,7 @@ func TestRequestCCInfo_AllTimeRanges(t *testing.T) { time.Sleep(50 * time.Millisecond) - response, err := RequestCCInfo(socketPath, timeRange, 5*time.Second) + response, err := RequestCCInfo(socketPath, timeRange, "", 5*time.Second) if err != nil { t.Fatalf("RequestCCInfo failed: %v", err) } diff --git a/daemon/git.go b/daemon/git.go new file mode 100644 index 0000000..2e9af40 --- /dev/null +++ b/daemon/git.go @@ -0,0 +1,46 @@ +package daemon + +import ( + "github.com/go-git/go-git/v5" +) + +// GitInfo contains git repository information +type GitInfo struct { + Branch string + Dirty bool + IsRepo bool +} + +// GetGitInfo returns git branch and dirty status for a directory. +// It walks up the directory tree to find the git repository root. +func GetGitInfo(workingDir string) GitInfo { + if workingDir == "" { + return GitInfo{} + } + + repo, err := git.PlainOpenWithOptions(workingDir, &git.PlainOpenOptions{ + DetectDotGit: true, // Walk up to find .git + }) + if err != nil { + return GitInfo{} // Not a git repo + } + + info := GitInfo{IsRepo: true} + + // Get branch name from HEAD + head, err := repo.Head() + if err == nil { + info.Branch = head.Name().Short() + } + + // Check dirty status by examining worktree status + worktree, err := repo.Worktree() + if err == nil { + status, err := worktree.Status() + if err == nil { + info.Dirty = !status.IsClean() + } + } + + return info +} diff --git a/daemon/git_test.go b/daemon/git_test.go new file mode 100644 index 0000000..996c61a --- /dev/null +++ b/daemon/git_test.go @@ -0,0 +1,188 @@ +package daemon + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type GitInfoTestSuite struct { + suite.Suite + tempDir string +} + +func (s *GitInfoTestSuite) SetupTest() { + var err error + s.tempDir, err = os.MkdirTemp("", "git-info-test-*") + assert.NoError(s.T(), err) +} + +func (s *GitInfoTestSuite) TearDownTest() { + os.RemoveAll(s.tempDir) +} + +func (s *GitInfoTestSuite) TestGetGitInfo_EmptyWorkingDir() { + info := GetGitInfo("") + assert.False(s.T(), info.IsRepo) + assert.Empty(s.T(), info.Branch) + assert.False(s.T(), info.Dirty) +} + +func (s *GitInfoTestSuite) TestGetGitInfo_NonGitDirectory() { + info := GetGitInfo(s.tempDir) + assert.False(s.T(), info.IsRepo) + assert.Empty(s.T(), info.Branch) + assert.False(s.T(), info.Dirty) +} + +func (s *GitInfoTestSuite) TestGetGitInfo_NonExistentDirectory() { + info := GetGitInfo("/nonexistent/path/that/does/not/exist") + assert.False(s.T(), info.IsRepo) + assert.Empty(s.T(), info.Branch) + assert.False(s.T(), info.Dirty) +} + +func (s *GitInfoTestSuite) TestGetGitInfo_GitRepo_CleanState() { + // Initialize git repo + cmd := exec.Command("git", "init") + cmd.Dir = s.tempDir + err := cmd.Run() + if err != nil { + s.T().Skip("git not available") + } + + // Configure git user (required for commits) + exec.Command("git", "-C", s.tempDir, "config", "user.email", "test@test.com").Run() + exec.Command("git", "-C", s.tempDir, "config", "user.name", "Test User").Run() + + // Create initial commit to establish HEAD + testFile := filepath.Join(s.tempDir, "test.txt") + os.WriteFile(testFile, []byte("test"), 0644) + exec.Command("git", "-C", s.tempDir, "add", ".").Run() + exec.Command("git", "-C", s.tempDir, "commit", "-m", "initial").Run() + + info := GetGitInfo(s.tempDir) + assert.True(s.T(), info.IsRepo) + assert.NotEmpty(s.T(), info.Branch) + assert.False(s.T(), info.Dirty) +} + +func (s *GitInfoTestSuite) TestGetGitInfo_GitRepo_DirtyState() { + // Initialize git repo + cmd := exec.Command("git", "init") + cmd.Dir = s.tempDir + err := cmd.Run() + if err != nil { + s.T().Skip("git not available") + } + + // Configure git user + exec.Command("git", "-C", s.tempDir, "config", "user.email", "test@test.com").Run() + exec.Command("git", "-C", s.tempDir, "config", "user.name", "Test User").Run() + + // Create initial commit + testFile := filepath.Join(s.tempDir, "test.txt") + os.WriteFile(testFile, []byte("test"), 0644) + exec.Command("git", "-C", s.tempDir, "add", ".").Run() + exec.Command("git", "-C", s.tempDir, "commit", "-m", "initial").Run() + + // Make a change (dirty state) + os.WriteFile(testFile, []byte("modified"), 0644) + + info := GetGitInfo(s.tempDir) + assert.True(s.T(), info.IsRepo) + assert.NotEmpty(s.T(), info.Branch) + assert.True(s.T(), info.Dirty) +} + +func (s *GitInfoTestSuite) TestGetGitInfo_GitRepo_UntrackedFile() { + // Initialize git repo + cmd := exec.Command("git", "init") + cmd.Dir = s.tempDir + err := cmd.Run() + if err != nil { + s.T().Skip("git not available") + } + + // Configure git user + exec.Command("git", "-C", s.tempDir, "config", "user.email", "test@test.com").Run() + exec.Command("git", "-C", s.tempDir, "config", "user.name", "Test User").Run() + + // Create initial commit + testFile := filepath.Join(s.tempDir, "test.txt") + os.WriteFile(testFile, []byte("test"), 0644) + exec.Command("git", "-C", s.tempDir, "add", ".").Run() + exec.Command("git", "-C", s.tempDir, "commit", "-m", "initial").Run() + + // Add untracked file (makes repo dirty) + untrackedFile := filepath.Join(s.tempDir, "untracked.txt") + os.WriteFile(untrackedFile, []byte("untracked"), 0644) + + info := GetGitInfo(s.tempDir) + assert.True(s.T(), info.IsRepo) + assert.True(s.T(), info.Dirty, "untracked files should make repo dirty") +} + +func (s *GitInfoTestSuite) TestGetGitInfo_GitRepo_BranchName() { + // Initialize git repo + cmd := exec.Command("git", "init") + cmd.Dir = s.tempDir + err := cmd.Run() + if err != nil { + s.T().Skip("git not available") + } + + // Configure git user + exec.Command("git", "-C", s.tempDir, "config", "user.email", "test@test.com").Run() + exec.Command("git", "-C", s.tempDir, "config", "user.name", "Test User").Run() + + // Create initial commit on main/master + testFile := filepath.Join(s.tempDir, "test.txt") + os.WriteFile(testFile, []byte("test"), 0644) + exec.Command("git", "-C", s.tempDir, "add", ".").Run() + exec.Command("git", "-C", s.tempDir, "commit", "-m", "initial").Run() + + // Create and checkout a new branch + exec.Command("git", "-C", s.tempDir, "checkout", "-b", "feature/test-branch").Run() + + info := GetGitInfo(s.tempDir) + assert.True(s.T(), info.IsRepo) + assert.Equal(s.T(), "feature/test-branch", info.Branch) +} + +func (s *GitInfoTestSuite) TestGetGitInfo_Subdirectory() { + // Initialize git repo + cmd := exec.Command("git", "init") + cmd.Dir = s.tempDir + err := cmd.Run() + if err != nil { + s.T().Skip("git not available") + } + + // Configure git user + exec.Command("git", "-C", s.tempDir, "config", "user.email", "test@test.com").Run() + exec.Command("git", "-C", s.tempDir, "config", "user.name", "Test User").Run() + + // Create initial commit + testFile := filepath.Join(s.tempDir, "test.txt") + os.WriteFile(testFile, []byte("test"), 0644) + exec.Command("git", "-C", s.tempDir, "add", ".").Run() + exec.Command("git", "-C", s.tempDir, "commit", "-m", "initial").Run() + + // Create subdirectory + subDir := filepath.Join(s.tempDir, "src", "components") + os.MkdirAll(subDir, 0755) + + // GetGitInfo from subdirectory should still work (DetectDotGit: true) + info := GetGitInfo(subDir) + assert.True(s.T(), info.IsRepo) + assert.NotEmpty(s.T(), info.Branch) +} + +func TestGitInfoTestSuite(t *testing.T) { + suite.Run(t, new(GitInfoTestSuite)) +} diff --git a/daemon/socket.go b/daemon/socket.go index c56d2f9..f2f129a 100644 --- a/daemon/socket.go +++ b/daemon/socket.go @@ -32,7 +32,8 @@ const ( ) type CCInfoRequest struct { - TimeRange CCInfoTimeRange `json:"timeRange"` + TimeRange CCInfoTimeRange `json:"timeRange"` + WorkingDirectory string `json:"workingDirectory"` } type CCInfoResponse struct { @@ -40,6 +41,8 @@ type CCInfoResponse struct { TotalSessionSeconds int `json:"totalSessionSeconds"` TimeRange string `json:"timeRange"` CachedAt time.Time `json:"cachedAt"` + GitBranch string `json:"gitBranch"` + GitDirty bool `json:"gitDirty"` } // StatusResponse contains daemon status information @@ -197,23 +200,32 @@ func (p *SocketHandler) handleStatus(conn net.Conn) { func (p *SocketHandler) handleCCInfo(conn net.Conn, msg SocketMessage) { slog.Debug("cc_info socket event received") - // Parse time range from payload, default to "today" + // Parse time range and working directory from payload timeRange := CCInfoTimeRangeToday + var workingDir string if payload, ok := msg.Payload.(map[string]interface{}); ok { if tr, ok := payload["timeRange"].(string); ok { timeRange = CCInfoTimeRange(tr) } + if wd, ok := payload["workingDirectory"].(string); ok { + workingDir = wd + } } // Get cached cost first (marks range as active), then notify activity (starts timer) cache := p.ccInfoTimer.GetCachedCost(timeRange) p.ccInfoTimer.NotifyActivity() + // Get git info (fast, no caching needed since it changes frequently) + gitInfo := GetGitInfo(workingDir) + response := CCInfoResponse{ TotalCostUSD: cache.TotalCostUSD, TotalSessionSeconds: cache.TotalSessionSeconds, TimeRange: string(timeRange), CachedAt: cache.FetchedAt, + GitBranch: gitInfo.Branch, + GitDirty: gitInfo.Dirty, } encoder := json.NewEncoder(conn) diff --git a/go.mod b/go.mod index 5bacf2c..3886a46 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,9 @@ require ( atomicgo.dev/cursor v0.2.0 // indirect atomicgo.dev/keyboard v0.2.9 // indirect atomicgo.dev/schedule v0.1.0 // indirect + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect @@ -38,10 +41,12 @@ require ( github.com/clipperhouse/displaywidth v0.6.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/cloudflare/circl v1.6.1 // indirect github.com/containerd/console v1.0.5 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect @@ -49,8 +54,10 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-resty/resty/v2 v2.17.0 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/mailru/easyjson v0.7.7 // indirect @@ -64,8 +71,10 @@ require ( github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -80,6 +89,7 @@ require ( go.opentelemetry.io/otel/sdk v1.39.0 // indirect go.opentelemetry.io/otel/sdk/log v0.15.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect + golang.org/x/crypto v0.46.0 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/term v0.38.0 // indirect diff --git a/go.sum b/go.sum index 6c62b77..ba1d4a0 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,7 @@ github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/ github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/PromptPal/go-sdk v0.4.2 h1:onABRhxodJ285s7CQaXTt7cmpPQ54ztTW5FMlXfGoM0= @@ -25,6 +26,10 @@ github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNx github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= github.com/ThreeDotsLabs/watermill v1.5.1/go.mod h1:Uop10dA3VeJWsSvis9qO3vbVY892LARrKAdki6WtXS4= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= @@ -52,12 +57,16 @@ github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM= @@ -161,11 +170,13 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -223,6 +234,7 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= @@ -231,6 +243,7 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= @@ -239,13 +252,16 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -261,6 +277,7 @@ golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= diff --git a/model/cc_statusline_types.go b/model/cc_statusline_types.go index 58b9d9f..265fb60 100644 --- a/model/cc_statusline_types.go +++ b/model/cc_statusline_types.go @@ -2,9 +2,10 @@ package model // CCStatuslineInput represents the JSON input from Claude Code statusline type CCStatuslineInput struct { - Model CCStatuslineModel `json:"model"` - Cost CCStatuslineCost `json:"cost"` - ContextWindow CCStatuslineContextWindow `json:"context_window"` + Model CCStatuslineModel `json:"model"` + Cost CCStatuslineCost `json:"cost"` + ContextWindow CCStatuslineContextWindow `json:"context_window"` + WorkingDirectory string `json:"working_directory"` } // CCStatuslineModel represents model information