From 660d78d8654931ff75c40ea332de104f6e3c5991 Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Mon, 22 Jun 2026 16:28:32 +0100 Subject: [PATCH 1/3] feat: add since init subcommand to create example config file --- cfg/config.go | 4 +- cmd/init.go | 81 ++++++++++++++++++ cmd/init_test.go | 179 +++++++++++++++++++++++++++++++++++++++ cmd/templates/since.yaml | 53 ++++++++++++ 4 files changed, 315 insertions(+), 2 deletions(-) create mode 100644 cmd/init.go create mode 100644 cmd/init_test.go create mode 100644 cmd/templates/since.yaml diff --git a/cfg/config.go b/cfg/config.go index 550ed69..d25b402 100644 --- a/cfg/config.go +++ b/cfg/config.go @@ -37,11 +37,11 @@ type SinceConfig struct { Ignore []string `yaml:"ignore"` } -const defaultConfigFile = "since.yaml" +const DefaultConfigFile = "since.yaml" // LoadConfig loads the YAML config file from the given directory. func LoadConfig(dir string) (SinceConfig, error) { - return loadConfig(path.Join(dir, defaultConfigFile)) + return loadConfig(path.Join(dir, DefaultConfigFile)) } // loadConfig loads the YAML config file from the given path. diff --git a/cmd/init.go b/cmd/init.go new file mode 100644 index 0000000..6632c44 --- /dev/null +++ b/cmd/init.go @@ -0,0 +1,81 @@ +/* +Copyright © 2023 Pete Cornish + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + _ "embed" + "fmt" + "os" + "path/filepath" + + "github.com/release-tools/since/cfg" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +//go:embed templates/since.yaml +var defaultConfig string + +var initSubCmd struct { + outputFile string +} + +// initSubCmd represents the init subcommand +var initSubCmdCmd = &cobra.Command{ + Use: "init", + Short: "Create a new since.yaml config file", + Long: `Creates a new since.yaml config file with example configuration, +including branch requirements, pre/post hook scripts, and commit exclusions. +If the config file already exists, it will be overwritten.`, + Args: cobra.NoArgs, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runInit() + }, +} + +func runInit() error { + configDir := initSubCmd.outputFile + if configDir == "" { + var err error + configDir, err = os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %w", err) + } + } + configPath := filepath.Join(configDir, cfg.DefaultConfigFile) + + if _, err := os.Stat(configPath); err == nil { + logrus.Warnf("config file '%s' already exists, overwriting", configPath) + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to check config file: %w", err) + } + + if err := os.WriteFile(configPath, []byte(defaultConfig), 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + logrus.Infof("created config file '%s'", configPath) + return nil +} + +func init() { + rootCmd.AddCommand(initSubCmdCmd) + + initSubCmdCmd.Flags().StringVarP(&initSubCmd.outputFile, "output", "o", "", "Directory to write the config file to (default: current directory)") +} diff --git a/cmd/init_test.go b/cmd/init_test.go new file mode 100644 index 0000000..7d1f4dc --- /dev/null +++ b/cmd/init_test.go @@ -0,0 +1,179 @@ +/* +Copyright © 2023 Pete Cornish + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func Test_runInit(t *testing.T) { + t.Run("creates config in current directory", func(t *testing.T) { + tmpDir := t.TempDir() + originalWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(originalWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + + err = runInit() + if err != nil { + t.Fatalf("runInit() unexpected error: %v", err) + } + + configPath := filepath.Join(tmpDir, "since.yaml") + content, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read created config file: %v", err) + } + + if len(content) == 0 { + t.Error("config file is empty") + } + }) + + t.Run("creates config in specified output directory", func(t *testing.T) { + tmpDir := t.TempDir() + + initSubCmd.outputFile = tmpDir + defer func() { initSubCmd.outputFile = "" }() + + err := runInit() + if err != nil { + t.Fatalf("runInit() unexpected error: %v", err) + } + + configPath := filepath.Join(tmpDir, "since.yaml") + content, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read created config file: %v", err) + } + + if len(content) == 0 { + t.Error("config file is empty") + } + }) + + t.Run("embeds requireBranch example", func(t *testing.T) { + tmpDir := t.TempDir() + originalWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(originalWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + + err = runInit() + if err != nil { + t.Fatalf("runInit() unexpected error: %v", err) + } + + content, err := os.ReadFile("since.yaml") + if err != nil { + t.Fatalf("failed to read created config file: %v", err) + } + + if !strings.Contains(string(content), "requireBranch:") { + t.Error("config file does not contain requireBranch example") + } + }) + + t.Run("embeds hook examples", func(t *testing.T) { + tmpDir := t.TempDir() + originalWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(originalWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + + err = runInit() + if err != nil { + t.Fatalf("runInit() unexpected error: %v", err) + } + + content, err := os.ReadFile("since.yaml") + if err != nil { + t.Fatalf("failed to read created config file: %v", err) + } + + if !strings.Contains(string(content), "command:") { + t.Error("config file does not contain command hook example") + } + }) + + t.Run("embeds ignore examples", func(t *testing.T) { + tmpDir := t.TempDir() + originalWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(originalWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + + err = runInit() + if err != nil { + t.Fatalf("runInit() unexpected error: %v", err) + } + + content, err := os.ReadFile("since.yaml") + if err != nil { + t.Fatalf("failed to read created config file: %v", err) + } + + if !strings.Contains(string(content), "ignore:") { + t.Error("config file does not contain ignore example") + } + }) + + t.Run("embeds script-based hook example", func(t *testing.T) { + tmpDir := t.TempDir() + originalWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(originalWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + + err = runInit() + if err != nil { + t.Fatalf("runInit() unexpected error: %v", err) + } + + content, err := os.ReadFile("since.yaml") + if err != nil { + t.Fatalf("failed to read created config file: %v", err) + } + + if !strings.Contains(string(content), "script:") { + t.Error("config file does not contain script-based hook example") + } + }) +} diff --git a/cmd/templates/since.yaml b/cmd/templates/since.yaml new file mode 100644 index 0000000..2b511b3 --- /dev/null +++ b/cmd/templates/since.yaml @@ -0,0 +1,53 @@ +# Since Configuration +# Uncomment and customise the settings below as required. + +# Require that the current branch matches a specific branch pattern +# Before running any since command, this check is performed automatically. +# Supports exact match or glob patterns (e.g. "release/*", "feature/*") +# requireBranch: main + +# Hooks are scripts or commands that run before or after release operations. +# They are executed in order and will abort the release if any hook fails. + +# Hooks can be defined in two ways: +# 1. Using command/args - runs the specified command with arguments +# 2. Using script - writes inline content to a temp file and executes it + +# Hooks have access to the following environment variables: +# SINCE_NEW_VERSION - The new version being released (e.g. "1.2.0") +# SINCE_OLD_VERSION - The previous version (e.g. "1.1.0") +# SINCE_SHA - The git commit SHA of the release +# SINCE_REPO_PATH - The path to the git repository + +# Example: Command-based hooks +# before: +# - command: sh +# args: +# - "./scripts/pre-release-check.sh" +# - command: echo +# args: +# - "Preparing release $SINCE_NEW_VERSION" +# after: +# - command: echo +# args: +# - "Release $SINCE_NEW_VERSION completed" + +# Example: Script-based hooks (inline shell scripts) +# before: +# - script: | +# #!/bin/bash +# echo "Running pre-release checks for $SINCE_NEW_VERSION..." +# if [ -n "$SINCE_NEW_VERSION" ]; then +# echo "Version is set to: $SINCE_NEW_VERSION" +# fi +# # Add custom checks below + +# Example: Using commit message exclusions to ignore certain commits +# These patterns are matched against the commit subject line. +# Commits matching any of these patterns are excluded from changelog entries. +# ignore: +# - "chore:" +# - "docs:" +# - "test:" +# - "ci:" +# - "Merge pull request" From 6ec3ba01818d1892cd3b2c578e25dcb97546e1d6 Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Sat, 1 Aug 2026 19:06:57 +0100 Subject: [PATCH 2/3] test: cover init overwrite, write-error and config round-trip paths Add cases for overwriting an existing config, erroring on a missing output directory, and loading the generated template through the real config loader. Also fix the config test to use the renamed exported DefaultConfigFile constant. --- cfg/config_test.go | 2 +- cmd/init_test.go | 70 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/cfg/config_test.go b/cfg/config_test.go index 42a30eb..d0835d4 100644 --- a/cfg/config_test.go +++ b/cfg/config_test.go @@ -69,7 +69,7 @@ func Test_loadConfig(t *testing.T) { func TestLoadConfig(t *testing.T) { dir := t.TempDir() content := "requireBranch: main\nbefore:\n - command: echo\n args:\n - hello world\n" - if err := os.WriteFile(path.Join(dir, defaultConfigFile), []byte(content), 0644); err != nil { + if err := os.WriteFile(path.Join(dir, DefaultConfigFile), []byte(content), 0644); err != nil { t.Fatal(err) } diff --git a/cmd/init_test.go b/cmd/init_test.go index 7d1f4dc..c1f5838 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -21,6 +21,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/release-tools/since/cfg" ) func Test_runInit(t *testing.T) { @@ -176,4 +178,72 @@ func Test_runInit(t *testing.T) { t.Error("config file does not contain script-based hook example") } }) + + t.Run("overwrites existing config file", func(t *testing.T) { + tmpDir := t.TempDir() + + initSubCmd.outputFile = tmpDir + defer func() { initSubCmd.outputFile = "" }() + + configPath := filepath.Join(tmpDir, "since.yaml") + if err := os.WriteFile(configPath, []byte("stale: content"), 0644); err != nil { + t.Fatalf("failed to seed existing config file: %v", err) + } + + err := runInit() + if err != nil { + t.Fatalf("runInit() unexpected error: %v", err) + } + + content, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read created config file: %v", err) + } + + if strings.Contains(string(content), "stale: content") { + t.Error("config file was not overwritten") + } + if !strings.Contains(string(content), "requireBranch:") { + t.Error("overwritten config file does not contain template content") + } + }) + + t.Run("returns error when output directory does not exist", func(t *testing.T) { + tmpDir := t.TempDir() + + initSubCmd.outputFile = filepath.Join(tmpDir, "does-not-exist") + defer func() { initSubCmd.outputFile = "" }() + + err := runInit() + if err == nil { + t.Fatal("runInit() expected an error when writing to a missing directory, got nil") + } + }) + + t.Run("generated config loads through the real loader", func(t *testing.T) { + tmpDir := t.TempDir() + + initSubCmd.outputFile = tmpDir + defer func() { initSubCmd.outputFile = "" }() + + if err := runInit(); err != nil { + t.Fatalf("runInit() unexpected error: %v", err) + } + + // The template ships with every example commented out, so the loader + // should parse it without error and yield an empty (default) config. + // This guards against invalid YAML or accidentally uncommented lines + // slipping into the template. + config, err := cfg.LoadConfig(tmpDir) + if err != nil { + t.Fatalf("generated config failed to load: %v", err) + } + + if config.RequireBranch != "" { + t.Errorf("expected empty RequireBranch, got %q", config.RequireBranch) + } + if len(config.Before) != 0 || len(config.After) != 0 || len(config.Ignore) != 0 { + t.Error("expected no active hooks or ignore patterns in default template") + } + }) } From 52b874c70305af7578c55d0d9c24f28ae873273f Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Sat, 1 Aug 2026 19:07:44 +0100 Subject: [PATCH 3/3] docs: document the since init subcommand in README --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 53d8da2..85b616b 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ go install github.com/release-tools/since - [version](#project-version) - [release](#project-release) +**Config** - Scaffold a config file for the tool. +- [init](#init) + --- ### `changelog generate` @@ -202,10 +205,35 @@ Global Flags: -t, --tag string Include commits after this tag ``` +--- + +### `init` + +Creates a new `since.yaml` config file, pre-populated with commented +examples for branch requirements, pre/post hook scripts, and commit +exclusions. If the file already exists, it is overwritten. + +``` +Usage: + since init [flags] + +Flags: + -h, --help help for init + -o, --output string Directory to write the config file to (default: current directory) + +Global Flags: + -l, --log-level string Log level (debug, info, warn, error, fatal, panic) (default "debug") + -q, --quiet Disable logging (useful for scripting) +``` + +--- + #### The `since.yaml` file You can also use a `since.yaml` file to configure the tool. This file should be placed in the root of your project repository. +The quickest way to start is `since init`, which drops a fully commented example config into your project. Uncomment the bits you need. + ```yaml # require us to be on the main branch requireBranch: main