internal/gitutil/merge.go

f8b976a97290a20d552056a999511f5d27d8e8ec
gitbay/internal/gitutil/merge.go history · blame · raw

401 lines · 15255 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	// Hash the new blob.
215	hb := exec.Command(toolpath.Look("git"), "-C", dir, "hash-object", "-w", "--stdin")
216	hb.Stdin = strings.NewReader(string(content))
217	out, err := hb.Output()
218	if err != nil {
219		return "", fmt.Errorf("hash-object: %w", err)
220	}
221	blob := strings.TrimSpace(string(out))
222
223	// Stage the parent tree in a temporary index, splice the blob in, and
224	// write the new tree.
225	idx, err := os.CreateTemp("", "gitbay-index-*")
226	if err != nil {
227		return "", err
228	}
229	idx.Close()
230	defer os.Remove(idx.Name())
231	env := append(os.Environ(), "GIT_INDEX_FILE="+idx.Name())
232
233	tree0 := parent + "^{tree}"
234	if parent == "" {
235		tree0 = "--empty"
236	}
237	rt := exec.Command(toolpath.Look("git"), "-C", dir, "read-tree", tree0)
238	rt.Env = env
239	if out, err := rt.CombinedOutput(); err != nil {
240		return "", fmt.Errorf("read-tree: %v\n%s", err, out)
241	}
242	ui := exec.Command(toolpath.Look("git"), "-C", dir, "update-index", "--add", "--cacheinfo", "100644,"+blob+","+path)
243	ui.Env = env
244	if out, err := ui.CombinedOutput(); err != nil {
245		return "", fmt.Errorf("update-index: %v\n%s", err, out)
246	}
247	wt := exec.Command(toolpath.Look("git"), "-C", dir, "write-tree")
248	wt.Env = env
249	out, err = wt.Output()
250	if err != nil {
251		return "", fmt.Errorf("write-tree: %w", err)
252	}
253	tree := strings.TrimSpace(string(out))
254
255	var parents []string
256	if parent != "" {
257		parents = []string{parent}
258	}
259	sha, err := CommitTree(dir, tree, parents, name, email, message)
260	if err != nil {
261		return "", err
262	}
263	if err := UpdateRefCAS(dir, branchRef, sha, parent); err != nil {
264		return "", fmt.Errorf("branch moved during edit; reload and retry: %w", err)
265	}
266	return sha, nil
267}
268
269// isEmptyRepo reports whether dir has no refs at all — a repository
270// created but never pushed to.
271func isEmptyRepo(dir string) bool {
272	out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "-n1", "--all").Output()
273	return err == nil && strings.TrimSpace(string(out)) == ""
274}
275
276// CommitParents returns the parent SHAs of a commit.
277func CommitParents(dir, sha string) ([]string, error) {
278	out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "--parents", "-n1", "--end-of-options", sha).Output()
279	if err != nil {
280		return nil, fmt.Errorf("rev-list --parents %s: %w", sha, err)
281	}
282	fields := strings.Fields(string(out))
283	if len(fields) < 1 {
284		return nil, fmt.Errorf("no output for %s", sha)
285	}
286	return fields[1:], nil
287}
288
289// AuthorIdent returns a commit's author name, email, and ISO date.
290func AuthorIdent(dir, sha string) (name, email, date string, err error) {
291	out, err := exec.Command(toolpath.Look("git"), "-C", dir, "log", "-1", "--format=%an%x1f%ae%x1f%aI", "--end-of-options", sha).Output()
292	if err != nil {
293		return "", "", "", fmt.Errorf("log %s: %w", sha, err)
294	}
295	parts := strings.SplitN(strings.TrimSpace(string(out)), "\x1f", 3)
296	if len(parts) != 3 {
297		return "", "", "", fmt.Errorf("bad ident for %s", sha)
298	}
299	return parts[0], parts[1], parts[2], nil
300}
301
302// CommitMessage returns a commit's full message.
303func CommitMessage(dir, sha string) (string, error) {
304	out, err := exec.Command(toolpath.Look("git"), "-C", dir, "log", "-1", "--format=%B", "--end-of-options", sha).Output()
305	if err != nil {
306		return "", fmt.Errorf("log %s: %w", sha, err)
307	}
308	return strings.TrimRight(string(out), "\n"), nil
309}
310
311// MergeTreeOnto replays commit's changes (relative to base) onto onto,
312// returning the resulting tree. conflict=true when it cannot apply cleanly.
313func MergeTreeOnto(dir, base, onto, commit string) (tree string, conflict bool, err error) {
314	cmd := exec.Command(toolpath.Look("git"), "-C", dir, "merge-tree", "--write-tree", "--merge-base="+base, "--end-of-options", onto, commit)
315	out, runErr := cmd.Output()
316	tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
317	if runErr != nil {
318		if ee, ok := runErr.(*exec.ExitError); ok && ee.ExitCode() == 1 {
319			return "", true, nil
320		}
321		return "", false, fmt.Errorf("merge-tree: %w", runErr)
322	}
323	return tree, false, nil
324}
325
326// CommitTreeIdent creates a commit with distinct author and committer
327// identities. Empty authorDate means now.
328func CommitTreeIdent(dir, tree string, parents []string,
329	authorName, authorEmail, authorDate, committerName, committerEmail, message string) (string, error) {
330	args := []string{"-C", dir, "commit-tree", tree, "-m", message}
331	for _, p := range parents {
332		args = append(args, "-p", p)
333	}
334	cmd := exec.Command(toolpath.Look("git"), args...)
335	env := append(os.Environ(),
336		"GIT_AUTHOR_NAME="+authorName, "GIT_AUTHOR_EMAIL="+authorEmail,
337		"GIT_COMMITTER_NAME="+committerName, "GIT_COMMITTER_EMAIL="+committerEmail,
338	)
339	if authorDate != "" {
340		env = append(env, "GIT_AUTHOR_DATE="+authorDate)
341	}
342	cmd.Env = env
343	out, err := cmd.Output()
344	if err != nil {
345		return "", fmt.Errorf("commit-tree: %w", err)
346	}
347	return strings.TrimSpace(string(out)), nil
348}
349
350// ResolveTree returns the tree id of a commit.
351func ResolveTree(dir, sha string) (string, error) {
352	out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-parse", "--verify", "--end-of-options", sha+"^{tree}").Output()
353	if err != nil {
354		return "", fmt.Errorf("rev-parse %s^{tree}: %w", sha, err)
355	}
356	return strings.TrimSpace(string(out)), nil
357}
358
359// DiffFiles lists the paths changed between old and new.
360func DiffFiles(dir, old, new string) ([]string, error) {
361	out, err := exec.Command(toolpath.Look("git"), "-C", dir, "diff", "--name-only", "--end-of-options", old, new).Output()
362	if err != nil {
363		return nil, fmt.Errorf("diff --name-only: %w", err)
364	}
365	var files []string
366	for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
367		if l != "" {
368			files = append(files, l)
369		}
370	}
371	return files, nil
372}
373
374// RangeDiff compares two revisions of the same work: what the commits
375// between oldBase and oldHead became between newBase and newHead. This is
376// what answers "what changed since I reviewed this", which a plain diff
377// of the two heads cannot — that shows the whole branch again, rebases
378// and all.
379//
380// Each side carries its own base, because the target moves: comparing
381// both revisions against today's base would attribute every commit that
382// landed on the target in between to the author of this merge request.
383//
384// --creation-factor is raised from git's default of 60. That default is
385// tuned for comparing two independently developed patch series, where
386// refusing to pair is the safe answer. Here the two sides are known to be
387// revisions of one branch, and the commonest revision of all — a commit
388// that adds a file, with one line inside it changed — is not paired at
389// 60: git reports the commit as deleted and a different one added, which
390// tells a reviewer nothing. It pairs at 80, and two genuinely unrelated
391// commits are still left unpaired there; both measured.
392func RangeDiff(dir, oldBase, oldHead, newBase, newHead string, limit int64) (patch string, truncated bool, err error) {
393	cmd := exec.Command(toolpath.Look("git"), "-C", dir, "range-diff", "--creation-factor=80", "--end-of-options",
394		oldBase+".."+oldHead, newBase+".."+newHead)
395	out, err := cmd.Output()
396	if err != nil {
397		return "", false, fmt.Errorf("range-diff: %w", err)
398	}
399	out, truncated = cutAtLine(out, limit)
400	return string(out), truncated, nil
401}