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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v
Go source — tracked or newly added — is missing its
`// SPDX-License-Identifier: GPL-3.0-or-later` header, keeping the 100%
coverage from regressing (ADR-0012).
- **`cache stats` subcommand.** Summarizes the local response cache:
entry count, total size, oldest/newest entry timestamps, the configured
size limit, and a per-provider/model breakdown. Read-only (ADR-0008).
- **`cache inspect <key>` subcommand.** Dumps a single cache entry's
metadata (provider, model, language, created-at, TTL + freshness, token
counts, on-disk size) by its cache key — the SHA-256 shown by
`--verbose` / `dry-run`, with or without the `.json` suffix. The cached
review body is shown only with `--show-content` (ADR-0008).
- **Size-bounded cache eviction.** New `cache.max_size_mb` config key:
when set (>0), each cache write that pushes the directory over the limit
evicts the oldest entries first until it fits; the just-written entry is
never evicted. Default `0` keeps the cache unlimited (`cache prune`
remains the manual stand-in). This is a fresh key with real Put-path
enforcement, not a revival of the v0.9.1-removed dead field (ADR-0008).

## [1.2.1]

Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ commitbrief list # command reference
# Cache maintenance
commitbrief cache clear # wipe every cached LLM response for this repo
commitbrief cache prune [flags] # bounded cleanup; defaults --keep-last 500 --older-than 7d
commitbrief cache stats # entry count, size, age range, per-provider breakdown
commitbrief cache inspect <key> # one entry's metadata (add --show-content for the body)
```

Global flags: `--json`, `--markdown`, `--output <file>`, `--copy`,
Expand Down Expand Up @@ -320,6 +322,7 @@ output:
cache:
enabled: true
ttl_days: 7
max_size_mb: 0 # 0 = unlimited; >0 evicts oldest entries past the cap
```

Review content lives in two files:
Expand Down Expand Up @@ -400,7 +403,10 @@ removed.
**When does the cache invalidate?**
The cache key is a SHA-256 of `diff + system prompt + provider + model
+ lang + schema version`. Change any of those and you get a fresh
review. Default TTL is 7 days; configurable via `cache.ttl_days`.
review. Default TTL is 7 days; configurable via `cache.ttl_days`. Set
`cache.max_size_mb` (>0) to bound the on-disk cache: writes that push it
past the limit evict the oldest entries first (the entry just written is
never evicted). Inspect it with `cache stats` / `cache inspect <key>`.

**Can I run it in CI?**
The primary target is the developer's terminal, but the CI-friendly
Expand Down
37 changes: 29 additions & 8 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,11 @@ const (
)

type Cache struct {
dir string
ttl time.Duration
repoRoot string
now func() time.Time
dir string
ttl time.Duration
repoRoot string
maxSizeBytes int64
now func() time.Time
}

type Options struct {
Expand All @@ -82,6 +83,15 @@ type Options struct {
// to DefaultTTL (7 days).
TTL time.Duration

// MaxSizeBytes bounds the on-disk cache size. After each successful
// Put, if the cache directory exceeds this many bytes, the oldest
// entries (by CreatedAt, mtime fallback) are evicted oldest-first
// until the total fits — see eviction.go. Zero or negative disables
// eviction (unlimited; the manual `cache prune` stays the stand-in).
// The just-written entry is never evicted, so a single entry larger
// than the cap survives.
MaxSizeBytes int64

// Now overrides time.Now (test injection); production callers leave it nil.
Now func() time.Time
}
Expand All @@ -99,10 +109,11 @@ func Open(opts Options) (*Cache, error) {
now = time.Now
}
return &Cache{
dir: opts.Dir,
ttl: ttl,
repoRoot: opts.RepoRoot,
now: now,
dir: opts.Dir,
ttl: ttl,
repoRoot: opts.RepoRoot,
maxSizeBytes: opts.MaxSizeBytes,
now: now,
}, nil
}

Expand Down Expand Up @@ -158,6 +169,16 @@ func (c *Cache) Put(key string, entry Entry) error {
return fmt.Errorf("cache: entry written but %w", err)
}
}

// Size-bounded eviction runs after the write so the just-stored entry
// counts toward the total and is protected from eviction. Failures
// here are best-effort cleanup — the entry is already persisted and a
// disk-pressure sweep that couldn't complete is not worth failing the
// surrounding review over, so we deliberately swallow the error.
if c.maxSizeBytes > 0 {
keep := filepath.Base(c.entryPath(key))
_, _, _ = enforceSizeLimit(c.dir, c.maxSizeBytes, keep)
}
return nil
}

Expand Down
120 changes: 120 additions & 0 deletions internal/cache/eviction.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package cache

import (
"encoding/json"
"io/fs"
"os"
"path/filepath"
"sort"
"time"
)

// enforceSizeLimit caps the on-disk cache directory at maxBytes by
// removing entries oldest-first until the summed size fits. "Oldest" is
// each entry's CreatedAt, falling back to the file mtime when the entry
// is corrupt or pre-dates the timestamp field. It is the automatic
// counterpart to `cache prune`: cheap, no access-time tracking, and
// deliberately conservative.
//
// keep is the base filename (e.g. "<sha>.json") of an entry that must
// never be evicted — callers pass the just-written entry so a fresh Put
// can never delete its own result. Its bytes still count toward the
// total, so when a single entry alone exceeds maxBytes every other entry
// is evicted and that one survives over-budget (the alternative — wiping
// the result the user just paid for — is worse).
//
// maxBytes <= 0 is treated as "no limit" and returns immediately. A
// missing directory is not an error. Returns (entries removed, bytes
// freed, error); remove failures stop the sweep and return what was
// freed so far.
func enforceSizeLimit(dir string, maxBytes int64, keep string) (removed int, freed int64, err error) {
if maxBytes <= 0 {
return 0, 0, nil
}

type candidate struct {
path string
size int64
createdAt time.Time
protected bool
}

var (
candidates []candidate
total int64
)

walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, werr error) error {
if werr != nil {
if os.IsNotExist(werr) && path == dir {
return filepath.SkipAll
}
return werr
}
if d.IsDir() {
return nil
}
// Only count finished entries. In-flight temp files (writeAtomic)
// and any non-entry files are ignored so a concurrent Put isn't
// double-counted or deleted mid-rename.
if filepath.Ext(path) != ".json" {
return nil
}
info, ierr := d.Info()
if ierr != nil {
return ierr
}
ts := info.ModTime()
if raw, rerr := os.ReadFile(path); rerr == nil {
var e Entry
if json.Unmarshal(raw, &e) == nil && !e.CreatedAt.IsZero() {
ts = e.CreatedAt
}
}
total += info.Size()
candidates = append(candidates, candidate{
path: path,
size: info.Size(),
createdAt: ts,
protected: filepath.Base(path) == keep,
})
return nil
})
if walkErr != nil {
return 0, 0, walkErr
}

if total <= maxBytes {
return 0, 0, nil
}

// Oldest first; protected entry sinks to the end so it's only removed
// if it were the last candidate — which the loop below never does.
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].protected != candidates[j].protected {
return !candidates[i].protected
}
return candidates[i].createdAt.Before(candidates[j].createdAt)
})

for _, cand := range candidates {
if total <= maxBytes {
break
}
if cand.protected {
// Reached the protected entry with the total still over
// budget: nothing more we may delete. Stop.
break
}
if rerr := os.Remove(cand.path); rerr != nil {
return removed, freed, rerr
}
removed++
freed += cand.size
total -= cand.size
}

return removed, freed, nil
}
Loading
Loading