Skip to content

GitOps

GitOps lets you manage a Subspace instance’s configuration, custom functions, pages, agents, skills, MCP servers, judges, settings, and the installed plugin set, as files in a git repository instead of clicks in the app. You get review, diffs, and rollback for the configuration surface the same way you already get them for code, and the deployed state of an instance becomes exactly what’s checked into a branch.

Point a server at a repository (SUBSPACE_GITOPS_REPO, plus SUBSPACE_GITOPS_BRANCH, SUBSPACE_GITOPS_PATH, and SUBSPACE_GITOPS_POLL) and it reconciles that repo’s pages/** directory into the knowledge base. The repo layout mirrors the OKF filesystem mirror path for path: pages/** holds canonical OKF markdown, and the blessed authoring flow is literally copying from the export, cp -r ~/Subspace/okf/<dirs> repo/pages/. skills/<slug>/SKILL.md packages work as directories too, and their helper assets (references/*.md and so on) sync down to the volume alongside the skill; those assets never become pages themselves and are never deleted by the sync.

The server reconciles on three triggers:

  • Boot: a blocking sync runs before the agent workflow host starts, so repo-pinned workflow bundles are guaranteed to be on disk before anything tries to run them.
  • Poll: SUBSPACE_GITOPS_POLL seconds between checks (default 300; 0 disables polling and leaves boot and webhook sync as the only triggers).
  • Webhook: see deploy ergonomics below.

A failing repo sync logs the failure, records it in state, and keeps serving the last successfully applied configuration. A bad commit never bricks boot.

Each page the sync applies carries metadata.gitops {path, repo}. It never carries the commit hash, that lives in the sync manifest and in the page’s version history as a gitops:<sha> source, so a no-op reconcile doesn’t mint spurious page versions.

A managed page is fully locked in the editor: bullets never enter edit mode and the title can’t be renamed. The write path rejects every top-level mutation with a gitops-managed error, including merges targeting a managed page. Two UI affordances make this legible without leaving the app:

  • An open in repo chip links straight to the file on your forge (https/ssh remotes resolve to <web-ui>/blob/<branch>/<subPath>/pages/<path>; a local path repo has no link to offer).
  • A drift banner appears when the live canonical hash of a managed page no longer matches the hash that was applied, which happens when an unmanaged rename elsewhere ripples a [[link]] rewrite into a managed page’s body. Drift is informational in read-only mode; write-back mode is what resolves it automatically.

Coexistence is per page, not a global switch: everything the repo doesn’t manage stays fully editable in the app, exactly as before.

An optional plugins.json in the repo (["spec", …] or {"plugins": […], "exact"?: true | "dry-run"}) pins the plugin set the same way SUBSPACE_PLUGINS does, and repo pins win if both are set. By default this is additive-and-pinning: it installs and pins the listed plugins but never disables anything it doesn’t mention.

Exact-state mode is the opt-in alternative: with "exact": true, the repo’s plugin list is the enabled set. Any enabled plugin the repo doesn’t name gets disabled, using the same cascade-disable semantics as disabling a plugin by hand. "exact": "dry-run" runs the same computation but only records what would be disabled, surfaced through gitops.status, so you can preview the effect of turning on exact-state mode before you commit to it. Tarball path specs aren’t supported under exact mode.

Set SUBSPACE_GITOPS_MODE=write-back (the default is readonly) and managed pages become editable in the app again, with a difference: every edit exports back to the repo as a commit instead of being rejected. A dispatcher consumer watches for command bursts on managed pages and coalesces them (roughly a 750ms debounce) into one commit containing the canonical OKF for the changed pages, with the metadata.gitops marker stripped before it’s written to the file. The commit is authored as the acting user (Name <email> from their session) with committer Subspace GitOps, and the commit message is subspace: update <paths>.

Push authenticates the same way pull does, through the gitops.git.token credential described below; a push race (someone else pushed between your fetch and your push) retries once over a freshly reset checkout.

SUBSPACE_GITOPS_WRITEBACK_BRANCH chooses the branch policy:

  • Direct-to-branch (the default): write-back commits land on the same branch the sync reads from, so git and the knowledge base never diverge.
  • A PR side branch: the sync keeps serving the main branch’s content, and your edits land on a side branch. Merging that PR lands your own content back as a no-op reconcile.

Conflicts, where upstream and the app both changed the same page since the last apply, resolve in favor of the app: the reconcile skips that file and records write-back-conflict:<path> in gitops.status. The next write-back commits the app’s state over upstream and clears the flag. In other words, once you’ve edited a managed page in the app, the app’s version wins until you push a change from the repo side that the app hasn’t since overridden.

Write-back is also what resolves ripple drift automatically: a [[link]] rewrite from an unmanaged rename that touched a managed page’s body gets picked up by the same consumer and committed, instead of lingering as a drift banner indefinitely.

POST /gitops/webhook is a public endpoint that triggers an immediate sync on a forge push event, so a deploy lands in seconds instead of waiting for the next poll interval. Authentication is a shared secret held in ops.secrets under gitops.webhook.secret (env-seeded from SUBSPACE_GITOPS_WEBHOOK_SECRET), verified constant-time as either a GitHub or Gitea X-Hub-Signature-256 HMAC or a GitLab X-Gitlab-Token header. If no secret is configured, the endpoint returns 403 to everyone; it never falls open. The sync runs detached, the webhook returns 202 {queued: true} immediately, and a burst of webhook deliveries coalesces onto the same advisory-lease single-flight the poll and boot triggers use, so a rapid sequence of pushes doesn’t queue up redundant syncs.

Private https remotes authenticate with a broker-held token: ops.secrets entry gitops.git.token (env-seeded from SUBSPACE_GITOPS_TOKEN, with SUBSPACE_GITOPS_GIT_USERNAME overriding the default x-access-token basic-auth username). The token is injected per git invocation through GIT_ASKPASS: the on-disk helper script itself is secret-free, and the token only ever rides child-process environment variables, never landing in the checkout or in a remote URL at rest. If no token is configured, the sync falls back to ambient authentication (ssh-agent, a configured credential helper), exactly as before this credential path existed.

The sync engine (apps/server/src/gitops/) is careful about a few things that matter in production:

  • Advisory-lease single-flight: concurrent triggers (boot, poll, webhook) never run two reconciles at once.
  • Fast paths: an unchanged commit, or an unchanged file blob within a changed commit, skips reapplication.
  • Per-file transaction isolation: one broken OKF file fails and records a file-error:<path> state row, cleared automatically once the file is fixed, while every other file in the same commit still applies.
  • Identity resolution: a page’s identity resolves manifest path first, then frontmatter id, then slug, adopting an existing page on collision (with a pre-adopt version snapshot) rather than refusing. The repo winning a collision is an explicit source-of-truth declaration, not an accident.
  • Unmanage on vanish: when a path disappears from the repo, the page’s metadata.gitops marker is stripped and the page survives, editable again, rather than being deleted.

The OKF filesystem mirror is GitOps-aware too: it reverts any external, on-disk edit to a managed file back to the canonical content, and re-exports the file if it’s unlinked rather than treating that as a delete. Plugin installs skip any page a GitOps manifest already manages, so the two provisioning paths never fight over the same page.