---
title: "Development & versioning"
description: "A world is a shared, multi-user editor session. Everyone connected sees your edits live and you see theirs — like a shared document, in both edit and play (the multiplayer guide). That shapes how…"
section: "Core"
slug: "core-development"
canonical: "https://origozero.ai/docs/core-development"
updated: "2026-09-02T07:01:53.689447293+00:00"
tags: ["documentation", "guide"]
---

# Development & versioning

## Durable now, published later

Writes to `/zero/source` are durable the instant you make them — there's **no save step**, and they sync to every collaborator immediately. But durable isn't the same as *published*. Two deliberate steps separate your live editing from what players see:

- **commit** — a navigable checkpoint in the world's history. Stays inside the editor.
- **push** — publishes a new playable version for players and other importers. Only pushed versions are what players run; a push bundles your unpushed commits into one release.

### Direct file writes vs. live scene edits

"No save step" applies to **direct writes to `/zero/source`** — `write_file`, `vfs.write`, `asset.create`. Those bytes are in the source the instant you make them.

**Editing the scene through the ECS is different.** Spawning or changing entities with `entity.*`, the `entityOps` tools, or the `scene` tools mutates the *running* scene, not `scene.json` directly. Those edits are live immediately, but they are held as **pending scene changes** — visible via `tools.use("sceneAuthoring", "changes")` — and reach `scene.json` only when you save the scene: `layers.active:save()`, or review-and-accept them with `tools.use("sceneAuthoring", "acceptChanges")`. A spawned entity that is never saved is live for the session but absent from the source, so it does not survive a reload. `topics/building-a-game` walks the play → `changes` → `acceptChanges` authoring loop in full.

The friendliest surface is the `zm` command in the engine **bash** — the verbs you already know from git, on the shell you already drive git with:

```sh
zm status                        # what's changed
zm add .                         # stage everything (or a specific path)
zm commit -m "describe the change"   # checkpoint
zm push                          # publish
```

With more than one of you authoring the world at once, add `--stage <name>` to `add` / `commit` / `unstage` / `status` / `diff` and each of you commits your own paths under your own message — the `core/worlds` guide covers what the staging area is and what naming one changes.

The same verbs exist as the `zm` Luau toolbox, and the full surface is `world.*`:

```lua
zm.status()                       -- what's changed
zm.add("/zero/source/foo.luau")   -- stage a path        (zm.addAll() for everything)
zm.unstage("/zero/source/foo.luau") -- unstage, keeping your working edits
zm.discard("/zero/source/foo.luau") -- throw away a file's uncommitted edits (recoverable)
zm.commit("describe the change")  -- checkpoint
zm.reset("<commit>")              -- rewind HEAD          (recoverable via zm.trash + zm.restore)
zm.push()                         -- publish

-- the full Luau surface, same operations:
world.add(path, { force = true? })   world.add_all()   world.unstage(path)
world.discard()                      world.discardFile(path)   -- drop the whole stage / one file's edits
local id  = world.commit("message")  -- returns the commit id
local zmId, verdict = world.push()   -- pushes the unpushed stack (or world.push(commitId));
                                     -- verdict is "published", or "already-published" when the
                                     -- remote already carries what the call would have pushed
```

Commit stages **only what you've added** (git semantics, not commit-all). Inspect with `world.vcsStatus()` (`{dirty, staged, ignored, untracked, unmerged}`), `world.log({limit})`, `world.diff(...)`, `world.show(...)`, and `world.head()`.

Push also **refuses while user scripts are broken**: if any script under `/zero/source` carries an error-severity diagnostic, `zm.push` raises `N script error(s) in user content — refusing to publish until they are fixed`, naming each `path:line — message`. This is unconditional — the `lsp.strict` overrides that relax the edit→play gate (the engine guide) do not apply to publish. Fix the errors (`lsp.checkAll({ scope = "user" })` lists them) and push again.

A push whose commits are already on the remote is the state you asked for, so it reports `already up to date` and returns the `"already-published"` verdict — a sibling session that published your commit as an ancestor of theirs leaves you here. A push ZeroMind refuses names the condition and the command that clears it: a branch that moved under you says so and points at `zm pull`, after which `zm push` lands.

A push reports **every** class of blocker it finds in one refusal — broken scripts, assets missing files their type requires, and asset references that can't be statically pinned are listed together, so one round of fixes clears the push.

## Branches & merging

A session is bound to one branch (`world.branch()`, `"main"` by default). Branching and merging are git, verb for verb:

```lua
zm.branch("feature")             -- git branch feature   (from HEAD; { from = "<commit>" } to override)
world.checkout("feature")        -- git checkout feature
```

Every branch owns its own working tree, so collaborators on other branches keep editing undisturbed. Bringing another branch's work into yours is `git merge`:

```lua
local r = zm.merge("feature")     -- or world.merge("feature")
-- r.status: "clean" (r.commit = the two-parent merge commit id)
--           "conflicts" (r.conflicts = { { path, kind, source_branch } })
--           "up_to_date"
```

