Skip to main content

Gitstore

Gitstore is the persistence backbone of Volodyslav. It uses a local Git repository as an atomic, versioned data store. All writes to event logs and runtime state go through gitstore, giving every mutation a commit, a history, and built-in conflict resolution.

Gitstore exposes two write primitives:

PrimitiveOne-liner
transactionClone → transform in temp dir → commit → push back. Retries on conflict.
checkpointStage all changes in the working copy and commit directly. No remote involved.

Core Idea

Instead of writing files directly, callers hand gitstore a transformation function. Gitstore:

  1. Prepares a fresh, writable directory containing the current state of the data.
  2. Calls the transformation with that directory.
  3. Commits any changes the transformation made.
  4. Pushes the new commit back to the authoritative store.

If the push fails (because another concurrent writer already advanced the head), the entire cycle repeats from the beginning: re-fetch, re-apply, re-push. This makes every transaction optimistic – conflicts are resolved by retrying, not by locking the remote.


Repository Roles

There are three distinct repository objects involved in any operation.

The Authoritative Store (Remote)

The source of truth. It can be:

  • A RemoteLocation – any URL or local filesystem path that Git can address.
  • The string "empty" – no remote exists; the system creates a fresh local-only repository the first time.

The Local Working Copy

A persistent clone of the authoritative store, kept on disk inside environment.workingDirectory() under the name given to workingPath. The working copy has receive.denyCurrentBranch = ignore so that other repositories can push into it directly.

workingRepository.getRepository(capabilities, workingPath, initial_state) is the gatekeeper:

  • If the local copy does not yet exist (no HEAD file), it is created:
    • For a RemoteLocation: cloned from the remote URL, then made pushable.
    • For "empty": a new repo is initialised, made pushable, and seeded with one empty commit so the branch exists.
  • If the local copy already exists, the call returns immediately without touching the remote.

The return value is the path to the .git directory of the working copy.

The Temporary Work Tree

Created fresh for every transaction attempt. It is a --depth=1 clone of the local working copy's .git directory. The transformation function receives a GitStore handle that exposes this directory via store.getWorkTree() and offers a store.commit(message) method.

The temp tree is always deleted in the finally block, whether the transaction succeeded or failed.


Checkpoints

A checkpoint is a lightweight alternative to a full transaction. It runs:

git add --all
git commit -m "$MESSAGE"

directly on the persistent local working copy – no clone, no temp directory, no push to a remote. If the working tree is already clean (no changes since the last commit), the checkpoint is a no-op: no new commit is created.

await checkpoint(capabilities, workingPath, initial_state, message);
// returns void; creates a new commit only when there are changes to commit

When to use a checkpoint vs. a transaction

SituationUse
You are the only writer (local-only "empty" repo)checkpoint
You need changes to survive a concurrent remote pushtransaction
You want a cheap point-in-time snapshot before later synchronisationcheckpoint
You need to apply a read-modify-write against the latest remote statetransaction

Important: work-tree scope

A checkpoint commits the files that are physically present in the local working copy's work tree directory. It does not automatically include files committed by a preceding transaction.

A transaction pushes new commits into the working copy's .git directory but does not update the work tree on disk. A checkpoint that follows will commit the current work tree state, which may be missing those files. For this reason, avoid mixing transactions and checkpoints on the same workingPath unless you fully control what is in the work tree.

The typical safe pattern is:

  • Use transaction for repositories that stage related changes before one final update.
  • Use checkpoint for local-only "empty" repositories (runtime_state_storage pattern) where the work tree is the single source of truth.

No-op safety

When the working tree is clean (no changes since the last commit), checkpoint is a no-op: it runs git add --all and detects that there is nothing staged, so it skips the git commit call entirely. The function returns void without creating a commit.

Mutex

Like transaction, checkpoint acquires the per-workingPath mutex before doing any work. It is therefore safe to interleave checkpoints and transactions on the same workingPath from the same process without risking a partial commit.


Transaction Lifecycle

transaction(capabilities, workingPath, initial_state, transformation)

├─ acquire in-process mutex for workingPath

└─ transactionWithRetry

├─ attempt N (up to maxAttempts):
│ │
│ ├─ workingRepository.getRepository → ensure local working copy exists
│ ├─ makeTemporaryWorkTree → create temp directory
│ ├─ clone(localGitDir, tempDir) → shallow clone into temp
│ ├─ transformation(store) → caller modifies files, calls store.commit()
│ ├─ push(tempDir → localGitDir) → push new commits into working copy
│ └─ deleteDirectory(tempDir) → always runs, success or failure

└─ on PushError: wait delayMs, retry from top of attempt loop
on any other error: rethrow immediately (no retry)

The mutex (capabilities.sleeper.withMutex) ensures that within the same process only one transaction for a given workingPath runs at a time. Cross-process or cross-machine conflicts are handled by the push-and-retry loop.


Retry Semantics

Only PushError (thrown by wrappers.push) triggers a retry. Every other error propagates immediately to the caller.

The default retry configuration is 5 attempts with 0 ms delay. Callers can supply a RetryOptions object:

/**
* @typedef {object} RetryOptions
* @property {number} maxAttempts
* @property {number} delayMs
*/

