8 files changed,
+540,
-46
+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
+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
+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
+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+}
+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+}
+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+}