The merge runs locally in the world and needs a **clean working tree** (commit or stash first). A clean merge lands a two-parent merge commit and the merged content appears in the working tree. On conflicts, each clashing text file gets git-style `<<<<<<<` / `=======` / `>>>>>>>` markers, the cleanly-merged remainder is applied as working-tree changes, and `world.vcsStatus().unmerged` lists what needs attention (git's "Unmerged paths"). Resolve each path — edit out the markers, rewrite the file, or remove it — then stage and commit as usual: that commit records the merge (second parent = the source head) and clears the unmerged set. Staging or committing a path that still carries markers is refused.

`zm.mergeAbort()` (`world.mergeAbort()`) is `git merge --abort`: it clears the unmerged set and restores the working tree to its pre-merge state — the branch head never moved.

Pushing after a merge sends the merge parent along, so the published history in ZeroMind records both parents.

## Staying in sync with ZeroMind (origin)

ZeroMind is the world's origin remote, and content can advance there outside your session: a collaborator pushes, a pull request merges, a fork syncs. Fetch is how that drift becomes visible, and pull is how it reconciles. Verb for verb with git:

```lua
local f = zm.fetch()      -- or world.fetch(): git fetch
-- f.status: "fetched" | "up_to_date" | "no_remote_commits"
-- f.behind: origin commits you don't have    f.ahead: local commits origin doesn't have
-- f.diverged: both at once

local r = zm.pull()       -- or world.pull(): git pull
-- r.status: "up_to_date"
--           "fast_forward" (r.commit = the new head; no merge commit)
--           "clean"        (r.commit = the two-parent merge commit)
--           "conflicts"    (same markers/resolve flow as a branch merge)
```

Fetch mirrors the origin head into local history without touching the working tree, so it is always safe. Pull needs a clean working tree, exactly like a branch merge. When the branch is strictly behind, pull fast-forwards and local history stays identical to origin's; when both sides moved, pull runs the same three-way merge as `zm.merge` with the origin head as the source, and the resolve-then-commit flow is unchanged.

`zm status` shows the remote-tracking line as of the last fetch. The counts are a snapshot: run `zm fetch` again to refresh them.

## Contributing changes upstream

When you improve content you installed from another world, you can send the change back to the world that owns it — the fork sends a pull request to its upstream. Verb for verb it is `git subtree push` ending in a PR:

```lua
zm.forkStatus()   -- or world.forkStatus(): what installed content is ahead of its origin
zm.contribute()   -- or world.contribute(opts): open (and by default merge) PR(s) upstream
```

`forkStatus` lists every installed asset whose content diverges from the version you pinned when you installed it, grouped by the true origin world. Provenance is transitive: a fix to a deep dependency is attributed to the world that authored it, not the intermediary world you happened to install through, so the pull request lands where it can actually be merged.

`contribute` does the rest per origin: it remaps your changed files to the origin's canonical paths, three-way merges them against the origin's current content, pushes a `contrib-<id>` branch into the origin world, and opens a pull request there. If the origin changed the same content in the meantime, those regions come back as local conflicts with markers — resolve them like a pull conflict and run `contribute` again. When you have write access the pull request is merged immediately (otherwise it is left open for review), and afterwards the local fork re-syncs so the asset reads as level with its origin again instead of perpetually ahead.

The pull-request surface is directly drivable too: `zm.prList(world?)` and `zm.prMerge(world, number, strategy?)` (also `world.prList` / `world.prMerge`).

## Throwaway working state: `.zmignore`

Some files need to sync to collaborators live but should never enter history — a scene editor's per-keystroke `scene_dirty/…` files, for instance. Drop a **`.zmignore`** (or `.gitignore` — both honored) under `/source` with gitignore(5) patterns (`*`, `**`, `!negation`, anchored `/`, dir-only `/`):

```
scene_dirty/
*.tmp
```

Matching files **still sync** (the filter is a staging gate, not a sync gate) and show up in `world.vcsStatus()` under `ignored`, but `world.add` refuses them and `world.add_all` skips them. `world.add(path, { force = true })` is the escape hatch. The gate also checks dependencies: staging or committing a file whose refs point at an ignored path is refused, so an ignore rule can't slip a broken-reference commit through.

## Safety rails — the editor is shared

Because edits affect everyone, cleanup is backed by a shared safety net rather than a wall of confirmations:

- **A shared 7-day trash.** Destructive operations snapshot what they remove into a world trash first, so they're recoverable: `world.trash()` (`zm.trash`) lists entries, `world.restore(rowId)` (`zm.restore`) brings one back. Rewinding history (`world.reset(commitId)`) and throwing away a file's uncommitted edits (`world.discardFile(path)` / `zm.discard`) run in one step; reset's orphaned commits restore in chain order (oldest first).
- **Affirmation tokens.** Permanently removing versioned files returns a one-shot `XXX-XXX-XXX` token you confirm with `world.affirm(token)` — the last rail before content leaves the trash-backed path.
- **Stashes.** Set aside your dirty + staged state and reapply later: `world.stash(label?)`, `world.stashes()`, `world.stashPop(handle)`, `world.stashDrop(handle)`.

`world.list()` enumerates your worlds; `world.installLibrary` / `world.installAsset` bring shared content in (the worlds guide), `world.assetInstallable` answers whether one published asset resolves a closure of its own before you pull it, and `world.installedAssets()` lists what this world carries from ZeroMind keyed by published guid — the identity to check a pull against, since an installed asset's local path is chosen by the installer.

## The model

Edit freely — it's durable and shared instantly. **Commit** to checkpoint, **push** to publish. Use `.zmignore` for live-but-throwaway state, and trust the affirmation/trash rails: in a shared world they're what keep one person's cleanup from being everyone's loss. Discover the full surface with `lsp` and by reading `modules/api/engine` (the discovering guide).
