Build a Git Growth Guard for Claude Code

Build a Go CLI and Claude Code hook that guards against Git repository growth.

Introduction

30 Second Summary

A coding assistant can turn one request into a pile of new files before you notice how much space the project has gained. One oversized asset can make every future download heavier.

In this project, you will build repo-size-guard. This standard-library-only Go CLI checks proposed Git growth before supported Claude Code Bash calls run.

What You'll Build

You'll watch repo-size-guard deny Claude Code's oversized binary commit before the Bash call runs.

By the end of this project, you'll have:

  • A size report that lets you compare raw file bytes with projected Git blob growth before content enters history.
  • A conservative command gate that stays silent for verified safe work. It requests confirmation for warnings or uncertainty. It denies failure-level staged growth.
  • A public evidence-backed repository whose disposable tests prove policy outcomes. Its reproducible benchmarks document performance. A Mermaid diagram explains the hook flow.
  • Secret Mission: Deny a known file-mutating command chain before Bash can create or stage its oversized destination file.

Are there any prerequisites?

Your Intel Mac needs macOS 13 or later. This project assumes Claude Code, Git, Visual Studio Code, Terminal, GitHub CLI, and a GitHub account are ready before Step 1 installs Go 1.27.3.

Before We Start

This opening checkpoint locks in the scope of repo-size-guard as a local guard that conservatively checks Git growth before supported Claude Code Bash calls run. Its narrow boundary requires confirmation for unverified command behavior or cleaned Git content because complete shell security sits outside the project's scope.

Install Go and Launch the CLI

repo-size-guard eventually evaluates Git work proposed by Claude Code. A missing compiler would make every later result suspect.

Go is the only missing prerequisite. A verified toolchain gives the starter CLI a trustworthy place to run.

In this step, get ready to:
  • Verify the existing developer tools plus their sign-ins.
  • Install Go 1.27.3 for Intel macOS.
  • Initialize a Go module that prints its version.
Verify the existing tools and sign-ins

The first checks confirm that your existing tools are reachable from Terminal. They also catch an outdated Claude Code installation before the project enables a fail-closed hook.

  • Press Cmd+Space to open Spotlight.
  • Type Terminal into Spotlight.
  • Press Return to open Terminal.
  • Check Git, Claude Code, GitHub CLI, plus both sign-ins by running:
git --version
claude --version
claude auth status
gh auth status

What do these checks prove?

  • The first command confirms that Git is available for repository setup.
  • The second command prints the installed Claude Code version.
  • The final two commands confirm that Claude Code plus GitHub CLI can use their authenticated accounts.
  • Choose the tab that matches your output.

✔️ Git, Claude Code, and GitHub CLI are ready

Your existing tools are ready. Claude Code reports 2.1.295 or later.

That clears the first dependency check. Both account status commands also confirm authentication.

ⓧ Claude Code is below 2.1.295

The later hook uses fail-closed behavior that requires Claude Code 2.1.295 or later. Update Claude Code before creating the project.

  • Update Claude Code by running this command:
claude update

What does this command do?

This command updates Claude Code to the current supported release. The newer runtime can block a Bash action when a project hook fails.

  • Repeat the four tool checks shown above.

You should now see Claude Code 2.1.295 or later. The authentication checks should also succeed.

Update still not working?

Check that the update completed before opening another Terminal window. A second installation of Claude Code can leave an older executable earlier in your command path.

Ask for help with the installation path: Help me find why Claude Code still reports an older version after updating.

ⓧ An authentication check fails

A failed status check means the CLI is installed without a usable account session. Each sign-in flow may open your browser or display interactive prompts.

  • Start both sign-in flows in sequence by running:
claude auth login
gh auth login

What do these commands do?

  • The first command signs Claude Code in to your Anthropic account.
  • The second command starts GitHub CLI's interactive browser authentication.
  • Complete each browser sign-in when prompted.
  • Repeat the four tool checks shown above.

Both status checks should now confirm an authenticated account. Your terminal tools are ready for the project.

Sign-in flow not completing?

Keep Terminal open while the browser flow completes. Return to Terminal if the browser displays a code that needs to be pasted back.

Ask for authentication help: Help me troubleshoot my Claude Code or GitHub CLI sign-in.

Install Go 1.27.3

The project targets the Intel macOS build of Go 1.27.3. The version check separates a ready toolchain from an older installation or a missing command.

  • Check whether Go is already available by running:
go version

How do I read the result?

The output identifies the installed Go release. It also identifies the operating system plus CPU architecture used by the compiler.

  • Choose the tab that matches the result.

✔️ I see Go 1.27.3 for darwin/amd64

Your compiler matches the project exactly. Go 1.27.3 is active for darwin/amd64.

That is the toolchain locked in. You can move straight to the project module.

ⓧ I see an older Go version

The active compiler is older than the project target. Install the Intel macOS package before continuing.

  • Open the Go downloads page in your browser.
  • Select the download named go1.27.3.darwin-amd64.pkg.
  • Open the downloaded package.
  • Complete the installer with its default destination.
  • Approve the administrator request if macOS displays one.
  • Press Cmd+N in Terminal to open a new window.
  • Run the Go version check shown above in the new window.

You should now see Go 1.27.3 for darwin/amd64. The new Terminal window reads the installer path update.

ⓧ Command not found

Go is not installed yet. The Intel macOS package adds the compiler to the standard system location.

  • Open the Go downloads page in your browser.
  • Select the download named go1.27.3.darwin-amd64.pkg.
  • Open the downloaded package.
  • Complete the installer with its default destination.
  • Approve the administrator request if macOS displays one.
  • Press Cmd+N in Terminal to open a new window.
  • Run the Go version check shown above in the new window.

You should now see Go 1.27.3 for darwin/amd64. The compiler is ready.

Still cannot run Go?

Confirm that the installer completed under /usr/local/go. Use a newly opened Terminal window because the installer adds the executable path for future sessions.

Ask for toolchain help: Help me troubleshoot Go after installing the Intel macOS package.

Create the module and launch the CLI

A Go module records the project identity plus the language version. This module uses repo-size-guard as its path.

  • Create ~/Projects/repo-size-guard from your Desktop by running these commands:
cd ~/Desktop
mkdir -p ~/Projects/repo-size-guard
cd ~/Projects/repo-size-guard
git init -b main
go mod init repo-size-guard
pwd
git status

What do these commands set up?

  • The first three commands create the project at ~/Projects/repo-size-guard and make it the current directory.
  • The Git initialization command creates the repository on branch main.
  • The module initialization command creates go.mod with module path repo-size-guard.
  • The final checks print the current path plus the repository state.

Your printed path should end with /Projects/repo-size-guard. Git should report branch main.

Project initialization failed?

Check that each command ran from top to bottom. A permission error while creating ~/Projects means your account cannot write to that location.

Ask for repository setup help: Help me initialize repo-size-guard in the required folder.

Visual Studio Code gives you one place to edit the starter files. Opening the exact repository folder also makes its integrated terminal start in the correct location.

  • Press Cmd+Space to open Spotlight.
  • Type Visual Studio Code into Spotlight.
  • Press Return to open Visual Studio Code.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select ~/Projects/repo-size-guard in the folder picker.
  • Click Open.

The Explorer sidebar should now show go.mod. This confirms that Visual Studio Code opened the initialized module.

  • Select go.mod in the Explorer sidebar.
  • Replace its contents with the exact module definition below:
module repo-size-guard

go 1.27

What does this file define?

  • The module line gives imports inside this project a stable module path.
  • The Go directive records language version 1.27 while your installed patch release remains 1.27.3.
  • Save go.mod.
  • Confirm that the editor shows the module line plus the Go directive.
  • Click the New File icon in the Explorer sidebar.
  • Type .gitignore as the file name.
  • Press Return to create the file.
  • Copy the following ignore rules into .gitignore:
/bin/
.DS_Store

What do these rules protect?

  • The first rule keeps compiled project binaries out of Git.
  • The second rule keeps macOS Finder metadata out of the repository.
  • Save .gitignore.
  • Confirm that .gitignore is listed beside go.mod in the Explorer sidebar.

File name looks different?

Make sure the name begins with a dot. Remove any automatic text extension that would turn it into a different file.

Ask for file setup help: Help me create the exact .gitignore file in VS Code.

  • Click the New File icon in the Explorer sidebar.
  • Type main.go as the file name.
  • Press Return to create the file.
  • Copy the following version CLI into main.go:
package main

import (
	"fmt"
	"os"
)

const appVersion = "1.0.0"

func main() {
	if len(os.Args) == 2 && os.Args[1] == "version" {
		fmt.Printf("repo-size-guard %s\n", appVersion)
		return
	}
	fmt.Fprintln(os.Stderr, "usage: repo-size-guard version")
	os.Exit(1)
}

What does this code do?

  • The appVersion constant keeps the CLI version in one place.
  • The argument check accepts the single version command.
  • A valid command prints repo-size-guard 1.0.0 to standard output.
  • Any other input prints usage guidance to standard error before returning a failure status.
  • Save main.go.
  • Return to the Terminal window that is already inside ~/Projects/repo-size-guard.

Before you run the final checks, what two version lines do you expect Terminal to print?

  • Verify the compiler plus the starter CLI by running:
go version
go run . version

What should I see?

The first line should identify Go 1.27.3 for darwin/amd64. The second line should read repo-size-guard 1.0.0.

That first successful run is your baseline. The repository now launches a real CLI from the module you initialized.

CLI does not print its version?

Confirm that Terminal is inside ~/Projects/repo-size-guard. Check that main.go is saved before running the command again.

Ask for CLI help: Help me diagnose why the starter Go CLI does not run.

✔️ Awesome, I've got everything!

Great work. Double check that all three files are saved and that Terminal prints repo-size-guard 1.0.0.

ⓧ I'd like to double check the full code

Compare each project file with the complete starter state below.

/bin/
.DS_Store
module repo-size-guard

go 1.27
package main

import (
	"fmt"
	"os"
)

const appVersion = "1.0.0"

func main() {
	if len(os.Args) == 2 && os.Args[1] == "version" {
		fmt.Printf("repo-size-guard %s\n", appVersion)
		return
	}
	fmt.Fprintln(os.Stderr, "usage: repo-size-guard version")
	os.Exit(1)
}

