Check committed Go formatting

This commit is contained in:
2026-10-07 22:39:15 +00:00
parent 368043984e
commit c5f925c949
17 changed files with 318 additions and 22 deletions

View File

@@ -78,6 +78,36 @@ func TestCheckCapturesCandidateAndCallsAdditionalChecksOnce(t *testing.T) {
}
}
func TestCheckStopsBeforeProductAndBuildCallbacksWhenGoFormatFails(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
writeTestFile(t, repository, "unformatted.go", "package candidate\nfunc Value( ) int{return 1}\n")
gitRun(t, repository, "add", "unformatted.go")
gitRun(t, repository, "commit", "-m", "add unformatted Go source")
productCalls := 0
buildCalls := 0
_, err := Check(context.Background(), Request{
RepositoryDir: repository,
Product: "Example Product",
Version: testVersion,
Validation: validation.Settings{GoFormat: true},
AdditionalChecks: func(context.Context, Candidate) error {
productCalls++
return nil
},
AfterValidate: func(context.Context, Candidate) error {
buildCalls++
return nil
},
})
var failure *validation.Failure
if !errors.As(err, &failure) || failure.Check != validation.GoFormat {
t.Fatalf("Check error = %v, want go-format failure", err)
}
if productCalls != 0 || buildCalls != 0 {
t.Fatalf("product callback calls = %d, build callback calls = %d; want neither after formatting failure", productCalls, buildCalls)
}
}
func TestCheckRunsDefaultOfflineGoValidation(t *testing.T) {
repository := newRepository(t, "# Example Product v1.2.3\n\nRelease note.\n")
var output bytes.Buffer

View File

@@ -15,6 +15,8 @@ const (
GoVet Check = "go-vet"
// PackageCompile identifies selected-target package compilation.
PackageCompile Check = "package-compile"
// GoFormat identifies committed Go source formatting.
GoFormat Check = "go-format"
)
// Finding describes a validation observation at an optional source location.

View File

@@ -0,0 +1,133 @@
package validation
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
)
func TestCheckGoFormatUsesCapturedRegularGoBlobs(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "main.go", []byte("package main\n\nfunc main() {}\n"))
writeTreeFile(t, repository, "vendor/example/format.go", []byte("package example\nfunc Value( ){return 1}\n"))
writeTreeFile(t, repository, "nested/module/odd name.go", []byte("package nested\nfunc Value( ){return 2}\n"))
writeTreeFile(t, repository, "notes.txt", []byte("not Go source\n"))
if err := os.Symlink("main.go", filepath.Join(repository, "link.go")); err != nil {
t.Fatal(err)
}
gitTree(t, repository, "add", ".")
gitTree(t, repository, "commit", "-m", "capture Go files")
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
// Worktree edits must not affect the formatting result for the captured commit.
writeTreeFile(t, repository, "main.go", []byte("package main\nfunc main( ){}\n"))
writeTreeFile(t, repository, "vendor/example/format.go", []byte("package example\n\nfunc Value() { return 1 }\n"))
worktreeMain, err := os.ReadFile(filepath.Join(repository, "main.go"))
if err != nil {
t.Fatal(err)
}
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
err = checkGoFormat(context.Background(), tree)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != GoFormat {
t.Fatalf("checkGoFormat error = %v, want go-format failure", err)
}
wantPaths := []string{"nested/module/odd name.go", "vendor/example/format.go"}
if len(failure.Findings) != len(wantPaths) {
t.Fatalf("format findings = %#v, want paths %q", failure.Findings, wantPaths)
}
for index, path := range wantPaths {
if failure.Findings[index].Path != path {
t.Errorf("format finding %d path = %q, want %q", index, failure.Findings[index].Path, path)
}
}
worktreeMainAfter, err := os.ReadFile(filepath.Join(repository, "main.go"))
if err != nil {
t.Fatal(err)
}
if string(worktreeMainAfter) != string(worktreeMain) {
t.Fatalf("format check changed worktree source: got %q, want %q", worktreeMainAfter, worktreeMain)
}
}
func TestCheckGoFormatPreservesParserAndInspectionCauses(t *testing.T) {
t.Run("parser", func(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "broken.go", []byte("package broken\nfunc ( {\n"))
gitTree(t, repository, "add", ".")
gitTree(t, repository, "commit", "-m", "capture invalid Go source")
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
tree, err := ReadCandidateTree(context.Background(), repository, commit)
if err != nil {
t.Fatal(err)
}
err = checkGoFormat(context.Background(), tree)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != GoFormat || failure.Cause == nil {
t.Fatalf("parser failure = %#v, want go-format failure with cause", err)
}
if len(failure.Findings) != 1 || failure.Findings[0].Path != "broken.go" {
t.Fatalf("parser findings = %#v, want broken.go", failure.Findings)
}
if !strings.Contains(failure.Cause.Error(), "broken.go") {
t.Fatalf("parser cause = %v, want committed filename context", failure.Cause)
}
})
t.Run("inspection", func(t *testing.T) {
tree := &CandidateTree{
repositoryDir: t.TempDir(),
entries: []git.TreeEntry{{Path: "missing.go", ObjectID: strings.Repeat("0", 40), Mode: "100644", Type: "blob"}},
byPath: map[string]int{"missing.go": 0},
blobs: make(map[string][]byte),
}
err := checkGoFormat(context.Background(), tree)
var failure *Failure
if !errors.As(err, &failure) || failure.Check != GoFormat || failure.Cause == nil {
t.Fatalf("inspection failure = %#v, want go-format failure with cause", err)
}
if len(failure.Findings) != 1 || failure.Findings[0].Path != "missing.go" {
t.Fatalf("inspection findings = %#v, want missing.go", failure.Findings)
}
})
}
func TestRunGoFormatIsOptionalAndStopsAfterFinding(t *testing.T) {
repository := newTreeRepository(t)
writeTreeFile(t, repository, "unformatted.go", []byte("package fixture\nfunc Value( ){return 1}\n"))
gitTree(t, repository, "add", ".")
gitTree(t, repository, "commit", "-m", "capture unformatted Go source")
commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD"))
logPath := installGoShim(t, "", false)
if err := Run(context.Background(), Request{RepositoryDir: repository, Commit: commit}); err != nil {
t.Fatalf("default validation = %v, want success with formatting disabled", err)
}
if got := strings.Join(readGoShimLog(t, logPath), "\n"); !strings.Contains(got, "test -mod=readonly -count=1 ./...") {
t.Fatalf("default Go commands = %q, want standard suite", got)
}
logPath = installGoShim(t, "", false)
err := Run(context.Background(), Request{
RepositoryDir: repository,
Commit: commit,
Settings: Settings{GoFormat: true},
})
var failure *Failure
if !errors.As(err, &failure) || failure.Check != GoFormat {
t.Fatalf("selected formatting error = %v, want go-format failure", err)
}
commands := strings.Join(readGoShimLog(t, logPath), "\n")
if strings.Contains(commands, "build ") || strings.Contains(commands, "vet ") {
t.Fatalf("later Go commands ran after formatting failure: %q", commands)
}
}

