Files
beads/cmd/bd/dep.go
beads/crew/emma 426f67db41 fix(dep): ensure cycle detection runs in daemon mode for --blocks flag
The daemon code path was returning early after adding the dependency,
skipping the cycle detection that runs for direct mode. Restructure
so both paths share the cycle detection and output code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 16:20:04 -08:00

1189 lines
35 KiB
Go

// Package main implements the bd CLI dependency management commands.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/routing"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/utils"
)
// getBeadsDir returns the .beads directory path, derived from the global dbPath.
func getBeadsDir() string {
if dbPath != "" {
return filepath.Dir(dbPath)
}
return ""
}
// isChildOf returns true if childID is a hierarchical child of parentID.
// For example, "bd-abc.1" is a child of "bd-abc", and "bd-abc.1.2" is a child of "bd-abc.1".
func isChildOf(childID, parentID string) bool {
// A child ID has the format "parentID.N" or "parentID.N.M" etc.
// Use ParseHierarchicalID to get the actual parent
_, actualParentID, depth := types.ParseHierarchicalID(childID)
if depth == 0 {
return false // Not a hierarchical ID
}
// Check if the immediate parent matches
if actualParentID == parentID {
return true
}
// Also check if parentID is an ancestor (e.g., "bd-abc" is parent of "bd-abc.1.2")
return strings.HasPrefix(childID, parentID+".")
}
var depCmd = &cobra.Command{
Use: "dep [issue-id]",
GroupID: "deps",
Short: "Manage dependencies",
Long: `Manage dependencies between issues.
When called with an issue ID and --blocks flag, creates a blocking dependency:
bd dep <blocker-id> --blocks <blocked-id>
This is equivalent to:
bd dep add <blocked-id> <blocker-id>
Examples:
bd dep bd-xyz --blocks bd-abc # bd-xyz blocks bd-abc
bd dep add bd-abc bd-xyz # Same as above (bd-abc depends on bd-xyz)`,
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
blocksID, _ := cmd.Flags().GetString("blocks")
// If no args and no flags, show help
if len(args) == 0 && blocksID == "" {
_ = cmd.Help()
return
}
// If --blocks flag is provided, create a blocking dependency
if blocksID != "" {
if len(args) != 1 {
FatalErrorRespectJSON("--blocks requires exactly one issue ID argument")
}
blockerID := args[0]
CheckReadonly("dep --blocks")
ctx := rootCtx
depType := "blocks"
// Resolve partial IDs first
var fromID, toID string
if daemonClient != nil {
// Resolve the blocked issue ID (the one that will depend on the blocker)
resolveArgs := &rpc.ResolveIDArgs{ID: blocksID}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
FatalErrorRespectJSON("resolving issue ID %s: %v", blocksID, err)
}
if err := json.Unmarshal(resp.Data, &fromID); err != nil {
FatalErrorRespectJSON("unmarshaling resolved ID: %v", err)
}
// Resolve the blocker issue ID
resolveArgs = &rpc.ResolveIDArgs{ID: blockerID}
resp, err = daemonClient.ResolveID(resolveArgs)
if err != nil {
FatalErrorRespectJSON("resolving issue ID %s: %v", blockerID, err)
}
if err := json.Unmarshal(resp.Data, &toID); err != nil {
FatalErrorRespectJSON("unmarshaling resolved ID: %v", err)
}
} else {
var err error
fromID, err = utils.ResolvePartialID(ctx, store, blocksID)
if err != nil {
FatalErrorRespectJSON("resolving issue ID %s: %v", blocksID, err)
}
toID, err = utils.ResolvePartialID(ctx, store, blockerID)
if err != nil {
FatalErrorRespectJSON("resolving issue ID %s: %v", blockerID, err)
}
}
// Check for child→parent dependency anti-pattern
if isChildOf(fromID, toID) {
FatalErrorRespectJSON("cannot add dependency: %s is already a child of %s. Children inherit dependency on parent completion via hierarchy. Adding an explicit dependency would create a deadlock", fromID, toID)
}
// Add the dependency via daemon or direct mode
if daemonClient != nil {
depArgs := &rpc.DepAddArgs{
FromID: fromID,
ToID: toID,
DepType: depType,
}
_, err := daemonClient.AddDependency(depArgs)
if err != nil {
FatalErrorRespectJSON("%v", err)
}
} else {
// Direct mode
dep := &types.Dependency{
IssueID: fromID,
DependsOnID: toID,
Type: types.DependencyType(depType),
}
if err := store.AddDependency(ctx, dep, actor); err != nil {
FatalErrorRespectJSON("%v", err)
}
// Schedule auto-flush
markDirtyAndScheduleFlush()
}
// Check for cycles after adding dependency (both daemon and direct mode)
cycles, err := store.DetectCycles(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to check for cycles: %v\n", err)
} else if len(cycles) > 0 {
fmt.Fprintf(os.Stderr, "\n%s Warning: Dependency cycle detected!\n", ui.RenderWarn("⚠"))
fmt.Fprintf(os.Stderr, "This can hide issues from the ready work list and cause confusion.\n\n")
fmt.Fprintf(os.Stderr, "Cycle path:\n")
for _, cycle := range cycles {
for j, issue := range cycle {
if j == 0 {
fmt.Fprintf(os.Stderr, " %s", issue.ID)
} else {
fmt.Fprintf(os.Stderr, " → %s", issue.ID)
}
}
if len(cycle) > 0 {
fmt.Fprintf(os.Stderr, " → %s", cycle[0].ID)
}
fmt.Fprintf(os.Stderr, "\n")
}
fmt.Fprintf(os.Stderr, "\nRun 'bd dep cycles' for detailed analysis.\n\n")
}
if jsonOutput {
outputJSON(map[string]interface{}{
"status": "added",
"blocker_id": toID,
"blocked_id": fromID,
"type": depType,
})
return
}
fmt.Printf("%s Added dependency: %s blocks %s\n",
ui.RenderPass("✓"), toID, fromID)
return
}
// If we have an arg but no --blocks flag, show help
_ = cmd.Help()
},
}
var depAddCmd = &cobra.Command{
Use: "add [issue-id] [depends-on-id]",
Short: "Add a dependency",
Long: `Add a dependency between two issues.
The depends-on-id can be provided as:
- A positional argument: bd dep add issue-123 issue-456
- A flag: bd dep add issue-123 --blocked-by issue-456
- A flag: bd dep add issue-123 --depends-on issue-456
The --blocked-by and --depends-on flags are aliases and both mean "issue-123
depends on (is blocked by) the specified issue."
The depends-on-id can be:
- A local issue ID (e.g., bd-xyz)
- An external reference: external:<project>:<capability>
External references are stored as-is and resolved at query time using
the external_projects config. They block the issue until the capability
is "shipped" in the target project.
Examples:
bd dep add bd-42 bd-41 # Positional args
bd dep add bd-42 --blocked-by bd-41 # Flag syntax (same effect)
bd dep add bd-42 --depends-on bd-41 # Alias (same effect)
bd dep add gt-xyz external:beads:mol-run-assignee # Cross-project dependency`,
Args: func(cmd *cobra.Command, args []string) error {
blockedBy, _ := cmd.Flags().GetString("blocked-by")
dependsOn, _ := cmd.Flags().GetString("depends-on")
hasFlag := blockedBy != "" || dependsOn != ""
if hasFlag {
// If a flag is provided, we only need 1 positional arg (the dependent issue)
if len(args) < 1 {
return fmt.Errorf("requires at least 1 arg(s), only received %d", len(args))
}
if len(args) > 1 {
return fmt.Errorf("cannot use both positional depends-on-id and --blocked-by/--depends-on flag")
}
return nil
}
// No flag provided, need exactly 2 positional args
if len(args) != 2 {
return fmt.Errorf("requires 2 arg(s), only received %d (or use --blocked-by/--depends-on flag)", len(args))
}
return nil
},
Run: func(cmd *cobra.Command, args []string) {
CheckReadonly("dep add")
depType, _ := cmd.Flags().GetString("type")
// Get the dependency target from flag or positional arg
blockedBy, _ := cmd.Flags().GetString("blocked-by")
dependsOn, _ := cmd.Flags().GetString("depends-on")
var dependsOnArg string
if blockedBy != "" {
dependsOnArg = blockedBy
} else if dependsOn != "" {
dependsOnArg = dependsOn
} else {
dependsOnArg = args[1]
}
ctx := rootCtx
// Resolve partial IDs first
var fromID, toID string
// Check if toID is an external reference (don't resolve it)
isExternalRef := strings.HasPrefix(dependsOnArg, "external:")
if daemonClient != nil {
resolveArgs := &rpc.ResolveIDArgs{ID: args[0]}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
FatalErrorRespectJSON("resolving issue ID %s: %v", args[0], err)
}
if err := json.Unmarshal(resp.Data, &fromID); err != nil {
FatalErrorRespectJSON("unmarshaling resolved ID: %v", err)
}
if isExternalRef {
// External references are stored as-is
toID = dependsOnArg
// Validate format: external:<project>:<capability>
if err := validateExternalRef(toID); err != nil {
FatalErrorRespectJSON("%v", err)
}
} else {
resolveArgs = &rpc.ResolveIDArgs{ID: dependsOnArg}
resp, err = daemonClient.ResolveID(resolveArgs)
if err != nil {
// Resolution failed - try auto-converting to external ref
beadsDir := getBeadsDir()
if extRef := routing.ResolveToExternalRef(dependsOnArg, beadsDir); extRef != "" {
toID = extRef
isExternalRef = true
} else {
FatalErrorRespectJSON("resolving dependency ID %s: %v", dependsOnArg, err)
}
} else if err := json.Unmarshal(resp.Data, &toID); err != nil {
FatalErrorRespectJSON("unmarshaling resolved ID: %v", err)
}
}
} else {
var err error
fromID, err = utils.ResolvePartialID(ctx, store, args[0])
if err != nil {
FatalErrorRespectJSON("resolving issue ID %s: %v", args[0], err)
}
if isExternalRef {
// External references are stored as-is
toID = dependsOnArg
// Validate format: external:<project>:<capability>
if err := validateExternalRef(toID); err != nil {
FatalErrorRespectJSON("%v", err)
}
} else {
toID, err = utils.ResolvePartialID(ctx, store, dependsOnArg)
if err != nil {
// Resolution failed - try auto-converting to external ref
beadsDir := getBeadsDir()
if extRef := routing.ResolveToExternalRef(dependsOnArg, beadsDir); extRef != "" {
toID = extRef
isExternalRef = true
} else {
FatalErrorRespectJSON("resolving dependency ID %s: %v", dependsOnArg, err)
}
}
}
}
// Check for child→parent dependency anti-pattern
// This creates a deadlock: child can't start (parent open), parent can't close (children not done)
if isChildOf(fromID, toID) {
FatalErrorRespectJSON("cannot add dependency: %s is already a child of %s. Children inherit dependency on parent completion via hierarchy. Adding an explicit dependency would create a deadlock", fromID, toID)
}
// If daemon is running, use RPC
if daemonClient != nil {
depArgs := &rpc.DepAddArgs{
FromID: fromID,
ToID: toID,
DepType: depType,
}
resp, err := daemonClient.AddDependency(depArgs)
if err != nil {
FatalErrorRespectJSON("%v", err)
}
if jsonOutput {
fmt.Println(string(resp.Data))
return
}
fmt.Printf("%s Added dependency: %s depends on %s (%s)\n",
ui.RenderPass("✓"), args[0], dependsOnArg, depType)
return
}
// Direct mode
dep := &types.Dependency{
IssueID: fromID,
DependsOnID: toID,
Type: types.DependencyType(depType),
}
if err := store.AddDependency(ctx, dep, actor); err != nil {
FatalErrorRespectJSON("%v", err)
}
// Schedule auto-flush
markDirtyAndScheduleFlush()
// Check for cycles after adding dependency
cycles, err := store.DetectCycles(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to check for cycles: %v\n", err)
} else if len(cycles) > 0 {
fmt.Fprintf(os.Stderr, "\n%s Warning: Dependency cycle detected!\n", ui.RenderWarn("⚠"))
fmt.Fprintf(os.Stderr, "This can hide issues from the ready work list and cause confusion.\n\n")
fmt.Fprintf(os.Stderr, "Cycle path:\n")
for _, cycle := range cycles {
for j, issue := range cycle {
if j == 0 {
fmt.Fprintf(os.Stderr, " %s", issue.ID)
} else {
fmt.Fprintf(os.Stderr, " → %s", issue.ID)
}
}
if len(cycle) > 0 {
fmt.Fprintf(os.Stderr, " → %s", cycle[0].ID)
}
fmt.Fprintf(os.Stderr, "\n")
}
fmt.Fprintf(os.Stderr, "\nRun 'bd dep cycles' for detailed analysis.\n\n")
}
if jsonOutput {
outputJSON(map[string]interface{}{
"status": "added",
"issue_id": fromID,
"depends_on_id": toID,
"type": depType,
})
return
}
fmt.Printf("%s Added dependency: %s depends on %s (%s)\n",
ui.RenderPass("✓"), fromID, toID, depType)
},
}
var depListCmd = &cobra.Command{
Use: "list [issue-id]",
Short: "List dependencies or dependents of an issue",
Long: `List dependencies or dependents of an issue with optional type filtering.
By default shows dependencies (what this issue depends on). Use --direction to control:
- down: Show dependencies (what this issue depends on) - default
- up: Show dependents (what depends on this issue)
Use --type to filter by dependency type (e.g., tracks, blocks, parent-child).
Examples:
bd dep list gt-abc # Show what gt-abc depends on
bd dep list gt-abc --direction=up # Show what depends on gt-abc
bd dep list gt-abc --direction=up -t tracks # Show what tracks gt-abc (convoy tracking)`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
ctx := rootCtx
// Resolve partial ID first
var fullID string
if daemonClient != nil {
resolveArgs := &rpc.ResolveIDArgs{ID: args[0]}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
FatalErrorRespectJSON("resolving issue ID %s: %v", args[0], err)
}
if err := json.Unmarshal(resp.Data, &fullID); err != nil {
FatalErrorRespectJSON("unmarshaling resolved ID: %v", err)
}
} else {
var err error
fullID, err = utils.ResolvePartialID(ctx, store, args[0])
if err != nil {
FatalErrorRespectJSON("resolving %s: %v", args[0], err)
}
}
// If daemon is running but doesn't support this command, use direct storage
if daemonClient != nil && store == nil {
var err error
store, err = sqlite.New(rootCtx, dbPath)
if err != nil {
FatalErrorRespectJSON("failed to open database: %v", err)
}
defer func() { _ = store.Close() }()
}
direction, _ := cmd.Flags().GetString("direction")
typeFilter, _ := cmd.Flags().GetString("type")
if direction == "" {
direction = "down"
}
var issues []*types.IssueWithDependencyMetadata
var err error
if direction == "up" {
issues, err = store.GetDependentsWithMetadata(ctx, fullID)
} else {
issues, err = store.GetDependenciesWithMetadata(ctx, fullID)
}
if err != nil {
FatalErrorRespectJSON("%v", err)
}
// Apply type filter if specified
if typeFilter != "" {
var filtered []*types.IssueWithDependencyMetadata
for _, iss := range issues {
if string(iss.DependencyType) == typeFilter {
filtered = append(filtered, iss)
}
}
issues = filtered
}
if jsonOutput {
if issues == nil {
issues = []*types.IssueWithDependencyMetadata{}
}
outputJSON(issues)
return
}
if len(issues) == 0 {
if typeFilter != "" {
if direction == "up" {
fmt.Printf("\nNo issues depend on %s with type '%s'\n", fullID, typeFilter)
} else {
fmt.Printf("\n%s has no dependencies of type '%s'\n", fullID, typeFilter)
}
} else {
if direction == "up" {
fmt.Printf("\nNo issues depend on %s\n", fullID)
} else {
fmt.Printf("\n%s has no dependencies\n", fullID)
}
}
return
}
if direction == "up" {
fmt.Printf("\n%s Issues that depend on %s:\n\n", ui.RenderAccent("📋"), fullID)
} else {
fmt.Printf("\n%s %s depends on:\n\n", ui.RenderAccent("📋"), fullID)
}
for _, iss := range issues {
// Color the ID based on status
var idStr string
switch iss.Status {
case types.StatusOpen:
idStr = ui.StatusOpenStyle.Render(iss.ID)
case types.StatusInProgress:
idStr = ui.StatusInProgressStyle.Render(iss.ID)
case types.StatusBlocked:
idStr = ui.StatusBlockedStyle.Render(iss.ID)
case types.StatusClosed:
idStr = ui.StatusClosedStyle.Render(iss.ID)
default:
idStr = iss.ID
}
fmt.Printf(" %s: %s [P%d] (%s) via %s\n",
idStr, iss.Title, iss.Priority, iss.Status, iss.DependencyType)
}
fmt.Println()
},
}
var depRemoveCmd = &cobra.Command{
Use: "remove [issue-id] [depends-on-id]",
Aliases: []string{"rm"},
Short: "Remove a dependency",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
CheckReadonly("dep remove")
ctx := rootCtx
// Resolve partial IDs first
var fromID, toID string
if daemonClient != nil {
resolveArgs := &rpc.ResolveIDArgs{ID: args[0]}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
FatalErrorRespectJSON("resolving issue ID %s: %v", args[0], err)
}
if err := json.Unmarshal(resp.Data, &fromID); err != nil {
FatalErrorRespectJSON("unmarshaling resolved ID: %v", err)
}
resolveArgs = &rpc.ResolveIDArgs{ID: args[1]}
resp, err = daemonClient.ResolveID(resolveArgs)
if err != nil {
FatalErrorRespectJSON("resolving dependency ID %s: %v", args[1], err)
}
if err := json.Unmarshal(resp.Data, &toID); err != nil {
FatalErrorRespectJSON("unmarshaling resolved ID: %v", err)
}
} else {
var err error
fromID, err = utils.ResolvePartialID(ctx, store, args[0])
if err != nil {
FatalErrorRespectJSON("resolving issue ID %s: %v", args[0], err)
}
toID, err = utils.ResolvePartialID(ctx, store, args[1])
if err != nil {
FatalErrorRespectJSON("resolving dependency ID %s: %v", args[1], err)
}
}
// If daemon is running, use RPC
if daemonClient != nil {
depArgs := &rpc.DepRemoveArgs{
FromID: fromID,
ToID: toID,
}
resp, err := daemonClient.RemoveDependency(depArgs)
if err != nil {
FatalErrorRespectJSON("%v", err)
}
if jsonOutput {
fmt.Println(string(resp.Data))
return
}
fmt.Printf("%s Removed dependency: %s no longer depends on %s\n",
ui.RenderPass("✓"), fromID, toID)
return
}
// Direct mode
fullFromID := fromID
fullToID := toID
if err := store.RemoveDependency(ctx, fullFromID, fullToID, actor); err != nil {
FatalErrorRespectJSON("%v", err)
}
// Schedule auto-flush
markDirtyAndScheduleFlush()
if jsonOutput {
outputJSON(map[string]interface{}{
"status": "removed",
"issue_id": fullFromID,
"depends_on_id": fullToID,
})
return
}
fmt.Printf("%s Removed dependency: %s no longer depends on %s\n",
ui.RenderPass("✓"), fullFromID, fullToID)
},
}
var depTreeCmd = &cobra.Command{
Use: "tree [issue-id]",
Short: "Show dependency tree",
Long: `Show dependency tree rooted at the given issue.
By default, shows dependencies (what blocks this issue). Use --direction to control:
- down: Show dependencies (what blocks this issue) - default
- up: Show dependents (what this issue blocks)
- both: Show full graph in both directions
Examples:
bd dep tree gt-0iqq # Show what blocks gt-0iqq
bd dep tree gt-0iqq --direction=up # Show what gt-0iqq blocks
bd dep tree gt-0iqq --status=open # Only show open issues
bd dep tree gt-0iqq --depth=3 # Limit to 3 levels deep`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
ctx := rootCtx
// Resolve partial ID first
var fullID string
if daemonClient != nil {
resolveArgs := &rpc.ResolveIDArgs{ID: args[0]}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
FatalErrorRespectJSON("resolving issue ID %s: %v", args[0], err)
}
if err := json.Unmarshal(resp.Data, &fullID); err != nil {
FatalErrorRespectJSON("unmarshaling resolved ID: %v", err)
}
} else {
var err error
fullID, err = utils.ResolvePartialID(ctx, store, args[0])
if err != nil {
FatalErrorRespectJSON("resolving %s: %v", args[0], err)
}
}
// If daemon is running but doesn't support this command, use direct storage
if daemonClient != nil && store == nil {
var err error
store, err = sqlite.New(rootCtx, dbPath)
if err != nil {
FatalErrorRespectJSON("failed to open database: %v", err)
}
defer func() { _ = store.Close() }()
}
showAllPaths, _ := cmd.Flags().GetBool("show-all-paths")
maxDepth, _ := cmd.Flags().GetInt("max-depth")
reverse, _ := cmd.Flags().GetBool("reverse")
direction, _ := cmd.Flags().GetString("direction")
statusFilter, _ := cmd.Flags().GetString("status")
formatStr, _ := cmd.Flags().GetString("format")
// Handle --direction flag (takes precedence over deprecated --reverse)
if direction == "" && reverse {
direction = "up"
} else if direction == "" {
direction = "down"
}
// Validate direction
if direction != "down" && direction != "up" && direction != "both" {
FatalErrorRespectJSON("--direction must be 'down', 'up', or 'both'")
}
if maxDepth < 1 {
FatalErrorRespectJSON("--max-depth must be >= 1")
}
// For "both" direction, we need to fetch both trees and merge them
var tree []*types.TreeNode
var err error
if direction == "both" {
// Get dependencies (down) - what blocks this issue
downTree, err := store.GetDependencyTree(ctx, fullID, maxDepth, showAllPaths, false)
if err != nil {
FatalErrorRespectJSON("%v", err)
}
// Get dependents (up) - what this issue blocks
upTree, err := store.GetDependencyTree(ctx, fullID, maxDepth, showAllPaths, true)
if err != nil {
FatalErrorRespectJSON("%v", err)
}
// Merge: root appears once, dependencies below, dependents above
// We'll show dependents first (with negative-like positioning conceptually),
// then root, then dependencies
tree = mergeBidirectionalTrees(downTree, upTree, fullID)
} else {
tree, err = store.GetDependencyTree(ctx, fullID, maxDepth, showAllPaths, direction == "up")
if err != nil {
FatalErrorRespectJSON("%v", err)
}
}
// Apply status filter if specified
if statusFilter != "" {
tree = filterTreeByStatus(tree, types.Status(statusFilter))
}
// Handle mermaid format
if formatStr == "mermaid" {
outputMermaidTree(tree, args[0])
return
}
if jsonOutput {
// Always output array, even if empty
if tree == nil {
tree = []*types.TreeNode{}
}
outputJSON(tree)
return
}
if len(tree) == 0 {
switch direction {
case "up":
fmt.Printf("\n%s has no dependents\n", fullID)
case "both":
fmt.Printf("\n%s has no dependencies or dependents\n", fullID)
default:
fmt.Printf("\n%s has no dependencies\n", fullID)
}
return
}
switch direction {
case "up":
fmt.Printf("\n%s Dependent tree for %s:\n\n", ui.RenderAccent("🌲"), fullID)
case "both":
fmt.Printf("\n%s Full dependency graph for %s:\n\n", ui.RenderAccent("🌲"), fullID)
default:
fmt.Printf("\n%s Dependency tree for %s:\n\n", ui.RenderAccent("🌲"), fullID)
}
// Render tree with proper connectors
renderTree(tree, maxDepth, direction)
fmt.Println()
},
}
var depCyclesCmd = &cobra.Command{
Use: "cycles",
Short: "Detect dependency cycles",
Run: func(cmd *cobra.Command, args []string) {
// If daemon is running but doesn't support this command, use direct storage
if daemonClient != nil && store == nil {
var err error
store, err = sqlite.New(rootCtx, dbPath)
if err != nil {
FatalErrorRespectJSON("failed to open database: %v", err)
}
defer func() { _ = store.Close() }()
}
ctx := rootCtx
cycles, err := store.DetectCycles(ctx)
if err != nil {
FatalErrorRespectJSON("%v", err)
}
if jsonOutput {
// Always output array, even if empty
if cycles == nil {
cycles = [][]*types.Issue{}
}
outputJSON(cycles)
return
}
if len(cycles) == 0 {
fmt.Printf("\n%s No dependency cycles detected\n\n", ui.RenderPass("✓"))
return
}
fmt.Printf("\n%s Found %d dependency cycles:\n\n", ui.RenderFail("⚠"), len(cycles))
for i, cycle := range cycles {
fmt.Printf("%d. Cycle involving:\n", i+1)
for _, issue := range cycle {
fmt.Printf(" - %s: %s\n", issue.ID, issue.Title)
}
fmt.Println()
}
},
}
// 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.StatusDeferred:
return "❄" // U+2744 Snowflake (on ice)
case types.StatusClosed:
return "☑" // U+2611 Ballot Box with Check
default:
return "?"
}
}
// treeRenderer holds state for rendering a tree with proper connectors
type treeRenderer struct {
// Track which nodes we've already displayed (for "shown above" handling)
seen map[string]bool
// Track connector state at each depth level (true = has more siblings)
activeConnectors []bool
// Maximum depth reached
maxDepth int
// Direction of traversal
direction string
}
// renderTree renders the tree with proper box-drawing connectors
func renderTree(tree []*types.TreeNode, maxDepth int, direction string) {
if len(tree) == 0 {
return
}
r := &treeRenderer{
seen: make(map[string]bool),
activeConnectors: make([]bool, maxDepth+1),
maxDepth: maxDepth,
direction: direction,
}
// Build a map of parent -> children for proper sibling tracking
children := make(map[string][]*types.TreeNode)
var root *types.TreeNode
for _, node := range tree {
if node.Depth == 0 {
root = node
} else {
children[node.ParentID] = append(children[node.ParentID], node)
}
}
if root == nil && len(tree) > 0 {
root = tree[0]
}
// Render recursively from root
r.renderNode(root, children, 0, true)
}
// renderNode renders a single node and its children
func (r *treeRenderer) renderNode(node *types.TreeNode, children map[string][]*types.TreeNode, depth int, isLast bool) {
if node == nil {
return
}
// Build the prefix with connectors
var prefix strings.Builder
// Add vertical lines for active parent connectors
for i := 0; i < depth; i++ {
if r.activeConnectors[i] {
prefix.WriteString("│ ")
} else {
prefix.WriteString(" ")
}
}
// Add the branch connector for non-root nodes
if depth > 0 {
if isLast {
prefix.WriteString("└── ")
} else {
prefix.WriteString("├── ")
}
}
// Check if we've seen this node before (diamond dependency)
if r.seen[node.ID] {
fmt.Printf("%s%s (shown above)\n", prefix.String(), ui.RenderMuted(node.ID))
return
}
r.seen[node.ID] = true
// Format the node line
line := formatTreeNode(node)
// Add truncation warning if at max depth and has children
if node.Truncated || (depth == r.maxDepth && len(children[node.ID]) > 0) {
line += ui.RenderWarn(" …")
}
fmt.Printf("%s%s\n", prefix.String(), line)
// Render children
nodeChildren := children[node.ID]
for i, child := range nodeChildren {
// Update connector state for this depth
// For depth 0 (root level), never show vertical connector since root has no siblings
if depth > 0 {
r.activeConnectors[depth] = (i < len(nodeChildren)-1)
}
r.renderNode(child, children, depth+1, i == len(nodeChildren)-1)
}
}
// formatTreeNode formats a single tree node with status, ready indicator, etc.
func formatTreeNode(node *types.TreeNode) string {
// Handle external dependencies specially
if IsExternalRef(node.ID) {
// External deps use their title directly which includes the status indicator
var idStr string
switch node.Status {
case types.StatusClosed:
idStr = ui.StatusClosedStyle.Render(node.Title)
case types.StatusBlocked:
idStr = ui.StatusBlockedStyle.Render(node.Title)
default:
idStr = node.Title
}
return fmt.Sprintf("%s (external)", idStr)
}
// Color the ID based on status
var idStr string
switch node.Status {
case types.StatusOpen:
idStr = ui.StatusOpenStyle.Render(node.ID)
case types.StatusInProgress:
idStr = ui.StatusInProgressStyle.Render(node.ID)
case types.StatusBlocked:
idStr = ui.StatusBlockedStyle.Render(node.ID)
case types.StatusClosed:
idStr = ui.StatusClosedStyle.Render(node.ID)
default:
idStr = node.ID
}
// Build the line
line := fmt.Sprintf("%s: %s [P%d] (%s)",
idStr, node.Title, node.Priority, node.Status)
// Add READY indicator for open issues (those that could be worked on)
// An issue is ready if it's open and has no blocking dependencies
// (In the tree view, depth 0 with status open implies ready in the "down" direction)
if node.Status == types.StatusOpen && node.Depth == 0 {
line += " " + ui.PassStyle.Bold(true).Render("[READY]")
}
return line
}
// filterTreeByStatus filters the tree to only include nodes with the given status
// Note: keeps parent chain to maintain tree structure
func filterTreeByStatus(tree []*types.TreeNode, status types.Status) []*types.TreeNode {
if len(tree) == 0 {
return tree
}
// First pass: identify which nodes match the status
matches := make(map[string]bool)
for _, node := range tree {
if node.Status == status {
matches[node.ID] = true
}
}
// If no matches, return empty
if len(matches) == 0 {
return []*types.TreeNode{}
}
// Second pass: keep matching nodes and their ancestors
// Build parent map
parentOf := make(map[string]string)
for _, node := range tree {
if node.ParentID != "" && node.ParentID != node.ID {
parentOf[node.ID] = node.ParentID
}
}
// Mark all ancestors of matching nodes
keep := make(map[string]bool)
for id := range matches {
keep[id] = true
// Walk up to root
current := id
for {
parent, ok := parentOf[current]
if !ok || parent == current {
break
}
keep[parent] = true
current = parent
}
}
// Filter the tree
var filtered []*types.TreeNode
for _, node := range tree {
if keep[node.ID] {
filtered = append(filtered, node)
}
}
return filtered
}
// mergeBidirectionalTrees merges up and down trees into a single visualization
// The root appears once, with dependencies shown below and dependents shown above
func mergeBidirectionalTrees(downTree, upTree []*types.TreeNode, rootID string) []*types.TreeNode {
// For bidirectional display, we show the down tree (dependencies) as the main tree
// and add a visual separator with the up tree (dependents)
//
// For simplicity, we'll just return the down tree for now
// A more sophisticated implementation would show both with visual separation
// Find root in each tree
var result []*types.TreeNode
// Add dependents section if any (excluding root)
hasUpNodes := false
for _, node := range upTree {
if node.ID != rootID {
hasUpNodes = true
break
}
}
if hasUpNodes {
// Add a header node for dependents section
// We'll mark these with negative depth for visual distinction
for _, node := range upTree {
if node.ID == rootID {
continue // Skip root, we'll add it once from down tree
}
// Clone node and mark it as "up" direction
upNode := *node
upNode.Depth = node.Depth // Keep original depth
result = append(result, &upNode)
}
}
// Add the down tree (dependencies)
result = append(result, downTree...)
return result
}
// validateExternalRef validates the format of an external dependency reference.
// Valid format: external:<project>:<capability>
func validateExternalRef(ref string) error {
if !strings.HasPrefix(ref, "external:") {
return fmt.Errorf("external reference must start with 'external:'")
}
parts := strings.SplitN(ref, ":", 3)
if len(parts) != 3 {
return fmt.Errorf("invalid external reference format: expected 'external:<project>:<capability>', got '%s'", ref)
}
project := parts[1]
capability := parts[2]
if project == "" {
return fmt.Errorf("external reference missing project name")
}
if capability == "" {
return fmt.Errorf("external reference missing capability name")
}
return nil
}
// IsExternalRef returns true if the dependency reference is an external reference.
func IsExternalRef(ref string) bool {
return strings.HasPrefix(ref, "external:")
}
// ParseExternalRef parses an external reference into project and capability.
// Returns empty strings if the format is invalid.
func ParseExternalRef(ref string) (project, capability string) {
if !IsExternalRef(ref) {
return "", ""
}
parts := strings.SplitN(ref, ":", 3)
if len(parts) != 3 {
return "", ""
}
return parts[1], parts[2]
}
func init() {
// dep command shorthand flag
depCmd.Flags().StringP("blocks", "b", "", "Issue ID that this issue blocks (shorthand for: bd dep add <blocked> <blocker>)")
depAddCmd.Flags().StringP("type", "t", "blocks", "Dependency type (blocks|tracks|related|parent-child|discovered-from|until|caused-by|validates|relates-to|supersedes)")
depAddCmd.Flags().String("blocked-by", "", "Issue ID that blocks the first issue (alternative to positional arg)")
depAddCmd.Flags().String("depends-on", "", "Issue ID that the first issue depends on (alias for --blocked-by)")
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 (deprecated: use --direction=up)")
depTreeCmd.Flags().String("direction", "", "Tree direction: 'down' (dependencies), 'up' (dependents), or 'both'")
depTreeCmd.Flags().String("status", "", "Filter to only show issues with this status (open, in_progress, blocked, deferred, closed)")
depTreeCmd.Flags().String("format", "", "Output format: 'mermaid' for Mermaid.js flowchart")
depTreeCmd.Flags().StringP("type", "t", "", "Filter to only show dependencies of this type (e.g., tracks, blocks, parent-child)")
depListCmd.Flags().String("direction", "down", "Direction: 'down' (dependencies), 'up' (dependents)")
depListCmd.Flags().StringP("type", "t", "", "Filter by dependency type (e.g., tracks, blocks, parent-child)")
depCmd.AddCommand(depAddCmd)
depCmd.AddCommand(depRemoveCmd)
depCmd.AddCommand(depListCmd)
depCmd.AddCommand(depTreeCmd)
depCmd.AddCommand(depCyclesCmd)
rootCmd.AddCommand(depCmd)
}