diff --git a/cmd/plugin/main.go b/cmd/plugin/main.go index b002fa2..f2b6349 100644 --- a/cmd/plugin/main.go +++ b/cmd/plugin/main.go @@ -1,25 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 The plugin-template Authors +// SPDX-FileCopyrightText: 2026 The semrel Authors package main import ( - "context" "log" - "os" - grpcserver "github.com/SemRels/plugin-template/internal/grpc" - semrelplugin "github.com/SemRels/plugin-template/internal/plugin" + plugin "github.com/SemRels/hook-gitplugin/internal/plugin" ) func main() { - provider := semrelplugin.NewProvider("replace-me") - server := grpcserver.NewProviderServer(provider) - - if _, err := server.Health(context.Background()); err != nil { - log.Printf("plugin health check failed: %v", err) - os.Exit(1) - } - - log.Printf("%s plugin template is ready", provider.Name()) + gitPlugin := plugin.NewPlugin(plugin.Config{}) + log.Printf("hook-gitplugin plugin ready: creates git commits, tags, and pushes (%T)", gitPlugin) } diff --git a/go.mod b/go.mod index cc2558a..3075a64 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,5 @@ -module github.com/SemRels/plugin-template +module github.com/SemRels/hook-gitplugin go 1.24 toolchain go1.24.0 - -require github.com/stretchr/testify v1.10.0 - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/go.sum b/go.sum index 713a0b4..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +0,0 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/grpc/server.go b/internal/grpc/server.go deleted file mode 100644 index c4a1af9..0000000 --- a/internal/grpc/server.go +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 The plugin-template Authors - -package grpc - -import ( - "context" - - semrelplugin "github.com/SemRels/plugin-template/internal/plugin" -) - -// HealthResponse is a lightweight stand-in until generated protobuf bindings are wired in. -type HealthResponse struct { - Name string -} - -// ProviderServer adapts a provider implementation for the future gRPC transport layer. -type ProviderServer struct { - provider semrelplugin.Provider -} - -func NewProviderServer(provider semrelplugin.Provider) *ProviderServer { - return &ProviderServer{provider: provider} -} - -func (s *ProviderServer) Health(ctx context.Context) (*HealthResponse, error) { - if err := s.provider.HealthCheck(ctx); err != nil { - return nil, err - } - - return &HealthResponse{Name: s.provider.Name()}, nil -} diff --git a/internal/plugin/git.go b/internal/plugin/git.go new file mode 100644 index 0000000..4ce2be4 --- /dev/null +++ b/internal/plugin/git.go @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The semrel Authors + +// Package plugin provides a built-in git plugin for semrel. +// It creates git tags, commits, and pushes to the remote repository as part +// of the release process. +package plugin + +import ( + "context" + "fmt" + "os/exec" + "strings" +) + +// Config holds the git plugin configuration. +type Config struct { + // TagName is the tag to create (e.g., "v1.2.3"). Supports {version} placeholder. + TagName string + // TagMessage is the annotated tag message. If empty, a lightweight tag is created. + TagMessage string + // CommitMessage is the commit message for release commits (e.g., updating CHANGELOG). + CommitMessage string + // Remote is the git remote to push to (defaults to "origin"). + Remote string + // Branch is the branch to push to (defaults to "main"). + Branch string + // Files is the list of files to stage and commit before tagging. + Files []string + // SignTag enables GPG signing of the tag. + SignTag bool + // SignedOffBy adds a Signed-off-by trailer to commits (for DCO compliance). + SignedOffBy bool +} + +// Plugin is the git built-in plugin. +type Plugin struct { + cfg Config + runner cmdRunner +} + +// cmdRunner abstracts exec.Command for testing. +type cmdRunner interface { + run(ctx context.Context, dir string, name string, args ...string) (string, error) +} + +type realRunner struct{} + +func (realRunner) run(ctx context.Context, dir string, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) + if dir != "" { + cmd.Dir = dir + } + out, err := cmd.CombinedOutput() + return string(out), err +} + +// NewPlugin creates a git Plugin with the given configuration. +func NewPlugin(cfg Config) *Plugin { + if cfg.Remote == "" { + cfg.Remote = "origin" + } + if cfg.Branch == "" { + cfg.Branch = "main" + } + return &Plugin{cfg: cfg, runner: realRunner{}} +} + +// Name returns the plugin name. +func (p *Plugin) Name() string { return "git" } + +// Version returns the plugin version. +func (p *Plugin) Version() string { return "1.0.0" } + +// Validate checks the plugin configuration. +func (p *Plugin) Validate() error { + if p.cfg.TagName == "" { + return fmt.Errorf("git plugin: tag_name is required") + } + return nil +} + +// CreateTag creates a git tag at the current HEAD. +func (p *Plugin) CreateTag(ctx context.Context, dir, version string) error { + tagName := expandPlaceholder(p.cfg.TagName, version) + + args := []string{"tag"} + if p.cfg.SignTag { + args = append(args, "-s") + } + if p.cfg.TagMessage != "" { + args = append(args, "-a", "-m", expandPlaceholder(p.cfg.TagMessage, version)) + } + args = append(args, tagName) + + if _, err := p.runner.run(ctx, dir, "git", args...); err != nil { + return fmt.Errorf("git plugin: create tag %q: %w", tagName, err) + } + return nil +} + +// CommitFiles stages the configured files and creates a commit. +func (p *Plugin) CommitFiles(ctx context.Context, dir, version string) error { + if len(p.cfg.Files) == 0 { + return nil + } + + // Stage files + addArgs := append([]string{"add"}, p.cfg.Files...) + if _, err := p.runner.run(ctx, dir, "git", addArgs...); err != nil { + return fmt.Errorf("git plugin: stage files: %w", err) + } + + // Commit + msg := expandPlaceholder(p.cfg.CommitMessage, version) + if msg == "" { + msg = fmt.Sprintf("chore: release %s", version) + } + commitArgs := []string{"commit", "-m", msg} + if p.cfg.SignedOffBy { + commitArgs = append(commitArgs, "--signoff") + } + if _, err := p.runner.run(ctx, dir, "git", commitArgs...); err != nil { + return fmt.Errorf("git plugin: commit: %w", err) + } + return nil +} + +// Push pushes the branch and tag to the remote. +func (p *Plugin) Push(ctx context.Context, dir, version string) error { + tagName := expandPlaceholder(p.cfg.TagName, version) + + // Push branch + if _, err := p.runner.run(ctx, dir, "git", "push", p.cfg.Remote, p.cfg.Branch); err != nil { + return fmt.Errorf("git plugin: push branch: %w", err) + } + + // Push tag + if _, err := p.runner.run(ctx, dir, "git", "push", p.cfg.Remote, tagName); err != nil { + return fmt.Errorf("git plugin: push tag %q: %w", tagName, err) + } + return nil +} + +// ExpandTagName returns the tag name with {version} replaced. +func ExpandTagName(template, version string) string { + return expandPlaceholder(template, version) +} + +// expandPlaceholder replaces {version} with the actual version. +func expandPlaceholder(s, version string) string { + return strings.ReplaceAll(s, "{version}", version) +} diff --git a/internal/plugin/git_test.go b/internal/plugin/git_test.go new file mode 100644 index 0000000..bba34e8 --- /dev/null +++ b/internal/plugin/git_test.go @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: 2026 The semrel Authors + +package plugin_test + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + gitplugin "github.com/SemRels/hook-gitplugin/internal/plugin" +) + +// runGit runs a git command in dir and logs errors. +func runGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Logf("git %v: %v\n%s", args, err, out) + } + return string(out) +} + +// initGitRepo creates a minimal git repo for testing. +func initGitRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + runGit(t, dir, "init") + runGit(t, dir, "config", "user.email", "test@test.com") + runGit(t, dir, "config", "user.name", "Test") + runGit(t, dir, "config", "commit.gpgsign", "false") + + testFile := filepath.Join(dir, "README.md") + os.WriteFile(testFile, []byte("# Test"), 0o644) + runGit(t, dir, "add", ".") + runGit(t, dir, "-c", "commit.gpgsign=false", "commit", "-m", "init") + return dir +} + +func TestExpandTagName(t *testing.T) { + tests := []struct { + template string + version string + expected string + }{ + {"v{version}", "1.2.3", "v1.2.3"}, + {"{version}", "2.0.0", "2.0.0"}, + {"myapp/v{version}", "1.0.0", "myapp/v1.0.0"}, + {"static-tag", "1.0.0", "static-tag"}, + } + + for _, tt := range tests { + got := gitplugin.ExpandTagName(tt.template, tt.version) + if got != tt.expected { + t.Errorf("ExpandTagName(%q, %q) = %q, want %q", tt.template, tt.version, got, tt.expected) + } + } +} + +func TestNewPlugin_Defaults(t *testing.T) { + p := gitplugin.NewPlugin(gitplugin.Config{TagName: "v{version}"}) + if p.Name() != "git" { + t.Errorf("expected name 'git', got %q", p.Name()) + } + if p.Version() == "" { + t.Error("expected non-empty version") + } +} + +func TestPlugin_Validate_MissingTag(t *testing.T) { + p := gitplugin.NewPlugin(gitplugin.Config{}) + if err := p.Validate(); err == nil { + t.Error("expected error for missing tag_name") + } +} + +func TestPlugin_Validate_OK(t *testing.T) { + p := gitplugin.NewPlugin(gitplugin.Config{TagName: "v{version}"}) + if err := p.Validate(); err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestPlugin_CreateTag(t *testing.T) { + dir := initGitRepo(t) + p := gitplugin.NewPlugin(gitplugin.Config{TagName: "v{version}", Remote: "origin"}) + + if err := p.CreateTag(context.Background(), dir, "1.2.3"); err != nil { + t.Fatalf("CreateTag failed: %v", err) + } + + // Verify tag was created + out := runGit(t, dir, "tag", "-l", "v1.2.3") + if !strings.Contains(out, "v1.2.3") { + t.Errorf("expected tag v1.2.3 to exist, got: %q", out) + } +} + +func TestPlugin_CommitFiles_Empty(t *testing.T) { + p := gitplugin.NewPlugin(gitplugin.Config{TagName: "v{version}", Files: nil}) + // No files to commit — should be a no-op + if err := p.CommitFiles(context.Background(), ".", "1.0.0"); err != nil { + t.Errorf("unexpected error with empty files: %v", err) + } +} + +func TestPlugin_CommitFiles_WithFiles(t *testing.T) { + dir := initGitRepo(t) + + // Write a file to commit + os.WriteFile(filepath.Join(dir, "CHANGELOG.md"), []byte("## v1.0.0\n- feature"), 0o644) + + p := gitplugin.NewPlugin(gitplugin.Config{ + TagName: "v{version}", + CommitMessage: "chore: release {version}", + Files: []string{"CHANGELOG.md"}, + Remote: "origin", + }) + + if err := p.CommitFiles(context.Background(), dir, "1.0.0"); err != nil { + t.Fatalf("CommitFiles failed: %v", err) + } + + // Verify commit was created + out := runGit(t, dir, "log", "--oneline", "-1") + if !strings.Contains(out, "release 1.0.0") { + t.Errorf("expected commit message to contain 'release 1.0.0', got: %q", out) + } +} diff --git a/internal/plugin/provider.go b/internal/plugin/provider.go deleted file mode 100644 index 71a5e01..0000000 --- a/internal/plugin/provider.go +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 The plugin-template Authors - -package plugin - -import "context" - -// Provider defines the minimal contract a SemRel provider plugin should implement. -type Provider interface { - Name() string - HealthCheck(context.Context) error -} - -// ProviderPlugin is a small default implementation that can be extended or replaced. -type ProviderPlugin struct { - name string -} - -func NewProvider(name string) *ProviderPlugin { - if name == "" { - name = "replace-me" - } - - return &ProviderPlugin{name: name} -} - -func (p *ProviderPlugin) Name() string { - return p.name -} - -func (p *ProviderPlugin) HealthCheck(context.Context) error { - return nil -} diff --git a/internal/plugin/provider_test.go b/internal/plugin/provider_test.go deleted file mode 100644 index 537286b..0000000 --- a/internal/plugin/provider_test.go +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: 2026 The plugin-template Authors - -package plugin - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestNewProviderDefaultsName(t *testing.T) { - t.Parallel() - - provider := NewProvider("") - - require.Equal(t, "replace-me", provider.Name()) - require.NoError(t, provider.HealthCheck(context.Background())) -} - -func TestNewProviderUsesProvidedName(t *testing.T) { - t.Parallel() - - provider := NewProvider("provider-example") - - require.Equal(t, "provider-example", provider.Name()) -}