Skip to content

Commit f5f0f75

Browse files
committed
feat: add commit message caching system to reduce API costs and improve performance
- Implement diff-based caching with SHA256 hashing - Add cache management CLI commands (stats, clear, cleanup) - Support all LLM providers with provider-aware caching - Include cost tracking and hit rate statistics - Add comprehensive test coverage (7 test functions) - Maintain backward compatibility with existing functionality"
1 parent 2ccd8c8 commit f5f0f75

9 files changed

Lines changed: 1319 additions & 5 deletions

File tree

cmd/cli/cache.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"github.com/dfanso/commit-msg/cmd/cli/store"
8+
"github.com/pterm/pterm"
9+
)
10+
11+
// ShowCacheStats displays cache statistics.
12+
func ShowCacheStats(Store *store.StoreMethods) error {
13+
stats := Store.GetCacheStats()
14+
15+
pterm.DefaultHeader.WithFullWidth().
16+
WithBackgroundStyle(pterm.NewStyle(pterm.BgBlue)).
17+
WithTextStyle(pterm.NewStyle(pterm.FgWhite, pterm.Bold)).
18+
Println("Commit Message Cache Statistics")
19+
20+
pterm.Println()
21+
22+
// Create statistics table
23+
statsData := [][]string{
24+
{"Total Entries", fmt.Sprintf("%d", stats.TotalEntries)},
25+
{"Cache Hits", fmt.Sprintf("%d", stats.TotalHits)},
26+
{"Cache Misses", fmt.Sprintf("%d", stats.TotalMisses)},
27+
{"Hit Rate", fmt.Sprintf("%.2f%%", stats.HitRate*100)},
28+
{"Total Cost Saved", fmt.Sprintf("$%.4f", stats.TotalCostSaved)},
29+
{"Cache Size", formatBytes(stats.CacheSizeBytes)},
30+
}
31+
32+
if stats.OldestEntry != "" {
33+
statsData = append(statsData, []string{"Oldest Entry", formatTime(stats.OldestEntry)})
34+
}
35+
36+
if stats.NewestEntry != "" {
37+
statsData = append(statsData, []string{"Newest Entry", formatTime(stats.NewestEntry)})
38+
}
39+
40+
pterm.DefaultTable.WithHasHeader(false).WithData(statsData).Render()
41+
42+
pterm.Println()
43+
44+
// Show cache status
45+
if stats.TotalEntries == 0 {
46+
pterm.Info.Println("Cache is empty. Generate some commit messages to start building the cache.")
47+
} else {
48+
pterm.Success.Printf("Cache is active with %d entries\n", stats.TotalEntries)
49+
50+
if stats.HitRate > 0 {
51+
pterm.Info.Printf("Cache hit rate: %.2f%% (%.0f%% of requests served from cache)\n",
52+
stats.HitRate*100, stats.HitRate*100)
53+
}
54+
55+
if stats.TotalCostSaved > 0 {
56+
pterm.Success.Printf("Total cost saved: $%.4f\n", stats.TotalCostSaved)
57+
}
58+
}
59+
60+
return nil
61+
}
62+
63+
// ClearCache removes all cached messages.
64+
func ClearCache(Store *store.StoreMethods) error {
65+
pterm.DefaultHeader.WithFullWidth().
66+
WithBackgroundStyle(pterm.NewStyle(pterm.BgRed)).
67+
WithTextStyle(pterm.NewStyle(pterm.FgWhite, pterm.Bold)).
68+
Println("Clear Cache")
69+
70+
pterm.Println()
71+
72+
// Get current stats before clearing
73+
stats := Store.GetCacheStats()
74+
75+
if stats.TotalEntries == 0 {
76+
pterm.Info.Println("Cache is already empty.")
77+
return nil
78+
}
79+
80+
// Confirm before clearing
81+
confirm, err := pterm.DefaultInteractiveConfirm.
82+
WithDefaultValue(false).
83+
Show(fmt.Sprintf("Are you sure you want to clear %d cached entries? This action cannot be undone.", stats.TotalEntries))
84+
85+
if err != nil {
86+
return fmt.Errorf("failed to get confirmation: %w", err)
87+
}
88+
89+
if !confirm {
90+
pterm.Info.Println("Cache clear cancelled.")
91+
return nil
92+
}
93+
94+
// Clear the cache
95+
if err := Store.ClearCache(); err != nil {
96+
return fmt.Errorf("failed to clear cache: %w", err)
97+
}
98+
99+
pterm.Success.Println("Cache cleared successfully!")
100+
pterm.Info.Printf("Removed %d entries and saved $%.4f in future API costs\n",
101+
stats.TotalEntries, stats.TotalCostSaved)
102+
103+
return nil
104+
}
105+
106+
// CleanupCache removes old cached messages.
107+
func CleanupCache(Store *store.StoreMethods) error {
108+
pterm.DefaultHeader.WithFullWidth().
109+
WithBackgroundStyle(pterm.NewStyle(pterm.BgYellow)).
110+
WithTextStyle(pterm.NewStyle(pterm.FgBlack, pterm.Bold)).
111+
Println("Cleanup Cache")
112+
113+
pterm.Println()
114+
115+
// Get stats before cleanup
116+
statsBefore := Store.GetCacheStats()
117+
118+
if statsBefore.TotalEntries == 0 {
119+
pterm.Info.Println("Cache is empty. Nothing to cleanup.")
120+
return nil
121+
}
122+
123+
pterm.Info.Println("Removing old and unused cached entries...")
124+
125+
// Perform cleanup
126+
if err := Store.CleanupCache(); err != nil {
127+
return fmt.Errorf("failed to cleanup cache: %w", err)
128+
}
129+
130+
// Get stats after cleanup
131+
statsAfter := Store.GetCacheStats()
132+
removed := statsBefore.TotalEntries - statsAfter.TotalEntries
133+
134+
if removed > 0 {
135+
pterm.Success.Printf("Cleanup completed! Removed %d old entries.\n", removed)
136+
pterm.Info.Printf("Cache now contains %d entries\n", statsAfter.TotalEntries)
137+
} else {
138+
pterm.Info.Println("No old entries found to remove.")
139+
}
140+
141+
return nil
142+
}
143+
144+
// Helper functions
145+
146+
// formatBytes formats bytes into human-readable format.
147+
func formatBytes(bytes int64) string {
148+
const unit = 1024
149+
if bytes < unit {
150+
return fmt.Sprintf("%d B", bytes)
151+
}
152+
div, exp := int64(unit), 0
153+
for n := bytes / unit; n >= unit; n /= unit {
154+
div *= unit
155+
exp++
156+
}
157+
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
158+
}
159+
160+
// formatTime formats a timestamp string for display.
161+
func formatTime(timestamp string) string {
162+
t, err := time.Parse(time.RFC3339, timestamp)
163+
if err != nil {
164+
return timestamp
165+
}
166+
return t.Format("2006-01-02 15:04:05")
167+
}

cmd/cli/createMsg.go

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ func CreateCommitMsg(Store *store.StoreMethods, dryRun bool, autoCommit bool) {
119119
}
120120

121121
attempt := 1
122-
commitMsg, err := generateMessage(ctx, providerInstance, changes, withAttempt(nil, attempt))
122+
commitMsg, err := generateMessageWithCache(ctx, providerInstance, Store, commitLLM, changes, withAttempt(nil, attempt))
123123
if err != nil {
124124
spinnerGenerating.Fail("Failed to generate commit message")
125125
displayProviderError(commitLLM, err)
@@ -182,7 +182,7 @@ interactionLoop:
182182
pterm.Error.Printf("Failed to start spinner: %v\n", err)
183183
continue
184184
}
185-
updatedMessage, genErr := generateMessage(ctx, providerInstance, changes, generationOpts)
185+
updatedMessage, genErr := generateMessageWithCache(ctx, providerInstance, Store, commitLLM, changes, generationOpts)
186186
if genErr != nil {
187187
spinner.Fail("Regeneration failed")
188188
displayProviderError(commitLLM, genErr)
@@ -297,6 +297,37 @@ func generateMessage(ctx context.Context, provider llm.Provider, changes string,
297297
return provider.Generate(ctx, changes, opts)
298298
}
299299

300+
// generateMessageWithCache generates a commit message with caching support.
301+
func generateMessageWithCache(ctx context.Context, provider llm.Provider, store *store.StoreMethods, providerType types.LLMProvider, changes string, opts *types.GenerationOptions) (string, error) {
302+
// Check cache first (only for first attempt to avoid caching regenerations)
303+
if opts == nil || opts.Attempt <= 1 {
304+
if cachedEntry, found := store.GetCachedMessage(providerType, changes, opts); found {
305+
pterm.Info.Printf("Using cached commit message (saved $%.4f)\n", cachedEntry.Cost)
306+
return cachedEntry.Message, nil
307+
}
308+
}
309+
310+
// Generate new message
311+
message, err := provider.Generate(ctx, changes, opts)
312+
if err != nil {
313+
return "", err
314+
}
315+
316+
// Cache the result (only for first attempt)
317+
if opts == nil || opts.Attempt <= 1 {
318+
// Estimate cost for caching
319+
cost := estimateCost(providerType, estimateTokens(types.BuildCommitPrompt(changes, opts)), 100)
320+
321+
// Store in cache
322+
if cacheErr := store.SetCachedMessage(providerType, changes, opts, message, cost, nil); cacheErr != nil {
323+
// Log cache error but don't fail the generation
324+
fmt.Printf("Warning: Failed to cache message: %v\n", cacheErr)
325+
}
326+
}
327+
328+
return message, nil
329+
}
330+
300331
func promptActionSelection() (string, error) {
301332
return pterm.DefaultInteractiveSelect.
302333
WithOptions(actionOptions).

cmd/cli/root.go

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ import (
77
"github.com/spf13/cobra"
88
)
99

10-
//store instance
10+
// store instance
1111
var Store *store.StoreMethods
1212

13-
//Initailize store
13+
// Initailize store
1414
func StoreInit(sm *store.StoreMethods) {
1515
Store = sm
1616
}
@@ -65,6 +65,36 @@ var llmUpdateCmd = &cobra.Command{
6565
},
6666
}
6767

68+
var cacheCmd = &cobra.Command{
69+
Use: "cache",
70+
Short: "Manage commit message cache",
71+
Long: `Manage the cache of generated commit messages to reduce API costs and improve performance.`,
72+
}
73+
74+
var cacheStatsCmd = &cobra.Command{
75+
Use: "stats",
76+
Short: "Show cache statistics",
77+
RunE: func(cmd *cobra.Command, args []string) error {
78+
return ShowCacheStats(Store)
79+
},
80+
}
81+
82+
var cacheClearCmd = &cobra.Command{
83+
Use: "clear",
84+
Short: "Clear all cached messages",
85+
RunE: func(cmd *cobra.Command, args []string) error {
86+
return ClearCache(Store)
87+
},
88+
}
89+
90+
var cacheCleanupCmd = &cobra.Command{
91+
Use: "cleanup",
92+
Short: "Remove old cached messages",
93+
RunE: func(cmd *cobra.Command, args []string) error {
94+
return CleanupCache(Store)
95+
},
96+
}
97+
6898
var creatCommitMsg = &cobra.Command{
6999
Use: ".",
70100
Short: "Create Commit Message",
@@ -101,6 +131,10 @@ func init() {
101131

102132
rootCmd.AddCommand(creatCommitMsg)
103133
rootCmd.AddCommand(llmCmd)
134+
rootCmd.AddCommand(cacheCmd)
104135
llmCmd.AddCommand(llmSetupCmd)
105136
llmCmd.AddCommand(llmUpdateCmd)
137+
cacheCmd.AddCommand(cacheStatsCmd)
138+
cacheCmd.AddCommand(cacheClearCmd)
139+
cacheCmd.AddCommand(cacheCleanupCmd)
106140
}

cmd/cli/store/store.go

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,34 @@ import (
1010

1111
"github.com/99designs/keyring"
1212

13+
"github.com/dfanso/commit-msg/internal/cache"
1314
"github.com/dfanso/commit-msg/pkg/types"
1415
StoreUtils "github.com/dfanso/commit-msg/utils"
1516
)
1617

1718
type StoreMethods struct {
18-
ring keyring.Keyring
19+
ring keyring.Keyring
20+
cache *cache.CacheManager
21+
}
22+
23+
// NewStoreMethods creates a new StoreMethods instance with cache support.
24+
func NewStoreMethods() (*StoreMethods, error) {
25+
ring, err := keyring.Open(keyring.Config{
26+
ServiceName: "commit-msg",
27+
})
28+
if err != nil {
29+
return nil, fmt.Errorf("failed to open keyring: %w", err)
30+
}
31+
32+
cacheManager, err := cache.NewCacheManager()
33+
if err != nil {
34+
return nil, fmt.Errorf("failed to initialize cache: %w", err)
35+
}
36+
37+
return &StoreMethods{
38+
ring: ring,
39+
cache: cacheManager,
40+
}, nil
1941
}
2042

2143
// Initializes Keyring instance
@@ -360,3 +382,35 @@ func (s *StoreMethods) UpdateAPIKey(Model types.LLMProvider, APIKey string) erro
360382
return os.WriteFile(configPath, data, 0600)
361383

362384
}
385+
386+
// Cache management methods
387+
388+
// GetCacheManager returns the cache manager instance.
389+
func (s *StoreMethods) GetCacheManager() *cache.CacheManager {
390+
return s.cache
391+
}
392+
393+
// GetCachedMessage retrieves a cached commit message if it exists.
394+
func (s *StoreMethods) GetCachedMessage(provider types.LLMProvider, diff string, opts *types.GenerationOptions) (*types.CacheEntry, bool) {
395+
return s.cache.Get(provider, diff, opts)
396+
}
397+
398+
// SetCachedMessage stores a commit message in the cache.
399+
func (s *StoreMethods) SetCachedMessage(provider types.LLMProvider, diff string, opts *types.GenerationOptions, message string, cost float64, tokens *types.UsageInfo) error {
400+
return s.cache.Set(provider, diff, opts, message, cost, tokens)
401+
}
402+
403+
// ClearCache removes all entries from the cache.
404+
func (s *StoreMethods) ClearCache() error {
405+
return s.cache.Clear()
406+
}
407+
408+
// GetCacheStats returns cache statistics.
409+
func (s *StoreMethods) GetCacheStats() *types.CacheStats {
410+
return s.cache.GetStats()
411+
}
412+
413+
// CleanupCache removes old entries from the cache.
414+
func (s *StoreMethods) CleanupCache() error {
415+
return s.cache.Cleanup()
416+
}

0 commit comments

Comments
 (0)