diff --git a/cmd/git-semver/git-semver.go b/cmd/git-semver/git-semver.go index 5219a0c..68c5858 100644 --- a/cmd/git-semver/git-semver.go +++ b/cmd/git-semver/git-semver.go @@ -3,11 +3,11 @@ package git_semver import ( "fmt" "log" - "strings" + "os" "github.com/Masterminds/semver" - git_semver "github.com/crqra/git-semver/internal/git-semver" - git "github.com/libgit2/git2go/v31" + "github.com/carlsberg/git-semver/internal/git" + git_semver "github.com/carlsberg/git-semver/internal/git-semver" "github.com/spf13/cobra" ) @@ -20,75 +20,33 @@ var bumpCmd = &cobra.Command{ Use: "bump", Short: "Bumps the latest version to the next version and tags it", Run: func(cmd *cobra.Command, args []string) { - repo, err := git_semver.OpenRepository("./") - if err != nil { - log.Fatal(err) - } - - versions, err := git_semver.ListVersions(repo) - if err != nil { - log.Fatal(err) - } - - versionsLen := len(versions) - 1 - - var commits []*git.Commit - var latestVersion *semver.Version - - if versionsLen >= 0 { - latestVersion = versions[versionsLen] - - commits, err = git_semver.ListCommitsInRange(repo, latestVersion.String(), "HEAD") - if err != nil { - log.Fatal(err) - } - } else { - latestVersion, err = semver.NewVersion("0.0.0") - if err != nil { - log.Fatal(err) + project := newProjectOrPanic(cmd) + versionFilenamesAndKeys := getVersionFilenamesAndKeysOrFail(cmd) + latest := getLatestVersionOrFail(project) + next := getNextVersionOrFail(project) + + if len(versionFilenamesAndKeys) > 0 { + for _, filenameAndKey := range versionFilenamesAndKeys { + updateVersionFileOrPanic(filenameAndKey, latest, next) } - commits, err = git_semver.ListCommits(repo) - if err != nil { + if err := git.CreateCommit(project.Repo(), fmt.Sprintf("bump: %s -> %s", latest.String(), next.String())); err != nil { log.Fatal(err) } } - increment := git_semver.DetectIncrement(commits) - - if increment == git_semver.None { - log.Fatal("No increment detected to bump the version") - } - - nextVersion := git_semver.BumpVersion(*latestVersion, increment) - - versionFiles := make([]git_semver.VersionFile, 0) - versionFilenamesAndKeys, err := cmd.Flags().GetStringArray("version-file") - if err != nil { - log.Fatal(err) - } - - for _, filenameAndKey := range versionFilenamesAndKeys { - slice := strings.Split(filenameAndKey, ":") + tagName := git_semver.TagNameFromProjectAndVersion(project, next) + tagMessage := fmt.Sprintf("Release %s", tagName) - if len(slice) != 2 { - log.Fatalf("%s is not correctly formatted. Should be `filename:key`", filenameAndKey) - } - - versionFiles = append(versionFiles, git_semver.VersionFile{Filename: slice[0], Key: slice[1]}) - } - - if err := git_semver.UpdateVersionFiles(repo, versionFiles, *latestVersion, nextVersion); err != nil { + if err := git.CreateTag(project.Repo(), tagName, tagMessage); err != nil { log.Fatal(err) } - if err := git_semver.TagVersion(repo, nextVersion); err != nil { + if err := git.PushTagToRemotes(project.Repo(), tagName); err != nil { log.Fatal(err) } - if err := git_semver.PushVersionTagToRemotes(repo, nextVersion); err != nil { - log.Fatal(err) - } + fmt.Printf("bump %s from %s to %s\n", project.Dir(), latest, next) }, } @@ -96,44 +54,10 @@ var nextCmd = &cobra.Command{ Use: "next", Short: "Outputs the next unreleased version", Run: func(cmd *cobra.Command, args []string) { - repo, err := git_semver.OpenRepository("./") - if err != nil { - log.Fatal(err) - } - - versions, err := git_semver.ListVersions(repo) - if err != nil { - log.Fatal(err) - } - - versionsLen := len(versions) - 1 - - var commits []*git.Commit - var latestVersion *semver.Version - - if versionsLen >= 0 { - latestVersion = versions[versionsLen] - - commits, err = git_semver.ListCommitsInRange(repo, latestVersion.String(), "HEAD") - if err != nil { - log.Fatal(err) - } - } else { - latestVersion, err = semver.NewVersion("0.0.0") - if err != nil { - log.Fatal(err) - } - - commits, err = git_semver.ListCommits(repo) - if err != nil { - log.Fatal(err) - } - } - - increment := git_semver.DetectIncrement(commits) - nextVersion := git_semver.BumpVersion(*latestVersion, increment) + project := newProjectOrPanic(cmd) + next := getNextVersionOrFail(project) - fmt.Println(nextVersion.String()) + fmt.Println(next.String()) }, } @@ -141,23 +65,10 @@ var latestCmd = &cobra.Command{ Use: "latest", Short: "Outputs the latest released version", Run: func(cmd *cobra.Command, args []string) { - repo, err := git_semver.OpenRepository("./") - if err != nil { - log.Fatal(err) - } - - versions, err := git_semver.ListVersions(repo) - if err != nil { - log.Fatal(err) - } - - versionsLen := len(versions) - 1 - - if versionsLen < 0 { - log.Fatal("No released versions found") - } + project := newProjectOrPanic(cmd) + latest := getLatestVersionOrFail(project) - fmt.Println(versions[versionsLen].String()) + fmt.Println(latest.String()) }, } @@ -169,6 +80,65 @@ func init() { rootCmd.AddCommand(bumpCmd) rootCmd.AddCommand(nextCmd) rootCmd.AddCommand(latestCmd) + rootCmd.PersistentFlags().StringP("project", "p", "", "Project") bumpCmd.Flags().StringArrayP("version-file", "f", make([]string, 0), "Specify version files to be updated with the new version in the format `filename:key` (i.e. `package.json:\"version\"`)") } + +func newProjectOrPanic(cmd *cobra.Command) *git_semver.Project { + cwd, err := os.Getwd() + if err != nil { + log.Fatal(err) + } + + dir, err := cmd.Flags().GetString("project") + if err != nil { + log.Fatal(err) + } + + project, err := git_semver.NewProject(cwd, dir) + if err != nil { + log.Fatal(err) + } + + return project +} + +func updateVersionFileOrPanic(filenameAndKey string, latest, next *semver.Version) { + vf, err := git_semver.NewVersionFile("./", filenameAndKey) + if err != nil { + log.Fatal(err) + } + + vf.UpdateVersion(latest, next) + if err != nil { + log.Fatal(err) + } +} + +func getLatestVersionOrFail(project *git_semver.Project) *semver.Version { + latest, err := project.LatestVersion() + if err != nil { + log.Fatal(err) + } + + return latest +} + +func getNextVersionOrFail(project *git_semver.Project) *semver.Version { + next, err := project.NextVersion() + if err != nil { + log.Fatal(err) + } + + return next +} + +func getVersionFilenamesAndKeysOrFail(cmd *cobra.Command) []string { + versionFilenamesAndKeys, err := cmd.Flags().GetStringArray("version-file") + if err != nil { + log.Fatal(err) + } + + return versionFilenamesAndKeys +} diff --git a/go.mod b/go.mod index 443c018..cbe1292 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,15 @@ -module github.com/crqra/git-semver +module github.com/carlsberg/git-semver go 1.17 require ( github.com/Masterminds/semver v1.5.0 - github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/libgit2/git2go/v31 v31.6.1 github.com/spf13/cobra v1.2.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c // indirect golang.org/x/sys v0.0.0-20210510120138-977fb7262007 // indirect diff --git a/internal/git-semver/mod.go b/internal/git-semver/mod.go deleted file mode 100644 index 33f1639..0000000 --- a/internal/git-semver/mod.go +++ /dev/null @@ -1,298 +0,0 @@ -package git_semver - -import ( - "fmt" - "io/fs" - "io/ioutil" - "log" - "regexp" - "sort" - "strings" - - "github.com/Masterminds/semver" - git "github.com/libgit2/git2go/v31" -) - -type Increment int64 -type Change string -type VersionFile struct { - Filename string - Key string -} - -const ( - Major Increment = 4 - Minor Increment = 3 - Patch Increment = 1 - None Increment = 0 -) - -const ( - Feature Change = "feat" - Fix Change = "fix" - Refactor Change = "refactor" - BreakingChange Change = "BREAKING CHANGE" -) - -func OpenRepository(path string) (*git.Repository, error) { - return git.OpenRepository(path) -} - -func ListVersions(repo *git.Repository) ([]*semver.Version, error) { - tags, err := repo.Tags.List() - if err != nil { - return make([]*semver.Version, 0), err - } - - var versions []*semver.Version - - reg, err := regexp.Compile(semver.SemVerRegex) - if err != nil { - return make([]*semver.Version, 0), err - } - - for _, tag := range tags { - if !reg.MatchString(tag) { - continue - } - - version, err := semver.NewVersion(tag) - if err != nil { - return make([]*semver.Version, 0), err - } - - versions = append(versions, version) - } - - sort.Sort(semver.Collection(versions)) - - return versions, nil -} - -func CreateCommit(repo *git.Repository, message string) error { - sig, err := repo.DefaultSignature() - if err != nil { - return err - } - - head, err := repo.Head() - if err != nil { - return err - } - - idx, err := repo.Index() - if err != nil { - return err - } - - idx.AddAll(make([]string, 0), git.IndexAddDefault, func(_, _ string) int { - return 0 - }) - - treeId, err := idx.WriteTree() - if err != nil { - return err - } - - err = idx.Write() - if err != nil { - return err - } - - tree, err := repo.LookupTree(treeId) - if err != nil { - return err - } - - commitTarget, err := repo.LookupCommit(head.Target()) - if err != nil { - return err - } - - _, err = repo.CreateCommit("refs/heads/main", sig, sig, message, tree, commitTarget) - if err != nil { - return err - } - - return nil -} - -func ListCommits(repo *git.Repository) ([]*git.Commit, error) { - revwalk, err := repo.Walk() - if err != nil { - return make([]*git.Commit, 0), err - } - - if err := revwalk.PushHead(); err != nil { - return make([]*git.Commit, 0), err - } - - var commits []*git.Commit - - err = revwalk.Iterate(func(commit *git.Commit) bool { - commits = append(commits, commit) - return true - }) - if err != nil { - return make([]*git.Commit, 0), err - } - - return commits, nil -} - -func ListCommitsInRange(repo *git.Repository, lRange string, rRange string) ([]*git.Commit, error) { - revwalk, err := repo.Walk() - if err != nil { - return make([]*git.Commit, 0), err - } - - if err := revwalk.PushRange(fmt.Sprintf("%s..%s", lRange, rRange)); err != nil { - return make([]*git.Commit, 0), err - } - - var commits []*git.Commit - - err = revwalk.Iterate(func(commit *git.Commit) bool { - commits = append(commits, commit) - return true - }) - if err != nil { - return make([]*git.Commit, 0), err - } - - return commits, nil -} - -func DetectIncrement(commits []*git.Commit) Increment { - var increment = None - - for _, commit := range commits { - var commitMessageArr = strings.Split(commit.Message(), ":") - - if len(commitMessageArr) == 0 { - continue - } - - var commitIncrement = None - var commitChange = Change(commitMessageArr[0]) - - if commitChange == Feature { - commitIncrement = Minor - } - - if commitChange == Fix { - commitIncrement = Patch - } - - if commitChange == Refactor || commitChange == BreakingChange { - commitIncrement = Major - } - - if commitIncrement > increment { - increment = commitIncrement - } - } - - return increment -} - -func BumpVersion(version semver.Version, increment Increment) semver.Version { - switch increment { - case Major: - return version.IncMajor() - - case Minor: - return version.IncMinor() - - case Patch: - return version.IncPatch() - } - - return version -} - -func TagVersion(repo *git.Repository, version semver.Version) error { - sig, err := repo.DefaultSignature() - if err != nil { - return err - } - - latestCommitObject, err := repo.RevparseSingle("HEAD") - if err != nil { - return err - } - - latestCommit, err := latestCommitObject.AsCommit() - if err != nil { - return err - } - - repo.Tags.Create(version.String(), latestCommit, sig, fmt.Sprintf("Release %s", version.String())) - - return nil -} - -func PushVersionTagToRemotes(repo *git.Repository, version semver.Version) error { - remotes, err := repo.Remotes.List() - if err != nil { - return err - } - - if len(remotes) == 0 { - log.Printf("No remotes found, skipping pushing tag %s\n", version.String()) - return nil - } - - for _, remote := range remotes { - repo.Remotes.AddPush(remote, version.String()) - } - - return nil -} - -func UpdateVersionFiles(repo *git.Repository, versionFiles []VersionFile, currentVersion semver.Version, nextVersion semver.Version) error { - if len(versionFiles) == 0 { - return nil - } - - for _, versionFile := range versionFiles { - err := updateVersionFileVersion(versionFile, currentVersion, nextVersion) - if err != nil { - return err - } - } - - err := CreateCommit(repo, fmt.Sprintf("bump: %s -> %s", currentVersion.String(), nextVersion.String())) - if err != nil { - return err - } - - return nil -} - -func regexForVersionFileKey(key string, currentVersion semver.Version) (*regexp.Regexp, error) { - return regexp.Compile(fmt.Sprintf("%s(.{1,})?%s", key, currentVersion.String())) -} - -func updateVersionFileVersion(versionFile VersionFile, currentVersion semver.Version, nextVersion semver.Version) error { - r, err := regexForVersionFileKey(versionFile.Key, currentVersion) - if err != nil { - return err - } - - contents, err := ioutil.ReadFile(versionFile.Filename) - if err != nil { - return err - } - - match := string(r.Find(contents)) - newVersionString := strings.Replace(match, currentVersion.String(), nextVersion.String(), 1) - newContents := strings.Replace(string(contents), match, newVersionString, 1) - - err = ioutil.WriteFile(versionFile.Filename, []byte(newContents), fs.ModePerm) - if err != nil { - return err - } - - return nil -} diff --git a/internal/git-semver/project.go b/internal/git-semver/project.go new file mode 100644 index 0000000..e0d047f --- /dev/null +++ b/internal/git-semver/project.go @@ -0,0 +1,225 @@ +package git_semver + +import ( + "errors" + "fmt" + "log" + "regexp" + "sort" + "strings" + + "github.com/Masterminds/semver" + "github.com/carlsberg/git-semver/internal/git" +) + +type Increment int64 +type Change string + +const ( + Major Increment = 4 + Minor Increment = 3 + Patch Increment = 1 + None Increment = 0 +) + +const ( + Feature Change = "feat" + Fix Change = "fix" + Refactor Change = "refactor" + BreakingChange Change = "BREAKING CHANGE" +) + +type Project struct { + dir string + repo *git.Repository +} + +func NewProject(root string, dir string) (*Project, error) { + repo, err := git.OpenRepository(root) + if err != nil { + return &Project{}, err + } + + return &Project{ + dir: dir, + repo: repo, + }, nil +} + +func (p Project) IsSubProject() bool { + return p.dir != "" && p.dir != "/" +} + +func (p Project) Dir() string { + return p.dir +} + +func (p Project) Repo() *git.Repository { + return p.repo +} + +func (p Project) Tags() ([]string, error) { + var reg *regexp.Regexp + var err error + + if p.IsSubProject() { + reg, err = regexp.Compile(fmt.Sprintf("^%s/%s$", p.dir, semver.SemVerRegex)) + } else { + reg, err = regexp.Compile(fmt.Sprintf("^%s$", semver.SemVerRegex)) + } + + if err != nil { + return make([]string, 0), err + } + + tags, err := git.FindTags(p.repo, reg) + if err != nil { + return make([]string, 0), err + } + + return tags, nil +} + +func (p Project) Versions() ([]*semver.Version, error) { + tags, err := p.Tags() + if err != nil { + return make([]*semver.Version, 0), err + } + + var versions []*semver.Version + + reg, err := regexp.Compile(semver.SemVerRegex) + if err != nil { + return make([]*semver.Version, 0), err + } + + for _, tag := range tags { + version, err := semver.NewVersion(reg.FindString(tag)) + if err != nil { + return make([]*semver.Version, 0), err + } + + versions = append(versions, version) + } + + sort.Sort(semver.Collection(versions)) + + return versions, nil +} + +func (p Project) NextVersion() (*semver.Version, error) { + versions, err := p.Versions() + if err != nil { + log.Fatal(err) + } + + var latestVersion *semver.Version + + versionsLen := len(versions) - 1 + + if versionsLen >= 0 { + latestVersion = versions[versionsLen] + } else { + latestVersion, err = semver.NewVersion("0.0.0") + if err != nil { + return &semver.Version{}, err + } + } + + increment, err := p.NextVersionIncrement() + if err != nil { + return &semver.Version{}, err + } + + var nextVersion = *latestVersion + + switch increment { + case Major: + nextVersion = latestVersion.IncMajor() + + case Minor: + nextVersion = latestVersion.IncMinor() + + case Patch: + nextVersion = latestVersion.IncPatch() + } + + return &nextVersion, nil +} + +func (p Project) LatestVersion() (*semver.Version, error) { + versions, err := p.Versions() + if err != nil { + return &semver.Version{}, err + } + + versionsLen := len(versions) - 1 + + if versionsLen < 0 { + return &semver.Version{}, errors.New("no released versions found") + } + + return versions[versionsLen], nil +} + +func (p *Project) NextVersionIncrement() (Increment, error) { + versions, err := p.Versions() + if err != nil { + log.Fatal(err) + } + + versionsLen := len(versions) - 1 + + var commits []*git.Commit + + if versionsLen >= 0 { + commits, err = git.ListCommitsInRange(p.repo, TagNameFromProjectAndVersion(p, versions[versionsLen]), "HEAD") + if err != nil { + return None, err + } + } else { + commits, err = git.ListCommits(p.repo) + if err != nil { + return None, err + } + } + + var increment = None + + for _, commit := range commits { + var commitMessageArr = strings.Split(commit.Message(), ":") + + if len(commitMessageArr) == 0 { + continue + } + + var commitIncrement = None + var commitChange = Change(commitMessageArr[0]) + + if commitChange == Feature { + commitIncrement = Minor + } + + if commitChange == Fix { + commitIncrement = Patch + } + + if commitChange == Refactor || commitChange == BreakingChange { + commitIncrement = Major + } + + if commitIncrement > increment { + increment = commitIncrement + } + } + + return increment, nil +} + +func TagNameFromProjectAndVersion(p *Project, v *semver.Version) string { + if p.IsSubProject() { + return fmt.Sprintf("%s/%s", p.dir, v.Original()) + } + + return v.Original() +} diff --git a/internal/git-semver/version_file.go b/internal/git-semver/version_file.go new file mode 100644 index 0000000..70bd4a1 --- /dev/null +++ b/internal/git-semver/version_file.go @@ -0,0 +1,54 @@ +package git_semver + +import ( + "fmt" + "io/fs" + "io/ioutil" + "regexp" + "strings" + + "github.com/Masterminds/semver" +) + +type VersionFile struct { + Filename string + Key string + // TODO: cwd string +} + +func NewVersionFile(cwd string, filenameAndKey string) (*VersionFile, error) { + slice := strings.Split(filenameAndKey, ":") + + if len(slice) != 2 { + return &VersionFile{}, fmt.Errorf("%s is not correctly formatted. Should be `filename:key`", filenameAndKey) + } + + return &VersionFile{Filename: slice[0], Key: slice[1]}, nil +} + +func (vf VersionFile) UpdateVersion(current *semver.Version, next *semver.Version) error { + r, err := regexForVersionFileKey(vf.Key, current) + if err != nil { + return err + } + + contents, err := ioutil.ReadFile(vf.Filename) + if err != nil { + return err + } + + match := string(r.Find(contents)) + newVersionString := strings.Replace(match, current.String(), next.String(), 1) + newContents := strings.Replace(string(contents), match, newVersionString, 1) + + err = ioutil.WriteFile(vf.Filename, []byte(newContents), fs.ModePerm) + if err != nil { + return err + } + + return nil +} + +func regexForVersionFileKey(key string, currentVersion *semver.Version) (*regexp.Regexp, error) { + return regexp.Compile(fmt.Sprintf("%s(.{1,})?%s", key, currentVersion.String())) +} diff --git a/internal/git/mod.go b/internal/git/mod.go new file mode 100644 index 0000000..9053c27 --- /dev/null +++ b/internal/git/mod.go @@ -0,0 +1,166 @@ +package git + +import ( + "fmt" + "log" + "regexp" + + git "github.com/libgit2/git2go/v31" +) + +type Commit = git.Commit +type Repository = git.Repository + +func OpenRepository(path string) (*git.Repository, error) { + return git.OpenRepository(path) +} + +func CreateCommit(repo *git.Repository, message string) error { + sig, err := repo.DefaultSignature() + if err != nil { + return err + } + + head, err := repo.Head() + if err != nil { + return err + } + + idx, err := repo.Index() + if err != nil { + return err + } + + idx.AddAll(make([]string, 0), git.IndexAddDefault, func(_, _ string) int { + return 0 + }) + + treeId, err := idx.WriteTree() + if err != nil { + return err + } + + err = idx.Write() + if err != nil { + return err + } + + tree, err := repo.LookupTree(treeId) + if err != nil { + return err + } + + commitTarget, err := repo.LookupCommit(head.Target()) + if err != nil { + return err + } + + _, err = repo.CreateCommit("refs/heads/main", sig, sig, message, tree, commitTarget) + if err != nil { + return err + } + + return nil +} + +func ListCommits(repo *git.Repository) ([]*git.Commit, error) { + revwalk, err := repo.Walk() + if err != nil { + return make([]*git.Commit, 0), err + } + + if err := revwalk.PushHead(); err != nil { + return make([]*git.Commit, 0), err + } + + var commits []*git.Commit + + err = revwalk.Iterate(func(commit *git.Commit) bool { + commits = append(commits, commit) + return true + }) + if err != nil { + return make([]*git.Commit, 0), err + } + + return commits, nil +} + +func ListCommitsInRange(repo *git.Repository, lRange string, rRange string) ([]*git.Commit, error) { + revwalk, err := repo.Walk() + if err != nil { + return make([]*git.Commit, 0), err + } + + if err := revwalk.PushRange(fmt.Sprintf("%s..%s", lRange, rRange)); err != nil { + return make([]*git.Commit, 0), err + } + + var commits []*git.Commit + + err = revwalk.Iterate(func(commit *git.Commit) bool { + commits = append(commits, commit) + return true + }) + if err != nil { + return make([]*git.Commit, 0), err + } + + return commits, nil +} + +func FindTags(repo *git.Repository, reg *regexp.Regexp) ([]string, error) { + var matchTags []string + + tags, err := repo.Tags.List() + if err != nil { + return make([]string, 0), nil + } + + for _, tag := range tags { + if reg.MatchString(tag) { + matchTags = append(matchTags, tag) + } + } + + return matchTags, nil +} + +func CreateTag(repo *git.Repository, tagName, message string) error { + sig, err := repo.DefaultSignature() + if err != nil { + return err + } + + latestCommitObject, err := repo.RevparseSingle("HEAD") + if err != nil { + return err + } + + latestCommit, err := latestCommitObject.AsCommit() + if err != nil { + return err + } + + repo.Tags.Create(tagName, latestCommit, sig, message) + + return nil +} + +func PushTagToRemotes(repo *git.Repository, tagName string) error { + remotes, err := repo.Remotes.List() + if err != nil { + return err + } + + if len(remotes) == 0 { + log.Printf("No remotes found, skipping pushing tag %s\n", tagName) + return nil + } + + for _, remote := range remotes { + repo.Remotes.AddPush(remote, tagName) + } + + return nil +} diff --git a/main.go b/main.go index 25fee79..eae47fd 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,7 @@ package main import ( - git_semver "github.com/crqra/git-semver/cmd/git-semver" + git_semver "github.com/carlsberg/git-semver/cmd/git-semver" "github.com/spf13/cobra" )