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
383 changes: 258 additions & 125 deletions cmd/opencode-worktree/main.go

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
32 changes: 12 additions & 20 deletions internal/merge/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type Result struct {
AgentBranch string
ParentBranch string
WorktreePath string
RepoRoot string
NoNewCommits bool
DirtyWorktree bool
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions internal/merge/merge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package merge_test
import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/danhenton/opencode-worktree/internal/git"
Expand Down Expand Up @@ -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)
Expand Down
88 changes: 88 additions & 0 deletions internal/worktree/sync.go
Original file line number Diff line number Diff line change
@@ -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
}
133 changes: 133 additions & 0 deletions internal/worktree/sync_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading