From 38a75d99e4a751e9c509a4fd124d86649d9693b7 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Thu, 8 Jan 2026 22:43:46 +0800 Subject: [PATCH] fix(daemon): add per-CWD git info cache for cc statusline Previously, the daemon stored only a single activeWorkingDir and gitInfo, causing race conditions when parallel requests came from different CWDs. Now each working directory has its own cache entry with separate git info, preventing incorrect branch/dirty status from being returned. - Add GitCacheEntry struct with Info, LastAccessed, and LastFetched fields - Replace single cache with map[string]*GitCacheEntry keyed by normalized path - Background fetch iterates over all recently accessed directories - Stale entries evicted after inactivity timeout Co-Authored-By: Claude Opus 4.5 --- daemon/cc_info_timer.go | 86 ++++++++++++++---- daemon/cc_info_timer_test.go | 171 +++++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+), 20 deletions(-) diff --git a/daemon/cc_info_timer.go b/daemon/cc_info_timer.go index 43d230d..e1814fc 100644 --- a/daemon/cc_info_timer.go +++ b/daemon/cc_info_timer.go @@ -3,6 +3,7 @@ package daemon import ( "context" "log/slog" + "path/filepath" "sync" "time" @@ -21,6 +22,12 @@ type CCInfoCache struct { FetchedAt time.Time } +// GitCacheEntry holds cached git info for a single working directory +type GitCacheEntry struct { + Info GitInfo + LastAccessed time.Time + LastFetched time.Time +} // CCInfoTimerService manages lazy-fetching of CC info data type CCInfoTimerService struct { @@ -37,9 +44,8 @@ type CCInfoTimerService struct { stopChan chan struct{} wg sync.WaitGroup - // Git info (single active working directory, fetched by timer) - activeWorkingDir string - gitInfo GitInfo + // Git info cache (per working directory) + gitCache map[string]*GitCacheEntry } // NewCCInfoTimerService creates a new CC info timer service @@ -48,6 +54,7 @@ func NewCCInfoTimerService(config *model.ShellTimeConfig) *CCInfoTimerService { config: config, cache: make(map[CCInfoTimeRange]CCInfoCache), activeRanges: make(map[CCInfoTimeRange]bool), + gitCache: make(map[string]*GitCacheEntry), stopChan: make(chan struct{}), } } @@ -122,11 +129,10 @@ func (s *CCInfoTimerService) stopTimer() { s.ticker.Stop() s.timerRunning = false - // Clear active ranges and git info when stopping + // Clear active ranges and git cache when stopping s.mu.Lock() s.activeRanges = make(map[CCInfoTimeRange]bool) - s.activeWorkingDir = "" - s.gitInfo = GitInfo{} + s.gitCache = make(map[string]*GitCacheEntry) s.mu.Unlock() slog.Info("CC info timer stopped due to inactivity") @@ -272,39 +278,79 @@ func (s *CCInfoTimerService) fetchCCInfo(ctx context.Context, timeRange CCInfoTi }, nil } -// GetCachedGitInfo marks the working directory as active and returns cached git info. +// GetCachedGitInfo returns cached git info for the given working directory. +// It marks the directory as active for background refresh. // Git info is fetched by the background timer, so first call may return empty. func (s *CCInfoTimerService) GetCachedGitInfo(workingDir string) GitInfo { if workingDir == "" { return GitInfo{} } + // Normalize path for consistent cache keys + normalizedDir := filepath.Clean(workingDir) + s.mu.Lock() - s.activeWorkingDir = workingDir - info := s.gitInfo + entry, exists := s.gitCache[normalizedDir] + if !exists { + // Create new entry for this directory + entry = &GitCacheEntry{ + LastAccessed: time.Now(), + } + s.gitCache[normalizedDir] = entry + } else { + // Update last accessed time + entry.LastAccessed = time.Now() + } + info := entry.Info s.mu.Unlock() return info } -// fetchGitInfo fetches git info for the active working directory +// fetchGitInfo fetches git info for all recently accessed working directories func (s *CCInfoTimerService) fetchGitInfo() { + // Collect directories that need refresh (under read lock) s.mu.RLock() - workingDir := s.activeWorkingDir + dirsToFetch := make([]string, 0, len(s.gitCache)) + for dir, entry := range s.gitCache { + // Only fetch for recently accessed entries + if time.Since(entry.LastAccessed) <= CCInfoInactivityTimeout { + dirsToFetch = append(dirsToFetch, dir) + } + } s.mu.RUnlock() - if workingDir == "" { - return + // Fetch git info for each directory (outside lock to avoid blocking) + for _, dir := range dirsToFetch { + info := GetGitInfo(dir) + + s.mu.Lock() + if entry, exists := s.gitCache[dir]; exists { + entry.Info = info + entry.LastFetched = time.Now() + } + s.mu.Unlock() + + slog.Debug("Git info updated", + slog.String("workingDir", dir), + slog.String("branch", info.Branch), + slog.Bool("dirty", info.Dirty)) } - info := GetGitInfo(workingDir) + // Cleanup stale entries + s.cleanupStaleGitCache() +} +// cleanupStaleGitCache removes entries not accessed within the inactivity timeout +func (s *CCInfoTimerService) cleanupStaleGitCache() { s.mu.Lock() - s.gitInfo = info - s.mu.Unlock() + defer s.mu.Unlock() - slog.Debug("Git info updated", - slog.String("workingDir", workingDir), - slog.String("branch", info.Branch), - slog.Bool("dirty", info.Dirty)) + for dir, entry := range s.gitCache { + if time.Since(entry.LastAccessed) > CCInfoInactivityTimeout { + delete(s.gitCache, dir) + slog.Debug("Git cache entry evicted", + slog.String("workingDir", dir)) + } + } } diff --git a/daemon/cc_info_timer_test.go b/daemon/cc_info_timer_test.go index 3cbf703..4a16b8c 100644 --- a/daemon/cc_info_timer_test.go +++ b/daemon/cc_info_timer_test.go @@ -465,6 +465,177 @@ func (s *CCInfoTimerTestSuite) TestConcurrentNotifyActivity() { wg.Wait() } +// Git Cache Tests + +func (s *CCInfoTimerTestSuite) TestGetCachedGitInfo_EmptyWorkingDir() { + config := &model.ShellTimeConfig{} + service := NewCCInfoTimerService(config) + + info := service.GetCachedGitInfo("") + + assert.Equal(s.T(), GitInfo{}, info) +} + +func (s *CCInfoTimerTestSuite) TestGetCachedGitInfo_CreatesNewEntry() { + config := &model.ShellTimeConfig{} + service := NewCCInfoTimerService(config) + + _ = service.GetCachedGitInfo("/project/a") + + service.mu.RLock() + defer service.mu.RUnlock() + assert.Contains(s.T(), service.gitCache, "/project/a") + assert.NotNil(s.T(), service.gitCache["/project/a"]) +} + +func (s *CCInfoTimerTestSuite) TestGetCachedGitInfo_MultipleCWDs() { + config := &model.ShellTimeConfig{} + service := NewCCInfoTimerService(config) + + // Request git info for multiple directories + _ = service.GetCachedGitInfo("/project/a") + _ = service.GetCachedGitInfo("/project/b") + _ = service.GetCachedGitInfo("/project/c") + + service.mu.RLock() + defer service.mu.RUnlock() + assert.Len(s.T(), service.gitCache, 3) + assert.Contains(s.T(), service.gitCache, "/project/a") + assert.Contains(s.T(), service.gitCache, "/project/b") + assert.Contains(s.T(), service.gitCache, "/project/c") +} + +func (s *CCInfoTimerTestSuite) TestGetCachedGitInfo_PathNormalization() { + config := &model.ShellTimeConfig{} + service := NewCCInfoTimerService(config) + + // Request with trailing slash and without - should be same entry + _ = service.GetCachedGitInfo("/project/a/") + _ = service.GetCachedGitInfo("/project/a") + + service.mu.RLock() + defer service.mu.RUnlock() + // Both should map to the same normalized path + assert.Len(s.T(), service.gitCache, 1) + assert.Contains(s.T(), service.gitCache, "/project/a") +} + +func (s *CCInfoTimerTestSuite) TestGetCachedGitInfo_UpdatesLastAccessed() { + config := &model.ShellTimeConfig{} + service := NewCCInfoTimerService(config) + + // First access + _ = service.GetCachedGitInfo("/project/a") + service.mu.RLock() + firstAccess := service.gitCache["/project/a"].LastAccessed + service.mu.RUnlock() + + time.Sleep(10 * time.Millisecond) + + // Second access + _ = service.GetCachedGitInfo("/project/a") + service.mu.RLock() + secondAccess := service.gitCache["/project/a"].LastAccessed + service.mu.RUnlock() + + assert.True(s.T(), secondAccess.After(firstAccess)) +} + +func (s *CCInfoTimerTestSuite) TestGetCachedGitInfo_ReturnsCachedValue() { + config := &model.ShellTimeConfig{} + service := NewCCInfoTimerService(config) + + // Manually set cached git info + expectedInfo := GitInfo{Branch: "main", Dirty: true, IsRepo: true} + service.mu.Lock() + service.gitCache["/project/a"] = &GitCacheEntry{ + Info: expectedInfo, + LastAccessed: time.Now(), + LastFetched: time.Now(), + } + service.mu.Unlock() + + info := service.GetCachedGitInfo("/project/a") + + assert.Equal(s.T(), expectedInfo, info) +} + +func (s *CCInfoTimerTestSuite) TestCleanupStaleGitCache_RemovesOldEntries() { + config := &model.ShellTimeConfig{} + service := NewCCInfoTimerService(config) + + // Add entries with different ages + service.mu.Lock() + service.gitCache["/project/old"] = &GitCacheEntry{ + LastAccessed: time.Now().Add(-CCInfoInactivityTimeout - time.Minute), + } + service.gitCache["/project/new"] = &GitCacheEntry{ + LastAccessed: time.Now(), + } + service.mu.Unlock() + + service.cleanupStaleGitCache() + + service.mu.RLock() + defer service.mu.RUnlock() + assert.NotContains(s.T(), service.gitCache, "/project/old") + assert.Contains(s.T(), service.gitCache, "/project/new") +} + +func (s *CCInfoTimerTestSuite) TestConcurrentGetCachedGitInfo_DifferentDirs() { + config := &model.ShellTimeConfig{} + service := NewCCInfoTimerService(config) + + var wg sync.WaitGroup + numGoroutines := 100 + + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + // Access different directories + dir := "/project/" + string(rune('a'+idx%10)) + service.GetCachedGitInfo(dir) + }(i) + } + + // Should complete without race conditions + wg.Wait() + + // Verify cache has entries + service.mu.RLock() + defer service.mu.RUnlock() + assert.GreaterOrEqual(s.T(), len(service.gitCache), 1) +} + +func (s *CCInfoTimerTestSuite) TestStopTimer_ClearsGitCache() { + config := &model.ShellTimeConfig{ + Token: "test-token", + APIEndpoint: s.server.URL, + } + service := NewCCInfoTimerService(config) + + // Add git cache entries + service.mu.Lock() + service.gitCache["/project/a"] = &GitCacheEntry{LastAccessed: time.Now()} + service.gitCache["/project/b"] = &GitCacheEntry{LastAccessed: time.Now()} + service.mu.Unlock() + + // Start timer + service.NotifyActivity() + time.Sleep(20 * time.Millisecond) + + // Stop timer + service.timerMu.Lock() + service.stopTimer() + service.timerMu.Unlock() + + // Git cache should be cleared + service.mu.RLock() + defer service.mu.RUnlock() + assert.Empty(s.T(), service.gitCache) +} + func TestCCInfoTimerTestSuite(t *testing.T) { suite.Run(t, new(CCInfoTimerTestSuite)) }