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"