Skip to content
Merged
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,5 +92,6 @@ All commands run locally — they execute git/tmux/filesystem directly on whatev
- **`syscall.Exec` for interactive commands**: `attach session`, `create worktree --attach`, and related commands replace the process rather than spawning a child
- **Local execution**: all session/project/worktree commands run git/tmux via `os/exec` on the local machine — no SSH indirection
- **Integration tests**: tagged with `//go:build integration` and run separately via `make test-integration`
- **Pin the git default branch in test repos**: helpers that `git init` a repo for worktree/session tests must pass `-b main` (e.g. `git init -b main <dir>`). Plain `git init` takes the branch name from the host's `init.defaultBranch`, which is unset on CI runners and defaults to `master`. Code that references `main` as a start point (e.g. `worktree.New`'s fallback) then fails with `fatal: invalid reference: main` on CI while passing on a developer machine configured with `init.defaultBranch=main`.
- **Tmux isolation in tests**: tests that touch a real tmux server must isolate it. Set `CODEHERD_TMUX_SOCKET` to a path under `t.TempDir()` (the `internal/tmux` `RealRunner` reads this env var and prepends `-S <path>` to every tmux call), also clear `$TMUX` so tmux does not think it is nested, and probe with a throwaway `tmux -S <socket> new-session` — `t.Skip` if it fails so sandboxed CI environments do not flake. Cleanup must `tmux -S <socket> kill-server`. The `cmd_test` package exposes `useIsolatedTmux(t)` + `tmuxCmd(t, args...)` helpers; tests in `package cmd` inline the same setup. Never call bare `exec.Command("tmux", …)` from a test — it will leak into the developer's outer tmux server and collide with parallel test runs.

25 changes: 25 additions & 0 deletions cmd/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,31 @@ func branchNames(entries []worktree.ListEntry) []string {
return names
}

// completionRemoteBrancher lists a project's remote-tracking branches during
// completion (no fetch). Declared as a var so tests can stub it.
var completionRemoteBrancher = func(cfg *config.Config, project string) ([]worktree.RemoteBranch, error) {
svc := worktree.NewService(cfg, worktree.NewRealWorktreeRunner(), tmux.NewClient(tmux.NewRealRunner()), &hooks.NoOp{})
return svc.ListRemoteBranches(project)
}

// completeRemoteBranches completes the --track flag against the remote-tracking
// branches of the project named in args[0].
func completeRemoteBranches(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
branches, err := completionRemoteBrancher(loadCompletionConfig(cmd), args[0])
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
refs := make([]string, 0, len(branches))
for _, b := range branches {
refs = append(refs, b.Ref)
}
sort.Strings(refs)
return refs, cobra.ShellCompDirectiveNoFileComp
}

// completeProjectThenBranch completes the <project> positional (arg 0),
// then the <branch> positional (arg 1) against that project's worktrees.
// Used by commands operating on an existing branch.
Expand Down
29 changes: 29 additions & 0 deletions cmd/completion_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,35 @@ func withStubConfig(t *testing.T, c *config.Config) {
t.Cleanup(func() { loadCompletionConfig = orig })
}

func TestCompleteRemoteBranches(t *testing.T) {
orig := completionRemoteBrancher
t.Cleanup(func() { completionRemoteBrancher = orig })
completionRemoteBrancher = func(_ *config.Config, project string) ([]worktree.RemoteBranch, error) {
return []worktree.RemoteBranch{
{Remote: "origin", Branch: "feat-x", Ref: "origin/feat-x"},
{Remote: "upstream", Branch: "fix-y", Ref: "upstream/fix-y"},
}, nil
}

cmd := (&CreateWorktreeCmd{}).Cobra()
got, directive := completeRemoteBranches(cmd, []string{"myapp"}, "")
if directive != cobra.ShellCompDirectiveNoFileComp {
t.Errorf("directive = %v", directive)
}
want := []string{"origin/feat-x", "upstream/fix-y"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Errorf("completions = %v, want %v", got, want)
}
}

func TestCompleteRemoteBranches_noProject(t *testing.T) {
cmd := (&CreateWorktreeCmd{}).Cobra()
got, _ := completeRemoteBranches(cmd, nil, "")
if got != nil {
t.Errorf("expected nil completions with no project, got %v", got)
}
}