View File

@@ -2,8 +2,10 @@
package validation
import (
"bytes"
"context"
"fmt"
"go/format"
"io"
"os"
"strings"
@@ -16,6 +18,7 @@ type Settings struct {
Race bool
FullVet bool
CompileTargets []Target
GoFormat bool
}
// Target selects one package-compilation target.
@@ -88,5 +91,55 @@ func Run(ctx context.Context, request Request) error {
}
}
}
if request.Settings.GoFormat {
tree, err := request.ReadCandidateTree(ctx)
if err != nil {
return &Failure{Check: GoFormat, Cause: err}
}
if err := checkGoFormat(ctx, tree); err != nil {
return err
}
}
return nil
}
func checkGoFormat(ctx context.Context, tree *CandidateTree) error {
var findings []Finding
for _, entry := range tree.Entries() {
if entry.Type != "blob" || (entry.Mode != "100644" && entry.Mode != "100755") || !strings.HasSuffix(entry.Path, ".go") {
continue
}
if err := ctx.Err(); err != nil {
return &Failure{Check: GoFormat, Cause: err}
}
contents, err := tree.ReadFile(ctx, entry.Path)
if err != nil {
return &Failure{
Check: GoFormat,
Findings: []Finding{{
Path: entry.Path,
Message: "unable to inspect committed Go source",
}},
Cause: fmt.Errorf("inspect committed Go file %q: %w", entry.Path, err),
}
}
formatted, err := format.Source(contents)
if err != nil {
return &Failure{
Check: GoFormat,
Findings: []Finding{{
Path: entry.Path,
Message: "committed Go source cannot be formatted",
}},
Cause: fmt.Errorf("format committed Go file %q: %w", entry.Path, err),
}
}
if !bytes.Equal(contents, formatted) {
findings = append(findings, Finding{Path: entry.Path, Message: "file is not gofmt-formatted"})
}
}
if len(findings) != 0 {
return &Failure{Check: GoFormat, Findings: findings}
}
return nil
}