diff --git a/main.go b/main.go index 283e997..7d4024a 100644 --- a/main.go +++ b/main.go @@ -28,29 +28,24 @@ const ( func main() { startTime := time.Now() - // Define flags with pflag for better help formatting + // Flags repositoryPath := pflag.StringP("repository", "r", ".", "Path to git repository") showVersion := pflag.Bool("version", false, "Display version information and exit") pflag.BoolVar(&debug, "debug", false, "Enable debug output") noProgress := pflag.Bool("no-progress", false, "Disable progress indicators") showHelp := pflag.BoolP("help", "h", false, "Display this help message") - pflag.Parse() - // Show help and exit if help flag is set if *showHelp { pflag.Usage() os.Exit(0) } - - // Show version and exit if version flag is set if *showVersion { fmt.Printf("git-metrics version %s\n", utils.GetGitMetricsVersion()) os.Exit(0) } - // Set progress visibility based on --no-progress flag and output destination - // Automatically disable progress when output is piped to a file or redirected + // Progress visibility (disabled if redirected) progress.ShowProgress = !*noProgress && utils.IsTerminal(os.Stdout) if !requirements.CheckRequirements() { @@ -58,13 +53,12 @@ func main() { os.Exit(9) } - // Get Git directory and change to repository directory + // Validate repository gitDir, err := git.GetGitDirectory(*repositoryPath) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - if err := os.Chdir(*repositoryPath); err != nil { fmt.Fprintf(os.Stderr, "Error: could not change to repository directory: %v\n", err) os.Exit(1) @@ -75,7 +69,7 @@ func main() { fmt.Println("\nREPOSITORY #############################################################################################################") fmt.Println() - // Get Git directory last modified time + // Git directory last modified lastModified := UnknownValue if info, err := os.Stat(gitDir); err == nil { lastModified = info.ModTime().Format("Mon, 02 Jan 2006 15:04 MST") @@ -83,28 +77,29 @@ func main() { fmt.Printf("Git directory %s\n", gitDir) - // Remote URL - only show if there is one + // Remote remoteOutput, err := git.RunGitCommand(debug, "remote", "get-url", "origin") remote := "" - if err == nil && len(strings.TrimSpace(string(remoteOutput))) > 0 { - if progress.ShowProgress { - fmt.Printf("Remote ... fetching\n") - } - remote = strings.TrimSpace(string(remoteOutput)) - if progress.ShowProgress { - fmt.Printf("\033[1A\033[2KRemote %s\n", remote) - } else { - fmt.Printf("Remote %s\n", remote) + if err == nil { + trimmed := strings.TrimSpace(string(remoteOutput)) + if trimmed != "" { + if progress.ShowProgress { + fmt.Printf("Remote ... fetching\n") + } + remote = trimmed + if progress.ShowProgress { + fmt.Printf("\033[1A\033[2KRemote %s\n", remote) + } else { + fmt.Printf("Remote %s\n", remote) + } } } - // Get fetch time and show last modified only if there's no recent fetch + // Fetch time recentFetch := git.GetLastFetchTime(gitDir) if recentFetch == "" { fmt.Printf("Last modified %s\n", lastModified) - } - - if recentFetch != "" { + } else { fmt.Printf("Most recent fetch %s\n", recentFetch) } @@ -112,15 +107,14 @@ func main() { if progress.ShowProgress { fmt.Printf("Most recent commit ... fetching\n") } - lastHashOutput, err := git.RunGitCommand(debug, "rev-parse", "--short", "HEAD") lastCommit := UnknownValue - if err == nil { - lastHash := strings.TrimSpace(string(lastHashOutput)) - dateCommand := exec.Command("git", "show", "-s", "--format=%cD", lastHash) - commandOutput, err := dateCommand.Output() - if err == nil { - lastDate, _ := time.Parse("Mon, 2 Jan 2006 15:04:05 -0700", strings.TrimSpace(string(commandOutput))) - lastCommit = fmt.Sprintf("%s (%s)", lastDate.Format("Mon, 02 Jan 2006"), lastHash) + if out, err := git.RunGitCommand(debug, "rev-parse", "--short", "HEAD"); err == nil { + hash := strings.TrimSpace(string(out)) + dateCmd := exec.Command("git", "show", "-s", "--format=%cD", hash) + if dcOut, derr := dateCmd.Output(); derr == nil { + if d, perr := time.Parse("Mon, 2 Jan 2006 15:04:05 -0700", strings.TrimSpace(string(dcOut))); perr == nil { + lastCommit = fmt.Sprintf("%s (%s)", d.Format("Mon, 02 Jan 2006"), hash) + } } } if progress.ShowProgress { @@ -129,34 +123,31 @@ func main() { fmt.Printf("Most recent commit %s\n", lastCommit) } - // First commit and age + // First commit & age if progress.ShowProgress { fmt.Printf("First commit ... fetching\n") } - firstOutput, err := git.RunGitCommand(debug, "rev-list", "--max-parents=0", "HEAD", "--format=%cD") firstCommit := UnknownValue ageString := UnknownValue var firstCommitTime time.Time - if err == nil { - lines := strings.Split(strings.TrimSpace(string(firstOutput)), "\n") - type commit struct { + 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 } - var commits []commit - for i := 0; i < len(lines); i += 2 { - if i+1 >= len(lines) { - break + 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] } - hash := strings.TrimPrefix(lines[i], "commit ")[:6] - if date, err := time.Parse("Mon, 2 Jan 2006 15:04:05 -0700", strings.TrimSpace(lines[i+1])); err == nil { - commits = append(commits, commit{hash: hash, date: date}) + 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}) } } if len(commits) > 0 { - sort.Slice(commits, func(i, j int) bool { - return commits[i].date.Before(commits[j].date) - }) + sort.Slice(commits, func(i, j int) bool { return commits[i].date.Before(commits[j].date) }) first := commits[0] firstCommitTime = first.date firstCommit = fmt.Sprintf("%s (%s)", first.date.Format("Mon, 02 Jan 2006"), first.hash) @@ -180,55 +171,47 @@ func main() { } else { fmt.Printf("First commit %s\n", firstCommit) } - - // If there are no commits, exit early if firstCommit == UnknownValue { fmt.Println("\n\nNo commits found in the repository.") os.Exit(2) } - fmt.Printf("Age %s\n", ageString) - // Display the section header before data collection + // Historic & estimated growth header fmt.Println() fmt.Println("HISTORIC & ESTIMATED GROWTH ############################################################################################") fmt.Println() - - // Print table headers before data collection (Year widened to 6 for ^* marker) fmt.Println("Year Commits Δ % ○ Object size Δ % ○ On-disk size Δ % ○") fmt.Println("------------------------------------------------------------------------------------------------------------------------") - // Calculate growth stats and totals var previous models.GrowthStatistics var totalStatistics models.GrowthStatistics - yearlyStatistics := make(map[int]models.GrowthStatistics) - // Start calculation with progress indicator (no newline before progress) for year := firstCommitTime.Year(); year <= time.Now().Year(); year++ { - progress.StartProgress(year, previous, startTime) // Start progress updates - if cumulativeStatistics, err := git.GetGrowthStats(year, previous, debug); err == nil { - totalStatistics = cumulativeStatistics - previous = cumulativeStatistics - yearlyStatistics[year] = cumulativeStatistics - progress.CurrentProgress.Statistics = cumulativeStatistics // Update current progress + progress.StartProgress(year, previous, startTime) + if stats, err := git.GetGrowthStats(year, previous, debug); err == nil { + totalStatistics = stats + previous = stats + yearlyStatistics[year] = stats + progress.CurrentProgress.Statistics = stats } } - progress.StopProgress() // Stop and clear progress line + progress.StopProgress() - // Compute cumulative unique authors per year for historic growth - cumulativeAuthorsByYear, totalAuthors, authorsErr := git.GetCumulativeUniqueAuthorsByYear() - if authorsErr == nil { - // Inject authors into yearly statistics + // Cumulative authors + if cumulativeAuthorsByYear, totalAuthors, err := git.GetCumulativeUniqueAuthorsByYear(); err == nil { for year, stats := range yearlyStatistics { - if authorsCount, ok := cumulativeAuthorsByYear[year]; ok { - stats.Authors = authorsCount + if authors, ok := cumulativeAuthorsByYear[year]; ok { + stats.Authors = authors yearlyStatistics[year] = stats } } + // Add repository info later after percentages computed + _ = totalAuthors } - // Save repository information with totals (including authors) + // Repository info (authors total set later via cumulative data loop) repositoryInformation := models.RepositoryInformation{ Remote: remote, LastCommit: lastCommit, @@ -236,22 +219,29 @@ func main() { Age: ageString, FirstDate: firstCommitTime, TotalCommits: totalStatistics.Commits, - TotalAuthors: totalAuthors, + TotalAuthors: 0, // will adjust below if we can derive TotalTrees: totalStatistics.Trees, TotalBlobs: totalStatistics.Blobs, CompressedSize: totalStatistics.Compressed, UncompressedSize: totalStatistics.Uncompressed, } - // Calculate and store delta, percentage, and delta percentage values + // Determine total unique authors from last year with data + if len(yearlyStatistics) > 0 { + maxYear := 0 + for y := range yearlyStatistics { + if y > maxYear { + maxYear = y + } + } + repositoryInformation.TotalAuthors = yearlyStatistics[maxYear].Authors + } + currentYear := time.Now().Year() var previousCumulative models.GrowthStatistics var previousDelta models.GrowthStatistics - - // Process each year to calculate and store all derived values for year := repositoryInformation.FirstDate.Year(); year <= currentYear; year++ { if cumulative, ok := yearlyStatistics[year]; ok { - // Calculate delta values (year-over-year changes) cumulative.AuthorsDelta = cumulative.Authors - previousCumulative.Authors cumulative.CommitsDelta = cumulative.Commits - previousCumulative.Commits cumulative.TreesDelta = cumulative.Trees - previousCumulative.Trees @@ -259,7 +249,6 @@ func main() { cumulative.CompressedDelta = cumulative.Compressed - previousCumulative.Compressed cumulative.UncompressedDelta = cumulative.Uncompressed - previousCumulative.Uncompressed - // Calculate percentage of total if repositoryInformation.TotalAuthors > 0 { cumulative.AuthorsPercent = float64(cumulative.AuthorsDelta) / float64(repositoryInformation.TotalAuthors) * 100 } @@ -279,47 +268,37 @@ func main() { cumulative.UncompressedPercent = float64(cumulative.UncompressedDelta) / float64(repositoryInformation.UncompressedSize) * 100 } - // Calculate delta percentage changes (Δ%) - if previousDelta.Year != 0 { // Skip first year + if previousDelta.Year != 0 { if previousDelta.AuthorsDelta > 0 { - cumulative.AuthorsDeltaPercent = float64(cumulative.AuthorsDelta-previousDelta.AuthorsDelta) / float64(previousDelta.AuthorsDelta) * 100 + cumulative.AuthorsDeltaPercent = diffPercent(cumulative.AuthorsDelta, previousDelta.AuthorsDelta) } if previousDelta.CommitsDelta > 0 { - cumulative.CommitsDeltaPercent = float64(cumulative.CommitsDelta-previousDelta.CommitsDelta) / float64(previousDelta.CommitsDelta) * 100 + cumulative.CommitsDeltaPercent = diffPercent(cumulative.CommitsDelta, previousDelta.CommitsDelta) } if previousDelta.TreesDelta > 0 { - cumulative.TreesDeltaPercent = float64(cumulative.TreesDelta-previousDelta.TreesDelta) / float64(previousDelta.TreesDelta) * 100 + cumulative.TreesDeltaPercent = diffPercent(cumulative.TreesDelta, previousDelta.TreesDelta) } if previousDelta.BlobsDelta > 0 { - cumulative.BlobsDeltaPercent = float64(cumulative.BlobsDelta-previousDelta.BlobsDelta) / float64(previousDelta.BlobsDelta) * 100 + cumulative.BlobsDeltaPercent = diffPercent(cumulative.BlobsDelta, previousDelta.BlobsDelta) } if previousDelta.CompressedDelta > 0 { - cumulative.CompressedDeltaPercent = float64(cumulative.CompressedDelta-previousDelta.CompressedDelta) / float64(previousDelta.CompressedDelta) * 100 + cumulative.CompressedDeltaPercent = diffPercent64(cumulative.CompressedDelta, previousDelta.CompressedDelta) } if previousDelta.UncompressedDelta > 0 { - cumulative.UncompressedDeltaPercent = float64(cumulative.UncompressedDelta-previousDelta.UncompressedDelta) / float64(previousDelta.UncompressedDelta) * 100 + cumulative.UncompressedDeltaPercent = diffPercent64(cumulative.UncompressedDelta, previousDelta.UncompressedDelta) } } - - // Store the updated statistics back in the map yearlyStatistics[year] = cumulative - - // Update for next iteration previousCumulative = cumulative previousDelta = cumulative } } - // Display unified historic and estimated growth using the new function sections.DisplayUnifiedGrowth(yearlyStatistics, repositoryInformation, firstCommitTime, recentFetch, lastModified) - // 1. Largest file extensions sections.PrintTopFileExtensions(previous.LargestFiles, repositoryInformation.TotalBlobs, repositoryInformation.CompressedSize) - - // 2. Largest file extensions on-disk size growth sections.PrintFileExtensionGrowth(yearlyStatistics) - // Prepare largest files data once for sections 3 & 4 largestFiles := totalStatistics.LargestFiles sort.Slice(largestFiles, func(i, j int) bool { if largestFiles[i].CompressedSize != largestFiles[j].CompressedSize { @@ -327,37 +306,48 @@ func main() { } return largestFiles[i].Path < largestFiles[j].Path }) - var totalFilesCompressedSize int64 - for _, file := range largestFiles { - totalFilesCompressedSize += file.CompressedSize + for _, f := range largestFiles { + totalFilesCompressedSize += f.CompressedSize } - if len(largestFiles) > 10 { largestFiles = largestFiles[:10] } - - // 3. Largest directories sections.PrintLargestDirectories(totalStatistics.LargestFiles, repositoryInformation.TotalBlobs, repositoryInformation.CompressedSize) - - // 4. Largest files sections.PrintLargestFiles(largestFiles, totalFilesCompressedSize, repositoryInformation.TotalBlobs, len(previous.LargestFiles)) - // 5. Rate of changes analysis - if ratesByYear, branchName, err := git.GetRateOfChanges(); err == nil && len(ratesByYear) > 0 { + // Rate of changes (provides commit hashes for checkout growth) + ratesByYear, branchName, ratesErr := git.GetRateOfChanges() + if ratesErr == nil && len(ratesByYear) > 0 { sections.DisplayRateOfChanges(ratesByYear, branchName) } - // 6 & 7. Authors with most commits, then Committers with most commits + // Contributors (authors & committers) if topAuthorsByYear, totalAuthorsByYear, totalCommitsByYear, topCommittersByYear, totalCommittersByYear, allTimeAuthors, allTimeCommitters, err := git.GetTopCommitAuthors(3); err == nil && len(topAuthorsByYear) > 0 { sections.DisplayContributorsWithMostCommits(topAuthorsByYear, totalAuthorsByYear, totalCommitsByYear, topCommittersByYear, totalCommittersByYear, allTimeAuthors, allTimeCommitters) } - // Get memory statistics for final output - var memoryStatistics runtime.MemStats - runtime.ReadMemStats(&memoryStatistics) + // Checkout growth (reuse ratesByYear commit hashes) + checkoutStatistics := make(map[int]models.CheckoutGrowthStatistics) + 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 stats, err := git.GetCheckoutGrowthStats(year, commitHash, debug); err == nil { + checkoutStatistics[year] = stats + } + } + } + sections.DisplayCheckoutGrowth(checkoutStatistics) + + var mem runtime.MemStats + runtime.ReadMemStats(&mem) + fmt.Printf("\nFinished in %s with a memory footprint of %s.\n", utils.FormatDuration(time.Since(startTime)), strings.TrimSpace(utils.FormatSize(int64(mem.Sys)))) +} - fmt.Printf("\nFinished in %s with a memory footprint of %s.\n", - utils.FormatDuration(time.Since(startTime)), - strings.TrimSpace(utils.FormatSize(int64(memoryStatistics.Sys)))) +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 } 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 cb7fd0c..f1b6cbd 100644 --- a/pkg/git/git.go +++ b/pkg/git/git.go @@ -252,6 +252,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 @@ -272,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 @@ -432,7 +459,7 @@ func GetCumulativeUniqueAuthorsByYear() (map[int]int, int, error) { // GetRateOfChanges calculates commit rate statistics for the current branch by year func GetRateOfChanges() (map[int]models.RateStatistics, string, error) { - // Get current branch name instead of remote default branch + // Determine current branch (we want stats for whatever is checked out) cmd := exec.Command("git", "branch", "--show-current") branchOutput, err := cmd.Output() if err != nil { @@ -443,8 +470,8 @@ func GetRateOfChanges() (map[int]models.RateStatistics, string, error) { return nil, "", fmt.Errorf("no current branch found") } - // Get all commits from current branch with timestamps, merge info, and authors - command := exec.Command("git", "log", currentBranch, "--format=%ct|%P|%an", "--reverse") + // Get all commits (chronological) with hash, timestamp, parents, author + command := exec.Command("git", "log", currentBranch, "--format=%H|%ct|%P|%an", "--reverse") output, err := command.Output() if err != nil { return nil, "", fmt.Errorf("failed to get commit log: %v", err) @@ -454,6 +481,94 @@ func GetRateOfChanges() (map[int]models.RateStatistics, string, error) { return rateStats, currentBranch, err } +// GetCheckoutGrowthStats calculates checkout growth statistics for a given year +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} + + if strings.TrimSpace(commitHash) == "" { + utils.DebugPrint(debug, "No year-end commit hash provided 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) { lines := strings.Split(strings.TrimSpace(gitLogOutput), "\n") @@ -471,24 +586,21 @@ func calculateRateStatistics(gitLogOutput string) (map[int]models.RateStatistics } parts := strings.Split(line, "|") - if len(parts) != 3 { + if len(parts) < 4 { // hash|timestamp|parents|author (parents may be empty string) continue } - // Parse timestamp - timestampStr := parts[0] + hash := parts[0] + 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 := parts[2] isMerge := strings.Contains(parents, " ") - - // Get author name - author := strings.TrimSpace(parts[2]) + author := strings.TrimSpace(parts[3]) // Check if it's a workday (Monday-Friday) weekday := commitTime.Weekday() @@ -496,6 +608,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, @@ -514,12 +627,14 @@ func calculateRateStatistics(gitLogOutput string) (map[int]models.RateStatistics PercentageOfTotal: float64(len(commits)) / float64(totalCommits) * 100, } - // Calculate merge statistics and count unique authors + if len(commits) > 0 { + stats.YearEndCommitHash = commits[len(commits)-1].hash // chronological order from --reverse + } + uniqueAuthors := make(map[string]bool) for _, commit := range commits { - // Count unique authors uniqueAuthors[commit.author] = true - + // Merge vs direct if commit.isMerge { stats.MergeCommits++ } else { @@ -533,7 +648,6 @@ func calculateRateStatistics(gitLogOutput string) (map[int]models.RateStatistics } } - // Set the number of active authors stats.ActiveAuthors = len(uniqueAuthors) if stats.TotalCommits > 0 { diff --git a/pkg/git/git_test.go b/pkg/git/git_test.go index 618b02a..d88eac0 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,7 +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) { + rates, _, err := GetRateOfChanges() + if err != nil { + t.Fatalf("GetRateOfChanges() error: %v", err) + } + if len(rates) == 0 { + t.Skip("no commits available for rate statistics; skipping checkout growth test") + } + 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 90c6898..e08280a 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -95,4 +95,15 @@ 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 +type CheckoutGrowthStatistics struct { + Year int + NumberDirectories int + MaxPathDepth int + MaxPathLength int + NumberFiles int + TotalSizeFiles int64 }