diff --git a/cmd/bd/autoimport.go b/cmd/bd/autoimport.go index c0775d0a..1d2d6b34 100644 --- a/cmd/bd/autoimport.go +++ b/cmd/bd/autoimport.go @@ -21,13 +21,20 @@ import ( "gopkg.in/yaml.v3" ) -// readFromGitRef reads file content from a git ref (branch or commit). +// readFromGitRef reads file content from a git ref (branch or commit) in the beads repo. // Returns the raw bytes from git show :. // The filePath is automatically converted to forward slashes for Windows compatibility. // Returns nil, err if the git command fails (e.g., file not found in ref). +// GH#1110: Now uses RepoContext to ensure git commands run in beads repo. func readFromGitRef(filePath, gitRef string) ([]byte, error) { gitPath := filepath.ToSlash(filePath) - cmd := exec.Command("git", "show", fmt.Sprintf("%s:%s", gitRef, gitPath)) // #nosec G204 - git command with safe args + var cmd *exec.Cmd + if rc, err := beads.GetRepoContext(); err == nil { + cmd = rc.GitCmd(context.Background(), "show", fmt.Sprintf("%s:%s", gitRef, gitPath)) + } else { + // Fallback to CWD for tests or repos without beads + cmd = exec.Command("git", "show", fmt.Sprintf("%s:%s", gitRef, gitPath)) // #nosec G204 - git command with safe args + } output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("failed to read from git: %w", err) @@ -135,8 +142,14 @@ func checkGitForIssues() (int, string, string) { // Check if the sync branch exists (locally or on remote) // Try origin/ first (more likely to exist in fresh clones), // then local + // GH#1110: Use RepoContext to ensure we check the beads repo for _, ref := range []string{"origin/" + syncBranch, syncBranch} { - cmd := exec.Command("git", "rev-parse", "--verify", "--quiet", ref) // #nosec G204 + var cmd *exec.Cmd + if rc, err := beads.GetRepoContext(); err == nil { + cmd = rc.GitCmd(context.Background(), "rev-parse", "--verify", "--quiet", ref) + } else { + cmd = exec.Command("git", "rev-parse", "--verify", "--quiet", ref) // #nosec G204 + } if err := cmd.Run(); err == nil { gitRef = ref break diff --git a/cmd/bd/import.go b/cmd/bd/import.go index b132a36b..ddf8bf8e 100644 --- a/cmd/bd/import.go +++ b/cmd/bd/import.go @@ -14,6 +14,7 @@ import ( "time" "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/debug" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" @@ -635,20 +636,27 @@ func countLinesInGitHEAD(filePath string, workDir string) int { return lines } -// attemptAutoMerge attempts to resolve git conflicts using bd merge 3-way merge +// attemptAutoMerge attempts to resolve git conflicts using bd merge 3-way merge. +// GH#1110: Now uses RepoContext to ensure we operate on the beads repo. func attemptAutoMerge(conflictedPath string) error { // Validate inputs if conflictedPath == "" { return fmt.Errorf("no file path provided for merge") } - // Get git repository root - gitRootCmd := exec.Command("git", "rev-parse", "--show-toplevel") // #nosec G204 -- fixed git invocation for repo root discovery - gitRootOutput, err := gitRootCmd.Output() - if err != nil { - return fmt.Errorf("not in a git repository: %w", err) + // Get git repository root from RepoContext + var gitRoot string + if rc, err := beads.GetRepoContext(); err == nil { + gitRoot = rc.RepoRoot + } else { + // Fallback to CWD-based lookup + gitRootCmd := exec.Command("git", "rev-parse", "--show-toplevel") // #nosec G204 -- fixed git invocation for repo root discovery + gitRootOutput, err := gitRootCmd.Output() + if err != nil { + return fmt.Errorf("not in a git repository: %w", err) + } + gitRoot = strings.TrimSpace(string(gitRootOutput)) } - gitRoot := strings.TrimSpace(string(gitRootOutput)) // Convert conflicted path to absolute path relative to git root absConflictedPath := conflictedPath diff --git a/cmd/bd/reinit_test.go b/cmd/bd/reinit_test.go index 5f2bdb7d..404baab8 100644 --- a/cmd/bd/reinit_test.go +++ b/cmd/bd/reinit_test.go @@ -9,6 +9,7 @@ import ( "runtime" "testing" + "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/git" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" @@ -41,6 +42,7 @@ func TestDatabaseReinitialization(t *testing.T) { // testFreshCloneAutoImport verifies auto-import works on fresh clone func testFreshCloneAutoImport(t *testing.T) { + beads.ResetCaches() // Reset cached RepoContext between subtests dir := t.TempDir() // Initialize git repo @@ -124,6 +126,7 @@ func testFreshCloneAutoImport(t *testing.T) { // testDatabaseRemovalScenario tests the primary bug scenario func testDatabaseRemovalScenario(t *testing.T) { + beads.ResetCaches() // Reset cached RepoContext between subtests dir := t.TempDir() // Initialize git repo @@ -225,6 +228,7 @@ func testDatabaseRemovalScenario(t *testing.T) { // testLegacyFilenameSupport tests issues.jsonl fallback func testLegacyFilenameSupport(t *testing.T) { + beads.ResetCaches() // Reset cached RepoContext between subtests dir := t.TempDir() // Initialize git repo @@ -303,6 +307,7 @@ func testLegacyFilenameSupport(t *testing.T) { // testPrecedenceTest verifies issues.jsonl is preferred over beads.jsonl func testPrecedenceTest(t *testing.T) { + beads.ResetCaches() // Reset cached RepoContext between subtests dir := t.TempDir() // Initialize git repo @@ -358,6 +363,7 @@ func testPrecedenceTest(t *testing.T) { // testInitSafetyCheck tests the safety check that prevents silent data loss func testInitSafetyCheck(t *testing.T) { + beads.ResetCaches() // Reset cached RepoContext between subtests dir := t.TempDir() // Initialize git repo diff --git a/cmd/bd/status.go b/cmd/bd/status.go index c82fc937..e8c4902e 100644 --- a/cmd/bd/status.go +++ b/cmd/bd/status.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "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/types" "github.com/steveyegge/beads/internal/ui" ) @@ -178,7 +180,8 @@ Examples: }, } -// getGitActivity calculates activity stats from git log of issues.jsonl +// getGitActivity calculates activity stats from git log of issues.jsonl. +// GH#1110: Now uses RepoContext to ensure git commands run in beads repo. func getGitActivity(hours int) *RecentActivitySummary { activity := &RecentActivitySummary{ HoursTracked: hours, @@ -186,7 +189,12 @@ func getGitActivity(hours int) *RecentActivitySummary { // Run git log to get patches for the last N hours since := fmt.Sprintf("%d hours ago", hours) - cmd := exec.Command("git", "log", "--since="+since, "--numstat", "--pretty=format:%H", ".beads/issues.jsonl") // #nosec G204 -- bounded arguments for local git history inspection + var cmd *exec.Cmd + if rc, err := beads.GetRepoContext(); err == nil { + cmd = rc.GitCmd(context.Background(), "log", "--since="+since, "--numstat", "--pretty=format:%H", ".beads/issues.jsonl") + } else { + cmd = exec.Command("git", "log", "--since="+since, "--numstat", "--pretty=format:%H", ".beads/issues.jsonl") // #nosec G204 -- bounded arguments for local git history inspection + } output, err := cmd.Output() if err != nil { @@ -222,7 +230,11 @@ func getGitActivity(hours int) *RecentActivitySummary { } // Get detailed diff to analyze changes - cmd = exec.Command("git", "log", "--since="+since, "-p", ".beads/issues.jsonl") // #nosec G204 -- bounded arguments for local git history inspection + if rc, err := beads.GetRepoContext(); err == nil { + cmd = rc.GitCmd(context.Background(), "log", "--since="+since, "-p", ".beads/issues.jsonl") + } else { + cmd = exec.Command("git", "log", "--since="+since, "-p", ".beads/issues.jsonl") // #nosec G204 -- bounded arguments for local git history inspection + } output, err = cmd.Output() if err != nil { return nil diff --git a/cmd/bd/sync_branch.go b/cmd/bd/sync_branch.go index c988539a..5d865b12 100644 --- a/cmd/bd/sync_branch.go +++ b/cmd/bd/sync_branch.go @@ -8,13 +8,22 @@ import ( "strings" "time" + "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/syncbranch" ) -// getCurrentBranch returns the name of the current git branch +// getCurrentBranch returns the name of the current git branch in the beads repo. // Uses symbolic-ref instead of rev-parse to work in fresh repos without commits (bd-flil) +// GH#1110: Now uses RepoContext to ensure we query the beads repo, not CWD. +// Falls back to CWD-based query if no beads context found (for tests/standalone git). func getCurrentBranch(ctx context.Context) (string, error) { - cmd := exec.CommandContext(ctx, "git", "symbolic-ref", "--short", "HEAD") + var cmd *exec.Cmd + if rc, err := beads.GetRepoContext(); err == nil { + cmd = rc.GitCmd(ctx, "symbolic-ref", "--short", "HEAD") + } else { + // Fallback to CWD for tests or repos without beads + cmd = exec.CommandContext(ctx, "git", "symbolic-ref", "--short", "HEAD") + } output, err := cmd.Output() if err != nil { return "", fmt.Errorf("failed to get current branch: %w", err) @@ -53,12 +62,18 @@ func getSyncBranch(ctx context.Context) (string, error) { return syncBranch, nil } -// showSyncStatus shows the diff between sync branch and main branch +// showSyncStatus shows the diff between sync branch and main branch. +// GH#1110: Now uses RepoContext to ensure git commands run in beads repo. func showSyncStatus(ctx context.Context) error { if !isGitRepo() { return fmt.Errorf("not in a git repository") } + rc, err := beads.GetRepoContext() + if err != nil { + return fmt.Errorf("failed to get repo context: %w", err) + } + currentBranch := getCurrentBranchOrHEAD(ctx) syncBranch, err := getSyncBranch(ctx) @@ -67,7 +82,7 @@ func showSyncStatus(ctx context.Context) error { } // Check if sync branch exists - checkCmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+syncBranch) //nolint:gosec // syncBranch from config + checkCmd := rc.GitCmd(ctx, "show-ref", "--verify", "--quiet", "refs/heads/"+syncBranch) if err := checkCmd.Run(); err != nil { return fmt.Errorf("sync branch '%s' does not exist", syncBranch) } @@ -77,7 +92,7 @@ func showSyncStatus(ctx context.Context) error { // Show commit diff fmt.Println("Commits in sync branch not in main:") - logCmd := exec.CommandContext(ctx, "git", "log", "--oneline", currentBranch+".."+syncBranch) //nolint:gosec // branch names from git + logCmd := rc.GitCmd(ctx, "log", "--oneline", currentBranch+".."+syncBranch) logOutput, err := logCmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to get commit log: %w\n%s", err, logOutput) @@ -90,7 +105,7 @@ func showSyncStatus(ctx context.Context) error { } fmt.Println("\nCommits in main not in sync branch:") - logCmd = exec.CommandContext(ctx, "git", "log", "--oneline", syncBranch+".."+currentBranch) //nolint:gosec // branch names from git + logCmd = rc.GitCmd(ctx, "log", "--oneline", syncBranch+".."+currentBranch) logOutput, err = logCmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to get commit log: %w\n%s", err, logOutput) @@ -104,7 +119,7 @@ func showSyncStatus(ctx context.Context) error { // Show file diff for .beads/issues.jsonl fmt.Println("\nFile differences in .beads/issues.jsonl:") - diffCmd := exec.CommandContext(ctx, "git", "diff", currentBranch+"..."+syncBranch, "--", ".beads/issues.jsonl") //nolint:gosec // branch names from git + diffCmd := rc.GitCmd(ctx, "diff", currentBranch+"..."+syncBranch, "--", ".beads/issues.jsonl") diffOutput, err := diffCmd.CombinedOutput() if err != nil { // diff returns non-zero when there are differences, which is fine @@ -122,12 +137,18 @@ func showSyncStatus(ctx context.Context) error { return nil } -// mergeSyncBranch merges the sync branch back to the main branch +// mergeSyncBranch merges the sync branch back to the main branch. +// GH#1110: Now uses RepoContext to ensure git commands run in beads repo. func mergeSyncBranch(ctx context.Context, dryRun bool) error { if !isGitRepo() { return fmt.Errorf("not in a git repository") } + rc, err := beads.GetRepoContext() + if err != nil { + return fmt.Errorf("failed to get repo context: %w", err) + } + currentBranch, err := getCurrentBranch(ctx) if err != nil { return err @@ -139,13 +160,13 @@ func mergeSyncBranch(ctx context.Context, dryRun bool) error { } // Check if sync branch exists - checkCmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+syncBranch) //nolint:gosec // syncBranch from config + checkCmd := rc.GitCmd(ctx, "show-ref", "--verify", "--quiet", "refs/heads/"+syncBranch) if err := checkCmd.Run(); err != nil { return fmt.Errorf("sync branch '%s' does not exist", syncBranch) } // Check if there are uncommitted changes - statusCmd := exec.CommandContext(ctx, "git", "status", "--porcelain") + statusCmd := rc.GitCmd(ctx, "status", "--porcelain") statusOutput, err := statusCmd.Output() if err != nil { return fmt.Errorf("failed to check git status: %w", err) @@ -159,7 +180,7 @@ func mergeSyncBranch(ctx context.Context, dryRun bool) error { if dryRun { fmt.Println("→ [DRY RUN] Would merge sync branch") // Show what would be merged - logCmd := exec.CommandContext(ctx, "git", "log", "--oneline", currentBranch+".."+syncBranch) //nolint:gosec // branch names from git + logCmd := rc.GitCmd(ctx, "log", "--oneline", currentBranch+".."+syncBranch) logOutput, _ := logCmd.CombinedOutput() if len(strings.TrimSpace(string(logOutput))) > 0 { fmt.Println("\nCommits that would be merged:") @@ -171,7 +192,7 @@ func mergeSyncBranch(ctx context.Context, dryRun bool) error { } // Perform the merge - mergeCmd := exec.CommandContext(ctx, "git", "merge", syncBranch, "-m", fmt.Sprintf("Merge sync branch '%s'", syncBranch)) //nolint:gosec // syncBranch from config + mergeCmd := rc.GitCmd(ctx, "merge", syncBranch, "-m", fmt.Sprintf("Merge sync branch '%s'", syncBranch)) mergeOutput, err := mergeCmd.CombinedOutput() if err != nil { return fmt.Errorf("merge failed: %w\n%s", err, mergeOutput) diff --git a/cmd/bd/sync_check.go b/cmd/bd/sync_check.go index 20defcb5..3cb029b9 100644 --- a/cmd/bd/sync_check.go +++ b/cmd/bd/sync_check.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/syncbranch" "github.com/steveyegge/beads/internal/types" ) @@ -112,12 +113,20 @@ func showSyncIntegrityCheck(ctx context.Context, jsonlPath string) { // checkForcedPush detects if the sync branch has diverged from remote. // This can happen when someone force-pushes to the sync branch. +// GH#1110: Now uses RepoContext to ensure git commands run in beads repo. func checkForcedPush(ctx context.Context) *ForcedPushCheck { result := &ForcedPushCheck{ Detected: false, Message: "No sync branch configured or no remote", } + // Get RepoContext for beads repo + rc, err := beads.GetRepoContext() + if err != nil { + result.Message = fmt.Sprintf("Failed to get repo context: %v", err) + return result + } + // Get sync branch name if err := ensureStoreActive(); err != nil { return result @@ -129,14 +138,14 @@ func checkForcedPush(ctx context.Context) *ForcedPushCheck { } // Check if sync branch exists locally - checkLocalCmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+syncBranch) //nolint:gosec // syncBranch from config + checkLocalCmd := rc.GitCmd(ctx, "show-ref", "--verify", "--quiet", "refs/heads/"+syncBranch) if checkLocalCmd.Run() != nil { result.Message = fmt.Sprintf("Sync branch '%s' does not exist locally", syncBranch) return result } // Get local ref - localRefCmd := exec.CommandContext(ctx, "git", "rev-parse", syncBranch) //nolint:gosec // syncBranch from config + localRefCmd := rc.GitCmd(ctx, "rev-parse", syncBranch) localRefOutput, err := localRefCmd.Output() if err != nil { result.Message = "Failed to get local sync branch ref" @@ -152,7 +161,7 @@ func checkForcedPush(ctx context.Context) *ForcedPushCheck { } // Get remote ref - remoteRefCmd := exec.CommandContext(ctx, "git", "rev-parse", remote+"/"+syncBranch) //nolint:gosec // remote and syncBranch from config + remoteRefCmd := rc.GitCmd(ctx, "rev-parse", remote+"/"+syncBranch) remoteRefOutput, err := remoteRefCmd.Output() if err != nil { result.Message = fmt.Sprintf("Remote tracking branch '%s/%s' does not exist", remote, syncBranch) @@ -168,14 +177,14 @@ func checkForcedPush(ctx context.Context) *ForcedPushCheck { } // Check if local is ahead of remote (normal case) - aheadCmd := exec.CommandContext(ctx, "git", "merge-base", "--is-ancestor", remoteRef, localRef) //nolint:gosec // refs from git rev-parse + aheadCmd := rc.GitCmd(ctx, "merge-base", "--is-ancestor", remoteRef, localRef) if aheadCmd.Run() == nil { result.Message = "Local sync branch is ahead of remote (normal)" return result } // Check if remote is ahead of local (behind, needs pull) - behindCmd := exec.CommandContext(ctx, "git", "merge-base", "--is-ancestor", localRef, remoteRef) //nolint:gosec // refs from git rev-parse + behindCmd := rc.GitCmd(ctx, "merge-base", "--is-ancestor", localRef, remoteRef) if behindCmd.Run() == nil { result.Message = "Local sync branch is behind remote (needs pull)" return result diff --git a/cmd/bd/sync_import.go b/cmd/bd/sync_import.go index 859a7b3d..95c830fe 100644 --- a/cmd/bd/sync_import.go +++ b/cmd/bd/sync_import.go @@ -9,6 +9,7 @@ import ( "os/exec" "time" + "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/debug" "github.com/steveyegge/beads/internal/types" ) @@ -162,6 +163,7 @@ func resolveNoGitHistoryForFromMain(fromMain, noGitHistory bool) bool { // This fetches beads from main and imports them, discarding local beads changes. // If sync.remote is configured (e.g., "upstream" for fork workflows), uses that remote // instead of "origin". +// GH#1110: Now uses RepoContext to ensure git commands run in beads repo. func doSyncFromMain(ctx context.Context, jsonlPath string, renameOnImport bool, dryRun bool, noGitHistory bool) error { // Determine which remote to use (default: origin, but can be configured via sync.remote) remote := "origin" @@ -190,8 +192,14 @@ func doSyncFromMain(ctx context.Context, jsonlPath string, renameOnImport bool, return fmt.Errorf("no git remote configured") } + // Get RepoContext for beads repo + rc, err := beads.GetRepoContext() + if err != nil { + return fmt.Errorf("failed to get repo context: %w", err) + } + // Verify the configured remote exists - checkRemoteCmd := exec.CommandContext(ctx, "git", "remote", "get-url", remote) + checkRemoteCmd := rc.GitCmd(ctx, "remote", "get-url", remote) if err := checkRemoteCmd.Run(); err != nil { return fmt.Errorf("configured sync.remote '%s' does not exist (run 'git remote add %s ')", remote, remote) } @@ -200,14 +208,14 @@ func doSyncFromMain(ctx context.Context, jsonlPath string, renameOnImport bool, // Step 1: Fetch from main fmt.Printf("→ Fetching from %s/%s...\n", remote, defaultBranch) - fetchCmd := exec.CommandContext(ctx, "git", "fetch", remote, defaultBranch) //nolint:gosec // remote and defaultBranch from config + fetchCmd := rc.GitCmd(ctx, "fetch", remote, defaultBranch) if output, err := fetchCmd.CombinedOutput(); err != nil { return fmt.Errorf("git fetch %s %s failed: %w\n%s", remote, defaultBranch, err, output) } // Step 2: Checkout .beads/ directory from main fmt.Printf("→ Checking out beads from %s/%s...\n", remote, defaultBranch) - checkoutCmd := exec.CommandContext(ctx, "git", "checkout", fmt.Sprintf("%s/%s", remote, defaultBranch), "--", ".beads/") //nolint:gosec // remote and defaultBranch from config + checkoutCmd := rc.GitCmd(ctx, "checkout", fmt.Sprintf("%s/%s", remote, defaultBranch), "--", ".beads/") if output, err := checkoutCmd.CombinedOutput(); err != nil { return fmt.Errorf("git checkout .beads/ from %s/%s failed: %w\n%s", remote, defaultBranch, err, output) }