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}