ctx/internal/plugins/git/format.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

294 lines
6.9 KiB
Go

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"
}