Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions cmd/bd/context_binding_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
//go:build cgo

package main

import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"

"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/storage/dolt"
"github.com/steveyegge/beads/internal/types"
)

func filteredEnvForContextBinding(keys ...string) []string {
strip := make(map[string]struct{}, len(keys))
for _, key := range keys {
strip[key+"="] = struct{}{}
}

env := os.Environ()
filtered := make([]string, 0, len(env))
for _, entry := range env {
if strings.HasPrefix(entry, "BEADS_") || strings.HasPrefix(entry, "BD_") {
continue
}
trim := false
for prefix := range strip {
if strings.HasPrefix(entry, prefix) {
trim = true
break
}
}
if !trim {
filtered = append(filtered, entry)
}
}
return filtered
}

func TestListExplicitDBPathRebindsTargetContext(t *testing.T) {
if testDoltServerPort == 0 {
t.Skip("Dolt test server not available, skipping")
}

tmpDir := t.TempDir()
callerRepo := filepath.Join(tmpDir, "caller")
callerBeadsDir := filepath.Join(callerRepo, ".beads")
writeTestConfigYAML(t, callerBeadsDir, "dolt.auto-commit: invalid\nactor: caller-actor\n")
if err := os.WriteFile(filepath.Join(callerBeadsDir, ".env"), []byte("BEADS_DOLT_SERVER_PORT=1\n"), 0o600); err != nil {
t.Fatalf("write caller .env: %v", err)
}

targetRepo := filepath.Join(tmpDir, "target")
targetBeadsDir := filepath.Join(targetRepo, ".beads")
writeTestConfigYAML(t, targetBeadsDir, "dolt.auto-commit: off\nactor: target-actor\n")
database := uniqueTestDBName(t)
if err := (&configfile.Config{
Backend: configfile.BackendDolt,
DoltMode: configfile.DoltModeServer,
DoltServerHost: "127.0.0.1",
DoltServerPort: testDoltServerPort,
DoltDatabase: database,
}).Save(targetBeadsDir); err != nil {
t.Fatalf("save target metadata: %v", err)
}

ctx := context.Background()
testStore, err := dolt.New(ctx, &dolt.Config{
Path: filepath.Join(targetBeadsDir, "dolt"),
BeadsDir: targetBeadsDir,
ServerHost: "127.0.0.1",
ServerPort: testDoltServerPort,
Database: database,
CreateIfMissing: true,
})
if err != nil {
t.Fatalf("create test store: %v", err)
}
defer func() {
_ = testStore.Close()
dropTestDatabase(database, testDoltServerPort)
}()
if err := testStore.SetConfig(ctx, "issue_prefix", "ctx"); err != nil {
t.Fatalf("set issue_prefix: %v", err)
}
now := time.Now()
nowIssue := &types.Issue{
ID: "ctx-1",
Title: "Context binding proof",
Description: "Proves explicit --db commands use the target workspace config",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: now,
UpdatedAt: now,
}
if err := testStore.CreateIssue(ctx, nowIssue, "test-user"); err != nil {
t.Fatalf("create issue: %v", err)
}

binPath := filepath.Join(t.TempDir(), "bd-under-test")
packageDir, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
buildCmd := exec.Command("go", "build", "-o", binPath, ".")
buildCmd.Dir = packageDir
buildOut, err := buildCmd.CombinedOutput()
if err != nil {
t.Fatalf("go build failed: %v\n%s", err, buildOut)
}

listCmd := exec.Command(binPath, "list", "--db", filepath.Join(targetBeadsDir, "dolt"), "--json")
listCmd.Dir = callerRepo
listCmd.Env = append(filteredEnvForContextBinding("BEADS_DIR", "BEADS_DB", "BD_DB", "BEADS_DOLT_SERVER_PORT", "BEADS_DOLT_SERVER_DATABASE"),
"HOME="+t.TempDir(),
"XDG_CONFIG_HOME="+t.TempDir(),
"BEADS_TEST_MODE=1",
"BEADS_DIR="+callerBeadsDir,
"BEADS_DB=",
)
output, err := listCmd.CombinedOutput()
if err != nil {
t.Fatalf("bd list failed: %v\n%s", err, output)
}
if !strings.Contains(string(output), "Context binding proof") {
t.Fatalf("expected list output to include target issue\n%s", output)
}

if _, err := os.Stat(filepath.Join(callerBeadsDir, localVersionFile)); err == nil {
t.Fatalf("caller workspace unexpectedly created %s", filepath.Join(callerBeadsDir, localVersionFile))
} else if !os.IsNotExist(err) {
t.Fatalf("stat caller %s: %v", localVersionFile, err)
}
if _, err := os.Stat(filepath.Join(targetBeadsDir, localVersionFile)); err != nil {
t.Fatalf("target workspace should create %s: %v", localVersionFile, err)
}
}
187 changes: 187 additions & 0 deletions cmd/bd/context_binding_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package main

