feat(git): implement compact git status summary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ricardo Carneiro 2026-04-27 13:50:32 -03:00
parent 69cadb4ea6
commit 6852be77ff
4 changed files with 611 additions and 12 deletions

View File

@ -23,6 +23,7 @@ Each subcommand targets a specific stack:
Output is always UTF-8 markdown on stdout, suitable for piping into Claude.`,
SilenceUsage: true,
SilenceErrors: true, // plugins print their own errors to stderr
}
// Execute runs the root command. Called by cmd/ctx/main.go.

View File

@ -0,0 +1,292 @@
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
}

View File

@ -0,0 +1,293 @@
package git
import (
"fmt"
"io"
"path/filepath"
"sort"
"strings"
"time"
)
// formatOutput writes the full markdown report to w.
func formatOutput(w io.Writer, data *gitData) {
fmt.Fprintln(w, "# Git Context")
fmt.Fprintln(w)
writeMeta(w, data)
fmt.Fprintln(w)
writeCommits(w, data.commits)
writeWorkingTree(w, data.tree)
writeDiffSummary(w, data.tree)
}
func writeMeta(w io.Writer, data *gitData) {
// Branch + upstream
branch := data.repo.branch
if data.repo.upstream != "" {
branch += fmt.Sprintf(" (ahead %d, behind %d vs %s)",
data.repo.ahead, data.repo.behind, data.repo.upstream)
}
fmt.Fprintf(w, "**branch:** %s\n", branch)
// Status summary
m, s, u := len(data.tree.modified), len(data.tree.staged), len(data.tree.untracked)
if m == 0 && s == 0 && u == 0 {
fmt.Fprintln(w, "**status:** clean")
} else {
var parts []string
if m > 0 {
parts = append(parts, fmt.Sprintf("%d modified", m))
}
if s > 0 {
parts = append(parts, fmt.Sprintf("%d staged", s))
}
if u > 0 {
parts = append(parts, fmt.Sprintf("%d untracked", u))
}
fmt.Fprintf(w, "**status:** %s\n", strings.Join(parts, ", "))
}
// Last fetched
if data.repo.hasFetch {
fmt.Fprintf(w, "**last fetched:** %s\n", relativeTime(data.repo.lastFetch))
}
}
func writeCommits(w io.Writer, commits []commit) {
if len(commits) == 0 {
return
}
fmt.Fprintf(w, "## Recent commits (last %d)\n", len(commits))
for _, c := range commits {
t := time.Unix(c.unixTs, 0)
fmt.Fprintf(w, "- `%s` (%s, %s) %s\n", c.hash, relativeTime(t), c.author, c.subject)
}
fmt.Fprintln(w)
}
func writeWorkingTree(w io.Writer, tree workingTree) {
if len(tree.modified) == 0 && len(tree.staged) == 0 && len(tree.untracked) == 0 {
return
}
fmt.Fprintln(w, "## Working tree")
if len(tree.modified) > 0 {
fmt.Fprintf(w, "### Modified (%d)\n", len(tree.modified))
for _, f := range tree.modified {
fmt.Fprintf(w, "- `%s` (+%d -%d)\n", f.path, f.added, f.removed)
}
fmt.Fprintln(w)
}
if len(tree.staged) > 0 {
fmt.Fprintf(w, "### Staged (%d)\n", len(tree.staged))
for _, f := range tree.staged {
fmt.Fprintf(w, "- `%s` (+%d -%d)\n", f.path, f.added, f.removed)
}
fmt.Fprintln(w)
}
if len(tree.untracked) > 0 {
fmt.Fprintf(w, "### Untracked (%d)\n", len(tree.untracked))
for _, u := range tree.untracked {
fmt.Fprintf(w, "- `%s`\n", u)
}
fmt.Fprintln(w)
}
}
func writeDiffSummary(w io.Writer, tree workingTree) {
all := mergeChanges(tree.modified, tree.staged)
if len(all) == 0 {
return
}
totalAdded, totalRemoved := 0, 0
for _, f := range all {
totalAdded += f.added
totalRemoved += f.removed
}
fmt.Fprintln(w, "## Diff summary (unstaged + staged)")
fmt.Fprintf(w, "**Total:** +%d -%d across %d file%s\n\n",
totalAdded, totalRemoved, len(all), plural(len(all)))
// By top-level directory (only if files span more than one).
byDir := groupByTopDir(all)
if len(byDir) > 1 {
fmt.Fprintln(w, "### By directory")
dirs := make([]string, 0, len(byDir))
for d := range byDir {
dirs = append(dirs, d)
}
sort.Strings(dirs)
for _, d := range dirs {
s := byDir[d]
label := d + "/"
if d == "." {
label = "root"
}
fmt.Fprintf(w, "- `%s`: +%d -%d\n", label, s.added, s.removed)
}
fmt.Fprintln(w)
}
// Notable changes — top 5 by total lines changed.
notable := topBySize(all, 5)
fmt.Fprintln(w, "### Notable changes")
for _, f := range notable {
kind := classify(f)
fmt.Fprintf(w, "- `%s`: %s (+%d lines) — %s\n", f.path, kind, f.added, reason(kind))
}
if len(all) > 5 {
more := len(all) - 5
fmt.Fprintf(w, "- ...and %d more file%s\n", more, plural(more))
}
}
// --- Helpers ---
type dirStat struct{ added, removed int }
func groupByTopDir(changes []fileChange) map[string]dirStat {
result := make(map[string]dirStat)
for _, f := range changes {
d := topDir(f.path)
s := result[d]
s.added += f.added
s.removed += f.removed
result[d] = s
}
return result
}
// topDir returns the first path component (directory), or "." for root files.
func topDir(path string) string {
// Normalize to forward slashes.
clean := filepath.ToSlash(path)
idx := strings.Index(clean, "/")
if idx == -1 {
return "."
}
return clean[:idx]
}
// mergeChanges deduplicates by path, summing stats for files that appear in both lists.
func mergeChanges(a, b []fileChange) []fileChange {
merged := make(map[string]fileChange)
for _, f := range a {
merged[f.path] = f
}
for _, f := range b {
if existing, ok := merged[f.path]; ok {
existing.added += f.added
existing.removed += f.removed
merged[f.path] = existing
} else {
merged[f.path] = f
}
}
result := make([]fileChange, 0, len(merged))
for _, f := range merged {
result = append(result, f)
}
sort.Slice(result, func(i, j int) bool { return result[i].path < result[j].path })
return result
}
// topBySize returns up to n files sorted by (added+removed) descending.
func topBySize(changes []fileChange, n int) []fileChange {
sorted := make([]fileChange, len(changes))
copy(sorted, changes)
sort.Slice(sorted, func(i, j int) bool {
ti := sorted[i].added + sorted[i].removed
tj := sorted[j].added + sorted[j].removed
return ti > tj
})
if n > len(sorted) {
n = len(sorted)
}
return sorted[:n]
}
func classify(f fileChange) string {
a, r := f.added, f.removed
switch {
case a > 30:
return "large change"
case a+r <= 10:
return "small change"
case r == 0:
return "pure addition"
case a == 0:
return "pure deletion"
case a > 20 && r > 20:
return "rewrite"
default:
if r > 0 {
ratio := float64(a) / float64(r)
if ratio >= 0.5 && ratio <= 2.0 {
return "refactor"
}
}
return "mixed change"
}
}
func reason(kind string) string {
switch kind {
case "large change":
return "likely new feature"
case "small change":
return "likely refactor or tweak"
case "pure addition":
return "new file or additions only"
case "pure deletion":
return "deletions only"
case "rewrite":
return "significant rewrite"
case "refactor":
return "likely refactor"
default:
return "mixed additions and removals"
}
}
// relativeTime returns a human-readable relative duration string.
func relativeTime(t time.Time) string {
d := time.Since(t)
if d < 0 {
d = -d
}
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
n := int(d.Minutes())
return fmt.Sprintf("%d minute%s ago", n, plural(n))
case d < 24*time.Hour:
n := int(d.Hours())
return fmt.Sprintf("%d hour%s ago", n, plural(n))
case d < 7*24*time.Hour:
n := int(d.Hours() / 24)
return fmt.Sprintf("%d day%s ago", n, plural(n))
case d < 30*24*time.Hour:
n := int(d.Hours() / (24 * 7))
return fmt.Sprintf("%d week%s ago", n, plural(n))
case d < 365*24*time.Hour:
n := int(d.Hours() / (24 * 30))
return fmt.Sprintf("%d month%s ago", n, plural(n))
default:
n := int(d.Hours() / (24 * 365))
return fmt.Sprintf("%d year%s ago", n, plural(n))
}
}
func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}

View File

@ -1,5 +1,4 @@
// Package git implements the ctx git plugin.
// Full implementation: prompt 1.
// Package git implements the ctx git plugin — compact git state summary.
package git
import (
@ -10,22 +9,36 @@ import (
)
func init() {
core.Register(&gitPlugin{})
core.Register(&plugin{})
}
type gitPlugin struct{}
type plugin struct{}
func (g *gitPlugin) Name() string { return "git" }
func (g *gitPlugin) Version() string { return "0.0.1" }
func (g *gitPlugin) ShortDescription() string { return "Git repository summary for Claude" }
func (p *plugin) Name() string { return "git" }
func (p *plugin) Version() string { return "0.1.0" }
func (p *plugin) ShortDescription() string { return "Summarize git state in compact markdown" }
func (g *gitPlugin) Command(ctx *core.Context) *cobra.Command {
func (p *plugin) Command(ctx *core.Context) *cobra.Command {
return &cobra.Command{
Use: "git",
Short: g.ShortDescription(),
Short: p.ShortDescription(),
Long: `Emit a compact markdown summary of the current git repository:
branch, ahead/behind upstream, recent commits, working tree changes,
and a diff summary grouped by directory.
Output goes to stdout. Pipe it into Claude or save it to a file.`,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Fprintln(ctx.Stderr, "Not implemented yet — coming in prompt 1")
return nil
return run(ctx)
},
}
}
func run(ctx *core.Context) error {
data, err := collect(ctx.WorkDir)
if err != nil {
fmt.Fprintln(ctx.Stderr, err.Error())
return fmt.Errorf("exit 1") // non-nil → os.Exit(1); SilenceErrors suppresses print
}
formatOutput(ctx.Stdout, data)
return nil
}