From ff0ecb526e99d0fd1b73fd2efc27f073b3a4a45c Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Wed, 17 Dec 2025 01:15:40 -0800 Subject: [PATCH] fix: daemon delete creates tombstones, export includes tombstones (bd-rp4o) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes to fix deleted issues resurrecting during bd sync: 1. daemon handleDelete now uses CreateTombstone instead of DeleteIssue - internal/rpc/server_issues_epics.go 2. sync.go exportToJSONL now includes IncludeTombstones:true - cmd/bd/sync.go 3. server_export_import_auto.go handleExport and auto-export now include tombstones in SearchIssues filter - internal/rpc/server_export_import_auto.go Also adds README.md documentation for sync.branch mode (bd-dsdh): - Explains "always dirty" working tree behavior - Shell alias tip: gs='git status -- ":!.beads/"' - When to use bd sync --merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- README.md | 42 +++++++++++++++++++++++ cmd/bd/sync.go | 5 +-- internal/rpc/server_export_import_auto.go | 9 ++--- internal/rpc/server_issues_epics.go | 25 +++++++++++--- 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index bdf2ba69..7646a59a 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,48 @@ bd --no-auto-flush create "Issue" # Skip auto-export bd --no-auto-import list # Skip auto-import check ``` +### Sync Branch Mode (sync.branch) + +For protected branches or to reduce commit noise on main, configure a separate sync branch: + +```bash +bd init --branch beads-sync # During init +# Or configure later: +git config beads.sync.branch beads-sync +``` + +**How it works:** +1. `bd sync` commits issue changes to the sync branch (e.g., `beads-sync`) +2. Issue data is copied to your working tree so CLI commands work normally +3. The sync branch accumulates commits; main stays clean +4. Periodically merge with `bd sync --merge` (releases, milestones, etc.) + +**The "always dirty" working tree:** + +When sync.branch is configured, `.beads/issues.jsonl` in your working tree will **always appear modified**. This is by design: + +- bd copies the latest JSONL to your working tree so commands work +- This copy is NOT committed to main (to reduce commit noise) +- The file is "dirty" because it's newer than what's on main + +**Be Zen about it.** This is expected behavior, not a bug. Options: + +1. **Accept it** - Use a shell alias to hide beads from git status: + ```bash + alias gs='git status -- ":!.beads/"' + ``` + +2. **Merge periodically** - Run `bd sync --merge` to snapshot issues to main: + ```bash + bd sync --merge # Merge beads-sync → main + bd sync --merge --dry-run # Preview what would be merged + ``` + +3. **Disable sync.branch** - If clean working tree matters more: + ```bash + git config --unset beads.sync.branch + ``` + ## Hash-Based Issue IDs **Version 0.20.1 introduces collision-resistant hash-based IDs** to enable reliable multi-worker and multi-branch workflows. diff --git a/cmd/bd/sync.go b/cmd/bd/sync.go index 12396e9e..c48645d0 100644 --- a/cmd/bd/sync.go +++ b/cmd/bd/sync.go @@ -1277,8 +1277,9 @@ func exportToJSONL(ctx context.Context, jsonlPath string) error { return fmt.Errorf("failed to initialize store: %w", err) } - // Get all issues - issues, err := store.SearchIssues(ctx, "", types.IssueFilter{}) + // Get all issues including tombstones for sync propagation (bd-rp4o fix) + // Tombstones must be exported so they propagate to other clones and prevent resurrection + issues, err := store.SearchIssues(ctx, "", types.IssueFilter{IncludeTombstones: true}) if err != nil { return fmt.Errorf("failed to get issues: %w", err) } diff --git a/internal/rpc/server_export_import_auto.go b/internal/rpc/server_export_import_auto.go index 254c32d9..76913ba8 100644 --- a/internal/rpc/server_export_import_auto.go +++ b/internal/rpc/server_export_import_auto.go @@ -49,8 +49,9 @@ func (s *Server) handleExport(req *Request) Response { manifest = export.NewManifest(cfg.Policy) } - // Get all issues (core operation, always fail-fast) - issues, err := store.SearchIssues(ctx, "", types.IssueFilter{}) + // Get all issues including tombstones for sync propagation (bd-rp4o fix) + // Tombstones must be exported so they propagate to other clones and prevent resurrection + issues, err := store.SearchIssues(ctx, "", types.IssueFilter{IncludeTombstones: true}) if err != nil { return Response{ Success: false, @@ -464,8 +465,8 @@ func (s *Server) triggerExport(ctx context.Context, store storage.Storage, dbPat } } - // Export to JSONL (this will update the file with remapped IDs) - allIssues, err := sqliteStore.SearchIssues(ctx, "", types.IssueFilter{}) + // Export to JSONL including tombstones for sync propagation (bd-rp4o fix) + allIssues, err := sqliteStore.SearchIssues(ctx, "", types.IssueFilter{IncludeTombstones: true}) if err != nil { return fmt.Errorf("failed to fetch issues for export: %w", err) } diff --git a/internal/rpc/server_issues_epics.go b/internal/rpc/server_issues_epics.go index ec18abb0..3bcbca3b 100644 --- a/internal/rpc/server_issues_epics.go +++ b/internal/rpc/server_issues_epics.go @@ -1,6 +1,7 @@ package rpc import ( + "context" "encoding/json" "fmt" "os" @@ -481,10 +482,26 @@ func (s *Server) handleDelete(req *Request) Response { continue } - // Delete the issue - if err := store.DeleteIssue(ctx, issueID); err != nil { - errors = append(errors, fmt.Sprintf("%s: %v", issueID, err)) - continue + // Create tombstone instead of hard delete (bd-rp4o fix) + // This preserves deletion history and prevents resurrection during sync + type tombstoner interface { + CreateTombstone(ctx context.Context, id string, actor string, reason string) error + } + if t, ok := store.(tombstoner); ok { + reason := deleteArgs.Reason + if reason == "" { + reason = "deleted via daemon" + } + if err := t.CreateTombstone(ctx, issueID, "daemon", reason); err != nil { + errors = append(errors, fmt.Sprintf("%s: %v", issueID, err)) + continue + } + } else { + // Fallback to hard delete if CreateTombstone not available + if err := store.DeleteIssue(ctx, issueID); err != nil { + errors = append(errors, fmt.Sprintf("%s: %v", issueID, err)) + continue + } } // Emit mutation event for event-driven daemon