Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,7 @@ pre-push:
- "go.mod"
- "go.sum"
run: tools/githooks/.bin/githooks test {push_files}

pr-size-guard:
tags: [guard, diff]
run: tools/githooks/.bin/githooks pr-size --fail-on-large
31 changes: 30 additions & 1 deletion tools/githooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@

## Features

- **End-of-File Normalization:** Ensures eligible text and code files (`.go`, `.py`, `.md`, `.yaml`, `.json`, etc.) end with exactly one newline (`\n`), leaving empty files 0 bytes.
- **End-of-File Normalization:** Ensures eligible text and code files end with exactly one newline.
- **Trailing Whitespace Fixing:** Fixes erroneous trailing whitespace across eligible text and document files (preserving Markdown 2-space hard line breaks).
- **Module & Package Resolution:** Automatically maps changed or staged Go files to their enclosing `go.mod` module roots and specific package paths (e.g. `./core/logger`).
- **Targeted Code Generation:** Runs `go generate` only for packages where `.proto` or generate files changed, updates config schema docs and `go.md` when relevant files change, and regenerates mocks via `mockery` when affected packages are listed in a `.mockery.yaml`.
- **Parallel Module Tidy:** Runs `go mod tidy` in parallel across all affected modules.
- **Targeted Linting:** Runs `golangci-lint` only against the exact changed packages within affected modules instead of scanning whole modules or the entire repository.
- **Targeted Unit Testing:** Discovers changed test packages and executes `tools/test` with `-short` directly on those packages (aligned with CI unit test scope).
- **PR Diff Size Guard:** Calculates branch diff against the default branch merge-base, classifies size (`small`, `medium`, `large`), warns or fails on large diffs, and prompts developers to break PRs into smaller chunks.
- **Dependency Changes:** Automatically runs on all packages (`./...`) if a module's `go.mod` or `go.sum` is modified.
- **Lefthook Integration:** Seamlessly works with Lefthook staged/push file filters and `stage_fixed`.

Expand Down Expand Up @@ -115,6 +116,34 @@ go -C tools/githooks run . test core/logger/logger_test.go tools/ci-testshard/ma
go -C tools/githooks run . test --short=false
```

### `pr-size` (aliases: `big-pr-guard`, `pr-guard`, `diff-guard`, `diff-size`)

Calculates the git diff of the current branch against the default branch (the PR diff), classifies it into size categories (`small`, `medium`, `large`), and prompts the developer with recommendations to split large PRs.

```bash
# Check PR diff size with default settings
go -C tools/githooks run . pr-size

# Check with custom thresholds
go -C tools/githooks run . pr-size --small-limit=100 --medium-limit=300

# Fail the command on large PRs (useful in CI or strict pre-push mode)
go -C tools/githooks run . pr-size --fail-on-large

# Bypass large PR failure via flag or env var
go -C tools/githooks run . pr-size --fail-on-large --allow-large-pr
ALLOW_LARGE_PR=true go -C tools/githooks run . pr-size --fail-on-large

# Select calculation strategy: per-file-max (default), sum, max, or weighted
go -C tools/githooks run . pr-size --strategy=per-file-max

# Diff against a specific base branch or ref
go -C tools/githooks run . pr-size --base=origin/develop

# Automatically ignores generated files defined in .gitattributes (linguist-generated=true)
go -C tools/githooks run . pr-size --ignore-generated=true
```

## Running Tests & Benchmarks

```bash
Expand Down
80 changes: 80 additions & 0 deletions tools/githooks/cmd/pr_size.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package cmd

import (
"errors"
"fmt"

"github.com/spf13/cobra"

"github.com/smartcontractkit/chainlink/v2/tools/githooks/internal/prsize"
)

func newPRSizeCmd() *cobra.Command {
var (
strategy string
smallLimit int
mediumLimit int
failOnLarge bool
allowLargePR bool
baseRef string
ignoreLockfiles bool
ignoreGenerated bool
includeUncommitted bool
)

cmd := &cobra.Command{
Use: "pr-size",
Aliases: []string{"big-pr-guard", "pr-guard", "diff-guard", "diff-size"},
Short: "Check PR diff size against default branch and guard against oversized PRs",
Long: "Calculates the git diff of the current branch against the default branch (the PR diff), " +
"classifies the diff as small, medium, or large, and prompts developers with recommendations " +
"to split large PRs into smaller, reviewable chunks.",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()

repoRoot, err := findRepoRoot(ctx)
if err != nil {
return fmt.Errorf("could not find repo root: %w", err)
}

cfg := prsize.Config{
RepoRoot: repoRoot,
BaseRef: baseRef,
Strategy: prsize.Strategy(strategy),
SmallLimit: smallLimit,
MediumLimit: mediumLimit,
FailOnLarge: failOnLarge,
AllowLargePR: allowLargePR,
IgnoreLockfiles: ignoreLockfiles,
IgnoreGenerated: ignoreGenerated,
IncludeUncommitted: includeUncommitted,
Stdout: cmd.OutOrStdout(),
Stderr: cmd.ErrOrStderr(),
UI: uiForCmd(cmd),
}

if err := prsize.Run(ctx, cfg); err != nil {
if errors.Is(err, prsize.ErrLargePR) {
return SilentError{Err: err}
}
return err
}
return nil
},
}