Your toolchain is verified. Next up, you will teach the CLI to distinguish raw file size from projected Git blob growth.

Measure Proposed Git Growth

Your starter Go CLI proves the project runs. It cannot yet answer whether a proposed change causes meaningful repository growth.

Raw filesystem size can differ from growth in the Git index. This step adds exact staged blob measurements alongside conservative worktree estimates.

In this step, get ready to:
  • Add shell-free Git inspection for staged content and worktree candidates.
  • Separate raw bytes from exact Git blob bytes and Git LFS object bytes.
  • Prove the policy with four disposable-repository tests.
Add the Git analysis engine

Git stores staged content as blobs. The scanner reads those blobs through Git commands launched with os/exec without invoking a shell.

Why inspect two different states?

A staged scan can read the exact blob that Git placed in the index. A worktree scan only has the current file until Git applies any clean filter.

The guard labels worktree values as estimates. Custom filters remain uncertain until their output reaches the index.

  • Press Cmd+Space on macOS or the Windows key on Windows to open system search.
  • Type Visual Studio Code and press Enter.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select ~/Projects/repo-size-guard.
  • Click Open to load the repository.
  • Click New File in the Explorer sidebar.
  • Enter guard.go as the file name.

The analysis engine combines status parsing with index inspection. Use the full-file reference below to populate guard.go.

  • Select the full-code tab below.
  • Copy its contents into guard.go.
  • Save guard.go.

✔️ Awesome, I've got everything!

Confirm that guard.go is saved in the repo-size-guard folder.

ⓧ I'd like to double check the full code

package main

import (
	"bytes"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
)

type FileFinding struct {
	Path                    string `json:"path"`
	IndexStatus             string `json:"index_status"`
	RawBytes                int64  `json:"raw_bytes"`
	GitBlobBytes            *int64 `json:"git_blob_bytes,omitempty"`
	ProjectedNewBlobBytes   *int64 `json:"projected_new_blob_bytes,omitempty"`
	Measurement             string `json:"measurement"`
	MeasurementExact        bool   `json:"measurement_exact"`
	AlreadyTracked          bool   `json:"already_tracked"`
	Binary                  bool   `json:"binary"`
	LFSPointer              bool   `json:"lfs_pointer"`
	LFSObjectBytes          *int64 `json:"lfs_object_bytes,omitempty"`
	Note                    string `json:"note"`
}

type Report struct {
	Scope                       string        `json:"scope"`
	Thresholds                  thresholds    `json:"thresholds"`
	TotalRawBytes               int64         `json:"total_raw_bytes"`
	ProjectedNewGitBlobBytes    int64         `json:"projected_new_git_blob_bytes"`
	LogicalBlobMeasurementsExact bool         `json:"logical_blob_measurements_exact"`
	ProjectedStorageIsEstimate bool          `json:"projected_storage_is_estimate"`
	Decision                    string        `json:"decision"`
	Reason                      string        `json:"reason"`
	Files                       []FileFinding `json:"files"`
	Uncertainties               []string      `json:"uncertainties,omitempty"`
	Recommendations            []string      `json:"recommendations,omitempty"`
}

type statusEntry struct {
	X    byte
	Y    byte
	Path string
}

func analyzeStaged(repo string, t thresholds) (Report, error) {
	report := Report{
		Scope: "staged",
		Thresholds: t,
		LogicalBlobMeasurementsExact: true,
		ProjectedStorageIsEstimate: true,
		Files: []FileFinding{},
	}
	entries, err := readStatus(repo, nil)
	if err != nil {
		return report, err
	}

	for _, entry := range entries {
		if unmerged(entry) {
			report.Uncertainties = append(report.Uncertainties, "unmerged index entry: "+entry.Path)
			continue
		}
		if entry.X != 'A' && entry.X != 'M' {
			continue
		}
		finding, err := stagedFinding(repo, entry)
		if err != nil {
			return report, err
		}
		report.Files = append(report.Files, finding)
	}
	finalizeReport(&report)
	return report, nil
}

func analyzeWorktree(repo string, paths []string, t thresholds) (Report, error) {
	report := Report{
		Scope: "worktree",
		Thresholds: t,
		LogicalBlobMeasurementsExact: false,
		ProjectedStorageIsEstimate: true,
		Files: []FileFinding{},
	}
	entries, err := readStatus(repo, paths)
	if err != nil {
		return report, err
	}

	for _, entry := range entries {
		if unmerged(entry) {
			report.Uncertainties = append(report.Uncertainties, "unmerged worktree entry: "+entry.Path)
			continue
		}
		untracked := entry.X == '?' && entry.Y == '?'
		if !untracked && entry.Y != 'M' && entry.Y != 'A' && entry.Y != 'T' {
			continue
		}

		fullPath := filepath.Join(repo, filepath.FromSlash(entry.Path))
		info, err := os.Lstat(fullPath)
		if err != nil {
			return report, fmt.Errorf("stat %s: %w", entry.Path, err)
		}
		finding := FileFinding{
			Path: entry.Path,
			IndexStatus: string([]byte{entry.X, entry.Y}),
			RawBytes: info.Size(),
			AlreadyTracked: !untracked,
			Measurement: "raw-file-estimate",
			MeasurementExact: false,
			Note: "The worktree size is a conservative estimate until Git writes the cleaned blob to the index.",
		}
		if !info.Mode().IsRegular() {
			report.Uncertainties = append(report.Uncertainties, "non-regular path requires confirmation: "+entry.Path)
			finding.Measurement = "unsupported-file-type"
			report.Files = append(report.Files, finding)
			continue
		}

		filter, err := filterAttribute(repo, entry.Path)
		if err != nil {
			return report, err
		}
		if filter == "lfs" {
			finding.Measurement = "lfs-clean-output-unknown"
			finding.Note = "Git LFS is configured for this path, so stage it first to measure the pointer blob exactly."
			report.Uncertainties = append(report.Uncertainties, "Git LFS clean output is not staged yet: "+entry.Path)
			report.Files = append(report.Files, finding)
			continue
		}
		if filter != "unspecified" && filter != "unset" {
			finding.Measurement = "custom-filter-output-unknown"
			finding.Note = "A custom clean filter can change the blob, so the operation requires confirmation."
			report.Uncertainties = append(report.Uncertainties, "custom filter output is unknown for "+entry.Path)
			report.Files = append(report.Files, finding)
			continue
		}

		estimate := info.Size()
		finding.GitBlobBytes = &estimate
		finding.ProjectedNewBlobBytes = &estimate
		finding.Binary, err = sampleIsBinary(fullPath)
		if err != nil {
			return report, err
		}
		if finding.Binary && estimate >= t.Warn {
			appendUnique(&report.Recommendations, "Consider Git LFS or a release asset for large binary content.")
		}
		report.Files = append(report.Files, finding)
	}
	finalizeReport(&report)
	return report, nil
}

func stagedFinding(repo string, entry statusEntry) (FileFinding, error) {
	oid, err := stagedOID(repo, entry.Path)
	if err != nil {
		return FileFinding{}, err
	}
	sizeOutput, err := gitOutput(repo, "cat-file", "-s", oid)
	if err != nil {
		return FileFinding{}, err
	}
	size, err := strconv.ParseInt(strings.TrimSpace(string(sizeOutput)), 10, 64)
	if err != nil {
		return FileFinding{}, fmt.Errorf("parse blob size for %s: %w", entry.Path, err)
	}

	finding := FileFinding{
		Path: entry.Path,
		IndexStatus: string([]byte{entry.X, entry.Y}),
		GitBlobBytes: &size,
		ProjectedNewBlobBytes: &size,
		Measurement: "exact-index-blob",
		MeasurementExact: true,
		AlreadyTracked: entry.X == 'M',
		Note: "The index blob size is exact. Physical packfile growth remains an estimate because Git may deduplicate, delta-compress, or compress objects.",
	}
	fullPath := filepath.Join(repo, filepath.FromSlash(entry.Path))
	if info, statErr := os.Stat(fullPath); statErr == nil {
		finding.RawBytes = info.Size()
		finding.Binary, err = sampleIsBinary(fullPath)
		if err != nil {
			return FileFinding{}, err
		}
	}

	if size < 1024 {
		content, contentErr := gitOutput(repo, "cat-file", "blob", oid)
		if contentErr != nil {
			return FileFinding{}, contentErr
		}
		if lfsSize, ok := parseLFSPointer(content); ok {
			finding.LFSPointer = true
			finding.LFSObjectBytes = &lfsSize
			finding.Note = "Git stores this exact small pointer blob. The large LFS object is stored separately and is not counted as Git blob growth."
		}
	}
	return finding, nil
}

func readStatus(repo string, paths []string) ([]statusEntry, error) {
	args := []string{"status", "--porcelain=v1", "-z", "--untracked-files=all", "--no-renames"}
	if len(paths) > 0 {
		args = append(args, "--")
		args = append(args, paths...)
	}
	out, err := gitOutput(repo, args...)
	if err != nil {
		return nil, err
	}
	return parseStatus(out)
}

func parseStatus(out []byte) ([]statusEntry, error) {
	records := bytes.Split(out, []byte{0})
	entries := make([]statusEntry, 0, len(records))
	for _, record := range records {
		if len(record) == 0 {
			continue
		}
		if len(record) < 4 || record[2] != ' ' {
			return nil, fmt.Errorf("unexpected porcelain record %q", record)
		}
		entries = append(entries, statusEntry{X: record[0], Y: record[1], Path: string(record[3:])})
	}
	return entries, nil
}

func stagedOID(repo, path string) (string, error) {
	out, err := gitOutput(repo, "ls-files", "--stage", "-z", "--", path)
	if err != nil {
		return "", err
	}
	record := bytes.TrimSuffix(out, []byte{0})
	parts := bytes.SplitN(record, []byte{'\t'}, 2)
	if len(parts) != 2 {
		return "", fmt.Errorf("missing stage-0 entry for %s", path)
	}
	fields := strings.Fields(string(parts[0]))
	if len(fields) != 3 || fields[2] != "0" {
		return "", fmt.Errorf("unexpected index entry for %s", path)
	}
	return fields[1], nil
}

