internal/gitutil/merge.go
409 lines · 15733 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// RefExists reports whether ref is present, whatever it points at. Unlike
49// ResolveRef it does not need the object to exist, so a ref left dangling
50// by an interrupted prune still reads as present and gets deleted.
51func RefExists(dir, ref string) bool {
52 return exec.Command(toolpath.Look("git"), "-C", dir, "show-ref", "--verify", "--quiet", ref).Run() == nil
53}
54
55// PruneNow repacks the repository and drops every unreachable object at
56// once, instead of after git's two-week grace. For when a ref was deleted
57// so that what it pointed at stops being fetchable by sha. Without the
58// grace, a push whose objects have left quarantine but whose ref is not
59// yet written can lose them; the window is milliseconds, and the one
60// caller is an explicit admin command, not a timer.
61func PruneNow(dir string) error {
62 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "gc", "--quiet", "--prune=now")
63 if out, err := cmd.CombinedOutput(); err != nil {
64 return fmt.Errorf("gc --prune=now: %v\n%s", err, out)
65 }
66 return nil
67}
68
69// FsckConnectivity checks that every object reachable from the
70// repository's refs is present, without reading blob contents. A backup
71// verify runs it on each archived repository (#259). dir is the git
72// directory itself; it is passed as --git-dir so that a directory that is
73// not a repository fails instead of git checking one enclosing it.
74func FsckConnectivity(dir string) error {
75 cmd := exec.Command(toolpath.Look("git"), "--git-dir="+dir, "fsck", "--connectivity-only", "--no-progress", "--no-dangling")
76 if out, err := cmd.CombinedOutput(); err != nil {
77 return fmt.Errorf("fsck --connectivity-only: %v\n%s", err, out)
78 }
79 return nil
80}
81
82// RevListRange returns commits in old..new, newest first.
83func RevListRange(dir, old, new string) ([]string, error) {
84 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "--end-of-options", new, "^"+old)
85 out, err := cmd.Output()
86 if err != nil {
87 return nil, fmt.Errorf("rev-list %s..%s: %w", old, new, err)
88 }
89 var shas []string
90 for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
91 if l != "" {
92 shas = append(shas, l)
93 }
94 }
95 return shas, nil
96}
97
98// MergeTree performs a real merge of ours and theirs, returning the merged
99// tree id. conflict=true means the merge cannot be done automatically.
100func MergeTree(dir, ours, theirs string) (tree string, conflict bool, err error) {
101 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "merge-tree", "--write-tree", "--end-of-options", ours, theirs)
102 out, runErr := cmd.Output()
103 tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
104 if runErr != nil {
105 if ee, ok := runErr.(*exec.ExitError); ok && ee.ExitCode() == 1 {
106 return "", true, nil // conflicted merge
107 }
108 return "", false, fmt.Errorf("merge-tree: %w", runErr)
109 }
110 return tree, false, nil
111}
112
113// CommitTree creates a merge commit with the given parents, authored and
114// committed by the merging user. There is no server signing key by design.
115func CommitTree(dir, tree string, parents []string, name, email, message string) (string, error) {
116 args := []string{"-C", dir, "commit-tree", tree, "-m", message}
117 for _, p := range parents {
118 args = append(args, "-p", p)
119 }
120 cmd := exec.Command(toolpath.Look("git"), args...)
121 cmd.Env = append(os.Environ(),
122 "GIT_AUTHOR_NAME="+name, "GIT_AUTHOR_EMAIL="+email,
123 "GIT_COMMITTER_NAME="+name, "GIT_COMMITTER_EMAIL="+email,
124 )
125 out, err := cmd.Output()
126 if err != nil {
127 return "", fmt.Errorf("commit-tree: %w", err)
128 }
129 return strings.TrimSpace(string(out)), nil
130}
131
132// Diff returns the patch for old..new (three-dot semantics are the caller's
133// job: pass the merge base as old), cut at limit bytes on a line boundary;
134// truncated says whether it was cut, so the caller can say so instead of
135// rendering a hunk that ends mid-line (#117).
136func Diff(dir, old, new string, limit int64) (patch string, truncated bool, err error) {
137 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "diff", "--stat", "--patch", "--end-of-options", old, new)
138 out, err := cmd.Output()
139 if err != nil {
140 return "", false, fmt.Errorf("diff: %w", err)
141 }
142 out, truncated = cutAtLine(out, limit)
143 return string(out), truncated, nil
144}
145
146// cutAtLine keeps at most limit bytes, ending on the last newline before
147// the limit.
148func cutAtLine(out []byte, limit int64) ([]byte, bool) {
149 if int64(len(out)) <= limit {
150 return out, false
151 }
152 cut := out[:limit]
153 if i := bytes.LastIndexByte(cut, '\n'); i >= 0 {
154 cut = cut[:i+1]
155 }
156 return cut, true
157}
158
159// PatchID identifies the change between old and new independently of the
160// commits carrying it: git patch-id --stable over the whole-range diff.
161// Two revisions with the same PatchID propose the same change, whatever
162// was rebased underneath. "" when the range has no diff.
163func PatchID(dir, old, new string) (string, error) {
164 diff := exec.Command(toolpath.Look("git"), "-C", dir, "diff", "--end-of-options", old, new)
165 pid := exec.Command(toolpath.Look("git"), "-C", dir, "patch-id", "--stable")
166 pipe, err := diff.StdoutPipe()
167 if err != nil {
168 return "", err
169 }
170 pid.Stdin = pipe
171 if err := diff.Start(); err != nil {
172 return "", fmt.Errorf("diff: %w", err)
173 }
174 out, err := pid.Output()
175 if werr := diff.Wait(); werr != nil {
176 return "", fmt.Errorf("diff: %w", werr)
177 }
178 if err != nil {
179 return "", fmt.Errorf("patch-id: %w", err)
180 }
181 fields := strings.Fields(string(out))
182 if len(fields) == 0 {
183 return "", nil
184 }
185 return fields[0], nil
186}
187
188// MergeBase returns the best common ancestor, or an error if none exists.
189func MergeBase(dir, a, b string) (string, error) {
190 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "merge-base", "--end-of-options", a, b)
191 out, err := cmd.Output()
192 if err != nil {
193 return "", fmt.Errorf("no common history between %s and %s", a, b)
194 }
195 return strings.TrimSpace(string(out)), nil
196}
197
198// CommitFileChange writes content at path on branch as a new commit and
199// advances the branch with compare-and-swap. Used by web edits; hooks do not
200// run, so callers enforce policy themselves.
201func CommitFileChange(dir, branch, path string, content []byte, name, email, message string) (string, error) {
202 branchRef := "refs/heads/" + branch
203 parent, err := ResolveRef(dir, branchRef)
204 if err != nil {
205 // An unborn branch is a root commit only in a repository with no
206 // refs at all. Anywhere else an unresolvable branch is a typo, and
207 // starting an orphan branch for it would be worse than refusing.
208 if !isEmptyRepo(dir) {
209 return "", fmt.Errorf("branch %s: %w", branch, err)
210 }
211 parent = ""
212 }
213
214 sha, err := CommitWithFile(dir, parent, path, "100644", content, name, email, message)
215 if err != nil {
216 return "", err
217 }
218 if err := UpdateRefCAS(dir, branchRef, sha, parent); err != nil {
219 return "", fmt.Errorf("branch moved during edit; reload and retry: %w", err)
220 }
221 return sha, nil
222}
223
224// CommitWithFile writes a commit on parent ("" for a root commit) whose
225// tree is parent's with content at path, as a file of mode (100644 or
226// 100755), authored and committed as name <email>. No ref moves: the
227// caller updates one, and enforces policy, since no hook runs.
228func CommitWithFile(dir, parent, path, mode string, content []byte, name, email, message string) (string, error) {
229 // Hash the new blob.
230 hb := exec.Command(toolpath.Look("git"), "-C", dir, "hash-object", "-w", "--stdin")
231 hb.Stdin = strings.NewReader(string(content))
232 out, err := hb.Output()
233 if err != nil {
234 return "", fmt.Errorf("hash-object: %w", err)
235 }
236 blob := strings.TrimSpace(string(out))
237
238 // Stage the parent tree in a temporary index, splice the blob in, and
239 // write the new tree.
240 idx, err := os.CreateTemp("", "gitbay-index-*")
241 if err != nil {
242 return "", err
243 }
244 idx.Close()
245 defer os.Remove(idx.Name())
246 env := append(os.Environ(), "GIT_INDEX_FILE="+idx.Name())
247
248 tree0 := parent + "^{tree}"
249 if parent == "" {
250 tree0 = "--empty"
251 }
252 rt := exec.Command(toolpath.Look("git"), "-C", dir, "read-tree", tree0)
253 rt.Env = env
254 if out, err := rt.CombinedOutput(); err != nil {
255 return "", fmt.Errorf("read-tree: %v\n%s", err, out)
256 }
257 ui := exec.Command(toolpath.Look("git"), "-C", dir, "update-index", "--add", "--cacheinfo", mode+","+blob+","+path)
258 ui.Env = env
259 if out, err := ui.CombinedOutput(); err != nil {
260 return "", fmt.Errorf("update-index: %v\n%s", err, out)
261 }
262 wt := exec.Command(toolpath.Look("git"), "-C", dir, "write-tree")
263 wt.Env = env
264 out, err = wt.Output()
265 if err != nil {
266 return "", fmt.Errorf("write-tree: %w", err)
267 }
268 tree := strings.TrimSpace(string(out))
269
270 var parents []string
271 if parent != "" {
272 parents = []string{parent}
273 }
274 return CommitTree(dir, tree, parents, name, email, message)
275}
276
277// isEmptyRepo reports whether dir has no refs at all — a repository
278// created but never pushed to.
279func isEmptyRepo(dir string) bool {
280 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "-n1", "--all").Output()
281 return err == nil && strings.TrimSpace(string(out)) == ""
282}
283
284// CommitParents returns the parent SHAs of a commit.
285func CommitParents(dir, sha string) ([]string, error) {
286 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "--parents", "-n1", "--end-of-options", sha).Output()
287 if err != nil {
288 return nil, fmt.Errorf("rev-list --parents %s: %w", sha, err)
289 }
290 fields := strings.Fields(string(out))
291 if len(fields) < 1 {
292 return nil, fmt.Errorf("no output for %s", sha)
293 }
294 return fields[1:], nil
295}
296
297// AuthorIdent returns a commit's author name, email, and ISO date.
298func AuthorIdent(dir, sha string) (name, email, date string, err error) {
299 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "log", "-1", "--format=%an%x1f%ae%x1f%aI", "--end-of-options", sha).Output()
300 if err != nil {
301 return "", "", "", fmt.Errorf("log %s: %w", sha, err)
302 }
303 parts := strings.SplitN(strings.TrimSpace(string(out)), "\x1f", 3)
304 if len(parts) != 3 {
305 return "", "", "", fmt.Errorf("bad ident for %s", sha)
306 }
307 return parts[0], parts[1], parts[2], nil
308}
309
310// CommitMessage returns a commit's full message.
311func CommitMessage(dir, sha string) (string, error) {
312 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "log", "-1", "--format=%B", "--end-of-options", sha).Output()
313 if err != nil {
314 return "", fmt.Errorf("log %s: %w", sha, err)
315 }
316 return strings.TrimRight(string(out), "\n"), nil
317}
318
319// MergeTreeOnto replays commit's changes (relative to base) onto onto,
320// returning the resulting tree. conflict=true when it cannot apply cleanly.
321func MergeTreeOnto(dir, base, onto, commit string) (tree string, conflict bool, err error) {
322 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "merge-tree", "--write-tree", "--merge-base="+base, "--end-of-options", onto, commit)
323 out, runErr := cmd.Output()
324 tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
325 if runErr != nil {
326 if ee, ok := runErr.(*exec.ExitError); ok && ee.ExitCode() == 1 {
327 return "", true, nil
328 }
329 return "", false, fmt.Errorf("merge-tree: %w", runErr)
330 }
331 return tree, false, nil
332}
333
334// CommitTreeIdent creates a commit with distinct author and committer
335// identities. Empty authorDate means now.
336func CommitTreeIdent(dir, tree string, parents []string,
337 authorName, authorEmail, authorDate, committerName, committerEmail, message string) (string, error) {
338 args := []string{"-C", dir, "commit-tree", tree, "-m", message}
339 for _, p := range parents {
340 args = append(args, "-p", p)
341 }
342 cmd := exec.Command(toolpath.Look("git"), args...)
343 env := append(os.Environ(),
344 "GIT_AUTHOR_NAME="+authorName, "GIT_AUTHOR_EMAIL="+authorEmail,
345 "GIT_COMMITTER_NAME="+committerName, "GIT_COMMITTER_EMAIL="+committerEmail,
346 )
347 if authorDate != "" {
348 env = append(env, "GIT_AUTHOR_DATE="+authorDate)
349 }
350 cmd.Env = env
351 out, err := cmd.Output()
352 if err != nil {
353 return "", fmt.Errorf("commit-tree: %w", err)
354 }
355 return strings.TrimSpace(string(out)), nil
356}
357
358// ResolveTree returns the tree id of a commit.
359func ResolveTree(dir, sha string) (string, error) {
360 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-parse", "--verify", "--end-of-options", sha+"^{tree}").Output()
361 if err != nil {
362 return "", fmt.Errorf("rev-parse %s^{tree}: %w", sha, err)
363 }
364 return strings.TrimSpace(string(out)), nil
365}
366
367// DiffFiles lists the paths changed between old and new.
368func DiffFiles(dir, old, new string) ([]string, error) {
369 out, err := exec.Command(toolpath.Look("git"), "-C", dir, "diff", "--name-only", "--end-of-options", old, new).Output()
370 if err != nil {
371 return nil, fmt.Errorf("diff --name-only: %w", err)
372 }
373 var files []string
374 for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
375 if l != "" {
376 files = append(files, l)
377 }
378 }
379 return files, nil
380}
381
382// RangeDiff compares two revisions of the same work: what the commits
383// between oldBase and oldHead became between newBase and newHead. This is
384// what answers "what changed since I reviewed this", which a plain diff
385// of the two heads cannot — that shows the whole branch again, rebases
386// and all.
387//
388// Each side carries its own base, because the target moves: comparing
389// both revisions against today's base would attribute every commit that
390// landed on the target in between to the author of this merge request.
391//
392// --creation-factor is raised from git's default of 60. That default is
393// tuned for comparing two independently developed patch series, where
394// refusing to pair is the safe answer. Here the two sides are known to be
395// revisions of one branch, and the commonest revision of all — a commit
396// that adds a file, with one line inside it changed — is not paired at
397// 60: git reports the commit as deleted and a different one added, which
398// tells a reviewer nothing. It pairs at 80, and two genuinely unrelated
399// commits are still left unpaired there; both measured.
400func RangeDiff(dir, oldBase, oldHead, newBase, newHead string, limit int64) (patch string, truncated bool, err error) {
401 cmd := exec.Command(toolpath.Look("git"), "-C", dir, "range-diff", "--creation-factor=80", "--end-of-options",
402 oldBase+".."+oldHead, newBase+".."+newHead)
403 out, err := cmd.Output()
404 if err != nil {
405 return "", false, fmt.Errorf("range-diff: %w", err)
406 }
407 out, truncated = cutAtLine(out, limit)
408 return string(out), truncated, nil
409}