From 2f92f1acd1061cc7ec1cdbf6baed985996d053e6 Mon Sep 17 00:00:00 2001 From: Stephen Jennings Date: Fri, 6 Mar 2026 23:05:38 -0800 Subject: [PATCH 1/3] chore(vcs): Create VCS abstraction Create an abstraction vcs.Backend which abstracts over a working copy. For now, git.GitBackend is the only implementation. Instead of calling package functions `git.Foo(repoDir)`, now we construct a `git.GitBackend` and call `b.Foo()`. --- cmd/agent-deck/launch_cmd.go | 23 +- cmd/agent-deck/main.go | 37 ++- cmd/agent-deck/session_cmd.go | 29 +- cmd/agent-deck/vcs_helper.go | 20 ++ cmd/agent-deck/worktree_cmd.go | 86 +++--- internal/git/git.go | 164 ++++++----- internal/git/git_test.go | 487 +++++++++++++++++++++++---------- internal/git/template_test.go | 39 +++ internal/session/instance.go | 18 ++ internal/ui/branch_picker.go | 4 +- internal/ui/home.go | 92 ++++--- internal/vcs/vcs.go | 48 ++++ 12 files changed, 710 insertions(+), 337 deletions(-) create mode 100644 cmd/agent-deck/vcs_helper.go create mode 100644 internal/vcs/vcs.go diff --git a/cmd/agent-deck/launch_cmd.go b/cmd/agent-deck/launch_cmd.go index 13d58b74..28b88c38 100644 --- a/cmd/agent-deck/launch_cmd.go +++ b/cmd/agent-deck/launch_cmd.go @@ -10,6 +10,7 @@ import ( "github.com/asheshgoplani/agent-deck/internal/git" "github.com/asheshgoplani/agent-deck/internal/session" + "github.com/asheshgoplani/agent-deck/internal/vcs" ) // handleLaunch combines add + start + optional send into a single command. @@ -141,18 +142,14 @@ func handleLaunch(profile string, args []string) { } // Handle worktree creation - var worktreePath, worktreeRepoRoot string + var worktreePath, worktreeRepoRoot, worktreeType string if wtBranch != "" { - if !git.IsGitRepo(path) { - out.Error(fmt.Sprintf("%s is not a git repository", path), ErrCodeInvalidOperation) - os.Exit(1) - } - - repoRoot, err := git.GetWorktreeBaseRoot(path) + backend, err := detectAndCreateBackend(path) if err != nil { - out.Error(fmt.Sprintf("failed to get repo root: %v", err), ErrCodeInvalidOperation) + out.Error(fmt.Sprintf("%v", err), ErrCodeInvalidOperation) os.Exit(1) } + worktreeType = string(backend.Type()) if err := git.ValidateBranchName(wtBranch); err != nil { out.Error(fmt.Sprintf("invalid branch name: %v", err), ErrCodeInvalidOperation) @@ -165,16 +162,15 @@ func handleLaunch(profile string, args []string) { location = *worktreeLocation } - worktreePath = git.WorktreePath(git.WorktreePathOptions{ + worktreePath = backend.WorktreePath(vcs.WorktreePathOptions{ Branch: wtBranch, Location: location, - RepoDir: repoRoot, SessionID: git.GeneratePathID(), Template: wtSettings.Template(), }) // Check for an existing worktree for this branch before creating a new one - if existingPath, err := git.GetWorktreeForBranch(repoRoot, wtBranch); err == nil && existingPath != "" { + if existingPath, err := backend.GetWorktreeForBranch(wtBranch); err == nil && existingPath != "" { fmt.Fprintf(os.Stderr, "Reusing existing worktree at %s for branch %s\n", existingPath, wtBranch) worktreePath = existingPath } else { @@ -188,13 +184,13 @@ func handleLaunch(profile string, args []string) { os.Exit(1) } - if err := git.CreateWorktree(repoRoot, worktreePath, wtBranch); err != nil { + if err := backend.CreateWorktree(worktreePath, wtBranch); err != nil { out.Error(fmt.Sprintf("failed to create worktree: %v", err), ErrCodeInvalidOperation) os.Exit(1) } } - worktreeRepoRoot = repoRoot + worktreeRepoRoot = backend.RepoDir() path = worktreePath } @@ -272,6 +268,7 @@ func handleLaunch(profile string, args []string) { newInstance.WorktreePath = worktreePath newInstance.WorktreeRepoRoot = worktreeRepoRoot newInstance.WorktreeBranch = wtBranch + newInstance.WorktreeType = worktreeType } if *resumeSession != "" { diff --git a/cmd/agent-deck/main.go b/cmd/agent-deck/main.go index e993c550..25325031 100644 --- a/cmd/agent-deck/main.go +++ b/cmd/agent-deck/main.go @@ -28,6 +28,7 @@ import ( "github.com/asheshgoplani/agent-deck/internal/statedb" "github.com/asheshgoplani/agent-deck/internal/ui" "github.com/asheshgoplani/agent-deck/internal/update" + "github.com/asheshgoplani/agent-deck/internal/vcs" "github.com/asheshgoplani/agent-deck/internal/web" ) @@ -1014,20 +1015,14 @@ func handleAdd(profile string, args []string) { } // Handle worktree creation - var worktreePath, worktreeRepoRoot string + var worktreePath, worktreeRepoRoot, worktreeType string if wtBranch != "" { - // Validate path is a git repo - if !git.IsGitRepo(path) { - fmt.Fprintf(os.Stderr, "Error: %s is not a git repository\n", path) - os.Exit(1) - } - - // Get repo root (resolve through worktrees to prevent nesting) - repoRoot, err := git.GetWorktreeBaseRoot(path) + backend, err := detectAndCreateBackend(path) if err != nil { - fmt.Fprintf(os.Stderr, "Error: failed to get repo root: %v\n", err) + fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } + worktreeType = string(backend.Type()) // Pre-validate branch name for better error messages if err := git.ValidateBranchName(wtBranch); err != nil { @@ -1043,16 +1038,15 @@ func handleAdd(profile string, args []string) { } // Generate worktree path - worktreePath = git.WorktreePath(git.WorktreePathOptions{ + worktreePath = backend.WorktreePath(vcs.WorktreePathOptions{ Branch: wtBranch, Location: location, - RepoDir: repoRoot, SessionID: git.GeneratePathID(), Template: wtSettings.Template(), }) // Check for an existing worktree for this branch before creating a new one - if existingPath, err := git.GetWorktreeForBranch(repoRoot, wtBranch); err == nil && existingPath != "" { + if existingPath, err := backend.GetWorktreeForBranch(wtBranch); err == nil && existingPath != "" { fmt.Fprintf(os.Stderr, "Reusing existing worktree at %s for branch %s\n", existingPath, wtBranch) worktreePath = existingPath } else { @@ -1064,7 +1058,7 @@ func handleAdd(profile string, args []string) { // Create worktree atomically (git handles existence checks). // This avoids a TOCTOU race from separate check-then-create steps. - if err := git.CreateWorktree(repoRoot, worktreePath, wtBranch); err != nil { + if err := backend.CreateWorktree(worktreePath, wtBranch); err != nil { if isWorktreeAlreadyExistsError(err) { fmt.Fprintf(os.Stderr, "Error: worktree already exists at %s\n", worktreePath) fmt.Fprintf(os.Stderr, "Tip: Use 'agent-deck add %s' to add the existing worktree\n", worktreePath) @@ -1076,7 +1070,7 @@ func handleAdd(profile string, args []string) { fmt.Printf("Created worktree at: %s\n", worktreePath) } - worktreeRepoRoot = repoRoot + worktreeRepoRoot = backend.RepoDir() // Update path to point to worktree so session uses worktree as working directory path = worktreePath } @@ -1133,6 +1127,7 @@ func handleAdd(profile string, args []string) { newInstance.WorktreePath = worktreePath newInstance.WorktreeRepoRoot = worktreeRepoRoot newInstance.WorktreeBranch = wtBranch + newInstance.WorktreeType = worktreeType } // Apply sandbox config if requested. @@ -1580,12 +1575,16 @@ func handleRemove(profile string, args []string) { // Clean up worktree directory if this is a worktree session if inst.IsWorktree() { - if err := git.RemoveWorktree(inst.WorktreeRepoRoot, inst.WorktreePath, false); err != nil { - if !*jsonOutput { - fmt.Printf("Warning: failed to remove worktree: %v\n", err) + if wtBackend, err := inst.Backend(); err == nil { + if err := wtBackend.RemoveWorktree(inst.WorktreePath, false); err != nil { + if !*jsonOutput { + fmt.Printf("Warning: failed to remove worktree: %v\n", err) + } } + _ = wtBackend.PruneWorktrees() + } else if !*jsonOutput { + fmt.Printf("Warning: failed to initialize VCS for worktree cleanup: %v\n", err) } - _ = git.PruneWorktrees(inst.WorktreeRepoRoot) } // Direct SQL DELETE first to prevent resurrection by concurrent TUI force saves. diff --git a/cmd/agent-deck/session_cmd.go b/cmd/agent-deck/session_cmd.go index b76e28be..577dc1b8 100644 --- a/cmd/agent-deck/session_cmd.go +++ b/cmd/agent-deck/session_cmd.go @@ -17,6 +17,7 @@ import ( "github.com/asheshgoplani/agent-deck/internal/session" "github.com/asheshgoplani/agent-deck/internal/tmux" "github.com/asheshgoplani/agent-deck/internal/ui" + "github.com/asheshgoplani/agent-deck/internal/vcs" ) // handleSession dispatches session subcommands @@ -472,40 +473,38 @@ func handleSessionFork(profile string, args []string) { if *worktreeBranchLong != "" { wtBranch = *worktreeBranchLong } - _ = *newBranch || *newBranchLong + createNewBranch := *newBranch || *newBranchLong // Handle worktree creation var opts *session.ClaudeOptions + var worktreeType string if wtBranch != "" { - if !git.IsGitRepo(inst.ProjectPath) { - out.Error("session path is not a git repository", ErrCodeInvalidOperation) - os.Exit(1) - } - repoRoot, err := git.GetWorktreeBaseRoot(inst.ProjectPath) + backend, err := detectAndCreateBackend(inst.ProjectPath) if err != nil { - out.Error(fmt.Sprintf("failed to get repo root: %v", err), ErrCodeInvalidOperation) + out.Error(fmt.Sprintf("%v", err), ErrCodeInvalidOperation) os.Exit(1) } + worktreeType = string(backend.Type()) - if err := git.ValidateBranchName(wtBranch); err != nil { + if err := git.ValidateBranchName(wtBranch); !createNewBranch && !backend.BranchExists(wtBranch) { out.Error(fmt.Sprintf("invalid branch name: %v", err), ErrCodeInvalidOperation) os.Exit(1) } wtSettings := session.GetWorktreeSettings() - worktreePath := git.WorktreePath(git.WorktreePathOptions{ + worktreePath := backend.WorktreePath(vcs.WorktreePathOptions{ Branch: wtBranch, Location: wtSettings.DefaultLocation, - RepoDir: repoRoot, SessionID: git.GeneratePathID(), Template: wtSettings.Template(), }) // Check for an existing worktree for this branch before creating a new one - if existingPath, err := git.GetWorktreeForBranch(repoRoot, wtBranch); err == nil && existingPath != "" { + if existingPath, err := backend.GetWorktreeForBranch(wtBranch); err == nil && existingPath != "" { fmt.Fprintf(os.Stderr, "Reusing existing worktree at %s for branch %s\n", existingPath, wtBranch) worktreePath = existingPath } else { + if _, statErr := os.Stat(worktreePath); statErr == nil { out.Error(fmt.Sprintf("worktree path already exists: %s", worktreePath), ErrCodeInvalidOperation) os.Exit(1) @@ -516,7 +515,7 @@ func handleSessionFork(profile string, args []string) { os.Exit(1) } - if err := git.CreateWorktree(repoRoot, worktreePath, wtBranch); err != nil { + if err := backend.CreateWorktree(worktreePath, wtBranch); err != nil { out.Error(fmt.Sprintf("worktree creation failed: %v", err), ErrCodeInvalidOperation) os.Exit(1) } @@ -526,7 +525,7 @@ func handleSessionFork(profile string, args []string) { opts = session.NewClaudeOptions(userConfig) opts.WorkDir = worktreePath opts.WorktreePath = worktreePath - opts.WorktreeRepoRoot = repoRoot + opts.WorktreeRepoRoot = backend.RepoDir() opts.WorktreeBranch = wtBranch } @@ -537,6 +536,10 @@ func handleSessionFork(profile string, args []string) { os.Exit(1) } + if worktreeType != "" { + forkedInst.WorktreeType = worktreeType + } + // Apply sandbox config if requested. if *sandbox { forkedInst.Sandbox = session.NewSandboxConfig(*sandboxImage) diff --git a/cmd/agent-deck/vcs_helper.go b/cmd/agent-deck/vcs_helper.go new file mode 100644 index 00000000..3f261907 --- /dev/null +++ b/cmd/agent-deck/vcs_helper.go @@ -0,0 +1,20 @@ +package main + +import ( + "fmt" + + "github.com/asheshgoplani/agent-deck/internal/git" + "github.com/asheshgoplani/agent-deck/internal/vcs" +) + +// detectAndCreateBackend detects the VCS type for the given directory and +// creates the appropriate backend. Returns the backend and the WorktreeType +// string to store on the session Instance. +func detectAndCreateBackend(dir string) (vcs.Backend, error) { + var b vcs.Backend + b, err := git.NewGitBackend(dir) + if err == nil { + return b, nil + } + return nil, fmt.Errorf("failed to initialize backend: %w", err) +} diff --git a/cmd/agent-deck/worktree_cmd.go b/cmd/agent-deck/worktree_cmd.go index f2c6b9e2..5af2d6c7 100644 --- a/cmd/agent-deck/worktree_cmd.go +++ b/cmd/agent-deck/worktree_cmd.go @@ -10,6 +10,7 @@ import ( "github.com/asheshgoplani/agent-deck/internal/git" "github.com/asheshgoplani/agent-deck/internal/session" + "github.com/asheshgoplani/agent-deck/internal/vcs" ) // handleWorktree dispatches worktree subcommands @@ -91,21 +92,14 @@ func handleWorktreeList(profile string, args []string) { os.Exit(1) } - // Check if in a git repo - if !git.IsGitRepo(cwd) { - out.Error("not in a git repository", ErrCodeInvalidOperation) - os.Exit(1) - } - - // Get repo root (resolve through worktrees to prevent nesting) - repoRoot, err := git.GetWorktreeBaseRoot(cwd) + backend, err := detectAndCreateBackend(cwd) if err != nil { - out.Error(fmt.Sprintf("failed to get repo root: %v", err), ErrCodeInvalidOperation) + out.Error(fmt.Sprintf("%v", err), ErrCodeInvalidOperation) os.Exit(1) } // List worktrees - worktrees, err := git.ListWorktrees(repoRoot) + worktrees, err := backend.ListWorktrees() if err != nil { out.Error(fmt.Sprintf("failed to list worktrees: %v", err), ErrCodeInvalidOperation) os.Exit(1) @@ -160,7 +154,7 @@ func handleWorktreeList(profile string, args []string) { if *jsonOutput { out.Print("", map[string]interface{}{ - "repo_root": repoRoot, + "repo_root": backend.RepoDir(), "worktrees": results, "count": len(results), }) @@ -173,7 +167,7 @@ func handleWorktreeList(profile string, args []string) { return } - fmt.Printf("Repository: %s\n\n", FormatPath(repoRoot)) + fmt.Printf("Repository: %s\n\n", FormatPath(backend.RepoDir())) fmt.Printf("%-40s %-20s %-10s %s\n", "PATH", "BRANCH", "TYPE", "SESSION") fmt.Printf("%-40s %-20s %-10s %s\n", strings.Repeat("-", 40), strings.Repeat("-", 20), strings.Repeat("-", 10), strings.Repeat("-", 20)) @@ -328,31 +322,29 @@ func handleWorktreeCleanup(profile string, args []string) { } // Find orphaned worktrees (exist but no session points to them) - var orphanedWorktrees []git.Worktree - var repoRoot string - - if git.IsGitRepo(cwd) { - repoRoot, err = git.GetWorktreeBaseRoot(cwd) - if err == nil { - worktrees, err := git.ListWorktrees(repoRoot) - if err == nil { - // Build set of paths that sessions use - sessionPaths := make(map[string]bool) - for _, inst := range instances { - sessionPaths[inst.ProjectPath] = true - if inst.WorktreePath != "" { - sessionPaths[inst.WorktreePath] = true - } + var orphanedWorktrees []vcs.Worktree + var cleanupBackend vcs.Backend + + if cleanupB, bErr := detectAndCreateBackend(cwd); bErr == nil { + cleanupBackend = cleanupB + worktrees, wErr := cleanupBackend.ListWorktrees() + if wErr == nil { + // Build set of paths that sessions use + sessionPaths := make(map[string]bool) + for _, inst := range instances { + sessionPaths[inst.ProjectPath] = true + if inst.WorktreePath != "" { + sessionPaths[inst.WorktreePath] = true } + } - // Check each worktree (skip the first one which is usually the main repo) - for i, wt := range worktrees { - if i == 0 { - continue // Skip main repo - } - if !sessionPaths[wt.Path] { - orphanedWorktrees = append(orphanedWorktrees, wt) - } + // Check each worktree (skip the first one which is usually the main repo) + for i, wt := range worktrees { + if i == 0 { + continue // Skip main repo + } + if !sessionPaths[wt.Path] { + orphanedWorktrees = append(orphanedWorktrees, wt) } } } @@ -469,7 +461,11 @@ func handleWorktreeCleanup(profile string, args []string) { // Remove orphaned worktrees removedWorktrees := 0 for _, wt := range orphanedWorktrees { - if err := git.RemoveWorktree(repoRoot, wt.Path, false); err != nil { + if cleanupBackend == nil { + fmt.Fprintf(os.Stderr, "Warning: no VCS backend for worktree removal\n") + break + } + if err := cleanupBackend.RemoveWorktree(wt.Path, false); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to remove worktree %s: %v\n", wt.Path, err) continue } @@ -547,7 +543,13 @@ func handleWorktreeFinish(profile string, args []string) { worktreePath := inst.WorktreePath worktreeBranch := inst.WorktreeBranch - // Check for uncommitted changes + finishBackend, err := inst.Backend() + if err != nil { + out.Error(fmt.Sprintf("failed to initialize VCS: %v", err), ErrCodeInvalidOperation) + os.Exit(1) + } + + // Check for uncommitted changes (uses worktree path, not repoDir — stays standalone) if !*force { dirty, err := git.HasUncommittedChanges(worktreePath) if err != nil { @@ -569,7 +571,7 @@ func handleWorktreeFinish(profile string, args []string) { // Determine target branch targetBranch := *into if targetBranch == "" && !*noMerge { - targetBranch, err = git.GetDefaultBranch(repoRoot) + targetBranch, err = finishBackend.GetDefaultBranch() if err != nil { out.Error(fmt.Sprintf("could not determine target branch: %v\nUse --into to specify", err), ErrCodeInvalidOperation) os.Exit(1) @@ -623,7 +625,7 @@ func handleWorktreeFinish(profile string, args []string) { } // Merge the worktree branch - if err := git.MergeBranch(repoRoot, worktreeBranch); err != nil { + if err := finishBackend.MergeBranch(worktreeBranch); err != nil { // Abort the merge to leave things clean abortCmd := exec.Command("git", "-C", repoRoot, "merge", "--abort") _ = abortCmd.Run() @@ -636,18 +638,18 @@ func handleWorktreeFinish(profile string, args []string) { // Step 2: Remove worktree if _, statErr := os.Stat(worktreePath); !os.IsNotExist(statErr) { fmt.Printf("Removing worktree at %s...\n", FormatPath(worktreePath)) - if err := git.RemoveWorktree(repoRoot, worktreePath, *force); err != nil { + if err := finishBackend.RemoveWorktree(worktreePath, *force); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to remove worktree: %v\n", err) } else { fmt.Printf(" %s Worktree removed\n", successSymbol) } } - _ = git.PruneWorktrees(repoRoot) + _ = finishBackend.PruneWorktrees() // Step 3: Delete branch (if not --keep-branch) if !*keepBranch { fmt.Printf("Deleting branch %s...\n", worktreeBranch) - if err := git.DeleteBranch(repoRoot, worktreeBranch, *force); err != nil { + if err := finishBackend.DeleteBranch(worktreeBranch, *force); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to delete branch: %v\n", err) } else { fmt.Printf(" %s Branch deleted\n", successSymbol) diff --git a/internal/git/git.go b/internal/git/git.go index 4f5c891a..af97991d 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -11,16 +11,48 @@ import ( "regexp" "sort" "strings" + + "github.com/asheshgoplani/agent-deck/internal/vcs" ) var consecutiveDashesRe = regexp.MustCompile(`-+`) -// Worktree represents a git worktree -type Worktree struct { - Path string // Filesystem path to the worktree - Branch string // Branch name checked out in this worktree - Commit string // HEAD commit SHA - Bare bool // Whether this is the bare repository +// GitBackend encapsulates a repository directory and provides methods that +// implement the vcs.Backend interface. Construct via NewGitBackend. +type GitBackend struct { + repoDir string +} + +// Compile-time check that *GitBackend satisfies vcs.Backend. +var _ vcs.Backend = (*GitBackend)(nil) + +// NewGitBackend validates dir is a git repository, resolves through worktrees +// to prevent nesting, and returns a GitBackend rooted at the main repo. +func NewGitBackend(dir string) (*GitBackend, error) { + if !IsGitRepo(dir) { + return nil, fmt.Errorf("not a git repository: %s", dir) + } + root, err := GetWorktreeBaseRoot(dir) + if err != nil { + return nil, err + } + return &GitBackend{repoDir: root}, nil +} + +func (g *GitBackend) Type() vcs.Type { return vcs.TypeGit } + +// RepoDir returns the root directory of the repository. +func (g *GitBackend) RepoDir() string { return g.repoDir } + +// WorktreePath generates a worktree path using the backend's repoDir. +func (g *GitBackend) WorktreePath(opts vcs.WorktreePathOptions) string { + return WorktreePath(WorktreePathOptions{ + Branch: opts.Branch, + Location: opts.Location, + RepoDir: g.repoDir, + SessionID: opts.SessionID, + Template: opts.Template, + }) } // IsGitRepo checks if the given directory is inside a git repository @@ -40,9 +72,9 @@ func GetRepoRoot(dir string) (string, error) { return strings.TrimSpace(string(output)), nil } -// GetCurrentBranch returns the current branch name for the repository at dir -func GetCurrentBranch(dir string) (string, error) { - cmd := exec.Command("git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD") +// GetCurrentBranch returns the current branch name. +func (g *GitBackend) GetCurrentBranch() (string, error) { + cmd := exec.Command("git", "-C", g.repoDir, "rev-parse", "--abbrev-ref", "HEAD") output, err := cmd.Output() if err != nil { return "", fmt.Errorf("failed to get current branch: %w", err) @@ -50,9 +82,9 @@ func GetCurrentBranch(dir string) (string, error) { return strings.TrimSpace(string(output)), nil } -// BranchExists checks if a branch exists in the repository -func BranchExists(repoDir, branchName string) bool { - cmd := exec.Command("git", "-C", repoDir, "show-ref", "--verify", "--quiet", "refs/heads/"+branchName) +// BranchExists checks if a branch exists in the repository. +func (g *GitBackend) BranchExists(branchName string) bool { + cmd := exec.Command("git", "-C", g.repoDir, "show-ref", "--verify", "--quiet", "refs/heads/"+branchName) err := cmd.Run() return err == nil } @@ -159,20 +191,19 @@ func GenerateWorktreePath(repoDir, branchName, location string) string { } } -// CreateWorktree creates a new git worktree at worktreePath for the given branch -// If the branch doesn't exist, it will be created -func CreateWorktree(repoDir, worktreePath, branchName string) error { - // Validate branch name first +// CreateWorktree creates a new git worktree. +// If the branch doesn't exist, it will be created. +func (g *GitBackend) CreateWorktree(worktreePath, branchName string) error { + if err := ValidateBranchName(branchName); err != nil { return fmt.Errorf("invalid branch name: %w", err) } - // Check if it's a git repo - if !IsGitRepo(repoDir) { + if !IsGitRepo(g.repoDir) { return errors.New("not a git repository") } - resolution, err := resolveWorktreeBranch(repoDir, branchName) + resolution, err := g.resolveWorktreeBranch(branchName) if err != nil { return err } @@ -180,15 +211,15 @@ func CreateWorktree(repoDir, worktreePath, branchName string) error { var cmd *exec.Cmd switch resolution.Mode { case worktreeBranchLocal: - // Reuse an existing local branch. - cmd = exec.Command("git", "-C", repoDir, "worktree", "add", worktreePath, branchName) + // Use existing branch + cmd = exec.Command("git", "-C", g.repoDir, "worktree", "add", worktreePath, branchName) case worktreeBranchRemote: // Create a local tracking branch from the default remote. remoteRef := resolution.Remote + "/" + branchName - cmd = exec.Command("git", "-C", repoDir, "worktree", "add", "--track", "-b", branchName, worktreePath, remoteRef) + cmd = exec.Command("git", "-C", g.repoDir, "worktree", "add", "--track", "-b", branchName, worktreePath, remoteRef) default: - // Create a new local branch. - cmd = exec.Command("git", "-C", repoDir, "worktree", "add", "-b", branchName, worktreePath) + // Create new branch with -b flag + cmd = exec.Command("git", "-C", g.repoDir, "worktree", "add", "-b", branchName, worktreePath) } output, err := cmd.CombinedOutput() @@ -199,13 +230,13 @@ func CreateWorktree(repoDir, worktreePath, branchName string) error { return nil } -// ListWorktrees returns all worktrees for the repository at repoDir -func ListWorktrees(repoDir string) ([]Worktree, error) { - if !IsGitRepo(repoDir) { +// ListWorktrees returns all worktrees for the repository. +func (g *GitBackend) ListWorktrees() ([]vcs.Worktree, error) { + if !IsGitRepo(g.repoDir) { return nil, errors.New("not a git repository") } - cmd := exec.Command("git", "-C", repoDir, "worktree", "list", "--porcelain") + cmd := exec.Command("git", "-C", g.repoDir, "worktree", "list", "--porcelain") output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("failed to list worktrees: %w", err) @@ -215,9 +246,9 @@ func ListWorktrees(repoDir string) ([]Worktree, error) { } // parseWorktreeList parses the output of `git worktree list --porcelain` -func parseWorktreeList(output string) []Worktree { - var worktrees []Worktree - var current Worktree +func parseWorktreeList(output string) []vcs.Worktree { + var worktrees []vcs.Worktree + var current vcs.Worktree scanner := bufio.NewScanner(strings.NewReader(output)) for scanner.Scan() { @@ -228,7 +259,7 @@ func parseWorktreeList(output string) []Worktree { if current.Path != "" { worktrees = append(worktrees, current) } - current = Worktree{} + current = vcs.Worktree{} continue } @@ -257,14 +288,14 @@ func parseWorktreeList(output string) []Worktree { return worktrees } -// RemoveWorktree removes a worktree from the repository -// If force is true, it will remove even if there are uncommitted changes -func RemoveWorktree(repoDir, worktreePath string, force bool) error { - if !IsGitRepo(repoDir) { +// RemoveWorktree removes a worktree from the repository. +// If force is true, it will remove even if there are uncommitted changes. +func (g *GitBackend) RemoveWorktree(worktreePath string, force bool) error { + if !IsGitRepo(g.repoDir) { return errors.New("not a git repository") } - args := []string{"-C", repoDir, "worktree", "remove"} + args := []string{"-C", g.repoDir, "worktree", "remove"} if force { args = append(args, "--force") } @@ -279,9 +310,9 @@ func RemoveWorktree(repoDir, worktreePath string, force bool) error { return nil } -// GetWorktreeForBranch returns the worktree path for a given branch, if any -func GetWorktreeForBranch(repoDir, branchName string) (string, error) { - worktrees, err := ListWorktrees(repoDir) +// GetWorktreeForBranch returns the worktree path for a given branch, if any. +func (g *GitBackend) GetWorktreeForBranch(branchName string) (string, error) { + worktrees, err := g.ListWorktrees() if err != nil { return "", err } @@ -390,8 +421,8 @@ func SanitizeBranchName(name string) string { return sanitized } -func resolveWorktreeBranch(repoDir, branchName string) (worktreeBranchResolution, error) { - if !IsGitRepo(repoDir) { +func (g *GitBackend) resolveWorktreeBranch(branchName string) (worktreeBranchResolution, error) { + if !IsGitRepo(g.repoDir) { return worktreeBranchResolution{}, errors.New("not a git repository") } @@ -400,13 +431,13 @@ func resolveWorktreeBranch(repoDir, branchName string) (worktreeBranchResolution Mode: worktreeBranchNew, } - if BranchExists(repoDir, branchName) { + if g.BranchExists(branchName) { resolution.Mode = worktreeBranchLocal return resolution, nil } - defaultRemote, err := getDefaultRemote(repoDir) - if err == nil && defaultRemote != "" && remoteBranchExists(repoDir, defaultRemote, branchName) { + defaultRemote, err := g.getDefaultRemote() + if err == nil && defaultRemote != "" && remoteBranchExists(g.repoDir, defaultRemote, branchName) { resolution.Mode = worktreeBranchRemote resolution.Remote = defaultRemote } @@ -414,8 +445,8 @@ func resolveWorktreeBranch(repoDir, branchName string) (worktreeBranchResolution return resolution, nil } -func getDefaultRemote(repoDir string) (string, error) { - remotes, err := listRemotes(repoDir) +func (g *GitBackend) getDefaultRemote() (string, error) { + remotes, err := listRemotes(g.repoDir) if err != nil { return "", err } @@ -423,9 +454,9 @@ func getDefaultRemote(repoDir string) (string, error) { return "", errors.New("no git remotes configured") } - currentBranch, err := GetCurrentBranch(repoDir) + currentBranch, err := g.GetCurrentBranch() if err == nil && currentBranch != "" && currentBranch != "HEAD" { - cmd := exec.Command("git", "-C", repoDir, "config", "--get", "branch."+currentBranch+".remote") + cmd := exec.Command("git", "-C", g.repoDir, "config", "--get", "branch."+currentBranch+".remote") output, err := cmd.Output() if err == nil { remote := strings.TrimSpace(string(output)) @@ -488,11 +519,8 @@ func listRefShortNames(repoDir string, refs ...string) ([]string, error) { // ListBranchCandidates returns unique branch names from local branches and the // default remote, normalized to plain branch names without a remote prefix. -func ListBranchCandidates(repoDir string) ([]string, error) { - if !IsGitRepo(repoDir) { - return nil, errors.New("not a git repository") - } - +func (g *GitBackend) ListBranchCandidates() ([]string, error) { + repoDir := g.repoDir repoRoot, err := GetWorktreeBaseRoot(repoDir) if err == nil && repoRoot != "" { repoDir = repoRoot @@ -508,7 +536,7 @@ func ListBranchCandidates(repoDir string) ([]string, error) { seen[branch] = struct{}{} } - if defaultRemote, err := getDefaultRemote(repoDir); err == nil && defaultRemote != "" { + if defaultRemote, err := g.getDefaultRemote(); err == nil && defaultRemote != "" { remoteBranches, err := listRefShortNames(repoDir, "refs/remotes/"+defaultRemote) if err != nil { return nil, err @@ -544,10 +572,10 @@ func HasUncommittedChanges(dir string) (bool, error) { return strings.TrimSpace(string(output)) != "", nil } -// GetDefaultBranch returns the default branch name (e.g. "main" or "master") for the repo -func GetDefaultBranch(repoDir string) (string, error) { +// GetDefaultBranch returns the default branch name (e.g. "main" or "master"). +func (g *GitBackend) GetDefaultBranch() (string, error) { // Try symbolic-ref first (works when remote HEAD is set) - cmd := exec.Command("git", "-C", repoDir, "symbolic-ref", "refs/remotes/origin/HEAD") + cmd := exec.Command("git", "-C", g.repoDir, "symbolic-ref", "refs/remotes/origin/HEAD") output, err := cmd.Output() if err == nil { ref := strings.TrimSpace(string(output)) @@ -558,19 +586,19 @@ func GetDefaultBranch(repoDir string) (string, error) { } // Fallback: check for common default branch names - if BranchExists(repoDir, "main") { + if g.BranchExists("main") { return "main", nil } - if BranchExists(repoDir, "master") { + if g.BranchExists("master") { return "master", nil } return "", errors.New("could not determine default branch (no origin/HEAD, no main or master branch)") } -// MergeBranch merges the given branch into the current branch of the repository -func MergeBranch(repoDir, branchName string) error { - cmd := exec.Command("git", "-C", repoDir, "merge", branchName) +// MergeBranch merges the given branch into the current branch. +func (g *GitBackend) MergeBranch(branchName string) error { + cmd := exec.Command("git", "-C", g.repoDir, "merge", branchName) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("merge failed: %s: %w", strings.TrimSpace(string(output)), err) @@ -579,12 +607,12 @@ func MergeBranch(repoDir, branchName string) error { } // DeleteBranch deletes a local branch. If force is true, uses -D (force delete). -func DeleteBranch(repoDir, branchName string, force bool) error { +func (g *GitBackend) DeleteBranch(branchName string, force bool) error { flag := "-d" if force { flag = "-D" } - cmd := exec.Command("git", "-C", repoDir, "branch", flag, branchName) + cmd := exec.Command("git", "-C", g.repoDir, "branch", flag, branchName) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to delete branch: %s: %w", strings.TrimSpace(string(output)), err) @@ -592,9 +620,9 @@ func DeleteBranch(repoDir, branchName string, force bool) error { return nil } -// PruneWorktrees removes stale worktree references -func PruneWorktrees(repoDir string) error { - cmd := exec.Command("git", "-C", repoDir, "worktree", "prune") +// PruneWorktrees removes stale worktree references. +func (g *GitBackend) PruneWorktrees() error { + cmd := exec.Command("git", "-C", g.repoDir, "worktree", "prune") output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to prune worktrees: %s: %w", strings.TrimSpace(string(output)), err) diff --git a/internal/git/git_test.go b/internal/git/git_test.go index c6cf528e..ccc8d108 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/asheshgoplani/agent-deck/internal/vcs" ) // Helper function to create a git repo for testing @@ -61,6 +63,28 @@ func createBranch(t *testing.T, dir, branchName string) { } } +// Helper to create a test repo and return a GitBackend for it +func newTestBackend(t *testing.T, dir string) *GitBackend { + t.Helper() + createTestRepo(t, dir) + backend, err := NewGitBackend(dir) + if err != nil { + t.Fatalf("failed to create GitBackend: %v", err) + } + return backend +} + +// getCurrentBranch is a test helper that gets current branch using raw git commands +func getCurrentBranch(t *testing.T, dir string) string { + t.Helper() + cmd := exec.Command("git", "-C", dir, "rev-parse", "--abbrev-ref", "HEAD") + output, err := cmd.Output() + if err != nil { + t.Fatalf("failed to get current branch: %v", err) + } + return strings.TrimSpace(string(output)) +} + func runGit(t *testing.T, dir string, args ...string) string { t.Helper() cmd := exec.Command("git", args...) @@ -162,17 +186,16 @@ func TestGetRepoRoot(t *testing.T) { }) } -func TestGetCurrentBranch(t *testing.T) { +func TestGitBackend_GetCurrentBranch_Extended(t *testing.T) { t.Run("returns main/master for new repo", func(t *testing.T) { dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) - branch, err := GetCurrentBranch(dir) + branch, err := backend.GetCurrentBranch() if err != nil { t.Fatalf("unexpected error: %v", err) } - // Could be main or master depending on git config if branch != "main" && branch != "master" { t.Errorf("expected main or master, got %s", branch) } @@ -180,7 +203,7 @@ func TestGetCurrentBranch(t *testing.T) { t.Run("returns correct branch after checkout", func(t *testing.T) { dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) createBranch(t, dir, "feature-branch") cmd := exec.Command("git", "checkout", "feature-branch") @@ -189,7 +212,7 @@ func TestGetCurrentBranch(t *testing.T) { t.Fatalf("failed to checkout branch: %v", err) } - branch, err := GetCurrentBranch(dir) + branch, err := backend.GetCurrentBranch() if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -198,44 +221,27 @@ func TestGetCurrentBranch(t *testing.T) { t.Errorf("expected feature-branch, got %s", branch) } }) - - t.Run("returns error for non-git directory", func(t *testing.T) { - dir := t.TempDir() - - _, err := GetCurrentBranch(dir) - if err == nil { - t.Error("expected error for non-git directory") - } - }) } -func TestBranchExists(t *testing.T) { +func TestGitBackend_BranchExists_Extended(t *testing.T) { t.Run("returns true for existing branch", func(t *testing.T) { dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) createBranch(t, dir, "existing-branch") - if !BranchExists(dir, "existing-branch") { + if !backend.BranchExists("existing-branch") { t.Error("expected BranchExists to return true for existing branch") } }) t.Run("returns false for non-existing branch", func(t *testing.T) { dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) - if BranchExists(dir, "nonexistent-branch") { + if backend.BranchExists("nonexistent-branch") { t.Error("expected BranchExists to return false for non-existing branch") } }) - - t.Run("returns false for non-git directory", func(t *testing.T) { - dir := t.TempDir() - - if BranchExists(dir, "any-branch") { - t.Error("expected BranchExists to return false for non-git directory") - } - }) } func TestValidateBranchName(t *testing.T) { @@ -448,29 +454,24 @@ func TestGenerateWorktreePath(t *testing.T) { }) } -func TestCreateWorktree(t *testing.T) { +func TestGitBackend_CreateWorktree(t *testing.T) { t.Run("creates worktree with existing branch", func(t *testing.T) { dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) createBranch(t, dir, "existing-branch") worktreePath := filepath.Join(t.TempDir(), "worktree") - err := CreateWorktree(dir, worktreePath, "existing-branch") + err := backend.CreateWorktree(worktreePath, "existing-branch") if err != nil { t.Fatalf("unexpected error: %v", err) } - // Verify worktree was created if _, err := os.Stat(worktreePath); os.IsNotExist(err) { t.Error("worktree directory was not created") } - // Verify it's on the correct branch - branch, err := GetCurrentBranch(worktreePath) - if err != nil { - t.Fatalf("failed to get branch: %v", err) - } + branch := getCurrentBranch(t, worktreePath) if branch != "existing-branch" { t.Errorf("expected branch existing-branch, got %s", branch) } @@ -478,25 +479,20 @@ func TestCreateWorktree(t *testing.T) { t.Run("creates worktree with new branch", func(t *testing.T) { dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) worktreePath := filepath.Join(t.TempDir(), "worktree") - err := CreateWorktree(dir, worktreePath, "new-branch") + err := backend.CreateWorktree(worktreePath, "new-branch") if err != nil { t.Fatalf("unexpected error: %v", err) } - // Verify worktree was created if _, err := os.Stat(worktreePath); os.IsNotExist(err) { t.Error("worktree directory was not created") } - // Verify it's on the new branch - branch, err := GetCurrentBranch(worktreePath) - if err != nil { - t.Fatalf("failed to get branch: %v", err) - } + branch := getCurrentBranch(t, worktreePath) if branch != "new-branch" { t.Errorf("expected branch new-branch, got %s", branch) } @@ -518,16 +514,25 @@ func TestCreateWorktree(t *testing.T) { runGit(t, dir, "checkout", "main") runGit(t, dir, "branch", "-D", "remote-only") + backend, err := NewGitBackend(dir) + if err != nil { + t.Fatalf("failed to create GitBackend: %v", err) + } + worktreePath := filepath.Join(t.TempDir(), "worktree") - if err := CreateWorktree(dir, worktreePath, "remote-only"); err != nil { + if err := backend.CreateWorktree(worktreePath, "remote-only"); err != nil { t.Fatalf("unexpected error: %v", err) } - if !BranchExists(dir, "remote-only") { + if !backend.BranchExists("remote-only") { t.Fatal("expected CreateWorktree to create a local tracking branch") } - branch, err := GetCurrentBranch(worktreePath) + wtBackend, err := NewGitBackend(worktreePath) + if err != nil { + t.Fatalf("failed to create GitBackend for worktree: %v", err) + } + branch, err := wtBackend.GetCurrentBranch() if err != nil { t.Fatalf("failed to get branch: %v", err) } @@ -543,25 +548,15 @@ func TestCreateWorktree(t *testing.T) { t.Run("returns error for invalid branch name", func(t *testing.T) { dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) worktreePath := filepath.Join(t.TempDir(), "worktree") - err := CreateWorktree(dir, worktreePath, "invalid..branch") + err := backend.CreateWorktree(worktreePath, "invalid..branch") if err == nil { t.Error("expected error for invalid branch name") } }) - - t.Run("returns error for non-git directory", func(t *testing.T) { - dir := t.TempDir() - worktreePath := filepath.Join(t.TempDir(), "worktree") - - err := CreateWorktree(dir, worktreePath, "branch") - if err == nil { - t.Error("expected error for non-git directory") - } - }) } func TestResolveWorktreeBranch(t *testing.T) { @@ -580,7 +575,11 @@ func TestResolveWorktreeBranch(t *testing.T) { runGit(t, dir, "push", "-u", "origin", "shared-branch") runGit(t, dir, "checkout", "main") - resolution, err := resolveWorktreeBranch(dir, "shared-branch") + backend, err := NewGitBackend(dir) + if err != nil { + t.Fatalf("failed to create GitBackend: %v", err) + } + resolution, err := backend.resolveWorktreeBranch("shared-branch") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -611,7 +610,11 @@ func TestListBranchCandidates(t *testing.T) { runGit(t, dir, "checkout", "main") runGit(t, dir, "branch", "-D", "feature/remote-only") - branches, err := ListBranchCandidates(dir) + backend, err := NewGitBackend(dir) + if err != nil { + t.Fatalf("failed to create GitBackend: %v", err) + } + branches, err := backend.ListBranchCandidates() if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -633,28 +636,25 @@ func containsString(items []string, want string) bool { return false } -func TestListWorktrees(t *testing.T) { +func TestGitBackend_ListWorktrees(t *testing.T) { t.Run("lists worktrees in repo", func(t *testing.T) { dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) - // Create a worktree worktreePath := filepath.Join(t.TempDir(), "worktree") - if err := CreateWorktree(dir, worktreePath, "feature-branch"); err != nil { + if err := backend.CreateWorktree(worktreePath, "feature-branch"); err != nil { t.Fatalf("failed to create worktree: %v", err) } - worktrees, err := ListWorktrees(dir) + worktrees, err := backend.ListWorktrees() if err != nil { t.Fatalf("unexpected error: %v", err) } - // Should have at least 2 worktrees (main + feature) if len(worktrees) < 2 { t.Errorf("expected at least 2 worktrees, got %d", len(worktrees)) } - // Find the feature worktree var found bool for _, wt := range worktrees { resolvedPath, _ := filepath.EvalSymlinks(wt.Path) @@ -670,34 +670,24 @@ func TestListWorktrees(t *testing.T) { t.Error("feature worktree not found in list") } }) - - t.Run("returns error for non-git directory", func(t *testing.T) { - dir := t.TempDir() - - _, err := ListWorktrees(dir) - if err == nil { - t.Error("expected error for non-git directory") - } - }) } -func TestRemoveWorktree(t *testing.T) { +func TestGitBackend_RemoveWorktree(t *testing.T) { t.Run("removes worktree", func(t *testing.T) { dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) worktreePath := filepath.Join(t.TempDir(), "worktree") - if err := CreateWorktree(dir, worktreePath, "feature-branch"); err != nil { + if err := backend.CreateWorktree(worktreePath, "feature-branch"); err != nil { t.Fatalf("failed to create worktree: %v", err) } - err := RemoveWorktree(dir, worktreePath, false) + err := backend.RemoveWorktree(worktreePath, false) if err != nil { t.Fatalf("unexpected error: %v", err) } - // Verify worktree was removed from list - worktrees, err := ListWorktrees(dir) + worktrees, err := backend.ListWorktrees() if err != nil { t.Fatalf("failed to list worktrees: %v", err) } @@ -713,20 +703,19 @@ func TestRemoveWorktree(t *testing.T) { t.Run("force removes worktree with changes", func(t *testing.T) { dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) worktreePath := filepath.Join(t.TempDir(), "worktree") - if err := CreateWorktree(dir, worktreePath, "feature-branch"); err != nil { + if err := backend.CreateWorktree(worktreePath, "feature-branch"); err != nil { t.Fatalf("failed to create worktree: %v", err) } - // Make uncommitted changes testFile := filepath.Join(worktreePath, "newfile.txt") if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil { t.Fatalf("failed to create test file: %v", err) } - err := RemoveWorktree(dir, worktreePath, true) + err := backend.RemoveWorktree(worktreePath, true) if err != nil { t.Fatalf("unexpected error with force: %v", err) } @@ -734,9 +723,9 @@ func TestRemoveWorktree(t *testing.T) { t.Run("returns error for non-existent worktree", func(t *testing.T) { dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) - err := RemoveWorktree(dir, "/nonexistent/worktree", false) + err := backend.RemoveWorktree("/nonexistent/worktree", false) if err == nil { t.Error("expected error for non-existent worktree") } @@ -745,7 +734,7 @@ func TestRemoveWorktree(t *testing.T) { func TestWorktreeStruct(t *testing.T) { t.Run("worktree has expected fields", func(t *testing.T) { - wt := Worktree{ + wt := vcs.Worktree{ Path: "/path/to/worktree", Branch: "feature-branch", Commit: "abc123", @@ -768,36 +757,27 @@ func TestWorktreeStruct(t *testing.T) { } func TestIntegration_WorktreeLifecycle(t *testing.T) { - // Full lifecycle test: create repo -> create worktree -> list -> remove dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) - // Verify initial state if !IsGitRepo(dir) { t.Fatal("test repo is not a git repo") } - root, err := GetRepoRoot(dir) - if err != nil { - t.Fatalf("failed to get repo root: %v", err) - } - - branch, err := GetCurrentBranch(dir) + branch, err := backend.GetCurrentBranch() if err != nil { t.Fatalf("failed to get current branch: %v", err) } t.Logf("Initial branch: %s", branch) - // Create worktree - worktreePath := GenerateWorktreePath(root, "feature-test", "sibling") + worktreePath := GenerateWorktreePath(backend.RepoDir(), "feature-test", "sibling") t.Logf("Creating worktree at: %s", worktreePath) - if err := CreateWorktree(root, worktreePath, "feature-test"); err != nil { + if err := backend.CreateWorktree(worktreePath, "feature-test"); err != nil { t.Fatalf("failed to create worktree: %v", err) } - // List and verify - worktrees, err := ListWorktrees(root) + worktrees, err := backend.ListWorktrees() if err != nil { t.Fatalf("failed to list worktrees: %v", err) } @@ -806,18 +786,15 @@ func TestIntegration_WorktreeLifecycle(t *testing.T) { t.Errorf("expected 2 worktrees, got %d", len(worktrees)) } - // Verify branch exists now - if !BranchExists(root, "feature-test") { + if !backend.BranchExists("feature-test") { t.Error("feature-test branch should exist after worktree creation") } - // Remove worktree - if err := RemoveWorktree(root, worktreePath, false); err != nil { + if err := backend.RemoveWorktree(worktreePath, false); err != nil { t.Fatalf("failed to remove worktree: %v", err) } - // Verify removal - worktrees, err = ListWorktrees(root) + worktrees, err = backend.ListWorktrees() if err != nil { t.Fatalf("failed to list worktrees after removal: %v", err) } @@ -826,7 +803,6 @@ func TestIntegration_WorktreeLifecycle(t *testing.T) { t.Errorf("expected 1 worktree after removal, got %d", len(worktrees)) } - // Cleanup - remove the worktree directory if it still exists os.RemoveAll(worktreePath) } @@ -899,9 +875,9 @@ func TestHasUncommittedChanges(t *testing.T) { func TestGetDefaultBranch(t *testing.T) { t.Run("detects main branch", func(t *testing.T) { dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) - branch, err := GetDefaultBranch(dir) + branch, err := backend.GetDefaultBranch() if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -913,17 +889,17 @@ func TestGetDefaultBranch(t *testing.T) { t.Run("returns error when no default branch exists", func(t *testing.T) { dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) // Rename the default branch to something non-standard - currentBranch, _ := GetCurrentBranch(dir) - cmd := exec.Command("git", "branch", "-m", currentBranch, "develop") + curBranch := getCurrentBranch(t, dir) + cmd := exec.Command("git", "branch", "-m", curBranch, "develop") cmd.Dir = dir if err := cmd.Run(); err != nil { t.Fatalf("failed to rename branch: %v", err) } - _, err := GetDefaultBranch(dir) + _, err := backend.GetDefaultBranch() if err == nil { t.Error("expected error when no main/master branch exists") } @@ -933,22 +909,22 @@ func TestGetDefaultBranch(t *testing.T) { func TestDeleteBranch(t *testing.T) { t.Run("deletes merged branch", func(t *testing.T) { dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) createBranch(t, dir, "to-delete") - err := DeleteBranch(dir, "to-delete", false) + err := backend.DeleteBranch("to-delete", false) if err != nil { t.Fatalf("unexpected error: %v", err) } - if BranchExists(dir, "to-delete") { + if backend.BranchExists("to-delete") { t.Error("branch should have been deleted") } }) t.Run("force deletes unmerged branch", func(t *testing.T) { dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) // Create branch with a unique commit cmd := exec.Command("git", "checkout", "-b", "unmerged-branch") @@ -967,9 +943,8 @@ func TestDeleteBranch(t *testing.T) { _ = cmd.Run() // Switch back to default branch - defaultBranch, _ := GetCurrentBranch(dir) - if defaultBranch == "unmerged-branch" { - // Need to get the original branch name + curBranch := getCurrentBranch(t, dir) + if curBranch == "unmerged-branch" { cmd = exec.Command("git", "checkout", "-") cmd.Dir = dir if err := cmd.Run(); err != nil { @@ -978,18 +953,18 @@ func TestDeleteBranch(t *testing.T) { } // Regular delete should fail - err := DeleteBranch(dir, "unmerged-branch", false) + err := backend.DeleteBranch("unmerged-branch", false) if err == nil { t.Error("expected error deleting unmerged branch without force") } // Force delete should succeed - err = DeleteBranch(dir, "unmerged-branch", true) + err = backend.DeleteBranch("unmerged-branch", true) if err != nil { t.Fatalf("unexpected error with force delete: %v", err) } - if BranchExists(dir, "unmerged-branch") { + if backend.BranchExists("unmerged-branch") { t.Error("branch should have been force-deleted") } }) @@ -998,7 +973,7 @@ func TestDeleteBranch(t *testing.T) { func TestMergeBranch(t *testing.T) { t.Run("fast-forward merge succeeds", func(t *testing.T) { dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) // Create feature branch with a commit cmd := exec.Command("git", "checkout", "-b", "feature-merge") @@ -1024,7 +999,7 @@ func TestMergeBranch(t *testing.T) { } // Merge feature branch - err := MergeBranch(dir, "feature-merge") + err := backend.MergeBranch("feature-merge") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1039,10 +1014,10 @@ func TestMergeBranch(t *testing.T) { func TestPruneWorktrees(t *testing.T) { t.Run("prune after manually removing worktree dir", func(t *testing.T) { dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) worktreePath := filepath.Join(t.TempDir(), "worktree") - if err := CreateWorktree(dir, worktreePath, "prune-test"); err != nil { + if err := backend.CreateWorktree(worktreePath, "prune-test"); err != nil { t.Fatalf("failed to create worktree: %v", err) } @@ -1050,13 +1025,13 @@ func TestPruneWorktrees(t *testing.T) { os.RemoveAll(worktreePath) // Prune should clean up the stale reference - err := PruneWorktrees(dir) + err := backend.PruneWorktrees() if err != nil { t.Fatalf("unexpected error: %v", err) } // After pruning, listing worktrees should show only the main one - worktrees, err := ListWorktrees(dir) + worktrees, err := backend.ListWorktrees() if err != nil { t.Fatalf("failed to list worktrees: %v", err) } @@ -1078,10 +1053,10 @@ func TestIsWorktree(t *testing.T) { t.Run("returns true for worktree", func(t *testing.T) { dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) worktreePath := filepath.Join(t.TempDir(), "wt") - if err := CreateWorktree(dir, worktreePath, "feature-wt"); err != nil { + if err := backend.CreateWorktree(worktreePath, "feature-wt"); err != nil { t.Fatalf("failed to create worktree: %v", err) } @@ -1101,10 +1076,10 @@ func TestIsWorktree(t *testing.T) { func TestGetMainWorktreePath(t *testing.T) { t.Run("returns main repo from worktree", func(t *testing.T) { dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) worktreePath := filepath.Join(t.TempDir(), "wt") - if err := CreateWorktree(dir, worktreePath, "feature-main"); err != nil { + if err := backend.CreateWorktree(worktreePath, "feature-main"); err != nil { t.Fatalf("failed to create worktree: %v", err) } @@ -1159,10 +1134,10 @@ func TestGetWorktreeBaseRoot(t *testing.T) { t.Run("returns main repo root from worktree", func(t *testing.T) { dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) worktreePath := filepath.Join(t.TempDir(), "wt") - if err := CreateWorktree(dir, worktreePath, "feature-base"); err != nil { + if err := backend.CreateWorktree(worktreePath, "feature-base"); err != nil { t.Fatalf("failed to create worktree: %v", err) } @@ -1181,10 +1156,10 @@ func TestGetWorktreeBaseRoot(t *testing.T) { t.Run("returns main repo root from worktree subdirectory", func(t *testing.T) { dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) worktreePath := filepath.Join(t.TempDir(), "wt") - if err := CreateWorktree(dir, worktreePath, "feature-sub"); err != nil { + if err := backend.CreateWorktree(worktreePath, "feature-sub"); err != nil { t.Fatalf("failed to create worktree: %v", err) } @@ -1220,11 +1195,11 @@ func TestIntegration_WorktreeNesting(t *testing.T) { // When creating a worktree from within another worktree, the new worktree // should be a sibling (relative to the main repo), not nested inside the first. dir := t.TempDir() - createTestRepo(t, dir) + backend := newTestBackend(t, dir) // Create first worktree (simulates Session A) wt1Path := filepath.Join(dir, ".worktrees", "feature-a") - if err := CreateWorktree(dir, wt1Path, "feature-a"); err != nil { + if err := backend.CreateWorktree(wt1Path, "feature-a"); err != nil { t.Fatalf("failed to create first worktree: %v", err) } @@ -1243,7 +1218,7 @@ func TestIntegration_WorktreeNesting(t *testing.T) { // Create second worktree using the resolved base root (simulates Session B fork) wt2Path := GenerateWorktreePath(baseRoot, "feature-b", "subdirectory") - if err := CreateWorktree(baseRoot, wt2Path, "feature-b"); err != nil { + if err := backend.CreateWorktree(wt2Path, "feature-b"); err != nil { t.Fatalf("failed to create second worktree: %v", err) } @@ -1274,3 +1249,225 @@ func TestIntegration_WorktreeNesting(t *testing.T) { t.Logf("Correct path: %s", actualWt2) t.Logf("Wrong path: %s (would have been nested)", wrongWt2) } + +// --- GitBackend tests --- + +func TestNewGitBackend(t *testing.T) { + t.Run("succeeds for git repo", func(t *testing.T) { + dir := t.TempDir() + createTestRepo(t, dir) + + backend, err := NewGitBackend(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedRoot, _ := filepath.EvalSymlinks(dir) + actualRoot, _ := filepath.EvalSymlinks(backend.RepoDir()) + if actualRoot != expectedRoot { + t.Errorf("expected repo dir %s, got %s", expectedRoot, actualRoot) + } + }) + + t.Run("resolves through worktree", func(t *testing.T) { + dir := t.TempDir() + backend := newTestBackend(t, dir) + + wtPath := filepath.Join(t.TempDir(), "wt") + if err := backend.CreateWorktree(wtPath, "feature-backend"); err != nil { + t.Fatalf("failed to create worktree: %v", err) + } + + backend, err := NewGitBackend(wtPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedRoot, _ := filepath.EvalSymlinks(dir) + actualRoot, _ := filepath.EvalSymlinks(backend.RepoDir()) + if actualRoot != expectedRoot { + t.Errorf("expected main repo root %s, got %s", expectedRoot, actualRoot) + } + }) + + t.Run("returns error for non-git directory", func(t *testing.T) { + dir := t.TempDir() + + _, err := NewGitBackend(dir) + if err == nil { + t.Error("expected error for non-git directory") + } + }) +} + +func TestGitBackend_BranchExists(t *testing.T) { + dir := t.TempDir() + createTestRepo(t, dir) + createBranch(t, dir, "exists") + + backend, err := NewGitBackend(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !backend.BranchExists("exists") { + t.Error("expected BranchExists to return true") + } + if backend.BranchExists("nope") { + t.Error("expected BranchExists to return false") + } +} + +func TestGitBackend_GetCurrentBranch(t *testing.T) { + dir := t.TempDir() + createTestRepo(t, dir) + + backend, err := NewGitBackend(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + branch, err := backend.GetCurrentBranch() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if branch != "main" && branch != "master" { + t.Errorf("expected main or master, got %s", branch) + } +} + +func TestGitBackend_GetDefaultBranch(t *testing.T) { + dir := t.TempDir() + createTestRepo(t, dir) + + backend, err := NewGitBackend(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + branch, err := backend.GetDefaultBranch() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if branch != "main" && branch != "master" { + t.Errorf("expected main or master, got %s", branch) + } +} + +func TestGitBackend_WorktreeLifecycle(t *testing.T) { + dir := t.TempDir() + createTestRepo(t, dir) + + backend, err := NewGitBackend(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Create worktree + wtPath := filepath.Join(t.TempDir(), "wt") + if err := backend.CreateWorktree(wtPath, "feature-lifecycle"); err != nil { + t.Fatalf("failed to create worktree: %v", err) + } + + // List worktrees + worktrees, err := backend.ListWorktrees() + if err != nil { + t.Fatalf("failed to list worktrees: %v", err) + } + if len(worktrees) < 2 { + t.Errorf("expected at least 2 worktrees, got %d", len(worktrees)) + } + + // GetWorktreeForBranch + foundPath, err := backend.GetWorktreeForBranch("feature-lifecycle") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + resolvedFound, _ := filepath.EvalSymlinks(foundPath) + resolvedExpected, _ := filepath.EvalSymlinks(wtPath) + if resolvedFound != resolvedExpected { + t.Errorf("expected worktree path %s, got %s", resolvedExpected, resolvedFound) + } + + // Remove worktree + if err := backend.RemoveWorktree(wtPath, false); err != nil { + t.Fatalf("failed to remove worktree: %v", err) + } + + // Prune + if err := backend.PruneWorktrees(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify removal + worktrees, err = backend.ListWorktrees() + if err != nil { + t.Fatalf("failed to list after removal: %v", err) + } + if len(worktrees) != 1 { + t.Errorf("expected 1 worktree after removal, got %d", len(worktrees)) + } +} + +func TestGitBackend_MergeAndDeleteBranch(t *testing.T) { + dir := t.TempDir() + createTestRepo(t, dir) + + backend, err := NewGitBackend(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Create branch with commit + cmd := exec.Command("git", "checkout", "-b", "merge-test") + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to create branch: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "merge.txt"), []byte("merge"), 0644); err != nil { + t.Fatalf("failed to write: %v", err) + } + cmd = exec.Command("git", "add", ".") + cmd.Dir = dir + _ = cmd.Run() + cmd = exec.Command("git", "commit", "-m", "merge commit") + cmd.Dir = dir + _ = cmd.Run() + + // Switch back + cmd = exec.Command("git", "checkout", "-") + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatalf("failed to checkout: %v", err) + } + + // Merge + if err := backend.MergeBranch("merge-test"); err != nil { + t.Fatalf("merge failed: %v", err) + } + + // Delete + if err := backend.DeleteBranch("merge-test", false); err != nil { + t.Fatalf("delete failed: %v", err) + } + + if backend.BranchExists("merge-test") { + t.Error("branch should have been deleted") + } +} + +func TestGitBackend_ImplementsVCSBackend(t *testing.T) { + dir := t.TempDir() + createTestRepo(t, dir) + + backend, err := NewGitBackend(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Use through the interface to verify it satisfies vcs.Backend + var b vcs.Backend = backend + if b.RepoDir() == "" { + t.Error("expected non-empty RepoDir") + } +} diff --git a/internal/git/template_test.go b/internal/git/template_test.go index 8faba8c5..6ff473f1 100644 --- a/internal/git/template_test.go +++ b/internal/git/template_test.go @@ -5,6 +5,7 @@ import ( "regexp" "testing" + "github.com/asheshgoplani/agent-deck/internal/vcs" "github.com/stretchr/testify/require" ) @@ -430,6 +431,44 @@ func TestWorktreePath(t *testing.T) { }) } +func TestGitBackend_WorktreePath(t *testing.T) { + t.Parallel() + + t.Run("uses template via backend", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + createTestRepo(t, dir) + + backend, err := NewGitBackend(dir) + require.NoError(t, err) + + result := backend.WorktreePath(vcs.WorktreePathOptions{ + Branch: "feature-branch", + Location: "sibling", + SessionID: "a1b2c3d4", + Template: "{repo-root}/.worktrees/{branch}", + }) + expected := filepath.Join(backend.RepoDir(), ".worktrees", "feature-branch") + require.Equal(t, expected, result) + }) + + t.Run("falls back to location without template", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + createTestRepo(t, dir) + + backend, err := NewGitBackend(dir) + require.NoError(t, err) + + result := backend.WorktreePath(vcs.WorktreePathOptions{ + Branch: "feature-branch", + Location: "sibling", + }) + expected := backend.RepoDir() + "-feature-branch" + require.Equal(t, expected, result) + }) +} + func TestGeneratePathID(t *testing.T) { t.Parallel() diff --git a/internal/session/instance.go b/internal/session/instance.go index 30c9fc6a..0ec23140 100644 --- a/internal/session/instance.go +++ b/internal/session/instance.go @@ -24,9 +24,11 @@ import ( "time" "github.com/asheshgoplani/agent-deck/internal/docker" + "github.com/asheshgoplani/agent-deck/internal/git" "github.com/asheshgoplani/agent-deck/internal/logging" "github.com/asheshgoplani/agent-deck/internal/send" "github.com/asheshgoplani/agent-deck/internal/tmux" + "github.com/asheshgoplani/agent-deck/internal/vcs" ) var ( @@ -64,6 +66,12 @@ const ( codexProbeMissingSentinel = "__AGENT_DECK_MISSING_TOOL__" ) +type WorktreeType string + +const ( + WorktreeTypeGit WorktreeType = "git" +) + // Instance represents a single agent/shell session type Instance struct { ID string `json:"id"` @@ -78,6 +86,7 @@ type Instance struct { WorktreePath string `json:"worktree_path,omitempty"` // Path to worktree (if session is in worktree) WorktreeRepoRoot string `json:"worktree_repo_root,omitempty"` // Original repo root WorktreeBranch string `json:"worktree_branch,omitempty"` // Branch name in worktree + WorktreeType string `json:"worktree_type,omitempty"` // "git" or "" (auto-detect) // Multi-repo support MultiRepoEnabled bool `json:"multi_repo_enabled,omitempty"` @@ -363,6 +372,15 @@ func (inst *Instance) IsSubSession() bool { return inst.ParentSessionID != "" } +// Backend returns the backend for this instance +func (inst *Instance) Backend() (vcs.Backend, error) { + switch inst.WorktreeType { + case string(WorktreeTypeGit), "": + return git.NewGitBackend(inst.WorktreePath) + } + return nil, fmt.Errorf("Unrecognized VCS type: %s", inst.WorktreeType) +} + // IsWorktree returns true if this session is running in a git worktree func (inst *Instance) IsWorktree() bool { return inst.WorktreePath != "" diff --git a/internal/ui/branch_picker.go b/internal/ui/branch_picker.go index d5365d40..6dc8682b 100644 --- a/internal/ui/branch_picker.go +++ b/internal/ui/branch_picker.go @@ -61,12 +61,12 @@ func (c *branchPickerExecCmd) Run() error { return errors.New("project path is empty") } - repoRoot, err := git.GetWorktreeBaseRoot(projectPath) + backend, err := git.NewGitBackend(projectPath) if err != nil { return errors.New("path is not a git repository") } - branches, err := git.ListBranchCandidates(repoRoot) + branches, err := backend.ListBranchCandidates() if err != nil { return err } diff --git a/internal/ui/home.go b/internal/ui/home.go index e350557e..08f943f3 100644 --- a/internal/ui/home.go +++ b/internal/ui/home.go @@ -4293,15 +4293,9 @@ func (h *Home) handleNewDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Resolve worktree target if enabled; actual worktree creation runs in async command. var worktreePath, worktreeRepoRoot string if worktreeEnabled && branchName != "" { - // Validate path is a git repo - if !git.IsGitRepo(path) { - h.newDialog.SetError("Path is not a git repository") - return h, nil - } - - repoRoot, err := git.GetWorktreeBaseRoot(path) + wtBackend, err := git.NewGitBackend(path) if err != nil { - h.newDialog.SetError(fmt.Sprintf("Failed to get repo root: %v", err)) + h.newDialog.SetError(fmt.Sprintf("Failed to initialize git: %v", err)) return h, nil } @@ -4310,13 +4304,13 @@ func (h *Home) handleNewDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { worktreePath = git.WorktreePath(git.WorktreePathOptions{ Branch: branchName, Location: wtSettings.DefaultLocation, - RepoDir: repoRoot, + RepoDir: wtBackend.RepoDir(), SessionID: git.GeneratePathID(), Template: wtSettings.Template(), }) // Store repo root for later use - worktreeRepoRoot = repoRoot + worktreeRepoRoot = wtBackend.RepoDir() } // Build generic toolOptionsJSON from tool-specific options @@ -5053,8 +5047,10 @@ func (h *Home) handleMainKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } // Determine default target branch defaultBranch := "main" - if detected, err := git.GetDefaultBranch(inst.WorktreeRepoRoot); err == nil { - defaultBranch = detected + if wtb, bErr := inst.Backend(); bErr == nil { + if detected, dErr := wtb.GetDefaultBranch(); dErr == nil { + defaultBranch = detected + } } h.worktreeFinishDialog.SetSize(h.width, h.height) h.worktreeFinishDialog.Show(inst.ID, inst.Title, inst.WorktreeBranch, inst.WorktreeRepoRoot, inst.WorktreePath, defaultBranch) @@ -6014,13 +6010,9 @@ func (h *Home) handleForkDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Resolve worktree target if enabled; actual creation runs in async command. if worktreeEnabled && branchName != "" { - if !git.IsGitRepo(source.ProjectPath) { - h.forkDialog.SetError("Path is not a git repository") - return h, nil - } - repoRoot, err := git.GetWorktreeBaseRoot(source.ProjectPath) - if err != nil { - h.forkDialog.SetError(fmt.Sprintf("Failed to get repo root: %v", err)) + forkBackend, forkErr := git.NewGitBackend(source.ProjectPath) + if forkErr != nil { + h.forkDialog.SetError(fmt.Sprintf("Failed to initialize git: %v", forkErr)) return h, nil } @@ -6028,7 +6020,7 @@ func (h *Home) handleForkDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { worktreePath := git.WorktreePath(git.WorktreePathOptions{ Branch: branchName, Location: wtSettings.DefaultLocation, - RepoDir: repoRoot, + RepoDir: forkBackend.RepoDir(), SessionID: git.GeneratePathID(), Template: wtSettings.Template(), }) @@ -6038,7 +6030,7 @@ func (h *Home) handleForkDialogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { opts.WorkDir = worktreePath opts.WorktreePath = worktreePath - opts.WorktreeRepoRoot = repoRoot + opts.WorktreeRepoRoot = forkBackend.RepoDir() opts.WorktreeBranch = branchName } @@ -6303,14 +6295,18 @@ func (h *Home) createSessionInGroupWithWorktreeAndOptions( // Single-repo worktree: create here. Multi-repo worktrees are handled below. // // Check for an existing worktree for this branch before creating a new one. - if existingPath, err := git.GetWorktreeForBranch(worktreeRepoRoot, worktreeBranch); err == nil && existingPath != "" { + wtBackend, err := git.NewGitBackend(worktreeRepoRoot) + if err != nil { + return sessionCreatedMsg{err: fmt.Errorf("failed to initialize git: %w", err)} + } + if existingPath, err := wtBackend.GetWorktreeForBranch(worktreeBranch); err == nil && existingPath != "" { uiLog.Info("worktree_reuse", slog.String("branch", worktreeBranch), slog.String("path", existingPath)) worktreePath = existingPath } else { if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil { return sessionCreatedMsg{err: fmt.Errorf("failed to create parent directory: %w", err)} } - if err := git.CreateWorktree(worktreeRepoRoot, worktreePath, worktreeBranch); err != nil { + if err := wtBackend.CreateWorktree(worktreePath, worktreeBranch); err != nil { return sessionCreatedMsg{err: fmt.Errorf("failed to create worktree: %w", err)} } } @@ -6406,7 +6402,18 @@ func (h *Home) createSessionInGroupWithWorktreeAndOptions( } continue } - if err := git.CreateWorktree(repoRoot, wtPath, worktreeBranch); err != nil { + mrBackend, mrErr := git.NewGitBackend(repoRoot) + if mrErr != nil { + uiLog.Warn("multi_repo_worktree_create_fail", slog.String("path", p), slog.String("error", mrErr.Error())) + _ = os.Symlink(p, wtPath) + if i == 0 { + newProjectPath = wtPath + } else { + newAdditionalPaths = append(newAdditionalPaths, wtPath) + } + continue + } + if err := mrBackend.CreateWorktree(wtPath, worktreeBranch); err != nil { uiLog.Warn("multi_repo_worktree_create_fail", slog.String("path", p), slog.String("error", err.Error())) _ = os.Symlink(p, wtPath) if i == 0 { @@ -6675,14 +6682,18 @@ func (h *Home) forkSessionCmdWithOptions( // so the TUI remains responsive. // // Check for an existing worktree for this branch before creating a new one. - if existingPath, err := git.GetWorktreeForBranch(opts.WorktreeRepoRoot, opts.WorktreeBranch); err == nil && existingPath != "" { + forkWtBackend, err := git.NewGitBackend(opts.WorktreeRepoRoot) + if err != nil { + return sessionForkedMsg{err: fmt.Errorf("failed to initialize git: %w", err), sourceID: sourceID} + } + if existingPath, err := forkWtBackend.GetWorktreeForBranch(opts.WorktreeBranch); err == nil && existingPath != "" { uiLog.Info("worktree_reuse", slog.String("branch", opts.WorktreeBranch), slog.String("path", existingPath)) opts.WorktreePath = existingPath } else { if err := os.MkdirAll(filepath.Dir(opts.WorktreePath), 0o755); err != nil { return sessionForkedMsg{err: fmt.Errorf("failed to create directory: %w", err), sourceID: sourceID} } - if err := git.CreateWorktree(opts.WorktreeRepoRoot, opts.WorktreePath, opts.WorktreeBranch); err != nil { + if err := forkWtBackend.CreateWorktree(opts.WorktreePath, opts.WorktreeBranch); err != nil { return sessionForkedMsg{err: fmt.Errorf("worktree creation failed: %w", err), sourceID: sourceID} } } @@ -6782,15 +6793,16 @@ func (h *Home) deleteSession(inst *session.Instance) tea.Cmd { id := inst.ID isWorktree := inst.IsWorktree() worktreePath := inst.WorktreePath - worktreeRepoRoot := inst.WorktreeRepoRoot isMultiRepo := inst.IsMultiRepo() multiRepoTempDir := inst.MultiRepoTempDir multiRepoWorktrees := inst.MultiRepoWorktrees return func() tea.Msg { killErr := inst.Kill() if isWorktree { - _ = git.RemoveWorktree(worktreeRepoRoot, worktreePath, false) - _ = git.PruneWorktrees(worktreeRepoRoot) + if delBackend, bErr := inst.Backend(); bErr == nil { + _ = delBackend.RemoveWorktree(worktreePath, false) + _ = delBackend.PruneWorktrees() + } } if isMultiRepo { // Clean up multi-repo temp directory @@ -6799,8 +6811,10 @@ func (h *Home) deleteSession(inst *session.Instance) tea.Cmd { } // Clean up per-repo worktrees for _, wt := range multiRepoWorktrees { - _ = git.RemoveWorktree(wt.RepoRoot, wt.WorktreePath, false) - _ = git.PruneWorktrees(wt.RepoRoot) + if wtCleanBackend, wtErr := git.NewGitBackend(wt.RepoRoot); wtErr == nil { + _ = wtCleanBackend.RemoveWorktree(wt.WorktreePath, false) + _ = wtCleanBackend.PruneWorktrees() + } } } return sessionDeletedMsg{deletedID: id, killErr: killErr} @@ -11530,6 +11544,14 @@ func (h *Home) finishWorktree(inst *session.Instance, sessionID, sessionTitle, b return func() tea.Msg { merged := false + finBackend, bErr := inst.Backend() + if bErr != nil { + return worktreeFinishResultMsg{ + sessionID: sessionID, sessionTitle: sessionTitle, + err: fmt.Errorf("failed to initialize VCS: %v", bErr), + } + } + // Step 1: Merge (if requested) if mergeEnabled { // Checkout target branch in main repo @@ -11543,7 +11565,7 @@ func (h *Home) finishWorktree(inst *session.Instance, sessionID, sessionTitle, b } // Merge the worktree branch - if err := git.MergeBranch(repoRoot, branchName); err != nil { + if err := finBackend.MergeBranch(branchName); err != nil { // Abort the merge to leave things clean abortCmd := exec.Command("git", "-C", repoRoot, "merge", "--abort") _ = abortCmd.Run() @@ -11557,14 +11579,14 @@ func (h *Home) finishWorktree(inst *session.Instance, sessionID, sessionTitle, b // Step 2: Remove worktree if _, statErr := os.Stat(worktreePath); !os.IsNotExist(statErr) { - _ = git.RemoveWorktree(repoRoot, worktreePath, false) + _ = finBackend.RemoveWorktree(worktreePath, false) } - _ = git.PruneWorktrees(repoRoot) + _ = finBackend.PruneWorktrees() // Step 3: Delete branch (if not keeping) if !keepBranch { // Use force delete if we merged (branch is fully merged), regular delete otherwise - _ = git.DeleteBranch(repoRoot, branchName, merged) + _ = finBackend.DeleteBranch(branchName, merged) } // Step 4: Kill tmux session diff --git a/internal/vcs/vcs.go b/internal/vcs/vcs.go new file mode 100644 index 00000000..0ba10e26 --- /dev/null +++ b/internal/vcs/vcs.go @@ -0,0 +1,48 @@ +// Package vcs defines a version control system abstraction layer. +package vcs + +// Worktree represents a VCS worktree. +type Worktree struct { + Path string // Filesystem path to the worktree + Branch string // Branch name checked out in this worktree + Commit string // HEAD commit SHA + Bare bool // Whether this is the bare repository +} + +// WorktreePathOptions configures worktree path generation for a Backend. +// Unlike git.WorktreePathOptions, it omits RepoDir because the backend supplies it. +type WorktreePathOptions struct { + Branch string + Location string + SessionID string + Template string +} + +type Type string + +const ( + TypeGit Type = "git" +) + +// Backend abstracts version control operations scoped to a repository. +type Backend interface { + Type() Type + + // RepoDir returns the root directory of the repository. + RepoDir() string + + // Branch operations + BranchExists(branchName string) bool + GetCurrentBranch() (string, error) + GetDefaultBranch() (string, error) + DeleteBranch(branchName string, force bool) error + MergeBranch(branchName string) error + + // Worktree operations + WorktreePath(opts WorktreePathOptions) string + CreateWorktree(worktreePath, branchName string) error + ListWorktrees() ([]Worktree, error) + RemoveWorktree(worktreePath string, force bool) error + GetWorktreeForBranch(branchName string) (string, error) + PruneWorktrees() error +} From b7d18f5d76ffc1fb153a62cc3c52115fed4ef2b4 Mon Sep 17 00:00:00 2001 From: Stephen Jennings Date: Fri, 6 Mar 2026 14:39:32 -0800 Subject: [PATCH 2/3] feat(vcs): Add support for Jujutsu repositories Jujutsu repositories should create a jj workspace instead of a git worktree. However, the concepts are basically the same. Jujutsu doesn't actually require creating a named bookmark (branch), but removing the assumption that every worktree has a named branch would be a lot of work. To start, we can create and advance a bookmark as we do with git, then possibly relax that later. --- cmd/agent-deck/vcs_helper.go | 7 +- internal/jujutsu/jujutsu.go | 338 +++++++++++++++++++++++++++++++++++ internal/session/instance.go | 8 +- internal/ui/home.go | 41 ++++- internal/vcs/vcs.go | 3 +- 5 files changed, 386 insertions(+), 11 deletions(-) create mode 100644 internal/jujutsu/jujutsu.go diff --git a/cmd/agent-deck/vcs_helper.go b/cmd/agent-deck/vcs_helper.go index 3f261907..b98bf07f 100644 --- a/cmd/agent-deck/vcs_helper.go +++ b/cmd/agent-deck/vcs_helper.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/asheshgoplani/agent-deck/internal/git" + "github.com/asheshgoplani/agent-deck/internal/jujutsu" "github.com/asheshgoplani/agent-deck/internal/vcs" ) @@ -12,7 +13,11 @@ import ( // string to store on the session Instance. func detectAndCreateBackend(dir string) (vcs.Backend, error) { var b vcs.Backend - b, err := git.NewGitBackend(dir) + b, err := jujutsu.NewJJBackend(dir) + if err == nil { + return b, nil + } + b, err = git.NewGitBackend(dir) if err == nil { return b, nil } diff --git a/internal/jujutsu/jujutsu.go b/internal/jujutsu/jujutsu.go new file mode 100644 index 00000000..ffb68a22 --- /dev/null +++ b/internal/jujutsu/jujutsu.go @@ -0,0 +1,338 @@ +// Package jujutsu provides jj (Jujutsu) VCS operations for agent-deck. +// It mirrors the internal/git package pattern with package-level functions +// that execute jj CLI commands. +package jujutsu + +import ( + "bufio" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/asheshgoplani/agent-deck/internal/git" + "github.com/asheshgoplani/agent-deck/internal/vcs" +) + +type JJBackend struct { + repoDir string +} + +// Compile-time check that *JJBackend satisfies vcs.Backend. +var _ vcs.Backend = (*JJBackend)(nil) + +func NewJJBackend(dir string) (*JJBackend, error) { + if !IsJJRepo(dir) { + return nil, fmt.Errorf("not a jujutsu repository: %s", dir) + } + root, err := GetWorktreeBaseRoot(dir) + if err != nil { + return nil, err + } + return &JJBackend{root}, nil +} + +func (g *JJBackend) Type() vcs.Type { return vcs.TypeJujutsu } + +// RepoDir returns the root directory of the repository. +func (b *JJBackend) RepoDir() string { return b.repoDir } + +// WorktreePath generates a workspace path using the backend's repoDir. +// Delegates to the shared template logic in the git package (VCS-agnostic). +func (b *JJBackend) WorktreePath(opts vcs.WorktreePathOptions) string { + return git.WorktreePath(git.WorktreePathOptions{ + Branch: opts.Branch, + Location: opts.Location, + RepoDir: b.repoDir, + SessionID: opts.SessionID, + Template: opts.Template, + }) +} + +// IsJJRepo checks if the given directory is inside a jj repository by running +// `jj root`. Returns false if jj is not installed or the directory is not a jj repo. +func IsJJRepo(dir string) bool { + if _, err := exec.LookPath("jj"); err != nil { + return false + } + cmd := exec.Command("jj", "root", "-R", dir, "--ignore-working-copy") + return cmd.Run() == nil +} + +// GetRepoRoot returns the root directory of the jj repository. +func GetRepoRoot(dir string) (string, error) { + cmd := exec.Command("jj", "root", "-R", dir, "--ignore-working-copy") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("not a jj repository: %w", err) + } + return strings.TrimSpace(string(output)), nil +} + +// GetCurrentBranch returns the first bookmark of the current working-copy change. +func (b *JJBackend) GetCurrentBranch() (string, error) { + cmd := exec.Command("jj", "log", "-r", "@", "--no-graph", "-T", "bookmarks", "-R", b.repoDir, "--ignore-working-copy") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get current bookmark: %w", err) + } + raw := strings.TrimSpace(string(output)) + if raw == "" { + return "", nil + } + // jj may return multiple bookmarks separated by spaces; take the first + parts := strings.Fields(raw) + // jj appends a '*' to bookmarks that have local changes; strip it + return strings.TrimRight(parts[0], "*"), nil +} + +// BranchExists checks if a bookmark exists in the repository. +func (b *JJBackend) BranchExists(branchName string) bool { + cmd := exec.Command("jj", "bookmark", "list", "--name", branchName, "-R", b.repoDir, "--ignore-working-copy") + output, err := cmd.Output() + if err != nil { + return false + } + return strings.TrimSpace(string(output)) != "" +} + +// Workspace represents a jj workspace parsed from `jj workspace list`. +type Workspace struct { + Name string + Path string +} + +// ListWorktrees returns all workspaces for the repository. +func (b *JJBackend) ListWorktrees() ([]vcs.Worktree, error) { + cmd := exec.Command("jj", "workspace", "list", "-R", b.repoDir, "-T", "name ++ ':' ++ target.commit_id() ++ \"\\n\"", "--ignore-working-copy") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to list workspaces: %w", err) + } + return parseWorkspacesList(string(output)) +} + +func parseWorkspacesList(output string) ([]vcs.Worktree, error) { + var workspaces []vcs.Worktree + scanner := bufio.NewScanner(strings.NewReader(string(output))) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + // Format: "name: rest..." + parts := strings.SplitN(line, ":", 2) + if len(parts) < 2 { + continue + } + name := strings.TrimSpace(parts[0]) + commitId := strings.TrimSpace(parts[1]) + workspaces = append(workspaces, vcs.Worktree{ + Branch: name, + Commit: commitId, + }) + } + return workspaces, nil +} + +// getWorkspacePath returns the filesystem path for a named workspace. +// It resolves the path from the repo root's .jj/working-copy stores. +func getWorkspacePath(repoDir, workspaceName string) (string, error) { + if workspaceName == "default" { + return GetRepoRoot(repoDir) + } + + cmd := exec.Command("jj", "workspace", "root", "--name", workspaceName, "--ignore-working-copy") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get workspace path: %w", err) + } + return string(output), nil +} + +// IsDefaultWorkspace returns true if the given directory is the default workspace. +func IsDefaultWorkspace(dir string) (bool, error) { + root, err := GetRepoRoot(dir) + if err != nil { + return false, err + } + absDir, err := filepath.Abs(dir) + if err != nil { + return false, err + } + return absDir == root, nil +} + +// IsWorktree checks if the directory is a non-default jj workspace. +func IsWorktree(dir string) bool { + isDefault, err := IsDefaultWorkspace(dir) + if err != nil { + return false + } + return !isDefault +} + +// GetWorktreeBaseRoot returns the default workspace path (equivalent to main worktree in git). +func GetWorktreeBaseRoot(dir string) (string, error) { + return GetRepoRoot(dir) +} + +// CreateWorkspace creates a new jj workspace at the given path. +func (b *JJBackend) CreateWorktree(workspacePath, branchName string) error { + // Derive workspace name from the path + wsName := workspaceNameFromPath(workspacePath) + + cmd := exec.Command("jj", "workspace", "add", "--name", wsName, workspacePath, "-R", b.repoDir) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to create workspace: %s: %w", strings.TrimSpace(string(output)), err) + } + + // Create/set bookmark on the new workspace's working copy + if branchName != "" { + if b.BranchExists(branchName) { + // Set existing bookmark to point to the new workspace's working copy + cmd = exec.Command("jj", "bookmark", "set", branchName, "-r", "@", "-R", workspacePath) + } else { + // Create new bookmark + cmd = exec.Command("jj", "bookmark", "create", branchName, "-r", "@", "-R", workspacePath) + } + output, err = cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to set bookmark: %s: %w", strings.TrimSpace(string(output)), err) + } + } + + return nil +} + +// RemoveWorktree forgets a workspace and optionally removes its directory. +func (b *JJBackend) RemoveWorktree(workspacePath string, force bool) error { + wsName := workspaceNameFromPath(workspacePath) + + cmd := exec.Command("jj", "workspace", "forget", wsName, "-R", b.repoDir) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to forget workspace: %s: %w", strings.TrimSpace(string(output)), err) + } + + // jj workspace forget doesn't remove the directory, so we do it ourselves + if err := os.RemoveAll(workspacePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove workspace directory: %w", err) + } + + return nil +} + +// PruneWorktrees removes workspace entries whose directories no longer exist. +func (b *JJBackend) PruneWorktrees() error { + workspaces, err := b.ListWorktrees() + if err != nil { + return err + } + for _, ws := range workspaces { + if ws.Path == "default" { + continue + } + path, pathErr := getWorkspacePath(b.repoDir, ws.Branch) + if pathErr != nil { + continue + } + if _, statErr := os.Stat(path); os.IsNotExist(statErr) { + cmd := exec.Command("jj", "workspace", "forget", ws.Branch, "-R", b.repoDir) + _ = cmd.Run() + } + } + return nil +} + +// HasUncommittedChanges checks if the working copy has uncommitted changes. +func (b *JJBackend) HasUncommittedChanges() (bool, error) { + cmd := exec.Command("jj", "diff", "--stat", "-R", b.repoDir, "--ignore-working-copy") + output, err := cmd.CombinedOutput() + if err != nil { + return false, fmt.Errorf("failed to check jj diff: %s: %w", strings.TrimSpace(string(output)), err) + } + return strings.TrimSpace(string(output)) != "", nil +} + +// GetDefaultBranch returns the default branch name (checks for main/master bookmarks). +func (b *JJBackend) GetDefaultBranch() (string, error) { + if b.BranchExists("main") { + return "main", nil + } + if b.BranchExists("master") { + return "master", nil + } + return "", errors.New("could not determine default branch (no main or master bookmark)") +} + +// MergeBranch creates a merge change combining the current change with the given bookmark. +func (b *JJBackend) MergeBranch(branchName string) error { + cmd := exec.Command("jj", "new", "@", branchName, "-R", b.repoDir) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("merge failed: %s: %w", strings.TrimSpace(string(output)), err) + } + return nil +} + +// DeleteBranch deletes a bookmark. +func (b *JJBackend) DeleteBranch(branchName string, force bool) error { + cmd := exec.Command("jj", "bookmark", "delete", branchName, "-R", b.repoDir) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to delete bookmark: %s: %w", strings.TrimSpace(string(output)), err) + } + return nil +} + +// CheckoutBranch moves the working copy to a new change based on the given bookmark. +func (b *JJBackend) CheckoutBranch(branchName string) error { + cmd := exec.Command("jj", "new", branchName, "-R", b.repoDir) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to checkout %s: %s: %w", branchName, strings.TrimSpace(string(output)), err) + } + return nil +} + +// AbortMerge undoes the last operation (equivalent to aborting a merge). +func AbortMerge(repoDir string) error { + cmd := exec.Command("jj", "undo", "-R", repoDir) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to undo: %s: %w", strings.TrimSpace(string(output)), err) + } + return nil +} + +// GetMainWorktreePath returns the path to the default workspace. +func GetMainWorktreePath(dir string) (string, error) { + return GetRepoRoot(dir) +} + +// workspaceNameFromPath generates a workspace name from a filesystem path. +func workspaceNameFromPath(path string) string { + name := filepath.Base(path) + // Replace characters that might be problematic in workspace names + name = strings.ReplaceAll(name, " ", "-") + return name +} + +func (b *JJBackend) GetWorktreeForBranch(branchName string) (string, error) { + worktrees, err := b.ListWorktrees() + if err != nil { + return "", err + } + + for _, wt := range worktrees { + if wt.Branch == branchName { + return wt.Path, nil + } + } + + return "", nil +} diff --git a/internal/session/instance.go b/internal/session/instance.go index 0ec23140..42aa665f 100644 --- a/internal/session/instance.go +++ b/internal/session/instance.go @@ -25,6 +25,7 @@ import ( "github.com/asheshgoplani/agent-deck/internal/docker" "github.com/asheshgoplani/agent-deck/internal/git" + "github.com/asheshgoplani/agent-deck/internal/jujutsu" "github.com/asheshgoplani/agent-deck/internal/logging" "github.com/asheshgoplani/agent-deck/internal/send" "github.com/asheshgoplani/agent-deck/internal/tmux" @@ -69,7 +70,8 @@ const ( type WorktreeType string const ( - WorktreeTypeGit WorktreeType = "git" + WorktreeTypeGit WorktreeType = "git" + WorktreeTypeJujutsu WorktreeType = "jujutsu" ) // Instance represents a single agent/shell session @@ -86,7 +88,7 @@ type Instance struct { WorktreePath string `json:"worktree_path,omitempty"` // Path to worktree (if session is in worktree) WorktreeRepoRoot string `json:"worktree_repo_root,omitempty"` // Original repo root WorktreeBranch string `json:"worktree_branch,omitempty"` // Branch name in worktree - WorktreeType string `json:"worktree_type,omitempty"` // "git" or "" (auto-detect) + WorktreeType string `json:"worktree_type,omitempty"` // "git", "jujutsu", or "" (auto-detect) // Multi-repo support MultiRepoEnabled bool `json:"multi_repo_enabled,omitempty"` @@ -377,6 +379,8 @@ func (inst *Instance) Backend() (vcs.Backend, error) { switch inst.WorktreeType { case string(WorktreeTypeGit), "": return git.NewGitBackend(inst.WorktreePath) + case string(WorktreeTypeJujutsu): + return jujutsu.NewJJBackend(inst.WorktreePath) } return nil, fmt.Errorf("Unrecognized VCS type: %s", inst.WorktreeType) } diff --git a/internal/ui/home.go b/internal/ui/home.go index 08f943f3..1b3bd120 100644 --- a/internal/ui/home.go +++ b/internal/ui/home.go @@ -28,11 +28,13 @@ import ( "github.com/asheshgoplani/agent-deck/internal/clipboard" "github.com/asheshgoplani/agent-deck/internal/costs" "github.com/asheshgoplani/agent-deck/internal/git" + "github.com/asheshgoplani/agent-deck/internal/jujutsu" "github.com/asheshgoplani/agent-deck/internal/logging" "github.com/asheshgoplani/agent-deck/internal/session" "github.com/asheshgoplani/agent-deck/internal/statedb" "github.com/asheshgoplani/agent-deck/internal/tmux" "github.com/asheshgoplani/agent-deck/internal/update" + "github.com/asheshgoplani/agent-deck/internal/vcs" "github.com/asheshgoplani/agent-deck/internal/web" ) @@ -6276,6 +6278,19 @@ func (h *Home) loadUIState() { h.pendingCursorRestore = &state } +// detectVCSBackend detects the VCS type for the given directory and creates the +// appropriate backend. It tries jujutsu first (since jj repos also contain a git +// store that would match git detection), then falls back to git. +func detectVCSBackend(dir string) (vcs.Backend, error) { + if b, err := jujutsu.NewJJBackend(dir); err == nil { + return b, nil + } + if b, err := git.NewGitBackend(dir); err == nil { + return b, nil + } + return nil, fmt.Errorf("no supported VCS found in %s", dir) +} + // createSessionInGroupWithWorktreeAndOptions creates a new session with full options including YOLO mode, sandbox, and tool options. func (h *Home) createSessionInGroupWithWorktreeAndOptions( name, path, command, groupPath, worktreePath, worktreeRepoRoot, worktreeBranch string, @@ -6295,9 +6310,9 @@ func (h *Home) createSessionInGroupWithWorktreeAndOptions( // Single-repo worktree: create here. Multi-repo worktrees are handled below. // // Check for an existing worktree for this branch before creating a new one. - wtBackend, err := git.NewGitBackend(worktreeRepoRoot) + wtBackend, err := detectVCSBackend(worktreeRepoRoot) if err != nil { - return sessionCreatedMsg{err: fmt.Errorf("failed to initialize git: %w", err)} + return sessionCreatedMsg{err: fmt.Errorf("failed to initialize VCS backend: %w", err)} } if existingPath, err := wtBackend.GetWorktreeForBranch(worktreeBranch); err == nil && existingPath != "" { uiLog.Info("worktree_reuse", slog.String("branch", worktreeBranch), slog.String("path", existingPath)) @@ -6347,6 +6362,12 @@ func (h *Home) createSessionInGroupWithWorktreeAndOptions( inst.WorktreePath = worktreePath inst.WorktreeRepoRoot = worktreeRepoRoot inst.WorktreeBranch = worktreeBranch + + worktreeType := vcs.TypeGit + if jujutsu.IsJJRepo(worktreeRepoRoot) { + worktreeType = vcs.TypeJujutsu + } + inst.WorktreeType = string(worktreeType) } applyCreateSessionToolOverrides(inst, tool, geminiYoloMode) @@ -6389,8 +6410,14 @@ func (h *Home) createSessionInGroupWithWorktreeAndOptions( var newAdditionalPaths []string for i, p := range allPaths { wtPath := filepath.Join(parentDir, dirnames[i]) - if git.IsGitRepo(p) { - repoRoot, rootErr := git.GetWorktreeBaseRoot(p) + if jujutsu.IsJJRepo(p) || git.IsGitRepo(p) { + var repoRoot string + var rootErr error + if jujutsu.IsJJRepo(p) { + repoRoot, rootErr = jujutsu.GetWorktreeBaseRoot(p) + } else { + repoRoot, rootErr = git.GetWorktreeBaseRoot(p) + } if rootErr != nil { uiLog.Warn("multi_repo_worktree_skip", slog.String("path", p), slog.String("error", rootErr.Error())) // Copy path as-is into the parent dir via symlink @@ -6402,7 +6429,7 @@ func (h *Home) createSessionInGroupWithWorktreeAndOptions( } continue } - mrBackend, mrErr := git.NewGitBackend(repoRoot) + mrBackend, mrErr := detectVCSBackend(repoRoot) if mrErr != nil { uiLog.Warn("multi_repo_worktree_create_fail", slog.String("path", p), slog.String("error", mrErr.Error())) _ = os.Symlink(p, wtPath) @@ -6682,9 +6709,9 @@ func (h *Home) forkSessionCmdWithOptions( // so the TUI remains responsive. // // Check for an existing worktree for this branch before creating a new one. - forkWtBackend, err := git.NewGitBackend(opts.WorktreeRepoRoot) + forkWtBackend, err := detectVCSBackend(opts.WorktreeRepoRoot) if err != nil { - return sessionForkedMsg{err: fmt.Errorf("failed to initialize git: %w", err), sourceID: sourceID} + return sessionForkedMsg{err: fmt.Errorf("failed to initialize VCS backend: %w", err), sourceID: sourceID} } if existingPath, err := forkWtBackend.GetWorktreeForBranch(opts.WorktreeBranch); err == nil && existingPath != "" { uiLog.Info("worktree_reuse", slog.String("branch", opts.WorktreeBranch), slog.String("path", existingPath)) diff --git a/internal/vcs/vcs.go b/internal/vcs/vcs.go index 0ba10e26..c0ba8fe2 100644 --- a/internal/vcs/vcs.go +++ b/internal/vcs/vcs.go @@ -21,7 +21,8 @@ type WorktreePathOptions struct { type Type string const ( - TypeGit Type = "git" + TypeGit Type = "git" + TypeJujutsu Type = "jujutsu" ) // Backend abstracts version control operations scoped to a repository. From 7fe8495267895f0b20053e4f7ad21fbd11242e04 Mon Sep 17 00:00:00 2001 From: Stephen Jennings Date: Fri, 6 Mar 2026 16:28:30 -0800 Subject: [PATCH 3/3] feat(preview): Show VCS type in preview window --- internal/ui/home.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/internal/ui/home.go b/internal/ui/home.go index 1b3bd120..e7ee72dc 100644 --- a/internal/ui/home.go +++ b/internal/ui/home.go @@ -10101,14 +10101,32 @@ func (h *Home) renderPreviewPane(width, height int) string { Background(ColorCyan). Padding(0, 1). Render(selected.GroupPath) + vcsBadgeText := string(vcs.TypeGit) + if selected.WorktreeType != "" { + vcsBadgeText = string(selected.WorktreeType) + } + vcsBadge := lipgloss.NewStyle(). + Foreground(ColorBg). + Background(ColorOrange). + Padding(0, 1). + Render(vcsBadgeText) b.WriteString(toolBadge) b.WriteString(" ") b.WriteString(groupBadge) + b.WriteString(" ") + b.WriteString(vcsBadge) b.WriteString("\n") // Worktree info section (for sessions running in git worktrees) if selected.IsWorktree() { - wtHeader := renderSectionDivider("Worktree", width-4) + var sectionHeader string + switch selected.WorktreeType { + case string(vcs.TypeGit), "": + sectionHeader = "Git Worktree" + case string(vcs.TypeJujutsu): + sectionHeader = "Jujutsu Workspace" + } + wtHeader := renderSectionDivider(sectionHeader, width-4) b.WriteString(wtHeader) b.WriteString("\n")