├── .gitignore ├── go.mod ├── .github └── workflows │ └── release.yml ├── README.md ├── .goreleaser.yaml └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | dist/ 3 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/marocchino/acommit 2 | 3 | go 1.20 4 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - "v[0-9]+.[0-9]+.[0-9]+" 6 | permissions: 7 | contents: write 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 0 16 | - name: Setup Go 17 | uses: actions/setup-go@v3 18 | with: 19 | go-version: 1.20 20 | - name: Run GoReleaser 21 | uses: goreleaser/goreleaser-action@v4 22 | with: 23 | distribution: goreleaser 24 | version: latest 25 | args: release --clean 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Acommit 2 | 3 | Generate commit message with chatgpt api 4 | 5 | ## Install 6 | 7 | Download the binary from [GitHub Releases](https://github.com/marocchino/acommit/releases/) and drop it in your $PATH. 8 | 9 | Or use go install 10 | 11 | ```bash 12 | go install github.com/marocchino/acommit 13 | ``` 14 | 15 | ## Usage 16 | 17 | ```bash 18 | # from your repo 19 | git add . 20 | acommit 21 | ``` 22 | 23 | ## Config 24 | 25 | You can customize the prompt by modifying `~/.config/acommit/prompt.txt`. 26 | 27 | ### I18n example 28 | 29 | If you want to write the commit message in a different language, you can add that language after the prompt. Let's say you're in Japanese. 30 | 31 | ``` 32 | And, Translate it to Japanese except prefix. 33 | ``` 34 | 35 | ### commit convention 36 | 37 | Using `gitmoji convention with emoji` by default. You may have several other options. 38 | 39 | - [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) 40 | - gitmoji convention with emoji markup 41 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | builds: 4 | - env: 5 | - CGO_ENABLED=0 6 | goos: 7 | - linux 8 | - windows 9 | - darwin 10 | 11 | archives: 12 | - format: tar.gz 13 | # this name template makes the OS and Arch compatible with the results of uname. 14 | name_template: >- 15 | {{ .ProjectName }}_ 16 | {{- title .Os }}_ 17 | {{- if eq .Arch "amd64" }}x86_64 18 | {{- else if eq .Arch "386" }}i386 19 | {{- else }}{{ .Arch }}{{ end }} 20 | {{- if .Arm }}v{{ .Arm }}{{ end }} 21 | # use zip for windows archives 22 | format_overrides: 23 | - goos: windows 24 | format: zip 25 | checksum: 26 | name_template: 'checksums.txt' 27 | snapshot: 28 | name_template: "{{ incpatch .Version }}-next" 29 | changelog: 30 | sort: asc 31 | filters: 32 | exclude: 33 | - '^📝' 34 | - '^✅' 35 | 36 | # The lines beneath this are called `modelines`. See `:help modeline` 37 | # Feel free to remove those if you don't want/use them. 38 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 39 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 40 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "strings" 12 | ) 13 | 14 | type Message struct { 15 | Role string `json:"role"` 16 | Content string `json:"content"` 17 | } 18 | 19 | type Choice struct { 20 | Message Message `json:"message"` 21 | } 22 | 23 | type JsonResponse struct { 24 | Choices []Choice `json:"choices"` 25 | } 26 | 27 | var ( 28 | apiKey = os.Getenv("OPENAI_API_KEY") 29 | ) 30 | 31 | func main() { 32 | 33 | diff, err := getStagedDiff() 34 | if err != nil { 35 | fmt.Printf("Error running git diff --staged: %v\n", err) 36 | return 37 | } 38 | result, err := generateText(diff) 39 | if err != nil { 40 | fmt.Printf("Error: %v\n", err) 41 | return 42 | } 43 | text, err := parseResponse(result) 44 | if err != nil { 45 | fmt.Printf("Error unmarshalling JSON: %v\n", err) 46 | return 47 | } 48 | err = commitWithEditor(text) 49 | if err != nil { 50 | fmt.Printf("Error running git commit: %v\n", err) 51 | return 52 | } 53 | } 54 | 55 | func getStagedDiff() (string, error) { 56 | cmd := exec.Command("git", "diff", "--staged") 57 | 58 | output, err := cmd.CombinedOutput() 59 | if err != nil { 60 | return "", err 61 | } 62 | result := strings.TrimSpace(string(output)) 63 | if result == "" { 64 | return "", fmt.Errorf("No staged changes.") 65 | } 66 | 67 | return result, nil 68 | } 69 | 70 | func fetchPrompt() (string, error) { 71 | home, err := os.UserHomeDir() 72 | if err != nil { 73 | return "", err 74 | } 75 | 76 | filePath := filepath.Join(home, ".config", "acommit", "prompt.txt") 77 | 78 | content, err := os.ReadFile(filePath) 79 | if os.IsNotExist(err) { 80 | fmt.Println("No prompt file found. Creating one at ~/.config/acommit/prompt.txt") 81 | const prompt = "You are to act as the author of a commit message in git. Your mission is to create clean and comprehensive commit messages in the gitmoji convention with emoji and explain why a change was done. I'll send you an output of 'git diff --staged' command, and you convert it into a commit message. Add a short description of WHY the changes are done after the commit message. Don't start it with 'This commit', just describe the changes. Use the present tense. Commit title must not be longer than 74 characters." 82 | // mkdir -p 83 | err = os.MkdirAll(filepath.Dir(filePath), 0755) 84 | if err != nil { 85 | return "", err 86 | } 87 | file, err := os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0666) 88 | if err != nil { 89 | return "", err 90 | } 91 | defer file.Close() 92 | _, err = file.WriteString(prompt) 93 | if err != nil { 94 | return "", err 95 | } 96 | return prompt, nil 97 | } else if err != nil { 98 | return "", err 99 | } 100 | fmt.Println("Using prompt from ~/.config/acommit/prompt.txt") 101 | 102 | return string(content), nil 103 | } 104 | 105 | func generateText(diff string) (string, error) { 106 | if apiKey == "" { 107 | return "", fmt.Errorf("OPENAI_API_KEY environment variable is not set. you can get it from https://platform.openai.com/account/api-keys.") 108 | } 109 | prompt, err := fetchPrompt() 110 | if err != nil { 111 | return "", err 112 | } 113 | url := "https://api.openai.com/v1/chat/completions" 114 | messages := []Message{ 115 | { 116 | Role: "system", 117 | Content: prompt, 118 | }, 119 | { 120 | Role: "user", 121 | Content: diff, 122 | }, 123 | } 124 | 125 | data := map[string]interface{}{ 126 | "model": "gpt-3.5-turbo", 127 | "messages": messages, 128 | } 129 | 130 | payload, err := json.Marshal(data) 131 | if err != nil { 132 | return "", err 133 | } 134 | 135 | req, err := http.NewRequest("POST", url, strings.NewReader(string(payload))) 136 | if err != nil { 137 | return "", err 138 | } 139 | 140 | req.Header.Set("Content-Type", "application/json") 141 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) 142 | 143 | resp, err := http.DefaultClient.Do(req) 144 | if err != nil { 145 | return "", err 146 | } 147 | defer resp.Body.Close() 148 | 149 | body, err := io.ReadAll(resp.Body) 150 | if err != nil { 151 | return "", err 152 | } 153 | text := string(body) 154 | if text == "" { 155 | return "", fmt.Errorf("No text generated.") 156 | } 157 | 158 | // Extract the generated text from the API response here. 159 | // You may need to use a JSON library like "encoding/json" to parse the response. 160 | return text, nil 161 | } 162 | 163 | func parseResponse(result string) (string, error) { 164 | var response JsonResponse 165 | err := json.Unmarshal([]byte(result), &response) 166 | if err != nil { 167 | return "", err 168 | } 169 | text := response.Choices[0].Message.Content 170 | return strings.Trim(text, "\n"), nil 171 | } 172 | 173 | func commitWithEditor(message string) error { 174 | // Create a temporary file to hold the commit message 175 | tempFile, err := os.CreateTemp("", "commit-message") 176 | if err != nil { 177 | return err 178 | } 179 | defer os.Remove(tempFile.Name()) 180 | 181 | // Write the commit message to the temporary file 182 | _, err = tempFile.WriteString(message) 183 | if err != nil { 184 | return err 185 | } 186 | 187 | // Close the temp file to flush the contents 188 | tempFile.Close() 189 | 190 | cmd := exec.Command("git", "commit", "-t", tempFile.Name()) 191 | cmd.Stdin = os.Stdin 192 | cmd.Stdout = os.Stdout 193 | cmd.Stderr = os.Stderr 194 | return cmd.Run() 195 | } 196 | --------------------------------------------------------------------------------