1package web
2
3import (
4 "fmt"
5 "strings"
6
7 gitmodule "github.com/aymanbagabas/git-module"
8 "github.com/charmbracelet/soft-serve/git"
9)
10
11// RefType specifies the type of git reference to fetch.
12type RefType string
13
14const (
15 RefTypeBranch RefType = "branch"
16 RefTypeTag RefType = "tag"
17)
18
19// RefItem represents a git reference with its associated metadata.
20type RefItem struct {
21 Reference *git.Reference
22 Tag *git.Tag
23 Commit *git.Commit
24}
25
26// resolveRefOrHash resolves a ref name or commit hash to a commit hash.
27// Returns the hash and whether it was resolved as a ref (true) or commit hash (false).
28func resolveRefOrHash(gr *git.Repository, refOrHash string) (hash string, isRef bool, err error) {
29 if refOrHash == "" {
30 return "", false, fmt.Errorf("empty ref or hash")
31 }
32
33 normalizedRef := refOrHash
34 if !strings.HasPrefix(refOrHash, "refs/") {
35 if gr.HasTag(refOrHash) {
36 normalizedRef = "refs/tags/" + refOrHash
37 } else {
38 normalizedRef = "refs/heads/" + refOrHash
39 }
40 }
41
42 hash, err = gr.ShowRefVerify(normalizedRef)
43 if err == nil {
44 return hash, true, nil
45 }
46
47 if _, err := gr.CatFileCommit(refOrHash); err == nil {
48 return refOrHash, false, nil
49 }
50
51 return "", false, fmt.Errorf("failed to resolve %s as ref or commit", refOrHash)
52}
53
54// parseRefAndPath splits a combined ref+path string into separate ref and path components.
55// It tries progressively longer prefixes as the ref name, checking if each is a valid ref or commit.
56// This allows branch names with forward slashes (e.g., "feature/branch-name") to work correctly.
57// Returns the ref (short name) and path. If no valid ref is found, returns the whole string as ref.
58func parseRefAndPath(gr *git.Repository, refAndPath string) (ref string, path string) {
59 if refAndPath == "" {
60 return "", "."
61 }
62
63 parts := strings.Split(refAndPath, "/")
64
65 for i := len(parts); i > 0; i-- {
66 potentialRef := strings.Join(parts[:i], "/")
67 potentialPath := "."
68 if i < len(parts) {
69 potentialPath = strings.Join(parts[i:], "/")
70 }
71
72 if _, _, err := resolveRefOrHash(gr, potentialRef); err == nil {
73 return potentialRef, potentialPath
74 }
75 }
76
77 return refAndPath, "."
78}
79
80// resolveAndBuildRef resolves a ref or hash and builds a git.Reference object.
81// Returns the reference, whether it's a commit hash (vs named ref), and any error.
82func resolveAndBuildRef(gr *git.Repository, refOrHash string) (*git.Reference, bool, error) {
83 hash, isRef, err := resolveRefOrHash(gr, refOrHash)
84 if err != nil {
85 return nil, false, err
86 }
87
88 refSpec := refOrHash
89 if isRef {
90 if !strings.HasPrefix(refOrHash, "refs/") {
91 if gr.HasTag(refOrHash) {
92 refSpec = "refs/tags/" + refOrHash
93 } else {
94 refSpec = "refs/heads/" + refOrHash
95 }
96 }
97 }
98
99 return &git.Reference{
100 Reference: &gitmodule.Reference{
101 ID: hash,
102 Refspec: refSpec,
103 },
104 }, !isRef, nil
105}
106
107// FetchRefsPaginated efficiently fetches a paginated subset of refs sorted by date.
108// It uses git for-each-ref to get pre-sorted refs without loading all objects upfront.
109// refType specifies whether to fetch branches or tags.
110// offset and limit control pagination (set limit to -1 to fetch all remaining refs).
111// defaultBranch specifies which branch to pin to the top (empty string to disable pinning).
112// Returns the paginated ref items and the total count of refs.
113func FetchRefsPaginated(gr *git.Repository, refType RefType, offset, limit int, defaultBranch string) ([]RefItem, int, error) {
114 var refPattern, sortField, format string
115 var checkRefFunc func(*git.Reference) bool
116
117 switch refType {
118 case RefTypeBranch:
119 refPattern = "refs/heads"
120 sortField = "-committerdate"
121 format = "%(refname:short)%09%(objectname)%09%(committerdate:unix)"
122 checkRefFunc = (*git.Reference).IsBranch
123 case RefTypeTag:
124 refPattern = "refs/tags"
125 sortField = "-creatordate"
126 format = "%(refname:short)%09%(*objectname)%09%(objectname)%09%(*authordate:unix)%09%(authordate:unix)%09%(contents:subject)"
127 checkRefFunc = (*git.Reference).IsTag
128 default:
129 return nil, 0, fmt.Errorf("unsupported ref type: %s", refType)
130 }
131
132 args := []string{"for-each-ref", "--sort=" + sortField, "--format=" + format, refPattern}
133
134 cmd := git.NewCommand(args...)
135 output, err := cmd.RunInDir(gr.Path)
136 if err != nil {
137 return nil, 0, err
138 }
139
140 lines := strings.Split(strings.TrimSpace(string(output)), "\n")
141 if len(lines) == 1 && lines[0] == "" {
142 return []RefItem{}, 0, nil
143 }
144
145 // Build reference map once
146 refs, err := gr.References()
147 if err != nil {
148 return nil, 0, err
149 }
150
151 refMap := make(map[string]*git.Reference)
152 for _, r := range refs {
153 if checkRefFunc(r) {
154 refMap[r.Name().Short()] = r
155 }
156 }
157
158 // Separate default branch from others if pinning is requested
159 var defaultBranchLine string
160 var otherLines []string
161
162 if refType == RefTypeBranch && defaultBranch != "" {
163 for _, line := range lines {
164 fields := strings.Split(line, "\t")
165 if len(fields) < 1 {
166 continue
167 }
168 refName := fields[0]
169 if refName == defaultBranch {
170 defaultBranchLine = line
171 } else {
172 otherLines = append(otherLines, line)
173 }
174 }
175 } else {
176 otherLines = lines
177 }
178
179 // Total count includes default branch if present
180 totalCount := len(otherLines)
181 hasDefaultBranch := defaultBranchLine != ""
182 if hasDefaultBranch {
183 totalCount++
184 }
185
186 items := make([]RefItem, 0)
187
188 // Add default branch to page 1 (offset 0)
189 if hasDefaultBranch && offset == 0 {
190 fields := strings.Split(defaultBranchLine, "\t")
191 if len(fields) >= 2 {
192 refName := fields[0]
193 commitID := fields[1]
194
195 if ref := refMap[refName]; ref != nil {
196 item := RefItem{Reference: ref}
197 if commitID != "" {
198 item.Commit, _ = gr.CatFileCommit(commitID)
199 }
200 items = append(items, item)
201 }
202 }
203 }
204
205 // Calculate pagination for non-default branches
206 // On page 1, we have one less slot because default branch takes the first position
207 adjustedOffset := offset
208 adjustedLimit := limit
209
210 if hasDefaultBranch {
211 if offset == 0 {
212 // Page 1: we already added default branch, so fetch limit-1 items
213 adjustedLimit = limit - 1
214 } else {
215 // Page 2+: offset needs to account for default branch being removed from the list
216 adjustedOffset = offset - 1
217 }
218 }
219
220 if adjustedLimit <= 0 {
221 return items, totalCount, nil
222 }
223
224 // Apply pagination to non-default branches
225 start := adjustedOffset
226 if start >= len(otherLines) {
227 return items, totalCount, nil
228 }
229
230 end := len(otherLines)
231 if adjustedLimit > 0 {
232 end = start + adjustedLimit
233 if end > len(otherLines) {
234 end = len(otherLines)
235 }
236 }
237
238 // Process only the paginated subset of non-default branches
239 for _, line := range otherLines[start:end] {
240 fields := strings.Split(line, "\t")
241
242 var refName, commitID string
243
244 if refType == RefTypeTag {
245 if len(fields) < 6 {
246 continue
247 }
248 refName = fields[0]
249 peeledCommitID := fields[1]
250 commitID = fields[2]
251 if peeledCommitID != "" {
252 commitID = peeledCommitID
253 }
254 } else {
255 if len(fields) < 2 {
256 continue
257 }
258 refName = fields[0]
259 commitID = fields[1]
260 }
261
262 ref := refMap[refName]
263 if ref == nil {
264 continue
265 }
266
267 item := RefItem{Reference: ref}
268
269 if refType == RefTypeTag {
270 item.Tag, _ = gr.Tag(refName)
271 }
272
273 if commitID != "" {
274 item.Commit, _ = gr.CatFileCommit(commitID)
275 }
276
277 items = append(items, item)
278 }
279
280 return items, totalCount, nil
281}