Emet has no database of its own. On a GitHub-hosted site, the branch you’re editing is the database, and a small sync service running in your browser keeps your view of it current and ships the few changes that don’t go straight to GitHub. This page is about what gets committed when, what the indicator in the header is telling you, and how [skip ci] ends up on your commit messages.
None of it applies in local or desktop mode. There Emet writes straight to disk, there’s nothing to sync, and the indicator doesn’t appear.
What’s committed immediately#
Nearly everything. Saving an entry commits it through GitHub’s Contents API the moment you press Ctrl+S, and the same goes for creating an entry, renaming one, uploading an image, saving a data file or archetype, and creating or deleting a folder. Bulk actions go up as one commit through the Git Trees API. In every case the commit exists on GitHub before Emet reports the save as done, so “saved” never means “saved to your browser and we’ll get to it”.
While one of those commits is in flight, the sync service refreshes its record of where the branch is, so that it doesn’t later mistake your own commit for someone else’s. If you use the publication workflow, the Sync with Production button spins at the same time.
What’s queued#
Deleting a single file is the exception. When you delete an entry, a data file or an archetype from its list, Emet marks the file as deleted in an IndexedDB cache and hides it from listings straight away, but the commit that actually removes it from the branch is made by the sync service, which runs every 30 seconds. It gathers everything pending into one Git Trees commit, so several deletes in quick succession become one commit rather than several.
If you don’t want to wait, click the indicator and press Push Now. Closing the tab with something still pending triggers the browser’s “leave site?” prompt, since the deletion only exists in that tab until it’s pushed.
Bulk delete isn’t queued; it’s a bulk action, so it commits immediately.
Keeping up with the branch#
Other people, CI bots, or you in another tab can all commit to the branch while you’re editing. The sync service checks the branch’s commit SHA every 60 seconds, and again whenever the tab regains focus. When it’s moved, Emet throws away its cached copies of unchanged files, reloads the entry lists, and re-checks any entries you have open against what’s now on the branch. Your unsaved edits are left alone; only things you haven’t touched are replaced.
There’s no prompt for this. It just happens, and lists quietly update. The refresh icon next to the indicator (or Pull in its popover) forces the same reconciliation by hand, which is occasionally useful when GitHub’s API is slow to reflect a commit you know is there.
If you have unsaved edits to a file someone else has since changed, you’ll find out when you save. See Conflicts.
The indicator#
The dot and label at the left of the header show the sync service’s state:
| Shows | Meaning |
|---|---|
| Saved (green) | Nothing is pending. |
| N unsaved (green) | N files are waiting for the next push. |
| Saving… (blue, pulsing) | A push is in progress. |
| Sync Error (red) | The last push failed. It’s retried on the next interval, or you can push by hand. |
Click it for a popover with the state, a Pull button, a Push Now button when something is pending, and the list of pending files. (Each pending file is labelled either New or Changed, so a queued delete shows as Changed, which is not the most informative label it could have.)
“Unsaved” here means “not yet committed”. It has nothing to do with the amber dot on an entry card, which means the editor holds edits you haven’t saved; those live in your browser’s local storage, not in the sync queue.
Conflicts during a push#
A push reads the branch head, builds a tree on top of it, creates a commit and moves the branch to it. If the branch moves between those steps (GitHub answers the last one with a 422), Emet re-reads the head and tries once more. A second failure lands in the error state and the next interval starts again from scratch. Since the queue only ever holds deletes, a push can’t overwrite anyone’s edits to a file; a deleted file is deleted either way.
Commit messages#
Direct commits have their own messages: Update my-post, Create my-post.md, Rename old → new, Upload image: 1712150400000-photo.jpg, Move 3 entries to archive, and so on. The sync service builds its messages from the paths involved:
| Pending | Message |
|---|---|
| One file | Delete content/blog/old-post.md |
| Two or three | Delete content/blog/a.md, content/blog/b.md |
| More | Delete content/blog/a.md and 4 other files |
The verb would be Update or Create if the queue held something other than deletes, which on a GitHub-hosted site it doesn’t.
Skipping CI#
If your site builds on every push, each draft you save costs you a build that produces nothing visible. So Emet appends a skip pattern to the commit message for changes that can’t affect the published site:
- Saving or renaming an entry whose frontmatter has
draft: true. - Uploading an image, always. An image on its own changes no page; the save of the page that uses it will trigger the build.
- A sync push where every file is a draft or is scheduled more than an hour out. In practice this never applies, because the queue holds deletes.
Bulk actions never add it, and neither does publishing a draft, so the build happens when it matters.
The pattern comes from deploy.skip_ci in site.yaml and defaults to [skip ci], which GitHub Actions, Netlify, Vercel and Cloudflare Pages all honour. Set it to your host’s pattern if it differs, or to an empty string to build on every commit:
deploy:
skip_ci: "[skip netlify]"Note that a scheduled entry (not a draft, dated in the future) gets a normal commit when you save it, even though Hugo won’t build it yet. Hugo also doesn’t know to rebuild when the date arrives; that’s your CI’s job.
Where next?#
- Entries for saving, conflicts and what happens to unsaved edits.
- Bulk actions for how multi-file commits behave.
- Publication if you’d rather changes didn’t go live on every save.