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:
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
108
cmd/bd/doctor.go
108
cmd/bd/doctor.go
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)",
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
270
cmd/bd/init.go
270
cmd/bd/init.go
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -128,7 +128,7 @@ Examples:
|
||||
existingTombstones[issue.ID] = true
|
||||
}
|
||||
}
|
||||
_ = file.Close()
|
||||
file.Close()
|
||||
}
|
||||
|
||||
// Determine which deletions need migration
|
||||
|
||||
230
cmd/bd/reset.go
230
cmd/bd/reset.go
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
113
cmd/bd/sync.go
113
cmd/bd/sync.go
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user