internal/gitutil/merge.go
315 lines · 11610 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}
287
288// RangeDiff compares two revisions of the same work: what the commits
289// between oldBase and oldHead became between newBase and newHead. This is
290// what answers "what changed since I reviewed this", which a plain diff
291// of the two heads cannot — that shows the whole branch again, rebases
292// and all.
293//
294// Each side carries its own base, because the target moves: comparing
295// both revisions against today's base would attribute every commit that
296// landed on the target in between to the author of this merge request.
297//
298// --creation-factor is raised from git's default of 60. That default is
299// tuned for comparing two independently developed patch series, where
300// refusing to pair is the safe answer. Here the two sides are known to be
301// revisions of one branch, and the commonest revision of all — a commit
302// that adds a file, with one line inside it changed — is not paired at
303// 60: git reports the commit as deleted and a different one added, which
304// tells a reviewer nothing. It pairs at 80, and two genuinely unrelated
305// commits are still left unpaired there; both measured.
306func RangeDiff(dir, oldBase, oldHead, newBase, newHead string, limit int64) (patch string, truncated bool, err error) {
307 cmd := exec.Command("git", "-C", dir, "range-diff", "--creation-factor=80", "--end-of-options",
308 oldBase+".."+oldHead, newBase+".."+newHead)
309 out, err := cmd.Output()
310 if err != nil {
311 return "", false, fmt.Errorf("range-diff: %w", err)
312 }
313 out, truncated = cutAtLine(out, limit)
314 return string(out), truncated, nil
315}