An AI-powered tool that generates human-readable summaries of git changes using tool calling with a self-hosted LLM
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 437 lines 9.9 kB view raw
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}