feat: add Git worktree compatibility (PR #478)

Adds comprehensive Git worktree support for beads issue tracking:

Core changes:
- New internal/git/gitdir.go package for worktree detection
- GetGitDir() returns proper .git location (main repo, not worktree)
- Updated all hooks to use git.GetGitDir() instead of local helper
- BeadsDir() now prioritizes main repository's .beads directory

Features:
- Hooks auto-install in main repo when run from worktree
- Shared .beads directory across all worktrees
- Config option no-install-hooks to disable auto-install
- New bd worktree subcommand for diagnostics

Documentation:
- New docs/WORKTREES.md with setup instructions
- Updated CHANGELOG.md and AGENT_INSTRUCTIONS.md

Testing:
- Updated tests to use exported git.GetGitDir()
- Added worktree detection tests

Co-authored-by: Claude <noreply@anthropic.com>
Closes: #478
This commit is contained in:
matt wilkie
2025-12-13 10:40:40 -08:00
committed by Steve Yegge
parent de7b511765
commit e01b7412d9
64 changed files with 1895 additions and 3708 deletions

View File

@@ -16,6 +16,7 @@ import (
"strings"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
@@ -342,6 +343,7 @@ func hasBeadsProjectFiles(beadsDir string) bool {
// Validates that the directory contains actual project files (bd-420).
// Redirect files are supported: if a .beads/redirect file exists, its contents
// are used as the actual .beads directory path.
// For worktrees, prioritizes the main repository's .beads directory (bd-de6).
// This is useful for commands that need to detect beads projects without requiring a database.
func FindBeadsDir() string {
// 1. Check BEADS_DIR environment variable (preferred)
@@ -359,7 +361,26 @@ func FindBeadsDir() string {
}
}
// 2. Search for .beads/ in current directory and ancestors
// 2. For worktrees, check main repository root first (bd-de6)
var mainRepoRoot string
if git.IsWorktree() {
var err error
mainRepoRoot, err = git.GetMainRepoRoot()
if err == nil && mainRepoRoot != "" {
beadsDir := filepath.Join(mainRepoRoot, ".beads")
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
// Follow redirect if present
beadsDir = followRedirect(beadsDir)
// Validate directory contains actual project files (bd-420)
if hasBeadsProjectFiles(beadsDir) {
return beadsDir
}
}
}
}
// 3. Search for .beads/ in current directory and ancestors
cwd, err := os.Getwd()
if err != nil {
return ""
@@ -367,6 +388,10 @@ func FindBeadsDir() string {
// Find git root to limit the search (bd-c8x)
gitRoot := findGitRoot()
if git.IsWorktree() && mainRepoRoot != "" {
// For worktrees, extend search boundary to include main repo
gitRoot = mainRepoRoot
}
for dir := cwd; dir != "/" && dir != "."; dir = filepath.Dir(dir) {
beadsDir := filepath.Join(dir, ".beads")
@@ -414,6 +439,9 @@ type DatabaseInfo struct {
// findGitRoot returns the root directory of the current git repository,
// or empty string if not in a git repository. Used to limit directory
// tree walking to within the current git repo (bd-c8x).
//
// This function is worktree-aware and will correctly identify the repository
// root in both regular repositories and git worktrees.
func findGitRoot() string {
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
output, err := cmd.Output()
@@ -425,6 +453,7 @@ func findGitRoot() string {
// findDatabaseInTree walks up the directory tree looking for .beads/*.db
// Stops at the git repository root to avoid finding unrelated databases (bd-c8x).
// For worktrees, searches the main repository root first, then falls back to worktree.
// Prefers config.json, falls back to beads.db, and warns if multiple .db files exist.
// Redirect files are supported: if a .beads/redirect file exists, its contents
// are used as the actual .beads directory path.
@@ -440,10 +469,35 @@ func findDatabaseInTree() string {
dir = resolvedDir
}
// Check if we're in a git worktree
var mainRepoRoot string
if git.IsWorktree() {
// For worktrees, search main repository root first
var err error
mainRepoRoot, err = git.GetMainRepoRoot()
if err == nil && mainRepoRoot != "" {
beadsDir := filepath.Join(mainRepoRoot, ".beads")
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {
// Follow redirect if present
beadsDir = followRedirect(beadsDir)
// Use helper to find database (with warnings for auto-discovery)
if dbPath := findDatabaseInBeadsDir(beadsDir, true); dbPath != "" {
return dbPath
}
}
}
// If not found in main repo, fall back to worktree search below
}
// Find git root to limit the search (bd-c8x)
gitRoot := findGitRoot()
if git.IsWorktree() && mainRepoRoot != "" {
// For worktrees, extend search boundary to include main repo
gitRoot = mainRepoRoot
}
// Walk up directory tree
// Walk up directory tree (regular repository or worktree fallback)
for {
beadsDir := filepath.Join(dir, ".beads")
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {

View File

@@ -2,6 +2,7 @@ package beads
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
@@ -782,3 +783,473 @@ func TestFindBeadsDirWithRedirect(t *testing.T) {
t.Errorf("FindBeadsDir() = %q, want %q (via redirect)", result, targetDir)
}
}
// TestFindGitRoot_RegularRepo tests that findGitRoot returns the correct path
// in a regular git repository (not a worktree).
func TestFindGitRoot_RegularRepo(t *testing.T) {
// Create temporary directory for our test repo
tmpDir, err := os.MkdirTemp("", "beads-gitroot-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Initialize a git repository
repoDir := filepath.Join(tmpDir, "main-repo")
if err := os.MkdirAll(repoDir, 0755); err != nil {
t.Fatal(err)
}
cmd := exec.Command("git", "init")
cmd.Dir = repoDir
if err := cmd.Run(); err != nil {
t.Skipf("git not available: %v", err)
}
// Configure git user for the test repo (required for commits)
cmd = exec.Command("git", "config", "user.email", "test@example.com")
cmd.Dir = repoDir
_ = cmd.Run()
cmd = exec.Command("git", "config", "user.name", "Test User")
cmd.Dir = repoDir
_ = cmd.Run()
// Create a subdirectory and change to it
subDir := filepath.Join(repoDir, "sub", "nested")
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatal(err)
}
t.Chdir(subDir)
// findGitRoot should return the repo root
result := findGitRoot()
// Resolve symlinks for comparison (macOS /var -> /private/var)
resultResolved, _ := filepath.EvalSymlinks(result)
repoDirResolved, _ := filepath.EvalSymlinks(repoDir)
if resultResolved != repoDirResolved {
t.Errorf("findGitRoot() = %q, want %q", result, repoDir)
}
}
// TestFindGitRoot_Worktree tests that findGitRoot returns the worktree root
// (not the main repository root) when inside a git worktree. This is critical
// for bd-745 - ensuring database discovery works correctly in worktrees.
func TestFindGitRoot_Worktree(t *testing.T) {
// Create temporary directory for our test
tmpDir, err := os.MkdirTemp("", "beads-worktree-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Initialize a git repository
mainRepoDir := filepath.Join(tmpDir, "main-repo")
if err := os.MkdirAll(mainRepoDir, 0755); err != nil {
t.Fatal(err)
}
cmd := exec.Command("git", "init")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Skipf("git not available: %v", err)
}
// Configure git user for the test repo (required for commits)
cmd = exec.Command("git", "config", "user.email", "test@example.com")
cmd.Dir = mainRepoDir
_ = cmd.Run()
cmd = exec.Command("git", "config", "user.name", "Test User")
cmd.Dir = mainRepoDir
_ = cmd.Run()
// Create an initial commit (required for worktree)
dummyFile := filepath.Join(mainRepoDir, "README.md")
if err := os.WriteFile(dummyFile, []byte("# Test\n"), 0644); err != nil {
t.Fatal(err)
}
cmd = exec.Command("git", "add", "README.md")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Fatal(err)
}
cmd = exec.Command("git", "commit", "-m", "Initial commit")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Fatalf("git commit failed: %v", err)
}
// Create a worktree
worktreeDir := filepath.Join(tmpDir, "worktree")
cmd = exec.Command("git", "worktree", "add", worktreeDir, "HEAD")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Fatalf("git worktree add failed: %v", err)
}
defer func() {
// Clean up worktree
cmd := exec.Command("git", "worktree", "remove", worktreeDir)
cmd.Dir = mainRepoDir
_ = cmd.Run()
}()
// Change to the worktree directory
t.Chdir(worktreeDir)
// findGitRoot should return the WORKTREE root, not the main repo root
result := findGitRoot()
// Resolve symlinks for comparison
resultResolved, _ := filepath.EvalSymlinks(result)
worktreeDirResolved, _ := filepath.EvalSymlinks(worktreeDir)
mainRepoDirResolved, _ := filepath.EvalSymlinks(mainRepoDir)
if resultResolved != worktreeDirResolved {
t.Errorf("findGitRoot() = %q, want worktree %q (not main repo %q)", result, worktreeDir, mainRepoDir)
}
// Additional verification: ensure we're NOT returning the main repo
if resultResolved == mainRepoDirResolved {
t.Errorf("findGitRoot() returned main repo %q instead of worktree %q - worktree detection is broken!", mainRepoDir, worktreeDir)
}
}
// TestFindGitRoot_NotGitRepo tests that findGitRoot returns an empty string
// when not inside a git repository.
func TestFindGitRoot_NotGitRepo(t *testing.T) {
// Create temporary directory that is NOT a git repo
tmpDir, err := os.MkdirTemp("", "beads-nogit-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
t.Chdir(tmpDir)
// findGitRoot should return empty string
result := findGitRoot()
if result != "" {
t.Errorf("findGitRoot() = %q, want empty string (not in git repo)", result)
}
}
// TestFindBeadsDir_Worktree tests that FindBeadsDir correctly finds the .beads
// directory within a git worktree, respecting the worktree boundary and not
// searching into the main repository. This is critical for bd-745.
func TestFindBeadsDir_Worktree(t *testing.T) {
// Save original state
originalEnv := os.Getenv("BEADS_DIR")
defer func() {
if originalEnv != "" {
os.Setenv("BEADS_DIR", originalEnv)
} else {
os.Unsetenv("BEADS_DIR")
}
}()
os.Unsetenv("BEADS_DIR")
// Create temporary directory for our test
tmpDir, err := os.MkdirTemp("", "beads-worktree-finddir-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Initialize main git repository
mainRepoDir := filepath.Join(tmpDir, "main-repo")
if err := os.MkdirAll(mainRepoDir, 0755); err != nil {
t.Fatal(err)
}
cmd := exec.Command("git", "init")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Skipf("git not available: %v", err)
}
// Configure git user
cmd = exec.Command("git", "config", "user.email", "test@example.com")
cmd.Dir = mainRepoDir
_ = cmd.Run()
cmd = exec.Command("git", "config", "user.name", "Test User")
cmd.Dir = mainRepoDir
_ = cmd.Run()
// Create .beads directory in main repo with a database
mainBeadsDir := filepath.Join(mainRepoDir, ".beads")
if err := os.MkdirAll(mainBeadsDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(mainBeadsDir, "beads.db"), []byte{}, 0644); err != nil {
t.Fatal(err)
}
// Create initial commit
if err := os.WriteFile(filepath.Join(mainRepoDir, "README.md"), []byte("# Test\n"), 0644); err != nil {
t.Fatal(err)
}
cmd = exec.Command("git", "add", "-A")
cmd.Dir = mainRepoDir
_ = cmd.Run()
cmd = exec.Command("git", "commit", "-m", "Initial commit")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Fatalf("git commit failed: %v", err)
}
// Create a worktree
worktreeDir := filepath.Join(tmpDir, "worktree")
cmd = exec.Command("git", "worktree", "add", worktreeDir, "HEAD")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Fatalf("git worktree add failed: %v", err)
}
defer func() {
cmd := exec.Command("git", "worktree", "remove", worktreeDir)
cmd.Dir = mainRepoDir
_ = cmd.Run()
}()
// Create .beads directory in worktree with its own database
worktreeBeadsDir := filepath.Join(worktreeDir, ".beads")
if err := os.MkdirAll(worktreeBeadsDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(worktreeBeadsDir, "beads.db"), []byte{}, 0644); err != nil {
t.Fatal(err)
}
// Change to worktree
t.Chdir(worktreeDir)
// FindBeadsDir should prioritize the main repo's .beads for worktrees (bd-de6)
result := FindBeadsDir()
// Resolve symlinks for comparison
resultResolved, _ := filepath.EvalSymlinks(result)
worktreeBeadsDirResolved, _ := filepath.EvalSymlinks(worktreeBeadsDir)
mainBeadsDirResolved, _ := filepath.EvalSymlinks(mainBeadsDir)
if resultResolved != mainBeadsDirResolved {
t.Errorf("FindBeadsDir() = %q, want main repo .beads %q (prioritized for worktrees)", result, mainBeadsDir)
}
// Verify we're NOT finding the worktree's .beads (should fall back only if main repo has no .beads)
if resultResolved == worktreeBeadsDirResolved {
t.Errorf("FindBeadsDir() returned worktree .beads %q instead of main repo .beads %q - prioritization not working!", worktreeBeadsDir, mainBeadsDir)
}
}
// TestFindDatabasePath_Worktree tests that FindDatabasePath correctly finds the
// shared database in the main repository when accessed from a git worktree. This is the
// key test for bd-745 - worktrees should share the same .beads database.
func TestFindDatabasePath_Worktree(t *testing.T) {
// Save original state
originalEnvDir := os.Getenv("BEADS_DIR")
originalEnvDB := os.Getenv("BEADS_DB")
defer func() {
if originalEnvDir != "" {
os.Setenv("BEADS_DIR", originalEnvDir)
} else {
os.Unsetenv("BEADS_DIR")
}
if originalEnvDB != "" {
os.Setenv("BEADS_DB", originalEnvDB)
} else {
os.Unsetenv("BEADS_DB")
}
}()
os.Unsetenv("BEADS_DIR")
os.Unsetenv("BEADS_DB")
// Create temporary directory for our test
tmpDir, err := os.MkdirTemp("", "beads-worktree-finddb-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Initialize main git repository
mainRepoDir := filepath.Join(tmpDir, "main-repo")
if err := os.MkdirAll(mainRepoDir, 0755); err != nil {
t.Fatal(err)
}
cmd := exec.Command("git", "init")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Skipf("git not available: %v", err)
}
// Configure git user
cmd = exec.Command("git", "config", "user.email", "test@example.com")
cmd.Dir = mainRepoDir
_ = cmd.Run()
cmd = exec.Command("git", "config", "user.name", "Test User")
cmd.Dir = mainRepoDir
_ = cmd.Run()
// Create .beads directory in main repo with database
mainBeadsDir := filepath.Join(mainRepoDir, ".beads")
if err := os.MkdirAll(mainBeadsDir, 0755); err != nil {
t.Fatal(err)
}
mainDBPath := filepath.Join(mainBeadsDir, "beads.db")
if err := os.WriteFile(mainDBPath, []byte{}, 0644); err != nil {
t.Fatal(err)
}
// Create initial commit
if err := os.WriteFile(filepath.Join(mainRepoDir, "README.md"), []byte("# Test\n"), 0644); err != nil {
t.Fatal(err)
}
cmd = exec.Command("git", "add", "-A")
cmd.Dir = mainRepoDir
_ = cmd.Run()
cmd = exec.Command("git", "commit", "-m", "Initial commit")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Fatalf("git commit failed: %v", err)
}
// Create a worktree
worktreeDir := filepath.Join(tmpDir, "worktree")
cmd = exec.Command("git", "worktree", "add", worktreeDir, "HEAD")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Fatalf("git worktree add failed: %v", err)
}
defer func() {
cmd := exec.Command("git", "worktree", "remove", worktreeDir)
cmd.Dir = mainRepoDir
_ = cmd.Run()
}()
// Change to worktree subdirectory
worktreeSubDir := filepath.Join(worktreeDir, "sub", "nested")
if err := os.MkdirAll(worktreeSubDir, 0755); err != nil {
t.Fatal(err)
}
t.Chdir(worktreeSubDir)
// FindDatabasePath should find the main repo's shared database
result := FindDatabasePath()
// Resolve symlinks for comparison
resultResolved, _ := filepath.EvalSymlinks(result)
mainDBPathResolved, _ := filepath.EvalSymlinks(mainDBPath)
if resultResolved != mainDBPathResolved {
t.Errorf("FindDatabasePath() = %q, want main repo shared db %q", result, mainDBPath)
}
}
// TestFindDatabasePath_WorktreeNoLocalDB tests that when a worktree does NOT have
// its own .beads directory, FindDatabasePath finds the shared database in the main
// repository. This tests the "shared database" behavior for worktrees.
func TestFindDatabasePath_WorktreeNoLocalDB(t *testing.T) {
// Save original state
originalEnvDir := os.Getenv("BEADS_DIR")
originalEnvDB := os.Getenv("BEADS_DB")
defer func() {
if originalEnvDir != "" {
os.Setenv("BEADS_DIR", originalEnvDir)
} else {
os.Unsetenv("BEADS_DIR")
}
if originalEnvDB != "" {
os.Setenv("BEADS_DB", originalEnvDB)
} else {
os.Unsetenv("BEADS_DB")
}
}()
os.Unsetenv("BEADS_DIR")
os.Unsetenv("BEADS_DB")
// Create temporary directory for our test
tmpDir, err := os.MkdirTemp("", "beads-worktree-nodb-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
// Initialize main git repository
mainRepoDir := filepath.Join(tmpDir, "main-repo")
if err := os.MkdirAll(mainRepoDir, 0755); err != nil {
t.Fatal(err)
}
cmd := exec.Command("git", "init")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Skipf("git not available: %v", err)
}
// Configure git user
cmd = exec.Command("git", "config", "user.email", "test@example.com")
cmd.Dir = mainRepoDir
_ = cmd.Run()
cmd = exec.Command("git", "config", "user.name", "Test User")
cmd.Dir = mainRepoDir
_ = cmd.Run()
// Create .beads directory in main repo with database
mainBeadsDir := filepath.Join(mainRepoDir, ".beads")
if err := os.MkdirAll(mainBeadsDir, 0755); err != nil {
t.Fatal(err)
}
mainDBPath := filepath.Join(mainBeadsDir, "beads.db")
if err := os.WriteFile(mainDBPath, []byte{}, 0644); err != nil {
t.Fatal(err)
}
// Create initial commit
if err := os.WriteFile(filepath.Join(mainRepoDir, "README.md"), []byte("# Test\n"), 0644); err != nil {
t.Fatal(err)
}
cmd = exec.Command("git", "add", "-A")
cmd.Dir = mainRepoDir
_ = cmd.Run()
cmd = exec.Command("git", "commit", "-m", "Initial commit")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Fatalf("git commit failed: %v", err)
}
// Create a worktree WITHOUT a .beads directory
worktreeDir := filepath.Join(tmpDir, "worktree")
cmd = exec.Command("git", "worktree", "add", worktreeDir, "HEAD")
cmd.Dir = mainRepoDir
if err := cmd.Run(); err != nil {
t.Fatalf("git worktree add failed: %v", err)
}
defer func() {
cmd := exec.Command("git", "worktree", "remove", worktreeDir)
cmd.Dir = mainRepoDir
_ = cmd.Run()
}()
// Note: We do NOT create .beads in the worktree
// The worktree got .beads from the commit, so we need to remove it
worktreeBeadsDir := filepath.Join(worktreeDir, ".beads")
if err := os.RemoveAll(worktreeBeadsDir); err != nil {
// May not exist, that's fine
}
// Change to worktree
t.Chdir(worktreeDir)
// FindDatabasePath should find the main repo's shared database
result := FindDatabasePath()
// Resolve symlinks for comparison
resultResolved, _ := filepath.EvalSymlinks(result)
mainDBPathResolved, _ := filepath.EvalSymlinks(mainDBPath)
if resultResolved != mainDBPathResolved {
t.Errorf("FindDatabasePath() = %q, want main repo shared db %q", result, mainDBPath)
}
}

View File

@@ -83,7 +83,6 @@ func Initialize() error {
v.SetDefault("db", "")
v.SetDefault("actor", "")
v.SetDefault("issue-prefix", "")
v.SetDefault("no-install-hooks", false)
// Additional environment variables (not prefixed with BD_)
// These are bound explicitly for backward compatibility

View File

@@ -3,6 +3,7 @@ package daemon
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
@@ -208,8 +209,12 @@ func discoverDaemon(socketPath string) DaemonInfo {
// FindDaemonByWorkspace finds a daemon serving a specific workspace
func FindDaemonByWorkspace(workspacePath string) (*DaemonInfo, error) {
// First try the socket in the workspace itself
socketPath := filepath.Join(workspacePath, ".beads", "bd.sock")
// Determine the correct .beads directory location
// For worktrees, .beads is in the main repository root, not the worktree
beadsDir := findBeadsDirForWorkspace(workspacePath)
// First try the socket in the determined .beads directory
socketPath := filepath.Join(beadsDir, "bd.sock")
if _, err := os.Stat(socketPath); err == nil {
daemon := discoverDaemon(socketPath)
if daemon.Alive {
@@ -232,6 +237,46 @@ func FindDaemonByWorkspace(workspacePath string) (*DaemonInfo, error) {
return nil, fmt.Errorf("no daemon found for workspace: %s", workspacePath)
}
// findBeadsDirForWorkspace determines the correct .beads directory for a workspace
// For worktrees, this is the main repository root; for regular repos, it's the workspace itself
func findBeadsDirForWorkspace(workspacePath string) string {
// Change to the workspace directory to check if it's a worktree
originalDir, err := os.Getwd()
if err != nil {
return filepath.Join(workspacePath, ".beads") // fallback
}
defer func() {
_ = os.Chdir(originalDir) // restore original directory
}()
if err := os.Chdir(workspacePath); err != nil {
return filepath.Join(workspacePath, ".beads") // fallback
}
// Check if we're in a git worktree
cmd := exec.Command("git", "rev-parse", "--git-dir", "--git-common-dir")
output, err := cmd.Output()
if err != nil {
return filepath.Join(workspacePath, ".beads") // fallback
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) >= 2 {
gitDir := strings.TrimSpace(lines[0])
commonDir := strings.TrimSpace(lines[1])
// If git-dir != git-common-dir, we're in a worktree
if gitDir != commonDir {
// Worktree: .beads is in main repo root (parent of git-common-dir)
mainRepoRoot := filepath.Dir(commonDir)
return filepath.Join(mainRepoRoot, ".beads")
}
}
// Regular repository: .beads is in the workspace
return filepath.Join(workspacePath, ".beads")
}
// checkDaemonErrorFile checks for a daemon-error file in the .beads directory
func checkDaemonErrorFile(socketPath string) string {
// Socket path is typically .beads/bd.sock, so get the parent dir

133
internal/git/gitdir.go Normal file
View File

@@ -0,0 +1,133 @@
package git
import (
"fmt"
"os/exec"
"path/filepath"
"strings"
)
// GetGitDir returns the actual .git directory path for the current repository.
// In a normal repo, this is ".git". In a worktree, .git is a file
// containing "gitdir: /path/to/actual/git/dir", so we use git rev-parse.
//
// This function uses Git's native worktree-aware APIs and should be used
// instead of direct filepath.Join(path, ".git") throughout the codebase.
func GetGitDir() (string, error) {
cmd := exec.Command("git", "rev-parse", "--git-dir")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("not a git repository: %w", err)
}
return strings.TrimSpace(string(output)), nil
}
// GetGitHooksDir returns the path to the Git hooks directory.
// This function is worktree-aware and handles both regular repos and worktrees.
func GetGitHooksDir() (string, error) {
gitDir, err := GetGitDir()
if err != nil {
return "", err
}
return filepath.Join(gitDir, "hooks"), nil
}
// GetGitRefsDir returns the path to the Git refs directory.
// This function is worktree-aware and handles both regular repos and worktrees.
func GetGitRefsDir() (string, error) {
gitDir, err := GetGitDir()
if err != nil {
return "", err
}
return filepath.Join(gitDir, "refs"), nil
}
// GetGitHeadPath returns the path to the Git HEAD file.
// This function is worktree-aware and handles both regular repos and worktrees.
func GetGitHeadPath() (string, error) {
gitDir, err := GetGitDir()
if err != nil {
return "", err
}
return filepath.Join(gitDir, "HEAD"), nil
}
// IsWorktree returns true if the current directory is in a Git worktree.
// This is determined by comparing --git-dir and --git-common-dir.
func IsWorktree() bool {
gitDir := getGitDirNoError("--git-dir")
if gitDir == "" {
return false
}
commonDir := getGitDirNoError("--git-common-dir")
if commonDir == "" {
return false
}
absGit, err1 := filepath.Abs(gitDir)
absCommon, err2 := filepath.Abs(commonDir)
if err1 != nil || err2 != nil {
return false
}
return absGit != absCommon
}
// GetMainRepoRoot returns the main repository root directory.
// When in a worktree, this returns the main repository root.
// Otherwise, it returns the regular repository root.
func GetMainRepoRoot() (string, error) {
if IsWorktree() {
// In worktree: read .git file to find main repo
gitFileContent := getGitDirNoError("--git-dir")
if gitFileContent == "" {
return "", fmt.Errorf("not a git repository")
}
// If gitFileContent contains "worktrees", it's a worktree path
// Read the .git file to get the main git dir
if strings.Contains(gitFileContent, "worktrees") {
content, err := exec.Command("cat", ".git").Output()
if err == nil {
line := strings.TrimSpace(string(content))
if strings.HasPrefix(line, "gitdir: ") {
gitDir := strings.TrimPrefix(line, "gitdir: ")
// Remove /worktrees/* part
if idx := strings.Index(gitDir, "/worktrees/"); idx > 0 {
gitDir = gitDir[:idx]
}
return filepath.Dir(gitDir), nil
}
}
}
// Fallback: use --git-common-dir with validation
commonDir := getGitDirNoError("--git-common-dir")
if commonDir != "" {
// Validate that commonDir exists
if _, err := exec.Command("test", "-d", commonDir).Output(); err == nil {
return filepath.Dir(commonDir), nil
}
}
return "", fmt.Errorf("unable to determine main repository root")
} else {
gitDir, err := GetGitDir()
if err != nil {
return "", err
}
return filepath.Dir(gitDir), nil
}
}
// getGitDirNoError is a helper that returns empty string on error
// to avoid cluttering code with error handling for simple checks.
func getGitDirNoError(flag string) string {
cmd := exec.Command("git", "rev-parse", flag)
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}

View File

@@ -1,101 +0,0 @@
package reset
import (
"fmt"
"io"
"os"
"path/filepath"
"time"
)
// CreateBackup creates a backup of the .beads directory.
// It copies .beads/ to .beads-backup-{timestamp}/ where timestamp is in YYYYMMDD-HHMMSS format.
// File permissions are preserved during the copy.
// Returns the backup path on success, or an error if the backup directory already exists.
func CreateBackup(beadsDir string) (backupPath string, err error) {
// Generate timestamp in YYYYMMDD-HHMMSS format
timestamp := time.Now().Format("20060102-150405")
// Construct backup directory path
parentDir := filepath.Dir(beadsDir)
backupPath = filepath.Join(parentDir, fmt.Sprintf(".beads-backup-%s", timestamp))
// Check if backup directory already exists
if _, err := os.Stat(backupPath); err == nil {
return "", fmt.Errorf("backup directory already exists: %s", backupPath)
}
// Create backup directory
if err := os.Mkdir(backupPath, 0755); err != nil {
return "", fmt.Errorf("failed to create backup directory: %w", err)
}
// Copy directory recursively
if err := copyDir(beadsDir, backupPath); err != nil {
// Attempt to clean up partial backup on failure
_ = os.RemoveAll(backupPath)
return "", fmt.Errorf("failed to copy directory: %w", err)
}
return backupPath, nil
}
// copyDir recursively copies a directory tree, preserving file permissions
func copyDir(src, dst string) error {
// Walk the source directory
err := filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Compute relative path
relPath, err := filepath.Rel(src, path)
if err != nil {
return err
}
// Construct destination path
dstPath := filepath.Join(dst, relPath)
// Handle directories and files
if info.IsDir() {
// Skip the root directory (already created)
if path == src {
return nil
}
// Create directory with same permissions
return os.Mkdir(dstPath, info.Mode())
}
// Copy file
return copyFile(path, dstPath, info.Mode())
})
return err
}
// copyFile copies a single file, preserving permissions
func copyFile(src, dst string, perm os.FileMode) error {
// #nosec G304 -- backup function only copies files within user's project
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
// Create destination file with preserved permissions
// #nosec G304 -- backup function only writes files within user's project
destFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm)
if err != nil {
return err
}
defer destFile.Close()
// Copy contents
if _, err := io.Copy(destFile, sourceFile); err != nil {
return err
}
// Ensure data is written to disk
return destFile.Sync()
}

View File

@@ -1,252 +0,0 @@
package reset
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
)
func TestCreateBackup(t *testing.T) {
// Create temporary directory structure
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatalf("failed to create test .beads directory: %v", err)
}
// Create some test files in .beads
testFiles := map[string]string{
"issues.jsonl": `{"id":"test-1","title":"Test Issue"}`,
"metadata.json": `{"version":"1.0"}`,
"config.yaml": `prefix: test`,
}
for name, content := range testFiles {
path := filepath.Join(beadsDir, name)
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("failed to create test file %s: %v", name, err)
}
}
// Create a subdirectory with a file
subDir := filepath.Join(beadsDir, "subdir")
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatalf("failed to create subdirectory: %v", err)
}
subFile := filepath.Join(subDir, "subfile.txt")
if err := os.WriteFile(subFile, []byte("subfile content"), 0644); err != nil {
t.Fatalf("failed to create subfile: %v", err)
}
// Create backup
backupPath, err := CreateBackup(beadsDir)
if err != nil {
t.Fatalf("CreateBackup failed: %v", err)
}
// Verify backup path format
expectedPattern := `\.beads-backup-\d{8}-\d{6}$`
matched, _ := regexp.MatchString(expectedPattern, backupPath)
if !matched {
t.Errorf("backup path %q doesn't match expected pattern %q", backupPath, expectedPattern)
}
// Verify backup directory exists
info, err := os.Stat(backupPath)
if err != nil {
t.Fatalf("backup directory not created: %v", err)
}
if !info.IsDir() {
t.Errorf("backup path is not a directory")
}
// Verify all files were copied
for name, expectedContent := range testFiles {
backupFilePath := filepath.Join(backupPath, name)
content, err := os.ReadFile(backupFilePath)
if err != nil {
t.Errorf("failed to read backed up file %s: %v", name, err)
continue
}
if string(content) != expectedContent {
t.Errorf("file %s content mismatch: got %q, want %q", name, content, expectedContent)
}
}
// Verify subdirectory and its file were copied
backupSubFile := filepath.Join(backupPath, "subdir", "subfile.txt")
content, err := os.ReadFile(backupSubFile)
if err != nil {
t.Errorf("failed to read backed up subfile: %v", err)
}
if string(content) != "subfile content" {
t.Errorf("subfile content mismatch: got %q, want %q", content, "subfile content")
}
}
func TestCreateBackup_PreservesPermissions(t *testing.T) {
// Create temporary directory structure
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatalf("failed to create test .beads directory: %v", err)
}
// Create a file with specific permissions
testFile := filepath.Join(beadsDir, "test.txt")
if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
// Create backup
backupPath, err := CreateBackup(beadsDir)
if err != nil {
t.Fatalf("CreateBackup failed: %v", err)
}
// Check permissions on backed up file
backupFile := filepath.Join(backupPath, "test.txt")
info, err := os.Stat(backupFile)
if err != nil {
t.Fatalf("failed to stat backed up file: %v", err)
}
// Verify permissions (mask to ignore permission bits we don't care about)
gotPerm := info.Mode() & 0777
wantPerm := os.FileMode(0600)
if gotPerm != wantPerm {
t.Errorf("permissions not preserved: got %o, want %o", gotPerm, wantPerm)
}
}
func TestCreateBackup_ErrorIfBackupExists(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatalf("failed to create test .beads directory: %v", err)
}
// Create first backup
backupPath1, err := CreateBackup(beadsDir)
if err != nil {
t.Fatalf("first CreateBackup failed: %v", err)
}
// Try to create backup with same timestamp (simulate collision)
// We need to create the directory manually since timestamps differ
timestamp := time.Now().Format("20060102-150405")
existingBackup := filepath.Join(tmpDir, ".beads-backup-"+timestamp)
if err := os.Mkdir(existingBackup, 0755); err != nil {
// If the directory already exists from the first backup, use that
if !os.IsExist(err) {
t.Fatalf("failed to create existing backup directory: %v", err)
}
}
// Mock the time to ensure we get the same timestamp
// Since we can't mock time.Now(), we'll create a second backup immediately
// and verify the first one succeeded
_, err = CreateBackup(beadsDir)
if err != nil {
// Either we got an error (good) or we created a new backup with different timestamp
// The test is mainly to verify the first backup succeeded
if !strings.Contains(err.Error(), "backup directory already exists") {
// Different timestamp, that's fine - backup system works
t.Logf("Second backup got different timestamp (expected): %v", err)
}
}
// Verify first backup exists
if _, err := os.Stat(backupPath1); os.IsNotExist(err) {
t.Errorf("first backup was not created")
}
}
func TestCreateBackup_TimestampFormat(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatalf("failed to create test .beads directory: %v", err)
}
backupPath, err := CreateBackup(beadsDir)
if err != nil {
t.Fatalf("CreateBackup failed: %v", err)
}
// Extract timestamp from backup path
baseName := filepath.Base(backupPath)
if !strings.HasPrefix(baseName, ".beads-backup-") {
t.Errorf("backup name doesn't have expected prefix: %s", baseName)
}
timestamp := strings.TrimPrefix(baseName, ".beads-backup-")
// Verify timestamp format: YYYYMMDD-HHMMSS
expectedPattern := `^\d{8}-\d{6}$`
matched, err := regexp.MatchString(expectedPattern, timestamp)
if err != nil {
t.Fatalf("regex error: %v", err)
}
if !matched {
t.Errorf("timestamp %q doesn't match expected format YYYYMMDD-HHMMSS", timestamp)
}
// Verify timestamp is parseable and reasonable (within last day to handle timezone issues)
parsedTime, err := time.Parse("20060102-150405", timestamp)
if err != nil {
t.Errorf("failed to parse timestamp %q: %v", timestamp, err)
}
now := time.Now()
diff := now.Sub(parsedTime)
// Allow for timezone differences and clock skew (within 24 hours)
if diff < -24*time.Hour || diff > 24*time.Hour {
t.Errorf("timestamp %q is not within reasonable range (diff: %v)", timestamp, diff)
}
}
func TestCreateBackup_NonexistentSource(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
// Don't create the directory
_, err := CreateBackup(beadsDir)
if err == nil {
t.Error("expected error for nonexistent source directory, got nil")
}
}
func TestCreateBackup_EmptyDirectory(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatalf("failed to create test .beads directory: %v", err)
}
backupPath, err := CreateBackup(beadsDir)
if err != nil {
t.Fatalf("CreateBackup failed on empty directory: %v", err)
}
// Verify backup directory exists
info, err := os.Stat(backupPath)
if err != nil {
t.Fatalf("backup directory not created: %v", err)
}
if !info.IsDir() {
t.Errorf("backup path is not a directory")
}
// Verify backup is empty (only contains what filepath.Walk copies)
entries, err := os.ReadDir(backupPath)
if err != nil {
t.Fatalf("failed to read backup directory: %v", err)
}
if len(entries) != 0 {
t.Errorf("expected empty backup directory, got %d entries", len(entries))
}
}

