A Repository can run an optional push daemon that asynchronously pushes new commits to a configured git remote, with retry and exponential backoff. Push-only — the daemon never pulls.
- api/repository.md —
repo.startPushDaemon(opts) - api/transaction.md — commits produced by
repo.transactare what the daemon pushes
A consumer process that writes to gitsheets is the single writer (see behaviors/transactions.md). Pulling from the remote at runtime would risk overwriting in-memory state and unstaged tree changes — neither is recoverable cleanly.
If a consumer needs to incorporate changes from elsewhere (a manual edit, a separate process), the canonical path is: stop the consumer, pull, restart. The library does not automate this.
1. Consumer calls repo.startPushDaemon(opts) → PushDaemon handle
2. Daemon registers a listener for ref-update events (or polls; impl detail)
3. On each new commit produced by `repo.transact`:
- Add to push queue
- Attempt push immediately
- On success: emit 'push' event
- On failure: schedule retry with backoff; emit 'error' / 'retry' events
4. Consumer optionally inspects daemon.status() at any time
5. Consumer calls daemon.stop() at shutdown:
- Stop accepting new commits to the queue
- Drain in-flight retries (with a configurable timeout)
- Resolve daemon.stop()'s Promise
The underlying operation is git push <remote> <branch> with no --force. Regular fast-forward only.
If the remote rejects the push (non-fast-forward, "remote contains work that you do not have"), the daemon does not retry — it emits an error event with reason: 'non-fast-forward' and surfaces a hard error in daemon.status() (also with lastError.reason === 'non-fast-forward'). The consumer needs to intervene; a never-force-push policy is non-negotiable.
The daemon does not attempt to merge or rebase. The local commit history is the source of truth.
Subsequent repo.transact commits will trigger fresh push attempts. If the remote is still ahead, those attempts also fail and emit their own non-fast-forward errors — each commit gets one classified error, never a retry storm.
Default: exponential, base 1 second, multiplier 2, cap 1 hour.
| Attempt | Delay |
|---|---|
| 1 | 1s |
| 2 | 2s |
| 3 | 4s |
| 4 | 8s |
| 5 | 16s |
| ... | ... |
| Stable max | 3600s |
Configurable:
repo.startPushDaemon({
remote: 'origin',
backoff: { base: 5000, multiplier: 1.5, cap: 1800_000 }, // 5s base, 30min cap
maxRetries: 100,
});Or accept the default with backoff: 'exponential'.
maxRetries defaults to Infinity — the daemon keeps retrying until success, or until daemon.stop() is called.
daemon.on('push', ({ commit, durationMs }) => { ... });
daemon.on('retry', ({ commit, attempt, nextDelayMs, reason }) => { ... });
daemon.on('error', ({ commit, err, attempt, reason }) => { ... });
daemon.on('stopped', () => { ... });push— successful push.durationMsis wall-clock for the push attempt.retry— a failed attempt has been scheduled for retry. Fires before the delay.reasonmirrors the upstreamerror's classification.error— a failure occurred.reasonis one of:'non-fast-forward'— terminal. Noretryevent follows. Status'lastError.reasoncarries the same value.'unknown'— anything else (network, auth, transient git errors). Retried per the configured backoff unlessmaxRetriesis exhausted.
stopped— emitted afterdaemon.stop()completes draining.
For startup-backlog errors (see "Startup backlog" below) the commit field is null and attempt is 0; the failure surfaces through the same error event channel.
Consumers building observability surfaces typically attach to push, retry, error for Prometheus / structured-logging fan-out.
daemon.status();
// → {
// running: boolean,
// lastPushAt: ISO 8601 | null,
// lastError: {
// message: string,
// at: ISO 8601,
// attempt: number,
// reason: 'non-fast-forward' | 'unknown',
// } | null,
// pendingCommits: number,
// currentBackoffMs: number | null, // current delay, if a retry is scheduled
// currentAttempt: number | null, // which attempt we're on for the next commit
// }Cheap to call — no I/O, just in-memory snapshot.
await daemon.stop({ timeoutMs?: 30_000 });- Stops accepting new commits
- Waits for the currently-in-flight push (if any) to complete or fail
- Drains the retry queue up to
timeoutMs(default 30s) - After the timeout, abandons remaining queued commits — they remain in the local repo and can be retried by restarting the daemon
- Resolves once the daemon is fully idle
daemon.stop() is the only correct way to end the daemon. Letting the process exit while the daemon is running may leave commits unpushed.
Out of scope for the daemon. The daemon runs git push and inherits the process's git auth configuration:
- SSH:
GIT_SSH_COMMAND, ssh-agent,~/.ssh/config - HTTPS: git credential helper, env-injected token in the remote URL
- GitHub App: token via credential helper (
gh auth setup-gitor equivalent)
The library doesn't know what credentials are in play. If a git push fails authentication, the daemon emits an error event with the underlying git stderr — the consumer decides whether that's recoverable.
For container deployments, common patterns:
- Deploy key (SSH): mount the private key, configure
GIT_SSH_COMMAND='ssh -i /run/secrets/deploy-key -o StrictHostKeyChecking=accept-new' - GitHub App: an init container or sidecar refreshes a short-lived token and writes a
.git-credentialsfile - HTTPS PAT: simplest; embed in the remote URL (avoid logging) or use a credential helper
Pushing a commit that's already on the remote is a no-op (Everything up-to-date). Push attempts are safe to retry.
If the daemon restarts and finds commits in the local repo that aren't on the remote, it pushes them. The daemon doesn't track a "pending" state across restarts — it diffs local vs. remote on startup and queues anything ahead.
repo.startPushDaemon returns the handle synchronously to the caller, then performs the startup-backlog check on the next event-loop tick (so consumers can attach error / push listeners first):
git fetch <remote> <branch>— best-effort, populatesrefs/remotes/<remote>/<branch>. If this fails (unreachable remote, auth) the daemon emits anerrorevent withcommit: null,attempt: 0, classifiedreason. The check continues — a previously-cached remote-tracking ref may still be usable.git rev-list --count <remote>/<branch>..<branch>— counts local commits the remote doesn't have. If the remote-tracking ref doesn't exist after the fetch (e.g., the branch was never on the remote), the check exits silently; the daemon stays alive for futurenotifyCommitevents.- If the count is non-zero, the daemon primes its pending counter and immediately drains — the queued commits flow through the same push pipeline as live ones.
The startup check never throws. The consumer's await repo.startPushDaemon(opts) resolves with the handle even when the remote is unreachable; observability comes through error events and daemon.status().
The daemon pushes once per commit by default. For high-frequency commit workloads, this can be wasteful — a debounceMs option may be added later to coalesce multiple commits into a single push (still all individual commits, just a single network round-trip). Out of scope for v1.0.
A Repository can host at most one push daemon at a time. repo.startPushDaemon(...) while one is already running throws TransactionError (push_daemon_running). Stop the existing one first.
A daemon is bound to a single (remote, branch) pair. To push the same commits to two remotes, run two daemons in two Repository instances (or feature-request a multi-remote daemon if a consumer needs it).
- api/repository.md
- api/transaction.md
- GitHub #132 — implementation issue