package gitx
import (
"context"
"fmt"
"github.com/go-git/go-git/v5/plumbing/format/diff"
"github.com/go-git/go-git/v5/plumbing/object"
"sourcecraft.dev/bigbes/sr-ht-compare/core"
)
// Patch is a rendered unified diff. Truncated is set when the generated text
// exceeded the byte cap and was cut at a file boundary; callers should then
// fall back to the file list plus a link to the raw .patch.
type Patch struct {
Text string
Truncated bool
}
// FileChange is one entry in a diff's file list. Status is the single-letter
// git class: A(dded), M(odified), D(eleted), R(enamed). (go-git's tree diff
// does not surface copy or type-change classes, so C and T never appear.)
// OldPath is set only for renames. Binary is true when the change is binary, in
// which case Additions and Deletions are zero.
type FileChange struct {
Path string
OldPath string
Status string
Additions int
Deletions int
Binary bool
}
// validateSpec checks both endpoints of a compare spec.
func validateSpec(spec core.CompareSpec) error {
if !core.ValidRef(spec.Base) {
return fmt.Errorf("%w: invalid base %q", core.ErrBadRef, spec.Base)
}
if !core.ValidRef(spec.Head) {
return fmt.Errorf("%w: invalid head %q", core.ErrBadRef, spec.Head)
}
return nil
}
// specChanges resolves a compare spec to the ordered tree changes. For a
// three-dot spec the old side is the merge base of base and head; for two-dot
// it is base directly. The new side is always head.
func (r *Repo) specChanges(ctx context.Context, spec core.CompareSpec) (object.Changes, error) {
if err := validateSpec(spec); err != nil {
return nil, err
}
baseCommit, err := r.resolveCommit(spec.Base)
if err != nil {
return nil, err
}
headCommit, err := r.resolveCommit(spec.Head)
if err != nil {
return nil, err
}
oldCommit := baseCommit
if spec.ThreeDot {
bases, err := baseCommit.MergeBase(headCommit)
if err != nil {
return nil, err
}
if len(bases) == 0 {
return nil, fmt.Errorf("%w: no merge base for %s...%s", core.ErrBadRef, spec.Base, spec.Head)
}
oldCommit = bases[0]
}
oldTree, err := oldCommit.Tree()
if err != nil {
return nil, err
}
newTree, err := headCommit.Tree()
if err != nil {
return nil, err
}
return object.DiffTreeWithOptions(ctx, oldTree, newTree, diffOpts)
}
// Diff renders the unified diff for a compare spec, capped at the in-page byte
// limit (Truncated set and the text cut at a file boundary on overflow).
func (r *Repo) Diff(ctx context.Context, spec core.CompareSpec) (*Patch, error) {
return r.diffPatch(ctx, spec, r.diffLimit(pageDiffLimit))
}
// RawDiff is Diff with the larger .patch-download byte cap.
func (r *Repo) RawDiff(ctx context.Context, spec core.CompareSpec) (*Patch, error) {
return r.diffPatch(ctx, spec, r.diffLimit(rawDiffLimit))
}
func (r *Repo) diffPatch(ctx context.Context, spec core.CompareSpec, limit int64) (*Patch, error) {
ctx, cancel := r.withTimeout(ctx)
defer cancel()
changes, err := r.specChanges(ctx, spec)
if err != nil {
return nil, err
}
return renderPatch(ctx, changes, limit)
}
// DiffStat returns the per-file change list for a compare spec.
func (r *Repo) DiffStat(ctx context.Context, spec core.CompareSpec) ([]FileChange, error) {
ctx, cancel := r.withTimeout(ctx)
defer cancel()
changes, err := r.specChanges(ctx, spec)
if err != nil {
return nil, err
}
patch, err := changes.PatchContext(ctx)
if err != nil {
return nil, err
}
return mapFilePatches(patch.FilePatches()), nil
}
// MergeBase returns the SHA of the best common ancestor of two revisions.
func (r *Repo) MergeBase(ctx context.Context, a, b string) (string, error) {
_, cancel := r.withTimeout(ctx)
defer cancel()
ca, err := r.resolveCommit(a)
if err != nil {
return "", err
}
cb, err := r.resolveCommit(b)
if err != nil {
return "", err
}
bases, err := ca.MergeBase(cb)
if err != nil {
return "", err
}
if len(bases) == 0 {
return "", fmt.Errorf("%w: no merge base for %s and %s", core.ErrNotFound, a, b)
}
return bases[0].Hash.String(), nil
}
// CommitPatch renders a single commit as a diff plus its file list and
// metadata. A non-merge commit is diffed against its parent (a root commit
// against the empty tree). A merge commit is diffed against its first parent —
// the conventional, reviewable single-parent view; callers detect the merge via
// len(CommitInfo.ParentSHAs) > 1 to show a banner.
func (r *Repo) CommitPatch(ctx context.Context, rev string) (*Patch, []FileChange, *CommitInfo, error) {
ctx, cancel := r.withTimeout(ctx)
defer cancel()
c, err := r.resolveCommit(rev)
if err != nil {
return nil, nil, nil, err
}
var oldTree *object.Tree
if c.NumParents() > 0 {
parent, err := c.Parent(0)
if err != nil {
return nil, nil, nil, err
}
oldTree, err = parent.Tree()
if err != nil {
return nil, nil, nil, err
}
}
newTree, err := c.Tree()
if err != nil {
return nil, nil, nil, err
}
changes, err := object.DiffTreeWithOptions(ctx, oldTree, newTree, diffOpts)
if err != nil {
return nil, nil, nil, err
}
patch, err := changes.PatchContext(ctx)
if err != nil {
return nil, nil, nil, err
}
text, truncated := cutPatch(patch.String(), r.diffLimit(pageDiffLimit))
files := mapFilePatches(patch.FilePatches())
return &Patch{Text: text, Truncated: truncated}, files, commitInfo(c), nil
}
// renderPatch turns tree changes into a size-capped Patch.
func renderPatch(ctx context.Context, changes object.Changes, limit int64) (*Patch, error) {
patch, err := changes.PatchContext(ctx)
if err != nil {
return nil, err
}
text, truncated := cutPatch(patch.String(), limit)
return &Patch{Text: text, Truncated: truncated}, nil
}
// mapFilePatches converts go-git file patches into the package's FileChange
// list. It is a pure function of the FilePatch slice so it can be unit-tested
// with synthetic patches. Status is derived from the from/to file pair; line
// counts are tallied from the chunks (skipped for binary files).
func mapFilePatches(fps []diff.FilePatch) []FileChange {
var out []FileChange
for _, fp := range fps {
from, to := fp.Files()
if from == nil && to == nil {
continue
}
fc := FileChange{Binary: fp.IsBinary()}
switch {
case from == nil:
fc.Status = "A"
fc.Path = to.Path()
case to == nil:
fc.Status = "D"
fc.Path = from.Path()
case from.Path() != to.Path():
fc.Status = "R"
fc.OldPath = from.Path()
fc.Path = to.Path()
default:
fc.Status = "M"
fc.Path = from.Path()
}
if !fc.Binary {
for _, ch := range fp.Chunks() {
s := ch.Content()
if s == "" {
continue
}
n := countLines(s)
switch ch.Type() {
case diff.Add:
fc.Additions += n
case diff.Delete:
fc.Deletions += n
}
}
}
out = append(out, fc)
}
return out
}
// countLines counts the lines in a chunk's content, counting a final
// unterminated line as one line (matching git's stat behaviour).
func countLines(s string) int {
n := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
n++
}
}
if len(s) > 0 && s[len(s)-1] != '\n' {
n++
}
return n
}