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>
1189 lines
35 KiB
Go
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)
|
|
}
|