Skip to content

Releases

1.8.0 — sync scope: exclude folders and files, or sync only what you pick

Choose a tag to compare

@yanqingwang yanqingwang released this 23 Oct 16:19

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 push

One 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 description in manifest.json was 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.json and styles.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

Choose a tag to compare

@yanqingwang yanqingwang released this 17 Oct 08:22

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, so fs resolved it against process.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

Choose a tag to compare

@yanqingwang yanqingwang released this 17 Oct 08:04

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, 0 disables. 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. fs resolves relative paths against process.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 absolute configDir still handled.
  • Settings now implement getSettingDefinitions() from a single spec table shared with display(), 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

Choose a tag to compare

@yanqingwang yanqingwang released this 05 Oct 00:44

Repo renamed to github-vault-force-sync (plugin id vault-force-sync unchanged). No behaviour changes.

Vault Force Sync 1.5.0

Choose a tag to compare

@yanqingwang yanqingwang released this 27 Aug 12:45

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-lease is always used instead of --force to 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.