# Folder sync Keep a folder on your computer and a folder on a filex server in step, in both directions. This is the Dropbox-shaped feature: files live on your disk, work offline, and catch up when you reconnect. It is available two ways, and they are the same engine: - **The desktop app** - Settings ⚙ → *Synced folders*. Runs in the background while the app sits in the tray. - **The CLI** - `filex sync`, for servers, scripts and headless machines. Both read and write `~/.filex/sync/pairs.json`, so a folder paired in the app is visible to the CLI and the other way round. **`FILEX_SYNC_DIR` moves that whole directory** - pairs and the per-pair baselines - somewhere else. The [portable Windows app](DESKTOP.md#portable-windows) sets it so its state lands beside the `.exe` rather than in the home directory of a machine the user is only borrowing; set it yourself when you want a self-contained checkout or a test run that cannot disturb your real pairings. An empty value is ignored. --- ## Quick start ```bash filex client login # once, per server filex sync add ~/Documents/work docs://work filex sync run # one pass filex sync run --watch 30s # keep going: changes sync as they happen; # every 30 s it ASKS what changed, and walks # every pair once in a while (--full-every) ``` The remote side is always `storage://path`. A bare path is ambiguous as soon as a server hosts more than one storage, so it is refused rather than guessed. --- ## What it does, and what it refuses to do Sync deletes files for a living, so the rules below are chosen so that the failure mode is *too many copies*, never *the file is gone*. | Situation | What happens | |---|---| | First run of a pair | **Nothing is deleted.** Both sides are merged. | | New on one side | Copied to the other | | Changed on one side | Copied over | | Changed in **both** places, to the **same bytes** | Nothing to keep twice: the path is settled, no copy is made | | Changed in **both** places, differently | **Both are kept, on both sides** - yours keeps its name, the server's version lands beside it as `report (server copy 2026-08-07 14-05).xlsx` here *and* on the server | | Deleted on one side, untouched on the other | The delete carries across | | Deleted on one side, **edited** on the other | The edit wins; the file comes back | | A folder on one side, a file of the same name on the other | Refused, nothing touched - the same collision the server-side guard rejects | ### A conflict compares the bytes first "Changed in both places" is decided on size and modification time, and most such changes are the same file: a pair whose history was lost, a reinstalled client, a scanner that touched a timestamp. So before keeping two copies the engine downloads the server's version and **compares it byte for byte** with yours. The same bytes settle the file; only a real difference makes a copy. When it does, the copy goes to the server too, under the same name, and both files are recorded at once - a copy you later tidy away on the server is deleted here as well instead of coming back as a new file. A copy's name never nests: a conflict on `report (server copy …).xlsx` makes another `report (server copy …).xlsx`, not `report (server copy …) (server copy …).xlsx`, and a name already taken gets ` (2)`. (Before this, one busy spreadsheet on a client that kept losing its history grew 14,724 nested copies, one every ~30 seconds.) ### An edit made while a run is busy is not lost A run of a large tree takes a while - a first sync can take hours. Only what the run actually **transferred** is recorded as in step; a file that changed on either side while the run was busy with others keeps its previous history, so the next run sees the change and carries it across. ### The first run never deletes With no record of a previous sync there is no way to tell *"you deleted this"* from *"you have not downloaded it yet"*. Guessing wrong empties someone's folder, so the first pass is a union merge. From the second run on, deletes propagate. ### A first run that would re-upload a stale copy asks first With no history, a file that is **new here** and a file that was **deleted on the server** look exactly the same - and the first run copies both up. That is right for a folder you are pairing for the first time, and exactly wrong for an old mirror of a folder that has since been tidied on the server: one client put 9,665 cleaned-up files back that way. So a first run that would upload **more than 100** files the server does not have, into a server folder that **already has files**, holds them instead. The rest of the run goes ahead (downloads, identical files); the held items - and any file that differs between the two sides - are left exactly as they are, and the pair shows `holding N item(s)` until you decide: ```bash filex sync confirm # they are wanted: the next run uploads them filex sync discard # they are stale: deleted from this computer - each one the server still has a copy of ``` `discard` deletes a held file only when **the server still holds an identical copy of it** - the same bytes (sha256) - that you can reach: in your trash on the server, or elsewhere in the pair's storage. Deleting it then loses nothing. Every other held item stays where it is and **stays held** (released, the next run would upload it again, the very thing the hold stops), and `discard` says why, file by file: | Reason (`--json` code) | The file stays because | |---|---| | `discard.kept_no_copy` | the server has no identical copy of it - it may be the only copy there is | | `discard.kept_changed` | it changed after it was held (or while the server was being asked): that is not what you decided about | | `discard.kept_unchecked` | the server could not be asked (offline, an older server, its search off and the trash without it) | | `discard.kept_failed` | it had a copy and could not be deleted (`params.error` says why) | | `discard.kept_synced` | the engine has it in step with the server by now; it is not held any more | What it deletes is **deleted** - not moved anywhere on this computer. How it looks for a copy, with the endpoints the server already has plus one: - **Your trash.** The trash listing (`GET /api/files/manager/trash`, read once) gives the entries of the file's size, its own name first; a trashed file has no download, so `POST /api/files/manager/trash/same` has the server compare the file's sha256 with each entry's bytes and answer "same" or "not" - for entries your own trash listing shows, and nothing more ([TRASH-VERSIONING.md](TRASH-VERSIONING.md#trash-endpoints)). - **The pair's storage.** The search (`GET /api/files/search`) by the file's name and exact size, under the pair's storage; each hit (at most 4) is downloaded and hashed on this computer. A server whose search is off cannot look there. `discard` asks the server, so it needs the pair's account signed in, like `sync run`. With `--json` it writes one event per line (`{"event":"discard","code":"discard.deleted","params":{"path","where"}, "message"}`, then one per file left with its reason, then `discard.done` with `deleted`, `kept` and `still_held`), the message in the `--lang` language - what the desktop app shows. A first sync into an **empty** server folder is never held. `filex sync list --json` carries `hold_new` and `held` for the desktop app, which offers the same two choices. ### An interrupted first run resumes A run of a large tree can be cut short - a closed laptop, a killed watcher, a dropped connection. Two things make the next run pick up where it stopped instead of starting over: - **The engine checkpoints its history while it works.** Every 50 settled transfers (or 15 seconds), the files that are now in step on both sides are written to the pair's history - uploads included, with the server asked for what it stored. The next run is then an ordinary incremental one: it finishes the transfers that were still pending and touches nothing that settled. - **Files with no history that match are adopted, not conflicted.** A download stamps the server's own modification time on the local copy, and two files with no history, the same size and a modification time within **two seconds** of each other are taken to be the same file (FAT stores times in two-second steps, and some tools land a millisecond off). Outside that window the usual rule applies and both copies are kept. Change detection against a recorded baseline stays exact - the tolerance exists only for files with no history. A checkpoint records only files that settled on both sides; everything else is still "never synced" and is copied across, never deleted. ### A mirror that is missing is not a mirror that was emptied If a pair's local folder is **gone** - its drive unplugged, the folder moved by hand - while the pair still has history, the run refuses and says so. Creating the folder empty and carrying on would read as "every file deleted here", and the next round would carry that to the server. Use `filex sync move` if the folder moved, plug the drive back in, or `filex sync remove` to stop syncing it. The same holds for the server side: a folder that could not be **listed** (a timeout, a proxy error) fails the run instead of reading as deleted - only a folder the server says does not exist is skipped. ### One engine per folder on this computer Two engines on the same pair at once - the desktop app and `filex sync run` in a terminal, or two copies of the desktop app (an installed one and the Microsoft Store one sign in with the same accounts) - would each plan from the history the other is about to overwrite: files uploaded twice, conflict copies out of nothing, a delete carried to the side that did not ask for it. So every run holds an **operating-system lock** on its pair (`~/.filex/sync/locks/.lock`; `LockFileEx` on Windows, `flock` elsewhere) and a pair another process holds is left alone: - `filex sync run` says `pair-1: lock: busy - another filex on this computer is syncing this pair (process 1234, …)`, syncs the other pairs, and exits with **status 4**. - `filex sync run --watch` holds its pairs' locks for as long as it syncs them. A busy pair gets no pass and no watches; the watcher keeps asking (every few seconds - no server traffic) and takes the pair over, with a full pass, as soon as the other process stops: `pair-1: lock: acquired`. The desktop app shows the busy state under that folder instead of an error, and does not restart anything. The lock belongs to the process: when it ends - quit, crashed or killed - the operating system lets go, so there is never a stale lock to delete. The `.lock` files themselves stay (they are a few bytes); deleting one does not free a pair, and is not needed. ### Deletions are restored from the server's trash A deletion on either side ends up in **the server's trash**, and that is where it is restored from (the Trash in the file list, `filex trash`): a file you delete here is deleted on the server, which moves it to its trash; a file deleted on the server is moved to its trash there and then **deleted here**. A restored file comes back down on the next run. Since 0.57 sync keeps **no local trash**. It used to move what it deleted into a hidden folder in your profile (`~/.filex/sync/trash`, copied and then removed when the sync folder was on another drive) - a second copy of a file the server already keeps, and the exact pattern an antivirus's ransomware detection looks for: Bitdefender stopped `filex.exe` mid-sync and quarantined the files. A deletion is now a plain delete, and only of what sync has carried: a folder deleted on the server keeps anything in it that sync never carried (a folder sync ignores by name, a file it could not read), and the run says so. What an older version already put in `~/.filex/sync/trash` is still swept after its 30 days. ### Clocks are never compared across the two sides An upload gives the server its own modification time, so the two sides legitimately differ the moment after a successful sync. Comparing them would make every file look permanently conflicted. Each side is compared against what *it* looked like at the end of the last run instead, which also means clock skew between your machine and the server changes nothing. --- ## In the desktop app: "Keep on this computer" Nothing below changes when the desktop app drives the engine - but you rarely type any of it there. Right-click a folder, a single file, or a whole storage in the window → **Keep on this computer**, and the app makes the pair for you: one root folder per account, chosen once (and movable later from Settings), with every kept item mirrored under it as `//`. Every row then carries a badge saying where it lives - ✓ here, ◐ holding kept items below, ⟳ syncing now, ☁ online-only. Keeping a parent absorbs kept children into a single pair; **Keep online only** removes the pair and asks whether the local copy should go to the Trash or stay. See **[docs/DESKTOP.md](DESKTOP.md#keeping-folders-on-this-computer)**. The app also has a **Pause sync** switch (tray menu and Settings). Paused, it runs no watcher at all - for any account, and across restarts - until it is resumed. A `filex sync run` you start in a terminal is not affected: the pause is the app's, not the pairs'. A pair that is holding items for a decision (`hold_new` / `held` in `filex sync list --json`) gets a notice on its card in Settings with the count and two buttons - **Upload them** runs `filex sync confirm `, **Delete from this computer** runs `filex sync discard --json` with the pair's account after asking, and then lists the files it left here, each with the engine's reason - and the account's watcher is restarted after either. Settings' **Download limit**, **Upload limit** and **When to sync** presets are handed to every watcher as `--limit-down` / `--limit-up` (KiB/s) and `--window HH:MM-HH:MM`; a change restarts the watchers. Nothing is passed while they are left at *Unlimited* / *Any time*. The window is the engine's to read: the app asks [`filex sync window --json`](#filex-sync-window) and stores what it answers. The app reads the watcher's [event stream](#the-event-stream---json) (`--json`) and shows each event's sentence as the engine said it, in the language of the account it syncs: the app passes no `--lang`, and the engine asks the server (a language a language pack adds included). An account language that changes restarts that account's watchers. When the server refuses an account's token (HTTP 401 - revoked or expired), the engine stops instead of retrying, and the app keeps that account's watcher stopped - across restarts - until you **Reconnect** it. Reconnecting as the same person keeps the account's pairs, so the next round is an ordinary incremental one. ⚠ A pair's remote path may not contain a `..` segment. Nothing legitimate needs one - the server resolves paths from its own storage root - and a client that turns a remote path into a local folder name would otherwise be told, by the server, to write outside the folder the user chose. --- ## Commands ``` filex sync add [--account