Files
beads/cmd/bd/deletion_tracking.go
Ryan Snodgrass 421d41dfa0 docs: add detailed comments explaining ID formats and merge deletion logic
Document the intent and nuances of recent fixes:

internal/importer/utils.go:
- RenameImportedIssuePrefixes: explain the three ID formats (sequential,
  hash-based, hierarchical) and how prefix renaming preserves identity
- isValidIDSuffix: document why dots are allowed (hierarchical parent-child
  relationships) and what characters are rejected

cmd/bd/deletion_tracking.go:
- isIssueNotFoundError: explain why "not found" is success during merge
  (issue may be tombstoned, never existed locally, or manually deleted)
- Deletion loop: document what "accepted deletions" means and why we
  tolerate missing issues during the pruning phase
2025-12-16 00:17:40 -08:00

227 lines
7.4 KiB
Go

package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/merge"
"github.com/steveyegge/beads/internal/storage"
)
// isIssueNotFoundError checks if the error indicates the issue doesn't exist in the database.
//
// During 3-way merge, we try to delete issues that were removed remotely. However, the issue
// may already be gone from the local database due to:
// - Already tombstoned by a previous sync/import
// - Never existed locally (multi-repo scenarios, partial clones)
// - Deleted by user between export and import phases
//
// In all these cases, "issue not found" is success from the merge's perspective - the goal
// is to ensure the issue is deleted, and it already is. We only fail on actual database errors.
func isIssueNotFoundError(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "issue not found:")
}
// getVersion returns the current bd version
func getVersion() string {
return Version
}
// captureLeftSnapshot copies the current JSONL to the left snapshot file
// This should be called after export, before git pull
func captureLeftSnapshot(jsonlPath string) error {
sm := NewSnapshotManager(jsonlPath)
return sm.CaptureLeft()
}
// updateBaseSnapshot copies the current JSONL to the base snapshot file
// This should be called after successful import to track the new baseline
func updateBaseSnapshot(jsonlPath string) error {
sm := NewSnapshotManager(jsonlPath)
return sm.UpdateBase()
}
// merge3WayAndPruneDeletions performs 3-way merge and prunes accepted deletions from DB
// Returns true if merge was performed, false if skipped (no base file)
func merge3WayAndPruneDeletions(ctx context.Context, store storage.Storage, jsonlPath string) (bool, error) {
sm := NewSnapshotManager(jsonlPath)
basePath, leftPath := sm.getSnapshotPaths()
// If no base snapshot exists, skip deletion handling (first run or bootstrap)
if !sm.BaseExists() {
return false, nil
}
// Validate snapshot metadata
if err := sm.Validate(); err != nil {
// Stale or invalid snapshot - clean up and skip merge
fmt.Fprintf(os.Stderr, "Warning: snapshot validation failed (%v), cleaning up\n", err)
_ = sm.Cleanup()
return false, nil
}
// Run 3-way merge: base (last import) vs left (pre-pull export) vs right (pulled JSONL)
tmpMerged := jsonlPath + ".merged"
// Ensure temp file cleanup on failure
defer func() {
if fileExists(tmpMerged) {
_ = os.Remove(tmpMerged)
}
}()
if err := merge.Merge3Way(tmpMerged, basePath, leftPath, jsonlPath, false); err != nil {
// Merge error (including conflicts) is returned as error
return false, fmt.Errorf("3-way merge failed: %w", err)
}
// Replace the JSONL with merged result
if err := os.Rename(tmpMerged, jsonlPath); err != nil {
return false, fmt.Errorf("failed to replace JSONL with merged result: %w", err)
}
// Compute accepted deletions (issues in base but not in merged, and unchanged locally)
acceptedDeletions, err := sm.ComputeAcceptedDeletions(jsonlPath)
if err != nil {
return false, fmt.Errorf("failed to compute accepted deletions: %w", err)
}
// Prune accepted deletions from the database.
//
// "Accepted deletions" are issues that:
// 1. Existed in the base snapshot (last successful import)
// 2. Were NOT modified locally (still in left snapshot, unchanged)
// 3. Are NOT in the merged result (deleted remotely)
//
// We tolerate "issue not found" errors because the issue may already be gone:
// - Tombstoned by auto-import's git-history-backfill
// - Deleted manually by the user
// - Never existed in this clone (multi-repo, partial history)
// The goal is ensuring deletion, so already-deleted is success.
var deletionErrors []error
var alreadyGone int
for _, id := range acceptedDeletions {
if err := store.DeleteIssue(ctx, id); err != nil {
if isIssueNotFoundError(err) {
alreadyGone++
continue
}
deletionErrors = append(deletionErrors, fmt.Errorf("issue %s: %w", id, err))
}
}
if len(deletionErrors) > 0 {
return false, fmt.Errorf("deletion failures (DB may be inconsistent): %v", deletionErrors)
}
// Print stats if deletions were found
stats := sm.GetStats()
actuallyDeleted := len(acceptedDeletions) - alreadyGone
if stats.DeletionsFound > 0 || alreadyGone > 0 {
if alreadyGone > 0 {
fmt.Fprintf(os.Stderr, "3-way merge: pruned %d deleted issue(s) from database, %d already gone (base: %d, left: %d, merged: %d)\n",
actuallyDeleted, alreadyGone, stats.BaseCount, stats.LeftCount, stats.MergedCount)
} else {
fmt.Fprintf(os.Stderr, "3-way merge: pruned %d deleted issue(s) from database (base: %d, left: %d, merged: %d)\n",
actuallyDeleted, stats.BaseCount, stats.LeftCount, stats.MergedCount)
}
}
return true, nil
}
// cleanupSnapshots removes the snapshot files and their metadata
// Deprecated: Use SnapshotManager.Cleanup() instead
func cleanupSnapshots(jsonlPath string) error {
sm := NewSnapshotManager(jsonlPath)
return sm.Cleanup()
}
// validateSnapshotConsistency checks if snapshot files are consistent
// Deprecated: Use SnapshotManager.Validate() instead
func validateSnapshotConsistency(jsonlPath string) error {
sm := NewSnapshotManager(jsonlPath)
return sm.Validate()
}
// getSnapshotStats returns statistics about the snapshot files
// Deprecated: Use SnapshotManager.GetStats() instead
func getSnapshotStats(jsonlPath string) (baseCount, leftCount int, baseExists, leftExists bool) {
sm := NewSnapshotManager(jsonlPath)
basePath, leftPath := sm.GetSnapshotPaths()
if baseIDs, err := sm.BuildIDSet(basePath); err == nil && len(baseIDs) > 0 {
baseExists = true
baseCount = len(baseIDs)
} else {
baseExists = fileExists(basePath)
}
if leftIDs, err := sm.BuildIDSet(leftPath); err == nil && len(leftIDs) > 0 {
leftExists = true
leftCount = len(leftIDs)
} else {
leftExists = fileExists(leftPath)
}
return
}
// initializeSnapshotsIfNeeded creates initial snapshot files if they don't exist
// Deprecated: Use SnapshotManager.Initialize() instead
func initializeSnapshotsIfNeeded(jsonlPath string) error {
sm := NewSnapshotManager(jsonlPath)
return sm.Initialize()
}
// getMultiRepoJSONLPaths returns all JSONL file paths for multi-repo mode
// Returns nil if not in multi-repo mode
func getMultiRepoJSONLPaths() []string {
multiRepo := config.GetMultiRepoConfig()
if multiRepo == nil {
return nil
}
var paths []string
// Primary repo JSONL
primaryPath := multiRepo.Primary
if primaryPath == "" {
primaryPath = "."
}
primaryJSONL := filepath.Join(primaryPath, ".beads", "issues.jsonl")
paths = append(paths, primaryJSONL)
// Additional repos' JSONLs
for _, repoPath := range multiRepo.Additional {
jsonlPath := filepath.Join(repoPath, ".beads", "issues.jsonl")
paths = append(paths, jsonlPath)
}
return paths
}
// applyDeletionsFromMerge applies deletions discovered during 3-way merge
// This is the main entry point for deletion tracking during sync
func applyDeletionsFromMerge(ctx context.Context, store storage.Storage, jsonlPath string) error {
merged, err := merge3WayAndPruneDeletions(ctx, store, jsonlPath)
if err != nil {
return err
}
if !merged {
// No merge performed (no base snapshot), initialize for next time
if err := initializeSnapshotsIfNeeded(jsonlPath); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to initialize snapshots: %v\n", err)
}
}
return nil
}