package git import ( "errors" "fmt" "strconv" "strings" "github.com/go-git/go-git/v5/plumbing" ) type Compare struct { BaseRef, BaseHash string HeadRef, HeadHash string MergeBase string Ahead int Behind int Commits []*Commit Diff *NiceDiff } func (g *Repo) Compare(baseRef, headRef string) (*Compare, error) { if baseRef == "" || headRef == "" { return nil, errors.New("base and head refs can not be empty") } baseHash, err := g.resolveRef(baseRef) if err != nil { return nil, fmt.Errorf("resolving base ref %q: %w", baseRef, err) } headHash, err := g.resolveRef(headRef) if err != nil { return nil, fmt.Errorf("resolving head ref %q: %w", headRef, err) } mergeBaseOut, err := g.mergeBase(baseHash.String(), headHash.String()) if err != nil { return nil, fmt.Errorf("merge-base for %q and %q: %w", baseRef, headRef, err) } mergeBase := strings.TrimSpace(string(mergeBaseOut)) if mergeBase == "" { return nil, fmt.Errorf("merge-base for %q and %q: empty output", baseRef, headRef) } countsOut, err := g.revList("--left-right", "--count", fmt.Sprintf("%s...%s", baseHash.String(), headHash.String())) if err != nil { return nil, fmt.Errorf("ahead/behind for %q and %q: %w", baseRef, headRef, err) } behind, ahead, err := parseAheadBehind(countsOut) if err != nil { return nil, err } commits, err := g.commitsInRange(baseHash, headHash) if err != nil { return nil, err } diff, err := g.diffBetween(plumbing.NewHash(mergeBase), headHash) if err != nil { return nil, err } return &Compare{ BaseRef: baseRef, HeadRef: headRef, BaseHash: baseHash.String(), HeadHash: headHash.String(), MergeBase: mergeBase, Ahead: ahead, Behind: behind, Commits: commits, Diff: diff, }, nil } func (g *Repo) resolveRef(ref string) (plumbing.Hash, error) { hash, err := g.r.ResolveRevision(plumbing.Revision(ref)) if err != nil { return plumbing.ZeroHash, err } return g.peelToCommit(*hash), nil } func parseAheadBehind(counts []byte) (behind, ahead int, err error) { fields := strings.Fields(strings.TrimSpace(string(counts))) if len(fields) != 2 { return 0, 0, fmt.Errorf("unexpected ahead/behind format: %q", counts) } behind, err = strconv.Atoi(fields[0]) if err != nil { return 0, 0, fmt.Errorf("invalid behind count %q: %w", fields[0], err) } ahead, err = strconv.Atoi(fields[1]) if err != nil { return 0, 0, fmt.Errorf("invalid ahead count %q: %w", fields[1], err) } return behind, ahead, nil } func (g *Repo) commitsInRange(base, head plumbing.Hash) ([]*Commit, error) { out, err := g.runGitCmd("log", "--format=%H", fmt.Sprintf("%s..%s", base.String(), head.String())) if err != nil { return nil, fmt.Errorf("commits in range %s..%s: %w", base, head, err) } lines := strings.Split(strings.TrimSpace(string(out)), "\n") if len(lines) == 1 && lines[0] == "" { return []*Commit{}, nil } commits := make([]*Commit, 0, len(lines)) for _, hash := range lines { hash = strings.TrimSpace(hash) if hash == "" { continue } c, err := g.r.CommitObject(plumbing.NewHash(hash)) if err != nil { return nil, fmt.Errorf("commit object %s: %w", hash, err) } commits = append(commits, newCommit(c)) } return commits, nil } func (g *Repo) revList(args ...string) ([]byte, error) { return g.runGitCmd("rev-list", args...) } func (g *Repo) mergeBase(args ...string) ([]byte, error) { return g.runGitCmd("merge-base", args...) }