diff --git a/.goreleaser.yml b/.goreleaser.yml index f027271..14ee037 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -13,7 +13,7 @@ builds: - main: . binary: wt ldflags: - - -s -w -X main.version={{.Version}} + - -s -w -X github.com/timvw/wt/cmd.Version={{.Version}} env: - CGO_ENABLED=0 goos: diff --git a/checkout.go b/cmd/checkout.go similarity index 97% rename from checkout.go rename to cmd/checkout.go index d5b05a0..69a72d9 100644 --- a/checkout.go +++ b/cmd/checkout.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "fmt" @@ -7,6 +7,7 @@ import ( "github.com/manifoldco/promptui" "github.com/spf13/cobra" + "github.com/timvw/wt/internal/fuzzy" ) var checkoutCmd = &cobra.Command{ @@ -33,7 +34,7 @@ var checkoutCmd = &cobra.Command{ prompt := promptui.Select{ Label: "Select branch to checkout", Items: branches, - Searcher: fuzzySearcher(branches), + Searcher: fuzzy.Searcher(branches), StartInSearchMode: true, } _, result, err := prompt.Run() diff --git a/cleanup.go b/cmd/cleanup.go similarity index 99% rename from cleanup.go rename to cmd/cleanup.go index d236144..67964da 100644 --- a/cleanup.go +++ b/cmd/cleanup.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "fmt" diff --git a/cleanup_test.go b/cmd/cleanup_test.go similarity index 99% rename from cleanup_test.go rename to cmd/cleanup_test.go index 6383cf2..982e402 100644 --- a/cleanup_test.go +++ b/cmd/cleanup_test.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "os" diff --git a/config.go b/cmd/config.go similarity index 99% rename from config.go rename to cmd/config.go index 5efd85d..d0172e6 100644 --- a/config.go +++ b/cmd/config.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "fmt" diff --git a/config_cmd.go b/cmd/config_cmd.go similarity index 99% rename from config_cmd.go rename to cmd/config_cmd.go index 0eef349..1d4dffa 100644 --- a/config_cmd.go +++ b/cmd/config_cmd.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "fmt" diff --git a/config_test.go b/cmd/config_test.go similarity index 99% rename from config_test.go rename to cmd/config_test.go index 8381573..b112a6c 100644 --- a/config_test.go +++ b/cmd/config_test.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "bytes" diff --git a/create.go b/cmd/create.go similarity index 99% rename from create.go rename to cmd/create.go index e55875b..4219dc3 100644 --- a/create.go +++ b/cmd/create.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "fmt" diff --git a/default.go b/cmd/default.go similarity index 97% rename from default.go rename to cmd/default.go index 9101221..0ac87b1 100644 --- a/default.go +++ b/cmd/default.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "fmt" diff --git a/default_test.go b/cmd/default_test.go similarity index 99% rename from default_test.go rename to cmd/default_test.go index 355bac3..c8b4157 100644 --- a/default_test.go +++ b/cmd/default_test.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "strings" diff --git a/e2e_interactive_test.go b/cmd/e2e_interactive_test.go similarity index 99% rename from e2e_interactive_test.go rename to cmd/e2e_interactive_test.go index 1fd5c03..ada4843 100644 --- a/e2e_interactive_test.go +++ b/cmd/e2e_interactive_test.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "bytes" @@ -1380,7 +1380,7 @@ func buildWtBinary(t *testing.T, tmpDir string) string { } builtWtBinaryPath = filepath.Join(buildDir, binaryName) - cmd := exec.Command("go", "build", "-o", builtWtBinaryPath, ".") + cmd := exec.Command("go", "build", "-o", builtWtBinaryPath, "github.com/timvw/wt") if output, err := cmd.CombinedOutput(); err != nil { builtWtBinaryErr = fmt.Errorf("failed to build wt binary: %v\nOutput: %s", err, output) return diff --git a/examples.go b/cmd/examples.go similarity index 99% rename from examples.go rename to cmd/examples.go index 48edba8..82dbf24 100644 --- a/examples.go +++ b/cmd/examples.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "fmt" diff --git a/examples_test.go b/cmd/examples_test.go similarity index 99% rename from examples_test.go rename to cmd/examples_test.go index c4f9b2e..79ff9a8 100644 --- a/examples_test.go +++ b/cmd/examples_test.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "bytes" diff --git a/hooks.go b/cmd/hooks.go similarity index 99% rename from hooks.go rename to cmd/hooks.go index 1351380..fbddd0e 100644 --- a/hooks.go +++ b/cmd/hooks.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "fmt" diff --git a/info.go b/cmd/info.go similarity index 99% rename from info.go rename to cmd/info.go index b21601e..a177e4e 100644 --- a/info.go +++ b/cmd/info.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "fmt" diff --git a/init.go b/cmd/init.go similarity index 99% rename from init.go rename to cmd/init.go index abff3bc..3c7893a 100644 --- a/init.go +++ b/cmd/init.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "fmt" diff --git a/init_test.go b/cmd/init_test.go similarity index 99% rename from init_test.go rename to cmd/init_test.go index 6b418e6..72f3ebd 100644 --- a/init_test.go +++ b/cmd/init_test.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "encoding/json" diff --git a/list.go b/cmd/list.go similarity index 97% rename from list.go rename to cmd/list.go index 32bdaf6..8e03f31 100644 --- a/list.go +++ b/cmd/list.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "os" diff --git a/main_test.go b/cmd/main_test.go similarity index 99% rename from main_test.go rename to cmd/main_test.go index fa089af..abfb2f1 100644 --- a/main_test.go +++ b/cmd/main_test.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "fmt" diff --git a/migrate.go b/cmd/migrate.go similarity index 99% rename from migrate.go rename to cmd/migrate.go index e5eac55..2b64243 100644 --- a/migrate.go +++ b/cmd/migrate.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "fmt" diff --git a/migrate_test.go b/cmd/migrate_test.go similarity index 99% rename from migrate_test.go rename to cmd/migrate_test.go index 6e1eaaf..e326506 100644 --- a/migrate_test.go +++ b/cmd/migrate_test.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "encoding/json" diff --git a/output.go b/cmd/output.go similarity index 99% rename from output.go rename to cmd/output.go index e839290..418ed4e 100644 --- a/output.go +++ b/cmd/output.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "encoding/json" diff --git a/output_test.go b/cmd/output_test.go similarity index 99% rename from output_test.go rename to cmd/output_test.go index af0d11a..88bb718 100644 --- a/output_test.go +++ b/cmd/output_test.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "bytes" diff --git a/pr.go b/cmd/pr.go similarity index 97% rename from pr.go rename to cmd/pr.go index 0f1c4b6..5cb727f 100644 --- a/pr.go +++ b/cmd/pr.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "fmt" @@ -8,6 +8,7 @@ import ( "github.com/manifoldco/promptui" "github.com/spf13/cobra" + "github.com/timvw/wt/internal/fuzzy" ) var prCmd = &cobra.Command{ @@ -43,7 +44,7 @@ Examples: prompt := promptui.Select{ Label: "Select Pull Request", Items: labels, - Searcher: fuzzySearcher(labels), + Searcher: fuzzy.Searcher(labels), StartInSearchMode: true, } idx, _, err := prompt.Run() @@ -92,7 +93,7 @@ Examples: prompt := promptui.Select{ Label: "Select Merge Request", Items: labels, - Searcher: fuzzySearcher(labels), + Searcher: fuzzy.Searcher(labels), StartInSearchMode: true, } idx, _, err := prompt.Run() diff --git a/prune.go b/cmd/prune.go similarity index 97% rename from prune.go rename to cmd/prune.go index eeda6bc..ad8f602 100644 --- a/prune.go +++ b/cmd/prune.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "fmt" diff --git a/remove.go b/cmd/remove.go similarity index 96% rename from remove.go rename to cmd/remove.go index eac368e..fa64166 100644 --- a/remove.go +++ b/cmd/remove.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "fmt" @@ -8,6 +8,7 @@ import ( "github.com/manifoldco/promptui" "github.com/spf13/cobra" + "github.com/timvw/wt/internal/fuzzy" ) var removeForce bool @@ -37,7 +38,7 @@ var removeCmd = &cobra.Command{ prompt := promptui.Select{ Label: "Select worktree to remove", Items: branches, - Searcher: fuzzySearcher(branches), + Searcher: fuzzy.Searcher(branches), StartInSearchMode: true, } _, result, err := prompt.Run() diff --git a/remove_cleanup_test.go b/cmd/remove_cleanup_test.go similarity index 99% rename from remove_cleanup_test.go rename to cmd/remove_cleanup_test.go index ca21c9c..c5f4e55 100644 --- a/remove_cleanup_test.go +++ b/cmd/remove_cleanup_test.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "fmt" diff --git a/remove_force_test.go b/cmd/remove_force_test.go similarity index 99% rename from remove_force_test.go rename to cmd/remove_force_test.go index 346f8c3..86e6b76 100644 --- a/remove_force_test.go +++ b/cmd/remove_force_test.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "os" diff --git a/repo.go b/cmd/repo.go similarity index 99% rename from repo.go rename to cmd/repo.go index 707b006..b8a882d 100644 --- a/repo.go +++ b/cmd/repo.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "encoding/json" diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..6b4757e --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,142 @@ +package cmd + +import ( + "bytes" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var ( + Version = "dev" + worktreeRoot string + worktreeStrategy string + worktreePattern string + worktreeSeparator string +) + +func init() { + loadWorktreeConfig() + rootCmd.Long = buildRootCmdLong() +} + +// Execute runs the root command. Called by main(). +func Execute() { + // Re-load config after cobra parses flags so --config is available + rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + if err := validateOutputFormat(); err != nil { + return err + } + if configFlag != "" { + loadWorktreeConfig() + rootCmd.Long = buildRootCmdLong() + } + return nil + } + if err := rootCmd.Execute(); err != nil { + if isJSONOutput() { + _ = emitJSONError(rootCmd, err) + } else { + fmt.Fprintln(os.Stderr, err) + } + os.Exit(1) + } +} + +var rootCmd = &cobra.Command{ + Use: "wt", + Short: "Git worktree helper with organized directory structure", + Long: "", + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return printCommandHelp(cmd) + }, +} + +func printCommandHelp(cmd *cobra.Command) error { + return cmd.Help() +} + +func init() { + rootCmd.PersistentFlags().StringVar(&configFlag, "config", "", "Path to config file (default: ~/.config/wt/config.toml)") + rootCmd.PersistentFlags().StringVar(&outputFormat, "format", formatText, "Output format: text or json") + + defaultHelp := rootCmd.HelpFunc() + rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + if !isJSONOutput() { + defaultHelp(cmd, args) + return + } + + buf := bytes.NewBuffer(nil) + origOut := cmd.OutOrStdout() + origErr := cmd.ErrOrStderr() + cmd.SetOut(buf) + cmd.SetErr(buf) + defaultHelp(cmd, args) + cmd.SetOut(origOut) + cmd.SetErr(origErr) + + _ = emitJSONSuccess(cmd, map[string]any{"help": buf.String()}) + }) + + rootCmd.AddCommand(checkoutCmd) + rootCmd.AddCommand(createCmd) + rootCmd.AddCommand(prCmd) + rootCmd.AddCommand(mrCmd) + rootCmd.AddCommand(listCmd) + rootCmd.AddCommand(removeCmd) + rootCmd.AddCommand(pruneCmd) + rootCmd.AddCommand(cleanupCmd) + rootCmd.AddCommand(migrateCmd) + rootCmd.AddCommand(shellenvCmd) + rootCmd.AddCommand(versionCmd) + rootCmd.AddCommand(initCmd) + rootCmd.AddCommand(infoCmd) + rootCmd.AddCommand(configCmd) + rootCmd.AddCommand(examplesCmd) + rootCmd.AddCommand(defaultCmd) + rootCmd.AddCommand(statusCmd) + statusCmd.Flags().BoolVar(&statusCI, "ci", false, "Show CI/CD pipeline status for each branch (requires gh or glab CLI)") + removeCmd.Flags().BoolVarP(&removeForce, "force", "f", false, "Force removal even if worktree has modifications") + cleanupCmd.Flags().BoolVar(&cleanupDryRun, "dry-run", false, "Preview what would be removed without making changes") + cleanupCmd.Flags().BoolVarP(&cleanupForce, "force", "f", false, "Remove all merged worktrees without confirmation") + cleanupCmd.Flags().BoolVar(&cleanupStale, "stale", false, "Also detect worktrees with deleted remote branches or old commits") + cleanupCmd.Flags().IntVar(&cleanupStaleDays, "stale-days", 30, "Threshold in days for considering a worktree inactive") + migrateCmd.Flags().BoolVarP(&migrateForce, "force", "f", false, "Force migration when target path exists and is non-empty") + initCmd.Flags().BoolVar(&initDryRun, "dry-run", false, "Preview changes without modifying files") + initCmd.Flags().BoolVar(&initUninstall, "uninstall", false, "Remove wt configuration from shell") + initCmd.Flags().BoolVar(&initNoPrompt, "no-prompt", false, "Skip activation instructions (for automated installs)") + configInitCmd.Flags().BoolVar(&configInitForce, "force", false, "Overwrite existing config file") +} + +func init() { + configCmd.AddCommand(configInitCmd) + configCmd.AddCommand(configShowCmd) + configCmd.AddCommand(configPathCmd) +} + +func buildRootCmdLong() string { + pattern, err := resolveWorktreePattern() + if err != nil { + pattern = worktreePattern + if pattern == "" { + pattern = "unknown" + } + } + + return fmt.Sprintf(`Git-like worktree management with organized directory structure. + +Strategy: %s +Pattern: %s +Root: %s + +Run 'wt info' to see available strategies and pattern variables. +Set WORKTREE_ROOT, WORKTREE_STRATEGY, and WORKTREE_PATTERN to customize.`, + worktreeStrategy, + pattern, + worktreeRoot, + ) +} diff --git a/shellenv.go b/cmd/shellenv.go similarity index 99% rename from shellenv.go rename to cmd/shellenv.go index ab5ae80..c9aeddd 100644 --- a/shellenv.go +++ b/cmd/shellenv.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "fmt" diff --git a/shellenv_test.go b/cmd/shellenv_test.go similarity index 94% rename from shellenv_test.go rename to cmd/shellenv_test.go index b57710f..0b6bc33 100644 --- a/shellenv_test.go +++ b/cmd/shellenv_test.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "os/exec" @@ -13,7 +13,7 @@ import ( // BUG: Currently fails because interactive mode doesn't capture output func TestShellenvInteractiveModeOutputCapture(t *testing.T) { // Get the shellenv output - cmd := exec.Command("go", "run", ".", "shellenv") + cmd := exec.Command("go", "run", "github.com/timvw/wt", "shellenv") output, err := cmd.Output() if err != nil { t.Fatalf("Failed to run wt shellenv: %v", err) @@ -61,7 +61,7 @@ func TestShellenvInteractiveModeOutputCapture(t *testing.T) { // // BUG: Currently fails because compdef is called unconditionally func TestShellenvZshCompdefProtection(t *testing.T) { - cmd := exec.Command("go", "run", ".", "shellenv") + cmd := exec.Command("go", "run", "github.com/timvw/wt", "shellenv") output, err := cmd.Output() if err != nil { t.Fatalf("Failed to run wt shellenv: %v", err) @@ -94,7 +94,7 @@ func TestShellenvZshCompdefProtection(t *testing.T) { func TestShellenvZshCompdefError(t *testing.T) { // Run shellenv and try to source it in a fresh zsh shell (without compinit) // This simulates the real-world error condition - cmd := exec.Command("zsh", "-c", "source <(go run . shellenv) 2>&1 && type wt") + cmd := exec.Command("zsh", "-c", "source <(go run github.com/timvw/wt shellenv) 2>&1 && type wt") output, err := cmd.CombinedOutput() // Check for compdef error - this is the BUG we're testing for @@ -112,7 +112,7 @@ func TestShellenvZshCompdefError(t *testing.T) { // TestShellenvCompletionCheckoutUsesAllBranches verifies that tab completion // for checkout/co/create completes from all branches (not just existing worktrees). func TestShellenvCompletionCheckoutUsesAllBranches(t *testing.T) { - cmd := exec.Command("go", "run", ".", "shellenv") + cmd := exec.Command("go", "run", "github.com/timvw/wt", "shellenv") output, err := cmd.Output() if err != nil { t.Fatalf("Failed to run wt shellenv: %v", err) @@ -143,7 +143,7 @@ func TestShellenvCompletionCheckoutUsesAllBranches(t *testing.T) { // TestShellenvCompletionRemoveUsesWorktreeList verifies that tab completion // for remove/rm only completes from existing worktrees (not all branches). func TestShellenvCompletionRemoveUsesWorktreeList(t *testing.T) { - cmd := exec.Command("go", "run", ".", "shellenv") + cmd := exec.Command("go", "run", "github.com/timvw/wt", "shellenv") output, err := cmd.Output() if err != nil { t.Fatalf("Failed to run wt shellenv: %v", err) @@ -166,7 +166,7 @@ func TestShellenvCompletionRemoveUsesWorktreeList(t *testing.T) { // `source <(wt shellenv)` can include CR bytes and break zsh parsing when a // session re-sources shellenv. func TestShellenvBypassesWrapperForShellenv(t *testing.T) { - cmd := exec.Command("go", "run", ".", "shellenv") + cmd := exec.Command("go", "run", "github.com/timvw/wt", "shellenv") output, err := cmd.Output() if err != nil { t.Fatalf("Failed to run wt shellenv: %v", err) diff --git a/stale.go b/cmd/stale.go similarity index 99% rename from stale.go rename to cmd/stale.go index 66a020d..e1951b2 100644 --- a/stale.go +++ b/cmd/stale.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "fmt" diff --git a/stale_test.go b/cmd/stale_test.go similarity index 99% rename from stale_test.go rename to cmd/stale_test.go index 5023156..4769cfb 100644 --- a/stale_test.go +++ b/cmd/stale_test.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "fmt" diff --git a/status.go b/cmd/status.go similarity index 92% rename from status.go rename to cmd/status.go index 5c95acf..3f6dd04 100644 --- a/status.go +++ b/cmd/status.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "encoding/json" @@ -9,6 +9,7 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/timvw/wt/internal/color" ) type worktreeStatus struct { @@ -89,7 +90,7 @@ Use --format json for machine-readable output.`, return emitJSONSuccess(cmd, map[string]any{"worktrees": statuses}) } - useColor := isColorEnabled() + useColor := color.IsEnabled() for _, st := range statuses { fmt.Println(formatStatusLineColor(st, useColor)) } @@ -135,7 +136,7 @@ func formatStatusLine(st worktreeStatus) string { // formatStatusLineColor formats a single worktree status entry as a human-readable line. // When color is true, ANSI escape codes are added for visual distinction. -func formatStatusLineColor(st worktreeStatus, color bool) string { +func formatStatusLineColor(st worktreeStatus, useColor bool) string { marker := " " if st.Current { marker = "*" @@ -156,33 +157,33 @@ func formatStatusLineColor(st worktreeStatus, color bool) string { ci = " " + st.CI } - if !color { + if !useColor { return fmt.Sprintf("%s %-14s %-30s %-7s %s%s", marker, st.Branch, st.Path, state, tracking, ci) } // Apply colors if st.Current { - marker = colorize("*", ansiBold+";"+ansiCyanRaw) + marker = color.Colorize("*", color.Bold+";"+color.CyanRaw) } - branch := colorize(st.Branch, ansiBold) + branch := color.Colorize(st.Branch, color.Bold) if st.Dirty { - state = colorize("dirty", ansiRed) + state = color.Colorize("dirty", color.Red) } else { - state = colorize("clean", ansiGreen) + state = color.Colorize("clean", color.Green) } if !st.HasUpstream { - tracking = colorize("no upstream", ansiDim) + tracking = color.Colorize("no upstream", color.Dim) } else { aheadStr := fmt.Sprintf("↑%d", st.Ahead) behindStr := fmt.Sprintf("↓%d", st.Behind) if st.Ahead > 0 { - aheadStr = colorize(aheadStr, ansiYellow) + aheadStr = color.Colorize(aheadStr, color.Yellow) } if st.Behind > 0 { - behindStr = colorize(behindStr, ansiYellow) + behindStr = color.Colorize(behindStr, color.Yellow) } tracking = aheadStr + " " + behindStr } @@ -198,13 +199,13 @@ func formatStatusLineColor(st worktreeStatus, color bool) string { func formatCIColor(ci string) string { switch ci { case "pass": - return colorize("✓ CI", ansiGreen) + return color.Colorize("✓ CI", color.Green) case "fail": - return colorize("✗ CI", ansiRed) + return color.Colorize("✗ CI", color.Red) case "pending": - return colorize("● CI", ansiYellow) + return color.Colorize("● CI", color.Yellow) default: - return colorize(ci, ansiDim) + return color.Colorize(ci, color.Dim) } } diff --git a/color_test.go b/cmd/status_color_test.go similarity index 71% rename from color_test.go rename to cmd/status_color_test.go index a4be3bb..9cf4765 100644 --- a/color_test.go +++ b/cmd/status_color_test.go @@ -1,93 +1,10 @@ -package main +package cmd import ( "strings" "testing" ) -func TestColorize(t *testing.T) { - tests := []struct { - name string - text string - code string - want string - }{ - { - name: "Bold text", - text: "hello", - code: ansiBold, - want: "\033[1mhello\033[0m", - }, - { - name: "Red text", - text: "dirty", - code: ansiRed, - want: "\033[31mdirty\033[0m", - }, - { - name: "Green text", - text: "clean", - code: ansiGreen, - want: "\033[32mclean\033[0m", - }, - { - name: "Yellow text", - text: "3", - code: ansiYellow, - want: "\033[33m3\033[0m", - }, - { - name: "Cyan text", - text: "*", - code: ansiCyan, - want: "\033[36m*\033[0m", - }, - { - name: "Dim text", - text: "no upstream", - code: ansiDim, - want: "\033[2mno upstream\033[0m", - }, - { - name: "Empty string", - text: "", - code: ansiBold, - want: "\033[1m\033[0m", - }, - { - name: "Combined bold+cyan", - text: "*", - code: ansiBold + ";" + ansiCyanRaw, - want: "\033[1;36m*\033[0m", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := colorize(tt.text, tt.code) - if got != tt.want { - t.Errorf("colorize(%q, %q) = %q, want %q", tt.text, tt.code, got, tt.want) - } - }) - } -} - -func TestIsColorEnabledRespectsNO_COLOR(t *testing.T) { - // When NO_COLOR is set, color should be disabled regardless - t.Setenv("NO_COLOR", "1") - if isColorEnabled() { - t.Error("isColorEnabled() = true, want false when NO_COLOR is set") - } -} - -func TestIsColorEnabledRespectsEmptyNO_COLOR(t *testing.T) { - // When NO_COLOR is set to empty string, it still counts per no-color.org spec - t.Setenv("NO_COLOR", "") - if isColorEnabled() { - t.Error("isColorEnabled() = true, want false when NO_COLOR is set (even empty)") - } -} - func TestFormatStatusLineNoColor(t *testing.T) { // When color is disabled, formatStatusLine should produce the same output as before tests := []struct { diff --git a/status_test.go b/cmd/status_test.go similarity index 99% rename from status_test.go rename to cmd/status_test.go index 57def92..036abc0 100644 --- a/status_test.go +++ b/cmd/status_test.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "bytes" diff --git a/version.go b/cmd/version.go similarity index 67% rename from version.go rename to cmd/version.go index b22d615..aeeb1b6 100644 --- a/version.go +++ b/cmd/version.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "fmt" @@ -11,9 +11,9 @@ var versionCmd = &cobra.Command{ Short: "Show version information", RunE: func(cmd *cobra.Command, args []string) error { if isJSONOutput() { - return emitJSONSuccess(cmd, map[string]string{"version": version}) + return emitJSONSuccess(cmd, map[string]string{"version": Version}) } - fmt.Printf("wt version %s\n", version) + fmt.Printf("wt version %s\n", Version) return nil }, } diff --git a/worktree_path.go b/cmd/worktree_path.go similarity index 99% rename from worktree_path.go rename to cmd/worktree_path.go index 1cb899b..9a2e24c 100644 --- a/worktree_path.go +++ b/cmd/worktree_path.go @@ -1,4 +1,4 @@ -package main +package cmd import ( "bytes" diff --git a/fuzzy.go b/fuzzy.go deleted file mode 100644 index fcd2fa6..0000000 --- a/fuzzy.go +++ /dev/null @@ -1,24 +0,0 @@ -package main - -import ( - "strings" - - "github.com/sahilm/fuzzy" -) - -// fuzzyMatch returns true if input fuzzy-matches the target string (case-insensitive). -func fuzzyMatch(input, target string) bool { - if input == "" { - return true - } - matches := fuzzy.Find(strings.ToLower(input), []string{strings.ToLower(target)}) - return len(matches) > 0 -} - -// fuzzySearcher returns a Searcher function compatible with promptui.Select.Searcher. -// It checks whether the input fuzzy-matches the item at the given index. -func fuzzySearcher(items []string) func(input string, index int) bool { - return func(input string, index int) bool { - return fuzzyMatch(input, items[index]) - } -} diff --git a/color.go b/internal/color/color.go similarity index 50% rename from color.go rename to internal/color/color.go index 1c3c0ec..77c7b11 100644 --- a/color.go +++ b/internal/color/color.go @@ -1,4 +1,4 @@ -package main +package color import ( "fmt" @@ -9,28 +9,28 @@ import ( // ANSI escape code constants. const ( - ansiReset = "\033[0m" - ansiBold = "1" - ansiDim = "2" - ansiRed = "31" - ansiGreen = "32" - ansiYellow = "33" - ansiCyan = "36" - ansiCyanRaw = "36" // for combining with other codes + Reset = "\033[0m" + Bold = "1" + Dim = "2" + Red = "31" + Green = "32" + Yellow = "33" + Cyan = "36" + CyanRaw = "36" // for combining with other codes ) -// colorize wraps s in ANSI escape codes. The code parameter can be a single +// Colorize wraps s in ANSI escape codes. The code parameter can be a single // code (e.g. "31" for red) or combined codes separated by semicolons // (e.g. "1;36" for bold cyan). -func colorize(s string, code string) string { - return fmt.Sprintf("\033[%sm%s%s", code, s, ansiReset) +func Colorize(s string, code string) string { + return fmt.Sprintf("\033[%sm%s%s", code, s, Reset) } -// isColorEnabled returns true if color output should be used. +// IsEnabled returns true if color output should be used. // Color is disabled when: // - the NO_COLOR environment variable is set (any value, per https://no-color.org/) // - stdout is not a terminal (i.e., output is piped) -func isColorEnabled() bool { +func IsEnabled() bool { if _, ok := os.LookupEnv("NO_COLOR"); ok { return false } diff --git a/internal/color/color_test.go b/internal/color/color_test.go new file mode 100644 index 0000000..34c962c --- /dev/null +++ b/internal/color/color_test.go @@ -0,0 +1,88 @@ +package color + +import ( + "testing" +) + +func TestColorize(t *testing.T) { + tests := []struct { + name string + text string + code string + want string + }{ + { + name: "Bold text", + text: "hello", + code: Bold, + want: "\033[1mhello\033[0m", + }, + { + name: "Red text", + text: "dirty", + code: Red, + want: "\033[31mdirty\033[0m", + }, + { + name: "Green text", + text: "clean", + code: Green, + want: "\033[32mclean\033[0m", + }, + { + name: "Yellow text", + text: "3", + code: Yellow, + want: "\033[33m3\033[0m", + }, + { + name: "Cyan text", + text: "*", + code: Cyan, + want: "\033[36m*\033[0m", + }, + { + name: "Dim text", + text: "no upstream", + code: Dim, + want: "\033[2mno upstream\033[0m", + }, + { + name: "Empty string", + text: "", + code: Bold, + want: "\033[1m\033[0m", + }, + { + name: "Combined bold+cyan", + text: "*", + code: Bold + ";" + CyanRaw, + want: "\033[1;36m*\033[0m", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Colorize(tt.text, tt.code) + if got != tt.want { + t.Errorf("Colorize(%q, %q) = %q, want %q", tt.text, tt.code, got, tt.want) + } + }) + } +} + +func TestIsEnabledRespectsNO_COLOR(t *testing.T) { + // When NO_COLOR is set, color should be disabled regardless + t.Setenv("NO_COLOR", "1") + if IsEnabled() { + t.Error("IsEnabled() = true, want false when NO_COLOR is set") + } +} + +func TestIsEnabledRespectsEmptyNO_COLOR(t *testing.T) { + // When NO_COLOR is set to empty string, it still counts per no-color.org spec + t.Setenv("NO_COLOR", "") + if IsEnabled() { + t.Error("IsEnabled() = true, want false when NO_COLOR is set (even empty)") + } +} diff --git a/internal/fuzzy/fuzzy.go b/internal/fuzzy/fuzzy.go new file mode 100644 index 0000000..ee843fb --- /dev/null +++ b/internal/fuzzy/fuzzy.go @@ -0,0 +1,24 @@ +package fuzzy + +import ( + "strings" + + fuzzypkg "github.com/sahilm/fuzzy" +) + +// Match returns true if input fuzzy-matches the target string (case-insensitive). +func Match(input, target string) bool { + if input == "" { + return true + } + matches := fuzzypkg.Find(strings.ToLower(input), []string{strings.ToLower(target)}) + return len(matches) > 0 +} + +// Searcher returns a Searcher function compatible with promptui.Select.Searcher. +// It checks whether the input fuzzy-matches the item at the given index. +func Searcher(items []string) func(input string, index int) bool { + return func(input string, index int) bool { + return Match(input, items[index]) + } +} diff --git a/fuzzy_test.go b/internal/fuzzy/fuzzy_test.go similarity index 84% rename from fuzzy_test.go rename to internal/fuzzy/fuzzy_test.go index 11e227e..5544308 100644 --- a/fuzzy_test.go +++ b/internal/fuzzy/fuzzy_test.go @@ -1,10 +1,10 @@ -package main +package fuzzy import ( "testing" ) -func TestFuzzyMatch(t *testing.T) { +func TestMatch(t *testing.T) { tests := []struct { input string target string @@ -21,22 +21,22 @@ func TestFuzzyMatch(t *testing.T) { for _, tt := range tests { t.Run(tt.input+"_vs_"+tt.target, func(t *testing.T) { - got := fuzzyMatch(tt.input, tt.target) + got := Match(tt.input, tt.target) if got != tt.want { - t.Errorf("fuzzyMatch(%q, %q) = %v, want %v", tt.input, tt.target, got, tt.want) + t.Errorf("Match(%q, %q) = %v, want %v", tt.input, tt.target, got, tt.want) } }) } } -func TestFuzzySearcher(t *testing.T) { +func TestSearcher(t *testing.T) { items := []string{ "feature/add-auth", "fix/bug-123", "main", "develop", } - searcher := fuzzySearcher(items) + searcher := Searcher(items) // "feat" should match "feature/add-auth" (index 0) if !searcher("feat", 0) { diff --git a/main.go b/main.go index fc16887..2ca2f2d 100644 --- a/main.go +++ b/main.go @@ -1,141 +1,7 @@ package main -import ( - "bytes" - "fmt" - "os" - - "github.com/spf13/cobra" -) - -var ( - version = "dev" - worktreeRoot string - worktreeStrategy string - worktreePattern string - worktreeSeparator string -) - -func init() { - loadWorktreeConfig() - rootCmd.Long = buildRootCmdLong() -} +import "github.com/timvw/wt/cmd" func main() { - // Re-load config after cobra parses flags so --config is available - rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { - if err := validateOutputFormat(); err != nil { - return err - } - if configFlag != "" { - loadWorktreeConfig() - rootCmd.Long = buildRootCmdLong() - } - return nil - } - if err := rootCmd.Execute(); err != nil { - if isJSONOutput() { - _ = emitJSONError(rootCmd, err) - } else { - fmt.Fprintln(os.Stderr, err) - } - os.Exit(1) - } -} - -var rootCmd = &cobra.Command{ - Use: "wt", - Short: "Git worktree helper with organized directory structure", - Long: "", - SilenceErrors: true, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - return printCommandHelp(cmd) - }, -} - -func printCommandHelp(cmd *cobra.Command) error { - return cmd.Help() -} - -func init() { - rootCmd.PersistentFlags().StringVar(&configFlag, "config", "", "Path to config file (default: ~/.config/wt/config.toml)") - rootCmd.PersistentFlags().StringVar(&outputFormat, "format", formatText, "Output format: text or json") - - defaultHelp := rootCmd.HelpFunc() - rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { - if !isJSONOutput() { - defaultHelp(cmd, args) - return - } - - buf := bytes.NewBuffer(nil) - origOut := cmd.OutOrStdout() - origErr := cmd.ErrOrStderr() - cmd.SetOut(buf) - cmd.SetErr(buf) - defaultHelp(cmd, args) - cmd.SetOut(origOut) - cmd.SetErr(origErr) - - _ = emitJSONSuccess(cmd, map[string]any{"help": buf.String()}) - }) - - rootCmd.AddCommand(checkoutCmd) - rootCmd.AddCommand(createCmd) - rootCmd.AddCommand(prCmd) - rootCmd.AddCommand(mrCmd) - rootCmd.AddCommand(listCmd) - rootCmd.AddCommand(removeCmd) - rootCmd.AddCommand(pruneCmd) - rootCmd.AddCommand(cleanupCmd) - rootCmd.AddCommand(migrateCmd) - rootCmd.AddCommand(shellenvCmd) - rootCmd.AddCommand(versionCmd) - rootCmd.AddCommand(initCmd) - rootCmd.AddCommand(infoCmd) - rootCmd.AddCommand(configCmd) - rootCmd.AddCommand(examplesCmd) - rootCmd.AddCommand(defaultCmd) - rootCmd.AddCommand(statusCmd) - statusCmd.Flags().BoolVar(&statusCI, "ci", false, "Show CI/CD pipeline status for each branch (requires gh or glab CLI)") - removeCmd.Flags().BoolVarP(&removeForce, "force", "f", false, "Force removal even if worktree has modifications") - cleanupCmd.Flags().BoolVar(&cleanupDryRun, "dry-run", false, "Preview what would be removed without making changes") - cleanupCmd.Flags().BoolVarP(&cleanupForce, "force", "f", false, "Remove all merged worktrees without confirmation") - cleanupCmd.Flags().BoolVar(&cleanupStale, "stale", false, "Also detect worktrees with deleted remote branches or old commits") - cleanupCmd.Flags().IntVar(&cleanupStaleDays, "stale-days", 30, "Threshold in days for considering a worktree inactive") - migrateCmd.Flags().BoolVarP(&migrateForce, "force", "f", false, "Force migration when target path exists and is non-empty") - initCmd.Flags().BoolVar(&initDryRun, "dry-run", false, "Preview changes without modifying files") - initCmd.Flags().BoolVar(&initUninstall, "uninstall", false, "Remove wt configuration from shell") - initCmd.Flags().BoolVar(&initNoPrompt, "no-prompt", false, "Skip activation instructions (for automated installs)") - configInitCmd.Flags().BoolVar(&configInitForce, "force", false, "Overwrite existing config file") -} - -func init() { - configCmd.AddCommand(configInitCmd) - configCmd.AddCommand(configShowCmd) - configCmd.AddCommand(configPathCmd) -} - -func buildRootCmdLong() string { - pattern, err := resolveWorktreePattern() - if err != nil { - pattern = worktreePattern - if pattern == "" { - pattern = "unknown" - } - } - - return fmt.Sprintf(`Git-like worktree management with organized directory structure. - -Strategy: %s -Pattern: %s -Root: %s - -Run 'wt info' to see available strategies and pattern variables. -Set WORKTREE_ROOT, WORKTREE_STRATEGY, and WORKTREE_PATTERN to customize.`, - worktreeStrategy, - pattern, - worktreeRoot, - ) + cmd.Execute() }