> For the complete documentation index, see [llms.txt](https://developer.celigo.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://developer.celigo.com/cli/local-tree/neutral-trees.md).

# Neutral trees

A standard [local tree](/cli/local-tree.md) is faithful to one account: files carry their `_id`s and every cross-reference is a raw id. That is right for one environment per repository, and wrong for keeping two environments on two git branches — the ids differ everywhere, so `git merge sandbox` into `prod` conflicts on every file even when the configuration is identical.

There are two answers. [`celigo promote`](/cli/local-tree/promote.md) writes a standard tree into another account or environment through a committed id map. This page describes the other: `celigo pull --neutral` pulls an **environment-neutral tree**, in which the tracked files contain nothing environment-specific — the ids are the environment, the content is the configuration — so one permanent git branch per environment merges cleanly.

## What is neutralized

In every tracked document:

* `_id` is removed. Identity lives in `.celigo/manifest.json`, which maps each file path to this environment's id.
* Server-managed fields (`_sourceId`, template and connector lineage, `lastExecutedAt`, …) are removed. A storage stub keeps only its name and MIME type.
* Every reference — declared paths, hook `_scriptId` and `_stackId` slots at any depth, and other `_xxxId` fields — is rewritten from a raw id to the target's **tree path**, the same placeholder form [push](/cli/local-tree/push.md#creates) accepts for new files: `"_exportId": "exports/Get Orders/export.json"`. A script reference points at the `.js`. An Event Streams topic is content too, not environment (since celigo-cli 2026.10.1): a listener or publisher names its topic by path (`"_topicId": "topics/orders.created/topic.json"`), and an integration's topic registrations leave its file with the other server-managed fields.
* Links omit the target `_id` in `.link.json` pointer files; the path identifies the target.

Two environments that hold the same configuration therefore write byte-identical files.

## The binding layer

Connections, iClients, MCP OAuth providers, stacks, and on-premise agents are legitimately *different* in each environment — different hosts, different credentials — and their [secrets never travel](/cli/local-tree.md#what-the-tree-strips-and-what-it-never-carries) anyway. In a neutral tree their documents are not tracked as files at all — since celigo-cli 2026.10.1 that includes MCP OAuth providers, which earlier releases tracked as files. A reference to one becomes a stable **binding key** derived from the resource's name:

```json
"_connectionId": "@bindings/connection/shopify"
```

The key → id resolution lives in `.celigo/bindings.json` — untracked, per environment, human-readable, and auto-populated by every pull from the account's actual resources:

```json
{
  "note": "Per-environment bindings for this neutral tree …",
  "bindings": {
    "connection/shopify": { "id": "6a1b2c3d4e5f6a7b8c9d0e1f", "name": "Shopify" }
  }
}
```

Each environment's copy maps the same keys to its own ids, which is the whole trick: the tracked reference is identical on every branch. `celigo resolve @bindings/connection/shopify` prints the current environment's id and name.

**Pins.** Edit an `id` by hand to point a key at a different resource — for example when production's connection is named `Shopify (Prod)`:

```json
"connection/shopify": { "id": "<production connection id>", "name": "Shopify (Prod)" }
```

A pin is durable: re-pulls preserve it (only entries whose id no longer exists refresh from the account), and re-renders keep writing the pinned key rather than the resource's own name-derived one, so the tracked files keep the shared symbol. The durable organizational fix is same-named connections in every environment, which makes pins unnecessary.

**Collisions.** Two resources whose names slugify to the same key collide; the second gets an id-suffixed key (`…--bbf193`) and the pull warns, because an id-suffixed key is environment-specific. Rename one resource so the key derives cleanly. A resource whose name has no a–z or 0–9 character gets a key derived from its id, and since celigo-cli 2026.10.1 the pull warns about it for the same reason.

**Renames and failed listings.** Since celigo-cli 2026.10.1 a connection renamed in one environment keeps one binding key in `pull`, `push`, and `diff` alike, so the tracked files no longer flip between its old and new key, which showed as differences nobody made. A pull that cannot list a type — a network error, a server error — keeps that type's entries in `.celigo/bindings.json`, pins included, and the tracked references to it stay symbolic; earlier releases dropped the entries and wrote this environment's raw ids into the files.

## What stays environment-specific

Ids embedded inside URI strings and ids hardcoded in script source are **not** rewritten — mangling a URI or rewriting code is worse than honesty. The pull reports each one as env-specific residue (`envResidue` in `--format json`) so a cross-environment merge knows what still needs review. Prefer Handlebars placeholders or script parameters over embedded ids where you can. Server-populated content that legitimately differs per environment — the `http._httpConnector*` metadata a connector-backed connection attaches, for instance — stays in the files too; restore your side's version after a merge that brought the other environment's (`git checkout ORIG_HEAD -- <files>`).

## The mode is decided once

`--neutral` is valid only on a tree's **first** pull (or with `--force` on a manifest that tracks nothing). It is recorded in `.celigo/manifest.json` and permanent: every later `pull`, `status`, `diff`, `push`, and `lint` honors it with no flag, and a standard tree can never be converted in place — re-pull into a fresh directory. The pull also writes a `.celigo/.gitignore` of `*`, keeping the manifest and the bindings — both per environment by definition — out of the repository. This is the opposite of a standard tree, whose manifest is committed.

A neutral pull fetches the **whole account** even when scoped: the symbol table needs every path and binding name. On a large account budget tens of seconds per pull.

## Push and diff in a neutral tree

Path references and `@bindings/` keys resolve to this environment's ids just before the wire; `_id` for an update comes from the manifest, matched by path — or, for a folder moved locally, by content ([below](#moving-a-folder-in-a-neutral-tree)). A push that meets a binding with no id in this environment refuses that item, naming the key and `.celigo/bindings.json` as the fix — or run `celigo pull` to auto-populate:

```
… → '@bindings/connection/store' has no id in this environment — add "connection/store" with this account's connection id to .celigo/bindings.json, or run celigo pull to auto-populate it
```

Creating a connection, iClient, MCP OAuth provider, stack, or on-premise agent from a file is refused: create it with its own command (`celigo connections create`), then pull to record its binding. Write-backs after a create render the neutral form, so a freshly pushed file is byte-identical to what the next pull would write; `diff` neutralizes the server copy before comparing, so only genuine content differences appear. An update whose pasted document carries a standard `_id` is still refused when that `_id` is not the tracked resource's.

A neutral tree names the integration that owns a document by the path of its `integration.json`. Since celigo-cli 2026.10.1 a neutral tree pulled with `--integration` pushes the integration's own flows, tools, syncs, and builder APIs; earlier releases refused each one as outside the tree's scope.

## Moving a folder in a neutral tree

> Added in celigo-cli 2026.10.1.

A neutral tree's documents carry no `_id`, so a move is recognized by content. A folder whose document and the files sent with it (`script.js`, `instructions.md`) are unedited since the pull, and match exactly one resource that left its path, is a move: `celigo status` reports it as `moved`, `push` sends nothing for it and never prunes it, and `celigo pull` puts the folder back, as in a standard tree. A lookup cache's `data.json` and a sync's datasets are its data, not its identity, so an edit to them still pushes from the moved folder. A move into a `.celigoignore`d folder is [parked](/cli/local-tree/status-and-diff.md#celigo-status).

A folder moved onto the path *another* tracked resource was pulled at — `rm -rf B && mv A B`, Finder's *Replace*, a chain of renames — is a move too, never an overwrite. A is moved, and B, whose own files are no longer in the tree, is missing: `status` names B in the move (`moved[].displaces` in `--format json`), `push` sends nothing for A and, under `--prune`, deletes only B, saying what now sits at its path, and `celigo pull` puts A back and restores B.

What content cannot pair is never guessed at:

* **A folder moved and edited** cannot be told from a delete plus a create. Into an untracked folder, `push --prune` plans exactly that, with a `note:` under the delete's line naming the files that may be the resource (`items[].mayBeMovedTo` in `--format json`), and the create of each such file says the same (`items[].mayBeMovedFrom`). Without `--prune`, the missing resource's line says it, and the create makes a second resource. A delete and a create give the resource a new id, and whatever references the old one breaks.
* **A resource's pulled files in more than one place**, or where another resource pulled with the same files sits, refuse that resource's delete, the create of each copy, and the update of the path holding them.
* **A file that is exactly another resource's pulled document** — a copy, two folders swapped — is never sent as an update of the resource whose path it sits at.
* **An edit at another resource's path that carries the name of a resource whose folder is gone** is refused with that resource's delete, since sending it would write it over the other resource.
* **A reference naming the path one resource was pulled at and another was moved onto** names both, and the document holding it is refused until `celigo pull` puts the folders back.

So in a neutral tree, run `celigo pull` to put a moved folder back before editing it. Earlier releases read every move in a neutral tree as a missing resource plus an untracked file, and `push --prune` deleted the live resource and created the moved file as a new one.

## One branch per environment

Each environment lives on a permanent branch, pulled from its own account with its own profile, in its own git worktree — a worktree keeps its own untracked `.celigo/` (manifest and bindings for that account), which is why worktrees beat `git switch` here.

```bash
git init repo && cd repo
git commit --allow-empty -m "Root: shared history for all environment branches"
git branch env/sandbox && git branch env/prod
git worktree add ../wt-sandbox env/sandbox
git worktree add ../wt-prod    env/prod

celigo pull --neutral --no-symlinks --dir ../wt-sandbox --profile sandbox
celigo pull --neutral --no-symlinks --dir ../wt-prod    --profile prod
```

Use `--no-symlinks` for git-tracked trees; the style is sticky.

* **Feature branches** are cut from the environment branch of the account you develop against, in a third worktree: `git worktree add -b feat/thing ../wt-feature env/sandbox`. No `.celigo/` is needed until you push.
* **PR checks.** From a worktree that has a `.celigo/` for the target account, check out the candidate content and run the two read-only gates: `celigo lint` and `celigo push --dry-run`.
* **Deploy = merge + push + a post-deploy commit.** Merge the feature into the environment branch, `celigo push`, then commit again: a push that creates resources normalizes the authored files (server defaults added, quick forms folded), and the next pull adds link files for new references. After that commit, re-pulls report everything unchanged. Branch protection that requires a pull request before merging refuses this commit; run the deploy from CI or from a bot account exempted from the protection.
* **Promote with a merge commit.** `git merge --no-ff env/sandbox` from the prod worktree, so every promotion is a recorded merge even when git could fast-forward, then `celigo push` from the prod worktree resolves the same tracked symbols against prod's `.celigo/`. Because the tracked files are identical wherever the configuration is identical, the merge carries only real edits — a flow enabled, a mapping changed — never id churn. Cherry-picking a single fix across environments works the same way.
* **The first promotion PR looks bigger than the merge.** GitHub's "Files changed" view is a three-dot diff against the merge base, which on the first `env/sandbox` → `env/prod` PR can be the shared root commit, so files prod already carries byte-identically are displayed as new. Git's own diff is the authoritative one; every later promotion PR has a recent merge base.
* **Cross-environment connections.** If prod has no connection named like the binding key, the push refuses the item naming the key. Pin it in the prod worktree's `.celigo/bindings.json` (above).

Neutral trees are not promotable with `celigo promote` in this version; the branch workflow above is their multi-environment story.

## Related

* [Pull a tree](/cli/local-tree/pull.md) — `--neutral`, `--no-symlinks`, `--force`.
* [Push changes](/cli/local-tree/push.md) — path placeholders and creates.
* [Status and diff](/cli/local-tree/status-and-diff.md) — `moved`, `parked`, and `unfinished` resources.
* [Promote to another account](/cli/local-tree/promote.md) — the standard-tree alternative.
* [Teams, git, and CI](/cli/local-tree/workflow.md) — the rules a standard tree follows instead.
