package gitx import ( "context" "fmt" "github.com/go-git/go-git/v5/plumbing/format/diff" "github.com/go-git/go-git/v5/plumbing/object" "go.bigb.es/sourcehut-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 }