1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli
4
5import (
6 "bufio"
7 "bytes"
8 "context"
9 "errors"
10 "fmt"
11 "io"
12 "os/exec"
13 "strconv"
14 "strings"
15
16 "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"
18)
19
20var _ ports.MergeGit = (*Git)(nil)
21
22// exitCode returns the exit status of a failed git run, or -1 when err is not
23// the exit of the process (it did not start, was cancelled, ...).
24func exitCode(err error) int {
25 var exit *exec.ExitError
26 if errors.As(err, &exit) {
27 return exit.ExitCode()
28 }
29 return -1
30}
31
32// parseID parses the id git printed on a line.
33func parseID(out string) (domain.ObjectID, error) {
34 id, err := domain.ParseObjectID(strings.TrimSpace(out))
35 if err != nil {
36 return domain.ObjectID{}, fmt.Errorf("git printed %q: %w", strings.TrimSpace(out), err)
37 }
38 return id, nil
39}
40
41// BranchHead resolves refs/heads/<branch>^{commit} with `cat-file`.
42//
43//hexcheck:ignore suspicious-business-logic-in-adapter maps a branch name to a git object name
44func (g *Git) BranchHead(ctx context.Context, repo domain.RepoName, branch string) (domain.ObjectID, error) {
45 dir, err := g.repoDir(repo)
46 if err != nil {
47 return domain.ObjectID{}, err
48 }
49 if _, err := domain.ParseBranchName(branch); err != nil {
50 return domain.ObjectID{}, fmt.Errorf("branch %q of %q: %w", branch, repo, domain.ErrNotFound)
51 }
52 var id domain.ObjectID
53 err = g.cat.do(ctx, dir, func(c *catConn) error {
54 h, err := c.info(headsRefPrefix + branch + "^{commit}")
55 if err != nil {
56 return err
57 }
58 id, err = domain.ParseObjectID(h.ID)
59 if err != nil {
60 return c.fail(err)
61 }
62 return nil
63 })
64 if notFound(err) {
65 return domain.ObjectID{}, fmt.Errorf("branch %q of %q: %w", branch, repo, domain.ErrNotFound)
66 }
67 if err != nil {
68 return domain.ObjectID{}, fmt.Errorf("branch %q of %q: %w", branch, repo, err)
69 }
70 return id, nil
71}
72
73// HasCommit asks `cat-file` for the type of the object.
74func (g *Git) HasCommit(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (bool, error) {
75 dir, err := g.repoDir(repo)
76 if err != nil {
77 return false, err
78 }
79 found := false
80 err = g.cat.do(ctx, dir, func(c *catConn) error {
81 h, err := c.info(id.String())
82 if notFound(err) {
83 return nil
84 }
85 if err != nil {
86 return err
87 }
88 found = h.Type == "commit"
89 return nil
90 })
91 if err != nil {
92 return false, fmt.Errorf("look up commit %s in %q: %w", id, repo, err)
93 }
94 return found, nil
95}
96
97// MergeBase runs `git merge-base`, which exits 1 when there is none.
98//
99//hexcheck:ignore suspicious-business-logic-in-adapter maps git exit codes to the domain not found error
100func (g *Git) MergeBase(ctx context.Context, repo domain.RepoName, a, b domain.ObjectID) (domain.ObjectID, error) {
101 dir, err := g.repoDir(repo)
102 if err != nil {
103 return domain.ObjectID{}, err
104 }
105 out, err := g.run(ctx, readArgs(dir, "merge-base", a.String(), b.String())...)
106 switch {
107 case exitCode(err) == 1:
108 return domain.ObjectID{}, fmt.Errorf("%s and %s share no history: %w", a, b, domain.ErrNotFound)
109 case err != nil:
110 return domain.ObjectID{}, fmt.Errorf("merge base in %q: %w", repo, err)
111 }
112 return parseID(out)
113}
114
115// IsAncestor runs `git merge-base --is-ancestor`, which exits 1 for "no".
116func (g *Git) IsAncestor(ctx context.Context, repo domain.RepoName, ancestor, descendant domain.ObjectID) (bool, error) {
117 dir, err := g.repoDir(repo)
118 if err != nil {
119 return false, err
120 }
121 _, err = g.run(ctx, readArgs(dir, "merge-base", "--is-ancestor", ancestor.String(), descendant.String())...)
122 switch {
123 case err == nil:
124 return true, nil
125 case exitCode(err) == 1:
126 return false, nil
127 }
128 return false, fmt.Errorf("ancestry in %q: %w", repo, err)
129}
130
131// ListRange runs `git log <head> ^<exclude>`.
132//
133//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port
134func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {
135 var excluded []string
136 if !exclude.IsZero() {
137 excluded = []string{"^" + exclude.String()}
138 }
139 return g.logCommits(ctx, repo, head, limit, nil, excluded)
140}
141
142// logCommits runs `git log` for the commits reachable from head: revArgs go
143// before the head (options such as --not), excluded after it. Newest first,
144// at most limit; more says there were others.
145//
146//hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port
147func (g *Git) logCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int, revArgs, excluded []string) ([]domain.Commit, bool, error) {
148 if limit < 1 || limit > ports.MaxCommitLimit {
149 return nil, false, fmt.Errorf("list range: %w: limit %d", domain.ErrInvalidPageSize, limit)
150 }
151 dir, err := g.repoDir(repo)
152 if err != nil {
153 return nil, false, err
154 }
155 args := readArgs(dir, "log", "-z", "-n", strconv.Itoa(limit+1), "--format="+commitFormat)
156 args = append(args, revArgs...)
157 args = append(args, "--end-of-options", head.String())
158 args = append(args, excluded...)
159 var (
160 commits []domain.Commit
161 more bool
162 )
163 err = g.pipe(ctx, args, func(r *bufio.Reader) error {
164 for {
165 c, err := readCommit(r)
166 if errors.Is(err, io.EOF) {
167 return nil
168 }
169 if err != nil {
170 return err
171 }
172 if len(commits) == limit {
173 more = true
174 return errStop
175 }
176 commits = append(commits, c)
177 }
178 })
179 if err != nil {
180 return nil, false, fmt.Errorf("commits of %s in %q: %w", head, repo, err)
181 }
182 return commits, more, nil
183}
184
185// DiffCommits reads the diff between two commits like GetCommit does.
186//
187//hexcheck:ignore suspicious-business-logic-in-adapter reads a git diff into the domain diff with the limits the port passes in
188func (g *Git) DiffCommits(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error) {
189 dir, err := g.repoDir(repo)
190 if err != nil {
191 return domain.DiffStats{}, domain.Diff{}, err
192 }
193 stats, diff, err := g.diffTrees(ctx, dir, base.String(), head.String(), limits)
194 if err != nil {
195 return domain.DiffStats{}, domain.Diff{}, fmt.Errorf("diff %s..%s in %q: %w", base, head, repo, err)
196 }
197 return stats, diff, nil
198}
199
200// CommitTree runs `git rev-parse <commit>^{tree}`.
201//
202//hexcheck:ignore suspicious-business-logic-in-adapter parses the id git printed
203func (g *Git) CommitTree(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (domain.ObjectID, error) {
204 dir, err := g.repoDir(repo)
205 if err != nil {
206 return domain.ObjectID{}, err
207 }
208 out, err := g.run(ctx, readArgs(dir, "rev-parse", "--verify", id.String()+"^{tree}")...)
209 if err != nil {
210 return domain.ObjectID{}, fmt.Errorf("tree of %s in %q: %w", id, repo, err)
211 }
212 return parseID(out)
213}
214
215// WriteCommit runs `git hash-object -t commit -w --stdin`, which also checks
216// that the object is a well-formed commit.
217func (g *Git) WriteCommit(ctx context.Context, repo domain.RepoName, object []byte) (domain.ObjectID, error) {
218 dir, err := g.repoDir(repo)
219 if err != nil {
220 return domain.ObjectID{}, err
221 }
222 out, err := g.runInput(ctx, bytes.NewReader(object), "--git-dir="+dir, "hash-object", "-t", "commit", "-w", "--stdin")
223 if err != nil {
224 return domain.ObjectID{}, fmt.Errorf("write commit in %q: %w", repo, err)
225 }
226 return parseID(out)
227}
228
229// UpdateBranch runs `git update-ref refs/heads/<branch> <new> <old>`, which
230// takes the ref lock and refuses when the ref is not at old. A failure is
231// told from a moved branch by reading the branch again.
232//
233//hexcheck:ignore suspicious-business-logic-in-adapter maps a refused update-ref to the conflict error of the port
234func (g *Git) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old, newID domain.ObjectID) error {
235 dir, err := g.repoDir(repo)
236 if err != nil {
237 return err
238 }
239 if _, err := domain.ParseBranchName(branch); err != nil {
240 return fmt.Errorf("update branch of %q: %w", repo, err)
241 }
242 _, err = g.run(ctx, "--git-dir="+dir, "update-ref", headsRefPrefix+branch, newID.String(), old.String())
243 if err == nil {
244 return nil
245 }
246 if cur, herr := g.BranchHead(ctx, repo, branch); herr == nil && cur != old {
247 return fmt.Errorf("branch %q of %q is at %s, not %s: %w", branch, repo, cur, old, domain.ErrConflict)
248 }
249 return fmt.Errorf("update branch %q of %q: %w", branch, repo, err)
250}
251
252// DeleteBranch runs `git update-ref -d refs/heads/<branch> <old>`, which takes
253// the ref lock and refuses when the ref is not at old. A failure is told from
254// a moved or deleted branch by reading the branch again.
255//
256//hexcheck:ignore suspicious-business-logic-in-adapter maps a refused update-ref to the conflict error of the port
257func (g *Git) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
258 dir, err := g.repoDir(repo)
259 if err != nil {
260 return err
261 }
262 if _, err := domain.ParseBranchName(branch); err != nil {
263 return fmt.Errorf("delete branch of %q: %w", repo, err)
264 }
265 _, err = g.run(ctx, "--git-dir="+dir, "update-ref", "-d", headsRefPrefix+branch, old.String())
266 if err == nil {
267 return nil
268 }
269 switch cur, herr := g.BranchHead(ctx, repo, branch); {
270 case errors.Is(herr, domain.ErrNotFound):
271 return fmt.Errorf("branch %q of %q is gone: %w", branch, repo, domain.ErrConflict)
272 case herr == nil && cur != old:
273 return fmt.Errorf("branch %q of %q is at %s, not %s: %w", branch, repo, cur, old, domain.ErrConflict)
274 }
275 return fmt.Errorf("delete branch %q of %q: %w", branch, repo, err)
276}