Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,23 @@ default_location = "subdirectory" # "sibling" (default), "subdirectory", or a c

`sibling` creates worktrees next to the repo (`repo-branch`). `subdirectory` creates them inside it (`repo/.worktrees/branch`). A custom path like `~/worktrees` or `/tmp/worktrees` creates repo-namespaced worktrees at `<path>/<repo_name>/<branch>`. The `--location` flag overrides the config per session.

#### Worktree Setup Script

Gitignored files (`.env`, `.mcp.json`, etc.) aren't copied into new worktrees. To automate this, create a setup script at `.agent-deck/worktree-setup.sh` in your repo. Agent-deck runs it automatically after creating a worktree.

```sh
#!/bin/sh
for f in .env .env.local .mcp.json; do
[ -f "$AGENT_DECK_REPO_ROOT/$f" ] && cp "$AGENT_DECK_REPO_ROOT/$f" "$AGENT_DECK_WORKTREE_PATH/$f"
done
```

The script receives two environment variables:
- `AGENT_DECK_REPO_ROOT` — path to the main repository
- `AGENT_DECK_WORKTREE_PATH` — path to the new worktree

The script runs via `sh -e` with a 60-second timeout. If it fails, the worktree is still created — you'll see a warning but the session proceeds normally.

### Docker Sandbox

