···11+package summarizer
22+33+// getSystemPrompt returns the appropriate system prompt for the given style
44+func getSystemPrompt(style string) string {
55+ switch style {
66+ case "bluesky":
77+ return `You are a git change summarizer. Write a VERY brief summary for a Bluesky post.
88+99+STRICT RULES:
1010+- MAXIMUM 300 characters (this is a hard limit, count carefully!)
1111+- Focus ONLY on user-facing changes
1212+- Skip internal/technical/refactoring changes
1313+- One or two short sentences only
1414+- No bullet points, no markdown, no emojis
1515+- Start with version if available
1616+1717+Example (59 chars): "Added dark mode, faster search, and fixed mobile login bug."`
1818+1919+ case "short":
2020+ return `You are a git change summarizer. Write a brief summary of the changes.
2121+2222+Rules:
2323+- Keep it under 500 characters
2424+- Focus on the most important changes
2525+- Use 2-4 bullet points max
2626+- Skip minor/internal changes`
2727+2828+ default: // "detailed"
2929+ return `You are a git change summarizer. Analyze changes between git refs and provide clear summaries.
3030+3131+You have been provided with the commit log, changed files, and diff stats. Use the tools only if you need additional detail (e.g., specific file diffs or file contents).
3232+3333+Provide a summary with:
3434+- High-level overview of what changed and why
3535+- Key modifications grouped by area/purpose
3636+- Breaking changes or important notes
3737+- Notable additions or removals
3838+3939+Be efficient - often you can summarize directly from the provided context without any tool calls.`
4040+ }
4141+}
+310
internal/summarizer/summarizer.go
···11+package summarizer
22+33+import (
44+ "bytes"
55+ "encoding/json"
66+ "fmt"
77+ "io"
88+ "log/slog"
99+ "net/http"
1010+ "regexp"
1111+ "strings"
1212+ "time"
1313+1414+ "git-summarizer/pkg/config"
1515+ "git-summarizer/pkg/git"
1616+ "git-summarizer/pkg/llm"
1717+1818+ "github.com/go-git/go-git/v5/plumbing/transport"
1919+ githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
2020+ gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
2121+)
2222+2323+// Summarizer handles the orchestration of git summarization
2424+type Summarizer struct {
2525+ Config config.Config
2626+ client *http.Client
2727+}
2828+2929+// New creates a new Summarizer
3030+func New(cfg config.Config) *Summarizer {
3131+ return &Summarizer{
3232+ Config: cfg,
3333+ client: &http.Client{Timeout: 120 * time.Second},
3434+ }
3535+}
3636+3737+// GetAuth returns appropriate auth method based on config
3838+func (s *Summarizer) GetAuth() transport.AuthMethod {
3939+ if s.Config.GitToken != "" {
4040+ return &githttp.BasicAuth{
4141+ Username: s.Config.GitUser,
4242+ Password: s.Config.GitToken,
4343+ }
4444+ }
4545+ if s.Config.SSHKeyPath != "" {
4646+ auth, err := gitssh.NewPublicKeysFromFile("git", s.Config.SSHKeyPath, "")
4747+ if err == nil {
4848+ return auth
4949+ }
5050+ slog.Warn("failed to load SSH key", "error", err)
5151+ }
5252+ return nil
5353+}
5454+5555+// executeTool runs a tool and returns the result
5656+func (s *Summarizer) executeTool(repo *git.Repo, name string, args map[string]interface{}) string {
5757+ var result string
5858+ var err error
5959+6060+ switch name {
6161+ case "git_log":
6262+ base := args["base"].(string)
6363+ head := args["head"].(string)
6464+ maxCount := 50
6565+ if mc, ok := args["max_count"].(float64); ok {
6666+ maxCount = int(mc)
6767+ }
6868+ result, err = repo.GetLog(base, head, maxCount)
6969+7070+ case "git_diff":
7171+ base := args["base"].(string)
7272+ head := args["head"].(string)
7373+ var files []string
7474+ if f, ok := args["files"].([]interface{}); ok {
7575+ for _, file := range f {
7676+ files = append(files, file.(string))
7777+ }
7878+ }
7979+ result, err = repo.GetDiff(base, head, files)
8080+8181+ case "list_changed_files":
8282+ base := args["base"].(string)
8383+ head := args["head"].(string)
8484+ result, err = repo.ListChangedFiles(base, head)
8585+8686+ case "git_show_commit":
8787+ ref := args["ref"].(string)
8888+ result, err = repo.ShowCommit(ref)
8989+9090+ case "read_file":
9191+ path := args["path"].(string)
9292+ ref := ""
9393+ if r, ok := args["ref"].(string); ok {
9494+ ref = r
9595+ }
9696+ result, err = repo.ReadFile(path, ref)
9797+9898+ case "git_diff_stats":
9999+ base := args["base"].(string)
100100+ head := args["head"].(string)
101101+ result, err = repo.GetDiffStats(base, head)
102102+103103+ default:
104104+ return fmt.Sprintf("Unknown tool: %s", name)
105105+ }
106106+107107+ if err != nil {
108108+ return fmt.Sprintf("Error: %v", err)
109109+ }
110110+111111+ // Truncate large outputs
112112+ if len(result) > s.Config.MaxDiffLen {
113113+ result = result[:s.Config.MaxDiffLen] + "\n... [truncated]"
114114+ }
115115+116116+ return result
117117+}
118118+119119+// parseXMLToolCalls extracts tool calls from XML-style format in message content
120120+// Handles: <function=name><parameter=key>value</parameter></function>
121121+func parseXMLToolCalls(content string) []llm.ToolCall {
122122+ var calls []llm.ToolCall
123123+124124+ // Match <function=name>...</function>
125125+ funcRe := regexp.MustCompile(`<function=(\w+)>([\s\S]*?)</function>`)
126126+ paramRe := regexp.MustCompile(`<parameter=(\w+)>([\s\S]*?)</parameter>`)
127127+128128+ matches := funcRe.FindAllStringSubmatch(content, -1)
129129+ for i, match := range matches {
130130+ if len(match) < 3 {
131131+ continue
132132+ }
133133+134134+ funcName := match[1]
135135+ funcBody := match[2]
136136+137137+ args := make(map[string]interface{})
138138+ paramMatches := paramRe.FindAllStringSubmatch(funcBody, -1)
139139+ for _, pm := range paramMatches {
140140+ if len(pm) >= 3 {
141141+ args[pm[1]] = strings.TrimSpace(pm[2])
142142+ }
143143+ }
144144+145145+ argsJSON, _ := json.Marshal(args)
146146+ calls = append(calls, llm.ToolCall{
147147+ ID: fmt.Sprintf("call_%d", i),
148148+ Type: "function",
149149+ Function: llm.FunctionCall{
150150+ Name: funcName,
151151+ Arguments: string(argsJSON),
152152+ },
153153+ })
154154+ }
155155+156156+ return calls
157157+}
158158+159159+// chat sends a request to the LLM
160160+func (s *Summarizer) chat(req llm.ChatRequest) (*llm.ChatResponse, error) {
161161+ body, err := json.Marshal(req)
162162+ if err != nil {
163163+ return nil, err
164164+ }
165165+166166+ resp, err := s.client.Post(
167167+ s.Config.LlamaURL+"/v1/chat/completions",
168168+ "application/json",
169169+ bytes.NewReader(body),
170170+ )
171171+ if err != nil {
172172+ return nil, err
173173+ }
174174+ defer resp.Body.Close()
175175+176176+ respBody, err := io.ReadAll(resp.Body)
177177+ if err != nil {
178178+ return nil, err
179179+ }
180180+181181+ var chatResp llm.ChatResponse
182182+ if err := json.Unmarshal(respBody, &chatResp); err != nil {
183183+ return nil, fmt.Errorf("failed to parse response: %s", string(respBody))
184184+ }
185185+186186+ if chatResp.Error != nil {
187187+ return nil, fmt.Errorf("API error: %s", chatResp.Error.Message)
188188+ }
189189+190190+ return &chatResp, nil
191191+}
192192+193193+// Summarize runs the agentic loop to summarize changes
194194+func (s *Summarizer) Summarize(repo *git.Repo, base, head, style string) (string, error) {
195195+ systemPrompt := getSystemPrompt(style)
196196+197197+ // Pre-fetch context to reduce tool calls
198198+ slog.Info("fetching initial context")
199199+ contextStart := time.Now()
200200+201201+ logStart := time.Now()
202202+ commitLog, err := repo.GetLog(base, head, 100)
203203+ if err != nil {
204204+ commitLog = fmt.Sprintf("Error fetching log: %v", err)
205205+ }
206206+ slog.Debug("git_log", "duration", time.Since(logStart))
207207+208208+ filesStart := time.Now()
209209+ changedFiles, err := repo.ListChangedFiles(base, head)
210210+ if err != nil {
211211+ changedFiles = fmt.Sprintf("Error fetching files: %v", err)
212212+ }
213213+ slog.Debug("list_changed_files", "duration", time.Since(filesStart))
214214+215215+ statsStart := time.Now()
216216+ diffStats, err := repo.GetDiffStats(base, head)
217217+ if err != nil {
218218+ diffStats = fmt.Sprintf("Error fetching stats: %v", err)
219219+ }
220220+ slog.Debug("git_diff_stats", "duration", time.Since(statsStart))
221221+ slog.Info("context fetched", "duration", time.Since(contextStart))
222222+223223+ userPrompt := fmt.Sprintf(`Please summarize the changes between '%s' and '%s' in this repository.
224224+225225+## Commit Log
226226+%s
227227+228228+## Changed Files
229229+%s
230230+231231+## Diff Stats
232232+%s
233233+234234+Use the available tools if you need to examine specific file diffs or read file contents for more context.`,
235235+ base, head, commitLog, changedFiles, diffStats)
236236+237237+ messages := []llm.Message{
238238+ {Role: "system", Content: systemPrompt},
239239+ {Role: "user", Content: userPrompt},
240240+ }
241241+242242+ maxIterations := 10
243243+ for i := 0; i < maxIterations; i++ {
244244+ slog.Info("sending request to LLM", "iteration", i+1)
245245+246246+ llmStart := time.Now()
247247+ resp, err := s.chat(llm.ChatRequest{
248248+ Model: s.Config.Model,
249249+ Messages: messages,
250250+ Tools: llm.Tools,
251251+ ToolChoice: "auto",
252252+ })
253253+ if err != nil {
254254+ return "", fmt.Errorf("LLM request failed: %w", err)
255255+ }
256256+ slog.Info("LLM responded", "duration", time.Since(llmStart))
257257+258258+ if len(resp.Choices) == 0 {
259259+ return "", fmt.Errorf("no response from LLM")
260260+ }
261261+262262+ msg := resp.Choices[0].Message
263263+264264+ // Check for XML-style tool calls in content if no native tool calls
265265+ if len(msg.ToolCalls) == 0 && strings.Contains(msg.Content, "<function=") {
266266+ slog.Debug("parsing XML-style tool calls from content")
267267+ msg.ToolCalls = parseXMLToolCalls(msg.Content)
268268+ // Strip the XML from content for cleaner logs
269269+ funcRe := regexp.MustCompile(`<function=\w+>[\s\S]*?</function>`)
270270+ msg.Content = strings.TrimSpace(funcRe.ReplaceAllString(msg.Content, ""))
271271+ }
272272+273273+ messages = append(messages, msg)
274274+275275+ // If no tool calls, we're done
276276+ if len(msg.ToolCalls) == 0 {
277277+ slog.Info("completed", "iterations", i+1)
278278+ return msg.Content, nil
279279+ }
280280+281281+ // Execute tool calls
282282+ for _, tc := range msg.ToolCalls {
283283+ var args map[string]interface{}
284284+ if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
285285+ args = make(map[string]interface{})
286286+ }
287287+288288+ argsJSON, _ := json.Marshal(args)
289289+ slog.Info("executing tool", "name", tc.Function.Name, "args", string(argsJSON))
290290+291291+ result := s.executeTool(repo, tc.Function.Name, args)
292292+293293+ // Log result summary
294294+ lines := strings.Count(result, "\n")
295295+ if len(result) > 200 {
296296+ slog.Debug("tool result", "bytes", len(result), "lines", lines)
297297+ } else {
298298+ slog.Debug("tool result", "output", strings.ReplaceAll(result, "\n", "\\n"))
299299+ }
300300+301301+ messages = append(messages, llm.Message{
302302+ Role: "tool",
303303+ ToolCallID: tc.ID,
304304+ Content: result,
305305+ })
306306+ }
307307+ }
308308+309309+ return "", fmt.Errorf("max iterations reached without completion")
310310+}
+183
pkg/cache/cache.go
···11+package cache
22+33+import (
44+ "container/list"
55+ "crypto/sha256"
66+ "encoding/hex"
77+ "fmt"
88+ "log/slog"
99+ "os"
1010+ "path/filepath"
1111+ "sync"
1212+1313+ "git-summarizer/pkg/git"
1414+1515+ "github.com/go-git/go-git/v5/plumbing/transport"
1616+)
1717+1818+// RepoCache manages cached git repositories with LRU eviction
1919+type RepoCache struct {
2020+ baseDir string
2121+ maxRepos int
2222+ mu sync.Mutex
2323+ repoLocks map[string]*sync.Mutex
2424+ lru *list.List
2525+ repos map[string]*cacheEntry
2626+}
2727+2828+type cacheEntry struct {
2929+ url string
3030+ path string
3131+ element *list.Element
3232+}
3333+3434+// New creates a new RepoCache
3535+func New(baseDir string, maxRepos int) *RepoCache {
3636+ return &RepoCache{
3737+ baseDir: baseDir,
3838+ maxRepos: maxRepos,
3939+ repoLocks: make(map[string]*sync.Mutex),
4040+ lru: list.New(),
4141+ repos: make(map[string]*cacheEntry),
4242+ }
4343+}
4444+4545+// GetOrClone returns a cached repo or clones it if not present
4646+// If the repo is cached, it fetches updates before returning
4747+func (c *RepoCache) GetOrClone(url string, auth transport.AuthMethod) (*git.Repo, error) {
4848+ // Get or create per-repo lock
4949+ repoLock := c.getRepoLock(url)
5050+ repoLock.Lock()
5151+ defer repoLock.Unlock()
5252+5353+ // Check if repo exists in cache
5454+ c.mu.Lock()
5555+ entry, exists := c.repos[url]
5656+ if exists {
5757+ // Move to front of LRU
5858+ c.lru.MoveToFront(entry.element)
5959+ c.mu.Unlock()
6060+6161+ // Fetch updates
6262+ slog.Info("fetching cached repo", "url", url)
6363+ repo, err := git.Open(entry.path)
6464+ if err != nil {
6565+ // Cache entry invalid, remove and re-clone
6666+ slog.Warn("cached repo invalid, re-cloning", "url", url, "error", err)
6767+ c.mu.Lock()
6868+ c.removeEntry(url)
6969+ c.mu.Unlock()
7070+ return c.cloneNew(url, auth)
7171+ }
7272+7373+ if err := repo.Fetch(auth); err != nil {
7474+ slog.Warn("fetch failed", "url", url, "error", err)
7575+ // Continue with potentially stale data rather than failing
7676+ }
7777+7878+ return repo, nil
7979+ }
8080+ c.mu.Unlock()
8181+8282+ // Clone new repo
8383+ return c.cloneNew(url, auth)
8484+}
8585+8686+// cloneNew clones a repo and adds it to the cache
8787+func (c *RepoCache) cloneNew(url string, auth transport.AuthMethod) (*git.Repo, error) {
8888+ c.mu.Lock()
8989+9090+ // Evict if at capacity
9191+ for c.lru.Len() >= c.maxRepos {
9292+ c.evictLRU()
9393+ }
9494+9595+ // Prepare cache path
9696+ path := c.urlToPath(url)
9797+ c.mu.Unlock()
9898+9999+ // Ensure cache directory exists
100100+ if err := os.MkdirAll(c.baseDir, 0755); err != nil {
101101+ return nil, fmt.Errorf("failed to create cache dir: %w", err)
102102+ }
103103+104104+ // Clone
105105+ slog.Info("cloning repo to cache", "url", url, "path", path)
106106+ repo, err := git.Clone(url, path, auth)
107107+ if err != nil {
108108+ return nil, err
109109+ }
110110+111111+ // Add to cache
112112+ c.mu.Lock()
113113+ entry := &cacheEntry{
114114+ url: url,
115115+ path: path,
116116+ }
117117+ entry.element = c.lru.PushFront(url)
118118+ c.repos[url] = entry
119119+ c.mu.Unlock()
120120+121121+ slog.Info("repo cached", "url", url, "cache_size", c.lru.Len())
122122+ return repo, nil
123123+}
124124+125125+// getRepoLock returns the lock for a specific repo URL
126126+func (c *RepoCache) getRepoLock(url string) *sync.Mutex {
127127+ c.mu.Lock()
128128+ defer c.mu.Unlock()
129129+130130+ lock, exists := c.repoLocks[url]
131131+ if !exists {
132132+ lock = &sync.Mutex{}
133133+ c.repoLocks[url] = lock
134134+ }
135135+ return lock
136136+}
137137+138138+// evictLRU removes the least recently used repo from the cache
139139+// Must be called with c.mu held
140140+func (c *RepoCache) evictLRU() {
141141+ elem := c.lru.Back()
142142+ if elem == nil {
143143+ return
144144+ }
145145+146146+ url := elem.Value.(string)
147147+ c.removeEntry(url)
148148+ slog.Info("evicted repo from cache", "url", url)
149149+}
150150+151151+// removeEntry removes a repo from the cache
152152+// Must be called with c.mu held
153153+func (c *RepoCache) removeEntry(url string) {
154154+ entry, exists := c.repos[url]
155155+ if !exists {
156156+ return
157157+ }
158158+159159+ // Remove from LRU list
160160+ c.lru.Remove(entry.element)
161161+162162+ // Remove from map
163163+ delete(c.repos, url)
164164+165165+ // Remove from disk
166166+ if err := os.RemoveAll(entry.path); err != nil {
167167+ slog.Warn("failed to remove cached repo", "path", entry.path, "error", err)
168168+ }
169169+}
170170+171171+// urlToPath converts a repo URL to a filesystem-safe path
172172+func (c *RepoCache) urlToPath(url string) string {
173173+ hash := sha256.Sum256([]byte(url))
174174+ hashStr := hex.EncodeToString(hash[:8]) // Use first 8 bytes (16 hex chars)
175175+ return filepath.Join(c.baseDir, hashStr)
176176+}
177177+178178+// Size returns the number of cached repos
179179+func (c *RepoCache) Size() int {
180180+ c.mu.Lock()
181181+ defer c.mu.Unlock()
182182+ return c.lru.Len()
183183+}
+67
pkg/config/config.go
···11+package config
22+33+import (
44+ "flag"
55+ "os"
66+)
77+88+// Config holds application configuration
99+type Config struct {
1010+ LlamaURL string
1111+ Model string
1212+ ListenAddr string
1313+ RepoDir string
1414+ MaxDiffLen int
1515+ GitUser string
1616+ GitToken string
1717+ SSHKeyPath string
1818+ CacheDir string
1919+ MaxCachedRepos int
2020+}
2121+2222+// LoadConfig parses flags and environment variables to build configuration
2323+func LoadConfig() Config {
2424+ config := Config{
2525+ LlamaURL: "http://localhost:8080",
2626+ Model: "qwen2.5-coder",
2727+ ListenAddr: ":8000",
2828+ RepoDir: "/tmp",
2929+ MaxDiffLen: 16000,
3030+ CacheDir: "/tmp/git-cache",
3131+ MaxCachedRepos: 50,
3232+ }
3333+3434+ flag.StringVar(&config.LlamaURL, "llama-url", config.LlamaURL, "llama.cpp server URL")
3535+ flag.StringVar(&config.Model, "model", config.Model, "Model name to use")
3636+ flag.StringVar(&config.ListenAddr, "listen", config.ListenAddr, "Listen address")
3737+ flag.StringVar(&config.RepoDir, "repo-dir", config.RepoDir, "Directory for cloned repos")
3838+ flag.IntVar(&config.MaxDiffLen, "max-diff", config.MaxDiffLen, "Max diff length to send to LLM")
3939+ flag.StringVar(&config.GitUser, "git-user", "", "Git username for HTTPS auth")
4040+ flag.StringVar(&config.GitToken, "git-token", "", "Git token/password for HTTPS auth")
4141+ flag.StringVar(&config.SSHKeyPath, "ssh-key", "", "Path to SSH private key")
4242+ flag.StringVar(&config.CacheDir, "cache-dir", config.CacheDir, "Directory for cached repos")
4343+ flag.IntVar(&config.MaxCachedRepos, "max-cached-repos", config.MaxCachedRepos, "Max repos to cache (LRU eviction)")
4444+ flag.Parse()
4545+4646+ // Environment variable overrides
4747+ if url := os.Getenv("LLAMA_URL"); url != "" {
4848+ config.LlamaURL = url
4949+ }
5050+ if model := os.Getenv("LLAMA_MODEL"); model != "" {
5151+ config.Model = model
5252+ }
5353+ if user := os.Getenv("GIT_USER"); user != "" {
5454+ config.GitUser = user
5555+ }
5656+ if token := os.Getenv("GIT_TOKEN"); token != "" {
5757+ config.GitToken = token
5858+ }
5959+ if key := os.Getenv("SSH_KEY_PATH"); key != "" {
6060+ config.SSHKeyPath = key
6161+ }
6262+ if dir := os.Getenv("CACHE_DIR"); dir != "" {
6363+ config.CacheDir = dir
6464+ }
6565+6666+ return config
6767+}
+437
pkg/git/repo.go
···11+package git
22+33+import (
44+ "fmt"
55+ "strings"
66+ "time"
77+88+ "github.com/go-git/go-git/v5"
99+ "github.com/go-git/go-git/v5/plumbing"
1010+ "github.com/go-git/go-git/v5/plumbing/object"
1111+ "github.com/go-git/go-git/v5/plumbing/transport"
1212+)
1313+1414+// Repo wraps go-git repository operations
1515+type Repo struct {
1616+ repo *git.Repository
1717+ path string
1818+}
1919+2020+// Open opens an existing git repository
2121+func Open(path string) (*Repo, error) {
2222+ repo, err := git.PlainOpen(path)
2323+ if err != nil {
2424+ return nil, fmt.Errorf("failed to open repo: %w", err)
2525+ }
2626+ return &Repo{repo: repo, path: path}, nil
2727+}
2828+2929+// Clone clones a repository
3030+func Clone(url, dest string, auth transport.AuthMethod) (*Repo, error) {
3131+ repo, err := git.PlainClone(dest, false, &git.CloneOptions{
3232+ URL: url,
3333+ Auth: auth,
3434+ Progress: nil,
3535+ Tags: git.AllTags,
3636+ })
3737+ if err != nil {
3838+ return nil, fmt.Errorf("failed to clone: %w", err)
3939+ }
4040+ return &Repo{repo: repo, path: dest}, nil
4141+}
4242+4343+// Fetch fetches updates from the remote
4444+func (g *Repo) Fetch(auth transport.AuthMethod) error {
4545+ err := g.repo.Fetch(&git.FetchOptions{
4646+ Auth: auth,
4747+ Tags: git.AllTags,
4848+ Force: true,
4949+ })
5050+ // "already up-to-date" is not an error
5151+ if err != nil && err != git.NoErrAlreadyUpToDate {
5252+ return fmt.Errorf("failed to fetch: %w", err)
5353+ }
5454+ return nil
5555+}
5656+5757+// resolveRef resolves a ref string to a commit hash
5858+func (g *Repo) resolveRef(refStr string) (*plumbing.Hash, error) {
5959+ // Try as a branch
6060+ ref, err := g.repo.Reference(plumbing.NewBranchReferenceName(refStr), true)
6161+ if err == nil {
6262+ h := ref.Hash()
6363+ return &h, nil
6464+ }
6565+6666+ // Try as a tag
6767+ ref, err = g.repo.Reference(plumbing.NewTagReferenceName(refStr), true)
6868+ if err == nil {
6969+ // Could be annotated tag, resolve to commit
7070+ h := ref.Hash()
7171+ tagObj, err := g.repo.TagObject(h)
7272+ if err == nil {
7373+ commit, err := tagObj.Commit()
7474+ if err == nil {
7575+ ch := commit.Hash
7676+ return &ch, nil
7777+ }
7878+ }
7979+ return &h, nil
8080+ }
8181+8282+ // Try as a remote branch
8383+ ref, err = g.repo.Reference(plumbing.NewRemoteReferenceName("origin", refStr), true)
8484+ if err == nil {
8585+ h := ref.Hash()
8686+ return &h, nil
8787+ }
8888+8989+ // Try HEAD
9090+ if refStr == "HEAD" {
9191+ ref, err := g.repo.Head()
9292+ if err == nil {
9393+ h := ref.Hash()
9494+ return &h, nil
9595+ }
9696+ }
9797+9898+ // Try as direct hash
9999+ if len(refStr) >= 4 {
100100+ h := plumbing.NewHash(refStr)
101101+ if _, err := g.repo.CommitObject(h); err == nil {
102102+ return &h, nil
103103+ }
104104+ }
105105+106106+ // Try revision parsing (HEAD~n, etc)
107107+ hash, err := g.repo.ResolveRevision(plumbing.Revision(refStr))
108108+ if err == nil {
109109+ return hash, nil
110110+ }
111111+112112+ return nil, fmt.Errorf("cannot resolve ref: %s", refStr)
113113+}
114114+115115+// GetLog returns commit log between two refs
116116+func (g *Repo) GetLog(baseRef, headRef string, maxCount int) (string, error) {
117117+ baseHash, err := g.resolveRef(baseRef)
118118+ if err != nil {
119119+ return "", fmt.Errorf("cannot resolve base ref '%s': %w", baseRef, err)
120120+ }
121121+122122+ headHash, err := g.resolveRef(headRef)
123123+ if err != nil {
124124+ return "", fmt.Errorf("cannot resolve head ref '%s': %w", headRef, err)
125125+ }
126126+127127+ // Get commits reachable from head
128128+ headCommit, err := g.repo.CommitObject(*headHash)
129129+ if err != nil {
130130+ return "", err
131131+ }
132132+133133+ // Collect commits between base and head
134134+ var commits []*object.Commit
135135+ seen := make(map[plumbing.Hash]bool)
136136+137137+ err = object.NewCommitIterCTime(headCommit, seen, nil).ForEach(func(c *object.Commit) error {
138138+ if c.Hash == *baseHash {
139139+ return fmt.Errorf("stop") // Use error to stop iteration
140140+ }
141141+ commits = append(commits, c)
142142+ if maxCount > 0 && len(commits) >= maxCount {
143143+ return fmt.Errorf("stop")
144144+ }
145145+ return nil
146146+ })
147147+ // Ignore the "stop" error
148148+ if err != nil && err.Error() != "stop" {
149149+ return "", err
150150+ }
151151+152152+ var buf strings.Builder
153153+ for _, c := range commits {
154154+ shortHash := c.Hash.String()[:7]
155155+ firstLine := strings.Split(c.Message, "\n")[0]
156156+ buf.WriteString(fmt.Sprintf("%s %s\n", shortHash, firstLine))
157157+ }
158158+159159+ if buf.Len() == 0 {
160160+ return "No commits found between refs", nil
161161+ }
162162+163163+ return buf.String(), nil
164164+}
165165+166166+// GetDiff returns the diff between two refs
167167+func (g *Repo) GetDiff(baseRef, headRef string, filterFiles []string) (string, error) {
168168+ baseHash, err := g.resolveRef(baseRef)
169169+ if err != nil {
170170+ return "", fmt.Errorf("cannot resolve base ref '%s': %w", baseRef, err)
171171+ }
172172+173173+ headHash, err := g.resolveRef(headRef)
174174+ if err != nil {
175175+ return "", fmt.Errorf("cannot resolve head ref '%s': %w", headRef, err)
176176+ }
177177+178178+ baseCommit, err := g.repo.CommitObject(*baseHash)
179179+ if err != nil {
180180+ return "", err
181181+ }
182182+183183+ headCommit, err := g.repo.CommitObject(*headHash)
184184+ if err != nil {
185185+ return "", err
186186+ }
187187+188188+ baseTree, err := baseCommit.Tree()
189189+ if err != nil {
190190+ return "", err
191191+ }
192192+193193+ headTree, err := headCommit.Tree()
194194+ if err != nil {
195195+ return "", err
196196+ }
197197+198198+ changes, err := baseTree.Diff(headTree)
199199+ if err != nil {
200200+ return "", err
201201+ }
202202+203203+ var buf strings.Builder
204204+ filterMap := make(map[string]bool)
205205+ for _, f := range filterFiles {
206206+ filterMap[f] = true
207207+ }
208208+209209+ for _, change := range changes {
210210+ // Apply file filter if specified
211211+ if len(filterFiles) > 0 {
212212+ name := change.To.Name
213213+ if name == "" {
214214+ name = change.From.Name
215215+ }
216216+ if !filterMap[name] {
217217+ continue
218218+ }
219219+ }
220220+221221+ patch, err := change.Patch()
222222+ if err != nil {
223223+ continue
224224+ }
225225+ buf.WriteString(patch.String())
226226+ buf.WriteString("\n")
227227+ }
228228+229229+ if buf.Len() == 0 {
230230+ return "No changes found", nil
231231+ }
232232+233233+ return buf.String(), nil
234234+}
235235+236236+// ListChangedFiles returns a list of changed files between refs
237237+func (g *Repo) ListChangedFiles(baseRef, headRef string) (string, error) {
238238+ baseHash, err := g.resolveRef(baseRef)
239239+ if err != nil {
240240+ return "", fmt.Errorf("cannot resolve base ref '%s': %w", baseRef, err)
241241+ }
242242+243243+ headHash, err := g.resolveRef(headRef)
244244+ if err != nil {
245245+ return "", fmt.Errorf("cannot resolve head ref '%s': %w", headRef, err)
246246+ }
247247+248248+ baseCommit, err := g.repo.CommitObject(*baseHash)
249249+ if err != nil {
250250+ return "", err
251251+ }
252252+253253+ headCommit, err := g.repo.CommitObject(*headHash)
254254+ if err != nil {
255255+ return "", err
256256+ }
257257+258258+ baseTree, err := baseCommit.Tree()
259259+ if err != nil {
260260+ return "", err
261261+ }
262262+263263+ headTree, err := headCommit.Tree()
264264+ if err != nil {
265265+ return "", err
266266+ }
267267+268268+ changes, err := baseTree.Diff(headTree)
269269+ if err != nil {
270270+ return "", err
271271+ }
272272+273273+ var buf strings.Builder
274274+ for _, change := range changes {
275275+ action := "M" // Modified
276276+ name := change.To.Name
277277+278278+ if change.From.Name == "" {
279279+ action = "A" // Added
280280+ } else if change.To.Name == "" {
281281+ action = "D" // Deleted
282282+ name = change.From.Name
283283+ } else if change.From.Name != change.To.Name {
284284+ action = "R" // Renamed
285285+ name = fmt.Sprintf("%s -> %s", change.From.Name, change.To.Name)
286286+ }
287287+288288+ buf.WriteString(fmt.Sprintf("%s\t%s\n", action, name))
289289+ }
290290+291291+ if buf.Len() == 0 {
292292+ return "No files changed", nil
293293+ }
294294+295295+ return buf.String(), nil
296296+}
297297+298298+// GetDiffStats returns statistics about changes
299299+func (g *Repo) GetDiffStats(baseRef, headRef string) (string, error) {
300300+ baseHash, err := g.resolveRef(baseRef)
301301+ if err != nil {
302302+ return "", fmt.Errorf("cannot resolve base ref '%s': %w", baseRef, err)
303303+ }
304304+305305+ headHash, err := g.resolveRef(headRef)
306306+ if err != nil {
307307+ return "", fmt.Errorf("cannot resolve head ref '%s': %w", headRef, err)
308308+ }
309309+310310+ baseCommit, err := g.repo.CommitObject(*baseHash)
311311+ if err != nil {
312312+ return "", err
313313+ }
314314+315315+ headCommit, err := g.repo.CommitObject(*headHash)
316316+ if err != nil {
317317+ return "", err
318318+ }
319319+320320+ baseTree, err := baseCommit.Tree()
321321+ if err != nil {
322322+ return "", err
323323+ }
324324+325325+ headTree, err := headCommit.Tree()
326326+ if err != nil {
327327+ return "", err
328328+ }
329329+330330+ changes, err := baseTree.Diff(headTree)
331331+ if err != nil {
332332+ return "", err
333333+ }
334334+335335+ patch, err := changes.Patch()
336336+ if err != nil {
337337+ return "", err
338338+ }
339339+340340+ stats := patch.Stats()
341341+342342+ var buf strings.Builder
343343+ totalAdd, totalDel := 0, 0
344344+345345+ for _, stat := range stats {
346346+ buf.WriteString(fmt.Sprintf("%s | %d + %d -\n", stat.Name, stat.Addition, stat.Deletion))
347347+ totalAdd += stat.Addition
348348+ totalDel += stat.Deletion
349349+ }
350350+351351+ buf.WriteString(fmt.Sprintf("\n%d files changed, %d insertions(+), %d deletions(-)\n",
352352+ len(stats), totalAdd, totalDel))
353353+354354+ return buf.String(), nil
355355+}
356356+357357+// ShowCommit shows details of a specific commit
358358+func (g *Repo) ShowCommit(refStr string) (string, error) {
359359+ hash, err := g.resolveRef(refStr)
360360+ if err != nil {
361361+ return "", fmt.Errorf("cannot resolve ref '%s': %w", refStr, err)
362362+ }
363363+364364+ commit, err := g.repo.CommitObject(*hash)
365365+ if err != nil {
366366+ return "", err
367367+ }
368368+369369+ var buf strings.Builder
370370+ buf.WriteString(fmt.Sprintf("commit %s\n", commit.Hash.String()))
371371+ buf.WriteString(fmt.Sprintf("Author: %s <%s>\n", commit.Author.Name, commit.Author.Email))
372372+ buf.WriteString(fmt.Sprintf("Date: %s\n\n", commit.Author.When.Format(time.RFC1123)))
373373+374374+ // Indent message
375375+ for _, line := range strings.Split(commit.Message, "\n") {
376376+ buf.WriteString(fmt.Sprintf(" %s\n", line))
377377+ }
378378+379379+ // Get stats if parent exists
380380+ if commit.NumParents() > 0 {
381381+ parent, err := commit.Parent(0)
382382+ if err == nil {
383383+ parentTree, _ := parent.Tree()
384384+ commitTree, _ := commit.Tree()
385385+ if parentTree != nil && commitTree != nil {
386386+ changes, err := parentTree.Diff(commitTree)
387387+ if err == nil {
388388+ patch, err := changes.Patch()
389389+ if err == nil {
390390+ stats := patch.Stats()
391391+ buf.WriteString("\n")
392392+ for _, stat := range stats {
393393+ buf.WriteString(fmt.Sprintf(" %s | %d +%d -%d\n",
394394+ stat.Name, stat.Addition+stat.Deletion, stat.Addition, stat.Deletion))
395395+ }
396396+ }
397397+ }
398398+ }
399399+ }
400400+ }
401401+402402+ return buf.String(), nil
403403+}
404404+405405+// ReadFile reads a file at a specific ref
406406+func (g *Repo) ReadFile(path, refStr string) (string, error) {
407407+ if refStr == "" {
408408+ refStr = "HEAD"
409409+ }
410410+411411+ hash, err := g.resolveRef(refStr)
412412+ if err != nil {
413413+ return "", fmt.Errorf("cannot resolve ref '%s': %w", refStr, err)
414414+ }
415415+416416+ commit, err := g.repo.CommitObject(*hash)
417417+ if err != nil {
418418+ return "", err
419419+ }
420420+421421+ tree, err := commit.Tree()
422422+ if err != nil {
423423+ return "", err
424424+ }
425425+426426+ file, err := tree.File(path)
427427+ if err != nil {
428428+ return "", fmt.Errorf("file not found: %s", path)
429429+ }
430430+431431+ content, err := file.Contents()
432432+ if err != nil {
433433+ return "", err
434434+ }
435435+436436+ return content, nil
437437+}