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
- Variables
- func DefaultStoreDir() (string, error)
- func FingerprintSnapshot(s Snapshot) string
- func IgnoredName(name string) bool
- func LocalFingerprint(p Pair) (string, error)
- func RemoveLocal(p string) error
- type Action
- type ActionKind
- type Baseline
- type BaselineEntry
- type BusyError
- type CopyCheck
- type CopyFinder
- type DiscardItem
- type DiscardResult
- type Engine
- type ListedFile
- type Listing
- type LockHolder
- type NameReport
- type Node
- type Options
- type Pair
- type PairLock
- type ProgressEvent
- type RemoteFS
- type RemoteLister
- type Result
- type Side
- type Snapshot
- func ListLocalDir(root, dir string) (Snapshot, error)
- func ListRemoteDir(ctx context.Context, api RemoteLister, remoteRoot, dir string) (Snapshot, error)
- func WalkLocal(root string) (Snapshot, []string, error)
- func WalkRemote(ctx context.Context, api RemoteLister, remoteRoot string, ...) (Snapshot, error)
- type Store
- func (s *Store) AddPair(p Pair) (Pair, error)
- func (s *Store) ConfirmHeld(pairID string) (int, error)
- func (s *Store) DiscardHeld(ctx context.Context, p Pair, find CopyFinder) (DiscardResult, error)
- func (s *Store) Holding(pairID string) (bool, error)
- func (s *Store) LoadBaseline(pairID string) (Baseline, bool, error)
- func (s *Store) LoadHeld(pairID string) (map[string]string, error)
- func (s *Store) LoadNames(pairID string) (map[string]string, error)
- func (s *Store) LoadPairs() ([]Pair, error)
- func (s *Store) LockPair(id string) (*PairLock, error)
- func (s *Store) MovePairLocal(id, newLocal string) (Pair, error)
- func (s *Store) RemovePair(id string) error
- func (s *Store) SaveBaseline(pairID string, b Baseline) error
- func (s *Store) SaveNames(pairID string, names map[string]string) error
- func (s *Store) SavePairs(pairs []Pair) error
- func (s *Store) SweepLegacyTrash(pairID string, days int, now time.Time) (int, error)
Constants ¶
const ( // KeptNoCopy: the server holds no identical copy. It stays held. KeptNoCopy = "no_copy" // KeptChanged: it changed after it was held - not what the person decided // about. It stays held. KeptChanged = "changed" // KeptUnchecked: the server could not be asked (Err says why). It stays held. KeptUnchecked = "unchecked" // KeptFailed: it had a copy, and could not be deleted (Err says why). It // stays held. KeptFailed = "failed" // KeptSynced: the engine has it in step on both sides by now (an identical // copy turned up on the server and was adopted, say). Deleting it here // would make the next run carry the deletion to the server; it is not held // any more. KeptSynced = "synced" )
Why a held item stayed on this computer after a discard.
const ( // ReasonSyncedAs: the server item Rel is synced to this computer as // Other (Why says why its own name is not used). ReasonSyncedAs = "synced_as" // ReasonTaken: the server item Rel would be synced as Other, and a file // that is not it already has that name here; it is not downloaded. ReasonTaken = "taken" // ReasonServerRefuses: the item Rel here has a name the server does not // accept; it is not uploaded. ReasonServerRefuses = "server_refuses" // ReasonImage: the item Rel HERE is what Windows made, for an earlier // version of this engine, of the server item Other (folder `back` for the // file `back\slash.txt`); uploading it would copy the server's item under // the wrong name and shape. ReasonImage = "image" // WhyTwin is ReasonSyncedAs's Why for the second of two names that are // one name here; the other reasons are localname's codes (Reserved, // Forbidden, Trailing, TooLong). WhyTwin = "twin" )
Why a name is reported.
const (
DefaultCheckpointEvery = 50
)
DefaultCheckpointEvery is how many settled transfers pass between two mid-run baseline writes; checkpointInterval caps the wait in time.
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.
const LegacyTrashDays = 30
LegacyTrashDays is how long the LOCAL SYNC TRASH an older filex left behind keeps what it holds before the engine sweeps it (SweepLegacyTrash).
⚠⚠ Since 0.57 (task #259) sync keeps no local trash. A file deleted on the server is deleted here directly: the copy that matters is the one in the SERVER's trash, where every deletion lands and from where it is restored (a file deleted here goes to the server's trash the same way). The local trash was a second copy of every deleted file, moved out of the sync folder into a hidden folder in the person's profile - copied and removed when the sync folder was on another drive - and that is the behaviour an antivirus's ransomware heuristics are written to catch: Bitdefender stopped filex.exe in the middle of a sync and quarantined the user's files (a folder deleted on the server, the deletion reaching a Windows client). What an older version already put there keeps the promise it was put there under - 30 days - and is then swept like before.
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.
Variables ¶
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 delete it) was NOT carried out. Same consequence as ErrRemoteChanged, from the other side.
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.
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.
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 ¶
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 and the per-pair baselines (and, from an older filex, a local trash with real copies of deleted files). 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 state.
func FingerprintSnapshot ¶
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 ¶
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 ¶
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.
func RemoveLocal ¶ added in v0.60.0
RemoveLocal deletes a synced file or folder from this computer (task #259: directly, not into a local trash - see LegacyTrashDays). The caller has already checked it is what the plan saw (stillAsPlanned) or what the person decided about (DiscardHeld).
A FOLDER is deleted only when nothing in it is the person's: its synced entries went first (the planner orders deletes deepest first, and stillAsPlanned refuses a folder that still holds one), and what may be left is what sync never carries. Of that, only filex's own and the system's leftovers - partial downloads, .DS_Store, Thumbs.db, desktop.ini, a keep marker - and symbolic links (the link goes, never what it points to) are deleted with it. Anything else - a folder sync ignores by name, a file it could not read - was never on the server, so nothing anywhere else holds a copy of it: the folder is left where it is and the action reports ErrLocalChanged, which keeps both sides as they are.
A file marked read-only is deleted all the same (Windows refuses to delete one that a move used to carry): it was in sync, and the server's trash holds it.
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 ¶
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 (deleted; the server's trash holds it) 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 ¶
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.
type CopyCheck ¶ added in v0.60.0
type CopyCheck struct {
Rel string // pair-relative path
Name string // its name
Size int64
SHA256 string // lower-case hex
}
CopyCheck is one held file the server is asked about.
type CopyFinder ¶ added in v0.60.0
CopyFinder looks for an identical copy of a local file that the person can reach on the server (cmd/filex serverCopies: their trash, then the pair's storage). where names the copy for the person; "" with a nil error is "the server holds none". An error is "could not say", and the file stays.
type DiscardItem ¶ added in v0.60.0
type DiscardItem struct {
Rel string
// Where is, for a deleted file, where the server's copy is.
Where string
// Reason is, for a file left in place, why (Kept*).
Reason string
// Err is what went wrong, for KeptUnchecked and KeptFailed.
Err string
}
DiscardItem is one held file a discard deleted or left in place.
type DiscardResult ¶ added in v0.60.0
type DiscardResult struct {
Deleted []DiscardItem
Kept []DiscardItem
// StillHeld is how many items the pair still holds afterwards (files and
// the folders holding them); 0 is "the hold is over".
StillHeld int
}
DiscardResult is what a discard did.
type Engine ¶
type Engine struct {
Pair Pair
API RemoteFS
Store *Store
// 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
// GOOS is the operating system whose names the local folder follows
// ("windows", "darwin", "linux"; empty: the one this runs on). A test sets
// it to hold the Windows and macOS rules on a Linux machine.
GOOS string
// 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 ¶
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 ¶
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 ¶
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 NameReport ¶ added in v0.60.0
type NameReport struct {
// Rel is the server's path for ReasonSyncedAs and ReasonTaken, this
// computer's for ReasonServerRefuses and ReasonImage (pair-relative).
Rel string `json:"rel"`
Reason string `json:"reason"`
// Other is the name here (ReasonSyncedAs, ReasonTaken) or the server's
// item (ReasonImage).
Other string `json:"other,omitempty"`
// Why is ReasonSyncedAs's cause: a localname code or WhyTwin.
Why string `json:"why,omitempty"`
}
NameReport is one item whose name a pass could not use as it is.
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 ¶
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 — 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.
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
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 ¶
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
// Full: the pass looked at the whole pair (Run), not some of its folders
// (RunDirs). What a full pass reports - Names above all - is the whole
// story; a targeted pass reports only the folders it looked at.
Full 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
// Names lists the items whose names this pass could not use as they are
// (localnames.go): a server item synced here under another name, one not
// downloaded because that name is taken here, an item here the server
// would refuse. Reported on every pass for as long as they are there.
Names []NameReport
// 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.
type Snapshot ¶
Snapshot is one side's tree, indexed by relative path.
func ListLocalDir ¶
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 ¶
ListRemoteDir is ListLocalDir's server half: one listing, direct children only, keyed by full pair-relative path.
func WalkLocal ¶
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 hold that waits for a person's decision.
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>/... an older filex's local trash, swept after LegacyTrashDays
func (*Store) AddPair ¶
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 ¶
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(ctx context.Context, p Pair, find CopyFinder) (DiscardResult, error)
DiscardHeld answers a hold with "not wanted": every held file the server holds an identical copy of (find) is deleted from this computer, and a folder that held only such files is removed once empty. Every other held item stays in place and stays held, and the result says why. A file that is gone already is nothing to decide about.
find nil (no server to ask) deletes nothing: every file stays, unchecked.
func (*Store) Holding ¶
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) LoadBaseline ¶
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 ¶
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) LoadNames ¶ added in v0.60.0
LoadNames returns the pair's name pairings: server path → path here, for the items synced to this computer under another name (localnames.go). No file, or one that cannot be read, is no pairings: the next pass maps every item again, and gives it the name it gave it before.
func (*Store) LoadPairs ¶
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
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 ¶
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) RemovePair ¶
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) SaveNames ¶ added in v0.60.0
SaveNames writes the pair's name pairings (names/<pair-id>.json).
func (*Store) SweepLegacyTrash ¶ added in v0.60.0
SweepLegacyTrash drops what an older filex put in this pair's local sync trash once it is older than days (LegacyTrashDays). Returns how many runs' worth were removed. It never adds anything: since 0.57 nothing is put there.