How Beads Prevents and Detects Circular Dependencies
Beads prevents circular dependencies through a pre-insertion recursive CTE guard in AddDependencyInTx and detects existing cycles via a post-insertion DFS scan in DetectCyclesInTx.
Circular dependencies in issue tracking graphs corrupt project timelines and invalidate automation workflows. The gastownhall/beads repository implements a two-layer defense system that prevents cycles during dependency creation while providing tools to audit and surface existing loops. This article examines the exact mechanisms used to maintain acyclic dependency graphs in Beads.
Pre-Insertion Cycle Prevention
When you add a blocking dependency using bd dep add, Beads validates the operation before committing it to the database.
The Recursive CTE Guard
The AddDependencyInTx function in internal/storage/issueops/dependencies.go (lines 25-53) runs a recursive Common Table Expression (CTE) to validate potential inserts. This guard specifically applies to DepBlocks and DepConditionalBlocks dependency types.
The CTE performs the following logic:
- Starts from the proposed target issue (
dep.DependsOnID) - Recursively follows all existing
blocksandconditional-blocksedges in both thedependenciesandwisp_dependenciestables - Checks if the source issue (
dep.IssueID) appears in the reachable set
If the source is reachable from the target, inserting the edge would close a loop, so the function returns the error "adding dependency would create a cycle" and aborts the transaction.
// internal/storage/issueops/dependencies.go
if !opts.SkipCycleCheck && (dep.Type == types.DepBlocks || dep.Type == types.DepConditionalBlocks) {
var unions []string
for _, t := range depTables {
unions = append(unions, fmt.Sprintf(
"SELECT issue_id, depends_on_id FROM %s WHERE type IN ('blocks','conditional-blocks')", t))
}
unionQuery := strings.Join(unions, " UNION ALL ")
var reachable int
if err := tx.QueryRowContext(ctx, fmt.Sprintf(`
WITH RECURSIVE reachable AS (
SELECT ? AS node, 0 AS depth
UNION ALL
SELECT d.depends_on_id, r.depth + 1
FROM reachable r
JOIN (%s) d ON d.issue_id = r.node
WHERE r.depth < 100
)
SELECT COUNT(*) FROM reachable WHERE node = ?
`, unionQuery), dep.DependsOnID, dep.IssueID).Scan(&reachable); err != nil {
return fmt.Errorf("failed to check for dependency cycle: %w", err)
}
if reachable > 0 {
return fmt.Errorf("adding dependency would create a cycle")
}
}
The recursion depth is capped at 100 levels to prevent runaway queries on deeply chained dependencies.
Bypassing the Guard
For bulk wiring operations, you can bypass the CTE check using the --no-cycle-check flag:
bd dep add --file bulk.jsonl --no-cycle-check
This is why Beads implements a secondary detection layer to catch cycles that bypass the initial guard.
Post-Insertion Cycle Detection
After dependencies are inserted—or when explicitly requested—Beads performs a comprehensive graph analysis to find cycles.
Building the Dependency Graph
The DetectCyclesInTx function in internal/storage/issueops/cycles.go (lines 11-34) reads all blocking edges from both storage tables into an in-memory adjacency map:
graph := make(map[string][]string)
for _, depTable := range []string{"dependencies", "wisp_dependencies"} {
rows, err := tx.QueryContext(ctx,
fmt.Sprintf(`SELECT issue_id, depends_on_id, type FROM %s`, depTable))
// ... scan rows ...
if types.DependencyType(depType) == types.DepBlocks ||
types.DependencyType(depType) == types.DepConditionalBlocks {
graph[issueID] = append(graph[issueID], dependsOnID)
}
}
DFS-Based Cycle Enumeration
Lines 44-96 implement a depth-first search (DFS) algorithm with recursion stack tracking to detect back-edges:
var cycles [][]*types.Issue
visited := make(map[string]bool)
recStack := make(map[string]bool)
var path []string
var dfs func(node string) bool
dfs = func(node string) bool {
visited[node] = true
recStack[node] = true
path = append(path, node)
for _, neighbor := range graph[node] {
if !visited[neighbor] {
if dfs(neighbor) { return true }
} else if recStack[neighbor] {
// Cycle detected: extract and resolve IDs to Issues
cycles = append(cycles, cycleIssues)
}
}
path = path[:len(path)-1]
recStack[node] = false
return false
}
When the algorithm encounters a node already present in the current recursion stack, it extracts the cycle path and resolves each ID to a full *types.Issue struct for display purposes.
CLI Integration and User Feedback
Beads surfaces cycle information through the CLI in two ways.
Post-Add Warnings
The warnIfCyclesExist function in cmd/bd/dep.go (lines 57-71) runs automatically after bd dep add completes successfully (unless --no-cycle-check was used):
func warnIfCyclesExist(s storage.DoltStorage) {
if s == nil { return }
cycles, err := s.DetectCycles(rootCtx)
if err != nil { /* handle error */ }
if len(cycles) == 0 { return }
fmt.Fprintf(os.Stderr, "\n%s Warning: Dependency cycle detected!\n", ui.RenderWarn("⚠"))
// ... pretty-print each cycle ...
}
Explicit Detection Command
The bd dep cycles command invokes store.DetectCycles, which delegates to DetectCyclesInTx for a full analysis:
bd dep cycles
This outputs a detailed report listing each cycle with issue titles and statuses.
Code Examples
Blocking a Cycle at Insert Time
# Create two issues
bd issue add "First" # → bd-001
bd issue add "Second" # → bd-002
# Add forward dependency (succeeds)
bd dep add bd-001 bd-002
# Attempt reverse (fails with cycle error)
bd dep add bd-002 bd-001
# Error: adding dependency would create a cycle
Detecting Cycles After Bulk Import
# Import with cycle check disabled
bd dep add --file bulk.jsonl --no-cycle-check
# Audit for cycles
bd dep cycles
# Output:
# 1. Cycle involving:
# - bd-001: First
# - bd-002: Second
# - bd-001 (repeated)
Programmatic Usage
ctx := context.Background()
dep := &types.Dependency{
IssueID: "bd-002",
DependsOnID: "bd-001",
Type: types.DepBlocks,
}
// This returns error if cycle would be created
if err := store.AddDependency(ctx, dep, "tester"); err != nil {
// err.Error() == "adding dependency would create a cycle"
}
Key Implementation Files
| File | Function | Purpose |
|---|---|---|
internal/storage/issueops/dependencies.go |
AddDependencyInTx |
Pre-insertion CTE validation |
internal/storage/issueops/cycles.go |
DetectCyclesInTx |
Post-insertion DFS enumeration |
cmd/bd/dep.go |
warnIfCyclesExist |
CLI warning after add operations |
cmd/bd/dep.go |
depCyclesCmd |
Implementation of bd dep cycles |
internal/storage/dolt/dependencies.go |
DetectCycles |
Storage-layer delegation to detection logic |
Summary
- Pre-insertion guard: The recursive CTE in
AddDependencyInTxrejects any dependency that would create a cycle before it reaches the database. - Post-insertion detection: The DFS algorithm in
DetectCyclesInTxscans the entire graph on demand to find existing cycles. - CLI feedback: Automatic warnings after
bd dep addand the explicitbd dep cyclescommand provide visibility into graph integrity. - Bypass safety: The
--no-cycle-checkflag allows bulk imports while relying on the secondary detection layer for remediation.
Frequently Asked Questions
What happens when I try to create a circular dependency in Beads?
Beads rejects the operation with the error "adding dependency would create a cycle". The AddDependencyInTx function runs a recursive CTE query starting from the target issue; if it can reach the source issue through existing blocks or conditional-blocks edges, the transaction aborts before persisting the new edge.
Can I skip the cycle check when adding dependencies?
Yes. Use the --no-cycle-check flag with bd dep add to bypass the pre-insertion CTE validation. This is useful for bulk imports but should be followed by running bd dep cycles to audit the graph for any cycles introduced during the import.
How does Beads detect cycles across temporary issues (wisps)?
The CTE guard and DFS detection both query both the dependencies and wisp_dependencies tables using a UNION ALL clause. This ensures that blocking relationships involving temporary issues (wisps) are included in the cycle detection logic, maintaining graph integrity across both permanent and temporary branches.
What is the maximum depth searched by the cycle prevention CTE?
The recursive CTE limits traversal depth to 100 levels (WHERE r.depth < 100). This prevents performance degradation on deeply nested dependency chains while still catching cycles in practical project management scenarios where dependencies rarely exceed dozens of levels.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →