feat(context): centralize RepoContext API for git operations (#1102)

Centralizes repository context resolution via RepoContext API, fixing bugs where git commands run in the wrong repo when BEADS_DIR points elsewhere or in worktree scenarios.
This commit is contained in:
Peter Chanthamynavong
2026-01-15 07:55:08 -08:00
committed by GitHub
parent 159114563b
commit 0a48519561
33 changed files with 3211 additions and 327 deletions

View File

@@ -86,20 +86,10 @@ func findJSONLPath() string {
if err := os.MkdirAll(dbDir, 0750); err != nil {
// If we can't create the directory, return discovered path anyway
// (the subsequent write will fail with a clearer error)
return canonicalizeIfRelative(jsonlPath)
return utils.CanonicalizeIfRelative(jsonlPath)
}
return canonicalizeIfRelative(jsonlPath)
}
// canonicalizeIfRelative ensures path is absolute for filepath.Rel() compatibility.
// Guards against any code path that might set dbPath to relative.
// See GH#959 for root cause analysis.
func canonicalizeIfRelative(path string) string {
if path != "" && !filepath.IsAbs(path) {
return utils.CanonicalizePath(path)
}
return path
return utils.CanonicalizeIfRelative(jsonlPath)
}
// detectPrefixFromJSONL extracts the issue prefix from JSONL data.

View File

@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
@@ -10,6 +11,7 @@ import (
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
@@ -248,8 +250,14 @@ func findPendingGates() ([]*types.Issue, error) {
}
// getGitBranchForGateDiscovery returns the current git branch name
// Uses CWD repo context since this is for user's project CI discovery
func getGitBranchForGateDiscovery() string {
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
rc, err := beads.GetRepoContext()
if err != nil {
return "main" // Default fallback
}
cmd := rc.GitCmdCWD(context.Background(), "rev-parse", "--abbrev-ref", "HEAD")
output, err := cmd.Output()
if err != nil {
return "main" // Default fallback
@@ -258,8 +266,14 @@ func getGitBranchForGateDiscovery() string {
}
// getGitCommitForGateDiscovery returns the current git commit SHA
// Uses CWD repo context since this is for user's project CI discovery
func getGitCommitForGateDiscovery() string {
cmd := exec.Command("git", "rev-parse", "HEAD")
rc, err := beads.GetRepoContext()
if err != nil {
return ""
}
cmd := rc.GitCmdCWD(context.Background(), "rev-parse", "HEAD")
output, err := cmd.Output()
if err != nil {
return ""

View File

@@ -2,6 +2,7 @@ package main
import (
"bufio"
"context"
"embed"
"encoding/json"
"fmt"
@@ -11,6 +12,7 @@ import (
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/git"
)
@@ -385,7 +387,13 @@ func installHooks(embeddedHooks map[string]string, force bool, shared bool, chai
func configureSharedHooksPath() error {
// Set git config core.hooksPath to .beads-hooks
// Note: This may run before .beads exists, so it uses git.GetRepoRoot() directly
repoRoot := git.GetRepoRoot()
if repoRoot == "" {
return fmt.Errorf("not in a git repository")
}
cmd := exec.Command("git", "config", "core.hooksPath", ".beads-hooks")
cmd.Dir = repoRoot
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git config failed: %w (output: %s)", err, string(output))
}
@@ -535,10 +543,18 @@ func runPreCommitHook() int {
}
} else {
// Default: auto-stage JSONL files
rc, rcErr := beads.GetRepoContext()
ctx := context.Background()
for _, f := range jsonlFiles {
if _, err := os.Stat(f); err == nil {
// #nosec G204 - f is from hardcoded list above, not user input
gitAdd := exec.Command("git", "add", f)
var gitAdd *exec.Cmd
if rcErr == nil {
gitAdd = rc.GitCmdCWD(ctx, "add", f)
} else {
// Fallback if RepoContext unavailable
// #nosec G204 -- f comes from jsonlFiles (controlled, hardcoded paths)
gitAdd = exec.Command("git", "add", f)
}
_ = gitAdd.Run() // Ignore errors - file may not exist
}
}
@@ -619,6 +635,10 @@ func runPrePushHook(args []string) int {
flushCmd := exec.Command("bd", "sync", "--flush-only", "--no-daemon")
_ = flushCmd.Run() // Ignore errors
// Get RepoContext for git operations
rc, rcErr := beads.GetRepoContext()
ctx := context.Background()
// Check for uncommitted JSONL changes
files := []string{}
for _, f := range []string{".beads/beads.jsonl", ".beads/issues.jsonl", ".beads/deletions.jsonl", ".beads/interactions.jsonl"} {
@@ -627,8 +647,13 @@ func runPrePushHook(args []string) int {
files = append(files, f)
} else {
// Check if tracked by git
// #nosec G204 - f is from hardcoded list above, not user input
checkCmd := exec.Command("git", "ls-files", "--error-unmatch", f)
var checkCmd *exec.Cmd
if rcErr == nil {
checkCmd = rc.GitCmdCWD(ctx, "ls-files", "--error-unmatch", f)
} else {
// #nosec G204 - f is from hardcoded list above, not user input
checkCmd = exec.Command("git", "ls-files", "--error-unmatch", f)
}
if checkCmd.Run() == nil {
files = append(files, f)
}
@@ -641,8 +666,13 @@ func runPrePushHook(args []string) int {
// Check for uncommitted changes using git status
statusArgs := append([]string{"status", "--porcelain", "--"}, files...)
// #nosec G204 - statusArgs built from hardcoded list and git subcommands
statusCmd := exec.Command("git", statusArgs...)
var statusCmd *exec.Cmd
if rcErr == nil {
statusCmd = rc.GitCmdCWD(ctx, statusArgs...)
} else {
// #nosec G204 - statusArgs built from hardcoded list and git subcommands
statusCmd = exec.Command("git", statusArgs...)
}
output, _ := statusCmd.Output()
if len(output) > 0 {
fmt.Fprintln(os.Stderr, "❌ Error: Uncommitted changes detected")
@@ -1053,8 +1083,14 @@ func hasBeadsJSONL() bool {
// Returns true if the file needs to be staged before commit.
func hasUnstagedChanges(path string) bool {
// Check git status for this specific file
// #nosec G204 - path is from hardcoded list in caller
cmd := exec.Command("git", "status", "--porcelain", "--", path)
rc, rcErr := beads.GetRepoContext()
var cmd *exec.Cmd
if rcErr == nil {
cmd = rc.GitCmdCWD(context.Background(), "status", "--porcelain", "--", path)
} else {
// #nosec G204 - path is from hardcoded list in caller
cmd = exec.Command("git", "status", "--porcelain", "--", path)
}
output, err := cmd.Output()
if err != nil {
return false // If git fails, assume no changes

View File

@@ -13,6 +13,7 @@ import (
"github.com/steveyegge/beads/internal/ui"
)
// preCommitFrameworkPattern matches pre-commit or prek framework hooks.
// Uses same patterns as hookManagerPatterns in doctor/fix/hooks.go for consistency.
// Includes all detection patterns: pre-commit run, prek run/hook-impl, config file refs, and pre-commit env vars.
@@ -409,8 +410,9 @@ exit 0
}
// mergeDriverInstalled checks if bd merge driver is configured correctly
// Note: This runs during bd init BEFORE .beads exists, so it runs git in CWD.
func mergeDriverInstalled() bool {
// Check git config for merge driver
// Check git config for merge driver (runs in CWD)
cmd := exec.Command("git", "config", "merge.beads.driver")
output, err := cmd.Output()
if err != nil || len(output) == 0 {
@@ -441,8 +443,9 @@ func mergeDriverInstalled() bool {
}
// installMergeDriver configures git to use bd merge for JSONL files
// Note: This runs during bd init BEFORE .beads exists, so it runs git in CWD.
func installMergeDriver() error {
// Configure git merge driver
// Configure git merge driver (runs in CWD)
cmd := exec.Command("git", "config", "merge.beads.driver", "bd merge %A %O %A %B")
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to configure git merge driver: %w\n%s", err, output)

View File

@@ -5,9 +5,9 @@ import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/syncbranch"
@@ -187,8 +187,14 @@ func runTeamWizard(ctx context.Context, store storage.Storage) error {
// getGitBranch returns the current git branch name
// Uses symbolic-ref instead of rev-parse to work in fresh repos without commits (bd-flil)
// Uses CWD repo context since this is for user's project configuration
func getGitBranch() (string, error) {
cmd := exec.Command("git", "symbolic-ref", "--short", "HEAD")
rc, err := beads.GetRepoContext()
if err != nil {
return "", err
}
cmd := rc.GitCmdCWD(context.Background(), "symbolic-ref", "--short", "HEAD")
output, err := cmd.Output()
if err != nil {
return "", err
@@ -198,16 +204,24 @@ func getGitBranch() (string, error) {
}
// createSyncBranch creates a new branch for beads sync
// Uses CWD repo context since this is for user's project configuration
func createSyncBranch(branchName string) error {
rc, err := beads.GetRepoContext()
if err != nil {
return err
}
ctx := context.Background()
// Check if branch already exists
cmd := exec.Command("git", "rev-parse", "--verify", branchName)
cmd := rc.GitCmdCWD(ctx, "rev-parse", "--verify", branchName)
if err := cmd.Run(); err == nil {
// Branch exists, nothing to do
return nil
}
// Create new branch from current HEAD
cmd = exec.Command("git", "checkout", "-b", branchName)
cmd = rc.GitCmdCWD(ctx, "checkout", "-b", branchName)
if err := cmd.Run(); err != nil {
return err
}
@@ -215,7 +229,7 @@ func createSyncBranch(branchName string) error {
// Switch back to original branch
currentBranch, err := getGitBranch()
if err == nil && currentBranch != branchName {
cmd = exec.Command("git", "checkout", "-")
cmd = rc.GitCmdCWD(ctx, "checkout", "-")
_ = cmd.Run() // Ignore error, branch creation succeeded
}

View File

@@ -8,6 +8,9 @@ import (
"runtime"
"strings"
"testing"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/git"
)
func TestInitCommand(t *testing.T) {
@@ -480,12 +483,18 @@ func TestInitNoDbMode(t *testing.T) {
// Set BEADS_DIR to prevent git repo detection from finding project's .beads
origBeadsDir := os.Getenv("BEADS_DIR")
os.Setenv("BEADS_DIR", filepath.Join(tmpDir, ".beads"))
// Reset caches so RepoContext picks up new BEADS_DIR and CWD
beads.ResetCaches()
git.ResetCaches()
defer func() {
if origBeadsDir != "" {
os.Setenv("BEADS_DIR", origBeadsDir)
} else {
os.Unsetenv("BEADS_DIR")
}
// Reset caches on cleanup too
beads.ResetCaches()
git.ResetCaches()
}()
// Initialize with --no-db flag

View File

@@ -1,13 +1,14 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/merge"
)
@@ -105,6 +106,14 @@ func cleanupMergeArtifacts(outputPath string, debug bool) {
fmt.Fprintf(os.Stderr, "Cleaning up artifacts in: %s\n", beadsDir)
}
// Get RepoContext for git operations (provides security via hook disabling)
// Note: rc may be nil if not in a git repo (e.g., tests) - git ops are skipped
rc, rcErr := beads.GetRepoContext()
if rcErr != nil && debug {
fmt.Fprintf(os.Stderr, "Warning: failed to get repo context (git ops skipped): %v\n", rcErr)
}
ctx := context.Background()
// 1. Find and remove any files with "backup" in the name
entries, err := os.ReadDir(beadsDir)
if err != nil {
@@ -121,15 +130,16 @@ func cleanupMergeArtifacts(outputPath string, debug bool) {
if strings.Contains(strings.ToLower(entry.Name()), "backup") {
fullPath := filepath.Join(beadsDir, entry.Name())
// Try to git rm if tracked
// #nosec G204 -- fullPath is safely constructed via filepath.Join from entry.Name()
// from os.ReadDir. exec.Command does NOT use shell interpretation - arguments
// are passed directly to git binary. See TestCleanupMergeArtifacts_CommandInjectionPrevention
gitRmCmd := exec.Command("git", "rm", "-f", "--quiet", fullPath)
gitRmCmd.Dir = filepath.Dir(beadsDir)
_ = gitRmCmd.Run() // Ignore errors, file may not be tracked
// Try to git rm if tracked (only if RepoContext available)
if rcErr == nil {
// #nosec G204 -- fullPath is safely constructed via filepath.Join from entry.Name()
// from os.ReadDir. exec.Command does NOT use shell interpretation - arguments
// are passed directly to git binary. See TestCleanupMergeArtifacts_CommandInjectionPrevention
gitRmCmd := rc.GitCmd(ctx, "rm", "-f", "--quiet", fullPath)
_ = gitRmCmd.Run() // Ignore errors, file may not be tracked
}
// Also remove from filesystem if git rm didn't work
// Also remove from filesystem if git rm didn't work (or wasn't available)
if err := os.Remove(fullPath); err == nil {
if debug {
fmt.Fprintf(os.Stderr, "Removed backup file: %s\n", entry.Name())
@@ -139,14 +149,17 @@ func cleanupMergeArtifacts(outputPath string, debug bool) {
}
// 2. Run git clean -f in .beads/ directory to remove untracked files
cleanCmd := exec.Command("git", "clean", "-f")
cleanCmd.Dir = beadsDir
if debug {
cleanCmd.Stderr = os.Stderr
cleanCmd.Stdout = os.Stderr
fmt.Fprintf(os.Stderr, "Running: git clean -f in %s\n", beadsDir)
// (only if RepoContext available)
if rcErr == nil {
cleanCmd := rc.GitCmd(ctx, "clean", "-f")
cleanCmd.Dir = beadsDir // Override to target .beads/ subdirectory specifically
if debug {
cleanCmd.Stderr = os.Stderr
cleanCmd.Stdout = os.Stderr
fmt.Fprintf(os.Stderr, "Running: git clean -f in %s\n", beadsDir)
}
_ = cleanCmd.Run() // Ignore errors, git clean may fail in some contexts
}
_ = cleanCmd.Run() // Ignore errors, git clean may fail in some contexts
if debug {
fmt.Fprintf(os.Stderr, "Cleanup complete\n\n")

View File

@@ -9,6 +9,7 @@ import (
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/syncbranch"
)
@@ -79,6 +80,12 @@ func runMigrateSync(ctx context.Context, branchName string, dryRun, force bool)
return fmt.Errorf("not in a git repository")
}
// Get RepoContext for git operations
rc, err := beads.GetRepoContext()
if err != nil {
return fmt.Errorf("failed to get repository context: %w", err)
}
// Ensure store is initialized for config operations
if err := ensureDirectMode("migrate-sync requires direct database access"); err != nil {
return err
@@ -116,11 +123,8 @@ func runMigrateSync(ctx context.Context, branchName string, dryRun, force bool)
fmt.Println("⚠ Warning: No git remote configured. Sync branch will only exist locally.")
}
// Get repo root
repoRoot, err := syncbranch.GetRepoRoot(ctx)
if err != nil {
return fmt.Errorf("failed to get repository root: %w", err)
}
// Get repo root (rc already initialized above)
repoRoot := rc.RepoRoot
// Find JSONL path
jsonlPath := findJSONLPath()
@@ -173,20 +177,20 @@ func runMigrateSync(ctx context.Context, branchName string, dryRun, force bool)
if !branchExistsLocally && !branchExistsRemotely {
// Create new branch from current HEAD
fmt.Printf(" Creating new branch '%s'...\n", branchName)
createCmd := exec.CommandContext(ctx, "git", "branch", branchName)
createCmd := rc.GitCmd(ctx, "branch", branchName)
if output, err := createCmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create branch: %w\n%s", err, output)
}
} else if !branchExistsLocally && branchExistsRemotely {
// Fetch and create local tracking branch
fmt.Printf(" Fetching remote branch '%s'...\n", branchName)
fetchCmd := exec.CommandContext(ctx, "git", "fetch", "origin", branchName)
fetchCmd := rc.GitCmd(ctx, "fetch", "origin", branchName)
if output, err := fetchCmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to fetch remote branch: %w\n%s", err, output)
}
// Create local branch tracking remote
createCmd := exec.CommandContext(ctx, "git", "branch", branchName, "origin/"+branchName)
createCmd := rc.GitCmd(ctx, "branch", branchName, "origin/"+branchName)
if output, err := createCmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create local tracking branch: %w\n%s", err, output)
}
@@ -298,17 +302,25 @@ func runMigrateSync(ctx context.Context, branchName string, dryRun, force bool)
// branchExistsLocal checks if a branch exists locally
func branchExistsLocal(ctx context.Context, branch string) bool {
cmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
rc, err := beads.GetRepoContext()
if err != nil {
return false
}
cmd := rc.GitCmd(ctx, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
return cmd.Run() == nil
}
// branchExistsRemote checks if a branch exists on origin remote
func branchExistsRemote(ctx context.Context, branch string) bool {
rc, err := beads.GetRepoContext()
if err != nil {
return false
}
// First fetch to ensure we have latest remote refs
fetchCmd := exec.CommandContext(ctx, "git", "fetch", "origin", "--prune")
fetchCmd := rc.GitCmd(ctx, "fetch", "origin", "--prune")
_ = fetchCmd.Run() // Best effort
cmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/remotes/origin/"+branch)
cmd := rc.GitCmd(ctx, "show-ref", "--verify", "--quiet", "refs/remotes/origin/"+branch)
return cmd.Run() == nil
}

View File

@@ -6,6 +6,9 @@ import (
"os/exec"
"path/filepath"
"testing"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/git"
)
func TestMigrateSyncValidation(t *testing.T) {
@@ -96,6 +99,13 @@ func TestMigrateSyncDryRun(t *testing.T) {
// Note: We need to run this from tmpDir context since branchExistsLocal uses git in cwd
ctx := context.Background()
t.Chdir(tmpDir)
// Reset caches so RepoContext picks up new CWD
beads.ResetCaches()
git.ResetCaches()
defer func() {
beads.ResetCaches()
git.ResetCaches()
}()
if branchExistsLocal(ctx, "beads-sync") {
t.Error("branchExistsLocal should return false for non-existent branch")

View File

@@ -6,12 +6,12 @@ import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/beads"
internalbeads "github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/syncbranch"
@@ -176,9 +176,12 @@ func isMCPActive() bool {
var isEphemeralBranch = func() bool {
// git rev-parse --abbrev-ref --symbolic-full-name @{u}
// Returns error code 128 if no upstream configured
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
err := cmd.Run()
return err != nil
rc, err := internalbeads.GetRepoContext()
if err != nil {
return true // Default to ephemeral if we can't determine context
}
cmd := rc.GitCmdCWD(context.Background(), "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
return cmd.Run() != nil
}
// primeHasGitRemote detects if any git remote is configured (stubbable for tests)

View File

@@ -201,8 +201,8 @@ Use --merge to merge the sync branch back to main branch.`,
if err := ensureStoreActive(); err == nil && store != nil {
if sb, _ := syncbranch.Get(ctx, store); sb != "" {
syncBranchName = sb
if rr, err := syncbranch.GetRepoRoot(ctx); err == nil {
syncBranchRepoRoot = rr
if rc, err := beads.GetRepoContext(); err == nil {
syncBranchRepoRoot = rc.RepoRoot
}
}
}

683
cmd/bd/sync_cwd_test.go Normal file
View File

@@ -0,0 +1,683 @@
package main
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
// TestMultiRepoPathResolutionCWDInvariant verifies that path resolution for
// repos.additional produces the same absolute paths regardless of CWD.
//
// The bug (oss-lbp): Running from .beads/ caused paths like "oss/" to become
// ".beads/oss/" instead of "{repo}/oss/". This test ensures the fix works
// by verifying resolution from multiple CWDs produces identical results.
//
// Covers: T040-T042
func TestMultiRepoPathResolutionCWDInvariant(t *testing.T) {
ctx := context.Background()
// Store original working directory
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get original working directory: %v", err)
}
defer func() { _ = os.Chdir(originalWd) }()
// Create temp repo structure
// Resolve symlinks to avoid macOS /var -> /private/var issues
tmpDir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatalf("eval symlinks failed: %v", err)
}
// Setup git repo
if err := setupGitRepoInDir(t, tmpDir); err != nil {
t.Fatalf("failed to setup git repo: %v", err)
}
// Create .beads directory structure
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads dir: %v", err)
}
// Create subdirectory for testing CWD from subdir
subDir := filepath.Join(tmpDir, "src", "pkg")
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatalf("failed to create subdir: %v", err)
}
// Create oss/ directory (the multi-repo target)
ossDir := filepath.Join(tmpDir, "oss")
ossBeadsDir := filepath.Join(ossDir, ".beads")
if err := os.MkdirAll(ossBeadsDir, 0755); err != nil {
t.Fatalf("failed to create oss/.beads dir: %v", err)
}
// Create config.yaml with relative path
configContent := `repos:
primary: "."
additional:
- oss/
`
configPath := filepath.Join(beadsDir, "config.yaml")
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
// Create database
dbPath := filepath.Join(beadsDir, "beads.db")
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
defer store.Close()
// Set issue prefix
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
t.Fatalf("failed to set issue_prefix: %v", err)
}
// Create a test issue
issue := &types.Issue{
ID: "test-1",
Title: "Test Issue",
Status: types.StatusOpen,
IssueType: types.TypeTask,
Priority: 2,
}
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("failed to create issue: %v", err)
}
store.Close()
// T040: Test from repo root
t.Run("T040_from_repo_root", func(t *testing.T) {
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("chdir to repo root failed: %v", err)
}
git.ResetCaches()
// Initialize config
if err := config.Initialize(); err != nil {
t.Fatalf("config.Initialize() failed: %v", err)
}
multiRepo := config.GetMultiRepoConfig()
if multiRepo == nil {
t.Fatal("GetMultiRepoConfig() returned nil")
}
// The key assertion: "oss/" should resolve to {repo}/oss/
if len(multiRepo.Additional) != 1 {
t.Fatalf("expected 1 additional repo, got %d", len(multiRepo.Additional))
}
// Verify config file used is in the right place
configUsed := config.ConfigFileUsed()
expectedConfig := filepath.Join(beadsDir, "config.yaml")
if configUsed != expectedConfig {
t.Errorf("ConfigFileUsed() = %q, want %q", configUsed, expectedConfig)
}
t.Logf("From repo root: additional[0] = %q", multiRepo.Additional[0])
t.Logf("ConfigFileUsed() = %q", configUsed)
})
// T041: Test from .beads/ directory (the bug trigger location)
t.Run("T041_from_beads_directory", func(t *testing.T) {
if err := os.Chdir(beadsDir); err != nil {
t.Fatalf("chdir to .beads failed: %v", err)
}
git.ResetCaches()
// Re-initialize config from new CWD
if err := config.Initialize(); err != nil {
t.Fatalf("config.Initialize() failed: %v", err)
}
multiRepo := config.GetMultiRepoConfig()
if multiRepo == nil {
t.Fatal("GetMultiRepoConfig() returned nil")
}
if len(multiRepo.Additional) != 1 {
t.Fatalf("expected 1 additional repo, got %d", len(multiRepo.Additional))
}
// Verify config is still found correctly
configUsed := config.ConfigFileUsed()
expectedConfig := filepath.Join(beadsDir, "config.yaml")
if configUsed != expectedConfig {
t.Errorf("ConfigFileUsed() = %q, want %q", configUsed, expectedConfig)
}
t.Logf("From .beads/: additional[0] = %q", multiRepo.Additional[0])
t.Logf("ConfigFileUsed() = %q", configUsed)
})
// T042: Test from subdirectory
t.Run("T042_from_subdirectory", func(t *testing.T) {
if err := os.Chdir(subDir); err != nil {
t.Fatalf("chdir to subdir failed: %v", err)
}
git.ResetCaches()
// Re-initialize config from new CWD
if err := config.Initialize(); err != nil {
t.Fatalf("config.Initialize() failed: %v", err)
}
multiRepo := config.GetMultiRepoConfig()
if multiRepo == nil {
t.Fatal("GetMultiRepoConfig() returned nil")
}
if len(multiRepo.Additional) != 1 {
t.Fatalf("expected 1 additional repo, got %d", len(multiRepo.Additional))
}
// Verify config is still found correctly
configUsed := config.ConfigFileUsed()
expectedConfig := filepath.Join(beadsDir, "config.yaml")
if configUsed != expectedConfig {
t.Errorf("ConfigFileUsed() = %q, want %q", configUsed, expectedConfig)
}
t.Logf("From subdir: additional[0] = %q", multiRepo.Additional[0])
t.Logf("ConfigFileUsed() = %q", configUsed)
})
}
// TestExportToMultiRepoCWDInvariant tests that ExportToMultiRepo produces
// consistent export paths regardless of CWD.
//
// This is an integration test that exercises the actual export code path
// which was affected by the bug.
func TestExportToMultiRepoCWDInvariant(t *testing.T) {
ctx := context.Background()
// Store original working directory
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get original working directory: %v", err)
}
defer func() { _ = os.Chdir(originalWd) }()
// Create temp repo structure
tmpDir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatalf("eval symlinks failed: %v", err)
}
// Setup git repo
if err := setupGitRepoInDir(t, tmpDir); err != nil {
t.Fatalf("failed to setup git repo: %v", err)
}
// Create .beads directory
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads dir: %v", err)
}
// Create oss/.beads directory
ossBeadsDir := filepath.Join(tmpDir, "oss", ".beads")
if err := os.MkdirAll(ossBeadsDir, 0755); err != nil {
t.Fatalf("failed to create oss/.beads dir: %v", err)
}
// Create config.yaml with relative path
configContent := `repos:
primary: "."
additional:
- oss/
`
configPath := filepath.Join(beadsDir, "config.yaml")
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
// Create database and issue once before CWD tests
dbPath := filepath.Join(beadsDir, "beads.db")
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
// Set issue prefix
if err := store.SetConfig(ctx, "issue_prefix", "oss"); err != nil {
store.Close()
t.Fatalf("failed to set issue_prefix: %v", err)
}
// Create a test issue for oss/ repo
issue := &types.Issue{
ID: "oss-1",
Title: "OSS Issue",
Status: types.StatusOpen,
IssueType: types.TypeTask,
Priority: 2,
SourceRepo: "oss/", // This routes to additional repo
}
if err := store.CreateIssue(ctx, issue, "oss"); err != nil {
store.Close()
t.Fatalf("failed to create issue: %v", err)
}
store.Close()
// Helper function to run export and check results
runExportTest := func(t *testing.T, testCwd string) string {
t.Helper()
// Change to test CWD
if err := os.Chdir(testCwd); err != nil {
t.Fatalf("chdir to %s failed: %v", testCwd, err)
}
git.ResetCaches()
// Initialize config
if err := config.Initialize(); err != nil {
t.Fatalf("config.Initialize() failed: %v", err)
}
// Open existing store
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to open store: %v", err)
}
defer store.Close()
// Run multi-repo export
results, err := store.ExportToMultiRepo(ctx)
if err != nil {
t.Fatalf("ExportToMultiRepo failed: %v", err)
}
// Check that oss/ was exported
if results == nil {
t.Fatal("ExportToMultiRepo returned nil results")
}
// The export should create issues.jsonl in oss/.beads/
expectedPath := filepath.Join(ossBeadsDir, "issues.jsonl")
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
t.Errorf("expected %s to exist after export from %s", expectedPath, testCwd)
}
return expectedPath
}
// Test from repo root
t.Run("export_from_repo_root", func(t *testing.T) {
path := runExportTest(t, tmpDir)
t.Logf("Export from repo root created: %s", path)
})
// Test from .beads/ directory
t.Run("export_from_beads_dir", func(t *testing.T) {
path := runExportTest(t, beadsDir)
t.Logf("Export from .beads/ created: %s", path)
// Key assertion: should NOT create .beads/oss/.beads/issues.jsonl
badPath := filepath.Join(beadsDir, "oss", ".beads", "issues.jsonl")
if _, err := os.Stat(badPath); err == nil {
t.Errorf("BUG: export created %s (CWD-relative path)", badPath)
}
})
// Test from subdirectory
subDir := filepath.Join(tmpDir, "src")
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatalf("failed to create subdir: %v", err)
}
t.Run("export_from_subdirectory", func(t *testing.T) {
path := runExportTest(t, subDir)
t.Logf("Export from subdir created: %s", path)
})
}
// TestSyncModePathResolution tests path resolution across different sync modes.
//
// Covers: T050-T052
func TestSyncModePathResolution(t *testing.T) {
ctx := context.Background()
// Store original working directory
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get original working directory: %v", err)
}
defer func() { _ = os.Chdir(originalWd) }()
// T050: Normal sync mode path resolution
t.Run("T050_normal_sync_mode", func(t *testing.T) {
// Restore CWD at end of subtest to prevent interference with subsequent tests.
// t.TempDir() cleanup happens after subtest returns, so CWD must be restored first.
subtestWd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get subtest working directory: %v", err)
}
defer func() { _ = os.Chdir(subtestWd) }()
tmpDir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatalf("eval symlinks failed: %v", err)
}
// Setup git repo
if err := setupGitRepoInDir(t, tmpDir); err != nil {
t.Fatalf("failed to setup git repo: %v", err)
}
// Create .beads directory
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads dir: %v", err)
}
// Create oss/.beads directory
ossBeadsDir := filepath.Join(tmpDir, "oss", ".beads")
if err := os.MkdirAll(ossBeadsDir, 0755); err != nil {
t.Fatalf("failed to create oss/.beads dir: %v", err)
}
// Create config.yaml with relative path
configContent := `repos:
primary: "."
additional:
- oss/
`
configPath := filepath.Join(beadsDir, "config.yaml")
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
// Create database
dbPath := filepath.Join(beadsDir, "beads.db")
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
// Set issue prefix
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
store.Close()
t.Fatalf("failed to set issue_prefix: %v", err)
}
// Create issue for oss/
issue := &types.Issue{
ID: "test-100",
Title: "Normal mode issue",
Status: types.StatusOpen,
IssueType: types.TypeTask,
Priority: 2,
SourceRepo: "oss/",
}
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
store.Close()
t.Fatalf("failed to create issue: %v", err)
}
// Change to repo root and initialize config
if err := os.Chdir(tmpDir); err != nil {
store.Close()
t.Fatalf("chdir failed: %v", err)
}
git.ResetCaches()
if err := config.Initialize(); err != nil {
store.Close()
t.Fatalf("config.Initialize() failed: %v", err)
}
// Export
results, err := store.ExportToMultiRepo(ctx)
store.Close()
if err != nil {
t.Fatalf("ExportToMultiRepo failed: %v", err)
}
// Verify export created file in correct location
expectedPath := filepath.Join(ossBeadsDir, "issues.jsonl")
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
t.Errorf("expected %s to exist", expectedPath)
}
t.Logf("Normal sync mode export results: %v", results)
})
// T051: Sync-branch mode with daemon context
t.Run("T051_sync_branch_mode", func(t *testing.T) {
// Restore CWD at end of subtest to prevent interference with subsequent tests.
subtestWd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get subtest working directory: %v", err)
}
defer func() { _ = os.Chdir(subtestWd) }()
tmpDir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatalf("eval symlinks failed: %v", err)
}
// Setup git repo
if err := setupGitRepoInDir(t, tmpDir); err != nil {
t.Fatalf("failed to setup git repo: %v", err)
}
// Create .beads directory
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads dir: %v", err)
}
// Create oss/.beads directory
ossBeadsDir := filepath.Join(tmpDir, "oss", ".beads")
if err := os.MkdirAll(ossBeadsDir, 0755); err != nil {
t.Fatalf("failed to create oss/.beads dir: %v", err)
}
// Create config.yaml with sync-branch AND multi-repo
configContent := `sync:
branch: beads-sync
repos:
primary: "."
additional:
- oss/
`
configPath := filepath.Join(beadsDir, "config.yaml")
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
// Create the sync branch
if err := exec.Command("git", "-C", tmpDir, "branch", "beads-sync").Run(); err != nil {
t.Fatalf("failed to create sync branch: %v", err)
}
// Create database
dbPath := filepath.Join(beadsDir, "beads.db")
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
// Set issue prefix
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
store.Close()
t.Fatalf("failed to set issue_prefix: %v", err)
}
// Create issue for oss/
issue := &types.Issue{
ID: "test-200",
Title: "Sync-branch mode issue",
Status: types.StatusOpen,
IssueType: types.TypeTask,
Priority: 2,
SourceRepo: "oss/",
}
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
store.Close()
t.Fatalf("failed to create issue: %v", err)
}
// Simulate daemon context: CWD is .beads/
if err := os.Chdir(beadsDir); err != nil {
store.Close()
t.Fatalf("chdir to .beads/ failed: %v", err)
}
git.ResetCaches()
if err := config.Initialize(); err != nil {
store.Close()
t.Fatalf("config.Initialize() failed: %v", err)
}
// Export from daemon-like context
results, err := store.ExportToMultiRepo(ctx)
store.Close()
if err != nil {
t.Fatalf("ExportToMultiRepo failed: %v", err)
}
// Key assertion: should still export to correct location
expectedPath := filepath.Join(ossBeadsDir, "issues.jsonl")
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
t.Errorf("expected %s to exist (sync-branch mode from .beads/)", expectedPath)
}
// Verify no spurious directory created
badPath := filepath.Join(beadsDir, "oss", ".beads", "issues.jsonl")
if _, err := os.Stat(badPath); err == nil {
t.Errorf("BUG: created %s (CWD-relative in sync-branch mode)", badPath)
}
t.Logf("Sync-branch mode export results: %v", results)
})
// T052: External BEADS_DIR mode
t.Run("T052_external_beads_dir_mode", func(t *testing.T) {
// Restore CWD at end of subtest to prevent interference with subsequent tests.
subtestWd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get subtest working directory: %v", err)
}
defer func() { _ = os.Chdir(subtestWd) }()
// Create main project repo
projectDir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatalf("eval symlinks failed: %v", err)
}
if err := setupGitRepoInDir(t, projectDir); err != nil {
t.Fatalf("failed to setup project repo: %v", err)
}
// Create external beads repo
externalDir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatalf("eval symlinks failed: %v", err)
}
if err := setupGitRepoInDir(t, externalDir); err != nil {
t.Fatalf("failed to setup external repo: %v", err)
}
// Create .beads in external repo
externalBeadsDir := filepath.Join(externalDir, ".beads")
if err := os.MkdirAll(externalBeadsDir, 0755); err != nil {
t.Fatalf("failed to create external .beads: %v", err)
}
// Create oss/.beads in external repo (sibling to external .beads)
ossBeadsDir := filepath.Join(externalDir, "oss", ".beads")
if err := os.MkdirAll(ossBeadsDir, 0755); err != nil {
t.Fatalf("failed to create oss/.beads: %v", err)
}
// Create config.yaml in external repo with relative path
configContent := `repos:
primary: "."
additional:
- oss/
`
configPath := filepath.Join(externalBeadsDir, "config.yaml")
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
// Create database in external repo
dbPath := filepath.Join(externalBeadsDir, "beads.db")
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
// Set issue prefix
if err := store.SetConfig(ctx, "issue_prefix", "ext"); err != nil {
store.Close()
t.Fatalf("failed to set issue_prefix: %v", err)
}
// Create issue for oss/ (ext prefix matches issue ID)
issue := &types.Issue{
ID: "ext-300",
Title: "External mode issue",
Status: types.StatusOpen,
IssueType: types.TypeTask,
Priority: 2,
SourceRepo: "oss/",
}
if err := store.CreateIssue(ctx, issue, "ext"); err != nil {
store.Close()
t.Fatalf("failed to create issue: %v", err)
}
// Simulate external mode: CWD is project repo, BEADS_DIR points elsewhere
if err := os.Chdir(projectDir); err != nil {
store.Close()
t.Fatalf("chdir to project failed: %v", err)
}
git.ResetCaches()
// Initialize config from external location
// In external mode, config is loaded from BEADS_DIR, not CWD
// We simulate this by changing to external dir for config init
if err := os.Chdir(externalDir); err != nil {
store.Close()
t.Fatalf("chdir to external failed: %v", err)
}
git.ResetCaches()
if err := config.Initialize(); err != nil {
store.Close()
t.Fatalf("config.Initialize() failed: %v", err)
}
// Export
results, err := store.ExportToMultiRepo(ctx)
store.Close()
if err != nil {
t.Fatalf("ExportToMultiRepo failed: %v", err)
}
// Verify export in correct location
expectedPath := filepath.Join(ossBeadsDir, "issues.jsonl")
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
t.Errorf("expected %s to exist (external mode)", expectedPath)
}
t.Logf("External BEADS_DIR mode export results: %v", results)
})
}

View File

@@ -14,15 +14,25 @@ import (
"github.com/steveyegge/beads/internal/git"
)
// isGitRepo checks if the current directory is in a git repository
// isGitRepo checks if the current working directory is in a git repository.
// NOTE: This intentionally checks CWD, not the beads repo. It's used as a guard
// before calling other git functions to prevent hangs on Windows (GH#727).
// Does not use RepoContext because it's a prerequisite check for git availability.
func isGitRepo() bool {
cmd := exec.Command("git", "rev-parse", "--git-dir")
return cmd.Run() == nil
}
// gitHasUnmergedPaths checks for unmerged paths or merge in progress
// gitHasUnmergedPaths checks for unmerged paths or merge in progress in the beads repository.
// Uses RepoContext to ensure git commands run in the correct repository.
func gitHasUnmergedPaths() (bool, error) {
cmd := exec.Command("git", "status", "--porcelain")
rc, err := beads.GetRepoContext()
if err != nil {
return false, fmt.Errorf("getting repo context: %w", err)
}
ctx := context.Background()
cmd := rc.GitCmd(ctx, "status", "--porcelain")
out, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("git status failed: %w", err)
@@ -39,18 +49,26 @@ func gitHasUnmergedPaths() (bool, error) {
}
// Check if MERGE_HEAD exists (merge in progress)
if exec.Command("git", "rev-parse", "-q", "--verify", "MERGE_HEAD").Run() == nil {
mergeCmd := rc.GitCmd(ctx, "rev-parse", "-q", "--verify", "MERGE_HEAD")
if mergeCmd.Run() == nil {
return true, nil
}
return false, nil
}
// gitHasUpstream checks if the current branch has an upstream configured
// Uses git config directly for compatibility with Git for Windows
// gitHasUpstream checks if the current branch has an upstream configured in the beads repository.
// Uses RepoContext to ensure git commands run in the correct repository.
// Uses git config directly for compatibility with Git for Windows.
func gitHasUpstream() bool {
rc, err := beads.GetRepoContext()
if err != nil {
return false
}
ctx := context.Background()
// Get current branch name
branchCmd := exec.Command("git", "symbolic-ref", "--short", "HEAD")
branchCmd := rc.GitCmd(ctx, "symbolic-ref", "--short", "HEAD")
branchOutput, err := branchCmd.Output()
if err != nil {
return false
@@ -64,10 +82,17 @@ func gitHasUpstream() bool {
// Unlike gitHasUpstream(), this works even when HEAD is detached (e.g., jj/jujutsu).
// This is critical for sync-branch workflows where the sync branch has upstream
// tracking but the main working copy may be in detached HEAD state.
// Uses RepoContext to ensure git commands run in the correct repository.
func gitBranchHasUpstream(branch string) bool {
rc, err := beads.GetRepoContext()
if err != nil {
return false
}
ctx := context.Background()
// Check if remote and merge refs are configured for the branch
remoteCmd := exec.Command("git", "config", "--get", fmt.Sprintf("branch.%s.remote", branch)) //nolint:gosec // G204: branch from caller
mergeCmd := exec.Command("git", "config", "--get", fmt.Sprintf("branch.%s.merge", branch)) //nolint:gosec // G204: branch from caller
remoteCmd := rc.GitCmd(ctx, "config", "--get", fmt.Sprintf("branch.%s.remote", branch)) //nolint:gosec // G204: branch from caller
mergeCmd := rc.GitCmd(ctx, "config", "--get", fmt.Sprintf("branch.%s.merge", branch)) //nolint:gosec // G204: branch from caller
remoteErr := remoteCmd.Run()
mergeErr := mergeCmd.Run()
@@ -75,9 +100,15 @@ func gitBranchHasUpstream(branch string) bool {
return remoteErr == nil && mergeErr == nil
}
// gitHasChanges checks if the specified file has uncommitted changes
// gitHasChanges checks if the specified file has uncommitted changes in the beads repository.
// Uses RepoContext to ensure git commands run in the correct repository.
func gitHasChanges(ctx context.Context, filePath string) (bool, error) {
cmd := exec.CommandContext(ctx, "git", "status", "--porcelain", filePath)
rc, err := beads.GetRepoContext()
if err != nil {
return false, fmt.Errorf("getting repo context: %w", err)
}
cmd := rc.GitCmd(ctx, "status", "--porcelain", filePath)
output, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("git status failed: %w", err)
@@ -85,59 +116,28 @@ 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(_ 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
// This function is worktree-aware and handles bare repo worktree setups (GH#827).
// It also handles redirected beads directories (bd-arjb) by running git commands
// from the directory containing the actual .beads/.
// gitHasBeadsChanges checks if any tracked files in .beads/ have uncommitted changes.
// Uses RepoContext to ensure git commands run in the correct repository.
// RepoContext handles worktrees (GH#827) and redirected beads directories (bd-arjb).
func gitHasBeadsChanges(ctx context.Context) (bool, error) {
// Get the absolute path to .beads directory
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
return false, fmt.Errorf("no .beads directory found")
rc, err := beads.GetRepoContext()
if err != nil {
return false, fmt.Errorf("getting repo context: %w", err)
}
// Check if beads directory is redirected (bd-arjb)
// When redirected, beadsDir points outside the current repo, so we need to
// run git commands from the directory containing the actual .beads/
redirectInfo := beads.GetRedirectInfo()
if redirectInfo.IsRedirected {
// beadsDir is the target (e.g., /path/to/mayor/rig/.beads)
// We need to run git from the parent of .beads (e.g., /path/to/mayor/rig)
targetRepoDir := filepath.Dir(beadsDir)
statusCmd := exec.CommandContext(ctx, "git", "-C", targetRepoDir, "status", "--porcelain", beadsDir) //nolint:gosec // G204: beadsDir from beads.FindBeadsDir()
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 absolute path from current directory.
// This is more robust than using -C with a repo root, because:
// 1. In bare repo worktree setups, GetMainRepoRoot() returns the parent
// of the bare repo, which isn't a valid working tree (GH#827)
// 2. Git will find the repository from cwd, which is always valid
statusCmd := exec.CommandContext(ctx, "git", "status", "--porcelain", beadsDir) //nolint:gosec // G204: beadsDir from beads.FindBeadsDir()
statusOutput, err := statusCmd.Output()
// rc.GitCmd runs in rc.RepoRoot which is the repo containing .beads/
// This works for both normal and redirected scenarios.
cmd := rc.GitCmd(ctx, "status", "--porcelain", rc.BeadsDir)
output, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("git status failed: %w", err)
}
return len(strings.TrimSpace(string(statusOutput))) > 0, nil
return len(strings.TrimSpace(string(output))) > 0, nil
}
// buildGitCommitArgs returns git commit args with config-based author and signing options (GH#600)
// This allows users to configure a separate author and disable GPG signing for beads commits.
// Includes -C repoRoot for use with raw exec.Command (not RepoContext).
func buildGitCommitArgs(repoRoot, message string, extraArgs ...string) []string {
args := []string{"-C", repoRoot, "commit"}
@@ -160,22 +160,47 @@ func buildGitCommitArgs(repoRoot, message string, extraArgs ...string) []string
return args
}
// gitCommit commits the specified file (worktree-aware)
// buildCommitArgs returns git commit args for use with RepoContext.GitCmd().
// Unlike buildGitCommitArgs, this does NOT include -C flag since GitCmd sets cmd.Dir.
// Applies config-based author and signing options (GH#600).
func buildCommitArgs(message string, extraArgs ...string) []string {
args := []string{"commit"}
// Add --author if configured
if author := config.GetString("git.author"); author != "" {
args = append(args, "--author", author)
}
// Add --no-gpg-sign if configured
if config.GetBool("git.no-gpg-sign") {
args = append(args, "--no-gpg-sign")
}
// Add message
args = append(args, "-m", message)
// Add any extra args (like -- pathspec)
args = append(args, extraArgs...)
return args
}
// gitCommit commits the specified file in the beads repository.
// Uses RepoContext to ensure git commands run in the correct repository (worktree-aware).
func gitCommit(ctx context.Context, filePath string, message string) error {
// Get the repository root (handles worktrees properly)
repoRoot := getRepoRootForWorktree(ctx)
if repoRoot == "" {
return fmt.Errorf("cannot determine repository root")
rc, err := beads.GetRepoContext()
if err != nil {
return fmt.Errorf("getting repo context: %w", err)
}
// Make file path relative to repo root for git operations
relPath, err := filepath.Rel(repoRoot, filePath)
relPath, err := filepath.Rel(rc.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) //nolint:gosec // G204: paths from internal git helpers
// Stage the file
addCmd := rc.GitCmd(ctx, "add", relPath)
if err := addCmd.Run(); err != nil {
return fmt.Errorf("git add failed: %w", err)
}
@@ -185,11 +210,11 @@ 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 from repo root context with config-based author and signing options
// Commit with config-based author and signing options
// Use pathspec to commit ONLY this file
// This prevents accidentally committing other staged files
commitArgs := buildGitCommitArgs(repoRoot, message, "--", relPath)
commitCmd := exec.CommandContext(ctx, "git", commitArgs...) //nolint:gosec // G204: args from buildGitCommitArgs
commitArgs := buildCommitArgs(message, "--", relPath)
commitCmd := rc.GitCmd(ctx, commitArgs...)
output, err := commitCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git commit failed: %w\n%s", err, output)
@@ -202,38 +227,22 @@ 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.
// Worktree-aware: handles cases where .beads is in the main repo but we're running from a worktree.
// Uses RepoContext to ensure git commands run in the correct repository.
// Handles worktrees and redirected beads directories.
func gitCommitBeadsDir(ctx context.Context, message string) error {
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
return fmt.Errorf("no .beads directory found")
}
// Determine the repository root
// When beads directory is redirected (bd-arjb), we need to run git commands
// from the directory containing the actual .beads/, not the current working directory
var repoRoot string
redirectInfo := beads.GetRedirectInfo()
if redirectInfo.IsRedirected {
// beadsDir is the target (e.g., /path/to/mayor/rig/.beads)
// We need to run git from the parent of .beads (e.g., /path/to/mayor/rig)
repoRoot = filepath.Dir(beadsDir)
} else {
// Get the repository root (handles worktrees properly)
repoRoot = getRepoRootForWorktree(ctx)
if repoRoot == "" {
return fmt.Errorf("cannot determine repository root")
}
rc, err := beads.GetRepoContext()
if err != nil {
return fmt.Errorf("getting repo context: %w", err)
}
// Stage only the specific sync-related files
// This avoids staging gitignored snapshot files (beads.*.jsonl, *.meta.json)
// that may still be tracked from before they were added to .gitignore
syncFiles := []string{
filepath.Join(beadsDir, "issues.jsonl"),
filepath.Join(beadsDir, "deletions.jsonl"),
filepath.Join(beadsDir, "interactions.jsonl"),
filepath.Join(beadsDir, "metadata.json"),
filepath.Join(rc.BeadsDir, "issues.jsonl"),
filepath.Join(rc.BeadsDir, "deletions.jsonl"),
filepath.Join(rc.BeadsDir, "interactions.jsonl"),
filepath.Join(rc.BeadsDir, "metadata.json"),
}
// Only add files that exist
@@ -241,7 +250,7 @@ func gitCommitBeadsDir(ctx context.Context, message string) error {
for _, f := range syncFiles {
if _, err := os.Stat(f); err == nil {
// Convert to relative path from repo root for git operations
relPath, err := filepath.Rel(repoRoot, f)
relPath, err := filepath.Rel(rc.RepoRoot, f)
if err != nil {
relPath = f // Fall back to absolute path if relative fails
}
@@ -253,9 +262,9 @@ func gitCommitBeadsDir(ctx context.Context, message string) error {
return fmt.Errorf("no sync files found to commit")
}
// Stage only the sync files from repo root context (worktree-aware)
args := append([]string{"-C", repoRoot, "add"}, filesToAdd...)
addCmd := exec.CommandContext(ctx, "git", args...) //nolint:gosec // G204: paths from internal git helpers
// Stage only the sync files
addArgs := append([]string{"add"}, filesToAdd...)
addCmd := rc.GitCmd(ctx, addArgs...)
if err := addCmd.Run(); err != nil {
return fmt.Errorf("git add failed: %w", err)
}
@@ -268,15 +277,14 @@ func gitCommitBeadsDir(ctx context.Context, message string) error {
// Commit only .beads/ files using -- pathspec
// This prevents accidentally committing other staged files that the user
// may have staged but wasn't ready to commit yet.
// Convert beadsDir to relative path for git commit (worktree-aware)
relBeadsDir, err := filepath.Rel(repoRoot, beadsDir)
relBeadsDir, err := filepath.Rel(rc.RepoRoot, rc.BeadsDir)
if err != nil {
relBeadsDir = beadsDir // Fall back to absolute path if relative fails
relBeadsDir = rc.BeadsDir // Fall back to absolute path if relative fails
}
// Use config-based author and signing options with pathspec
commitArgs := buildGitCommitArgs(repoRoot, message, "--", relBeadsDir)
commitCmd := exec.CommandContext(ctx, "git", commitArgs...) //nolint:gosec // G204: args from buildGitCommitArgs
commitArgs := buildCommitArgs(message, "--", relBeadsDir)
commitCmd := rc.GitCmd(ctx, commitArgs...)
output, err := commitCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git commit failed: %w\n%s", err, output)
@@ -285,9 +293,15 @@ func gitCommitBeadsDir(ctx context.Context, message string) error {
return nil
}
// hasGitRemote checks if a git remote exists in the repository
// hasGitRemote checks if a git remote exists in the beads repository.
// Uses RepoContext to ensure git commands run in the correct repository
// regardless of current working directory.
func hasGitRemote(ctx context.Context) bool {
cmd := exec.CommandContext(ctx, "git", "remote")
rc, err := beads.GetRepoContext()
if err != nil {
return false
}
cmd := rc.GitCmd(ctx, "remote")
output, err := cmd.Output()
if err != nil {
return false
@@ -316,10 +330,17 @@ func isInRebase() bool {
return false
}
// hasJSONLConflict checks if the beads JSONL file has a merge conflict
// Returns true only if the JSONL file (issues.jsonl or beads.jsonl) is the only file in conflict
// hasJSONLConflict checks if the beads JSONL file has a merge conflict in the beads repository.
// Returns true only if the JSONL file (issues.jsonl or beads.jsonl) is the only file in conflict.
// Uses RepoContext to ensure git commands run in the correct repository.
func hasJSONLConflict() bool {
cmd := exec.Command("git", "status", "--porcelain")
rc, err := beads.GetRepoContext()
if err != nil {
return false
}
ctx := context.Background()
cmd := rc.GitCmd(ctx, "status", "--porcelain")
out, err := cmd.Output()
if err != nil {
return false
@@ -337,10 +358,10 @@ func hasJSONLConflict() bool {
status := line[:2]
if status == "UU" || status == "AA" || status == "DD" ||
status == "AU" || status == "UA" || status == "DU" || status == "UD" {
filepath := strings.TrimSpace(line[3:])
filePath := strings.TrimSpace(line[3:])
// Check for beads JSONL files (issues.jsonl or beads.jsonl in .beads/)
if strings.HasSuffix(filepath, "issues.jsonl") || strings.HasSuffix(filepath, "beads.jsonl") {
if strings.HasSuffix(filePath, "issues.jsonl") || strings.HasSuffix(filePath, "beads.jsonl") {
hasJSONLConflict = true
} else {
hasOtherConflict = true
@@ -352,9 +373,15 @@ func hasJSONLConflict() bool {
return hasJSONLConflict && !hasOtherConflict
}
// runGitRebaseContinue continues a rebase after resolving conflicts
// runGitRebaseContinue continues a rebase after resolving conflicts in the beads repository.
// Uses RepoContext to ensure git commands run in the correct repository.
func runGitRebaseContinue(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "git", "rebase", "--continue")
rc, err := beads.GetRepoContext()
if err != nil {
return fmt.Errorf("getting repo context: %w", err)
}
cmd := rc.GitCmd(ctx, "rebase", "--continue")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git rebase --continue failed: %w\n%s", err, output)
@@ -362,19 +389,25 @@ func runGitRebaseContinue(ctx context.Context) error {
return nil
}
// gitPull pulls from the current branch's upstream
// Returns nil if no remote configured (local-only mode)
// gitPull pulls from the current branch's upstream in the beads repository.
// Returns nil if no remote configured (local-only mode).
// If configuredRemote is non-empty, uses that instead of the branch's configured remote.
// This allows respecting the sync.remote bd config.
// Uses RepoContext to ensure git commands run in the correct repository.
func gitPull(ctx context.Context, configuredRemote string) error {
// Check if any remote exists (support local-only repos)
if !hasGitRemote(ctx) {
return nil // Gracefully skip - local-only mode
}
rc, err := beads.GetRepoContext()
if err != nil {
return fmt.Errorf("getting repo context: %w", err)
}
// Get current branch name
// Use symbolic-ref to work in fresh repos without commits
branchCmd := exec.CommandContext(ctx, "git", "symbolic-ref", "--short", "HEAD")
branchCmd := rc.GitCmd(ctx, "symbolic-ref", "--short", "HEAD")
branchOutput, err := branchCmd.Output()
if err != nil {
return fmt.Errorf("failed to get current branch: %w", err)
@@ -387,7 +420,7 @@ func gitPull(ctx context.Context, configuredRemote string) error {
// 3. Fall back to "origin"
remote := configuredRemote
if remote == "" {
remoteCmd := exec.CommandContext(ctx, "git", "config", "--get", fmt.Sprintf("branch.%s.remote", branch)) //nolint:gosec // G204: branch from git symbolic-ref
remoteCmd := rc.GitCmd(ctx, "config", "--get", fmt.Sprintf("branch.%s.remote", branch)) //nolint:gosec // G204: branch from git symbolic-ref
remoteOutput, err := remoteCmd.Output()
if err != nil {
// If no remote configured, default to "origin"
@@ -398,7 +431,7 @@ func gitPull(ctx context.Context, configuredRemote string) error {
}
// Pull with explicit remote and branch
cmd := exec.CommandContext(ctx, "git", "pull", remote, branch) //nolint:gosec // G204: remote/branch from git config, not user input
cmd := rc.GitCmd(ctx, "pull", remote, branch) //nolint:gosec // G204: remote/branch from git config, not user input
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git pull failed: %w\n%s", err, output)
@@ -406,27 +439,33 @@ func gitPull(ctx context.Context, configuredRemote string) error {
return nil
}
// gitPush pushes to the current branch's upstream
// Returns nil if no remote configured (local-only mode)
// gitPush pushes to the current branch's upstream in the beads repository.
// Returns nil if no remote configured (local-only mode).
// If configuredRemote is non-empty, pushes to that remote explicitly.
// This allows respecting the sync.remote bd config.
// Uses RepoContext to ensure git commands run in the correct repository.
func gitPush(ctx context.Context, configuredRemote string) error {
// Check if any remote exists (support local-only repos)
if !hasGitRemote(ctx) {
return nil // Gracefully skip - local-only mode
}
rc, err := beads.GetRepoContext()
if err != nil {
return fmt.Errorf("getting repo context: %w", err)
}
// If configuredRemote is set, push explicitly to that remote with current branch
if configuredRemote != "" {
// Get current branch name
branchCmd := exec.CommandContext(ctx, "git", "symbolic-ref", "--short", "HEAD")
branchCmd := rc.GitCmd(ctx, "symbolic-ref", "--short", "HEAD")
branchOutput, err := branchCmd.Output()
if err != nil {
return fmt.Errorf("failed to get current branch: %w", err)
}
branch := strings.TrimSpace(string(branchOutput))
cmd := exec.CommandContext(ctx, "git", "push", configuredRemote, branch) //nolint:gosec // G204: configuredRemote from bd config
cmd := rc.GitCmd(ctx, "push", configuredRemote, branch) //nolint:gosec // G204: configuredRemote from bd config
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git push failed: %w\n%s", err, output)
@@ -435,7 +474,7 @@ func gitPush(ctx context.Context, configuredRemote string) error {
}
// Default: use git's default push behavior
cmd := exec.CommandContext(ctx, "git", "push")
cmd := rc.GitCmd(ctx, "push")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git push failed: %w\n%s", err, output)
@@ -443,9 +482,17 @@ func gitPush(ctx context.Context, configuredRemote string) error {
return nil
}
// checkMergeDriverConfig checks if the merge driver is misconfigured in the beads repository.
// Uses RepoContext to ensure git commands run in the correct repository.
func checkMergeDriverConfig() {
rc, err := beads.GetRepoContext()
if err != nil {
return // No beads context, skip check
}
ctx := context.Background()
// Get current merge driver configuration
cmd := exec.Command("git", "config", "merge.beads.driver")
cmd := rc.GitCmd(ctx, "config", "merge.beads.driver")
output, err := cmd.Output()
if err != nil {
// No merge driver configured - this is OK, user may not need it
@@ -468,23 +515,23 @@ func checkMergeDriverConfig() {
// restoreBeadsDirFromBranch restores .beads/ directory from the current branch's committed state.
// This is used after sync when sync.branch is configured to keep the working directory clean.
// The actual beads data lives on the sync branch; the main branch's .beads/ is just a snapshot.
// Uses RepoContext to ensure git commands run in the correct repository.
func restoreBeadsDirFromBranch(ctx context.Context) error {
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
return fmt.Errorf("no .beads directory found")
rc, err := beads.GetRepoContext()
if err != nil {
return fmt.Errorf("getting repo context: %w", err)
}
// Skip restore when beads directory is redirected (bd-lmqhe)
// When redirected, the beads directory is in a different repo, so
// git checkout from the current repo won't work for paths outside it.
redirectInfo := beads.GetRedirectInfo()
if redirectInfo.IsRedirected {
if rc.IsRedirected {
return nil
}
// Restore .beads/ from HEAD (current branch's committed state)
// Using -- to ensure .beads/ is treated as a path, not a branch name
cmd := exec.CommandContext(ctx, "git", "checkout", "HEAD", "--", beadsDir) //nolint:gosec // G204: beadsDir from FindBeadsDir(), not user input
cmd := rc.GitCmd(ctx, "checkout", "HEAD", "--", rc.BeadsDir) //nolint:gosec // G204: beadsDir from RepoContext
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git checkout failed: %w\n%s", err, output)
@@ -496,31 +543,18 @@ func restoreBeadsDirFromBranch(ctx context.Context) error {
// This detects the failure mode where a previous sync exported but failed before commit.
// Returns true if the JSONL file has staged or unstaged changes (M or A status).
// GH#885: Pre-flight safety check to detect incomplete sync operations.
// Also handles redirected beads directories (bd-arjb).
// Uses RepoContext to ensure git commands run in the correct repository (handles redirects).
func gitHasUncommittedBeadsChanges(ctx context.Context) (bool, error) {
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
return false, nil // No beads dir, nothing to check
rc, err := beads.GetRepoContext()
if err != nil {
return false, nil // No beads context, nothing to check
}
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
jsonlPath := filepath.Join(rc.BeadsDir, "issues.jsonl")
// Check if beads directory is redirected (bd-arjb)
// When redirected, beadsDir points outside the current repo, so we need to
// run git commands from the directory containing the actual .beads/
redirectInfo := beads.GetRedirectInfo()
if redirectInfo.IsRedirected {
targetRepoDir := filepath.Dir(beadsDir)
cmd := exec.CommandContext(ctx, "git", "-C", targetRepoDir, "status", "--porcelain", jsonlPath) //nolint:gosec // G204: jsonlPath from internal beads.FindBeadsDir()
output, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("git status failed: %w", err)
}
return parseGitStatusForBeadsChanges(string(output)), nil
}
// Check git status for the JSONL file specifically
cmd := exec.CommandContext(ctx, "git", "status", "--porcelain", jsonlPath) //nolint:gosec // G204: jsonlPath from internal beads.FindBeadsDir()
// rc.GitCmd runs in rc.RepoRoot which is the repo containing .beads/
// This works for both normal and redirected scenarios.
cmd := rc.GitCmd(ctx, "status", "--porcelain", jsonlPath)
output, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("git status failed: %w", err)
@@ -552,17 +586,24 @@ func parseGitStatusForBeadsChanges(statusOutput string) bool {
return false
}
// getDefaultBranch returns the default branch name (main or master) for origin remote
// Checks remote HEAD first, then falls back to checking if main/master exist
// getDefaultBranch returns the default branch name (main or master) for origin remote.
// Uses RepoContext to ensure git commands run in the correct repository.
// Checks remote HEAD first, then falls back to checking if main/master exist.
func getDefaultBranch(ctx context.Context) string {
return getDefaultBranchForRemote(ctx, "origin")
}
// getDefaultBranchForRemote returns the default branch name for a specific remote
// Checks remote HEAD first, then falls back to checking if main/master exist
// getDefaultBranchForRemote returns the default branch name for a specific remote in the beads repository.
// Uses RepoContext to ensure git commands run in the correct repository.
// Checks remote HEAD first, then falls back to checking if main/master exist.
func getDefaultBranchForRemote(ctx context.Context, remote string) string {
rc, err := beads.GetRepoContext()
if err != nil {
return "main" // Default fallback if context unavailable
}
// Try to get default branch from remote
cmd := exec.CommandContext(ctx, "git", "symbolic-ref", fmt.Sprintf("refs/remotes/%s/HEAD", remote)) //nolint:gosec // G204: remote from git config
cmd := rc.GitCmd(ctx, "symbolic-ref", fmt.Sprintf("refs/remotes/%s/HEAD", remote)) //nolint:gosec // G204: remote from git config
output, err := cmd.Output()
if err == nil {
ref := strings.TrimSpace(string(output))
@@ -574,12 +615,14 @@ func getDefaultBranchForRemote(ctx context.Context, remote string) string {
}
// Fallback: check if <remote>/main exists
if exec.CommandContext(ctx, "git", "rev-parse", "--verify", fmt.Sprintf("%s/main", remote)).Run() == nil { //nolint:gosec // G204: remote from git config
mainCmd := rc.GitCmd(ctx, "rev-parse", "--verify", fmt.Sprintf("%s/main", remote)) //nolint:gosec // G204: remote from git config
if mainCmd.Run() == nil {
return "main"
}
// Fallback: check if <remote>/master exists
if exec.CommandContext(ctx, "git", "rev-parse", "--verify", fmt.Sprintf("%s/master", remote)).Run() == nil { //nolint:gosec // G204: remote from git config
masterCmd := rc.GitCmd(ctx, "rev-parse", "--verify", fmt.Sprintf("%s/master", remote)) //nolint:gosec // G204: remote from git config
if masterCmd.Run() == nil {
return "master"
}

View File

@@ -7,6 +7,7 @@ import (
"path/filepath"
"testing"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/git"
)
@@ -26,6 +27,7 @@ func setupGitRepoWithBeads(t *testing.T) (repoPath string, cleanup func()) {
}
git.ResetCaches()
beads.ResetCaches()
// Initialize git repo
if err := exec.Command("git", "init", "--initial-branch=main").Run(); err != nil {
@@ -33,6 +35,7 @@ func setupGitRepoWithBeads(t *testing.T) (repoPath string, cleanup func()) {
t.Fatalf("failed to init git repo: %v", err)
}
git.ResetCaches()
beads.ResetCaches()
// Configure git
_ = exec.Command("git", "config", "user.email", "test@test.com").Run()
@@ -61,6 +64,7 @@ func setupGitRepoWithBeads(t *testing.T) (repoPath string, cleanup func()) {
cleanup = func() {
_ = os.Chdir(originalWd)
git.ResetCaches()
beads.ResetCaches()
}
return tmpDir, cleanup
@@ -169,10 +173,12 @@ func setupRedirectedBeadsRepo(t *testing.T) (sourcePath, targetPath string, clea
t.Fatalf("failed to change to source directory: %v", err)
}
git.ResetCaches()
beads.ResetCaches()
cleanup = func() {
_ = os.Chdir(originalWd)
git.ResetCaches()
beads.ResetCaches()
}
return sourcePath, targetPath, cleanup
@@ -468,13 +474,22 @@ func TestGitBranchHasUpstream(t *testing.T) {
}
}
// Create .beads directory (required for RepoContext)
beadsDir := filepath.Join(localDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
if err := os.WriteFile(filepath.Join(beadsDir, "issues.jsonl"), []byte{}, 0644); err != nil {
t.Fatalf("Failed to write issues.jsonl: %v", err)
}
// Create initial commit on main
testFile := filepath.Join(localDir, "test.txt")
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
cmds = [][]string{
{"git", "add", "test.txt"},
{"git", "add", "."},
{"git", "commit", "-m", "initial"},
{"git", "push", "-u", "origin", "main"},
}
@@ -504,7 +519,14 @@ func TestGitBranchHasUpstream(t *testing.T) {
if err := os.Chdir(localDir); err != nil {
t.Fatalf("Failed to chdir: %v", err)
}
defer os.Chdir(origDir)
// Reset caches after changing directory so RepoContext uses this repo
git.ResetCaches()
beads.ResetCaches()
defer func() {
os.Chdir(origDir)
git.ResetCaches()
beads.ResetCaches()
}()
// Test 1: beads-sync branch should have upstream
t.Run("branch with upstream returns true", func(t *testing.T) {

View File

@@ -29,6 +29,40 @@ func TestBuildGitCommitArgs_ConfigOptions(t *testing.T) {
if !strings.Contains(joined, "-m hello") {
t.Fatalf("expected message in args: %v", args)
}
// buildGitCommitArgs includes -C for raw exec.Command
if !strings.HasPrefix(joined, "-C /repo commit") {
t.Fatalf("expected -C /repo prefix in args: %v", args)
}
}
func TestBuildCommitArgs_ForRepoContext(t *testing.T) {
// buildCommitArgs is for use with RepoContext.GitCmd() which sets cmd.Dir,
// so it should NOT include the -C flag.
if err := config.Initialize(); err != nil {
t.Fatalf("config.Initialize: %v", err)
}
config.Set("git.author", "Test User <test@example.com>")
config.Set("git.no-gpg-sign", true)
args := buildCommitArgs("hello", "--", ".beads")
joined := strings.Join(args, " ")
// Should start with "commit", not "-C"
if !strings.HasPrefix(joined, "commit") {
t.Fatalf("expected to start with 'commit', got: %v", args)
}
if strings.Contains(joined, "-C") {
t.Fatalf("buildCommitArgs should NOT contain -C (RepoContext sets cmd.Dir): %v", args)
}
if !strings.Contains(joined, "--author") {
t.Fatalf("expected --author in args: %v", args)
}
if !strings.Contains(joined, "--no-gpg-sign") {
t.Fatalf("expected --no-gpg-sign in args: %v", args)
}
if !strings.Contains(joined, "-m hello") {
t.Fatalf("expected message in args: %v", args)
}
}
func TestGitCommitBeadsDir_PathspecDoesNotCommitOtherStagedFiles(t *testing.T) {

View File

@@ -3,9 +3,11 @@ package main
import (
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/git"
)
@@ -24,7 +26,7 @@ func waitFor(t *testing.T, timeout, poll time.Duration, pred func() bool) {
}
// setupGitRepo creates a temporary git repository and returns its path and cleanup function.
// The repo is initialized with git config and an initial commit.
// The repo is initialized with git config, a .beads directory, and an initial commit.
// The current directory is changed to the new repo.
func setupGitRepo(t *testing.T) (repoPath string, cleanup func()) {
t.Helper()
@@ -39,8 +41,9 @@ func setupGitRepo(t *testing.T) (repoPath string, cleanup func()) {
t.Fatalf("failed to change to temp directory: %v", err)
}
// Reset git caches after changing directory
// Reset caches after changing directory
git.ResetCaches()
beads.ResetCaches()
// Initialize git repo with 'main' as default branch (modern git convention)
if err := exec.Command("git", "init", "--initial-branch=main").Run(); err != nil {
@@ -48,17 +51,29 @@ func setupGitRepo(t *testing.T) (repoPath string, cleanup func()) {
t.Fatalf("failed to init git repo: %v", err)
}
git.ResetCaches()
beads.ResetCaches()
// Configure git
_ = exec.Command("git", "config", "user.email", "test@test.com").Run()
_ = exec.Command("git", "config", "user.name", "Test User").Run()
// Create .beads directory with minimal issues.jsonl (required for RepoContext)
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
_ = os.Chdir(originalWd)
t.Fatalf("failed to create .beads directory: %v", err)
}
if err := os.WriteFile(filepath.Join(beadsDir, "issues.jsonl"), []byte{}, 0600); err != nil {
_ = os.Chdir(originalWd)
t.Fatalf("failed to write issues.jsonl: %v", err)
}
// Create initial commit
if err := os.WriteFile("test.txt", []byte("test"), 0600); err != nil {
_ = os.Chdir(originalWd)
t.Fatalf("failed to write test file: %v", err)
}
_ = exec.Command("git", "add", "test.txt").Run()
_ = exec.Command("git", "add", ".").Run()
if err := exec.Command("git", "commit", "-m", "initial").Run(); err != nil {
_ = os.Chdir(originalWd)
t.Fatalf("failed to create initial commit: %v", err)
@@ -67,6 +82,7 @@ func setupGitRepo(t *testing.T) (repoPath string, cleanup func()) {
cleanup = func() {
_ = os.Chdir(originalWd)
git.ResetCaches()
beads.ResetCaches()
}
return tmpDir, cleanup
@@ -87,8 +103,9 @@ func setupGitRepoWithBranch(t *testing.T, branch string) (repoPath string, clean
t.Fatalf("failed to change to temp directory: %v", err)
}
// Reset git caches after changing directory
// Reset caches after changing directory
git.ResetCaches()
beads.ResetCaches()
// Initialize git repo with specific branch
if err := exec.Command("git", "init", "-b", branch).Run(); err != nil {
@@ -96,17 +113,29 @@ func setupGitRepoWithBranch(t *testing.T, branch string) (repoPath string, clean
t.Fatalf("failed to init git repo: %v", err)
}
git.ResetCaches()
beads.ResetCaches()
// Configure git
_ = exec.Command("git", "config", "user.email", "test@test.com").Run()
_ = exec.Command("git", "config", "user.name", "Test User").Run()
// Create .beads directory with minimal issues.jsonl (required for RepoContext)
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
_ = os.Chdir(originalWd)
t.Fatalf("failed to create .beads directory: %v", err)
}
if err := os.WriteFile(filepath.Join(beadsDir, "issues.jsonl"), []byte{}, 0600); err != nil {
_ = os.Chdir(originalWd)
t.Fatalf("failed to write issues.jsonl: %v", err)
}
// Create initial commit
if err := os.WriteFile("test.txt", []byte("test"), 0600); err != nil {
_ = os.Chdir(originalWd)
t.Fatalf("failed to write test file: %v", err)
}
_ = exec.Command("git", "add", "test.txt").Run()
_ = exec.Command("git", "add", ".").Run()
if err := exec.Command("git", "commit", "-m", "initial").Run(); err != nil {
_ = os.Chdir(originalWd)
t.Fatalf("failed to create initial commit: %v", err)
@@ -115,6 +144,7 @@ func setupGitRepoWithBranch(t *testing.T, branch string) (repoPath string, clean
cleanup = func() {
_ = os.Chdir(originalWd)
git.ResetCaches()
beads.ResetCaches()
}
return tmpDir, cleanup
@@ -135,8 +165,9 @@ func setupMinimalGitRepo(t *testing.T) (repoPath string, cleanup func()) {
t.Fatalf("failed to change to temp directory: %v", err)
}
// Reset git caches after changing directory
// Reset caches after changing directory
git.ResetCaches()
beads.ResetCaches()
// Initialize git repo with 'main' as default branch (modern git convention)
if err := exec.Command("git", "init", "--initial-branch=main").Run(); err != nil {
@@ -151,6 +182,7 @@ func setupMinimalGitRepo(t *testing.T) (repoPath string, cleanup func()) {
cleanup = func() {
_ = os.Chdir(originalWd)
git.ResetCaches()
beads.ResetCaches()
}
return tmpDir, cleanup

View File

@@ -1,9 +1,9 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"runtime/debug"
"strings"
@@ -152,11 +152,13 @@ func resolveBranch() string {
// Fallback: try to get branch from git at runtime
// Use symbolic-ref to work in fresh repos without commits
cmd := exec.Command("git", "symbolic-ref", "--short", "HEAD")
cmd.Dir = "."
if output, err := cmd.Output(); err == nil {
if branch := strings.TrimSpace(string(output)); branch != "" && branch != "HEAD" {
return branch
// Uses CWD repo context since this shows user's current branch
if rc, err := beads.GetRepoContext(); err == nil {
cmd := rc.GitCmdCWD(context.Background(), "symbolic-ref", "--short", "HEAD")
if output, err := cmd.Output(); err == nil {
if branch := strings.TrimSpace(string(output)); branch != "" && branch != "HEAD" {
return branch
}
}
}

View File

@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
@@ -12,6 +13,7 @@ import (
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/utils"
)
// WorktreeInfo contains information about a git worktree
@@ -139,6 +141,7 @@ func init() {
func runWorktreeCreate(cmd *cobra.Command, args []string) error {
CheckReadonly("worktree create")
ctx := context.Background()
name := args[0]
@@ -153,17 +156,20 @@ func runWorktreeCreate(cmd *cobra.Command, args []string) error {
return fmt.Errorf("path already exists: %s", worktreePath)
}
// Find main repository root
repoRoot := git.GetRepoRoot()
// Get repository context (validates .beads exists and resolves paths)
rc, err := beads.GetRepoContext()
if err != nil {
return fmt.Errorf("no .beads directory found; run 'bd init' first: %w", err)
}
// Worktree operations use CWD repo (where user is working), not BEADS_DIR repo
repoRoot := rc.CWDRepoRoot
if repoRoot == "" {
return fmt.Errorf("not in a git repository")
}
// Find main beads directory
mainBeadsDir := beads.FindBeadsDir()
if mainBeadsDir == "" {
return fmt.Errorf("no .beads directory found; run 'bd init' first")
}
// Use BeadsDir from RepoContext (already follows redirects)
mainBeadsDir := rc.BeadsDir
// Determine branch name
branch := worktreeBranch
@@ -171,16 +177,12 @@ func runWorktreeCreate(cmd *cobra.Command, args []string) error {
branch = filepath.Base(name)
}
// Create the worktree
// #nosec G204 - branch and worktreePath are user input but git validates them
gitCmd := exec.Command("git", "worktree", "add", "-b", branch, worktreePath)
gitCmd.Dir = repoRoot
// Create the worktree using secure git command
gitCmd := gitCmdInDir(ctx, repoRoot, "worktree", "add", "-b", branch, worktreePath)
output, err := gitCmd.CombinedOutput()
if err != nil {
// Try without -b if branch already exists
// #nosec G204 - branch and worktreePath are user input but git validates them
gitCmd = exec.Command("git", "worktree", "add", worktreePath, branch)
gitCmd.Dir = repoRoot
gitCmd = gitCmdInDir(ctx, repoRoot, "worktree", "add", worktreePath, branch)
output, err = gitCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to create worktree: %w\n%s", err, string(output))
@@ -189,8 +191,8 @@ func runWorktreeCreate(cmd *cobra.Command, args []string) error {
// Helper to clean up worktree on failure
cleanupWorktree := func() {
// #nosec G204 - worktreePath was created by us above
_ = exec.Command("git", "worktree", "remove", "--force", worktreePath).Run()
cleanupCmd := gitCmdInDir(ctx, repoRoot, "worktree", "remove", "--force", worktreePath)
_ = cleanupCmd.Run()
}
// Create .beads directory in worktree
@@ -202,10 +204,13 @@ func runWorktreeCreate(cmd *cobra.Command, args []string) error {
// Create redirect file
redirectPath := filepath.Join(worktreeBeadsDir, beads.RedirectFileName)
relPath, err := filepath.Rel(worktreeBeadsDir, mainBeadsDir)
// Ensure mainBeadsDir is absolute for correct filepath.Rel() computation (GH#1098)
// beads.FindBeadsDir() may return a relative path in some contexts
absMainBeadsDir := utils.CanonicalizeIfRelative(mainBeadsDir)
relPath, err := filepath.Rel(worktreeBeadsDir, absMainBeadsDir)
if err != nil {
// Fall back to absolute path
relPath = mainBeadsDir
relPath = absMainBeadsDir
}
// #nosec G306 - redirect file needs to be readable
if err := os.WriteFile(redirectPath, []byte(relPath+"\n"), 0644); err != nil {
@@ -244,15 +249,28 @@ func runWorktreeCreate(cmd *cobra.Command, args []string) error {
}
func runWorktreeList(cmd *cobra.Command, args []string) error {
// Get repository root
repoRoot := git.GetRepoRoot()
ctx := context.Background()
// Get repository context
rc, err := beads.GetRepoContext()
if err != nil {
// Allow listing worktrees even without .beads (but no beads state info)
// Fall back to git.GetRepoRoot() for this case
repoRoot := git.GetRepoRoot()
if repoRoot == "" {
return fmt.Errorf("not in a git repository")
}
return listWorktreesWithoutBeads(ctx, repoRoot)
}
// Worktree operations use CWD repo (where user is working)
repoRoot := rc.CWDRepoRoot
if repoRoot == "" {
return fmt.Errorf("not in a git repository")
}
// List worktrees
gitCmd := exec.Command("git", "worktree", "list", "--porcelain")
gitCmd.Dir = repoRoot
// List worktrees using secure git command
gitCmd := gitCmdInDir(ctx, repoRoot, "worktree", "list", "--porcelain")
output, err := gitCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to list worktrees: %w", err)
@@ -261,8 +279,8 @@ func runWorktreeList(cmd *cobra.Command, args []string) error {
// Parse worktree list
worktrees := parseWorktreeList(string(output))
// Enrich with beads state
mainBeadsDir := beads.FindBeadsDir()
// Enrich with beads state (using BeadsDir from RepoContext)
mainBeadsDir := rc.BeadsDir
for i := range worktrees {
worktrees[i].BeadsState = getBeadsState(worktrees[i].Path, mainBeadsDir)
if worktrees[i].BeadsState == "redirect" {
@@ -304,17 +322,26 @@ func runWorktreeList(cmd *cobra.Command, args []string) error {
func runWorktreeRemove(cmd *cobra.Command, args []string) error {
CheckReadonly("worktree remove")
ctx := context.Background()
name := args[0]
// Find the worktree
repoRoot := git.GetRepoRoot()
// Get repository context - worktree remove works even without .beads
// but we try RepoContext first for consistency
var repoRoot string
rc, err := beads.GetRepoContext()
if err != nil {
// Fallback to git.GetRepoRoot() if no .beads
repoRoot = git.GetRepoRoot()
} else {
repoRoot = rc.CWDRepoRoot
}
if repoRoot == "" {
return fmt.Errorf("not in a git repository")
}
// Resolve worktree path
worktreePath, err := resolveWorktreePath(repoRoot, name)
worktreePath, err := resolveWorktreePath(ctx, repoRoot, name)
if err != nil {
return err
}
@@ -328,19 +355,18 @@ func runWorktreeRemove(cmd *cobra.Command, args []string) error {
// Safety checks unless --force
if !worktreeForce {
if err := checkWorktreeSafety(worktreePath); err != nil {
if err := checkWorktreeSafety(ctx, worktreePath); err != nil {
return fmt.Errorf("safety check failed: %w\nUse --force to skip safety checks", err)
}
}
// Remove worktree
// #nosec G204 - worktreePath is validated above
gitCmd := exec.Command("git", "worktree", "remove", worktreePath)
// Remove worktree using secure git command
var gitCmd *exec.Cmd
if worktreeForce {
// #nosec G204 - worktreePath is validated above
gitCmd = exec.Command("git", "worktree", "remove", "--force", worktreePath)
gitCmd = gitCmdInDir(ctx, repoRoot, "worktree", "remove", "--force", worktreePath)
} else {
gitCmd = gitCmdInDir(ctx, repoRoot, "worktree", "remove", worktreePath)
}
gitCmd.Dir = repoRoot
output, err := gitCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to remove worktree: %w\n%s", err, string(output))
@@ -370,13 +396,22 @@ func runWorktreeRemove(cmd *cobra.Command, args []string) error {
}
func runWorktreeInfo(cmd *cobra.Command, args []string) error {
ctx := context.Background()
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
// Check if we're in a worktree
if !git.IsWorktree() {
// Check if we're in a worktree (use RepoContext if available, fallback to git)
var isWorktree bool
rc, rcErr := beads.GetRepoContext()
if rcErr == nil {
isWorktree = rc.IsWorktree
} else {
isWorktree = git.IsWorktree()
}
if !isWorktree {
if jsonOutput {
result := map[string]interface{}{
"is_worktree": false,
@@ -395,7 +430,7 @@ func runWorktreeInfo(cmd *cobra.Command, args []string) error {
mainRepoRoot = "(unknown)"
}
branch := getWorktreeCurrentBranch()
branch := getWorktreeCurrentBranch(ctx, cwd)
redirectInfo := beads.GetRedirectInfo()
if jsonOutput {
@@ -431,6 +466,67 @@ func runWorktreeInfo(cmd *cobra.Command, args []string) error {
// Helper functions
// gitCmdInDir creates a git command that runs in the specified directory.
// This is used for worktree operations that need to run in a specific location
// (either the CWD repo root or a specific worktree path).
//
// Security: Sets GIT_HOOKS_PATH and GIT_TEMPLATE_DIR to disable hooks/templates
// for defense-in-depth, matching the pattern in RepoContext.GitCmd().
func gitCmdInDir(ctx context.Context, dir string, args ...string) *exec.Cmd {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = dir
// Security: Disable git hooks and templates (SEC-001, SEC-002)
cmd.Env = append(os.Environ(),
"GIT_HOOKS_PATH=",
"GIT_TEMPLATE_DIR=",
)
return cmd
}
// listWorktreesWithoutBeads lists worktrees when no .beads directory exists.
// This fallback allows the command to work in repos that haven't been initialized.
func listWorktreesWithoutBeads(ctx context.Context, repoRoot string) error {
gitCmd := gitCmdInDir(ctx, repoRoot, "worktree", "list", "--porcelain")
output, err := gitCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to list worktrees: %w", err)
}
worktrees := parseWorktreeList(string(output))
// Set beads state to "none" for all worktrees
for i := range worktrees {
worktrees[i].BeadsState = "none"
}
if jsonOutput {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(worktrees)
}
// Human-readable output
if len(worktrees) == 0 {
fmt.Println("No worktrees found")
return nil
}
fmt.Printf("%-20s %-40s %-20s %s\n", "NAME", "PATH", "BRANCH", "BEADS")
for _, wt := range worktrees {
name := filepath.Base(wt.Path)
if wt.IsMain {
name = "(main)"
}
fmt.Printf("%-20s %-40s %-20s %s\n",
truncate(name, 20),
truncate(wt.Path, 40),
truncate(wt.Branch, 20),
"none")
}
return nil
}
func parseWorktreeList(output string) []WorktreeInfo {
var worktrees []WorktreeInfo
var current WorktreeInfo
@@ -503,7 +599,7 @@ func getRedirectTarget(worktreePath string) string {
return target
}
func resolveWorktreePath(repoRoot, name string) (string, error) {
func resolveWorktreePath(ctx context.Context, repoRoot, name string) (string, error) {
// Try as absolute path first
if filepath.IsAbs(name) {
if _, err := os.Stat(name); err == nil {
@@ -526,9 +622,7 @@ func resolveWorktreePath(repoRoot, name string) (string, error) {
// Consult git's worktree registry - match by name (basename) or path
// This handles worktrees created in subdirectories (e.g., .worktrees/foo)
// where the name shown in "bd worktree list" doesn't match a simple path
// #nosec G204 - repoRoot comes from git.GetRepoRoot()
gitCmd := exec.Command("git", "worktree", "list", "--porcelain")
gitCmd.Dir = repoRoot
gitCmd := gitCmdInDir(ctx, repoRoot, "worktree", "list", "--porcelain")
output, err := gitCmd.CombinedOutput()
if err == nil {
worktrees := parseWorktreeList(string(output))
@@ -544,10 +638,9 @@ func resolveWorktreePath(repoRoot, name string) (string, error) {
return "", fmt.Errorf("worktree not found: %s", name)
}
func checkWorktreeSafety(worktreePath string) error {
func checkWorktreeSafety(ctx context.Context, worktreePath string) error {
// Check for uncommitted changes
gitCmd := exec.Command("git", "status", "--porcelain")
gitCmd.Dir = worktreePath
gitCmd := gitCmdInDir(ctx, worktreePath, "status", "--porcelain")
output, err := gitCmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to check git status: %w", err)
@@ -557,8 +650,7 @@ func checkWorktreeSafety(worktreePath string) error {
}
// Check for unpushed commits
gitCmd = exec.Command("git", "log", "@{upstream}..", "--oneline")
gitCmd.Dir = worktreePath
gitCmd = gitCmdInDir(ctx, worktreePath, "log", "@{upstream}..", "--oneline")
output, _ = gitCmd.CombinedOutput() // Ignore error (no upstream is ok)
if len(strings.TrimSpace(string(output))) > 0 {
return fmt.Errorf("worktree has unpushed commits")
@@ -571,8 +663,9 @@ func checkWorktreeSafety(worktreePath string) error {
return nil
}
func getWorktreeCurrentBranch() string {
output, err := exec.Command("git", "branch", "--show-current").CombinedOutput()
func getWorktreeCurrentBranch(ctx context.Context, dir string) string {
gitCmd := gitCmdInDir(ctx, dir, "branch", "--show-current")
output, err := gitCmd.CombinedOutput()
if err != nil {
return "(unknown)"
}

213
cmd/bd/worktree_cmd_test.go Normal file
View File

@@ -0,0 +1,213 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/steveyegge/beads/internal/utils"
)
// TestWorktreeRedirectDepth tests that worktree redirect paths are computed correctly
// for different worktree directory depths. This is the fix for GH#1098.
//
// The redirect file contains a relative path from the worktree's .beads directory
// to the main repository's .beads directory. The depth of ../ components depends
// on how deeply nested the worktree is.
func TestWorktreeRedirectDepth(t *testing.T) {
// Create a temporary repo structure
tmpDir := t.TempDir()
// Main repo's .beads directory
mainBeadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(mainBeadsDir, 0755); err != nil {
t.Fatalf("failed to create main .beads dir: %v", err)
}
tests := []struct {
name string
worktreePath string // Relative to tmpDir
expectedRelPrefix string // Expected prefix (number of ../)
}{
{
name: "depth 1: .worktrees/foo",
worktreePath: ".worktrees/foo",
expectedRelPrefix: "../../",
},
{
name: "depth 2: .worktrees/a/b",
worktreePath: ".worktrees/a/b",
expectedRelPrefix: "../../../",
},
{
name: "depth 3: .worktrees/a/b/c",
worktreePath: ".worktrees/a/b/c",
expectedRelPrefix: "../../../../",
},
{
name: "sibling worktree: agents/worker1",
worktreePath: "agents/worker1",
expectedRelPrefix: "../../",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create worktree .beads directory
worktreeDir := filepath.Join(tmpDir, tt.worktreePath)
worktreeBeadsDir := filepath.Join(worktreeDir, ".beads")
if err := os.MkdirAll(worktreeBeadsDir, 0755); err != nil {
t.Fatalf("failed to create worktree .beads dir: %v", err)
}
defer os.RemoveAll(worktreeDir)
// Simulate the worktree redirect computation from worktree_cmd.go:205-213
// absMainBeadsDir := utils.CanonicalizeIfRelative(mainBeadsDir)
// relPath, err := filepath.Rel(worktreeBeadsDir, absMainBeadsDir)
absMainBeadsDir := utils.CanonicalizeIfRelative(mainBeadsDir)
relPath, err := filepath.Rel(worktreeBeadsDir, absMainBeadsDir)
if err != nil {
t.Fatalf("filepath.Rel() failed: %v", err)
}
// Verify the relative path starts with the expected ../ prefix
if !strings.HasPrefix(relPath, tt.expectedRelPrefix) {
t.Errorf("expected relPath to start with %q, got %q", tt.expectedRelPrefix, relPath)
}
// Verify the relative path ends with .beads
if !strings.HasSuffix(relPath, ".beads") {
t.Errorf("expected relPath to end with .beads, got %q", relPath)
}
// Verify the path actually resolves correctly
resolvedPath := filepath.Join(worktreeBeadsDir, relPath)
resolvedPath = filepath.Clean(resolvedPath)
canonicalMain := utils.CanonicalizePath(mainBeadsDir)
canonicalResolved := utils.CanonicalizePath(resolvedPath)
if canonicalResolved != canonicalMain {
t.Errorf("resolved path mismatch:\n expected: %s\n got: %s", canonicalMain, canonicalResolved)
}
})
}
}
// TestWorktreeRedirectWithRelativeMainBeadsDir tests that worktree redirect
// works correctly even when mainBeadsDir is returned as a relative path.
// This ensures CanonicalizeIfRelative() is being used properly.
func TestWorktreeRedirectWithRelativeMainBeadsDir(t *testing.T) {
// Create a temporary repo structure
tmpDir := t.TempDir()
// Main repo's .beads directory
mainBeadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(mainBeadsDir, 0755); err != nil {
t.Fatalf("failed to create main .beads dir: %v", err)
}
// Create worktree
worktreeDir := filepath.Join(tmpDir, ".worktrees", "test-wt")
worktreeBeadsDir := filepath.Join(worktreeDir, ".beads")
if err := os.MkdirAll(worktreeBeadsDir, 0755); err != nil {
t.Fatalf("failed to create worktree .beads dir: %v", err)
}
// Change to tmpDir to simulate relative path scenario
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
defer os.Chdir(origDir)
// Test with RELATIVE mainBeadsDir (as it might be returned by beads.FindBeadsDir())
relativeMainBeadsDir := ".beads"
// The fix: CanonicalizeIfRelative ensures the path is absolute
absMainBeadsDir := utils.CanonicalizeIfRelative(relativeMainBeadsDir)
// Verify it's now absolute
if !filepath.IsAbs(absMainBeadsDir) {
t.Errorf("CanonicalizeIfRelative should return absolute path, got %q", absMainBeadsDir)
}
// Compute relative path from worktree's .beads to main .beads
relPath, err := filepath.Rel(worktreeBeadsDir, absMainBeadsDir)
if err != nil {
t.Fatalf("filepath.Rel() failed: %v", err)
}
// Verify the path looks correct (should be ../../.beads)
if !strings.HasPrefix(relPath, "../../") {
t.Errorf("expected relPath to start with ../../, got %q", relPath)
}
// Verify resolution works
resolvedPath := filepath.Clean(filepath.Join(worktreeBeadsDir, relPath))
canonicalMain := utils.CanonicalizePath(mainBeadsDir)
canonicalResolved := utils.CanonicalizePath(resolvedPath)
if canonicalResolved != canonicalMain {
t.Errorf("resolved path mismatch:\n expected: %s\n got: %s", canonicalMain, canonicalResolved)
}
}
// TestWorktreeRedirectWithoutFix demonstrates what would happen without
// the CanonicalizeIfRelative fix. This documents the bug behavior.
func TestWorktreeRedirectWithoutFix(t *testing.T) {
tmpDir := t.TempDir()
// Main repo's .beads directory
mainBeadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(mainBeadsDir, 0755); err != nil {
t.Fatalf("failed to create main .beads dir: %v", err)
}
// Create worktree
worktreeDir := filepath.Join(tmpDir, ".worktrees", "test-wt")
worktreeBeadsDir := filepath.Join(worktreeDir, ".beads")
if err := os.MkdirAll(worktreeBeadsDir, 0755); err != nil {
t.Fatalf("failed to create worktree .beads dir: %v", err)
}
// Change to tmpDir
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
defer os.Chdir(origDir)
// Bug scenario: relative mainBeadsDir WITHOUT CanonicalizeIfRelative
relativeMainBeadsDir := ".beads"
// filepath.Rel with relative base path produces INCORRECT results
relPathBuggy, err := filepath.Rel(worktreeBeadsDir, relativeMainBeadsDir)
if err != nil {
// This might error, which is also a bug symptom
t.Logf("filepath.Rel() failed with relative base: %v (expected behavior)", err)
return
}
// The buggy relPath will be something like "../../../.beads" when it should be "../../.beads"
// or it might be completely wrong depending on the relative path interpretation
t.Logf("Buggy relPath (without fix): %q", relPathBuggy)
// The path likely won't resolve correctly
resolvedBuggy := filepath.Clean(filepath.Join(worktreeBeadsDir, relPathBuggy))
canonicalMain := utils.CanonicalizePath(mainBeadsDir)
canonicalBuggy := utils.CanonicalizePath(resolvedBuggy)
// Document that the bug exists (or doesn't, if Go handles it)
if canonicalBuggy != canonicalMain {
t.Logf("Bug confirmed: buggy path %q != expected %q", canonicalBuggy, canonicalMain)
} else {
t.Logf("Note: filepath.Rel handled relative base correctly in this case")
}
}

View File

@@ -1,6 +1,7 @@
package main
import (
"context"
"os"
"os/exec"
"path/filepath"
@@ -121,9 +122,11 @@ func TestResolveWorktreePathByName(t *testing.T) {
_ = cmd.Run()
}()
ctx := context.Background()
t.Run("resolves by name when worktree is in subdirectory", func(t *testing.T) {
// This should find the worktree by consulting git's registry
resolved, err := resolveWorktreePath(mainDir, "test-wt")
resolved, err := resolveWorktreePath(ctx, mainDir, "test-wt")
if err != nil {
t.Errorf("resolveWorktreePath(repoRoot, \"test-wt\") failed: %v", err)
return
@@ -138,7 +141,7 @@ func TestResolveWorktreePathByName(t *testing.T) {
t.Run("resolves by relative path", func(t *testing.T) {
// This should work via the existing relative-to-repo-root logic
resolved, err := resolveWorktreePath(mainDir, ".worktrees/test-wt")
resolved, err := resolveWorktreePath(ctx, mainDir, ".worktrees/test-wt")
if err != nil {
t.Errorf("resolveWorktreePath(repoRoot, \".worktrees/test-wt\") failed: %v", err)
return
@@ -149,7 +152,7 @@ func TestResolveWorktreePathByName(t *testing.T) {
})
t.Run("resolves by absolute path", func(t *testing.T) {
resolved, err := resolveWorktreePath(mainDir, worktreePath)
resolved, err := resolveWorktreePath(ctx, mainDir, worktreePath)
if err != nil {
t.Errorf("resolveWorktreePath(repoRoot, absolutePath) failed: %v", err)
return
@@ -160,7 +163,7 @@ func TestResolveWorktreePathByName(t *testing.T) {
})
t.Run("returns error for non-existent worktree", func(t *testing.T) {
_, err := resolveWorktreePath(mainDir, "non-existent")
_, err := resolveWorktreePath(ctx, mainDir, "non-existent")
if err == nil {
t.Error("resolveWorktreePath should return error for non-existent worktree")
}