// 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 ` 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()) }