func TestCompleteAgents(t *testing.T) {
withStubConfig(t, &config.Config{Agents: map[string]config.AgentConfig{
"claude": {Cmd: "claude"},
Expand Down
2 changes: 1 addition & 1 deletion cmd/session_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func initBareRepo(t *testing.T, cloneDir string) {
t.Helper()
os.MkdirAll(cloneDir, 0o755)
cmds := [][]string{
{"git", "init", cloneDir},
{"git", "init", "-b", "main", cloneDir},
{"git", "-C", cloneDir, "config", "user.email", "test@test.com"},
{"git", "-C", cloneDir, "config", "user.name", "Test"},
{"git", "-C", cloneDir, "commit", "--allow-empty", "-m", "init"},
Expand Down
36 changes: 27 additions & 9 deletions cmd/worktree.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,30 +66,40 @@ func (c *ListWorktreeCmd) Run(cmd *cobra.Command, args []string) error {

type CreateWorktreeCmd struct {
From string
Track string
Attach bool
Agent string
}

func (c *CreateWorktreeCmd) Cobra() *cobra.Command {
cmd := &cobra.Command{
Use: "worktree <project> <branch>",
Use: "worktree <project> [branch]",
Aliases: []string{"worktrees", "wt"},
Short: "Create a new worktree for a project",
Args: cobra.ExactArgs(2),
Args: cobra.RangeArgs(1, 2),
RunE: c.Run,
ValidArgsFunction: completeProjectOnly,
}
cmd.Flags().StringVar(&c.From, "from", "", "base branch to create worktree from")
cmd.Flags().StringVar(&c.Track, "track", "", "remote branch to fetch and check out (e.g. feat-x or upstream/feat-x)")
cmd.Flags().BoolVar(&c.Attach, "attach", false, "start a coding session after creation")
cmd.Flags().StringVar(&c.Agent, "agent", "", "agent to use for the session (with --attach)")
cmd.MarkFlagsMutuallyExclusive("from", "track")
_ = cmd.RegisterFlagCompletionFunc("from", completeBranches)
_ = cmd.RegisterFlagCompletionFunc("track", completeRemoteBranches)
_ = cmd.RegisterFlagCompletionFunc("agent", completeAgents)
return cmd
}

func (c *CreateWorktreeCmd) Run(cmd *cobra.Command, args []string) error {
project, branch := args[0], args[1]
fmt.Fprintf(cmd.OutOrStdout(), "Creating worktree %s/%s... ", project, branch)
project := args[0]
posBranch := ""
if len(args) > 1 {
posBranch = args[1]
}
if c.Track == "" && posBranch == "" {
return fmt.Errorf("a <branch> argument is required unless --track is given")
}

projCfg := cfg.Projects[project]
h := hooks.New(projCfg.Hooks)
Expand All @@ -102,16 +112,24 @@ func (c *CreateWorktreeCmd) Run(cmd *cobra.Command, args []string) error {
svc := worktree.NewService(cfg, worktree.NewRealWorktreeRunner(), tmux.NewClient(tmux.NewRealRunner()), h)
var result worktree.NewResult
var err error
if c.From != "" {
result, err = svc.NewFrom(project, branch, c.From)
} else {
result, err = svc.New(project, branch)
switch {
case c.Track != "":
fmt.Fprintf(cmd.OutOrStdout(), "Checking out %s into a new worktree... ", c.Track)
result, err = svc.NewTracking(project, posBranch, c.Track)
case c.From != "":
fmt.Fprintf(cmd.OutOrStdout(), "Creating worktree %s/%s... ", project, posBranch)
result, err = svc.NewFrom(project, posBranch, c.From)
default:
fmt.Fprintf(cmd.OutOrStdout(), "Creating worktree %s/%s... ", project, posBranch)
result, err = svc.New(project, posBranch)
}
if err != nil {
fmt.Fprintln(cmd.OutOrStdout())
return worktreeErr(cmd, project, branch, err)
return worktreeErr(cmd, project, posBranch, err)
}

branch := result.Branch

// File copy
if len(projCfg.Files) > 0 {
copySvc := filecopy.New(h)
Expand Down
36 changes: 36 additions & 0 deletions cmd/worktree_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package cmd

import (
"strings"
"testing"
)

func TestCreateWorktree_trackFlagRegistered(t *testing.T) {
c := (&CreateWorktreeCmd{}).Cobra()
if c.Flags().Lookup("track") == nil {
t.Fatal("expected --track flag to be registered")
}
}

func TestCreateWorktree_trackAndFromMutuallyExclusive(t *testing.T) {
c := (&CreateWorktreeCmd{}).Cobra()
c.SetArgs([]string{"myapp", "--track", "feat-x", "--from", "main"})
c.SilenceUsage = true
c.SilenceErrors = true
err := c.Execute()
// Cobra's mutual-exclusion error names both flags in the group.
if err == nil || !strings.Contains(err.Error(), "from") || !strings.Contains(err.Error(), "track") {
t.Fatalf("expected mutual-exclusion error, got %v", err)
}
}

func TestCreateWorktree_branchRequiredWithoutTrack(t *testing.T) {
c := (&CreateWorktreeCmd{}).Cobra()
c.SetArgs([]string{"myapp"})
c.SilenceUsage = true
c.SilenceErrors = true
err := c.Execute()
if err == nil {
t.Fatal("expected error when no branch and no --track given")
}
}
Loading
Loading