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
13 changes: 12 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,18 @@ Run these from the `main/` worktree directory:
```bash
just setup # First-time setup: install dev tools + git hooks
just test # Run all tests
just test-unit # Run unit tests only
just test-integration # Run integration tests (builds first)
just test-integration-parallel # Run integration tests in parallel
just test-match <pattern> # Run specific test by pattern
just test-pkg <pkg> # Run tests for specific package (e.g., git, hooks)
just lint # Run go vet + golangci-lint
just build # Build to ./bin/
just build-all # Cross-platform build check (linux/darwin/windows)
just dev # Build and show version
just clean # Remove build artifacts and test fixtures
just fmt # Format code (go fmt + goimports)
just setup-test-repo # Set up test repo for integration tests
```

Or directly with Go:
Expand Down Expand Up @@ -121,7 +127,7 @@ cmd/wt/main.go → commands.Execute()
rootCmd.Execute() (Cobra)
Subcommand (clone, add, list, switch, delete, prune, repair)
Subcommand (clone, add, list, switch, delete, prune, repair, config, completion)
internal/git/* for git operations
Expand All @@ -141,6 +147,11 @@ Commands register in `init()` via `rootCmd.AddCommand()`.
| `internal/config/` | TOML config loading, hierarchical merge, workflow configs |
| `internal/ui/` | Terminal styling (lipgloss) and JSON output envelope |

**Test structure:**

- `test/integration/` - Integration tests (use `just test-integration`)
- `internal/*/_test.go` - Unit tests co-located with source

**Key patterns:**

- Branch names with slashes flatten to dashes for directory names (`feature/auth` → `feature-auth`)
Expand Down
73 changes: 30 additions & 43 deletions internal/commands/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,14 @@ func runConfigInit(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to create directory: %w", err)
}

// Write config template
template := config.GenerateConfigTemplate()
// For global config, detect integrations BEFORE generating template
var selections config.IntegrationSelections
if configGlobal && !IsJSONOutput() {
selections = detectIntegrations()
}

// Generate config template with selected integrations
template := config.GenerateConfigWithIntegrations(selections)
if err := os.WriteFile(configPath, []byte(template), 0644); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
Expand All @@ -110,9 +116,9 @@ func runConfigInit(cmd *cobra.Command, args []string) error {

fmt.Println(ui.SuccessMsg(fmt.Sprintf("Created %s", configPath)))

// For global config, detect available tools and offer to enable
// Show what was enabled
if configGlobal {
detectAndEnableIntegrations(configPath)
printEnabledIntegrations(selections)
}

return nil
Expand All @@ -125,8 +131,9 @@ type integration struct {
detected bool
}

// detectAndEnableIntegrations detects available tools and offers to enable them
func detectAndEnableIntegrations(configPath string) {
// detectIntegrations detects available tools and prompts user to select which to enable
// Returns the user's selections for template generation
func detectIntegrations() config.IntegrationSelections {
// Detect available tools
integrations := []integration{
{
Expand Down Expand Up @@ -155,7 +162,7 @@ func detectAndEnableIntegrations(configPath string) {
}

if len(available) == 0 {
return
return config.IntegrationSelections{}
}

// Build options for multi-select
Expand All @@ -180,61 +187,41 @@ func detectAndEnableIntegrations(configPath string) {
).WithKeyMap(DefaultFormKeyMap())

if err := form.Run(); err != nil {
return // User aborted, silently exit
return config.IntegrationSelections{} // User aborted
}

// Enable selected integrations
// Convert selections to struct
var selections config.IntegrationSelections
for _, name := range selected {
switch name {
case "zoxide":
enableZoxide(configPath)
selections.Zoxide = true
case "gh":
enableGitHub(configPath)
selections.GitHub = true
case "direnv":
enableDirenv(configPath)
selections.Direnv = true
}
}

return selections
}

func isCommandAvailable(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}

func enableZoxide(configPath string) {
for _, event := range []string{"post_clone", "post_add"} {
if err := config.AddHookToConfig(configPath, "zoxide", event); err != nil {
fmt.Println(ui.WarningMsg(fmt.Sprintf("Failed to enable zoxide: %v", err)))
return
}
}
fmt.Println(ui.SuccessMsg("Enabled zoxide for quick worktree navigation"))
}

func enableGitHub(configPath string) {
// Add gh-default to post_clone for auto-setting default repo
if err := config.AddHookToConfig(configPath, "gh-default", "post_clone"); err != nil {
fmt.Println(ui.WarningMsg(fmt.Sprintf("Failed to enable gh-default: %v", err)))
return
// printEnabledIntegrations shows the user what integrations were configured
func printEnabledIntegrations(selections config.IntegrationSelections) {
if selections.Zoxide {
fmt.Println(ui.SuccessMsg("Enabled zoxide for quick worktree navigation"))
}

// Add github-issue to feature and bugfix workflows
for _, workflow := range []string{"feature", "bugfix"} {
if err := config.AddWorkflowHook(configPath, workflow, "github-issue", "pre_create"); err != nil {
fmt.Println(ui.WarningMsg(fmt.Sprintf("Failed to enable github-issue for %s workflow: %v", workflow, err)))
return
}
if selections.GitHub {
fmt.Println(ui.SuccessMsg("Enabled GitHub CLI integration (gh-default, github-issue)"))
}

fmt.Println(ui.SuccessMsg("Enabled GitHub CLI integration (gh-default, github-issue, github-pr)"))
}

func enableDirenv(configPath string) {
if err := config.AddHookToConfig(configPath, "direnv", "post_add"); err != nil {
fmt.Println(ui.WarningMsg(fmt.Sprintf("Failed to enable direnv: %v", err)))
return
if selections.Direnv {
fmt.Println(ui.SuccessMsg("Enabled direnv for auto-loading .envrc files"))
}
fmt.Println(ui.SuccessMsg("Enabled direnv for auto-loading .envrc files"))
}

func runConfigShow(cmd *cobra.Command, args []string) error {
Expand Down
156 changes: 151 additions & 5 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ func ptrBool(b bool) *bool {
return &b
}

// IntegrationSelections holds the user's selected integrations for config generation
type IntegrationSelections struct {
Zoxide bool
GitHub bool
Direnv bool
}

// DefaultConfig returns the default configuration
func DefaultConfig() *Config {
return &Config{
Expand Down Expand Up @@ -197,6 +204,20 @@ func Load(path string) (*Config, error) {
return nil, err
}

// Restore defaults for zero timeout values (prevents instant timeouts when
// users have git_long_timeout = 0 in their config, which was a common issue
// from copying the config template without editing)
defaults := DefaultConfig()
if cfg.GitTimeout == 0 {
cfg.GitTimeout = defaults.GitTimeout
}
if cfg.GitLongTimeout == 0 {
cfg.GitLongTimeout = defaults.GitLongTimeout
}
if cfg.HookTimeout == 0 {
cfg.HookTimeout = defaults.HookTimeout
}

return cfg, nil
}

Expand Down Expand Up @@ -572,9 +593,15 @@ func removeFromSlice(slice []string, item string) []string {

// GenerateConfigTemplate returns a config file template with all options commented
func GenerateConfigTemplate() string {
return `# ============================================================
return GenerateConfigWithIntegrations(IntegrationSelections{})
}

// GenerateConfigWithIntegrations returns a config template with selected integrations pre-configured
func GenerateConfigWithIntegrations(selections IntegrationSelections) string {
// Build the base template (settings section)
template := `# ============================================================
# wt configuration
# Generated by: git wt config init
# Generated by: wt config init
# Uncomment and modify options as needed
# ============================================================

Expand Down Expand Up @@ -628,13 +655,132 @@ func GenerateConfigTemplate() string {
# Flag: --hook-timeout
# hook_timeout = 30

# --- Hooks ---
# Shell commands to run after operations
`

// Build hooks section based on selections
template += generateHooksSection(selections)

// Build workflows section if GitHub is enabled
if selections.GitHub {
template += generateWorkflowsSection(selections)
}

return template
}

// generateHooksSection creates the hooks section of the config template
func generateHooksSection(selections IntegrationSelections) string {
hasAnyHook := selections.Zoxide || selections.GitHub || selections.Direnv

if !hasAnyHook {
// No integrations selected - show commented template
return `# --- Hooks ---
# Shell commands or bundled hook names to run after operations
# Environment variables: WT_PATH, WT_BRANCH, WT_PROJECT_ROOT, WT_DEFAULT_BRANCH
# Template variables: {{.Path}}, {{.Branch}}, {{.ProjectRoot}}, {{.DefaultBranch}}
# Available hooks: zoxide, gh-default, direnv, github-issue, github-pr
# Run 'wt hooks list' to see all available hooks

# [hooks]
# post_clone = []
# post_add = []
`
}

// Build hook arrays
var postClone, postAdd []string

if selections.Zoxide {
postClone = append(postClone, "zoxide")
postAdd = append(postAdd, "zoxide")
}
if selections.GitHub {
postClone = append(postClone, "gh-default")
}
if selections.Direnv {
postAdd = append(postAdd, "direnv")
}

section := `# --- Hooks ---
# Shell commands or bundled hook names to run after operations
# Environment variables: WT_PATH, WT_BRANCH, WT_PROJECT_ROOT, WT_DEFAULT_BRANCH
# Available hooks: zoxide, gh-default, direnv, github-issue, github-pr
# Run 'wt hooks list' to see all available hooks

[hooks]
`

// Format post_clone
section += "# Runs after cloning a repository (wt clone)\n"
section += fmt.Sprintf("post_clone = %s\n\n", formatStringSlice(postClone))

// Format post_add
section += "# Runs after creating a new worktree (wt add/new)\n"
section += fmt.Sprintf("post_add = %s\n", formatStringSlice(postAdd))

return section
}

