Monorepo for Tangled
tangled.org
1package git
2
3import (
4 "bytes"
5 "fmt"
6 "log"
7 "os"
8 "os/exec"
9 "slices"
10 "strings"
11
12 "github.com/bluekeyes/go-gitdiff/gitdiff"
13 "github.com/go-git/go-git/v5/plumbing"
14 "github.com/go-git/go-git/v5/plumbing/object"
15 "tangled.org/core/patchutil"
16 "tangled.org/core/types"
17)
18
19func (g *GitRepo) Diff() (*types.NiceDiff, error) {
20 c, err := g.r.CommitObject(g.h)
21 if err != nil {
22 return nil, fmt.Errorf("commit object: %w", err)
23 }
24
25 patch := &object.Patch{}
26 commitTree, err := c.Tree()
27 parent := &object.Commit{}
28 if err == nil {
29 parentTree := &object.Tree{}
30 if c.NumParents() != 0 {
31 parent, err = c.Parents().Next()
32 if err == nil {
33 parentTree, err = parent.Tree()
34 if err == nil {
35 patch, err = parentTree.Patch(commitTree)
36 if err != nil {
37 return nil, fmt.Errorf("patch: %w", err)
38 }
39 }
40 }
41 } else {
42 patch, err = parentTree.Patch(commitTree)
43 if err != nil {
44 return nil, fmt.Errorf("patch: %w", err)
45 }
46 }
47 }
48
49 diffs, _, err := gitdiff.Parse(strings.NewReader(patch.String()))
50 if err != nil {
51 log.Println(err)
52 }
53
54 nd := types.NiceDiff{}
55 for _, d := range diffs {
56 ndiff := types.Diff{}
57 ndiff.Name.New = d.NewName
58 ndiff.Name.Old = d.OldName
59 ndiff.IsBinary = d.IsBinary
60 ndiff.IsNew = d.IsNew
61 ndiff.IsDelete = d.IsDelete
62 ndiff.IsCopy = d.IsCopy
63 ndiff.IsRename = d.IsRename
64
65 for _, tf := range d.TextFragments {
66 ndiff.TextFragments = append(ndiff.TextFragments, *tf)
67 nd.Stat.Insertions += tf.LinesAdded
68 nd.Stat.Deletions += tf.LinesDeleted
69 }
70
71 nd.Diff = append(nd.Diff, ndiff)
72 }
73
74 nd.Stat.FilesChanged += len(diffs)
75 nd.Commit.FromGoGitCommit(c)
76
77 return &nd, nil
78}
79
80func (g *GitRepo) MergeBase(a, b *object.Commit) (*object.Commit, error) {
81 out, err := g.mergeBase(a.Hash.String(), b.Hash.String())
82 if err != nil {
83 return nil, fmt.Errorf("merge-base %s %s: %w", a.Hash, b.Hash, err)
84 }
85
86 hash := plumbing.NewHash(strings.TrimSpace(string(out)))
87 return g.r.CommitObject(hash)
88}
89
90func (g *GitRepo) DiffTree(commit1, commit2 *object.Commit) (*types.DiffTree, error) {
91 tree1, err := commit1.Tree()
92 if err != nil {
93 return nil, err
94 }
95
96 tree2, err := commit2.Tree()
97 if err != nil {
98 return nil, err
99 }
100
101 diff, err := object.DiffTree(tree1, tree2)
102 if err != nil {
103 return nil, err
104 }
105
106 patch, err := diff.Patch()
107 if err != nil {
108 return nil, err
109 }
110
111 patchStr := patch.String()
112 diffs, _, err := gitdiff.Parse(strings.NewReader(patchStr))
113 if err != nil {
114 return nil, err
115 }
116
117 return &types.DiffTree{
118 Rev1: commit1.Hash.String(),
119 Rev2: commit2.Hash.String(),
120 Patch: patchStr,
121 Diff: diffs,
122 }, nil
123}
124
125// FormatPatch generates a git-format-patch output between two commits,
126// and returns the raw format-patch series, a parsed FormatPatch and an error.
127func (g *GitRepo) formatSinglePatch(commit plumbing.Hash, extraArgs ...string) (string, *types.FormatPatch, error) {
128 var stdout bytes.Buffer
129
130 args := []string{
131 "-C",
132 g.path,
133 "format-patch",
134 "-1",
135 commit.String(),
136 "--stdout",
137 }
138 args = append(args, extraArgs...)
139
140 cmd := exec.Command("git", args...)
141 cmd.Stdout = &stdout
142 cmd.Stderr = os.Stderr
143 err := cmd.Run()
144 if err != nil {
145 return "", nil, err
146 }
147
148 raw := stdout.String()
149 formatPatch, err := patchutil.ExtractPatches(raw)
150 if err != nil {
151 return "", nil, err
152 }
153
154 if len(formatPatch) > 1 {
155 return "", nil, fmt.Errorf("running format-patch on single commit produced more than on patch")
156 }
157
158 return raw, &formatPatch[0], nil
159}
160
161// ChangedFilesBetween returns the list of files changed between oldSha and newSha.
162// If oldSha is the zero hash (initial push), all files in newSha are returned.
163func (g *GitRepo) ChangedFilesBetween(oldSha, newSha string) ([]string, error) {
164 newCommit, err := g.ResolveRevision(newSha)
165 if err != nil {
166 return nil, err
167 }
168
169 if plumbing.NewHash(oldSha) == plumbing.ZeroHash {
170 tree, err := newCommit.Tree()
171 if err != nil {
172 return nil, err
173 }
174 var files []string
175 tree.Files().ForEach(func(f *object.File) error {
176 files = append(files, f.Name)
177 return nil
178 })
179 return files, nil
180 }
181
182 oldCommit, err := g.ResolveRevision(oldSha)
183 if err != nil {
184 return nil, err
185 }
186
187 dt, err := g.DiffTree(oldCommit, newCommit)
188 if err != nil {
189 return nil, err
190 }
191
192 seen := make(map[string]struct{})
193 for _, f := range dt.Diff {
194 if f.OldName != "" {
195 seen[f.OldName] = struct{}{}
196 }
197 if f.NewName != "" {
198 seen[f.NewName] = struct{}{}
199 }
200 }
201
202 files := make([]string, 0, len(seen))
203 for name := range seen {
204 files = append(files, name)
205 }
206 return files, nil
207}
208
209func (g *GitRepo) ResolveRevision(revStr string) (*object.Commit, error) {
210 rev, err := g.r.ResolveRevision(plumbing.Revision(revStr))
211 if err != nil {
212 return nil, fmt.Errorf("resolving revision %s: %w", revStr, err)
213 }
214
215 commit, err := g.r.CommitObject(*rev)
216 if err != nil {
217
218 return nil, fmt.Errorf("getting commit for %s: %w", revStr, err)
219 }
220
221 return commit, nil
222}
223
224func (g *GitRepo) commitsBetween(newCommit, oldCommit *object.Commit) ([]*object.Commit, error) {
225 var commits []*object.Commit
226
227 output, err := g.revList(
228 "--no-merges", // format-patch explicitly prepares only non-merges
229 fmt.Sprintf("%s..%s", oldCommit.Hash.String(), newCommit.Hash.String()),
230 )
231 if err != nil {
232 return nil, fmt.Errorf("revlist: %w", err)
233 }
234
235 lines := strings.Split(strings.TrimSpace(string(output)), "\n")
236 if len(lines) == 1 && lines[0] == "" {
237 return commits, nil
238 }
239
240 for _, item := range lines {
241 obj, err := g.r.CommitObject(plumbing.NewHash(item))
242 if err != nil {
243 continue
244 }
245 commits = append(commits, obj)
246 }
247
248 return commits, nil
249}
250
251func (g *GitRepo) FormatPatch(base, commit2 *object.Commit) (string, []types.FormatPatch, error) {
252 // get list of commits between commit2 and base
253 commits, err := g.commitsBetween(commit2, base)
254 if err != nil {
255 return "", nil, fmt.Errorf("failed to get commits: %w", err)
256 }
257
258 // reverse the list so we start from the oldest one and go up to the most recent one
259 slices.Reverse(commits)
260
261 var allPatchesContent strings.Builder
262 var allPatches []types.FormatPatch
263
264 for _, commit := range commits {
265 changeId := ""
266 if val, ok := commit.ExtraHeaders["change-id"]; ok {
267 changeId = string(val)
268 }
269
270 var additionalArgs []string
271 if changeId != "" {
272 additionalArgs = append(additionalArgs, "--add-header", fmt.Sprintf("Change-Id: %s", changeId))
273 }
274
275 stdout, patch, err := g.formatSinglePatch(commit.Hash, additionalArgs...)
276 if err != nil {
277 return "", nil, fmt.Errorf("failed to format patch for commit %s: %w", commit.Hash.String(), err)
278 }
279
280 allPatchesContent.WriteString(stdout)
281 allPatchesContent.WriteString("\n")
282
283 allPatches = append(allPatches, *patch)
284 }
285
286 return allPatchesContent.String(), allPatches, nil
287}