cmd.Flags().StringVar(&strategy, "strategy", string(prsize.StrategyPerFileMax), "Diff size strategy: per-file-max, sum, max, weighted")
cmd.Flags().IntVar(&smallLimit, "small-limit", prsize.DefaultSmallLimit, "Maximum effective lines for small classification")
cmd.Flags().IntVar(&smallLimit, "max-small", prsize.DefaultSmallLimit, "Alias for --small-limit")
cmd.Flags().IntVar(&mediumLimit, "medium-limit", prsize.DefaultMediumLimit, "Maximum effective lines for medium classification")
cmd.Flags().IntVar(&mediumLimit, "max-medium", prsize.DefaultMediumLimit, "Alias for --medium-limit")
cmd.Flags().BoolVar(&failOnLarge, "fail-on-large", false, "Exit with error code if PR is classified as large")
cmd.Flags().BoolVar(&allowLargePR, "allow-large-pr", false, "Allow large PRs without failing (or set ALLOW_LARGE_PR=true)")
cmd.Flags().StringVar(&baseRef, "base", "", "Base branch or ref to diff against (default: origin default branch merge-base)")
cmd.Flags().StringVar(&baseRef, "rev", "", "Alias for --base")
cmd.Flags().BoolVar(&ignoreLockfiles, "ignore-lockfiles", true, "Ignore lockfiles (go.sum, package-lock.json, etc.) from diff line count")
cmd.Flags().BoolVar(&ignoreGenerated, "ignore-generated", true, "Ignore generated changes marked in .gitattributes (linguist-generated)")
cmd.Flags().BoolVar(&includeUncommitted, "include-uncommitted", false, "Include uncommitted staged and unstaged working-tree changes")

return cmd
}
42 changes: 42 additions & 0 deletions tools/githooks/cmd/pr_size_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package cmd_test

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

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/smartcontractkit/chainlink/v2/tools/githooks/cmd"
)

//nolint:paralleltest // t.Chdir is process-global
func TestPRSizeCmd_Run(t *testing.T) {
dir := t.TempDir()
git(t, dir, "init")
require.NoError(t, os.WriteFile(filepath.Join(dir, "base.go"), []byte("package main\n"), 0o600))
git(t, dir, "add", ".")
git(t, dir, "commit", "-m", "init")
baseSHA := git(t, dir, "rev-parse", "HEAD")
git(t, dir, "update-ref", "refs/remotes/origin/develop", baseSHA)
git(t, dir, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/develop")

require.NoError(t, os.WriteFile(filepath.Join(dir, "feature.go"), []byte("package main\n// new\n"), 0o600))
git(t, dir, "add", ".")
git(t, dir, "commit", "-m", "feature")

t.Chdir(dir)

root := cmd.NewRootCmd()
buf := new(bytes.Buffer)
root.SetOut(buf)
root.SetErr(buf)
root.SetArgs([]string{"pr-size", "--no-color"})

err := root.Execute()
require.NoError(t, err)
assert.Contains(t, buf.String(), "PR Diff Size")
assert.Contains(t, buf.String(), "Classification: [ SMALL ] [ OK ]")
}
2 changes: 2 additions & 0 deletions tools/githooks/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ func NewRootCmd() *cobra.Command {
rootCmd.AddCommand(newGenerateCmd())
rootCmd.AddCommand(newEOFCmd())
rootCmd.AddCommand(newWhitespaceCmd())
rootCmd.AddCommand(newPRSizeCmd())
rootCmd.AddCommand(newSampleOutputsCmd())
return rootCmd
}

Expand Down
89 changes: 89 additions & 0 deletions tools/githooks/cmd/sample_outputs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package cmd

import (
"fmt"
"io"

"github.com/spf13/cobra"

"github.com/smartcontractkit/chainlink/v2/tools/githooks/internal/ui"
)

func newSampleOutputsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "sample-outputs",
Short: "Showcase all terminal UI output styles (hidden developer tool)",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
u := uiForCmd(cmd)
out := cmd.OutOrStdout()

renderSampleOutputs(out, u)
return nil
},
}

