scooter  ·  2026-04-13

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}