From 20188603beb48ab7221ce6583a19cd22d85f16ec Mon Sep 17 00:00:00 2001 From: Dan Henton Date: Mon, 13 Apr 2026 20:12:14 +1200 Subject: [PATCH 1/3] Do not clean up uncommited changes --- cmd/opencode-worktree/main.go | 12 ++++- internal/git/git.go | 29 ++++++++++++ internal/git/git_test.go | 43 +++++++++++++++++ internal/merge/merge.go | 17 ++++++- internal/merge/merge_test.go | 74 ++++++++++++++++++++++++++++++ internal/worktree/worktree.go | 19 +++++++- internal/worktree/worktree_test.go | 18 +++++++- 7 files changed, 205 insertions(+), 7 deletions(-) diff --git a/cmd/opencode-worktree/main.go b/cmd/opencode-worktree/main.go index 0718053..34c598d 100644 --- a/cmd/opencode-worktree/main.go +++ b/cmd/opencode-worktree/main.go @@ -217,12 +217,20 @@ func runCleanup() { } func printMergeResult(result *merge.Result) { - if result.NoNewCommits { + 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.") + } + if result.NoNewCommits && !result.DirtyWorktree { fmt.Printf("⚠️ No new commits found on %s. Cleaned up worktree only.\n", result.AgentBranch) return } if result.Merged { - fmt.Printf("🚀 Merged %s into %s and cleaned up.\n", result.AgentBranch, result.ParentBranch) + if result.DirtyWorktree { + fmt.Printf("🚀 Merged %s into %s (worktree kept due to uncommitted changes).\n", result.AgentBranch, result.ParentBranch) + } else { + fmt.Printf("🚀 Merged %s into %s and cleaned up.\n", result.AgentBranch, result.ParentBranch) + } } } diff --git a/internal/git/git.go b/internal/git/git.go index 95e1d0c..5c774dd 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -105,6 +105,35 @@ func GitCommonDir(dir string) (string, error) { return filepath.Join(dir, out), nil } +func HasUncommittedChanges(dir string, excludePaths []string) (bool, error) { + out, err := run(dir, "status", "--porcelain") + if err != nil { + return false, err + } + if out == "" { + return false, nil + } + if len(excludePaths) == 0 { + return true, nil + } + + excluded := make(map[string]bool, len(excludePaths)) + for _, p := range excludePaths { + excluded[p] = true + } + + for _, line := range strings.Split(out, "\n") { + if len(line) < 4 { + continue + } + filePath := strings.TrimSpace(line[3:]) + if !excluded[filePath] { + return true, nil + } + } + return false, nil +} + 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 5d3c714..d82ff1f 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -1,6 +1,7 @@ package git_test import ( + "os" "path/filepath" "testing" @@ -75,3 +76,45 @@ func TestCommitCountBetween(t *testing.T) { t.Errorf("expected 3 commits, got %d", count) } } + +func TestHasUncommittedChanges(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + + dirty, err := git.HasUncommittedChanges(repoDir, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dirty { + t.Errorf("expected clean repo, got dirty") + } + + if err := os.WriteFile(filepath.Join(repoDir, "untracked.txt"), []byte("hello"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + dirty, err = git.HasUncommittedChanges(repoDir, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !dirty { + t.Errorf("expected dirty repo after adding untracked file") + } + + dirty, err = git.HasUncommittedChanges(repoDir, []string{"untracked.txt"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dirty { + t.Errorf("expected clean repo when excluding the untracked file") + } + + testutil.CommitFile(t, repoDir, "untracked.txt", "hello", "Commit untracked") + + dirty, err = git.HasUncommittedChanges(repoDir, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dirty { + t.Errorf("expected clean repo after committing") + } +} diff --git a/internal/merge/merge.go b/internal/merge/merge.go index 683be2d..dfaf293 100644 --- a/internal/merge/merge.go +++ b/internal/merge/merge.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/danhenton/opencode-worktree/internal/git" + "github.com/danhenton/opencode-worktree/internal/worktree" "github.com/gofrs/flock" ) @@ -15,7 +16,9 @@ type Result struct { ConflictFiles []string AgentBranch string ParentBranch string + WorktreePath string NoNewCommits bool + DirtyWorktree bool } func Run(worktreePath string, cleanup bool) (*Result, error) { @@ -54,11 +57,21 @@ func Run(worktreePath string, cleanup bool) (*Result, error) { result := &Result{ AgentBranch: agentBranch, ParentBranch: parentBranch, + WorktreePath: worktreePath, + } + + dirty, err := git.HasUncommittedChanges(worktreePath, worktree.MarkerFiles) + if err != nil { + return nil, fmt.Errorf("failed to check worktree status: %w", err) + } + + if dirty { + result.DirtyWorktree = true } if commitCount == 0 { result.NoNewCommits = true - if cleanup { + if cleanup && !dirty { return result, cleanupWorktree(repoRoot, worktreePath, agentBranch) } return result, nil @@ -91,7 +104,7 @@ func Run(worktreePath string, cleanup bool) (*Result, error) { result.Merged = true - if cleanup { + if cleanup && !dirty { if err := cleanupWorktree(repoRoot, worktreePath, agentBranch); err != nil { return result, fmt.Errorf("merge succeeded but cleanup failed: %w", err) } diff --git a/internal/merge/merge_test.go b/internal/merge/merge_test.go index 234d68b..6e437c5 100644 --- a/internal/merge/merge_test.go +++ b/internal/merge/merge_test.go @@ -135,6 +135,80 @@ func TestMergeNoCleanup(t *testing.T) { } } +func TestMergeDirtyWorktreeNoCommitsPreservesWorktree(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + parentBranch, _ := git.CurrentBranch(repoDir) + + taskName := "feature-dirty" + worktreeDir, err := worktree.Create(repoDir, taskName, parentBranch) + if err != nil { + t.Fatalf("failed to create worktree: %v", err) + } + + if err := os.WriteFile(filepath.Join(worktreeDir, "unsaved-work.txt"), []byte("important work"), 0644); err != nil { + t.Fatalf("failed to write uncommitted 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 DirtyWorktree to be true") + } + + if _, err := os.Stat(worktreeDir); os.IsNotExist(err) { + t.Errorf("expected worktree to be preserved due to uncommitted changes") + } + + if _, err := os.Stat(filepath.Join(worktreeDir, "unsaved-work.txt")); os.IsNotExist(err) { + t.Errorf("expected uncommitted file to still exist") + } +} + +func TestMergeDirtyWorktreeWithCommitsPreservesWorktree(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + parentBranch, _ := git.CurrentBranch(repoDir) + + taskName := "feature-dirty-commits" + worktreeDir, err := worktree.Create(repoDir, taskName, parentBranch) + if err != nil { + t.Fatalf("failed to create worktree: %v", err) + } + + testutil.CommitFile(t, worktreeDir, "committed.txt", "committed content", "Agent commit") + + if err := os.WriteFile(filepath.Join(worktreeDir, "unsaved-work.txt"), []byte("more work"), 0644); err != nil { + t.Fatalf("failed to write uncommitted file: %v", err) + } + + result, err := merge.Run(worktreeDir, true) + if err != nil { + t.Fatalf("unexpected error during merge: %v", err) + } + + if !result.Merged { + t.Errorf("expected merge to succeed") + } + + if !result.DirtyWorktree { + t.Errorf("expected DirtyWorktree to be true") + } + + if _, err := os.Stat(worktreeDir); os.IsNotExist(err) { + t.Errorf("expected worktree to be preserved due to uncommitted changes") + } + + if _, err := os.Stat(filepath.Join(worktreeDir, "unsaved-work.txt")); os.IsNotExist(err) { + t.Errorf("expected uncommitted file to still exist") + } +} + func TestDetectWorktree(t *testing.T) { repoDir := testutil.NewTestRepo(t) parentBranch, _ := git.CurrentBranch(repoDir) diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index 1ef8388..711ec42 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -111,6 +111,13 @@ func LaunchOpenCode(worktreeDir, initialPrompt string) error { return cmd.Run() } +var MarkerFiles = []string{ + ".agent-parent-branch", + ".agent-context", + "opencode.json", + ".opencode/", +} + func List(repoRoot string) (string, error) { out, err := git.WorktreeList(repoRoot) if err != nil { @@ -119,9 +126,17 @@ func List(repoRoot string) (string, error) { var agentLines []string for _, line := range strings.Split(out, "\n") { - if strings.Contains(line, BranchPrefix) { - agentLines = append(agentLines, line) + if !strings.Contains(line, BranchPrefix) { + continue } + + worktreePath := strings.Fields(line)[0] + dirty, _ := git.HasUncommittedChanges(worktreePath, MarkerFiles) + if dirty { + line += " (uncommitted changes)" + } + + agentLines = append(agentLines, line) } if len(agentLines) == 0 { diff --git a/internal/worktree/worktree_test.go b/internal/worktree/worktree_test.go index 2b1f45a..b6dd8ae 100644 --- a/internal/worktree/worktree_test.go +++ b/internal/worktree/worktree_test.go @@ -151,7 +151,8 @@ func TestList(t *testing.T) { taskName := "test-list" parentBranch, _ := git.CurrentBranch(repoDir) - if _, err := worktree.Create(repoDir, taskName, parentBranch); err != nil { + worktreeDir, err := worktree.Create(repoDir, taskName, parentBranch) + if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -162,6 +163,21 @@ func TestList(t *testing.T) { if !strings.Contains(list, "agent/"+taskName) { t.Errorf("expected list to contain branch %q, got %q", "agent/"+taskName, list) } + if strings.Contains(list, "(uncommitted changes)") { + t.Errorf("expected no uncommitted changes indicator for clean worktree, got %q", list) + } + + if err := os.WriteFile(filepath.Join(worktreeDir, "dirty.txt"), []byte("wip"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + list, err = worktree.List(repoDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(list, "(uncommitted changes)") { + t.Errorf("expected uncommitted changes indicator for dirty worktree, got %q", list) + } } func TestCleanup(t *testing.T) { From 3f14319d260f5f0c4e129f9e3ff2052e30f5a426 Mon Sep 17 00:00:00 2001 From: Dan Henton Date: Mon, 13 Apr 2026 20:29:18 +1200 Subject: [PATCH 2/3] Add attach command with shell completions support --- cmd/opencode-worktree/main.go | 95 ++++++++++++++++++++++++++++++ internal/worktree/worktree.go | 46 +++++++++++++++ internal/worktree/worktree_test.go | 59 +++++++++++++++++++ 3 files changed, 200 insertions(+) diff --git a/cmd/opencode-worktree/main.go b/cmd/opencode-worktree/main.go index 34c598d..9983bcd 100644 --- a/cmd/opencode-worktree/main.go +++ b/cmd/opencode-worktree/main.go @@ -4,6 +4,8 @@ import ( "fmt" "os" + "strings" + "github.com/danhenton/opencode-worktree/internal/git" "github.com/danhenton/opencode-worktree/internal/merge" "github.com/danhenton/opencode-worktree/internal/worktree" @@ -18,12 +20,16 @@ func main() { switch os.Args[1] { case "task": runTask(os.Args[2:]) + case "attach": + runAttach(os.Args[2:]) case "merge": runMerge(os.Args[2:]) case "list": runList() case "cleanup": runCleanup() + case "--completions": + runCompletions(os.Args[2:]) case "-h", "--help", "help": printUsage() default: @@ -38,6 +44,7 @@ func printUsage() { 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 list Show active agent worktrees cleanup Remove orphaned worktrees and branches @@ -45,6 +52,9 @@ Commands: 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 @@ -142,6 +152,91 @@ func runTask(args []string) { 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) + } + } + } + + if taskName == "" { + exitError("task name is required\n\nUsage: opencode-worktree attach [--no-merge]") + } + + 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("🔗 Attaching to agent session: %s\n", 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 { + 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) + } + printMergeResult(result) +} + +func runCompletions(args []string) { + repoRoot, err := git.RepoRoot(".") + if err != nil { + os.Exit(1) + } + + if len(args) == 0 { + fmt.Println(strings.Join([]string{"task", "attach", "merge", "list", "cleanup"}, "\n")) + return + } + + switch args[0] { + case "attach": + names, err := worktree.ActiveTaskNames(repoRoot) + if err != nil { + os.Exit(1) + } + if len(names) > 0 { + fmt.Println(strings.Join(names, "\n")) + } + } +} + func runMerge(args []string) { var worktreePath string noCleanup := false diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index 711ec42..090dd10 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -145,6 +145,52 @@ func List(repoRoot string) (string, error) { return strings.Join(agentLines, "\n"), nil } +func ActiveTaskNames(repoRoot string) ([]string, error) { + out, err := git.WorktreeList(repoRoot) + if err != nil { + return nil, err + } + + var names []string + for _, line := range strings.Split(out, "\n") { + start := strings.Index(line, "["+BranchPrefix) + if start == -1 { + continue + } + end := strings.Index(line[start:], "]") + if end == -1 { + continue + } + branch := line[start+1 : start+end] + name := strings.TrimPrefix(branch, BranchPrefix) + if name != "" { + names = append(names, name) + } + } + return names, nil +} + +func ResolveWorktreeDir(repoRoot, taskName string) (string, error) { + porcelain, err := git.WorktreeListPorcelain(repoRoot) + if err != nil { + return "", err + } + + 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 ") + } + if strings.TrimSpace(line) == targetBranch && currentWorktree != "" { + return currentWorktree, nil + } + } + + return "", fmt.Errorf("no worktree found for task '%s'", taskName) +} + func Cleanup(repoRoot string) error { if err := git.WorktreePrune(repoRoot); err != nil { return fmt.Errorf("failed to prune worktree entries: %w", err) diff --git a/internal/worktree/worktree_test.go b/internal/worktree/worktree_test.go index b6dd8ae..17f5bf7 100644 --- a/internal/worktree/worktree_test.go +++ b/internal/worktree/worktree_test.go @@ -180,6 +180,65 @@ func TestList(t *testing.T) { } } +func TestActiveTaskNames(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + parentBranch, _ := git.CurrentBranch(repoDir) + + names, err := worktree.ActiveTaskNames(repoDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(names) != 0 { + t.Errorf("expected no task names, got %v", names) + } + + if _, err := worktree.Create(repoDir, "alpha", parentBranch); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, err := worktree.Create(repoDir, "beta", parentBranch); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + names, err = worktree.ActiveTaskNames(repoDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(names) != 2 { + t.Fatalf("expected 2 task names, got %v", names) + } + + found := map[string]bool{} + for _, n := range names { + found[n] = true + } + if !found["alpha"] || !found["beta"] { + t.Errorf("expected alpha and beta, got %v", names) + } +} + +func TestResolveWorktreeDir(t *testing.T) { + repoDir := testutil.NewTestRepo(t) + parentBranch, _ := git.CurrentBranch(repoDir) + + _, err := worktree.ResolveWorktreeDir(repoDir, "nonexistent") + if err == nil { + t.Errorf("expected error for nonexistent task") + } + + createdDir, err := worktree.Create(repoDir, "resolve-me", parentBranch) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + resolved, err := worktree.ResolveWorktreeDir(repoDir, "resolve-me") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resolved != createdDir { + t.Errorf("expected %q, got %q", createdDir, resolved) + } +} + func TestCleanup(t *testing.T) { repoDir := testutil.NewTestRepo(t) parentBranch, _ := git.CurrentBranch(repoDir) From f298f3bd99d4c8543e38f5664e72e5c7e0d11daf Mon Sep 17 00:00:00 2001 From: Dan Henton Date: Mon, 13 Apr 2026 20:29:24 +1200 Subject: [PATCH 3/3] Add shell completion snippet to installer --- install.sh | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/install.sh b/install.sh index 4248eba..da1a623 100755 --- a/install.sh +++ b/install.sh @@ -48,3 +48,65 @@ case ":$PATH:" in printf ' export PATH="%s:$PATH"\n' "$BIN_DIR" ;; esac + +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' + + 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' + + shell="$(basename "${SHELL:-}")" + rc_file="" + snippet="" + + case "$shell" in + zsh) + snippet="$zsh_snippet" + if [ -f "$HOME/.zshrc" ]; then + rc_file="$HOME/.zshrc" + fi + ;; + bash) + snippet="$bash_snippet" + if [ -f "$HOME/.bashrc" ]; then + rc_file="$HOME/.bashrc" + elif [ -f "$HOME/.bash_profile" ]; then + rc_file="$HOME/.bash_profile" + fi + ;; + esac + + if [ -z "$rc_file" ] || [ -z "$snippet" ]; then + printf '\nShell completions: could not detect shell rc file. Add manually:\n' + printf ' See: https://github.com/%s#shell-completions\n' "$REPO" + return + fi + + if grep -qF "$completion_marker" "$rc_file" 2>/dev/null; then + printf '\nShell completions already installed in %s\n' "$rc_file" + return + fi + + printf '\n%s\n' "$snippet" >> "$rc_file" + printf '\nShell completions installed in %s\n' "$rc_file" + printf 'Restart your shell or run: source %s\n' "$rc_file" +} + +install_completions