Skip to content

Add --include-unreachable flag and related processing #141

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
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