Halyard Sync
approvedby Tim Helgeson
Sync your vault with a git repository (GitHub, GitLab, Bitbucket, Gitea/Forgejo, Azure DevOps, or any HTTPS host) on desktop and mobile. No native git required. - This plugin has not been manually reviewed by Obsidian staff.
Halyard Sync
Sync an Obsidian vault with a git repository over HTTPS — on iOS, Android, macOS and Windows.
Git here is real libgit2, compiled to WebAssembly and bundled into the plugin, so nothing needs installing on the device. No native git binary, no shell, no SSH keys — but real git plumbing underneath, including a real git-crypt-compatible filter.
AI disclaimer
This plugin was developed with substantial AI assistance (Claude).
Supported providers
| Provider | Sign-in button | PRs/MRs on conflict | Notes |
|---|---|---|---|
| GitHub | OAuth device flow* | yes | fine-grained PAT also works |
| GitLab (incl. self-managed 17.9+) | OAuth device flow* | yes | self-managed base URL under Advanced |
| Bitbucket Cloud | PAT only | yes | needs an API token, not an app password |
| Gitea / Forgejo / Codeberg | PAT only | yes | self-managed base URL under Advanced |
| Azure DevOps | PAT only | yes | PR creation resolves the repo GUID first |
| Any other host | PAT only | no (branch still pushed) | configurable git username, default oauth2 |
* Appears only if the distribution has OAuth client IDs configured — see OAuth client IDs. GitHub and GitLab are the only forges with a usable device grant: Bitbucket has none, Gitea/Forgejo's is an unreleased feature request, and Azure DevOps's Entra ID flow routinely trips tenant conditional-access policies.
Bitbucket app passwords are being retired — no new ones since 2025-09-09, brownout 2026-06-09 to 2026-07-27, removed 2026-07-28. Use an Atlassian API token. Settings → Advanced also needs your Atlassian account email, which the REST API's Basic-auth convention requires for pull requests even though git sync itself doesn't.
How it works
- The vault is the git repository;
.git/lives inside it. - Each sync commits your changes, checks the remote with one cheap request, fetches if it moved, merges (fast-forward or 3-way), and pushes.
- Merges never write conflict markers into notes. Genuine collisions go through the conflict model.
.obsidian/workspace*and.trash/never sync. Add your own ignore globs as needed.
Community plugin and Obsidian config policy
Settings → Plugin sync lists the community plugins installed in this vault. For each plugin choose one of:
- Share plugin + settings — sync the plugin folder, including its standard
data.jsonsettings. - Share code; keep settings local — sync the plugin files but keep that
plugin's
data.jsonon each device. - Device-local plugin folder — keep the complete plugin folder on each device.
The enabled-plugin list has its own control for sharing or keeping
<configDir>/community-plugins.json local. The default is the existing
behavior: plugin files and the enabled list remain shareable. Halyard Sync's
own operational data.json is always device-local, while its code remains
shareable.
Non-shared choices are vault/repository-wide distribution policy. They are not
per-consumer pull filters: every device using the repository sees the same
managed policy. Halyard Sync records it in a marked block in the tracked
.gitignore, preserving user-written lines. If a selected path is already
tracked, Git's normal ignore rules cannot stop it from being distributed. The
settings page shows the exact paths and requires an explicit “Review and
apply” confirmation before removing them from the index. That migration keeps
local files in place, creates one migration commit, and pushes it without
rewriting history. A failed push leaves the policy visibly pending; ordinary
sync remains available to fetch/merge and retry the push.
When a device receives a newer managed policy, Halyard Sync fetches that policy before staging local edits. During a clean fast-forward or merge it snapshots the matching local plugin files through Obsidian's DataAdapter and restores their exact bytes, including parent folders, after checkout. Only the deterministic plugin-policy patterns are protected; user ignore rules are not used for this restoration. Conflicted or failed integrations do not restore a stale snapshot over the working tree.
Generated content and managed exclusions
Halyard Sync supports both generated-content topologies. A producer plugin such as Halyard Fetch can claim a folder as a local cache, keeping it out of commits, or release that claim and write the completed result through Sync for other devices to pull. Managed exclusions appear separately from your own ignore patterns in Settings, with the owning plugin and label shown.
Generated writes use an external-write batch. Sync waits for the complete write
before staging, then queues one sync only after success. If materialization
fails, automatic and manual sync are blocked until the producer retries
successfully or you clear the block from the sync panel after reviewing the
destination. A broader user ignore such as Sources/ can still exclude a
shared destination; the producer receives that pattern as a diagnostic, and
the user pattern remains under their control.
Active note Git details
Open the Halyard Sync sidebar while viewing a Markdown note to see the latest
commit that changed that exact path. The read-only section shows the commit
timestamp, author identity from Git, short hash, full-hash copy action, commit
message, and Halyard platform/device attribution when the commit uses the
current sync message format. Existing vault sync: ... (desktop) commits are
parsed as platform-only; unrelated commits do not invent a device.
The lookup reads the checked-out branch's commit tree through the bundled libgit2-WASM engine. It does not write frontmatter, note properties, or any other note content. An untracked note, a tracked path with unavailable/shallow history, and a Git read error are shown as distinct states. The panel refreshes when the active file changes and after a sync; results are cached by path and branch tip so ordinary panel redraws do not repeat history walks.
Platform support
| Desktop (Win/macOS/Linux) | Mobile (iOS/Android) | |
|---|---|---|
| Sync engine | libgit2 → WebAssembly (bundled) | libgit2 → WebAssembly (bundled) |
| Transport | HTTPS | HTTPS |
| Background sync | while Obsidian is open | foreground only (OS limitation) |
| Token storage | OS keychain | secure storage, with fallback |
Installation
Community plugin directory — not yet listed; submission is pending.
BRAT (beta) — install BRAT,
then "Add beta plugin" with timrs2998/halyard-sync.
Manual — download manifest.json, main.js and styles.css from the
latest release into
<vault>/.obsidian/plugins/halyard-sync/. The libgit2 binary is embedded in
main.js, so there is no fourth file to copy.
Setup
Run "Halyard Sync: Open setup wizard" from the command palette or click the ribbon icon. Three steps:
- Remote URL — the repository's HTTPS URL
(
https://github.com/you/vault.git). SSH URLs are rejected: SSH cannot run inside Obsidian on mobile, so HTTPS is the only transport that works everywhere. - Authenticate — either Sign in with GitHub / GitLab (device flow: confirm a
short code in your browser), or a personal access token:
- GitHub: fine-grained PAT scoped to the repository with Contents: read/write, Metadata: read, Pull requests: read/write (the last lets conflict branches open PRs).
- GitLab: scopes
write_repositoryandapi(apicreates merge requests). Set your instance URL under Advanced for self-managed. - Bitbucket Cloud: an API token with Repositories and Pull requests read/write, plus your Atlassian account email under Advanced.
- Gitea / Forgejo / Codeberg: any token with repository read/write. Self-hosted needs its base URL under Advanced.
- Azure DevOps: a PAT with Code (Read & Write).
- Any other host: any token with repo read/write; the username sent alongside
it is configurable (default
oauth2).
- Connect — Clone the repository into the vault (shallow), or Initialize it from the current vault contents if the remote is empty.
Conflict model
Most syncs merge cleanly. When edits truly overlap, the plugin never writes
<<<<<<< markers into notes and never discards anything silently. Pick a
strategy under Settings → Sync → On conflict:
- PR branch (default). Your local state is pushed to
sync-conflict/{device}-{timestamp}, a pull or merge request opens against the sync branch, and the vault follows the remote. Your work is parked on the forge where you can merge it with a real diff UI, from any device, whenever you like. If PR creation fails — a token scope, say — the branch is still pushed and you're told to open the PR yourself. Nothing is lost either way. - Discard local. Hard-reset to the remote, after confirmation listing the differing files.
- Keep local and pause. Nothing changes; auto-sync pauses until you resolve it from the status bar or the "Resolve conflict" command.
Sync schedule
| Setting | Desktop default | Mobile default |
|---|---|---|
| Sync on startup | on | on |
| Sync on foreground | off | on |
| Interval | 5 min | 30 min |
| Debounced sync after edits | off | off |
Mobile battery. Mobile OSes suspend Obsidian in the background, so the interval only ticks while the app is open — the effective mechanism is startup plus foreground, with catch-up when more than one interval passed while closed. A no-op check costs roughly one small HTTPS request, but radio wakeups dominate battery cost, so prefer long intervals over tight polling. Battery saver disables the interval entirely and keeps only the startup and foreground triggers.
git-crypt support
Halyard Sync runs git-crypt's clean/smudge filter natively — a real compiled-in
git_filter_register, not a subprocess — so a git-crypt-encrypted repository syncs
correctly rather than being detected and refused. Both the default key and
named keys (git-crypt init <name>, filter=git-crypt-<name> in
.gitattributes) work, and one repository can mix a default key for most paths with
named keys for specific subtrees.
- On a machine where the repository is already unlocked, export each key the device
needs:
git-crypt export-key <file>, orgit-crypt export-key -k <name> <file>for a named one. - In Settings → Encryption (git-crypt), you'll see every key the repository's
.gitattributesreferences, marked ✓ configured or ✗ missing. Click Import key file… next to a missing entry. The file's embedded key name decides which slot it fills, so there's nothing to select manually. - Sync as normal. Encrypted paths encrypt on commit and decrypt on checkout using the right key per path, and the conflict modal's per-file line counts decrypt too instead of showing "(binary)".
Until every referenced key is configured, the repository shows 🔑 key needed — distinct from 🔒 sync blocked — naming exactly which keys are missing. Auto-sync pauses, but nothing is broken and no re-clone is needed. This is all-or-nothing by design: one missing named key pauses the whole repository rather than syncing some paths and not others.
Security
- Tokens live in Obsidian's SecretStorage (OS keychain on desktop) when available.
- Where it isn't, they fall back to
data.jsonin plain text, and settings shows a warning. Use the narrowest scope you can. - Git traffic goes through Obsidian's native
requestUrl— no third-party proxy, no CORS middleman.
What this plugin can reach
- Your vault, through Obsidian's own APIs. Notes are read and written through
Vault/DataAdapter; the git repository lives in an in-memory mirror mounted into the WebAssembly module (seeDESIGN.md). The shipped bundle contains no Node filesystem code — the compiled libgit2 module is linked for the web only, so there is no code path to a file outside the vault, on any platform. - The clipboard: writes only, one place. The OAuth device-flow dialog's "Copy code" button writes the one-time code this plugin just generated. Nothing in the plugin ever reads the clipboard, so content you copied elsewhere is never seen.
- The network: your git host, and nothing else. Every request goes to the remote URL you configured (plus that provider's own token/PR endpoints). Nothing is sent anywhere else — no telemetry, no analytics, no update pings.
OAuth client IDs (for distributors)
The device flows need OAuth app client IDs. This repository ships with empty IDs
(DEFAULT_GITHUB_CLIENT_ID / DEFAULT_GITLAB_CLIENT_ID), so the sign-in buttons stay
hidden and PAT auth works out of the box with no registration. A distributor
registers the OAuth apps — GitHub: a device-flow-enabled OAuth app; GitLab: an
application with write_repository api scopes — and either fills the constants in or
uses the settings overrides under Account → Advanced.
Limitations
- HTTPS only, no SSH — impossible on mobile: no subprocess, no SSH transport.
- Shallow clones by default (mobile memory;
requestUrlbuffers whole responses). Escape hatch: re-clone on desktop. - Active-note history follows the exact current path and checked-out branch; it does not follow renames. A shallow clone can only report history present in the downloaded objects.
- No submodules, no LFS, no rebase or history rewrite.
- No gitattributes filter drivers besides git-crypt. Git LFS and any other custom
clean/smudge filter are unconditionally unsupported: the wizard and every sync
refuse a repo whose
.gitattributesdeclares one, and auto-sync pauses rather than silently committing plaintext — or a literal LFS pointer — into what should stay transformed. - No background sync on mobile — iOS and Android suspend the app; catch-up on launch and foreground compensates.
- Very large vaults or huge binaries can hit mobile memory limits during clone and
fetch: the whole working tree is mirrored into memory for the duration of a sync
(see
src/git/libgit2/fs-backend.ts).
Development
npm install
npm run dev # esbuild watch -> main.js
npm run build # typecheck + production bundle
npm run lint # eslint
npm test # vitest: pure logic + real tests against the compiled libgit2
npm run test:e2e # real Obsidian, driven headlessly via WebdriverIO
Both dev and build copy src/git/libgit2/build/dist/halyard-libgit2.wasm next to
main.js — that file must ship alongside main.js, manifest.json and
styles.css. The loader reads it via app.vault.adapter.readBinary against a path
derived from the plugin's own manifest.dir; see src/git/libgit2/loader.ts.
The compiled .wasm and .js glue are committed, so contributing needs no Docker or
Emscripten. Regenerate them only when src/git/libgit2/native/*.c changes — see
src/git/libgit2/build/BUILD.md.
npm run test:e2e uses
wdio-obsidian-service,
which downloads a real Obsidian build (cached in .obsidian-cache/, gitignored) and
drives it against e2e/vaults/simple — once as desktop Obsidian, once under
emulated-mobile UI. Requires main.js to be built first. GitHub Actions runs this
coverage on pushes, pull requests, and release tags on Ubuntu with Xvfb and
herbstluftwm; it does not test a physical iOS device.
Architecture lives in DESIGN.md, the engine layer in
src/git/libgit2/README.md, and contributor conventions
in AGENTS.md.
Releasing
npm version x.y.z— bumpspackage.json,manifest.jsonandversions.jsontogether, commits, and tagsx.y.zgit push origin main --tags- GitHub Actions builds, verifies the tag matches
manifest.json, and attachesmanifest.json,main.jsandstyles.cssto the release
No v prefix on the tag — Obsidian requires it to equal manifest.json's version
exactly. .npmrc's tag-version-prefix="" is what keeps npm version from adding one.
Prior art and credits
- obsidian-git (Vinzent03) — the plugin that defined git-in-Obsidian, and the reference every design decision here was measured against. Halyard Sync differs by compiling libgit2 to WebAssembly for real mobile support, and by refusing to write conflict markers into notes.
- libgit2 — the actual git implementation this plugin runs. GPLv2 with a linking exception; see THIRD-PARTY-NOTICES.md.
- isomorphic-git — powered
the engine before the libgit2 cutover, and its
onAuthandHttpClientshapes still inform the binding's API. - git-crypt (Andrew Ayer) — the file format the native filter interoperates with. Implemented from scratch against the format; no git-crypt source is used.
- Emscripten — the toolchain that makes libgit2 run in a webview at all.
- wdio-obsidian-service (Jesse Hines) — end-to-end testing against real Obsidian.
- obsidian-sample-plugin — build scaffolding conventions.
- BRAT (TfTHacker) — the beta distribution path used above.
License
MIT — see LICENSE. Third-party notices, including libgit2's GPLv2 linking exception, are in THIRD-PARTY-NOTICES.md.
For plugin developers
Search results and similarity scores are powered by semantic analysis of your plugin's README. If your plugin isn't appearing for searches you'd expect, try updating your README to clearly describe your plugin's purpose, features, and use cases.