8 files changed,
+1069,
-1
+2,
-0
1@@ -1,3 +1,5 @@
2+./pgit
3+./bug
4 *.swp
5 *.log
6 public/
+470,
-0
1@@ -0,0 +1,470 @@
2+package main
3+
4+import (
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "sort"
9+ "strings"
10+ "time"
11+
12+ "github.com/charmbracelet/lipgloss"
13+ "github.com/dustin/go-humanize"
14+ "github.com/git-bug/git-bug/entities/bug"
15+ "github.com/git-bug/git-bug/repository"
16+ "github.com/picosh/pgit"
17+ "github.com/spf13/cobra"
18+)
19+
20+// BugIssue represents a git-bug issue for display
21+type BugIssue struct {
22+ FullID string
23+ ShortID string
24+ Title string
25+ Labels []string
26+ CreatedAt time.Time
27+ Status string
28+}
29+
30+// LoadBugs loads all issues from the git-bug repository
31+func LoadBugs(repoPath string) ([]BugIssue, error) {
32+ repo, err := repository.OpenGoGitRepo(repoPath, "", nil)
33+ if err != nil {
34+ return nil, fmt.Errorf("failed to open repository: %w", err)
35+ }
36+
37+ // Generate short IDs for all issues
38+ gen := pgit.NewShortIDGenerator(repoPath)
39+ shortIDMap, err := gen.Generate()
40+ if err != nil {
41+ return nil, fmt.Errorf("failed to generate short IDs: %w", err)
42+ }
43+
44+ var issues []BugIssue
45+
46+ for streamedBug := range bug.ReadAll(repo) {
47+ if streamedBug.Err != nil {
48+ continue // Skip errors, process what we can
49+ }
50+
51+ b := streamedBug.Entity
52+ snap := b.Compile()
53+
54+ fullID := b.Id().String()
55+ shortID, _ := shortIDMap.GetShortID(fullID)
56+
57+ labels := make([]string, len(snap.Labels))
58+ for i, label := range snap.Labels {
59+ labels[i] = string(label)
60+ }
61+
62+ issues = append(issues, BugIssue{
63+ FullID: fullID,
64+ ShortID: shortID,
65+ Title: snap.Title,
66+ Labels: labels,
67+ CreatedAt: snap.CreateTime,
68+ Status: snap.Status.String(),
69+ })
70+ }
71+
72+ return issues, nil
73+}
74+
75+// FilterSpec represents a filter criteria
76+type FilterSpec struct {
77+ Labels []string // List of labels that must all be present
78+ AgeOp string // "<" or ">"
79+ AgeValue time.Duration // The duration value for age comparison
80+}
81+
82+// ParseFilter parses a filter string like "label:bug" or "age:<10d"
83+func ParseFilter(filterStr string) (*FilterSpec, error) {
84+ if filterStr == "" {
85+ return nil, nil
86+ }
87+
88+ spec := &FilterSpec{}
89+
90+ // Handle label filter: label:value or label:value1,value2,...
91+ if strings.HasPrefix(filterStr, "label:") {
92+ labelStr := strings.TrimPrefix(filterStr, "label:")
93+ // Split by comma to support multiple labels
94+ labels := strings.Split(labelStr, ",")
95+ // Trim whitespace from each label
96+ for i, label := range labels {
97+ labels[i] = strings.TrimSpace(label)
98+ }
99+ spec.Labels = labels
100+ return spec, nil
101+ }
102+
103+ // Handle age filter: age:<duration>, age:>duration, or age:duration
104+ if strings.HasPrefix(filterStr, "age:") {
105+ ageStr := strings.TrimPrefix(filterStr, "age:")
106+
107+ // Check for operator
108+ spec.AgeOp = "<" // Default operator
109+ if strings.HasPrefix(ageStr, "<") {
110+ ageStr = strings.TrimPrefix(ageStr, "<")
111+ } else if strings.HasPrefix(ageStr, ">") {
112+ spec.AgeOp = ">"
113+ ageStr = strings.TrimPrefix(ageStr, ">")
114+ }
115+
116+ // Parse duration like "10d", "1h", etc.
117+ duration, err := time.ParseDuration(ageStr)
118+ if err != nil {
119+ // Try to parse days (e.g., "10d" -> "240h")
120+ if strings.HasSuffix(ageStr, "d") {
121+ daysStr := strings.TrimSuffix(ageStr, "d")
122+ var days int
123+ if _, err := fmt.Sscanf(daysStr, "%d", &days); err == nil {
124+ duration = time.Duration(days) * 24 * time.Hour
125+ } else {
126+ return nil, fmt.Errorf("invalid age filter: %s", filterStr)
127+ }
128+ } else {
129+ return nil, fmt.Errorf("invalid age filter: %s", filterStr)
130+ }
131+ }
132+ spec.AgeValue = duration
133+ return spec, nil
134+ }
135+
136+ return nil, fmt.Errorf("invalid filter format: %s", filterStr)
137+}
138+
139+// ApplyFilter filters issues based on the filter spec
140+func ApplyFilter(issues []BugIssue, spec *FilterSpec) []BugIssue {
141+ if spec == nil {
142+ return issues
143+ }
144+
145+ var filtered []BugIssue
146+ now := time.Now()
147+
148+ for _, issue := range issues {
149+ // Check label filter - must have ALL specified labels
150+ if len(spec.Labels) > 0 {
151+ hasAllLabels := true
152+ for _, requiredLabel := range spec.Labels {
153+ found := false
154+ for _, label := range issue.Labels {
155+ if label == requiredLabel {
156+ found = true
157+ break
158+ }
159+ }
160+ if !found {
161+ hasAllLabels = false
162+ break
163+ }
164+ }
165+ if !hasAllLabels {
166+ continue
167+ }
168+ }
169+
170+ // Check age filter
171+ if spec.AgeValue > 0 {
172+ age := now.Sub(issue.CreatedAt)
173+ if spec.AgeOp == "<" {
174+ // Bugs newer than AgeValue
175+ if age > spec.AgeValue {
176+ continue
177+ }
178+ } else if spec.AgeOp == ">" {
179+ // Bugs older than AgeValue
180+ if age < spec.AgeValue {
181+ continue
182+ }
183+ }
184+ }
185+
186+ filtered = append(filtered, issue)
187+ }
188+
189+ return filtered
190+}
191+
192+// SortSpec represents sort criteria
193+type SortSpec struct {
194+ Field string // "id" or "age"
195+ Direction string // "asc" or "desc"
196+}
197+
198+// ParseSort parses a sort string like "id:asc" or "age:desc"
199+func ParseSort(sortStr string) (*SortSpec, error) {
200+ if sortStr == "" {
201+ // Default: age:desc (newest first)
202+ return &SortSpec{Field: "age", Direction: "desc"}, nil
203+ }
204+
205+ parts := strings.Split(sortStr, ":")
206+ if len(parts) != 2 {
207+ return nil, fmt.Errorf("invalid sort format: %s (expected field:direction)", sortStr)
208+ }
209+
210+ field := strings.ToLower(parts[0])
211+ direction := strings.ToLower(parts[1])
212+
213+ if field != "id" && field != "age" {
214+ return nil, fmt.Errorf("invalid sort field: %s (expected 'id' or 'age')", field)
215+ }
216+
217+ if direction != "asc" && direction != "desc" {
218+ return nil, fmt.Errorf("invalid sort direction: %s (expected 'asc' or 'desc')", direction)
219+ }
220+
221+ return &SortSpec{Field: field, Direction: direction}, nil
222+}
223+
224+// ApplySort sorts issues based on the sort spec
225+func ApplySort(issues []BugIssue, spec *SortSpec) {
226+ if spec == nil {
227+ return
228+ }
229+
230+ sort.Slice(issues, func(i, j int) bool {
231+ var less bool
232+
233+ switch spec.Field {
234+ case "id":
235+ less = issues[i].FullID < issues[j].FullID
236+ case "age":
237+ less = issues[i].CreatedAt.After(issues[j].CreatedAt) // Newer first
238+ }
239+
240+ if spec.Direction == "desc" {
241+ return !less
242+ }
243+ return less
244+ })
245+}
246+
247+// formatID renders the ID with the short portion in purple
248+// Shows at least 7 characters, with the short ID portion colored
249+func formatID(fullID, shortID string) string {
250+ purpleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#9932CC")) // Purple
251+ normalStyle := lipgloss.NewStyle()
252+
253+ // Always show at least 7 characters
254+ displayLen := 7
255+ if len(shortID) > displayLen {
256+ displayLen = len(shortID)
257+ }
258+ if displayLen > len(fullID) {
259+ displayLen = len(fullID)
260+ }
261+
262+ shortLen := len(shortID)
263+ if shortLen > displayLen {
264+ shortLen = displayLen
265+ }
266+
267+ return purpleStyle.Render(fullID[:shortLen]) + normalStyle.Render(fullID[shortLen:displayLen])
268+}
269+
270+// stripANSI removes ANSI escape codes from a string
271+func stripANSI(s string) string {
272+ result := make([]rune, 0, len(s))
273+ inEscape := false
274+ for _, r := range s {
275+ if inEscape {
276+ if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
277+ inEscape = false
278+ }
279+ continue
280+ }
281+ if r == '\x1b' {
282+ inEscape = true
283+ continue
284+ }
285+ result = append(result, r)
286+ }
287+ return string(result)
288+}
289+
290+// printTable prints a simple text table with styled output
291+// Columns are dynamically sized with at least 2 spaces between them
292+func printTable(issues []BugIssue) error {
293+ // First pass: calculate column widths based on content
294+ const minColSpacing = 2
295+ const maxSummaryLen = 70
296+
297+ idWidth := len("ID")
298+ summaryWidth := len("Summary")
299+ labelsWidth := len("Labels")
300+ ageWidth := len("Age")
301+
302+ // Pre-calculate all formatted values and widths
303+ type rowData struct {
304+ id string
305+ summary string
306+ labels string
307+ age string
308+ }
309+
310+ rows := make([]rowData, len(issues))
311+
312+ for i, issue := range issues {
313+ // Format ID (with ANSI codes)
314+ id := formatID(issue.FullID, issue.ShortID)
315+ idVisualLen := len(stripANSI(id))
316+ if idVisualLen > idWidth {
317+ idWidth = idVisualLen
318+ }
319+
320+ // Format summary (truncate at 70 chars)
321+ summary := issue.Title
322+ if len(summary) > maxSummaryLen {
323+ summary = summary[:maxSummaryLen-3] + "..."
324+ }
325+ if len(summary) > summaryWidth {
326+ summaryWidth = len(summary)
327+ }
328+
329+ // Format labels
330+ labels := strings.Join(issue.Labels, ", ")
331+ if labels == "" {
332+ labels = "-"
333+ }
334+ if len(labels) > labelsWidth {
335+ labelsWidth = len(labels)
336+ }
337+
338+ // Format age
339+ age := humanize.Time(issue.CreatedAt)
340+ if len(age) > ageWidth {
341+ ageWidth = len(age)
342+ }
343+
344+ rows[i] = rowData{id: id, summary: summary, labels: labels, age: age}
345+ }
346+
347+ // Print header
348+ fmt.Printf("%s%s%s%s%s%s%s\n",
349+ padRight("ID", idWidth),
350+ strings.Repeat(" ", minColSpacing),
351+ padRight("Summary", summaryWidth),
352+ strings.Repeat(" ", minColSpacing),
353+ padRight("Labels", labelsWidth),
354+ strings.Repeat(" ", minColSpacing),
355+ padRight("Age", ageWidth),
356+ )
357+
358+ // Print separator
359+ totalWidth := idWidth + minColSpacing + summaryWidth + minColSpacing + labelsWidth + minColSpacing + ageWidth
360+ fmt.Println(strings.Repeat("-", totalWidth))
361+
362+ // Print rows
363+ for _, row := range rows {
364+ fmt.Printf("%s%s%s%s%s%s%s\n",
365+ padRight(row.id, idWidth),
366+ strings.Repeat(" ", minColSpacing),
367+ padRight(row.summary, summaryWidth),
368+ strings.Repeat(" ", minColSpacing),
369+ padRight(row.labels, labelsWidth),
370+ strings.Repeat(" ", minColSpacing),
371+ padRight(row.age, ageWidth),
372+ )
373+ }
374+
375+ return nil
376+}
377+
378+// padRight pads a string to the specified width, accounting for ANSI codes
379+func padRight(s string, width int) string {
380+ visualLen := len(stripANSI(s))
381+ padding := width - visualLen
382+ if padding < 0 {
383+ padding = 0
384+ }
385+ return s + strings.Repeat(" ", padding)
386+}
387+
388+var (
389+ repoPath string
390+ filterFlag string
391+ sortFlag string
392+)
393+
394+var rootCmd = &cobra.Command{
395+ Use: "bug",
396+ Short: "A CLI for git-bug issue tracking",
397+ Long: `bug is a simplified CLI interface to git-bug issues and comments.
398+It provides an easy way to list and manage issues in your git repository.`,
399+}
400+
401+var listCmd = &cobra.Command{
402+ Use: "list",
403+ Aliases: []string{"ls"},
404+ Short: "List all issues in the repository",
405+ Long: `Display a table of all git-bug issues with their ID, summary, labels, and age.
406+
407+Filtering:
408+ --filter label:bug Show only issues with label "bug"
409+ --filter age:<10d Show only issues newer than 10 days
410+
411+Sorting:
412+ --sort id:asc Sort by ID (ascending)
413+ --sort id:desc Sort by ID (descending)
414+ --sort age:asc Sort by age, oldest first
415+ --sort age:desc Sort by age, newest first (default)`,
416+ RunE: func(cmd *cobra.Command, args []string) error {
417+ // Load issues
418+ issues, err := LoadBugs(repoPath)
419+ if err != nil {
420+ return err
421+ }
422+
423+ if len(issues) == 0 {
424+ fmt.Println("No Issues")
425+ return nil
426+ }
427+
428+ // Parse and apply filter
429+ filterSpec, err := ParseFilter(filterFlag)
430+ if err != nil {
431+ return err
432+ }
433+ issues = ApplyFilter(issues, filterSpec)
434+
435+ // Parse and apply sort
436+ sortSpec, err := ParseSort(sortFlag)
437+ if err != nil {
438+ return err
439+ }
440+ ApplySort(issues, sortSpec)
441+
442+ if len(issues) == 0 {
443+ fmt.Println("No Issues")
444+ return nil
445+ }
446+
447+ return printTable(issues)
448+ },
449+}
450+
451+func init() {
452+ rootCmd.PersistentFlags().StringVar(&repoPath, "repo", ".", "path to git repository")
453+ listCmd.Flags().StringVarP(&filterFlag, "filter", "f", "", "filter issues (label:value or age:<duration>)")
454+ listCmd.Flags().StringVarP(&sortFlag, "sort", "s", "", "sort issues (field:direction, default: age:desc)")
455+ rootCmd.AddCommand(listCmd)
456+}
457+
458+func main() {
459+ // Get absolute path for repo
460+ if repoPath == "." {
461+ if cwd, err := os.Getwd(); err == nil {
462+ repoPath = cwd
463+ }
464+ }
465+ repoPath, _ = filepath.Abs(repoPath)
466+
467+ if err := rootCmd.Execute(); err != nil {
468+ fmt.Fprintln(os.Stderr, err)
469+ os.Exit(1)
470+ }
471+}
M
go.mod
+15,
-1
1@@ -4,10 +4,12 @@ go 1.25.0
2
3 require (
4 github.com/alecthomas/chroma/v2 v2.13.0
5+ github.com/charmbracelet/lipgloss v1.1.0
6 github.com/dustin/go-humanize v1.0.1
7 github.com/git-bug/git-bug v0.10.1
8 github.com/gogs/git-module v1.6.0
9 github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
10+ github.com/spf13/cobra v1.10.2
11 github.com/tdewolff/minify/v2 v2.24.12
12 )
13
14@@ -18,6 +20,7 @@ require (
15 github.com/Microsoft/go-winio v0.6.2 // indirect
16 github.com/ProtonMail/go-crypto v1.4.1 // indirect
17 github.com/RoaringBitmap/roaring v1.9.4 // indirect
18+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
19 github.com/bits-and-blooms/bitset v1.24.4 // indirect
20 github.com/blevesearch/bleve v1.0.14 // indirect
21 github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
22@@ -29,6 +32,13 @@ require (
23 github.com/blevesearch/zap/v13 v13.0.6 // indirect
24 github.com/blevesearch/zap/v14 v14.0.5 // indirect
25 github.com/blevesearch/zap/v15 v15.0.3 // indirect
26+ github.com/charmbracelet/colorprofile v0.4.1 // indirect
27+ github.com/charmbracelet/x/ansi v0.11.6 // indirect
28+ github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
29+ github.com/charmbracelet/x/term v0.2.2 // indirect
30+ github.com/clipperhouse/displaywidth v0.9.0 // indirect
31+ github.com/clipperhouse/stringish v0.1.1 // indirect
32+ github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
33 github.com/cloudflare/circl v1.6.3 // indirect
34 github.com/couchbase/vellum v1.0.2 // indirect
35 github.com/cyphar/filepath-securejoin v0.6.1 // indirect
36@@ -50,23 +60,27 @@ require (
37 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
38 github.com/kevinburke/ssh_config v1.6.0 // indirect
39 github.com/klauspost/cpuid/v2 v2.3.0 // indirect
40+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
41 github.com/mattn/go-colorable v0.1.14 // indirect
42 github.com/mattn/go-isatty v0.0.20 // indirect
43+ github.com/mattn/go-runewidth v0.0.19 // indirect
44 github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 // indirect
45 github.com/mschoch/smat v0.2.0 // indirect
46 github.com/mtibben/percent v0.2.1 // indirect
47+ github.com/muesli/termenv v0.16.0 // indirect
48 github.com/pjbgf/sha1cd v0.5.0 // indirect
49 github.com/pkg/errors v0.9.1 // indirect
50 github.com/pmezard/go-difflib v1.0.0 // indirect
51+ github.com/rivo/uniseg v0.4.7 // indirect
52 github.com/sergi/go-diff v1.4.0 // indirect
53 github.com/skeema/knownhosts v1.3.2 // indirect
54- github.com/spf13/cobra v1.10.2 // indirect
55 github.com/spf13/pflag v1.0.9 // indirect
56 github.com/steveyen/gtreap v0.1.0 // indirect
57 github.com/stretchr/testify v1.11.1 // indirect
58 github.com/tdewolff/parse/v2 v2.8.11 // indirect
59 github.com/willf/bitset v1.1.11 // indirect
60 github.com/xanzy/ssh-agent v0.3.3 // indirect
61+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
62 go.etcd.io/bbolt v1.4.3 // indirect
63 golang.org/x/crypto v0.49.0 // indirect
64 golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
M
go.sum
+28,
-0
1@@ -24,6 +24,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuW
2 github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
3 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
4 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
5+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
6+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
7 github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
8 github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
9 github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
10@@ -52,6 +54,22 @@ github.com/blevesearch/zap/v14 v14.0.5 h1:NdcT+81Nvmp2zL+NhwSvGSLh7xNgGL8QRVZ67n
11 github.com/blevesearch/zap/v14 v14.0.5/go.mod h1:bWe8S7tRrSBTIaZ6cLRbgNH4TUDaC9LZSpRGs85AsGY=
12 github.com/blevesearch/zap/v15 v15.0.3 h1:Ylj8Oe+mo0P25tr9iLPp33lN6d4qcztGjaIsP51UxaY=
13 github.com/blevesearch/zap/v15 v15.0.3/go.mod h1:iuwQrImsh1WjWJ0Ue2kBqY83a0rFtJTqfa9fp1rbVVU=
14+github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
15+github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
16+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
17+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
18+github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
19+github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
20+github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
21+github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
22+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
23+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
24+github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
25+github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
26+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
27+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
28+github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
29+github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
30 github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
31 github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
32 github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
33@@ -150,11 +168,15 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
34 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
35 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
36 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
37+github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
38+github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
39 github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
40 github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
41 github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
42 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
43 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
44+github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
45+github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
46 github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 h1:Pijfgr7ZuvX7QIQiEwLdRVr3RoMG+i0SbBO1Qu+7yVk=
47 github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo=
48 github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
49@@ -164,6 +186,8 @@ github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
50 github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
51 github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
52 github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
53+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
54+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
55 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
56 github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
57 github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
58@@ -180,6 +204,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
59 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
60 github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
61 github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
62+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
63+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
64 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
65 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
66 github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
67@@ -227,6 +253,8 @@ github.com/willf/bitset v1.1.11 h1:N7Z7E9UvjW+sGsEl7k/SJrvY2reP1A07MrGuCjIOjRE=
68 github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI=
69 github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
70 github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
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 go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
75 go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
+209,
-0
1@@ -0,0 +1,209 @@
2+package pgit
3+
4+import (
5+ "errors"
6+ "fmt"
7+ "sort"
8+ "strings"
9+
10+ "github.com/git-bug/git-bug/entities/bug"
11+ "github.com/git-bug/git-bug/repository"
12+)
13+
14+// Errors returned by ShortIDMap operations
15+var (
16+ ErrIDNotFound = errors.New("full ID not found")
17+ ErrAmbiguousPrefix = errors.New("short ID prefix is ambiguous")
18+ ErrPrefixNotFound = errors.New("short ID prefix not found")
19+)
20+
21+// ShortIDMap holds the mapping between full IDs and their shortest unique prefixes
22+type ShortIDMap struct {
23+ // fullToShort maps full ID → shortest unique prefix
24+ fullToShort map[string]string
25+
26+ // shortToFull maps shortest unique prefix → full ID
27+ shortToFull map[string]string
28+
29+ // allFullIDs holds all full IDs for prefix matching
30+ allFullIDs []string
31+}
32+
33+// ShortIDGenerator creates ShortIDMap from git-bug artifacts (issues and comments)
34+type ShortIDGenerator struct {
35+ repoPath string
36+}
37+
38+// NewShortIDGenerator creates a generator for the given repo path
39+func NewShortIDGenerator(repoPath string) *ShortIDGenerator {
40+ return &ShortIDGenerator{
41+ repoPath: repoPath,
42+ }
43+}
44+
45+// diffPosition returns the first position where two strings differ
46+// Returns the length of the shorter string if one is a prefix of the other
47+func diffPosition(a, b string) int {
48+ minLen := len(a)
49+ if len(b) < minLen {
50+ minLen = len(b)
51+ }
52+
53+ for i := 0; i < minLen; i++ {
54+ if a[i] != b[i] {
55+ return i
56+ }
57+ }
58+
59+ return minLen
60+}
61+
62+// findMinPrefix calculates the minimum unique prefix length for an ID at the given index
63+// in a sorted slice of IDs. It compares with both previous and next neighbors.
64+func findMinPrefix(sortedIDs []string, index int) int {
65+ if len(sortedIDs) == 0 {
66+ return 0
67+ }
68+
69+ if len(sortedIDs) == 1 {
70+ return 1
71+ }
72+
73+ id := sortedIDs[index]
74+ maxDiff := 0
75+
76+ // Compare with previous neighbor
77+ if index > 0 {
78+ prevDiff := diffPosition(id, sortedIDs[index-1])
79+ if prevDiff > maxDiff {
80+ maxDiff = prevDiff
81+ }
82+ }
83+
84+ // Compare with next neighbor
85+ if index < len(sortedIDs)-1 {
86+ nextDiff := diffPosition(id, sortedIDs[index+1])
87+ if nextDiff > maxDiff {
88+ maxDiff = nextDiff
89+ }
90+ }
91+
92+ // Minimum unique prefix length is maxDiff + 1
93+ // But never exceed the full ID length
94+ minLen := maxDiff + 1
95+ if minLen > len(id) {
96+ minLen = len(id)
97+ }
98+
99+ return minLen
100+}
101+
102+// Generate builds a ShortIDMap from all artifacts in the repo.
103+// This queries git-bug for all issues and their comments.
104+// Issue IDs are 64-char SHA256 hashes, comment IDs are 64-char CombinedIds
105+// (interleaved BugID + OperationID). Both are unique across the repo.
106+func (g *ShortIDGenerator) Generate() (*ShortIDMap, error) {
107+ repo, err := repository.OpenGoGitRepo(g.repoPath, "", nil)
108+ if err != nil {
109+ return nil, fmt.Errorf("failed to open repository: %w", err)
110+ }
111+
112+ var allIDs []string
113+
114+ // Collect all issue IDs and comment IDs
115+ for streamedBug := range bug.ReadAll(repo) {
116+ if streamedBug.Err != nil {
117+ continue // Skip errors, process what we can
118+ }
119+
120+ b := streamedBug.Entity
121+
122+ // Add issue ID
123+ issueID := b.Id().String()
124+ allIDs = append(allIDs, issueID)
125+
126+ // Add comment IDs
127+ // Comment CombinedId is an interleaved ID (BugID + OperationID)
128+ // It's unique across the repo because Bug IDs are unique
129+ snap := b.Compile()
130+ for _, comment := range snap.Comments {
131+ commentID := comment.CombinedId().String()
132+ allIDs = append(allIDs, commentID)
133+ }
134+ }
135+
136+ if len(allIDs) == 0 {
137+ return &ShortIDMap{
138+ fullToShort: make(map[string]string),
139+ shortToFull: make(map[string]string),
140+ allFullIDs: []string{},
141+ }, nil
142+ }
143+
144+ // Sort IDs to enable neighbor comparison
145+ sort.Strings(allIDs)
146+
147+ // Build mappings
148+ fullToShort := make(map[string]string)
149+ shortToFull := make(map[string]string)
150+
151+ for i, fullID := range allIDs {
152+ prefixLen := findMinPrefix(allIDs, i)
153+ shortID := fullID[:prefixLen]
154+
155+ fullToShort[fullID] = shortID
156+ shortToFull[shortID] = fullID
157+ }
158+
159+ return &ShortIDMap{
160+ fullToShort: fullToShort,
161+ shortToFull: shortToFull,
162+ allFullIDs: allIDs,
163+ }, nil
164+}
165+
166+// GetShortID returns the shortest unique prefix for a full ID.
167+// Returns ErrIDNotFound if the full ID is not in the map.
168+func (m *ShortIDMap) GetShortID(fullID string) (string, error) {
169+ if m == nil {
170+ return "", ErrIDNotFound
171+ }
172+
173+ shortID, ok := m.fullToShort[fullID]
174+ if !ok {
175+ return "", fmt.Errorf("%w: %s", ErrIDNotFound, fullID)
176+ }
177+
178+ return shortID, nil
179+}
180+
181+// GetFullID returns the full ID for a short ID prefix.
182+// The prefix can be the exact short ID or a longer prefix of the full ID.
183+// Returns ErrPrefixNotFound if no ID matches, ErrAmbiguousPrefix if multiple match.
184+func (m *ShortIDMap) GetFullID(shortID string) (string, error) {
185+ if m == nil {
186+ return "", ErrPrefixNotFound
187+ }
188+
189+ // First, check if it's an exact match for a short ID
190+ if fullID, ok := m.shortToFull[shortID]; ok {
191+ return fullID, nil
192+ }
193+
194+ // Otherwise, search for all IDs that start with this prefix
195+ var matches []string
196+ for _, fullID := range m.allFullIDs {
197+ if strings.HasPrefix(fullID, shortID) {
198+ matches = append(matches, fullID)
199+ }
200+ }
201+
202+ switch len(matches) {
203+ case 0:
204+ return "", fmt.Errorf("%w: %s", ErrPrefixNotFound, shortID)
205+ case 1:
206+ return matches[0], nil
207+ default:
208+ return "", fmt.Errorf("%w: prefix %q matches %v", ErrAmbiguousPrefix, shortID, matches)
209+ }
210+}
+45,
-0
1@@ -0,0 +1,45 @@
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+}
+46,
-0
1@@ -0,0 +1,46 @@
2+//go:build integration
3+// +build integration
4+
5+package pgit
6+
7+import (
8+ "testing"
9+)
10+
11+// TestGenerate_Integration tests the Generate method with a real git-bug repo.
12+// This test requires a git repository with git-bug data at the specified path.
13+// Run with: go test -tags=integration -v ./...
14+func TestGenerate_Integration(t *testing.T) {
15+ // This is a placeholder - in practice, you'd need to set up a test repo
16+ // or use an environment variable to specify the repo path
17+ repoPath := "."
18+
19+ gen := NewShortIDGenerator(repoPath)
20+ m, err := gen.Generate()
21+ if err != nil {
22+ // It's okay if there's no git-bug data, just skip
23+ t.Skipf("Skipping integration test - no git-bug data: %v", err)
24+ }
25+
26+ // Verify we can get short IDs for all full IDs
27+ for fullID, shortID := range m.fullToShort {
28+ gotShortID, err := m.GetShortID(fullID)
29+ if err != nil {
30+ t.Errorf("GetShortID(%q) failed: %v", fullID, err)
31+ continue
32+ }
33+ if gotShortID != shortID {
34+ t.Errorf("GetShortID(%q) = %q, want %q", fullID, gotShortID, shortID)
35+ }
36+
37+ // Verify we can reverse it
38+ gotFullID, err := m.GetFullID(shortID)
39+ if err != nil {
40+ t.Errorf("GetFullID(%q) failed: %v", shortID, err)
41+ continue
42+ }
43+ if gotFullID != fullID {
44+ t.Errorf("GetFullID(%q) = %q, want %q", shortID, gotFullID, fullID)
45+ }
46+ }
47+}
+254,
-0
1@@ -0,0 +1,254 @@
2+package pgit
3+
4+import (
5+ "errors"
6+ "sort"
7+ "testing"
8+)
9+
10+func TestDiffPosition(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ a string
14+ b string
15+ expected int
16+ }{
17+ {"identical strings", "abc", "abc", 3},
18+ {"differs at start", "abc", "xyz", 0},
19+ {"differs at position 1", "abc", "axc", 1},
20+ {"differs at position 2", "abc", "abx", 2},
21+ {"prefix of other", "abc", "abcd", 3},
22+ {"empty strings", "", "", 0},
23+ {"one empty", "abc", "", 0},
24+ {"hex IDs", "87cghty", "87dfty4", 2},
25+ }
26+
27+ for _, tt := range tests {
28+ t.Run(tt.name, func(t *testing.T) {
29+ result := diffPosition(tt.a, tt.b)
30+ if result != tt.expected {
31+ t.Errorf("diffPosition(%q, %q) = %d, want %d", tt.a, tt.b, result, tt.expected)
32+ }
33+ })
34+ }
35+}
36+
37+func TestFindMinPrefix(t *testing.T) {
38+ tests := []struct {
39+ name string
40+ ids []string
41+ index int
42+ expected int
43+ }{
44+ {"single ID", []string{"abc"}, 0, 1},
45+ {"two IDs - first", []string{"87c", "87d"}, 0, 3},
46+ {"two IDs - second", []string{"87c", "87d"}, 1, 3},
47+ {"three IDs - first", []string{"87cghty", "87dfty4", "abcdef"}, 0, 3},
48+ {"three IDs - middle", []string{"87cghty", "87dfty4", "abcdef"}, 1, 3},
49+ {"three IDs - last", []string{"87cghty", "87dfty4", "abcdef"}, 2, 1},
50+ {"all unique at start", []string{"a", "b", "c"}, 0, 1},
51+ {"shared long prefix", []string{"abcd123", "abcd456", "abcd789"}, 1, 5},
52+ {"empty slice", []string{}, 0, 0},
53+ }
54+
55+ for _, tt := range tests {
56+ t.Run(tt.name, func(t *testing.T) {
57+ result := findMinPrefix(tt.ids, tt.index)
58+ if result != tt.expected {
59+ t.Errorf("findMinPrefix(%v, %d) = %d, want %d", tt.ids, tt.index, result, tt.expected)
60+ }
61+ })
62+ }
63+}
64+
65+func TestShortIDMap_GetShortID(t *testing.T) {
66+ // Create a test map
67+ m := &ShortIDMap{
68+ fullToShort: map[string]string{
69+ "87cghty": "87c",
70+ "87dfty4": "87d",
71+ "abcdef": "a",
72+ },
73+ shortToFull: map[string]string{
74+ "87c": "87cghty",
75+ "87d": "87dfty4",
76+ "a": "abcdef",
77+ },
78+ allFullIDs: []string{"87cghty", "87dfty4", "abcdef"},
79+ }
80+
81+ tests := []struct {
82+ name string
83+ fullID string
84+ want string
85+ wantErr bool
86+ errIs error
87+ }{
88+ {"existing ID 1", "87cghty", "87c", false, nil},
89+ {"existing ID 2", "87dfty4", "87d", false, nil},
90+ {"existing ID 3", "abcdef", "a", false, nil},
91+ {"non-existent ID", "nonexistent", "", true, ErrIDNotFound},
92+ }
93+
94+ for _, tt := range tests {
95+ t.Run(tt.name, func(t *testing.T) {
96+ got, err := m.GetShortID(tt.fullID)
97+ if (err != nil) != tt.wantErr {
98+ t.Errorf("GetShortID() error = %v, wantErr %v", err, tt.wantErr)
99+ return
100+ }
101+ if tt.wantErr && tt.errIs != nil && !errors.Is(err, tt.errIs) {
102+ t.Errorf("GetShortID() error = %v, want error Is %v", err, tt.errIs)
103+ return
104+ }
105+ if got != tt.want {
106+ t.Errorf("GetShortID() = %v, want %v", got, tt.want)
107+ }
108+ })
109+ }
110+}
111+
112+func TestShortIDMap_GetShortID_NilMap(t *testing.T) {
113+ var m *ShortIDMap
114+ _, err := m.GetShortID("test")
115+ if !errors.Is(err, ErrIDNotFound) {
116+ t.Errorf("expected ErrIDNotFound for nil map, got %v", err)
117+ }
118+}
119+
120+func TestShortIDMap_GetFullID(t *testing.T) {
121+ // Create a test map
122+ m := &ShortIDMap{
123+ fullToShort: map[string]string{
124+ "87cghty": "87c",
125+ "87dfty4": "87d",
126+ "abcdef": "a",
127+ },
128+ shortToFull: map[string]string{
129+ "87c": "87cghty",
130+ "87d": "87dfty4",
131+ "a": "abcdef",
132+ },
133+ allFullIDs: []string{"87cghty", "87dfty4", "abcdef"},
134+ }
135+
136+ tests := []struct {
137+ name string
138+ shortID string
139+ want string
140+ wantErr bool
141+ errIs error
142+ }{
143+ {"exact short ID 1", "87c", "87cghty", false, nil},
144+ {"exact short ID 2", "87d", "87dfty4", false, nil},
145+ {"exact short ID 3", "a", "abcdef", false, nil},
146+ {"longer prefix", "87cg", "87cghty", false, nil},
147+ {"non-existent prefix", "xyz", "", true, ErrPrefixNotFound},
148+ {"ambiguous prefix", "87", "", true, ErrAmbiguousPrefix},
149+ {"empty prefix", "", "", true, ErrAmbiguousPrefix},
150+ }
151+
152+ for _, tt := range tests {
153+ t.Run(tt.name, func(t *testing.T) {
154+ got, err := m.GetFullID(tt.shortID)
155+ if (err != nil) != tt.wantErr {
156+ t.Errorf("GetFullID() error = %v, wantErr %v", err, tt.wantErr)
157+ return
158+ }
159+ if tt.wantErr && tt.errIs != nil && !errors.Is(err, tt.errIs) {
160+ t.Errorf("GetFullID() error = %v, want error Is %v", err, tt.errIs)
161+ return
162+ }
163+ if got != tt.want {
164+ t.Errorf("GetFullID() = %v, want %v", got, tt.want)
165+ }
166+ })
167+ }
168+}
169+
170+func TestShortIDMap_GetFullID_NilMap(t *testing.T) {
171+ var m *ShortIDMap
172+ _, err := m.GetFullID("test")
173+ if !errors.Is(err, ErrPrefixNotFound) {
174+ t.Errorf("expected ErrPrefixNotFound for nil map, got %v", err)
175+ }
176+}
177+
178+func TestShortIDMap_EdgeCases(t *testing.T) {
179+ tests := []struct {
180+ name string
181+ ids []string
182+ expectedMap map[string]string // fullID -> expected shortID
183+ }{
184+ {
185+ name: "empty repo",
186+ ids: []string{},
187+ expectedMap: map[string]string{},
188+ },
189+ {
190+ name: "single ID",
191+ ids: []string{"abc123"},
192+ expectedMap: map[string]string{
193+ "abc123": "a",
194+ },
195+ },
196+ {
197+ name: "two IDs differ at start",
198+ ids: []string{"abc", "xyz"},
199+ expectedMap: map[string]string{
200+ "abc": "a",
201+ "xyz": "x",
202+ },
203+ },
204+ {
205+ name: "shared long prefix",
206+ ids: []string{"abcd123", "abcd456", "abcd789"},
207+ expectedMap: map[string]string{
208+ "abcd123": "abcd1",
209+ "abcd456": "abcd4",
210+ "abcd789": "abcd7",
211+ },
212+ },
213+ {
214+ name: "sequential hex IDs",
215+ ids: []string{"87cghty", "87dfty4", "abcdef"},
216+ expectedMap: map[string]string{
217+ "87cghty": "87c",
218+ "87dfty4": "87d",
219+ "abcdef": "a",
220+ },
221+ },
222+ }
223+
224+ for _, tt := range tests {
225+ t.Run(tt.name, func(t *testing.T) {
226+ // Sort the IDs like Generate does
227+ sort.Strings(tt.ids)
228+
229+ // Build the map
230+ fullToShort := make(map[string]string)
231+ for i, fullID := range tt.ids {
232+ prefixLen := findMinPrefix(tt.ids, i)
233+ shortID := fullID[:prefixLen]
234+ fullToShort[fullID] = shortID
235+ }
236+
237+ // Verify
238+ for fullID, expectedShort := range tt.expectedMap {
239+ if got := fullToShort[fullID]; got != expectedShort {
240+ t.Errorf("fullID %q: got shortID %q, want %q", fullID, got, expectedShort)
241+ }
242+ }
243+ })
244+ }
245+}
246+
247+func TestNewShortIDGenerator(t *testing.T) {
248+ gen := NewShortIDGenerator("/path/to/repo")
249+ if gen == nil {
250+ t.Fatal("NewShortIDGenerator returned nil")
251+ }
252+ if gen.repoPath != "/path/to/repo" {
253+ t.Errorf("repoPath = %q, want %q", gen.repoPath, "/path/to/repo")
254+ }
255+}