adds bug parents with tree-like UI to the bug ls command
15 files changed,  +607, -75
M .gitignore
+2, -0
1@@ -12,3 +12,5 @@ devenv.local.yaml
2 
3 # pre-commit
4 .pre-commit-config.yaml
5+update-flake-lock
6+update-vendor-hash
M README.md
+11, -0
 1@@ -44,6 +44,9 @@ bug list --status closed                 # Show only closed issues
 2 bug list --status all                    # Show all issues
 3 ```
 4 
 5+**Parent/child tree:**
 6+Issues created with `--parent` are nested under their parent with tree markers (`├─`, `└─`). Parents sort newest first, with each parent's children nested below it newest first. Passing `--sort` forces a flat table with that sort order instead.
 7+
 8 ### Create a New Issue
 9 
10 **Using flags:**
11@@ -59,6 +62,14 @@ bug new
12 
13 The editor opens with a template. The first non-empty line becomes the title, and the rest becomes the description. Lines starting with `;;` are ignored.
14 
15+**Linking to a parent issue:**
16+```bash
17+bug new --title "Subtask" --message "Details" --parent abc1234
18+bug new -t "Subtask" -m "Details" -p abc1234
19+```
20+
21+The parent ID accepts a 7-character short ID or a full ID.
22+
23 ### Read/Show an Issue
24 
25 Display an issue with all its metadata and comments:
M SKILL.md
+7, -1
 1@@ -104,7 +104,7 @@ bug agent read abc1234
 2 - Retrieve documented plans from comments
 3 - Check current status before acting
 4 
 5-### bug agent new --title "..." [--message "..." | --stdin | --from-file <path>]
 6+### bug agent new --title "..." [--message "..." | --stdin | --from-file <path>] [--parent <bugID>]
 7 
 8 Create a new bug/issue as the agent.
 9 
10@@ -112,6 +112,9 @@ Create a new bug/issue as the agent.
11 # Using --message flag (simple strings)
12 bug agent new --title "CI Failure" --message "Build failed on commit abc123"
13 
14+# Linking under a parent issue (short or full ID)
15+bug agent new --title "Subtask" --message "Details" --parent abc1234
16+
17 # Using --stdin (pipe content)
18 echo "Build failed on commit abc123" | bug agent new --title "CI Failure" --stdin
19 
20@@ -126,6 +129,9 @@ bug agent new --title "CI Failure" --from-file /tmp/description.md
21   - `--stdin`: Read message from standard input
22   - `--from-file <path>`: Read message from a file
23 
24+**Optional flags:**
25+- `--parent <bugID>`: Link the new issue under a parent issue (short or full ID)
26+
27 **Important:** `--message`, `--stdin`, and `--from-file` are **mutually exclusive**. You must choose exactly one method to provide the message content.
28 
29 **Best practice for multi-line markdown content:** Use `--stdin` or `--from-file` instead of `--message`. Long multi-line strings with markdown formatting often fail when passed as shell command-line arguments due to escaping issues.
M agent_comment_integration_test.go
+7, -7
 1@@ -35,7 +35,7 @@ func TestAgentCommentCommand_WithFlag(t *testing.T) {
 2 	// Create a bug first as user
 3 	title := "Test Bug for Agent Comment"
 4 	message := "Initial bug description"
 5-	if err := runNew(tmpDir, title, message); err != nil {
 6+	if err := runNew(tmpDir, title, message, ""); err != nil {
 7 		t.Fatalf("runNew failed: %v", err)
 8 	}
 9 
10@@ -121,7 +121,7 @@ func TestAgentCommentCommand_WithShortID(t *testing.T) {
11 
12 	// Create a bug
13 	title := "Test Bug for Agent Short ID"
14-	if err := runNew(tmpDir, title, ""); err != nil {
15+	if err := runNew(tmpDir, title, "", ""); err != nil {
16 		t.Fatalf("runNew failed: %v", err)
17 	}
18 
19@@ -204,7 +204,7 @@ func TestAgentCommentCommand_MissingAgentIdentity(t *testing.T) {
20 
21 	// Create a bug as user
22 	title := "Test Bug"
23-	if err := runNew(tmpDir, title, ""); err != nil {
24+	if err := runNew(tmpDir, title, "", ""); err != nil {
25 		t.Fatalf("runNew failed: %v", err)
26 	}
27 
28@@ -260,7 +260,7 @@ func TestAgentCommentCommand_EmptyMessageError(t *testing.T) {
29 
30 	// Create a bug
31 	title := "Test Bug"
32-	if err := runNew(tmpDir, title, ""); err != nil {
33+	if err := runNew(tmpDir, title, "", ""); err != nil {
34 		t.Fatalf("runNew failed: %v", err)
35 	}
36 
37@@ -346,7 +346,7 @@ func TestAgentCommentCommand_MultipleComments(t *testing.T) {
38 
39 	// Create a bug as user
40 	title := "Test Bug for Multiple Agent Comments"
41-	if err := runNew(tmpDir, title, ""); err != nil {
42+	if err := runNew(tmpDir, title, "", ""); err != nil {
43 		t.Fatalf("runNew failed: %v", err)
44 	}
45 
46@@ -433,7 +433,7 @@ func TestAgentCommentCommand_WithMarkdown(t *testing.T) {
47 
48 	// Create a bug
49 	title := "Test Bug for Markdown"
50-	if err := runNew(tmpDir, title, ""); err != nil {
51+	if err := runNew(tmpDir, title, "", ""); err != nil {
52 		t.Fatalf("runNew failed: %v", err)
53 	}
54 
55@@ -514,7 +514,7 @@ func TestAgentCommentCommand_WithStdin(t *testing.T) {
56 
57 	// Create a bug
58 	title := "Test Bug for Stdin Comment"
59-	if err := runNew(tmpDir, title, ""); err != nil {
60+	if err := runNew(tmpDir, title, "", ""); err != nil {
61 		t.Fatalf("runNew failed: %v", err)
62 	}
63 
M agent_edit_integration_test.go
+5, -5
 1@@ -35,7 +35,7 @@ func TestAgentEditCommand_EditIssue(t *testing.T) {
 2 	}
 3 
 4 	// Create a bug as agent
 5-	if err := runAgentNew(tmpDir, "Original Title", "Original description", "", false); err != nil {
 6+	if err := runAgentNew(tmpDir, "Original Title", "Original description", "", "", false); err != nil {
 7 		t.Fatalf("runAgentNew failed: %v", err)
 8 	}
 9 
10@@ -114,7 +114,7 @@ func TestAgentEditCommand_EditComment(t *testing.T) {
11 	}
12 
13 	// Create a bug as agent
14-	if err := runAgentNew(tmpDir, "Test Bug", "Description", "", false); err != nil {
15+	if err := runAgentNew(tmpDir, "Test Bug", "Description", "", "", false); err != nil {
16 		t.Fatalf("runAgentNew failed: %v", err)
17 	}
18 
19@@ -211,7 +211,7 @@ func TestAgentEditCommand_RequiresMessage(t *testing.T) {
20 	}
21 
22 	// Create a bug as agent
23-	if err := runAgentNew(tmpDir, "Test Bug", "Description", "", false); err != nil {
24+	if err := runAgentNew(tmpDir, "Test Bug", "Description", "", "", false); err != nil {
25 		t.Fatalf("runAgentNew failed: %v", err)
26 	}
27 
28@@ -288,7 +288,7 @@ func TestAgentEditCommand_RequiresAtLeastOneField(t *testing.T) {
29 	}
30 
31 	// Create a bug as agent
32-	if err := runAgentNew(tmpDir, "Test Bug", "Description", "", false); err != nil {
33+	if err := runAgentNew(tmpDir, "Test Bug", "Description", "", "", false); err != nil {
34 		t.Fatalf("runAgentNew failed: %v", err)
35 	}
36 
37@@ -348,7 +348,7 @@ func TestAgentEditCommand_OnlyTitle(t *testing.T) {
38 	// Create a bug as agent
39 	originalTitle := "Original Title"
40 	originalDesc := "Original description"
41-	if err := runAgentNew(tmpDir, originalTitle, originalDesc, "", false); err != nil {
42+	if err := runAgentNew(tmpDir, originalTitle, originalDesc, "", "", false); err != nil {
43 		t.Fatalf("runAgentNew failed: %v", err)
44 	}
45 
M agent_integration_test.go
+4, -4
 1@@ -34,7 +34,7 @@ func TestAgentNewCommand_WithFlags(t *testing.T) {
 2 	// Create a bug as agent using flags
 3 	title := "Agent Created Bug"
 4 	message := "This bug was created by an automated agent"
 5-	if err := runAgentNew(tmpDir, title, message, "", false); err != nil {
 6+	if err := runAgentNew(tmpDir, title, message, "", "", false); err != nil {
 7 		t.Fatalf("runAgentNew failed: %v", err)
 8 	}
 9 
10@@ -90,7 +90,7 @@ func TestAgentNewCommand_MissingAgentIdentity(t *testing.T) {
11 	}
12 
13 	// Try to create a bug as agent (should fail)
14-	err := runAgentNew(tmpDir, "Title", "Message", "", false)
15+	err := runAgentNew(tmpDir, "Title", "Message", "", "", false)
16 	if err == nil {
17 		t.Error("expected error when agent identity not found, got nil")
18 	}
19@@ -120,7 +120,7 @@ func TestAgentNewCommand_EmptyTitleError(t *testing.T) {
20 	}
21 
22 	// Try with empty title
23-	err := runAgentNew(tmpDir, "   ", "Message", "", false)
24+	err := runAgentNew(tmpDir, "   ", "Message", "", "", false)
25 	if err == nil {
26 		t.Error("expected error for empty title, got nil")
27 	}
28@@ -150,7 +150,7 @@ func TestAgentNewCommand_EmptyMessageError(t *testing.T) {
29 	}
30 
31 	// Try with empty message
32-	err := runAgentNew(tmpDir, "Title", "   ", "", false)
33+	err := runAgentNew(tmpDir, "Title", "   ", "", "", false)
34 	if err == nil {
35 		t.Error("expected error for empty message, got nil")
36 	}
M comment_integration_test.go
+4, -4
 1@@ -31,7 +31,7 @@ func TestCommentCommand_WithFlag(t *testing.T) {
 2 	// Create a bug first
 3 	title := "Test Bug for Comment"
 4 	message := "Initial bug description"
 5-	if err := runNew(tmpDir, title, message); err != nil {
 6+	if err := runNew(tmpDir, title, message, ""); err != nil {
 7 		t.Fatalf("runNew failed: %v", err)
 8 	}
 9 
10@@ -110,7 +110,7 @@ func TestCommentCommand_WithShortID(t *testing.T) {
11 
12 	// Create a bug
13 	title := "Test Bug for Short ID"
14-	if err := runNew(tmpDir, title, ""); err != nil {
15+	if err := runNew(tmpDir, title, "", ""); err != nil {
16 		t.Fatalf("runNew failed: %v", err)
17 	}
18 
19@@ -214,7 +214,7 @@ func TestCommentCommand_EmptyMessageError(t *testing.T) {
20 
21 	// Create a bug first
22 	title := "Test Bug"
23-	if err := runNew(tmpDir, title, ""); err != nil {
24+	if err := runNew(tmpDir, title, "", ""); err != nil {
25 		t.Fatalf("runNew failed: %v", err)
26 	}
27 
28@@ -296,7 +296,7 @@ func TestCommentCommand_MultipleComments(t *testing.T) {
29 
30 	// Create a bug
31 	title := "Test Bug for Multiple Comments"
32-	if err := runNew(tmpDir, title, ""); err != nil {
33+	if err := runNew(tmpDir, title, "", ""); err != nil {
34 		t.Fatalf("runNew failed: %v", err)
35 	}
36 
M edit_integration_test.go
+5, -5
 1@@ -31,7 +31,7 @@ func TestEditCommand_EditIssueTitle(t *testing.T) {
 2 	// Create a bug
 3 	title := "Original Title"
 4 	message := "Original description"
 5-	if err := runNew(tmpDir, title, message); err != nil {
 6+	if err := runNew(tmpDir, title, message, ""); err != nil {
 7 		t.Fatalf("runNew failed: %v", err)
 8 	}
 9 
10@@ -106,7 +106,7 @@ func TestEditCommand_EditIssueDescription(t *testing.T) {
11 	// Create a bug
12 	title := "Original Title"
13 	message := "Original description"
14-	if err := runNew(tmpDir, title, message); err != nil {
15+	if err := runNew(tmpDir, title, message, ""); err != nil {
16 		t.Fatalf("runNew failed: %v", err)
17 	}
18 
19@@ -179,7 +179,7 @@ func TestEditCommand_EditIssueBoth(t *testing.T) {
20 	}
21 
22 	// Create a bug
23-	if err := runNew(tmpDir, "Original Title", "Original description"); err != nil {
24+	if err := runNew(tmpDir, "Original Title", "Original description", ""); err != nil {
25 		t.Fatalf("runNew failed: %v", err)
26 	}
27 
28@@ -248,7 +248,7 @@ func TestEditCommand_EditComment(t *testing.T) {
29 	}
30 
31 	// Create a bug
32-	if err := runNew(tmpDir, "Test Bug", "Description"); err != nil {
33+	if err := runNew(tmpDir, "Test Bug", "Description", ""); err != nil {
34 		t.Fatalf("runNew failed: %v", err)
35 	}
36 
37@@ -366,7 +366,7 @@ func TestEditCommand_WithShortID(t *testing.T) {
38 	}
39 
40 	// Create a bug
41-	if err := runNew(tmpDir, "Original Title", "Original description"); err != nil {
42+	if err := runNew(tmpDir, "Original Title", "Original description", ""); err != nil {
43 		t.Fatalf("runNew failed: %v", err)
44 	}
45 
M main.go
+316, -44
  1@@ -13,6 +13,7 @@ import (
  2 
  3 	"github.com/charmbracelet/lipgloss"
  4 	"github.com/dustin/go-humanize"
  5+	"github.com/git-bug/git-bug/cache"
  6 	"github.com/git-bug/git-bug/entities/bug"
  7 	"github.com/git-bug/git-bug/entities/identity"
  8 	"github.com/git-bug/git-bug/entity"
  9@@ -28,6 +29,7 @@ type BugIssue struct {
 10 	Labels    []string
 11 	CreatedAt time.Time
 12 	Status    string
 13+	ParentID  string // Full ID of parent bug
 14 }
 15 
 16 // LoadBugs loads all issues from the git-bug repository
 17@@ -36,6 +38,19 @@ func LoadBugs(repoPath string) ([]BugIssue, error) {
 18 	if err != nil {
 19 		return nil, fmt.Errorf("failed to open repository: %w", err)
 20 	}
 21+	defer repo.Close()
 22+
 23+	// Create cache to access bugs with metadata support
 24+	// Use NewRepoCache and wait for build events to ensure cache is fully built
 25+	cacheInstance, events := cache.NewRepoCache(repo)
 26+	defer cacheInstance.Close()
 27+
 28+	// Wait for cache to be fully built
 29+	for ev := range events {
 30+		if ev.Err != nil {
 31+			return nil, fmt.Errorf("failed to build bug cache: %w", ev.Err)
 32+		}
 33+	}
 34 
 35 	// Generate short IDs for all issues
 36 	gen := NewShortIDGenerator(repoPath)
 37@@ -46,15 +61,20 @@ func LoadBugs(repoPath string) ([]BugIssue, error) {
 38 
 39 	var issues []BugIssue
 40 
 41-	for streamedBug := range bug.ReadAll(repo) {
 42-		if streamedBug.Err != nil {
 43+	// Use cache to list bugs with metadata
 44+	bugCache := cacheInstance.Bugs()
 45+	ids := bugCache.AllIds()
 46+
 47+	for _, id := range ids {
 48+		// Use Resolve to get BugCache which has Snapshot() with GetCreateMetadata
 49+		b, err := bugCache.Resolve(id)
 50+		if err != nil {
 51 			continue // Skip errors, process what we can
 52 		}
 53 
 54-		b := streamedBug.Entity
 55-		snap := b.Compile()
 56+		snap := b.Snapshot()
 57 
 58-		fullID := b.Id().String()
 59+		fullID := id.String()
 60 		shortID, _ := shortIDMap.GetShortID(fullID)
 61 
 62 		labels := make([]string, len(snap.Labels))
 63@@ -62,6 +82,12 @@ func LoadBugs(repoPath string) ([]BugIssue, error) {
 64 			labels[i] = string(label)
 65 		}
 66 
 67+		// Read parent metadata from CreateMetadata
 68+		parentID := ""
 69+		if parent, ok := snap.GetCreateMetadata("parent"); ok {
 70+			parentID = parent
 71+		}
 72+
 73 		issues = append(issues, BugIssue{
 74 			FullID:    fullID,
 75 			ShortID:   shortID,
 76@@ -69,6 +95,7 @@ func LoadBugs(repoPath string) ([]BugIssue, error) {
 77 			Labels:    labels,
 78 			CreatedAt: snap.CreateTime,
 79 			Status:    snap.Status.String(),
 80+			ParentID:  parentID,
 81 		})
 82 	}
 83 
 84@@ -237,7 +264,7 @@ func ApplySort(issues []BugIssue, spec *SortSpec) {
 85 		case "id":
 86 			less = issues[i].FullID < issues[j].FullID
 87 		case "age":
 88-			less = issues[i].CreatedAt.After(issues[j].CreatedAt) // Newer first
 89+			less = issues[i].CreatedAt.Before(issues[j].CreatedAt) // Older first (ascending)
 90 		}
 91 
 92 		if spec.Direction == "desc" {
 93@@ -378,6 +405,185 @@ func printTable(issues []BugIssue) error {
 94 	return nil
 95 }
 96 
 97+// TreeNode represents a node in the issue tree
 98+type TreeNode struct {
 99+	Issue    BugIssue
100+	Children []*TreeNode
101+}
102+
103+// buildIssueTree builds a tree structure from flat issues
104+// Returns root nodes (issues with no parent or parent not in the list)
105+func buildIssueTree(issues []BugIssue) []*TreeNode {
106+	// Map of full ID to TreeNode
107+	nodeMap := make(map[string]*TreeNode)
108+
109+	// Create nodes for all issues
110+	for i := range issues {
111+		nodeMap[issues[i].FullID] = &TreeNode{Issue: issues[i]}
112+	}
113+
114+	// Link children to parents
115+	var roots []*TreeNode
116+	for _, node := range nodeMap {
117+		if node.Issue.ParentID != "" {
118+			if parent, ok := nodeMap[node.Issue.ParentID]; ok {
119+				parent.Children = append(parent.Children, node)
120+			} else {
121+				// Parent not in list, treat as root
122+				roots = append(roots, node)
123+			}
124+		} else {
125+			// No parent, treat as root
126+			roots = append(roots, node)
127+		}
128+	}
129+
130+	// Sort roots by age descending (newest first)
131+	sort.Slice(roots, func(i, j int) bool {
132+		return roots[i].Issue.CreatedAt.After(roots[j].Issue.CreatedAt)
133+	})
134+
135+	// Sort children of each node by age descending
136+	var sortChildren func([]*TreeNode)
137+	sortChildren = func(nodes []*TreeNode) {
138+		sort.Slice(nodes, func(i, j int) bool {
139+			return nodes[i].Issue.CreatedAt.After(nodes[j].Issue.CreatedAt)
140+		})
141+		for _, n := range nodes {
142+			if len(n.Children) > 0 {
143+				sortChildren(n.Children)
144+			}
145+		}
146+	}
147+	sortChildren(roots)
148+
149+	return roots
150+}
151+
152+// printTreeTable prints issues as a tree with indentation markers
153+func printTreeTable(roots []*TreeNode) error {
154+	// First pass: calculate column widths
155+	const minColSpacing = 2
156+	const maxSummaryLen = 70
157+
158+	idWidth := len("ID")
159+	summaryWidth := len("Summary")
160+	labelsWidth := len("Labels")
161+	ageWidth := len("Age")
162+
163+	type rowData struct {
164+		id      string
165+		summary string
166+		labels  string
167+		age     string
168+		prefix  string // Tree prefix for indentation
169+	}
170+
171+	var allRows []rowData
172+
173+	// Recursive function to collect all rows with prefixes.
174+	// Roots (isRoot) have no prefix; their children get tree markers,
175+	// and deeper levels carry vertical guides from their ancestors.
176+	var collectRows func(nodes []*TreeNode, prefix string, isRoot bool)
177+	collectRows = func(nodes []*TreeNode, prefix string, isRoot bool) {
178+		for i, node := range nodes {
179+			isLast := i == len(nodes)-1
180+			var nodePrefix string
181+			if isRoot {
182+				nodePrefix = ""
183+			} else if isLast {
184+				nodePrefix = prefix + "└─ "
185+			} else {
186+				nodePrefix = prefix + "├─ "
187+			}
188+
189+			// Format ID (with ANSI codes)
190+			id := formatID(node.Issue.FullID, node.Issue.ShortID)
191+			idVisualLen := len(nodePrefix) + len(stripANSI(id))
192+			if idVisualLen > idWidth {
193+				idWidth = idVisualLen
194+			}
195+
196+			// Format summary (truncate at 70 chars)
197+			summary := node.Issue.Title
198+			if len(summary) > maxSummaryLen {
199+				summary = summary[:maxSummaryLen-3] + "..."
200+			}
201+			if len(summary) > summaryWidth {
202+				summaryWidth = len(summary)
203+			}
204+
205+			// Format labels
206+			labels := strings.Join(node.Issue.Labels, ", ")
207+			if labels == "" {
208+				labels = "-"
209+			}
210+			if len(labels) > labelsWidth {
211+				labelsWidth = len(labels)
212+			}
213+
214+			// Format age
215+			age := humanize.Time(node.Issue.CreatedAt)
216+			if len(age) > ageWidth {
217+				ageWidth = len(age)
218+			}
219+
220+			allRows = append(allRows, rowData{
221+				id:      id,
222+				summary: summary,
223+				labels:  labels,
224+				age:     age,
225+				prefix:  nodePrefix,
226+			})
227+
228+			// Recurse into children
229+			if len(node.Children) > 0 {
230+				var childPrefix string
231+				if isRoot {
232+					childPrefix = ""
233+				} else if isLast {
234+					childPrefix = prefix + "   "
235+				} else {
236+					childPrefix = prefix + "│  "
237+				}
238+				collectRows(node.Children, childPrefix, false)
239+			}
240+		}
241+	}
242+
243+	collectRows(roots, "", true)
244+
245+	// Print header
246+	fmt.Printf("%s%s%s%s%s%s%s\n",
247+		padRight("ID", idWidth),
248+		strings.Repeat(" ", minColSpacing),
249+		padRight("Summary", summaryWidth),
250+		strings.Repeat(" ", minColSpacing),
251+		padRight("Labels", labelsWidth),
252+		strings.Repeat(" ", minColSpacing),
253+		padRight("Age", ageWidth),
254+	)
255+
256+	// Print separator
257+	totalWidth := idWidth + minColSpacing + summaryWidth + minColSpacing + labelsWidth + minColSpacing + ageWidth
258+	fmt.Println(strings.Repeat("-", totalWidth))
259+
260+	// Print rows
261+	for _, row := range allRows {
262+		fmt.Printf("%s%s%s%s%s%s%s\n",
263+			padRight(row.prefix+row.id, idWidth),
264+			strings.Repeat(" ", minColSpacing),
265+			padRight(row.summary, summaryWidth),
266+			strings.Repeat(" ", minColSpacing),
267+			padRight(row.labels, labelsWidth),
268+			strings.Repeat(" ", minColSpacing),
269+			padRight(row.age, ageWidth),
270+		)
271+	}
272+
273+	return nil
274+}
275+
276 // padRight pads a string to the specified width, accounting for ANSI codes
277 func padRight(s string, width int) string {
278 	visualLen := len(stripANSI(s))
279@@ -1152,7 +1358,7 @@ func parseCommentContent(content string) (string, error) {
280 
281 // runNew creates a new bug/issue in the repository
282 // If title is empty, opens editor for interactive input
283-func runNew(repoPath, title, message string) error {
284+func runNew(repoPath, title, message, parentID string) error {
285 	// If no title provided via flags, open editor
286 	if title == "" && message == "" {
287 		content, err := launchEditor("")
288@@ -1194,16 +1400,29 @@ func runNew(repoPath, title, message string) error {
289 		return fmt.Errorf("failed to get user identity: %w\n\nRun 'bug init' first to set up identities", err)
290 	}
291 
292-	// Create the bug
293-	unixTime := time.Now().Unix()
294-	newBug, _, err := bug.Create(author, unixTime, title, message, nil, nil)
295+	// Prepare metadata for parent if provided
296+	var metadata map[string]string
297+	if parentID != "" {
298+		// Resolve parent ID to full ID
299+		parentBug, err := resolveBugID(repoPath, parentID)
300+		if err != nil {
301+			return fmt.Errorf("failed to resolve parent bug ID %q: %w", parentID, err)
302+		}
303+		metadata = map[string]string{"parent": parentBug.Id().String()}
304+	}
305+
306+	// Create cache to use NewRaw which supports metadata
307+	cacheInstance, err := cache.NewRepoCacheNoEvents(repo)
308 	if err != nil {
309-		return fmt.Errorf("failed to create bug: %w", err)
310+		return fmt.Errorf("failed to create cache: %w", err)
311 	}
312+	defer cacheInstance.Close()
313 
314-	// Commit the bug to the repository
315-	if err := newBug.Commit(repo); err != nil {
316-		return fmt.Errorf("failed to commit bug: %w", err)
317+	// Create the bug with metadata support
318+	unixTime := time.Now().Unix()
319+	newBug, _, err := cacheInstance.Bugs().NewRaw(author, unixTime, title, message, nil, metadata)
320+	if err != nil {
321+		return fmt.Errorf("failed to create bug: %w", err)
322 	}
323 
324 	// Get the bug ID and display first 7 characters (like bug ls table)
325@@ -1321,21 +1540,23 @@ func runInitWithReader(repoPath string, reader io.Reader) error {
326 }
327 
328 var (
329-	repoPath        string
330-	filterFlag      string
331-	sortFlag        string
332-	newTitle        string
333-	newMessage      string
334-	agentTitle      string
335-	agentMessage    string
336-	commentMessage  string
337-	commentBugID    string
338-	agentCommentMsg string
339-	editTitle       string
340-	editMessage     string
341-	agentEditTitle  string
342-	agentEditMsg    string
343-	statusFlag      string
344+	repoPath         string
345+	filterFlag       string
346+	sortFlag         string
347+	newTitle         string
348+	newMessage       string
349+	newParentID      string
350+	agentTitle       string
351+	agentMessage     string
352+	agentNewParentID string
353+	commentMessage   string
354+	commentBugID     string
355+	agentCommentMsg  string
356+	editTitle        string
357+	editMessage      string
358+	agentEditTitle   string
359+	agentEditMsg     string
360+	statusFlag       string
361 
362 	// Agent command stdin/from-file flags
363 	agentNewStdinFlag     bool
364@@ -1359,6 +1580,11 @@ var listCmd = &cobra.Command{
365 	Short:   "List all issues in the repository",
366 	Long: `Display a table of all git-bug issues with their ID, summary, labels, and age.
367 
368+Issues linked to a parent via --parent are nested under it with tree
369+markers (├─, └─). Parents sort newest first, with each parent's
370+children nested below it newest first. Passing --sort forces a flat
371+table with that sort order instead.
372+
373 Filtering:
374   --filter label:bug         Show only issues with label "bug"
375   --filter age:<10d          Show only issues newer than 10 days
376@@ -1407,18 +1633,42 @@ Status Filtering:
377 			issues = filtered
378 		}
379 
380-		// Parse and apply sort
381+		if len(issues) == 0 {
382+			fmt.Println("No Issues")
383+			return nil
384+		}
385+
386+		// Check if any issues have parent relationships
387+		hasParentRelations := false
388+		for _, issue := range issues {
389+			if issue.ParentID != "" {
390+				hasParentRelations = true
391+				break
392+			}
393+		}
394+
395+		// Parse sort flag
396 		sortSpec, err := ParseSort(sortFlag)
397 		if err != nil {
398 			return err
399 		}
400-		ApplySort(issues, sortSpec)
401 
402-		if len(issues) == 0 {
403-			fmt.Println("No Issues")
404-			return nil
405+		// If user specified a sort flag, use flat table with that sort
406+		// Otherwise, if there are parent relations, use tree display
407+		// Otherwise, use flat table with default sort (age:desc)
408+		if sortFlag != "" {
409+			ApplySort(issues, sortSpec)
410+			return printTable(issues)
411+		}
412+
413+		if hasParentRelations {
414+			// Build and print tree
415+			roots := buildIssueTree(issues)
416+			return printTreeTable(roots)
417 		}
418 
419+		// Default: flat table with age:desc (already the default in ApplySort)
420+		ApplySort(issues, sortSpec)
421 		return printTable(issues)
422 	},
423 }
424@@ -1464,13 +1714,17 @@ You can provide the title and message via flags:
425   bug new --title "Bug title" --message "Detailed description"
426   bug new -t "Bug title" -m "Detailed description"
427 
428+To link the new issue under a parent issue:
429+  bug new --title "Subtask" --message "Details" --parent abc1234
430+  bug new -t "Subtask" -m "Details" -p abc1234
431+
432 Or launch an editor to write them interactively:
433   bug new
434 
435 The editor will open with a template. The first non-empty line becomes the title,
436 and the rest becomes the description.`,
437 	RunE: func(cmd *cobra.Command, args []string) error {
438-		return runNew(repoPath, newTitle, newMessage)
439+		return runNew(repoPath, newTitle, newMessage, newParentID)
440 	},
441 }
442 
443@@ -1494,11 +1748,14 @@ var agentNewCmd = &cobra.Command{
444 This command is non-interactive and requires both --title and --message flags.
445 It uses the agent identity created during 'bug init'.
446 
447+To link the new issue under a parent issue:
448+  bug agent new --title "Subtask" --message "Details" --parent abc1234
449+
450 Example:
451   bug agent new --title "CI Failure" --message "Build failed on commit abc123"
452   bug agent new -t "Bug found" -m "Automated scan detected issue"`,
453 	RunE: func(cmd *cobra.Command, args []string) error {
454-		return runAgentNew(repoPath, agentTitle, agentMessage, agentNewFromFile, agentNewStdinFlag)
455+		return runAgentNew(repoPath, agentTitle, agentMessage, agentNewParentID, agentNewFromFile, agentNewStdinFlag)
456 	},
457 }
458 
459@@ -1745,10 +2002,12 @@ func init() {
460 
461 	newCmd.Flags().StringVarP(&newTitle, "title", "t", "", "issue title")
462 	newCmd.Flags().StringVarP(&newMessage, "message", "m", "", "issue description/message")
463+	newCmd.Flags().StringVarP(&newParentID, "parent", "p", "", "parent bug ID (7-char short or full ID)")
464 
465 	// Agent new command flags - both required, no shorthand to force explicit usage
466 	agentNewCmd.Flags().StringVar(&agentTitle, "title", "", "issue title (required)")
467 	agentNewCmd.Flags().StringVar(&agentMessage, "message", "", "issue description")
468+	agentNewCmd.Flags().StringVar(&agentNewParentID, "parent", "", "parent bug ID (7-char short or full ID)")
469 	agentNewCmd.Flags().BoolVar(&agentNewStdinFlag, "stdin", false, "read message from stdin")
470 	agentNewCmd.Flags().StringVar(&agentNewFromFile, "from-file", "", "read message from file")
471 	agentNewCmd.MarkFlagRequired("title")
472@@ -1859,7 +2118,7 @@ Run 'bug agent <command> --help' for more information.`)
473 
474 // runAgentNew creates a new bug/issue using the agent identity
475 // Both title and message are required - this is non-interactive
476-func runAgentNew(repoPath, title, message string, fromFile string, useStdin bool) error {
477+func runAgentNew(repoPath, title, message, parentID string, fromFile string, useStdin bool) error {
478 	// Validate title (still required)
479 	if strings.TrimSpace(title) == "" {
480 		return fmt.Errorf("title cannot be empty")
481@@ -1884,16 +2143,29 @@ func runAgentNew(repoPath, title, message string, fromFile string, useStdin bool
482 		return err
483 	}
484 
485-	// Create the bug
486-	unixTime := time.Now().Unix()
487-	newBug, _, err := bug.Create(author, unixTime, title, messageContent, nil, nil)
488+	// Prepare metadata for parent if provided
489+	var metadata map[string]string
490+	if parentID != "" {
491+		// Resolve parent ID to full ID
492+		parentBug, err := resolveBugID(repoPath, parentID)
493+		if err != nil {
494+			return fmt.Errorf("failed to resolve parent bug ID %q: %w", parentID, err)
495+		}
496+		metadata = map[string]string{"parent": parentBug.Id().String()}
497+	}
498+
499+	// Create cache to use NewRaw which supports metadata
500+	cacheInstance, err := cache.NewRepoCacheNoEvents(repo)
501 	if err != nil {
502-		return fmt.Errorf("failed to create bug: %w", err)
503+		return fmt.Errorf("failed to create cache: %w", err)
504 	}
505+	defer cacheInstance.Close()
506 
507-	// Commit the bug to persist it
508-	if err := newBug.Commit(repo); err != nil {
509-		return fmt.Errorf("failed to commit bug: %w", err)
510+	// Create the bug with metadata support
511+	unixTime := time.Now().Unix()
512+	newBug, _, err := cacheInstance.Bugs().NewRaw(author, unixTime, title, messageContent, nil, metadata)
513+	if err != nil {
514+		return fmt.Errorf("failed to create bug: %w", err)
515 	}
516 
517 	// Get the bug ID and display first 7 characters (like bug ls table)
M new_integration_test.go
+4, -4
 1@@ -30,7 +30,7 @@ func TestNewCommand_WithFlags(t *testing.T) {
 2 	// Create a bug using flags
 3 	title := "Test Bug Title"
 4 	message := "Test bug description"
 5-	if err := runNew(tmpDir, title, message); err != nil {
 6+	if err := runNew(tmpDir, title, message, ""); err != nil {
 7 		t.Fatalf("runNew failed: %v", err)
 8 	}
 9 
10@@ -84,7 +84,7 @@ func TestNewCommand_TitleOnly(t *testing.T) {
11 
12 	// Create a bug with title only
13 	title := "Title Only Bug"
14-	if err := runNew(tmpDir, title, ""); err != nil {
15+	if err := runNew(tmpDir, title, "", ""); err != nil {
16 		t.Fatalf("runNew failed: %v", err)
17 	}
18 
19@@ -130,7 +130,7 @@ func TestNewCommand_NoIdentityError(t *testing.T) {
20 	}
21 
22 	// Try to create a bug without identity
23-	err := runNew(tmpDir, "Test Title", "Test message")
24+	err := runNew(tmpDir, "Test Title", "Test message", "")
25 	if err == nil {
26 		t.Error("expected error when no identity is set, got nil")
27 	}
28@@ -158,7 +158,7 @@ func TestNewCommand_EmptyTitleError(t *testing.T) {
29 
30 	// Try to create a bug with empty title (this would normally trigger editor)
31 	// For direct testing, we test the validation after editor parsing
32-	err := runNew(tmpDir, "   ", "") // Whitespace-only title
33+	err := runNew(tmpDir, "   ", "", "") // Whitespace-only title
34 	if err == nil {
35 		t.Error("expected error for empty title, got nil")
36 	}
A parent_integration_test.go
+103, -0
  1@@ -0,0 +1,103 @@
  2+//go:build integration
  3+
  4+package main
  5+
  6+import (
  7+	"os/exec"
  8+	"strings"
  9+	"testing"
 10+)
 11+
 12+// setupParentTestRepo initializes a git repo with user and agent identities
 13+func setupParentTestRepo(t *testing.T) string {
 14+	t.Helper()
 15+	tmpDir := t.TempDir()
 16+
 17+	initCmd := exec.Command("git", "init")
 18+	initCmd.Dir = tmpDir
 19+	if err := initCmd.Run(); err != nil {
 20+		t.Fatalf("failed to init git repo: %v", err)
 21+	}
 22+
 23+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
 24+		t.Fatalf("failed to create user identity: %v", err)
 25+	}
 26+	if err := createIdentity(tmpDir, "agent", "", false); err != nil {
 27+		t.Fatalf("failed to create agent identity: %v", err)
 28+	}
 29+
 30+	return tmpDir
 31+}
 32+
 33+// findIssueByTitle returns the BugIssue with the given title
 34+func findIssueByTitle(t *testing.T, repoPath, title string) BugIssue {
 35+	t.Helper()
 36+	issues, err := LoadBugs(repoPath)
 37+	if err != nil {
 38+		t.Fatalf("LoadBugs failed: %v", err)
 39+	}
 40+	for _, issue := range issues {
 41+		if issue.Title == title {
 42+			return issue
 43+		}
 44+	}
 45+	t.Fatalf("bug with title %q not found", title)
 46+	return BugIssue{}
 47+}
 48+
 49+// TestNewCommand_WithParent creates a child bug linked to a parent via short ID
 50+func TestNewCommand_WithParent(t *testing.T) {
 51+	tmpDir := setupParentTestRepo(t)
 52+
 53+	parentTitle := "Parent Bug"
 54+	if err := runNew(tmpDir, parentTitle, "Parent description", ""); err != nil {
 55+		t.Fatalf("runNew parent failed: %v", err)
 56+	}
 57+	parent := findIssueByTitle(t, tmpDir, parentTitle)
 58+	if parent.ShortID == "" {
 59+		t.Fatal("parent has empty short ID")
 60+	}
 61+
 62+	childTitle := "Child Bug"
 63+	if err := runNew(tmpDir, childTitle, "Child description", parent.ShortID); err != nil {
 64+		t.Fatalf("runNew child failed: %v", err)
 65+	}
 66+
 67+	child := findIssueByTitle(t, tmpDir, childTitle)
 68+	if child.ParentID != parent.FullID {
 69+		t.Errorf("child ParentID = %q, want parent full ID %q", child.ParentID, parent.FullID)
 70+	}
 71+}
 72+
 73+// TestAgentNewCommand_WithParent creates a child bug as agent linked to a parent
 74+func TestAgentNewCommand_WithParent(t *testing.T) {
 75+	tmpDir := setupParentTestRepo(t)
 76+
 77+	parentTitle := "Agent Parent Bug"
 78+	if err := runAgentNew(tmpDir, parentTitle, "Parent description", "", "", false); err != nil {
 79+		t.Fatalf("runAgentNew parent failed: %v", err)
 80+	}
 81+	parent := findIssueByTitle(t, tmpDir, parentTitle)
 82+
 83+	childTitle := "Agent Child Bug"
 84+	if err := runAgentNew(tmpDir, childTitle, "Child description", parent.ShortID, "", false); err != nil {
 85+		t.Fatalf("runAgentNew child failed: %v", err)
 86+	}
 87+
 88+	child := findIssueByTitle(t, tmpDir, childTitle)
 89+	if child.ParentID != parent.FullID {
 90+		t.Errorf("child ParentID = %q, want parent full ID %q", child.ParentID, parent.FullID)
 91+	}
 92+}
 93+
 94+// TestNewCommand_InvalidParentError tests error for unknown parent ID
 95+func TestNewCommand_InvalidParentError(t *testing.T) {
 96+	tmpDir := setupParentTestRepo(t)
 97+
 98+	err := runNew(tmpDir, "Child Bug", "Child description", "deadbee")
 99+	if err == nil {
100+		t.Error("expected error for unknown parent ID, got nil")
101+	} else if !strings.Contains(err.Error(), "deadbee") {
102+		t.Errorf("expected error to mention parent ID, got: %v", err)
103+	}
104+}
M read_integration_test.go
+1, -1
1@@ -34,7 +34,7 @@ func TestReadCommand_Integration(t *testing.T) {
2 	// Create a bug with a title and description
3 	title := "Test Issue for Read Command"
4 	message := "This is a test description for the read command."
5-	if err := runNew(tmpDir, title, message); err != nil {
6+	if err := runNew(tmpDir, title, message, ""); err != nil {
7 		t.Fatalf("Failed to create bug: %v", err)
8 	}
9 
A tree_test.go
+138, -0
  1@@ -0,0 +1,138 @@
  2+package main
  3+
  4+import (
  5+	"io"
  6+	"os"
  7+	"strings"
  8+	"testing"
  9+	"time"
 10+)
 11+
 12+func TestApplySort_AgeDescNewestFirst(t *testing.T) {
 13+	now := time.Now()
 14+	issues := []BugIssue{
 15+		{FullID: "older", CreatedAt: now.Add(-2 * time.Hour)},
 16+		{FullID: "newer", CreatedAt: now.Add(-1 * time.Hour)},
 17+	}
 18+
 19+	ApplySort(issues, &SortSpec{Field: "age", Direction: "desc"})
 20+
 21+	if issues[0].FullID != "newer" {
 22+		t.Errorf("age:desc first = %q, want %q (newest first)", issues[0].FullID, "newer")
 23+	}
 24+}
 25+
 26+func TestApplySort_AgeAscOldestFirst(t *testing.T) {
 27+	now := time.Now()
 28+	issues := []BugIssue{
 29+		{FullID: "newer", CreatedAt: now.Add(-1 * time.Hour)},
 30+		{FullID: "older", CreatedAt: now.Add(-2 * time.Hour)},
 31+	}
 32+
 33+	ApplySort(issues, &SortSpec{Field: "age", Direction: "asc"})
 34+
 35+	if issues[0].FullID != "older" {
 36+		t.Errorf("age:asc first = %q, want %q (oldest first)", issues[0].FullID, "older")
 37+	}
 38+}
 39+
 40+func TestBuildIssueTree_NestsChildrenUnderParent(t *testing.T) {
 41+	now := time.Now()
 42+	issues := []BugIssue{
 43+		{FullID: "parent-full-id", ShortID: "parent", Title: "Parent", CreatedAt: now.Add(-2 * time.Hour)},
 44+		{FullID: "child1-full-id", ShortID: "child1", Title: "Child 1", CreatedAt: now.Add(-1 * time.Hour), ParentID: "parent-full-id"},
 45+		{FullID: "child2-full-id", ShortID: "child2", Title: "Child 2", CreatedAt: now.Add(-30 * time.Minute), ParentID: "parent-full-id"},
 46+	}
 47+
 48+	roots := buildIssueTree(issues)
 49+
 50+	if len(roots) != 1 {
 51+		t.Fatalf("roots = %d, want 1", len(roots))
 52+	}
 53+	if len(roots[0].Children) != 2 {
 54+		t.Fatalf("children = %d, want 2", len(roots[0].Children))
 55+	}
 56+}
 57+
 58+func TestBuildIssueTree_SortsRootsAndChildrenNewestFirst(t *testing.T) {
 59+	now := time.Now()
 60+	issues := []BugIssue{
 61+		{FullID: "old-parent", ShortID: "old", Title: "Old parent", CreatedAt: now.Add(-48 * time.Hour)},
 62+		{FullID: "new-parent", ShortID: "new", Title: "New parent", CreatedAt: now.Add(-24 * time.Hour)},
 63+		{FullID: "old-child", ShortID: "oldc", Title: "Old child", CreatedAt: now.Add(-2 * time.Hour), ParentID: "new-parent"},
 64+		{FullID: "new-child", ShortID: "newc", Title: "New child", CreatedAt: now.Add(-1 * time.Hour), ParentID: "new-parent"},
 65+	}
 66+
 67+	roots := buildIssueTree(issues)
 68+
 69+	if len(roots) != 2 {
 70+		t.Fatalf("roots = %d, want 2", len(roots))
 71+	}
 72+	if roots[0].Issue.FullID != "new-parent" {
 73+		t.Errorf("first root = %q, want %q (newest parent first)", roots[0].Issue.FullID, "new-parent")
 74+	}
 75+	if len(roots[0].Children) != 2 {
 76+		t.Fatalf("children = %d, want 2", len(roots[0].Children))
 77+	}
 78+	if roots[0].Children[0].Issue.FullID != "new-child" {
 79+		t.Errorf("first child = %q, want %q (newest child first)", roots[0].Children[0].Issue.FullID, "new-child")
 80+	}
 81+}
 82+
 83+func TestBuildIssueTree_OrphanParentTreatedAsRoot(t *testing.T) {
 84+	now := time.Now()
 85+	issues := []BugIssue{
 86+		{FullID: "orphan", ShortID: "orphan", Title: "Orphan", CreatedAt: now, ParentID: "missing-parent"},
 87+	}
 88+
 89+	roots := buildIssueTree(issues)
 90+
 91+	if len(roots) != 1 {
 92+		t.Fatalf("roots = %d, want 1 (orphan treated as root)", len(roots))
 93+	}
 94+}
 95+
 96+func captureTreeOutput(t *testing.T, roots []*TreeNode) string {
 97+	t.Helper()
 98+
 99+	old := os.Stdout
100+	r, w, err := os.Pipe()
101+	if err != nil {
102+		t.Fatalf("os.Pipe failed: %v", err)
103+	}
104+	os.Stdout = w
105+
106+	printErr := printTreeTable(roots)
107+
108+	_ = w.Close()
109+	os.Stdout = old
110+
111+	if printErr != nil {
112+		t.Fatalf("printTreeTable failed: %v", printErr)
113+	}
114+
115+	out, err := io.ReadAll(r)
116+	if err != nil {
117+		t.Fatalf("reading captured output failed: %v", err)
118+	}
119+	return string(out)
120+}
121+
122+func TestPrintTreeTable_ShowsChildMarkers(t *testing.T) {
123+	now := time.Now()
124+	issues := []BugIssue{
125+		{FullID: "parent-full-id", ShortID: "parent", Title: "Parent", CreatedAt: now.Add(-2 * time.Hour)},
126+		{FullID: "child1-full-id", ShortID: "child1", Title: "Child 1", CreatedAt: now.Add(-1 * time.Hour), ParentID: "parent-full-id"},
127+		{FullID: "child2-full-id", ShortID: "child2", Title: "Child 2", CreatedAt: now.Add(-30 * time.Minute), ParentID: "parent-full-id"},
128+	}
129+
130+	roots := buildIssueTree(issues)
131+	out := captureTreeOutput(t, roots)
132+
133+	if !strings.Contains(out, "├─ ") {
134+		t.Errorf("output missing non-last child marker %q\noutput:\n%s", "├─ ", out)
135+	}
136+	if !strings.Contains(out, "└─ ") {
137+		t.Errorf("output missing last child marker %q\noutput:\n%s", "└─ ", out)
138+	}
139+}
D update-flake-lock
+0, -0
D update-vendor-hash
+0, -0