Skip to content
Merged
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
18 changes: 4 additions & 14 deletions cmd/plugin/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
10 changes: 1 addition & 9 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
10 changes: 0 additions & 10 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
32 changes: 0 additions & 32 deletions internal/grpc/server.go

This file was deleted.

153 changes: 153 additions & 0 deletions internal/plugin/git.go
Original file line number Diff line number Diff line change
@@ -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)
}
135 changes: 135 additions & 0 deletions internal/plugin/git_test.go
Original file line number Diff line number Diff line change
@@ -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)

Check failure on line 40 in internal/plugin/git_test.go

View workflow job for this annotation

GitHub Actions / Build, test, lint, and scan

Error return value of `os.WriteFile` is not checked (errcheck)
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)

Check failure on line 117 in internal/plugin/git_test.go

View workflow job for this annotation

GitHub Actions / Build, test, lint, and scan

Error return value of `os.WriteFile` is not checked (errcheck)

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)
}
}
Loading
Loading