From 718736c73e6dac94ea9b354b4d2708609d64a5de Mon Sep 17 00:00:00 2001 From: npikall Date: Tue, 10 Nov 2026 11:35:14 +0100 Subject: [PATCH 1/3] refactor: rename git package to vcs --- internal/svu/svu.go | 16 +++++++------- internal/svu/svu_test.go | 36 +++++++++++++++---------------- internal/{git => vcs}/git.go | 4 ++-- internal/{git => vcs}/git_test.go | 2 +- main.go | 14 ++++++------ pkg/svu/svu.go | 8 +++---- 6 files changed, 40 insertions(+), 40 deletions(-) rename internal/{git => vcs}/git.go (97%) rename internal/{git => vcs}/git_test.go (99%) diff --git a/internal/svu/svu.go b/internal/svu/svu.go index 19a72c1..7df2e1a 100644 --- a/internal/svu/svu.go +++ b/internal/svu/svu.go @@ -11,7 +11,7 @@ import ( "strings" "github.com/Masterminds/semver/v3" - "github.com/caarlos0/svu/v3/internal/git" + "github.com/caarlos0/svu/v3/internal/vcs" ) type Action uint @@ -60,7 +60,7 @@ type VersionInfo struct { } func Version(opts Options) (string, error) { - tag, err := git.DescribeTag(opts.Ctx, opts.TagMode, opts.Pattern) + tag, err := vcs.DescribeTag(opts.Ctx, opts.TagMode, opts.Pattern) if err != nil { return "", fmt.Errorf("failed to get current tag for repo: %w", err) } @@ -204,7 +204,7 @@ func findNextWithGitLog( tag string, opts Options, ) (semver.Version, error) { - log, err := git.Changelog(opts.Ctx, tag, opts.Directories) + log, err := vcs.Changelog(opts.Ctx, tag, opts.Directories) if err != nil { return semver.Version{}, fmt.Errorf("failed to get changelog: %w", err) } @@ -212,20 +212,20 @@ func findNextWithGitLog( return findNext(current, log, opts), nil } -func isBreaking(commit git.Commit) bool { +func isBreaking(commit vcs.Commit) bool { return breakingBody.MatchString(commit.Body) || breaking.MatchString(commit.Title) } -func isFeature(commit git.Commit) bool { +func isFeature(commit vcs.Commit) bool { return feature.MatchString(commit.Title) } -func isPatch(commit git.Commit) bool { +func isPatch(commit vcs.Commit) bool { return patch.MatchString(commit.Title) } -func findNext(current *semver.Version, changes []git.Commit, opts Options) semver.Version { - var major, minor, patch *git.Commit +func findNext(current *semver.Version, changes []vcs.Commit, opts Options) semver.Version { + var major, minor, patch *vcs.Commit for _, commit := range changes { if isBreaking(commit) { major = &commit diff --git a/internal/svu/svu_test.go b/internal/svu/svu_test.go index d1f3d9e..1d10333 100644 --- a/internal/svu/svu_test.go +++ b/internal/svu/svu_test.go @@ -5,12 +5,12 @@ import ( "testing" "github.com/Masterminds/semver/v3" - "github.com/caarlos0/svu/v3/internal/git" + "github.com/caarlos0/svu/v3/internal/vcs" "github.com/stretchr/testify/require" ) func TestIsBreaking(t *testing.T) { - for _, commit := range []git.Commit{ + for _, commit := range []vcs.Commit{ {Title: "feat!: foo"}, {Title: "chore(lala)!: foo"}, {Title: "docs: lalala", Body: "BREAKING CHANGE: lalal"}, @@ -21,7 +21,7 @@ func TestIsBreaking(t *testing.T) { }) } - for _, commit := range []git.Commit{ + for _, commit := range []vcs.Commit{ {Title: "feat: foo"}, {Title: "chore(lol): foo"}, {Title: "docs: lalala"}, @@ -36,7 +36,7 @@ func TestIsBreaking(t *testing.T) { } func TestIsFeature(t *testing.T) { - for _, commit := range []git.Commit{ + for _, commit := range []vcs.Commit{ {Title: "feat: foo"}, {Title: "feat(lalal): foobar"}, } { @@ -45,7 +45,7 @@ func TestIsFeature(t *testing.T) { }) } - for _, commit := range []git.Commit{ + for _, commit := range []vcs.Commit{ {Title: "fix: foo"}, {Title: "chore: foo"}, {Title: "docs: lalala"}, @@ -61,7 +61,7 @@ func TestIsFeature(t *testing.T) { } func TestIsPatch(t *testing.T) { - for _, commit := range []git.Commit{ + for _, commit := range []vcs.Commit{ {Title: "fix: foo"}, {Title: "fix(lalal): lalala"}, } { @@ -70,7 +70,7 @@ func TestIsPatch(t *testing.T) { }) } - for _, commit := range []git.Commit{ + for _, commit := range []vcs.Commit{ {Title: "chore: foobar"}, {Title: "docs: something"}, {Title: "invalid commit"}, @@ -88,17 +88,17 @@ func TestFindNext(t *testing.T) { version2 := semver.MustParse("v2.4.12") version3 := semver.MustParse("v3.4.5-beta34+ads") for expected, next := range map[string]semver.Version{ - "0.4.5": findNext(version0a, []git.Commit{{Title: "chore: should do nothing"}}, Options{Ctx: t.Context()}), - "0.4.6": findNext(version0a, []git.Commit{{Title: "fix: inc patch"}}, Options{Ctx: t.Context()}), - "0.5.0": findNext(version0a, []git.Commit{{Title: "feat: inc minor"}}, Options{Ctx: t.Context()}), - "1.0.0": findNext(version0b, []git.Commit{{Title: "feat!: inc minor"}}, Options{Ctx: t.Context()}), - "0.6.0": findNext(version0b, []git.Commit{{Title: "feat!: inc minor"}}, Options{Ctx: t.Context(), KeepV0: true}), - "1.2.3": findNext(version1, []git.Commit{{Title: "chore: should do nothing"}}, Options{Ctx: t.Context()}), - "1.2.4": findNext(version1, []git.Commit{{Title: "chore: always"}}, Options{Ctx: t.Context(), Always: true}), - "1.3.0": findNext(version1, []git.Commit{{Title: "feat: inc major"}}, Options{Ctx: t.Context()}), - "2.0.0": findNext(version1, []git.Commit{{Title: "chore!: hashbang incs major"}}, Options{Ctx: t.Context()}), - "3.0.0": findNext(version2, []git.Commit{{Title: "feat: something", Body: "BREAKING CHANGE: increases major"}}, Options{Ctx: t.Context()}), - "3.5.0": findNext(version3, []git.Commit{{Title: "feat: inc major"}}, Options{Ctx: t.Context()}), + "0.4.5": findNext(version0a, []vcs.Commit{{Title: "chore: should do nothing"}}, Options{Ctx: t.Context()}), + "0.4.6": findNext(version0a, []vcs.Commit{{Title: "fix: inc patch"}}, Options{Ctx: t.Context()}), + "0.5.0": findNext(version0a, []vcs.Commit{{Title: "feat: inc minor"}}, Options{Ctx: t.Context()}), + "1.0.0": findNext(version0b, []vcs.Commit{{Title: "feat!: inc minor"}}, Options{Ctx: t.Context()}), + "0.6.0": findNext(version0b, []vcs.Commit{{Title: "feat!: inc minor"}}, Options{Ctx: t.Context(), KeepV0: true}), + "1.2.3": findNext(version1, []vcs.Commit{{Title: "chore: should do nothing"}}, Options{Ctx: t.Context()}), + "1.2.4": findNext(version1, []vcs.Commit{{Title: "chore: always"}}, Options{Ctx: t.Context(), Always: true}), + "1.3.0": findNext(version1, []vcs.Commit{{Title: "feat: inc major"}}, Options{Ctx: t.Context()}), + "2.0.0": findNext(version1, []vcs.Commit{{Title: "chore!: hashbang incs major"}}, Options{Ctx: t.Context()}), + "3.0.0": findNext(version2, []vcs.Commit{{Title: "feat: something", Body: "BREAKING CHANGE: increases major"}}, Options{Ctx: t.Context()}), + "3.5.0": findNext(version3, []vcs.Commit{{Title: "feat: inc major"}}, Options{Ctx: t.Context()}), } { t.Run(expected, func(t *testing.T) { require.Equal(t, expected, next.String()) diff --git a/internal/git/git.go b/internal/vcs/git.go similarity index 97% rename from internal/git/git.go rename to internal/vcs/git.go index 8fd6a81..8a56568 100644 --- a/internal/git/git.go +++ b/internal/vcs/git.go @@ -1,5 +1,5 @@ -// Package git provides git operations for version management. -package git +// Package vcs provides git operations for version management. +package vcs import ( "context" diff --git a/internal/git/git_test.go b/internal/vcs/git_test.go similarity index 99% rename from internal/git/git_test.go rename to internal/vcs/git_test.go index 78cf4b2..4181a81 100644 --- a/internal/git/git_test.go +++ b/internal/vcs/git_test.go @@ -1,4 +1,4 @@ -package git +package vcs import ( "context" diff --git a/main.go b/main.go index ec7a2e8..594a0db 100644 --- a/main.go +++ b/main.go @@ -12,8 +12,8 @@ import ( "charm.land/fang/v2" goversion "github.com/caarlos0/go-version" - "github.com/caarlos0/svu/v3/internal/git" "github.com/caarlos0/svu/v3/internal/svu" + "github.com/caarlos0/svu/v3/internal/vcs" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -49,13 +49,13 @@ func main() { SilenceUsage: true, PersistentPreRunE: func(*cobra.Command, []string) error { switch opts.TagMode { - case git.TagModeAll, git.TagModeCurrent: + case vcs.TagModeAll, vcs.TagModeCurrent: default: return fmt.Errorf( "invalid tag-mode: %q: valid options are %q and %q", opts.TagMode, - git.TagModeCurrent, - git.TagModeAll, + vcs.TagModeCurrent, + vcs.TagModeAll, ) } @@ -84,7 +84,7 @@ func main() { nextCmd := &cobra.Command{ Use: "next", Aliases: []string{"n"}, - Short: "Next version based on git history", + Short: "Next version based on vcs history", RunE: func(cmd *cobra.Command, _ []string) error { opts.Action = svu.Next return runFunc(cmd) @@ -154,7 +154,7 @@ func main() { cmd.Flags().StringVar(&opts.Pattern, "tag.pattern", "", "ignore tags that do not match the given pattern") cmd.Flags().StringVar(&opts.Prefix, "tag.prefix", "v", "sets a tag custom prefix") cmd.Flags().StringVar(&opts.PrefixOutput, "tag.output", "^tag.prefix^", "set the tag output to use when printing the version (default: 'tag.prefix')") - cmd.Flags().StringVar(&opts.TagMode, "tag.mode", git.TagModeAll, "determine if it should look for tags in all branches, or just the current one") + cmd.Flags().StringVar(&opts.TagMode, "tag.mode", vcs.TagModeAll, "determine if it should look for tags in all branches, or just the current one") cmd.Flags().StringVar(&opts.PreRelease, "prerelease", "", "sets the version prerelease") cmd.Flags().StringVar(&opts.Metadata, "metadata", "", "sets the version metadata") rootCmd.AddCommand(cmd) @@ -175,7 +175,7 @@ func main() { viper.AutomaticEnv() viper.SetEnvPrefix("svu") viper.AddConfigPath(cfgPath) - viper.AddConfigPath(git.Root(context.Background())) + viper.AddConfigPath(vcs.Root(context.Background())) viper.AddConfigPath(config) viper.AddConfigPath(home) viper.SetConfigType("yaml") diff --git a/pkg/svu/svu.go b/pkg/svu/svu.go index fcaaf73..15a490f 100644 --- a/pkg/svu/svu.go +++ b/pkg/svu/svu.go @@ -4,8 +4,8 @@ package svu import ( "context" - "github.com/caarlos0/svu/v3/internal/git" "github.com/caarlos0/svu/v3/internal/svu" + "github.com/caarlos0/svu/v3/internal/vcs" ) type option func(o *svu.Options) @@ -81,14 +81,14 @@ func WithDirectories(directories ...string) Option { // ForCurrentBranch look for tags in the current branch only. func ForCurrentBranch() Option { return func(o *svu.Options) { - o.TagMode = git.TagModeCurrent + o.TagMode = vcs.TagModeCurrent } } // ForAllBranches look for tags in all branches. func ForAllBranches() Option { return func(o *svu.Options) { - o.TagMode = git.TagModeAll + o.TagMode = vcs.TagModeAll } } @@ -112,7 +112,7 @@ func version(opts ...Option) (string, error) { Ctx: context.Background(), Action: svu.Next, Prefix: "v", - TagMode: git.TagModeCurrent, + TagMode: vcs.TagModeCurrent, } for _, opt := range opts { option(opt)(options) From fd54529aabd5b93293e821ee9b4c524d2878892f Mon Sep 17 00:00:00 2001 From: npikall Date: Tue, 10 Nov 2026 11:43:11 +0100 Subject: [PATCH 2/3] refactor: move git behind an interface --- internal/svu/svu.go | 13 +++-- internal/svu/svu_test.go | 40 ++++++------- internal/vcs/git.go | 112 ++++++++---------------------------- internal/vcs/git_test.go | 12 ++-- internal/vcs/repo.go | 121 +++++++++++++++++++++++++++++++++++++++ main.go | 4 +- 6 files changed, 180 insertions(+), 122 deletions(-) create mode 100644 internal/vcs/repo.go diff --git a/internal/svu/svu.go b/internal/svu/svu.go index 7df2e1a..6bfd6b0 100644 --- a/internal/svu/svu.go +++ b/internal/svu/svu.go @@ -60,7 +60,8 @@ type VersionInfo struct { } func Version(opts Options) (string, error) { - tag, err := vcs.DescribeTag(opts.Ctx, opts.TagMode, opts.Pattern) + repo := vcs.Detect(".") + tag, err := repo.DescribeTag(opts.Ctx, opts.TagMode, opts.Pattern) if err != nil { return "", fmt.Errorf("failed to get current tag for repo: %w", err) } @@ -70,7 +71,7 @@ func Version(opts Options) (string, error) { return "", fmt.Errorf("could not get current version from tag: '%s': %w", tag, err) } - result, err := nextVersion(current, tag, opts) + result, err := nextVersion(repo, current, tag, opts) if err != nil { return "", fmt.Errorf("could not get next tag: '%s': %w", tag, err) } @@ -83,6 +84,7 @@ func Version(opts Options) (string, error) { } func nextVersion( + repo vcs.Repo, current *semver.Version, tag string, opts Options, @@ -95,7 +97,7 @@ func nextVersion( var err error switch opts.Action { case Next, PreRelease: - result, err = findNextWithGitLog(current, tag, opts) + result, err = findNextWithLog(repo, current, tag, opts) case Major: result = current.IncMajor() case Minor: @@ -199,12 +201,13 @@ func getCurrentVersion(tag, prefix string) (*semver.Version, error) { return current, err } -func findNextWithGitLog( +func findNextWithLog( + repo vcs.Repo, current *semver.Version, tag string, opts Options, ) (semver.Version, error) { - log, err := vcs.Changelog(opts.Ctx, tag, opts.Directories) + log, err := repo.Changelog(opts.Ctx, tag, opts.Directories) if err != nil { return semver.Version{}, fmt.Errorf("failed to get changelog: %w", err) } diff --git a/internal/svu/svu_test.go b/internal/svu/svu_test.go index 1d10333..b024be8 100644 --- a/internal/svu/svu_test.go +++ b/internal/svu/svu_test.go @@ -110,7 +110,7 @@ func TestCmd(t *testing.T) { ver := func() *semver.Version { return semver.MustParse("1.2.3-pre+123") } t.Run("current", func(t *testing.T) { t.Run("version has meta", func(t *testing.T) { - v, err := nextVersion(ver(), "v1.2.3", Options{ + v, err := nextVersion(vcs.Detect(t.TempDir()), ver(), "v1.2.3", Options{ Ctx: t.Context(), Action: Current, }) @@ -118,7 +118,7 @@ func TestCmd(t *testing.T) { require.Equal(t, "1.2.3-pre+123", v.String()) }) t.Run("version is clean", func(t *testing.T) { - v, err := nextVersion(semver.MustParse("v1.2.3"), "v1.2.3", Options{ + v, err := nextVersion(vcs.Detect(t.TempDir()), semver.MustParse("v1.2.3"), "v1.2.3", Options{ Ctx: t.Context(), Action: Current, }) @@ -129,7 +129,7 @@ func TestCmd(t *testing.T) { t.Run("minor", func(t *testing.T) { t.Run("clean", func(t *testing.T) { - v, err := nextVersion(ver(), "v1.2.3", Options{ + v, err := nextVersion(vcs.Detect(t.TempDir()), ver(), "v1.2.3", Options{ Ctx: t.Context(), Action: Minor, }) @@ -137,7 +137,7 @@ func TestCmd(t *testing.T) { require.Equal(t, "1.3.0", v.String()) }) t.Run("metadata", func(t *testing.T) { - v, err := nextVersion(ver(), "v1.2.3", Options{ + v, err := nextVersion(vcs.Detect(t.TempDir()), ver(), "v1.2.3", Options{ Ctx: t.Context(), Action: Minor, Metadata: "124", @@ -146,7 +146,7 @@ func TestCmd(t *testing.T) { require.Equal(t, "1.3.0+124", v.String()) }) t.Run("prerelease", func(t *testing.T) { - v, err := nextVersion(ver(), "v1.2.3", Options{ + v, err := nextVersion(vcs.Detect(t.TempDir()), ver(), "v1.2.3", Options{ Ctx: t.Context(), Action: Minor, PreRelease: "alpha.1", @@ -155,7 +155,7 @@ func TestCmd(t *testing.T) { require.Equal(t, "1.3.0-alpha.1", v.String()) }) t.Run("all", func(t *testing.T) { - v, err := nextVersion(ver(), "v1.2.3", Options{ + v, err := nextVersion(vcs.Detect(t.TempDir()), ver(), "v1.2.3", Options{ Ctx: t.Context(), Action: Minor, PreRelease: "alpha.2", @@ -168,7 +168,7 @@ func TestCmd(t *testing.T) { t.Run("patch", func(t *testing.T) { t.Run("clean", func(t *testing.T) { - v, err := nextVersion(semver.MustParse("1.2.3"), "v1.2.3", Options{ + v, err := nextVersion(vcs.Detect(t.TempDir()), semver.MustParse("1.2.3"), "v1.2.3", Options{ Ctx: t.Context(), Action: Patch, }) @@ -176,7 +176,7 @@ func TestCmd(t *testing.T) { require.Equal(t, "1.2.4", v.String()) }) t.Run("previous had meta", func(t *testing.T) { - v, err := nextVersion(semver.MustParse("1.2.3-alpha.1+1"), "v1.2.3", Options{ + v, err := nextVersion(vcs.Detect(t.TempDir()), semver.MustParse("1.2.3-alpha.1+1"), "v1.2.3", Options{ Ctx: t.Context(), Action: Patch, }) @@ -184,7 +184,7 @@ func TestCmd(t *testing.T) { require.Equal(t, "1.2.3", v.String()) }) t.Run("previous had meta + always", func(t *testing.T) { - v, err := nextVersion(semver.MustParse("1.2.3-alpha.1+1"), "v1.2.3", Options{ + v, err := nextVersion(vcs.Detect(t.TempDir()), semver.MustParse("1.2.3-alpha.1+1"), "v1.2.3", Options{ Ctx: t.Context(), Action: Patch, Always: true, @@ -193,7 +193,7 @@ func TestCmd(t *testing.T) { require.Equal(t, "1.2.3", v.String()) }) t.Run("previous had meta + always, add meta", func(t *testing.T) { - v, err := nextVersion(semver.MustParse("1.2.3-alpha.1+1"), "v1.2.3-alpha.1+1", Options{ + v, err := nextVersion(vcs.Detect(t.TempDir()), semver.MustParse("1.2.3-alpha.1+1"), "v1.2.3-alpha.1+1", Options{ Ctx: t.Context(), Action: Patch, Always: true, @@ -204,7 +204,7 @@ func TestCmd(t *testing.T) { require.Equal(t, "1.2.3-alpha.2+10", v.String()) }) t.Run("previous had meta, change it", func(t *testing.T) { - v, err := nextVersion(semver.MustParse("1.2.3-alpha.1+1"), "v1.2.3-alpha.1+1", Options{ + v, err := nextVersion(vcs.Detect(t.TempDir()), semver.MustParse("1.2.3-alpha.1+1"), "v1.2.3-alpha.1+1", Options{ Ctx: t.Context(), Action: Patch, PreRelease: "alpha.2", @@ -214,7 +214,7 @@ func TestCmd(t *testing.T) { require.Equal(t, "1.2.3-alpha.2+10", v.String()) }) t.Run("metadata", func(t *testing.T) { - v, err := nextVersion(semver.MustParse("1.2.3"), "v1.2.3", Options{ + v, err := nextVersion(vcs.Detect(t.TempDir()), semver.MustParse("1.2.3"), "v1.2.3", Options{ Ctx: t.Context(), Action: Patch, Metadata: "124", @@ -223,7 +223,7 @@ func TestCmd(t *testing.T) { require.Equal(t, "1.2.4+124", v.String()) }) t.Run("prerelease", func(t *testing.T) { - v, err := nextVersion(semver.MustParse("1.2.3"), "v1.2.3", Options{ + v, err := nextVersion(vcs.Detect(t.TempDir()), semver.MustParse("1.2.3"), "v1.2.3", Options{ Ctx: t.Context(), Action: Patch, PreRelease: "alpha.1", @@ -232,7 +232,7 @@ func TestCmd(t *testing.T) { require.Equal(t, "1.2.4-alpha.1", v.String()) }) t.Run("all meta", func(t *testing.T) { - v, err := nextVersion(semver.MustParse("1.2.3"), "v1.2.3", Options{ + v, err := nextVersion(vcs.Detect(t.TempDir()), semver.MustParse("1.2.3"), "v1.2.3", Options{ Ctx: t.Context(), Action: Patch, Metadata: "125", @@ -245,7 +245,7 @@ func TestCmd(t *testing.T) { t.Run("major", func(t *testing.T) { t.Run("no meta", func(t *testing.T) { - v, err := nextVersion(ver(), "v1.2.3", Options{ + v, err := nextVersion(vcs.Detect(t.TempDir()), ver(), "v1.2.3", Options{ Ctx: t.Context(), Action: Major, }) @@ -253,7 +253,7 @@ func TestCmd(t *testing.T) { require.Equal(t, "2.0.0", v.String()) }) t.Run("metadata", func(t *testing.T) { - v, err := nextVersion(ver(), "v1.2.3", Options{ + v, err := nextVersion(vcs.Detect(t.TempDir()), ver(), "v1.2.3", Options{ Ctx: t.Context(), Action: Major, Metadata: "124", @@ -262,7 +262,7 @@ func TestCmd(t *testing.T) { require.Equal(t, "2.0.0+124", v.String()) }) t.Run("prerelease", func(t *testing.T) { - v, err := nextVersion(ver(), "v1.2.3", Options{ + v, err := nextVersion(vcs.Detect(t.TempDir()), ver(), "v1.2.3", Options{ Ctx: t.Context(), Action: Major, PreRelease: "alpha.1", @@ -271,7 +271,7 @@ func TestCmd(t *testing.T) { require.Equal(t, "2.0.0-alpha.1", v.String()) }) t.Run("all meta", func(t *testing.T) { - v, err := nextVersion(ver(), "v1.2.3", Options{ + v, err := nextVersion(vcs.Detect(t.TempDir()), ver(), "v1.2.3", Options{ Ctx: t.Context(), Action: Major, PreRelease: "alpha.2", @@ -284,11 +284,11 @@ func TestCmd(t *testing.T) { t.Run("errors", func(t *testing.T) { t.Run("invalid build", func(t *testing.T) { - _, err := nextVersion(semver.MustParse("1.2.3"), "v1.2.3", Options{Ctx: t.Context()}) + _, err := nextVersion(vcs.Detect(t.TempDir()), semver.MustParse("1.2.3"), "v1.2.3", Options{Ctx: t.Context()}) require.Error(t, err) }) t.Run("invalid prerelease", func(t *testing.T) { - _, err := nextVersion(semver.MustParse("1.2.3"), "v1.2.3", Options{Ctx: t.Context()}) + _, err := nextVersion(vcs.Detect(t.TempDir()), semver.MustParse("1.2.3"), "v1.2.3", Options{Ctx: t.Context()}) require.Error(t, err) }) }) diff --git a/internal/vcs/git.go b/internal/vcs/git.go index 8a56568..fcff28e 100644 --- a/internal/vcs/git.go +++ b/internal/vcs/git.go @@ -1,4 +1,3 @@ -// Package vcs provides git operations for version management. package vcs import ( @@ -7,121 +6,56 @@ import ( "fmt" "os/exec" "strings" - - "github.com/gobwas/glob" ) -// Commit is a commit with a hash, title (first line of the message), and body -// (rest of the message, not including the title). -type Commit struct { - SHA string - Title string - Body string -} - -func (c Commit) String() string { - return c.SHA + ": " + c.Title + "\n" + c.Body +// gitBackend reads a git repository through the git CLI. +type gitBackend struct { + dir string } -const ( - TagModeAll = "all" - TagModeCurrent = "current" -) - // copied from goreleaser -func Root(ctx context.Context) string { - out, _ := run(ctx, "rev-parse", "--show-toplevel") - return strings.TrimSpace(out) +func (b gitBackend) root(ctx context.Context) (string, error) { + out, err := gitRun(ctx, b.dir, "rev-parse", "--show-toplevel") + return strings.TrimSpace(out), err } -func getAllTags(ctx context.Context, args ...string) ([]string, error) { - tags, err := run(ctx, append([]string{"-c", "versionsort.suffix=-", "tag", "--sort=-version:refname"}, args...)...) +func (b gitBackend) tags(ctx context.Context, tagMode string) ([]string, error) { + args := []string{"-c", "versionsort.suffix=-", "tag", "--sort=-version:refname"} + if tagMode == TagModeCurrent { + args = append(args, "--merged") + } + tags, err := gitRun(ctx, b.dir, args...) if err != nil { return nil, err } return strings.Split(tags, "\n"), nil } -func DescribeTag(ctx context.Context, tagMode string, pattern string) (string, error) { - args := []string{} - if tagMode == TagModeCurrent { - args = []string{"--merged"} - } - tags, err := getAllTags(ctx, args...) - if err != nil { - return "", err - } - - if len(tags) == 0 { - return "", nil - } - if pattern == "" { - return tags[0], nil - } - - g, err := glob.Compile(pattern) - if err != nil { - return "", err - } - for _, tag := range tags { - if g.Match(tag) { - return tag, nil - } +func (b gitBackend) log(ctx context.Context, tag string, dirs []string) (string, error) { + ref := "HEAD" + if tag != "" { + ref = fmt.Sprintf("tags/%s..HEAD", tag) } - return "", fmt.Errorf("no tags match '%s'", pattern) -} - -func Changelog(ctx context.Context, tag string, dirs []string) ([]Commit, error) { - if tag == "" { - return gitLog(ctx, dirs, "HEAD") + args := []string{"log", "--no-decorate", "--no-color", "--format=%H:%B" + commitEnd, ref} + if len(dirs) > 0 { + args = append(args, "--") + args = append(args, dirs...) } - return gitLog(ctx, dirs, fmt.Sprintf("tags/%s..HEAD", tag)) + return gitRun(ctx, b.dir, args...) } -func run(ctx context.Context, args ...string) (string, error) { +func gitRun(ctx context.Context, dir string, args ...string) (string, error) { extraArgs := []string{ "-c", "log.showSignature=false", } args = append(extraArgs, args...) /* #nosec */ cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = dir bts, err := cmd.CombinedOutput() if err != nil { return "", errors.New(string(bts)) } return string(bts), nil } - -func gitLog(ctx context.Context, dirs []string, refs ...string) ([]Commit, error) { - args := []string{"log", "--no-decorate", "--no-color", `--format=%H:%B`} - args = append(args, refs...) - if len(dirs) > 0 { - args = append(args, "--") - args = append(args, dirs...) - } - s, err := run(ctx, args...) - if err != nil { - return nil, err - } - var result []Commit - for commit := range strings.SplitSeq(s, "") { - commit = strings.TrimSpace(commit) - if commit == "" { // accounts for the last split, which will be an empty line - continue - } - - hashEndIdx := strings.Index(commit, ":") - titleEndIdx := strings.Index(commit, "\n") - if titleEndIdx < 0 { - titleEndIdx = len(commit) - } - - result = append(result, Commit{ - commit[:hashEndIdx], - commit[hashEndIdx+1 : titleEndIdx], - commit[min(titleEndIdx+1, len(commit)):], - }) - } - return result, nil -} diff --git a/internal/vcs/git_test.go b/internal/vcs/git_test.go index 4181a81..c9a142f 100644 --- a/internal/vcs/git_test.go +++ b/internal/vcs/git_test.go @@ -33,21 +33,21 @@ func TestDescribeTag(t *testing.T) { } t.Run(TagModeCurrent, func(t *testing.T) { setup(t) - tag, err := DescribeTag(t.Context(), TagModeCurrent, "") + tag, err := Detect(".").DescribeTag(t.Context(), TagModeCurrent, "") require.NoError(t, err) require.Equal(t, "v1.2.4", tag) }) t.Run(TagModeAll, func(t *testing.T) { setup(t) - tag, err := DescribeTag(t.Context(), TagModeAll, "") + tag, err := Detect(".").DescribeTag(t.Context(), TagModeAll, "") require.NoError(t, err) require.Equal(t, "v1.2.5", tag) }) t.Run("pattern", func(t *testing.T) { setup(t) - tag, err := DescribeTag(t.Context(), TagModeCurrent, "pattern-*") + tag, err := Detect(".").DescribeTag(t.Context(), TagModeCurrent, "pattern-*") require.NoError(t, err) require.Equal(t, "pattern-1.2.3", tag) }) @@ -66,7 +66,7 @@ func TestChangelog(t *testing.T) { } { gitCommit(t, msg) } - log, err := Changelog(t.Context(), "v1.2.3", nil) + log, err := Detect(".").Changelog(t.Context(), "v1.2.3", nil) require.NoError(t, err) for _, title := range []string{ "chore: foobar", @@ -107,7 +107,7 @@ func TestChangelogWithDirectory(t *testing.T) { gitCommit(t, "feat: foobar") gitAdd(t, file) gitCommit(t, "chore: filtered dir") - log, err := Changelog(t.Context(), "v1.2.3", []string{localDir}) + log, err := Detect(".").Changelog(t.Context(), "v1.2.3", []string{localDir}) require.NoError(t, err) requireLogContains(t, log, "chore: filtered dir") @@ -184,5 +184,5 @@ func fakeGitRun(ctx context.Context, args ...string) (string, error) { "-c", "log.showSignature=false", } allArgs = append(allArgs, args...) - return run(ctx, allArgs...) + return gitRun(ctx, ".", allArgs...) } diff --git a/internal/vcs/repo.go b/internal/vcs/repo.go new file mode 100644 index 0000000..d72f310 --- /dev/null +++ b/internal/vcs/repo.go @@ -0,0 +1,121 @@ +// Package vcs reads tags and commits from a version control repository. +// Git and non-colocated Jujutsu (jj) repositories are supported. +package vcs + +import ( + "context" + "fmt" + "strings" + + "github.com/gobwas/glob" +) + +const ( + TagModeAll = "all" + TagModeCurrent = "current" +) + +// commitEnd terminates every commit in raw log output. +const commitEnd = "" + +// Commit is a commit with a hash, title (first line of the message), and body +// (rest of the message, not including the title). +type Commit struct { + SHA string + Title string + Body string +} + +func (c Commit) String() string { + return c.SHA + ": " + c.Title + "\n" + c.Body +} + +// backend is what a version control tool must answer. +type backend interface { + // root returns the top level directory of the repository. + root(ctx context.Context) (string, error) + // tags returns the tags visible in tagMode, newest version first. + tags(ctx context.Context, tagMode string) ([]string, error) + // log returns the commits after tag (all when tag is empty) that touch + // dirs, newest first, each formatted as ":" + commitEnd. + log(ctx context.Context, tag string, dirs []string) (string, error) +} + +// Repo is a repository svu reads versions from. +type Repo struct { + backend backend +} + +// Root returns the top level directory of the repository, or "" if unknown. +func (r Repo) Root(ctx context.Context) string { + root, _ := r.backend.root(ctx) + return root +} + +// DescribeTag returns the newest tag matching pattern ("" matches all). +func (r Repo) DescribeTag(ctx context.Context, tagMode string, pattern string) (string, error) { + tags, err := r.backend.tags(ctx, tagMode) + if err != nil { + return "", err + } + return selectTag(tags, pattern) +} + +// Changelog returns the commits since tag that touch dirs, newest first. +func (r Repo) Changelog(ctx context.Context, tag string, dirs []string) ([]Commit, error) { + out, err := r.backend.log(ctx, tag, dirs) + if err != nil { + return nil, err + } + return parseLog(out), nil +} + +func selectTag(tags []string, pattern string) (string, error) { + if len(tags) == 0 { + return "", nil + } + if pattern == "" { + return tags[0], nil + } + + g, err := glob.Compile(pattern) + if err != nil { + return "", err + } + for _, tag := range tags { + if g.Match(tag) { + return tag, nil + } + } + return "", fmt.Errorf("no tags match '%s'", pattern) +} + +func parseLog(s string) []Commit { + var result []Commit + for commit := range strings.SplitSeq(s, commitEnd) { + commit = strings.TrimSpace(commit) + if commit == "" { // accounts for the last split, which will be an empty line + continue + } + + hashEndIdx := strings.Index(commit, ":") + titleEndIdx := strings.Index(commit, "\n") + if titleEndIdx < 0 { + titleEndIdx = len(commit) + } + + result = append(result, Commit{ + commit[:hashEndIdx], + commit[hashEndIdx+1 : titleEndIdx], + commit[min(titleEndIdx+1, len(commit)):], + }) + } + return result +} + +// Detect returns the repository that contains dir. The nearest enclosing +// ".git" or ".jj" decides; a colocated jj repository has both and is read as +// git. Without either, git is used so its errors reach the user. +func Detect(dir string) Repo { + return Repo{gitBackend{dir}} +} diff --git a/main.go b/main.go index 594a0db..be24cd5 100644 --- a/main.go +++ b/main.go @@ -84,7 +84,7 @@ func main() { nextCmd := &cobra.Command{ Use: "next", Aliases: []string{"n"}, - Short: "Next version based on vcs history", + Short: "Next version based on git history", RunE: func(cmd *cobra.Command, _ []string) error { opts.Action = svu.Next return runFunc(cmd) @@ -175,7 +175,7 @@ func main() { viper.AutomaticEnv() viper.SetEnvPrefix("svu") viper.AddConfigPath(cfgPath) - viper.AddConfigPath(vcs.Root(context.Background())) + viper.AddConfigPath(vcs.Detect(".").Root(context.Background())) viper.AddConfigPath(config) viper.AddConfigPath(home) viper.SetConfigType("yaml") From de6ddccacfd64aa89cd72dbbe49d005bdd60450e Mon Sep 17 00:00:00 2001 From: npikall Date: Tue, 10 Nov 2026 11:43:11 +0100 Subject: [PATCH 3/3] feat: support non-colocated jj repositories Detect the nearest .git/.jj marker and read tags and history through the jj CLI when the repo is not colocated. Git code moves behind a backend interface in internal/vcs; tag/log parsing is shared. --- README.md | 6 ++ internal/vcs/jj.go | 125 ++++++++++++++++++++++++ internal/vcs/jj_test.go | 209 ++++++++++++++++++++++++++++++++++++++++ internal/vcs/repo.go | 30 ++++++ 4 files changed, 370 insertions(+) create mode 100644 internal/vcs/jj.go create mode 100644 internal/vcs/jj_test.go diff --git a/README.md b/README.md index 7a73450..e87910d 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,12 @@ version based on this table: > alias gtn='git tag $(svu next)' > ``` +> [!NOTE] +> [Jujutsu (jj)](https://github.com/jj-vcs/jj) repositories that are not +> colocated with git are supported as well: svu reads the tags and the +> history of the working-copy commit (`@`) with the `jj` CLI, skipping commits +> without a description. Colocated repositories are read through git. + ## configuration Every flag option can also be set in a `.svu.yml` in the current diff --git a/internal/vcs/jj.go b/internal/vcs/jj.go new file mode 100644 index 0000000..3b88b25 --- /dev/null +++ b/internal/vcs/jj.go @@ -0,0 +1,125 @@ +package vcs + +import ( + "bytes" + "cmp" + "context" + "errors" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "unicode" + + "github.com/Masterminds/semver/v3" +) + +// jjBackend reads a non-colocated Jujutsu repository through the jj CLI. +// The working-copy commit (@) plays the role of git's HEAD. +type jjBackend struct { + dir string +} + +func (b jjBackend) root(ctx context.Context) (string, error) { + out, err := jjRun(ctx, b.dir, "root") + return strings.TrimSpace(out), err +} + +func (b jjBackend) tags(ctx context.Context, tagMode string) ([]string, error) { + revset := "tags()" + if tagMode == TagModeCurrent { + revset = "tags() & ::@" + } + out, err := jjRun(ctx, b.dir, "log", "--no-graph", "-r", revset, + "-T", `tags.map(|t| t.name()).join("\n") ++ "\n"`) + if err != nil { + return nil, err + } + tags := strings.Fields(out) + sortTagsNewestFirst(tags) + return tags, nil +} + +func (b jjBackend) log(ctx context.Context, tag string, dirs []string) (string, error) { + revset := "::@" + if tag != "" { + revset = "tags(exact:" + strconv.Quote(tag) + ")..@" + } + // undescribed commits (the root, a fresh working copy) carry no + // conventional commit message + revset = "(" + revset + `) ~ description(exact:"")` + args := []string{ + "log", "--no-graph", "-r", revset, + "-T", `commit_id ++ ":" ++ description ++ ` + strconv.Quote(commitEnd), + } + if len(dirs) > 0 { + args = append(args, "--") + for _, dir := range dirs { + args = append(args, "cwd:"+strconv.Quote(realPath(dir))) + } + } + return jjRun(ctx, b.dir, args...) +} + +// realPath resolves symlinks in an absolute path, as jj only accepts paths +// under the real repository root. +func realPath(path string) string { + if !filepath.IsAbs(path) { + return path + } + if realPath, err := filepath.EvalSymlinks(path); err == nil { + return realPath + } + return path +} + +// sortTagsNewestFirst orders tags like git's "-version:refname" sort with +// versionsort.suffix=-: by prefix, then by semantic version, pre-releases +// before their release. Tags without a version go last. +func sortTagsNewestFirst(tags []string) { + slices.SortStableFunc(tags, func(a, b string) int { + return compareTags(b, a) + }) +} + +func compareTags(a, b string) int { + aPrefix, aVersion := splitTag(a) + bPrefix, bVersion := splitTag(b) + switch { + case aVersion == nil && bVersion == nil: + return strings.Compare(a, b) + case aVersion == nil: + return -1 + case bVersion == nil: + return 1 + } + return cmp.Or(strings.Compare(aPrefix, bPrefix), aVersion.Compare(bVersion)) +} + +// splitTag splits "v1.2.3" into "v" and 1.2.3. The version is nil when the +// tag holds none. +func splitTag(tag string) (string, *semver.Version) { + i := strings.IndexFunc(tag, unicode.IsDigit) + if i < 0 { + return tag, nil + } + version, err := semver.NewVersion(tag[i:]) + if err != nil { + return tag, nil + } + return tag[:i], version +} + +func jjRun(ctx context.Context, dir string, args ...string) (string, error) { + /* #nosec */ + cmd := exec.CommandContext(ctx, "jj", append([]string{"--color=never"}, args...)...) + cmd.Dir = dir + var stderr bytes.Buffer + cmd.Stderr = &stderr // jj warns on stderr, keep it out of the output + bts, err := cmd.Output() + if err != nil { + return "", errors.New(stderr.String()) + } + return string(bts), nil +} diff --git a/internal/vcs/jj_test.go b/internal/vcs/jj_test.go new file mode 100644 index 0000000..29005ad --- /dev/null +++ b/internal/vcs/jj_test.go @@ -0,0 +1,209 @@ +package vcs + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestJJDescribeTag(t *testing.T) { + setup := func(tb testing.TB) { + tb.Helper() + tempdir(tb) + jjInit(tb) + jjCommit(tb, "chore: foobar") + jjTag(tb, "pattern-1.2.3") + jjCommit(tb, "lalalala") + jjTag(tb, "v1.2.3") + jjTag(tb, "v1.2.4") // multiple tags in a single commit + jjCommit(tb, "chore: aaafoobar") + jjCommit(tb, "docs: asdsad") + jjCommit(tb, "fix: fooaaa") + jjCommit(tb, "docs: update") // side branch starts here + jjCommit(tb, "foo: bar") + jjTag(tb, "v1.2.5") + jjTag(tb, "v1.2.5-prerelease") + jjCheckout(tb, `tags(exact:"v1.2.5")--`) // back to "fix: fooaaa" + } + t.Run(TagModeCurrent, func(t *testing.T) { + setup(t) + tag, err := Detect(".").DescribeTag(t.Context(), TagModeCurrent, "") + require.NoError(t, err) + require.Equal(t, "v1.2.4", tag) + }) + + t.Run(TagModeAll, func(t *testing.T) { + setup(t) + tag, err := Detect(".").DescribeTag(t.Context(), TagModeAll, "") + require.NoError(t, err) + require.Equal(t, "v1.2.5", tag) + }) + + t.Run("pattern", func(t *testing.T) { + setup(t) + tag, err := Detect(".").DescribeTag(t.Context(), TagModeCurrent, "pattern-*") + require.NoError(t, err) + require.Equal(t, "pattern-1.2.3", tag) + }) + + t.Run("no tags", func(t *testing.T) { + tempdir(t) + jjInit(t) + jjCommit(t, "chore: foobar") + tag, err := Detect(".").DescribeTag(t.Context(), TagModeAll, "") + require.NoError(t, err) + require.Empty(t, tag) + }) +} + +func TestJJChangelog(t *testing.T) { + tempdir(t) + jjInit(t) + jjCommit(t, "chore: foobar") + jjCommit(t, "lalalala") + jjTag(t, "v1.2.3") + for _, msg := range []string{ + "chore: foobar", + "fix: foo", + "feat: foobar\n\nBREAKING CHANGE: body", + } { + jjCommit(t, msg) + } + log, err := Detect(".").Changelog(t.Context(), "v1.2.3", nil) + require.NoError(t, err) + requireLogContains(t, log, "chore: foobar") + requireLogContains(t, log, "fix: foo") + requireLogContains(t, log, "feat: foobar") + requireLogNotContains(t, log, "lalalala") + require.Contains(t, log[0].Body, "BREAKING CHANGE: body") +} + +func TestJJChangelogWithoutTag(t *testing.T) { + tempdir(t) + jjInit(t) + jjCommit(t, "chore: foobar") + jjCommit(t, "fix: foo") + log, err := Detect(".").Changelog(t.Context(), "", nil) + require.NoError(t, err) + require.Len(t, log, 2) // undescribed commits are not part of the history + requireLogContains(t, log, "chore: foobar") + requireLogContains(t, log, "fix: foo") +} + +func TestJJChangelogWithDirectory(t *testing.T) { + tempDir := tempdir(t) + localDir := dir(t, tempDir) + jjInit(t) + jjCommit(t, "chore: foobar") + jjCommit(t, "lalalala") + jjTag(t, "v1.2.3") + jjCommit(t, "feat: foobar") + jjCommit(t, "chore: filtered dir") + tempfile(t, localDir) + log, err := Detect(".").Changelog(t.Context(), "v1.2.3", []string{localDir}) + require.NoError(t, err) + + requireLogContains(t, log, "chore: filtered dir") + requireLogNotContains(t, log, "feat: foobar") +} + +func TestJJRoot(t *testing.T) { + tempDir := tempdir(t) + jjInit(t) + sub := dir(t, tempDir) + root, err := filepath.EvalSymlinks(tempDir) + require.NoError(t, err) + require.Equal(t, root, Detect(sub).Root(t.Context())) +} + +func TestDetect(t *testing.T) { + t.Run("non-colocated jj", func(t *testing.T) { + tempDir := tempdir(t) + jjInit(t) + require.IsType(t, jjBackend{}, Detect(dir(t, tempDir)).backend) + }) + + t.Run("colocated jj uses git", func(t *testing.T) { + tempdir(t) + _, err := fakeJJRun(t.Context(), "git", "init", "--colocate") + require.NoError(t, err) + require.IsType(t, gitBackend{}, Detect(".").backend) + }) + + t.Run("jj nested in git uses jj", func(t *testing.T) { + tempDir := tempdir(t) + gitInit(t) + nested := dir(t, tempDir) + t.Chdir(nested) + jjInit(t) + require.IsType(t, jjBackend{}, Detect(nested).backend) + }) + + t.Run("no repository falls back to git", func(t *testing.T) { + require.IsType(t, gitBackend{}, Detect(t.TempDir()).backend) + }) +} + +func TestSortTagsNewestFirst(t *testing.T) { + tags := []string{ + "v1.9.0", + "pattern-1.2.3", + "v1.10.0-rc.1", + "latest", + "v1.10.0", + "v1.2.3", + } + sortTagsNewestFirst(tags) + require.Equal(t, []string{ + "v1.10.0", + "v1.10.0-rc.1", + "v1.9.0", + "v1.2.3", + "pattern-1.2.3", + "latest", + }, tags) +} + +func jjInit(tb testing.TB) { + tb.Helper() + _, err := fakeJJRun(tb.Context(), "git", "init", "--no-colocate") + require.NoError(tb, err) +} + +// jjCommit starts a new commit on top of the working copy with msg. +func jjCommit(tb testing.TB, msg string) { + tb.Helper() + _, err := fakeJJRun(tb.Context(), "new", "-m", msg) + require.NoError(tb, err) +} + +func jjTag(tb testing.TB, tag string) { + tb.Helper() + _, err := fakeJJRun(tb.Context(), "tag", "set", tag, "-r", "@") + require.NoError(tb, err) +} + +func jjCheckout(tb testing.TB, revset string) { + tb.Helper() + _, err := fakeJJRun(tb.Context(), "new", revset) + require.NoError(tb, err) +} + +func fakeJJRun(ctx context.Context, args ...string) (string, error) { + return jjRun(ctx, ".", append([]string{ + "--config", "user.name=svu", + "--config", "user.email=svu@example.com", + "--config", "signing.behavior=drop", + }, args...)...) +} + +func TestMain(m *testing.M) { + // keep the developer's jj config out of the tests + if err := os.Setenv("JJ_CONFIG", os.DevNull); err != nil { + panic(err) + } + os.Exit(m.Run()) +} diff --git a/internal/vcs/repo.go b/internal/vcs/repo.go index d72f310..a59a882 100644 --- a/internal/vcs/repo.go +++ b/internal/vcs/repo.go @@ -5,6 +5,8 @@ package vcs import ( "context" "fmt" + "os" + "path/filepath" "strings" "github.com/gobwas/glob" @@ -117,5 +119,33 @@ func parseLog(s string) []Commit { // ".git" or ".jj" decides; a colocated jj repository has both and is read as // git. Without either, git is used so its errors reach the user. func Detect(dir string) Repo { + if isJJ(dir) { + return Repo{jjBackend{dir}} + } return Repo{gitBackend{dir}} } + +func isJJ(dir string) bool { + d, err := filepath.Abs(dir) + if err != nil { + return false + } + for { + if exists(filepath.Join(d, ".git")) { + return false + } + if exists(filepath.Join(d, ".jj")) { + return true + } + parent := filepath.Dir(d) + if parent == d { + return false + } + d = parent + } +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +}