separate library from pgit
26 files changed,  +7828, -0
A .gitignore
+2, -0
1@@ -0,0 +1,2 @@
2+bin/
3+.devbox
A AGENTS.md
+9, -0
 1@@ -0,0 +1,9 @@
 2+This is an alternative CLI for the [git-bug](https://github.com/git-bug/git-bug) library. Invoke REQUIRED SKILL **superpowers** when working on features in this application.
 3+
 4+The `bug` application uses Cobra for handling subcommands. Always recommend the minimum nesting of subcommands to accomplish the goal so that the API is easier for users to understand.  **GOOD**: `bug new`.  **BAD**: `bug issue new` = too many levels of subcommands.
 5+
 6+Use TDD. Break up large files into smaller, cohesive units that implement an operation, i.e., one file for each subcommand, with shared functionality in a separate file that deals with a particular entity type, such as an issue, comment, or identity.
 7+
 8+Use descriptive error messages meant for the user -- **THINK** = what steps does the user need to take to accomplish the command successfully.  Provide guidance on what to do next, not details about the internal structure of the application.
 9+
10+The application uses two different APIs - one suitable for a human = `bug new`, `bug edit`, `bug comment`, `bug read`, etc. These should be lenient and help the user achieve their intent.  There is a separate API nested under `bug agent` that has similar functionality but is more strict, suitable for use by an agent to collaborate with the user.  When new features are added, update README.md with instructions for the human and SKILL.md with instructions for the agent.
A Makefile
+61, -0
 1@@ -0,0 +1,61 @@
 2+
 3+build: clean
 4+	@if command -v nix >/dev/null 2>&1; then \
 5+		echo "Building with nix..."; \
 6+		nix build; \
 7+	else \
 8+		echo "Building with go..."; \
 9+		mkdir -p result/bin; \
10+		go build -o result/bin/bug *.go; \
11+	fi
12+.PHONY: build
13+
14+clean:
15+	@# Remove result directory/symlink
16+	@rm -rf result
17+	@rm -rf bin/
18+.PHONY: clean
19+
20+fmt:
21+	go fmt ./...
22+.PHONY: fmt
23+
24+lint:
25+	golangci-lint run
26+.PHONY: lint
27+
28+test:
29+	go test ./...
30+.PHONY: test
31+
32+# Update flake.nix vendorHash when go.mod/go.sum changes
33+# This target depends on go.mod and go.sum, so it will only run when they change
34+update-vendor-hash: flake.nix go.mod go.sum
35+	@echo "Checking if vendorHash needs update..."
36+	@nix build 2>&1 | tee /tmp/nix-build.log; \
37+	if grep -q "hash mismatch" /tmp/nix-build.log; then \
38+		NEW_HASH=$$(grep "got:" /tmp/nix-build.log | sed 's/.*got: *//' | tail -1); \
39+		echo "Updating vendorHash to $$NEW_HASH"; \
40+		sed -i "s|vendorHash = \"sha256-[^\"]*\";|vendorHash = \"$$NEW_HASH\";|" flake.nix; \
41+		sed -i "s|vendorHash = pkgs.lib.fakeHash;|vendorHash = \"$$NEW_HASH\";|" flake.nix; \
42+	elif grep -q "error:.*fakeHash" /tmp/nix-build.log; then \
43+		NEW_HASH=$$(grep "got:" /tmp/nix-build.log | sed 's/.*got: *//' | tail -1); \
44+		echo "Updating vendorHash to $$NEW_HASH"; \
45+		sed -i "s|vendorHash = pkgs.lib.fakeHash;|vendorHash = \"$$NEW_HASH\";|" flake.nix; \
46+	else \
47+		echo "vendorHash is up to date"; \
48+	fi
49+	@rm -f /tmp/nix-build.log
50+	@touch update-vendor-hash
51+.PHONY: update-vendor-hash
52+
53+# Update flake.lock when flake.nix inputs change
54+update-flake-lock: flake.nix
55+	nix flake lock
56+	touch update-flake-lock
57+.PHONY: update-flake-lock
58+
59+# Update both vendor hash and flake lock
60+update-flake: update-vendor-hash update-flake-lock
61+	@echo "Flake updated successfully"
62+.PHONY: update-flake
A README.md
+161, -0
  1@@ -0,0 +1,161 @@
  2+# Bug CLI Tool
  3+
  4+A simplified CLI interface for [git-bug](https://github.com/git-bug/git-bug) issue tracking. Git-bug has its own CLI, but it's not intuitive to use.  This project implements an alternative API that is suitable for a user and an agent to use git-bug to collaborate on issues, while staying fully compatible with the original git-bug.
  5+
  6+## Quick Start
  7+
  8+Initialize git-bug in your repository:
  9+```bash
 10+bug init
 11+```
 12+
 13+This creates user and agent identities and configures git remotes for syncing bugs. Unlike git-bug, this sets up refspecs for remote tracking of bug references and identities.  You no longer need to `git push` and then `git-bug push`.  A simple `git push` or `git fetch` will also update tracked bugs.
 14+
 15+## Commands
 16+
 17+### List Issues
 18+
 19+Display all issues in a formatted table:
 20+```bash
 21+bug list              # List all open issues
 22+bug ls                # Same as above (alias)
 23+```
 24+
 25+**Filtering:**
 26+```bash
 27+bug list --filter label:bug              # Show only issues with label "bug"
 28+bug list --filter label:bug,critical     # Show issues with both labels
 29+bug list --filter age:<10d               # Show issues newer than 10 days
 30+bug list --filter age:>30d               # Show issues older than 30 days
 31+```
 32+
 33+**Sorting:**
 34+```bash
 35+bug list --sort id:asc                   # Sort by ID ascending
 36+bug list --sort id:desc                  # Sort by ID descending
 37+bug list --sort age:asc                  # Sort by age, oldest first
 38+bug list --sort age:desc                 # Sort by age, newest first (default)
 39+```
 40+
 41+**Status:**
 42+```bash
 43+bug list --status open                   # Show only open issues (default)
 44+bug list --status closed                 # Show only closed issues
 45+bug list --status all                    # Show all issues
 46+```
 47+
 48+### Create a New Issue
 49+
 50+**Using flags:**
 51+```bash
 52+bug new --title "Bug title" --message "Detailed description"
 53+bug new -t "Bug title" -m "Detailed description"
 54+```
 55+
 56+**Interactively (opens editor):**
 57+```bash
 58+bug new
 59+```
 60+
 61+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.
 62+
 63+### Read/Show an Issue
 64+
 65+Display an issue with all its metadata and comments:
 66+```bash
 67+bug read abc1234              # Display bug with ID prefix abc1234
 68+bug show abc1234              # Same as above (alias)
 69+bug read --repo /path abc1234 # Display bug from specific repository
 70+```
 71+
 72+Output includes:
 73+- Title, Author, Creation date, Status, Labels
 74+- Description (first comment)
 75+- All additional comments with their IDs
 76+
 77+### Add a Comment
 78+
 79+**Using flags:**
 80+```bash
 81+bug comment abc1234 --message "This is my comment"
 82+bug comment abc1234 -m "This is my comment"
 83+```
 84+
 85+**Interactively (opens editor):**
 86+```bash
 87+bug comment abc1234
 88+```
 89+
 90+### Edit an Issue or Comment
 91+
 92+**Edit an issue:**
 93+```bash
 94+bug edit abc1234                    # Open editor with current content
 95+bug edit abc1234 -t "New Title"     # Update only the title
 96+bug edit abc1234 -m "New desc"      # Update only the description
 97+bug edit abc1234 -t "Title" -m "Desc"  # Update both
 98+```
 99+
100+**Edit a comment:**
101+```bash
102+bug edit def5678                    # Open editor with current comment text
103+bug edit def5678 -m "New text"      # Update the comment
104+```
105+
106+When using the editor:
107+- For issues: title on first line, blank line, then description
108+- For comments: edit the comment text directly
109+- Lines starting with `;;` are ignored (instructions)
110+
111+### Open/Close Issues
112+
113+**Open a closed issue:**
114+```bash
115+bug open abc1234
116+```
117+
118+**Close an open issue:**
119+```bash
120+bug close abc1234
121+```
122+
123+### Remove an Issue
124+
125+**WARNING: This action is permanent and cannot be undone.**
126+
127+```bash
128+bug rm abc1234                    # Remove bug with short ID
129+bug rm abc1234567890abcdef        # Remove bug with full ID
130+bug remove abc1234                # Same as above (alias)
131+```
132+
133+Note: Individual comments cannot be removed. Use `bug rm <bug-id>` to remove the entire issue including all comments.
134+
135+## IDs: Short vs Full
136+
137+Bug IDs are 64-character hex strings, but the CLI accepts shortened prefixes:
138+
139+- **Short ID**: First 7 characters (e.g., `abc1234`)
140+- **Full ID**: Complete 64-character ID
141+
142+Commands automatically resolve short IDs to full IDs. Use short IDs for convenience when interacting with bugs.
143+
144+## Working with a Repository in a Different Location
145+
146+All commands accept a `--repo` flag:
147+```bash
148+bug --repo /path/to/repo list
149+bug --repo /path/to/repo new --title "Title" --message "Message"
150+```
151+
152+By default, commands operate on the current directory (`.`).
153+
154+## Agent Collaboration
155+
156+This tool supports agent collaboration. Agents use commands under `bug agent` which are designed for non-interactive use. See SKILL.md for agent-specific documentation.  Copy the SKILL.md to `~/.agents/skills/working-with-bug`. It is designed for use with superpowers skills if colocated in `~/.agents/skills/superpowers/working-with-bug`.
157+
158+A typical flow is to define a complicated prompt in a git-bug issue, then tell the agent to `read bug [bugID] and come up with a plan`. It will then use the skill in coordination with the `superpowers/writing-plans` and `superpowers/executing-plans` skills to document its plan as a comment on the bug.  It will also add a summary after completion of development as a comment on the bug.  I like this workflow better because it retains information about which prompts/bugs led to which plans in a way that the typical superpowers flow of a `docs/plans` folder full of random markdown plans does not.
159+
160+## Syncing Bugs
161+
162+After `bug init`, git is configured to automatically sync bugs and identities when you push/fetch from the `origin` remote. Bugs are stored in git refs and will be synchronized with your team.
A SKILL.md
+308, -0
  1@@ -0,0 +1,308 @@
  2+---
  3+name: working-with-bug
  4+description: Use when collaborating with humans using the bug CLI tool to track and resolve issues
  5+---
  6+
  7+# Bug CLI Agent Collaboration
  8+
  9+## Overview
 10+
 11+The `bug` CLI tool provides a dual API for issue tracking:
 12+- **Human API** (`bug new`, `bug read`, etc.): Lenient, interactive, designed for human use
 13+- **Agent API** (`bug agent new`, `bug agent read`, etc.): Strict, non-interactive, designed for automation
 14+
 15+**Core principle:** Always use commands under `bug agent` when acting as an agent. These commands are non-interactive, require explicit flags, and use the agent identity created during `bug init`.
 16+
 17+**Announce at start:** "I'm using the bug-cli-agent skill to collaborate on this issue."
 18+
 19+## Typical Collaboration Workflow
 20+
 21+When a user asks you to work on a bug, follow this workflow:
 22+
 23+### Step 1: Read the Bug
 24+
 25+Use `bug agent read [bugID]` to fetch the bug details and all existing comments (which may include a previously documented plan):
 26+
 27+```bash
 28+bug agent read abc1234
 29+```
 30+
 31+**Important:** Use `bug agent read`, not `bug read`. The agent version is non-interactive and returns structured output suitable for parsing.
 32+
 33+### Step 2: Create an Implementation Plan
 34+
 35+**REQUIRED SUB-SKILL:** Invoke `superpowers:writing-plans` to create a comprehensive implementation plan.
 36+
 37+```
 38+Announce: "I'm using superpowers:writing-plans to create an implementation plan."
 39+```
 40+
 41+This will generate a detailed, bite-sized plan for resolving the bug.
 42+
 43+### Step 3: Document the Plan as a Comment
 44+
 45+Save the plan to the bug by adding it as a comment. Plans should be written in raw markdown format:
 46+
 47+```bash
 48+bug agent comment abc1234 --message "## Implementation Plan
 49+
 50+### Task 1: [Component Name]
 51+**Files:**
 52+- Create: path/to/file.ts
 53+- Test: path/to/test.ts
 54+
 55+**Step 1: Write the failing test**
 56+[code example]
 57+
 58+**Step 2: Run test to verify it fails**
 59+Run: command
 60+Expected: output"
 61+```
 62+
 63+For multi-line plans, the message flag accepts the full markdown content.
 64+
 65+### Step 4: Execute the Plan
 66+
 67+When ready to implement:
 68+
 69+1. **Read the bug again** to get the plan from the comments:
 70+   ```bash
 71+   bug agent read abc1234
 72+   ```
 73+
 74+2. **REQUIRED SUB-SKILL:** Invoke `superpowers:executing-plans` to execute the plan task-by-task:
 75+   ```
 76+   Announce: "I'm using superpowers:executing-plans to implement the documented plan."
 77+   ```
 78+
 79+3. **Update the bug** when work is complete with a summary of what was done using `bug agent comment`.
 80+
 81+4. **Do not close the bug** until the user checks the implementation and tells you to do so. Then use:
 82+   ```bash
 83+   bug agent close abc1234
 84+   ```
 85+
 86+## Agent API Reference
 87+
 88+All agent commands are non-interactive and require explicit flags. They use the agent identity (name: "agent", created during `bug init`).
 89+
 90+### bug agent read [bugID]
 91+
 92+Display a bug/issue and all its comments.
 93+
 94+```bash
 95+bug agent read abc1234
 96+```
 97+
 98+**Output includes:**
 99+- Title, Author, Creation date, Status, Labels
100+- Description (first comment)
101+- All additional comments with their IDs
102+
103+**Use this to:**
104+- Fetch bug details for analysis
105+- Retrieve documented plans from comments
106+- Check current status before acting
107+
108+### bug agent new --title "..." --message "..."
109+
110+Create a new bug/issue as the agent.
111+
112+```bash
113+bug agent new --title "CI Failure" --message "Build failed on commit abc123"
114+```
115+
116+**Required flags:**
117+- `--title`: Issue title
118+- `--message`: Issue description
119+
120+**Both flags are required.** This command is strictly non-interactive.
121+
122+### bug agent comment [bugID] --message "..."
123+
124+Add a comment to an existing bug.
125+
126+```bash
127+bug agent comment abc1234 --message "Automated analysis complete"
128+```
129+
130+**Required flag:**
131+- `--message`: Comment text (supports markdown)
132+
133+**Use this to:**
134+- Document implementation plans
135+- Add progress updates
136+- Record findings or analysis results
137+
138+**Tip:** For multi-line plans, write the markdown content directly after `--message`. The shell handles line breaks appropriately.
139+
140+### bug agent edit [bugID] --title "..." --message "..."
141+
142+Edit an issue's title and/or description.
143+
144+```bash
145+bug agent edit abc1234 --title "New Title"
146+bug agent edit abc1234 --message "New description"
147+bug agent edit abc1234 --title "Title" --message "Description"
148+```
149+
150+**Flags (at least one required):**
151+- `--title`: New issue title
152+- `--message`: New issue description
153+
154+**Note:** When editing bugs, at least one of `--title` or `--message` must be provided.
155+
156+### bug agent edit [commentID] --message "..."
157+
158+Edit an existing comment.
159+
160+```bash
161+bug agent edit def5678 --message "Updated comment text"
162+```
163+
164+**Required flag:**
165+- `--message`: New comment text
166+
167+**Important:** Comments have no title field. Only `--message` is accepted when editing a comment ID.
168+
169+### bug agent open [bugID]
170+
171+Open a closed bug/issue.
172+
173+```bash
174+bug agent open abc1234
175+```
176+
177+### bug agent close [bugID]
178+
179+Close an open bug/issue.
180+
181+```bash
182+bug agent close abc1234
183+```
184+
185+Use this when the issue is resolved.
186+
187+### bug agent rm [bugID]
188+
189+Remove a bug/issue.
190+
191+```bash
192+bug agent rm abc1234
193+bug agent remove abc1234
194+```
195+
196+**WARNING: This action is permanent.**
197+
198+**Note:** Individual comments cannot be removed. Use this to remove the entire issue.
199+
200+## Important Notes
201+
202+### Agent Identity
203+
204+The agent identity is created during `bug init` with:
205+- Name: "agent"
206+- Email: "" (empty)
207+
208+All `bug agent` commands use this identity. Do not attempt to create or modify the agent identity.
209+
210+### ID Formats
211+
212+Bug IDs are 64-character hex strings. The CLI accepts shortened prefixes:
213+- **Short ID**: First 7 characters (e.g., `abc1234`)
214+- **Full ID**: Complete 64-character ID
215+
216+Both formats work with all commands. Short IDs are preferred for convenience.
217+
218+### Working with a Repository in a Different Location
219+
220+All commands accept a `--repo` flag:
221+```bash
222+bug agent --repo /path/to/repo read abc1234
223+bug agent --repo /path/to/repo comment abc1234 --message "Update"
224+```
225+
226+By default, commands operate on the current directory (`.`).
227+
228+### Comment IDs vs Bug IDs
229+
230+When editing:
231+- **Bug IDs** (e.g., `abc1234`) refer to the entire issue
232+- **Comment IDs** (e.g., `def5678`) refer to individual comments
233+
234+The `bug agent edit` command automatically detects the type and behaves accordingly:
235+- For bugs: accepts `--title` and/or `--message`
236+- For comments: accepts only `--message`
237+
238+## Complete Workflow Example
239+
240+**Scenario:** User asks "Can you fix the bug with ID abc1234?"
241+
242+```bash
243+# Step 1: Read the bug
244+bug agent read abc1234
245+
246+# Output shows:
247+# Title: Memory leak in data processor
248+# Author: [email protected]
249+# Status: open
250+# Description: The data processor leaks memory when processing large files...
251+
252+# Step 2: Invoke writing-plans skill
253+Announce: "I'm using superpowers:writing-plans to create an implementation plan."
254+
255+# Step 3: Document the plan as a comment
256+bug agent comment abc1234 --message "## Fix: Memory leak in data processor
257+
258+### Task 1: Add failing test
259+**Files:**
260+- Create: test/memory_test.go
261+- Test: TestMemoryLeak
262+
263+**Step 1: Write failing test**
264+[code]
265+
266+**Step 2: Verify test fails**
267+Run: go test -v ./test
268+Expected: FAIL: memory leak detected
269+
270+### Task 2: Implement fix
271+..."
272+
273+# Step 4: When ready to execute, read the bug again
274+bug agent read abc1234
275+
276+# Step 5: Invoke executing-plans skill
277+Announce: "I'm using superpowers:executing-plans to implement the documented plan."
278+
279+# Step 6: Add progress comments as needed
280+bug agent comment abc1234 --message "Task 1 complete: Added failing test in commit abc1234"
281+
282+# Step 7: Close the bug when complete
283+bug agent close abc1234
284+```
285+
286+## Integration with Superpowers Skills
287+
288+**REQUIRED SUB-SKILLS:**
289+
290+- **superpowers:writing-plans** - REQUIRED for creating implementation plans
291+  - Creates bite-sized, detailed tasks
292+  - Documents exact files, code, and verification steps
293+  - Use when analyzing a bug before implementing
294+
295+- **superpowers:executing-plans** - REQUIRED for implementing documented plans
296+  - Executes tasks in batches with review checkpoints
297+  - Follows the plan exactly as documented in bug comments
298+  - Use when implementing a plan previously documented on a bug
299+
300+## Error Handling
301+
302+If a command fails:
303+
304+1. **Read the error message** - it includes guidance on what to do next
305+2. **Common issues:**
306+   - "agent identity not found" → Run `bug init` first
307+   - "failed to resolve bug ID" → Check the ID is correct
308+   - "title/message cannot be empty" → Ensure required flags are provided
309+3. **Use `bug agent read`** to verify the current state before retrying
A agent_comment_integration_test.go
+493, -0
  1@@ -0,0 +1,493 @@
  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+// TestAgentCommentCommand_WithFlag adds a comment as agent using the message flag
 17+func TestAgentCommentCommand_WithFlag(t *testing.T) {
 18+	tmpDir := t.TempDir()
 19+
 20+	// Initialize a git repo
 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+	// Initialize git-bug with both user and agent identities
 28+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
 29+		t.Fatalf("failed to create user identity: %v", err)
 30+	}
 31+	if err := createIdentity(tmpDir, "agent", "", false); err != nil {
 32+		t.Fatalf("failed to create agent identity: %v", err)
 33+	}
 34+
 35+	// Create a bug first as user
 36+	title := "Test Bug for Agent Comment"
 37+	message := "Initial bug description"
 38+	if err := runNew(tmpDir, title, message); err != nil {
 39+		t.Fatalf("runNew failed: %v", err)
 40+	}
 41+
 42+	// Get the bug ID
 43+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
 44+	if err != nil {
 45+		t.Fatalf("failed to open repo: %v", err)
 46+	}
 47+
 48+	var bugID string
 49+	for streamedBug := range bug.ReadAll(repo) {
 50+		if streamedBug.Err != nil {
 51+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
 52+		}
 53+		b := streamedBug.Entity
 54+		snap := b.Compile()
 55+		if snap.Title == title {
 56+			bugID = b.Id().String()
 57+			break
 58+		}
 59+	}
 60+	repo.Close()
 61+
 62+	if bugID == "" {
 63+		t.Fatal("could not find created bug")
 64+	}
 65+
 66+	// Add a comment as agent using the flag
 67+	commentText := "This is an automated agent comment"
 68+	if err := runAgentComment(tmpDir, bugID, commentText); err != nil {
 69+		t.Fatalf("runAgentComment failed: %v", err)
 70+	}
 71+
 72+	// Verify comment was added with agent as author
 73+	repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
 74+	if err != nil {
 75+		t.Fatalf("failed to open repo: %v", err)
 76+	}
 77+	defer repo.Close()
 78+
 79+	b, err := bug.Read(repo, entity.Id(bugID))
 80+	if err != nil {
 81+		t.Fatalf("failed to read bug: %v", err)
 82+	}
 83+
 84+	snap := b.Compile()
 85+	if len(snap.Comments) != 2 {
 86+		t.Errorf("expected 2 comments (original + new), got %d", len(snap.Comments))
 87+	}
 88+
 89+	// Check the second comment (index 1) is our agent comment
 90+	if snap.Comments[1].Message != commentText {
 91+		t.Errorf("comment message = %q, want %q", snap.Comments[1].Message, commentText)
 92+	}
 93+
 94+	if snap.Comments[1].Author.Name() != "agent" {
 95+		t.Errorf("comment author = %q, want %q", snap.Comments[1].Author.Name(), "agent")
 96+	}
 97+
 98+	if snap.Comments[1].Author.Email() != "" {
 99+		t.Errorf("comment author email = %q, want empty", snap.Comments[1].Author.Email())
100+	}
101+}
102+
103+// TestAgentCommentCommand_WithShortID adds a comment as agent using short bug ID
104+func TestAgentCommentCommand_WithShortID(t *testing.T) {
105+	tmpDir := t.TempDir()
106+
107+	// Initialize a git repo
108+	initCmd := exec.Command("git", "init")
109+	initCmd.Dir = tmpDir
110+	if err := initCmd.Run(); err != nil {
111+		t.Fatalf("failed to init git repo: %v", err)
112+	}
113+
114+	// Initialize identities
115+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
116+		t.Fatalf("failed to create user identity: %v", err)
117+	}
118+	if err := createIdentity(tmpDir, "agent", "", false); err != nil {
119+		t.Fatalf("failed to create agent identity: %v", err)
120+	}
121+
122+	// Create a bug
123+	title := "Test Bug for Agent Short ID"
124+	if err := runNew(tmpDir, title, ""); err != nil {
125+		t.Fatalf("runNew failed: %v", err)
126+	}
127+
128+	// Get the bug ID and short ID
129+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
130+	if err != nil {
131+		t.Fatalf("failed to open repo: %v", err)
132+	}
133+
134+	var bugID, shortID string
135+	for streamedBug := range bug.ReadAll(repo) {
136+		if streamedBug.Err != nil {
137+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
138+		}
139+		b := streamedBug.Entity
140+		snap := b.Compile()
141+		if snap.Title == title {
142+			bugID = b.Id().String()
143+			if len(bugID) > 7 {
144+				shortID = bugID[:7]
145+			} else {
146+				shortID = bugID
147+			}
148+			break
149+		}
150+	}
151+	repo.Close()
152+
153+	if shortID == "" {
154+		t.Fatal("could not find created bug")
155+	}
156+
157+	// Add a comment as agent using short ID
158+	commentText := "Agent comment via short ID"
159+	if err := runAgentComment(tmpDir, shortID, commentText); err != nil {
160+		t.Fatalf("runAgentComment with short ID failed: %v", err)
161+	}
162+
163+	// Verify comment was added
164+	repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
165+	if err != nil {
166+		t.Fatalf("failed to open repo: %v", err)
167+	}
168+	defer repo.Close()
169+
170+	b, err := bug.Read(repo, entity.Id(bugID))
171+	if err != nil {
172+		t.Fatalf("failed to read bug: %v", err)
173+	}
174+
175+	snap := b.Compile()
176+	if len(snap.Comments) != 2 {
177+		t.Errorf("expected 2 comments, got %d", len(snap.Comments))
178+	}
179+
180+	if snap.Comments[1].Message != commentText {
181+		t.Errorf("comment message = %q, want %q", snap.Comments[1].Message, commentText)
182+	}
183+
184+	if snap.Comments[1].Author.Name() != "agent" {
185+		t.Errorf("comment author = %q, want %q", snap.Comments[1].Author.Name(), "agent")
186+	}
187+}
188+
189+// TestAgentCommentCommand_MissingAgentIdentity tests error when agent not initialized
190+func TestAgentCommentCommand_MissingAgentIdentity(t *testing.T) {
191+	tmpDir := t.TempDir()
192+
193+	// Initialize a git repo
194+	initCmd := exec.Command("git", "init")
195+	initCmd.Dir = tmpDir
196+	if err := initCmd.Run(); err != nil {
197+		t.Fatalf("failed to init git repo: %v", err)
198+	}
199+
200+	// Only create user identity, NOT agent
201+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
202+		t.Fatalf("failed to create user identity: %v", err)
203+	}
204+
205+	// Create a bug as user
206+	title := "Test Bug"
207+	if err := runNew(tmpDir, title, ""); err != nil {
208+		t.Fatalf("runNew failed: %v", err)
209+	}
210+
211+	// Get the bug ID
212+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
213+	if err != nil {
214+		t.Fatalf("failed to open repo: %v", err)
215+	}
216+
217+	var bugID string
218+	for streamedBug := range bug.ReadAll(repo) {
219+		if streamedBug.Err != nil {
220+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
221+		}
222+		b := streamedBug.Entity
223+		snap := b.Compile()
224+		if snap.Title == title {
225+			bugID = b.Id().String()
226+			break
227+		}
228+	}
229+	repo.Close()
230+
231+	// Try to add a comment as agent (should fail)
232+	err = runAgentComment(tmpDir, bugID, "Test comment")
233+	if err == nil {
234+		t.Error("expected error when agent identity not found, got nil")
235+	}
236+
237+	if !strings.Contains(err.Error(), "agent identity not found") {
238+		t.Errorf("expected 'agent identity not found' error, got: %v", err)
239+	}
240+}
241+
242+// TestAgentCommentCommand_EmptyMessageError tests error for empty message
243+func TestAgentCommentCommand_EmptyMessageError(t *testing.T) {
244+	tmpDir := t.TempDir()
245+
246+	// Initialize a git repo
247+	initCmd := exec.Command("git", "init")
248+	initCmd.Dir = tmpDir
249+	if err := initCmd.Run(); err != nil {
250+		t.Fatalf("failed to init git repo: %v", err)
251+	}
252+
253+	// Initialize identities
254+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
255+		t.Fatalf("failed to create user identity: %v", err)
256+	}
257+	if err := createIdentity(tmpDir, "agent", "", false); err != nil {
258+		t.Fatalf("failed to create agent identity: %v", err)
259+	}
260+
261+	// Create a bug
262+	title := "Test Bug"
263+	if err := runNew(tmpDir, title, ""); err != nil {
264+		t.Fatalf("runNew failed: %v", err)
265+	}
266+
267+	// Get the bug ID
268+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
269+	if err != nil {
270+		t.Fatalf("failed to open repo: %v", err)
271+	}
272+
273+	var bugID string
274+	for streamedBug := range bug.ReadAll(repo) {
275+		if streamedBug.Err != nil {
276+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
277+		}
278+		b := streamedBug.Entity
279+		snap := b.Compile()
280+		if snap.Title == title {
281+			bugID = b.Id().String()
282+			break
283+		}
284+	}
285+	repo.Close()
286+
287+	// Try with empty message
288+	err = runAgentComment(tmpDir, bugID, "   ")
289+	if err == nil {
290+		t.Error("expected error for empty message, got nil")
291+	}
292+
293+	if !strings.Contains(err.Error(), "message cannot be empty") {
294+		t.Errorf("expected 'message cannot be empty' error, got: %v", err)
295+	}
296+}
297+
298+// TestAgentCommentCommand_InvalidBugID tests error for invalid bug ID
299+func TestAgentCommentCommand_InvalidBugID(t *testing.T) {
300+	tmpDir := t.TempDir()
301+
302+	// Initialize a git repo
303+	initCmd := exec.Command("git", "init")
304+	initCmd.Dir = tmpDir
305+	if err := initCmd.Run(); err != nil {
306+		t.Fatalf("failed to init git repo: %v", err)
307+	}
308+
309+	// Initialize identities
310+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
311+		t.Fatalf("failed to create user identity: %v", err)
312+	}
313+	if err := createIdentity(tmpDir, "agent", "", false); err != nil {
314+		t.Fatalf("failed to create agent identity: %v", err)
315+	}
316+
317+	// Try to add a comment to non-existent bug
318+	err := runAgentComment(tmpDir, "nonexistent", "Test comment")
319+	if err == nil {
320+		t.Error("expected error for invalid bug ID, got nil")
321+	}
322+
323+	if !strings.Contains(err.Error(), "failed to resolve bug ID") {
324+		t.Errorf("expected 'failed to resolve bug ID' error, got: %v", err)
325+	}
326+}
327+
328+// TestAgentCommentCommand_MultipleComments tests adding multiple agent comments
329+func TestAgentCommentCommand_MultipleComments(t *testing.T) {
330+	tmpDir := t.TempDir()
331+
332+	// Initialize a git repo
333+	initCmd := exec.Command("git", "init")
334+	initCmd.Dir = tmpDir
335+	if err := initCmd.Run(); err != nil {
336+		t.Fatalf("failed to init git repo: %v", err)
337+	}
338+
339+	// Initialize identities
340+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
341+		t.Fatalf("failed to create user identity: %v", err)
342+	}
343+	if err := createIdentity(tmpDir, "agent", "", false); err != nil {
344+		t.Fatalf("failed to create agent identity: %v", err)
345+	}
346+
347+	// Create a bug as user
348+	title := "Test Bug for Multiple Agent Comments"
349+	if err := runNew(tmpDir, title, ""); err != nil {
350+		t.Fatalf("runNew failed: %v", err)
351+	}
352+
353+	// Get the bug ID
354+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
355+	if err != nil {
356+		t.Fatalf("failed to open repo: %v", err)
357+	}
358+
359+	var bugID string
360+	for streamedBug := range bug.ReadAll(repo) {
361+		if streamedBug.Err != nil {
362+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
363+		}
364+		b := streamedBug.Entity
365+		snap := b.Compile()
366+		if snap.Title == title {
367+			bugID = b.Id().String()
368+			break
369+		}
370+	}
371+	repo.Close()
372+
373+	// Add multiple comments as agent
374+	comments := []string{
375+		"First agent comment",
376+		"Second agent comment",
377+		"Third agent comment with **markdown**",
378+	}
379+
380+	for _, comment := range comments {
381+		if err := runAgentComment(tmpDir, bugID, comment); err != nil {
382+			t.Fatalf("runAgentComment failed: %v", err)
383+		}
384+	}
385+
386+	// Verify all comments were added
387+	repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
388+	if err != nil {
389+		t.Fatalf("failed to open repo: %v", err)
390+	}
391+	defer repo.Close()
392+
393+	b, err := bug.Read(repo, entity.Id(bugID))
394+	if err != nil {
395+		t.Fatalf("failed to read bug: %v", err)
396+	}
397+
398+	snap := b.Compile()
399+	// Original + 3 comments = 4 total
400+	if len(snap.Comments) != 4 {
401+		t.Errorf("expected 4 comments, got %d", len(snap.Comments))
402+	}
403+
404+	// Verify each comment (index 1-3 are our added comments)
405+	for i, expected := range comments {
406+		if snap.Comments[i+1].Message != expected {
407+			t.Errorf("comment %d message = %q, want %q", i+1, snap.Comments[i+1].Message, expected)
408+		}
409+		if snap.Comments[i+1].Author.Name() != "agent" {
410+			t.Errorf("comment %d author = %q, want %q", i+1, snap.Comments[i+1].Author.Name(), "agent")
411+		}
412+	}
413+}
414+
415+// TestAgentCommentCommand_WithMarkdown tests that markdown is preserved
416+func TestAgentCommentCommand_WithMarkdown(t *testing.T) {
417+	tmpDir := t.TempDir()
418+
419+	// Initialize a git repo
420+	initCmd := exec.Command("git", "init")
421+	initCmd.Dir = tmpDir
422+	if err := initCmd.Run(); err != nil {
423+		t.Fatalf("failed to init git repo: %v", err)
424+	}
425+
426+	// Initialize identities
427+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
428+		t.Fatalf("failed to create user identity: %v", err)
429+	}
430+	if err := createIdentity(tmpDir, "agent", "", false); err != nil {
431+		t.Fatalf("failed to create agent identity: %v", err)
432+	}
433+
434+	// Create a bug
435+	title := "Test Bug for Markdown"
436+	if err := runNew(tmpDir, title, ""); err != nil {
437+		t.Fatalf("runNew failed: %v", err)
438+	}
439+
440+	// Get the bug ID
441+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
442+	if err != nil {
443+		t.Fatalf("failed to open repo: %v", err)
444+	}
445+
446+	var bugID string
447+	for streamedBug := range bug.ReadAll(repo) {
448+		if streamedBug.Err != nil {
449+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
450+		}
451+		b := streamedBug.Entity
452+		snap := b.Compile()
453+		if snap.Title == title {
454+			bugID = b.Id().String()
455+			break
456+		}
457+	}
458+	repo.Close()
459+
460+	// Add a comment with markdown
461+	markdownComment := `# Analysis Report
462+
463+## Summary
464+- **Status**: PASSED
465+- **Duration**: 5m 32s
466+
467+## Details
468+All tests passed successfully.`
469+
470+	if err := runAgentComment(tmpDir, bugID, markdownComment); err != nil {
471+		t.Fatalf("runAgentComment failed: %v", err)
472+	}
473+
474+	// Verify comment was added with markdown preserved
475+	repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
476+	if err != nil {
477+		t.Fatalf("failed to open repo: %v", err)
478+	}
479+	defer repo.Close()
480+
481+	b, err := bug.Read(repo, entity.Id(bugID))
482+	if err != nil {
483+		t.Fatalf("failed to read bug: %v", err)
484+	}
485+
486+	snap := b.Compile()
487+	if len(snap.Comments) != 2 {
488+		t.Errorf("expected 2 comments, got %d", len(snap.Comments))
489+	}
490+
491+	if snap.Comments[1].Message != markdownComment {
492+		t.Errorf("comment message does not match expected markdown:\ngot:\n%s\nwant:\n%s", snap.Comments[1].Message, markdownComment)
493+	}
494+}
A agent_edit_integration_test.go
+400, -0
  1@@ -0,0 +1,400 @@
  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+// TestAgentEditCommand_EditIssue edits an issue as the agent
 17+func TestAgentEditCommand_EditIssue(t *testing.T) {
 18+	tmpDir := t.TempDir()
 19+
 20+	// Initialize a git repo
 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+	// Add origin remote for bug init
 27+	remoteCmd := exec.Command("git", "remote", "add", "origin", "[email protected]:test/repo.git")
 28+	remoteCmd.Dir = tmpDir
 29+	if err := remoteCmd.Run(); err != nil {
 30+		t.Fatalf("failed to add origin remote: %v", err)
 31+	}
 32+
 33+	// Initialize git-bug identities (creates both user and agent)
 34+	if err := runInitWithReader(tmpDir, strings.NewReader("Test User\[email protected]\n")); err != nil {
 35+		t.Fatalf("runInit failed: %v", err)
 36+	}
 37+
 38+	// Create a bug as agent
 39+	if err := runAgentNew(tmpDir, "Original Title", "Original description"); err != nil {
 40+		t.Fatalf("runAgentNew failed: %v", err)
 41+	}
 42+
 43+	// Get the bug ID
 44+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
 45+	if err != nil {
 46+		t.Fatalf("failed to open repo: %v", err)
 47+	}
 48+
 49+	var bugID string
 50+	for streamedBug := range bug.ReadAll(repo) {
 51+		if streamedBug.Err != nil {
 52+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
 53+		}
 54+		b := streamedBug.Entity
 55+		snap := b.Compile()
 56+		if snap.Title == "Original Title" {
 57+			bugID = b.Id().String()
 58+			break
 59+		}
 60+	}
 61+	repo.Close()
 62+
 63+	if bugID == "" {
 64+		t.Fatal("could not find created bug")
 65+	}
 66+
 67+	// Edit as agent
 68+	newTitle := "Agent Updated Title"
 69+	newMessage := "Agent updated description"
 70+	if err := runAgentEdit(tmpDir, bugID, newTitle, newMessage); err != nil {
 71+		t.Fatalf("runAgentEdit failed: %v", err)
 72+	}
 73+
 74+	// Verify both were updated
 75+	repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
 76+	if err != nil {
 77+		t.Fatalf("failed to open repo: %v", err)
 78+	}
 79+	defer repo.Close()
 80+
 81+	b, err := bug.Read(repo, entity.Id(bugID))
 82+	if err != nil {
 83+		t.Fatalf("failed to read bug: %v", err)
 84+	}
 85+
 86+	snap := b.Compile()
 87+	if snap.Title != newTitle {
 88+		t.Errorf("title = %q, want %q", snap.Title, newTitle)
 89+	}
 90+	if len(snap.Comments) == 0 || snap.Comments[0].Message != newMessage {
 91+		t.Errorf("description = %q, want %q", snap.Comments[0].Message, newMessage)
 92+	}
 93+}
 94+
 95+// TestAgentEditCommand_EditComment edits a comment as the agent
 96+func TestAgentEditCommand_EditComment(t *testing.T) {
 97+	tmpDir := t.TempDir()
 98+
 99+	// Initialize a git repo
100+	initCmd := exec.Command("git", "init")
101+	initCmd.Dir = tmpDir
102+	if err := initCmd.Run(); err != nil {
103+		t.Fatalf("failed to init git repo: %v", err)
104+	}
105+	// Add origin remote for bug init
106+	remoteCmd := exec.Command("git", "remote", "add", "origin", "[email protected]:test/repo.git")
107+	remoteCmd.Dir = tmpDir
108+	if err := remoteCmd.Run(); err != nil {
109+		t.Fatalf("failed to add origin remote: %v", err)
110+	}
111+
112+	// Initialize git-bug identities
113+	if err := runInitWithReader(tmpDir, strings.NewReader("Test User\[email protected]\n")); err != nil {
114+		t.Fatalf("runInit failed: %v", err)
115+	}
116+
117+	// Create a bug as agent
118+	if err := runAgentNew(tmpDir, "Test Bug", "Description"); err != nil {
119+		t.Fatalf("runAgentNew failed: %v", err)
120+	}
121+
122+	// Get the bug ID
123+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
124+	if err != nil {
125+		t.Fatalf("failed to open repo: %v", err)
126+	}
127+
128+	var bugID string
129+	for streamedBug := range bug.ReadAll(repo) {
130+		if streamedBug.Err != nil {
131+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
132+		}
133+		b := streamedBug.Entity
134+		snap := b.Compile()
135+		if snap.Title == "Test Bug" {
136+			bugID = b.Id().String()
137+			break
138+		}
139+	}
140+	repo.Close()
141+
142+	// Add a comment as agent
143+	originalComment := "Original agent comment"
144+	if err := runAgentComment(tmpDir, bugID, originalComment); err != nil {
145+		t.Fatalf("runAgentComment failed: %v", err)
146+	}
147+
148+	// Get the comment ID
149+	repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
150+	if err != nil {
151+		t.Fatalf("failed to open repo: %v", err)
152+	}
153+
154+	b, err := bug.Read(repo, entity.Id(bugID))
155+	if err != nil {
156+		t.Fatalf("failed to read bug: %v", err)
157+	}
158+	snap := b.Compile()
159+	if len(snap.Comments) < 2 {
160+		t.Fatal("expected at least 2 comments")
161+	}
162+	commentID := snap.Comments[1].CombinedId().String()
163+	repo.Close()
164+
165+	// Edit the comment as agent
166+	newComment := "Updated agent comment"
167+	if err := runAgentEdit(tmpDir, commentID, "", newComment); err != nil {
168+		t.Fatalf("runAgentEdit failed: %v", err)
169+	}
170+
171+	// Verify comment was updated
172+	repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
173+	if err != nil {
174+		t.Fatalf("failed to open repo: %v", err)
175+	}
176+	defer repo.Close()
177+
178+	b, err = bug.Read(repo, entity.Id(bugID))
179+	if err != nil {
180+		t.Fatalf("failed to read bug: %v", err)
181+	}
182+
183+	snap = b.Compile()
184+	if len(snap.Comments) < 2 {
185+		t.Fatal("expected at least 2 comments")
186+	}
187+	if snap.Comments[1].Message != newComment {
188+		t.Errorf("comment = %q, want %q", snap.Comments[1].Message, newComment)
189+	}
190+}
191+
192+// TestAgentEditCommand_RequiresMessage tests that agent edit requires at least one field
193+func TestAgentEditCommand_RequiresMessage(t *testing.T) {
194+	tmpDir := t.TempDir()
195+
196+	// Initialize a git repo
197+	initCmd := exec.Command("git", "init")
198+	initCmd.Dir = tmpDir
199+	if err := initCmd.Run(); err != nil {
200+		t.Fatalf("failed to init git repo: %v", err)
201+	}
202+	// Add origin remote for bug init
203+	remoteCmd := exec.Command("git", "remote", "add", "origin", "[email protected]:test/repo.git")
204+	remoteCmd.Dir = tmpDir
205+	if err := remoteCmd.Run(); err != nil {
206+		t.Fatalf("failed to add origin remote: %v", err)
207+	}
208+
209+	// Initialize git-bug identities
210+	if err := runInitWithReader(tmpDir, strings.NewReader("Test User\[email protected]\n")); err != nil {
211+		t.Fatalf("runInit failed: %v", err)
212+	}
213+
214+	// Create a bug as agent
215+	if err := runAgentNew(tmpDir, "Test Bug", "Description"); err != nil {
216+		t.Fatalf("runAgentNew failed: %v", err)
217+	}
218+
219+	// Get the bug ID
220+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
221+	if err != nil {
222+		t.Fatalf("failed to open repo: %v", err)
223+	}
224+
225+	var bugID string
226+	for streamedBug := range bug.ReadAll(repo) {
227+		if streamedBug.Err != nil {
228+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
229+		}
230+		b := streamedBug.Entity
231+		snap := b.Compile()
232+		if snap.Title == "Test Bug" {
233+			bugID = b.Id().String()
234+			break
235+		}
236+	}
237+	repo.Close()
238+
239+	// Add a comment as agent
240+	if err := runAgentComment(tmpDir, bugID, "Original comment"); err != nil {
241+		t.Fatalf("runAgentComment failed: %v", err)
242+	}
243+
244+	// Get the comment ID
245+	repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
246+	if err != nil {
247+		t.Fatalf("failed to open repo: %v", err)
248+	}
249+
250+	b, err := bug.Read(repo, entity.Id(bugID))
251+	if err != nil {
252+		t.Fatalf("failed to read bug: %v", err)
253+	}
254+	snap := b.Compile()
255+	commentID := snap.Comments[1].CombinedId().String()
256+	repo.Close()
257+
258+	// Try to edit comment without message
259+	err = runAgentEdit(tmpDir, commentID, "", "")
260+	if err == nil {
261+		t.Error("expected error when editing comment without message, got nil")
262+	}
263+
264+	if !strings.Contains(err.Error(), "either --title or --message") {
265+		t.Errorf("expected 'either --title or --message' error, got: %v", err)
266+	}
267+}
268+
269+// TestAgentEditCommand_RequiresAtLeastOneField tests that agent edit requires at least one field
270+func TestAgentEditCommand_RequiresAtLeastOneField(t *testing.T) {
271+	tmpDir := t.TempDir()
272+
273+	// Initialize a git repo
274+	initCmd := exec.Command("git", "init")
275+	initCmd.Dir = tmpDir
276+	if err := initCmd.Run(); err != nil {
277+		t.Fatalf("failed to init git repo: %v", err)
278+	}
279+	// Add origin remote for bug init
280+	remoteCmd := exec.Command("git", "remote", "add", "origin", "[email protected]:test/repo.git")
281+	remoteCmd.Dir = tmpDir
282+	if err := remoteCmd.Run(); err != nil {
283+		t.Fatalf("failed to add origin remote: %v", err)
284+	}
285+
286+	// Initialize git-bug identities
287+	if err := runInitWithReader(tmpDir, strings.NewReader("Test User\[email protected]\n")); err != nil {
288+		t.Fatalf("runInit failed: %v", err)
289+	}
290+
291+	// Create a bug as agent
292+	if err := runAgentNew(tmpDir, "Test Bug", "Description"); err != nil {
293+		t.Fatalf("runAgentNew failed: %v", err)
294+	}
295+
296+	// Get the bug ID
297+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
298+	if err != nil {
299+		t.Fatalf("failed to open repo: %v", err)
300+	}
301+
302+	var bugID string
303+	for streamedBug := range bug.ReadAll(repo) {
304+		if streamedBug.Err != nil {
305+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
306+		}
307+		b := streamedBug.Entity
308+		snap := b.Compile()
309+		if snap.Title == "Test Bug" {
310+			bugID = b.Id().String()
311+			break
312+		}
313+	}
314+	repo.Close()
315+
316+	// Try to edit without any flags
317+	err = runAgentEdit(tmpDir, bugID, "", "")
318+	if err == nil {
319+		t.Error("expected error when editing without any fields, got nil")
320+	}
321+
322+	if !strings.Contains(err.Error(), "either --title or --message") {
323+		t.Errorf("expected 'either --title or --message' error, got: %v", err)
324+	}
325+}
326+
327+// TestAgentEditCommand_OnlyTitle edits only the title as agent
328+func TestAgentEditCommand_OnlyTitle(t *testing.T) {
329+	tmpDir := t.TempDir()
330+
331+	// Initialize a git repo
332+	initCmd := exec.Command("git", "init")
333+	initCmd.Dir = tmpDir
334+	if err := initCmd.Run(); err != nil {
335+		t.Fatalf("failed to init git repo: %v", err)
336+	}
337+	// Add origin remote for bug init
338+	remoteCmd := exec.Command("git", "remote", "add", "origin", "[email protected]:test/repo.git")
339+	remoteCmd.Dir = tmpDir
340+	if err := remoteCmd.Run(); err != nil {
341+		t.Fatalf("failed to add origin remote: %v", err)
342+	}
343+
344+	// Initialize git-bug identities
345+	if err := runInitWithReader(tmpDir, strings.NewReader("Test User\[email protected]\n")); err != nil {
346+		t.Fatalf("runInit failed: %v", err)
347+	}
348+
349+	// Create a bug as agent
350+	originalTitle := "Original Title"
351+	originalDesc := "Original description"
352+	if err := runAgentNew(tmpDir, originalTitle, originalDesc); err != nil {
353+		t.Fatalf("runAgentNew failed: %v", err)
354+	}
355+
356+	// Get the bug ID
357+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
358+	if err != nil {
359+		t.Fatalf("failed to open repo: %v", err)
360+	}
361+
362+	var bugID string
363+	for streamedBug := range bug.ReadAll(repo) {
364+		if streamedBug.Err != nil {
365+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
366+		}
367+		b := streamedBug.Entity
368+		snap := b.Compile()
369+		if snap.Title == originalTitle {
370+			bugID = b.Id().String()
371+			break
372+		}
373+	}
374+	repo.Close()
375+
376+	// Edit only title as agent
377+	newTitle := "New Title"
378+	if err := runAgentEdit(tmpDir, bugID, newTitle, ""); err != nil {
379+		t.Fatalf("runAgentEdit failed: %v", err)
380+	}
381+
382+	// Verify only title changed
383+	repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
384+	if err != nil {
385+		t.Fatalf("failed to open repo: %v", err)
386+	}
387+	defer repo.Close()
388+
389+	b, err := bug.Read(repo, entity.Id(bugID))
390+	if err != nil {
391+		t.Fatalf("failed to read bug: %v", err)
392+	}
393+
394+	snap := b.Compile()
395+	if snap.Title != newTitle {
396+		t.Errorf("title = %q, want %q", snap.Title, newTitle)
397+	}
398+	if len(snap.Comments) == 0 || snap.Comments[0].Message != originalDesc {
399+		t.Errorf("description was changed unexpectedly")
400+	}
401+}
A agent_integration_test.go
+233, -0
  1@@ -0,0 +1,233 @@
  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/repository"
 13+)
 14+
 15+// TestAgentNewCommand_WithFlags creates a bug as agent using flags
 16+func TestAgentNewCommand_WithFlags(t *testing.T) {
 17+	tmpDir := t.TempDir()
 18+
 19+	// Initialize a git repo
 20+	initCmd := exec.Command("git", "init")
 21+	initCmd.Dir = tmpDir
 22+	if err := initCmd.Run(); err != nil {
 23+		t.Fatalf("failed to init git repo: %v", err)
 24+	}
 25+
 26+	// Initialize git-bug with both user and agent identities
 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+	// Create a bug as agent using flags
 35+	title := "Agent Created Bug"
 36+	message := "This bug was created by an automated agent"
 37+	if err := runAgentNew(tmpDir, title, message); err != nil {
 38+		t.Fatalf("runAgentNew failed: %v", err)
 39+	}
 40+
 41+	// Verify bug was created with agent as author
 42+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
 43+	if err != nil {
 44+		t.Fatalf("failed to open repo: %v", err)
 45+	}
 46+	defer repo.Close()
 47+
 48+	// Count bugs
 49+	bugCount := 0
 50+	for streamedBug := range bug.ReadAll(repo) {
 51+		if streamedBug.Err != nil {
 52+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
 53+		}
 54+		b := streamedBug.Entity
 55+		snap := b.Compile()
 56+
 57+		if snap.Title == title {
 58+			bugCount++
 59+			if snap.Comments[0].Message != message {
 60+				t.Errorf("message = %q, want %q", snap.Comments[0].Message, message)
 61+			}
 62+			if snap.Author.Name() != "agent" {
 63+				t.Errorf("author = %q, want %q", snap.Author.Name(), "agent")
 64+			}
 65+			if snap.Author.Email() != "" {
 66+				t.Errorf("author email = %q, want empty", snap.Author.Email())
 67+			}
 68+		}
 69+	}
 70+
 71+	if bugCount != 1 {
 72+		t.Errorf("expected 1 bug with title %q, found %d", title, bugCount)
 73+	}
 74+}
 75+
 76+// TestAgentNewCommand_MissingAgentIdentity tests error when agent not initialized
 77+func TestAgentNewCommand_MissingAgentIdentity(t *testing.T) {
 78+	tmpDir := t.TempDir()
 79+
 80+	// Initialize a git repo
 81+	initCmd := exec.Command("git", "init")
 82+	initCmd.Dir = tmpDir
 83+	if err := initCmd.Run(); err != nil {
 84+		t.Fatalf("failed to init git repo: %v", err)
 85+	}
 86+
 87+	// Only create user identity, NOT agent
 88+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
 89+		t.Fatalf("failed to create user identity: %v", err)
 90+	}
 91+
 92+	// Try to create a bug as agent (should fail)
 93+	err := runAgentNew(tmpDir, "Title", "Message")
 94+	if err == nil {
 95+		t.Error("expected error when agent identity not found, got nil")
 96+	}
 97+
 98+	if !strings.Contains(err.Error(), "agent identity not found") {
 99+		t.Errorf("expected 'agent identity not found' error, got: %v", err)
100+	}
101+}
102+
103+// TestAgentNewCommand_EmptyTitleError tests error for empty title
104+func TestAgentNewCommand_EmptyTitleError(t *testing.T) {
105+	tmpDir := t.TempDir()
106+
107+	// Initialize a git repo
108+	initCmd := exec.Command("git", "init")
109+	initCmd.Dir = tmpDir
110+	if err := initCmd.Run(); err != nil {
111+		t.Fatalf("failed to init git repo: %v", err)
112+	}
113+
114+	// Initialize identities
115+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
116+		t.Fatalf("failed to create user identity: %v", err)
117+	}
118+	if err := createIdentity(tmpDir, "agent", "", false); err != nil {
119+		t.Fatalf("failed to create agent identity: %v", err)
120+	}
121+
122+	// Try with empty title
123+	err := runAgentNew(tmpDir, "   ", "Message")
124+	if err == nil {
125+		t.Error("expected error for empty title, got nil")
126+	}
127+
128+	if !strings.Contains(err.Error(), "title cannot be empty") {
129+		t.Errorf("expected 'title cannot be empty' error, got: %v", err)
130+	}
131+}
132+
133+// TestAgentNewCommand_EmptyMessageError tests error for empty message
134+func TestAgentNewCommand_EmptyMessageError(t *testing.T) {
135+	tmpDir := t.TempDir()
136+
137+	// Initialize a git repo
138+	initCmd := exec.Command("git", "init")
139+	initCmd.Dir = tmpDir
140+	if err := initCmd.Run(); err != nil {
141+		t.Fatalf("failed to init git repo: %v", err)
142+	}
143+
144+	// Initialize identities
145+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
146+		t.Fatalf("failed to create user identity: %v", err)
147+	}
148+	if err := createIdentity(tmpDir, "agent", "", false); err != nil {
149+		t.Fatalf("failed to create agent identity: %v", err)
150+	}
151+
152+	// Try with empty message
153+	err := runAgentNew(tmpDir, "Title", "   ")
154+	if err == nil {
155+		t.Error("expected error for empty message, got nil")
156+	}
157+
158+	if !strings.Contains(err.Error(), "message cannot be empty") {
159+		t.Errorf("expected 'message cannot be empty' error, got: %v", err)
160+	}
161+}
162+
163+// TestGetAgentIdentity_Success tests retrieving agent identity
164+func TestGetAgentIdentity_Success(t *testing.T) {
165+	tmpDir := t.TempDir()
166+
167+	// Initialize a git repo
168+	initCmd := exec.Command("git", "init")
169+	initCmd.Dir = tmpDir
170+	if err := initCmd.Run(); err != nil {
171+		t.Fatalf("failed to init git repo: %v", err)
172+	}
173+
174+	// Create user and agent identities
175+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
176+		t.Fatalf("failed to create user identity: %v", err)
177+	}
178+	if err := createIdentity(tmpDir, "agent", "", false); err != nil {
179+		t.Fatalf("failed to create agent identity: %v", err)
180+	}
181+
182+	// Open repo and get agent identity
183+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
184+	if err != nil {
185+		t.Fatalf("failed to open repo: %v", err)
186+	}
187+	defer repo.Close()
188+
189+	agent, err := getAgentIdentity(repo)
190+	if err != nil {
191+		t.Fatalf("getAgentIdentity failed: %v", err)
192+	}
193+
194+	if agent.Name() != "agent" {
195+		t.Errorf("agent name = %q, want %q", agent.Name(), "agent")
196+	}
197+
198+	if agent.Email() != "" {
199+		t.Errorf("agent email = %q, want empty", agent.Email())
200+	}
201+}
202+
203+// TestGetAgentIdentity_NotFound tests error when agent doesn't exist
204+func TestGetAgentIdentity_NotFound(t *testing.T) {
205+	tmpDir := t.TempDir()
206+
207+	// Initialize a git repo
208+	initCmd := exec.Command("git", "init")
209+	initCmd.Dir = tmpDir
210+	if err := initCmd.Run(); err != nil {
211+		t.Fatalf("failed to init git repo: %v", err)
212+	}
213+
214+	// Only create user identity
215+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
216+		t.Fatalf("failed to create user identity: %v", err)
217+	}
218+
219+	// Open repo and try to get agent identity
220+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
221+	if err != nil {
222+		t.Fatalf("failed to open repo: %v", err)
223+	}
224+	defer repo.Close()
225+
226+	_, err = getAgentIdentity(repo)
227+	if err == nil {
228+		t.Error("expected error when agent identity not found, got nil")
229+	}
230+
231+	if !strings.Contains(err.Error(), "agent identity not found") {
232+		t.Errorf("expected 'agent identity not found' error, got: %v", err)
233+	}
234+}
A comment_integration_test.go
+360, -0
  1@@ -0,0 +1,360 @@
  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+// TestCommentCommand_WithFlag adds a comment using the message flag
 17+func TestCommentCommand_WithFlag(t *testing.T) {
 18+	tmpDir := t.TempDir()
 19+
 20+	// Initialize a git repo
 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+	// Initialize git-bug identities
 28+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
 29+		t.Fatalf("failed to create identity: %v", err)
 30+	}
 31+
 32+	// Create a bug first
 33+	title := "Test Bug for Comment"
 34+	message := "Initial bug description"
 35+	if err := runNew(tmpDir, title, message); err != nil {
 36+		t.Fatalf("runNew failed: %v", err)
 37+	}
 38+
 39+	// Get the bug ID
 40+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
 41+	if err != nil {
 42+		t.Fatalf("failed to open repo: %v", err)
 43+	}
 44+
 45+	var bugID string
 46+	for streamedBug := range bug.ReadAll(repo) {
 47+		if streamedBug.Err != nil {
 48+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
 49+		}
 50+		b := streamedBug.Entity
 51+		snap := b.Compile()
 52+		if snap.Title == title {
 53+			bugID = b.Id().String()
 54+			break
 55+		}
 56+	}
 57+	repo.Close()
 58+
 59+	if bugID == "" {
 60+		t.Fatal("could not find created bug")
 61+	}
 62+
 63+	// Add a comment using the flag
 64+	commentText := "This is a test comment"
 65+	if err := runComment(tmpDir, bugID, commentText); err != nil {
 66+		t.Fatalf("runComment failed: %v", err)
 67+	}
 68+
 69+	// Verify comment was added
 70+	repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
 71+	if err != nil {
 72+		t.Fatalf("failed to open repo: %v", err)
 73+	}
 74+	defer repo.Close()
 75+
 76+	b, err := bug.Read(repo, entity.Id(bugID))
 77+	if err != nil {
 78+		t.Fatalf("failed to read bug: %v", err)
 79+	}
 80+
 81+	snap := b.Compile()
 82+	if len(snap.Comments) != 2 {
 83+		t.Errorf("expected 2 comments (original + new), got %d", len(snap.Comments))
 84+	}
 85+
 86+	// Check the second comment (index 1) is our new comment
 87+	if snap.Comments[1].Message != commentText {
 88+		t.Errorf("comment message = %q, want %q", snap.Comments[1].Message, commentText)
 89+	}
 90+
 91+	if snap.Comments[1].Author.Name() != "Test User" {
 92+		t.Errorf("comment author = %q, want %q", snap.Comments[1].Author.Name(), "Test User")
 93+	}
 94+}
 95+
 96+// TestCommentCommand_WithShortID adds a comment using a short bug ID
 97+func TestCommentCommand_WithShortID(t *testing.T) {
 98+	tmpDir := t.TempDir()
 99+
100+	// Initialize a git repo
101+	initCmd := exec.Command("git", "init")
102+	initCmd.Dir = tmpDir
103+	if err := initCmd.Run(); err != nil {
104+		t.Fatalf("failed to init git repo: %v", err)
105+	}
106+
107+	// Initialize git-bug identities
108+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
109+		t.Fatalf("failed to create identity: %v", err)
110+	}
111+
112+	// Create a bug
113+	title := "Test Bug for Short ID"
114+	if err := runNew(tmpDir, title, ""); err != nil {
115+		t.Fatalf("runNew failed: %v", err)
116+	}
117+
118+	// Get the bug ID and short ID
119+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
120+	if err != nil {
121+		t.Fatalf("failed to open repo: %v", err)
122+	}
123+
124+	var bugID, shortID string
125+	for streamedBug := range bug.ReadAll(repo) {
126+		if streamedBug.Err != nil {
127+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
128+		}
129+		b := streamedBug.Entity
130+		snap := b.Compile()
131+		if snap.Title == title {
132+			bugID = b.Id().String()
133+			// Use first 7 chars as short ID
134+			if len(bugID) > 7 {
135+				shortID = bugID[:7]
136+			} else {
137+				shortID = bugID
138+			}
139+			break
140+		}
141+	}
142+	repo.Close()
143+
144+	if shortID == "" {
145+		t.Fatal("could not find created bug")
146+	}
147+
148+	// Add a comment using short ID
149+	commentText := "Comment added via short ID"
150+	if err := runComment(tmpDir, shortID, commentText); err != nil {
151+		t.Fatalf("runComment with short ID failed: %v", err)
152+	}
153+
154+	// Verify comment was added
155+	repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
156+	if err != nil {
157+		t.Fatalf("failed to open repo: %v", err)
158+	}
159+	defer repo.Close()
160+
161+	b, err := bug.Read(repo, entity.Id(bugID))
162+	if err != nil {
163+		t.Fatalf("failed to read bug: %v", err)
164+	}
165+
166+	snap := b.Compile()
167+	if len(snap.Comments) != 2 {
168+		t.Errorf("expected 2 comments, got %d", len(snap.Comments))
169+	}
170+
171+	if snap.Comments[1].Message != commentText {
172+		t.Errorf("comment message = %q, want %q", snap.Comments[1].Message, commentText)
173+	}
174+}
175+
176+// TestCommentCommand_NoIdentityError tests error when identity not set
177+func TestCommentCommand_NoIdentityError(t *testing.T) {
178+	tmpDir := t.TempDir()
179+
180+	// Initialize a git repo (but no git-bug identities)
181+	initCmd := exec.Command("git", "init")
182+	initCmd.Dir = tmpDir
183+	if err := initCmd.Run(); err != nil {
184+		t.Fatalf("failed to init git repo: %v", err)
185+	}
186+
187+	// Don't create any identities - try to add a comment to non-existent bug
188+	// Bug ID resolution happens before identity check, so we should get ID error first
189+	err := runComment(tmpDir, "nonexistent123", "Test comment")
190+	if err == nil {
191+		t.Error("expected error, got nil")
192+	}
193+
194+	// Since no bugs exist at all, we should get an ID resolution error
195+	if !strings.Contains(err.Error(), "failed to resolve bug ID") {
196+		t.Errorf("expected 'failed to resolve bug ID' error, got: %v", err)
197+	}
198+}
199+
200+// TestCommentCommand_EmptyMessageError tests error for empty message
201+func TestCommentCommand_EmptyMessageError(t *testing.T) {
202+	tmpDir := t.TempDir()
203+
204+	// Initialize a git repo
205+	initCmd := exec.Command("git", "init")
206+	initCmd.Dir = tmpDir
207+	if err := initCmd.Run(); err != nil {
208+		t.Fatalf("failed to init git repo: %v", err)
209+	}
210+
211+	// Initialize git-bug identities
212+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
213+		t.Fatalf("failed to create identity: %v", err)
214+	}
215+
216+	// Create a bug first
217+	title := "Test Bug"
218+	if err := runNew(tmpDir, title, ""); err != nil {
219+		t.Fatalf("runNew failed: %v", err)
220+	}
221+
222+	// Get the bug ID
223+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
224+	if err != nil {
225+		t.Fatalf("failed to open repo: %v", err)
226+	}
227+
228+	var bugID string
229+	for streamedBug := range bug.ReadAll(repo) {
230+		if streamedBug.Err != nil {
231+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
232+		}
233+		b := streamedBug.Entity
234+		snap := b.Compile()
235+		if snap.Title == title {
236+			bugID = b.Id().String()
237+			break
238+		}
239+	}
240+	repo.Close()
241+
242+	// Try to add a comment with empty message (whitespace only)
243+	// This triggers the editor path, which returns "no comment provided"
244+	err = runComment(tmpDir, bugID, "   ")
245+	if err == nil {
246+		t.Error("expected error for empty message, got nil")
247+	}
248+
249+	// When empty message is provided, editor opens and returns "no comment provided"
250+	if !strings.Contains(err.Error(), "no comment provided") {
251+		t.Errorf("expected 'no comment provided' error, got: %v", err)
252+	}
253+}
254+
255+// TestCommentCommand_InvalidBugID tests error for invalid bug ID
256+func TestCommentCommand_InvalidBugID(t *testing.T) {
257+	tmpDir := t.TempDir()
258+
259+	// Initialize a git repo
260+	initCmd := exec.Command("git", "init")
261+	initCmd.Dir = tmpDir
262+	if err := initCmd.Run(); err != nil {
263+		t.Fatalf("failed to init git repo: %v", err)
264+	}
265+
266+	// Initialize git-bug identities
267+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
268+		t.Fatalf("failed to create identity: %v", err)
269+	}
270+
271+	// Try to add a comment to non-existent bug
272+	err := runComment(tmpDir, "nonexistent", "Test comment")
273+	if err == nil {
274+		t.Error("expected error for invalid bug ID, got nil")
275+	}
276+
277+	if !strings.Contains(err.Error(), "failed to resolve bug ID") {
278+		t.Errorf("expected 'failed to resolve bug ID' error, got: %v", err)
279+	}
280+}
281+
282+// TestCommentCommand_MultipleComments tests adding multiple comments
283+func TestCommentCommand_MultipleComments(t *testing.T) {
284+	tmpDir := t.TempDir()
285+
286+	// Initialize a git repo
287+	initCmd := exec.Command("git", "init")
288+	initCmd.Dir = tmpDir
289+	if err := initCmd.Run(); err != nil {
290+		t.Fatalf("failed to init git repo: %v", err)
291+	}
292+
293+	// Initialize git-bug identities
294+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
295+		t.Fatalf("failed to create identity: %v", err)
296+	}
297+
298+	// Create a bug
299+	title := "Test Bug for Multiple Comments"
300+	if err := runNew(tmpDir, title, ""); err != nil {
301+		t.Fatalf("runNew failed: %v", err)
302+	}
303+
304+	// Get the bug ID
305+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
306+	if err != nil {
307+		t.Fatalf("failed to open repo: %v", err)
308+	}
309+
310+	var bugID string
311+	for streamedBug := range bug.ReadAll(repo) {
312+		if streamedBug.Err != nil {
313+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
314+		}
315+		b := streamedBug.Entity
316+		snap := b.Compile()
317+		if snap.Title == title {
318+			bugID = b.Id().String()
319+			break
320+		}
321+	}
322+	repo.Close()
323+
324+	// Add multiple comments
325+	comments := []string{
326+		"First comment",
327+		"Second comment",
328+		"Third comment with **markdown**",
329+	}
330+
331+	for _, comment := range comments {
332+		if err := runComment(tmpDir, bugID, comment); err != nil {
333+			t.Fatalf("runComment failed: %v", err)
334+		}
335+	}
336+
337+	// Verify all comments were added
338+	repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
339+	if err != nil {
340+		t.Fatalf("failed to open repo: %v", err)
341+	}
342+	defer repo.Close()
343+
344+	b, err := bug.Read(repo, entity.Id(bugID))
345+	if err != nil {
346+		t.Fatalf("failed to read bug: %v", err)
347+	}
348+
349+	snap := b.Compile()
350+	// Original + 3 comments = 4 total
351+	if len(snap.Comments) != 4 {
352+		t.Errorf("expected 4 comments, got %d", len(snap.Comments))
353+	}
354+
355+	// Verify each comment (index 1-3 are our added comments)
356+	for i, expected := range comments {
357+		if snap.Comments[i+1].Message != expected {
358+			t.Errorf("comment %d message = %q, want %q", i+1, snap.Comments[i+1].Message, expected)
359+		}
360+	}
361+}
A devbox.json
+21, -0
 1@@ -0,0 +1,21 @@
 2+{
 3+  "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.17.1/.schema/devbox.schema.json",
 4+  "packages": [
 5+    "go@latest",
 6+    "gnumake@latest",
 7+    "ripgrep@latest",
 8+    "yarn@latest",
 9+    "bash@latest",
10+    "coreutils@latest"
11+  ],
12+  "shell": {
13+    "init_hook": [
14+      "echo 'Welcome to devbox!' > /dev/null"
15+    ],
16+    "scripts": {
17+      "test": [
18+        "echo \"Error: no test specified\" && exit 1"
19+      ]
20+    }
21+  }
22+}
A devbox.lock
+457, -0
  1@@ -0,0 +1,457 @@
  2+{
  3+  "lockfile_version": "1",
  4+  "packages": {
  5+    "bash@latest": {
  6+      "last_modified": "2026-04-07T16:32:49Z",
  7+      "resolved": "github:NixOS/nixpkgs/dfd9566f82a6e1d55c30f861879186440614696e#bash",
  8+      "source": "devbox-search",
  9+      "version": "5.3p9",
 10+      "systems": {
 11+        "aarch64-darwin": {
 12+          "outputs": [
 13+            {
 14+              "name": "out",
 15+              "path": "/nix/store/my9bsdsfxcaxkb400i4xvvh1ahb8pybs-bash-interactive-5.3p9",
 16+              "default": true
 17+            },
 18+            {
 19+              "name": "man",
 20+              "path": "/nix/store/5nwbrxj440mxkv8sqzy3d9xsfpswhkkx-bash-interactive-5.3p9-man",
 21+              "default": true
 22+            },
 23+            {
 24+              "name": "dev",
 25+              "path": "/nix/store/047i8vx61kv70j0xahh65x1p0gs4bzp5-bash-interactive-5.3p9-dev"
 26+            },
 27+            {
 28+              "name": "doc",
 29+              "path": "/nix/store/p8v2kq7q82l8cz5axc9lvyj2klib1799-bash-interactive-5.3p9-doc"
 30+            },
 31+            {
 32+              "name": "info",
 33+              "path": "/nix/store/bqh3ll20jibzdrc42lclk29k144fanak-bash-interactive-5.3p9-info"
 34+            }
 35+          ],
 36+          "store_path": "/nix/store/my9bsdsfxcaxkb400i4xvvh1ahb8pybs-bash-interactive-5.3p9"
 37+        },
 38+        "aarch64-linux": {
 39+          "outputs": [
 40+            {
 41+              "name": "out",
 42+              "path": "/nix/store/f6lsdzsgbh5mxaaa91gykyi8mqmlzpr2-bash-interactive-5.3p9",
 43+              "default": true
 44+            },
 45+            {
 46+              "name": "man",
 47+              "path": "/nix/store/87ljrnbjn8w6iqf3bzirh6wd7lpmhvzp-bash-interactive-5.3p9-man",
 48+              "default": true
 49+            },
 50+            {
 51+              "name": "debug",
 52+              "path": "/nix/store/zyi5m4r7wma9vvvfzg7r99avh8sxg9m1-bash-interactive-5.3p9-debug"
 53+            },
 54+            {
 55+              "name": "dev",
 56+              "path": "/nix/store/hkmlf3zy6brfn3xr3magif6c54ln3z4c-bash-interactive-5.3p9-dev"
 57+            },
 58+            {
 59+              "name": "doc",
 60+              "path": "/nix/store/l7bcjyprsmzdnrimjg8al47wsr4vsy6q-bash-interactive-5.3p9-doc"
 61+            },
 62+            {
 63+              "name": "info",
 64+              "path": "/nix/store/cad8ahawmbf12gvh0bq7sf9rjjwbfzg9-bash-interactive-5.3p9-info"
 65+            }
 66+          ],
 67+          "store_path": "/nix/store/f6lsdzsgbh5mxaaa91gykyi8mqmlzpr2-bash-interactive-5.3p9"
 68+        },
 69+        "x86_64-darwin": {
 70+          "outputs": [
 71+            {
 72+              "name": "out",
 73+              "path": "/nix/store/hzc40jxl7zhc1cikxri178a4w6f4fzd6-bash-interactive-5.3p9",
 74+              "default": true
 75+            },
 76+            {
 77+              "name": "man",
 78+              "path": "/nix/store/8lp42ghh8l89v5kj6q5asbfdskssgcxn-bash-interactive-5.3p9-man",
 79+              "default": true
 80+            },
 81+            {
 82+              "name": "dev",
 83+              "path": "/nix/store/jfiwg11dqs0vzg45s58kkabjm0rm8d0c-bash-interactive-5.3p9-dev"
 84+            },
 85+            {
 86+              "name": "doc",
 87+              "path": "/nix/store/rlz86kfy3jxfi7ap587rhrm9ynbw2kvc-bash-interactive-5.3p9-doc"
 88+            },
 89+            {
 90+              "name": "info",
 91+              "path": "/nix/store/d9y32zx4cxwm3h20c0zrzsabjmws3z0m-bash-interactive-5.3p9-info"
 92+            }
 93+          ],
 94+          "store_path": "/nix/store/hzc40jxl7zhc1cikxri178a4w6f4fzd6-bash-interactive-5.3p9"
 95+        },
 96+        "x86_64-linux": {
 97+          "outputs": [
 98+            {
 99+              "name": "out",
100+              "path": "/nix/store/sfvyavxai6qvzmv9p9x6mp4wwdz4v41m-bash-interactive-5.3p9",
101+              "default": true
102+            },
103+            {
104+              "name": "man",
105+              "path": "/nix/store/lw0v8hggdjsqs9zpwwrxajcc4rbsmlfq-bash-interactive-5.3p9-man",
106+              "default": true
107+            },
108+            {
109+              "name": "debug",
110+              "path": "/nix/store/h979dcfkxhswbsdqcwqbzynaqnz1n5a0-bash-interactive-5.3p9-debug"
111+            },
112+            {
113+              "name": "dev",
114+              "path": "/nix/store/832yrsfhq3z41zn9rqsvv0cv22mblv4c-bash-interactive-5.3p9-dev"
115+            },
116+            {
117+              "name": "doc",
118+              "path": "/nix/store/fy5pa2zv8g7l3v0nn6rpwib8nl4whdx1-bash-interactive-5.3p9-doc"
119+            },
120+            {
121+              "name": "info",
122+              "path": "/nix/store/p9lkzmrvl0wqjs4mjv87h5lqcypgrzbp-bash-interactive-5.3p9-info"
123+            }
124+          ],
125+          "store_path": "/nix/store/sfvyavxai6qvzmv9p9x6mp4wwdz4v41m-bash-interactive-5.3p9"
126+        }
127+      }
128+    },
129+    "coreutils@latest": {
130+      "last_modified": "2026-04-06T17:08:15Z",
131+      "resolved": "github:NixOS/nixpkgs/ff048610a1b698607a66f4671821716c2272dc9f#coreutils",
132+      "source": "devbox-search",
133+      "version": "9.10",
134+      "systems": {
135+        "aarch64-darwin": {
136+          "outputs": [
137+            {
138+              "name": "out",
139+              "path": "/nix/store/akih5l2yxpzqyh63xvyc6zsxl7kl2x4v-coreutils-9.10",
140+              "default": true
141+            },
142+            {
143+              "name": "info",
144+              "path": "/nix/store/rqr62g2a1dl14qg090lixy4kyalamxnc-coreutils-9.10-info"
145+            }
146+          ],
147+          "store_path": "/nix/store/akih5l2yxpzqyh63xvyc6zsxl7kl2x4v-coreutils-9.10"
148+        },
149+        "aarch64-linux": {
150+          "outputs": [
151+            {
152+              "name": "out",
153+              "path": "/nix/store/f03gf7yy36rlr9n1wkblvikq12a3hg6c-coreutils-9.10",
154+              "default": true
155+            },
156+            {
157+              "name": "debug",
158+              "path": "/nix/store/l7pb1mavzin4hmwpp87f6xisfprrgnr2-coreutils-9.10-debug"
159+            },
160+            {
161+              "name": "info",
162+              "path": "/nix/store/802yhcvnd2kp712af4v48klcxqzjgdkp-coreutils-9.10-info"
163+            }
164+          ],
165+          "store_path": "/nix/store/f03gf7yy36rlr9n1wkblvikq12a3hg6c-coreutils-9.10"
166+        },
167+        "x86_64-darwin": {
168+          "outputs": [
169+            {
170+              "name": "out",
171+              "path": "/nix/store/33dari5qaqpza7z0yhyzrjg85xmclg8c-coreutils-9.10",
172+              "default": true
173+            },
174+            {
175+              "name": "info",
176+              "path": "/nix/store/mybh7m3jhp3hzp83hsz8aj6w7wr49hxv-coreutils-9.10-info"
177+            }
178+          ],
179+          "store_path": "/nix/store/33dari5qaqpza7z0yhyzrjg85xmclg8c-coreutils-9.10"
180+        },
181+        "x86_64-linux": {
182+          "outputs": [
183+            {
184+              "name": "out",
185+              "path": "/nix/store/74sind1d6vf2bfwd7yklg8chsvzqxmmq-coreutils-9.10",
186+              "default": true
187+            },
188+            {
189+              "name": "debug",
190+              "path": "/nix/store/hm1z5hlgc4p99s3vng7g69cqgdn1j93h-coreutils-9.10-debug"
191+            },
192+            {
193+              "name": "info",
194+              "path": "/nix/store/c5dpvsjmin1cx3ma6jizdzb26bx2avdl-coreutils-9.10-info"
195+            }
196+          ],
197+          "store_path": "/nix/store/74sind1d6vf2bfwd7yklg8chsvzqxmmq-coreutils-9.10"
198+        }
199+      }
200+    },
201+    "github:NixOS/nixpkgs/nixpkgs-unstable": {
202+      "last_modified": "2026-03-16T02:27:38Z",
203+      "resolved": "github:NixOS/nixpkgs/f8573b9c935cfaa162dd62cc9e75ae2db86f85df?lastModified=1773628058&narHash=sha256-hpXH0z3K9xv0fHaje136KY872VT2T5uwxtezlAskQgY%3D"
204+    },
205+    "gnumake@latest": {
206+      "last_modified": "2026-04-06T17:08:15Z",
207+      "resolved": "github:NixOS/nixpkgs/ff048610a1b698607a66f4671821716c2272dc9f#gnumake",
208+      "source": "devbox-search",
209+      "version": "4.4.1",
210+      "systems": {
211+        "aarch64-darwin": {
212+          "outputs": [
213+            {
214+              "name": "out",
215+              "path": "/nix/store/y40n7jzzy9qydb120kxgbzi55mprbkfm-gnumake-4.4.1",
216+              "default": true
217+            },
218+            {
219+              "name": "man",
220+              "path": "/nix/store/b2y9y7i57sml44mk7wl4ba8wr8adgavs-gnumake-4.4.1-man",
221+              "default": true
222+            },
223+            {
224+              "name": "doc",
225+              "path": "/nix/store/hb81l6za1swj09szhxj902bjf9b9cwzc-gnumake-4.4.1-doc"
226+            },
227+            {
228+              "name": "info",
229+              "path": "/nix/store/xynyfhcn9r7jd4iakdr71yb6grabjgf7-gnumake-4.4.1-info"
230+            }
231+          ],
232+          "store_path": "/nix/store/y40n7jzzy9qydb120kxgbzi55mprbkfm-gnumake-4.4.1"
233+        },
234+        "aarch64-linux": {
235+          "outputs": [
236+            {
237+              "name": "out",
238+              "path": "/nix/store/j8hf0sbds6y5il4vb2bz3rx0xivmnsl1-gnumake-4.4.1",
239+              "default": true
240+            },
241+            {
242+              "name": "man",
243+              "path": "/nix/store/j4psv3ifmnw0wa8p38rxv5k6vskz4wcs-gnumake-4.4.1-man",
244+              "default": true
245+            },
246+            {
247+              "name": "debug",
248+              "path": "/nix/store/px43zlq4bz3zjmc91b6wb6949c8dfxvi-gnumake-4.4.1-debug"
249+            },
250+            {
251+              "name": "doc",
252+              "path": "/nix/store/jhss2k9jczl72jk284kvaj9303yvylwy-gnumake-4.4.1-doc"
253+            },
254+            {
255+              "name": "info",
256+              "path": "/nix/store/1qf6j3yza10lw1iy9w0qm9gliqzfw6xk-gnumake-4.4.1-info"
257+            }
258+          ],
259+          "store_path": "/nix/store/j8hf0sbds6y5il4vb2bz3rx0xivmnsl1-gnumake-4.4.1"
260+        },
261+        "x86_64-darwin": {
262+          "outputs": [
263+            {
264+              "name": "out",
265+              "path": "/nix/store/bijx0sq7avqq7apvqsfb3lmza97lpcxz-gnumake-4.4.1",
266+              "default": true
267+            },
268+            {
269+              "name": "man",
270+              "path": "/nix/store/r2qnj53aynk05zan3z2j960klx7085dq-gnumake-4.4.1-man",
271+              "default": true
272+            },
273+            {
274+              "name": "doc",
275+              "path": "/nix/store/2fz6r9nchhyn4i7f9gg5assx9ld2j3mm-gnumake-4.4.1-doc"
276+            },
277+            {
278+              "name": "info",
279+              "path": "/nix/store/h0jgx7zbyyvhcj0lq0la6siv4d3bl9xa-gnumake-4.4.1-info"
280+            }
281+          ],
282+          "store_path": "/nix/store/bijx0sq7avqq7apvqsfb3lmza97lpcxz-gnumake-4.4.1"
283+        },
284+        "x86_64-linux": {
285+          "outputs": [
286+            {
287+              "name": "out",
288+              "path": "/nix/store/45npani18v2m7sbkrzrv2xyilyghrny9-gnumake-4.4.1",
289+              "default": true
290+            },
291+            {
292+              "name": "man",
293+              "path": "/nix/store/p75qwzzsxdhpnhdjx4fbjy20p9qxp326-gnumake-4.4.1-man",
294+              "default": true
295+            },
296+            {
297+              "name": "debug",
298+              "path": "/nix/store/bzap48lhfqcjczhj2ry3gwqjh9klkgxf-gnumake-4.4.1-debug"
299+            },
300+            {
301+              "name": "doc",
302+              "path": "/nix/store/0jx03f9vis4zp8b7s2ybp9anlgk68ihy-gnumake-4.4.1-doc"
303+            },
304+            {
305+              "name": "info",
306+              "path": "/nix/store/pbs6fhcn3gjr6x9fzdlk0kwqg1m5gk5g-gnumake-4.4.1-info"
307+            }
308+          ],
309+          "store_path": "/nix/store/45npani18v2m7sbkrzrv2xyilyghrny9-gnumake-4.4.1"
310+        }
311+      }
312+    },
313+    "go@latest": {
314+      "last_modified": "2026-03-21T07:29:51Z",
315+      "resolved": "github:NixOS/nixpkgs/09061f748ee21f68a089cd5d91ec1859cd93d0be#go",
316+      "source": "devbox-search",
317+      "version": "1.26.1",
318+      "systems": {
319+        "aarch64-darwin": {
320+          "outputs": [
321+            {
322+              "name": "out",
323+              "path": "/nix/store/kh43nhaz1qcpwws2xq805lrmwpmn9i3k-go-1.26.1",
324+              "default": true
325+            }
326+          ],
327+          "store_path": "/nix/store/kh43nhaz1qcpwws2xq805lrmwpmn9i3k-go-1.26.1"
328+        },
329+        "aarch64-linux": {
330+          "outputs": [
331+            {
332+              "name": "out",
333+              "path": "/nix/store/rz1pqbm5z3zfby250i0djfmfzzj7khg9-go-1.26.1",
334+              "default": true
335+            }
336+          ],
337+          "store_path": "/nix/store/rz1pqbm5z3zfby250i0djfmfzzj7khg9-go-1.26.1"
338+        },
339+        "x86_64-darwin": {
340+          "outputs": [
341+            {
342+              "name": "out",
343+              "path": "/nix/store/yv6jj27racylbfjw6a1cdr91ndxbgyf6-go-1.26.1",
344+              "default": true
345+            }
346+          ],
347+          "store_path": "/nix/store/yv6jj27racylbfjw6a1cdr91ndxbgyf6-go-1.26.1"
348+        },
349+        "x86_64-linux": {
350+          "outputs": [
351+            {
352+              "name": "out",
353+              "path": "/nix/store/ckcq2mj8zk0drhaaacy6mp9d924hnr4m-go-1.26.1",
354+              "default": true
355+            }
356+          ],
357+          "store_path": "/nix/store/ckcq2mj8zk0drhaaacy6mp9d924hnr4m-go-1.26.1"
358+        }
359+      }
360+    },
361+    "ripgrep@latest": {
362+      "last_modified": "2026-03-21T07:29:51Z",
363+      "resolved": "github:NixOS/nixpkgs/09061f748ee21f68a089cd5d91ec1859cd93d0be#ripgrep",
364+      "source": "devbox-search",
365+      "version": "15.1.0",
366+      "systems": {
367+        "aarch64-darwin": {
368+          "outputs": [
369+            {
370+              "name": "out",
371+              "path": "/nix/store/qx2i265ck9hi773q0hhg96bjchkghpgm-ripgrep-15.1.0",
372+              "default": true
373+            }
374+          ],
375+          "store_path": "/nix/store/qx2i265ck9hi773q0hhg96bjchkghpgm-ripgrep-15.1.0"
376+        },
377+        "aarch64-linux": {
378+          "outputs": [
379+            {
380+              "name": "out",
381+              "path": "/nix/store/p16w1972rd9hj2kdczmdlwv0wa8c0drf-ripgrep-15.1.0",
382+              "default": true
383+            }
384+          ],
385+          "store_path": "/nix/store/p16w1972rd9hj2kdczmdlwv0wa8c0drf-ripgrep-15.1.0"
386+        },
387+        "x86_64-darwin": {
388+          "outputs": [
389+            {
390+              "name": "out",
391+              "path": "/nix/store/hm6mgwzrdznpadk6nxzmlls1p534m2d3-ripgrep-15.1.0",
392+              "default": true
393+            }
394+          ],
395+          "store_path": "/nix/store/hm6mgwzrdznpadk6nxzmlls1p534m2d3-ripgrep-15.1.0"
396+        },
397+        "x86_64-linux": {
398+          "outputs": [
399+            {
400+              "name": "out",
401+              "path": "/nix/store/922crn2k3v8yaqk7anps80hba919lnds-ripgrep-15.1.0",
402+              "default": true
403+            }
404+          ],
405+          "store_path": "/nix/store/922crn2k3v8yaqk7anps80hba919lnds-ripgrep-15.1.0"
406+        }
407+      }
408+    },
409+    "yarn@latest": {
410+      "last_modified": "2026-03-21T07:29:51Z",
411+      "resolved": "github:NixOS/nixpkgs/09061f748ee21f68a089cd5d91ec1859cd93d0be#yarn",
412+      "source": "devbox-search",
413+      "version": "1.22.22",
414+      "systems": {
415+        "aarch64-darwin": {
416+          "outputs": [
417+            {
418+              "name": "out",
419+              "path": "/nix/store/59yqhmvbl7bsyv5qc30nmrpaf4iqj79h-yarn-1.22.22",
420+              "default": true
421+            }
422+          ],
423+          "store_path": "/nix/store/59yqhmvbl7bsyv5qc30nmrpaf4iqj79h-yarn-1.22.22"
424+        },
425+        "aarch64-linux": {
426+          "outputs": [
427+            {
428+              "name": "out",
429+              "path": "/nix/store/ybspc2b9q9j9drfpa5d1bmw2qgrkx4qc-yarn-1.22.22",
430+              "default": true
431+            }
432+          ],
433+          "store_path": "/nix/store/ybspc2b9q9j9drfpa5d1bmw2qgrkx4qc-yarn-1.22.22"
434+        },
435+        "x86_64-darwin": {
436+          "outputs": [
437+            {
438+              "name": "out",
439+              "path": "/nix/store/8j2xmk7ijy7nijpy2yc3mcjf7hxpnpyl-yarn-1.22.22",
440+              "default": true
441+            }
442+          ],
443+          "store_path": "/nix/store/8j2xmk7ijy7nijpy2yc3mcjf7hxpnpyl-yarn-1.22.22"
444+        },
445+        "x86_64-linux": {
446+          "outputs": [
447+            {
448+              "name": "out",
449+              "path": "/nix/store/g1ksanljq7v16gj8yb7zs6wkv7ikycy3-yarn-1.22.22",
450+              "default": true
451+            }
452+          ],
453+          "store_path": "/nix/store/g1ksanljq7v16gj8yb7zs6wkv7ikycy3-yarn-1.22.22"
454+        }
455+      }
456+    }
457+  }
458+}
A edit_integration_test.go
+424, -0
  1@@ -0,0 +1,424 @@
  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+// TestEditCommand_EditIssueTitle edits only the issue title
 17+func TestEditCommand_EditIssueTitle(t *testing.T) {
 18+	tmpDir := t.TempDir()
 19+
 20+	// Initialize a git repo
 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+	// Initialize git-bug identities
 28+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
 29+		t.Fatalf("failed to create identity: %v", err)
 30+	}
 31+
 32+	// Create a bug
 33+	title := "Original Title"
 34+	message := "Original description"
 35+	if err := runNew(tmpDir, title, message); err != nil {
 36+		t.Fatalf("runNew failed: %v", err)
 37+	}
 38+
 39+	// Get the bug ID
 40+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
 41+	if err != nil {
 42+		t.Fatalf("failed to open repo: %v", err)
 43+	}
 44+
 45+	var bugID string
 46+	for streamedBug := range bug.ReadAll(repo) {
 47+		if streamedBug.Err != nil {
 48+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
 49+		}
 50+		b := streamedBug.Entity
 51+		snap := b.Compile()
 52+		if snap.Title == title {
 53+			bugID = b.Id().String()
 54+			break
 55+		}
 56+	}
 57+	repo.Close()
 58+
 59+	if bugID == "" {
 60+		t.Fatal("could not find created bug")
 61+	}
 62+
 63+	// Edit only the title
 64+	newTitle := "Updated Title"
 65+	if err := runEdit(tmpDir, bugID, newTitle, ""); err != nil {
 66+		t.Fatalf("runEdit failed: %v", err)
 67+	}
 68+
 69+	// Verify title was updated
 70+	repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
 71+	if err != nil {
 72+		t.Fatalf("failed to open repo: %v", err)
 73+	}
 74+	defer repo.Close()
 75+
 76+	b, err := bug.Read(repo, entity.Id(bugID))
 77+	if err != nil {
 78+		t.Fatalf("failed to read bug: %v", err)
 79+	}
 80+
 81+	snap := b.Compile()
 82+	if snap.Title != newTitle {
 83+		t.Errorf("title = %q, want %q", snap.Title, newTitle)
 84+	}
 85+	// Description should remain unchanged
 86+	if len(snap.Comments) == 0 || snap.Comments[0].Message != message {
 87+		t.Errorf("description was changed unexpectedly")
 88+	}
 89+}
 90+
 91+// TestEditCommand_EditIssueDescription edits only the issue description
 92+func TestEditCommand_EditIssueDescription(t *testing.T) {
 93+	tmpDir := t.TempDir()
 94+
 95+	// Initialize a git repo
 96+	initCmd := exec.Command("git", "init")
 97+	initCmd.Dir = tmpDir
 98+	if err := initCmd.Run(); err != nil {
 99+		t.Fatalf("failed to init git repo: %v", err)
100+	}
101+
102+	// Initialize git-bug identities
103+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
104+		t.Fatalf("failed to create identity: %v", err)
105+	}
106+
107+	// Create a bug
108+	title := "Original Title"
109+	message := "Original description"
110+	if err := runNew(tmpDir, title, message); err != nil {
111+		t.Fatalf("runNew failed: %v", err)
112+	}
113+
114+	// Get the bug ID
115+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
116+	if err != nil {
117+		t.Fatalf("failed to open repo: %v", err)
118+	}
119+
120+	var bugID string
121+	for streamedBug := range bug.ReadAll(repo) {
122+		if streamedBug.Err != nil {
123+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
124+		}
125+		b := streamedBug.Entity
126+		snap := b.Compile()
127+		if snap.Title == title {
128+			bugID = b.Id().String()
129+			break
130+		}
131+	}
132+	repo.Close()
133+
134+	if bugID == "" {
135+		t.Fatal("could not find created bug")
136+	}
137+
138+	// Edit only the description
139+	newMessage := "Updated description"
140+	if err := runEdit(tmpDir, bugID, "", newMessage); err != nil {
141+		t.Fatalf("runEdit failed: %v", err)
142+	}
143+
144+	// Verify description was updated
145+	repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
146+	if err != nil {
147+		t.Fatalf("failed to open repo: %v", err)
148+	}
149+	defer repo.Close()
150+
151+	b, err := bug.Read(repo, entity.Id(bugID))
152+	if err != nil {
153+		t.Fatalf("failed to read bug: %v", err)
154+	}
155+
156+	snap := b.Compile()
157+	// Title should remain unchanged
158+	if snap.Title != title {
159+		t.Errorf("title was changed unexpectedly")
160+	}
161+	if len(snap.Comments) == 0 || snap.Comments[0].Message != newMessage {
162+		t.Errorf("description = %q, want %q", snap.Comments[0].Message, newMessage)
163+	}
164+}
165+
166+// TestEditCommand_EditIssueBoth edits both title and description
167+func TestEditCommand_EditIssueBoth(t *testing.T) {
168+	tmpDir := t.TempDir()
169+
170+	// Initialize a git repo
171+	initCmd := exec.Command("git", "init")
172+	initCmd.Dir = tmpDir
173+	if err := initCmd.Run(); err != nil {
174+		t.Fatalf("failed to init git repo: %v", err)
175+	}
176+
177+	// Initialize git-bug identities
178+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
179+		t.Fatalf("failed to create identity: %v", err)
180+	}
181+
182+	// Create a bug
183+	if err := runNew(tmpDir, "Original Title", "Original description"); err != nil {
184+		t.Fatalf("runNew failed: %v", err)
185+	}
186+
187+	// Get the bug ID
188+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
189+	if err != nil {
190+		t.Fatalf("failed to open repo: %v", err)
191+	}
192+
193+	var bugID string
194+	for streamedBug := range bug.ReadAll(repo) {
195+		if streamedBug.Err != nil {
196+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
197+		}
198+		b := streamedBug.Entity
199+		snap := b.Compile()
200+		if snap.Title == "Original Title" {
201+			bugID = b.Id().String()
202+			break
203+		}
204+	}
205+	repo.Close()
206+
207+	// Edit both title and description
208+	newTitle := "Updated Title"
209+	newMessage := "Updated description"
210+	if err := runEdit(tmpDir, bugID, newTitle, newMessage); err != nil {
211+		t.Fatalf("runEdit failed: %v", err)
212+	}
213+
214+	// Verify both were updated
215+	repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
216+	if err != nil {
217+		t.Fatalf("failed to open repo: %v", err)
218+	}
219+	defer repo.Close()
220+
221+	b, err := bug.Read(repo, entity.Id(bugID))
222+	if err != nil {
223+		t.Fatalf("failed to read bug: %v", err)
224+	}
225+
226+	snap := b.Compile()
227+	if snap.Title != newTitle {
228+		t.Errorf("title = %q, want %q", snap.Title, newTitle)
229+	}
230+	if len(snap.Comments) == 0 || snap.Comments[0].Message != newMessage {
231+		t.Errorf("description = %q, want %q", snap.Comments[0].Message, newMessage)
232+	}
233+}
234+
235+// TestEditCommand_EditComment edits a comment
236+func TestEditCommand_EditComment(t *testing.T) {
237+	tmpDir := t.TempDir()
238+
239+	// Initialize a git repo
240+	initCmd := exec.Command("git", "init")
241+	initCmd.Dir = tmpDir
242+	if err := initCmd.Run(); err != nil {
243+		t.Fatalf("failed to init git repo: %v", err)
244+	}
245+
246+	// Initialize git-bug identities
247+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
248+		t.Fatalf("failed to create identity: %v", err)
249+	}
250+
251+	// Create a bug
252+	if err := runNew(tmpDir, "Test Bug", "Description"); err != nil {
253+		t.Fatalf("runNew failed: %v", err)
254+	}
255+
256+	// Get the bug ID
257+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
258+	if err != nil {
259+		t.Fatalf("failed to open repo: %v", err)
260+	}
261+
262+	var bugID string
263+	for streamedBug := range bug.ReadAll(repo) {
264+		if streamedBug.Err != nil {
265+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
266+		}
267+		b := streamedBug.Entity
268+		snap := b.Compile()
269+		if snap.Title == "Test Bug" {
270+			bugID = b.Id().String()
271+			break
272+		}
273+	}
274+	repo.Close()
275+
276+	// Add a comment
277+	originalComment := "Original comment text"
278+	if err := runComment(tmpDir, bugID, originalComment); err != nil {
279+		t.Fatalf("runComment failed: %v", err)
280+	}
281+
282+	// Get the comment ID
283+	repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
284+	if err != nil {
285+		t.Fatalf("failed to open repo: %v", err)
286+	}
287+
288+	b, err := bug.Read(repo, entity.Id(bugID))
289+	if err != nil {
290+		t.Fatalf("failed to read bug: %v", err)
291+	}
292+	snap := b.Compile()
293+	if len(snap.Comments) < 2 {
294+		t.Fatal("expected at least 2 comments")
295+	}
296+	commentID := snap.Comments[1].CombinedId().String()
297+	repo.Close()
298+
299+	// Edit the comment
300+	newComment := "Updated comment text"
301+	if err := runEdit(tmpDir, commentID, "", newComment); err != nil {
302+		t.Fatalf("runEdit failed: %v", err)
303+	}
304+
305+	// Verify comment was updated
306+	repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
307+	if err != nil {
308+		t.Fatalf("failed to open repo: %v", err)
309+	}
310+	defer repo.Close()
311+
312+	b, err = bug.Read(repo, entity.Id(bugID))
313+	if err != nil {
314+		t.Fatalf("failed to read bug: %v", err)
315+	}
316+
317+	snap = b.Compile()
318+	if len(snap.Comments) < 2 {
319+		t.Fatal("expected at least 2 comments")
320+	}
321+	if snap.Comments[1].Message != newComment {
322+		t.Errorf("comment = %q, want %q", snap.Comments[1].Message, newComment)
323+	}
324+}
325+
326+// TestEditCommand_InvalidID tests error for invalid ID
327+func TestEditCommand_InvalidID(t *testing.T) {
328+	tmpDir := t.TempDir()
329+
330+	// Initialize a git repo
331+	initCmd := exec.Command("git", "init")
332+	initCmd.Dir = tmpDir
333+	if err := initCmd.Run(); err != nil {
334+		t.Fatalf("failed to init git repo: %v", err)
335+	}
336+
337+	// Initialize git-bug identities
338+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
339+		t.Fatalf("failed to create identity: %v", err)
340+	}
341+
342+	// Try to edit non-existent ID
343+	err := runEdit(tmpDir, "nonexistent123", "Title", "Message")
344+	if err == nil {
345+		t.Error("expected error for invalid ID, got nil")
346+	}
347+
348+	if !strings.Contains(err.Error(), "failed to resolve ID") {
349+		t.Errorf("expected 'failed to resolve ID' error, got: %v", err)
350+	}
351+}
352+
353+// TestEditCommand_WithShortID tests editing using a short ID
354+func TestEditCommand_WithShortID(t *testing.T) {
355+	tmpDir := t.TempDir()
356+
357+	// Initialize a git repo
358+	initCmd := exec.Command("git", "init")
359+	initCmd.Dir = tmpDir
360+	if err := initCmd.Run(); err != nil {
361+		t.Fatalf("failed to init git repo: %v", err)
362+	}
363+
364+	// Initialize git-bug identities
365+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
366+		t.Fatalf("failed to create identity: %v", err)
367+	}
368+
369+	// Create a bug
370+	if err := runNew(tmpDir, "Original Title", "Original description"); err != nil {
371+		t.Fatalf("runNew failed: %v", err)
372+	}
373+
374+	// Get the bug ID and short ID
375+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
376+	if err != nil {
377+		t.Fatalf("failed to open repo: %v", err)
378+	}
379+
380+	var bugID, shortID string
381+	for streamedBug := range bug.ReadAll(repo) {
382+		if streamedBug.Err != nil {
383+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
384+		}
385+		b := streamedBug.Entity
386+		snap := b.Compile()
387+		if snap.Title == "Original Title" {
388+			bugID = b.Id().String()
389+			if len(bugID) > 7 {
390+				shortID = bugID[:7]
391+			} else {
392+				shortID = bugID
393+			}
394+			break
395+		}
396+	}
397+	repo.Close()
398+
399+	if shortID == "" {
400+		t.Fatal("could not find created bug")
401+	}
402+
403+	// Edit using short ID
404+	newTitle := "Updated Title"
405+	if err := runEdit(tmpDir, shortID, newTitle, ""); err != nil {
406+		t.Fatalf("runEdit with short ID failed: %v", err)
407+	}
408+
409+	// Verify title was updated
410+	repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
411+	if err != nil {
412+		t.Fatalf("failed to open repo: %v", err)
413+	}
414+	defer repo.Close()
415+
416+	b, err := bug.Read(repo, entity.Id(bugID))
417+	if err != nil {
418+		t.Fatalf("failed to read bug: %v", err)
419+	}
420+
421+	snap := b.Compile()
422+	if snap.Title != newTitle {
423+		t.Errorf("title = %q, want %q", snap.Title, newTitle)
424+	}
425+}
A edit_test.go
+150, -0
  1@@ -0,0 +1,150 @@
  2+package main
  3+
  4+import (
  5+	"testing"
  6+)
  7+
  8+func TestParseEditContent_IssueFormat(t *testing.T) {
  9+	tests := []struct {
 10+		name        string
 11+		content     string
 12+		wantTitle   string
 13+		wantMessage string
 14+		wantErr     bool
 15+	}{
 16+		{
 17+			name:        "simple title and message",
 18+			content:     "Title\n\nMessage body",
 19+			wantTitle:   "Title",
 20+			wantMessage: "\nMessage body",
 21+			wantErr:     false,
 22+		},
 23+		{
 24+			name:        "title with comment lines",
 25+			content:     ";; comment\nTitle\n\nMessage",
 26+			wantTitle:   "Title",
 27+			wantMessage: "Message",
 28+			wantErr:     false,
 29+		},
 30+		{
 31+			name:        "multiline message",
 32+			content:     "Title\n\nLine 1\nLine 2\nLine 3",
 33+			wantTitle:   "Title",
 34+			wantMessage: "\nLine 1\nLine 2\nLine 3",
 35+			wantErr:     false,
 36+		},
 37+		{
 38+			name:        "title only no message",
 39+			content:     "Title",
 40+			wantTitle:   "Title",
 41+			wantMessage: "",
 42+			wantErr:     false,
 43+		},
 44+		{
 45+			name:        "empty content",
 46+			content:     "",
 47+			wantTitle:   "",
 48+			wantMessage: "",
 49+			wantErr:     true,
 50+		},
 51+		{
 52+			name:        "only comment lines",
 53+			content:     ";; comment 1\n;; comment 2",
 54+			wantTitle:   "",
 55+			wantMessage: "",
 56+			wantErr:     true,
 57+		},
 58+		{
 59+			name:        "title with leading/trailing whitespace in lines",
 60+			content:     "  Title  \n\n  Line 1  \n  Line 2  ",
 61+			wantTitle:   "Title",
 62+			wantMessage: "\n  Line 1  \n  Line 2  ",
 63+			wantErr:     false,
 64+		},
 65+	}
 66+
 67+	for _, tt := range tests {
 68+		t.Run(tt.name, func(t *testing.T) {
 69+			gotTitle, gotMessage, err := parseEditContent(tt.content, false)
 70+			if (err != nil) != tt.wantErr {
 71+				t.Errorf("parseEditContent() error = %v, wantErr %v", err, tt.wantErr)
 72+				return
 73+			}
 74+			if gotTitle != tt.wantTitle {
 75+				t.Errorf("parseEditContent() title = %q, want %q", gotTitle, tt.wantTitle)
 76+			}
 77+			if gotMessage != tt.wantMessage {
 78+				t.Errorf("parseEditContent() message = %q, want %q", gotMessage, tt.wantMessage)
 79+			}
 80+		})
 81+	}
 82+}
 83+
 84+func TestParseEditContent_CommentFormat(t *testing.T) {
 85+	tests := []struct {
 86+		name        string
 87+		content     string
 88+		wantTitle   string
 89+		wantMessage string
 90+		wantErr     bool
 91+	}{
 92+		{
 93+			name:        "simple comment",
 94+			content:     "Comment text",
 95+			wantTitle:   "",
 96+			wantMessage: "Comment text",
 97+			wantErr:     false,
 98+		},
 99+		{
100+			name:        "multiline comment",
101+			content:     "Line 1\nLine 2\nLine 3",
102+			wantTitle:   "",
103+			wantMessage: "Line 1\nLine 2\nLine 3",
104+			wantErr:     false,
105+		},
106+		{
107+			name:        "comment with instruction lines",
108+			content:     ";; instruction\nComment text",
109+			wantTitle:   "",
110+			wantMessage: "Comment text",
111+			wantErr:     false,
112+		},
113+		{
114+			name:        "empty content",
115+			content:     "",
116+			wantTitle:   "",
117+			wantMessage: "",
118+			wantErr:     true,
119+		},
120+		{
121+			name:        "only whitespace",
122+			content:     "   \n   ",
123+			wantTitle:   "",
124+			wantMessage: "",
125+			wantErr:     true,
126+		},
127+		{
128+			name:        "only comment lines",
129+			content:     ";; comment 1\n;; comment 2",
130+			wantTitle:   "",
131+			wantMessage: "",
132+			wantErr:     true,
133+		},
134+	}
135+
136+	for _, tt := range tests {
137+		t.Run(tt.name, func(t *testing.T) {
138+			gotTitle, gotMessage, err := parseEditContent(tt.content, true)
139+			if (err != nil) != tt.wantErr {
140+				t.Errorf("parseEditContent() error = %v, wantErr %v", err, tt.wantErr)
141+				return
142+			}
143+			if gotTitle != tt.wantTitle {
144+				t.Errorf("parseEditContent() title = %q, want %q", gotTitle, tt.wantTitle)
145+			}
146+			if gotMessage != tt.wantMessage {
147+				t.Errorf("parseEditContent() message = %q, want %q", gotMessage, tt.wantMessage)
148+			}
149+		})
150+	}
151+}
A flake.nix
+75, -0
 1@@ -0,0 +1,75 @@
 2+{
 3+  description = "bug - a simplified CLI to git-bug";
 4+
 5+  inputs = {
 6+    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
 7+    flake-utils.url = "github:numtide/flake-utils";
 8+  };
 9+
10+  outputs = { self, nixpkgs, flake-utils }:
11+    flake-utils.lib.eachDefaultSystem (system:
12+      let
13+        pkgs = nixpkgs.legacyPackages.${system};
14+
15+        # Read the version from go.mod or use a default
16+        version = "0.1.0";
17+
18+        # Build the bug CLI tool
19+        bug = pkgs.buildGoModule {
20+          pname = "bug";
21+          inherit version;
22+          src = ./.;
23+
24+          # This is the vendor hash - update it when go.mod/go.sum changes
25+          # Run: nix build 2>&1 | grep "got:" | sed 's/.*got: *//'
26+          vendorHash = "sha256-3qUblOQOd8unYZzK8qCtkfChDycdOvQPzzOQcmYxDRw=";
27+
28+          # Build flags
29+          ldflags = [
30+            "-s" # Strip symbol table
31+            "-w" # Strip debug info
32+            "-X main.version=${version}"
33+          ];
34+
35+          # Sub-packages to build (build all in root directory)
36+          subPackages = [ "." ];
37+
38+          meta = with pkgs.lib; {
39+            description = "Simplified CLI to git-bug";
40+            homepage = "https://code.kilimanjaro.io/bug";
41+            license = licenses.mit;
42+            mainProgram = "bug";
43+            platforms = platforms.all;
44+          };
45+        };
46+      in
47+      {
48+        # Default package builds the bug CLI
49+        packages.default = bug;
50+
51+        # Also expose the bug package directly
52+        packages.bug = bug;
53+
54+        # Development shell with Go tooling
55+        devShells.default = pkgs.mkShell {
56+          buildInputs = with pkgs; [
57+            go
58+            gopls
59+            golangci-lint
60+          ];
61+
62+          shellHook = ''
63+            echo "bug dev shell"
64+            echo "  Go version: $(go version)"
65+            echo "  Run 'make build' to build with nix"
66+            echo "  Run 'make test' to run tests"
67+          '';
68+        };
69+
70+        # App for running directly
71+        apps.default = flake-utils.lib.mkApp {
72+          drv = bug;
73+          exePath = "/bin/bug";
74+        };
75+      });
76+}
A go.mod
+117, -0
  1@@ -0,0 +1,117 @@
  2+module code.kilimanjaro.io/bug
  3+
  4+go 1.26.1
  5+
  6+require (
  7+	dario.cat/mergo v1.0.2 // indirect
  8+	github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
  9+	github.com/99designs/gqlgen v0.17.89 // indirect
 10+	github.com/99designs/keyring v1.2.2 // indirect
 11+	github.com/MichaelMure/go-term-text v0.3.1 // indirect
 12+	github.com/Microsoft/go-winio v0.6.2 // indirect
 13+	github.com/ProtonMail/go-crypto v1.4.1 // indirect
 14+	github.com/RoaringBitmap/roaring v1.9.4 // indirect
 15+	github.com/VividCortex/ewma v1.2.0 // indirect
 16+	github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
 17+	github.com/agnivade/levenshtein v1.2.1 // indirect
 18+	github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect
 19+	github.com/awesome-gocui/gocui v1.1.0 // indirect
 20+	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 21+	github.com/bits-and-blooms/bitset v1.24.4 // indirect
 22+	github.com/blevesearch/bleve v1.0.14 // indirect
 23+	github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
 24+	github.com/blevesearch/mmap-go v1.2.0 // indirect
 25+	github.com/blevesearch/segment v0.9.1 // indirect
 26+	github.com/blevesearch/snowballstem v0.9.0 // indirect
 27+	github.com/blevesearch/zap/v11 v11.0.14 // indirect
 28+	github.com/blevesearch/zap/v12 v12.0.14 // indirect
 29+	github.com/blevesearch/zap/v13 v13.0.6 // indirect
 30+	github.com/blevesearch/zap/v14 v14.0.5 // indirect
 31+	github.com/blevesearch/zap/v15 v15.0.3 // indirect
 32+	github.com/charmbracelet/colorprofile v0.4.3 // indirect
 33+	github.com/charmbracelet/lipgloss v1.1.0 // indirect
 34+	github.com/charmbracelet/x/ansi v0.11.6 // indirect
 35+	github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
 36+	github.com/charmbracelet/x/term v0.2.2 // indirect
 37+	github.com/clipperhouse/displaywidth v0.11.0 // indirect
 38+	github.com/clipperhouse/stringish v0.1.1 // indirect
 39+	github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
 40+	github.com/cloudflare/circl v1.6.3 // indirect
 41+	github.com/couchbase/vellum v1.0.2 // indirect
 42+	github.com/cyphar/filepath-securejoin v0.6.1 // indirect
 43+	github.com/danieljoos/wincred v1.2.3 // indirect
 44+	github.com/davecgh/go-spew v1.1.1 // indirect
 45+	github.com/dustin/go-humanize v1.0.1 // indirect
 46+	github.com/dvsekhvalnov/jose2go v1.8.0 // indirect
 47+	github.com/emirpasic/gods v1.18.1 // indirect
 48+	github.com/fatih/color v1.19.0 // indirect
 49+	github.com/gdamore/encoding v1.0.1 // indirect
 50+	github.com/gdamore/tcell/v2 v2.13.8 // indirect
 51+	github.com/git-bug/git-bug v0.10.1 // indirect
 52+	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
 53+	github.com/go-git/go-billy/v5 v5.8.0 // indirect
 54+	github.com/go-git/go-git/v5 v5.17.2 // indirect
 55+	github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
 56+	github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
 57+	github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
 58+	github.com/golang/protobuf v1.5.4 // indirect
 59+	github.com/golang/snappy v1.0.0 // indirect
 60+	github.com/google/go-querystring v1.2.0 // indirect
 61+	github.com/google/uuid v1.6.0 // indirect
 62+	github.com/gorilla/mux v1.8.1 // indirect
 63+	github.com/gorilla/websocket v1.5.3 // indirect
 64+	github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
 65+	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 66+	github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
 67+	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
 68+	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 69+	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
 70+	github.com/kevinburke/ssh_config v1.6.0 // indirect
 71+	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
 72+	github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
 73+	github.com/mattn/go-colorable v0.1.14 // indirect
 74+	github.com/mattn/go-isatty v0.0.21 // indirect
 75+	github.com/mattn/go-runewidth v0.0.23 // indirect
 76+	github.com/mschoch/smat v0.2.0 // indirect
 77+	github.com/mtibben/percent v0.2.1 // indirect
 78+	github.com/muesli/termenv v0.16.0 // indirect
 79+	github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 // indirect
 80+	github.com/pjbgf/sha1cd v0.5.0 // indirect
 81+	github.com/pkg/errors v0.9.1 // indirect
 82+	github.com/pmezard/go-difflib v1.0.0 // indirect
 83+	github.com/rivo/uniseg v0.4.7 // indirect
 84+	github.com/sergi/go-diff v1.4.0 // indirect
 85+	github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed // indirect
 86+	github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf // indirect
 87+	github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c // indirect
 88+	github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92 // indirect
 89+	github.com/skeema/knownhosts v1.3.2 // indirect
 90+	github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
 91+	github.com/sosodev/duration v1.4.0 // indirect
 92+	github.com/spf13/cobra v1.10.2 // indirect
 93+	github.com/spf13/pflag v1.0.10 // indirect
 94+	github.com/steveyen/gtreap v0.1.0 // indirect
 95+	github.com/stretchr/testify v1.11.1 // indirect
 96+	github.com/vbauerster/mpb/v8 v8.12.0 // indirect
 97+	github.com/vektah/gqlparser/v2 v2.5.32 // indirect
 98+	github.com/willf/bitset v1.1.11 // indirect
 99+	github.com/xanzy/ssh-agent v0.3.3 // indirect
100+	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
101+	gitlab.com/gitlab-org/api/client-go v1.46.0 // indirect
102+	go.etcd.io/bbolt v1.4.3 // indirect
103+	golang.org/x/crypto v0.50.0 // indirect
104+	golang.org/x/mod v0.35.0 // indirect
105+	golang.org/x/net v0.53.0 // indirect
106+	golang.org/x/oauth2 v0.36.0 // indirect
107+	golang.org/x/sync v0.20.0 // indirect
108+	golang.org/x/sys v0.43.0 // indirect
109+	golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect
110+	golang.org/x/term v0.42.0 // indirect
111+	golang.org/x/text v0.36.0 // indirect
112+	golang.org/x/time v0.15.0 // indirect
113+	golang.org/x/tools v0.44.0 // indirect
114+	golang.org/x/vuln v1.1.4 // indirect
115+	google.golang.org/protobuf v1.36.11 // indirect
116+	gopkg.in/warnings.v0 v0.1.2 // indirect
117+	gopkg.in/yaml.v3 v3.0.1 // indirect
118+)
A go.sum
+362, -0
  1@@ -0,0 +1,362 @@
  2+dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
  3+dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
  4+github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
  5+github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
  6+github.com/99designs/gqlgen v0.17.89 h1:KzEcxPiMgQoMw3m/E85atUEHyZyt0PbAflMia5Kw8z8=
  7+github.com/99designs/gqlgen v0.17.89/go.mod h1:GFqruTVGB7ZTdrf1uzOagpXbY7DrEt1pIxnTdhIbWvQ=
  8+github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=
  9+github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk=
 10+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 11+github.com/MichaelMure/go-term-text v0.3.1 h1:Kw9kZanyZWiCHOYu9v/8pWEgDQ6UVN9/ix2Vd2zzWf0=
 12+github.com/MichaelMure/go-term-text v0.3.1/go.mod h1:QgVjAEDUnRMlzpS6ky5CGblux7ebeiLnuy9dAaFZu8o=
 13+github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
 14+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
 15+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
 16+github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
 17+github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
 18+github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo=
 19+github.com/RoaringBitmap/roaring v1.9.4 h1:yhEIoH4YezLYT04s1nHehNO64EKFTop/wBhxv2QzDdQ=
 20+github.com/RoaringBitmap/roaring v1.9.4/go.mod h1:6AXUsoIEzDTFFQCe1RbGA6uFONMhvejWj5rqITANK90=
 21+github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
 22+github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
 23+github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
 24+github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
 25+github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
 26+github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
 27+github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
 28+github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
 29+github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
 30+github.com/awesome-gocui/gocui v1.1.0 h1:db2j7yFEoHZjpQFeE2xqiatS8bm1lO3THeLwE6MzOII=
 31+github.com/awesome-gocui/gocui v1.1.0/go.mod h1:M2BXkrp7PR97CKnPRT7Rk0+rtswChPtksw/vRAESGpg=
 32+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
 33+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 34+github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
 35+github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
 36+github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
 37+github.com/blevesearch/bleve v1.0.14 h1:Q8r+fHTt35jtGXJUM0ULwM3Tzg+MRfyai4ZkWDy2xO4=
 38+github.com/blevesearch/bleve v1.0.14/go.mod h1:e/LJTr+E7EaoVdkQZTfoz7dt4KoDNvDbLb8MSKuNTLQ=
 39+github.com/blevesearch/blevex v1.0.0/go.mod h1:2rNVqoG2BZI8t1/P1awgTKnGlx5MP9ZbtEciQaNhswc=
 40+github.com/blevesearch/cld2 v0.0.0-20200327141045-8b5f551d37f5/go.mod h1:PN0QNTLs9+j1bKy3d/GB/59wsNBFC4sWLWG3k69lWbc=
 41+github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
 42+github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
 43+github.com/blevesearch/mmap-go v1.0.2/go.mod h1:ol2qBqYaOUsGdm7aRMRrYGgPvnwLe6Y+7LMvAB5IbSA=
 44+github.com/blevesearch/mmap-go v1.2.0 h1:l33nNKPFcBjJUMwem6sAYJPUzhUCABoK9FxZDGiFNBI=
 45+github.com/blevesearch/mmap-go v1.2.0/go.mod h1:Vd6+20GBhEdwJnU1Xohgt88XCD/CTWcqbCNxkZpyBo0=
 46+github.com/blevesearch/segment v0.9.0/go.mod h1:9PfHYUdQCgHktBgvtUOF4x+pc4/l8rdH0u5spnW85UQ=
 47+github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
 48+github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
 49+github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
 50+github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
 51+github.com/blevesearch/zap/v11 v11.0.14 h1:IrDAvtlzDylh6H2QCmS0OGcN9Hpf6mISJlfKjcwJs7k=
 52+github.com/blevesearch/zap/v11 v11.0.14/go.mod h1:MUEZh6VHGXv1PKx3WnCbdP404LGG2IZVa/L66pyFwnY=
 53+github.com/blevesearch/zap/v12 v12.0.14 h1:2o9iRtl1xaRjsJ1xcqTyLX414qPAwykHNV7wNVmbp3w=
 54+github.com/blevesearch/zap/v12 v12.0.14/go.mod h1:rOnuZOiMKPQj18AEKEHJxuI14236tTQ1ZJz4PAnWlUg=
 55+github.com/blevesearch/zap/v13 v13.0.6 h1:r+VNSVImi9cBhTNNR+Kfl5uiGy8kIbb0JMz/h8r6+O4=
 56+github.com/blevesearch/zap/v13 v13.0.6/go.mod h1:L89gsjdRKGyGrRN6nCpIScCvvkyxvmeDCwZRcjjPCrw=
 57+github.com/blevesearch/zap/v14 v14.0.5 h1:NdcT+81Nvmp2zL+NhwSvGSLh7xNgGL8QRVZ67njR0NU=
 58+github.com/blevesearch/zap/v14 v14.0.5/go.mod h1:bWe8S7tRrSBTIaZ6cLRbgNH4TUDaC9LZSpRGs85AsGY=
 59+github.com/blevesearch/zap/v15 v15.0.3 h1:Ylj8Oe+mo0P25tr9iLPp33lN6d4qcztGjaIsP51UxaY=
 60+github.com/blevesearch/zap/v15 v15.0.3/go.mod h1:iuwQrImsh1WjWJ0Ue2kBqY83a0rFtJTqfa9fp1rbVVU=
 61+github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
 62+github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
 63+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
 64+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
 65+github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
 66+github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
 67+github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
 68+github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
 69+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
 70+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
 71+github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
 72+github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
 73+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
 74+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
 75+github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
 76+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
 77+github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
 78+github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
 79+github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
 80+github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
 81+github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
 82+github.com/couchbase/ghistogram v0.1.0/go.mod h1:s1Jhy76zqfEecpNWJfWUiKZookAFaiGOEoyzgHt9i7k=
 83+github.com/couchbase/moss v0.1.0/go.mod h1:9MaHIaRuy9pvLPUJxB8sh8OrLfyDczECVL37grCIubs=
 84+github.com/couchbase/vellum v1.0.2 h1:BrbP0NKiyDdndMPec8Jjhy0U47CZ0Lgx3xUC2r9rZqw=
 85+github.com/couchbase/vellum v1.0.2/go.mod h1:FcwrEivFpNi24R3jLOs3n+fs5RnuQnQqCLBJ1uAg1W4=
 86+github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
 87+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 88+github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
 89+github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
 90+github.com/cznic/b v0.0.0-20181122101859-a26611c4d92d/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8=
 91+github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM=
 92+github.com/cznic/strutil v0.0.0-20181122101858-275e90344537/go.mod h1:AHHPPPXTw0h6pVabbcbyGRK1DckRn7r/STdZEeIDzZc=
 93+github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
 94+github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
 95+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 96+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 97+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 98+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 99+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
100+github.com/dvsekhvalnov/jose2go v1.8.0 h1:LqkkVKAlHFfH9LOEl5fe4p/zL02OhWE7pCufMBG2jLA=
101+github.com/dvsekhvalnov/jose2go v1.8.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
102+github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
103+github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
104+github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64=
105+github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg=
106+github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=
107+github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
108+github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
109+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
110+github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
111+github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
112+github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
113+github.com/gdamore/tcell/v2 v2.4.0/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU=
114+github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
115+github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
116+github.com/git-bug/git-bug v0.10.1 h1:mjJK/wrfKWUze5EkVpJj+3N3LzSfeujqqH1qq8x4aco=
117+github.com/git-bug/git-bug v0.10.1/go.mod h1:43BRtb/Nr6QEJlNLkLY/64vyopxA0y5nvjSNsktnan8=
118+github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
119+github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
120+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
121+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
122+github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
123+github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
124+github.com/go-git/go-git/v5 v5.17.2 h1:B+nkdlxdYrvyFK4GPXVU8w1U+YkbsgciIR7f2sZJ104=
125+github.com/go-git/go-git/v5 v5.17.2/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
126+github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
127+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
128+github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0=
129+github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
130+github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
131+github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
132+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
133+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
134+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
135+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
136+github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
137+github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
138+github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
139+github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
140+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
141+github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
142+github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
143+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
144+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
145+github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
146+github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
147+github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
148+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
149+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
150+github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
151+github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
152+github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
153+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
154+github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
155+github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
156+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
157+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
158+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
159+github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
160+github.com/ikawaha/kagome.ipadic v1.1.2/go.mod h1:DPSBbU0czaJhAb/5uKQZHMc9MTVRpDugJfX+HddPHHg=
161+github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
162+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
163+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
164+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
165+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
166+github.com/jmhodges/levigo v1.0.0/go.mod h1:Q6Qx+uH3RAqyK4rFQroq9RL7mdkABMcfhEI+nNuzMJQ=
167+github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
168+github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
169+github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
170+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
171+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
172+github.com/kljensen/snowball v0.6.0/go.mod h1:27N7E8fVU5H68RlUmnWwZCfxgt4POBJfENGMvNRhldw=
173+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
174+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
175+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
176+github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
177+github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
178+github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
179+github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
180+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
181+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
182+github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
183+github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
184+github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
185+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
186+github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
187+github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
188+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
189+github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
190+github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
191+github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
192+github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
193+github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
194+github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
195+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
196+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
197+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
198+github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
199+github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
200+github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
201+github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
202+github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI=
203+github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE=
204+github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
205+github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
206+github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
207+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
208+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
209+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
210+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
211+github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
212+github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
213+github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
214+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
215+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
216+github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
217+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
218+github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
219+github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
220+github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
221+github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed h1:KT7hI8vYXgU0s2qaMkrfq9tCA1w/iEPgfredVP+4Tzw=
222+github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8=
223+github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf h1:o1uxfymjZ7jZ4MsgCErcwWGtVKSiNAXtS59Lhs6uI/g=
224+github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE=
225+github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c h1:aqg5Vm5dwtvL+YgDpBcK1ITf3o96N/K7/wsRXQnUTEs=
226+github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c/go.mod h1:owqhoLW1qZoYLZzLnBw+QkPP9WZnjlSWihhxAJC1+/M=
227+github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92 h1:OfRzdxCzDhp+rsKWXuOO2I/quKMJ/+TQwVbIP/gltZg=
228+github.com/shurcooL/vfsgen v0.0.0-20230704071429-0000e147ea92/go.mod h1:7/OT02F6S6I7v6WXb+IjhMuZEYfH/RJ5RwEWnEo5BMg=
229+github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
230+github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
231+github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
232+github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
233+github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
234+github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
235+github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
236+github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
237+github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
238+github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
239+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
240+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
241+github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
242+github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
243+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
244+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
245+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
246+github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
247+github.com/steveyen/gtreap v0.1.0 h1:CjhzTa274PyJLJuMZwIzCO1PfC00oRa8d1Kc78bFXJM=
248+github.com/steveyen/gtreap v0.1.0/go.mod h1:kl/5J7XbrOmlIbYIXdRHDDE5QxHqpk0cmkT7Z4dM9/Y=
249+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
250+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
251+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
252+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
253+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
254+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
255+github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
256+github.com/tebeka/snowball v0.4.2/go.mod h1:4IfL14h1lvwZcp1sfXuuc7/7yCsvVffTWxWxCLfFpYg=
257+github.com/tecbot/gorocksdb v0.0.0-20191217155057-f0fad39f321c/go.mod h1:ahpPrc7HpcfEWDQRZEmnXMzHY03mLDYMCxeDzy46i+8=
258+github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
259+github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
260+github.com/vbauerster/mpb/v8 v8.12.0 h1:+gneY3ifzc88tKDzOtfG8k8gfngCx615S2ZmFM4liWg=
261+github.com/vbauerster/mpb/v8 v8.12.0/go.mod h1:V02YIuMVo301Y1VE9VtZlD8s84OMsk+EKN6mwvf/588=
262+github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
263+github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
264+github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
265+github.com/willf/bitset v1.1.11 h1:N7Z7E9UvjW+sGsEl7k/SJrvY2reP1A07MrGuCjIOjRE=
266+github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI=
267+github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
268+github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
269+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
270+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
271+github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
272+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
273+gitlab.com/gitlab-org/api/client-go v1.46.0 h1:YxBWFZIFYKcGESCb9fpkwzouo+apyB9pr/XTWzNoL24=
274+gitlab.com/gitlab-org/api/client-go v1.46.0/go.mod h1:FtgyU6g2HS5+fMhw6nLK96GBEEBx5MzntOiJWfIaiN8=
275+go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
276+go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
277+go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
278+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
279+golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
280+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
281+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
282+golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
283+golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
284+golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
285+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
286+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
287+golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
288+golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
289+golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
290+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
291+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
292+golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
293+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
294+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
295+golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
296+golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
297+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
298+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
299+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
300+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
301+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
302+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
303+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
304+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
305+golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
306+golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
307+golang.org/x/sys v0.0.0-20181221143128-b4a75ba826a6/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
308+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
309+golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
310+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
311+golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
312+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
313+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
314+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
315+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
316+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
317+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
318+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
319+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
320+golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
321+golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
322+golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4=
323+golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
324+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
325+golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
326+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
327+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
328+golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
329+golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
330+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
331+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
332+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
333+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
334+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
335+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
336+golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
337+golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
338+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
339+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
340+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
341+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
342+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
343+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
344+golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
345+golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
346+golang.org/x/vuln v1.1.4 h1:Ju8QsuyhX3Hk8ma3CesTbO8vfJD9EvUBgHvkxHBzj0I=
347+golang.org/x/vuln v1.1.4/go.mod h1:F+45wmU18ym/ca5PLTPLsSzr2KppzswxPP603ldA67s=
348+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
349+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
350+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
351+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
352+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
353+gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
354+gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
355+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
356+gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
357+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
358+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
359+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
360+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
361+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
362+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
363+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
A init_integration_test.go
+390, -0
  1@@ -0,0 +1,390 @@
  2+//go:build integration
  3+
  4+package main
  5+
  6+import (
  7+	"os"
  8+	"os/exec"
  9+	"path/filepath"
 10+	"strings"
 11+	"testing"
 12+
 13+	"github.com/git-bug/git-bug/entities/identity"
 14+	"github.com/git-bug/git-bug/repository"
 15+)
 16+
 17+// setupGitRepo creates a git repo with an origin remote for testing
 18+func setupGitRepo(t *testing.T) string {
 19+	tmpDir := t.TempDir()
 20+
 21+	// Initialize a git repo
 22+	initCmd := exec.Command("git", "init")
 23+	initCmd.Dir = tmpDir
 24+	if err := initCmd.Run(); err != nil {
 25+		t.Fatalf("failed to init git repo: %v", err)
 26+	}
 27+	// Add origin remote for bug init
 28+	remoteCmd := exec.Command("git", "remote", "add", "origin", "[email protected]:test/repo.git")
 29+	remoteCmd.Dir = tmpDir
 30+	if err := remoteCmd.Run(); err != nil {
 31+		t.Fatalf("failed to add origin remote: %v", err)
 32+	}
 33+
 34+	// Configure git user for the repo (needed for git operations)
 35+	exec.Command("git", "-C", tmpDir, "config", "user.email", "[email protected]").Run()
 36+	exec.Command("git", "-C", tmpDir, "config", "user.name", "Test User").Run()
 37+
 38+	return tmpDir
 39+}
 40+
 41+// setupGitRepoWithInit creates a git repo with origin remote and runs bug init
 42+func setupGitRepoWithInit(t *testing.T) string {
 43+	tmpDir := setupGitRepo(t)
 44+
 45+	// Initialize git-bug
 46+	if err := runInitWithReader(tmpDir, strings.NewReader("Test User\[email protected]\n")); err != nil {
 47+		t.Fatalf("runInit failed: %v", err)
 48+	}
 49+
 50+	return tmpDir
 51+}
 52+
 53+// TestInitCommand_WithJJ tests init when jj config is available
 54+func TestInitCommand_WithJJ(t *testing.T) {
 55+	tmpDir := setupGitRepo(t)
 56+
 57+	// Create .jj directory and config file directly
 58+	jjDir := filepath.Join(tmpDir, ".jj")
 59+	if err := os.MkdirAll(jjDir, 0755); err != nil {
 60+		t.Fatalf("failed to create .jj directory: %v", err)
 61+	}
 62+
 63+	// Write jj config file directly
 64+	configContent := `[user]
 65+name = "JJ Test User"
 66+email = "[email protected]"
 67+`
 68+	configPath := filepath.Join(jjDir, "config.toml")
 69+	if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
 70+		t.Fatalf("failed to write jj config: %v", err)
 71+	}
 72+
 73+	// Run init
 74+	err := runInit(tmpDir)
 75+	if err != nil {
 76+		t.Fatalf("runInit failed: %v", err)
 77+	}
 78+
 79+	// Verify identities were created
 80+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
 81+	if err != nil {
 82+		t.Fatalf("failed to open repo: %v", err)
 83+	}
 84+	defer repo.Close()
 85+
 86+	ids, err := identity.ListLocalIds(repo)
 87+	if err != nil {
 88+		t.Fatalf("ListLocalIds failed: %v", err)
 89+	}
 90+
 91+	// Should have at least 2 identities (user + agent)
 92+	if len(ids) < 2 {
 93+		t.Errorf("expected at least 2 identities, got %d", len(ids))
 94+	}
 95+
 96+	// Check if user identity is set
 97+	isSet, err := identity.IsUserIdentitySet(repo)
 98+	if err != nil {
 99+		t.Fatalf("IsUserIdentitySet failed: %v", err)
100+	}
101+	if !isSet {
102+		t.Error("expected user identity to be set")
103+	}
104+
105+	// Verify refspecs were configured
106+	config, err := parseGitConfig(tmpDir)
107+	if err != nil {
108+		t.Fatalf("failed to parse git config: %v", err)
109+	}
110+
111+	originSection := `remote "origin"`
112+	if _, exists := config[originSection]; !exists {
113+		t.Fatal("origin remote not found in config")
114+	}
115+
116+	// Check fetch refspecs
117+	hasBugsFetch := false
118+	hasIdentitiesFetch := false
119+	for _, refspec := range config[originSection]["fetch"] {
120+		if refspec == "+refs/bugs/*:refs/remotes/origin/bugs/*" {
121+			hasBugsFetch = true
122+		}
123+		if refspec == "+refs/identities/*:refs/remotes/origin/identities/*" {
124+			hasIdentitiesFetch = true
125+		}
126+	}
127+	if !hasBugsFetch {
128+		t.Error("bugs fetch refspec not configured")
129+	}
130+	if !hasIdentitiesFetch {
131+		t.Error("identities fetch refspec not configured")
132+	}
133+
134+	// Check push refspecs
135+	hasBugsPush := false
136+	hasIdentitiesPush := false
137+	for _, refspec := range config[originSection]["push"] {
138+		if refspec == "+refs/bugs/*:refs/bugs/*" {
139+			hasBugsPush = true
140+		}
141+		if refspec == "+refs/identities/*:refs/identities/*" {
142+			hasIdentitiesPush = true
143+		}
144+	}
145+	if !hasBugsPush {
146+		t.Error("bugs push refspec not configured")
147+	}
148+	if !hasIdentitiesPush {
149+		t.Error("identities push refspec not configured")
150+	}
151+}
152+
153+// TestInitCommand_WithoutJJ tests init without jj config (interactive mode)
154+func TestInitCommand_WithoutJJ(t *testing.T) {
155+	tmpDir := setupGitRepo(t)
156+
157+	// Simulate user input for interactive mode
158+	input := "Interactive User\[email protected]\n"
159+
160+	// Run init with simulated input
161+	err := runInitWithReader(tmpDir, strings.NewReader(input))
162+	if err != nil {
163+		t.Fatalf("runInitWithReader failed: %v", err)
164+	}
165+
166+	// Verify identities were created
167+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
168+	if err != nil {
169+		t.Fatalf("failed to open repo: %v", err)
170+	}
171+	defer repo.Close()
172+
173+	ids, err := identity.ListLocalIds(repo)
174+	if err != nil {
175+		t.Fatalf("ListLocalIds failed: %v", err)
176+	}
177+
178+	// Should have at least 2 identities (user + agent)
179+	if len(ids) < 2 {
180+		t.Errorf("expected at least 2 identities, got %d", len(ids))
181+	}
182+
183+	// Verify the user identity has the correct info
184+	isSet, err := identity.IsUserIdentitySet(repo)
185+	if err != nil {
186+		t.Fatalf("IsUserIdentitySet failed: %v", err)
187+	}
188+	if !isSet {
189+		t.Error("expected user identity to be set")
190+	}
191+
192+	userIdentity, err := identity.GetUserIdentity(repo)
193+	if err != nil {
194+		t.Fatalf("GetUserIdentity failed: %v", err)
195+	}
196+
197+	if userIdentity.Name() != "Interactive User" {
198+		t.Errorf("expected name 'Interactive User', got %q", userIdentity.Name())
199+	}
200+
201+	if userIdentity.Email() != "[email protected]" {
202+		t.Errorf("expected email '[email protected]', got %q", userIdentity.Email())
203+	}
204+}
205+
206+// TestInitCommand_EmptyNameError tests that empty name is rejected
207+func TestInitCommand_EmptyNameError(t *testing.T) {
208+	tmpDir := setupGitRepo(t)
209+
210+	// Simulate empty name input
211+	input := "\n"
212+
213+	err := runInitWithReader(tmpDir, strings.NewReader(input))
214+	if err == nil {
215+		t.Error("expected error for empty name, got nil")
216+	}
217+
218+	if !strings.Contains(err.Error(), "name cannot be empty") {
219+		t.Errorf("expected 'name cannot be empty' error, got: %v", err)
220+	}
221+}
222+
223+// TestInitCommand_NoOriginRemote tests that init fails without origin remote
224+func TestInitCommand_NoOriginRemote(t *testing.T) {
225+	tmpDir := t.TempDir()
226+
227+	// Initialize a git repo WITHOUT origin remote
228+	initCmd := exec.Command("git", "init")
229+	initCmd.Dir = tmpDir
230+	if err := initCmd.Run(); err != nil {
231+		t.Fatalf("failed to init git repo: %v", err)
232+	}
233+
234+	// Configure git user
235+	exec.Command("git", "-C", tmpDir, "config", "user.email", "[email protected]").Run()
236+	exec.Command("git", "-C", tmpDir, "config", "user.name", "Test User").Run()
237+
238+	// Create jj config to avoid interactive mode
239+	jjDir := filepath.Join(tmpDir, ".jj")
240+	if err := os.MkdirAll(jjDir, 0755); err != nil {
241+		t.Fatalf("failed to create .jj directory: %v", err)
242+	}
243+	configContent := `[user]
244+name = "JJ Test User"
245+email = "[email protected]"
246+`
247+	configPath := filepath.Join(jjDir, "config.toml")
248+	if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
249+		t.Fatalf("failed to write jj config: %v", err)
250+	}
251+
252+	// Run init - should fail with ErrNoOriginRemote
253+	err := runInit(tmpDir)
254+	if err == nil {
255+		t.Fatal("expected error when no origin remote, got nil")
256+	}
257+
258+	// Check that it's the specific ErrNoOriginRemote type
259+	if _, ok := err.(ErrNoOriginRemote); !ok {
260+		t.Errorf("expected ErrNoOriginRemote, got: %T - %v", err, err)
261+	}
262+}
263+
264+// TestInitCommand_IdempotentIdentities tests that running init twice doesn't duplicate identities
265+func TestInitCommand_IdempotentIdentities(t *testing.T) {
266+	tmpDir := setupGitRepo(t)
267+
268+	// Create jj config
269+	jjDir := filepath.Join(tmpDir, ".jj")
270+	if err := os.MkdirAll(jjDir, 0755); err != nil {
271+		t.Fatalf("failed to create .jj directory: %v", err)
272+	}
273+	configContent := `[user]
274+name = "JJ Test User"
275+email = "[email protected]"
276+`
277+	configPath := filepath.Join(jjDir, "config.toml")
278+	if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
279+		t.Fatalf("failed to write jj config: %v", err)
280+	}
281+
282+	// Run init first time
283+	err := runInit(tmpDir)
284+	if err != nil {
285+		t.Fatalf("first runInit failed: %v", err)
286+	}
287+
288+	// Count identities after first run
289+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
290+	if err != nil {
291+		t.Fatalf("failed to open repo: %v", err)
292+	}
293+
294+	ids1, err := identity.ListLocalIds(repo)
295+	if err != nil {
296+		t.Fatalf("ListLocalIds failed: %v", err)
297+	}
298+	repo.Close()
299+
300+	// Run init second time
301+	err = runInit(tmpDir)
302+	if err != nil {
303+		t.Fatalf("second runInit failed: %v", err)
304+	}
305+
306+	// Count identities after second run
307+	repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
308+	if err != nil {
309+		t.Fatalf("failed to open repo: %v", err)
310+	}
311+	defer repo.Close()
312+
313+	ids2, err := identity.ListLocalIds(repo)
314+	if err != nil {
315+		t.Fatalf("ListLocalIds failed: %v", err)
316+	}
317+
318+	// Should have same number of identities
319+	if len(ids1) != len(ids2) {
320+		t.Errorf("identities count changed after second init: first=%d, second=%d", len(ids1), len(ids2))
321+	}
322+
323+	// Verify identities are the same
324+	idMap := make(map[string]bool)
325+	for _, id := range ids1 {
326+		idMap[id.String()] = true
327+	}
328+	for _, id := range ids2 {
329+		if !idMap[id.String()] {
330+			t.Errorf("new identity found after second init: %s", id.String())
331+		}
332+	}
333+}
334+
335+// TestInitCommand_IdempotentRefspecs tests that running init twice doesn't duplicate refspecs
336+func TestInitCommand_IdempotentRefspecs(t *testing.T) {
337+	tmpDir := setupGitRepo(t)
338+
339+	// Create jj config
340+	jjDir := filepath.Join(tmpDir, ".jj")
341+	if err := os.MkdirAll(jjDir, 0755); err != nil {
342+		t.Fatalf("failed to create .jj directory: %v", err)
343+	}
344+	configContent := `[user]
345+name = "JJ Test User"
346+email = "[email protected]"
347+`
348+	configPath := filepath.Join(jjDir, "config.toml")
349+	if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
350+		t.Fatalf("failed to write jj config: %v", err)
351+	}
352+
353+	// Run init first time
354+	err := runInit(tmpDir)
355+	if err != nil {
356+		t.Fatalf("first runInit failed: %v", err)
357+	}
358+
359+	// Get refspec count after first run
360+	config1, err := parseGitConfig(tmpDir)
361+	if err != nil {
362+		t.Fatalf("failed to parse git config: %v", err)
363+	}
364+
365+	originSection := `remote "origin"`
366+	fetchCount1 := len(config1[originSection]["fetch"])
367+	pushCount1 := len(config1[originSection]["push"])
368+
369+	// Run init second time
370+	err = runInit(tmpDir)
371+	if err != nil {
372+		t.Fatalf("second runInit failed: %v", err)
373+	}
374+
375+	// Get refspec count after second run
376+	config2, err := parseGitConfig(tmpDir)
377+	if err != nil {
378+		t.Fatalf("failed to parse git config: %v", err)
379+	}
380+
381+	fetchCount2 := len(config2[originSection]["fetch"])
382+	pushCount2 := len(config2[originSection]["push"])
383+
384+	// Should have same number of refspecs
385+	if fetchCount1 != fetchCount2 {
386+		t.Errorf("fetch refspecs count changed: first=%d, second=%d", fetchCount1, fetchCount2)
387+	}
388+	if pushCount1 != pushCount2 {
389+		t.Errorf("push refspecs count changed: first=%d, second=%d", pushCount1, pushCount2)
390+	}
391+}
A main.go
+2544, -0
   1@@ -0,0 +1,2544 @@
   2+package main
   3+
   4+import (
   5+	"bufio"
   6+	"fmt"
   7+	"io"
   8+	"os"
   9+	"os/exec"
  10+	"path/filepath"
  11+	"sort"
  12+	"strings"
  13+	"time"
  14+
  15+	"github.com/charmbracelet/lipgloss"
  16+	"github.com/dustin/go-humanize"
  17+	"github.com/git-bug/git-bug/entities/bug"
  18+	"github.com/git-bug/git-bug/entities/identity"
  19+	"github.com/git-bug/git-bug/entity"
  20+	"github.com/git-bug/git-bug/repository"
  21+	"github.com/spf13/cobra"
  22+)
  23+
  24+// BugIssue represents a git-bug issue for display
  25+type BugIssue struct {
  26+	FullID    string
  27+	ShortID   string
  28+	Title     string
  29+	Labels    []string
  30+	CreatedAt time.Time
  31+	Status    string
  32+}
  33+
  34+// LoadBugs loads all issues from the git-bug repository
  35+func LoadBugs(repoPath string) ([]BugIssue, error) {
  36+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
  37+	if err != nil {
  38+		return nil, fmt.Errorf("failed to open repository: %w", err)
  39+	}
  40+
  41+	// Generate short IDs for all issues
  42+	gen := NewShortIDGenerator(repoPath)
  43+	shortIDMap, err := gen.Generate()
  44+	if err != nil {
  45+		return nil, fmt.Errorf("failed to generate short IDs: %w", err)
  46+	}
  47+
  48+	var issues []BugIssue
  49+
  50+	for streamedBug := range bug.ReadAll(repo) {
  51+		if streamedBug.Err != nil {
  52+			continue // Skip errors, process what we can
  53+		}
  54+
  55+		b := streamedBug.Entity
  56+		snap := b.Compile()
  57+
  58+		fullID := b.Id().String()
  59+		shortID, _ := shortIDMap.GetShortID(fullID)
  60+
  61+		labels := make([]string, len(snap.Labels))
  62+		for i, label := range snap.Labels {
  63+			labels[i] = string(label)
  64+		}
  65+
  66+		issues = append(issues, BugIssue{
  67+			FullID:    fullID,
  68+			ShortID:   shortID,
  69+			Title:     snap.Title,
  70+			Labels:    labels,
  71+			CreatedAt: snap.CreateTime,
  72+			Status:    snap.Status.String(),
  73+		})
  74+	}
  75+
  76+	return issues, nil
  77+}
  78+
  79+// FilterSpec represents a filter criteria
  80+type FilterSpec struct {
  81+	Labels   []string      // List of labels that must all be present
  82+	AgeOp    string        // "<" or ">"
  83+	AgeValue time.Duration // The duration value for age comparison
  84+}
  85+
  86+// ParseFilter parses a filter string like "label:bug" or "age:<10d"
  87+func ParseFilter(filterStr string) (*FilterSpec, error) {
  88+	if filterStr == "" {
  89+		return nil, nil
  90+	}
  91+
  92+	spec := &FilterSpec{}
  93+
  94+	// Handle label filter: label:value or label:value1,value2,...
  95+	if strings.HasPrefix(filterStr, "label:") {
  96+		labelStr := strings.TrimPrefix(filterStr, "label:")
  97+		// Split by comma to support multiple labels
  98+		labels := strings.Split(labelStr, ",")
  99+		// Trim whitespace from each label
 100+		for i, label := range labels {
 101+			labels[i] = strings.TrimSpace(label)
 102+		}
 103+		spec.Labels = labels
 104+		return spec, nil
 105+	}
 106+
 107+	// Handle age filter: age:<duration>, age:>duration, or age:duration
 108+	if strings.HasPrefix(filterStr, "age:") {
 109+		ageStr := strings.TrimPrefix(filterStr, "age:")
 110+
 111+		// Check for operator
 112+		spec.AgeOp = "<" // Default operator
 113+		if strings.HasPrefix(ageStr, "<") {
 114+			ageStr = strings.TrimPrefix(ageStr, "<")
 115+		} else if strings.HasPrefix(ageStr, ">") {
 116+			spec.AgeOp = ">"
 117+			ageStr = strings.TrimPrefix(ageStr, ">")
 118+		}
 119+
 120+		// Parse duration like "10d", "1h", etc.
 121+		duration, err := time.ParseDuration(ageStr)
 122+		if err != nil {
 123+			// Try to parse days (e.g., "10d" -> "240h")
 124+			if strings.HasSuffix(ageStr, "d") {
 125+				daysStr := strings.TrimSuffix(ageStr, "d")
 126+				var days int
 127+				if _, err := fmt.Sscanf(daysStr, "%d", &days); err == nil {
 128+					duration = time.Duration(days) * 24 * time.Hour
 129+				} else {
 130+					return nil, fmt.Errorf("invalid age filter: %s", filterStr)
 131+				}
 132+			} else {
 133+				return nil, fmt.Errorf("invalid age filter: %s", filterStr)
 134+			}
 135+		}
 136+		spec.AgeValue = duration
 137+		return spec, nil
 138+	}
 139+
 140+	return nil, fmt.Errorf("invalid filter format: %s", filterStr)
 141+}
 142+
 143+// ApplyFilter filters issues based on the filter spec
 144+func ApplyFilter(issues []BugIssue, spec *FilterSpec) []BugIssue {
 145+	if spec == nil {
 146+		return issues
 147+	}
 148+
 149+	var filtered []BugIssue
 150+	now := time.Now()
 151+
 152+	for _, issue := range issues {
 153+		// Check label filter - must have ALL specified labels
 154+		if len(spec.Labels) > 0 {
 155+			hasAllLabels := true
 156+			for _, requiredLabel := range spec.Labels {
 157+				found := false
 158+				for _, label := range issue.Labels {
 159+					if label == requiredLabel {
 160+						found = true
 161+						break
 162+					}
 163+				}
 164+				if !found {
 165+					hasAllLabels = false
 166+					break
 167+				}
 168+			}
 169+			if !hasAllLabels {
 170+				continue
 171+			}
 172+		}
 173+
 174+		// Check age filter
 175+		if spec.AgeValue > 0 {
 176+			age := now.Sub(issue.CreatedAt)
 177+			if spec.AgeOp == "<" {
 178+				// Bugs newer than AgeValue
 179+				if age > spec.AgeValue {
 180+					continue
 181+				}
 182+			} else if spec.AgeOp == ">" {
 183+				// Bugs older than AgeValue
 184+				if age < spec.AgeValue {
 185+					continue
 186+				}
 187+			}
 188+		}
 189+
 190+		filtered = append(filtered, issue)
 191+	}
 192+
 193+	return filtered
 194+}
 195+
 196+// SortSpec represents sort criteria
 197+type SortSpec struct {
 198+	Field     string // "id" or "age"
 199+	Direction string // "asc" or "desc"
 200+}
 201+
 202+// ParseSort parses a sort string like "id:asc" or "age:desc"
 203+func ParseSort(sortStr string) (*SortSpec, error) {
 204+	if sortStr == "" {
 205+		// Default: age:desc (newest first)
 206+		return &SortSpec{Field: "age", Direction: "desc"}, nil
 207+	}
 208+
 209+	parts := strings.Split(sortStr, ":")
 210+	if len(parts) != 2 {
 211+		return nil, fmt.Errorf("invalid sort format: %s (expected field:direction)", sortStr)
 212+	}
 213+
 214+	field := strings.ToLower(parts[0])
 215+	direction := strings.ToLower(parts[1])
 216+
 217+	if field != "id" && field != "age" {
 218+		return nil, fmt.Errorf("invalid sort field: %s (expected 'id' or 'age')", field)
 219+	}
 220+
 221+	if direction != "asc" && direction != "desc" {
 222+		return nil, fmt.Errorf("invalid sort direction: %s (expected 'asc' or 'desc')", direction)
 223+	}
 224+
 225+	return &SortSpec{Field: field, Direction: direction}, nil
 226+}
 227+
 228+// ApplySort sorts issues based on the sort spec
 229+func ApplySort(issues []BugIssue, spec *SortSpec) {
 230+	if spec == nil {
 231+		return
 232+	}
 233+
 234+	sort.Slice(issues, func(i, j int) bool {
 235+		var less bool
 236+
 237+		switch spec.Field {
 238+		case "id":
 239+			less = issues[i].FullID < issues[j].FullID
 240+		case "age":
 241+			less = issues[i].CreatedAt.After(issues[j].CreatedAt) // Newer first
 242+		}
 243+
 244+		if spec.Direction == "desc" {
 245+			return !less
 246+		}
 247+		return less
 248+	})
 249+}
 250+
 251+// formatID renders the ID with the short portion in purple
 252+// Shows at least 7 characters, with the short ID portion colored
 253+func formatID(fullID, shortID string) string {
 254+	purpleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#9932CC")) // Purple
 255+	normalStyle := lipgloss.NewStyle()
 256+
 257+	// Always show at least 7 characters
 258+	displayLen := 7
 259+	if len(shortID) > displayLen {
 260+		displayLen = len(shortID)
 261+	}
 262+	if displayLen > len(fullID) {
 263+		displayLen = len(fullID)
 264+	}
 265+
 266+	shortLen := len(shortID)
 267+	if shortLen > displayLen {
 268+		shortLen = displayLen
 269+	}
 270+
 271+	return purpleStyle.Render(fullID[:shortLen]) + normalStyle.Render(fullID[shortLen:displayLen])
 272+}
 273+
 274+// stripANSI removes ANSI escape codes from a string
 275+func stripANSI(s string) string {
 276+	result := make([]rune, 0, len(s))
 277+	inEscape := false
 278+	for _, r := range s {
 279+		if inEscape {
 280+			if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
 281+				inEscape = false
 282+			}
 283+			continue
 284+		}
 285+		if r == '\x1b' {
 286+			inEscape = true
 287+			continue
 288+		}
 289+		result = append(result, r)
 290+	}
 291+	return string(result)
 292+}
 293+
 294+// printTable prints a simple text table with styled output
 295+// Columns are dynamically sized with at least 2 spaces between them
 296+func printTable(issues []BugIssue) error {
 297+	// First pass: calculate column widths based on content
 298+	const minColSpacing = 2
 299+	const maxSummaryLen = 70
 300+
 301+	idWidth := len("ID")
 302+	summaryWidth := len("Summary")
 303+	labelsWidth := len("Labels")
 304+	ageWidth := len("Age")
 305+
 306+	// Pre-calculate all formatted values and widths
 307+	type rowData struct {
 308+		id      string
 309+		summary string
 310+		labels  string
 311+		age     string
 312+	}
 313+
 314+	rows := make([]rowData, len(issues))
 315+
 316+	for i, issue := range issues {
 317+		// Format ID (with ANSI codes)
 318+		id := formatID(issue.FullID, issue.ShortID)
 319+		idVisualLen := len(stripANSI(id))
 320+		if idVisualLen > idWidth {
 321+			idWidth = idVisualLen
 322+		}
 323+
 324+		// Format summary (truncate at 70 chars)
 325+		summary := issue.Title
 326+		if len(summary) > maxSummaryLen {
 327+			summary = summary[:maxSummaryLen-3] + "..."
 328+		}
 329+		if len(summary) > summaryWidth {
 330+			summaryWidth = len(summary)
 331+		}
 332+
 333+		// Format labels
 334+		labels := strings.Join(issue.Labels, ", ")
 335+		if labels == "" {
 336+			labels = "-"
 337+		}
 338+		if len(labels) > labelsWidth {
 339+			labelsWidth = len(labels)
 340+		}
 341+
 342+		// Format age
 343+		age := humanize.Time(issue.CreatedAt)
 344+		if len(age) > ageWidth {
 345+			ageWidth = len(age)
 346+		}
 347+
 348+		rows[i] = rowData{id: id, summary: summary, labels: labels, age: age}
 349+	}
 350+
 351+	// Print header
 352+	fmt.Printf("%s%s%s%s%s%s%s\n",
 353+		padRight("ID", idWidth),
 354+		strings.Repeat(" ", minColSpacing),
 355+		padRight("Summary", summaryWidth),
 356+		strings.Repeat(" ", minColSpacing),
 357+		padRight("Labels", labelsWidth),
 358+		strings.Repeat(" ", minColSpacing),
 359+		padRight("Age", ageWidth),
 360+	)
 361+
 362+	// Print separator
 363+	totalWidth := idWidth + minColSpacing + summaryWidth + minColSpacing + labelsWidth + minColSpacing + ageWidth
 364+	fmt.Println(strings.Repeat("-", totalWidth))
 365+
 366+	// Print rows
 367+	for _, row := range rows {
 368+		fmt.Printf("%s%s%s%s%s%s%s\n",
 369+			padRight(row.id, idWidth),
 370+			strings.Repeat(" ", minColSpacing),
 371+			padRight(row.summary, summaryWidth),
 372+			strings.Repeat(" ", minColSpacing),
 373+			padRight(row.labels, labelsWidth),
 374+			strings.Repeat(" ", minColSpacing),
 375+			padRight(row.age, ageWidth),
 376+		)
 377+	}
 378+
 379+	return nil
 380+}
 381+
 382+// padRight pads a string to the specified width, accounting for ANSI codes
 383+func padRight(s string, width int) string {
 384+	visualLen := len(stripANSI(s))
 385+	padding := width - visualLen
 386+	if padding < 0 {
 387+		padding = 0
 388+	}
 389+	return s + strings.Repeat(" ", padding)
 390+}
 391+
 392+// promptUser prompts the user for input with the given message
 393+// It reads from stdin and returns the trimmed input
 394+func promptUser(prompt string) (string, error) {
 395+	fmt.Print(prompt)
 396+	scanner := bufio.NewScanner(os.Stdin)
 397+	if scanner.Scan() {
 398+		return strings.TrimSpace(scanner.Text()), nil
 399+	}
 400+	if err := scanner.Err(); err != nil {
 401+		return "", fmt.Errorf("failed to read input: %w", err)
 402+	}
 403+	return "", nil
 404+}
 405+
 406+// invalidateGitBugCache deletes the git-bug cache directories to force a rebuild.
 407+// This ensures the `bug` command and `git-bug` binary stay in sync when either
 408+// is used to modify issues or identities. Failures are silent (best effort).
 409+func invalidateGitBugCache(repoPath string) {
 410+	gitBugPath := filepath.Join(repoPath, ".git", "git-bug")
 411+
 412+	// Remove cache directory (serialized excerpts)
 413+	cachePath := filepath.Join(gitBugPath, "cache")
 414+	_ = os.RemoveAll(cachePath)
 415+
 416+	// Remove indexes directory (bleve search indexes)
 417+	indexesPath := filepath.Join(gitBugPath, "indexes")
 418+	_ = os.RemoveAll(indexesPath)
 419+}
 420+
 421+// promptUserWithScanner prompts the user using the provided scanner
 422+// It prints the prompt and returns the trimmed input
 423+func promptUserWithScanner(prompt string, scanner *bufio.Scanner) (string, error) {
 424+	fmt.Print(prompt)
 425+	if scanner.Scan() {
 426+		return strings.TrimSpace(scanner.Text()), nil
 427+	}
 428+	if err := scanner.Err(); err != nil {
 429+		return "", fmt.Errorf("failed to read input: %w", err)
 430+	}
 431+	return "", nil
 432+}
 433+
 434+// getEditor returns the editor command to use for interactive editing
 435+// Checks $EDITOR environment variable, falls back to 'vi' or 'notepad'
 436+func getEditor() string {
 437+	if editor := os.Getenv("EDITOR"); editor != "" {
 438+		return editor
 439+	}
 440+	// Default editors based on OS
 441+	if os.PathSeparator == '\\' {
 442+		return "notepad"
 443+	}
 444+	return "vi"
 445+}
 446+
 447+// launchEditorWithTemplate opens the user's preferred editor with a custom template
 448+// Returns the content written by the user
 449+func launchEditorWithTemplate(template string) (string, error) {
 450+	// Create temporary file
 451+	tmpFile, err := os.CreateTemp("", "bug-edit-*.txt")
 452+	if err != nil {
 453+		return "", fmt.Errorf("failed to create temp file: %w", err)
 454+	}
 455+	tmpPath := tmpFile.Name()
 456+	defer os.Remove(tmpPath)
 457+
 458+	if _, err := tmpFile.WriteString(template); err != nil {
 459+		tmpFile.Close()
 460+		return "", fmt.Errorf("failed to write template: %w", err)
 461+	}
 462+	tmpFile.Close()
 463+
 464+	// Launch editor
 465+	editor := getEditor()
 466+	cmd := exec.Command(editor, tmpPath)
 467+	cmd.Stdin = os.Stdin
 468+	cmd.Stdout = os.Stdout
 469+	cmd.Stderr = os.Stderr
 470+
 471+	if err := cmd.Run(); err != nil {
 472+		return "", fmt.Errorf("editor failed: %w", err)
 473+	}
 474+
 475+	// Read the result
 476+	content, err := os.ReadFile(tmpPath)
 477+	if err != nil {
 478+		return "", fmt.Errorf("failed to read edited file: %w", err)
 479+	}
 480+
 481+	return string(content), nil
 482+}
 483+
 484+// launchEditor opens the user's preferred editor with a temporary file for new issues
 485+// Returns the content written by the user
 486+func launchEditor(initialContent string) (string, error) {
 487+	// Write initial content with instructions
 488+	template := `;; Enter your issue title on the first line (required)
 489+;; Enter the description below the title
 490+;; Lines starting with ;; will be ignored
 491+;; Save and close the editor when done
 492+;;
 493+` + initialContent
 494+
 495+	return launchEditorWithTemplate(template)
 496+}
 497+
 498+// parseEditorContent parses editor output into title and message
 499+// First non-empty line is title, rest is message
 500+// Lines starting with ;; are treated as comments and ignored
 501+func parseEditorContent(content string) (title, message string, err error) {
 502+	lines := strings.Split(content, "\n")
 503+	var nonCommentLines []string
 504+	commentsFiltered := false
 505+
 506+	// Filter out comment lines and collect non-empty lines
 507+	for _, line := range lines {
 508+		trimmed := strings.TrimSpace(line)
 509+		if strings.HasPrefix(trimmed, ";;") {
 510+			commentsFiltered = true
 511+			continue
 512+		}
 513+		nonCommentLines = append(nonCommentLines, line)
 514+	}
 515+
 516+	// Find first non-empty line for title
 517+	titleIdx := -1
 518+	for i, line := range nonCommentLines {
 519+		if strings.TrimSpace(line) != "" {
 520+			title = strings.TrimSpace(line)
 521+			titleIdx = i
 522+			break
 523+		}
 524+	}
 525+
 526+	if titleIdx == -1 {
 527+		return "", "", fmt.Errorf("no title provided")
 528+	}
 529+
 530+	// Rest is the message (preserve original formatting)
 531+	if titleIdx+1 < len(nonCommentLines) {
 532+		messageLines := nonCommentLines[titleIdx+1:]
 533+		startIdx := 0
 534+		// Trim leading empty lines only if comments were filtered
 535+		if commentsFiltered {
 536+			for startIdx < len(messageLines) && strings.TrimSpace(messageLines[startIdx]) == "" {
 537+				startIdx++
 538+			}
 539+		}
 540+		// Trim trailing empty lines from message
 541+		endIdx := len(messageLines)
 542+		for endIdx > startIdx && strings.TrimSpace(messageLines[endIdx-1]) == "" {
 543+			endIdx--
 544+		}
 545+		message = strings.Join(messageLines[startIdx:endIdx], "\n")
 546+	}
 547+
 548+	return title, message, nil
 549+}
 550+
 551+// parseEditContent parses editor output for edit operations
 552+// It handles two formats:
 553+// 1. Issue format: title on first line, blank line, then description
 554+// 2. Comment format: single message (when parsing comment content)
 555+// For issue format, returns title and message
 556+// For comment format, set isComment=true to skip title parsing
 557+func parseEditContent(content string, isComment bool) (title, message string, err error) {
 558+	lines := strings.Split(content, "\n")
 559+	var nonCommentLines []string
 560+	commentsFiltered := false
 561+
 562+	// Filter out comment lines and collect non-empty lines
 563+	for _, line := range lines {
 564+		trimmed := strings.TrimSpace(line)
 565+		if strings.HasPrefix(trimmed, ";;") {
 566+			commentsFiltered = true
 567+			continue
 568+		}
 569+		nonCommentLines = append(nonCommentLines, line)
 570+	}
 571+
 572+	if isComment {
 573+		// Comment format: just extract the message
 574+		startIdx := 0
 575+		for startIdx < len(nonCommentLines) && strings.TrimSpace(nonCommentLines[startIdx]) == "" {
 576+			startIdx++
 577+		}
 578+		if startIdx >= len(nonCommentLines) {
 579+			return "", "", fmt.Errorf("no content provided")
 580+		}
 581+		endIdx := len(nonCommentLines)
 582+		for endIdx > startIdx && strings.TrimSpace(nonCommentLines[endIdx-1]) == "" {
 583+			endIdx--
 584+		}
 585+		message = strings.Join(nonCommentLines[startIdx:endIdx], "\n")
 586+		return "", message, nil
 587+	}
 588+
 589+	// Issue format: title on first line, description follows
 590+	// Find first non-empty line for title
 591+	titleIdx := -1
 592+	for i, line := range nonCommentLines {
 593+		if strings.TrimSpace(line) != "" {
 594+			title = strings.TrimSpace(line)
 595+			titleIdx = i
 596+			break
 597+		}
 598+	}
 599+
 600+	if titleIdx == -1 {
 601+		return "", "", fmt.Errorf("no title provided")
 602+	}
 603+
 604+	// Rest is the message (preserve original formatting)
 605+	if titleIdx+1 < len(nonCommentLines) {
 606+		messageLines := nonCommentLines[titleIdx+1:]
 607+		startIdx := 0
 608+		// Trim leading empty lines only if comments were filtered
 609+		if commentsFiltered {
 610+			for startIdx < len(messageLines) && strings.TrimSpace(messageLines[startIdx]) == "" {
 611+				startIdx++
 612+			}
 613+		}
 614+		// Trim trailing empty lines from message
 615+		endIdx := len(messageLines)
 616+		for endIdx > startIdx && strings.TrimSpace(messageLines[endIdx-1]) == "" {
 617+			endIdx--
 618+		}
 619+		message = strings.Join(messageLines[startIdx:endIdx], "\n")
 620+	}
 621+
 622+	return title, message, nil
 623+}
 624+
 625+// detectJJConfig attempts to get user.name and user.email from jj config
 626+// Returns empty strings if .jj directory doesn't exist or config is not found
 627+// Note: We read the config file directly because 'jj config get' walks up the
 628+// directory tree and may find parent repo configs, which is not what we want.
 629+func detectJJConfig(repoPath string) (name, email string, err error) {
 630+	configPath := filepath.Join(repoPath, ".jj", "config.toml")
 631+
 632+	// Check if the config file exists
 633+	if _, err := os.Stat(configPath); os.IsNotExist(err) {
 634+		return "", "", nil
 635+	}
 636+
 637+	// Read the config file
 638+	content, err := os.ReadFile(configPath)
 639+	if err != nil {
 640+		return "", "", nil
 641+	}
 642+
 643+	// Parse the TOML content line by line to extract user.name and user.email
 644+	// We do simple parsing to avoid adding a TOML dependency
 645+	lines := strings.Split(string(content), "\n")
 646+	inUserSection := false
 647+
 648+	for _, line := range lines {
 649+		trimmed := strings.TrimSpace(line)
 650+
 651+		// Check if we're entering the [user] section
 652+		if trimmed == "[user]" {
 653+			inUserSection = true
 654+			continue
 655+		}
 656+
 657+		// Check if we're leaving the [user] section (new section starts)
 658+		if strings.HasPrefix(trimmed, "[") && trimmed != "[user]" {
 659+			inUserSection = false
 660+			continue
 661+		}
 662+
 663+		// Extract name and email from the user section
 664+		if inUserSection {
 665+			if strings.HasPrefix(trimmed, "name") {
 666+				parts := strings.SplitN(trimmed, "=", 2)
 667+				if len(parts) == 2 {
 668+					name = strings.Trim(strings.TrimSpace(parts[1]), `"`)
 669+				}
 670+			}
 671+			if strings.HasPrefix(trimmed, "email") {
 672+				parts := strings.SplitN(trimmed, "=", 2)
 673+				if len(parts) == 2 {
 674+					email = strings.Trim(strings.TrimSpace(parts[1]), `"`)
 675+				}
 676+			}
 677+		}
 678+	}
 679+
 680+	return name, email, nil
 681+}
 682+
 683+// createIdentity creates a new git-bug identity with the given name and email
 684+// If setAsUser is true, the identity will be set as the current user identity
 685+func createIdentity(repoPath, name, email string, setAsUser bool) error {
 686+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
 687+	if err != nil {
 688+		return fmt.Errorf("failed to open repository: %w", err)
 689+	}
 690+	defer repo.Close()
 691+
 692+	newIdentity, err := identity.NewIdentity(repo, name, email)
 693+	if err != nil {
 694+		return fmt.Errorf("failed to create identity: %w", err)
 695+	}
 696+
 697+	if err := newIdentity.Commit(repo); err != nil {
 698+		return fmt.Errorf("failed to commit identity: %w", err)
 699+	}
 700+
 701+	if setAsUser {
 702+		if err := identity.SetUserIdentity(repo, newIdentity); err != nil {
 703+			return fmt.Errorf("failed to set user identity: %w", err)
 704+		}
 705+	}
 706+
 707+	return nil
 708+}
 709+
 710+// getAgentIdentity retrieves the agent identity from the repository
 711+// The agent identity is created during 'bug init' with name "agent" and empty email
 712+func getAgentIdentity(repo repository.Repo) (*identity.Identity, error) {
 713+	// List all local identities
 714+	ids, err := identity.ListLocalIds(repo)
 715+	if err != nil {
 716+		return nil, fmt.Errorf("failed to list identities: %w", err)
 717+	}
 718+
 719+	// Find the identity with name "agent"
 720+	for _, id := range ids {
 721+		i, err := identity.ReadLocal(repo, id)
 722+		if err != nil {
 723+			continue // Skip identities we can't read
 724+		}
 725+		if i.Name() == "agent" {
 726+			return i, nil
 727+		}
 728+	}
 729+
 730+	return nil, fmt.Errorf("agent identity not found. Run 'bug init' first")
 731+}
 732+
 733+// checkIdentitiesExist checks if user and agent identities already exist
 734+// Returns (userExists, agentExists, error)
 735+func checkIdentitiesExist(repoPath string) (bool, bool, error) {
 736+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
 737+	if err != nil {
 738+		return false, false, fmt.Errorf("failed to open repository: %w", err)
 739+	}
 740+	defer repo.Close()
 741+
 742+	// Check if user identity is set
 743+	userExists, err := identity.IsUserIdentitySet(repo)
 744+	if err != nil {
 745+		return false, false, fmt.Errorf("failed to check user identity: %w", err)
 746+	}
 747+
 748+	// Check if agent identity exists
 749+	agentExists := false
 750+	if userExists {
 751+		_, err := getAgentIdentity(repo)
 752+		if err == nil {
 753+			agentExists = true
 754+		}
 755+	}
 756+
 757+	return userExists, agentExists, nil
 758+}
 759+
 760+// parseGitConfig reads and parses the git config file
 761+// Returns a map of section names to their key-value pairs
 762+// For sections with multiple values (like refspec), values are stored as a slice
 763+func parseGitConfig(repoPath string) (map[string]map[string][]string, error) {
 764+	configPath := filepath.Join(repoPath, ".git", "config")
 765+
 766+	data, err := os.ReadFile(configPath)
 767+	if err != nil {
 768+		if os.IsNotExist(err) {
 769+			return make(map[string]map[string][]string), nil
 770+		}
 771+		return nil, fmt.Errorf("failed to read git config: %w", err)
 772+	}
 773+
 774+	config := make(map[string]map[string][]string)
 775+	var currentSection string
 776+
 777+	lines := strings.Split(string(data), "\n")
 778+	for _, line := range lines {
 779+		trimmed := strings.TrimSpace(line)
 780+
 781+		// Skip empty lines and comments
 782+		if trimmed == "" || strings.HasPrefix(trimmed, "#") {
 783+			continue
 784+		}
 785+
 786+		// Check for section header like [remote "origin"]
 787+		if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
 788+			sectionContent := trimmed[1 : len(trimmed)-1]
 789+			currentSection = sectionContent
 790+			if _, exists := config[currentSection]; !exists {
 791+				config[currentSection] = make(map[string][]string)
 792+			}
 793+			continue
 794+		}
 795+
 796+		// Parse key-value pairs within a section
 797+		if currentSection != "" && strings.Contains(trimmed, "=") {
 798+			parts := strings.SplitN(trimmed, "=", 2)
 799+			if len(parts) == 2 {
 800+				key := strings.TrimSpace(parts[0])
 801+				value := strings.TrimSpace(parts[1])
 802+				config[currentSection][key] = append(config[currentSection][key], value)
 803+			}
 804+		}
 805+	}
 806+
 807+	return config, nil
 808+}
 809+
 810+// writeGitConfig writes the git config back to file
 811+func writeGitConfig(repoPath string, config map[string]map[string][]string) error {
 812+	configPath := filepath.Join(repoPath, ".git", "config")
 813+
 814+	var buf strings.Builder
 815+
 816+	for section, values := range config {
 817+		buf.WriteString("[" + section + "]\n")
 818+		for key, vals := range values {
 819+			for _, val := range vals {
 820+				buf.WriteString("\t" + key + " = " + val + "\n")
 821+			}
 822+		}
 823+		buf.WriteString("\n")
 824+	}
 825+
 826+	if err := os.WriteFile(configPath, []byte(buf.String()), 0644); err != nil {
 827+		return fmt.Errorf("failed to write git config: %w", err)
 828+	}
 829+
 830+	return nil
 831+}
 832+
 833+// hasRefspec checks if a refspec is already configured
 834+func hasRefspec(config map[string][]string, refspec string) bool {
 835+	for _, existing := range config["fetch"] {
 836+		if existing == refspec {
 837+			return true
 838+		}
 839+	}
 840+	for _, existing := range config["push"] {
 841+		if existing == refspec {
 842+			return true
 843+		}
 844+	}
 845+	return false
 846+}
 847+
 848+// configureOriginRefspecs adds bug and identity refspecs to origin remote if not present
 849+// Returns true if any changes were made, false if all refspecs already exist
 850+func configureOriginRefspecs(repoPath string) (bool, error) {
 851+	config, err := parseGitConfig(repoPath)
 852+	if err != nil {
 853+		return false, err
 854+	}
 855+
 856+	// Check if origin remote exists
 857+	originSection := `remote "origin"`
 858+	if _, exists := config[originSection]; !exists {
 859+		return false, fmt.Errorf("no 'origin' remote configured")
 860+	}
 861+
 862+	// Define the refspecs we want to add
 863+	fetchRefspecs := []string{
 864+		"+refs/bugs/*:refs/remotes/origin/bugs/*",
 865+		"+refs/identities/*:refs/remotes/origin/identities/*",
 866+	}
 867+	pushRefspecs := []string{
 868+		"+refs/bugs/*:refs/bugs/*",
 869+		"+refs/identities/*:refs/identities/*",
 870+	}
 871+
 872+	changed := false
 873+
 874+	// Add fetch refspecs if not present
 875+	for _, refspec := range fetchRefspecs {
 876+		if !hasRefspec(config[originSection], refspec) {
 877+			config[originSection]["fetch"] = append(config[originSection]["fetch"], refspec)
 878+			changed = true
 879+		}
 880+	}
 881+
 882+	// Add push refspecs if not present
 883+	for _, refspec := range pushRefspecs {
 884+		if !hasRefspec(config[originSection], refspec) {
 885+			config[originSection]["push"] = append(config[originSection]["push"], refspec)
 886+			changed = true
 887+		}
 888+	}
 889+
 890+	if !changed {
 891+		return false, nil
 892+	}
 893+
 894+	if err := writeGitConfig(repoPath, config); err != nil {
 895+		return false, err
 896+	}
 897+
 898+	return true, nil
 899+}
 900+
 901+// resolveBugID resolves a bug ID (short or full) to a bug entity
 902+// It uses ShortIDMap to handle short ID resolution
 903+func resolveBugID(repoPath, idStr string) (*bug.Bug, error) {
 904+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
 905+	if err != nil {
 906+		return nil, fmt.Errorf("failed to open repository: %w", err)
 907+	}
 908+	defer repo.Close()
 909+
 910+	// Generate short ID map for resolution
 911+	gen := NewShortIDGenerator(repoPath)
 912+	shortIDMap, err := gen.Generate()
 913+	if err != nil {
 914+		return nil, fmt.Errorf("failed to generate short IDs: %w", err)
 915+	}
 916+
 917+	// Try to resolve the ID (could be short or full)
 918+	fullID, err := shortIDMap.GetFullID(idStr)
 919+	if err != nil {
 920+		return nil, fmt.Errorf("failed to resolve bug ID %q: %w", idStr, err)
 921+	}
 922+
 923+	// Read the bug using the full ID
 924+	b, err := bug.Read(repo, entity.Id(fullID))
 925+	if err != nil {
 926+		return nil, fmt.Errorf("failed to read bug %q: %w", idStr, err)
 927+	}
 928+
 929+	return b, nil
 930+}
 931+
 932+// resolveCommentID resolves a comment ID (short or full) and returns the bug and comment
 933+// It uses ShortIDMap to handle short ID resolution
 934+func resolveCommentID(repoPath, idStr string) (*bug.Bug, *bug.Comment, error) {
 935+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
 936+	if err != nil {
 937+		return nil, nil, fmt.Errorf("failed to open repository: %w", err)
 938+	}
 939+	defer repo.Close()
 940+
 941+	// Generate short ID map for resolution
 942+	gen := NewShortIDGenerator(repoPath)
 943+	shortIDMap, err := gen.Generate()
 944+	if err != nil {
 945+		return nil, nil, fmt.Errorf("failed to generate short IDs: %w", err)
 946+	}
 947+
 948+	// Try to resolve the ID (could be short or full)
 949+	fullID, err := shortIDMap.GetFullID(idStr)
 950+	if err != nil {
 951+		return nil, nil, fmt.Errorf("failed to resolve comment ID %q: %w", idStr, err)
 952+	}
 953+
 954+	// Parse the combined ID to get bug ID prefix and comment ID prefix
 955+	combinedID := entity.CombinedId(fullID)
 956+	bugIDPrefix, _ := entity.SeparateIds(string(combinedID))
 957+
 958+	// SeparateIds returns prefixes, not full IDs. We need to look up the full bug ID.
 959+	// The bug ID prefix is 50 characters, but we need the full 64-character ID.
 960+	fullBugID, err := shortIDMap.GetFullID(bugIDPrefix)
 961+	if err != nil {
 962+		return nil, nil, fmt.Errorf("failed to resolve bug ID from prefix %q: %w", bugIDPrefix, err)
 963+	}
 964+
 965+	// Read the bug using the full bug ID
 966+	b, err := bug.Read(repo, entity.Id(fullBugID))
 967+	if err != nil {
 968+		return nil, nil, fmt.Errorf("failed to read bug for comment %q: %w", idStr, err)
 969+	}
 970+
 971+	// Search for the comment in the bug
 972+	snap := b.Compile()
 973+	comment, err := snap.SearchComment(combinedID)
 974+	if err != nil {
 975+		return nil, nil, fmt.Errorf("comment %q not found in bug: %w", idStr, err)
 976+	}
 977+
 978+	return b, comment, nil
 979+}
 980+
 981+// IDType represents the type of ID resolved
 982+type IDType int
 983+
 984+const (
 985+	IDTypeBug IDType = iota
 986+	IDTypeComment
 987+)
 988+
 989+// ResolvedID holds the result of ID resolution
 990+type ResolvedID struct {
 991+	Type    IDType
 992+	Bug     *bug.Bug
 993+	Comment *bug.Comment
 994+	ID      string // Full ID
 995+}
 996+
 997+// resolveID attempts to resolve an ID as a bug ID first, then as a comment ID
 998+// Returns ResolvedID with the type and appropriate data
 999+func resolveID(repoPath, idStr string) (*ResolvedID, error) {
1000+	// Try to resolve as bug ID first
1001+	b, err := resolveBugID(repoPath, idStr)
1002+	if err == nil {
1003+		return &ResolvedID{
1004+			Type: IDTypeBug,
1005+			Bug:  b,
1006+			ID:   b.Id().String(),
1007+		}, nil
1008+	}
1009+
1010+	// Try to resolve as comment ID
1011+	b, comment, err := resolveCommentID(repoPath, idStr)
1012+	if err == nil {
1013+		return &ResolvedID{
1014+			Type:    IDTypeComment,
1015+			Bug:     b,
1016+			Comment: comment,
1017+			ID:      comment.CombinedId().String(),
1018+		}, nil
1019+	}
1020+
1021+	// Neither resolved - return combined error
1022+	return nil, fmt.Errorf("failed to resolve ID %q as bug or comment: %w", idStr, err)
1023+}
1024+
1025+// launchCommentEditor opens the user's preferred editor for comment input
1026+// Returns the content written by the user with comment lines filtered
1027+func launchCommentEditor(initialContent string) (string, error) {
1028+	// Write initial content with instructions
1029+	template := `;; Enter your comment below
1030+;; Lines starting with ;; will be ignored
1031+;; You can use markdown formatting
1032+;; Save and close the editor when done
1033+;;
1034+` + initialContent
1035+
1036+	return launchEditorWithTemplate(template)
1037+}
1038+
1039+// runRead reads and displays a bug/issue with all its comments
1040+// Outputs structured metadata followed by description and all comments
1041+func runRead(repoPath, bugIDStr string) error {
1042+	// Resolve bug ID
1043+	b, err := resolveBugID(repoPath, bugIDStr)
1044+	if err != nil {
1045+		return err
1046+	}
1047+
1048+	// Compile bug snapshot
1049+	snap := b.Compile()
1050+
1051+	// Get the first comment (description) for author info
1052+	var authorName, authorEmail string
1053+	var createdAt time.Time
1054+	if len(snap.Comments) > 0 {
1055+		firstComment := snap.Comments[0]
1056+		authorName = firstComment.Author.Name()
1057+		authorEmail = firstComment.Author.Email()
1058+		// Parse the formatted time string
1059+		createdAt, _ = time.Parse("Mon Jan 2 15:04:05 2006 -0700", firstComment.FormatTime())
1060+	}
1061+	if createdAt.IsZero() {
1062+		createdAt = snap.CreateTime
1063+	}
1064+
1065+	// Format author string (name only if no email)
1066+	authorStr := authorName
1067+	if authorEmail != "" {
1068+		authorStr = fmt.Sprintf("%s <%s>", authorName, authorEmail)
1069+	}
1070+
1071+	// Format labels
1072+	labelsStr := "-"
1073+	if len(snap.Labels) > 0 {
1074+		labels := make([]string, len(snap.Labels))
1075+		for i, label := range snap.Labels {
1076+			labels[i] = string(label)
1077+		}
1078+		labelsStr = strings.Join(labels, ", ")
1079+	}
1080+
1081+	// Print metadata header
1082+	fmt.Printf("Title: %s\n", snap.Title)
1083+	fmt.Printf("Author: %s\n", authorStr)
1084+	fmt.Printf("Created: %s\n", createdAt.Format(time.RFC3339))
1085+	fmt.Printf("Status: %s\n", snap.Status.String())
1086+	fmt.Printf("Labels: %s\n", labelsStr)
1087+	fmt.Println()
1088+
1089+	// Print description (first comment)
1090+	if len(snap.Comments) > 0 {
1091+		description := snap.Comments[0].Message
1092+		if description != "" {
1093+			fmt.Println(description)
1094+		}
1095+
1096+		// Print additional comments
1097+		for i := 1; i < len(snap.Comments); i++ {
1098+			comment := snap.Comments[i]
1099+			commentID := comment.CombinedId().String()
1100+			if len(commentID) > 7 {
1101+				commentID = commentID[:7]
1102+			}
1103+
1104+			commentAuthor := comment.Author.Name()
1105+			commentDate := comment.FormatTime()
1106+
1107+			fmt.Println()
1108+			fmt.Printf("Comment %s made by %s on %s:\n\n", commentID, commentAuthor, commentDate)
1109+			fmt.Println(comment.Message)
1110+		}
1111+	}
1112+
1113+	return nil
1114+}
1115+
1116+// parseCommentContent parses editor output for comments
1117+// Lines starting with ;; are treated as comments and ignored
1118+func parseCommentContent(content string) (string, error) {
1119+	lines := strings.Split(content, "\n")
1120+	var nonCommentLines []string
1121+
1122+	// Filter out comment lines
1123+	for _, line := range lines {
1124+		trimmed := strings.TrimSpace(line)
1125+		if strings.HasPrefix(trimmed, ";;") {
1126+			continue
1127+		}
1128+		nonCommentLines = append(nonCommentLines, line)
1129+	}
1130+
1131+	// Find first non-empty line
1132+	startIdx := 0
1133+	for startIdx < len(nonCommentLines) && strings.TrimSpace(nonCommentLines[startIdx]) == "" {
1134+		startIdx++
1135+	}
1136+
1137+	// If no content left, return error
1138+	if startIdx >= len(nonCommentLines) {
1139+		return "", fmt.Errorf("no comment provided")
1140+	}
1141+
1142+	// Trim trailing empty lines
1143+	endIdx := len(nonCommentLines)
1144+	for endIdx > startIdx && strings.TrimSpace(nonCommentLines[endIdx-1]) == "" {
1145+		endIdx--
1146+	}
1147+
1148+	// Join remaining lines
1149+	message := strings.Join(nonCommentLines[startIdx:endIdx], "\n")
1150+
1151+	return message, nil
1152+}
1153+
1154+// runNew creates a new bug/issue in the repository
1155+// If title is empty, opens editor for interactive input
1156+func runNew(repoPath, title, message string) error {
1157+	// If no title provided via flags, open editor
1158+	if title == "" && message == "" {
1159+		content, err := launchEditor("")
1160+		if err != nil {
1161+			return err
1162+		}
1163+		title, message, err = parseEditorContent(content)
1164+		if err != nil {
1165+			return err
1166+		}
1167+	} else if title == "" {
1168+		// Message provided but no title - still need editor
1169+		content, err := launchEditor(message)
1170+		if err != nil {
1171+			return err
1172+		}
1173+		title, message, err = parseEditorContent(content)
1174+		if err != nil {
1175+			return err
1176+		}
1177+	}
1178+	// If only title provided, message can be empty (that's OK)
1179+
1180+	// Validate title
1181+	if strings.TrimSpace(title) == "" {
1182+		return fmt.Errorf("title cannot be empty")
1183+	}
1184+
1185+	// Open repository
1186+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
1187+	if err != nil {
1188+		return fmt.Errorf("failed to open repository: %w", err)
1189+	}
1190+	defer repo.Close()
1191+
1192+	// Get user identity
1193+	author, err := identity.GetUserIdentity(repo)
1194+	if err != nil {
1195+		return fmt.Errorf("failed to get user identity: %w\n\nRun 'bug init' first to set up identities", err)
1196+	}
1197+
1198+	// Create the bug
1199+	unixTime := time.Now().Unix()
1200+	newBug, _, err := bug.Create(author, unixTime, title, message, nil, nil)
1201+	if err != nil {
1202+		return fmt.Errorf("failed to create bug: %w", err)
1203+	}
1204+
1205+	// Commit the bug to the repository
1206+	if err := newBug.Commit(repo); err != nil {
1207+		return fmt.Errorf("failed to commit bug: %w", err)
1208+	}
1209+
1210+	// Get the bug ID and display first 7 characters (like bug ls table)
1211+	bugID := newBug.Id().String()
1212+	if len(bugID) > 7 {
1213+		bugID = bugID[:7]
1214+	}
1215+
1216+	fmt.Printf("Created bug %s: %s\n", bugID, title)
1217+
1218+	// Invalidate cache so git-bug sees the new bug
1219+	invalidateGitBugCache(repoPath)
1220+
1221+	return nil
1222+}
1223+
1224+// runInit creates initial identities in the repository
1225+// It attempts to get user info from jj config if .jj exists, otherwise prompts the user
1226+// It also creates an agent identity
1227+func runInit(repoPath string) error {
1228+	return runInitWithReader(repoPath, os.Stdin)
1229+}
1230+
1231+// ErrNoOriginRemote is returned when no origin remote is configured
1232+type ErrNoOriginRemote struct{}
1233+
1234+func (e ErrNoOriginRemote) Error() string {
1235+	return "no 'origin' remote configured"
1236+}
1237+
1238+// runInitWithReader is the testable version that accepts an io.Reader for stdin
1239+func runInitWithReader(repoPath string, reader io.Reader) error {
1240+	// Check if identities already exist (idempotent behavior)
1241+	userExists, agentExists, err := checkIdentitiesExist(repoPath)
1242+	if err != nil {
1243+		return err
1244+	}
1245+
1246+	// Track if we need to prompt for user info
1247+	var name, email string
1248+
1249+	if userExists {
1250+		fmt.Println("User identity already exists, skipping creation")
1251+	} else {
1252+		// Try to get user info from jj config
1253+		name, email, err = detectJJConfig(repoPath)
1254+		if err != nil {
1255+			return fmt.Errorf("failed to detect jj config: %w", err)
1256+		}
1257+
1258+		// If jj config was found, use it
1259+		if name != "" && email != "" {
1260+			fmt.Printf("Creating user identity from jj config: %s <%s>\n", name, email)
1261+		} else {
1262+			// Prompt user for name and email
1263+			fmt.Println("No jj config found. Please provide your information:")
1264+
1265+			// Create a single scanner to use for all reads
1266+			scanner := bufio.NewScanner(reader)
1267+
1268+			name, err = promptUserWithScanner("Name: ", scanner)
1269+			if err != nil {
1270+				return fmt.Errorf("failed to read name: %w", err)
1271+			}
1272+			if name == "" {
1273+				return fmt.Errorf("name cannot be empty")
1274+			}
1275+
1276+			email, err = promptUserWithScanner("Email: ", scanner)
1277+			if err != nil {
1278+				return fmt.Errorf("failed to read email: %w", err)
1279+			}
1280+		}
1281+
1282+		// Create user identity
1283+		fmt.Printf("Creating user identity: %s <%s>\n", name, email)
1284+		if err := createIdentity(repoPath, name, email, true); err != nil {
1285+			return fmt.Errorf("failed to create user identity: %w", err)
1286+		}
1287+		fmt.Println("User identity created successfully")
1288+	}
1289+
1290+	if agentExists {
1291+		fmt.Println("Agent identity already exists, skipping creation")
1292+	} else {
1293+		// Create agent identity
1294+		// Empty email is intentional per spec: equivalent to `git-bug user new --name 'agent' --email ''`
1295+		fmt.Println("Creating agent identity...")
1296+		if err := createIdentity(repoPath, "agent", "", false); err != nil {
1297+			return fmt.Errorf("failed to create agent identity: %w", err)
1298+		}
1299+		fmt.Println("Agent identity created successfully")
1300+	}
1301+
1302+	// Configure git remote refspecs for origin
1303+	configured, err := configureOriginRefspecs(repoPath)
1304+	if err != nil {
1305+		if err.Error() == "no 'origin' remote configured" {
1306+			return ErrNoOriginRemote{}
1307+		}
1308+		return fmt.Errorf("failed to configure git refspecs: %w", err)
1309+	}
1310+
1311+	if configured {
1312+		fmt.Println("Configured git to automatically sync bugs and identities with origin")
1313+	} else if userExists && agentExists {
1314+		// All identities exist and refspecs are configured - nothing to do
1315+		fmt.Println("Nothing to do")
1316+	}
1317+
1318+	// Invalidate cache so git-bug sees the new identities
1319+	invalidateGitBugCache(repoPath)
1320+
1321+	return nil
1322+}
1323+
1324+var (
1325+	repoPath        string
1326+	filterFlag      string
1327+	sortFlag        string
1328+	newTitle        string
1329+	newMessage      string
1330+	agentTitle      string
1331+	agentMessage    string
1332+	commentMessage  string
1333+	commentBugID    string
1334+	agentCommentMsg string
1335+	editTitle       string
1336+	editMessage     string
1337+	agentEditTitle  string
1338+	agentEditMsg    string
1339+	statusFlag      string
1340+)
1341+
1342+var rootCmd = &cobra.Command{
1343+	Use:   "bug",
1344+	Short: "A CLI for git-bug issue tracking",
1345+	Long: `bug is a simplified CLI interface to git-bug issues and comments.
1346+It provides an easy way to list and manage issues in your git repository.`,
1347+}
1348+
1349+var listCmd = &cobra.Command{
1350+	Use:     "list",
1351+	Aliases: []string{"ls"},
1352+	Short:   "List all issues in the repository",
1353+	Long: `Display a table of all git-bug issues with their ID, summary, labels, and age.
1354+
1355+Filtering:
1356+  --filter label:bug         Show only issues with label "bug"
1357+  --filter age:<10d          Show only issues newer than 10 days
1358+
1359+Sorting:
1360+  --sort id:asc              Sort by ID (ascending)
1361+  --sort id:desc             Sort by ID (descending)
1362+  --sort age:asc             Sort by age, oldest first
1363+  --sort age:desc            Sort by age, newest first (default)
1364+
1365+Status Filtering:
1366+  -S, --status open          Show only open issues (default)
1367+  -S, --status closed        Show only closed issues
1368+  -S, --status all           Show all issues`,
1369+	RunE: func(cmd *cobra.Command, args []string) error {
1370+		// Load issues
1371+		issues, err := LoadBugs(repoPath)
1372+		if err != nil {
1373+			return err
1374+		}
1375+
1376+		if len(issues) == 0 {
1377+			fmt.Println("No Issues")
1378+			return nil
1379+		}
1380+
1381+		// Parse and apply filter
1382+		filterSpec, err := ParseFilter(filterFlag)
1383+		if err != nil {
1384+			return err
1385+		}
1386+		issues = ApplyFilter(issues, filterSpec)
1387+
1388+		// Apply status filter
1389+		if statusFlag != "" && strings.ToLower(statusFlag) != "all" {
1390+			statusLower := strings.ToLower(statusFlag)
1391+			if statusLower != "open" && statusLower != "closed" {
1392+				return fmt.Errorf("invalid status: %s (expected 'open', 'closed', or 'all')", statusFlag)
1393+			}
1394+			var filtered []BugIssue
1395+			for _, issue := range issues {
1396+				if strings.ToLower(issue.Status) == statusLower {
1397+					filtered = append(filtered, issue)
1398+				}
1399+			}
1400+			issues = filtered
1401+		}
1402+
1403+		// Parse and apply sort
1404+		sortSpec, err := ParseSort(sortFlag)
1405+		if err != nil {
1406+			return err
1407+		}
1408+		ApplySort(issues, sortSpec)
1409+
1410+		if len(issues) == 0 {
1411+			fmt.Println("No Issues")
1412+			return nil
1413+		}
1414+
1415+		return printTable(issues)
1416+	},
1417+}
1418+
1419+var initCmd = &cobra.Command{
1420+	Use:   "init",
1421+	Short: "Initialize git-bug identities in the repository",
1422+	Long: `Initialize git-bug by creating initial identities in the repository.
1423+
1424+If a .jj directory exists, the command will attempt to read user.name and user.email
1425+from jj config and create a user identity. Otherwise, it will prompt you interactively
1426+for your name and email. It also creates an agent identity.
1427+
1428+This command also configures git push/fetch refspecs for the 'origin' remote
1429+to automatically sync bugs and identities.
1430+
1431+Examples:
1432+  bug init                    # Initialize in current directory
1433+  bug init --repo /path/to/repo  # Initialize in specific repository`,
1434+	RunE: func(cmd *cobra.Command, args []string) error {
1435+		err := runInit(repoPath)
1436+		if err != nil {
1437+			// Check for special error types
1438+			if _, ok := err.(ErrNoOriginRemote); ok {
1439+				fmt.Fprintln(os.Stderr, "Error: No 'origin' remote configured.")
1440+				fmt.Fprintln(os.Stderr, "")
1441+				fmt.Fprintln(os.Stderr, "Please add a remote with: git remote add origin <url>")
1442+				fmt.Fprintln(os.Stderr, "Then run 'bug init' again to configure bug push/fetch refspecs.")
1443+				os.Exit(1)
1444+			}
1445+			return err
1446+		}
1447+		return nil
1448+	},
1449+}
1450+
1451+var newCmd = &cobra.Command{
1452+	Use:   "new",
1453+	Short: "Create a new issue/bug",
1454+	Long: `Create a new issue in the git-bug repository.
1455+
1456+You can provide the title and message via flags:
1457+  bug new --title "Bug title" --message "Detailed description"
1458+  bug new -t "Bug title" -m "Detailed description"
1459+
1460+Or launch an editor to write them interactively:
1461+  bug new
1462+
1463+The editor will open with a template. The first non-empty line becomes the title,
1464+and the rest becomes the description.`,
1465+	RunE: func(cmd *cobra.Command, args []string) error {
1466+		return runNew(repoPath, newTitle, newMessage)
1467+	},
1468+}
1469+
1470+var agentCmd = &cobra.Command{
1471+	Use:   "agent",
1472+	Short: "Agent commands for automated bug creation",
1473+	Long: `Commands for creating bugs as the agent identity.
1474+
1475+These commands are designed for automated/agent use and are non-interactive.
1476+All agent commands use the agent identity created during 'bug init'.
1477+
1478+Example:
1479+  bug agent new --title "Automated bug" --message "Found by CI"`,
1480+}
1481+
1482+var agentNewCmd = &cobra.Command{
1483+	Use:   "new",
1484+	Short: "Create a new issue as the agent",
1485+	Long: `Create a new issue in the git-bug repository using the agent identity.
1486+
1487+This command is non-interactive and requires both --title and --message flags.
1488+It uses the agent identity created during 'bug init'.
1489+
1490+Example:
1491+  bug agent new --title "CI Failure" --message "Build failed on commit abc123"
1492+  bug agent new -t "Bug found" -m "Automated scan detected issue"`,
1493+	RunE: func(cmd *cobra.Command, args []string) error {
1494+		return runAgentNew(repoPath, agentTitle, agentMessage)
1495+	},
1496+}
1497+
1498+var commentCmd = &cobra.Command{
1499+	Use:   "comment [bugid]",
1500+	Short: "Add a comment to an existing bug",
1501+	Long: `Add a comment to an existing bug in the git-bug repository.
1502+
1503+You can provide the comment via the message flag:
1504+  bug comment abc1234 --message "This is my comment"
1505+  bug comment abc1234 -m "This is my comment"
1506+
1507+Or launch an editor to write it interactively:
1508+  bug comment abc1234
1509+
1510+The editor will open with a template. Lines starting with ;; are ignored.
1511+You can use markdown formatting in your comment.`,
1512+	Args: cobra.ExactArgs(1),
1513+	RunE: func(cmd *cobra.Command, args []string) error {
1514+		commentBugID = args[0]
1515+		return runComment(repoPath, commentBugID, commentMessage)
1516+	},
1517+}
1518+
1519+var editCmd = &cobra.Command{
1520+	Use:   "edit [bugid|commentid]",
1521+	Short: "Edit an issue or comment",
1522+	Long: `Edit an existing issue or comment in the git-bug repository.
1523+
1524+The ID can be a bug ID (full or short) or a comment ID (full or short).
1525+The command first tries to resolve the ID as a bug, then as a comment.
1526+
1527+For issues:
1528+  bug edit abc1234                    # Open editor with current title and description
1529+  bug edit abc1234 -t "New Title"     # Update only the title
1530+  bug edit abc1234 -m "New desc"      # Update only the description
1531+  bug edit abc1234 -t "Title" -m "Desc"  # Update both
1532+
1533+For comments:
1534+  bug edit def5678                    # Open editor with current comment text
1535+  bug edit def5678 -m "New text"      # Update the comment
1536+
1537+When using the editor:
1538+  - For issues: title on first line, blank line, then description
1539+  - For comments: edit the comment text directly
1540+  - Lines starting with ;; are ignored (instructions)`,
1541+	Args: cobra.ExactArgs(1),
1542+	RunE: func(cmd *cobra.Command, args []string) error {
1543+		return runEdit(repoPath, args[0], editTitle, editMessage)
1544+	},
1545+}
1546+
1547+var readCmd = &cobra.Command{
1548+	Use:     "read [bugid]",
1549+	Aliases: []string{"show"},
1550+	Short:   "Display a bug/issue with all comments",
1551+	Long: `Display a bug/issue and all its comments in a structured format.
1552+
1553+The output includes metadata (title, author, creation date, status, labels)
1554+followed by the description and all comments.
1555+
1556+Examples:
1557+  bug read abc1234           # Display bug with ID prefix abc1234
1558+  bug show abc1234           # Same as above (alias)
1559+  bug read --repo /path abc  # Display bug from specific repository`,
1560+	Args: cobra.ExactArgs(1),
1561+	RunE: func(cmd *cobra.Command, args []string) error {
1562+		return runRead(repoPath, args[0])
1563+	},
1564+}
1565+
1566+var rmCmd = &cobra.Command{
1567+	Use:     "rm [bugid|commentid]",
1568+	Aliases: []string{"remove"},
1569+	Short:   "Remove a bug/issue by its ID",
1570+	Long: `Remove a bug/issue from the git-bug repository.
1571+
1572+The ID can be a bug ID (full or short). Comments cannot be removed individually;
1573+use 'bug rm <bug-id>' to remove the entire issue including all its comments.
1574+
1575+WARNING: This action is permanent and cannot be undone.
1576+
1577+Examples:
1578+  bug rm abc1234                    # Remove bug with short ID abc1234
1579+  bug rm abc1234567890abcdef        # Remove bug with full ID
1580+  bug remove abc1234                # Same as above (alias)
1581+
1582+Note: If a comment ID is provided, the command will return an error explaining
1583+that individual comments cannot be removed.`,
1584+	Args: cobra.ExactArgs(1),
1585+	RunE: func(cmd *cobra.Command, args []string) error {
1586+		return runRemove(repoPath, args[0])
1587+	},
1588+}
1589+
1590+var agentRmCmd = &cobra.Command{
1591+	Use:     "rm [bugid]",
1592+	Aliases: []string{"remove"},
1593+	Short:   "Remove a bug/issue as the agent",
1594+	Long: `Remove a bug/issue from the git-bug repository using the agent identity.
1595+
1596+This command is non-interactive and uses the agent identity created during 'bug init'.
1597+Comments cannot be removed individually; use 'bug agent rm <bug-id>' to remove
1598+	the entire issue including all its comments.
1599+
1600+WARNING: This action is permanent and cannot be undone.
1601+
1602+Examples:
1603+  bug agent rm abc1234              # Remove bug with short ID abc1234
1604+  bug agent remove abc1234          # Same as above (alias)`,
1605+	Args: cobra.ExactArgs(1),
1606+	RunE: func(cmd *cobra.Command, args []string) error {
1607+		return runRemove(repoPath, args[0])
1608+	},
1609+}
1610+
1611+var openCmd = &cobra.Command{
1612+	Use:   "open [bugid]",
1613+	Short: "Open a closed bug/issue",
1614+	Long: `Open a bug/issue by changing its status from closed to open.
1615+
1616+The ID can be a bug ID (full or short). This command changes the bug's
1617+	status to "open".
1618+
1619+Examples:
1620+  bug open abc1234                    # Open bug with short ID abc1234
1621+  bug open abc1234567890abcdef        # Open bug with full ID`,
1622+	Args: cobra.ExactArgs(1),
1623+	RunE: func(cmd *cobra.Command, args []string) error {
1624+		return runOpen(repoPath, args[0])
1625+	},
1626+}
1627+
1628+var closeCmd = &cobra.Command{
1629+	Use:   "close [bugid]",
1630+	Short: "Close an open bug/issue",
1631+	Long: `Close a bug/issue by changing its status from open to closed.
1632+
1633+The ID can be a bug ID (full or short). This command changes the bug's
1634+	status to "closed".
1635+
1636+Examples:
1637+  bug close abc1234                    # Close bug with short ID abc1234
1638+  bug close abc1234567890abcdef        # Close bug with full ID`,
1639+	Args: cobra.ExactArgs(1),
1640+	RunE: func(cmd *cobra.Command, args []string) error {
1641+		return runClose(repoPath, args[0])
1642+	},
1643+}
1644+
1645+var agentCommentCmd = &cobra.Command{
1646+	Use:   "comment [bugid]",
1647+	Short: "Add a comment to a bug as the agent",
1648+	Long: `Add a comment to an existing bug using the agent identity.
1649+
1650+This command is non-interactive and requires the --message flag.
1651+It uses the agent identity created during 'bug init'.
1652+
1653+Example:
1654+  bug agent comment abc1234 --message "Automated analysis complete"
1655+  bug agent comment abc1234 -m "Build passed all tests"`,
1656+	Args: cobra.ExactArgs(1),
1657+	RunE: func(cmd *cobra.Command, args []string) error {
1658+		commentBugID = args[0]
1659+		return runAgentComment(repoPath, commentBugID, agentCommentMsg)
1660+	},
1661+}
1662+
1663+var agentEditCmd = &cobra.Command{
1664+	Use:   "edit [bugid|commentid]",
1665+	Short: "Edit an issue or comment as the agent",
1666+	Long: `Edit an existing issue or comment using the agent identity.
1667+
1668+This command is non-interactive and requires at least one of --title or --message.
1669+It uses the agent identity created during 'bug init'.
1670+
1671+For issues:
1672+  bug agent edit abc1234 --title "New Title"
1673+  bug agent edit abc1234 --message "New description"
1674+  bug agent edit abc1234 --title "Title" --message "Description"
1675+
1676+For comments:
1677+  bug agent edit def5678 --message "Updated comment text"`,
1678+	Args: cobra.ExactArgs(1),
1679+	RunE: func(cmd *cobra.Command, args []string) error {
1680+		return runAgentEdit(repoPath, args[0], agentEditTitle, agentEditMsg)
1681+	},
1682+}
1683+
1684+var agentOpenCmd = &cobra.Command{
1685+	Use:   "open [bugid]",
1686+	Short: "Open a closed bug/issue as the agent",
1687+	Long: `Open a bug/issue by changing its status from closed to open.
1688+
1689+This command uses the agent identity created during 'bug init'.
1690+It is non-interactive and designed for automated/agent use.
1691+
1692+Examples:
1693+  bug agent open abc1234              # Open bug with short ID abc1234`,
1694+	Args: cobra.ExactArgs(1),
1695+	RunE: func(cmd *cobra.Command, args []string) error {
1696+		return runAgentOpen(repoPath, args[0])
1697+	},
1698+}
1699+
1700+var agentCloseCmd = &cobra.Command{
1701+	Use:   "close [bugid]",
1702+	Short: "Close an open bug/issue as the agent",
1703+	Long: `Close a bug/issue by changing its status from open to closed.
1704+
1705+This command uses the agent identity created during 'bug init'.
1706+It is non-interactive and designed for automated/agent use.
1707+
1708+Examples:
1709+  bug agent close abc1234              # Close bug with short ID abc1234`,
1710+	Args: cobra.ExactArgs(1),
1711+	RunE: func(cmd *cobra.Command, args []string) error {
1712+		return runAgentClose(repoPath, args[0])
1713+	},
1714+}
1715+
1716+var agentReadCmd = &cobra.Command{
1717+	Use:   "read [bugid]",
1718+	Short: "Display a bug/issue with all comments",
1719+	Long: `Display a bug/issue and all its comments in a structured format.
1720+
1721+This is the same as 'bug read' but accessible under the agent subcommand.
1722+The output includes metadata (title, author, creation date, status, labels)
1723+followed by the description and all comments.
1724+
1725+Examples:
1726+  bug agent read abc1234     # Display bug with ID prefix abc1234`,
1727+	Args: cobra.ExactArgs(1),
1728+	RunE: func(cmd *cobra.Command, args []string) error {
1729+		return runRead(repoPath, args[0])
1730+	},
1731+}
1732+
1733+func init() {
1734+	rootCmd.PersistentFlags().StringVar(&repoPath, "repo", ".", "path to git repository")
1735+	listCmd.Flags().StringVarP(&filterFlag, "filter", "f", "", "filter issues (label:value or age:<duration>)")
1736+	listCmd.Flags().StringVarP(&sortFlag, "sort", "s", "", "sort issues (field:direction, default: age:desc)")
1737+	listCmd.Flags().StringVarP(&statusFlag, "status", "S", "open", "filter by status (open|closed|all)")
1738+
1739+	newCmd.Flags().StringVarP(&newTitle, "title", "t", "", "issue title")
1740+	newCmd.Flags().StringVarP(&newMessage, "message", "m", "", "issue description/message")
1741+
1742+	// Agent new command flags - both required, no shorthand to force explicit usage
1743+	agentNewCmd.Flags().StringVar(&agentTitle, "title", "", "issue title (required)")
1744+	agentNewCmd.Flags().StringVar(&agentMessage, "message", "", "issue description (required)")
1745+	agentNewCmd.MarkFlagRequired("title")
1746+	agentNewCmd.MarkFlagRequired("message")
1747+
1748+	// Comment command flags - message is optional (opens editor if not provided)
1749+	commentCmd.Flags().StringVarP(&commentMessage, "message", "m", "", "comment message (opens editor if not provided)")
1750+
1751+	// Agent comment command flags - message is required (non-interactive)
1752+	agentCommentCmd.Flags().StringVarP(&agentCommentMsg, "message", "m", "", "comment message (required)")
1753+	agentCommentCmd.MarkFlagRequired("message")
1754+
1755+	// Edit command flags - both optional (opens editor if neither provided)
1756+	editCmd.Flags().StringVarP(&editTitle, "title", "t", "", "new issue title")
1757+	editCmd.Flags().StringVarP(&editMessage, "message", "m", "", "new issue description or comment text")
1758+
1759+	// Agent edit command flags - at least one required
1760+	agentEditCmd.Flags().StringVar(&agentEditTitle, "title", "", "new issue title")
1761+	agentEditCmd.Flags().StringVarP(&agentEditMsg, "message", "m", "", "new issue description or comment text")
1762+
1763+	rootCmd.AddCommand(listCmd)
1764+	rootCmd.AddCommand(initCmd)
1765+	rootCmd.AddCommand(newCmd)
1766+	rootCmd.AddCommand(commentCmd)
1767+	rootCmd.AddCommand(editCmd)
1768+	rootCmd.AddCommand(readCmd)
1769+	rootCmd.AddCommand(rmCmd)    // NEW: Add rm command
1770+	rootCmd.AddCommand(termCmd)  // NEW: Add term command
1771+	rootCmd.AddCommand(openCmd)  // NEW: Add open command
1772+	rootCmd.AddCommand(closeCmd) // NEW: Add close command
1773+
1774+	// Add agent command with its subcommands
1775+	agentCmd.AddCommand(agentNewCmd)
1776+	agentCmd.AddCommand(agentCommentCmd)
1777+	agentCmd.AddCommand(agentEditCmd)
1778+	agentCmd.AddCommand(agentReadCmd)
1779+	agentCmd.AddCommand(agentRmCmd)    // NEW: Add agent rm command
1780+	agentCmd.AddCommand(agentOpenCmd)  // NEW: Add agent open command
1781+	agentCmd.AddCommand(agentCloseCmd) // NEW: Add agent close command
1782+	rootCmd.AddCommand(agentCmd)
1783+}
1784+
1785+// runAgentNew creates a new bug/issue using the agent identity
1786+// Both title and message are required - this is non-interactive
1787+func runAgentNew(repoPath, title, message string) error {
1788+	// Validate inputs (both required for agent commands)
1789+	if strings.TrimSpace(title) == "" {
1790+		return fmt.Errorf("title cannot be empty")
1791+	}
1792+	if strings.TrimSpace(message) == "" {
1793+		return fmt.Errorf("message cannot be empty")
1794+	}
1795+
1796+	// Open repository
1797+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
1798+	if err != nil {
1799+		return fmt.Errorf("failed to open repository: %w", err)
1800+	}
1801+	defer repo.Close()
1802+
1803+	// Get agent identity (not user identity)
1804+	author, err := getAgentIdentity(repo)
1805+	if err != nil {
1806+		return err
1807+	}
1808+
1809+	// Create the bug
1810+	unixTime := time.Now().Unix()
1811+	newBug, _, err := bug.Create(author, unixTime, title, message, nil, nil)
1812+	if err != nil {
1813+		return fmt.Errorf("failed to create bug: %w", err)
1814+	}
1815+
1816+	// Commit the bug to persist it
1817+	if err := newBug.Commit(repo); err != nil {
1818+		return fmt.Errorf("failed to commit bug: %w", err)
1819+	}
1820+
1821+	// Get the bug ID and display first 7 characters (like bug ls table)
1822+	bugID := newBug.Id().String()
1823+	if len(bugID) > 7 {
1824+		bugID = bugID[:7]
1825+	}
1826+
1827+	fmt.Printf("Created bug %s: %s\n", bugID, title)
1828+
1829+	// Invalidate cache so git-bug sees the new bug
1830+	invalidateGitBugCache(repoPath)
1831+
1832+	return nil
1833+}
1834+
1835+// runComment adds a comment to an existing bug
1836+// If message is empty, opens editor for interactive input
1837+func runComment(repoPath, bugIDStr, message string) error {
1838+	// If no message provided, open editor
1839+	if strings.TrimSpace(message) == "" {
1840+		content, err := launchCommentEditor("")
1841+		if err != nil {
1842+			return err
1843+		}
1844+		message, err = parseCommentContent(content)
1845+		if err != nil {
1846+			return err
1847+		}
1848+	}
1849+
1850+	// Validate message
1851+	if strings.TrimSpace(message) == "" {
1852+		return fmt.Errorf("comment cannot be empty")
1853+	}
1854+
1855+	// Resolve bug ID
1856+	b, err := resolveBugID(repoPath, bugIDStr)
1857+	if err != nil {
1858+		return err
1859+	}
1860+
1861+	// Open repository for identity lookup
1862+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
1863+	if err != nil {
1864+		return fmt.Errorf("failed to open repository: %w", err)
1865+	}
1866+	defer repo.Close()
1867+
1868+	// Get user identity
1869+	author, err := identity.GetUserIdentity(repo)
1870+	if err != nil {
1871+		return fmt.Errorf("failed to get user identity: %w\n\nRun 'bug init' first to set up identities", err)
1872+	}
1873+
1874+	// Add the comment
1875+	unixTime := time.Now().Unix()
1876+	commentID, _, err := bug.AddComment(b, author, unixTime, message, nil, nil)
1877+	if err != nil {
1878+		return fmt.Errorf("failed to add comment: %w", err)
1879+	}
1880+
1881+	// Commit the bug to persist the comment
1882+	if err = b.Commit(repo); err != nil {
1883+		return fmt.Errorf("failed to commit comment: %w", err)
1884+	}
1885+
1886+	// Get IDs for display (first 7 characters)
1887+	displayCommentID := commentID.String()
1888+	displayBugID := b.Id().String()
1889+	if len(displayCommentID) > 7 {
1890+		displayCommentID = displayCommentID[:7]
1891+	}
1892+	if len(displayBugID) > 7 {
1893+		displayBugID = displayBugID[:7]
1894+	}
1895+
1896+	// Display success message
1897+	fmt.Printf("Created comment %s for bug %s\n", displayCommentID, displayBugID)
1898+
1899+	// Invalidate cache so git-bug sees the new comment
1900+	invalidateGitBugCache(repoPath)
1901+
1902+	return nil
1903+}
1904+
1905+// runAgentComment adds a comment to an existing bug as the agent
1906+// Message is required - this is non-interactive
1907+func runAgentComment(repoPath, bugIDStr, message string) error {
1908+	// Validate message (required for agent commands)
1909+	if strings.TrimSpace(message) == "" {
1910+		return fmt.Errorf("message cannot be empty")
1911+	}
1912+
1913+	// Resolve bug ID
1914+	b, err := resolveBugID(repoPath, bugIDStr)
1915+	if err != nil {
1916+		return err
1917+	}
1918+
1919+	// Open repository for identity lookup
1920+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
1921+	if err != nil {
1922+		return fmt.Errorf("failed to open repository: %w", err)
1923+	}
1924+	defer repo.Close()
1925+
1926+	// Get agent identity
1927+	author, err := getAgentIdentity(repo)
1928+	if err != nil {
1929+		return err
1930+	}
1931+
1932+	// Add the comment
1933+	unixTime := time.Now().Unix()
1934+	commentID, _, err := bug.AddComment(b, author, unixTime, message, nil, nil)
1935+	if err != nil {
1936+		return fmt.Errorf("failed to add comment: %w", err)
1937+	}
1938+
1939+	// Commit the bug to persist the comment
1940+	if err = b.Commit(repo); err != nil {
1941+		return fmt.Errorf("failed to commit comment: %w", err)
1942+	}
1943+
1944+	// Get IDs for display (first 7 characters)
1945+	displayCommentID := commentID.String()
1946+	displayBugID := b.Id().String()
1947+	if len(displayCommentID) > 7 {
1948+		displayCommentID = displayCommentID[:7]
1949+	}
1950+	if len(displayBugID) > 7 {
1951+		displayBugID = displayBugID[:7]
1952+	}
1953+
1954+	// Display success message
1955+	fmt.Printf("Created comment %s for bug %s\n", displayCommentID, displayBugID)
1956+
1957+	// Invalidate cache so git-bug sees the new comment
1958+	invalidateGitBugCache(repoPath)
1959+
1960+	return nil
1961+}
1962+
1963+// runEdit edits an issue or comment
1964+// If title and message flags are provided, uses them directly
1965+// Otherwise, opens an editor with current content pre-populated
1966+func runEdit(repoPath, idStr, newTitle, newMessage string) error {
1967+	// Resolve the ID (bug or comment)
1968+	resolved, err := resolveID(repoPath, idStr)
1969+	if err != nil {
1970+		return err
1971+	}
1972+
1973+	// Open repository for identity lookup
1974+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
1975+	if err != nil {
1976+		return fmt.Errorf("failed to open repository: %w", err)
1977+	}
1978+	defer repo.Close()
1979+
1980+	// Get user identity
1981+	author, err := identity.GetUserIdentity(repo)
1982+	if err != nil {
1983+		return fmt.Errorf("failed to get user identity: %w\n\nRun 'bug init' first to set up identities", err)
1984+	}
1985+
1986+	unixTime := time.Now().Unix()
1987+
1988+	var editErr error
1989+	switch resolved.Type {
1990+	case IDTypeBug:
1991+		editErr = editBug(repo, resolved.Bug, author, unixTime, newTitle, newMessage)
1992+	case IDTypeComment:
1993+		editErr = editComment(repo, resolved.Bug, resolved.Comment, author, unixTime, newMessage)
1994+	default:
1995+		return fmt.Errorf("unknown ID type")
1996+	}
1997+
1998+	if editErr == nil {
1999+		// Invalidate cache so git-bug sees the changes
2000+		invalidateGitBugCache(repoPath)
2001+	}
2002+
2003+	return editErr
2004+}
2005+
2006+// editBug edits an issue's title and/or description
2007+func editBug(repo repository.ClockedRepo, b *bug.Bug, author identity.Interface, unixTime int64, newTitle, newMessage string) error {
2008+	snap := b.Compile()
2009+	currentTitle := snap.Title
2010+	currentMessage := ""
2011+	if len(snap.Comments) > 0 {
2012+		currentMessage = snap.Comments[0].Message
2013+	}
2014+
2015+	// If no flags provided, open editor with current content
2016+	if strings.TrimSpace(newTitle) == "" && strings.TrimSpace(newMessage) == "" {
2017+		// Prepare template: title on first line, blank line, description
2018+		template := fmt.Sprintf("%s\n\n%s", currentTitle, currentMessage)
2019+
2020+		// Add edit instructions
2021+		template = `;; Edit the title on the first line (required)
2022+;; Edit the description below the blank line
2023+;; Lines starting with ;; will be ignored
2024+;; Save and close the editor when done
2025+;;
2026+` + template
2027+
2028+		content, err := launchEditorWithTemplate(template)
2029+		if err != nil {
2030+			return err
2031+		}
2032+		newTitle, newMessage, err = parseEditContent(content, false)
2033+		if err != nil {
2034+			return err
2035+		}
2036+	} else {
2037+		// Flags provided - use current values for fields not specified
2038+		if strings.TrimSpace(newTitle) == "" {
2039+			newTitle = currentTitle
2040+		}
2041+		if strings.TrimSpace(newMessage) == "" {
2042+			newMessage = currentMessage
2043+		}
2044+	}
2045+
2046+	// Validate title
2047+	if strings.TrimSpace(newTitle) == "" {
2048+		return fmt.Errorf("title cannot be empty")
2049+	}
2050+
2051+	// Apply changes
2052+	changes := false
2053+
2054+	// Update title if changed
2055+	if newTitle != currentTitle {
2056+		_, err := bug.SetTitle(b, author, unixTime, newTitle, nil)
2057+		if err != nil {
2058+			return fmt.Errorf("failed to update title: %w", err)
2059+		}
2060+		changes = true
2061+	}
2062+
2063+	// Update description (first comment) if changed
2064+	if newMessage != currentMessage {
2065+		_, _, err := bug.EditCreateComment(b, author, unixTime, newMessage, nil, nil)
2066+		if err != nil {
2067+			return fmt.Errorf("failed to update description: %w", err)
2068+		}
2069+		changes = true
2070+	}
2071+
2072+	// Commit changes if any
2073+	if changes {
2074+		if err := b.Commit(repo); err != nil {
2075+			return fmt.Errorf("failed to commit changes: %w", err)
2076+		}
2077+	}
2078+
2079+	// Get short ID for display
2080+	bugID := b.Id().String()
2081+	if len(bugID) > 7 {
2082+		bugID = bugID[:7]
2083+	}
2084+
2085+	fmt.Printf("Updated bug %s: %s\n", bugID, newTitle)
2086+	return nil
2087+}
2088+
2089+// editComment edits an existing comment
2090+func editComment(repo repository.ClockedRepo, b *bug.Bug, comment *bug.Comment, author identity.Interface, unixTime int64, newMessage string) error {
2091+	currentMessage := comment.Message
2092+
2093+	// If no message provided, open editor with current content
2094+	if strings.TrimSpace(newMessage) == "" {
2095+		// Prepare template: current comment content
2096+		template := currentMessage
2097+
2098+		// Add edit instructions
2099+		template = `;; Edit your comment below
2100+;; Lines starting with ;; will be ignored
2101+;; You can use markdown formatting
2102+;; Save and close the editor when done
2103+;;
2104+` + template
2105+
2106+		content, err := launchEditorWithTemplate(template)
2107+		if err != nil {
2108+			return err
2109+		}
2110+		_, newMessage, err = parseEditContent(content, true)
2111+		if err != nil {
2112+			return err
2113+		}
2114+	}
2115+
2116+	// Validate message
2117+	if strings.TrimSpace(newMessage) == "" {
2118+		return fmt.Errorf("comment cannot be empty")
2119+	}
2120+
2121+	// Only update if changed
2122+	if newMessage != currentMessage {
2123+		// Get the target comment ID (the operation ID for this comment)
2124+		targetID := comment.TargetId()
2125+
2126+		_, _, err := bug.EditComment(b, author, unixTime, targetID, newMessage, nil, nil)
2127+		if err != nil {
2128+			return fmt.Errorf("failed to update comment: %w", err)
2129+		}
2130+
2131+		// Commit the changes
2132+		if err := b.Commit(repo); err != nil {
2133+			return fmt.Errorf("failed to commit changes: %w", err)
2134+		}
2135+	}
2136+
2137+	// Get IDs for display
2138+	bugID := b.Id().String()
2139+	commentID := comment.CombinedId().String()
2140+	if len(bugID) > 7 {
2141+		bugID = bugID[:7]
2142+	}
2143+	if len(commentID) > 7 {
2144+		commentID = commentID[:7]
2145+	}
2146+
2147+	fmt.Printf("Updated comment %s in bug %s\n", commentID, bugID)
2148+	return nil
2149+}
2150+
2151+// runAgentEdit edits an issue or comment as the agent identity
2152+// This is non-interactive and requires at least one of --title or --message
2153+func runAgentEdit(repoPath, idStr, newTitle, newMessage string) error {
2154+	// Validate that at least one field is being updated
2155+	if strings.TrimSpace(newTitle) == "" && strings.TrimSpace(newMessage) == "" {
2156+		return fmt.Errorf("either --title or --message must be provided")
2157+	}
2158+
2159+	// Resolve the ID (bug or comment)
2160+	resolved, err := resolveID(repoPath, idStr)
2161+	if err != nil {
2162+		return err
2163+	}
2164+
2165+	// Open repository for identity lookup
2166+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
2167+	if err != nil {
2168+		return fmt.Errorf("failed to open repository: %w", err)
2169+	}
2170+	defer repo.Close()
2171+
2172+	// Get agent identity
2173+	author, err := getAgentIdentity(repo)
2174+	if err != nil {
2175+		return err
2176+	}
2177+
2178+	unixTime := time.Now().Unix()
2179+
2180+	var editErr error
2181+	switch resolved.Type {
2182+	case IDTypeBug:
2183+		editErr = editBugAgent(repo, resolved.Bug, author, unixTime, newTitle, newMessage)
2184+	case IDTypeComment:
2185+		// For comments, only message is applicable
2186+		if strings.TrimSpace(newMessage) == "" {
2187+			return fmt.Errorf("--message is required when editing a comment")
2188+		}
2189+		editErr = editCommentAgent(repo, resolved.Bug, resolved.Comment, author, unixTime, newMessage)
2190+	default:
2191+		return fmt.Errorf("unknown ID type")
2192+	}
2193+
2194+	if editErr == nil {
2195+		// Invalidate cache so git-bug sees the changes
2196+		invalidateGitBugCache(repoPath)
2197+	}
2198+
2199+	return editErr
2200+}
2201+
2202+// editBugAgent edits an issue as the agent (non-interactive)
2203+func editBugAgent(repo repository.ClockedRepo, b *bug.Bug, author identity.Interface, unixTime int64, newTitle, newMessage string) error {
2204+	snap := b.Compile()
2205+	currentTitle := snap.Title
2206+	currentMessage := ""
2207+	if len(snap.Comments) > 0 {
2208+		currentMessage = snap.Comments[0].Message
2209+	}
2210+
2211+	// For bugs, require at least one field to be set
2212+	if strings.TrimSpace(newTitle) == "" && strings.TrimSpace(newMessage) == "" {
2213+		return fmt.Errorf("either --title or --message must be provided")
2214+	}
2215+
2216+	// Use current values for fields not provided
2217+	if strings.TrimSpace(newTitle) == "" {
2218+		newTitle = currentTitle
2219+	}
2220+	if strings.TrimSpace(newMessage) == "" {
2221+		newMessage = currentMessage
2222+	}
2223+
2224+	changes := false
2225+
2226+	// Update title if changed
2227+	if newTitle != currentTitle {
2228+		_, err := bug.SetTitle(b, author, unixTime, newTitle, nil)
2229+		if err != nil {
2230+			return fmt.Errorf("failed to update title: %w", err)
2231+		}
2232+		changes = true
2233+	}
2234+
2235+	// Update description (first comment) if changed
2236+	if newMessage != currentMessage {
2237+		_, _, err := bug.EditCreateComment(b, author, unixTime, newMessage, nil, nil)
2238+		if err != nil {
2239+			return fmt.Errorf("failed to update description: %w", err)
2240+		}
2241+		changes = true
2242+	}
2243+
2244+	// Commit changes if any
2245+	if changes {
2246+		if err := b.Commit(repo); err != nil {
2247+			return fmt.Errorf("failed to commit changes: %w", err)
2248+		}
2249+	}
2250+
2251+	// Get short ID for display
2252+	bugID := b.Id().String()
2253+	if len(bugID) > 7 {
2254+		bugID = bugID[:7]
2255+	}
2256+
2257+	fmt.Printf("Updated bug %s: %s\n", bugID, newTitle)
2258+	return nil
2259+}
2260+
2261+// editCommentAgent edits a comment as the agent (non-interactive)
2262+func editCommentAgent(repo repository.ClockedRepo, b *bug.Bug, comment *bug.Comment, author identity.Interface, unixTime int64, newMessage string) error {
2263+	currentMessage := comment.Message
2264+
2265+	// Validate message
2266+	if strings.TrimSpace(newMessage) == "" {
2267+		return fmt.Errorf("message cannot be empty")
2268+	}
2269+
2270+	// Only update if changed
2271+	if newMessage != currentMessage {
2272+		// Get the target comment ID (the operation ID for this comment)
2273+		targetID := comment.TargetId()
2274+
2275+		_, _, err := bug.EditComment(b, author, unixTime, targetID, newMessage, nil, nil)
2276+		if err != nil {
2277+			return fmt.Errorf("failed to update comment: %w", err)
2278+		}
2279+
2280+		// Commit the changes
2281+		if err := b.Commit(repo); err != nil {
2282+			return fmt.Errorf("failed to commit changes: %w", err)
2283+		}
2284+	}
2285+
2286+	// Get IDs for display
2287+	bugID := b.Id().String()
2288+	commentID := comment.CombinedId().String()
2289+	if len(bugID) > 7 {
2290+		bugID = bugID[:7]
2291+	}
2292+	if len(commentID) > 7 {
2293+		commentID = commentID[:7]
2294+	}
2295+
2296+	fmt.Printf("Updated comment %s in bug %s\n", commentID, bugID)
2297+	return nil
2298+}
2299+
2300+// runOpen opens a bug by changing its status to Open
2301+func runOpen(repoPath, bugIDStr string) error {
2302+	// Resolve bug ID
2303+	b, err := resolveBugID(repoPath, bugIDStr)
2304+	if err != nil {
2305+		return err
2306+	}
2307+
2308+	// Open repository for identity lookup
2309+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
2310+	if err != nil {
2311+		return fmt.Errorf("failed to open repository: %w", err)
2312+	}
2313+	defer repo.Close()
2314+
2315+	// Get user identity
2316+	author, err := identity.GetUserIdentity(repo)
2317+	if err != nil {
2318+		return fmt.Errorf("failed to get user identity: %w\n\nRun 'bug init' first to set up identities", err)
2319+	}
2320+
2321+	// Open the bug
2322+	unixTime := time.Now().Unix()
2323+	_, err = bug.Open(b, author, unixTime, nil)
2324+	if err != nil {
2325+		return fmt.Errorf("failed to open bug: %w", err)
2326+	}
2327+
2328+	// Commit the changes
2329+	if err := b.Commit(repo); err != nil {
2330+		return fmt.Errorf("failed to commit changes: %w", err)
2331+	}
2332+
2333+	// Get short ID for display
2334+	bugID := b.Id().String()
2335+	if len(bugID) > 7 {
2336+		bugID = bugID[:7]
2337+	}
2338+
2339+	fmt.Printf("Opened bug %s: %s\n", bugID, b.Compile().Title)
2340+
2341+	// Invalidate cache so git-bug sees the changes
2342+	invalidateGitBugCache(repoPath)
2343+
2344+	return nil
2345+}
2346+
2347+// runClose closes a bug by changing its status to Closed
2348+func runClose(repoPath, bugIDStr string) error {
2349+	// Resolve bug ID
2350+	b, err := resolveBugID(repoPath, bugIDStr)
2351+	if err != nil {
2352+		return err
2353+	}
2354+
2355+	// Open repository for identity lookup
2356+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
2357+	if err != nil {
2358+		return fmt.Errorf("failed to open repository: %w", err)
2359+	}
2360+	defer repo.Close()
2361+
2362+	// Get user identity
2363+	author, err := identity.GetUserIdentity(repo)
2364+	if err != nil {
2365+		return fmt.Errorf("failed to get user identity: %w\n\nRun 'bug init' first to set up identities", err)
2366+	}
2367+
2368+	// Close the bug
2369+	unixTime := time.Now().Unix()
2370+	_, err = bug.Close(b, author, unixTime, nil)
2371+	if err != nil {
2372+		return fmt.Errorf("failed to close bug: %w", err)
2373+	}
2374+
2375+	// Commit the changes
2376+	if err := b.Commit(repo); err != nil {
2377+		return fmt.Errorf("failed to commit changes: %w", err)
2378+	}
2379+
2380+	// Get short ID for display
2381+	bugID := b.Id().String()
2382+	if len(bugID) > 7 {
2383+		bugID = bugID[:7]
2384+	}
2385+
2386+	fmt.Printf("Closed bug %s: %s\n", bugID, b.Compile().Title)
2387+
2388+	// Invalidate cache so git-bug sees the changes
2389+	invalidateGitBugCache(repoPath)
2390+
2391+	return nil
2392+}
2393+
2394+// runAgentOpen opens a bug using the agent identity
2395+func runAgentOpen(repoPath, bugIDStr string) error {
2396+	// Resolve bug ID
2397+	b, err := resolveBugID(repoPath, bugIDStr)
2398+	if err != nil {
2399+		return err
2400+	}
2401+
2402+	// Open repository for identity lookup
2403+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
2404+	if err != nil {
2405+		return fmt.Errorf("failed to open repository: %w", err)
2406+	}
2407+	defer repo.Close()
2408+
2409+	// Get agent identity
2410+	author, err := getAgentIdentity(repo)
2411+	if err != nil {
2412+		return err
2413+	}
2414+
2415+	// Open the bug
2416+	unixTime := time.Now().Unix()
2417+	_, err = bug.Open(b, author, unixTime, nil)
2418+	if err != nil {
2419+		return fmt.Errorf("failed to open bug: %w", err)
2420+	}
2421+
2422+	// Commit the changes
2423+	if err := b.Commit(repo); err != nil {
2424+		return fmt.Errorf("failed to commit changes: %w", err)
2425+	}
2426+
2427+	// Get short ID for display
2428+	bugID := b.Id().String()
2429+	if len(bugID) > 7 {
2430+		bugID = bugID[:7]
2431+	}
2432+
2433+	fmt.Printf("Opened bug %s: %s\n", bugID, b.Compile().Title)
2434+
2435+	// Invalidate cache so git-bug sees the changes
2436+	invalidateGitBugCache(repoPath)
2437+
2438+	return nil
2439+}
2440+
2441+// runAgentClose closes a bug using the agent identity
2442+func runAgentClose(repoPath, bugIDStr string) error {
2443+	// Resolve bug ID
2444+	b, err := resolveBugID(repoPath, bugIDStr)
2445+	if err != nil {
2446+		return err
2447+	}
2448+
2449+	// Open repository for identity lookup
2450+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
2451+	if err != nil {
2452+		return fmt.Errorf("failed to open repository: %w", err)
2453+	}
2454+	defer repo.Close()
2455+
2456+	// Get agent identity
2457+	author, err := getAgentIdentity(repo)
2458+	if err != nil {
2459+		return err
2460+	}
2461+
2462+	// Close the bug
2463+	unixTime := time.Now().Unix()
2464+	_, err = bug.Close(b, author, unixTime, nil)
2465+	if err != nil {
2466+		return fmt.Errorf("failed to close bug: %w", err)
2467+	}
2468+
2469+	// Commit the changes
2470+	if err := b.Commit(repo); err != nil {
2471+		return fmt.Errorf("failed to commit changes: %w", err)
2472+	}
2473+
2474+	// Get short ID for display
2475+	bugID := b.Id().String()
2476+	if len(bugID) > 7 {
2477+		bugID = bugID[:7]
2478+	}
2479+
2480+	fmt.Printf("Closed bug %s: %s\n", bugID, b.Compile().Title)
2481+
2482+	// Invalidate cache so git-bug sees the changes
2483+	invalidateGitBugCache(repoPath)
2484+
2485+	return nil
2486+}
2487+
2488+// runRemove removes a bug by its ID
2489+// Resolves ID as bug first, then as comment (though comments cannot be removed individually)
2490+func runRemove(repoPath, idStr string) error {
2491+	// Resolve the ID (bug or comment)
2492+	resolved, err := resolveID(repoPath, idStr)
2493+	if err != nil {
2494+		return err
2495+	}
2496+
2497+	// Open repository
2498+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
2499+	if err != nil {
2500+		return fmt.Errorf("failed to open repository: %w", err)
2501+	}
2502+	defer repo.Close()
2503+
2504+	switch resolved.Type {
2505+	case IDTypeBug:
2506+		// Remove the bug
2507+		if err := bug.Remove(repo, resolved.Bug.Id()); err != nil {
2508+			return fmt.Errorf("failed to remove bug: %w", err)
2509+		}
2510+
2511+		// Get short ID for display
2512+		bugID := resolved.Bug.Id().String()
2513+		if len(bugID) > 7 {
2514+			bugID = bugID[:7]
2515+		}
2516+
2517+		fmt.Printf("Removed bug %s: %s\n", bugID, resolved.Bug.Compile().Title)
2518+
2519+	case IDTypeComment:
2520+		// Comments cannot be removed individually in git-bug
2521+		return fmt.Errorf("removing individual comments is not supported; use 'bug rm <bug-id>' to remove the entire issue")
2522+	default:
2523+		return fmt.Errorf("unknown ID type")
2524+	}
2525+
2526+	// Invalidate cache so git-bug sees the changes
2527+	invalidateGitBugCache(repoPath)
2528+
2529+	return nil
2530+}
2531+
2532+func main() {
2533+	// Get absolute path for repo
2534+	if repoPath == "." {
2535+		if cwd, err := os.Getwd(); err == nil {
2536+			repoPath = cwd
2537+		}
2538+	}
2539+	repoPath, _ = filepath.Abs(repoPath)
2540+
2541+	if err := rootCmd.Execute(); err != nil {
2542+		fmt.Fprintln(os.Stderr, err)
2543+		os.Exit(1)
2544+	}
2545+}
A main_test.go
+453, -0
  1@@ -0,0 +1,453 @@
  2+package main
  3+
  4+import (
  5+	"bufio"
  6+	"os"
  7+	"os/exec"
  8+	"path/filepath"
  9+	"runtime"
 10+	"strings"
 11+	"testing"
 12+
 13+	"github.com/git-bug/git-bug/entities/identity"
 14+	"github.com/git-bug/git-bug/repository"
 15+)
 16+
 17+func TestPromptUser(t *testing.T) {
 18+	tests := []struct {
 19+		name        string
 20+		input       string
 21+		prompt      string
 22+		expected    string
 23+		expectError bool
 24+	}{
 25+		{
 26+			name:     "simple input",
 27+			input:    "Test User\n",
 28+			prompt:   "Enter name: ",
 29+			expected: "Test User",
 30+		},
 31+		{
 32+			name:     "input with spaces",
 33+			input:    "John Doe\n",
 34+			prompt:   "Enter name: ",
 35+			expected: "John Doe",
 36+		},
 37+		{
 38+			name:     "empty input",
 39+			input:    "\n",
 40+			prompt:   "Enter name: ",
 41+			expected: "",
 42+		},
 43+		{
 44+			name:     "input with trailing newline",
 45+			input:    "[email protected]\n",
 46+			prompt:   "Enter email: ",
 47+			expected: "[email protected]",
 48+		},
 49+	}
 50+
 51+	for _, tt := range tests {
 52+		t.Run(tt.name, func(t *testing.T) {
 53+			// Create a scanner from the test input string
 54+			scanner := bufio.NewScanner(strings.NewReader(tt.input))
 55+
 56+			// Call promptUserWithScanner with our test scanner
 57+			result, err := promptUserWithScanner(tt.prompt, scanner)
 58+
 59+			if tt.expectError {
 60+				if err == nil {
 61+					t.Errorf("expected error, got nil")
 62+				}
 63+				return
 64+			}
 65+
 66+			if err != nil {
 67+				t.Errorf("unexpected error: %v", err)
 68+				return
 69+			}
 70+
 71+			if result != tt.expected {
 72+				t.Errorf("promptUser() = %q, want %q", result, tt.expected)
 73+			}
 74+		})
 75+	}
 76+}
 77+
 78+func TestDetectJJConfig(t *testing.T) {
 79+	// Test case 1: No .jj directory
 80+	t.Run("no_jj_directory", func(t *testing.T) {
 81+		tmpDir := t.TempDir()
 82+		name, email, err := detectJJConfig(tmpDir)
 83+		if err != nil {
 84+			t.Fatalf("unexpected error: %v", err)
 85+		}
 86+		if name != "" || email != "" {
 87+			t.Errorf("expected empty name and email, got name=%q, email=%q", name, email)
 88+		}
 89+	})
 90+
 91+	// Test case 2: .jj directory exists (would need actual jj config in practice)
 92+	t.Run("jj_directory_exists", func(t *testing.T) {
 93+		// This test documents the expected behavior when .jj exists
 94+		// In a real scenario, it would try to run jj commands
 95+		tmpDir := t.TempDir()
 96+		jjDir := filepath.Join(tmpDir, ".jj")
 97+		if err := os.MkdirAll(jjDir, 0755); err != nil {
 98+			t.Fatalf("failed to create .jj directory: %v", err)
 99+		}
100+		// We expect the function to attempt jj config commands
101+		// Since jj is not available in test env, we just verify the directory detection
102+		_, _, err := detectJJConfig(tmpDir)
103+		// We expect an error since jj binary won't be available
104+		if err == nil {
105+			t.Skip("jj not available in test environment, skipping")
106+		}
107+	})
108+}
109+
110+func TestCreateIdentity(t *testing.T) {
111+	// Create a temporary directory for a test git repo
112+	tmpDir := t.TempDir()
113+
114+	// Initialize a git repo
115+	initCmd := exec.Command("git", "init")
116+	initCmd.Dir = tmpDir
117+	if err := initCmd.Run(); err != nil {
118+		t.Fatalf("failed to init git repo: %v", err)
119+	}
120+
121+	// Test creating an identity
122+	err := createIdentity(tmpDir, "Test User", "[email protected]", true)
123+	if err != nil {
124+		t.Fatalf("createIdentity failed: %v", err)
125+	}
126+
127+	// Verify identity was created by checking if we can get it
128+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
129+	if err != nil {
130+		t.Fatalf("failed to open repo: %v", err)
131+	}
132+	defer repo.Close()
133+
134+	isSet, err := identity.IsUserIdentitySet(repo)
135+	if err != nil {
136+		t.Fatalf("IsUserIdentitySet failed: %v", err)
137+	}
138+	if !isSet {
139+		t.Error("expected user identity to be set")
140+	}
141+
142+	userIdentity, err := identity.GetUserIdentity(repo)
143+	if err != nil {
144+		t.Fatalf("GetUserIdentity failed: %v", err)
145+	}
146+
147+	if userIdentity.Name() != "Test User" {
148+		t.Errorf("expected name 'Test User', got %q", userIdentity.Name())
149+	}
150+
151+	if userIdentity.Email() != "[email protected]" {
152+		t.Errorf("expected email '[email protected]', got %q", userIdentity.Email())
153+	}
154+}
155+
156+func TestInitCommand_InteractiveMode(t *testing.T) {
157+	// Create a temporary directory for a test git repo
158+	tmpDir := t.TempDir()
159+
160+	// Initialize a git repo
161+	initCmd := exec.Command("git", "init")
162+	initCmd.Dir = tmpDir
163+	if err := initCmd.Run(); err != nil {
164+		t.Fatalf("failed to init git repo: %v", err)
165+	}
166+
167+	// Add origin remote (required for bug init)
168+	remoteCmd := exec.Command("git", "remote", "add", "origin", "[email protected]:test/repo.git")
169+	remoteCmd.Dir = tmpDir
170+	if err := remoteCmd.Run(); err != nil {
171+		t.Fatalf("failed to add origin remote: %v", err)
172+	}
173+
174+	// Test the init function with a mock input (simulating interactive mode)
175+	// Since we don't have jj, we'll test the interactive path by mocking stdin
176+	input := "Interactive User\[email protected]\n"
177+	r, w, _ := os.Pipe()
178+	go func() {
179+		w.WriteString(input)
180+		w.Close()
181+	}()
182+
183+	// We need to test runInitWithReader to inject our stdin
184+	err := runInitWithReader(tmpDir, r)
185+	if err != nil {
186+		t.Fatalf("runInitWithReader failed: %v", err)
187+	}
188+
189+	// Verify repo was opened
190+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
191+	if err != nil {
192+		t.Fatalf("failed to open repo: %v", err)
193+	}
194+	defer repo.Close()
195+
196+	// Check if identities were created
197+	ids, err := identity.ListLocalIds(repo)
198+	if err != nil {
199+		t.Fatalf("ListLocalIds failed: %v", err)
200+	}
201+
202+	// Should have at least 2 identities (user + agent)
203+	if len(ids) < 2 {
204+		t.Errorf("expected at least 2 identities (user + agent), got %d", len(ids))
205+	}
206+
207+	// Verify the user identity has the expected name and email
208+	userIdentity, err := identity.GetUserIdentity(repo)
209+	if err != nil {
210+		t.Fatalf("GetUserIdentity failed: %v", err)
211+	}
212+
213+	if userIdentity.Name() != "Interactive User" {
214+		t.Errorf("expected user name 'Interactive User', got %q", userIdentity.Name())
215+	}
216+
217+	if userIdentity.Email() != "[email protected]" {
218+		t.Errorf("expected user email '[email protected]', got %q", userIdentity.Email())
219+	}
220+
221+	// Verify the agent identity exists with empty email
222+	// The agent identity should be one of the identities with name "agent" and empty email
223+	foundAgent := false
224+	for _, id := range ids {
225+		i, err := identity.ReadLocal(repo, id)
226+		if err != nil {
227+			t.Fatalf("failed to read identity %s: %v", id, err)
228+		}
229+		if i.Name() == "agent" && i.Email() == "" {
230+			foundAgent = true
231+			break
232+		}
233+	}
234+	if !foundAgent {
235+		t.Error("expected to find an agent identity with empty email")
236+	}
237+}
238+
239+func TestParseEditorContent(t *testing.T) {
240+	tests := []struct {
241+		name            string
242+		content         string
243+		expectedTitle   string
244+		expectedMessage string
245+		expectError     bool
246+	}{
247+		{
248+			name:            "simple title only",
249+			content:         "Bug title\n",
250+			expectedTitle:   "Bug title",
251+			expectedMessage: "",
252+		},
253+		{
254+			name:            "title with description",
255+			content:         "Bug title\n\nThis is a description\nwith multiple lines\n",
256+			expectedTitle:   "Bug title",
257+			expectedMessage: "\nThis is a description\nwith multiple lines",
258+		},
259+		{
260+			name:            "with comment lines",
261+			content:         ";; This is a comment\nBug title\n\nDescription here\n;; Another comment",
262+			expectedTitle:   "Bug title",
263+			expectedMessage: "Description here",
264+		},
265+		{
266+			name:        "empty content",
267+			content:     "",
268+			expectError: true,
269+		},
270+		{
271+			name:        "only comments",
272+			content:     ";; Comment 1\n;; Comment 2\n",
273+			expectError: true,
274+		},
275+		{
276+			name:            "leading empty lines",
277+			content:         "\n\n  \nActual title\nDescription",
278+			expectedTitle:   "Actual title",
279+			expectedMessage: "Description",
280+		},
281+		{
282+			name:            "markdown headers preserved",
283+			content:         "Bug title\n\n# Header 1\n## Header 2\nContent here",
284+			expectedTitle:   "Bug title",
285+			expectedMessage: "\n# Header 1\n## Header 2\nContent here",
286+		},
287+	}
288+
289+	for _, tt := range tests {
290+		t.Run(tt.name, func(t *testing.T) {
291+			title, message, err := parseEditorContent(tt.content)
292+
293+			if tt.expectError {
294+				if err == nil {
295+					t.Errorf("expected error, got nil")
296+				}
297+				return
298+			}
299+
300+			if err != nil {
301+				t.Errorf("unexpected error: %v", err)
302+				return
303+			}
304+
305+			if title != tt.expectedTitle {
306+				t.Errorf("title = %q, want %q", title, tt.expectedTitle)
307+			}
308+
309+			if message != tt.expectedMessage {
310+				t.Errorf("message = %q, want %q", message, tt.expectedMessage)
311+			}
312+		})
313+	}
314+}
315+
316+func TestParseCommentContent(t *testing.T) {
317+	tests := []struct {
318+		name            string
319+		content         string
320+		expectedMessage string
321+		expectError     bool
322+	}{
323+		{
324+			name:            "simple comment",
325+			content:         "This is a comment\n",
326+			expectedMessage: "This is a comment",
327+		},
328+		{
329+			name:            "multiline comment",
330+			content:         "First line\nSecond line\nThird line\n",
331+			expectedMessage: "First line\nSecond line\nThird line",
332+		},
333+		{
334+			name:            "with comment lines",
335+			content:         ";; This is a comment\nActual content\n;; Another comment",
336+			expectedMessage: "Actual content",
337+		},
338+		{
339+			name:        "only comment lines",
340+			content:     ";; Comment 1\n;; Comment 2\n",
341+			expectError: true,
342+		},
343+		{
344+			name:        "empty content",
345+			content:     "",
346+			expectError: true,
347+		},
348+		{
349+			name:        "only whitespace",
350+			content:     "   \n\n   \n",
351+			expectError: true,
352+		},
353+		{
354+			name:            "leading empty lines",
355+			content:         "\n\n  \nActual content\nMore content",
356+			expectedMessage: "Actual content\nMore content",
357+		},
358+		{
359+			name:            "trailing empty lines",
360+			content:         "Actual content\nMore content\n\n  \n",
361+			expectedMessage: "Actual content\nMore content",
362+		},
363+		{
364+			name:            "markdown content",
365+			content:         "# Header\n\n- List item 1\n- List item 2\n\n**Bold text**",
366+			expectedMessage: "# Header\n\n- List item 1\n- List item 2\n\n**Bold text**",
367+		},
368+		{
369+			name:            "mixed comments and content",
370+			content:         ";; Instructions here\n;; More instructions\n\nActual comment\n;; Hidden comment\nMore content\n;; End comment",
371+			expectedMessage: "Actual comment\nMore content",
372+		},
373+	}
374+
375+	for _, tt := range tests {
376+		t.Run(tt.name, func(t *testing.T) {
377+			message, err := parseCommentContent(tt.content)
378+
379+			if tt.expectError {
380+				if err == nil {
381+					t.Errorf("expected error, got nil")
382+				}
383+				return
384+			}
385+
386+			if err != nil {
387+				t.Errorf("unexpected error: %v", err)
388+				return
389+			}
390+
391+			if message != tt.expectedMessage {
392+				t.Errorf("message = %q, want %q", message, tt.expectedMessage)
393+			}
394+		})
395+	}
396+}
397+
398+func TestGetEditor(t *testing.T) {
399+	// Save original EDITOR value
400+	originalEditor := os.Getenv("EDITOR")
401+	defer os.Setenv("EDITOR", originalEditor)
402+
403+	tests := []struct {
404+		name      string
405+		editorEnv string
406+		check     func(t *testing.T, got string)
407+	}{
408+		{
409+			name:      "with EDITOR set",
410+			editorEnv: "vim",
411+			check: func(t *testing.T, got string) {
412+				if !strings.Contains(got, "vim") {
413+					t.Errorf("getEditor() = %q, want to contain 'vim'", got)
414+				}
415+			},
416+		},
417+		{
418+			name:      "with EDITOR set to path",
419+			editorEnv: "/usr/bin/nano",
420+			check: func(t *testing.T, got string) {
421+				if !strings.Contains(got, "nano") {
422+					t.Errorf("getEditor() = %q, want to contain 'nano'", got)
423+				}
424+			},
425+		},
426+		{
427+			name:      "with EDITOR empty uses default",
428+			editorEnv: "",
429+			check: func(t *testing.T, got string) {
430+				if got == "" {
431+					t.Error("expected default editor, got empty string")
432+				}
433+				// Check it returns expected default based on platform
434+				if runtime.GOOS == "windows" {
435+					if got != "notepad" {
436+						t.Errorf("expected 'notepad' on Windows, got %q", got)
437+					}
438+				} else {
439+					if got != "vi" {
440+						t.Errorf("expected 'vi' on Unix, got %q", got)
441+					}
442+				}
443+			},
444+		},
445+	}
446+
447+	for _, tt := range tests {
448+		t.Run(tt.name, func(t *testing.T) {
449+			os.Setenv("EDITOR", tt.editorEnv)
450+			got := getEditor()
451+			tt.check(t, got)
452+		})
453+	}
454+}
A new_integration_test.go
+169, -0
  1@@ -0,0 +1,169 @@
  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/repository"
 13+)
 14+
 15+// TestNewCommand_WithFlags creates a bug using command-line flags
 16+func TestNewCommand_WithFlags(t *testing.T) {
 17+	tmpDir := t.TempDir()
 18+
 19+	// Initialize a git repo
 20+	initCmd := exec.Command("git", "init")
 21+	initCmd.Dir = tmpDir
 22+	if err := initCmd.Run(); err != nil {
 23+		t.Fatalf("failed to init git repo: %v", err)
 24+	}
 25+
 26+	// Initialize git-bug identities
 27+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
 28+		t.Fatalf("failed to create identity: %v", err)
 29+	}
 30+
 31+	// Create a bug using flags
 32+	title := "Test Bug Title"
 33+	message := "Test bug description"
 34+	if err := runNew(tmpDir, title, message); err != nil {
 35+		t.Fatalf("runNew failed: %v", err)
 36+	}
 37+
 38+	// Verify bug was created
 39+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
 40+	if err != nil {
 41+		t.Fatalf("failed to open repo: %v", err)
 42+	}
 43+	defer repo.Close()
 44+
 45+	// Count bugs
 46+	bugCount := 0
 47+	for streamedBug := range bug.ReadAll(repo) {
 48+		if streamedBug.Err != nil {
 49+			t.Fatalf("failed to read bug: %v", streamedBug.Err)
 50+		}
 51+		b := streamedBug.Entity
 52+		snap := b.Compile()
 53+
 54+		if snap.Title == title {
 55+			bugCount++
 56+			if snap.Comments[0].Message != message {
 57+				t.Errorf("message = %q, want %q", snap.Comments[0].Message, message)
 58+			}
 59+			if snap.Author.Name() != "Test User" {
 60+				t.Errorf("author = %q, want %q", snap.Author.Name(), "Test User")
 61+			}
 62+		}
 63+	}
 64+
 65+	if bugCount != 1 {
 66+		t.Errorf("expected 1 bug with title %q, found %d", title, bugCount)
 67+	}
 68+}
 69+
 70+// TestNewCommand_TitleOnly creates a bug with only title
 71+func TestNewCommand_TitleOnly(t *testing.T) {
 72+	tmpDir := t.TempDir()
 73+
 74+	// Initialize a git repo
 75+	initCmd := exec.Command("git", "init")
 76+	initCmd.Dir = tmpDir
 77+	if err := initCmd.Run(); err != nil {
 78+		t.Fatalf("failed to init git repo: %v", err)
 79+	}
 80+
 81+	// Initialize git-bug identities
 82+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
 83+		t.Fatalf("failed to create identity: %v", err)
 84+	}
 85+
 86+	// Create a bug with title only
 87+	title := "Title Only Bug"
 88+	if err := runNew(tmpDir, title, ""); err != nil {
 89+		t.Fatalf("runNew failed: %v", err)
 90+	}
 91+
 92+	// Verify bug was created
 93+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
 94+	if err != nil {
 95+		t.Fatalf("failed to open repo: %v", err)
 96+	}
 97+	defer repo.Close()
 98+
 99+	// Find the bug
