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