coverage~bigbes/sr-ht-compare9720ccc2gitx/diff.go

Coverage
79.7% 98/123 statements
Δ
Blob
ab51e18
1 package gitx
2
3 import (
4 "context"
5 "fmt"
6
7 "github.com/go-git/go-git/v5/plumbing/format/diff"
8 "github.com/go-git/go-git/v5/plumbing/object"
9
10 "sourcecraft.dev/bigbes/sr-ht-compare/core"
11 )
12
13 // Patch is a rendered unified diff. Truncated is set when the generated text
14 // exceeded the byte cap and was cut at a file boundary; callers should then
15 // fall back to the file list plus a link to the raw .patch.
16 type Patch struct {
17 Text string
18 Truncated bool
19 }
20
21 // FileChange is one entry in a diff's file list. Status is the single-letter
22 // git class: A(dded), M(odified), D(eleted), R(enamed). (go-git's tree diff
23 // does not surface copy or type-change classes, so C and T never appear.)
24 // OldPath is set only for renames. Binary is true when the change is binary, in
25 // which case Additions and Deletions are zero.
26 type FileChange struct {
27 Path string
28 OldPath string
29 Status string
30 Additions int
31 Deletions int
32 Binary bool
33 }
34
35 // validateSpec checks both endpoints of a compare spec.
36 7 func validateSpec(spec core.CompareSpec) error {
37 7 if !core.ValidRef(spec.Base) {
38 0 return fmt.Errorf("%w: invalid base %q", core.ErrBadRef, spec.Base)
39 0 }
40 7 if !core.ValidRef(spec.Head) {
41 0 return fmt.Errorf("%w: invalid head %q", core.ErrBadRef, spec.Head)
42 0 }
43 7 return nil
44 }
45
46 // specChanges resolves a compare spec to the ordered tree changes. For a
47 // three-dot spec the old side is the merge base of base and head; for two-dot
48 // it is base directly. The new side is always head.
49 7 func (r *Repo) specChanges(ctx context.Context, spec core.CompareSpec) (object.Changes, error) {
50 7 if err := validateSpec(spec); err != nil {
51 0 return nil, err
52 0 }
53 7 baseCommit, err := r.resolveCommit(spec.Base)
54 7 if err != nil {
55 0 return nil, err
56 0 }
57 7 headCommit, err := r.resolveCommit(spec.Head)
58 7 if err != nil {
59 0 return nil, err
60 0 }
61
62 7 oldCommit := baseCommit
63 7 if spec.ThreeDot {
64 3 bases, err := baseCommit.MergeBase(headCommit)
65 3 if err != nil {
66 0 return nil, err
67 0 }
68 3 if len(bases) == 0 {
69 0 return nil, fmt.Errorf("%w: no merge base for %s...%s", core.ErrBadRef, spec.Base, spec.Head)
70 0 }
71 3 oldCommit = bases[0]
72 }
73
74 7 oldTree, err := oldCommit.Tree()
75 7 if err != nil {
76 0 return nil, err
77 0 }
78 7 newTree, err := headCommit.Tree()
79 7 if err != nil {
80 0 return nil, err
81 0 }
82 7 return object.DiffTreeWithOptions(ctx, oldTree, newTree, diffOpts)
83 }
84
85 // Diff renders the unified diff for a compare spec, capped at the in-page byte
86 // limit (Truncated set and the text cut at a file boundary on overflow).
87 5 func (r *Repo) Diff(ctx context.Context, spec core.CompareSpec) (*Patch, error) {
88 5 return r.diffPatch(ctx, spec, r.diffLimit(pageDiffLimit))
89 5 }
90
91 // RawDiff is Diff with the larger .patch-download byte cap.
92 0 func (r *Repo) RawDiff(ctx context.Context, spec core.CompareSpec) (*Patch, error) {
93 0 return r.diffPatch(ctx, spec, r.diffLimit(rawDiffLimit))
94 0 }
95
96 5 func (r *Repo) diffPatch(ctx context.Context, spec core.CompareSpec, limit int64) (*Patch, error) {
97 5 ctx, cancel := r.withTimeout(ctx)
98 5 defer cancel()
99 5
100 5 changes, err := r.specChanges(ctx, spec)
101 5 if err != nil {
102 0 return nil, err
103 0 }
104 5 return renderPatch(ctx, changes, limit)
105 }
106
107 // DiffStat returns the per-file change list for a compare spec.
108 2 func (r *Repo) DiffStat(ctx context.Context, spec core.CompareSpec) ([]FileChange, error) {
109 2 ctx, cancel := r.withTimeout(ctx)
110 2 defer cancel()
111 2
112 2 changes, err := r.specChanges(ctx, spec)
113 2 if err != nil {
114 0 return nil, err
115 0 }
116 2 patch, err := changes.PatchContext(ctx)
117 2 if err != nil {
118 0 return nil, err
119 0 }
120 2 return mapFilePatches(patch.FilePatches()), nil
121 }
122
123 // MergeBase returns the SHA of the best common ancestor of two revisions.
124 1 func (r *Repo) MergeBase(ctx context.Context, a, b string) (string, error) {
125 1 _, cancel := r.withTimeout(ctx)
126 1 defer cancel()
127 1
128 1 ca, err := r.resolveCommit(a)
129 1 if err != nil {
130 0 return "", err
131 0 }
132 1 cb, err := r.resolveCommit(b)
133 1 if err != nil {
134 0 return "", err
135 0 }
136 1 bases, err := ca.MergeBase(cb)
137 1 if err != nil {
138 0 return "", err
139 0 }
140 1 if len(bases) == 0 {
141 0 return "", fmt.Errorf("%w: no merge base for %s and %s", core.ErrNotFound, a, b)
142 0 }
143 1 return bases[0].Hash.String(), nil
144 }
145
146 // CommitPatch renders a single commit as a diff plus its file list and
147 // metadata. A non-merge commit is diffed against its parent (a root commit
148 // against the empty tree). A merge commit is diffed against its first parent —
149 // the conventional, reviewable single-parent view; callers detect the merge via
150 // len(CommitInfo.ParentSHAs) > 1 to show a banner.
151 3 func (r *Repo) CommitPatch(ctx context.Context, rev string) (*Patch, []FileChange, *CommitInfo, error) {
152 3 ctx, cancel := r.withTimeout(ctx)
153 3 defer cancel()
154 3
155 3 c, err := r.resolveCommit(rev)
156 3 if err != nil {
157 0 return nil, nil, nil, err
158 0 }
159
160 3 var oldTree *object.Tree
161 3 if c.NumParents() > 0 {
162 2 parent, err := c.Parent(0)
163 2 if err != nil {
164 0 return nil, nil, nil, err
165 0 }
166 2 oldTree, err = parent.Tree()
167 2 if err != nil {
168 0 return nil, nil, nil, err
169 0 }
170 }
171 3 newTree, err := c.Tree()
172 3 if err != nil {
173 0 return nil, nil, nil, err
174 0 }
175
176 3 changes, err := object.DiffTreeWithOptions(ctx, oldTree, newTree, diffOpts)
177 3 if err != nil {
178 0 return nil, nil, nil, err
179 0 }
180 3 patch, err := changes.PatchContext(ctx)
181 3 if err != nil {
182 0 return nil, nil, nil, err
183 0 }
184 3 text, truncated := cutPatch(patch.String(), r.diffLimit(pageDiffLimit))
185 3 files := mapFilePatches(patch.FilePatches())
186 3 return &Patch{Text: text, Truncated: truncated}, files, commitInfo(c), nil
187 }
188
189 // renderPatch turns tree changes into a size-capped Patch.
190 5 func renderPatch(ctx context.Context, changes object.Changes, limit int64) (*Patch, error) {
191 5 patch, err := changes.PatchContext(ctx)
192 5 if err != nil {
193 0 return nil, err
194 0 }
195 5 text, truncated := cutPatch(patch.String(), limit)
196 5 return &Patch{Text: text, Truncated: truncated}, nil
197 }
198
199 // mapFilePatches converts go-git file patches into the package's FileChange
200 // list. It is a pure function of the FilePatch slice so it can be unit-tested
201 // with synthetic patches. Status is derived from the from/to file pair; line
202 // counts are tallied from the chunks (skipped for binary files).
203 12 func mapFilePatches(fps []diff.FilePatch) []FileChange {
204 12 var out []FileChange
205 15 for _, fp := range fps {
206 15 from, to := fp.Files()
207 15 if from == nil && to == nil {
208 1 continue
209 }
210 14 fc := FileChange{Binary: fp.IsBinary()}
211 14 switch {
212 6 case from == nil:
213 6 fc.Status = "A"
214 6 fc.Path = to.Path()
215 1 case to == nil:
216 1 fc.Status = "D"
217 1 fc.Path = from.Path()
218 3 case from.Path() != to.Path():
219 3 fc.Status = "R"
220 3 fc.OldPath = from.Path()
221 3 fc.Path = to.Path()
222 4 default:
223 4 fc.Status = "M"
224 4 fc.Path = from.Path()
225 }
226 14 if !fc.Binary {
227 19 for _, ch := range fp.Chunks() {
228 19 s := ch.Content()
229 19 if s == "" {
230 0 continue
231 }
232 19 n := countLines(s)
233 19 switch ch.Type() {
234 8 case diff.Add:
235 8 fc.Additions += n
236 5 case diff.Delete:
237 5 fc.Deletions += n
238 }
239 }
240 }
241 14 out = append(out, fc)
242 }
243 12 return out
244 }
245
246 // countLines counts the lines in a chunk's content, counting a final
247 // unterminated line as one line (matching git's stat behaviour).
248 19 func countLines(s string) int {
249 19 n := 0
250 142 for i := 0; i < len(s); i++ {
251 142 if s[i] == '\n' {
252 27 n++
253 27 }
254 }
255 19 if len(s) > 0 && s[len(s)-1] != '\n' {
256 1 n++
257 1 }
258 19 return n
259 }