larc r9

387 lines ยท 10.0 KB Raw
1 package convert
2
3 import (
4 "fmt"
5 "log/slog"
6 "os"
7 "path/filepath"
8 "sort"
9 "time"
10
11 "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/filemode"
14 "github.com/go-git/go-git/v5/plumbing/object"
15
16 "larc.wejust.rest/larc/internal/core"
17 "larc.wejust.rest/larc/internal/repo"
18 )
19
20 /* Larc2Git exports a larc repository to git format.
21 * Converts sequential revisions to git commits with proper tree structure. */
22
23 // Larc2GitOptions configures the conversion
24 type Larc2GitOptions struct {
25 LarcPath string // path to larc repository
26 GitPath string // path for new git repository
27 Verbose bool // verbose output
28 }
29
30 // Larc2GitResult contains conversion results
31 type Larc2GitResult struct {
32 RevisionsConverted int
33 BlobsConverted int
34 BranchesConverted int
35 Mapping *MappingStore
36 }
37
38 // Larc2Git converts a larc repository to git
39 func Larc2Git(opts Larc2GitOptions) (*Larc2GitResult, error) {
40 log := slog.Default()
41 if opts.Verbose {
42 log = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
43 }
44
45 log.Info("opening larc repository", "path", opts.LarcPath)
46
47 /* open larc repo */
48 larcRepo, err := repo.Open(opts.LarcPath)
49 if err != nil {
50 return nil, fmt.Errorf("open larc repo: %w", err)
51 }
52 defer larcRepo.Close()
53
54 /* create git repo */
55 log.Info("creating git repository", "path", opts.GitPath)
56
57 if err := os.MkdirAll(opts.GitPath, 0755); err != nil {
58 return nil, fmt.Errorf("create git dir: %w", err)
59 }
60
61 gitRepo, err := git.PlainInit(opts.GitPath, false)
62 if err != nil {
63 return nil, fmt.Errorf("init git repo: %w", err)
64 }
65
66 mapping := NewMappingStore()
67 result := &Larc2GitResult{Mapping: mapping}
68
69 /* get all revisions in chronological order */
70 latestRev, err := larcRepo.Meta.GetLatestRevision()
71 if err != nil {
72 return nil, fmt.Errorf("get latest revision: %w", err)
73 }
74
75 if latestRev == 0 {
76 log.Info("no revisions to convert")
77 return result, nil
78 }
79
80 /* load all revisions */
81 allRevs, err := larcRepo.Meta.ListRevisions("", int(latestRev), 0)
82 if err != nil {
83 return nil, fmt.Errorf("list revisions: %w", err)
84 }
85
86 /* reverse to chronological order (oldest first) */
87 sort.Slice(allRevs, func(i, j int) bool {
88 return allRevs[i].Number < allRevs[j].Number
89 })
90
91 log.Info("converting revisions", "count", len(allRevs))
92
93 /* process each revision */
94 for _, rev := range allRevs {
95 log.Debug("converting revision", "number", rev.Number, "branch", rev.Branch)
96
97 sha, blobCount, err := convertRevision(larcRepo, gitRepo, rev, mapping)
98 if err != nil {
99 return nil, fmt.Errorf("convert r%d: %w", rev.Number, err)
100 }
101
102 mapping.AddRevisionMapping(rev.Number, sha)
103 result.RevisionsConverted++
104 result.BlobsConverted += blobCount
105 }
106
107 /* convert branches */
108 branches, err := larcRepo.Meta.ListBranches()
109 if err != nil {
110 return nil, fmt.Errorf("list branches: %w", err)
111 }
112
113 for _, branch := range branches {
114 log.Debug("converting branch", "name", branch.Name, "head", branch.HeadRev)
115
116 if err := convertBranch(gitRepo, branch, mapping); err != nil {
117 return nil, fmt.Errorf("convert branch %s: %w", branch.Name, err)
118 }
119 result.BranchesConverted++
120 }
121
122 /* set HEAD to main branch */
123 headRef := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName("main"))
124 if err := gitRepo.Storer.SetReference(headRef); err != nil {
125 log.Warn("failed to set HEAD", "error", err)
126 }
127
128 log.Info("conversion complete",
129 "revisions", result.RevisionsConverted,
130 "blobs", result.BlobsConverted,
131 "branches", result.BranchesConverted)
132
133 return result, nil
134 }
135
136 // convertRevision converts a single larc revision to a git commit
137 func convertRevision(
138 larcRepo *repo.Repository,
139 gitRepo *git.Repository,
140 rev *core.Revision,
141 mapping *MappingStore,
142 ) (string, int, error) {
143 /* get tree for this revision */
144 tree, err := larcRepo.GetTree(rev.TreeHash)
145 if err != nil {
146 return "", 0, fmt.Errorf("get tree: %w", err)
147 }
148
149 /* convert blobs and build git tree */
150 blobCount := 0
151 var treeEntries []object.TreeEntry
152
153 /* group entries by directory for hierarchical tree */
154 hierarchical := FlatTreeToHierarchical(tree.Entries)
155
156 /* recursively create git trees */
157 rootTreeHash, blobs, err := createGitTree(larcRepo, gitRepo, hierarchical, mapping)
158 if err != nil {
159 return "", 0, fmt.Errorf("create git tree: %w", err)
160 }
161 blobCount = blobs
162 _ = treeEntries // not used directly, hierarchical approach
163
164 /* determine parent commits */
165 var parents []plumbing.Hash
166 if rev.Parent > 0 {
167 if parentSHA, ok := mapping.GetGitSHA(rev.Parent); ok {
168 parents = append(parents, plumbing.NewHash(parentSHA))
169 }
170 }
171 if rev.MergeParent > 0 {
172 if mergeParentSHA, ok := mapping.GetGitSHA(rev.MergeParent); ok {
173 parents = append(parents, plumbing.NewHash(mergeParentSHA))
174 }
175 }
176
177 /* create commit */
178 commit := &object.Commit{
179 Author: object.Signature{
180 Name: rev.Author,
181 Email: fmt.Sprintf("%s@larc", rev.Author),
182 When: time.Unix(rev.Timestamp, 0),
183 },
184 Committer: object.Signature{
185 Name: rev.Author,
186 Email: fmt.Sprintf("%s@larc", rev.Author),
187 When: time.Unix(rev.Timestamp, 0),
188 },
189 Message: FormatRevisionMessage(rev.Message, rev.Number),
190 TreeHash: rootTreeHash,
191 ParentHashes: parents,
192 }
193
194 /* encode and store commit */
195 commitObj := gitRepo.Storer.NewEncodedObject()
196 commitObj.SetType(plumbing.CommitObject)
197
198 if err := commit.Encode(commitObj); err != nil {
199 return "", 0, fmt.Errorf("encode commit: %w", err)
200 }
201
202 commitHash, err := gitRepo.Storer.SetEncodedObject(commitObj)
203 if err != nil {
204 return "", 0, fmt.Errorf("store commit: %w", err)
205 }
206
207 return commitHash.String(), blobCount, nil
208 }
209
210 // createGitTree recursively creates git tree objects from hierarchical structure
211 func createGitTree(
212 larcRepo *repo.Repository,
213 gitRepo *git.Repository,
214 node *TreeNode,
215 mapping *MappingStore,
216 ) (plumbing.Hash, int, error) {
217 var entries []object.TreeEntry
218 blobCount := 0
219
220 /* sort children for consistent ordering */
221 names := make([]string, 0, len(node.Children))
222 for name := range node.Children {
223 names = append(names, name)
224 }
225 sort.Strings(names)
226
227 for _, name := range names {
228 child := node.Children[name]
229
230 if child.IsDir {
231 /* recurse into directory */
232 subTreeHash, subBlobs, err := createGitTree(larcRepo, gitRepo, child, mapping)
233 if err != nil {
234 return plumbing.ZeroHash, 0, err
235 }
236 blobCount += subBlobs
237
238 entries = append(entries, object.TreeEntry{
239 Name: name,
240 Mode: filemode.Dir,
241 Hash: subTreeHash,
242 })
243 } else {
244 /* convert blob */
245 blobHash, isNew, err := convertBlob(larcRepo, gitRepo, child.BlobSHA, mapping)
246 if err != nil {
247 return plumbing.ZeroHash, 0, fmt.Errorf("convert blob %s: %w", name, err)
248 }
249 if isNew {
250 blobCount++
251 }
252
253 /* determine file mode */
254 mode := filemode.Regular
255 if child.Mode&0111 != 0 {
256 mode = filemode.Executable
257 }
258
259 entries = append(entries, object.TreeEntry{
260 Name: name,
261 Mode: mode,
262 Hash: blobHash,
263 })
264 }
265 }
266
267 /* create tree object */
268 tree := &object.Tree{Entries: entries}
269
270 treeObj := gitRepo.Storer.NewEncodedObject()
271 treeObj.SetType(plumbing.TreeObject)
272
273 if err := tree.Encode(treeObj); err != nil {
274 return plumbing.ZeroHash, 0, fmt.Errorf("encode tree: %w", err)
275 }
276
277 treeHash, err := gitRepo.Storer.SetEncodedObject(treeObj)
278 if err != nil {
279 return plumbing.ZeroHash, 0, fmt.Errorf("store tree: %w", err)
280 }
281
282 return treeHash, blobCount, nil
283 }
284
285 // convertBlob converts a larc blob to git blob
286 func convertBlob(
287 larcRepo *repo.Repository,
288 gitRepo *git.Repository,
289 larcHash string,
290 mapping *MappingStore,
291 ) (plumbing.Hash, bool, error) {
292 /* check if already converted */
293 if gitSHA, ok := mapping.GetGitBlobSHA(larcHash); ok {
294 return plumbing.NewHash(gitSHA), false, nil
295 }
296
297 /* read blob from larc */
298 data, err := larcRepo.Blobs.Read(larcHash)
299 if err != nil {
300 return plumbing.ZeroHash, false, fmt.Errorf("read larc blob: %w", err)
301 }
302
303 /* create git blob object */
304 blobObj := gitRepo.Storer.NewEncodedObject()
305 blobObj.SetType(plumbing.BlobObject)
306 blobObj.SetSize(int64(len(data)))
307
308 writer, err := blobObj.Writer()
309 if err != nil {
310 return plumbing.ZeroHash, false, fmt.Errorf("blob writer: %w", err)
311 }
312
313 if _, err := writer.Write(data); err != nil {
314 writer.Close()
315 return plumbing.ZeroHash, false, fmt.Errorf("write blob: %w", err)
316 }
317 writer.Close()
318
319 blobHash, err := gitRepo.Storer.SetEncodedObject(blobObj)
320 if err != nil {
321 return plumbing.ZeroHash, false, fmt.Errorf("store blob: %w", err)
322 }
323
324 mapping.AddBlobMapping(larcHash, blobHash.String())
325 return blobHash, true, nil
326 }
327
328 // convertBranch creates a git branch reference
329 func convertBranch(
330 gitRepo *git.Repository,
331 branch *core.Branch,
332 mapping *MappingStore,
333 ) error {
334 commitSHA, ok := mapping.GetGitSHA(branch.HeadRev)
335 if !ok {
336 return fmt.Errorf("no commit for revision %d", branch.HeadRev)
337 }
338
339 gitBranchName := ConvertBranchName(branch.Name)
340 refName := plumbing.NewBranchReferenceName(gitBranchName)
341 ref := plumbing.NewHashReference(refName, plumbing.NewHash(commitSHA))
342
343 return gitRepo.Storer.SetReference(ref)
344 }
345
346 // ExportToGit is a convenience function for CLI
347 func ExportToGit(larcPath, gitPath string, verbose bool) error {
348 /* resolve paths */
349 absLarc, err := filepath.Abs(larcPath)
350 if err != nil {
351 return fmt.Errorf("resolve larc path: %w", err)
352 }
353
354 absGit, err := filepath.Abs(gitPath)
355 if err != nil {
356 return fmt.Errorf("resolve git path: %w", err)
357 }
358
359 /* check larc repo exists */
360 if _, err := os.Stat(filepath.Join(absLarc, ".larc")); os.IsNotExist(err) {
361 return fmt.Errorf("not a larc repository: %s", absLarc)
362 }
363
364 /* check git path doesn't exist or is empty */
365 if entries, err := os.ReadDir(absGit); err == nil && len(entries) > 0 {
366 return fmt.Errorf("git path not empty: %s", absGit)
367 }
368
369 opts := Larc2GitOptions{
370 LarcPath: absLarc,
371 GitPath: absGit,
372 Verbose: verbose,
373 }
374
375 result, err := Larc2Git(opts)
376 if err != nil {
377 return err
378 }
379
380 fmt.Printf("Exported larc repository to git:\n")
381 fmt.Printf(" Revisions: %d\n", result.RevisionsConverted)
382 fmt.Printf(" Blobs: %d\n", result.BlobsConverted)
383 fmt.Printf(" Branches: %d\n", result.BranchesConverted)
384
385 return nil
386 }
387