Skip to content
Closed
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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ diffguard /path/to/repo
# Specify a base branch
diffguard --base main /path/to/repo

# Restrict diff mode to a subtree
diffguard --base develop --include-paths miner/ /path/to/repo

# Refactoring mode: analyze entire files/dirs (no diff required)
diffguard --paths internal/foo/bar.go /path/to/repo
diffguard --paths internal/foo/,internal/bar/ /path/to/repo
Expand All @@ -61,9 +64,9 @@ diffguard \

### Modes

**Diff mode (default):** Analyzes only the regions changed between `HEAD` and the base branch. Use this as a CI gate for PRs.
**Diff mode (default):** Analyzes only the regions changed between `HEAD` and the base branch. Use this as a CI gate for PRs. Add `--include-paths` to limit diff mode to specific files or directories after the git diff is computed.

**Refactoring mode (`--paths`):** Analyzes the full content of the specified files or directories, ignoring git diff entirely. Use this when iterating on an existing file's quality without a base to compare against.
**Refactoring mode (`--paths`):** Analyzes the full content of the specified files or directories, ignoring git diff entirely. Use this when iterating on an existing file's quality without a base to compare against. `--paths` is mutually exclusive with `--base` and `--include-paths`.

## What It Measures

Expand Down Expand Up @@ -158,6 +161,7 @@ diffguard [flags] <repo-path>

