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.
81func resolveAndBuildRef(gr *git.Repository, refOrHash string) (*git.Reference, error) {
82 hash, isRef, err := resolveRefOrHash(gr, refOrHash)
83 if err != nil {
84 return nil, err
85 }
86
87 refSpec := refOrHash
88 if isRef {
89 if !strings.HasPrefix(refOrHash, "refs/") {
90 if gr.HasTag(refOrHash) {
91 refSpec = "refs/tags/" + refOrHash
92 } else {
93 refSpec = "refs/heads/" + refOrHash
94 }
95 }
96 }
97
98 return &git.Reference{
99 Reference: &gitmodule.Reference{
100 ID: hash,
101 Refspec: refSpec,
102 },
103 }, nil
104}
105
106// FetchRefsPaginated efficiently fetches a paginated subset of refs sorted by date.
107// It uses git for-each-ref to get pre-sorted refs without loading all objects upfront.
108// refType specifies whether to fetch branches or tags.
109// offset and limit control pagination (set limit to -1 to fetch all remaining refs).
110// defaultBranch specifies which branch to pin to the top (empty string to disable pinning).
111// Returns the paginated ref items and the total count of refs.
112func FetchRefsPaginated(gr *git.Repository, refType RefType, offset, limit int, defaultBranch string) ([]RefItem, int, error) {
113 var refPattern, sortField, format string
114 var checkRefFunc func(*git.Reference) bool
115
116 switch refType {
117 case RefTypeBranch:
118 refPattern = "refs/heads"
119 sortField = "-committerdate"
120 format = "%(refname:short)%09%(objectname)%09%(committerdate:unix)"
121 checkRefFunc = (*git.Reference).IsBranch
122 case RefTypeTag:
123 refPattern = "refs/tags"
124 sortField = "-creatordate"
125 format = "%(refname:short)%09%(*objectname)%09%(objectname)%09%(*authordate:unix)%09%(authordate:unix)%09%(contents:subject)"
126 checkRefFunc = (*git.Reference).IsTag
127 default:
128 return nil, 0, fmt.Errorf("unsupported ref type: %s", refType)
129 }
130
131 args := []string{"for-each-ref", "--sort=" + sortField, "--format=" + format, refPattern}
132
133 cmd := git.NewCommand(args...)
134 output, err := cmd.RunInDir(gr.Path)
135 if err != nil {
136 return nil, 0, err
137 }
138
139 lines := strings.Split(strings.TrimSpace(string(output)), "\n")
140 if len(lines) == 1 && lines[0] == "" {
141 return []RefItem{}, 0, nil
142 }
143
144 // Build reference map once
145 refs, err := gr.References()
146 if err != nil {
147 return nil, 0, err
148 }
149
150 refMap := make(map[string]*git.Reference)
151 for _, r := range refs {
152 if checkRefFunc(r) {
153 refMap[r.Name().Short()] = r
154 }
155 }
156
157 // Separate default branch from others if pinning is requested
158 var defaultBranchLine string
159 var otherLines []string
160
161 if refType == RefTypeBranch && defaultBranch != "" {
162 for _, line := range lines {
163 fields := strings.Split(line, "\t")
164 if len(fields) < 1 {
165 continue
166 }
167 refName := fields[0]
168 if refName == defaultBranch {
169 defaultBranchLine = line
170 } else {
171 otherLines = append(otherLines, line)
172 }
173 }
174 } else {
175 otherLines = lines
176 }
177
178 // Total count includes default branch if present
179 totalCount := len(otherLines)
180 hasDefaultBranch := defaultBranchLine != ""
181 if hasDefaultBranch {
182 totalCount++
183 }
184
185 items := make([]RefItem, 0)
186
187 // Add default branch to page 1 (offset 0)
188 if hasDefaultBranch && offset == 0 {
189 fields := strings.Split(defaultBranchLine, "\t")
190 if len(fields) >= 2 {
191 refName := fields[0]
192 commitID := fields[1]
193
194 if ref := refMap[refName]; ref != nil {
195 item := RefItem{Reference: ref}
196 if commitID != "" {
197 item.Commit, _ = gr.CatFileCommit(commitID)
198 }
199 items = append(items, item)
200 }
201 }
202 }
203
204 // Calculate pagination for non-default branches
205 // On page 1, we have one less slot because default branch takes the first position
206 adjustedOffset := offset
207 adjustedLimit := limit
208
209 if hasDefaultBranch {
210 if offset == 0 {
211 // Page 1: we already added default branch, so fetch limit-1 items
212 adjustedLimit = limit - 1
213 } else {
214 // Page 2+: offset needs to account for default branch being removed from the list
215 adjustedOffset = offset - 1
216 }
217 }
218
219 if adjustedLimit <= 0 {
220 return items, totalCount, nil
221 }
222
223 // Apply pagination to non-default branches
224 start := adjustedOffset
225 if start >= len(otherLines) {
226 return items, totalCount, nil
227 }
228
229 end := len(otherLines)
230 if adjustedLimit > 0 {
231 end = start + adjustedLimit
232 if end > len(otherLines) {
233 end = len(otherLines)
234 }
235 }
236
237 // Process only the paginated subset of non-default branches
238 for _, line := range otherLines[start:end] {
239 fields := strings.Split(line, "\t")
240
241 var refName, commitID string
242
243 if refType == RefTypeTag {
244 if len(fields) < 6 {
245 continue
246 }
247 refName = fields[0]
248 peeledCommitID := fields[1]
249 commitID = fields[2]
250 if peeledCommitID != "" {
251 commitID = peeledCommitID
252 }
253 } else {
254 if len(fields) < 2 {
255 continue
256 }
257 refName = fields[0]
258 commitID = fields[1]
259 }
260
261 ref := refMap[refName]
262 if ref == nil {
263 continue
264 }
265
266 item := RefItem{Reference: ref}
267
268 if refType == RefTypeTag {
269 item.Tag, _ = gr.Tag(refName)
270 }
271
272 if commitID != "" {
273 item.Commit, _ = gr.CatFileCommit(commitID)
274 }
275
276 items = append(items, item)
277 }
278
279 return items, totalCount, nil
280}