服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
817 lines
22 KiB
Go
817 lines
22 KiB
Go
package version
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
const (
|
|
repositoryFormat = 1
|
|
hashAlgorithm = "sha256"
|
|
mainRef = "main"
|
|
snapshotCacheMax = 4
|
|
)
|
|
|
|
var (
|
|
ErrRepositoryNotFound = errors.New("版本库不存在")
|
|
ErrRepositoryCorrupt = errors.New("版本库损坏")
|
|
)
|
|
|
|
// Repository is a local sidecar version database and immutable object store.
|
|
type Repository struct {
|
|
mu sync.Mutex
|
|
root string
|
|
db *sql.DB
|
|
objects *ObjectStore
|
|
meta Meta
|
|
baseSnapshot Snapshot
|
|
snapshotCache map[string]Snapshot
|
|
snapshotOrder []string
|
|
commitCache map[string]Commit
|
|
}
|
|
|
|
// SidecarPath returns the repository directory for an artifact path.
|
|
func SidecarPath(artifactPath string) string { return artifactPath + ".pvfine" }
|
|
|
|
// Open opens an existing sidecar repository.
|
|
func Open(root string) (*Repository, error) {
|
|
root = filepath.Clean(root)
|
|
if _, err := os.Stat(filepath.Join(root, "repo.db")); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, ErrRepositoryNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
db, err := sql.Open("sqlite", filepath.Join(root, "repo.db"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
repo := &Repository{
|
|
root: root,
|
|
db: db,
|
|
objects: newObjectStore(filepath.Join(root, "objects")),
|
|
snapshotCache: make(map[string]Snapshot),
|
|
commitCache: make(map[string]Commit),
|
|
}
|
|
if err := repo.configure(); err != nil {
|
|
_ = db.Close()
|
|
return nil, err
|
|
}
|
|
if err := repo.loadMeta(); err != nil {
|
|
_ = db.Close()
|
|
return nil, err
|
|
}
|
|
return repo, nil
|
|
}
|
|
|
|
// Init creates a repository with one immutable base snapshot and an initial
|
|
// commit. The caller must pass an unmodified archive snapshot.
|
|
func Init(root, baseFilePath, baseHash string, base Snapshot) (*Repository, error) {
|
|
root = filepath.Clean(root)
|
|
if _, err := os.Stat(filepath.Join(root, "repo.db")); err == nil {
|
|
return nil, fmt.Errorf("版本库已存在: %s", root)
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return nil, err
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(root, "objects"), 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
basePath := filepath.Join(root, "base.pvf")
|
|
if err := copyFileAtomic(baseFilePath, basePath); err != nil {
|
|
return nil, fmt.Errorf("保存版本基线失败: %w", err)
|
|
}
|
|
db, err := sql.Open("sqlite", filepath.Join(root, "repo.db"))
|
|
if err != nil {
|
|
_ = os.Remove(basePath)
|
|
return nil, err
|
|
}
|
|
repo := &Repository{
|
|
root: root,
|
|
db: db,
|
|
objects: newObjectStore(filepath.Join(root, "objects")),
|
|
snapshotCache: make(map[string]Snapshot),
|
|
commitCache: make(map[string]Commit),
|
|
}
|
|
if err := repo.configure(); err != nil {
|
|
_ = db.Close()
|
|
return nil, err
|
|
}
|
|
if err := repo.initialize(baseFilePath, baseHash, base); err != nil {
|
|
_ = db.Close()
|
|
return nil, err
|
|
}
|
|
return repo, nil
|
|
}
|
|
|
|
func (r *Repository) configure() error {
|
|
statements := []string{
|
|
"PRAGMA busy_timeout = 5000",
|
|
"PRAGMA foreign_keys = ON",
|
|
"PRAGMA journal_mode = WAL",
|
|
"PRAGMA synchronous = FULL",
|
|
`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`,
|
|
`CREATE TABLE IF NOT EXISTS refs (
|
|
name TEXT PRIMARY KEY,
|
|
commit_id TEXT NOT NULL
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS commits (
|
|
id TEXT PRIMARY KEY,
|
|
parent_id TEXT,
|
|
message TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
change_count INTEGER NOT NULL
|
|
)`,
|
|
`CREATE TABLE IF NOT EXISTS changes (
|
|
commit_id TEXT NOT NULL,
|
|
sequence INTEGER NOT NULL,
|
|
path TEXT NOT NULL,
|
|
display_path TEXT NOT NULL,
|
|
old_path TEXT NOT NULL DEFAULT '',
|
|
old_display_path TEXT NOT NULL DEFAULT '',
|
|
operation TEXT NOT NULL,
|
|
before_hash TEXT NOT NULL DEFAULT '',
|
|
after_hash TEXT NOT NULL DEFAULT '',
|
|
before_type INTEGER NOT NULL DEFAULT 0,
|
|
after_type INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (commit_id, sequence),
|
|
FOREIGN KEY (commit_id) REFERENCES commits(id) ON DELETE CASCADE
|
|
)`,
|
|
`CREATE INDEX IF NOT EXISTS changes_path_idx ON changes(path, commit_id)`,
|
|
`CREATE TABLE IF NOT EXISTS base_entries (
|
|
path TEXT PRIMARY KEY,
|
|
display_path TEXT NOT NULL,
|
|
data_type INTEGER NOT NULL,
|
|
hash TEXT NOT NULL
|
|
)`,
|
|
}
|
|
for _, statement := range statements {
|
|
if _, err := r.db.Exec(statement); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) initialize(baseFilePath, baseHash string, base Snapshot) error {
|
|
rootID, err := newID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := time.Now().UTC().UnixNano()
|
|
tx, err := r.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rollback := true
|
|
defer func() {
|
|
if rollback {
|
|
_ = tx.Rollback()
|
|
}
|
|
}()
|
|
meta := map[string]string{
|
|
"format": fmt.Sprint(repositoryFormat),
|
|
"base_file": filepath.Base(baseFilePath),
|
|
"base_hash": baseHash,
|
|
"hash_algorithm": hashAlgorithm,
|
|
"artifact_hash": baseHash,
|
|
"artifact_tree_hash": TreeHash(base),
|
|
"artifact_commit_id": rootID,
|
|
}
|
|
for key, value := range meta {
|
|
if _, err := tx.Exec(`INSERT INTO meta(key, value) VALUES(?, ?)`, key, value); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := tx.Exec(
|
|
`INSERT INTO commits(id, parent_id, message, created_at, change_count) VALUES(?, NULL, ?, ?, 0)`,
|
|
rootID, "初始版本", now,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(`INSERT INTO refs(name, commit_id) VALUES(?, ?)`, mainRef, rootID); err != nil {
|
|
return err
|
|
}
|
|
keys := make([]string, 0, len(base))
|
|
for key := range base {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
entry := base[key]
|
|
if _, err := tx.Exec(
|
|
`INSERT INTO base_entries(path, display_path, data_type, hash) VALUES(?, ?, ?, ?)`,
|
|
key, entry.Path, entry.DataType, entry.Hash,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
rollback = false
|
|
r.baseSnapshot = CloneSnapshot(base)
|
|
r.snapshotCache = make(map[string]Snapshot)
|
|
r.snapshotOrder = nil
|
|
r.commitCache = map[string]Commit{
|
|
rootID: {ID: rootID, Message: "初始版本", CreatedAt: now},
|
|
}
|
|
return r.loadMeta()
|
|
}
|
|
|
|
func (r *Repository) loadMeta() error {
|
|
values := make(map[string]string)
|
|
rows, err := r.db.Query(`SELECT key, value FROM meta`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var key, value string
|
|
if err := rows.Scan(&key, &value); err != nil {
|
|
return err
|
|
}
|
|
values[key] = value
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return err
|
|
}
|
|
if values["format"] != "1" || values["hash_algorithm"] != hashAlgorithm || values["base_hash"] == "" {
|
|
return fmt.Errorf("%w:不支持的格式或元数据不完整", ErrRepositoryCorrupt)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(r.root, "base.pvf")); err != nil {
|
|
return fmt.Errorf("%w:基线文件不存在", ErrRepositoryCorrupt)
|
|
}
|
|
r.meta = Meta{
|
|
Format: repositoryFormat,
|
|
BaseFile: values["base_file"],
|
|
BaseHash: values["base_hash"],
|
|
HashAlgorithm: values["hash_algorithm"],
|
|
Artifact: ArtifactState{
|
|
PVFHash: values["artifact_hash"],
|
|
TreeHash: values["artifact_tree_hash"],
|
|
CommitID: values["artifact_commit_id"],
|
|
},
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Close closes the SQLite handle. It is safe to call more than once.
|
|
func (r *Repository) Close() error {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.db == nil {
|
|
return nil
|
|
}
|
|
err := r.db.Close()
|
|
r.db = nil
|
|
r.baseSnapshot = nil
|
|
r.snapshotCache = nil
|
|
r.snapshotOrder = nil
|
|
r.commitCache = nil
|
|
return err
|
|
}
|
|
|
|
func (r *Repository) Root() string { return r.root }
|
|
|
|
func (r *Repository) BasePath() string { return filepath.Join(r.root, "base.pvf") }
|
|
|
|
func (r *Repository) Meta() Meta {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.meta
|
|
}
|
|
|
|
func (r *Repository) Head() (Commit, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.headLocked()
|
|
}
|
|
|
|
// GetCommit returns a commit by ID and verifies that it exists in the
|
|
// repository's commit graph.
|
|
func (r *Repository) GetCommit(id string) (Commit, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if strings.TrimSpace(id) == "" {
|
|
return Commit{}, errors.New("提交 ID 不能为空")
|
|
}
|
|
return r.commitLocked(strings.TrimSpace(id))
|
|
}
|
|
|
|
func (r *Repository) headLocked() (Commit, error) {
|
|
var id string
|
|
if err := r.db.QueryRow(`SELECT commit_id FROM refs WHERE name = ?`, mainRef).Scan(&id); err != nil {
|
|
return Commit{}, err
|
|
}
|
|
return r.commitLocked(id)
|
|
}
|
|
|
|
func (r *Repository) commitLocked(id string) (Commit, error) {
|
|
if commit, ok := r.commitCache[id]; ok {
|
|
return commit, nil
|
|
}
|
|
var commit Commit
|
|
var parent sql.NullString
|
|
if err := r.db.QueryRow(
|
|
`SELECT id, parent_id, message, created_at, change_count FROM commits WHERE id = ?`, id,
|
|
).Scan(&commit.ID, &parent, &commit.Message, &commit.CreatedAt, &commit.ChangeCount); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Commit{}, fmt.Errorf("%w:找不到提交 %s", ErrRepositoryCorrupt, id)
|
|
}
|
|
return Commit{}, err
|
|
}
|
|
if parent.Valid {
|
|
commit.ParentID = parent.String
|
|
}
|
|
if r.commitCache == nil {
|
|
r.commitCache = make(map[string]Commit)
|
|
}
|
|
r.commitCache[id] = commit
|
|
return commit, nil
|
|
}
|
|
|
|
// History walks the main branch from HEAD backwards. Cursor is an offset in
|
|
// that chain rather than a timestamp, which keeps pagination deterministic.
|
|
func (r *Repository) History(cursor, limit int) ([]Commit, int, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if cursor < 0 {
|
|
cursor = 0
|
|
}
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
head, err := r.headLocked()
|
|
if err != nil {
|
|
return nil, -1, err
|
|
}
|
|
chain := make([]Commit, 0)
|
|
current := head
|
|
for {
|
|
chain = append(chain, current)
|
|
if current.ParentID == "" {
|
|
break
|
|
}
|
|
current, err = r.commitLocked(current.ParentID)
|
|
if err != nil {
|
|
return nil, -1, err
|
|
}
|
|
if len(chain) > 1_000_000 {
|
|
return nil, -1, fmt.Errorf("%w:提交链过长或存在循环", ErrRepositoryCorrupt)
|
|
}
|
|
}
|
|
if cursor >= len(chain) {
|
|
return []Commit{}, -1, nil
|
|
}
|
|
end := cursor + limit
|
|
if end > len(chain) {
|
|
end = len(chain)
|
|
}
|
|
result := make([]Commit, end-cursor)
|
|
copy(result, chain[cursor:end])
|
|
next := -1
|
|
if end < len(chain) {
|
|
next = end
|
|
}
|
|
return result, next, nil
|
|
}
|
|
|
|
// Changes returns one commit's compact path changes.
|
|
func (r *Repository) Changes(commitID string) ([]FileChange, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.changesLocked(commitID)
|
|
}
|
|
|
|
// ChangedPathsBetween returns the union of paths changed between two commits
|
|
// on the single main branch. It intentionally returns only identities, so a
|
|
// caller can materialize the target snapshot without scanning every file.
|
|
func (r *Repository) ChangedPathsBetween(fromID, toID string) ([]string, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
fromID = strings.TrimSpace(fromID)
|
|
toID = strings.TrimSpace(toID)
|
|
if fromID == "" || toID == "" {
|
|
return nil, errors.New("起止提交 ID 不能为空")
|
|
}
|
|
if fromID == toID {
|
|
return []string{}, nil
|
|
}
|
|
|
|
fromAncestors := make(map[string]struct{})
|
|
for currentID := fromID; currentID != ""; {
|
|
if _, exists := fromAncestors[currentID]; exists {
|
|
return nil, fmt.Errorf("%w:提交链存在循环", ErrRepositoryCorrupt)
|
|
}
|
|
fromAncestors[currentID] = struct{}{}
|
|
commit, err := r.commitLocked(currentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
currentID = commit.ParentID
|
|
if len(fromAncestors) > 1_000_000 {
|
|
return nil, fmt.Errorf("%w:提交链过长", ErrRepositoryCorrupt)
|
|
}
|
|
}
|
|
|
|
pathSet := make(map[string]struct{})
|
|
addCommitChanges := func(commitID string) error {
|
|
changes, err := r.changesLocked(commitID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, change := range changes {
|
|
pathSet[CanonicalPath(change.Path)] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Walk from the target backwards until the first common ancestor. This
|
|
// covers the target-only side of the interval.
|
|
targetOnly := make([]string, 0)
|
|
commonID := ""
|
|
for currentID := toID; currentID != ""; {
|
|
if _, common := fromAncestors[currentID]; common {
|
|
commonID = currentID
|
|
break
|
|
}
|
|
targetOnly = append(targetOnly, currentID)
|
|
commit, err := r.commitLocked(currentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
currentID = commit.ParentID
|
|
if len(targetOnly) > 1_000_000 {
|
|
return nil, fmt.Errorf("%w:提交链过长", ErrRepositoryCorrupt)
|
|
}
|
|
}
|
|
if commonID == "" {
|
|
return nil, fmt.Errorf("%w:提交不属于同一主分支", ErrRepositoryCorrupt)
|
|
}
|
|
for _, commitID := range targetOnly {
|
|
if err := addCommitChanges(commitID); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Walk from the source back to the common ancestor. These are the source
|
|
// commits that must be undone when moving to the target.
|
|
for currentID := fromID; currentID != commonID; {
|
|
if err := addCommitChanges(currentID); err != nil {
|
|
return nil, err
|
|
}
|
|
commit, err := r.commitLocked(currentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
currentID = commit.ParentID
|
|
if currentID == "" {
|
|
return nil, fmt.Errorf("%w:找不到共同祖先", ErrRepositoryCorrupt)
|
|
}
|
|
}
|
|
|
|
result := make([]string, 0, len(pathSet))
|
|
for path := range pathSet {
|
|
if path != "" {
|
|
result = append(result, path)
|
|
}
|
|
}
|
|
sort.Strings(result)
|
|
return result, nil
|
|
}
|
|
|
|
func (r *Repository) changesLocked(commitID string) ([]FileChange, error) {
|
|
rows, err := r.db.Query(`
|
|
SELECT path, display_path, old_path, old_display_path, operation,
|
|
before_hash, after_hash, before_type, after_type
|
|
FROM changes WHERE commit_id = ? ORDER BY sequence`, commitID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
result := make([]FileChange, 0)
|
|
for rows.Next() {
|
|
var change FileChange
|
|
if err := rows.Scan(
|
|
&change.Path, &change.DisplayPath, &change.OldPath, &change.OldDisplayPath,
|
|
&change.Operation, &change.BeforeHash, &change.AfterHash,
|
|
&change.BeforeType, &change.AfterType,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, change)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// Snapshot reconstructs a commit's logical tree from the base manifest and
|
|
// its parent deltas.
|
|
func (r *Repository) Snapshot(commitID string) (Snapshot, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if commitID == "" {
|
|
commit, err := r.headLocked()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
commitID = commit.ID
|
|
}
|
|
return r.snapshotLocked(commitID)
|
|
}
|
|
|
|
func (r *Repository) baseSnapshotLocked() (Snapshot, error) {
|
|
if r.baseSnapshot != nil {
|
|
return CloneSnapshot(r.baseSnapshot), nil
|
|
}
|
|
rows, err := r.db.Query(`SELECT path, display_path, data_type, hash FROM base_entries ORDER BY path`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
result := make(Snapshot)
|
|
for rows.Next() {
|
|
var key, path, hash string
|
|
var dataType int32
|
|
if err := rows.Scan(&key, &path, &dataType, &hash); err != nil {
|
|
return nil, err
|
|
}
|
|
result[key] = Entry{Path: path, DataType: dataType, Hash: hash}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
r.baseSnapshot = CloneSnapshot(result)
|
|
return result, nil
|
|
}
|
|
|
|
// PutObject stores one after-state payload before its commit transaction.
|
|
func (r *Repository) PutObject(raw []byte) (string, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.objects.Put(raw)
|
|
}
|
|
|
|
func (r *Repository) ReadObject(hash string) ([]byte, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.objects.Read(hash)
|
|
}
|
|
|
|
// Commit appends a commit to main after validating its parent and before
|
|
// hashes. Object writes are immutable; an interrupted database transaction
|
|
// can leave harmless unreachable objects which can be collected later.
|
|
func (r *Repository) Commit(message string, changes []FileChange, objects map[string][]byte) (Commit, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
message = strings.TrimSpace(message)
|
|
if message == "" {
|
|
return Commit{}, errors.New("提交说明不能为空")
|
|
}
|
|
if len(changes) == 0 {
|
|
return Commit{}, errors.New("没有可提交的变更")
|
|
}
|
|
head, err := r.headLocked()
|
|
if err != nil {
|
|
return Commit{}, err
|
|
}
|
|
headSnapshot, err := r.snapshotLocked(head.ID)
|
|
if err != nil {
|
|
return Commit{}, err
|
|
}
|
|
for _, change := range changes {
|
|
if err := validateChange(headSnapshot, change); err != nil {
|
|
return Commit{}, err
|
|
}
|
|
if change.AfterHash != "" {
|
|
raw, ok := objects[change.AfterHash]
|
|
if !ok {
|
|
existing, readErr := r.objects.Read(change.AfterHash)
|
|
if readErr != nil {
|
|
return Commit{}, fmt.Errorf("提交 %q 缺少对象 %s: %w", change.DisplayPath, change.AfterHash, readErr)
|
|
}
|
|
if HashBytes(existing) != change.AfterHash {
|
|
return Commit{}, fmt.Errorf("对象 hash 校验失败: %s", change.AfterHash)
|
|
}
|
|
continue
|
|
}
|
|
if HashBytes(raw) != change.AfterHash {
|
|
return Commit{}, fmt.Errorf("对象 hash 校验失败: %s", change.AfterHash)
|
|
}
|
|
if _, err := r.objects.Put(raw); err != nil {
|
|
return Commit{}, err
|
|
}
|
|
}
|
|
}
|
|
id, err := newID()
|
|
if err != nil {
|
|
return Commit{}, err
|
|
}
|
|
now := time.Now().UTC().UnixNano()
|
|
tx, err := r.db.Begin()
|
|
if err != nil {
|
|
return Commit{}, err
|
|
}
|
|
rollback := true
|
|
defer func() {
|
|
if rollback {
|
|
_ = tx.Rollback()
|
|
}
|
|
}()
|
|
if _, err := tx.Exec(
|
|
`INSERT INTO commits(id, parent_id, message, created_at, change_count) VALUES(?, ?, ?, ?, ?)`,
|
|
id, head.ID, message, now, len(changes),
|
|
); err != nil {
|
|
return Commit{}, err
|
|
}
|
|
for index, change := range changes {
|
|
if _, err := tx.Exec(`
|
|
INSERT INTO changes(
|
|
commit_id, sequence, path, display_path, old_path, old_display_path,
|
|
operation, before_hash, after_hash, before_type, after_type
|
|
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
id, index, CanonicalPath(change.Path), DisplayPath(change.DisplayPath),
|
|
CanonicalPath(change.OldPath), DisplayPath(change.OldDisplayPath),
|
|
string(change.Operation), change.BeforeHash, change.AfterHash,
|
|
change.BeforeType, change.AfterType,
|
|
); err != nil {
|
|
return Commit{}, err
|
|
}
|
|
}
|
|
if _, err := tx.Exec(`UPDATE refs SET commit_id = ? WHERE name = ?`, id, mainRef); err != nil {
|
|
return Commit{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return Commit{}, err
|
|
}
|
|
rollback = false
|
|
commit := Commit{ID: id, ParentID: head.ID, Message: message, CreatedAt: now, ChangeCount: len(changes)}
|
|
if r.commitCache == nil {
|
|
r.commitCache = make(map[string]Commit)
|
|
}
|
|
r.commitCache[id] = commit
|
|
nextSnapshot := CloneSnapshot(headSnapshot)
|
|
for _, change := range changes {
|
|
key := CanonicalPath(change.Path)
|
|
if change.Operation == OperationDelete {
|
|
delete(nextSnapshot, key)
|
|
continue
|
|
}
|
|
nextSnapshot[key] = Entry{
|
|
Path: change.DisplayPath, DataType: change.AfterType, Hash: change.AfterHash,
|
|
}
|
|
}
|
|
if r.snapshotCache == nil {
|
|
r.snapshotCache = make(map[string]Snapshot)
|
|
}
|
|
r.cacheSnapshotLocked(id, nextSnapshot)
|
|
return commit, nil
|
|
}
|
|
|
|
func validateChange(head Snapshot, change FileChange) error {
|
|
key := CanonicalPath(change.Path)
|
|
entry, exists := head[key]
|
|
switch change.Operation {
|
|
case OperationAdd:
|
|
if exists {
|
|
return fmt.Errorf("新增文件已存在: %s", change.DisplayPath)
|
|
}
|
|
if change.AfterHash == "" {
|
|
return fmt.Errorf("新增文件缺少内容: %s", change.DisplayPath)
|
|
}
|
|
case OperationModify:
|
|
if !exists {
|
|
return fmt.Errorf("修改文件不存在: %s", change.DisplayPath)
|
|
}
|
|
if entry.Hash != change.BeforeHash || entry.DataType != change.BeforeType {
|
|
return fmt.Errorf("文件在提交前已变化: %s", change.DisplayPath)
|
|
}
|
|
if change.AfterHash == "" {
|
|
return fmt.Errorf("修改文件缺少新内容: %s", change.DisplayPath)
|
|
}
|
|
case OperationDelete:
|
|
if !exists {
|
|
return fmt.Errorf("删除文件不存在: %s", change.DisplayPath)
|
|
}
|
|
if entry.Hash != change.BeforeHash || entry.DataType != change.BeforeType {
|
|
return fmt.Errorf("文件在删除前已变化: %s", change.DisplayPath)
|
|
}
|
|
default:
|
|
return fmt.Errorf("V1 暂不支持版本操作: %s", change.Operation)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) snapshotLocked(commitID string) (Snapshot, error) {
|
|
if cached, ok := r.snapshotCache[commitID]; ok {
|
|
return CloneSnapshot(cached), nil
|
|
}
|
|
base, err := r.baseSnapshotLocked()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
chain := make([]Commit, 0)
|
|
for currentID := commitID; currentID != ""; {
|
|
commit, err := r.commitLocked(currentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
chain = append(chain, commit)
|
|
currentID = commit.ParentID
|
|
if len(chain) > 1_000_000 {
|
|
return nil, fmt.Errorf("%w:提交链过长或存在循环", ErrRepositoryCorrupt)
|
|
}
|
|
}
|
|
for index := len(chain) - 1; index >= 0; index-- {
|
|
changes, err := r.changesLocked(chain[index].ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, change := range changes {
|
|
key := CanonicalPath(change.Path)
|
|
if change.Operation == OperationDelete {
|
|
delete(base, key)
|
|
continue
|
|
}
|
|
base[key] = Entry{Path: change.DisplayPath, DataType: change.AfterType, Hash: change.AfterHash}
|
|
}
|
|
}
|
|
r.cacheSnapshotLocked(commitID, base)
|
|
return base, nil
|
|
}
|
|
|
|
func (r *Repository) cacheSnapshotLocked(commitID string, snapshot Snapshot) {
|
|
if r.snapshotCache == nil {
|
|
r.snapshotCache = make(map[string]Snapshot)
|
|
}
|
|
if _, exists := r.snapshotCache[commitID]; exists {
|
|
for index, cachedID := range r.snapshotOrder {
|
|
if cachedID == commitID {
|
|
r.snapshotOrder = append(r.snapshotOrder[:index], r.snapshotOrder[index+1:]...)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
r.snapshotCache[commitID] = CloneSnapshot(snapshot)
|
|
r.snapshotOrder = append(r.snapshotOrder, commitID)
|
|
for len(r.snapshotOrder) > snapshotCacheMax {
|
|
oldest := r.snapshotOrder[0]
|
|
r.snapshotOrder = r.snapshotOrder[1:]
|
|
delete(r.snapshotCache, oldest)
|
|
}
|
|
}
|
|
|
|
// SetArtifactState records the last successful PVF save. It is intentionally
|
|
// separate from HEAD so save and commit remain independent operations.
|
|
func (r *Repository) SetArtifactState(state ArtifactState) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
values := map[string]string{
|
|
"artifact_hash": state.PVFHash,
|
|
"artifact_tree_hash": state.TreeHash,
|
|
"artifact_commit_id": state.CommitID,
|
|
}
|
|
tx, err := r.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rollback := true
|
|
defer func() {
|
|
if rollback {
|
|
_ = tx.Rollback()
|
|
}
|
|
}()
|
|
for key, value := range values {
|
|
if _, err := tx.Exec(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
rollback = false
|
|
r.meta.Artifact = state
|
|
return nil
|
|
}
|
|
|
|
func newID() (string, error) {
|
|
var raw [16]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(raw[:]), nil
|
|
}
|