Merge origin/main into db/fix-2 (resolve conflicts)
This commit is contained in:
@@ -112,7 +112,7 @@ func Initialize() error {
|
||||
v.SetDefault("remote-sync-interval", "30s")
|
||||
|
||||
// Routing configuration defaults
|
||||
v.SetDefault("routing.mode", "auto")
|
||||
v.SetDefault("routing.mode", "")
|
||||
v.SetDefault("routing.default", ".")
|
||||
v.SetDefault("routing.maintainer", ".")
|
||||
v.SetDefault("routing.contributor", "~/.beads-planning")
|
||||
|
||||
@@ -941,6 +941,35 @@ external_projects:
|
||||
})
|
||||
}
|
||||
|
||||
func TestRoutingModeDefaultIsEmpty(t *testing.T) {
|
||||
// GH#1165: routing.mode must default to empty (disabled)
|
||||
// to prevent unexpected auto-routing to ~/.beads-planning
|
||||
// Isolate from environment variables
|
||||
restore := envSnapshot(t)
|
||||
defer restore()
|
||||
|
||||
// Initialize config
|
||||
if err := Initialize(); err != nil {
|
||||
t.Fatalf("Initialize() returned error: %v", err)
|
||||
}
|
||||
|
||||
// Verify routing.mode defaults to empty string (disabled)
|
||||
if got := GetString("routing.mode"); got != "" {
|
||||
t.Errorf("GetString(routing.mode) = %q, want \"\" (empty = disabled by default)", got)
|
||||
}
|
||||
|
||||
// Verify other routing defaults are still set correctly
|
||||
if got := GetString("routing.default"); got != "." {
|
||||
t.Errorf("GetString(routing.default) = %q, want \".\"", got)
|
||||
}
|
||||
if got := GetString("routing.maintainer"); got != "." {
|
||||
t.Errorf("GetString(routing.maintainer) = %q, want \".\"", got)
|
||||
}
|
||||
if got := GetString("routing.contributor"); got != "~/.beads-planning" {
|
||||
t.Errorf("GetString(routing.contributor) = %q, want \"~/.beads-planning\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationConfigDefaults(t *testing.T) {
|
||||
// Isolate from environment variables
|
||||
restore := envSnapshot(t)
|
||||
|
||||
@@ -2,6 +2,7 @@ package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -271,3 +272,45 @@ func ResetCaches() {
|
||||
gitCtxOnce = sync.Once{}
|
||||
gitCtx = gitContext{}
|
||||
}
|
||||
|
||||
// IsJujutsuRepo returns true if the current directory is in a jujutsu (jj) repository.
|
||||
// Jujutsu stores its data in a .jj directory at the repository root.
|
||||
func IsJujutsuRepo() bool {
|
||||
_, err := GetJujutsuRoot()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// IsColocatedJJGit returns true if this is a colocated jujutsu+git repository.
|
||||
// Colocated repos have both .jj and .git directories, created via `jj git init --colocate`.
|
||||
// In colocated repos, git hooks work normally since jj manages the git repo.
|
||||
func IsColocatedJJGit() bool {
|
||||
if !IsJujutsuRepo() {
|
||||
return false
|
||||
}
|
||||
// If we're also in a git repo, it's colocated
|
||||
_, err := getGitContext()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GetJujutsuRoot returns the root directory of the jujutsu repository.
|
||||
// Returns empty string and error if not in a jujutsu repository.
|
||||
func GetJujutsuRoot() (string, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get current directory: %w", err)
|
||||
}
|
||||
|
||||
dir := cwd
|
||||
for {
|
||||
jjPath := filepath.Join(dir, ".jj")
|
||||
if info, err := os.Stat(jjPath); err == nil && info.IsDir() {
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "", fmt.Errorf("not a jujutsu repository (no .jj directory found)")
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsJujutsuRepo(t *testing.T) {
|
||||
// Save original directory
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current directory: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = os.Chdir(origDir)
|
||||
ResetCaches()
|
||||
}()
|
||||
|
||||
t.Run("not a jj repo", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("Failed to chdir: %v", err)
|
||||
}
|
||||
ResetCaches()
|
||||
|
||||
if IsJujutsuRepo() {
|
||||
t.Error("Expected IsJujutsuRepo() to return false for non-jj directory")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("jj repo root", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
jjDir := filepath.Join(tmpDir, ".jj")
|
||||
if err := os.Mkdir(jjDir, 0750); err != nil {
|
||||
t.Fatalf("Failed to create .jj directory: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("Failed to chdir: %v", err)
|
||||
}
|
||||
ResetCaches()
|
||||
|
||||
if !IsJujutsuRepo() {
|
||||
t.Error("Expected IsJujutsuRepo() to return true for jj repo root")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("jj repo subdirectory", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
jjDir := filepath.Join(tmpDir, ".jj")
|
||||
if err := os.Mkdir(jjDir, 0750); err != nil {
|
||||
t.Fatalf("Failed to create .jj directory: %v", err)
|
||||
}
|
||||
subDir := filepath.Join(tmpDir, "src", "lib")
|
||||
if err := os.MkdirAll(subDir, 0750); err != nil {
|
||||
t.Fatalf("Failed to create subdirectory: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Chdir(subDir); err != nil {
|
||||
t.Fatalf("Failed to chdir: %v", err)
|
||||
}
|
||||
ResetCaches()
|
||||
|
||||
if !IsJujutsuRepo() {
|
||||
t.Error("Expected IsJujutsuRepo() to return true for jj repo subdirectory")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsColocatedJJGit(t *testing.T) {
|
||||
// Save original directory
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current directory: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = os.Chdir(origDir)
|
||||
ResetCaches()
|
||||
}()
|
||||
|
||||
t.Run("jj only (not colocated)", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
jjDir := filepath.Join(tmpDir, ".jj")
|
||||
if err := os.Mkdir(jjDir, 0750); err != nil {
|
||||
t.Fatalf("Failed to create .jj directory: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("Failed to chdir: %v", err)
|
||||
}
|
||||
ResetCaches()
|
||||
|
||||
if IsColocatedJJGit() {
|
||||
t.Error("Expected IsColocatedJJGit() to return false for jj-only repo")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not a repo", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("Failed to chdir: %v", err)
|
||||
}
|
||||
ResetCaches()
|
||||
|
||||
if IsColocatedJJGit() {
|
||||
t.Error("Expected IsColocatedJJGit() to return false for non-repo")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetJujutsuRoot(t *testing.T) {
|
||||
// Save original directory
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current directory: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = os.Chdir(origDir)
|
||||
ResetCaches()
|
||||
}()
|
||||
|
||||
t.Run("not a jj repo", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("Failed to chdir: %v", err)
|
||||
}
|
||||
ResetCaches()
|
||||
|
||||
_, err := GetJujutsuRoot()
|
||||
if err == nil {
|
||||
t.Error("Expected GetJujutsuRoot() to return error for non-jj directory")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("jj repo root", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
// Resolve symlinks for comparison (macOS /var -> /private/var)
|
||||
tmpDir, _ = filepath.EvalSymlinks(tmpDir)
|
||||
|
||||
jjDir := filepath.Join(tmpDir, ".jj")
|
||||
if err := os.Mkdir(jjDir, 0750); err != nil {
|
||||
t.Fatalf("Failed to create .jj directory: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("Failed to chdir: %v", err)
|
||||
}
|
||||
ResetCaches()
|
||||
|
||||
root, err := GetJujutsuRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("GetJujutsuRoot() returned error: %v", err)
|
||||
}
|
||||
if root != tmpDir {
|
||||
t.Errorf("Expected root %q, got %q", tmpDir, root)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("jj repo subdirectory", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
// Resolve symlinks for comparison (macOS /var -> /private/var)
|
||||
tmpDir, _ = filepath.EvalSymlinks(tmpDir)
|
||||
|
||||
jjDir := filepath.Join(tmpDir, ".jj")
|
||||
if err := os.Mkdir(jjDir, 0750); err != nil {
|
||||
t.Fatalf("Failed to create .jj directory: %v", err)
|
||||
}
|
||||
subDir := filepath.Join(tmpDir, "src", "lib")
|
||||
if err := os.MkdirAll(subDir, 0750); err != nil {
|
||||
t.Fatalf("Failed to create subdirectory: %v", err)
|
||||
}
|
||||
|
||||
if err := os.Chdir(subDir); err != nil {
|
||||
t.Fatalf("Failed to chdir: %v", err)
|
||||
}
|
||||
ResetCaches()
|
||||
|
||||
root, err := GetJujutsuRoot()
|
||||
if err != nil {
|
||||
t.Fatalf("GetJujutsuRoot() returned error: %v", err)
|
||||
}
|
||||
if root != tmpDir {
|
||||
t.Errorf("Expected root %q, got %q", tmpDir, root)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -459,13 +459,13 @@ func TestBranchPerAgentMergeRace(t *testing.T) {
|
||||
}
|
||||
|
||||
// First merge should succeed
|
||||
_, err1 := store.Merge(ctx, "agent-1")
|
||||
conflicts1, err1 := store.Merge(ctx, "agent-1")
|
||||
|
||||
// Second merge may conflict (both modified same row)
|
||||
_, err2 := store.Merge(ctx, "agent-2")
|
||||
conflicts2, err2 := store.Merge(ctx, "agent-2")
|
||||
|
||||
t.Logf("Merge agent-1 result: %v", err1)
|
||||
t.Logf("Merge agent-2 result: %v", err2)
|
||||
t.Logf("Merge agent-1 result: err=%v conflicts=%d", err1, len(conflicts1))
|
||||
t.Logf("Merge agent-2 result: err=%v conflicts=%d", err2, len(conflicts2))
|
||||
|
||||
// At least one should succeed
|
||||
if err1 != nil && err2 != nil {
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package dolt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// Federation Prototype Tests
|
||||
//
|
||||
// These tests validate the Dolt APIs needed for federation between towns.
|
||||
// Production federation uses hosted Dolt remotes (DoltHub, S3, etc.), not file://.
|
||||
//
|
||||
// What we can test locally:
|
||||
// 1. Database isolation between towns (separate Dolt databases)
|
||||
// 2. Version control APIs (commit, branch, merge)
|
||||
// 3. Remote configuration APIs (AddRemote)
|
||||
// 4. History and diff queries
|
||||
//
|
||||
// What requires hosted infrastructure:
|
||||
// 1. Actual push/pull between towns (needs DoltHub or dolt sql-server)
|
||||
// 2. Cross-town sync via DOLT_FETCH/DOLT_PUSH
|
||||
// 3. Federation message exchange
|
||||
//
|
||||
// See ~/hop/docs/architecture/FEDERATION.md for full federation spec.
|
||||
|
||||
// TestFederationDatabaseIsolation verifies that two DoltStores have isolated databases
|
||||
func TestFederationDatabaseIsolation(t *testing.T) {
|
||||
skipIfNoDolt(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
baseDir, err := os.MkdirTemp("", "federation-isolation-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create base dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(baseDir)
|
||||
|
||||
// Setup Town Alpha
|
||||
alphaDir := filepath.Join(baseDir, "town-alpha")
|
||||
alphaStore, alphaCleanup := setupFederationStore(t, ctx, alphaDir, "alpha")
|
||||
defer alphaCleanup()
|
||||
|
||||
// Setup Town Beta
|
||||
betaDir := filepath.Join(baseDir, "town-beta")
|
||||
betaStore, betaCleanup := setupFederationStore(t, ctx, betaDir, "beta")
|
||||
defer betaCleanup()
|
||||
|
||||
t.Logf("Alpha path: %s", alphaStore.Path())
|
||||
t.Logf("Beta path: %s", betaStore.Path())
|
||||
|
||||
// Create issue in Alpha
|
||||
alphaIssue := &types.Issue{
|
||||
ID: "alpha-001",
|
||||
Title: "Work item from Town Alpha",
|
||||
Description: "This issue exists only in Town Alpha",
|
||||
IssueType: types.TypeTask,
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := alphaStore.CreateIssue(ctx, alphaIssue, "federation-test"); err != nil {
|
||||
t.Fatalf("failed to create issue in alpha: %v", err)
|
||||
}
|
||||
if err := alphaStore.Commit(ctx, "Create alpha-001"); err != nil {
|
||||
t.Fatalf("failed to commit in alpha: %v", err)
|
||||
}
|
||||
|
||||
// Create different issue in Beta
|
||||
betaIssue := &types.Issue{
|
||||
ID: "beta-001",
|
||||
Title: "Work item from Town Beta",
|
||||
Description: "This issue exists only in Town Beta",
|
||||
IssueType: types.TypeTask,
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := betaStore.CreateIssue(ctx, betaIssue, "federation-test"); err != nil {
|
||||
t.Fatalf("failed to create issue in beta: %v", err)
|
||||
}
|
||||
if err := betaStore.Commit(ctx, "Create beta-001"); err != nil {
|
||||
t.Fatalf("failed to commit in beta: %v", err)
|
||||
}
|
||||
|
||||
// Verify isolation: Alpha should NOT see Beta's issue
|
||||
issueFromAlpha, err := alphaStore.GetIssue(ctx, "beta-001")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if issueFromAlpha != nil {
|
||||
t.Fatalf("isolation violated: alpha found beta-001")
|
||||
}
|
||||
t.Log("✓ Alpha cannot see beta-001")
|
||||
|
||||
// Verify isolation: Beta should NOT see Alpha's issue
|
||||
issueFromBeta, err := betaStore.GetIssue(ctx, "alpha-001")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if issueFromBeta != nil {
|
||||
t.Fatalf("isolation violated: beta found alpha-001")
|
||||
}
|
||||
t.Log("✓ Beta cannot see alpha-001")
|
||||
|
||||
// Verify each town sees its own issue
|
||||
alphaCheck, _ := alphaStore.GetIssue(ctx, "alpha-001")
|
||||
if alphaCheck == nil {
|
||||
t.Fatal("alpha should see its own issue")
|
||||
}
|
||||
t.Logf("✓ Alpha sees alpha-001: %q", alphaCheck.Title)
|
||||
|
||||
betaCheck, _ := betaStore.GetIssue(ctx, "beta-001")
|
||||
if betaCheck == nil {
|
||||
t.Fatal("beta should see its own issue")
|
||||
}
|
||||
t.Logf("✓ Beta sees beta-001: %q", betaCheck.Title)
|
||||
}
|
||||
|
||||
// TestFederationVersionControlAPIs tests the Dolt version control operations
|
||||
// needed for federation (branch, commit, merge)
|
||||
func TestFederationVersionControlAPIs(t *testing.T) {
|
||||
skipIfNoDolt(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
store, cleanup := setupTestStore(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create initial issue
|
||||
issue := &types.Issue{
|
||||
ID: "vc-001",
|
||||
Title: "Version control test",
|
||||
IssueType: types.TypeTask,
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
||||
t.Fatalf("failed to create issue: %v", err)
|
||||
}
|
||||
if err := store.Commit(ctx, "Initial issue"); err != nil {
|
||||
t.Fatalf("failed to commit: %v", err)
|
||||
}
|
||||
|
||||
// Test branch creation
|
||||
if err := store.Branch(ctx, "feature-branch"); err != nil {
|
||||
t.Fatalf("failed to create branch: %v", err)
|
||||
}
|
||||
t.Log("✓ Created feature-branch")
|
||||
|
||||
// Test checkout
|
||||
if err := store.Checkout(ctx, "feature-branch"); err != nil {
|
||||
t.Fatalf("failed to checkout: %v", err)
|
||||
}
|
||||
|
||||
// Verify current branch
|
||||
branch, err := store.CurrentBranch(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get current branch: %v", err)
|
||||
}
|
||||
if branch != "feature-branch" {
|
||||
t.Errorf("expected feature-branch, got %s", branch)
|
||||
}
|
||||
t.Logf("✓ Checked out to %s", branch)
|
||||
|
||||
// Make change on feature branch
|
||||
updates := map[string]interface{}{
|
||||
"title": "Updated on feature branch",
|
||||
}
|
||||
if err := store.UpdateIssue(ctx, "vc-001", updates, "test"); err != nil {
|
||||
t.Fatalf("failed to update: %v", err)
|
||||
}
|
||||
if err := store.Commit(ctx, "Feature update"); err != nil {
|
||||
t.Fatalf("failed to commit: %v", err)
|
||||
}
|
||||
|
||||
// Switch back to main
|
||||
if err := store.Checkout(ctx, "main"); err != nil {
|
||||
t.Fatalf("failed to checkout main: %v", err)
|
||||
}
|
||||
|
||||
// Verify main still has original title
|
||||
mainIssue, _ := store.GetIssue(ctx, "vc-001")
|
||||
if mainIssue.Title != "Version control test" {
|
||||
t.Errorf("main should have original title, got %q", mainIssue.Title)
|
||||
}
|
||||
t.Log("✓ Main branch unchanged")
|
||||
|
||||
// Merge feature branch
|
||||
conflicts, err := store.Merge(ctx, "feature-branch")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to merge: %v", err)
|
||||
}
|
||||
if len(conflicts) > 0 {
|
||||
t.Logf("Merge produced %d conflicts", len(conflicts))
|
||||
}
|
||||
t.Log("✓ Merged feature-branch into main")
|
||||
|
||||
// Verify merge result
|
||||
mergedIssue, _ := store.GetIssue(ctx, "vc-001")
|
||||
if mergedIssue.Title != "Updated on feature branch" {
|
||||
t.Errorf("expected merged title, got %q", mergedIssue.Title)
|
||||
}
|
||||
t.Logf("✓ Merge applied: title now %q", mergedIssue.Title)
|
||||
|
||||
// Test branch listing
|
||||
branches, err := store.ListBranches(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list branches: %v", err)
|
||||
}
|
||||
t.Logf("✓ Branches: %v", branches)
|
||||
}
|
||||
|
||||
// TestFederationRemoteConfiguration tests AddRemote API
|
||||
// Note: This only tests configuration, not actual push/pull which requires a running remote
|
||||
func TestFederationRemoteConfiguration(t *testing.T) {
|
||||
skipIfNoDolt(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
store, cleanup := setupTestStore(t)
|
||||
defer cleanup()
|
||||
|
||||
// Add a remote (configuration only - won't actually connect)
|
||||
// Production would use: dolthub://org/repo, s3://bucket/path, etc.
|
||||
err := store.AddRemote(ctx, "origin", "dolthub://example/beads")
|
||||
if err != nil {
|
||||
// AddRemote may fail if remote can't be validated, which is expected
|
||||
t.Logf("AddRemote result: %v (expected for unreachable remote)", err)
|
||||
} else {
|
||||
t.Log("✓ Added remote 'origin'")
|
||||
}
|
||||
|
||||
// Add federation peer remote
|
||||
err = store.AddRemote(ctx, "town-beta", "dolthub://acme/town-beta-beads")
|
||||
if err != nil {
|
||||
t.Logf("AddRemote town-beta result: %v", err)
|
||||
} else {
|
||||
t.Log("✓ Added remote 'town-beta'")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFederationHistoryQueries tests history queries needed for CV and audit
|
||||
func TestFederationHistoryQueries(t *testing.T) {
|
||||
skipIfNoDolt(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
store, cleanup := setupTestStore(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create issue
|
||||
issue := &types.Issue{
|
||||
ID: "hist-001",
|
||||
Title: "History test - v1",
|
||||
IssueType: types.TypeTask,
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
||||
t.Fatalf("failed to create: %v", err)
|
||||
}
|
||||
if err := store.Commit(ctx, "Create hist-001 v1"); err != nil {
|
||||
t.Fatalf("failed to commit: %v", err)
|
||||
}
|
||||
|
||||
// Update multiple times
|
||||
for i := 2; i <= 3; i++ {
|
||||
updates := map[string]interface{}{
|
||||
"title": "History test - v" + string(rune('0'+i)),
|
||||
}
|
||||
if err := store.UpdateIssue(ctx, "hist-001", updates, "test"); err != nil {
|
||||
t.Fatalf("failed to update v%d: %v", i, err)
|
||||
}
|
||||
if err := store.Commit(ctx, "Update to v"+string(rune('0'+i))); err != nil {
|
||||
t.Fatalf("failed to commit v%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Query history
|
||||
history, err := store.History(ctx, "hist-001")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get history: %v", err)
|
||||
}
|
||||
t.Logf("✓ Found %d history entries for hist-001", len(history))
|
||||
for i, entry := range history {
|
||||
t.Logf(" [%d] %s: %s", i, entry.CommitHash[:8], entry.Issue.Title)
|
||||
}
|
||||
|
||||
// Get current commit
|
||||
hash, err := store.GetCurrentCommit(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get current commit: %v", err)
|
||||
}
|
||||
t.Logf("✓ Current commit: %s", hash[:12])
|
||||
|
||||
// Query recent log
|
||||
log, err := store.Log(ctx, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get log: %v", err)
|
||||
}
|
||||
t.Logf("✓ Recent commits:")
|
||||
for _, c := range log {
|
||||
t.Logf(" %s: %s", c.Hash[:8], c.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// setupFederationStore creates a Dolt store for federation testing
|
||||
func setupFederationStore(t *testing.T, ctx context.Context, path, prefix string) (*DoltStore, func()) {
|
||||
t.Helper()
|
||||
|
||||
cfg := &Config{
|
||||
Path: path,
|
||||
CommitterName: "town-" + prefix,
|
||||
CommitterEmail: prefix + "@federation.test",
|
||||
Database: "beads",
|
||||
}
|
||||
|
||||
store, err := New(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create %s store: %v", prefix, err)
|
||||
}
|
||||
|
||||
// Set up issue prefix
|
||||
if err := store.SetConfig(ctx, "issue_prefix", prefix); err != nil {
|
||||
store.Close()
|
||||
t.Fatalf("failed to set prefix for %s: %v", prefix, err)
|
||||
}
|
||||
|
||||
// Initial commit to establish main branch
|
||||
if err := store.Commit(ctx, "Initialize "+prefix+" town"); err != nil {
|
||||
// Ignore if nothing to commit
|
||||
t.Logf("Initial commit for %s: %v", prefix, err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
store.Close()
|
||||
}
|
||||
|
||||
return store, cleanup
|
||||
}
|
||||
@@ -272,8 +272,12 @@ func PullFromSyncBranch(ctx context.Context, repoRoot, syncBranch, jsonlPath str
|
||||
// Case 1: Already up to date (remote has nothing new)
|
||||
if remoteAhead == 0 {
|
||||
result.Pulled = true
|
||||
// Still copy JSONL in case worktree has uncommitted changes
|
||||
if err := copyJSONLToMainRepo(worktreePath, jsonlRelPath, jsonlPath); err != nil {
|
||||
// GH#1173: Do NOT copy uncommitted worktree changes to main repo.
|
||||
// The worktree may have uncommitted changes from previous exports that
|
||||
// haven't been committed yet. Copying those to main would make local
|
||||
// data appear as "remote" data, corrupting the 3-way merge.
|
||||
// Instead, copy only the COMMITTED state from the worktree.
|
||||
if err := copyCommittedJSONLToMainRepo(ctx, worktreePath, jsonlRelPath, jsonlPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
@@ -655,6 +659,35 @@ func extractJSONLFromCommit(ctx context.Context, worktreePath, commit, filePath
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// copyCommittedJSONLToMainRepo copies the COMMITTED JSONL from worktree to main repo.
|
||||
// GH#1173: This extracts the file from HEAD rather than the working directory,
|
||||
// ensuring uncommitted local changes don't corrupt the 3-way merge.
|
||||
func copyCommittedJSONLToMainRepo(ctx context.Context, worktreePath, jsonlRelPath, jsonlPath string) error {
|
||||
// GH#785: Handle bare repo worktrees
|
||||
normalizedRelPath := normalizeBeadsRelPath(jsonlRelPath)
|
||||
|
||||
// Extract the committed JSONL from HEAD
|
||||
data, err := extractJSONLFromCommit(ctx, worktreePath, "HEAD", normalizedRelPath)
|
||||
if err != nil {
|
||||
// File might not exist in HEAD yet (first sync), nothing to copy
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(jsonlPath, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write main JSONL: %w", err)
|
||||
}
|
||||
|
||||
// Also copy committed metadata.json if it exists
|
||||
beadsDir := filepath.Dir(jsonlPath)
|
||||
metadataRelPath := filepath.Join(filepath.Dir(normalizedRelPath), "metadata.json")
|
||||
if metaData, err := extractJSONLFromCommit(ctx, worktreePath, "HEAD", metadataRelPath); err == nil {
|
||||
dstPath := filepath.Join(beadsDir, "metadata.json")
|
||||
_ = os.WriteFile(dstPath, metaData, 0600) // Best effort
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyJSONLToMainRepo copies JSONL and related files from worktree to main repo.
|
||||
func copyJSONLToMainRepo(worktreePath, jsonlRelPath, jsonlPath string) error {
|
||||
// GH#785: Handle bare repo worktrees where jsonlRelPath might include the
|
||||
|
||||
Reference in New Issue
Block a user