Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

190 lines
5.5 KiB
Go

package git
import (
"bufio"
"bytes"
"context"
"fmt"
"regexp"
"strconv"
"strings"
gitcmd "go.kenn.io/kit/git/cmd"
)
// LogResult aggregates author-filtered counts from `git log --numstat` output.
type LogResult struct {
Commits int
LOCAdded int
LOCRemoved int
FilesChanged int
}
// AggregateLog runs `git log --numstat` filtered by author and window and
// returns total commits, lines added, lines removed, and files changed.
//
// The since/until timestamps should be RFC3339 strings; git accepts them
// directly via `--since`/`--until`. LOC counts from binary files (which
// numstat represents as `-\t-\t<path>`) are skipped, but the file still
// counts toward FilesChanged.
//
// If the window is empty or the author matches nothing, a zero-valued
// LogResult is returned with no error. Exec failures (bad repo path, git
// missing from PATH) are surfaced as errors.
func AggregateLog(
ctx context.Context, repo, authorEmail, since, until string,
) (LogResult, error) {
runner := gitcmd.New()
runner.NullGlobalConfig = false
out, stderr, err := runner.Run(
ctx, repo, nil,
"log",
"--numstat",
"--format=%H",
"--since="+since,
"--until="+until,
"--author="+authorEmailPattern(authorEmail),
)
if err != nil {
msg := strings.TrimSpace(string(stderr))
// An empty repo (initialized but no commits, or a worktree
// pointed at an unborn branch) is a normal state, not an
// error — there is simply no log to aggregate. Treat as a
// zero result so callers don't spam the user with errors
// for every checkout that hasn't been used yet.
if isEmptyRepoErr(msg) {
return LogResult{}, nil
}
if msg == "" {
return LogResult{}, fmt.Errorf("git log in %s: %w", repo, err)
}
return LogResult{}, fmt.Errorf(
"git log in %s: %w: %s", repo, err, msg,
)
}
return parseNumstat(out), nil
}
// isEmptyRepoErr reports whether a `git log` stderr message indicates
// the repo has no commits on the current branch — i.e., not a real
// failure, just nothing to aggregate. Both phrasings below have been
// stable across modern git versions; the second one shows up when
// HEAD points at a ref that doesn't yet exist (a freshly-created
// worktree on an unborn branch).
func isEmptyRepoErr(stderr string) bool {
return strings.Contains(stderr, "does not have any commits yet") ||
strings.Contains(stderr, "bad default revision 'HEAD'")
}
// parseNumstat walks `git log --numstat --format=%H` output and aggregates
// commit/LOC/file totals. The format emits a SHA line, a blank line, then
// zero or more numstat lines per commit:
//
// <40-hex-sha>
//
// <added>\t<removed>\t<path>
// -\t-\t<binary-path>
func parseNumstat(out []byte) LogResult {
var result LogResult
scanner := bufio.NewScanner(bytes.NewReader(out))
// Allow long paths; 1 MiB is generous but keeps a hard ceiling.
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
if isSHALine(line) {
result.Commits++
continue
}
added, removed, ok := parseNumstatLine(line)
if !ok {
continue
}
result.FilesChanged++
result.LOCAdded += added
result.LOCRemoved += removed
}
return result
}
// isSHALine reports whether s is a 40-character lowercase hex SHA, as emitted
// by `--format=%H`.
func isSHALine(s string) bool {
if len(s) != 40 {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= '0' && c <= '9':
case c >= 'a' && c <= 'f':
default:
return false
}
}
return true
}
// parseNumstatLine parses a single `added\tremoved\tpath` numstat row.
// Binary files use "-" for both counts; we treat those as zero LOC but
// still return ok=true so the file is counted.
func parseNumstatLine(line string) (added, removed int, ok bool) {
parts := strings.SplitN(line, "\t", 3)
if len(parts) < 3 {
return 0, 0, false
}
added, err := parseNumstatCount(parts[0])
if err != nil {
return 0, 0, false
}
removed, err = parseNumstatCount(parts[1])
if err != nil {
return 0, 0, false
}
return added, removed, true
}
// parseNumstatCount returns 0 for the binary marker "-" and parses digits
// otherwise. Anything else is a parse error so the caller can reject the line.
func parseNumstatCount(s string) (int, error) {
if s == "-" {
return 0, nil
}
return strconv.Atoi(s)
}
// authorEmailPattern returns a regex that matches authorEmail literally.
// `git log --author` interprets its value as a regex, so emails containing
// metacharacters like "." or "+" (e.g. "first.last+dev@example.com") match
// many unrelated authors. Anchoring an escaped pattern with `<...>` keeps
// the match scoped to the author header's "<email>" portion — git formats
// the author line as "Name <email>", so the angle brackets bound the email
// without needing a full ^...$ on the whole header.
func authorEmailPattern(email string) string {
return "<" + regexp.QuoteMeta(email) + ">"
}
// AuthorEmail returns `git config user.email` run from inside the repo,
// falling back to the global config. Returns "" if neither is set or git
// is not available.
func AuthorEmail(ctx context.Context, repo string) string {
localRunner := gitcmd.New()
localRunner.NullGlobalConfig = false
out, err := localRunner.Output(ctx, repo, "config", "user.email")
if err == nil {
if v := strings.TrimSpace(string(out)); v != "" {
return v
}
}
globalRunner := gitcmd.New()
globalRunner.NullGlobalConfig = false
out, err = globalRunner.Output(ctx, "", "config", "--global", "user.email")
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}