@@ -32,7 +32,13 @@ const (
3232// authorRow is one materialised row in the report. Sentinel is true for the
3333// "(before window)" pseudo-author whose lines pre-date the walk window.
3434type authorRow struct {
35- Name string
35+ Name string
36+ // Display is the name as rendered in the tabular report and bus-factor
37+ // footer. It equals Name unless two or more in-window identities share
38+ // the same Name, in which case each colliding row is suffixed with a
39+ // distinguishing marker (see disambiguateNames) so the reader can tell
40+ // the identities apart. CSV/JSON emit Name and Email raw and ignore this.
41+ Display string
3642 Email string
3743 Code int64
3844 Comment int64
@@ -247,6 +253,7 @@ func (o *historyAuthorsObserver) Finalise(window HistoryWindow, head HeadSnapsho
247253 }
248254 return strings .Compare (a .Name , b .Name )
249255 })
256+ disambiguateNames (rows )
250257 o .rows = rows
251258
252259 cumPercent := 0.0
@@ -258,7 +265,7 @@ func (o *historyAuthorsObserver) Finalise(window HistoryWindow, head HeadSnapsho
258265 break
259266 }
260267 cumPercent += r .InWindowPercent
261- o .busAuthors = append (o .busAuthors , r .Name )
268+ o .busAuthors = append (o .busAuthors , r .Display )
262269 if cumPercent > 50 {
263270 break
264271 }
@@ -267,6 +274,95 @@ func (o *historyAuthorsObserver) Finalise(window HistoryWindow, head HeadSnapsho
267274 o .busCovered = cumPercent
268275}
269276
277+ // disambiguateNames sets Display on every row. When two or more in-window
278+ // identities share the same display Name (e.g. one contributor committing
279+ // under both a work and a noreply email — kept as distinct identities because
280+ // no .mailmap merges them), each colliding row is suffixed with a short
281+ // marker so the reader — and the bus-factor footer, which reuses Display —
282+ // can tell them apart. Non-colliding names, the "others" roll-up and the
283+ // sentinel are left bare.
284+ func disambiguateNames (rows []authorRow ) {
285+ groups := map [string ][]int {}
286+ for i := range rows {
287+ if rows [i ].Sentinel {
288+ continue
289+ }
290+ groups [rows [i ].Name ] = append (groups [rows [i ].Name ], i )
291+ }
292+ for _ , idx := range groups {
293+ if len (idx ) < 2 {
294+ continue
295+ }
296+ markers := disambiguationMarkers (rows , idx )
297+ for k , i := range idx {
298+ rows [i ].Display = rows [i ].Name + " (" + markers [k ] + ")"
299+ }
300+ }
301+ for i := range rows {
302+ if rows [i ].Display == "" {
303+ rows [i ].Display = rows [i ].Name
304+ }
305+ }
306+ }
307+
308+ // disambiguationMarkers returns one marker per row in idx (all sharing a
309+ // display Name), picking the shortest candidate form that is distinct across
310+ // the whole group: registrable domain first (the tidiest, e.g. "github.com"),
311+ // then the full domain, then the full email. Because two identities with the
312+ // same name and same email intern to one authorID, a collision group always
313+ // has distinct emails, so the final form is guaranteed to separate them.
314+ func disambiguationMarkers (rows []authorRow , idx []int ) []string {
315+ forms := []func (string ) string {
316+ registrableDomain ,
317+ emailDomain ,
318+ func (email string ) string { return email },
319+ }
320+ for _ , form := range forms {
321+ out := make ([]string , len (idx ))
322+ seen := map [string ]struct {}{}
323+ distinct := true
324+ for k , i := range idx {
325+ m := form (rows [i ].Email )
326+ if m == "" {
327+ distinct = false
328+ break
329+ }
330+ if _ , dup := seen [m ]; dup {
331+ distinct = false
332+ break
333+ }
334+ seen [m ] = struct {}{}
335+ out [k ] = m
336+ }
337+ if distinct {
338+ return out
339+ }
340+ }
341+ // Unreachable in practice (see doc comment); fall back to raw email.
342+ out := make ([]string , len (idx ))
343+ for k , i := range idx {
344+ out [k ] = rows [i ].Email
345+ }
346+ return out
347+ }
348+
349+ // registrableDomain returns the last two labels of the email's domain
350+ // (e.g. "users.noreply.github.com" -> "github.com"), a short human-readable
351+ // marker for the common single-TLD case. It is a display heuristic, not a
352+ // public-suffix-correct computation — disambiguationMarkers falls back to the
353+ // full domain when this form fails to separate a collision group.
354+ func registrableDomain (email string ) string {
355+ d := emailDomain (email )
356+ if d == "" {
357+ return ""
358+ }
359+ parts := strings .Split (d , "." )
360+ if len (parts ) <= 2 {
361+ return d
362+ }
363+ return strings .Join (parts [len (parts )- 2 :], "." )
364+ }
365+
270366// runAuthorsReport is the dispatch entry point called from Process() when
271367// --by-author is set (and --timeline is not). Opens the repo at repoPath,
272368// walks history with baseline seeding, and writes the chosen format to
@@ -344,7 +440,7 @@ func renderAuthorsTabular(o *historyAuthorsObserver) string {
344440
345441 for i := range limit {
346442 r := realRows [i ]
347- writeAuthorRow (& sb , p , wide , r .Name , r .Code , r .Comment , r .Complexity ,
443+ writeAuthorRow (& sb , p , wide , r .Display , r .Code , r .Comment , r .Complexity ,
348444 fmt .Sprintf ("%d" , r .Files ), r .OwnsPercent , lastSeenString (r ))
349445 }
350446
0 commit comments