Repository navigation
Releases
1.8.0 — sync scope: exclude folders and files, or sync only what you pick
This release adds the sync scope: decide which parts of the vault a device is allowed to publish, instead of the whole vault or nothing.
1. Two exclusion lists, one selection list, four name switches
| Control | Meaning |
|---|---|
| Excluded folders | Never staged or pushed. Folder-boundary matching: Archive covers Archive/**, never Archive-old or notes/Archive. |
| Excluded files | Never staged or pushed, by exact vault-relative path: Inbox/token.md. For the case where the folder is fine to share and one file in it is not. |
| Sync only these folders | Empty = the whole vault. Otherwise only what is under the listed folders is staged. |
| Exclude dot-prefixed files | .env, .DS_Store. On by default. |
| Exclude dot-prefixed folders | .obsidian, .archive and everything inside. On by default. |
| Exclude underscore-prefixed files / folders | _draft.md, _templates/. Off by default, because these are usually real notes. |
The four name switches are independent: turning off the dotfile rule syncs .env without syncing .obsidian.
Exclusion always outranks selection. A whitelisted folder cannot rescue a path that is excluded, and .git, .trash and this plugin's own folder stay excluded whatever you type. Both list rows take paths by hand or through a picker — the folder picker offers folders, the file picker offers files.
2. Scope is applied after staging, not through .gitignore
The obvious implementation writes your exclusions into .gitignore. That breaks moves: git sees a file appear in an ignored folder and a file vanish, so a rename becomes a deletion pushed to every other device.
So the plugin stages, then inspects the staged list (git diff --cached --name-status --find-renames -z) and resets what falls outside scope. Because the pair is still visible, a move into an excluded folder stays a move and the old remote copy survives. A heavily rewritten same-name move is caught by a second guard, and Obsidian's .trash is deliberately not an archive: moving a tracked note there publishes the deletion, while the trash copy itself is never committed.
The generated .gitignore is gone. Older versions wrote a .* block into it, which is why turning dotfile syncing back on never seemed to work. On load, that block is migrated to a comment; anything you wrote around it is preserved. A .* rule you wrote still hides dotfiles from git, because that file is yours — the plugin removes only what it generated. The root .gitignore stays syncable in every mode as a repository control file.
3. Excluding never deletes
A file that was already tracked when you excluded it — by folder or by name — stays in the repository. Its local edits stop being pushed, and a local deletion is unstaged. Publishing a removal has to be a decision:
git rm --cached -- Archive # then let the plugin commit and pushOne consequence worth knowing: those unstaged edits leave the working tree dirty, so a later pull that needs the same file can be refused by git. The plugin reports that instead of discarding your edit.
Scope decides what this device stages. It is not a download filter and not a history rewrite — commits already on the remote still arrive on other devices, and excluding a folder here does not erase it from another clone.
4. Submission requirements
- The
descriptioninmanifest.jsonwas 296 characters. Obsidian's limit is 250, and it asks for an action statement without stray punctuation, so it is now 228. - One credential check asserted on the machine (does this laptop's git helper answer for
github.com) rather than on the plugin. It reports a skip with the reason now; the plugin-side assertions in that check still run. - This release exists as a GitHub release with
main.js,manifest.jsonandstyles.css, which is what the directory needs in order to scan and install it.
Tests: 82 checks
sh tests/run.sh # needs nothing but Node and git| File | Covers | Checks |
|---|---|---|
tests/sync.test.js |
Two devices on one bare remote: local-only, remote-only, clean divergence, conflict rollback, rebase, force, blocked merge, re-entrancy | 8 |
tests/settings.test.js |
Declarative definitions and search index, read/write, path-list normalization, folder and file pickers, validation, timer lifecycle, log path | 20 |
tests/credentials.test.js |
Credential resolution, SecretStorage missing/empty, the generated helper holding no token, git credential fill itself, configDir shapes |
17 |
tests/safety.test.js |
Repository-root refusal, force-push backups and recovery, backup pruning, credentials in a remote URL, size-skip patterns released | 15 |
tests/scope.test.js |
Folder-boundary matching, excluded folders, excluded files, sync-only lists and the rule that exclusions outrank them, the four name switches, legacy .gitignore migration, tracked files, renames, .trash |
22 |
Nothing here runs against a mocked git. A merge that silently conflicts, or a deletion that rides in on a rename, is invisible to a stub — so every scenario builds real repositories and a real bare remote, drives the real binary, and then asks git ls-files and git show HEAD:<file> what actually reached the repository.
Installing
Download main.js, manifest.json, and styles.css into <vault>/.obsidian/plugins/vault-force-sync/, then enable the plugin. minAppVersion is unchanged at 0.15.0; the scope settings are all empty/on-default, so an existing vault keeps syncing exactly what it synced before until you add a rule.
Docs: README.md · 中文:README.zh-CN.md
1.7.0 — data safety: never touch the wrong repository, force pushes are recoverable
This release is about the one thing that has to hold: pointing this plugin at a vault should never be the reason you lose notes. Four ways it could still cost data are closed, each with a test that drives the real git binary.
1. It could operate on the wrong repository entirely
git rev-parse --is-inside-work-tree returns true for a vault merely nested inside a project, and git add -A run from a subdirectory stages the entire repository, not the subdirectory.
Measured on a real nested layout, the two together staged files outside the vault — and staged deletions among them. Anyone who keeps their vault inside a notes or projects repository, which is common, would have had that whole project committed and pushed to whatever origin it happened to have. Worse, the plugin's existing check passed, so nothing looked wrong.
The vault must now be the root of its repository. Anything else is refused, with both paths named:
Refusing to sync: the vault is not the root of its git repository.
vault: /path/to/vault
repository: /path/to/project
This plugin stages and commits the whole repository, so syncing here would
publish every file in that project -- including files outside the vault.
Give the vault its own repository (`git init` inside it) and sync that instead.
The check runs before .gitignore is written, so refusing leaves the directory exactly as it was found. Pull remote changes only and Show sync status refuse too — neither should read or write a foreign repository.
If you are upgrading and syncing stops. That is the check firing. Your vault is inside another repository; give it its own with git init in the vault and point Remote URL at that repository.
2. A force push could discard commits nothing else pointed at
--force-with-lease guards against a remote that moved unexpectedly. It does not make the commits you are about to discard recoverable — they stayed only until git next ran gc.
The remote's state is now snapshotted to refs/vault-force-sync/<timestamp> before any force push. Last 5 kept, local only, never pushed:
git -C <vault> for-each-ref refs/vault-force-sync/
git -C <vault> branch recovered refs/vault-force-sync/<timestamp>If the backup cannot be written, the force push is aborted rather than proceeding unprotected.
3. A size-skip pattern could outlive its reason
Oversized files are listed in .git/info/exclude so they do not slow every sync. That list was only ever appended to — so raising the limit, shrinking the file, or deleting it left the pattern behind, keeping that file out of the repository forever, with nothing on screen explaining why it never reached the other device.
The list is now a marked, managed block, rewritten on every run and released the moment it stops applying. Anything you wrote in .git/info/exclude yourself is preserved byte for byte, and that is asserted.
4. A credential could still reach disk
A token pasted into the remote URL is copied verbatim into .git/config — the one hole left in "no secret on disk". It is now flagged once per session. The URL is not rewritten: it is yours, and guessing at it would be worse than telling you.
Tests are now in the repository
They lived outside it and nobody else could run them. tests/ ships with a dependency-free runner:
sh tests/run.sh # 57 checks, needs only Node and git| File | Covers | Checks |
|---|---|---|
tests/sync.test.js |
Two devices on one bare remote: local-only, remote-only, clean divergence, conflict rollback, rebase, force, blocked merge, re-entrancy | 8 |
tests/settings.test.js |
Declarative definitions and search index, read/write, validation, timer lifecycle, log path | 17 |
tests/credentials.test.js |
Credential resolution, SecretStorage missing/empty, helper holding no token, git credential fill itself, configDir shapes |
17 |
tests/safety.test.js |
All four fixes above, including that the parent project is left byte-for-byte untouched | 15 |
These are not unit tests over a mocked git. A merge that silently conflicts, or a push that runs before the pull, is invisible to a stub — so every scenario creates real repositories, a real bare remote, and runs the real binary.
Also
- Log path fixed. It joined
Vault#configDir, which is a folder name rather than a path, sofsresolved it againstprocess.cwd()— the Obsidian install directory, not the vault. - README rewritten around these guarantees, with a Chinese translation: README.zh-CN.md.
Installing
Download main.js, manifest.json, and styles.css into <vault>/.obsidian/plugins/vault-force-sync/, then enable the plugin. minAppVersion is unchanged at 0.15.0.
1.6.0 — two-way sync, automatic pull/push, optional PAT
Two-way sync
Until now the plugin only pushed. It staged, committed, fetched, and then stopped whenever the remote was ahead, with "Pull to update" — and there was no pull code path at all. Divergence had to be resolved by hand in a terminal, or by reaching for Force, which discards remote work.
One sync is now:
stage -> commit -> fetch -> integrate remote -> push
Commit comes before integrating because git rebase refuses to run with unstaged changes, and a merge would otherwise have to resolve the commit we just made. Integrating comes before pushing so your commits land on top of remote history instead of against it.
Divergence is integrated, not refused. Choose merge (default — keeps existing commit hashes, so nothing already on the remote is ever rewritten) or rebase (linear history, only replaying your not-yet-pushed commits). --force-with-lease is now reached only in Force mode, or in Auto mode when the remote is strictly behind.
Automatic push and pull
- Auto sync every N minutes — default 5,
0disables. Pull and push on one timer. - Auto sync on load — one full sync shortly after Obsidian starts.
- A re-entrancy guard: a trigger arriving while a sync is running is dropped rather than queued. With two intervals armed this would otherwise race on the same index.
New commands: Pull remote changes only (fetch and integrate, never commits or pushes) and Show sync status (branch, short SHA, ahead/behind, uncommitted count).
Conflicts cannot corrupt notes
Obsidian has no merge UI, and a note nobody opens would keep its <<<<<<< markers indefinitely. So a conflicted sync is rolled back to the state it started from and the conflicting files are listed. Local commits stay, the remote is untouched, your note is byte-for-byte what you wrote.
Turn Auto-abort sync conflicts off to leave the merge in place and resolve it yourself. Either way, an unresolved merge blocks the next sync rather than being committed.
Optional credentials (new)
By default the plugin still uses your machine's own git credentials — gh auth login, Keychain, Git Credential Manager, libsecret, SSH. Nothing to configure, and that path is unchanged.
The gap this closes: git runs without a terminal here, so it cannot show a login prompt. On a machine with no credential helper, sync simply failed with an auth error in the log. Settings → Credentials now lets you select a personal access token:
- Stored in Obsidian's SecretStorage, not
data.json— only the secret's name is kept in settings. - Passed to git through a generated askpass helper in the child process's environment. Never written into
.git/config, the remote URL, or this repository. - Git's credential-helper list is cleared for that command, so your chosen token is the one actually used instead of a cached credential silently winning.
- Leave it empty for zero change.
Requires Obsidian 1.11.4+ for SecretStorage; older versions say so in the settings row rather than failing to authenticate.
Fixes
- Log path: it joined
Vault#configDir, which is a folder name, not a path.fsresolves relative paths againstprocess.cwd()— the Obsidian install directory, not the vault — so the log was being written somewhere the vault never looks. Now joined onto the vault, with absoluteconfigDirstill handled. - Settings now implement
getSettingDefinitions()from a single spec table shared withdisplay(), so they are searchable on Obsidian 1.13.0+ and the two renderers cannot disagree about what settings exist. - Async settings callbacks are wrapped so a rejected save surfaces as a Notice instead of an unhandled rejection that silently reverts.
Installing
Download main.js, manifest.json, and styles.css into <vault>/.obsidian/plugins/vault-force-sync/, then enable the plugin. minAppVersion is unchanged at 0.15.0.
Verified against real repositories rather than mocks — 8 two-device scenarios (push-only, pull-only fast-forward, clean divergence, conflict rollback, rebase, force, blocked merge, re-entrancy), 17 settings/timer checks, and 17 credential checks that drive git credential fill itself. 42/42.
Vault Force Sync 1.5.1
Repo renamed to github-vault-force-sync (plugin id vault-force-sync unchanged). No behaviour changes.
Vault Force Sync 1.5.0
Initial public release.
Features
- One-command vault sync to a GitHub remote via the system git (ribbon button + command palette)
- Incremental sync: only the delta since the last sync is uploaded
- Three push modes: Incremental / Auto (force-with-lease only when safe) / Always force
--force-with-leaseis always used instead of--forceto avoid blind overwrites- Large-file guard: files over the configured limit (default 100 MB) are skipped so the rest of the sync continues
- Dotfiles (.obsidian etc.) are excluded via .gitignore
- Local sync log with in-app viewer and rotation
- Optional git path, HTTP(S) proxy, branch, auto-sync on startup
Install
Download main.js, manifest.json, and styles.css from this release and place them in .obsidian/plugins/vault-force-sync/ inside your vault, then enable the plugin in Settings → Community plugins.