100+	found := false
101+	for streamedBug := range bug.ReadAll(repo) {
102+		if streamedBug.Err != nil {
103+			continue
104+		}
105+		b := streamedBug.Entity
106+		snap := b.Compile()
107+
108+		if snap.Title == title {
109+			found = true
110+			if snap.Comments[0].Message != "" {
111+				t.Errorf("expected empty message, got %q", snap.Comments[0].Message)
112+			}
113+			break
114+		}
115+	}
116+
117+	if !found {
118+		t.Errorf("bug with title %q not found", title)
119+	}
120+}
121+
122+// TestNewCommand_NoIdentityError tests error when identity not set
123+func TestNewCommand_NoIdentityError(t *testing.T) {
124+	tmpDir := t.TempDir()
125+
126+	// Initialize a git repo (but no git-bug identities)
127+	initCmd := exec.Command("git", "init")
128+	initCmd.Dir = tmpDir
129+	if err := initCmd.Run(); err != nil {
130+		t.Fatalf("failed to init git repo: %v", err)
131+	}
132+
133+	// Try to create a bug without identity
134+	err := runNew(tmpDir, "Test Title", "Test message")
135+	if err == nil {
136+		t.Error("expected error when no identity is set, got nil")
137+	}
138+
139+	if !strings.Contains(err.Error(), "failed to get user identity") {
140+		t.Errorf("expected 'failed to get user identity' error, got: %v", err)
141+	}
142+}
143+
144+// TestNewCommand_EmptyTitleError tests error for empty title
145+func TestNewCommand_EmptyTitleError(t *testing.T) {
146+	tmpDir := t.TempDir()
147+
148+	// Initialize a git repo
149+	initCmd := exec.Command("git", "init")
150+	initCmd.Dir = tmpDir
151+	if err := initCmd.Run(); err != nil {
152+		t.Fatalf("failed to init git repo: %v", err)
153+	}
154+
155+	// Initialize git-bug identities
156+	if err := createIdentity(tmpDir, "Test User", "[email protected]", true); err != nil {
157+		t.Fatalf("failed to create identity: %v", err)
158+	}
159+
160+	// Try to create a bug with empty title (this would normally trigger editor)
161+	// For direct testing, we test the validation after editor parsing
162+	err := runNew(tmpDir, "   ", "") // Whitespace-only title
163+	if err == nil {
164+		t.Error("expected error for empty title, got nil")
165+	}
166+
167+	if !strings.Contains(err.Error(), "title cannot be empty") {
168+		t.Errorf("expected 'title cannot be empty' error, got: %v", err)
169+	}
170+}
A read_integration_test.go
+66, -0
 1@@ -0,0 +1,66 @@
 2+//go:build integration
 3+
 4+package main
 5+
 6+import (
 7+	"os/exec"
 8+	"strings"
 9+	"testing"
10+)
11+
12+// TestReadCommand_Integration tests the read command functionality
13+func TestReadCommand_Integration(t *testing.T) {
14+	// Create a temporary directory for our test repo
15+	tmpDir := t.TempDir()
16+
17+	// Initialize a git repo
18+	initCmd := exec.Command("git", "init")
19+	initCmd.Dir = tmpDir
20+	if err := initCmd.Run(); err != nil {
21+		t.Fatalf("failed to init git repo: %v", err)
22+	}
23+	// Add origin remote for bug init
24+	remoteCmd := exec.Command("git", "remote", "add", "origin", "[email protected]:test/repo.git")
25+	remoteCmd.Dir = tmpDir
26+	if err := remoteCmd.Run(); err != nil {
27+		t.Fatalf("failed to add origin remote: %v", err)
28+	}
29+
30+	// Initialize bug identities
31+	if err := runInitWithReader(tmpDir, strings.NewReader("Test User\[email protected]\n")); err != nil {
32+		t.Fatalf("Failed to init bug: %v", err)
33+	}
34+
35+	// Create a bug with a title and description
36+	title := "Test Issue for Read Command"
37+	message := "This is a test description for the read command."
38+	if err := runNew(tmpDir, title, message); err != nil {
39+		t.Fatalf("Failed to create bug: %v", err)
40+	}
41+
42+	// Load bugs to get the ID
43+	issues, err := LoadBugs(tmpDir)
44+	if err != nil {
45+		t.Fatalf("Failed to load bugs: %v", err)
46+	}
47+
48+	if len(issues) == 0 {
49+		t.Fatal("No issues found after creating bug")
50+	}
51+
52+	bugID := issues[0].ShortID
53+
54+	// Add a comment to the bug
55+	commentMsg := "This is a test comment."
56+	if err := runComment(tmpDir, bugID, commentMsg); err != nil {
57+		t.Fatalf("Failed to add comment: %v", err)
58+	}
59+
60+	// Test read command - this should not error
61+	// We can't easily capture stdout, but we can verify it doesn't panic or error
62+	// For a more complete test, we could refactor runRead to return a string
63+	err = runRead(tmpDir, bugID)
64+	if err != nil {
65+		t.Fatalf("runRead failed: %v", err)
66+	}
67+}
A shortid.go
+209, -0
  1@@ -0,0 +1,209 @@
  2+package main
  3+
  4+import (
  5+	"errors"
  6+	"fmt"
  7+	"sort"
  8+	"strings"
  9+
 10+	"github.com/git-bug/git-bug/entities/bug"
 11+	"github.com/git-bug/git-bug/repository"
 12+)
 13+
 14+// Errors returned by ShortIDMap operations
 15+var (
 16+	ErrIDNotFound      = errors.New("full ID not found")
 17+	ErrAmbiguousPrefix = errors.New("short ID prefix is ambiguous")
 18+	ErrPrefixNotFound  = errors.New("short ID prefix not found")
 19+)
 20+
 21+// ShortIDMap holds the mapping between full IDs and their shortest unique prefixes
 22+type ShortIDMap struct {
 23+	// fullToShort maps full ID → shortest unique prefix
 24+	fullToShort map[string]string
 25+
 26+	// shortToFull maps shortest unique prefix → full ID
 27+	shortToFull map[string]string
 28+
 29+	// allFullIDs holds all full IDs for prefix matching
 30+	allFullIDs []string
 31+}
 32+
 33+// ShortIDGenerator creates ShortIDMap from git-bug artifacts (issues and comments)
 34+type ShortIDGenerator struct {
 35+	repoPath string
 36+}
 37+
 38+// NewShortIDGenerator creates a generator for the given repo path
 39+func NewShortIDGenerator(repoPath string) *ShortIDGenerator {
 40+	return &ShortIDGenerator{
 41+		repoPath: repoPath,
 42+	}
 43+}
 44+
 45+// diffPosition returns the first position where two strings differ
 46+// Returns the length of the shorter string if one is a prefix of the other
 47+func diffPosition(a, b string) int {
 48+	minLen := len(a)
 49+	if len(b) < minLen {
 50+		minLen = len(b)
 51+	}
 52+
 53+	for i := 0; i < minLen; i++ {
 54+		if a[i] != b[i] {
 55+			return i
 56+		}
 57+	}
 58+
 59+	return minLen
 60+}
 61+
 62+// findMinPrefix calculates the minimum unique prefix length for an ID at the given index
 63+// in a sorted slice of IDs. It compares with both previous and next neighbors.
 64+func findMinPrefix(sortedIDs []string, index int) int {
 65+	if len(sortedIDs) == 0 {
 66+		return 0
 67+	}
 68+
 69+	if len(sortedIDs) == 1 {
 70+		return 1
 71+	}
 72+
 73+	id := sortedIDs[index]
 74+	maxDiff := 0
 75+
 76+	// Compare with previous neighbor
 77+	if index > 0 {
 78+		prevDiff := diffPosition(id, sortedIDs[index-1])
 79+		if prevDiff > maxDiff {
 80+			maxDiff = prevDiff
 81+		}
 82+	}
 83+
 84+	// Compare with next neighbor
 85+	if index < len(sortedIDs)-1 {
 86+		nextDiff := diffPosition(id, sortedIDs[index+1])
 87+		if nextDiff > maxDiff {
 88+			maxDiff = nextDiff
 89+		}
 90+	}
 91+
 92+	// Minimum unique prefix length is maxDiff + 1
 93+	// But never exceed the full ID length
 94+	minLen := maxDiff + 1
 95+	if minLen > len(id) {
 96+		minLen = len(id)
 97+	}
 98+
 99+	return minLen
100+}
101+
102+// Generate builds a ShortIDMap from all artifacts in the repo.
103+// This queries git-bug for all issues and their comments.
104+// Issue IDs are 64-char SHA256 hashes, comment IDs are 64-char CombinedIds
105+// (interleaved BugID + OperationID). Both are unique across the repo.
106+func (g *ShortIDGenerator) Generate() (*ShortIDMap, error) {
107+	repo, err := repository.OpenGoGitRepo(g.repoPath, "", nil)
108+	if err != nil {
109+		return nil, fmt.Errorf("failed to open repository: %w", err)
110+	}
111+
112+	var allIDs []string
113+
114+	// Collect all issue IDs and comment IDs
115+	for streamedBug := range bug.ReadAll(repo) {
116+		if streamedBug.Err != nil {
117+			continue // Skip errors, process what we can
118+		}
119+
120+		b := streamedBug.Entity
121+
122+		// Add issue ID
123+		issueID := b.Id().String()
124+		allIDs = append(allIDs, issueID)
125+
126+		// Add comment IDs
127+		// Comment CombinedId is an interleaved ID (BugID + OperationID)
128+		// It's unique across the repo because Bug IDs are unique
129+		snap := b.Compile()
130+		for _, comment := range snap.Comments {
131+			commentID := comment.CombinedId().String()
132+			allIDs = append(allIDs, commentID)
133+		}
134+	}
135+
136+	if len(allIDs) == 0 {
137+		return &ShortIDMap{
138+			fullToShort: make(map[string]string),
139+			shortToFull: make(map[string]string),
140+			allFullIDs:  []string{},
141+		}, nil
142+	}
143+
144+	// Sort IDs to enable neighbor comparison
145+	sort.Strings(allIDs)
146+
147+	// Build mappings
148+	fullToShort := make(map[string]string)
149+	shortToFull := make(map[string]string)
150+
151+	for i, fullID := range allIDs {
152+		prefixLen := findMinPrefix(allIDs, i)
153+		shortID := fullID[:prefixLen]
154+
155+		fullToShort[fullID] = shortID
156+		shortToFull[shortID] = fullID
157+	}
158+
159+	return &ShortIDMap{
160+		fullToShort: fullToShort,
161+		shortToFull: shortToFull,
162+		allFullIDs:  allIDs,
163+	}, nil
164+}
165+
166+// GetShortID returns the shortest unique prefix for a full ID.
167+// Returns ErrIDNotFound if the full ID is not in the map.
168+func (m *ShortIDMap) GetShortID(fullID string) (string, error) {
169+	if m == nil {
170+		return "", ErrIDNotFound
171+	}
172+
173+	shortID, ok := m.fullToShort[fullID]
174+	if !ok {
175+		return "", fmt.Errorf("%w: %s", ErrIDNotFound, fullID)
176+	}
177+
178+	return shortID, nil
179+}
180+
181+// GetFullID returns the full ID for a short ID prefix.
182+// The prefix can be the exact short ID or a longer prefix of the full ID.
183+// Returns ErrPrefixNotFound if no ID matches, ErrAmbiguousPrefix if multiple match.
184+func (m *ShortIDMap) GetFullID(shortID string) (string, error) {
185+	if m == nil {
186+		return "", ErrPrefixNotFound
187+	}
188+
189+	// First, check if it's an exact match for a short ID
190+	if fullID, ok := m.shortToFull[shortID]; ok {
191+		return fullID, nil
192+	}
193+
194+	// Otherwise, search for all IDs that start with this prefix
195+	var matches []string
196+	for _, fullID := range m.allFullIDs {
197+		if strings.HasPrefix(fullID, shortID) {
198+			matches = append(matches, fullID)
199+		}
200+	}
201+
202+	switch len(matches) {
203+	case 0:
204+		return "", fmt.Errorf("%w: %s", ErrPrefixNotFound, shortID)
205+	case 1:
206+		return matches[0], nil
207+	default:
208+		return "", fmt.Errorf("%w: prefix %q matches %v", ErrAmbiguousPrefix, shortID, matches)
209+	}
210+}
A shortid_integration_test.go
+46, -0
 1@@ -0,0 +1,46 @@
 2+//go:build integration
 3+// +build integration
 4+
 5+package main
 6+
 7+import (
 8+	"testing"
 9+)
