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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ project/
```yaml
version: 1
git_dir: .bare
main_branch: main
editor: cursor
setup:
- "cp .env.example .env"
Expand All @@ -256,6 +257,7 @@ parallel_teardown:
|-------|-------------|---------|
| `version` | Config version | `1` |
| `git_dir` | Path to bare repository | `.bare` |
| `main_branch` | Primary branch (protected from removal, used as base for new branches) | `main` |
| `editor` | Preferred editor binary name | (auto-detect) |
| `setup` | Commands to run sequentially after creating a worktree | `[]` |
| `parallel_setup` | Commands to run concurrently after serial setup hooks | `[]` |
Expand Down
3 changes: 2 additions & 1 deletion cmd/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ func runAdd(cmd *cobra.Command, args []string) error {
return err
}
} else {
if err := runner.WorktreeAddNew(ctx, worktreePath, branch); err != nil {
startPoint := runner.ResolveStartPoint(ctx, cfg.MainBranchOrDefault())
if err := runner.WorktreeAddNew(ctx, worktreePath, branch, startPoint); err != nil {
return err
}
}
Expand Down
2 changes: 2 additions & 0 deletions cmd/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ After wt init (existing repo):
version: 1
git_dir: .bare # .bare for clone, .git for init
worktree_dir: worktrees
main_branch: main # auto-detected at clone/init
editor: cursor
setup:
- "npm install"
Expand All @@ -173,6 +174,7 @@ Fields:
- version: Config version (always 1)
- git_dir: Path to git directory (.bare for cloned, .git for initialized)
- worktree_dir: Directory for worktrees (default: worktrees)
- main_branch: Primary branch, protected from removal and used as base for new branches (default: main)
- editor: Preferred editor binary name (default: auto-detect)
- setup: Commands run sequentially after creating a worktree
- parallel_setup: Commands run concurrently after setup completes
Expand Down
3 changes: 2 additions & 1 deletion cmd/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ func runClaudeHookWorktreeCreate(cmd *cobra.Command, _ []string) error {
return fmt.Errorf("worktree add failed: %w", err)
}
} else {
if err := runner.WorktreeAddNew(ctx, worktreePath, branch); err != nil {
startPoint := runner.ResolveStartPoint(ctx, cfg.MainBranchOrDefault())
if err := runner.WorktreeAddNew(ctx, worktreePath, branch, startPoint); err != nil {
return fmt.Errorf("worktree add (new branch) failed: %w", err)
}
}
Expand Down
11 changes: 10 additions & 1 deletion cmd/clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,17 @@ func runClone(cmd *cobra.Command, args []string) error {
return err
}

// Detect the remote's default branch
ui.Step("Detecting default branch")
detectedBranch, err := runner.GetDefaultBranch(ctx)
if err != nil {
ui.Warning("Could not detect default branch, defaulting to 'main'")
detectedBranch = config.DefaultMainBranch
}

// Create scaffold directories
cfg := config.DefaultConfig()
cfg.MainBranch = detectedBranch
ui.Step("Creating project scaffold")
if err := project.CreateScaffold(projectRoot, &cfg, dry); err != nil {
return err
Expand All @@ -90,7 +99,7 @@ func runClone(cmd *cobra.Command, args []string) error {
if dry {
ui.DryRunNotice("write " + filepath.Join(projectRoot, config.ConfigFileName))
} else {
if err := config.WriteAnnotated(projectRoot); err != nil {
if err := config.WriteAnnotatedWithValues(projectRoot, &cfg); err != nil {
return err
}
}
Expand Down
12 changes: 11 additions & 1 deletion cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"

"github.com/bkildow/wt-cli/internal/config"
"github.com/bkildow/wt-cli/internal/git"
"github.com/bkildow/wt-cli/internal/project"
"github.com/bkildow/wt-cli/internal/ui"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -49,14 +50,23 @@ func runInit(cmd *cobra.Command, args []string) error {
cfg.WorktreeDir = ".worktrees"
cfg.SharedDir = ".worktrees/shared"

// Detect the repository's default branch
gitDir := filepath.Join(projectRoot, cfg.GitDir)
initRunner := git.NewRunner(gitDir, dry)
detectedBranch, err := initRunner.GetDefaultBranch(cmd.Context())
if err != nil {
ui.Warning("Could not detect default branch, defaulting to 'main'")
detectedBranch = config.DefaultMainBranch
}
cfg.MainBranch = detectedBranch

// Create scaffold directories
ui.Step("Creating project scaffold")
if err := project.CreateScaffold(projectRoot, &cfg, dry); err != nil {
return err
}

// Configure local git excludes for wt-managed files
gitDir := filepath.Join(projectRoot, cfg.GitDir)
ui.Step("Configuring local git excludes")
if err := project.EnsureGitExclude(gitDir, dry); err != nil {
return err
Expand Down
5 changes: 1 addition & 4 deletions cmd/prune.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,7 @@ func runPrune(cmd *cobra.Command, args []string) error {
cwd, _ := os.Getwd()
runner := git.NewRunner(project.GitDirPath(projectRoot, cfg), IsDryRun())

defaultBranch, err := runner.GetDefaultBranch(ctx)
if err != nil {
return err
}
defaultBranch := cfg.MainBranchOrDefault()

worktrees, err := runner.WorktreeList(ctx)
if err != nil {
Expand Down
5 changes: 5 additions & 0 deletions cmd/remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ func runRemove(cmd *cobra.Command, args []string) error {
}
}

// Protect the main branch from accidental removal.
if selected.Branch == cfg.MainBranchOrDefault() {
return fmt.Errorf("cannot remove the main branch worktree (%s)", selected.Branch)
}

force, _ := cmd.Flags().GetBool("force")

if !force {
Expand Down
17 changes: 17 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const (
DefaultGitDir = ".bare"
DefaultWorktreeDir = "worktrees"
DefaultSharedDir = "shared"
DefaultMainBranch = "main"
)

var (
Expand All @@ -28,6 +29,7 @@ type Config struct {
GitDir string `yaml:"git_dir"`
WorktreeDir string `yaml:"worktree_dir"`
SharedDir string `yaml:"shared_dir"`
MainBranch string `yaml:"main_branch,omitempty"`
Setup []string `yaml:"setup,omitempty"`
ParallelSetup []string `yaml:"parallel_setup,omitempty"`
Teardown []string `yaml:"teardown,omitempty"`
Expand All @@ -36,6 +38,14 @@ type Config struct {
Editor string `yaml:"editor,omitempty"`
}

// MainBranchOrDefault returns the configured main branch, falling back to DefaultMainBranch.
func (c *Config) MainBranchOrDefault() string {
if c.MainBranch != "" {
return c.MainBranch
}
return DefaultMainBranch
}

func DefaultConfig() Config {
return Config{
Version: 1,
Expand Down Expand Up @@ -116,6 +126,13 @@ func renderAnnotatedConfig(cfg *Config) string {
fmt.Fprintf(&b, "shared_dir: %s\n", DefaultSharedDir)
}

b.WriteString("\n# The primary branch of the repository (used as base for new branches, protected from removal)\n")
if cfg != nil && cfg.MainBranch != "" {
fmt.Fprintf(&b, "main_branch: %s\n", cfg.MainBranch)
} else {
b.WriteString("# main_branch: main\n")
}

b.WriteString("\n# Editor for 'wt open' (e.g. cursor, code, zed)\n")
b.WriteString("# Falls back to $EDITOR, then auto-detects\n")
if cfg != nil && cfg.Editor != "" {
Expand Down
91 changes: 91 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,11 @@ func TestWriteAnnotated(t *testing.T) {
t.Error("missing worktree_dir default")
}

// main_branch should be commented out
if !strings.Contains(content, "# main_branch: main") {
t.Error("main_branch should be commented out as example")
}

// Optional fields should be commented out
if !strings.Contains(content, "# editor: cursor") {
t.Error("editor should be commented out as example")
Expand Down Expand Up @@ -233,6 +238,7 @@ func TestWriteAnnotatedWithValues(t *testing.T) {
Version: 1,
GitDir: ".bare",
WorktreeDir: "trees",
MainBranch: "develop",
Setup: []string{"npm install", "cp .env.example .env"},
Teardown: []string{"docker compose down"},
Editor: "cursor",
Expand All @@ -253,6 +259,14 @@ func TestWriteAnnotatedWithValues(t *testing.T) {
t.Error("missing header comment")
}

// main_branch should be uncommented with existing value
if !strings.Contains(content, "main_branch: develop") {
t.Error("missing main_branch value")
}
if strings.Contains(content, "# main_branch:") {
t.Error("main_branch should not be commented out when value exists")
}

// Editor should be uncommented with existing value
if !strings.Contains(content, "editor: cursor") {
t.Error("missing editor value")
Expand Down Expand Up @@ -339,6 +353,83 @@ func TestWriteAnnotatedWithGitDirDotGit(t *testing.T) {
}
}

func TestMainBranchOrDefault(t *testing.T) {
t.Run("empty returns default", func(t *testing.T) {
cfg := Config{}
if got := cfg.MainBranchOrDefault(); got != DefaultMainBranch {
t.Errorf("MainBranchOrDefault() = %q, want %q", got, DefaultMainBranch)
}
})
t.Run("set value is returned", func(t *testing.T) {
cfg := Config{MainBranch: "develop"}
if got := cfg.MainBranchOrDefault(); got != "develop" {
t.Errorf("MainBranchOrDefault() = %q, want %q", got, "develop")
}
})
}

func TestLoadConfigWithMainBranch(t *testing.T) {
dir := t.TempDir()
content := `version: 1
git_dir: .bare
main_branch: develop
`
if err := os.WriteFile(filepath.Join(dir, ConfigFileName), []byte(content), 0o644); err != nil {
t.Fatal(err)
}

cfg, err := Load(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.MainBranch != "develop" {
t.Errorf("main_branch = %q, want %q", cfg.MainBranch, "develop")
}
if cfg.MainBranchOrDefault() != "develop" {
t.Errorf("MainBranchOrDefault() = %q, want %q", cfg.MainBranchOrDefault(), "develop")
}
}

func TestLoadConfigWithoutMainBranch(t *testing.T) {
dir := t.TempDir()
content := `version: 1
git_dir: .bare
`
if err := os.WriteFile(filepath.Join(dir, ConfigFileName), []byte(content), 0o644); err != nil {
t.Fatal(err)
}

cfg, err := Load(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.MainBranch != "" {
t.Errorf("main_branch = %q, want empty (backward compat)", cfg.MainBranch)
}
if cfg.MainBranchOrDefault() != DefaultMainBranch {
t.Errorf("MainBranchOrDefault() = %q, want %q", cfg.MainBranchOrDefault(), DefaultMainBranch)
}
}

func TestSaveAndLoadMainBranchRoundTrip(t *testing.T) {
dir := t.TempDir()
original := &Config{
Version: 1,
GitDir: ".bare",
MainBranch: "develop",
}
if err := original.Save(dir); err != nil {
t.Fatalf("save error: %v", err)
}
loaded, err := Load(dir)
if err != nil {
t.Fatalf("load error: %v", err)
}
if loaded.MainBranch != "develop" {
t.Errorf("main_branch = %q, want %q", loaded.MainBranch, "develop")
}
}

func TestYamlQuote(t *testing.T) {
tests := []struct {
input string
Expand Down
19 changes: 16 additions & 3 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type Git interface {
ListRemoteBranches(ctx context.Context) ([]string, error)
HasRemoteBranch(ctx context.Context, branch string) (bool, error)
WorktreeAdd(ctx context.Context, path, branch string) error
WorktreeAddNew(ctx context.Context, path, branch string) error
WorktreeAddNew(ctx context.Context, path, branch, baseBranch string) error
WorktreeRemove(ctx context.Context, path string, force bool) error
WorktreeList(ctx context.Context) ([]WorktreeInfo, error)
WorktreePrune(ctx context.Context) error
Expand Down Expand Up @@ -155,8 +155,8 @@ func (r *Runner) WorktreeAdd(ctx context.Context, path, branch string) error {
return err
}

func (r *Runner) WorktreeAddNew(ctx context.Context, path, branch string) error {
_, err := r.Run(ctx, "worktree", "add", "--relative-paths", "-b", branch, path, "HEAD")
func (r *Runner) WorktreeAddNew(ctx context.Context, path, branch, baseBranch string) error {
_, err := r.Run(ctx, "worktree", "add", "--relative-paths", "-b", branch, path, baseBranch)
return err
}

Expand Down Expand Up @@ -298,6 +298,19 @@ func (r *Runner) GetDefaultBranch(ctx context.Context) (string, error) {
return "", fmt.Errorf("could not determine default branch")
}

// ResolveStartPoint finds a valid git ref for the given branch name.
// It tries origin/<branch> first (for bare repos), then the local branch,
// and falls back to HEAD if neither exists.
func (r *Runner) ResolveStartPoint(ctx context.Context, branch string) string {
if _, err := r.Run(ctx, "rev-parse", "--verify", "origin/"+branch); err == nil {
return "origin/" + branch
}
if _, err := r.Run(ctx, "rev-parse", "--verify", branch); err == nil {
return branch
}
return "HEAD"
}

func (r *Runner) WorktreePrune(ctx context.Context) error {
_, err := r.Run(ctx, "worktree", "prune")
return err
Expand Down
2 changes: 1 addition & 1 deletion internal/git/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ func TestDryRunMode(t *testing.T) {
}

// WorktreeAddNew
if err := runner.WorktreeAddNew(ctx, "/tmp/wt", "feature-x"); err != nil {
if err := runner.WorktreeAddNew(ctx, "/tmp/wt", "feature-x", "main"); err != nil {
t.Errorf("dry-run WorktreeAddNew returned error: %v", err)
}

Expand Down