mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
Authentication (previous phase, now landed together with its follow-ups): - Email+password+name accounts behind an AuthProvider interface; the OSS server ships BuiltinAuth only (file-backed auth.json: bcrypt password hashes + SHA-256 token digests, plaintext never stored; server-owned /auth/* pages; managed deployments can swap in another provider). - bdrive login: loopback-callback browser flow (sign-up on the page, the terminal finishes itself) with a device-code fallback for headless machines; long-lived revocable device tokens in settings.json. - Password reset via plain SMTP (stdlib) with a log-link fallback when no SMTP is configured. Move-proof projects: - .bdrive is now a directory; config.json carries a stable mount id. The volume store (~/.bdrive/volumes/<mount-id>/) and registry are keyed by that id — never the folder path — so renames/moves are free. - The daemon re-reads the project config each tick and exits cleanly (propagating nothing) when its folder vanishes; the registry self-heals and the next bdrive command at the new location resumes with zero spurious changes. bdrive init is the front door (mnt/umnt removed; bdrive stop pauses): - Interactive on a TTY (create new / connect existing project from the server's list; whole folder / shared subfolder via the include list), full flag bypass (--name/--project/--shared/--yes), never prompts without a TTY. Runs the login flow first when there is no session. Default server: beardrive.ai (config.DefaultServer). Web history (revert-ready): - Hubs now always require auth; journal ops carry the signed-in account (user/user_name) alongside the git/OS fallback author. - File-backed device registry: per-device name, OS, account, and the public IP the server observed, joined into history at read time. - GET /api/p/<id>/history?path=|prefix= (newest first) and GET /api/p/<id>/blob?sha= stream any exact version — blobs are retained forever, so the next phase's revert is re-putting an old blob. - UI: History button (file versions or project feed), per-folder history shortcut, view/download of any past version. Tests: auth flows (callback, device-code, reset single-use, persistence, gating), history API + device registry, folder-move survival, registry self-heal, ops-carry-account; docs (README/SKILL/CLAUDE) updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
621 lines
17 KiB
Go
621 lines
17 KiB
Go
// Package syncer drives a volume's sync cycle:
|
|
//
|
|
// scan → commit local ops → pull peer journals → preserve conflicts →
|
|
// materialize merged state → push blobs + own journal
|
|
//
|
|
// Scanning always happens before pulling, so local edits are committed to the
|
|
// journal (and their content captured in the blob store) before any remote
|
|
// state can overwrite the working folder. Concurrent edits resolve
|
|
// deterministically last-writer-wins; the losing local version is preserved
|
|
// as a "<name>.bdrive-conflict-<device>-<time>" file that syncs like any other.
|
|
package syncer
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/runbear-io/beardrive/internal/config"
|
|
"github.com/runbear-io/beardrive/internal/journal"
|
|
"github.com/runbear-io/beardrive/internal/remote"
|
|
"github.com/runbear-io/beardrive/internal/store"
|
|
)
|
|
|
|
// Session ties a working folder to its volume store and (optionally) remote.
|
|
type Session struct {
|
|
Folder string
|
|
MountID string // the stable project mount id from .bdrive/config.json
|
|
Store *store.Store
|
|
Device config.Device
|
|
// Account is the signed-in user (from `bdrive login`); ops carry it so
|
|
// history shows who changed what. Zero on offline/no-auth setups —
|
|
// Device.Author remains the fallback identity.
|
|
Account config.Settings
|
|
Backend remote.Backend // nil = work offline
|
|
}
|
|
|
|
func (s *Session) mountID() string {
|
|
if s.MountID != "" {
|
|
return s.MountID
|
|
}
|
|
// Fallback for sessions built without a project (tests): key the state
|
|
// cache by the folder path.
|
|
sum := sha256.Sum256([]byte(s.Folder))
|
|
return hex.EncodeToString(sum[:])[:12]
|
|
}
|
|
|
|
// Result summarizes one sync cycle.
|
|
type Result struct {
|
|
LocalOps int // local changes committed to the journal
|
|
PulledOps int // ops received from other devices
|
|
Conflicts int // conflict copies created
|
|
Materialized int // files written/removed in the working folder
|
|
Pushed bool // own journal/blobs uploaded
|
|
Offline bool // remote configured but unreachable this cycle
|
|
OfflineErr error
|
|
}
|
|
|
|
func (r *Result) Activity() bool {
|
|
return r.LocalOps > 0 || r.PulledOps > 0 || r.Conflicts > 0 || r.Materialized > 0
|
|
}
|
|
|
|
// The .bdrive settings dir (config.ProjectDir) never syncs: it is the
|
|
// mount's local identity, and syncing it would let one device silently
|
|
// repoint another.
|
|
var ignoreNames = map[string]bool{".DS_Store": true}
|
|
var ignoreDirs = map[string]bool{".git": true, config.ProjectDir: true}
|
|
|
|
func ignoredFile(name string) bool {
|
|
return ignoreNames[name] || strings.HasPrefix(name, ".bdrive-tmp-")
|
|
}
|
|
|
|
// Cycle runs one full scan/sync/materialize pass under the volume lock.
|
|
func (s *Session) Cycle(ctx context.Context) (*Result, error) {
|
|
unlock, err := s.Store.Lock()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lock volume: %w", err)
|
|
}
|
|
defer unlock()
|
|
|
|
res := &Result{}
|
|
cache, err := s.Store.LoadCache(s.mountID())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load state: %w", err)
|
|
}
|
|
st, err := s.Store.LoadSync()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load sync state: %w", err)
|
|
}
|
|
myOps, err := s.Store.DeviceOps(s.Device.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read own journal: %w", err)
|
|
}
|
|
proj, _, err := config.LoadProject(s.Folder)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
filter, err := loadFilter(s.Folder, proj.Include)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load %s: %w", IgnoreFile, err)
|
|
}
|
|
|
|
// 1. Scan the working folder and journal any local changes.
|
|
localOps, err := s.scan(cache, &st, int64(len(myOps)), filter)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan: %w", err)
|
|
}
|
|
if len(localOps) > 0 {
|
|
if err := s.Store.AppendOps(s.Device.ID, localOps); err != nil {
|
|
return nil, fmt.Errorf("append journal: %w", err)
|
|
}
|
|
myOps = append(myOps, localOps...)
|
|
res.LocalOps = len(localOps)
|
|
}
|
|
|
|
// 2. Pull journals + blobs from other devices.
|
|
var pulled []journal.Op
|
|
if s.Backend != nil {
|
|
pulled, err = s.pull(ctx)
|
|
if err != nil {
|
|
res.Offline = true
|
|
res.OfflineErr = err
|
|
}
|
|
res.PulledOps = len(pulled)
|
|
for _, op := range pulled {
|
|
if op.Lamport > st.Lamport {
|
|
st.Lamport = op.Lamport
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Preserve losing local edits as conflict copies.
|
|
if len(pulled) > 0 {
|
|
conflictOps, err := s.conflictCopies(myOps, st.PushedOps, pulled, &st)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(conflictOps) > 0 {
|
|
if err := s.Store.AppendOps(s.Device.ID, conflictOps); err != nil {
|
|
return nil, fmt.Errorf("append conflict ops: %w", err)
|
|
}
|
|
myOps = append(myOps, conflictOps...)
|
|
res.Conflicts = len(conflictOps)
|
|
}
|
|
}
|
|
|
|
// 4. Materialize the merged state into the working folder.
|
|
all, err := s.Store.AllOps()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read journals: %w", err)
|
|
}
|
|
target := journal.Replay(all)
|
|
n, err := s.materialize(target, cache, filter)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("materialize: %w", err)
|
|
}
|
|
res.Materialized = n
|
|
|
|
// 5. Push our blobs and journal.
|
|
if s.Backend != nil && !res.Offline && int64(len(myOps)) > st.PushedOps {
|
|
if err := s.push(ctx, myOps, &st); err != nil {
|
|
res.Offline = true
|
|
res.OfflineErr = err
|
|
} else {
|
|
res.Pushed = true
|
|
}
|
|
}
|
|
|
|
if err := s.Store.SaveCache(s.mountID(), cache); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.Store.SaveSync(st); err != nil {
|
|
return nil, err
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// scan diffs the working folder against the state cache and returns ops for
|
|
// every local change, storing new content in the blob store. Filtered paths
|
|
// are neither journaled nor deleted: a path that becomes ignored is dropped
|
|
// from the cache without a delete op, so opting out locally never removes
|
|
// the file from other devices.
|
|
func (s *Session) scan(cache map[string]store.CachedFile, st *store.SyncState, seqBase int64, filter *Filter) ([]journal.Op, error) {
|
|
seen := make(map[string]bool, len(cache))
|
|
var ops []journal.Op
|
|
nextOp := func(kind, rel string) journal.Op {
|
|
st.Lamport++
|
|
seqBase++
|
|
return journal.Op{
|
|
Seq: seqBase, Lamport: st.Lamport, Time: time.Now().UTC(),
|
|
Device: s.Device.ID, DeviceName: s.Device.Name, Author: s.Device.Author,
|
|
User: s.Account.Email, UserName: s.Account.Name,
|
|
Kind: kind, Path: rel,
|
|
}
|
|
}
|
|
|
|
err := filepath.WalkDir(s.Folder, func(p string, d fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return nil // skip unreadable entries
|
|
}
|
|
rel, err := filepath.Rel(s.Folder, p)
|
|
if err != nil || rel == "." {
|
|
return nil
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
if d.IsDir() {
|
|
if ignoreDirs[d.Name()] || filter.PruneDir(rel) {
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if !d.Type().IsRegular() || ignoredFile(d.Name()) || filter.Skip(rel) {
|
|
return nil
|
|
}
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
seen[rel] = true
|
|
size, mt := info.Size(), info.ModTime().UnixNano()
|
|
mode := uint32(info.Mode().Perm())
|
|
c, ok := cache[rel]
|
|
if ok && c.Size == size && c.MTimeNS == mt {
|
|
return nil // unchanged (cheap path)
|
|
}
|
|
sum, n, err := s.Store.PutBlobFile(p)
|
|
if err != nil {
|
|
return nil // file vanished or unreadable; next cycle
|
|
}
|
|
if ok && c.Blob == sum {
|
|
// content unchanged, just touched
|
|
c.Size, c.MTimeNS, c.Mode = n, mt, mode
|
|
cache[rel] = c
|
|
return nil
|
|
}
|
|
op := nextOp(journal.KindPut, rel)
|
|
op.Blob, op.Size, op.Mode = sum, n, mode
|
|
ops = append(ops, op)
|
|
cache[rel] = store.CachedFile{Blob: sum, Size: n, Mode: mode, MTimeNS: mt}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for rel := range cache {
|
|
if seen[rel] {
|
|
continue
|
|
}
|
|
if filter.Skip(rel) {
|
|
delete(cache, rel) // newly filtered, not deleted: stop tracking silently
|
|
continue
|
|
}
|
|
ops = append(ops, nextOp(journal.KindDelete, rel))
|
|
delete(cache, rel)
|
|
}
|
|
return ops, nil
|
|
}
|
|
|
|
// pull fetches journals that grew on the remote and any blobs we are missing
|
|
// for the new ops. Returns only the ops we had not seen before.
|
|
func (s *Session) pull(ctx context.Context) ([]journal.Op, error) {
|
|
objs, err := s.Backend.List(ctx, "journal/")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var newOps []journal.Op
|
|
for _, o := range objs {
|
|
name := strings.TrimPrefix(o.Key, "journal/")
|
|
if !strings.HasSuffix(name, ".jsonl") || strings.Contains(name, "/") {
|
|
continue
|
|
}
|
|
dev := strings.TrimSuffix(name, ".jsonl")
|
|
if dev == s.Device.ID {
|
|
continue
|
|
}
|
|
lp := s.Store.JournalPath(dev)
|
|
var localSize int64
|
|
if fi, err := os.Stat(lp); err == nil {
|
|
localSize = fi.Size()
|
|
}
|
|
if o.Size <= localSize && localSize > 0 {
|
|
continue
|
|
}
|
|
rc, err := s.Backend.Get(ctx, o.Key)
|
|
if err != nil {
|
|
return newOps, err
|
|
}
|
|
data, err := io.ReadAll(rc)
|
|
rc.Close()
|
|
if err != nil {
|
|
return newOps, err
|
|
}
|
|
fresh, err := journal.Parse(data)
|
|
if err != nil {
|
|
continue // corrupt remote journal; ignore rather than break sync
|
|
}
|
|
prev, err := s.Store.DeviceOps(dev)
|
|
if err != nil {
|
|
return newOps, err
|
|
}
|
|
if len(fresh) <= len(prev) {
|
|
continue
|
|
}
|
|
if err := store.WriteFileAtomic(lp, data, 0o644); err != nil {
|
|
return newOps, err
|
|
}
|
|
newOps = append(newOps, fresh[len(prev):]...)
|
|
}
|
|
|
|
// Fetch content for new ops. Blobs are uploaded before journals on push,
|
|
// so anything referenced should exist.
|
|
for _, op := range newOps {
|
|
if op.Kind != journal.KindPut || op.Blob == "" || s.Store.HasBlob(op.Blob) {
|
|
continue
|
|
}
|
|
rc, err := s.Backend.Get(ctx, "blobs/"+op.Blob)
|
|
if err != nil {
|
|
return newOps, fmt.Errorf("fetch blob %s: %w", op.Blob[:12], err)
|
|
}
|
|
sum, _, err := s.Store.PutBlobReader(rc)
|
|
rc.Close()
|
|
if err != nil {
|
|
return newOps, err
|
|
}
|
|
if sum != op.Blob {
|
|
return newOps, fmt.Errorf("blob %s corrupt on remote (got %s)", op.Blob[:12], sum[:12])
|
|
}
|
|
}
|
|
return newOps, nil
|
|
}
|
|
|
|
// conflictCopies detects paths edited concurrently — we hold a not-yet-pushed
|
|
// op and just pulled a competing op for the same path. Last-writer-wins
|
|
// resolves the path itself deterministically; here the device that observed
|
|
// the concurrency preserves the losing version (ours or the pulled one) as a
|
|
// conflict-copy file so no content is silently dropped.
|
|
func (s *Session) conflictCopies(myOps []journal.Op, pushed int64, pulled []journal.Op, st *store.SyncState) ([]journal.Op, error) {
|
|
if pushed > int64(len(myOps)) {
|
|
pushed = int64(len(myOps))
|
|
}
|
|
unpushed := map[string]journal.Op{}
|
|
for _, op := range myOps[pushed:] {
|
|
unpushed[op.Path] = op // latest local op per path
|
|
}
|
|
pulledLatest := map[string]journal.Op{}
|
|
for _, op := range pulled {
|
|
if _, ok := unpushed[op.Path]; !ok {
|
|
continue
|
|
}
|
|
if prev, ok := pulledLatest[op.Path]; !ok || journal.Less(prev, op) {
|
|
pulledLatest[op.Path] = op
|
|
}
|
|
}
|
|
if len(pulledLatest) == 0 {
|
|
return nil, nil
|
|
}
|
|
all, err := s.Store.AllOps()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
state := journal.Replay(all)
|
|
seqBase := int64(len(myOps))
|
|
var out []journal.Op
|
|
for p, theirs := range pulledLatest {
|
|
mine := unpushed[p]
|
|
cur, exists := state[p]
|
|
mineWon := (mine.Kind == journal.KindPut && exists && cur.Blob == mine.Blob) ||
|
|
(mine.Kind == journal.KindDelete && !exists)
|
|
loser := mine
|
|
if mineWon {
|
|
loser = theirs
|
|
}
|
|
if loser.Kind != journal.KindPut || loser.Blob == "" {
|
|
continue // a lost delete needs no preservation
|
|
}
|
|
if exists && cur.Blob == loser.Blob {
|
|
continue // identical content; nothing actually lost
|
|
}
|
|
if !s.Store.HasBlob(loser.Blob) {
|
|
continue // content unavailable (partial pull); skip rather than fail
|
|
}
|
|
st.Lamport++
|
|
seqBase++
|
|
out = append(out, journal.Op{
|
|
Seq: seqBase, Lamport: st.Lamport, Time: time.Now().UTC(),
|
|
Device: s.Device.ID, DeviceName: s.Device.Name, Author: s.Device.Author,
|
|
User: s.Account.Email, UserName: s.Account.Name,
|
|
Kind: journal.KindPut, Path: conflictName(p, loser.DeviceName, loser.Time),
|
|
Blob: loser.Blob, Size: loser.Size, Mode: loser.Mode,
|
|
Note: "conflict copy of " + p,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func conflictName(p, deviceName string, t time.Time) string {
|
|
return p + ".bdrive-conflict-" + sanitize(deviceName) + "-" + t.UTC().Format("20060102T150405Z")
|
|
}
|
|
|
|
func sanitize(s string) string {
|
|
return strings.Map(func(r rune) rune {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
|
return r
|
|
default:
|
|
return '-'
|
|
}
|
|
}, s)
|
|
}
|
|
|
|
// materialize applies the merged state to the working folder, never
|
|
// clobbering files that changed since the scan earlier in this cycle.
|
|
// Filtered paths are not written: other devices' files that match the local
|
|
// ignore/include rules simply don't appear here.
|
|
func (s *Session) materialize(target map[string]journal.FileState, cache map[string]store.CachedFile, filter *Filter) (int, error) {
|
|
changed := 0
|
|
for rel, want := range target {
|
|
if filter.Skip(rel) {
|
|
continue
|
|
}
|
|
c, ok := cache[rel]
|
|
if ok && c.Blob == want.Blob && c.Mode == want.Mode {
|
|
continue
|
|
}
|
|
abs := filepath.Join(s.Folder, filepath.FromSlash(rel))
|
|
if fi, err := os.Stat(abs); err == nil {
|
|
if ok && (fi.Size() != c.Size || fi.ModTime().UnixNano() != c.MTimeNS) {
|
|
continue // dirty: changed mid-cycle, next scan commits it
|
|
}
|
|
if !ok {
|
|
// Untracked file already at this path: adopt if identical,
|
|
// otherwise leave it for the next scan to journal.
|
|
sum, err := hashFile(abs)
|
|
if err != nil || sum != want.Blob {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
if !s.Store.HasBlob(want.Blob) {
|
|
continue // content not fetched yet; retry next cycle
|
|
}
|
|
if err := s.writeFile(abs, want); err != nil {
|
|
return changed, fmt.Errorf("write %s: %w", rel, err)
|
|
}
|
|
fi, err := os.Stat(abs)
|
|
if err != nil {
|
|
return changed, err
|
|
}
|
|
cache[rel] = store.CachedFile{Blob: want.Blob, Size: fi.Size(), Mode: want.Mode, MTimeNS: fi.ModTime().UnixNano()}
|
|
changed++
|
|
}
|
|
|
|
for rel, c := range cache {
|
|
if _, ok := target[rel]; ok {
|
|
continue
|
|
}
|
|
abs := filepath.Join(s.Folder, filepath.FromSlash(rel))
|
|
if fi, err := os.Stat(abs); err == nil {
|
|
if fi.Size() != c.Size || fi.ModTime().UnixNano() != c.MTimeNS {
|
|
continue // dirty; do not delete fresh local edits
|
|
}
|
|
if err := os.Remove(abs); err != nil {
|
|
return changed, err
|
|
}
|
|
pruneEmptyDirs(s.Folder, filepath.Dir(abs))
|
|
}
|
|
delete(cache, rel)
|
|
changed++
|
|
}
|
|
return changed, nil
|
|
}
|
|
|
|
func (s *Session) writeFile(abs string, want journal.FileState) error {
|
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
|
return err
|
|
}
|
|
src, err := s.Store.OpenBlob(want.Blob)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer src.Close()
|
|
tmp, err := os.CreateTemp(filepath.Dir(abs), ".bdrive-tmp-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(tmp.Name())
|
|
if _, err := io.Copy(tmp, src); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
mode := os.FileMode(want.Mode)
|
|
if mode == 0 {
|
|
mode = 0o644
|
|
}
|
|
if err := os.Chmod(tmp.Name(), mode); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp.Name(), abs)
|
|
}
|
|
|
|
// push uploads blobs referenced by unpushed ops, then the journal itself.
|
|
// Blob-before-journal ordering means peers never see an op whose content is
|
|
// missing.
|
|
func (s *Session) push(ctx context.Context, myOps []journal.Op, st *store.SyncState) error {
|
|
if st.PushedOps > int64(len(myOps)) {
|
|
st.PushedOps = int64(len(myOps))
|
|
}
|
|
uploaded := map[string]bool{}
|
|
for _, op := range myOps[st.PushedOps:] {
|
|
if op.Kind != journal.KindPut || op.Blob == "" || uploaded[op.Blob] {
|
|
continue
|
|
}
|
|
key := "blobs/" + op.Blob
|
|
ok, err := s.Backend.Exists(ctx, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
f, err := s.Store.OpenBlob(op.Blob)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fi, err := f.Stat()
|
|
if err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
err = s.Backend.Put(ctx, key, f, fi.Size())
|
|
f.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
uploaded[op.Blob] = true
|
|
}
|
|
|
|
jp := s.Store.JournalPath(s.Device.ID)
|
|
f, err := os.Open(jp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
fi, err := f.Stat()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.Backend.Put(ctx, "journal/"+s.Device.ID+".jsonl", f, fi.Size()); err != nil {
|
|
return err
|
|
}
|
|
st.PushedOps = int64(len(myOps))
|
|
return nil
|
|
}
|
|
|
|
func hashFile(path string) (string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
h := sha256.New()
|
|
if _, err := io.Copy(h, f); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil)), nil
|
|
}
|
|
|
|
func pruneEmptyDirs(root, dir string) {
|
|
root = filepath.Clean(root)
|
|
for {
|
|
dir = filepath.Clean(dir)
|
|
if dir == root || !strings.HasPrefix(dir, root+string(filepath.Separator)) {
|
|
return
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil || len(entries) > 0 {
|
|
return
|
|
}
|
|
if err := os.Remove(dir); err != nil {
|
|
return
|
|
}
|
|
dir = filepath.Dir(dir)
|
|
}
|
|
}
|
|
|
|
// LogEntries returns the volume history, newest first.
|
|
func LogEntries(st *store.Store, pathFilter string, limit int) ([]journal.Op, error) {
|
|
all, err := st.AllOps()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
journal.Sort(all)
|
|
// reverse
|
|
for i, j := 0, len(all)-1; i < j; i, j = i+1, j-1 {
|
|
all[i], all[j] = all[j], all[i]
|
|
}
|
|
if pathFilter != "" {
|
|
filtered := all[:0]
|
|
for _, op := range all {
|
|
if op.Path == pathFilter || strings.HasPrefix(op.Path, pathFilter+"/") || path.Dir(op.Path) == pathFilter {
|
|
filtered = append(filtered, op)
|
|
}
|
|
}
|
|
all = filtered
|
|
}
|
|
if limit > 0 && len(all) > limit {
|
|
all = all[:limit]
|
|
}
|
|
return all, nil
|
|
}
|