package convert import ( "fmt" "log/slog" "os" "path/filepath" "sort" "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/object" "github.com/lain/larc/internal/core" "github.com/lain/larc/internal/repo" ) /* Larc2Git exports a larc repository to git format. * Converts sequential revisions to git commits with proper tree structure. */ // Larc2GitOptions configures the conversion type Larc2GitOptions struct { LarcPath string // path to larc repository GitPath string // path for new git repository Verbose bool // verbose output } // Larc2GitResult contains conversion results type Larc2GitResult struct { RevisionsConverted int BlobsConverted int BranchesConverted int Mapping *MappingStore } // Larc2Git converts a larc repository to git func Larc2Git(opts Larc2GitOptions) (*Larc2GitResult, error) { log := slog.Default() if opts.Verbose { log = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) } log.Info("opening larc repository", "path", opts.LarcPath) /* open larc repo */ larcRepo, err := repo.Open(opts.LarcPath) if err != nil { return nil, fmt.Errorf("open larc repo: %w", err) } defer larcRepo.Close() /* create git repo */ log.Info("creating git repository", "path", opts.GitPath) if err := os.MkdirAll(opts.GitPath, 0755); err != nil { return nil, fmt.Errorf("create git dir: %w", err) } gitRepo, err := git.PlainInit(opts.GitPath, false) if err != nil { return nil, fmt.Errorf("init git repo: %w", err) } mapping := NewMappingStore() result := &Larc2GitResult{Mapping: mapping} /* get all revisions in chronological order */ latestRev, err := larcRepo.Meta.GetLatestRevision() if err != nil { return nil, fmt.Errorf("get latest revision: %w", err) } if latestRev == 0 { log.Info("no revisions to convert") return result, nil } /* load all revisions */ allRevs, err := larcRepo.Meta.ListRevisions("", int(latestRev), 0) if err != nil { return nil, fmt.Errorf("list revisions: %w", err) } /* reverse to chronological order (oldest first) */ sort.Slice(allRevs, func(i, j int) bool { return allRevs[i].Number < allRevs[j].Number }) log.Info("converting revisions", "count", len(allRevs)) /* process each revision */ for _, rev := range allRevs { log.Debug("converting revision", "number", rev.Number, "branch", rev.Branch) sha, blobCount, err := convertRevision(larcRepo, gitRepo, rev, mapping) if err != nil { return nil, fmt.Errorf("convert r%d: %w", rev.Number, err) } mapping.AddRevisionMapping(rev.Number, sha) result.RevisionsConverted++ result.BlobsConverted += blobCount } /* convert branches */ branches, err := larcRepo.Meta.ListBranches() if err != nil { return nil, fmt.Errorf("list branches: %w", err) } for _, branch := range branches { log.Debug("converting branch", "name", branch.Name, "head", branch.HeadRev) if err := convertBranch(gitRepo, branch, mapping); err != nil { return nil, fmt.Errorf("convert branch %s: %w", branch.Name, err) } result.BranchesConverted++ } /* set HEAD to main branch */ headRef := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("main")) if err := gitRepo.Storer.SetReference(headRef); err != nil { log.Warn("failed to set HEAD", "error", err) } log.Info("conversion complete", "revisions", result.RevisionsConverted, "blobs", result.BlobsConverted, "branches", result.BranchesConverted) return result, nil } // convertRevision converts a single larc revision to a git commit func convertRevision( larcRepo *repo.Repository, gitRepo *git.Repository, rev *core.Revision, mapping *MappingStore, ) (string, int, error) { /* get tree for this revision */ tree, err := larcRepo.GetTree(rev.TreeHash) if err != nil { return "", 0, fmt.Errorf("get tree: %w", err) } /* convert blobs and build git tree */ blobCount := 0 var treeEntries []object.TreeEntry /* group entries by directory for hierarchical tree */ hierarchical := FlatTreeToHierarchical(tree.Entries) /* recursively create git trees */ rootTreeHash, blobs, err := createGitTree(larcRepo, gitRepo, hierarchical, mapping) if err != nil { return "", 0, fmt.Errorf("create git tree: %w", err) } blobCount = blobs _ = treeEntries // not used directly, hierarchical approach /* determine parent commits */ 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: FormatRevisionMessage(rev.Message, rev.Number), TreeHash: rootTreeHash, ParentHashes: parents, } /* encode and store commit */ commitObj := gitRepo.Storer.NewEncodedObject() commitObj.SetType(plumbing.CommitObject) if err := commit.Encode(commitObj); err != nil { return "", 0, fmt.Errorf("encode commit: %w", err) } commitHash, err := gitRepo.Storer.SetEncodedObject(commitObj) if err != nil { return "", 0, fmt.Errorf("store commit: %w", err) } return commitHash.String(), blobCount, nil } // createGitTree recursively creates git tree objects from hierarchical structure func createGitTree( larcRepo *repo.Repository, gitRepo *git.Repository, node *TreeNode, mapping *MappingStore, ) (plumbing.Hash, int, error) { var entries []object.TreeEntry blobCount := 0 /* sort children for consistent ordering */ 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 { /* recurse into directory */ subTreeHash, subBlobs, err := createGitTree(larcRepo, gitRepo, child, mapping) if err != nil { return plumbing.ZeroHash, 0, err } blobCount += subBlobs entries = append(entries, object.TreeEntry{ Name: name, Mode: filemode.Dir, Hash: subTreeHash, }) } else { /* convert blob */ blobHash, isNew, err := convertBlob(larcRepo, gitRepo, child.BlobSHA, mapping) if err != nil { return plumbing.ZeroHash, 0, fmt.Errorf("convert blob %s: %w", name, err) } if isNew { blobCount++ } /* determine file mode */ mode := filemode.Regular if child.Mode&0111 != 0 { mode = filemode.Executable } entries = append(entries, object.TreeEntry{ Name: name, Mode: mode, Hash: blobHash, }) } } /* create tree object */ tree := &object.Tree{Entries: entries} treeObj := gitRepo.Storer.NewEncodedObject() treeObj.SetType(plumbing.TreeObject) if err := tree.Encode(treeObj); err != nil { return plumbing.ZeroHash, 0, fmt.Errorf("encode tree: %w", err) } treeHash, err := gitRepo.Storer.SetEncodedObject(treeObj) if err != nil { return plumbing.ZeroHash, 0, fmt.Errorf("store tree: %w", err) } return treeHash, blobCount, nil } // convertBlob converts a larc blob to git blob func convertBlob( larcRepo *repo.Repository, gitRepo *git.Repository, larcHash string, mapping *MappingStore, ) (plumbing.Hash, bool, error) { /* check if already converted */ if gitSHA, ok := mapping.GetGitBlobSHA(larcHash); ok { return plumbing.NewHash(gitSHA), false, nil } /* read blob from larc */ data, err := larcRepo.Blobs.Read(larcHash) if err != nil { return plumbing.ZeroHash, false, fmt.Errorf("read larc blob: %w", err) } /* create git blob object */ blobObj := gitRepo.Storer.NewEncodedObject() blobObj.SetType(plumbing.BlobObject) blobObj.SetSize(int64(len(data))) writer, err := blobObj.Writer() if err != nil { return plumbing.ZeroHash, false, fmt.Errorf("blob writer: %w", err) } if _, err := writer.Write(data); err != nil { writer.Close() return plumbing.ZeroHash, false, fmt.Errorf("write blob: %w", err) } writer.Close() blobHash, err := gitRepo.Storer.SetEncodedObject(blobObj) if err != nil { return plumbing.ZeroHash, false, fmt.Errorf("store blob: %w", err) } mapping.AddBlobMapping(larcHash, blobHash.String()) return blobHash, true, nil } // convertBranch creates a git branch reference func convertBranch( gitRepo *git.Repository, branch *core.Branch, mapping *MappingStore, ) error { commitSHA, ok := mapping.GetGitSHA(branch.HeadRev) if !ok { return fmt.Errorf("no commit for revision %d", branch.HeadRev) } gitBranchName := ConvertBranchName(branch.Name) refName := plumbing.NewBranchReferenceName(gitBranchName) ref := plumbing.NewHashReference(refName, plumbing.NewHash(commitSHA)) return gitRepo.Storer.SetReference(ref) } // ExportToGit is a convenience function for CLI func ExportToGit(larcPath, gitPath string, verbose bool) error { /* resolve paths */ absLarc, err := filepath.Abs(larcPath) if err != nil { return fmt.Errorf("resolve larc path: %w", err) } absGit, err := filepath.Abs(gitPath) if err != nil { return fmt.Errorf("resolve git path: %w", err) } /* check larc repo exists */ if _, err := os.Stat(filepath.Join(absLarc, ".larc")); os.IsNotExist(err) { return fmt.Errorf("not a larc repository: %s", absLarc) } /* check git path doesn't exist or is empty */ if entries, err := os.ReadDir(absGit); err == nil && len(entries) > 0 { return fmt.Errorf("git path not empty: %s", absGit) } opts := Larc2GitOptions{ LarcPath: absLarc, GitPath: absGit, Verbose: verbose, } result, err := Larc2Git(opts) if err != nil { return err } fmt.Printf("Exported larc repository to git:\n") fmt.Printf(" Revisions: %d\n", result.RevisionsConverted) fmt.Printf(" Blobs: %d\n", result.BlobsConverted) fmt.Printf(" Branches: %d\n", result.BranchesConverted) return nil }