| 1 | package changelog |
| 2 | |
| 3 | import ( |
| 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. |
| 15 | type ExecGit struct { |
| 16 | Dir string |
| 17 | tombstones map[string]Tag |
| 18 | live map[string]bool |
| 19 | } |
| 20 | |
| 21 | func 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 | |
| 42 | func (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 | |
| 53 | var releaseTagRe = regexp.MustCompile(`^v\d+\.\d+\.\d+$`) |
| 54 | |
| 55 | func (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 | |
| 79 | const logFormat = "--format=%H%x00%P%x00%an%x00%ae%x00%cI%x00%B%x1e" |
| 80 | |
| 81 | func (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 | |
| 109 | func (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 | |
| 117 | func (g *ExecGit) Range(from, to string) ([]Commit, error) { |
| 118 | return g.log("--no-merges", from+".."+to, "--") |
| 119 | } |
| 120 | |
| 121 | func (g *ExecGit) Tombstone(branch string) (string, time.Time, bool) { |
| 122 | t, ok := g.tombstones[branch] |
| 123 | return t.SHA, t.Date, ok |
| 124 | } |
| 125 | |
| 126 | func (g *ExecGit) LiveBranch(branch string) bool { return g.live[branch] } |
| 127 | |
| 128 | var 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. |
| 133 | func (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 | |