├── assets ├── tu_logo.ai ├── tu_logo.ico ├── tu_logo.png ├── tu_social.ai ├── tu_social.png └── docs │ ├── usage.md │ ├── shell-completion.md │ ├── installation.md │ └── integration.md ├── pkg ├── constants │ └── string.go ├── test │ ├── time.go │ ├── common.go │ └── git.go ├── git │ ├── remote.go │ ├── stage_test.go │ ├── remote_test.go │ ├── stage.go │ ├── commit_test.go │ ├── repo.go │ ├── commit.go │ ├── repo_test.go │ ├── hooks.go │ └── hooks_test.go ├── integrations │ ├── provider_test.go │ ├── issue_test.go │ ├── provider.go │ ├── openai.go │ ├── jira.go │ ├── issue.go │ ├── gitlab.go │ ├── jira_test.go │ └── gitlab_test.go └── format │ ├── branch.go │ ├── branch_test.go │ ├── commit.go │ └── commit_test.go ├── .gitmodules ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── need-help.md │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── go.yml │ └── release.yml ├── internal └── cmdbuilder │ ├── prerun.go │ └── repository.go ├── .talismanrc ├── cmd ├── completion_test.go ├── check_test.go ├── version_test.go ├── root.go ├── new_test.go ├── version.go ├── log-filter.go ├── commit_test.go ├── completion.go ├── release_test.go ├── check.go ├── new.go ├── release.go ├── log.go └── commit.go ├── Makefile ├── LICENSE ├── README.md ├── main.go ├── go.mod ├── .gitignore ├── CODE_OF_CONDUCT.md ├── scripts └── gen-doc.go ├── CONTRIBUTING.md └── go.sum /assets/tu_logo.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/b4nst/turbogit/HEAD/assets/tu_logo.ai -------------------------------------------------------------------------------- /assets/tu_logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/b4nst/turbogit/HEAD/assets/tu_logo.ico -------------------------------------------------------------------------------- /assets/tu_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/b4nst/turbogit/HEAD/assets/tu_logo.png -------------------------------------------------------------------------------- /assets/tu_social.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/b4nst/turbogit/HEAD/assets/tu_social.ai -------------------------------------------------------------------------------- /assets/tu_social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/b4nst/turbogit/HEAD/assets/tu_social.png -------------------------------------------------------------------------------- /pkg/constants/string.go: -------------------------------------------------------------------------------- 1 | package constants 2 | 3 | const ( 4 | LINE_BREAK = "\n" 5 | ) 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "git2go"] 2 | path = git2go 3 | url = https://github.com/libgit2/git2go 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | reviewers: 10 | - b4nst 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/need-help.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Need help 3 | about: Ask anything about usage or contribution. 4 | title: "[HELP]" 5 | labels: help wanted 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /pkg/test/time.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import "time" 4 | 5 | // Clock is a clock that returns always the same value 6 | type Clock struct { 7 | Value time.Time 8 | } 9 | 10 | // Now returns the Clock value 11 | func (c Clock) Now() time.Time { 12 | return c.Value 13 | } 14 | -------------------------------------------------------------------------------- /internal/cmdbuilder/prerun.go: -------------------------------------------------------------------------------- 1 | package cmdbuilder 2 | 3 | import "github.com/spf13/cobra" 4 | 5 | // AppendPreRun appends a prerun function to cmd. 6 | func AppendPreRun(cmd *cobra.Command, prerun func(cmd *cobra.Command, args []string)) { 7 | mem := cmd.PreRun 8 | 9 | cmd.PreRun = func(cmd *cobra.Command, args []string) { 10 | if mem != nil { 11 | mem(cmd, args) 12 | } 13 | 14 | prerun(cmd, args) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.talismanrc: -------------------------------------------------------------------------------- 1 | fileignoreconfig: 2 | - filename: go.sum 3 | checksum: b8c74270b3988878ec79282883af9835e5936994b79afc72ad76ebb25d1a69a7 4 | ignore_detectors: [] 5 | - filename: assets/tu_logo.ai 6 | checksum: 914c8f4d7cefc065c7826d91ca686825382b8a49b52c23c6a10a445df16869b6 7 | ignore_detectors: [] 8 | - filename: assets/tu_social.ai 9 | checksum: b8093b4dd7767038ad955b9a2352a664877940dd4efebfdfa85e12d709044db5 10 | ignore_detectors: [] 11 | scopeconfig: [] -------------------------------------------------------------------------------- /pkg/git/remote.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "net/url" 5 | 6 | git "github.com/libgit2/git2go/v33" 7 | giturls "github.com/whilp/git-urls" 8 | ) 9 | 10 | func ParseRemote(r *git.Repository, name string, fallback bool) (*url.URL, error) { 11 | rawurl := "" 12 | remote, err := r.Remotes.Lookup(name) 13 | if err != nil { 14 | if !fallback { 15 | return nil, err 16 | } 17 | rl, err := r.Remotes.List() 18 | if err != nil { 19 | return nil, err 20 | } 21 | rawurl = rl[0] 22 | } else { 23 | rawurl = remote.Url() 24 | } 25 | return giturls.Parse(rawurl) 26 | } 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEAT]" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | 12 | 13 | **Describe the solution you'd like** 14 | 15 | 16 | **Additional context** 17 | 18 | -------------------------------------------------------------------------------- /pkg/git/stage_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/b4nst/turbogit/pkg/test" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestStageReady(t *testing.T) { 11 | r := test.TestRepo(t) 12 | defer test.CleanupRepo(t, r) 13 | 14 | nc, err := StageReady(r) 15 | assert.NoError(t, err) 16 | assert.False(t, nc) 17 | 18 | f := test.NewFile(t, r) 19 | nc, err = StageReady(r) 20 | assert.EqualError(t, err, "No changes added to commit") 21 | 22 | test.StageFile(t, f, r) 23 | nc, err = StageReady(r) 24 | assert.NoError(t, err) 25 | assert.True(t, nc) 26 | } 27 | -------------------------------------------------------------------------------- /assets/docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ```shell 4 | Set of opinionated git plugins. 5 | 6 | Usage: 7 | tug [command] 8 | 9 | Available Commands: 10 | check Check the history to follow conventional commit 11 | commit Commit using conventional commit message 12 | completion Generate the autocompletion script for the specified shell 13 | help Help about any command 14 | logs Shows the commit logs. 15 | new Start a new branch. 16 | release Release a SemVer tag based on the commit history. 17 | version Print current version 18 | 19 | Flags: 20 | -h, --help help for tug 21 | 22 | Use "tug [command] --help" for more information about a command. 23 | ``` 24 | -------------------------------------------------------------------------------- /cmd/completion_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/b4nst/turbogit/pkg/test" 8 | "github.com/spf13/cobra" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestCompletion(t *testing.T) { 13 | cmd := &cobra.Command{} 14 | 15 | f, restore := test.CaptureStd(t, os.Stdout) 16 | defer restore() 17 | defer os.RemoveAll(f.Name()) 18 | 19 | assert.NoError(t, runCompletion(cmd, []string{"bash"})) 20 | assert.NoError(t, runCompletion(cmd, []string{"zsh"})) 21 | assert.NoError(t, runCompletion(cmd, []string{"fish"})) 22 | assert.NoError(t, runCompletion(cmd, []string{"powershell"})) 23 | assert.Error(t, runCompletion(cmd, []string{"other"})) 24 | } 25 | -------------------------------------------------------------------------------- /pkg/test/common.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func CaptureStd(t *testing.T, std *os.File) (f *os.File, reset func()) { 13 | f, err := ioutil.TempFile("", path.Base(std.Name())) 14 | require.NoError(t, err) 15 | 16 | backup := *std 17 | reset = func() { 18 | *std = backup 19 | } 20 | *std = *f 21 | return 22 | } 23 | 24 | func WriteGitHook(t *testing.T, hook string, content string) { 25 | wd, err := os.Getwd() 26 | require.NoError(t, err) 27 | hooks := path.Join(wd, ".git", "hooks") 28 | err = os.MkdirAll(hooks, 0700) 29 | require.NoError(t, err) 30 | err = ioutil.WriteFile(path.Join(hooks, hook), []byte(content), 0777) 31 | require.NoError(t, err) 32 | } 33 | -------------------------------------------------------------------------------- /assets/docs/shell-completion.md: -------------------------------------------------------------------------------- 1 | # Shell completion 2 | 3 | ## Fish 4 | 5 | ```shell 6 | tug completion fish | source 7 | ``` 8 | 9 | To load completions for each session, execute once: 10 | 11 | ```shell 12 | tug completion fish > ~/.config/fish/completions/tug.fish 13 | ``` 14 | 15 | ## Zsh 16 | 17 | ```zsh 18 | source <(tug completion zsh) 19 | ``` 20 | 21 | To load completions for each session, execute once: 22 | 23 | ```zsh 24 | tug completion zsh > "${fpath[1]}/_tug" 25 | ``` 26 | 27 | ## Bash 28 | 29 | ```bash 30 | source <(tug completion bash) 31 | ``` 32 | 33 | To load completions for each session, execute once: 34 | *Linux* 35 | 36 | ```bash 37 | tug completion bash > /etc/bash_completion.d/tug 38 | ``` 39 | 40 | *MacOS* 41 | 42 | ```bash 43 | tug completion bash > /usr/local/etc/bash_completion.d/tug 44 | ``` 45 | -------------------------------------------------------------------------------- /pkg/git/remote_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/b4nst/turbogit/pkg/test" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParseRemote(t *testing.T) { 12 | r := test.TestRepo(t) 13 | defer test.CleanupRepo(t, r) 14 | _, err := r.Remotes.Create("origin", "git@alice.com:namespace/project.git") 15 | require.NoError(t, err) 16 | 17 | // Direct 18 | u, err := ParseRemote(r, "origin", false) 19 | assert.NoError(t, err) 20 | assert.Equal(t, u.String(), "ssh://git@alice.com/namespace/project.git") 21 | 22 | // No fallback 23 | u, err = ParseRemote(r, "rename", false) 24 | assert.EqualError(t, err, "remote 'rename' does not exist") 25 | 26 | // Fallback 27 | u, err = ParseRemote(r, "rename", true) 28 | assert.NoError(t, err) 29 | assert.Equal(t, u.String(), "file://origin") 30 | 31 | } 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug, need-investigation 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Describe the bug 11 | 12 | 13 | 14 | ## To Reproduce 15 | 16 | **Steps** 17 | 18 | 19 | **Expected behavior** 20 | 21 | 22 | **Actual behavior** 23 | 24 | 25 | ## Versions 26 | 27 | 28 | - *tug*: 29 | 30 | - *OS*: 31 | 32 | ## Additional context 33 | 34 | 35 | -------------------------------------------------------------------------------- /pkg/git/stage.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "errors" 5 | 6 | git "github.com/libgit2/git2go/v33" 7 | ) 8 | 9 | // StageReady returns true if the stage is ready to be committed. Otherwise it returns false if there is nothing to commit or an error. 10 | func StageReady(r *git.Repository) (bool, error) { 11 | s, err := r.StatusList(&git.StatusOptions{Show: git.StatusShowIndexAndWorkdir, Flags: git.StatusOptIncludeUntracked}) 12 | if err != nil { 13 | return false, err 14 | } 15 | 16 | count, err := s.EntryCount() 17 | if err != nil { 18 | return false, err 19 | } 20 | if count <= 0 { 21 | return false, nil 22 | } 23 | for i := 0; i < count; i++ { 24 | se, err := s.ByIndex(i) 25 | if err != nil { 26 | return false, err 27 | } 28 | if se.Status <= git.StatusIndexTypeChange { 29 | return true, nil 30 | } 31 | } 32 | return false, errors.New("No changes added to commit") 33 | } 34 | -------------------------------------------------------------------------------- /internal/cmdbuilder/repository.go: -------------------------------------------------------------------------------- 1 | package cmdbuilder 2 | 3 | import ( 4 | "context" 5 | 6 | tugit "github.com/b4nst/turbogit/pkg/git" 7 | git "github.com/libgit2/git2go/v33" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | type repoKey struct{} 12 | 13 | // GetRepo returns the current git repository. 14 | func GetRepo(cmd *cobra.Command) *git.Repository { 15 | v := cmd.Context().Value(repoKey{}) 16 | return v.(*git.Repository) 17 | } 18 | 19 | func RepoAware(cmd *cobra.Command) { 20 | AppendPreRun(cmd, repoPreRun) 21 | } 22 | 23 | func MockRepoAware(cmd *cobra.Command, repo *git.Repository) { 24 | parent := cmd.Context() 25 | if parent == nil { 26 | parent = context.TODO() 27 | } 28 | cmd.SetContext(context.WithValue(parent, repoKey{}, repo)) 29 | } 30 | 31 | func repoPreRun(cmd *cobra.Command, args []string) { 32 | repo, err := tugit.Getrepo() 33 | cobra.CheckErr(err) 34 | 35 | cmd.SetContext(context.WithValue(cmd.Context(), repoKey{}, repo)) 36 | } 37 | -------------------------------------------------------------------------------- /cmd/check_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | tugit "github.com/b4nst/turbogit/pkg/git" 8 | "github.com/b4nst/turbogit/pkg/test" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestRunCheck(t *testing.T) { 14 | r := test.TestRepo(t) 15 | defer test.CleanupRepo(t, r) 16 | test.InitRepoConf(t, r) 17 | 18 | c1, err := tugit.Commit(r, "bad commit 1") 19 | require.NoError(t, err) 20 | sid1, err := c1.ShortId() 21 | require.NoError(t, err) 22 | _, err = tugit.Commit(r, "feat: ok commit") 23 | assert.NoError(t, err) 24 | c3, err := tugit.Commit(r, "bad commit 2") 25 | assert.NoError(t, err) 26 | sid3, err := c3.ShortId() 27 | require.NoError(t, err) 28 | 29 | err = runCheck(&checkOpt{All: false, From: "HEAD", Repo: r}) 30 | assert.EqualError(t, err, fmt.Sprintf("2 errors occurred:\n\t* %s ('bad commit 2') is not compliant\n\t* %s ('bad commit 1') is not compliant\n\n", sid3, sid1)) 31 | } 32 | -------------------------------------------------------------------------------- /pkg/integrations/provider_test.go: -------------------------------------------------------------------------------- 1 | package integrations 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/b4nst/turbogit/pkg/test" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestProvidersFrom(t *testing.T) { 12 | r := test.TestRepo(t) 13 | defer test.CleanupRepo(t, r) 14 | c, err := r.Config() 15 | require.NoError(t, err) 16 | require.NoError(t, c.SetBool("jira.enabled", true)) 17 | require.NoError(t, c.SetString("jira.username", "alice@ecorp.com")) 18 | require.NoError(t, c.SetString("jira.token", "supersecret")) 19 | require.NoError(t, c.SetString("jira.domain", "foo.bar")) 20 | require.NoError(t, c.SetString("jira.filter", "query")) 21 | require.NoError(t, c.SetString("gitlab.token", "supersecret")) 22 | r.Remotes.Create("origin", "git@gitlab.com:namespace/project.git") 23 | 24 | p, err := Issuers(r) 25 | assert.NoError(t, err) 26 | assert.Len(t, p, 2) 27 | assert.IsType(t, JiraProvider{}, p[0]) 28 | assert.IsType(t, GitLabProvider{}, p[1]) 29 | } 30 | -------------------------------------------------------------------------------- /pkg/integrations/issue_test.go: -------------------------------------------------------------------------------- 1 | package integrations 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/b4nst/turbogit/pkg/format" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestFormat(t *testing.T) { 11 | id := IssueDescription{"ID-245", "Issue name", "An issue description.", "Jira", "type"} 12 | actual := id.Format(false) 13 | assert.Equal(t, "ID-245", id.ID) 14 | assert.Equal(t, "ID-245 - Issue name\n\nAn issue description.\n\nIssue provided by Jira", actual) 15 | actual = id.Format(true) 16 | assert.Contains(t, actual, "\x1B[0m") 17 | } 18 | 19 | func TestShortFormat(t *testing.T) { 20 | id := IssueDescription{"ID-245", "Issue name", "An issue description.", "Jira", "type"} 21 | actual := id.ShortFormat() 22 | assert.Equal(t, "ID-245 - Issue name", actual) 23 | } 24 | 25 | func TestToBranch(t *testing.T) { 26 | id := IssueDescription{Type: "feat", ID: "ID-245", Name: "feature 245."} 27 | expected := format.TugBranch{Type: id.Type, Prefix: id.ID, Description: id.Name} 28 | assert.Equal(t, expected, id.ToBranch(map[string]string{})) 29 | } 30 | -------------------------------------------------------------------------------- /cmd/version_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "runtime" 7 | "testing" 8 | 9 | "github.com/b4nst/turbogit/pkg/test" 10 | "github.com/spf13/cobra" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestRunVersion(t *testing.T) { 16 | f, restore := test.CaptureStd(t, os.Stdout) 17 | defer restore() 18 | runVersion(&cobra.Command{}, []string{}) 19 | 20 | content, err := ioutil.ReadFile(f.Name()) 21 | require.NoError(t, err) 22 | assert.Contains(t, string(content), Version, "Version should contains turbogit version") 23 | assert.Contains(t, string(content), runtime.Version(), "Version should contains runtime version") 24 | assert.Contains(t, string(content), Commit, "Version should contains build commit") 25 | assert.Contains(t, string(content), BuildDate, "Version should contains build date") 26 | assert.Contains(t, string(content), runtime.GOARCH, "Version should contains go arch") 27 | assert.Contains(t, string(content), runtime.GOOS, "Version should contains go OS") 28 | } 29 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Build config 2 | BUILD_FILES = $(shell go list -f '{{range .GoFiles}}{{$$.Dir}}/{{.}} {{end}}' ./...) 3 | 4 | DATE=$(shell date -u "+%a %b %d %T %Y") 5 | TUG_COMMIT ?= $(shell git rev-parse --short HEAD) 6 | TUG_VERSION ?= dev 7 | 8 | LDFLAGS = -s -w 9 | LDFLAGS += -X "github.com/b4nst/turbogit/cmd.BuildDate=$(DATE)" 10 | LDFLAGS += -X "github.com/b4nst/turbogit/cmd.Commit=$(TUG_COMMIT)" 11 | LDFLAGS += -X "github.com/b4nst/turbogit/cmd.Version=$(TUG_VERSION)" 12 | 13 | # Go config 14 | BUILD_ARGS=-trimpath -tags=static -ldflags='$(LDFLAGS)' 15 | GOCMD=go 16 | GOBUILD=$(GOCMD) build $(BUILD_ARGS) 17 | GOTEST=$(GOCMD) test -tags=static 18 | GORUN=$(GOCMD) run -tags=static 19 | 20 | dist/bin/tug: $(BUILD_FILES) 21 | $(GOBUILD) -o "$@" ./main.go 22 | 23 | build: libgit2 dist/bin/tug 24 | .PHONY: build 25 | 26 | libgit2: 27 | $(MAKE) -C ./git2go install-static 28 | 29 | test: $(BUILD_FILES) 30 | $(GOTEST) ./... -coverprofile c.out 31 | .PHONY: test 32 | 33 | doc: libgit2 34 | $(GORUN) scripts/gen-doc.go 35 | cd dist/doc; doctave build --release 36 | .PHONY: doc 37 | 38 | clean: 39 | rm -rf bin dist c.out 40 | .PHONY: clean 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2020 banst 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /pkg/integrations/provider.go: -------------------------------------------------------------------------------- 1 | package integrations 2 | 3 | import ( 4 | git "github.com/libgit2/git2go/v33" 5 | ) 6 | 7 | // Issuer interface abstracts cross-platform providers 8 | type Issuer interface { 9 | // Search a list of issue in the provider 10 | Search() ([]IssueDescription, error) 11 | } 12 | 13 | type Commiter interface { 14 | // Propose commit messages from a diff 15 | CommitMessages(*git.Diff) ([]string, error) 16 | } 17 | 18 | func Issuers(r *git.Repository) (issuers []Issuer, err error) { 19 | // Jira 20 | jp, err := NewJiraProvider(r) 21 | if err != nil { 22 | return nil, err 23 | } 24 | if jp != nil { 25 | issuers = append(issuers, *jp) 26 | } 27 | 28 | // Gitlab 29 | glp, err := NewGitLabProvider(r) 30 | if err != nil { 31 | return nil, err 32 | } 33 | if glp != nil { 34 | issuers = append(issuers, *glp) 35 | } 36 | 37 | return 38 | } 39 | 40 | func Commiters(r *git.Repository) (commiters []Commiter, err error) { 41 | // OpenAI 42 | oai, err := NewOpenAIProvider(r) 43 | if err != nil { 44 | return nil, err 45 | } 46 | if oai != nil { 47 | commiters = append(commiters, oai) 48 | } 49 | 50 | return 51 | } 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Turbogit (tug) 3 | 4 | [![build](https://github.com/b4nst/turbogit/workflows/Go/badge.svg)](https://github.com/b4nst/turbogit/actions?query=workflow%3AGo) 5 | [![version](https://img.shields.io/github/v/release/b4nst/turbogit?include_prereleases&label=latest&logo=ferrari)](https://github.com/b4nst/turbogit/releases/latest) 6 | [![Test Coverage](https://api.codeclimate.com/v1/badges/5173f55b5e67109d3ca5/test_coverage)](https://codeclimate.com/github/b4nst/turbogit/test_coverage) 7 | [![Maintainability](https://api.codeclimate.com/v1/badges/5173f55b5e67109d3ca5/maintainability)](https://codeclimate.com/github/b4nst/turbogit/maintainability) 8 | 9 | ![logo](assets/tu_logo.png) 10 | 11 | tug is a set of git plugins built to help you deal with your day-to-day git work. 12 | tug enforces conventions like [The Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/), [SemVer](https://semver.org/), [Trunk Based Development](https://trunkbaseddevelopment.com), 13 | without overwhelming you. 14 | tug is your friend. 15 | 16 | ## Documentation 17 | 18 | Full documentation is available at [b4nst.github.io/turbogit](https://b4nst.github.io/turbogit) 19 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2020 banst 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | The above copyright notice and this permission notice shall be included in 10 | all copies or substantial portions of the Software. 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 17 | THE SOFTWARE. 18 | */ 19 | package main 20 | 21 | import "github.com/b4nst/turbogit/cmd" 22 | 23 | func main() { 24 | cmd.Execute() 25 | } 26 | -------------------------------------------------------------------------------- /pkg/git/commit_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/b4nst/turbogit/pkg/test" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestCommit(t *testing.T) { 13 | r := test.TestRepo(t) 14 | defer test.CleanupRepo(t, r) 15 | test.InitRepoConf(t, r) 16 | 17 | commit, err := Commit(r, "commit message") 18 | assert.NoError(t, err) 19 | assert.Equal(t, "commit message", commit.Message()) 20 | assert.Equal(t, test.GIT_USERNAME, commit.Author().Name) 21 | assert.Equal(t, test.GIT_EMAIL, commit.Author().Email) 22 | assert.WithinDuration(t, time.Now(), commit.Author().When, 5*time.Second) 23 | head, err := r.Head() 24 | require.NoError(t, err) 25 | headCommit, err := r.LookupCommit(head.Target()) 26 | require.NoError(t, err) 27 | assert.Equal(t, headCommit.Id(), commit.Id()) 28 | } 29 | 30 | func TestAmend(t *testing.T) { 31 | r := test.TestRepo(t) 32 | test.InitRepoConf(t, r) 33 | commit, err := Commit(r, "foo") 34 | require.NoError(t, err) 35 | 36 | amendc, err := Amend(commit, "bar") 37 | assert.NoError(t, err) 38 | head, err := r.Head() 39 | require.NoError(t, err) 40 | headc, err := r.LookupCommit(head.Target()) 41 | require.NoError(t, err) 42 | assert.Equal(t, "bar", headc.Message()) 43 | assert.Equal(t, headc.Id(), amendc.Id()) 44 | } 45 | -------------------------------------------------------------------------------- /assets/docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | [![Packaging status](https://repology.org/badge/vertical-allrepos/turbogit.svg)](https://repology.org/project/turbogit/versions) 4 | 5 | ## macOS 6 | 7 | `turbogit` is available on [MacPorts](https://www.macports.org/install.php) and Homebrew. 8 | 9 | ### Macports (preferred) 10 | 11 | install 12 | 13 | ```shell 14 | sudo port install turbogit 15 | ``` 16 | 17 | upgrade 18 | 19 | ```shell 20 | sudo port selfupdate && sudo port upgrade turbogit 21 | ``` 22 | 23 | ### Homebrew 24 | 25 | install 26 | 27 | ```shell 28 | brew tap b4nst/homebrew-tap 29 | brew install turbogit 30 | ``` 31 | 32 | upgrade 33 | 34 | ```shell 35 | brew upgrade turbogit 36 | ``` 37 | 38 | ## Linux 39 | 40 | ### NixOS 41 | 42 | ``` 43 | nix-env -i turbogit 44 | ``` 45 | 46 | ### Other distributions 47 | 48 | Download pre-built binaries from the [latest release page](https://github.com/b4nst/turbogit/releases/latest). 49 | 50 | ## Windows 51 | 52 | > Since git2go refactor, tug is not available as a Windows package anymore. 53 | > Please check [#48](https://github.com/b4nst/turbogit/issues/48). 54 | 55 | Please follow [the instructions](/installation#build-from-source) to build it from source. 56 | 57 | ## Build from source 58 | 59 | 1. Clone the repo (don't forget the submodule) 60 | 2. Run build command 61 | 62 | ```shell 63 | git clone --recurse-submodules https://github.com/b4nst/turbogit.git 64 | cd turbogit 65 | make build 66 | ``` 67 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/b4nst/turbogit 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/AlecAivazis/survey/v2 v2.3.6 7 | github.com/andygrunwald/go-jira v1.16.0 8 | github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de 9 | github.com/blang/semver/v4 v4.0.0 10 | github.com/briandowns/spinner v1.23.0 11 | github.com/fatih/color v1.15.0 // indirect 12 | github.com/gdamore/tcell/v2 v2.6.0 // indirect 13 | github.com/golang-jwt/jwt/v4 v4.5.0 // indirect 14 | github.com/hashicorp/errwrap v1.1.0 // indirect 15 | github.com/hashicorp/go-hclog v1.0.0 // indirect 16 | github.com/hashicorp/go-multierror v1.1.1 17 | github.com/hpcloud/golor v0.0.0-20150914221010-dc1b58c471a0 18 | github.com/imdario/mergo v0.3.15 19 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 20 | github.com/kr/text v0.2.0 // indirect 21 | github.com/ktr0731/go-fuzzyfinder v0.7.0 22 | github.com/libgit2/git2go/v33 v33.0.9 23 | github.com/mattn/go-isatty v0.0.18 // indirect 24 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect 25 | github.com/rivo/uniseg v0.4.4 // indirect 26 | github.com/sashabaranov/go-openai v1.9.3 27 | github.com/spf13/cobra v1.6.1 28 | github.com/stretchr/testify v1.8.2 29 | github.com/whilp/git-urls v1.0.0 30 | github.com/xanzy/go-gitlab v0.81.0 31 | golang.org/x/crypto v0.7.0 // indirect 32 | google.golang.org/protobuf v1.30.0 // indirect 33 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 34 | gopkg.in/yaml.v3 v3.0.1 35 | ) 36 | 37 | replace github.com/libgit2/git2go/v33 => ./git2go 38 | -------------------------------------------------------------------------------- /pkg/git/repo.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | git2go "github.com/libgit2/git2go/v33" 8 | ) 9 | 10 | // Getrepo returns the repository in the current directory or an error. 11 | func Getrepo() (*git2go.Repository, error) { 12 | wd, err := os.Getwd() 13 | if err != nil { 14 | return nil, err 15 | } 16 | rpath, err := git2go.Discover(wd, false, nil) 17 | if err != nil { 18 | return nil, err 19 | } 20 | repo, err := git2go.OpenRepository(rpath) 21 | if err != nil { 22 | return nil, err 23 | } 24 | return repo, nil 25 | } 26 | 27 | func StagedDiff(r *git2go.Repository) (*git2go.Diff, error) { 28 | var tree *git2go.Tree 29 | if obj, err := r.RevparseSingle("HEAD^{tree}"); err == nil { 30 | tree, err = obj.AsTree() 31 | if err != nil { 32 | return nil, err 33 | } 34 | } 35 | 36 | diff, err := r.DiffTreeToIndex(tree, nil, &git2go.DiffOptions{ 37 | Flags: git2go.DiffIgnoreWhitespace, 38 | IgnoreSubmodules: git2go.SubmoduleIgnoreAll, 39 | }) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | return diff, nil 45 | } 46 | 47 | func PatchFromDiff(diff *git2go.Diff) (string, error) { 48 | numDeltas, err := diff.NumDeltas() 49 | if err != nil { 50 | return "", err 51 | } 52 | 53 | patches := make([]string, numDeltas) 54 | for i := 0; i < numDeltas; i++ { 55 | p, err := diff.Patch(i) 56 | if err != nil { 57 | return "", err 58 | } 59 | ps, err := p.String() 60 | if err != nil { 61 | return "", err 62 | } 63 | patches = append(patches, ps) 64 | } 65 | 66 | return strings.Join(patches, "\n"), nil 67 | } 68 | -------------------------------------------------------------------------------- /pkg/integrations/openai.go: -------------------------------------------------------------------------------- 1 | package integrations 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | tugit "github.com/b4nst/turbogit/pkg/git" 8 | git "github.com/libgit2/git2go/v33" 9 | "github.com/sashabaranov/go-openai" 10 | ) 11 | 12 | type OpenAIProvider struct { 13 | client *openai.Client 14 | } 15 | 16 | func NewOpenAIProvider(r *git.Repository) (*OpenAIProvider, error) { 17 | c, err := r.Config() 18 | if err != nil { 19 | return nil, err 20 | } 21 | enabled, err := c.LookupBool("openai.enabled") 22 | if !enabled { 23 | return nil, err 24 | } 25 | token, err := c.LookupString("openai.token") 26 | if err != nil { 27 | return nil, err 28 | } 29 | oc := openai.NewClient(token) 30 | 31 | return &OpenAIProvider{client: oc}, nil 32 | } 33 | 34 | func (oai *OpenAIProvider) CommitMessages(diff *git.Diff) ([]string, error) { 35 | spatch, err := tugit.PatchFromDiff(diff) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | msg := fmt.Sprintf("Propose a conventional commit message for this git diff \n```\n%s\n```", spatch) 41 | 42 | resp, err := oai.client.CreateChatCompletion( 43 | context.Background(), 44 | openai.ChatCompletionRequest{ 45 | Model: openai.GPT3Dot5Turbo, 46 | Messages: []openai.ChatCompletionMessage{ 47 | { 48 | Role: openai.ChatMessageRoleUser, 49 | Content: msg, 50 | }, 51 | }, 52 | }, 53 | ) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | cmos := make([]string, 0, len(resp.Choices)) 59 | for _, c := range resp.Choices { 60 | cmos = append(cmos, c.Message.Content) 61 | } 62 | 63 | return cmos, nil 64 | } 65 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2020 banst 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "log" 26 | "os" 27 | 28 | "github.com/spf13/cobra" 29 | ) 30 | 31 | const ( 32 | BIN_NAME = "tug" 33 | ) 34 | 35 | var ( 36 | Version = "dev" 37 | Commit = "nil" 38 | BuildDate = "nil" 39 | 40 | RootCmd = &cobra.Command{ 41 | Use: BIN_NAME, 42 | Short: "Set of opinionated git plugins.", 43 | } 44 | ) 45 | 46 | func Execute() { 47 | if err := RootCmd.Execute(); err != nil { 48 | os.Exit(1) 49 | } 50 | } 51 | 52 | func init() { 53 | log.SetFlags(0) 54 | } 55 | -------------------------------------------------------------------------------- /pkg/git/commit.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import git "github.com/libgit2/git2go/v33" 4 | 5 | // RepoTree return the current index tree 6 | func RepoTree(r *git.Repository) (*git.Tree, error) { 7 | idx, err := r.Index() 8 | if err != nil { 9 | return nil, err 10 | } 11 | treeId, err := idx.WriteTree() 12 | if err != nil { 13 | return nil, err 14 | } 15 | tree, err := r.LookupTree(treeId) 16 | if err != nil { 17 | return nil, err 18 | } 19 | return tree, nil 20 | } 21 | 22 | // Commit creates a new commit with the current tree. 23 | func Commit(r *git.Repository, msg string) (*git.Commit, error) { 24 | // Signature 25 | sig, err := r.DefaultSignature() 26 | if err != nil { 27 | return nil, err 28 | } 29 | // Tree 30 | tree, err := RepoTree(r) 31 | if err != nil { 32 | return nil, err 33 | } 34 | // Parents 35 | parents := []*git.Commit{} 36 | head, err := r.Head() 37 | if err == nil { // We found head 38 | headRef, err := r.LookupCommit(head.Target()) 39 | if err != nil { 40 | return nil, err 41 | } 42 | parents = append(parents, headRef) 43 | } 44 | 45 | oid, err := r.CreateCommit("HEAD", sig, sig, msg, tree, parents...) 46 | if err != nil { 47 | return nil, err 48 | } 49 | return r.LookupCommit(oid) 50 | } 51 | 52 | // Amend amends the HEAD commit 53 | func Amend(ca *git.Commit, msg string) (*git.Commit, error) { 54 | r := ca.Object.Owner() 55 | // Signature 56 | sig, err := r.DefaultSignature() 57 | if err != nil { 58 | return nil, err 59 | } 60 | // Tree 61 | tree, err := RepoTree(r) 62 | if err != nil { 63 | return nil, err 64 | } 65 | oid, err := ca.Amend("HEAD", ca.Author(), sig, msg, tree) 66 | if err != nil { 67 | return nil, err 68 | } 69 | return r.LookupCommit(oid) 70 | } 71 | -------------------------------------------------------------------------------- /cmd/new_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestBranchCreate(t *testing.T) { 8 | t.Skip("TODO") 9 | // r := test.TestRepo(t) 10 | // defer test.CleanupRepo(t, r) 11 | // test.InitRepoConf(t, r) 12 | 13 | // bco := &option{ 14 | // format.TugBranch{Type: "feat", Description: "foo"}, 15 | // r, 16 | // } 17 | // assert.Error(t, run(bco), "No commit to create branch from, please create the initial commit") 18 | // tugit.Commit(r, "initial commit") 19 | 20 | // assert.NoError(t, run(bco)) 21 | // h, err := r.Head() 22 | // require.NoError(t, err) 23 | // assert.Equal(t, "refs/heads/feat/foo", h.Name()) 24 | } 25 | 26 | func TestParseBranchCmd(t *testing.T) { 27 | t.Skip("TODO") 28 | // r := test.TestRepo(t) 29 | // defer test.CleanupRepo(t, r) 30 | // test.InitRepoConf(t, r) 31 | 32 | // cmd := &cobra.Command{} 33 | 34 | // // User branch 35 | // bco, err := parseCmd(cmd, []string{"user", "my", "branch"}) 36 | // assert.NoError(t, err) 37 | // expected := option{ 38 | // NewBranch: format.TugBranch{Type: "user", Prefix: test.GIT_USERNAME, Description: "my branch"}, 39 | // Repo: r, 40 | // } 41 | // assert.Equal(t, expected, *bco) 42 | // // Users branch 43 | // bco, err = parseCmd(cmd, []string{"users", "my", "branch"}) 44 | // assert.NoError(t, err) 45 | // expected = option{ 46 | // NewBranch: format.TugBranch{Type: "users", Prefix: test.GIT_USERNAME, Description: "my branch"}, 47 | // Repo: r, 48 | // } 49 | // assert.Equal(t, expected, *bco) 50 | // // Classic branch 51 | // bco, err = parseCmd(cmd, []string{"feat", "foo", "bar"}) 52 | // assert.NoError(t, err) 53 | // expected = option{ 54 | // NewBranch: format.TugBranch{Type: "feat", Description: "foo bar"}, 55 | // Repo: r, 56 | // } 57 | // assert.Equal(t, expected, *bco) 58 | } 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/macos,vscode,go 3 | # Edit at https://www.gitignore.io/?templates=macos,vscode,go 4 | 5 | ### Go ### 6 | # Binaries for programs and plugins 7 | *.exe 8 | *.exe~ 9 | *.dll 10 | *.so 11 | *.dylib 12 | 13 | # Test binary, built with `go test -c` 14 | *.test 15 | 16 | # Output of the go coverage tool, specifically when used with LiteIDE 17 | *.out 18 | 19 | # Dependency directories (remove the comment below to include it) 20 | # vendor/ 21 | 22 | ### Go Patch ### 23 | /vendor/ 24 | /Godeps/ 25 | 26 | ### macOS ### 27 | # General 28 | .DS_Store 29 | .AppleDouble 30 | .LSOverride 31 | 32 | # Icon must end with two \r 33 | Icon 34 | 35 | # Thumbnails 36 | ._* 37 | 38 | # Files that might appear in the root of a volume 39 | .DocumentRevisions-V100 40 | .fseventsd 41 | .Spotlight-V100 42 | .TemporaryItems 43 | .Trashes 44 | .VolumeIcon.icns 45 | .com.apple.timemachine.donotpresent 46 | 47 | # Directories potentially created on remote AFP share 48 | .AppleDB 49 | .AppleDesktop 50 | Network Trash Folder 51 | Temporary Items 52 | .apdisk 53 | 54 | #!! ERROR: vscode is undefined. Use list command to see defined gitignore types !!# 55 | 56 | # End of https://www.gitignore.io/api/macos,vscode,go 57 | 58 | /bin 59 | dist 60 | # Created by https://www.toptal.com/developers/gitignore/api/vim 61 | # Edit at https://www.toptal.com/developers/gitignore?templates=vim 62 | 63 | ### Vim ### 64 | # Swap 65 | [._]*.s[a-v][a-z] 66 | !*.svg # comment out if you don't need vector files 67 | [._]*.sw[a-p] 68 | [._]s[a-rt-v][a-z] 69 | [._]ss[a-gi-z] 70 | [._]sw[a-p] 71 | 72 | # Session 73 | Session.vim 74 | Sessionx.vim 75 | 76 | # Temporary 77 | .netrwhist 78 | *~ 79 | # Auto-generated tag files 80 | tags 81 | # Persistent undo 82 | [._]*.un~ 83 | 84 | # End of https://www.toptal.com/developers/gitignore/api/vim 85 | -------------------------------------------------------------------------------- /pkg/test/git.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | git "github.com/libgit2/git2go/v33" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | const ( 14 | GIT_USERNAME = "Alice" 15 | GIT_EMAIL = "alice@ecorp.com" 16 | ) 17 | 18 | // TestRepo creates a new repository in a temporary directory 19 | func TestRepo(t *testing.T) (repo *git.Repository) { 20 | path, err := ioutil.TempDir("", "turbogit") 21 | require.NoError(t, err) 22 | r, err := git.InitRepository(path, false) 23 | require.NoError(t, err) 24 | require.NoError(t, os.Chdir(path)) 25 | return r 26 | } 27 | 28 | // CleanupRepo removes the repo directory recursively 29 | func CleanupRepo(t *testing.T, r *git.Repository) { 30 | p := r.Workdir() 31 | if r.IsBare() { 32 | p = r.Path() 33 | } 34 | require.NoError(t, os.RemoveAll(p)) 35 | } 36 | 37 | // NewFile creates a new temp file in the repo working directory 38 | func NewFile(t *testing.T, r *git.Repository) *os.File { 39 | f, err := ioutil.TempFile(r.Workdir(), "") 40 | require.NoError(t, err) 41 | return f 42 | } 43 | 44 | // StageFile adds a file to the repository index 45 | func StageFile(t *testing.T, f *os.File, r *git.Repository) { 46 | frel, err := filepath.Rel(r.Workdir(), f.Name()) 47 | require.NoError(t, err) 48 | idx, err := r.Index() 49 | require.NoError(t, err) 50 | require.NoError(t, idx.AddByPath(frel)) 51 | } 52 | 53 | // StageNewFile creates a new file and adds it to the index 54 | func StageNewFile(t *testing.T, r *git.Repository) { 55 | StageFile(t, NewFile(t, r), r) 56 | } 57 | 58 | // InitRepoConf set the repository initial configuration 59 | func InitRepoConf(t *testing.T, r *git.Repository) { 60 | c, err := r.Config() 61 | require.NoError(t, err) 62 | require.NoError(t, c.SetString("user.name", GIT_USERNAME)) 63 | require.NoError(t, c.SetString("user.email", GIT_EMAIL)) 64 | } 65 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2020 banst 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "fmt" 26 | "os" 27 | "runtime" 28 | "text/tabwriter" 29 | 30 | "github.com/spf13/cobra" 31 | ) 32 | 33 | func init() { 34 | RootCmd.AddCommand(versionCmd) 35 | } 36 | 37 | // versionCmd represents the version command 38 | var versionCmd = &cobra.Command{ 39 | Use: "version", 40 | Short: "Print current version", 41 | DisableFlagsInUseLine: true, 42 | Run: runVersion, 43 | } 44 | 45 | func runVersion(cmd *cobra.Command, args []string) { 46 | w := tabwriter.NewWriter(os.Stdout, 8, 8, 0, '\t', tabwriter.AlignRight) 47 | defer w.Flush() 48 | 49 | fmt.Fprintf(w, "%s\t%s\t", "Version:", Version) 50 | fmt.Fprintf(w, "\n%s\t%s\t", "Go version:", runtime.Version()) 51 | fmt.Fprintf(w, "\n%s\t%s\t", "Git commit:", Commit) 52 | fmt.Fprintf(w, "\n%s\t%s\t", "Built:", BuildDate) 53 | fmt.Fprintf(w, "\n%s\t%s/%s\t", "OS/Arch:", runtime.GOOS, runtime.GOARCH) 54 | } 55 | -------------------------------------------------------------------------------- /pkg/integrations/jira.go: -------------------------------------------------------------------------------- 1 | package integrations 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/andygrunwald/go-jira" 7 | "github.com/briandowns/spinner" 8 | git "github.com/libgit2/git2go/v33" 9 | ) 10 | 11 | const ( 12 | // Jira provider's name 13 | JIRA_PROVIDER = "Jira" 14 | ) 15 | 16 | // JiraProvider represents the Jira issue provider. 17 | type JiraProvider struct { 18 | filter string 19 | client *jira.Client 20 | } 21 | 22 | // Search returns a list of issues matching the query or an error if the request failed. 23 | func (jp JiraProvider) Search() ([]IssueDescription, error) { 24 | sopts := &jira.SearchOptions{} 25 | 26 | s := spinner.New(spinner.CharSets[39], 100*time.Millisecond) 27 | s.Suffix = " Searching on Jira" 28 | s.Start() 29 | raw, _, err := jp.client.Issue.Search(jp.filter, sopts) 30 | s.Stop() 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | res := make([]IssueDescription, len(raw)) 36 | for i, r := range raw { 37 | res[i] = IssueDescription{ 38 | ID: r.Key, 39 | Name: r.Fields.Summary, 40 | Description: r.Fields.Description, 41 | Type: r.Fields.Type.Name, 42 | Provider: JIRA_PROVIDER, 43 | } 44 | } 45 | 46 | return res, nil 47 | } 48 | 49 | func NewJiraProvider(r *git.Repository) (*JiraProvider, error) { 50 | c, err := r.Config() 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | enable, _ := c.LookupBool("jira.enabled") 56 | if !enable { 57 | return nil, nil 58 | } 59 | 60 | username, err := c.LookupString("jira.username") 61 | if err != nil { 62 | return nil, err 63 | } 64 | token, err := c.LookupString("jira.token") 65 | if err != nil { 66 | return nil, err 67 | } 68 | domain, err := c.LookupString("jira.domain") 69 | if err != nil { 70 | return nil, err 71 | } 72 | filter, err := c.LookupString("jira.filter") 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | tp := jira.BasicAuthTransport{ 78 | Username: username, 79 | Password: token, 80 | } 81 | jc, err := jira.NewClient(tp.Client(), domain) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | return &JiraProvider{client: jc, filter: filter}, nil 87 | } 88 | -------------------------------------------------------------------------------- /cmd/log-filter.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/b4nst/turbogit/pkg/format" 7 | git "github.com/libgit2/git2go/v33" 8 | ) 9 | 10 | type LogFilter func(c *git.Commit, co *format.CommitMessageOption) (keep, walk bool) 11 | 12 | var PassThru LogFilter = func(c *git.Commit, co *format.CommitMessageOption) (bool, bool) { return true, true } 13 | 14 | func ApplyFilters(c *git.Commit, co *format.CommitMessageOption, filters ...LogFilter) (keep, walk bool) { 15 | for _, filter := range filters { 16 | keep, walk = filter(c, co) 17 | if !walk || !keep { 18 | return 19 | } 20 | } 21 | return true, true 22 | } 23 | 24 | func Since(since *time.Time) LogFilter { 25 | if since == nil { 26 | return PassThru 27 | } 28 | 29 | return func(c *git.Commit, co *format.CommitMessageOption) (keep, walk bool) { 30 | d := c.Committer().When 31 | if d.Before(*since) { 32 | return false, false 33 | } 34 | return true, true 35 | } 36 | } 37 | 38 | func Until(until *time.Time) LogFilter { 39 | if until == nil { 40 | return PassThru 41 | } 42 | 43 | return func(c *git.Commit, co *format.CommitMessageOption) (keep, walk bool) { 44 | d := c.Committer().When 45 | if d.After(*until) { 46 | return false, true 47 | } 48 | return true, true 49 | } 50 | } 51 | 52 | func BreakingChange(is bool) LogFilter { 53 | return func(c *git.Commit, co *format.CommitMessageOption) (keep, walk bool) { 54 | return co.BreakingChanges == is, true 55 | } 56 | } 57 | 58 | func Type(types []format.CommitType) LogFilter { 59 | if len(types) <= 0 { 60 | return PassThru 61 | } 62 | 63 | mt := make(map[format.CommitType]bool, len(types)) 64 | for _, ct := range types { 65 | mt[ct] = true 66 | } 67 | return func(c *git.Commit, co *format.CommitMessageOption) (keep, walk bool) { 68 | return mt[co.Ctype], true 69 | } 70 | } 71 | 72 | func Scope(scopes []string) LogFilter { 73 | if len(scopes) <= 0 { 74 | return PassThru 75 | } 76 | 77 | ms := make(map[string]bool, len(scopes)) 78 | for _, s := range scopes { 79 | ms[s] = true 80 | } 81 | return func(c *git.Commit, co *format.CommitMessageOption) (keep, walk bool) { 82 | return ms[co.Scope], true 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /pkg/git/repo_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "testing" 8 | 9 | "github.com/b4nst/turbogit/pkg/test" 10 | git "github.com/libgit2/git2go/v33" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestGetrepo(t *testing.T) { 16 | dir, err := ioutil.TempDir("", "turbogit-test-getrepo") 17 | require.NoError(t, err) 18 | defer os.RemoveAll(dir) 19 | require.NoError(t, os.Chdir(dir)) 20 | 21 | // Working dir is not a repo 22 | _, err = Getrepo() 23 | assert.Error(t, err) 24 | if err != nil { 25 | assert.Contains(t, err.Error(), "could not find repository from") 26 | } 27 | 28 | // Working dir is a repo 29 | r, err := git.InitRepository(dir, false) 30 | require.NoError(t, err) 31 | repo, err := Getrepo() 32 | assert.NoError(t, err) 33 | assert.Equal(t, r, repo) 34 | } 35 | 36 | func TestStagedDiff(t *testing.T) { 37 | r := test.TestRepo(t) 38 | defer test.CleanupRepo(t, r) 39 | test.InitRepoConf(t, r) 40 | 41 | f := test.NewFile(t, r) 42 | test.StageFile(t, f, r) 43 | _, err := Commit(r, "feat: initial commit") 44 | require.NoError(t, err) 45 | // Staged stuff 46 | fmt.Fprintln(f, "Staged") 47 | test.StageFile(t, f, r) 48 | // Not staged stuff 49 | fmt.Fprintln(f, "Not_staged") 50 | test.NewFile(t, r) 51 | 52 | diff, err := StagedDiff(r) 53 | assert.NoError(t, err) 54 | deltas, err := diff.NumDeltas() 55 | assert.NoError(t, err) 56 | assert.Equal(t, 1, deltas) 57 | } 58 | 59 | func TestCurrentPatch(t *testing.T) { 60 | r := test.TestRepo(t) 61 | defer test.CleanupRepo(t, r) 62 | test.InitRepoConf(t, r) 63 | 64 | test.StageNewFile(t, r) 65 | c, err := Commit(r, "feat: initial commit") 66 | require.NoError(t, err) 67 | tree, err := c.Tree() 68 | require.NoError(t, err) 69 | 70 | test.StageNewFile(t, r) 71 | c1, err := Commit(r, "feat: second commit") 72 | require.NoError(t, err) 73 | tree1, err := c1.Tree() 74 | require.NoError(t, err) 75 | 76 | diff, err := r.DiffTreeToTree(tree, tree1, nil) 77 | require.NoError(t, err) 78 | 79 | s, err := PatchFromDiff(diff) 80 | fmt.Println(s) 81 | assert.NoError(t, err) 82 | assert.Contains(t, s, "new file mode 100644") 83 | } 84 | -------------------------------------------------------------------------------- /pkg/format/branch.go: -------------------------------------------------------------------------------- 1 | package format 2 | 3 | import ( 4 | "errors" 5 | "path" 6 | "regexp" 7 | "strings" 8 | "unicode" 9 | ) 10 | 11 | var ( 12 | forbiddenChar = regexp.MustCompile(`(?m)[\x60\?\*~^:\\\[\]]|@{|\.{2}`) 13 | blank = regexp.MustCompile(`\s+`) 14 | void = []byte("") 15 | sep = []byte("-") 16 | 17 | // A default type rewrite map 18 | DefaultTypeRewrite = map[string]string{ 19 | "feature": "feat", 20 | "bug": "fix", 21 | "task": "feat", 22 | "story": "feat", 23 | } 24 | ) 25 | 26 | // TugBranch represents a turbogit branch 27 | type TugBranch struct { 28 | // Branch type (e.g. 'feat', 'fix', 'user', etc...) 29 | Type string 30 | // Branch prefix (issue id, user name, etc...) 31 | Prefix string 32 | // Branch description 33 | Description string 34 | } 35 | 36 | // String builds a git-sanitized branch name. 37 | func (tb TugBranch) String() string { 38 | raw := path.Join(tb.Type, tb.Prefix, strings.ToLower(tb.Description)) 39 | return sanitizeBranch(raw) 40 | } 41 | 42 | // ParseBranch parses a given string into a TugBranch or return an error on bad format. 43 | func ParseBranch(s string) (TugBranch, error) { 44 | split := strings.SplitN(s, "/", 3) 45 | if len(split) < 2 { 46 | return TugBranch{}, errors.New("Bad branch format") 47 | } 48 | tb := TugBranch{} 49 | tb.Type = split[0] 50 | 51 | if len(split) < 3 { 52 | tb.Description = split[1] 53 | } else { 54 | tb.Prefix = split[1] 55 | tb.Description = split[2] 56 | } 57 | // Desanitize description 58 | desc := []rune(strings.ReplaceAll(tb.Description, "-", " ")) 59 | desc[0] = unicode.ToUpper(desc[0]) 60 | tb.Description = string(desc) 61 | 62 | return tb, nil 63 | } 64 | 65 | // WithType returns a TugBranch with the given type 't' or it's correlation in the rewrite map if it exists. 66 | func (tb TugBranch) WithType(t string, rewrite map[string]string) TugBranch { 67 | ts := strings.ToLower(t) 68 | if tr, ok := rewrite[ts]; ok { 69 | tb.Type = tr 70 | } else { 71 | tb.Type = ts 72 | } 73 | 74 | return tb 75 | } 76 | 77 | func sanitizeBranch(s string) string { 78 | sb := forbiddenChar.ReplaceAll([]byte(s), void) 79 | s = string(blank.ReplaceAll(sb, sep)) 80 | return strings.Trim(s, "./") 81 | } 82 | -------------------------------------------------------------------------------- /pkg/integrations/issue.go: -------------------------------------------------------------------------------- 1 | package integrations 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/b4nst/turbogit/pkg/format" 8 | "github.com/hpcloud/golor" 9 | "github.com/ktr0731/go-fuzzyfinder" 10 | ) 11 | 12 | type IssueDescription struct { 13 | // Issue id 14 | ID string 15 | // Issue Name 16 | Name string 17 | // Issue description 18 | Description string 19 | // Issue provider 20 | Provider string 21 | // Issue type 22 | Type string 23 | } 24 | 25 | // Format format an issue description into a colored or raw string. 26 | func (id IssueDescription) Format(color bool) string { 27 | var sb strings.Builder 28 | if color { 29 | id.ID = golor.Colorize(id.ID, golor.G, -1) 30 | id.Provider = golor.Colorize(id.Provider, golor.AssignColor(id.Provider), -1) 31 | } 32 | 33 | // Add ID 34 | sb.WriteString(id.ID) 35 | 36 | // ID - Title separator 37 | sb.WriteString(" - ") 38 | 39 | // Name 40 | sb.WriteString(id.Name) 41 | 42 | // Body 43 | if id.Description != "" { 44 | sb.WriteString("\n\n") 45 | sb.WriteString(id.Description) 46 | } 47 | 48 | // Provider 49 | sb.WriteString("\n\n") 50 | sb.WriteString("Issue provided by ") 51 | sb.WriteString(id.Provider) 52 | 53 | return sb.String() 54 | } 55 | 56 | // ShortFormat returns the shor representation string of an IssueDescription 57 | func (id IssueDescription) ShortFormat() string { 58 | return fmt.Sprintf("%s - %s", id.ID, id.Name) 59 | } 60 | 61 | // ToBranch create a format.TugBranch from the issue description 62 | func (id IssueDescription) ToBranch(rwtype map[string]string) format.TugBranch { 63 | return format.TugBranch{Prefix: id.ID, Description: id.Name}.WithType(id.Type, rwtype) 64 | } 65 | 66 | // SelectIssue prompts a fuzzy finder and returns the selected IssueDescription 67 | // or an error if something unexpected happened 68 | func SelectIssue(ids []IssueDescription, color bool) (IssueDescription, error) { 69 | idx, err := fuzzyfinder.Find(ids, func(i int) string { 70 | return ids[i].ShortFormat() 71 | }, 72 | fuzzyfinder.WithPreviewWindow(func(i, _, _ int) string { 73 | if i == -1 { 74 | return "" 75 | } 76 | return ids[i].Format(color) 77 | })) 78 | if err != nil { 79 | return IssueDescription{}, err 80 | } 81 | return ids[idx], nil 82 | } 83 | -------------------------------------------------------------------------------- /pkg/git/hooks.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "os/exec" 8 | "path" 9 | ) 10 | 11 | // Hooks 12 | 13 | func hookCmd(root string, hook string) (*exec.Cmd, error) { 14 | script := path.Join(root, ".git", "hooks", hook) 15 | info, err := os.Stat(script) 16 | if err != nil { 17 | if os.IsNotExist(err) { 18 | return nil, nil 19 | } 20 | return nil, err 21 | } 22 | if info.IsDir() { 23 | return nil, fmt.Errorf("Hook .git/hooks/%s is a directory, it should be an executable file.", hook) 24 | } 25 | return &exec.Cmd{ 26 | Dir: root, 27 | Path: script, 28 | Args: []string{script}, 29 | Stdout: os.Stdout, 30 | Stderr: os.Stderr, 31 | }, nil 32 | } 33 | 34 | func noArgHook(root string, hook string) error { 35 | cmd, err := hookCmd(root, hook) 36 | if err != nil { 37 | return err 38 | } 39 | if cmd == nil { 40 | return nil 41 | } 42 | 43 | fmt.Printf("Running %s hook...\n", hook) 44 | return cmd.Run() 45 | } 46 | 47 | func fileHook(root string, hook string, initial string) (out string, err error) { 48 | out = initial 49 | cmd, err := hookCmd(root, hook) 50 | if cmd == nil { 51 | return initial, nil 52 | } 53 | 54 | file, err := ioutil.TempFile("", "file-hook-") 55 | if err != nil { 56 | return 57 | } 58 | defer file.Close() 59 | _, err = file.Write([]byte(initial)) 60 | if err != nil { 61 | return 62 | } 63 | file.Close() 64 | 65 | cmd.Args = append(cmd.Args, file.Name()) 66 | fmt.Printf("Running %s hook...\n", hook) 67 | err = cmd.Run() 68 | if err != nil { 69 | return 70 | } 71 | 72 | file, err = os.Open(file.Name()) 73 | defer file.Close() 74 | if err != nil { 75 | return 76 | } 77 | content, err := ioutil.ReadAll(file) 78 | if err != nil { 79 | return 80 | } 81 | out = string(content) 82 | return 83 | } 84 | 85 | func PreCommitHook(root string) error { 86 | return noArgHook(root, "pre-commit") 87 | } 88 | 89 | func PostCommitHook(root string) error { 90 | return noArgHook(root, "post-commit") 91 | } 92 | 93 | func PrepareCommitMsgHook(root string) (msg string, err error) { 94 | return fileHook(root, "prepare-commit-msg", "") 95 | } 96 | 97 | func CommitMsgHook(root string, in string) (msg string, err error) { 98 | return fileHook(root, "commit-msg", in) 99 | } 100 | -------------------------------------------------------------------------------- /cmd/commit_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 banst 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "os" 26 | "testing" 27 | 28 | "github.com/b4nst/turbogit/internal/cmdbuilder" 29 | "github.com/b4nst/turbogit/pkg/format" 30 | "github.com/b4nst/turbogit/pkg/test" 31 | "github.com/spf13/cobra" 32 | "github.com/stretchr/testify/assert" 33 | "github.com/stretchr/testify/require" 34 | ) 35 | 36 | func TestParseCommitCmd(t *testing.T) { 37 | r := test.TestRepo(t) 38 | defer test.CleanupRepo(t, r) 39 | require.NoError(t, os.Chdir(r.Workdir())) 40 | 41 | cmd := &cobra.Command{} 42 | cmd.Flags().StringP("type", "t", "fix", "") 43 | cmd.Flags().BoolP("breaking-changes", "c", true, "") 44 | cmd.Flags().BoolP("edit", "e", true, "") 45 | cmd.Flags().StringP("scope", "s", "scope", "") 46 | cmd.Flags().BoolP("amend", "a", true, "") 47 | cmd.Flags().BoolP("fill", "f", true, "") 48 | 49 | cmdbuilder.MockRepoAware(cmd, r) 50 | 51 | cco, err := parseCommitCmd(cmd, []string{"hello", "world!"}) 52 | require.NoError(t, err) 53 | expect := commitOpt{ 54 | CType: format.FixCommit, 55 | Message: "hello world!", 56 | Scope: "scope", 57 | BreakingChanges: true, 58 | PromptEditor: true, 59 | Amend: true, 60 | Repo: r, 61 | Fill: true, 62 | } 63 | assert.Equal(t, expect, *cco) 64 | } 65 | -------------------------------------------------------------------------------- /pkg/format/branch_test.go: -------------------------------------------------------------------------------- 1 | package format 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestTugBranchString(t *testing.T) { 11 | tcs := map[string]struct { 12 | tb TugBranch 13 | expected string 14 | }{ 15 | "Without prefix": { 16 | tb: TugBranch{Type: "feat", Description: "A foo feature."}, 17 | expected: "feat/a-foo-feature", 18 | }, 19 | "With prefix": { 20 | tb: TugBranch{Type: "user", Prefix: "alice", Description: "Alice branch"}, 21 | expected: "user/alice/alice-branch", 22 | }, 23 | } 24 | 25 | for name, tc := range tcs { 26 | t.Run(name, func(t *testing.T) { 27 | assert.Equal(t, tc.expected, tc.tb.String()) 28 | }) 29 | } 30 | } 31 | 32 | func TestParseBranch(t *testing.T) { 33 | tcs := map[string]struct { 34 | str string 35 | err error 36 | expected TugBranch 37 | }{ 38 | "Without prefix": { 39 | str: "feat/a-foo-feature", 40 | err: nil, 41 | expected: TugBranch{Type: "feat", Description: "A foo feature"}, 42 | }, 43 | "With prefix": { 44 | str: "user/alice/alice-branch", 45 | err: nil, 46 | expected: TugBranch{Type: "user", Prefix: "alice", Description: "Alice branch"}, 47 | }, 48 | "Error branch": { 49 | str: "BADBEEF", 50 | err: errors.New("Bad branch format"), 51 | expected: TugBranch{}, 52 | }, 53 | } 54 | 55 | for name, tc := range tcs { 56 | t.Run(name, func(t *testing.T) { 57 | b, err := ParseBranch(tc.str) 58 | assert.Equal(t, tc.expected, b) 59 | if tc.err == nil { 60 | assert.NoError(t, err) 61 | } else { 62 | assert.EqualError(t, err, tc.err.Error()) 63 | } 64 | }) 65 | } 66 | } 67 | 68 | func TestTugBranchWithType(t *testing.T) { 69 | tcs := map[string]struct { 70 | t string 71 | rw map[string]string 72 | expected TugBranch 73 | }{ 74 | "Empty rewrite": { 75 | t: "type", 76 | rw: map[string]string{}, 77 | expected: TugBranch{Type: "type"}, 78 | }, 79 | "With rewrite": { 80 | t: "type", 81 | rw: map[string]string{"type": "foo"}, 82 | expected: TugBranch{Type: "foo"}, 83 | }, 84 | } 85 | 86 | for name, tc := range tcs { 87 | t.Run(name, func(t *testing.T) { 88 | tb := TugBranch{}.WithType(tc.t, tc.rw) 89 | assert.Equal(t, tc.expected, tb) 90 | }) 91 | } 92 | } 93 | 94 | func TestSanitizeBranch(t *testing.T) { 95 | dirty := "/A dirty branch/`should` ?be *~^:\\ [cleaned]../" 96 | 97 | assert.Equal(t, "A-dirty-branch/should-be-cleaned", sanitizeBranch(dirty)) 98 | } 99 | -------------------------------------------------------------------------------- /pkg/integrations/gitlab.go: -------------------------------------------------------------------------------- 1 | package integrations 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | 8 | tugit "github.com/b4nst/turbogit/pkg/git" 9 | git "github.com/libgit2/git2go/v33" 10 | "github.com/xanzy/go-gitlab" 11 | ) 12 | 13 | const ( 14 | // GitLab provider's name 15 | GITLAB_PROVIDER = "Gitlab" 16 | // GitLab cloud host 17 | GITLAB_CLOUD_HOST = "gitlab.com" 18 | // GitLab default protocol 19 | GITLAB_DEFAULT_PROTOCOL = "https" 20 | ) 21 | 22 | type GitLabProvider struct { 23 | project string 24 | client *gitlab.Client 25 | } 26 | 27 | // Search return a list of issues of a GitLab project 28 | func (glp GitLabProvider) Search() ([]IssueDescription, error) { 29 | scope := "assigned_to_me" 30 | issues, _, err := glp.client.Issues.ListProjectIssues(glp.project, &gitlab.ListProjectIssuesOptions{Scope: &scope}) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | res := make([]IssueDescription, len(issues)) 36 | for i, r := range issues { 37 | res[i] = IssueDescription{ 38 | ID: fmt.Sprint(r.IID), 39 | Name: r.Title, 40 | Description: r.Description, 41 | // TODO provide type 42 | Provider: GITLAB_PROVIDER, 43 | } 44 | } 45 | return res, nil 46 | } 47 | 48 | func NewGitLabProvider(r *git.Repository) (*GitLabProvider, error) { 49 | c, err := r.Config() 50 | if err != nil { 51 | return nil, err 52 | } 53 | enabled, err := c.LookupBool("gitlab.enabled") 54 | if err == nil && !enabled { 55 | return nil, nil 56 | } 57 | remote, err := tugit.ParseRemote(r, "origin", true) 58 | if err != nil { 59 | return nil, err 60 | } 61 | if !isGitLabRemote(remote, c) { 62 | if enabled { 63 | return nil, fmt.Errorf("GitLab provider is enabled but %s is not a known gitlab host. Please add it to gitlab.hosts config or disable GitLab provider for this repository", remote.Hostname()) 64 | } 65 | return nil, nil 66 | } 67 | token, err := c.LookupString("gitlab.token") 68 | if err != nil { 69 | return nil, err 70 | } 71 | protocol, err := c.LookupString("gitlab.protocol") 72 | if err != nil { 73 | protocol = GITLAB_DEFAULT_PROTOCOL 74 | } 75 | baseUrl := protocol + "://" + remote.Host 76 | client, err := gitlab.NewClient(token, gitlab.WithBaseURL(baseUrl)) 77 | if err != nil { 78 | return nil, err 79 | } 80 | fmt.Println(remote.Path) 81 | project := strings.TrimSuffix(remote.Path, ".git") 82 | 83 | return &GitLabProvider{client: client, project: project}, nil 84 | } 85 | 86 | func isGitLabRemote(remote *url.URL, c *git.Config) bool { 87 | hosts := []string{GITLAB_CLOUD_HOST} 88 | if rhosts, err := c.LookupString("gitlab.hosts"); err == nil { 89 | hosts = append(hosts, strings.Split(rhosts, ",")...) 90 | } 91 | remoteHost := remote.Hostname() 92 | for _, h := range hosts { 93 | if h == remoteHost { 94 | return true 95 | } 96 | } 97 | return false 98 | } 99 | -------------------------------------------------------------------------------- /cmd/completion.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2020 banst 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "fmt" 26 | 27 | "github.com/spf13/cobra" 28 | ) 29 | 30 | var validCompletionArgs = []string{"bash", "zsh", "fish", "powershell"} 31 | 32 | func init() { 33 | RootCmd.AddCommand(completionCmd) 34 | } 35 | 36 | // completionCmd represents the completion command 37 | var completionCmd = &cobra.Command{ 38 | Use: fmt.Sprintf("completion %s", validCompletionArgs), 39 | Short: "Generate completion script", 40 | DisableFlagsInUseLine: true, 41 | Example: ` 42 | Bash: 43 | 44 | $ source <(tug completion bash) 45 | 46 | # To load completions for each session, execute once: 47 | Linux: 48 | $ tug completion bash > /etc/bash_completion.d/tug 49 | MacOS: 50 | $ tug completion bash > /usr/local/etc/bash_completion.d/tug 51 | 52 | Zsh: 53 | 54 | $ source <(tug completion zsh) 55 | 56 | # To load completions for each session, execute once: 57 | $ tug completion zsh > "${fpath[1]}/_tug" 58 | 59 | Fish: 60 | 61 | $ tug completion fish | source 62 | 63 | # To load completions for each session, execute once: 64 | $ tug completion fish > ~/.config/fish/completions/tug.fish 65 | `, 66 | ValidArgs: validCompletionArgs, 67 | Args: cobra.ExactValidArgs(1), 68 | 69 | Run: func(cmd *cobra.Command, args []string) { 70 | cobra.CheckErr(runCompletion(cmd, args)) 71 | }, 72 | } 73 | 74 | func runCompletion(cmd *cobra.Command, args []string) error { 75 | switch args[0] { 76 | case "bash": 77 | cmd.Root().GenBashCompletion(cmd.OutOrStdout()) 78 | case "zsh": 79 | cmd.Root().GenZshCompletion(cmd.OutOrStdout()) 80 | case "fish": 81 | cmd.Root().GenFishCompletion(cmd.OutOrStdout(), true) 82 | case "powershell": 83 | cmd.Root().GenPowerShellCompletion(cmd.OutOrStdout()) 84 | default: 85 | return fmt.Errorf("%s is not a supported shell", args[0]) 86 | } 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /cmd/release_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/b4nst/turbogit/pkg/format" 8 | "github.com/blang/semver/v4" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestParseDescription(t *testing.T) { 13 | t.Parallel() 14 | tests := []struct { 15 | name string 16 | input string 17 | err error 18 | v *semver.Version 19 | offset int 20 | }{ 21 | {"parse desc 1", "v1.0.0", nil, &[]semver.Version{semver.MustParse("1.0.0")}[0], 1}, 22 | {"parse desc 2", "v1.0.0-2-ab23e5f1", nil, &[]semver.Version{semver.MustParse("1.0.0")}[0], 3}, 23 | {"parse desc 3", "latest", nil, nil, 1}, 24 | {"parse desc 4", "latest-3-5570541a", nil, nil, 4}, 25 | } 26 | 27 | for _, tt := range tests { 28 | tt := tt 29 | t.Run(tt.name, func(t *testing.T) { 30 | t.Parallel() 31 | v, offset, err := parseDescription(tt.input) 32 | if tt.err != nil { 33 | assert.EqualError(t, err, tt.err.Error()) 34 | } else { 35 | assert.NoError(t, err) 36 | } 37 | assert.Equal(t, tt.v, v) 38 | assert.Equal(t, tt.offset, offset) 39 | }) 40 | } 41 | } 42 | 43 | func TestBumpVersion(t *testing.T) { 44 | t.Parallel() 45 | tests := []struct { 46 | name string 47 | curr *semver.Version 48 | bump format.Bump 49 | err error 50 | expected *semver.Version 51 | }{ 52 | {"bump version 1", &semver.Version{Major: 0, Minor: 0, Patch: 0}, format.BUMP_PATCH, nil, &semver.Version{Major: 0, Minor: 0, Patch: 1}}, 53 | {"bump version 2", &semver.Version{Major: 3, Minor: 4, Patch: 7}, format.BUMP_PATCH, nil, &semver.Version{Major: 3, Minor: 4, Patch: 8}}, 54 | {"bump version 3", &semver.Version{Major: 0, Minor: 0, Patch: 0}, format.BUMP_MINOR, nil, &semver.Version{Major: 0, Minor: 1, Patch: 0}}, 55 | {"bump version 4", &semver.Version{Major: 3, Minor: 4, Patch: 7}, format.BUMP_MINOR, nil, &semver.Version{Major: 3, Minor: 5, Patch: 0}}, 56 | {"bump version 5", &semver.Version{Major: 0, Minor: 0, Patch: 0}, format.BUMP_MAJOR, nil, &semver.Version{Major: 0, Minor: 1, Patch: 0}}, 57 | {"bump version 6", &semver.Version{Major: 3, Minor: 4, Patch: 7}, format.BUMP_MAJOR, nil, &semver.Version{Major: 4, Minor: 0, Patch: 0}}, 58 | {"bump version 7", nil, format.BUMP_PATCH, errors.New("current version must not be nil"), nil}, 59 | {"bump version 8", nil, format.BUMP_MINOR, errors.New("current version must not be nil"), nil}, 60 | {"bump version 9", nil, format.BUMP_MAJOR, errors.New("current version must not be nil"), nil}, 61 | {"bump version 10", &semver.Version{Major: 0, Minor: 0, Patch: 0}, format.BUMP_NONE, nil, &semver.Version{Major: 0, Minor: 0, Patch: 0}}, 62 | {"bump version 11", &semver.Version{Major: 3, Minor: 4, Patch: 7}, format.BUMP_NONE, nil, &semver.Version{Major: 3, Minor: 4, Patch: 7}}, 63 | {"bump version 12", nil, format.BUMP_MAJOR, errors.New("current version must not be nil"), nil}, 64 | } 65 | 66 | for _, tt := range tests { 67 | tt := tt 68 | t.Run(tt.name, func(t *testing.T) { 69 | t.Parallel() 70 | err := bumpVersion(tt.curr, tt.bump) 71 | if tt.err != nil { 72 | assert.EqualError(t, err, tt.err.Error()) 73 | } else { 74 | assert.NoError(t, err) 75 | } 76 | assert.Equal(t, tt.expected, tt.curr) 77 | }) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /pkg/integrations/jira_test.go: -------------------------------------------------------------------------------- 1 | package integrations 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/andygrunwald/go-jira" 10 | "github.com/b4nst/turbogit/pkg/test" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestJiraProvider(t *testing.T) { 16 | r := test.TestRepo(t) 17 | defer test.CleanupRepo(t, r) 18 | test.InitRepoConf(t, r) 19 | 20 | p, err := NewJiraProvider(r) 21 | assert.NoError(t, err) 22 | assert.Nil(t, p) 23 | 24 | c, err := r.Config() 25 | require.NoError(t, err) 26 | require.NoError(t, c.SetBool("jira.enabled", true)) 27 | 28 | // No username 29 | p, err = NewJiraProvider(r) 30 | assert.EqualError(t, err, "config value 'jira.username' was not found") 31 | assert.Nil(t, p) 32 | 33 | // No token 34 | require.NoError(t, c.SetString("jira.username", "alice@ecorp.com")) 35 | p, err = NewJiraProvider(r) 36 | assert.EqualError(t, err, "config value 'jira.token' was not found") 37 | assert.Nil(t, p) 38 | 39 | // No domain 40 | require.NoError(t, c.SetString("jira.token", "supersecret")) 41 | p, err = NewJiraProvider(r) 42 | assert.EqualError(t, err, "config value 'jira.domain' was not found") 43 | assert.Nil(t, p) 44 | 45 | // No filter 46 | require.NoError(t, c.SetString("jira.domain", "foo.bar")) 47 | p, err = NewJiraProvider(r) 48 | assert.EqualError(t, err, "config value 'jira.filter' was not found") 49 | assert.Nil(t, p) 50 | 51 | // All's ok 52 | require.NoError(t, c.SetString("jira.filter", "query filter")) 53 | p, err = NewJiraProvider(r) 54 | assert.NoError(t, err) 55 | assert.IsType(t, &JiraProvider{}, p) 56 | } 57 | 58 | func TestSearch(t *testing.T) { 59 | filter := "foofilter" 60 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 61 | assert.Equal(t, filter, r.URL.Query().Get("jql")) 62 | 63 | js, err := json.Marshal(struct { 64 | Issues []jira.Issue `json:"issues" structs:"issues"` 65 | StartAt int `json:"startAt" structs:"startAt"` 66 | MaxResults int `json:"maxResults" structs:"maxResults"` 67 | Total int `json:"total" structs:"total"` 68 | }{ 69 | StartAt: 0, 70 | MaxResults: 50, 71 | Total: 1, 72 | Issues: []jira.Issue{ 73 | { 74 | Key: "B#NST", 75 | Fields: &jira.IssueFields{ 76 | Summary: "issue", 77 | Description: "description", 78 | Type: jira.IssueType{ 79 | Name: "type", 80 | }, 81 | }, 82 | }, 83 | }, 84 | }) 85 | require.NoError(t, err) 86 | w.Header().Set("Content-Type", "application/json") 87 | w.Write(js) 88 | })) 89 | defer ts.Close() 90 | 91 | client, err := jira.NewClient(ts.Client(), ts.URL) 92 | require.NoError(t, err) 93 | provider := JiraProvider{ 94 | filter: filter, 95 | client: client, 96 | } 97 | ids, err := provider.Search() 98 | assert.NoError(t, err) 99 | assert.Len(t, ids, 1) 100 | assert.Equal(t, IssueDescription{ 101 | ID: "B#NST", 102 | Name: "issue", 103 | Description: "description", 104 | Type: "type", 105 | Provider: JIRA_PROVIDER, 106 | }, ids[0]) 107 | 108 | } 109 | -------------------------------------------------------------------------------- /cmd/check.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 banst 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "fmt" 26 | 27 | "github.com/b4nst/turbogit/internal/cmdbuilder" 28 | "github.com/b4nst/turbogit/pkg/format" 29 | "github.com/hashicorp/go-multierror" 30 | git "github.com/libgit2/git2go/v33" 31 | "github.com/spf13/cobra" 32 | ) 33 | 34 | func init() { 35 | RootCmd.AddCommand(CheckCmd) 36 | 37 | CheckCmd.Flags().BoolP("all", "a", false, "Check all the commits in refs/*, along with HEAD") 38 | CheckCmd.Flags().StringP("from", "f", "HEAD", "Commit to start from. Can be a hash or any revision as accepted by rev parse.") 39 | 40 | cmdbuilder.RepoAware(CheckCmd) 41 | } 42 | 43 | var CheckCmd = &cobra.Command{ 44 | Use: "check", 45 | Short: "Ensure the history follows conventional commit", 46 | Example: ` 47 | # Run a check from HEAD 48 | $ tug check 49 | `, 50 | Args: cobra.NoArgs, 51 | 52 | Run: func(cmd *cobra.Command, args []string) { 53 | opt := &checkOpt{} 54 | var err error 55 | 56 | opt.All, err = cmd.Flags().GetBool("all") 57 | cobra.CheckErr(err) 58 | 59 | opt.From, err = cmd.Flags().GetString("from") 60 | cobra.CheckErr(err) 61 | 62 | opt.Repo = cmdbuilder.GetRepo(cmd) 63 | 64 | cobra.CheckErr(runCheck(opt)) 65 | 66 | cmd.Println("repository compliant.") 67 | }, 68 | } 69 | 70 | type checkOpt struct { 71 | All bool 72 | From string 73 | Repo *git.Repository 74 | } 75 | 76 | func runCheck(opt *checkOpt) error { 77 | walk, err := opt.Repo.Walk() 78 | if err != nil { 79 | return err 80 | } 81 | if opt.All { 82 | if err := walk.PushGlob("refs/*"); err != nil { 83 | return err 84 | } 85 | } else { 86 | from, err := opt.Repo.RevparseSingle(opt.From) 87 | if err != nil { 88 | return err 89 | } 90 | if err := walk.Push(from.Id()); err != nil { 91 | return err 92 | } 93 | } 94 | 95 | merr := &multierror.Error{} 96 | if err := walk.Iterate(walker(merr)); err != nil { 97 | return err 98 | } 99 | return merr.ErrorOrNil() 100 | } 101 | 102 | func walker(merr *multierror.Error) git.RevWalkIterator { 103 | return func(c *git.Commit) bool { 104 | sid, err := c.ShortId() 105 | if err != nil { 106 | multierror.Append(merr, err) 107 | return true 108 | } 109 | co := format.ParseCommitMsg(c.Message()) 110 | if co == nil { 111 | multierror.Append(merr, fmt.Errorf("%s ('%s') is not compliant", sid, c.Summary())) 112 | } 113 | return true 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | go: 11 | name: Build and test 12 | strategy: 13 | matrix: 14 | os: 15 | - ubuntu 16 | - macos 17 | # TODO: https://github.com/b4nst/turbogit/issues/48 18 | # - windows 19 | include: 20 | # - os: windows 21 | # shell: msys2 {0} 22 | - os: ubuntu 23 | shell: bash 24 | - os: macos 25 | shell: bash 26 | runs-on: ${{ matrix.os }}-latest 27 | defaults: 28 | run: 29 | shell: ${{ matrix.shell }} 30 | steps: 31 | - name: Install windows dependencies 32 | uses: msys2/setup-msys2@v2 33 | if: matrix.os == 'windows' 34 | with: 35 | update: true 36 | msystem: CLANG64 37 | install: >- 38 | pkg-config 39 | make 40 | path-type: inherit 41 | 42 | - name: Set up Go 1.x 43 | uses: actions/setup-go@v2 44 | with: 45 | go-version: ^1.17 46 | id: go 47 | 48 | - name: Check out code into the Go module directory 49 | uses: actions/checkout@v2 50 | with: 51 | submodules: recursive 52 | 53 | - name: Build 54 | run: make build 55 | 56 | - name: Download codeclimate reporter 57 | if: matrix.os == 'ubuntu' 58 | run: wget -O cc-reporter https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 && chmod +x cc-reporter 59 | 60 | - name: Prepare code coverage 61 | if: matrix.os == 'ubuntu' 62 | run: ./cc-reporter before-build 63 | 64 | - name: Test 65 | run: make test 66 | 67 | - name: Upload code coverage 68 | if: matrix.os == 'ubuntu' 69 | env: 70 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 71 | run: ./cc-reporter after-build --coverage-input-type gocov -p github.com/b4nst/turbogit 72 | 73 | doc: 74 | name: Deploy documentation 75 | runs-on: ubuntu-latest 76 | steps: 77 | - name: Check out code into the Go module directory 78 | uses: actions/checkout@v2 79 | with: 80 | submodules: recursive 81 | - name: Set up Go 1.x 82 | uses: actions/setup-go@v2 83 | with: 84 | go-version: ^1.17 85 | id: go 86 | - name: Install doctave 87 | run: | 88 | mkdir -p $GITHUB_WORKSPACE/bin 89 | curl -sSL https://github.com/Doctave/doctave/releases/download/0.4.2/doctave-0.4.2-x86_64-unknown-linux-musl.tar.gz | tar xvz 90 | mv doctave-0.4.2-x86_64-unknown-linux-musl/doctave $GITHUB_WORKSPACE/bin/doctave 91 | chmod +x $GITHUB_WORKSPACE/bin/doctave 92 | echo "$GITHUB_WORKSPACE/bin" >> $GITHUB_PATH 93 | - uses: supplypike/setup-bin@v1 94 | name: Install doctave 95 | with: 96 | uri: https://github.com/Doctave/doctave/releases/download/0.4.2/doctave-0.4.2-x86_64-unknown-linux-musl.tar.gz 97 | name: doctave 98 | version: 0.4.2 99 | - name: Build 100 | run: make doc 101 | - name: Deploy (dry-run) 102 | if: success() 103 | uses: crazy-max/ghaction-github-pages@v3 104 | with: 105 | target_branch: gh-pages 106 | build_dir: dist/doc/site 107 | dry_run: true 108 | env: 109 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 110 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at bastyen.a@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | create_release: 10 | name: Create release 11 | runs-on: ubuntu-latest 12 | outputs: 13 | upload_url: ${{ steps.create_release.outputs.upload_url }} 14 | raw_tag: ${{ steps.get_version.outputs.VERSION }} 15 | tag: ${{ steps.get_version.outputs.VERSION_NO_PREFIX }} 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v2 19 | - name: Create Release 20 | id: create_release 21 | uses: actions/create-release@v1 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | with: 25 | tag_name: ${{ github.event.ref }} 26 | release_name: Release ${{ github.event.ref }} 27 | draft: false 28 | prerelease: false 29 | - name: Get the version 30 | id: get_version 31 | run: | 32 | echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} 33 | echo ::set-output name=VERSION_NO_PREFIX::${GITHUB_REF#refs/tags/v} 34 | 35 | build: 36 | name: Build binaries 37 | strategy: 38 | matrix: 39 | os: [ubuntu-latest, macos-latest] 40 | goarch: [amd64] 41 | runs-on: ${{ matrix.os }} 42 | needs: create_release 43 | env: 44 | GOARCH: ${{ matrix.goarch }} 45 | steps: 46 | - name: Set up Go 1.x 47 | uses: actions/setup-go@v2 48 | with: 49 | go-version: ^1.17 50 | id: go 51 | - name: Check out code into the Go module directory 52 | uses: actions/checkout@v2 53 | with: 54 | submodules: recursive 55 | - name: Build 56 | id: build 57 | env: 58 | TUG_COMMIT: ${{ github.sha }} 59 | TUG_VERSION: ${{ needs.create_release.outputs.tag }} 60 | RAW_TAG: ${{ needs.create_release.outputs.raw_tag }} 61 | run: | 62 | make build 63 | echo ::set-output name=ASSET_NAME::turbogit_${RAW_TAG}_$(go env GOOS)_$(go env GOARCH).tar.gz 64 | - name: Package 65 | env: 66 | ASSET_NAME: ${{ steps.build.outputs.ASSET_NAME }} 67 | run: tar -zcvf ${ASSET_NAME} -C dist/bin/ . 68 | - name: Upload Release Asset 69 | uses: actions/upload-release-asset@v1 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | ASSET_NAME: ${{ steps.build.outputs.ASSET_NAME }} 73 | with: 74 | upload_url: ${{ needs.create_release.outputs.upload_url }} 75 | asset_path: ./${{ env.ASSET_NAME }} 76 | asset_name: ${{ env.ASSET_NAME }} 77 | asset_content_type: application/octet-stream 78 | 79 | doc: 80 | name: Deploy documentation 81 | runs-on: ubuntu-latest 82 | steps: 83 | - name: Check out code into the Go module directory 84 | uses: actions/checkout@v2 85 | with: 86 | submodules: recursive 87 | - name: Set up Go 1.x 88 | uses: actions/setup-go@v2 89 | with: 90 | go-version: ^1.17 91 | id: go 92 | - name: Install doctave 93 | run: | 94 | mkdir -p $GITHUB_WORKSPACE/bin 95 | curl -sSL https://github.com/Doctave/doctave/releases/download/0.4.2/doctave-0.4.2-x86_64-unknown-linux-musl.tar.gz | tar xvz 96 | mv doctave-0.4.2-x86_64-unknown-linux-musl/doctave $GITHUB_WORKSPACE/bin/doctave 97 | chmod +x $GITHUB_WORKSPACE/bin/doctave 98 | echo "$GITHUB_WORKSPACE/bin" >> $GITHUB_PATH 99 | - name: Build 100 | run: make doc 101 | - name: Deploy 102 | if: success() 103 | uses: crazy-max/ghaction-github-pages@v3 104 | with: 105 | target_branch: gh-pages 106 | build_dir: dist/doc/site 107 | env: 108 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 109 | -------------------------------------------------------------------------------- /cmd/new.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 banst 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "errors" 26 | "strings" 27 | 28 | "github.com/b4nst/turbogit/internal/cmdbuilder" 29 | "github.com/b4nst/turbogit/pkg/format" 30 | "github.com/b4nst/turbogit/pkg/integrations" 31 | git "github.com/libgit2/git2go/v33" 32 | "github.com/spf13/cobra" 33 | ) 34 | 35 | func init() { 36 | RootCmd.AddCommand(NewCmd) 37 | 38 | cmdbuilder.RepoAware(NewCmd) 39 | } 40 | 41 | // NewCmd represents the base command when called without any subcommands 42 | var NewCmd = &cobra.Command{ 43 | Use: "new [type] [description]", 44 | Short: "Start a new branch.", 45 | Long: ` 46 | If you don't give any argument, the command will look for issue in pre-configured issues provider. 47 | The issue ID will be used as a prefix. 48 | If type=user(s), a prefix with your git username will be added to the branch name. 49 | `, 50 | Example: ` 51 | # Start new feature feat/my-feature from current branch 52 | $ tug new feat my feature 53 | 54 | # Start working on a user branch (my-branch). This will create user/alice/my-branch, given that alice is your git username 55 | $ tug new user my branch 56 | 57 | # Start working on a new issue (an issue provider must be configured on the repositoty) 58 | $ tug new 59 | `, 60 | Args: func(cmd *cobra.Command, args []string) error { 61 | if len(args) == 1 { 62 | return errors.New("accepts 0 or at least 2 args, received 1") 63 | } 64 | return nil 65 | }, 66 | 67 | Run: func(cmd *cobra.Command, args []string) { 68 | opt := &newOpt{} 69 | var err error 70 | 71 | opt.Repo = cmdbuilder.GetRepo(cmd) 72 | 73 | if len(args) <= 0 { 74 | opt.NewBranch, err = promptProviderBranch(opt.Repo) 75 | cobra.CheckErr(err) 76 | } else { 77 | opt.NewBranch = format.TugBranch{Description: strings.Join(args[1:], " ")}. 78 | WithType(args[0], format.DefaultTypeRewrite) 79 | } 80 | 81 | // User(s) branch 82 | if opt.NewBranch.Type == "user" || opt.NewBranch.Type == "users" { 83 | // Get user name from config 84 | config, err := opt.Repo.Config() 85 | cobra.CheckErr(err) 86 | 87 | username, _ := config.LookupString("user.name") 88 | if username == "" { 89 | cobra.CheckErr("You need to configure your username before creating a user branch.") 90 | } 91 | opt.NewBranch.Prefix = username 92 | } 93 | 94 | cobra.CheckErr(runNew(opt)) 95 | }, 96 | } 97 | 98 | type newOpt struct { 99 | NewBranch format.TugBranch 100 | Repo *git.Repository 101 | } 102 | 103 | func runNew(opt *newOpt) error { 104 | r := opt.Repo 105 | 106 | var t *git.Commit = nil 107 | head, err := r.Head() 108 | if err == nil { 109 | t, err = r.LookupCommit(head.Target()) 110 | if err != nil { 111 | return err 112 | } 113 | } 114 | if t == nil { 115 | return errors.New("No commit to create branch from, please create the initial commit") 116 | } 117 | 118 | // Create new branch 119 | b, err := r.CreateBranch(opt.NewBranch.String(), t, false) 120 | if err != nil { 121 | return err 122 | } 123 | bc, err := r.LookupCommit(b.Target()) 124 | if err != nil { 125 | return err 126 | } 127 | tree, err := r.LookupTree(bc.TreeId()) 128 | if err != nil { 129 | return err 130 | } 131 | 132 | // Checkout the branch 133 | err = r.CheckoutTree(tree, &git.CheckoutOpts{Strategy: git.CheckoutSafe}) 134 | if err != nil { 135 | return err 136 | } 137 | err = r.SetHead(b.Reference.Name()) 138 | return err 139 | } 140 | 141 | func promptProviderBranch(repo *git.Repository) (nb format.TugBranch, err error) { 142 | providers, err := integrations.Issuers(repo) 143 | if err != nil { 144 | return nb, err 145 | } 146 | issues := []integrations.IssueDescription{} 147 | for _, p := range providers { 148 | // TODO concurrent search 149 | pIssues, err := p.Search() 150 | if err != nil { 151 | return nb, err 152 | } 153 | issues = append(issues, pIssues...) 154 | } 155 | issue, err := integrations.SelectIssue(issues, false) 156 | if err != nil { 157 | return nb, err 158 | } 159 | 160 | return issue.ToBranch(format.DefaultTypeRewrite), nil 161 | } 162 | -------------------------------------------------------------------------------- /scripts/gen-doc.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | "path" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/b4nst/turbogit/cmd" 13 | "github.com/spf13/cobra/doc" 14 | "gopkg.in/yaml.v3" 15 | ) 16 | 17 | const ( 18 | Workdir = "dist/doc" 19 | AssetsSrcDir = "assets" 20 | titleTemplate = `--- 21 | title: %s 22 | --- 23 | ` 24 | ) 25 | 26 | var ( 27 | DocsDir = path.Join(Workdir, "docs") 28 | IncludeDir = path.Join(DocsDir, "_include") 29 | ) 30 | 31 | func main() { 32 | log.Println("Ensure output dir exists and is empty...") 33 | checkErr(os.RemoveAll(Workdir)) 34 | checkErr(os.MkdirAll(Workdir, 0700)) 35 | 36 | log.Println("Prepare doctave structure...") 37 | checkErr(os.MkdirAll(IncludeDir, 0700)) 38 | doctave := Doctave{ 39 | Title: "Turbogit", 40 | Colors: Colors{Main: "#e96900"}, 41 | Logo: "assets/tu_logo.png", 42 | BasePath: "/turbogit", 43 | } 44 | 45 | log.Println("Copy assets...") 46 | _, err := Copy(path.Join(IncludeDir, doctave.Logo), path.Join(AssetsSrcDir, "tu_logo.png"), "") 47 | checkErr(err) 48 | 49 | log.Println("Copy static documentation") 50 | _, err = Copy(path.Join(DocsDir, "contributing.md"), "CONTRIBUTING.md", "Contributing") 51 | _, err = Copy(path.Join(DocsDir, "code-of-conduct.md"), "CODE_OF_CONDUCT.md", "Code of conduct") 52 | _, err = Copy(path.Join(DocsDir, "README.md"), "README.md", "Turbogit") 53 | _, err = Copy(path.Join(DocsDir, "installation.md"), "assets/docs/installation.md", "Installation") 54 | _, err = Copy(path.Join(DocsDir, "integration.md"), "assets/docs/integration.md", "Integration") 55 | _, err = Copy(path.Join(DocsDir, "shell-completion.md"), "assets/docs/shell-completion.md", "Shell completion") 56 | checkErr(err) 57 | 58 | cmdDir := path.Join(DocsDir, "commands") 59 | log.Println("Ensure commands dir exists...") 60 | checkErr(os.MkdirAll(cmdDir, 0700)) 61 | log.Println("Generate commands documentation...") 62 | filePrepender := func(filename string) string { 63 | name := filepath.Base(filename) 64 | base := strings.TrimSuffix(name, path.Ext(name)) 65 | return fmt.Sprintf(titleTemplate, strings.ReplaceAll(base, "_", " ")) 66 | } 67 | linkHandler := func(name string) string { 68 | base := strings.TrimSuffix(name, path.Ext(name)) 69 | return "/commands/" + strings.ToLower(base) 70 | } 71 | checkErr(doc.GenMarkdownTreeCustom(cmd.RootCmd, cmdDir, filePrepender, linkHandler)) 72 | _, err = Copy(path.Join(cmdDir, "README.md"), "assets/docs/usage.md", "Usage") 73 | checkErr(err) 74 | 75 | log.Println("Generate nav bar...") 76 | doctave.Navigation = []Nav{ 77 | { 78 | Path: "docs/installation.md", 79 | }, 80 | { 81 | Path: strings.TrimPrefix(cmdDir, Workdir+"/"), 82 | Children: "*", 83 | }, 84 | { 85 | Path: "docs/integration.md", 86 | }, 87 | { 88 | Path: "docs/shell-completion.md", 89 | }, 90 | { 91 | Path: "docs/contributing.md", 92 | }, 93 | { 94 | Path: "docs/code-of-conduct.md", 95 | }, 96 | } 97 | 98 | log.Println("Marshal doctave configuration...") 99 | d, err := yaml.Marshal(doctave) 100 | checkErr(err) 101 | checkErr(os.WriteFile(path.Join(Workdir, "doctave.yaml"), d, 0700)) 102 | 103 | log.Println("Add GitHub page files...") 104 | nj, err := os.Create(path.Join(IncludeDir, ".nojekyll")) 105 | checkErr(err) 106 | defer nj.Close() 107 | _, err = Copy(path.Join(IncludeDir, "favicon.ico"), path.Join(AssetsSrcDir, "tu_logo.ico"), "") 108 | 109 | log.Println("Add head tags") 110 | headTmpl := ` 111 | 112 | ` 113 | head, err := os.Create(path.Join(IncludeDir, "_head.html")) 114 | checkErr(err) 115 | defer head.Close() 116 | _, err = head.WriteString(fmt.Sprintf(headTmpl, path.Join("/", doctave.BasePath, "favicon.ico"))) 117 | checkErr(err) 118 | 119 | log.Println("Done.") 120 | log.Println("Use 'doctave serve' or 'doctave build'") 121 | } 122 | 123 | func checkErr(err error) { 124 | if err != nil { 125 | log.Fatal(err) 126 | } 127 | } 128 | 129 | func Copy(dst, src, rename string) (int64, error) { 130 | stat, err := os.Stat(src) 131 | if err != nil { 132 | return 0, err 133 | } 134 | if !stat.Mode().IsRegular() { 135 | return 0, fmt.Errorf("%s is not a regular file", src) 136 | } 137 | 138 | source, err := os.Open(src) 139 | if err != nil { 140 | return 0, err 141 | } 142 | defer source.Close() 143 | 144 | if err := os.MkdirAll(path.Dir(dst), 0700); err != nil { 145 | return 0, err 146 | } 147 | 148 | destination, err := os.Create(dst) 149 | if err != nil { 150 | return 0, err 151 | } 152 | if rename != "" { 153 | fmt.Fprintf(destination, titleTemplate, rename) 154 | } 155 | defer destination.Close() 156 | return io.Copy(destination, source) 157 | } 158 | 159 | type Doctave struct { 160 | Title string `yaml:"title"` 161 | Port uint `yaml:"port,omitempty"` 162 | BasePath string `yaml:"base_path,omitempty"` 163 | DocsDir string `yaml:"docs_dir,omitempty"` 164 | Logo string `yaml:"logo,omitempty"` 165 | Colors Colors `yaml:"colors,omitempty"` 166 | Navigation []Nav `yaml:"navigation,omitempty"` 167 | } 168 | 169 | type Nav struct { 170 | Path string `yaml:"path"` 171 | Children string `yaml:"children,omitempty"` 172 | } 173 | 174 | type Colors struct { 175 | Main string `yaml:"main,omitempty"` 176 | } 177 | -------------------------------------------------------------------------------- /cmd/release.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 banst 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "errors" 26 | "fmt" 27 | "regexp" 28 | "strconv" 29 | 30 | "github.com/b4nst/turbogit/internal/cmdbuilder" 31 | "github.com/b4nst/turbogit/pkg/format" 32 | "github.com/blang/semver/v4" 33 | git "github.com/libgit2/git2go/v33" 34 | "github.com/spf13/cobra" 35 | ) 36 | 37 | func init() { 38 | RootCmd.AddCommand(ReleaseCmd) 39 | 40 | ReleaseCmd.Flags().BoolP("dry-run", "d", false, "Do not tag.") 41 | ReleaseCmd.Flags().StringP("prefix", "p", "v", "Tag prefix.") 42 | 43 | cmdbuilder.RepoAware(ReleaseCmd) 44 | } 45 | 46 | // ReleaseCmd represents the base command when called without any subcommands 47 | var ReleaseCmd = &cobra.Command{ 48 | Use: "release", 49 | Short: "Release a SemVer tag based on the commit history.", 50 | Example: ` 51 | # Given that the last release tag was v1.0.0, some feature were committed but no breaking changes. 52 | # The following command will create the tag v1.1.0 53 | $ git release 54 | `, 55 | Args: cobra.NoArgs, 56 | SilenceUsage: true, 57 | 58 | Run: func(cmd *cobra.Command, args []string) { 59 | opt := &releaseOpt{} 60 | var err error 61 | 62 | opt.DryRun, err = cmd.Flags().GetBool("dry-run") 63 | cobra.CheckErr(err) 64 | opt.Prefix, err = cmd.Flags().GetString("prefix") 65 | cobra.CheckErr(err) 66 | opt.Repo = cmdbuilder.GetRepo(cmd) 67 | 68 | cobra.CheckErr(runRelease(opt)) 69 | }, 70 | } 71 | 72 | type releaseOpt struct { 73 | DryRun bool 74 | Prefix string 75 | Repo *git.Repository 76 | } 77 | 78 | func runRelease(opt *releaseOpt) error { 79 | // initialize walker 80 | walk, err := opt.Repo.Walk() 81 | if err != nil { 82 | return err 83 | } 84 | if err := walk.PushHead(); err != nil { 85 | return err 86 | } 87 | 88 | // find next version 89 | bump := format.BUMP_NONE 90 | curr := semver.Version{} 91 | walker, err := commitWalker(&bump, &curr, opt.Prefix) 92 | if err != nil { 93 | return err 94 | } 95 | if err := walk.Iterate(walker); err != nil { 96 | return err 97 | } 98 | 99 | if bump == format.BUMP_NONE { 100 | fmt.Println("Nothing to do") 101 | return nil 102 | } 103 | // Bump tag 104 | if err := bumpVersion(&curr, bump); err != nil { 105 | return err 106 | } 107 | 108 | // do tag 109 | tagname := fmt.Sprintf("refs/tags/%s%s", opt.Prefix, curr) 110 | return tagHead(opt.Repo, tagname, opt.DryRun) 111 | } 112 | 113 | func tagHead(r *git.Repository, tagname string, dry bool) error { 114 | head, err := r.Head() 115 | if err != nil { 116 | return err 117 | } 118 | if dry { 119 | fmt.Println(tagname, "would be created on", head.Target()) 120 | } else { 121 | tag, err := r.References.Create(tagname, head.Target(), false, "") 122 | if err != nil { 123 | return err 124 | } 125 | fmt.Println(tag.Target(), "-->", tagname) 126 | } 127 | return nil 128 | } 129 | 130 | func bumpVersion(curr *semver.Version, bump format.Bump) error { 131 | if curr == nil { 132 | return errors.New("current version must not be nil") 133 | } 134 | switch bump { 135 | case format.BUMP_MAJOR: 136 | if curr.Major == 0 { 137 | return curr.IncrementMinor() 138 | } 139 | return curr.IncrementMajor() 140 | case format.BUMP_MINOR: 141 | return curr.IncrementMinor() 142 | case format.BUMP_PATCH: 143 | return curr.IncrementPatch() 144 | default: 145 | return nil 146 | } 147 | } 148 | 149 | func commitWalker(bump *format.Bump, curr *semver.Version, prefix string) (func(*git.Commit) bool, error) { 150 | dfo, err := git.DefaultDescribeFormatOptions() 151 | if err != nil { 152 | return nil, err 153 | } 154 | dco := &git.DescribeOptions{ 155 | MaxCandidatesTags: 1, 156 | Strategy: git.DescribeTags, 157 | Pattern: fmt.Sprintf("%s*", prefix), 158 | OnlyFollowFirstParent: true, 159 | } 160 | 161 | return func(c *git.Commit) bool { 162 | dr, err := c.Describe(dco) 163 | if err != nil { 164 | // No next tag matching 165 | *bump = format.NextBump(c.Message(), *bump) 166 | return true 167 | } 168 | d, err := dr.Format(&dfo) 169 | if err != nil { 170 | panic(err) 171 | } 172 | v, offset, err := parseDescription(d) 173 | *curr = *v 174 | if err != nil { 175 | panic(err) 176 | } 177 | if offset <= 1 { 178 | return false 179 | } 180 | *bump = format.NextBump(c.Message(), *bump) 181 | return true 182 | }, nil 183 | } 184 | 185 | func parseDescription(d string) (*semver.Version, int, error) { 186 | re, err := regexp.Compile(`-(\d+)-[a-z0-9]{8}$`) 187 | if err != nil { 188 | return nil, 0, err 189 | } 190 | offset := 1 191 | 192 | if res := re.FindStringSubmatch(d); res != nil { 193 | offset, err = strconv.Atoi(res[1]) 194 | if err != nil { 195 | return nil, 0, err 196 | } 197 | offset++ 198 | d = d[:len(d)-len(res[0])] 199 | } 200 | 201 | if v, err := semver.ParseTolerant(d); err == nil { 202 | return &v, offset, nil 203 | } 204 | return nil, offset, nil 205 | } 206 | -------------------------------------------------------------------------------- /pkg/git/hooks_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "os/exec" 8 | "path" 9 | "testing" 10 | 11 | "github.com/b4nst/turbogit/pkg/test" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestHookCmd(t *testing.T) { 17 | dir, err := ioutil.TempDir("", "turbogit-test-hook") 18 | require.NoError(t, err) 19 | defer os.RemoveAll(dir) 20 | require.NoError(t, os.Chdir(dir)) 21 | 22 | // Test when no hooks exists 23 | hook := "hook-script" 24 | hc, err := hookCmd(dir, hook) 25 | assert.NoError(t, err) 26 | assert.Nil(t, hc) 27 | 28 | // Test error with directory script instead of file 29 | err = os.MkdirAll(path.Join(".git", "hooks", hook), 0700) 30 | require.NoError(t, err) 31 | hc, err = hookCmd(dir, hook) 32 | assert.EqualError(t, err, fmt.Sprintf("Hook .git/hooks/%s is a directory, it should be an executable file.", hook)) 33 | assert.Nil(t, hc) 34 | err = os.Remove(path.Join(".git", "hooks", hook)) 35 | require.NoError(t, err) 36 | 37 | // Test command 38 | test.WriteGitHook(t, hook, "") 39 | hc, err = hookCmd(dir, hook) 40 | assert.NoError(t, err) 41 | assert.Equal(t, &exec.Cmd{ 42 | Dir: dir, 43 | Path: path.Join(dir, ".git", "hooks", hook), 44 | Args: []string{path.Join(dir, ".git", "hooks", hook)}, 45 | Stdout: os.Stdout, 46 | Stderr: os.Stderr, 47 | }, hc) 48 | } 49 | 50 | func TestNoArgHook(t *testing.T) { 51 | dir, err := ioutil.TempDir("", "turbogit-test-hook") 52 | require.NoError(t, err) 53 | defer os.RemoveAll(dir) 54 | require.NoError(t, os.Chdir(dir)) 55 | 56 | hook := "hook-script" 57 | 58 | // Test without script 59 | err = noArgHook(dir, hook) 60 | assert.NoError(t, err) 61 | 62 | // Test error script 63 | script := `#!/bin/sh 64 | >&2 echo standard error 65 | exit 3 66 | ` 67 | test.WriteGitHook(t, hook, script) 68 | stderr, resetSterr := test.CaptureStd(t, os.Stderr) 69 | defer resetSterr() 70 | err = noArgHook(dir, hook) 71 | assert.EqualError(t, err, "exit status 3") 72 | stde, err := ioutil.ReadFile(stderr.Name()) 73 | require.NoError(t, err) 74 | assert.Equal(t, "standard error\n", string(stde)) 75 | 76 | // Test successful script 77 | script = `#!/bin/sh 78 | echo Hello world! 79 | exit 0 80 | ` 81 | test.WriteGitHook(t, hook, script) 82 | stdout, resetStdout := test.CaptureStd(t, os.Stdout) 83 | defer resetStdout() 84 | err = noArgHook(dir, hook) 85 | assert.NoError(t, err) 86 | stdo, err := ioutil.ReadFile(stdout.Name()) 87 | require.NoError(t, err) 88 | assert.Equal(t, "Running hook-script hook...\nHello world!\n", string(stdo)) 89 | } 90 | 91 | func TestFileHook(t *testing.T) { 92 | dir, err := ioutil.TempDir("", "turbogit-test-hook") 93 | require.NoError(t, err) 94 | defer os.RemoveAll(dir) 95 | require.NoError(t, os.Chdir(dir)) 96 | 97 | hook := "hook-script" 98 | 99 | // Test without script 100 | msg, err := fileHook(dir, hook, "hello world!") 101 | assert.NoError(t, err) 102 | assert.Equal(t, "hello world!", msg) 103 | 104 | // Test error script 105 | script := `#!/bin/sh 106 | >&2 echo standard error 107 | exit 3 108 | ` 109 | test.WriteGitHook(t, hook, script) 110 | stderr, resetSterr := test.CaptureStd(t, os.Stderr) 111 | defer resetSterr() 112 | msg, err = fileHook(dir, hook, "hello world!") 113 | assert.EqualError(t, err, "exit status 3") 114 | assert.Equal(t, "hello world!", msg) 115 | stde, err := ioutil.ReadFile(stderr.Name()) 116 | require.NoError(t, err) 117 | assert.Equal(t, "standard error\n", string(stde)) 118 | 119 | // Test successful script 120 | script = `#!/bin/sh 121 | echo "Hello world!" > "$1" 122 | exit 0 123 | ` 124 | test.WriteGitHook(t, hook, script) 125 | msg, err = fileHook(dir, hook, "Hey you!") 126 | assert.NoError(t, err) 127 | assert.Equal(t, "Hello world!\n", msg) 128 | } 129 | 130 | func TestPreCommitHook(t *testing.T) { 131 | dir, err := ioutil.TempDir("", "turbogit-test-hook") 132 | require.NoError(t, err) 133 | defer os.RemoveAll(dir) 134 | require.NoError(t, os.Chdir(dir)) 135 | 136 | script := `#!/bin/sh 137 | echo Hello world! 138 | exit 0 139 | ` 140 | test.WriteGitHook(t, "pre-commit", script) 141 | stdout, resetStdout := test.CaptureStd(t, os.Stdout) 142 | defer resetStdout() 143 | err = PreCommitHook(dir) 144 | assert.NoError(t, err) 145 | stdo, err := ioutil.ReadFile(stdout.Name()) 146 | require.NoError(t, err) 147 | assert.Equal(t, "Running pre-commit hook...\nHello world!\n", string(stdo)) 148 | } 149 | 150 | func TestPrepareCommitMsg(t *testing.T) { 151 | dir, err := ioutil.TempDir("", "turbogit-test-hook") 152 | require.NoError(t, err) 153 | defer os.RemoveAll(dir) 154 | require.NoError(t, os.Chdir(dir)) 155 | 156 | // Test successful script 157 | script := `#!/bin/sh 158 | echo "Hello world!" > "$1" 159 | exit 0 160 | ` 161 | test.WriteGitHook(t, "prepare-commit-msg", script) 162 | msg, err := PrepareCommitMsgHook(dir) 163 | assert.NoError(t, err) 164 | assert.Equal(t, "Hello world!\n", msg) 165 | } 166 | 167 | func TestCommitMsg(t *testing.T) { 168 | dir, err := ioutil.TempDir("", "turbogit-test-hook") 169 | require.NoError(t, err) 170 | defer os.RemoveAll(dir) 171 | require.NoError(t, os.Chdir(dir)) 172 | 173 | // Test successful script 174 | script := `#!/bin/sh 175 | echo world! >> "$1" 176 | exit 0 177 | ` 178 | test.WriteGitHook(t, "commit-msg", script) 179 | msg, err := CommitMsgHook(dir, "Hello ") 180 | assert.NoError(t, err) 181 | assert.Equal(t, "Hello world!\n", msg) 182 | } 183 | 184 | func TestPostCommit(t *testing.T) { 185 | dir, err := ioutil.TempDir("", "turbogit-test-hook") 186 | require.NoError(t, err) 187 | defer os.RemoveAll(dir) 188 | require.NoError(t, os.Chdir(dir)) 189 | 190 | script := `#!/bin/sh 191 | echo Hello world! 192 | exit 0 193 | ` 194 | test.WriteGitHook(t, "post-commit", script) 195 | stdout, resetStdout := test.CaptureStd(t, os.Stdout) 196 | defer resetStdout() 197 | err = PostCommitHook(dir) 198 | assert.NoError(t, err) 199 | stdo, err := ioutil.ReadFile(stdout.Name()) 200 | require.NoError(t, err) 201 | assert.Equal(t, "Running post-commit hook...\nHello world!\n", string(stdo)) 202 | } 203 | -------------------------------------------------------------------------------- /assets/docs/integration.md: -------------------------------------------------------------------------------- 1 | # Integration 2 | 3 | Turbogit can plug itself with with some of your favourite tools whenever it makes sense. 4 | For instance including a ticket id in the branch name. 5 | If you think something is missing in your workflow with turbogit, do not hesitate to raise an issue on [b4nst/turbogit](https://github.com/b4nst/turbogit/issues). 6 | 7 | ## OpenAI integration 8 | 9 | The OpenAI integration enables you to fill commit messages automatically based on the staged diff. 10 | It currently uses `gpt-3.5-turbo` model. 11 | 12 | ### Configuration 13 | 14 | In order to enable OpenAI integration, you will need to set some configuration keys. 15 | 16 | | key | type | description | recommended location | 17 | | --- | --- | --- | --- | 18 | | **openai.enabled** | `bool` | Enable GitLab integration | global | 19 | | **openai.token** | `string` | OpenAI [API key](https://platform.openai.com/account/api-keys) | global | 20 | 21 | 22 | Set a global key: 23 | 24 | ```shell 25 | git config --global 26 | ``` 27 | 28 | Set a local key 29 | 30 | ```shell 31 | git config 32 | ``` 33 | 34 | ### Usage 35 | 36 | First set the proper configuration to activate OpenAI integration. 37 | Then you just have to 38 | 39 | ```shell 40 | tug commit --fill 41 | ``` 42 | 43 | This will ask OpenAI for a commit message based on the current staged diff. 44 | You can refine it either overwritting by passing other args (type, summary, scope, etc) or 45 | spawn a editor with `-e` option. 46 | 47 | 48 | ## GitHub integration 49 | 50 | _This is a work in progress, please check [#60](https://github.com/b4nst/turbogit/issues/60) for further details._ 51 | 52 | ## GitLab integration 53 | 54 | The Gitlab integration enables you to create branches automatically from GitLab issues. 55 | 56 | ### Configuration 57 | 58 | In order to enable GitLab integration, you will need to set some configuration keys. 59 | 60 | | key | type | description | recommended location | 61 | | --- | --- | --- | --- | 62 | | **gitlab.enabled** | `bool` | Enable GitLab integration | local | 63 | | **gitlab.token** | `string` | GitLab [personal access token](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#create-a-personal-access-token). | global | 64 | | **gitlab.protocol** (optional) | `string` | Override GitLab API protocol (default https) | - | 65 | 66 | 67 | Set a global key: 68 | 69 | ```shell 70 | git config --global 71 | ``` 72 | 73 | Set a local key 74 | 75 | ```shell 76 | git config 77 | ``` 78 | 79 | ### Usage 80 | 81 | First set the proper configuration to activate GitLab integration. 82 | Then you just have to 83 | 84 | ```shell 85 | tug new 86 | ``` 87 | 88 | It will prompt you a list of issues with a fuzzy finder at your disposal to refine your selection. 89 | Select your issue and turbogit will take care of creating and checkout the branch for you. 90 | 91 | ## Jira integration 92 | 93 | The Jira integration enables you to create branches automatically from Jira issues. 94 | 95 | ### Configuration 96 | 97 | In order to enable Jira integration, you will need to set those configuration keys: 98 | 99 | | key | type | description | recommended location | 100 | | --- | --- | --- | --- | 101 | | **jira.enabled** | `bool` | Enable jira integration | local | 102 | | **jira.token** | `string` | Jira personal token. Create one [here](https://id.atlassian.com/manage-profile/security/api-tokens) | global | 103 | | **jira.username** | `string` | Your Jira username (email) | global | 104 | | **jira.domain** | `string` | Your Jira domain, including protocol (e.g. https://company.atlassian.net) | global | 105 | | **jira.filter** | `string` | JQL filter to gather issues | global: a wide filter, local: override with narrower filter | 106 | 107 | Set a global key: 108 | 109 | ```shell 110 | git config --global 111 | ``` 112 | 113 | Set a local key 114 | 115 | ```shell 116 | git config 117 | ``` 118 | 119 | ### Usage 120 | 121 | First set the proper configuration to activate Jira integration. 122 | Then you just have to 123 | 124 | ```shell 125 | tug new 126 | ``` 127 | 128 | It will prompt you a list of issues matching `jira.filter` with a fuzzy finder at your disposal to refine your selection. 129 | Select your issue and turbogit will take care of creating and checkout the branch for you. 130 | -------------------------------------------------------------------------------- /pkg/integrations/gitlab_test.go: -------------------------------------------------------------------------------- 1 | package integrations 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "path" 7 | "testing" 8 | 9 | "github.com/b4nst/turbogit/pkg/test" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | "github.com/xanzy/go-gitlab" 13 | ) 14 | 15 | func TestNewGitLabProvider(t *testing.T) { 16 | r := test.TestRepo(t) 17 | defer test.CleanupRepo(t, r) 18 | test.InitRepoConf(t, r) 19 | r.Remotes.Create("blank", "git@blank.com:project.git") 20 | 21 | // nil, nil when not in a GitLab repo 22 | provider, err := NewGitLabProvider(r) 23 | assert.NoError(t, err) 24 | assert.Nil(t, provider) 25 | 26 | r.Remotes.Create("origin", "git@gitlab.com:namespace/project.git") 27 | // no token 28 | provider, err = NewGitLabProvider(r) 29 | assert.EqualError(t, err, "config value 'gitlab.token' was not found") 30 | 31 | c, err := r.Config() 32 | require.NoError(t, err) 33 | require.NoError(t, c.SetString("gitlab.token", "supersecret")) 34 | // default values 35 | provider, err = NewGitLabProvider(r) 36 | assert.NoError(t, err) 37 | assert.IsType(t, &GitLabProvider{}, provider) 38 | assert.Equal(t, "namespace/project", provider.project) 39 | } 40 | 41 | func TestGitLabSearch(t *testing.T) { 42 | r := test.TestRepo(t) 43 | defer test.CleanupRepo(t, r) 44 | test.InitRepoConf(t, r) 45 | r.Remotes.Create("origin", "git@gitlab.com:namespace/project.git") 46 | c, err := r.Config() 47 | require.NoError(t, err) 48 | require.NoError(t, c.SetString("gitlab.token", "supersecret")) 49 | 50 | ts := gitlabMockServer(t, "myproject") 51 | defer ts.Close() 52 | 53 | client, err := gitlab.NewClient("supersecret", gitlab.WithBaseURL(ts.URL), gitlab.WithHTTPClient(ts.Client())) 54 | require.NoError(t, err) 55 | provider := GitLabProvider{ 56 | project: "myproject", 57 | client: client, 58 | } 59 | ids, err := provider.Search() 60 | assert.NoError(t, err) 61 | assert.Len(t, ids, 1) 62 | assert.Equal(t, IssueDescription{ 63 | ID: "1", 64 | Name: "Ut commodi ullam eos dolores perferendis nihil sunt.", 65 | Description: "Omnis vero earum sunt corporis dolor et placeat.", 66 | Provider: GITLAB_PROVIDER, 67 | }, ids[0]) 68 | } 69 | 70 | func gitlabMockServer(t *testing.T, project string) *httptest.Server { 71 | mux := http.NewServeMux() 72 | 73 | mux.HandleFunc("/api/v4/", func(w http.ResponseWriter, r *http.Request) { 74 | w.WriteHeader(http.StatusOK) 75 | }) 76 | 77 | mux.HandleFunc(path.Join("/api/v4/projects/", project, "issues"), func(w http.ResponseWriter, r *http.Request) { 78 | assert.Equal(t, "assigned_to_me", r.URL.Query().Get("scope")) 79 | 80 | w.Header().Set("Content-Type", "application/json") 81 | w.Write([]byte(gitlabIssue)) 82 | }) 83 | 84 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 85 | t.Errorf("Unexpected path '%s'", r.URL.Path) 86 | w.WriteHeader(http.StatusNotFound) 87 | }) 88 | 89 | return httptest.NewServer(mux) 90 | } 91 | 92 | const gitlabIssue = ` 93 | [ 94 | { 95 | "project_id" : 4, 96 | "milestone" : { 97 | "due_date" : null, 98 | "project_id" : 4, 99 | "state" : "closed", 100 | "description" : "Rerum est voluptatem provident consequuntur molestias similique ipsum dolor.", 101 | "iid" : 3, 102 | "id" : 11, 103 | "title" : "v3.0", 104 | "created_at" : "2016-01-04T15:31:39.788Z", 105 | "updated_at" : "2016-01-04T15:31:39.788Z" 106 | }, 107 | "author" : { 108 | "state" : "active", 109 | "web_url" : "https://gitlab.example.com/root", 110 | "avatar_url" : null, 111 | "username" : "root", 112 | "id" : 1, 113 | "name" : "Administrator" 114 | }, 115 | "description" : "Omnis vero earum sunt corporis dolor et placeat.", 116 | "state" : "closed", 117 | "iid" : 1, 118 | "assignees" : [{ 119 | "avatar_url" : null, 120 | "web_url" : "https://gitlab.example.com/lennie", 121 | "state" : "active", 122 | "username" : "lennie", 123 | "id" : 9, 124 | "name" : "Dr. Luella Kovacek" 125 | }], 126 | "assignee" : { 127 | "avatar_url" : null, 128 | "web_url" : "https://gitlab.example.com/lennie", 129 | "state" : "active", 130 | "username" : "lennie", 131 | "id" : 9, 132 | "name" : "Dr. Luella Kovacek" 133 | }, 134 | "labels" : ["foo", "bar"], 135 | "upvotes": 4, 136 | "downvotes": 0, 137 | "merge_requests_count": 0, 138 | "id" : 41, 139 | "title" : "Ut commodi ullam eos dolores perferendis nihil sunt.", 140 | "updated_at" : "2016-01-04T15:31:46.176Z", 141 | "created_at" : "2016-01-04T15:31:46.176Z", 142 | "closed_at" : "2016-01-05T15:31:46.176Z", 143 | "closed_by" : { 144 | "state" : "active", 145 | "web_url" : "https://gitlab.example.com/root", 146 | "avatar_url" : null, 147 | "username" : "root", 148 | "id" : 1, 149 | "name" : "Administrator" 150 | }, 151 | "user_notes_count": 1, 152 | "due_date": "2016-07-22", 153 | "web_url": "http://gitlab.example.com/my-group/my-project/issues/1", 154 | "references": { 155 | "short": "#1", 156 | "relative": "#1", 157 | "full": "my-group/my-project#1" 158 | }, 159 | "time_stats": { 160 | "time_estimate": 0, 161 | "total_time_spent": 0, 162 | "human_time_estimate": null, 163 | "human_total_time_spent": null 164 | }, 165 | "has_tasks": true, 166 | "task_status": "10 of 15 tasks completed", 167 | "confidential": false, 168 | "discussion_locked": false, 169 | "_links":{ 170 | "self":"http://gitlab.example.com/api/v4/projects/4/issues/41", 171 | "notes":"http://gitlab.example.com/api/v4/projects/4/issues/41/notes", 172 | "award_emoji":"http://gitlab.example.com/api/v4/projects/4/issues/41/award_emoji", 173 | "project":"http://gitlab.example.com/api/v4/projects/4" 174 | }, 175 | "task_completion_status":{ 176 | "count":0, 177 | "completed_count":0 178 | } 179 | } 180 | ] 181 | ` 182 | -------------------------------------------------------------------------------- /cmd/log.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright © 2022 banst 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | */ 22 | package cmd 23 | 24 | import ( 25 | "fmt" 26 | "io" 27 | "os" 28 | "text/tabwriter" 29 | "time" 30 | 31 | "github.com/araddon/dateparse" 32 | "github.com/b4nst/turbogit/internal/cmdbuilder" 33 | "github.com/b4nst/turbogit/pkg/format" 34 | git "github.com/libgit2/git2go/v33" 35 | "github.com/spf13/cobra" 36 | ) 37 | 38 | func init() { 39 | RootCmd.AddCommand(LogCmd) 40 | 41 | cmdbuilder.RepoAware(LogCmd) 42 | 43 | LogCmd.Flags().BoolP("all", "a", false, "Pretend as if all the refs in refs/, along with HEAD, are listed on the command line as . If set on true, the --from option will be ignored.") 44 | LogCmd.Flags().Bool("no-color", false, "Disable color output") 45 | LogCmd.Flags().StringP("from", "f", "HEAD", "Logs only commits reachable from this one") 46 | LogCmd.Flags().String("since", "", "Show commits more recent than a specific date") 47 | LogCmd.Flags().String("until", "", "Show commits older than a specific date") 48 | // logCmd.Flags().String("path", "", "Filter commits based on the path of files that are updated. Accept regexp") 49 | // Filters 50 | LogCmd.Flags().StringArrayP("type", "t", []string{}, "Filter commits by type (repeatable option)") 51 | LogCmd.Flags().StringArrayP("scope", "s", []string{}, "Filter commits by scope (repeatable option)") 52 | LogCmd.Flags().BoolP("breaking-changes", "c", false, "Only shows breaking changes") 53 | } 54 | 55 | // LogCmd represents the log command 56 | var LogCmd = &cobra.Command{ 57 | Use: "logs", 58 | Short: "Shows the commit logs.", 59 | Args: cobra.NoArgs, 60 | 61 | Run: func(cmd *cobra.Command, args []string) { 62 | opt := &logOpt{} 63 | var err error 64 | 65 | // --all 66 | opt.All, err = cmd.Flags().GetBool("all") 67 | cobra.CheckErr(err) 68 | // --no-color 69 | opt.NoColor, err = cmd.Flags().GetBool("no-color") 70 | cobra.CheckErr(err) 71 | // --from 72 | opt.From, err = cmd.Flags().GetString("from") 73 | cobra.CheckErr(err) 74 | // --since 75 | fSince, err := cmd.Flags().GetString("since") 76 | cobra.CheckErr(err) 77 | if fSince != "" { 78 | date, err := dateparse.ParseAny(fSince) 79 | cobra.CheckErr(err) 80 | opt.Since = &date 81 | } 82 | // --until 83 | fUntil, err := cmd.Flags().GetString("until") 84 | cobra.CheckErr(err) 85 | if fUntil != "" { 86 | date, err := dateparse.ParseAny(fUntil) 87 | cobra.CheckErr(err) 88 | opt.Until = &date 89 | } 90 | // --types 91 | fTypes, err := cmd.Flags().GetStringArray("type") 92 | cobra.CheckErr(err) 93 | for _, v := range fTypes { 94 | opt.Types = append(opt.Types, format.FindCommitType(v)) 95 | // TODO warn or error on nil commit type 96 | } 97 | // --scopes 98 | opt.Scopes, err = cmd.Flags().GetStringArray("scope") 99 | cobra.CheckErr(err) 100 | // --breaking-changes 101 | opt.BreakingChange, err = cmd.Flags().GetBool("breaking-changes") 102 | cobra.CheckErr(err) 103 | 104 | opt.Repo = cmdbuilder.GetRepo(cmd) 105 | 106 | cobra.CheckErr(err) 107 | cobra.CheckErr(runLog(opt)) 108 | }, 109 | } 110 | 111 | type logOpt struct { 112 | All bool 113 | NoColor bool 114 | From string 115 | Since *time.Time 116 | Until *time.Time 117 | Types []format.CommitType 118 | Scopes []string 119 | BreakingChange bool 120 | Repo *git.Repository 121 | } 122 | 123 | func runLog(opt *logOpt) error { 124 | r := opt.Repo 125 | 126 | walk, err := r.Walk() 127 | if err != nil { 128 | return err 129 | } 130 | if opt.All { 131 | if err := walk.PushGlob("refs/*"); err != nil { 132 | return err 133 | } 134 | } else { 135 | from, err := r.RevparseSingle(opt.From) 136 | if err != nil { 137 | return err 138 | } 139 | if err := walk.Push(from.Id()); err != nil { 140 | return err 141 | } 142 | } 143 | 144 | // Build filters 145 | filters := []LogFilter{ 146 | Since(opt.Since), 147 | Until(opt.Until), 148 | Type(opt.Types), 149 | Scope(opt.Scopes), 150 | BreakingChange(opt.BreakingChange), 151 | } 152 | 153 | tw := tabwriter.NewWriter(os.Stdout, 10, 1, 1, ' ', 0) 154 | defer tw.Flush() 155 | if err := walk.Iterate(buildLogWalker(tw, !opt.NoColor, filters)); err != nil { 156 | return err 157 | } 158 | 159 | return nil 160 | } 161 | 162 | func buildLogWalker(w io.Writer, color bool, filters []LogFilter) func(c *git.Commit) bool { 163 | return func(c *git.Commit) bool { 164 | co := format.ParseCommitMsg(c.Message()) 165 | if co == nil { 166 | co = &format.CommitMessageOption{} 167 | } 168 | keep, walk := ApplyFilters(c, co, filters...) 169 | if !keep { 170 | return walk 171 | } 172 | 173 | // Hash 174 | h, err := c.ShortId() 175 | if err != nil { 176 | h = c.Id().String() 177 | } 178 | // type 179 | var ctype string 180 | if color { 181 | h = fmt.Sprintf("\x1b[38;5;231m%s\x1b[0m", h) 182 | ctype = co.Ctype.ColorString() 183 | } else { 184 | ctype = co.Ctype.String() 185 | } 186 | // description 187 | msg := co.Description 188 | if msg == "" { 189 | msg = c.Summary() 190 | } 191 | fmt.Fprintf(w, "%s\t%s\t%s\t\n", h, ctype, msg) 192 | 193 | return walk 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /pkg/format/commit.go: -------------------------------------------------------------------------------- 1 | package format 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/b4nst/turbogit/pkg/constants" 10 | "github.com/imdario/mergo" 11 | ) 12 | 13 | type CommitType int 14 | 15 | const ( 16 | NilCommit CommitType = iota 17 | BuildCommit 18 | CiCommit 19 | ChoreCommit 20 | DocCommit 21 | FeatureCommit 22 | FixCommit 23 | PerfCommit 24 | RefactorCommit 25 | StyleCommit 26 | TestCommit 27 | AutoCommit 28 | ) 29 | 30 | func (b CommitType) String() string { 31 | return [...]string{ 32 | "", 33 | "build", 34 | "ci", 35 | "chore", 36 | "docs", 37 | "feat", 38 | "fix", 39 | "perf", 40 | "refactor", 41 | "style", 42 | "test", 43 | "auto", 44 | }[b] 45 | } 46 | 47 | func colorize(s string, code int) string { 48 | return fmt.Sprintf("\x1b[38;5;%03dm%s\x1b[0m", code, s) 49 | } 50 | 51 | func (b CommitType) ColorString() string { 52 | return [...]string{ 53 | colorize("", 0), 54 | colorize("build", 200), 55 | colorize("ci", 92), 56 | colorize("chore", 15), 57 | colorize("docs", 250), 58 | colorize("feat", 2), 59 | colorize("fix", 1), 60 | colorize("perf", 3), 61 | colorize("refactor", 30), 62 | colorize("style", 6), 63 | colorize("test", 11), 64 | }[b] 65 | } 66 | 67 | func AllCommitType() []string { 68 | return []string{ 69 | BuildCommit.String(), 70 | CiCommit.String(), 71 | ChoreCommit.String(), 72 | DocCommit.String(), 73 | FeatureCommit.String(), 74 | FixCommit.String(), 75 | PerfCommit.String(), 76 | RefactorCommit.String(), 77 | StyleCommit.String(), 78 | TestCommit.String(), 79 | AutoCommit.String(), 80 | } 81 | } 82 | 83 | var ( 84 | buildCommitRe = regexp.MustCompile(`(?i)^b(?:uilds?)?$`) 85 | ciCommitRe = regexp.MustCompile(`(?i)^ci$`) 86 | choreCommitRe = regexp.MustCompile(`(?i)^ch(?:ores?)?$`) 87 | docCommitRe = regexp.MustCompile(`(?i)^d(?:ocs?)?$`) 88 | featureCommitRe = regexp.MustCompile(`(?i)^fe(?:at(?:ure)?s?)?$`) 89 | fixCommitRe = regexp.MustCompile(`(?i)^fi(?:x(?:es)?)?$`) 90 | perfCommitRe = regexp.MustCompile(`(?i)^p(?:erf(:?ormance)?s?)?$`) 91 | refactorCommitRe = regexp.MustCompile(`(?i)^r(?:efactors?)?$`) 92 | styleCommitRe = regexp.MustCompile(`(?i)^s(?:tyles?)?$`) 93 | testCommitRe = regexp.MustCompile(`(?i)^t(?:ests?)?$`) 94 | autoCommitRe = regexp.MustCompile(`(?i)^auto$`) 95 | ) 96 | 97 | type CommitMessageOption struct { 98 | // Commit type (optional) 99 | Ctype CommitType 100 | // Commit scope (optional) 101 | Scope string 102 | // Commit subject (required) 103 | Description string 104 | // Commit body (optional) 105 | Body string 106 | // Commit footers (optional) 107 | Footers []string 108 | // Breaking change flag (optional) 109 | BreakingChanges bool 110 | } 111 | 112 | // Overwrite values with another CommitMessageOption. 113 | func (cmo *CommitMessageOption) Overwrite(other *CommitMessageOption) error { 114 | return mergo.Merge(cmo, other, mergo.WithOverride) 115 | } 116 | 117 | // Check CommitMessageOption compliance 118 | func (cmo *CommitMessageOption) Check() error { 119 | if cmo.Ctype == NilCommit { 120 | return errors.New("A commit type is required") 121 | } 122 | 123 | if cmo.Description == "" { 124 | return errors.New("A commit description is required") 125 | } 126 | 127 | return nil 128 | } 129 | 130 | // Format commit message according to https://www.conventionalcommits.org/en/v1.0.0/ 131 | func CommitMessage(o *CommitMessageOption) string { 132 | msg := o.Ctype.String() 133 | // Add scope if any 134 | if o.Scope != "" { 135 | msg += fmt.Sprintf("(%s)", o.Scope) 136 | } 137 | // Mark breaking changes 138 | if o.BreakingChanges { 139 | msg += "!" 140 | } 141 | // Add description 142 | msg += fmt.Sprintf(": %s", o.Description) 143 | // Add body if any 144 | if o.Body != "" { 145 | msg += constants.LINE_BREAK + constants.LINE_BREAK + o.Body 146 | } 147 | // Add footers if any 148 | if len(o.Footers) > 0 { 149 | msg += constants.LINE_BREAK 150 | for _, f := range o.Footers { 151 | msg += constants.LINE_BREAK + f 152 | } 153 | } 154 | 155 | return msg 156 | } 157 | 158 | // Extract type from string 159 | func FindCommitType(str string) CommitType { 160 | s := []byte(str) 161 | switch { 162 | case buildCommitRe.Match(s): 163 | return BuildCommit 164 | case ciCommitRe.Match(s): 165 | return CiCommit 166 | case choreCommitRe.Match(s): 167 | return ChoreCommit 168 | case docCommitRe.Match(s): 169 | return DocCommit 170 | case featureCommitRe.Match(s): 171 | return FeatureCommit 172 | case fixCommitRe.Match(s): 173 | return FixCommit 174 | case perfCommitRe.Match(s): 175 | return PerfCommit 176 | case refactorCommitRe.Match(s): 177 | return RefactorCommit 178 | case styleCommitRe.Match(s): 179 | return StyleCommit 180 | case testCommitRe.Match(s): 181 | return TestCommit 182 | case autoCommitRe.Match(s): 183 | return AutoCommit 184 | default: 185 | return NilCommit 186 | } 187 | } 188 | 189 | func ParseCommitMsg(msg string) *CommitMessageOption { 190 | lines := strings.Split(msg, "\n") 191 | 192 | // First line 193 | re := regexp.MustCompile(`(?m)^(?P\w+)(?:\((?P[^)]+)\))?(?P!)?: (?P.+)$`) 194 | match := re.FindStringSubmatch(lines[0]) 195 | if len(match) <= 0 { 196 | return nil 197 | } 198 | res := make(map[string]string) 199 | for i, name := range re.SubexpNames() { 200 | if i != 0 && name != "" { 201 | res[name] = match[i] 202 | } 203 | } 204 | cmo := &CommitMessageOption{ 205 | Ctype: FindCommitType(res["type"]), 206 | Description: res["subject"], 207 | Scope: res["scope"], 208 | BreakingChanges: res["bc"] == "!", 209 | } 210 | 211 | // Body and footers 212 | re = regexp.MustCompile(`(?m)^\w+(?: #|: )`) 213 | for _, l := range lines[1:] { 214 | if re.MatchString(l) { 215 | cmo.Footers = append(cmo.Footers, l) 216 | } else { 217 | cmo.Body += l 218 | } 219 | } 220 | cmo.Body = strings.Trim(cmo.Body, "\n") 221 | 222 | return cmo 223 | } 224 | 225 | type Bump int 226 | 227 | const ( 228 | BUMP_NONE Bump = iota 229 | BUMP_PATCH 230 | BUMP_MINOR 231 | BUMP_MAJOR 232 | ) 233 | 234 | // Get the next bump that this commit message would generate 235 | func NextBump(cmsg string, curr Bump) Bump { 236 | if curr == BUMP_MAJOR { 237 | return curr 238 | } 239 | co := ParseCommitMsg(cmsg) 240 | if co == nil { 241 | return curr 242 | } 243 | if co.BreakingChanges { 244 | return BUMP_MAJOR 245 | } 246 | if co.Ctype == FeatureCommit { 247 | return BUMP_MINOR 248 | } 249 | if curr < BUMP_PATCH && co.Ctype == FixCommit { 250 | return BUMP_PATCH 251 | } 252 | 253 | return curr 254 | } 255 | -------------------------------------------------------------------------------- /pkg/format/commit_test.go: -------------------------------------------------------------------------------- 1 | package format 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestCommitMessage(t *testing.T) { 11 | tcs := map[string]struct { 12 | o *CommitMessageOption 13 | expected string 14 | }{ 15 | "Simple subject feature": { 16 | o: &CommitMessageOption{Ctype: FeatureCommit, Description: "commit description"}, 17 | expected: "feat: commit description", 18 | }, 19 | "Subject + scope perf": { 20 | o: &CommitMessageOption{Ctype: PerfCommit, Description: "message", Scope: "scope"}, 21 | expected: "perf(scope): message", 22 | }, 23 | "Breaking change refactor": { 24 | o: &CommitMessageOption{Ctype: RefactorCommit, Description: "message", BreakingChanges: true}, 25 | expected: "refactor!: message", 26 | }, 27 | "Full stuff": { 28 | o: &CommitMessageOption{Ctype: FeatureCommit, Scope: "scope", Description: "message", BreakingChanges: true, Body: "The message body", Footers: []string{"First foot", "Second foot"}}, 29 | expected: "feat(scope)!: message\n\nThe message body\n\nFirst foot\nSecond foot", 30 | }, 31 | } 32 | 33 | for name, tc := range tcs { 34 | t.Run(name, func(t *testing.T) { 35 | assert.Equal(t, tc.expected, CommitMessage(tc.o)) 36 | }) 37 | } 38 | } 39 | 40 | func TestCMOCheck(t *testing.T) { 41 | tcs := map[string]struct { 42 | cmo *CommitMessageOption 43 | err error 44 | }{ 45 | "No type": { 46 | cmo: &CommitMessageOption{}, 47 | err: errors.New("A commit type is required"), 48 | }, 49 | "No description": { 50 | cmo: &CommitMessageOption{Ctype: FeatureCommit}, 51 | err: errors.New("A commit description is required"), 52 | }, 53 | "Ok": { 54 | cmo: &CommitMessageOption{Ctype: FeatureCommit, Description: "foo"}, 55 | err: nil, 56 | }, 57 | } 58 | 59 | for name, tc := range tcs { 60 | t.Run(name, func(t *testing.T) { 61 | assert.Equal(t, tc.err, tc.cmo.Check()) 62 | }) 63 | } 64 | } 65 | 66 | func TestCMOOverwrite(t *testing.T) { 67 | tcs := map[string]struct { 68 | src *CommitMessageOption 69 | override *CommitMessageOption 70 | expected *CommitMessageOption 71 | }{ 72 | "Override type": { 73 | src: &CommitMessageOption{Ctype: FeatureCommit, Description: "foo"}, 74 | override: &CommitMessageOption{Ctype: FixCommit}, 75 | expected: &CommitMessageOption{Ctype: FixCommit, Description: "foo"}, 76 | }, 77 | "Override everything": { 78 | src: &CommitMessageOption{Ctype: FeatureCommit, Description: "foo", Scope: "foo", Body: "foo", Footers: []string{"foo", "foo"}, BreakingChanges: true}, 79 | override: &CommitMessageOption{Ctype: FixCommit, Description: "bar", Scope: "bar", Body: "bar", Footers: []string{"bar", "bar"}, BreakingChanges: false}, 80 | expected: &CommitMessageOption{Ctype: FixCommit, Description: "bar", Scope: "bar", Body: "bar", Footers: []string{"bar", "bar"}, BreakingChanges: true}, 81 | }, 82 | } 83 | 84 | for name, tc := range tcs { 85 | t.Run(name, func(t *testing.T) { 86 | err := tc.src.Overwrite(tc.override) 87 | assert.NoError(t, err) 88 | assert.Equal(t, tc.expected, tc.src) 89 | }) 90 | } 91 | } 92 | 93 | func TestFindCommitType(t *testing.T) { 94 | tcs := map[string]struct { 95 | str string 96 | expected CommitType 97 | }{ 98 | "Nil": {"fail", NilCommit}, 99 | 100 | "B": {"b", BuildCommit}, 101 | "Build": {"bUild", BuildCommit}, 102 | "Builds": {"builds", BuildCommit}, 103 | 104 | "Ci": {"ci", CiCommit}, 105 | 106 | "Ch": {"ch", ChoreCommit}, 107 | "Chore": {"chore", ChoreCommit}, 108 | "Chores": {"chOreS", ChoreCommit}, 109 | 110 | "D": {"d", DocCommit}, 111 | "Doc": {"Doc", DocCommit}, 112 | "Docs": {"docs", DocCommit}, 113 | 114 | "Fe": {"fe", FeatureCommit}, 115 | "Feat": {"feAt", FeatureCommit}, 116 | "Feats": {"feats", FeatureCommit}, 117 | "Feature": {"feature", FeatureCommit}, 118 | "Features": {"features", FeatureCommit}, 119 | 120 | "Fi": {"fi", FixCommit}, 121 | "Fix": {"Fix", FixCommit}, 122 | "Fixes": {"fixEs", FixCommit}, 123 | 124 | "P": {"p", PerfCommit}, 125 | "Perf": {"perf", PerfCommit}, 126 | "Perfs": {"pErFs", PerfCommit}, 127 | "Performance": {"performance", PerfCommit}, 128 | "Performances": {"performances", PerfCommit}, 129 | 130 | "R": {"r", RefactorCommit}, 131 | "Refactor": {"reFactor", RefactorCommit}, 132 | "Refactors": {"reFactors", RefactorCommit}, 133 | 134 | "S": {"s", StyleCommit}, 135 | "Style": {"style", StyleCommit}, 136 | "Styles": {"stYles", StyleCommit}, 137 | 138 | "T": {"t", TestCommit}, 139 | "Test": {"Test", TestCommit}, 140 | "Tests": {"tests", TestCommit}, 141 | 142 | "Auto": {"auto", AutoCommit}, 143 | } 144 | 145 | for name, tc := range tcs { 146 | t.Run(name, func(t *testing.T) { 147 | assert.Equal(t, tc.expected, FindCommitType(tc.str)) 148 | }) 149 | } 150 | } 151 | 152 | func TestParseCommitMsg(t *testing.T) { 153 | tcs := map[string]struct { 154 | str string 155 | expected *CommitMessageOption 156 | }{ 157 | "Bad": {"i'm bad", nil}, 158 | "Simple": {"feat: message description", 159 | &CommitMessageOption{Ctype: FeatureCommit, Description: "message description"}}, 160 | "Scoped": {"fix(scope): message description", 161 | &CommitMessageOption{Ctype: FixCommit, Description: "message description", Scope: "scope"}}, 162 | "Breaking change": {"feat!: message description", 163 | &CommitMessageOption{Ctype: FeatureCommit, Description: "message description", BreakingChanges: true}}, 164 | "With body": {"feat: message description\n\nCommit body\n", 165 | &CommitMessageOption{Ctype: FeatureCommit, Description: "message description", Body: "Commit body"}}, 166 | "With footers": {"feat: message description\n\nCommit body\n\nFooter: 1\nFooter #2", 167 | &CommitMessageOption{Ctype: FeatureCommit, Description: "message description", Body: "Commit body", Footers: []string{"Footer: 1", "Footer #2"}}}, 168 | } 169 | 170 | for name, tc := range tcs { 171 | t.Run(name, func(t *testing.T) { 172 | assert.Equal(t, tc.expected, ParseCommitMsg(tc.str)) 173 | }) 174 | } 175 | } 176 | 177 | func TestNextBump(t *testing.T) { 178 | t.Parallel() 179 | tests := []struct { 180 | name string 181 | cmsg string 182 | curr Bump 183 | next Bump 184 | }{ 185 | {"Test next bump 1", "feat: a feature", BUMP_NONE, BUMP_MINOR}, 186 | {"Test next bump 2", "feat: a feature", BUMP_PATCH, BUMP_MINOR}, 187 | {"Test next bump 3", "feat: a feature", BUMP_MINOR, BUMP_MINOR}, 188 | {"Test next bump 3", "feat: a feature", BUMP_MAJOR, BUMP_MAJOR}, 189 | {"Test next bump 4", "fix: a fix", BUMP_NONE, BUMP_PATCH}, 190 | {"Test next bump 5", "fix: a fix", BUMP_PATCH, BUMP_PATCH}, 191 | {"Test next bump 6", "fix: a fix", BUMP_MINOR, BUMP_MINOR}, 192 | {"Test next bump 7", "fix: a fix", BUMP_MAJOR, BUMP_MAJOR}, 193 | {"Test next bump 8", "baadbeef", BUMP_NONE, BUMP_NONE}, 194 | {"Test next bump 9", "baadbeef", BUMP_PATCH, BUMP_PATCH}, 195 | {"Test next bump 10", "baadbeef", BUMP_MINOR, BUMP_MINOR}, 196 | {"Test next bump 11", "baadbeef", BUMP_MAJOR, BUMP_MAJOR}, 197 | {"Test next bump 12", "chore!: breaking", BUMP_NONE, BUMP_MAJOR}, 198 | {"Test next bump 13", "chore!: breaking", BUMP_PATCH, BUMP_MAJOR}, 199 | {"Test next bump 14", "chore!: breaking", BUMP_MINOR, BUMP_MAJOR}, 200 | {"Test next bump 15", "chore!: breaking", BUMP_MAJOR, BUMP_MAJOR}, 201 | } 202 | 203 | for _, tt := range tests { 204 | tt := tt 205 | t.Run(tt.name, func(t *testing.T) { 206 | t.Parallel() 207 | actual := NextBump(tt.cmsg, tt.curr) 208 | assert.Equal(t, tt.next, actual) 209 | }) 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Turbogit 2 | 3 | We would love for you to contribute to Turbogit and help make it even better than it is 4 | today! As a contributor, here are the guidelines we would like you to follow: 5 | 6 | ## Code of Conduct 7 | Help us keep Turbogit open and inclusive. Please read and follow our [Code of Conduct](/code-of-conduct). 8 | 9 | ## Got a Question or Problem? 10 | If you have any questions regarding how to use Turbogit or contributing to this repo 11 | please [submit an issue](/contributing#submitting-an-issue) using the **Need help** template. 12 | 13 | ## Found a Bug? 14 | If you find a bug in the source code, you can help us by 15 | [submitting an issue](/contributing#submitting-an-issue) using the **Bug report** template. Even better, you can 16 | [submit a Pull Request](/contributing#submitting-a-pull-request) with a fix. 17 | 18 | ## Missing a Feature? 19 | You can *request* a new feature by [submitting an issue](/contributing#submitting-an-issue) to our GitHub 20 | Repository using the **Feature request** template. If you would like to *implement* a new feature, please submit an issue with 21 | a proposal for your work first, to be sure that we can use it. 22 | Please consider what kind of change it is: 23 | 24 | * For a **Major Feature**, first open an issue and outline your proposal so that it can be 25 | discussed. This will also allow us to better coordinate our efforts, prevent duplication of work, 26 | and help you to craft the change so that it is successfully accepted into the project. 27 | * **Small Features** can be crafted and directly [submitted as a Pull Request](/contributing#submitting-a-pull-request). 28 | 29 | ## Submission Guidelines 30 | 31 | ### Submitting an Issue 32 | 33 | Before you submit an issue, please search the issue tracker, maybe an issue for your problem already exists and the discussion might inform you of workarounds readily available. 34 | 35 | We want to fix all the issues as soon as possible, but before fixing a bug we need to reproduce and confirm it. In order to reproduce bugs, please fill the *To Reproduce* section when possible. 36 | Having a reproducible scenario gives us a wealth of important information without going back & forth to you with additional questions like: 37 | 38 | - version of Turbogit used 39 | - general system information 40 | - and most importantly - a use-case that fails 41 | 42 | A minimal reproduce scenarion allows us to quickly confirm a bug (or point out coding problem) as well as confirm that we are fixing the right problem. 43 | 44 | We will be insisting on a minimal reproduce scenario in order to save maintainers time and ultimately be able to fix more bugs. Interestingly, from our experience users often find coding problems themselves while preparing a minimal plunk. We understand that sometimes it might be hard to extract essentials bits of code from a larger code-base but we really need to isolate the problem before we can fix it. 45 | 46 | Unfortunately, we are not able to investigate / fix bugs without a minimal reproduction, so if we don't hear back from you we are going to close an issue that doesn't have enough info to be reproduced. 47 | 48 | You can file new issues by filling out our [new issue form](https://github.com/b4nst/turbogit/issues/new/choose) using the **Bug report** template. 49 | 50 | 51 | ### Submitting a Pull Request 52 | Before you submit your Pull Request (PR) consider the following guidelines: 53 | 54 | 1. Search [GitHub](https://github.com/angular/angular/pulls) for an open or closed PR 55 | that relates to your submission. You don't want to duplicate effort. 56 | 1. Fork the b4nst/turbogit repo. 57 | 1. Make your changes in a new git branch: 58 | 59 | ```shell 60 | git checkout -b my-fix-branch main 61 | ``` 62 | 63 | 1. Create your patch, **including appropriate test cases**. 64 | 1. Follow our [Coding Rules](/contributing#coding-rules). 65 | 1. Commit your changes using a descriptive commit message that follows our 66 | [commit message conventions](/contributing#commit-message-format). Adherence to these conventions 67 | is necessary because release notes will be automatically generated from these messages (eventually). 68 | 69 | ```shell 70 | tug commit 71 | ``` 72 | 73 | 1. Push your branch to GitHub: 74 | 75 | ```shell 76 | git push origin my-fix-branch 77 | ``` 78 | 79 | 1. In GitHub, send a pull request to `turbogit:main`. 80 | * If we suggest changes, or the ci fail then: 81 | * Make the required updates. 82 | * Rebase your branch and force push to your GitHub repository (this will update your Pull Request): 83 | 84 | ```shell 85 | git rebase main -i 86 | git push -f 87 | ``` 88 | 89 | That's it! Thank you for your contribution! 90 | 91 | #### After your pull request is merged 92 | 93 | After your pull request is merged, you can safely delete your branch and pull the changes 94 | from the main (upstream) repository: 95 | 96 | * Delete the remote branch on GitHub either through the GitHub web UI or your local shell as follows: 97 | 98 | ```shell 99 | git push origin --delete my-fix-branch 100 | ``` 101 | 102 | * Check out the main branch: 103 | 104 | ```shell 105 | git checkout main -f 106 | ``` 107 | 108 | * Delete the local branch: 109 | 110 | ```shell 111 | git branch -D my-fix-branch 112 | ``` 113 | 114 | * Update your main with the latest upstream version: 115 | 116 | ```shell 117 | git pull --ff upstream main 118 | ``` 119 | 120 | ## Coding Rules 121 | To ensure consistency throughout the source code, keep these rules in mind as you are working: 122 | 123 | * All features or bug fixes **must be tested** by one or more specs (unit-tests). 124 | * All commands **must be documented** using cobra generated documentation. 125 | 126 | ## Commit Message Guidelines 127 | 128 | We follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for out commit messages. This leads to **more 129 | readable messages** that are easy to follow when looking through the **project history**. 130 | 131 | ### Commit Message Format 132 | Each commit message consists of a **header**, a **body** and a **footer**. The header has a special 133 | format that includes a **type**, a **scope** and a **subject**: 134 | 135 | ``` 136 | (): 137 | 138 | 139 | 140 |