1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli
4
5import (
6 "bufio"
7 "context"
8 "errors"
9 "fmt"
10 "io"
11 "strconv"
12 "strings"
13 "time"
14
15 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/ports"
17)
18
19// CountCommits runs `git rev-list --count`, from the commit graph when the
20// repository has one.
21//
22//hexcheck:ignore suspicious-business-logic-in-adapter reads the count git prints
23func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error) {
24 if head.IsZero() {
25 return 0, fmt.Errorf("count commits: %w: no head", domain.ErrInvalid)
26 }
27 dir, err := g.repoDir(repo)
28 if err != nil {
29 return 0, err
30 }
31 out, err := g.run(ctx, readArgs(dir, "rev-list", "--count", "--end-of-options", head.String())...)
32 if err != nil {
33 if exitCode(err) == 128 {
34 return 0, fmt.Errorf("count commits of %q: %w", repo, domain.ErrNotFound)
35 }
36 return 0, fmt.Errorf("count commits of %q: %w", repo, err)
37 }
38 n, err := strconv.Atoi(strings.TrimSpace(out))
39 if err != nil {
40 return 0, fmt.Errorf("count commits of %q: git printed %q", repo, strings.TrimSpace(out))
41 }
42 return n, nil
43}
44
45// CommitTimes runs `git rev-list --since-as-filter --format=%ct`. The filter
46// looks at every reachable commit: --since stops the walk at the first older
47// one, and misses newer commits behind it when dates are out of order.
48//
49//hexcheck:ignore suspicious-business-logic-in-adapter reads the dates git prints; the bound comes from the port
50func (g *Git) CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error) {
51 if head.IsZero() {
52 return nil, false, fmt.Errorf("commit times: %w: no head", domain.ErrInvalid)
53 }
54 dir, err := g.repoDir(repo)
55 if err != nil {
56 return nil, false, err
57 }
58 args := readArgs(dir, "rev-list", "--no-commit-header", "--format=%ct",
59 "--since-as-filter=@"+strconv.FormatInt(since.Unix(), 10), "--end-of-options", head.String())
60 var (
61 times []time.Time
62 truncated bool
63 )
64 err = g.pipe(ctx, args, func(r *bufio.Reader) error {
65 for {
66 line, err := r.ReadString('\n')
67 if errors.Is(err, io.EOF) && line == "" {
68 return nil
69 }
70 if err != nil && !errors.Is(err, io.EOF) {
71 return err
72 }
73 sec, perr := strconv.ParseInt(strings.TrimSpace(line), 10, 64)
74 if perr != nil {
75 return fmt.Errorf("git rev-list: unexpected line %q", truncateText(line))
76 }
77 if len(times) == ports.MaxCommitTimes {
78 truncated = true
79 return errStop
80 }
81 times = append(times, time.Unix(sec, 0).UTC())
82 }
83 })
84 if err != nil {
85 if exitCode(err) == 128 {
86 return nil, false, fmt.Errorf("commit times of %q: %w", repo, domain.ErrNotFound)
87 }
88 return nil, false, fmt.Errorf("commit times of %q: %w", repo, err)
89 }
90 return times, truncated, nil
91}
92
93// LatestTag runs `git for-each-ref --sort=-creatordate --count=1` on the tags.
94//
95//hexcheck:ignore suspicious-business-logic-in-adapter reads the line git prints
96func (g *Git) LatestTag(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error) {
97 dir, err := g.repoDir(repo)
98 if err != nil {
99 return domain.TagInfo{}, false, err
100 }
101 out, err := g.run(ctx, readArgs(dir, "for-each-ref", "--sort=-creatordate", "--count=1",
102 "--format=%(refname:strip=2)%00%(creatordate:unix)%00%(objecttype)%00%(objectname)%00%(*objecttype)%00%(*objectname)", tagsRefPrefix)...)
103 if err != nil {
104 return domain.TagInfo{}, false, fmt.Errorf("latest tag of %q: %w", repo, err)
105 }
106 line := strings.TrimSuffix(out, "\n")
107 if line == "" {
108 return domain.TagInfo{}, false, nil
109 }
110 f := strings.Split(line, "\x00")
111 if len(f) != 6 || f[0] == "" {
112 return domain.TagInfo{}, false, fmt.Errorf("latest tag of %q: unexpected line %q", repo, truncateText(line))
113 }
114 sec, err := strconv.ParseInt(f[1], 10, 64)
115 if err != nil {
116 return domain.TagInfo{}, false, fmt.Errorf("latest tag of %q: unexpected line %q", repo, truncateText(line))
117 }
118 tag := domain.TagInfo{Name: domain.ValidGitText(f[0]), At: time.Unix(sec, 0).UTC()}
119 // A lightweight tag names its commit; an annotated one is peeled to the
120 // end of its chain of tags.
121 commit := ""
122 switch {
123 case f[2] == "commit":
124 commit = f[3]
125 case f[4] == "commit":
126 commit = f[5]
127 }
128 if commit != "" {
129 if tag.CommitID, err = domain.ParseObjectID(commit); err != nil {
130 return domain.TagInfo{}, false, fmt.Errorf("latest tag of %q: %w", repo, err)
131 }
132 }
133 return tag, true, nil
134}