carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package changelog
2
3import (
4 "bytes"
5 "fmt"
6 "os/exec"
7 "regexp"
8 "strconv"
9 "strings"
10 "time"
11)
12
13// ExecGit reads a local checkout with git. Tags, Amadan's tombstones
14// (refs/amadan/closed/*) and origin's branches must already be fetched.
15type ExecGit struct {
16 Dir string
17 tombstones map[string]Tag
18 live map[string]bool
19}
20
21func NewExecGit(dir string) (*ExecGit, error) {
22 g := &ExecGit{Dir: dir, tombstones: map[string]Tag{}, live: map[string]bool{}}
23 out, err := g.run("for-each-ref", "--format=%(refname)%00%(objectname)%00%(committerdate:iso-strict)", "refs/amadan/closed", "refs/remotes/origin")
24 if err != nil {
25 return nil, err
26 }
27 for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
28 f := strings.Split(line, "\x00")
29 if len(f) != 3 {
30 continue
31 }
32 if b, ok := strings.CutPrefix(f[0], "refs/amadan/closed/"); ok {
33 when, _ := time.Parse(time.RFC3339, f[2])
34 g.tombstones[b] = Tag{Name: b, SHA: f[1], Date: when}
35 } else if b, ok := strings.CutPrefix(f[0], "refs/remotes/origin/"); ok && b != "HEAD" {
36 g.live[b] = true
37 }
38 }
39 return g, nil
40}
41
42func (g *ExecGit) run(args ...string) (string, error) {
43 cmd := exec.Command("git", append([]string{"-C", g.Dir}, args...)...)
44 var stderr bytes.Buffer
45 cmd.Stderr = &stderr
46 out, err := cmd.Output()
47 if err != nil {
48 return "", fmt.Errorf("git %s: %v: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
49 }
50 return string(out), nil
51}
52
53var releaseTagRe = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
54
55func (g *ExecGit) Tags() ([]Tag, error) {
56 out, err := g.run("for-each-ref", "--format=%(refname:short)%00%(*objectname)%00%(objectname)%00%(creatordate:iso-strict)", "refs/tags")
57 if err != nil {
58 return nil, err
59 }
60 var tags []Tag
61 for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
62 f := strings.Split(line, "\x00")
63 if len(f) != 4 || !releaseTagRe.MatchString(f[0]) {
64 continue
65 }
66 sha := f[1]
67 if sha == "" {
68 sha = f[2] // a lightweight tag points at the commit itself
69 }
70 when, err := time.Parse(time.RFC3339, f[3])
71 if err != nil {
72 return nil, fmt.Errorf("tag %s: date %q: %w", f[0], f[3], err)
73 }
74 tags = append(tags, Tag{Name: f[0], SHA: sha, Date: when})
75 }
76 return tags, nil
77}
78
79const logFormat = "--format=%H%x00%P%x00%an%x00%ae%x00%cI%x00%B%x1e"
80
81func (g *ExecGit) log(args ...string) ([]Commit, error) {
82 out, err := g.run(append([]string{"log", logFormat}, args...)...)
83 if err != nil {
84 return nil, err
85 }
86 var commits []Commit
87 for _, rec := range strings.Split(out, "\x1e") {
88 rec = strings.TrimLeft(rec, "\n")
89 if rec == "" {
90 continue
91 }
92 f := strings.SplitN(rec, "\x00", 6)
93 if len(f) != 6 {
94 return nil, fmt.Errorf("git log: malformed record %q", rec)
95 }
96 when, err := time.Parse(time.RFC3339, f[4])
97 if err != nil {
98 return nil, err
99 }
100 commits = append(commits, Commit{
101 SHA: f[0], Parents: strings.Fields(f[1]),
102 AuthorName: f[2], AuthorEmail: f[3], Date: when,
103 Message: strings.TrimRight(f[5], "\n"),
104 })
105 }
106 return commits, nil
107}
108
109func (g *ExecGit) FirstParent(to string, exclude []string) ([]Commit, error) {
110 args := []string{"--first-parent", to}
111 for _, e := range exclude {
112 args = append(args, "^"+e)
113 }
114 return g.log(append(args, "--")...)
115}
116
117func (g *ExecGit) Range(from, to string) ([]Commit, error) {
118 return g.log("--no-merges", from+".."+to, "--")
119}
120
121func (g *ExecGit) Tombstone(branch string) (string, time.Time, bool) {
122 t, ok := g.tombstones[branch]
123 return t.SHA, t.Date, ok
124}
125
126func (g *ExecGit) LiveBranch(branch string) bool { return g.live[branch] }
127
128var hunkRe = regexp.MustCompile(`^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@`)
129
130// DocsChanges diffs a commit against its first parent (a root commit
131// against nothing) under docs/site and reports the lines each markdown
132// page gained.
133func (g *ExecGit) DocsChanges(c Commit) ([]DocsChange, error) {
134 var out string
135 var err error
136 if len(c.Parents) == 0 {
137 out, err = g.run("show", "--format=", "--unified=0", c.SHA, "--", "docs/site")
138 } else {
139 out, err = g.run("diff", "--unified=0", c.Parents[0], c.SHA, "--", "docs/site")
140 }
141 if err != nil {
142 return nil, err
143 }
144 var changes []DocsChange
145 var cur *DocsChange
146 for _, line := range strings.Split(out, "\n") {
147 if p, ok := strings.CutPrefix(line, "+++ "); ok {
148 cur = nil
149 p = strings.TrimPrefix(p, "b/")
150 slug, ok := strings.CutPrefix(p, "docs/site/")
151 if !ok || !strings.HasSuffix(slug, ".md") {
152 continue
153 }
154 changes = append(changes, DocsChange{Page: strings.TrimSuffix(slug, ".md")})
155 cur = &changes[len(changes)-1]
156 continue
157 }
158 if cur == nil {
159 continue
160 }
161 if m := hunkRe.FindStringSubmatch(line); m != nil {
162 start, _ := strconv.Atoi(m[1])
163 n := 1
164 if m[2] != "" {
165 n, _ = strconv.Atoi(m[2])
166 }
167 for i := 0; i < n; i++ {
168 cur.Added = append(cur.Added, start+i)
169 }
170 }
171 }
172 for i := range changes {
173 body, err := g.run("show", c.SHA+":docs/site/"+changes[i].Page+".md")
174 if err != nil {
175 return nil, err
176 }
177 changes[i].Body = body
178 }
179 return changes, nil
180}
181