Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import (
const (
ConfigFileName = "config.json"
defaultProgram = "claude"
// WorktreeRootSubdirectory stores worktrees under the global config subdirectory.
WorktreeRootSubdirectory = "subdirectory"
// WorktreeRootSibling stores worktrees in a sibling directory next to the repository.
WorktreeRootSibling = "sibling"
)

// GetConfigDir returns the path to the application's configuration directory
Expand All @@ -36,6 +40,8 @@ type Config struct {
DaemonPollInterval int `json:"daemon_poll_interval"`
// BranchPrefix is the prefix used for git branches created by the application.
BranchPrefix string `json:"branch_prefix"`
// WorktreeRoot controls where worktrees are created: "subdirectory" or "sibling".
WorktreeRoot string `json:"worktree_root"`
}

// DefaultConfig returns the default configuration
Expand All @@ -58,6 +64,7 @@ func DefaultConfig() *Config {
}
return fmt.Sprintf("%s/", strings.ToLower(user.Username))
}(),
WorktreeRoot: WorktreeRootSubdirectory,
}
}

Expand Down Expand Up @@ -138,6 +145,10 @@ func LoadConfig() *Config {
return DefaultConfig()
}

if config.WorktreeRoot == "" {
config.WorktreeRoot = WorktreeRootSubdirectory
}

return &config
}

