Skip to content

Commit f33aa29

Browse files
committed
Add Git branch name generation feature with AI support
1 parent 4c590dc commit f33aa29

File tree

14 files changed

+377
-21
lines changed

14 files changed

+377
-21
lines changed

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,4 @@ jobs:
3030
version: latest
3131
args: release --clean
3232
env:
33-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
33+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,4 @@ jobs:
2727
run: go vet ./...
2828

2929
- name: Run tests
30-
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
30+
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...

.goreleaser.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,4 @@ changelog:
3232
exclude:
3333
- '^docs:'
3434
- '^test:'
35-
- '^ci:'
35+
- '^ci:'

README.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ Git AI enhances your Git workflow with AI-powered features.
1010
- Commit automatically with `--auto` flag
1111
- Add detailed descriptions with `--with-descriptions`
1212
- Control format with `--conventional` and `--no-conventional` flags
13+
- `git ai branch`: Generates meaningful branch names from user input
14+
- Create descriptive branch names based on your description
15+
- Check existing local and remote branches for naming conventions
16+
- Provide interactive approval with edit option
17+
- Create branch automatically with `--auto` flag
18+
- Print branch name only without creating with the option menu
1319
- `git ai config`: Manages LLM settings
1420
- Set up API keys for your preferred provider
1521
- Offer various models (OpenAI, Anthropic, Ollama, etc.)
@@ -122,6 +128,15 @@ git ai commit --no-conventional
122128
# Amend previous commit
123129
git ai commit --amend
124130

