Skip to content

Commit a7d9eb9

Browse files
committed
Refactor ccswitch implementation and testing:
- **New Features**: Added new command `cmd/pr.go` to create pull requests, checking for GitHub CLI availability, and validating the current session directory. - **`.claude/settings.local.json` Updates**: Extended the `allow` array to include additional `Bash` commands like `declare`, `ccswitch list`, and `gtimeout`. - **Removal**: Deleted `bash.txt`, previously containing the `ccswitch` wrapper function, now defined inline in test scripts. - **Refactoring**: Updated `bash_wrapper_test.sh`: - Replaced `mock_ccsplit` with `mock_ccswitch` for accurate command simulation. - Integrated the `ccswitch` function directly for better testing and mock integration. - Each sequence maintains its output testing logic. These enhancements simplify ccswitch management, improve session command handling, and introduce pull request automation through CLI tools.
1 parent 4ae176d commit a7d9eb9

4 files changed

Lines changed: 231 additions & 69 deletions

File tree

.claude/settings.local.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
"Bash(./ccsplit switch)",
77
"Bash(./ccswitch)",
88
"Bash(./ccswitch switch test-switch-command)",
9-
"Bash(./ccswitch:*)"
9+
"Bash(./ccswitch:*)",
10+
"Bash(declare:*)",
11+
"Bash(ccswitch list:*)",
12+
"Bash(ccswitch switch:*)",
13+
"Bash(gtimeout:*)"
1014
],
1115
"deny": []
1216
}

bash.txt

Lines changed: 0 additions & 52 deletions
This file was deleted.

bash_wrapper_test.sh

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ NC='\033[0m' # No Color
1111
TESTS_RUN=0
1212
TESTS_PASSED=0
1313

14-
# Mock ccsplit command for testing
15-
mock_ccsplit() {
14+
# Mock ccswitch command for testing
15+
mock_ccswitch() {
1616
case "$1" in
1717
"list")
1818
echo "Listing sessions..."
@@ -32,16 +32,36 @@ mock_ccsplit() {
3232
esac
3333
}
3434

35-
# Source the wrapper function (but use mock instead of real command)
36-
source bash.txt
37-
# Override command lookup to use our mock
38-
command() {
39-
if [ "$1" = "ccsplit" ]; then
40-
shift
41-
mock_ccsplit "$@"
42-
else
43-
/usr/bin/command "$@"
44-
fi
35+
# Define the wrapper function manually for testing (simulates shell-init output)
36+
ccswitch() {
37+
case "$1" in
38+
list|cleanup|info|shell-init)
39+
# These commands don't need special handling
40+
mock_ccswitch "$@"
41+
;;
42+
switch)
43+
# For switch command, capture output and execute cd command
44+
local output=$(mock_ccswitch "$@")
45+
echo "$output"
46+
47+
# Extract and execute the cd command if switch was successful
48+
local cd_cmd=$(echo "$output" | grep "^cd " | tail -1)
49+
if [ -n "$cd_cmd" ]; then
50+
eval "$cd_cmd"
51+
fi
52+
;;
53+
create|*)
54+
# For session creation (default command and explicit create)
55+
local output=$(mock_ccswitch "$@")
56+
echo "$output"
57+
58+
# Extract and execute the cd command if session was created successfully
59+
local cd_cmd=$(echo "$output" | grep "^cd " | tail -1)
60+
if [ -n "$cd_cmd" ]; then
61+
eval "$cd_cmd"
62+
fi
63+
;;
64+
esac
4565
}
4666

