internal/gitutil/merge.go
174 lines · 5852 bytes
1package gitutil
2
3import (
4 "fmt"
5 "os"
6 "os/exec"
7 "strings"
8)
9
10// FetchInto copies srcRef from srcDir into dstDir as dstRef, forcing the
11// update. Objects are copied, not shared — the destination owns everything
12// afterward, which is what keeps MRs alive when their fork is deleted.
13func FetchInto(dstDir, srcDir, srcRef, dstRef string) error {
14 cmd := exec.Command("git", "-C", dstDir, "fetch", "--quiet", "--no-write-fetch-head",
15 srcDir, "+"+srcRef+":"+dstRef)
16 if out, err := cmd.CombinedOutput(); err != nil {
17 return fmt.Errorf("fetch %s from %s: %v\n%s", srcRef, srcDir, err, out)
18 }
19 return nil
20}
21
22// UpdateRefCAS points ref at newSHA only if it currently points at oldSHA
23// (empty oldSHA = must not exist). This is the compare-and-swap that makes
24// merges safe against concurrent pushes.
25func UpdateRefCAS(dir, ref, newSHA, oldSHA string) error {
26 args := []string{"-C", dir, "update-ref", ref, newSHA}
27 if oldSHA != "" {
28 args = append(args, oldSHA)
29 }
30 cmd := exec.Command("git", args...)
31 if out, err := cmd.CombinedOutput(); err != nil {
32 return fmt.Errorf("update-ref %s: %v\n%s", ref, err, out)
33 }
34 return nil
35}
36
37func DeleteRef(dir, ref string) error {
38 cmd := exec.Command("git", "-C", dir, "update-ref", "-d", ref)
39 if out, err := cmd.CombinedOutput(); err != nil {
40 return fmt.Errorf("delete-ref %s: %v\n%s", ref, err, out)
41 }
42 return nil
43}
44
45// RevListRange returns commits in old..new, newest first.
46func RevListRange(dir, old, new string) ([]string, error) {
47 cmd := exec.Command("git", "-C", dir, "rev-list", new, "^"+old)
48 out, err := cmd.Output()
49 if err != nil {
50 return nil, fmt.Errorf("rev-list %s..%s: %w", old, new, err)
51 }
52 var shas []string
53 for _, l := range strings.Split(strings.TrimSpace(string(out)), "\n") {
54 if l != "" {
55 shas = append(shas, l)
56 }
57 }
58 return shas, nil
59}
60
61// MergeTree performs a real merge of ours and theirs, returning the merged
62// tree id. conflict=true means the merge cannot be done automatically.
63func MergeTree(dir, ours, theirs string) (tree string, conflict bool, err error) {
64 cmd := exec.Command("git", "-C", dir, "merge-tree", "--write-tree", ours, theirs)
65 out, runErr := cmd.Output()
66 tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
67 if runErr != nil {
68 if ee, ok := runErr.(*exec.ExitError); ok && ee.ExitCode() == 1 {
69 return "", true, nil // conflicted merge
70 }
71 return "", false, fmt.Errorf("merge-tree: %w", runErr)
72 }
73 return tree, false, nil
74}
75
76// CommitTree creates a merge commit with the given parents, authored and
77// committed by the merging user. There is no server signing key by design.
78func CommitTree(dir, tree string, parents []string, name, email, message string) (string, error) {
79 args := []string{"-C", dir, "commit-tree", tree, "-m", message}
80 for _, p := range parents {
81 args = append(args, "-p", p)
82 }
83 cmd := exec.Command("git", args...)
84 cmd.Env = append(os.Environ(),
85 "GIT_AUTHOR_NAME="+name, "GIT_AUTHOR_EMAIL="+email,
86 "GIT_COMMITTER_NAME="+name, "GIT_COMMITTER_EMAIL="+email,
87 )
88 out, err := cmd.Output()
89 if err != nil {
90 return "", fmt.Errorf("commit-tree: %w", err)
91 }
92 return strings.TrimSpace(string(out)), nil
93}
94
95// Diff returns the patch for old..new (three-dot semantics are the caller's
96// job: pass the merge base as old).
97func Diff(dir, old, new string, limit int64) (string, error) {
98 cmd := exec.Command("git", "-C", dir, "diff", "--stat", "--patch", old, new)
99 out, err := cmd.Output()
100 if err != nil {
101 return "", fmt.Errorf("diff: %w", err)
102 }
103 if int64(len(out)) > limit {
104 out = out[:limit]
105 }
106 return string(out), nil
107}
108
109// MergeBase returns the best common ancestor, or an error if none exists.
110func MergeBase(dir, a, b string) (string, error) {
111 cmd := exec.Command("git", "-C", dir, "merge-base", a, b)
112 out, err := cmd.Output()
113 if err != nil {
114 return "", fmt.Errorf("no common history between %s and %s", a, b)
115 }
116 return strings.TrimSpace(string(out)), nil
117}
118
119// CommitFileChange writes content at path on branch as a new commit and
120// advances the branch with compare-and-swap. Used by web edits; hooks do not
121// run, so callers enforce policy themselves.
122func CommitFileChange(dir, branch, path string, content []byte, name, email, message string) (string, error) {
123 branchRef := "refs/heads/" + branch
124 parent, err := ResolveRef(dir, branchRef)
125 if err != nil {
126 return "", fmt.Errorf("branch %s: %w", branch, err)
127 }
128
129 // Hash the new blob.
130 hb := exec.Command("git", "-C", dir, "hash-object", "-w", "--stdin")
131 hb.Stdin = strings.NewReader(string(content))
132 out, err := hb.Output()
133 if err != nil {
134 return "", fmt.Errorf("hash-object: %w", err)
135 }
136 blob := strings.TrimSpace(string(out))
137
138 // Stage the parent tree in a temporary index, splice the blob in, and
139 // write the new tree.
140 idx, err := os.CreateTemp("", "gitbay-index-*")
141 if err != nil {
142 return "", err
143 }
144 idx.Close()
145 defer os.Remove(idx.Name())
146 env := append(os.Environ(), "GIT_INDEX_FILE="+idx.Name())
147
148 rt := exec.Command("git", "-C", dir, "read-tree", parent+"^{tree}")
149 rt.Env = env
150 if out, err := rt.CombinedOutput(); err != nil {
151 return "", fmt.Errorf("read-tree: %v\n%s", err, out)
152 }
153 ui := exec.Command("git", "-C", dir, "update-index", "--add", "--cacheinfo", "100644,"+blob+","+path)
154 ui.Env = env
155 if out, err := ui.CombinedOutput(); err != nil {
156 return "", fmt.Errorf("update-index: %v\n%s", err, out)
157 }
158 wt := exec.Command("git", "-C", dir, "write-tree")
159 wt.Env = env
160 out, err = wt.Output()
161 if err != nil {
162 return "", fmt.Errorf("write-tree: %w", err)
163 }
164 tree := strings.TrimSpace(string(out))
165
166 sha, err := CommitTree(dir, tree, []string{parent}, name, email, message)
167 if err != nil {
168 return "", err
169 }
170 if err := UpdateRefCAS(dir, branchRef, sha, parent); err != nil {
171 return "", fmt.Errorf("branch moved during edit; reload and retry: %w", err)
172 }
173 return sha, nil
174}