package server import ( "bytes" "fmt" "io" "log/slog" "sort" "strings" "sync" "time" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/filemode" "github.com/go-git/go-git/v5/plumbing/format/packfile" "github.com/go-git/go-git/v5/plumbing/format/pktline" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/protocol/packp" "github.com/go-git/go-git/v5/plumbing/storer" "github.com/go-git/go-git/v5/storage/memory" "github.com/gofiber/fiber/v2" "larc.wejust.rest/larc/internal/convert" "larc.wejust.rest/larc/internal/core" "larc.wejust.rest/larc/internal/repo" ) /* Git Smart HTTP handlers for transparent git client support. * Converts larc repositories to git format on-the-fly. * READ-ONLY: git clone/fetch supported, push is rejected. * * Endpoints: * - GET /:repo.git/info/refs?service=git-upload-pack (clone/fetch) * - POST /:repo.git/git-upload-pack (clone/fetch data) * - git push -> rejected! pls use larc CLI */ // gitRepoCache caches converted git repositories type gitRepoCache struct { mu sync.RWMutex repos map[string]*gitCacheEntry } type gitCacheEntry struct { gitRepo *git.Repository mapping *convert.MappingStore larcRev int64 // larc revision when cache was built createdAt time.Time } var gitCache = &gitRepoCache{ repos: make(map[string]*gitCacheEntry), } const gitCacheTTL = 5 * time.Minute // getOrCreateGitRepo gets or creates a git repository from larc func (s *Server) getOrCreateGitRepo(repoName string, larcRepo *repo.Repository) (*git.Repository, *convert.MappingStore, error) { /* check current larc revision */ currentRev, err := larcRepo.Meta.GetLatestRevision() if err != nil { return nil, nil, fmt.Errorf("get latest revision: %w", err) } /* check cache */ gitCache.mu.RLock() entry, ok := gitCache.repos[repoName] gitCache.mu.RUnlock() if ok && entry.larcRev == currentRev && time.Since(entry.createdAt) < gitCacheTTL { return entry.gitRepo, entry.mapping, nil } /* create new git repo in memory */ gitCache.mu.Lock() defer gitCache.mu.Unlock() /* double-check after acquiring write lock */ entry, ok = gitCache.repos[repoName] if ok && entry.larcRev == currentRev && time.Since(entry.createdAt) < gitCacheTTL { return entry.gitRepo, entry.mapping, nil } slog.Debug("building git cache for repo", "name", repoName, "rev", currentRev) gitRepo, mapping, err := buildGitRepo(larcRepo) if err != nil { return nil, nil, fmt.Errorf("build git repo: %w", err) } gitCache.repos[repoName] = &gitCacheEntry{ gitRepo: gitRepo, mapping: mapping, larcRev: currentRev, createdAt: time.Now(), } return gitRepo, mapping, nil } // buildGitRepo builds an in-memory git repository from larc func buildGitRepo(larcRepo *repo.Repository) (*git.Repository, *convert.MappingStore, error) { storage := memory.NewStorage() gitRepo, err := git.Init(storage, nil) if err != nil { return nil, nil, fmt.Errorf("init git repo: %w", err) } mapping := convert.NewMappingStore() /* get all revisions */ latestRev, err := larcRepo.Meta.GetLatestRevision() if err != nil { return nil, nil, fmt.Errorf("get latest revision: %w", err) } if latestRev == 0 { /* empty repo */ return gitRepo, mapping, nil } allRevs, err := larcRepo.Meta.ListRevisions("", int(latestRev), 0) if err != nil { return nil, nil, fmt.Errorf("list revisions: %w", err) } /* sort oldest first */ sort.Slice(allRevs, func(i, j int) bool { return allRevs[i].Number < allRevs[j].Number }) /* convert each revision */ for _, rev := range allRevs { sha, err := convertRevisionToGit(larcRepo, gitRepo, rev, mapping) if err != nil { return nil, nil, fmt.Errorf("convert r%d: %w", rev.Number, err) } mapping.AddRevisionMapping(rev.Number, sha) } /* create branch refs */ branches, err := larcRepo.Meta.ListBranches() if err != nil { return nil, nil, fmt.Errorf("list branches: %w", err) } for _, branch := range branches { commitSHA, ok := mapping.GetGitSHA(branch.HeadRev) if !ok { continue } gitBranchName := convert.ConvertBranchName(branch.Name) refName := plumbing.NewBranchReferenceName(gitBranchName) ref := plumbing.NewHashReference(refName, plumbing.NewHash(commitSHA)) if err := storage.SetReference(ref); err != nil { slog.Warn("failed to set branch ref", "branch", gitBranchName, "error", err) } } /* set HEAD */ headRef := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("main")) storage.SetReference(headRef) return gitRepo, mapping, nil } // convertRevisionToGit converts a single larc revision to git commit func convertRevisionToGit( larcRepo *repo.Repository, gitRepo *git.Repository, rev *core.Revision, mapping *convert.MappingStore, ) (string, error) { /* get tree */ tree, err := larcRepo.GetTree(rev.TreeHash) if err != nil { return "", fmt.Errorf("get tree: %w", err) } /* build hierarchical tree */ hierarchical := convert.FlatTreeToHierarchical(tree.Entries) /* create git tree */ rootTreeHash, err := createGitTreeRecursive(larcRepo, gitRepo, hierarchical, mapping) if err != nil { return "", fmt.Errorf("create git tree: %w", err) } /* determine parents */ var parents []plumbing.Hash if rev.Parent > 0 { if parentSHA, ok := mapping.GetGitSHA(rev.Parent); ok { parents = append(parents, plumbing.NewHash(parentSHA)) } } if rev.MergeParent > 0 { if mergeParentSHA, ok := mapping.GetGitSHA(rev.MergeParent); ok { parents = append(parents, plumbing.NewHash(mergeParentSHA)) } } /* create commit */ commit := &object.Commit{ Author: object.Signature{ Name: rev.Author, Email: fmt.Sprintf("%s@larc", rev.Author), When: time.Unix(rev.Timestamp, 0), }, Committer: object.Signature{ Name: rev.Author, Email: fmt.Sprintf("%s@larc", rev.Author), When: time.Unix(rev.Timestamp, 0), }, Message: convert.FormatRevisionMessage(rev.Message, rev.Number), TreeHash: rootTreeHash, ParentHashes: parents, } commitObj := gitRepo.Storer.NewEncodedObject() commitObj.SetType(plumbing.CommitObject) if err := commit.Encode(commitObj); err != nil { return "", fmt.Errorf("encode commit: %w", err) } commitHash, err := gitRepo.Storer.SetEncodedObject(commitObj) if err != nil { return "", fmt.Errorf("store commit: %w", err) } return commitHash.String(), nil } func createGitTreeRecursive( larcRepo *repo.Repository, gitRepo *git.Repository, node *convert.TreeNode, mapping *convert.MappingStore, ) (plumbing.Hash, error) { var entries []object.TreeEntry for name, child := range node.Children { if child.IsDir { subTreeHash, err := createGitTreeRecursive(larcRepo, gitRepo, child, mapping) if err != nil { return plumbing.ZeroHash, err } entries = append(entries, object.TreeEntry{ Name: name, Mode: filemode.Dir, Hash: subTreeHash, }) } else { blobHash, err := convertBlobToGit(larcRepo, gitRepo, child.BlobSHA, mapping) if err != nil { return plumbing.ZeroHash, fmt.Errorf("convert blob %s: %w", name, err) } mode := filemode.Regular if child.Mode&0111 != 0 { mode = filemode.Executable } entries = append(entries, object.TreeEntry{ Name: name, Mode: mode, Hash: blobHash, }) } } /* git requires specific sorting: dirs sorted as if they have trailing "/" */ sort.Slice(entries, func(i, j int) bool { ni, nj := entries[i].Name, entries[j].Name if entries[i].Mode == filemode.Dir { ni += "/" } if entries[j].Mode == filemode.Dir { nj += "/" } return ni < nj }) tree := &object.Tree{Entries: entries} treeObj := gitRepo.Storer.NewEncodedObject() treeObj.SetType(plumbing.TreeObject) if err := tree.Encode(treeObj); err != nil { return plumbing.ZeroHash, fmt.Errorf("encode tree: %w", err) } treeHash, err := gitRepo.Storer.SetEncodedObject(treeObj) if err != nil { return plumbing.ZeroHash, fmt.Errorf("store tree: %w", err) } return treeHash, nil } func convertBlobToGit( larcRepo *repo.Repository, gitRepo *git.Repository, larcHash string, mapping *convert.MappingStore, ) (plumbing.Hash, error) { /* check cache */ if gitSHA, ok := mapping.GetGitBlobSHA(larcHash); ok { return plumbing.NewHash(gitSHA), nil } /* read larc blob */ data, err := larcRepo.Blobs.Read(larcHash) if err != nil { return plumbing.ZeroHash, fmt.Errorf("read larc blob: %w", err) } /* create git blob */ blobObj := gitRepo.Storer.NewEncodedObject() blobObj.SetType(plumbing.BlobObject) blobObj.SetSize(int64(len(data))) writer, err := blobObj.Writer() if err != nil { return plumbing.ZeroHash, fmt.Errorf("blob writer: %w", err) } if _, err := writer.Write(data); err != nil { writer.Close() return plumbing.ZeroHash, fmt.Errorf("write blob: %w", err) } writer.Close() blobHash, err := gitRepo.Storer.SetEncodedObject(blobObj) if err != nil { return plumbing.ZeroHash, fmt.Errorf("store blob: %w", err) } mapping.AddBlobMapping(larcHash, blobHash.String()) return blobHash, nil } // handleGitInfoRefs handles GET /:repo.git/info/refs func (s *Server) handleGitInfoRefs(c *fiber.Ctx) error { repoName := strings.TrimSuffix(c.Params("repo"), ".git") service := c.Query("service") slog.Debug("git info/refs", "repo", repoName, "service", service) /* only allow git-upload-pack (clone/fetch), reject push */ if service == "git-receive-pack" { c.Set("Content-Type", "text/plain") return c.Status(fiber.StatusForbidden).SendString( "git push is NOT supported.\n" + "For contributing, please use larc CLI:\n" + " larc clone " + c.BaseURL() + "/" + repoName + "\n" + " larc push\n") } if service != "git-upload-pack" { return fiber.NewError(fiber.StatusForbidden, "Service not allowed") } /* get larc repo */ repoCfg := s.config.GetRepoConfig(repoName) if repoCfg == nil { return fiber.NewError(fiber.StatusNotFound, "Repository not found") } larcRepo, ok := s.repos[repoName] if !ok { return fiber.NewError(fiber.StatusNotFound, "Repository not initialized") } /* check auth for push */ if service == "git-receive-pack" { username, _ := c.Locals("username").(string) if !repoCfg.IsUserAllowed(username, true) { c.Set("WWW-Authenticate", `Basic realm="`+s.config.Auth.Realm+`"`) return fiber.NewError(fiber.StatusUnauthorized, "Authentication required") } } /* get or create git repo */ gitRepo, _, err := s.getOrCreateGitRepo(repoName, larcRepo) if err != nil { slog.Error("failed to create git repo", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to prepare repository") } /* build refs response */ var buf bytes.Buffer enc := pktline.NewEncoder(&buf) /* service announcement */ enc.Encodef("# service=%s\n", service) enc.Flush() /* get refs */ refs, err := gitRepo.References() if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get references") } var refLines []string var headSHA string err = refs.ForEach(func(ref *plumbing.Reference) error { if ref.Type() == plumbing.SymbolicReference { /* HEAD - resolve it */ resolved, err := gitRepo.Reference(ref.Target(), true) if err == nil { headSHA = resolved.Hash().String() } return nil } sha := ref.Hash().String() name := ref.Name().String() /* first ref includes capabilities */ if len(refLines) == 0 { caps := "multi_ack thin-pack side-band side-band-64k ofs-delta shallow no-progress include-tag multi_ack_detailed" if service == "git-receive-pack" { caps = "report-status delete-refs side-band-64k quiet ofs-delta" } refLines = append(refLines, fmt.Sprintf("%s %s\x00%s\n", sha, name, caps)) } else { refLines = append(refLines, fmt.Sprintf("%s %s\n", sha, name)) } return nil }) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to iterate references") } /* add HEAD if we have refs */ if headSHA != "" && len(refLines) > 0 { /* insert HEAD at the beginning with capabilities */ caps := "multi_ack thin-pack side-band side-band-64k ofs-delta shallow no-progress include-tag multi_ack_detailed" if service == "git-receive-pack" { caps = "report-status delete-refs side-band-64k quiet ofs-delta" } headLine := fmt.Sprintf("%s HEAD\x00%s\n", headSHA, caps) /* rebuild refs without caps on first line */ var newRefLines []string newRefLines = append(newRefLines, headLine) for i, line := range refLines { if i == 0 { /* remove caps from first ref */ parts := strings.SplitN(line, "\x00", 2) newRefLines = append(newRefLines, parts[0]+"\n") } else { newRefLines = append(newRefLines, line) } } refLines = newRefLines } /* encode refs */ for _, line := range refLines { enc.EncodeString(line) } enc.Flush() c.Set("Content-Type", fmt.Sprintf("application/x-%s-advertisement", service)) c.Set("Cache-Control", "no-cache") return c.Send(buf.Bytes()) } // handleGitUploadPack handles POST /:repo.git/git-upload-pack func (s *Server) handleGitUploadPack(c *fiber.Ctx) error { repoName := strings.TrimSuffix(c.Params("repo"), ".git") slog.Debug("git upload-pack", "repo", repoName) /* get larc repo */ repoCfg := s.config.GetRepoConfig(repoName) if repoCfg == nil { return fiber.NewError(fiber.StatusNotFound, "Repository not found") } larcRepo, ok := s.repos[repoName] if !ok { return fiber.NewError(fiber.StatusNotFound, "Repository not initialized") } /* get git repo */ gitRepo, _, err := s.getOrCreateGitRepo(repoName, larcRepo) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to prepare repository") } /* parse upload-pack request */ body := bytes.NewReader(c.Body()) req := packp.NewUploadPackRequest() if err := req.Decode(body); err != nil { slog.Error("failed to decode upload-pack request", "error", err) return fiber.NewError(fiber.StatusBadRequest, "Invalid request") } slog.Debug("upload-pack request", "wants", len(req.Wants), "haves", len(req.Haves), "shallows", len(req.Shallows)) /* collect objects to send */ objectsToSend, err := collectObjectsForPack(gitRepo, req.Wants, req.Haves) if err != nil { slog.Error("failed to collect objects", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to collect objects") } slog.Debug("sending objects", "count", len(objectsToSend)) /* build pack file */ var packBuf bytes.Buffer if err := buildPackfile(gitRepo, objectsToSend, &packBuf); err != nil { slog.Error("failed to build packfile", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to build pack") } /* build response */ var respBuf bytes.Buffer enc := pktline.NewEncoder(&respBuf) /* NAK (we don't support multi_ack properly yet) */ enc.EncodeString("NAK\n") /* send pack data via side-band */ packData := packBuf.Bytes() /* side-band-64k: channel 1 = pack data */ for i := 0; i < len(packData); i += 65515 { end := i + 65515 if end > len(packData) { end = len(packData) } chunk := packData[i:end] /* channel 1 prefix */ data := append([]byte{1}, chunk...) enc.Encode(data) } /* flush */ enc.Flush() c.Set("Content-Type", "application/x-git-upload-pack-result") c.Set("Cache-Control", "no-cache") return c.Send(respBuf.Bytes()) } // collectObjectsForPack collects all objects needed for the pack func collectObjectsForPack(gitRepo *git.Repository, wants []plumbing.Hash, haves []plumbing.Hash) ([]plumbing.Hash, error) { haveSet := make(map[plumbing.Hash]bool) for _, h := range haves { haveSet[h] = true } visited := make(map[plumbing.Hash]bool) var result []plumbing.Hash var walkCommit func(hash plumbing.Hash) error walkCommit = func(hash plumbing.Hash) error { if visited[hash] || haveSet[hash] { return nil } visited[hash] = true commit, err := gitRepo.CommitObject(hash) if err != nil { return err } result = append(result, hash) /* walk tree */ if err := walkTree(gitRepo, commit.TreeHash, visited, haveSet, &result); err != nil { return err } /* walk parents */ for _, parent := range commit.ParentHashes { if err := walkCommit(parent); err != nil { return err } } return nil } for _, want := range wants { if err := walkCommit(want); err != nil { return nil, err } } return result, nil } func walkTree(gitRepo *git.Repository, hash plumbing.Hash, visited, haveSet map[plumbing.Hash]bool, result *[]plumbing.Hash) error { if visited[hash] || haveSet[hash] { return nil } visited[hash] = true *result = append(*result, hash) tree, err := gitRepo.TreeObject(hash) if err != nil { return err } for _, entry := range tree.Entries { if entry.Mode == filemode.Dir { if err := walkTree(gitRepo, entry.Hash, visited, haveSet, result); err != nil { return err } } else { if !visited[entry.Hash] && !haveSet[entry.Hash] { visited[entry.Hash] = true *result = append(*result, entry.Hash) } } } return nil } // buildPackfile builds a packfile from objects using go-git's encoder func buildPackfile(gitRepo *git.Repository, objects []plumbing.Hash, w io.Writer) error { stor := gitRepo.Storer.(storer.EncodedObjectStorer) /* create packfile encoder */ encoder := packfile.NewEncoder(w, stor, false) /* encode all objects */ _, err := encoder.Encode(objects, 10) return err } // handleGitReceivePack handles POST /:repo.git/git-receive-pack func (s *Server) handleGitReceivePack(c *fiber.Ctx) error { repoName := strings.TrimSuffix(c.Params("repo"), ".git") slog.Debug("git receive-pack rejected", "repo", repoName) /* git push is not supported - return friendly error */ c.Set("Content-Type", "text/plain") return c.Status(fiber.StatusForbidden).SendString( "git push is NOT supported.\n" + "For contributing, please use larc CLI:\n" + " larc clone " + c.BaseURL() + "/" + repoName + "\n" + " larc push\n") }