Skip to content
Open
11 changes: 9 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,15 @@ Please note that this project is released with a [Contributor Code of Conduct][c
## Submitting a pull request

1. [Fork][fork] and clone the repository
2. Configure and install the dependencies: `script/bootstrap`
3. Make sure the tests pass on your machine: `make test`



2. Configure and install the dependencies
- On Unix-like machines: `script/bootstrap`
- On Windows: `script/bootstrap.ps1` (requires PowerShell 7+)
3. Make sure the tests pass on your machine
- On Unix-like machines: `make test`
- On Windows machines: `make -f Makefile.win test` (because there's a different Makefile when building on Windows)
4. Create a new branch: `git checkout -b my-branch-name`
5. Make your change, add tests, and make sure the tests still pass
6. Push to your fork and [submit a pull request][pr]
Expand Down
57 changes: 57 additions & 0 deletions Makefile.win
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Windows-specific Makefile for git-sizer designed for PowerShell

PACKAGE := github.com/github/git-sizer
GO111MODULES := 1

# Use the project's go wrapper script via the -File parameter to avoid loading your profile
GOSCRIPT := $(CURDIR)/script/go.ps1
GO := pwsh.exe -NoProfile -ExecutionPolicy Bypass -File $(GOSCRIPT)

# Get the build version from git using try/catch instead of "||"
BUILD_VERSION := $(shell pwsh.exe -NoProfile -ExecutionPolicy Bypass -Command "try { git describe --tags --always --dirty 2>$$null } catch { Write-Output 'unknown' }")
LDFLAGS := -X github.com/github/git-sizer/main.BuildVersion=$(BUILD_VERSION)
GOFLAGS := -mod=readonly

ifdef USE_ISATTY
GOFLAGS := $(GOFLAGS) --tags isatty
endif

# Find all Go source files
GO_SRC_FILES := $(shell powershell -NoProfile -ExecutionPolicy Bypass -Command "Get-ChildItem -Path . -Filter *.go -Recurse | Select-Object -ExpandProperty FullName")

# Define common PowerShell command
PWSH := @powershell -NoProfile -ExecutionPolicy Bypass -Command

# Default target
all: bin/git-sizer.exe

# Main binary target - depend on all Go source files
bin/git-sizer.exe: $(GO_SRC_FILES)
$(PWSH) "if (-not (Test-Path bin)) { New-Item -ItemType Directory -Path bin | Out-Null }"
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -a -o .\bin\git-sizer.exe .

# Test target - explicitly run the build first to ensure binary is up to date
test:
@$(MAKE) -f Makefile.win bin/git-sizer.exe
@$(MAKE) -f Makefile.win gotest

# Run go tests
gotest:
$(GO) test -timeout 60s $(GOFLAGS) -ldflags "$(LDFLAGS)" ./...

# Clean up builds
clean:
$(PWSH) "if (Test-Path bin) { Remove-Item -Recurse -Force bin }"

# Help target
help:
$(PWSH) "Write-Host 'Windows Makefile for git-sizer' -ForegroundColor Cyan"
$(PWSH) "Write-Host ''"
$(PWSH) "Write-Host 'Targets:' -ForegroundColor Green"
$(PWSH) "Write-Host ' all - Build git-sizer (default)'"
$(PWSH) "Write-Host ' test - Run tests'"
$(PWSH) "Write-Host ' clean - Clean build artifacts'"
$(PWSH) "Write-Host ''"
$(PWSH) "Write-Host 'Example usage:' -ForegroundColor Green"
$(PWSH) "Write-Host ' make -f Makefile.win'"
$(PWSH) "Write-Host ' make -f Makefile.win test'"
27 changes: 27 additions & 0 deletions git-sizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

"github.com/spf13/pflag"

"github.com/github/git-sizer/counts"
"github.com/github/git-sizer/git"
"github.com/github/git-sizer/internal/refopts"
"github.com/github/git-sizer/isatty"
Expand Down Expand Up @@ -46,6 +47,7 @@
gitconfig: 'sizer.jsonVersion'.
--[no-]progress report (don't report) progress to stderr. Can
be set via gitconfig: 'sizer.progress'.
--include-unreachable include unreachable objects
--version only report the git-sizer version number

Object selection:
Expand Down Expand Up @@ -131,6 +133,7 @@
var progress bool
var version bool
var showRefs bool
var includeUnreachable bool

// Try to open the repository, but it's not an error yet if this
// fails, because the user might only be asking for `--help`.
Expand Down Expand Up @@ -207,6 +210,7 @@
rgb.AddRefopts(flags)

flags.BoolVar(&showRefs, "show-refs", false, "list the references being processed")
flags.BoolVar(&includeUnreachable, "include-unreachable", false, "include unreachable objects")

flags.SortFlags = false

Expand Down Expand Up @@ -331,6 +335,29 @@
return fmt.Errorf("error scanning repository: %w", err)
}

// Calculate the actual size of the .git directory.
gitDir, err := repo.GitDir()
if err != nil {
return fmt.Errorf("error getting Git directory path: %w", err)
}

gitDirSize, err := sizes.CalculateGitDirSize(gitDir)
if err != nil {
return fmt.Errorf("error calculating Git directory size: %w", err)
}

historySize.GitDirSize = gitDirSize

// Get unreachable object stats and add to output if requested
if includeUnreachable {
historySize.ShowUnreachable = true
unreachableStats, err := repo.GetUnreachableStats()
if err == nil {
historySize.UnreachableObjectCount = counts.Count32(unreachableStats.Count)

Check failure on line 356 in git-sizer.go

View workflow job for this annotation

GitHub Actions / test (macos-latest)

cannot use counts.Count32(unreachableStats.Count) (type counts.Count32) as type counts.Count64 in assignment

Check failure on line 356 in git-sizer.go

View workflow job for this annotation

GitHub Actions / lint

cannot use counts.Count32(unreachableStats.Count) (value of type counts.Count32) as counts.Count64 value in assignment (typecheck)

Check failure on line 356 in git-sizer.go

View workflow job for this annotation

GitHub Actions / test (windows-latest)

cannot use counts.Count32(unreachableStats.Count) (type counts.Count32) as type counts.Count64 in assignment

Check failure on line 356 in git-sizer.go

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest)

cannot use counts.Count32(unreachableStats.Count) (type counts.Count32) as type counts.Count64 in assignment
historySize.UnreachableObjectSize = counts.Count64(unreachableStats.Size)
}
}

if jsonOutput {
var j []byte
var err error
Expand Down
104 changes: 101 additions & 3 deletions git/git.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package git

import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strings"
)

// ObjectType represents the type of a Git object ("blob", "tree",
Expand Down Expand Up @@ -150,11 +153,14 @@

// GitDir returns the path to `repo`'s `GIT_DIR`. It might be absolute
// or it might be relative to the current directory.
func (repo *Repository) GitDir() string {
return repo.gitDir
func (repo *Repository) GitDir() (string, error) {
if repo.gitDir == "" {
return "", errors.New("gitDir is not set")
}
return repo.gitDir, nil
}

// GitPath returns that path of a file within the git repository, by
// GitPath returns the path of a file within the git repository, by
// calling `git rev-parse --git-path $relPath`. The returned path is
// relative to the current directory.
func (repo *Repository) GitPath(relPath string) (string, error) {
Expand All @@ -170,3 +176,95 @@
// current directory, we can use it as-is:
return string(bytes.TrimSpace(out)), nil
}

// UnreachableStats holds the count and size of unreachable objects.
type UnreachableStats struct {
Count int64
Size int64
}

// GetUnreachableStats runs 'git fsck --unreachable --no-reflogs --full'
// and returns the count and total size of unreachable objects.
// This implementation collects all OIDs from fsck output and then uses
// batch mode to efficiently retrieve their sizes.
func (repo *Repository) GetUnreachableStats() (UnreachableStats, error) {
// Run git fsck. Using CombinedOutput captures both stdout and stderr.
cmd := exec.Command(repo.gitBin, "-C", repo.gitDir, "fsck", "--unreachable", "--no-reflogs", "--full")

Check failure on line 192 in git/git.go

View workflow job for this annotation

GitHub Actions / lint

G204: Subprocess launched with a potential tainted input or cmd arguments (gosec)
Comment thread
ScottArbeit marked this conversation as resolved.
Outdated
cmd.Env = os.Environ()
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "An error occurred trying to process unreachable objects.")
os.Stderr.Write(output)

Check failure on line 198 in git/git.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `os.Stderr.Write` is not checked (errcheck)
fmt.Fprintln(os.Stderr)
return UnreachableStats{Count: 0, Size: 0}, err
}

var oids []string
count := int64(0)
for _, line := range bytes.Split(output, []byte{'\n'}) {
fields := bytes.Fields(line)
// Expected line format: "unreachable <type> <oid> ..."
if len(fields) >= 3 && string(fields[0]) == "unreachable" {
count++
oid := string(fields[2])
oids = append(oids, oid)
}
}

// Retrieve the total size using batch mode.
totalSize, err := repo.getTotalSizeFromOids(oids)
if err != nil {
return UnreachableStats{}, fmt.Errorf("failed to get sizes via batch mode: %w", err)
}

return UnreachableStats{Count: count, Size: totalSize}, nil
}

// getTotalSizeFromOids uses 'git cat-file --batch-check' to retrieve sizes for
// the provided OIDs. It writes each OID to stdin and reads back lines in the
// format: "<oid> <type> <size>".
func (repo *Repository) getTotalSizeFromOids(oids []string) (int64, error) {
cmd := exec.Command(repo.gitBin, "-C", repo.gitDir, "cat-file", "--batch-check")

Check failure on line 228 in git/git.go

View workflow job for this annotation

GitHub Actions / lint

G204: Subprocess launched with a potential tainted input or cmd arguments (gosec)
stdinPipe, err := cmd.StdinPipe()
if err != nil {
return 0, fmt.Errorf("failed to get stdin pipe: %w", err)
}
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return 0, fmt.Errorf("failed to get stdout pipe: %w", err)
}

if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("failed to start git cat-file batch: %w", err)
}

// Write all OIDs to the batch process.
go func() {
defer stdinPipe.Close()

Check failure on line 244 in git/git.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `stdinPipe.Close` is not checked (errcheck)
for _, oid := range oids {
io.WriteString(stdinPipe, oid+"\n")

Check failure on line 246 in git/git.go

View workflow job for this annotation

GitHub Actions / lint

Error return value of `io.WriteString` is not checked (errcheck)
}
}()

var totalSize int64
scanner := bufio.NewScanner(stdoutPipe)
// Each line is expected to be: "<oid> <type> <size>"
for scanner.Scan() {
parts := strings.Fields(scanner.Text())
if len(parts) == 3 {
var size int64
fmt.Sscanf(parts[2], "%d", &size)

Check failure on line 257 in git/git.go

View workflow job for this annotation

GitHub Actions / lint

G104: Errors unhandled. (gosec)
totalSize += size
} else {
return 0, fmt.Errorf("unexpected output format: %s", scanner.Text())
}
}
if err := scanner.Err(); err != nil {
return 0, fmt.Errorf("error reading git cat-file output: %w", err)
}
if err := cmd.Wait(); err != nil {
return 0, fmt.Errorf("git cat-file batch process error: %w", err)
}
return totalSize, nil
}
18 changes: 18 additions & 0 deletions script/bootstrap.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/usr/bin/env pwsh

# Exit immediately if any command fails
$ErrorActionPreference = "Stop"

# Change directory to the parent directory of the script
Set-Location -Path (Split-Path -Parent $PSCommandPath | Split-Path -Parent)

# Set ROOTDIR environment variable to the current directory
$env:ROOTDIR = (Get-Location).Path

# Check if the operating system is macOS
if ($IsMacOS) {
brew bundle
}

# Source the ensure-go-installed.ps1 script
. ./script/ensure-go-installed.ps1
64 changes: 64 additions & 0 deletions script/ensure-go-installed.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# This script is meant to be sourced with ROOTDIR set.

if (-not $env:ROOTDIR) {
$env:ROOTDIR = (Resolve-Path (Join-Path $scriptDir "..")).Path
}

# Function to check if Go is installed and at least version 1.21
function GoOk {
$goVersionOutput = & go version 2>$null
if ($goVersionOutput) {
$goVersion = $goVersionOutput -match 'go(\d+)\.(\d+)' | Out-Null
$majorVersion = [int]$Matches[1]
$minorVersion = [int]$Matches[2]
return ($majorVersion -eq 1 -and $minorVersion -ge 21)
}
return $false
}

# Function to set up a local Go installation if available
function SetUpVendoredGo {
$GO_VERSION = "go1.23.7"
$VENDORED_GOROOT = Join-Path -Path $env:ROOTDIR -ChildPath "vendor/$GO_VERSION/go"
if (Test-Path -Path "$VENDORED_GOROOT/bin/go") {
$env:GOROOT = $VENDORED_GOROOT
$env:PATH = "$env:GOROOT/bin;$env:PATH"
}
}

# Function to check if Make is installed and install it if needed
function EnsureMakeInstalled {
$makeInstalled = $null -ne (Get-Command "make" -ErrorAction SilentlyContinue)
if (-not $makeInstalled) {
#Write-Host "Installing Make using winget..."
winget install --no-upgrade --nowarn -e --id GnuWin32.Make
if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne 0x8A150061) {
Write-Error "Failed to install Make. Please install it manually. Exit code: $LASTEXITCODE"
}
# Refresh PATH to include the newly installed Make
$env:PATH = [System.Environment]::GetEnvironmentVariable("PATH", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("PATH", "User")
}

# Add GnuWin32 bin directory directly to the PATH
$gnuWin32Path = "C:\Program Files (x86)\GnuWin32\bin"
if (Test-Path -Path $gnuWin32Path) {
$env:PATH = "$gnuWin32Path;$env:PATH"
} else {
Write-Host "Couldn't find GnuWin32 bin directory at the expected location."
# Also refresh PATH from environment variables as a fallback
$env:PATH = [System.Environment]::GetEnvironmentVariable("PATH", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("PATH", "User")
}
}

SetUpVendoredGo

if (-not (GoOk)) {
& ./script/install-vendored-go >$null
if ($LASTEXITCODE -ne 0) {
exit 1
}
SetUpVendoredGo
}

# Ensure Make is installed
EnsureMakeInstalled
21 changes: 21 additions & 0 deletions script/go.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Ensure that script errors stop execution
$ErrorActionPreference = "Stop"

# Determine the root directory of the project.
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$ROOTDIR = (Resolve-Path (Join-Path $scriptDir "..")).Path

# Source the ensure-go-installed functionality.
# (This assumes you have a corresponding PowerShell version of ensure-go-installed.
# If not, you could call the bash version via bash.exe if available.)
$ensureScript = Join-Path $ROOTDIR "script\ensure-go-installed.ps1"
if (Test-Path $ensureScript) {
. $ensureScript
} else {
Write-Error "Unable to locate '$ensureScript'. Please provide a PowerShell version of ensure-go-installed."
}

# Execute the actual 'go' command with passed arguments.
# This re-invokes the Go tool in PATH.
$goExe = "go"
& $goExe @args
Loading
Loading