package gitutil import ( "fmt" "os/exec" "strings" "gitbay.org/gitbay/internal/toolpath" ) const zeroSHA = "0000000000000000000000000000000000000000" type CommitAuthor struct { SHA string Email string Day string // author date, YYYY-MM-DD } // RevListAuthors returns sha, author email, and author date for commits // reachable from new but not old, capped at max. Empty or zero old lists // from new alone. func RevListAuthors(dir, old, new string, max int) ([]CommitAuthor, error) { args := []string{"-C", dir, "log", fmt.Sprintf("--max-count=%d", max), "--format=%H%x00%ae%x00%as", new} if old != "" && old != zeroSHA { args = append(args, "^"+old) } out, err := exec.Command(toolpath.Look("git"), args...).Output() if err != nil { return nil, fmt.Errorf("rev-list authors: %w", err) } var authors []CommitAuthor for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { parts := strings.SplitN(line, "\x00", 3) if len(parts) != 3 { continue } authors = append(authors, CommitAuthor{SHA: parts[0], Email: parts[1], Day: parts[2]}) } return authors, nil } // Parents returns a commit's parent shas. func Parents(dir, sha string) []string { out, err := exec.Command(toolpath.Look("git"), "-C", dir, "log", "-1", "--format=%P", sha).Output() if err != nil { return nil } return strings.Fields(string(out)) } // LastCommitDate returns the committer date (YYYY-MM-DD) of the ref tip, // or "" for empty repos. func LastCommitDate(dir, ref string) string { out, err := exec.Command(toolpath.Look("git"), "-C", dir, "log", "-1", "--format=%cs", ref).Output() if err != nil { return "" } return strings.TrimSpace(string(out)) } // HasCommit reports whether sha names a commit object present in dir. func HasCommit(dir, sha string) bool { return exec.Command(toolpath.Look("git"), "-C", dir, "cat-file", "-e", sha+"^{commit}").Run() == nil } type CommitMsg struct { SHA string Message string AuthorName string AuthorEmail string } // RevListMessages returns sha and full message for commits reachable from // new but not old, newest first, capped at max. An empty or zero old (new // branch) lists from new alone, still capped. func RevListMessages(dir, old, new string, max int) ([]CommitMsg, error) { args := []string{"-C", dir, "rev-list", fmt.Sprintf("-n%d", max), "--format=%an%x01%ae%x01%B%x00", new} if old != "" && old != zeroSHA { args = append(args, "^"+old) } out, err := exec.Command(toolpath.Look("git"), args...).Output() if err != nil { return nil, fmt.Errorf("rev-list messages: %w", err) } var msgs []CommitMsg for _, chunk := range strings.Split(string(out), "\x00") { chunk = strings.TrimLeft(chunk, "\n") if chunk == "" { continue } header, body, ok := strings.Cut(chunk, "\n") if !ok || !strings.HasPrefix(header, "commit ") { continue } name, rest, ok := strings.Cut(body, "\x01") if !ok { continue } email, message, ok := strings.Cut(rest, "\x01") if !ok { continue } msgs = append(msgs, CommitMsg{ SHA: strings.TrimPrefix(header, "commit "), Message: strings.TrimSpace(message), AuthorName: name, AuthorEmail: email, }) } return msgs, nil } // Subjects returns the first line of each named commit's message, keyed // by sha. One git log for the whole set rather than one per sha: a page // of builds names a handful of distinct commits and a subprocess each // would show. --ignore-missing keeps a sha git cannot resolve from // failing the rest, because a build outlives the commit it ran on once a // branch is force-pushed; such a sha is simply absent from the map. func Subjects(dir string, shas []string) map[string]string { if len(shas) == 0 { return nil } args := append([]string{"-C", dir, "log", "--no-walk=unsorted", "--ignore-missing", "--format=%H%x00%s"}, shas...) args = append(args, "--") out, err := exec.Command(toolpath.Look("git"), args...).Output() if err != nil { return nil } subjects := map[string]string{} for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { if sha, subject, ok := strings.Cut(line, "\x00"); ok { subjects[sha] = subject } } return subjects }