From 6852be77ffeb03e680c000233942a0cd82cbfc96 Mon Sep 17 00:00:00 2001 From: Ricardo Carneiro Date: Mon, 27 Apr 2026 13:50:32 -0300 Subject: [PATCH] feat(git): implement compact git status summary Co-Authored-By: Claude Sonnet 4.6 --- internal/cli/root.go | 3 +- internal/plugins/git/collect.go | 292 +++++++++++++++++++++++++++++++ internal/plugins/git/format.go | 293 ++++++++++++++++++++++++++++++++ internal/plugins/git/git.go | 35 ++-- 4 files changed, 611 insertions(+), 12 deletions(-) create mode 100644 internal/plugins/git/collect.go create mode 100644 internal/plugins/git/format.go diff --git a/internal/cli/root.go b/internal/cli/root.go index 70f477f..3b5ea0a 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -22,7 +22,8 @@ Each subcommand targets a specific stack: ctx react — React / TypeScript analysis Output is always UTF-8 markdown on stdout, suitable for piping into Claude.`, - SilenceUsage: true, + SilenceUsage: true, + SilenceErrors: true, // plugins print their own errors to stderr } // Execute runs the root command. Called by cmd/ctx/main.go. diff --git a/internal/plugins/git/collect.go b/internal/plugins/git/collect.go new file mode 100644 index 0000000..a02c181 --- /dev/null +++ b/internal/plugins/git/collect.go @@ -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 +} diff --git a/internal/plugins/git/format.go b/internal/plugins/git/format.go new file mode 100644 index 0000000..0038f4e --- /dev/null +++ b/internal/plugins/git/format.go @@ -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" +} diff --git a/internal/plugins/git/git.go b/internal/plugins/git/git.go index de37ed2..af2fb42 100644 --- a/internal/plugins/git/git.go +++ b/internal/plugins/git/git.go @@ -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 +}