Abdulkader Safi

Sync said unchanged, and the new project stayed empty

Adding a project to a repo GitBasedDocs had already synced showed an empty project until someone pushed. The check that makes sync cheap was keyed on the branch head, and creating a project does not move the head.

4 min read

Share
A GitBasedDocs project page saying no pages yet, next to a repo folder full of Markdown

The cheapest thing GitBasedDocs sync does is ask GitHub one question: has the branch moved? One call to git/refs/heads/main returns a sha. If it matches lastSyncedSha, the run logs "unchanged" and stops. That check is why a timed recheck every ten minutes costs one API call.

Before using the app day to day, I read lib/sync/sync.ts next to the project code, and the two did not agree about what "nothing changed" means.

The bug

Creating a project inserts a row into projects. It does not touch the repo connection. So the next run goes like this:

  1. Ask for the head. Same sha as last time.
  2. Log "unchanged" and stop.
  3. Never look at the tree, so never see the new project's files.

The project page says "No pages yet. Push Markdown to the repo and it shows up here after the next sync." The Markdown is already in the repo. Pressing Sync now gives the same answer, because the button calls the same function.

Changing a project's folder has the same problem, and so does restoring an archived one. None of those moves the branch head.

The fix was already in the codebase for a neighbouring case. Saving a connection that points at a different repo or branch clears the stored head:

...(retargeted
  ? { lastSyncedSha: null, lastCheckedAt: null, lastError: null }
  : {}),

Project changes needed the same treatment.

Forgetting the head, twice

export async function forgetSyncedHead() {
  const db = await getDb()
  await db.update(repoConnections).set({ lastSyncedSha: null })
}

export async function resync(): Promise<SyncResult> {
  await forgetSyncedHead()
  if (inFlight) {
    await inFlight.catch(() => null)
    await forgetSyncedHead()
  }
  return runSync("manual")
}

The first forgetSyncedHead is the durable one. If the process restarts before the run, the timed recheck still finds no stored head and reads the tree.

The second one handles a sync that was already running when the admin clicked Create. That run read the project list before the new row existed, and when it finishes it writes the head back. Without waiting for it and clearing again, the new project would be skipped by the exact race this function exists to fix.

The create and update routes call it inside after(), so the admin gets their response straight away and the pages appear a few seconds later.

A second bug underneath

While I was in that loop, the skip for unchanged files caught my eye:

const unchangedBlob =
  prior && prior.blobSha === entry.sha && prior.status === "active"
if (unchangedBlob) continue

A page's slug comes from its path relative to the project folder. docs/acme/guides/auth.md under a project at docs/acme is guides/auth. Move that project to docs and the same file should become acme/guides/auth.

The file did not change, so its blob sha did not change, so the row was skipped and kept its old slug. The sidebar is built from slugs, so it showed the old nesting, and links written against the new layout broke.

The skip now also compares the slug:

prior.slug === pageSlug(project.repoPath, entry.path)

Moving a folder rewrites those rows without fetching a single blob again.

Checking it without GitHub

lib/sync/resync.check.ts runs full syncs on a scratch SQLite file. It replaces globalThis.fetch with a fake GitHub that serves a head sha, a tree and blobs from an in-memory object.

The check writes the old behaviour down on purpose:

const beta = await addProject("beta", "beta")
assert.equal((await runSync("manual")).status, "unchanged")
assert.deepEqual(await slugsOf(beta), [], "the old behaviour this fix is for")
assert.equal((await resync()).status, "synced")
assert.deepEqual(await slugsOf(beta), ["b"])

Then it moves a project to the repo root and checks the slug changed.

One test did not survive. I wrote a case for "resync while a run is in flight", then noticed the in-flight run started after the project was inserted, so it passed with or without the fix. A check that cannot fail is worse than no check, because it reads as coverage. It came out, and the card says the race is not covered.

Last updated 13 Sep 2026 · filed under Typescript, tools, web application, nextjs

Every entry on this project

19 build notes, in order

Including the ones where nothing worked. You are on part 14.

Read the series →

FAQ

Frequently asked questions

Why did a new GitBasedDocs project show no pages?

Sync stops early when the branch head matches the last synced sha. Creating, moving or restoring a project does not move the head, so the next run logged unchanged and never read the new project's files. Those actions now clear the stored head and start a sync.

How do you stop a running sync from undoing a reset of its stored state?

Clear the state, wait for any run already in flight, then clear it again before starting a new run. The in-flight run read its inputs before the change and writes its result when it ends, so a single clear can be overwritten.

Written by

Abdulkader Safi

Software Engineer

Lead engineer at DSRPT, from Lebanon and based in Kuwait. I write about the tools and bugs from real client work, with the numbers I measured.

About me → GitHub LinkedIn

Need this kind of work done on your project?

Start a project →

Keep reading

More from GitBasedDocs

All 19 entries →
  • The GitBasedDocs search palette showing results for a partial word
    GitBasedDocs

    · part 19 of 19

    Search that quotes whatever you type

    GitBasedDocs search moved from LIKE over every page body to SQLite FTS5 kept in step by triggers. Word prefixes and accent folding came with the tokenizer. The real work was quoting input, choosing a stable order, and a backup detail about rowids.

  • A GitBasedDocs doc page with a Linked from list under the article
    GitBasedDocs

    · part 18 of 19

    Backlinks that use the renderer's own link rules

    GitBasedDocs pages now show which pages link to them, resolve Obsidian aliases, open embedded PDFs and list recent changes. The backlinks reuse the functions that turn links into hrefs, so a page appears under Linked from exactly when its link works.

  • A docker-compose.yml with BACKUP_ENABLED set to true and a folder of dated backup archives
    GitBasedDocs

    · part 17 of 19

    Backups behind one flag in docker compose

    GitBasedDocs now has a Dockerfile, a compose file and backups you turn on with BACKUP_ENABLED. Each archive is a VACUUM INTO copy plus the asset files, and the copy is edited before it is packed so a restored app catches up on its own.