Watches a git repository of notes: commits your edits automatically, pulls
notes written on other machines, and maintains GitJournal-style created: /
modified: timestamps in each note's YAML frontmatter.
gitnotesync ~/notesgo install github.com/kvth/gitnotesync@latestOr from a checkout of kvth/gitnotesync, build a deb or rpm package.
make install-deps # nfpm v2.47.0 via go install (NFPM_VERSION=vX.Y.Z)
make packages # deb and rpm
make deb # -> build/gitnotesync_<version>_amd64.deb
make rpm # -> build/gitnotesync-<version>-1.x86_64.rpm
- packages are built from a release: the version is the
vX.Y.Zgit tag onHEAD(git tag v<version>), and the tree must be clean ARCH=arm64(or any Go architecture) cross-builds; the default is the host's- they depend only on
git(git-corefor rpm) and install/usr/bin/gitnotesync, the systemd user unit to/usr/lib/systemd/user/gitnotesync@.servicewithExecStartpointed at/usr/bin, and bash, zsh and fish completions generated bygitnotesync completion
| Command | What it does |
|---|---|
gitnotesync <repo> |
Same as watch |
watch <repo> |
Watch and sync continuously |
sync <repo> |
One cycle, then exit — for cron or a systemd timer |
Try it read-only first:
gitnotesync sync ~/notes --dry-run -vsync classifies why it stopped, so a timer, a cron wrapper or a status bar
can tell a failure that needs you from one that will be gone by the next run —
without scraping stderr for wording that is free to change.
| Status | Meaning | Clears by itself? |
|---|---|---|
| 0 | Synced, or nothing to do | — |
| 1 | Something else failed | Maybe |
| 3 | Rebase/merge in progress, unmerged paths, or detached HEAD | No |
| 4 | Local and upstream history diverged | No |
| 5 | Setup cannot work: no committer identity, bad template, not a repository | No |
| 6 | Fetch or push did not complete | Usually |
Statuses 3, 4 and 5 mean nothing will sync until someone attends to the repository; a hint line on stderr says what to do. 2 is unused, being conventionally a usage error.
- Refuse if a rebase/merge/cherry-pick is half-finished, if any path is unmerged, or if HEAD is detached.
- Stamp frontmatter on changed notes.
- Commit everything git reports as changed.
- Fetch, and rebase onto upstream if behind.
- Push if ahead.
A sync is triggered by a filesystem change (after a quiet period), by the poll timer, or when the machine wakes from sleep.
That is the part of this tool that writes to your notes, so the design is built around four properties.
It cannot loop. Only files git already reports as dirty are ever opened, and
anything rewritten is committed in the same cycle. The write does wake the
watcher once more; that pass finds a clean worktree and stops. Verified in
TestSyncIsIdempotent.
It will not clobber a save in progress. A file is skipped unless it has been
quiet for --settle (2s). Before writing, its mtime and size are re-checked
against what was read — if the editor touched it in between, the file is left for
the next cycle. Writes go to a temp file and rename(2), so no reader ever sees
a half-written note.
It will not reformat your YAML. The frontmatter is edited as lines of text,
never parsed and re-serialised. Key order, quoting style, comments, anchors,
block scalars and CRLF endings all survive, because nothing but the one
timestamp line is touched. A nested modified:, one inside a | block, one
inside a multi-line quoted string, or one in the body past the closing --- are
all correctly ignored. A file that opens with --- and never closes it is left
completely alone rather than guessed at.
It will not overwrite created:. An existing non-empty value is never
touched. A missing one is backfilled from the file's first commit, so notes that
predate this tool get their real date rather than today's.
The residual risks, honestly:
- An editor with a stale in-memory buffer can overwrite the added frontmatter on its next save. Obsidian, VS Code and vim all reload on external change; an editor that does not will lose the stamp until the next edit. No external writer can prevent this.
modified:conflicts on rebase are merged. Two machines editing one note make that line conflict even when the notes themselves merge fine. Nobody wrote those timestamps, so when the frontmatter timestamps are the only thing in conflict they are reconciled — the latermodified:, the earliercreated:— and the rebase carries on. Anything else in the conflict, including a note whose body genuinely disagrees, aborts the rebase and leaves your worktree untouched.--no-resolve-timestampsturns the merging off and goes back to aborting on both.- Frontmatter blocks are added to every note you edit. On a vault that has
never had frontmatter, that is a large first diff. Run
sync --dry-run -vfirst, or pass--no-create-frontmatterto maintain only the notes that already have a block.
Flags can be persisted per repository in .git/config; explicit flags win.
[gitnotesync]
poll = 5m
include = *.md
noCreateFrontmatter = trueKey names are matched ignoring case and dashes, so noCreateFrontmatter and
no-create-frontmatter both reach --no-create-frontmatter. A bare key with
no = means true, so noCreateFrontmatter on a line of its own does the same. Values keep their newlines, so a multi-line
commit template can be quoted the way git expects:
[gitnotesync]
commitMessage = "vault: {{.Count}} note(s)\n\n{{range .Files}}- {{.}}\n{{end}}"Selected flags:
| Flag | Default | |
|---|---|---|
--poll |
10m |
How often to sync anyway, for remote notes |
--debounce |
3s |
Quiet period after the last edit before syncing |
--max-debounce |
60s |
Longest continuous editing may postpone a sync |
--settle |
2s |
How long a file must be quiet before it is rewritten |
--include |
*.md,*.markdown |
Which files to stamp |
--set-created / --set-modified |
created / modified |
Frontmatter key for each timestamp; empty disables that one |
--time-format |
RFC3339 | Go time layout |
--no-modified-from-mtime |
Record when the daemon woke, not when you saved | |
--no-backfill-created |
Leave a missing created missing |
|
--no-create-frontmatter |
Leave notes that have no block alone | |
--commit-subject |
notes: auto-sync |
First line of generated commits |
--commit-message |
built-in | Go template for the whole message (see below) |
--no-resolve-timestamps |
Abort on a timestamp-only conflict instead of merging it | |
--no-pull / --no-push |
Switch off either network half | |
--no-frontmatter |
Pure auto-sync, no note rewriting (same as emptying both keys) | |
--dry-run |
Change nothing |
--commit-subject changes the first line. --commit-message replaces the
message entirely with a Go template, executed against:
| Field | |
|---|---|
.Subject |
Whatever --commit-subject is set to |
.Count |
How many files the commit touches |
.Files |
The repository-relative paths, sorted |
.Changes |
The same files as M path status labels |
.Time |
When the commit is made, a time.Time |
The built-in template is the subject, a blank line, then one label per file:
{{.Subject}} ({{.Count}} file(s))
{{range .Changes}}{{.}}
{{end}}
\n and \t in the flag value become real characters, so a multi-line
template still fits in one argument — inside {{...}} they are left alone:
gitnotesync sync ~/notes --commit-message 'vault: {{.Count}} note(s)\n\n{{range .Files}}- {{.}}\n{{end}}'A template that fails to parse is reported before any git command runs, so a typo cannot wedge a running watcher.
Commit signing is always off, and git hooks are off by default: in a
background daemon a signing passphrase prompt has nowhere to appear and would
hang forever, and a failing pre-commit hook would wedge syncing with no
visible cause. Hooks can be re-enabled with --run-hooks; signing cannot, so
commit these notes by hand if you need them signed.
contrib/systemd/gitnotesync@.service
is a template unit: the instance name is the vault's path, so one file serves
every vault you have. The deb and rpm packages install it already; otherwise
install it yourself, with the binary in ~/.local/bin, where its ExecStart
looks:
install -Dm644 contrib/systemd/gitnotesync@.service ~/.config/systemd/user/gitnotesync@.servicesystemctl --user enable --now "gitnotesync@$(systemd-escape --path ~/notes)"Each vault is then a service of its own — one stuck on a conflict neither stops the others nor buries their logs:
journalctl --user -u "gitnotesync@$(systemd-escape --path ~/notes)" -fA daemon whose every sync fails looks exactly like a healthy one from the
outside: active (running), forever. The unit is therefore Type=notify, and
the watcher reports what it is doing through sd_notify(3):
systemctl --user status "gitnotesync@$(systemd-escape --path ~/notes)"Active: active (running) since Mon 2026-09-07 09:12:03 CEST
Status: "BLOCKED since Sep 9 21:03: sync stopped on a conflict: origin/main and local history diverged; resolve by hand"
BLOCKED means nothing will sync until you deal with the repository;
degraded means the remote was unreachable and it will keep trying. A healthy
unit shows the path it watches and when it last synced. The daemon stays
running through all of it — the moment you resolve the conflict, the next cycle
picks up where it left off and the status goes back to normal.
The same transitions are in the journal, once each, with a kind= attribute:
journalctl --user -u "gitnotesync@$(systemd-escape --path ~/notes)" -p warningThe daemon inherits its environment from the systemd user manager, so
credential helpers work. Two things it may not inherit: SSH_AUTH_SOCK, if
pushes fail with Permission denied (publickey), and PATH, which is how you
point it at a different git — both are commented examples in the unit file.
GIT_TERMINAL_PROMPT=0 and SSH BatchMode=yes are forced regardless, so a
missing credential fails fast and loudly instead of hanging.
Your .gitignore. That is the whole mechanism — there is no parallel skip list
in this tool to keep in sync with it. Ignore questions are put to
git check-ignore, so per-directory .gitignore files, .git/info/exclude,
core.excludesFile, negations (!keep.log), **, directory-only patterns and
their precedence all behave exactly as they do for git status. Tracked files
are correctly never treated as ignored, and changing a .gitignore takes effect
on the next cycle with nothing to invalidate.
This is precise in a way a list of directory names cannot be: if you gitignore
.obsidian/workspace.json, that one churning file stops waking the daemon while
the rest of .obsidian/ keeps syncing. Whether your vault config is synced is
your decision to record in your .gitignore, not this tool's to guess.
The watcher asks once per debounce window, not once per event, so a burst of
edits costs a single subprocess — and a window in which everything was ignored
costs no sync at all. At startup the tree is walked breadth-first with one
check-ignore call per depth level, so a gitignored node_modules is never
descended into.
Exactly two things are decided without asking git, and both are this tool's own doing rather than yours:
.git/is always excluded. git does not ignore.git; it is simply outside the worktree, sogit check-ignore .git/indexanswers "not ignored". Watching it would be self-defeating — every commit, fetch and ref update this tool performs writes there, so each sync would immediately trigger the next.- Our own scratch files (
.gitnotesync-*) are skipped. Rewriting a note goes through a temp file in the same directory and arename(2), so one exists in your vault for an instant on every stamp — and a crash in between leaves one behind. Committing that would push half a note into your notes.
Editor scratch files (.swp, 4913, foo~, #foo#, .goutputstream-*,
.part) get no such treatment — they are yours. If your editor drops them in
the vault, gitignore them, the same as any other file you do not want
committed:
*~
*.sw[a-p]
.#*
\#*#Every git operation shells out to the real binary. That keeps credential
helpers, the SSH agent, signing, hooks and .gitignore semantics identical to
what you get on the command line — and rebase, which the core loop depends on,
does not exist in go-git at all. Using git status --porcelain=v2 and
git check-ignore as the source of truth also means ignore rules never need
reimplementing: a hand-rolled matcher that is 95% correct silently refuses to
sync a note, and you find out months later.
Requires git ≥ 2.25.
go test ./...The tests run against real temporary repositories with a real remote, covering conflict abort, refusal during a half-finished merge, detached HEAD, dry run, loop safety and frontmatter round-tripping.