internal/gitutil/merge.go
346 lines · 12904 bytes
1package gitutil
2
3import (
4 "bytes"
5 "fmt"
6 "os"
7 "os/exec"
8 "strings"
9
10 "gitbay.org/gitbay/internal/toolpath"
11)
12
13// FetchInto copies srcRef from srcDir into dstDir as dstRef, forcing the
14// update. Objects are copied, not shared — the destination owns everything
15// afterward, which is what keeps MRs alive when their fork is deleted.
16func FetchInto(dstDir, srcDir, srcRef, dstRef string) error {
17 cmd := exec.Command(toolpath.Look("git"), "-C", dstDir, "fetch", "--quiet", "--no-write-fetch-head",
18 srcDir, "+"+srcRef+":"+dstRef)
19 if out, err := cmd.CombinedOutput(); err != nil {
20 return fmt.Errorf("fetch %s from %s: %v\n%s", srcRef, srcDir, err, out)
21 }
22 return nil
23}
24
25// UpdateRefCAS points ref at newSHA only if it currently points at oldSHA
26// (empty oldSHA = must not exist). This is the compare-and-swap that makes
27// merges safe against concurrent pushes.
28func UpdateRefCAS(dir, ref, newSHA, oldSHA string) error {
29 args := []string{"-C", dir, "update-ref", ref, newSHA}
30 if oldSHA != "" {
31 args = append(args, oldSHA)
32 }
33 cmd := exec.Command(toolpath.Look("git"), args...)
34 if out, err := cmd.CombinedOutput(); err != nil {
35 return fmt.Errorf("update-ref %s: %v\n%s", ref, err, out)
36 }
37 return nil
38}
39
40func DeleteRef(dir, ref string) error {
41 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "update-ref", "-d", ref)
42 if out, err := cmd.CombinedOutput(); err != nil {
43 return fmt.Errorf("delete-ref %s: %v\n%s", ref, err, out)
44 }
45 return nil
46}
47
48// RevListRange returns commits in old..new, newest first.
49func RevListRange(dir, old, new string) ([]string, error) {
50 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "--end-of-options", new, "^"+old)
51 out, err := cmd.Output()
52 if err != nil {
53 return nil, fmt.Errorf("rev-list %s..%s: %w", old, new, err)
54 }
55 var shas []string
56 for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
57 if l != "" {
58 shas = append(shas, l)
59 }
60 }
61 return shas, nil
62}
63
64// MergeTree performs a real merge of ours and theirs, returning the merged
65// tree id. conflict=true means the merge cannot be done automatically.
66func MergeTree(dir, ours, theirs string) (tree string, conflict bool, err error) {
67 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "merge-tree", "--write-tree", "--end-of-options", ours, theirs)
68 out, runErr := cmd.Output()
69 tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
70 if runErr != nil {
71 if ee, ok := runErr.(*exec.ExitError); ok && ee.ExitCode() == 1 {
72 return "", true, nil // conflicted merge
73 }
74 return "", false, fmt.Errorf("merge-tree: %w", runErr)
75 }
76 return tree, false, nil
77}
78
79// CommitTree creates a merge commit with the given parents, authored and
80// committed by the merging user. There is no server signing key by design.
81func CommitTree(dir, tree string, parents []string, name, email, message string) (string, error) {
82 args := []string{"-C", dir, "commit-tree", tree, "-m", message}
83 for _, p := range parents {
84 args = append(args, "-p", p)
85 }
86 cmd := exec.Command(toolpath.Look("git"), args...)
87 cmd.Env = append(os.Environ(),
88 "GIT_AUTHOR_NAME="+name, "GIT_AUTHOR_EMAIL="+email,
89 "GIT_COMMITTER_NAME="+name, "GIT_COMMITTER_EMAIL="+email,
90 )
91 out, err := cmd.Output()
92 if err != nil {
93 return "", fmt.Errorf("commit-tree: %w", err)
94 }
95 return strings.TrimSpace(string(out)), nil
96}
97
98// Diff returns the patch for old..new (three-dot semantics are the caller's
99// job: pass the merge base as old), cut at limit bytes on a line boundary;
100// truncated says whether it was cut, so the caller can say so instead of
101// rendering a hunk that ends mid-line (#117).
102func Diff(dir, old, new string, limit int64) (patch string, truncated bool, err error) {
103 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "diff", "--stat", "--patch", "--end-of-options", old, new)
104 out, err := cmd.Output()
105 if err != nil {
106 return "", false, fmt.Errorf("diff: %w", err)
107 }
108 out, truncated = cutAtLine(out, limit)
109 return string(out), truncated, nil
110}
111
112// cutAtLine keeps at most limit bytes, ending on the last newline before
113// the limit.
114func cutAtLine(out []byte, limit int64) ([]byte, bool) {
115 if int64(len(out)) <= limit {
116 return out, false
117 }
118 cut := out[:limit]
119 if i := bytes.LastIndexByte(cut, '\n'); i >= 0 {
120 cut = cut[:i+1]
121 }
122 return cut, true
123}
124
125// PatchID identifies the change between old and new independently of the
126// commits carrying it: git patch-id --stable over the whole-range diff.
127// Two revisions with the same PatchID propose the same change, whatever
128// was rebased underneath. "" when the range has no diff.
129func PatchID(dir, old, new string) (string, error) {
130 diff := exec.Command(toolpath.Look("git"), "-C", dir, "diff", "--end-of-options", old, new)
131 pid := exec.Command(toolpath.Look("git"), "-C", dir, "patch-id", "--stable")
132 pipe, err := diff.StdoutPipe()
133 if err != nil {
134 return "", err
135 }
136 pid.Stdin = pipe
137 if err := diff.Start(); err != nil {
138 return "", fmt.Errorf("diff: %w", err)
139 }
140 out, err := pid.Output()
141 if werr := diff.Wait(); werr != nil {
142 return "", fmt.Errorf("diff: %w", werr)
143 }
144 if err != nil {
145 return "", fmt.Errorf("patch-id: %w", err)
146 }
147 fields := strings.Fields(string(out))
148 if len(fields) == 0 {
149 return "", nil
150 }
151 return fields[0], nil
152}
153
154// MergeBase returns the best common ancestor, or an error if none exists.
155func MergeBase(dir, a, b string) (string, error) {
156 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "merge-base", "--end-of-options", a, b)
157 out, err := cmd.Output()
158 if err != nil {
159 return "", fmt.Errorf("no common history between %s and %s", a, b)
160 }
161 return strings.TrimSpace(string(out)), nil
162}
163
164// CommitFileChange writes content at path on branch as a new commit and
165// advances the branch with compare-and-swap. Used by web edits; hooks do not
166// run, so callers enforce policy themselves.
167func CommitFileChange(dir, branch, path string, content []byte, name, email, message string) (string, error) {
168 branchRef := "refs/heads/" + branch
169 parent, err := ResolveRef(dir, branchRef)
170 if err != nil {
171 return "", fmt.Errorf("branch %s: %w", branch, err)
172 }
173
174 // Hash the new blob.
175 hb := exec.Command(toolpath.Look("git"), "-C", dir, "hash-object", "-w", "--stdin")
176 hb.Stdin = strings.NewReader(string(content))
177 out, err := hb.Output()
178 if err != nil {
179 return "", fmt.Errorf("hash-object: %w", err)
180 }
181 blob := strings.TrimSpace(string(out))
182
183 // Stage the parent tree in a temporary index, splice the blob in, and
184 // write the new tree.
185 idx, err := os.CreateTemp("", "gitbay-index-*")
186 if err != nil {
187 return "", err
188 }
189 idx.Close()
190 defer os.Remove(idx.Name())
191 env := append(os.Environ(), "GIT_INDEX_FILE="+idx.Name())
192
193 rt := exec.Command(toolpath.Look("git"), "-C", dir, "read-tree", parent+"^{tree}")
194 rt.Env = env
195 if out, err := rt.CombinedOutput(); err != nil {
196 return "", fmt.Errorf("read-tree: %v\n%s", err, out)
197 }
198 ui := exec.Command(toolpath.Look("git"), "-C", dir, "update-index", "--add", "--cacheinfo", "100644,"+blob+","+path)
199 ui.Env = env
200 if out, err := ui.CombinedOutput(); err != nil {
201 return "", fmt.Errorf("update-index: %v\n%s", err, out)
202 }
203 wt := exec.Command(toolpath.Look("git"), "-C", dir, "write-tree")
204 wt.Env = env
205 out, err = wt.Output()
206 if err != nil {
207 return "", fmt.Errorf("write-tree: %w", err)
208 }
209 tree := strings.TrimSpace(string(out))
210
211 sha, err := CommitTree(dir, tree, []string{parent}, name, email, message)
212 if err != nil {
213 return "", err
214 }
215 if err := UpdateRefCAS(dir, branchRef, sha, parent); err != nil {
216 return "", fmt.Errorf("branch moved during edit; reload and retry: %w", err)
217 }
218 return sha, nil
219}
220
221// CommitParents returns the parent SHAs of a commit.
222func CommitParents(dir, sha string) ([]string, error) {
223 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "--parents", "-n1", "--end-of-options", sha).Output()
224 if err != nil {
225 return nil, fmt.Errorf("rev-list --parents %s: %w", sha, err)
226 }
227 fields := strings.Fields(string(out))
228 if len(fields) < 1 {
229 return nil, fmt.Errorf("no output for %s", sha)
230 }
231 return fields[1:], nil
232}
233
234// AuthorIdent returns a commit's author name, email, and ISO date.
235func AuthorIdent(dir, sha string) (name, email, date string, err error) {
236 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "log", "-1", "--format=%an%x1f%ae%x1f%aI", "--end-of-options", sha).Output()
237 if err != nil {
238 return "", "", "", fmt.Errorf("log %s: %w", sha, err)
239 }
240 parts := strings.SplitN(strings.TrimSpace(string(out)), "\x1f", 3)
241 if len(parts) != 3 {
242 return "", "", "", fmt.Errorf("bad ident for %s", sha)
243 }
244 return parts[0], parts[1], parts[2], nil
245}
246
247// CommitMessage returns a commit's full message.
248func CommitMessage(dir, sha string) (string, error) {
249 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "log", "-1", "--format=%B", "--end-of-options", sha).Output()
250 if err != nil {
251 return "", fmt.Errorf("log %s: %w", sha, err)
252 }
253 return strings.TrimRight(string(out), "\n"), nil
254}
255
256// MergeTreeOnto replays commit's changes (relative to base) onto onto,
257// returning the resulting tree. conflict=true when it cannot apply cleanly.
258func MergeTreeOnto(dir, base, onto, commit string) (tree string, conflict bool, err error) {
259 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "merge-tree", "--write-tree", "--merge-base="+base, "--end-of-options", onto, commit)
260 out, runErr := cmd.Output()
261 tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
262 if runErr != nil {
263 if ee, ok := runErr.(*exec.ExitError); ok && ee.ExitCode() == 1 {
264 return "", true, nil
265 }
266 return "", false, fmt.Errorf("merge-tree: %w", runErr)
267 }
268 return tree, false, nil
269}
270
271// CommitTreeIdent creates a commit with distinct author and committer
272// identities. Empty authorDate means now.
273func CommitTreeIdent(dir, tree string, parents []string,
274 authorName, authorEmail, authorDate, committerName, committerEmail, message string) (string, error) {
275 args := []string{"-C", dir, "commit-tree", tree, "-m", message}
276 for _, p := range parents {
277 args = append(args, "-p", p)
278 }
279 cmd := exec.Command(toolpath.Look("git"), args...)
280 env := append(os.Environ(),
281 "GIT_AUTHOR_NAME="+authorName, "GIT_AUTHOR_EMAIL="+authorEmail,
282 "GIT_COMMITTER_NAME="+committerName, "GIT_COMMITTER_EMAIL="+committerEmail,
283 )
284 if authorDate != "" {
285 env = append(env, "GIT_AUTHOR_DATE="+authorDate)
286 }
287 cmd.Env = env
288 out, err := cmd.Output()
289 if err != nil {
290 return "", fmt.Errorf("commit-tree: %w", err)
291 }
292 return strings.TrimSpace(string(out)), nil
293}
294
295// ResolveTree returns the tree id of a commit.
296func ResolveTree(dir, sha string) (string, error) {
297 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-parse", "--verify", "--end-of-options", sha+"^{tree}").Output()
298 if err != nil {
299 return "", fmt.Errorf("rev-parse %s^{tree}: %w", sha, err)
300 }
301 return strings.TrimSpace(string(out)), nil
302}
303
304// DiffFiles lists the paths changed between old and new.
305func DiffFiles(dir, old, new string) ([]string, error) {
306 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "diff", "--name-only", "--end-of-options", old, new).Output()
307 if err != nil {
308 return nil, fmt.Errorf("diff --name-only: %w", err)
309 }
310 var files []string
311 for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
312 if l != "" {
313 files = append(files, l)
314 }
315 }
316 return files, nil
317}
318
319// RangeDiff compares two revisions of the same work: what the commits
320// between oldBase and oldHead became between newBase and newHead. This is
321// what answers "what changed since I reviewed this", which a plain diff
322// of the two heads cannot — that shows the whole branch again, rebases
323// and all.
324//
325// Each side carries its own base, because the target moves: comparing
326// both revisions against today's base would attribute every commit that
327// landed on the target in between to the author of this merge request.
328//
329// --creation-factor is raised from git's default of 60. That default is
330// tuned for comparing two independently developed patch series, where
331// refusing to pair is the safe answer. Here the two sides are known to be
332// revisions of one branch, and the commonest revision of all — a commit
333// that adds a file, with one line inside it changed — is not paired at
334// 60: git reports the commit as deleted and a different one added, which
335// tells a reviewer nothing. It pairs at 80, and two genuinely unrelated
336// commits are still left unpaired there; both measured.
337func RangeDiff(dir, oldBase, oldHead, newBase, newHead string, limit int64) (patch string, truncated bool, err error) {
338 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "range-diff", "--creation-factor=80", "--end-of-options",
339 oldBase+".."+oldHead, newBase+".."+newHead)
340 out, err := cmd.Output()
341 if err != nil {
342 return "", false, fmt.Errorf("range-diff: %w", err)
343 }
344 out, truncated = cutAtLine(out, limit)
345 return string(out), truncated, nil
346}