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 if limit < 1 || limit > ports.MaxCommitLimit {
136 return nil, false, fmt.Errorf("list range: %w: limit %d", domain.ErrInvalidPageSize, limit)
137 }
138 dir, err := g.repoDir(repo)
139 if err != nil {
140 return nil, false, err
141 }
142 args := readArgs(dir, "log", "-z", "-n", strconv.Itoa(limit+1), "--format="+commitFormat, "--end-of-options", head.String())
143 if !exclude.IsZero() {
144 args = append(args, "^"+exclude.String())
145 }
146 var (
147 commits []domain.Commit
148 more bool
149 )
150 err = g.pipe(ctx, args, func(r *bufio.Reader) error {
151 for {
152 c, err := readCommit(r)
153 if errors.Is(err, io.EOF) {
154 return nil
155 }
156 if err != nil {
157 return err
158 }
159 if len(commits) == limit {
160 more = true
161 return errStop
162 }
163 commits = append(commits, c)
164 }
165 })
166 if err != nil {
167 return nil, false, fmt.Errorf("commits of %s in %q: %w", head, repo, err)
168 }
169 return commits, more, nil
170}
171
172// DiffCommits reads the diff between two commits like GetCommit does.
173//
174//hexcheck:ignore suspicious-business-logic-in-adapter reads a git diff into the domain diff with the limits the port passes in
175func (g *Git) DiffCommits(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error) {
176 dir, err := g.repoDir(repo)
177 if err != nil {
178 return domain.DiffStats{}, domain.Diff{}, err
179 }
180 stats, diff, err := g.diffTrees(ctx, dir, base.String(), head.String(), limits)
181 if err != nil {
182 return domain.DiffStats{}, domain.Diff{}, fmt.Errorf("diff %s..%s in %q: %w", base, head, repo, err)
183 }
184 return stats, diff, nil
185}
186
187// CommitTree runs `git rev-parse <commit>^{tree}`.
188//
189//hexcheck:ignore suspicious-business-logic-in-adapter parses the id git printed
190func (g *Git) CommitTree(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (domain.ObjectID, error) {
191 dir, err := g.repoDir(repo)
192 if err != nil {
193 return domain.ObjectID{}, err
194 }
195 out, err := g.run(ctx, readArgs(dir, "rev-parse", "--verify", id.String()+"^{tree}")...)
196 if err != nil {
197 return domain.ObjectID{}, fmt.Errorf("tree of %s in %q: %w", id, repo, err)
198 }
199 return parseID(out)
200}
201
202// WriteCommit runs `git hash-object -t commit -w --stdin`, which also checks
203// that the object is a well-formed commit.
204func (g *Git) WriteCommit(ctx context.Context, repo domain.RepoName, object []byte) (domain.ObjectID, error) {
205 dir, err := g.repoDir(repo)
206 if err != nil {
207 return domain.ObjectID{}, err
208 }
209 out, err := g.runInput(ctx, bytes.NewReader(object), "--git-dir="+dir, "hash-object", "-t", "commit", "-w", "--stdin")
210 if err != nil {
211 return domain.ObjectID{}, fmt.Errorf("write commit in %q: %w", repo, err)
212 }
213 return parseID(out)
214}
215
216// UpdateBranch runs `git update-ref refs/heads/<branch> <new> <old>`, which
217// takes the ref lock and refuses when the ref is not at old. A failure is
218// told from a moved branch by reading the branch again.
219//
220//hexcheck:ignore suspicious-business-logic-in-adapter maps a refused update-ref to the conflict error of the port
221func (g *Git) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old, newID domain.ObjectID) error {
222 dir, err := g.repoDir(repo)
223 if err != nil {
224 return err
225 }
226 if _, err := domain.ParseBranchName(branch); err != nil {
227 return fmt.Errorf("update branch of %q: %w", repo, err)
228 }
229 _, err = g.run(ctx, "--git-dir="+dir, "update-ref", headsRefPrefix+branch, newID.String(), old.String())
230 if err == nil {
231 return nil
232 }
233 if cur, herr := g.BranchHead(ctx, repo, branch); herr == nil && cur != old {
234 return fmt.Errorf("branch %q of %q is at %s, not %s: %w", branch, repo, cur, old, domain.ErrConflict)
235 }
236 return fmt.Errorf("update branch %q of %q: %w", branch, repo, err)
237}
238
239// DeleteBranch runs `git update-ref -d refs/heads/<branch> <old>`, which takes
240// the ref lock and refuses when the ref is not at old. A failure is told from
241// a moved or deleted branch by reading the branch again.
242//
243//hexcheck:ignore suspicious-business-logic-in-adapter maps a refused update-ref to the conflict error of the port
244func (g *Git) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
245 dir, err := g.repoDir(repo)
246 if err != nil {
247 return err
248 }
249 if _, err := domain.ParseBranchName(branch); err != nil {
250 return fmt.Errorf("delete branch of %q: %w", repo, err)
251 }
252 _, err = g.run(ctx, "--git-dir="+dir, "update-ref", "-d", headsRefPrefix+branch, old.String())
253 if err == nil {
254 return nil
255 }
256 switch cur, herr := g.BranchHead(ctx, repo, branch); {
257 case errors.Is(herr, domain.ErrNotFound):
258 return fmt.Errorf("branch %q of %q is gone: %w", branch, repo, domain.ErrConflict)
259 case herr == nil && cur != old:
260 return fmt.Errorf("branch %q of %q is at %s, not %s: %w", branch, repo, cur, old, domain.ErrConflict)
261 }
262 return fmt.Errorf("delete branch %q of %q: %w", branch, repo, err)
263}