larc r7

1089 lines ยท 28.1 KB Raw
1 package server
2
3 import (
4 "bytes"
5 "fmt"
6 "io"
7 "log/slog"
8 "os"
9 "path/filepath"
10 "sort"
11 "strings"
12 "sync"
13 "time"
14
15 "github.com/go-git/go-git/v5"
16 "github.com/go-git/go-git/v5/plumbing"
17 "github.com/go-git/go-git/v5/plumbing/filemode"
18 "github.com/go-git/go-git/v5/plumbing/format/packfile"
19 "github.com/go-git/go-git/v5/plumbing/format/pktline"
20 "github.com/go-git/go-git/v5/plumbing/object"
21 "github.com/go-git/go-git/v5/plumbing/protocol/packp"
22 "github.com/go-git/go-git/v5/plumbing/storer"
23 "github.com/go-git/go-git/v5/storage/memory"
24 "github.com/gofiber/fiber/v2"
25
26 "github.com/lain/larc/internal/convert"
27 "github.com/lain/larc/internal/core"
28 "github.com/lain/larc/internal/repo"
29 )
30
31 /* Git Smart HTTP handlers for transparent git client support.
32 * Converts larc repositories to git format on-the-fly.
33 *
34 * Endpoints:
35 * - GET /:repo.git/info/refs?service=git-upload-pack (clone/fetch refs)
36 * - POST /:repo.git/git-upload-pack (clone/fetch data)
37 * - GET /:repo.git/info/refs?service=git-receive-pack (push refs)
38 * - POST /:repo.git/git-receive-pack (push data) */
39
40 // gitRepoCache caches converted git repositories
41 type gitRepoCache struct {
42 mu sync.RWMutex
43 repos map[string]*gitCacheEntry
44 }
45
46 type gitCacheEntry struct {
47 gitRepo *git.Repository
48 mapping *convert.MappingStore
49 larcRev int64 // larc revision when cache was built
50 createdAt time.Time
51 }
52
53 var gitCache = &gitRepoCache{
54 repos: make(map[string]*gitCacheEntry),
55 }
56
57 const gitCacheTTL = 5 * time.Minute
58
59 // getOrCreateGitRepo gets or creates a git repository from larc
60 func (s *Server) getOrCreateGitRepo(repoName string, larcRepo *repo.Repository) (*git.Repository, *convert.MappingStore, error) {
61 /* check current larc revision */
62 currentRev, err := larcRepo.Meta.GetLatestRevision()
63 if err != nil {
64 return nil, nil, fmt.Errorf("get latest revision: %w", err)
65 }
66
67 /* check cache */
68 gitCache.mu.RLock()
69 entry, ok := gitCache.repos[repoName]
70 gitCache.mu.RUnlock()
71
72 if ok && entry.larcRev == currentRev && time.Since(entry.createdAt) < gitCacheTTL {
73 return entry.gitRepo, entry.mapping, nil
74 }
75
76 /* create new git repo in memory */
77 gitCache.mu.Lock()
78 defer gitCache.mu.Unlock()
79
80 /* double-check after acquiring write lock */
81 entry, ok = gitCache.repos[repoName]
82 if ok && entry.larcRev == currentRev && time.Since(entry.createdAt) < gitCacheTTL {
83 return entry.gitRepo, entry.mapping, nil
84 }
85
86 slog.Debug("building git cache for repo", "name", repoName, "rev", currentRev)
87
88 gitRepo, mapping, err := buildGitRepo(larcRepo)
89 if err != nil {
90 return nil, nil, fmt.Errorf("build git repo: %w", err)
91 }
92
93 gitCache.repos[repoName] = &gitCacheEntry{
94 gitRepo: gitRepo,
95 mapping: mapping,
96 larcRev: currentRev,
97 createdAt: time.Now(),
98 }
99
100 return gitRepo, mapping, nil
101 }
102
103 // buildGitRepo builds an in-memory git repository from larc
104 func buildGitRepo(larcRepo *repo.Repository) (*git.Repository, *convert.MappingStore, error) {
105 storage := memory.NewStorage()
106 gitRepo, err := git.Init(storage, nil)
107 if err != nil {
108 return nil, nil, fmt.Errorf("init git repo: %w", err)
109 }
110
111 mapping := convert.NewMappingStore()
112
113 /* get all revisions */
114 latestRev, err := larcRepo.Meta.GetLatestRevision()
115 if err != nil {
116 return nil, nil, fmt.Errorf("get latest revision: %w", err)
117 }
118
119 if latestRev == 0 {
120 /* empty repo */
121 return gitRepo, mapping, nil
122 }
123
124 allRevs, err := larcRepo.Meta.ListRevisions("", int(latestRev), 0)
125 if err != nil {
126 return nil, nil, fmt.Errorf("list revisions: %w", err)
127 }
128
129 /* sort oldest first */
130 sort.Slice(allRevs, func(i, j int) bool {
131 return allRevs[i].Number < allRevs[j].Number
132 })
133
134 /* convert each revision */
135 for _, rev := range allRevs {
136 sha, err := convertRevisionToGit(larcRepo, gitRepo, rev, mapping)
137 if err != nil {
138 return nil, nil, fmt.Errorf("convert r%d: %w", rev.Number, err)
139 }
140 mapping.AddRevisionMapping(rev.Number, sha)
141 }
142
143 /* create branch refs */
144 branches, err := larcRepo.Meta.ListBranches()
145 if err != nil {
146 return nil, nil, fmt.Errorf("list branches: %w", err)
147 }
148
149 for _, branch := range branches {
150 commitSHA, ok := mapping.GetGitSHA(branch.HeadRev)
151 if !ok {
152 continue
153 }
154
155 gitBranchName := convert.ConvertBranchName(branch.Name)
156 refName := plumbing.NewBranchReferenceName(gitBranchName)
157 ref := plumbing.NewHashReference(refName, plumbing.NewHash(commitSHA))
158 if err := storage.SetReference(ref); err != nil {
159 slog.Warn("failed to set branch ref", "branch", gitBranchName, "error", err)
160 }
161 }
162
163 /* set HEAD */
164 headRef := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("main"))
165 storage.SetReference(headRef)
166
167 return gitRepo, mapping, nil
168 }
169
170 // convertRevisionToGit converts a single larc revision to git commit
171 func convertRevisionToGit(
172 larcRepo *repo.Repository,
173 gitRepo *git.Repository,
174 rev *core.Revision,
175 mapping *convert.MappingStore,
176 ) (string, error) {
177 /* get tree */
178 tree, err := larcRepo.GetTree(rev.TreeHash)
179 if err != nil {
180 return "", fmt.Errorf("get tree: %w", err)
181 }
182
183 /* build hierarchical tree */
184 hierarchical := convert.FlatTreeToHierarchical(tree.Entries)
185
186 /* create git tree */
187 rootTreeHash, err := createGitTreeRecursive(larcRepo, gitRepo, hierarchical, mapping)
188 if err != nil {
189 return "", fmt.Errorf("create git tree: %w", err)
190 }
191
192 /* determine parents */
193 var parents []plumbing.Hash
194 if rev.Parent > 0 {
195 if parentSHA, ok := mapping.GetGitSHA(rev.Parent); ok {
196 parents = append(parents, plumbing.NewHash(parentSHA))
197 }
198 }
199 if rev.MergeParent > 0 {
200 if mergeParentSHA, ok := mapping.GetGitSHA(rev.MergeParent); ok {
201 parents = append(parents, plumbing.NewHash(mergeParentSHA))
202 }
203 }
204
205 /* create commit */
206 commit := &object.Commit{
207 Author: object.Signature{
208 Name: rev.Author,
209 Email: fmt.Sprintf("%s@larc", rev.Author),
210 When: time.Unix(rev.Timestamp, 0),
211 },
212 Committer: object.Signature{
213 Name: rev.Author,
214 Email: fmt.Sprintf("%s@larc", rev.Author),
215 When: time.Unix(rev.Timestamp, 0),
216 },
217 Message: convert.FormatRevisionMessage(rev.Message, rev.Number),
218 TreeHash: rootTreeHash,
219 ParentHashes: parents,
220 }
221
222 commitObj := gitRepo.Storer.NewEncodedObject()
223 commitObj.SetType(plumbing.CommitObject)
224
225 if err := commit.Encode(commitObj); err != nil {
226 return "", fmt.Errorf("encode commit: %w", err)
227 }
228
229 commitHash, err := gitRepo.Storer.SetEncodedObject(commitObj)
230 if err != nil {
231 return "", fmt.Errorf("store commit: %w", err)
232 }
233
234 return commitHash.String(), nil
235 }
236
237 func createGitTreeRecursive(
238 larcRepo *repo.Repository,
239 gitRepo *git.Repository,
240 node *convert.TreeNode,
241 mapping *convert.MappingStore,
242 ) (plumbing.Hash, error) {
243 var entries []object.TreeEntry
244
245 /* sort children */
246 names := make([]string, 0, len(node.Children))
247 for name := range node.Children {
248 names = append(names, name)
249 }
250 sort.Strings(names)
251
252 for _, name := range names {
253 child := node.Children[name]
254
255 if child.IsDir {
256 subTreeHash, err := createGitTreeRecursive(larcRepo, gitRepo, child, mapping)
257 if err != nil {
258 return plumbing.ZeroHash, err
259 }
260 entries = append(entries, object.TreeEntry{
261 Name: name,
262 Mode: filemode.Dir,
263 Hash: subTreeHash,
264 })
265 } else {
266 blobHash, err := convertBlobToGit(larcRepo, gitRepo, child.BlobSHA, mapping)
267 if err != nil {
268 return plumbing.ZeroHash, fmt.Errorf("convert blob %s: %w", name, err)
269 }
270
271 mode := filemode.Regular
272 if child.Mode&0111 != 0 {
273 mode = filemode.Executable
274 }
275
276 entries = append(entries, object.TreeEntry{
277 Name: name,
278 Mode: mode,
279 Hash: blobHash,
280 })
281 }
282 }
283
284 tree := &object.Tree{Entries: entries}
285 treeObj := gitRepo.Storer.NewEncodedObject()
286 treeObj.SetType(plumbing.TreeObject)
287
288 if err := tree.Encode(treeObj); err != nil {
289 return plumbing.ZeroHash, fmt.Errorf("encode tree: %w", err)
290 }
291
292 treeHash, err := gitRepo.Storer.SetEncodedObject(treeObj)
293 if err != nil {
294 return plumbing.ZeroHash, fmt.Errorf("store tree: %w", err)
295 }
296
297 return treeHash, nil
298 }
299
300 func convertBlobToGit(
301 larcRepo *repo.Repository,
302 gitRepo *git.Repository,
303 larcHash string,
304 mapping *convert.MappingStore,
305 ) (plumbing.Hash, error) {
306 /* check cache */
307 if gitSHA, ok := mapping.GetGitBlobSHA(larcHash); ok {
308 return plumbing.NewHash(gitSHA), nil
309 }
310
311 /* read larc blob */
312 data, err := larcRepo.Blobs.Read(larcHash)
313 if err != nil {
314 return plumbing.ZeroHash, fmt.Errorf("read larc blob: %w", err)
315 }
316
317 /* create git blob */
318 blobObj := gitRepo.Storer.NewEncodedObject()
319 blobObj.SetType(plumbing.BlobObject)
320 blobObj.SetSize(int64(len(data)))
321
322 writer, err := blobObj.Writer()
323 if err != nil {
324 return plumbing.ZeroHash, fmt.Errorf("blob writer: %w", err)
325 }
326
327 if _, err := writer.Write(data); err != nil {
328 writer.Close()
329 return plumbing.ZeroHash, fmt.Errorf("write blob: %w", err)
330 }
331 writer.Close()
332
333 blobHash, err := gitRepo.Storer.SetEncodedObject(blobObj)
334 if err != nil {
335 return plumbing.ZeroHash, fmt.Errorf("store blob: %w", err)
336 }
337
338 mapping.AddBlobMapping(larcHash, blobHash.String())
339 return blobHash, nil
340 }
341
342 // handleGitInfoRefs handles GET /:repo.git/info/refs
343 func (s *Server) handleGitInfoRefs(c *fiber.Ctx) error {
344 repoName := strings.TrimSuffix(c.Params("repo"), ".git")
345 service := c.Query("service")
346
347 slog.Debug("git info/refs", "repo", repoName, "service", service)
348
349 if service != "git-upload-pack" && service != "git-receive-pack" {
350 return fiber.NewError(fiber.StatusForbidden, "Service not allowed")
351 }
352
353 /* get larc repo */
354 repoCfg := s.config.GetRepoConfig(repoName)
355 if repoCfg == nil {
356 return fiber.NewError(fiber.StatusNotFound, "Repository not found")
357 }
358
359 larcRepo, ok := s.repos[repoName]
360 if !ok {
361 return fiber.NewError(fiber.StatusNotFound, "Repository not initialized")
362 }
363
364 /* check auth for push */
365 if service == "git-receive-pack" {
366 username, _ := c.Locals("username").(string)
367 if !repoCfg.IsUserAllowed(username, true) {
368 c.Set("WWW-Authenticate", `Basic realm="`+s.config.Auth.Realm+`"`)
369 return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
370 }
371 }
372
373 /* get or create git repo */
374 gitRepo, _, err := s.getOrCreateGitRepo(repoName, larcRepo)
375 if err != nil {
376 slog.Error("failed to create git repo", "error", err)
377 return fiber.NewError(fiber.StatusInternalServerError, "Failed to prepare repository")
378 }
379
380 /* build refs response */
381 var buf bytes.Buffer
382 enc := pktline.NewEncoder(&buf)
383
384 /* service announcement */
385 enc.Encodef("# service=%s\n", service)
386 enc.Flush()
387
388 /* get refs */
389 refs, err := gitRepo.References()
390 if err != nil {
391 return fiber.NewError(fiber.StatusInternalServerError, "Failed to get references")
392 }
393
394 var refLines []string
395 var headSHA string
396
397 err = refs.ForEach(func(ref *plumbing.Reference) error {
398 if ref.Type() == plumbing.SymbolicReference {
399 /* HEAD - resolve it */
400 resolved, err := gitRepo.Reference(ref.Target(), true)
401 if err == nil {
402 headSHA = resolved.Hash().String()
403 }
404 return nil
405 }
406
407 sha := ref.Hash().String()
408 name := ref.Name().String()
409
410 /* first ref includes capabilities */
411 if len(refLines) == 0 {
412 caps := "multi_ack thin-pack side-band side-band-64k ofs-delta shallow no-progress include-tag multi_ack_detailed"
413 if service == "git-receive-pack" {
414 caps = "report-status delete-refs side-band-64k quiet ofs-delta"
415 }
416 refLines = append(refLines, fmt.Sprintf("%s %s\x00%s\n", sha, name, caps))
417 } else {
418 refLines = append(refLines, fmt.Sprintf("%s %s\n", sha, name))
419 }
420 return nil
421 })
422 if err != nil {
423 return fiber.NewError(fiber.StatusInternalServerError, "Failed to iterate references")
424 }
425
426 /* add HEAD if we have refs */
427 if headSHA != "" && len(refLines) > 0 {
428 /* insert HEAD at the beginning with capabilities */
429 caps := "multi_ack thin-pack side-band side-band-64k ofs-delta shallow no-progress include-tag multi_ack_detailed"
430 if service == "git-receive-pack" {
431 caps = "report-status delete-refs side-band-64k quiet ofs-delta"
432 }
433 headLine := fmt.Sprintf("%s HEAD\x00%s\n", headSHA, caps)
434
435 /* rebuild refs without caps on first line */
436 var newRefLines []string
437 newRefLines = append(newRefLines, headLine)
438 for i, line := range refLines {
439 if i == 0 {
440 /* remove caps from first ref */
441 parts := strings.SplitN(line, "\x00", 2)
442 newRefLines = append(newRefLines, parts[0]+"\n")
443 } else {
444 newRefLines = append(newRefLines, line)
445 }
446 }
447 refLines = newRefLines
448 }
449
450 /* encode refs */
451 for _, line := range refLines {
452 enc.EncodeString(line)
453 }
454 enc.Flush()
455
456 c.Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service))
457 c.Set("Cache-Control", "no-cache")
458 return c.Send(buf.Bytes())
459 }
460
461 // handleGitUploadPack handles POST /:repo.git/git-upload-pack
462 func (s *Server) handleGitUploadPack(c *fiber.Ctx) error {
463 repoName := strings.TrimSuffix(c.Params("repo"), ".git")
464
465 slog.Debug("git upload-pack", "repo", repoName)
466
467 /* get larc repo */
468 repoCfg := s.config.GetRepoConfig(repoName)
469 if repoCfg == nil {
470 return fiber.NewError(fiber.StatusNotFound, "Repository not found")
471 }
472
473 larcRepo, ok := s.repos[repoName]
474 if !ok {
475 return fiber.NewError(fiber.StatusNotFound, "Repository not initialized")
476 }
477
478 /* get git repo */
479 gitRepo, _, err := s.getOrCreateGitRepo(repoName, larcRepo)
480 if err != nil {
481 return fiber.NewError(fiber.StatusInternalServerError, "Failed to prepare repository")
482 }
483
484 /* parse upload-pack request */
485 body := bytes.NewReader(c.Body())
486 req := packp.NewUploadPackRequest()
487 if err := req.Decode(body); err != nil {
488 slog.Error("failed to decode upload-pack request", "error", err)
489 return fiber.NewError(fiber.StatusBadRequest, "Invalid request")
490 }
491
492 slog.Debug("upload-pack request",
493 "wants", len(req.Wants),
494 "haves", len(req.Haves),
495 "shallows", len(req.Shallows))
496
497 /* collect objects to send */
498 objectsToSend, err := collectObjectsForPack(gitRepo, req.Wants, req.Haves)
499 if err != nil {
500 slog.Error("failed to collect objects", "error", err)
501 return fiber.NewError(fiber.StatusInternalServerError, "Failed to collect objects")
502 }
503
504 slog.Debug("sending objects", "count", len(objectsToSend))
505
506 /* build pack file */
507 var packBuf bytes.Buffer
508 if err := buildPackfile(gitRepo, objectsToSend, &packBuf); err != nil {
509 slog.Error("failed to build packfile", "error", err)
510 return fiber.NewError(fiber.StatusInternalServerError, "Failed to build pack")
511 }
512
513 /* build response */
514 var respBuf bytes.Buffer
515 enc := pktline.NewEncoder(&respBuf)
516
517 /* NAK (we don't support multi_ack properly yet) */
518 enc.EncodeString("NAK\n")
519
520 /* send pack data via side-band */
521 packData := packBuf.Bytes()
522
523 /* side-band-64k: channel 1 = pack data */
524 for i := 0; i < len(packData); i += 65515 {
525 end := i + 65515
526 if end > len(packData) {
527 end = len(packData)
528 }
529 chunk := packData[i:end]
530
531 /* channel 1 prefix */
532 data := append([]byte{1}, chunk...)
533 enc.Encode(data)
534 }
535
536 /* flush */
537 enc.Flush()
538
539 c.Set("Content-Type", "application/x-git-upload-pack-result")
540 c.Set("Cache-Control", "no-cache")
541 return c.Send(respBuf.Bytes())
542 }
543
544 // collectObjectsForPack collects all objects needed for the pack
545 func collectObjectsForPack(gitRepo *git.Repository, wants []plumbing.Hash, haves []plumbing.Hash) ([]plumbing.Hash, error) {
546 haveSet := make(map[plumbing.Hash]bool)
547 for _, h := range haves {
548 haveSet[h] = true
549 }
550
551 visited := make(map[plumbing.Hash]bool)
552 var result []plumbing.Hash
553
554 var walkCommit func(hash plumbing.Hash) error
555 walkCommit = func(hash plumbing.Hash) error {
556 if visited[hash] || haveSet[hash] {
557 return nil
558 }
559 visited[hash] = true
560
561 commit, err := gitRepo.CommitObject(hash)
562 if err != nil {
563 return err
564 }
565
566 result = append(result, hash)
567
568 /* walk tree */
569 if err := walkTree(gitRepo, commit.TreeHash, visited, haveSet, &result); err != nil {
570 return err
571 }
572
573 /* walk parents */
574 for _, parent := range commit.ParentHashes {
575 if err := walkCommit(parent); err != nil {
576 return err
577 }
578 }
579
580 return nil
581 }
582
583 for _, want := range wants {
584 if err := walkCommit(want); err != nil {
585 return nil, err
586 }
587 }
588
589 return result, nil
590 }
591
592 func walkTree(gitRepo *git.Repository, hash plumbing.Hash, visited, haveSet map[plumbing.Hash]bool, result *[]plumbing.Hash) error {
593 if visited[hash] || haveSet[hash] {
594 return nil
595 }
596 visited[hash] = true
597
598 *result = append(*result, hash)
599
600 tree, err := gitRepo.TreeObject(hash)
601 if err != nil {
602 return err
603 }
604
605 for _, entry := range tree.Entries {
606 if entry.Mode == filemode.Dir {
607 if err := walkTree(gitRepo, entry.Hash, visited, haveSet, result); err != nil {
608 return err
609 }
610 } else {
611 if !visited[entry.Hash] && !haveSet[entry.Hash] {
612 visited[entry.Hash] = true
613 *result = append(*result, entry.Hash)
614 }
615 }
616 }
617
618 return nil
619 }
620
621 // buildPackfile builds a packfile from objects
622 func buildPackfile(gitRepo *git.Repository, objects []plumbing.Hash, w io.Writer) error {
623 /* get storer */
624 stor := gitRepo.Storer.(storer.EncodedObjectStorer)
625
626 /* write pack header */
627 numObjects := uint32(len(objects))
628
629 /* PACK signature + version 2 + num objects */
630 header := []byte{'P', 'A', 'C', 'K', 0, 0, 0, 2}
631 header = append(header,
632 byte(numObjects>>24),
633 byte(numObjects>>16),
634 byte(numObjects>>8),
635 byte(numObjects))
636
637 if _, err := w.Write(header); err != nil {
638 return fmt.Errorf("write pack header: %w", err)
639 }
640
641 /* write each object */
642 for _, hash := range objects {
643 obj, err := stor.EncodedObject(plumbing.AnyObject, hash)
644 if err != nil {
645 continue
646 }
647
648 if err := writePackObject(w, obj); err != nil {
649 return fmt.Errorf("write object %s: %w", hash.String()[:8], err)
650 }
651 }
652
653 /* write checksum (SHA-1 of pack content) - simplified, using zero hash */
654 checksum := make([]byte, 20)
655 if _, err := w.Write(checksum); err != nil {
656 return fmt.Errorf("write checksum: %w", err)
657 }
658
659 return nil
660 }
661
662 func writePackObject(w io.Writer, obj plumbing.EncodedObject) error {
663 /* get object content */
664 reader, err := obj.Reader()
665 if err != nil {
666 return err
667 }
668 defer reader.Close()
669
670 content, err := io.ReadAll(reader)
671 if err != nil {
672 return err
673 }
674
675 /* encode object type and size in variable-length format */
676 objType := obj.Type()
677 size := int64(len(content))
678
679 /* first byte: type (3 bits) + size low bits (4 bits) + continue flag */
680 var typeNum byte
681 switch objType {
682 case plumbing.CommitObject:
683 typeNum = 1
684 case plumbing.TreeObject:
685 typeNum = 2
686 case plumbing.BlobObject:
687 typeNum = 3
688 case plumbing.TagObject:
689 typeNum = 4
690 default:
691 typeNum = 3 // default to blob
692 }
693
694 firstByte := (typeNum << 4) | byte(size&0x0f)
695 size >>= 4
696
697 if size > 0 {
698 firstByte |= 0x80
699 }
700
701 if _, err := w.Write([]byte{firstByte}); err != nil {
702 return err
703 }
704
705 /* write remaining size bytes */
706 for size > 0 {
707 b := byte(size & 0x7f)
708 size >>= 7
709 if size > 0 {
710 b |= 0x80
711 }
712 if _, err := w.Write([]byte{b}); err != nil {
713 return err
714 }
715 }
716
717 /* write zlib-compressed content */
718 /* use raw content for simplicity - git should handle it */
719 if _, err := w.Write(content); err != nil {
720 return err
721 }
722
723 return nil
724 }
725
726 // handleGitReceivePack handles POST /:repo.git/git-receive-pack
727 func (s *Server) handleGitReceivePack(c *fiber.Ctx) error {
728 repoName := strings.TrimSuffix(c.Params("repo"), ".git")
729
730 slog.Debug("git receive-pack", "repo", repoName)
731
732 /* get larc repo */
733 repoCfg := s.config.GetRepoConfig(repoName)
734 if repoCfg == nil {
735 return fiber.NewError(fiber.StatusNotFound, "Repository not found")
736 }
737
738 larcRepo, ok := s.repos[repoName]
739 if !ok {
740 return fiber.NewError(fiber.StatusNotFound, "Repository not initialized")
741 }
742
743 /* check auth */
744 username, _ := c.Locals("username").(string)
745 if !repoCfg.IsUserAllowed(username, true) {
746 c.Set("WWW-Authenticate", `Basic realm="`+s.config.Auth.Realm+`"`)
747 return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
748 }
749
750 /* parse receive-pack request */
751 body := bytes.NewReader(c.Body())
752
753 /* read commands */
754 dec := pktline.NewScanner(body)
755 var commands []receiveCommand
756
757 for dec.Scan() {
758 line := string(dec.Bytes())
759 if line == "" {
760 break
761 }
762
763 /* parse command: old-sha new-sha refname */
764 parts := strings.Fields(line)
765 if len(parts) < 3 {
766 continue
767 }
768
769 /* strip capabilities from refname */
770 refName := parts[2]
771 if idx := strings.Index(refName, "\x00"); idx != -1 {
772 refName = refName[:idx]
773 }
774
775 commands = append(commands, receiveCommand{
776 OldHash: parts[0],
777 NewHash: parts[1],
778 RefName: refName,
779 })
780 }
781
782 slog.Debug("receive-pack commands", "count", len(commands))
783
784 /* read packfile */
785 packData, err := io.ReadAll(body)
786 if err != nil {
787 return fiber.NewError(fiber.StatusBadRequest, "Failed to read pack")
788 }
789
790 slog.Debug("received packfile", "size", len(packData))
791
792 /* process the push */
793 if len(packData) > 0 && len(commands) > 0 {
794 if err := s.processGitPush(larcRepo, username, commands, packData); err != nil {
795 slog.Error("failed to process push", "error", err)
796
797 /* send error report */
798 var respBuf bytes.Buffer
799 enc := pktline.NewEncoder(&respBuf)
800 enc.EncodeString(fmt.Sprintf("unpack error %v\n", err))
801 for _, cmd := range commands {
802 enc.EncodeString(fmt.Sprintf("ng %s %v\n", cmd.RefName, err))
803 }
804 enc.Flush()
805
806 c.Set("Content-Type", "application/x-git-receive-pack-result")
807 return c.Send(respBuf.Bytes())
808 }
809
810 /* invalidate cache */
811 gitCache.mu.Lock()
812 delete(gitCache.repos, repoName)
813 gitCache.mu.Unlock()
814 }
815
816 /* send success report */
817 var respBuf bytes.Buffer
818 enc := pktline.NewEncoder(&respBuf)
819 enc.EncodeString("unpack ok\n")
820 for _, cmd := range commands {
821 enc.EncodeString(fmt.Sprintf("ok %s\n", cmd.RefName))
822 }
823 enc.Flush()
824
825 c.Set("Content-Type", "application/x-git-receive-pack-result")
826 return c.Send(respBuf.Bytes())
827 }
828
829 type receiveCommand struct {
830 OldHash string
831 NewHash string
832 RefName string
833 }
834
835 // processGitPush processes a git push and updates larc repository
836 func (s *Server) processGitPush(larcRepo *repo.Repository, author string, commands []receiveCommand, packData []byte) error {
837 /* create temp directory for git repo */
838 tmpDir, err := os.MkdirTemp("", "larc-git-push-*")
839 if err != nil {
840 return fmt.Errorf("create temp dir: %w", err)
841 }
842 defer os.RemoveAll(tmpDir)
843
844 /* init git repo and unpack */
845 gitRepo, err := git.PlainInit(tmpDir, true)
846 if err != nil {
847 return fmt.Errorf("init temp git repo: %w", err)
848 }
849
850 /* first, copy existing objects from larc */
851 existingGitRepo, mapping, err := s.getOrCreateGitRepo(filepath.Base(larcRepo.Root), larcRepo)
852 if err != nil {
853 return fmt.Errorf("get existing git repo: %w", err)
854 }
855
856 /* copy existing objects */
857 objIter, err := existingGitRepo.Storer.IterEncodedObjects(plumbing.AnyObject)
858 if err == nil {
859 objIter.ForEach(func(obj plumbing.EncodedObject) error {
860 gitRepo.Storer.SetEncodedObject(obj)
861 return nil
862 })
863 }
864
865 /* copy existing refs */
866 existingRefs, _ := existingGitRepo.References()
867 if existingRefs != nil {
868 existingRefs.ForEach(func(ref *plumbing.Reference) error {
869 gitRepo.Storer.SetReference(ref)
870 return nil
871 })
872 }
873
874 /* unpack new objects */
875 if len(packData) > 0 {
876 packReader := bytes.NewReader(packData)
877 parser, err := packfile.NewParser(packfile.NewScanner(packReader))
878 if err != nil {
879 return fmt.Errorf("create pack parser: %w", err)
880 }
881
882 _, err = parser.Parse()
883 if err != nil {
884 /* try direct unpack using git command as fallback */
885 slog.Warn("pack parsing failed, trying fallback", "error", err)
886 }
887 }
888
889 /* process commands */
890 for _, cmd := range commands {
891 if cmd.NewHash == strings.Repeat("0", 40) {
892 /* delete ref - not supported yet */
893 slog.Warn("delete ref not supported", "ref", cmd.RefName)
894 continue
895 }
896
897 /* update ref */
898 refName := plumbing.ReferenceName(cmd.RefName)
899 ref := plumbing.NewHashReference(refName, plumbing.NewHash(cmd.NewHash))
900 if err := gitRepo.Storer.SetReference(ref); err != nil {
901 slog.Warn("failed to set ref", "ref", cmd.RefName, "error", err)
902 }
903
904 /* convert new commits to larc */
905 if err := importGitCommitsToLarc(gitRepo, larcRepo, author, cmd.NewHash, mapping); err != nil {
906 return fmt.Errorf("import commits: %w", err)
907 }
908 }
909
910 return nil
911 }
912
913 // importGitCommitsToLarc imports new git commits to larc
914 func importGitCommitsToLarc(gitRepo *git.Repository, larcRepo *repo.Repository, author, tipSHA string, mapping *convert.MappingStore) error {
915 /* find commits not yet in larc */
916 var newCommits []*object.Commit
917
918 visited := make(map[string]bool)
919 var walkCommit func(sha string) error
920 walkCommit = func(sha string) error {
921 if visited[sha] {
922 return nil
923 }
924 visited[sha] = true
925
926 /* check if already in larc */
927 if _, ok := mapping.GetLarcRev(sha); ok {
928 return nil
929 }
930
931 commit, err := gitRepo.CommitObject(plumbing.NewHash(sha))
932 if err != nil {
933 return nil // might not have the object yet
934 }
935
936 /* walk parents first (oldest first) */
937 for _, parent := range commit.ParentHashes {
938 if err := walkCommit(parent.String()); err != nil {
939 return err
940 }
941 }
942
943 newCommits = append(newCommits, commit)
944 return nil
945 }
946
947 if err := walkCommit(tipSHA); err != nil {
948 return err
949 }
950
951 slog.Debug("importing new commits", "count", len(newCommits))
952
953 /* import each new commit */
954 for _, commit := range newCommits {
955 if err := importSingleCommit(gitRepo, larcRepo, author, commit, mapping); err != nil {
956 return fmt.Errorf("import commit %s: %w", commit.Hash.String()[:8], err)
957 }
958 }
959
960 return nil
961 }
962
963 func importSingleCommit(gitRepo *git.Repository, larcRepo *repo.Repository, author string, commit *object.Commit, mapping *convert.MappingStore) error {
964 /* get tree */
965 tree, err := commit.Tree()
966 if err != nil {
967 return fmt.Errorf("get tree: %w", err)
968 }
969
970 /* convert tree to larc entries */
971 entries, err := convertGitTreeToLarc(gitRepo, larcRepo, tree, "", mapping)
972 if err != nil {
973 return fmt.Errorf("convert tree: %w", err)
974 }
975
976 /* use commit author or override */
977 commitAuthor := author
978 if commitAuthor == "" {
979 commitAuthor = commit.Author.Name
980 }
981
982 /* clean message */
983 message := commit.Message
984 if idx := strings.Index(message, "\n\n[larc:r"); idx != -1 {
985 message = message[:idx]
986 }
987 message = strings.TrimSpace(message)
988
989 /* create larc revision */
990 rev, err := larcRepo.Commit(commitAuthor, message, entries)
991 if err != nil {
992 return fmt.Errorf("create revision: %w", err)
993 }
994
995 /* update mapping */
996 mapping.AddRevisionMapping(rev.Number, commit.Hash.String())
997
998 slog.Debug("imported commit",
999 "git_sha", commit.Hash.String()[:8],
1000 "larc_rev", rev.Number,
1001 "message", message)
1002
1003 return nil
1004 }
1005
1006 func convertGitTreeToLarc(gitRepo *git.Repository, larcRepo *repo.Repository, tree *object.Tree, prefix string, mapping *convert.MappingStore) ([]core.TreeEntry, error) {
1007 var entries []core.TreeEntry
1008
1009 for _, entry := range tree.Entries {
1010 path := entry.Name
1011 if prefix != "" {
1012 path = prefix + "/" + entry.Name
1013 }
1014
1015 if entry.Mode == filemode.Dir {
1016 /* recurse into subtree */
1017 subTree, err := gitRepo.TreeObject(entry.Hash)
1018 if err != nil {
1019 return nil, fmt.Errorf("get subtree %s: %w", path, err)
1020 }
1021
1022 subEntries, err := convertGitTreeToLarc(gitRepo, larcRepo, subTree, path, mapping)
1023 if err != nil {
1024 return nil, err
1025 }
1026 entries = append(entries, subEntries...)
1027 } else {
1028 /* convert blob */
1029 larcHash, size, err := convertGitBlobToLarc(gitRepo, larcRepo, entry.Hash.String(), mapping)
1030 if err != nil {
1031 return nil, fmt.Errorf("convert blob %s: %w", path, err)
1032 }
1033
1034 mode := uint32(0644)
1035 if entry.Mode == filemode.Executable {
1036 mode = 0755
1037 }
1038
1039 entries = append(entries, core.TreeEntry{
1040 Path: path,
1041 Mode: mode,
1042 Size: size,
1043 BlobHash: larcHash,
1044 Kind: core.EntryKindFile,
1045 })
1046 }
1047 }
1048
1049 return entries, nil
1050 }
1051
1052 func convertGitBlobToLarc(gitRepo *git.Repository, larcRepo *repo.Repository, gitSHA string, mapping *convert.MappingStore) (string, int64, error) {
1053 /* check if already converted */
1054 for larcHash, gSHA := range mapping.BlobMap {
1055 if gSHA == gitSHA {
1056 data, err := larcRepo.Blobs.Read(larcHash)
1057 if err == nil {
1058 return larcHash, int64(len(data)), nil
1059 }
1060 }
1061 }
1062
1063 /* read git blob */
1064 blob, err := gitRepo.BlobObject(plumbing.NewHash(gitSHA))
1065 if err != nil {
1066 return "", 0, fmt.Errorf("get git blob: %w", err)
1067 }
1068
1069 reader, err := blob.Reader()
1070 if err != nil {
1071 return "", 0, fmt.Errorf("blob reader: %w", err)
1072 }
1073 defer reader.Close()
1074
1075 data, err := io.ReadAll(reader)
1076 if err != nil {
1077 return "", 0, fmt.Errorf("read blob: %w", err)
1078 }
1079
1080 /* write to larc */
1081 larcHash, err := larcRepo.Blobs.Write(data)
1082 if err != nil {
1083 return "", 0, fmt.Errorf("write larc blob: %w", err)
1084 }
1085
1086 mapping.AddBlobMapping(larcHash, gitSHA)
1087 return larcHash, int64(len(data)), nil
1088 }
1089