Run sessions inside isolated Docker containers. The project directory is bind-mounted read-write, so agents work on your code while the rest of the system stays protected.
Expand Down
18 changes: 8 additions & 10 deletions cmd/agent-deck/launch_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ func handleLaunch(profile string, args []string) {
// Worktree flags
worktreeBranch := fs.String("w", "", "Create session in git worktree for branch")
worktreeBranchLong := fs.String("worktree", "", "Create session in git worktree for branch")
newBranch := fs.Bool("b", false, "Create new branch (use with --worktree)")
newBranchLong := fs.Bool("new-branch", false, "Create new branch")
newBranch := fs.Bool("b", false, "Create new branch if needed (reuse existing branch when present)")
newBranchLong := fs.Bool("new-branch", false, "Create new branch if needed (reuse existing branch when present)")
worktreeLocation := fs.String("location", "", "Worktree location: sibling, subdirectory, or custom path")

// MCP flag
Expand Down Expand Up @@ -129,7 +129,7 @@ func handleLaunch(profile string, args []string) {
if *worktreeBranchLong != "" {
wtBranch = *worktreeBranchLong
}
createNewBranch := *newBranch || *newBranchLong
_ = *newBranch || *newBranchLong

// Validate --resume-session requires Claude
if *resumeSession != "" {
Expand Down Expand Up @@ -159,12 +159,6 @@ func handleLaunch(profile string, args []string) {
os.Exit(1)
}

branchExists := git.BranchExists(repoRoot, wtBranch)
if createNewBranch && branchExists {
out.Error(fmt.Sprintf("branch '%s' already exists (remove -b flag to use existing branch)", wtBranch), ErrCodeInvalidOperation)
os.Exit(1)
}

wtSettings := session.GetWorktreeSettings()
location := wtSettings.DefaultLocation
if *worktreeLocation != "" {
Expand Down Expand Up @@ -194,10 +188,14 @@ func handleLaunch(profile string, args []string) {
os.Exit(1)
}

if err := git.CreateWorktree(repoRoot, worktreePath, wtBranch); err != nil {
setupErr, err := git.CreateWorktreeWithSetup(repoRoot, worktreePath, wtBranch, os.Stdout, os.Stderr)
if err != nil {
out.Error(fmt.Sprintf("failed to create worktree: %v", err), ErrCodeInvalidOperation)
os.Exit(1)
}
if setupErr != nil {
fmt.Fprintf(os.Stderr, "Warning: worktree setup script failed: %v\n", setupErr)
}
}

worktreeRepoRoot = repoRoot
Expand Down
23 changes: 8 additions & 15 deletions cmd/agent-deck/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -815,8 +815,8 @@ func handleAdd(profile string, args []string) {
// Worktree flags
worktreeBranch := fs.String("w", "", "Create session in git worktree for branch")
worktreeBranchLong := fs.String("worktree", "", "Create session in git worktree for branch")
newBranch := fs.Bool("b", false, "Create new branch (use with --worktree)")
newBranchLong := fs.Bool("new-branch", false, "Create new branch")
newBranch := fs.Bool("b", false, "Create new branch if needed (reuse existing branch when present)")
newBranchLong := fs.Bool("new-branch", false, "Create new branch if needed (reuse existing branch when present)")
worktreeLocation := fs.String("location", "", "Worktree location: sibling, subdirectory, or custom path")

// MCP flag - can be specified multiple times
Expand Down Expand Up @@ -895,7 +895,7 @@ func handleAdd(profile string, args []string) {
if *worktreeBranchLong != "" {
wtBranch = *worktreeBranchLong
}
createNewBranch := *newBranch || *newBranchLong
_ = *newBranch || *newBranchLong

// Merge short and long flags
sessionTitle := mergeFlags(*title, *titleShort)
Expand Down Expand Up @@ -1035,17 +1035,6 @@ func handleAdd(profile string, args []string) {
os.Exit(1)
}

// Check -b flag logic: if -b is passed, branch must NOT exist (user wants new branch)
branchExists := git.BranchExists(repoRoot, wtBranch)
if createNewBranch && branchExists {
fmt.Fprintf(
os.Stderr,
"Error: branch '%s' already exists (remove -b flag to use existing branch)\n",
wtBranch,
)
os.Exit(1)
}

// Determine worktree location: CLI flag overrides config
wtSettings := session.GetWorktreeSettings()
location := wtSettings.DefaultLocation
Expand Down Expand Up @@ -1075,7 +1064,8 @@ func handleAdd(profile string, args []string) {

// Create worktree atomically (git handles existence checks).
// This avoids a TOCTOU race from separate check-then-create steps.
if err := git.CreateWorktree(repoRoot, worktreePath, wtBranch); err != nil {
setupErr, err := git.CreateWorktreeWithSetup(repoRoot, worktreePath, wtBranch, os.Stdout, os.Stderr)
if err != nil {
if isWorktreeAlreadyExistsError(err) {
fmt.Fprintf(os.Stderr, "Error: worktree already exists at %s\n", worktreePath)
fmt.Fprintf(os.Stderr, "Tip: Use 'agent-deck add %s' to add the existing worktree\n", worktreePath)
Expand All @@ -1084,6 +1074,9 @@ func handleAdd(profile string, args []string) {
fmt.Fprintf(os.Stderr, "Error: failed to create worktree: %v\n", err)
os.Exit(1)
}
if setupErr != nil {
fmt.Fprintf(os.Stderr, "Warning: worktree setup script failed: %v\n", setupErr)
}

fmt.Printf("Created worktree at: %s\n", worktreePath)
}
Expand Down
16 changes: 10 additions & 6 deletions cmd/agent-deck/session_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,8 +383,8 @@ func handleSessionFork(profile string, args []string) {
groupShort := fs.String("g", "", "Group for forked session (short)")
worktreeBranch := fs.String("w", "", "Create fork in git worktree for branch")
worktreeBranchLong := fs.String("worktree", "", "Create fork in git worktree for branch")
newBranch := fs.Bool("b", false, "Create new branch (use with --worktree)")
newBranchLong := fs.Bool("new-branch", false, "Create new branch")
newBranch := fs.Bool("b", false, "Create new branch if needed (reuse existing branch when present)")
newBranchLong := fs.Bool("new-branch", false, "Create new branch if needed (reuse existing branch when present)")
sandbox := fs.Bool("sandbox", false, "Run forked session in Docker sandbox")
sandboxImage := fs.String("sandbox-image", "", "Docker image for sandbox (overrides config default)")

Expand Down Expand Up @@ -472,7 +472,7 @@ func handleSessionFork(profile string, args []string) {
if *worktreeBranchLong != "" {
wtBranch = *worktreeBranchLong
}
createNewBranch := *newBranch || *newBranchLong
_ = *newBranch || *newBranchLong

// Handle worktree creation
var opts *session.ClaudeOptions
Expand All @@ -487,8 +487,8 @@ func handleSessionFork(profile string, args []string) {
os.Exit(1)
}

if !createNewBranch && !git.BranchExists(repoRoot, wtBranch) {
out.Error(fmt.Sprintf("branch '%s' does not exist (use -b to create)", wtBranch), ErrCodeInvalidOperation)
if err := git.ValidateBranchName(wtBranch); err != nil {
out.Error(fmt.Sprintf("invalid branch name: %v", err), ErrCodeInvalidOperation)
os.Exit(1)
}

Expand Down Expand Up @@ -516,10 +516,14 @@ func handleSessionFork(profile string, args []string) {
os.Exit(1)
}

if err := git.CreateWorktree(repoRoot, worktreePath, wtBranch); err != nil {
setupErr, err := git.CreateWorktreeWithSetup(repoRoot, worktreePath, wtBranch, os.Stdout, os.Stderr)
if err != nil {
out.Error(fmt.Sprintf("worktree creation failed: %v", err), ErrCodeInvalidOperation)
os.Exit(1)
}
if setupErr != nil {
fmt.Fprintf(os.Stderr, "Warning: worktree setup script failed: %v\n", setupErr)
}
}

userConfig, _ := session.LoadUserConfig()
Expand Down
184 changes: 179 additions & 5 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
)

Expand Down Expand Up @@ -56,6 +57,26 @@ func BranchExists(repoDir, branchName string) bool {
return err == nil
}

func remoteBranchExists(repoDir, remoteName, branchName string) bool {
cmd := exec.Command("git", "-C", repoDir, "show-ref", "--verify", "--quiet", "refs/remotes/"+remoteName+"/"+branchName)
err := cmd.Run()
return err == nil
}

type worktreeBranchMode int

const (
worktreeBranchNew worktreeBranchMode = iota
worktreeBranchLocal
worktreeBranchRemote
)

type worktreeBranchResolution struct {
Branch string
Mode worktreeBranchMode
Remote string
}

// ValidateBranchName validates that a branch name follows git's naming rules
func ValidateBranchName(name string) error {
if name == "" {
Expand Down Expand Up @@ -151,13 +172,22 @@ func CreateWorktree(repoDir, worktreePath, branchName string) error {
return errors.New("not a git repository")
}

var cmd *exec.Cmd
resolution, err := resolveWorktreeBranch(repoDir, branchName)
if err != nil {
return err
}

if BranchExists(repoDir, branchName) {
// Use existing branch
var cmd *exec.Cmd
switch resolution.Mode {
case worktreeBranchLocal:
// Reuse an existing local branch.
cmd = exec.Command("git", "-C", repoDir, "worktree", "add", worktreePath, branchName)
} else {
// Create new branch with -b flag
case worktreeBranchRemote:
// Create a local tracking branch from the default remote.
remoteRef := resolution.Remote + "/" + branchName
cmd = exec.Command("git", "-C", repoDir, "worktree", "add", "--track", "-b", branchName, worktreePath, remoteRef)
default:
// Create a new local branch.
cmd = exec.Command("git", "-C", repoDir, "worktree", "add", "-b", branchName, worktreePath)
}

Expand Down Expand Up @@ -360,6 +390,150 @@ func SanitizeBranchName(name string) string {
return sanitized
}

func resolveWorktreeBranch(repoDir, branchName string) (worktreeBranchResolution, error) {
if !IsGitRepo(repoDir) {
return worktreeBranchResolution{}, errors.New("not a git repository")
}

resolution := worktreeBranchResolution{
Branch: branchName,
Mode: worktreeBranchNew,
}

if BranchExists(repoDir, branchName) {
resolution.Mode = worktreeBranchLocal
return resolution, nil
}

defaultRemote, err := getDefaultRemote(repoDir)
if err == nil && defaultRemote != "" && remoteBranchExists(repoDir, defaultRemote, branchName) {
resolution.Mode = worktreeBranchRemote
resolution.Remote = defaultRemote
}

return resolution, nil
}

func getDefaultRemote(repoDir string) (string, error) {
remotes, err := listRemotes(repoDir)
if err != nil {
return "", err
}
if len(remotes) == 0 {
return "", errors.New("no git remotes configured")
}

currentBranch, err := GetCurrentBranch(repoDir)
if err == nil && currentBranch != "" && currentBranch != "HEAD" {
cmd := exec.Command("git", "-C", repoDir, "config", "--get", "branch."+currentBranch+".remote")
output, err := cmd.Output()
if err == nil {
remote := strings.TrimSpace(string(output))
if remote != "" {
return remote, nil
}
}
}

for _, remote := range remotes {
if remote == "origin" {
return remote, nil
}
}

if len(remotes) == 1 {
return remotes[0], nil
}

return "", fmt.Errorf("could not determine default remote from %d remotes", len(remotes))
}

func listRemotes(repoDir string) ([]string, error) {
cmd := exec.Command("git", "-C", repoDir, "remote")
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to list remotes: %w", err)
}

lines := strings.Split(strings.TrimSpace(string(output)), "\n")
var remotes []string
for _, line := range lines {
remote := strings.TrimSpace(line)
if remote != "" {
remotes = append(remotes, remote)
}
}
return remotes, nil
}

func listRefShortNames(repoDir string, refs ...string) ([]string, error) {
args := []string{"-C", repoDir, "for-each-ref", "--format=%(refname:short)"}
args = append(args, refs...)
cmd := exec.Command("git", args...)
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to list refs: %w", err)
}

lines := strings.Split(strings.TrimSpace(string(output)), "\n")
var names []string
for _, line := range lines {
name := strings.TrimSpace(line)
if name != "" {
names = append(names, name)
}
}
return names, nil
}

// ListBranchCandidates returns unique branch names from local branches and the
// default remote, normalized to plain branch names without a remote prefix.
func ListBranchCandidates(repoDir string) ([]string, error) {
if !IsGitRepo(repoDir) {
return nil, errors.New("not a git repository")
}

repoRoot, err := GetWorktreeBaseRoot(repoDir)
if err == nil && repoRoot != "" {
repoDir = repoRoot
}

branches, err := listRefShortNames(repoDir, "refs/heads")
if err != nil {
return nil, err
}

seen := make(map[string]struct{}, len(branches))
for _, branch := range branches {
seen[branch] = struct{}{}
}

if defaultRemote, err := getDefaultRemote(repoDir); err == nil && defaultRemote != "" {
remoteBranches, err := listRefShortNames(repoDir, "refs/remotes/"+defaultRemote)
if err != nil {
return nil, err
}
prefix := defaultRemote + "/"
for _, branch := range remoteBranches {
if branch == defaultRemote+"/HEAD" {
continue
}
branch = strings.TrimPrefix(branch, prefix)
if branch == "" {
continue
}
seen[branch] = struct{}{}
}
}

branches = branches[:0]
for branch := range seen {
branches = append(branches, branch)
}
sort.Strings(branches)
return branches, nil
}

// HasUncommittedChanges checks if the repository at dir has uncommitted changes
func HasUncommittedChanges(dir string) (bool, error) {
cmd := exec.Command("git", "-C", dir, "status", "--porcelain")
Expand Down
Loading
Loading