shortid.go
1package main
2
3import (
4 "errors"
5 "fmt"
6 "sort"
7 "strings"
8
9 "github.com/git-bug/git-bug/entities/bug"
10 "github.com/git-bug/git-bug/repository"
11)
12
13// Errors returned by ShortIDMap operations
14var (
15 ErrIDNotFound = errors.New("full ID not found")
16 ErrAmbiguousPrefix = errors.New("short ID prefix is ambiguous")
17 ErrPrefixNotFound = errors.New("short ID prefix not found")
18)
19
20// ShortIDMap holds the mapping between full IDs and their shortest unique prefixes
21type ShortIDMap struct {
22 // fullToShort maps full ID → shortest unique prefix
23 fullToShort map[string]string
24
25 // shortToFull maps shortest unique prefix → full ID
26 shortToFull map[string]string
27
28 // allFullIDs holds all full IDs for prefix matching
29 allFullIDs []string
30}
31
32// ShortIDGenerator creates ShortIDMap from git-bug artifacts (issues and comments)
33type ShortIDGenerator struct {
34 repoPath string
35}
36
37// NewShortIDGenerator creates a generator for the given repo path
38func NewShortIDGenerator(repoPath string) *ShortIDGenerator {
39 return &ShortIDGenerator{
40 repoPath: repoPath,
41 }
42}
43
44// diffPosition returns the first position where two strings differ
45// Returns the length of the shorter string if one is a prefix of the other
46func diffPosition(a, b string) int {
47 minLen := len(a)
48 if len(b) < minLen {
49 minLen = len(b)
50 }
51
52 for i := 0; i < minLen; i++ {
53 if a[i] != b[i] {
54 return i
55 }
56 }
57
58 return minLen
59}
60
61// findMinPrefix calculates the minimum unique prefix length for an ID at the given index
62// in a sorted slice of IDs. It compares with both previous and next neighbors.
63func findMinPrefix(sortedIDs []string, index int) int {
64 if len(sortedIDs) == 0 {
65 return 0
66 }
67
68 if len(sortedIDs) == 1 {
69 return 1
70 }
71
72 id := sortedIDs[index]
73 maxDiff := 0
74
75 // Compare with previous neighbor
76 if index > 0 {
77 prevDiff := diffPosition(id, sortedIDs[index-1])
78 if prevDiff > maxDiff {
79 maxDiff = prevDiff
80 }
81 }
82
83 // Compare with next neighbor
84 if index < len(sortedIDs)-1 {
85 nextDiff := diffPosition(id, sortedIDs[index+1])
86 if nextDiff > maxDiff {
87 maxDiff = nextDiff
88 }
89 }
90
91 // Minimum unique prefix length is maxDiff + 1
92 // But never exceed the full ID length
93 minLen := maxDiff + 1
94 if minLen > len(id) {
95 minLen = len(id)
96 }
97
98 return minLen
99}
100
101// Generate builds a ShortIDMap from all artifacts in the repo.
102// This queries git-bug for all issues and their comments.
103// Issue IDs are 64-char SHA256 hashes, comment IDs are 64-char CombinedIds
104// (interleaved BugID + OperationID). Both are unique across the repo.
105func (g *ShortIDGenerator) Generate() (*ShortIDMap, error) {
106 repo, err := repository.OpenGoGitRepo(g.repoPath, "", nil)
107 if err != nil {
108 return nil, fmt.Errorf("failed to open repository: %w", err)
109 }
110
111 var allIDs []string
112
113 // Collect all issue IDs and comment IDs
114 for streamedBug := range bug.ReadAll(repo) {
115 if streamedBug.Err != nil {
116 continue // Skip errors, process what we can
117 }
118
119 b := streamedBug.Entity
120
121 // Add issue ID
122 issueID := b.Id().String()
123 allIDs = append(allIDs, issueID)
124
125 // Add comment IDs
126 // Comment CombinedId is an interleaved ID (BugID + OperationID)
127 // It's unique across the repo because Bug IDs are unique
128 snap := b.Compile()
129 for _, comment := range snap.Comments {
130 commentID := comment.CombinedId().String()
131 allIDs = append(allIDs, commentID)
132 }
133 }
134
135 if len(allIDs) == 0 {
136 return &ShortIDMap{
137 fullToShort: make(map[string]string),
138 shortToFull: make(map[string]string),
139 allFullIDs: []string{},
140 }, nil
141 }
142
143 // Sort IDs to enable neighbor comparison
144 sort.Strings(allIDs)
145
146 // Build mappings
147 fullToShort := make(map[string]string)
148 shortToFull := make(map[string]string)
149
150 for i, fullID := range allIDs {
151 prefixLen := findMinPrefix(allIDs, i)
152 shortID := fullID[:prefixLen]
153
154 fullToShort[fullID] = shortID
155 shortToFull[shortID] = fullID
156 }
157
158 return &ShortIDMap{
159 fullToShort: fullToShort,
160 shortToFull: shortToFull,
161 allFullIDs: allIDs,
162 }, nil
163}
164
165// GetShortID returns the shortest unique prefix for a full ID.
166// Returns ErrIDNotFound if the full ID is not in the map.
167func (m *ShortIDMap) GetShortID(fullID string) (string, error) {
168 if m == nil {
169 return "", ErrIDNotFound
170 }
171
172 shortID, ok := m.fullToShort[fullID]
173 if !ok {
174 return "", fmt.Errorf("%w: %s", ErrIDNotFound, fullID)
175 }
176
177 return shortID, nil
178}
179
180// GetFullID returns the full ID for a short ID prefix.
181// The prefix can be the exact short ID or a longer prefix of the full ID.
182// Returns ErrPrefixNotFound if no ID matches, ErrAmbiguousPrefix if multiple match.
183func (m *ShortIDMap) GetFullID(shortID string) (string, error) {
184 if m == nil {
185 return "", ErrPrefixNotFound
186 }
187
188 // First, check if it's an exact match for a short ID
189 if fullID, ok := m.shortToFull[shortID]; ok {
190 return fullID, nil
191 }
192
193 // Otherwise, search for all IDs that start with this prefix
194 var matches []string
195 for _, fullID := range m.allFullIDs {
196 if strings.HasPrefix(fullID, shortID) {
197 matches = append(matches, fullID)
198 }
199 }
200
201 switch len(matches) {
202 case 0:
203 return "", fmt.Errorf("%w: %s", ErrPrefixNotFound, shortID)
204 case 1:
205 return matches[0], nil
206 default:
207 return "", fmt.Errorf("%w: prefix %q matches %v", ErrAmbiguousPrefix, shortID, matches)
208 }
209}