@@ -3,8 +3,10 @@ package cmd
33import (
44 "bytes"
55 "fmt"
6+ "os"
67 "sort"
78 "strings"
9+ "sync"
810
911 "github.com/mubbie/gx-cli/internal/git"
1012 "github.com/mubbie/gx-cli/internal/ui"
@@ -45,8 +47,19 @@ func runWho(cmd *cobra.Command, args []string) error {
4547 if len (args ) == 0 {
4648 return whoRepo (n , since )
4749 }
48- ui .PrintInfo ("File/directory-level who is coming soon. Showing repo level." )
49- return whoRepo (n , since )
50+
51+ path := args [0 ]
52+ // Check if it's a file or directory
53+ info , err := os .Stat (path )
54+ if err != nil {
55+ ui .PrintError (fmt .Sprintf ("Path not found: %s" , path ))
56+ return nil
57+ }
58+ if info .IsDir () {
59+ noLimit , _ := cmd .Flags ().GetBool ("no-limit" )
60+ return whoDir (path , n , since , noLimit )
61+ }
62+ return whoFile (path , n , since )
5063}
5164
5265func whoRepo (n int , since string ) error {
@@ -322,3 +335,218 @@ func whoRepo(n int, since string) error {
322335 return nil
323336}
324337
338+ // whoFile shows line ownership for a single file via git blame.
339+ func whoFile (path string , n int , since string ) error {
340+ sp := ui .StartSpinner (fmt .Sprintf ("Analyzing %s..." , path ))
341+
342+ blameArgs := []string {"blame" , "--line-porcelain" }
343+ if since != "" {
344+ blameArgs = append (blameArgs , "--since" , since )
345+ }
346+ blameArgs = append (blameArgs , path )
347+
348+ out , err := git .Run (blameArgs ... )
349+ sp .Stop ()
350+ if err != nil {
351+ ui .PrintError (fmt .Sprintf ("Failed to blame %s: %s" , path , err ))
352+ return nil
353+ }
354+
355+ // Parse blame output: count lines per author
356+ counts := map [string ]int {} // author -> lines
357+ emails := map [string ]string {} // author -> email
358+ for _ , line := range strings .Split (out , "\n " ) {
359+ if strings .HasPrefix (line , "author " ) {
360+ name := strings .TrimPrefix (line , "author " )
361+ if name != "Not Committed Yet" {
362+ counts [name ]++
363+ }
364+ }
365+ if strings .HasPrefix (line , "author-mail " ) {
366+ mail := strings .TrimPrefix (line , "author-mail " )
367+ mail = strings .Trim (mail , "<>" )
368+ // Find the last author we incremented
369+ for name := range counts {
370+ if _ , exists := emails [name ]; ! exists {
371+ emails [name ] = strings .ToLower (mail )
372+ }
373+ }
374+ }
375+ }
376+
377+ total := 0
378+ for _ , c := range counts {
379+ total += c
380+ }
381+ if total == 0 {
382+ ui .PrintInfo (fmt .Sprintf ("No blame data for %s" , path ))
383+ return nil
384+ }
385+
386+ // Sort by line count
387+ type entry struct {
388+ name string
389+ lines int
390+ }
391+ var sorted []entry
392+ for name , lines := range counts {
393+ sorted = append (sorted , entry {name , lines })
394+ }
395+ sort .Slice (sorted , func (i , j int ) bool {
396+ return sorted [i ].lines > sorted [j ].lines
397+ })
398+
399+ currentName := git .RunUnchecked ("config" , "user.name" )
400+
401+ var rows [][]string
402+ for i , e := range sorted {
403+ if i >= n {
404+ break
405+ }
406+ displayName := e .name
407+ if e .name == currentName {
408+ displayName = ui .SuccessStyle .Bold (true ).Render ("You" )
409+ }
410+ pct := fmt .Sprintf ("%.1f%%" , float64 (e .lines )/ float64 (total )* 100 )
411+ rows = append (rows , []string {
412+ ui .DimStyle .Render (fmt .Sprintf ("%d" , i + 1 )),
413+ displayName ,
414+ ui .BoldStyle .Render (fmt .Sprintf ("%d" , e .lines )),
415+ pct ,
416+ })
417+ }
418+
419+ fmt .Println ()
420+ ui .PrintTable ([]string {"#" , "Author" , "Lines" , "%" }, rows , fmt .Sprintf ("Ownership of %s (%d lines)" , path , total ))
421+ return nil
422+ }
423+
424+ // whoDir shows line ownership across all files in a directory via git blame.
425+ func whoDir (dir string , n int , since string , noLimit bool ) error {
426+ sp := ui .StartSpinner (fmt .Sprintf ("Analyzing %s (this may take a moment)..." , dir ))
427+
428+ // Get tracked files
429+ filesOut , err := git .Run ("ls-files" , dir )
430+ if err != nil || filesOut == "" {
431+ sp .Stop ()
432+ ui .PrintInfo (fmt .Sprintf ("No tracked files in %s" , dir ))
433+ return nil
434+ }
435+
436+ files := strings .Split (strings .TrimSpace (filesOut ), "\n " )
437+ maxFiles := 200
438+ if noLimit {
439+ maxFiles = len (files )
440+ }
441+ if len (files ) > maxFiles {
442+ sp .Stop ()
443+ ui .PrintWarning (fmt .Sprintf ("%s contains %d tracked files. Analyzing first %d. Use --no-limit to analyze all." , dir , len (files ), maxFiles ))
444+ files = files [:maxFiles ]
445+ sp = ui .StartSpinner (fmt .Sprintf ("Analyzing %d files..." , len (files )))
446+ }
447+
448+ // Blame all files concurrently (capped at 8 workers)
449+ type blameResult struct {
450+ counts map [string ]int
451+ }
452+ results := make (chan blameResult , len (files ))
453+ sem := make (chan struct {}, 8 ) // concurrency limit
454+ var wg sync.WaitGroup
455+
456+ for _ , file := range files {
457+ file = strings .TrimSpace (file )
458+ if file == "" {
459+ continue
460+ }
461+ wg .Add (1 )
462+ go func (f string ) {
463+ defer wg .Done ()
464+ sem <- struct {}{}
465+ defer func () { <- sem }()
466+
467+ blameArgs := []string {"blame" , "--line-porcelain" }
468+ if since != "" {
469+ blameArgs = append (blameArgs , "--since" , since )
470+ }
471+ blameArgs = append (blameArgs , f )
472+
473+ out := git .RunUnchecked (blameArgs ... )
474+ counts := map [string ]int {}
475+ for _ , line := range strings .Split (out , "\n " ) {
476+ if strings .HasPrefix (line , "author " ) {
477+ name := strings .TrimPrefix (line , "author " )
478+ if name != "Not Committed Yet" {
479+ counts [name ]++
480+ }
481+ }
482+ }
483+ results <- blameResult {counts }
484+ }(file )
485+ }
486+
487+ go func () {
488+ wg .Wait ()
489+ close (results )
490+ }()
491+
492+ // Aggregate
493+ totalCounts := map [string ]int {}
494+ filesTouched := map [string ]int {}
495+ for r := range results {
496+ for name , lines := range r .counts {
497+ totalCounts [name ] += lines
498+ filesTouched [name ]++
499+ }
500+ }
501+
502+ sp .Stop ()
503+
504+ totalLines := 0
505+ for _ , c := range totalCounts {
506+ totalLines += c
507+ }
508+ if totalLines == 0 {
509+ ui .PrintInfo (fmt .Sprintf ("No blame data for %s" , dir ))
510+ return nil
511+ }
512+
513+ type entry struct {
514+ name string
515+ lines int
516+ files int
517+ }
518+ var sorted []entry
519+ for name , lines := range totalCounts {
520+ sorted = append (sorted , entry {name , lines , filesTouched [name ]})
521+ }
522+ sort .Slice (sorted , func (i , j int ) bool {
523+ return sorted [i ].lines > sorted [j ].lines
524+ })
525+
526+ currentName := git .RunUnchecked ("config" , "user.name" )
527+
528+ var rows [][]string
529+ for i , e := range sorted {
530+ if i >= n {
531+ break
532+ }
533+ displayName := e .name
534+ if e .name == currentName {
535+ displayName = ui .SuccessStyle .Bold (true ).Render ("You" )
536+ }
537+ pct := fmt .Sprintf ("%.1f%%" , float64 (e .lines )/ float64 (totalLines )* 100 )
538+ rows = append (rows , []string {
539+ ui .DimStyle .Render (fmt .Sprintf ("%d" , i + 1 )),
540+ displayName ,
541+ ui .BoldStyle .Render (fmt .Sprintf ("%d" , e .lines )),
542+ pct ,
543+ ui .DimStyle .Render (fmt .Sprintf ("%d" , e .files )),
544+ })
545+ }
546+
547+ fmt .Println ()
548+ ui .PrintTable ([]string {"#" , "Author" , "Lines" , "%" , "Files Touched" }, rows ,
549+ fmt .Sprintf ("Ownership of %s (%d files, %d lines)" , dir , len (files ), totalLines ))
550+ return nil
551+ }
552+
0 commit comments