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:
committed by
GitHub
parent
159114563b
commit
0a48519561
@@ -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.
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
683
cmd/bd/sync_cwd_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
213
cmd/bd/worktree_cmd_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user