internal/gitutil/merge.go

7cc95d1fc1115f22008751a05e9e07156d49d6b0
gitbay/internal/gitutil/merge.go history · blame · raw

286 lines · 10069 bytes

  1package gitutil
  2
  3import (
  4	"bytes"
  5	"fmt"
  6	"os"
  7	"os/exec"
  8	"strings"
  9)
 10
 11// FetchInto copies srcRef from srcDir into dstDir as dstRef, forcing the
 12// update. Objects are copied, not shared — the destination owns everything
 13// afterward, which is what keeps MRs alive when their fork is deleted.
 14func FetchInto(dstDir, srcDir, srcRef, dstRef string) error {
 15	cmd := exec.Command("git", "-C", dstDir, "fetch", "--quiet", "--no-write-fetch-head",
 16		srcDir, "+"+srcRef+":"+dstRef)
 17	if out, err := cmd.CombinedOutput(); err != nil {
 18		return fmt.Errorf("fetch %s from %s: %v\n%s", srcRef, srcDir, err, out)
 19	}
 20	return nil
 21}
 22
 23// UpdateRefCAS points ref at newSHA only if it currently points at oldSHA
 24// (empty oldSHA = must not exist). This is the compare-and-swap that makes
 25// merges safe against concurrent pushes.
 26func UpdateRefCAS(dir, ref, newSHA, oldSHA string) error {
 27	args := []string{"-C", dir, "update-ref", ref, newSHA}
 28	if oldSHA != "" {
 29		args = append(args, oldSHA)
 30	}
 31	cmd := exec.Command("git", args...)
 32	if out, err := cmd.CombinedOutput(); err != nil {
 33		return fmt.Errorf("update-ref %s: %v\n%s", ref, err, out)
 34	}
 35	return nil
 36}
 37
 38func DeleteRef(dir, ref string) error {
 39	cmd := exec.Command("git", "-C", dir, "update-ref", "-d", ref)
 40	if out, err := cmd.CombinedOutput(); err != nil {
 41		return fmt.Errorf("delete-ref %s: %v\n%s", ref, err, out)
 42	}
 43	return nil
 44}
 45
 46// RevListRange returns commits in old..new, newest first.
 47func RevListRange(dir, old, new string) ([]string, error) {
 48	cmd := exec.Command("git", "-C", dir, "rev-list", "--end-of-options", new, "^"+old)
 49	out, err := cmd.Output()
 50	if err != nil {
 51		return nil, fmt.Errorf("rev-list %s..%s: %w", old, new, err)
 52	}
 53	var shas []string
 54	for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
 55		if l != "" {
 56			shas = append(shas, l)
 57		}
 58	}
 59	return shas, nil
 60}
 61
 62// MergeTree performs a real merge of ours and theirs, returning the merged
 63// tree id. conflict=true means the merge cannot be done automatically.
 64func MergeTree(dir, ours, theirs string) (tree string, conflict bool, err error) {
 65	cmd := exec.Command("git", "-C", dir, "merge-tree", "--write-tree", "--end-of-options", ours, theirs)
 66	out, runErr := cmd.Output()
 67	tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
 68	if runErr != nil {
 69		if ee, ok := runErr.(*exec.ExitError); ok && ee.ExitCode() == 1 {
 70			return "", true, nil // conflicted merge
 71		}
 72		return "", false, fmt.Errorf("merge-tree: %w", runErr)
 73	}
 74	return tree, false, nil
 75}
 76
 77// CommitTree creates a merge commit with the given parents, authored and
 78// committed by the merging user. There is no server signing key by design.
 79func CommitTree(dir, tree string, parents []string, name, email, message string) (string, error) {
 80	args := []string{"-C", dir, "commit-tree", tree, "-m", message}
 81	for _, p := range parents {
 82		args = append(args, "-p", p)
 83	}
 84	cmd := exec.Command("git", args...)
 85	cmd.Env = append(os.Environ(),
 86		"GIT_AUTHOR_NAME="+name, "GIT_AUTHOR_EMAIL="+email,
 87		"GIT_COMMITTER_NAME="+name, "GIT_COMMITTER_EMAIL="+email,
 88	)
 89	out, err := cmd.Output()
 90	if err != nil {
 91		return "", fmt.Errorf("commit-tree: %w", err)
 92	}
 93	return strings.TrimSpace(string(out)), nil
 94}
 95
 96// Diff returns the patch for old..new (three-dot semantics are the caller's
 97// job: pass the merge base as old), cut at limit bytes on a line boundary;
 98// truncated says whether it was cut, so the caller can say so instead of
 99// rendering a hunk that ends mid-line (#117).
