Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 105 additions & 2 deletions cmd/opencode-worktree/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
Expand All @@ -38,13 +44,17 @@ func printUsage() {

Commands:
task <name> [message] Create agent worktree and launch opencode
attach <name> 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

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

Expand Down Expand Up @@ -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 <name> [--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
Expand Down Expand Up @@ -217,12 +312,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)
}
}
}

Expand Down
62 changes: 62 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 29 additions & 0 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
43 changes: 43 additions & 0 deletions internal/git/git_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package git_test

import (
"os"
"path/filepath"
"testing"

Expand Down Expand Up @@ -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")
}
}
17 changes: 15 additions & 2 deletions internal/merge/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strings"

"github.com/danhenton/opencode-worktree/internal/git"
"github.com/danhenton/opencode-worktree/internal/worktree"
"github.com/gofrs/flock"
)

Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
Loading