10+
11+// TestGenerate_Integration tests the Generate method with a real git-bug repo.
12+// This test requires a git repository with git-bug data at the specified path.
13+// Run with: go test -tags=integration -v ./...
14+func TestGenerate_Integration(t *testing.T) {
15+	// This is a placeholder - in practice, you'd need to set up a test repo
16+	// or use an environment variable to specify the repo path
17+	repoPath := "."
18+
19+	gen := NewShortIDGenerator(repoPath)
20+	m, err := gen.Generate()
21+	if err != nil {
22+		// It's okay if there's no git-bug data, just skip
23+		t.Skipf("Skipping integration test - no git-bug data: %v", err)
24+	}
25+
26+	// Verify we can get short IDs for all full IDs
27+	for fullID, shortID := range m.fullToShort {
28+		gotShortID, err := m.GetShortID(fullID)
29+		if err != nil {
30+			t.Errorf("GetShortID(%q) failed: %v", fullID, err)
31+			continue
32+		}
33+		if gotShortID != shortID {
34+			t.Errorf("GetShortID(%q) = %q, want %q", fullID, gotShortID, shortID)
35+		}
36+
37+		// Verify we can reverse it
38+		gotFullID, err := m.GetFullID(shortID)
39+		if err != nil {
40+			t.Errorf("GetFullID(%q) failed: %v", shortID, err)
41+			continue
42+		}
43+		if gotFullID != fullID {
44+			t.Errorf("GetFullID(%q) = %q, want %q", shortID, gotFullID, fullID)
45+		}
46+	}
47+}
A shortid_test.go
+254, -0
  1@@ -0,0 +1,254 @@
  2+package main
  3+
  4+import (
  5+	"errors"
  6+	"sort"
  7+	"testing"
  8+)
  9+
 10+func TestDiffPosition(t *testing.T) {
 11+	tests := []struct {
 12+		name     string
 13+		a        string
 14+		b        string
 15+		expected int
 16+	}{
 17+		{"identical strings", "abc", "abc", 3},
 18+		{"differs at start", "abc", "xyz", 0},
 19+		{"differs at position 1", "abc", "axc", 1},
 20+		{"differs at position 2", "abc", "abx", 2},
 21+		{"prefix of other", "abc", "abcd", 3},
 22+		{"empty strings", "", "", 0},
 23+		{"one empty", "abc", "", 0},
 24+		{"hex IDs", "87cghty", "87dfty4", 2},
 25+	}
 26+
 27+	for _, tt := range tests {
 28+		t.Run(tt.name, func(t *testing.T) {
 29+			result := diffPosition(tt.a, tt.b)
 30+			if result != tt.expected {
 31+				t.Errorf("diffPosition(%q, %q) = %d, want %d", tt.a, tt.b, result, tt.expected)
 32+			}
 33+		})
 34+	}
 35+}
 36+
 37+func TestFindMinPrefix(t *testing.T) {
 38+	tests := []struct {
 39+		name     string
 40+		ids      []string
 41+		index    int
 42+		expected int
 43+	}{
 44+		{"single ID", []string{"abc"}, 0, 1},
 45+		{"two IDs - first", []string{"87c", "87d"}, 0, 3},
 46+		{"two IDs - second", []string{"87c", "87d"}, 1, 3},
 47+		{"three IDs - first", []string{"87cghty", "87dfty4", "abcdef"}, 0, 3},
 48+		{"three IDs - middle", []string{"87cghty", "87dfty4", "abcdef"}, 1, 3},
 49+		{"three IDs - last", []string{"87cghty", "87dfty4", "abcdef"}, 2, 1},
 50+		{"all unique at start", []string{"a", "b", "c"}, 0, 1},
 51+		{"shared long prefix", []string{"abcd123", "abcd456", "abcd789"}, 1, 5},
 52+		{"empty slice", []string{}, 0, 0},
 53+	}
 54+
 55+	for _, tt := range tests {
 56+		t.Run(tt.name, func(t *testing.T) {
 57+			result := findMinPrefix(tt.ids, tt.index)
 58+			if result != tt.expected {
 59+				t.Errorf("findMinPrefix(%v, %d) = %d, want %d", tt.ids, tt.index, result, tt.expected)
 60+			}
 61+		})
 62+	}
 63+}
 64+
 65+func TestShortIDMap_GetShortID(t *testing.T) {
 66+	// Create a test map
 67+	m := &ShortIDMap{
 68+		fullToShort: map[string]string{
 69+			"87cghty": "87c",
 70+			"87dfty4": "87d",
 71+			"abcdef":  "a",
 72+		},
 73+		shortToFull: map[string]string{
 74+			"87c": "87cghty",
 75+			"87d": "87dfty4",
 76+			"a":   "abcdef",
 77+		},
 78+		allFullIDs: []string{"87cghty", "87dfty4", "abcdef"},
 79+	}
 80+
 81+	tests := []struct {
 82+		name    string
 83+		fullID  string
 84+		want    string
 85+		wantErr bool
 86+		errIs   error
 87+	}{
 88+		{"existing ID 1", "87cghty", "87c", false, nil},
 89+		{"existing ID 2", "87dfty4", "87d", false, nil},
 90+		{"existing ID 3", "abcdef", "a", false, nil},
 91+		{"non-existent ID", "nonexistent", "", true, ErrIDNotFound},
 92+	}
 93+
 94+	for _, tt := range tests {
 95+		t.Run(tt.name, func(t *testing.T) {
 96+			got, err := m.GetShortID(tt.fullID)
 97+			if (err != nil) != tt.wantErr {
 98+				t.Errorf("GetShortID() error = %v, wantErr %v", err, tt.wantErr)
 99+				return
100+			}
101+			if tt.wantErr && tt.errIs != nil && !errors.Is(err, tt.errIs) {
102+				t.Errorf("GetShortID() error = %v, want error Is %v", err, tt.errIs)
103+				return
104+			}
105+			if got != tt.want {
106+				t.Errorf("GetShortID() = %v, want %v", got, tt.want)
107+			}
108+		})
109+	}
110+}
111+
112+func TestShortIDMap_GetShortID_NilMap(t *testing.T) {
113+	var m *ShortIDMap
114+	_, err := m.GetShortID("test")
115+	if !errors.Is(err, ErrIDNotFound) {
116+		t.Errorf("expected ErrIDNotFound for nil map, got %v", err)
117+	}
118+}
119+
120+func TestShortIDMap_GetFullID(t *testing.T) {
121+	// Create a test map
122+	m := &ShortIDMap{
123+		fullToShort: map[string]string{
124+			"87cghty": "87c",
125+			"87dfty4": "87d",
126+			"abcdef":  "a",
127+		},
128+		shortToFull: map[string]string{
129+			"87c": "87cghty",
130+			"87d": "87dfty4",
131+			"a":   "abcdef",
132+		},
133+		allFullIDs: []string{"87cghty", "87dfty4", "abcdef"},
134+	}
135+
136+	tests := []struct {
137+		name    string
138+		shortID string
139+		want    string
140+		wantErr bool
141+		errIs   error
142+	}{
143+		{"exact short ID 1", "87c", "87cghty", false, nil},
144+		{"exact short ID 2", "87d", "87dfty4", false, nil},
145+		{"exact short ID 3", "a", "abcdef", false, nil},
146+		{"longer prefix", "87cg", "87cghty", false, nil},
147+		{"non-existent prefix", "xyz", "", true, ErrPrefixNotFound},
148+		{"ambiguous prefix", "87", "", true, ErrAmbiguousPrefix},
149+		{"empty prefix", "", "", true, ErrAmbiguousPrefix},
150+	}
151+
152+	for _, tt := range tests {
153+		t.Run(tt.name, func(t *testing.T) {
154+			got, err := m.GetFullID(tt.shortID)
155+			if (err != nil) != tt.wantErr {
156+				t.Errorf("GetFullID() error = %v, wantErr %v", err, tt.wantErr)
157+				return
158+			}
159+			if tt.wantErr && tt.errIs != nil && !errors.Is(err, tt.errIs) {
160+				t.Errorf("GetFullID() error = %v, want error Is %v", err, tt.errIs)
161+				return
162+			}
163+			if got != tt.want {
164+				t.Errorf("GetFullID() = %v, want %v", got, tt.want)
165+			}
166+		})
167+	}
168+}
169+
170+func TestShortIDMap_GetFullID_NilMap(t *testing.T) {
171+	var m *ShortIDMap
172+	_, err := m.GetFullID("test")
173+	if !errors.Is(err, ErrPrefixNotFound) {
174+		t.Errorf("expected ErrPrefixNotFound for nil map, got %v", err)
175+	}
176+}
177+
178+func TestShortIDMap_EdgeCases(t *testing.T) {
179+	tests := []struct {
180+		name        string
181+		ids         []string
182+		expectedMap map[string]string // fullID -> expected shortID
183+	}{
184+		{
185+			name:        "empty repo",
186+			ids:         []string{},
187+			expectedMap: map[string]string{},
188+		},
189+		{
190+			name: "single ID",
191+			ids:  []string{"abc123"},
192+			expectedMap: map[string]string{
193+				"abc123": "a",
194+			},
195+		},
196+		{
197+			name: "two IDs differ at start",
198+			ids:  []string{"abc", "xyz"},
199+			expectedMap: map[string]string{
200+				"abc": "a",
201+				"xyz": "x",
202+			},
203+		},
204+		{
205+			name: "shared long prefix",
206+			ids:  []string{"abcd123", "abcd456", "abcd789"},
207+			expectedMap: map[string]string{
208+				"abcd123": "abcd1",
209+				"abcd456": "abcd4",
210+				"abcd789": "abcd7",
211+			},
212+		},
213+		{
214+			name: "sequential hex IDs",
215+			ids:  []string{"87cghty", "87dfty4", "abcdef"},
216+			expectedMap: map[string]string{
217+				"87cghty": "87c",
218+				"87dfty4": "87d",
219+				"abcdef":  "a",
220+			},
221+		},
222+	}
223+
224+	for _, tt := range tests {
225+		t.Run(tt.name, func(t *testing.T) {
226+			// Sort the IDs like Generate does
227+			sort.Strings(tt.ids)
228+
229+			// Build the map
230+			fullToShort := make(map[string]string)
231+			for i, fullID := range tt.ids {
232+				prefixLen := findMinPrefix(tt.ids, i)
233+				shortID := fullID[:prefixLen]
234+				fullToShort[fullID] = shortID
235+			}
236+
237+			// Verify
238+			for fullID, expectedShort := range tt.expectedMap {
239+				if got := fullToShort[fullID]; got != expectedShort {
240+					t.Errorf("fullID %q: got shortID %q, want %q", fullID, got, expectedShort)
241+				}
242+			}
243+		})
244+	}
245+}
246+
247+func TestNewShortIDGenerator(t *testing.T) {
248+	gen := NewShortIDGenerator("/path/to/repo")
249+	if gen == nil {
250+		t.Fatal("NewShortIDGenerator returned nil")
251+	}
252+	if gen.repoPath != "/path/to/repo" {
253+		t.Errorf("repoPath = %q, want %q", gen.repoPath, "/path/to/repo")
254+	}
255+}
A term.go
+64, -0
 1@@ -0,0 +1,64 @@
 2+package main
 3+
 4+import (
 5+	"fmt"
 6+
 7+	"github.com/git-bug/git-bug/cache"
 8+	"github.com/git-bug/git-bug/repository"
 9+	"github.com/git-bug/git-bug/termui"
10+	"github.com/spf13/cobra"
11+)
12+
13+// runTerm launches the git-bug terminal UI
14+// It first invalidates the cache to ensure git-bug rebuilds it,
15+// then opens the repository, creates a cache, and runs the termui.
16+func runTerm(repoPath string) error {
17+	// Invalidate cache so git-bug is forced to rebuild it
18+	invalidateGitBugCache(repoPath)
19+
20+	// Open the repository
21+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
22+	if err != nil {
23+		return fmt.Errorf("failed to open repository: %w", err)
24+	}
25+	defer repo.Close()
26+
27+	// Create the cache (will rebuild since we invalidated)
28+	// Using NewRepoCacheNoEvents to simplify - it waits for all build events
29+	cacheInstance, err := cache.NewRepoCacheNoEvents(repo)
30+	if err != nil {
31+		return fmt.Errorf("failed to create cache: %w", err)
32+	}
33+	defer cacheInstance.Close()
34+
35+	// Run the terminal UI
36+	if err := termui.Run(cacheInstance); err != nil {
37+		return fmt.Errorf("termui error: %w", err)
38+	}
39+
40+	return nil
41+}
42+
43+var termCmd = &cobra.Command{
44+	Use:     "term",
45+	Aliases: []string{"termui", "ui"},
46+	Short:   "Launch the terminal UI to browse and edit bugs",
47+	Long: `Launch the git-bug terminal UI to browse and edit bugs interactively.
48+
49+This command starts the full-featured terminal interface for managing git-bug
50+issues. It provides a two-pane view with a bug list and detailed bug view,
51+allowing you to browse, create, edit, and comment on bugs.
52+
53+Before starting the UI, the cache is invalidated to ensure git-bug rebuilds
54+it with the latest data. This keeps the 'bug' command and 'git-bug' binary
55+in sync.
56+
57+Examples:
58+  bug term                    # Launch terminal UI in current directory
59+  bug termui                  # Same as above (alias)
60+  bug ui                      # Same as above (alias)
61+  bug term --repo /path/to/repo  # Launch UI for specific repository`,
62+	RunE: func(cmd *cobra.Command, args []string) error {
63+		return runTerm(repoPath)
64+	},
65+}
A update-vendor-hash
+0, -0