ctx/internal/plugins/git/collect.go
Ricardo Carneiro 6852be77ff feat(git): implement compact git status summary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 13:50:32 -03:00

293 lines
5.8 KiB
Go

package git
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// --- Data types ---
type repoInfo struct {
branch string
upstream string // "origin/main" or "" if no upstream
ahead int
behind int
lastFetch time.Time
hasFetch bool
}
type commit struct {
hash string
unixTs int64
author string
subject string
}
type fileChange struct {
path string
added int
removed int
}
type workingTree struct {
modified []fileChange // unstaged (git diff --numstat)
staged []fileChange // staged (git diff --cached --numstat)
untracked []string
}
type gitData struct {
repo repoInfo
commits []commit
tree workingTree
}
// --- Git runner ---
// gitCmd runs a git command in dir and returns trimmed stdout.
// Returns an error if git exits non-zero.
func gitCmd(dir string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
var out, errBuf bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errBuf
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
}
return strings.TrimRight(out.String(), "\r\n"), nil
}
// --- Top-level collector ---
// collect gathers all git data in parallel. Returns a friendly error if dir
// is not inside a git repository.
func collect(dir string) (*gitData, error) {
// Verify we're in a repo before spawning goroutines.
if _, err := gitCmd(dir, "rev-parse", "--is-inside-work-tree"); err != nil {
return nil, fmt.Errorf("not a git repository (run from inside a git repo)")
}
var (
data gitData
mu sync.Mutex
wg sync.WaitGroup
errs []error
)
record := func(err error) {
if err != nil {
mu.Lock()
errs = append(errs, err)
mu.Unlock()
}
}
wg.Add(3)
go func() {
defer wg.Done()
info, err := collectRepoInfo(dir)
record(err)
if err == nil {
mu.Lock()
data.repo = info
mu.Unlock()
}
}()
go func() {
defer wg.Done()
commits, err := collectCommits(dir, 5)
record(err)
if err == nil {
mu.Lock()
data.commits = commits
mu.Unlock()
}
}()
go func() {
defer wg.Done()
tree, err := collectWorkingTree(dir)
record(err)
if err == nil {
mu.Lock()
data.tree = tree
mu.Unlock()
}
}()
wg.Wait()
if len(errs) > 0 {
return nil, errs[0]
}
return &data, nil
}
// --- Individual collectors ---
func collectRepoInfo(dir string) (repoInfo, error) {
var info repoInfo
branch, err := gitCmd(dir, "rev-parse", "--abbrev-ref", "HEAD")
if err != nil {
return info, err
}
info.branch = branch
// Ahead/behind vs upstream (fails gracefully if no upstream).
if ab, err := gitCmd(dir, "rev-list", "--left-right", "--count", "HEAD...@{u}"); err == nil {
parts := strings.Fields(ab)
if len(parts) == 2 {
info.ahead, _ = strconv.Atoi(parts[0])
info.behind, _ = strconv.Atoi(parts[1])
if u, err := gitCmd(dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"); err == nil {
info.upstream = u
}
}
}
// FETCH_HEAD mtime — find git dir first (handles worktrees, submodules).
if gitDir, err := gitCmd(dir, "rev-parse", "--git-dir"); err == nil {
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(dir, gitDir)
}
if fi, err := os.Stat(filepath.Join(gitDir, "FETCH_HEAD")); err == nil {
info.lastFetch = fi.ModTime()
info.hasFetch = true
}
}
return info, nil
}
func collectCommits(dir string, n int) ([]commit, error) {
out, err := gitCmd(dir, "log", fmt.Sprintf("-n%d", n), "--pretty=format:%h|%at|%an|%s")
if err != nil || out == "" {
// Empty repo or no commits — not a hard error.
return nil, nil
}
var commits []commit
for _, line := range strings.Split(out, "\n") {
if line == "" {
continue
}
// SplitN 4 so subject can contain "|".
parts := strings.SplitN(line, "|", 4)
if len(parts) != 4 {
continue
}
ts, _ := strconv.ParseInt(parts[1], 10, 64)
commits = append(commits, commit{
hash: parts[0],
unixTs: ts,
author: parts[2],
subject: parts[3],
})
}
return commits, nil
}
func collectWorkingTree(dir string) (workingTree, error) {
var (
tree workingTree
wg sync.WaitGroup
mu sync.Mutex
errs []error
)
record := func(err error) {
if err != nil {
mu.Lock()
errs = append(errs, err)
mu.Unlock()
}
}
wg.Add(3)
go func() {
defer wg.Done()
changes, err := parseNumstat(dir, false)
record(err)
mu.Lock()
tree.modified = changes
mu.Unlock()
}()
go func() {
defer wg.Done()
changes, err := parseNumstat(dir, true)
record(err)
mu.Lock()
tree.staged = changes
mu.Unlock()
}()
go func() {
defer wg.Done()
untracked, err := parseUntracked(dir)
record(err)
mu.Lock()
tree.untracked = untracked
mu.Unlock()
}()
wg.Wait()
if len(errs) > 0 {
return tree, errs[0]
}
return tree, nil
}
func parseNumstat(dir string, cached bool) ([]fileChange, error) {
args := []string{"diff", "--numstat"}
if cached {
args = append(args, "--cached")
}
out, err := gitCmd(dir, args...)
if err != nil || out == "" {
return nil, nil
}
var changes []fileChange
for _, line := range strings.Split(out, "\n") {
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 3)
if len(parts) != 3 {
continue
}
// Binary files show "-" — treat as 0.
added, _ := strconv.Atoi(parts[0])
removed, _ := strconv.Atoi(parts[1])
changes = append(changes, fileChange{
path: parts[2],
added: added,
removed: removed,
})
}
return changes, nil
}
func parseUntracked(dir string) ([]string, error) {
out, err := gitCmd(dir, "status", "--porcelain=v1")
if err != nil || out == "" {
return nil, nil
}
var untracked []string
for _, line := range strings.Split(out, "\n") {
if len(line) >= 3 && line[0] == '?' && line[1] == '?' {
untracked = append(untracked, strings.TrimSpace(line[3:]))
}
}
return untracked, nil
}