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
332 lines
10 KiB
Go
332 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"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"
|
|
"github.com/steveyegge/beads/internal/utils"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// checkAndAutoImport checks if the database is empty but git has issues.
|
|
// If so, it automatically imports them and returns true.
|
|
// Returns false if no import was needed or if import failed.
|
|
func checkAndAutoImport(ctx context.Context, store storage.Storage) bool {
|
|
// Don't auto-import if auto-import is explicitly disabled
|
|
if noAutoImport {
|
|
return false
|
|
}
|
|
|
|
// Check if database has any issues
|
|
stats, err := store.GetStatistics(ctx)
|
|
if err != nil || stats.TotalIssues > 0 {
|
|
// Either error checking or DB has issues - don't auto-import
|
|
return false
|
|
}
|
|
|
|
// Database is empty - check if git has issues
|
|
issueCount, jsonlPath, gitRef := checkGitForIssues()
|
|
if issueCount == 0 {
|
|
// No issues in git either
|
|
return false
|
|
}
|
|
|
|
// Found issues in git! Auto-import them
|
|
if !jsonOutput {
|
|
fmt.Fprintf(os.Stderr, "Found 0 issues in database but %d in git. Importing...\n", issueCount)
|
|
}
|
|
|
|
// Import from git
|
|
if err := importFromGit(ctx, dbPath, store, jsonlPath, gitRef); err != nil {
|
|
if !jsonOutput {
|
|
fmt.Fprintf(os.Stderr, "Warning: auto-import failed: %v\n", err)
|
|
fmt.Fprintf(os.Stderr, "Try manually: git show %s:%s | bd import -i /dev/stdin\n", gitRef, jsonlPath)
|
|
}
|
|
return false
|
|
}
|
|
|
|
if !jsonOutput {
|
|
fmt.Fprintf(os.Stderr, "Successfully imported %d issues from git.\n\n", issueCount)
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// checkGitForIssues checks if git has issues in .beads/beads.jsonl or issues.jsonl
|
|
// When sync-branch is configured, reads from that branch; otherwise reads from HEAD.
|
|
// Returns (issue_count, relative_jsonl_path, git_ref)
|
|
func checkGitForIssues() (int, string, string) {
|
|
// Try to find .beads directory
|
|
beadsDir := findBeadsDir()
|
|
if beadsDir == "" {
|
|
return 0, "", ""
|
|
}
|
|
|
|
// Construct relative path from git root
|
|
gitRoot := findGitRoot()
|
|
if gitRoot == "" {
|
|
return 0, "", ""
|
|
}
|
|
|
|
// Resolve symlinks to ensure consistent paths for filepath.Rel()
|
|
// This is necessary because on macOS, /var is a symlink to /private/var,
|
|
// and git rev-parse returns the resolved path while os.Getwd() may not.
|
|
resolvedBeadsDir, err := filepath.EvalSymlinks(beadsDir)
|
|
if err != nil {
|
|
return 0, "", ""
|
|
}
|
|
beadsDir = resolvedBeadsDir
|
|
resolvedGitRoot, err := filepath.EvalSymlinks(gitRoot)
|
|
if err != nil {
|
|
return 0, "", ""
|
|
}
|
|
gitRoot = resolvedGitRoot
|
|
|
|
// Clean paths to ensure consistent separators
|
|
beadsDir = filepath.Clean(beadsDir)
|
|
gitRoot = filepath.Clean(gitRoot)
|
|
|
|
relBeads, err := filepath.Rel(gitRoot, beadsDir)
|
|
if err != nil {
|
|
return 0, "", ""
|
|
}
|
|
|
|
// Determine which branch to read from (bd-0is fix)
|
|
// If sync-branch is configured in local config.yaml, use it; otherwise fall back to HEAD
|
|
// We read sync-branch directly from local config file rather than using cached global config
|
|
// to handle cases where CWD has changed since config initialization (e.g., in tests)
|
|
gitRef := "HEAD"
|
|
syncBranch := getLocalSyncBranch(beadsDir)
|
|
if syncBranch != "" {
|
|
// Check if the sync branch exists (locally or on remote)
|
|
// Try origin/<branch> first (more likely to exist in fresh clones),
|
|
// then local <branch>
|
|
for _, ref := range []string{"origin/" + syncBranch, syncBranch} {
|
|
cmd := exec.Command("git", "rev-parse", "--verify", "--quiet", ref) // #nosec G204
|
|
if err := cmd.Run(); err == nil {
|
|
gitRef = ref
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try canonical JSONL filenames in precedence order (issues.jsonl is canonical)
|
|
candidates := []string{
|
|
filepath.Join(relBeads, "issues.jsonl"),
|
|
filepath.Join(relBeads, "beads.jsonl"),
|
|
}
|
|
|
|
for _, relPath := range candidates {
|
|
// Use ToSlash for git path compatibility on Windows
|
|
gitPath := filepath.ToSlash(relPath)
|
|
cmd := exec.Command("git", "show", fmt.Sprintf("%s:%s", gitRef, gitPath)) // #nosec G204 - git command with safe args
|
|
output, err := cmd.Output()
|
|
if err == nil && len(output) > 0 {
|
|
lines := bytes.Count(output, []byte("\n"))
|
|
if lines > 0 {
|
|
return lines, relPath, gitRef
|
|
}
|
|
}
|
|
}
|
|
|
|
return 0, "", ""
|
|
}
|
|
|
|
// localConfig represents the subset of config.yaml we need for auto-import and no-db detection.
|
|
// Using proper YAML parsing handles edge cases like comments, indentation, and special characters.
|
|
type localConfig struct {
|
|
SyncBranch string `yaml:"sync-branch"`
|
|
NoDb bool `yaml:"no-db"`
|
|
}
|
|
|
|
// isNoDbModeConfigured checks if no-db: true is set in config.yaml.
|
|
// Uses proper YAML parsing to avoid false matches in comments or nested keys.
|
|
func isNoDbModeConfigured(beadsDir string) bool {
|
|
configPath := filepath.Join(beadsDir, "config.yaml")
|
|
data, err := os.ReadFile(configPath) // #nosec G304 - config file path from beadsDir
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
var cfg localConfig
|
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
return false
|
|
}
|
|
|
|
return cfg.NoDb
|
|
}
|
|
|
|
// getLocalSyncBranch reads sync-branch from the local config.yaml file.
|
|
// This reads directly from the file rather than using cached config to handle
|
|
// cases where CWD has changed since config initialization.
|
|
func getLocalSyncBranch(beadsDir string) string {
|
|
// First check environment variable (highest priority)
|
|
if envBranch := os.Getenv(syncbranch.EnvVar); envBranch != "" {
|
|
return envBranch
|
|
}
|
|
|
|
// Read config.yaml directly from the .beads directory
|
|
configPath := filepath.Join(beadsDir, "config.yaml")
|
|
data, err := os.ReadFile(configPath) // #nosec G304 - config file path from findBeadsDir
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
// Parse YAML properly to handle edge cases (comments, indentation, special chars)
|
|
var cfg localConfig
|
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
return ""
|
|
}
|
|
|
|
return cfg.SyncBranch
|
|
}
|
|
|
|
// findBeadsDir finds the .beads directory in current or parent directories
|
|
func findBeadsDir() string {
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
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() {
|
|
// Resolve symlinks to get canonical path (fixes macOS /var -> /private/var)
|
|
resolved, err := filepath.EvalSymlinks(beadsDir)
|
|
if err != nil {
|
|
return beadsDir // Fall back to unresolved if EvalSymlinks fails
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
// Reached root
|
|
break
|
|
}
|
|
dir = parent
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// findGitRoot finds the git repository root
|
|
func findGitRoot() string {
|
|
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
root := string(bytes.TrimSpace(output))
|
|
|
|
// Normalize path for the current OS
|
|
// Git on Windows may return paths with forward slashes (C:/Users/...)
|
|
// or Unix-style paths (/c/Users/...), convert to native format
|
|
if runtime.GOOS == "windows" {
|
|
if len(root) > 0 && root[0] == '/' && len(root) >= 3 && root[2] == '/' {
|
|
// Convert /c/Users/... to C:\Users\...
|
|
root = strings.ToUpper(string(root[1])) + ":" + filepath.FromSlash(root[2:])
|
|
} else {
|
|
// Convert C:/Users/... to C:\Users\...
|
|
root = filepath.FromSlash(root)
|
|
}
|
|
}
|
|
|
|
// Resolve symlinks to get canonical path (fixes macOS /var -> /private/var)
|
|
resolved, err := filepath.EvalSymlinks(root)
|
|
if err != nil {
|
|
return root // Fall back to unresolved if EvalSymlinks fails
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
// importFromGit imports issues from git at the specified ref (bd-0is: supports sync-branch)
|
|
func importFromGit(ctx context.Context, dbFilePath string, store storage.Storage, jsonlPath, gitRef string) error {
|
|
// Get content from git (use ToSlash for Windows compatibility)
|
|
gitPath := filepath.ToSlash(jsonlPath)
|
|
cmd := exec.Command("git", "show", fmt.Sprintf("%s:%s", gitRef, gitPath)) // #nosec G204 - git command with safe args
|
|
jsonlData, err := cmd.Output()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read from git: %w", err)
|
|
}
|
|
|
|
// Parse JSONL data
|
|
scanner := bufio.NewScanner(bytes.NewReader(jsonlData))
|
|
// Increase buffer size to handle large JSONL lines (e.g., big descriptions)
|
|
scanner.Buffer(make([]byte, 0, 1024*1024), 64*1024*1024) // allow up to 64MB per line
|
|
var issues []*types.Issue
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var issue types.Issue
|
|
if err := json.Unmarshal([]byte(line), &issue); err != nil {
|
|
return fmt.Errorf("failed to parse issue: %w", err)
|
|
}
|
|
issues = append(issues, &issue)
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return fmt.Errorf("failed to scan JSONL: %w", err)
|
|
}
|
|
|
|
// CRITICAL (bd-166): Set issue_prefix from first imported issue if missing
|
|
// This prevents derivePrefixFromPath fallback which caused duplicate issues
|
|
if len(issues) > 0 {
|
|
configuredPrefix, err := store.GetConfig(ctx, "issue_prefix")
|
|
if err == nil && strings.TrimSpace(configuredPrefix) == "" {
|
|
// Database has no prefix configured - derive from first issue
|
|
firstPrefix := utils.ExtractIssuePrefix(issues[0].ID)
|
|
if firstPrefix != "" {
|
|
if err := store.SetConfig(ctx, "issue_prefix", firstPrefix); err != nil {
|
|
return fmt.Errorf("failed to set issue_prefix from imported issues: %w", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Use existing import logic with auto-resolve collisions
|
|
// Note: SkipPrefixValidation allows mixed prefixes during auto-import
|
|
// (but now we set the prefix first, so CreateIssue won't use filename fallback)
|
|
opts := ImportOptions{
|
|
DryRun: false,
|
|
SkipUpdate: false,
|
|
SkipPrefixValidation: true, // Auto-import is lenient about prefixes
|
|
NoGitHistory: true, // Skip git history backfill during auto-import (bd-4pv)
|
|
}
|
|
|
|
_, err = importIssuesCore(ctx, dbFilePath, store, issues, opts)
|
|
return err
|
|
}
|