func filterAttribute(repo, path string) (string, error) {
	input := append([]byte(path), 0)
	out, err := gitInputOutput(repo, input, "check-attr", "--stdin", "-z", "filter")
	if err != nil {
		return "", err
	}
	parts := bytes.Split(out, []byte{0})
	if len(parts) < 3 || string(parts[1]) != "filter" {
		return "", fmt.Errorf("unexpected check-attr output for %s", path)
	}
	return string(parts[2]), nil
}

func gitOutput(repo string, args ...string) ([]byte, error) {
	cmd := exec.Command("git", args...)
	cmd.Dir = repo
	out, err := cmd.Output()
	if err != nil {
		if exitErr, ok := err.(*exec.ExitError); ok {
			return nil, fmt.Errorf("git %s: %s", strings.Join(args, " "), strings.TrimSpace(string(exitErr.Stderr)))
		}
		return nil, err
	}
	return out, nil
}

func gitInputOutput(repo string, input []byte, args ...string) ([]byte, error) {
	cmd := exec.Command("git", args...)
	cmd.Dir = repo
	cmd.Stdin = bytes.NewReader(input)
	out, err := cmd.Output()
	if err != nil {
		if exitErr, ok := err.(*exec.ExitError); ok {
			return nil, fmt.Errorf("git %s: %s", strings.Join(args, " "), strings.TrimSpace(string(exitErr.Stderr)))
		}
		return nil, err
	}
	return out, nil
}

func sampleIsBinary(path string) (bool, error) {
	file, err := os.Open(path)
	if err != nil {
		return false, err
	}
	defer file.Close()

	sample := make([]byte, 8192)
	n, err := file.Read(sample)
	if err != nil && err != io.EOF {
		return false, err
	}
	return bytes.IndexByte(sample[:n], 0) >= 0, nil
}

func parseLFSPointer(content []byte) (int64, bool) {
	if len(content) == 0 || len(content) >= 1024 {
		return 0, false
	}
	lines := strings.Split(strings.TrimSuffix(string(content), "\n"), "\n")
	if len(lines) < 3 || lines[0] != "version https://git-lfs.github.com/spec/v1" {
		return 0, false
	}
	var oid string
	var sizeText string
	for _, line := range lines[1:] {
		if strings.HasPrefix(line, "oid sha256:") {
			oid = strings.TrimPrefix(line, "oid sha256:")
		}
		if strings.HasPrefix(line, "size ") {
			sizeText = strings.TrimPrefix(line, "size ")
		}
	}
	if len(oid) != 64 || !lowerHex(oid) || sizeText == "" {
		return 0, false
	}
	size, err := strconv.ParseInt(sizeText, 10, 64)
	return size, err == nil && size >= 0
}

func lowerHex(value string) bool {
	for _, r := range value {
		if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
			return false
		}
	}
	return true
}

func unmerged(entry statusEntry) bool {
	return entry.X == 'U' || entry.Y == 'U' || (entry.X == 'A' && entry.Y == 'A') || (entry.X == 'D' && entry.Y == 'D')
}

func finalizeReport(report *Report) {
	unknown := len(report.Uncertainties) > 0
	for _, file := range report.Files {
		report.TotalRawBytes += file.RawBytes
		if file.ProjectedNewBlobBytes == nil {
			unknown = true
			continue
		}
		report.ProjectedNewGitBlobBytes += *file.ProjectedNewBlobBytes
	}

	switch {
	case report.ProjectedNewGitBlobBytes >= report.Thresholds.Fail:
		report.Decision = "deny"
		report.Reason = "Projected Git blob growth meets or exceeds the failure threshold."
		appendUnique(&report.Recommendations, "Use Git LFS, a release asset, object storage, or remove the oversized content before retrying.")
	case unknown:
		report.Decision = "ask"
		report.Reason = "At least one operation or cleaned blob size is uncertain and requires confirmation."
	case report.ProjectedNewGitBlobBytes >= report.Thresholds.Warn:
		report.Decision = "ask"
		report.Reason = "Projected Git blob growth meets or exceeds the warning threshold."
		appendUnique(&report.Recommendations, "Confirm the growth intentionally or move large binary content to Git LFS or a release asset.")
	default:
		report.Decision = "allow"
		report.Reason = "All supported measurements are below the warning threshold."
	}
}

func appendUnique(values *[]string, value string) {
	for _, existing := range *values {
		if existing == value {
			return
		}
	}
	*values = append(*values, value)
}

What does the analysis engine do?

  • The readStatus() function requests stable NUL-delimited status records so spaces inside paths remain intact.
  • The stagedFinding() function looks up each index object ID and reads its exact logical blob size.
  • The analyzeWorktree() function uses raw file size as a conservative estimate when no clean filter changes the content.
  • The finalizeReport() function returns allow, ask, or deny from the configured thresholds and uncertainties.
  • Select main.go in the Explorer sidebar.
  • Select the full-code tab below.
  • Replace the existing contents of main.go with the scan-capable version.
  • Save main.go.

✔️ Awesome, I've got everything!

Confirm that main.go now contains the scan command and threshold parser.

ⓧ I'd like to double check the full code

package main

import (
	"encoding/json"
	"fmt"
	"os"
	"strconv"
)

const appVersion = "1.0.0"

const (
	defaultWarnBytes int64 = 50 * 1024 * 1024
	defaultFailBytes int64 = 100 * 1024 * 1024
)

type thresholds struct {
	Warn int64 `json:"warn_bytes"`
	Fail int64 `json:"fail_bytes"`
}

func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, "repo-size-guard:", err)
		os.Exit(1)
	}
}

func run() error {
	if len(os.Args) < 2 {
		return fmt.Errorf("usage: repo-size-guard <version|scan|hook>")
	}

	switch os.Args[1] {
	case "version":
		fmt.Printf("repo-size-guard %s\n", appVersion)
		return nil
	case "scan":
		return runScan(os.Args[2:])
	default:
		return fmt.Errorf("unknown command %q", os.Args[1])
	}
}

func runScan(args []string) error {
	t, rest, err := parseThresholds(args)
	if err != nil {
		return err
	}

	mode := "staged"
	paths := []string(nil)
	for i := 0; i < len(rest); i++ {
		switch rest[i] {
		case "--staged":
			mode = "staged"
		case "--worktree":
			mode = "worktree"
		case "--":
			paths = append(paths, rest[i+1:]...)
			i = len(rest)
		default:
			return fmt.Errorf("unknown scan argument %q", rest[i])
		}
	}
	if mode == "staged" && len(paths) != 0 {
		return fmt.Errorf("staged scans do not accept worktree pathspecs")
	}

	cwd, err := os.Getwd()
	if err != nil {
		return err
	}

	var report Report
	if mode == "staged" {
		report, err = analyzeStaged(cwd, t)
	} else {
		report, err = analyzeWorktree(cwd, paths, t)
	}
	if err != nil {
		return err
	}

	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	return enc.Encode(report)
}

func parseThresholds(args []string) (thresholds, []string, error) {
	t := thresholds{Warn: defaultWarnBytes, Fail: defaultFailBytes}
	rest := make([]string, 0, len(args))

	for i := 0; i < len(args); i++ {
		switch args[i] {
		case "--warn-bytes", "--fail-bytes":
			if i+1 >= len(args) {
				return t, nil, fmt.Errorf("%s requires a value", args[i])
			}
			value, err := strconv.ParseInt(args[i+1], 10, 64)
			if err != nil || value <= 0 {
				return t, nil, fmt.Errorf("%s must be a positive integer", args[i])
			}
			if args[i] == "--warn-bytes" {
				t.Warn = value
			} else {
				t.Fail = value
			}
			i++
		default:
			rest = append(rest, args[i])
		}
	}
	if t.Warn >= t.Fail {
		return t, nil, fmt.Errorf("warn threshold must be smaller than fail threshold")
	}
	return t, rest, nil
}

What does the scan command add?

  • The default warning threshold is 50 MiB.
  • The default failure threshold is 100 MiB.
  • The runScan() function chooses staged or worktree analysis.
  • The parseThresholds() function accepts positive byte values while keeping the warning threshold below the failure threshold.

Before you run the first scan, what decision do you expect from a repository with no staged additions or modifications?

  • Switch back to the Terminal window in ~/Projects/repo-size-guard.
  • Run a staged scan with the new command:
go run . scan --staged

What does this command prove?

The command compiles the updated CLI and analyzes the real Git index. It prints the report as indented JSON.

You should see staged as the scope. You should also see an allow decision with zero projected new blob bytes.

Scan failing to compile?

Confirm that guard.go and main.go are saved inside ~/Projects/repo-size-guard.

Compare both files with the full-code tabs above if the terminal reports an undefined identifier or syntax problem.

Help me debug my repo-size-guard scan compilation error.

Add disposable repository tests

A growth guard needs evidence for the cases that can fool a raw file-size check. Each test creates an isolated repository with testing.T.TempDir so the fixture disappears after the test.

What do the fixtures prove?

One fixture proves that clean tracked content contributes no new projected bytes. Another proves that an oversized staged binary reaches the denial threshold.

The remaining fixtures separate a large worktree file from its small staged Git LFS pointer. They also keep unstaged filter output at ask because the cleaned blob is still unknown.

  • Return to the Explorer sidebar in Visual Studio Code.
  • Click New File.
  • Enter guard_test.go as the file name.
  • Select the full-code tab below.
  • Copy its contents into guard_test.go.
  • Save guard_test.go.

✔️ Awesome, I've got everything!

Confirm that guard_test.go contains four tests plus the disposable-repository helpers.

ⓧ I'd like to double check the full code

package main

import (
	"bytes"
	"os"
	"os/exec"
	"path/filepath"
	"testing"
)

func TestAlreadyTrackedContentNotCounted(t *testing.T) {
	repo := newRepo(t)
	writeTestFile(t, repo, "tracked.bin", bytes.Repeat([]byte{0}, 4096))
	mustGit(t, repo, "add", "tracked.bin")
	mustGit(t, repo, "commit", "-m", "track fixture")

	report, err := analyzeStaged(repo, thresholds{Warn: 1024, Fail: 2048})
	if err != nil {
		t.Fatal(err)
	}
	if len(report.Files) != 0 || report.ProjectedNewGitBlobBytes != 0 || report.Decision != "allow" {
		t.Fatalf("clean tracked content counted as growth: %+v", report)
	}
}

