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
Configuration
Add env entries to the envs section of your b.yaml:
That's it — run b update to sync.
Fields
| Field | Description | Default |
|---|---|---|
version | Git tag, branch, or commit SHA to pin | HEAD |
strategy | How to handle local changes: replace, client, or merge | replace |
group | Group name for selective syncing with --group | none |
ignore | Glob patterns to exclude from all file matches | none |
files | Map of glob patterns to destination config (required; use "**" for all) | none |
onPreSync | Shell command to run before syncing | none |
onPostSync | Shell command to run after syncing | none |
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:
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@bmid-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 inb.yamlyou'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 forkThe jmespath-community v1.1.1 Go fork returns
[key, value]arrays fromitems()rather than the{key, value}objects shown in the JMESPath website's tutorial. Access the value's fields with[1].fieldrather thanvalue.field. So a binaries-by-groups filter is written as:not
from_items(items(binaries)[?contains(value.groups, 'core')]). If you copy an example from jmespath.org and getnull, this is why.
Example — pull only the binaries that have core in their groups, preserving the original keys:
Mix simple and complex on the same file:
Supported file extensions: .yaml, .yml, .json.
Note: When
bwrites to yourb.yaml(e.g.b install --add,b env add), existing comments and formatting are preserved.
Glob patterns
| Pattern | Matches |
|---|---|
manifests/base/** | All files recursively under manifests/base/ |
**/*.yaml | All .yaml files anywhere in the repo |
configs/ingress.yaml | A single literal file |
charts/*/values.yaml | values.yaml one directory deep under charts/ |
Destination paths
The glob prefix is stripped and the dest value is prepended:
| Source file | Glob | Dest | Result |
|---|---|---|---|
manifests/hetzner/deploy.yaml | manifests/hetzner/** | hetzner/ | hetzner/deploy.yaml |
configs/ingress.yaml | configs/ingress.yaml | config/ | config/ingress.yaml |
README.md | **/*.md | (none) | README.md |
Labels
Use the same repo multiple times with different configurations:
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:
SSH authentication uses your system's ssh-agent (SSH_AUTH_SOCK). No tokens or passwords are needed — just ensure your SSH key is loaded:
Local repos
Sync files from local git repos using relative or absolute paths:
Local repos are used directly — no cloning or network access required.
SCP syntax
For one-off syncs without editing b.yaml:
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: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 tosafety: auto. -
CI / non-TTY: overwrites without prompting under
auto; refused understrictorprompt(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:
- Base — file at the previously synced commit (from
b.lock) - Local — current file on disk
- Upstream — file at the new commit
Conflict markers are inserted when both sides changed the same region:
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:
-
The underlying 3-way merge is line-based (
git merge-file), so concurrent adjacent additions inside the scoped region — e.g. local addsargsh, upstream addstilt— 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. -
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 aselect:filter,bwill 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 theselect:, 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:
Preview
Test what a glob matches before adding it to config:
Remove
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:
--plan-json writes a single JSON array of plan objects (one per env) so you can parse the entire run with one jq . invocation:
Rollback
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):bruns a dry-run sync to compute the plan, prints it, then either applies (after passing the safety gate) or refuses. - Apply-first (
auto,--yes):bapplies 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:
| Mode | Interactive (TTY) | Non-interactive (CI) | --yes effect |
|---|---|---|---|
strict | Refuses any destructive plan | Refuses any destructive plan | No effect — still refuses |
prompt* | Shows plan, asks [y/N] | Refuses destructive | Acts like auto |
auto | Applies without prompting | Applies 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:
Or override on the command line:
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:
Hooks
Run shell commands before or after syncing:
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:
| Field | Description |
|---|---|
description | Human-readable description shown in b env profiles |
includes | Compose 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
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
- Resolve version —
git ls-remoteconverts the version to a commit SHA. - Check lock — Skip if
b.lockalready has the same commit. - Clone/fetch — Bare clone to
~/.cache/b/repos/, fetch the target commit. - Match files —
git ls-tree+ glob matching, filtering ignored paths. - Detect changes — Compare on-disk SHA256 against
b.lockchecksums. - Apply strategy — Write files per the configured strategy.
- Update lock — Record commit SHA, file checksums, modes, and destinations.
Lock file
b.lock tracks the exact state of every synced env:
Commit b.lock to version control so your team stays in sync and b verify can detect drift.
Git cache
Uses shallow bare clones (--depth 1) to minimize disk usage.
Tips
- CI/CD:
replaceapplies silently in non-interactive mode. Useclientormergeif your pipeline modifies synced files. - Verify:
b verifychecks all artifacts againstb.lockchecksums. - Conflicts: If two env entries write to the same path, b warns before syncing.
- Auth: Private repos need
GITHUB_TOKEN,GITLAB_TOKEN, orGITEA_TOKEN. See Authentication. - Force:
b update --forcere-syncs even when up-to-date. - Unchanged: Files identical to upstream are automatically skipped.