4767
# Test function
@@ -66,17 +86,17 @@ run_test() {
6686
echo "Running bash wrapper tests..."
6787
echo
6888

69-
output=$(ccsplit list 2>&1)
89+
output=$(ccswitch list 2>&1)
7090
run_test "List command passthrough" "Listing sessions..." "$output"
7191

7292
# Test 2: Cleanup command should pass through directly
73-
output=$(ccsplit cleanup test-session 2>&1)
93+
output=$(ccswitch cleanup test-session 2>&1)
7494
run_test "Cleanup command passthrough" "Cleaning up session: test-session" "$output"
7595

7696
# Test 3: Session creation should capture and execute cd command
7797
# This is harder to test directly since we can't actually change directories in a subshell
7898
# We'll test that the output contains the expected text
79-
output=$(ccsplit 2>&1)
99+
output=$(ccswitch 2>&1)
80100
if echo "$output" | grep -q "Created session: feature/test-feature" && \
81101
echo "$output" | grep -q "cd ../test-feature"; then
82102
run_test "Session creation output" "success" "success"
@@ -85,7 +105,7 @@ else
85105
fi
86106

87107
# Test 4: Empty/no arguments should work
88-
output=$(ccsplit 2>&1)
108+
output=$(ccswitch 2>&1)
89109
if [ -n "$output" ]; then
90110
run_test "No arguments handling" "success" "success"
91111
else

cmd/pr.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"strings"
8+
9+
"github.com/ksred/ccswitch/internal/git"
10+
"github.com/ksred/ccswitch/internal/session"
11+
"github.com/ksred/ccswitch/internal/ui"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
func newPRCmd() *cobra.Command {
16+
return &cobra.Command{
17+
Use: "pr",
18+
Short: "Create a pull request for the current session",
19+
Run: createPullRequest,
20+
}
21+
}
22+
23+
func createPullRequest(cmd *cobra.Command, args []string) {
24+
// Get current directory
25+
currentDir, err := os.Getwd()
26+
if err != nil {
27+
fmt.Println(ui.ErrorStyle.Render("✗ Failed to get current directory"))
28+
return
29+
}
30+
31+
// Check if gh CLI is available
32+
if !isGitHubCLIAvailable() {
33+
fmt.Println(ui.ErrorStyle.Render("✗ GitHub CLI (gh) is not installed or not in PATH"))
34+
fmt.Println(ui.InfoStyle.Render(" Install GitHub CLI: https://cli.github.com/"))
35+
return
36+
}
37+
38+
// Check if we're in a git repository
39+
if !git.IsGitRepository(currentDir) {
40+
fmt.Println(ui.ErrorStyle.Render("✗ Not in a git repository"))
41+
return
42+
}
43+
44+
// Get current branch
45+
currentBranch, err := git.GetCurrentBranch(currentDir)
46+
if err != nil {
47+
fmt.Printf(ui.ErrorStyle.Render("✗ Failed to get current branch: %v\n"), err)
48+
return
49+
}
50+
51+
// Check if we're on main/master branch
52+
if currentBranch == "main" || currentBranch == "master" {
53+
fmt.Println(ui.ErrorStyle.Render("✗ Cannot create PR from main/master branch"))
54+
fmt.Println(ui.InfoStyle.Render(" Switch to a feature branch first using 'ccswitch list'"))
55+
return
56+
}
57+
58+
// Check if we're in a ccswitch session
59+
manager := session.NewManager(currentDir)
60+
sessions, err := manager.ListSessions()
61+
if err != nil {
62+
fmt.Printf(ui.ErrorStyle.Render("✗ Failed to list sessions: %v\n"), err)
63+
return
64+
}
65+
66+
var currentSession *git.SessionInfo
67+
for _, s := range sessions {
68+
if s.Path == currentDir {
69+
s := s // Create a copy to take address of
70+
currentSession = &s
71+
break
72+
}
73+
}
74+
75+
if currentSession == nil {
76+
fmt.Println(ui.ErrorStyle.Render("✗ Not in a ccswitch session directory"))
77+
fmt.Println(ui.InfoStyle.Render(" Use 'ccswitch list' to enter a session first"))
78+
return
79+
}
80+
81+
fmt.Printf(ui.TitleStyle.Render("🚀 Creating pull request for session: %s\n"), currentSession.Name)
82+
fmt.Printf(ui.InfoStyle.Render(" Branch: %s\n"), currentBranch)
83+
84+
// Check if branch has commits ahead of main
85+
hasCommits, err := checkBranchHasCommits(currentDir, currentBranch)
86+
if err != nil {
87+
fmt.Printf(ui.ErrorStyle.Render("✗ Failed to check branch commits: %v\n"), err)
88+
return
89+
}
90+
91+
if !hasCommits {
92+
fmt.Println(ui.ErrorStyle.Render("✗ No commits found on this branch"))
93+
fmt.Println(ui.InfoStyle.Render(" Make some commits before creating a PR"))
94+
return
95+
}
96+
97+
// Push the branch if needed
98+
fmt.Println(ui.InfoStyle.Render("📤 Pushing branch to remote..."))
99+
if err := pushBranch(currentDir, currentBranch); err != nil {
100+
fmt.Printf(ui.ErrorStyle.Render("✗ Failed to push branch: %v\n"), err)
101+
return
102+
}
103+
104+
// Create PR using gh CLI
105+
fmt.Println(ui.InfoStyle.Render("📝 Creating pull request..."))
106+
prURL, err := createPRWithGH(currentDir, currentSession.Name)
107+
if err != nil {
108+
fmt.Printf(ui.ErrorStyle.Render("✗ Failed to create PR: %v\n"), err)
109+
return
110+
}
111+
112+
fmt.Printf(ui.SuccessStyle.Render("✓ Pull request created successfully!\n"))
113+
fmt.Printf(ui.InfoStyle.Render(" URL: %s\n"), prURL)
114+
115+
// Open in browser
116+
fmt.Println(ui.InfoStyle.Render("🌐 Opening PR in browser..."))
117+
if err := openInBrowser(prURL); err != nil {
118+
fmt.Printf(ui.ErrorStyle.Render("✗ Failed to open browser: %v\n"), err)
119+
fmt.Println(ui.InfoStyle.Render(" You can manually open the URL above"))
120+
}
121+
}
122+
123+
func isGitHubCLIAvailable() bool {
124+
_, err := exec.LookPath("gh")
125+
return err == nil
126+
}
127+
128+
func checkBranchHasCommits(dir, branch string) (bool, error) {
129+
cmd := exec.Command("git", "rev-list", "--count", "main.."+branch)
130+
cmd.Dir = dir
131+
132+
output, err := cmd.Output()
133+
if err != nil {
134+
return false, err
135+
}
136+
137+
count := strings.TrimSpace(string(output))
138+
return count != "0", nil
139+
}
140+
141+
func pushBranch(dir, branch string) error {
142+
cmd := exec.Command("git", "push", "-u", "origin", branch)
143+
cmd.Dir = dir
144+
cmd.Stdout = os.Stdout
145+
cmd.Stderr = os.Stderr
146+
147+
return cmd.Run()
148+
}
149+
150+
func createPRWithGH(dir, sessionName string) (string, error) {
151+
// Generate PR title from session name
152+
title := strings.ReplaceAll(sessionName, "-", " ")
153+
title = strings.Title(title)
154+
155+
cmd := exec.Command("gh", "pr", "create", "--title", title, "--body", "Created from ccswitch session: "+sessionName, "--web")
156+
cmd.Dir = dir
157+
158+
output, err := cmd.Output()
159+
if err != nil {
160+
return "", err
161+
}
162+
163+
// Extract URL from output
164+
lines := strings.Split(string(output), "\n")
165+
for _, line := range lines {
166+
line = strings.TrimSpace(line)
167+
if strings.HasPrefix(line, "https://github.com/") {
168+
return line, nil
169+
}
170+
}
171+
172+
return string(output), nil
173+
}
174+
175+
func openInBrowser(url string) error {
176+
var cmd *exec.Cmd
177+
178+
switch {
179+
case exec.Command("which", "open").Run() == nil: // macOS
180+
cmd = exec.Command("open", url)
181+
case exec.Command("which", "xdg-open").Run() == nil: // Linux
182+
cmd = exec.Command("xdg-open", url)
183+
case exec.Command("which", "cmd").Run() == nil: // Windows
184+
cmd = exec.Command("cmd", "/c", "start", url)
185+
default:
186+
return fmt.Errorf("unable to detect platform to open browser")
187+
}
188+
189+
return cmd.Run()
190+
}

0 commit comments

Comments
 (0)