Skip to content

feat(env): safety tiers + plan output for env update (#125 phase 1)#128

Merged
fentas merged 13 commits into
mainfrom
feat/env-safety-plan
Apr 7, 2026
Merged

feat(env): safety tiers + plan output for env update (#125 phase 1)#128
fentas merged 13 commits into
mainfrom
feat/env-safety-plan

Conversation

@fentas

@fentas fentas commented Apr 7, 2026

Copy link
Copy Markdown
Owner

Phase 1 of #125. Adds the user-consent and visibility layer.

What's new

  • Per-env `safety` field: `strict` | `prompt` (default) | `auto`
  • Plan-style output for every sync: `+ add` / `~ update` / `= keep` / `! overwrite` / `⊕ merge` / `✗ conflict` + summary line
  • `--safety` flag to override per-env safety from the CLI
  • `--plan-json` flag for machine-readable output (PR comment bots, CI summary jobs)
  • `--dry-run` always emits a plan and never writes
  • `--yes` / `-y` skips the prompt confirmation in interactive mode

Safety semantics

Mode Interactive (TTY) Non-interactive (CI)
`strict` Refuses any destructive plan Refuses any destructive plan
`prompt` * Shows plan, asks `[y/N]` Refuses destructive (= strict)
`auto` Applies without prompting Applies without prompting

* default

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

⚠ BREAKING CHANGE for non-TTY consumers

The default safety changed from "always apply" to "prompt → strict on CI". CI pipelines that previously ran `b update` and got silent auto-application of overwrites/conflicts will now fail unless one of:

  • add `safety: auto` per-env in `b.yaml`
  • pass `--safety=auto` on the command line
  • pass `--yes` / `-y` for one-off runs
  • pass `--dry-run` to preview without applying

This is the consent footgun #125 was opened to fix. The breaking change is intentional and the migration is mechanical.

Performance trade-off

The plan + apply flow only doubles `SyncEnv` calls when the safety mode might block (strict, prompt-with-confirm). For `auto` and `--yes`, the CLI runs `SyncEnv` exactly once and renders the plan from the apply result. Auto users pay zero overhead vs. the old behavior.

When the gate might reject (strict / interactive prompt), the CLI runs `SyncEnv` twice: once in dry-run to compute the plan, then once for the real apply if approved. The second pass benefits from a hot git cache. The eventual right design is to split `SyncEnv` into `Plan(cfg) → Plan` and `Apply(cfg, plan) → Result` so the plan-first path doesn't pay double — that's a follow-up refactor flagged as Phase 2.

What's NEW (files)

  • `pkg/env/plan.go` — `PlanAction` enum, `Plan` / `PlanRow` structs, `PlanFromResult` mapping from `lock.LockFile` statuses, `RenderPlanText` + `RenderPlanJSON` renderers, `IsDestructive` / `HasDestructive` helpers
  • `pkg/env/plan_test.go` — status-mapping matrix, destructive detection, deterministic ordering, text + JSON renderers
  • `pkg/state/types.go` — `Safety` field on `EnvEntry` + `envEntryRaw`, safety constants, `NormalizeSafety(s)`
  • `pkg/state/resolve.go` — profile composition propagates `Safety`
  • `pkg/cli/update.go` — `gateApply` / `confirmApply` helpers, plan-first vs auto-fast-path branching, `--safety` / `--plan-json` / `--yes` flags, plan rendering before apply, lock-write only on real apply
  • `pkg/cli/update_test.go` — 8 new tests pinning auto / strict / prompt / yes / plan-json / dry-run behaviors and `SyncEnv` call counts
  • `docs/env-sync.mdx` — new "Safety tiers" section + plan output example + performance note

What's UNCHANGED

  • Lock format
  • `SyncEnv` signature (still accepts `DryRun bool`)
  • Existing strategy semantics (`replace` / `client` / `merge`)
  • Per-file conflict resolution UI for replace strategy on TTY

Out of scope (deferred Phase 2–4 of #125)

  • `b.pin:` per-key annotations
  • skipped-delete tracking
  • in-place YAML conflict markers
  • `b env resolve` subcommand

These need design work + the structural-merge follow-up from #122. Tracking in #125 itself.

Test plan

  • `go test -race ./...` green
  • `go vet` clean
  • `gofmt -l` clean
  • Smoke: `b update --dry-run` against a real env, eyeball plan
  • Smoke: `b update --plan-json` produces parseable JSON
  • Smoke: `b update` on a TTY prompts and accepts/rejects
  • Smoke: `b update` on a CI runner refuses destructive plans without `--yes`

Independence

This PR is independent of #122 and #124. It branches from `main` (not from either of the other PR branches) and touches a disjoint set of files. They can land in any order.

🤖 Generated with Claude Code

Adds the user-consent and visibility layer that #125 calls out:

- Per-env safety field: strict | prompt (default) | auto
- Plan-style output for every sync: + add / ~ update / = keep
  / ! overwrite / ⊕ merge / ✗ conflict + summary line
- --safety CLI override
- --plan-json for machine-readable plan output (PR comment bots,
  CI summary jobs)
- --dry-run always emits a plan and never writes
- --yes / -y skips the prompt confirmation in interactive mode

Safety semantics:
  - auto: applies without prompting (legacy default behavior)
  - prompt (NEW DEFAULT): TTY → asks Apply? [y/N]; non-TTY → falls
    back to strict (refuses any destructive plan unless --yes)
  - strict: refuses any plan that contains overwrite or conflict
    rows, with or without TTY

A plan row is "destructive" when it would lose user-owned content
(overwrite or conflict).

BREAKING CHANGE for non-TTY consumers
=====================================
The default safety changed from "always apply" to "prompt → strict
on CI". CI pipelines that previously ran `b update` and got silent
auto-application of overwrites/conflicts will now need one of:

  - add `safety: auto` per-env in b.yaml
  - pass `--safety=auto` on the command line
  - pass `--yes` / `-y` for one-off runs
  - pass `--dry-run` to preview without applying

This is the consent footgun #125 was opened to fix. The breaking
change is intentional and the migration is mechanical (one flag
or one yaml line).

Performance
===========
The plan + apply flow only doubles SyncEnv calls when the safety
mode might block (strict, prompt-with-confirm). For SafetyAuto
and --yes, the CLI runs SyncEnv exactly once and renders the plan
from the apply result. Auto users pay zero overhead vs. the old
behavior. Documented in docs/env-sync.mdx.

What's NEW
==========
  - pkg/env/plan.go: PlanAction enum, Plan / PlanRow structs,
    PlanFromResult mapping from lock.LockFile statuses, RenderPlanText
    + RenderPlanJSON renderers, IsDestructive / HasDestructive helpers
  - pkg/env/plan_test.go: status mapping matrix, destructive
    detection, deterministic ordering, text + JSON renderers
  - pkg/state/types.go: Safety field on EnvEntry + envEntryRaw,
    Safety constants, NormalizeSafety(s)
  - pkg/state/resolve.go: profile composition propagates Safety
  - pkg/cli/update.go: gateApply / confirmApply helpers, plan-first
    vs auto-fast-path branching, --safety / --plan-json / --yes
    flags, plan rendering before apply, lock-write only on real apply
  - pkg/cli/update_test.go: 8 new tests pinning auto/strict/prompt/
    yes/plan-json/dry-run behaviors and SyncEnv call counts
  - docs/env-sync.mdx: new "Safety tiers" section + plan output
    example + performance note + plan-json mention in dry-run

What's UNCHANGED
================
  - Lock format
  - SyncEnv signature (still accepts DryRun bool)
  - Existing strategy semantics (replace / client / merge)
  - Per-file conflict resolution UI for replace strategy on TTY

Out of scope (deferred to phase 2-4 of #125):
  - b.pin: per-key annotations
  - skipped-delete tracking
  - in-place YAML conflict markers
  - b env resolve subcommand

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@fentas

fentas commented Apr 7, 2026

Copy link
Copy Markdown
Owner Author

Review by Claude (Opus 4.6, 1M context) — context: I tracked #122 and reviewed #126 + #127. I did not file #125 but I am downstream of it (lok8s → kubehz consumer chain) and will live with this default change.

Verdict: approve with notes

Phase 1 of #125 delivers exactly what the issue specified for the consent layer. The plan/apply split, the safety tier semantics, and the auto-vs-plan-first fast path are all well-thought-through. The breaking change is real but justified, and the migration is mechanical. Ship it — the notes below are improvements, not blockers.

What's right

1. The auto / --yes fast path is the make-or-break design choice. Calling `SyncEnv` exactly once for the auto path keeps existing automated runs at zero performance overhead. This was the obvious risk in moving to plan-first — the naive implementation would double every sync — and the PR avoided it cleanly. The branching in `updateEnvs` (`needsPlanFirst` flag) is the right place to make that decision.

2. The destructive-action definition is conservative on purpose. Right now only `overwrite` and `conflict` count. `update` doesn't, even though semantically a version bump is "data loss" of the old version. That's the correct call: the issue defines destructive as "would lose user-owned content," and an update of a value the user didn't author is not user-owned content. If the user pinned a value, they'll get the pin behavior in Phase 2 of #125. Don't expand the destructive set in Phase 1.

3. Plan-first attaches the resolver on the second pass. This is subtle and easy to get wrong. The interactive conflict resolver is a TTY interaction — running it during the dry-run plan computation would prompt the user before they've even seen the plan. The PR explicitly attaches `ResolveConflict` only on `applyCfg` (line ~390), with a comment explaining why. Good defensive coding; the test `TestUpdateEnvs_TTYConflictResolver` had to be updated with `Yes: true` precisely because the resolver moved to the apply pass.

4. `NormalizeSafety` collapses empty + unknown to `prompt`, not `auto`. The "fail safe, not fail open" choice. Someone typos `safety: stict` in their b.yaml and they get the safe default rather than silent auto-apply. Test `TestNormalizeSafety_DefaultsToPrompt` pins this. Good.

5. The plan rendering is renderer-agnostic. `PlanFromResult` builds a structured `Plan`, and `RenderPlanText` / `RenderPlanJSON` are independent renderers. This means someone can write `RenderPlanMarkdown` for PR comment bots later without touching the plan computation. Right separation of concerns.

6. Deterministic ordering by Dest in `PlanFromResult` means snapshot tests are stable across runs. The test `TestPlanFromResult_DeterministicOrdering` pins this. Will save someone an hour of "why does my golden file flake on CI" debugging.

7. Test coverage is on point. 8 new tests in `update_test.go` cover the matrix: auto/strict/prompt × destructive/non-destructive × TTY/non-TTY × --yes/--plan-json/--dry-run. Each test uses the `makeSyncFunc` recording stub to assert call counts (1 for auto, 2 for plan-first), which is the load-bearing performance contract — not just the output. Excellent test design.

Notes (non-blocking)

N1. The breaking change deserves a louder migration footnote in the PR description

The PR description has a "BREAKING CHANGE" section, which is good. But the migration path is buried in a list. Specifically: existing CI pipelines that ran `b update` and got silent auto-apply will now exit non-zero on any destructive change with no immediate context. The error message `prompt safety on non-TTY: plan contains destructive changes — re-run with --yes, --safety=auto, or --dry-run` is clear, but a CI maintainer reading "build broken: b update failed" in their inbox needs to know:

  1. What's a destructive change? (overwrite or conflict — currently undocumented in the error message itself)
  2. How do they preview without applying? (`--dry-run` is in the message but not the rationale)
  3. Which fix is right for their situation? (`--yes` for one-off, `--safety=auto` for permanent)

Suggested improvements:

  • Add a one-line CHANGELOG entry pointing at the docs section
  • Consider extending the error message to include the destructive action types: `prompt safety on non-TTY: plan contains 1 overwrite and 0 conflicts — see plan above. Re-run with --yes for one-off apply, set safety: auto in b.yaml for permanent, or --dry-run to preview.`
  • Optionally print the first destructive row's path in the error message so users know which file is the problem without scrolling up

N2. `gateApply`'s `isDryRun` parameter is doubled by `o.PlanJSON`

In `updateEnvs`:

```go
isDryRun := o.DryRun || o.PlanJSON
```

Then in `gateApply`:

```go
if isDryRun {
return false, nil
}
```

This is correct, but the relationship is implicit. A future maintainer adding a new dry-run-like flag has to remember to OR it into both `needsPlanFirst` and the `gateApply` call. Would be cleaner to pin this on the `UpdateOptions` itself with a method:

```go
func (o *UpdateOptions) effectiveDryRun() bool {
return o.DryRun || o.PlanJSON
}
```

Then both call sites use `o.effectiveDryRun()` and the relationship is documented in one place. Tiny refactor; not blocking.

N3. The "merge failed" Note extraction loses the parenthetical content

In `planRowFromLockFile`:

```go
case strings.HasPrefix(status, "replaced (merge failed"):
row.Action = PlanOverwrite
// Extract the parenthetical reason for the user.
row.Note = strings.TrimSuffix(strings.TrimPrefix(status, "replaced "), "")
```

`strings.TrimSuffix(s, "")` is a no-op (trimming the empty string from anywhere does nothing). I think the intent was to strip a trailing `)` to leave the bare reason, but this never executes. Result: the note ends up like `(merge failed: )` with the parens still in it, then gets re-wrapped in parens by `RenderPlanText` (`(%s)` format). User sees `((merge failed: ...))`.

Either:

  • (a) Strip a trailing `)`: `strings.TrimSuffix(strings.TrimPrefix(status, "replaced ("), ")")` — produces a bare reason
  • (b) Drop the manipulation entirely and just use `row.Note = status` — produces `(replaced (merge failed: ...))` which is at least informative

I'd go with (a). Add a test for it: `TestPlanFromResult_StatusMapping` has a "replaced" case but not a "replaced (merge failed: ...)" case.

N4. `PlanJSON` outputs one JSON object per env, not a JSON array

Looking at the `updateEnvs` loop:

```go
if o.PlanJSON {
if err := env.RenderPlanJSON(o.IO.Out, plan); err != nil { ... }
continue
}
```

This calls `RenderPlanJSON` once per env. Each call writes a complete JSON object plus newline. For a config with 3 envs, the output is 3 separate JSON documents concatenated, not a JSON array.

This is technically NDJSON / JSON Lines and parseable with the right tooling, but most JSON consumers (`jq .`, `json.Parse`) expect a single root document. PR comment bots and CI summary jobs that read `b update --plan-json` will likely break on the second env.

Two fix options:

  • (a) Wrap the whole loop output in a JSON array: collect all plans into a slice, render once at the end. This requires the loop to defer `continue`s until after the array is closed.
  • (b) Document that `--plan-json` produces NDJSON, not JSON. `jq -s .` slurps it back into an array. Cheaper but surprising.

The PR description says "machine-readable output (PR comment bots, CI summary jobs)" — those consumers usually expect a single document. I'd go with (a). Could file as a Phase 1.5 follow-up rather than blocking this PR.

If you keep NDJSON, at least add a test that verifies the output is parseable by `jq -s` or by streaming `json.Decoder`, and document the format in the docs section.

N5. The plan summary line includes counts that are always 0 in common cases

```go
fmt.Fprintf(w, " → %d add, %d update, %d keep, %d overwrite, %d merge, %d conflict\n", ...)
```

For a typical sync that just updated 3 files, the user sees:

```
→ 0 add, 3 update, 0 keep, 0 overwrite, 0 merge, 0 conflict
```

Lots of noise for one signal. Two ways to clean this up:

  • (a) Omit zero counts: `→ 3 update` (cleaner)
  • (b) Always show all counts but suppress when all are zero (your current behavior is roughly this without the all-zero check)

(a) is the standard UX choice (Terraform, kubectl, helm all do it). Tiny change to `RenderPlanText`. The renderer-agnostic structure means it's localized.

N6. The `prompt → strict on CI` semantic shift is actually `prompt → "refuse destructive on CI"`

Reading the PR description's table:

Mode Interactive (TTY) Non-interactive (CI)
`prompt` * Shows plan, asks `[y/N]` Refuses destructive (= strict)

This says "= strict" which is technically accurate but misleading. `strict` refuses any destructive plan with no recovery. `prompt` on CI also refuses destructive plans, but the user can recover by passing `--yes` or setting `safety: auto`. `strict` doesn't have that recovery — passing `--yes` to a strict env would still apply (the gate is on the safety value, not on `--yes`).

Wait, actually let me double-check that. Looking at `gateApply`:

```go
case state.SafetyStrict:
if destructive {
return false, fmt.Errorf("strict safety: ...")
}
return true, nil
```

`--yes` is not checked in the strict branch. So `strict + --yes` still refuses destructive plans. Good — that matches the issue description ("safest, non-interactive-friendly").

But the table in the PR description / docs doesn't make this distinction clear. A user reading "Non-interactive (CI): Refuses destructive (= strict)" might assume `prompt + --yes` and `strict + --yes` behave the same way (both refuse). Actually `prompt + --yes` applies (treats prompt as auto), while `strict + --yes` still refuses.

Suggest updating the docs table to make this explicit:

Mode Interactive (TTY) Non-interactive (CI) --yes effect
`strict` Refuses any destructive plan Refuses any destructive plan No effect
`prompt` * Shows plan, asks `[y/N]` Refuses destructive Acts like `auto`
`auto` Applies without prompting Applies without prompting (no-op)

This avoids the trap where someone tries `b update --yes` against a strict env and is confused why it fails.

Things I'd file as follow-ups (not blocking, in priority order)

  1. N4: --plan-json should be a JSON array, not NDJSON. Most painful for consumers.
  2. N3: `merge failed` note extraction is broken. Cosmetic but real.
  3. N5: Drop zero counts from the summary line. Pure UX polish.
  4. N1: Better breaking-change error message. First-time CI confusion mitigation.
  5. N6: Clarify --yes vs strict in the docs table. Documentation consistency.
  6. N2: Encapsulate `isDryRun` derivation. Tiny refactor; only matters if more dry-run-like flags are added.

How this composes with #126 and #127

This PR is independent of both, as the description says. But the interaction is interesting:

  • Once fix(env): symmetric filtering + splice for select + merge (#122) #126 (symmetric merge) lands, the `merge` action becomes the actually-correct default for `.bin/b.yaml` syncs. Plan rows for merged files will be common, and the plan output's `⊕ merge` glyph + summary count become the user-visible signal that "this sync had real merging happen, you might want to review."
  • Once feat(env): hybrid JMESPath selector for select: filters (#124) #127 (JMESPath) lands, `Source` paths in plan rows could end up as JMESPath expressions in the future (when select is set with a complex query and the merge happens at the filtered scope). The plan renderer treats Source as opaque text, so it'll work — but the docs might want to mention "for complex selectors, the source path may be the selector expression itself."
  • All three together unlock the lok8s → kubehz binaries-by-groups flow with reviewable consent. Upstream tags binaries, JMESPath filters them at sync time, symmetric merge updates the consumer's binaries section while preserving `envs:`, and the safety prompt makes sure the consumer reviews any overwrites or conflicts before they hit disk. That's the dream state.

TL;DR

Ship it. The 6 follow-up items are all polish or 1-3 line fixes. The architecture is sound, the test coverage hits the right contract points (especially the SyncEnv call counts), the breaking change is justified, and the auto fast-path keeps existing automation at zero overhead. Good Phase 1.

The two I'd most want to see fixed before merging if they're cheap:

  • N3 (merge-failed note extraction bug) — actively renders wrong output
  • N4 (--plan-json should be array) — actively breaks consumers

Everything else is polish that can land in a 128.1 follow-up.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a safety/consent layer and standardized “plan” output to b update/env syncing, including per-env safety configuration, CLI overrides, and machine-readable plan output.

Changes:

  • Introduces per-env safety (strict / prompt default / auto) and propagates it through state resolution.
  • Adds pkg/env plan model + renderers (text + JSON) and tests for status→action mapping.
  • Updates the CLI update flow to support plan-first gating, --safety, --yes/-y, --plan-json, and updated tests/docs.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
pkg/state/types.go Adds EnvEntry.Safety, safety constants, and NormalizeSafety; marshals default safety tersely.
pkg/state/resolve.go Ensures composed profiles propagate Safety.
pkg/env/plan.go Introduces plan/actions model and text/JSON renderers derived from SyncResult.
pkg/env/plan_test.go Adds tests for action destructiveness, status mapping, ordering, and rendering.
pkg/cli/update.go Implements plan rendering + safety gate, new flags, and plan-first vs apply-first branching.
pkg/cli/update_test.go Adds/adjusts tests for safety modes, plan-json/dry-run behavior, and SyncEnv call counts.
pkg/cli/env_test.go Updates tests to match new plan-based output and safety prompting behavior.
docs/env-sync.mdx Documents safety tiers, new flags, plan output, and performance trade-off.

Comment thread pkg/cli/update.go
Comment thread pkg/cli/update.go
Comment thread pkg/cli/update.go Outdated
Comment thread pkg/env/plan.go
Comment thread pkg/env/plan.go Outdated
Comment thread docs/env-sync.mdx Outdated
Comment thread docs/env-sync.mdx Outdated
Copilot review:
  1. (#3044962211) --safety values were silently coerced via
     state.NormalizeSafety, so a typo like --safety=autp fell back
     to prompt with no error. Fix: Validate() now hard-rejects
     unknown --safety values up front, matching the existing
     --strategy validation. NormalizeSafety still applies to
     config-loaded values for backward compatibility.

  2. (#3044962259) Safety gate refusals printed an error and
     `continue`d but updateEnvs returned nil, contradicting the
     "CI pipelines will fail" promise. Fix: track refused envs in
     a slice; after the loop, return an aggregated error
     ("safety refused N env(s): ..."). Lock for non-refused envs
     is still written so a single bad apple doesn't block the
     rest of the run. Tests SafetyStrict_RefusesDestructive and
     PromptDefault_NonTTY_BlocksDestructive updated to assert the
     non-nil return.

  3. (#3044962280, #3044962389) --plan-json emitted one JSON
     document per env, producing concatenated docs that aren't
     valid JSON for typical parsers. Fix: collect plans into a
     slice during the loop and emit a single JSON array at the
     end via new env.RenderPlansJSON helper. Docs updated to
     show the array format with a jsonc example.

  4. (#3044962301) PlanAdd was unreachable — SyncEnv reports new
     files as Status: "replaced" so the plan would always
     classify them as Update. Fix: PlanFromResult now takes a
     `prev *lock.EnvEntry` parameter and uses the previous
     lock's source-path set to distinguish "new file (Add)" from
     "existing file changed (Update)". New tests
     PlanFromResult_NewFileBecomesPlanAdd and
     MergeFailedNoteHasNoDoubleParens pin the contract.

  5. (#3044962320) "replaced (merge failed: ...)" notes were
     extracted with TrimSuffix(s, "") which is a no-op,
     leaving the parens in. The renderer wraps notes in parens
     itself, producing ((merge failed: ...)). Fix: strip leading
     ( and trailing ) from the extracted note. Test
     MergeFailedNoteHasNoDoubleParens covers it.

  6. (#3044962355) Docs claimed "Every b update produces a plan
     before applying" which is false for the safety:auto path
     (apply-first, plan rendered from result). Fix: docs now
     describe the plan-first vs apply-first split explicitly.

  7. (#3044962389) See #3 — same fix.

Reviewer notes (downstream Claude agent):
  - N5: drop zero counts from summary line. Fixed —
    "→ 1 add" instead of "→ 1 add, 0 update, 0 keep, 0 ...".
  - N6: docs table didn't make --yes vs strict distinction
    explicit. Fixed — added a fourth column showing --yes effect
    per mode and a paragraph explaining "no effect on strict".
  - N1, N2: deferred follow-ups (not blocking, polish only).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@fentas fentas requested a review from Copilot April 7, 2026 12:44
@fentas

fentas commented Apr 7, 2026

Copy link
Copy Markdown
Owner Author

Thanks for the deep review. Addressed N3, N4, N5, N6 in 5174db0:

  • N3 (merge-failed double parens) — covered by copilot fix fix: add argsh binary #5 (thread above). Note extraction now strips leading ( and trailing ). Test MergeFailedNoteHasNoDoubleParens pins it.
  • N4 (NDJSON not array) — covered by copilot fix feat: Make config optional #3. --plan-json now emits a single JSON array via new env.RenderPlansJSON. Docs show a jsonc example.
  • N5 (zero counts in summary) — fixed in RenderPlanText. "→ 1 add" instead of "→ 1 add, 0 update, 0 keep, 0 overwrite, 0 merge, 0 conflict". Empty plans render as "→ no changes".
  • N6 (--yes vs strict in docs table) — added a fourth column showing --yes effect per mode and a paragraph explaining "--yes has no effect on strict".

Deferred (filed mentally as polish follow-ups):

  • N1: even-better breaking-change error message (showing first destructive row's path inline)
  • N2: encapsulate isDryRun derivation in a method on UpdateOptions

The strict refusal also now produces a non-zero exit (copilot fix #2), which is the load-bearing piece for CI noticing the breaking change at all.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Comment thread pkg/cli/update.go
Comment thread pkg/state/types.go Outdated
Comment thread pkg/state/types.go
Comment thread pkg/cli/update_test.go
  1. (#3045074081) --plan-json output was getting corrupted by the
     "(up to date)" human line printed for skipped envs and the
     "No binaries or envs to update" line in runAll. Both now
     suppress the human-readable output when o.PlanJSON is set:
     skipped envs are silently dropped from the JSON array, and
     empty configs emit `[]` instead of the prose line.

  2. (#3045074142) SafetyStrict's doc comment said it refuses
     "delete" actions but the destructive set in Phase 1 is just
     overwrite + conflict (no delete action exists yet). Comment
     updated to be precise about Phase 1 scope and to flag delete
     as a future addition when skipped-delete tracking lands.

  3. (#3045074173) NormalizeSafety did a strict case-sensitive
     switch, so "Auto" / " auto " / "STRICT" silently fell back
     to prompt — surprising for users editing b.yaml. Now trims
     whitespace and lowercases before comparison. Validate() does
     the same for --safety so the CLI flag accepts the same forms.
     New test cases for "Auto", "AUTO", " auto ", "\tStrict\n",
     "Prompt" added to TestNormalizeSafety_DefaultsToPrompt.

  4. (#3045074206) makeSyncFunc test helper shallow-copied
     SyncResult so the underlying Files backing array was shared
     between calls — a test that mutated clone.Files[i] would
     leak into subsequent invocations. Now deep-copies Files via
     append([]lock.LockFile(nil), result.Files...).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Comment thread pkg/cli/update.go Outdated
Comment thread pkg/cli/update_test.go Outdated
Comment thread docs/env-sync.mdx Outdated
Comment thread pkg/state/types.go Outdated
  1. (#3045148116) Skipped envs disappeared from --plan-json output,
     so consumers couldn't tell "no envs configured" (`[]`) apart
     from "all envs up-to-date" (also `[]`). Fix: skipped envs
     now produce a plan object in the array with empty Rows plus
     ref/label/version/commit metadata. Plain dry-run still
     prints the cheap "(up to date)" line for backward compat.
     New test TestUpdateEnvs_PlanJSON_IncludesSkippedEnvs.

  2. (#3045148163) Test contract comment said --plan-json "emits
     one JSON document per env" but the implementation now emits
     a single array. Comment updated.

  3. (#3045148193) Docs claimed --dry-run "always prints the
     plan" but the skipped path prints "(up to date)" instead of
     a plan table. Wording clarified to describe both paths
     accurately, plus the plan-json behavior of emitting empty
     plans for up-to-date envs.

  4. (#3045148213) MarshalYAML's "omit safety when default"
     check compared the raw value against the literal "prompt"
     constant, so non-canonical inputs like "Prompt" or " auto "
     would round-trip into b.yaml as the non-canonical form
     instead of being normalized away. Fix: compare against
     NormalizeSafety(e.Safety) and write the normalized value
     when emitting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Comment thread pkg/env/plan.go
Comment thread pkg/env/plan.go Outdated
…und 4)

  1. (#3045189400) Plan.Rows could be nil (e.g. for the skipped-env
     plans introduced in round 3), which encoded as `"rows": null`
     instead of `"rows": []`. The plan-json contract advertised in
     docs/env-sync.mdx promises `[]` for up-to-date envs, and
     consumers that index into the array would break on `null`.
     Fix: implement MarshalJSON on Plan that initializes a nil
     Rows slice to an empty one before encoding. New test
     TestPlan_MarshalJSON_NilRowsEncodesAsEmptyArray pins it.

  2. (#3045189485) RenderPlanText's format-comment block had its
     `+` glyph for PlanAdd eaten by godoc list-bullet rendering,
     so future maintainers reading the comment would see `- add`
     instead of `+ add`. Fix: wrap each marker glyph in single
     quotes ('+', '~', '=', '!', '⊕', '✗') so godoc renders them
     literally and they match the output of planMarker(). Also
     simplified the summary-line example to show non-zero counts
     only, matching the round-2 RenderPlanText change that drops
     zero counts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Comment thread pkg/cli/update.go
Copilot round 5 caught: when SyncEnv returned an error for an env,
the code printed it to stderr and continued, but updateEnvs returned
nil unless a safety refusal also happened. CI runs would succeed
with exit code 0 even though one or more env syncs failed silently.

Fix: track per-env failures in `failedEnvs` (mirror of the
`refusedEnvs` slice from round 1) at every error site:

  - rollback with no previous commit
  - first-pass SyncEnv error
  - second-pass real-apply SyncEnv error

After the loop, return an aggregated error like
"N env(s) failed: ..." or, when refusals AND failures both
happened, both attributed in one message.

The `--plan-json` path doesn't have a sync error site (it just
appends to a slice), so plan-json continues to work the same.

Test contract update: TestUpdateEnvs_SyncError previously asserted
"updateEnvs should not return error on per-env failure". That's
the old behavior. The new contract is the opposite — failure
must produce a non-zero exit. Test updated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Comment thread pkg/cli/update.go
Comment thread pkg/cli/update.go Outdated
Comment thread pkg/cli/update.go
Comment thread pkg/env/plan.go Outdated
 round 6)

  1. (#3045301278) runAll() updates binaries first via
     callUpdateBinaries(), which writes progress output to stdout.
     In --plan-json mode that corrupts the JSON document. Fix:
     skip binary updates in plan-json mode entirely. Plan-json
     is documented as env-only machine output anyway.

  2. (#3045301328) The plan-json branch returned nil immediately
     after rendering the JSON array, so b update --plan-json
     would exit 0 even when some env plans couldn't be generated
     (clone errors, etc.). Fix: extracted the failure-aggregation
     logic into a new helper aggregateEnvErrors and call it from
     both the lock-write path AND the plan-json path. CI can now
     detect incomplete plan generation via exit code while still
     getting the partial JSON for any envs that did succeed.

  3. (#3045301369) Comment in the firstResult.Skipped branch said
     "(header + 'no changes' summary)" was printed in dry-run /
     plan-text mode, but the implementation only prints the cheap
     "(up to date)" line. Comment updated to match.

  4. (#3045301387) plan.go's "replaced" status comment said
     "the file was new or already in sync (identical on disk)",
     but SyncEnv reports identical tracked files as "unchanged"
     (which hits the PlanKeep branch above), not "replaced".
     Comment updated to be precise: "replaced" only fires when
     upstream content was actually written.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Comment thread pkg/cli/update.go
Comment thread pkg/state/types.go Outdated
…arshalYAML (#128 round 7)

  1. (#3045354515) --plan-json was suppressing binary updates in
     runAll() but NOT in runSpecified(). \`b update --plan-json
     <binary>\` (or mixed binary+env args) would still call
     callUpdateBinaries, writing progress output to stdout and
     corrupting the JSON. Fix: gate the same way in runSpecified
     and emit a stderr warning when binaries are explicitly listed
     so the user knows the binary args were ignored.

  2. (#3045354569) MarshalYAML routed the safety value through
     NormalizeSafety, which folds unknown values to "prompt" for
     runtime safety. That meant a future b version with a new
     safety mode in b.yaml would have the safety field silently
     erased the next time an older b rewrote the file (because
     "FutureMode" → NormalizeSafety → "prompt" → omitted as
     default). Forward-compat broken.

     Fix: distinguish "known" from "unknown" by direct comparison
     to the canonical constants, NOT through NormalizeSafety.
     Three serialization branches:

       - rawSafety == "prompt": omit (canonical default)
       - rawSafety == "auto"/"strict": emit normalized
       - rawSafety unknown: emit verbatim (lowercased+trimmed)

     Runtime semantics unchanged: NormalizeSafety still folds
     unknown to prompt for the safety gate, so the runtime is
     still safe. Only the serialization round-trip is affected.

     New table-driven test
     TestEnvConfigMarshal_PreservesUnknownSafety with 7 cases
     covering empty, canonical default, case+whitespace
     normalization for known values, and verbatim preservation
     of two unknown values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Comment thread pkg/cli/update.go Outdated
Comment thread pkg/state/types.go Outdated
…on includes (#128 round 8)

  1. (#3045425086) --dry-run mode returned nil unconditionally,
     dropping any accumulated failedEnvs. CI couldn't tell that
     planning had partially failed (e.g. clone error on one env).
     Fix: dry-run path now also returns aggregateEnvErrors so the
     same exit-code-non-zero contract applies as the apply path.
     The lock is still NOT written. New test
     TestUpdateEnvs_DryRun_PropagatesFailures pins the contract.

  2. (#3045425149) MarshalYAML always omitted "safety: prompt" as
     the default. That made it impossible to override an inherited
     non-default safety with explicit prompt: an entry with
     `includes: [core]` and explicit `safety: prompt` would have
     the prompt dropped on SaveConfig and the included profile's
     safety would win on next load.

     Fix: when the entry uses `includes:`, an explicit prompt
     value is treated as an override and emitted, not omitted.
     Without includes, prompt is still omitted as the canonical
     default. New test
     TestEnvConfigMarshal_PromptOverrideWithIncludes pins both
     halves.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Comment thread pkg/cli/update.go
Comment thread pkg/state/types_test.go
fentas added a commit that referenced this pull request Apr 7, 2026
…ts (#127 round 7)

  1. (#3045442977) isSimpleDotPath treated selectors starting with
     `@` as simple because `@` wasn't in the JMESPath grammar
     blocklist. But leading `@` is the JMESPath current-node
     operator (\`@\`, \`@.field\`, \`@[?...]\`), so those expressions
     would be misclassified as simple and routed to the legacy
     dot-path validator, returning empty/wrong output.

     Fix: explicit early-return on \`s[0] == '@'\` after the
     leading-dot trim. \`@\` mid-key is still allowed (e.g.
     \`a@b.com\` is a valid plain key) — only the leading
     position is reserved for the current-node operator.

     New test cases in TestIsSimpleDotPath: \`@\`, \`@.foo\`,
     \`.@.foo\` all classify as complex.

  2. (#3045443041, #3045443090) Code and test comments referenced
     internal review history ("per copilot review on PR #127
     round X"). Reviewer asked for timeless wording — those
     references age poorly and the technical reason is what
     future maintainers actually need.

     Sweep: removed "copilot review on PR #127" / "round X"
     references from pkg/env/select.go, pkg/env/select_jmespath.go,
     and pkg/env/select_jmespath_test.go (the three files
     copilot flagged). Each comment was rewritten to describe
     the technical rationale directly. Git history still has
     the full context for archaeologists.

     Other branches (#126, #128) still have similar references
     that will get the same treatment when those branches see
     similar reviewer feedback. Leaving them alone here so this
     PR stays scoped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…st (#128 round 9)

  1. (#3045512163) The plan-first flow had a plan-vs-reality skew:
     the dry-run pass that produced the plan ran without
     ResolveConflict (so files with local changes showed as
     "overwrite" in the plan), but the apply pass attached the
     legacy interactive resolver, which would actually prompt the
     user to choose keep/merge/diff per file. Strict could refuse
     based on a plan that wouldn't have happened in practice, and
     prompt would prompt twice (once for the whole plan, once
     per file).

     Fix: drop ResolveConflict from the plan-first apply pass
     entirely. The safety gate IS the user consent now. The
     legacy per-file resolver is still attached for the auto /
     --yes path (where there's no plan gate), so users who want
     per-file granularity can opt into auto.

     Updated docs/env-sync.mdx to explain the split: legacy
     per-file resolver only fires under safety: auto on TTY;
     strict / prompt use the plan gate instead.

  2. (#3045512250) New round-8 tests called yaml.Marshal with
     `data, _ := ...` instead of checking the error. Other tests
     in this file check marshal errors for diagnosability. Added
     `if err != nil { t.Fatalf(...) }` to both marshal calls in
     TestEnvConfigMarshal_PromptOverrideWithIncludes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Comment thread pkg/state/types.go Outdated
Comment thread pkg/cli/update.go Outdated
Comment thread pkg/env/plan.go Outdated
Comment on lines +61 to +62
// consumers that index into the array would break on `null`. Per
// copilot review on PR #128 round 4.

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MarshalJSON doc comment includes a reference to a specific "Copilot review" PR/round. Consider removing that PR/round attribution and keeping only the contract rationale (encode nil Rows as [] to keep JSON schema stable for consumers).

Suggested change
// consumers that index into the array would break on `null`. Per
// copilot review on PR #128 round 4.
// consumers that index into the array would break on `null`.

Copilot uses AI. Check for mistakes.
#128 round 10)

Reviewer asked for the same sweep that landed on PR #127: code and
test comments shouldn't reference internal review history (PR/round
numbers) because those references age poorly. The technical reason
should stand on its own; git history captures the review context.

Affected files (all on the #128 branch):
  - pkg/cli/update.go
  - pkg/cli/update_test.go
  - pkg/state/types.go
  - pkg/state/types_test.go
  - pkg/env/plan.go
  - pkg/env/plan_test.go

Each "Per copilot review on PR #128 round X" annotation was rewritten
to a brief technical rationale (e.g. "Both refusals and failures must
turn into a non-zero exit so CI notices"). No behavioral changes.

Note: a regex sweep accidentally turned three legitimate Go variadic
\`...\` calls into \`..\` (in EnvList.Remove, makeUpdateOpts opts
parameter, makeSyncFunc deep-copy, and the cobra Use string).
Restored those after running the test suite caught the syntax errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Comment thread pkg/cli/update.go
Comment on lines +267 to +271
// Tracks any per-env safety gate refusals so we can return a
// non-zero exit at the end.: silent
// refusal contradicts the documented "CI pipelines will fail"
// promise. Per-env apply work continues for non-refused envs so a
// single bad apple doesn't block the rest of the run.

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Several comments in this block have obvious typos/punctuation artifacts (e.g. ": silent"), which makes the rationale harder to read. Please clean up the wording so the comment is a complete sentence without stray punctuation.

Copilot uses AI. Check for mistakes.
Comment thread pkg/cli/update.go Outdated
Comment thread pkg/env/plan.go Outdated
fentas added a commit that referenced this pull request Apr 7, 2026
…und 10)

Same sweep that landed on #127 and #128: code and test comments
shouldn't reference internal review history (PR/round numbers)
because those references age poorly. The technical reason should
stand on its own; git history captures the review context.

Affected files (all on the #126 branch):
  - pkg/env/env.go
  - pkg/env/splice.go
  - pkg/env/splice_test.go
  - pkg/env/sync_select_merge_test.go

31 occurrences of "Per copilot review on PR #126 round X" rewritten
to brief technical rationale. No behavioral changes.

Note: an earlier regex sweep accidentally collapsed multi-space
indentation in embedded YAML test strings. Reverted via git checkout
and used a comment-line-aware Python sweeper plus manual fixups for
the remaining cases. Verified YAML test strings still have 2-space
indentation and the test suite passes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fentas and others added 2 commits April 7, 2026 17:21
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@fentas fentas merged commit 3e02aaf into main Apr 7, 2026
6 of 7 checks passed
@fentas fentas deleted the feat/env-safety-plan branch April 7, 2026 15:22
fentas pushed a commit that referenced this pull request Apr 7, 2026
🤖 I have created a release *beep* *boop*
---


## [4.13.0](v4.12.0...v4.13.0)
(2026-04-07)


### Features

* **env:** hybrid JMESPath selector for select: filters
([#124](#124))
([#127](#127))
([aaf184e](aaf184e))
* **env:** safety tiers + plan output for env update
([#125](#125) phase 1)
([#128](#128))
([3e02aaf](3e02aaf))


### Bug Fixes

* **env:** symmetric filtering + splice for select + merge
([#122](#122))
([#126](#126))
([3617709](3617709))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
fentas added a commit that referenced this pull request Apr 7, 2026
…Run helper

Two follow-ups from PR #128 reviewer notes (N1, N2):

  N1. Better breaking-change error messages for safety refusals.
      The previous "plan contains destructive changes" message
      was opaque — CI maintainers reading "build broken: b update
      failed" would have to scroll back through the printed plan
      to find which file is the problem.

      Fix: new helper destructiveRefusalError that builds a
      message with three parts: which gate refused, a count
      breakdown (e.g. "2 overwrite, 1 conflict"), and the first
      destructive row's path so users have a concrete file to
      look at without scrolling.

      Example:
        strict safety: plan contains 2 overwrite, 1 conflict
          (first: hetzner/legacy.yaml) — use --safety=prompt or
          --safety=auto to apply, or --dry-run to preview

      New env.Plan.DestructiveRows() helper returns the rows in
      source order so callers can pin the first one.

  N2. Encapsulate the isDryRun derivation. Previously
      pkg/cli/update.go had `isDryRun := o.DryRun || o.PlanJSON`
      duplicated as an inline expression, and a future
      dry-run-like flag would need to be added in multiple
      places. New o.effectiveDryRun() method centralizes the
      decision so the rest of the loop stays agnostic.

Tests: TestUpdateEnvs_SafetyStrict_RefusesDestructive now also
asserts the new "1 overwrite" count and "first: a.yaml" path
appear in stderr.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fentas added a commit that referenced this pull request Apr 7, 2026
…Run helper (#132)

Two small follow-ups from PR #128 reviewer notes (N1, N2).

## N1. Better safety-refusal error messages

**Before:** \`strict safety: plan contains destructive changes — use
--safety=prompt or --safety=auto to apply\`

A CI maintainer reading \"build broken: b update failed\" had to scroll
back through the printed plan to find which file caused the refusal.

**After:** \`strict safety: plan contains 2 overwrite, 1 conflict
(first: hetzner/legacy.yaml) — use --safety=prompt or --safety=auto to
apply, or --dry-run to preview\`

The new \`destructiveRefusalError\` helper builds a message with three
parts:
1. Which gate refused (so users know what to flip).
2. A breakdown of WHAT is destructive: count by action type, plus the
first row's path so users have a concrete file to look at without
scrolling.
3. The recovery hint with all three escape hatches (\`--yes\`,
\`--safety=auto\`, \`--dry-run\`).

New \`env.Plan.DestructiveRows()\` helper returns the rows in
`Plan.Rows` order, which `PlanFromResult` sorts by destination path, so
the first row is a stable, dest-sorted choice.

## N2. Encapsulate \`isDryRun\` derivation

\`pkg/cli/update.go\` had \`isDryRun := o.DryRun || o.PlanJSON\`
duplicated inline. A future dry-run-like flag would need to be added in
multiple places. New \`o.effectiveDryRun()\` method centralizes the
decision.

## Tests

\`TestUpdateEnvs_SafetyStrict_RefusesDestructive\` now also asserts the
new \`1 overwrite\` count and \`first: a.yaml\` path appear in stderr.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants