This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
- Don't assume. Don't hide confusion. Surface tradeoffs.
- Minimum code that solves the problem. Nothing speculative.
- Touch only what you must. Clean up only your own mess.
- Define success criteria. Loop until verified.
- Follow Occam's Razor. Keep your project simple — but don't overcomplicate it. Not sure how? Just ask!
An Obsidian plugin that syncs a local vault with a GitHub repository using only the GitHub REST API — no git binary, no isomorphic-git. This constraint is deliberate so the plugin works identically on desktop and on Obsidian Mobile. Branching, rebasing, non-GitHub hosts are out of scope.
- User-facing overview, installation, settings reference, conflict-resolution UX, migration from other plugins:
README.md. - Per-release notes (Keep-a-Changelog format):
CHANGELOG.md. README links here for "What's new"; do NOT add per-release notes back into README. New release → add a section toCHANGELOG.mdand bump the version inpackage.json+manifest.json+manifest-beta.json+versions.json. - Canonical spec for the whole sync engine — conflict-resolution layer (sibling files, conflict branches, three-step / five-step atomic protocols, scenarios A–E) AND push pipeline layer (pre-flight validation, pending-deletions queue, push-queue depth signal) AND cross-cutting infrastructure (cross-platform contracts, typed error hierarchy, skip-class discipline):
docs/PSEUDO-MERGE-MODE.md. Read this first when working on anything undersrc/sync2/,src/errors.ts, the GitHub client (src/github/client.ts), or any test that exercises the engine. Code comments cross-reference the article's section numbers (§4.3, §9.4, §10 Scenario E, §11 cross-platform, §12.1 pre-flight validation, §12.2 pending-deletions, §13 error taxonomy, §14 skip-class, §16 field postmortems, etc.). - Diff2 widget design (in-progress UX layer on top of pseudo-merge mode, lives on the
diff2branch):docs/DIFF2_IMPLEMENTATION_PLAN.md.
Behaviour described in the article is locked in by the unit + integration suites. If you change anything in the engine and the article disagrees, fix the code OR update the article — don't let them drift.
Package manager is pnpm (CI uses pnpm@latest-10).
pnpm dev— esbuild watch mode, emitsmain.jswith inline sourcemaps. SetOBSIDIAN_PLUGIN_DIRenv var to also mirrormain.js/manifest.json/styles.cssinto a vault's plugin folder on every successful build (paths starting with~/are expanded). On macOS, IDE-set env vars don't pass through shell expansion — the config does that itself.pnpm build— typecheck (tsc -noEmit) then production bundle. Run before committing; CI runs the same on tag pushes.pnpm test— vitest unit suite, runs once and exits (~5 s).pnpm test:watch— vitest watch mode.pnpm test:integration— full integration suite against real GitHub (~20 min). Bootstrap suite included.pnpm test:integration:bootstrap— bootstrap suite only (~3 min).pnpm test:integration:nonbootstrap— everything except bootstrap (~17 min).pnpm test:perf— opt-in performance baselines undertests/perf/. Not in CI; emitsPERF_BASELINE {…}lines.pnpm benchmark— predates the integration suite; requires SSH-accessible remote. Rarely needed;test:integrationis preferred.
Triggered by a pushed tag matching [0-9].[0-9]+.[0-9]+*; a -beta suffix cuts a prerelease. npm version <ver> runs version-bump.mjs, which syncs manifest.json and versions.json from package.json.
manifest-beta.json is NOT auto-synced. When bumping to a -beta version, edit it manually to match.
src/
├── main.ts # Plugin entry; commands, ribbons, IntervalScheduler wiring,
│ # resetPluginState (calls renameVaultSiblingsToUnresolved
│ # before clearAll), pushPluginsDataJsonCached
├── gi.ts # GI (gitignore matcher) — path-browserify, mobile-safe
├── logger.ts # Truncated JSON log file
├── utils.ts # hasTextExtension, retry helpers, calculateGitBlobSHA,
│ # isRetriableStatus / isWriteRetriableStatus / isRetriableError,
│ # describeError (typed-error extractor used by safeStringify)
├── errors.ts # SyncError class hierarchy: NetworkError, GithubAPIError +
│ # 4 status subclasses, PlatformError, StaleStateError, makeGithubAPIError
│ # dispatcher. PSEUDO-MERGE-MODE §13.
├── github/client.ts # Thin requestUrl wrapper, retryUntil; throws via makeGithubAPIError;
│ # getContentsAtRef does Blobs-API fallback for >1MB files (PSEUDO-MERGE-MODE §16.6);
│ # every HTTP call routes through WorkerClient.httpRequest when one is wired
├── settings/
│ ├── settings.ts # GitHubSyncSettings + DEFAULT_SETTINGS (syncStartsWithCommit,
│ │ # showCommitRibbonButton, consolidateCommits, maxAutoMergeSizeBytes)
│ └── tab.ts # Settings UI (trim onChange, Reset modal, GitHub sync status section,
│ # Performance group with max-auto-merge KB input)
├── worker/ # Web Worker orchestra (PSEUDO-MERGE-MODE §17). esbuild emits each entry
│ ├── types.ts # point as an IIFE, inlines as string via `define`, runtime wraps in Blob URL.
│ ├── cpu-worker.ts # CPU pool: decode-base64, compute-git-blob-sha, merge-text (bundles node-diff3)
│ ├── network-worker.ts # Single dedicated thread; native fetch executor for every GitHub HTTP call
│ └── worker-client.ts # Main-thread controller; pool dispatch, request-id multiplex, terminate, fallback
└── sync2/
├── sync2-manager.ts # Orchestrator: syncAll, syncFile, drain, processBatch,
│ # validateDeletionsAgainstHead (pre-flight, §12.1),
│ # finalizeConflictBranchIfReady, synthesizeResolutionSideBatches,
│ # registerConflictAndDropPath, pushConflictPathsToBranch
├── interval-scheduler.ts # Periodic tick + onload startup (testable in isolation)
├── change-detector.ts # Vault walk + findChanges + queue bridge
├── push-queue.ts # .push-queue/ persistence + markers + meta serdes + enqueueSynthetic
├── tree-builder.ts # Batch → tree entries (with uploadedBlobs skip)
├── snapshot-store.ts # github-easy-sync-metadata.json (file name is historic)
├── pending-deletions-store.ts # .pending-deletions/<id>/meta.json — pull-sanitize delete-intents
│ # (PSEUDO-MERGE-MODE §12.2)
├── cross-platform.ts # Centralized contracts: sanitizeFilename (12 forbidden ASCII →
│ # Unicode), encodePathForGithub, safeRename. PSEUDO-MERGE-MODE §11.
├── gitignore-invariants.ts # Invariant .gitignore blocks; always-write enforce
├── commit-message.ts # Hardcoded format* helpers; commitMessageForBatch
├── atomic-write.ts # 5-step atomicWriteFile + stagingPathFor + AtomicWriteRecovery.sweep;
│ # fast-path uses vault.modifyBinary for open TFiles (preserves editor cursor/scroll)
│ # via a .sync-tmp + .<basename>.sync-tmp. marker forward-recovery protocol
├── conflict-store.ts # ConflictRecord + 3-step create + renameVaultSiblingsToUnresolved
├── conflict-classifier.ts # Pure classify() + evaluateConflictState (Phase A + Phase B)
├── conflict-watcher.ts # vault.on listener; READ-ONLY counter.markDirty()
├── conflict-counter.ts # UI count formula + debounced recompute + subscribe
├── conflict-branch.ts # buildConflictBranchName + CONFLICT_BRANCH_PREFIX
├── conflict-detection.ts # attemptAutoMerge dispatch + classifyConflictKind
├── plugin-js.ts # isAtomicPluginFile, compareSemver, readPluginVersion
├── three-way-merge.ts # mergeText (diff3-style)
├── text-normalize.ts # CRLF→LF, BOM strip, trailing-NL
├── types.ts # QueueBatch, FileChange, EnqueueMeta
└── views/
├── conflict-status-indicator.ts # Status-bar 🔀 count
├── pre-sync-conflict-modal.ts # Pre-Sync confirmation modal
└── token-expired-modal.ts # 401 / 403 recovery dialog (Stage 7)
Three independent suites — each in its own directory, own vitest config, own pnpm script. All run against the same mock-obsidian.ts alias (fs-backed vault stand-in); integration + perf hit the real GitHub API on top of that.
| Suite | Scope | Network | Command | Wall-clock |
|---|---|---|---|---|
| Unit | Pure helpers, store/queue/classifier invariants, orchestrator under a fake client | No | pnpm test |
~5 s |
| Integration | Sync2Manager end-to-end against real GitHub |
Yes | pnpm test:integration |
~20 min full |
| Perf baselines | Wall-clock signal on real GitHub upload paths | Yes | pnpm test:perf |
~1 min |
pnpm build runs tsc -noEmit before bundling — keep it green.
GITHUB_TOKEN— fine-grained PAT on the persistent int-test repo. Permissions: Contents R/W, Metadata R. Cannot create or delete repos — leak blast radius is one repo's contents.INT_TEST_OWNER/INT_TEST_REPO— that private int-test repo. Tests use branch-per-test (int-test-<scenario>-<timestamp>-<n>), deleted inafterEach. Default branch is bootstrapped lazily on first run viaensureRepoNotBare.GITHUB_BOOTSTRAP_TOKEN— classic PAT withpublic_repo+delete_repo. Only for the bootstrap suite, which must delete+recreate to regain bare state. The two-token split exists because fine-grained PATs can't create repos.INT_BOOTSTRAP_TEST_REPO— public ephemeral repo the bootstrap suite recreates. Dropped at end of run viatests/integration/teardown.ts.INT_TEST_BRANCH_PREFIX— defaults toint-test; override if multiple users share the same int-test repo.
sync2/
├── bootstrap/ # A-series: bare-repo bootstrap (uses BOOTSTRAP_TOKEN)
├── adoption/ # B-series: first sync against non-bare remote
├── normalization/ # C-series: CRLF/BOM round-trips, resume strategies
├── incremental/ # D-series: post-adoption incremental flows
├── conflicts-misc/ # E-series: reconcile-onload, binary, plugin-js semver/mtime
├── edges/ # F: special chars in paths + content edge cases
├── multi-device/ # G-series: rotation, multi-device conflicts
├── drift/ # H-series: out-of-band drift, transient PATCH retry
├── settings-lifecycle/ # I-series: reset, syncConfigDir toggle, deviceLabel change, repo switch
├── api-failures/ # J-series: 401/429/404/network drop
├── manifest-corruption/ # K-series: corrupted snapshot manifest scenarios
├── accumulate/ # L-series: accumulate semantics + .attempted marker
├── conflicts/ # Pseudo-merge end-to-end (branch lifecycle, edit-while-in-conflict, etc.)
├── rename/ # gitignore + rename interaction
└── empty-progression.test.ts
Tests use branch-per-test on the persistent private int-test repo. Bootstrap is the exception — it needs delete+recreate, so uses the public ephemeral repo.
pnpm vitest run tests/sync2/conflict-store.test.ts
pnpm vitest run --config vitest.integration.config.ts tests/integration/scenarios/sync2/conflicts
The bucket form takes a glob — tests/integration/scenarios/sync2/conflicts* matches both conflicts/ and conflicts-misc/.
tests/integration/scenarios/sync2/helpers.ts: createSync2Client, Sync2TestClient, sync2AllAndAssertNoErrors, sync2FileAndAssertNoErrors. The client owns its vault temp dir by default; pass ownsVaultPath: false (first instance) + ownsVaultPath: true (second) to share a vault across two test "sessions". Pass autoCanonicalize: true to opt into canonicalize for tests that exercise that codepath (helper default is true for back-compat with the C-series; production default is false).
tests/integration/helpers.ts exports the test-side wrappers; mock-obsidian.ts carries the RequestFaultInjector itself:
failOnNthMatch(matcher, n, message)— throws on the Nth matching request.respondForFirstN(matcher, n, fakeResponse)— short-circuits the first N matching requests with a synthesized HTTP response (exercises retry logic without rate-limiting the live PAT).
Always reset in afterEach via installRequestFaultInjector(null) — the injector is global to the vitest worker and would leak between tests otherwise.
tests/mock-obsidian-platform.test.ts parametrises a describe.each([{platform: "desktop"}, {platform: "mobile"}]) so the same body runs under both POSIX rename semantics (overwrites silently) and Capacitor rename semantics (throws on existing destination). Use this pattern for any new test touching adapter.rename so a Capacitor-only regression cannot slip through.
-
Paths always through
normalizePathfromobsidianbefore touching the adapter. -
main.jsat repo root is the build output Obsidian loads (manifest.jsonpoints at it). It's not source. -
Mobile support —
isDesktopOnly: falseinmanifest.json. Don't introduce Node-only APIs insrc/;benchmark.tsandmock-obsidian.tsare the only Node-side files and aren't bundled. A top-levelimport * as fs from "fs"(orpath,os,crypto, etc.) leaves arequire("fs")at the top of the bundle (esbuild marks these external by default) and throws on Obsidian Mobile at module load — there is no Node runtime in the Capacitor WebView — silently crashing the plugin during "Enable" in the community-plugins list. Two valid patterns:- (a) use a pure-JS polyfill (
src/gi.tsusespath-browserify; remove the polyfill's name from the esbuildexternallist so it gets bundled instead ofrequire'd); - (b) wrap the
requireinside a function body withtry/catchso it's never evaluated at module load — seedefaultReadFileinsrc/gi.tsfor the fs case (only test-time code path; production injects a vault-adapter reader instead).
To verify, grep the production bundle:
grep -E "=require\\(\\\"fs\\\"\\)|=require\\(\\\"path\\\"\\)" main.jsmust return zero matches at file scope. - (a) use a pure-JS polyfill (
-
Capacitor
renamedoes not overwrite. On iOS / Android the vault adapter'srenamethrows "Destination file already exists" when the target is occupied. POSIXrenameoverwrites silently. The portable pattern isif (exists(dst)) await remove(dst); await rename(src, dst);.src/sync2/atomic-write.tsandsrc/sync2/conflict-store.tsalready follow it. Any new write-then-rename path must too — pair it with aMOCK_PLATFORM=mobiletest. -
Settings-tab text inputs must trim user input. Android keyboards (and several third-party iOS ones) reliably append trailing whitespace to paste operations from the suggestion bar. A token like
ghp_abc123(one trailing space) makes every GitHub REST call return 404 with valid permission headers — GitHub masks "valid token, repo outside scope" as 404 to avoid leaking private-repo existence, and a whitespaced token never matches the configured repo's scope.src/settings/tab.tscalls.trim()in everyonChangefor token/owner/repo/branch, andsrc/main.ts:loadSettingsruns a one-pass sanitize on read so existing installs with whitespace-poisoned values self-heal on plugin restart. -
vault.adapter.readis for text only. UsereadBinaryfor anythinghasTextExtensionsays false. Especially important on iOS, where the text path silently corrupts binary content. -
Don't add files to the hardcoded
isSyncableblocklist without a real reason. The default for new "should we sync this?" rules is to add patterns to the seeded gitignore (CONFIG_DIR_SEED/ROOT_SEEDingitignore-invariants.ts) — that way users can opt out. -
Don't hand-edit the canonical block in
<configDir>/.gitignore—GitignoreInvariants.enforce()will rewrite it on the next plugin load. To customise the truly-required behaviour, edit the constants ingitignore-invariants.tsand ship a new build. -
Polling, not events, for the sync engine.
findChangeswalks the vault on each sync click; novault.onsubscription for sync purposes. Implication: edits made while the plugin was disabled get picked up on the next sync click without any "missed events" failure mode. The conflict layer'sConflictWatcherIS event-driven (vault.on('delete'|'modify'|'rename')), but read-only — it only callscounter.markDirty(), never mutates store; all conflict mutations happen at drain-start. Seedocs/PSEUDO-MERGE-MODE.md§5. -
No scheduler logic in
main.ts. Periodic-tick decisions (interval enabled vs watchdog vssyncStartsWithCommit) and the onload-startup pulse live insrc/sync2/interval-scheduler.tsso they can be unit-tested in isolation under a fake timer. If you find yourself adding ansetIntervalorapp.workspace.onLayoutReadycallback for sync purposes insidemain.ts, move it intoIntervalSchedulerinstead. -
Worker orchestra: CPU pool + dedicated network worker. Stage 4-6 of the 2.0.2-beta rework moved every hot-path CPU operation (3-way merge, base64 decode, SHA computation) and every GitHub HTTP call off the main thread. The orchestra lives in
src/worker/; esbuild emits each worker entry point as a standalone IIFE and inlines the source as a string constant viadefine, somain.jsships a single bundle. Runtime wraps each string in aBlobURL and constructsnew Worker(url)from it — noimportScripts, no separate file fetch, no Capacitorapp://URL ambiguity. Workers CANNOT touch any Obsidian API (vault.adapter.*,app.workspace, settings) — those stay on main. All HTTP calls from the engine MUST go throughWorkerClient.httpRequest(CORS-validated againstapi.github.comon Capacitor Android). The Settings-tab connection probe is the one allowed exception — it usesrequestUrldirectly so a click never touches plugin state. -
Modify-in-place uses
vault.modifyBinary+ a.sync-tmp.marker for crash safety. When the engine writes to a file that already exists as a TFile,atomicWriteFiletakes a fast path that preserves any open editor's cursor + scroll position. Protocol: stage new bytes in<file>.sync-tmp.<ext>→ drop a zero-byte marker at.<basename>.sync-tmp.(leading + trailing dot — syntactically distinct from staging files) →modifyBinary(target, newBytes)→ cleanup. On crash,AtomicWriteRecovery.sweepsees the marker, renames sync-tmp over the target (forward-complete), and removes the marker. Recovery runs at plugin onload BEFOREworkspace.onLayoutReadyso the rename's editor-close side effect is moot. The rename strategy still runs for brand-new files (no existing TFile to modify); SHA-based recovery handles its.sync-bakorphans, unchanged from 2.0.1. -
syncStartsWithCommitmaster toggle controls all sync surfaces (defaulttrue). Manual[Sync]click, interval tick, and startup sync all branch on this single setting.true→ commit + drain (today's manual-click semantic; preserves backward compat).false→ drain only; commit becomes the user's separate action via the[Commit]ribbon button or thecommit-localcommand. TheshowCommitRibbonButtontoggle controls the ribbon icon independently — it's a UI affordance, not a semantic. -
atomicWriteFileis invoked from many places. Settings-tab UI text should NOT name engine concepts ("drain", "queue", "batch") — use plain English for users. Engine identifiers (cancelDrain, DrainStatus, setDrainStatusListener) stay as code-level jargon because they're API names, not user copy. Stage 7 specifically swapped UI copy: "Drain status" → "GitHub sync status", "Stop drain" → "Stop sync", "Drain running" → "Syncing with GitHub". -
drain()is re-entrant-safe via arunningflag onSync2Manager. ConcurrentsyncAll()calls (e.g. interval tick fires while user click is mid-flight) collapse into one drain — the second call returns immediately. Don't bypass this with a separate code path; the integration suite's H3 test pins the serialisation. -
Commit messages are hardcoded in
src/sync2/commit-message.ts(formatSyncMessage,formatResolveConflictMessage, etc.). Don't reintroduce a per-user template field — the design choice was deliberate (date/time live in commit metadata; provenance lives in the trailing(deviceLabel)suffix). -
When working on conflict resolution OR the push pipeline OR cross-cutting infrastructure (cross-platform contracts in
cross-platform.ts, typed errors inerrors.ts, pending-deletions inpending-deletions-store.ts, skip-class annotations in any loop),docs/PSEUDO-MERGE-MODE.mdis the canonical spec the code targets. Code comments reference the article's section numbers (e.g.§9.4,§10 Scenario E,§11 cross-platform contracts,§12.1 pre-flight validation,§13 error taxonomy,§14 skip-class); use those to navigate between code and design rationale. The bug catalog in§16 Field Postmortemsis the triage index for similar future symptoms.