Files
90CNServer/pvf/services/version.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

1776 lines
51 KiB
Go

package services
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/wailsapp/wails/v3/pkg/application"
"pvfine/internal/pvf"
pvfversion "pvfine/internal/version"
)
var (
ErrVersionNotEnabled = errors.New("当前归档尚未初始化版本库")
ErrVersionLoading = errors.New("版本控制正在后台加载,请稍候")
ErrVersionArtifactChanged = errors.New("版本库关联的 PVF 产物已被外部修改,请先处理后再继续")
)
const (
VersionOperationAdd = string(pvfversion.OperationAdd)
VersionOperationModify = string(pvfversion.OperationModify)
VersionOperationDelete = string(pvfversion.OperationDelete)
)
// VersionService exposes local logical-file version control to the Wails
// frontend.
type VersionService struct{ c *core }
func NewVersionService(c *core) *VersionService { return &VersionService{c: c} }
// VersionStatus is the UI-facing version session state.
type VersionStatus struct {
Enabled bool `json:"enabled"`
Loading bool `json:"loading"`
RepositoryPath string `json:"repositoryPath"`
Branch string `json:"branch"`
HeadID string `json:"headId"`
HeadMessage string `json:"headMessage"`
ChangedFiles int `json:"changedFiles"`
PendingChangeSets int `json:"pendingChangeSets"`
NeedsSave bool `json:"needsSave"`
ViewCommitID string `json:"viewCommitId"`
Error string `json:"error"`
}
// VersionChange is a path-level worktree or commit change.
type VersionChange struct {
Path string `json:"path"`
Operation string `json:"operation"`
BeforeHash string `json:"beforeHash,omitempty"`
AfterHash string `json:"afterHash,omitempty"`
BeforeType int32 `json:"beforeType,omitempty"`
AfterType int32 `json:"afterType,omitempty"`
}
type VersionChangePage struct {
Changes []*VersionChange `json:"changes"`
NextCursor int `json:"nextCursor"`
Total int `json:"total"`
}
type VersionCommit struct {
ID string `json:"id"`
ParentID string `json:"parentId"`
Message string `json:"message"`
CreatedAt int64 `json:"createdAt"`
ChangeCount int `json:"changeCount"`
}
type VersionHistoryPage struct {
Commits []*VersionCommit `json:"commits"`
NextCursor int `json:"nextCursor"`
}
// VersionFileDiff contains one commit change and its logical text values
// when the underlying PVF type is editable.
type VersionFileDiff struct {
Path string `json:"path"`
Operation string `json:"operation"`
BeforeHash string `json:"beforeHash,omitempty"`
AfterHash string `json:"afterHash,omitempty"`
BeforeType int32 `json:"beforeType,omitempty"`
AfterType int32 `json:"afterType,omitempty"`
BeforeText string `json:"beforeText,omitempty"`
AfterText string `json:"afterText,omitempty"`
TextAvailable bool `json:"textAvailable"`
}
type versionUndoRecord struct {
Label string
Before pvfversion.ContentSnapshot
After pvfversion.ContentSnapshot
}
// detachVersionLocked closes the repository session. The caller must hold
// c.mu; it is called when an archive is replaced or closed.
func (c *core) detachVersionLocked() {
c.versionLoadID++
c.versionLoading = false
c.versionLoadError = ""
if c.versionRepo != nil {
_ = c.versionRepo.Close()
}
c.versionRepo = nil
c.versionHead = pvfversion.Commit{}
c.versionHeadSnapshot = nil
c.versionWorking = nil
c.versionChanges = nil
c.versionChangeMap = nil
c.versionUndo = nil
c.versionSavedSnapshot = nil
c.versionArtifactDirty = nil
c.versionSavedTree = ""
c.versionSavedPVF = ""
c.versionSavedCommit = ""
c.versionViewCommit = ""
c.versionBaseArchive = nil
}
type preparedVersionSession struct {
archive *pvf.Archive
repo *pvfversion.Repository
head pvfversion.Commit
headSnapshot pvfversion.Snapshot
working pvfversion.Snapshot
savedSnapshot pvfversion.Snapshot
meta pvfversion.Meta
baseArchive *pvf.Archive
changes []pvfversion.FileChange
changeMap map[string]pvfversion.FileChange
artifactDirty map[string]struct{}
}
// attachVersionSessionLocked installs a fully prepared version session. All
// repository IO, PVF parsing and snapshot diffing must happen before entering
// this method so opening an archive does not hold c.mu during the slow work.
func (c *core) attachVersionSessionLocked(session *preparedVersionSession) error {
if session == nil || session.repo == nil {
return ErrVersionNotEnabled
}
if session.working == nil {
return errors.New("版本工作区快照为空")
}
c.versionRepo = session.repo
c.versionHead = session.head
// These snapshots and derived maps are detached values prepared by the
// background loader; attaching only swaps pointers under the short lock.
c.versionHeadSnapshot = session.headSnapshot
c.versionWorking = session.working
c.versionSavedSnapshot = session.savedSnapshot
c.versionChanges = session.changes
c.versionChangeMap = session.changeMap
c.versionArtifactDirty = session.artifactDirty
c.versionUndo = nil
c.versionSavedTree = session.meta.Artifact.TreeHash
c.versionSavedPVF = session.meta.Artifact.PVFHash
c.versionSavedCommit = session.meta.Artifact.CommitID
c.versionViewCommit = ""
c.versionBaseArchive = session.baseArchive
return nil
}
func (c *core) currentVersionChangesLocked() []pvfversion.FileChange {
if c.versionRepo == nil {
return nil
}
return append([]pvfversion.FileChange(nil), c.versionChanges...)
}
func (c *core) versionStatusLocked() VersionStatus {
status := VersionStatus{Branch: "main", Loading: c.versionLoading, Error: c.versionLoadError}
if c.versionRepo == nil {
return status
}
status.Enabled = true
status.RepositoryPath = c.versionRepo.Root()
status.HeadID = c.versionHead.ID
status.HeadMessage = c.versionHead.Message
status.ChangedFiles = len(c.versionChanges)
status.PendingChangeSets = len(c.versionUndo)
status.ViewCommitID = c.versionViewCommit
status.NeedsSave = len(c.versionArtifactDirty) > 0
status.Error = ""
return status
}
func (c *core) ensureVersionReadyLocked() error {
if c.versionLoading {
return ErrVersionLoading
}
return nil
}
func sameVersionEntry(left, right pvfversion.Entry) bool {
return left.DataType == right.DataType && left.Hash == right.Hash
}
func versionChangeForPath(before, after pvfversion.Snapshot, key string) (pvfversion.FileChange, bool) {
oldEntry, hadOld := before[key]
newEntry, hasNew := after[key]
switch {
case !hadOld && hasNew:
return pvfversion.FileChange{
Path: key, DisplayPath: newEntry.Path,
Operation: pvfversion.OperationAdd,
AfterHash: newEntry.Hash, AfterType: newEntry.DataType,
}, true
case hadOld && !hasNew:
return pvfversion.FileChange{
Path: key, DisplayPath: oldEntry.Path,
Operation: pvfversion.OperationDelete,
BeforeHash: oldEntry.Hash, BeforeType: oldEntry.DataType,
}, true
case hadOld && hasNew && !sameVersionEntry(oldEntry, newEntry):
return pvfversion.FileChange{
Path: key, DisplayPath: newEntry.Path,
Operation: pvfversion.OperationModify,
BeforeHash: oldEntry.Hash, AfterHash: newEntry.Hash,
BeforeType: oldEntry.DataType, AfterType: newEntry.DataType,
}, true
default:
return pvfversion.FileChange{}, false
}
}
func (c *core) rebuildVersionChangesLocked() {
if c.versionRepo == nil {
c.versionChanges = nil
c.versionChangeMap = nil
return
}
c.versionChanges, c.versionChangeMap = versionChangesForSnapshots(c.versionHeadSnapshot, c.versionWorking)
}
func versionChangesForSnapshots(head, working pvfversion.Snapshot) ([]pvfversion.FileChange, map[string]pvfversion.FileChange) {
changes := pvfversion.Diff(head, working)
changeMap := make(map[string]pvfversion.FileChange, len(changes))
for _, change := range changes {
changeMap[change.Path] = change
}
return changes, changeMap
}
func (c *core) refreshVersionChangeLocked(key string) {
if c.versionChangeMap == nil {
c.versionChangeMap = make(map[string]pvfversion.FileChange)
}
if change, ok := versionChangeForPath(c.versionHeadSnapshot, c.versionWorking, key); ok {
c.versionChangeMap[key] = change
} else {
delete(c.versionChangeMap, key)
}
}
func (c *core) sortVersionChangesLocked() {
c.versionChanges = c.versionChanges[:0]
for _, change := range c.versionChangeMap {
c.versionChanges = append(c.versionChanges, change)
}
sort.Slice(c.versionChanges, func(left, right int) bool {
return c.versionChanges[left].Path < c.versionChanges[right].Path
})
}
func (c *core) rebuildVersionArtifactDirtyLocked() {
if c.versionRepo == nil {
c.versionArtifactDirty = nil
return
}
c.versionArtifactDirty = versionArtifactDirtyForSnapshots(c.versionSavedSnapshot, c.versionWorking)
}
func versionArtifactDirtyForSnapshots(saved, working pvfversion.Snapshot) map[string]struct{} {
dirty := make(map[string]struct{})
keys := make(map[string]struct{}, len(saved)+len(working))
for key := range saved {
keys[key] = struct{}{}
}
for key := range working {
keys[key] = struct{}{}
}
for key := range keys {
savedEntry, hasSaved := saved[key]
workingEntry, hasWorking := working[key]
if hasSaved != hasWorking || (hasSaved && !sameVersionEntry(savedEntry, workingEntry)) {
dirty[key] = struct{}{}
}
}
return dirty
}
func (c *core) refreshVersionArtifactDirtyLocked(key string) {
if c.versionArtifactDirty == nil {
c.versionArtifactDirty = make(map[string]struct{})
}
saved, hasSaved := c.versionSavedSnapshot[key]
working, hasWorking := c.versionWorking[key]
if hasSaved != hasWorking || (hasSaved && !sameVersionEntry(saved, working)) {
c.versionArtifactDirty[key] = struct{}{}
} else {
delete(c.versionArtifactDirty, key)
}
}
func sameVersionStructure(before, after pvfversion.Snapshot) bool {
if len(before) != len(after) {
return false
}
for key, oldEntry := range before {
newEntry, ok := after[key]
if !ok || oldEntry.DataType != newEntry.DataType {
return false
}
}
return true
}
func changedVersionIndexesForPaths(c *core, paths []string) map[int32]struct{} {
indexes := make(map[int32]struct{}, len(paths))
for _, path := range paths {
if index, exists := c.archive.Find(path); exists {
indexes[index] = struct{}{}
}
}
return indexes
}
func payloadOnlyForPaths(before, after pvfversion.Snapshot, paths []string) bool {
for _, rawPath := range paths {
key := pvfversion.CanonicalPath(rawPath)
oldEntry, hadOld := before[key]
newEntry, hasNew := after[key]
if !hadOld || !hasNew || oldEntry.DataType != newEntry.DataType {
return false
}
}
return true
}
func (s *VersionService) materializePathsLocked(viewCommit string) ([]string, error) {
paths := make(map[string]struct{}, len(s.c.versionChanges))
for _, change := range s.c.versionChanges {
paths[change.Path] = struct{}{}
}
if viewCommit != "" {
fromCommit := s.c.versionViewCommit
if fromCommit == "" {
fromCommit = s.c.versionHead.ID
}
if fromCommit != viewCommit {
changed, err := s.c.versionRepo.ChangedPathsBetween(fromCommit, viewCommit)
if err != nil {
return nil, err
}
for _, path := range changed {
paths[path] = struct{}{}
}
}
}
result := make([]string, 0, len(paths))
for path := range paths {
result = append(result, path)
}
sort.Strings(result)
return result, nil
}
func (c *core) markVersionArtifactSavedLocked(path string) error {
if c.versionRepo == nil {
return nil
}
pvfHash := ""
if c.archive != nil {
pvfHash = c.archive.SourceHash()
}
if pvfHash == "" {
var err error
pvfHash, err = pvfversion.HashFile(path)
if err != nil {
return fmt.Errorf("计算已保存 PVF 的 hash 失败: %w", err)
}
}
treeHash := pvfversion.TreeHash(c.versionWorking)
state := pvfversion.ArtifactState{PVFHash: pvfHash, TreeHash: treeHash, CommitID: c.versionHead.ID}
if err := c.versionRepo.SetArtifactState(state); err != nil {
return fmt.Errorf("更新版本库产物状态失败: %w", err)
}
c.versionSavedSnapshot = pvfversion.CloneSnapshot(c.versionWorking)
c.versionArtifactDirty = make(map[string]struct{})
c.versionSavedPVF = pvfHash
c.versionSavedTree = treeHash
c.versionSavedCommit = c.versionHead.ID
return nil
}
// recordVersionMutationLocked records one atomic mutation for the worktree
// undo stack and updates the current logical snapshot. The caller must hold
// c.mu and must already have applied the mutation to c.archive.
func (c *core) recordVersionMutationLocked(label string, before, after pvfversion.ContentSnapshot) error {
if c.versionRepo == nil {
return nil
}
beforeSnapshot := pvfversion.SnapshotFromContent(before)
afterSnapshot := pvfversion.SnapshotFromContent(after)
keys := make(map[string]struct{}, len(beforeSnapshot)+len(afterSnapshot))
for key := range beforeSnapshot {
keys[key] = struct{}{}
}
for key := range afterSnapshot {
keys[key] = struct{}{}
}
changed := false
for key := range keys {
beforeEntry, hasBefore := beforeSnapshot[key]
afterEntry, hasAfter := afterSnapshot[key]
if hasBefore != hasAfter || (hasBefore && !sameVersionEntry(beforeEntry, afterEntry)) {
changed = true
break
}
}
if !changed {
return nil
}
c.versionUndo = append(c.versionUndo, versionUndoRecord{
Label: label,
Before: pvfversion.CloneContentSnapshot(before),
After: pvfversion.CloneContentSnapshot(after),
})
if c.versionWorking == nil {
c.versionWorking = make(pvfversion.Snapshot)
}
for key := range beforeSnapshot {
if _, exists := afterSnapshot[key]; !exists {
delete(c.versionWorking, key)
}
}
for key, entry := range afterSnapshot {
c.versionWorking[key] = entry
}
for key := range keys {
c.refreshVersionChangeLocked(key)
c.refreshVersionArtifactDirtyLocked(key)
}
c.sortVersionChangesLocked()
return nil
}
// Initialize creates the sidecar repository for the currently opened PVF.
func (s *VersionService) Initialize() (*VersionStatus, error) {
finishTask := s.c.archiveTasks.begin()
defer finishTask()
s.c.mu.RLock()
a := s.c.archive
if a == nil {
s.c.mu.RUnlock()
return nil, ErrNoArchive
}
if s.c.versionLoading {
s.c.mu.RUnlock()
return nil, ErrVersionLoading
}
if s.c.versionRepo != nil {
status := s.c.versionStatusLocked()
s.c.mu.RUnlock()
return &status, nil
}
if a.SourcePath() == "" {
s.c.mu.RUnlock()
return nil, errors.New("归档没有源文件,请先保存 PVF")
}
if a.Modified() {
s.c.mu.RUnlock()
return nil, errors.New("初始化版本库前请先保存或放弃当前修改")
}
working, err := pvfversion.SnapshotFromArchive(a)
if err != nil {
s.c.mu.RUnlock()
return nil, err
}
path := a.SourcePath()
archiveHash := a.SourceHash()
revision := s.c.batchRevision
s.c.mu.RUnlock()
baseHash, err := pvfversion.HashFile(path)
if err != nil {
return nil, fmt.Errorf("读取 PVF 基线失败: %w", err)
}
if archiveHash != "" && archiveHash != baseHash {
return nil, ErrVersionArtifactChanged
}
repo, err := pvfversion.Init(pvfversion.SidecarPath(path), path, baseHash, working)
if err != nil {
return nil, err
}
head, headSnapshot, meta, err := loadVersionRepositoryState(repo)
if err != nil {
_ = repo.Close()
return nil, err
}
baseArchive, err := pvf.Open(repo.BasePath())
if err != nil {
_ = repo.Close()
return nil, fmt.Errorf("打开版本基线失败: %w", err)
}
session, err := buildPreparedVersionSession(repo, a, head, headSnapshot, meta, working, baseArchive)
if err != nil {
_ = repo.Close()
return nil, err
}
s.c.mu.Lock()
if s.c.archive != a || s.c.batchRevision != revision || s.c.versionRepo != nil {
s.c.mu.Unlock()
_ = repo.Close()
return nil, errors.New("初始化版本库期间归档状态已变化,请重试")
}
if err := s.c.attachVersionSessionLocked(session); err != nil {
s.c.mu.Unlock()
_ = repo.Close()
return nil, err
}
status := s.c.versionStatusLocked()
s.c.mu.Unlock()
s.emitVersionChanged("initialized")
return &status, nil
}
// Status returns the current version session without modifying disk state.
func (s *VersionService) Status() VersionStatus {
s.c.mu.RLock()
defer s.c.mu.RUnlock()
return s.c.versionStatusLocked()
}
// ListChanges lists the current working-tree changes relative to HEAD.
func (s *VersionService) ListChanges(cursor, limit int) (*VersionChangePage, error) {
s.c.mu.RLock()
defer s.c.mu.RUnlock()
if err := s.c.ensureVersionReadyLocked(); err != nil {
return nil, err
}
if s.c.versionRepo == nil {
return nil, ErrVersionNotEnabled
}
changes := s.c.versionChanges
if cursor < 0 {
cursor = 0
}
if limit <= 0 || limit > 500 {
limit = 100
}
if cursor >= len(changes) {
return &VersionChangePage{Changes: []*VersionChange{}, NextCursor: -1, Total: len(changes)}, nil
}
end := cursor + limit
if end > len(changes) {
end = len(changes)
}
result := &VersionChangePage{Changes: make([]*VersionChange, 0, end-cursor), Total: len(changes), NextCursor: -1}
for _, change := range changes[cursor:end] {
result.Changes = append(result.Changes, versionChangeDTO(change))
}
if end < len(changes) {
result.NextCursor = end
}
return result, nil
}
// Commit freezes the current working tree as one commit without saving the
// packed PVF artifact.
func (s *VersionService) Commit(message string) (*VersionCommit, error) {
s.c.mu.Lock()
if err := s.c.ensureVersionReadyLocked(); err != nil {
s.c.mu.Unlock()
return nil, err
}
if s.c.versionRepo == nil {
s.c.mu.Unlock()
return nil, ErrVersionNotEnabled
}
if s.c.archive == nil {
s.c.mu.Unlock()
return nil, ErrNoArchive
}
working := s.c.versionWorking
changes := s.c.currentVersionChangesLocked()
if len(changes) == 0 {
s.c.mu.Unlock()
return nil, errors.New("没有可提交的变更")
}
objects := make(map[string][]byte)
for _, change := range changes {
if change.AfterHash == "" {
continue
}
index, ok := s.c.archive.Find(change.Path)
if !ok {
s.c.mu.Unlock()
return nil, fmt.Errorf("提交文件不存在: %s", change.DisplayPath)
}
content, err := pvfversion.ContentSnapshotFromArchive(s.c.archive, []string{change.Path})
if err != nil {
s.c.mu.Unlock()
return nil, err
}
value, ok := content[pvfversion.CanonicalPath(change.Path)]
if !ok || value.Hash != change.AfterHash || index < 0 {
s.c.mu.Unlock()
return nil, fmt.Errorf("提交内容已变化: %s", change.DisplayPath)
}
objects[change.AfterHash] = pvfversion.ContentObject(value)
}
commit, err := s.c.versionRepo.Commit(message, changes, objects)
if err != nil {
s.c.mu.Unlock()
return nil, err
}
s.c.versionHead = commit
s.c.versionHeadSnapshot = pvfversion.CloneSnapshot(working)
// Keep the existing working snapshot. The new HEAD snapshot above is the
// detached copy that protects it from subsequent in-memory edits.
s.c.versionWorking = working
s.c.versionChanges = nil
s.c.versionChangeMap = make(map[string]pvfversion.FileChange)
s.c.versionUndo = nil
s.c.versionViewCommit = ""
result := versionCommitDTO(commit)
s.c.mu.Unlock()
s.emitVersionChanged("committed")
emitEvent("version:committed", result)
return result, nil
}
// History returns main-branch commits from newest to oldest.
func (s *VersionService) History(cursor, limit int) (*VersionHistoryPage, error) {
s.c.mu.RLock()
defer s.c.mu.RUnlock()
if err := s.c.ensureVersionReadyLocked(); err != nil {
return nil, err
}
if s.c.versionRepo == nil {
return nil, ErrVersionNotEnabled
}
commits, next, err := s.c.versionRepo.History(cursor, limit)
if err != nil {
return nil, err
}
result := &VersionHistoryPage{Commits: make([]*VersionCommit, 0, len(commits)), NextCursor: next}
for _, commit := range commits {
result.Commits = append(result.Commits, versionCommitDTO(commit))
}
return result, nil
}
// ListCommitChanges lists the compact path changes belonging to one commit.
func (s *VersionService) ListCommitChanges(commitID string, cursor, limit int) (*VersionChangePage, error) {
commitID = strings.TrimSpace(commitID)
if commitID == "" {
return nil, errors.New("提交 ID 不能为空")
}
s.c.mu.RLock()
defer s.c.mu.RUnlock()
if err := s.c.ensureVersionReadyLocked(); err != nil {
return nil, err
}
if s.c.versionRepo == nil {
return nil, ErrVersionNotEnabled
}
if _, err := s.c.versionRepo.GetCommit(commitID); err != nil {
return nil, err
}
changes, err := s.c.versionRepo.Changes(commitID)
if err != nil {
return nil, err
}
if cursor < 0 {
cursor = 0
}
if limit <= 0 || limit > 500 {
limit = 100
}
if cursor >= len(changes) {
return &VersionChangePage{Changes: []*VersionChange{}, NextCursor: -1, Total: len(changes)}, nil
}
end := cursor + limit
if end > len(changes) {
end = len(changes)
}
result := &VersionChangePage{Changes: make([]*VersionChange, 0, end-cursor), NextCursor: -1, Total: len(changes)}
for _, change := range changes[cursor:end] {
result.Changes = append(result.Changes, versionChangeDTO(change))
}
if end < len(changes) {
result.NextCursor = end
}
return result, nil
}
// Remove disables version control for the current artifact and deletes only
// its sidecar repository. The loaded PVF and its in-memory edits are kept.
func (s *VersionService) Remove() (*VersionStatus, error) {
s.c.mu.Lock()
if err := s.c.ensureVersionReadyLocked(); err != nil {
s.c.mu.Unlock()
return nil, err
}
if s.c.versionRepo == nil {
s.c.mu.Unlock()
return nil, ErrVersionNotEnabled
}
if s.c.archive == nil || s.c.archive.SourcePath() == "" {
s.c.mu.Unlock()
return nil, errors.New("当前归档没有可关联的源文件")
}
repositoryRoot := filepath.Clean(s.c.versionRepo.Root())
expectedRoot := filepath.Clean(pvfversion.SidecarPath(s.c.archive.SourcePath()))
if repositoryRoot != expectedRoot || filepath.Base(repositoryRoot) == "." || filepath.Base(repositoryRoot) == string(filepath.Separator) || !strings.HasSuffix(repositoryRoot, ".pvfine") {
s.c.mu.Unlock()
return nil, errors.New("版本库路径校验失败,为安全起见未删除任何文件")
}
if info, err := os.Lstat(repositoryRoot); err == nil && info.Mode()&os.ModeSymlink != 0 {
s.c.mu.Unlock()
return nil, errors.New("版本库目录是符号链接,为安全起见未删除任何文件")
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
s.c.mu.Unlock()
return nil, err
}
s.c.detachVersionLocked()
removeErr := os.RemoveAll(repositoryRoot)
status := s.c.versionStatusLocked()
s.c.mu.Unlock()
s.emitVersionChanged("disabled")
if removeErr != nil {
return &status, fmt.Errorf("取消版本控制失败,部分版本文件可能仍然存在: %w", removeErr)
}
return &status, nil
}
// ExportCommitFilesDialog exports the files touched by one commit. Add/modify
// entries use their after-state; deleted entries use their before-state so the
// exported directory represents every file involved in that version.
func (s *VersionService) ExportCommitFilesDialog(commitID string) (string, error) {
commitID = strings.TrimSpace(commitID)
if commitID == "" {
return "", errors.New("提交 ID 不能为空")
}
s.c.mu.RLock()
if err := s.c.ensureVersionReadyLocked(); err != nil {
s.c.mu.RUnlock()
return "", err
}
repo := s.c.versionRepo
if repo == nil {
s.c.mu.RUnlock()
return "", ErrVersionNotEnabled
}
if _, err := repo.GetCommit(commitID); err != nil {
s.c.mu.RUnlock()
return "", err
}
changes, err := repo.Changes(commitID)
s.c.mu.RUnlock()
if err != nil {
return "", err
}
if len(changes) == 0 {
return "", errors.New("该版本没有可导出的文件")
}
dir, err := application.Get().Dialog.OpenFile().
CanChooseFiles(false).
CanChooseDirectories(true).
CanCreateDirectories(true).
SetTitle("选择版本文件导出目录").
PromptForSingleSelection()
if err != nil {
return "", err
}
if dir == "" {
return "", nil
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
s.c.mu.RLock()
defer s.c.mu.RUnlock()
if s.c.versionRepo != repo {
return "", ErrVersionNotEnabled
}
base := s.c.versionBaseArchive
if base == nil {
base, err = pvf.Open(repo.BasePath())
if err != nil {
return "", err
}
}
seenDestinations := make(map[string]struct{}, len(changes))
exported := 0
for _, change := range changes {
hash := change.AfterHash
dataType := change.AfterType
path := change.DisplayPath
if hash == "" {
hash = change.BeforeHash
dataType = change.BeforeType
if path == "" {
path = change.OldDisplayPath
}
}
if hash == "" || path == "" {
continue
}
content, err := readVersionContent(repo, base, pvfversion.Entry{
Path: path, DataType: dataType, Hash: hash,
})
if err != nil {
return "", fmt.Errorf("读取版本文件 %q 失败: %w", path, err)
}
destination, err := safeExportPath(dir, path)
if err != nil {
return "", err
}
if _, exists := seenDestinations[destination]; exists {
return "", fmt.Errorf("版本文件导出路径冲突: %s", path)
}
seenDestinations[destination] = struct{}{}
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
return "", err
}
if err := os.WriteFile(destination, content.Raw, 0o644); err != nil {
return "", err
}
exported++
}
if exported == 0 {
return "", errors.New("该版本没有可导出的文件")
}
return dir, nil
}
// Undo reverses the most recent uncommitted mutation as one atomic operation.
func (s *VersionService) Undo() (*VersionStatus, error) {
s.c.mu.Lock()
if err := s.c.ensureVersionReadyLocked(); err != nil {
s.c.mu.Unlock()
return nil, err
}
if s.c.versionRepo == nil {
s.c.mu.Unlock()
return nil, ErrVersionNotEnabled
}
if len(s.c.versionUndo) == 0 {
s.c.mu.Unlock()
return nil, errors.New("没有可撤销的版本变更")
}
record := s.c.versionUndo[len(s.c.versionUndo)-1]
paths := make([]string, 0, len(record.Before)+len(record.After))
seenPaths := make(map[string]struct{}, len(record.Before)+len(record.After))
for key := range record.Before {
seenPaths[key] = struct{}{}
paths = append(paths, key)
}
for key := range record.After {
if _, exists := seenPaths[key]; exists {
continue
}
paths = append(paths, key)
}
a := s.c.archive
mutationCheckpoint := a.MutationCheckpoint()
if err := applyVersionContentPathsLocked(s.c, paths, record.Before); err != nil {
s.c.mu.Unlock()
return nil, err
}
for key, content := range record.Before {
s.c.versionWorking[key] = content.Entry
}
for key := range record.After {
if _, exists := record.Before[key]; !exists {
delete(s.c.versionWorking, key)
}
}
for key := range record.Before {
s.c.refreshVersionChangeLocked(key)
s.c.refreshVersionArtifactDirtyLocked(key)
}
for key := range record.After {
s.c.refreshVersionChangeLocked(key)
s.c.refreshVersionArtifactDirtyLocked(key)
}
s.c.sortVersionChangesLocked()
s.c.versionUndo = s.c.versionUndo[:len(s.c.versionUndo)-1]
mutationSummary := a.MutationsSince(mutationCheckpoint)
a.ClearMutations()
status := s.c.versionStatusLocked()
info := s.c.archive.Info()
s.c.mu.Unlock()
if err := s.c.scheduleArchiveMutations(a, mutationSummary); err != nil {
return nil, err
}
emitEvent("archive:reloaded", info)
s.emitVersionChanged("undo")
return &status, nil
}
// Discard restores the current HEAD into the in-memory working archive.
func (s *VersionService) Discard() (*VersionStatus, error) {
s.c.mu.Lock()
if err := s.c.ensureVersionReadyLocked(); err != nil {
s.c.mu.Unlock()
return nil, err
}
if s.c.versionRepo == nil {
s.c.mu.Unlock()
return nil, ErrVersionNotEnabled
}
target := pvfversion.CloneSnapshot(s.c.versionHeadSnapshot)
if err := s.materializeLocked(target, ""); err != nil {
s.c.mu.Unlock()
return nil, err
}
a := s.c.archive
mutationSummary := a.MutationsSince(0)
a.ClearMutations()
status := s.c.versionStatusLocked()
info := s.c.archive.Info()
s.c.mu.Unlock()
if err := s.c.scheduleArchiveMutations(a, mutationSummary); err != nil {
return nil, err
}
emitEvent("archive:reloaded", info)
s.emitVersionChanged("discarded")
return &status, nil
}
// Checkout loads a historical logical snapshot into the working tree. V1
// keeps main HEAD unchanged; committing afterwards creates a new commit on
// the current main line.
func (s *VersionService) Checkout(commitID string) (*VersionStatus, error) {
commitID = strings.TrimSpace(commitID)
if commitID == "" {
return nil, errors.New("提交 ID 不能为空")
}
s.c.mu.Lock()
if err := s.c.ensureVersionReadyLocked(); err != nil {
s.c.mu.Unlock()
return nil, err
}
if s.c.versionRepo == nil {
s.c.mu.Unlock()
return nil, ErrVersionNotEnabled
}
target, err := s.c.versionRepo.Snapshot(commitID)
if err != nil {
s.c.mu.Unlock()
return nil, err
}
if err := s.materializeLocked(target, commitID); err != nil {
s.c.mu.Unlock()
return nil, err
}
a := s.c.archive
mutationSummary := a.MutationsSince(0)
a.ClearMutations()
status := s.c.versionStatusLocked()
info := s.c.archive.Info()
s.c.mu.Unlock()
if err := s.c.scheduleArchiveMutations(a, mutationSummary); err != nil {
return nil, err
}
emitEvent("archive:reloaded", info)
s.emitVersionChanged("checkout")
emitEvent("version:checked-out", status)
return &status, nil
}
func versionFileDiffDTO(change pvfversion.FileChange) *VersionFileDiff {
return &VersionFileDiff{
Path: change.DisplayPath,
Operation: string(change.Operation),
BeforeHash: change.BeforeHash,
AfterHash: change.AfterHash,
BeforeType: change.BeforeType,
AfterType: change.AfterType,
}
}
func appendVersionDiffText(result *VersionFileDiff, content pvfversion.Content) {
if content.DataType != pvf.TypeScript && content.DataType != pvf.TypeUnicode {
return
}
if result.BeforeHash == content.Hash {
result.BeforeText = string(content.Raw)
} else if result.AfterHash == content.Hash {
result.AfterText = string(content.Raw)
}
result.TextAvailable = true
}
func (s *VersionService) versionBaseArchiveLocked() (*pvf.Archive, error) {
if s.c.versionBaseArchive != nil {
return s.c.versionBaseArchive, nil
}
if s.c.versionRepo == nil {
return nil, ErrVersionNotEnabled
}
return pvf.Open(s.c.versionRepo.BasePath())
}
func (s *VersionService) fillVersionCommitDiffLocked(
result *VersionFileDiff,
change pvfversion.FileChange,
base *pvf.Archive,
) error {
if change.BeforeHash != "" {
before, err := readVersionContent(s.c.versionRepo, base, pvfversion.Entry{
Path: change.DisplayPath, DataType: change.BeforeType, Hash: change.BeforeHash,
})
if err != nil {
return err
}
appendVersionDiffText(result, before)
}
if change.AfterHash != "" {
after, err := readVersionContent(s.c.versionRepo, base, pvfversion.Entry{
Path: change.DisplayPath, DataType: change.AfterType, Hash: change.AfterHash,
})
if err != nil {
return err
}
appendVersionDiffText(result, after)
}
return nil
}
// Diff returns a single commit change, including logical text where possible.
func (s *VersionService) Diff(commitID, path string) (*VersionFileDiff, error) {
commitID = strings.TrimSpace(commitID)
path = pvfversion.CanonicalPath(path)
if commitID == "" || path == "" {
return nil, errors.New("提交 ID 和文件路径不能为空")
}
s.c.mu.RLock()
defer s.c.mu.RUnlock()
if err := s.c.ensureVersionReadyLocked(); err != nil {
return nil, err
}
if s.c.versionRepo == nil {
return nil, ErrVersionNotEnabled
}
if _, err := s.c.versionRepo.GetCommit(commitID); err != nil {
return nil, err
}
changes, err := s.c.versionRepo.Changes(commitID)
if err != nil {
return nil, err
}
var change *pvfversion.FileChange
for index := range changes {
if pvfversion.CanonicalPath(changes[index].Path) == path {
change = &changes[index]
break
}
}
if change == nil {
return nil, fmt.Errorf("提交中没有文件变更: %s", path)
}
result := versionFileDiffDTO(*change)
base, err := s.versionBaseArchiveLocked()
if err != nil {
return nil, err
}
if err := s.fillVersionCommitDiffLocked(result, *change, base); err != nil {
return nil, err
}
return result, nil
}
// DiffWorking returns the current worktree change for one path relative to
// HEAD. The current after-state is read from the in-memory archive, so the
// call does not require saving the packed PVF artifact first.
func (s *VersionService) DiffWorking(path string) (*VersionFileDiff, error) {
path = pvfversion.CanonicalPath(path)
if path == "" {
return nil, errors.New("文件路径不能为空")
}
s.c.mu.RLock()
defer s.c.mu.RUnlock()
if err := s.c.ensureVersionReadyLocked(); err != nil {
return nil, err
}
if s.c.versionRepo == nil {
return nil, ErrVersionNotEnabled
}
change, ok := s.c.versionChangeMap[path]
if !ok {
return nil, fmt.Errorf("当前工作区没有文件变更: %s", path)
}
result := versionFileDiffDTO(change)
base, err := s.versionBaseArchiveLocked()
if err != nil {
return nil, err
}
if change.BeforeHash != "" {
before, err := readVersionContent(s.c.versionRepo, base, pvfversion.Entry{
Path: change.DisplayPath, DataType: change.BeforeType, Hash: change.BeforeHash,
})
if err != nil {
return nil, err
}
appendVersionDiffText(result, before)
}
if change.AfterHash != "" {
afterSnapshot, err := pvfversion.ContentSnapshotFromArchive(s.c.archive, []string{path})
if err != nil {
return nil, err
}
after, ok := afterSnapshot[path]
if !ok || after.Hash != change.AfterHash || after.DataType != change.AfterType {
return nil, fmt.Errorf("当前工作区内容已变化,请刷新后重试: %s", path)
}
appendVersionDiffText(result, after)
}
return result, nil
}
// RestorePath restores one changed path to the current HEAD. It only
// materializes the selected path and records the operation in the existing
// in-memory undo stack.
func (s *VersionService) RestorePath(path string) (*VersionStatus, error) {
path = pvfversion.CanonicalPath(path)
if path == "" {
return nil, errors.New("文件路径不能为空")
}
s.c.mu.Lock()
if err := s.c.ensureVersionReadyLocked(); err != nil {
s.c.mu.Unlock()
return nil, err
}
if s.c.versionRepo == nil {
s.c.mu.Unlock()
return nil, ErrVersionNotEnabled
}
change, ok := s.c.versionChangeMap[path]
if !ok {
s.c.mu.Unlock()
return nil, fmt.Errorf("当前工作区没有文件变更: %s", path)
}
before, err := pvfversion.ContentSnapshotFromArchive(s.c.archive, []string{path})
if err != nil {
s.c.mu.Unlock()
return nil, err
}
desired := make(pvfversion.ContentSnapshot)
if entry, exists := s.c.versionHeadSnapshot[path]; exists {
base, baseErr := s.versionBaseArchiveLocked()
if baseErr != nil {
s.c.mu.Unlock()
return nil, baseErr
}
content, contentErr := readVersionContent(s.c.versionRepo, base, entry)
if contentErr != nil {
s.c.mu.Unlock()
return nil, contentErr
}
desired[path] = content
}
if len(before) == 0 && len(desired) == 0 {
s.c.mu.Unlock()
return nil, fmt.Errorf("文件已经恢复到 HEAD: %s", path)
}
if err := applyVersionContentPathsLocked(s.c, []string{path}, desired); err != nil {
s.c.mu.Unlock()
return nil, err
}
a := s.c.archive
mutationSummary := a.MutationsSince(0)
a.ClearMutations()
if content, exists := desired[path]; exists {
s.c.versionWorking[path] = content.Entry
} else {
delete(s.c.versionWorking, path)
}
s.c.refreshVersionChangeLocked(path)
s.c.refreshVersionArtifactDirtyLocked(path)
s.c.sortVersionChangesLocked()
s.c.versionUndo = append(s.c.versionUndo, versionUndoRecord{
Label: fmt.Sprintf("还原 %s", change.DisplayPath),
Before: pvfversion.CloneContentSnapshot(before),
After: pvfversion.CloneContentSnapshot(desired),
})
status := s.c.versionStatusLocked()
info := s.c.archive.Info()
s.c.mu.Unlock()
if err := s.c.scheduleArchiveMutations(a, mutationSummary); err != nil {
return nil, err
}
emitEvent("archive:reloaded", info)
s.emitVersionChanged("restored")
return &status, nil
}
func (s *VersionService) materializeLocked(target pvfversion.Snapshot, viewCommit string) error {
if s.c.archive == nil || s.c.versionRepo == nil {
return ErrNoArchive
}
changePaths, err := s.materializePathsLocked(viewCommit)
if err != nil {
return err
}
payloadOnly := payloadOnlyForPaths(s.c.versionWorking, target, changePaths)
if len(changePaths) == 0 {
payloadOnly = sameVersionStructure(s.c.versionWorking, target)
}
materializePaths := changePaths
if len(changePaths) == 0 && !payloadOnly {
// This is only a defensive fallback for a malformed/incomplete change
// set; a normal main-branch transition always has path identities.
materializePaths = nil
}
materialized, err := materializeVersionArchive(
s.c.versionRepo.BasePath(), s.c.versionBaseArchive, target,
s.c.archive, s.c.versionWorking, s.c.versionRepo, materializePaths, s.c.archive.SourcePath(),
)
if err != nil {
return err
}
if payloadOnly {
changedIndexes := changedVersionIndexesForPaths(s.c, changePaths)
if err := s.c.replaceArchivePayloadLocked(materialized, changedIndexes); err != nil {
return err
}
} else if err := s.c.replaceArchiveLocked(materialized); err != nil {
return err
}
// target is a detached snapshot owned by this operation; retaining it
// avoids another full map clone after materialization.
s.c.versionWorking = target
if materializePaths == nil {
s.c.rebuildVersionChangesLocked()
s.c.rebuildVersionArtifactDirtyLocked()
} else {
for _, path := range materializePaths {
key := pvfversion.CanonicalPath(path)
s.c.refreshVersionChangeLocked(key)
s.c.refreshVersionArtifactDirtyLocked(key)
}
s.c.sortVersionChangesLocked()
}
s.c.versionUndo = nil
s.c.versionViewCommit = viewCommit
return nil
}
func (s *VersionService) emitVersionChanged(reason string) {
emitVersionState(s.c, reason)
}
func emitVersionState(c *core, reason string) {
c.mu.RLock()
status := c.versionStatusLocked()
changes := c.currentVersionChangesLocked()
changeDTOs := make([]*VersionChange, 0, len(changes))
for _, change := range changes {
changeDTOs = append(changeDTOs, versionChangeDTO(change))
}
c.mu.RUnlock()
emitEvent("version:changed", map[string]any{
"reason": reason, "status": status, "changes": changeDTOs,
})
}
func (c *core) startVersionLoad(path string, archive *pvf.Archive) {
if !versionSidecarExists(path) {
return
}
c.mu.Lock()
if c.archive != archive {
c.mu.Unlock()
return
}
c.versionLoadID++
loadID := c.versionLoadID
c.versionLoading = true
c.versionLoadError = ""
c.mu.Unlock()
finishTask := c.archiveTasks.begin()
c.mu.RLock()
current := c.versionLoadCurrentLocked(archive, loadID)
c.mu.RUnlock()
if !current {
finishTask()
return
}
go func() {
defer finishTask()
session, err := prepareVersionedSession(path, archive)
if err != nil {
c.finishVersionLoad(archive, loadID, err)
return
}
if session == nil {
c.finishVersionLoad(archive, loadID, nil)
return
}
var prepared *preparedArchiveIndexes
if session.archive != archive {
prepared, err = prepareArchiveDerivedIndexes(session.archive)
if err != nil {
closePreparedArchiveIndexes(prepared)
_ = session.repo.Close()
c.finishVersionLoad(archive, loadID, err)
return
}
}
c.mu.Lock()
if !c.versionLoadCurrentLocked(archive, loadID) {
c.mu.Unlock()
_ = session.repo.Close()
return
}
if session.archive != archive {
if err := c.installPreparedArchiveIndexesLocked(session.archive, prepared, true); err != nil {
c.mu.Unlock()
closePreparedArchiveIndexes(prepared)
_ = session.repo.Close()
c.finishVersionLoad(archive, loadID, err)
return
}
}
if err := c.attachVersionSessionLocked(session); err != nil {
c.versionLoading = false
c.versionLoadError = err.Error()
c.mu.Unlock()
_ = session.repo.Close()
emitVersionState(c, "load-error")
return
}
c.versionLoading = false
c.versionLoadError = ""
info := c.archive.Info()
reloaded := session.archive != archive
c.mu.Unlock()
// No archive references are read below. Release this task before the
// index acquires its own task, or closing can wait on this parent
// while its child waits for closing to finish.
finishTask()
if reloaded {
c.startSearchIndexForced()
emitEvent("archive:reloaded", info)
}
emitVersionState(c, "loaded")
}()
}
func versionSidecarExists(path string) bool {
_, err := os.Stat(pvfversion.SidecarPath(path))
return err == nil
}
func (c *core) versionLoadCurrentLocked(archive *pvf.Archive, loadID uint64) bool {
return c.archive == archive && c.versionLoading && c.versionLoadID == loadID
}
func (c *core) finishVersionLoad(archive *pvf.Archive, loadID uint64, loadErr error) {
c.mu.Lock()
if !c.versionLoadCurrentLocked(archive, loadID) {
c.mu.Unlock()
return
}
c.versionLoading = false
if loadErr != nil {
c.versionLoadError = loadErr.Error()
} else {
c.versionLoadError = ""
}
c.mu.Unlock()
if loadErr != nil {
emitVersionState(c, "load-error")
return
}
emitVersionState(c, "loaded")
}
func loadVersionRepositoryState(repo *pvfversion.Repository) (pvfversion.Commit, pvfversion.Snapshot, pvfversion.Meta, error) {
if repo == nil {
return pvfversion.Commit{}, nil, pvfversion.Meta{}, ErrVersionNotEnabled
}
head, err := repo.Head()
if err != nil {
return pvfversion.Commit{}, nil, pvfversion.Meta{}, err
}
headSnapshot, err := repo.Snapshot(head.ID)
if err != nil {
return pvfversion.Commit{}, nil, pvfversion.Meta{}, err
}
return head, headSnapshot, repo.Meta(), nil
}
func buildPreparedVersionSession(
repo *pvfversion.Repository,
archive *pvf.Archive,
head pvfversion.Commit,
headSnapshot pvfversion.Snapshot,
meta pvfversion.Meta,
working pvfversion.Snapshot,
baseArchive *pvf.Archive,
) (*preparedVersionSession, error) {
if repo == nil {
return nil, ErrVersionNotEnabled
}
if working == nil {
return nil, errors.New("版本工作区快照为空")
}
savedSnapshot := pvfversion.CloneSnapshot(working)
if meta.Artifact.CommitID != "" {
var err error
savedSnapshot, err = repo.Snapshot(meta.Artifact.CommitID)
if err != nil {
return nil, err
}
}
changes, changeMap := versionChangesForSnapshots(headSnapshot, working)
return &preparedVersionSession{
archive: archive,
repo: repo,
head: head,
headSnapshot: headSnapshot,
working: working,
savedSnapshot: savedSnapshot,
meta: meta,
baseArchive: baseArchive,
changes: changes,
changeMap: changeMap,
artifactDirty: versionArtifactDirtyForSnapshots(savedSnapshot, working),
}, nil
}
// prepareVersionedSession discovers an existing sidecar and restores an
// unsaved HEAD in memory. It is called only from the background loader; all
// disk IO, snapshot reconstruction and the baseline PVF parse stay off c.mu.
func prepareVersionedSession(path string, archive *pvf.Archive) (*preparedVersionSession, error) {
repo, err := pvfversion.Open(pvfversion.SidecarPath(path))
if errors.Is(err, pvfversion.ErrRepositoryNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
closeOnError := true
defer func() {
if closeOnError {
_ = repo.Close()
}
}()
head, headSnapshot, meta, err := loadVersionRepositoryState(repo)
if err != nil {
return nil, err
}
sourceHash := archive.SourceHash()
if meta.Artifact.PVFHash != "" && sourceHash != meta.Artifact.PVFHash {
return nil, ErrVersionArtifactChanged
}
working := pvfversion.Snapshot(nil)
preparedArchive := archive
var baseArchive *pvf.Archive
if meta.Artifact.CommitID != "" && meta.Artifact.CommitID != head.ID {
artifactSnapshot, err := repo.Snapshot(meta.Artifact.CommitID)
if err != nil {
return nil, err
}
changedPaths, err := repo.ChangedPathsBetween(meta.Artifact.CommitID, head.ID)
if err != nil {
return nil, err
}
baseArchive, err = pvf.Open(repo.BasePath())
if err != nil {
return nil, fmt.Errorf("打开版本基线失败: %w", err)
}
preparedArchive, err = materializeVersionArchive(
repo.BasePath(), baseArchive, headSnapshot, archive, artifactSnapshot, repo,
changedPaths, path,
)
if err != nil {
return nil, err
}
working = pvfversion.CloneSnapshot(headSnapshot)
} else if meta.Artifact.CommitID == head.ID && sourceHash == meta.Artifact.PVFHash {
// The packed artifact already represents HEAD, so re-use the restored
// manifest instead of decoding every logical file again.
working = pvfversion.CloneSnapshot(headSnapshot)
} else {
working, err = pvfversion.SnapshotFromArchive(archive)
if err != nil {
return nil, err
}
if meta.Artifact.TreeHash != "" && pvfversion.TreeHash(working) != meta.Artifact.TreeHash {
return nil, ErrVersionArtifactChanged
}
}
if baseArchive == nil {
baseArchive, err = pvf.Open(repo.BasePath())
if err != nil {
return nil, fmt.Errorf("打开版本基线失败: %w", err)
}
}
session, err := buildPreparedVersionSession(
repo, preparedArchive, head, headSnapshot, meta, working, baseArchive,
)
if err != nil {
return nil, err
}
closeOnError = false
return session, nil
}
func materializeVersionArchive(
basePath string,
baseArchive *pvf.Archive,
target pvfversion.Snapshot,
currentArchive *pvf.Archive,
currentSnapshot pvfversion.Snapshot,
repo *pvfversion.Repository,
changePaths []string,
outputPath string,
) (*pvf.Archive, error) {
base := baseArchive
if base == nil {
var err error
base, err = pvf.Open(basePath)
if err != nil {
return nil, err
}
}
archive := base
if currentArchive != nil {
archive = currentArchive
}
archive = archive.CloneForBatch()
keys := make([]string, 0)
if changePaths == nil {
remove := make([]int32, 0)
for index := int32(0); index < archive.FileCount(); index++ {
if _, exists := target[pvfversion.CanonicalPath(archive.Path(index))]; !exists {
remove = append(remove, index)
}
}
if len(remove) > 0 {
if _, err := archive.RemoveFiles(remove); err != nil {
return nil, err
}
}
keys = make([]string, 0, len(target))
for key := range target {
keys = append(keys, key)
}
} else {
seen := make(map[string]struct{}, len(changePaths))
for _, rawPath := range changePaths {
key := pvfversion.CanonicalPath(rawPath)
if key == "" {
continue
}
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
keys = append(keys, key)
}
}
for _, key := range keys {
entry, targetExists := target[key]
index, exists := archive.Find(key)
if !targetExists {
if exists {
if _, err := archive.RemoveFiles([]int32{index}); err != nil {
return nil, err
}
}
continue
}
if exists {
if value, ok := currentSnapshot[key]; ok && sameVersionEntry(value, entry) {
continue
}
}
content, err := readVersionContent(repo, base, entry)
if err != nil {
return nil, err
}
if exists && archive.File(index).DataType != entry.DataType {
if _, err := archive.RemoveFiles([]int32{index}); err != nil {
return nil, err
}
exists = false
}
if !exists {
index := archive.AddFile(entry.Path, nil, entry.DataType)
if err := setVersionContent(archive, index, content); err != nil {
return nil, err
}
continue
}
if err := setVersionContent(archive, index, content); err != nil {
return nil, err
}
}
archive.SetSourcePath(outputPath)
return archive, nil
}
func readVersionContent(repo *pvfversion.Repository, base *pvf.Archive, entry pvfversion.Entry) (pvfversion.Content, error) {
if object, err := repo.ReadObject(entry.Hash); err == nil {
if pvfversion.HashBytes(object) != entry.Hash {
return pvfversion.Content{}, fmt.Errorf("版本对象 hash 校验失败: %s", entry.Path)
}
dataType, data, decodeErr := pvfversion.DecodeContent(object)
if decodeErr != nil {
return pvfversion.Content{}, decodeErr
}
if dataType != entry.DataType {
return pvfversion.Content{}, fmt.Errorf("版本对象类型不匹配: %s", entry.Path)
}
return pvfversion.Content{Entry: entry, Raw: data}, nil
}
values, err := pvfversion.ContentSnapshotFromArchive(base, []string{entry.Path})
if err != nil {
return pvfversion.Content{}, err
}
value, ok := values[pvfversion.CanonicalPath(entry.Path)]
if !ok || value.Hash != entry.Hash || value.DataType != entry.DataType {
return pvfversion.Content{}, fmt.Errorf("找不到版本对象: %s", entry.Path)
}
return value, nil
}
func setVersionContent(archive *pvf.Archive, index int32, content pvfversion.Content) error {
switch content.DataType {
case pvf.TypeScript, pvf.TypeUnicode:
return archive.SetText(index, string(content.Raw))
default:
return archive.SetRawBytes(index, content.Raw)
}
}
func applyVersionContentPathsLocked(c *core, paths []string, desired pvfversion.ContentSnapshot) error {
return applyContentPathsLocked(c, paths, desired, true)
}
// applyContentPathsLocked materializes desired onto the live archive. When
// compareWorking is set, entries the version working snapshot already matches
// are skipped (undo/checkout). Backup recovery passes false: there the backup is
// authoritative for every recovered path, so a payload can never be dropped
// because some other snapshot happens to agree with it.
func applyContentPathsLocked(c *core, paths []string, desired pvfversion.ContentSnapshot, compareWorking bool) error {
// Undo is a live archive mutation even when the file table does not change.
// Advance the shared revision before touching payloads so any script
// preview or running transaction is rejected/cancelled consistently.
c.batchRevision++
c.batchPlan = nil
c.invalidateScriptLocked()
remove := make([]int32, 0)
for _, key := range paths {
index, exists := c.archive.Find(key)
want, hasWant := desired[pvfversion.CanonicalPath(key)]
if !exists {
continue
}
if !hasWant || c.archive.File(index).DataType != want.DataType {
remove = append(remove, index)
}
}
structural := len(remove) > 0
if len(remove) > 0 {
if _, err := c.archive.RemoveFiles(remove); err != nil {
return err
}
}
touchedIndexes := make(map[int32]struct{}, len(desired))
for key, content := range desired {
index, exists := c.archive.Find(key)
if !exists {
index := c.archive.AddFile(content.Path, nil, content.DataType)
if err := setVersionContent(c.archive, index, content); err != nil {
return err
}
structural = true
continue
}
if compareWorking {
if current, ok := c.versionWorking[key]; ok && sameVersionEntry(current, content.Entry) {
continue
}
}
if err := setVersionContent(c.archive, index, content); err != nil {
return err
}
touchedIndexes[index] = struct{}{}
}
if structural {
if err := c.rebuildArchiveIndexesLocked(c.archive); err != nil {
return err
}
} else {
refreshArchiveIndexMetadataLocked(c, touchedIndexes)
}
c.editorText = make(map[int32]string)
c.editorAnnotation = editorAnnotationCache{}
c.annotationRelations = make(map[string]map[string]*relationTarget)
c.markAdvancedSearchStaleLocked(c.archive)
return nil
}
// refreshArchiveIndexMetadataLocked updates size/type fields after payload-only
// changes. Paths and directory membership are unchanged, so rebuilding the
// complete tree index is unnecessary for ordinary text undo operations.
func refreshArchiveIndexMetadataLocked(c *core, indexes map[int32]struct{}) {
if len(indexes) == 0 || c.archive == nil {
return
}
if c.diskIndex != nil {
_ = c.diskIndex.refreshFileMetadata(c.archive, indexes)
return
}
for index := range c.sortedPaths {
entry := &c.sortedPaths[index]
if _, ok := indexes[entry.idx]; !ok {
continue
}
file := c.archive.File(entry.idx)
entry.size = file.DataSize
entry.typ = file.DataType
}
for _, children := range c.dirChildren {
for _, node := range children {
if node.IsDir {
continue
}
if _, ok := indexes[node.FileIndex]; !ok {
continue
}
file := c.archive.File(node.FileIndex)
node.Size = file.DataSize
node.DataType = file.DataType
}
}
}
func versionChangeDTO(change pvfversion.FileChange) *VersionChange {
return &VersionChange{
Path: change.DisplayPath, Operation: string(change.Operation),
BeforeHash: change.BeforeHash, AfterHash: change.AfterHash,
BeforeType: change.BeforeType, AfterType: change.AfterType,
}
}
func versionCommitDTO(commit pvfversion.Commit) *VersionCommit {
return &VersionCommit{
ID: commit.ID, ParentID: commit.ParentID, Message: commit.Message,
CreatedAt: commit.CreatedAt, ChangeCount: commit.ChangeCount,
}
}