// generateWorkflowsSection creates the workflows section for GitHub integration
func generateWorkflowsSection(selections IntegrationSelections) string {
// Build post_add hooks for workflows
var workflowPostAdd []string
if selections.Direnv {
workflowPostAdd = append(workflowPostAdd, "direnv")
}
if selections.Zoxide {
workflowPostAdd = append(workflowPostAdd, "zoxide")
}

section := `
# --- Workflows ---
# Predefined branch naming and hook configurations
# Use with: wt new --workflow=feature "my feature"
# Or use shortcuts: wt new --issue 123, wt new --pr 456

[workflows.feature]
# For new feature development
description = "New feature development"
branch_template = "feat/{slug}"
[workflows.feature.hooks]
# Prompts for GitHub issue to link (optional)
pre_create = ["github-issue"]
`
section += fmt.Sprintf("post_add = %s\n", formatStringSlice(workflowPostAdd))

section += `
[workflows.bugfix]
# For bug fixes
description = "Bug fix"
branch_template = "fix/{slug}"
[workflows.bugfix.hooks]
# Prompts for GitHub issue to link (optional)
pre_create = ["github-issue"]
`
section += fmt.Sprintf("post_add = %s\n", formatStringSlice(workflowPostAdd))

return section
}

// formatStringSlice formats a string slice as a TOML array
func formatStringSlice(s []string) string {
if len(s) == 0 {
return "[]"
}
quoted := make([]string, len(s))
for i, v := range s {
quoted[i] = fmt.Sprintf("%q", v)
}
return "[" + joinStrings(quoted, ", ") + "]"
}

// joinStrings joins strings with a separator (avoiding strings import for this simple case)
func joinStrings(s []string, sep string) string {
if len(s) == 0 {
return ""
}
result := s[0]
for i := 1; i < len(s); i++ {
result += sep + s[i]
}
return result
}
Loading