refactor: remove all deletions.jsonl code (bd-fom)
Complete removal of the legacy deletions.jsonl manifest system. Tombstones are now the sole deletion mechanism. Removed: - internal/deletions/ - entire package - cmd/bd/deleted.go - deleted command - cmd/bd/doctor/fix/deletions.go - HydrateDeletionsManifest - Tests for all removed functionality Cleaned: - cmd/bd/sync.go - removed sanitize, auto-compact - cmd/bd/delete.go - removed dual-writes - cmd/bd/doctor.go - removed checkDeletionsManifest - internal/importer/importer.go - removed deletions checks - internal/syncbranch/worktree.go - removed deletions merge - cmd/bd/integrity.go - updated validation (warn-only on decrease) Files removed: 12 Lines removed: ~7500 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,248 +0,0 @@
|
||||
package fix
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/deletions"
|
||||
)
|
||||
|
||||
// HydrateDeletionsManifest populates deletions.jsonl from git history.
|
||||
// It finds all issue IDs that were ever in the JSONL but are no longer present,
|
||||
// and adds them to the deletions manifest.
|
||||
// Note (bd-ffr9): After tombstone migration, this is a no-op since inline tombstones
|
||||
// are used instead of deletions.jsonl.
|
||||
func HydrateDeletionsManifest(path string) error {
|
||||
if err := validateBeadsWorkspace(path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
beadsDir := filepath.Join(path, ".beads")
|
||||
|
||||
// bd-ffr9: Skip hydrating deletions.jsonl if tombstone migration is complete
|
||||
if deletions.IsTombstoneMigrationComplete(beadsDir) {
|
||||
fmt.Println(" Tombstone migration complete - skipping deletions.jsonl hydration")
|
||||
return nil
|
||||
}
|
||||
|
||||
// bd-6xd: issues.jsonl is the canonical filename
|
||||
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
|
||||
// Also check for legacy beads.jsonl
|
||||
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
|
||||
legacyPath := filepath.Join(beadsDir, "beads.jsonl")
|
||||
if _, err := os.Stat(legacyPath); err == nil {
|
||||
jsonlPath = legacyPath
|
||||
} else {
|
||||
return fmt.Errorf("no JSONL file found in .beads/")
|
||||
}
|
||||
}
|
||||
|
||||
// Load existing deletions manifest to avoid duplicates
|
||||
deletionsPath := deletions.DefaultPath(beadsDir)
|
||||
existingDeletions, err := deletions.LoadDeletions(deletionsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load existing deletions: %w", err)
|
||||
}
|
||||
|
||||
// Get current IDs from JSONL
|
||||
currentIDs, err := getCurrentJSONLIDs(jsonlPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read current JSONL: %w", err)
|
||||
}
|
||||
|
||||
// Get historical IDs from git
|
||||
historicalIDs, err := getHistoricalJSONLIDs(path, jsonlPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get historical IDs from git: %w", err)
|
||||
}
|
||||
|
||||
// Find deleted IDs (in history but not in current, and not already in manifest)
|
||||
var deletedIDs []string
|
||||
for id := range historicalIDs {
|
||||
if !currentIDs[id] {
|
||||
// Skip if already in deletions manifest
|
||||
if _, exists := existingDeletions.Records[id]; exists {
|
||||
continue
|
||||
}
|
||||
deletedIDs = append(deletedIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
if len(deletedIDs) == 0 {
|
||||
// Create empty deletions manifest to signal hydration is complete
|
||||
// This prevents the check from re-warning after --fix runs
|
||||
if err := deletions.WriteDeletions(deletionsPath, nil); err != nil {
|
||||
return fmt.Errorf("failed to create empty deletions manifest: %w", err)
|
||||
}
|
||||
fmt.Println(" No deleted issues found in git history (created empty manifest)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add to deletions manifest
|
||||
now := time.Now()
|
||||
|
||||
for _, id := range deletedIDs {
|
||||
record := deletions.DeletionRecord{
|
||||
ID: id,
|
||||
Timestamp: now,
|
||||
Actor: "bd-doctor-hydrate",
|
||||
Reason: "Hydrated from git history",
|
||||
}
|
||||
if err := deletions.AppendDeletion(deletionsPath, record); err != nil {
|
||||
return fmt.Errorf("failed to append deletion record for %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf(" Added %d deletion records to manifest\n", len(deletedIDs))
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCurrentJSONLIDs reads the current JSONL file and returns a set of IDs.
|
||||
func getCurrentJSONLIDs(jsonlPath string) (map[string]bool, error) {
|
||||
ids := make(map[string]bool)
|
||||
|
||||
file, err := os.Open(jsonlPath) // #nosec G304 - path validated by caller
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ids, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var issue struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &issue); err != nil {
|
||||
continue
|
||||
}
|
||||
// Include ALL issues including tombstones (bd-552 fix)
|
||||
// Tombstones represent migrated deletions that ARE accounted for.
|
||||
// By including them in currentIDs, they won't appear "missing" when
|
||||
// compared to historicalIDs, preventing erroneous re-addition to
|
||||
// deletions.jsonl. The previous bd-in7q fix had backwards logic.
|
||||
if issue.ID != "" {
|
||||
ids[issue.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
return ids, scanner.Err()
|
||||
}
|
||||
|
||||
// getHistoricalJSONLIDs uses git log to find all IDs that were ever in the JSONL.
|
||||
func getHistoricalJSONLIDs(repoPath, jsonlPath string) (map[string]bool, error) {
|
||||
// Get the relative path for the JSONL file
|
||||
relPath, err := filepath.Rel(repoPath, jsonlPath)
|
||||
if err != nil {
|
||||
relPath = jsonlPath
|
||||
}
|
||||
|
||||
// Use the commit-by-commit approach which is more memory efficient
|
||||
// and allows us to properly parse JSON rather than regex matching
|
||||
return getHistoricalIDsViaDiff(repoPath, relPath)
|
||||
}
|
||||
|
||||
// looksLikeIssueID validates that a string looks like a beads issue ID.
|
||||
// Issue IDs have the format: prefix-hash or prefix-number (e.g., bd-abc123, myproject-42)
|
||||
func looksLikeIssueID(id string) bool {
|
||||
if id == "" {
|
||||
return false
|
||||
}
|
||||
// Must contain at least one dash
|
||||
dashIdx := strings.Index(id, "-")
|
||||
if dashIdx <= 0 || dashIdx >= len(id)-1 {
|
||||
return false
|
||||
}
|
||||
// Prefix should be alphanumeric (letters/numbers/underscores)
|
||||
prefix := id[:dashIdx]
|
||||
for _, c := range prefix {
|
||||
isValidPrefixChar := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
|
||||
if !isValidPrefixChar {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Suffix should be alphanumeric (base36 hash or number), may contain dots for children
|
||||
suffix := id[dashIdx+1:]
|
||||
for _, c := range suffix {
|
||||
isValidSuffixChar := (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.'
|
||||
if !isValidSuffixChar {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// getHistoricalIDsViaDiff walks through git history commit-by-commit to find all IDs.
|
||||
// This is more memory efficient than git log -p and allows proper JSON parsing.
|
||||
func getHistoricalIDsViaDiff(repoPath, relPath string) (map[string]bool, error) {
|
||||
ids := make(map[string]bool)
|
||||
|
||||
// Get list of all commits that touched the file
|
||||
cmd := exec.Command("git", "log", "--all", "--format=%H", "--", relPath)
|
||||
cmd.Dir = repoPath
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ids, fmt.Errorf("git log failed: %w", err)
|
||||
}
|
||||
|
||||
commits := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(commits) == 0 || (len(commits) == 1 && commits[0] == "") {
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// For each commit, get the file content and extract IDs
|
||||
for _, commit := range commits {
|
||||
if commit == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get file content at this commit
|
||||
showCmd := exec.Command("git", "show", commit+":"+relPath) // #nosec G204 - args are from git log output
|
||||
showCmd.Dir = repoPath
|
||||
|
||||
content, err := showCmd.Output()
|
||||
if err != nil {
|
||||
// File might not exist at this commit
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse each line for IDs
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(content)))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.Contains(line, `"id"`) {
|
||||
var issue struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &issue); err == nil && issue.ID != "" {
|
||||
// Validate the ID looks like an issue ID to avoid false positives
|
||||
if looksLikeIssueID(issue.ID) {
|
||||
ids[issue.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package fix
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// TestGetCurrentJSONLIDs_IncludesTombstones verifies that tombstones ARE included
|
||||
// in the current ID set. This is critical for bd-552 fix: tombstones represent
|
||||
// migrated deletions that are accounted for. By including them, they won't appear
|
||||
// "missing" when compared to historicalIDs, preventing erroneous re-addition to
|
||||
// deletions.jsonl.
|
||||
func TestGetCurrentJSONLIDs_IncludesTombstones(t *testing.T) {
|
||||
// Setup: Create temp file with mix of normal issues and tombstones
|
||||
tmpDir := t.TempDir()
|
||||
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
||||
|
||||
// Create a JSONL file with both normal issues and tombstones
|
||||
issues := []*types.Issue{
|
||||
{
|
||||
ID: "bd-abc",
|
||||
Title: "Normal issue",
|
||||
Status: types.StatusOpen,
|
||||
},
|
||||
{
|
||||
ID: "bd-def",
|
||||
Title: "(deleted)",
|
||||
Status: types.StatusTombstone,
|
||||
DeletedBy: "test-user",
|
||||
},
|
||||
{
|
||||
ID: "bd-ghi",
|
||||
Title: "Another normal issue",
|
||||
Status: types.StatusOpen,
|
||||
},
|
||||
{
|
||||
ID: "bd-jkl",
|
||||
Title: "(deleted)",
|
||||
Status: types.StatusTombstone,
|
||||
DeletedBy: "test-user",
|
||||
},
|
||||
}
|
||||
|
||||
file, err := os.Create(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test JSONL file: %v", err)
|
||||
}
|
||||
encoder := json.NewEncoder(file)
|
||||
for _, issue := range issues {
|
||||
if err := encoder.Encode(issue); err != nil {
|
||||
_ = file.Close()
|
||||
t.Fatalf("Failed to write issue to JSONL: %v", err)
|
||||
}
|
||||
}
|
||||
_ = file.Close()
|
||||
|
||||
// Call getCurrentJSONLIDs
|
||||
ids, err := getCurrentJSONLIDs(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("getCurrentJSONLIDs failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify: Should contain ALL IDs including tombstones (bd-552 fix)
|
||||
expectedIDs := map[string]bool{
|
||||
"bd-abc": true,
|
||||
"bd-def": true, // tombstone - must be included
|
||||
"bd-ghi": true,
|
||||
"bd-jkl": true, // tombstone - must be included
|
||||
}
|
||||
|
||||
if len(ids) != len(expectedIDs) {
|
||||
t.Errorf("Expected %d IDs, got %d. IDs: %v", len(expectedIDs), len(ids), ids)
|
||||
}
|
||||
|
||||
for expectedID := range expectedIDs {
|
||||
if !ids[expectedID] {
|
||||
t.Errorf("Expected ID %s to be present", expectedID)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify tombstones ARE included (this is the bd-552 fix)
|
||||
if !ids["bd-def"] {
|
||||
t.Error("Tombstone bd-def MUST be included in current IDs (bd-552 fix)")
|
||||
}
|
||||
if !ids["bd-jkl"] {
|
||||
t.Error("Tombstone bd-jkl MUST be included in current IDs (bd-552 fix)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCurrentJSONLIDs_HandlesEmptyFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
||||
|
||||
// Create empty file
|
||||
if _, err := os.Create(jsonlPath); err != nil {
|
||||
t.Fatalf("Failed to create empty file: %v", err)
|
||||
}
|
||||
|
||||
ids, err := getCurrentJSONLIDs(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("getCurrentJSONLIDs failed: %v", err)
|
||||
}
|
||||
|
||||
if len(ids) != 0 {
|
||||
t.Errorf("Expected 0 IDs from empty file, got %d", len(ids))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCurrentJSONLIDs_HandlesMissingFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
nonexistentPath := filepath.Join(tmpDir, "nonexistent.jsonl")
|
||||
|
||||
ids, err := getCurrentJSONLIDs(nonexistentPath)
|
||||
if err != nil {
|
||||
t.Fatalf("getCurrentJSONLIDs should handle missing file gracefully: %v", err)
|
||||
}
|
||||
|
||||
if len(ids) != 0 {
|
||||
t.Errorf("Expected 0 IDs from missing file, got %d", len(ids))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCurrentJSONLIDs_SkipsInvalidJSON(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
jsonlPath := filepath.Join(tmpDir, "issues.jsonl")
|
||||
|
||||
// Write mixed valid and invalid JSON lines
|
||||
content := `{"id":"bd-valid","status":"open"}
|
||||
invalid json line
|
||||
{"id":"bd-another","status":"open"}
|
||||
`
|
||||
if err := os.WriteFile(jsonlPath, []byte(content), 0600); err != nil {
|
||||
t.Fatalf("Failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
ids, err := getCurrentJSONLIDs(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("getCurrentJSONLIDs failed: %v", err)
|
||||
}
|
||||
|
||||
if len(ids) != 2 {
|
||||
t.Errorf("Expected 2 valid IDs, got %d. IDs: %v", len(ids), ids)
|
||||
}
|
||||
if !ids["bd-valid"] || !ids["bd-another"] {
|
||||
t.Error("Expected to parse both valid issues despite invalid line in between")
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Full integration test for HydrateDeletionsManifest would require git repo setup.
|
||||
// The unit tests above verify the core fix (bd-552: including tombstones in getCurrentJSONLIDs
|
||||
// so they aren't erroneously re-added to deletions.jsonl).
|
||||
// Integration tests are handled in migrate_tombstones_test.go with full sync cycle.
|
||||
@@ -37,17 +37,6 @@ func DatabaseVersion(path string) error {
|
||||
return fmt.Errorf("failed to initialize database: %w", err)
|
||||
}
|
||||
|
||||
// bd-8v5o: Clean up deletions manifest for hydrated issues
|
||||
// After init, remove any issues from deletions.jsonl that exist in JSONL
|
||||
// This prevents perpetual "Skipping bd-xxx (in deletions manifest)" warnings
|
||||
jsonlPath := findJSONLPath(beadsDir)
|
||||
if jsonlPath != "" {
|
||||
if err := cleanupDeletionsManifest(beadsDir, jsonlPath); err != nil {
|
||||
// Non-fatal - just log warning
|
||||
fmt.Printf(" Warning: failed to clean up deletions manifest: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,59 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/deletions"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
// legacyDeletionRecord represents a single deletion entry from the legacy deletions.jsonl manifest.
|
||||
// This is inlined here for migration purposes only - new code uses inline tombstones.
|
||||
type legacyDeletionRecord struct {
|
||||
ID string `json:"id"` // Issue ID that was deleted
|
||||
Timestamp time.Time `json:"ts"` // When the deletion occurred
|
||||
Actor string `json:"by"` // Who performed the deletion
|
||||
Reason string `json:"reason,omitempty"` // Optional reason for deletion
|
||||
}
|
||||
|
||||
// loadLegacyDeletions reads the legacy deletions.jsonl manifest.
|
||||
// Returns a map of deletion records keyed by issue ID.
|
||||
// This is inlined here for migration purposes only.
|
||||
func loadLegacyDeletions(path string) (map[string]legacyDeletionRecord, error) {
|
||||
records := make(map[string]legacyDeletionRecord)
|
||||
|
||||
f, err := os.Open(path) // #nosec G304 - controlled path from caller
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return records, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to open deletions file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 0, 1024), 1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var record legacyDeletionRecord
|
||||
if err := json.Unmarshal([]byte(line), &record); err != nil {
|
||||
continue // Skip corrupt lines
|
||||
}
|
||||
if record.ID == "" {
|
||||
continue // Skip records without ID
|
||||
}
|
||||
records[record.ID] = record
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error reading deletions file: %w", err)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// MigrateTombstones converts legacy deletions.jsonl entries to inline tombstones.
|
||||
// This is called by bd doctor --fix when legacy deletions are detected.
|
||||
func MigrateTombstones(path string) error {
|
||||
@@ -30,12 +79,12 @@ func MigrateTombstones(path string) error {
|
||||
}
|
||||
|
||||
// Load deletions
|
||||
loadResult, err := deletions.LoadDeletions(deletionsPath)
|
||||
records, err := loadLegacyDeletions(deletionsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load deletions: %w", err)
|
||||
}
|
||||
|
||||
if len(loadResult.Records) == 0 {
|
||||
if len(records) == 0 {
|
||||
fmt.Println(" deletions.jsonl is empty - nothing to migrate")
|
||||
return nil
|
||||
}
|
||||
@@ -60,9 +109,9 @@ func MigrateTombstones(path string) error {
|
||||
}
|
||||
|
||||
// Convert deletions to tombstones
|
||||
var toMigrate []deletions.DeletionRecord
|
||||
var toMigrate []legacyDeletionRecord
|
||||
var skipped int
|
||||
for _, record := range loadResult.Records {
|
||||
for _, record := range records {
|
||||
if existingTombstones[record.ID] {
|
||||
skipped++
|
||||
continue
|
||||
@@ -81,7 +130,7 @@ func MigrateTombstones(path string) error {
|
||||
defer file.Close()
|
||||
|
||||
for _, record := range toMigrate {
|
||||
tombstone := convertDeletionToTombstone(record)
|
||||
tombstone := convertLegacyDeletionToTombstone(record)
|
||||
data, err := json.Marshal(tombstone)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal tombstone for %s: %w", record.ID, err)
|
||||
@@ -106,8 +155,8 @@ func MigrateTombstones(path string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertDeletionToTombstone converts a DeletionRecord to a tombstone Issue.
|
||||
func convertDeletionToTombstone(record deletions.DeletionRecord) *types.Issue {
|
||||
// convertLegacyDeletionToTombstone converts a legacy DeletionRecord to a tombstone Issue.
|
||||
func convertLegacyDeletionToTombstone(record legacyDeletionRecord) *types.Issue {
|
||||
now := time.Now()
|
||||
deletedAt := record.Timestamp
|
||||
if deletedAt.IsZero() {
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
package fix
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/deletions"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
func TestMigrateTombstones(t *testing.T) {
|
||||
// Setup: create temp .beads directory
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create .beads dir: %v", err)
|
||||
}
|
||||
|
||||
deletionsPath := filepath.Join(beadsDir, "deletions.jsonl")
|
||||
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
|
||||
// Create an issue in issues.jsonl
|
||||
issue := &types.Issue{
|
||||
ID: "test-abc",
|
||||
Title: "Test Issue",
|
||||
Status: types.StatusOpen,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
issueData, _ := json.Marshal(issue)
|
||||
if err := os.WriteFile(jsonlPath, append(issueData, '\n'), 0644); err != nil {
|
||||
t.Fatalf("failed to write issues.jsonl: %v", err)
|
||||
}
|
||||
|
||||
// Create deletions.jsonl with one entry
|
||||
record := deletions.DeletionRecord{
|
||||
ID: "test-deleted",
|
||||
Timestamp: time.Now().Add(-time.Hour),
|
||||
Actor: "testuser",
|
||||
Reason: "test deletion",
|
||||
}
|
||||
if err := deletions.AppendDeletion(deletionsPath, record); err != nil {
|
||||
t.Fatalf("failed to create deletions.jsonl: %v", err)
|
||||
}
|
||||
|
||||
// Run migration
|
||||
err := MigrateTombstones(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("MigrateTombstones failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify deletions.jsonl was archived
|
||||
if _, err := os.Stat(deletionsPath); !os.IsNotExist(err) {
|
||||
t.Error("deletions.jsonl should have been archived")
|
||||
}
|
||||
if _, err := os.Stat(deletionsPath + ".migrated"); os.IsNotExist(err) {
|
||||
t.Error("deletions.jsonl.migrated should exist")
|
||||
}
|
||||
|
||||
// Verify tombstone was added to issues.jsonl
|
||||
data, err := os.ReadFile(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read issues.jsonl: %v", err)
|
||||
}
|
||||
|
||||
// Should have 2 lines now (original issue + tombstone)
|
||||
lines := 0
|
||||
var foundTombstone bool
|
||||
for _, line := range splitLines(data) {
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
lines++
|
||||
var iss struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &iss); err == nil {
|
||||
if iss.ID == "test-deleted" && iss.Status == string(types.StatusTombstone) {
|
||||
foundTombstone = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lines != 2 {
|
||||
t.Errorf("expected 2 lines in issues.jsonl, got %d", lines)
|
||||
}
|
||||
if !foundTombstone {
|
||||
t.Error("tombstone for test-deleted not found in issues.jsonl")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateTombstones_SkipsExisting(t *testing.T) {
|
||||
// Setup: create temp .beads directory
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create .beads dir: %v", err)
|
||||
}
|
||||
|
||||
deletionsPath := filepath.Join(beadsDir, "deletions.jsonl")
|
||||
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
|
||||
// Create issues.jsonl with an existing tombstone
|
||||
tombstone := &types.Issue{
|
||||
ID: "test-already-tombstone",
|
||||
Title: "[Deleted]",
|
||||
Status: types.StatusTombstone,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
tombstoneData, _ := json.Marshal(tombstone)
|
||||
if err := os.WriteFile(jsonlPath, append(tombstoneData, '\n'), 0644); err != nil {
|
||||
t.Fatalf("failed to write issues.jsonl: %v", err)
|
||||
}
|
||||
|
||||
// Create deletions.jsonl with the same ID
|
||||
record := deletions.DeletionRecord{
|
||||
ID: "test-already-tombstone",
|
||||
Timestamp: time.Now().Add(-time.Hour),
|
||||
Actor: "testuser",
|
||||
Reason: "test deletion",
|
||||
}
|
||||
if err := deletions.AppendDeletion(deletionsPath, record); err != nil {
|
||||
t.Fatalf("failed to create deletions.jsonl: %v", err)
|
||||
}
|
||||
|
||||
// Run migration
|
||||
err := MigrateTombstones(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("MigrateTombstones failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify issues.jsonl still has only 1 line (no duplicate tombstone)
|
||||
data, err := os.ReadFile(jsonlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read issues.jsonl: %v", err)
|
||||
}
|
||||
|
||||
lines := 0
|
||||
for _, line := range splitLines(data) {
|
||||
if len(line) > 0 {
|
||||
lines++
|
||||
}
|
||||
}
|
||||
|
||||
if lines != 1 {
|
||||
t.Errorf("expected 1 line in issues.jsonl (existing tombstone), got %d", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateTombstones_NoDeletionsFile(t *testing.T) {
|
||||
// Setup: create temp .beads directory without deletions.jsonl
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create .beads dir: %v", err)
|
||||
}
|
||||
|
||||
// Run migration - should succeed without error
|
||||
err := MigrateTombstones(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("MigrateTombstones failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func splitLines(data []byte) [][]byte {
|
||||
var lines [][]byte
|
||||
start := 0
|
||||
for i, b := range data {
|
||||
if b == '\n' {
|
||||
lines = append(lines, data[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(data) {
|
||||
lines = append(lines, data[start:])
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
package fix
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/steveyegge/beads/internal/deletions"
|
||||
)
|
||||
|
||||
// DBJSONLSync fixes database-JSONL sync issues by running bd sync --import-only
|
||||
@@ -31,13 +27,10 @@ func DBJSONLSync(path string) error {
|
||||
}
|
||||
|
||||
hasJSONL := false
|
||||
actualJSONLPath := ""
|
||||
if _, err := os.Stat(jsonlPath); err == nil {
|
||||
hasJSONL = true
|
||||
actualJSONLPath = jsonlPath
|
||||
} else if _, err := os.Stat(beadsJSONLPath); err == nil {
|
||||
hasJSONL = true
|
||||
actualJSONLPath = beadsJSONLPath
|
||||
}
|
||||
|
||||
if !hasDB || !hasJSONL {
|
||||
@@ -61,107 +54,5 @@ func DBJSONLSync(path string) error {
|
||||
return fmt.Errorf("failed to sync database with JSONL: %w", err)
|
||||
}
|
||||
|
||||
// bd-8v5o: Clean up deletions manifest for hydrated issues
|
||||
// After sync, remove any issues from deletions.jsonl that exist in JSONL
|
||||
// This prevents perpetual "Skipping bd-xxx (in deletions manifest)" warnings
|
||||
if err := cleanupDeletionsManifest(beadsDir, actualJSONLPath); err != nil {
|
||||
// Non-fatal - just log warning
|
||||
fmt.Printf(" Warning: failed to clean up deletions manifest: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupDeletionsManifest removes issues from deletions.jsonl that exist in JSONL.
|
||||
// This is needed because when issues are hydrated from git history (e.g., via bd init
|
||||
// or bd sync --import-only), they may still be in the deletions manifest from a
|
||||
// previous deletion. This causes perpetual skip warnings during sync.
|
||||
func cleanupDeletionsManifest(beadsDir, jsonlPath string) error {
|
||||
deletionsPath := deletions.DefaultPath(beadsDir)
|
||||
|
||||
// Check if deletions manifest exists
|
||||
if _, err := os.Stat(deletionsPath); os.IsNotExist(err) {
|
||||
return nil // No deletions manifest, nothing to clean up
|
||||
}
|
||||
|
||||
// Load deletions manifest
|
||||
loadResult, err := deletions.LoadDeletions(deletionsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load deletions manifest: %w", err)
|
||||
}
|
||||
|
||||
if len(loadResult.Records) == 0 {
|
||||
return nil // No deletions, nothing to clean up
|
||||
}
|
||||
|
||||
// Get IDs from JSONL (excluding tombstones)
|
||||
jsonlIDs, err := getNonTombstoneJSONLIDs(jsonlPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read JSONL: %w", err)
|
||||
}
|
||||
|
||||
// Find IDs that are in both deletions manifest and JSONL
|
||||
var idsToRemove []string
|
||||
for id := range loadResult.Records {
|
||||
if jsonlIDs[id] {
|
||||
idsToRemove = append(idsToRemove, id)
|
||||
}
|
||||
}
|
||||
|
||||
if len(idsToRemove) == 0 {
|
||||
return nil // No conflicting entries
|
||||
}
|
||||
|
||||
// Remove conflicting entries from deletions manifest
|
||||
result, err := deletions.RemoveDeletions(deletionsPath, idsToRemove)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove deletions: %w", err)
|
||||
}
|
||||
|
||||
if result.RemovedCount > 0 {
|
||||
fmt.Printf(" Removed %d issue(s) from deletions manifest (now hydrated in JSONL)\n", result.RemovedCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getNonTombstoneJSONLIDs reads the JSONL file and returns a set of IDs
|
||||
// that are not tombstones (status != "tombstone").
|
||||
func getNonTombstoneJSONLIDs(jsonlPath string) (map[string]bool, error) {
|
||||
ids := make(map[string]bool)
|
||||
|
||||
file, err := os.Open(jsonlPath) // #nosec G304 - path validated by caller
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ids, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var issue struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &issue); err != nil {
|
||||
continue
|
||||
}
|
||||
// Only include non-tombstone issues
|
||||
if issue.ID != "" && issue.Status != "tombstone" {
|
||||
ids[issue.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
return ids, scanner.Err()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user