From 62af077065d08403f8b243eacdd3d176bb33eb34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Sep 2025 03:59:23 +0000 Subject: [PATCH 1/8] Initial plan From 00dc4cbb91369c9577f480676e8ac7cb9a9344fd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Sep 2025 04:07:48 +0000 Subject: [PATCH 2/8] Implement CHECKOUT GROWTH section with year-end repository statistics Co-authored-by: steffen <6301+steffen@users.noreply.github.com> --- main.go | 9 ++ pkg/display/sections/checkout_growth.go | 49 +++++++++++ pkg/display/sections/checkout_growth_test.go | 93 ++++++++++++++++++++ pkg/git/git.go | 83 +++++++++++++++++ pkg/git/git_test.go | 33 +++++++ pkg/models/models.go | 10 +++ 6 files changed, 277 insertions(+) create mode 100644 pkg/display/sections/checkout_growth.go create mode 100644 pkg/display/sections/checkout_growth_test.go diff --git a/main.go b/main.go index 7a669dc..9fb9a4c 100644 --- a/main.go +++ b/main.go @@ -353,6 +353,15 @@ func main() { // Display file extension growth statistics sections.PrintFileExtensionGrowth(yearlyStatistics) + // Calculate and display checkout growth statistics + checkoutStatistics := make(map[int]models.CheckoutGrowthStatistics) + for year := firstCommitTime.Year(); year <= time.Now().Year(); year++ { + if checkoutStats, err := git.GetCheckoutGrowthStats(year, debug); err == nil { + checkoutStatistics[year] = checkoutStats + } + } + sections.DisplayCheckoutGrowth(checkoutStatistics) + // Get memory statistics for final output var memoryStatistics runtime.MemStats runtime.ReadMemStats(&memoryStatistics) diff --git a/pkg/display/sections/checkout_growth.go b/pkg/display/sections/checkout_growth.go new file mode 100644 index 0000000..fd852d1 --- /dev/null +++ b/pkg/display/sections/checkout_growth.go @@ -0,0 +1,49 @@ +package sections + +import ( + "fmt" + "git-metrics/pkg/models" + "git-metrics/pkg/utils" + "sort" + "strconv" + "strings" +) + +// DisplayCheckoutGrowth displays the checkout growth statistics section +func DisplayCheckoutGrowth(checkoutStatistics map[int]models.CheckoutGrowthStatistics) { + if len(checkoutStatistics) == 0 { + return + } + + fmt.Println() + fmt.Println("CHECKOUT GROWTH ################################################################################################") + fmt.Println() + fmt.Println("Year Directories Max depth Max path length Files Total size") + fmt.Println("----------------------------------------------------------------------------------------------------") + + // Get years and sort them + var years []int + for year := range checkoutStatistics { + years = append(years, year) + } + sort.Ints(years) + + // Display each year's statistics + for _, year := range years { + stats := checkoutStatistics[year] + DisplayCheckoutGrowthRow(stats) + } +} + +// DisplayCheckoutGrowthRow displays a single row of checkout growth statistics +func DisplayCheckoutGrowthRow(stats models.CheckoutGrowthStatistics) { + yearDisplay := strconv.Itoa(stats.Year) + + fmt.Printf("%-9s%11s%12d%18d%14s%16s\n", + yearDisplay, + utils.FormatNumber(stats.NumberDirectories), + stats.MaxPathDepth, + stats.MaxPathLength, + utils.FormatNumber(stats.NumberFiles), + strings.TrimSpace(utils.FormatSize(stats.TotalSizeFiles))) +} \ No newline at end of file diff --git a/pkg/display/sections/checkout_growth_test.go b/pkg/display/sections/checkout_growth_test.go new file mode 100644 index 0000000..9127b84 --- /dev/null +++ b/pkg/display/sections/checkout_growth_test.go @@ -0,0 +1,93 @@ +package sections + +import ( + "git-metrics/pkg/models" + "strings" + "testing" +) + +func TestDisplayCheckoutGrowth(t *testing.T) { + // Test with empty statistics + output := captureOutput(func() { + DisplayCheckoutGrowth(make(map[int]models.CheckoutGrowthStatistics)) + }) + if output != "" { + t.Errorf("expected empty output for empty statistics, got: %s", output) + } + + // Test with sample statistics + checkoutStats := map[int]models.CheckoutGrowthStatistics{ + 2023: { + Year: 2023, + NumberDirectories: 10, + MaxPathDepth: 3, + MaxPathLength: 45, + NumberFiles: 25, + TotalSizeFiles: 1024000, + }, + 2024: { + Year: 2024, + NumberDirectories: 15, + MaxPathDepth: 4, + MaxPathLength: 60, + NumberFiles: 35, + TotalSizeFiles: 2048000, + }, + } + + output = captureOutput(func() { + DisplayCheckoutGrowth(checkoutStats) + }) + + expectedSnippets := []string{ + "CHECKOUT GROWTH", + "Year", + "Directories", + "Max depth", + "Max path length", + "Files", + "Total size", + "2023", + "2024", + "10", // number of directories for 2023 + "15", // number of directories for 2024 + "3", // max depth for 2023 + "4", // max depth for 2024 + } + + for _, expected := range expectedSnippets { + if !strings.Contains(output, expected) { + t.Errorf("expected output to contain %q.\nOutput: %s", expected, output) + } + } +} + +func TestDisplayCheckoutGrowthRow(t *testing.T) { + stats := models.CheckoutGrowthStatistics{ + Year: 2023, + NumberDirectories: 10, + MaxPathDepth: 3, + MaxPathLength: 45, + NumberFiles: 25, + TotalSizeFiles: 1024000, + } + + output := captureOutput(func() { + DisplayCheckoutGrowthRow(stats) + }) + + expectedSnippets := []string{ + "2023", + "10", // directories + "3", // max depth + "45", // max path length + "25", // files + "1.0", // should contain part of formatted size (1.0 MB) + } + + for _, expected := range expectedSnippets { + if !strings.Contains(output, expected) { + t.Errorf("expected output to contain %q.\nOutput: %s", expected, output) + } + } +} \ No newline at end of file diff --git a/pkg/git/git.go b/pkg/git/git.go index 440bea8..8333002 100644 --- a/pkg/git/git.go +++ b/pkg/git/git.go @@ -434,6 +434,89 @@ func GetRateOfChanges() (map[int]models.RateStatistics, error) { return calculateRateStatistics(string(output)) } +// GetCheckoutGrowthStats calculates checkout growth statistics for a given year +func GetCheckoutGrowthStats(year int, debug bool) (models.CheckoutGrowthStatistics, error) { + utils.DebugPrint(debug, "Calculating checkout growth stats for year %d", year) + statistics := models.CheckoutGrowthStatistics{Year: year} + + // Build shell command to get file tree at end of year + commandString := fmt.Sprintf("git rev-list --objects --all --before %d-01-01 | git cat-file --batch-check='%%(objecttype) %%(objectname) %%(objectsize:disk) %%(rest)'", year+1) + command := exec.Command(ShellToUse(), "-c", commandString) + output, err := command.Output() + if err != nil { + return statistics, err + } + + // Track unique directories and file statistics + directories := make(map[string]bool) + maxPathDepth := 0 + maxPathLength := 0 + fileCount := 0 + totalSize := int64(0) + + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + + objectType := fields[0] + if objectType != "blob" { + continue + } + + // Extract file path (4th field onward) + filePath := strings.Join(fields[3:], " ") + filePath = strings.TrimSpace(filePath) + if filePath == "" { + continue + } + + fileCount++ + + // Parse size for total + if size, err := strconv.ParseInt(fields[2], 10, 64); err == nil { + totalSize += size + } + + // Calculate path length + if len(filePath) > maxPathLength { + maxPathLength = len(filePath) + } + + // Calculate path depth and collect directories + pathParts := strings.Split(filePath, "/") + depth := len(pathParts) - 1 // Subtract 1 because file itself doesn't count + if depth > maxPathDepth { + maxPathDepth = depth + } + + // Collect all parent directories + currentPath := "" + for i := 0; i < len(pathParts)-1; i++ { + if currentPath == "" { + currentPath = pathParts[i] + } else { + currentPath = currentPath + "/" + pathParts[i] + } + directories[currentPath] = true + } + } + + statistics.NumberDirectories = len(directories) + statistics.MaxPathDepth = maxPathDepth + statistics.MaxPathLength = maxPathLength + statistics.NumberFiles = fileCount + statistics.TotalSizeFiles = totalSize + + utils.DebugPrint(debug, "Finished calculating checkout growth stats for year %d", year) + return statistics, nil +} + // calculateRateStatistics processes git log output and calculates rate statistics func calculateRateStatistics(gitLogOutput string) (map[int]models.RateStatistics, error) { lines := strings.Split(strings.TrimSpace(gitLogOutput), "\n") diff --git a/pkg/git/git_test.go b/pkg/git/git_test.go index 618b02a..e98091d 100644 --- a/pkg/git/git_test.go +++ b/pkg/git/git_test.go @@ -110,3 +110,36 @@ func TestGetGitDirectory(t *testing.T) { func mockRunGitCommand(_ bool, _ ...string) ([]byte, error) { return []byte("git version 2.35.1"), nil } + +func TestGetCheckoutGrowthStats(t *testing.T) { + // Test with current year - should have some data + stats, err := GetCheckoutGrowthStats(2025, false) + if err != nil { + t.Fatalf("GetCheckoutGrowthStats() returned error: %v", err) + } + + if stats.Year != 2025 { + t.Errorf("expected Year to be 2025, got %d", stats.Year) + } + + // In a working git repo, we should have at least some files and directories + if stats.NumberFiles == 0 { + t.Errorf("expected NumberFiles to be greater than 0, got %d", stats.NumberFiles) + } + + if stats.NumberDirectories == 0 { + t.Errorf("expected NumberDirectories to be greater than 0, got %d", stats.NumberDirectories) + } + + if stats.MaxPathDepth < 0 { + t.Errorf("expected MaxPathDepth to be non-negative, got %d", stats.MaxPathDepth) + } + + if stats.MaxPathLength <= 0 { + t.Errorf("expected MaxPathLength to be positive, got %d", stats.MaxPathLength) + } + + if stats.TotalSizeFiles <= 0 { + t.Errorf("expected TotalSizeFiles to be positive, got %d", stats.TotalSizeFiles) + } +} diff --git a/pkg/models/models.go b/pkg/models/models.go index 2d9e83a..ba91bf6 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -89,3 +89,13 @@ type RateStatistics struct { WeekendCommits int // Commits during weekends WorkdayWeekendRatio float64 // Ratio of workday to weekend commits } + +// CheckoutGrowthStatistics holds checkout growth statistics for a specific year +type CheckoutGrowthStatistics struct { + Year int + NumberDirectories int + MaxPathDepth int + MaxPathLength int + NumberFiles int + TotalSizeFiles int64 +} From dbd8a726199dd7b35d7de85b5819d9ff1f140529 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Sep 2025 04:09:38 +0000 Subject: [PATCH 3/8] Address code review feedback with error handling and validation Co-authored-by: steffen <6301+steffen@users.noreply.github.com> --- pkg/git/git.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/git/git.go b/pkg/git/git.go index 8333002..ac2bc49 100644 --- a/pkg/git/git.go +++ b/pkg/git/git.go @@ -440,6 +440,10 @@ func GetCheckoutGrowthStats(year int, debug bool) (models.CheckoutGrowthStatisti statistics := models.CheckoutGrowthStatistics{Year: year} // Build shell command to get file tree at end of year + // Validate year parameter to prevent command injection + if year < 1900 || year > 3000 { + return statistics, fmt.Errorf("invalid year %d: must be between 1900 and 3000", year) + } commandString := fmt.Sprintf("git rev-list --objects --all --before %d-01-01 | git cat-file --batch-check='%%(objecttype) %%(objectname) %%(objectsize:disk) %%(rest)'", year+1) command := exec.Command(ShellToUse(), "-c", commandString) output, err := command.Output() @@ -481,6 +485,8 @@ func GetCheckoutGrowthStats(year int, debug bool) (models.CheckoutGrowthStatisti // Parse size for total if size, err := strconv.ParseInt(fields[2], 10, 64); err == nil { totalSize += size + } else { + utils.DebugPrint(debug, "Warning: could not parse size for file %s: %v", filePath, err) } // Calculate path length @@ -489,6 +495,7 @@ func GetCheckoutGrowthStats(year int, debug bool) (models.CheckoutGrowthStatisti } // Calculate path depth and collect directories + // Git always uses forward slashes in object paths regardless of OS pathParts := strings.Split(filePath, "/") depth := len(pathParts) - 1 // Subtract 1 because file itself doesn't count if depth > maxPathDepth { From 5514ee918ab7ea9a447621cb8b608ab96c8ba513 Mon Sep 17 00:00:00 2001 From: Steffen Hiller <6301+steffen@users.noreply.github.com> Date: Sun, 28 Sep 2025 16:29:31 -0700 Subject: [PATCH 4/8] Use git ls-tree -r instead --- pkg/git/git.go | 106 +++++++++++++++++++++++++++++++------------------ 1 file changed, 67 insertions(+), 39 deletions(-) diff --git a/pkg/git/git.go b/pkg/git/git.go index ac2bc49..e4c0ff1 100644 --- a/pkg/git/git.go +++ b/pkg/git/git.go @@ -240,6 +240,32 @@ func ShellToUse() string { return "sh" } +// GetYearEndCommit returns the commit hash at (just before) the start of the next year +// for the repository's default branch. This represents the snapshot of the default +// branch as of the end of the requested year. If there is no commit prior to the +// boundary (e.g. repository did not exist yet), an empty string is returned. +func GetYearEndCommit(year int, debug bool) (string, error) { + if year < 1900 || year > 3000 { + return "", fmt.Errorf("invalid year %d: must be between 1900 and 3000", year) + } + + defaultBranch, err := GetDefaultBranch() + if err != nil { + return "", fmt.Errorf("could not determine default branch: %v", err) + } + + boundary := fmt.Sprintf("%d-01-01", year+1) + cmd := exec.Command("git", "rev-list", "-1", "--before", boundary, defaultBranch) + out, err := cmd.Output() + if err != nil { + return "", err + } + + hash := strings.TrimSpace(string(out)) + utils.DebugPrint(debug, "Year %d end commit on %s: %s", year, defaultBranch, hash) + return hash, nil +} + // GetContributors returns all commit authors and committers with dates from git history func GetContributors() ([]string, error) { // Execute the git command to get all contributors with their commit dates @@ -436,81 +462,83 @@ func GetRateOfChanges() (map[int]models.RateStatistics, error) { // GetCheckoutGrowthStats calculates checkout growth statistics for a given year func GetCheckoutGrowthStats(year int, debug bool) (models.CheckoutGrowthStatistics, error) { - utils.DebugPrint(debug, "Calculating checkout growth stats for year %d", year) + utils.DebugPrint(debug, "Calculating checkout growth stats (default branch snapshot) for year %d", year) statistics := models.CheckoutGrowthStatistics{Year: year} - // Build shell command to get file tree at end of year - // Validate year parameter to prevent command injection - if year < 1900 || year > 3000 { - return statistics, fmt.Errorf("invalid year %d: must be between 1900 and 3000", year) + commitHash, err := GetYearEndCommit(year, debug) + if err != nil { + return statistics, err } - commandString := fmt.Sprintf("git rev-list --objects --all --before %d-01-01 | git cat-file --batch-check='%%(objecttype) %%(objectname) %%(objectsize:disk) %%(rest)'", year+1) - command := exec.Command(ShellToUse(), "-c", commandString) - output, err := command.Output() + if commitHash == "" { // No commit exists before boundary + utils.DebugPrint(debug, "No year-end commit found for %d; returning empty stats", year) + return statistics, nil + } + + // Resolve repository root to ensure consistent path context even when called from subdirectories + repoRootCmd := exec.Command("git", "rev-parse", "--show-toplevel") + repoRootOut, err := repoRootCmd.Output() + if err != nil { + return statistics, fmt.Errorf("failed to determine repository root: %v", err) + } + repoRoot := strings.TrimSpace(string(repoRootOut)) + + // git ls-tree -r --long + cmd := exec.Command("git", "ls-tree", "-r", "--long", commitHash) + cmd.Dir = repoRoot + output, err := cmd.Output() if err != nil { return statistics, err } - // Track unique directories and file statistics - directories := make(map[string]bool) + directories := make(map[string]struct{}) + var fileCount int + var totalSize int64 maxPathDepth := 0 maxPathLength := 0 - fileCount := 0 - totalSize := int64(0) - lines := strings.Split(string(output), "\n") + lines := strings.Split(strings.TrimSpace(string(output)), "\n") for _, line := range lines { if strings.TrimSpace(line) == "" { continue } fields := strings.Fields(line) - if len(fields) < 4 { + // Expected at least: mode type objectHash size path... + if len(fields) < 5 { continue } - - objectType := fields[0] + objectType := fields[1] if objectType != "blob" { continue } - - // Extract file path (4th field onward) - filePath := strings.Join(fields[3:], " ") + sizeStr := fields[3] + filePath := strings.Join(fields[4:], " ") filePath = strings.TrimSpace(filePath) if filePath == "" { continue } - - fileCount++ - - // Parse size for total - if size, err := strconv.ParseInt(fields[2], 10, 64); err == nil { + if size, err := strconv.ParseInt(sizeStr, 10, 64); err == nil { totalSize += size } else { utils.DebugPrint(debug, "Warning: could not parse size for file %s: %v", filePath, err) } - // Calculate path length - if len(filePath) > maxPathLength { - maxPathLength = len(filePath) + fileCount++ + if l := len(filePath); l > maxPathLength { + maxPathLength = l } - - // Calculate path depth and collect directories - // Git always uses forward slashes in object paths regardless of OS pathParts := strings.Split(filePath, "/") - depth := len(pathParts) - 1 // Subtract 1 because file itself doesn't count + depth := len(pathParts) - 1 if depth > maxPathDepth { maxPathDepth = depth } - - // Collect all parent directories - currentPath := "" + current := "" for i := 0; i < len(pathParts)-1; i++ { - if currentPath == "" { - currentPath = pathParts[i] + if current == "" { + current = pathParts[i] } else { - currentPath = currentPath + "/" + pathParts[i] + current = current + "/" + pathParts[i] } - directories[currentPath] = true + directories[current] = struct{}{} } } @@ -520,7 +548,7 @@ func GetCheckoutGrowthStats(year int, debug bool) (models.CheckoutGrowthStatisti statistics.NumberFiles = fileCount statistics.TotalSizeFiles = totalSize - utils.DebugPrint(debug, "Finished calculating checkout growth stats for year %d", year) + utils.DebugPrint(debug, "Finished calculating checkout growth stats for year %d (commit %s)", year, commitHash) return statistics, nil } From eca7c46e2a311e7ca73dc62a8c5814f7ee6fead9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Sep 2025 00:15:40 +0000 Subject: [PATCH 5/8] Consolidate checkout growth calculation with existing growth stats to eliminate additional git commands Co-authored-by: steffen <6301+steffen@users.noreply.github.com> --- main.go | 10 +- pkg/display/sections/checkout_growth.go | 10 +- pkg/display/sections/checkout_growth_test.go | 6 +- pkg/git/git.go | 132 ++++++------------- pkg/git/git_test.go | 11 +- pkg/models/models.go | 17 ++- 6 files changed, 66 insertions(+), 120 deletions(-) diff --git a/main.go b/main.go index eca451b..2eb42ca 100644 --- a/main.go +++ b/main.go @@ -357,14 +357,8 @@ func main() { sections.DisplayContributorsWithMostCommits(topAuthorsByYear, totalAuthorsByYear, totalCommitsByYear, topCommittersByYear, totalCommittersByYear, allTimeAuthors, allTimeCommitters) } - // Calculate and display checkout growth statistics - checkoutStatistics := make(map[int]models.CheckoutGrowthStatistics) - for year := firstCommitTime.Year(); year <= time.Now().Year(); year++ { - if checkoutStats, err := git.GetCheckoutGrowthStats(year, debug); err == nil { - checkoutStatistics[year] = checkoutStats - } - } - sections.DisplayCheckoutGrowth(checkoutStatistics) + // Display checkout growth statistics using data already collected + sections.DisplayCheckoutGrowth(yearlyStatistics) // Get memory statistics for final output var memoryStatistics runtime.MemStats diff --git a/pkg/display/sections/checkout_growth.go b/pkg/display/sections/checkout_growth.go index fd852d1..dd73f9f 100644 --- a/pkg/display/sections/checkout_growth.go +++ b/pkg/display/sections/checkout_growth.go @@ -10,8 +10,8 @@ import ( ) // DisplayCheckoutGrowth displays the checkout growth statistics section -func DisplayCheckoutGrowth(checkoutStatistics map[int]models.CheckoutGrowthStatistics) { - if len(checkoutStatistics) == 0 { +func DisplayCheckoutGrowth(growthStatistics map[int]models.GrowthStatistics) { + if len(growthStatistics) == 0 { return } @@ -23,20 +23,20 @@ func DisplayCheckoutGrowth(checkoutStatistics map[int]models.CheckoutGrowthStati // Get years and sort them var years []int - for year := range checkoutStatistics { + for year := range growthStatistics { years = append(years, year) } sort.Ints(years) // Display each year's statistics for _, year := range years { - stats := checkoutStatistics[year] + stats := growthStatistics[year] DisplayCheckoutGrowthRow(stats) } } // DisplayCheckoutGrowthRow displays a single row of checkout growth statistics -func DisplayCheckoutGrowthRow(stats models.CheckoutGrowthStatistics) { +func DisplayCheckoutGrowthRow(stats models.GrowthStatistics) { yearDisplay := strconv.Itoa(stats.Year) fmt.Printf("%-9s%11s%12d%18d%14s%16s\n", diff --git a/pkg/display/sections/checkout_growth_test.go b/pkg/display/sections/checkout_growth_test.go index 9127b84..eac3271 100644 --- a/pkg/display/sections/checkout_growth_test.go +++ b/pkg/display/sections/checkout_growth_test.go @@ -9,14 +9,14 @@ import ( func TestDisplayCheckoutGrowth(t *testing.T) { // Test with empty statistics output := captureOutput(func() { - DisplayCheckoutGrowth(make(map[int]models.CheckoutGrowthStatistics)) + DisplayCheckoutGrowth(make(map[int]models.GrowthStatistics)) }) if output != "" { t.Errorf("expected empty output for empty statistics, got: %s", output) } // Test with sample statistics - checkoutStats := map[int]models.CheckoutGrowthStatistics{ + checkoutStats := map[int]models.GrowthStatistics{ 2023: { Year: 2023, NumberDirectories: 10, @@ -63,7 +63,7 @@ func TestDisplayCheckoutGrowth(t *testing.T) { } func TestDisplayCheckoutGrowthRow(t *testing.T) { - stats := models.CheckoutGrowthStatistics{ + stats := models.GrowthStatistics{ Year: 2023, NumberDirectories: 10, MaxPathDepth: 3, diff --git a/pkg/git/git.go b/pkg/git/git.go index ee705a6..325a26c 100644 --- a/pkg/git/git.go +++ b/pkg/git/git.go @@ -149,6 +149,13 @@ func GetGrowthStats(year int, previousGrowthStatistics models.GrowthStatistics, blobsMap := make(map[string]models.FileInformation) var commitsDelta, treesDelta, blobsDelta int var compressedDelta, uncompressedDelta int64 + + // Checkout growth statistics tracking + directories := make(map[string]bool) + maxPathDepth := 0 + maxPathLength := 0 + fileCount := 0 + totalFileSize := int64(0) lines := strings.Split(string(output), "\n") for _, line := range lines { if strings.TrimSpace(line) == "" { @@ -203,6 +210,34 @@ func GetGrowthStats(year int, previousGrowthStatistics models.GrowthStatistics, // LastChange remains zero as we do not parse it here } } + + // Calculate checkout growth statistics for this file + fileCount++ + totalFileSize += compressedSize + + // Calculate path length + if len(filePath) > maxPathLength { + maxPathLength = len(filePath) + } + + // Calculate path depth and collect directories + // Git always uses forward slashes in object paths regardless of OS + pathParts := strings.Split(filePath, "/") + depth := len(pathParts) - 1 // Subtract 1 because file itself doesn't count + if depth > maxPathDepth { + maxPathDepth = depth + } + + // Collect all parent directories + currentPath := "" + for i := 0; i < len(pathParts)-1; i++ { + if currentPath == "" { + currentPath = pathParts[i] + } else { + currentPath = currentPath + "/" + pathParts[i] + } + directories[currentPath] = true + } } } } @@ -240,6 +275,13 @@ func GetGrowthStats(year int, previousGrowthStatistics models.GrowthStatistics, mergedBlobs = append(mergedBlobs, blob) } currentStatistics.LargestFiles = mergedBlobs + + // Set checkout growth statistics + currentStatistics.NumberDirectories = len(directories) + currentStatistics.MaxPathDepth = maxPathDepth + currentStatistics.MaxPathLength = maxPathLength + currentStatistics.NumberFiles = fileCount + currentStatistics.TotalSizeFiles = totalFileSize utils.DebugPrint(debug, "Finished calculating stats for year %d in %v", year, currentStatistics.RunTime) return currentStatistics, nil @@ -473,96 +515,6 @@ func GetRateOfChanges() (map[int]models.RateStatistics, error) { } // GetCheckoutGrowthStats calculates checkout growth statistics for a given year -func GetCheckoutGrowthStats(year int, debug bool) (models.CheckoutGrowthStatistics, error) { - utils.DebugPrint(debug, "Calculating checkout growth stats (default branch snapshot) for year %d", year) - statistics := models.CheckoutGrowthStatistics{Year: year} - - commitHash, err := GetYearEndCommit(year, debug) - if err != nil { - return statistics, err - } - if commitHash == "" { // No commit exists before boundary - utils.DebugPrint(debug, "No year-end commit found for %d; returning empty stats", year) - return statistics, nil - } - - // Resolve repository root to ensure consistent path context even when called from subdirectories - repoRootCmd := exec.Command("git", "rev-parse", "--show-toplevel") - repoRootOut, err := repoRootCmd.Output() - if err != nil { - return statistics, fmt.Errorf("failed to determine repository root: %v", err) - } - repoRoot := strings.TrimSpace(string(repoRootOut)) - - // git ls-tree -r --long - cmd := exec.Command("git", "ls-tree", "-r", "--long", commitHash) - cmd.Dir = repoRoot - output, err := cmd.Output() - if err != nil { - return statistics, err - } - - directories := make(map[string]struct{}) - var fileCount int - var totalSize int64 - maxPathDepth := 0 - maxPathLength := 0 - - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - for _, line := range lines { - if strings.TrimSpace(line) == "" { - continue - } - fields := strings.Fields(line) - // Expected at least: mode type objectHash size path... - if len(fields) < 5 { - continue - } - objectType := fields[1] - if objectType != "blob" { - continue - } - sizeStr := fields[3] - filePath := strings.Join(fields[4:], " ") - filePath = strings.TrimSpace(filePath) - if filePath == "" { - continue - } - if size, err := strconv.ParseInt(sizeStr, 10, 64); err == nil { - totalSize += size - } else { - utils.DebugPrint(debug, "Warning: could not parse size for file %s: %v", filePath, err) - } - - fileCount++ - if l := len(filePath); l > maxPathLength { - maxPathLength = l - } - pathParts := strings.Split(filePath, "/") - depth := len(pathParts) - 1 - if depth > maxPathDepth { - maxPathDepth = depth - } - current := "" - for i := 0; i < len(pathParts)-1; i++ { - if current == "" { - current = pathParts[i] - } else { - current = current + "/" + pathParts[i] - } - directories[current] = struct{}{} - } - } - - statistics.NumberDirectories = len(directories) - statistics.MaxPathDepth = maxPathDepth - statistics.MaxPathLength = maxPathLength - statistics.NumberFiles = fileCount - statistics.TotalSizeFiles = totalSize - - utils.DebugPrint(debug, "Finished calculating checkout growth stats for year %d (commit %s)", year, commitHash) - return statistics, nil -} // calculateRateStatistics processes git log output and calculates rate statistics func calculateRateStatistics(gitLogOutput string) (map[int]models.RateStatistics, error) { diff --git a/pkg/git/git_test.go b/pkg/git/git_test.go index e98091d..5dd9566 100644 --- a/pkg/git/git_test.go +++ b/pkg/git/git_test.go @@ -1,6 +1,7 @@ package git import ( + "git-metrics/pkg/models" "os" "os/exec" "strings" @@ -111,18 +112,18 @@ func mockRunGitCommand(_ bool, _ ...string) ([]byte, error) { return []byte("git version 2.35.1"), nil } -func TestGetCheckoutGrowthStats(t *testing.T) { - // Test with current year - should have some data - stats, err := GetCheckoutGrowthStats(2025, false) +func TestGetGrowthStatsCheckoutData(t *testing.T) { + // Test that GetGrowthStats now includes checkout growth data + stats, err := GetGrowthStats(2025, models.GrowthStatistics{}, false) if err != nil { - t.Fatalf("GetCheckoutGrowthStats() returned error: %v", err) + t.Fatalf("GetGrowthStats() returned error: %v", err) } if stats.Year != 2025 { t.Errorf("expected Year to be 2025, got %d", stats.Year) } - // In a working git repo, we should have at least some files and directories + // In a working git repo, we should have checkout growth data if stats.NumberFiles == 0 { t.Errorf("expected NumberFiles to be greater than 0, got %d", stats.NumberFiles) } diff --git a/pkg/models/models.go b/pkg/models/models.go index 9f3ddde..45a4043 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -52,6 +52,13 @@ type GrowthStatistics struct { BlobsDeltaPercent float64 CompressedDeltaPercent float64 UncompressedDeltaPercent float64 + + // Checkout growth statistics + NumberDirectories int + MaxPathDepth int + MaxPathLength int + NumberFiles int + TotalSizeFiles int64 } // FileInformation holds information about a file in the repository @@ -96,12 +103,4 @@ type RateStatistics struct { WorkdayWeekendRatio float64 // Ratio of workday to weekend commits } -// CheckoutGrowthStatistics holds checkout growth statistics for a specific year -type CheckoutGrowthStatistics struct { - Year int - NumberDirectories int - MaxPathDepth int - MaxPathLength int - NumberFiles int - TotalSizeFiles int64 -} + From 9e48e66b283032b0aa2f2233d1f8d90e663625f0 Mon Sep 17 00:00:00 2001 From: Steffen Hiller <6301+steffen@users.noreply.github.com> Date: Sun, 28 Sep 2025 20:57:06 -0700 Subject: [PATCH 6/8] Revert "Consolidate checkout growth calculation with existing growth stats to eliminate additional git commands" This reverts commit eca7c46e2a311e7ca73dc62a8c5814f7ee6fead9. --- main.go | 10 +- pkg/display/sections/checkout_growth.go | 10 +- pkg/display/sections/checkout_growth_test.go | 6 +- pkg/git/git.go | 132 +++++++++++++------ pkg/git/git_test.go | 11 +- pkg/models/models.go | 17 +-- 6 files changed, 120 insertions(+), 66 deletions(-) diff --git a/main.go b/main.go index 2eb42ca..eca451b 100644 --- a/main.go +++ b/main.go @@ -357,8 +357,14 @@ func main() { sections.DisplayContributorsWithMostCommits(topAuthorsByYear, totalAuthorsByYear, totalCommitsByYear, topCommittersByYear, totalCommittersByYear, allTimeAuthors, allTimeCommitters) } - // Display checkout growth statistics using data already collected - sections.DisplayCheckoutGrowth(yearlyStatistics) + // Calculate and display checkout growth statistics + checkoutStatistics := make(map[int]models.CheckoutGrowthStatistics) + for year := firstCommitTime.Year(); year <= time.Now().Year(); year++ { + if checkoutStats, err := git.GetCheckoutGrowthStats(year, debug); err == nil { + checkoutStatistics[year] = checkoutStats + } + } + sections.DisplayCheckoutGrowth(checkoutStatistics) // Get memory statistics for final output var memoryStatistics runtime.MemStats diff --git a/pkg/display/sections/checkout_growth.go b/pkg/display/sections/checkout_growth.go index dd73f9f..fd852d1 100644 --- a/pkg/display/sections/checkout_growth.go +++ b/pkg/display/sections/checkout_growth.go @@ -10,8 +10,8 @@ import ( ) // DisplayCheckoutGrowth displays the checkout growth statistics section -func DisplayCheckoutGrowth(growthStatistics map[int]models.GrowthStatistics) { - if len(growthStatistics) == 0 { +func DisplayCheckoutGrowth(checkoutStatistics map[int]models.CheckoutGrowthStatistics) { + if len(checkoutStatistics) == 0 { return } @@ -23,20 +23,20 @@ func DisplayCheckoutGrowth(growthStatistics map[int]models.GrowthStatistics) { // Get years and sort them var years []int - for year := range growthStatistics { + for year := range checkoutStatistics { years = append(years, year) } sort.Ints(years) // Display each year's statistics for _, year := range years { - stats := growthStatistics[year] + stats := checkoutStatistics[year] DisplayCheckoutGrowthRow(stats) } } // DisplayCheckoutGrowthRow displays a single row of checkout growth statistics -func DisplayCheckoutGrowthRow(stats models.GrowthStatistics) { +func DisplayCheckoutGrowthRow(stats models.CheckoutGrowthStatistics) { yearDisplay := strconv.Itoa(stats.Year) fmt.Printf("%-9s%11s%12d%18d%14s%16s\n", diff --git a/pkg/display/sections/checkout_growth_test.go b/pkg/display/sections/checkout_growth_test.go index eac3271..9127b84 100644 --- a/pkg/display/sections/checkout_growth_test.go +++ b/pkg/display/sections/checkout_growth_test.go @@ -9,14 +9,14 @@ import ( func TestDisplayCheckoutGrowth(t *testing.T) { // Test with empty statistics output := captureOutput(func() { - DisplayCheckoutGrowth(make(map[int]models.GrowthStatistics)) + DisplayCheckoutGrowth(make(map[int]models.CheckoutGrowthStatistics)) }) if output != "" { t.Errorf("expected empty output for empty statistics, got: %s", output) } // Test with sample statistics - checkoutStats := map[int]models.GrowthStatistics{ + checkoutStats := map[int]models.CheckoutGrowthStatistics{ 2023: { Year: 2023, NumberDirectories: 10, @@ -63,7 +63,7 @@ func TestDisplayCheckoutGrowth(t *testing.T) { } func TestDisplayCheckoutGrowthRow(t *testing.T) { - stats := models.GrowthStatistics{ + stats := models.CheckoutGrowthStatistics{ Year: 2023, NumberDirectories: 10, MaxPathDepth: 3, diff --git a/pkg/git/git.go b/pkg/git/git.go index 325a26c..ee705a6 100644 --- a/pkg/git/git.go +++ b/pkg/git/git.go @@ -149,13 +149,6 @@ func GetGrowthStats(year int, previousGrowthStatistics models.GrowthStatistics, blobsMap := make(map[string]models.FileInformation) var commitsDelta, treesDelta, blobsDelta int var compressedDelta, uncompressedDelta int64 - - // Checkout growth statistics tracking - directories := make(map[string]bool) - maxPathDepth := 0 - maxPathLength := 0 - fileCount := 0 - totalFileSize := int64(0) lines := strings.Split(string(output), "\n") for _, line := range lines { if strings.TrimSpace(line) == "" { @@ -210,34 +203,6 @@ func GetGrowthStats(year int, previousGrowthStatistics models.GrowthStatistics, // LastChange remains zero as we do not parse it here } } - - // Calculate checkout growth statistics for this file - fileCount++ - totalFileSize += compressedSize - - // Calculate path length - if len(filePath) > maxPathLength { - maxPathLength = len(filePath) - } - - // Calculate path depth and collect directories - // Git always uses forward slashes in object paths regardless of OS - pathParts := strings.Split(filePath, "/") - depth := len(pathParts) - 1 // Subtract 1 because file itself doesn't count - if depth > maxPathDepth { - maxPathDepth = depth - } - - // Collect all parent directories - currentPath := "" - for i := 0; i < len(pathParts)-1; i++ { - if currentPath == "" { - currentPath = pathParts[i] - } else { - currentPath = currentPath + "/" + pathParts[i] - } - directories[currentPath] = true - } } } } @@ -275,13 +240,6 @@ func GetGrowthStats(year int, previousGrowthStatistics models.GrowthStatistics, mergedBlobs = append(mergedBlobs, blob) } currentStatistics.LargestFiles = mergedBlobs - - // Set checkout growth statistics - currentStatistics.NumberDirectories = len(directories) - currentStatistics.MaxPathDepth = maxPathDepth - currentStatistics.MaxPathLength = maxPathLength - currentStatistics.NumberFiles = fileCount - currentStatistics.TotalSizeFiles = totalFileSize utils.DebugPrint(debug, "Finished calculating stats for year %d in %v", year, currentStatistics.RunTime) return currentStatistics, nil @@ -515,6 +473,96 @@ func GetRateOfChanges() (map[int]models.RateStatistics, error) { } // GetCheckoutGrowthStats calculates checkout growth statistics for a given year +func GetCheckoutGrowthStats(year int, debug bool) (models.CheckoutGrowthStatistics, error) { + utils.DebugPrint(debug, "Calculating checkout growth stats (default branch snapshot) for year %d", year) + statistics := models.CheckoutGrowthStatistics{Year: year} + + commitHash, err := GetYearEndCommit(year, debug) + if err != nil { + return statistics, err + } + if commitHash == "" { // No commit exists before boundary + utils.DebugPrint(debug, "No year-end commit found for %d; returning empty stats", year) + return statistics, nil + } + + // Resolve repository root to ensure consistent path context even when called from subdirectories + repoRootCmd := exec.Command("git", "rev-parse", "--show-toplevel") + repoRootOut, err := repoRootCmd.Output() + if err != nil { + return statistics, fmt.Errorf("failed to determine repository root: %v", err) + } + repoRoot := strings.TrimSpace(string(repoRootOut)) + + // git ls-tree -r --long + cmd := exec.Command("git", "ls-tree", "-r", "--long", commitHash) + cmd.Dir = repoRoot + output, err := cmd.Output() + if err != nil { + return statistics, err + } + + directories := make(map[string]struct{}) + var fileCount int + var totalSize int64 + maxPathDepth := 0 + maxPathLength := 0 + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + fields := strings.Fields(line) + // Expected at least: mode type objectHash size path... + if len(fields) < 5 { + continue + } + objectType := fields[1] + if objectType != "blob" { + continue + } + sizeStr := fields[3] + filePath := strings.Join(fields[4:], " ") + filePath = strings.TrimSpace(filePath) + if filePath == "" { + continue + } + if size, err := strconv.ParseInt(sizeStr, 10, 64); err == nil { + totalSize += size + } else { + utils.DebugPrint(debug, "Warning: could not parse size for file %s: %v", filePath, err) + } + + fileCount++ + if l := len(filePath); l > maxPathLength { + maxPathLength = l + } + pathParts := strings.Split(filePath, "/") + depth := len(pathParts) - 1 + if depth > maxPathDepth { + maxPathDepth = depth + } + current := "" + for i := 0; i < len(pathParts)-1; i++ { + if current == "" { + current = pathParts[i] + } else { + current = current + "/" + pathParts[i] + } + directories[current] = struct{}{} + } + } + + statistics.NumberDirectories = len(directories) + statistics.MaxPathDepth = maxPathDepth + statistics.MaxPathLength = maxPathLength + statistics.NumberFiles = fileCount + statistics.TotalSizeFiles = totalSize + + utils.DebugPrint(debug, "Finished calculating checkout growth stats for year %d (commit %s)", year, commitHash) + return statistics, nil +} // calculateRateStatistics processes git log output and calculates rate statistics func calculateRateStatistics(gitLogOutput string) (map[int]models.RateStatistics, error) { diff --git a/pkg/git/git_test.go b/pkg/git/git_test.go index 5dd9566..e98091d 100644 --- a/pkg/git/git_test.go +++ b/pkg/git/git_test.go @@ -1,7 +1,6 @@ package git import ( - "git-metrics/pkg/models" "os" "os/exec" "strings" @@ -112,18 +111,18 @@ func mockRunGitCommand(_ bool, _ ...string) ([]byte, error) { return []byte("git version 2.35.1"), nil } -func TestGetGrowthStatsCheckoutData(t *testing.T) { - // Test that GetGrowthStats now includes checkout growth data - stats, err := GetGrowthStats(2025, models.GrowthStatistics{}, false) +func TestGetCheckoutGrowthStats(t *testing.T) { + // Test with current year - should have some data + stats, err := GetCheckoutGrowthStats(2025, false) if err != nil { - t.Fatalf("GetGrowthStats() returned error: %v", err) + t.Fatalf("GetCheckoutGrowthStats() returned error: %v", err) } if stats.Year != 2025 { t.Errorf("expected Year to be 2025, got %d", stats.Year) } - // In a working git repo, we should have checkout growth data + // In a working git repo, we should have at least some files and directories if stats.NumberFiles == 0 { t.Errorf("expected NumberFiles to be greater than 0, got %d", stats.NumberFiles) } diff --git a/pkg/models/models.go b/pkg/models/models.go index 45a4043..9f3ddde 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -52,13 +52,6 @@ type GrowthStatistics struct { BlobsDeltaPercent float64 CompressedDeltaPercent float64 UncompressedDeltaPercent float64 - - // Checkout growth statistics - NumberDirectories int - MaxPathDepth int - MaxPathLength int - NumberFiles int - TotalSizeFiles int64 } // FileInformation holds information about a file in the repository @@ -103,4 +96,12 @@ type RateStatistics struct { WorkdayWeekendRatio float64 // Ratio of workday to weekend commits } - +// CheckoutGrowthStatistics holds checkout growth statistics for a specific year +type CheckoutGrowthStatistics struct { + Year int + NumberDirectories int + MaxPathDepth int + MaxPathLength int + NumberFiles int + TotalSizeFiles int64 +} From e46351c1bc62dbf0df6358097897c7c5c314f1dd Mon Sep 17 00:00:00 2001 From: Steffen Hiller <6301+steffen@users.noreply.github.com> Date: Sun, 28 Sep 2025 21:24:05 -0700 Subject: [PATCH 7/8] Enhance checkout growth statistics by reusing commit data from rate of changes analysis --- main.go | 14 ++++-- pkg/git/git.go | 38 +++++++++------ pkg/git/git_test.go | 112 ++++++++++--------------------------------- pkg/models/models.go | 1 + 4 files changed, 61 insertions(+), 104 deletions(-) diff --git a/main.go b/main.go index eca451b..35e98ba 100644 --- a/main.go +++ b/main.go @@ -345,8 +345,9 @@ func main() { // 4. Largest files sections.PrintLargestFiles(largestFiles, totalFilesCompressedSize, repositoryInformation.TotalBlobs, len(previous.LargestFiles)) - // 5. Rate of changes analysis - if ratesByYear, err := git.GetRateOfChanges(); err == nil && len(ratesByYear) > 0 { + // 5. Rate of changes analysis (also used for checkout growth commit selection) + ratesByYear, ratesErr := git.GetRateOfChanges() + if ratesErr == nil && len(ratesByYear) > 0 { if defaultBranch, branchErr := git.GetDefaultBranch(); branchErr == nil { sections.DisplayRateOfChanges(ratesByYear, defaultBranch) } @@ -358,9 +359,16 @@ func main() { } // Calculate and display checkout growth statistics + // Checkout growth now reuses the commit list gathered for rate statistics. + // For each year we take the YearEndCommitHash (last commit chronologically in that year) + // and run a single git ls-tree against it instead of invoking an additional git rev-list. checkoutStatistics := make(map[int]models.CheckoutGrowthStatistics) for year := firstCommitTime.Year(); year <= time.Now().Year(); year++ { - if checkoutStats, err := git.GetCheckoutGrowthStats(year, debug); err == nil { + commitHash := "" + if stats, ok := ratesByYear[year]; ok { + commitHash = stats.YearEndCommitHash + } + if checkoutStats, err := git.GetCheckoutGrowthStats(year, commitHash, debug); err == nil { checkoutStatistics[year] = checkoutStats } } diff --git a/pkg/git/git.go b/pkg/git/git.go index ee705a6..9cce165 100644 --- a/pkg/git/git.go +++ b/pkg/git/git.go @@ -298,6 +298,7 @@ type contributorEntry struct { // commitInfo stores information about a commit for rate calculations type commitInfo struct { + hash string timestamp time.Time isMerge bool isWorkday bool @@ -462,8 +463,8 @@ func GetRateOfChanges() (map[int]models.RateStatistics, error) { return nil, fmt.Errorf("could not determine default branch: %v", err) } - // Get all commits from default branch with timestamps and merge info - command := exec.Command("git", "log", defaultBranch, "--format=%ct|%P", "--reverse") + // Get all commits from default branch with hash, timestamps and merge info (reverse for chronological order) + command := exec.Command("git", "log", defaultBranch, "--format=%H|%ct|%P", "--reverse") output, err := command.Output() if err != nil { return nil, fmt.Errorf("failed to get commit log: %v", err) @@ -473,16 +474,12 @@ func GetRateOfChanges() (map[int]models.RateStatistics, error) { } // GetCheckoutGrowthStats calculates checkout growth statistics for a given year -func GetCheckoutGrowthStats(year int, debug bool) (models.CheckoutGrowthStatistics, error) { +func GetCheckoutGrowthStats(year int, commitHash string, debug bool) (models.CheckoutGrowthStatistics, error) { utils.DebugPrint(debug, "Calculating checkout growth stats (default branch snapshot) for year %d", year) statistics := models.CheckoutGrowthStatistics{Year: year} - commitHash, err := GetYearEndCommit(year, debug) - if err != nil { - return statistics, err - } - if commitHash == "" { // No commit exists before boundary - utils.DebugPrint(debug, "No year-end commit found for %d; returning empty stats", year) + if strings.TrimSpace(commitHash) == "" { + utils.DebugPrint(debug, "No year-end commit hash provided for %d; returning empty stats", year) return statistics, nil } @@ -581,20 +578,27 @@ func calculateRateStatistics(gitLogOutput string) (map[int]models.RateStatistics } parts := strings.Split(line, "|") - if len(parts) != 2 { + if len(parts) < 2 { // legacy safety check continue } - // Parse timestamp - timestampStr := parts[0] + // Expected format: hash|timestamp|parents... + hash := parts[0] + if len(parts) < 2 { + continue + } + timestampStr := parts[1] timestamp, err := strconv.ParseInt(timestampStr, 10, 64) if err != nil { continue } commitTime := time.Unix(timestamp, 0) - // Check if it's a merge commit (has multiple parents) - parents := strings.TrimSpace(parts[1]) + // Parents (may be empty). If there were parents they start at index 2 joined by spaces from original formatting. + parents := "" + if len(parts) >= 3 { + parents = strings.TrimSpace(strings.Join(parts[2:], "|")) // original parents separated by spaces, not pipes, but safe + } isMerge := strings.Contains(parents, " ") // Check if it's a workday (Monday-Friday) @@ -603,6 +607,7 @@ func calculateRateStatistics(gitLogOutput string) (map[int]models.RateStatistics year := commitTime.Year() commitsByYear[year] = append(commitsByYear[year], commitInfo{ + hash: hash, timestamp: commitTime, isMerge: isMerge, isWorkday: isWorkday, @@ -620,6 +625,11 @@ func calculateRateStatistics(gitLogOutput string) (map[int]models.RateStatistics PercentageOfTotal: float64(len(commits)) / float64(totalCommits) * 100, } + if len(commits) > 0 { + // commits are in chronological order (oldest->newest) due to --reverse + stats.YearEndCommitHash = commits[len(commits)-1].hash + } + // Calculate merge statistics for _, commit := range commits { if commit.isMerge { diff --git a/pkg/git/git_test.go b/pkg/git/git_test.go index e98091d..f6a0d08 100644 --- a/pkg/git/git_test.go +++ b/pkg/git/git_test.go @@ -9,14 +9,9 @@ import ( func TestGetGitVersion(t *testing.T) { version := GetGitVersion() - - // We can't predict the exact version, but we can check that it's not empty - // and follows a typical format like "2.35.1" or similar if version == "" || version == "Unknown" { - t.Errorf("GetGitVersion() returned %q, expected a non-empty git version", version) + t.Fatalf("GetGitVersion() returned %q, expected a non-empty git version", version) } - - // Basic format check - shouldn't contain "git version" prefix since that's stripped if strings.Contains(version, "git version") { t.Errorf("GetGitVersion() = %q, should not contain 'git version' prefix", version) } @@ -30,53 +25,15 @@ func TestGetGitDirectory(t *testing.T) { cleanupFunc func(string) wantErr bool }{ - { - name: "Non-existent path", - path: "/path/does/not/exist", - wantErr: true, - }, - { - name: "Path exists but not a git repository", - setupFunc: func() string { - // Create a temporary directory - tempDir, _ := os.MkdirTemp("", "not-git-repo") - return tempDir - }, - cleanupFunc: func(path string) { - os.RemoveAll(path) - }, - wantErr: true, - }, - { - name: "Valid git repository", - setupFunc: func() string { - // Create a temporary directory and initialize a git repo in it - tempDir, _ := os.MkdirTemp("", "git-repo") - cmd := exec.Command("git", "init", tempDir) - cmd.Run() - return tempDir - }, - cleanupFunc: func(path string) { - os.RemoveAll(path) - }, - wantErr: false, - }, - { - name: "Valid bare repository", - setupFunc: func() string { - // Create a temporary directory and initialize a bare repo in it - tempDir, _ := os.MkdirTemp("", "git-repo-bare") - cmd := exec.Command("git", "init", "--bare", tempDir) - cmd.Run() - return tempDir - }, - cleanupFunc: func(path string) { - os.RemoveAll(path) - }, - wantErr: false, - }, + {name: "Non-existent path", path: "/path/does/not/exist", wantErr: true}, + {name: "Path exists but not a git repository", setupFunc: func() string { d, _ := os.MkdirTemp("", "not-git-repo"); return d }, cleanupFunc: func(p string) { os.RemoveAll(p) }, wantErr: true}, + {name: "Valid git repository", setupFunc: func() string { d, _ := os.MkdirTemp("", "git-repo"); exec.Command("git", "init", d).Run(); return d }, cleanupFunc: func(p string) { os.RemoveAll(p) }, wantErr: false}, + {name: "Valid bare repository", setupFunc: func() string { + d, _ := os.MkdirTemp("", "git-repo-bare") + exec.Command("git", "init", "--bare", d).Run() + return d + }, cleanupFunc: func(p string) { os.RemoveAll(p) }, wantErr: false}, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var path string @@ -86,18 +43,14 @@ func TestGetGitDirectory(t *testing.T) { tt.path = path } } - if tt.cleanupFunc != nil && path != "" { defer tt.cleanupFunc(path) } - gitDir, err := GetGitDirectory(tt.path) if (err != nil) != tt.wantErr { t.Errorf("GetGitDirectory() error = %v, wantErr %v", err, tt.wantErr) } - if err == nil { - // If no error, verify that the path exists and is a git directory if _, err := os.Stat(gitDir); err != nil { t.Errorf("GetGitDirectory() returned path %v that does not exist", gitDir) } @@ -106,40 +59,25 @@ func TestGetGitDirectory(t *testing.T) { } } -// Mock for testing -func mockRunGitCommand(_ bool, _ ...string) ([]byte, error) { - return []byte("git version 2.35.1"), nil -} - func TestGetCheckoutGrowthStats(t *testing.T) { - // Test with current year - should have some data - stats, err := GetCheckoutGrowthStats(2025, false) + rates, err := GetRateOfChanges() if err != nil { - t.Fatalf("GetCheckoutGrowthStats() returned error: %v", err) - } - - if stats.Year != 2025 { - t.Errorf("expected Year to be 2025, got %d", stats.Year) + t.Fatalf("GetRateOfChanges() error: %v", err) } - - // In a working git repo, we should have at least some files and directories - if stats.NumberFiles == 0 { - t.Errorf("expected NumberFiles to be greater than 0, got %d", stats.NumberFiles) + if len(rates) == 0 { + t.Skip("no commits available for rate statistics; skipping checkout growth test") } - - if stats.NumberDirectories == 0 { - t.Errorf("expected NumberDirectories to be greater than 0, got %d", stats.NumberDirectories) - } - - if stats.MaxPathDepth < 0 { - t.Errorf("expected MaxPathDepth to be non-negative, got %d", stats.MaxPathDepth) - } - - if stats.MaxPathLength <= 0 { - t.Errorf("expected MaxPathLength to be positive, got %d", stats.MaxPathLength) - } - - if stats.TotalSizeFiles <= 0 { - t.Errorf("expected TotalSizeFiles to be positive, got %d", stats.TotalSizeFiles) + for year, rateStats := range rates { + stats, cgErr := GetCheckoutGrowthStats(year, rateStats.YearEndCommitHash, false) + if cgErr != nil { + t.Fatalf("GetCheckoutGrowthStats() returned error: %v", cgErr) + } + if stats.Year != year { + t.Errorf("expected Year to be %d, got %d", year, stats.Year) + } + if stats.NumberFiles < 0 || stats.NumberDirectories < 0 || stats.MaxPathDepth < 0 || stats.MaxPathLength < 0 || stats.TotalSizeFiles < 0 { + t.Errorf("invalid stats for year %d: %+v", year, stats) + } + break // only need one year for basic validation } } diff --git a/pkg/models/models.go b/pkg/models/models.go index 9f3ddde..57e6161 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -94,6 +94,7 @@ type RateStatistics struct { WorkdayCommits int // Commits during weekdays WeekendCommits int // Commits during weekends WorkdayWeekendRatio float64 // Ratio of workday to weekend commits + YearEndCommitHash string // Commit hash representing the final state of the year (last commit in that year) } // CheckoutGrowthStatistics holds checkout growth statistics for a specific year From bf04d21414027c14bed83120d2bbed2f30688f39 Mon Sep 17 00:00:00 2001 From: Steffen Hiller <6301+steffen@users.noreply.github.com> Date: Sun, 28 Sep 2025 21:43:03 -0700 Subject: [PATCH 8/8] Refactor progress output formatting for improved readability in main.go and update test for GetRateOfChanges in git_test.go --- main.go | 122 +++++++++++++++++++++++++++++++++----------- pkg/git/git_test.go | 2 +- 2 files changed, 94 insertions(+), 30 deletions(-) diff --git a/main.go b/main.go index 5157fcf..7d4024a 100644 --- a/main.go +++ b/main.go @@ -83,7 +83,9 @@ func main() { if err == nil { trimmed := strings.TrimSpace(string(remoteOutput)) if trimmed != "" { - if progress.ShowProgress { fmt.Printf("Remote ... fetching\n") } + if progress.ShowProgress { + fmt.Printf("Remote ... fetching\n") + } remote = trimmed if progress.ShowProgress { fmt.Printf("\033[1A\033[2KRemote %s\n", remote) @@ -102,7 +104,9 @@ func main() { } // Most recent commit - if progress.ShowProgress { fmt.Printf("Most recent commit ... fetching\n") } + if progress.ShowProgress { + fmt.Printf("Most recent commit ... fetching\n") + } lastCommit := UnknownValue if out, err := git.RunGitCommand(debug, "rev-parse", "--short", "HEAD"); err == nil { hash := strings.TrimSpace(string(out)) @@ -113,20 +117,31 @@ func main() { } } } - if progress.ShowProgress { fmt.Printf("\033[1A\033[2KMost recent commit %s\n", lastCommit) } else { fmt.Printf("Most recent commit %s\n", lastCommit) } + if progress.ShowProgress { + fmt.Printf("\033[1A\033[2KMost recent commit %s\n", lastCommit) + } else { + fmt.Printf("Most recent commit %s\n", lastCommit) + } // First commit & age - if progress.ShowProgress { fmt.Printf("First commit ... fetching\n") } + if progress.ShowProgress { + fmt.Printf("First commit ... fetching\n") + } firstCommit := UnknownValue ageString := UnknownValue var firstCommitTime time.Time if out, err := git.RunGitCommand(debug, "rev-list", "--max-parents=0", "HEAD", "--format=%cD"); err == nil { lines := strings.Split(strings.TrimSpace(string(out)), "\n") - type cinfo struct { hash string; date time.Time } + type cinfo struct { + hash string + date time.Time + } var commits []cinfo for i := 0; i+1 < len(lines); i += 2 { hash := strings.TrimPrefix(lines[i], "commit ") - if len(hash) >= 6 { hash = hash[:6] } + if len(hash) >= 6 { + hash = hash[:6] + } if d, perr := time.Parse("Mon, 2 Jan 2006 15:04:05 -0700", strings.TrimSpace(lines[i+1])); perr == nil { commits = append(commits, cinfo{hash: hash, date: d}) } @@ -139,18 +154,31 @@ func main() { now := time.Now() years, months, days := utils.CalculateYearsMonthsDays(first.date, now) var parts []string - if years > 0 { parts = append(parts, fmt.Sprintf("%d years", years)) } - if months > 0 { parts = append(parts, fmt.Sprintf("%d months", months)) } - if days > 0 { parts = append(parts, fmt.Sprintf("%d days", days)) } + if years > 0 { + parts = append(parts, fmt.Sprintf("%d years", years)) + } + if months > 0 { + parts = append(parts, fmt.Sprintf("%d months", months)) + } + if days > 0 { + parts = append(parts, fmt.Sprintf("%d days", days)) + } ageString = strings.Join(parts, " ") } } - if progress.ShowProgress { fmt.Printf("\033[1A\033[2KFirst commit %s\n", firstCommit) } else { fmt.Printf("First commit %s\n", firstCommit) } - if firstCommit == UnknownValue { fmt.Println("\n\nNo commits found in the repository."); os.Exit(2) } + if progress.ShowProgress { + fmt.Printf("\033[1A\033[2KFirst commit %s\n", firstCommit) + } else { + fmt.Printf("First commit %s\n", firstCommit) + } + if firstCommit == UnknownValue { + fmt.Println("\n\nNo commits found in the repository.") + os.Exit(2) + } fmt.Printf("Age %s\n", ageString) // Historic & estimated growth header - fmt.Println() + fmt.Println() fmt.Println("HISTORIC & ESTIMATED GROWTH ############################################################################################") fmt.Println() fmt.Println("Year Commits Δ % ○ Object size Δ % ○ On-disk size Δ % ○") @@ -201,7 +229,11 @@ func main() { // Determine total unique authors from last year with data if len(yearlyStatistics) > 0 { maxYear := 0 - for y := range yearlyStatistics { if y > maxYear { maxYear = y } } + for y := range yearlyStatistics { + if y > maxYear { + maxYear = y + } + } repositoryInformation.TotalAuthors = yearlyStatistics[maxYear].Authors } @@ -217,20 +249,44 @@ func main() { cumulative.CompressedDelta = cumulative.Compressed - previousCumulative.Compressed cumulative.UncompressedDelta = cumulative.Uncompressed - previousCumulative.Uncompressed - if repositoryInformation.TotalAuthors > 0 { cumulative.AuthorsPercent = float64(cumulative.AuthorsDelta) / float64(repositoryInformation.TotalAuthors) * 100 } - if repositoryInformation.TotalCommits > 0 { cumulative.CommitsPercent = float64(cumulative.CommitsDelta) / float64(repositoryInformation.TotalCommits) * 100 } - if repositoryInformation.TotalTrees > 0 { cumulative.TreesPercent = float64(cumulative.TreesDelta) / float64(repositoryInformation.TotalTrees) * 100 } - if repositoryInformation.TotalBlobs > 0 { cumulative.BlobsPercent = float64(cumulative.BlobsDelta) / float64(repositoryInformation.TotalBlobs) * 100 } - if repositoryInformation.CompressedSize > 0 { cumulative.CompressedPercent = float64(cumulative.CompressedDelta) / float64(repositoryInformation.CompressedSize) * 100 } - if repositoryInformation.UncompressedSize > 0 { cumulative.UncompressedPercent = float64(cumulative.UncompressedDelta) / float64(repositoryInformation.UncompressedSize) * 100 } + if repositoryInformation.TotalAuthors > 0 { + cumulative.AuthorsPercent = float64(cumulative.AuthorsDelta) / float64(repositoryInformation.TotalAuthors) * 100 + } + if repositoryInformation.TotalCommits > 0 { + cumulative.CommitsPercent = float64(cumulative.CommitsDelta) / float64(repositoryInformation.TotalCommits) * 100 + } + if repositoryInformation.TotalTrees > 0 { + cumulative.TreesPercent = float64(cumulative.TreesDelta) / float64(repositoryInformation.TotalTrees) * 100 + } + if repositoryInformation.TotalBlobs > 0 { + cumulative.BlobsPercent = float64(cumulative.BlobsDelta) / float64(repositoryInformation.TotalBlobs) * 100 + } + if repositoryInformation.CompressedSize > 0 { + cumulative.CompressedPercent = float64(cumulative.CompressedDelta) / float64(repositoryInformation.CompressedSize) * 100 + } + if repositoryInformation.UncompressedSize > 0 { + cumulative.UncompressedPercent = float64(cumulative.UncompressedDelta) / float64(repositoryInformation.UncompressedSize) * 100 + } if previousDelta.Year != 0 { - if previousDelta.AuthorsDelta > 0 { cumulative.AuthorsDeltaPercent = diffPercent(cumulative.AuthorsDelta, previousDelta.AuthorsDelta) } - if previousDelta.CommitsDelta > 0 { cumulative.CommitsDeltaPercent = diffPercent(cumulative.CommitsDelta, previousDelta.CommitsDelta) } - if previousDelta.TreesDelta > 0 { cumulative.TreesDeltaPercent = diffPercent(cumulative.TreesDelta, previousDelta.TreesDelta) } - if previousDelta.BlobsDelta > 0 { cumulative.BlobsDeltaPercent = diffPercent(cumulative.BlobsDelta, previousDelta.BlobsDelta) } - if previousDelta.CompressedDelta > 0 { cumulative.CompressedDeltaPercent = diffPercent64(cumulative.CompressedDelta, previousDelta.CompressedDelta) } - if previousDelta.UncompressedDelta > 0 { cumulative.UncompressedDeltaPercent = diffPercent64(cumulative.UncompressedDelta, previousDelta.UncompressedDelta) } + if previousDelta.AuthorsDelta > 0 { + cumulative.AuthorsDeltaPercent = diffPercent(cumulative.AuthorsDelta, previousDelta.AuthorsDelta) + } + if previousDelta.CommitsDelta > 0 { + cumulative.CommitsDeltaPercent = diffPercent(cumulative.CommitsDelta, previousDelta.CommitsDelta) + } + if previousDelta.TreesDelta > 0 { + cumulative.TreesDeltaPercent = diffPercent(cumulative.TreesDelta, previousDelta.TreesDelta) + } + if previousDelta.BlobsDelta > 0 { + cumulative.BlobsDeltaPercent = diffPercent(cumulative.BlobsDelta, previousDelta.BlobsDelta) + } + if previousDelta.CompressedDelta > 0 { + cumulative.CompressedDeltaPercent = diffPercent64(cumulative.CompressedDelta, previousDelta.CompressedDelta) + } + if previousDelta.UncompressedDelta > 0 { + cumulative.UncompressedDeltaPercent = diffPercent64(cumulative.UncompressedDelta, previousDelta.UncompressedDelta) + } } yearlyStatistics[year] = cumulative previousCumulative = cumulative @@ -251,8 +307,12 @@ func main() { return largestFiles[i].Path < largestFiles[j].Path }) var totalFilesCompressedSize int64 - for _, f := range largestFiles { totalFilesCompressedSize += f.CompressedSize } - if len(largestFiles) > 10 { largestFiles = largestFiles[:10] } + for _, f := range largestFiles { + totalFilesCompressedSize += f.CompressedSize + } + if len(largestFiles) > 10 { + largestFiles = largestFiles[:10] + } sections.PrintLargestDirectories(totalStatistics.LargestFiles, repositoryInformation.TotalBlobs, repositoryInformation.CompressedSize) sections.PrintLargestFiles(largestFiles, totalFilesCompressedSize, repositoryInformation.TotalBlobs, len(previous.LargestFiles)) @@ -272,7 +332,9 @@ func main() { if len(ratesByYear) > 0 { // only if we have rate data for year := firstCommitTime.Year(); year <= time.Now().Year(); year++ { commitHash := "" - if rs, ok := ratesByYear[year]; ok { commitHash = rs.YearEndCommitHash } + if rs, ok := ratesByYear[year]; ok { + commitHash = rs.YearEndCommitHash + } if stats, err := git.GetCheckoutGrowthStats(year, commitHash, debug); err == nil { checkoutStatistics[year] = stats } @@ -286,4 +348,6 @@ func main() { } func diffPercent(newVal, oldVal int) float64 { return float64(newVal-oldVal) / float64(oldVal) * 100 } -func diffPercent64(newVal, oldVal int64) float64 { return float64(newVal-oldVal) / float64(oldVal) * 100 } +func diffPercent64(newVal, oldVal int64) float64 { + return float64(newVal-oldVal) / float64(oldVal) * 100 +} diff --git a/pkg/git/git_test.go b/pkg/git/git_test.go index f6a0d08..d88eac0 100644 --- a/pkg/git/git_test.go +++ b/pkg/git/git_test.go @@ -60,7 +60,7 @@ func TestGetGitDirectory(t *testing.T) { } func TestGetCheckoutGrowthStats(t *testing.T) { - rates, err := GetRateOfChanges() + rates, _, err := GetRateOfChanges() if err != nil { t.Fatalf("GetRateOfChanges() error: %v", err) }