Halyard Sync

approved

by 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.

1 stars520 downloadsUpdated 25d agoMIT

Halyard Sync icon

Halyard Sync

CI Release License: MIT Obsidian Mobile Engine: libgit2

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

ProviderSign-in buttonPRs/MRs on conflictNotes
GitHubOAuth device flow*yesfine-grained PAT also works
GitLab (incl. self-managed 17.9+)OAuth device flow*yesself-managed base URL under Advanced
Bitbucket CloudPAT onlyyesneeds an API token, not an app password
Gitea / Forgejo / CodebergPAT onlyyesself-managed base URL under Advanced
Azure DevOpsPAT onlyyesPR creation resolves the repo GUID first
Any other hostPAT onlyno (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.json settings.
  • Share code; keep settings local — sync the plugin files but keep that plugin's data.json on 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 enginelibgit2 → WebAssembly (bundled)libgit2 → WebAssembly (bundled)
TransportHTTPSHTTPS
Background syncwhile Obsidian is openforeground only (OS limitation)
Token storageOS keychainsecure 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:

  1. 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.
  2. 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_repository and api (api creates 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).
  3. ConnectClone 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

SettingDesktop defaultMobile default
Sync on startuponon
Sync on foregroundoffon
Interval5 min30 min
Debounced sync after editsoffoff

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.

  1. On a machine where the repository is already unlocked, export each key the device needs: git-crypt export-key <file>, or git-crypt export-key -k <name> <file> for a named one.
  2. In Settings → Encryption (git-crypt), you'll see every key the repository's .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.
  3. 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.json in 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 (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 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; requestUrl buffers 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 .gitattributes declares 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.jsthat 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 — bumps package.json, manifest.json and versions.json together, commits, and tags x.y.z
  • git push origin main --tags
  • GitHub Actions builds, verifies the tag matches manifest.json, and attaches manifest.json, main.js and styles.css to 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 onAuth and HttpClient shapes 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.