| 1 |
package repo |
| 2 |
|
| 3 |
import ( |
| 4 |
"errors" |
| 5 |
"fmt" |
| 6 |
"os" |
| 7 |
"path/filepath" |
| 8 |
"time" |
| 9 |
|
| 10 |
"github.com/bytedance/sonic" |
| 11 |
|
| 12 |
"github.com/lain/larc/internal/core" |
| 13 |
"github.com/lain/larc/internal/hash" |
| 14 |
"github.com/lain/larc/internal/storage" |
| 15 |
) |
| 16 |
|
| 17 |
/* Repository is the main interface for larc operations. |
| 18 |
* Combines metadata storage (SQLite) with blob storage (files + zstd). |
| 19 |
* Handles init, commit, checkout, and branch operations. */ |
| 20 |
|
| 21 |
const ( |
| 22 |
LarcDir = ".larc" |
| 23 |
ConfigFile = "config.yaml" |
| 24 |
DBFile = "larc.db" |
| 25 |
ObjectsDir = "objects" |
| 26 |
TreesDir = "trees" |
| 27 |
BranchFile = "branch" |
| 28 |
RevisionFile = "revision" |
| 29 |
RemoteFile = "remote" |
| 30 |
) |
| 31 |
|
| 32 |
var ( |
| 33 |
ErrNotARepo = errors.New("not a larc repository") |
| 34 |
ErrAlreadyExists = errors.New("repository already exists") |
| 35 |
ErrNoChanges = errors.New("no changes to commit") |
| 36 |
) |
| 37 |
|
| 38 |
// Config is the repository configuration stored in config.yaml |
| 39 |
type Config struct { |
| 40 |
Name string `yaml:"name" json:"name"` |
| 41 |
DefaultBranch string `yaml:"default_branch" json:"default_branch"` |
| 42 |
CompressionLevel int `yaml:"compression" json:"compression"` |
| 43 |
} |
| 44 |
|
| 45 |
// Repository represents a larc repository |
| 46 |
type Repository struct { |
| 47 |
Root string // working directory root |
| 48 |
Dir string // .larc directory path |
| 49 |
Config *Config // repository configuration |
| 50 |
Meta *storage.MetaStore // SQLite metadata |
| 51 |
Blobs *storage.BlobStore // blob storage |
| 52 |
} |
| 53 |
|
| 54 |
// Open opens an existing repository |
| 55 |
func Open(path string) (*Repository, error) { |
| 56 |
larcDir := filepath.Join(path, LarcDir) |
| 57 |
|
| 58 |
if _, err := os.Stat(larcDir); os.IsNotExist(err) { |
| 59 |
return nil, ErrNotARepo |
| 60 |
} |
| 61 |
|
| 62 |
meta, err := storage.NewMetaStore(filepath.Join(larcDir, DBFile)) |
| 63 |
if err != nil { |
| 64 |
return nil, fmt.Errorf("open meta store: %w", err) |
| 65 |
} |
| 66 |
|
| 67 |
blobs, err := storage.NewBlobStore(filepath.Join(larcDir, ObjectsDir)) |
| 68 |
if err != nil { |
| 69 |
meta.Close() |
| 70 |
return nil, fmt.Errorf("open blob store: %w", err) |
| 71 |
} |
| 72 |
|
| 73 |
repo := &Repository{ |
| 74 |
Root: path, |
| 75 |
Dir: larcDir, |
| 76 |
Meta: meta, |
| 77 |
Blobs: blobs, |
| 78 |
Config: &Config{DefaultBranch: core.DefaultBranchName}, |
| 79 |
} |
| 80 |
|
| 81 |
return repo, nil |
| 82 |
} |
| 83 |
|
| 84 |
// Init creates a new repository |
| 85 |
func Init(path string) (*Repository, error) { |
| 86 |
larcDir := filepath.Join(path, LarcDir) |
| 87 |
|
| 88 |
if _, err := os.Stat(larcDir); err == nil { |
| 89 |
return nil, ErrAlreadyExists |
| 90 |
} |
| 91 |
|
| 92 |
/* create directory structure */ |
| 93 |
dirs := []string{ |
| 94 |
larcDir, |
| 95 |
filepath.Join(larcDir, ObjectsDir), |
| 96 |
filepath.Join(larcDir, TreesDir), |
| 97 |
} |
| 98 |
|
| 99 |
for _, dir := range dirs { |
| 100 |
if err := os.MkdirAll(dir, 0755); err != nil { |
| 101 |
return nil, fmt.Errorf("create dir %s: %w", dir, err) |
| 102 |
} |
| 103 |
} |
| 104 |
|
| 105 |
/* init meta store */ |
| 106 |
meta, err := storage.NewMetaStore(filepath.Join(larcDir, DBFile)) |
| 107 |
if err != nil { |
| 108 |
return nil, fmt.Errorf("init meta store: %w", err) |
| 109 |
} |
| 110 |
|
| 111 |
if err := meta.InitRepo(); err != nil { |
| 112 |
meta.Close() |
| 113 |
return nil, fmt.Errorf("init repo: %w", err) |
| 114 |
} |
| 115 |
|
| 116 |
/* init blob store */ |
| 117 |
blobs, err := storage.NewBlobStore(filepath.Join(larcDir, ObjectsDir)) |
| 118 |
if err != nil { |
| 119 |
meta.Close() |
| 120 |
return nil, fmt.Errorf("init blob store: %w", err) |
| 121 |
} |
| 122 |
|
| 123 |
/* write current branch */ |
| 124 |
if err := os.WriteFile(filepath.Join(larcDir, BranchFile), []byte(core.DefaultBranchName), 0644); err != nil { |
| 125 |
meta.Close() |
| 126 |
return nil, fmt.Errorf("write branch file: %w", err) |
| 127 |
} |
| 128 |
|
| 129 |
/* write current revision (0 = no commits yet) */ |
| 130 |
if err := os.WriteFile(filepath.Join(larcDir, RevisionFile), []byte("0"), 0644); err != nil { |
| 131 |
meta.Close() |
| 132 |
return nil, fmt.Errorf("write revision file: %w", err) |
| 133 |
} |
| 134 |
|
| 135 |
repo := &Repository{ |
| 136 |
Root: path, |
| 137 |
Dir: larcDir, |
| 138 |
Meta: meta, |
| 139 |
Blobs: blobs, |
| 140 |
Config: &Config{DefaultBranch: core.DefaultBranchName, CompressionLevel: storage.DefaultCompressionLevel}, |
| 141 |
} |
| 142 |
|
| 143 |
return repo, nil |
| 144 |
} |
| 145 |
|
| 146 |
// Close closes the repository |
| 147 |
func (r *Repository) Close() error { |
| 148 |
return r.Meta.Close() |
| 149 |
} |
| 150 |
|
| 151 |
// CurrentBranch returns the current branch name |
| 152 |
func (r *Repository) CurrentBranch() (string, error) { |
| 153 |
data, err := os.ReadFile(filepath.Join(r.Dir, BranchFile)) |
| 154 |
if err != nil { |
| 155 |
return "", fmt.Errorf("read branch file: %w", err) |
| 156 |
} |
| 157 |
return string(data), nil |
| 158 |
} |
| 159 |
|
| 160 |
// SetCurrentBranch sets the current branch |
| 161 |
func (r *Repository) SetCurrentBranch(name string) error { |
| 162 |
return os.WriteFile(filepath.Join(r.Dir, BranchFile), []byte(name), 0644) |
| 163 |
} |
| 164 |
|
| 165 |
// CurrentRevision returns the current revision number |
| 166 |
func (r *Repository) CurrentRevision() (int64, error) { |
| 167 |
data, err := os.ReadFile(filepath.Join(r.Dir, RevisionFile)) |
| 168 |
if err != nil { |
| 169 |
return 0, fmt.Errorf("read revision file: %w", err) |
| 170 |
} |
| 171 |
|
| 172 |
var rev int64 |
| 173 |
fmt.Sscanf(string(data), "%d", &rev) |
| 174 |
return rev, nil |
| 175 |
} |
| 176 |
|
| 177 |
// SetCurrentRevision sets the current revision |
| 178 |
func (r *Repository) SetCurrentRevision(rev int64) error { |
| 179 |
return os.WriteFile(filepath.Join(r.Dir, RevisionFile), []byte(fmt.Sprintf("%d", rev)), 0644) |
| 180 |
} |
| 181 |
|
| 182 |
// Commit creates a new revision from the given entries |
| 183 |
func (r *Repository) Commit(author, message string, entries []core.TreeEntry) (*core.Revision, error) { |
| 184 |
if len(entries) == 0 { |
| 185 |
return nil, ErrNoChanges |
| 186 |
} |
| 187 |
|
| 188 |
branch, err := r.CurrentBranch() |
| 189 |
if err != nil { |
| 190 |
return nil, err |
| 191 |
} |
| 192 |
|
| 193 |
parent, err := r.CurrentRevision() |
| 194 |
if err != nil { |
| 195 |
return nil, err |
| 196 |
} |
| 197 |
|
| 198 |
/* create tree */ |
| 199 |
tree := &core.Tree{Entries: entries} |
| 200 |
treeData, err := sonic.Marshal(tree) |
| 201 |
if err != nil { |
| 202 |
return nil, fmt.Errorf("marshal tree: %w", err) |
| 203 |
} |
| 204 |
tree.Hash = hash.Bytes(treeData) |
| 205 |
|
| 206 |
/* store tree */ |
| 207 |
if err := r.Meta.StoreTree(tree.Hash, treeData); err != nil { |
| 208 |
return nil, fmt.Errorf("store tree: %w", err) |
| 209 |
} |
| 210 |
|
| 211 |
/* get next revision number */ |
| 212 |
revNum, err := r.Meta.NextRevision() |
| 213 |
if err != nil { |
| 214 |
return nil, fmt.Errorf("get next revision: %w", err) |
| 215 |
} |
| 216 |
|
| 217 |
/* create revision */ |
| 218 |
rev := &core.Revision{ |
| 219 |
Number: revNum, |
| 220 |
Timestamp: time.Now().Unix(), |
| 221 |
Author: author, |
| 222 |
Message: message, |
| 223 |
Branch: branch, |
| 224 |
Parent: parent, |
| 225 |
TreeHash: tree.Hash, |
| 226 |
} |
| 227 |
|
| 228 |
if err := r.Meta.CreateRevision(rev); err != nil { |
| 229 |
return nil, fmt.Errorf("create revision: %w", err) |
| 230 |
} |
| 231 |
|
| 232 |
/* update branch head */ |
| 233 |
if err := r.Meta.UpdateBranchHead(branch, revNum); err != nil { |
| 234 |
return nil, fmt.Errorf("update branch head: %w", err) |
| 235 |
} |
| 236 |
|
| 237 |
/* update current revision */ |
| 238 |
if err := r.SetCurrentRevision(revNum); err != nil { |
| 239 |
return nil, fmt.Errorf("set current revision: %w", err) |
| 240 |
} |
| 241 |
|
| 242 |
return rev, nil |
| 243 |
} |
| 244 |
|
| 245 |
// GetTree retrieves and parses a tree by hash |
| 246 |
func (r *Repository) GetTree(h string) (*core.Tree, error) { |
| 247 |
data, err := r.Meta.GetTree(h) |
| 248 |
if err != nil { |
| 249 |
return nil, err |
| 250 |
} |
| 251 |
|
| 252 |
var tree core.Tree |
| 253 |
if err := sonic.Unmarshal(data, &tree); err != nil { |
| 254 |
return nil, fmt.Errorf("unmarshal tree: %w", err) |
| 255 |
} |
| 256 |
tree.Hash = h |
| 257 |
|
| 258 |
return &tree, nil |
| 259 |
} |
| 260 |
|
| 261 |
// FindRepoRoot walks up from path to find repository root |
| 262 |
func FindRepoRoot(path string) (string, error) { |
| 263 |
absPath, err := filepath.Abs(path) |
| 264 |
if err != nil { |
| 265 |
return "", err |
| 266 |
} |
| 267 |
|
| 268 |
for { |
| 269 |
larcDir := filepath.Join(absPath, LarcDir) |
| 270 |
if _, err := os.Stat(larcDir); err == nil { |
| 271 |
return absPath, nil |
| 272 |
} |
| 273 |
|
| 274 |
parent := filepath.Dir(absPath) |
| 275 |
if parent == absPath { |
| 276 |
return "", ErrNotARepo |
| 277 |
} |
| 278 |
absPath = parent |
| 279 |
} |
| 280 |
} |
| 281 |
|