diff --git a/AGENTS.md b/AGENTS.md index 8cd68ed..51195ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,15 @@ go fmt ./... ## Project Structure -- `cmd/opencode-worktree/main.go` — Entry point, subcommand routing, flag parsing +- `cmd/opencode-worktree/main.go` — Entry point, routing switch (`run()`), usage output, `errSilent` dispatch +- `cmd/opencode-worktree/task.go` — `runTask` handler +- `cmd/opencode-worktree/attach.go` — `runAttach` handler +- `cmd/opencode-worktree/merge_cmd.go` — `runMerge` handler +- `cmd/opencode-worktree/sync_cmd.go` — `runSync` handler +- `cmd/opencode-worktree/list.go` — `runList` handler +- `cmd/opencode-worktree/cleanup.go` — `runCleanup` handler +- `cmd/opencode-worktree/completions.go` — `runCompletions` handler +- `cmd/opencode-worktree/output.go` — Shared output helpers: `emoji`, `printMergeResult`, `handleMergeError`, `handleSyncError`, `errSilent` - `internal/git/` — Thin wrappers around `exec.Command("git", ...)`, no abstractions - `internal/worktree/` — Create, list, cleanup worktrees; launch opencode; copy config - `internal/merge/` — Merge agent branch into parent with flock serialization diff --git a/cmd/opencode-worktree/attach.go b/cmd/opencode-worktree/attach.go new file mode 100644 index 0000000..a664d51 --- /dev/null +++ b/cmd/opencode-worktree/attach.go @@ -0,0 +1,58 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/danhenton/opencode-worktree/internal/git" + "github.com/danhenton/opencode-worktree/internal/worktree" +) + +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] + +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 + } + + 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]) + } + + taskName := positional[0] + + repoRoot, err := git.RepoRoot(".") + if err != nil { + return fmt.Errorf("not inside a git repository") + } + + worktreeDir, err := worktree.ResolveWorktreeDir(repoRoot, taskName) + if err != nil { + return err + } + + fmt.Printf("%sAttaching to agent session: %s\n", emoji("🔗 ", ""), taskName) + fmt.Printf(" Path: %s\n\n", worktreeDir) + + return launchAndMaybeMerge(worktreeDir, "", *noMerge) +} diff --git a/cmd/opencode-worktree/cleanup.go b/cmd/opencode-worktree/cleanup.go new file mode 100644 index 0000000..4e3b427 --- /dev/null +++ b/cmd/opencode-worktree/cleanup.go @@ -0,0 +1,50 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/danhenton/opencode-worktree/internal/git" + "github.com/danhenton/opencode-worktree/internal/worktree" +) + +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] + +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 +`) + } + + 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") + } + + 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 +} diff --git a/cmd/opencode-worktree/completions.go b/cmd/opencode-worktree/completions.go new file mode 100644 index 0000000..d0d71f5 --- /dev/null +++ b/cmd/opencode-worktree/completions.go @@ -0,0 +1,35 @@ +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/flags.go b/cmd/opencode-worktree/flags.go new file mode 100644 index 0000000..a47337b --- /dev/null +++ b/cmd/opencode-worktree/flags.go @@ -0,0 +1,24 @@ +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/handlers_test.go b/cmd/opencode-worktree/handlers_test.go new file mode 100644 index 0000000..d7202e7 --- /dev/null +++ b/cmd/opencode-worktree/handlers_test.go @@ -0,0 +1,427 @@ +package main + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/danhenton/opencode-worktree/internal/git" + "github.com/danhenton/opencode-worktree/internal/testutil" + "github.com/danhenton/opencode-worktree/internal/worktree" +) + +func TestRunTaskMissingName(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + + err := runTask([]string{}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "task name is required") { + t.Errorf("expected 'task name is required', got: %v", err) + } +} + +func TestRunTaskInvalidName(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + + err := runTask([]string{"bad name with spaces"}) + if err == nil { + t.Fatal("expected error for invalid task name, got nil") + } +} + +func TestRunTaskExtraArg(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + + err := runTask([]string{"valid-name", "msg", "extra"}) + 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 TestRunTaskNotInGitRepo(t *testing.T) { + t.Chdir(t.TempDir()) + + err := runTask([]string{"some-task"}) + if err == nil { + t.Fatal("expected error when not in git repo, got nil") + } + if !strings.Contains(err.Error(), "not inside a git repository") { + t.Errorf("expected 'not inside a git repository', got: %v", err) + } +} + +func TestRunTaskAlreadyExists(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) + } + + taskName := "my-existing-task" + if _, err := worktree.Create(repoDir, taskName, parentBranch); err != nil { + t.Fatalf("failed to pre-create worktree: %v", err) + } + + err = runTask([]string{taskName}) + if err == nil { + t.Fatal("expected error for already-existing worktree, got nil") + } + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("expected 'already exists' in error, got: %v", err) + } +} + +func TestRunTaskUnknownFlag(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + + err := runTask([]string{"--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) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + gitPath, err := exec.LookPath("git") + if err != nil { + t.Fatalf("failed to find git: %v", err) + } + t.Setenv("PATH", filepath.Dir(gitPath)) + + err = runTask([]string{"launch-fails", "--no-merge"}) + if err == nil { + t.Fatal("expected error when opencode is unavailable") + } + if !strings.Contains(err.Error(), "opencode not found in PATH") { + t.Errorf("expected missing opencode error, got: %v", err) + } +} + +func TestRunAttachMissingName(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + + err := runAttach([]string{}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "task name is required") { + t.Errorf("expected 'task name is required', got: %v", err) + } +} + +func TestRunAttachExtraArg(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + + err := runAttach([]string{"name", "extra"}) + 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 TestRunAttachNotInGitRepo(t *testing.T) { + t.Chdir(t.TempDir()) + + err := runAttach([]string{"some-task"}) + if err == nil { + t.Fatal("expected error when not in git repo, got nil") + } + if !strings.Contains(err.Error(), "not inside a git repository") { + t.Errorf("expected 'not inside a git repository', got: %v", err) + } +} + +func TestRunAttachWorktreeNotFound(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + + err := runAttach([]string{"nonexistent-task"}) + if err == nil { + t.Fatal("expected error for nonexistent worktree, got nil") + } +} + +func TestRunAttachUnknownFlag(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + + err := runAttach([]string{"--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) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + gitPath, err := exec.LookPath("git") + if err != nil { + t.Fatalf("failed to find git: %v", err) + } + t.Setenv("PATH", filepath.Dir(gitPath)) + + parentBranch, err := git.CurrentBranch(repoDir) + if err != nil { + t.Fatalf("failed to get current branch: %v", err) + } + if _, err := worktree.Create(repoDir, "attach-launch-fails", parentBranch); err != nil { + t.Fatalf("failed to create worktree: %v", err) + } + + err = runAttach([]string{"attach-launch-fails", "--no-merge"}) + if err == nil { + t.Fatal("expected error when opencode is unavailable") + } + if !strings.Contains(err.Error(), "opencode not found in PATH") { + t.Errorf("expected missing opencode error, got: %v", err) + } +} + +func TestRunMergeAcceptsTrailingNoCleanupFlag(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + parentBranch, err := git.CurrentBranch(repoDir) + if err != nil { + t.Fatalf("failed to get current branch: %v", err) + } + + worktreeDir, err := worktree.Create(repoDir, "merge-trailing-flag", parentBranch) + if err != nil { + t.Fatalf("failed to create worktree: %v", err) + } + + testutil.CommitFile(t, worktreeDir, "merged.txt", "content", "Agent commit") + + err = runMerge([]string{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) { + t.Fatalf("expected worktree to be preserved when --no-cleanup is set") + } +} + +func TestRunMergeNotInAgentWorktree(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + + err := runMerge([]string{}) + if err == nil { + t.Fatal("expected error when not in agent worktree, got nil") + } +} + +func TestRunMergeExtraArgs(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + + err := runMerge([]string{"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) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + + err := runMerge([]string{"--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) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + + err := runSync([]string{}) + if err == nil { + t.Fatal("expected error when not in agent worktree, got nil") + } +} + +func TestRunSyncExtraArgs(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + + err := runSync([]string{"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) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + + err := runSync([]string{"--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) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + + err := runList([]string{}) + if err != nil { + t.Errorf("expected nil error for list in empty repo, got: %v", err) + } +} + +func TestRunListNotInGitRepo(t *testing.T) { + t.Chdir(t.TempDir()) + + err := runList([]string{}) + if err == nil { + t.Fatal("expected error when not in git repo, got nil") + } + if !strings.Contains(err.Error(), "not inside a git repository") { + t.Errorf("expected 'not inside a git repository', got: %v", err) + } +} + +func TestRunCleanupInGitRepo(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + + err := runCleanup([]string{"--yes"}) + if err != nil { + t.Errorf("expected nil error for cleanup in git repo, got: %v", err) + } +} + +func TestRunCleanupDryRun(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + + err := runCleanup([]string{"--dry-run"}) + if err != nil { + t.Errorf("expected nil error for --dry-run cleanup, got: %v", err) + } +} + +func TestRunCleanupNotInGitRepo(t *testing.T) { + t.Chdir(t.TempDir()) + + err := runCleanup([]string{"--dry-run"}) + if err == nil { + t.Fatal("expected error when not in git repo, got nil") + } + if !strings.Contains(err.Error(), "not inside a git repository") { + t.Errorf("expected 'not inside a git repository', got: %v", err) + } +} + +func TestRunCleanupUnknownFlag(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + t.Chdir(repoDir) + + err := runCleanup([]string{"--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) { + 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"}) + 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) + } +} diff --git a/cmd/opencode-worktree/list.go b/cmd/opencode-worktree/list.go new file mode 100644 index 0000000..31593bb --- /dev/null +++ b/cmd/opencode-worktree/list.go @@ -0,0 +1,25 @@ +package main + +import ( + "fmt" + + "github.com/danhenton/opencode-worktree/internal/git" + "github.com/danhenton/opencode-worktree/internal/worktree" +) + +func runList(args []string) error { + _ = args + + 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 +} diff --git a/cmd/opencode-worktree/main.go b/cmd/opencode-worktree/main.go index 5913439..9542823 100644 --- a/cmd/opencode-worktree/main.go +++ b/cmd/opencode-worktree/main.go @@ -1,47 +1,54 @@ package main import ( - "flag" + "errors" "fmt" "os" - - "github.com/danhenton/opencode-worktree/internal/git" - "github.com/danhenton/opencode-worktree/internal/merge" - "github.com/danhenton/opencode-worktree/internal/worktree" ) // 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 !errors.Is(err, errSilent) { + fmt.Fprint(os.Stderr, emoji("❌ ", "error: ")+err.Error()+"\n") + } + os.Exit(1) + } +} + +func run() error { if len(os.Args) < 2 { printUsage() - os.Exit(1) + return errSilent } switch os.Args[1] { case "task": - runTask(os.Args[2:]) + return runTask(os.Args[2:]) case "attach": - runAttach(os.Args[2:]) + return runAttach(os.Args[2:]) case "merge": - runMerge(os.Args[2:]) + return runMerge(os.Args[2:]) case "list": - runList() + return runList(os.Args[2:]) case "cleanup": - runCleanup(os.Args[2:]) + return runCleanup(os.Args[2:]) case "sync": - runSync(os.Args[2:]) + return runSync(os.Args[2:]) case "--completions": - runCompletions(os.Args[2:]) + 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() - os.Exit(1) + return errSilent } } @@ -68,412 +75,3 @@ Alias: The installer adds 'ocwt' as a shell alias for opencode-worktree. `, version) } - -func runTask(args []string) { - 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] - -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 -`) - } - - if err := fs.Parse(args); err != nil { - os.Exit(1) - } - - positional := fs.Args() - if len(positional) == 0 { - exitError("task name is required\n\nUsage: opencode-worktree task [message] [--no-merge]") - } - if len(positional) > 2 { - exitError("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 { - exitError("%v", err) - } - - repoRoot, err := git.RepoRoot(".") - if err != nil { - exitError("not inside a git repository") - } - - parentBranch, err := git.CurrentBranch(repoRoot) - if err != nil || parentBranch == "" { - exitError("not on a named branch (detached HEAD) — checkout a branch first") - } - - exists, err := worktree.AlreadyExists(repoRoot, taskName) - if err != nil { - exitError("%v", err) - } - if exists { - exitError("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 { - exitError("%v", 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() - - _ = worktree.LaunchOpenCode(createdDir, initialPrompt) - - if *noMerge { - return - } - - fmt.Println() - result, err := merge.Run(createdDir, true) - if err != nil { - handleMergeError(result, err) - } - printMergeResult(result) -} - -func runAttach(args []string) { - 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] - -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(args); err != nil { - os.Exit(1) - } - - positional := fs.Args() - if len(positional) == 0 { - exitError("task name is required\n\nUsage: opencode-worktree attach [--no-merge]") - } - if len(positional) > 1 { - exitError("unexpected extra argument: %s", positional[1]) - } - - taskName := positional[0] - - repoRoot, err := git.RepoRoot(".") - if err != nil { - exitError("not inside a git repository") - } - - worktreeDir, err := worktree.ResolveWorktreeDir(repoRoot, taskName) - if err != nil { - exitError("%v", err) - } - - fmt.Printf("%sAttaching to agent session: %s\n", emoji("🔗 ", ""), taskName) - fmt.Printf(" Path: %s\n\n", worktreeDir) - - _ = worktree.LaunchOpenCode(worktreeDir, "") - - if *noMerge { - return - } - - fmt.Println() - result, err := merge.Run(worktreeDir, true) - if err != nil { - handleMergeError(result, err) - } - printMergeResult(result) -} - -func runCompletions(args []string) { - repoRoot, err := git.RepoRoot(".") - if err != nil { - os.Exit(1) - } - - if len(args) == 0 { - for _, cmd := range []string{"task", "attach", "merge", "sync", "list", "cleanup"} { - fmt.Println(cmd) - } - return - } - - switch args[0] { - case "attach": - names, err := worktree.ActiveTaskNames(repoRoot) - if err != nil { - os.Exit(1) - } - for _, name := range names { - fmt.Println(name) - } - } -} - -func runMerge(args []string) { - 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] - -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 -`) - } - - if err := fs.Parse(args); err != nil { - os.Exit(1) - } - - positional := fs.Args() - if len(positional) > 1 { - exitError("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 { - exitError("%v\n\nUsage: opencode-worktree merge [worktree-path] [--no-cleanup]") - } - worktreePath = detected - } - - cleanup := !*noCleanup - result, err := merge.Run(worktreePath, cleanup) - if err != nil { - handleMergeError(result, err) - } - printMergeResult(result) -} - -func runSync(args []string) { - 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 -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 { - os.Exit(1) - } - - positional := fs.Args() - if len(positional) > 1 { - exitError("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 { - exitError("%v\n\nUsage: opencode-worktree sync [worktree-path]") - } - worktreePath = detected - } - - result, err := worktree.Sync(worktreePath) - if err != nil { - handleSyncError(result, err) - } - - if result.AlreadyCurrent { - fmt.Printf("%sAlready up to date with %s.\n", emoji("✅ ", ""), result.ParentBranch) - return - } - - fmt.Printf("%sRebased %s onto %s.\n", emoji("✅ ", ""), result.AgentBranch, result.ParentBranch) -} - -func handleSyncError(result *worktree.SyncResult, err error) { - if result != nil && len(result.ConflictFiles) > 0 { - fmt.Fprintf(os.Stderr, "%sRebase conflict: %s onto %s\n", emoji("❌ ", "error: "), result.AgentBranch, result.ParentBranch) - fmt.Fprintln(os.Stderr, "Conflicting files:") - for _, f := range result.ConflictFiles { - fmt.Fprintf(os.Stderr, " %s\n", f) - } - fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, "The rebase was aborted. To resolve manually:") - fmt.Fprintf(os.Stderr, " cd %s\n", result.WorktreePath) - fmt.Fprintf(os.Stderr, " git rebase %s\n", result.ParentBranch) - fmt.Fprintln(os.Stderr, " # Fix conflicts in the listed files") - fmt.Fprintln(os.Stderr, " git add ") - fmt.Fprintln(os.Stderr, " git rebase --continue") - os.Exit(1) - } - exitError("%v", err) -} - -func runList() { - repoRoot, err := git.RepoRoot(".") - if err != nil { - exitError("not inside a git repository") - } - - fmt.Printf("%sActive agent worktrees:\n", emoji("🗂️ ", "")) - out, err := worktree.List(repoRoot) - if err != nil { - exitError("%v", err) - } - fmt.Println(out) -} - -func runCleanup(args []string) { - 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] - -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 -`) - } - - if err := fs.Parse(args); err != nil { - os.Exit(1) - } - - repoRoot, err := git.RepoRoot(".") - if err != nil { - exitError("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 { - exitError("%v", err) - } - if !*dryRun { - fmt.Printf("%sCleanup complete.\n", emoji("✅ ", "")) - } -} - -func printMergeResult(result *merge.Result) { - if result.DirtyWorktree { - fmt.Fprintf(os.Stderr, "%sWorktree has uncommitted changes — preserved at: %s\n", emoji("⚠️ ", "warning: "), result.WorktreePath) - fmt.Fprintf(os.Stderr, " Next: commit or discard changes, then run 'opencode-worktree merge %s'\n", result.WorktreePath) - } - if result.NoNewCommits && !result.DirtyWorktree { - fmt.Fprintf(os.Stderr, "%sNo new commits found on %s. Cleaned up worktree only.\n", emoji("⚠️ ", "warning: "), result.AgentBranch) - return - } - if result.Merged { - if result.DirtyWorktree { - fmt.Printf("%sMerged %s into %s (worktree kept due to uncommitted changes).\n", emoji("🚀 ", ""), result.AgentBranch, result.ParentBranch) - } else { - fmt.Printf("%sMerged %s into %s and cleaned up.\n", emoji("🚀 ", ""), result.AgentBranch, result.ParentBranch) - } - } -} - -func handleMergeError(result *merge.Result, err error) { - if result != nil && len(result.ConflictFiles) > 0 { - fmt.Fprintf(os.Stderr, "%sMerge conflict: %s into %s\n", emoji("❌ ", "error: "), result.AgentBranch, result.ParentBranch) - fmt.Fprintln(os.Stderr, "Conflicting files:") - for _, f := range result.ConflictFiles { - fmt.Fprintf(os.Stderr, " %s\n", f) - } - fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, "To resolve:") - fmt.Fprintf(os.Stderr, " cd %s\n", result.RepoRoot) - fmt.Fprintln(os.Stderr, " git status") - fmt.Fprintln(os.Stderr, " # Fix conflicts in the listed files") - fmt.Fprintln(os.Stderr, " git add ") - fmt.Fprintln(os.Stderr, " git commit") - fmt.Fprintln(os.Stderr, " opencode-worktree cleanup") - os.Exit(1) - } - exitError("%v", err) -} - -var useEmoji = detectTerminal() - -func detectTerminal() bool { - fi, err := os.Stdout.Stat() - if err != nil { - return false - } - return fi.Mode()&os.ModeCharDevice != 0 -} - -func emoji(e, fallback string) string { - if useEmoji { - return e - } - return fallback -} - -func exitError(format string, args ...any) { - fmt.Fprintf(os.Stderr, emoji("❌ ", "error: ")+format+"\n", args...) - os.Exit(1) -} diff --git a/cmd/opencode-worktree/merge_cmd.go b/cmd/opencode-worktree/merge_cmd.go new file mode 100644 index 0000000..1123ecb --- /dev/null +++ b/cmd/opencode-worktree/merge_cmd.go @@ -0,0 +1,62 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/danhenton/opencode-worktree/internal/merge" +) + +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] + +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 +`) + } + + 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 + } + + cleanup := !*noCleanup + result, err := merge.Run(worktreePath, cleanup) + if err != nil { + if err := handleMergeError(result, err); err != nil { + return err + } + } + printMergeResult(result) + return nil +} diff --git a/cmd/opencode-worktree/output.go b/cmd/opencode-worktree/output.go new file mode 100644 index 0000000..deeb8d5 --- /dev/null +++ b/cmd/opencode-worktree/output.go @@ -0,0 +1,108 @@ +package main + +import ( + "errors" + "fmt" + "os" + + "github.com/danhenton/opencode-worktree/internal/merge" + "github.com/danhenton/opencode-worktree/internal/worktree" +) + +var ( + errSilent = errors.New("") + useEmoji = detectTerminal() +) + +func detectTerminal() bool { + fi, err := os.Stdout.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} + +func emoji(e, fallback string) string { + if useEmoji { + return e + } + return fallback +} + +func printMergeResult(result *merge.Result) { + if result.DirtyWorktree { + fmt.Fprintf(os.Stderr, "%sWorktree has uncommitted changes — preserved at: %s\n", emoji("⚠️ ", "warning: "), result.WorktreePath) + fmt.Fprintf(os.Stderr, " Next: commit or discard changes, then run 'opencode-worktree merge %s'\n", result.WorktreePath) + } + if result.NoNewCommits && !result.DirtyWorktree { + fmt.Fprintf(os.Stderr, "%sNo new commits found on %s. Cleaned up worktree only.\n", emoji("⚠️ ", "warning: "), result.AgentBranch) + return + } + if result.Merged { + if result.DirtyWorktree { + fmt.Printf("%sMerged %s into %s (worktree kept due to uncommitted changes).\n", emoji("🚀 ", ""), result.AgentBranch, result.ParentBranch) + } else { + fmt.Printf("%sMerged %s into %s and cleaned up.\n", emoji("🚀 ", ""), result.AgentBranch, result.ParentBranch) + } + } +} + +func handleMergeError(result *merge.Result, err error) error { + if result != nil && len(result.ConflictFiles) > 0 { + fmt.Fprintf(os.Stderr, "%sMerge conflict: %s into %s\n", emoji("❌ ", "error: "), result.AgentBranch, result.ParentBranch) + fmt.Fprintln(os.Stderr, "Conflicting files:") + for _, f := range result.ConflictFiles { + fmt.Fprintf(os.Stderr, " %s\n", f) + } + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "To resolve:") + fmt.Fprintf(os.Stderr, " cd %s\n", result.RepoRoot) + fmt.Fprintln(os.Stderr, " git status") + fmt.Fprintln(os.Stderr, " # Fix conflicts in the listed files") + fmt.Fprintln(os.Stderr, " git add ") + fmt.Fprintln(os.Stderr, " git commit") + fmt.Fprintln(os.Stderr, " opencode-worktree cleanup") + return errSilent + } + return err +} + +func handleSyncError(result *worktree.SyncResult, err error) error { + if result != nil && len(result.ConflictFiles) > 0 { + fmt.Fprintf(os.Stderr, "%sRebase conflict: %s onto %s\n", emoji("❌ ", "error: "), result.AgentBranch, result.ParentBranch) + fmt.Fprintln(os.Stderr, "Conflicting files:") + for _, f := range result.ConflictFiles { + fmt.Fprintf(os.Stderr, " %s\n", f) + } + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "The rebase was aborted. To resolve manually:") + fmt.Fprintf(os.Stderr, " cd %s\n", result.WorktreePath) + fmt.Fprintf(os.Stderr, " git rebase %s\n", result.ParentBranch) + fmt.Fprintln(os.Stderr, " # Fix conflicts in the listed files") + fmt.Fprintln(os.Stderr, " git add ") + fmt.Fprintln(os.Stderr, " git rebase --continue") + return errSilent + } + return err +} + +func launchAndMaybeMerge(worktreeDir, initialPrompt string, skipMerge bool) error { + launchErr := worktree.LaunchOpenCode(worktreeDir, initialPrompt) + if skipMerge { + return launchErr + } + + fmt.Println() + result, err := merge.Run(worktreeDir, true) + if err != nil { + if err := handleMergeError(result, err); err != nil { + return err + } + } + printMergeResult(result) + + if launchErr != nil { + return launchErr + } + return nil +} diff --git a/cmd/opencode-worktree/routing_test.go b/cmd/opencode-worktree/routing_test.go new file mode 100644 index 0000000..26d8407 --- /dev/null +++ b/cmd/opencode-worktree/routing_test.go @@ -0,0 +1,195 @@ +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 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 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) + + 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) + + 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) + + 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) + + if err != nil { + t.Errorf("expected nil for version subcommand, 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()) + } +} + +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) + + if err != nil { + t.Errorf("expected nil for --version, got %v", err) + } +} + +func TestEmojiDisabled(t *testing.T) { + origEmoji := useEmoji + defer func() { useEmoji = origEmoji }() + useEmoji = false + + if got := emoji("🌿 ", ""); got != "" { + t.Errorf("expected empty string, got %q", got) + } + if got := emoji("❌ ", "error: "); got != "error: " { + t.Errorf("expected 'error: ', got %q", got) + } +} + +func TestEmojiEnabled(t *testing.T) { + origEmoji := useEmoji + defer func() { useEmoji = origEmoji }() + useEmoji = true + + if got := emoji("🌿 ", ""); got != "🌿 " { + t.Errorf("expected '🌿 ', got %q", got) + } +} + +func TestDetectTerminalInTestContext(t *testing.T) { + if detectTerminal() { + t.Errorf("expected detectTerminal() to return false in test context (stdout is a pipe)") + } +} + +func TestErrSilentSentinel(t *testing.T) { + if !errors.Is(errSilent, errSilent) { + t.Errorf("expected errors.Is(errSilent, errSilent) to be true") + } + if errSilent.Error() != "" { + t.Errorf("expected errSilent.Error() to be empty string, got %q", errSilent.Error()) + } +} diff --git a/cmd/opencode-worktree/sync_cmd.go b/cmd/opencode-worktree/sync_cmd.go new file mode 100644 index 0000000..49736b9 --- /dev/null +++ b/cmd/opencode-worktree/sync_cmd.go @@ -0,0 +1,63 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/danhenton/opencode-worktree/internal/merge" + "github.com/danhenton/opencode-worktree/internal/worktree" +) + +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 +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] + } + + 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 +} diff --git a/cmd/opencode-worktree/task.go b/cmd/opencode-worktree/task.go new file mode 100644 index 0000000..efcbcff --- /dev/null +++ b/cmd/opencode-worktree/task.go @@ -0,0 +1,92 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/danhenton/opencode-worktree/internal/git" + "github.com/danhenton/opencode-worktree/internal/worktree" +) + +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] + +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 +`) + } + + 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() + + return launchAndMaybeMerge(createdDir, initialPrompt, *noMerge) +} diff --git a/internal/git/git.go b/internal/git/git.go index 8a2ec0d..c5c3daa 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -117,23 +117,70 @@ func HasUncommittedChanges(dir string, excludePaths []string) (bool, error) { return true, nil } - excluded := make(map[string]bool, len(excludePaths)) - for _, p := range excludePaths { - excluded[p] = true - } - - for _, line := range strings.Split(out, "\n") { + for line := range strings.SplitSeq(out, "\n") { if len(line) < 4 { continue } - filePath := strings.TrimSpace(line[3:]) - if !excluded[filePath] { + if !allPathsExcluded(parseStatusPaths(line[3:]), excludePaths) { return true, nil } } return false, nil } +func parseStatusPaths(statusPath string) []string { + statusPath = strings.TrimSpace(statusPath) + if statusPath == "" { + return nil + } + + parts := strings.Split(statusPath, " -> ") + paths := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.Trim(strings.TrimSpace(part), "\"") + if part != "" { + paths = append(paths, normalizeStatusPath(part)) + } + } + return paths +} + +func allPathsExcluded(paths, excludePaths []string) bool { + if len(paths) == 0 { + return true + } + for _, p := range paths { + if !pathExcluded(p, excludePaths) { + return false + } + } + return true +} + +func pathExcluded(path string, excludePaths []string) bool { + for _, excludePath := range excludePaths { + normalizedExclude := normalizeStatusPath(excludePath) + if strings.HasSuffix(excludePath, "/") { + if path == normalizedExclude || strings.HasPrefix(path, normalizedExclude+"/") { + return true + } + continue + } + if path == normalizedExclude { + return true + } + } + return false +} + +func normalizeStatusPath(path string) string { + cleaned := filepath.ToSlash(filepath.Clean(path)) + if cleaned == "." { + return "" + } + return cleaned +} + func BranchList(dir string) (string, error) { return run(dir, "branch") } diff --git a/internal/git/git_test.go b/internal/git/git_test.go index d82ff1f..9b9e571 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -118,3 +118,34 @@ func TestHasUncommittedChanges(t *testing.T) { t.Errorf("expected clean repo after committing") } } + +func TestHasUncommittedChangesExcludesDirectories(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + + if err := os.MkdirAll(filepath.Join(repoDir, ".sisyphus", "plans"), 0755); err != nil { + t.Fatalf("failed to create nested directory: %v", err) + } + if err := os.WriteFile(filepath.Join(repoDir, ".sisyphus", "plans", "plan.md"), []byte("draft"), 0644); err != nil { + t.Fatalf("failed to write nested file: %v", err) + } + + dirty, err := git.HasUncommittedChanges(repoDir, []string{".sisyphus/"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dirty { + t.Errorf("expected clean repo when excluding .sisyphus/ contents") + } + + if err := os.WriteFile(filepath.Join(repoDir, "real-change.txt"), []byte("hello"), 0644); err != nil { + t.Fatalf("failed to write regular file: %v", err) + } + + dirty, err = git.HasUncommittedChanges(repoDir, []string{".sisyphus/"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !dirty { + t.Errorf("expected dirty repo when non-excluded files exist") + } +} diff --git a/internal/merge/merge_test.go b/internal/merge/merge_test.go index d725124..4805675 100644 --- a/internal/merge/merge_test.go +++ b/internal/merge/merge_test.go @@ -195,6 +195,39 @@ func TestMergeDirtyWorktreeNoCommitsPreservesWorktree(t *testing.T) { } } +func TestMergeIgnoresMarkerDirectoryChanges(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + parentBranch, _ := git.CurrentBranch(repoDir) + + taskName := "feature-marker-only" + worktreeDir, err := worktree.Create(repoDir, taskName, parentBranch) + if err != nil { + t.Fatalf("failed to create worktree: %v", err) + } + + if err := os.MkdirAll(filepath.Join(worktreeDir, ".sisyphus", "plans"), 0755); err != nil { + t.Fatalf("failed to create .sisyphus dir: %v", err) + } + if err := os.WriteFile(filepath.Join(worktreeDir, ".sisyphus", "plans", "draft.md"), []byte("draft"), 0644); err != nil { + t.Fatalf("failed to write marker-dir file: %v", err) + } + + result, err := merge.Run(worktreeDir, true) + if err != nil { + t.Fatalf("unexpected error during merge: %v", err) + } + + if !result.NoNewCommits { + t.Errorf("expected NoNewCommits to be true") + } + if result.DirtyWorktree { + t.Errorf("expected marker directory changes to be ignored") + } + if _, err := os.Stat(worktreeDir); !os.IsNotExist(err) { + t.Errorf("expected worktree to be cleaned up when only marker directories changed") + } +} + func TestMergeDirtyWorktreeWithCommitsPreservesWorktree(t *testing.T) { repoDir := testutil.NewTestRepo(t) parentBranch, _ := git.CurrentBranch(repoDir) diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index 8419201..6859d3c 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -151,6 +151,7 @@ var MarkerFiles = []string{ ".agent-context", "opencode.json", ".opencode/", + ".sisyphus/", } func List(repoRoot string) (string, error) { @@ -160,7 +161,7 @@ func List(repoRoot string) (string, error) { } var agentLines []string - for _, line := range strings.Split(out, "\n") { + for line := range strings.SplitSeq(out, "\n") { if !strings.Contains(line, BranchPrefix) { continue } @@ -187,7 +188,7 @@ func ActiveTaskNames(repoRoot string) ([]string, error) { } var names []string - for _, line := range strings.Split(out, "\n") { + for line := range strings.SplitSeq(out, "\n") { start := strings.Index(line, "["+BranchPrefix) if start == -1 { continue @@ -214,9 +215,9 @@ func ResolveWorktreeDir(repoRoot, taskName string) (string, error) { targetBranch := "branch refs/heads/" + BranchPrefix + taskName var currentWorktree string - for _, line := range strings.Split(porcelain, "\n") { - if strings.HasPrefix(line, "worktree ") { - currentWorktree = strings.TrimPrefix(line, "worktree ") + for line := range strings.SplitSeq(porcelain, "\n") { + if worktreePath, ok := strings.CutPrefix(line, "worktree "); ok { + currentWorktree = worktreePath } if strings.TrimSpace(line) == targetBranch && currentWorktree != "" { return currentWorktree, nil @@ -296,9 +297,9 @@ func findOrphanedDirectories(repoRoot string) ([]string, error) { } activeWorktrees := make(map[string]bool) - for _, line := range strings.Split(porcelain, "\n") { - if strings.HasPrefix(line, "worktree ") { - activeWorktrees[strings.TrimPrefix(line, "worktree ")] = true + for line := range strings.SplitSeq(porcelain, "\n") { + if worktreePath, ok := strings.CutPrefix(line, "worktree "); ok { + activeWorktrees[worktreePath] = true } } @@ -340,15 +341,14 @@ func findOrphanedBranches(repoRoot string) ([]string, error) { } activeBranches := make(map[string]bool) - for _, line := range strings.Split(porcelain, "\n") { - if strings.HasPrefix(line, "branch refs/heads/") { - branch := strings.TrimPrefix(line, "branch refs/heads/") + for line := range strings.SplitSeq(porcelain, "\n") { + if branch, ok := strings.CutPrefix(line, "branch refs/heads/"); ok { activeBranches[branch] = true } } var stale []string - for _, line := range strings.Split(branchOutput, "\n") { + for line := range strings.SplitSeq(branchOutput, "\n") { branch := strings.TrimSpace(strings.TrimPrefix(line, "* ")) if !strings.HasPrefix(branch, BranchPrefix) { continue