import (
"os"
"path/filepath"
"testing"

"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/doltserver"
)

func writeTestConfigYAML(t *testing.T, beadsDir, contents string) {
t.Helper()
if err := os.MkdirAll(beadsDir, 0o700); err != nil {
t.Fatalf("mkdir beads dir: %v", err)
}
if err := os.WriteFile(filepath.Join(beadsDir, "config.yaml"), []byte(contents), 0o600); err != nil {
t.Fatalf("write config.yaml: %v", err)
}
}

type flagSnapshot struct {
value string
changed bool
}

func snapshotRootFlagState() map[string]flagSnapshot {
state := map[string]flagSnapshot{}
for _, name := range []string{"db", "json", "format", "readonly", "actor", "dolt-auto-commit"} {
flag := rootCmd.PersistentFlags().Lookup(name)
if flag == nil {
continue
}
state[name] = flagSnapshot{value: flag.Value.String(), changed: flag.Changed}
}
return state
}

func restoreRootFlagState(t *testing.T, state map[string]flagSnapshot) {
t.Helper()
for name, snapshot := range state {
flag := rootCmd.PersistentFlags().Lookup(name)
if flag == nil {
continue
}
if err := flag.Value.Set(snapshot.value); err != nil {
t.Fatalf("restore %s flag: %v", name, err)
}
flag.Changed = snapshot.changed
}
}

