Skip to content

Commit d022ad0

Browse files
committed
Release v2.6.2: File and directory level who, speed optimizations
gx who <file>: - Line ownership via git blame (who currently owns each line) - Shows: Author, Lines, % of file gx who <dir>: - Aggregates blame across all tracked files - 8 concurrent workers for speed - Capped at 200 files (--no-limit to override) - Shows: Author, Lines, %, Files Touched Speed: - who: removed N per-author git calls, last-active from shortstat - who: parallel shortlog + shortstat - graph: detectParent only checks trunk + known parents (not O(N^2)) - graph: batch merged-branch check per unique parent
1 parent 76f94cb commit d022ad0

3 files changed

Lines changed: 232 additions & 4 deletions

File tree

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2.6.1
1+
2.6.2

cmd/who.go

Lines changed: 230 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package cmd
33
import (
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

5265
func 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+

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "gx-git"
7-
version = "2.6.1"
7+
version = "2.6.2"
88
description = "Git Productivity Toolkit"
99
readme = "README.md"
1010
license = {text = "MIT"}

0 commit comments

Comments
 (0)