diff --git a/README.md b/README.md index 4fe9333..e03a137 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ -# go-versionbump +# VersionBump -**Latest Version:** v0.2.0 +![VersionBump Gopher](assets//versionbump_gopher-250.png) + +**Latest Version:** v0.2.0 ([Download Binary](https://github.com/ptgoetz/go-versionbump/releases/tag/v0.2.0)) VersionBump is a powerful command-line tool designed to streamline the process of version management in your projects. By automating version bumping, VersionBump ensures that your project’s version numbers are always up-to-date across all @@ -8,14 +10,15 @@ relevant files, reducing the risk of human error and saving you valuable time. ## Key Features -- **Automated Version Bumping**: Automatically updates version numbers in specified files, ensuring consistency and accuracy. +- **Automated Version Bumping**: Automatically updates version numbers in specified files, ensuring consistency and + accuracy. - **Git Integration**: Seamlessly integrates with Git to commit and tag changes, making version control effortless. -- **Dry Run Mode**: Preview changes without making any modifications, giving you full control over the process. -- **User Confirmation**: Prompts for user confirmation before making changes, with options to disable prompts for a fully automated experience. +- **GPG Integration**: Supports GPG signing of git commits and tags for enhanced security and authenticity. +- **Interactive Mode**: Prompts for user confirmation before making changes, with options to disable prompts for a fully + automated experience. - **Verbose Logging**: Detailed logging for debugging and verification, with options to enable or disable as needed. - **Customizable Configuration**: Flexible configuration options to tailor VersionBump to your specific needs. - ## Rationale Any project that relies on version strings embedded in code and/or configuration files can get unwieldy pretty quickly if you have to manually update those version strings. VersionBump is designed to automate this process so you can focus @@ -30,41 +33,74 @@ With VersionBump you'll never have to switch between virtual environments, insta compatibility issues. It is a simple, lightweight tool that gets the job done without any fuss, and will work with any project that uses version strings in code or configuration files. +#### Existing Projects with Similar Functionality + +The following Python projects drove and inspired the development of VersionBump. +- **[bumpversion](https://github.com/peritus/bumpversion)**: No longer maintained. Requires Python. +- **[bump2version](https://github.com/c4urself/bump2version)**: No longer maintained. Requires Python. +- **[bump-my-version](https://github.com/callowayproject/bump-my-version)**: This is the closest to VersionBump in terms + of intended functionality. Requires Python. + +**Why Not Use Python Tools?** + +The problem at hand is essentially a text-based search and replace operation, with some extra external tool calls for git +integration. Dealing with Python dependencies, virtual environments, and compatibility issues is overkill for this +problem, especially when a tool's dependencies require switching between virtual environments so as not to conflict with +your prject's dependencies. + ### Do No Harm By default VersionBump will do its best to not not make any changes to your project unless you approve them. You will -be prompted to confirm the changes before they are made. You can also run VersionBump in `--dry-run` mode to see what -changes would be made without actually making them. To make VersionBump truly silent and prompt-less, you have use the -`--no-prompt` and `--silent` flags. +be prompted to confirm the changes before they are made. By default VersionBump will run in "interactive" and will +prompt you to approve all changes and extensively log what actions it's performing. To make VersionBump truly silent +and prompt-less, you have use the `--no-prompt` and `--silent` flags. If anything goes wrong, VersionBump will not make any changes to your project and will exit with a non-zero error code. ## Installation -When installed with `go install`, it provides a `versionbump` binary that can be run from the command line. In the -future we will also provide pre-built binaries for common platforms and CPU architectures. +When installed with `go install`, it provides a `versionbump` binary that can be run from the command line. ```shell go install github.com/ptgoetz/go-versionbump/cmd/versionbump ``` -## Usage -Run VersionBump with the desired options: - -```sh -# Bump the version -./versionbump [--config path/to/versionbump.yaml][--dry-run] [--no-prompt] [--quiet] bump-part +If you don't have Go installed and just want the binary executable, you can download a prebuilt binaries from +[here]([Download Binary](https://github.com/ptgoetz/go-versionbump/releases/tag/v0.2.0). -# Reset the version in all tracked files -./versionbump [--config path/to/versionbump.yaml] --reset version +## Usage +Run VersionBump without any arguments to see the available flags and commands: + +```console +$ versionbump + +VersionBump is a command-line tool designed to automate version string management in projects. + +Usage: + versionbump [flags] + versionbump [command] + +Available Commands: + completion Generate the autocompletion script for the specified shell + config Show the effective configuration of the project. + help Help about any command + major Bump the major version number (e.g. 1.2.3 -> 2.0.0). + minor Bump the minor version number (e.g. 1.2.3 -> 1.3.0). + patch Bump the patch version number (e.g. 1.2.3 -> 1.2.4). + reset Reset the project version to the specified value. + show Show potential versioning paths for the project version or a specific version. + +Flags: + -h, --help help for versionbump + -V, --version Show the version of Config and exit. + +Use "versionbump [command] --help" for more information about a command. ``` -- `bump-part`: The part of the version number to bump (`major`, `minor`, or `patch`). -- `--V`: Print the VersionBump version and exit. + +- `--version`: Print the VersionBump version and exit. - `--config`: Path to the configuration file (default: `./versionbump.yaml`). -- `--dry-run`: Perform a dry run without making any changes. - `--no-prompt`: Do not prompt the user for confirmation before making changes. - `--no-git`: Do not commit or tag the changes in a Git repository. - `--no-color`: Disable colorized output. - `--quiet`: Disable verbose logging. -- `--reset [version]`: Reset the version number to the specified value. ## Configuration The configuration file (**Default:** `versionbump.yaml`) defines the version bump settings: @@ -72,9 +108,10 @@ The configuration file (**Default:** `versionbump.yaml`) defines the version bum ```yaml version: "0.0.0" # (REQUIRED) The current version of the project. -# Git settings are optional. All default too `false`. +# Git settings are optional. All default to `false`. git-commit: false # Whether to create a git commit for the version bump. git-tag: false # Whether to create a git tag for the version bump. +git-sign: false # Whether to sign the git commit/tag. files: # The files to update with the new version. - path: "version.go" # The path to the file to update. @@ -88,12 +125,14 @@ files: # The files to update with the new version. `major.minor.patch` string. - `git-commit`: (Optional) Whether to `git commit` the changes. - `git-tag`: (Optional) Whether to tag the commit (implies `git-commit`). +- `git-sign`: (Optional) Whether to sign the commit/tag with GPG. - `files`: (Required) A list of files to update with the new version number. - `path`: The path to the file. **Note**: Relative file paths are relative to the config file parent directory. Absolute paths are used as-is. - `replace`: The string to replace with the new version number. Use `{version}` as a placeholder. **Important Note:** + The specified or default configuration file is implicitly included as a file that will undergo version replacement. It serves as the source of truth for the version number. VersionBump will always include it as a file to update with the new version number. @@ -112,15 +151,16 @@ The following placeholders can be used in the templates: - `{old}`: The old semantic version number. - `{new}`: The new semantic version number. -## Sample Ouput +## Examples ### Configuration File ```yaml -version: "0.1.7" # The current version of the project. +version: "0.1.9" # The current version of the project. git-commit: true # Whether to create a git commit for the version bump. git-tag: true # Whether to create a git tag for the version bump. +git-sign: true # Whether to sign the git commit/tag. -files: # The files to update with the new version. +files: # The files to update with the new version (i.e. "Tracked files"). - path: "main.go" # The path to the file to update. replace: "v{version}" # The search string to replace in the file. @@ -129,64 +169,189 @@ files: # The files to update with the new version. ``` ### Default (Verbose) Output with Prompts -```text +In the following scenario, the project is not a git repository but git features are enabled, so VersionBump will +offer to initialize a git repository in the project directory. VersionBump will add tracked files to the git repository +and perform an initial commit before continuing. + +```console $ versionbump patch -VersionBump v0.0.0 -Config path: versionbump.yaml +VersionBump v0.3.0 +Configuration file: versionbump.yaml +Project root directory: /Users/tgoetz/Projects/ptgoetz/test-project +Checking git configuration... Git version: 2.39.3 (Apple Git-146) -Project root: /Users/tgoetz/Projects/ptgoetz/test-project -Current Version: 0.1.6 +The project directory is not a git repository. +Do you want to initialize a git repository in the project directory? [y/N]: y +Initialized Git repository. +Adding tracked files... +Tracked Files: + - main.go + - README.md + - versionbump.yaml +Performing initial commit. +Current branch: main +Checking for existing tag... +GPG signing of git commits is enabled. Checking configuration... +Git commits will be signed with GPG key: ACEFE18DD2322E1E84587A148DE03962E80B8FFD Tracked Files: - main.go - README.md - versionbump.yaml Bumping version part: patch -Will bump version 0.1.6 --> 0.1.7 +Will bump version 0.1.10 --> 0.1.11 main.go - Find: "v0.1.6" - Replace: "v0.1.7" + Find: "v0.1.10" + Replace: "v0.1.11" Found 1 replacement(s) README.md - Find: "**Current Version:** v0.1.6" - Replace: "**Current Version:** v0.1.7" + Find: "**Current Version:** v0.1.10" + Replace: "**Current Version:** v0.1.11" Found 1 replacement(s) versionbump.yaml - Find: "version: "0.1.6"" - Replace: "version: "0.1.7"" + Find: "version: "0.1.10"" + Replace: "version: "0.1.11"" Found 1 replacement(s) -The following files will be updated: - - main.go - - README.md - - versionbump.yaml -Do you want to proceed with the changes? [y/N]: y +Proceed with the changes? [y/N]: y Updated file: main.go Updated file: README.md Updated file: versionbump.yaml +Commit Message: Bump version 0.1.10 --> 0.1.11 +Tag Message: Release version 0.1.11 +Tag Name: v0.1.11 Do you want to commit the changes to the git repository? [y/N]: y Committing changes... -Commit message: 'Bump version 0.1.6 --> 0.1.7' +Committed changes with message: Bump version 0.1.10 --> 0.1.11 Tagging changes... -Tag 'v0.1.7' created with message: 'Release 0.1.7' +Tag 'v0.1.11' created with message: Release version 0.1.11 + ``` ### Suppressing Prompts and Verbose Output -```shell +```consdole $ versionbump --no-prompt --quiet patch # No output $ echo $? 0 # Success -$ git log --name-status HEAD^..HEAD # Show last commit -commit c78b938150ab0e1c70178bf9e6c5a82f6c762830 (HEAD -> main, tag: v0.1.8) +$ git log --show-signature --name-status HEAD^..HEAD # Show last commit +commit e695bb7aaa8d4f7b6c821eb13d15fe4c658a929f (HEAD -> main, tag: v0.1.12) +gpg: Signature made Fri Sep 13 19:08:11 2024 EDT +gpg: using RSA key ACEFE18DD2322E1E84587A148DE03962E80B8FFD +gpg: Good signature from "P. Taylor Goetz " [ultimate] +gpg: aka "P. Taylor Goetz " [ultimate] Author: P. Taylor Goetz -Date: Sat Aug 24 14:15:24 2024 -0400 - Bump version 0.1.7 --> 0.1.8 +Date: Fri Sep 13 19:08:11 2024 -0400 + + Bump version 0.1.11 --> 0.1.12 M README.md M main.go M versionbump.yaml + +``` + +### Show Command +Without parameters, the `show` command will display the potential versioning paths for the project version: +```console +$ versionbump show +Potential versioning paths for project version: 0.1.7 +0.1.7 ── bump ─┬─ major ─ 1.0.0 + ├─ minor ─ 0.2.0 + ╰─ patch ─ 0.1.8 ``` -## Development +You can also specify any version identifier to see the potential versioning paths: +```console +versionbump show 1.2.3 +Potential versioning paths for version: 1.2.3 +1.2.3 ── bump ─┬─ major ─ 2.0.0 + ├─ minor ─ 1.3.0 + ╰─ patch ─ 1.2.4 +``` + +### Config Command + +The `config` command will display the effective configuration of the project. This will show default values for any +configuration settings that are not explicitly set in the configuration file. + +```console +$ versionbump config +Config file: versionbump.yaml +Project root: /Users/tgoetz/Projects/ptgoetz/test-project +Effective Configuration YAML: +version: 0.1.7 +git-commit: true +git-commit-template: Bump version {old} --> {new} +git-sign: true +git-tag: true +git-tag-template: v{new} +git-tag-message-template: Release version {new} +files: + - path: main.go + replace: v{version} + - path: README.md + replace: '**Current Version:** v{version}' + - path: versionbump.yaml + replace: 'version: "{version}"' + +Potential versioning paths for project version: 0.1.7 +0.1.7 ── bump ─┬─ major ─ 1.0.0 + ├─ minor ─ 0.2.0 + ╰─ patch ─ 0.1.8 +``` + +## Failure Modes and Errors +VersionBump does its best to prevent leaving your project in an inconsistent state. Before making any changes, it will +perform a series of "pre-flight" checks to ensure that the version bump can be completed successfully. If any errors are +detected, VersionBump will exit with a non-zero error code and will not make any changes to your project. + +If VersionBump is run in `--no-prompt` mode, it will exit with an error if any of the pre-flight checks fail. If it is +run in interactive mode (default), it will prompt the user to confirm whether to proceed with the version bump. + +If git integration is enabled in the VersionBump configuration, VersionBump will also exit with an error if it detects +that any git operations (e.g., committing or tagging) will fail (e.g. the project directory is not a git repository). +When running in interactive mode, VersionBump will prompt the user to correct git issues it can fix (e.g. initializing +a git repository). + + +### Standard Pre-Flight Checks + +- **Configuration File**: VersionBump will check that the configuration file exists and is read/write. If the file is + missing or cannot be read or written, VersionBump will exit with an error. +- **Version Number**: VersionBump will check that the version number in the configuration file is a valid semantic + version number. If the version number is invalid, VersionBump will exit with an error. Note that VersionBump will + normalize the version strings to a semantic version number before proceeding. For example the string `"1.2.003"` will + be normalized to `1.2.3`. +- **Tracked Files**: VersionBump will check that all tracked files in the configuration file exist and are read/write. + If any files are missing or cannot be read, VersionBump will exit with an error. +- **At Least One Replacement**: VersionBump will check that at least one replacement will be made in each tracked file. + If no replacements would be made, VersionBump will exit with an error. + +### Git Pre-Flight Checks + +- **Git Installed**: VersionBump will check that the `git` command is available in the system path. If the `git` command + is not available, VersionBump will exit with an error. +- **Git Repository**: If git integration is enabled, VersionBump will check that the project directory is a git + repository. If the project directory is not a git repository, VersionBump will exit with an error. + + In interactive mode, VersionBump will prompt the user to initialize a git repository in the project directory. It will + also add all tracked files to the git repository and commit them with the message "Initial commit". +- **Git Clean**: VersionBump will check that the git repository is clean (i.e., no uncommitted changes). If the git + repository is not clean, VersionBump will exit with an error. +- **Git Tagging**: If git tagging is enabled, VersionBump will check that the tag name does not already exist in the git + repository. If the tag name already exists, VersionBump will exit with an error. + +### GPG Pre-Flight Checks + +If signing of git commits and tags is enabled, either in the VersionBump or git configuration, VersionBump will perform +the following additional checks: + +- **GPG Signing Key**: VersionBump will check that a GPG signing key is available in the git configuration + (`git config --get user.signingkey`). If no GPG signing key is available, VersionBump will exit with an error. +- **Sign/Don't Sign Conflict**: If signing is disabled in the VersionBump configuration, but enabled in the git + configuration, VersionBump will log a warning message and continue. VersionBump will not override the git + configuration for signing. + +## Contributing If you want to hack and/or contribute to VersionBump, look at the [DEVELOPER.md](DEVELOPER.md) file for more information. diff --git a/assets/versionbump_gopher-250.png b/assets/versionbump_gopher-250.png new file mode 100644 index 0000000..50ee290 Binary files /dev/null and b/assets/versionbump_gopher-250.png differ diff --git a/assets/versionbump_gopher-500.png b/assets/versionbump_gopher-500.png new file mode 100644 index 0000000..50ee290 Binary files /dev/null and b/assets/versionbump_gopher-500.png differ diff --git a/cmd/versionbump/main.go b/cmd/versionbump/main.go index dc8eb1e..ee27536 100644 --- a/cmd/versionbump/main.go +++ b/cmd/versionbump/main.go @@ -4,54 +4,165 @@ import ( "fmt" "github.com/ptgoetz/go-versionbump/internal" vbc "github.com/ptgoetz/go-versionbump/internal/config" - vbv "github.com/ptgoetz/go-versionbump/internal/version" + "github.com/ptgoetz/go-versionbump/internal/version" "github.com/spf13/cobra" - "log" + "github.com/spf13/pflag" "os" ) +func main() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + var opts vbc.Options -func main() { - var rootCmd = &cobra.Command{ - Use: "versionbump [bump-part]", - Short: "VersionBump is a tool for managing version bumps", - Long: `VersionBump is a tool for managing version bumps with optional git integration.`, - Args: cobra.MaximumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { - if opts.ShowVersion { - fmt.Println(vbv.VersionBumpVersion) - os.Exit(0) - } - - if len(args) > 0 { - opts.BumpPart = args[0] - } - if len(args) == 0 && opts.ResetVersion == "" { - fmt.Println("ERROR: no version part specified.") - cmd.Usage() - os.Exit(1) - } - - vb, err := internal.NewVersionBump(opts) - if err != nil { - log.Fatal(err) - } - vb.Run() - }, +var rootCmd = &cobra.Command{ + Use: "versionbump", + Short: `VersionBump is a command-line tool designed to automate version string management in projects.`, + Long: `VersionBump is a command-line tool designed to automate version string management in projects.`, + RunE: runRootCmd, // Use RunE for better error handling +} + +var majorCmd = &cobra.Command{ + Use: "major", + Short: `Bump the major version number (e.g. 1.2.3 -> 2.0.0).`, + Long: `Bump the major version number (e.g. 1.2.3 -> 2.0.0).`, + RunE: bumpMajor, // Use RunE for better error handling +} + +var minorCmd = &cobra.Command{ + Use: "minor", + Short: `Bump the minor version number (e.g. 1.2.3 -> 1.3.0).`, + Long: `Bump the minor version number (e.g. 1.2.3 -> 1.3.0).`, + RunE: bumpMinor, // Use RunE for better error handling +} + +var patchCmd = &cobra.Command{ + Use: "patch", + Short: `Bump the patch version number (e.g. 1.2.3 -> 1.2.4).`, + Long: `Bump the patch version number (e.g. 1.2.3 -> 1.2.4).`, + RunE: bumpPatch, // Use RunE for better error handling +} + +var configCmd = &cobra.Command{ + Use: "config", + Short: `Show the effective configuration of the project.`, + Long: `Show the effective configuration of the project.`, + RunE: runConfigCmd, // Use RunE for better error handling +} + +var resetCmd = &cobra.Command{ + Use: "reset ", + Short: `Reset the project version to the specified value.`, + Long: `Reset the project version to the specified value. Value can be any valid semantic version string.`, + Args: cobra.ExactArgs(1), + RunE: runResetCmd, // Use RunE for better error handling +} + +var showCmd = &cobra.Command{ + Use: "show [version]", + Short: `Show potential versioning paths for the project version or a specific version.`, + Long: `Show potential versioning paths for the project version or a specific version.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + vb, err := internal.NewVersionBump(opts) + if err != nil { + return err + } + versionStr := "" + if len(args) > 0 { + versionStr = args[0] + } + + return vb.Show(versionStr) + }, +} + +func init() { + rootCmd.Flags().BoolVarP(&opts.ShowVersion, "version", "V", false, "Show the VersionBump version and exit.") + + commonFlags := pflag.NewFlagSet("common", pflag.ExitOnError) + commonFlags.StringVarP(&opts.ConfigPath, "config", "c", "versionbump.yaml", "The path to the configuration file") + commonFlags.BoolVar(&opts.NoPrompt, "no-prompt", false, "Don't prompt the user for confirmation before making changes.") + commonFlags.BoolVarP(&opts.Quiet, "quiet", "q", false, "Don't print verbose output.") + commonFlags.BoolVar(&opts.NoGit, "no-git", false, "Don't perform any git operations.") + commonFlags.BoolVar(&opts.NoColor, "no-color", false, "Disable color output.") + + configColorFlags := pflag.NewFlagSet("config-color", pflag.ExitOnError) + configColorFlags.StringVarP(&opts.ConfigPath, "config", "c", "versionbump.yaml", "The path to the configuration file") + configColorFlags.BoolVar(&opts.NoColor, "no-color", false, "Disable color output.") + + commonFlags.AddFlagSet(configColorFlags) + + showCmd.Flags().AddFlagSet(configColorFlags) + configCmd.Flags().AddFlagSet(configColorFlags) + + majorCmd.Flags().AddFlagSet(commonFlags) + minorCmd.Flags().AddFlagSet(commonFlags) + patchCmd.Flags().AddFlagSet(commonFlags) + resetCmd.Flags().AddFlagSet(commonFlags) + + rootCmd.AddCommand(majorCmd) + rootCmd.AddCommand(minorCmd) + rootCmd.AddCommand(patchCmd) + rootCmd.AddCommand(resetCmd) + rootCmd.AddCommand(showCmd) + rootCmd.AddCommand(configCmd) + +} + +func runRootCmd(cmd *cobra.Command, args []string) error { + if opts.ShowVersion { + fmt.Println(version.VersionBumpVersion) + return nil } + return cmd.Help() +} - rootCmd.Flags().BoolVarP(&opts.ShowVersion, "version", "V", false, "Show the version of Config and exit.") - rootCmd.Flags().StringVarP(&opts.ConfigPath, "config", "c", "versionbump.yaml", "The path to the configuration file") - rootCmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Dry run. Don't change anything, just report what would be changed") - rootCmd.Flags().BoolVar(&opts.NoPrompt, "no-prompt", false, "Don't prompt the user for confirmation before making changes.") - rootCmd.Flags().BoolVarP(&opts.Quiet, "quiet", "q", false, "Don't print verbose output.") - rootCmd.Flags().StringVar(&opts.ResetVersion, "reset", "", "Reset the version to the specified value.") - rootCmd.Flags().BoolVar(&opts.NoGit, "no-git", false, "Don't perform any git operations.") - rootCmd.Flags().BoolVar(&opts.NoColor, "no-color", false, "Disable color output.") +func bumpMajor(cmd *cobra.Command, args []string) error { + return runVersionBump(version.VersionMajorStr) +} - if err := rootCmd.Execute(); err != nil { - fmt.Println(err) - os.Exit(1) +func bumpMinor(cmd *cobra.Command, args []string) error { + return runVersionBump(version.VersionMinorStr) +} + +func bumpPatch(cmd *cobra.Command, args []string) error { + return runVersionBump(version.VersionPatchStr) +} + +func runResetCmd(cmd *cobra.Command, args []string) error { + opts.ResetVersion = args[0] + vb, err := internal.NewVersionBump(opts) + if err != nil { + return err + } + + vb.Run() + return nil +} + +func runConfigCmd(cmd *cobra.Command, args []string) error { + vb, err := internal.NewVersionBump(opts) + if err != nil { + return err } + + return vb.ShowEffectiveConfig() +} + +// runVersionBump contains the logic for executing the version bump process +func runVersionBump(bumpPart string) error { + opts.BumpPart = bumpPart + + vb, err := internal.NewVersionBump(opts) + if err != nil { + return err + } + + // Run the version bump process + vb.Run() + return nil } diff --git a/internal/config/config.go b/internal/config/config.go index 3dcbe16..e40a769 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" "github.com/ptgoetz/go-versionbump/internal/utils" + "github.com/ptgoetz/go-versionbump/internal/version" "gopkg.in/yaml.v3" "os" "path" @@ -19,6 +20,7 @@ type Config struct { Version string `yaml:"version"` GitCommit bool `yaml:"git-commit"` GitCommitTemplate string `yaml:"git-commit-template"` + GitSign bool `yaml:"git-sign"` GitTag bool `yaml:"git-tag"` GitTagTemplate string `yaml:"git-tag-template"` GitTagMessageTemplate string `yaml:"git-tag-message-template"` @@ -35,7 +37,6 @@ type GitMeta struct { type Options struct { ConfigPath string - DryRun bool Quiet bool NoPrompt bool ShowVersion bool @@ -88,11 +89,21 @@ func LoadConfig(filePath string) (*Config, string, error) { return nil, "", fmt.Errorf("error parsing config file: %w", err) } + // make sure we can resolve the parent directory root, err := utils.ParentDirAbsolutePath(filePath) if err != nil { return nil, "", fmt.Errorf("error getting parent directory: %w", err) } + // validate the version string is not empty + if config.Version == "" { + return nil, "", fmt.Errorf("version string is required") + } + + if !version.ValidateVersion(config.Version) { + return nil, "", fmt.Errorf("invalid version string: %s", config.Version) + } + configPtr := &config // include the config file as a file to update configPtr.Files = append(configPtr.Files, VersionedFile{Path: configFile, Replace: "version: \"{version}\""}) diff --git a/internal/git/git.go b/internal/git/git.go index 812cb13..e4b5a37 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -3,7 +3,7 @@ package git import ( "bytes" "fmt" - "os" + vbc "github.com/ptgoetz/go-versionbump/internal/config" "os/exec" "path/filepath" "strings" @@ -12,180 +12,118 @@ import ( // IsGitAvailable checks if the 'git' command is available on the system and returns the Git version if available. func IsGitAvailable() (bool, string) { // Attempt to run the 'git --version' command - cmd := exec.Command("git", "--version") - - // Capture the output - var out bytes.Buffer - cmd.Stdout = &out - - // Run the command and check if it executes successfully - if err := cmd.Run(); err != nil { + out, _, err := runGitCommand("", "--version") + if err != nil { return false, "" } - - // Return true and the output (which contains the Git version) - return true, out.String() + return true, out } -// IsGitRepository checks if the given directory is a Git repository. -func IsGitRepository(dirPath string) (bool, error) { - // Ensure the path is an absolute path - absPath, err := filepath.Abs(dirPath) - if err != nil { - return false, fmt.Errorf("failed to get absolute path: %w", err) - } - - // Run the 'git rev-parse --is-inside-work-tree' command in the specified directory - cmd := exec.Command("git", "rev-parse", "--is-inside-work-tree") - cmd.Dir = absPath - - // Run the command and capture the output - output, err := cmd.Output() +// IsRepository checks if the given directory is a Git repository. +func IsRepository(dirPath string) (bool, error) { + out, _, err := runGitCommand(dirPath, "rev-parse", "--is-inside-work-tree") if err != nil { - return false, nil // Not a git repository if command fails + return false, nil } - // Check if the output is "true\n" - if string(output) == "true\n" { + if string(out) == "true\n" { return true, nil } - return false, nil } // HasPendingChanges checks if the given directory has pending changes (uncommitted changes) in the Git repository. func HasPendingChanges(dirPath string) (bool, error) { - // Ensure the path is an absolute path - absPath, err := filepath.Abs(dirPath) - if err != nil { - return false, fmt.Errorf("failed to get absolute path: %w", err) - } - - // Run the 'git status --porcelain' command in the specified directory - cmd := exec.Command("git", "status", "--porcelain", "--untracked-files=no") - cmd.Dir = absPath - - // Run the command and capture the output - output, err := cmd.Output() + out, _, err := runGitCommand(dirPath, "status", "--porcelain", "--untracked-files=no") if err != nil { return false, fmt.Errorf("failed to check git status: %w", err) } - // If the output is not empty, there are pending changes - if len(output) > 0 { + if len(out) > 0 { return true, nil } - return false, nil } // InitializeGitRepo initializes a new Git repository in the specified directory path. func InitializeGitRepo(dirPath string) error { - // Ensure the directory path is an absolute path - absPath, err := filepath.Abs(dirPath) + _, _, err := runGitCommand(dirPath, "init") if err != nil { - return fmt.Errorf("failed to get absolute path: %w", err) + return fmt.Errorf("failed to initialize git repository: %w", err) } + return nil +} - // Ensure the directory exists - if _, err := os.Stat(absPath); os.IsNotExist(err) { - if err := os.MkdirAll(absPath, 0755); err != nil { - return fmt.Errorf("failed to create directory: %w", err) +// AddFiles adds the specified files to the staging area of the git repository. +func AddFiles(dirPath string, files ...vbc.VersionedFile) error { + for _, file := range files { + _, _, err := runGitCommand(dirPath, "add", file.Path) + if err != nil { + return fmt.Errorf("failed to add file to git staging area: %w", err) } } + return nil +} - // Run the 'git init' command in the specified directory - cmd := exec.Command("git", "init") - cmd.Dir = absPath - - // Execute the command and check for errors - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to initialize git repository: %w", err) +// IsSigningEnabled checks if GPG signing is enabled for commits in the git repository. +func IsSigningEnabled(dirPath string) (bool, error) { + out, _, err := runGitCommand(dirPath, "config", "--get", "commit.gpgsign") + if err != nil { + return false, fmt.Errorf("failed to get git config 'commit.gpgsign': %w", err) } + return strings.TrimSpace(out) == "true", nil +} - return nil +// GetSigningKey returns the GPG signing key used for signing commits and tags. +func GetSigningKey(dirPath string) (string, error) { + out, _, err := runGitCommand(dirPath, "config", "--get", "user.signingkey") + if err != nil { + return "", fmt.Errorf("failed to get git signing key: %w", err) + } + return strings.TrimSpace(out), nil } // CommitChanges commits any pending staged changes to the git repository. // It performsa a `git commit -am `. -func CommitChanges(dirPath string, commitMessage string) error { - // Ensure the path is an absolute path - absPath, err := filepath.Abs(dirPath) - if err != nil { - return fmt.Errorf("failed to get absolute path: %w", err) +func CommitChanges(dirPath string, commitMessage string, sign bool) error { + args := []string{"commit", "-am", commitMessage} + if sign { + args = append(args, "-S") } - - cmd := exec.Command("git", "commit", "-am", commitMessage) - cmd.Dir = absPath - - // Capture the output - var stdOut bytes.Buffer - cmd.Stdout = &stdOut - - var stdErr bytes.Buffer - cmd.Stderr = &stdErr - - // Run the command - if err := cmd.Run(); err != nil { - fmt.Println(stdOut.String()) - fmt.Println(stdErr.String()) - return fmt.Errorf("git commit failed: %w", err) + _, _, err := runGitCommand(dirPath, args...) + if err != nil { + return fmt.Errorf("failed to commit changes: %w", err) } - return nil } -func TagChanges(root string, name string, message string) error { - // Ensure the path is an absolute path - absPath, err := filepath.Abs(root) - if err != nil { - return fmt.Errorf("failed to get absolute path: %w", err) +// TagChanges creates a new tag in the git repository with the specified name and message. +func TagChanges(root string, name string, message string, sign bool) error { + args := []string{"tag", "-a", name, "-m", message} + if sign { + args = append(args, "-s") } - - cmd := exec.Command("git", "tag", "-a", name, "-m", message) - cmd.Dir = absPath - - // Capture the output - var stdOut bytes.Buffer - cmd.Stdout = &stdOut - - var stdErr bytes.Buffer - cmd.Stderr = &stdErr - - // Run the command - if err := cmd.Run(); err != nil { - fmt.Println(stdOut.String()) - fmt.Println(stdErr.String()) - return fmt.Errorf("git commit failed: %w", err) + _, _, err := runGitCommand(root, args...) + if err != nil { + return fmt.Errorf("failed to tag changes: %w", err) } - return nil } // GetTags returns a list of git tags for the given project directory -func GetGitTags(projectDir string) ([]string, error) { - // Prepare the git command - cmd := exec.Command("git", "tag") - cmd.Dir = projectDir - - // Capture the output - var out bytes.Buffer - cmd.Stdout = &out - - // Run the command - if err := cmd.Run(); err != nil { - return nil, err +func GetTags(projectDir string) ([]string, error) { + out, _, err := runGitCommand(projectDir, "tag", "--list") + if err != nil { + return nil, fmt.Errorf("failed to get git tags: %w", err) } - // Convert the output to a slice of strings, one per line - tags := strings.Split(strings.TrimSpace(out.String()), "\n") - + tags := strings.Split(strings.TrimSpace(out), "\n") return tags, nil } // TagExists checks if the given tag exists in the git repository of the project directory func TagExists(projectDir string, tagName string) (bool, error) { - tags, err := GetGitTags(projectDir) + tags, err := GetTags(projectDir) if err != nil { return false, err } @@ -199,18 +137,38 @@ func TagExists(projectDir string, tagName string) (bool, error) { // GetCurrentBranch returns the current branch of the git repository in the given project directory func GetCurrentBranch(projectDir string) (string, error) { - // Prepare the git command - cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") - cmd.Dir = projectDir + out, _, err := runGitCommand(projectDir, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return "", fmt.Errorf("failed to get current branch: %w", err) + } + return strings.TrimSpace(out), nil +} + +// runGitCommand runs a git command in the specified directory and returns the output and error messages. +func runGitCommand(root string, args ...string) (string, string, error) { + absPath, err := filepath.Abs(root) + if err != nil { + return "", "", fmt.Errorf("failed to get absolute path: %w", err) + } + + cmd := exec.Command("git", args...) + cmd.Dir = absPath + + var stdOut bytes.Buffer + cmd.Stdout = &stdOut + + var stdErr bytes.Buffer + cmd.Stderr = &stdErr - // Capture the output - var out bytes.Buffer - cmd.Stdout = &out + // Some git commends will return a non-zero exit code even if they succeed + // For example `git conig --get user.email` will return 1 if the email is not set + // We don't want to treat this as an error + _ = cmd.Run() // Run the command - if err := cmd.Run(); err != nil { - return "", err + if stdErr.String() != "" { + return "", "", fmt.Errorf("git command failed: %s", stdErr.String()) } - return strings.TrimSpace(out.String()), nil + return stdOut.String(), stdErr.String(), nil } diff --git a/internal/git/git_test.go b/internal/git/git_test.go index c540d12..fbc482e 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -18,7 +18,7 @@ func TestIsGitAvailable(t *testing.T) { t.Logf("Git is available with version: %s", version) } -// TestIsGitRepository tests the IsGitRepository function +// TestIsGitRepository tests the IsRepository function func TestIsGitRepository(t *testing.T) { // Create a temporary directory dir, err := os.MkdirTemp("", "gitrepo") @@ -34,7 +34,7 @@ func TestIsGitRepository(t *testing.T) { } // Check if the directory is recognized as a Git repository - isRepo, err := IsGitRepository(dir) + isRepo, err := IsRepository(dir) if err != nil { t.Fatalf("Unexpected error: %v", err) } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 568234b..0fba2e6 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -84,3 +84,13 @@ func ParentDirAbsolutePath(relativePath string) (string, error) { // Return the absolute path of the parent directory return parentDir, nil } + +func PaddingString(length int, padChar string) string { + if len(padChar) != 1 { + panic("padChar must be a single character") + } + if length <= 0 { + return "" + } + return strings.Repeat(padChar, length) +} diff --git a/internal/version/version.go b/internal/version/version.go index 9e0f229..7d707e9 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -19,15 +19,17 @@ const ( ) type Version struct { - Major int - Minor int - Patch int + major int + minor int + patch int } +// NewVersion creates a new immutable Version instance func NewVersion(major int, minor int, patch int) *Version { - return &Version{Major: major, Minor: minor, Patch: patch} + return &Version{major: major, minor: minor, patch: patch} } +// ParseVersion parses a version string and returns a new Version instance func ParseVersion(version string) (*Version, error) { vals := strings.Split(version, ".") if len(vals) != 3 { @@ -48,30 +50,27 @@ func ParseVersion(version string) (*Version, error) { return NewVersion(major, minor, patch), nil } +// String returns the version string func (v *Version) String() string { - return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) + return fmt.Sprintf("%d.%d.%d", v.major, v.minor, v.patch) } -func (v *Version) Bump(versionPart int) error { +// Bump returns a new Version instance after incrementing the specified part +func (v *Version) Bump(versionPart int) *Version { switch versionPart { case VersionMajor: - v.Major++ - v.Minor = 0 - v.Patch = 0 - return nil + return NewVersion(v.major+1, 0, 0) case VersionMinor: - v.Minor++ - v.Patch = 0 - return nil + return NewVersion(v.major, v.minor+1, 0) case VersionPatch: - v.Patch++ - return nil + return NewVersion(v.major, v.minor, v.patch+1) default: - return fmt.Errorf("invalid version part: %d", versionPart) + panic(fmt.Sprintf("invalid version part: %d.\n", versionPart)) } } -func (v *Version) StringBump(versionPart string) error { +// StringBump returns a new Version instance after incrementing the specified part (as a string) +func (v *Version) StringBump(versionPart string) *Version { switch versionPart { case VersionMajorStr: return v.Bump(VersionMajor) @@ -80,10 +79,11 @@ func (v *Version) StringBump(versionPart string) error { case VersionPatchStr: return v.Bump(VersionPatch) default: - return fmt.Errorf("invalid version part: %s", versionPart) + panic(fmt.Sprintf("invalid version part: %s. Call `ValidateVersionPart()` to prevent this error.\n", versionPart)) } } +// ValidateVersionPart checks if the provided version part string is valid func ValidateVersionPart(part string) bool { switch part { case VersionMajorStr, VersionMinorStr, VersionPatchStr: @@ -93,6 +93,7 @@ func ValidateVersionPart(part string) bool { } } +// ValidateVersion checks if the provided version string is a valid semantic version func ValidateVersion(version string) bool { _, err := ParseVersion(version) return err == nil diff --git a/internal/version/version_test.go b/internal/version/version_test.go index ca29449..4c2f251 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -6,7 +6,7 @@ import ( func TestParseVersion(t *testing.T) { version := "1.2.3" - expected := Version{Major: 1, Minor: 2, Patch: 3} + expected := Version{major: 1, minor: 2, patch: 3} actual, _ := ParseVersion(version) if *actual != expected { t.Errorf("Expected %v but got %v", expected, *actual) @@ -14,7 +14,7 @@ func TestParseVersion(t *testing.T) { } func TestVersion_String(t *testing.T) { - version := Version{Major: 1, Minor: 2, Patch: 3} + version := Version{major: 1, minor: 2, patch: 3} expected := "1.2.3" actual := version.String() if actual != expected { diff --git a/internal/versionbump.go b/internal/versionbump.go index 1364dfa..132e45d 100644 --- a/internal/versionbump.go +++ b/internal/versionbump.go @@ -8,66 +8,140 @@ import ( "github.com/ptgoetz/go-versionbump/internal/utils" vbu "github.com/ptgoetz/go-versionbump/internal/utils" vbv "github.com/ptgoetz/go-versionbump/internal/version" + "gopkg.in/yaml.v3" "os" "path" "strings" ) type VersionBump struct { - Config config.Config - Options config.Options - ParentDir string - OldVersion string - NewVersion string + Config config.Config + Options config.Options + ParentDir string } // NewVersionBump creates a new VersionBump instance. -// It loads the configuration file and determines/validates the old and new versions. -// If the reset version option is set, the new version is set to the reset version. func NewVersionBump(options config.Options) (*VersionBump, error) { - // Log the version and configuration path cfg, parentDir, err := config.LoadConfig(options.ConfigPath) if err != nil { return nil, err } - // determine the old and new versions - var oldVersion string - var newVersion string + vb := &VersionBump{ + Config: *cfg, + Options: options, + ParentDir: parentDir, + } - // get the old version from the config - vTemp, err := vbv.ParseVersion(cfg.Version) - if err != nil { - logFatal(options, fmt.Sprintf("Failed to parse semantic version string for old version: %s", err)) + return vb, nil +} + +func (vb *VersionBump) GetOldVersion() string { + if !vbv.ValidateVersion(vb.Config.Version) { + logFatal(vb.Options, fmt.Sprintf("Failed to parse semantic version string for old version: %s", vb.Config.Version)) } - oldVersion = vTemp.String() + oldVersion, _ := vbv.ParseVersion(vb.Config.Version) + return oldVersion.String() +} - // get the new or reset version - if options.IsResetVersion() { - if !vbv.ValidateVersion(options.ResetVersion) { - logFatal(options, fmt.Sprintf("Failed to parse semantic version reset string: %s\n", options.ResetVersion)) +func (vb *VersionBump) GetNewVersion() string { + if vb.Options.IsResetVersion() { + v, err := vbv.ParseVersion(vb.Options.ResetVersion) + if err != nil { + logFatal(vb.Options, fmt.Sprintf("Failed to parse semantic version string for reset version: %s", vb.Options.ResetVersion)) } - // set new version to the reset version - vTemp, _ = vbv.ParseVersion(options.ResetVersion) - newVersion = vTemp.String() + return v.String() + } + oldVersionStr := vb.GetOldVersion() + oldVersion, _ := vbv.ParseVersion(oldVersionStr) + newVersion := oldVersion.StringBump(vb.Options.BumpPart) + return newVersion.String() +} + +func (vb *VersionBump) Run() { + vb.preamble() + vb.gitPreFlight() + vb.logTrackedFiles() + vb.bumpPreflight() + if vb.promptProceedWithChanges() { + vb.makeChanges() + vb.gitCommit() + } +} + +func (vb *VersionBump) Show(versionStr string) error { + var curVersionStr string + isProject := false + if versionStr != "" { + curVersionStr = versionStr } else { - if !vbv.ValidateVersionPart(options.BumpPart) { - logFatal(options, fmt.Sprintf("Invalid version part: %s", options.BumpPart)) - } - _ = vTemp.StringBump(options.BumpPart) - newVersion = vTemp.String() + curVersionStr = vb.Config.Version + isProject = true + } + curVersion, err := vbv.ParseVersion(curVersionStr) + if err != nil { + return err } - vb := &VersionBump{ - Config: *cfg, - Options: options, - ParentDir: parentDir, - OldVersion: oldVersion, - NewVersion: newVersion, + if !isProject { + logVerbose(vb.Options, fmt.Sprintf("Potential versioning paths for version: %s", + curVersion.String())) + } else { + logVerbose(vb.Options, fmt.Sprintf("Potential versioning paths for project version: %s", + curVersion.String())) } + // we now know we have a valid version + majorVersion := curVersion.StringBump(vbv.VersionMajorStr) + minorVersion := curVersion.StringBump(vbv.VersionMinorStr) + patchVersion := curVersion.StringBump(vbv.VersionPatchStr) + + padLen := len(curVersion.String()) - 2 + padding := utils.PaddingString(padLen, " ") + + tree := fmt.Sprintf( + `%s ── bump ─┬─ major ─ %s + %s ├─ minor ─ %s + %s ╰─ patch ─ %s +`, + curVersion.String(), + majorVersion.String(), + padding, + minorVersion.String(), + padding, + patchVersion.String()) + + printColorOpts(vb.Options, tree, ColorBlue) + return nil +} - return vb, nil +func (vb *VersionBump) ShowEffectiveConfig() error { + logVerbose(vb.Options, fmt.Sprintf("Config file: %s", vb.Options.ConfigPath)) + logVerbose(vb.Options, fmt.Sprintf("Project root: %s", vb.ParentDir)) + logVerbose(vb.Options, "Effective Configuration YAML:") + + conf := &vb.Config + + // set defaults if not overridden + if conf.GitCommitTemplate == "" { + conf.GitCommitTemplate = config.DefaultGitCommitTemplate + } + if conf.GitTagTemplate == "" { + conf.GitTagTemplate = config.DefaultGitTagTemplate + } + if conf.GitTagMessageTemplate == "" { + conf.GitTagMessageTemplate = config.DefaultGitTagMessageTemplate + } + + b, err := yaml.Marshal(conf) + if err != nil { + return err + } + printColorOpts(vb.Options, string(b), ColorBlue) + fmt.Println() + + err = vb.Show("") + return err } func (vb *VersionBump) GitMetadata() (*config.GitMeta, error) { @@ -77,8 +151,8 @@ func (vb *VersionBump) GitMetadata() (*config.GitMeta, error) { } else { commitMessageTemplate = config.DefaultGitCommitTemplate } - commitMessage := utils.ReplaceInString(commitMessageTemplate, "{old}", vb.OldVersion) - commitMessage = utils.ReplaceInString(commitMessage, "{new}", vb.NewVersion) + commitMessage := utils.ReplaceInString(commitMessageTemplate, "{old}", vb.GetOldVersion()) + commitMessage = utils.ReplaceInString(commitMessage, "{new}", vb.GetNewVersion()) var tagTemplate string if vb.Config.GitTagTemplate != "" { @@ -86,8 +160,8 @@ func (vb *VersionBump) GitMetadata() (*config.GitMeta, error) { } else { tagTemplate = config.DefaultGitTagTemplate } - tagName := utils.ReplaceInString(tagTemplate, "{old}", vb.OldVersion) - tagName = utils.ReplaceInString(tagName, "{new}", vb.NewVersion) + tagName := utils.ReplaceInString(tagTemplate, "{old}", vb.GetOldVersion()) + tagName = utils.ReplaceInString(tagName, "{new}", vb.GetNewVersion()) var tagMessageTemplate string if vb.Config.GitTagMessageTemplate != "" { @@ -95,8 +169,8 @@ func (vb *VersionBump) GitMetadata() (*config.GitMeta, error) { } else { tagMessageTemplate = config.DefaultGitTagMessageTemplate } - tagMessage := utils.ReplaceInString(tagMessageTemplate, "{old}", vb.OldVersion) - tagMessage = utils.ReplaceInString(tagMessage, "{new}", vb.NewVersion) + tagMessage := utils.ReplaceInString(tagMessageTemplate, "{old}", vb.GetOldVersion()) + tagMessage = utils.ReplaceInString(tagMessage, "{new}", vb.GetNewVersion()) return &config.GitMeta{ CommitMessage: commitMessage, @@ -105,26 +179,13 @@ func (vb *VersionBump) GitMetadata() (*config.GitMeta, error) { }, nil } -// ------------------------- - -// ------------------------- - -func (vb *VersionBump) Run() { - vb.preamble() - vb.gitPreFlight() - vb.logTrackedFiles() - vb.bumpPreflight() - if vb.promptProceedWithChanges() { - vb.makeChanges() - vb.gitCommit() - } -} - func (vb *VersionBump) gitPreFlight() { if vb.Options.NoGit { return } + logVerbose(vb.Options, "Checking git configuration...") + // make sure the `git` command is available if vb.Config.IsGitRequired() { isGitAvalable, version := git.IsGitAvailable() @@ -138,7 +199,7 @@ func (vb *VersionBump) gitPreFlight() { } // check if the parent directory is a Git repository - isGitRepo, err := git.IsGitRepository(vb.ParentDir) + isGitRepo, err := git.IsRepository(vb.ParentDir) if err != nil { logFatal(vb.Options, fmt.Sprintf("Error checking for git repository: %v\n", err)) } @@ -148,11 +209,47 @@ func (vb *VersionBump) gitPreFlight() { logFatal(vb.Options, "The project root is not a Git repository, but Git options are enabled in the "+ "configuration file.") } - if promptUserConfirmation("Do you want to initialize a Git repository in this directory?") { + if promptUserConfirmation("The project directory is not a git repository.\nDo you want to initialize a git repository in the project directory?") { err := git.InitializeGitRepo(vb.ParentDir) if err != nil { logFatal(vb.Options, fmt.Sprintf("Unable to initialize Git repository: %v\n", err)) } + logVerbose(vb.Options, "Initialized Git repository.\nAdding tracked files...") + vb.logTrackedFiles() + err = git.AddFiles(vb.ParentDir, vb.Config.Files...) + if err != nil { + logFatal(vb.Options, fmt.Sprintf("Error adding files to the Git staging area: %v\n", err)) + } + logVerbose(vb.Options, "Performing initial commit.") + err = git.CommitChanges(vb.ParentDir, "Initial commit", vb.Config.GitSign) + if err != nil { + logFatal(vb.Options, fmt.Sprintf("Error committing initial changes: %v\n", err)) + } + } else { + os.Exit(0) + } + } + + branch, err := git.GetCurrentBranch(vb.ParentDir) + if err != nil { + logFatal(vb.Options, fmt.Sprintf("Error getting current branch: %v\n", err)) + } + logVerbose(vb.Options, fmt.Sprintf("Current branch: %s", branch)) + + if vb.Config.GitTag { + // check to see if the tag already exists + logVerbose(vb.Options, "Checking for existing tag...") + gitMeta, err := vb.GitMetadata() + if err != nil { + logFatal(vb.Options, fmt.Sprintf("Unable to get Git metadata: %v\n", err)) + } + tagExists, err := git.TagExists(vb.ParentDir, gitMeta.TagName) + if err != nil { + logFatal(vb.Options, fmt.Sprintf("Error checking for existing tag: %v\n", err)) + } + if tagExists { + logFatal(vb.Options, fmt.Sprintf("Tag '%s' already exists in the git repository. "+ + "Please bump to a different version or remove the existing tag.\n", gitMeta.TagName)) } } @@ -161,9 +258,35 @@ func (vb *VersionBump) gitPreFlight() { if isDirty { logFatal(vb.Options, "The Git repository has pending changes. Please commit or stash them before proceeding.") } + + // check if GPG signing is enabled for commits + signKey, err := git.GetSigningKey(vb.ParentDir) + if err != nil { + logFatal(vb.Options, fmt.Sprintf("Error checking for GPG signing key: %v\n", err)) + } + signByDefault, err := git.IsSigningEnabled(vb.ParentDir) + if err != nil { + logFatal(vb.Options, fmt.Sprintf("Error checking if GPG signing is enabled: %v\n", err)) + } + if signByDefault || vb.Config.GitSign { + logVerbose(vb.Options, "GPG signing of git commits is enabled. Checking configuration...") + } + // sanity check signing key + if (signByDefault || vb.Config.GitSign) && signKey == "" { + logFatal(vb.Options, "GPG signing of git commits is enabled but no signing key is configured. "+ + "Please configure a signing key in git.") + } + if signByDefault && !vb.Config.GitSign { + logWarning(vb.Options, "GPG signing of git commits is enabled by default in the git configuration. "+ + "Consider enabling GPG signing in the VersionBump configuration.") + } + if signByDefault || vb.Config.GitSign { + logVerbose(vb.Options, fmt.Sprintf("Git commits will be signed with GPG key: %s", signKey)) + } + } -// gitPreFlight performs a pre-flight check for Git operations. +// preamble prints the version bump preamble. func (vb *VersionBump) preamble() { logVerbose(vb.Options, vbv.VersionBumpVersion) logVerbose(vb.Options, fmt.Sprintf("Configuration file: %s", vb.Options.ConfigPath)) @@ -202,7 +325,7 @@ func (vb *VersionBump) gitCommit() { // commit changes if vb.Config.GitCommit { logVerbose(vb.Options, "Committing changes...") - err := git.CommitChanges(vb.ParentDir, gitMeta.CommitMessage) + err := git.CommitChanges(vb.ParentDir, gitMeta.CommitMessage, vb.Config.GitSign) if err != nil { fmt.Printf("Error committing changes: %v\n", err) os.Exit(1) @@ -211,14 +334,14 @@ func (vb *VersionBump) gitCommit() { } if vb.Config.GitTag { logVerbose(vb.Options, "Tagging changes...") - err := git.TagChanges(vb.ParentDir, gitMeta.TagName, gitMeta.TagMessage) + err := git.TagChanges(vb.ParentDir, gitMeta.TagName, gitMeta.TagMessage, vb.Config.GitSign) if err != nil { fmt.Printf("Error tagging changes: %v\n", err) os.Exit(1) } logVerbose(vb.Options, fmt.Sprintf( - "Tagged '%s' created with message: %s", + "Tag '%s' created with message: %s", gitMeta.TagName, gitMeta.TagMessage)) } @@ -226,17 +349,17 @@ func (vb *VersionBump) gitCommit() { // bumpPreflight performs a pre-flight check for the version bump operation. func (vb *VersionBump) bumpPreflight() { - if vb.Options.ResetVersion == "" { + if !vb.Options.IsResetVersion() { logVerbose(vb.Options, fmt.Sprintf("Bumping version part: %s", vb.Options.BumpPart)) } else { - logVerbose(vb.Options, fmt.Sprintf("Resetting version to: %s", vb.NewVersion)) + logVerbose(vb.Options, fmt.Sprintf("Resetting version to: %s", vb.GetNewVersion())) } - logVerbose(vb.Options, fmt.Sprintf("Will bump version %s --> %s", vb.OldVersion, vb.NewVersion)) + logVerbose(vb.Options, fmt.Sprintf("Will bump version %s --> %s", vb.GetOldVersion(), vb.GetNewVersion())) // log what changes will be made to each file for _, file := range vb.Config.Files { - find := vbu.ReplaceInString(file.Replace, "{version}", vb.OldVersion) - replace := vbu.ReplaceInString(file.Replace, "{version}", vb.NewVersion) + find := vbu.ReplaceInString(file.Replace, "{version}", vb.GetOldVersion()) + replace := vbu.ReplaceInString(file.Replace, "{version}", vb.GetNewVersion()) logVerbose(vb.Options, file.Path) logVerbose(vb.Options, fmt.Sprintf(" Find: \"%s\"", find)) @@ -249,8 +372,7 @@ func (vb *VersionBump) bumpPreflight() { if count > 0 { logVerbose(vb.Options, fmt.Sprintf(" Found %d replacement(s)", count)) } else { - fmt.Println("ERROR: No replacements found in file: ", file.Path) - os.Exit(1) + logFatal(vb.Options, fmt.Sprintf("No replacements found in file: %s\n", file.Path)) } } } @@ -259,31 +381,29 @@ func (vb *VersionBump) bumpPreflight() { func (vb *VersionBump) makeChanges() { // at this point we have already checked the config and there are no errors for _, file := range vb.Config.Files { - find := vbu.ReplaceInString(file.Replace, "{version}", vb.OldVersion) - replace := vbu.ReplaceInString(file.Replace, "{version}", vb.NewVersion) - - if !vb.Options.DryRun { - var resolvedPath string - if path.IsAbs(file.Path) { - resolvedPath = file.Path - } else { - resolvedPath = path.Join(vb.ParentDir, file.Path) - } - err := vbu.ReplaceInFile(resolvedPath, find, replace) - if err != nil { - fmt.Println(fmt.Errorf("error updating file %s: a%v", file.Path, err)) - os.Exit(1) - } - logVerbose(vb.Options, fmt.Sprintf("Updated file: %s", file.Path)) + find := vbu.ReplaceInString(file.Replace, "{version}", vb.GetOldVersion()) + replace := vbu.ReplaceInString(file.Replace, "{version}", vb.GetNewVersion()) + + var resolvedPath string + if path.IsAbs(file.Path) { + resolvedPath = file.Path + } else { + resolvedPath = path.Join(vb.ParentDir, file.Path) } + err := vbu.ReplaceInFile(resolvedPath, find, replace) + if err != nil { + fmt.Println(fmt.Errorf("error updating file %s: a%v", file.Path, err)) + os.Exit(1) + } + logVerbose(vb.Options, fmt.Sprintf("Updated file: %s", file.Path)) } + } // promptProceedWithChanges prompts the user to proceed with the changes. func (vb *VersionBump) promptProceedWithChanges() bool { if !vb.Options.NoPrompt { if !promptUserConfirmation("Proceed with the changes?") { - logWarning(vb.Options, "Cancelled by user.") os.Exit(0) } } @@ -314,6 +434,7 @@ func promptUserConfirmation(prompt string) bool { if input == "y" { return true } else if input == "n" { + logVerbose(config.Options{}, "Operation canceled by user.") return false } else { printColor("Invalid input. Please enter 'y' or 'n'.", ColorYellow) @@ -322,16 +443,16 @@ func promptUserConfirmation(prompt string) bool { } func logWarning(opts config.Options, msg string) { - printColorOpts(opts, fmt.Sprintf("WARNING: %s", msg), ColorYellow) + printColorOpts(opts, fmt.Sprintf("WARNING: %s\n", msg), ColorYellow) } func logFatal(opts config.Options, msg string) { - printColorOpts(opts, fmt.Sprintf("ERROR: %s", msg), ColorRed) + printColorOpts(opts, fmt.Sprintf("ERROR: %s\n", msg), ColorRed) os.Exit(1) } func logVerbose(opts config.Options, msg string) { - if opts.DryRun || !opts.Quiet { + if !opts.Quiet { printColorOpts(opts, fmt.Sprintf("%s\n", msg), ColorLightGray) } } diff --git a/internal/versionbump_test.go b/internal/versionbump_test.go index 99c0e74..36ded76 100644 --- a/internal/versionbump_test.go +++ b/internal/versionbump_test.go @@ -40,20 +40,22 @@ func TestNewVersionBump(t *testing.T) { vb, err := NewVersionBump(options) assert.NoError(t, err) - assert.Equal(t, "1.0.0", vb.OldVersion) - assert.Equal(t, "1.0.1", vb.NewVersion) + assert.Equal(t, "1.0.0", vb.GetOldVersion()) + assert.Equal(t, "1.0.1", vb.GetNewVersion()) assert.Equal(t, dir, vb.ParentDir) } func TestGitMetadata(t *testing.T) { vb := &VersionBump{ Config: config.Config{ + Version: "1.0.0", GitCommitTemplate: "Commit {old} to {new}", GitTagTemplate: "v{new}", GitTagMessageTemplate: "Tagging version {new}", }, - OldVersion: "1.0.0", - NewVersion: "1.0.1", + Options: config.Options{ + BumpPart: "patch", + }, } gitMeta, err := vb.GitMetadata() @@ -62,13 +64,3 @@ func TestGitMetadata(t *testing.T) { assert.Equal(t, "v1.0.1", gitMeta.TagName) assert.Equal(t, "Tagging version 1.0.1", gitMeta.TagMessage) } - -//func verifyFileContent(t *testing.T, filePath, expectedContent string) { -// content, err := os.ReadFile(filePath) -// if err != nil { -// t.Fatalf("Failed to read file %s: %v", filePath, err) -// } -// if string(content) != expectedContent { -// t.Errorf("Expected content '%s', but got '%s'", expectedContent, string(content)) -// } -//} diff --git a/versionbump.yaml b/versionbump.yaml index 6156928..3dffa6a 100644 --- a/versionbump.yaml +++ b/versionbump.yaml @@ -10,5 +10,9 @@ files: # The files to update with the new version. - path: "README.md" replace: "**Latest Version:** v{version}" + # Rewrite download URLs in the README to point to the new version. + - path: "README.md" + replace: "/v{version}" + - path: "Makefile" replace: "VERSION := \"v{version}\""