Log inGet started

Development & versioning

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…

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.

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:

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

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

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 = world.push()            -- pushes the unpushed stack (or world.push(commitId))

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 refusing to publish — N script error(s) in user content: 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.

Branches & merging

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

zm.branch("feature")                            -- git branch feature   (from HEAD; { from = "<commit>" } to override)
task.await(world.swap(world.guid(), "", "", "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:

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:

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:

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).

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).

  • documentation
  • guide