Skip to content

feat: sync the graph's git repository with its remote - #203

Merged
polyrainbow merged 4 commits into
mainfrom
feat/git-auto-sync
Nov 6, 2026
Merged

polyrainbow merged 4 commits into
mainfrom
feat/git-auto-sync

Conversation

@polyrainbow

Copy link
Copy Markdown
Owner

Stacked on #202: merge that first, then delete its branch (or change this PR's base to main) so this PR shows only its own commits.

Summary

Every saved change is already a commit. With auto-sync switched on (per repository, off by default), each commit is pushed to the remote right away, and what other machines pushed is pulled in.

All git work moves from isomorphic-git in the notes worker to the system git in the Electron main process:

 electron/
+├── git/
+│   ├── runGit.ts        # spawns git: non-interactive, time-limited, kills the process group
+│   ├── GitRepo.ts       # init, commit, log, diff, remote detection
+│   ├── GitSync.ts       # worktree lock, pull/push queue, triggers, retries, status
+│   ├── rebase.ts        # rebase with last-writer-wins resolution
+│   └── classifyError.ts # offline / auth / rejected / other
 └── storage/
-    ├── nodeFsGit.ts
     └── bridge.ts        # storage writes take the worktree lock; git.* RPC
 src/lib/
+├── git/                 # types, commit messages, diff (shared)
 ├── electron/
-│   ├── GitFsProxy.ts
+│   └── GitClient.ts     # the worker's handle: commit, log, diff
 └── notes-worker/
-    ├── git.ts, GitFs.ts, FileSystemAccessFs.ts

Saving never waits for the network: only local steps take the lock.

sequenceDiagram
    participant Worker as Notes worker
    participant Main as GitSync (main)
    participant Remote
    Worker->>Main: write files (worktree lock)
    Worker->>Main: git.commit (worktree lock)
    Main-->>Worker: done — the save is over
    Main->>Remote: push (network queue, no lock)
    Remote-->>Main: rejected: non-fast-forward
    Main->>Remote: fetch (no lock)
    Main->>Main: rebase, last writer wins (worktree lock)
    Main-->>Worker: pathsChanged → reload those notes
    Main->>Remote: push
Loading
  • When: push after every commit. Pull on folder open, on window focus (at most once a minute), every 5 minutes while focused, and when a push is rejected. A pull waits while the editor has unsaved changes.
  • Conflicts, per file, by author time: overlapping text hunks go to the newer side (git merge-file --ours/--theirs). Edits that don't overlap survive from both sides. Binary files go whole to the newer side. An edit always beats a deletion. A local commit that loses is kept under refs/neno/overwritten/.
  • Offline is a state, not an error: a LAN-only remote seen from elsewhere shows "Offline · commits waiting: N" and is retried with backoff, and immediately on the online event. Only auth failures, a missing remote and unrelated histories are errors.
  • Setup: the toggle lives in .git/config (neno.autoSync), and the remote is the branch's upstream, else origin. A repo whose history is unrelated to its remote is refused. A rebase left behind by a crash is aborted when the folder opens.
  • UI: an auto-sync section in Settings (toggle, state, git's message, "Sync now") and a state icon at the foot of the navigation rail.
  • Desktop only: OPFS mode has no version history now. isomorphic-git and buffer are removed.

Evidence

  • Before: commits stayed local. Auto-sync did not exist.
  • After: 40 specs in electron/git/ run the real /usr/bin/git, with a bare repo as the remote and two clones as two machines:
pushes a commit right after making it                          ✓
rebases and retries when a push is rejected                    ✓
waits while the editor has unsaved changes                     ✓
keeps the newer side of an overlapping edit: local / remote    ✓
keeps the overwritten local version under refs/neno/           ✓
keeps edits that do not overlap from both sides                ✓
brings a note back that one side deleted and the other edited  ✓
gives a binary file whole to the newer side                    ✓
refuses a repo that shares no history with the remote          ✓
reports offline, not an error, and keeps the commits           ✓
never makes a save wait for the network   (remote: ext::sleep 30) ✓
finishes a rebase an earlier run left behind                   ✓
is not blocked by an untracked file the remote has too         ✓

End to end, the built app (over neno://, driven with Playwright's Electron driver against a scratch graph, bare remote and second clone) passed all seven checks:

PASS  pulls B's note when the folder opens
PASS  shows the sync indicator in the rail
PASS  pushes the save straight away  — create: saved-on-a
PASS  leaves no uncommitted tracked changes
PASS  Sync now brings in B's edit
PASS  settings show the sync state  — Synced · last synced …
PASS  quits within 15 s

npm run test (350), npm run lint, npm run stylelint, npm run build, npm run build:electron and npm run integration-test (65) pass.

Merge Danger

Door: two-way for the code, and one-way for what sync does to a remote

Reverting restores isomorphic-git; repositories stay standard git repositories either way. But once auto-sync is switched on, commits leave the machine. Rebases rewrite unpushed local commits, and a losing local version survives only under refs/neno/overwritten/.

Blast Radius: version history

  • Git becomes required: version history needs git installed (Command Line Tools). Without it, Settings says so and saving still works.
  • Hooks and signing: automatic commits skip hooks and signing, as isomorphic-git did. A user's own GIT_SSH_COMMAND is respected, and it bypasses the BatchMode/ConnectTimeout defaults.
  • Cost per save: each save now spawns a handful of git processes instead of running isomorphic-git in the worker.
  • OPFS mode loses its version history.

🤖 Generated with Claude Code

polyrainbow and others added 3 commits November 6, 2026 09:01
Since the Vite 8.3 upgrade (#196), a `?worker&url` asset imported from
inside a worker is no longer written out. The notes worker imports the
subwaytext parser that way, so a production build kept a URL to a file
that did not exist: the parser workers failed to start, and every graph
read — the note list first — rejected with `undefined`. The dev server,
and with it the Playwright suite, was unaffected.

Start the parser pool with `new Worker(new URL(…, import.meta.url))`,
the form Vite resolves itself, and fail the build whenever an emitted
script refers to an asset that is missing, so the next upgrade that
drops one is caught at build time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Commits made on every save are now pushed to the repository's remote
straight away, and what other machines pushed is pulled in: when the
folder opens, on window focus (at most once a minute), every five
minutes while focused, and whenever a push is rejected. Auto-sync is
opt-in per repository (neno.autoSync in .git/config) and uses the
branch's upstream or origin; NENO does not manage remotes.

Pulls rebase. A conflict goes to the side whose latest commit touching
the file is newer: overlapping hunks of text files to that side, edits
that do not overlap from both, binary files whole. An edit always beats
a deletion. A local commit that loses is kept under refs/neno/.

Saving must never wait for the network, so only local steps — writes,
commits, the rebase — take the worktree lock; fetch and push run in
their own queue with connect and stall limits, non-interactively, and
are killed on quit or folder switch. An unreachable remote is a neutral
"offline" state, retried with backoff. Repositories that share no
history with their remote are refused, and a rebase left behind by a
crash is aborted when the folder opens.

All git work moves from isomorphic-git in the notes worker to the system
git in the Electron main process, so git is now desktop-only and OPFS
mode has no version history. isomorphic-git and the buffer polyfill are
removed; DatabaseIO can reload just the paths a pull changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md and CONTRIBUTING.md still described git as isomorphic-git in
the notes worker, reached through GitFsProxy and nodeFsGit. Describe
electron/git/ instead — the worktree lock, the non-interactive runner,
when sync pulls and pushes, the conflict rules — and note in the README
that versioning needs git and that sync exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from fix/nested-worker-build to main November 6, 2026 08:15
@polyrainbow
polyrainbow merged commit 1643094 into main Nov 6, 2026
4 of 5 checks passed
@polyrainbow
polyrainbow deleted the feat/git-auto-sync branch November 6, 2026 08:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant