Skip to main content
Skip to main content

Env Sync

b can sync configuration files from upstream git repositories into your project. This is useful for sharing manifests, configuration templates, or any files across repositories while tracking changes and handling conflicts.

Quick start

# Sync files from any git repo
b install github.com/org/infra:/manifests/base/** ./base

# Add to b.yaml for repeated syncing
b install --add github.com/org/infra@v2.0:/manifests/** ./config

# Update all envs from b.yaml
b update

Configuration

Add env entries to the envs section of your b.yaml:

envs:
github.com/org/infra:
version: v2.0
strategy: merge
ignore:
- "*.md"
files:
manifests/base/**:
dest: base/
manifests/hetzner/**:
dest: hetzner/

# Sync all files from HEAD
github.com/org/shared-config:
files:
"**":

That's it — run b update to sync.

Fields

FieldDescriptionDefault
versionGit tag, branch, or commit SHA to pinHEAD
strategyHow to handle local changes: replace, client, or mergereplace
groupGroup name for selective syncing with --groupnone
ignoreGlob patterns to exclude from all file matchesnone
filesMap of glob patterns to destination config (required; use "**" for all)none
onPreSyncShell command to run before syncingnone
onPostSyncShell command to run after syncingnone

Files map

files:
# Bare key — preserve original path structure
manifests/base/**:

# String shorthand — set destination directory
manifests/hetzner/**: hetzner/

# Full config — dest, per-glob ignore, and select
manifests/monitoring/**:
dest: monitoring/
ignore:
- "*.test.yaml"

# Select specific keys from YAML/JSON files
.bin/b.yaml:
select:
- .binaries

Select (YAML/JSON filtering)

For YAML and JSON files, select extracts specific keys instead of syncing the entire file:

files:
config.yaml:
select:
- .database
- .logging
.bin/b.yaml:
select:
- .binaries

Selectors use hybrid syntax:

  • Simple dot-paths like binaries, .binaries, database.host, or even plain keys with characters that the legacy validator accepts (foo/bar, my+key, a@b mid-key) are extracted via the YAML Node API. Whole top-level keys keep their comments, ordering, and block/flow style. (# is fine for the classifier itself, but in b.yaml you'd need to quote a selector starting with # like "#tag" so YAML doesn't treat it as a comment. Selectors starting with @ are reserved for JMESPath's current-node operator and are routed to the complex path — see the callout below.)
  • Complex JMESPath expressions — anything containing brackets, parens, braces, pipes, multi-select hashes, or function calls (i.e. JMESPath grammar characters) — are routed to JMESPath (jmespath-community fork, supports items / from_items). Comments are NOT preserved for keys produced by the complex side.

Selecting top-level keys with JMESPath grammar characters

If your YAML file has a top-level key that contains characters JMESPath uses as grammar ([, ], (, ), {, }, |, *, &, ,, ', ", <, >, =, !, ?, backtick, backslash, whitespace), the simple dot-path classifier will route it to JMESPath. Use a quoted identifier to access the key, for example '"my key with spaces"' or '"weird[name]"'.

Important: selecting the key directly this way does not preserve the original top-level key name in the merged output. Object results get merged at the top level (so the key name is consumed by the merge), and scalar/array results get wrapped under result. If you need to keep the original key name in the output, wrap it explicitly in the JMESPath expression — for example '{"weird[name]": "weird[name]"}' for a scalar/array, or just rename it to a friendlier identifier with '{normal_name: "weird[name]"}'.

Plain keys with /, +, #, and @ mid-key work without quoting because the classifier passes them through to the Node API path. Leading @ (e.g. @foo, .@.foo) is the one exception: JMESPath uses @ for the current-node operator, so any selector starting with @ is treated as complex. Top-level keys whose name starts with @ need to be selected via JMESPath quoted identifiers (e.g. '"@foo"') instead of as bare dot-paths.

  • Mixed lists are supported for YAML files only: simple and complex selectors on the same YAML file are evaluated independently and merged at the top level via the Node API. Simple-side comments and layout survive the merge; complex-side keys carry no comments. JMESPath wins on key collisions. JSON files reject mixed selectors with an explicit error — use either dot-paths only or JMESPath only — because JSON has no comments to preserve and the merge logic only existed to keep YAML comments intact.

items() returns tuples, not objects in the v1.1.1 Go fork

The jmespath-community v1.1.1 Go fork returns [key, value] arrays from items() rather than the {key, value} objects shown in the JMESPath website's tutorial. Access the value's fields with [1].field rather than value.field. So a binaries-by-groups filter is written as:

from_items(items(binaries)[?contains([1].groups, 'core')])

not from_items(items(binaries)[?contains(value.groups, 'core')]). If you copy an example from jmespath.org and get null, this is why.

Example — pull only the binaries that have core in their groups, preserving the original keys:

files:
.bin/b.yaml:
select:
- "{binaries: from_items(items(binaries)[?contains([1].groups, 'core')])}"

Mix simple and complex on the same file:

files:
.bin/b.yaml:
select:
- envs # simple, comments preserved
- "{binaries: from_items(items(binaries)[?...])}" # complex, JMESPath

Supported file extensions: .yaml, .yml, .json.

Note: When b writes to your b.yaml (e.g. b install --add, b env add), existing comments and formatting are preserved.

Glob patterns

PatternMatches
manifests/base/**All files recursively under manifests/base/
**/*.yamlAll .yaml files anywhere in the repo
configs/ingress.yamlA single literal file
charts/*/values.yamlvalues.yaml one directory deep under charts/

Destination paths

The glob prefix is stripped and the dest value is prepended:

Source fileGlobDestResult
manifests/hetzner/deploy.yamlmanifests/hetzner/**hetzner/hetzner/deploy.yaml
configs/ingress.yamlconfigs/ingress.yamlconfig/config/ingress.yaml
README.md**/*.md(none)README.md

