DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,349 @@
|
||||
// Package version implements pvfine's local, logical-file version store.
|
||||
//
|
||||
// A version repository stores PVF entry payloads and paths. The packed PVF
|
||||
// file remains an export artifact and is never used as a binary diff target.
|
||||
package version
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"pvfine/internal/pvf"
|
||||
)
|
||||
|
||||
// Operation describes how a path changed between two logical snapshots.
|
||||
type Operation string
|
||||
|
||||
const (
|
||||
OperationAdd Operation = "add"
|
||||
OperationModify Operation = "modify"
|
||||
OperationDelete Operation = "delete"
|
||||
OperationRename Operation = "rename"
|
||||
)
|
||||
|
||||
// Entry is the versioned state of one logical PVF file.
|
||||
type Entry struct {
|
||||
Path string
|
||||
DataType int32
|
||||
Hash string
|
||||
}
|
||||
|
||||
// Snapshot is keyed by a canonical, case-insensitive PVF path.
|
||||
type Snapshot map[string]Entry
|
||||
|
||||
// Content is an entry together with its logical payload. Script and Unicode
|
||||
// files use stable canonical rendered text because script token bytes refer to
|
||||
// PVF string pools; unknown types retain their raw bytes. It is used by the
|
||||
// in-memory undo stack, while committed content is wrapped and stored in
|
||||
// ObjectStore.
|
||||
type Content struct {
|
||||
Entry
|
||||
Raw []byte
|
||||
}
|
||||
|
||||
// ContentSnapshot contains only the paths affected by one mutation.
|
||||
type ContentSnapshot map[string]Content
|
||||
|
||||
// FileChange is the compact change record stored in a commit.
|
||||
type FileChange struct {
|
||||
Path string
|
||||
DisplayPath string
|
||||
OldPath string
|
||||
OldDisplayPath string
|
||||
Operation Operation
|
||||
BeforeHash string
|
||||
AfterHash string
|
||||
BeforeType int32
|
||||
AfterType int32
|
||||
}
|
||||
|
||||
// Commit is the public repository commit model.
|
||||
type Commit struct {
|
||||
ID string
|
||||
ParentID string
|
||||
Message string
|
||||
CreatedAt int64
|
||||
ChangeCount int
|
||||
}
|
||||
|
||||
// ArtifactState identifies the last PVF artifact written by the editor.
|
||||
// ArtifactCommitID is the HEAD at the time of that save; it may differ from
|
||||
// the repository's current HEAD when a commit was created without saving.
|
||||
type ArtifactState struct {
|
||||
PVFHash string
|
||||
TreeHash string
|
||||
CommitID string
|
||||
}
|
||||
|
||||
// Meta describes repository compatibility and recovery information.
|
||||
type Meta struct {
|
||||
Format int
|
||||
BaseFile string
|
||||
BaseHash string
|
||||
HashAlgorithm string
|
||||
Artifact ArtifactState
|
||||
}
|
||||
|
||||
// CanonicalPath returns the path identity used by the repository. PVF path
|
||||
// lookup is case-insensitive, so case-only differences are intentionally not
|
||||
// treated as distinct files in V1.
|
||||
func CanonicalPath(path string) string {
|
||||
path = strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")
|
||||
path = strings.Trim(path, "/")
|
||||
return strings.ToLower(path)
|
||||
}
|
||||
|
||||
// DisplayPath normalizes separators while retaining the original casing for
|
||||
// UI display and newly-created archive entries.
|
||||
func DisplayPath(path string) string {
|
||||
return strings.Trim(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"), "/")
|
||||
}
|
||||
|
||||
// HashBytes returns the V1 content hash format used in the object store.
|
||||
func HashBytes(raw []byte) string {
|
||||
sum := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// EncodeContent makes an object self-describing. The four-byte type prefix is
|
||||
// part of the hashed object, so identical text in different PVF data types is
|
||||
// never confused during checkout.
|
||||
func EncodeContent(dataType int32, data []byte) []byte {
|
||||
result := make([]byte, 4+len(data))
|
||||
binary.LittleEndian.PutUint32(result, uint32(dataType))
|
||||
copy(result[4:], data)
|
||||
return result
|
||||
}
|
||||
|
||||
// DecodeContent validates and unwraps an object payload.
|
||||
func DecodeContent(raw []byte) (int32, []byte, error) {
|
||||
if len(raw) < 4 {
|
||||
return 0, nil, fmt.Errorf("版本对象内容过短")
|
||||
}
|
||||
return int32(binary.LittleEndian.Uint32(raw)), append([]byte(nil), raw[4:]...), nil
|
||||
}
|
||||
|
||||
func contentFromArchive(a *pvf.Archive, index int32) (Content, error) {
|
||||
file := a.File(index)
|
||||
var data []byte
|
||||
var err error
|
||||
switch file.DataType {
|
||||
case pvf.TypeScript, pvf.TypeUnicode:
|
||||
var text string
|
||||
text, err = a.CanonicalText(index)
|
||||
data = []byte(text)
|
||||
default:
|
||||
var raw []byte
|
||||
raw, err = a.RawBytes(index)
|
||||
data = append([]byte(nil), raw...)
|
||||
}
|
||||
if err != nil {
|
||||
return Content{}, err
|
||||
}
|
||||
return Content{
|
||||
Entry: Entry{Path: DisplayPath(a.Path(index)), DataType: file.DataType},
|
||||
Raw: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func finalizeContent(content Content) Content {
|
||||
content.Entry.Hash = HashBytes(EncodeContent(content.DataType, content.Raw))
|
||||
content.Raw = append([]byte(nil), content.Raw...)
|
||||
return content
|
||||
}
|
||||
|
||||
// HashFile hashes a packed PVF without retaining a second copy in memory.
|
||||
func HashFile(path string) (string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// SnapshotFromArchive reads raw payloads from an archive and returns a
|
||||
// logical snapshot. It rejects duplicate canonical paths because an index is
|
||||
// not a stable identity across PVF structural edits.
|
||||
func SnapshotFromArchive(a *pvf.Archive) (Snapshot, error) {
|
||||
if a == nil {
|
||||
return nil, fmt.Errorf("版本快照的归档为空")
|
||||
}
|
||||
result := make(Snapshot, a.FileCount())
|
||||
for index := int32(0); index < a.FileCount(); index++ {
|
||||
path := DisplayPath(a.Path(index))
|
||||
key := CanonicalPath(path)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("文件 %d 的路径为空,无法建立版本身份", index)
|
||||
}
|
||||
if _, exists := result[key]; exists {
|
||||
return nil, fmt.Errorf("归档包含重复路径 %q, V1 版本控制暂不支持", path)
|
||||
}
|
||||
content, err := contentFromArchive(a, index)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 %q 失败: %w", path, err)
|
||||
}
|
||||
content.Entry.Path = path
|
||||
result[key] = finalizeContent(content).Entry
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ContentSnapshotFromArchive captures selected paths for an undoable
|
||||
// mutation. Missing paths are intentionally omitted.
|
||||
func ContentSnapshotFromArchive(a *pvf.Archive, paths []string) (ContentSnapshot, error) {
|
||||
if a == nil {
|
||||
return nil, fmt.Errorf("版本快照的归档为空")
|
||||
}
|
||||
result := make(ContentSnapshot, len(paths))
|
||||
seen := make(map[string]struct{}, len(paths))
|
||||
for _, rawPath := range paths {
|
||||
key := CanonicalPath(rawPath)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, done := seen[key]; done {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
index, ok := a.Find(key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
path := DisplayPath(a.Path(index))
|
||||
content, err := contentFromArchive(a, index)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 %q 失败: %w", path, err)
|
||||
}
|
||||
content.Entry.Path = path
|
||||
result[key] = finalizeContent(content)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ContentObject returns the self-describing object bytes for a content value.
|
||||
func ContentObject(content Content) []byte {
|
||||
return EncodeContent(content.DataType, content.Raw)
|
||||
}
|
||||
|
||||
// SameContent reports whether two content values represent the same logical
|
||||
// object, including the PVF data type.
|
||||
func SameContent(left, right Content) bool {
|
||||
return left.DataType == right.DataType && bytes.Equal(left.Raw, right.Raw)
|
||||
}
|
||||
|
||||
// SnapshotFromContent converts a partial content snapshot to a regular
|
||||
// snapshot, preserving only present entries.
|
||||
func SnapshotFromContent(values ContentSnapshot) Snapshot {
|
||||
result := make(Snapshot, len(values))
|
||||
for key, value := range values {
|
||||
result[key] = value.Entry
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// CloneSnapshot makes a detached snapshot copy.
|
||||
func CloneSnapshot(values Snapshot) Snapshot {
|
||||
result := make(Snapshot, len(values))
|
||||
for key, value := range values {
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// CloneContentSnapshot makes a detached content snapshot copy.
|
||||
func CloneContentSnapshot(values ContentSnapshot) ContentSnapshot {
|
||||
result := make(ContentSnapshot, len(values))
|
||||
for key, value := range values {
|
||||
result[key] = Content{Entry: value.Entry, Raw: append([]byte(nil), value.Raw...)}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// TreeHash is a deterministic hash of a logical snapshot, independent of
|
||||
// map iteration order and PVF binary layout.
|
||||
func TreeHash(values Snapshot) string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
h := sha256.New()
|
||||
for _, key := range keys {
|
||||
entry := values[key]
|
||||
// Length-prefix strings so unusual PVF paths cannot create ambiguous
|
||||
// concatenations.
|
||||
_, _ = h.Write([]byte(strconv.Itoa(len(key))))
|
||||
_, _ = h.Write([]byte(":"))
|
||||
_, _ = h.Write([]byte(key))
|
||||
_, _ = h.Write([]byte(":"))
|
||||
_, _ = h.Write([]byte(strconv.FormatInt(int64(entry.DataType), 10)))
|
||||
_, _ = h.Write([]byte(":"))
|
||||
_, _ = h.Write([]byte(entry.Hash))
|
||||
_, _ = h.Write([]byte("\n"))
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// Diff returns deterministic path-level changes from before to after.
|
||||
func Diff(before, after Snapshot) []FileChange {
|
||||
keys := make(map[string]struct{}, len(before)+len(after))
|
||||
for key := range before {
|
||||
keys[key] = struct{}{}
|
||||
}
|
||||
for key := range after {
|
||||
keys[key] = struct{}{}
|
||||
}
|
||||
ordered := make([]string, 0, len(keys))
|
||||
for key := range keys {
|
||||
ordered = append(ordered, key)
|
||||
}
|
||||
sort.Strings(ordered)
|
||||
|
||||
changes := make([]FileChange, 0, len(ordered))
|
||||
for _, key := range ordered {
|
||||
oldEntry, hadOld := before[key]
|
||||
newEntry, hasNew := after[key]
|
||||
switch {
|
||||
case !hadOld && hasNew:
|
||||
changes = append(changes, FileChange{
|
||||
Path: key,
|
||||
DisplayPath: newEntry.Path,
|
||||
Operation: OperationAdd,
|
||||
AfterHash: newEntry.Hash,
|
||||
AfterType: newEntry.DataType,
|
||||
})
|
||||
case hadOld && !hasNew:
|
||||
changes = append(changes, FileChange{
|
||||
Path: key,
|
||||
DisplayPath: oldEntry.Path,
|
||||
Operation: OperationDelete,
|
||||
BeforeHash: oldEntry.Hash,
|
||||
BeforeType: oldEntry.DataType,
|
||||
})
|
||||
case hadOld && hasNew && (oldEntry.Hash != newEntry.Hash || oldEntry.DataType != newEntry.DataType):
|
||||
changes = append(changes, FileChange{
|
||||
Path: key,
|
||||
DisplayPath: newEntry.Path,
|
||||
Operation: OperationModify,
|
||||
BeforeHash: oldEntry.Hash,
|
||||
AfterHash: newEntry.Hash,
|
||||
BeforeType: oldEntry.DataType,
|
||||
AfterType: newEntry.DataType,
|
||||
})
|
||||
}
|
||||
}
|
||||
return changes
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ObjectStore stores raw logical file payloads by SHA-256.
|
||||
type ObjectStore struct {
|
||||
root string
|
||||
}
|
||||
|
||||
func newObjectStore(root string) *ObjectStore { return &ObjectStore{root: root} }
|
||||
|
||||
// Put atomically writes raw content and returns its content hash. Existing
|
||||
// objects are immutable and are reused.
|
||||
func (s *ObjectStore) Put(raw []byte) (string, error) {
|
||||
if s == nil {
|
||||
return "", errors.New("对象存储为空")
|
||||
}
|
||||
hash := HashBytes(raw)
|
||||
target, err := s.pathFor(hash)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
return hash, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(target), ".object-*.tmp")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
removeTemp := true
|
||||
defer func() {
|
||||
if removeTemp {
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
if _, err := tmp.Write(raw); err != nil {
|
||||
_ = tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmpPath, target); err != nil {
|
||||
// Another writer may have installed the immutable object first.
|
||||
if _, statErr := os.Stat(target); statErr == nil {
|
||||
return hash, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
removeTemp = false
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
// Read returns an immutable object payload.
|
||||
func (s *ObjectStore) Read(hash string) ([]byte, error) {
|
||||
path, err := s.pathFor(hash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
func (s *ObjectStore) pathFor(hash string) (string, error) {
|
||||
hash = strings.ToLower(strings.TrimSpace(hash))
|
||||
if len(hash) != 64 {
|
||||
return "", fmt.Errorf("对象 hash 长度无效: %q", hash)
|
||||
}
|
||||
if _, err := hex.DecodeString(hash); err != nil {
|
||||
return "", fmt.Errorf("对象 hash 无效: %w", err)
|
||||
}
|
||||
return filepath.Join(s.root, hash[:2], hash[2:]), nil
|
||||
}
|
||||
|
||||
func copyFileAtomic(source, target string) error {
|
||||
input, err := os.Open(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer input.Close()
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(target), ".copy-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
removeTemp := true
|
||||
defer func() {
|
||||
if removeTemp {
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
if _, err := io.Copy(tmp, input); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, target); err != nil {
|
||||
return err
|
||||
}
|
||||
removeTemp = false
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"pvfine/internal/pvf"
|
||||
"pvfine/internal/rendering"
|
||||
)
|
||||
|
||||
func TestSnapshotHashIgnoresPresentationRenderer(t *testing.T) {
|
||||
a := pvf.New()
|
||||
index, err := a.AddFileText("skills/test.skl", "[records]\n1 2 3 4 5\n[/records]", pvf.TypeScript)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := ContentSnapshotFromArchive(a, []string{a.Path(index)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
engine, err := rendering.Parse([]byte(`{
|
||||
"version": 1,
|
||||
"rules": [{
|
||||
"id": "skl.records",
|
||||
"match": {"extensions": [".skl"]},
|
||||
"target": {"kind": "section", "section": "records"},
|
||||
"format": {"tokensPerLine": 2}
|
||||
}]
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.SetScriptRenderer(engine)
|
||||
after, err := ContentSnapshotFromArchive(a, []string{a.Path(index)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := CanonicalPath(a.Path(index))
|
||||
if before[key].Hash != after[key].Hash {
|
||||
t.Fatalf("snapshot hash changed after presentation override: before=%s after=%s", before[key].Hash, after[key].Hash)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,816 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"pvfine/internal/pvf"
|
||||
)
|
||||
|
||||
func makeVersionFixture(t *testing.T) (string, *pvf.Archive, Snapshot) {
|
||||
t.Helper()
|
||||
a := pvf.New()
|
||||
if _, err := a.AddFileText("skill/a.stk", "[name]\n`初始`", pvf.TypeScript); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.AddFileText("text/name.str", "初始名称", pvf.TypeUnicode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "Script.pvf")
|
||||
if err := a.SaveAs(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := pvf.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := SnapshotFromArchive(parsed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path, parsed, snapshot
|
||||
}
|
||||
|
||||
func TestRepositoryCommitReopenAndMaterialize(t *testing.T) {
|
||||
basePath, archive, base := makeVersionFixture(t)
|
||||
baseHash, err := HashFile(basePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := SidecarPath(basePath)
|
||||
repo, err := Init(root, basePath, baseHash, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rootCommit, err := repo.Head()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
index, ok := archive.Find("skill/a.stk")
|
||||
if !ok {
|
||||
t.Fatal("fixture file missing")
|
||||
}
|
||||
if err := archive.SetText(index, "[name]\n`修改后`"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := SnapshotFromArchive(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changes := Diff(base, after)
|
||||
if len(changes) != 1 || changes[0].Operation != OperationModify {
|
||||
t.Fatalf("changes = %#v", changes)
|
||||
}
|
||||
content, err := ContentSnapshotFromArchive(archive, []string{"skill/a.stk"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
objects := map[string][]byte{}
|
||||
for _, value := range content {
|
||||
objects[value.Hash] = ContentObject(value)
|
||||
}
|
||||
commit, err := repo.Commit("修改技能名称", changes, objects)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if commit.ParentID == "" || commit.ChangeCount != 1 {
|
||||
t.Fatalf("commit = %#v", commit)
|
||||
}
|
||||
paths, err := repo.ChangedPathsBetween(rootCommit.ID, commit.ID)
|
||||
if err != nil || len(paths) != 1 || paths[0] != "skill/a.stk" {
|
||||
t.Fatalf("changed paths forward = %#v err=%v", paths, err)
|
||||
}
|
||||
paths, err = repo.ChangedPathsBetween(commit.ID, rootCommit.ID)
|
||||
if err != nil || len(paths) != 1 || paths[0] != "skill/a.stk" {
|
||||
t.Fatalf("changed paths reverse = %#v err=%v", paths, err)
|
||||
}
|
||||
|
||||
materialized, err := repo.Snapshot(commit.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if materialized["skill/a.stk"].Hash != after["skill/a.stk"].Hash {
|
||||
t.Fatalf("materialized = %#v after = %#v", materialized, after)
|
||||
}
|
||||
if materialized["text/name.str"].Hash != base["text/name.str"].Hash {
|
||||
t.Fatalf("unchanged file was changed: %#v", materialized["text/name.str"])
|
||||
}
|
||||
if err := repo.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reopened, err := Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
head, err := reopened.Head()
|
||||
if err != nil || head.ID != commit.ID {
|
||||
t.Fatalf("head = %#v err=%v", head, err)
|
||||
}
|
||||
history, next, err := reopened.History(0, 10)
|
||||
if err != nil || next >= 0 || len(history) != 2 {
|
||||
t.Fatalf("history = %#v next=%d err=%v", history, next, err)
|
||||
}
|
||||
got, err := reopened.Snapshot("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if TreeHash(got) != TreeHash(after) {
|
||||
t.Fatalf("reopened tree hash = %s want %s", TreeHash(got), TreeHash(after))
|
||||
}
|
||||
}
|
||||
|
||||
func TestObjectStoreDeduplicatesAndRejectsInvalidHash(t *testing.T) {
|
||||
store := newObjectStore(filepath.Join(t.TempDir(), "objects"))
|
||||
raw := []byte("object")
|
||||
first, err := store.Put(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := store.Put(raw)
|
||||
if err != nil || first != second {
|
||||
t.Fatalf("duplicate object = %q %q err=%v", first, second, err)
|
||||
}
|
||||
got, err := store.Read(first)
|
||||
if err != nil || !bytes.Equal(got, raw) {
|
||||
t.Fatalf("read object = %q err=%v", got, err)
|
||||
}
|
||||
if _, err := store.Read("not-a-hash"); err == nil {
|
||||
t.Fatal("invalid hash unexpectedly accepted")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(store.root, first[:2], first[2:])); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user