// Package main implements the bd CLI dependency management commands. package main import ( "encoding/json" "fmt" "os" "strings" "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/utils" ) var depCmd = &cobra.Command{ Use: "dep", Short: "Manage dependencies", } var depAddCmd = &cobra.Command{ Use: "add [issue-id] [depends-on-id]", Short: "Add a dependency", Args: cobra.ExactArgs(2), Run: func(cmd *cobra.Command, args []string) { CheckReadonly("dep add") depType, _ := cmd.Flags().GetString("type") 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 { fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err) os.Exit(1) } if err := json.Unmarshal(resp.Data, &fromID); err != nil { fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err) os.Exit(1) } resolveArgs = &rpc.ResolveIDArgs{ID: args[1]} resp, err = daemonClient.ResolveID(resolveArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error resolving dependency ID %s: %v\n", args[1], err) os.Exit(1) } if err := json.Unmarshal(resp.Data, &toID); err != nil { fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err) os.Exit(1) } } else { var err error fromID, err = utils.ResolvePartialID(ctx, store, args[0]) if err != nil { fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err) os.Exit(1) } toID, err = utils.ResolvePartialID(ctx, store, args[1]) if err != nil { fmt.Fprintf(os.Stderr, "Error resolving dependency ID %s: %v\n", args[1], err) os.Exit(1) } } // 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 { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } if jsonOutput { fmt.Println(string(resp.Data)) return } green := color.New(color.FgGreen).SprintFunc() fmt.Printf("%s Added dependency: %s depends on %s (%s)\n", green("✓"), args[0], args[1], depType) return } // Direct mode dep := &types.Dependency{ IssueID: fromID, DependsOnID: toID, Type: types.DependencyType(depType), } if err := store.AddDependency(ctx, dep, actor); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } // 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 { yellow := color.New(color.FgYellow).SprintFunc() fmt.Fprintf(os.Stderr, "\n%s Warning: Dependency cycle detected!\n", yellow("⚠")) 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 } green := color.New(color.FgGreen).SprintFunc() fmt.Printf("%s Added dependency: %s depends on %s (%s)\n", green("✓"), fromID, toID, depType) }, } var depRemoveCmd = &cobra.Command{ Use: "remove [issue-id] [depends-on-id]", 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 { fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err) os.Exit(1) } if err := json.Unmarshal(resp.Data, &fromID); err != nil { fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err) os.Exit(1) } resolveArgs = &rpc.ResolveIDArgs{ID: args[1]} resp, err = daemonClient.ResolveID(resolveArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error resolving dependency ID %s: %v\n", args[1], err) os.Exit(1) } if err := json.Unmarshal(resp.Data, &toID); err != nil { fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err) os.Exit(1) } } else { var err error fromID, err = utils.ResolvePartialID(ctx, store, args[0]) if err != nil { fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err) os.Exit(1) } toID, err = utils.ResolvePartialID(ctx, store, args[1]) if err != nil { fmt.Fprintf(os.Stderr, "Error resolving dependency ID %s: %v\n", args[1], err) os.Exit(1) } } // If daemon is running, use RPC if daemonClient != nil { depArgs := &rpc.DepRemoveArgs{ FromID: fromID, ToID: toID, } resp, err := daemonClient.RemoveDependency(depArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } if jsonOutput { fmt.Println(string(resp.Data)) return } green := color.New(color.FgGreen).SprintFunc() fmt.Printf("%s Removed dependency: %s no longer depends on %s\n", green("✓"), fromID, toID) return } // Direct mode fullFromID := fromID fullToID := toID if err := store.RemoveDependency(ctx, fullFromID, fullToID, actor); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } // Schedule auto-flush markDirtyAndScheduleFlush() if jsonOutput { outputJSON(map[string]interface{}{ "status": "removed", "issue_id": fullFromID, "depends_on_id": fullToID, }) return } green := color.New(color.FgGreen).SprintFunc() fmt.Printf("%s Removed dependency: %s no longer depends on %s\n", green("✓"), 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 { fmt.Fprintf(os.Stderr, "Error resolving issue ID %s: %v\n", args[0], err) os.Exit(1) } if err := json.Unmarshal(resp.Data, &fullID); err != nil { fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err) os.Exit(1) } } else { var err error fullID, err = utils.ResolvePartialID(ctx, store, args[0]) if err != nil { fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", args[0], err) os.Exit(1) } } // 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 { fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err) os.Exit(1) } 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" { fmt.Fprintf(os.Stderr, "Error: --direction must be 'down', 'up', or 'both'\n") os.Exit(1) } if maxDepth < 1 { fmt.Fprintf(os.Stderr, "Error: --max-depth must be >= 1\n") os.Exit(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 { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } // Get dependents (up) - what this issue blocks upTree, err := store.GetDependencyTree(ctx, fullID, maxDepth, showAllPaths, true) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } // 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 { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } // 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 } cyan := color.New(color.FgCyan).SprintFunc() switch direction { case "up": fmt.Printf("\n%s Dependent tree for %s:\n\n", cyan("🌲"), fullID) case "both": fmt.Printf("\n%s Full dependency graph for %s:\n\n", cyan("🌲"), fullID) default: fmt.Printf("\n%s Dependency tree for %s:\n\n", cyan("🌲"), 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 { fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err) os.Exit(1) } defer func() { _ = store.Close() }() } ctx := rootCtx cycles, err := store.DetectCycles(ctx) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } if jsonOutput { // Always output array, even if empty if cycles == nil { cycles = [][]*types.Issue{} } outputJSON(cycles) return } if len(cycles) == 0 { green := color.New(color.FgGreen).SprintFunc() fmt.Printf("\n%s No dependency cycles detected\n\n", green("✓")) return } red := color.New(color.FgRed).SprintFunc() fmt.Printf("\n%s Found %d dependency cycles:\n\n", red("⚠"), 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.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] { gray := color.New(color.FgHiBlack).SprintFunc() fmt.Printf("%s%s (shown above)\n", prefix.String(), gray(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) { yellow := color.New(color.FgYellow).SprintFunc() line += yellow(" …") } 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 { // Color the ID based on status var idStr string switch node.Status { case types.StatusOpen: idStr = color.New(color.FgWhite).Sprint(node.ID) case types.StatusInProgress: idStr = color.New(color.FgYellow).Sprint(node.ID) case types.StatusBlocked: idStr = color.New(color.FgRed).Sprint(node.ID) case types.StatusClosed: idStr = color.New(color.FgGreen).Sprint(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 { green := color.New(color.FgGreen, color.Bold).SprintFunc() line += " " + green("[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 } func init() { depAddCmd.Flags().StringP("type", "t", "blocks", "Dependency type (blocks|related|parent-child|discovered-from)") // Note: --json flag is defined as a persistent flag in main.go, not here // Note: --json flag is defined as a persistent flag in main.go, not here 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, closed)") depTreeCmd.Flags().String("format", "", "Output format: 'mermaid' for Mermaid.js flowchart") // Note: --json flag is defined as a persistent flag in main.go, not here // Note: --json flag is defined as a persistent flag in main.go, not here depCmd.AddCommand(depAddCmd) depCmd.AddCommand(depRemoveCmd) depCmd.AddCommand(depTreeCmd) depCmd.AddCommand(depCyclesCmd) rootCmd.AddCommand(depCmd) }