An AI-powered tool that generates human-readable summaries of git changes using tool calling with a self-hosted LLM
1package cache
2
3import (
4 "container/list"
5 "crypto/sha256"
6 "encoding/hex"
7 "fmt"
8 "log/slog"
9 "os"
10 "path/filepath"
11 "sync"
12
13 "git-summarizer/pkg/git"
14
15 "github.com/go-git/go-git/v5/plumbing/transport"
16)
17
18// RepoCache manages cached git repositories with LRU eviction
19type RepoCache struct {
20 baseDir string
21 maxRepos int
22 mu sync.Mutex
23 repoLocks map[string]*sync.Mutex
24 lru *list.List
25 repos map[string]*cacheEntry
26}
27
28type cacheEntry struct {
29 url string
30 path string
31 element *list.Element
32}
33
34// New creates a new RepoCache
35func New(baseDir string, maxRepos int) *RepoCache {
36 return &RepoCache{
37 baseDir: baseDir,
38 maxRepos: maxRepos,
39 repoLocks: make(map[string]*sync.Mutex),
40 lru: list.New(),
41 repos: make(map[string]*cacheEntry),
42 }
43}
44
45// GetOrClone returns a cached repo or clones it if not present
46// If the repo is cached, it fetches updates before returning
47func (c *RepoCache) GetOrClone(url string, auth transport.AuthMethod) (*git.Repo, error) {
48 // Get or create per-repo lock
49 repoLock := c.getRepoLock(url)
50 repoLock.Lock()
51 defer repoLock.Unlock()
52
53 // Check if repo exists in cache
54 c.mu.Lock()
55 entry, exists := c.repos[url]
56 if exists {
57 // Move to front of LRU
58 c.lru.MoveToFront(entry.element)
59 c.mu.Unlock()
60
61 // Fetch updates
62 slog.Info("fetching cached repo", "url", url)
63 repo, err := git.Open(entry.path)
64 if err != nil {
65 // Cache entry invalid, remove and re-clone
66 slog.Warn("cached repo invalid, re-cloning", "url", url, "error", err)
67 c.mu.Lock()
68 c.removeEntry(url)
69 c.mu.Unlock()
70 return c.cloneNew(url, auth)
71 }
72
73 if err := repo.Fetch(auth); err != nil {
74 slog.Warn("fetch failed", "url", url, "error", err)
75 // Continue with potentially stale data rather than failing
76 }
77
78 return repo, nil
79 }
80 c.mu.Unlock()
81
82 // Clone new repo
83 return c.cloneNew(url, auth)
84}
85
86// cloneNew clones a repo and adds it to the cache
87func (c *RepoCache) cloneNew(url string, auth transport.AuthMethod) (*git.Repo, error) {
88 c.mu.Lock()
89
90 // Evict if at capacity
91 for c.lru.Len() >= c.maxRepos {
92 c.evictLRU()
93 }
94
95 // Prepare cache path
96 path := c.urlToPath(url)
97 c.mu.Unlock()
98
99 // Ensure cache directory exists
100 if err := os.MkdirAll(c.baseDir, 0755); err != nil {
101 return nil, fmt.Errorf("failed to create cache dir: %w", err)
102 }
103
104 // Clone
105 slog.Info("cloning repo to cache", "url", url, "path", path)
106 repo, err := git.Clone(url, path, auth)
107 if err != nil {
108 return nil, err
109 }
110
111 // Add to cache
112 c.mu.Lock()
113 entry := &cacheEntry{
114 url: url,
115 path: path,
116 }
117 entry.element = c.lru.PushFront(url)
118 c.repos[url] = entry
119 c.mu.Unlock()
120
121 slog.Info("repo cached", "url", url, "cache_size", c.lru.Len())
122 return repo, nil
123}
124
125// getRepoLock returns the lock for a specific repo URL
126func (c *RepoCache) getRepoLock(url string) *sync.Mutex {
127 c.mu.Lock()
128 defer c.mu.Unlock()
129
130 lock, exists := c.repoLocks[url]
131 if !exists {
132 lock = &sync.Mutex{}
133 c.repoLocks[url] = lock
134 }
135 return lock
136}
137
138// evictLRU removes the least recently used repo from the cache
139// Must be called with c.mu held
140func (c *RepoCache) evictLRU() {
141 elem := c.lru.Back()
142 if elem == nil {
143 return
144 }
145
146 url := elem.Value.(string)
147 c.removeEntry(url)
148 slog.Info("evicted repo from cache", "url", url)
149}
150
151// removeEntry removes a repo from the cache
152// Must be called with c.mu held
153func (c *RepoCache) removeEntry(url string) {
154 entry, exists := c.repos[url]
155 if !exists {
156 return
157 }
158
159 // Remove from LRU list
160 c.lru.Remove(entry.element)
161
162 // Remove from map
163 delete(c.repos, url)
164
165 // Remove from disk
166 if err := os.RemoveAll(entry.path); err != nil {
167 slog.Warn("failed to remove cached repo", "path", entry.path, "error", err)
168 }
169}
170
171// urlToPath converts a repo URL to a filesystem-safe path
172func (c *RepoCache) urlToPath(url string) string {
173 hash := sha256.Sum256([]byte(url))
174 hashStr := hex.EncodeToString(hash[:8]) // Use first 8 bytes (16 hex chars)
175 return filepath.Join(c.baseDir, hashStr)
176}
177
178// Size returns the number of cached repos
179func (c *RepoCache) Size() int {
180 c.mu.Lock()
181 defer c.mu.Unlock()
182 return c.lru.Len()
183}