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}