From 6127b167e12ba6d56e17de83f43a09bf209ba294 Mon Sep 17 00:00:00 2001 From: Dan Henton Date: Wed, 15 Apr 2026 10:28:01 +1200 Subject: [PATCH 1/4] refactor(cli): add cobra dependency and rewrite root command --- cmd/opencode-worktree/flags.go | 24 ---------- cmd/opencode-worktree/main.go | 81 +++++++++++----------------------- go.mod | 11 ++++- go.sum | 10 +++++ 4 files changed, 44 insertions(+), 82 deletions(-) delete mode 100644 cmd/opencode-worktree/flags.go diff --git a/cmd/opencode-worktree/flags.go b/cmd/opencode-worktree/flags.go deleted file mode 100644 index a47337b..0000000 --- a/cmd/opencode-worktree/flags.go +++ /dev/null @@ -1,24 +0,0 @@ -package main - -func reorderKnownBoolFlags(args []string, knownFlags ...string) []string { - if len(args) == 0 || len(knownFlags) == 0 { - return args - } - - known := make(map[string]struct{}, len(knownFlags)) - for _, flagName := range knownFlags { - known[flagName] = struct{}{} - } - - reordered := make([]string, 0, len(args)) - remaining := make([]string, 0, len(args)) - for _, arg := range args { - if _, ok := known[arg]; ok { - reordered = append(reordered, arg) - continue - } - remaining = append(remaining, arg) - } - - return append(reordered, remaining...) -} diff --git a/cmd/opencode-worktree/main.go b/cmd/opencode-worktree/main.go index 9542823..59246dc 100644 --- a/cmd/opencode-worktree/main.go +++ b/cmd/opencode-worktree/main.go @@ -4,13 +4,15 @@ import ( "errors" "fmt" "os" + + "github.com/spf13/cobra" ) // version is set at build time via ldflags. Defaults to "dev" for local builds. var version = "dev" func main() { - if err := run(); err != nil { + if err := newRootCmd().Execute(); err != nil { if !errors.Is(err, errSilent) { fmt.Fprint(os.Stderr, emoji("โŒ ", "error: ")+err.Error()+"\n") } @@ -18,60 +20,27 @@ func main() { } } -func run() error { - if len(os.Args) < 2 { - printUsage() - return errSilent - } - - switch os.Args[1] { - case "task": - return runTask(os.Args[2:]) - case "attach": - return runAttach(os.Args[2:]) - case "merge": - return runMerge(os.Args[2:]) - case "list": - return runList(os.Args[2:]) - case "cleanup": - return runCleanup(os.Args[2:]) - case "sync": - return runSync(os.Args[2:]) - case "--completions": - return runCompletions(os.Args[2:]) - case "-h", "--help", "help": - printUsage() - return nil - case "version", "--version": - fmt.Printf("opencode-worktree %s\n", version) - return nil - default: - fmt.Fprintf(os.Stderr, "%sUnknown command: %s\n\n", emoji("โŒ ", "error: "), os.Args[1]) - printUsage() - return errSilent +func newRootCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "opencode-worktree", + Short: "Git worktree manager for isolated OpenCode agent sessions", + Version: version, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, } -} - -func printUsage() { - fmt.Printf(`opencode-worktree %s - -Usage: opencode-worktree [options] - -Commands: - task [message] Create agent worktree and launch opencode - attach Reattach to an existing agent worktree session - merge [path] Merge agent branch back into parent - sync [path] Rebase agent branch onto latest parent - list Show active agent worktrees - cleanup Remove orphaned worktrees and branches - -Run 'opencode-worktree --help' for command-specific help. - -General: - -h, --help Show this help message - version, --version Show version - -Alias: - The installer adds 'ocwt' as a shell alias for opencode-worktree. -`, version) + cmd.SetVersionTemplate("{{.Name}} {{.Version}}\n") + + cmd.AddCommand( + newTaskCmd(), + newAttachCmd(), + newMergeCmd(), + newSyncCmd(), + newListCmd(), + newCleanupCmd(), + ) + + return cmd } diff --git a/go.mod b/go.mod index 6228a5c..aa33fee 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,13 @@ module github.com/danhenton/opencode-worktree go 1.24.0 -require github.com/gofrs/flock v0.12.1 +require ( + github.com/gofrs/flock v0.12.1 + github.com/spf13/cobra v1.10.2 +) -require golang.org/x/sys v0.33.0 // indirect +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.33.0 // indirect +) diff --git a/go.sum b/go.sum index 4532456..5435165 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,22 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 9ffa199d8505bf8f049048feb6e06c7e45c4fbc8 Mon Sep 17 00:00:00 2001 From: Dan Henton Date: Wed, 15 Apr 2026 10:28:14 +1200 Subject: [PATCH 2/4] refactor(cli): migrate all commands to cobra --- cmd/opencode-worktree/attach.go | 84 +++++++++------- cmd/opencode-worktree/cleanup.go | 61 ++++++------ cmd/opencode-worktree/completions.go | 35 ------- cmd/opencode-worktree/list.go | 33 ++++--- cmd/opencode-worktree/merge_cmd.go | 79 +++++++-------- cmd/opencode-worktree/sync_cmd.go | 85 +++++++--------- cmd/opencode-worktree/task.go | 143 +++++++++++++-------------- 7 files changed, 239 insertions(+), 281 deletions(-) delete mode 100644 cmd/opencode-worktree/completions.go diff --git a/cmd/opencode-worktree/attach.go b/cmd/opencode-worktree/attach.go index a664d51..ccd8ddb 100644 --- a/cmd/opencode-worktree/attach.go +++ b/cmd/opencode-worktree/attach.go @@ -1,58 +1,68 @@ package main import ( - "flag" "fmt" - "os" "github.com/danhenton/opencode-worktree/internal/git" "github.com/danhenton/opencode-worktree/internal/worktree" + "github.com/spf13/cobra" ) -func runAttach(args []string) error { - fs := flag.NewFlagSet("attach", flag.ContinueOnError) - noMerge := fs.Bool("no-merge", false, "Skip auto-merge after opencode exits") - fs.Usage = func() { - fmt.Fprint(os.Stderr, `Usage: opencode-worktree attach [--no-merge] +func newAttachCmd() *cobra.Command { + var noMerge bool -Reattach to an existing agent worktree session. + cmd := &cobra.Command{ + Use: "attach ", + Short: "Reattach to an existing agent worktree session", + Long: `Reattach to an existing agent worktree session. -Options: -`) - fs.PrintDefaults() - fmt.Fprint(os.Stderr, ` Examples: opencode-worktree attach fix-auth-bug - opencode-worktree attach fix-auth-bug --no-merge -`) - } - - if err := fs.Parse(reorderKnownBoolFlags(args, "--no-merge")); err != nil { - return errSilent - } + opencode-worktree attach fix-auth-bug --no-merge`, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("task name is required\n\nUsage: opencode-worktree attach [--no-merge]") + } + if len(args) > 1 { + return fmt.Errorf("unexpected extra argument: %s", args[1]) + } + return nil + }, + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + repoRoot, err := git.RepoRoot(".") + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + names, err := worktree.ActiveTaskNames(repoRoot) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return names, cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + taskName := args[0] - positional := fs.Args() - if len(positional) == 0 { - return fmt.Errorf("task name is required\n\nUsage: opencode-worktree attach [--no-merge]") - } - if len(positional) > 1 { - return fmt.Errorf("unexpected extra argument: %s", positional[1]) - } + repoRoot, err := git.RepoRoot(".") + if err != nil { + return fmt.Errorf("not inside a git repository") + } - taskName := positional[0] + worktreeDir, err := worktree.ResolveWorktreeDir(repoRoot, taskName) + if err != nil { + return err + } - repoRoot, err := git.RepoRoot(".") - if err != nil { - return fmt.Errorf("not inside a git repository") - } + fmt.Printf("%sAttaching to agent session: %s\n", emoji("๐Ÿ”— ", ""), taskName) + fmt.Printf(" Path: %s\n\n", worktreeDir) - worktreeDir, err := worktree.ResolveWorktreeDir(repoRoot, taskName) - if err != nil { - return err + return launchAndMaybeMerge(worktreeDir, "", noMerge) + }, } - fmt.Printf("%sAttaching to agent session: %s\n", emoji("๐Ÿ”— ", ""), taskName) - fmt.Printf(" Path: %s\n\n", worktreeDir) + cmd.Flags().BoolVarP(&noMerge, "no-merge", "n", false, "Skip auto-merge after opencode exits") - return launchAndMaybeMerge(worktreeDir, "", *noMerge) + return cmd } diff --git a/cmd/opencode-worktree/cleanup.go b/cmd/opencode-worktree/cleanup.go index 4e3b427..f1fb635 100644 --- a/cmd/opencode-worktree/cleanup.go +++ b/cmd/opencode-worktree/cleanup.go @@ -1,50 +1,47 @@ package main import ( - "flag" "fmt" - "os" "github.com/danhenton/opencode-worktree/internal/git" "github.com/danhenton/opencode-worktree/internal/worktree" + "github.com/spf13/cobra" ) -func runCleanup(args []string) error { - fs := flag.NewFlagSet("cleanup", flag.ContinueOnError) - dryRun := fs.Bool("dry-run", false, "Show what would be removed without removing anything") - yes := fs.Bool("yes", false, "Skip confirmation prompt") - fs.Usage = func() { - fmt.Fprint(os.Stderr, `Usage: opencode-worktree cleanup [--dry-run] [--yes] +func newCleanupCmd() *cobra.Command { + var dryRun bool + var yes bool -Remove orphaned agent worktrees and branches. + cmd := &cobra.Command{ + Use: "cleanup", + Short: "Remove orphaned worktrees and branches", + Long: `Remove orphaned agent worktrees and branches. -Options: -`) - fs.PrintDefaults() - fmt.Fprint(os.Stderr, ` Examples: opencode-worktree cleanup opencode-worktree cleanup --dry-run - opencode-worktree cleanup --yes -`) + opencode-worktree cleanup --yes`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + repoRoot, err := git.RepoRoot(".") + if err != nil { + return fmt.Errorf("not inside a git repository") + } + + fmt.Printf("%sCleaning up orphaned agent worktrees and branches...\n", emoji("๐Ÿงน ", "")) + opts := worktree.CleanupOptions{DryRun: dryRun, Yes: yes} + if err := worktree.Cleanup(repoRoot, opts); err != nil { + return err + } + if !dryRun { + fmt.Printf("%sCleanup complete.\n", emoji("โœ… ", "")) + } + return nil + }, } - if err := fs.Parse(reorderKnownBoolFlags(args, "--dry-run", "--yes")); err != nil { - return errSilent - } - - repoRoot, err := git.RepoRoot(".") - if err != nil { - return fmt.Errorf("not inside a git repository") - } + cmd.Flags().BoolVarP(&dryRun, "dry-run", "d", false, "Show what would be removed without removing anything") + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") - fmt.Printf("%sCleaning up orphaned agent worktrees and branches...\n", emoji("๐Ÿงน ", "")) - opts := worktree.CleanupOptions{DryRun: *dryRun, Yes: *yes} - if err := worktree.Cleanup(repoRoot, opts); err != nil { - return err - } - if !*dryRun { - fmt.Printf("%sCleanup complete.\n", emoji("โœ… ", "")) - } - return nil + return cmd } diff --git a/cmd/opencode-worktree/completions.go b/cmd/opencode-worktree/completions.go deleted file mode 100644 index d0d71f5..0000000 --- a/cmd/opencode-worktree/completions.go +++ /dev/null @@ -1,35 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/danhenton/opencode-worktree/internal/git" - "github.com/danhenton/opencode-worktree/internal/worktree" -) - -func runCompletions(args []string) error { - repoRoot, err := git.RepoRoot(".") - if err != nil { - return errSilent - } - - if len(args) == 0 { - for _, cmd := range []string{"task", "attach", "merge", "sync", "list", "cleanup"} { - fmt.Println(cmd) - } - return nil - } - - switch args[0] { - case "attach": - names, err := worktree.ActiveTaskNames(repoRoot) - if err != nil { - return errSilent - } - for _, name := range names { - fmt.Println(name) - } - } - - return nil -} diff --git a/cmd/opencode-worktree/list.go b/cmd/opencode-worktree/list.go index 31593bb..2f008d3 100644 --- a/cmd/opencode-worktree/list.go +++ b/cmd/opencode-worktree/list.go @@ -5,21 +5,28 @@ import ( "github.com/danhenton/opencode-worktree/internal/git" "github.com/danhenton/opencode-worktree/internal/worktree" + "github.com/spf13/cobra" ) -func runList(args []string) error { - _ = args +func newListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Show active agent worktrees", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + repoRoot, err := git.RepoRoot(".") + if err != nil { + return fmt.Errorf("not inside a git repository") + } - repoRoot, err := git.RepoRoot(".") - if err != nil { - return fmt.Errorf("not inside a git repository") + fmt.Printf("%sActive agent worktrees:\n", emoji("๐Ÿ—‚๏ธ ", "")) + out, err := worktree.List(repoRoot) + if err != nil { + return err + } + fmt.Println(out) + return nil + }, } - - fmt.Printf("%sActive agent worktrees:\n", emoji("๐Ÿ—‚๏ธ ", "")) - out, err := worktree.List(repoRoot) - if err != nil { - return err - } - fmt.Println(out) - return nil + return cmd } diff --git a/cmd/opencode-worktree/merge_cmd.go b/cmd/opencode-worktree/merge_cmd.go index 1123ecb..06e3d98 100644 --- a/cmd/opencode-worktree/merge_cmd.go +++ b/cmd/opencode-worktree/merge_cmd.go @@ -1,62 +1,53 @@ package main import ( - "flag" "fmt" - "os" "github.com/danhenton/opencode-worktree/internal/merge" + "github.com/spf13/cobra" ) -func runMerge(args []string) error { - fs := flag.NewFlagSet("merge", flag.ContinueOnError) - noCleanup := fs.Bool("no-cleanup", false, "Merge but keep worktree and branch") - fs.Usage = func() { - fmt.Fprint(os.Stderr, `Usage: opencode-worktree merge [path] [--no-cleanup] +func newMergeCmd() *cobra.Command { + var noCleanup bool -Merge agent branch back into parent. If no path is given, + cmd := &cobra.Command{ + Use: "merge [worktree-path]", + Short: "Merge agent branch back into parent", + Long: `Merge agent branch back into parent. If no path is given, auto-detects the current directory as an agent worktree. -Options: -`) - fs.PrintDefaults() - fmt.Fprint(os.Stderr, ` Examples: opencode-worktree merge opencode-worktree merge /path/to/worktree - opencode-worktree merge --no-cleanup -`) + opencode-worktree merge --no-cleanup`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var worktreePath string + if len(args) == 1 { + worktreePath = args[0] + } + + if worktreePath == "" { + detected, err := merge.DetectWorktree() + if err != nil { + return fmt.Errorf("%v\n\nUsage: opencode-worktree merge [worktree-path] [--no-cleanup]", err) + } + worktreePath = detected + } + + cleanup := !noCleanup + result, err := merge.Run(worktreePath, cleanup) + if err != nil { + if err := handleMergeError(result, err); err != nil { + return err + } + } + printMergeResult(result) + return nil + }, } - if err := fs.Parse(reorderKnownBoolFlags(args, "--no-cleanup")); err != nil { - return errSilent - } - - positional := fs.Args() - if len(positional) > 1 { - return fmt.Errorf("unexpected extra argument: %s", positional[1]) - } - - var worktreePath string - if len(positional) == 1 { - worktreePath = positional[0] - } - - if worktreePath == "" { - detected, err := merge.DetectWorktree() - if err != nil { - return fmt.Errorf("%v\n\nUsage: opencode-worktree merge [worktree-path] [--no-cleanup]", err) - } - worktreePath = detected - } + cmd.Flags().BoolVarP(&noCleanup, "no-cleanup", "c", false, "Merge but keep worktree and branch") - cleanup := !*noCleanup - result, err := merge.Run(worktreePath, cleanup) - if err != nil { - if err := handleMergeError(result, err); err != nil { - return err - } - } - printMergeResult(result) - return nil + return cmd } diff --git a/cmd/opencode-worktree/sync_cmd.go b/cmd/opencode-worktree/sync_cmd.go index 49736b9..37c6fa2 100644 --- a/cmd/opencode-worktree/sync_cmd.go +++ b/cmd/opencode-worktree/sync_cmd.go @@ -1,63 +1,54 @@ package main import ( - "flag" "fmt" - "os" "github.com/danhenton/opencode-worktree/internal/merge" "github.com/danhenton/opencode-worktree/internal/worktree" + "github.com/spf13/cobra" ) -func runSync(args []string) error { - fs := flag.NewFlagSet("sync", flag.ContinueOnError) - fs.Usage = func() { - fmt.Fprint(os.Stderr, `Usage: opencode-worktree sync [path] - -Rebase the agent branch onto the latest parent branch, pulling in +func newSyncCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "sync [worktree-path]", + Short: "Rebase agent branch onto latest parent", + Long: `Rebase the agent branch onto the latest parent branch, pulling in upstream changes. If no path is given, auto-detects the current directory as an agent worktree. Examples: opencode-worktree sync - opencode-worktree sync /path/to/worktree -`) - } - - if err := fs.Parse(args); err != nil { - return errSilent - } - - positional := fs.Args() - if len(positional) > 1 { - return fmt.Errorf("unexpected extra argument: %s", positional[1]) - } - - var worktreePath string - if len(positional) == 1 { - worktreePath = positional[0] + opencode-worktree sync /path/to/worktree`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var worktreePath string + if len(args) == 1 { + worktreePath = args[0] + } + + if worktreePath == "" { + detected, err := merge.DetectWorktree() + if err != nil { + return fmt.Errorf("%v\n\nUsage: opencode-worktree sync [worktree-path]", err) + } + worktreePath = detected + } + + result, err := worktree.Sync(worktreePath) + if err != nil { + if err := handleSyncError(result, err); err != nil { + return err + } + } + + if result.AlreadyCurrent { + fmt.Printf("%sAlready up to date with %s.\n", emoji("โœ… ", ""), result.ParentBranch) + return nil + } + + fmt.Printf("%sRebased %s onto %s.\n", emoji("โœ… ", ""), result.AgentBranch, result.ParentBranch) + return nil + }, } - - if worktreePath == "" { - detected, err := merge.DetectWorktree() - if err != nil { - return fmt.Errorf("%v\n\nUsage: opencode-worktree sync [worktree-path]", err) - } - worktreePath = detected - } - - result, err := worktree.Sync(worktreePath) - if err != nil { - if err := handleSyncError(result, err); err != nil { - return err - } - } - - if result.AlreadyCurrent { - fmt.Printf("%sAlready up to date with %s.\n", emoji("โœ… ", ""), result.ParentBranch) - return nil - } - - fmt.Printf("%sRebased %s onto %s.\n", emoji("โœ… ", ""), result.AgentBranch, result.ParentBranch) - return nil + return cmd } diff --git a/cmd/opencode-worktree/task.go b/cmd/opencode-worktree/task.go index efcbcff..fbb777e 100644 --- a/cmd/opencode-worktree/task.go +++ b/cmd/opencode-worktree/task.go @@ -1,92 +1,89 @@ package main import ( - "flag" "fmt" "os" "github.com/danhenton/opencode-worktree/internal/git" "github.com/danhenton/opencode-worktree/internal/worktree" + "github.com/spf13/cobra" ) -func runTask(args []string) error { - fs := flag.NewFlagSet("task", flag.ContinueOnError) - noMerge := fs.Bool("no-merge", false, "Skip auto-merge after opencode exits") - fs.Usage = func() { - fmt.Fprint(os.Stderr, `Usage: opencode-worktree task [message] [--no-merge] +func newTaskCmd() *cobra.Command { + var noMerge bool -Create an agent worktree and launch opencode in it. + cmd := &cobra.Command{ + Use: "task [message]", + Short: "Create agent worktree and launch opencode", + Long: `Create an agent worktree and launch opencode in it. -Options: -`) - fs.PrintDefaults() - fmt.Fprint(os.Stderr, ` Examples: opencode-worktree task fix-auth-bug opencode-worktree task fix-auth-bug "Fix the JWT token expiry bug" - opencode-worktree task add-feature --no-merge -`) + opencode-worktree task add-feature --no-merge`, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("task name is required\n\nUsage: opencode-worktree task [message] [--no-merge]") + } + if len(args) > 2 { + return fmt.Errorf("unexpected extra argument: %s", args[2]) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + taskName := args[0] + var initialPrompt string + if len(args) > 1 { + initialPrompt = args[1] + } + + if err := worktree.ValidateTaskName(taskName); err != nil { + return err + } + + repoRoot, err := git.RepoRoot(".") + if err != nil { + return fmt.Errorf("not inside a git repository") + } + + parentBranch, err := git.CurrentBranch(repoRoot) + if err != nil || parentBranch == "" { + return fmt.Errorf("not on a named branch (detached HEAD) โ€” checkout a branch first") + } + + exists, err := worktree.AlreadyExists(repoRoot, taskName) + if err != nil { + return err + } + if exists { + return fmt.Errorf("a worktree for '%s%s' already exists โ€” use 'opencode-worktree list' to see active sessions", worktree.BranchPrefix, taskName) + } + + worktreeDir := worktree.WorktreeDir(repoRoot, taskName) + branchName := worktree.BranchName(taskName) + + fmt.Printf("%sCreating worktree for task: %s\n", emoji("๐ŸŒฟ ", ""), taskName) + fmt.Printf(" Branch: %s\n", branchName) + fmt.Printf(" From: %s\n", parentBranch) + fmt.Printf(" Path: %s\n\n", worktreeDir) + + createdDir, err := worktree.Create(repoRoot, taskName, parentBranch) + if err != nil { + return err + } + + fmt.Printf("%sAgent session '%s' starting.\n", emoji("โœ… ", ""), taskName) + fmt.Printf(" Worktree: %s\n", createdDir) + if noMerge { + fmt.Fprintf(os.Stderr, " %s--no-merge is set. Run 'opencode-worktree merge' manually when done.\n", emoji("โš ๏ธ ", "Note: ")) + } + fmt.Println() + + return launchAndMaybeMerge(createdDir, initialPrompt, noMerge) + }, } - if err := fs.Parse(reorderKnownBoolFlags(args, "--no-merge")); err != nil { - return errSilent - } - - positional := fs.Args() - if len(positional) == 0 { - return fmt.Errorf("task name is required\n\nUsage: opencode-worktree task [message] [--no-merge]") - } - if len(positional) > 2 { - return fmt.Errorf("unexpected extra argument: %s", positional[2]) - } - - taskName := positional[0] - var initialPrompt string - if len(positional) > 1 { - initialPrompt = positional[1] - } - - if err := worktree.ValidateTaskName(taskName); err != nil { - return err - } - - repoRoot, err := git.RepoRoot(".") - if err != nil { - return fmt.Errorf("not inside a git repository") - } - - parentBranch, err := git.CurrentBranch(repoRoot) - if err != nil || parentBranch == "" { - return fmt.Errorf("not on a named branch (detached HEAD) โ€” checkout a branch first") - } - - exists, err := worktree.AlreadyExists(repoRoot, taskName) - if err != nil { - return err - } - if exists { - return fmt.Errorf("a worktree for '%s%s' already exists โ€” use 'opencode-worktree list' to see active sessions", worktree.BranchPrefix, taskName) - } - - worktreeDir := worktree.WorktreeDir(repoRoot, taskName) - branchName := worktree.BranchName(taskName) - - fmt.Printf("%sCreating worktree for task: %s\n", emoji("๐ŸŒฟ ", ""), taskName) - fmt.Printf(" Branch: %s\n", branchName) - fmt.Printf(" From: %s\n", parentBranch) - fmt.Printf(" Path: %s\n\n", worktreeDir) - - createdDir, err := worktree.Create(repoRoot, taskName, parentBranch) - if err != nil { - return err - } - - fmt.Printf("%sAgent session '%s' starting.\n", emoji("โœ… ", ""), taskName) - fmt.Printf(" Worktree: %s\n", createdDir) - if *noMerge { - fmt.Fprintf(os.Stderr, " %s--no-merge is set. Run 'opencode-worktree merge' manually when done.\n", emoji("โš ๏ธ ", "Note: ")) - } - fmt.Println() + cmd.Flags().BoolVarP(&noMerge, "no-merge", "n", false, "Skip auto-merge after opencode exits") - return launchAndMaybeMerge(createdDir, initialPrompt, *noMerge) + return cmd } From 29be4be5f4ceb5eb903f3c7a44f9126c9d8bb11f Mon Sep 17 00:00:00 2001 From: Dan Henton Date: Wed, 15 Apr 2026 10:28:20 +1200 Subject: [PATCH 3/4] test(cli): rewrite tests for cobra commands --- cmd/opencode-worktree/handlers_test.go | 268 +++++++++++++------------ cmd/opencode-worktree/routing_test.go | 179 ++++++----------- 2 files changed, 199 insertions(+), 248 deletions(-) diff --git a/cmd/opencode-worktree/handlers_test.go b/cmd/opencode-worktree/handlers_test.go index d7202e7..71128f7 100644 --- a/cmd/opencode-worktree/handlers_test.go +++ b/cmd/opencode-worktree/handlers_test.go @@ -1,7 +1,7 @@ package main import ( - "errors" + "bytes" "os" "os/exec" "path/filepath" @@ -13,11 +13,23 @@ import ( "github.com/danhenton/opencode-worktree/internal/worktree" ) -func TestRunTaskMissingName(t *testing.T) { +func newTestCmd() (*bytes.Buffer, *bytes.Buffer, func(args ...string) error) { + var outBuf, errBuf bytes.Buffer + return &outBuf, &errBuf, func(args ...string) error { + root := newRootCmd() + root.SetOut(&outBuf) + root.SetErr(&errBuf) + root.SetArgs(args) + return root.Execute() + } +} + +func TestTaskMissingName(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) - err := runTask([]string{}) + _, _, exec := newTestCmd() + err := exec("task") if err == nil { t.Fatal("expected error, got nil") } @@ -26,21 +38,23 @@ func TestRunTaskMissingName(t *testing.T) { } } -func TestRunTaskInvalidName(t *testing.T) { +func TestTaskInvalidName(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) - err := runTask([]string{"bad name with spaces"}) + _, _, run := newTestCmd() + err := run("task", "bad name with spaces") if err == nil { t.Fatal("expected error for invalid task name, got nil") } } -func TestRunTaskExtraArg(t *testing.T) { +func TestTaskExtraArg(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) - err := runTask([]string{"valid-name", "msg", "extra"}) + _, _, run := newTestCmd() + err := run("task", "valid-name", "msg", "extra") if err == nil { t.Fatal("expected error, got nil") } @@ -49,10 +63,11 @@ func TestRunTaskExtraArg(t *testing.T) { } } -func TestRunTaskNotInGitRepo(t *testing.T) { +func TestTaskNotInGitRepo(t *testing.T) { t.Chdir(t.TempDir()) - err := runTask([]string{"some-task"}) + _, _, run := newTestCmd() + err := run("task", "some-task") if err == nil { t.Fatal("expected error when not in git repo, got nil") } @@ -61,7 +76,7 @@ func TestRunTaskNotInGitRepo(t *testing.T) { } } -func TestRunTaskAlreadyExists(t *testing.T) { +func TestTaskAlreadyExists(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) @@ -75,7 +90,8 @@ func TestRunTaskAlreadyExists(t *testing.T) { t.Fatalf("failed to pre-create worktree: %v", err) } - err = runTask([]string{taskName}) + _, _, run := newTestCmd() + err = run("task", taskName) if err == nil { t.Fatal("expected error for already-existing worktree, got nil") } @@ -84,20 +100,18 @@ func TestRunTaskAlreadyExists(t *testing.T) { } } -func TestRunTaskUnknownFlag(t *testing.T) { +func TestTaskUnknownFlag(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) - err := runTask([]string{"--unknown-flag"}) + _, _, run := newTestCmd() + err := run("task", "--unknown-flag") if err == nil { t.Fatal("expected error for unknown flag, got nil") } - if !errors.Is(err, errSilent) { - t.Errorf("expected errSilent for unknown flag, got: %v", err) - } } -func TestRunTaskReturnsLaunchError(t *testing.T) { +func TestTaskReturnsLaunchError(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) gitPath, err := exec.LookPath("git") @@ -106,7 +120,8 @@ func TestRunTaskReturnsLaunchError(t *testing.T) { } t.Setenv("PATH", filepath.Dir(gitPath)) - err = runTask([]string{"launch-fails", "--no-merge"}) + _, _, run := newTestCmd() + err = run("task", "launch-fails", "--no-merge") if err == nil { t.Fatal("expected error when opencode is unavailable") } @@ -115,11 +130,45 @@ func TestRunTaskReturnsLaunchError(t *testing.T) { } } -func TestRunAttachMissingName(t *testing.T) { +func TestTaskBranchExistsWithoutWorktree(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + + parentBranch, err := git.CurrentBranch(repoDir) + if err != nil { + t.Fatalf("failed to get current branch: %v", err) + } + + worktreeDir, err := worktree.Create(repoDir, "existing-branch", parentBranch) + if err != nil { + t.Fatalf("failed to create worktree: %v", err) + } + if err := git.WorktreeRemove(repoDir, worktreeDir); err != nil { + t.Fatalf("failed to remove worktree: %v", err) + } + if err := git.WorktreePrune(repoDir); err != nil { + t.Fatalf("failed to prune worktrees: %v", err) + } + if _, statErr := os.Stat(filepath.Join(worktreeDir, ".agent-parent-branch")); !os.IsNotExist(statErr) { + t.Fatalf("expected worktree to be removed") + } + + _, _, run := newTestCmd() + err = run("task", "existing-branch", "--no-merge") + if err == nil { + t.Fatal("expected error when branch exists without worktree") + } + if !strings.Contains(err.Error(), "branch named 'agent/existing-branch' already exists") { + t.Errorf("expected existing branch error, got: %v", err) + } +} + +func TestAttachMissingName(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) - err := runAttach([]string{}) + _, _, run := newTestCmd() + err := run("attach") if err == nil { t.Fatal("expected error, got nil") } @@ -128,11 +177,12 @@ func TestRunAttachMissingName(t *testing.T) { } } -func TestRunAttachExtraArg(t *testing.T) { +func TestAttachExtraArg(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) - err := runAttach([]string{"name", "extra"}) + _, _, run := newTestCmd() + err := run("attach", "name", "extra") if err == nil { t.Fatal("expected error, got nil") } @@ -141,10 +191,11 @@ func TestRunAttachExtraArg(t *testing.T) { } } -func TestRunAttachNotInGitRepo(t *testing.T) { +func TestAttachNotInGitRepo(t *testing.T) { t.Chdir(t.TempDir()) - err := runAttach([]string{"some-task"}) + _, _, run := newTestCmd() + err := run("attach", "some-task") if err == nil { t.Fatal("expected error when not in git repo, got nil") } @@ -153,30 +204,29 @@ func TestRunAttachNotInGitRepo(t *testing.T) { } } -func TestRunAttachWorktreeNotFound(t *testing.T) { +func TestAttachWorktreeNotFound(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) - err := runAttach([]string{"nonexistent-task"}) + _, _, run := newTestCmd() + err := run("attach", "nonexistent-task") if err == nil { t.Fatal("expected error for nonexistent worktree, got nil") } } -func TestRunAttachUnknownFlag(t *testing.T) { +func TestAttachUnknownFlag(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) - err := runAttach([]string{"--unknown-flag"}) + _, _, run := newTestCmd() + err := run("attach", "--unknown-flag") if err == nil { t.Fatal("expected error for unknown flag, got nil") } - if !errors.Is(err, errSilent) { - t.Errorf("expected errSilent for unknown flag, got: %v", err) - } } -func TestRunAttachReturnsLaunchError(t *testing.T) { +func TestAttachReturnsLaunchError(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) gitPath, err := exec.LookPath("git") @@ -193,7 +243,8 @@ func TestRunAttachReturnsLaunchError(t *testing.T) { t.Fatalf("failed to create worktree: %v", err) } - err = runAttach([]string{"attach-launch-fails", "--no-merge"}) + _, _, run := newTestCmd() + err = run("attach", "attach-launch-fails", "--no-merge") if err == nil { t.Fatal("expected error when opencode is unavailable") } @@ -202,7 +253,7 @@ func TestRunAttachReturnsLaunchError(t *testing.T) { } } -func TestRunMergeAcceptsTrailingNoCleanupFlag(t *testing.T) { +func TestMergeAcceptsTrailingNoCleanupFlag(t *testing.T) { repoDir := testutil.NewTestRepo(t) parentBranch, err := git.CurrentBranch(repoDir) if err != nil { @@ -216,102 +267,99 @@ func TestRunMergeAcceptsTrailingNoCleanupFlag(t *testing.T) { testutil.CommitFile(t, worktreeDir, "merged.txt", "content", "Agent commit") - err = runMerge([]string{worktreeDir, "--no-cleanup"}) + _, _, run := newTestCmd() + err = run("merge", worktreeDir, "--no-cleanup") if err != nil { t.Fatalf("expected trailing --no-cleanup flag to parse, got: %v", err) } - if _, err := os.Stat(worktreeDir); os.IsNotExist(err) { + if _, statErr := os.Stat(worktreeDir); os.IsNotExist(statErr) { t.Fatalf("expected worktree to be preserved when --no-cleanup is set") } } -func TestRunMergeNotInAgentWorktree(t *testing.T) { +func TestMergeNotInAgentWorktree(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) - err := runMerge([]string{}) + _, _, run := newTestCmd() + err := run("merge") if err == nil { t.Fatal("expected error when not in agent worktree, got nil") } } -func TestRunMergeExtraArgs(t *testing.T) { +func TestMergeExtraArgs(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) - err := runMerge([]string{"arg1", "arg2"}) + _, _, run := newTestCmd() + err := run("merge", "arg1", "arg2") if err == nil { t.Fatal("expected error, got nil") } - if !strings.Contains(err.Error(), "unexpected extra argument") { - t.Errorf("expected 'unexpected extra argument', got: %v", err) - } } -func TestRunMergeUnknownFlag(t *testing.T) { +func TestMergeUnknownFlag(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) - err := runMerge([]string{"--unknown-flag"}) + _, _, run := newTestCmd() + err := run("merge", "--unknown-flag") if err == nil { t.Fatal("expected error for unknown flag, got nil") } - if !errors.Is(err, errSilent) { - t.Errorf("expected errSilent for unknown flag, got: %v", err) - } } -func TestRunSyncNotInAgentWorktree(t *testing.T) { +func TestSyncNotInAgentWorktree(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) - err := runSync([]string{}) + _, _, run := newTestCmd() + err := run("sync") if err == nil { t.Fatal("expected error when not in agent worktree, got nil") } } -func TestRunSyncExtraArgs(t *testing.T) { +func TestSyncExtraArgs(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) - err := runSync([]string{"arg1", "arg2"}) + _, _, run := newTestCmd() + err := run("sync", "arg1", "arg2") if err == nil { t.Fatal("expected error, got nil") } - if !strings.Contains(err.Error(), "unexpected extra argument") { - t.Errorf("expected 'unexpected extra argument', got: %v", err) - } } -func TestRunSyncUnknownFlag(t *testing.T) { +func TestSyncUnknownFlag(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) - err := runSync([]string{"--unknown-flag"}) + _, _, run := newTestCmd() + err := run("sync", "--unknown-flag") if err == nil { t.Fatal("expected error for unknown flag, got nil") } - if !errors.Is(err, errSilent) { - t.Errorf("expected errSilent for unknown flag, got: %v", err) - } } -func TestRunListNoWorktrees(t *testing.T) { +func TestListNoWorktrees(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) - err := runList([]string{}) + _, _, run := newTestCmd() + err := run("list") if err != nil { t.Errorf("expected nil error for list in empty repo, got: %v", err) } } -func TestRunListNotInGitRepo(t *testing.T) { +func TestListNotInGitRepo(t *testing.T) { t.Chdir(t.TempDir()) - err := runList([]string{}) + _, _, run := newTestCmd() + err := run("list") if err == nil { t.Fatal("expected error when not in git repo, got nil") } @@ -320,30 +368,44 @@ func TestRunListNotInGitRepo(t *testing.T) { } } -func TestRunCleanupInGitRepo(t *testing.T) { +func TestListExtraArg(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) - err := runCleanup([]string{"--yes"}) + _, _, run := newTestCmd() + err := run("list", "extra-arg") + if err == nil { + t.Fatal("expected error for extra arg to list, got nil") + } +} + +func TestCleanupInGitRepo(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + + _, _, run := newTestCmd() + err := run("cleanup", "--yes") if err != nil { t.Errorf("expected nil error for cleanup in git repo, got: %v", err) } } -func TestRunCleanupDryRun(t *testing.T) { +func TestCleanupDryRun(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) - err := runCleanup([]string{"--dry-run"}) + _, _, run := newTestCmd() + err := run("cleanup", "--dry-run") if err != nil { t.Errorf("expected nil error for --dry-run cleanup, got: %v", err) } } -func TestRunCleanupNotInGitRepo(t *testing.T) { +func TestCleanupNotInGitRepo(t *testing.T) { t.Chdir(t.TempDir()) - err := runCleanup([]string{"--dry-run"}) + _, _, run := newTestCmd() + err := run("cleanup", "--dry-run") if err == nil { t.Fatal("expected error when not in git repo, got nil") } @@ -352,76 +414,24 @@ func TestRunCleanupNotInGitRepo(t *testing.T) { } } -func TestRunCleanupUnknownFlag(t *testing.T) { +func TestCleanupUnknownFlag(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) - err := runCleanup([]string{"--unknown-flag"}) + _, _, run := newTestCmd() + err := run("cleanup", "--unknown-flag") if err == nil { t.Fatal("expected error for unknown flag, got nil") } - if !errors.Is(err, errSilent) { - t.Errorf("expected errSilent for unknown flag, got: %v", err) - } -} - -func TestRunCompletionsNoArgs(t *testing.T) { - repoDir := testutil.NewTestRepo(t) - t.Chdir(repoDir) - - err := runCompletions([]string{}) - if err != nil { - t.Errorf("expected nil error for completions with no args, got: %v", err) - } -} - -func TestRunCompletionsAttachSubcommand(t *testing.T) { - repoDir := testutil.NewTestRepo(t) - t.Chdir(repoDir) - - err := runCompletions([]string{"attach"}) - if err != nil { - t.Errorf("expected nil error for completions attach in repo, got: %v", err) - } -} - -func TestRunCompletionsNotInGitRepo(t *testing.T) { - t.Chdir(t.TempDir()) - - err := runCompletions([]string{}) - if err != nil && !errors.Is(err, errSilent) { - t.Errorf("expected nil or errSilent when not in git repo, got: %v", err) - } } -func TestRunTaskBranchExistsWithoutWorktree(t *testing.T) { +func TestCleanupExtraArg(t *testing.T) { repoDir := testutil.NewTestRepo(t) t.Chdir(repoDir) - parentBranch, err := git.CurrentBranch(repoDir) - if err != nil { - t.Fatalf("failed to get current branch: %v", err) - } - - worktreeDir, err := worktree.Create(repoDir, "existing-branch", parentBranch) - if err != nil { - t.Fatalf("failed to create worktree: %v", err) - } - if err := git.WorktreeRemove(repoDir, worktreeDir); err != nil { - t.Fatalf("failed to remove worktree: %v", err) - } - if err := git.WorktreePrune(repoDir); err != nil { - t.Fatalf("failed to prune worktrees: %v", err) - } - if _, err := os.Stat(filepath.Join(worktreeDir, ".agent-parent-branch")); !os.IsNotExist(err) { - t.Fatalf("expected worktree to be removed") - } - - err = runTask([]string{"existing-branch", "--no-merge"}) + _, _, run := newTestCmd() + err := run("cleanup", "extra-arg") if err == nil { - t.Fatal("expected error when branch exists without worktree") - } - if !strings.Contains(err.Error(), "branch named 'agent/existing-branch' already exists") { - t.Errorf("expected existing branch error, got: %v", err) + t.Fatal("expected error for extra arg to cleanup, got nil") } } diff --git a/cmd/opencode-worktree/routing_test.go b/cmd/opencode-worktree/routing_test.go index 26d8407..41ddab7 100644 --- a/cmd/opencode-worktree/routing_test.go +++ b/cmd/opencode-worktree/routing_test.go @@ -3,156 +3,97 @@ package main import ( "bytes" "errors" - "io" - "os" "strings" "testing" ) -func TestRunNoSubcommand(t *testing.T) { - origArgs := os.Args - defer func() { os.Args = origArgs }() - os.Args = []string{"opencode-worktree"} - - err := run() - if !errors.Is(err, errSilent) { - t.Errorf("expected errSilent, got %v", err) +func TestRouteNoSubcommand(t *testing.T) { + root := newRootCmd() + var outBuf, errBuf bytes.Buffer + root.SetOut(&outBuf) + root.SetErr(&errBuf) + root.SetArgs([]string{}) + err := root.Execute() + if err != nil { + t.Errorf("expected nil error for no subcommand, got %v", err) } } -func TestRunUnknownCommand(t *testing.T) { - origArgs := os.Args - defer func() { os.Args = origArgs }() - os.Args = []string{"opencode-worktree", "unknown-cmd"} - - r, w, _ := os.Pipe() - origStderr := os.Stderr - os.Stderr = w - defer func() { os.Stderr = origStderr; w.Close() }() - - err := run() - - w.Close() - os.Stderr = origStderr - - var buf bytes.Buffer - io.Copy(&buf, r) - - if !errors.Is(err, errSilent) { - t.Errorf("expected errSilent for unknown command, got %v", err) - } - if !strings.Contains(buf.String(), "Unknown command") { - t.Errorf("expected stderr to contain 'Unknown command', got: %q", buf.String()) +func TestRouteUnknownCommand(t *testing.T) { + root := newRootCmd() + var outBuf, errBuf bytes.Buffer + root.SetOut(&outBuf) + root.SetErr(&errBuf) + root.SetArgs([]string{"unknown-cmd"}) + err := root.Execute() + if err == nil { + t.Errorf("expected non-nil error for unknown command, got nil") } } -func TestRunHelpShortFlag(t *testing.T) { - origArgs := os.Args - defer func() { os.Args = origArgs }() - os.Args = []string{"opencode-worktree", "-h"} - - r, w, _ := os.Pipe() - origStdout := os.Stdout - os.Stdout = w - defer func() { os.Stdout = origStdout; w.Close() }() - - err := run() - - w.Close() - os.Stdout = origStdout - io.Copy(io.Discard, r) - +func TestRouteHelpShortFlag(t *testing.T) { + root := newRootCmd() + var outBuf, errBuf bytes.Buffer + root.SetOut(&outBuf) + root.SetErr(&errBuf) + root.SetArgs([]string{"-h"}) + err := root.Execute() if err != nil { t.Errorf("expected nil for -h, got %v", err) } } -func TestRunHelpLongFlag(t *testing.T) { - origArgs := os.Args - defer func() { os.Args = origArgs }() - os.Args = []string{"opencode-worktree", "--help"} - - r, w, _ := os.Pipe() - origStdout := os.Stdout - os.Stdout = w - defer func() { os.Stdout = origStdout; w.Close() }() - - err := run() - - w.Close() - os.Stdout = origStdout - io.Copy(io.Discard, r) - +func TestRouteHelpLongFlag(t *testing.T) { + root := newRootCmd() + var outBuf, errBuf bytes.Buffer + root.SetOut(&outBuf) + root.SetErr(&errBuf) + root.SetArgs([]string{"--help"}) + err := root.Execute() if err != nil { t.Errorf("expected nil for --help, got %v", err) } } -func TestRunHelpSubcommand(t *testing.T) { - origArgs := os.Args - defer func() { os.Args = origArgs }() - os.Args = []string{"opencode-worktree", "help"} - - r, w, _ := os.Pipe() - origStdout := os.Stdout - os.Stdout = w - defer func() { os.Stdout = origStdout; w.Close() }() - - err := run() - - w.Close() - os.Stdout = origStdout - io.Copy(io.Discard, r) - +func TestRouteHelpSubcommand(t *testing.T) { + root := newRootCmd() + var outBuf, errBuf bytes.Buffer + root.SetOut(&outBuf) + root.SetErr(&errBuf) + root.SetArgs([]string{"help"}) + err := root.Execute() if err != nil { t.Errorf("expected nil for help subcommand, got %v", err) } } -func TestRunVersionSubcommand(t *testing.T) { - origArgs := os.Args - defer func() { os.Args = origArgs }() - os.Args = []string{"opencode-worktree", "version"} - - r, w, _ := os.Pipe() - origStdout := os.Stdout - os.Stdout = w - defer func() { os.Stdout = origStdout; w.Close() }() - - err := run() - - w.Close() - os.Stdout = origStdout - - var buf bytes.Buffer - io.Copy(&buf, r) - +func TestRouteVersionFlag(t *testing.T) { + root := newRootCmd() + var outBuf, errBuf bytes.Buffer + root.SetOut(&outBuf) + root.SetErr(&errBuf) + root.SetArgs([]string{"--version"}) + err := root.Execute() if err != nil { - t.Errorf("expected nil for version subcommand, got %v", err) + t.Errorf("expected nil for --version, got %v", err) } - if !strings.Contains(buf.String(), "opencode-worktree dev\n") { - t.Errorf("expected version output 'opencode-worktree dev\\n', got: %q", buf.String()) + if !strings.Contains(outBuf.String(), "opencode-worktree") { + t.Errorf("expected version output to contain 'opencode-worktree', got: %q", outBuf.String()) } } -func TestRunVersionFlag(t *testing.T) { - origArgs := os.Args - defer func() { os.Args = origArgs }() - os.Args = []string{"opencode-worktree", "--version"} - - r, w, _ := os.Pipe() - origStdout := os.Stdout - os.Stdout = w - defer func() { os.Stdout = origStdout; w.Close() }() - - err := run() - - w.Close() - os.Stdout = origStdout - io.Copy(io.Discard, r) - +func TestRouteVersionShortFlag(t *testing.T) { + root := newRootCmd() + var outBuf, errBuf bytes.Buffer + root.SetOut(&outBuf) + root.SetErr(&errBuf) + root.SetArgs([]string{"-v"}) + err := root.Execute() if err != nil { - t.Errorf("expected nil for --version, got %v", err) + t.Errorf("expected nil for -v, got %v", err) + } + if !strings.Contains(outBuf.String(), "opencode-worktree") { + t.Errorf("expected version output to contain 'opencode-worktree', got: %q", outBuf.String()) } } From 68402e04f66fde19b7533ec952baea83077ca1c8 Mon Sep 17 00:00:00 2001 From: Dan Henton Date: Wed, 15 Apr 2026 10:28:28 +1200 Subject: [PATCH 4/4] fix(install): update shell completions for cobra --- install.sh | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/install.sh b/install.sh index 9b6e130..5d8ce1a 100755 --- a/install.sh +++ b/install.sh @@ -53,24 +53,10 @@ install_completions() { completion_marker="# opencode-worktree completions" zsh_snippet='# opencode-worktree completions -_opencode_worktree() { - if (( CURRENT == 2 )); then - compadd $(opencode-worktree --completions 2>/dev/null) - elif (( CURRENT == 3 )); then - compadd $(opencode-worktree --completions ${words[2]} 2>/dev/null) - fi -} -compdef _opencode_worktree opencode-worktree' +eval "$(opencode-worktree completion zsh)"' bash_snippet='# opencode-worktree completions -_opencode_worktree() { - if [ "${#COMP_WORDS[@]}" -eq 2 ]; then - COMPREPLY=($(compgen -W "$(opencode-worktree --completions 2>/dev/null)" -- "${COMP_WORDS[1]}")) - elif [ "${#COMP_WORDS[@]}" -eq 3 ]; then - COMPREPLY=($(compgen -W "$(opencode-worktree --completions "${COMP_WORDS[1]}" 2>/dev/null)" -- "${COMP_WORDS[2]}")) - fi -} -complete -F _opencode_worktree opencode-worktree' +eval "$(opencode-worktree completion bash)"' shell="$(basename "${SHELL:-}")" rc_file=""