Labels

Use the same repo multiple times with different configurations:

envs:
github.com/org/infra#base:
version: v2.0
files:
manifests/base/**:
dest: base/

github.com/org/infra#monitoring:
version: v2.0
strategy: merge
files:
manifests/monitoring/**:
dest: monitoring/

Labels create separate lock entries so each group is tracked independently.

SSH repos

Use SSH URLs for private repos or repos where you have SSH key access:

envs:
# Implicit SSH format
git@github.com:org/private-infra:
files:
manifests/**:
dest: manifests/

# Explicit SSH format
ssh://git@github.com/org/private-infra:
files:
configs/**:
dest: configs/

SSH authentication uses your system's ssh-agent (SSH_AUTH_SOCK). No tokens or passwords are needed — just ensure your SSH key is loaded:

# Verify ssh-agent has your key
ssh-add -l

# Add a key if needed
ssh-add ~/.ssh/id_ed25519

Local repos

Sync files from local git repos using relative or absolute paths:

envs:
# Relative to b.yaml location
git://../../shared-config#base:
files:
manifests/**:
dest: manifests/

# Absolute path
/home/user/repos/shared-config:
files:
configs/**:
dest: configs/

Local repos are used directly — no cloning or network access required.


SCP syntax

For one-off syncs without editing b.yaml:

<repo>[@<version>]:/<glob> [<dest>]
b install github.com/org/infra:/manifests/base/** ./base
b install github.com/org/infra@v2.0:/manifests/hetzner/** ./hetzner
b install --add github.com/org/infra@v2.0:/manifests/** ./config

Merge strategies

When upstream files change and you've also modified the local copy, the strategy controls what happens.

replace (default)

Overwrites local files with upstream content. The behavior depends on the safety mode:

  • safety: auto (or --yes) on a TTY: still uses the legacy per-file interactive resolver, which prompts:

      system/config.yaml has local changes.
    [r]eplace [k]eep [m]erge [d]iff > _

    This lets you make per-file decisions during apply.

  • safety: strict / safety: prompt (default): uses the plan-based gate instead of the legacy per-file resolver. You see the full plan up-front, then approve or reject the whole batch — there are no per-file prompts during apply, because the plan you approved IS the apply contract. If you need per-file granularity, switch the env to safety: auto.

  • CI / non-TTY: overwrites without prompting under auto; refused under strict or prompt (the latter falls back to strict on non-TTY). See Safety tiers below.

client

Keeps local files unchanged when modified. Only new files from upstream are written.

merge

Three-way merge using the previous commit as base:

  1. Base — file at the previously synced commit (from b.lock)
  2. Local — current file on disk
  3. Upstream — file at the new commit

Conflict markers are inserted when both sides changed the same region:

<<<<<<< local
your-value: true
||||||| base
original-value: true
=======
upstream-value: true
>>>>>>> upstream

Falls back to replace when the base version is unavailable.

merge + select

When a file has a select: filter and strategy: merge, all three merge inputs (base, local, upstream) are filtered through the same selector before being passed to the 3-way merge. Without symmetric filtering, the merge would see "upstream = filtered subset" vs "base/local = whole file" and treat everything outside the selected scope as a giant deletion — silently wiping consumer-owned content like other top-level keys, comments, and custom sections.

After merging, the result is spliced back into the consumer's full local file:

  • In-scope top-level keys are replaced with the merged values
  • Out-of-scope keys (other envs:, profiles:, etc.) are preserved
  • Comments on out-of-scope keys stay attached to their original nodes
  • Even when the merge produces conflict markers, the splice preserves out-of-scope content byte-for-byte, so conflict resolution only happens inside the scoped region

Example: consumer uses select: [binaries] to sync only the upstream's binaries: section into its own .bin/b.yaml. The consumer's envs: block is untouched on every sync, and only the binaries: section participates in the merge.

Known limitations:

  1. The underlying 3-way merge is line-based (git merge-file), so concurrent adjacent additions inside the scoped region — e.g. local adds argsh, upstream adds tilt — can produce a spurious conflict marker even though the changes are semantically independent. The data-loss bug (losing out-of-scope content) is fixed; the cosmetic spurious-conflict issue is tracked as a follow-up.

  2. JSON files are not supported in select + non-trivial sync. The splice that puts a (filtered or merged) scope back into the consumer's full file is implemented for YAML only. If you have a JSON file with a select: filter, b will error out at sync time — including the very first sync to a non-existent destination, so you can't accidentally end up with a half-written file. Either drop the select:, move the data to YAML, or wait for JSON splice support. Tracked as a follow-up.


Managing envs

Status

Check for upstream changes and local drift without syncing:

b env status

Preview

Test what a glob matches before adding it to config:

b env match github.com/org/infra "manifests/base/**" ./base

Remove

b env remove github.com/org/infra
b env remove github.com/org/infra#monitoring
b env remove --delete-files github.com/org/infra

Dry-run

b update --dry-run

--dry-run prints a per-file plan for any env that has changes and never writes. Envs that are already up-to-date with the lock print a single (up to date) line instead of an empty plan table — that's the cheap path. In --plan-json mode, every env gets an entry in the array (even up-to-date ones, with rows: []) so consumers can distinguish "no envs configured" from "all envs up-to-date". Combine with --plan-json to emit a machine-readable plan for PR comment bots and CI summary jobs:

b update --plan-json

--plan-json writes a single JSON array of plan objects (one per env) so you can parse the entire run with one jq . invocation:

[
{ "ref": "github.com/org/infra", "commit": "def4567", "rows": [ ... ] },
{ "ref": "github.com/org/other", "commit": "abc1234", "rows": [ ... ] }
]

Rollback

b update --rollback
b update --rollback github.com/org/infra

Safety tiers

Every b update produces a plan — a per-file table of additions, updates, keeps, overwrites, merges, and conflicts. There are two flows depending on the safety setting:

  • Plan-first (strict, prompt): b runs a dry-run sync to compute the plan, prints it, then either applies (after passing the safety gate) or refuses.
  • Apply-first (auto, --yes): b applies directly and renders the plan from the apply result. The plan still appears in the output but the writes have already happened by the time you see it.

The safety setting controls which flow b uses and what it does with destructive changes:

ModeInteractive (TTY)Non-interactive (CI)--yes effect
strictRefuses any destructive planRefuses any destructive planNo effect — still refuses
prompt*Shows plan, asks [y/N]Refuses destructiveActs like auto
autoApplies without promptingApplies without prompting(no-op)

* prompt is the default. It is the safe option both interactively and in CI.

--yes is the CI escape hatch for prompt. It does NOT override strict: if you want a permanent override use safety: auto in b.yaml or --safety=auto on the command line. A safety refusal causes b update to exit non-zero so CI pipelines actually notice.

A plan row is "destructive" when it would lose user-owned content — currently that means overwrite (a non-merge strategy is about to clobber locally modified files) or conflict (a 3-way merge produced markers and the file needs manual resolution).

Configure per-env in b.yaml:

envs:
github.com/locked-down/repo:
safety: strict # CI fails on any overwrite or conflict
github.com/trusted/upstream:
safety: auto # apply silently, no prompts
github.com/normal/repo:
# safety omitted → defaults to "prompt"

Or override on the command line:

b update --safety=auto              # apply everything without prompting
b update --safety=strict # fail loudly on any destructive change
b update -y # one-time --yes: skip the prompt for this run
b update --plan-json # emit JSON plan, never apply

Plan output

  github.com/org/infra                     abc1234 → def4567
+ add hetzner/control-plane.yaml
~ update hetzner/load-balancer.yaml
= keep hetzner/firewall.yaml (local changes preserved)
! overwrite hetzner/legacy.yaml
⊕ merge hetzner/network.yaml
✗ conflict hetzner/secrets.yaml
1 add, 1 update, 1 keep, 1 overwrite, 1 merge, 1 conflict

Performance note

When safety is auto (or --yes is set), b runs SyncEnv exactly once: it applies and renders the plan from the apply result. When safety is strict or prompt, b runs SyncEnv twice — once in dry-run to compute the plan for the gate, then once in real apply mode if approved. The second pass benefits from a hot git cache, but it does still re-read and re-merge files. If you need every cycle, use safety: auto for fully-automated runs.


Groups

Tag envs for selective syncing:

envs:
github.com/org/infra#base:
group: dev
files:
manifests/base/**:
dest: base/

github.com/org/infra#monitoring:
group: prod
files:
manifests/monitoring/**:
dest: monitoring/
b update --group=dev

Hooks

Run shell commands before or after syncing:

envs:
github.com/org/infra:
onPreSync: "echo 'Starting sync...'"
onPostSync: "kubectl apply -k manifests/"
files:
manifests/**:
dest: manifests/

Hooks run in the project root directory and respect --quiet mode. They are skipped during --dry-run.


File modes

b preserves executable file modes from upstream. Files with git mode 100755 target 0755 permissions; others target 0644. The initial write respects your system umask; subsequent updates normalize permissions to match upstream.


Profiles (optional)

Profiles are an optional feature for upstream repos that want to offer preconfigured file sets to consumers. They are not required — you can always configure envs directly as shown above.

When to use profiles

  • You maintain a shared infrastructure repo and want to offer named presets (e.g. "base", "monitoring", "staging")
  • You want consumers to discover and install your file sets without knowing the directory structure
  • You want to compose presets from smaller building blocks using includes

Publishing profiles

Add a profiles section to your upstream repo's b.yaml:

profiles:
base:
description: "Base Kubernetes manifests"
files:
manifests/base/**:
dest: base/

monitoring:
description: "Prometheus + Grafana stack"
strategy: merge
files:
manifests/monitoring/**:
dest: monitoring/

staging:
description: "Staging preset (base + monitoring)"
includes:
- base
- monitoring
ignore:
- "**/prod-*"

