1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "slices"
10 "strconv"
11 "strings"
12
13 "bnema.dev/ermite/internal/domain"
14)
15
16// MergeTree runs `git merge-tree --write-tree -z --name-only`, which merges in
17// the object database and touches no ref or index. It prints the tree of the
18// result, then on a conflict (exit 1) the NUL-separated conflicted paths.
19// Commits with no common history do not merge: not clean, without paths.
20func (g *Git) MergeTree(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (domain.MergeTreeResult, error) {
21 shared, err := g.sharesHistory(ctx, repo, base, head)
22 if err != nil || !shared {
23 return domain.MergeTreeResult{}, err
24 }
25 out, conflict, err := g.mergeTreeOutput(ctx, repo, base, head)
26 if err != nil {
27 return domain.MergeTreeResult{}, err
28 }
29 return parseMergeTree(out, conflict)
30}
31
32// mergeTreeOutput runs merge-tree and returns what it printed; conflict is its
33// exit status 1.
34func (g *Git) mergeTreeOutput(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (out string, conflict bool, err error) {
35 dir, err := g.repoDir(repo)
36 if err != nil {
37 return "", false, err
38 }
39 out, err = g.runCapture(ctx, nil, readArgs(dir, "merge-tree", "--write-tree", "-z", "--name-only", "--no-messages", "--end-of-options", base.String(), head.String())...)
40 conflict = exitCode(err) == 1
41 if err != nil && !conflict {
42 return "", false, fmt.Errorf("merge %s into %s in %q: %w", head, base, repo, err)
43 }
44 return out, conflict, nil
45}
46
47// sharesHistory reports whether the commits have a merge base.
48func (g *Git) sharesHistory(ctx context.Context, repo domain.RepoName, a, b domain.ObjectID) (bool, error) {
49 _, err := g.MergeBase(ctx, repo, a, b)
50 if errors.Is(err, domain.ErrNotFound) {
51 return false, nil
52 }
53 return err == nil, err
54}
55
56// parseMergeTree reads the output of merge-tree: the tree, then on a conflict
57// the NUL-separated paths, all of them.
58func parseMergeTree(out string, conflict bool) (domain.MergeTreeResult, error) {
59 fields := strings.Split(out, "\x00")
60 tree, err := parseID(fields[0])
61 if !conflict {
62 return domain.MergeTreeResult{Tree: tree, Clean: true}, err
63 }
64 paths := fields[1:]
65 if i := slices.Index(paths, ""); i >= 0 {
66 paths = paths[:i]
67 }
68 return domain.MergeTreeResult{ConflictPaths: paths}, nil
69}
70
71// CountBehind runs `git rev-list --count --max-count=<limit> <target> ^<head>`
72// and returns the count git printed.
73func (g *Git) CountBehind(ctx context.Context, repo domain.RepoName, head, target domain.ObjectID, limit int) (int, error) {
74 dir, err := g.repoDir(repo)
75 if err != nil {
76 return 0, err
77 }
78 out, err := g.run(ctx, readArgs(dir, "rev-list", "--count", "--max-count="+strconv.Itoa(limit), target.String(), "^"+head.String(), "--")...)
79 if err != nil {
80 return 0, fmt.Errorf("count behind %s in %q: %w", target, repo, err)
81 }
82 n, err := strconv.Atoi(strings.TrimSpace(out))
83 if err != nil {
84 return 0, fmt.Errorf("git printed %q: %w", strings.TrimSpace(out), err)
85 }
86 return n, nil
87}