package cmd import ( "flag" "fmt" "os" "path/filepath" "sort" "strings" "git.noodles.cam/claude-skills/marketplace/cli/internal/issue" ) func init() { register(&Command{ Name: "tree", Group: GroupIssue, Args: "[…]", Short: "draw the dependency graph of the local store", Long: `Edges come from the ` + "`depends:`" + ` metadata, which is the authoritative edge list; prose in the body is never walked. Because the graph is slugs all the way down, this works identically for issues that were never pushed anywhere. Downwards is what this draws — what an issue depends on. The other direction is a grep, not a flag: grep -ln 'depends:.*migrate-schema' .kettle/issues/*.md`, Examples: []Example{ {"kettle tree", "every root (nothing depends on it)"}, {"kettle tree wire-sqlc-appclick", "one subtree"}, {"kettle tree --depth 2 --write", "shallow, and saved beside the issues"}, }, Setup: func(fs *flag.FlagSet) func([]string) error { depth := fs.Int("depth", 6, "maximum depth") write := fs.Bool("write", false, "also write /tree-.md") out := storeFlag(fs) return func(args []string) error { root, err := storeRoot(*out) if err != nil { return err } if err := issue.StoreError(root); err != nil { return err } issues, err := issue.LoadAll(root) if err != nil { return err } edges := issue.Graph(issues) roots := args for _, r := range roots { if _, ok := issues[r]; !ok { return Fail("no issue %q in %s", r, root) } } if len(roots) == 0 { dependedOn := map[string]bool{} for _, deps := range edges { for _, d := range deps { dependedOn[d] = true } } for id := range issues { if !dependedOn[id] { roots = append(roots, id) } } if len(roots) == 0 { // every issue is somebody's dependency for id := range issues { roots = append(roots, id) } } sort.Strings(roots) } text := renderTree(roots, issues, edges, *depth) fmt.Print(text) if *write { slug := "all" if len(roots) == 1 { slug = roots[0] } path := filepath.Join(root, "tree-"+slug+".md") if err := os.WriteFile(path, []byte(text), 0o644); err != nil { return err } fmt.Printf("written: %s\n", path) } return nil } }, }) } func renderTree(roots []string, issues map[string]*issue.Issue, edges map[string][]string, depth int) string { var lines []string seen := map[string]bool{} var walk func(id, prefix string, isLast, isRoot bool, level int) walk = func(id, prefix string, isLast, isRoot bool, level int) { connector := "" if !isRoot { connector = "├── " if isLast { connector = "└── " } } lines = append(lines, prefix+connector+treeLabel(id, issues, seen, edges)) if seen[id] || level >= depth { return } seen[id] = true kids := edges[id] childPrefix := prefix if !isRoot { childPrefix = prefix + "│ " if isLast { childPrefix = prefix + " " } } for i, k := range kids { walk(k, childPrefix, i == len(kids)-1, false, level+1) } } for _, r := range roots { if seen[r] { continue // already drawn as somebody's child — one tree, not two } walk(r, "", true, true, 0) lines = append(lines, "") } head := fmt.Sprintf("%d root(s)", len(roots)) if len(roots) == 1 { head = roots[0] } out := fmt.Sprintf("# Dependency tree — %s\n\n```\n%s```\n", head, strings.Join(lines, "\n")) if cycles := issue.FindCycles(edges); len(cycles) > 0 { out += "\n## Cycles\n\n" for _, c := range cycles { out += "- " + strings.Join(c, " -> ") + "\n" } } return out } func treeLabel(id string, issues map[string]*issue.Issue, seen map[string]bool, edges map[string][]string) string { i, ok := issues[id] if !ok { return id + " (not in the store)" } tail := "" if seen[id] && len(edges[id]) > 0 { tail = " (see above)" } typ := i.Type() if typ == "" { typ = "-" } return fmt.Sprintf("%s [%s] %s — %s %s.md%s", id, typ, i.Title, i.State, id, tail) }