adds an rm command, cleans up rendering of issues in pgit
feat: add bug open command feat: add bug close command
27 files changed,  +1280, -119
M cmd/bug/agent_edit_integration_test.go
+30, -0
 1@@ -22,6 +22,12 @@ func TestAgentEditCommand_EditIssue(t *testing.T) {
 2 	if err := initCmd.Run(); err != nil {
 3 		t.Fatalf("failed to init git repo: %v", err)
 4 	}
 5+	// Add origin remote for bug init
 6+	remoteCmd := exec.Command("git", "remote", "add", "origin", "[email protected]:test/repo.git")
 7+	remoteCmd.Dir = tmpDir
 8+	if err := remoteCmd.Run(); err != nil {
 9+		t.Fatalf("failed to add origin remote: %v", err)
10+	}
11 
12 	// Initialize git-bug identities (creates both user and agent)
13 	if err := runInitWithReader(tmpDir, strings.NewReader("Test User\[email protected]\n")); err != nil {
14@@ -95,6 +101,12 @@ func TestAgentEditCommand_EditComment(t *testing.T) {
15 	if err := initCmd.Run(); err != nil {
16 		t.Fatalf("failed to init git repo: %v", err)
17 	}
18+	// Add origin remote for bug init
19+	remoteCmd := exec.Command("git", "remote", "add", "origin", "[email protected]:test/repo.git")
20+	remoteCmd.Dir = tmpDir
21+	if err := remoteCmd.Run(); err != nil {
22+		t.Fatalf("failed to add origin remote: %v", err)
23+	}
24 
25 	// Initialize git-bug identities
26 	if err := runInitWithReader(tmpDir, strings.NewReader("Test User\[email protected]\n")); err != nil {
27@@ -186,6 +198,12 @@ func TestAgentEditCommand_RequiresMessage(t *testing.T) {
28 	if err := initCmd.Run(); err != nil {
29 		t.Fatalf("failed to init git repo: %v", err)
30 	}
31+	// Add origin remote for bug init
32+	remoteCmd := exec.Command("git", "remote", "add", "origin", "[email protected]:test/repo.git")
33+	remoteCmd.Dir = tmpDir
34+	if err := remoteCmd.Run(); err != nil {
35+		t.Fatalf("failed to add origin remote: %v", err)
36+	}
37 
38 	// Initialize git-bug identities
39 	if err := runInitWithReader(tmpDir, strings.NewReader("Test User\[email protected]\n")); err != nil {
40@@ -257,6 +275,12 @@ func TestAgentEditCommand_RequiresAtLeastOneField(t *testing.T) {
41 	if err := initCmd.Run(); err != nil {
42 		t.Fatalf("failed to init git repo: %v", err)
43 	}
44+	// Add origin remote for bug init
45+	remoteCmd := exec.Command("git", "remote", "add", "origin", "[email protected]:test/repo.git")
46+	remoteCmd.Dir = tmpDir
47+	if err := remoteCmd.Run(); err != nil {
48+		t.Fatalf("failed to add origin remote: %v", err)
49+	}
50 
51 	// Initialize git-bug identities
52 	if err := runInitWithReader(tmpDir, strings.NewReader("Test User\[email protected]\n")); err != nil {
53@@ -309,6 +333,12 @@ func TestAgentEditCommand_OnlyTitle(t *testing.T) {
54 	if err := initCmd.Run(); err != nil {
55 		t.Fatalf("failed to init git repo: %v", err)
56 	}
57+	// Add origin remote for bug init
58+	remoteCmd := exec.Command("git", "remote", "add", "origin", "[email protected]:test/repo.git")
59+	remoteCmd.Dir = tmpDir
60+	if err := remoteCmd.Run(); err != nil {
61+		t.Fatalf("failed to add origin remote: %v", err)
62+	}
63 
64 	// Initialize git-bug identities
65 	if err := runInitWithReader(tmpDir, strings.NewReader("Test User\[email protected]\n")); err != nil {
M cmd/bug/init_integration_test.go
+249, -17
  1@@ -13,8 +13,8 @@ import (
  2 	"github.com/git-bug/git-bug/repository"
  3 )
  4 
  5-// TestInitCommand_WithJJ tests init when jj config is available
  6-func TestInitCommand_WithJJ(t *testing.T) {
  7+// setupGitRepo creates a git repo with an origin remote for testing
  8+func setupGitRepo(t *testing.T) string {
  9 	tmpDir := t.TempDir()
 10 
 11 	// Initialize a git repo
 12@@ -23,6 +23,35 @@ func TestInitCommand_WithJJ(t *testing.T) {
 13 	if err := initCmd.Run(); err != nil {
 14 		t.Fatalf("failed to init git repo: %v", err)
 15 	}
 16+	// Add origin remote for bug init
 17+	remoteCmd := exec.Command("git", "remote", "add", "origin", "[email protected]:test/repo.git")
 18+	remoteCmd.Dir = tmpDir
 19+	if err := remoteCmd.Run(); err != nil {
 20+		t.Fatalf("failed to add origin remote: %v", err)
 21+	}
 22+
 23+	// Configure git user for the repo (needed for git operations)
 24+	exec.Command("git", "-C", tmpDir, "config", "user.email", "[email protected]").Run()
 25+	exec.Command("git", "-C", tmpDir, "config", "user.name", "Test User").Run()
 26+
 27+	return tmpDir
 28+}
 29+
 30+// setupGitRepoWithInit creates a git repo with origin remote and runs bug init
 31+func setupGitRepoWithInit(t *testing.T) string {
 32+	tmpDir := setupGitRepo(t)
 33+
 34+	// Initialize git-bug
 35+	if err := runInitWithReader(tmpDir, strings.NewReader("Test User\[email protected]\n")); err != nil {
 36+		t.Fatalf("runInit failed: %v", err)
 37+	}
 38+
 39+	return tmpDir
 40+}
 41+
 42+// TestInitCommand_WithJJ tests init when jj config is available
 43+func TestInitCommand_WithJJ(t *testing.T) {
 44+	tmpDir := setupGitRepo(t)
 45 
 46 	// Create .jj directory and config file directly
 47 	jjDir := filepath.Join(tmpDir, ".jj")
 48@@ -71,18 +100,58 @@ email = "[email protected]"
 49 	if !isSet {
 50 		t.Error("expected user identity to be set")
 51 	}
 52+
 53+	// Verify refspecs were configured
 54+	config, err := parseGitConfig(tmpDir)
 55+	if err != nil {
 56+		t.Fatalf("failed to parse git config: %v", err)
 57+	}
 58+
 59+	originSection := `remote "origin"`
 60+	if _, exists := config[originSection]; !exists {
 61+		t.Fatal("origin remote not found in config")
 62+	}
 63+
 64+	// Check fetch refspecs
 65+	hasBugsFetch := false
 66+	hasIdentitiesFetch := false
 67+	for _, refspec := range config[originSection]["fetch"] {
 68+		if refspec == "+refs/bugs/*:refs/remotes/origin/bugs/*" {
 69+			hasBugsFetch = true
 70+		}
 71+		if refspec == "+refs/identities/*:refs/remotes/origin/identities/*" {
 72+			hasIdentitiesFetch = true
 73+		}
 74+	}
 75+	if !hasBugsFetch {
 76+		t.Error("bugs fetch refspec not configured")
 77+	}
 78+	if !hasIdentitiesFetch {
 79+		t.Error("identities fetch refspec not configured")
 80+	}
 81+
 82+	// Check push refspecs
 83+	hasBugsPush := false
 84+	hasIdentitiesPush := false
 85+	for _, refspec := range config[originSection]["push"] {
 86+		if refspec == "+refs/bugs/*:refs/bugs/*" {
 87+			hasBugsPush = true
 88+		}
 89+		if refspec == "+refs/identities/*:refs/identities/*" {
 90+			hasIdentitiesPush = true
 91+		}
 92+	}
 93+	if !hasBugsPush {
 94+		t.Error("bugs push refspec not configured")
 95+	}
 96+	if !hasIdentitiesPush {
 97+		t.Error("identities push refspec not configured")
 98+	}
 99 }
100 
101 // TestInitCommand_WithoutJJ tests init without jj config (interactive mode)
102 func TestInitCommand_WithoutJJ(t *testing.T) {
103-	tmpDir := t.TempDir()
104-
105-	// Initialize a git repo (no jj)
106-	initCmd := exec.Command("git", "init")
107-	initCmd.Dir = tmpDir
108-	if err := initCmd.Run(); err != nil {
109-		t.Fatalf("failed to init git repo: %v", err)
110-	}
111+	tmpDir := setupGitRepo(t)
112 
113 	// Simulate user input for interactive mode
114 	input := "Interactive User\[email protected]\n"
115@@ -135,24 +204,187 @@ func TestInitCommand_WithoutJJ(t *testing.T) {
116 
117 // TestInitCommand_EmptyNameError tests that empty name is rejected
118 func TestInitCommand_EmptyNameError(t *testing.T) {
119+	tmpDir := setupGitRepo(t)
120+
121+	// Simulate empty name input
122+	input := "\n"
123+
124+	err := runInitWithReader(tmpDir, strings.NewReader(input))
125+	if err == nil {
126+		t.Error("expected error for empty name, got nil")
127+	}
128+
129+	if !strings.Contains(err.Error(), "name cannot be empty") {
130+		t.Errorf("expected 'name cannot be empty' error, got: %v", err)
131+	}
132+}
133+
134+// TestInitCommand_NoOriginRemote tests that init fails without origin remote
135+func TestInitCommand_NoOriginRemote(t *testing.T) {
136 	tmpDir := t.TempDir()
137 
138-	// Initialize a git repo
139+	// Initialize a git repo WITHOUT origin remote
140 	initCmd := exec.Command("git", "init")
141 	initCmd.Dir = tmpDir
142 	if err := initCmd.Run(); err != nil {
143 		t.Fatalf("failed to init git repo: %v", err)
144 	}
145 
146-	// Simulate empty name input
147-	input := "\n"
148+	// Configure git user
149+	exec.Command("git", "-C", tmpDir, "config", "user.email", "[email protected]").Run()
150+	exec.Command("git", "-C", tmpDir, "config", "user.name", "Test User").Run()
151 
152-	err := runInitWithReader(tmpDir, strings.NewReader(input))
153+	// Create jj config to avoid interactive mode
154+	jjDir := filepath.Join(tmpDir, ".jj")
155+	if err := os.MkdirAll(jjDir, 0755); err != nil {
156+		t.Fatalf("failed to create .jj directory: %v", err)
157+	}
158+	configContent := `[user]
159+name = "JJ Test User"
160+email = "[email protected]"
161+`
162+	configPath := filepath.Join(jjDir, "config.toml")
163+	if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
164+		t.Fatalf("failed to write jj config: %v", err)
165+	}
166+
167+	// Run init - should fail with ErrNoOriginRemote
168+	err := runInit(tmpDir)
169 	if err == nil {
170-		t.Error("expected error for empty name, got nil")
171+		t.Fatal("expected error when no origin remote, got nil")
172 	}
173 
174-	if !strings.Contains(err.Error(), "name cannot be empty") {
175-		t.Errorf("expected 'name cannot be empty' error, got: %v", err)
176+	// Check that it's the specific ErrNoOriginRemote type
177+	if _, ok := err.(ErrNoOriginRemote); !ok {
178+		t.Errorf("expected ErrNoOriginRemote, got: %T - %v", err, err)
179+	}
180+}
181+
182+// TestInitCommand_IdempotentIdentities tests that running init twice doesn't duplicate identities
183+func TestInitCommand_IdempotentIdentities(t *testing.T) {
184+	tmpDir := setupGitRepo(t)
185+
186+	// Create jj config
187+	jjDir := filepath.Join(tmpDir, ".jj")
188+	if err := os.MkdirAll(jjDir, 0755); err != nil {
189+		t.Fatalf("failed to create .jj directory: %v", err)
190+	}
191+	configContent := `[user]
192+name = "JJ Test User"
193+email = "[email protected]"
194+`
195+	configPath := filepath.Join(jjDir, "config.toml")
196+	if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
197+		t.Fatalf("failed to write jj config: %v", err)
198+	}
199+
200+	// Run init first time
201+	err := runInit(tmpDir)
202+	if err != nil {
203+		t.Fatalf("first runInit failed: %v", err)
204+	}
205+
206+	// Count identities after first run
207+	repo, err := repository.OpenGoGitRepo(tmpDir, "", nil)
208+	if err != nil {
209+		t.Fatalf("failed to open repo: %v", err)
210+	}
211+
212+	ids1, err := identity.ListLocalIds(repo)
213+	if err != nil {
214+		t.Fatalf("ListLocalIds failed: %v", err)
215+	}
216+	repo.Close()
217+
218+	// Run init second time
219+	err = runInit(tmpDir)
220+	if err != nil {
221+		t.Fatalf("second runInit failed: %v", err)
222+	}
223+
224+	// Count identities after second run
225+	repo, err = repository.OpenGoGitRepo(tmpDir, "", nil)
226+	if err != nil {
227+		t.Fatalf("failed to open repo: %v", err)
228+	}
229+	defer repo.Close()
230+
231+	ids2, err := identity.ListLocalIds(repo)
232+	if err != nil {
233+		t.Fatalf("ListLocalIds failed: %v", err)
234+	}
235+
236+	// Should have same number of identities
237+	if len(ids1) != len(ids2) {
238+		t.Errorf("identities count changed after second init: first=%d, second=%d", len(ids1), len(ids2))
239+	}
240+
241+	// Verify identities are the same
242+	idMap := make(map[string]bool)
243+	for _, id := range ids1 {
244+		idMap[id.String()] = true
245+	}
246+	for _, id := range ids2 {
247+		if !idMap[id.String()] {
248+			t.Errorf("new identity found after second init: %s", id.String())
249+		}
250+	}
251+}
252+
253+// TestInitCommand_IdempotentRefspecs tests that running init twice doesn't duplicate refspecs
254+func TestInitCommand_IdempotentRefspecs(t *testing.T) {
255+	tmpDir := setupGitRepo(t)
256+
257+	// Create jj config
258+	jjDir := filepath.Join(tmpDir, ".jj")
259+	if err := os.MkdirAll(jjDir, 0755); err != nil {
260+		t.Fatalf("failed to create .jj directory: %v", err)
261+	}
262+	configContent := `[user]
263+name = "JJ Test User"
264+email = "[email protected]"
265+`
266+	configPath := filepath.Join(jjDir, "config.toml")
267+	if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
268+		t.Fatalf("failed to write jj config: %v", err)
269+	}
270+
271+	// Run init first time
272+	err := runInit(tmpDir)
273+	if err != nil {
274+		t.Fatalf("first runInit failed: %v", err)
275+	}
276+
277+	// Get refspec count after first run
278+	config1, err := parseGitConfig(tmpDir)
279+	if err != nil {
280+		t.Fatalf("failed to parse git config: %v", err)
281+	}
282+
283+	originSection := `remote "origin"`
284+	fetchCount1 := len(config1[originSection]["fetch"])
285+	pushCount1 := len(config1[originSection]["push"])
286+
287+	// Run init second time
288+	err = runInit(tmpDir)
289+	if err != nil {
290+		t.Fatalf("second runInit failed: %v", err)
291+	}
292+
293+	// Get refspec count after second run
294+	config2, err := parseGitConfig(tmpDir)
295+	if err != nil {
296+		t.Fatalf("failed to parse git config: %v", err)
297+	}
298+
299+	fetchCount2 := len(config2[originSection]["fetch"])
300+	pushCount2 := len(config2[originSection]["push"])
301+
302+	// Should have same number of refspecs
303+	if fetchCount1 != fetchCount2 {
304+		t.Errorf("fetch refspecs count changed: first=%d, second=%d", fetchCount1, fetchCount2)
305+	}
306+	if pushCount1 != pushCount2 {
307+		t.Errorf("push refspecs count changed: first=%d, second=%d", pushCount1, pushCount2)
308 	}
309 }
M cmd/bug/main.go
+800, -41
   1@@ -17,7 +17,6 @@ import (
   2 	"github.com/git-bug/git-bug/entities/identity"
   3 	"github.com/git-bug/git-bug/entity"
   4 	"github.com/git-bug/git-bug/repository"
   5-	"github.com/picosh/pgit"
   6 	"github.com/spf13/cobra"
   7 )
   8 
   9@@ -39,7 +38,7 @@ func LoadBugs(repoPath string) ([]BugIssue, error) {
  10 	}
  11 
  12 	// Generate short IDs for all issues
  13-	gen := pgit.NewShortIDGenerator(repoPath)
  14+	gen := NewShortIDGenerator(repoPath)
  15 	shortIDMap, err := gen.Generate()
  16 	if err != nil {
  17 		return nil, fmt.Errorf("failed to generate short IDs: %w", err)
  18@@ -403,6 +402,21 @@ func promptUser(prompt string) (string, error) {
  19 	return "", nil
  20 }
  21 
  22+// invalidateGitBugCache deletes the git-bug cache directories to force a rebuild.
  23+// This ensures the `bug` command and `git-bug` binary stay in sync when either
  24+// is used to modify issues or identities. Failures are silent (best effort).
  25+func invalidateGitBugCache(repoPath string) {
  26+	gitBugPath := filepath.Join(repoPath, ".git", "git-bug")
  27+
  28+	// Remove cache directory (serialized excerpts)
  29+	cachePath := filepath.Join(gitBugPath, "cache")
  30+	_ = os.RemoveAll(cachePath)
  31+
  32+	// Remove indexes directory (bleve search indexes)
  33+	indexesPath := filepath.Join(gitBugPath, "indexes")
  34+	_ = os.RemoveAll(indexesPath)
  35+}
  36+
  37 // promptUserWithScanner prompts the user using the provided scanner
  38 // It prints the prompt and returns the trimmed input
  39 func promptUserWithScanner(prompt string, scanner *bufio.Scanner) (string, error) {
  40@@ -715,6 +729,174 @@ func getAgentIdentity(repo repository.Repo) (*identity.Identity, error) {
  41 	return nil, fmt.Errorf("agent identity not found. Run 'bug init' first")
  42 }
  43 
  44+// checkIdentitiesExist checks if user and agent identities already exist
  45+// Returns (userExists, agentExists, error)
  46+func checkIdentitiesExist(repoPath string) (bool, bool, error) {
  47+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
  48+	if err != nil {
  49+		return false, false, fmt.Errorf("failed to open repository: %w", err)
  50+	}
  51+	defer repo.Close()
  52+
  53+	// Check if user identity is set
  54+	userExists, err := identity.IsUserIdentitySet(repo)
  55+	if err != nil {
  56+		return false, false, fmt.Errorf("failed to check user identity: %w", err)
  57+	}
  58+
  59+	// Check if agent identity exists
  60+	agentExists := false
  61+	if userExists {
  62+		_, err := getAgentIdentity(repo)
  63+		if err == nil {
  64+			agentExists = true
  65+		}
  66+	}
  67+
  68+	return userExists, agentExists, nil
  69+}
  70+
  71+// parseGitConfig reads and parses the git config file
  72+// Returns a map of section names to their key-value pairs
  73+// For sections with multiple values (like refspec), values are stored as a slice
  74+func parseGitConfig(repoPath string) (map[string]map[string][]string, error) {
  75+	configPath := filepath.Join(repoPath, ".git", "config")
  76+
  77+	data, err := os.ReadFile(configPath)
  78+	if err != nil {
  79+		if os.IsNotExist(err) {
  80+			return make(map[string]map[string][]string), nil
  81+		}
  82+		return nil, fmt.Errorf("failed to read git config: %w", err)
  83+	}
  84+
  85+	config := make(map[string]map[string][]string)
  86+	var currentSection string
  87+
  88+	lines := strings.Split(string(data), "\n")
  89+	for _, line := range lines {
  90+		trimmed := strings.TrimSpace(line)
  91+
  92+		// Skip empty lines and comments
  93+		if trimmed == "" || strings.HasPrefix(trimmed, "#") {
  94+			continue
  95+		}
  96+
  97+		// Check for section header like [remote "origin"]
  98+		if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
  99+			sectionContent := trimmed[1 : len(trimmed)-1]
 100+			currentSection = sectionContent
 101+			if _, exists := config[currentSection]; !exists {
 102+				config[currentSection] = make(map[string][]string)
 103+			}
 104+			continue
 105+		}
 106+
 107+		// Parse key-value pairs within a section
 108+		if currentSection != "" && strings.Contains(trimmed, "=") {
 109+			parts := strings.SplitN(trimmed, "=", 2)
 110+			if len(parts) == 2 {
 111+				key := strings.TrimSpace(parts[0])
 112+				value := strings.TrimSpace(parts[1])
 113+				config[currentSection][key] = append(config[currentSection][key], value)
 114+			}
 115+		}
 116+	}
 117+
 118+	return config, nil
 119+}
 120+
 121+// writeGitConfig writes the git config back to file
 122+func writeGitConfig(repoPath string, config map[string]map[string][]string) error {
 123+	configPath := filepath.Join(repoPath, ".git", "config")
 124+
 125+	var buf strings.Builder
 126+
 127+	for section, values := range config {
 128+		buf.WriteString("[" + section + "]\n")
 129+		for key, vals := range values {
 130+			for _, val := range vals {
 131+				buf.WriteString("\t" + key + " = " + val + "\n")
 132+			}
 133+		}
 134+		buf.WriteString("\n")
 135+	}
 136+
 137+	if err := os.WriteFile(configPath, []byte(buf.String()), 0644); err != nil {
 138+		return fmt.Errorf("failed to write git config: %w", err)
 139+	}
 140+
 141+	return nil
 142+}
 143+
 144+// hasRefspec checks if a refspec is already configured
 145+func hasRefspec(config map[string][]string, refspec string) bool {
 146+	for _, existing := range config["fetch"] {
 147+		if existing == refspec {
 148+			return true
 149+		}
 150+	}
 151+	for _, existing := range config["push"] {
 152+		if existing == refspec {
 153+			return true
 154+		}
 155+	}
 156+	return false
 157+}
 158+
 159+// configureOriginRefspecs adds bug and identity refspecs to origin remote if not present
 160+// Returns true if any changes were made, false if all refspecs already exist
 161+func configureOriginRefspecs(repoPath string) (bool, error) {
 162+	config, err := parseGitConfig(repoPath)
 163+	if err != nil {
 164+		return false, err
 165+	}
 166+
 167+	// Check if origin remote exists
 168+	originSection := `remote "origin"`
 169+	if _, exists := config[originSection]; !exists {
 170+		return false, fmt.Errorf("no 'origin' remote configured")
 171+	}
 172+
 173+	// Define the refspecs we want to add
 174+	fetchRefspecs := []string{
 175+		"+refs/bugs/*:refs/remotes/origin/bugs/*",
 176+		"+refs/identities/*:refs/remotes/origin/identities/*",
 177+	}
 178+	pushRefspecs := []string{
 179+		"+refs/bugs/*:refs/bugs/*",
 180+		"+refs/identities/*:refs/identities/*",
 181+	}
 182+
 183+	changed := false
 184+
 185+	// Add fetch refspecs if not present
 186+	for _, refspec := range fetchRefspecs {
 187+		if !hasRefspec(config[originSection], refspec) {
 188+			config[originSection]["fetch"] = append(config[originSection]["fetch"], refspec)
 189+			changed = true
 190+		}
 191+	}
 192+
 193+	// Add push refspecs if not present
 194+	for _, refspec := range pushRefspecs {
 195+		if !hasRefspec(config[originSection], refspec) {
 196+			config[originSection]["push"] = append(config[originSection]["push"], refspec)
 197+			changed = true
 198+		}
 199+	}
 200+
 201+	if !changed {
 202+		return false, nil
 203+	}
 204+
 205+	if err := writeGitConfig(repoPath, config); err != nil {
 206+		return false, err
 207+	}
 208+
 209+	return true, nil
 210+}
 211+
 212 // resolveBugID resolves a bug ID (short or full) to a bug entity
 213 // It uses ShortIDMap to handle short ID resolution
 214 func resolveBugID(repoPath, idStr string) (*bug.Bug, error) {
 215@@ -725,7 +907,7 @@ func resolveBugID(repoPath, idStr string) (*bug.Bug, error) {
 216 	defer repo.Close()
 217 
 218 	// Generate short ID map for resolution
 219-	gen := pgit.NewShortIDGenerator(repoPath)
 220+	gen := NewShortIDGenerator(repoPath)
 221 	shortIDMap, err := gen.Generate()
 222 	if err != nil {
 223 		return nil, fmt.Errorf("failed to generate short IDs: %w", err)
 224@@ -756,7 +938,7 @@ func resolveCommentID(repoPath, idStr string) (*bug.Bug, *bug.Comment, error) {
 225 	defer repo.Close()
 226 
 227 	// Generate short ID map for resolution
 228-	gen := pgit.NewShortIDGenerator(repoPath)
 229+	gen := NewShortIDGenerator(repoPath)
 230 	shortIDMap, err := gen.Generate()
 231 	if err != nil {
 232 		return nil, nil, fmt.Errorf("failed to generate short IDs: %w", err)
 233@@ -853,6 +1035,83 @@ func launchCommentEditor(initialContent string) (string, error) {
 234 	return launchEditorWithTemplate(template)
 235 }
 236 
 237+// runRead reads and displays a bug/issue with all its comments
 238+// Outputs structured metadata followed by description and all comments
 239+func runRead(repoPath, bugIDStr string) error {
 240+	// Resolve bug ID
 241+	b, err := resolveBugID(repoPath, bugIDStr)
 242+	if err != nil {
 243+		return err
 244+	}
 245+
 246+	// Compile bug snapshot
 247+	snap := b.Compile()
 248+
 249+	// Get the first comment (description) for author info
 250+	var authorName, authorEmail string
 251+	var createdAt time.Time
 252+	if len(snap.Comments) > 0 {
 253+		firstComment := snap.Comments[0]
 254+		authorName = firstComment.Author.Name()
 255+		authorEmail = firstComment.Author.Email()
 256+		// Parse the formatted time string
 257+		createdAt, _ = time.Parse("Mon Jan 2 15:04:05 2006 -0700", firstComment.FormatTime())
 258+	}
 259+	if createdAt.IsZero() {
 260+		createdAt = snap.CreateTime
 261+	}
 262+
 263+	// Format author string (name only if no email)
 264+	authorStr := authorName
 265+	if authorEmail != "" {
 266+		authorStr = fmt.Sprintf("%s <%s>", authorName, authorEmail)
 267+	}
 268+
 269+	// Format labels
 270+	labelsStr := "-"
 271+	if len(snap.Labels) > 0 {
 272+		labels := make([]string, len(snap.Labels))
 273+		for i, label := range snap.Labels {
 274+			labels[i] = string(label)
 275+		}
 276+		labelsStr = strings.Join(labels, ", ")
 277+	}
 278+
 279+	// Print metadata header
 280+	fmt.Printf("Title: %s\n", snap.Title)
 281+	fmt.Printf("Author: %s\n", authorStr)
 282+	fmt.Printf("Created: %s\n", createdAt.Format(time.RFC3339))
 283+	fmt.Printf("Status: %s\n", snap.Status.String())
 284+	fmt.Printf("Labels: %s\n", labelsStr)
 285+	fmt.Println()
 286+
 287+	// Print description (first comment)
 288+	if len(snap.Comments) > 0 {
 289+		description := snap.Comments[0].Message
 290+		if description != "" {
 291+			fmt.Println(description)
 292+		}
 293+
 294+		// Print additional comments
 295+		for i := 1; i < len(snap.Comments); i++ {
 296+			comment := snap.Comments[i]
 297+			commentID := comment.CombinedId().String()
 298+			if len(commentID) > 7 {
 299+				commentID = commentID[:7]
 300+			}
 301+
 302+			commentAuthor := comment.Author.Name()
 303+			commentDate := comment.FormatTime()
 304+
 305+			fmt.Println()
 306+			fmt.Printf("Comment %s made by %s on %s:\n\n", commentID, commentAuthor, commentDate)
 307+			fmt.Println(comment.Message)
 308+		}
 309+	}
 310+
 311+	return nil
 312+}
 313+
 314 // parseCommentContent parses editor output for comments
 315 // Lines starting with ;; are treated as comments and ignored
 316 func parseCommentContent(content string) (string, error) {
 317@@ -954,6 +1213,10 @@ func runNew(repoPath, title, message string) error {
 318 	}
 319 
 320 	fmt.Printf("Created bug %s: %s\n", bugID, title)
 321+
 322+	// Invalidate cache so git-bug sees the new bug
 323+	invalidateGitBugCache(repoPath)
 324+
 325 	return nil
 326 }
 327 
 328@@ -964,52 +1227,95 @@ func runInit(repoPath string) error {
 329 	return runInitWithReader(repoPath, os.Stdin)
 330 }
 331 
 332+// ErrNoOriginRemote is returned when no origin remote is configured
 333+type ErrNoOriginRemote struct{}
 334+
 335+func (e ErrNoOriginRemote) Error() string {
 336+	return "no 'origin' remote configured"
 337+}
 338+
 339 // runInitWithReader is the testable version that accepts an io.Reader for stdin
 340 func runInitWithReader(repoPath string, reader io.Reader) error {
 341-	// Try to get user info from jj config
 342-	name, email, err := detectJJConfig(repoPath)
 343+	// Check if identities already exist (idempotent behavior)
 344+	userExists, agentExists, err := checkIdentitiesExist(repoPath)
 345 	if err != nil {
 346-		return fmt.Errorf("failed to detect jj config: %w", err)
 347+		return err
 348 	}
 349 
 350-	// If jj config was found, use it
 351-	if name != "" && email != "" {
 352-		fmt.Printf("Creating user identity from jj config: %s <%s>\n", name, email)
 353+	// Track if we need to prompt for user info
 354+	var name, email string
 355+
 356+	if userExists {
 357+		fmt.Println("User identity already exists, skipping creation")
 358 	} else {
 359-		// Prompt user for name and email
 360-		fmt.Println("No jj config found. Please provide your information:")
 361+		// Try to get user info from jj config
 362+		name, email, err = detectJJConfig(repoPath)
 363+		if err != nil {
 364+			return fmt.Errorf("failed to detect jj config: %w", err)
 365+		}
 366 
 367-		// Create a single scanner to use for all reads
 368-		scanner := bufio.NewScanner(reader)
 369+		// If jj config was found, use it
 370+		if name != "" && email != "" {
 371+			fmt.Printf("Creating user identity from jj config: %s <%s>\n", name, email)
 372+		} else {
 373+			// Prompt user for name and email
 374+			fmt.Println("No jj config found. Please provide your information:")
 375 
 376-		name, err = promptUserWithScanner("Name: ", scanner)
 377-		if err != nil {
 378-			return fmt.Errorf("failed to read name: %w", err)
 379+			// Create a single scanner to use for all reads
 380+			scanner := bufio.NewScanner(reader)
 381+
 382+			name, err = promptUserWithScanner("Name: ", scanner)
 383+			if err != nil {
 384+				return fmt.Errorf("failed to read name: %w", err)
 385+			}
 386+			if name == "" {
 387+				return fmt.Errorf("name cannot be empty")
 388+			}
 389+
 390+			email, err = promptUserWithScanner("Email: ", scanner)
 391+			if err != nil {
 392+				return fmt.Errorf("failed to read email: %w", err)
 393+			}
 394 		}
 395-		if name == "" {
 396-			return fmt.Errorf("name cannot be empty")
 397+
 398+		// Create user identity
 399+		fmt.Printf("Creating user identity: %s <%s>\n", name, email)
 400+		if err := createIdentity(repoPath, name, email, true); err != nil {
 401+			return fmt.Errorf("failed to create user identity: %w", err)
 402 		}
 403+		fmt.Println("User identity created successfully")
 404+	}
 405 
 406-		email, err = promptUserWithScanner("Email: ", scanner)
 407-		if err != nil {
 408-			return fmt.Errorf("failed to read email: %w", err)
 409+	if agentExists {
 410+		fmt.Println("Agent identity already exists, skipping creation")
 411+	} else {
 412+		// Create agent identity
 413+		// Empty email is intentional per spec: equivalent to `git-bug user new --name 'agent' --email ''`
 414+		fmt.Println("Creating agent identity...")
 415+		if err := createIdentity(repoPath, "agent", "", false); err != nil {
 416+			return fmt.Errorf("failed to create agent identity: %w", err)
 417 		}
 418+		fmt.Println("Agent identity created successfully")
 419 	}
 420 
 421-	// Create user identity
 422-	fmt.Printf("Creating user identity: %s <%s>\n", name, email)
 423-	if err := createIdentity(repoPath, name, email, true); err != nil {
 424-		return fmt.Errorf("failed to create user identity: %w", err)
 425+	// Configure git remote refspecs for origin
 426+	configured, err := configureOriginRefspecs(repoPath)
 427+	if err != nil {
 428+		if err.Error() == "no 'origin' remote configured" {
 429+			return ErrNoOriginRemote{}
 430+		}
 431+		return fmt.Errorf("failed to configure git refspecs: %w", err)
 432 	}
 433-	fmt.Println("User identity created successfully")
 434 
 435-	// Create agent identity
 436-	// Empty email is intentional per spec: equivalent to `git-bug user new --name 'agent' --email ''`
 437-	fmt.Println("Creating agent identity...")
 438-	if err := createIdentity(repoPath, "agent", "", false); err != nil {
 439-		return fmt.Errorf("failed to create agent identity: %w", err)
 440+	if configured {
 441+		fmt.Println("Configured git to automatically sync bugs and identities with origin")
 442+	} else if userExists && agentExists {
 443+		// All identities exist and refspecs are configured - nothing to do
 444+		fmt.Println("Nothing to do")
 445 	}
 446-	fmt.Println("Agent identity created successfully")
 447+
 448+	// Invalidate cache so git-bug sees the new identities
 449+	invalidateGitBugCache(repoPath)
 450 
 451 	return nil
 452 }
 453@@ -1029,6 +1335,7 @@ var (
 454 	editMessage     string
 455 	agentEditTitle  string
 456 	agentEditMsg    string
 457+	statusFlag      string
 458 )
 459 
 460 var rootCmd = &cobra.Command{
 461@@ -1052,7 +1359,12 @@ Sorting:
 462   --sort id:asc              Sort by ID (ascending)
 463   --sort id:desc             Sort by ID (descending)
 464   --sort age:asc             Sort by age, oldest first
 465-  --sort age:desc            Sort by age, newest first (default)`,
 466+  --sort age:desc            Sort by age, newest first (default)
 467+
 468+Status Filtering:
 469+  -S, --status open          Show only open issues (default)
 470+  -S, --status closed        Show only closed issues
 471+  -S, --status all           Show all issues`,
 472 	RunE: func(cmd *cobra.Command, args []string) error {
 473 		// Load issues
 474 		issues, err := LoadBugs(repoPath)
 475@@ -1072,6 +1384,21 @@ Sorting:
 476 		}
 477 		issues = ApplyFilter(issues, filterSpec)
 478 
 479+		// Apply status filter
 480+		if statusFlag != "" && strings.ToLower(statusFlag) != "all" {
 481+			statusLower := strings.ToLower(statusFlag)
 482+			if statusLower != "open" && statusLower != "closed" {
 483+				return fmt.Errorf("invalid status: %s (expected 'open', 'closed', or 'all')", statusFlag)
 484+			}
 485+			var filtered []BugIssue
 486+			for _, issue := range issues {
 487+				if strings.ToLower(issue.Status) == statusLower {
 488+					filtered = append(filtered, issue)
 489+				}
 490+			}
 491+			issues = filtered
 492+		}
 493+
 494 		// Parse and apply sort
 495 		sortSpec, err := ParseSort(sortFlag)
 496 		if err != nil {
 497@@ -1097,11 +1424,26 @@ If a .jj directory exists, the command will attempt to read user.name and user.e
 498 from jj config and create a user identity. Otherwise, it will prompt you interactively
 499 for your name and email. It also creates an agent identity.
 500 
 501+This command also configures git push/fetch refspecs for the 'origin' remote
 502+to automatically sync bugs and identities.
 503+
 504 Examples:
 505   bug init                    # Initialize in current directory
 506   bug init --repo /path/to/repo  # Initialize in specific repository`,
 507 	RunE: func(cmd *cobra.Command, args []string) error {
 508-		return runInit(repoPath)
 509+		err := runInit(repoPath)
 510+		if err != nil {
 511+			// Check for special error types
 512+			if _, ok := err.(ErrNoOriginRemote); ok {
 513+				fmt.Fprintln(os.Stderr, "Error: No 'origin' remote configured.")
 514+				fmt.Fprintln(os.Stderr, "")
 515+				fmt.Fprintln(os.Stderr, "Please add a remote with: git remote add origin <url>")
 516+				fmt.Fprintln(os.Stderr, "Then run 'bug init' again to configure bug push/fetch refspecs.")
 517+				os.Exit(1)
 518+			}
 519+			return err
 520+		}
 521+		return nil
 522 	},
 523 }
 524 
 525@@ -1201,6 +1543,104 @@ When using the editor:
 526 	},
 527 }
 528 
 529+var readCmd = &cobra.Command{
 530+	Use:     "read [bugid]",
 531+	Aliases: []string{"show"},
 532+	Short:   "Display a bug/issue with all comments",
 533+	Long: `Display a bug/issue and all its comments in a structured format.
 534+
 535+The output includes metadata (title, author, creation date, status, labels)
 536+followed by the description and all comments.
 537+
 538+Examples:
 539+  bug read abc1234           # Display bug with ID prefix abc1234
 540+  bug show abc1234           # Same as above (alias)
 541+  bug read --repo /path abc  # Display bug from specific repository`,
 542+	Args: cobra.ExactArgs(1),
 543+	RunE: func(cmd *cobra.Command, args []string) error {
 544+		return runRead(repoPath, args[0])
 545+	},
 546+}
 547+
 548+var rmCmd = &cobra.Command{
 549+	Use:     "rm [bugid|commentid]",
 550+	Aliases: []string{"remove"},
 551+	Short:   "Remove a bug/issue by its ID",
 552+	Long: `Remove a bug/issue from the git-bug repository.
 553+
 554+The ID can be a bug ID (full or short). Comments cannot be removed individually;
 555+use 'bug rm <bug-id>' to remove the entire issue including all its comments.
 556+
 557+WARNING: This action is permanent and cannot be undone.
 558+
 559+Examples:
 560+  bug rm abc1234                    # Remove bug with short ID abc1234
 561+  bug rm abc1234567890abcdef        # Remove bug with full ID
 562+  bug remove abc1234                # Same as above (alias)
 563+
 564+Note: If a comment ID is provided, the command will return an error explaining
 565+that individual comments cannot be removed.`,
 566+	Args: cobra.ExactArgs(1),
 567+	RunE: func(cmd *cobra.Command, args []string) error {
 568+		return runRemove(repoPath, args[0])
 569+	},
 570+}
 571+
 572+var agentRmCmd = &cobra.Command{
 573+	Use:     "rm [bugid]",
 574+	Aliases: []string{"remove"},
 575+	Short:   "Remove a bug/issue as the agent",
 576+	Long: `Remove a bug/issue from the git-bug repository using the agent identity.
 577+
 578+This command is non-interactive and uses the agent identity created during 'bug init'.
 579+Comments cannot be removed individually; use 'bug agent rm <bug-id>' to remove
 580+	the entire issue including all its comments.
 581+
 582+WARNING: This action is permanent and cannot be undone.
 583+
 584+Examples:
 585+  bug agent rm abc1234              # Remove bug with short ID abc1234
 586+  bug agent remove abc1234          # Same as above (alias)`,
 587+	Args: cobra.ExactArgs(1),
 588+	RunE: func(cmd *cobra.Command, args []string) error {
 589+		return runRemove(repoPath, args[0])
 590+	},
 591+}
 592+
 593+var openCmd = &cobra.Command{
 594+	Use:   "open [bugid]",
 595+	Short: "Open a closed bug/issue",
 596+	Long: `Open a bug/issue by changing its status from closed to open.
 597+
 598+The ID can be a bug ID (full or short). This command changes the bug's
 599+	status to "open".
 600+
 601+Examples:
 602+  bug open abc1234                    # Open bug with short ID abc1234
 603+  bug open abc1234567890abcdef        # Open bug with full ID`,
 604+	Args: cobra.ExactArgs(1),
 605+	RunE: func(cmd *cobra.Command, args []string) error {
 606+		return runOpen(repoPath, args[0])
 607+	},
 608+}
 609+
 610+var closeCmd = &cobra.Command{
 611+	Use:   "close [bugid]",
 612+	Short: "Close an open bug/issue",
 613+	Long: `Close a bug/issue by changing its status from open to closed.
 614+
 615+The ID can be a bug ID (full or short). This command changes the bug's
 616+	status to "closed".
 617+
 618+Examples:
 619+  bug close abc1234                    # Close bug with short ID abc1234
 620+  bug close abc1234567890abcdef        # Close bug with full ID`,
 621+	Args: cobra.ExactArgs(1),
 622+	RunE: func(cmd *cobra.Command, args []string) error {
 623+		return runClose(repoPath, args[0])
 624+	},
 625+}
 626+
 627 var agentCommentCmd = &cobra.Command{
 628 	Use:   "comment [bugid]",
 629 	Short: "Add a comment to a bug as the agent",
 630@@ -1240,10 +1680,60 @@ For comments:
 631 	},
 632 }
 633 
 634+var agentOpenCmd = &cobra.Command{
 635+	Use:   "open [bugid]",
 636+	Short: "Open a closed bug/issue as the agent",
 637+	Long: `Open a bug/issue by changing its status from closed to open.
 638+
 639+This command uses the agent identity created during 'bug init'.
 640+It is non-interactive and designed for automated/agent use.
 641+
 642+Examples:
 643+  bug agent open abc1234              # Open bug with short ID abc1234`,
 644+	Args: cobra.ExactArgs(1),
 645+	RunE: func(cmd *cobra.Command, args []string) error {
 646+		return runAgentOpen(repoPath, args[0])
 647+	},
 648+}
 649+
 650+var agentCloseCmd = &cobra.Command{
 651+	Use:   "close [bugid]",
 652+	Short: "Close an open bug/issue as the agent",
 653+	Long: `Close a bug/issue by changing its status from open to closed.
 654+
 655+This command uses the agent identity created during 'bug init'.
 656+It is non-interactive and designed for automated/agent use.
 657+
 658+Examples:
 659+  bug agent close abc1234              # Close bug with short ID abc1234`,
 660+	Args: cobra.ExactArgs(1),
 661+	RunE: func(cmd *cobra.Command, args []string) error {
 662+		return runAgentClose(repoPath, args[0])
 663+	},
 664+}
 665+
 666+var agentReadCmd = &cobra.Command{
 667+	Use:   "read [bugid]",
 668+	Short: "Display a bug/issue with all comments",
 669+	Long: `Display a bug/issue and all its comments in a structured format.
 670+
 671+This is the same as 'bug read' but accessible under the agent subcommand.
 672+The output includes metadata (title, author, creation date, status, labels)
 673+followed by the description and all comments.
 674+
 675+Examples:
 676+  bug agent read abc1234     # Display bug with ID prefix abc1234`,
 677+	Args: cobra.ExactArgs(1),
 678+	RunE: func(cmd *cobra.Command, args []string) error {
 679+		return runRead(repoPath, args[0])
 680+	},
 681+}
 682+
 683 func init() {
 684 	rootCmd.PersistentFlags().StringVar(&repoPath, "repo", ".", "path to git repository")
 685 	listCmd.Flags().StringVarP(&filterFlag, "filter", "f", "", "filter issues (label:value or age:<duration>)")
 686 	listCmd.Flags().StringVarP(&sortFlag, "sort", "s", "", "sort issues (field:direction, default: age:desc)")
 687+	listCmd.Flags().StringVarP(&statusFlag, "status", "S", "open", "filter by status (open|closed|all)")
 688 
 689 	newCmd.Flags().StringVarP(&newTitle, "title", "t", "", "issue title")
 690 	newCmd.Flags().StringVarP(&newMessage, "message", "m", "", "issue description/message")
 691@@ -1274,11 +1764,20 @@ func init() {
 692 	rootCmd.AddCommand(newCmd)
 693 	rootCmd.AddCommand(commentCmd)
 694 	rootCmd.AddCommand(editCmd)
 695+	rootCmd.AddCommand(readCmd)
 696+	rootCmd.AddCommand(rmCmd)    // NEW: Add rm command
 697+	rootCmd.AddCommand(termCmd)  // NEW: Add term command
 698+	rootCmd.AddCommand(openCmd)  // NEW: Add open command
 699+	rootCmd.AddCommand(closeCmd) // NEW: Add close command
 700 
 701 	// Add agent command with its subcommands
 702 	agentCmd.AddCommand(agentNewCmd)
 703 	agentCmd.AddCommand(agentCommentCmd)
 704 	agentCmd.AddCommand(agentEditCmd)
 705+	agentCmd.AddCommand(agentReadCmd)
 706+	agentCmd.AddCommand(agentRmCmd)    // NEW: Add agent rm command
 707+	agentCmd.AddCommand(agentOpenCmd)  // NEW: Add agent open command
 708+	agentCmd.AddCommand(agentCloseCmd) // NEW: Add agent close command
 709 	rootCmd.AddCommand(agentCmd)
 710 }
 711 
 712@@ -1325,6 +1824,10 @@ func runAgentNew(repoPath, title, message string) error {
 713 	}
 714 
 715 	fmt.Printf("Created bug %s: %s\n", bugID, title)
 716+
 717+	// Invalidate cache so git-bug sees the new bug
 718+	invalidateGitBugCache(repoPath)
 719+
 720 	return nil
 721 }
 722 
 723@@ -1391,6 +1894,10 @@ func runComment(repoPath, bugIDStr, message string) error {
 724 
 725 	// Display success message
 726 	fmt.Printf("Created comment %s for bug %s\n", displayCommentID, displayBugID)
 727+
 728+	// Invalidate cache so git-bug sees the new comment
 729+	invalidateGitBugCache(repoPath)
 730+
 731 	return nil
 732 }
 733 
 734@@ -1445,6 +1952,10 @@ func runAgentComment(repoPath, bugIDStr, message string) error {
 735 
 736 	// Display success message
 737 	fmt.Printf("Created comment %s for bug %s\n", displayCommentID, displayBugID)
 738+
 739+	// Invalidate cache so git-bug sees the new comment
 740+	invalidateGitBugCache(repoPath)
 741+
 742 	return nil
 743 }
 744 
 745@@ -1473,14 +1984,22 @@ func runEdit(repoPath, idStr, newTitle, newMessage string) error {
 746 
 747 	unixTime := time.Now().Unix()
 748 
 749+	var editErr error
 750 	switch resolved.Type {
 751 	case IDTypeBug:
 752-		return editBug(repo, resolved.Bug, author, unixTime, newTitle, newMessage)
 753+		editErr = editBug(repo, resolved.Bug, author, unixTime, newTitle, newMessage)
 754 	case IDTypeComment:
 755-		return editComment(repo, resolved.Bug, resolved.Comment, author, unixTime, newMessage)
 756+		editErr = editComment(repo, resolved.Bug, resolved.Comment, author, unixTime, newMessage)
 757+	default:
 758+		return fmt.Errorf("unknown ID type")
 759+	}
 760+
 761+	if editErr == nil {
 762+		// Invalidate cache so git-bug sees the changes
 763+		invalidateGitBugCache(repoPath)
 764 	}
 765 
 766-	return fmt.Errorf("unknown ID type")
 767+	return editErr
 768 }
 769 
 770 // editBug edits an issue's title and/or description
 771@@ -1657,18 +2176,26 @@ func runAgentEdit(repoPath, idStr, newTitle, newMessage string) error {
 772 
 773 	unixTime := time.Now().Unix()
 774 
 775+	var editErr error
 776 	switch resolved.Type {
 777 	case IDTypeBug:
 778-		return editBugAgent(repo, resolved.Bug, author, unixTime, newTitle, newMessage)
 779+		editErr = editBugAgent(repo, resolved.Bug, author, unixTime, newTitle, newMessage)
 780 	case IDTypeComment:
 781 		// For comments, only message is applicable
 782 		if strings.TrimSpace(newMessage) == "" {
 783 			return fmt.Errorf("--message is required when editing a comment")
 784 		}
 785-		return editCommentAgent(repo, resolved.Bug, resolved.Comment, author, unixTime, newMessage)
 786+		editErr = editCommentAgent(repo, resolved.Bug, resolved.Comment, author, unixTime, newMessage)
 787+	default:
 788+		return fmt.Errorf("unknown ID type")
 789 	}
 790 
 791-	return fmt.Errorf("unknown ID type")
 792+	if editErr == nil {
 793+		// Invalidate cache so git-bug sees the changes
 794+		invalidateGitBugCache(repoPath)
 795+	}
 796+
 797+	return editErr
 798 }
 799 
 800 // editBugAgent edits an issue as the agent (non-interactive)
 801@@ -1769,6 +2296,238 @@ func editCommentAgent(repo repository.ClockedRepo, b *bug.Bug, comment *bug.Comm
 802 	return nil
 803 }
 804 
 805+// runOpen opens a bug by changing its status to Open
 806+func runOpen(repoPath, bugIDStr string) error {
 807+	// Resolve bug ID
 808+	b, err := resolveBugID(repoPath, bugIDStr)
 809+	if err != nil {
 810+		return err
 811+	}
 812+
 813+	// Open repository for identity lookup
 814+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
 815+	if err != nil {
 816+		return fmt.Errorf("failed to open repository: %w", err)
 817+	}
 818+	defer repo.Close()
 819+
 820+	// Get user identity
 821+	author, err := identity.GetUserIdentity(repo)
 822+	if err != nil {
 823+		return fmt.Errorf("failed to get user identity: %w\n\nRun 'bug init' first to set up identities", err)
 824+	}
 825+
 826+	// Open the bug
 827+	unixTime := time.Now().Unix()
 828+	_, err = bug.Open(b, author, unixTime, nil)
 829+	if err != nil {
 830+		return fmt.Errorf("failed to open bug: %w", err)
 831+	}
 832+
 833+	// Commit the changes
 834+	if err := b.Commit(repo); err != nil {
 835+		return fmt.Errorf("failed to commit changes: %w", err)
 836+	}
 837+
 838+	// Get short ID for display
 839+	bugID := b.Id().String()
 840+	if len(bugID) > 7 {
 841+		bugID = bugID[:7]
 842+	}
 843+
 844+	fmt.Printf("Opened bug %s: %s\n", bugID, b.Compile().Title)
 845+
 846+	// Invalidate cache so git-bug sees the changes
 847+	invalidateGitBugCache(repoPath)
 848+
 849+	return nil
 850+}
 851+
 852+// runClose closes a bug by changing its status to Closed
 853+func runClose(repoPath, bugIDStr string) error {
 854+	// Resolve bug ID
 855+	b, err := resolveBugID(repoPath, bugIDStr)
 856+	if err != nil {
 857+		return err
 858+	}
 859+
 860+	// Open repository for identity lookup
 861+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
 862+	if err != nil {
 863+		return fmt.Errorf("failed to open repository: %w", err)
 864+	}
 865+	defer repo.Close()
 866+
 867+	// Get user identity
 868+	author, err := identity.GetUserIdentity(repo)
 869+	if err != nil {
 870+		return fmt.Errorf("failed to get user identity: %w\n\nRun 'bug init' first to set up identities", err)
 871+	}
 872+
 873+	// Close the bug
 874+	unixTime := time.Now().Unix()
 875+	_, err = bug.Close(b, author, unixTime, nil)
 876+	if err != nil {
 877+		return fmt.Errorf("failed to close bug: %w", err)
 878+	}
 879+
 880+	// Commit the changes
 881+	if err := b.Commit(repo); err != nil {
 882+		return fmt.Errorf("failed to commit changes: %w", err)
 883+	}
 884+
 885+	// Get short ID for display
 886+	bugID := b.Id().String()
 887+	if len(bugID) > 7 {
 888+		bugID = bugID[:7]
 889+	}
 890+
 891+	fmt.Printf("Closed bug %s: %s\n", bugID, b.Compile().Title)
 892+
 893+	// Invalidate cache so git-bug sees the changes
 894+	invalidateGitBugCache(repoPath)
 895+
 896+	return nil
 897+}
 898+
 899+// runAgentOpen opens a bug using the agent identity
 900+func runAgentOpen(repoPath, bugIDStr string) error {
 901+	// Resolve bug ID
 902+	b, err := resolveBugID(repoPath, bugIDStr)
 903+	if err != nil {
 904+		return err
 905+	}
 906+
 907+	// Open repository for identity lookup
 908+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
 909+	if err != nil {
 910+		return fmt.Errorf("failed to open repository: %w", err)
 911+	}
 912+	defer repo.Close()
 913+
 914+	// Get agent identity
 915+	author, err := getAgentIdentity(repo)
 916+	if err != nil {
 917+		return err
 918+	}
 919+
 920+	// Open the bug
 921+	unixTime := time.Now().Unix()
 922+	_, err = bug.Open(b, author, unixTime, nil)
 923+	if err != nil {
 924+		return fmt.Errorf("failed to open bug: %w", err)
 925+	}
 926+
 927+	// Commit the changes
 928+	if err := b.Commit(repo); err != nil {
 929+		return fmt.Errorf("failed to commit changes: %w", err)
 930+	}
 931+
 932+	// Get short ID for display
 933+	bugID := b.Id().String()
 934+	if len(bugID) > 7 {
 935+		bugID = bugID[:7]
 936+	}
 937+
 938+	fmt.Printf("Opened bug %s: %s\n", bugID, b.Compile().Title)
 939+
 940+	// Invalidate cache so git-bug sees the changes
 941+	invalidateGitBugCache(repoPath)
 942+
 943+	return nil
 944+}
 945+
 946+// runAgentClose closes a bug using the agent identity
 947+func runAgentClose(repoPath, bugIDStr string) error {
 948+	// Resolve bug ID
 949+	b, err := resolveBugID(repoPath, bugIDStr)
 950+	if err != nil {
 951+		return err
 952+	}
 953+
 954+	// Open repository for identity lookup
 955+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
 956+	if err != nil {
 957+		return fmt.Errorf("failed to open repository: %w", err)
 958+	}
 959+	defer repo.Close()
 960+
 961+	// Get agent identity
 962+	author, err := getAgentIdentity(repo)
 963+	if err != nil {
 964+		return err
 965+	}
 966+
 967+	// Close the bug
 968+	unixTime := time.Now().Unix()
 969+	_, err = bug.Close(b, author, unixTime, nil)
 970+	if err != nil {
 971+		return fmt.Errorf("failed to close bug: %w", err)
 972+	}
 973+
 974+	// Commit the changes
 975+	if err := b.Commit(repo); err != nil {
 976+		return fmt.Errorf("failed to commit changes: %w", err)
 977+	}
 978+
 979+	// Get short ID for display
 980+	bugID := b.Id().String()
 981+	if len(bugID) > 7 {
 982+		bugID = bugID[:7]
 983+	}
 984+
 985+	fmt.Printf("Closed bug %s: %s\n", bugID, b.Compile().Title)
 986+
 987+	// Invalidate cache so git-bug sees the changes
 988+	invalidateGitBugCache(repoPath)
 989+
 990+	return nil
 991+}
 992+
 993+// runRemove removes a bug by its ID
 994+// Resolves ID as bug first, then as comment (though comments cannot be removed individually)
 995+func runRemove(repoPath, idStr string) error {
 996+	// Resolve the ID (bug or comment)
 997+	resolved, err := resolveID(repoPath, idStr)
 998+	if err != nil {
 999+		return err
1000+	}
1001+
1002+	// Open repository
1003+	repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
1004+	if err != nil {
1005+		return fmt.Errorf("failed to open repository: %w", err)
1006+	}
1007+	defer repo.Close()
1008+
1009+	switch resolved.Type {
1010+	case IDTypeBug:
1011+		// Remove the bug
1012+		if err := bug.Remove(repo, resolved.Bug.Id()); err != nil {
1013+			return fmt.Errorf("failed to remove bug: %w", err)
1014+		}
1015+
1016+		// Get short ID for display
1017+		bugID := resolved.Bug.Id().String()
1018+		if len(bugID) > 7 {
1019+			bugID = bugID[:7]
1020+		}
1021+
1022+		fmt.Printf("Removed bug %s: %s\n", bugID, resolved.Bug.Compile().Title)
1023+
1024+	case IDTypeComment:
1025+		// Comments cannot be removed individually in git-bug
1026+		return fmt.Errorf("removing individual comments is not supported; use 'bug rm <bug-id>' to remove the entire issue")
1027+	default:
1028+		return fmt.Errorf("unknown ID type")
1029+	}
1030+
1031+	// Invalidate cache so git-bug sees the changes
1032+	invalidateGitBugCache(repoPath)
1033+
1034+	return nil
1035+}
1036+
1037 func main() {
1038 	// Get absolute path for repo
1039 	if repoPath == "." {
M cmd/bug/main_test.go
+7, -0
 1@@ -163,6 +163,13 @@ func TestInitCommand_InteractiveMode(t *testing.T) {
 2 		t.Fatalf("failed to init git repo: %v", err)
 3 	}
 4 
 5+	// Add origin remote (required for bug init)
 6+	remoteCmd := exec.Command("git", "remote", "add", "origin", "[email protected]:test/repo.git")
 7+	remoteCmd.Dir = tmpDir
 8+	if err := remoteCmd.Run(); err != nil {
 9+		t.Fatalf("failed to add origin remote: %v", err)
10+	}
11+
12 	// Test the init function with a mock input (simulating interactive mode)
13 	// Since we don't have jj, we'll test the interactive path by mocking stdin
14 	input := "Interactive User\[email protected]\n"
A cmd/bug/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+}
R shortid.go => cmd/bug/shortid.go
+1, -1
1@@ -1,4 +1,4 @@
2-package pgit
3+package main
4 
5 import (
6 	"errors"
R shortid_integration_test.go => cmd/bug/shortid_integration_test.go
+1, -1
1@@ -1,7 +1,7 @@
2 //go:build integration
3 // +build integration
4 
5-package pgit
6+package main
7 
8 import (
9 	"testing"
R shortid_test.go => cmd/bug/shortid_test.go
+1, -1
1@@ -1,4 +1,4 @@
2-package pgit
3+package main
4 
5 import (
6 	"errors"
A cmd/bug/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+}
M go.mod
+5, -0
 1@@ -17,9 +17,11 @@ require (
 2 	dario.cat/mergo v1.0.2 // indirect
 3 	github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
 4 	github.com/99designs/keyring v1.2.2 // indirect
 5+	github.com/MichaelMure/go-term-text v0.3.1 // indirect
 6 	github.com/Microsoft/go-winio v0.6.2 // indirect
 7 	github.com/ProtonMail/go-crypto v1.4.1 // indirect
 8 	github.com/RoaringBitmap/roaring v1.9.4 // indirect
 9+	github.com/awesome-gocui/gocui v1.1.0 // indirect
10 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
11 	github.com/bits-and-blooms/bitset v1.24.4 // indirect
12 	github.com/blevesearch/bleve v1.0.14 // indirect
13@@ -48,6 +50,8 @@ require (
14 	github.com/dvsekhvalnov/jose2go v1.8.0 // indirect
15 	github.com/emirpasic/gods v1.18.1 // indirect
16 	github.com/fatih/color v1.19.0 // indirect
17+	github.com/gdamore/encoding v1.0.1 // indirect
18+	github.com/gdamore/tcell/v2 v2.7.4 // indirect
19 	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
20 	github.com/go-git/go-billy/v5 v5.8.0 // indirect
21 	github.com/go-git/go-git/v5 v5.17.2 // indirect
22@@ -56,6 +60,7 @@ require (
23 	github.com/golang/protobuf v1.5.4 // indirect
24 	github.com/golang/snappy v1.0.0 // indirect
25 	github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
26+	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
27 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
28 	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
29 	github.com/kevinburke/ssh_config v1.6.0 // indirect
M go.sum
+49, -0
  1@@ -5,6 +5,8 @@ github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN
  2 github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=
  3 github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk=
  4 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
  5+github.com/MichaelMure/go-term-text v0.3.1 h1:Kw9kZanyZWiCHOYu9v/8pWEgDQ6UVN9/ix2Vd2zzWf0=
  6+github.com/MichaelMure/go-term-text v0.3.1/go.mod h1:QgVjAEDUnRMlzpS6ky5CGblux7ebeiLnuy9dAaFZu8o=
  7 github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
  8 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
  9 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
 10@@ -24,6 +26,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuW
 11 github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
 12 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
 13 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
 14+github.com/awesome-gocui/gocui v1.1.0 h1:db2j7yFEoHZjpQFeE2xqiatS8bm1lO3THeLwE6MzOII=
 15+github.com/awesome-gocui/gocui v1.1.0/go.mod h1:M2BXkrp7PR97CKnPRT7Rk0+rtswChPtksw/vRAESGpg=
 16 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
 17 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 18 github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
 19@@ -108,6 +112,12 @@ github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4/go.mod h1:5tD+ne
 20 github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
 21 github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
 22 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 23+github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
 24+github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
 25+github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
 26+github.com/gdamore/tcell/v2 v2.4.0/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU=
 27+github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU=
 28+github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg=
 29 github.com/git-bug/git-bug v0.10.1 h1:mjJK/wrfKWUze5EkVpJj+3N3LzSfeujqqH1qq8x4aco=
 30 github.com/git-bug/git-bug v0.10.1/go.mod h1:43BRtb/Nr6QEJlNLkLY/64vyopxA0y5nvjSNsktnan8=
 31 github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
 32@@ -143,6 +153,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
 33 github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
 34 github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
 35 github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
 36+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
 37+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
 38 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
 39 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
 40 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
 41@@ -168,6 +180,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 42 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 43 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 44 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 45+github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 46+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 47 github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
 48 github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 49 github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
 50@@ -175,6 +189,9 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
 51 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
 52 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 53 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 54+github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
 55+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
 56+github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 57 github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
 58 github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
 59 github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 h1:Pijfgr7ZuvX7QIQiEwLdRVr3RoMG+i0SbBO1Qu+7yVk=
 60@@ -204,6 +221,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 61 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 62 github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
 63 github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 64+github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 65+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 66+github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 67 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 68 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 69 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
 70@@ -256,27 +276,40 @@ github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI
 71 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
 72 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
 73 github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
 74+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 75 go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
 76 go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
 77 go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
 78 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 79 golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 80+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 81+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 82 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 83 golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
 84 golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
 85 golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
 86 golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
 87+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 88+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 89 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 90+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 91+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 92 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 93+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 94+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 95 golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
 96 golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
 97 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 98+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 99 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
100+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
101+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
102 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
103 golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
104 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
105 golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
106 golang.org/x/sys v0.0.0-20181221143128-b4a75ba826a6/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
107+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
108 golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
109 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
110 golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
111@@ -284,18 +317,34 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
112 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
113 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
114 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
115+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
116 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
117+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
118+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
119 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
120+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
121 golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
122 golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
123 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
124+golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
125+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
126+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
127+golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
128 golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
129 golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
130 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
131+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
132 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
133+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
134+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
135+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
136 golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
137 golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
138 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
139+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
140+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
141+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
142+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
143 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
144 google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
145 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
M html/issue_detail.page.tmpl
+2, -10
 1@@ -25,16 +25,8 @@
 2     {{end}}
 3   </div>
 4 
 5-  <div class="issue-description">
 6-    <div class="issue-comment">
 7-      <div class="issue-comment__header">
 8-        <span class="issue-comment__author">{{.Issue.Author}}</span>
 9-        <span class="issue-comment__date">
10-          <span class="human-time" data-time="{{.Issue.CreatedAtISO}}">{{.Issue.CreatedAtDisp}}</span>
11-        </span>
12-      </div>
13-      <div class="issue-comment__body markdown">{{.Issue.Description}}</div>
14-    </div>
15+  <div class="issue-description markdown">
16+    {{.Issue.Description}}
17   </div>
18 
19   {{if .Issue.Comments}}
D shortid_example_test.go
+0, -45
 1@@ -1,45 +0,0 @@
 2-package pgit_test
 3-
 4-import (
 5-	"fmt"
 6-	"log"
 7-
 8-	"github.com/picosh/pgit"
 9-)
10-
11-func ExampleShortIDGenerator() {
12-	// Create a generator for a repository
13-	gen := pgit.NewShortIDGenerator("/path/to/git/repo")
14-
15-	// Generate the short ID map
16-	m, err := gen.Generate()
17-	if err != nil {
18-		log.Fatal(err)
19-	}
20-
21-	// Get the short ID for a full issue ID
22-	shortID, err := m.GetShortID("87cd34f1234567890")
23-	if err != nil {
24-		log.Fatal(err)
25-	}
26-	fmt.Printf("Short ID: %s\n", shortID)
27-
28-	// Get the full ID from a short ID
29-	fullID, err := m.GetFullID("87c")
30-	if err != nil {
31-		log.Fatal(err)
32-	}
33-	fmt.Printf("Full ID: %s\n", fullID)
34-}
35-
36-func ExampleShortIDMap_GetShortID() {
37-	// Example with a predefined map showing how the algorithm works
38-	// Given issues: 87cghty, 87dfty4, abcdef
39-	// The algorithm would produce:
40-	//   - 87cghty → 87c (differs from 87dfty4 at position 2)
41-	//   - 87dfty4 → 87d (differs from 87cghty at position 2, from abcdef at position 0)
42-	//   - abcdef → a (differs from 87dfty4 at position 0)
43-
44-	fmt.Println("Given IDs: 87cghty, 87dfty4, abcdef")
45-	fmt.Println("Short IDs: 87c, 87d, a")
46-}
M testdata/clocks/bugs-create
+1, -1
1@@ -1 +1 @@
2-7
3+9
M testdata/clocks/bugs-edit
+1, -1
1@@ -1 +1 @@
2-27
3+29
A testdata/objects/11/7e682f1de1cbae684e10cb70a592f1ffd38b10
+0, -0
A testdata/objects/12/65343166dbe8f1ab3d2d63eb0d3eb295102081
+0, -0
A testdata/objects/45/e300d5bcf73bb6ab46bfedf2f2bb6dfbc4f246
+0, -0
A testdata/objects/70/edc96401893a09af3fa85ed706740c32f9819f
+0, -0
A testdata/objects/7f/e9cc90fb886b4deac3337950c555c709bfffb7
+0, -0
A testdata/objects/8a/89d6cef442997e3a200c55d1a0dedd4d9b2f38
+0, -0
A testdata/objects/9b/746ecf427c5f4753997b45db4d7deba8854149
+0, -0
A testdata/objects/9d/5c935e47471b8022e6a8f42b06c613decf3d9e
+0, -0
A testdata/objects/e1/0974b80a04fe987ebe0613608ca61922ca1b5a
+0, -0
A testdata/refs/bugs/1c991e3f28be87690d170576072a4c2904d046cf4abbc51c4ef3e71fe298b9bf
+1, -0
1@@ -0,0 +1 @@
2+e10974b80a04fe987ebe0613608ca61922ca1b5a
A testdata/refs/bugs/60cb9cae95767a0383346a5cf38a3c202c6154d23b585766707051f3def76574
+1, -0
1@@ -0,0 +1 @@
2+7fe9cc90fb886b4deac3337950c555c709bfffb7
M testdata/refs/heads/main
+1, -1
1@@ -1 +1 @@
2-dac8123e5796000d7a1d8acad91a9275ae510fd3
3+8a89d6cef442997e3a200c55d1a0dedd4d9b2f38