return cmd
}

func renderSampleOutputs(out io.Writer, u *ui.UI) {
// 1. Headers & Section
fmt.Fprintln(out, u.Bold("=== [githooks UI Output Showcase] ==="))
fmt.Fprintln(out)

// 2. Badges & Tags
fmt.Fprintln(out, u.Bold("--- Status Badges & Tags ---"))
fmt.Fprintf(out, "Success: %s\n", u.BadgeSuccess("OK"))
fmt.Fprintf(out, "Warning: %s\n", u.BadgeWarning("MEDIUM"))
fmt.Fprintf(out, "Danger: %s\n", u.BadgeDanger("LARGE"))
fmt.Fprintf(out, "Info: %s\n", u.BadgeInfo("SMALL"))
fmt.Fprintf(out, "Tags: %s %s %s %s\n", u.BadgeTag("LINT"), u.BadgeTag("TEST"), u.BadgeTag("FIXED"), u.BadgeTag("CHECK FAIL"))
fmt.Fprintln(out)

// 3. PR Size Guard Samples
fmt.Fprintln(out, u.Bold("--- PR Size Guard (pr-size) ---"))
// Small
fmt.Fprintf(out, "PR Diff Size: %s -> Classification: %s %s\n",
u.FormatDiff(45, 45, 0, 2, "per-file-max"),
u.BadgeInfo("SMALL"),
u.BadgeSuccess("OK"),
)
// Medium
fmt.Fprintf(out, "PR Diff Size: %s -> Classification: %s %s\n",
u.FormatDiff(320, 280, 40, 6, "per-file-max"),
u.BadgeWarning("MEDIUM"),
u.BadgeSuccess("OK"),
)
fmt.Fprintf(out, " %s\n", u.Dim("(Excluded 3 lock/ignored files)"))
fmt.Fprintln(out)

// Large PR Warning Card (Variation 1A)
title := fmt.Sprintf("%s %s", u.BadgeDangerPill("LARGE PR"), u.Danger("(1250 lines > 500 limit)"))
diffLine := fmt.Sprintf("%s %s / %s in 14 files (per-file-max) • %s",
u.Bold("Diff:"),
u.Additions(1100),
u.Deletions(150),
u.Dim("Excluded: 2 lockfiles"),
)
bypassLine := fmt.Sprintf("%s %s", u.Bold("Bypass:"), u.Dim("ALLOW_LARGE_PR=true git push"))
promptLine := u.Bold("AI Prompt:") + " Execute the skill @tools/githooks/skills/split-pr/SKILL.md to break feature-branch branch up."

fmt.Fprintln(out, u.CompactWarningCard(title, []string{diffLine, bypassLine, promptLine}))
fmt.Fprintln(out)

// 4. Runner Step Headers
fmt.Fprintln(out, u.Bold("--- Runners (lint & test) ---"))
fmt.Fprintln(out, u.RunnerHeader("LINT", "core", "4 packages"))
fmt.Fprintln(out, u.RunnerHeader("LINT", "deployment", "./environment"))
fmt.Fprintln(out, u.RunnerHeader("TEST", "plugins", "2 packages"))
fmt.Fprintln(out, u.RunnerHeader("TEST", ".", "./core/logger ./core/services"))
fmt.Fprintln(out)

// 5. Fixer Status Items
fmt.Fprintln(out, u.Bold("--- Fixers (whitespace & eof) ---"))
fmt.Fprintln(out, u.StatusItem("FIXED", "core/services/app.go", ""))
fmt.Fprintln(out, u.StatusItem("FIXED", "deployment/environment.go", ""))
fmt.Fprintln(out, u.StatusItem("CHECK FAIL", "core/config/config.go", "erroneous trailing whitespace"))
fmt.Fprintln(out, u.StatusItem("CHECK FAIL", "README.md", "missing trailing newline"))
fmt.Fprintln(out)
}
31 changes: 31 additions & 0 deletions tools/githooks/cmd/sample_outputs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package cmd_test

import (
"bytes"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/smartcontractkit/chainlink/v2/tools/githooks/cmd"
)

func TestSampleOutputsCmd_ExecutesSuccessfully(t *testing.T) {
t.Parallel()

root := cmd.NewRootCmd()
buf := new(bytes.Buffer)
root.SetOut(buf)
root.SetErr(buf)
root.SetArgs([]string{"sample-outputs", "--no-color"})

err := root.Execute()
require.NoError(t, err)

out := buf.String()
assert.Contains(t, out, "PR Diff Size")
assert.Contains(t, out, "LARGE PR")
assert.Contains(t, out, "LINT")
assert.Contains(t, out, "TEST")
assert.Contains(t, out, "FIXED")
}
Loading