| 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 |
"github.com/lain/larc/internal/core" |
| 17 |
"github.com/lain/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 |
|