Migrate to Git CLI

64886f479dbe875e8df76d496e67b9162e8ae41b
This is to support SHA256 repositories.
Alexis Sellier committed ago 1 parent ae82ac81
git.go +46 -586
1 1
package main
2 2
3 3
import (
4 -
	"fmt"
5 -
	"io"
6 4
	"sort"
7 5
	"strings"
8 6
	"time"
9 -
	"unicode/utf8"
10 -
11 -
	git "github.com/go-git/go-git/v5"
12 -
	"github.com/go-git/go-git/v5/plumbing"
13 -
	"github.com/go-git/go-git/v5/plumbing/object"
14 7
)
15 8
16 9
type RepoInfo struct {
17 10
	Name        string
18 11
	Path        string
12 +
	GitDir      string
19 13
	Description string
20 14
	Owner       string
21 15
	LastUpdated time.Time
22 -
	Repo        *git.Repository
16 +
	Git         *gitCLIBackend
23 17
}
24 18
25 19
type CommitInfo struct {
26 20
	Hash        string
27 21
	ShortHash   string
58 52
	Date      time.Time
59 53
	IsTag     bool
60 54
}
61 55
62 56
type DiffFile struct {
63 -
	OldName string
64 -
	NewName string
65 -
	Status  string
66 -
	Hunks   []DiffHunk
57 +
	OldName  string
58 +
	NewName  string
59 +
	Status   string
60 +
	Hunks    []DiffHunk
67 61
	IsBinary bool
68 -
	Stats   DiffStats
62 +
	Stats    DiffStats
69 63
}
70 64
71 65
type DiffStats struct {
72 66
	Added   int
73 67
	Deleted int
83 77
	Content string
84 78
	OldNum  int
85 79
	NewNum  int
86 80
}
87 81
88 -
const maxBlobDisplaySize = 1 << 20 // 1MB
89 -
const diffContextLines = 5
90 -
91 -
func commitToInfo(c *object.Commit) CommitInfo {
92 -
	subject, body, _ := strings.Cut(c.Message, "\n")
93 -
	body = strings.TrimSpace(body)
94 -
	parents := make([]string, len(c.ParentHashes))
95 -
	for i, p := range c.ParentHashes {
96 -
		parents[i] = p.String()
97 -
	}
98 -
	return CommitInfo{
99 -
		Hash:        c.Hash.String(),
100 -
		ShortHash:   c.Hash.String()[:8],
101 -
		Subject:     subject,
102 -
		Body:        body,
103 -
		Author:      c.Author.Name,
104 -
		AuthorEmail: c.Author.Email,
105 -
		AuthorDate:  c.Author.When,
106 -
		Parents:     parents,
107 -
	}
108 -
}
109 -
110 -
func resolveRef(repo *git.Repository, refStr string) (*plumbing.Hash, error) {
111 -
	if refStr == "" {
112 -
		head, err := repo.Head()
113 -
		if err != nil {
114 -
			return nil, err
115 -
		}
116 -
		h := head.Hash()
117 -
		return &h, nil
118 -
	}
119 -
	h, err := repo.ResolveRevision(plumbing.Revision(refStr))
120 -
	if err != nil {
121 -
		return nil, err
122 -
	}
123 -
	return h, nil
124 -
}
125 -
126 -
func resolveRefAndPath(repo *git.Repository, segments []string) (hash *plumbing.Hash, ref string, path string, err error) {
127 -
	// Try progressively longer prefixes as ref names
128 -
	for i := 1; i <= len(segments); i++ {
129 -
		candidate := strings.Join(segments[:i], "/")
130 -
		h, err := resolveRef(repo, candidate)
131 -
		if err == nil {
132 -
			rest := ""
133 -
			if i < len(segments) {
134 -
				rest = strings.Join(segments[i:], "/")
135 -
			}
136 -
			return h, candidate, rest, nil
137 -
		}
138 -
	}
139 -
	return nil, "", "", fmt.Errorf("cannot resolve ref from path segments: %s", strings.Join(segments, "/"))
140 -
}
141 -
142 -
func getDefaultBranch(repo *git.Repository) string {
143 -
	head, err := repo.Head()
144 -
	if err != nil {
145 -
		return "main"
146 -
	}
147 -
	name := head.Name()
148 -
	if name.IsBranch() {
149 -
		return name.Short()
150 -
	}
151 -
	return "main"
152 -
}
153 -
154 -
func getLog(repo *git.Repository, hash plumbing.Hash, page, perPage int) ([]CommitInfo, bool, error) {
155 -
	iter, err := repo.Log(&git.LogOptions{From: hash})
156 -
	if err != nil {
157 -
		return nil, false, err
158 -
	}
159 -
	defer iter.Close()
160 -
161 -
	skip := page * perPage
162 -
	for i := 0; i < skip; i++ {
163 -
		if _, err := iter.Next(); err != nil {
164 -
			return nil, false, nil
165 -
		}
166 -
	}
167 -
168 -
	var commits []CommitInfo
169 -
	for i := 0; i < perPage+1; i++ {
170 -
		c, err := iter.Next()
171 -
		if err == io.EOF {
172 -
			break
173 -
		}
174 -
		if err != nil {
175 -
			return nil, false, err
176 -
		}
177 -
		if i == perPage {
178 -
			return commits, true, nil
179 -
		}
180 -
		commits = append(commits, commitToInfo(c))
181 -
	}
182 -
	return commits, false, nil
82 +
type TreeNode struct {
83 +
	Name     string
84 +
	IsDir    bool
85 +
	Size     int64
86 +
	Path     string
87 +
	Depth    int
88 +
	IsOpen   bool
89 +
	IsActive bool
183 90
}
184 91
185 -
func getTree(repo *git.Repository, hash plumbing.Hash, path string) ([]TreeEntryInfo, error) {
186 -
	commit, err := repo.CommitObject(hash)
187 -
	if err != nil {
188 -
		return nil, err
189 -
	}
190 -
	tree, err := commit.Tree()
191 -
	if err != nil {
192 -
		return nil, err
193 -
	}
194 -
195 -
	if path != "" {
196 -
		tree, err = tree.Tree(path)
197 -
		if err != nil {
198 -
			return nil, err
199 -
		}
200 -
	}
201 -
202 -
	var entries []TreeEntryInfo
203 -
	for _, e := range tree.Entries {
204 -
		isDir := !e.Mode.IsFile()
205 -
		var size int64
206 -
		if !isDir {
207 -
			f, err := tree.TreeEntryFile(&e)
208 -
			if err == nil {
209 -
				size = f.Size
210 -
			}
211 -
		}
212 -
213 -
		mode := fmt.Sprintf("%06o", uint32(e.Mode))
214 -
215 -
		entries = append(entries, TreeEntryInfo{
216 -
			Name:  e.Name,
217 -
			IsDir: isDir,
218 -
			Size:  size,
219 -
			Mode:  mode,
220 -
			Hash:  e.Hash.String()[:8],
221 -
		})
222 -
	}
92 +
const maxBlobDisplaySize = 1 << 20 // 1MB
93 +
const diffContextLines = 5
223 94
95 +
func sortTreeEntries(entries []TreeEntryInfo) {
224 96
	sort.Slice(entries, func(i, j int) bool {
225 97
		if entries[i].IsDir != entries[j].IsDir {
226 98
			return entries[i].IsDir
227 99
		}
228 100
		return entries[i].Name < entries[j].Name
229 101
	})
230 -
231 -
	return entries, nil
232 -
}
233 -
234 -
func getBlob(repo *git.Repository, hash plumbing.Hash, path string) (*BlobInfo, error) {
235 -
	commit, err := repo.CommitObject(hash)
236 -
	if err != nil {
237 -
		return nil, err
238 -
	}
239 -
	tree, err := commit.Tree()
240 -
	if err != nil {
241 -
		return nil, err
242 -
	}
243 -
	f, err := tree.File(path)
244 -
	if err != nil {
245 -
		return nil, err
246 -
	}
247 -
248 -
	info := &BlobInfo{
249 -
		Name: f.Name,
250 -
		Size: f.Size,
251 -
	}
252 -
253 -
	isBin, err := f.IsBinary()
254 -
	if err != nil {
255 -
		return nil, err
256 -
	}
257 -
	info.IsBinary = isBin
258 -
259 -
	if isBin || f.Size > maxBlobDisplaySize {
260 -
		info.IsBinary = true
261 -
		return info, nil
262 -
	}
263 -
264 -
	content, err := f.Contents()
265 -
	if err != nil {
266 -
		return nil, err
267 -
	}
268 -
269 -
	if !utf8.ValidString(content) {
270 -
		info.IsBinary = true
271 -
		return info, nil
272 -
	}
273 -
274 -
	info.Content = content
275 -
	info.Lines = strings.Split(content, "\n")
276 -
	// Remove trailing empty line from final newline
277 -
	if len(info.Lines) > 0 && info.Lines[len(info.Lines)-1] == "" {
278 -
		info.Lines = info.Lines[:len(info.Lines)-1]
279 -
	}
280 -
281 -
	return info, nil
282 -
}
283 -
284 -
func getRawBlob(repo *git.Repository, hash plumbing.Hash, path string) (io.ReadCloser, string, int64, error) {
285 -
	commit, err := repo.CommitObject(hash)
286 -
	if err != nil {
287 -
		return nil, "", 0, err
288 -
	}
289 -
	tree, err := commit.Tree()
290 -
	if err != nil {
291 -
		return nil, "", 0, err
292 -
	}
293 -
	f, err := tree.File(path)
294 -
	if err != nil {
295 -
		return nil, "", 0, err
296 -
	}
297 -
298 -
	reader, err := f.Reader()
299 -
	if err != nil {
300 -
		return nil, "", 0, err
301 -
	}
302 -
303 -
	// Detect content type from name
304 -
	ct := "application/octet-stream"
305 -
	if mtype := mimeFromPath(path); mtype != "" {
306 -
		ct = mtype
307 -
	}
308 -
309 -
	return reader, ct, f.Size, nil
310 -
}
311 -
312 -
var mimeTypes = map[string]string{
313 -
	".txt":  "text/plain; charset=utf-8",
314 -
	".md":   "text/plain; charset=utf-8",
315 -
	".go":   "text/plain; charset=utf-8",
316 -
	".rs":   "text/plain; charset=utf-8",
317 -
	".py":   "text/plain; charset=utf-8",
318 -
	".js":   "text/plain; charset=utf-8",
319 -
	".ts":   "text/plain; charset=utf-8",
320 -
	".c":    "text/plain; charset=utf-8",
321 -
	".h":    "text/plain; charset=utf-8",
322 -
	".css":  "text/css; charset=utf-8",
323 -
	".html": "text/html; charset=utf-8",
324 -
	".json": "application/json",
325 -
	".xml":  "application/xml",
326 -
	".png":  "image/png",
327 -
	".jpg":  "image/jpeg",
328 -
	".jpeg": "image/jpeg",
329 -
	".gif":  "image/gif",
330 -
	".svg":  "image/svg+xml",
331 -
	".pdf":  "application/pdf",
332 -
}
333 -
334 -
func mimeFromPath(path string) string {
335 -
	// Use a small set of common types; let the browser figure out the rest
336 -
	idx := strings.LastIndex(path, ".")
337 -
	if idx < 0 {
338 -
		return ""
339 -
	}
340 -
	ext := strings.ToLower(path[idx:])
341 -
	if t, ok := mimeTypes[ext]; ok {
342 -
		return t
343 -
	}
344 -
	return ""
345 -
}
346 -
347 -
func getCommit(repo *git.Repository, hash plumbing.Hash) (*CommitInfo, error) {
348 -
	c, err := repo.CommitObject(hash)
349 -
	if err != nil {
350 -
		return nil, err
351 -
	}
352 -
	info := commitToInfo(c)
353 -
	return &info, nil
354 -
}
355 -
356 -
func getDiff(repo *git.Repository, hash plumbing.Hash) ([]DiffFile, error) {
357 -
	commit, err := repo.CommitObject(hash)
358 -
	if err != nil {
359 -
		return nil, err
360 -
	}
361 -
362 -
	var parentTree *object.Tree
363 -
	if commit.NumParents() > 0 {
364 -
		parent, err := commit.Parent(0)
365 -
		if err != nil {
366 -
			return nil, err
367 -
		}
368 -
		parentTree, err = parent.Tree()
369 -
		if err != nil {
370 -
			return nil, err
371 -
		}
372 -
	}
373 -
374 -
	commitTree, err := commit.Tree()
375 -
	if err != nil {
376 -
		return nil, err
377 -
	}
378 -
379 -
	if parentTree == nil {
380 -
		// Initial commit: diff against empty tree
381 -
		changes, err := object.DiffTree(nil, commitTree)
382 -
		if err != nil {
383 -
			return nil, err
384 -
		}
385 -
		return changesToDiffFiles(changes)
386 -
	}
387 -
388 -
	changes, err := object.DiffTree(parentTree, commitTree)
389 -
	if err != nil {
390 -
		return nil, err
391 -
	}
392 -
	return changesToDiffFiles(changes)
393 -
}
394 -
395 -
func changesToDiffFiles(changes object.Changes) ([]DiffFile, error) {
396 -
	patch, err := changes.Patch()
397 -
	if err != nil {
398 -
		return nil, err
399 -
	}
400 -
401 -
	var files []DiffFile
402 -
	for _, fp := range patch.FilePatches() {
403 -
		from, to := fp.Files()
404 -
		df := DiffFile{
405 -
			IsBinary: fp.IsBinary(),
406 -
		}
407 -
408 -
		if from != nil {
409 -
			df.OldName = from.Path()
410 -
		}
411 -
		if to != nil {
412 -
			df.NewName = to.Path()
413 -
		}
414 -
415 -
		switch {
416 -
		case from == nil && to != nil:
417 -
			df.Status = "A"
418 -
		case from != nil && to == nil:
419 -
			df.Status = "D"
420 -
		case from != nil && to != nil && from.Path() != to.Path():
421 -
			df.Status = "R"
422 -
		default:
423 -
			df.Status = "M"
424 -
		}
425 -
426 -
		if !fp.IsBinary() {
427 -
			oldNum, newNum := 1, 1
428 -
			for _, chunk := range fp.Chunks() {
429 -
				lines := strings.Split(chunk.Content(), "\n")
430 -
				// Remove trailing empty string from split
431 -
				if len(lines) > 0 && lines[len(lines)-1] == "" {
432 -
					lines = lines[:len(lines)-1]
433 -
				}
434 -
435 -
				for _, line := range lines {
436 -
					if len(df.Hunks) == 0 {
437 -
						df.Hunks = append(df.Hunks, DiffHunk{})
438 -
					}
439 -
					last := &df.Hunks[len(df.Hunks)-1]
440 -
441 -
					switch chunk.Type() {
442 -
					case 0: // Equal
443 -
						last.Lines = append(last.Lines, DiffLine{Type: "context", Content: line, OldNum: oldNum, NewNum: newNum})
444 -
						oldNum++
445 -
						newNum++
446 -
					case 1: // Add
447 -
						df.Stats.Added++
448 -
						last.Lines = append(last.Lines, DiffLine{Type: "add", Content: line, NewNum: newNum})
449 -
						newNum++
450 -
					case 2: // Delete
451 -
						df.Stats.Deleted++
452 -
						last.Lines = append(last.Lines, DiffLine{Type: "del", Content: line, OldNum: oldNum})
453 -
						oldNum++
454 -
					}
455 -
				}
456 -
			}
457 -
		}
458 -
459 -
		// Collapse context lines, keeping only diffContextLines around changes.
460 -
		if len(df.Hunks) == 1 {
461 -
			df.Hunks = collapseContext(df.Hunks[0].Lines)
462 -
		}
463 -
464 -
		files = append(files, df)
465 -
	}
466 -
	return files, nil
467 102
}
468 103
469 104
// collapseContext takes a flat list of diff lines and splits them into
470 105
// hunks, keeping only diffContextLines of context around add/del lines.
471 106
func collapseContext(lines []DiffLine) []DiffHunk {
512 147
	}
513 148
514 149
	return hunks
515 150
}
516 151
517 -
func getBranches(repo *git.Repository) ([]RefInfo, error) {
518 -
	iter, err := repo.Branches()
519 -
	if err != nil {
520 -
		return nil, err
521 -
	}
522 -
	defer iter.Close()
523 -
524 -
	var refs []RefInfo
525 -
	err = iter.ForEach(func(ref *plumbing.Reference) error {
526 -
		c, err := repo.CommitObject(ref.Hash())
527 -
		if err != nil {
528 -
			return nil
529 -
		}
530 -
		refs = append(refs, RefInfo{
531 -
			Name:      ref.Name().Short(),
532 -
			Hash:      ref.Hash().String(),
533 -
			ShortHash: ref.Hash().String()[:8],
534 -
			Subject:   firstLine(c.Message),
535 -
			Author:    c.Author.Name,
536 -
			Date:      c.Author.When,
537 -
		})
538 -
		return nil
539 -
	})
540 -
541 -
	sort.Slice(refs, func(i, j int) bool {
542 -
		return refs[i].Date.After(refs[j].Date)
543 -
	})
544 -
545 -
	return refs, err
546 -
}
547 -
548 -
func getTags(repo *git.Repository) ([]RefInfo, error) {
549 -
	iter, err := repo.Tags()
550 -
	if err != nil {
551 -
		return nil, err
552 -
	}
553 -
	defer iter.Close()
554 -
555 -
	var refs []RefInfo
556 -
	err = iter.ForEach(func(ref *plumbing.Reference) error {
557 -
		hash := ref.Hash()
558 -
559 -
		// Try to peel annotated tag
560 -
		tagObj, err := repo.TagObject(hash)
561 -
		if err == nil {
562 -
			commit, err := tagObj.Commit()
563 -
			if err == nil {
564 -
				refs = append(refs, RefInfo{
565 -
					Name:      ref.Name().Short(),
566 -
					Hash:      commit.Hash.String(),
567 -
					ShortHash: commit.Hash.String()[:8],
568 -
					Subject:   firstLine(commit.Message),
569 -
					Author:    commit.Author.Name,
570 -
					Date:      commit.Author.When,
571 -
					IsTag:     true,
572 -
				})
573 -
				return nil
574 -
			}
575 -
		}
576 -
577 -
		// Lightweight tag - try as commit directly
578 -
		c, err := repo.CommitObject(hash)
579 -
		if err != nil {
580 -
			return nil
581 -
		}
582 -
		refs = append(refs, RefInfo{
583 -
			Name:      ref.Name().Short(),
584 -
			Hash:      hash.String(),
585 -
			ShortHash: hash.String()[:8],
586 -
			Subject:   firstLine(c.Message),
587 -
			Author:    c.Author.Name,
588 -
			Date:      c.Author.When,
589 -
			IsTag:     true,
590 -
		})
591 -
		return nil
592 -
	})
593 -
594 -
	sort.Slice(refs, func(i, j int) bool {
595 -
		return refs[i].Date.After(refs[j].Date)
596 -
	})
597 -
598 -
	return refs, err
599 -
}
600 -
601 -
// isTreePath checks if a path in a commit is a directory
602 -
func isTreePath(repo *git.Repository, hash plumbing.Hash, path string) bool {
603 -
	commit, err := repo.CommitObject(hash)
604 -
	if err != nil {
605 -
		return false
606 -
	}
607 -
	tree, err := commit.Tree()
608 -
	if err != nil {
609 -
		return false
610 -
	}
611 -
	if path == "" {
612 -
		return true
613 -
	}
614 -
	_, err = tree.Tree(path)
615 -
	return err == nil
616 -
}
617 -
618 -
type TreeNode struct {
619 -
	Name     string
620 -
	IsDir    bool
621 -
	Size     int64
622 -
	Path     string
623 -
	Depth    int
624 -
	IsOpen   bool
625 -
	IsActive bool
626 -
}
627 -
628 -
func buildTreeNodes(repo *git.Repository, hash plumbing.Hash, activePath string) []TreeNode {
629 -
	var activeComponents []string
630 -
	if activePath != "" {
631 -
		activeComponents = strings.Split(activePath, "/")
632 -
	}
633 -
	return buildTreeLevel(repo, hash, "", 0, activeComponents)
634 -
}
635 -
636 -
func buildTreeLevel(repo *git.Repository, hash plumbing.Hash, basePath string, depth int, activeComponents []string) []TreeNode {
637 -
	entries, err := getTree(repo, hash, basePath)
638 -
	if err != nil {
639 -
		return nil
640 -
	}
641 -
642 -
	var nodes []TreeNode
643 -
	for _, e := range entries {
644 -
		fullPath := e.Name
645 -
		if basePath != "" {
646 -
			fullPath = basePath + "/" + e.Name
647 -
		}
648 -
649 -
		isOpen := false
650 -
		isActive := false
651 -
652 -
		if len(activeComponents) > 0 && e.Name == activeComponents[0] {
653 -
			if e.IsDir {
654 -
				isOpen = true
655 -
				if len(activeComponents) == 1 {
656 -
					isActive = true
657 -
				}
658 -
			} else if len(activeComponents) == 1 {
659 -
				isActive = true
660 -
			}
661 -
		}
662 -
663 -
		nodes = append(nodes, TreeNode{
664 -
			Name:     e.Name,
665 -
			IsDir:    e.IsDir,
666 -
			Size:     e.Size,
667 -
			Path:     fullPath,
668 -
			Depth:    depth,
669 -
			IsOpen:   isOpen,
670 -
			IsActive: isActive,
671 -
		})
672 -
673 -
		if isOpen {
674 -
			var childActive []string
675 -
			if len(activeComponents) > 1 {
676 -
				childActive = activeComponents[1:]
677 -
			}
678 -
			children := buildTreeLevel(repo, hash, fullPath, depth+1, childActive)
679 -
			nodes = append(nodes, children...)
680 -
		}
681 -
	}
682 -
683 -
	return nodes
152 +
var mimeTypes = map[string]string{
153 +
	".txt":  "text/plain; charset=utf-8",
154 +
	".md":   "text/plain; charset=utf-8",
155 +
	".go":   "text/plain; charset=utf-8",
156 +
	".rs":   "text/plain; charset=utf-8",
157 +
	".py":   "text/plain; charset=utf-8",
158 +
	".js":   "text/plain; charset=utf-8",
159 +
	".ts":   "text/plain; charset=utf-8",
160 +
	".c":    "text/plain; charset=utf-8",
161 +
	".h":    "text/plain; charset=utf-8",
162 +
	".css":  "text/css; charset=utf-8",
163 +
	".html": "text/html; charset=utf-8",
164 +
	".json": "application/json",
165 +
	".xml":  "application/xml",
166 +
	".png":  "image/png",
167 +
	".jpg":  "image/jpeg",
168 +
	".jpeg": "image/jpeg",
169 +
	".gif":  "image/gif",
170 +
	".svg":  "image/svg+xml",
171 +
	".pdf":  "application/pdf",
684 172
}
685 173
686 -
func getReadme(repo *git.Repository, hash plumbing.Hash, dir string) *BlobInfo {
687 -
	commit, err := repo.CommitObject(hash)
688 -
	if err != nil {
689 -
		return nil
690 -
	}
691 -
	root, err := commit.Tree()
692 -
	if err != nil {
693 -
		return nil
694 -
	}
695 -
	tree := root
696 -
	if dir != "" {
697 -
		tree, err = root.Tree(dir)
698 -
		if err != nil {
699 -
			return nil
700 -
		}
701 -
	}
702 -
	for _, entry := range tree.Entries {
703 -
		lower := strings.ToLower(entry.Name)
704 -
		if lower == "readme" || lower == "readme.md" || lower == "readme.txt" {
705 -
			path := entry.Name
706 -
			if dir != "" {
707 -
				path = dir + "/" + entry.Name
708 -
			}
709 -
			blob, err := getBlob(repo, hash, path)
710 -
			if err != nil {
711 -
				return nil
712 -
			}
713 -
			return blob
714 -
		}
174 +
func mimeFromPath(path string) string {
175 +
	idx := strings.LastIndex(path, ".")
176 +
	if idx < 0 {
177 +
		return ""
715 178
	}
716 -
	return nil
717 -
}
718 -
719 -
func firstLine(s string) string {
720 -
	if i := strings.IndexByte(s, '\n'); i >= 0 {
721 -
		return s[:i]
179 +
	ext := strings.ToLower(path[idx:])
180 +
	if t, ok := mimeTypes[ext]; ok {
181 +
		return t
722 182
	}
723 -
	return s
183 +
	return ""
724 184
}
git_cli.go added +331 -0
1 +
package main
2 +
3 +
import (
4 +
	"bufio"
5 +
	"bytes"
6 +
	"fmt"
7 +
	"io"
8 +
	"os/exec"
9 +
	"strconv"
10 +
	"strings"
11 +
	"time"
12 +
	"unicode/utf8"
13 +
)
14 +
15 +
// gitCLIBackend implements git operations by shelling out to the git CLI.
16 +
// Used for SHA256 repos which go-git cannot handle.
17 +
type gitCLIBackend struct {
18 +
	gitDir string
19 +
}
20 +
21 +
func newGitCLIBackend(gitDir string) *gitCLIBackend {
22 +
	return &gitCLIBackend{gitDir: gitDir}
23 +
}
24 +
25 +
func (g *gitCLIBackend) cmd(args ...string) (string, error) {
26 +
	cmd := exec.Command("git", append([]string{"--git-dir", g.gitDir}, args...)...)
27 +
	var stdout, stderr bytes.Buffer
28 +
	cmd.Stdout = &stdout
29 +
	cmd.Stderr = &stderr
30 +
	if err := cmd.Run(); err != nil {
31 +
		return "", fmt.Errorf("git %s: %s: %w", strings.Join(args, " "), stderr.String(), err)
32 +
	}
33 +
	return stdout.String(), nil
34 +
}
35 +
36 +
func (g *gitCLIBackend) pipe(args ...string) (io.ReadCloser, func() error, error) {
37 +
	cmd := exec.Command("git", append([]string{"--git-dir", g.gitDir}, args...)...)
38 +
	stdout, err := cmd.StdoutPipe()
39 +
	if err != nil {
40 +
		return nil, nil, err
41 +
	}
42 +
	if err := cmd.Start(); err != nil {
43 +
		return nil, nil, err
44 +
	}
45 +
	return stdout, cmd.Wait, nil
46 +
}
47 +
48 +
func (g *gitCLIBackend) resolveRef(refStr string) (string, error) {
49 +
	if refStr == "" {
50 +
		refStr = "HEAD"
51 +
	}
52 +
	out, err := g.cmd("rev-parse", refStr)
53 +
	if err != nil {
54 +
		return "", err
55 +
	}
56 +
	return strings.TrimSpace(out), nil
57 +
}
58 +
59 +
func (g *gitCLIBackend) resolveRefAndPath(segments []string) (hash string, ref string, path string, err error) {
60 +
	for i := 1; i <= len(segments); i++ {
61 +
		candidate := strings.Join(segments[:i], "/")
62 +
		h, err := g.resolveRef(candidate)
63 +
		if err == nil {
64 +
			rest := ""
65 +
			if i < len(segments) {
66 +
				rest = strings.Join(segments[i:], "/")
67 +
			}
68 +
			return h, candidate, rest, nil
69 +
		}
70 +
	}
71 +
	return "", "", "", fmt.Errorf("cannot resolve ref from path segments: %s", strings.Join(segments, "/"))
72 +
}
73 +
74 +
func (g *gitCLIBackend) getDefaultBranch() string {
75 +
	out, err := g.cmd("symbolic-ref", "--short", "HEAD")
76 +
	if err != nil {
77 +
		return "main"
78 +
	}
79 +
	branch := strings.TrimSpace(out)
80 +
81 +
	// Verify the branch actually exists; HEAD may point to a non-existent branch
82 +
	// (e.g. HEAD -> master but only a "main" branch exists).
83 +
	if _, err := g.resolveRef(branch); err == nil {
84 +
		return branch
85 +
	}
86 +
87 +
	// Fall back to the first available branch.
88 +
	branches, err := g.getBranches()
89 +
	if err == nil && len(branches) > 0 {
90 +
		return branches[0].Name
91 +
	}
92 +
	return branch
93 +
}
94 +
95 +
func (g *gitCLIBackend) getLog(hash string, page, perPage int) ([]CommitInfo, bool, error) {
96 +
	skip := page * perPage
97 +
	out, err := g.cmd("log", "--format=%H%n%h%n%an%n%ae%n%aI%n%s%n%b%x00",
98 +
		"--skip", strconv.Itoa(skip), "-n", strconv.Itoa(perPage+1), hash)
99 +
	if err != nil {
100 +
		return nil, false, err
101 +
	}
102 +
103 +
	entries := strings.Split(out, "\x00")
104 +
	var commits []CommitInfo
105 +
	for _, entry := range entries {
106 +
		entry = strings.TrimSpace(entry)
107 +
		if entry == "" {
108 +
			continue
109 +
		}
110 +
		lines := strings.SplitN(entry, "\n", 7)
111 +
		if len(lines) < 6 {
112 +
			continue
113 +
		}
114 +
		t, _ := time.Parse(time.RFC3339, strings.TrimSpace(lines[4]))
115 +
		body := ""
116 +
		if len(lines) > 6 {
117 +
			body = strings.TrimSpace(lines[6])
118 +
		}
119 +
		commits = append(commits, CommitInfo{
120 +
			Hash:        strings.TrimSpace(lines[0]),
121 +
			ShortHash:   strings.TrimSpace(lines[1]),
122 +
			Subject:     strings.TrimSpace(lines[5]),
123 +
			Body:        body,
124 +
			Author:      strings.TrimSpace(lines[2]),
125 +
			AuthorEmail: strings.TrimSpace(lines[3]),
126 +
			AuthorDate:  t,
127 +
		})
128 +
	}
129 +
130 +
	if len(commits) > perPage {
131 +
		return commits[:perPage], true, nil
132 +
	}
133 +
	return commits, false, nil
134 +
}
135 +
136 +
func (g *gitCLIBackend) getTree(hash string, path string) ([]TreeEntryInfo, error) {
137 +
	treeish := hash
138 +
	if path != "" {
139 +
		treeish = hash + ":" + path
140 +
	}
141 +
	out, err := g.cmd("ls-tree", "-l", treeish)
142 +
	if err != nil {
143 +
		return nil, err
144 +
	}
145 +
146 +
	var entries []TreeEntryInfo
147 +
	scanner := bufio.NewScanner(strings.NewReader(out))
148 +
	for scanner.Scan() {
149 +
		line := scanner.Text()
150 +
		// format: <mode> SP <type> SP <hash> SP <size>\t<name>
151 +
		tabIdx := strings.IndexByte(line, '\t')
152 +
		if tabIdx < 0 {
153 +
			continue
154 +
		}
155 +
		name := line[tabIdx+1:]
156 +
		meta := strings.Fields(line[:tabIdx])
157 +
		if len(meta) < 4 {
158 +
			continue
159 +
		}
160 +
		mode := meta[0]
161 +
		objType := meta[1]
162 +
		sizeStr := meta[3]
163 +
164 +
		isDir := objType == "tree"
165 +
		var size int64
166 +
		if !isDir && sizeStr != "-" {
167 +
			size, _ = strconv.ParseInt(sizeStr, 10, 64)
168 +
		}
169 +
170 +
		objHash := meta[2]
171 +
		shortHash := objHash
172 +
		if len(shortHash) > 8 {
173 +
			shortHash = shortHash[:8]
174 +
		}
175 +
176 +
		entries = append(entries, TreeEntryInfo{
177 +
			Name:  name,
178 +
			IsDir: isDir,
179 +
			Size:  size,
180 +
			Mode:  mode,
181 +
			Hash:  shortHash,
182 +
		})
183 +
	}
184 +
185 +
	sortTreeEntries(entries)
186 +
	return entries, nil
187 +
}
188 +
189 +
func (g *gitCLIBackend) getBlob(hash string, path string) (*BlobInfo, error) {
190 +
	out, err := g.cmd("cat-file", "-s", hash+":"+path)
191 +
	if err != nil {
192 +
		return nil, err
193 +
	}
194 +
	size, _ := strconv.ParseInt(strings.TrimSpace(out), 10, 64)
195 +
196 +
	name := path
197 +
	if idx := strings.LastIndex(path, "/"); idx >= 0 {
198 +
		name = path[idx+1:]
199 +
	}
200 +
201 +
	info := &BlobInfo{
202 +
		Name: name,
203 +
		Size: size,
204 +
	}
205 +
206 +
	if size > maxBlobDisplaySize {
207 +
		info.IsBinary = true
208 +
		return info, nil
209 +
	}
210 +
211 +
	content, err := g.cmd("cat-file", "blob", hash+":"+path)
212 +
	if err != nil {
213 +
		return nil, err
214 +
	}
215 +
216 +
	// Check binary
217 +
	checkLen := len(content)
218 +
	if checkLen > 8000 {
219 +
		checkLen = 8000
220 +
	}
221 +
	if strings.Contains(content[:checkLen], "\x00") {
222 +
		info.IsBinary = true
223 +
		return info, nil
224 +
	}
225 +
226 +
	if !utf8.ValidString(content) {
227 +
		info.IsBinary = true
228 +
		return info, nil
229 +
	}
230 +
231 +
	info.Content = content
232 +
	info.Lines = strings.Split(content, "\n")
233 +
	if len(info.Lines) > 0 && info.Lines[len(info.Lines)-1] == "" {
234 +
		info.Lines = info.Lines[:len(info.Lines)-1]
235 +
	}
236 +
	return info, nil
237 +
}
238 +
239 +
func (g *gitCLIBackend) getRawBlob(hash string, path string) (io.ReadCloser, string, int64, error) {
240 +
	out, err := g.cmd("cat-file", "-s", hash+":"+path)
241 +
	if err != nil {
242 +
		return nil, "", 0, err
243 +
	}
244 +
	size, _ := strconv.ParseInt(strings.TrimSpace(out), 10, 64)
245 +
246 +
	ct := "application/octet-stream"
247 +
	if mtype := mimeFromPath(path); mtype != "" {
248 +
		ct = mtype
249 +
	}
250 +
251 +
	reader, wait, err := g.pipe("cat-file", "blob", hash+":"+path)
252 +
	if err != nil {
253 +
		return nil, "", 0, err
254 +
	}
255 +
256 +
	return &waitReadCloser{reader: reader, wait: wait}, ct, size, nil
257 +
}
258 +
259 +
type waitReadCloser struct {
260 +
	reader io.ReadCloser
261 +
	wait   func() error
262 +
}
263 +
264 +
func (w *waitReadCloser) Read(p []byte) (int, error) {
265 +
	return w.reader.Read(p)
266 +
}
267 +
268 +
func (w *waitReadCloser) Close() error {
269 +
	err := w.reader.Close()
270 +
	w.wait()
271 +
	return err
272 +
}
273 +
274 +
func (g *gitCLIBackend) getCommit(hash string) (*CommitInfo, error) {
275 +
	out, err := g.cmd("log", "-1", "--format=%H%n%h%n%an%n%ae%n%aI%n%P%n%s%n%b", hash)
276 +
	if err != nil {
277 +
		return nil, err
278 +
	}
279 +
280 +
	lines := strings.SplitN(strings.TrimRight(out, "\n"), "\n", 8)
281 +
	if len(lines) < 7 {
282 +
		return nil, fmt.Errorf("unexpected git log output")
283 +
	}
284 +
285 +
	t, _ := time.Parse(time.RFC3339, strings.TrimSpace(lines[4]))
286 +
287 +
	var parents []string
288 +
	if p := strings.TrimSpace(lines[5]); p != "" {
289 +
		parents = strings.Fields(p)
290 +
	}
291 +
292 +
	body := ""
293 +
	if len(lines) > 7 {
294 +
		body = strings.TrimSpace(lines[7])
295 +
	}
296 +
297 +
	return &CommitInfo{
298 +
		Hash:        strings.TrimSpace(lines[0]),
299 +
		ShortHash:   strings.TrimSpace(lines[1]),
300 +
		Subject:     strings.TrimSpace(lines[6]),
301 +
		Body:        body,
302 +
		Author:      strings.TrimSpace(lines[2]),
303 +
		AuthorEmail: strings.TrimSpace(lines[3]),
304 +
		AuthorDate:  t,
305 +
		Parents:     parents,
306 +
	}, nil
307 +
}
308 +
309 +
func (g *gitCLIBackend) getDiff(hash string) ([]DiffFile, error) {
310 +
	var out string
311 +
	var err error
312 +
313 +
	// Check if this is a root commit (no parents)
314 +
	parentOut, perr := g.cmd("rev-parse", hash+"^")
315 +
	if perr != nil {
316 +
		// Root commit: diff against empty tree
317 +
		out, err = g.cmd("diff-tree", "-p", "--no-commit-id", "--root", hash)
318 +
	} else {
319 +
		parent := strings.TrimSpace(parentOut)
320 +
		out, err = g.cmd("diff", parent, hash)
321 +
	}
322 +
	if err != nil {
323 +
		return nil, err
324 +
	}
325 +
326 +
	return parseDiffOutput(out), nil
327 +
}
328 +
329 +
func parseDiffOutput(output string) []DiffFile {
330 +
	var files []DiffFile
331 +
	parts := strings.Split(output, "
+0 -0
go.mod +0 -24
1 1
module source-browser
2 2
3 3
go 1.25.7
4 -
5 -
require github.com/go-git/go-git/v5 v5.16.5
6 -
7 -
require (
8 -
	dario.cat/mergo v1.0.0 // indirect
9 -
	github.com/Microsoft/go-winio v0.6.2 // indirect
10 -
	github.com/ProtonMail/go-crypto v1.1.6 // indirect
11 -
	github.com/cloudflare/circl v1.6.1 // indirect
12 -
	github.com/cyphar/filepath-securejoin v0.4.1 // indirect
13 -
	github.com/emirpasic/gods v1.18.1 // indirect
14 -
	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
15 -
	github.com/go-git/go-billy/v5 v5.6.2 // indirect
16 -
	github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
17 -
	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
18 -
	github.com/kevinburke/ssh_config v1.2.0 // indirect
19 -
	github.com/pjbgf/sha1cd v0.3.2 // indirect
20 -
	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
21 -
	github.com/skeema/knownhosts v1.3.1 // indirect
22 -
	github.com/xanzy/ssh-agent v0.3.3 // indirect
23 -
	golang.org/x/crypto v0.45.0 // indirect
24 -
	golang.org/x/net v0.47.0 // indirect
25 -
	golang.org/x/sys v0.38.0 // indirect
26 -
	gopkg.in/warnings.v0 v0.1.2 // indirect
27 -
)
go.sum +0 -102
1 -
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
2 -
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
3 -
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
4 -
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
5 -
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
6 -
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
7 -
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
8 -
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
9 -
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
10 -
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
11 -
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
12 -
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
13 -
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
14 -
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
15 -
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
16 -
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
17 -
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
18 -
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
19 -
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
20 -
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
21 -
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
22 -
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
23 -
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
24 -
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
25 -
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
26 -
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
27 -
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
28 -
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
29 -
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
30 -
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
31 -
github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s=
32 -
github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M=
33 -
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
34 -
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
35 -
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
36 -
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
37 -
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
38 -
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
39 -
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
40 -
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
41 -
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
42 -
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
43 -
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
44 -
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
45 -
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
46 -
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
47 -
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
48 -
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
49 -
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
50 -
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
51 -
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
52 -
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
53 -
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
54 -
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
55 -
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
56 -
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
57 -
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
58 -
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
59 -
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
60 -
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
61 -
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
62 -
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
63 -
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
64 -
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
65 -
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
66 -
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
67 -
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
68 -
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
69 -
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
70 -
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
71 -
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
72 -
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
73 -
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
74 -
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
75 -
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
76 -
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
77 -
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
78 -
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
79 -
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
80 -
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
81 -
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
82 -
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
83 -
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
84 -
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
85 -
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
86 -
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
87 -
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
88 -
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
89 -
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
90 -
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
91 -
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
92 -
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
93 -
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
94 -
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
95 -
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
96 -
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
97 -
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
98 -
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
99 -
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
100 -
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
101 -
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
102 -
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
handler.go +29 -24
4 4
	"fmt"
5 5
	"io"
6 6
	"net/http"
7 7
	"strconv"
8 8
	"strings"
9 -
10 -
	"github.com/go-git/go-git/v5/plumbing"
11 9
)
12 10
13 11
type pageData struct {
14 12
	SiteTitle   string
15 13
	BaseURL     string
149 147
func (s *server) handleSummary(w http.ResponseWriter, r *http.Request, repo *RepoInfo) {
150 148
	s.renderHome(w, repo, "", nil, "")
151 149
}
152 150
153 151
func (s *server) renderHome(w http.ResponseWriter, repo *RepoInfo, ref string, blob *BlobInfo, activePath string) {
152 +
	git := repo.Git
153 +
154 154
	if ref == "" {
155 -
		ref = getDefaultBranch(repo.Repo)
155 +
		ref = git.getDefaultBranch()
156 156
	}
157 157
158 -
	head, err := resolveRef(repo.Repo, ref)
158 +
	hash, err := git.resolveRef(ref)
159 159
	if err != nil {
160 160
		pd := s.newPageData(repo, "home", ref)
161 161
		pd.Data = homeData{
162 162
			DefaultRef: ref,
163 163
			IsEmpty:    true,
164 164
		}
165 165
		s.tmpl.render(w, "home", pd)
166 166
		return
167 167
	}
168 168
169 -
	tree := buildTreeNodes(repo.Repo, *head, activePath)
170 -
	lastCommit, _ := getCommit(repo.Repo, *head)
171 -
	branches, _ := getBranches(repo.Repo)
169 +
	tree := git.buildTreeNodes(hash, activePath)
170 +
	lastCommit, _ := git.getCommit(hash)
171 +
	branches, _ := git.getBranches()
172 172
173 173
	// Show README for the active directory (or root if no active path / file selected)
174 174
	readmeDir := ""
175 175
	if activePath != "" && blob == nil {
176 176
		readmeDir = activePath
177 177
	}
178 -
	readme := getReadme(repo.Repo, *head, readmeDir)
178 +
	readme := git.getReadme(hash, readmeDir)
179 179
180 180
	pd := s.newPageData(repo, "home", ref)
181 181
	pd.Data = homeData{
182 182
		DefaultRef: ref,
183 183
		Branches:   branches,
209 209
		if n, err := strconv.Atoi(p); err == nil && n >= 0 {
210 210
			page = n
211 211
		}
212 212
	}
213 213
214 +
	git := repo.Git
214 215
	refStr := rest
215 216
	if refStr == "" {
216 -
		refStr = getDefaultBranch(repo.Repo)
217 +
		refStr = git.getDefaultBranch()
217 218
	}
218 219
219 -
	hash, err := resolveRef(repo.Repo, refStr)
220 +
	hash, err := git.resolveRef(refStr)
220 221
	if err != nil {
221 222
		pd := s.newPageData(repo, "log", refStr)
222 223
		pd.Data = logData{IsEmpty: true, Ref: refStr}
223 224
		s.tmpl.render(w, "log", pd)
224 225
		return
225 226
	}
226 227
227 -
	commits, hasMore, err := getLog(repo.Repo, *hash, page, 50)
228 +
	commits, hasMore, err := git.getLog(hash, page, 50)
228 229
	if err != nil {
229 230
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
230 231
		return
231 232
	}
232 233
233 -
	branches, _ := getBranches(repo.Repo)
234 -
	lastCommit, _ := getCommit(repo.Repo, *hash)
234 +
	branches, _ := git.getBranches()
235 +
	lastCommit, _ := git.getCommit(hash)
235 236
236 237
	pd := s.newPageData(repo, "log", refStr)
237 238
	pd.Data = logData{
238 239
		Branches:   branches,
239 240
		LastCommit: lastCommit,
248 249
	}
249 250
	s.tmpl.render(w, "log", pd)
250 251
}
251 252
252 253
func (s *server) handleTree(w http.ResponseWriter, r *http.Request, repo *RepoInfo, rest string) {
254 +
	git := repo.Git
255 +
253 256
	if rest == "" {
254 -
		rest = getDefaultBranch(repo.Repo)
257 +
		rest = git.getDefaultBranch()
255 258
	}
256 259
257 260
	segments := strings.Split(rest, "/")
258 -
	hash, ref, path, err := resolveRefAndPath(repo.Repo, segments)
261 +
	hash, ref, path, err := git.resolveRefAndPath(segments)
259 262
	if err != nil {
260 263
		s.renderError(w, r, http.StatusNotFound, "Ref not found")
261 264
		return
262 265
	}
263 266
264 267
	// File: show blob in content view
265 -
	if path != "" && !isTreePath(repo.Repo, *hash, path) {
266 -
		blob, err := getBlob(repo.Repo, *hash, path)
268 +
	if path != "" && !git.isTreePath(hash, path) {
269 +
		blob, err := git.getBlob(hash, path)
267 270
		if err != nil {
268 271
			s.renderError(w, r, http.StatusNotFound, "File not found")
269 272
			return
270 273
		}
271 274
		s.renderHome(w, repo, ref, blob, path)
280 283
	type commitData struct {
281 284
		Commit *CommitInfo
282 285
		Files  []DiffFile
283 286
	}
284 287
285 -
	hash := plumbing.NewHash(rest)
286 -
	commit, err := getCommit(repo.Repo, hash)
288 +
	git := repo.Git
289 +
	commit, err := git.getCommit(rest)
287 290
	if err != nil {
288 291
		s.renderError(w, r, http.StatusNotFound, "Commit not found")
289 292
		return
290 293
	}
291 294
292 -
	files, err := getDiff(repo.Repo, hash)
295 +
	files, err := git.getDiff(rest)
293 296
	if err != nil {
294 297
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
295 298
		return
296 299
	}
297 300
308 311
	type refsData struct {
309 312
		Branches []RefInfo
310 313
		Tags     []RefInfo
311 314
	}
312 315
313 -
	branches, _ := getBranches(repo.Repo)
314 -
	tags, _ := getTags(repo.Repo)
316 +
	git := repo.Git
317 +
	branches, _ := git.getBranches()
318 +
	tags, _ := git.getTags()
315 319
316 320
	pd := s.newPageData(repo, "refs", "")
317 321
	pd.Data = refsData{Branches: branches, Tags: tags}
318 322
	s.tmpl.render(w, "refs", pd)
319 323
}
322 326
	if rest == "" {
323 327
		s.renderError(w, r, http.StatusNotFound, "No path specified")
324 328
		return
325 329
	}
326 330
331 +
	git := repo.Git
327 332
	segments := strings.Split(rest, "/")
328 -
	hash, _, path, err := resolveRefAndPath(repo.Repo, segments)
333 +
	hash, _, path, err := git.resolveRefAndPath(segments)
329 334
	if err != nil {
330 335
		s.renderError(w, r, http.StatusNotFound, "Ref not found")
331 336
		return
332 337
	}
333 338
	if path == "" {
334 339
		s.renderError(w, r, http.StatusNotFound, "No file path")
335 340
		return
336 341
	}
337 342
338 -
	reader, contentType, size, err := getRawBlob(repo.Repo, *hash, path)
343 +
	reader, contentType, size, err := git.getRawBlob(hash, path)
339 344
	if err != nil {
340 345
		s.renderError(w, r, http.StatusNotFound, "File not found")
341 346
		return
342 347
	}
343 348
	defer reader.Close()
main.go +11 -21
7 7
	"net/http"
8 8
	"os"
9 9
	"path/filepath"
10 10
	"sort"
11 11
	"strings"
12 -
	"time"
13 -
14 -
	git "github.com/go-git/go-git/v5"
15 12
)
16 13
17 14
type server struct {
18 15
	repos    map[string]*RepoInfo
19 16
	sorted   []string
115 112
		if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
116 113
			continue
117 114
		}
118 115
		dirPath := filepath.Join(root, entry.Name())
119 116
120 -
		var repo *git.Repository
121 117
		var gitDir string
122 118
123 119
		if isBareRepo(dirPath) {
124 120
			gitDir = dirPath
125 121
		} else if nonBare && isWorkTreeRepo(dirPath) {
131 127
		// Repos are private by default; skip unless a 'public' file exists.
132 128
		if _, err := os.Stat(filepath.Join(gitDir, "public")); err != nil {
133 129
			continue
134 130
		}
135 131
136 -
		repo, err = git.PlainOpen(dirPath)
137 -
		if err != nil {
138 -
			log.Printf("skip %s: %v", entry.Name(), err)
139 -
			continue
140 -
		}
141 -
142 132
		name := strings.TrimSuffix(entry.Name(), ".git")
143 133
144 134
		desc := ""
145 135
		descBytes, err := os.ReadFile(filepath.Join(gitDir, "description"))
146 136
		if err == nil {
154 144
		ownerBytes, err := os.ReadFile(filepath.Join(gitDir, "owner"))
155 145
		if err == nil {
156 146
			owner = strings.TrimSpace(string(ownerBytes))
157 147
		}
158 148
159 -
		var lastUpdated time.Time
160 -
		head, err := repo.Head()
161 -
		if err == nil {
162 -
			c, err := repo.CommitObject(head.Hash())
163 -
			if err == nil {
164 -
				lastUpdated = c.Author.When
165 -
			}
166 -
		}
149 +
		git := newGitCLIBackend(gitDir)
167 150
168 -
		repos[name] = &RepoInfo{
151 +
		info := &RepoInfo{
169 152
			Name:        name,
170 153
			Path:        dirPath,
154 +
			GitDir:      gitDir,
171 155
			Description: desc,
172 156
			Owner:       owner,
173 -
			LastUpdated: lastUpdated,
174 -
			Repo:        repo,
157 +
			Git:         git,
175 158
		}
159 +
160 +
		commit, err := git.getCommit("HEAD")
161 +
		if err == nil {
162 +
			info.LastUpdated = commit.AuthorDate
163 +
		}
164 +
165 +
		repos[name] = info
176 166
	}
177 167
178 168
	return repos, nil
179 169
}
180 170
template.go +1 -1
127 127
		if total < blocks {
128 128
			blocks = total
129 129
		}
130 130
		addBlocks := 0
131 131
		if total > 0 {
132 -
			addBlocks = (added * blocks + total - 1) / total
132 +
			addBlocks = (added*blocks + total - 1) / total
133 133
		}
134 134
		if addBlocks > blocks {
135 135
			addBlocks = blocks
136 136
		}
137 137
		delBlocks := blocks - addBlocks