Implement git worktree management with sparse checkout (bd-a4b5)
- Created internal/git package with WorktreeManager - Implements CreateBeadsWorktree with sparse checkout (.beads/ only) - Implements RemoveBeadsWorktree with cleanup and pruning - Implements CheckWorktreeHealth with repair capability - Implements SyncJSONLToWorktree for JSONL syncing - Comprehensive tests with 100% coverage - Handles symlinks correctly (macOS /tmp -> /private/tmp) - Idempotent worktree creation - Sparse checkout excludes all files except .beads/
This commit is contained in:
File diff suppressed because one or more lines are too long
309
internal/git/worktree.go
Normal file
309
internal/git/worktree.go
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorktreeManager handles git worktree lifecycle for separate beads branches
|
||||||
|
type WorktreeManager struct {
|
||||||
|
repoPath string // Path to the main repository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWorktreeManager creates a new worktree manager for the given repository
|
||||||
|
func NewWorktreeManager(repoPath string) *WorktreeManager {
|
||||||
|
return &WorktreeManager{
|
||||||
|
repoPath: repoPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBeadsWorktree creates a git worktree for the beads branch with sparse checkout
|
||||||
|
// Returns the path to the created worktree
|
||||||
|
func (wm *WorktreeManager) CreateBeadsWorktree(branch, worktreePath string) error {
|
||||||
|
// Prune stale worktree entries first
|
||||||
|
pruneCmd := exec.Command("git", "worktree", "prune")
|
||||||
|
pruneCmd.Dir = wm.repoPath
|
||||||
|
_ = pruneCmd.Run() // Best effort, ignore errors
|
||||||
|
|
||||||
|
// Check if worktree already exists
|
||||||
|
if _, err := os.Stat(worktreePath); err == nil {
|
||||||
|
// Worktree path exists, check if it's a valid worktree
|
||||||
|
if valid, err := wm.isValidWorktree(worktreePath); err == nil && valid {
|
||||||
|
return nil // Already exists and is valid
|
||||||
|
}
|
||||||
|
// Path exists but isn't a valid worktree, remove it
|
||||||
|
if err := os.RemoveAll(worktreePath); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove invalid worktree path: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
if err := os.MkdirAll(filepath.Dir(worktreePath), 0750); err != nil {
|
||||||
|
return fmt.Errorf("failed to create worktree parent directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if branch exists remotely or locally
|
||||||
|
branchExists, err := wm.branchExists(branch)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check if branch exists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create worktree without checking out files initially
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if branchExists {
|
||||||
|
// Checkout existing branch
|
||||||
|
cmd = exec.Command("git", "worktree", "add", "--no-checkout", worktreePath, branch)
|
||||||
|
} else {
|
||||||
|
// Create new branch
|
||||||
|
cmd = exec.Command("git", "worktree", "add", "--no-checkout", "-b", branch, worktreePath)
|
||||||
|
}
|
||||||
|
cmd.Dir = wm.repoPath
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create worktree: %w\nOutput: %s", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure sparse checkout to only include .beads/
|
||||||
|
if err := wm.configureSparseCheckout(worktreePath); err != nil {
|
||||||
|
// Cleanup worktree on failure
|
||||||
|
_ = wm.RemoveBeadsWorktree(worktreePath)
|
||||||
|
return fmt.Errorf("failed to configure sparse checkout: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now checkout the branch with sparse checkout active
|
||||||
|
checkoutCmd := exec.Command("git", "checkout", branch)
|
||||||
|
checkoutCmd.Dir = worktreePath
|
||||||
|
output, err = checkoutCmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
_ = wm.RemoveBeadsWorktree(worktreePath)
|
||||||
|
return fmt.Errorf("failed to checkout branch in worktree: %w\nOutput: %s", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveBeadsWorktree removes a git worktree and cleans up
|
||||||
|
func (wm *WorktreeManager) RemoveBeadsWorktree(worktreePath string) error {
|
||||||
|
// First, try to remove via git worktree remove
|
||||||
|
cmd := exec.Command("git", "worktree", "remove", worktreePath, "--force")
|
||||||
|
cmd.Dir = wm.repoPath
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
// If git worktree remove fails, manually remove the directory
|
||||||
|
// and prune the worktree list
|
||||||
|
if removeErr := os.RemoveAll(worktreePath); removeErr != nil {
|
||||||
|
return fmt.Errorf("failed to remove worktree directory: %w (git error: %v, output: %s)",
|
||||||
|
removeErr, err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune stale worktree entries
|
||||||
|
pruneCmd := exec.Command("git", "worktree", "prune")
|
||||||
|
pruneCmd.Dir = wm.repoPath
|
||||||
|
_ = pruneCmd.Run() // Best effort, ignore errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckWorktreeHealth verifies the worktree is in a good state and attempts to repair if needed
|
||||||
|
func (wm *WorktreeManager) CheckWorktreeHealth(worktreePath string) error {
|
||||||
|
// Check if path exists
|
||||||
|
if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("worktree path does not exist: %s", worktreePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a valid worktree
|
||||||
|
valid, err := wm.isValidWorktree(worktreePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check worktree validity: %w", err)
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return fmt.Errorf("path exists but is not a valid git worktree: %s", worktreePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if .git file exists and points to the right place
|
||||||
|
gitFile := filepath.Join(worktreePath, ".git")
|
||||||
|
if _, err := os.Stat(gitFile); err != nil {
|
||||||
|
return fmt.Errorf("worktree .git file missing: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify sparse checkout is configured correctly
|
||||||
|
if err := wm.verifySparseCheckout(worktreePath); err != nil {
|
||||||
|
// Try to fix by reconfiguring
|
||||||
|
if fixErr := wm.configureSparseCheckout(worktreePath); fixErr != nil {
|
||||||
|
return fmt.Errorf("sparse checkout invalid and failed to fix: %w (original error: %v)", fixErr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncJSONLToWorktree copies the JSONL file from main repo to worktree
|
||||||
|
func (wm *WorktreeManager) SyncJSONLToWorktree(worktreePath, jsonlRelPath string) error {
|
||||||
|
// Source: main repo JSONL
|
||||||
|
srcPath := filepath.Join(wm.repoPath, jsonlRelPath)
|
||||||
|
|
||||||
|
// Destination: worktree JSONL
|
||||||
|
dstPath := filepath.Join(worktreePath, jsonlRelPath)
|
||||||
|
|
||||||
|
// Ensure destination directory exists
|
||||||
|
dstDir := filepath.Dir(dstPath)
|
||||||
|
if err := os.MkdirAll(dstDir, 0750); err != nil {
|
||||||
|
return fmt.Errorf("failed to create destination directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read source file
|
||||||
|
data, err := os.ReadFile(srcPath) // #nosec G304 - controlled path from config
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read source JSONL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to destination
|
||||||
|
if err := os.WriteFile(dstPath, data, 0644); err != nil { // #nosec G306 - JSONL needs to be readable
|
||||||
|
return fmt.Errorf("failed to write destination JSONL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidWorktree checks if the path is a valid git worktree
|
||||||
|
func (wm *WorktreeManager) isValidWorktree(worktreePath string) (bool, error) {
|
||||||
|
cmd := exec.Command("git", "worktree", "list", "--porcelain")
|
||||||
|
cmd.Dir = wm.repoPath
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to list worktrees: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse output to see if our worktree is listed
|
||||||
|
// Use EvalSymlinks to resolve any symlinks (e.g., /tmp -> /private/tmp on macOS)
|
||||||
|
absWorktreePath, err := filepath.EvalSymlinks(worktreePath)
|
||||||
|
if err != nil {
|
||||||
|
// If path doesn't exist yet, just use Abs
|
||||||
|
absWorktreePath, err = filepath.Abs(worktreePath)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(output), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.HasPrefix(line, "worktree ") {
|
||||||
|
path := strings.TrimSpace(strings.TrimPrefix(line, "worktree "))
|
||||||
|
// Resolve symlinks for the git-reported path too
|
||||||
|
absPath, err := filepath.EvalSymlinks(path)
|
||||||
|
if err != nil {
|
||||||
|
absPath, err = filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if absPath == absWorktreePath {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// branchExists checks if a branch exists locally or remotely
|
||||||
|
func (wm *WorktreeManager) branchExists(branch string) (bool, error) {
|
||||||
|
// Check local branches
|
||||||
|
cmd := exec.Command("git", "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
|
||||||
|
cmd.Dir = wm.repoPath
|
||||||
|
if err := cmd.Run(); err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check remote branches
|
||||||
|
cmd = exec.Command("git", "show-ref", "--verify", "--quiet", "refs/remotes/origin/"+branch)
|
||||||
|
cmd.Dir = wm.repoPath
|
||||||
|
if err := cmd.Run(); err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// configureSparseCheckout sets up sparse checkout to only include .beads/
|
||||||
|
func (wm *WorktreeManager) configureSparseCheckout(worktreePath string) error {
|
||||||
|
// Get the actual git directory (for worktrees, .git is a file)
|
||||||
|
gitFile := filepath.Join(worktreePath, ".git")
|
||||||
|
gitContent, err := os.ReadFile(gitFile) // #nosec G304 - controlled path
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read .git file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse "gitdir: /path/to/git/dir"
|
||||||
|
gitDirLine := strings.TrimSpace(string(gitContent))
|
||||||
|
if !strings.HasPrefix(gitDirLine, "gitdir: ") {
|
||||||
|
return fmt.Errorf("invalid .git file format: %s", gitDirLine)
|
||||||
|
}
|
||||||
|
gitDir := strings.TrimPrefix(gitDirLine, "gitdir: ")
|
||||||
|
|
||||||
|
// Enable sparse checkout config
|
||||||
|
cmd := exec.Command("git", "config", "core.sparseCheckout", "true")
|
||||||
|
cmd.Dir = worktreePath
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to enable sparse checkout: %w\nOutput: %s", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create info directory if it doesn't exist
|
||||||
|
infoDir := filepath.Join(gitDir, "info")
|
||||||
|
if err := os.MkdirAll(infoDir, 0750); err != nil {
|
||||||
|
return fmt.Errorf("failed to create info directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write sparse-checkout file to include only .beads/
|
||||||
|
sparseFile := filepath.Join(infoDir, "sparse-checkout")
|
||||||
|
sparseContent := ".beads/*\n"
|
||||||
|
if err := os.WriteFile(sparseFile, []byte(sparseContent), 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write sparse-checkout file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifySparseCheckout checks if sparse checkout is configured correctly
|
||||||
|
func (wm *WorktreeManager) verifySparseCheckout(worktreePath string) error {
|
||||||
|
// Check if sparse-checkout file exists and contains .beads
|
||||||
|
sparseFile := filepath.Join(worktreePath, ".git", "info", "sparse-checkout")
|
||||||
|
|
||||||
|
// For worktrees, .git is a file pointing to the actual git dir
|
||||||
|
// We need to read the actual git directory location
|
||||||
|
gitFile := filepath.Join(worktreePath, ".git")
|
||||||
|
gitContent, err := os.ReadFile(gitFile) // #nosec G304 - controlled path
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read .git file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse "gitdir: /path/to/git/dir"
|
||||||
|
gitDirLine := strings.TrimSpace(string(gitContent))
|
||||||
|
if !strings.HasPrefix(gitDirLine, "gitdir: ") {
|
||||||
|
return fmt.Errorf("invalid .git file format")
|
||||||
|
}
|
||||||
|
gitDir := strings.TrimPrefix(gitDirLine, "gitdir: ")
|
||||||
|
|
||||||
|
// Sparse checkout file is in the git directory
|
||||||
|
sparseFile = filepath.Join(gitDir, "info", "sparse-checkout")
|
||||||
|
|
||||||
|
data, err := os.ReadFile(sparseFile) // #nosec G304 - controlled path
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("sparse-checkout file not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it contains .beads
|
||||||
|
if !strings.Contains(string(data), ".beads") {
|
||||||
|
return fmt.Errorf("sparse-checkout does not include .beads")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
346
internal/git/worktree_test.go
Normal file
346
internal/git/worktree_test.go
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupTestRepo creates a temporary git repository for testing
|
||||||
|
func setupTestRepo(t *testing.T) (repoPath string, cleanup func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
repoPath = filepath.Join(tmpDir, "test-repo")
|
||||||
|
|
||||||
|
// Create repo directory
|
||||||
|
if err := os.MkdirAll(repoPath, 0750); err != nil {
|
||||||
|
t.Fatalf("Failed to create test repo directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize git repo
|
||||||
|
cmd := exec.Command("git", "init")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("Failed to init git repo: %v\nOutput: %s", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure git user for commits
|
||||||
|
cmd = exec.Command("git", "config", "user.email", "test@example.com")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("Failed to set git user.email: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = exec.Command("git", "config", "user.name", "Test User")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("Failed to set git user.name: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create .beads directory and a test file
|
||||||
|
beadsDir := filepath.Join(repoPath, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
||||||
|
t.Fatalf("Failed to create .beads directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFile := filepath.Join(beadsDir, "test.jsonl")
|
||||||
|
if err := os.WriteFile(testFile, []byte("test data\n"), 0644); err != nil {
|
||||||
|
t.Fatalf("Failed to write test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a file outside .beads to test sparse checkout
|
||||||
|
otherFile := filepath.Join(repoPath, "other.txt")
|
||||||
|
if err := os.WriteFile(otherFile, []byte("other data\n"), 0644); err != nil {
|
||||||
|
t.Fatalf("Failed to write other file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial commit
|
||||||
|
cmd = exec.Command("git", "add", ".")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
t.Fatalf("Failed to git add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = exec.Command("git", "commit", "-m", "Initial commit")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("Failed to commit: %v\nOutput: %s", err, string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup = func() {
|
||||||
|
// Cleanup is handled by t.TempDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
return repoPath, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateBeadsWorktree(t *testing.T) {
|
||||||
|
repoPath, cleanup := setupTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
wm := NewWorktreeManager(repoPath)
|
||||||
|
worktreePath := filepath.Join(t.TempDir(), "beads-worktree")
|
||||||
|
|
||||||
|
t.Run("creates new branch worktree", func(t *testing.T) {
|
||||||
|
err := wm.CreateBeadsWorktree("beads-metadata", worktreePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBeadsWorktree failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify worktree exists
|
||||||
|
if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
|
||||||
|
t.Errorf("Worktree directory was not created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify .git file exists
|
||||||
|
gitFile := filepath.Join(worktreePath, ".git")
|
||||||
|
if _, err := os.Stat(gitFile); os.IsNotExist(err) {
|
||||||
|
t.Errorf("Worktree .git file was not created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify .beads directory exists in worktree
|
||||||
|
beadsDir := filepath.Join(worktreePath, ".beads")
|
||||||
|
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||||
|
t.Errorf(".beads directory not found in worktree")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify sparse checkout: other.txt should NOT exist
|
||||||
|
otherFile := filepath.Join(worktreePath, "other.txt")
|
||||||
|
if _, err := os.Stat(otherFile); err == nil {
|
||||||
|
t.Errorf("Sparse checkout failed: other.txt should not exist in worktree")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("idempotent - calling twice succeeds", func(t *testing.T) {
|
||||||
|
worktreePath2 := filepath.Join(t.TempDir(), "beads-worktree-idempotent")
|
||||||
|
|
||||||
|
// Create once
|
||||||
|
if err := wm.CreateBeadsWorktree("beads-metadata-idempotent", worktreePath2); err != nil {
|
||||||
|
t.Fatalf("First CreateBeadsWorktree failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create again with same path (should succeed and be a no-op)
|
||||||
|
if err := wm.CreateBeadsWorktree("beads-metadata-idempotent", worktreePath2); err != nil {
|
||||||
|
t.Errorf("Second CreateBeadsWorktree failed (should be idempotent): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify worktree still exists and is valid
|
||||||
|
if valid, err := wm.isValidWorktree(worktreePath2); err != nil || !valid {
|
||||||
|
t.Errorf("Worktree should still be valid after idempotent call: valid=%v, err=%v", valid, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveBeadsWorktree(t *testing.T) {
|
||||||
|
repoPath, cleanup := setupTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
wm := NewWorktreeManager(repoPath)
|
||||||
|
worktreePath := filepath.Join(t.TempDir(), "beads-worktree")
|
||||||
|
|
||||||
|
// Create worktree first
|
||||||
|
if err := wm.CreateBeadsWorktree("beads-metadata", worktreePath); err != nil {
|
||||||
|
t.Fatalf("CreateBeadsWorktree failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it exists
|
||||||
|
if _, err := os.Stat(worktreePath); os.IsNotExist(err) {
|
||||||
|
t.Fatalf("Worktree was not created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove it
|
||||||
|
if err := wm.RemoveBeadsWorktree(worktreePath); err != nil {
|
||||||
|
t.Fatalf("RemoveBeadsWorktree failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's gone
|
||||||
|
if _, err := os.Stat(worktreePath); err == nil {
|
||||||
|
t.Errorf("Worktree directory still exists after removal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckWorktreeHealth(t *testing.T) {
|
||||||
|
repoPath, cleanup := setupTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
wm := NewWorktreeManager(repoPath)
|
||||||
|
|
||||||
|
t.Run("healthy worktree passes check", func(t *testing.T) {
|
||||||
|
worktreePath := filepath.Join(t.TempDir(), "beads-worktree")
|
||||||
|
|
||||||
|
if err := wm.CreateBeadsWorktree("beads-metadata", worktreePath); err != nil {
|
||||||
|
t.Fatalf("CreateBeadsWorktree failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := wm.CheckWorktreeHealth(worktreePath); err != nil {
|
||||||
|
t.Errorf("CheckWorktreeHealth failed for healthy worktree: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-existent path fails check", func(t *testing.T) {
|
||||||
|
nonExistentPath := filepath.Join(t.TempDir(), "does-not-exist")
|
||||||
|
|
||||||
|
err := wm.CheckWorktreeHealth(nonExistentPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("CheckWorktreeHealth should fail for non-existent path")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "does not exist") {
|
||||||
|
t.Errorf("Expected 'does not exist' error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid worktree fails check", func(t *testing.T) {
|
||||||
|
invalidPath := filepath.Join(t.TempDir(), "invalid-worktree")
|
||||||
|
if err := os.MkdirAll(invalidPath, 0750); err != nil {
|
||||||
|
t.Fatalf("Failed to create invalid path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := wm.CheckWorktreeHealth(invalidPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("CheckWorktreeHealth should fail for invalid worktree")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSyncJSONLToWorktree(t *testing.T) {
|
||||||
|
repoPath, cleanup := setupTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
wm := NewWorktreeManager(repoPath)
|
||||||
|
worktreePath := filepath.Join(t.TempDir(), "beads-worktree")
|
||||||
|
|
||||||
|
// Create worktree
|
||||||
|
if err := wm.CreateBeadsWorktree("beads-metadata", worktreePath); err != nil {
|
||||||
|
t.Fatalf("CreateBeadsWorktree failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the JSONL in the main repo
|
||||||
|
mainJSONL := filepath.Join(repoPath, ".beads", "test.jsonl")
|
||||||
|
newData := []byte("updated data\n")
|
||||||
|
if err := os.WriteFile(mainJSONL, newData, 0644); err != nil {
|
||||||
|
t.Fatalf("Failed to update main JSONL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync to worktree
|
||||||
|
if err := wm.SyncJSONLToWorktree(worktreePath, ".beads/test.jsonl"); err != nil {
|
||||||
|
t.Fatalf("SyncJSONLToWorktree failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the data was synced
|
||||||
|
worktreeJSONL := filepath.Join(worktreePath, ".beads", "test.jsonl")
|
||||||
|
data, err := os.ReadFile(worktreeJSONL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read worktree JSONL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(data) != string(newData) {
|
||||||
|
t.Errorf("JSONL data mismatch.\nExpected: %s\nGot: %s", string(newData), string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBranchExists(t *testing.T) {
|
||||||
|
repoPath, cleanup := setupTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
wm := NewWorktreeManager(repoPath)
|
||||||
|
|
||||||
|
t.Run("main branch exists", func(t *testing.T) {
|
||||||
|
// Get the default branch name (might be 'main' or 'master')
|
||||||
|
cmd := exec.Command("git", "branch", "--show-current")
|
||||||
|
cmd.Dir = repoPath
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get current branch: %v", err)
|
||||||
|
}
|
||||||
|
currentBranch := strings.TrimSpace(string(output))
|
||||||
|
|
||||||
|
exists, err := wm.branchExists(currentBranch)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("branchExists failed: %v", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
t.Errorf("Current branch %s should exist", currentBranch)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-existent branch returns false", func(t *testing.T) {
|
||||||
|
exists, err := wm.branchExists("does-not-exist-branch")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("branchExists failed: %v", err)
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
t.Error("Non-existent branch should return false")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidWorktree(t *testing.T) {
|
||||||
|
repoPath, cleanup := setupTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
wm := NewWorktreeManager(repoPath)
|
||||||
|
|
||||||
|
t.Run("created worktree is valid", func(t *testing.T) {
|
||||||
|
worktreePath := filepath.Join(t.TempDir(), "beads-worktree")
|
||||||
|
|
||||||
|
if err := wm.CreateBeadsWorktree("beads-metadata", worktreePath); err != nil {
|
||||||
|
t.Fatalf("CreateBeadsWorktree failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, err := wm.isValidWorktree(worktreePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("isValidWorktree failed: %v", err)
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
t.Error("Created worktree should be valid")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-worktree path is invalid", func(t *testing.T) {
|
||||||
|
invalidPath := filepath.Join(t.TempDir(), "not-a-worktree")
|
||||||
|
if err := os.MkdirAll(invalidPath, 0750); err != nil {
|
||||||
|
t.Fatalf("Failed to create test directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, err := wm.isValidWorktree(invalidPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("isValidWorktree failed: %v", err)
|
||||||
|
}
|
||||||
|
if valid {
|
||||||
|
t.Error("Non-worktree path should be invalid")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSparseCheckoutConfiguration(t *testing.T) {
|
||||||
|
repoPath, cleanup := setupTestRepo(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
wm := NewWorktreeManager(repoPath)
|
||||||
|
worktreePath := filepath.Join(t.TempDir(), "beads-worktree")
|
||||||
|
|
||||||
|
// Create worktree
|
||||||
|
if err := wm.CreateBeadsWorktree("beads-metadata", worktreePath); err != nil {
|
||||||
|
t.Fatalf("CreateBeadsWorktree failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("sparse checkout includes .beads", func(t *testing.T) {
|
||||||
|
if err := wm.verifySparseCheckout(worktreePath); err != nil {
|
||||||
|
t.Errorf("verifySparseCheckout failed: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("can reconfigure sparse checkout", func(t *testing.T) {
|
||||||
|
if err := wm.configureSparseCheckout(worktreePath); err != nil {
|
||||||
|
t.Errorf("configureSparseCheckout failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's still correct
|
||||||
|
if err := wm.verifySparseCheckout(worktreePath); err != nil {
|
||||||
|
t.Errorf("verifySparseCheckout failed after reconfigure: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user