~bigbes/sr-ht-compare

ref: 05a6bcb619654504eb8290afde47486d1ec61363 sr-ht-compare/gitx/diff.go -rw-r--r-- 7.0 KiB
05a6bcb6 — bigbes readme: name the ecore packages the web tier draws from 9 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
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
}