Go 315 lines
// Package gitx provides the read-only git helpers eeco needs. It never
// commits, pushes, or mutates a repository: every function here only
// inspects. Anything that would write to git history is deliberately
// absent so the engine cannot do it even by mistake (Constraint 6).
package gitx
import (
"errors"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
// ErrUnavailable is returned when the git binary cannot be found. A
// caller that needs git should treat this as "blocked" (contract code
// 2), not as a failure.
var ErrUnavailable = errors.New("git not available on PATH")
// Available reports whether a usable git binary is on PATH.
func Available() bool {
_, err := exec.LookPath("git")
return err == nil
}
// UserName returns the configured `git config user.name` at root,
// trimmed of surrounding whitespace. An unset user.name is not an
// error: `git config <key>` exits non-zero when the key is missing, and
// that case returns "" with a nil error so callers can fall back to
// another source. A missing git binary returns ErrUnavailable. The call
// is strictly read-only.
func UserName(root string) (string, error) {
if !Available() {
return "", ErrUnavailable
}
cmd := exec.Command("git", "config", "user.name")
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return "", nil
}
return "", wrap("git config user.name", err)
}
return strings.TrimSpace(string(out)), nil
}
// TrackedFiles returns every path git tracks at root, repo-relative and
// slash-separated. It runs `git ls-files -z` with root as the working
// directory and is strictly read-only.
func TrackedFiles(root string) ([]string, error) {
if !Available() {
return nil, ErrUnavailable
}
cmd := exec.Command("git", "ls-files", "-z")
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
return nil, wrap("git ls-files", err)
}
var files []string
for _, p := range strings.Split(string(out), "\x00") {
if p == "" {
continue
}
files = append(files, filepath.ToSlash(p))
}
return files, nil
}
// HeadSHA returns the current commit SHA at root. It is read-only.
func HeadSHA(root string) (string, error) {
if !Available() {
return "", ErrUnavailable
}
cmd := exec.Command("git", "rev-parse", "HEAD")
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
return "", wrap("git rev-parse", err)
}
return strings.TrimSpace(string(out)), nil
}
// ChangesSince returns a one-line commit log and a diffstat for the
// range since..HEAD. It is strictly read-only (log + diff, no write
// surface). An empty since yields the changes for HEAD alone.
func ChangesSince(root, since string) (log, stat string, err error) {
if !Available() {
return "", "", ErrUnavailable
}
rng := "HEAD"
if since != "" {
rng = since + "..HEAD"
}
logCmd := exec.Command("git", "log", "--oneline", rng)
logCmd.Dir = root
lo, lerr := logCmd.Output()
if lerr != nil {
return "", "", wrap("git log", lerr)
}
statCmd := exec.Command("git", "diff", "--stat", rng)
statCmd.Dir = root
so, serr := statCmd.Output()
if serr != nil {
return "", "", wrap("git diff", serr)
}
return strings.TrimSpace(string(lo)), strings.TrimSpace(string(so)), nil
}
// RemoteTags returns the tag names advertised by a remote, via `git
// ls-remote --tags`. root sets the working directory; remote, when
// non-empty, is the explicit remote name or URL to query (empty uses
// the repository's default remote). It is strictly read-only (no fetch,
// no write surface) and reaches the network only to list refs. Peeled
// tag entries (the `refs/tags/x^{}` dereference lines) are collapsed to
// the bare tag name, and the result is de-duplicated. An empty result
// with a nil error means the remote advertised no tags; ErrUnavailable
// means git itself is missing.
func RemoteTags(root, remote string) ([]string, error) {
if !Available() {
return nil, ErrUnavailable
}
args := []string{"ls-remote", "--tags"}
if remote != "" {
args = append(args, remote)
}
cmd := exec.Command("git", args...)
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
return nil, wrap("git ls-remote", err)
}
seen := map[string]struct{}{}
var tags []string
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
_, ref, ok := strings.Cut(line, "\t")
if !ok {
continue
}
name, ok := strings.CutPrefix(strings.TrimSpace(ref), "refs/tags/")
if !ok {
continue
}
name = strings.TrimSuffix(name, "^{}")
if _, dup := seen[name]; dup || name == "" {
continue
}
seen[name] = struct{}{}
tags = append(tags, name)
}
return tags, nil
}
// LatestSemverTag returns the most recent semver-shaped tag reachable
// from HEAD at root, as `vX.Y.Z`. The match is restricted to tags
// matching the strict shape `v` + three dot-separated unsigned integers;
// pre-release / build-metadata suffixes are skipped. Ordering uses git's
// own `--sort=-v:refname` (descending semver). Returns an empty string
// with a nil error when no semver-shaped tag is reachable (a fresh repo
// or one carrying only foreign tags). ErrUnavailable means git itself
// is missing.
func LatestSemverTag(root string) (string, error) {
if !Available() {
return "", ErrUnavailable
}
cmd := exec.Command("git", "tag", "--list", "v*", "--merged", "HEAD", "--sort=-v:refname")
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
msg := strings.ToLower(strings.TrimSpace(string(ee.Stderr)))
if strings.Contains(msg, "head") || strings.Contains(msg, "no such ref") {
return "", nil
}
}
return "", wrap("git tag", err)
}
re := regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if re.MatchString(line) {
return line, nil
}
}
return "", nil
}
// SemverTags returns every semver-shaped tag reachable from HEAD at
// root, as `vX.Y.Z`, ordered descending (newest first). The match is
// restricted to the strict shape `v` + three dot-separated unsigned
// integers; pre-release / build-metadata suffixes are skipped, exactly
// as LatestSemverTag does. Ordering uses git's own `--sort=-v:refname`.
// Returns an empty slice with a nil error when no semver-shaped tag is
// reachable (a fresh repo, or one carrying only foreign tags).
// ErrUnavailable means git itself is missing. The call is read-only.
func SemverTags(root string) ([]string, error) {
if !Available() {
return nil, ErrUnavailable
}
cmd := exec.Command("git", "tag", "--list", "v*", "--merged", "HEAD", "--sort=-v:refname")
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
msg := strings.ToLower(strings.TrimSpace(string(ee.Stderr)))
if strings.Contains(msg, "head") || strings.Contains(msg, "no such ref") {
return nil, nil
}
}
return nil, wrap("git tag", err)
}
re := regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
var tags []string
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if re.MatchString(line) {
tags = append(tags, line)
}
}
return tags, nil
}
// LastCommitDate returns the committer date of the most recent commit
// at root that touched relPath, via `git log -1 --format=%cI`. relPath
// is repo-relative and slash-separated. ok is false — with a nil error —
// when relPath has no commit history at all (untracked, or never
// committed), so the caller has nothing to date it against. The call is
// strictly read-only (a log query, no write surface). ErrUnavailable
// means the git binary itself is missing.
func LastCommitDate(root, relPath string) (date time.Time, ok bool, err error) {
if !Available() {
return time.Time{}, false, ErrUnavailable
}
cmd := exec.Command("git", "log", "-1", "--format=%cI", "--", relPath)
cmd.Dir = root
out, oerr := cmd.Output()
if oerr != nil {
return time.Time{}, false, wrap("git log", oerr)
}
s := strings.TrimSpace(string(out))
if s == "" {
return time.Time{}, false, nil
}
t, perr := time.Parse(time.RFC3339, s)
if perr != nil {
return time.Time{}, false, errors.New("git log: parse commit date: " + perr.Error())
}
return t, true, nil
}
// IsDirty reports whether the working tree at root carries uncommitted work —
// staged or unstaged changes to tracked files, or untracked files — via the
// `git status --porcelain` non-empty test. It is strictly read-only.
// ErrUnavailable means the git binary itself is missing.
func IsDirty(root string) (bool, error) {
if !Available() {
return false, ErrUnavailable
}
cmd := exec.Command("git", "status", "--porcelain")
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
return false, wrap("git status", err)
}
return strings.TrimSpace(string(out)) != "", nil
}
// LastCommitTime returns the committer time of HEAD at root. ok is false (with
// a nil error) when the repository has no commits yet (a fresh repo with no
// HEAD), so the caller has nothing to date against. It is strictly read-only (a
// log query, no write surface). ErrUnavailable means the git binary itself is
// missing.
func LastCommitTime(root string) (date time.Time, ok bool, err error) {
if !Available() {
return time.Time{}, false, ErrUnavailable
}
cmd := exec.Command("git", "log", "-1", "--format=%cI")
cmd.Dir = root
out, oerr := cmd.Output()
if oerr != nil {
var ee *exec.ExitError
if errors.As(oerr, &ee) {
// No commits / no HEAD yet: not an error for the caller.
return time.Time{}, false, nil
}
return time.Time{}, false, wrap("git log", oerr)
}
s := strings.TrimSpace(string(out))
if s == "" {
return time.Time{}, false, nil
}
t, perr := time.Parse(time.RFC3339, s)
if perr != nil {
return time.Time{}, false, errors.New("git log: parse commit date: " + perr.Error())
}
return t, true, nil
}
func wrap(what string, err error) error {
var ee *exec.ExitError
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
return errors.New(what + ": " + strings.TrimSpace(string(ee.Stderr)))
}
return errors.New(what + ": " + err.Error())
}