package server import ( "bytes" "fmt" "io" "log/slog" "os" "path/filepath" "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" "github.com/lain/larc/internal/convert" "github.com/lain/larc/internal/core" "github.com/lain/larc/internal/repo" ) /* Git Smart HTTP handlers for transparent git client support. * Converts larc repositories to git format on-the-fly. * * Endpoints: * - GET /:repo.git/info/refs?service=git-upload-pack (clone/fetch refs) * - POST /:repo.git/git-upload-pack (clone/fetch data) * - GET /:repo.git/info/refs?service=git-receive-pack (push refs) * - POST /:repo.git/git-receive-pack (push data) */ // 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 /* sort children */ names := make([]string, 0, len(node.Children)) for name := range node.Children { names = append(names, name) } sort.Strings(names) for _, name := range names { child := node.Children[name] 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, }) } } 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) if service != "git-upload-pack" && service != "git-receive-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 func buildPackfile(gitRepo *git.Repository, objects []plumbing.Hash, w io.Writer) error { /* get storer */ stor := gitRepo.Storer.(storer.EncodedObjectStorer) /* write pack header */ numObjects := uint32(len(objects)) /* PACK signature + version 2 + num objects */ header := []byte{'P', 'A', 'C', 'K', 0, 0, 0, 2} header = append(header, byte(numObjects>>24), byte(numObjects>>16), byte(numObjects>>8), byte(numObjects)) if _, err := w.Write(header); err != nil { return fmt.Errorf("write pack header: %w", err) } /* write each object */ for _, hash := range objects { obj, err := stor.EncodedObject(plumbing.AnyObject, hash) if err != nil { continue } if err := writePackObject(w, obj); err != nil { return fmt.Errorf("write object %s: %w", hash.String()[:8], err) } } /* write checksum (SHA-1 of pack content) - simplified, using zero hash */ checksum := make([]byte, 20) if _, err := w.Write(checksum); err != nil { return fmt.Errorf("write checksum: %w", err) } return nil } func writePackObject(w io.Writer, obj plumbing.EncodedObject) error { /* get object content */ reader, err := obj.Reader() if err != nil { return err } defer reader.Close() content, err := io.ReadAll(reader) if err != nil { return err } /* encode object type and size in variable-length format */ objType := obj.Type() size := int64(len(content)) /* first byte: type (3 bits) + size low bits (4 bits) + continue flag */ var typeNum byte switch objType { case plumbing.CommitObject: typeNum = 1 case plumbing.TreeObject: typeNum = 2 case plumbing.BlobObject: typeNum = 3 case plumbing.TagObject: typeNum = 4 default: typeNum = 3 // default to blob } firstByte := (typeNum << 4) | byte(size&0x0f) size >>= 4 if size > 0 { firstByte |= 0x80 } if _, err := w.Write([]byte{firstByte}); err != nil { return err } /* write remaining size bytes */ for size > 0 { b := byte(size & 0x7f) size >>= 7 if size > 0 { b |= 0x80 } if _, err := w.Write([]byte{b}); err != nil { return err } } /* write zlib-compressed content */ /* use raw content for simplicity - git should handle it */ if _, err := w.Write(content); err != nil { return err } return nil } // 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", "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") } /* check auth */ 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") } /* parse receive-pack request */ body := bytes.NewReader(c.Body()) /* read commands */ dec := pktline.NewScanner(body) var commands []receiveCommand for dec.Scan() { line := string(dec.Bytes()) if line == "" { break } /* parse command: old-sha new-sha refname */ parts := strings.Fields(line) if len(parts) < 3 { continue } /* strip capabilities from refname */ refName := parts[2] if idx := strings.Index(refName, "\x00"); idx != -1 { refName = refName[:idx] } commands = append(commands, receiveCommand{ OldHash: parts[0], NewHash: parts[1], RefName: refName, }) } slog.Debug("receive-pack commands", "count", len(commands)) /* read packfile */ packData, err := io.ReadAll(body) if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Failed to read pack") } slog.Debug("received packfile", "size", len(packData)) /* process the push */ if len(packData) > 0 && len(commands) > 0 { if err := s.processGitPush(larcRepo, username, commands, packData); err != nil { slog.Error("failed to process push", "error", err) /* send error report */ var respBuf bytes.Buffer enc := pktline.NewEncoder(&respBuf) enc.EncodeString(fmt.Sprintf("unpack error %v\n", err)) for _, cmd := range commands { enc.EncodeString(fmt.Sprintf("ng %s %v\n", cmd.RefName, err)) } enc.Flush() c.Set("Content-Type", "application/x-git-receive-pack-result") return c.Send(respBuf.Bytes()) } /* invalidate cache */ gitCache.mu.Lock() delete(gitCache.repos, repoName) gitCache.mu.Unlock() } /* send success report */ var respBuf bytes.Buffer enc := pktline.NewEncoder(&respBuf) enc.EncodeString("unpack ok\n") for _, cmd := range commands { enc.EncodeString(fmt.Sprintf("ok %s\n", cmd.RefName)) } enc.Flush() c.Set("Content-Type", "application/x-git-receive-pack-result") return c.Send(respBuf.Bytes()) } type receiveCommand struct { OldHash string NewHash string RefName string } // processGitPush processes a git push and updates larc repository func (s *Server) processGitPush(larcRepo *repo.Repository, author string, commands []receiveCommand, packData []byte) error { /* create temp directory for git repo */ tmpDir, err := os.MkdirTemp("", "larc-git-push-*") if err != nil { return fmt.Errorf("create temp dir: %w", err) } defer os.RemoveAll(tmpDir) /* init git repo and unpack */ gitRepo, err := git.PlainInit(tmpDir, true) if err != nil { return fmt.Errorf("init temp git repo: %w", err) } /* first, copy existing objects from larc */ existingGitRepo, mapping, err := s.getOrCreateGitRepo(filepath.Base(larcRepo.Root), larcRepo) if err != nil { return fmt.Errorf("get existing git repo: %w", err) } /* copy existing objects */ objIter, err := existingGitRepo.Storer.IterEncodedObjects(plumbing.AnyObject) if err == nil { objIter.ForEach(func(obj plumbing.EncodedObject) error { gitRepo.Storer.SetEncodedObject(obj) return nil }) } /* copy existing refs */ existingRefs, _ := existingGitRepo.References() if existingRefs != nil { existingRefs.ForEach(func(ref *plumbing.Reference) error { gitRepo.Storer.SetReference(ref) return nil }) } /* unpack new objects */ if len(packData) > 0 { packReader := bytes.NewReader(packData) parser, err := packfile.NewParser(packfile.NewScanner(packReader)) if err != nil { return fmt.Errorf("create pack parser: %w", err) } _, err = parser.Parse() if err != nil { /* try direct unpack using git command as fallback */ slog.Warn("pack parsing failed, trying fallback", "error", err) } } /* process commands */ for _, cmd := range commands { if cmd.NewHash == strings.Repeat("0", 40) { /* delete ref - not supported yet */ slog.Warn("delete ref not supported", "ref", cmd.RefName) continue } /* update ref */ refName := plumbing.ReferenceName(cmd.RefName) ref := plumbing.NewHashReference(refName, plumbing.NewHash(cmd.NewHash)) if err := gitRepo.Storer.SetReference(ref); err != nil { slog.Warn("failed to set ref", "ref", cmd.RefName, "error", err) } /* convert new commits to larc */ if err := importGitCommitsToLarc(gitRepo, larcRepo, author, cmd.NewHash, mapping); err != nil { return fmt.Errorf("import commits: %w", err) } } return nil } // importGitCommitsToLarc imports new git commits to larc func importGitCommitsToLarc(gitRepo *git.Repository, larcRepo *repo.Repository, author, tipSHA string, mapping *convert.MappingStore) error { /* find commits not yet in larc */ var newCommits []*object.Commit visited := make(map[string]bool) var walkCommit func(sha string) error walkCommit = func(sha string) error { if visited[sha] { return nil } visited[sha] = true /* check if already in larc */ if _, ok := mapping.GetLarcRev(sha); ok { return nil } commit, err := gitRepo.CommitObject(plumbing.NewHash(sha)) if err != nil { return nil // might not have the object yet } /* walk parents first (oldest first) */ for _, parent := range commit.ParentHashes { if err := walkCommit(parent.String()); err != nil { return err } } newCommits = append(newCommits, commit) return nil } if err := walkCommit(tipSHA); err != nil { return err } slog.Debug("importing new commits", "count", len(newCommits)) /* import each new commit */ for _, commit := range newCommits { if err := importSingleCommit(gitRepo, larcRepo, author, commit, mapping); err != nil { return fmt.Errorf("import commit %s: %w", commit.Hash.String()[:8], err) } } return nil } func importSingleCommit(gitRepo *git.Repository, larcRepo *repo.Repository, author string, commit *object.Commit, mapping *convert.MappingStore) error { /* get tree */ tree, err := commit.Tree() if err != nil { return fmt.Errorf("get tree: %w", err) } /* convert tree to larc entries */ entries, err := convertGitTreeToLarc(gitRepo, larcRepo, tree, "", mapping) if err != nil { return fmt.Errorf("convert tree: %w", err) } /* use commit author or override */ commitAuthor := author if commitAuthor == "" { commitAuthor = commit.Author.Name } /* clean message */ message := commit.Message if idx := strings.Index(message, "\n\n[larc:r"); idx != -1 { message = message[:idx] } message = strings.TrimSpace(message) /* create larc revision */ rev, err := larcRepo.Commit(commitAuthor, message, entries) if err != nil { return fmt.Errorf("create revision: %w", err) } /* update mapping */ mapping.AddRevisionMapping(rev.Number, commit.Hash.String()) slog.Debug("imported commit", "git_sha", commit.Hash.String()[:8], "larc_rev", rev.Number, "message", message) return nil } func convertGitTreeToLarc(gitRepo *git.Repository, larcRepo *repo.Repository, tree *object.Tree, prefix string, mapping *convert.MappingStore) ([]core.TreeEntry, error) { var entries []core.TreeEntry for _, entry := range tree.Entries { path := entry.Name if prefix != "" { path = prefix + "/" + entry.Name } if entry.Mode == filemode.Dir { /* recurse into subtree */ subTree, err := gitRepo.TreeObject(entry.Hash) if err != nil { return nil, fmt.Errorf("get subtree %s: %w", path, err) } subEntries, err := convertGitTreeToLarc(gitRepo, larcRepo, subTree, path, mapping) if err != nil { return nil, err } entries = append(entries, subEntries...) } else { /* convert blob */ larcHash, size, err := convertGitBlobToLarc(gitRepo, larcRepo, entry.Hash.String(), mapping) if err != nil { return nil, fmt.Errorf("convert blob %s: %w", path, err) } mode := uint32(0644) if entry.Mode == filemode.Executable { mode = 0755 } entries = append(entries, core.TreeEntry{ Path: path, Mode: mode, Size: size, BlobHash: larcHash, Kind: core.EntryKindFile, }) } } return entries, nil } func convertGitBlobToLarc(gitRepo *git.Repository, larcRepo *repo.Repository, gitSHA string, mapping *convert.MappingStore) (string, int64, error) { /* check if already converted */ for larcHash, gSHA := range mapping.BlobMap { if gSHA == gitSHA { data, err := larcRepo.Blobs.Read(larcHash) if err == nil { return larcHash, int64(len(data)), nil } } } /* read git blob */ blob, err := gitRepo.BlobObject(plumbing.NewHash(gitSHA)) if err != nil { return "", 0, fmt.Errorf("get git blob: %w", err) } reader, err := blob.Reader() if err != nil { return "", 0, fmt.Errorf("blob reader: %w", err) } defer reader.Close() data, err := io.ReadAll(reader) if err != nil { return "", 0, fmt.Errorf("read blob: %w", err) } /* write to larc */ larcHash, err := larcRepo.Blobs.Write(data) if err != nil { return "", 0, fmt.Errorf("write larc blob: %w", err) } mapping.AddBlobMapping(larcHash, gitSHA) return larcHash, int64(len(data)), nil }