Tim Helgeson520 downloadsSync 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.
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.
This plugin was developed with substantial AI assistance (Claude).
| 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.
.git/ lives inside it..obsidian/workspace* and .trash/ never sync. Add your own ignore globs as
needed.Settings → Plugin sync lists the community plugins installed in this vault. For each plugin choose one of:
data.json settings.data.json 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.
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.
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.
| 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 |
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.
Run "Halyard Sync: Open setup wizard" from the command palette or click the ribbon icon. Three steps:
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.write_repository and api (api creates merge
requests). Set your instance URL under Advanced for self-managed.oauth2).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:
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.| 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.
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.
git-crypt export-key <file>, or git-crypt export-key -k <name> <file>
for a named one..gitattributes references, 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.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.
data.json in plain text, and settings shows
a warning. Use the narrowest scope you can.requestUrl — no third-party proxy, no
CORS middleman.Vault/DataAdapter; the git repository lives in an in-memory mirror mounted into
the WebAssembly module (see DESIGN.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 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.
requestUrl buffers whole
responses). Escape hatch: re-clone on desktop..gitattributes declares one, and auto-sync pauses rather than
silently committing plaintext — or a literal LFS pointer — into what should stay
transformed.src/git/libgit2/fs-backend.ts).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.
npm version x.y.z — bumps package.json, manifest.json and versions.json
together, commits, and tags x.y.zgit push origin main --tagsmanifest.json, and attaches
manifest.json, main.js and styles.css to the releaseNo 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.
onAuth and HttpClient shapes
still inform the binding's API.MIT — see LICENSE. Third-party notices, including libgit2's GPLv2 linking exception, are in THIRD-PARTY-NOTICES.md.