131+
# Generate branch name
132+
git ai branch "Add sorting feature to user list"
133+
134+
# Generate branch with auto-approval
135+
git ai branch --auto "Fix authentication bug"
136+
137+
# Provide description with flag
138+
git ai branch -d "Update documentation for API endpoints"
139+
125140
# Use specific config file
126141
git ai --config /path/to/config.yaml commit
127142
```
@@ -166,9 +181,14 @@ Git AI embeds prompt templates from text files into the binary at compile time.
166181

167182
- `commit_system.txt`: LLM instructions with sections for conventional vs. standard format
168183
- `commit_user.txt`: User prompt template with placeholders for content
169-
170-
Both files use Go's template syntax:
171-
- `{{if .UseConventional}}...{{else}}...{{end}}` controls format instructions
172-
- `{{.Diff}}`, `{{.ChangedFiles}}`, `{{.RecentCommits}}` insert content
184+
- `branch_system.txt`: LLM instructions for branch name generation
185+
- `branch_user.txt`: User prompt template for branch creation
186+
187+
The prompt files use Go's template syntax:
188+
- For commit prompts:
189+
- `{{if .UseConventional}}...{{else}}...{{end}}` controls format instructions
190+
- `{{.Diff}}`, `{{.ChangedFiles}}`, `{{.RecentCommits}}` insert content
191+
- For branch prompts:
192+
- `{{.Request}}`, `{{.LocalBranches}}`, `{{.RemoteBranches}}` insert content
173193

174194
Rebuild with `go build` after modifying prompts.

cmd/branch/branch.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package branch
2+
3+
import (
4+
"os"
5+
6+
"github.com/recrsn/git-ai/pkg/config"
7+
"github.com/recrsn/git-ai/pkg/git"
8+
"github.com/recrsn/git-ai/pkg/llm"
9+
"github.com/recrsn/git-ai/pkg/logger"
10+
"github.com/recrsn/git-ai/pkg/ui"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
var (
15+
autoApprove bool
16+
description string
17+
)
18+
19+
// Cmd represents the branch command
20+
var Cmd = &cobra.Command{
21+
Use: "branch [description]",
22+
Short: "Generate a meaningful Git branch name",
23+
Long: `Analyzes your input and existing branches to generate a meaningful branch name.`,
24+
Args: cobra.MaximumNArgs(1),
25+
Run: func(cmd *cobra.Command, args []string) {
26+
// If description is provided as an argument, use it
27+
if len(args) > 0 {
28+
description = args[0]
29+
}
30+
31+
// If no description is provided, prompt for one
32+
if description == "" {
33+
var err error
34+
description, err = ui.PromptForInput("Enter a brief description of your branch:", "")
35+
if err != nil {
36+
logger.Fatal("Error prompting for description: %v", err)
37+
}
38+
if description == "" {
39+
logger.Error("Description cannot be empty.")
40+
os.Exit(1)
41+
}
42+
}
43+
44+
executeBranch(description)
45+
},
46+
}
47+
48+
func init() {
49+
Cmd.Flags().BoolVar(&autoApprove, "auto", false, "Automatically approve the generated branch name without prompting")
50+
Cmd.Flags().StringVarP(&description, "description", "d", "", "Brief description of the branch purpose")
51+
}
52+
53+
func executeBranch(description string) {
54+
cfg, err := config.LoadConfig()
55+
if err != nil {
56+
logger.Fatal("Failed to load config: %v", err)
57+
}
58+
59+
// Generate branch name - with spinner
60+
spinner, err := ui.ShowSpinner("Generating branch name with LLM...")
61+
if err != nil {
62+
logger.Error("Failed to start spinner: %v", err)
63+
}
64+
65+
branchName, err := llm.GenerateBranchName(cfg, description)
66+
if err != nil {
67+
if spinner != nil {
68+
spinner.Fail("Failed to generate branch name!")
69+
}
70+
logger.Fatal("Failed to generate branch name: %v", err)
71+
}
72+
73+
if spinner != nil {
74+
spinner.Success("Branch name generated!")
75+
}
76+
77+
// If auto-approve flag is not set, ask user to confirm or edit
78+
var proceed bool
79+
if !autoApprove {
80+
ui.DisplayBox("Generated Branch Name", branchName)
81+
82+
options := []string{"Create branch", "Edit name", "Print name only", "Cancel"}
83+
selectedOption, err := ui.PromptForSelection(options, "Create branch", "What would you like to do?")
84+
if err != nil {
85+
logger.Fatal("Error prompting for selection: %v", err)
86+
}
87+
88+
switch selectedOption {
89+
case "Create branch":
90+
proceed = true
91+
case "Edit name":
92+
// Use text input for editing with pre-filled value
93+
branchName, err = ui.PromptForInput("Edit branch name:", branchName)
94+
if err != nil {
95+
logger.Fatal("Error prompting for input: %v", err)
96+
}
97+
if branchName == "" {
98+
logger.Error("Branch name cannot be empty.")
99+
os.Exit(1)
100+
}
101+
proceed = true
102+
case "Print name only":
103+
logger.PrintMessage(branchName)
104+
os.Exit(0)
105+
case "Cancel":
106+
logger.PrintMessage("Branch creation cancelled.")
107+
os.Exit(0)
108+
}
109+
} else {
110+
proceed = true
111+
}
112+
113+
if proceed {
114+
// Create the branch
115+
err = git.CreateBranch(branchName)
116+
if err != nil {
117+
logger.Fatal("Failed to create branch: %v", err)
118+
}
119+
120+
logger.PrintMessagef("Branch '%s' created successfully!", branchName)
121+
}
122+
}

install.sh

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,15 @@ else
5858
fi
5959
else
6060
print_step "No local binary found, downloading from GitHub releases..."
61-
61+
6262
# Get latest release from GitHub API
6363
LATEST_RELEASE_URL=$(curl -s https://api.github.com/repos/recrsn/git-ai/releases/latest | grep "browser_download_url.*$(uname -s | tr '[:upper:]' '[:lower:]')*$(uname -m)*" | cut -d : -f 2,3 | tr -d \")
64-
64+
6565
if [ -z "$LATEST_RELEASE_URL" ]; then
6666
print_error "Could not find a release for your platform ($(uname -s), $(uname -m))"
6767
exit 1
6868
fi
69-
69+
7070
print_step "Downloading from: $LATEST_RELEASE_URL"
7171
curl -L -o "$INSTALL_DIR/$BINARY_NAME" "$LATEST_RELEASE_URL"
7272
chmod +x "$INSTALL_DIR/$BINARY_NAME"
@@ -80,7 +80,7 @@ chmod +x "$INSTALL_DIR/$BINARY_NAME"
8080
# Check if ~/.local/bin is in PATH
8181
if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
8282
print_warning "$INSTALL_DIR is not in your PATH"
83-
83+
8484
# Determine shell and provide appropriate command
8585
SHELL_NAME="$(basename "$SHELL")"
8686
case "$SHELL_NAME" in
@@ -106,4 +106,4 @@ else
106106
fi
107107

108108
print_success "Installation complete!"
109-
print_step "Run 'git ai config' to set up your LLM provider"
109+
print_step "Run 'git ai config' to set up your LLM provider"

main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"github.com/recrsn/git-ai/cmd/branch"
45
"github.com/recrsn/git-ai/cmd/commit"
56
cmdConfig "github.com/recrsn/git-ai/cmd/config"
67
"github.com/recrsn/git-ai/pkg/config"
@@ -39,6 +40,7 @@ func init() {
3940
rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "Path to config file (default is $HOME/.git-ai.yaml and ./.git-ai.yaml)")
4041

4142
// Add subcommands
43+
rootCmd.AddCommand(branch.Cmd)
4244
rootCmd.AddCommand(commit.Cmd)
4345
rootCmd.AddCommand(cmdConfig.Cmd)
4446
}

pkg/git/git.go

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"fmt"
66
"os"
77
"os/exec"
8-
"path/filepath"
98
"regexp"
109
"strings"
1110

@@ -223,3 +222,58 @@ func SetConfig(key, value string) error {
223222
}
224223
return nil
225224
}
225+
226+
// GetLocalBranches returns a list of local git branches
227+
func GetLocalBranches() ([]string, error) {
228+
cmd := exec.Command("git", "branch", "--format=%(refname:short)")
229+
var out bytes.Buffer
230+
cmd.Stdout = &out
231+
err := cmd.Run()
232+
if err != nil {
233+
logger.Error("Error getting local branches: %v", err)
234+
return nil, fmt.Errorf("error getting local branches: %v", err)
235+
}
236+
237+
branches := strings.Split(strings.TrimSpace(out.String()), "\n")
238+
var result []string
239+
for _, branch := range branches {
240+
if branch != "" {
241+
result = append(result, branch)
242+
}
243+
}
244+
return result, nil
245+
}
246+
247+
// GetRemoteBranches returns a list of remote git branches
248+
func GetRemoteBranches() ([]string, error) {
249+
cmd := exec.Command("git", "branch", "-r", "--format=%(refname:short)")
250+
var out bytes.Buffer
251+
cmd.Stdout = &out
252+
err := cmd.Run()
253+
if err != nil {
254+
logger.Error("Error getting remote branches: %v", err)
255+
return nil, fmt.Errorf("error getting remote branches: %v", err)
256+
}
257+
258+
branches := strings.Split(strings.TrimSpace(out.String()), "\n")
259+
var result []string
260+
for _, branch := range branches {
261+
if branch != "" {
262+
result = append(result, branch)
263+
}
264+
}
265+
return result, nil
266+
}
267+
268+
// CreateBranch creates a new git branch
269+
func CreateBranch(name string) error {
270+
cmd := exec.Command("git", "checkout", "-b", name)
271+
var stderr bytes.Buffer
272+
cmd.Stderr = &stderr
273+
err := cmd.Run()
274+
if err != nil {
275+
logger.Error("Error creating branch %s: %v: %s", name, err, stderr.String())
276+
return fmt.Errorf("error creating branch %s: %v: %s", name, err, stderr.String())
277+
}
278+
return nil
279+
}

pkg/llm/llm.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,84 @@ func generateSimpleMessage() string {
9393

9494
return message
9595
}
96+
97+
// GenerateBranchName generates a branch name based on user input and existing branches
98+
func GenerateBranchName(cfg config.Config, request string) (string, error) {
99+
if cfg.Endpoint == "" || cfg.APIKey == "" {
100+
return "", fmt.Errorf("LLM endpoint or API key not configured")
101+
}
102+
103+
client, err := NewClient(cfg.Endpoint, cfg.APIKey)
104+
if err != nil {
105+
return "", fmt.Errorf("failed to create LLM client: %w", err)
106+
}
107+
108+
// Get lists of existing branches
109+
localBranches, err := git.GetLocalBranches()
110+
if err != nil {
111+
logger.Warn("Failed to get local branches: %v", err)
112+
localBranches = []string{}
113+
}
114+
115+
remoteBranches, err := git.GetRemoteBranches()
116+
if err != nil {
117+
logger.Warn("Failed to get remote branches: %v", err)
118+
remoteBranches = []string{}
119+
}
120+
121+
// Get system and user prompts
122+
systemPrompt := GetBranchSystemPrompt()
123+
userPrompt, err := GetBranchUserPrompt(request, localBranches, remoteBranches)
124+
if err != nil {
125+
return "", fmt.Errorf("failed to build prompt: %w", err)
126+
}
127+
128+
// Call the LLM API
129+
messages := []Message{
130+
{
131+
Role: "system",
132+
Content: systemPrompt,
133+
},
134+
{
135+
Role: "user",
136+
Content: userPrompt,
137+
},
138+
}
139+
140+
response, err := client.ChatCompletion(cfg.Model, messages)
141+
if err != nil {
142+
return "", fmt.Errorf("failed to get completion: %w", err)
143+
}
144+
145+
// Clean up the response
146+
branchName := strings.TrimSpace(response)
147+
148+
// Ensure it doesn't contain any invalid characters
149+
branchName = sanitizeBranchName(branchName)
150+
151+
return branchName, nil
152+
}
153+
154+
// sanitizeBranchName ensures the branch name follows Git conventions
155+
func sanitizeBranchName(name string) string {
156+
// Replace spaces with hyphens
157+
name = strings.ReplaceAll(name, " ", "-")
158+
159+
// Remove any Git-unfriendly characters
160+
name = strings.Map(func(r rune) rune {
161+
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '/' || r == '_' || r == '.' {
162+
return r
163+
}
164+
return '-'
165+
}, name)
166+
167+
// Convert multiple hyphens to a single one
168+
for strings.Contains(name, "--") {
169+
name = strings.ReplaceAll(name, "--", "-")
170+
}
171+
172+
// Trim hyphens from the start and end
173+
name = strings.Trim(name, "-")
174+
175+
return name
176+
}

0 commit comments

Comments
 (0)