Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ test.txt
AGENTS.md
commit~
commit-msg
.idea
35 changes: 19 additions & 16 deletions cmd/cli/createMsg.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ func CreateCommitMsg(Store *store.StoreMethods, dryRun bool, autoCommit bool, ve
// Handle dry-run mode: display what would be sent to LLM without making API call
if dryRun {
pterm.Println()
displayDryRunInfo(commitLLM, config, changes, apiKey, verbose)
displayDryRunInfo(commitLLM, Store, config, changes, apiKey, verbose)
return
}

Expand Down Expand Up @@ -355,45 +355,47 @@ func generateMessage(ctx context.Context, provider llm.Provider, changes string,
// generateMessageWithCache generates a commit message with caching support.
func generateMessageWithCache(ctx context.Context, provider llm.Provider, store *store.StoreMethods, providerType types.LLMProvider, changes string, opts *types.GenerationOptions) (string, error) {
startTime := time.Now()

// Determine if this is a first attempt (cache check eligible)
isFirstAttempt := opts == nil || opts.Attempt <= 1

// Check cache first (only for first attempt to avoid caching regenerations)
if isFirstAttempt {
if cachedEntry, found := store.GetCachedMessage(providerType, changes, opts); found {
pterm.Info.Printf("Using cached commit message (saved $%.4f)\n", cachedEntry.Cost)

// Record cache hit event
event := &types.GenerationEvent{
Provider: providerType,
Success: true,
GenerationTime: float64(time.Since(startTime).Nanoseconds()) / 1e6, // Convert to milliseconds
TokensUsed: 0, // No tokens used for cached result
Cost: 0, // No cost for cached result
TokensUsed: 0, // No tokens used for cached result
Cost: 0, // No cost for cached result
CacheHit: true,
CacheChecked: true,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}

if err := store.RecordGenerationEvent(event); err != nil {
// Log the error but don't fail the operation
fmt.Printf("Warning: Failed to record usage statistics: %v\n", err)
}

return cachedEntry.Message, nil
}
}

// Generate new message
message, err := provider.Generate(ctx, changes, opts)
generationTime := float64(time.Since(startTime).Nanoseconds()) / 1e6 // Convert to milliseconds


customTemplate, _ := store.GetTemplate()

// Estimate tokens and cost
inputTokens := estimateTokens(types.BuildCommitPrompt(changes, opts))
inputTokens := estimateTokens(types.BuildCommitPromptWithTemplate(changes, opts, customTemplate))
outputTokens := 100 // Estimate output tokens
cost := estimateCost(providerType, inputTokens, outputTokens)

// Record generation event
event := &types.GenerationEvent{
Provider: providerType,
Expand All @@ -405,17 +407,17 @@ func generateMessageWithCache(ctx context.Context, provider llm.Provider, store
CacheChecked: isFirstAttempt, // Only first attempts check cache
Timestamp: time.Now().UTC().Format(time.RFC3339),
}

if err != nil {
event.ErrorMessage = err.Error()
}

// Record the event regardless of success/failure
if statsErr := store.RecordGenerationEvent(event); statsErr != nil {
// Log the error but don't fail the operation
fmt.Printf("Warning: Failed to record usage statistics: %v\n", statsErr)
}

if err != nil {
return "", err
}
Expand Down Expand Up @@ -630,7 +632,7 @@ func displayMissingCredentialHint(provider types.LLMProvider) {
}

// displayDryRunInfo shows what would be sent to the LLM without making an API call
func displayDryRunInfo(provider types.LLMProvider, config *types.Config, changes string, apiKey string, verbose bool) {
func displayDryRunInfo(provider types.LLMProvider, storeMethods *store.StoreMethods, config *types.Config, changes string, apiKey string, verbose bool) {
pterm.DefaultHeader.WithFullWidth().
WithBackgroundStyle(pterm.NewStyle(pterm.BgBlue)).
WithTextStyle(pterm.NewStyle(pterm.FgWhite, pterm.Bold)).
Expand Down Expand Up @@ -665,7 +667,8 @@ func displayDryRunInfo(provider types.LLMProvider, config *types.Config, changes

// Build and display the prompt
opts := &types.GenerationOptions{Attempt: 1}
prompt := types.BuildCommitPrompt(changes, opts)
customTemplate, _ := storeMethods.GetTemplate()
prompt := types.BuildCommitPromptWithTemplate(changes, opts, customTemplate)

pterm.DefaultSection.Println("Prompt That Would Be Sent")
pterm.Println()
Expand Down
126 changes: 124 additions & 2 deletions cmd/cli/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"time"

"os"

Expand Down Expand Up @@ -62,8 +63,10 @@ type LLMProvider struct {

// Config describes the on-disk structure for all saved LLM providers.
type Config struct {
Default types.LLMProvider `json:"default"`
LLMProviders []types.LLMProvider `json:"models"`
Default types.LLMProvider `json:"default"`
LLMProviders []types.LLMProvider `json:"models"`
CustomTemplate string `json:"custom_template,omitempty"`
TemplateUpdatedAt string `json:"template_updated_at,omitempty"`
}

// Save persists or updates an LLM provider entry, marking it as the default.
Expand Down Expand Up @@ -459,3 +462,122 @@ func (s *StoreMethods) GetProviderRanking() []types.LLMProvider {
func (s *StoreMethods) ResetUsageStats() error {
return s.usage.ResetStats()
}

// Template management methods

// SaveTemplate stores a custom commit message template.
func (s *StoreMethods) SaveTemplate(template string) error {
var cfg Config

configPath, err := StoreUtils.GetConfigPath()

if err != nil {
return err
}

isConfigExists := StoreUtils.CheckConfig(configPath)
if !isConfigExists {
err := StoreUtils.CreateConfigFile(configPath)
if err != nil {
return err
}
}

data, err := os.ReadFile(configPath)
if errors.Is(err, os.ErrNotExist) {
data = []byte("{}")
} else if err != nil {
return err
}

if len(data) > 2 {
err = json.Unmarshal(data, &cfg)
if err != nil {
return fmt.Errorf("config file format error: %w", err)
}
}

cfg.CustomTemplate = template
cfg.TemplateUpdatedAt = fmt.Sprintf("%v", time.Now())
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Use standard timestamp format instead of default string representation.

Using fmt.Sprintf("%v", time.Now()) produces an inconsistent, non-standard timestamp format like "2025-11-09 21:05:16.123456 +0000 UTC" that's harder to parse programmatically. This could cause issues if the timestamp is later used for sorting, filtering, or display purposes.

Apply this diff to use RFC3339 format:

-	cfg.TemplateUpdatedAt = fmt.Sprintf("%v", time.Now())
+	cfg.TemplateUpdatedAt = time.Now().UTC().Format(time.RFC3339)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cfg.TemplateUpdatedAt = fmt.Sprintf("%v", time.Now())
cfg.TemplateUpdatedAt = time.Now().UTC().Format(time.RFC3339)
🤖 Prompt for AI Agents
In cmd/cli/store/store.go around line 501, the code sets cfg.TemplateUpdatedAt
using fmt.Sprintf("%v", time.Now()) which produces a non-standard, hard-to-parse
timestamp; replace this with a RFC3339 formatted timestamp by assigning
cfg.TemplateUpdatedAt = time.Now().Format(time.RFC3339) (or
time.Now().UTC().Format(time.RFC3339) if you want UTC) and ensure the time
package is imported if not already.


data, err = json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}

return os.WriteFile(configPath, data, 0600)
}

func (s *StoreMethods) GetTemplate() (string, error) {
var cfg Config

configPath, err := StoreUtils.GetConfigPath()
if err != nil {
return "", err
}

isConfigExists := StoreUtils.CheckConfig(configPath)
if !isConfigExists {
return "", fmt.Errorf("config file does not exist at %s, run 'commit llm setup' to create it", configPath)
}

data, err := os.ReadFile(configPath)
if err != nil {
return "", err
}

if len(data) > 2 {
err = json.Unmarshal(data, &cfg)
if err != nil {
return "", fmt.Errorf("config file format error: %w", err)
}
}

if cfg.CustomTemplate == "" {
return "", errors.New("no custom template set")
}

return cfg.CustomTemplate, nil
}

// DeleteTemplate removes the custom commit message template.
func (s *StoreMethods) DeleteTemplate() error {
var cfg Config

configPath, err := StoreUtils.GetConfigPath()
if err != nil {
return err
}

isConfigExists := StoreUtils.CheckConfig(configPath)
if !isConfigExists {
return fmt.Errorf("config file does not exist at %s, run 'commit llm setup' to create it", configPath)
}

data, err := os.ReadFile(configPath)
if err != nil {
return err
}

if len(data) > 2 {
err = json.Unmarshal(data, &cfg)
if err != nil {
return fmt.Errorf("config file format error: %w", err)
}
}

cfg.CustomTemplate = ""
cfg.TemplateUpdatedAt = ""

data, err = json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}

return os.WriteFile(configPath, data, 0600)
}

func (s *StoreMethods) HasCustomTemplate() bool {
template, err := s.GetTemplate()
return err == nil && template != ""
}
Loading
Loading