yggsync is a small native sync engine for Yggdrasil endpoints.
It copies files to SMB shares or plain local paths without shelling out to rclone.
This README is the operator manual for editing and running ~/.config/ygg_sync.toml.
yggsync has two modes:
- file jobs for screenshots, media, archives, and backups
- worktree jobs for a local working copy against a central repository, especially Obsidian
That split is deliberate. Generic two-way sync is the wrong model for a live vault on SMB.
flowchart TD
A[yggsync run] --> B[File job]
A --> C[Worktree job]
B --> D[copy]
B --> E[sync]
B --> F[retained_copy]
D --> G[SMB target or local path]
E --> G
F --> G
C --> H[Local worktree]
C --> I[Central repository]
C --> J[State file]
copy: copy local files to the destinationsync: mirror local files to the destination and delete remote files missing locallyretained_copy: copy first, then prune eligible local files only after remote confirmationworktree: local working copy against a central repository with explicit state tracking
bisync is accepted as a legacy alias for worktree.
The remote side can be either:
- a named target from
[[targets]] - a plain absolute local path
Today yggsync supports:
type = "smb"type = "local"
Remote values work like this:
remote = "nas:immich/alice/DCIM"means targetnas, relative pathimmich/alice/DCIMremote = "/mnt/nas/data/archive"means use that mounted local path directly
worktree reconciles a local working copy against a central hub using a
hub-authoritative ledger as the common ancestor. The ledger, a
content-addressed blob store, tombstones, and per-client cursors all live under
<remote>/.yggsync/ so history travels with the vault and survives a client
reset. See docs/adr-001-hub-authoritative-ledger.md for the full design.
Each path is resolved three-way against the ledger:
- changed on only one side -> the other side is authoritative (copy or delete)
- a rename is a clean delete at the old path plus an add at the new path — it is matched by content and is never reported as a conflict
- changed on both sides -> a line-based
diff3merge is attempted
When a genuine conflict cannot be merged cleanly, the hub version stays live and
your version is preserved beside it as <name>.mergefail.<UTC-timestamp>. The
run still exits non-zero, writes a CONFLICTS.md at the vault root, and fires a
Termux notification — but one unmergeable file never blocks the rest of the
vault. No .conflictN-style silent overwrites, and nothing is lost.
Safety:
- A run that would delete a large share of hub files (e.g. an emptied or
misconfigured local tree) aborts unless you pass
-allow-mass-delete. -dry-runreports the planned actions without touching either replica.
flowchart LR
L[(.yggsync ledger<br/>on hub)] --- A
L --- C
A[Local worktree] -->|per-path 3-way| E{changed<br/>where?}
C[Central hub] -->|per-path 3-way| E
E -->|one side| F[copy / delete]
E -->|both, mergeable| G[diff3 merge -> both]
E -->|both, conflict| H[.mergefail sidecar<br/>+ CONFLICTS.md]
For most setups, you usually change only these fields.
Normally leave these alone:
lock_fileworktree_state_dir
Usually edit:
hostshareusernameorusername_envpassword_envorpasswordbase_pathif all jobs live under one common subtreepathfortype = "local"
Prefer password_env over password for steady-state use.
Usually edit:
localremotelocal_retention_daysforretained_copyfilter_rules,include, orexcludetimeout_secondsfor very large jobs
Rules:
- do not mix
filter_ruleswithincludeorexcludein the same job retained_copywithoutlocal_retention_daysis usually a mistake- use
worktreefor local-vault-to-central-repository sync - use
synconly when remote deletions are intended
The safest first run is small and explicit.
- Put credentials in the environment if the target uses
password_env. - List jobs.
- Dry-run one small file job.
- Dry-run the worktree job to see the planned pulls/pushes before committing.
- Only after that, let scheduled jobs run normally.
Example:
export SAMBA_PASSWORD='your-password'
yggsync -config ~/.config/ygg_sync.toml -list
yggsync -config ~/.config/ygg_sync.toml -jobs screenshots -dry-run
yggsync obsidian -config ~/.config/ygg_sync.toml -dry-runA worktree job needs no explicit initialization mode. On first contact with a
fresh hub (no .yggsync ledger) it seeds the ledger and reconciles: an empty
local pulls everything, an empty hub receives everything, and identical trees
just record state. A reset client safely re-pulls from the hub ledger rather
than proposing to delete hub files.
For ordinary file jobs, the normal pattern is:
- render or edit the config once
- dry-run
- let
yggclientor your own scheduler callyggsync
Run the job on a schedule; it reconciles automatically against the hub ledger.
-dry-run previews actions; -allow-mass-delete is required only for an
intentional bulk deletion (e.g. deliberately clearing the hub from an emptied
local tree).
On first initialization:
- if one side is empty,
yggsynccan initialize from the populated side - if both sides are populated but differ,
yggsyncstops and requires explicitupdateorcommit
[[targets]]
name = "nas"
type = "smb"
host = "nas.internal"
share = "data"
username = "smb-login"
password_env = "SAMBA_PASSWORD"Optional fields:
portbase_pathdomainusername_envpassword
[[targets]]
name = "mounted"
type = "local"
path = "/mnt/nas/data"This is useful when a laptop already uses a mounted NAS path and you do not want yggsync opening its own SMB session.
[[jobs]]
name = "screenshots"
type = "retained_copy"
local = "~/storage/shared/Pictures/Screenshots"
remote = "nas:immich/path-user/android/Screenshots"
local_retention_days = 31[[jobs]]
name = "screenshots"
type = "copy"
local = "~/Pictures/Screenshots"
remote = "/mnt/nas/data/immich/path-user/desktop/Screenshots"Use this only if the destination is a real mount point. Guard scheduled runs so a missing mount does not turn into writes into an empty local directory.
[[jobs]]
name = "obsidian"
type = "worktree"
client_id = "phone" # identity in the hub ledger
local = "~/Documents/obsidian/main" # scope to a single vault
remote = "nas:netshare/path-user/obsidian/main"
filter_rules = [
"- **/.yggsync/**", # never sync the ledger itself
"- CONFLICTS.md", # generated conflict report
"- **/.obsidian/**",
"- **/.trash/**",
"- **/*.conflict*",
]client_id should be unique per device sharing a hub. local/remote can
point at a single vault subtree to sync just that vault. The .yggsync/
exclusion is enforced in code as well, but keep it in the filter so the ledger
is never scanned as content.
For SMB shares that expose DOS 8.3 aliases, exclude them too:
filter_rules = [
"- **/.obsidian/**",
"- **/.trash/**",
"- **/*.conflict*",
"- [A-Za-z0-9_][A-Za-z0-9_][A-Za-z0-9_][A-Za-z0-9_][A-Za-z0-9_][A-Za-z0-9_]~[A-Za-z0-9].*",
"- **/[A-Za-z0-9_][A-Za-z0-9_][A-Za-z0-9_][A-Za-z0-9_][A-Za-z0-9_][A-Za-z0-9_]~[A-Za-z0-9].*",
]yggsync supports:
includeexcludefilter_rules
filter_rules are order-sensitive and support:
+ pattern- pattern
Patterns are glob-style and support:
***?- character classes like
[A-Za-z0-9_]
Use filter_rules when simple include/exclude is not expressive enough.
The job name in -jobs does not match any configured [[jobs]].name.
Run:
yggsync -config ~/.config/ygg_sync.toml -listBoth local and remote already contain data and there is no saved state yet. Choose one explicitly:
-worktree-op update-worktree-op commit
You tried commit after the remote changed independently.
Run sync or update first, then commit again if needed.
Usually this means one of these:
- multiple live writers touched the same vault
.obsidianor other high-churn paths were not filtered- the SMB server exposed DOS alias names and they were not filtered
This usually means the mount was absent and the path resolved locally. Guard scheduled runs with a mount check such as:
[Unit]
ConditionPathIsMountPoint=/mnt/nas/dataThe job's local root is a symlink (on Termux, ~/storage/shared points
into /storage/emulated/0). Older binaries could not traverse a symlinked
root: some builds failed every run trying to read the root as a file, others
silently synced nothing. Fixed in v0.4.1 — upgrade the phone binary. If a
job still skips paths, look for walk: skipping … lines in the run log:
those are unreadable entries (typically Android/data under scoped
storage), which are excluded from the sync rather than failing the job.
Exclude them explicitly with exclude rules to silence the log.
Common commands:
yggsync -config ~/.config/ygg_sync.toml
yggsync -jobs dcim,screenshots
yggsync -jobs notes -worktree-op update
yggsync -jobs notes -worktree-op commit
yggsync -jobs notes -worktree-op sync
yggsync -jobs notes -dry-run
yggsync -list
yggsync -versionDevice-run forms (Termux wrappers):
# Profile run: gate against [profiles.<name>] of the runtime TOML,
# notify "<profile> stopped" on failure when notify = true.
# Without -jobs this covers every non-worktree job.
yggsync run -config ygg_sync.toml -runtime yggsync.runtime.toml -profile bulk -reason scheduled
# Single job by name; gating/notification come from [profiles.<job-name>]
# when the runtime TOML defines one.
yggsync obsidian -config ygg_sync.toml -runtime yggsync.runtime.toml -reason scheduled
# Regenerate ~/.local/state/yggsync/jobs/run-{obsidian,bulk}.sh and register
# them with termux-job-scheduler (jobs 101/102, unmetered, persisted).
# Termux:Boot re-runs this at every power-on.
yggsync android install-jobs -config ygg_sync.toml -runtime yggsync.runtime.toml \
-obsidian-period-ms 10800000 -bulk-period-ms 43200000Flags:
-config: config file path. Defaults to~/.config/ygg_sync.tomlor$YGG_SYNC_CONFIG-jobs: comma-separated job names. Default is all jobs (non-worktree jobs for a-profilerun)-dry-run: simulate file operations-worktree-op:sync,update, orcommit-list: print configured job names-reason:manualbypasses the device gate; anything else is gated-runtime: device-runtime TOML providing[gate]or[profiles.<name>]-profile: device profile for gating and failure notifications-version: print the binary version
Legacy compatibility:
--resyncand--force-bisyncare accepted as no-op compatibility flags while old wrappers are migrated
go build ./cmd/yggsyncCross-build examples:
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o dist/yggsync-linux-amd64 ./cmd/yggsync
GOOS=android GOARCH=arm64 CGO_ENABLED=0 go build -o dist/yggsync-android-arm64 ./cmd/yggsync- Code: GPL-3.0-or-later, full text in
LICENSE - Documentation: CC BY-SA 4.0, see
LICENSE-CC-BY-SA-4.0 - Names and logos: neither licence covers them — see
TRADEMARKS.md
Copyright 2026 Avikalpa Kundu [email protected].
Yggsync was Apache-2.0 until 2026-08-01. Anything published under that licence stays available under it; everything from the relicensing commit onward is GPL-3.0-or-later.
Contributions need a signed CLA, because this project is also licensed
commercially. See CONTRIBUTING.md and CLA.md — it is a page, you keep your
copyright, and it takes one line to sign.