├── 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 | [](https://github.com/b4nst/turbogit/actions?query=workflow%3AGo)
5 | [](https://github.com/b4nst/turbogit/releases/latest)
6 | [](https://codeclimate.com/github/b4nst/turbogit/test_coverage)
7 | [](https://codeclimate.com/github/b4nst/turbogit/maintainability)
8 |
9 | 
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 | [](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 |