internal/gitutil/merge.go

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

317 lines · 11950 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// MergeBase returns the best common ancestor, or an error if none exists.
126func MergeBase(dir, a, b string) (string, error) {
127	cmd := exec.Command(toolpath.Look("git"), "-C", dir, "merge-base", "--end-of-options", a, b)
128	out, err := cmd.Output()
129	if err != nil {
130		return "", fmt.Errorf("no common history between %s and %s", a, b)
131	}
132	return strings.TrimSpace(string(out)), nil
133}
134
135// CommitFileChange writes content at path on branch as a new commit and
136// advances the branch with compare-and-swap. Used by web edits; hooks do not
137// run, so callers enforce policy themselves.
138func CommitFileChange(dir, branch, path string, content []byte, name, email, message string) (string, error) {
139	branchRef := "refs/heads/" + branch
140	parent, err := ResolveRef(dir, branchRef)
141	if err != nil {
142		return "", fmt.Errorf("branch %s: %w", branch, err)
143	}
144
145	// Hash the new blob.
146	hb := exec.Command(toolpath.Look("git"), "-C", dir, "hash-object", "-w", "--stdin")
147	hb.Stdin = strings.NewReader(string(content))
148	out, err := hb.Output()
149	if err != nil {
150		return "", fmt.Errorf("hash-object: %w", err)
151	}
152	blob := strings.TrimSpace(string(out))
153
154	// Stage the parent tree in a temporary index, splice the blob in, and
155	// write the new tree.
156	idx, err := os.CreateTemp("", "gitbay-index-*")
157	if err != nil {
158		return "", err
159	}
160	idx.Close()
161	defer os.Remove(idx.Name())
162	env := append(os.Environ(), "GIT_INDEX_FILE="+idx.Name())
163
164	rt := exec.Command(toolpath.Look("git"), "-C", dir, "read-tree", parent+"^{tree}")
165	rt.Env = env
166	if out, err := rt.CombinedOutput(); err != nil {
167		return "", fmt.Errorf("read-tree: %v\n%s", err, out)
168	}
169	ui := exec.Command(toolpath.Look("git"), "-C", dir, "update-index", "--add", "--cacheinfo", "100644,"+blob+","+path)
170	ui.Env = env
171	if out, err := ui.CombinedOutput(); err != nil {
172		return "", fmt.Errorf("update-index: %v\n%s", err, out)
173	}
174	wt := exec.Command(toolpath.Look("git"), "-C", dir, "write-tree")
175	wt.Env = env
176	out, err = wt.Output()
177	if err != nil {
178		return "", fmt.Errorf("write-tree: %w", err)
179	}
180	tree := strings.TrimSpace(string(out))
181
182	sha, err := CommitTree(dir, tree, []string{parent}, name, email, message)
183	if err != nil {
184		return "", err
185	}
186	if err := UpdateRefCAS(dir, branchRef, sha, parent); err != nil {
187		return "", fmt.Errorf("branch moved during edit; reload and retry: %w", err)
188	}
189	return sha, nil
190}
191
192// CommitParents returns the parent SHAs of a commit.
193func CommitParents(dir, sha string) ([]string, error) {
194	out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-list", "--parents", "-n1", "--end-of-options", sha).Output()
195	if err != nil {
196		return nil, fmt.Errorf("rev-list --parents %s: %w", sha, err)
197	}
198	fields := strings.Fields(string(out))
199	if len(fields) < 1 {
200		return nil, fmt.Errorf("no output for %s", sha)
201	}
202	return fields[1:], nil
203}
204
205// AuthorIdent returns a commit's author name, email, and ISO date.
206func AuthorIdent(dir, sha string) (name, email, date string, err error) {
207	out, err := exec.Command(toolpath.Look("git"), "-C", dir, "log", "-1", "--format=%an%x1f%ae%x1f%aI", "--end-of-options", sha).Output()
208	if err != nil {
209		return "", "", "", fmt.Errorf("log %s: %w", sha, err)
210	}
211	parts := strings.SplitN(strings.TrimSpace(string(out)), "\x1f", 3)
212	if len(parts) != 3 {
213		return "", "", "", fmt.Errorf("bad ident for %s", sha)
214	}
215	return parts[0], parts[1], parts[2], nil
216}
217
218// CommitMessage returns a commit's full message.
219func CommitMessage(dir, sha string) (string, error) {
220	out, err := exec.Command(toolpath.Look("git"), "-C", dir, "log", "-1", "--format=%B", "--end-of-options", sha).Output()
221	if err != nil {
222		return "", fmt.Errorf("log %s: %w", sha, err)
223	}
224	return strings.TrimRight(string(out), "\n"), nil
225}
226
227// MergeTreeOnto replays commit's changes (relative to base) onto onto,
228// returning the resulting tree. conflict=true when it cannot apply cleanly.
229func MergeTreeOnto(dir, base, onto, commit string) (tree string, conflict bool, err error) {
230	cmd := exec.Command(toolpath.Look("git"), "-C", dir, "merge-tree", "--write-tree", "--merge-base="+base, "--end-of-options", onto, commit)
231	out, runErr := cmd.Output()
232	tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
233	if runErr != nil {
234		if ee, ok := runErr.(*exec.ExitError); ok && ee.ExitCode() == 1 {
235			return "", true, nil
236		}
237		return "", false, fmt.Errorf("merge-tree: %w", runErr)
238	}
239	return tree, false, nil
240}
241
242// CommitTreeIdent creates a commit with distinct author and committer
243// identities. Empty authorDate means now.
244func CommitTreeIdent(dir, tree string, parents []string,
245	authorName, authorEmail, authorDate, committerName, committerEmail, message string) (string, error) {
246	args := []string{"-C", dir, "commit-tree", tree, "-m", message}
247	for _, p := range parents {
248		args = append(args, "-p", p)
249	}
250	cmd := exec.Command(toolpath.Look("git"), args...)
251	env := append(os.Environ(),
252		"GIT_AUTHOR_NAME="+authorName, "GIT_AUTHOR_EMAIL="+authorEmail,
253		"GIT_COMMITTER_NAME="+committerName, "GIT_COMMITTER_EMAIL="+committerEmail,
254	)
255	if authorDate != "" {
256		env = append(env, "GIT_AUTHOR_DATE="+authorDate)
257	}
258	cmd.Env = env
259	out, err := cmd.Output()
260	if err != nil {
261		return "", fmt.Errorf("commit-tree: %w", err)
262	}
263	return strings.TrimSpace(string(out)), nil
264}
265
266// ResolveTree returns the tree id of a commit.
267func ResolveTree(dir, sha string) (string, error) {
268	out, err := exec.Command(toolpath.Look("git"), "-C", dir, "rev-parse", "--verify", "--end-of-options", sha+"^{tree}").Output()
269	if err != nil {
270		return "", fmt.Errorf("rev-parse %s^{tree}: %w", sha, err)
271	}
272	return strings.TrimSpace(string(out)), nil
273}
274
275// DiffFiles lists the paths changed between old and new.
276func DiffFiles(dir, old, new string) ([]string, error) {
277	out, err := exec.Command(toolpath.Look("git"), "-C", dir, "diff", "--name-only", "--end-of-options", old, new).Output()
278	if err != nil {
279		return nil, fmt.Errorf("diff --name-only: %w", err)
280	}
281	var files []string
282	for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
283		if l != "" {
284			files = append(files, l)
285		}
286	}
287	return files, nil
288}
289
290// RangeDiff compares two revisions of the same work: what the commits
291// between oldBase and oldHead became between newBase and newHead. This is
292// what answers "what changed since I reviewed this", which a plain diff
293// of the two heads cannot — that shows the whole branch again, rebases
294// and all.
295//
296// Each side carries its own base, because the target moves: comparing
297// both revisions against today's base would attribute every commit that
298// landed on the target in between to the author of this merge request.
299//
300// --creation-factor is raised from git's default of 60. That default is
301// tuned for comparing two independently developed patch series, where
302// refusing to pair is the safe answer. Here the two sides are known to be
303// revisions of one branch, and the commonest revision of all — a commit
304// that adds a file, with one line inside it changed — is not paired at
305// 60: git reports the commit as deleted and a different one added, which
306// tells a reviewer nothing. It pairs at 80, and two genuinely unrelated
307// commits are still left unpaired there; both measured.
308func RangeDiff(dir, oldBase, oldHead, newBase, newHead string, limit int64) (patch string, truncated bool, err error) {
309	cmd := exec.Command(toolpath.Look("git"), "-C", dir, "range-diff", "--creation-factor=80", "--end-of-options",
310		oldBase+".."+oldHead, newBase+".."+newHead)
311	out, err := cmd.Output()
312	if err != nil {
313		return "", false, fmt.Errorf("range-diff: %w", err)
314	}
315	out, truncated = cutAtLine(out, limit)
316	return string(out), truncated, nil
317}