package changelog import ( "bytes" "fmt" "os/exec" "regexp" "strconv" "strings" "time" ) // ExecGit reads a local checkout with git. Tags, Amadan's tombstones // (refs/amadan/closed/*) and origin's branches must already be fetched. type ExecGit struct { Dir string tombstones map[string]Tag live map[string]bool } func NewExecGit(dir string) (*ExecGit, error) { g := &ExecGit{Dir: dir, tombstones: map[string]Tag{}, live: map[string]bool{}} out, err := g.run("for-each-ref", "--format=%(refname)%00%(objectname)%00%(committerdate:iso-strict)", "refs/amadan/closed", "refs/remotes/origin") if err != nil { return nil, err } for _, line := range strings.Split(strings.TrimSpace(out), "\n") { f := strings.Split(line, "\x00") if len(f) != 3 { continue } if b, ok := strings.CutPrefix(f[0], "refs/amadan/closed/"); ok { when, _ := time.Parse(time.RFC3339, f[2]) g.tombstones[b] = Tag{Name: b, SHA: f[1], Date: when} } else if b, ok := strings.CutPrefix(f[0], "refs/remotes/origin/"); ok && b != "HEAD" { g.live[b] = true } } return g, nil } func (g *ExecGit) run(args ...string) (string, error) { cmd := exec.Command("git", append([]string{"-C", g.Dir}, args...)...) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { return "", fmt.Errorf("git %s: %v: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String())) } return string(out), nil } var releaseTagRe = regexp.MustCompile(`^v\d+\.\d+\.\d+$`) func (g *ExecGit) Tags() ([]Tag, error) { out, err := g.run("for-each-ref", "--format=%(refname:short)%00%(*objectname)%00%(objectname)%00%(creatordate:iso-strict)", "refs/tags") if err != nil { return nil, err } var tags []Tag for _, line := range strings.Split(strings.TrimSpace(out), "\n") { f := strings.Split(line, "\x00") if len(f) != 4 || !releaseTagRe.MatchString(f[0]) { continue } sha := f[1] if sha == "" { sha = f[2] // a lightweight tag points at the commit itself } when, err := time.Parse(time.RFC3339, f[3]) if err != nil { return nil, fmt.Errorf("tag %s: date %q: %w", f[0], f[3], err) } tags = append(tags, Tag{Name: f[0], SHA: sha, Date: when}) } return tags, nil } const logFormat = "--format=%H%x00%P%x00%an%x00%ae%x00%cI%x00%B%x1e" func (g *ExecGit) log(args ...string) ([]Commit, error) { out, err := g.run(append([]string{"log", logFormat}, args...)...) if err != nil { return nil, err } var commits []Commit for _, rec := range strings.Split(out, "\x1e") { rec = strings.TrimLeft(rec, "\n") if rec == "" { continue } f := strings.SplitN(rec, "\x00", 6) if len(f) != 6 { return nil, fmt.Errorf("git log: malformed record %q", rec) } when, err := time.Parse(time.RFC3339, f[4]) if err != nil { return nil, err } commits = append(commits, Commit{ SHA: f[0], Parents: strings.Fields(f[1]), AuthorName: f[2], AuthorEmail: f[3], Date: when, Message: strings.TrimRight(f[5], "\n"), }) } return commits, nil } func (g *ExecGit) FirstParent(to string, exclude []string) ([]Commit, error) { args := []string{"--first-parent", to} for _, e := range exclude { args = append(args, "^"+e) } return g.log(append(args, "--")...) } func (g *ExecGit) Range(from, to string) ([]Commit, error) { return g.log("--no-merges", from+".."+to, "--") } func (g *ExecGit) Tombstone(branch string) (string, time.Time, bool) { t, ok := g.tombstones[branch] return t.SHA, t.Date, ok } func (g *ExecGit) LiveBranch(branch string) bool { return g.live[branch] } var hunkRe = regexp.MustCompile(`^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@`) // DocsChanges diffs a commit against its first parent (a root commit // against nothing) under docs/site and reports the lines each markdown // page gained. func (g *ExecGit) DocsChanges(c Commit) ([]DocsChange, error) { var out string var err error if len(c.Parents) == 0 { out, err = g.run("show", "--format=", "--unified=0", c.SHA, "--", "docs/site") } else { out, err = g.run("diff", "--unified=0", c.Parents[0], c.SHA, "--", "docs/site") } if err != nil { return nil, err } var changes []DocsChange var cur *DocsChange for _, line := range strings.Split(out, "\n") { if p, ok := strings.CutPrefix(line, "+++ "); ok { cur = nil p = strings.TrimPrefix(p, "b/") slug, ok := strings.CutPrefix(p, "docs/site/") if !ok || !strings.HasSuffix(slug, ".md") { continue } changes = append(changes, DocsChange{Page: strings.TrimSuffix(slug, ".md")}) cur = &changes[len(changes)-1] continue } if cur == nil { continue } if m := hunkRe.FindStringSubmatch(line); m != nil { start, _ := strconv.Atoi(m[1]) n := 1 if m[2] != "" { n, _ = strconv.Atoi(m[2]) } for i := 0; i < n; i++ { cur.Added = append(cur.Added, start+i) } } } for i := range changes { body, err := g.run("show", c.SHA+":docs/site/"+changes[i].Page+".md") if err != nil { return nil, err } changes[i].Body = body } return changes, nil }