feat(dep): add --blocks shorthand flag for natural dependency syntax (GH#884)

Adds a --blocks (-b) shorthand flag to bd dep command for natural dependency syntax:
  bd dep bd-xyz --blocks bd-abc  # bd-xyz blocks bd-abc

Equivalent to: bd dep add bd-abc bd-xyz

- Full daemon and direct mode support
- Cycle detection and child-parent anti-pattern checks
- JSON output support

Contributed by: kraitsura
This commit is contained in:
beads/crew/wolf
2026-01-04 16:20:45 -08:00
committed by Steve Yegge
2 changed files with 247 additions and 7 deletions

View File

@@ -43,9 +43,153 @@ func isChildOf(childID, parentID string) bool {
} }
var depCmd = &cobra.Command{ var depCmd = &cobra.Command{
Use: "dep", Use: "dep [issue-id]",
GroupID: "deps", GroupID: "deps",
Short: "Manage dependencies", 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{ var depAddCmd = &cobra.Command{
@@ -1017,10 +1161,12 @@ func ParseExternalRef(ref string) (project, capability string) {
} }
func init() { 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().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("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)") depAddCmd.Flags().String("depends-on", "", "Issue ID that the first issue depends on (alias for --blocked-by)")
// 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().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().IntP("max-depth", "d", 50, "Maximum tree depth to display (safety limit)")
@@ -1029,9 +1175,6 @@ func init() {
depTreeCmd.Flags().String("status", "", "Filter to only show issues with this status (open, in_progress, blocked, deferred, closed)") 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().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)") depTreeCmd.Flags().StringP("type", "t", "", "Filter to only show dependencies of this type (e.g., tracks, blocks, parent-child)")
// 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
depListCmd.Flags().String("direction", "down", "Direction: 'down' (dependencies), 'up' (dependents)") 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)") depListCmd.Flags().StringP("type", "t", "", "Filter by dependency type (e.g., tracks, blocks, parent-child)")

View File

@@ -237,8 +237,8 @@ func TestDepCommandsInit(t *testing.T) {
t.Fatal("depCmd should be initialized") t.Fatal("depCmd should be initialized")
} }
if depCmd.Use != "dep" { if depCmd.Use != "dep [issue-id]" {
t.Errorf("Expected Use='dep', got %q", depCmd.Use) t.Errorf("Expected Use='dep [issue-id]', got %q", depCmd.Use)
} }
if depAddCmd == nil { if depAddCmd == nil {
@@ -279,6 +279,103 @@ func TestDepAddFlagAliases(t *testing.T) {
} }
} }
func TestDepBlocksFlag(t *testing.T) {
// Test that the --blocks flag exists on depCmd
flag := depCmd.Flags().Lookup("blocks")
if flag == nil {
t.Fatal("depCmd should have --blocks flag")
}
// Test shorthand is -b
if flag.Shorthand != "b" {
t.Errorf("Expected shorthand='b', got %q", flag.Shorthand)
}
// Test default value is empty string
if flag.DefValue != "" {
t.Errorf("Expected default blocks='', got %q", flag.DefValue)
}
// Test usage text
if !strings.Contains(flag.Usage, "blocks") {
t.Errorf("Expected flag usage to mention 'blocks', got %q", flag.Usage)
}
}
func TestDepBlocksFlagFunctionality(t *testing.T) {
tmpDir := t.TempDir()
testDB := filepath.Join(tmpDir, ".beads", "beads.db")
s := newTestStore(t, testDB)
ctx := context.Background()
// Create test issues
issues := []*types.Issue{
{
ID: "test-blocks-1",
Title: "Blocker Issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
},
{
ID: "test-blocks-2",
Title: "Blocked Issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
},
}
for _, issue := range issues {
if err := s.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatal(err)
}
}
// Add dependency using the same logic as --blocks flag would:
// "blocker --blocks blocked" means blocked depends on blocker
dep := &types.Dependency{
IssueID: "test-blocks-2", // blocked issue
DependsOnID: "test-blocks-1", // blocker issue
Type: types.DepBlocks,
CreatedAt: time.Now(),
}
if err := s.AddDependency(ctx, dep, "test"); err != nil {
t.Fatalf("AddDependency failed: %v", err)
}
// Verify the blocked issue now depends on the blocker
deps, err := s.GetDependencies(ctx, "test-blocks-2")
if err != nil {
t.Fatalf("GetDependencies failed: %v", err)
}
if len(deps) != 1 {
t.Fatalf("Expected 1 dependency, got %d", len(deps))
}
if deps[0].ID != "test-blocks-1" {
t.Errorf("Expected blocked issue to depend on test-blocks-1, got %s", deps[0].ID)
}
// Verify the blocker has a dependent
dependents, err := s.GetDependents(ctx, "test-blocks-1")
if err != nil {
t.Fatalf("GetDependents failed: %v", err)
}
if len(dependents) != 1 {
t.Fatalf("Expected 1 dependent, got %d", len(dependents))
}
if dependents[0].ID != "test-blocks-2" {
t.Errorf("Expected test-blocks-1 to have dependent test-blocks-2, got %s", dependents[0].ID)
}
}
func TestDepTreeFormatFlag(t *testing.T) { func TestDepTreeFormatFlag(t *testing.T) {
// Test that the --format flag exists on depTreeCmd // Test that the --format flag exists on depTreeCmd
flag := depTreeCmd.Flags().Lookup("format") flag := depTreeCmd.Flags().Lookup("format")