Repository navigation
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