func TestOversizedBinaryDenied(t *testing.T) {
	repo := newRepo(t)
	writeTestFile(t, repo, "large.bin", bytes.Repeat([]byte{0}, 3072))
	mustGit(t, repo, "add", "large.bin")

	report, err := analyzeStaged(repo, thresholds{Warn: 1024, Fail: 2048})
	if err != nil {
		t.Fatal(err)
	}
	if report.Decision != "deny" || len(report.Files) != 1 || !report.Files[0].Binary {
		t.Fatalf("oversized binary was not denied: %+v", report)
	}
}

func TestLFSPointerSeparatesRawAndBlobSize(t *testing.T) {
	repo := newRepo(t)
	pointer := "version https://git-lfs.github.com/spec/v1\n" +
		"oid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\n" +
		"size 104857600\n"
	writeTestFile(t, repo, "asset.bin", []byte(pointer))
	mustGit(t, repo, "add", "asset.bin")
	if err := os.Truncate(filepath.Join(repo, "asset.bin"), 100*1024*1024); err != nil {
		t.Fatal(err)
	}

	report, err := analyzeStaged(repo, thresholds{Warn: defaultWarnBytes, Fail: defaultFailBytes})
	if err != nil {
		t.Fatal(err)
	}
	finding := report.Files[0]
	if !finding.LFSPointer || finding.RawBytes != 100*1024*1024 || finding.GitBlobBytes == nil || *finding.GitBlobBytes >= 1024 {
		t.Fatalf("raw and LFS pointer sizes were not separated: %+v", finding)
	}
	if report.Decision != "allow" {
		t.Fatalf("small staged pointer should be allowed: %+v", report)
	}
}

func TestWorktreeLFSFilterAsks(t *testing.T) {
	repo := newRepo(t)
	writeTestFile(t, repo, ".gitattributes", []byte("*.bin filter=lfs diff=lfs merge=lfs -text\n"))
	writeTestFile(t, repo, "asset.bin", bytes.Repeat([]byte{0}, 3072))

	report, err := analyzeWorktree(repo, []string{"asset.bin"}, thresholds{Warn: 1024, Fail: 2048})
	if err != nil {
		t.Fatal(err)
	}
	if report.Decision != "ask" || report.Files[0].ProjectedNewBlobBytes != nil {
		t.Fatalf("unstaged LFS clean output must remain uncertain: %+v", report)
	}
}

func newRepo(t testing.TB) string {
	t.Helper()
	repo := t.TempDir()
	mustGit(t, repo, "init", "-b", "main")
	return repo
}

func writeTestFile(t testing.TB, repo, name string, content []byte) {
	t.Helper()
	path := filepath.Join(repo, filepath.FromSlash(name))
	if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(path, content, 0644); err != nil {
		t.Fatal(err)
	}
}

func mustGit(t testing.TB, repo string, args ...string) {
	t.Helper()
	cmd := exec.Command("git", args...)
	cmd.Dir = repo
	cmd.Env = append(os.Environ(),
		"GIT_AUTHOR_NAME=Repo Guard Test",
		"GIT_AUTHOR_EMAIL=repo-guard@example.invalid",
		"GIT_COMMITTER_NAME=Repo Guard Test",
		"GIT_COMMITTER_EMAIL=repo-guard@example.invalid",
	)
	if out, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("git %v failed: %v\n%s", args, err, out)
	}
}

How do the tests stay isolated?

  • The newRepo() helper creates a temporary directory and initializes its main branch.
  • The mustGit() helper supplies a test-only commit identity without changing your global Git configuration.
  • The binary fixtures use NUL bytes so sampleIsBinary() can recognize them.
  • The LFS fixture stages a canonical pointer before expanding the worktree file to 100 MiB.
Verify exact and estimated growth

The focused test run now checks all four measurement boundaries together. Before you run it, which case do you expect to return uncertainty instead of a byte projection?

  • Switch back to the Terminal window in ~/Projects/repo-size-guard.
  • Run the focused growth-analysis tests:
go test -run 'TestAlreadyTracked|TestOversizedBinary|TestLFSPointer|TestWorktreeLFS' -v

What does this test run prove?

  • The unchanged tracked-content case proves that a clean index adds zero projected bytes.
  • The oversized binary case proves that staged growth beyond the failure threshold returns deny.
  • The staged LFS case proves that a 100 MiB worktree file can map to a pointer blob smaller than 1 KiB.
  • The unstaged LFS case proves that unknown clean-filter output returns ask.

You should see all four tests finish with PASS. That confirms the scanner distinguishes exact index data from conservative worktree estimates.

Seeing a failing growth test?

Check the first failing test name because each fixture isolates one measurement rule. A failure in the LFS cases usually points to pointer parsing or filter detection.

Compare guard.go and guard_test.go with the full-code tabs before rerunning the command.

Help me diagnose my failing repo-size-guard growth test.

Your CLI can now measure staged Git blobs exactly while treating unstaged filter output conservatively. Next, you will connect these decisions to Claude Code before Bash commands run.

Gate Claude Code Bash Commands

Your scanner can now measure proposed growth from the worktree or the real Git index. The next challenge is applying that evidence before a risky Bash command runs.

In this step, you will connect the scanner to Claude Code through a project hook. The hook will stay silent for verified safe operations. It will return a structured decision when growth is risky or the command is uncertain.

In this step, get ready to:
  • Classify supported Git commands with a conservative command parser.
  • Convert scan results into Claude Code hook decisions.
  • Build the executable and register the project-scoped Bash hook.
Classify supported Git commands

A command classifier decides which scanner should run before Claude Code uses Bash. Narrow support keeps the decision trustworthy because unfamiliar syntax moves into an explicit confirmation path.

Why use a conservative classifier?

Shell commands can contain expansions or aliases that change what eventually runs. The classifier only verifies literal forms whose effect it can reason about.

Unsupported flags or dynamic syntax receive confirmation instead of silent continuation. Uncertainty always remains visible.

  • Switch back to Visual Studio Code from the previous step.
  • Create a file named classify.go inside the repo-size-guard folder.
  • Create a file named classify_test.go inside the repo-size-guard folder.
  • Use the complete-file references below to fill both files.

✔️ Awesome, I've got everything!

Confirm that classify.go contains the hook protocol and command classifier. Confirm that classify_test.go contains the supported and uncertain command cases.

ⓧ I'd like to double check the full code

Compare your complete classify.go file with this reference:

package main

import (
	"encoding/json"
	"fmt"
	"io"
	"strings"
)

type actionKind string

const (
	actionIgnore   actionKind = "ignore"
	actionStaged   actionKind = "staged"
	actionWorktree actionKind = "worktree"
	actionAsk      actionKind = "ask"
	actionDeny     actionKind = "deny"
)

type commandAction struct {
	Kind   actionKind
	Paths  []string
	Reason string
}

type HookInput struct {
	CWD       string `json:"cwd"`
	ToolName  string `json:"tool_name"`
	ToolInput struct {
		Command string `json:"command"`
	} `json:"tool_input"`
}

type HookDecision struct {
	HookSpecificOutput struct {
		HookEventName           string `json:"hookEventName"`
		PermissionDecision      string `json:"permissionDecision"`
		PermissionDecisionReason string `json:"permissionDecisionReason"`
	} `json:"hookSpecificOutput"`
}

func runHook(input io.Reader, output io.Writer, t thresholds) error {
	var event HookInput
	if err := json.NewDecoder(input).Decode(&event); err != nil {
		return fmt.Errorf("decode hook input: %w", err)
	}
	if event.ToolName != "Bash" {
		return nil
	}

	action := classifyCommand(event.ToolInput.Command)
	switch action.Kind {
	case actionIgnore:
		return nil
	case actionAsk, actionDeny:
		return writeHookDecision(output, string(action.Kind), action.Reason)
	}

	var report Report
	var err error
	if action.Kind == actionStaged {
		report, err = analyzeStaged(event.CWD, t)
	} else {
		report, err = analyzeWorktree(event.CWD, action.Paths, t)
	}
	if err != nil {
		return writeHookDecision(output, "ask", "The guard could not verify repository growth: "+err.Error())
	}
	if report.Decision == "allow" {
		return nil
	}

	reason := fmt.Sprintf("%s Projected new Git blob bytes: %d.", report.Reason, report.ProjectedNewGitBlobBytes)
	if len(report.Recommendations) > 0 {
		reason += " " + strings.Join(report.Recommendations, " ")
	}
	return writeHookDecision(output, report.Decision, reason)
}

func writeHookDecision(output io.Writer, decision, reason string) error {
	var result HookDecision
	result.HookSpecificOutput.HookEventName = "PreToolUse"
	result.HookSpecificOutput.PermissionDecision = decision
	result.HookSpecificOutput.PermissionDecisionReason = reason
	return json.NewEncoder(output).Encode(result)
}

func classifyCommand(command string) commandAction {
	segments, operators, err := tokenizeSegments(command)
	if err != nil {
		if looksGuarded(command) {
			return commandAction{Kind: actionAsk, Reason: "Dynamic or malformed shell syntax could not be verified."}
		}
		return commandAction{Kind: actionIgnore}
	}
	if len(segments) == 0 {
		return commandAction{Kind: actionIgnore}
	}
	if len(segments) > 1 || len(operators) > 0 {
		for _, segment := range segments {
			if guardedSegment(segment) {
				return commandAction{Kind: actionAsk, Reason: "Compound commands require confirmation until each segment is classified."}
			}
		}
		return commandAction{Kind: actionIgnore}
	}
	return classifySimple(segments[0])
}

func classifySimple(tokens []string) commandAction {
	if len(tokens) < 2 || tokens[0] != "git" {
		return commandAction{Kind: actionIgnore}
	}
	switch tokens[1] {
	case "add":
		return classifyAdd(tokens[2:])
	case "commit":
		return classifyCommit(tokens[2:])
	default:
		return commandAction{Kind: actionIgnore}
	}
}

func classifyAdd(args []string) commandAction {
	all := false
	afterDash := false
	paths := []string{}
	for _, arg := range args {
		if !afterDash && arg == "--" {
			afterDash = true
			continue
		}
		if !afterDash && (arg == "-A" || arg == "--all") {
			all = true
			continue
		}
		if !afterDash && strings.HasPrefix(arg, "-") {
			return commandAction{Kind: actionAsk, Reason: "Unsupported git add flag requires confirmation: " + arg}
		}
		if strings.ContainsAny(arg, "*?[") {
			return commandAction{Kind: actionAsk, Reason: "Glob pathspecs are not expanded by the guard."}
		}
		paths = append(paths, arg)
	}
	if !all && len(paths) == 0 {
		return commandAction{Kind: actionAsk, Reason: "git add without a supported literal pathspec is not verified."}
	}
	if all && len(paths) == 0 {
		paths = nil
	}
	return commandAction{Kind: actionWorktree, Paths: paths}
}

func classifyCommit(args []string) commandAction {
	for i := 0; i < len(args); i++ {
		switch {
		case args[i] == "-m" || args[i] == "--message":
			if i+1 >= len(args) {
				return commandAction{Kind: actionAsk, Reason: "Commit message flag is missing its value."}
			}
			i++
		case strings.HasPrefix(args[i], "--message="):
		case args[i] == "--no-verify", args[i] == "--allow-empty", args[i] == "--allow-empty-message", args[i] == "-q", args[i] == "--quiet":
		case args[i] == "-a", args[i] == "--all", args[i] == "-i", args[i] == "--include", args[i] == "-o", args[i] == "--only", args[i] == "-p", args[i] == "--patch", args[i] == "--amend":
			return commandAction{Kind: actionAsk, Reason: "This git commit form can select or stage worktree content and requires confirmation."}
		default:
			return commandAction{Kind: actionAsk, Reason: "Unsupported git commit argument requires confirmation: " + args[i]}
		}
	}
	return commandAction{Kind: actionStaged}
}

func tokenizeSegments(command string) ([][]string, []string, error) {
	var segments [][]string
	var operators []string
	var tokens []string
	var word strings.Builder
	var quote byte

	flushWord := func() {
		if word.Len() > 0 {
			tokens = append(tokens, word.String())
			word.Reset()
		}
	}
	flushSegment := func(operator string) error {
		flushWord()
		if len(tokens) == 0 {
			return fmt.Errorf("empty command segment")
		}
		segments = append(segments, tokens)
		tokens = nil
		operators = append(operators, operator)
		return nil
	}

	for i := 0; i < len(command); i++ {
		ch := command[i]
		if quote != 0 {
			if ch == quote {
				quote = 0
				continue
			}
			if quote == '"' && (ch == '$' || ch == '`') {
				return nil, nil, fmt.Errorf("dynamic expansion")
			}
			if ch == '\\' && quote == '"' && i+1 < len(command) {
				i++
				word.WriteByte(command[i])
				continue
			}
			word.WriteByte(ch)
			continue
		}

		switch ch {
		case '\'', '"':
			quote = ch
		case '\\':
			if i+1 >= len(command) {
				return nil, nil, fmt.Errorf("trailing escape")
			}
			i++
			word.WriteByte(command[i])
		case ' ', '\t', '\r':
			flushWord()
		case '\n', ';', '', '':
			if err := flushSegment(";"); err != nil {
				return nil, nil, err
			}
		case '&':
			if i+1 >= len(command) || command[i+1] != '&' {
				return nil, nil, fmt.Errorf("background commands are unsupported")
			}
			i++
			if err := flushSegment("&&"); err != nil {
				return nil, nil, err
			}
		case '|':
			op := "|"
			if i+1 < len(command) && command[i+1] == '|' {
				i++
				op = "||"
			}
			if err := flushSegment(op); err != nil {
				return nil, nil, err
			}
		case '<', '>':
			if err := flushSegment(string(ch)); err != nil {
				return nil, nil, err
			}
		case '$', '`', '(', ')':
			return nil, nil, fmt.Errorf("dynamic shell syntax")
		default:
			word.WriteByte(ch)
		}
	}
	if quote != 0 {
		return nil, nil, fmt.Errorf("unterminated quote")
	}
	flushWord()
	if len(tokens) > 0 {
		segments = append(segments, tokens)
	}
	return segments, operators, nil
}

func guardedSegment(tokens []string) bool {
	if len(tokens) < 2 || tokens[0] != "git" {
		return false
	}
	for _, token := range tokens[1:] {
		if token == "add" || token == "commit" {
			return true
		}
	}
	return false
}

func looksGuarded(command string) bool {
	lower := strings.ToLower(command)
	return strings.Contains(lower, "git add") || strings.Contains(lower, "git commit")
}

How does the classifier work?

  • The HookInput type maps the incoming Bash command and repository directory from the hook event.
  • The tokenizeSegments() function handles ordinary quoting while rejecting dynamic shell behavior.
  • The classifyAdd() function accepts literal pathspecs and the supported whole-worktree forms.
  • The classifyCommit() function accepts ordinary message forms that commit the existing index.

Compare your complete classify_test.go file with this reference:

package main

import "testing"

func TestClassifySupportedCommands(t *testing.T) {
	cases := []struct {
		command string
		kind    actionKind
	}{
		{"git add .", actionWorktree},
		{"git add -A", actionWorktree},
		{"git add -- \"assets/demo file.bin\"", actionWorktree},
		{"git commit -m \"portfolio release\"", actionStaged},
	}
	for _, tc := range cases {
		if got := classifyCommand(tc.command); got.Kind != tc.kind {
			t.Fatalf("%q: got %s, want %s", tc.command, got.Kind, tc.kind)
		}
	}
}

func TestClassifyUncertainCommandsAsk(t *testing.T) {
	commands := []string{
		"git add -p",
		"git add $TARGET",
		"git commit -a -m update",
		"git commit README.md",
		"cp oversized.bin attack.bin && git add attack.bin",
	}
	for _, command := range commands {
		if got := classifyCommand(command); got.Kind != actionAsk {
			t.Fatalf("%q: got %s, want ask", command, got.Kind)
		}
	}
}

What do these tests prove?

The supported cases select worktree or staged analysis. The uncertain cases verify that unsupported command forms require confirmation.

The final fixture exercises a compound command with a file-changing prefix. Its current expectation exposes the boundary that you will observe next.

  • Save classify.go.
  • Save classify_test.go.

Before you run the classifier tests, do you think the compound fixture receives a precise safety decision or a conservative escalation?

  • Run the focused test suite from the repo-size-guard directory by entering:
go test -run 'TestHookOutcomes|TestClassify' -v

What does this check?

The regular expression selects tests whose names begin with TestHookOutcomes or TestClassify. At this point, the classifier tests are the available matches.

You will see both classifier tests pass. The compound fixture receives actionAsk because the base classifier escalates every guarded command chain.

That result is the intended shortfall. The hook avoids a silent safe classification, but it cannot yet distinguish a harmless prefix from a known file mutator.

Seeing a classifier test failure?

Check that each command string in classify_test.go matches the complete-file reference. A missing escape or quote changes how the tokenizer reads the fixture.

Compare the returned actionKind with the failing expectation. Help me debug my command classifier tests.

Connect scan results to hook decisions

The hook protocol turns classifier outcomes and scan reports into JSON responses. A verified safe operation produces no hook output, so Claude Code continues through its normal permission flow.

Warning-level or uncertain work produces ask. Failure-level growth produces deny with the measured bytes and remediation guidance.

  • Replace guard_test.go with the complete cumulative test file in the double-check tab below.

✔️ Awesome, I've got everything!

Confirm that guard_test.go now includes hook outcomes and both benchmark functions.

ⓧ I'd like to double check the full code

Compare your complete guard_test.go file with this reference:

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"testing"
)

func TestAlreadyTrackedContentNotCounted(t *testing.T) {
	repo := newRepo(t)
	writeTestFile(t, repo, "tracked.bin", bytes.Repeat([]byte{0}, 4096))
	mustGit(t, repo, "add", "tracked.bin")
	mustGit(t, repo, "commit", "-m", "track fixture")

	report, err := analyzeStaged(repo, thresholds{Warn: 1024, Fail: 2048})
	if err != nil {
		t.Fatal(err)
	}
	if len(report.Files) != 0 || report.ProjectedNewGitBlobBytes != 0 || report.Decision != "allow" {
		t.Fatalf("clean tracked content counted as growth: %+v", report)
	}
}

func TestOversizedBinaryDenied(t *testing.T) {
	repo := newRepo(t)
	writeTestFile(t, repo, "large.bin", bytes.Repeat([]byte{0}, 3072))
	mustGit(t, repo, "add", "large.bin")

	report, err := analyzeStaged(repo, thresholds{Warn: 1024, Fail: 2048})
	if err != nil {
		t.Fatal(err)
	}
	if report.Decision != "deny" || len(report.Files) != 1 || !report.Files[0].Binary {
		t.Fatalf("oversized binary was not denied: %+v", report)
	}
}

func TestLFSPointerSeparatesRawAndBlobSize(t *testing.T) {
	repo := newRepo(t)
	pointer := "version https://git-lfs.github.com/spec/v1\n" +
		"oid sha256:4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393\n" +
		"size 104857600\n"
	writeTestFile(t, repo, "asset.bin", []byte(pointer))
	mustGit(t, repo, "add", "asset.bin")
	if err := os.Truncate(filepath.Join(repo, "asset.bin"), 100*1024*1024); err != nil {
		t.Fatal(err)
	}

	report, err := analyzeStaged(repo, thresholds{Warn: defaultWarnBytes, Fail: defaultFailBytes})
	if err != nil {
		t.Fatal(err)
	}
	finding := report.Files[0]
	if !finding.LFSPointer || finding.RawBytes != 100*1024*1024 || finding.GitBlobBytes == nil || *finding.GitBlobBytes >= 1024 {
		t.Fatalf("raw and LFS pointer sizes were not separated: %+v", finding)
	}
	if report.Decision != "allow" {
		t.Fatalf("small staged pointer should be allowed: %+v", report)
	}
}

func TestWorktreeLFSFilterAsks(t *testing.T) {
	repo := newRepo(t)
	writeTestFile(t, repo, ".gitattributes", []byte("*.bin filter=lfs diff=lfs merge=lfs -text\n"))
	writeTestFile(t, repo, "asset.bin", bytes.Repeat([]byte{0}, 3072))

	report, err := analyzeWorktree(repo, []string{"asset.bin"}, thresholds{Warn: 1024, Fail: 2048})
	if err != nil {
		t.Fatal(err)
	}
	if report.Decision != "ask" || report.Files[0].ProjectedNewBlobBytes != nil {
		t.Fatalf("unstaged LFS clean output must remain uncertain: %+v", report)
	}
}

func TestHookOutcomes(t *testing.T) {
	cases := []struct {
		name     string
		size     int
		decision string
	}{
		{name: "safe", size: 512, decision: ""},
		{name: "warning", size: 1536, decision: "ask"},
		{name: "failure", size: 3072, decision: "deny"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			repo := newRepo(t)
			writeTestFile(t, repo, "candidate.bin", bytes.Repeat([]byte{0}, tc.size))
			mustGit(t, repo, "add", "candidate.bin")

			var event HookInput
			event.CWD = repo
			event.ToolName = "Bash"
			event.ToolInput.Command = "git commit -m \"fixture\""
			payload, err := json.Marshal(event)
			if err != nil {
				t.Fatal(err)
			}
			var output bytes.Buffer
			if err := runHook(bytes.NewReader(payload), &output, thresholds{Warn: 1024, Fail: 2048}); err != nil {
				t.Fatal(err)
			}
			if tc.decision == "" {
				if output.Len() != 0 {
					t.Fatalf("safe hook printed a decision: %s", output.String())
				}
				return
			}
			var result HookDecision
			if err := json.Unmarshal(output.Bytes(), &result); err != nil {
				t.Fatal(err)
			}
			if result.HookSpecificOutput.PermissionDecision != tc.decision {
				t.Fatalf("got %q, want %q", result.HookSpecificOutput.PermissionDecision, tc.decision)
			}
		})
	}
}

func BenchmarkParseStatus1000(b *testing.B) {
	var fixture bytes.Buffer
	for i := 0; i < 1000; i++ {
		fmt.Fprintf(&fixture, "?? file-%04d.bin%c", i, 0)
	}
	payload := fixture.Bytes()
	b.ReportAllocs()
	for b.Loop() {
		entries, err := parseStatus(payload)
		if err != nil || len(entries) != 1000 {
			b.Fatalf("parse failed: %v", err)
		}
	}
}

func BenchmarkClassifyCommand(b *testing.B) {
	b.ReportAllocs()
	for b.Loop() {
		action := classifyCommand("git add -- assets/video.bin docs/report.pdf")
		if action.Kind != actionWorktree {
			b.Fatal("unexpected classification")
		}
	}
}

func newRepo(t testing.TB) string {
	t.Helper()
	repo := t.TempDir()
	mustGit(t, repo, "init", "-b", "main")
	return repo
}

func writeTestFile(t testing.TB, repo, name string, content []byte) {
	t.Helper()
	path := filepath.Join(repo, filepath.FromSlash(name))
	if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(path, content, 0644); err != nil {
		t.Fatal(err)
	}
}

func mustGit(t testing.TB, repo string, args ...string) {
	t.Helper()
	cmd := exec.Command("git", args...)
	cmd.Dir = repo
	cmd.Env = append(os.Environ(),
		"GIT_AUTHOR_NAME=Repo Guard Test",
		"GIT_AUTHOR_EMAIL=repo-guard@example.invalid",
		"GIT_COMMITTER_NAME=Repo Guard Test",
		"GIT_COMMITTER_EMAIL=repo-guard@example.invalid",
	)
	if out, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("git %v failed: %v\n%s", args, err, out)
	}
}

What does the hook test cover?

  • The safe case verifies that the hook writes zero output bytes.
  • The warning case verifies a valid ask decision.
  • The failure case verifies a valid deny decision.
  • Each case uses a disposable repository with small test thresholds.
  • Save guard_test.go.

Before you rerun the focused suite, which hook case do you expect to produce no decision object?

  • Run the classifier and hook outcome tests by entering:
go test -run 'TestHookOutcomes|TestClassify' -v

What does this run verify?

The command now runs the classifier tests plus the three hook outcome subtests. It checks classification and hook serialization through one focused test run.

You will see the safe, warning, and failure hook cases pass. The safe fixture proves that verified low growth produces no hook decision.

Good progress. Your scanner now speaks the Claude Code hook protocol without granting silent approval.

Seeing a hook outcome failure?

Check that the safe case compares output.Len() with zero. Check that the other cases decode HookDecision before comparing the decision.

A JSON decoding failure usually points to extra output from runHook(). Help me trace my hook outcome test failure.

Build and register the project hook

Project-scoped hook settings live in .claude/settings.json. The configuration launches your compiled binary directly with one argument.

The failure policy blocks the Bash action when the hook process fails or exceeds its timeout. This gives the guard a fail-closed boundary inside this project.

  • Create a folder named bin in the Visual Studio Code Explorer sidebar.
  • Create a folder named .claude in the repo-size-guard folder.
  • Create a file named settings.json inside the .claude folder.
  • Paste this configuration into .claude/settings.json:
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/bin/repo-size-guard",
            "args": ["hook"],
            "timeout": 10,
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}

How is the hook configured?

  • The Bash matcher limits the hook to Bash tool calls.
  • The command points to the executable inside the current Claude project.
  • The argument selects the CLI's hook mode.
  • The timeout limits the check to 10 seconds.
  • The failure policy blocks the action when the guard cannot complete.
  • Save .claude/settings.json.
  • Confirm that the Explorer sidebar lists settings.json inside .claude.
  • Build the executable inside bin by running:
go build -o bin/repo-size-guard .

What does this build create?

The Go compiler builds the current main package. It writes the executable to bin/repo-size-guard so the project hook can launch it.

The build completes without terminal output. You will see repo-size-guard inside the bin folder in Visual Studio Code.

Did the build fail?

Confirm that the terminal is inside the repo-size-guard directory. Confirm that the bin folder exists before running the build.

A Go compilation error identifies the file and line that need attention. Help me fix my repo-size-guard build error.

Before you inspect the loaded hook, do you expect Claude Code to show a project-level Bash hook or a user-level hook?

  • Start Claude Code from the repo-size-guard directory.
  • Enter /hooks in Claude Code.
  • Inspect the hook list for the project-level Bash matcher.

You will see the project-scoped PreToolUse hook for Bash. Its command points to bin/repo-size-guard with the hook argument.

Hook missing from the list?

Confirm that the settings file path is exactly .claude/settings.json. Restart Claude Code after saving the project configuration.

Confirm that bin/repo-size-guard exists in the project. Help me find why my project hook is missing.

Your growth scanner now guards supported Bash operations before they run. Next, you will gather performance evidence and publish the project with an explicit security model.

Prove Performance and Publish

Your guard now measures repository growth before supported Git operations. A security-focused tool also needs repeatable evidence that its decisions are correct.

In this step, you will use Go tests and benchmarks to capture that evidence. You will document the security boundary before publishing the project through GitHub.

In this step, get ready to:
  • Run the formatting, vetting, and test gates across the complete module.
  • Save reproducible benchmark evidence and document the guard's architecture.
  • Commit the finished project and publish its public GitHub repository.
Run the quality gates and benchmarks

Formatting catches inconsistent source layout. Vetting checks suspicious Go constructs. The full test suite proves the guard's repository analysis and hook decisions still behave together.

Before you run these checks, do you expect every disposable-repository test and hook outcome to pass?

  • Format the module, inspect it with the Go vet tool, and run every test by executing:
go fmt ./...
go vet ./...
go test ./...

What do these checks prove?

  • The formatting command rewrites Go source into the standard layout.
  • The vet command reports suspicious constructs that compile but may behave incorrectly.
  • The test command creates disposable repositories for staged blobs, Git LFS pointers, custom filters, command classification, and hook outcomes.
  • Review the terminal output for failures.

You should see the package finish with a passing result. The passing `TestClassifyUncertainCommandsAsk` case confirms the base hook still escalates the compound mutating command to `ask`.

Seeing a formatting, vetting, or test failure?

Read the first failing file or test before the later output. Later failures can be consequences of that first problem.

Compare the named function with the corresponding source or test file. Help me diagnose the first failing Go quality check.

Benchmarks measure the status parser and command classifier repeatedly. The numbers depend on your machine, so the environment belongs beside the results.

  • Run each benchmark three times with allocation reporting by executing:
go test ./... -bench=. -benchmem -count=3

What does this benchmark measure?

  • `BenchmarkParseStatus1000` measures the parser against 1,000 NUL-delimited status records.
  • `BenchmarkClassifyCommand` measures classification of a supported literal `git add` command.
  • The three runs expose normal timing variation instead of presenting one result as universal.

You should see repeated rows for both benchmarks. Each row includes timing and allocation measurements.

Missing benchmark rows?

Confirm that `guard_test.go` still contains `BenchmarkParseStatus1000` and `BenchmarkClassifyCommand`. A regular test result without benchmark rows means the benchmark filter did not run.

Help me find why my Go benchmarks did not run.

  • Print the installed Go version for the benchmark record by running:
go version

Why record the Go version?

Compiler and runtime changes can affect benchmark measurements. The output also identifies the `darwin/amd64` platform used for this project.

  • Print the installed Git version for the benchmark record by running:
git --version

Why record the Git version?

The test suite creates real repositories and invokes Git processes. Recording the version makes that test environment reproducible.

  • Copy the complete benchmark output from the terminal.
  • Switch back to the `repo-size-guard` workspace in Visual Studio Code.
  • Create a `benchmarks` folder with the new-folder control in the Explorer sidebar.

You should now see `benchmarks` beside the Go source files in the Explorer sidebar.

  • Create `latest.txt` inside the `benchmarks` folder with the new-file control.
  • Paste the copied benchmark output into `benchmarks/latest.txt`.

The file now holds repeated results for `BenchmarkParseStatus1000` and `BenchmarkClassifyCommand`.

  • Add today's date above the benchmark output.
  • Add the Go version and Git version you just printed.
  • Add the macOS version from your Intel Mac's system information.
  • Add the `amd64` CPU architecture shown by the Go version output.
  • Add the exact benchmark command shown above.
  • Save `benchmarks/latest.txt`.

You should see the environment metadata above the measured output. Anyone can now distinguish your reproducible procedure from machine-dependent timing values.

Benchmark file missing context?

Check that `benchmarks/latest.txt` includes all six metadata items plus both benchmark result groups. Keep the exact command beside the results so another developer can repeat the run.

Help me audit my saved benchmark evidence.

Document the architecture and security boundary

A portfolio README must explain what the guard can prove. It must also show where exact measurements end and conservative estimates begin.

  • Create `README.md` inside the `repo-size-guard` folder with the new-file control in the Explorer sidebar.
  • Add the overview and threshold policy by pasting:
# repo-size-guard

`repo-size-guard` is a standard-library-only Go CLI and Claude Code `PreToolUse` hook that estimates repository growth before supported `git add` operations and measures exact staged logical blob sizes before ordinary commits.

## Policy

| Projected new Git blob bytes | Hook outcome |
| --- | --- |
| Below 50 MiB with verified supported input | No hook decision. Normal Claude Code permissions still apply. |
| 50 MiB to below 100 MiB | `ask` |
| 100 MiB or more | `deny` |
| Unsupported syntax, custom filter output, or unknown operation | `ask` |
| Known mutating compound prefix followed by guarded Git work | `deny` after the Secret Mission |

Thresholds are configurable with `--warn-bytes` and `--fail-bytes`.

What does this section communicate?

  • The opening identifies the CLI and its Claude Code hook role.
  • The table maps measured or uncertain growth to silence, `ask`, or `deny`.
  • The final policy row reserves known compound-chain denial for the optional hardening challenge.
  • Save `README.md`.

You should now see the project overview and five policy outcomes in the editor.

Policy table not aligned?

Check that every table row begins and ends with a pipe character. Keep the separator row directly below the two column headings.

Help me repair the Markdown policy table.

A Mermaid flowchart makes the hook's decision path visible. It separates worktree estimation from exact index inspection before both paths reach the threshold policy.

  • Move to the end of `README.md`.
  • Add the architecture diagram by pasting:
## Architecture

```mermaid
flowchart LR
    A[Claude Code Bash tool call] --> B[PreToolUse hook]
    B --> C[Conservative command classifier]
    C -->|git add| D[Worktree candidate scanner]
    C -->|ordinary git commit| E[Git index blob scanner]
    C -->|unsupported or ambiguous| F[Ask]
    D --> G[Raw-size estimate and filter check]
    E --> H[Exact logical blob size and LFS pointer check]
    G --> I[Threshold policy]
    H --> I
    I -->|verified below warning| J[No hook decision]
    I -->|warning or uncertainty| F
    I -->|failure or known unsafe chain| K[Deny]
```

How does the flowchart map the guard?

  • Supported `git add` commands enter the conservative worktree scanner.
  • Ordinary commits enter the exact index blob scanner.
  • Warnings and uncertainty converge on `ask`.
  • Failure-level growth converges on `deny`.
  • Save `README.md`.

You should now see the complete Mermaid source beneath the architecture heading.

Diagram source looks incomplete?

Confirm that the Mermaid fence has both an opening and closing line. Keep each node identifier consistent across the connecting arrows.

Help me compare my Mermaid flowchart with the intended hook path.

  • Move to the end of `README.md`.
  • Add the measurement semantics and supported-command scope by pasting:
## Measurement semantics

- `raw_bytes` is the current worktree file size from the filesystem.
- `git_blob_bytes` is exact for content already staged in the index.
- Worktree projections are estimates until Git applies clean filters and writes a blob.
- A configured `filter=lfs` or any other custom filter produces `ask` before staging because its cleaned blob is not yet measurable by this implementation.
- A canonical staged Git LFS pointer is measured as the small pointer blob. Its `size` line describes the separately stored LFS object.
- `projected_new_git_blob_bytes` is a logical estimate relative to changed index entries. It is not exact physical packfile growth. Existing object IDs elsewhere in history, compression, and delta compression can reduce disk growth.
- Unchanged tracked files do not appear as index-versus-HEAD changes and contribute zero projected bytes.

## Supported command scope

Supported:

- `git add .`
- `git add -A`
- `git add --all`
- `git add -- <literal paths>`
- Ordinary `git commit`
- `git commit -m "message"`
- A small set of message and non-content commit flags implemented in `classify.go`

Returns `ask`:

- Interactive staging and commit modes
- Commit pathspecs, `git commit -a`, and amend flows
- Shell variables, command substitution, backticks, globs, pipes, redirections, aliases, wrappers, and unknown flags
- Custom clean filters whose result is not staged
- Unknown compound-command prefixes

This project is not a complete shell parser or a replacement for Claude Code permissions, managed policy, branch protection, remote size limits, malware scanning, or secret scanning.

Why separate these semantics?

  • Staged blob sizes are exact logical measurements.
  • Worktree sizes remain estimates until Git applies clean filters.
  • Physical storage remains an estimate because object reuse and pack compression happen below the tool's model.
  • Unknown command forms require confirmation because the classifier cannot verify their effects.
  • Save `README.md`.

You should now see separate supported and `ask` lists beneath the measurement model.

Measurement claims feel ambiguous?

Check every use of exact and estimate. Exact applies to staged logical blob bytes. Estimated applies to worktree projections and physical repository growth.

Help me audit the README measurement claims.

  • Move to the end of `README.md`.
  • Add the build, hook, and standalone scan instructions by pasting:
## Build and enable

```bash
go version
go fmt ./...
go vet ./...
go test ./...
mkdir -p bin
go build -o bin/repo-size-guard .
```

The committed `.claude/settings.json` runs the binary in exec form and sets `onFailure` to `block`. Claude Code 2.1.295 or later is required for that fail-closed field.

Use `/hooks` inside Claude Code to confirm the project hook is loaded.

## Standalone reports

```bash
./bin/repo-size-guard scan --staged
./bin/repo-size-guard scan --worktree -- .
./bin/repo-size-guard scan --worktree --warn-bytes 1048576 --fail-bytes 2097152 -- assets
```

What can a reader reproduce here?

  • The build sequence formats, checks, tests, and compiles the executable.
  • The hook note records the fail-closed Claude Code requirement.
  • The scan examples cover staged analysis, worktree analysis, and custom thresholds.
  • Save `README.md`.

You should now see one build command group and one standalone report group.

Build or scan instructions missing?

Confirm that both fenced command groups are closed. Keep the hook explanation between the build group and the standalone report heading.

Help me restore the README command sections.

  • Move to the end of `README.md`.
  • Add the test coverage and reproducible benchmark procedure by pasting:
## Tests

```bash
go test ./...
```

The suite creates disposable repositories with `testing.T.TempDir` and covers:

- Already-tracked unchanged content
- Oversized binary denial
- Raw worktree size versus staged Git LFS pointer size
- Unstaged LFS filter uncertainty
- Silent safe outcome, `ask`, and `deny`
- Conservative command classification

## Reproducible benchmarks

```bash
go test ./... -bench=. -benchmem -count=3
```

Save the terminal output as `benchmarks/latest.txt` and record:

- Date
- `go version`
- `git --version`
- macOS version
- CPU architecture
- Exact benchmark command

Benchmark values are machine-dependent. The command and fixture are reproducible, not the exact nanoseconds on different hardware.

Why publish the procedure with the numbers?

A benchmark result only describes one recorded environment. The command and fixture let another developer repeat the experiment on a different machine.

  • Save `README.md`.

You should now see the disposable-repository coverage list and all six benchmark metadata fields.

Benchmark documentation incomplete?

Compare the metadata list with `benchmarks/latest.txt`. Both places should name the date, tool versions, macOS version, architecture, and exact command.

Help me align the README with my benchmark record.

  • Move to the end of `README.md`.
  • Add remediation guidance and the security limitations by pasting:
## Large-file remediation

For large binaries, consider:

1. Git LFS when versioned large assets must remain associated with repository history.
2. GitHub release assets for distributable builds or datasets.
3. Object storage for generated or operational artifacts.
4. Regenerating derived files instead of committing them.

A canonical Git LFS pointer begins with:

```text
version https://git-lfs.github.com/spec/v1
oid sha256:<64 lowercase hexadecimal characters>
size <bytes>
```

Git stores the pointer as a normal small blob. Git LFS stores the referenced large object separately.

## Security limitations

- The parser intentionally supports a narrow Bash grammar.
- Aliases and wrapper scripts can hide Git behavior from this hook.
- Sparse checkouts, submodules, alternate worktrees, alternate indexes, explicit Git environment overrides, unusual clean filters, and interactive Git modes are outside the verified path.
- Worktree measurements without filters are raw-size estimates. Staged index blob sizes are exact logical sizes.
- Physical repository growth is always an estimate because Git object reuse and pack compression happen below this tool's model.
- Failures in the hook process are blocked by Claude Code through `onFailure: "block"`.

What threat model does this establish?

  • Large binaries have concrete alternatives outside ordinary Git history.
  • The pointer example explains what Git stores when Git LFS handles the large object.
  • The limitations name command forms and repository configurations outside the verified path.
  • Hook process failures remain fail-closed through the project configuration.
  • Save `README.md`.

You should now see four remediation options followed by six explicit security limitations.

Security boundary sounds too broad?

Keep the claims tied to the implemented parser and repository model. The guard verifies a narrow command scope and escalates unsupported forms.

Help me tighten an overstated security claim.

  • Move to the end of `README.md`.
  • Add the publication instructions by pasting:
## Publish

```bash
gh auth status
git add .
git commit -m "Build repo-size-guard"
gh repo create repo-size-guard-go --public --source=. --remote=origin --push
```

What does this final section provide?

The commands verify GitHub CLI authentication before staging and committing the project. The final command creates the public repository with `origin` as its remote and pushes the current branch.

  • Save `README.md`.

You should now see the publication command group at the end of the README.

README section order looks wrong?

Keep publication last. Readers should understand the policy, architecture, evidence, remediation, and limitations before they reach the publishing commands.

Help me check my README section order.

✔️ Awesome, I've got everything!

Great. Double check that `README.md` is saved and `benchmarks/latest.txt` contains your measured output with its environment metadata.

ⓧ I'd like to double check the full code

Compare your complete `README.md` with this reference:

# repo-size-guard

`repo-size-guard` is a standard-library-only Go CLI and Claude Code `PreToolUse` hook that estimates repository growth before supported `git add` operations and measures exact staged logical blob sizes before ordinary commits.

## Policy

| Projected new Git blob bytes | Hook outcome |
| --- | --- |
| Below 50 MiB with verified supported input | No hook decision. Normal Claude Code permissions still apply. |
| 50 MiB to below 100 MiB | `ask` |
| 100 MiB or more | `deny` |
| Unsupported syntax, custom filter output, or unknown operation | `ask` |
| Known mutating compound prefix followed by guarded Git work | `deny` after the Secret Mission |

Thresholds are configurable with `--warn-bytes` and `--fail-bytes`.

## Architecture

```mermaid
flowchart LR
    A[Claude Code Bash tool call] --> B[PreToolUse hook]
    B --> C[Conservative command classifier]
    C -->|git add| D[Worktree candidate scanner]
    C -->|ordinary git commit| E[Git index blob scanner]
    C -->|unsupported or ambiguous| F[Ask]
    D --> G[Raw-size estimate and filter check]
    E --> H[Exact logical blob size and LFS pointer check]
    G --> I[Threshold policy]
    H --> I
    I -->|verified below warning| J[No hook decision]
    I -->|warning or uncertainty| F
    I -->|failure or known unsafe chain| K[Deny]
```

## Measurement semantics

- `raw_bytes` is the current worktree file size from the filesystem.
- `git_blob_bytes` is exact for content already staged in the index.
- Worktree projections are estimates until Git applies clean filters and writes a blob.
- A configured `filter=lfs` or any other custom filter produces `ask` before staging because its cleaned blob is not yet measurable by this implementation.
- A canonical staged Git LFS pointer is measured as the small pointer blob. Its `size` line describes the separately stored LFS object.
- `projected_new_git_blob_bytes` is a logical estimate relative to changed index entries. It is not exact physical packfile growth. Existing object IDs elsewhere in history, compression, and delta compression can reduce disk growth.
- Unchanged tracked files do not appear as index-versus-HEAD changes and contribute zero projected bytes.

## Supported command scope

Supported:

- `git add .`
- `git add -A`
- `git add --all`
- `git add -- <literal paths>`
- Ordinary `git commit`
- `git commit -m "message"`
- A small set of message and non-content commit flags implemented in `classify.go`

Returns `ask`:

- Interactive staging and commit modes
- Commit pathspecs, `git commit -a`, and amend flows
- Shell variables, command substitution, backticks, globs, pipes, redirections, aliases, wrappers, and unknown flags
- Custom clean filters whose result is not staged
- Unknown compound-command prefixes

This project is not a complete shell parser or a replacement for Claude Code permissions, managed policy, branch protection, remote size limits, malware scanning, or secret scanning.

## Build and enable

```bash
go version
go fmt ./...
go vet ./...
go test ./...
mkdir -p bin
go build -o bin/repo-size-guard .
```

The committed `.claude/settings.json` runs the binary in exec form and sets `onFailure` to `block`. Claude Code 2.1.295 or later is required for that fail-closed field.

Use `/hooks` inside Claude Code to confirm the project hook is loaded.

## Standalone reports

```bash
./bin/repo-size-guard scan --staged
./bin/repo-size-guard scan --worktree -- .
./bin/repo-size-guard scan --worktree --warn-bytes 1048576 --fail-bytes 2097152 -- assets
```

## Tests

```bash
go test ./...
```

The suite creates disposable repositories with `testing.T.TempDir` and covers:

- Already-tracked unchanged content
- Oversized binary denial
- Raw worktree size versus staged Git LFS pointer size
- Unstaged LFS filter uncertainty
- Silent safe outcome, `ask`, and `deny`
- Conservative command classification

## Reproducible benchmarks

```bash
go test ./... -bench=. -benchmem -count=3
```

Save the terminal output as `benchmarks/latest.txt` and record:

- Date
- `go version`
- `git --version`
- macOS version
- CPU architecture
- Exact benchmark command

Benchmark values are machine-dependent. The command and fixture are reproducible, not the exact nanoseconds on different hardware.

## Large-file remediation

For large binaries, consider:

1. Git LFS when versioned large assets must remain associated with repository history.
2. GitHub release assets for distributable builds or datasets.
3. Object storage for generated or operational artifacts.
4. Regenerating derived files instead of committing them.

A canonical Git LFS pointer begins with:

```text
version https://git-lfs.github.com/spec/v1
oid sha256:<64 lowercase hexadecimal characters>
size <bytes>
```

Git stores the pointer as a normal small blob. Git LFS stores the referenced large object separately.

## Security limitations

- The parser intentionally supports a narrow Bash grammar.
- Aliases and wrapper scripts can hide Git behavior from this hook.
- Sparse checkouts, submodules, alternate worktrees, alternate indexes, explicit Git environment overrides, unusual clean filters, and interactive Git modes are outside the verified path.
- Worktree measurements without filters are raw-size estimates. Staged index blob sizes are exact logical sizes.
- Physical repository growth is always an estimate because Git object reuse and pack compression happen below this tool's model.
- Failures in the hook process are blocked by Claude Code through `onFailure: "block"`.

## Publish

```bash
gh auth status
git add .
git commit -m "Build repo-size-guard"
gh repo create repo-size-guard-go --public --source=. --remote=origin --push
```
Publish the portfolio repository

Publishing makes every tracked file visible in a public repository. Review the changed-file list first so the commit contains source, tests, documentation, hook configuration, and benchmark evidence only.

  • Review the changed-file list in Visual Studio Code's source control panel.
  • Confirm that no credentials or private files are included.

Before you publish, what do you expect the new GitHub repository page to show after the push completes?

  • Verify authentication, create the final commit, and publish the repository by running:
gh auth status
git add .
git commit -m "Build repo-size-guard"
gh repo create repo-size-guard-go --public --source=. --remote=origin --push

What happens during publication?

  • GitHub CLI first confirms the active authenticated account.
  • Git stages the project and creates the `Build repo-size-guard` commit.
  • GitHub creates the public `repo-size-guard-go` repository.
  • The final push sends the current branch and configures `origin` as the remote.
  • Open the public repository URL printed by GitHub CLI.
  • Scroll to the architecture section in the rendered `README.md`.

You should see the Mermaid flowchart rendered as a diagram. The repository should also show the `Build repo-size-guard` commit and the saved benchmark evidence.

That completes the evidence trail. Your public repository now explains what the guard measures, what it escalates, and where its security boundary ends.

Repository creation or push failed?

An authentication problem means the active GitHub CLI session needs attention. A repository-name conflict means `repo-size-guard-go` already exists under the account or is unavailable.

Fix the reported account or naming issue before repeating the publication command. Help me diagnose my GitHub publication failure.

Secret mission

Block the Command-Chain Bypass

The base hook sends every compound command for confirmation. Harden that policy so known file mutators before guarded Git work receive an immediate denial. Unknown command chains must continue to require confirmation.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep the guard active, pause its project hook for later, or remove the project entirely. This project uses only local files and a public repository, so it has no ongoing costs.

Resources you used:

  • Local Go module at ~/Projects/repo-size-guard. This directory contains the source files, tests, benchmark evidence, and compiled binary.
  • Project-scoped Claude Code hook configuration at .claude/settings.json.
  • Public GitHub repository named repo-size-guard-go.

Keep everything running

No action is needed. Choose this option if you are still developing the guard or demonstrating its policy decisions.

  • Leave .claude/settings.json in place so Claude Code keeps screening supported Git commands in this project.
  • Retain benchmarks/latest.txt as your reproducible performance record.
  • Keep repo-size-guard-go public so other people can inspect the implementation and tests.

Pause - I'll come back to this later

Pausing removes the project hook from Claude Code's loaded configuration. Your source files and public repository remain available for later.

Finding .claude/settings.json can be a little fiddly because the folder name starts with a dot.

  • Return to the repo-size-guard workspace in Visual Studio Code.
  • Find .claude/settings.json in the Explorer sidebar.
  • Move .claude/settings.json outside ~/Projects/repo-size-guard.
  • Restart Claude Code.
  • Enter /hooks in Claude Code to inspect the loaded project hooks.

You should no longer see the repo-size-guard project hook. This confirms its pre-command checks are paused.

  • Return the file to .claude/settings.json inside ~/Projects/repo-size-guard when you want to resume.
  • Restart Claude Code.
  • Enter /hooks to confirm the project hook is loaded again.

Delete - I don't want to use this again

Removing both copies is permanent. The scoped actions below leave every other GitHub repository and local project untouched.

  • Return to the public repo-size-guard-go repository on GitHub.
  • Open the repository settings page.
  • Start GitHub's repository deletion flow.
  • Enter repo-size-guard-go when GitHub asks you to confirm the repository name.
  • Complete the deletion confirmation.
  • Open your GitHub repository list to confirm the remote copy is gone.

You should no longer see repo-size-guard-go in your repository list. This confirms the public copy is deleted.

The local ~/Projects/repo-size-guard directory contains every remaining project artifact. Removing it also removes the project-scoped hook configuration.

  • Remove the local project directory by running this command in Terminal:
rm -rf ~/Projects/repo-size-guard

What Does This Command Remove?

The command targets only ~/Projects/repo-size-guard. It removes the source files, tests, benchmark evidence, compiled binary, and hook configuration.

Removing .claude/settings.json prevents the project hook from loading again.

  • Check the ~/Projects folder in Finder.

You should no longer see the repo-size-guard directory. The local guard and its project hook are now removed.

Local Folder Still Present?

  • Close any Visual Studio Code window that still uses ~/Projects/repo-size-guard.
  • End any Claude Code session that still uses ~/Projects/repo-size-guard.

Help me remove the local project directory safely.

Nice Work!

Nice Work!

You did it! Your public GitHub repository now showcases a standard-library-only Go guard that measures Git growth before supported Claude Code Bash calls run.

You've learned how to:

  • You built a standard-library-only Go CLI for shell-free Git inspection. Its JSON reports show raw file bytes separately from exact staged logical blob bytes. Worktree growth remains clearly labelled as an estimate. Git LFS pointers expose their separately stored object size.
  • You implemented a conservative hook policy for Claude Code's PreToolUse event. Verified safe commands produce no hook decision. Warning-level or uncertain commands return ask. Failure-level commands return deny. The default policy warns at 50 MiB. It denies at 100 MiB.
  • You proved the guard with disposable repository tests. You recorded reproducible benchmark evidence with machine metadata. You published a Mermaid architecture diagram in the public repo-size-guard-go repository.
  • In the Secret Mission, you added command-chain hardening. Known mutating prefixes now return deny before Bash can create the destination file. Unknown compound forms continue to return ask. The TestKnownMutatingChainDenied test proves the oversized destination file stays absent.

Ready to quiz yourself?