diff --git a/cmd/opencode-worktree/main.go b/cmd/opencode-worktree/main.go index 812dc49..5913439 100644 --- a/cmd/opencode-worktree/main.go +++ b/cmd/opencode-worktree/main.go @@ -1,11 +1,10 @@ package main import ( + "flag" "fmt" "os" - "strings" - "github.com/danhenton/opencode-worktree/internal/git" "github.com/danhenton/opencode-worktree/internal/merge" "github.com/danhenton/opencode-worktree/internal/worktree" @@ -30,7 +29,9 @@ func main() { case "list": runList() case "cleanup": - runCleanup() + runCleanup(os.Args[2:]) + case "sync": + runSync(os.Args[2:]) case "--completions": runCompletions(os.Args[2:]) case "-h", "--help", "help": @@ -38,7 +39,7 @@ func main() { case "version", "--version": fmt.Printf("opencode-worktree %s\n", version) default: - fmt.Fprintf(os.Stderr, "โŒ Unknown command: %s\n\n", os.Args[1]) + fmt.Fprintf(os.Stderr, "%sUnknown command: %s\n\n", emoji("โŒ ", "error: "), os.Args[1]) printUsage() os.Exit(1) } @@ -53,17 +54,11 @@ 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 -Task Options: - --no-merge Skip auto-merge after opencode exits - -Attach Options: - --no-merge Skip auto-merge after opencode exits - -Merge Options: - --no-cleanup Merge but keep worktree and branch +Run 'opencode-worktree --help' for command-specific help. General: -h, --help Show this help message @@ -75,33 +70,41 @@ Alias: } func runTask(args []string) { - var taskName, initialPrompt string - noMerge := false - - for i := 0; i < len(args); i++ { - switch args[i] { - case "--no-merge": - noMerge = true - case "-h", "--help": - printUsage() - os.Exit(0) - default: - if len(args[i]) > 0 && args[i][0] == '-' { - exitError("unknown option: %s", args[i]) - } - if taskName == "" { - taskName = args[i] - } else if initialPrompt == "" { - initialPrompt = args[i] - } else { - exitError("unexpected extra argument: %s", args[i]) - } - } + 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 taskName == "" { + 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) @@ -117,14 +120,18 @@ func runTask(args []string) { exitError("not on a named branch (detached HEAD) โ€” checkout a branch first") } - if worktree.AlreadyExists(repoRoot, taskName) { + 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("๐ŸŒฟ Creating worktree for task: %s\n", 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) @@ -134,61 +141,58 @@ func runTask(args []string) { exitError("%v", err) } - fmt.Printf("โœ… Agent session '%s' starting.\n", taskName) + fmt.Printf("%sAgent session '%s' starting.\n", emoji("โœ… ", ""), taskName) fmt.Printf(" Worktree: %s\n", createdDir) - if noMerge { - fmt.Println(" โš ๏ธ --no-merge is set. Run 'opencode-worktree merge' manually when done.") + 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 { + if *noMerge { return } fmt.Println() result, err := merge.Run(createdDir, true) if err != nil { - if result != nil && len(result.ConflictFiles) > 0 { - fmt.Fprintf(os.Stderr, "โŒ %v\n", err) - fmt.Fprintln(os.Stderr, "Conflicting files:") - for _, f := range result.ConflictFiles { - fmt.Fprintf(os.Stderr, " %s\n", f) - } - os.Exit(1) - } - exitError("%v", err) + handleMergeError(result, err) } printMergeResult(result) } func runAttach(args []string) { - var taskName string - noMerge := false - - for _, arg := range args { - switch arg { - case "--no-merge": - noMerge = true - case "-h", "--help": - printUsage() - os.Exit(0) - default: - if len(arg) > 0 && arg[0] == '-' { - exitError("unknown option: %s", arg) - } - if taskName == "" { - taskName = arg - } else { - exitError("unexpected extra argument: %s", arg) - } - } + 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 taskName == "" { + 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 { @@ -200,27 +204,19 @@ func runAttach(args []string) { exitError("%v", err) } - fmt.Printf("๐Ÿ”— Attaching to agent session: %s\n", taskName) + fmt.Printf("%sAttaching to agent session: %s\n", emoji("๐Ÿ”— ", ""), taskName) fmt.Printf(" Path: %s\n\n", worktreeDir) _ = worktree.LaunchOpenCode(worktreeDir, "") - if noMerge { + if *noMerge { return } fmt.Println() result, err := merge.Run(worktreeDir, true) if err != nil { - if result != nil && len(result.ConflictFiles) > 0 { - fmt.Fprintf(os.Stderr, "โŒ %v\n", err) - fmt.Fprintln(os.Stderr, "Conflicting files:") - for _, f := range result.ConflictFiles { - fmt.Fprintf(os.Stderr, " %s\n", f) - } - os.Exit(1) - } - exitError("%v", err) + handleMergeError(result, err) } printMergeResult(result) } @@ -232,7 +228,9 @@ func runCompletions(args []string) { } if len(args) == 0 { - fmt.Println(strings.Join([]string{"task", "attach", "merge", "list", "cleanup"}, "\n")) + for _, cmd := range []string{"task", "attach", "merge", "sync", "list", "cleanup"} { + fmt.Println(cmd) + } return } @@ -242,33 +240,44 @@ func runCompletions(args []string) { if err != nil { os.Exit(1) } - if len(names) > 0 { - fmt.Println(strings.Join(names, "\n")) + 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 - noCleanup := false - - for _, arg := range args { - switch arg { - case "--no-cleanup": - noCleanup = true - case "-h", "--help": - printUsage() - os.Exit(0) - default: - if len(arg) > 0 && arg[0] == '-' { - exitError("unknown option: %s", arg) - } - if worktreePath == "" { - worktreePath = arg - } else { - exitError("unexpected extra argument: %s", arg) - } - } + if len(positional) == 1 { + worktreePath = positional[0] } if worktreePath == "" { @@ -279,29 +288,90 @@ func runMerge(args []string) { worktreePath = detected } - cleanup := !noCleanup + cleanup := !*noCleanup result, err := merge.Run(worktreePath, cleanup) if err != nil { - if result != nil && len(result.ConflictFiles) > 0 { - fmt.Fprintf(os.Stderr, "โŒ %v\n", err) - fmt.Fprintln(os.Stderr, "Conflicting files:") - for _, f := range result.ConflictFiles { - fmt.Fprintf(os.Stderr, " %s\n", f) - } - os.Exit(1) - } - exitError("%v", err) + 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.Println("๐Ÿ—‚๏ธ Active agent worktrees:") + fmt.Printf("%sActive agent worktrees:\n", emoji("๐Ÿ—‚๏ธ ", "")) out, err := worktree.List(repoRoot) if err != nil { exitError("%v", err) @@ -309,38 +379,101 @@ func runList() { fmt.Println(out) } -func runCleanup() { +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.Println("๐Ÿงน Cleaning up orphaned agent worktrees and branches...") - if err := worktree.Cleanup(repoRoot); err != nil { + 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) } - fmt.Println("โœ… Cleanup complete.") + if !*dryRun { + fmt.Printf("%sCleanup complete.\n", emoji("โœ… ", "")) + } } func printMergeResult(result *merge.Result) { if result.DirtyWorktree { - fmt.Printf("โš ๏ธ Worktree has uncommitted changes โ€” preserved at: %s\n", result.WorktreePath) - fmt.Println(" Commit or discard your changes, then run 'opencode-worktree merge' to finish.") + 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.Printf("โš ๏ธ No new commits found on %s. Cleaned up worktree only.\n", result.AgentBranch) + 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("๐Ÿš€ Merged %s into %s (worktree kept due to uncommitted changes).\n", result.AgentBranch, result.ParentBranch) + fmt.Printf("%sMerged %s into %s (worktree kept due to uncommitted changes).\n", emoji("๐Ÿš€ ", ""), result.AgentBranch, result.ParentBranch) } else { - fmt.Printf("๐Ÿš€ Merged %s into %s and cleaned up.\n", result.AgentBranch, result.ParentBranch) + 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, "โŒ "+format+"\n", args...) + fmt.Fprintf(os.Stderr, emoji("โŒ ", "error: ")+format+"\n", args...) os.Exit(1) } diff --git a/internal/git/git.go b/internal/git/git.go index 5c774dd..8a2ec0d 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -137,3 +137,17 @@ func HasUncommittedChanges(dir string, excludePaths []string) (bool, error) { func BranchList(dir string) (string, error) { return run(dir, "branch") } + +func Rebase(dir, onto string) error { + _, err := run(dir, "rebase", onto) + return err +} + +func RebaseAbort(dir string) error { + _, err := run(dir, "rebase", "--abort") + return err +} + +func MergeBase(dir, a, b string) (string, error) { + return run(dir, "merge-base", a, b) +} diff --git a/internal/merge/merge.go b/internal/merge/merge.go index dfaf293..bd84688 100644 --- a/internal/merge/merge.go +++ b/internal/merge/merge.go @@ -17,6 +17,7 @@ type Result struct { AgentBranch string ParentBranch string WorktreePath string + RepoRoot string NoNewCommits bool DirtyWorktree bool } @@ -31,7 +32,7 @@ func Run(worktreePath string, cleanup bool) (*Result, error) { return nil, fmt.Errorf("worktree path does not exist: %s", worktreePath) } - parentBranch, err := readParentBranch(worktreePath) + parentBranch, err := worktree.ReadParentBranch(worktreePath) if err != nil { return nil, err } @@ -43,8 +44,11 @@ func Run(worktreePath string, cleanup bool) (*Result, error) { if agentBranch == "" { return nil, fmt.Errorf("could not determine agent branch for worktree: %s (detached HEAD?)", worktreePath) } + if !strings.HasPrefix(agentBranch, worktree.BranchPrefix) { + return nil, fmt.Errorf("not a managed agent worktree: branch %q does not have %s prefix", agentBranch, worktree.BranchPrefix) + } - repoRoot, err := resolveRepoRoot(worktreePath) + repoRoot, gitDir, err := resolveRepoRoot(worktreePath) if err != nil { return nil, err } @@ -58,6 +62,7 @@ func Run(worktreePath string, cleanup bool) (*Result, error) { AgentBranch: agentBranch, ParentBranch: parentBranch, WorktreePath: worktreePath, + RepoRoot: repoRoot, } dirty, err := git.HasUncommittedChanges(worktreePath, worktree.MarkerFiles) @@ -77,7 +82,7 @@ func Run(worktreePath string, cleanup bool) (*Result, error) { return result, nil } - lockPath := filepath.Join(os.TempDir(), filepath.Base(repoRoot)+"-merge.lock") + lockPath := filepath.Join(gitDir, "agent-merge.lock") fileLock := flock.New(lockPath) if err := fileLock.Lock(); err != nil { @@ -127,32 +132,19 @@ func DetectWorktree() (string, error) { return dir, nil } -func readParentBranch(worktreePath string) (string, error) { - markerPath := filepath.Join(worktreePath, ".agent-parent-branch") - data, err := os.ReadFile(markerPath) - if err != nil { - return "", fmt.Errorf("missing parent branch marker: %s", markerPath) - } - branch := strings.TrimSpace(string(data)) - if branch == "" { - return "", fmt.Errorf("parent branch marker is empty: %s", markerPath) - } - return branch, nil -} - -func resolveRepoRoot(worktreePath string) (string, error) { +func resolveRepoRoot(worktreePath string) (string, string, error) { commonDir, err := git.GitCommonDir(worktreePath) if err != nil { - return "", fmt.Errorf("failed to resolve git common dir: %w", err) + return "", "", fmt.Errorf("failed to resolve git common dir: %w", err) } absCommonDir, err := filepath.Abs(commonDir) if err != nil { - return "", fmt.Errorf("failed to resolve absolute common dir: %w", err) + return "", "", fmt.Errorf("failed to resolve absolute common dir: %w", err) } repoRoot := filepath.Dir(absCommonDir) - return repoRoot, nil + return repoRoot, absCommonDir, nil } func cleanupWorktree(repoRoot, worktreePath, agentBranch string) error { diff --git a/internal/merge/merge_test.go b/internal/merge/merge_test.go index 6e437c5..d725124 100644 --- a/internal/merge/merge_test.go +++ b/internal/merge/merge_test.go @@ -3,6 +3,7 @@ package merge_test import ( "os" "path/filepath" + "strings" "testing" "github.com/danhenton/opencode-worktree/internal/git" @@ -82,6 +83,29 @@ func TestMergeNoNewCommits(t *testing.T) { } } +func TestMergeRejectsNonAgentWorktree(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + parentBranch, _ := git.CurrentBranch(repoDir) + + worktreeDir := filepath.Join(t.TempDir(), "plain-worktree") + if err := git.WorktreeAdd(repoDir, worktreeDir, "plain-feature", parentBranch); err != nil { + t.Fatalf("failed to create worktree: %v", err) + } + + if err := os.WriteFile(filepath.Join(worktreeDir, ".agent-parent-branch"), []byte(parentBranch), 0644); err != nil { + t.Fatalf("failed to write parent branch marker: %v", err) + } + + _, err := merge.Run(worktreeDir, true) + if err == nil { + t.Fatalf("expected error for non-agent worktree, got nil") + } + + if !strings.Contains(err.Error(), "not a managed agent worktree") { + t.Fatalf("expected managed worktree error, got %v", err) + } +} + func TestMergeConflict(t *testing.T) { repoDir := testutil.NewTestRepo(t) parentBranch, _ := git.CurrentBranch(repoDir) diff --git a/internal/worktree/sync.go b/internal/worktree/sync.go new file mode 100644 index 0000000..8e4d362 --- /dev/null +++ b/internal/worktree/sync.go @@ -0,0 +1,88 @@ +package worktree + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/danhenton/opencode-worktree/internal/git" +) + +type SyncResult struct { + AgentBranch string + ParentBranch string + WorktreePath string + AlreadyCurrent bool + Rebased bool + ConflictFiles []string +} + +func Sync(worktreePath string) (*SyncResult, error) { + parentBranch, err := ReadParentBranch(worktreePath) + if err != nil { + return nil, err + } + + agentBranch, err := git.CurrentBranch(worktreePath) + if err != nil { + return nil, fmt.Errorf("failed to determine agent branch: %w", err) + } + if agentBranch == "" { + return nil, fmt.Errorf("detached HEAD in worktree โ€” cannot sync") + } + if !strings.HasPrefix(agentBranch, BranchPrefix) { + return nil, fmt.Errorf("not a managed agent worktree: branch %q does not have %s prefix", agentBranch, BranchPrefix) + } + + result := &SyncResult{ + AgentBranch: agentBranch, + ParentBranch: parentBranch, + WorktreePath: worktreePath, + } + + dirty, err := git.HasUncommittedChanges(worktreePath, MarkerFiles) + if err != nil { + return nil, fmt.Errorf("failed to check worktree status: %w", err) + } + if dirty { + return nil, fmt.Errorf("worktree has uncommitted changes โ€” commit or stash before syncing") + } + + mergeBase, err := git.MergeBase(worktreePath, agentBranch, parentBranch) + if err != nil { + return nil, fmt.Errorf("failed to find merge base: %w", err) + } + + parentTip, err := git.CommitCountBetween(worktreePath, mergeBase, parentBranch) + if err != nil { + return nil, fmt.Errorf("failed to check parent branch: %w", err) + } + if parentTip == 0 { + result.AlreadyCurrent = true + return result, nil + } + + if err := git.Rebase(worktreePath, parentBranch); err != nil { + conflicts, _ := git.ConflictingFiles(worktreePath) + _ = git.RebaseAbort(worktreePath) + result.ConflictFiles = conflicts + return result, fmt.Errorf("rebase conflict while syncing %s onto %s", agentBranch, parentBranch) + } + + result.Rebased = true + return result, nil +} + +func ReadParentBranch(worktreePath string) (string, error) { + markerPath := filepath.Join(worktreePath, ".agent-parent-branch") + data, err := os.ReadFile(markerPath) + if err != nil { + return "", fmt.Errorf("missing parent branch marker: %s", markerPath) + } + branch := strings.TrimSpace(string(data)) + if branch == "" { + return "", fmt.Errorf("parent branch marker is empty: %s", markerPath) + } + return branch, nil +} diff --git a/internal/worktree/sync_test.go b/internal/worktree/sync_test.go new file mode 100644 index 0000000..4d33ef2 --- /dev/null +++ b/internal/worktree/sync_test.go @@ -0,0 +1,133 @@ +package worktree_test + +import ( + "os" + "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 TestSyncAlreadyCurrent(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + parentBranch, _ := git.CurrentBranch(repoDir) + + worktreeDir, err := worktree.Create(repoDir, "sync-noop", parentBranch) + if err != nil { + t.Fatalf("failed to create worktree: %v", err) + } + + result, err := worktree.Sync(worktreeDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.AlreadyCurrent { + t.Error("expected AlreadyCurrent to be true") + } + if result.Rebased { + t.Error("expected Rebased to be false") + } +} + +func TestSyncRebasesParentChanges(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + parentBranch, _ := git.CurrentBranch(repoDir) + + worktreeDir, err := worktree.Create(repoDir, "sync-rebase", parentBranch) + if err != nil { + t.Fatalf("failed to create worktree: %v", err) + } + + testutil.CommitFile(t, worktreeDir, "agent-work.txt", "agent changes\n", "Agent commit") + + testutil.CommitFile(t, repoDir, "parent-update.txt", "new parent work\n", "Parent commit") + + result, err := worktree.Sync(worktreeDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.AlreadyCurrent { + t.Error("expected AlreadyCurrent to be false") + } + if !result.Rebased { + t.Error("expected Rebased to be true") + } + + if _, err := os.Stat(filepath.Join(worktreeDir, "parent-update.txt")); os.IsNotExist(err) { + t.Error("expected parent-update.txt to be present after sync") + } + if _, err := os.Stat(filepath.Join(worktreeDir, "agent-work.txt")); os.IsNotExist(err) { + t.Error("expected agent-work.txt to be preserved after sync") + } +} + +func TestSyncConflictAbortsRebase(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + parentBranch, _ := git.CurrentBranch(repoDir) + + worktreeDir, err := worktree.Create(repoDir, "sync-conflict", parentBranch) + if err != nil { + t.Fatalf("failed to create worktree: %v", err) + } + + testutil.CommitFile(t, worktreeDir, "shared.txt", "agent version\n", "Agent edits shared") + + testutil.CommitFile(t, repoDir, "shared.txt", "parent version\n", "Parent edits shared") + + result, err := worktree.Sync(worktreeDir) + if err == nil { + t.Fatal("expected error on conflict") + } + if !strings.Contains(err.Error(), "rebase conflict") { + t.Errorf("expected rebase conflict error, got: %v", err) + } + if len(result.ConflictFiles) == 0 { + t.Error("expected conflict files to be reported") + } + + branch, _ := git.CurrentBranch(worktreeDir) + if branch == "" { + t.Error("expected worktree to be on a branch after abort (not in rebase state)") + } +} + +func TestSyncRejectsDirtyWorktree(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + parentBranch, _ := git.CurrentBranch(repoDir) + + worktreeDir, err := worktree.Create(repoDir, "sync-dirty", parentBranch) + if err != nil { + t.Fatalf("failed to create worktree: %v", err) + } + + if err := os.WriteFile(filepath.Join(worktreeDir, "uncommitted.txt"), []byte("wip"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + _, err = worktree.Sync(worktreeDir) + if err == nil { + t.Fatal("expected error for dirty worktree") + } + if !strings.Contains(err.Error(), "uncommitted changes") { + t.Errorf("expected uncommitted changes error, got: %v", err) + } +} + +func TestSyncRejectsNonAgentBranch(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + + if err := os.WriteFile(filepath.Join(repoDir, ".agent-parent-branch"), []byte("main\n"), 0644); err != nil { + t.Fatalf("failed to write marker: %v", err) + } + + _, err := worktree.Sync(repoDir) + if err == nil { + t.Fatal("expected error for non-agent branch") + } + if !strings.Contains(err.Error(), "not a managed agent worktree") { + t.Errorf("expected non-agent worktree error, got: %v", err) + } +} diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index 9a86623..8419201 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -1,6 +1,7 @@ package worktree import ( + "bufio" "fmt" "os" "os/exec" @@ -31,12 +32,12 @@ func WorktreeDir(repoRoot, taskName string) string { return filepath.Join(filepath.Dir(repoRoot), filepath.Base(repoRoot)+DirSuffix+taskName) } -func AlreadyExists(repoRoot, taskName string) bool { +func AlreadyExists(repoRoot, taskName string) (bool, error) { out, err := git.WorktreeList(repoRoot) if err != nil { - return false + return false, fmt.Errorf("failed to check existing worktrees: %w", err) } - return strings.Contains(out, BranchPrefix+taskName) + return strings.Contains(out, BranchPrefix+taskName), nil } func Create(repoRoot, taskName, parentBranch string) (string, error) { @@ -78,8 +79,7 @@ func copyOpenCodeConfig(repoRoot, worktreeDir string) error { configFile := filepath.Join(repoRoot, "opencode.json") if info, err := os.Stat(configFile); err == nil && !info.IsDir() { dest := filepath.Join(worktreeDir, "opencode.json") - cmd := exec.Command("cp", configFile, dest) - if err := cmd.Run(); err != nil { + if err := copyFile(configFile, dest, info.Mode()); err != nil { return fmt.Errorf("failed to copy opencode.json: %w", err) } } @@ -87,8 +87,7 @@ func copyOpenCodeConfig(repoRoot, worktreeDir string) error { configDir := filepath.Join(repoRoot, ".opencode") if info, err := os.Stat(configDir); err == nil && info.IsDir() { dest := filepath.Join(worktreeDir, ".opencode") - cmd := exec.Command("cp", "-r", configDir, dest) - if err := cmd.Run(); err != nil { + if err := copyDir(configDir, dest); err != nil { return fmt.Errorf("failed to copy .opencode/: %w", err) } } @@ -96,7 +95,43 @@ func copyOpenCodeConfig(repoRoot, worktreeDir string) error { return nil } +func copyFile(src, dst string, mode os.FileMode) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, mode) +} + +func copyDir(src, dst string) error { + return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + + if d.IsDir() { + return os.MkdirAll(target, 0755) + } + + info, err := d.Info() + if err != nil { + return err + } + return copyFile(path, target, info.Mode()) + }) +} + func LaunchOpenCode(worktreeDir, initialPrompt string) error { + if _, err := exec.LookPath("opencode"); err != nil { + return fmt.Errorf("opencode not found in PATH โ€” install it from https://opencode.ai") + } + var cmd *exec.Cmd if initialPrompt != "" { cmd = exec.Command("opencode", "--prompt", initialPrompt) @@ -191,22 +226,73 @@ func ResolveWorktreeDir(repoRoot, taskName string) (string, error) { return "", fmt.Errorf("no worktree found for task '%s'", taskName) } -func Cleanup(repoRoot string) error { +type CleanupOptions struct { + DryRun bool + Yes bool +} + +func Cleanup(repoRoot string, opts CleanupOptions) error { if err := git.WorktreePrune(repoRoot); err != nil { return fmt.Errorf("failed to prune worktree entries: %w", err) } - if err := cleanupOrphanedDirectories(repoRoot); err != nil { + staleDirs, err := findOrphanedDirectories(repoRoot) + if err != nil { return err } - return cleanupOrphanedBranches(repoRoot) + staleBranches, err := findOrphanedBranches(repoRoot) + if err != nil { + return err + } + + if len(staleDirs) == 0 && len(staleBranches) == 0 { + return nil + } + + if opts.DryRun { + for _, dir := range staleDirs { + fmt.Fprintf(os.Stderr, "Would remove directory: %s\n", dir) + } + for _, branch := range staleBranches { + fmt.Fprintf(os.Stderr, "Would delete branch: %s\n", branch) + } + return nil + } + + if !opts.Yes { + if fi, _ := os.Stdin.Stat(); fi != nil && fi.Mode()&os.ModeCharDevice != 0 { + fmt.Fprintf(os.Stderr, "Remove %d worktree(s) and %d branch(es)? [y/N] ", len(staleDirs), len(staleBranches)) + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() || (scanner.Text() != "y" && scanner.Text() != "Y") { + return fmt.Errorf("cleanup aborted") + } + } + } + + for _, dir := range staleDirs { + fmt.Fprintf(os.Stderr, "Removing stale worktree directory: %s\n", dir) + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("failed to remove %s: %w", dir, err) + } + fmt.Printf("Removed: %s\n", dir) + } + + for _, branch := range staleBranches { + if _, err := git.BranchDelete(repoRoot, branch); err != nil { + fmt.Fprintf(os.Stderr, "Could not delete branch (unmerged?): %s โ€” use 'git branch -D %s' to force\n", branch, branch) + } else { + fmt.Printf("Deleted branch: %s\n", branch) + } + } + + return nil } -func cleanupOrphanedDirectories(repoRoot string) error { +func findOrphanedDirectories(repoRoot string) ([]string, error) { porcelain, err := git.WorktreeListPorcelain(repoRoot) if err != nil { - return err + return nil, err } activeWorktrees := make(map[string]bool) @@ -220,9 +306,10 @@ func cleanupOrphanedDirectories(repoRoot string) error { parentDir := filepath.Dir(repoRoot) entries, err := os.ReadDir(parentDir) if err != nil { - return fmt.Errorf("failed to read parent directory: %w", err) + return nil, fmt.Errorf("failed to read parent directory: %w", err) } + var stale []string for _, entry := range entries { if !entry.IsDir() { continue @@ -232,28 +319,24 @@ func cleanupOrphanedDirectories(repoRoot string) error { continue } if activeWorktrees[fullPath] { - fmt.Printf("โš ๏ธ Skipping active worktree directory: %s\n", fullPath) + fmt.Fprintf(os.Stderr, "Skipping active worktree directory: %s\n", fullPath) continue } - fmt.Printf("โš ๏ธ Removing stale worktree directory: %s\n", fullPath) - if err := os.RemoveAll(fullPath); err != nil { - return fmt.Errorf("failed to remove %s: %w", fullPath, err) - } - fmt.Printf("โœ… Removed: %s\n", fullPath) + stale = append(stale, fullPath) } - return nil + return stale, nil } -func cleanupOrphanedBranches(repoRoot string) error { +func findOrphanedBranches(repoRoot string) ([]string, error) { branchOutput, err := git.BranchList(repoRoot) if err != nil { - return err + return nil, err } porcelain, err := git.WorktreeListPorcelain(repoRoot) if err != nil { - return err + return nil, err } activeBranches := make(map[string]bool) @@ -264,21 +347,18 @@ func cleanupOrphanedBranches(repoRoot string) error { } } + var stale []string for _, line := range strings.Split(branchOutput, "\n") { branch := strings.TrimSpace(strings.TrimPrefix(line, "* ")) if !strings.HasPrefix(branch, BranchPrefix) { continue } if activeBranches[branch] { - fmt.Printf("โš ๏ธ Skipping active worktree branch: %s\n", branch) + fmt.Fprintf(os.Stderr, "Skipping active worktree branch: %s\n", branch) continue } - if _, err := git.BranchDelete(repoRoot, branch); err != nil { - fmt.Printf("โš ๏ธ Could not delete branch (unmerged?): %s โ€” use 'git branch -D %s' to force\n", branch, branch) - } else { - fmt.Printf("โœ… Deleted branch: %s\n", branch) - } + stale = append(stale, branch) } - return nil + return stale, nil } diff --git a/internal/worktree/worktree_test.go b/internal/worktree/worktree_test.go index 17f5bf7..44ee0dd 100644 --- a/internal/worktree/worktree_test.go +++ b/internal/worktree/worktree_test.go @@ -125,7 +125,11 @@ func TestAlreadyExists(t *testing.T) { taskName := "test-exists" parentBranch, _ := git.CurrentBranch(repoDir) - if worktree.AlreadyExists(repoDir, taskName) { + exists, err := worktree.AlreadyExists(repoDir, taskName) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if exists { t.Errorf("expected worktree not to exist yet") } @@ -133,7 +137,11 @@ func TestAlreadyExists(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if !worktree.AlreadyExists(repoDir, taskName) { + exists, err = worktree.AlreadyExists(repoDir, taskName) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !exists { t.Errorf("expected worktree to exist") } } @@ -255,7 +263,7 @@ func TestCleanup(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if err := worktree.Cleanup(repoDir); err != nil { + if err := worktree.Cleanup(repoDir, worktree.CleanupOptions{Yes: true}); err != nil { t.Fatalf("unexpected error during cleanup: %v", err) } @@ -267,3 +275,33 @@ func TestCleanup(t *testing.T) { t.Errorf("expected active worktree dir to be preserved") } } + +func TestCleanupDryRun(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + + orphanedTask := "orphaned-dry" + orphanedDir := worktree.WorktreeDir(repoDir, orphanedTask) + if err := os.MkdirAll(orphanedDir, 0755); err != nil { + t.Fatalf("failed to create orphaned dir: %v", err) + } + + if err := worktree.Cleanup(repoDir, worktree.CleanupOptions{DryRun: true}); err != nil { + t.Fatalf("unexpected error during dry-run cleanup: %v", err) + } + + if _, err := os.Stat(orphanedDir); os.IsNotExist(err) { + t.Errorf("expected orphaned dir to still exist after dry-run") + } +} + +func TestLaunchOpenCodeMissingBinary(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + err := worktree.LaunchOpenCode(t.TempDir(), "") + if err == nil { + t.Fatal("expected error when opencode is not in PATH") + } + if !strings.Contains(err.Error(), "opencode not found in PATH") { + t.Errorf("expected error about missing opencode, got: %v", err) + } +}