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`.
42func (g *Git) BranchHead(ctx context.Context, repo domain.RepoName, branch string) (domain.ObjectID, error) {
43 dir, err := g.repoDir(repo)
44 if err != nil {
45 return domain.ObjectID{}, err
46 }
47 if _, err := domain.ParseBranchName(branch); err != nil {
48 return domain.ObjectID{}, fmt.Errorf("branch %q of %q: %w", branch, repo, domain.ErrNotFound)
49 }
50 var id domain.ObjectID
51 err = g.cat.do(ctx, dir, func(c *catConn) error {
52 h, err := c.info(headsRefPrefix + branch + "^{commit}")
53 if err != nil {
54 return err
55 }
56 id, err = domain.ParseObjectID(h.ID)
57 if err != nil {
58 return c.fail(err)
59 }
60 return nil
61 })
62 if notFound(err) {
63 return domain.ObjectID{}, fmt.Errorf("branch %q of %q: %w", branch, repo, domain.ErrNotFound)
64 }
65 if err != nil {
66 return domain.ObjectID{}, fmt.Errorf("branch %q of %q: %w", branch, repo, err)
67 }
68 return id, nil
69}
70
71// HasCommit asks `cat-file` for the type of the object.
72func (g *Git) HasCommit(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (bool, error) {
73 dir, err := g.repoDir(repo)
74 if err != nil {
75 return false, err
76 }
77 found := false
78 err = g.cat.do(ctx, dir, func(c *catConn) error {
79 h, err := c.info(id.String())
80 if notFound(err) {
81 return nil
82 }
83 if err != nil {
84 return err
85 }
86 found = h.Type == "commit"
87 return nil
88 })
89 if err != nil {
90 return false, fmt.Errorf("look up commit %s in %q: %w", id, repo, err)
91 }
92 return found, nil
93}
94
95// MergeBase runs `git merge-base`, which exits 1 when there is none.
96func (g *Git) MergeBase(ctx context.Context, repo domain.RepoName, a, b domain.ObjectID) (domain.ObjectID, error) {
97 dir, err := g.repoDir(repo)
98 if err != nil {
99 return domain.ObjectID{}, err
100 }
101 out, err := g.run(ctx, readArgs(dir, "merge-base", a.String(), b.String())...)
102 switch {
103 case exitCode(err) == 1:
104 return domain.ObjectID{}, fmt.Errorf("%s and %s share no history: %w", a, b, domain.ErrNotFound)
105 case err != nil:
106 return domain.ObjectID{}, fmt.Errorf("merge base in %q: %w", repo, err)
107 }
108 return parseID(out)
109}
110
111// IsAncestor runs `git merge-base --is-ancestor`, which exits 1 for "no".
112func (g *Git) IsAncestor(ctx context.Context, repo domain.RepoName, ancestor, descendant domain.ObjectID) (bool, error) {
113 dir, err := g.repoDir(repo)
114 if err != nil {
115 return false, err
116 }
117 _, err = g.run(ctx, readArgs(dir, "merge-base", "--is-ancestor", ancestor.String(), descendant.String())...)
118 switch {
119 case err == nil:
120 return true, nil
121 case exitCode(err) == 1:
122 return false, nil
123 }
124 return false, fmt.Errorf("ancestry in %q: %w", repo, err)
125}
126
127// ListRange runs `git log <head> ^<exclude>`.
128func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) {
129 var excluded []string
130 if !exclude.IsZero() {
131 excluded = []string{"^" + exclude.String()}
132 }
133 return g.logCommits(ctx, repo, head, limit, nil, excluded)
134}
135
136// logCommits runs `git log` for the commits reachable from head: revArgs go
137// before the head (options such as --not), excluded after it. Newest first,
138// at most limit; more says there were others.
139func (g *Git) logCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int, revArgs, excluded []string) ([]domain.Commit, bool, error) {
140 if limit < 1 || limit > ports.MaxCommitLimit {
141 return nil, false, fmt.Errorf("list range: %w: limit %d", domain.ErrInvalidPageSize, limit)
142 }
143 dir, err := g.repoDir(repo)
144 if err != nil {
145 return nil, false, err
146 }
147 args := readArgs(dir, "log", "-z", "-n", strconv.Itoa(limit+1), "--format="+commitFormat)
148 args = append(args, revArgs...)
149 args = append(args, "--end-of-options", head.String())
150 args = append(args, excluded...)
151 var (
152 commits []domain.Commit
153 more bool
154 )
155 err = g.pipe(ctx, args, func(r *bufio.Reader) error {
156 for {
157 c, err := readCommit(r)
158 if errors.Is(err, io.EOF) {
159 return nil
160 }
161 if err != nil {
162 return err
163 }
164 if len(commits) == limit {
165 more = true
166 return errStop
167 }
168 commits = append(commits, c)
169 }
170 })
171 if err != nil {
172 return nil, false, fmt.Errorf("commits of %s in %q: %w", head, repo, err)
173 }
174 return commits, more, nil
175}
176
177// DiffCommits reads the diff between two commits like GetCommit does.
178func (g *Git) DiffCommits(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error) {
179 dir, err := g.repoDir(repo)
180 if err != nil {
181 return domain.DiffStats{}, domain.Diff{}, err
182 }
183 stats, diff, err := g.diffTrees(ctx, dir, base.String(), head.String(), limits)
184 if err != nil {
185 return domain.DiffStats{}, domain.Diff{}, fmt.Errorf("diff %s..%s in %q: %w", base, head, repo, err)
186 }
187 return stats, diff, nil
188}
189
190// CommitTree runs `git rev-parse <commit>^{tree}`.
191func (g *Git) CommitTree(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (domain.ObjectID, error) {
192 dir, err := g.repoDir(repo)
193 if err != nil {
194 return domain.ObjectID{}, err
195 }
196 out, err := g.run(ctx, readArgs(dir, "rev-parse", "--verify", id.String()+"^{tree}")...)
197 if err != nil {
198 return domain.ObjectID{}, fmt.Errorf("tree of %s in %q: %w", id, repo, err)
199 }
200 return parseID(out)
201}
202
203// WriteCommit runs `git hash-object -t commit -w --stdin`, which also checks
204// that the object is a well-formed commit.
205func (g *Git) WriteCommit(ctx context.Context, repo domain.RepoName, object []byte) (domain.ObjectID, error) {
206 dir, err := g.repoDir(repo)
207 if err != nil {
208 return domain.ObjectID{}, err
209 }
210 out, err := g.runInput(ctx, bytes.NewReader(object), "--git-dir="+dir, "hash-object", "-t", "commit", "-w", "--stdin")
211 if err != nil {
212 return domain.ObjectID{}, fmt.Errorf("write commit in %q: %w", repo, err)
213 }
214 return parseID(out)
215}
216
217// UpdateBranch runs `git update-ref refs/heads/<branch> <new> <old>`, which
218// takes the ref lock and refuses when the ref is not at old. A failure is
219// told from a moved branch by reading the branch again.
220func (g *Git) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old, newID domain.ObjectID) error {
221 dir, err := g.repoDir(repo)
222 if err != nil {
223 return err
224 }
225 if _, err := domain.ParseBranchName(branch); err != nil {
226 return fmt.Errorf("update branch of %q: %w", repo, err)
227 }
228 _, err = g.run(ctx, "--git-dir="+dir, "update-ref", headsRefPrefix+branch, newID.String(), old.String())
229 if err == nil {
230 return nil
231 }
232 if cur, herr := g.BranchHead(ctx, repo, branch); herr == nil && cur != old {
233 return fmt.Errorf("branch %q of %q is at %s, not %s: %w", branch, repo, cur, old, domain.ErrConflict)
234 }
235 return fmt.Errorf("update branch %q of %q: %w", branch, repo, err)
236}
237
238// DeleteBranch runs `git update-ref -d refs/heads/<branch> <old>`, which takes
239// the ref lock and refuses when the ref is not at old. A failure is told from
240// a moved or deleted branch by reading the branch again.
241func (g *Git) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error {
242 dir, err := g.repoDir(repo)
243 if err != nil {
244 return err
245 }
246 if _, err := domain.ParseBranchName(branch); err != nil {
247 return fmt.Errorf("delete branch of %q: %w", repo, err)
248 }
249 _, err = g.run(ctx, "--git-dir="+dir, "update-ref", "-d", headsRefPrefix+branch, old.String())
250 if err == nil {
251 return nil
252 }
253 switch cur, herr := g.BranchHead(ctx, repo, branch); {
254 case errors.Is(herr, domain.ErrNotFound):
255 return fmt.Errorf("branch %q of %q is gone: %w", branch, repo, domain.ErrConflict)
256 case herr == nil && cur != old:
257 return fmt.Errorf("branch %q of %q is at %s, not %s: %w", branch, repo, cur, old, domain.ErrConflict)
258 }
259 return fmt.Errorf("delete branch %q of %q: %w", branch, repo, err)
260}