637 lines
20 KiB
Go
637 lines
20 KiB
Go
//go:build integration
|
|
// +build integration
|
|
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/steveyegge/beads/internal/deletions"
|
|
"github.com/steveyegge/beads/internal/importer"
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// importJSONLFile parses a JSONL file and imports using ImportIssues
|
|
func importJSONLFile(ctx context.Context, store *sqlite.SQLiteStorage, dbPath, jsonlPath string, opts importer.Options) (*importer.Result, error) {
|
|
data, err := os.ReadFile(jsonlPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
// Empty import if file doesn't exist
|
|
return importer.ImportIssues(ctx, dbPath, store, nil, opts)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
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 nil, err
|
|
}
|
|
issues = append(issues, &issue)
|
|
}
|
|
|
|
return importer.ImportIssues(ctx, dbPath, store, issues, opts)
|
|
}
|
|
|
|
// TestDeletionPropagation_AcrossClones verifies that when an issue is deleted
|
|
// in one clone, the deletion propagates to other clones via the deletions manifest.
|
|
func TestDeletionPropagation_AcrossClones(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
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)
|
|
}
|
|
runGitCmd(t, remoteDir, "init", "--bare")
|
|
|
|
// Create clone1 (will create and delete issue)
|
|
clone1Dir := filepath.Join(tempDir, "clone1")
|
|
runGitCmd(t, tempDir, "clone", remoteDir, clone1Dir)
|
|
configureGit(t, clone1Dir)
|
|
|
|
// Create clone2 (will receive deletion via sync)
|
|
clone2Dir := filepath.Join(tempDir, "clone2")
|
|
runGitCmd(t, tempDir, "clone", remoteDir, clone2Dir)
|
|
configureGit(t, clone2Dir)
|
|
|
|
// 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, "beads.db")
|
|
clone1Store := newTestStore(t, clone1DBPath)
|
|
defer clone1Store.Close()
|
|
|
|
// Create an issue in clone1
|
|
issue := &types.Issue{
|
|
Title: "Issue to be deleted",
|
|
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
|
|
t.Logf("Created issue: %s", issueID)
|
|
|
|
// Export to JSONL
|
|
clone1JSONLPath := filepath.Join(clone1BeadsDir, "beads.jsonl")
|
|
if err := exportIssuesToJSONL(ctx, clone1Store, clone1JSONLPath); 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 issue")
|
|
runGitCmd(t, clone1Dir, "push", "origin", "master")
|
|
|
|
// Clone2 pulls the issue
|
|
runGitCmd(t, clone2Dir, "pull")
|
|
|
|
// Initialize beads in clone2
|
|
clone2BeadsDir := filepath.Join(clone2Dir, ".beads")
|
|
clone2DBPath := filepath.Join(clone2BeadsDir, "beads.db")
|
|
clone2Store := newTestStore(t, clone2DBPath)
|
|
defer clone2Store.Close()
|
|
|
|
// Import to clone2
|
|
clone2JSONLPath := filepath.Join(clone2BeadsDir, "beads.jsonl")
|
|
result, err := importJSONLFile(ctx, clone2Store, clone2DBPath, clone2JSONLPath, importer.Options{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to import to clone2: %v", err)
|
|
}
|
|
t.Logf("Clone2 import: created=%d, updated=%d", result.Created, result.Updated)
|
|
|
|
// Verify clone2 has the issue
|
|
clone2Issue, err := clone2Store.GetIssue(ctx, issueID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get issue from clone2: %v", err)
|
|
}
|
|
if clone2Issue == nil {
|
|
t.Fatal("Clone2 should have the issue after import")
|
|
}
|
|
t.Log("✓ Both clones have the issue")
|
|
|
|
// Clone1 deletes the issue
|
|
if err := clone1Store.DeleteIssue(ctx, issueID); err != nil {
|
|
t.Fatalf("Failed to delete issue from clone1: %v", err)
|
|
}
|
|
|
|
// Record deletion in manifest
|
|
clone1DeletionsPath := filepath.Join(clone1BeadsDir, "deletions.jsonl")
|
|
delRecord := deletions.DeletionRecord{
|
|
ID: issueID,
|
|
Timestamp: time.Now().UTC(),
|
|
Actor: "test-user",
|
|
Reason: "test deletion",
|
|
}
|
|
if err := deletions.AppendDeletion(clone1DeletionsPath, delRecord); err != nil {
|
|
t.Fatalf("Failed to record deletion: %v", err)
|
|
}
|
|
|
|
// Re-export JSONL (issue is now gone)
|
|
if err := exportIssuesToJSONL(ctx, clone1Store, clone1JSONLPath); err != nil {
|
|
t.Fatalf("Failed to export after deletion: %v", err)
|
|
}
|
|
|
|
// Commit and push deletion
|
|
runGitCmd(t, clone1Dir, "add", ".beads")
|
|
runGitCmd(t, clone1Dir, "commit", "-m", "Delete issue")
|
|
runGitCmd(t, clone1Dir, "push", "origin", "master")
|
|
t.Log("✓ Clone1 deleted issue and pushed")
|
|
|
|
// Clone2 pulls the deletion
|
|
runGitCmd(t, clone2Dir, "pull")
|
|
|
|
// Verify deletions.jsonl was synced to clone2
|
|
clone2DeletionsPath := filepath.Join(clone2BeadsDir, "deletions.jsonl")
|
|
if _, err := os.Stat(clone2DeletionsPath); err != nil {
|
|
t.Fatalf("deletions.jsonl should be synced to clone2: %v", err)
|
|
}
|
|
|
|
// Import to clone2 (should purge the deleted issue)
|
|
result, err = importJSONLFile(ctx, clone2Store, clone2DBPath, clone2JSONLPath, importer.Options{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to import after deletion sync: %v", err)
|
|
}
|
|
t.Logf("Clone2 import after sync: purged=%d, purgedIDs=%v", result.Purged, result.PurgedIDs)
|
|
|
|
// Verify clone2 no longer has the issue
|
|
clone2Issue, err = clone2Store.GetIssue(ctx, issueID)
|
|
if err != nil {
|
|
t.Fatalf("Failed to check issue in clone2: %v", err)
|
|
}
|
|
if clone2Issue != nil {
|
|
t.Errorf("Clone2 should NOT have the issue after sync (deletion should propagate)")
|
|
} else {
|
|
t.Log("✓ Deletion propagated to clone2")
|
|
}
|
|
|
|
// Verify purge count
|
|
if result.Purged != 1 {
|
|
t.Errorf("Expected 1 purged issue, got %d", result.Purged)
|
|
}
|
|
}
|
|
|
|
// TestDeletionPropagation_SimultaneousDeletions verifies that when both clones
|
|
// delete the same issue, the deletions are handled idempotently.
|
|
func TestDeletionPropagation_SimultaneousDeletions(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
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)
|
|
}
|
|
runGitCmd(t, remoteDir, "init", "--bare")
|
|
|
|
// Create clone1
|
|
clone1Dir := filepath.Join(tempDir, "clone1")
|
|
runGitCmd(t, tempDir, "clone", remoteDir, clone1Dir)
|
|
configureGit(t, clone1Dir)
|
|
|
|
// Create clone2
|
|
clone2Dir := filepath.Join(tempDir, "clone2")
|
|
runGitCmd(t, tempDir, "clone", remoteDir, clone2Dir)
|
|
configureGit(t, clone2Dir)
|
|
|
|
// 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, "beads.db")
|
|
clone1Store := newTestStore(t, clone1DBPath)
|
|
defer clone1Store.Close()
|
|
|
|
// Create an issue in clone1
|
|
issue := &types.Issue{
|
|
Title: "Issue deleted by both",
|
|
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
|
|
|
|
// Export and push
|
|
clone1JSONLPath := filepath.Join(clone1BeadsDir, "beads.jsonl")
|
|
if err := exportIssuesToJSONL(ctx, clone1Store, clone1JSONLPath); err != nil {
|
|
t.Fatalf("Failed to export: %v", err)
|
|
}
|
|
runGitCmd(t, clone1Dir, "add", ".beads")
|
|
runGitCmd(t, clone1Dir, "commit", "-m", "Add issue")
|
|
runGitCmd(t, clone1Dir, "push", "origin", "master")
|
|
|
|
// Clone2 pulls and imports
|
|
runGitCmd(t, clone2Dir, "pull")
|
|
|
|
clone2BeadsDir := filepath.Join(clone2Dir, ".beads")
|
|
clone2DBPath := filepath.Join(clone2BeadsDir, "beads.db")
|
|
clone2Store := newTestStore(t, clone2DBPath)
|
|
defer clone2Store.Close()
|
|
|
|
clone2JSONLPath := filepath.Join(clone2BeadsDir, "beads.jsonl")
|
|
if _, err := importJSONLFile(ctx, clone2Store, clone2DBPath, clone2JSONLPath, importer.Options{}); err != nil {
|
|
t.Fatalf("Failed to import to clone2: %v", err)
|
|
}
|
|
|
|
// Both clones delete the issue simultaneously
|
|
// Clone1 deletes
|
|
clone1Store.DeleteIssue(ctx, issueID)
|
|
clone1DeletionsPath := filepath.Join(clone1BeadsDir, "deletions.jsonl")
|
|
deletions.AppendDeletion(clone1DeletionsPath, deletions.DeletionRecord{
|
|
ID: issueID,
|
|
Timestamp: time.Now().UTC(),
|
|
Actor: "user1",
|
|
Reason: "deleted by clone1",
|
|
})
|
|
exportIssuesToJSONL(ctx, clone1Store, clone1JSONLPath)
|
|
|
|
// Clone2 deletes (before pulling clone1's deletion)
|
|
clone2Store.DeleteIssue(ctx, issueID)
|
|
clone2DeletionsPath := filepath.Join(clone2BeadsDir, "deletions.jsonl")
|
|
deletions.AppendDeletion(clone2DeletionsPath, deletions.DeletionRecord{
|
|
ID: issueID,
|
|
Timestamp: time.Now().UTC(),
|
|
Actor: "user2",
|
|
Reason: "deleted by clone2",
|
|
})
|
|
exportIssuesToJSONL(ctx, clone2Store, clone2JSONLPath)
|
|
|
|
t.Log("✓ Both clones deleted the issue locally")
|
|
|
|
// Clone1 commits and pushes first
|
|
runGitCmd(t, clone1Dir, "add", ".beads")
|
|
runGitCmd(t, clone1Dir, "commit", "-m", "Delete issue (clone1)")
|
|
runGitCmd(t, clone1Dir, "push", "origin", "master")
|
|
|
|
// Clone2 commits, pulls (may have conflict), and pushes
|
|
runGitCmd(t, clone2Dir, "add", ".beads")
|
|
runGitCmd(t, clone2Dir, "commit", "-m", "Delete issue (clone2)")
|
|
|
|
// Pull with rebase to handle the concurrent deletion
|
|
// The deletions.jsonl conflict is handled by accepting both (append-only)
|
|
runGitCmdAllowError(t, clone2Dir, "pull", "--rebase")
|
|
|
|
// If there's a conflict in deletions.jsonl, resolve by concatenating
|
|
resolveDeletionsConflict(t, clone2Dir)
|
|
|
|
runGitCmdAllowError(t, clone2Dir, "rebase", "--continue")
|
|
runGitCmdAllowError(t, clone2Dir, "push", "origin", "master")
|
|
|
|
// Verify deletions.jsonl contains both deletion records (deduplicated by ID on load)
|
|
finalDeletionsPath := filepath.Join(clone2BeadsDir, "deletions.jsonl")
|
|
result, err := deletions.LoadDeletions(finalDeletionsPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to load deletions: %v", err)
|
|
}
|
|
|
|
// Should have the deletion record (may be from either clone, deduplication keeps one)
|
|
if _, found := result.Records[issueID]; !found {
|
|
t.Error("Expected deletion record to exist after simultaneous deletions")
|
|
}
|
|
|
|
t.Log("✓ Simultaneous deletions handled correctly (idempotent)")
|
|
}
|
|
|
|
// TestDeletionPropagation_LocalWorkPreserved verifies that local unpushed work
|
|
// is NOT deleted when deletions are synced.
|
|
func TestDeletionPropagation_LocalWorkPreserved(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
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)
|
|
}
|
|
runGitCmd(t, remoteDir, "init", "--bare")
|
|
|
|
// Create clone1
|
|
clone1Dir := filepath.Join(tempDir, "clone1")
|
|
runGitCmd(t, tempDir, "clone", remoteDir, clone1Dir)
|
|
configureGit(t, clone1Dir)
|
|
|
|
// Create clone2
|
|
clone2Dir := filepath.Join(tempDir, "clone2")
|
|
runGitCmd(t, tempDir, "clone", remoteDir, clone2Dir)
|
|
configureGit(t, clone2Dir)
|
|
|
|
// 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, "beads.db")
|
|
clone1Store := newTestStore(t, clone1DBPath)
|
|
defer clone1Store.Close()
|
|
|
|
// Create shared issue in clone1
|
|
sharedIssue := &types.Issue{
|
|
Title: "Shared issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if err := clone1Store.CreateIssue(ctx, sharedIssue, "test-user"); err != nil {
|
|
t.Fatalf("Failed to create shared issue: %v", err)
|
|
}
|
|
sharedID := sharedIssue.ID
|
|
|
|
// Export and push
|
|
clone1JSONLPath := filepath.Join(clone1BeadsDir, "beads.jsonl")
|
|
if err := exportIssuesToJSONL(ctx, clone1Store, clone1JSONLPath); err != nil {
|
|
t.Fatalf("Failed to export: %v", err)
|
|
}
|
|
runGitCmd(t, clone1Dir, "add", ".beads")
|
|
runGitCmd(t, clone1Dir, "commit", "-m", "Add shared issue")
|
|
runGitCmd(t, clone1Dir, "push", "origin", "master")
|
|
|
|
// Clone2 pulls and imports the shared issue
|
|
runGitCmd(t, clone2Dir, "pull")
|
|
|
|
clone2BeadsDir := filepath.Join(clone2Dir, ".beads")
|
|
clone2DBPath := filepath.Join(clone2BeadsDir, "beads.db")
|
|
clone2Store := newTestStore(t, clone2DBPath)
|
|
defer clone2Store.Close()
|
|
|
|
clone2JSONLPath := filepath.Join(clone2BeadsDir, "beads.jsonl")
|
|
if _, err := importJSONLFile(ctx, clone2Store, clone2DBPath, clone2JSONLPath, importer.Options{}); err != nil {
|
|
t.Fatalf("Failed to import to clone2: %v", err)
|
|
}
|
|
|
|
// Clone2 creates LOCAL work (not synced)
|
|
localIssue := &types.Issue{
|
|
Title: "Local work in clone2",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if err := clone2Store.CreateIssue(ctx, localIssue, "clone2-user"); err != nil {
|
|
t.Fatalf("Failed to create local issue: %v", err)
|
|
}
|
|
localID := localIssue.ID
|
|
t.Logf("Clone2 created local issue: %s", localID)
|
|
|
|
// Clone1 deletes the shared issue
|
|
clone1Store.DeleteIssue(ctx, sharedID)
|
|
clone1DeletionsPath := filepath.Join(clone1BeadsDir, "deletions.jsonl")
|
|
deletions.AppendDeletion(clone1DeletionsPath, deletions.DeletionRecord{
|
|
ID: sharedID,
|
|
Timestamp: time.Now().UTC(),
|
|
Actor: "clone1-user",
|
|
Reason: "cleanup",
|
|
})
|
|
exportIssuesToJSONL(ctx, clone1Store, clone1JSONLPath)
|
|
runGitCmd(t, clone1Dir, "add", ".beads")
|
|
runGitCmd(t, clone1Dir, "commit", "-m", "Delete shared issue")
|
|
runGitCmd(t, clone1Dir, "push", "origin", "master")
|
|
|
|
// Clone2 pulls and imports (should delete shared, preserve local)
|
|
runGitCmd(t, clone2Dir, "pull")
|
|
result, err := importJSONLFile(ctx, clone2Store, clone2DBPath, clone2JSONLPath, importer.Options{})
|
|
if err != nil {
|
|
t.Fatalf("Failed to import after pull: %v", err)
|
|
}
|
|
t.Logf("Clone2 import: purged=%d, purgedIDs=%v", result.Purged, result.PurgedIDs)
|
|
|
|
// Verify shared issue is gone
|
|
sharedCheck, _ := clone2Store.GetIssue(ctx, sharedID)
|
|
if sharedCheck != nil {
|
|
t.Error("Shared issue should be deleted")
|
|
}
|
|
|
|
// Verify local issue is preserved
|
|
localCheck, _ := clone2Store.GetIssue(ctx, localID)
|
|
if localCheck == nil {
|
|
t.Error("Local work should be preserved (not in deletions manifest)")
|
|
}
|
|
|
|
t.Log("✓ Local work preserved while synced deletions propagated")
|
|
}
|
|
|
|
// TestDeletionPropagation_CorruptLineRecovery verifies that corrupt lines
|
|
// in deletions.jsonl are skipped gracefully during import.
|
|
func TestDeletionPropagation_CorruptLineRecovery(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
tempDir := t.TempDir()
|
|
|
|
// Setup single clone for this test
|
|
beadsDir := filepath.Join(tempDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
|
t.Fatalf("Failed to create .beads dir: %v", err)
|
|
}
|
|
|
|
dbPath := filepath.Join(beadsDir, "beads.db")
|
|
store := newTestStore(t, dbPath)
|
|
defer store.Close()
|
|
|
|
// Create two issues
|
|
issue1 := &types.Issue{
|
|
Title: "Issue 1 (to be deleted)",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
issue2 := &types.Issue{
|
|
Title: "Issue 2 (to keep)",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
store.CreateIssue(ctx, issue1, "test-user")
|
|
store.CreateIssue(ctx, issue2, "test-user")
|
|
|
|
// Create deletions.jsonl with corrupt lines + valid deletion for issue1
|
|
deletionsPath := filepath.Join(beadsDir, "deletions.jsonl")
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
corruptContent := `this is not valid json
|
|
{"broken
|
|
{"id":"` + issue1.ID + `","ts":"` + now + `","by":"test-user","reason":"valid deletion"}
|
|
more garbage {{{
|
|
`
|
|
if err := os.WriteFile(deletionsPath, []byte(corruptContent), 0644); err != nil {
|
|
t.Fatalf("Failed to write corrupt deletions: %v", err)
|
|
}
|
|
|
|
// Load deletions - should skip corrupt lines but parse valid one
|
|
result, err := deletions.LoadDeletions(deletionsPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadDeletions should not fail on corrupt lines: %v", err)
|
|
}
|
|
|
|
if result.Skipped != 3 {
|
|
t.Errorf("Expected 3 skipped lines, got %d", result.Skipped)
|
|
}
|
|
|
|
if len(result.Records) != 1 {
|
|
t.Errorf("Expected 1 valid record, got %d", len(result.Records))
|
|
}
|
|
|
|
if _, found := result.Records[issue1.ID]; !found {
|
|
t.Error("Valid deletion record should be parsed")
|
|
}
|
|
|
|
if len(result.Warnings) != 3 {
|
|
t.Errorf("Expected 3 warnings, got %d", len(result.Warnings))
|
|
}
|
|
|
|
t.Logf("Warnings: %v", result.Warnings)
|
|
t.Log("✓ Corrupt deletions.jsonl lines handled gracefully")
|
|
}
|
|
|
|
// TestDeletionPropagation_EmptyManifest verifies that import works with
|
|
// empty or missing deletions manifest.
|
|
func TestDeletionPropagation_EmptyManifest(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
tempDir := t.TempDir()
|
|
|
|
beadsDir := filepath.Join(tempDir, ".beads")
|
|
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
|
t.Fatalf("Failed to create .beads dir: %v", err)
|
|
}
|
|
|
|
dbPath := filepath.Join(beadsDir, "beads.db")
|
|
store := newTestStore(t, dbPath)
|
|
defer store.Close()
|
|
|
|
// Create an issue
|
|
issue := &types.Issue{
|
|
Title: "Test issue",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeTask,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
store.CreateIssue(ctx, issue, "test-user")
|
|
|
|
// Export to JSONL
|
|
jsonlPath := filepath.Join(beadsDir, "beads.jsonl")
|
|
if err := exportIssuesToJSONL(ctx, store, jsonlPath); err != nil {
|
|
t.Fatalf("Failed to export: %v", err)
|
|
}
|
|
|
|
// Test 1: No deletions.jsonl exists
|
|
result, err := importJSONLFile(ctx, store, dbPath, jsonlPath, importer.Options{})
|
|
if err != nil {
|
|
t.Fatalf("Import should succeed without deletions.jsonl: %v", err)
|
|
}
|
|
if result.Purged != 0 {
|
|
t.Errorf("Expected 0 purged with no deletions manifest, got %d", result.Purged)
|
|
}
|
|
t.Log("✓ Import works without deletions.jsonl")
|
|
|
|
// Test 2: Empty deletions.jsonl
|
|
deletionsPath := filepath.Join(beadsDir, "deletions.jsonl")
|
|
if err := os.WriteFile(deletionsPath, []byte{}, 0644); err != nil {
|
|
t.Fatalf("Failed to create empty deletions.jsonl: %v", err)
|
|
}
|
|
|
|
result, err = importJSONLFile(ctx, store, dbPath, jsonlPath, importer.Options{})
|
|
if err != nil {
|
|
t.Fatalf("Import should succeed with empty deletions.jsonl: %v", err)
|
|
}
|
|
if result.Purged != 0 {
|
|
t.Errorf("Expected 0 purged with empty deletions manifest, got %d", result.Purged)
|
|
}
|
|
t.Log("✓ Import works with empty deletions.jsonl")
|
|
|
|
// Verify issue still exists
|
|
check, _ := store.GetIssue(ctx, issue.ID)
|
|
if check == nil {
|
|
t.Error("Issue should still exist")
|
|
}
|
|
}
|
|
|
|
// Helper to resolve deletions.jsonl conflicts by keeping all lines
|
|
func resolveDeletionsConflict(t *testing.T, dir string) {
|
|
t.Helper()
|
|
deletionsPath := filepath.Join(dir, ".beads", "deletions.jsonl")
|
|
content, err := os.ReadFile(deletionsPath)
|
|
if err != nil {
|
|
return // No conflict file
|
|
}
|
|
|
|
if !strings.Contains(string(content), "<<<<<<<") {
|
|
return // No conflict markers
|
|
}
|
|
|
|
// Remove conflict markers, keep all deletion records
|
|
var cleanLines []string
|
|
for _, line := range strings.Split(string(content), "\n") {
|
|
if strings.HasPrefix(line, "<<<<<<<") ||
|
|
strings.HasPrefix(line, "=======") ||
|
|
strings.HasPrefix(line, ">>>>>>>") {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(line) != "" && strings.HasPrefix(line, "{") {
|
|
cleanLines = append(cleanLines, line)
|
|
}
|
|
}
|
|
|
|
cleaned := strings.Join(cleanLines, "\n") + "\n"
|
|
os.WriteFile(deletionsPath, []byte(cleaned), 0644)
|
|
runGitCmdAllowError(t, dir, "add", deletionsPath)
|
|
}
|
|
|
|
// runGitCmdAllowError runs git command and ignores errors
|
|
func runGitCmdAllowError(t *testing.T, dir string, args ...string) {
|
|
t.Helper()
|
|
cmd := runCommandInDir(dir, "git", args...)
|
|
_ = cmd // ignore error
|
|
}
|