basic bug list function with filters and sorting
8 files changed,  +1069, -1
M .gitignore
+2, -0
1@@ -1,3 +1,5 @@
2+./pgit
3+./bug
4 *.swp
5 *.log
6 public/
A cmd/bug/main.go
+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=
A shortid.go
+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+}
A shortid_example_test.go
+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+}
A shortid_integration_test.go
+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+}
A shortid_test.go
+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+}