A retry is a full restart: the temp tree is discarded, the working copy is re-examined, a new temp tree is cloned, and the transformation is executed again from scratch. Callers must therefore treat the transformation as pure – it should derive all state from the work tree provided by store.getWorkTree(), not from external mutable state accumulated across calls.


Synchronise vs. Transaction vs. Checkpoint

Three independent operations that work on the same local copy for different purposes.

workingRepository.synchronizegitstore.transactiongitstore.checkpoint
PurposeBi-directional sync with real remoteAtomically mutate files via temp work treeDirectly commit current working copy state
Directionpull from remote, then push to remote, and for generators optionally merge other fetched hostname branchesclone → transform → push to working copyadd --all + commit on working copy
Temp dirNoYes (cleaned up always)No
Push to remoteYesNo (writes to local working copy only)No
RetriesUp to 100 attemptsUp to maxAttempts (default 5) on PushErrorNone
Nothing-to-commitN/AN/ANo-op (skips commit)
MutexNoYesYes

synchronize is never called inside transaction or checkpoint. They operate independently: transactions and checkpoints write to the local working copy; synchronisation propagates those writes to the real remote and pulls in changes made elsewhere.


Shallow Clones and Branch Convention

All clone operations use:

git clone --depth=1 --no-single-branch --branch=<hostname>-main

The clone stays shallow because of --depth=1, so only the branch tips are fetched one commit deep. The active branch is derived from VOLODYSLAV_HOSTNAME as <hostname>-main, and the hostname must match [0-9a-zA-Z_-]+. Using --no-single-branch keeps all remote hostname branches discoverable locally without pulling their full history.

For the generators database, synchronisation performs one extra reconciliation pass after the normal pull/push (or clone/reset) flow:

  1. git fetch origin
  2. enumerate refs/remotes/origin/*
  3. merge every matching origin/<hostname>-main branch except the current one

The merge uses git merge --no-edit --allow-unrelated-histories because host branches may have diverged from separate roots. Merge failures are collected per hostname so synchronisation can keep merging the remaining branches and then report an organized summary of the hosts that failed.

Sync reset targets (Reset to Host)

The sync API supports one reset payload form:

  • {"reset_to_hostname": "<hostname>"} — reset to the specific hostname branch (<hostname>-main).

When reset_to_hostname is provided, the local working branch remains the current host branch, but its files are reset to match origin/<hostname>-main (including deletions). If that reset changes files, a new commit is created with a merge-like reset message. This is what powers the frontend Reset to Host mode.

To power the Reset to Host UI, the backend also exposes a hostname discovery endpoint:

  • GET /api/sync/hostnames — returns a JSON object with a hostnames array of available hostnames that can be used as <hostname> values in the reset_to_hostname payload.

Example response:

{
"hostnames": [
"laptop",
"desktop",
"workstation"
]
}

Module Map

FileResponsibility
index.jsPublic API: re-exports transaction, checkpoint, and workingRepository
transaction.jsAcquires per-workingPath mutex, then delegates to retry layer
transaction_retry.jsRetry loop; distinguishes push vs. non-push errors
transaction_attempt.jsSingle attempt: temp tree lifecycle, clone, transform, push
checkpoint.jsCheckpoint: add --all + commit directly on local working copy; no-op when clean (nothing to commit)
working_repository.jsPersistent local copy: create, synchronize, expose .git path
wrappers.jsThin wrappers over raw git calls: clone, pull, push, commit, init, makePushable
transaction_logging.jsStructured log messages for every stage of the retry lifecycle
default_branch.jsDerives the active branch name as <hostname>-main

Error Types

Error classThrown byMeaning
PushErrorwrappers.pushThe git push command failed; triggers retry in the transaction loop
WorkingRepositoryErrorworking_repository.synchronize, initializeEmptyRepository, getRepositoryCould not establish a usable local copy of the repository
GitUnavailablewrappers.ensureGitAvailableThe git executable is not on $PATH

All three follow the project's error-as-value convention: use the corresponding is* type guard rather than instanceof at call sites.


Known Callers

CallerworkingPathinitial_stateUsage
runtime_state_storage/transaction.js"runtime-state-repository""empty"Writes transient runtime state; local-only, never pushed to a remote
runtime_state_storage/synchronize.js"runtime-state-repository""empty"Ensures the local-only runtime state repo exists
generators/incremental_graph/database/gitstore.js (checkpointDatabase)"generators-database""empty"Records a single rendered snapshot commit of the live incremental-graph database
generators/incremental_graph/database/gitstore.js (runMigrationInTransaction)"generators-database""empty"Runs a migration inside one gitstore transaction with pre/post rendered snapshot commits

Incremental-Graph Checkpoint Policy

runMigrationInTransaction (in generators/incremental_graph/database/gitstore.js) wraps each runMigration call in a single gitstore transaction and records two commits in that transaction — one before the migration callback runs and one after it completes successfully. Normal incremental-graph writes do not trigger migration snapshots.

This is intentional. LevelDB produces many small internal files at high frequency during ordinary operation, and checkpointing every write would create an unbounded stream of near-identical commits with little historical value. Migration boundaries represent discrete, application-level schema transitions that are worth preserving as durable snapshots.