filesync

package
v0.56.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 11, 2026 License: MIT Imports: 20 Imported by: 0

Documentation

Overview

Package filesync keeps a local folder and a remote filex folder in step.

⚠ Not named `sync`: internal/sync already means something else here (walking a storage to refresh the catalog). This package is the Dropbox-shaped thing — a folder on someone's disk mirrored against a server path.

The design is deliberately split in two:

  • plan.go is a PURE function. Local snapshot + remote snapshot + the baseline from the last run go in, a list of actions comes out. No disk, no network, no clock. This is where every decision about someone's files is made, and it is decided in code that a test can drive through every branch without a server.
  • engine.go performs those actions and writes the new baseline.

Sync code deletes files for a living. The rules below are chosen so that the failure mode is "too many copies", never "the file is gone".

Index

Constants

View Source
const (
	DefaultCheckpointEvery = 50
)

DefaultCheckpointEvery is how many settled transfers pass between two mid-run baseline writes; checkpointInterval caps the wait in time.

View Source
const DefaultTransfers = 4

DefaultTransfers is how many uploads/downloads run concurrently. Four is enough to stop a tree of small files being priced at one full round-trip each, without stampeding a small server.

View Source
const MassUploadThreshold = 100

MassUploadThreshold is how many local-only files a FIRST run may upload into a server folder that already has content before it stops and asks.

View Source
const TrashRetentionDays = 30

TrashRetentionDays is how long a file deleted by sync is kept on this machine before it is really gone.

Sync deletes files because the OTHER side lost them, which is exactly when a mistake is least visible: a folder emptied on a phone quietly empties the laptop too. The local copy is the last line of defence, so it is kept for a month rather than a few days.

Variables

View Source
var ErrLocalChanged = errors.New("changed on this computer while it was being synced")

ErrLocalChanged means a file on this computer changed after the run looked at it, so the action planned for it (replace it with the server's copy, or move it to the trash) was NOT carried out. Same consequence as ErrRemoteChanged, from the other side.

View Source
var ErrPairBusy = errors.New("another filex on this computer is syncing this pair")

ErrPairBusy is what a pass returns when another process on this computer holds the pair: nothing was read or written.

View Source
var ErrRemoteChanged = errors.New("changed on the server while it was being synced")

ErrRemoteChanged means the server refused a conditional upload: the file was written on the server (typically saved in the browser) after the listing this run planned from. Nothing was written. The path keeps its old baseline row, so the next pass sees BOTH sides changed and keeps both.

View Source
var ErrRemoteNotFound = errors.New("remote folder not found")

ErrRemoteNotFound marks a listing error that means "this folder does not exist on the server" — the ONE kind of failure WalkRemote may skip (a folder deleted between its parent's listing and its own). The API adapter wraps a 404 with it.

Every other error fails the walk. A folder that merely could not be listed — a timeout, a 5xx, the half-dead connection this client now detects — must never come back as "gone from the server": the planner would read that as a delete and bin the local copy of the whole subtree, and if it happened in the settle pass the baseline would lose the subtree instead, turning every uploaded file in it into a conflict pair on the next round. The walk is eight listings wide now, so one bad second can touch eight folders at once.

Functions

func DefaultStoreDir

func DefaultStoreDir() (string, error)

DefaultStoreDir is $FILEX_SYNC_DIR when set, otherwise ~/.filex/sync (beside the CLI's own config, which honours $FILEX_CLI_CONFIG the same way).

⚠ The override is not a convenience. This directory holds the pairs, the per-pair baselines and the local trash — which contains real copies of files the engine deleted. The desktop app's PORTABLE build runs from a USB stick on a machine that is not the user's, and everything it keeps has to live in its own folder so that deleting that folder leaves nothing behind. Without this, a portable copy would seed somebody else's home directory with another person's deleted files.

func FingerprintSnapshot

func FingerprintSnapshot(s Snapshot) string

FingerprintSnapshot digests a snapshot: two equal fingerprints mean the same paths with the same signatures. The watcher keeps one per pair to tell, with a local walk and no request at all, whether this side changed since the last run.

func IgnoredName

func IgnoredName(name string) bool

IgnoredName reports whether a file or folder name is never sync material: the engine's own state, OS junk, filex's own reserved names (syspath) and its half-written downloads. A file-system watcher uses it to drop events the walk would ignore anyway — above all the `.filex-part-*` temporaries every download creates, which would otherwise wake the engine up once per file it writes.

func LocalFingerprint

func LocalFingerprint(p Pair) (string, error)

LocalFingerprint walks a pair's local side exactly as a run does and fingerprints it. It is the same value a full run reports in Result.LocalFingerprint when nothing changed on this machine in between.

Types

type Action

type Action struct {
	Kind ActionKind
	Rel  string
	// ConflictName is set only for ActionConflict: the name the SERVER's copy
	// is saved under locally so the user's own edit keeps its filename.
	ConflictName string
	// Reason is human-readable and shown in `filex sync run` output. Sync is
	// the one subsystem where people need to see why their file moved.
	Reason string
	// RemoteMod carries the server file's mtime (Unix millis) on download
	// and conflict actions, so the local copy can be stamped with it. 0 =
	// the storage reported none.
	RemoteMod int64
	// RemoteSize carries the size the server LISTED on download and conflict
	// actions. The engine refuses to install a body of any other length: it is
	// how a status report sent in place of a file (a 202 "preparing" JSON, a
	// proxy's error page) is told apart from the file.
	RemoteSize int64
	// Mixed marks a conflict between a folder on one side and a file on the
	// other. Nothing is downloaded or uploaded for it: the user is told.
	Mixed bool
	// Hold marks a conflict planned while the pair holds its local-only items
	// for a decision (Pair.HoldNew): it is settled if both sides turn out to
	// hold the same bytes, and otherwise left exactly as it is.
	Hold bool
	// LocalSig / RemoteSig are the two sides' signatures AT PLAN TIME
	// (Node.Signature(); "" = absent on that side). The plan is a decision
	// about the world as it was when the snapshots were taken, and these are
	// what let the engine notice that the world moved before the decision
	// was carried out:
	//
	//   - a download or local delete only replaces/removes the local file if
	//     it still carries LocalSig — otherwise it was edited here in the
	//     meantime and the "changed in both places" rule applies next round;
	//   - an upload is sent with RemoteSig as its precondition ("expect"), so
	//     the server refuses it if the file was saved in the browser after the
	//     listing — same rule, from the other side;
	//   - the new baseline row is built from them, never from a later
	//     snapshot (see ledger in engine.go).
	//
	// ⚠⚠ Measured before this existed: a web edit landing while a run was
	// already under way was written into the baseline as "agreed" by the
	// settle pass and never downloaded at all — the local file stayed old,
	// silently, until the next edit on either side.
	LocalSig  string
	RemoteSig string
}

Action is one unit of work. Rel is always the pair-relative path.

func Plan

func Plan(local, remote Snapshot, base Baseline, opts Options) []Action

Plan decides what to do. It is pure: same inputs, same output, no I/O.

The rules, in the order they are applied per path:

both sides unchanged since the baseline      → nothing
present on one side only, never synced       → copy it to the other side
changed on one side only                     → copy that change over
changed on both sides, and differently       → conflict (keep both)
gone from one side, unchanged on the other   → delete the other (not on a first run)
gone from one side, CHANGED on the other     → resurrect: copy the change back

The last rule is the one that matters most: a delete never beats an edit.

type ActionKind

type ActionKind string

ActionKind is what the engine should do about one path.

const (
	ActionUpload      ActionKind = "upload"       // local → remote
	ActionDownload    ActionKind = "download"     // remote → local
	ActionMkdirRemote ActionKind = "mkdir_remote" // create a folder on the server
	ActionMkdirLocal  ActionKind = "mkdir_local"  // create a folder on disk
	ActionDeleteLocal ActionKind = "delete_local" // remove locally (into local trash)
	ActionDeleteRemot ActionKind = "delete_remote"
	// ActionConflict keeps both versions: the remote copy is downloaded beside
	// the local one under ConflictName, then the local file is uploaded.
	ActionConflict ActionKind = "conflict"
)

type Baseline

type Baseline map[string]BaselineEntry

Baseline is the state carried between runs.

func NextBaseline

func NextBaseline(local, remote Snapshot) Baseline

NextBaseline rebuilds the carried state from the two snapshots taken AFTER a run. Paths still disagreeing (a conflict nobody resolved) are left out, so the next run looks at them again rather than assuming they are settled.

type BaselineEntry

type BaselineEntry struct {
	Local  string `json:"local"`  // Signature() of the local node
	Remote string `json:"remote"` // Signature() of the remote node
	IsDir  bool   `json:"is_dir"`
}

BaselineEntry records what a path looked like on BOTH sides when they were last agreed. Absence from the baseline means "never synced".

type BusyError added in v0.44.2

type BusyError struct {
	Pair   string
	Holder LockHolder
}

BusyError is ErrPairBusy for one pair, with what the lock file says about its holder.

func (*BusyError) Error added in v0.44.2

func (e *BusyError) Error() string

func (*BusyError) Is added in v0.44.2

func (e *BusyError) Is(target error) bool

type Engine

type Engine struct {
	Pair  Pair
	API   RemoteFS
	Store *Store
	// TrashDays is how long locally-deleted files are kept before being
	// dropped. Zero means the default (TrashRetentionDays).
	TrashDays int
	// Transfers caps how many uploads/downloads run at once. 0 means
	// DefaultTransfers; 1 restores the fully serial engine.
	Transfers int
	// CheckpointEvery is how many settled transfers may pass before the
	// baseline is written mid-run (see ledger). 0 means
	// DefaultCheckpointEvery.
	CheckpointEvery int
	// Log receives one line per action. Optional.
	Log func(string)
	// Progress receives one short status line per phase — inventory counts,
	// transfer counts, settling — and stays on even when the per-action Log
	// is silenced. The desktop app runs the engine with --quiet and mirrors
	// the last stdout line into its panel; before this hook a large first
	// sync spent its whole inventory phase (minutes of per-folder listings)
	// printing nothing, and looked dead enough that people cancelled it.
	// Optional.
	Progress func(string)
	// OnProgress receives the same reports as figures (ProgressEvent). A
	// caller that says them in words of its own sets it: `filex sync run
	// --json` turns each into an event whose sentence is in the reader's
	// language, so the desktop app no longer reads the English line above
	// with regular expressions. Optional; independent of Progress.
	OnProgress func(ProgressEvent)
	// StopOn, when set, is asked about every action that failed; true ends
	// the pass at once — the rest of the plan is not attempted, the ledger is
	// still written — and the pass returns that error. The CLI sets it to
	// "the server no longer accepts this token": a revoked token must not be
	// tried once for every remaining file.
	StopOn func(error) bool
	// Now is injectable for tests.
	Now func() time.Time
	// Lock is the pair's lock (Store.LockPair) when the caller holds it
	// across passes — `filex sync run --watch` does, for as long as it syncs
	// the pair. nil: each pass takes the lock itself and gives it back when
	// it ends. Either way no pass runs without it: a pair another process on
	// this computer is syncing fails the pass with a *BusyError before
	// anything is read (lock.go).
	Lock *PairLock
	// contains filtered or unexported fields
}

Engine runs one pair.

func (*Engine) LocalDirChanged

func (e *Engine) LocalDirChanged(dir string) (bool, error)

LocalDirChanged reports whether one local folder's direct children differ from what the baseline last agreed — without touching the network.

⚠ This is how the engine ignores its OWN writes. Every download it makes raises file-system events (the rename into place, the mtime stamp), and a watcher that answered each one with a server listing would spend a round trip per downloaded file finding out that nothing happened. The download records exactly the signature it left on disk, so an unchanged folder is recognised from the disk alone. A genuine edit changes size or mtime and fails this check.

func (*Engine) Run

func (e *Engine) Run(ctx context.Context) (Result, error)

Run performs one full pass: snapshot both sides, plan, apply, record what was agreed.

A failed action does not abort the run — the rest of the pair still syncs and the failure is reported. A path whose action failed keeps the baseline row it had, so the next pass looks at it again; that is what stops one broken file from permanently wedging a folder, and it is also what makes a change that raced the run (an edit saved while it was going) come back as a change instead of vanishing into the baseline. See ledger.

func (*Engine) RunDirs

func (e *Engine) RunDirs(ctx context.Context, dirs []string) (Result, error)

RunDirs reconciles only the given folders (pair-relative, "" = the pair's root). It is what a live change triggers; Run stays the full pass.

A pair with no history yet takes a full pass instead — the first run of a pair is a merge of both trees and must see all of them — and so does a pair whose local folder is missing (the full pass owns that refusal).

type ListedFile

type ListedFile struct {
	Basename     string
	IsDir        bool
	Size         int64
	LastModified int64 // Unix millis; 0 = the storage does not report one
}

ListedFile is one row of a remote listing.

type Listing

type Listing struct {
	Files []ListedFile
}

Listing is one remote directory. It mirrors the server's index response, reduced to what sync actually reads.

type LockHolder added in v0.44.2

type LockHolder struct {
	PID   int       `json:"pid"`
	Exe   string    `json:"exe,omitempty"`
	Since time.Time `json:"since"`
}

LockHolder is who took a pair's lock, as that process wrote it down.

func (LockHolder) String added in v0.44.2

func (h LockHolder) String() string

String is "process 1234, C:\…\filex.exe", or "" when nothing is known.

type Node

type Node struct {
	Rel   string
	IsDir bool
	Size  int64
	// ModMillis is the modification time in Unix milliseconds. Local and remote
	// clocks are NEVER compared to each other — see Signature.
	ModMillis int64
}

Node is one file or directory in a snapshot, keyed by its slash-separated path relative to the pair root ("" is the root itself and never appears).

func (Node) Signature

func (n Node) Signature() string

Signature is the cheap "has this changed?" test for one side.

⚠ Local and remote signatures are never compared against each other. An upload gives the server its own mtime, so the two sides legitimately differ the instant after a successful sync; comparing them would make every file look permanently conflicted. Instead each side is compared against what IT looked like at the end of the last run, which is what the baseline stores.

type Options

type Options struct {
	// FirstRun suppresses every delete. With no baseline there is no way to
	// tell "you deleted this" from "you have not downloaded this yet", and
	// guessing wrong empties someone's folder. A first run is a union merge.
	FirstRun bool
	// Now stamps conflict copy names. Zero value means the caller did not care
	// (tests); the engine always sets it.
	Now time.Time
}

Options tunes a plan.

type Pair

type Pair struct {
	ID     string `json:"id"`
	Local  string `json:"local"`  // absolute path on this machine
	Remote string `json:"remote"` // adapter://path on the server
	// Account is an opaque label the desktop app uses to remember which signed-in
	// server this pair belongs to. The engine only carries it.
	Account string `json:"account,omitempty"`
	Paused  bool   `json:"paused,omitempty"`
	// File marks a single-file pair: Local is a file path and Remote names a
	// file on the server. Same planner, same rules, same trash — the
	// snapshots just carry one entry.
	File bool `json:"file,omitempty"`
	// HoldNew says the pair holds this machine's unknown items for a decision
	// (hold.go), and Held how many. Both are READ from held/<pair-id>.json by
	// Store.LoadPairs and never written to pairs.json (see holdFile): they are
	// on the pair only so `sync list --json` — which the desktop app reads —
	// carries them.
	HoldNew bool `json:"hold_new,omitempty"`
	Held    int  `json:"held,omitempty"`
}

Pair is one folder mapping.

func (Pair) WatchRoot

func (p Pair) WatchRoot() string

WatchRoot is the server folder whose changes concern this pair: the pair's own folder, or — for a single-file pair — the folder holding the file.

type PairLock added in v0.44.2

type PairLock struct {
	PairID string
	// contains filtered or unexported fields
}

PairLock is a held pair lock. Release gives it back; the operating system does the same when the process ends.

func (*PairLock) Release added in v0.44.2

func (l *PairLock) Release()

Release gives the lock back. Safe to call more than once.

func (*PairLock) Unenforced added in v0.44.2

func (l *PairLock) Unenforced() bool

Unenforced reports a lock taken on a file system that cannot lock: nothing stops a second engine on this pair.

type ProgressEvent added in v0.54.0

type ProgressEvent struct {
	Phase         string
	Here          int
	Listing       bool
	Listed        int
	ListedFolders int
	Done, Total   int
	BytesDone     int64
	BytesTotal    int64
	ETA           time.Duration
	HasETA        bool
	Held          int
}

ProgressEvent is one progress report in figures (Engine.OnProgress) — the same report Engine.Progress gets as an English line.

Phase is inventory | plan | transfer | settling | hold:

  • inventory: Here is what was found on this computer; once the server listing reports, Listing is true and Listed / ListedFolders are its items and folders so far (Here still set).
  • plan: Total is the changes to make.
  • transfer: Done / Total actions; BytesDone / BytesTotal once there are bytes to move; ETA the estimate of the time left, HasETA when there is one (the first seconds have none).
  • settling: Done of Total changes recorded.
  • hold: Held is the items held for a decision.

type RemoteFS

type RemoteFS interface {
	RemoteLister
	// Download writes the file at remote into w. size is what the listing
	// said (-1 = unknown); an implementation should refuse a body of any
	// other length before writing it, and the engine checks again after.
	Download(ctx context.Context, remote string, size int64, w io.Writer) (int64, error)
	// Upload writes localPath to remote. expect is the overwrite precondition
	// the server checks before it writes: "none" (the file must not exist
	// yet) or the listing signature "<size>:<mtime-ms>" the plan was made
	// from. A refusal must come back wrapping ErrRemoteChanged.
	//
	// It returns the file as the server listed it right after the write, or
	// nil when it cannot say — the engine then lists the folder itself. That
	// signature is what the baseline records for the server side; see ledger.
	Upload(ctx context.Context, localPath, remote, expect string) (*ListedFile, error)
	Mkdir(ctx context.Context, remote string) error
	Remove(ctx context.Context, remote string) error
}

RemoteFS is everything the engine does to a server. Kept as an interface so the engine can be driven end to end in a test against a fake server, which is the only honest way to test code that deletes files.

type RemoteLister

type RemoteLister interface {
	List(ctx context.Context, remote string) (*Listing, error)
}

RemoteLister is the slice of the API client this package needs. Declaring it here rather than importing cliclient.Client keeps the planner and the walkers testable with a fake, and stops this package from growing a dependency on every REST call the CLI happens to support.

type Result

type Result struct {
	Planned      int
	Applied      int
	Uploaded     int
	Downloaded   int
	DeletedLocal int
	DeletedRemot int
	Conflicts    int
	Skipped      []string
	Errors       []string
	// Raced lists actions that stood down because the other side changed
	// while the pass was running (ErrLocalChanged / ErrRemoteChanged). They
	// are not failures: nothing was lost, and the next pass keeps both
	// versions. Reported apart from Errors so a live client can run that
	// next pass at once instead of showing an error.
	Raced    []string
	FirstRun bool
	Duration time.Duration

	// Identical counts conflicts that turned out to hold the same bytes on
	// both sides: no copy was made and nothing was uploaded.
	Identical int
	// Held counts local items left alone for a decision (hold.go).
	Held int
	// Retry lists the folders (pair-relative, "" = the pair's root) holding
	// an action that failed, so a caller can retry just those (RunDirs)
	// instead of walking the whole pair again.
	Retry []string
	// LocalFingerprint digests the local side as a FULL pass left it: the
	// snapshot the plan was made from, with what the pass itself wrote on
	// this machine folded in. ⚠ Never a later walk — an edit made while the
	// pass ran would be folded in too, and a watcher comparing fingerprints
	// would never see it as a change. "" for a targeted pass, and when the
	// pass did not get that far.
	LocalFingerprint string
}

Result reports what one run did. Every field is a measurement, not an intention: Applied counts actions that actually completed.

type Side

type Side string

Side names the two halves of a pair. Used in conflict copy names so the surviving file says where it came from.

const (
	SideLocal  Side = "local"
	SideRemote Side = "server"
)

type Snapshot

type Snapshot map[string]Node

Snapshot is one side's tree, indexed by relative path.

func ListLocalDir

func ListLocalDir(root, dir string) (Snapshot, error)

ListLocalDir snapshots ONE local folder (dir is pair-relative, "" = the root) without descending: the direct children only, keyed by their full pair-relative path. It is the local half of a targeted pass — a change announced for one folder is reconciled by looking at that folder, not by walking the whole tree. The folder itself missing is reported as os.ErrNotExist so the caller can widen the scope to its parent.

func ListRemoteDir

func ListRemoteDir(ctx context.Context, api RemoteLister, remoteRoot, dir string) (Snapshot, error)

ListRemoteDir is ListLocalDir's server half: one listing, direct children only, keyed by full pair-relative path.

func WalkLocal

func WalkLocal(root string) (Snapshot, []string, error)

WalkLocal snapshots a directory tree. Unreadable entries are skipped rather than failing the run: one locked file must not stop the other thousand from syncing. The names of skipped entries are returned so the caller can report them instead of silently doing less than it claimed.

func WalkRemote

func WalkRemote(ctx context.Context, api RemoteLister, remoteRoot string, progress func(dirsListed, itemsSeen int)) (Snapshot, error)

WalkRemote snapshots a server folder by listing it breadth-first, one level at a time with listWorkers concurrent listings per level. The snapshot itself is only written by this goroutine — workers hand their listings back and the merge stays single-threaded.

remoteRoot is an `adapter://path` prefix; every returned Node.Rel is relative to it, so local and remote snapshots share one key space and the planner can compare them directly.

progress, when non-nil, is called after each merged directory with the running totals.

type Store

type Store struct{ Dir string }

Store holds the on-disk state for every pair: the baseline that makes change detection possible, and the trash that makes deletes recoverable.

Layout under Dir:

pairs.json                     the configured pairs
baseline/<pair-id>.json        last agreed state
held/<pair-id>.json            a hold: local items kept for a decision (hold.go)
trash/<pair-id>/<stamp>/...    files this engine deleted locally

func (*Store) AddPair

func (s *Store) AddPair(p Pair) (Pair, error)

AddPair validates and stores a new mapping.

⚠ Overlapping local roots are refused. Two pairs sharing a directory each see the other's writes as user edits and push them back and forth forever.

func (*Store) ConfirmHeld

func (s *Store) ConfirmHeld(pairID string) (int, error)

ConfirmHeld ends a hold so the next run sends the held items to the server. It returns how many the pair held.

func (*Store) DiscardHeld

func (s *Store) DiscardHeld(p Pair, now time.Time) (moved, kept int, err error)

DiscardHeld ends a hold by moving the held files into the pair's local sync trash (recoverable for TrashRetentionDays), so the next run makes this side match the server: files that are only here are gone, and a file that differs comes down from the server. A folder that held only such files is removed once empty. A file whose signature changed since it was held is not what the person decided about: it is left in place and counted in kept.

⚠⚠ So is a file that has a baseline row by now. A row means the engine has since recorded it as in step on BOTH sides — an identical copy turned up on the server and was adopted, say — and trashing it here would make the next run carry the deletion to the server.

func (*Store) Holding

func (s *Store) Holding(pairID string) (bool, error)

Holding reports whether the pair is holding its unknown local items for a decision.

⚠ An unreadable hold file reads as HOLDING. The hold exists to stop a stale mirror being uploaded over a cleaned-up server; failing open would do exactly that. `filex sync confirm` or `discard` rewrite the file either way.

func (*Store) ListTrash

func (s *Store) ListTrash(pairID string) ([]TrashItem, error)

ListTrash reports what can still be recovered, newest first.

func (*Store) LoadBaseline

func (s *Store) LoadBaseline(pairID string) (Baseline, bool, error)

LoadBaseline returns the state carried from the previous run. The bool reports whether a baseline existed at all: the first run of a pair must not delete anything, and this is how the engine knows.

func (*Store) LoadHeld

func (s *Store) LoadHeld(pairID string) (map[string]string, error)

LoadHeld returns the items the pair holds for a decision, keyed by pair-relative path, with the local signature each had then ("dir" for a folder). Nothing held is an empty map.

func (*Store) LoadPairs

func (s *Store) LoadPairs() ([]Pair, error)

LoadPairs reads the configured folder pairs. A missing file is not an error: it means nothing has been paired yet.

func (*Store) LockPair added in v0.44.2

func (s *Store) LockPair(id string) (*PairLock, error)

LockPair takes the pair's lock without waiting. A pair another process holds is a *BusyError (errors.Is ErrPairBusy).

⚠ The same process asking twice is refused too — the lock is per open file, not per process — which is what makes a second lock taken by mistake (an engine that did not get its caller's) fail loudly instead of passing.

⚠ A file system that cannot lock at all is not "busy": refusing there would stop sync for good on a home directory mounted from a server without a lock daemon. The lock is then held in name only, exactly the engine as it was before locks, and Unenforced says so.

func (*Store) MovePairLocal

func (s *Store) MovePairLocal(id, newLocal string) (Pair, error)

MovePairLocal repoints a pair at a new local path, keeping its identity — and, crucially, its BASELINE: baselines store relative paths only, so a mirror that physically moved keeps its whole history and the next run is an ordinary incremental pass. The alternative (remove + re-add) throws the baseline away, and the first-run merge that follows conflicts every file whose two sides carry different mtimes — which is every file this machine ever uploaded.

func (*Store) PruneTrash

func (s *Store) PruneTrash(pairID string, days int, now time.Time) (int, error)

PruneTrash drops trashed items older than days. Returns how many were removed.

func (*Store) RemovePair

func (s *Store) RemovePair(id string) error

RemovePair forgets a mapping. Files on either side are left exactly where they are — unpairing is not deleting, and a user who wanted the files gone would have deleted them.

func (*Store) SaveBaseline

func (s *Store) SaveBaseline(pairID string, b Baseline) error

func (*Store) SavePairs

func (s *Store) SavePairs(pairs []Pair) error

func (*Store) TrashLocal

func (s *Store) TrashLocal(pairID, root, rel string, now time.Time) error

TrashLocal moves a path out of the sync folder instead of deleting it.

The engine NEVER calls os.Remove on user content: everything it removes from disk lands here first, under a per-run timestamp, and is only really gone once PruneTrash finds it older than the retention window.

type TrashItem

type TrashItem struct {
	PairID  string
	Deleted time.Time
	Rel     string
	Path    string
	Size    int64
}

TrashItem is one recoverable deletion.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL