feat(env): safety tiers + plan output for env update (#125 phase 1)#128
Conversation
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>
Verdict: approve with notesPhase 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 right1. 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 descriptionThe 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:
Suggested improvements:
N2. `gateApply`'s `isDryRun` parameter is doubled by `o.PlanJSON`In `updateEnvs`: ```go Then in `gateApply`: ```go 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 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 contentIn `planRowFromLockFile`: ```go `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:
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 arrayLooking at the `updateEnvs` loop: ```go 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:
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 For a typical sync that just updated 3 files, the user sees: ``` Lots of noise for one signal. Two ways to clean this up:
(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:
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 `--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:
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)
How this composes with #126 and #127This PR is independent of both, as the description says. But the interaction is interesting:
TL;DRShip 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:
Everything else is polish that can land in a 128.1 follow-up. |
There was a problem hiding this comment.
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/promptdefault /auto) and propagates it through state resolution. - Adds
pkg/envplan 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. |
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>
|
Thanks for the deep review. Addressed N3, N4, N5, N6 in 5174db0:
Deferred (filed mentally as polish follow-ups):
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. |
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>
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>
…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 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>
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>
…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>
…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>
…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>
| // consumers that index into the array would break on `null`. Per | ||
| // copilot review on PR #128 round 4. |
There was a problem hiding this comment.
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).
| // 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`. |
#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>
| // 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. |
There was a problem hiding this comment.
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.
…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>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
🤖 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).
…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>
…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>
Phase 1 of #125. Adds the user-consent and visibility layer.
What's new
Safety semantics
* 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:
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)
What's UNCHANGED
Out of scope (deferred Phase 2–4 of #125)
These need design work + the structural-merge follow-up from #122. Tracking in #125 itself.
Test plan
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