+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 {
+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 }
+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 == "." {
+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"
+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"
+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=
+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}}
+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-}
+1,
-1
1@@ -1 +1 @@
2-7
3+9
+1,
-1
1@@ -1 +1 @@
2-27
3+29
1@@ -0,0 +1 @@
2+e10974b80a04fe987ebe0613608ca61922ca1b5a
1@@ -0,0 +1 @@
2+7fe9cc90fb886b4deac3337950c555c709bfffb7
+1,
-1
1@@ -1 +1 @@
2-dac8123e5796000d7a1d8acad91a9275ae510fd3
3+8a89d6cef442997e3a200c55d1a0dedd4d9b2f38