diff --git a/README.md b/README.md index d83c1c5..6b4b6eb 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,17 @@ go install github.com/flaticols/bump@latest ```bash # In your git repository -bump # Bumps patch version (e.g., 1.2.3 -> 1.2.4) -bump major # Bumps major version (e.g., 1.2.3 -> 2.0.0) -bump minor # Bumps minor version (e.g., 1.2.3 -> 1.3.0) -bump patch # Bumps patch version (e.g., 1.2.3 -> 1.2.4) +bump # Bumps patch version (e.g., v1.2.3 -> v1.2.4) +bump major # Bumps major version (e.g., v1.2.3 -> v2.0.0) +bump minor # Bumps minor version (e.g., v1.2.3 -> v1.3.0) +bump patch # Bumps patch version (e.g., v1.2.3 -> v1.2.4) + +# Monorepo: specify package name as last argument +bump pkg/x # Bumps patch for pkg/x (e.g., pkg/x/v1.2.3 -> pkg/x/v1.2.4) +bump major pkg/x # Bumps major for pkg/x (e.g., pkg/x/v1.2.3 -> pkg/x/v2.0.0) +bump minor services/api # Works with any prefix structure + +# Other commands bump undo # Removes the latest semver git tag ``` @@ -42,6 +49,8 @@ bump undo # Removes the latest semver git tag --local, -l If local is set, bump will not error if no remotes are found --brave, -b If brave is set, bump will not ask any questions (default: false) --no-color Disable colorful output (default: false) +--json Output a single JSON object to stdout +--prefix Tag prefix to use for monorepo support (e.g., 'pkg/x') --version Print version information ``` @@ -78,6 +87,40 @@ $ bump --brave • tag v1.2.4 pushed ``` +## Monorepo Support + +`bump` supports monorepos where each package has its own version tags with prefixes. Simply specify the package name as the last argument: + +```bash +# Bump version for a specific package in a monorepo +bump pkg/x # Bumps patch version (e.g., pkg/x/v1.2.3 -> pkg/x/v1.2.4) +bump major pkg/x # Bumps major version (e.g., pkg/x/v1.2.3 -> pkg/x/v2.0.0) +bump minor pkg/x # Bumps minor version (e.g., pkg/x/v1.2.3 -> pkg/x/v1.3.0) + +# Different packages in the same repo can have different versions +bump pkg/y # Works independently of pkg/x tags +bump services/api # Can use any prefix structure +bump major libs/utils # Works with all version parts + +# You can also use the --prefix flag for backward compatibility +bump --prefix pkg/x # Same as: bump pkg/x +``` + +The package name is used exactly as provided - if you want `pkg/x/foo/v1.0.0`, use `bump pkg/x/foo`. + +Example output: +```bash +$ bump major pkg/x +• on default branch: main +• no uncommitted changes +• no remote changes +• no unpushed changes +• no new remote tags +• bump tag pkg/x/v1.2.3 => pkg/x/v2.0.0 +• tag pkg/x/v2.0.0 created +• tag pkg/x/v2.0.0 pushed +``` + ## Features - Automatically detects and increments from the latest git tag @@ -88,3 +131,4 @@ $ bump --brave - Provides colorful terminal output with status indicators - Support for brave mode to bypass warnings and continue operations - Allows removing the latest tag with the `undo` command +- Monorepo support with custom tag prefixes (e.g., `pkg/name/vX.X.X`) diff --git a/internal/cmd/bump.go b/internal/cmd/bump.go index 4f752c0..3456b6f 100644 --- a/internal/cmd/bump.go +++ b/internal/cmd/bump.go @@ -6,6 +6,7 @@ import ( "os/exec" "runtime/debug" "slices" + "strings" "github.com/flaticols/bump/internal/git" G "github.com/flaticols/bump/internal/git" @@ -98,70 +99,46 @@ type Options struct { // - bump patch # Bumps the patch version (e.g., v1.2.3 -> v1.2.4). func CreateRootCmd(opts *Options) *cobra.Command { cmd := &cobra.Command{ - Use: "bump [major|minor|patch]", + Use: "bump [major|minor|patch] [package]", Short: "A command-line tool to easily bump the git tag version of your project using semantic versioning", Long: `Bump is a lightweight command-line tool that helps you manage semantic versioning tags in Git repositories. It automates version increments following SemVer standards, making it easy to maintain proper versioning in your projects.`, - Example: " bump # Bumps patch version (e.g., v1.2.3 -> v1.2.4)\n bump major # Bumps major version (e.g., v1.2.3 -> v2.0.0)\n bump minor # Bumps minor version (e.g., v1.2.3 -> v1.3.0)\n bump patch # Bumps patch version (e.g., v1.2.3 -> v1.2.4)", - Args: cobra.OnlyValidArgs, - ValidArgs: []string{major, minor, patch}, + Example: " bump # Bumps patch version (e.g., v1.2.3 -> v1.2.4)\n bump major # Bumps major version (e.g., v1.2.3 -> v2.0.0)\n bump minor # Bumps minor version (e.g., v1.2.3 -> v1.3.0)\n bump patch # Bumps patch version (e.g., v1.2.3 -> v1.2.4)\n bump pkg/x # Bumps patch for pkg/x (e.g., pkg/x/v1.2.3 -> pkg/x/v1.2.4)\n bump major pkg/x # Bumps major for pkg/x (e.g., pkg/x/v1.2.3 -> pkg/x/v2.0.0)", + Args: cobra.MaximumNArgs(2), PersistentPreRunE: func(cmd *cobra.Command, args []string) error { return gitStateChecks(opts) }, RunE: func(cmd *cobra.Command, args []string) error { - ver, err := git.CmdGetTag(opts.Prefix) - var tagErr G.SemVerTagError - var nextVer semver.Version + // Parse arguments and normalize prefix + incPart := getIncPart(args) + opts.Prefix = normalizePrefix(getPackageName(args), opts.Prefix) + + // Get current version or start from 0.0.0 + ver, noTags, err := getCurrentVersion(opts) if err != nil { - if errors.As(err, &tagErr) { - if !tagErr.NoTags { - return fmt.Errorf("invalid semver tag: %s", tagErr.Tag) - } - // No tags: start from 0.0.0 - opts.P.Printf("%s no tags found, using default version %s\n", opts.P.Symbols.Bullet, - opts.P.Version(G.DefaultVersion)) - ver, _ = semver.Parse("0.0.0") - } else { - return err - } + return err } - // Persist current tag (empty if NoTags) - if err == nil { - current := opts.P.Version(ver.String()) - if opts.Prefix != "" { - current = opts.Prefix + current - } - opts.Result.Tag.Current = current - } else if tagErr.NoTags { - opts.Result.Tag.Current = "" + // Store current tag in result + if !noTags { + opts.Result.Tag.Current = formatTag(ver.String(), opts.Prefix, opts.P.Version) } - nextVer = createNewVersion(getIncPart(args), ver) - newTag := opts.P.Version(nextVer.String()) - if opts.Prefix != "" { - newTag = opts.Prefix + newTag - } + // Calculate and create new version + nextVer := createNewVersion(incPart, ver) + newTag := formatTag(nextVer.String(), opts.Prefix, opts.P.Version) opts.Result.Tag.New = newTag - if err != nil && tagErr.NoTags { - opts.P.Printf("%s set tag %s\n", opts.P.Symbols.Ok, newTag) - } else { - prev := opts.P.Version(ver.String()) - if opts.Prefix != "" { - prev = opts.Prefix + prev - } - opts.P.Printf("%s bump tag %s => %s\n", opts.P.Symbols.Bullet, prev, newTag) - } + // Print version change + printVersionChange(opts, noTags, ver.String(), newTag) - err = git.CmdCreateTag(newTag) - if err != nil { + // Create and push tag + if err := git.CmdCreateTag(newTag); err != nil { return err } opts.P.Printf("%s tag %s created\n", opts.P.Symbols.Ok, newTag) if !opts.OnlyLocal { - err = git.CmdPushTag(newTag) - if err != nil { + if err := git.CmdPushTag(newTag); err != nil { return err } opts.P.Printf("%s tag %s pushed\n", opts.P.Symbols.Ok, newTag) @@ -180,6 +157,59 @@ func CreateRootCmd(opts *Options) *cobra.Command { return cmd } +// getCurrentVersion retrieves the current version from git tags or returns 0.0.0 if no tags exist +func getCurrentVersion(opts *Options) (semver.Version, bool, error) { + ver, err := git.CmdGetTag(opts.Prefix) + if err != nil { + var tagErr G.SemVerTagError + if errors.As(err, &tagErr) { + if !tagErr.NoTags { + return semver.Version{}, false, fmt.Errorf("invalid semver tag: %s", tagErr.Tag) + } + // No tags: start from 0.0.0 + opts.P.Printf("%s no tags found, using default version %s\n", + opts.P.Symbols.Bullet, opts.P.Version(G.DefaultVersion)) + ver, _ = semver.Parse("0.0.0") + return ver, true, nil + } + return semver.Version{}, false, err + } + return ver, false, nil +} + +// normalizePrefix ensures the prefix has a trailing slash if non-empty +// Positional package argument takes priority over --prefix flag +func normalizePrefix(pkgName, prefixFlag string) string { + prefix := prefixFlag + if pkgName != "" { + prefix = pkgName + } + + if prefix != "" && !strings.HasSuffix(prefix, "/") { + return prefix + "/" + } + return prefix +} + +// formatTag formats a version string with the prefix and version formatter +func formatTag(version, prefix string, formatter VersionPrinter) string { + tag := formatter(version) + if prefix != "" { + return prefix + tag + } + return tag +} + +// printVersionChange prints the appropriate version change message +func printVersionChange(opts *Options, noTags bool, oldVersion, newTag string) { + if noTags { + opts.P.Printf("%s set tag %s\n", opts.P.Symbols.Ok, newTag) + } else { + prevTag := formatTag(oldVersion, opts.Prefix, opts.P.Version) + opts.P.Printf("%s bump tag %s => %s\n", opts.P.Symbols.Bullet, prevTag, newTag) + } +} + // gitStateChecks performs a series of checks on the Git repository state to ensure // it is in a valid state for further operations. The checks include: // @@ -196,82 +226,105 @@ func CreateRootCmd(opts *Options) *cobra.Command { // - opts (*Options): A pointer to an Options struct containing configuration // and utility methods for performing Git operations and printing messages. func gitStateChecks(opts *Options) error { + // Check current branch branch, err := git.CmdCurrentBranch() - if err != nil { - opts.P.Printf("%s %s\n", opts.P.Symbols.Error, err.Error()) - if !opts.BraveMode { - return err - } + if err := handleGitError(opts, err); err != nil { + return err } - ok := slices.Contains(opts.DefaultBranchs, branch) - if !ok { - opts.P.Printf("%s not on default branch (%s)\n", opts.P.Symbols.Error, branch) - if !opts.BraveMode { - return errors.New("not on default branch") + + if !slices.Contains(opts.DefaultBranchs, branch) { + if err := handleCheckFailure(opts, fmt.Sprintf("not on default branch (%s)", branch), "not on default branch"); err != nil { + return err } } else { opts.P.Printf("%s on default branch (%s)\n", opts.P.Symbols.Ok, branch) } - if yes, err := git.CmdHasLocalChanges(); err != nil { - opts.P.Printf("%s %s\n", opts.P.Symbols.Error, err.Error()) - if !opts.BraveMode { - return err - } - } else if yes { - opts.P.Printf("%s uncommitted changes\n", opts.P.Symbols.Error) - if !opts.BraveMode { - return errors.New("uncommitted changes") - } - } else { - opts.P.Printf("%s no uncommitted changes\n", opts.P.Symbols.Ok) + // Check local changes + if err := checkCondition(opts, git.CmdHasLocalChanges, + "uncommitted changes", "uncommitted changes", + "no uncommitted changes"); err != nil { + return err } + // Remote checks if !opts.OnlyLocal { - if yes, err := git.CmdHasRemoteChanges(); err != nil { - opts.P.Printf("%s %s\n", opts.P.Symbols.Error, err.Error()) - if !opts.BraveMode { - return err - } - } else if yes { - opts.P.Printf("%s remote changes, pull first\n", opts.P.Symbols.Error) - if !opts.BraveMode { - return errors.New("remote changes present") - } - } else { - opts.P.Printf("%s no remote changes\n", opts.P.Symbols.Ok) + if err := checkCondition(opts, git.CmdHasRemoteChanges, + "remote changes, pull first", "remote changes present", + "no remote changes"); err != nil { + return err } - if yes, err := git.CmdHasUnpushedChanges(branch); err != nil { - opts.P.Printf("%s %s\n", opts.P.Symbols.Error, err.Error()) - if !opts.BraveMode { - return err - } - } else if yes { - opts.P.Printf("%s unpushed changes\n", opts.P.Symbols.Error) - if !opts.BraveMode { - return errors.New("unpushed changes present") - } - } else { - opts.P.Printf("%s no unpushed changes\n", opts.P.Symbols.Ok) + if err := checkCondition(opts, func() (bool, error) { return git.CmdHasUnpushedChanges(branch) }, + "unpushed changes", "unpushed changes present", + "no unpushed changes"); err != nil { + return err } - if yes, err := git.CmdHasRemoteUnfetchedTags(); err != nil { - opts.P.Printf("%s %s\n", opts.P.Symbols.Warning, err.Error()) - } else if yes { - opts.P.Printf("%s remote has new tags, fetching tags first\n", opts.P.Symbols.Warning) - fetchCmd := exec.Command("git", "fetch", "--tags") - if err := fetchCmd.Run(); err != nil { - opts.P.Printf("%s failed to fetch tags: %s\n", opts.P.Symbols.Error, err.Error()) - if !opts.BraveMode { - return err - } - } - opts.P.Printf("%s tags fetched successfully\n", opts.P.Symbols.Ok) - } else { - opts.P.Printf("%s no new remote tags\n", opts.P.Symbols.Ok) + if err := handleRemoteTags(opts); err != nil { + return err + } + } + + return nil +} + +// handleGitError handles git command errors with brave mode support +func handleGitError(opts *Options, err error) error { + if err == nil { + return nil + } + opts.P.Printf("%s %s\n", opts.P.Symbols.Error, err.Error()) + if opts.BraveMode { + return nil + } + return err +} + +// handleCheckFailure handles check failures with brave mode support +func handleCheckFailure(opts *Options, message, errMsg string) error { + opts.P.Printf("%s %s\n", opts.P.Symbols.Error, message) + if opts.BraveMode { + return nil + } + return errors.New(errMsg) +} + +// checkCondition runs a boolean check and handles the result +func checkCondition(opts *Options, check func() (bool, error), failMsg, errMsg, okMsg string) error { + result, err := check() + if err != nil { + return handleGitError(opts, err) + } + if result { + return handleCheckFailure(opts, failMsg, errMsg) + } + opts.P.Printf("%s %s\n", opts.P.Symbols.Ok, okMsg) + return nil +} + +// handleRemoteTags checks and fetches remote tags if needed +func handleRemoteTags(opts *Options) error { + yes, err := git.CmdHasRemoteUnfetchedTags() + if err != nil { + opts.P.Printf("%s %s\n", opts.P.Symbols.Warning, err.Error()) + return nil // Non-fatal + } + + if !yes { + opts.P.Printf("%s no new remote tags\n", opts.P.Symbols.Ok) + return nil + } + + opts.P.Printf("%s remote has new tags, fetching tags first\n", opts.P.Symbols.Warning) + fetchCmd := exec.Command("git", "fetch", "--tags") + if err := fetchCmd.Run(); err != nil { + opts.P.Printf("%s failed to fetch tags: %s\n", opts.P.Symbols.Error, err.Error()) + if !opts.BraveMode { + return err } } + opts.P.Printf("%s tags fetched successfully\n", opts.P.Symbols.Ok) return nil } @@ -282,15 +335,48 @@ func handleVersionCommand() string { } // getIncPart returns the semantic version part to increment based on the provided arguments. -// If the input slice contains at least one element, the first element is returned as the part to increment. -// Otherwise, it defaults to returning the patch part. +// If the first argument is a valid version part (major/minor/patch), it is returned. +// Otherwise, it defaults to returning patch. func getIncPart(args []string) semVerPart { if len(args) > 0 { - return args[0] + firstArg := args[0] + // Check if first arg is a valid version part + if firstArg == major || firstArg == minor || firstArg == patch { + return firstArg + } + // First arg is not a version part, so it must be a package name + // Default to patch + return patch } return patch } +// getPackageName extracts the package name from command arguments. +// Returns empty string if no package name is provided. +// Supports two argument patterns: +// - bump # package is first arg +// - bump # package is second arg +func getPackageName(args []string) string { + if len(args) == 0 { + return "" + } + + firstArg := args[0] + + // If we have 2 args, second is always the package name + if len(args) == 2 { + return args[1] + } + + // If we have 1 arg and it's a version part, no package name + if firstArg == major || firstArg == minor || firstArg == patch { + return "" + } + + // Single arg that's not a version part must be the package name + return firstArg +} + // createNewVersion returns a new semantic version by incrementing the specified part of the provided version. // The incPart parameter determines which part of the version to increment: major for a major update, // minor for a minor update, and patch (or any unrecognized value, due to fallthrough) for a patch update. diff --git a/internal/cmd/bump_test.go b/internal/cmd/bump_test.go new file mode 100644 index 0000000..5dd498f --- /dev/null +++ b/internal/cmd/bump_test.go @@ -0,0 +1,136 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestGetIncPart tests the getIncPart function +func TestGetIncPart(t *testing.T) { + testCases := []struct { + name string + args []string + expected semVerPart + }{ + { + name: "No arguments defaults to patch", + args: []string{}, + expected: patch, + }, + { + name: "Major argument", + args: []string{"major"}, + expected: major, + }, + { + name: "Minor argument", + args: []string{"minor"}, + expected: minor, + }, + { + name: "Patch argument", + args: []string{"patch"}, + expected: patch, + }, + { + name: "Package name only defaults to patch", + args: []string{"pkg/x"}, + expected: patch, + }, + { + name: "Major with package name", + args: []string{"major", "pkg/x"}, + expected: major, + }, + { + name: "Minor with package name", + args: []string{"minor", "services/api"}, + expected: minor, + }, + { + name: "Patch with package name", + args: []string{"patch", "libs/utils"}, + expected: patch, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := getIncPart(tc.args) + require.Equal(t, tc.expected, result) + }) + } +} + +// TestGetPackageName tests the getPackageName function +func TestGetPackageName(t *testing.T) { + testCases := []struct { + name string + args []string + expected string + }{ + { + name: "No arguments", + args: []string{}, + expected: "", + }, + { + name: "Major only, no package", + args: []string{"major"}, + expected: "", + }, + { + name: "Minor only, no package", + args: []string{"minor"}, + expected: "", + }, + { + name: "Patch only, no package", + args: []string{"patch"}, + expected: "", + }, + { + name: "Package name only", + args: []string{"pkg/x"}, + expected: "pkg/x", + }, + { + name: "Package name with underscores", + args: []string{"pkg_x"}, + expected: "pkg_x", + }, + { + name: "Package name with nested path", + args: []string{"pkg/x/foo"}, + expected: "pkg/x/foo", + }, + { + name: "Major with package name", + args: []string{"major", "pkg/x"}, + expected: "pkg/x", + }, + { + name: "Minor with package name", + args: []string{"minor", "services/api"}, + expected: "services/api", + }, + { + name: "Patch with package name", + args: []string{"patch", "libs/utils"}, + expected: "libs/utils", + }, + { + name: "Service prefix pattern", + args: []string{"services/api"}, + expected: "services/api", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := getPackageName(tc.args) + require.Equal(t, tc.expected, result) + }) + } +} diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 69fdd3b..ebc206c 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -7,11 +7,34 @@ import ( "github.com/fatih/color" "github.com/flaticols/bump/internal" + "github.com/spf13/cobra" ) func Run() { - // Create color printers for formatted output - opts := &Options{ + opts := createOptions() + rootCmd := setupCommands(opts) + + color.NoColor = opts.NoColor + printStartupMessages(opts) + + if err := internal.SetBumpWd(opts.RepoDirectory); err != nil { + outputJSON(opts) + opts.P.Println(opts.P.Err(err.Error())) + os.Exit(1) + } + + execErr := rootCmd.Execute() + outputJSON(opts) + + if execErr != nil { + opts.P.Println(opts.P.Err(execErr.Error())) + os.Exit(1) + } +} + +// createOptions initializes and returns the Options struct with default values +func createOptions() *Options { + return &Options{ P: TextPrinters{ Err: color.New(color.FgRed).SprintfFunc(), Info: color.New(color.FgBlue).SprintfFunc(), @@ -29,9 +52,13 @@ func Run() { }, DefaultBranchs: []string{"latest", "main", "master", "develop"}, } +} +// setupCommands configures the root command and its flags +func setupCommands(opts *Options) *cobra.Command { rootCmd := CreateRootCmd(opts) + // Register persistent flags pf := rootCmd.PersistentFlags() pf.StringVarP(&opts.RepoDirectory, "repo", "r", "", "path to the repository") pf.BoolVar(&opts.Verbose, "verbose", false, "enable verbose output") @@ -40,97 +67,50 @@ func Run() { pf.BoolVar(&opts.NoColor, "no-color", false, "disable colorful output (default: false)") pf.BoolVar(&opts.JSON, "json", false, "output a single JSON object to stdout") pf.StringVar(&opts.Prefix, "prefix", "", "tag prefix to use (e.g., 'pkg/x')") - rootCmd.ParseFlags(os.Args[1:]) - - opts.Exit = func() { - if !opts.BraveMode { - os.Exit(1) - } - os.Exit(0) - } - - undoCmd := CreateUndoCmd(opts) - rootCmd.AddCommand(undoCmd) + rootCmd.ParseFlags(os.Args[1:]) + rootCmd.AddCommand(CreateUndoCmd(opts)) - color.NoColor = opts.NoColor + return rootCmd +} +// printStartupMessages prints initial status messages if flags are enabled +func printStartupMessages(opts *Options) { if opts.BraveMode { opts.P.Printf("%s brave mode enabled, ignoring warnings and errors\n", opts.P.Symbols.Warning) } - if opts.Verbose { opts.P.Printf("%s working directory: %s\n", opts.P.Symbols.Bullet, opts.RepoDirectory) } +} - err := internal.SetBumpWd(opts.RepoDirectory) - if err != nil { - if opts.JSON { - if opts.Result.Checks == nil { - opts.Result.Checks = []string{} - } - b, _ := json.Marshal(opts.Result) - fmt.Fprintln(os.Stdout, string(b)) - } - opts.P.Println(opts.P.Err(err.Error())) - os.Exit(1) - } - - rootCmd.ErrOrStderr() - execErr := rootCmd.Execute() - - if opts.JSON { - // Always print a single JSON line to stdout - if opts.Result.Checks == nil { - opts.Result.Checks = []string{} - } - b, _ := json.Marshal(opts.Result) - fmt.Fprintln(os.Stdout, string(b)) +// outputJSON outputs the result as JSON if JSON mode is enabled +func outputJSON(opts *Options) { + if !opts.JSON { + return } - - if execErr != nil { - // Print error to stderr and exit 1 - opts.P.Println(opts.P.Err(execErr.Error())) - os.Exit(1) + if opts.Result.Checks == nil { + opts.Result.Checks = []string{} } + b, _ := json.Marshal(opts.Result) + fmt.Fprintln(os.Stdout, string(b)) } -// printfStderr writes a formatted string to the standard error output (os.Stderr). -// It takes a format string and a variadic number of arguments to format the output. -// If an error occurs during writing, the function panics with the encountered error. -// -// Parameters: -// - format: A string specifying the format of the output, similar to fmt.Sprintf. -// - a: Variadic arguments to be formatted according to the format string. +// printfStderr writes formatted output to stderr func printfStderr(format string, a ...any) { - _, err := fmt.Fprintf(os.Stderr, format, a...) - if err != nil { + if _, err := fmt.Fprintf(os.Stderr, format, a...); err != nil { panic(err) } } -// printlnStderr writes a formatted string to the standard error output (os.Stderr). -// It takes a format string and a variadic list of arguments, similar to fmt.Sprintf. -// If an error occurs while writing to os.Stderr, the function will panic. -// -// Parameters: -// - format: A string containing the text to be formatted. -// - a: A variadic list of arguments to be formatted into the string. +// printlnStderr writes formatted output to stderr with a newline func printlnStderr(format string, a ...any) { - _, err := fmt.Fprintln(os.Stderr, fmt.Sprintf(format, a...)) - if err != nil { + if _, err := fmt.Fprintln(os.Stderr, fmt.Sprintf(format, a...)); err != nil { panic(err) } } -// versionPrinter formats the given version string by prefixing it with "v". -// -// Parameters: -// - ver: A string representing the version number. -// -// Returns: -// -// A formatted string with the version number prefixed by "v". +// versionPrinter formats a version string with "v" prefix func versionPrinter(ver string) string { - return fmt.Sprintf("v%s", ver) + return "v" + ver } diff --git a/internal/git/git.go b/internal/git/git.go index c4fe5e4..72434ff 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -26,164 +26,145 @@ func (e SemVerTagError) Error() string { return fmt.Sprintf("error parsing semver tag: '%s'", e.Tag) } -// CmdCurrentBranch checks if the current Git branch is one of the predefined default branches. -// Returns a boolean and an error if one occurs. +// CmdCurrentBranch returns the name of the current Git branch func CmdCurrentBranch() (string, error) { - // Try the normal approach first - cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") - output, err := cmd.CombinedOutput() - // If the command fails, try the fallback method + return getCurrentBranch() +} + +// getCurrentBranch gets the current branch name with fallback +func getCurrentBranch() (string, error) { + // Try rev-parse first (works for most cases) + if branch, err := runGitCommand("rev-parse", "--abbrev-ref", "HEAD"); err == nil { + return strings.TrimSpace(branch), nil + } + + // Fallback to symbolic-ref (works for repos without commits) + branch, err := runGitCommand("symbolic-ref", "HEAD") if err != nil { - // Try using symbolic-ref instead which works for repos without commits - fallbackCmd := exec.Command("git", "symbolic-ref", "HEAD") - fallbackOutput, fallbackErr := fallbackCmd.Output() - if fallbackErr != nil { - return "", fmt.Errorf("failed to get current branch: %w", fallbackErr) - } - // Remove the refs/heads/ prefix from the output - branchRef := strings.TrimSpace(string(fallbackOutput)) - b := strings.TrimPrefix(branchRef, "refs/heads/") - return b, nil + return "", fmt.Errorf("failed to get current branch: %w", err) } - b := strings.TrimSpace(string(output)) - return b, nil + + // Remove refs/heads/ prefix + return strings.TrimPrefix(strings.TrimSpace(branch), "refs/heads/"), nil } -// CmdHasLocalChanges checks for uncommitted changes in the local Git repository by running `git status --porcelain` and returns the status. -func CmdHasLocalChanges() (bool, error) { - // Run git status --porcelain - cmd := exec.Command("git", "status", "--porcelain") +// runGitCommand executes a git command and returns its output +func runGitCommand(args ...string) (string, error) { + cmd := exec.Command("git", args...) output, err := cmd.Output() + if err != nil { + return "", err + } + return string(output), nil +} + +// CmdHasLocalChanges checks for uncommitted changes in the local repository +func CmdHasLocalChanges() (bool, error) { + output, err := runGitCommand("status", "--porcelain") if err != nil { return false, fmt.Errorf("failed to execute git command: %w", err) } + return strings.TrimSpace(output) != "", nil +} - // If output is not empty, there are uncommitted changes - return len(strings.TrimSpace(string(output))) > 0, nil +// hasRemote checks if the repository has any remote configured +func hasRemote() (bool, error) { + output, err := runGitCommand("remote") + if err != nil { + return false, err + } + return strings.TrimSpace(output) != "", nil } -// CmdHasRemoteChanges checks if there are changes in the remote repository that are not present in the local repository. -// It fetches the latest changes from the remote and compares the local branch with the tracking branch to detect differences. +// CmdHasRemoteChanges checks if there are remote changes that need to be pulled func CmdHasRemoteChanges() (bool, error) { - // First check if remotes exist - remoteCmd := exec.Command("git", "remote") - remoteOutput, err := remoteCmd.Output() - - // If no remotes exist - if err != nil || len(strings.TrimSpace(string(remoteOutput))) == 0 { + if ok, _ := hasRemote(); !ok { return false, fmt.Errorf("no remotes found in repository") } - // Fetch the latest changes from remote - fetchCmd := exec.Command("git", "fetch", "origin") - if err := fetchCmd.Run(); err != nil { + // Fetch latest changes + cmd := exec.Command("git", "fetch", "origin") + if err := cmd.Run(); err != nil { return false, fmt.Errorf("failed to fetch from remote: %w", err) } // Get current branch - branchCmd := exec.Command("git", "symbolic-ref", "HEAD") - branchOutput, branchErr := branchCmd.Output() - - // If we can't get the branch, try the fallback - if branchErr != nil { - fallbackCmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") - fallbackOutput, fallbackErr := fallbackCmd.Output() - if fallbackErr != nil { - return false, fmt.Errorf("failed to get current branch: %w", fallbackErr) - } - branchOutput = fallbackOutput + currentBranch, err := getCurrentBranch() + if err != nil { + return false, err } - currentBranch := strings.TrimSpace(string(branchOutput)) - currentBranch = strings.TrimPrefix(currentBranch, "refs/heads/") - - // Check if there are remote changes not in local, first try with origin/main - cmd := exec.Command("git", "log", "HEAD..origin/main", "--oneline") - output, err := cmd.Output() - if err != nil { - // Try with current branch - cmd = exec.Command("git", "log", fmt.Sprintf("HEAD..origin/%s", currentBranch), "--oneline") - output, err = cmd.Output() - if err != nil { - return false, fmt.Errorf("failed to check remote changes: %w", err) + // Check for remote changes (try origin/main first, then current branch) + for _, remoteBranch := range []string{"origin/main", fmt.Sprintf("origin/%s", currentBranch)} { + output, err := runGitCommand("log", fmt.Sprintf("HEAD..%s", remoteBranch), "--oneline") + if err == nil { + return strings.TrimSpace(output) != "", nil } } - return len(strings.TrimSpace(string(output))) > 0, nil + return false, fmt.Errorf("failed to check remote changes") } -// CmdHasUnpushedChanges checks if there are commits in the local branch that haven't been pushed to the remote. +// CmdHasUnpushedChanges checks if there are unpushed commits on the given branch func CmdHasUnpushedChanges(branch string) (bool, error) { - remoteCmd := exec.Command("git", "remote") - remoteOutput, err := remoteCmd.Output() - - if err != nil || len(strings.TrimSpace(string(remoteOutput))) == 0 { + if ok, _ := hasRemote(); !ok { return false, nil } - cmd := exec.Command("git", "rev-list", "--count", fmt.Sprintf("origin/%s..%s", branch, branch)) - output, err := cmd.Output() - if err != nil { - checkRemoteBranchCmd := exec.Command("git", "ls-remote", "--heads", "origin", branch) - remoteBranchOutput, _ := checkRemoteBranchCmd.Output() - - if len(strings.TrimSpace(string(remoteBranchOutput))) == 0 { - checkLocalCommitsCmd := exec.Command("git", "rev-list", "--count", branch) - localCommitsOutput, localErr := checkLocalCommitsCmd.Output() - if localErr != nil { - return false, fmt.Errorf("failed to check local commits: %w", localErr) - } + // Try to count commits ahead of remote + output, err := runGitCommand("rev-list", "--count", fmt.Sprintf("origin/%s..%s", branch, branch)) + if err == nil { + return strings.TrimSpace(output) != "0", nil + } - count := strings.TrimSpace(string(localCommitsOutput)) - return count != "0", nil + // If remote branch doesn't exist, check if we have local commits + remoteBranchOutput, _ := runGitCommand("ls-remote", "--heads", "origin", branch) + if strings.TrimSpace(remoteBranchOutput) == "" { + localCount, err := runGitCommand("rev-list", "--count", branch) + if err != nil { + return false, fmt.Errorf("failed to check local commits: %w", err) } - return false, fmt.Errorf("failed to check unpushed changes: %w", err) + return strings.TrimSpace(localCount) != "0", nil } - count := strings.TrimSpace(string(output)) - return count != "0", nil + + return false, fmt.Errorf("failed to check unpushed changes: %w", err) } -// CmdHasRemoteUnfetchedTags checks if there are tags in the remote repository that haven't been fetched locally. -// Returns true if unfetched tags exist, false otherwise, and an error if the process fails. +// CmdHasRemoteUnfetchedTags checks if there are unfetched tags in the remote repository func CmdHasRemoteUnfetchedTags() (bool, error) { - remoteCmd := exec.Command("git", "remote") - remoteOutput, err := remoteCmd.Output() - if err != nil || len(strings.TrimSpace(string(remoteOutput))) == 0 { + if ok, _ := hasRemote(); !ok { return false, fmt.Errorf("no remotes found in repository") } - localTagsCmd := exec.Command("git", "tag") - localTagsOutput, err := localTagsCmd.Output() + // Get local tags + localTagsOutput, err := runGitCommand("tag") if err != nil { return false, fmt.Errorf("failed to get local tags: %w", err) } - localTags := strings.Split(strings.TrimSpace(string(localTagsOutput)), "\n") + localTagSet := make(map[string]bool) - for _, tag := range localTags { + for _, tag := range strings.Split(strings.TrimSpace(localTagsOutput), "\n") { if tag != "" { localTagSet[tag] = true } } - lsRemoteCmd := exec.Command("git", "ls-remote", "--tags", "origin") - lsRemoteOutput, err := lsRemoteCmd.Output() + // Get remote tags + remoteOutput, err := runGitCommand("ls-remote", "--tags", "origin") if err != nil { return false, fmt.Errorf("failed to list remote tags: %w", err) } - for _, line := range strings.Split(strings.TrimSpace(string(lsRemoteOutput)), "\n") { + // Check for unfetched tags + for _, line := range strings.Split(strings.TrimSpace(remoteOutput), "\n") { if line == "" { continue } parts := strings.Split(line, "\t") - if len(parts) < 2 { - continue - } - refPath := parts[1] - if strings.Contains(refPath, "^{}") { + if len(parts) < 2 || strings.Contains(parts[1], "^{}") { continue } - tagName := strings.TrimPrefix(refPath, "refs/tags/") + tagName := strings.TrimPrefix(parts[1], "refs/tags/") if !localTagSet[tagName] { return true, nil } @@ -192,10 +173,9 @@ func CmdHasRemoteUnfetchedTags() (bool, error) { return false, nil } -// CmdGetTag retrieves the latest Git tag from the current repository. -// Returns the tag as a semver.Version and an error if unsuccessful. -// CmdGetTag retrieves the latest Git tag from the current repository by grouping tags by creation timestamp. -// It returns the highest semver tag from the most recent group of tags that share the same creation timestamp. +// CmdGetTag retrieves the latest semver tag from the repository. +// Tags are grouped by creation timestamp, and the highest semver tag from the most +// recent group is returned. An optional prefix can filter tags (e.g., "pkg/x/"). func CmdGetTag(prefix string) (semver.Version, error) { cmd := exec.Command("git", "for-each-ref", "--sort=-creatordate", "--format=%(refname:short) %(creatordate:iso-strict)", "refs/tags") output, err := cmd.CombinedOutput() diff --git a/internal/git/git_test.go b/internal/git/git_test.go index d0e73d7..8624dd8 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -339,6 +339,96 @@ func TestCheckLocalChanges(t *testing.T) { } } +// TestCmdGetTagWithPrefix tests the CmdGetTag function with prefixes for monorepo support +func TestCmdGetTagWithPrefix(t *testing.T) { + testCases := []struct { + name string + tags []string + prefix string + expectedTag string + expectNoTags bool + expectError bool + }{ + { + name: "No tags in repository", + tags: []string{}, + prefix: "", + expectedTag: "", + expectNoTags: true, + expectError: false, + }, + { + name: "Simple tag without prefix", + tags: []string{"v1.2.3 2024-01-01T00:00:00Z"}, + prefix: "", + expectedTag: "1.2.3", + expectNoTags: false, + expectError: false, + }, + { + name: "Monorepo tag with prefix pkg/x", + tags: []string{"pkg/x/v1.2.3 2024-01-01T00:00:00Z"}, + prefix: "pkg/x/", + expectedTag: "1.2.3", + expectNoTags: false, + expectError: false, + }, + { + name: "Multiple packages, filter by prefix", + tags: []string{"pkg/x/v2.0.0 2024-01-02T00:00:00Z", "pkg/y/v1.5.0 2024-01-02T00:00:00Z", "pkg/x/v1.2.3 2024-01-01T00:00:00Z"}, + prefix: "pkg/x/", + expectedTag: "2.0.0", + expectNoTags: false, + expectError: false, + }, + { + name: "Prefix not found in tags", + tags: []string{"pkg/x/v1.2.3 2024-01-01T00:00:00Z", "pkg/y/v1.5.0 2024-01-01T00:00:00Z"}, + prefix: "pkg/z/", + expectedTag: "", + expectNoTags: true, + expectError: false, + }, + { + name: "Same timestamp, multiple tags with same prefix", + tags: []string{"pkg/x/v2.0.0 2024-01-01T00:00:00Z", "pkg/x/v1.5.0 2024-01-01T00:00:00Z"}, + prefix: "pkg/x/", + expectedTag: "2.0.0", + expectNoTags: false, + expectError: false, + }, + { + name: "Mixed tags with and without prefix", + tags: []string{"v3.0.0 2024-01-02T00:00:00Z", "pkg/x/v2.0.0 2024-01-01T00:00:00Z"}, + prefix: "pkg/x/", + expectedTag: "2.0.0", + expectNoTags: false, + expectError: false, + }, + { + name: "Service prefix pattern", + tags: []string{"services/api/v1.2.3 2024-01-01T00:00:00Z", "services/web/v2.0.0 2024-01-01T00:00:00Z"}, + prefix: "services/api/", + expectedTag: "1.2.3", + expectNoTags: false, + expectError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Note: This is a conceptual test. In a real scenario, you would need to: + // 1. Create a test git repository + // 2. Add the test tags + // 3. Call CmdGetTag with the prefix + // 4. Verify the results + // For now, this serves as documentation of expected behavior + t.Logf("Test case: %s", tc.name) + t.Logf("Expected tag: %s, Prefix: %s, ExpectNoTags: %v", tc.expectedTag, tc.prefix, tc.expectNoTags) + }) + } +} + // TestIsDefaultBranch tests the IsDefaultBranch method func TestIsDefaultBranch(t *testing.T) { testCases := []struct {