Problem: When a JSONL file hash mismatch was detected (e.g., after git operations that modify the JSONL without updating export_hashes), the autoflush system would trigger a full re-export. During this re-export, all issue comments were silently dropped from the exported JSONL file. Steps to reproduce: 1. Have a beads database with issues containing comments 2. Create a situation where JSONL hash doesn't match stored hash (e.g., clone a repo, or manual JSONL edits) 3. Run any bd command that triggers autoflush (e.g., `bd create foo`) 4. Observe warning: "JSONL file hash mismatch detected" 5. Check .beads/issues.jsonl - all comments are now missing Root cause: Two different export code paths existed: - exportToJSONLWithStore (daemon_sync.go) - correctly populated comments - fetchAndMergeIssues (autoflush.go) - only fetched dependencies, NOT comments When hash mismatch triggered a full re-export via the autoflush path, fetchAndMergeIssues was called but it never populated issue.Comments, resulting in all comments being lost. Fix: Add GetIssueComments call in fetchAndMergeIssues to populate comments for each issue before export, matching the behavior of exportToJSONLWithStore. Note: Labels were not affected because GetIssue() already populates them internally. Comments are stored in a separate table and require explicit fetching via GetIssueComments().
284 lines
8.9 KiB
Go
284 lines
8.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/beads"
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// TestFindJSONLPath_RelativeDbPath tests that findJSONLPath() returns an absolute
|
|
// path or empty string, never a relative path. This is critical for daemon_sync_branch.go
|
|
// which calls filepath.Rel(repoRoot, jsonlPath) - mixing absolute base with relative
|
|
// target fails.
|
|
//
|
|
// Bug: When dbPath is set to a relative fallback (e.g., ".beads/beads.db"),
|
|
// findJSONLPath() returns ".beads/issues.jsonl" (relative), causing:
|
|
// "Rel: can't make .beads/issues.jsonl relative to /path/to/repo"
|
|
//
|
|
// See: https://github.com/steveyegge/beads/issues/959
|
|
func TestFindJSONLPath_RelativeDbPath(t *testing.T) {
|
|
// Save/restore global state
|
|
origDbPath := dbPath
|
|
defer func() { dbPath = origDbPath }()
|
|
|
|
// Set relative dbPath (triggers bug)
|
|
// This simulates the fallback in main.go:471 when no database is found
|
|
dbPath = filepath.Join(".beads", beads.CanonicalDatabaseName)
|
|
|
|
result := findJSONLPath()
|
|
|
|
// Bug: returns ".beads/issues.jsonl" (relative)
|
|
// Fixed: returns absolute path or ""
|
|
if result != "" && !filepath.IsAbs(result) {
|
|
t.Errorf("findJSONLPath() returned relative path: %q\n"+
|
|
"Expected: absolute path or empty string\n"+
|
|
"This will cause filepath.Rel() to fail in daemon_sync_branch.go",
|
|
result)
|
|
}
|
|
}
|
|
|
|
// TestFindJSONLPath_BEADS_JSONL_Relative tests that BEADS_JSONL env var is
|
|
// canonicalized to absolute path even when set to a relative value.
|
|
func TestFindJSONLPath_BEADS_JSONL_Relative(t *testing.T) {
|
|
// Save/restore global state
|
|
origDbPath := dbPath
|
|
origEnv := os.Getenv("BEADS_JSONL")
|
|
defer func() {
|
|
dbPath = origDbPath
|
|
if origEnv != "" {
|
|
os.Setenv("BEADS_JSONL", origEnv)
|
|
} else {
|
|
os.Unsetenv("BEADS_JSONL")
|
|
}
|
|
}()
|
|
|
|
// Set relative BEADS_JSONL (should be canonicalized)
|
|
dbPath = ""
|
|
os.Setenv("BEADS_JSONL", "./custom/path/issues.jsonl")
|
|
|
|
result := findJSONLPath()
|
|
|
|
// The env var path should be canonicalized to absolute
|
|
if result != "" && !filepath.IsAbs(result) {
|
|
t.Errorf("findJSONLPath() with BEADS_JSONL returned relative path: %q\n"+
|
|
"Expected: absolute path (canonicalized from env var)",
|
|
result)
|
|
}
|
|
}
|
|
|
|
// TestFindJSONLPath_BEADS_DIR_Relative tests that BEADS_DIR env var is
|
|
// canonicalized to absolute path even when set to a relative value.
|
|
// Note: BEADS_DIR is already canonicalized in FindBeadsDir() (line 428),
|
|
// but this test verifies the invariant holds through findJSONLPath().
|
|
func TestFindJSONLPath_BEADS_DIR_Relative(t *testing.T) {
|
|
// Save/restore global state
|
|
origDbPath := dbPath
|
|
origBeadsDir := os.Getenv("BEADS_DIR")
|
|
origBeadsJSONL := os.Getenv("BEADS_JSONL")
|
|
defer func() {
|
|
dbPath = origDbPath
|
|
if origBeadsDir != "" {
|
|
os.Setenv("BEADS_DIR", origBeadsDir)
|
|
} else {
|
|
os.Unsetenv("BEADS_DIR")
|
|
}
|
|
if origBeadsJSONL != "" {
|
|
os.Setenv("BEADS_JSONL", origBeadsJSONL)
|
|
} else {
|
|
os.Unsetenv("BEADS_JSONL")
|
|
}
|
|
}()
|
|
|
|
// Create temp directory with .beads structure
|
|
tmpDir := t.TempDir()
|
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
|
t.Fatalf("Failed to create .beads dir: %v", err)
|
|
}
|
|
// Create issues.jsonl to make it a valid beads directory
|
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
|
if err := os.WriteFile(jsonlPath, []byte(""), 0644); err != nil {
|
|
t.Fatalf("Failed to create issues.jsonl: %v", err)
|
|
}
|
|
|
|
// Change to parent of tmpDir so relative path works
|
|
origWd, _ := os.Getwd()
|
|
defer os.Chdir(origWd)
|
|
os.Chdir(filepath.Dir(tmpDir))
|
|
|
|
// Set relative BEADS_DIR (should be canonicalized by FindBeadsDir)
|
|
relPath := filepath.Base(tmpDir) + "/.beads"
|
|
os.Setenv("BEADS_DIR", relPath)
|
|
os.Unsetenv("BEADS_JSONL")
|
|
dbPath = ""
|
|
|
|
result := findJSONLPath()
|
|
|
|
// The path should be absolute (BEADS_DIR canonicalized by FindBeadsDir)
|
|
if result != "" && !filepath.IsAbs(result) {
|
|
t.Errorf("findJSONLPath() with relative BEADS_DIR returned relative path: %q\n"+
|
|
"Expected: absolute path (canonicalized from BEADS_DIR)",
|
|
result)
|
|
}
|
|
}
|
|
|
|
// TestFindJSONLPath_EmptyDbPath tests that empty dbPath returns empty string
|
|
// or falls back to FindBeadsDir() path (which should be absolute).
|
|
func TestFindJSONLPath_EmptyDbPath(t *testing.T) {
|
|
// Save/restore global state
|
|
origDbPath := dbPath
|
|
origEnv := os.Getenv("BEADS_JSONL")
|
|
defer func() {
|
|
dbPath = origDbPath
|
|
if origEnv != "" {
|
|
os.Setenv("BEADS_JSONL", origEnv)
|
|
} else {
|
|
os.Unsetenv("BEADS_JSONL")
|
|
}
|
|
}()
|
|
|
|
// Clear both dbPath and BEADS_JSONL
|
|
dbPath = ""
|
|
os.Unsetenv("BEADS_JSONL")
|
|
|
|
result := findJSONLPath()
|
|
|
|
// Result should be absolute or empty (FindBeadsDir returns absolute or "")
|
|
if result != "" && !filepath.IsAbs(result) {
|
|
t.Errorf("findJSONLPath() with empty dbPath returned relative path: %q\n"+
|
|
"Expected: absolute path or empty string",
|
|
result)
|
|
}
|
|
}
|
|
|
|
// TestFetchAndMergeIssues_IncludesComments verifies that fetchAndMergeIssues
|
|
// populates comments on issues fetched from the database.
|
|
// Bug: fetchAndMergeIssues was only fetching dependencies, not comments or labels.
|
|
// This caused comments to be lost during autoflush full-export triggered by hash mismatch.
|
|
func TestFetchAndMergeIssues_IncludesComments(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
dbPath := filepath.Join(tmpDir, ".beads", "beads.db")
|
|
|
|
// Create storage
|
|
store, err := sqlite.New(context.Background(), dbPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to create store: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Set issue_prefix to prevent "database not initialized" errors
|
|
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
|
t.Fatalf("failed to set issue_prefix: %v", err)
|
|
}
|
|
|
|
// Create test issue
|
|
issue := &types.Issue{
|
|
ID: "test-1",
|
|
Title: "Test Issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
|
t.Fatalf("failed to create issue: %v", err)
|
|
}
|
|
|
|
// Add a comment to the issue (use AddIssueComment which writes to comments table)
|
|
if _, err := store.AddIssueComment(ctx, "test-1", "tester", "This is a test comment"); err != nil {
|
|
t.Fatalf("failed to add comment: %v", err)
|
|
}
|
|
|
|
// Call fetchAndMergeIssues
|
|
issueMap := make(map[string]*types.Issue)
|
|
dirtyIDs := []string{"test-1"}
|
|
if err := fetchAndMergeIssues(ctx, store, dirtyIDs, issueMap); err != nil {
|
|
t.Fatalf("fetchAndMergeIssues failed: %v", err)
|
|
}
|
|
|
|
// Verify issue was fetched
|
|
fetchedIssue, ok := issueMap["test-1"]
|
|
if !ok {
|
|
t.Fatal("issue test-1 not found in issueMap")
|
|
}
|
|
|
|
// Verify comments were populated
|
|
if len(fetchedIssue.Comments) == 0 {
|
|
t.Errorf("fetchAndMergeIssues did not populate comments: expected 1 comment, got 0")
|
|
} else if len(fetchedIssue.Comments) != 1 {
|
|
t.Errorf("fetchAndMergeIssues: expected 1 comment, got %d", len(fetchedIssue.Comments))
|
|
} else if fetchedIssue.Comments[0].Text != "This is a test comment" {
|
|
t.Errorf("comment text mismatch: expected 'This is a test comment', got %q", fetchedIssue.Comments[0].Text)
|
|
}
|
|
}
|
|
|
|
// TestFetchAndMergeIssues_IncludesMultipleComments verifies that fetchAndMergeIssues
|
|
// populates all comments on issues, not just one.
|
|
func TestFetchAndMergeIssues_IncludesMultipleComments(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
dbPath := filepath.Join(tmpDir, ".beads", "beads.db")
|
|
|
|
// Create storage
|
|
store, err := sqlite.New(context.Background(), dbPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to create store: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Set issue_prefix
|
|
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
|
t.Fatalf("failed to set issue_prefix: %v", err)
|
|
}
|
|
|
|
// Create test issue
|
|
issue := &types.Issue{
|
|
ID: "test-1",
|
|
Title: "Test Issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
|
t.Fatalf("failed to create issue: %v", err)
|
|
}
|
|
|
|
// Add multiple comments (use AddIssueComment which writes to comments table)
|
|
if _, err := store.AddIssueComment(ctx, "test-1", "alice", "First comment"); err != nil {
|
|
t.Fatalf("failed to add first comment: %v", err)
|
|
}
|
|
if _, err := store.AddIssueComment(ctx, "test-1", "bob", "Second comment"); err != nil {
|
|
t.Fatalf("failed to add second comment: %v", err)
|
|
}
|
|
|
|
// Call fetchAndMergeIssues
|
|
issueMap := make(map[string]*types.Issue)
|
|
dirtyIDs := []string{"test-1"}
|
|
if err := fetchAndMergeIssues(ctx, store, dirtyIDs, issueMap); err != nil {
|
|
t.Fatalf("fetchAndMergeIssues failed: %v", err)
|
|
}
|
|
|
|
// Verify issue was fetched
|
|
fetchedIssue, ok := issueMap["test-1"]
|
|
if !ok {
|
|
t.Fatal("issue test-1 not found in issueMap")
|
|
}
|
|
|
|
// Verify all comments were populated
|
|
if len(fetchedIssue.Comments) != 2 {
|
|
t.Errorf("fetchAndMergeIssues: expected 2 comments, got %d", len(fetchedIssue.Comments))
|
|
}
|
|
}
|