Skip to content
Draft
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*.dll
*.so
*.dylib
gobreaker

# Test binary, built with `go test -c`
*.test
Expand Down
57 changes: 48 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,34 +23,71 @@ go build -o gobreaker ./cmd/gobreaker
## Usage

```bash
gobreaker [OPTIONS] <base-ref> [repo-path]
gobreaker [OPTIONS] <old-ref> [new-ref]
gobreaker [OPTIONS] <old-path> <new-path>
```

gobreaker automatically detects whether you're comparing git references or filesystem directories.

### Arguments

- `base-ref` (required): The base reference to compare against (branch, tag, or commit SHA)
- `repo-path` (optional): Path to the git repository (defaults to current directory)
- `old-ref` or `old-path` (required): Old git reference (branch, tag, or commit) or filesystem path to compare from
- `new-ref` or `new-path` (optional): New git reference or filesystem path to compare to (default: HEAD for git mode)

### Options

- `-o, --output <format>`: Output format - `text` (default), `json`, or `markdown`
- `-r, --repo <path>`: Path to git repository (default: current directory, only used in git mode)
- `-f, --format <format>`: Output format - `text` (default), `json`, or `markdown`
- `-i, --include-internal`: Include internal packages in API analysis
- `-q, --quiet`: Suppress output
- `-v, --version`: Print version information and exit
- `-h, --help`: Show help message

### Examples

**Git mode** (compares commits without touching your current branch):

```bash
# Compare current branch against main in current directory
# Compare HEAD against main branch (skips internal packages by default)
gobreaker main

# Compare against a specific tag in a different repository
gobreaker v1.0.0 /path/to/repo
# Compare main branch against develop branch
gobreaker main develop

# Compare HEAD against main and include internal packages
gobreaker main --include-internal

# Compare specific commits
gobreaker abc123 def456

# Compare in a different repository
gobreaker main --repo /path/to/repo

# Compare with a different repository and specific commits
gobreaker abc123 def456 --repo /path/to/repo
```

**Filesystem mode** (compares directories directly):

```bash
# Compare two directories
gobreaker /path/to/old /path/to/new

# Compare with relative paths
gobreaker ./v1 ./v2

# Include internal packages when comparing directories
gobreaker /old/version /new/version --include-internal
```

**General examples:**

```bash
# Output results as JSON
gobreaker -o json main
gobreaker main --format json

# Output results as Markdown (useful for PR comments)
gobreaker --output markdown main
gobreaker main --format markdown

# Check version
gobreaker --version
Expand All @@ -77,6 +114,8 @@ gobreaker identifies various types of breaking changes including:
- Interface method changes
- Type definition changes

**Note on Internal Packages:** By default, gobreaker skips internal packages (those with `/internal/` in their path). This is because internal packages are implementation details not meant to be used outside the module. However, if you want to track breaking changes in internal package public APIs (useful for maintaining internal API stability), use the `--include-internal` flag. When this flag is enabled, gobreaker analyzes **only the exported (public) APIs** of internal packages, just as it does for regular packages.

## Development

### Building
Expand Down
66 changes: 51 additions & 15 deletions cmd/gobreaker/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,24 @@
"runtime/debug"

"github.com/flaticols/gobreaker/internal/git"
"github.com/flaticols/gobreaker/pkg/breaking"
"github.com/jessevdk/go-flags"
)

type programOptions struct {
//nolint:golines
RepoPath string `short:"r" long:"repo" description:"Path to git repository (default: current directory)"`
//nolint:golines
OldRef string `short:"o" long:"old" description:"Old reference (branch, tag, or commit) to compare from, or 'latest' to compare latest against HEAD" required:"true"`
NewRef string `short:"n" long:"new" description:"New reference (branch, tag, or commit) to compare to" default:"HEAD"`
//nolint:golines
Output string `short:"f" long:"format" description:"Output format (text, json, markdown)" default:"text" choice:"text"`
Quite bool `short:"q" long:"quiet" description:"Suppress output"`
Version bool `short:"v" long:"version" description:"Print version information and exit"`
Output string `short:"f" long:"format" description:"Output format (text, json, markdown)" default:"text" choice:"text"`
IncludeInternal bool `short:"i" long:"include-internal" description:"Include internal packages in API analysis"`
Quite bool `short:"q" long:"quiet" description:"Suppress output"`

Check failure on line 19 in cmd/gobreaker/main.go

View workflow job for this annotation

GitHub Actions / golangci-lint

File is not properly formatted (golines)

Check failure on line 19 in cmd/gobreaker/main.go

View workflow job for this annotation

GitHub Actions / golangci-lint

File is not properly formatted (golines)
Version bool `short:"v" long:"version" description:"Print version information and exit"`

// Positional arguments
Args struct {
OldRef string `positional-arg-name:"old-ref" description:"Old reference (branch, tag, or commit) to compare from"`
NewRef string `positional-arg-name:"new-ref" description:"New reference (branch, tag, or commit) to compare to (default: HEAD)"`
} `positional-args:"yes"`
}

func main() {
Expand All @@ -38,29 +43,60 @@
os.Exit(0)
}

// Handle positional arguments
oldRef := programCfg.Args.OldRef
newRef := programCfg.Args.NewRef

if oldRef == "" {
_, _ = fmt.Fprintf(os.Stderr, "Error: old-ref is required\n")
_, _ = fmt.Fprintf(os.Stderr, "Usage: gobreaker [OPTIONS] <old-ref> [new-ref]\n")
_, _ = fmt.Fprintf(os.Stderr, " gobreaker [OPTIONS] <old-path> <new-path>\n")
_, _ = fmt.Fprintf(os.Stderr, "Run 'gobreaker --help' for more information\n")
os.Exit(1)
}

if newRef == "" {
newRef = "HEAD"
}

if programCfg.RepoPath == "" {
wd, err := os.Getwd()
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
programCfg.RepoPath = wd
} else {
err := os.Chdir(programCfg.RepoPath)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}

if len(args) > 0 {
_, _ = fmt.Fprintf(os.Stderr, "Error: unexpected arguments: %v\n", args)
os.Exit(1)
}

diff, err := git.OpenRepo(programCfg.RepoPath, programCfg.OldRef, programCfg.NewRef)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Error: %v\n", err)
var diff *breaking.Diff

// Auto-detect: filesystem paths or git refs
oldIsPath := git.IsFilesystemPath(oldRef)
newIsPath := git.IsFilesystemPath(newRef)

if oldIsPath && newIsPath {
// Both are filesystem paths - compare directories directly
diff, err = git.CompareFilesystems(oldRef, newRef, programCfg.IncludeInternal)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
} else if !oldIsPath && !newIsPath {
// Both are git refs - use git mode
diff, err = git.OpenRepo(programCfg.RepoPath, oldRef, newRef, programCfg.IncludeInternal)
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
} else {
// Mixed mode not supported
_, _ = fmt.Fprintf(os.Stderr, "Error: cannot mix filesystem paths and git refs\n")
_, _ = fmt.Fprintf(os.Stderr, "Use either two filesystem paths or two git refs\n")
os.Exit(1)
}

Expand Down
123 changes: 95 additions & 28 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,70 +2,113 @@ package git

import (
"fmt"
"os"
"path/filepath"

"github.com/flaticols/gobreaker/pkg/breaking"
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/format/gitignore"
)

// CompareFilesystems compares API differences between two filesystem directories.
// It returns a Diff report with details on compatibility and breaking changes.
// If includeInternal is false, internal packages are excluded from analysis.
func CompareFilesystems(oldPath, newPath string, includeInternal bool) (*breaking.Diff, error) {
// Verify paths exist
if _, err := os.Stat(oldPath); err != nil {
return nil, fmt.Errorf("old path %q does not exist: %w", oldPath, err)
}
if _, err := os.Stat(newPath); err != nil {
return nil, fmt.Errorf("new path %q does not exist: %w", newPath, err)
}

selfOld, importsOld, err := getPackagesFromPath(oldPath, includeInternal)
if err != nil {
return nil, fmt.Errorf("failed to get packages from old path %q: %w", oldPath, err)
}

selfNew, importsNew, err := getPackagesFromPath(newPath, includeInternal)
if err != nil {
return nil, fmt.Errorf("failed to get packages from new path %q: %w", newPath, err)
}

apiReports, incompatible := comparePackages(selfOld, selfNew)
apiImports, breakingImports := compareImports(importsOld, importsNew)

d := breaking.New(apiReports, apiImports)
d.SetBreakingImports(breakingImports)
d.SetIncompatible(incompatible)

return d, nil
}

// OpenRepo compares API differences between two commits in a Git repository.
// It uses temporary clones to avoid modifying the current branch.
// It returns a Diff report with details on compatibility and breaking changes.
func OpenRepo(repoPath, oldCommit, newCommit string) (*breaking.Diff, error) {
// If includeInternal is false, internal packages are excluded from analysis.
func OpenRepo(repoPath, oldCommit, newCommit string, includeInternal bool) (*breaking.Diff, error) {
repo, err := git.PlainOpen(repoPath)
if err != nil {
return nil, fmt.Errorf("failed to open repository at %s: %w", repoPath, err)
}

wt, err := repo.Worktree()
oldHash, newHash, err := getHashes(repo, plumbing.Revision(oldCommit), plumbing.Revision(newCommit))
if err != nil {
return nil, fmt.Errorf("failed to lookup git commit hashes: %w", err)
}

// Create temporary directories for analysis
oldWorktreePath, err := os.MkdirTemp("", "gobreaker-old-*")
if err != nil {
return nil, fmt.Errorf("failed to get worktree: %w", err)
return nil, fmt.Errorf("failed to create temp directory for old worktree: %w", err)
}
defer os.RemoveAll(oldWorktreePath)

wt.Filesystem = osfs.New(repoPath)
rootFS := osfs.New("/")
newWorktreePath, err := os.MkdirTemp("", "gobreaker-new-*")
if err != nil {
return nil, fmt.Errorf("failed to create temp directory for new worktree: %w", err)
}
defer os.RemoveAll(newWorktreePath)

globalIgnoreFile, err := gitignore.LoadGlobalPatterns(rootFS)
// Clone repository to temp directories and checkout specific commits
oldRepo, err := git.PlainClone(oldWorktreePath, false, &git.CloneOptions{
URL: repoPath,
})
if err != nil {
return nil, fmt.Errorf("failed to load gitignore: %v", err)
return nil, fmt.Errorf("failed to clone for old commit: %w", err)
}
wt.Excludes = append(wt.Excludes, globalIgnoreFile...)

sysIgnoreFile, err := gitignore.LoadSystemPatterns(rootFS)
oldWt, err := oldRepo.Worktree()
if err != nil {
return nil, fmt.Errorf("failed to load system gitignore: %v", err)
return nil, fmt.Errorf("failed to get old worktree: %w", err)
}
wt.Excludes = append(wt.Excludes, sysIgnoreFile...)

if stat, err := wt.Status(); err != nil {
return nil, fmt.Errorf("failed to get git status: %w", err)
} else if !stat.IsClean() {
return nil, &StatusError{stat, fmt.Errorf("current git tree is dirty")}
if err := oldWt.Checkout(&git.CheckoutOptions{Hash: *oldHash}); err != nil {
return nil, fmt.Errorf("failed to checkout old commit %s: %w", oldHash, err)
}

origRef, err := repo.Head()
newRepo, err := git.PlainClone(newWorktreePath, false, &git.CloneOptions{
URL: repoPath,
})
if err != nil {
return nil, fmt.Errorf("failed to get current HEAD reference: %w", err)
return nil, fmt.Errorf("failed to clone for new commit: %w", err)
}

oldHash, newHash, err := getHashes(repo, plumbing.Revision(oldCommit), plumbing.Revision(newCommit))
newWt, err := newRepo.Worktree()
if err != nil {
return nil, fmt.Errorf("failed to lookup git commit hashes: %w", err)
return nil, fmt.Errorf("failed to get new worktree: %w", err)
}

defer func() {
if err := checkoutRef(*wt, *origRef); err != nil {
fmt.Printf("WARNING: failed to checkout your original working commit after diff: %v\n", err)
}
}()
if err := newWt.Checkout(&git.CheckoutOptions{Hash: *newHash}); err != nil {
return nil, fmt.Errorf("failed to checkout new commit %s: %w", newHash, err)
}

selfOld, importsOld, err := getPackages(*wt, *oldHash)
selfOld, importsOld, err := getPackagesFromPath(oldWorktreePath, includeInternal)
if err != nil {
return nil, fmt.Errorf("failed to get packages from old commit %q (%s): %w", oldCommit, oldHash, err)
}

selfNew, importsNew, err := getPackages(*wt, *newHash)
selfNew, importsNew, err := getPackagesFromPath(newWorktreePath, includeInternal)
if err != nil {
return nil, fmt.Errorf("failed to get packages from new commit %q (%s): %w", newCommit, newHash, err)
}
Expand All @@ -79,3 +122,27 @@ func OpenRepo(repoPath, oldCommit, newCommit string) (*breaking.Diff, error) {

return d, nil
}

// IsGitRef checks if a string is a git reference by attempting to resolve it.
func IsGitRef(repoPath, ref string) bool {
repo, err := git.PlainOpen(repoPath)
if err != nil {
return false
}

_, err = repo.ResolveRevision(plumbing.Revision(ref))
return err == nil
}

// IsFilesystemPath checks if a string is a valid filesystem path.
func IsFilesystemPath(path string) bool {
// Check if it's an absolute path or if it exists relative to current directory
if filepath.IsAbs(path) {
_, err := os.Stat(path)
return err == nil
}

// Check relative path
_, err := os.Stat(path)
return err == nil
}
Loading
Loading