Merge branch 'main' of github.com:steveyegge/beads

# Conflicts:
#	.beads/beads.jsonl
This commit is contained in:
Steve Yegge
2025-10-31 15:12:08 -07:00
26 changed files with 845 additions and 305 deletions

File diff suppressed because one or more lines are too long

View File

@@ -197,43 +197,53 @@ bd automatically detects when you're in a worktree and shows a prominent warning
**Why It Matters:**
The daemon maintains its own view of the current working directory and git state. When multiple worktrees share the same `.beads` database, the daemon may commit changes intended for one branch to a different branch, leading to confusion and incorrect git history.
## Handling Import Collisions
## Handling Git Merge Conflicts
When merging branches or pulling changes, you may encounter ID collisions (same ID, different content). bd detects and safely handles these:
**With hash-based IDs (v0.20.1+), ID collisions are eliminated.** Different issues get different hash IDs, so concurrent creation doesn't cause conflicts.
**Check for collisions after merge:**
### Understanding Same-ID Scenarios
When you encounter the same ID during import, it's an **update operation**, not a collision:
- Hash IDs are content-based and remain stable across updates
- Same ID + different fields = normal update to existing issue
- bd automatically applies updates when importing
**Preview changes before importing:**
```bash
# After git merge or pull
bd import -i .beads/issues.jsonl --dry-run
# Output shows:
# === Collision Detection Report ===
# Exact matches (idempotent): 15
# New issues: 5
# COLLISIONS DETECTED: 3
# Updates: 3
#
# Colliding issues:
# bd-10: Fix authentication (conflicting fields: [title, priority])
# bd-12: Add feature (conflicting fields: [description, status])
# Issues to be updated:
# bd-a3f2: Fix authentication (changed: priority, status)
# bd-b8e1: Add feature (changed: description)
```
**Resolve collisions automatically:**
### Git Merge Conflicts
The conflicts you'll encounter are **git merge conflicts** in the JSONL file when the same issue was modified on both branches (different timestamps/fields). This is not an ID collision.
**Resolution:**
```bash
# Let bd resolve collisions by remapping incoming issues to new IDs
bd import -i .beads/issues.jsonl --resolve-collisions
# After git merge creates conflict
git checkout --theirs .beads/beads.jsonl # Accept remote version
# OR
git checkout --ours .beads/beads.jsonl # Keep local version
# OR manually resolve in editor (keep line with newer updated_at)
# bd will:
# - Keep existing issues unchanged
# - Assign new IDs to colliding issues (bd-25, bd-26, etc.)
# - Update ALL text references and dependencies automatically
# - Report the remapping with reference counts
# Import the resolved JSONL
bd import -i .beads/beads.jsonl
# Commit the merge
git add .beads/beads.jsonl
git commit
```
**Important**: The `--resolve-collisions` flag is safe and recommended for branch merges. It preserves the existing database and only renumbers the incoming colliding issues. All text mentions like "see bd-10" and dependency links are automatically updated to use the new IDs.
**Manual resolution** (alternative):
If you prefer manual control, resolve the Git conflict in `.beads/issues.jsonl` directly, then import normally without `--resolve-collisions`.
### Advanced: Intelligent Merge Tools
For Git merge conflicts in `.beads/issues.jsonl`, consider using **[beads-merge](https://github.com/neongreen/mono/tree/main/beads-merge)** - a specialized merge tool by @neongreen that:
@@ -244,9 +254,7 @@ For Git merge conflicts in `.beads/issues.jsonl`, consider using **[beads-merge]
- Leaves remaining conflicts for manual resolution
- Works as a Git/jujutsu merge driver
**Two types of conflicts, two tools:**
- **Git merge conflicts** (same issue modified in two branches) → Use beads-merge during git merge
- **ID collisions** (different issues with same ID) → Use `bd import --resolve-collisions` after merge
After using beads-merge to resolve the git conflict, just run `bd import` to update your database.
## Custom Git Hooks

View File

@@ -147,6 +147,12 @@ bd create "Issue title" -t bug -p 1 -l bug,critical --json
# Create multiple issues from markdown file
bd create -f feature-plan.md --json
# Create epic with hierarchical child tasks
bd create "Auth System" -t epic -p 1 --json # Returns: bd-a3f8e9
bd create "Login UI" -p 1 --json # Auto-assigned: bd-a3f8e9.1
bd create "Backend validation" -p 1 --json # Auto-assigned: bd-a3f8e9.2
bd create "Tests" -p 1 --json # Auto-assigned: bd-a3f8e9.3
# Update one or more issues
bd update <id> [<id>...] --status in_progress --json
bd update <id> [<id>...] --priority 1 --json
@@ -192,10 +198,10 @@ bd rename-prefix kw- --json # Apply rename
# Restore compacted issue from git history
bd restore <id> # View full history at time of compaction
# Import with collision detection
bd import -i .beads/issues.jsonl --dry-run # Preview only
bd import -i .beads/issues.jsonl --resolve-collisions # Auto-resolve
bd import -i .beads/issues.jsonl --resolve-collisions --dedupe-after # Auto-resolve + detect duplicates
# Import issues from JSONL
bd import -i .beads/issues.jsonl --dry-run # Preview changes
bd import -i .beads/issues.jsonl # Import and update issues
bd import -i .beads/issues.jsonl --dedupe-after # Import + detect duplicates
# Find and merge duplicate issues
bd duplicates # Show all duplicates
@@ -335,9 +341,11 @@ bd daemons killall # Restart with default (poll) mode
- `bug` - Something broken that needs fixing
- `feature` - New functionality
- `task` - Work item (tests, docs, refactoring)
- `epic` - Large feature composed of multiple issues
- `epic` - Large feature composed of multiple issues (supports hierarchical children)
- `chore` - Maintenance work (dependencies, tooling)
**Hierarchical children:** Epics can have child issues with dotted IDs (e.g., `bd-a3f8e9.1`, `bd-a3f8e9.2`). Children are auto-numbered sequentially. Up to 3 levels of nesting supported. The parent hash ensures unique namespace - no coordination needed between agents working on different epics.
### Priorities
- `0` - Critical (security, data loss, broken builds)
@@ -371,8 +379,8 @@ bd duplicates --auto-merge
# Preview what would be merged
bd duplicates --dry-run
# During import (after collision resolution)
bd import -i issues.jsonl --resolve-collisions --dedupe-after
# During import
bd import -i issues.jsonl --dedupe-after
```
**Detection strategies:**
@@ -557,42 +565,30 @@ bd automatically detects when you're in a worktree and shows a prominent warning
**Why It Matters:**
The daemon maintains its own view of the current working directory and git state. When multiple worktrees share the same `.beads` database, the daemon may commit changes intended for one branch to a different branch, leading to confusion and incorrect git history.
### Handling Import Collisions
### Handling Git Merge Conflicts
When merging branches or pulling changes, you may encounter ID collisions (same ID, different content). bd detects and safely handles these:
**With hash-based IDs (v0.20.1+), ID collisions are eliminated!** Different issues get different hash IDs, so most git merges succeed cleanly.
**Check for collisions after merge:**
**When git merge conflicts occur:**
Git conflicts in `.beads/beads.jsonl` happen when the same issue is modified on both branches (different timestamps/fields). This is a **same-issue update conflict**, not an ID collision.
**Resolution:**
```bash
# After git merge or pull
bd import -i .beads/issues.jsonl --dry-run
# After git merge creates conflict
git checkout --theirs .beads/beads.jsonl # Accept remote version
# OR
git checkout --ours .beads/beads.jsonl # Keep local version
# OR manually resolve in editor
# Output shows:
# === Collision Detection Report ===
# Exact matches (idempotent): 15
# New issues: 5
# COLLISIONS DETECTED: 3
#
# Colliding issues:
# bd-10: Fix authentication (conflicting fields: [title, priority])
# bd-12: Add feature (conflicting fields: [description, status])
# Import the resolved JSONL
bd import -i .beads/beads.jsonl
# Commit the merge
git add .beads/beads.jsonl
git commit
```
**Resolve collisions automatically:**
```bash
# Let bd resolve collisions by remapping incoming issues to new IDs
bd import -i .beads/issues.jsonl --resolve-collisions
# bd will:
# - Keep existing issues unchanged
# - Assign new IDs to colliding issues (bd-25, bd-26, etc.)
# - Update ALL text references and dependencies automatically
# - Report the remapping with reference counts
```
**Important**: The `--resolve-collisions` flag is safe and recommended for branch merges. It preserves the existing database and only renumbers the incoming colliding issues. All text mentions like "see bd-10" and dependency links are automatically updated to use the new IDs.
**Manual resolution** (alternative):
If you prefer manual control, resolve the Git conflict in `.beads/issues.jsonl` directly, then import normally without `--resolve-collisions`.
**bd automatically handles updates** - same ID with different content is a normal update operation. No special flags needed.
### Advanced: Intelligent Merge Tools
@@ -604,9 +600,7 @@ For Git merge conflicts in `.beads/issues.jsonl`, consider using **[beads-merge]
- Leaves remaining conflicts for manual resolution
- Works as a Git/jujutsu merge driver
**Two types of conflicts, two tools:**
- **Git merge conflicts** (same issue modified in two branches) → Use beads-merge during git merge
- **ID collisions** (different issues with same ID) → Use `bd import --resolve-collisions` after merge
**Beads-merge** helps with intelligent field-level merging during git merge. After resolving, just `bd import` to update your database.
## Current Project Status
@@ -709,8 +703,8 @@ rm .beads/.exclusive-lock
- Use `--no-auto-flush` or `--no-auto-import` to disable automatic sync if needed
- Use `bd dep tree` to understand complex dependencies
- Priority 0-1 issues are usually more important than 2-4
- Use `--dry-run` to preview import collisions before resolving
- Use `--resolve-collisions` for safe automatic branch merges
- Use `--dry-run` to preview import changes before applying
- Hash IDs eliminate collisions - same ID with different content is a normal update
- Use `--id` flag with `bd create` to partition ID space for parallel workers (e.g., `worker1-100`, `worker2-500`)
## Building and Testing

77
FAQ.md
View File

@@ -83,6 +83,74 @@ Follow the repo for updates and the path to 1.0!
## Usage Questions
### Why hash-based IDs? Why not sequential?
**Hash IDs eliminate collisions** when multiple agents or branches create issues concurrently.
**The problem with sequential IDs:**
```bash
# Branch A creates bd-10
git checkout -b feature-auth
bd create "Add OAuth" # Sequential ID: bd-10
# Branch B also creates bd-10
git checkout -b feature-payments
bd create "Add Stripe" # Collision! Same sequential ID: bd-10
# Merge conflict!
git merge feature-auth # Two different issues, same ID
```
**Hash IDs solve this:**
```bash
# Branch A
bd create "Add OAuth" # Hash ID: bd-a1b2 (from random UUID)
# Branch B
bd create "Add Stripe" # Hash ID: bd-f14c (different UUID, different hash)
# Clean merge!
git merge feature-auth # No collision, different IDs
```
**Progressive length scaling:**
- 4 chars (0-500 issues): `bd-a1b2`
- 5 chars (500-1,500 issues): `bd-f14c3`
- 6 chars (1,500+ issues): `bd-3e7a5b`
bd automatically extends hash length as your database grows to maintain low collision probability.
### What are hierarchical child IDs?
**Hierarchical IDs** (e.g., `bd-a3f8e9.1`, `bd-a3f8e9.2`) provide human-readable structure for epics and their subtasks.
**Example:**
```bash
# Create epic (generates parent hash)
bd create "Auth System" -t epic -p 1
# Returns: bd-a3f8e9
# Create children (auto-numbered .1, .2, .3)
bd create "Login UI" -p 1 # bd-a3f8e9.1
bd create "Validation" -p 1 # bd-a3f8e9.2
bd create "Tests" -p 1 # bd-a3f8e9.3
```
**Benefits:**
- Parent hash ensures unique namespace (no cross-epic collisions)
- Sequential child IDs are human-friendly
- Up to 3 levels of nesting supported
- Clear visual grouping in issue lists
**When to use:**
- Epics with multiple related tasks
- Large features with sub-features
- Work breakdown structures
**When NOT to use:**
- Simple one-off tasks (use regular hash IDs)
- Cross-cutting dependencies (use `bd dep add` instead)
### Should I run bd init or have my agent do it?
**Either works!** But use the right flag:
@@ -218,12 +286,11 @@ When two developers create new issues:
Git may show a conflict, but resolution is simple: **keep both lines** (both changes are compatible).
For ID collisions (same ID, different content):
```bash
bd import -i .beads/issues.jsonl --resolve-collisions
```
**With hash-based IDs (v0.20.1+), same-ID scenarios are updates, not collisions:**
See [ADVANCED.md#handling-import-collisions](ADVANCED.md#handling-import-collisions) for details.
If you import an issue with the same ID but different fields, bd treats it as an update to the existing issue. This is normal behavior - hash IDs remain stable, so same ID = same issue being updated.
For git conflicts where the same issue was modified on both branches, manually resolve the JSONL conflict (usually keeping the newer `updated_at` timestamp), then `bd import` will apply the update.
## Migration Questions

View File

@@ -22,6 +22,36 @@ go build -o bd ./cmd/bd
./bd list
```
**Note:** Issue IDs are hash-based (e.g., `bd-a1b2`, `bd-f14c`) to prevent collisions when multiple agents/branches work concurrently.
## Hierarchical Issues (Epics)
For large features, use hierarchical IDs to organize work:
```bash
# Create epic (generates parent hash ID)
./bd create "Auth System" -t epic -p 1
# Returns: bd-a3f8e9
# Create child tasks (automatically get .1, .2, .3 suffixes)
./bd create "Design login UI" -p 1 # bd-a3f8e9.1
./bd create "Backend validation" -p 1 # bd-a3f8e9.2
./bd create "Integration tests" -p 1 # bd-a3f8e9.3
# View hierarchy
./bd dep tree bd-a3f8e9
```
Output:
```
🌲 Dependency tree for bd-a3f8e9:
→ bd-a3f8e9: Auth System [epic] [P1] (open)
→ bd-a3f8e9.1: Design login UI [P1] (open)
→ bd-a3f8e9.2: Backend validation [P1] (open)
→ bd-a3f8e9.3: Integration tests [P1] (open)
```
## Add Dependencies
```bash

View File

@@ -271,6 +271,47 @@ bd info
**Note:** Hash IDs require schema version 9+. The `bd migrate` command detects old schemas and upgrades automatically.
### Hierarchical Child IDs
Hash IDs support **hierarchical children** for natural work breakdown structures. Child IDs use dot notation:
```
bd-a3f8e9 [epic] Auth System
bd-a3f8e9.1 [task] Design login UI
bd-a3f8e9.2 [task] Backend validation
bd-a3f8e9.3 [epic] Password Reset
bd-a3f8e9.3.1 [task] Email templates
bd-a3f8e9.3.2 [task] Reset flow tests
```
**Benefits:**
- **Collision-free**: Parent hash ensures unique namespace
- **Human-readable**: Clear parent-child relationships
- **Flexible depth**: Up to 3 levels of nesting
- **No coordination needed**: Each epic owns its child ID space
**Common patterns:**
- 1 level: Epic → tasks (most projects)
- 2 levels: Epic → features → tasks (large projects)
- 3 levels: Epic → features → stories → tasks (complex projects)
**Example workflow:**
```bash
# Create parent epic (generates hash ID automatically)
bd create "Auth System" -t epic -p 1
# Returns: bd-a3f8e9
# Create child tasks
bd create "Design login UI" -p 1 # Auto-assigned: bd-a3f8e9.1
bd create "Backend validation" -p 1 # Auto-assigned: bd-a3f8e9.2
# Create nested epic with its own children
bd create "Password Reset" -t epic -p 1 # Auto-assigned: bd-a3f8e9.3
bd create "Email templates" -p 1 # Auto-assigned: bd-a3f8e9.3.1
```
**Note:** Child IDs are automatically assigned sequentially within each parent's namespace. No need to specify parent manually - bd tracks context from git branch/working directory.
## Usage
### Health Check

View File

@@ -187,19 +187,24 @@ bd import -i .beads/issues.jsonl # Sync to SQLite
See [ADVANCED.md](ADVANCED.md) for detailed merge strategies.
### ID collisions after branch merge
### Git merge conflicts in JSONL
When merging branches where different issues were created with the same ID:
**With hash-based IDs (v0.20.1+), ID collisions don't occur.** Different issues get different hash IDs.
If git shows a conflict in `.beads/issues.jsonl`, it's because the same issue was modified on both branches:
```bash
# Check for collisions
# Preview what will be updated
bd import -i .beads/issues.jsonl --dry-run
# Automatically resolve collisions
bd import -i .beads/issues.jsonl --resolve-collisions
# Resolve git conflict (keep newer version or manually merge)
git checkout --theirs .beads/issues.jsonl # Or --ours, or edit manually
# Import updates the database
bd import -i .beads/issues.jsonl
```
See [ADVANCED.md#handling-import-collisions](ADVANCED.md#handling-import-collisions) for details.
See [ADVANCED.md#handling-git-merge-conflicts](ADVANCED.md#handling-git-merge-conflicts) for details.
### Permission denied on git hooks

View File

@@ -5,6 +5,7 @@ import (
"context"
"fmt"
"os"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
@@ -273,6 +274,7 @@ var depTreeCmd = &cobra.Command{
showAllPaths, _ := cmd.Flags().GetBool("show-all-paths")
maxDepth, _ := cmd.Flags().GetInt("max-depth")
reverse, _ := cmd.Flags().GetBool("reverse")
formatStr, _ := cmd.Flags().GetString("format")
if maxDepth < 1 {
fmt.Fprintf(os.Stderr, "Error: --max-depth must be >= 1\n")
@@ -285,6 +287,12 @@ var depTreeCmd = &cobra.Command{
os.Exit(1)
}
// Handle mermaid format
if formatStr == "mermaid" {
outputMermaidTree(tree, args[0])
return
}
if jsonOutput {
// Always output array, even if empty
if tree == nil {
@@ -383,11 +391,71 @@ var depCyclesCmd = &cobra.Command{
},
}
// outputMermaidTree outputs a dependency tree in Mermaid.js flowchart format
func outputMermaidTree(tree []*types.TreeNode, rootID string) {
if len(tree) == 0 {
fmt.Println("flowchart TD")
fmt.Printf(" %s[\"No dependencies\"]\n", rootID)
return
}
fmt.Println("flowchart TD")
// Output nodes
nodesSeen := make(map[string]bool)
for _, node := range tree {
if !nodesSeen[node.ID] {
emoji := getStatusEmoji(node.Status)
label := fmt.Sprintf("%s %s: %s", emoji, node.ID, node.Title)
// Escape quotes and backslashes in label
label = strings.ReplaceAll(label, "\\", "\\\\")
label = strings.ReplaceAll(label, "\"", "\\\"")
fmt.Printf(" %s[\"%s\"]\n", node.ID, label)
nodesSeen[node.ID] = true
}
}
fmt.Println()
// Output edges - use explicit parent relationships from ParentID
for _, node := range tree {
if node.ParentID != "" && node.ParentID != node.ID {
fmt.Printf(" %s --> %s\n", node.ParentID, node.ID)
}
}
}
// getStatusEmoji returns a symbol indicator for a given status
func getStatusEmoji(status types.Status) string {
switch status {
case types.StatusOpen:
return "☐" // U+2610 Ballot Box
case types.StatusInProgress:
return "◧" // U+25E7 Square Left Half Black
case types.StatusBlocked:
return "⚠" // U+26A0 Warning Sign
case types.StatusClosed:
return "☑" // U+2611 Ballot Box with Check
default:
return "?"
}
}
func init() {
depAddCmd.Flags().StringP("type", "t", "blocks", "Dependency type (blocks|related|parent-child|discovered-from)")
depAddCmd.Flags().Bool("json", false, "Output JSON format")
depRemoveCmd.Flags().Bool("json", false, "Output JSON format")
depTreeCmd.Flags().Bool("show-all-paths", false, "Show all paths to nodes (no deduplication for diamond dependencies)")
depTreeCmd.Flags().IntP("max-depth", "d", 50, "Maximum tree depth to display (safety limit)")
depTreeCmd.Flags().Bool("reverse", false, "Show dependent tree (what was discovered from this) instead of dependency tree (what blocks this)")
depTreeCmd.Flags().String("format", "", "Output format: 'mermaid' for Mermaid.js flowchart")
depTreeCmd.Flags().Bool("json", false, "Output JSON format")
depCyclesCmd.Flags().Bool("json", false, "Output JSON format")
depCmd.AddCommand(depAddCmd)
depCmd.AddCommand(depRemoveCmd)
depCmd.AddCommand(depTreeCmd)

View File

@@ -1,9 +1,13 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -266,3 +270,217 @@ func TestDepRemove(t *testing.T) {
t.Errorf("Expected 0 dependencies after removal, got %d", len(deps))
}
}
func TestDepTreeFormatFlag(t *testing.T) {
// Test that the --format flag exists on depTreeCmd
flag := depTreeCmd.Flags().Lookup("format")
if flag == nil {
t.Fatal("depTreeCmd should have --format flag")
}
// Test default value is empty string
if flag.DefValue != "" {
t.Errorf("Expected default format='', got %q", flag.DefValue)
}
// Test usage text mentions mermaid
if !strings.Contains(flag.Usage, "mermaid") {
t.Errorf("Expected flag usage to mention 'mermaid', got %q", flag.Usage)
}
}
func TestGetStatusEmoji(t *testing.T) {
tests := []struct {
status types.Status
want string
}{
{types.StatusOpen, "☐"},
{types.StatusInProgress, "◧"},
{types.StatusBlocked, "⚠"},
{types.StatusClosed, "☑"},
{types.Status("unknown"), "?"},
}
for _, tt := range tests {
t.Run(string(tt.status), func(t *testing.T) {
got := getStatusEmoji(tt.status)
if got != tt.want {
t.Errorf("getStatusEmoji(%q) = %q, want %q", tt.status, got, tt.want)
}
})
}
}
func TestOutputMermaidTree(t *testing.T) {
tests := []struct {
name string
tree []*types.TreeNode
rootID string
want []string // Lines that must appear in output
}{
{
name: "empty tree",
tree: []*types.TreeNode{},
rootID: "test-1",
want: []string{
"flowchart TD",
`test-1["No dependencies"]`,
},
},
{
name: "single dependency",
tree: []*types.TreeNode{
{
Issue: types.Issue{ID: "test-1", Title: "Task 1", Status: types.StatusInProgress},
Depth: 0,
ParentID: "",
},
{
Issue: types.Issue{ID: "test-2", Title: "Task 2", Status: types.StatusClosed},
Depth: 1,
ParentID: "test-1",
},
},
rootID: "test-1",
want: []string{
"flowchart TD",
`test-1["◧ test-1: Task 1"]`,
`test-2["☑ test-2: Task 2"]`,
"test-1 --> test-2",
},
},
{
name: "multiple dependencies",
tree: []*types.TreeNode{
{
Issue: types.Issue{ID: "test-1", Title: "Main", Status: types.StatusOpen},
Depth: 0,
ParentID: "",
},
{
Issue: types.Issue{ID: "test-2", Title: "Sub 1", Status: types.StatusClosed},
Depth: 1,
ParentID: "test-1",
},
{
Issue: types.Issue{ID: "test-3", Title: "Sub 2", Status: types.StatusBlocked},
Depth: 1,
ParentID: "test-1",
},
},
rootID: "test-1",
want: []string{
"flowchart TD",
`test-1["☐ test-1: Main"]`,
`test-2["☑ test-2: Sub 1"]`,
`test-3["⚠ test-3: Sub 2"]`,
"test-1 --> test-2",
"test-1 --> test-3",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Capture stdout
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
outputMermaidTree(tt.tree, tt.rootID)
w.Close()
os.Stdout = old
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
// Verify all expected lines appear
for _, line := range tt.want {
if !strings.Contains(output, line) {
t.Errorf("expected output to contain %q, got:\n%s", line, output)
}
}
})
}
}
func TestOutputMermaidTree_Siblings(t *testing.T) {
// Test case: Siblings with children (reproduces issue with wrong parent inference)
// Structure:
// BD-1 (root)
// ├── BD-2 (sibling 1)
// │ └── BD-4 (child of BD-2)
// └── BD-3 (sibling 2)
// └── BD-5 (child of BD-3)
tree := []*types.TreeNode{
{
Issue: types.Issue{ID: "BD-1", Title: "Parent", Status: types.StatusOpen},
Depth: 0,
ParentID: "",
},
{
Issue: types.Issue{ID: "BD-2", Title: "Sibling 1", Status: types.StatusOpen},
Depth: 1,
ParentID: "BD-1",
},
{
Issue: types.Issue{ID: "BD-3", Title: "Sibling 2", Status: types.StatusOpen},
Depth: 1,
ParentID: "BD-1",
},
{
Issue: types.Issue{ID: "BD-4", Title: "Child of Sibling 1", Status: types.StatusOpen},
Depth: 2,
ParentID: "BD-2",
},
{
Issue: types.Issue{ID: "BD-5", Title: "Child of Sibling 2", Status: types.StatusOpen},
Depth: 2,
ParentID: "BD-3",
},
}
// Capture stdout
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
outputMermaidTree(tree, "BD-1")
w.Close()
os.Stdout = old
var buf bytes.Buffer
io.Copy(&buf, r)
output := buf.String()
// Verify correct edges exist
correctEdges := []string{
"BD-1 --> BD-2",
"BD-1 --> BD-3",
"BD-2 --> BD-4",
"BD-3 --> BD-5",
}
for _, edge := range correctEdges {
if !strings.Contains(output, edge) {
t.Errorf("expected edge %q to be present, got:\n%s", edge, output)
}
}
// Verify incorrect edges do NOT exist (siblings shouldn't be connected)
incorrectEdges := []string{
"BD-2 --> BD-3", // Siblings shouldn't be connected
"BD-3 --> BD-4", // BD-4's parent is BD-2, not BD-3
"BD-4 --> BD-3", // Wrong direction
"BD-4 --> BD-5", // These are cousins, not parent-child
}
for _, edge := range incorrectEdges {
if strings.Contains(output, edge) {
t.Errorf("incorrect edge %q should NOT be present, got:\n%s", edge, output)
}
}
}

View File

@@ -38,6 +38,7 @@ Example:
autoMerge, _ := cmd.Flags().GetBool("auto-merge")
dryRun, _ := cmd.Flags().GetBool("dry-run")
jsonOutput, _ := cmd.Flags().GetBool("json")
ctx := context.Background()
@@ -174,6 +175,7 @@ Example:
func init() {
duplicatesCmd.Flags().Bool("auto-merge", false, "Automatically merge all duplicates")
duplicatesCmd.Flags().Bool("dry-run", false, "Show what would be merged without making changes")
duplicatesCmd.Flags().Bool("json", false, "Output JSON format")
rootCmd.AddCommand(duplicatesCmd)
}

View File

@@ -22,7 +22,7 @@ var labelCmd = &cobra.Command{
}
// Helper function to process label operations for multiple issues
func processBatchLabelOperation(issueIDs []string, label string, operation string,
func processBatchLabelOperation(issueIDs []string, label string, operation string, jsonOut bool,
daemonFunc func(string, string) error, storeFunc func(context.Context, string, string, string) error) {
ctx := context.Background()
results := []map[string]interface{}{}
@@ -40,7 +40,7 @@ func processBatchLabelOperation(issueIDs []string, label string, operation strin
continue
}
if jsonOutput {
if jsonOut {
results = append(results, map[string]interface{}{
"status": operation,
"issue_id": issueID,
@@ -62,7 +62,7 @@ func processBatchLabelOperation(issueIDs []string, label string, operation strin
markDirtyAndScheduleFlush()
}
if jsonOutput && len(results) > 0 {
if jsonOut && len(results) > 0 {
outputJSON(results)
}
}
@@ -79,6 +79,7 @@ var labelAddCmd = &cobra.Command{
Short: "Add a label to one or more issues",
Args: cobra.MinimumNArgs(2),
Run: func(cmd *cobra.Command, args []string) {
jsonOutput, _ := cmd.Flags().GetBool("json")
issueIDs, label := parseLabelArgs(args)
// Resolve partial IDs
@@ -107,7 +108,7 @@ var labelAddCmd = &cobra.Command{
}
issueIDs = resolvedIDs
processBatchLabelOperation(issueIDs, label, "added",
processBatchLabelOperation(issueIDs, label, "added", jsonOutput,
func(issueID, lbl string) error {
_, err := daemonClient.AddLabel(&rpc.LabelAddArgs{ID: issueID, Label: lbl})
return err
@@ -124,6 +125,7 @@ var labelRemoveCmd = &cobra.Command{
Short: "Remove a label from one or more issues",
Args: cobra.MinimumNArgs(2),
Run: func(cmd *cobra.Command, args []string) {
jsonOutput, _ := cmd.Flags().GetBool("json")
issueIDs, label := parseLabelArgs(args)
// Resolve partial IDs
@@ -152,7 +154,7 @@ var labelRemoveCmd = &cobra.Command{
}
issueIDs = resolvedIDs
processBatchLabelOperation(issueIDs, label, "removed",
processBatchLabelOperation(issueIDs, label, "removed", jsonOutput,
func(issueID, lbl string) error {
_, err := daemonClient.RemoveLabel(&rpc.LabelRemoveArgs{ID: issueID, Label: lbl})
return err
@@ -168,6 +170,7 @@ var labelListCmd = &cobra.Command{
Short: "List labels for an issue",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
jsonOutput, _ := cmd.Flags().GetBool("json")
ctx := context.Background()
// Resolve partial ID first
@@ -242,6 +245,7 @@ var labelListAllCmd = &cobra.Command{
Use: "list-all",
Short: "List all unique labels in the database",
Run: func(cmd *cobra.Command, args []string) {
jsonOutput, _ := cmd.Flags().GetBool("json")
ctx := context.Background()
var issues []*types.Issue
@@ -342,6 +346,11 @@ var labelListAllCmd = &cobra.Command{
}
func init() {
labelAddCmd.Flags().Bool("json", false, "Output JSON format")
labelRemoveCmd.Flags().Bool("json", false, "Output JSON format")
labelListCmd.Flags().Bool("json", false, "Output JSON format")
labelListAllCmd.Flags().Bool("json", false, "Output JSON format")
labelCmd.AddCommand(labelAddCmd)
labelCmd.AddCommand(labelRemoveCmd)
labelCmd.AddCommand(labelListCmd)

View File

@@ -836,9 +836,9 @@ func TestAutoImportDisabled(t *testing.T) {
storeMutex.Unlock()
}
// TestAutoImportWithCollision tests that auto-import detects collisions and preserves local changes
func TestAutoImportWithCollision(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-test-collision-*")
// TestAutoImportWithUpdate tests that auto-import detects same-ID updates and applies them
func TestAutoImportWithUpdate(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-test-update-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
@@ -877,7 +877,7 @@ func TestAutoImportWithCollision(t *testing.T) {
t.Fatalf("Failed to create issue: %v", err)
}
// Create JSONL with same ID but status=open (conflict)
// Create JSONL with same ID but status=open (update scenario)
jsonlIssue := &types.Issue{
ID: "test-col-1",
Title: "Remote version",
@@ -911,9 +911,9 @@ func TestAutoImportWithCollision(t *testing.T) {
}
}
// TestAutoImportNoCollision tests happy path with no conflicts
func TestAutoImportNoCollision(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-test-nocoll-*")
// TestAutoImportNoUpdate tests happy path with no updates needed
func TestAutoImportNoUpdate(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-test-noupdate-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}

View File

@@ -43,6 +43,7 @@ Example:
sourceIDs := args
dryRun, _ := cmd.Flags().GetBool("dry-run")
jsonOutput, _ := cmd.Flags().GetBool("json")
// Validate merge operation
if err := validateMerge(targetID, sourceIDs); err != nil {
@@ -96,6 +97,7 @@ Example:
func init() {
mergeCmd.Flags().String("into", "", "Target issue ID to merge into (required)")
mergeCmd.Flags().Bool("dry-run", false, "Validate without making changes")
mergeCmd.Flags().Bool("json", false, "Output JSON format")
rootCmd.AddCommand(mergeCmd)
}

View File

@@ -206,6 +206,8 @@ var statsCmd = &cobra.Command{
Use: "stats",
Short: "Show statistics",
Run: func(cmd *cobra.Command, args []string) {
jsonOutput, _ := cmd.Flags().GetBool("json")
// If daemon is running, use RPC
if daemonClient != nil {
resp, err := daemonClient.Stats()
@@ -296,6 +298,8 @@ func init() {
readyCmd.Flags().StringP("sort", "s", "hybrid", "Sort policy: hybrid (default), priority, oldest")
readyCmd.Flags().Bool("json", false, "Output JSON format")
statsCmd.Flags().Bool("json", false, "Output JSON format")
rootCmd.AddCommand(readyCmd)
rootCmd.AddCommand(blockedCmd)
rootCmd.AddCommand(statsCmd)

View File

@@ -22,6 +22,7 @@ This is more explicit than 'bd update --status open' and emits a Reopened event.
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
reason, _ := cmd.Flags().GetString("reason")
jsonOutput, _ := cmd.Flags().GetBool("json")
ctx := context.Background()
@@ -146,5 +147,6 @@ This is more explicit than 'bd update --status open' and emits a Reopened event.
func init() {
reopenCmd.Flags().StringP("reason", "r", "", "Reason for reopening")
reopenCmd.Flags().Bool("json", false, "Output JSON format")
rootCmd.AddCommand(reopenCmd)
}

View File

@@ -20,6 +20,7 @@ var showCmd = &cobra.Command{
Short: "Show issue details",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
jsonOutput, _ := cmd.Flags().GetBool("json")
ctx := context.Background()
// Resolve partial IDs first
@@ -329,6 +330,7 @@ var updateCmd = &cobra.Command{
Short: "Update one or more issues",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
jsonOutput, _ := cmd.Flags().GetBool("json")
updates := make(map[string]interface{})
if cmd.Flags().Changed("status") {
@@ -691,6 +693,7 @@ var closeCmd = &cobra.Command{
if reason == "" {
reason = "Closed"
}
jsonOutput, _ := cmd.Flags().GetBool("json")
ctx := context.Background()
@@ -776,6 +779,7 @@ var closeCmd = &cobra.Command{
}
func init() {
showCmd.Flags().Bool("json", false, "Output JSON format")
rootCmd.AddCommand(showCmd)
updateCmd.Flags().StringP("status", "s", "", "New status")
@@ -789,6 +793,7 @@ func init() {
updateCmd.Flags().String("acceptance-criteria", "", "DEPRECATED: use --acceptance")
_ = updateCmd.Flags().MarkHidden("acceptance-criteria")
updateCmd.Flags().String("external-ref", "", "External reference (e.g., 'gh-9', 'jira-ABC')")
updateCmd.Flags().Bool("json", false, "Output JSON format")
rootCmd.AddCommand(updateCmd)
editCmd.Flags().Bool("title", false, "Edit the title")
@@ -799,5 +804,6 @@ func init() {
rootCmd.AddCommand(editCmd)
closeCmd.Flags().StringP("reason", "r", "", "Reason for closing")
closeCmd.Flags().Bool("json", false, "Output JSON format")
rootCmd.AddCommand(closeCmd)
}

View File

@@ -23,6 +23,7 @@ Manage dependencies between beads issues.
- $2: Issue ID
- Flags:
- `--reverse`: Show dependent tree (what was discovered from this) instead of dependency tree (what blocks this)
- `--format mermaid`: Output as Mermaid.js flowchart (renders in GitHub/GitLab markdown)
- `--json`: Output as JSON
- `--max-depth N`: Limit tree depth (default: 50)
- `--show-all-paths`: Show all paths (no deduplication for diamond dependencies)
@@ -36,12 +37,45 @@ Manage dependencies between beads issues.
- **parent-child**: Epic/subtask relationship
- **discovered-from**: Track issues found during work
## Mermaid Format
The `--format mermaid` option outputs the dependency tree as a Mermaid.js flowchart:
**Example:**
```bash
bd dep tree bd-1 --format mermaid
```
Output can be embedded in markdown:
````markdown
```mermaid
flowchart TD
bd-1["◧ bd-1: Main task"]
bd-2["☑ bd-2: Subtask"]
bd-1 --> bd-2
```
````
**Status Indicators:**
Each node includes a symbol indicator for quick visual status identification:
- ☐ **Open** - Not started yet (empty checkbox)
- ◧ **In Progress** - Currently being worked on (half-filled box)
- ⚠ **Blocked** - Waiting on something (warning sign)
- ☑ **Closed** - Completed! (checked checkbox)
The diagram colors are determined by your Mermaid theme (default, dark, forest, neutral, or base). Mermaid diagrams render natively in GitHub, GitLab, VSCode markdown preview, and can be imported to Miro.
## Examples
- `bd dep add bd-10 bd-20 --type blocks`: bd-10 blocks bd-20
- `bd dep tree bd-20`: Show what blocks bd-20 (dependency tree going UP)
- `bd dep tree bd-1 --reverse`: Show what was discovered from bd-1 (dependent tree going DOWN)
- `bd dep tree bd-1 --reverse --max-depth 3`: Show discovery tree with depth limit
- `bd dep tree bd-20 --format mermaid > tree.md`: Generate Mermaid diagram for documentation
- `bd dep cycles`: Check for circular dependencies
## Reverse Mode: Discovery Trees

View File

@@ -10,21 +10,21 @@ Import issues from JSON Lines format (one JSON object per line).
- **From stdin**: `bd import` (reads from stdin)
- **From file**: `bd import -i issues.jsonl`
- **Preview**: `bd import -i issues.jsonl --dry-run`
- **Resolve collisions**: `bd import -i issues.jsonl --resolve-collisions`
## Behavior
- **Existing issues** (same ID): Updated with new data
- **New issues**: Created
- **Collisions** (same ID, different content): Detected and reported
- **Same-ID scenarios**: With hash-based IDs (v0.20.1+), same ID = same issue being updated (not a collision)
## Collision Handling
## Preview Changes
When merging branches or pulling changes, ID collisions can occur:
Use `--dry-run` to see what will change before importing:
- **--dry-run**: Preview collisions without making changes
- **--resolve-collisions**: Automatically remap colliding issues to new IDs
- All text references and dependencies are automatically updated
```bash
bd import -i issues.jsonl --dry-run
# Shows: new issues, updates, exact matches
```
## Automatic Import

View File

@@ -263,32 +263,25 @@ Import issues from JSONL format.
```bash
bd import < issues.jsonl
bd import --resolve-collisions < issues.jsonl
bd import -i issues.jsonl --dry-run # Preview changes
```
**Flags:**
- `--resolve-collisions` - Automatically remap conflicting issue IDs
**Behavior with hash-based IDs (v0.20.1+):**
- Same ID = update operation (hash IDs remain stable)
- Different issues get different hash IDs (no collisions)
- Import automatically applies updates to existing issues
**Use cases for --resolve-collisions:**
- **Reimporting** after manual JSONL edits - if you closed an issue in the JSONL that's still open in DB
- **Merging databases** - importing issues from another database with overlapping IDs
- **Restoring from backup** - when database state has diverged from JSONL
**What --resolve-collisions does:**
1. Detects ID conflicts (same ID, different status/content)
2. Remaps conflicting imports to new IDs
3. Updates all references and dependencies to use new IDs
4. Reports remapping (e.g., "mit-1 → bd-4")
**Without --resolve-collisions**: Import fails on first conflict.
**Example scenario:**
**Use `--dry-run` to preview:**
```bash
# You have: mit-1 (open) in database
# Importing: mit-1 (closed) from JSONL
# Result: Import creates bd-4 with closed status, preserves existing mit-1
bd import -i issues.jsonl --dry-run
# Shows: new issues, updates, exact matches
```
**Use cases:**
- **Syncing after git pull** - daemon auto-imports, manual rarely needed
- **Merging databases** - import issues from another database
- **Restoring from backup** - reimport JSONL to restore state
---
## Setup Commands

View File

@@ -63,14 +63,16 @@ The hook is silent on success, fast (no git operations), and safe (fails commit
After a git pull or merge, the hook runs:
```bash
bd import -i .beads/issues.jsonl --resolve-collisions
bd import -i .beads/issues.jsonl
```
This ensures your local database reflects the merged state. The hook:
- Only runs if `.beads/issues.jsonl` exists
- Automatically resolves ID collisions from branch merges
- Imports any new issues or updates from the merge
- Warns on failure but doesn't block the merge
**Note:** With hash-based IDs (v0.20.1+), ID collisions don't occur - different issues get different hash IDs.
## Compatibility
- **Auto-sync**: Works alongside bd's automatic 5-second debounce

View File

@@ -176,11 +176,11 @@ bd ready # See what's ready to work on
# Import all issues (open and closed)
python gh2jsonl.py --repo mycompany/myapp > all-issues.jsonl
# Preview import (check for collisions)
# Preview import (check for new issues and updates)
bd import -i all-issues.jsonl --dry-run
# Import with collision resolution if needed
bd import -i all-issues.jsonl --resolve-collisions
# Import issues
bd import -i all-issues.jsonl
# View stats
bd stats

View File

@@ -90,7 +90,7 @@ func ImportIssues(ctx context.Context, dbPath string, store storage.Storage, iss
}
// Detect and resolve collisions
issues, err = handleCollisions(ctx, sqliteStore, issues, opts, result)
issues, err = detectUpdates(ctx, sqliteStore, issues, opts, result)
if err != nil {
return result, err
}
@@ -193,8 +193,8 @@ func handlePrefixMismatch(ctx context.Context, sqliteStore *sqlite.SQLiteStorage
return nil
}
// handleCollisions detects and resolves ID collisions
func handleCollisions(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues []*types.Issue, opts Options, result *Result) ([]*types.Issue, error) {
// detectUpdates detects same-ID scenarios (which are updates with hash IDs, not collisions)
func detectUpdates(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues []*types.Issue, opts Options, result *Result) ([]*types.Issue, error) {
// Phase 1: Detect (read-only)
collisionResult, err := sqlite.DetectCollisions(ctx, sqliteStore, issues)
if err != nil {
@@ -206,24 +206,12 @@ func handleCollisions(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, is
result.CollisionIDs = append(result.CollisionIDs, collision.ID)
}
// Handle collisions - with hash IDs, collisions shouldn't happen
// With hash IDs, "collisions" (same ID, different content) are actually UPDATES
// Hash IDs are based on creation content and remain stable across updates
// So same ID + different fields = normal update operation, not a collision
// The collisionResult.Collisions list represents issues that will be updated
if len(collisionResult.Collisions) > 0 {
// Hash-based IDs make collisions extremely unlikely (same ID = same content)
// If we get here, it's likely a bug or manual ID manipulation
return nil, fmt.Errorf("collision detected for issues: %v (this should not happen with hash-based IDs)", result.CollisionIDs)
// Remove colliding issues from the list (they're already processed)
filteredIssues := make([]*types.Issue, 0)
collidingIDs := make(map[string]bool)
for _, collision := range collisionResult.Collisions {
collidingIDs[collision.ID] = true
}
for _, issue := range issues {
if !collidingIDs[issue.ID] {
filteredIssues = append(filteredIssues, issue)
}
}
return filteredIssues, nil
result.Updated = len(collisionResult.Collisions)
}
// Phase 4: Renames removed - obsolete with hash IDs (bd-8e05)
@@ -435,7 +423,7 @@ func upsertIssues(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues
// Phase 2: New content - check for ID collision
if existingWithID, found := dbByID[incoming.ID]; found {
// ID exists but different content - this is a collision
// The collision should have been handled earlier by handleCollisions
// The update should have been detected earlier by detectUpdates
// If we reach here, it means collision wasn't resolved - treat as update
if !opts.SkipUpdate {
// Build updates map

View File

@@ -616,6 +616,7 @@ func (m *MemoryStorage) SetJSONLFileHash(ctx context.Context, fileHash string) e
// GetDependencyTree gets the dependency tree for an issue
func (m *MemoryStorage) GetDependencyTree(ctx context.Context, issueID string, maxDepth int, showAllPaths bool, reverse bool) ([]*types.TreeNode, error) {
// Simplified implementation - just return direct dependencies
// Note: reverse parameter is accepted for interface compatibility but not fully implemented in memory storage
deps, err := m.GetDependencies(ctx, issueID)
if err != nil {
return nil, err

View File

@@ -431,7 +431,7 @@ func (s *SQLiteStorage) GetDependencyTree(ctx context.Context, issueID string, m
if err != nil {
return nil, fmt.Errorf("failed to scan tree node: %w", err)
}
_ = parentID // Silence unused variable warning
node.ParentID = parentID
if closedAt.Valid {
node.ClosedAt = &closedAt.Time

View File

@@ -1045,9 +1045,9 @@ func TestGetStatistics(t *testing.T) {
// does not affect normal usage where WAL mode handles typical concurrent operations.
// For very high concurrency needs, consider using CGO-enabled sqlite3 driver or PostgreSQL.
// TestParallelIssueCreation verifies that parallel issue creation doesn't cause ID collisions
// This is a regression test for bd-89 (GH-6) where race conditions in ID generation caused
// UNIQUE constraint failures when creating issues rapidly in parallel.
// TestParallelIssueCreation verifies that parallel issue creation works correctly with hash IDs
// This is a regression test for bd-89 (GH-6). With hash-based IDs, parallel creation works
// naturally since each issue gets a unique random hash - no coordination needed.
func TestParallelIssueCreation(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()

View File

@@ -222,8 +222,9 @@ type BlockedIssue struct {
// TreeNode represents a node in a dependency tree
type TreeNode struct {
Issue
Depth int `json:"depth"`
Truncated bool `json:"truncated"`
Depth int `json:"depth"`
ParentID string `json:"parent_id"`
Truncated bool `json:"truncated"`
}
// Statistics provides aggregate metrics