Analysis found these commands are dead code: - gt never calls `bd pin` - uses `bd update --status=pinned` instead - Beads.Pin() wrapper exists but is never called - bd hook functionality duplicated by gt mol status - Code comment says "pinned field is cosmetic for bd hook visibility" Removed: - cmd/bd/pin.go - cmd/bd/unpin.go - cmd/bd/hook.go 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
319 lines
9.0 KiB
Go
319 lines
9.0 KiB
Go
//go:build integration
|
|
// +build integration
|
|
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// TestGitPullSyncIntegration tests the full git pull sync scenario
|
|
// Verifies that after git pull, both daemon and non-daemon modes pick up changes automatically
|
|
func TestGitPullSyncIntegration(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
// Create temp directory for test repositories
|
|
tempDir := t.TempDir()
|
|
|
|
// Create "remote" repository
|
|
remoteDir := filepath.Join(tempDir, "remote")
|
|
if err := os.MkdirAll(remoteDir, 0750); err != nil {
|
|
t.Fatalf("Failed to create remote dir: %v", err)
|
|
}
|
|
|
|
// Initialize remote git repo
|
|
runGitCmd(t, remoteDir, "init", "--bare", "-b", "master")
|
|
|
|
// Create "clone1" repository
|
|
clone1Dir := filepath.Join(tempDir, "clone1")
|
|
runGitCmd(t, tempDir, "clone", remoteDir, clone1Dir)
|
|
configureGit(t, clone1Dir)
|
|
|
|
// Initialize beads in clone1
|
|
clone1BeadsDir := filepath.Join(clone1Dir, ".beads")
|
|
if err := os.MkdirAll(clone1BeadsDir, 0750); err != nil {
|
|
t.Fatalf("Failed to create .beads dir: %v", err)
|
|
}
|
|
|
|
clone1DBPath := filepath.Join(clone1BeadsDir, "test.db")
|
|
clone1Store := newTestStore(t, clone1DBPath)
|
|
defer clone1Store.Close()
|
|
|
|
ctx := context.Background()
|
|
if err := clone1Store.SetMetadata(ctx, "issue_prefix", "test"); err != nil {
|
|
t.Fatalf("Failed to set prefix: %v", err)
|
|
}
|
|
|
|
// Create and close an issue in clone1
|
|
issue := &types.Issue{
|
|
Title: "Test sync issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if err := clone1Store.CreateIssue(ctx, issue, "test-user"); err != nil {
|
|
t.Fatalf("Failed to create issue: %v", err)
|
|
}
|
|
issueID := issue.ID
|
|
|
|
// Close the issue
|
|
if err := clone1Store.CloseIssue(ctx, issueID, "Test completed", "test-user"); err != nil {
|
|
t.Fatalf("Failed to close issue: %v", err)
|
|
}
|
|
|
|
// Export to JSONL
|
|
jsonlPath := filepath.Join(clone1BeadsDir, "issues.jsonl")
|
|
if err := exportIssuesToJSONL(ctx, clone1Store, jsonlPath); err != nil {
|
|
t.Fatalf("Failed to export: %v", err)
|
|
}
|
|
|
|
// Commit and push from clone1
|
|
runGitCmd(t, clone1Dir, "add", ".beads")
|
|
runGitCmd(t, clone1Dir, "commit", "-m", "Add closed issue")
|
|
runGitCmd(t, clone1Dir, "push", "origin", "master")
|
|
|
|
// Create "clone2" repository
|
|
clone2Dir := filepath.Join(tempDir, "clone2")
|
|
runGitCmd(t, tempDir, "clone", remoteDir, clone2Dir)
|
|
configureGit(t, clone2Dir)
|
|
|
|
// Initialize empty database in clone2
|
|
clone2BeadsDir := filepath.Join(clone2Dir, ".beads")
|
|
clone2DBPath := filepath.Join(clone2BeadsDir, "test.db")
|
|
clone2Store := newTestStore(t, clone2DBPath)
|
|
defer clone2Store.Close()
|
|
|
|
if err := clone2Store.SetMetadata(ctx, "issue_prefix", "test"); err != nil {
|
|
t.Fatalf("Failed to set prefix: %v", err)
|
|
}
|
|
|
|
// Import the existing JSONL (simulating initial sync)
|
|
clone2JSONLPath := filepath.Join(clone2BeadsDir, "issues.jsonl")
|
|
if err := importJSONLToStore(ctx, clone2Store, clone2DBPath, clone2JSONLPath); err != nil {
|
|
t.Fatalf("Failed to import: %v", err)
|
|
}
|
|
|
|
// Verify issue exists and is closed
|
|
verifyIssueClosed(t, clone2Store, issueID)
|
|
|
|
// Note: We don't commit in clone2 - it stays clean as a read-only consumer
|
|
|
|
// Now test git pull scenario: Clone1 makes a change (update priority)
|
|
if err := clone1Store.UpdateIssue(ctx, issueID, map[string]interface{}{
|
|
"priority": 0,
|
|
}, "test-user"); err != nil {
|
|
t.Fatalf("Failed to update issue: %v", err)
|
|
}
|
|
|
|
if err := exportIssuesToJSONL(ctx, clone1Store, jsonlPath); err != nil {
|
|
t.Fatalf("Failed to export after update: %v", err)
|
|
}
|
|
|
|
runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl")
|
|
runGitCmd(t, clone1Dir, "commit", "-m", "Update priority")
|
|
runGitCmd(t, clone1Dir, "push", "origin", "master")
|
|
|
|
// Clone2 pulls the change
|
|
runGitCmd(t, clone2Dir, "pull")
|
|
|
|
// Test auto-import in non-daemon mode
|
|
t.Run("NonDaemonAutoImport", func(t *testing.T) {
|
|
// Use a temporary local store for this test
|
|
localStore := newTestStore(t, clone2DBPath)
|
|
defer localStore.Close()
|
|
|
|
// Manually import to simulate auto-import behavior
|
|
startTime := time.Now()
|
|
if err := importJSONLToStore(ctx, localStore, clone2DBPath, clone2JSONLPath); err != nil {
|
|
t.Fatalf("Failed to auto-import: %v", err)
|
|
}
|
|
elapsed := time.Since(startTime)
|
|
|
|
// Verify priority was updated
|
|
issue, err := localStore.GetIssue(ctx, issueID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get issue: %v", err)
|
|
}
|
|
if issue.Priority != 0 {
|
|
t.Errorf("Expected priority 0 after auto-import, got %d", issue.Priority)
|
|
}
|
|
|
|
// Verify performance: import should be fast
|
|
if elapsed > 100*time.Millisecond {
|
|
t.Logf("Info: import took %v", elapsed)
|
|
}
|
|
})
|
|
|
|
// Test bd sync --import-only command
|
|
t.Run("BdSyncCommand", func(t *testing.T) {
|
|
// Make another change in clone1 (change priority back to 1)
|
|
if err := clone1Store.UpdateIssue(ctx, issueID, map[string]interface{}{
|
|
"priority": 1,
|
|
}, "test-user"); err != nil {
|
|
t.Fatalf("Failed to update issue: %v", err)
|
|
}
|
|
|
|
if err := exportIssuesToJSONL(ctx, clone1Store, jsonlPath); err != nil {
|
|
t.Fatalf("Failed to export: %v", err)
|
|
}
|
|
|
|
runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl")
|
|
runGitCmd(t, clone1Dir, "commit", "-m", "Update priority")
|
|
runGitCmd(t, clone1Dir, "push", "origin", "master")
|
|
|
|
// Clone2 pulls
|
|
runGitCmd(t, clone2Dir, "pull")
|
|
|
|
// Use a fresh store for import
|
|
syncStore := newTestStore(t, clone2DBPath)
|
|
defer syncStore.Close()
|
|
|
|
// Manually trigger import via in-process equivalent
|
|
if err := importJSONLToStore(ctx, syncStore, clone2DBPath, clone2JSONLPath); err != nil {
|
|
t.Fatalf("Failed to import via sync: %v", err)
|
|
}
|
|
|
|
// Verify priority was updated back to 1
|
|
issue, err := syncStore.GetIssue(ctx, issueID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get issue: %v", err)
|
|
}
|
|
if issue.Priority != 1 {
|
|
t.Errorf("Expected priority 1, got %d", issue.Priority)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func runGitCmd(t *testing.T, dir string, args ...string) {
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
cmd.Env = append(os.Environ(), "GIT_COMMITTER_DATE=2024-01-01T00:00:00", "GIT_AUTHOR_DATE=2024-01-01T00:00:00")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("git %v failed in %s: %v\n%s", args, dir, err, output)
|
|
}
|
|
}
|
|
|
|
func configureGit(t *testing.T, dir string) {
|
|
runGitCmd(t, dir, "config", "user.email", "test@example.com")
|
|
runGitCmd(t, dir, "config", "user.name", "Test User")
|
|
runGitCmd(t, dir, "config", "pull.rebase", "false")
|
|
|
|
// Create .gitignore to prevent test database files from being tracked
|
|
gitignorePath := filepath.Join(dir, ".gitignore")
|
|
gitignoreContent := `# Test database files
|
|
*.db
|
|
*.db-journal
|
|
*.db-wal
|
|
*.db-shm
|
|
`
|
|
if err := os.WriteFile(gitignorePath, []byte(gitignoreContent), 0644); err != nil {
|
|
t.Fatalf("Failed to create .gitignore: %v", err)
|
|
}
|
|
}
|
|
|
|
func exportIssuesToJSONL(ctx context.Context, store *sqlite.SQLiteStorage, jsonlPath string) error {
|
|
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Populate dependencies
|
|
allDeps, err := store.GetAllDependencyRecords(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, issue := range issues {
|
|
issue.Dependencies = allDeps[issue.ID]
|
|
labels, _ := store.GetLabels(ctx, issue.ID)
|
|
issue.Labels = labels
|
|
}
|
|
|
|
f, err := os.Create(jsonlPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
encoder := json.NewEncoder(f)
|
|
for _, issue := range issues {
|
|
if err := encoder.Encode(issue); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func importJSONLToStore(ctx context.Context, store *sqlite.SQLiteStorage, dbPath, jsonlPath string) error {
|
|
data, err := os.ReadFile(jsonlPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Use the autoimport package's AutoImportIfNewer function
|
|
// For testing, we'll directly parse and import
|
|
var issues []*types.Issue
|
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
|
for decoder.More() {
|
|
var issue types.Issue
|
|
if err := decoder.Decode(&issue); err != nil {
|
|
return err
|
|
}
|
|
issues = append(issues, &issue)
|
|
}
|
|
|
|
// Import each issue
|
|
for _, issue := range issues {
|
|
existing, _ := store.GetIssue(ctx, issue.ID)
|
|
if existing != nil {
|
|
// Update
|
|
updates := map[string]interface{}{
|
|
"status": issue.Status,
|
|
"priority": issue.Priority,
|
|
}
|
|
if err := store.UpdateIssue(ctx, issue.ID, updates, "import"); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
// Create
|
|
if err := store.CreateIssue(ctx, issue, "import"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set last_import_time metadata so staleness check works
|
|
if err := store.SetMetadata(ctx, "last_import_time", time.Now().Format(time.RFC3339)); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func verifyIssueClosed(t *testing.T, store *sqlite.SQLiteStorage, issueID string) {
|
|
issue, err := store.GetIssue(context.Background(), issueID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get issue %s: %v", issueID, err)
|
|
}
|
|
if issue.Status != types.StatusClosed {
|
|
t.Errorf("Expected issue %s to be closed, got status %s", issueID, issue.Status)
|
|
}
|
|
}
|