100func Diff(dir, old, new string, limit int64) (patch string, truncated bool, err error) {
101	cmd := exec.Command("git", "-C", dir, "diff", "--stat", "--patch", "--end-of-options", old, new)
102	out, err := cmd.Output()
103	if err != nil {
104		return "", false, fmt.Errorf("diff: %w", err)
105	}
106	out, truncated = cutAtLine(out, limit)
107	return string(out), truncated, nil
108}
109
110// cutAtLine keeps at most limit bytes, ending on the last newline before
111// the limit.
112func cutAtLine(out []byte, limit int64) ([]byte, bool) {
113	if int64(len(out)) <= limit {
114		return out, false
115	}
116	cut := out[:limit]
117	if i := bytes.LastIndexByte(cut, '\n'); i >= 0 {
118		cut = cut[:i+1]
119	}
120	return cut, true
121}
122
123// MergeBase returns the best common ancestor, or an error if none exists.
124func MergeBase(dir, a, b string) (string, error) {
125	cmd := exec.Command("git", "-C", dir, "merge-base", "--end-of-options", a, b)
126	out, err := cmd.Output()
127	if err != nil {
128		return "", fmt.Errorf("no common history between %s and %s", a, b)
129	}
130	return strings.TrimSpace(string(out)), nil
131}
132
133// CommitFileChange writes content at path on branch as a new commit and
134// advances the branch with compare-and-swap. Used by web edits; hooks do not
135// run, so callers enforce policy themselves.
136func CommitFileChange(dir, branch, path string, content []byte, name, email, message string) (string, error) {
137	branchRef := "refs/heads/" + branch
138	parent, err := ResolveRef(dir, branchRef)
139	if err != nil {
140		return "", fmt.Errorf("branch %s: %w", branch, err)
141	}
142
143	// Hash the new blob.
144	hb := exec.Command("git", "-C", dir, "hash-object", "-w", "--stdin")
145	hb.Stdin = strings.NewReader(string(content))
146	out, err := hb.Output()
147	if err != nil {
148		return "", fmt.Errorf("hash-object: %w", err)
149	}
150	blob := strings.TrimSpace(string(out))
151
152	// Stage the parent tree in a temporary index, splice the blob in, and
153	// write the new tree.
154	idx, err := os.CreateTemp("", "gitbay-index-*")
155	if err != nil {
156		return "", err
157	}
158	idx.Close()
159	defer os.Remove(idx.Name())
160	env := append(os.Environ(), "GIT_INDEX_FILE="+idx.Name())
161
162	rt := exec.Command("git", "-C", dir, "read-tree", parent+"^{tree}")
163	rt.Env = env
164	if out, err := rt.CombinedOutput(); err != nil {
165		return "", fmt.Errorf("read-tree: %v\n%s", err, out)
166	}
167	ui := exec.Command("git", "-C", dir, "update-index", "--add", "--cacheinfo", "100644,"+blob+","+path)
168	ui.Env = env
169	if out, err := ui.CombinedOutput(); err != nil {
170		return "", fmt.Errorf("update-index: %v\n%s", err, out)
171	}
172	wt := exec.Command("git", "-C", dir, "write-tree")
173	wt.Env = env
174	out, err = wt.Output()
175	if err != nil {
176		return "", fmt.Errorf("write-tree: %w", err)
177	}
178	tree := strings.TrimSpace(string(out))
179
180	sha, err := CommitTree(dir, tree, []string{parent}, name, email, message)
181	if err != nil {
182		return "", err
183	}
184	if err := UpdateRefCAS(dir, branchRef, sha, parent); err != nil {
185		return "", fmt.Errorf("branch moved during edit; reload and retry: %w", err)
186	}
187	return sha, nil
188}
189
190// CommitParents returns the parent SHAs of a commit.
191func CommitParents(dir, sha string) ([]string, error) {
192	out, err := exec.Command("git", "-C", dir, "rev-list", "--parents", "-n1", "--end-of-options", sha).Output()
193	if err != nil {
194		return nil, fmt.Errorf("rev-list --parents %s: %w", sha, err)
195	}
196	fields := strings.Fields(string(out))
197	if len(fields) < 1 {
198		return nil, fmt.Errorf("no output for %s", sha)
199	}
200	return fields[1:], nil
201}
202
203// AuthorIdent returns a commit's author name, email, and ISO date.
204func AuthorIdent(dir, sha string) (name, email, date string, err error) {
205	out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%an%x1f%ae%x1f%aI", "--end-of-options", sha).Output()
206	if err != nil {
207		return "", "", "", fmt.Errorf("log %s: %w", sha, err)
208	}
209	parts := strings.SplitN(strings.TrimSpace(string(out)), "\x1f", 3)
210	if len(parts) != 3 {
211		return "", "", "", fmt.Errorf("bad ident for %s", sha)
212	}
213	return parts[0], parts[1], parts[2], nil
214}
215
216// CommitMessage returns a commit's full message.
217func CommitMessage(dir, sha string) (string, error) {
218	out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%B", "--end-of-options", sha).Output()
219	if err != nil {
220		return "", fmt.Errorf("log %s: %w", sha, err)
221	}
222	return strings.TrimRight(string(out), "\n"), nil
223}
224
225// MergeTreeOnto replays commit's changes (relative to base) onto onto,
226// returning the resulting tree. conflict=true when it cannot apply cleanly.
227func MergeTreeOnto(dir, base, onto, commit string) (tree string, conflict bool, err error) {
228	cmd := exec.Command("git", "-C", dir, "merge-tree", "--write-tree", "--merge-base="+base, "--end-of-options", onto, commit)
229	out, runErr := cmd.Output()
230	tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
231	if runErr != nil {
232		if ee, ok := runErr.(*exec.ExitError); ok && ee.ExitCode() == 1 {
233			return "", true, nil
234		}
235		return "", false, fmt.Errorf("merge-tree: %w", runErr)
236	}
237	return tree, false, nil
238}
239
240// CommitTreeIdent creates a commit with distinct author and committer
241// identities. Empty authorDate means now.
242func CommitTreeIdent(dir, tree string, parents []string,
243	authorName, authorEmail, authorDate, committerName, committerEmail, message string) (string, error) {
244	args := []string{"-C", dir, "commit-tree", tree, "-m", message}
245	for _, p := range parents {
246		args = append(args, "-p", p)
247	}
248	cmd := exec.Command("git", args...)
249	env := append(os.Environ(),
250		"GIT_AUTHOR_NAME="+authorName, "GIT_AUTHOR_EMAIL="+authorEmail,
251		"GIT_COMMITTER_NAME="+committerName, "GIT_COMMITTER_EMAIL="+committerEmail,
252	)
253	if authorDate != "" {
254		env = append(env, "GIT_AUTHOR_DATE="+authorDate)
255	}
256	cmd.Env = env
257	out, err := cmd.Output()
258	if err != nil {
259		return "", fmt.Errorf("commit-tree: %w", err)
260	}
261	return strings.TrimSpace(string(out)), nil
262}
263
264// ResolveTree returns the tree id of a commit.
265func ResolveTree(dir, sha string) (string, error) {
266	out, err := exec.Command("git", "-C", dir, "rev-parse", "--verify", "--end-of-options", sha+"^{tree}").Output()
267	if err != nil {
268		return "", fmt.Errorf("rev-parse %s^{tree}: %w", sha, err)
269	}
270	return strings.TrimSpace(string(out)), nil
271}
272
273// DiffFiles lists the paths changed between old and new.
274func DiffFiles(dir, old, new string) ([]string, error) {
275	out, err := exec.Command("git", "-C", dir, "diff", "--name-only", "--end-of-options", old, new).Output()
276	if err != nil {
277		return nil, fmt.Errorf("diff --name-only: %w", err)
278	}
279	var files []string
280	for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
281		if l != "" {
282			files = append(files, l)
283		}
284	}
285	return files, nil
286}