processor/history_authors.go GO 689 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	"github.com/mattn/go-runewidth"15	glanguage "golang.org/x/text/language"16	gmessage "golang.org/x/text/message"17)1819// authorsTopN is the cap on tabular rows for the author rollup. CSV/JSON20// output is not capped. The remainder collapses into a single "others (N)"21// row in the tabular table.22const authorsTopN = 152324// authorNameColWidth / authorNameTrim are the trim/pad widths for the25// "Author" column. The 79-col report uses the short pair; the 109-col --wide26// report uses the wide pair so the table fills the wider rule instead of27// leaving the break line hanging past the content.28const (29	authorNameTrim     = 3030	authorNameColWidth = 313132	authorNameTrimWide     = 6033	authorNameColWidthWide = 6134)3536// authorRow is one materialised row in the report. Sentinel is true for the37// "(before window)" pseudo-author whose lines pre-date the walk window.38type authorRow struct {39	Name string40	// Display is the name as rendered in the tabular report and bus-factor41	// footer. It equals Name unless two or more in-window identities share42	// the same Name, in which case each colliding row is suffixed with a43	// distinguishing marker (see disambiguateNames) so the reader can tell44	// the identities apart. CSV/JSON emit Name and Email raw and ignore this.45	Display         string46	Email           string47	Code            int6448	Comment         int6449	Complexity      int6450	Files           int51	OwnsPercent     float6452	InWindowPercent float6453	LastCommit      time.Time54	Sentinel        bool55}5657// historyAuthorsObserver accumulates per-file forward-replay blame during58// the walk, then collapses it into per-author totals on Finalise. It59// implements both CommitObserver and BaselineObserver, so the engine seeds60// it with the pre-window tree state (and the .mailmap) before the walk.61type historyAuthorsObserver struct {62	blame      map[string][]authorID63	lineTypes  map[string][]LineType64	complexity map[string][]int6566	registry *authorRegistry67	lastSeen map[authorID]time.Time6869	window   HistoryWindow70	snapshot HeadSnapshot7172	rows         []authorRow73	busFactor    int74	busAuthors   []string75	busCovered   float6476	inWindowCode int6477}7879func newHistoryAuthorsObserver() *historyAuthorsObserver {80	return &historyAuthorsObserver{81		blame:      map[string][]authorID{},82		lineTypes:  map[string][]LineType{},83		complexity: map[string][]int{},84		lastSeen:   map[authorID]time.Time{},85		registry:   newAuthorRegistry(nil),86	}87}8889// Seed installs the mailmap and seeds the per-file blame maps from the90// baseline snapshot — every pre-window line maps to sentinelAuthorID so91// surviving untouched lines are correctly attributed to "(before window)".92func (o *historyAuthorsObserver) Seed(baseline BaselineSnapshot) {93	o.registry = newAuthorRegistry(baseline.Mailmap)94	for path, bf := range baseline.Files {95		n := len(bf.LineTypes)96		if n == 0 {97			continue98		}99		o.blame[path] = make([]authorID, n) // zero value = sentinelAuthorID100		o.lineTypes[path] = bf.LineTypes101		o.complexity[path] = bf.Complexity102	}103}104105func (o *historyAuthorsObserver) Observe(c CommitInfo, changes []FileChange) {106	aid := o.registry.intern(c.Author, c.Email)107	if prev, ok := o.lastSeen[aid]; !ok || c.When.After(prev) {108		o.lastSeen[aid] = c.When109	}110	for _, fc := range changes {111		prev := o.blame[fc.Path]112		// Rename: carry the old path's per-line blame forward as the prior113		// state, then drop the stale key so it is not double-counted. A114		// pure rename has no Added/Removed ranges, so applyDiffToBlame just115		// copies the carried-forward blame — every line keeps its original116		// author. A rename with edits attributes only the edited lines to117		// the renaming commit.118		if fc.FromPath != "" && fc.FromPath != fc.Path {119			if oldBlame, ok := o.blame[fc.FromPath]; ok {120				prev = oldBlame121				delete(o.blame, fc.FromPath)122				delete(o.lineTypes, fc.FromPath)123				delete(o.complexity, fc.FromPath)124			}125		}126		newN := len(fc.LineTypes)127		o.blame[fc.Path] = applyDiffToBlame(prev, newN, fc.AddedRanges, fc.RemovedRanges, aid)128		o.lineTypes[fc.Path] = fc.LineTypes129		o.complexity[fc.Path] = fc.Complexity130	}131}132133func (o *historyAuthorsObserver) Finalise(window HistoryWindow, head HeadSnapshot) {134	o.window = window135	o.snapshot = head136137	type acc struct {138		Code       int64139		Comment    int64140		Complexity int64141		Files      int142	}143	totals := map[authorID]*acc{}144	var grandCode int64145146	for path, blame := range o.blame {147		if _, alive := head.Files[path]; !alive {148			continue149		}150		types := o.lineTypes[path]151		perFile := map[authorID]int64{}152153		for i := 0; i < len(blame) && i < len(types); i++ {154			aid := blame[i]155			a := totals[aid]156			if a == nil {157				a = &acc{}158				totals[aid] = a159			}160			switch types[i] {161			case LINE_CODE:162				a.Code++163				perFile[aid]++164				grandCode++165			case LINE_COMMENT:166				a.Comment++167			}168		}169		for _, lineNo := range o.complexity[path] {170			idx := lineNo - 1171			if idx < 0 || idx >= len(blame) {172				continue173			}174			aid := blame[idx]175			a := totals[aid]176			if a == nil {177				a = &acc{}178				totals[aid] = a179			}180			a.Complexity++181		}182183		// Plurality of code lines: who has the most code in this file. A184		// real author always outranks the sentinel — the sentinel only owns185		// the file when no real author has any code here. Tie-break on186		// smaller authorID for determinism.187		var plur authorID188		var plurCount int64189		for aid, c := range perFile {190			if aid == sentinelAuthorID {191				continue192			}193			if c > plurCount || (c == plurCount && aid < plur) {194				plur = aid195				plurCount = c196			}197		}198		if plurCount == 0 {199			// No real author has code here; fall back to the sentinel.200			if c, ok := perFile[sentinelAuthorID]; ok {201				plur = sentinelAuthorID202				plurCount = c203			}204		}205		if plurCount > 0 {206			totals[plur].Files++207		}208	}209210	var sentinelCode int64211	if s, ok := totals[sentinelAuthorID]; ok {212		sentinelCode = s.Code213	}214	inWindowCode := grandCode - sentinelCode215	o.inWindowCode = inWindowCode216217	rows := make([]authorRow, 0, len(totals))218	for aid, a := range totals {219		rec := o.registry.record(aid)220		row := authorRow{221			Name:       rec.Name,222			Email:      rec.Email,223			Code:       a.Code,224			Comment:    a.Comment,225			Complexity: a.Complexity,226			Files:      a.Files,227		}228		if grandCode > 0 {229			row.OwnsPercent = float64(a.Code) / float64(grandCode) * 100.0230		}231		if aid == sentinelAuthorID {232			row.Sentinel = true233		} else {234			if inWindowCode > 0 {235				row.InWindowPercent = float64(a.Code) / float64(inWindowCode) * 100.0236			}237			if when, ok := o.lastSeen[aid]; ok {238				row.LastCommit = when239			}240		}241		rows = append(rows, row)242	}243244	// Sentinel sorted to the end; real authors by Code desc, then Name.245	slices.SortFunc(rows, func(a, b authorRow) int {246		if a.Sentinel != b.Sentinel {247			if a.Sentinel {248				return 1249			}250			return -1251		}252		if a.Code != b.Code {253			if a.Code < b.Code {254				return 1255			}256			return -1257		}258		return strings.Compare(a.Name, b.Name)259	})260	disambiguateNames(rows)261	o.rows = rows262263	cumPercent := 0.0264	for _, r := range rows {265		if r.Sentinel {266			continue267		}268		if r.Code == 0 {269			break270		}271		cumPercent += r.InWindowPercent272		o.busAuthors = append(o.busAuthors, r.Display)273		if cumPercent > 50 {274			break275		}276	}277	o.busFactor = len(o.busAuthors)278	o.busCovered = cumPercent279}280281// disambiguateNames sets Display on every row. When two or more in-window282// identities share the same display Name (e.g. one contributor committing283// under both a work and a noreply email — kept as distinct identities because284// no .mailmap merges them), each colliding row is suffixed with a short285// marker so the reader — and the bus-factor footer, which reuses Display —286// can tell them apart. Non-colliding names, the "others" roll-up and the287// sentinel are left bare.288func disambiguateNames(rows []authorRow) {289	groups := map[string][]int{}290	for i := range rows {291		if rows[i].Sentinel {292			continue293		}294		groups[rows[i].Name] = append(groups[rows[i].Name], i)295	}296	for _, idx := range groups {297		if len(idx) < 2 {298			continue299		}300		markers := disambiguationMarkers(rows, idx)301		for k, i := range idx {302			rows[i].Display = rows[i].Name + " (" + markers[k] + ")"303		}304	}305	for i := range rows {306		if rows[i].Display == "" {307			rows[i].Display = rows[i].Name308		}309	}310}311312// disambiguationMarkers returns one marker per row in idx (all sharing a313// display Name), picking the shortest candidate form that is distinct across314// the whole group: registrable domain first (the tidiest, e.g. "github.com"),315// then the full domain, then the full email. Because two identities with the316// same name and same email intern to one authorID, a collision group always317// has distinct emails, so the final form is guaranteed to separate them.318func disambiguationMarkers(rows []authorRow, idx []int) []string {319	forms := []func(string) string{320		registrableDomain,321		emailDomain,322		func(email string) string { return email },323	}324	for _, form := range forms {325		out := make([]string, len(idx))326		seen := map[string]struct{}{}327		distinct := true328		for k, i := range idx {329			m := form(rows[i].Email)330			if m == "" {331				distinct = false332				break333			}334			if _, dup := seen[m]; dup {335				distinct = false336				break337			}338			seen[m] = struct{}{}339			out[k] = m340		}341		if distinct {342			return out343		}344	}345	// Unreachable in practice (see doc comment); fall back to raw email.346	out := make([]string, len(idx))347	for k, i := range idx {348		out[k] = rows[i].Email349	}350	return out351}352353// registrableDomain returns the last two labels of the email's domain354// (e.g. "users.noreply.github.com" -> "github.com"), a short human-readable355// marker for the common single-TLD case. It is a display heuristic, not a356// public-suffix-correct computation — disambiguationMarkers falls back to the357// full domain when this form fails to separate a collision group.358func registrableDomain(email string) string {359	d := emailDomain(email)360	if d == "" {361		return ""362	}363	parts := strings.Split(d, ".")364	if len(parts) <= 2 {365		return d366	}367	return strings.Join(parts[len(parts)-2:], ".")368}369370// runAuthorsReport is the dispatch entry point called from Process() when371// --by-author is set (and --timeline is not). Opens the repo at repoPath,372// walks history with baseline seeding, and writes the chosen format to373// stdout or FileOutput.374func runAuthorsReport(repoPath string) error {375	observer := newHistoryAuthorsObserver()376	if _, err := runHistory(repoPath, observer); err != nil {377		return err378	}379	out, err := renderAuthors(observer)380	if err != nil {381		return err382	}383	if FileOutput == "" {384		fmt.Print(out)385	} else {386		if err := os.WriteFile(FileOutput, []byte(out), 0644); err != nil {387			return err388		}389		fmt.Println("results written to " + FileOutput)390	}391	return nil392}393394func renderAuthors(o *historyAuthorsObserver) (string, error) {395	switch strings.ToLower(Format) {396	case "", "tabular", "wide":397		return renderAuthorsTabular(o), nil398	case "csv":399		return renderAuthorsCSV(o)400	case "json":401		return renderAuthorsJSON(o)402	default:403		return "", fmt.Errorf("unsupported --format %q for --by-author (supported: tabular, csv, json)", Format)404	}405}406407// Short tabular: %-31s %9s %9s %7s %8s %10s = 79.408var tabularShortAuthorsFormatHead = "%-31s %9s %9s %7s %8s %10s\n"409410// Wide tabular: identical columns to the short form, with the Author column411// stretched to fill the 109-col rule so long names show in full.412// %-61s %9s %9s %7s %8s %10s = 109.413var tabularWideAuthorsFormatHead = "%-61s %9s %9s %7s %8s %10s\n"414415func renderAuthorsTabular(o *historyAuthorsObserver) string {416	wide := More || strings.EqualFold(Format, "wide")417	brk := tabularBreakFor(wide)418419	var sb strings.Builder420	sb.WriteString(historyHeader("Authors", o.window, wide))421422	p := gmessage.NewPrinter(glanguage.Make(os.Getenv("LANG")))423424	if wide {425		_, _ = fmt.Fprintf(&sb, tabularWideAuthorsFormatHead,426			"Author", "Code", "Cmplx", "Files", "Owns", "Last seen")427	} else {428		_, _ = fmt.Fprintf(&sb, tabularShortAuthorsFormatHead,429			"Author", "Code", "Cmplx", "Files", "Owns", "Last seen")430	}431	sb.WriteString(brk)432433	realRows := make([]authorRow, 0, len(o.rows))434	var sentinel *authorRow435	for i := range o.rows {436		r := o.rows[i]437		if r.Sentinel {438			s := r439			sentinel = &s440		} else {441			realRows = append(realRows, r)442		}443	}444445	limit := min(len(realRows), authorsTopN)446447	for i := range limit {448		r := realRows[i]449		writeAuthorRow(&sb, p, wide, r.Display, r.Code, r.Comment, r.Complexity,450			fmt.Sprintf("%d", r.Files), r.OwnsPercent, lastSeenString(r))451	}452453	if limit < len(realRows) {454		var (455			count      int456			code       int64457			comment    int64458			complexity int64459			owns       float64460		)461		for i := limit; i < len(realRows); i++ {462			r := realRows[i]463			count++464			code += r.Code465			comment += r.Comment466			complexity += r.Complexity467			owns += r.OwnsPercent468		}469		writeAuthorRow(&sb, p, wide,470			fmt.Sprintf("others (%d)", count), code, comment, complexity,471			"—", owns, "—")472	}473474	if sentinel != nil && (sentinel.Code+sentinel.Comment+sentinel.Complexity) > 0 {475		writeAuthorRow(&sb, p, wide,476			"(before window)", sentinel.Code, sentinel.Comment, sentinel.Complexity,477			fmt.Sprintf("%d", sentinel.Files), sentinel.OwnsPercent, "—")478	}479480	sb.WriteString(brk)481482	footerWidth := runewidth.StringWidth(strings.TrimRight(brk, "\n"))483	footer := formatAuthorsFooter(o, footerWidth)484	sb.WriteString(footer)485	sb.WriteByte('\n')486	sb.WriteString(brk)487488	return sb.String()489}490491func lastSeenString(r authorRow) string {492	if r.LastCommit.IsZero() {493		return "—"494	}495	return r.LastCommit.UTC().Format(historyDateLayout)496}497498func writeAuthorRow(sb *strings.Builder, p *gmessage.Printer, wide bool,499	name string, code, comment, complexity int64,500	files string, owns float64, lastSeen string) {501502	trim, colWidth := authorNameTrim, authorNameColWidth503	format := tabularShortAuthorsFormatHead504	if wide {505		trim, colWidth = authorNameTrimWide, authorNameColWidthWide506		format = tabularWideAuthorsFormatHead507	}508	nameCol := unicodeAwareTrim(name, trim)509	nameCol = unicodeAwareRightPad(nameCol, colWidth)510	codeStr := formatWithCommas(p, code)511	cmplxStr := formatWithCommas(p, complexity)512	ownsStr := fmt.Sprintf("%6.1f%%", owns)513514	_, _ = fmt.Fprintf(sb, format,515		nameCol, codeStr, cmplxStr, files, ownsStr, lastSeen)516}517518func formatAuthorsFooter(o *historyAuthorsObserver, width int) string {519	if o.inWindowCode == 0 {520		return "Bus factor 0 · no code touched in window"521	}522	if o.busFactor == 0 {523		return "Bus factor 0 · no authored code in window"524	}525	covered := o.busCovered526	if covered > 100 {527		covered = 100528	}529530	prefix := fmt.Sprintf("Bus factor %d · ", o.busFactor)531	var suffix string532	if o.busFactor == 1 {533		suffix = fmt.Sprintf(" last-touched %.0f%% of in-window code (single point of failure)", covered)534	} else {535		suffix = fmt.Sprintf(" last-touched %.0f%% of in-window code", covered)536	}537538	single := prefix + strings.Join(o.busAuthors, " + ") + suffix539	if width <= 0 || runewidth.StringWidth(single) <= width {540		return single541	}542	return wrapBusFactorFooter(prefix, o.busAuthors, suffix, width)543}544545// wrapBusFactorFooter word-wraps the bus-factor line on " + " token546// boundaries when it would otherwise exceed width. Continuation lines are547// indented to align under the first name so the structure stays readable.548// Width is measured in display columns (runewidth), not bytes, so non-ASCII549// author names and CI-mode ASCII breaks both produce the right wrap point.550func wrapBusFactorFooter(prefix string, names []string, suffix string, width int) string {551	indent := strings.Repeat(" ", runewidth.StringWidth(prefix))552	var sb strings.Builder553	line := prefix554	lineWidth := runewidth.StringWidth(line)555556	for i, name := range names {557		token := name558		if i < len(names)-1 {559			token += " + "560		}561		tokenWidth := runewidth.StringWidth(token)562		if line != prefix && line != indent && lineWidth+tokenWidth > width {563			sb.WriteString(strings.TrimRight(line, " "))564			sb.WriteByte('\n')565			line = indent566			lineWidth = runewidth.StringWidth(indent)567		}568		line += token569		lineWidth += tokenWidth570	}571572	suffixWidth := runewidth.StringWidth(suffix)573	if lineWidth+suffixWidth > width && line != indent {574		sb.WriteString(strings.TrimRight(line, " "))575		sb.WriteByte('\n')576		line = indent + strings.TrimLeft(suffix, " ")577	} else {578		line += suffix579	}580	sb.WriteString(line)581	return sb.String()582}583584func renderAuthorsCSV(o *historyAuthorsObserver) (string, error) {585	var sb strings.Builder586	sb.WriteString(formatWindowComment(o.window))587	sb.WriteByte('\n')588589	w := csv.NewWriter(&sb)590	_ = w.Write([]string{591		"Author", "Email", "Code", "Complexity", "Comment", "Files",592		"OwnsPercent", "LastCommit", "BeforeWindow",593	})594	for _, r := range o.rows {595		name, email := r.Name, r.Email596		lastCommit := ""597		beforeWindow := "false"598		if r.Sentinel {599			name, email = "", ""600			beforeWindow = "true"601		} else if !r.LastCommit.IsZero() {602			lastCommit = r.LastCommit.UTC().Format(historyDateLayout)603		}604		_ = w.Write([]string{605			name,606			email,607			fmt.Sprintf("%d", r.Code),608			fmt.Sprintf("%d", r.Complexity),609			fmt.Sprintf("%d", r.Comment),610			fmt.Sprintf("%d", r.Files),611			fmt.Sprintf("%.1f", r.OwnsPercent),612			lastCommit,613			beforeWindow,614		})615	}616	w.Flush()617	if err := w.Error(); err != nil {618		return "", err619	}620	return sb.String(), nil621}622623type authorsJSONAuthor struct {624	Name            *string `json:"name"`625	Email           *string `json:"email"`626	Code            int64   `json:"code"`627	Complexity      int64   `json:"complexity"`628	Comment         int64   `json:"comment"`629	Files           int     `json:"files"`630	OwnsPercent     float64 `json:"ownsPercent"`631	InWindowPercent float64 `json:"inWindowPercent"`632	LastCommit      string  `json:"lastCommit,omitempty"`633	BeforeWindow    bool    `json:"beforeWindow"`634}635636type authorsJSONWindow struct {637	Depth   int    `json:"depth"`638	Commits int    `json:"commits"`639	From    string `json:"from"`640	To      string `json:"to"`641}642643type authorsJSONDoc struct {644	Report    string              `json:"report"`645	Window    authorsJSONWindow   `json:"window"`646	BusFactor int                 `json:"busFactor"`647	Authors   []authorsJSONAuthor `json:"authors"`648}649650func renderAuthorsJSON(o *historyAuthorsObserver) (string, error) {651	doc := authorsJSONDoc{652		Report: "authors",653		Window: authorsJSONWindow{654			Depth:   o.window.Depth,655			Commits: o.window.Commits,656			From:    formatWindowDate(o.window.From),657			To:      formatWindowDate(o.window.To),658		},659		BusFactor: o.busFactor,660		Authors:   make([]authorsJSONAuthor, 0, len(o.rows)),661	}662	for _, r := range o.rows {663		a := authorsJSONAuthor{664			Code:            r.Code,665			Complexity:      r.Complexity,666			Comment:         r.Comment,667			Files:           r.Files,668			OwnsPercent:     round1(r.OwnsPercent),669			InWindowPercent: round1(r.InWindowPercent),670		}671		if r.Sentinel {672			a.BeforeWindow = true673		} else {674			name, email := r.Name, r.Email675			a.Name = &name676			a.Email = &email677			if !r.LastCommit.IsZero() {678				a.LastCommit = r.LastCommit.UTC().Format(historyDateLayout)679			}680		}681		doc.Authors = append(doc.Authors, a)682	}683	b, err := jsoniter.Marshal(doc)684	if err != nil {685		return "", err686	}687	return string(b), nil688}

Code quality findings 17

Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(&sb, tabularWideAuthorsFormatHead,
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_, _ = fmt.Fprintf(&sb, tabularShortAuthorsFormatHead,
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
_ = w.Write([]string{
Blank identifier discarding results; verify intentional ignoring of return values
warning correctness blank-identifier-discard
_ = w.Write([]string{
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 path, bf := range baseline.Files {
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 path, blame := range o.blame {
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, c := range perFile {
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, a := range totals {
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
o.busAuthors = append(o.busAuthors, r.Display)
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
groups[rows[i].Name] = append(groups[rows[i].Name], i)
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 k, i := range idx {
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 k, i := range idx {
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 k, i := range idx {
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, name := range names {
Multiple appends without pre-allocation; use make() with capacity when size is known
info performance append-without-prealloc
doc.Authors = append(doc.Authors, a)

Get this view in your editor

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