processor/history_author_timeline.go GO 454 lines View on github.com → Search inside
1// SPDX-License-Identifier: MIT23package processor45import (6	"encoding/csv"7	"fmt"8	"os"9	"slices"10	"strings"11	"time"1213	jsoniter "github.com/json-iterator/go"14	glanguage "golang.org/x/text/language"15	gmessage "golang.org/x/text/message"16)1718// authorTimelineSparkCells is the fixed width of the Activity sparkline cell19// in the 79-column tabular report. The fixed-resolution per-bucket series is20// downsampled to this many cells.21const authorTimelineSparkCells = 242223// authorTimelineTopN is the number of authors shown individually before the24// rest are folded into a single "others (N)" row whose sparkline sums their25// activity — mirroring the --by-author rollup rather than either hard-capping26// (hides the tail) or listing every author (a wall of sparklines). CSV/JSON27// stay uncapped.28const authorTimelineTopN = 152930// authorTimelineBucket is one bucket's worth of accumulator state for one31// author.32type authorTimelineBucket struct {33	Commits   int34	CodeDelta int6435}3637// authorTimelineRow is the materialised per-author result.38type authorTimelineRow struct {39	Name         string40	Email        string41	TotalCommits int42	CodeDelta    int6443	Series       []authorTimelineBucket44}4546// authorTimelineEvent records one observed commit's contribution so the47// observer can bin it under the real window's Bucketing in Finalise (the48// engine doesn't expose the window until after the walk).49type authorTimelineEvent struct {50	Author    authorID51	When      time.Time52	CodeDelta int6453}5455// historyAuthorTimelineObserver collects per-commit events during the walk56// and bins them into per-(author, bucket) totals on Finalise. It implements57// MailmapObserver to pick up the mailmap without paying for the full58// start-tree classification — the report tracks deltas, not last-toucher59// attribution, so per-line baseline state is not needed.60type historyAuthorTimelineObserver struct {61	registry    *authorRegistry62	events      []authorTimelineEvent63	bucketCount int6465	bucket Bucketing66	window HistoryWindow67	rows   []authorTimelineRow68}6970func newHistoryAuthorTimelineObserver(buckets int) *historyAuthorTimelineObserver {71	if buckets <= 0 {72		buckets = 6073	}74	return &historyAuthorTimelineObserver{75		registry:    newAuthorRegistry(nil),76		bucketCount: buckets,77	}78}7980// SetMailmap satisfies MailmapObserver — rebuilds the registry with the81// repo's .mailmap so the timeline folds identities the same way the author82// rollup does.83func (o *historyAuthorTimelineObserver) SetMailmap(mm *mailmap) {84	o.registry = newAuthorRegistry(mm)85}8687func (o *historyAuthorTimelineObserver) Observe(c CommitInfo, changes []FileChange) {88	aid := o.registry.intern(c.Author, c.Email)8990	var delta int6491	for _, fc := range changes {92		added := splitAddedCodeLines(fc.AddedRanges, fc.LineTypes)93		removed := splitRemovedCodeLines(fc.RemovedRanges, fc.RemovedLineTypes)94		delta += int64(added) - int64(removed)95	}96	o.events = append(o.events, authorTimelineEvent{97		Author:    aid,98		When:      c.When,99		CodeDelta: delta,100	})101}102103func (o *historyAuthorTimelineObserver) Finalise(window HistoryWindow, head HeadSnapshot) {104	o.window = window105	o.bucket = NewBucketing(window.From, window.To, o.bucketCount)106107	series := map[authorID][]authorTimelineBucket{}108	for _, ev := range o.events {109		s := series[ev.Author]110		if s == nil {111			s = make([]authorTimelineBucket, o.bucket.N)112			series[ev.Author] = s113		}114		idx := o.bucket.Index(ev.When)115		s[idx].Commits++116		s[idx].CodeDelta += ev.CodeDelta117	}118119	rows := make([]authorTimelineRow, 0, len(series))120	for aid, s := range series {121		if aid == sentinelAuthorID {122			continue123		}124		rec := o.registry.record(aid)125		row := authorTimelineRow{126			Name:   rec.Name,127			Email:  rec.Email,128			Series: s,129		}130		for _, b := range s {131			row.TotalCommits += b.Commits132			row.CodeDelta += b.CodeDelta133		}134		rows = append(rows, row)135	}136137	slices.SortFunc(rows, func(a, b authorTimelineRow) int {138		if a.TotalCommits != b.TotalCommits {139			if a.TotalCommits < b.TotalCommits {140				return 1141			}142			return -1143		}144		return strings.Compare(a.Name, b.Name)145	})146	o.rows = rows147}148149// splitAddedCodeLines returns the count of added lines classified as code by150// the new blob's LineTypes vector. Mirrors splitChurnByType but only returns151// the code component.152func splitAddedCodeLines(added []LineRange, lineTypes []LineType) int {153	code := 0154	for _, r := range added {155		for i := 0; i < r.Count; i++ {156			ln := r.Start - 1 + i157			if ln < 0 || ln >= len(lineTypes) {158				continue159			}160			if lineTypes[ln] == LINE_CODE {161				code++162			}163		}164	}165	return code166}167168// splitRemovedCodeLines counts removed lines classified as code by the OLD169// blob's LineTypes vector. RemovedRanges are in old-blob (pre-diff) line170// coordinates, so they index removedLineTypes directly. Removed-side mirror171// of splitAddedCodeLines — together they yield a symmetric code-only delta.172// Returns 0 when the old blob could not be classified.173func splitRemovedCodeLines(removed []LineRange, removedLineTypes []LineType) int {174	code := 0175	for _, r := range removed {176		for i := 0; i < r.Count; i++ {177			ln := r.Start - 1 + i178			if ln < 0 || ln >= len(removedLineTypes) {179				continue180			}181			if removedLineTypes[ln] == LINE_CODE {182				code++183			}184		}185	}186	return code187}188189// runAuthorTimelineReport is the dispatch entry point called from Process()190// when --by-author --timeline is set. Opens the repo, walks the window with191// the configured bucket count, and writes the chosen format.192func runAuthorTimelineReport(repoPath string) error {193	observer := newHistoryAuthorTimelineObserver(HistoryBuckets)194	if _, err := runHistory(repoPath, observer); err != nil {195		return err196	}197	out, err := renderAuthorTimeline(observer)198	if err != nil {199		return err200	}201	if FileOutput == "" {202		fmt.Print(out)203	} else {204		if err := os.WriteFile(FileOutput, []byte(out), 0644); err != nil {205			return err206		}207		fmt.Println("results written to " + FileOutput)208	}209	return nil210}211212func renderAuthorTimeline(o *historyAuthorTimelineObserver) (string, error) {213	switch strings.ToLower(Format) {214	case "", "tabular", "wide":215		return renderAuthorTimelineTabular(o), nil216	case "csv":217		return renderAuthorTimelineCSV(o)218	case "json":219		return renderAuthorTimelineJSON(o)220	default:221		return "", fmt.Errorf("unsupported --format %q for --by-author --timeline (supported: tabular, csv, json)", Format)222	}223}224225// Tabular column format. 24+1+24+1+8+1+9+1+10 = 79.226var tabularShortAuthorTimelineFormatHead = "%-24s %-24s %8s %9s %-10s\n"227228// Wide tabular: same columns, Author widened to fill the 109-col rule so long229// names show in full. 54+1+24+1+8+1+9+1+10 = 109.230var tabularWideAuthorTimelineFormatHead = "%-54s %-24s %8s %9s %-10s\n"231232func renderAuthorTimelineTabular(o *historyAuthorTimelineObserver) string {233	wide := More || strings.EqualFold(Format, "wide")234	brk := tabularBreakFor(wide)235236	var sb strings.Builder237	sb.WriteString(historyHeader("Authors", o.window, wide))238239	p := gmessage.NewPrinter(glanguage.Make(os.Getenv("LANG")))240241	nameTrim, nameWidth, format := 23, 24, tabularShortAuthorTimelineFormatHead242	if wide {243		nameTrim, nameWidth, format = 53, 54, tabularWideAuthorTimelineFormatHead244	}245246	_, _ = fmt.Fprintf(&sb, format,247		"Author", "Activity", "Commits", "Code±", "")248	sb.WriteString(brk)249250	writeRow := func(r authorTimelineRow) {251		nameCol := unicodeAwareTrim(r.Name, nameTrim)252		nameCol = unicodeAwareRightPad(nameCol, nameWidth)253		spark := renderAuthorTimelineSparkline(r.Series, authorTimelineSparkCells)254		tag := authorTimelineTag(r.Series, o.bucket.Width)255		commitsStr := formatWithCommas(p, int64(r.TotalCommits))256		codeStr := formatCodeDelta(p, r.CodeDelta)257		_, _ = fmt.Fprintf(&sb, format,258			nameCol, spark, commitsStr, codeStr, tag)259	}260261	limit := min(len(o.rows), authorTimelineTopN)262	for _, r := range o.rows[:limit] {263		writeRow(r)264	}265	if len(o.rows) > limit {266		writeRow(aggregateOthersTimelineRow(o.rows[limit:]))267	}268269	sb.WriteString(brk)270	return sb.String()271}272273// aggregateOthersTimelineRow folds the tail authors into one synthetic row: the274// sparkline sums their per-bucket commit counts, so the combined activity trend275// of everyone below the cut is still visible rather than dropped.276func aggregateOthersTimelineRow(tail []authorTimelineRow) authorTimelineRow {277	others := authorTimelineRow{Name: fmt.Sprintf("others (%d)", len(tail))}278	if len(tail) == 0 {279		return others280	}281	n := len(tail[0].Series)282	others.Series = make([]authorTimelineBucket, n)283	for _, r := range tail {284		others.TotalCommits += r.TotalCommits285		others.CodeDelta += r.CodeDelta286		for i := 0; i < n && i < len(r.Series); i++ {287			others.Series[i].Commits += r.Series[i].Commits288			others.Series[i].CodeDelta += r.Series[i].CodeDelta289		}290	}291	return others292}293294// renderAuthorTimelineSparkline projects per-bucket commit counts to a295// sparkline using the shared helper from history_render.go.296func renderAuthorTimelineSparkline(series []authorTimelineBucket, cells int) string {297	if len(series) == 0 {298		if asciiOutput() {299			return strings.Repeat(".", cells)300		}301		return strings.Repeat("▁", cells)302	}303	values := make([]float64, len(series))304	for i, b := range series {305		values[i] = float64(b.Commits)306	}307	return renderSparkline(values, cells)308}309310// authorTimelineTag derives the trailing presentation tag. Returns:311//   - "↑"        — final bucket is >= 80% of the row's peak (still active).312//   - "quiet Nmo" — trailing zero buckets cover >= 1 month of wall clock.313//   - ""         — no notable trend.314//315// Tags are tabular-only; CSV/JSON do not carry them.316func authorTimelineTag(series []authorTimelineBucket, width time.Duration) string {317	if len(series) == 0 {318		return ""319	}320	maxCommits := 0321	for _, b := range series {322		if b.Commits > maxCommits {323			maxCommits = b.Commits324		}325	}326	if maxCommits == 0 {327		return ""328	}329330	last := series[len(series)-1].Commits331	if last > 0 && float64(last) >= 0.8*float64(maxCommits) {332		return "↑"333	}334335	zeroTail := 0336	for i := len(series) - 1; i >= 0; i-- {337		if series[i].Commits != 0 {338			break339		}340		zeroTail++341	}342	if zeroTail == 0 || width <= 0 {343		return ""344	}345	totalQuiet := time.Duration(zeroTail) * width346	const month = 30 * 24 * time.Hour347	if totalQuiet < month {348		return ""349	}350	months := max(int((totalQuiet+month/2)/month), 1)351	return fmt.Sprintf("quiet %dmo", months)352}353354// formatCodeDelta renders a signed code delta with a leading sign and355// thousands separators, e.g. "+38,000" or "-21".356func formatCodeDelta(p *gmessage.Printer, delta int64) string {357	if delta >= 0 {358		return "+" + formatWithCommas(p, delta)359	}360	return "-" + formatWithCommas(p, -delta)361}362363func renderAuthorTimelineCSV(o *historyAuthorTimelineObserver) (string, error) {364	var sb strings.Builder365	sb.WriteString(formatWindowComment(o.window))366	sb.WriteByte('\n')367	_, _ = fmt.Fprintf(&sb, "# buckets: %d\n", o.bucket.N)368369	w := csv.NewWriter(&sb)370	_ = w.Write([]string{"Author", "Email", "BucketStart", "Commits", "CodeDelta"})371372	for _, r := range o.rows {373		for i, b := range r.Series {374			bucketStart := o.bucket.Start(i).UTC().Format(historyDateLayout)375			_ = w.Write([]string{376				r.Name,377				r.Email,378				bucketStart,379				fmt.Sprintf("%d", b.Commits),380				fmt.Sprintf("%d", b.CodeDelta),381			})382		}383	}384	w.Flush()385	if err := w.Error(); err != nil {386		return "", err387	}388	return sb.String(), nil389}390391type authorTimelineJSONBucket struct {392	BucketStart string `json:"bucketStart"`393	Commits     int    `json:"commits"`394	CodeDelta   int64  `json:"codeDelta"`395}396397type authorTimelineJSONAuthor struct {398	Name         string                     `json:"name"`399	Email        string                     `json:"email"`400	TotalCommits int                        `json:"totalCommits"`401	CodeDelta    int64                      `json:"codeDelta"`402	Series       []authorTimelineJSONBucket `json:"series"`403}404405type authorTimelineJSONWindow struct {406	Depth   int    `json:"depth"`407	Commits int    `json:"commits"`408	From    string `json:"from"`409	To      string `json:"to"`410}411412type authorTimelineJSONDoc struct {413	Report  string                     `json:"report"`414	Window  authorTimelineJSONWindow   `json:"window"`415	Buckets int                        `json:"buckets"`416	Authors []authorTimelineJSONAuthor `json:"authors"`417}418419func renderAuthorTimelineJSON(o *historyAuthorTimelineObserver) (string, error) {420	doc := authorTimelineJSONDoc{421		Report: "author-timeline",422		Window: authorTimelineJSONWindow{423			Depth:   o.window.Depth,424			Commits: o.window.Commits,425			From:    formatWindowDate(o.window.From),426			To:      formatWindowDate(o.window.To),427		},428		Buckets: o.bucket.N,429		Authors: make([]authorTimelineJSONAuthor, 0, len(o.rows)),430	}431	for _, r := range o.rows {432		ja := authorTimelineJSONAuthor{433			Name:         r.Name,434			Email:        r.Email,435			TotalCommits: r.TotalCommits,436			CodeDelta:    r.CodeDelta,437			Series:       make([]authorTimelineJSONBucket, 0, len(r.Series)),438		}439		for i, b := range r.Series {440			ja.Series = append(ja.Series, authorTimelineJSONBucket{441				BucketStart: o.bucket.Start(i).UTC().Format(historyDateLayout),442				Commits:     b.Commits,443				CodeDelta:   b.CodeDelta,444			})445		}446		doc.Authors = append(doc.Authors, ja)447	}448	b, err := jsoniter.Marshal(doc)449	if err != nil {450		return "", err451	}452	return string(b), nil453}

Code quality findings 12

Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(&sb, format,
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(&sb, format,
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(&sb, "# buckets: %d\n", o.bucket.N)
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_ = w.Write([]string{"Author", "Email", "BucketStart", "Commits", "CodeDelta"})
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
o.events = append(o.events, authorTimelineEvent{
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for aid, s := range series {
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
rows = append(rows, row)
Unstructured output; use a structured logging library (e.g., slog, zap, zerolog, logrus)
info correctness fmt-println
fmt.Println("results written to " + FileOutput)
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for i, b := range series {
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for i, b := range r.Series {
Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
info performance copy-large-struct
for i, b := range r.Series {
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
ja.Series = append(ja.Series, authorTimelineJSONBucket{

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.