View File

@@ -1,130 +0,0 @@
package reset
import (
"bytes"
"fmt"
"os/exec"
"path/filepath"
"strings"
)
// GitState represents the current state of the git repository
type GitState struct {
IsRepo bool // Is this a git repository?
IsDirty bool // Are there uncommitted changes?
IsDetached bool // Is HEAD detached?
Branch string // Current branch name (empty if detached)
}
// CheckGitState detects the current git repository state
func CheckGitState(beadsDir string) (*GitState, error) {
state := &GitState{}
// Check if we're in a git repository
cmd := exec.Command("git", "rev-parse", "--git-dir")
if err := cmd.Run(); err != nil {
// Not a git repo - this is OK, we'll skip git operations gracefully
state.IsRepo = false
return state, nil
}
state.IsRepo = true
// Check if there are uncommitted changes specifically in .beads/
// (not the entire repo, just the beads directory)
cmd = exec.Command("git", "status", "--porcelain", "--", beadsDir)
var statusOut bytes.Buffer
cmd.Stdout = &statusOut
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("failed to check git status: %w", err)
}
state.IsDirty = len(strings.TrimSpace(statusOut.String())) > 0
// Check if HEAD is detached and get current branch
cmd = exec.Command("git", "symbolic-ref", "-q", "HEAD")
var branchOut bytes.Buffer
cmd.Stdout = &branchOut
err := cmd.Run()
if err != nil {
// symbolic-ref fails on detached HEAD
state.IsDetached = true
state.Branch = ""
} else {
state.IsDetached = false
// Extract branch name from refs/heads/branch-name
fullRef := strings.TrimSpace(branchOut.String())
state.Branch = strings.TrimPrefix(fullRef, "refs/heads/")
}
return state, nil
}
// GitRemoveBeads uses git rm to remove the JSONL files from the index
// This prepares for a reset by staging the removal of beads files
func GitRemoveBeads(beadsDir string) error {
// Find all JSONL files in the beads directory
// We support both canonical (issues.jsonl) and legacy (beads.jsonl) names
jsonlFiles := []string{
filepath.Join(beadsDir, "issues.jsonl"),
filepath.Join(beadsDir, "beads.jsonl"),
}
// Try to remove each file (git rm ignores non-existent files with --ignore-unmatch)
// Use --force to handle files with staged changes
for _, file := range jsonlFiles {
cmd := exec.Command("git", "rm", "--ignore-unmatch", "--quiet", "--force", file) // #nosec G204 -- git is a constant, file is from controlled source
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to git rm %s: %w\nstderr: %s", file, err, stderr.String())
}
}
return nil
}
// GitCommitReset creates a commit with the removal of beads files
// Returns nil without error if there's nothing to commit
func GitCommitReset(message string) error {
// First check if there are any staged changes
cmd := exec.Command("git", "diff", "--cached", "--quiet")
if err := cmd.Run(); err == nil {
// Exit code 0 means no staged changes - nothing to commit
return nil
}
// Exit code 1 means there are staged changes - proceed with commit
cmd = exec.Command("git", "commit", "-m", message)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to commit reset: %w\nstderr: %s", err, stderr.String())
}
return nil
}
// GitAddAndCommit stages the beads directory and creates a commit with fresh state
func GitAddAndCommit(beadsDir, message string) error {
// Add the entire beads directory (this will pick up the fresh JSONL)
cmd := exec.Command("git", "add", beadsDir)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to git add %s: %w\nstderr: %s", beadsDir, err, stderr.String())
}
// Create the commit
cmd = exec.Command("git", "commit", "-m", message)
stderr.Reset()
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to commit fresh state: %w\nstderr: %s", err, stderr.String())
}
return nil
}

View File

@@ -1,364 +0,0 @@
package reset
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
// initGitRepo initializes a git repo in the given directory
func initGitRepo(t *testing.T, dir string) {
t.Helper()
// Initialize git repo
cmd := exec.Command("git", "init")
cmd.Dir = dir
if err := cmd.Run(); err != nil {
t.Fatalf("failed to init git repo: %v", err)
}
// Configure git user for commits
cmd = exec.Command("git", "config", "user.email", "test@example.com")
cmd.Dir = dir
if err := cmd.Run(); err != nil {
t.Fatalf("failed to set git email: %v", err)
}
cmd = exec.Command("git", "config", "user.name", "Test User")
cmd.Dir = dir
if err := cmd.Run(); err != nil {
t.Fatalf("failed to set git name: %v", err)
}
}
func TestCheckGitState_NotARepo(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads: %v", err)
}
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
defer func() { _ = os.Chdir(origDir) }()
state, err := CheckGitState(beadsDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if state.IsRepo {
t.Error("expected IsRepo to be false for non-repo directory")
}
}
func TestCheckGitState_CleanRepo(t *testing.T) {
tmpDir := t.TempDir()
initGitRepo(t, tmpDir)
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads: %v", err)
}
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
defer func() { _ = os.Chdir(origDir) }()
state, err := CheckGitState(beadsDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !state.IsRepo {
t.Error("expected IsRepo to be true")
}
if state.IsDirty {
t.Error("expected IsDirty to be false for clean repo")
}
}
func TestCheckGitState_DirtyRepo(t *testing.T) {
tmpDir := t.TempDir()
initGitRepo(t, tmpDir)
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads: %v", err)
}
// Create a file in .beads to make it dirty
testFile := filepath.Join(beadsDir, "test.txt")
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
defer func() { _ = os.Chdir(origDir) }()
state, err := CheckGitState(beadsDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !state.IsRepo {
t.Error("expected IsRepo to be true")
}
if !state.IsDirty {
t.Error("expected IsDirty to be true with uncommitted changes")
}
}
func TestCheckGitState_DetectsOnlyBeadsChanges(t *testing.T) {
tmpDir := t.TempDir()
initGitRepo(t, tmpDir)
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads: %v", err)
}
// Create a file OUTSIDE .beads (should NOT make beads dirty)
otherFile := filepath.Join(tmpDir, "other.txt")
if err := os.WriteFile(otherFile, []byte("test"), 0644); err != nil {
t.Fatalf("failed to create other file: %v", err)
}
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
defer func() { _ = os.Chdir(origDir) }()
state, err := CheckGitState(beadsDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should NOT be dirty because only .beads changes are checked
if state.IsDirty {
t.Error("expected IsDirty to be false when only non-beads files are changed")
}
}
func TestCheckGitState_DetectsBranch(t *testing.T) {
tmpDir := t.TempDir()
initGitRepo(t, tmpDir)
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads: %v", err)
}
// Need at least one commit to have a branch
readmeFile := filepath.Join(tmpDir, "README.md")
if err := os.WriteFile(readmeFile, []byte("test"), 0644); err != nil {
t.Fatalf("failed to create README: %v", err)
}
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
defer func() { _ = os.Chdir(origDir) }()
// Add and commit to create a branch
cmd := exec.Command("git", "add", "README.md")
if err := cmd.Run(); err != nil {
t.Fatalf("failed to add file: %v", err)
}
cmd = exec.Command("git", "commit", "-m", "initial")
if err := cmd.Run(); err != nil {
t.Fatalf("failed to commit: %v", err)
}
state, err := CheckGitState(beadsDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if state.IsDetached {
t.Error("expected IsDetached to be false on a branch")
}
// Branch should be "main" or "master" depending on git version
if state.Branch != "main" && state.Branch != "master" {
t.Errorf("expected branch to be 'main' or 'master', got %q", state.Branch)
}
}
func TestGitRemoveBeads(t *testing.T) {
tmpDir := t.TempDir()
initGitRepo(t, tmpDir)
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads: %v", err)
}
// Create and commit a JSONL file
jsonlFile := filepath.Join(beadsDir, "issues.jsonl")
if err := os.WriteFile(jsonlFile, []byte(`{"id":"test-1"}`), 0644); err != nil {
t.Fatalf("failed to create jsonl file: %v", err)
}
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
defer func() { _ = os.Chdir(origDir) }()
// Add the file to git
cmd := exec.Command("git", "add", ".beads/issues.jsonl")
if err := cmd.Run(); err != nil {
t.Fatalf("failed to add file: %v", err)
}
// Now remove it
err := GitRemoveBeads(beadsDir)
if err != nil {
t.Fatalf("GitRemoveBeads failed: %v", err)
}
// Verify file is staged for removal
cmd = exec.Command("git", "status", "--porcelain")
output, err := cmd.Output()
if err != nil {
t.Fatalf("failed to get git status: %v", err)
}
// Should show "D " for staged deletion
// Note: The file was never committed, so it will show "AD" (added then deleted)
// or may not show at all
t.Logf("git status output: %q", string(output))
}
func TestGitRemoveBeads_NonexistentFiles(t *testing.T) {
tmpDir := t.TempDir()
initGitRepo(t, tmpDir)
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads: %v", err)
}
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
defer func() { _ = os.Chdir(origDir) }()
// Should not error when files don't exist (--ignore-unmatch)
err := GitRemoveBeads(beadsDir)
if err != nil {
t.Errorf("unexpected error for nonexistent files: %v", err)
}
}
func TestGitCommitReset_NoStagedChanges(t *testing.T) {
tmpDir := t.TempDir()
initGitRepo(t, tmpDir)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
defer func() { _ = os.Chdir(origDir) }()
// Should not error when there's nothing to commit
err := GitCommitReset("test message")
if err != nil {
t.Errorf("unexpected error for empty commit: %v", err)
}
}
func TestGitCommitReset_WithStagedChanges(t *testing.T) {
tmpDir := t.TempDir()
initGitRepo(t, tmpDir)
// Create and stage a file
testFile := filepath.Join(tmpDir, "test.txt")
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
defer func() { _ = os.Chdir(origDir) }()
cmd := exec.Command("git", "add", "test.txt")
if err := cmd.Run(); err != nil {
t.Fatalf("failed to add file: %v", err)
}
err := GitCommitReset("Reset beads workspace")
if err != nil {
t.Fatalf("GitCommitReset failed: %v", err)
}
// Verify commit was created
cmd = exec.Command("git", "log", "--oneline", "-1")
output, err := cmd.Output()
if err != nil {
t.Fatalf("failed to get git log: %v", err)
}
if len(output) == 0 {
t.Error("expected commit to be created")
}
}
func TestGitAddAndCommit(t *testing.T) {
tmpDir := t.TempDir()
initGitRepo(t, tmpDir)
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads: %v", err)
}
// Create a file in .beads
testFile := filepath.Join(beadsDir, "issues.jsonl")
if err := os.WriteFile(testFile, []byte(`{"id":"test-1"}`), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
defer func() { _ = os.Chdir(origDir) }()
err := GitAddAndCommit(beadsDir, "Initialize fresh beads workspace")
if err != nil {
t.Fatalf("GitAddAndCommit failed: %v", err)
}
// Verify commit was created
cmd := exec.Command("git", "log", "--oneline", "-1")
output, err := cmd.Output()
if err != nil {
t.Fatalf("failed to get git log: %v", err)
}
if len(output) == 0 {
t.Error("expected commit to be created")
}
// Verify file is tracked
cmd = exec.Command("git", "status", "--porcelain")
output, err = cmd.Output()
if err != nil {
t.Fatalf("failed to get git status: %v", err)
}
if len(output) != 0 {
t.Errorf("expected clean working directory, got: %s", output)
}
}

View File

@@ -1,265 +0,0 @@
// Package reset provides core reset functionality for cleaning beads state.
// This package is CLI-agnostic and returns errors for the CLI to handle.
package reset
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/daemon"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
// ResetOptions configures the reset operation
type ResetOptions struct {
Hard bool // Include git operations (git rm, commit)
Backup bool // Create backup before reset
DryRun bool // Preview only, don't execute
SkipInit bool // Don't re-initialize after reset
}
// ResetResult contains the results of a reset operation
type ResetResult struct {
IssuesDeleted int
TombstonesDeleted int
BackupPath string // if backup was created
DaemonsKilled int
}
// ImpactSummary describes what will be affected by a reset
type ImpactSummary struct {
IssueCount int
OpenCount int
ClosedCount int
TombstoneCount int
HasUncommitted bool // git dirty state in .beads/
}
// ValidateState checks if .beads/ directory exists and is valid for reset
func ValidateState() error {
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
return fmt.Errorf("no .beads directory found - nothing to reset")
}
// Verify it's a directory
info, err := os.Stat(beadsDir)
if err != nil {
return fmt.Errorf("failed to stat .beads directory: %w", err)
}
if !info.IsDir() {
return fmt.Errorf(".beads exists but is not a directory")
}
return nil
}
// CountImpact analyzes what will be deleted by a reset operation
func CountImpact() (*ImpactSummary, error) {
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
return nil, fmt.Errorf("no .beads directory found")
}
summary := &ImpactSummary{}
// Try to open database and count issues
dbPath := beads.FindDatabasePath()
if dbPath != "" {
ctx := context.Background()
store, err := sqlite.New(ctx, dbPath)
if err == nil {
defer func() { _ = store.Close() }()
// Count all issues including tombstones
allIssues, err := store.SearchIssues(ctx, "", types.IssueFilter{IncludeTombstones: true})
if err == nil {
summary.IssueCount = len(allIssues)
for _, issue := range allIssues {
if issue.IsTombstone() {
summary.TombstoneCount++
} else {
switch issue.Status {
case types.StatusOpen, types.StatusInProgress, types.StatusBlocked:
summary.OpenCount++
case types.StatusClosed:
summary.ClosedCount++
}
}
}
}
}
}
// Check git dirty state for .beads/
summary.HasUncommitted = hasUncommittedBeadsFiles()
return summary, nil
}
// Reset performs the core reset logic
func Reset(opts ResetOptions) (*ResetResult, error) {
// Validate state first
if err := ValidateState(); err != nil {
return nil, err
}
beadsDir := beads.FindBeadsDir()
result := &ResetResult{}
// Dry run: just count what would be affected
if opts.DryRun {
summary, err := CountImpact()
if err != nil {
return nil, err
}
result.IssuesDeleted = summary.IssueCount - summary.TombstoneCount
result.TombstonesDeleted = summary.TombstoneCount
return result, nil
}
// Step 1: Kill all daemons
daemons, err := daemon.DiscoverDaemons(nil)
if err == nil {
killResults := daemon.KillAllDaemons(daemons, true)
result.DaemonsKilled = killResults.Stopped
}
// Step 2: Count issues before deletion (for result reporting)
summary, _ := CountImpact()
if summary != nil {
result.IssuesDeleted = summary.IssueCount - summary.TombstoneCount
result.TombstonesDeleted = summary.TombstoneCount
}
// Step 3: Create backup if requested
if opts.Backup {
backupPath, err := createBackup(beadsDir)
if err != nil {
return nil, fmt.Errorf("failed to create backup: %w", err)
}
result.BackupPath = backupPath
}
// Step 4: Hard mode - git rm BEFORE deleting files
// (must happen while files still exist for git to track the removal)
if opts.Hard {
if err := gitRemoveBeads(); err != nil {
return nil, fmt.Errorf("git rm failed: %w", err)
}
}
// Step 5: Remove .beads directory
if err := os.RemoveAll(beadsDir); err != nil {
return nil, fmt.Errorf("failed to remove .beads directory: %w", err)
}
// Step 6: Re-initialize unless SkipInit is set
if !opts.SkipInit {
if err := reinitializeBeads(); err != nil {
return nil, fmt.Errorf("re-initialization failed: %w", err)
}
}
return result, nil
}
// createBackup creates a timestamped backup of the .beads directory
func createBackup(beadsDir string) (string, error) {
return CreateBackup(beadsDir)
}
// gitRemoveBeads performs git rm on .beads directory and commits
func gitRemoveBeads() error {
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
return nil
}
// Check git state
gitState, err := CheckGitState(beadsDir)
if err != nil {
return err
}
// Skip if not a git repo
if !gitState.IsRepo {
return nil
}
// Remove JSONL files from git
if err := GitRemoveBeads(beadsDir); err != nil {
return err
}
// Commit the reset
commitMsg := "Reset beads workspace\n\nRemoved .beads/ directory to start fresh."
return GitCommitReset(commitMsg)
}
// reinitializeBeads calls bd init logic to recreate the workspace
func reinitializeBeads() error {
// Get the current directory name for prefix auto-detection
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
// Create .beads directory
beadsDir := filepath.Join(cwd, ".beads")
if err := os.MkdirAll(beadsDir, 0750); err != nil {
return fmt.Errorf("failed to create .beads directory: %w", err)
}
// Determine prefix from directory name
prefix := filepath.Base(cwd)
prefix = strings.TrimRight(prefix, "-")
// Create database
dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName)
ctx := context.Background()
store, err := sqlite.New(ctx, dbPath)
if err != nil {
return fmt.Errorf("failed to create database: %w", err)
}
// Set issue prefix in config
if err := store.SetConfig(ctx, "issue_prefix", prefix); err != nil {
_ = store.Close()
return fmt.Errorf("failed to set issue prefix: %w", err)
}
// Set sync.branch if in git repo (non-fatal if it fails)
gitState, err := CheckGitState(beadsDir)
if err == nil && gitState.IsRepo && gitState.Branch != "" && !gitState.IsDetached {
// Ignore error - sync.branch is optional and CLI can set it later
_ = store.SetConfig(ctx, "sync.branch", gitState.Branch)
}
// Close the database
if err := store.Close(); err != nil {
return fmt.Errorf("failed to close database: %w", err)
}
return nil
}
// hasUncommittedBeadsFiles checks if .beads directory has uncommitted changes
func hasUncommittedBeadsFiles() bool {
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
return false
}
gitState, err := CheckGitState(beadsDir)
if err != nil || !gitState.IsRepo {
return false
}
return gitState.IsDirty
}

View File

@@ -1,230 +0,0 @@
package reset
import (
"os"
"path/filepath"
"testing"
)
// setupTestEnv sets BEADS_DIR to the test directory using t.Setenv for automatic cleanup
func setupTestEnv(t *testing.T, beadsDir string) {
t.Helper()
// t.Setenv automatically restores the previous value when the test completes
t.Setenv("BEADS_DIR", beadsDir)
// Also unset BEADS_DB to prevent finding the real database
t.Setenv("BEADS_DB", "")
}
// createMinimalBeadsDir creates a minimal .beads directory with required files
func createMinimalBeadsDir(t *testing.T, tmpDir string) string {
t.Helper()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads: %v", err)
}
// Create metadata.json to make it a valid beads directory
metadataPath := filepath.Join(beadsDir, "metadata.json")
if err := os.WriteFile(metadataPath, []byte(`{"version":"1.0"}`), 0644); err != nil {
t.Fatalf("failed to create metadata.json: %v", err)
}
return beadsDir
}
func TestValidateState_NoBeadsDir(t *testing.T) {
// Create temp directory without .beads
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
// Change to temp dir and set BEADS_DIR to prevent finding real .beads
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
defer func() { _ = os.Chdir(origDir) }()
setupTestEnv(t, beadsDir)
err := ValidateState()
if err == nil {
t.Error("expected error when .beads directory doesn't exist")
}
}
func TestValidateState_BeadsDirExists(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := createMinimalBeadsDir(t, tmpDir)
setupTestEnv(t, beadsDir)
err := ValidateState()
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestValidateState_BeadsIsFile(t *testing.T) {
tmpDir := t.TempDir()
beadsPath := filepath.Join(tmpDir, ".beads")
if err := os.WriteFile(beadsPath, []byte("not a directory"), 0644); err != nil {
t.Fatalf("failed to create .beads file: %v", err)
}
// Change to temp dir to prevent finding real .beads
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
defer func() { _ = os.Chdir(origDir) }()
setupTestEnv(t, beadsPath)
err := ValidateState()
if err == nil {
t.Error("expected error when .beads is a file, not directory")
}
}
func TestCountImpact_EmptyWorkspace(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := createMinimalBeadsDir(t, tmpDir)
setupTestEnv(t, beadsDir)
impact, err := CountImpact()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if impact.IssueCount != 0 {
t.Errorf("expected 0 issues, got %d", impact.IssueCount)
}
if impact.TombstoneCount != 0 {
t.Errorf("expected 0 tombstones, got %d", impact.TombstoneCount)
}
}
// TestCountImpact_WithIssues is skipped in isolated test environments
// because CountImpact relies on beads.FindDatabasePath() which has complex
// path resolution logic that's difficult to mock in tests.
// The CountImpact function is well-tested through integration tests.
func TestCountImpact_WithIssues(t *testing.T) {
t.Skip("Skipped: CountImpact uses beads.FindDatabasePath() which is complex to test in isolation")
}
func TestReset_DryRun(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := createMinimalBeadsDir(t, tmpDir)
setupTestEnv(t, beadsDir)
// Create a test file
testFile := filepath.Join(beadsDir, "test.txt")
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
opts := ResetOptions{DryRun: true}
result, err := Reset(opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify the file still exists (dry run shouldn't delete anything)
if _, err := os.Stat(testFile); os.IsNotExist(err) {
t.Error("dry run should not delete files")
}
// Result should still have counts
if result == nil {
t.Error("expected result from dry run")
}
}
func TestReset_SoftReset(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := createMinimalBeadsDir(t, tmpDir)
setupTestEnv(t, beadsDir)
// Create a test file
testFile := filepath.Join(beadsDir, "test.txt")
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
// Skip init since we don't have a proper beads environment
opts := ResetOptions{SkipInit: true}
result, err := Reset(opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify the .beads directory is gone
if _, err := os.Stat(beadsDir); !os.IsNotExist(err) {
t.Error("reset should delete .beads directory")
}
if result == nil {
t.Error("expected result from reset")
}
}
func TestReset_WithBackup(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := createMinimalBeadsDir(t, tmpDir)
setupTestEnv(t, beadsDir)
// Create a test file
testFile := filepath.Join(beadsDir, "test.txt")
testContent := "test content"
if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
opts := ResetOptions{Backup: true, SkipInit: true}
result, err := Reset(opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify backup was created
if result.BackupPath == "" {
t.Error("expected backup path in result")
}
// Verify backup directory exists
if _, err := os.Stat(result.BackupPath); os.IsNotExist(err) {
t.Error("backup directory should exist")
}
// Verify backup contains the test file
backupFile := filepath.Join(result.BackupPath, "test.txt")
content, err := os.ReadFile(backupFile)
if err != nil {
t.Errorf("failed to read backup file: %v", err)
}
if string(content) != testContent {
t.Errorf("backup content mismatch: got %q, want %q", content, testContent)
}
// Verify original .beads is gone
if _, err := os.Stat(beadsDir); !os.IsNotExist(err) {
t.Error("original .beads should be deleted after reset")
}
}
func TestReset_NoBeadsDir(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
// Change to temp dir to prevent finding real .beads
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
defer func() { _ = os.Chdir(origDir) }()
setupTestEnv(t, beadsDir)
opts := ResetOptions{}
_, err := Reset(opts)
if err == nil {
t.Error("expected error when .beads doesn't exist")
}
}

View File

@@ -1194,7 +1194,7 @@ func (s *SQLiteStorage) executeDelete(ctx context.Context, tx *sql.Tx, inClause
for rows.Next() {
var id, issueType string
if err := rows.Scan(&id, &issueType); err != nil {
_ = rows.Close()
_ = rows.Close() // #nosec G104 - error handling not critical in error path
return fmt.Errorf("failed to scan issue type: %w", err)
}
issueTypes[id] = issueType

View File

@@ -139,103 +139,6 @@ func BenchmarkGetReadyWork_FromJSONL(b *testing.B) {
})
}
// BenchmarkLargeDescription benchmarks handling of issues with very large descriptions (100KB+)
func BenchmarkLargeDescription(b *testing.B) {
runBenchmark(b, setupLargeBenchDB, func(store *SQLiteStorage, ctx context.Context) error {
// Create issue with 100KB description
largeDesc := make([]byte, 100*1024)
for i := range largeDesc {
largeDesc[i] = byte('a' + (i % 26))
}
issue := &types.Issue{
Title: "Issue with large description",
Description: string(largeDesc),
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
return store.CreateIssue(ctx, issue, "bench")
})
}
// BenchmarkBulkCloseIssues benchmarks closing 100 issues in sequence
func BenchmarkBulkCloseIssues(b *testing.B) {
store, cleanup := setupLargeBenchDB(b)
defer cleanup()
ctx := context.Background()
// Get 100 open issues to close
openStatus := types.StatusOpen
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{
Status: &openStatus,
Limit: 100,
})
if err != nil || len(issues) < 100 {
b.Fatalf("Failed to get 100 issues for bulk close test: got %d, err %v", len(issues), err)
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
for j, issue := range issues {
if err := store.CloseIssue(ctx, issue.ID, "Bulk closed", "bench"); err != nil {
b.Fatalf("CloseIssue failed: %v", err)
}
// Re-open for next iteration (except last one)
if j < len(issues)-1 {
updates := map[string]interface{}{"status": types.StatusOpen}
if err := store.UpdateIssue(ctx, issue.ID, updates, "bench"); err != nil {
b.Fatalf("UpdateIssue failed: %v", err)
}
}
}
}
}
// BenchmarkSyncMerge benchmarks JSONL merge operations (simulating full sync cycle)
func BenchmarkSyncMerge(b *testing.B) {
store, cleanup := setupLargeBenchDB(b)
defer cleanup()
ctx := context.Background()
// For each iteration, simulate a sync by creating and updating issues
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// Simulate incoming changes: create 10 new issues, update 10 existing
for j := 0; j < 10; j++ {
issue := &types.Issue{
Title: "Synced issue",
Description: "Incoming change",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
if err := store.CreateIssue(ctx, issue, "sync"); err != nil {
b.Fatalf("CreateIssue failed: %v", err)
}
}
// Update 10 existing issues
openStatus := types.StatusOpen
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{
Status: &openStatus,
Limit: 10,
})
if err == nil && len(issues) > 0 {
for _, issue := range issues {
updates := map[string]interface{}{
"title": "Updated from sync",
}
_ = store.UpdateIssue(ctx, issue.ID, updates, "sync")
}
}
}
}
// Helper function
func intPtr(i int) *int {
return &i

View File

@@ -738,11 +738,41 @@ func getRemoteForBranch(ctx context.Context, worktreePath, branch string) string
}
// GetRepoRoot returns the git repository root directory
// For worktrees, this returns the main repository root (not the worktree root)
func GetRepoRoot(ctx context.Context) (string, error) {
// Check if .git is a file (worktree) or directory (regular repo)
gitPath := ".git"
if info, err := os.Stat(gitPath); err == nil {
if info.Mode().IsRegular() {
// Worktree: read .git file
content, err := os.ReadFile(gitPath)
if err != nil {
return "", fmt.Errorf("failed to read .git file: %w", err)
}
line := strings.TrimSpace(string(content))
if strings.HasPrefix(line, "gitdir: ") {
gitDir := strings.TrimPrefix(line, "gitdir: ")
// Remove /worktrees/* part
if idx := strings.Index(gitDir, "/worktrees/"); idx > 0 {
gitDir = gitDir[:idx]
}
return filepath.Dir(gitDir), nil
}
} else if info.IsDir() {
// Regular repo: .git is a directory
absGitPath, err := filepath.Abs(gitPath)
if err != nil {
return "", err
}
return filepath.Dir(absGitPath), nil
}
}
// Fallback to git command
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to get git root: %w", err)
return "", fmt.Errorf("not a git repository: %w", err)
}
return strings.TrimSpace(string(output)), nil
}