update edit command to add parent, reject cycles
feat: reject parent cycles in edit, salvage cycled nodes in list feat: shared parent-link helpers for bug edit feat: parent flag and help text for bug edit commands feat: set parent link via bug edit and agent edit (add-only)
8 files changed,  +540, -46
M README.md
+3, -0
 1@@ -105,8 +105,11 @@ bug edit abc1234                    # Open editor with current content
 2 bug edit abc1234 -t "New Title"     # Update only the title
 3 bug edit abc1234 -m "New desc"      # Update only the description
 4 bug edit abc1234 -t "Title" -m "Desc"  # Update both
 5+bug edit abc1234 -p def5678         # Set the parent issue (only if none set)
 6 ```
 7 
 8+A parent link can only be added when the issue has none; parent links cannot be changed or removed afterwards. Links that would close a dependency cycle are rejected.
 9+
10 **Edit a comment:**
11 ```bash
12 bug edit def5678                    # Open editor with current comment text
M SKILL.md
+7, -1
 1@@ -236,6 +236,9 @@ bug agent edit abc1234 --from-file /tmp/new_desc.md
 2 
 3 # Edit both title and message
 4 bug agent edit abc1234 --title "New Title" --message "New description"
 5+
 6+# Set the parent issue (only if none set)
 7+bug agent edit abc1234 --parent def5678
 8 ```
 9 
10 **Flags:**
11@@ -244,6 +247,9 @@ bug agent edit abc1234 --title "New Title" --message "New description"
12   - `--message "..."`: Provide message as a command-line string
13   - `--stdin`: Read message from standard input
14   - `--from-file <path>`: Read message from a file
15+- `--parent <bugID>`: Parent issue short or full ID (optional, issues only; at least one of `--title`, message content, or `--parent` must be provided)
16+
17+**Note:** A parent link can only be added when the issue has none; parent links cannot be changed or removed afterwards. Links that would close a dependency cycle are rejected.
18 
19 **Important:** When editing the message, `--message`, `--stdin`, and `--from-file` are **mutually exclusive**. You must choose exactly one method to provide the message content.
20 
21@@ -262,7 +268,7 @@ New details about this bug:
22 EOF
23 ```
24 
25-**Note:** When editing bugs, at least one of `--title` or a message option must be provided.
26+**Note:** When editing bugs, at least one of `--title`, a message option, or `--parent` must be provided.
27 
28 ### bug agent edit [commentID] [--message "..." | --stdin | --from-file <path>]
29 
M agent_edit_integration_test.go
+7, -7
 1@@ -66,7 +66,7 @@ func TestAgentEditCommand_EditIssue(t *testing.T) {
 2 	// Edit as agent
 3 	newTitle := "Agent Updated Title"
 4 	newMessage := "Agent updated description"
 5-	if err := runAgentEdit(tmpDir, bugID, newTitle, newMessage, "", false); err != nil {
 6+	if err := runAgentEdit(tmpDir, bugID, newTitle, newMessage, "", false, ""); err != nil {
 7 		t.Fatalf("runAgentEdit failed: %v", err)
 8 	}
 9 
10@@ -163,7 +163,7 @@ func TestAgentEditCommand_EditComment(t *testing.T) {
11 
12 	// Edit the comment as agent
13 	newComment := "Updated agent comment"
14-	if err := runAgentEdit(tmpDir, commentID, "", newComment, "", false); err != nil {
15+	if err := runAgentEdit(tmpDir, commentID, "", newComment, "", false, ""); err != nil {
16 		t.Fatalf("runAgentEdit failed: %v", err)
17 	}
18 
19@@ -255,7 +255,7 @@ func TestAgentEditCommand_RequiresMessage(t *testing.T) {
20 	repo.Close()
21 
22 	// Try to edit comment without message
23-	err = runAgentEdit(tmpDir, commentID, "", "", "", false)
24+	err = runAgentEdit(tmpDir, commentID, "", "", "", false, "")
25 	if err == nil {
26 		t.Error("expected error when editing comment without message, got nil")
27 	}
28@@ -313,13 +313,13 @@ func TestAgentEditCommand_RequiresAtLeastOneField(t *testing.T) {
29 	repo.Close()
30 
31 	// Try to edit without any flags
32-	err = runAgentEdit(tmpDir, bugID, "", "", "", false)
33+	err = runAgentEdit(tmpDir, bugID, "", "", "", false, "")
34 	if err == nil {
35 		t.Error("expected error when editing without any fields, got nil")
36 	}
37 
38-	if !strings.Contains(err.Error(), "either --title or --message") {
39-		t.Errorf("expected 'either --title or --message' error, got: %v", err)
40+	if !strings.Contains(err.Error(), "at least one of --title, --message, or --parent") {
41+		t.Errorf("expected 'at least one of --title, --message, or --parent' error, got: %v", err)
42 	}
43 }
44 
45@@ -374,7 +374,7 @@ func TestAgentEditCommand_OnlyTitle(t *testing.T) {
46 
47 	// Edit only title as agent
48 	newTitle := "New Title"
49-	if err := runAgentEdit(tmpDir, bugID, newTitle, "", "", false); err != nil {
50+	if err := runAgentEdit(tmpDir, bugID, newTitle, "", "", false, ""); err != nil {
51 		t.Fatalf("runAgentEdit failed: %v", err)
52 	}
53 
M edit_integration_test.go
+6, -6
 1@@ -61,7 +61,7 @@ func TestEditCommand_EditIssueTitle(t *testing.T) {
 2 
 3 	// Edit only the title
 4 	newTitle := "Updated Title"
 5-	if err := runEdit(tmpDir, bugID, newTitle, ""); err != nil {
 6+	if err := runEdit(tmpDir, bugID, newTitle, "", ""); err != nil {
 7 		t.Fatalf("runEdit failed: %v", err)
 8 	}
 9 
10@@ -136,7 +136,7 @@ func TestEditCommand_EditIssueDescription(t *testing.T) {
11 
12 	// Edit only the description
13 	newMessage := "Updated description"
14-	if err := runEdit(tmpDir, bugID, "", newMessage); err != nil {
15+	if err := runEdit(tmpDir, bugID, "", newMessage, ""); err != nil {
16 		t.Fatalf("runEdit failed: %v", err)
17 	}
18 
19@@ -206,7 +206,7 @@ func TestEditCommand_EditIssueBoth(t *testing.T) {
20 	// Edit both title and description
21 	newTitle := "Updated Title"
22 	newMessage := "Updated description"
23-	if err := runEdit(tmpDir, bugID, newTitle, newMessage); err != nil {
24+	if err := runEdit(tmpDir, bugID, newTitle, newMessage, ""); err != nil {
25 		t.Fatalf("runEdit failed: %v", err)
26 	}
27 
28@@ -297,7 +297,7 @@ func TestEditCommand_EditComment(t *testing.T) {
29 
30 	// Edit the comment
31 	newComment := "Updated comment text"
32-	if err := runEdit(tmpDir, commentID, "", newComment); err != nil {
33+	if err := runEdit(tmpDir, commentID, "", newComment, ""); err != nil {
34 		t.Fatalf("runEdit failed: %v", err)
35 	}
36 
37@@ -339,7 +339,7 @@ func TestEditCommand_InvalidID(t *testing.T) {
38 	}
39 
40 	// Try to edit non-existent ID
41-	err := runEdit(tmpDir, "nonexistent123", "Title", "Message")
42+	err := runEdit(tmpDir, "nonexistent123", "Title", "Message", "")
43 	if err == nil {
44 		t.Error("expected error for invalid ID, got nil")
45 	}
46@@ -401,7 +401,7 @@ func TestEditCommand_WithShortID(t *testing.T) {
47 
48 	// Edit using short ID
49 	newTitle := "Updated Title"
50-	if err := runEdit(tmpDir, shortID, newTitle, ""); err != nil {
51+	if err := runEdit(tmpDir, shortID, newTitle, "", ""); err != nil {
52 		t.Fatalf("runEdit with short ID failed: %v", err)
53 	}
54 
M main.go
+179, -32
  1@@ -438,6 +438,33 @@ func buildIssueTree(issues []BugIssue) []*TreeNode {
  2 		}
  3 	}
  4 
  5+	// Salvage nodes unreachable from any root (dependency cycles can
  6+	// only arise from manual metadata tampering: the CLI refuses to
  7+	// create them). Without this they would silently vanish from display.
  8+	reachable := make(map[*TreeNode]bool)
  9+	var mark func(n *TreeNode)
 10+	mark = func(n *TreeNode) {
 11+		if reachable[n] {
 12+			return
 13+		}
 14+		reachable[n] = true
 15+		for _, child := range n.Children {
 16+			mark(child)
 17+		}
 18+	}
 19+	for _, root := range roots {
 20+		mark(root)
 21+	}
 22+	for _, node := range nodeMap {
 23+		if !reachable[node] {
 24+			// Break the cycle for display: show the node as a root
 25+			// but leave its children linked so nothing renders twice.
 26+			node.Children = nil
 27+			roots = append(roots, node)
 28+			mark(node)
 29+		}
 30+	}
 31+
 32 	// Sort roots by age descending (newest first)
 33 	sort.Slice(roots, func(i, j int) bool {
 34 		return roots[i].Issue.CreatedAt.After(roots[j].Issue.CreatedAt)
 35@@ -1540,23 +1567,25 @@ func runInitWithReader(repoPath string, reader io.Reader) error {
 36 }
 37 
 38 var (
 39-	repoPath         string
 40-	filterFlag       string
 41-	sortFlag         string
 42-	newTitle         string
 43-	newMessage       string
 44-	newParentID      string
 45-	agentTitle       string
 46-	agentMessage     string
 47-	agentNewParentID string
 48-	commentMessage   string
 49-	commentBugID     string
 50-	agentCommentMsg  string
 51-	editTitle        string
 52-	editMessage      string
 53-	agentEditTitle   string
 54-	agentEditMsg     string
 55-	statusFlag       string
 56+	repoPath          string
 57+	filterFlag        string
 58+	sortFlag          string
 59+	newTitle          string
 60+	newMessage        string
 61+	newParentID       string
 62+	agentTitle        string
 63+	agentMessage      string
 64+	agentNewParentID  string
 65+	commentMessage    string
 66+	commentBugID      string
 67+	agentCommentMsg   string
 68+	editTitle         string
 69+	editMessage       string
 70+	editParentID      string
 71+	agentEditTitle    string
 72+	agentEditMsg      string
 73+	agentEditParentID string
 74+	statusFlag        string
 75 
 76 	// Agent command stdin/from-file flags
 77 	agentNewStdinFlag     bool
 78@@ -1793,6 +1822,11 @@ For issues:
 79   bug edit abc1234 -t "New Title"     # Update only the title
 80   bug edit abc1234 -m "New desc"      # Update only the description
 81   bug edit abc1234 -t "Title" -m "Desc"  # Update both
 82+  bug edit abc1234 -p def5678         # Set the parent issue (only if none set)
 83+
 84+A parent link can only be added when the issue has none; parent links
 85+cannot be changed or removed afterwards. Links that would close a
 86+dependency cycle are rejected.
 87 
 88 For comments:
 89   bug edit def5678                    # Open editor with current comment text
 90@@ -1804,7 +1838,7 @@ When using the editor:
 91   - Lines starting with ;; are ignored (instructions)`,
 92 	Args: cobra.ExactArgs(1),
 93 	RunE: func(cmd *cobra.Command, args []string) error {
 94-		return runEdit(repoPath, args[0], editTitle, editMessage)
 95+		return runEdit(repoPath, args[0], editTitle, editMessage, editParentID)
 96 	},
 97 }
 98 
 99@@ -1929,19 +1963,24 @@ var agentEditCmd = &cobra.Command{
100 	Short: "Edit an issue or comment as the agent",
101 	Long: `Edit an existing issue or comment using the agent identity.
102 
103-This command is non-interactive and requires at least one of --title or --message.
104+This command is non-interactive and requires at least one of --title, --message, or --parent.
105 It uses the agent identity created during 'bug init'.
106 
107 For issues:
108   bug agent edit abc1234 --title "New Title"
109   bug agent edit abc1234 --message "New description"
110   bug agent edit abc1234 --title "Title" --message "Description"
111+  bug agent edit abc1234 --parent def5678
112+
113+A parent link can only be added when the issue has none; parent links
114+cannot be changed or removed afterwards. Links that would close a
115+dependency cycle are rejected.
116 
117 For comments:
118   bug agent edit def5678 --message "Updated comment text"`,
119 	Args: cobra.ExactArgs(1),
120 	RunE: func(cmd *cobra.Command, args []string) error {
121-		return runAgentEdit(repoPath, args[0], agentEditTitle, agentEditMsg, agentEditFromFile, agentEditStdinFlag)
122+		return runAgentEdit(repoPath, args[0], agentEditTitle, agentEditMsg, agentEditFromFile, agentEditStdinFlag, agentEditParentID)
123 	},
124 }
125 
126@@ -2023,10 +2062,12 @@ func init() {
127 	// Edit command flags - both optional (opens editor if neither provided)
128 	editCmd.Flags().StringVarP(&editTitle, "title", "t", "", "new issue title")
129 	editCmd.Flags().StringVarP(&editMessage, "message", "m", "", "new issue description or comment text")
130+	editCmd.Flags().StringVarP(&editParentID, "parent", "p", "", "parent bug ID (7-char short or full ID, issues only)")
131 
132 	// Agent edit command flags - at least one required
133 	agentEditCmd.Flags().StringVar(&agentEditTitle, "title", "", "new issue title")
134 	agentEditCmd.Flags().StringVarP(&agentEditMsg, "message", "m", "", "new issue description or comment text")
135+	agentEditCmd.Flags().StringVar(&agentEditParentID, "parent", "", "parent bug ID (7-char short or full ID, issues only)")
136 	agentEditCmd.Flags().BoolVar(&agentEditStdinFlag, "stdin", false, "read message from stdin")
137 	agentEditCmd.Flags().StringVar(&agentEditFromFile, "from-file", "", "read message from file")
138 
139@@ -2314,7 +2355,7 @@ func runAgentComment(repoPath, bugIDStr, message string, fromFile string, useStd
140 // runEdit edits an issue or comment
141 // If title and message flags are provided, uses them directly
142 // Otherwise, opens an editor with current content pre-populated
143-func runEdit(repoPath, idStr, newTitle, newMessage string) error {
144+func runEdit(repoPath, idStr, newTitle, newMessage, parentID string) error {
145 	// Resolve the ID (bug or comment)
146 	resolved, err := resolveID(repoPath, idStr)
147 	if err != nil {
148@@ -2339,8 +2380,29 @@ func runEdit(repoPath, idStr, newTitle, newMessage string) error {
149 	var editErr error
150 	switch resolved.Type {
151 	case IDTypeBug:
152-		editErr = editBug(repo, resolved.Bug, author, unixTime, newTitle, newMessage)
153+		var parentFullID string
154+		if parentID != "" {
155+			parentBug, err := resolveBugID(repoPath, parentID)
156+			if err != nil {
157+				return fmt.Errorf("failed to resolve parent bug ID %q: %w\n\nUse 'bug ls' to see available bugs", parentID, err)
158+			}
159+			parentFullID = parentBug.Id().String()
160+			if parentFullID == resolved.Bug.Id().String() {
161+				return fmt.Errorf("bug cannot be its own parent")
162+			}
163+			issues, err := LoadBugs(repoPath)
164+			if err != nil {
165+				return fmt.Errorf("failed to load bugs: %w", err)
166+			}
167+			if createsParentCycle(issues, resolved.Bug.Id().String(), parentFullID) {
168+				return fmt.Errorf("setting parent %q would create a dependency cycle", parentID)
169+			}
170+		}
171+		editErr = editBug(repo, resolved.Bug, author, unixTime, newTitle, newMessage, parentFullID)
172 	case IDTypeComment:
173+		if parentID != "" {
174+			return fmt.Errorf("--parent only applies to bugs, not comments")
175+		}
176 		editErr = editComment(repo, resolved.Bug, resolved.Comment, author, unixTime, newMessage)
177 	default:
178 		return fmt.Errorf("unknown ID type")
179@@ -2355,7 +2417,7 @@ func runEdit(repoPath, idStr, newTitle, newMessage string) error {
180 }
181 
182 // editBug edits an issue's title and/or description
183-func editBug(repo repository.ClockedRepo, b *bug.Bug, author identity.Interface, unixTime int64, newTitle, newMessage string) error {
184+func editBug(repo repository.ClockedRepo, b *bug.Bug, author identity.Interface, unixTime int64, newTitle, newMessage, parentFullID string) error {
185 	snap := b.Compile()
186 	currentTitle := snap.Title
187 	currentMessage := ""
188@@ -2364,7 +2426,7 @@ func editBug(repo repository.ClockedRepo, b *bug.Bug, author identity.Interface,
189 	}
190 
191 	// If no flags provided, open editor with current content
192-	if strings.TrimSpace(newTitle) == "" && strings.TrimSpace(newMessage) == "" {
193+	if strings.TrimSpace(newTitle) == "" && strings.TrimSpace(newMessage) == "" && parentFullID == "" {
194 		// Prepare template: title on first line, blank line, description
195 		template := fmt.Sprintf("%s\n\n%s", currentTitle, currentMessage)
196 
197@@ -2420,6 +2482,31 @@ func editBug(repo repository.ClockedRepo, b *bug.Bug, author identity.Interface,
198 		changes = true
199 	}
200 
201+	// Update parent link if requested (add-only: metadata keys are immutable)
202+	parentChanged := false
203+	if parentFullID != "" {
204+		currentParent := currentParentID(snap)
205+		if currentParent != "" {
206+			if currentParent == parentFullID {
207+				// Already linked to this parent: nothing to do
208+			} else {
209+				shortParent := currentParent
210+				if len(shortParent) > 7 {
211+					shortParent = shortParent[:7]
212+				}
213+				return fmt.Errorf("bug already has a parent (%s); parent links cannot be changed or removed once set", shortParent)
214+			}
215+		} else {
216+			if len(snap.Operations) == 0 {
217+				return fmt.Errorf("cannot set parent: bug has no operations")
218+			}
219+			if err := setBugParentLink(repo, b, snap.Operations[0].Id(), parentFullID); err != nil {
220+				return err
221+			}
222+			parentChanged = true
223+		}
224+	}
225+
226 	// Commit changes if any
227 	if changes {
228 		if err := b.Commit(repo); err != nil {
229@@ -2434,6 +2521,13 @@ func editBug(repo repository.ClockedRepo, b *bug.Bug, author identity.Interface,
230 	}
231 
232 	fmt.Printf("Updated bug %s: %s\n", bugID, newTitle)
233+	if parentChanged {
234+		shortParent := parentFullID
235+		if len(shortParent) > 7 {
236+			shortParent = shortParent[:7]
237+		}
238+		fmt.Printf("Set parent of bug %s to %s\n", bugID, shortParent)
239+	}
240 	return nil
241 }
242 
243@@ -2501,7 +2595,7 @@ func editComment(repo repository.ClockedRepo, b *bug.Bug, comment *bug.Comment,
244 
245 // runAgentEdit edits an issue or comment as the agent identity
246 // This is non-interactive and requires at least one of --title or --message
247-func runAgentEdit(repoPath, idStr, newTitle, newMessage string, fromFile string, useStdin bool) error {
248+func runAgentEdit(repoPath, idStr, newTitle, newMessage string, fromFile string, useStdin bool, parentID string) error {
249 	// Read message from appropriate source if provided
250 	var messageContent string
251 	hasMessageSource := fromFile != "" || useStdin || newMessage != ""
252@@ -2537,12 +2631,33 @@ func runAgentEdit(repoPath, idStr, newTitle, newMessage string, fromFile string,
253 	var editErr error
254 	switch resolved.Type {
255 	case IDTypeBug:
256-		// For bugs, require at least title or message
257-		if strings.TrimSpace(newTitle) == "" && messageContent == "" {
258-			return fmt.Errorf("either --title or --message must be provided")
259+		// For bugs, require at least title, message, or parent
260+		if strings.TrimSpace(newTitle) == "" && messageContent == "" && parentID == "" {
261+			return fmt.Errorf("at least one of --title, --message, or --parent must be provided")
262+		}
263+		var parentFullID string
264+		if parentID != "" {
265+			parentBug, err := resolveBugID(repoPath, parentID)
266+			if err != nil {
267+				return fmt.Errorf("failed to resolve parent bug ID %q: %w\n\nUse 'bug ls' to see available bugs", parentID, err)
268+			}
269+			parentFullID = parentBug.Id().String()
270+			if parentFullID == resolved.Bug.Id().String() {
271+				return fmt.Errorf("bug cannot be its own parent")
272+			}
273+			issues, err := LoadBugs(repoPath)
274+			if err != nil {
275+				return fmt.Errorf("failed to load bugs: %w", err)
276+			}
277+			if createsParentCycle(issues, resolved.Bug.Id().String(), parentFullID) {
278+				return fmt.Errorf("setting parent %q would create a dependency cycle", parentID)
279+			}
280 		}
281-		editErr = editBugAgent(repo, resolved.Bug, author, unixTime, newTitle, messageContent)
282+		editErr = editBugAgent(repo, resolved.Bug, author, unixTime, newTitle, messageContent, parentFullID)
283 	case IDTypeComment:
284+		if parentID != "" {
285+			return fmt.Errorf("--parent only applies to bugs, not comments")
286+		}
287 		// For comments, only message is applicable
288 		if messageContent == "" {
289 			return fmt.Errorf("--message is required when editing a comment")
290@@ -2561,7 +2676,7 @@ func runAgentEdit(repoPath, idStr, newTitle, newMessage string, fromFile string,
291 }
292 
293 // editBugAgent edits an issue as the agent (non-interactive)
294-func editBugAgent(repo repository.ClockedRepo, b *bug.Bug, author identity.Interface, unixTime int64, newTitle, newMessage string) error {
295+func editBugAgent(repo repository.ClockedRepo, b *bug.Bug, author identity.Interface, unixTime int64, newTitle, newMessage, parentFullID string) error {
296 	snap := b.Compile()
297 	currentTitle := snap.Title
298 	currentMessage := ""
299@@ -2570,8 +2685,8 @@ func editBugAgent(repo repository.ClockedRepo, b *bug.Bug, author identity.Inter
300 	}
301 
302 	// For bugs, require at least one field to be set
303-	if strings.TrimSpace(newTitle) == "" && strings.TrimSpace(newMessage) == "" {
304-		return fmt.Errorf("either --title or --message must be provided")
305+	if strings.TrimSpace(newTitle) == "" && strings.TrimSpace(newMessage) == "" && parentFullID == "" {
306+		return fmt.Errorf("at least one of --title, --message, or --parent must be provided")
307 	}
308 
309 	// Use current values for fields not provided
310@@ -2602,6 +2717,31 @@ func editBugAgent(repo repository.ClockedRepo, b *bug.Bug, author identity.Inter
311 		changes = true
312 	}
313 
314+	// Update parent link if requested (add-only: metadata keys are immutable)
315+	parentChanged := false
316+	if parentFullID != "" {
317+		currentParent := currentParentID(snap)
318+		if currentParent != "" {
319+			if currentParent == parentFullID {
320+				// Already linked to this parent: nothing to do
321+			} else {
322+				shortParent := currentParent
323+				if len(shortParent) > 7 {
324+					shortParent = shortParent[:7]
325+				}
326+				return fmt.Errorf("bug already has a parent (%s); parent links cannot be changed or removed once set", shortParent)
327+			}
328+		} else {
329+			if len(snap.Operations) == 0 {
330+				return fmt.Errorf("cannot set parent: bug has no operations")
331+			}
332+			if err := setBugParentLink(repo, b, snap.Operations[0].Id(), parentFullID); err != nil {
333+				return err
334+			}
335+			parentChanged = true
336+		}
337+	}
338+
339 	// Commit changes if any
340 	if changes {
341 		if err := b.Commit(repo); err != nil {
342@@ -2616,6 +2756,13 @@ func editBugAgent(repo repository.ClockedRepo, b *bug.Bug, author identity.Inter
343 	}
344 
345 	fmt.Printf("Updated bug %s: %s\n", bugID, newTitle)
346+	if parentChanged {
347+		shortParent := parentFullID
348+		if len(shortParent) > 7 {
349+			shortParent = shortParent[:7]
350+		}
351+		fmt.Printf("Set parent of bug %s to %s\n", bugID, shortParent)
352+	}
353 	return nil
354 }
355 
A parent.go
+72, -0
 1@@ -0,0 +1,72 @@
 2+package main
 3+
 4+import (
 5+	"fmt"
 6+
 7+	"github.com/git-bug/git-bug/cache"
 8+	"github.com/git-bug/git-bug/entities/bug"
 9+	"github.com/git-bug/git-bug/entity"
10+	"github.com/git-bug/git-bug/repository"
11+)
12+
13+// parentMetadataKey is the CreateOp metadata key holding the full ID
14+// of the parent bug. Absent means "no parent".
15+//
16+// git-bug metadata keys are immutable once set: a later SetMetadata
17+// cannot overwrite or delete the key. Parent links can therefore only
18+// be added to bugs that have none; they cannot be changed or removed.
19+const parentMetadataKey = "parent"
20+
21+// currentParentID returns the bug's current parent full ID, or "".
22+func currentParentID(snap *bug.Snapshot) string {
23+	if parent, ok := snap.GetCreateMetadata(parentMetadataKey); ok && parent != "" {
24+		return parent
25+	}
26+	return ""
27+}
28+
29+// createsParentCycle reports whether linking bugFullID under parentFullID
30+// would close a dependency cycle (i.e. bugFullID is already an ancestor
31+// of parentFullID). Self-links are also reported as cycles.
32+func createsParentCycle(issues []BugIssue, bugFullID, parentFullID string) bool {
33+	if bugFullID == parentFullID {
34+		return true
35+	}
36+	byID := make(map[string]BugIssue, len(issues))
37+	for _, issue := range issues {
38+		byID[issue.FullID] = issue
39+	}
40+	for current := parentFullID; current != ""; {
41+		if current == bugFullID {
42+			return true
43+		}
44+		ancestor, ok := byID[current]
45+		if !ok {
46+			return false
47+		}
48+		current = ancestor.ParentID
49+	}
50+	return false
51+}
52+
53+// setBugParentLink records parentFullID on the bug's create operation.
54+// The caller must ensure the bug has no parent yet: overwriting an
55+// existing key is silently ignored by git-bug.
56+func setBugParentLink(repo repository.ClockedRepo, b *bug.Bug, createOpID entity.Id, parentFullID string) error {
57+	cacheInstance, err := cache.NewRepoCacheNoEvents(repo)
58+	if err != nil {
59+		return fmt.Errorf("failed to create cache: %w", err)
60+	}
61+	defer cacheInstance.Close()
62+	bc, err := cacheInstance.Bugs().Resolve(b.Id())
63+	if err != nil {
64+		return fmt.Errorf("failed to resolve bug: %w", err)
65+	}
66+	if _, err := bc.SetMetadata(createOpID, map[string]string{parentMetadataKey: parentFullID}); err != nil {
67+		return fmt.Errorf("failed to set parent: %w", err)
68+	}
69+	if err := bc.CommitAsNeeded(); err != nil {
70+		return fmt.Errorf("failed to commit parent link: %w", err)
71+	}
72+	return nil
73+}
A parent_cycle_test.go
+59, -0
 1@@ -0,0 +1,59 @@
 2+package main
 3+
 4+import (
 5+	"strings"
 6+	"testing"
 7+	"time"
 8+)
 9+
10+func TestCreatesParentCycle(t *testing.T) {
11+	issues := []BugIssue{
12+		{FullID: "grandparent"},
13+		{FullID: "parent", ParentID: "grandparent"},
14+		{FullID: "child", ParentID: "parent"},
15+		{FullID: "unrelated"},
16+	}
17+
18+	tests := []struct {
19+		name   string
20+		bug    string
21+		parent string
22+		want   bool
23+	}{
24+		{"direct cycle", "parent", "child", true},
25+		{"indirect cycle", "grandparent", "child", true},
26+		{"self link", "child", "child", true},
27+		{"unrelated link", "unrelated", "child", false},
28+		{"sibling link", "child", "unrelated", false},
29+	}
30+
31+	for _, tt := range tests {
32+		t.Run(tt.name, func(t *testing.T) {
33+			if got := createsParentCycle(issues, tt.bug, tt.parent); got != tt.want {
34+				t.Errorf("createsParentCycle(%q, %q) = %v, want %v", tt.bug, tt.parent, got, tt.want)
35+			}
36+		})
37+	}
38+}
39+
40+// TestBuildIssueTree_CycleNodesShownOnce ensures issues in a dependency
41+// cycle (possible only via manual metadata tampering, since the CLI
42+// refuses to create cycles) are still displayed exactly once instead
43+// of silently vanishing from `bug ls`.
44+func TestBuildIssueTree_CycleNodesShownOnce(t *testing.T) {
45+	now := time.Now()
46+	issues := []BugIssue{
47+		{FullID: "aaa", ShortID: "aaa", Title: "A", CreatedAt: now.Add(-3 * time.Hour), ParentID: "bbb"},
48+		{FullID: "bbb", ShortID: "bbb", Title: "B", CreatedAt: now.Add(-2 * time.Hour), ParentID: "aaa"},
49+		{FullID: "root", ShortID: "root", Title: "Root", CreatedAt: now.Add(-1 * time.Hour)},
50+	}
51+
52+	roots := buildIssueTree(issues)
53+	out := captureTreeOutput(t, roots)
54+
55+	for _, id := range []string{"aaa", "bbb", "root"} {
56+		if got := strings.Count(out, id); got != 1 {
57+			t.Errorf("ID %q appears %d times, want exactly once\noutput:\n%s", id, got, out)
58+		}
59+	}
60+}
A parent_edit_integration_test.go
+207, -0
  1@@ -0,0 +1,207 @@
  2+//go:build integration
  3+
  4+package main
  5+
  6+import (
  7+	"os/exec"
  8+	"strings"
  9+	"testing"
 10+
 11+	"github.com/git-bug/git-bug/entities/bug"
 12+	"github.com/git-bug/git-bug/entity"
 13+	"github.com/git-bug/git-bug/repository"
 14+)
 15+
 16+// setupParentEditRepo initializes a git repo with user and agent identities
 17+func setupParentEditRepo(t *testing.T) string {
 18+	t.Helper()
 19+	tmpDir := t.TempDir()
 20+
 21+	initCmd := exec.Command("git", "init")
 22+	initCmd.Dir = tmpDir
 23+	if err := initCmd.Run(); err != nil {
 24+		t.Fatalf("failed to init git repo: %v", err)
 25+	}
 26+
 27+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
 28+		t.Fatalf("failed to create user identity: %v", err)
 29+	}
 30+	if err := createIdentity(tmpDir, "agent", "", false); err != nil {
 31+		t.Fatalf("failed to create agent identity: %v", err)
 32+	}
 33+
 34+	return tmpDir
 35+}
 36+
 37+// findEditIssueByTitle returns the BugIssue with the given title
 38+func findEditIssueByTitle(t *testing.T, repoPath, title string) BugIssue {
 39+	t.Helper()
 40+	issues, err := LoadBugs(repoPath)
 41+	if err != nil {
 42+		t.Fatalf("LoadBugs failed: %v", err)
 43+	}
 44+	for _, issue := range issues {
 45+		if issue.Title == title {
 46+			return issue
 47+		}
 48+	}
 49+	t.Fatalf("bug with title %q not found", title)
 50+	return BugIssue{}
 51+}
 52+
 53+// createLinkedPair creates a parent bug and an unlinked child bug
 54+func createLinkedPair(t *testing.T, repoPath string) (parent, child BugIssue) {
 55+	t.Helper()
 56+	if err := runNew(repoPath, "Edit Parent", "parent body", ""); err != nil {
 57+		t.Fatalf("runNew parent failed: %v", err)
 58+	}
 59+	if err := runNew(repoPath, "Edit Child", "child body", ""); err != nil {
 60+		t.Fatalf("runNew child failed: %v", err)
 61+	}
 62+	return findEditIssueByTitle(t, repoPath, "Edit Parent"),
 63+		findEditIssueByTitle(t, repoPath, "Edit Child")
 64+}
 65+
 66+// TestEditCommand_SetParent links an unlinked bug under a parent
 67+func TestEditCommand_SetParent(t *testing.T) {
 68+	tmpDir := setupParentEditRepo(t)
 69+	parent, child := createLinkedPair(t, tmpDir)
 70+
 71+	if child.ParentID != "" {
 72+		t.Fatalf("child already has parent %q", child.ParentID)
 73+	}
 74+
 75+	if err := runEdit(tmpDir, child.ShortID, "", "", parent.ShortID); err != nil {
 76+		t.Fatalf("runEdit set parent failed: %v", err)
 77+	}
 78+
 79+	updated := findEditIssueByTitle(t, tmpDir, "Edit Child")
 80+	if updated.ParentID != parent.FullID {
 81+		t.Errorf("ParentID = %q, want parent full ID %q", updated.ParentID, parent.FullID)
 82+	}
 83+}
 84+
 85+// TestEditCommand_ReplaceParentError verifies parent links cannot be changed
 86+func TestEditCommand_ReplaceParentError(t *testing.T) {
 87+	tmpDir := setupParentEditRepo(t)
 88+	parent, child := createLinkedPair(t, tmpDir)
 89+
 90+	if err := runNew(tmpDir, "Second Parent", "body", ""); err != nil {
 91+		t.Fatalf("runNew second parent failed: %v", err)
 92+	}
 93+	second := findEditIssueByTitle(t, tmpDir, "Second Parent")
 94+
 95+	if err := runEdit(tmpDir, child.ShortID, "", "", parent.ShortID); err != nil {
 96+		t.Fatalf("runEdit set parent failed: %v", err)
 97+	}
 98+
 99+	err := runEdit(tmpDir, child.ShortID, "", "", second.ShortID)
100+	if err == nil {
101+		t.Error("expected error when replacing an existing parent, got nil")
102+	} else if !strings.Contains(err.Error(), "already has a parent") {
103+		t.Errorf("expected 'already has a parent' error, got: %v", err)
104+	}
105+
106+	// Original link must be intact
107+	updated := findEditIssueByTitle(t, tmpDir, "Edit Child")
108+	if updated.ParentID != parent.FullID {
109+		t.Errorf("ParentID = %q, want original parent %q", updated.ParentID, parent.FullID)
110+	}
111+}
112+
113+// TestEditCommand_ParentCycleError verifies a parent link that would
114+// close a dependency cycle is refused and the original state is kept
115+func TestEditCommand_ParentCycleError(t *testing.T) {
116+	tmpDir := setupParentEditRepo(t)
117+	parent, child := createLinkedPair(t, tmpDir)
118+
119+	if err := runEdit(tmpDir, child.ShortID, "", "", parent.ShortID); err != nil {
120+		t.Fatalf("runEdit set parent failed: %v", err)
121+	}
122+
123+	err := runEdit(tmpDir, parent.ShortID, "", "", child.ShortID)
124+	if err == nil {
125+		t.Error("expected error for cyclic parent link, got nil")
126+	} else if !strings.Contains(err.Error(), "cycle") {
127+		t.Errorf("expected 'cycle' error, got: %v", err)
128+	}
129+
130+	// Parent must remain link-free
131+	updated := findEditIssueByTitle(t, tmpDir, "Edit Parent")
132+	if updated.ParentID != "" {
133+		t.Errorf("ParentID = %q, want link-free parent", updated.ParentID)
134+	}
135+}
136+
137+// TestEditCommand_InvalidParent tests error for unknown parent ID
138+func TestEditCommand_InvalidParent(t *testing.T) {
139+	tmpDir := setupParentEditRepo(t)
140+	_, child := createLinkedPair(t, tmpDir)
141+
142+	err := runEdit(tmpDir, child.ShortID, "", "", "deadbee")
143+	if err == nil {
144+		t.Error("expected error for unknown parent ID, got nil")
145+	} else if !strings.Contains(err.Error(), "deadbee") {
146+		t.Errorf("expected error to mention parent ID, got: %v", err)
147+	}
148+}
149+
150+// TestEditCommand_SelfParent tests error when linking a bug to itself
151+func TestEditCommand_SelfParent(t *testing.T) {
152+	tmpDir := setupParentEditRepo(t)
153+	_, child := createLinkedPair(t, tmpDir)
154+
155+	err := runEdit(tmpDir, child.ShortID, "", "", child.ShortID)
156+	if err == nil {
157+		t.Error("expected error for self parent, got nil")
158+	} else if !strings.Contains(err.Error(), "own parent") {
159+		t.Errorf("expected 'own parent' error, got: %v", err)
160+	}
161+}
162+
163+// TestEditCommand_ParentOnComment tests error when linking a comment
164+func TestEditCommand_ParentOnComment(t *testing.T) {
165+	tmpDir := setupParentEditRepo(t)
166+	parent, _ := createLinkedPair(t, tmpDir)
167+
168+	if err := runComment(tmpDir, parent.FullID, "a comment"); err != nil {
169+		t.Fatalf("runComment failed: %v", err)
170+	}
171+
172+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
173+	if err != nil {
174+		t.Fatalf("failed to open repo: %v", err)
175+	}
176+	b, err := bug.Read(repo, entity.Id(parent.FullID))
177+	if err != nil {
178+		t.Fatalf("failed to read bug: %v", err)
179+	}
180+	snap := b.Compile()
181+	if len(snap.Comments) < 2 {
182+		t.Fatal("expected at least 2 comments")
183+	}
184+	commentID := snap.Comments[1].CombinedId().String()
185+	repo.Close()
186+
187+	err = runEdit(tmpDir, commentID, "", "", parent.ShortID)
188+	if err == nil {
189+		t.Error("expected error for parent flag on comment, got nil")
190+	} else if !strings.Contains(err.Error(), "only applies to bugs") {
191+		t.Errorf("expected 'only applies to bugs' error, got: %v", err)
192+	}
193+}
194+
195+// TestAgentEditCommand_SetParent links an unlinked bug as the agent
196+func TestAgentEditCommand_SetParent(t *testing.T) {
197+	tmpDir := setupParentEditRepo(t)
198+	parent, child := createLinkedPair(t, tmpDir)
199+
200+	if err := runAgentEdit(tmpDir, child.ShortID, "", "", "", false, parent.ShortID); err != nil {
201+		t.Fatalf("runAgentEdit set parent failed: %v", err)
202+	}
203+
204+	updated := findEditIssueByTitle(t, tmpDir, "Edit Child")
205+	if updated.ParentID != parent.FullID {
206+		t.Errorf("ParentID = %q, want parent full ID %q", updated.ParentID, parent.FullID)
207+	}
208+}