An AI-powered tool that generates human-readable summaries of git changes using tool calling with a self-hosted LLM
1package git
2
3import (
4 "fmt"
5 "strings"
6 "time"
7
8 "github.com/go-git/go-git/v5"
9 "github.com/go-git/go-git/v5/plumbing"
10 "github.com/go-git/go-git/v5/plumbing/object"
11 "github.com/go-git/go-git/v5/plumbing/transport"
12)
13
14// Repo wraps go-git repository operations
15type Repo struct {
16 repo *git.Repository
17 path string
18}
19
20// Open opens an existing git repository
21func Open(path string) (*Repo, error) {
22 repo, err := git.PlainOpen(path)
23 if err != nil {
24 return nil, fmt.Errorf("failed to open repo: %w", err)
25 }
26 return &Repo{repo: repo, path: path}, nil
27}
28
29// Clone clones a repository
30func Clone(url, dest string, auth transport.AuthMethod) (*Repo, error) {
31 repo, err := git.PlainClone(dest, false, &git.CloneOptions{
32 URL: url,
33 Auth: auth,
34 Progress: nil,
35 Tags: git.AllTags,
36 })
37 if err != nil {
38 return nil, fmt.Errorf("failed to clone: %w", err)
39 }
40 return &Repo{repo: repo, path: dest}, nil
41}
42
43// Fetch fetches updates from the remote
44func (g *Repo) Fetch(auth transport.AuthMethod) error {
45 err := g.repo.Fetch(&git.FetchOptions{
46 Auth: auth,
47 Tags: git.AllTags,
48 Force: true,
49 })
50 // "already up-to-date" is not an error
51 if err != nil && err != git.NoErrAlreadyUpToDate {
52 return fmt.Errorf("failed to fetch: %w", err)
53 }
54 return nil
55}
56
57// resolveRef resolves a ref string to a commit hash
58func (g *Repo) resolveRef(refStr string) (*plumbing.Hash, error) {
59 // Try as a branch
60 ref, err := g.repo.Reference(plumbing.NewBranchReferenceName(refStr), true)
61 if err == nil {
62 h := ref.Hash()
63 return &h, nil
64 }
65
66 // Try as a tag
67 ref, err = g.repo.Reference(plumbing.NewTagReferenceName(refStr), true)
68 if err == nil {
69 // Could be annotated tag, resolve to commit
70 h := ref.Hash()
71 tagObj, err := g.repo.TagObject(h)
72 if err == nil {
73 commit, err := tagObj.Commit()
74 if err == nil {
75 ch := commit.Hash
76 return &ch, nil
77 }
78 }
79 return &h, nil
80 }
81
82 // Try as a remote branch
83 ref, err = g.repo.Reference(plumbing.NewRemoteReferenceName("origin", refStr), true)
84 if err == nil {
85 h := ref.Hash()
86 return &h, nil
87 }
88
89 // Try HEAD
90 if refStr == "HEAD" {
91 ref, err := g.repo.Head()
92 if err == nil {
93 h := ref.Hash()
94 return &h, nil
95 }
96 }
97
98 // Try as direct hash
99 if len(refStr) >= 4 {
100 h := plumbing.NewHash(refStr)
101 if _, err := g.repo.CommitObject(h); err == nil {
102 return &h, nil
103 }
104 }
105
106 // Try revision parsing (HEAD~n, etc)
107 hash, err := g.repo.ResolveRevision(plumbing.Revision(refStr))
108 if err == nil {
109 return hash, nil
110 }
111
112 return nil, fmt.Errorf("cannot resolve ref: %s", refStr)
113}
114
115// GetLog returns commit log between two refs
116func (g *Repo) GetLog(baseRef, headRef string, maxCount int) (string, error) {
117 baseHash, err := g.resolveRef(baseRef)
118 if err != nil {
119 return "", fmt.Errorf("cannot resolve base ref '%s': %w", baseRef, err)
120 }
121
122 headHash, err := g.resolveRef(headRef)
123 if err != nil {
124 return "", fmt.Errorf("cannot resolve head ref '%s': %w", headRef, err)
125 }
126
127 // Get commits reachable from head
128 headCommit, err := g.repo.CommitObject(*headHash)
129 if err != nil {
130 return "", err
131 }
132
133 // Collect commits between base and head
134 var commits []*object.Commit
135 seen := make(map[plumbing.Hash]bool)
136
137 err = object.NewCommitIterCTime(headCommit, seen, nil).ForEach(func(c *object.Commit) error {
138 if c.Hash == *baseHash {
139 return fmt.Errorf("stop") // Use error to stop iteration
140 }
141 commits = append(commits, c)
142 if maxCount > 0 && len(commits) >= maxCount {
143 return fmt.Errorf("stop")
144 }
145 return nil
146 })
147 // Ignore the "stop" error
148 if err != nil && err.Error() != "stop" {
149 return "", err
150 }
151
152 var buf strings.Builder
153 for _, c := range commits {
154 shortHash := c.Hash.String()[:7]
155 firstLine := strings.Split(c.Message, "\n")[0]
156 buf.WriteString(fmt.Sprintf("%s %s\n", shortHash, firstLine))
157 }
158
159 if buf.Len() == 0 {
160 return "No commits found between refs", nil
161 }
162
163 return buf.String(), nil
164}
165
166// GetDiff returns the diff between two refs
167func (g *Repo) GetDiff(baseRef, headRef string, filterFiles []string) (string, error) {
168 baseHash, err := g.resolveRef(baseRef)
169 if err != nil {
170 return "", fmt.Errorf("cannot resolve base ref '%s': %w", baseRef, err)
171 }
172
173 headHash, err := g.resolveRef(headRef)
174 if err != nil {
175 return "", fmt.Errorf("cannot resolve head ref '%s': %w", headRef, err)
176 }
177
178 baseCommit, err := g.repo.CommitObject(*baseHash)
179 if err != nil {
180 return "", err
181 }
182
183 headCommit, err := g.repo.CommitObject(*headHash)
184 if err != nil {
185 return "", err
186 }
187
188 baseTree, err := baseCommit.Tree()
189 if err != nil {
190 return "", err
191 }
192
193 headTree, err := headCommit.Tree()
194 if err != nil {
195 return "", err
196 }
197
198 changes, err := baseTree.Diff(headTree)
199 if err != nil {
200 return "", err
201 }
202
203 var buf strings.Builder
204 filterMap := make(map[string]bool)
205 for _, f := range filterFiles {
206 filterMap[f] = true
207 }
208
209 for _, change := range changes {
210 // Apply file filter if specified
211 if len(filterFiles) > 0 {
212 name := change.To.Name
213 if name == "" {
214 name = change.From.Name
215 }
216 if !filterMap[name] {
217 continue
218 }
219 }
220
221 patch, err := change.Patch()
222 if err != nil {
223 continue
224 }
225 buf.WriteString(patch.String())
226 buf.WriteString("\n")
227 }
228
229 if buf.Len() == 0 {
230 return "No changes found", nil
231 }
232
233 return buf.String(), nil
234}
235
236// ListChangedFiles returns a list of changed files between refs
237func (g *Repo) ListChangedFiles(baseRef, headRef string) (string, error) {
238 baseHash, err := g.resolveRef(baseRef)
239 if err != nil {
240 return "", fmt.Errorf("cannot resolve base ref '%s': %w", baseRef, err)
241 }
242
243 headHash, err := g.resolveRef(headRef)
244 if err != nil {
245 return "", fmt.Errorf("cannot resolve head ref '%s': %w", headRef, err)
246 }
247
248 baseCommit, err := g.repo.CommitObject(*baseHash)
249 if err != nil {
250 return "", err
251 }
252
253 headCommit, err := g.repo.CommitObject(*headHash)
254 if err != nil {
255 return "", err
256 }
257
258 baseTree, err := baseCommit.Tree()
259 if err != nil {
260 return "", err
261 }
262
263 headTree, err := headCommit.Tree()
264 if err != nil {
265 return "", err
266 }
267
268 changes, err := baseTree.Diff(headTree)
269 if err != nil {
270 return "", err
271 }
272
273 var buf strings.Builder
274 for _, change := range changes {
275 action := "M" // Modified
276 name := change.To.Name
277
278 if change.From.Name == "" {
279 action = "A" // Added
280 } else if change.To.Name == "" {
281 action = "D" // Deleted
282 name = change.From.Name
283 } else if change.From.Name != change.To.Name {
284 action = "R" // Renamed
285 name = fmt.Sprintf("%s -> %s", change.From.Name, change.To.Name)
286 }
287
288 buf.WriteString(fmt.Sprintf("%s\t%s\n", action, name))
289 }
290
291 if buf.Len() == 0 {
292 return "No files changed", nil
293 }
294
295 return buf.String(), nil
296}
297
298// GetDiffStats returns statistics about changes
299func (g *Repo) GetDiffStats(baseRef, headRef string) (string, error) {
300 baseHash, err := g.resolveRef(baseRef)
301 if err != nil {
302 return "", fmt.Errorf("cannot resolve base ref '%s': %w", baseRef, err)
303 }
304
305 headHash, err := g.resolveRef(headRef)
306 if err != nil {
307 return "", fmt.Errorf("cannot resolve head ref '%s': %w", headRef, err)
308 }
309
310 baseCommit, err := g.repo.CommitObject(*baseHash)
311 if err != nil {
312 return "", err
313 }
314
315 headCommit, err := g.repo.CommitObject(*headHash)
316 if err != nil {
317 return "", err
318 }
319
320 baseTree, err := baseCommit.Tree()
321 if err != nil {
322 return "", err
323 }
324
325 headTree, err := headCommit.Tree()
326 if err != nil {
327 return "", err
328 }
329
330 changes, err := baseTree.Diff(headTree)
331 if err != nil {
332 return "", err
333 }
334
335 patch, err := changes.Patch()
336 if err != nil {
337 return "", err
338 }
339
340 stats := patch.Stats()
341
342 var buf strings.Builder
343 totalAdd, totalDel := 0, 0
344
345 for _, stat := range stats {
346 buf.WriteString(fmt.Sprintf("%s | %d + %d -\n", stat.Name, stat.Addition, stat.Deletion))
347 totalAdd += stat.Addition
348 totalDel += stat.Deletion
349 }
350
351 buf.WriteString(fmt.Sprintf("\n%d files changed, %d insertions(+), %d deletions(-)\n",
352 len(stats), totalAdd, totalDel))
353
354 return buf.String(), nil
355}
356
357// ShowCommit shows details of a specific commit
358func (g *Repo) ShowCommit(refStr string) (string, error) {
359 hash, err := g.resolveRef(refStr)
360 if err != nil {
361 return "", fmt.Errorf("cannot resolve ref '%s': %w", refStr, err)
362 }
363
364 commit, err := g.repo.CommitObject(*hash)
365 if err != nil {
366 return "", err
367 }
368
369 var buf strings.Builder
370 buf.WriteString(fmt.Sprintf("commit %s\n", commit.Hash.String()))
371 buf.WriteString(fmt.Sprintf("Author: %s <%s>\n", commit.Author.Name, commit.Author.Email))
372 buf.WriteString(fmt.Sprintf("Date: %s\n\n", commit.Author.When.Format(time.RFC1123)))
373
374 // Indent message
375 for _, line := range strings.Split(commit.Message, "\n") {
376 buf.WriteString(fmt.Sprintf(" %s\n", line))
377 }
378
379 // Get stats if parent exists
380 if commit.NumParents() > 0 {
381 parent, err := commit.Parent(0)
382 if err == nil {
383 parentTree, _ := parent.Tree()
384 commitTree, _ := commit.Tree()
385 if parentTree != nil && commitTree != nil {
386 changes, err := parentTree.Diff(commitTree)
387 if err == nil {
388 patch, err := changes.Patch()
389 if err == nil {
390 stats := patch.Stats()
391 buf.WriteString("\n")
392 for _, stat := range stats {
393 buf.WriteString(fmt.Sprintf(" %s | %d +%d -%d\n",
394 stat.Name, stat.Addition+stat.Deletion, stat.Addition, stat.Deletion))
395 }
396 }
397 }
398 }
399 }
400 }
401
402 return buf.String(), nil
403}
404
405// ReadFile reads a file at a specific ref
406func (g *Repo) ReadFile(path, refStr string) (string, error) {
407 if refStr == "" {
408 refStr = "HEAD"
409 }
410
411 hash, err := g.resolveRef(refStr)
412 if err != nil {
413 return "", fmt.Errorf("cannot resolve ref '%s': %w", refStr, err)
414 }
415
416 commit, err := g.repo.CommitObject(*hash)
417 if err != nil {
418 return "", err
419 }
420
421 tree, err := commit.Tree()
422 if err != nil {
423 return "", err
424 }
425
426 file, err := tree.File(path)
427 if err != nil {
428 return "", fmt.Errorf("file not found: %s", path)
429 }
430
431 content, err := file.Contents()
432 if err != nil {
433 return "", err
434 }
435
436 return content, nil
437}