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

@@ -12,6 +12,7 @@ import (
"runtime"
"strings"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/syncbranch"
"github.com/steveyegge/beads/internal/types"
@@ -199,6 +200,22 @@ func findBeadsDir() string {
return ""
}
// Check if we're in a git worktree with sparse checkout
// In worktrees, .beads might not exist locally, so we need to resolve to the main repo
if git.IsWorktree() {
mainRepoRoot, err := git.GetMainRepoRoot()
if err == nil && mainRepoRoot != "" {
mainBeadsDir := filepath.Join(mainRepoRoot, ".beads")
if info, err := os.Stat(mainBeadsDir); err == nil && info.IsDir() {
resolved, err := filepath.EvalSymlinks(mainBeadsDir)
if err != nil {
return mainBeadsDir
}
return resolved
}
}
}
for {
beadsDir := filepath.Join(dir, ".beads")
if info, err := os.Stat(beadsDir); err == nil && info.IsDir() {

View File

@@ -1019,7 +1019,7 @@ func pruneExpiredTombstones() (*TombstonePruneResult, error) {
}
allIssues = append(allIssues, &issue)
}
_ = file.Close() // Best effort close, already read all data
file.Close()
// Determine TTL
ttl := types.DefaultTombstoneTTL
@@ -1052,20 +1052,20 @@ func pruneExpiredTombstones() (*TombstonePruneResult, error) {
encoder := json.NewEncoder(tempFile)
for _, issue := range kept {
if err := encoder.Encode(issue); err != nil {
_ = tempFile.Close()
_ = os.Remove(tempPath) // Best effort cleanup
tempFile.Close()
os.Remove(tempPath)
return nil, fmt.Errorf("failed to write issue %s: %w", issue.ID, err)
}
}
if err := tempFile.Close(); err != nil {
_ = os.Remove(tempPath) // Best effort cleanup
os.Remove(tempPath)
return nil, fmt.Errorf("failed to close temp file: %w", err)
}
// Atomically replace
if err := os.Rename(tempPath, issuesPath); err != nil {
_ = os.Remove(tempPath) // Best effort cleanup
os.Remove(tempPath)
return nil, fmt.Errorf("failed to replace issues.jsonl: %w", err)
}

View File

@@ -183,18 +183,7 @@ func acquireStartLock(lockPath, socketPath string) bool {
// nolint:gosec // G304: lockPath is derived from secure beads directory
lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil {
// Lock file exists - check if it's from a dead process (stale) or alive daemon
lockPID, pidErr := readPIDFromFile(lockPath)
if pidErr != nil || !isPIDAlive(lockPID) {
// Stale lock from crashed process - clean up immediately (avoids 5s wait)
debugLog("startlock is stale (PID %d dead or unreadable), cleaning up", lockPID)
_ = os.Remove(lockPath)
// Retry lock acquisition after cleanup
return acquireStartLock(lockPath, socketPath)
}
// PID is alive - daemon is legitimately starting, wait for socket to be ready
debugLog("another process (PID %d) is starting daemon, waiting for readiness", lockPID)
debugLog("another process is starting daemon, waiting for readiness")
if waitForSocketReadiness(socketPath, 5*time.Second) {
return true
}

View File

@@ -601,18 +601,6 @@ func performAutoImport(ctx context.Context, store storage.Storage, skipGit bool,
return
}
// Update jsonl_content_hash after successful import to prevent repeated imports
// Uses repoKey for multi-repo support (bd-ar2.10, bd-ar2.11)
hashKey := "jsonl_content_hash"
if repoKey != "" {
hashKey += ":" + repoKey
}
if currentHash, err := computeJSONLHash(jsonlPath); err == nil {
if err := store.SetMetadata(importCtx, hashKey, currentHash); err != nil {
log.log("Warning: failed to update %s after import: %v", hashKey, err)
}
}
if skipGit {
log.log("Local auto-import complete")
} else {

View File

@@ -44,14 +44,20 @@ func syncBranchCommitAndPushWithOptions(ctx context.Context, store storage.Stora
log.log("Using sync branch: %s", syncBranch)
// Get repo root
repoRoot, err := getGitRoot(ctx)
// Get main repo root (for worktrees, this is the main repo, not worktree)
repoRoot, err := git.GetMainRepoRoot()
if err != nil {
return false, fmt.Errorf("failed to get git root: %w", err)
return false, fmt.Errorf("failed to get main repo root: %w", err)
}
// Use worktree-aware git directory detection
gitDir, err := git.GetGitDir()
if err != nil {
return false, fmt.Errorf("not a git repository: %w", err)
}
// Worktree path is under .git/beads-worktrees/<branch>
worktreePath := filepath.Join(repoRoot, ".git", "beads-worktrees", syncBranch)
worktreePath := filepath.Join(gitDir, "beads-worktrees", syncBranch)
// Initialize worktree manager
wtMgr := git.NewWorktreeManager(repoRoot)
@@ -216,14 +222,20 @@ func syncBranchPull(ctx context.Context, store storage.Storage, log daemonLogger
return false, nil
}
// Get repo root
repoRoot, err := getGitRoot(ctx)
// Get main repo root (for worktrees, this is the main repo, not worktree)
repoRoot, err := git.GetMainRepoRoot()
if err != nil {
return false, fmt.Errorf("failed to get git root: %w", err)
return false, fmt.Errorf("failed to get main repo root: %w", err)
}
// Use worktree-aware git directory detection
gitDir, err := git.GetGitDir()
if err != nil {
return false, fmt.Errorf("not a git repository: %w", err)
}
// Worktree path is under .git/beads-worktrees/<branch>
worktreePath := filepath.Join(repoRoot, ".git", "beads-worktrees", syncBranch)
worktreePath := filepath.Join(gitDir, "beads-worktrees", syncBranch)
// Initialize worktree manager
wtMgr := git.NewWorktreeManager(repoRoot)

View File

@@ -10,6 +10,7 @@ import (
"time"
"github.com/fsnotify/fsnotify"
"github.com/steveyegge/beads/internal/git"
)
// FileWatcher monitors JSONL and git ref changes using filesystem events or polling.
@@ -53,10 +54,16 @@ func NewFileWatcher(jsonlPath string, onChanged func()) (*FileWatcher, error) {
fallbackEnv := os.Getenv("BEADS_WATCHER_FALLBACK")
fallbackDisabled := fallbackEnv == "false" || fallbackEnv == "0"
// Store git paths for filtering
gitDir := filepath.Join(fw.parentDir, "..", ".git")
fw.gitRefsPath = filepath.Join(gitDir, "refs", "heads")
fw.gitHeadPath = filepath.Join(gitDir, "HEAD")
// Store git paths for filtering using worktree-aware detection
gitDir, err := git.GetGitDir()
if err != nil {
// Not a git repo, skip git path setup
fw.gitRefsPath = ""
fw.gitHeadPath = ""
} else {
fw.gitRefsPath = filepath.Join(gitDir, "refs", "heads")
fw.gitHeadPath = filepath.Join(gitDir, "HEAD")
}
// Get initial git HEAD state for polling
if stat, err := os.Stat(fw.gitHeadPath); err == nil {
@@ -103,8 +110,12 @@ func NewFileWatcher(jsonlPath string, onChanged func()) (*FileWatcher, error) {
}
// Also watch .git/refs/heads and .git/HEAD for branch changes (best effort)
_ = watcher.Add(fw.gitRefsPath) // Ignore error - not all setups have this
_ = watcher.Add(fw.gitHeadPath) // Ignore error - not all setups have this
if fw.gitRefsPath != "" {
_ = watcher.Add(fw.gitRefsPath) // Ignore error - not all setups have this
}
if fw.gitHeadPath != "" {
_ = watcher.Add(fw.gitHeadPath) // Ignore error - not all setups have this
}
return fw, nil
}
@@ -258,32 +269,34 @@ func (fw *FileWatcher) startPolling(ctx context.Context, log daemonLogger) {
}
}
// Check .git/HEAD for branch changes
headStat, err := os.Stat(fw.gitHeadPath)
if err != nil {
if os.IsNotExist(err) {
if fw.lastHeadExists {
fw.lastHeadExists = false
fw.lastHeadModTime = time.Time{}
log.log("Git HEAD missing (polling): %s", fw.gitHeadPath)
// Check .git/HEAD for branch changes (only if git paths are available)
if fw.gitHeadPath != "" {
headStat, err := os.Stat(fw.gitHeadPath)
if err != nil {
if os.IsNotExist(err) {
if fw.lastHeadExists {
fw.lastHeadExists = false
fw.lastHeadModTime = time.Time{}
log.log("Git HEAD missing (polling): %s", fw.gitHeadPath)
changed = true
}
}
// Ignore other errors for HEAD - it's optional
} else {
// HEAD exists
if !fw.lastHeadExists {
// HEAD appeared
fw.lastHeadExists = true
fw.lastHeadModTime = headStat.ModTime()
log.log("Git HEAD appeared (polling): %s", fw.gitHeadPath)
changed = true
} else if !headStat.ModTime().Equal(fw.lastHeadModTime) {
// HEAD changed (branch switch)
fw.lastHeadModTime = headStat.ModTime()
log.log("Git HEAD change detected (polling): %s", fw.gitHeadPath)
changed = true
}
}
// Ignore other errors for HEAD - it's optional
} else {
// HEAD exists
if !fw.lastHeadExists {
// HEAD appeared
fw.lastHeadExists = true
fw.lastHeadModTime = headStat.ModTime()
log.log("Git HEAD appeared (polling): %s", fw.gitHeadPath)
changed = true
} else if !headStat.ModTime().Equal(fw.lastHeadModTime) {
// HEAD changed (branch switch)
fw.lastHeadModTime = headStat.ModTime()
log.log("Git HEAD change detected (polling): %s", fw.gitHeadPath)
changed = true
}
}
if changed {

View File

@@ -22,6 +22,7 @@ import (
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/daemon"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/syncbranch"
)
@@ -432,7 +433,7 @@ func runCheckHealth(path string) {
// Check if database exists
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
// No database - only check hooks
if issue := checkHooksQuick(path); issue != "" {
if issue := checkHooksQuick(); issue != "" {
printCheckHealthHint([]string{issue})
}
return
@@ -442,7 +443,7 @@ func runCheckHealth(path string) {
db, err := sql.Open("sqlite3", "file:"+dbPath+"?mode=ro")
if err != nil {
// Can't open DB - only check hooks
if issue := checkHooksQuick(path); issue != "" {
if issue := checkHooksQuick(); issue != "" {
printCheckHealthHint([]string{issue})
}
return
@@ -462,8 +463,13 @@ func runCheckHealth(path string) {
issues = append(issues, issue)
}
// Check 2: Outdated git hooks
if issue := checkHooksQuick(path); issue != "" {
// Check 2: Sync branch not configured (now reads from config.yaml, not DB)
if issue := checkSyncBranchQuick(); issue != "" {
issues = append(issues, issue)
}
// Check 3: Outdated git hooks
if issue := checkHooksQuick(); issue != "" {
issues = append(issues, issue)
}
@@ -521,21 +527,23 @@ func checkVersionMismatchDB(db *sql.DB) string {
return ""
}
// checkSyncBranchQuick checks if sync-branch is configured in config.yaml.
// Fast check that doesn't require database access.
func checkSyncBranchQuick() string {
if syncbranch.IsConfigured() {
return ""
}
return "sync-branch not configured in config.yaml"
}
// checkHooksQuick does a fast check for outdated git hooks.
// Checks all beads hooks: pre-commit, post-merge, pre-push, post-checkout (bd-2em).
func checkHooksQuick(path string) string {
func checkHooksQuick() string {
// Get actual git directory (handles worktrees where .git is a file)
cmd := exec.Command("git", "rev-parse", "--git-dir")
cmd.Dir = path
output, err := cmd.Output()
gitDir, err := git.GetGitDir()
if err != nil {
return "" // Not a git repo, skip
}
gitDir := strings.TrimSpace(string(output))
// Make absolute if relative
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(path, gitDir)
}
hooksDir := filepath.Join(gitDir, "hooks")
// Check if hooks dir exists
@@ -607,7 +615,7 @@ func runDiagnostics(path string) doctorResult {
}
// Check Git Hooks early (even if .beads/ doesn't exist yet)
hooksCheck := checkGitHooks(path)
hooksCheck := checkGitHooks()
result.Checks = append(result.Checks, hooksCheck)
// Don't fail overall check for missing hooks, just warn
@@ -1323,7 +1331,6 @@ func printDiagnostics(result doctorResult) {
// Print warnings/errors with fixes
hasIssues := false
unfixableErrors := 0
for _, check := range result.Checks {
if check.Status != statusOK && check.Fix != "" {
if !hasIssues {
@@ -1338,27 +1345,12 @@ func printDiagnostics(result doctorResult) {
}
fmt.Printf(" Fix: %s\n\n", check.Fix)
} else if check.Status == statusError && check.Fix == "" {
// Count unfixable errors
unfixableErrors++
}
}
if !hasIssues {
color.Green("✓ All checks passed\n")
}
// Suggest reset if there are multiple unfixable errors
if unfixableErrors >= 3 {
fmt.Println()
color.Yellow("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
color.Yellow("⚠ Found %d unfixable errors\n", unfixableErrors)
fmt.Println()
fmt.Println(" Your beads state may be too corrupted to repair automatically.")
fmt.Println(" Consider running 'bd reset' to start fresh.")
fmt.Println(" (Use 'bd reset --backup' to save current state first)")
color.Yellow("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
}
}
func checkMultipleDatabases(path string) doctorCheck {
@@ -1881,10 +1873,10 @@ func checkDependencyCycles(path string) doctorCheck {
}
}
func checkGitHooks(path string) doctorCheck {
// Check if we're in a git repository
gitDir := filepath.Join(path, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
func checkGitHooks() doctorCheck {
// Check if we're in a git repository using worktree-aware detection
gitDir, err := git.GetGitDir()
if err != nil {
return doctorCheck{
Name: "Git Hooks",
Status: statusOK,
@@ -2105,9 +2097,9 @@ func checkDatabaseIntegrity(path string) doctorCheck {
}
func checkMergeDriver(path string) doctorCheck {
// Check if we're in a git repository
gitDir := filepath.Join(path, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
// Check if we're in a git repository using worktree-aware detection
_, err := git.GetGitDir()
if err != nil {
return doctorCheck{
Name: "Git Merge Driver",
Status: statusOK,
@@ -2306,9 +2298,9 @@ func checkSyncBranchConfig(path string) doctorCheck {
}
}
// Check if we're in a git repository
gitDir := filepath.Join(path, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
// Check if we're in a git repository using worktree-aware detection
_, err := git.GetGitDir()
if err != nil {
return doctorCheck{
Name: "Sync Branch Config",
Status: statusOK,
@@ -2350,20 +2342,26 @@ func checkSyncBranchConfig(path string) doctorCheck {
}
}
// Not configured - check if repo has a remote to provide appropriate message
// sync-branch is optional, only needed for protected branches or multi-clone workflows
// See GitHub issue #498
// Not configured - this is optional but recommended for multi-clone setups
// Check if this looks like a multi-clone setup (has remote)
hasRemote := false
cmd = exec.Command("git", "remote")
cmd.Dir = path
if output, err := cmd.Output(); err == nil && len(strings.TrimSpace(string(output))) > 0 {
hasRemote = true
}
if hasRemote {
return doctorCheck{
Name: "Sync Branch Config",
Status: statusOK,
Message: "Not configured (optional)",
Detail: "Only needed for protected branches or multi-clone workflows",
Status: statusWarning,
Message: "sync-branch not configured",
Detail: "Multi-clone setups should configure sync-branch in config.yaml",
Fix: "Add 'sync-branch: beads-sync' to .beads/config.yaml",
}
}
// No remote - probably a local-only repo, sync-branch not needed
return doctorCheck{
Name: "Sync Branch Config",
Status: statusOK,
@@ -2375,9 +2373,9 @@ func checkSyncBranchConfig(path string) doctorCheck {
// or from the remote sync branch (after a force-push reset).
// bd-6rf: Detect and fix stale beads-sync branch
func checkSyncBranchHealth(path string) doctorCheck {
// Skip if not in a git repo
gitDir := filepath.Join(path, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
// Skip if not in a git repo using worktree-aware detection
_, err := git.GetGitDir()
if err != nil {
return doctorCheck{
Name: "Sync Branch Health",
Status: statusOK,
@@ -2542,9 +2540,9 @@ func checkDeletionsManifest(path string) doctorCheck {
}
}
// Check if we're in a git repository
gitDir := filepath.Join(path, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
// Check if we're in a git repository using worktree-aware detection
_, err := git.GetGitDir()
if err != nil {
return doctorCheck{
Name: "Deletions Manifest",
Status: statusOK,
@@ -2738,9 +2736,9 @@ func checkUntrackedBeadsFiles(path string) doctorCheck {
}
}
// Check if we're in a git repository
gitDir := filepath.Join(path, ".git")
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
// Check if we're in a git repository using worktree-aware detection
_, err := git.GetGitDir()
if err != nil {
return doctorCheck{
Name: "Untracked Files",
Status: statusOK,

View File

@@ -3,7 +3,6 @@ package fix
import (
"os"
"path/filepath"
"runtime"
"testing"
)
@@ -14,9 +13,6 @@ import (
// - When .beads is a symlink, Permissions() should return nil without changing anything
// - This prevents attempts to chmod symlink targets (which may be read-only like /nix/store)
func TestPermissions_SkipsSymlinkedBeadsDir(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Skipping symlink test on Windows - requires elevated privileges")
}
tmpDir := t.TempDir()
// Create target .beads directory with wrong permissions
@@ -66,9 +62,6 @@ func TestPermissions_SkipsSymlinkedBeadsDir(t *testing.T) {
// TestPermissions_SkipsSymlinkedDatabase verifies that chmod is skipped for
// symlinked database files, but .beads directory permissions are still fixed.
func TestPermissions_SkipsSymlinkedDatabase(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Skipping symlink test on Windows - requires elevated privileges")
}
tmpDir := t.TempDir()
// Create real .beads directory with wrong permissions
@@ -132,9 +125,6 @@ func TestPermissions_SkipsSymlinkedDatabase(t *testing.T) {
// TestPermissions_FixesRegularFiles verifies that permissions ARE fixed for
// regular (non-symlinked) files.
func TestPermissions_FixesRegularFiles(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Skipping permissions test on Windows - Unix-style permissions don't apply")
}
tmpDir := t.TempDir()
// Create .beads directory with wrong permissions

View File

@@ -7,38 +7,49 @@ import (
)
// GitignoreTemplate is the canonical .beads/.gitignore content
// Uses whitelist approach: ignore everything by default, explicitly allow tracked files.
// This prevents confusion about which files to commit (fixes GitHub #473).
const GitignoreTemplate = `# Ignore all .beads/ contents by default (local workspace files)
# Only files explicitly whitelisted below will be tracked in git
*
const GitignoreTemplate = `# SQLite databases
*.db
*.db?*
*.db-journal
*.db-wal
*.db-shm
# === Files tracked in git (shared across clones) ===
# Daemon runtime files
daemon.lock
daemon.log
daemon.pid
bd.sock
# This gitignore file itself
!.gitignore
# Local version tracking (prevents upgrade notification spam after git ops)
.local_version
# Issue data in JSONL format (the main data file)
# Legacy database files
db.sqlite
bd.db
# Merge artifacts (temporary files from 3-way merge)
beads.base.jsonl
beads.base.meta.json
beads.left.jsonl
beads.left.meta.json
beads.right.jsonl
beads.right.meta.json
# Keep JSONL exports and config (source of truth for git)
!issues.jsonl
# Repository metadata (database name, JSONL filename)
!metadata.json
# Configuration template (sync branch, integrations)
!config.yaml
# Documentation for contributors
!README.md
!config.json
`
// requiredPatterns are patterns that MUST be in .beads/.gitignore
// With the whitelist approach, we check for the blanket ignore and whitelisted files
var requiredPatterns = []string{
"*", // Blanket ignore (whitelist approach)
"!.gitignore", // Whitelist the gitignore itself
"!issues.jsonl",
"!metadata.json",
"!config.yaml", // Fixed: was incorrectly !config.json before #473
"beads.base.jsonl",
"beads.left.jsonl",
"beads.right.jsonl",
"beads.base.meta.json",
"beads.left.meta.json",
"beads.right.meta.json",
"*.db?*",
}
// CheckGitignore checks if .beads/.gitignore is up to date
@@ -69,7 +80,7 @@ func CheckGitignore() DoctorCheck {
return DoctorCheck{
Name: "Gitignore",
Status: "warning",
Message: "Outdated .beads/.gitignore (needs whitelist patterns)",
Message: "Outdated .beads/.gitignore (missing merge artifact patterns)",
Detail: "Missing: " + strings.Join(missing, ", "),
Fix: "Run: bd doctor --fix or bd init (safe to re-run)",
}

View File

@@ -9,6 +9,8 @@ import (
"path/filepath"
"strings"
"testing"
"github.com/steveyegge/beads/internal/git"
)
func TestDoctorNoBeadsDir(t *testing.T) {
@@ -684,8 +686,30 @@ func TestCheckGitHooks(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
tmpDir := t.TempDir()
// Always change to tmpDir to ensure GetGitDir detects the correct context
oldDir, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get current directory: %v", err)
}
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to change to test directory: %v", err)
}
defer func() {
_ = os.Chdir(oldDir)
}()
if tc.hasGitDir {
gitDir := filepath.Join(tmpDir, ".git")
// Initialize a real git repository in the test directory
cmd := exec.Command("git", "init")
cmd.Dir = tmpDir
if err := cmd.Run(); err != nil {
t.Skipf("Skipping test: git init failed: %v", err)
}
gitDir, err := git.GetGitDir()
if err != nil {
t.Fatalf("git.GetGitDir() failed: %v", err)
}
hooksDir := filepath.Join(gitDir, "hooks")
if err := os.MkdirAll(hooksDir, 0750); err != nil {
t.Fatal(err)
@@ -700,7 +724,7 @@ func TestCheckGitHooks(t *testing.T) {
}
}
check := checkGitHooks(tmpDir)
check := checkGitHooks()
if check.Status != tc.expectedStatus {
t.Errorf("Expected status %s, got %s", tc.expectedStatus, check.Status)

View File

@@ -11,20 +11,9 @@ import (
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/git"
)
// getGitDir returns the actual .git directory path.
// 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.
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
}
//go:embed templates/hooks/*
var hooksFS embed.FS
@@ -59,7 +48,7 @@ func CheckGitHooks() []HookStatus {
statuses := make([]HookStatus, 0, len(hooks))
// Get actual git directory (handles worktrees)
gitDir, err := getGitDir()
gitDir, err := git.GetGitDir()
if err != nil {
// Not a git repo - return all hooks as not installed
for _, hookName := range hooks {
@@ -299,7 +288,7 @@ var hooksListCmd = &cobra.Command{
func installHooks(embeddedHooks map[string]string, force bool, shared bool) error {
// Get actual git directory (handles worktrees where .git is a file)
gitDir, err := getGitDir()
gitDir, err := git.GetGitDir()
if err != nil {
return err
}
@@ -361,7 +350,7 @@ func configureSharedHooksPath() error {
func uninstallHooks() error {
// Get actual git directory (handles worktrees)
gitDir, err := getGitDir()
gitDir, err := git.GetGitDir()
if err != nil {
return err
}

View File

@@ -6,6 +6,8 @@ import (
"path/filepath"
"runtime"
"testing"
"github.com/steveyegge/beads/internal/git"
)
func TestGetEmbeddedHooks(t *testing.T) {
@@ -43,9 +45,9 @@ func TestInstallHooks(t *testing.T) {
t.Skipf("Skipping test: git init failed: %v", err)
}
gitDirPath, err := getGitDir()
gitDirPath, err := git.GetGitDir()
if err != nil {
t.Fatalf("getGitDir() failed: %v", err)
t.Fatalf("git.GetGitDir() failed: %v", err)
}
gitDir := filepath.Join(gitDirPath, "hooks")
@@ -94,9 +96,9 @@ func TestInstallHooksBackup(t *testing.T) {
t.Skipf("Skipping test: git init failed: %v", err)
}
gitDirPath, err := getGitDir()
gitDirPath, err := git.GetGitDir()
if err != nil {
t.Fatalf("getGitDir() failed: %v", err)
t.Fatalf("git.GetGitDir() failed: %v", err)
}
gitDir := filepath.Join(gitDirPath, "hooks")
@@ -151,9 +153,9 @@ func TestInstallHooksForce(t *testing.T) {
t.Skipf("Skipping test: git init failed: %v", err)
}
gitDirPath, err := getGitDir()
gitDirPath, err := git.GetGitDir()
if err != nil {
t.Fatalf("getGitDir() failed: %v", err)
t.Fatalf("git.GetGitDir() failed: %v", err)
}
gitDir := filepath.Join(gitDirPath, "hooks")
@@ -198,9 +200,9 @@ func TestUninstallHooks(t *testing.T) {
t.Skipf("Skipping test: git init failed: %v", err)
}
gitDirPath, err := getGitDir()
gitDirPath, err := git.GetGitDir()
if err != nil {
t.Fatalf("getGitDir() failed: %v", err)
t.Fatalf("git.GetGitDir() failed: %v", err)
}
gitDir := filepath.Join(gitDirPath, "hooks")
@@ -320,9 +322,9 @@ func TestInstallHooksShared(t *testing.T) {
}
// Verify hooks were NOT installed to .git/hooks/
gitDirPath, err := getGitDir()
gitDirPath, err := git.GetGitDir()
if err != nil {
t.Fatalf("getGitDir() failed: %v", err)
t.Fatalf("git.GetGitDir() failed: %v", err)
}
standardHooksDir := filepath.Join(gitDirPath, "hooks")
for hookName := range hooks {

View File

@@ -17,6 +17,7 @@ import (
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/syncbranch"
"github.com/steveyegge/beads/internal/types"
@@ -127,14 +128,30 @@ With --stealth: configures global git settings for invisible beads usage:
os.Exit(1)
}
// Determine if we should create .beads/ directory in CWD
// Only create it if the database will be stored there
// Determine if we should create .beads/ directory in CWD or main repo root
// For worktrees, .beads should always be in the main repository root
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to get current directory: %v\n", err)
os.Exit(1)
}
// Check if we're in a git worktree
isWorktree := git.IsWorktree()
var beadsDir string
if isWorktree {
// For worktrees, .beads should be in the main repository root
mainRepoRoot, err := git.GetMainRepoRoot()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to get main repository root: %v\n", err)
os.Exit(1)
}
beadsDir = filepath.Join(mainRepoRoot, ".beads")
} else {
// For regular repos, use current directory
beadsDir = filepath.Join(cwd, ".beads")
}
// Prevent nested .beads directories
// Check if current working directory is inside a .beads directory
if strings.Contains(filepath.Clean(cwd), string(filepath.Separator)+".beads"+string(filepath.Separator)) ||
@@ -145,24 +162,23 @@ With --stealth: configures global git settings for invisible beads usage:
os.Exit(1)
}
localBeadsDir := filepath.Join(cwd, ".beads")
initDBDir := filepath.Dir(initDBPath)
// Convert both to absolute paths for comparison
localBeadsDirAbs, err := filepath.Abs(localBeadsDir)
beadsDirAbs, err := filepath.Abs(beadsDir)
if err != nil {
localBeadsDirAbs = filepath.Clean(localBeadsDir)
beadsDirAbs = filepath.Clean(beadsDir)
}
initDBDirAbs, err := filepath.Abs(initDBDir)
if err != nil {
initDBDirAbs = filepath.Clean(initDBDir)
}
useLocalBeads := filepath.Clean(initDBDirAbs) == filepath.Clean(localBeadsDirAbs)
useLocalBeads := filepath.Clean(initDBDirAbs) == filepath.Clean(beadsDirAbs)
if useLocalBeads {
// Create .beads directory
if err := os.MkdirAll(localBeadsDir, 0750); err != nil {
if err := os.MkdirAll(beadsDir, 0750); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to create .beads directory: %v\n", err)
os.Exit(1)
}
@@ -170,7 +186,7 @@ With --stealth: configures global git settings for invisible beads usage:
// Handle --no-db mode: create issues.jsonl file instead of database
if noDb {
// Create empty issues.jsonl file
jsonlPath := filepath.Join(localBeadsDir, "issues.jsonl")
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
// nolint:gosec // G306: JSONL file needs to be readable by other tools
if err := os.WriteFile(jsonlPath, []byte{}, 0644); err != nil {
@@ -181,19 +197,19 @@ With --stealth: configures global git settings for invisible beads usage:
// Create metadata.json for --no-db mode
cfg := configfile.DefaultConfig()
if err := cfg.Save(localBeadsDir); err != nil {
if err := cfg.Save(beadsDir); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create metadata.json: %v\n", err)
// Non-fatal - continue anyway
}
// Create config.yaml with no-db: true
if err := createConfigYaml(localBeadsDir, true); err != nil {
if err := createConfigYaml(beadsDir, true); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create config.yaml: %v\n", err)
// Non-fatal - continue anyway
}
// Create README.md
if err := createReadme(localBeadsDir); err != nil {
if err := createReadme(beadsDir); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create README.md: %v\n", err)
// Non-fatal - continue anyway
}
@@ -213,7 +229,7 @@ With --stealth: configures global git settings for invisible beads usage:
}
// Create/update .gitignore in .beads directory (idempotent - always update to latest)
gitignorePath := filepath.Join(localBeadsDir, ".gitignore")
gitignorePath := filepath.Join(beadsDir, ".gitignore")
if err := os.WriteFile(gitignorePath, []byte(doctor.GitignoreTemplate), 0600); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create/update .gitignore: %v\n", err)
// Non-fatal - continue anyway
@@ -308,7 +324,7 @@ With --stealth: configures global git settings for invisible beads usage:
// Create or preserve metadata.json for database metadata (bd-zai fix)
if useLocalBeads {
// First, check if metadata.json already exists
existingCfg, err := configfile.Load(localBeadsDir)
existingCfg, err := configfile.Load(beadsDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to load existing metadata.json: %v\n", err)
}
@@ -321,27 +337,27 @@ With --stealth: configures global git settings for invisible beads usage:
// Create new config, detecting JSONL filename from existing files
cfg = configfile.DefaultConfig()
// Check if beads.jsonl exists but issues.jsonl doesn't (legacy)
issuesPath := filepath.Join(localBeadsDir, "issues.jsonl")
beadsPath := filepath.Join(localBeadsDir, "beads.jsonl")
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
beadsPath := filepath.Join(beadsDir, "beads.jsonl")
if _, err := os.Stat(beadsPath); err == nil {
if _, err := os.Stat(issuesPath); os.IsNotExist(err) {
cfg.JSONLExport = "beads.jsonl" // Legacy filename
}
}
}
if err := cfg.Save(localBeadsDir); err != nil {
if err := cfg.Save(beadsDir); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create metadata.json: %v\n", err)
// Non-fatal - continue anyway
}
// Create config.yaml template
if err := createConfigYaml(localBeadsDir, false); err != nil {
if err := createConfigYaml(beadsDir, false); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create config.yaml: %v\n", err)
// Non-fatal - continue anyway
}
// Create README.md
if err := createReadme(localBeadsDir); err != nil {
if err := createReadme(beadsDir); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create README.md: %v\n", err)
// Non-fatal - continue anyway
}
@@ -388,8 +404,8 @@ With --stealth: configures global git settings for invisible beads usage:
}
// Check if we're in a git repo and hooks aren't installed
// Install by default unless --skip-hooks is passed or no-install-hooks config is set
if !skipHooks && !config.GetBool("no-install-hooks") && isGitRepo() && !hooksInstalled() {
// Install by default unless --skip-hooks is passed
if !skipHooks && isGitRepo() && !hooksInstalled() {
if err := installGitHooks(); err != nil && !quiet {
yellow := color.New(color.FgYellow).SprintFunc()
fmt.Fprintf(os.Stderr, "\n%s Failed to install git hooks: %v\n", yellow("⚠"), err)
@@ -460,7 +476,7 @@ func init() {
// hooksInstalled checks if bd git hooks are installed
func hooksInstalled() bool {
gitDir, err := getGitDir()
gitDir, err := git.GetGitDir()
if err != nil {
return false
}
@@ -520,7 +536,7 @@ type hookInfo struct {
// detectExistingHooks scans for existing git hooks
func detectExistingHooks() []hookInfo {
gitDir, err := getGitDir()
gitDir, err := git.GetGitDir()
if err != nil {
return nil
}
@@ -578,7 +594,7 @@ func promptHookAction(existingHooks []hookInfo) string {
// installGitHooks installs git hooks inline (no external dependencies)
func installGitHooks() error {
gitDir, err := getGitDir()
gitDir, err := git.GetGitDir()
if err != nil {
return err
}
@@ -657,34 +673,61 @@ func installGitHooks() error {
# Run existing hook first
if [ -x "` + existingPreCommit + `" ]; then
"` + existingPreCommit + `" "$@"
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
exit $EXIT_CODE
fi
"` + existingPreCommit + `" "$@"
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
exit $EXIT_CODE
fi
fi
# Check if bd is available
if ! command -v bd >/dev/null 2>&1; then
echo "Warning: bd command not found, skipping pre-commit flush" >&2
exit 0
echo "Warning: bd command not found, skipping pre-commit flush" >&2
exit 0
fi
# Check if we're in a bd workspace
if [ ! -d .beads ]; then
exit 0
# For worktrees, .beads is in the main repository root, not the worktree
BEADS_DIR=""
if git rev-parse --git-dir >/dev/null 2>&1; then
# Check if we're in a worktree
if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ]; then
# Worktree: .beads is in main repo root
MAIN_REPO_ROOT="$(git rev-parse --git-common-dir)"
MAIN_REPO_ROOT="$(dirname "$MAIN_REPO_ROOT")"
if [ -d "$MAIN_REPO_ROOT/.beads" ]; then
BEADS_DIR="$MAIN_REPO_ROOT/.beads"
fi
else
# Regular repo: check current directory
if [ -d .beads ]; then
BEADS_DIR=".beads"
fi
fi
fi
if [ -z "$BEADS_DIR" ]; then
exit 0
fi
# Flush pending changes to JSONL
if ! bd sync --flush-only >/dev/null 2>&1; then
echo "Error: Failed to flush bd changes to JSONL" >&2
echo "Run 'bd sync --flush-only' manually to diagnose" >&2
exit 1
echo "Error: Failed to flush bd changes to JSONL" >&2
echo "Run 'bd sync --flush-only' manually to diagnose" >&2
exit 1
fi
# If the JSONL file was modified, stage it
if [ -f .beads/issues.jsonl ]; then
git add .beads/issues.jsonl 2>/dev/null || true
# For worktrees, the JSONL is in the main repo's working tree, not the worktree,
# so we can't use git add. Skip this step for worktrees.
if [ -f "$BEADS_DIR/issues.jsonl" ]; then
if [ "$(git rev-parse --git-dir)" = "$(git rev-parse --git-common-dir)" ]; then
# Regular repo: file is in the working tree, safe to add
git add "$BEADS_DIR/issues.jsonl" 2>/dev/null || true
fi
# For worktrees: .beads is in the main repo's working tree, not this worktree
# Git rejects adding files outside the worktree, so we skip it.
# The main repo will see the changes on the next pull/sync.
fi
exit 0
@@ -700,28 +743,55 @@ exit 0
# Check if bd is available
if ! command -v bd >/dev/null 2>&1; then
echo "Warning: bd command not found, skipping pre-commit flush" >&2
exit 0
echo "Warning: bd command not found, skipping pre-commit flush" >&2
exit 0
fi
# Check if we're in a bd workspace
if [ ! -d .beads ]; then
# Not a bd workspace, nothing to do
exit 0
# For worktrees, .beads is in the main repository root, not the worktree
BEADS_DIR=""
if git rev-parse --git-dir >/dev/null 2>&1; then
# Check if we're in a worktree
if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ]; then
# Worktree: .beads is in main repo root
MAIN_REPO_ROOT="$(git rev-parse --git-common-dir)"
MAIN_REPO_ROOT="$(dirname "$MAIN_REPO_ROOT")"
if [ -d "$MAIN_REPO_ROOT/.beads" ]; then
BEADS_DIR="$MAIN_REPO_ROOT/.beads"
fi
else
# Regular repo: check current directory
if [ -d .beads ]; then
BEADS_DIR=".beads"
fi
fi
fi
if [ -z "$BEADS_DIR" ]; then
# Not a bd workspace, nothing to do
exit 0
fi
# Flush pending changes to JSONL
# Use --flush-only to skip git operations (we're already in a git hook)
# Suppress output unless there's an error
if ! bd sync --flush-only >/dev/null 2>&1; then
echo "Error: Failed to flush bd changes to JSONL" >&2
echo "Run 'bd sync --flush-only' manually to diagnose" >&2
exit 1
echo "Error: Failed to flush bd changes to JSONL" >&2
echo "Run 'bd sync --flush-only' manually to diagnose" >&2
exit 1
fi
# If the JSONL file was modified, stage it
if [ -f .beads/issues.jsonl ]; then
git add .beads/issues.jsonl 2>/dev/null || true
# For worktrees, the JSONL is in the main repo's working tree, not the worktree,
# so we can't use git add. Skip this step for worktrees.
if [ -f "$BEADS_DIR/issues.jsonl" ]; then
if [ "$(git rev-parse --git-dir)" = "$(git rev-parse --git-common-dir)" ]; then
# Regular repo: file is in the working tree, safe to add
git add "$BEADS_DIR/issues.jsonl" 2>/dev/null || true
fi
# For worktrees: .beads is in the main repo's working tree, not this worktree
# Git rejects adding files outside the worktree, so we skip it.
# The main repo will see the changes on the next pull/sync.
fi
exit 0
@@ -755,33 +825,52 @@ exit 0
# Run existing hook first
if [ -x "` + existingPostMerge + `" ]; then
"` + existingPostMerge + `" "$@"
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
exit $EXIT_CODE
fi
"` + existingPostMerge + `" "$@"
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
exit $EXIT_CODE
fi
fi
# Check if bd is available
if ! command -v bd >/dev/null 2>&1; then
echo "Warning: bd command not found, skipping post-merge import" >&2
exit 0
echo "Warning: bd command not found, skipping post-merge import" >&2
exit 0
fi
# Check if we're in a bd workspace
if [ ! -d .beads ]; then
exit 0
# For worktrees, .beads is in the main repository root, not the worktree
BEADS_DIR=""
if git rev-parse --git-dir >/dev/null 2>&1; then
# Check if we're in a worktree
if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ]; then
# Worktree: .beads is in main repo root
MAIN_REPO_ROOT="$(git rev-parse --git-common-dir)"
MAIN_REPO_ROOT="$(dirname "$MAIN_REPO_ROOT")"
if [ -d "$MAIN_REPO_ROOT/.beads" ]; then
BEADS_DIR="$MAIN_REPO_ROOT/.beads"
fi
else
# Regular repo: check current directory
if [ -d .beads ]; then
BEADS_DIR=".beads"
fi
fi
fi
if [ -z "$BEADS_DIR" ]; then
exit 0
fi
# Check if issues.jsonl exists and was updated
if [ ! -f .beads/issues.jsonl ]; then
exit 0
if [ ! -f "$BEADS_DIR/issues.jsonl" ]; then
exit 0
fi
# Import the updated JSONL
if ! bd import -i .beads/issues.jsonl >/dev/null 2>&1; then
echo "Warning: Failed to import bd changes after merge" >&2
echo "Run 'bd import -i .beads/issues.jsonl' manually to see the error" >&2
if ! bd import -i "$BEADS_DIR/issues.jsonl" >/dev/null 2>&1; then
echo "Warning: Failed to import bd changes after merge" >&2
echo "Run 'bd import -i $BEADS_DIR/issues.jsonl' manually to see the error" >&2
fi
exit 0
@@ -796,28 +885,47 @@ exit 0
# Check if bd is available
if ! command -v bd >/dev/null 2>&1; then
echo "Warning: bd command not found, skipping post-merge import" >&2
exit 0
echo "Warning: bd command not found, skipping post-merge import" >&2
exit 0
fi
# Check if we're in a bd workspace
if [ ! -d .beads ]; then
# Not a bd workspace, nothing to do
exit 0
# For worktrees, .beads is in the main repository root, not the worktree
BEADS_DIR=""
if git rev-parse --git-dir >/dev/null 2>&1; then
# Check if we're in a worktree
if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ]; then
# Worktree: .beads is in main repo root
MAIN_REPO_ROOT="$(git rev-parse --git-common-dir)"
MAIN_REPO_ROOT="$(dirname "$MAIN_REPO_ROOT")"
if [ -d "$MAIN_REPO_ROOT/.beads" ]; then
BEADS_DIR="$MAIN_REPO_ROOT/.beads"
fi
else
# Regular repo: check current directory
if [ -d .beads ]; then
BEADS_DIR=".beads"
fi
fi
fi
if [ -z "$BEADS_DIR" ]; then
# Not a bd workspace, nothing to do
exit 0
fi
# Check if issues.jsonl exists and was updated
if [ ! -f .beads/issues.jsonl ]; then
exit 0
if [ ! -f "$BEADS_DIR/issues.jsonl" ]; then
exit 0
fi
# Import the updated JSONL
# The auto-import feature should handle this, but we force it here
# to ensure immediate sync after merge
if ! bd import -i .beads/issues.jsonl >/dev/null 2>&1; then
echo "Warning: Failed to import bd changes after merge" >&2
echo "Run 'bd import -i .beads/issues.jsonl' manually to see the error" >&2
# Don't fail the merge, just warn
if ! bd import -i "$BEADS_DIR/issues.jsonl" >/dev/null 2>&1; then
echo "Warning: Failed to import bd changes after merge" >&2
echo "Run 'bd import -i $BEADS_DIR/issues.jsonl' manually to see the error" >&2
# Don't fail the merge, just warn
fi
exit 0
@@ -1385,13 +1493,28 @@ func setupGlobalGitIgnore(homeDir string, projectPath string, verbose bool) erro
// Note: This only blocks when a database already exists (workspace is initialized).
// Fresh clones with JSONL but no database are allowed - init will create the database
// and import from JSONL automatically (bd-4h9: fixes circular dependency with doctor --fix).
//
// For worktrees, checks the main repository root instead of current directory
// since worktrees should share the database with the main repository.
func checkExistingBeadsData(prefix string) error {
cwd, err := os.Getwd()
if err != nil {
return nil // Can't determine CWD, allow init to proceed
}
beadsDir := filepath.Join(cwd, ".beads")
// Determine where to check for .beads directory
var beadsDir string
if git.IsWorktree() {
// For worktrees, .beads should be in the main repository root
mainRepoRoot, err := git.GetMainRepoRoot()
if err != nil {
return nil // Can't determine main repo root, allow init to proceed
}
beadsDir = filepath.Join(mainRepoRoot, ".beads")
} else {
// For regular repos, check current directory
beadsDir = filepath.Join(cwd, ".beads")
}
// Check if .beads directory exists
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
@@ -1427,7 +1550,6 @@ Aborting.`, yellow("⚠"), dbPath, cyan("bd list"), prefix)
}
// setupClaudeSettings creates or updates .claude/settings.local.json with onboard instruction
func setupClaudeSettings(verbose bool) error {
claudeDir := ".claude"

View File

@@ -6,6 +6,8 @@ import (
"path/filepath"
"strings"
"testing"
"github.com/steveyegge/beads/internal/git"
)
func TestDetectExistingHooks(t *testing.T) {
@@ -18,9 +20,9 @@ func TestDetectExistingHooks(t *testing.T) {
t.Skipf("Skipping test: git init failed: %v", err)
}
gitDirPath, err := getGitDir()
gitDirPath, err := git.GetGitDir()
if err != nil {
t.Fatalf("getGitDir() failed: %v", err)
t.Fatalf("git.GetGitDir() failed: %v", err)
}
hooksDir := filepath.Join(gitDirPath, "hooks")
@@ -112,9 +114,9 @@ func TestInstallGitHooks_NoExistingHooks(t *testing.T) {
t.Skipf("Skipping test: git init failed: %v", err)
}
gitDirPath, err := getGitDir()
gitDirPath, err := git.GetGitDir()
if err != nil {
t.Fatalf("getGitDir() failed: %v", err)
t.Fatalf("git.GetGitDir() failed: %v", err)
}
hooksDir := filepath.Join(gitDirPath, "hooks")
@@ -154,9 +156,9 @@ func TestInstallGitHooks_ExistingHookBackup(t *testing.T) {
t.Skipf("Skipping test: git init failed: %v", err)
}
gitDirPath, err := getGitDir()
gitDirPath, err := git.GetGitDir()
if err != nil {
t.Fatalf("getGitDir() failed: %v", err)
t.Fatalf("git.GetGitDir() failed: %v", err)
}
hooksDir := filepath.Join(gitDirPath, "hooks")

View File

@@ -115,15 +115,21 @@ func TestInitCommand(t *testing.T) {
if err != nil {
t.Errorf(".gitignore file was not created: %v", err)
} else {
// Check for essential patterns (whitelist approach - GitHub #473)
// Check for essential patterns
gitignoreStr := string(gitignoreContent)
expectedPatterns := []string{
"*", // Blanket ignore
"!.gitignore", // Whitelist gitignore itself
"!issues.jsonl", // Whitelist JSONL
"!metadata.json",
"!config.yaml",
"!README.md",
"*.db",
"*.db?*",
"*.db-journal",
"*.db-wal",
"*.db-shm",
"daemon.log",
"daemon.pid",
"bd.sock",
"beads.base.jsonl",
"beads.left.jsonl",
"beads.right.jsonl",
"!issues.jsonl",
}
for _, pattern := range expectedPatterns {
if !strings.Contains(gitignoreStr, pattern) {
@@ -1041,188 +1047,3 @@ func TestSetupClaudeSettings_NoExistingFile(t *testing.T) {
t.Error("File should contain bd onboard prompt")
}
}
// TestInitSkipHooksWithEnvVar verifies BD_NO_INSTALL_HOOKS=1 prevents hook installation
func TestInitSkipHooksWithEnvVar(t *testing.T) {
// Reset global state
origDBPath := dbPath
defer func() { dbPath = origDBPath }()
dbPath = ""
tmpDir := t.TempDir()
t.Chdir(tmpDir)
// Initialize git repo first (hooks only install in git repos)
if err := runCommandInDir(tmpDir, "git", "init"); err != nil {
t.Fatalf("Failed to init git: %v", err)
}
// Set environment variable to skip hooks
os.Setenv("BD_NO_INSTALL_HOOKS", "1")
defer os.Unsetenv("BD_NO_INSTALL_HOOKS")
// Run bd init
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--quiet"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Init failed: %v", err)
}
// Verify .beads directory was created
beadsDir := filepath.Join(tmpDir, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
t.Error(".beads directory was not created")
}
// Verify git hooks were NOT installed
preCommitHook := filepath.Join(tmpDir, ".git", "hooks", "pre-commit")
if _, err := os.Stat(preCommitHook); err == nil {
t.Error("pre-commit hook should NOT be installed when BD_NO_INSTALL_HOOKS=1")
}
postMergeHook := filepath.Join(tmpDir, ".git", "hooks", "post-merge")
if _, err := os.Stat(postMergeHook); err == nil {
t.Error("post-merge hook should NOT be installed when BD_NO_INSTALL_HOOKS=1")
}
}
// TestInitSkipHooksWithConfigFile verifies no-install-hooks: true in config prevents hook installation
func TestInitSkipHooksWithConfigFile(t *testing.T) {
// Reset global state
origDBPath := dbPath
defer func() { dbPath = origDBPath }()
dbPath = ""
tmpDir := t.TempDir()
t.Chdir(tmpDir)
// Initialize git repo first
if err := runCommandInDir(tmpDir, "git", "init"); err != nil {
t.Fatalf("Failed to init git: %v", err)
}
// Create global config directory with no-install-hooks: true
configDir, err := os.UserConfigDir()
if err != nil {
t.Fatalf("Failed to get user config dir: %v", err)
}
bdConfigDir := filepath.Join(configDir, "bd")
if err := os.MkdirAll(bdConfigDir, 0755); err != nil {
t.Fatalf("Failed to create config dir: %v", err)
}
configPath := filepath.Join(bdConfigDir, "config.yaml")
// Backup existing config if present
existingConfig, existingErr := os.ReadFile(configPath)
defer func() {
if existingErr == nil {
os.WriteFile(configPath, existingConfig, 0644)
} else {
os.Remove(configPath)
}
}()
// Write test config
if err := os.WriteFile(configPath, []byte("no-install-hooks: true\n"), 0644); err != nil {
t.Fatalf("Failed to write config: %v", err)
}
// Run bd init
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--quiet"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Init failed: %v", err)
}
// Verify .beads directory was created
beadsDir := filepath.Join(tmpDir, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
t.Error(".beads directory was not created")
}
// Verify git hooks were NOT installed
preCommitHook := filepath.Join(tmpDir, ".git", "hooks", "pre-commit")
if _, err := os.Stat(preCommitHook); err == nil {
t.Error("pre-commit hook should NOT be installed when no-install-hooks: true in config")
}
}
// TestInitSkipHooksFlag verifies --skip-hooks flag still works (backward compat)
func TestInitSkipHooksFlag(t *testing.T) {
// Reset global state
origDBPath := dbPath
defer func() { dbPath = origDBPath }()
dbPath = ""
// Reset Cobra flags
initCmd.Flags().Set("skip-hooks", "false")
tmpDir := t.TempDir()
t.Chdir(tmpDir)
// Initialize git repo first
if err := runCommandInDir(tmpDir, "git", "init"); err != nil {
t.Fatalf("Failed to init git: %v", err)
}
// Run bd init with --skip-hooks
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--quiet", "--skip-hooks"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Init failed: %v", err)
}
// Verify .beads directory was created
beadsDir := filepath.Join(tmpDir, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
t.Error(".beads directory was not created")
}
// Verify git hooks were NOT installed
preCommitHook := filepath.Join(tmpDir, ".git", "hooks", "pre-commit")
if _, err := os.Stat(preCommitHook); err == nil {
t.Error("pre-commit hook should NOT be installed when --skip-hooks is passed")
}
}
// TestInitDefaultInstallsHooks verifies default behavior installs hooks
func TestInitDefaultInstallsHooks(t *testing.T) {
// Reset global state
origDBPath := dbPath
defer func() { dbPath = origDBPath }()
dbPath = ""
// Reset Cobra flags
initCmd.Flags().Set("skip-hooks", "false")
// Clear any env var that might affect hooks
os.Unsetenv("BD_NO_INSTALL_HOOKS")
tmpDir := t.TempDir()
t.Chdir(tmpDir)
// Initialize git repo first
if err := runCommandInDir(tmpDir, "git", "init"); err != nil {
t.Fatalf("Failed to init git: %v", err)
}
// Run bd init without any hook-skipping options
rootCmd.SetArgs([]string{"init", "--prefix", "test", "--quiet"})
if err := rootCmd.Execute(); err != nil {
t.Fatalf("Init failed: %v", err)
}
// Verify .beads directory was created
beadsDir := filepath.Join(tmpDir, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
t.Error(".beads directory was not created")
}
// Verify git hooks WERE installed (default behavior)
preCommitHook := filepath.Join(tmpDir, ".git", "hooks", "pre-commit")
if _, err := os.Stat(preCommitHook); os.IsNotExist(err) {
t.Error("pre-commit hook SHOULD be installed by default")
}
postMergeHook := filepath.Join(tmpDir, ".git", "hooks", "post-merge")
if _, err := os.Stat(postMergeHook); os.IsNotExist(err) {
t.Error("post-merge hook SHOULD be installed by default")
}
}

View File

@@ -148,7 +148,12 @@ func runMigrateSync(ctx context.Context, branchName string, dryRun, force bool)
fmt.Printf("→ Would create new branch '%s'\n", branchName)
}
worktreePath := filepath.Join(repoRoot, ".git", "beads-worktrees", branchName)
// Use worktree-aware git directory detection
gitDir, err := git.GetGitDir()
if err != nil {
return fmt.Errorf("not a git repository: %w", err)
}
worktreePath := filepath.Join(gitDir, "beads-worktrees", branchName)
fmt.Printf("→ Would create worktree at: %s\n", worktreePath)
fmt.Println("\n=== END DRY RUN ===")
@@ -183,7 +188,12 @@ func runMigrateSync(ctx context.Context, branchName string, dryRun, force bool)
}
// Step 2: Create the worktree
worktreePath := filepath.Join(repoRoot, ".git", "beads-worktrees", branchName)
// Use worktree-aware git directory detection
gitDir, err := git.GetGitDir()
if err != nil {
return fmt.Errorf("not a git repository: %w", err)
}
worktreePath := filepath.Join(gitDir, "beads-worktrees", branchName)
fmt.Printf("→ Creating worktree at %s...\n", worktreePath)
wtMgr := git.NewWorktreeManager(repoRoot)

View File

@@ -128,7 +128,7 @@ Examples:
existingTombstones[issue.ID] = true
}
}
_ = file.Close()
file.Close()
}
// Determine which deletions need migration

View File

@@ -1,230 +0,0 @@
package main
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/reset"
)
var resetCmd = &cobra.Command{
Use: "reset",
Short: "Reset beads to a clean starting state",
Long: `Reset beads to a clean starting state by clearing .beads/ and reinitializing.
This command is useful when:
- Your beads workspace is in an invalid state after an update
- You want to start fresh with issue tracking
- bd doctor cannot automatically fix problems
RESET MODES:
Soft Reset (default):
- Kills all daemons
- Clears .beads/ directory
- Reinitializes with bd init
- Git history is unchanged
Hard Reset (--hard):
- Same as soft reset, plus:
- Removes .beads/ files from git (git rm)
- Creates a commit removing the old state
- Creates a commit with fresh initialized state
OPTIONS:
--backup Create .beads-backup-{timestamp}/ before clearing
--dry-run Preview what would happen without making changes
--force Skip confirmation prompt
--hard Include git operations (git rm + commit)
--skip-init Don't reinitialize after clearing (leaves .beads/ empty)
--verbose Show detailed progress
EXAMPLES:
bd reset # Reset with confirmation prompt
bd reset --backup # Reset with backup first
bd reset --dry-run # Preview the impact
bd reset --hard # Reset including git history
bd reset --force # Reset without confirmation`,
Run: func(cmd *cobra.Command, _ []string) {
hard, _ := cmd.Flags().GetBool("hard")
force, _ := cmd.Flags().GetBool("force")
backup, _ := cmd.Flags().GetBool("backup")
dryRun, _ := cmd.Flags().GetBool("dry-run")
skipInit, _ := cmd.Flags().GetBool("skip-init")
verbose, _ := cmd.Flags().GetBool("verbose")
// Color helpers
red := color.New(color.FgRed).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc()
green := color.New(color.FgGreen).SprintFunc()
cyan := color.New(color.FgCyan).SprintFunc()
// Validate state
if err := reset.ValidateState(); err != nil {
fmt.Fprintf(os.Stderr, "%s %v\n", red("Error:"), err)
os.Exit(1)
}
// Get impact summary
impact, err := reset.CountImpact()
if err != nil {
fmt.Fprintf(os.Stderr, "%s Failed to analyze workspace: %v\n", red("Error:"), err)
os.Exit(1)
}
// Show impact summary
fmt.Printf("\n%s Reset Impact Summary\n", yellow("⚠"))
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
totalIssues := impact.IssueCount - impact.TombstoneCount
if totalIssues > 0 {
fmt.Printf(" Issues to delete: %s\n", cyan(fmt.Sprintf("%d", totalIssues)))
fmt.Printf(" - Open: %d\n", impact.OpenCount)
fmt.Printf(" - Closed: %d\n", impact.ClosedCount)
} else {
fmt.Printf(" Issues to delete: %s\n", cyan("0"))
}
if impact.TombstoneCount > 0 {
fmt.Printf(" Tombstones to delete: %s\n", cyan(fmt.Sprintf("%d", impact.TombstoneCount)))
}
if impact.HasUncommitted {
fmt.Printf(" %s Uncommitted changes in .beads/ will be lost\n", yellow("⚠"))
}
fmt.Printf("\n")
// Show what will happen
fmt.Printf("Actions:\n")
if backup {
fmt.Printf(" 1. Create backup (.beads-backup-{timestamp}/)\n")
}
fmt.Printf(" %s. Kill all daemons\n", actionNumber(backup, 1))
if hard {
fmt.Printf(" %s. Remove .beads/ from git index and commit\n", actionNumber(backup, 2))
}
fmt.Printf(" %s. Delete .beads/ directory\n", actionNumber(backup, hardOffset(hard, 2)))
if !skipInit {
fmt.Printf(" %s. Reinitialize workspace (bd init)\n", actionNumber(backup, hardOffset(hard, 3)))
if hard {
fmt.Printf(" %s. Commit fresh state to git\n", actionNumber(backup, hardOffset(hard, 4)))
}
}
fmt.Printf("\n")
// Dry run - stop here
if dryRun {
fmt.Printf("%s This was a dry run. No changes were made.\n\n", cyan(""))
return
}
// Confirmation prompt (unless --force)
if !force {
fmt.Printf("%s This will permanently delete all issues. Continue? [y/N]: ", yellow("Warning:"))
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
fmt.Fprintf(os.Stderr, "%s Failed to read response: %v\n", red("Error:"), err)
os.Exit(1)
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Printf("Reset canceled.\n")
return
}
}
// Execute reset
if verbose {
fmt.Printf("\n%s Starting reset...\n", cyan("→"))
}
opts := reset.ResetOptions{
Hard: hard,
Backup: backup,
DryRun: false, // Already handled above
SkipInit: skipInit,
}
result, err := reset.Reset(opts)
if err != nil {
fmt.Fprintf(os.Stderr, "%s Reset failed: %v\n", red("Error:"), err)
os.Exit(1)
}
// Handle --hard mode: commit fresh state after reinit
if hard && !skipInit {
beadsDir := beads.FindBeadsDir()
if beadsDir != "" {
commitMsg := "Initialize fresh beads workspace\n\nCreated new .beads/ directory after reset."
if err := reset.GitAddAndCommit(beadsDir, commitMsg); err != nil {
fmt.Fprintf(os.Stderr, "%s Failed to commit fresh state: %v\n", yellow("Warning:"), err)
fmt.Fprintf(os.Stderr, "You may need to manually run: git add .beads && git commit -m \"Fresh beads state\"\n")
} else if verbose {
fmt.Printf(" %s Committed fresh state to git\n", green("✓"))
}
}
}
// Show results
fmt.Printf("\n%s Reset complete!\n\n", green("✓"))
if result.BackupPath != "" {
fmt.Printf(" Backup created: %s\n", cyan(result.BackupPath))
}
if result.DaemonsKilled > 0 {
fmt.Printf(" Daemons stopped: %d\n", result.DaemonsKilled)
}
if result.IssuesDeleted > 0 || result.TombstonesDeleted > 0 {
fmt.Printf(" Issues deleted: %d\n", result.IssuesDeleted)
if result.TombstonesDeleted > 0 {
fmt.Printf(" Tombstones deleted: %d\n", result.TombstonesDeleted)
}
}
if !skipInit {
fmt.Printf("\n Workspace reinitialized. Run %s to get started.\n", cyan("bd quickstart"))
} else {
fmt.Printf("\n .beads/ directory has been cleared. Run %s to reinitialize.\n", cyan("bd init"))
}
fmt.Printf("\n")
},
}
// actionNumber returns the step number accounting for backup offset
func actionNumber(hasBackup bool, step int) string {
if hasBackup {
return fmt.Sprintf("%d", step+1)
}
return fmt.Sprintf("%d", step)
}
// hardOffset adjusts step number for --hard mode which adds extra steps
func hardOffset(isHard bool, step int) int {
if isHard {
return step + 1
}
return step
}
func init() {
resetCmd.Flags().Bool("hard", false, "Include git operations (git rm + commit)")
resetCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
resetCmd.Flags().Bool("backup", false, "Create backup before reset")
resetCmd.Flags().Bool("dry-run", false, "Preview what would happen without making changes")
resetCmd.Flags().Bool("skip-init", false, "Don't reinitialize after clearing")
resetCmd.Flags().BoolP("verbose", "v", false, "Show detailed progress")
rootCmd.AddCommand(resetCmd)
}

View File

@@ -1,331 +0,0 @@
//go:build integration
// +build integration
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// runBDResetExec runs bd reset via exec.Command for clean state isolation
// Reset has persistent flag state that doesn't work well with in-process testing
func runBDResetExec(t *testing.T, dir string, stdin string, args ...string) (string, error) {
t.Helper()
// Add --no-daemon to all commands
args = append([]string{"--no-daemon"}, args...)
cmd := exec.Command(testBD, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "BEADS_NO_DAEMON=1")
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
out, err := cmd.CombinedOutput()
return string(out), err
}
func TestCLI_ResetDryRun(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
tmpDir := setupCLITestDB(t)
// Create some issues
runBDExec(t, tmpDir, "create", "Issue 1", "-p", "1")
runBDExec(t, tmpDir, "create", "Issue 2", "-p", "2")
// Run dry-run reset
out, err := runBDResetExec(t, tmpDir, "", "reset", "--dry-run")
if err != nil {
t.Fatalf("dry-run reset failed: %v\nOutput: %s", err, out)
}
// Verify output contains impact summary
if !strings.Contains(out, "Reset Impact Summary") {
t.Errorf("Expected 'Reset Impact Summary' in output, got: %s", out)
}
if !strings.Contains(out, "dry run") {
t.Errorf("Expected 'dry run' in output, got: %s", out)
}
// Verify .beads directory still exists (dry run shouldn't delete anything)
beadsDir := filepath.Join(tmpDir, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
t.Error("dry run should not delete .beads directory")
}
// Verify we can still list issues
listOut := runBDExec(t, tmpDir, "list")
if !strings.Contains(listOut, "Issue 1") {
t.Errorf("Issues should still exist after dry run, got: %s", listOut)
}
}
func TestCLI_ResetForce(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
tmpDir := setupCLITestDB(t)
// Create some issues
runBDExec(t, tmpDir, "create", "Issue to delete", "-p", "1")
// Run reset with --force (no confirmation needed)
out, err := runBDResetExec(t, tmpDir, "", "reset", "--force")
if err != nil {
t.Fatalf("reset --force failed: %v\nOutput: %s", err, out)
}
// Verify success message
if !strings.Contains(out, "Reset complete") {
t.Errorf("Expected 'Reset complete' in output, got: %s", out)
}
// Verify .beads directory was recreated (reinit by default)
beadsDir := filepath.Join(tmpDir, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
t.Error(".beads directory should be recreated after reset")
}
// Verify issues are gone (reinit creates empty workspace)
listOut := runBDExec(t, tmpDir, "list")
if strings.Contains(listOut, "Issue to delete") {
t.Errorf("Issues should be deleted after reset, got: %s", listOut)
}
}
func TestCLI_ResetSkipInit(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
tmpDir := setupCLITestDB(t)
// Create an issue
runBDExec(t, tmpDir, "create", "Test issue", "-p", "1")
// Run reset with --skip-init
out, err := runBDResetExec(t, tmpDir, "", "reset", "--force", "--skip-init")
if err != nil {
t.Fatalf("reset --skip-init failed: %v\nOutput: %s", err, out)
}
// Verify .beads directory doesn't exist
beadsDir := filepath.Join(tmpDir, ".beads")
if _, err := os.Stat(beadsDir); !os.IsNotExist(err) {
t.Error(".beads directory should not exist after reset with --skip-init")
}
// Verify output mentions bd init
if !strings.Contains(out, "bd init") {
t.Errorf("Expected hint about 'bd init' in output, got: %s", out)
}
}
func TestCLI_ResetBackup(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
tmpDir := setupCLITestDB(t)
// Create some issues
runBDExec(t, tmpDir, "create", "Backup test issue", "-p", "1")
// Run reset with --backup
out, err := runBDResetExec(t, tmpDir, "", "reset", "--force", "--backup")
if err != nil {
t.Fatalf("reset --backup failed: %v\nOutput: %s", err, out)
}
// Verify backup was mentioned in output
if !strings.Contains(out, "Backup created") || !strings.Contains(out, ".beads-backup-") {
t.Errorf("Expected backup path in output, got: %s", out)
}
// Verify a backup directory exists
entries, err := os.ReadDir(tmpDir)
if err != nil {
t.Fatalf("Failed to read dir: %v", err)
}
foundBackup := false
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".beads-backup-") && entry.IsDir() {
foundBackup = true
// Verify backup has content
backupPath := filepath.Join(tmpDir, entry.Name())
backupEntries, err := os.ReadDir(backupPath)
if err != nil {
t.Fatalf("Failed to read backup dir: %v", err)
}
if len(backupEntries) == 0 {
t.Error("Backup directory should not be empty")
}
break
}
}
if !foundBackup {
t.Error("No backup directory found")
}
}
func TestCLI_ResetWithConfirmation(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
tmpDir := setupCLITestDB(t)
// Create an issue
runBDExec(t, tmpDir, "create", "Confirm test", "-p", "1")
// Run reset with confirmation (type 'y')
out, err := runBDResetExec(t, tmpDir, "y\n", "reset")
if err != nil {
t.Fatalf("reset with confirmation failed: %v\nOutput: %s", err, out)
}
// Verify success
if !strings.Contains(out, "Reset complete") {
t.Errorf("Expected 'Reset complete' in output, got: %s", out)
}
}
func TestCLI_ResetCancelled(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
tmpDir := setupCLITestDB(t)
// Create an issue
runBDExec(t, tmpDir, "create", "Keep this", "-p", "1")
// Run reset but cancel (type 'n')
out, err := runBDResetExec(t, tmpDir, "n\n", "reset")
// Cancellation is not an error
if err != nil {
t.Fatalf("reset cancellation failed: %v\nOutput: %s", err, out)
}
// Verify cancelled message
if !strings.Contains(out, "cancelled") {
t.Errorf("Expected 'cancelled' in output, got: %s", out)
}
// Verify issue still exists
listOut := runBDExec(t, tmpDir, "list")
if !strings.Contains(listOut, "Keep this") {
t.Errorf("Issues should still exist after cancelled reset, got: %s", listOut)
}
}
func TestCLI_ResetNoBeadsDir(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
// Create temp dir without .beads
tmpDir := createTempDirWithCleanup(t)
// Run reset - should fail
out, err := runBDResetExec(t, tmpDir, "", "reset", "--force")
if err == nil {
t.Error("reset should fail when no .beads directory exists")
}
// Verify error message
if !strings.Contains(out, "no .beads directory found") && !strings.Contains(out, "Error") {
t.Errorf("Expected error about missing .beads directory, got: %s", out)
}
}
func TestCLI_ResetWithIssues(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
tmpDir := setupCLITestDB(t)
// Create multiple issues with different states
runBDExec(t, tmpDir, "create", "Open issue 1", "-p", "1")
runBDExec(t, tmpDir, "create", "Open issue 2", "-p", "2")
out1 := runBDExec(t, tmpDir, "create", "To close", "-p", "1", "--json")
id := extractIDFromJSON(t, out1)
runBDExec(t, tmpDir, "close", id)
// Run dry-run to see counts
out, err := runBDResetExec(t, tmpDir, "", "reset", "--dry-run")
if err != nil {
t.Fatalf("dry-run failed: %v\nOutput: %s", err, out)
}
// Verify impact shows correct counts
if !strings.Contains(out, "Issues to delete") {
t.Errorf("Expected 'Issues to delete' in output, got: %s", out)
}
if !strings.Contains(out, "Open:") {
t.Errorf("Expected 'Open:' count in output, got: %s", out)
}
if !strings.Contains(out, "Closed:") {
t.Errorf("Expected 'Closed:' count in output, got: %s", out)
}
// Now do actual reset
out, err = runBDResetExec(t, tmpDir, "", "reset", "--force")
if err != nil {
t.Fatalf("reset failed: %v\nOutput: %s", err, out)
}
// Verify all issues are gone
listOut := runBDExec(t, tmpDir, "list")
if strings.Contains(listOut, "Open issue") || strings.Contains(listOut, "To close") {
t.Errorf("All issues should be deleted after reset, got: %s", listOut)
}
}
func TestCLI_ResetVerbose(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow CLI test in short mode")
}
tmpDir := setupCLITestDB(t)
// Create an issue
runBDExec(t, tmpDir, "create", "Verbose test", "-p", "1")
// Run reset with --verbose
out, err := runBDResetExec(t, tmpDir, "", "reset", "--force", "--verbose")
if err != nil {
t.Fatalf("reset --verbose failed: %v\nOutput: %s", err, out)
}
// Verify verbose output shows more details
if !strings.Contains(out, "Starting reset") {
t.Errorf("Expected 'Starting reset' in verbose output, got: %s", out)
}
}
// extractIDFromJSON extracts an ID from JSON output
func extractIDFromJSON(t *testing.T, out string) string {
t.Helper()
// Try both formats: "id":"xxx" and "id": "xxx"
idx := strings.Index(out, `"id":"`)
offset := 6
if idx == -1 {
idx = strings.Index(out, `"id": "`)
offset = 7
}
if idx == -1 {
t.Fatalf("No id found in JSON output: %s", out)
}
start := idx + offset
end := strings.Index(out[start:], `"`)
if end == -1 {
t.Fatalf("Malformed JSON output: %s", out)
}
return out[start : start+end]
}

View File

@@ -65,11 +65,6 @@ func InstallClaude(project bool, stealth bool) {
fmt.Println("✓ Registered PreCompact hook")
}
// Add bd to allowedTools so commands don't require per-command approval
if addAllowedTool(settings, "Bash(bd *)") {
fmt.Println("✓ Added bd to allowedTools (no per-command approval needed)")
}
// Write back to file
data, err = json.MarshalIndent(settings, "", " ")
if err != nil {
@@ -154,9 +149,6 @@ func RemoveClaude(project bool) {
removeHookCommand(hooks, "SessionStart", "bd prime --stealth")
removeHookCommand(hooks, "PreCompact", "bd prime --stealth")
// Remove bd from allowedTools
removeAllowedTool(settings, "Bash(bd *)")
// Write back
data, err = json.MarshalIndent(settings, "", " ")
if err != nil {
@@ -172,49 +164,6 @@ func RemoveClaude(project bool) {
fmt.Println("✓ Claude hooks removed")
}
// addAllowedTool adds a tool pattern to allowedTools if not already present
// Returns true if tool was added, false if already exists
func addAllowedTool(settings map[string]interface{}, tool string) bool {
// Get or create allowedTools array
allowedTools, ok := settings["allowedTools"].([]interface{})
if !ok {
allowedTools = []interface{}{}
}
// Check if tool already in list
for _, t := range allowedTools {
if t == tool {
fmt.Printf("✓ Tool already in allowedTools: %s\n", tool)
return false
}
}
// Add tool to array
allowedTools = append(allowedTools, tool)
settings["allowedTools"] = allowedTools
return true
}
// removeAllowedTool removes a tool pattern from allowedTools
func removeAllowedTool(settings map[string]interface{}, tool string) {
allowedTools, ok := settings["allowedTools"].([]interface{})
if !ok {
return
}
// Filter out the tool
var filtered []interface{}
for _, t := range allowedTools {
if t != tool {
filtered = append(filtered, t)
} else {
fmt.Printf("✓ Removed %s from allowedTools\n", tool)
}
}
settings["allowedTools"] = filtered
}
// addHookCommand adds a hook command to an event if not already present
// Returns true if hook was added, false if already exists
func addHookCommand(hooks map[string]interface{}, event, command string) bool {

View File

@@ -406,135 +406,3 @@ func TestIdempotencyWithStealth(t *testing.T) {
t.Errorf("Expected 'bd prime --stealth', got %v", cmdMap["command"])
}
}
func TestAddAllowedTool(t *testing.T) {
tests := []struct {
name string
existingSettings map[string]interface{}
tool string
wantAdded bool
wantLen int
}{
{
name: "add tool to empty settings",
existingSettings: make(map[string]interface{}),
tool: "Bash(bd *)",
wantAdded: true,
wantLen: 1,
},
{
name: "add tool to existing allowedTools",
existingSettings: map[string]interface{}{
"allowedTools": []interface{}{"Bash(git *)"},
},
tool: "Bash(bd *)",
wantAdded: true,
wantLen: 2,
},
{
name: "tool already exists",
existingSettings: map[string]interface{}{
"allowedTools": []interface{}{"Bash(bd *)"},
},
tool: "Bash(bd *)",
wantAdded: false,
wantLen: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := addAllowedTool(tt.existingSettings, tt.tool)
if got != tt.wantAdded {
t.Errorf("addAllowedTool() = %v, want %v", got, tt.wantAdded)
}
allowedTools, ok := tt.existingSettings["allowedTools"].([]interface{})
if !ok {
t.Fatal("allowedTools not found")
}
if len(allowedTools) != tt.wantLen {
t.Errorf("Expected %d tools, got %d", tt.wantLen, len(allowedTools))
}
// Verify tool exists in list
found := false
for _, tool := range allowedTools {
if tool == tt.tool {
found = true
break
}
}
if !found {
t.Errorf("Tool %q not found in allowedTools", tt.tool)
}
})
}
}
func TestRemoveAllowedTool(t *testing.T) {
tests := []struct {
name string
existingSettings map[string]interface{}
tool string
wantLen int
}{
{
name: "remove only tool",
existingSettings: map[string]interface{}{
"allowedTools": []interface{}{"Bash(bd *)"},
},
tool: "Bash(bd *)",
wantLen: 0,
},
{
name: "remove one of multiple tools",
existingSettings: map[string]interface{}{
"allowedTools": []interface{}{"Bash(git *)", "Bash(bd *)", "Bash(npm *)"},
},
tool: "Bash(bd *)",
wantLen: 2,
},
{
name: "remove non-existent tool",
existingSettings: map[string]interface{}{
"allowedTools": []interface{}{"Bash(git *)"},
},
tool: "Bash(bd *)",
wantLen: 1,
},
{
name: "remove from empty settings",
existingSettings: make(map[string]interface{}),
tool: "Bash(bd *)",
wantLen: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
removeAllowedTool(tt.existingSettings, tt.tool)
allowedTools, ok := tt.existingSettings["allowedTools"].([]interface{})
if !ok {
// If allowedTools doesn't exist, treat as empty
if tt.wantLen != 0 {
t.Errorf("Expected %d tools, got 0 (allowedTools not found)", tt.wantLen)
}
return
}
if len(allowedTools) != tt.wantLen {
t.Errorf("Expected %d remaining tools, got %d", tt.wantLen, len(allowedTools))
}
// Verify tool is actually gone
for _, tool := range allowedTools {
if tool == tt.tool {
t.Errorf("Tool %q still present after removal", tt.tool)
}
}
})
}
}

View File

@@ -19,6 +19,7 @@ import (
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/debug"
"github.com/steveyegge/beads/internal/deletions"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/syncbranch"
"github.com/steveyegge/beads/internal/types"
@@ -733,20 +734,6 @@ Use --merge to merge the sync branch back to main branch.`,
if err := restoreBeadsDirFromBranch(ctx); err != nil {
// Non-fatal - just means git status will show modified files
debug.Logf("sync: failed to restore .beads/ from branch: %v", err)
} else {
// Update jsonl_content_hash to match the restored file
// This prevents daemon/CLI from seeing a hash mismatch and re-importing
// which would trigger re-export and dirty the working directory (bd-lw0x race fix)
// Uses repoKey for multi-repo support (bd-ar2.10, bd-ar2.11)
hashKey := "jsonl_content_hash"
if rk := getRepoKeyForPath(jsonlPath); rk != "" {
hashKey += ":" + rk
}
if restoredHash, err := computeJSONLHash(jsonlPath); err == nil {
if err := store.SetMetadata(ctx, hashKey, restoredHash); err != nil {
debug.Logf("sync: failed to update hash after restore: %v", err)
}
}
}
// Skip final flush in PersistentPostRun - we've already exported to sync branch
// and restored the working directory to match the current branch
@@ -838,24 +825,68 @@ func gitHasChanges(ctx context.Context, filePath string) (bool, error) {
return len(strings.TrimSpace(string(output))) > 0, nil
}
// getRepoRootForWorktree returns the main repository root for running git commands
// This is always the main repository root, never the worktree root
func getRepoRootForWorktree(ctx context.Context) string {
repoRoot, err := git.GetMainRepoRoot()
if err != nil {
// Fallback to current directory if GetMainRepoRoot fails
return "."
}
return repoRoot
}
// gitHasBeadsChanges checks if any tracked files in .beads/ have uncommitted changes
func gitHasBeadsChanges(ctx context.Context) (bool, error) {
// Get the absolute path to .beads directory
beadsDir := findBeadsDir()
if beadsDir == "" {
return false, fmt.Errorf("no .beads directory found")
}
cmd := exec.CommandContext(ctx, "git", "status", "--porcelain", beadsDir)
output, err := cmd.Output()
// Get the repository root (handles worktrees properly)
repoRoot := getRepoRootForWorktree(ctx)
if repoRoot == "" {
return false, fmt.Errorf("cannot determine repository root")
}
// Compute relative path from repo root to .beads
relPath, err := filepath.Rel(repoRoot, beadsDir)
if err != nil {
// Fall back to absolute path if relative path fails
statusCmd := exec.CommandContext(ctx, "git", "status", "--porcelain", beadsDir)
statusOutput, err := statusCmd.Output()
if err != nil {
return false, fmt.Errorf("git status failed: %w", err)
}
return len(strings.TrimSpace(string(statusOutput))) > 0, nil
}
// Run git status with relative path from repo root
statusCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "status", "--porcelain", relPath)
statusOutput, err := statusCmd.Output()
if err != nil {
return false, fmt.Errorf("git status failed: %w", err)
}
return len(strings.TrimSpace(string(output))) > 0, nil
return len(strings.TrimSpace(string(statusOutput))) > 0, nil
}
// gitCommit commits the specified file
// gitCommit commits the specified file (worktree-aware)
func gitCommit(ctx context.Context, filePath string, message string) error {
// Stage the file
addCmd := exec.CommandContext(ctx, "git", "add", filePath)
// Get the repository root (handles worktrees properly)
repoRoot := getRepoRootForWorktree(ctx)
if repoRoot == "" {
return fmt.Errorf("cannot determine repository root")
}
// Make file path relative to repo root for git operations
relPath, err := filepath.Rel(repoRoot, filePath)
if err != nil {
relPath = filePath // Fall back to absolute path
}
// Stage the file from repo root context
addCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "add", relPath)
if err := addCmd.Run(); err != nil {
return fmt.Errorf("git add failed: %w", err)
}
@@ -865,8 +896,8 @@ func gitCommit(ctx context.Context, filePath string, message string) error {
message = fmt.Sprintf("bd sync: %s", time.Now().Format("2006-01-02 15:04:05"))
}
// Commit
commitCmd := exec.CommandContext(ctx, "git", "commit", "-m", message)
// Commit from repo root context
commitCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "commit", "-m", message)
output, err := commitCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git commit failed: %w\n%s", err, output)
@@ -879,12 +910,19 @@ func gitCommit(ctx context.Context, filePath string, message string) error {
// This ensures bd sync doesn't accidentally commit other staged files.
// Only stages specific sync files (issues.jsonl, deletions.jsonl, metadata.json)
// to avoid staging gitignored snapshot files that may be tracked. (bd-guc fix)
// Worktree-aware: handles cases where .beads is in the main repo but we're running from a worktree.
func gitCommitBeadsDir(ctx context.Context, message string) error {
beadsDir := findBeadsDir()
if beadsDir == "" {
return fmt.Errorf("no .beads directory found")
}
// Get the repository root (handles worktrees properly)
repoRoot := getRepoRootForWorktree(ctx)
if repoRoot == "" {
return fmt.Errorf("cannot determine repository root")
}
// Stage only the specific sync-related files (bd-guc)
// This avoids staging gitignored snapshot files (beads.*.jsonl, *.meta.json)
// that may still be tracked from before they were added to .gitignore
@@ -898,7 +936,12 @@ func gitCommitBeadsDir(ctx context.Context, message string) error {
var filesToAdd []string
for _, f := range syncFiles {
if _, err := os.Stat(f); err == nil {
filesToAdd = append(filesToAdd, f)
// Convert to relative path from repo root for git operations
relPath, err := filepath.Rel(repoRoot, f)
if err != nil {
relPath = f // Fall back to absolute path if relative fails
}
filesToAdd = append(filesToAdd, relPath)
}
}
@@ -906,8 +949,8 @@ func gitCommitBeadsDir(ctx context.Context, message string) error {
return fmt.Errorf("no sync files found to commit")
}
// Stage only the sync files
args := append([]string{"add"}, filesToAdd...)
// Stage only the sync files from repo root context (worktree-aware)
args := append([]string{"-C", repoRoot, "add"}, filesToAdd...)
addCmd := exec.CommandContext(ctx, "git", args...)
if err := addCmd.Run(); err != nil {
return fmt.Errorf("git add failed: %w", err)
@@ -921,7 +964,13 @@ func gitCommitBeadsDir(ctx context.Context, message string) error {
// Commit only .beads/ files using -- pathspec (bd-red)
// This prevents accidentally committing other staged files that the user
// may have staged but wasn't ready to commit yet.
commitCmd := exec.CommandContext(ctx, "git", "commit", "-m", message, "--", beadsDir)
// Convert beadsDir to relative path for git commit (worktree-aware)
relBeadsDir, err := filepath.Rel(repoRoot, beadsDir)
if err != nil {
relBeadsDir = beadsDir // Fall back to absolute path if relative fails
}
commitCmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "commit", "-m", message, "--", relBeadsDir)
output, err := commitCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git commit failed: %w\n%s", err, output)
@@ -942,12 +991,20 @@ func hasGitRemote(ctx context.Context) bool {
// isInRebase checks if we're currently in a git rebase state
func isInRebase() bool {
// Get actual git directory (handles worktrees)
gitDir, err := git.GetGitDir()
if err != nil {
return false
}
// Check for rebase-merge directory (interactive rebase)
if _, err := os.Stat(".git/rebase-merge"); err == nil {
rebaseMergePath := filepath.Join(gitDir, "rebase-merge")
if _, err := os.Stat(rebaseMergePath); err == nil {
return true
}
// Check for rebase-apply directory (non-interactive rebase)
if _, err := os.Stat(".git/rebase-apply"); err == nil {
rebaseApplyPath := filepath.Join(gitDir, "rebase-apply")
if _, err := os.Stat(rebaseApplyPath); err == nil {
return true
}
return false

View File

@@ -53,9 +53,19 @@ if ! bd sync --flush-only >/dev/null 2>&1; then
fi
# Stage all tracked JSONL files (beads.jsonl, issues.jsonl for backward compat, deletions.jsonl for deletion propagation)
# git add is harmless if file doesn't exist
for f in .beads/beads.jsonl .beads/issues.jsonl .beads/deletions.jsonl; do
[ -f "$f" ] && git add "$f" 2>/dev/null || true
done
# For worktrees, .beads is in the main repo's working tree, not the worktree,
# so we can't use git add. Skip staging for worktrees.
if [ "$(git rev-parse --git-dir)" = "$(git rev-parse --git-common-dir)" ]; then
# Regular repo: files are in the working tree, safe to add
# git add is harmless if file doesn't exist
for f in .beads/beads.jsonl .beads/issues.jsonl .beads/deletions.jsonl; do
[ -f "$f" ] && git add "$f" 2>/dev/null || true
done
else
# Worktree: .beads is in the main repo's working tree, not this worktree
# Git rejects adding files outside the worktree, so we skip it.
# The main repo will see the changes on the next pull/sync.
: # do nothing
fi
exit 0

View File

@@ -8,6 +8,7 @@ import (
"strings"
"testing"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/storage/sqlite"
)
@@ -37,34 +38,37 @@ func failIfProductionDatabase(t *testing.T, dbPath string) {
return
}
// Check if database is in a directory that contains .git
dir := filepath.Dir(absPath)
for {
gitPath := filepath.Join(dir, ".git")
if _, err := os.Stat(gitPath); err == nil {
// Found .git directory - check if this is a test or production database
beadsPath := filepath.Join(dir, ".beads")
if strings.HasPrefix(absPath, beadsPath) {
// Database is in .beads/ directory of a git repository
// This is ONLY allowed if we're in a temp directory
if !strings.Contains(absPath, os.TempDir()) {
t.Fatalf("PRODUCTION DATABASE POLLUTION DETECTED (bd-2c5a):\n"+
" Database: %s\n"+
" Git repo: %s\n"+
" Tests MUST use t.TempDir() or tempfile to create isolated databases.\n"+
" This prevents test issues from polluting the production database.",
absPath, dir)
}
}
break
// Use worktree-aware git directory detection
gitDir, err := git.GetGitDir()
if err != nil {
// Not a git repository, no pollution risk
return
}
// Check if database is in .beads/ directory of this git repository
beadsPath := ""
gitDirAbs, err := filepath.Abs(gitDir)
if err != nil {
t.Logf("Warning: Could not get absolute path for git dir %s: %v", gitDir, err)
return
}
// The .beads directory should be at the root of the git repository
// For worktrees, gitDir points to the main repo's .git directory
repoRoot := filepath.Dir(gitDirAbs)
beadsPath = filepath.Join(repoRoot, ".beads")
if strings.HasPrefix(absPath, beadsPath) {
// Database is in .beads/ directory of a git repository
// This is ONLY allowed if we're in a temp directory
if !strings.Contains(absPath, os.TempDir()) {
t.Fatalf("PRODUCTION DATABASE POLLUTION DETECTED (bd-2c5a):\n"+
" Database: %s\n"+
" Git repo: %s\n"+
" Tests MUST use t.TempDir() or tempfile to create isolated databases.\n"+
" This prevents test issues from polluting the production database.",
absPath, repoRoot)
}
parent := filepath.Dir(dir)
if parent == dir {
// Reached filesystem root
break
}
dir = parent
}
}

View File

@@ -256,7 +256,7 @@ func isClaudeSetupComplete() bool {
// Check if beads plugin is installed - plugin now provides hooks automatically
settingsPath := filepath.Join(home, ".claude", "settings.json")
// #nosec G304 -- settingsPath is constructed from user home dir, not user input
// #nosec G304 - path is constructed from user home directory
if data, err := os.ReadFile(settingsPath); err == nil {
var settings map[string]interface{}
if err := json.Unmarshal(data, &settings); err == nil {
@@ -279,7 +279,8 @@ func isClaudeSetupComplete() bool {
}
// Project-level hooks in .claude/settings.local.json
if hasBeadsPrimeHooks(".claude/settings.local.json") {
localSettingsPath := filepath.Join(home, ".claude", "settings.local.json")
if hasBeadsPrimeHooks(localSettingsPath) {
return true
}
@@ -288,7 +289,8 @@ func isClaudeSetupComplete() bool {
// hasBeadsPrimeHooks checks if a settings file has bd prime hooks configured
func hasBeadsPrimeHooks(settingsPath string) bool {
data, err := os.ReadFile(settingsPath) // #nosec G304 -- path is either from home dir or relative project path
// #nosec G304 - path is constructed from user home directory
data, err := os.ReadFile(settingsPath)
if err != nil {
return false
}

View File

@@ -8,31 +8,17 @@ import (
"strings"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/git"
)
// isGitWorktree detects if the current directory is in a git worktree
// by comparing --git-dir and --git-common-dir (canonical detection method)
// isGitWorktree detects if the current directory is in a git worktree.
// This is a wrapper around git.IsWorktree() for CLI-layer compatibility.
func isGitWorktree() bool {
gitDir := gitRevParse("--git-dir")
if gitDir == "" {
return false
}
commonDir := gitRevParse("--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
return git.IsWorktree()
}
// gitRevParse runs git rev-parse with the given flag and returns the trimmed output
// gitRevParse runs git rev-parse with the given flag and returns the trimmed output.
// This is a helper for CLI utilities that need git command execution.
func gitRevParse(flag string) string {
out, err := exec.Command("git", "rev-parse", flag).Output()
if err != nil {
@@ -44,12 +30,11 @@ func gitRevParse(flag string) string {
// getWorktreeGitDir returns the .git directory path for a worktree
// Returns empty string if not in a git repo or not a worktree
func getWorktreeGitDir() string {
cmd := exec.Command("git", "rev-parse", "--git-dir")
out, err := cmd.Output()
gitDir, err := git.GetGitDir()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
return gitDir
}
// warnWorktreeDaemon prints a warning if using daemon with worktrees