feat(git): implement compact git status summary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
69cadb4ea6
commit
6852be77ff
@ -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.
|
||||
|
||||
292
internal/plugins/git/collect.go
Normal file
292
internal/plugins/git/collect.go
Normal 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
|
||||
}
|
||||
293
internal/plugins/git/format.go
Normal file
293
internal/plugins/git/format.go
Normal 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"
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user