├── .github ├── FUNDING.yml └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── README.md ├── config.go ├── demo.png ├── git.go ├── go.mod ├── go.sum ├── gui.go └── main.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [liamg] 2 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | build: 10 | name: releasing 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 0 17 | 18 | - uses: actions/setup-go@v2 19 | with: 20 | go-version: '1.17' 21 | 22 | - name: Release 23 | uses: goreleaser/goreleaser-action@v2 24 | with: 25 | version: latest 26 | args: release --rm-dist 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [pull_request] 2 | name: Test 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [1.17.x] 8 | os: [ubuntu-latest] 9 | runs-on: ${{ matrix.os }} 10 | steps: 11 | - name: Install Go 12 | uses: actions/setup-go@v2 13 | with: 14 | go-version: ${{ matrix.go-version }} 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | - name: Test 18 | run: go test ./... -race -cover 19 | 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liamg/comet/23ef85b6553902d28e55997e24ba0ac31d25d355/.gitignore -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - id: comet 3 | main: . 4 | binary: comet 5 | ldflags: 6 | - "-s -w" 7 | flags: 8 | - "--trimpath" 9 | env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - darwin 14 | goarch: 15 | - "386" 16 | - amd64 17 | - arm64 18 | archives: 19 | - 20 | format: binary 21 | name_template: "{{ .Binary}}-{{ .Os }}-{{ .Arch }}" 22 | release: 23 | prerelease: auto 24 | github: 25 | owner: liamg 26 | name: comet 27 | 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Liam Galvin 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Comet 2 | 3 | Comet is a simple CLI tool that helps you to use [conventional commits](https://www.conventionalcommits.org/) with git. 4 | 5 | You can call `comet` where you'd normally type `git commit`. All flags supported in `git commit` will still work. 6 | 7 | ![Demo](demo.png) 8 | 9 | ## Installation 10 | 11 | Install with Go (1.17+): 12 | 13 | ```console 14 | go install github.com/liamg/comet@latest 15 | ``` 16 | 17 | Or grab a binary from [the latest release](https://github.com/liamg/comet/releases/latest). 18 | 19 | ## Customisation 20 | 21 | You can customise the options available by creating a `.comet.json` in the root of your repository, or in your home directory. The repository-level config will be preferred if it exists. 22 | 23 | The content should be in the following format: 24 | 25 | ```json 26 | { 27 | "signOffCommits": false, 28 | "prefixes": [ 29 | { "title": "feat", "description": "a new feature"}, 30 | { "title": "fix", "description": "a bug fix"}, 31 | { "title": "bug", "description": "introducing a bug"} 32 | ] 33 | } 34 | ``` 35 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/charmbracelet/bubbles/list" 10 | ) 11 | 12 | type prefix struct { 13 | T string `json:"title"` 14 | D string `json:"description"` 15 | } 16 | 17 | type config struct { 18 | Prefixes []prefix `json:"prefixes"` 19 | SignOffCommits bool `json:"signOffCommits"` 20 | } 21 | 22 | func (i prefix) Title() string { return i.T } 23 | func (i prefix) Description() string { return i.D } 24 | func (i prefix) FilterValue() string { return i.T } 25 | 26 | var defaultPrefixes = []list.Item{ 27 | prefix{ 28 | T: "feat", 29 | D: "Introduces a new feature", 30 | }, 31 | prefix{ 32 | T: "fix", 33 | D: "Patches a bug", 34 | }, 35 | prefix{ 36 | T: "docs", 37 | D: "Documentation changes only", 38 | }, 39 | prefix{ 40 | T: "test", 41 | D: "Adding missing tests or correcting existing tests", 42 | }, 43 | prefix{ 44 | T: "build", 45 | D: "Changes that affect the build system", 46 | }, 47 | prefix{ 48 | T: "ci", 49 | D: "Changes to CI configuration files and scripts", 50 | }, 51 | prefix{ 52 | T: "perf", 53 | D: "A code change that improves performance", 54 | }, 55 | prefix{ 56 | T: "refactor", 57 | D: "A code change that neither fixes a bug nor adds a feature", 58 | }, 59 | prefix{ 60 | T: "revert", 61 | D: "Reverts a previous change", 62 | }, 63 | prefix{ 64 | T: "style", 65 | D: "Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)", 66 | }, 67 | prefix{ 68 | T: "chore", 69 | D: "A minor change which does not fit into any other category", 70 | }, 71 | } 72 | 73 | const configFile = ".comet.json" 74 | 75 | func loadConfig() ([]list.Item, bool, error) { 76 | 77 | if _, err := os.Stat(configFile); err == nil { 78 | return loadConfigFile(configFile) 79 | } 80 | 81 | if home, err := os.UserHomeDir(); err == nil { 82 | path := filepath.Join(home, configFile) 83 | if _, err := os.Stat(path); err == nil { 84 | return loadConfigFile(path) 85 | } 86 | } 87 | 88 | if _, err := os.Stat(configFile); err == nil { 89 | return loadConfigFile(configFile) 90 | } 91 | 92 | return defaultPrefixes, false, nil 93 | } 94 | 95 | func loadConfigFile(path string) ([]list.Item, bool, error) { 96 | data, err := os.ReadFile(path) 97 | if err != nil { 98 | return nil, false, fmt.Errorf("failed to read config file: %w", err) 99 | } 100 | var c config 101 | if err := json.Unmarshal(data, &c); err != nil { 102 | return nil, false, fmt.Errorf("invalid json in config file '%s': %w", path, err) 103 | } 104 | return convertPrefixes(c.Prefixes), c.SignOffCommits, nil 105 | } 106 | 107 | func convertPrefixes(prefixes []prefix) []list.Item { 108 | var output []list.Item 109 | for _, prefix := range prefixes { 110 | output = append(output, prefix) 111 | } 112 | if len(output) == 0 { 113 | return defaultPrefixes 114 | } 115 | return output 116 | } 117 | -------------------------------------------------------------------------------- /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liamg/comet/23ef85b6553902d28e55997e24ba0ac31d25d355/demo.png -------------------------------------------------------------------------------- /git.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "os/exec" 8 | ) 9 | 10 | const maxGitRecursion = 32 11 | 12 | func checkGitInPath() error { 13 | if _, err := exec.LookPath("git"); err != nil { 14 | return fmt.Errorf("cannot find git in PATH: %w", err) 15 | } 16 | return nil 17 | } 18 | 19 | func findGitDir() (string, error) { 20 | cmd := exec.Command("git", "rev-parse", "--show-toplevel") 21 | output, err := cmd.CombinedOutput() 22 | 23 | if err != nil { 24 | return "", fmt.Errorf(string(output)) 25 | } 26 | 27 | 28 | return strings.TrimSpace(string(output)), nil 29 | } 30 | 31 | func commit(msg string, body bool, signOff bool) error { 32 | args := append([]string{ 33 | "commit", "-m", msg, 34 | }, os.Args[1:]...) 35 | if body { 36 | args = append(args, "-e") 37 | } 38 | if signOff { 39 | args = append(args, "-s") 40 | } 41 | cmd := exec.Command("git", args...) 42 | cmd.Stdin = os.Stdin 43 | cmd.Stdout = os.Stdout 44 | cmd.Stderr = os.Stderr 45 | return cmd.Run() 46 | } 47 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/liamg/comet 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.10.3 7 | github.com/charmbracelet/bubbletea v0.20.0 8 | github.com/charmbracelet/lipgloss v0.5.0 9 | ) 10 | 11 | require ( 12 | github.com/atotto/clipboard v0.1.4 // indirect 13 | github.com/containerd/console v1.0.3 // indirect 14 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 15 | github.com/mattn/go-isatty v0.0.14 // indirect 16 | github.com/mattn/go-runewidth v0.0.13 // indirect 17 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect 18 | github.com/muesli/reflow v0.3.0 // indirect 19 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect 20 | github.com/rivo/uniseg v0.2.0 // indirect 21 | github.com/sahilm/fuzzy v0.1.0 // indirect 22 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect 23 | golang.org/x/term v0.0.0-20210422114643-f5beecf764ed // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 3 | github.com/charmbracelet/bubbles v0.10.3 h1:fKarbRaObLn/DCsZO4Y3vKCwRUzynQD9L+gGev1E/ho= 4 | github.com/charmbracelet/bubbles v0.10.3/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA= 5 | github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA= 6 | github.com/charmbracelet/bubbletea v0.20.0 h1:/b8LEPgCbNr7WWZ2LuE/BV1/r4t5PyYJtDb+J3vpwxc= 7 | github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM= 8 | github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 9 | github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM= 10 | github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= 11 | github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= 12 | github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ= 13 | github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= 14 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 15 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 16 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 17 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 18 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 19 | github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 20 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 21 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 22 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 23 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 24 | github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= 25 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 26 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 27 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 28 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 29 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 30 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 31 | github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= 32 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 33 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= 34 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 35 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 36 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 37 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 38 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 39 | github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= 40 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 41 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 42 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 43 | golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 44 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 45 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I= 46 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 47 | golang.org/x/term v0.0.0-20210422114643-f5beecf764ed h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0= 48 | golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 49 | -------------------------------------------------------------------------------- /gui.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | "github.com/charmbracelet/bubbles/textinput" 9 | 10 | "github.com/charmbracelet/bubbles/list" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | ) 14 | 15 | const defaultWidth = 20 16 | const listHeight = 14 17 | 18 | var ( 19 | titleStyle = lipgloss.NewStyle().MarginLeft(2) 20 | itemStyle = lipgloss.NewStyle().PaddingLeft(4) 21 | selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("#ff55ff")) 22 | itemDescriptionStyle = lipgloss.NewStyle().PaddingLeft(2).Faint(true) 23 | paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) 24 | helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) 25 | quitTextStyle = lipgloss.NewStyle().Margin(1, 0, 2, 4) 26 | ) 27 | 28 | type itemDelegate struct{} 29 | 30 | func (d itemDelegate) Height() int { return 1 } 31 | func (d itemDelegate) Spacing() int { return 0 } 32 | func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } 33 | func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { 34 | i, ok := listItem.(prefix) 35 | if !ok { 36 | return 37 | } 38 | 39 | str := fmt.Sprintf("%d. %s", index+1, i.Title()) 40 | 41 | var output string 42 | if index == m.Index() { 43 | output = selectedItemStyle.Render("> " + str) 44 | } else { 45 | output = itemStyle.Render(str) 46 | } 47 | output += itemDescriptionStyle.PaddingLeft(12 - len(str)).Render(i.Description()) 48 | 49 | _, _ = fmt.Fprint(w, output) 50 | } 51 | 52 | type model struct { 53 | chosenPrefix bool 54 | chosenScope bool 55 | chosenMsg bool 56 | chosenBody bool 57 | specifyBody bool 58 | prefix string 59 | scope string 60 | msg string 61 | prefixList list.Model 62 | msgInput textinput.Model 63 | scopeInput textinput.Model 64 | ynInput textinput.Model 65 | items []prefix 66 | quitting bool 67 | err error 68 | } 69 | 70 | func newModel(prefixes []list.Item) *model { 71 | 72 | // set up list 73 | prefixList := list.New(prefixes, itemDelegate{}, defaultWidth, listHeight) 74 | prefixList.Title = "What are you committing?" 75 | prefixList.SetShowStatusBar(false) 76 | prefixList.SetFilteringEnabled(true) 77 | prefixList.Styles.Title = titleStyle 78 | prefixList.Styles.PaginationStyle = paginationStyle 79 | prefixList.Styles.HelpStyle = helpStyle 80 | 81 | // set up scope prompt 82 | scopeInput := textinput.New() 83 | scopeInput.Placeholder = "Scope" 84 | scopeInput.CharLimit = 16 85 | scopeInput.Width = 20 86 | 87 | // set up commit message prompt 88 | commitInput := textinput.New() 89 | commitInput.Placeholder = "Commit message" 90 | commitInput.CharLimit = 100 91 | commitInput.Width = 50 92 | 93 | // set up add body confirmation 94 | bodyConfirmation := textinput.New() 95 | bodyConfirmation.Placeholder = "y/N" 96 | bodyConfirmation.CharLimit = 1 97 | bodyConfirmation.Width = 20 98 | 99 | return &model{ 100 | prefixList: prefixList, 101 | scopeInput: scopeInput, 102 | msgInput: commitInput, 103 | ynInput: bodyConfirmation, 104 | } 105 | } 106 | 107 | func (m *model) Init() tea.Cmd { 108 | return nil 109 | } 110 | 111 | func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 112 | switch { 113 | case !m.chosenPrefix: 114 | return m.updatePrefixList(msg) 115 | case !m.chosenScope: 116 | return m.updateScopeInput(msg) 117 | case !m.chosenMsg: 118 | return m.updateMsgInput(msg) 119 | case !m.chosenBody: 120 | return m.updateYNInput(msg) 121 | default: 122 | return m, tea.Quit 123 | } 124 | } 125 | 126 | func (m *model) Finished() bool { 127 | return m.chosenBody 128 | } 129 | 130 | func (m *model) CommitMessage() (string, bool) { 131 | prefix := m.prefix 132 | if m.scope != "" { 133 | prefix = fmt.Sprintf("%s(%s)", prefix, m.scope) 134 | } 135 | return fmt.Sprintf("%s: %s", prefix, m.msg), m.specifyBody 136 | } 137 | 138 | func (m *model) updatePrefixList(msg tea.Msg) (tea.Model, tea.Cmd) { 139 | switch msg := msg.(type) { 140 | case tea.WindowSizeMsg: 141 | m.prefixList.SetWidth(msg.Width) 142 | return m, nil 143 | 144 | case tea.KeyMsg: 145 | switch keypress := msg.String(); keypress { 146 | case "ctrl+c": 147 | m.quitting = true 148 | return m, tea.Quit 149 | 150 | case "enter": 151 | i, ok := m.prefixList.SelectedItem().(prefix) 152 | if ok { 153 | m.prefix = i.Title() 154 | m.chosenPrefix = true 155 | m.scopeInput.Focus() 156 | } 157 | } 158 | } 159 | 160 | var cmd tea.Cmd 161 | m.prefixList, cmd = m.prefixList.Update(msg) 162 | return m, cmd 163 | } 164 | 165 | func (m *model) updateScopeInput(msg tea.Msg) (tea.Model, tea.Cmd) { 166 | 167 | switch msg := msg.(type) { 168 | case tea.KeyMsg: 169 | switch msg.Type { 170 | case tea.KeyEnter: 171 | m.chosenScope = true 172 | m.scope = m.scopeInput.Value() 173 | m.msgInput.Focus() 174 | case tea.KeyCtrlC, tea.KeyEsc: 175 | return m, tea.Quit 176 | } 177 | } 178 | 179 | var cmd tea.Cmd 180 | m.scopeInput, cmd = m.scopeInput.Update(msg) 181 | return m, cmd 182 | } 183 | 184 | func (m *model) updateMsgInput(msg tea.Msg) (tea.Model, tea.Cmd) { 185 | 186 | switch msg := msg.(type) { 187 | case tea.KeyMsg: 188 | switch msg.Type { 189 | case tea.KeyEnter: 190 | m.chosenMsg = true 191 | m.msg = m.msgInput.Value() 192 | m.ynInput.Focus() 193 | case tea.KeyCtrlC, tea.KeyEsc: 194 | return m, tea.Quit 195 | } 196 | } 197 | 198 | var cmd tea.Cmd 199 | m.msgInput, cmd = m.msgInput.Update(msg) 200 | return m, cmd 201 | } 202 | 203 | func (m *model) updateYNInput(msg tea.Msg) (tea.Model, tea.Cmd) { 204 | 205 | switch msg := msg.(type) { 206 | case tea.KeyMsg: 207 | switch msg.Type { 208 | case tea.KeyEnter: 209 | m.chosenMsg = true 210 | switch strings.ToLower(m.ynInput.Value()) { 211 | case "y": 212 | m.specifyBody = true 213 | } 214 | m.chosenBody = true 215 | return m, tea.Quit 216 | case tea.KeyCtrlC, tea.KeyEsc: 217 | return m, tea.Quit 218 | } 219 | } 220 | 221 | var cmd tea.Cmd 222 | m.ynInput, cmd = m.ynInput.Update(msg) 223 | return m, cmd 224 | } 225 | 226 | func (m *model) View() string { 227 | switch { 228 | case !m.chosenPrefix: 229 | return "\n" + m.prefixList.View() 230 | case !m.chosenScope: 231 | return titleStyle.Render(fmt.Sprintf( 232 | "\nEnter a scope (enter to skip):\n\n%s\n\n%s", 233 | m.scopeInput.View(), 234 | "(esc to cancel)", 235 | ) + "\n") 236 | case !m.chosenMsg: 237 | return titleStyle.Render(fmt.Sprintf( 238 | "\nEnter a commit message:\n\n%s\n\n%s", 239 | m.msgInput.View(), 240 | "(esc to cancel)", 241 | ) + "\n") 242 | case !m.chosenBody: 243 | return fmt.Sprintf("\nDo you need to specify a body/footer?\n\n%s\n", m.ynInput.View()) 244 | case m.quitting: 245 | return quitTextStyle.Render("Aborted.\n") 246 | default: 247 | return "\nCreating commit...\n" 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | ) 9 | 10 | func main() { 11 | if err := checkGitInPath(); err != nil { 12 | fail("Error: %s", err) 13 | } 14 | 15 | gitRoot, err := findGitDir() 16 | if err != nil { 17 | fail("Error: %s", err) 18 | } 19 | 20 | if err := os.Chdir(gitRoot); err != nil { 21 | fail("Error: could not change directory: %s", err) 22 | } 23 | 24 | prefixes, signOff, err := loadConfig() 25 | if err != nil { 26 | fail("Error: %s", err) 27 | } 28 | 29 | m := newModel(prefixes) 30 | if err := tea.NewProgram(m).Start(); err != nil { 31 | fail("Error: %s", err) 32 | } 33 | 34 | fmt.Println("") 35 | 36 | if !m.Finished() { 37 | fail("Aborted.") 38 | } 39 | 40 | msg, withBody := m.CommitMessage() 41 | if err := commit(msg, withBody, signOff); err != nil { 42 | fail("Error creating commit: %s", err) 43 | } 44 | } 45 | 46 | func fail(format string, args ...interface{}) { 47 | _, _ = fmt.Fprintf(os.Stderr, format+"\n", args...) 48 | os.Exit(1) 49 | } 50 | --------------------------------------------------------------------------------