func TestPrepareSelectedCommandContext_RebindsTargetConfig(t *testing.T) {
t.Setenv("BEADS_DOLT_SERVER_DATABASE", "")
t.Setenv("BEADS_DOLT_SERVER_PORT", "")

callerDir := t.TempDir()
callerBeadsDir := filepath.Join(callerDir, ".beads")
writeTestConfigYAML(t, callerBeadsDir, "actor: caller-actor\ndolt.auto-start: true\ndolt.port: 1111\ndolt.auto-commit: on\n")

targetDir := t.TempDir()
targetBeadsDir := filepath.Join(targetDir, ".beads")
writeTestConfigYAML(t, targetBeadsDir, "actor: target-actor\ndolt.auto-start: false\ndolt.port: 4242\ndolt.auto-commit: batch\njson: true\nreadonly: true\n")
if err := (&configfile.Config{
Backend: configfile.BackendDolt,
DoltMode: configfile.DoltModeServer,
}).Save(targetBeadsDir); err != nil {
t.Fatalf("save target metadata: %v", err)
}

t.Setenv("BEADS_DIR", callerBeadsDir)
config.ResetForTesting()
t.Cleanup(config.ResetForTesting)
if err := config.Initialize(); err != nil {
t.Fatalf("config.Initialize: %v", err)
}

oldServerMode := serverMode
oldJSONOutput := jsonOutput
oldReadonlyMode := readonlyMode
oldActor := actor
oldDoltAutoCommit := doltAutoCommit
flagState := snapshotRootFlagState()
t.Cleanup(func() {
serverMode = oldServerMode
jsonOutput = oldJSONOutput
readonlyMode = oldReadonlyMode
actor = oldActor
doltAutoCommit = oldDoltAutoCommit
restoreRootFlagState(t, flagState)
})

serverMode = false
jsonOutput = false
readonlyMode = false
actor = ""
doltAutoCommit = ""
for _, name := range []string{"json", "format", "readonly", "actor", "dolt-auto-commit"} {
if flag := rootCmd.PersistentFlags().Lookup(name); flag != nil {
flag.Changed = false
}
}

prepareSelectedCommandContext(targetBeadsDir, false)
refreshBoundCommandConfig(rootCmd)

if got := os.Getenv("BEADS_DIR"); got != targetBeadsDir {
t.Fatalf("BEADS_DIR = %q, want %q", got, targetBeadsDir)
}
if !serverMode {
t.Fatal("serverMode should be true after rebinding to target metadata")
}
if !jsonOutput {
t.Fatal("jsonOutput should be rebound from target config")
}
if !readonlyMode {
t.Fatal("readonlyMode should be rebound from target config")
}
if actor != "target-actor" {
t.Fatalf("actor = %q, want %q", actor, "target-actor")
}
if doltAutoCommit != "batch" {
t.Fatalf("doltAutoCommit = %q, want %q", doltAutoCommit, "batch")
}
if !doltserver.IsAutoStartDisabled() {
t.Fatal("IsAutoStartDisabled should honor target config after rebinding")
}
if got := doltserver.DefaultConfig(targetBeadsDir).Port; got != 4242 {
t.Fatalf("DefaultConfig(target).Port = %d, want %d", got, 4242)
}
}

func TestPrepareSelectedCommandContext_DoesNotMergeCallerConfigForUnsetKeys(t *testing.T) {
t.Setenv("BEADS_DOLT_SERVER_DATABASE", "")
t.Setenv("BEADS_DOLT_SERVER_PORT", "")

root := t.TempDir()
callerDir := filepath.Join(root, "caller")
callerBeadsDir := filepath.Join(callerDir, ".beads")
writeTestConfigYAML(t, callerBeadsDir, "readonly: true\njson: true\n")

targetDir := filepath.Join(root, "target")
targetBeadsDir := filepath.Join(targetDir, ".beads")
writeTestConfigYAML(t, targetBeadsDir, "actor: target-actor\n")

t.Chdir(callerDir)
t.Setenv("BEADS_DIR", callerBeadsDir)
config.ResetForTesting()
t.Cleanup(config.ResetForTesting)
if err := config.Initialize(); err != nil {
t.Fatalf("config.Initialize: %v", err)
}

oldJSONOutput := jsonOutput
oldReadonlyMode := readonlyMode
oldActor := actor
flagState := snapshotRootFlagState()
t.Cleanup(func() {
jsonOutput = oldJSONOutput
readonlyMode = oldReadonlyMode
actor = oldActor
restoreRootFlagState(t, flagState)
})

jsonOutput = false
readonlyMode = false
actor = ""
for _, name := range []string{"json", "format", "readonly", "actor"} {
if flag := rootCmd.PersistentFlags().Lookup(name); flag != nil {
flag.Changed = false
}
}

prepareSelectedCommandContext(targetBeadsDir, false)
refreshBoundCommandConfig(rootCmd)

if readonlyMode {
t.Fatal("readonlyMode should stay false when target config leaves readonly unset")
}
if jsonOutput {
t.Fatal("jsonOutput should stay false when target config leaves json unset")
}
if actor != "target-actor" {
t.Fatalf("actor = %q, want %q", actor, "target-actor")
}
}
2 changes: 1 addition & 1 deletion cmd/bd/context_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Examples:
}

// Resolve repo context (works without DB open)
if selected := selectedNoDBBeadsDir(); selected != "" {
if selected := selectedNoDBBeadsDir(cmd); selected != "" {
prepareSelectedNoDBContext(selected)
}

Expand Down
Loading
Loading