Flags:
--base string Base branch to diff against (default: auto-detect)
--include-paths string Comma-separated files/dirs to restrict diff mode to after git diff
--paths string Comma-separated files/dirs to analyze in full (refactoring mode); skips git diff
--complexity-threshold int Maximum cognitive complexity per function (default 10)
--function-size-threshold int Maximum lines per function (default 50)
Expand Down
86 changes: 73 additions & 13 deletions cmd/diffguard/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func main() {
flag.StringVar(&cfg.FailOn, "fail-on", "warn", "Exit non-zero if thresholds breached: none, warn, all")
flag.StringVar(&cfg.BaseBranch, "base", "", "Base branch to diff against (default: auto-detect)")
flag.StringVar(&cfg.Paths, "paths", "", "Comma-separated files/dirs to analyze in full (refactoring mode); skips git diff")
flag.StringVar(&cfg.IncludePaths, "include-paths", "", "Comma-separated files/dirs to restrict diff mode to after git diff")
flag.Parse()

if flag.NArg() < 1 {
Expand Down Expand Up @@ -74,9 +75,14 @@ type Config struct {
FailOn string
BaseBranch string
Paths string
IncludePaths string
}

func run(repoPath string, cfg Config) error {
if err := validateConfig(cfg); err != nil {
return err
}

d, err := loadFiles(repoPath, cfg)
if err != nil {
return err
Expand All @@ -102,11 +108,7 @@ func run(repoPath string, cfg Config) error {
}

func announceRun(d *diff.Result, cfg Config) {
if cfg.Paths != "" {
fmt.Fprintf(os.Stderr, "Analyzing %d Go files (refactoring mode)...\n", len(d.Files))
} else {
fmt.Fprintf(os.Stderr, "Analyzing %d changed Go files against %s...\n", len(d.Files), cfg.BaseBranch)
}
fmt.Fprintln(os.Stderr, announceMessage(len(d.Files), cfg))
}

func runAnalyses(repoPath string, d *diff.Result, cfg Config) ([]report.Section, error) {
Expand Down Expand Up @@ -182,20 +184,78 @@ func checkExitCode(r report.Report, failOn string) error {

func loadFiles(repoPath string, cfg Config) (*diff.Result, error) {
if cfg.Paths != "" {
paths := strings.Split(cfg.Paths, ",")
for i := range paths {
paths[i] = strings.TrimSpace(paths[i])
}
d, err := diff.CollectPaths(repoPath, paths)
if err != nil {
return nil, fmt.Errorf("collecting paths: %w", err)
return loadRefactoringFiles(repoPath, cfg.Paths)
}
return loadDiffFiles(repoPath, cfg)
}

func validateConfig(cfg Config) error {
if cfg.Paths != "" && cfg.BaseBranch != "" {
return fmt.Errorf("--paths and --base are mutually exclusive; use --include-paths to scope diff mode")
}
if cfg.Paths != "" && cfg.IncludePaths != "" {
return fmt.Errorf("--paths and --include-paths are mutually exclusive")
}
return nil
}

func parsePathList(raw, flagName string) ([]string, error) {
parts := strings.Split(raw, ",")
paths := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
paths = append(paths, part)
}
return d, nil
}
if len(paths) == 0 {
return nil, fmt.Errorf("--%s requires at least one path", flagName)
}
return paths, nil
}

func announceMessage(fileCount int, cfg Config) string {
if cfg.Paths != "" {
return fmt.Sprintf("Analyzing %d Go files (refactoring mode)...", fileCount)
}
if cfg.IncludePaths != "" {
return fmt.Sprintf("Analyzing %d changed Go files against %s (filtered to %s)...", fileCount, cfg.BaseBranch, cfg.IncludePaths)
}
return fmt.Sprintf("Analyzing %d changed Go files against %s...", fileCount, cfg.BaseBranch)
}

func loadRefactoringFiles(repoPath, rawPaths string) (*diff.Result, error) {
paths, err := parsePathList(rawPaths, "paths")
if err != nil {
return nil, err
}
d, err := diff.CollectPaths(repoPath, paths)
if err != nil {
return nil, fmt.Errorf("collecting paths: %w", err)
}
return d, nil
}

func loadDiffFiles(repoPath string, cfg Config) (*diff.Result, error) {
d, err := diff.Parse(repoPath, cfg.BaseBranch)
if err != nil {
return nil, fmt.Errorf("parsing diff: %w", err)
}
return filterDiffFiles(repoPath, d, cfg.IncludePaths)
}

func filterDiffFiles(repoPath string, d *diff.Result, rawPaths string) (*diff.Result, error) {
if rawPaths == "" {
return d, nil
}
paths, err := parsePathList(rawPaths, "include-paths")
if err != nil {
return nil, err
}
d, err = diff.FilterPaths(repoPath, d, paths)
if err != nil {
return nil, fmt.Errorf("filtering paths: %w", err)
}
return d, nil
}

Expand Down
165 changes: 165 additions & 0 deletions cmd/diffguard/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package main

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/0xPolygon/diffguard/internal/diff"
)

func TestRun_RejectsPathsWithBase(t *testing.T) {
err := run(t.TempDir(), Config{Paths: "miner", BaseBranch: "develop"})
if err == nil {
t.Fatal("expected error for --paths with --base")
}
if !strings.Contains(err.Error(), "--paths and --base are mutually exclusive") {
t.Fatalf("unexpected error: %v", err)
}
}

func TestRun_RejectsPathsWithIncludePaths(t *testing.T) {
err := run(t.TempDir(), Config{Paths: "miner", IncludePaths: "miner"})
if err == nil {
t.Fatal("expected error for --paths with --include-paths")
}
if !strings.Contains(err.Error(), "--paths and --include-paths are mutually exclusive") {
t.Fatalf("unexpected error: %v", err)
}
}

func TestAnnounceMessage(t *testing.T) {
tests := []struct {
name string
cfg Config
want string
}{
{
name: "refactoring mode",
cfg: Config{Paths: "miner"},
want: "Analyzing 2 Go files (refactoring mode)...",
},
{
name: "filtered diff mode",
cfg: Config{BaseBranch: "develop", IncludePaths: "miner"},
want: "Analyzing 2 changed Go files against develop (filtered to miner)...",
},
{
name: "plain diff mode",
cfg: Config{BaseBranch: "develop"},
want: "Analyzing 2 changed Go files against develop...",
},
}

for _, tt := range tests {
if got := announceMessage(2, tt.cfg); got != tt.want {
t.Fatalf("%s: announceMessage() = %q, want %q", tt.name, got, tt.want)
}
}
}

func TestLoadFiles_IncludePathsFiltersDiff(t *testing.T) {
repo := initGitRepo(t)
writeFile(t, filepath.Join(repo, "miner", "a.go"), "package miner\n\nfunc A() int { return 1 }\n")
writeFile(t, filepath.Join(repo, "other", "b.go"), "package other\n\nfunc B() int { return 1 }\n")
git(t, repo, "add", ".")
git(t, repo, "commit", "-m", "initial")

git(t, repo, "checkout", "-b", "feature")
writeFile(t, filepath.Join(repo, "miner", "a.go"), "package miner\n\nfunc A() int { return 2 }\n")
writeFile(t, filepath.Join(repo, "other", "b.go"), "package other\n\nfunc B() int { return 2 }\n")
git(t, repo, "add", ".")
git(t, repo, "commit", "-m", "change")

d, err := loadFiles(repo, Config{BaseBranch: "develop", IncludePaths: "miner"})
if err != nil {
t.Fatalf("loadFiles error: %v", err)
}

if len(d.Files) != 1 {
t.Fatalf("expected 1 filtered file, got %d", len(d.Files))
}
if d.Files[0].Path != "miner/a.go" {
t.Fatalf("filtered path = %q, want miner/a.go", d.Files[0].Path)
}
}

func TestFilterDiffFiles_EmptyIncludePaths(t *testing.T) {
d := &diff.Result{
BaseBranch: "develop",
Files: []diff.FileChange{
{Path: "miner/a.go"},
},
}

filtered, err := filterDiffFiles(t.TempDir(), d, "")
if err != nil {
t.Fatalf("filterDiffFiles error: %v", err)
}
if filtered != d {
t.Fatal("expected empty include-paths to return original diff result")
}
}

func TestFilterDiffFiles_InvalidIncludePaths(t *testing.T) {
d := &diff.Result{
BaseBranch: "develop",
Files: []diff.FileChange{
{Path: "miner/a.go"},
},
}

_, err := filterDiffFiles(t.TempDir(), d, " , ")
if err == nil {
t.Fatal("expected invalid include-paths to return an error")
}
if !strings.Contains(err.Error(), "--include-paths requires at least one path") {
t.Fatalf("unexpected error: %v", err)
}
}

func TestParsePathList_RejectsEmptyInput(t *testing.T) {
_, err := parsePathList(" , ", "paths")
if err == nil {
t.Fatal("expected empty path list to return an error")
}
if !strings.Contains(err.Error(), "--paths requires at least one path") {
t.Fatalf("unexpected error: %v", err)
}
}

func initGitRepo(t *testing.T) string {
t.Helper()

repo := t.TempDir()
git(t, repo, "init")
git(t, repo, "config", "user.email", "test@example.com")
git(t, repo, "config", "user.name", "Test User")
git(t, repo, "config", "commit.gpgsign", "false")
git(t, repo, "checkout", "-b", "develop")
return repo
}

func git(t *testing.T, repo string, args ...string) {
t.Helper()

cmd := exec.Command("git", args...)
cmd.Dir = repo
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, out)
}
}

func writeFile(t *testing.T, path, content string) {
t.Helper()

if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatalf("MkdirAll(%q): %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("WriteFile(%q): %v", path, err)
}
}
Loading
Loading