krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
main: internal/gitutil/merge.go · raw
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}
175
176// CommitParents returns the parent SHAs of a commit.
177func CommitParents(dir, sha string) ([]string, error) {
178 out, err := exec.Command("git", "-C", dir, "rev-list", "--parents", "-n1", sha).Output()
179 if err != nil {
180 return nil, fmt.Errorf("rev-list --parents %s: %w", sha, err)
181 }
182 fields := strings.Fields(string(out))
183 if len(fields) < 1 {
184 return nil, fmt.Errorf("no output for %s", sha)
185 }
186 return fields[1:], nil
187}
188
189// AuthorIdent returns a commit's author name, email, and ISO date.
190func AuthorIdent(dir, sha string) (name, email, date string, err error) {
191 out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%an%x1f%ae%x1f%aI", sha).Output()
192 if err != nil {
193 return "", "", "", fmt.Errorf("log %s: %w", sha, err)
194 }
195 parts := strings.SplitN(strings.TrimSpace(string(out)), "\x1f", 3)
196 if len(parts) != 3 {
197 return "", "", "", fmt.Errorf("bad ident for %s", sha)
198 }
199 return parts[0], parts[1], parts[2], nil
200}
201
202// CommitMessage returns a commit's full message.
203func CommitMessage(dir, sha string) (string, error) {
204 out, err := exec.Command("git", "-C", dir, "log", "-1", "--format=%B", sha).Output()
205 if err != nil {
206 return "", fmt.Errorf("log %s: %w", sha, err)
207 }
208 return strings.TrimRight(string(out), "\n"), nil
209}
210
211// MergeTreeOnto replays commit's changes (relative to base) onto onto,
212// returning the resulting tree. conflict=true when it cannot apply cleanly.
213func MergeTreeOnto(dir, base, onto, commit string) (tree string, conflict bool, err error) {
214 cmd := exec.Command("git", "-C", dir, "merge-tree", "--write-tree", "--merge-base="+base, onto, commit)
215 out, runErr := cmd.Output()
216 tree = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
217 if runErr != nil {
218 if ee, ok := runErr.(*exec.ExitError); ok && ee.ExitCode() == 1 {
219 return "", true, nil
220 }
221 return "", false, fmt.Errorf("merge-tree: %w", runErr)
222 }
223 return tree, false, nil
224}
225
226// CommitTreeIdent creates a commit with distinct author and committer
227// identities. Empty authorDate means now.
228func CommitTreeIdent(dir, tree string, parents []string,
229 authorName, authorEmail, authorDate, committerName, committerEmail, message string) (string, error) {
230 args := []string{"-C", dir, "commit-tree", tree, "-m", message}
231 for _, p := range parents {
232 args = append(args, "-p", p)
233 }
234 cmd := exec.Command("git", args...)
235 env := append(os.Environ(),
236 "GIT_AUTHOR_NAME="+authorName, "GIT_AUTHOR_EMAIL="+authorEmail,
237 "GIT_COMMITTER_NAME="+committerName, "GIT_COMMITTER_EMAIL="+committerEmail,
238 )
239 if authorDate != "" {
240 env = append(env, "GIT_AUTHOR_DATE="+authorDate)
241 }
242 cmd.Env = env
243 out, err := cmd.Output()
244 if err != nil {
245 return "", fmt.Errorf("commit-tree: %w", err)
246 }
247 return strings.TrimSpace(string(out)), nil
248}
249
250// ResolveTree returns the tree id of a commit.
251func ResolveTree(dir, sha string) (string, error) {
252 out, err := exec.Command("git", "-C", dir, "rev-parse", sha+"^{tree}").Output()
253 if err != nil {
254 return "", fmt.Errorf("rev-parse %s^{tree}: %w", sha, err)
255 }
256 return strings.TrimSpace(string(out)), nil
257}