Expand Down
32 changes: 31 additions & 1 deletion config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ func TestDefaultConfig(t *testing.T) {
assert.Equal(t, 1000, config.DaemonPollInterval)
assert.NotEmpty(t, config.BranchPrefix)
assert.True(t, strings.HasSuffix(config.BranchPrefix, "/"))
assert.Equal(t, WorktreeRootSubdirectory, config.WorktreeRoot)
})

}
Expand Down Expand Up @@ -155,7 +156,8 @@ func TestLoadConfig(t *testing.T) {
"default_program": "test-claude",
"auto_yes": true,
"daemon_poll_interval": 2000,
"branch_prefix": "test/"
"branch_prefix": "test/",
"worktree_root": "sibling"
}`
err = os.WriteFile(configPath, []byte(configContent), 0644)
require.NoError(t, err)
Expand All @@ -172,6 +174,31 @@ func TestLoadConfig(t *testing.T) {
assert.True(t, config.AutoYes)
assert.Equal(t, 2000, config.DaemonPollInterval)
assert.Equal(t, "test/", config.BranchPrefix)
assert.Equal(t, WorktreeRootSibling, config.WorktreeRoot)
})

t.Run("defaults worktree_root when missing from existing config", func(t *testing.T) {
tempHome := t.TempDir()
configDir := filepath.Join(tempHome, ".claude-squad")
err := os.MkdirAll(configDir, 0755)
require.NoError(t, err)

configPath := filepath.Join(configDir, ConfigFileName)
configContent := `{
"default_program": "test-claude",
"auto_yes": true,
"daemon_poll_interval": 2000,
"branch_prefix": "test/"
}`
err = os.WriteFile(configPath, []byte(configContent), 0644)
require.NoError(t, err)

originalHome := os.Getenv("HOME")
os.Setenv("HOME", tempHome)
defer os.Setenv("HOME", originalHome)

loadedConfig := LoadConfig()
assert.Equal(t, WorktreeRootSubdirectory, loadedConfig.WorktreeRoot)
})

t.Run("returns default config on invalid JSON", func(t *testing.T) {
Expand Down Expand Up @@ -199,6 +226,7 @@ func TestLoadConfig(t *testing.T) {
assert.NotEmpty(t, config.DefaultProgram)
assert.False(t, config.AutoYes) // Default value
assert.Equal(t, 1000, config.DaemonPollInterval) // Default value
assert.Equal(t, WorktreeRootSubdirectory, config.WorktreeRoot)
})
}

Expand All @@ -218,6 +246,7 @@ func TestSaveConfig(t *testing.T) {
AutoYes: true,
DaemonPollInterval: 3000,
BranchPrefix: "test-branch/",
WorktreeRoot: WorktreeRootSibling,
}

err := SaveConfig(testConfig)
Expand All @@ -235,5 +264,6 @@ func TestSaveConfig(t *testing.T) {
assert.Equal(t, testConfig.AutoYes, loadedConfig.AutoYes)
assert.Equal(t, testConfig.DaemonPollInterval, loadedConfig.DaemonPollInterval)
assert.Equal(t, testConfig.BranchPrefix, loadedConfig.BranchPrefix)
assert.Equal(t, testConfig.WorktreeRoot, loadedConfig.WorktreeRoot)
})
}
37 changes: 35 additions & 2 deletions session/git/worktree.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,29 @@ import (
)

func getWorktreeDirectory() (string, error) {
configDir, err := config.GetConfigDir()
if err != nil {
return "", err
}
return filepath.Join(configDir, "worktrees"), nil
}

func getWorktreeDirectoryForRepo(repoPath string) (string, error) {
cfg := config.LoadConfig()
if cfg.WorktreeRoot == config.WorktreeRootSibling {
if repoPath == "" {
return "", fmt.Errorf("repo path is required when worktree_root is %q", config.WorktreeRootSibling)
}

repoRoot, err := findGitRepoRoot(repoPath)
if err != nil {
return "", err
}

repoParent := filepath.Dir(repoRoot)
return repoParent, nil
}

configDir, err := config.GetConfigDir()
if err != nil {
return "", err
Expand All @@ -23,6 +46,8 @@ type GitWorktree struct {
repoPath string
// Path to the worktree
worktreePath string
// Root directory containing all worktrees for this repo/config mode
worktreeDir string
// Name of the session
sessionName string
// Branch name for the worktree
Expand All @@ -35,6 +60,7 @@ func NewGitWorktreeFromStorage(repoPath string, worktreePath string, sessionName
return &GitWorktree{
repoPath: repoPath,
worktreePath: worktreePath,
worktreeDir: filepath.Dir(worktreePath),
sessionName: sessionName,
branchName: branchName,
baseCommitSHA: baseCommitSHA,
Expand Down Expand Up @@ -62,20 +88,27 @@ func NewGitWorktree(repoPath string, sessionName string) (tree *GitWorktree, bra
return nil, "", err
}

worktreeDir, err := getWorktreeDirectory()
worktreeDir, err := getWorktreeDirectoryForRepo(repoPath)
if err != nil {
return nil, "", err
}

// Use sanitized branch name for the worktree directory name
worktreePath := filepath.Join(worktreeDir, branchName)
var worktreePath string
if cfg.WorktreeRoot == config.WorktreeRootSibling {
repoName := filepath.Base(repoPath)
worktreePath = filepath.Join(worktreeDir, repoName+"-"+sessionName)
} else {
worktreePath = filepath.Join(worktreeDir, branchName)
}
worktreePath = worktreePath + "_" + fmt.Sprintf("%x", time.Now().UnixNano())

return &GitWorktree{
repoPath: repoPath,
sessionName: sessionName,
branchName: branchName,
worktreePath: worktreePath,
worktreeDir: worktreeDir,
}, branchName, nil
}

Expand Down
81 changes: 42 additions & 39 deletions session/git/worktree_ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,22 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)

// Setup creates a new worktree for the session
func (g *GitWorktree) Setup() error {
// Ensure worktrees directory exists early (can be done in parallel with branch check)
worktreesDir, err := getWorktreeDirectory()
if err != nil {
return fmt.Errorf("failed to get worktree directory: %w", err)
if g.worktreeDir == "" {
return fmt.Errorf("failed to get worktree directory: empty worktree directory")
}

if err := os.MkdirAll(worktreesDir, 0755); err != nil {
if err := os.MkdirAll(g.worktreeDir, 0755); err != nil {
return err
}

// Check if branch exists using git CLI (much faster than go-git PlainOpen)
_, err = g.runGitCommand(g.repoPath, "show-ref", "--verify", fmt.Sprintf("refs/heads/%s", g.branchName))
_, err := g.runGitCommand(g.repoPath, "show-ref", "--verify", fmt.Sprintf("refs/heads/%s", g.branchName))
branchExists := err == nil

if branchExists {
Expand Down Expand Up @@ -132,66 +130,71 @@ func (g *GitWorktree) Prune() error {

// CleanupWorktrees removes all worktrees and their associated branches
func CleanupWorktrees() error {
worktreesDir, err := getWorktreeDirectory()
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get worktree directory: %w", err)
return fmt.Errorf("failed to get current directory: %w", err)
}

entries, err := os.ReadDir(worktreesDir)
repoRoot, err := findGitRepoRoot(cwd)
if err != nil {
return fmt.Errorf("failed to read worktree directory: %w", err)
return fmt.Errorf("failed to find git repo root: %w", err)
}

// Get a list of all branches associated with worktrees
cmd := exec.Command("git", "worktree", "list", "--porcelain")
// List all worktrees from the repo
cmd := exec.Command("git", "-C", repoRoot, "worktree", "list", "--porcelain")
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to list worktrees: %w", err)
}

// Parse the output to extract branch names
worktreeBranches := make(map[string]string)
currentWorktree := ""
// Parse output to get (worktreePath, branchName) pairs.
// Each block is separated by a blank line. A worktree may have no branch (detached HEAD).
type worktreeInfo struct {
path string
branch string // empty if detached HEAD
}
var worktrees []worktreeInfo
currentPath := ""
currentBranch := ""
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "worktree ") {
currentWorktree = strings.TrimPrefix(line, "worktree ")
currentPath = strings.TrimPrefix(line, "worktree ")
} else if strings.HasPrefix(line, "branch ") {
branchPath := strings.TrimPrefix(line, "branch ")
// Extract branch name from refs/heads/branch-name
branchName := strings.TrimPrefix(branchPath, "refs/heads/")
if currentWorktree != "" {
worktreeBranches[currentWorktree] = branchName
currentBranch = strings.TrimPrefix(branchPath, "refs/heads/")
} else if line == "" {
if currentPath != "" {
worktrees = append(worktrees, worktreeInfo{path: currentPath, branch: currentBranch})
}
currentPath = ""
currentBranch = ""
}
}
// Handle last entry if output doesn't end with a blank line
if currentPath != "" {
worktrees = append(worktrees, worktreeInfo{path: currentPath, branch: currentBranch})
}

for _, entry := range entries {
if entry.IsDir() {
worktreePath := filepath.Join(worktreesDir, entry.Name())

// Delete the branch associated with this worktree if found
for path, branch := range worktreeBranches {
if strings.Contains(path, entry.Name()) {
// Delete the branch
deleteCmd := exec.Command("git", "branch", "-D", branch)
if err := deleteCmd.Run(); err != nil {
// Log the error but continue with other worktrees
log.ErrorLog.Printf("failed to delete branch %s: %v", branch, err)
}
break
// Skip the first entry (the main worktree / repo itself)
if len(worktrees) > 1 {
for _, wt := range worktrees[1:] {
// Delete the branch if one exists (worktree may have detached HEAD)
if wt.branch != "" {
deleteCmd := exec.Command("git", "-C", repoRoot, "branch", "-D", wt.branch)
if err := deleteCmd.Run(); err != nil {
log.ErrorLog.Printf("failed to delete branch %s: %v", wt.branch, err)
}
}

// Remove the worktree directory
os.RemoveAll(worktreePath)
os.RemoveAll(wt.path)
}
}

// You have to prune the cleaned up worktrees.
cmd = exec.Command("git", "worktree", "prune")
_, err = cmd.Output()
if err != nil {
// Prune worktree references
pruneCmd := exec.Command("git", "-C", repoRoot, "worktree", "prune")
if _, err := pruneCmd.Output(); err != nil {
return fmt.Errorf("failed to prune worktrees: %w", err)
}

Expand Down
75 changes: 75 additions & 0 deletions session/git/worktree_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package git

import (
"claude-squad/config"
"claude-squad/log"
"os"
"os/exec"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestMain(m *testing.M) {
log.Initialize(false)
defer log.Close()
os.Exit(m.Run())
}

func TestGetWorktreeDirectoryForRepo_Subdirectory(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)

cfg := config.DefaultConfig()
cfg.WorktreeRoot = config.WorktreeRootSubdirectory
require.NoError(t, config.SaveConfig(cfg))

worktreeDir, err := getWorktreeDirectoryForRepo(t.TempDir())
require.NoError(t, err)

configDir, err := config.GetConfigDir()
require.NoError(t, err)
assert.Equal(t, filepath.Join(configDir, "worktrees"), worktreeDir)
}

func TestGetWorktreeDirectoryForRepo_Sibling(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)

repoRoot := createGitRepo(t)

cfg := config.DefaultConfig()
cfg.WorktreeRoot = config.WorktreeRootSibling
require.NoError(t, config.SaveConfig(cfg))

worktreeDir, err := getWorktreeDirectoryForRepo(repoRoot)
require.NoError(t, err)
assert.Equal(t, filepath.Dir(repoRoot), worktreeDir)
}

func TestGetWorktreeDirectoryForRepo_SiblingRequiresRepoPath(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)

cfg := config.DefaultConfig()
cfg.WorktreeRoot = config.WorktreeRootSibling
require.NoError(t, config.SaveConfig(cfg))

_, err := getWorktreeDirectoryForRepo("")
require.Error(t, err)
}

func createGitRepo(t *testing.T) string {
t.Helper()
repoRoot := filepath.Join(t.TempDir(), "repo")
require.NoError(t, os.MkdirAll(repoRoot, 0755))

cmd := exec.Command("git", "init")
cmd.Dir = repoRoot
out, err := cmd.CombinedOutput()
require.NoError(t, err, string(out))

return repoRoot
}
Loading