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}