Profile fields are the same as env fields, plus:

FieldDescription
descriptionHuman-readable description shown in b env profiles
includesCompose from other profiles (resolved recursively; later overrides earlier)

Discovering profiles

$ b env profiles github.com/org/infra@v2.1

Available profiles from github.com/org/infra @ v2.1:

base Base Kubernetes manifests
manifests/base/** → base/

monitoring Prometheus + Grafana stack
manifests/monitoring/** → monitoring/

staging Staging preset (base + monitoring)
includes: base, monitoring

Install a profile with:
b env add --version v2.1 github.com/org/infra#<name>

If the upstream repo has no b.yaml, the command falls back to listing the directory structure with suggested install commands.

Installing a profile

# Add a profile to your local b.yaml
b env add github.com/org/infra#monitoring

# Pin a specific version
b env add --version v2.0 github.com/org/infra#base

This copies the profile config into your local envs section. Profiles with includes are automatically flattened. Run b update to sync.

Version: If --version is given, the profile is pinned. If omitted, it tracks HEAD.

Interactive selection

$ b env add -i github.com/org/infra

Available profiles from github.com/org/infra:

[1] base Base Kubernetes manifests
[2] monitoring Prometheus + Grafana stack
[3] staging Staging preset (base + monitoring)

Select profiles (space-separated numbers, e.g. "1 3"): 1 3
Added github.com/org/infra#base to b.yaml
Added github.com/org/infra#monitoring to b.yaml

Run `b update` to sync files.

Requires a terminal (not available in CI/CD).


How it works

  1. Resolve versiongit ls-remote converts the version to a commit SHA.
  2. Check lock — Skip if b.lock already has the same commit.
  3. Clone/fetch — Bare clone to ~/.cache/b/repos/, fetch the target commit.
  4. Match filesgit ls-tree + glob matching, filtering ignored paths.
  5. Detect changes — Compare on-disk SHA256 against b.lock checksums.
  6. Apply strategy — Write files per the configured strategy.
  7. Update lock — Record commit SHA, file checksums, modes, and destinations.

Lock file

b.lock tracks the exact state of every synced env:

{
"envs": [
{
"ref": "github.com/org/infra",
"label": "",
"version": "v2.0",
"commit": "abc123def456...",
"previousCommit": "old789...",
"files": [
{
"path": "manifests/base/deploy.yaml",
"dest": "base/deploy.yaml",
"sha256": "e3b0c44298fc...",
"mode": "644"
}
]
}
]
}

Commit b.lock to version control so your team stays in sync and b verify can detect drift.


Git cache

b cache path              # Show cache location
du -sh $(b cache path) # Check cache size
b cache clean # Remove all cached repos

Uses shallow bare clones (--depth 1) to minimize disk usage.


Tips

  • CI/CD: replace applies silently in non-interactive mode. Use client or merge if your pipeline modifies synced files.
  • Verify: b verify checks all artifacts against b.lock checksums.
  • Conflicts: If two env entries write to the same path, b warns before syncing.
  • Auth: Private repos need GITHUB_TOKEN, GITLAB_TOKEN, or GITEA_TOKEN. See Authentication.
  • Force: b update --force re-syncs even when up-to-date.
  • Unchanged: Files identical to upstream are automatically skipped.
Was this section helpful?