feat(env): structural 3-way merge for YAML/JSON#134
Merged
Conversation
Replace the text-only `git merge-file` path with a structural, map-walking merge for YAML and JSON destinations. The text path remains as a fallback for unsupported formats and for cases the structural merge cannot resolve. The motivating case is "both sides added adjacent map entries": when local and upstream both insert new keys into the same parent map, `git merge-file` sees overlapping line ranges and produces a spurious conflict, even though the changes are semantically independent. The structural merge walks the union of keys per map and treats each as an independent decision. Conflict resolution rules per key: - equal on all sides → keep - one side unchanged from base → take the other - both changed identically → keep (no conflict) - both changed and both maps → recurse - both changed otherwise → leaf conflict - delete + unchanged → drop; delete + modify → conflict - add by one side only (not in base) → take the addition Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Introduces a YAML/JSON-aware structural 3-way merge to reduce spurious conflicts during b env sync merges (especially adjacent map-entry insertions) by merging parsed trees instead of line-based git merge-file.
Changes:
- Added
Merge3WayStructuralto parse and merge YAML/JSON maps key-by-key. - Added unit tests covering adjacent inserts, leaf conflicts, deletes, and JSON behavior.
- Wired structural merge into
doMergeahead of the existing text-based merge fallback.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| pkg/env/merge_structural.go | Implements structural YAML/JSON 3-way merge + normalization/serialization helpers. |
| pkg/env/merge_structural_test.go | Adds focused tests for the new merge behavior. |
| pkg/env/env.go | Calls Merge3WayStructural from doMerge before falling back to git merge-file. |
- doMerge now tries the text merge FIRST and only falls back to the
structural merge when text reports conflicts. Clean merges keep
byte-for-byte git merge-file output, preserving local key order
and comments. The structural path still resolves the
adjacent-add case the reviewer flagged as the regression motivator
— that's exactly when text merge fails, so the structural merge
takes over.
- detectStructuralFormat no longer sniffs bytes; the destination
filename extension is authoritative. Sniffing risked routing a
YAML file with a flow-style root through the JSON path.
- serializeStructural returns nil for an empty map so an
intentionally blank file doesn't get re-emitted as `{}`.
- mergeMaps doc tightened to acknowledge that the alphabetical
ordering is squashed by the encoder anyway and that the
structural path's "messy diff" trade-off only kicks in when
text merge would otherwise leave conflict markers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- serializeStructural now propagates yaml.Encoder.Close() errors
instead of dropping them. yaml.v3 surfaces buffered write
failures on Close, so a swallowed error could mean a truncated
doc on disk.
- Extract the doMerge text→structural fallback into
mergeWithStructuralFallback so the wiring is directly testable
without spinning up a real git repo.
- New tests:
- CleanTextMergePreservesBytes proves the clean-text path keeps
comments verbatim (the structural merge would drop them).
- AdjacentInsertsResolveStructurally proves the structural
fallback kicks in for the headline regression case.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Distinguish "every input was empty bytes" from "the merged result
is an explicit empty map". The previous shortcut returned nil for
any empty map, which silently dropped a real \`{}\` document. New
serializeStructuralAlways takes an anyContent flag and emits a
real document whenever at least one input had real bytes.
- mergeValues no longer recurses into a synthetic empty base map
when both sides converted a scalar to a map. Treat that as a
genuine type-change leaf conflict so the user resolves it
explicitly instead of silently merging unrelated maps.
- Doc comment now says the merged bytes are not byte-for-byte
identical to local — the structural merge round-trips through
yaml.v3 / encoding/json so callers shouldn't expect comments,
key order, or whitespace to survive. The doMerge wiring still
runs the text merge first so clean merges keep local intact.
- New TestStructural_ExplicitEmptyJSONRoundTrips covers the
empty-doc round-trip case.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Lint flagged the wrapper as unused. Inline the anyContent flag into the single function so there's only one entry point. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…lict (#134 round 4) - mergeValues now also recurses when base is nil and both local and upstream added a map at the same path. That's a "both added concurrently" case, not a type change, and previously fell through to a leaf conflict. The non-nil non-map base + map local + map upstream case (genuine scalar→map type change) still stays a leaf conflict. - TestMergeWithStructuralFallback_AdjacentInsertsResolveStructurally now sanity-checks that gitcache.Merge3Way actually flags the input as conflicted before exercising the structural path. If a future git merge-file resolves this case cleanly the test now skips instead of silently passing without exercising the fallback. - New TestStructural_BothAddSameMapKey covers the base-absent recursive add case directly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fentas
pushed a commit
that referenced
this pull request
Apr 8, 2026
🤖 I have created a release *beep* *boop* --- ## [4.14.0](v4.13.0...v4.14.0) (2026-04-08) ### Features * **cli:** b env resolve for merge conflicts ([#125](#125) phase 4) ([#138](#138)) ([b3d00c8](b3d00c8)) * **cli:** plan-json polish — destructive row in error + effectiveDryRun helper ([#132](#132)) ([4ad5dfa](4ad5dfa)) * **cli:** surface conflict markers in env status ([#139](#139)) ([1e54c73](1e54c73)) * **env:** format-preserving byte-level YAML splice fast path ([#133](#133)) ([94cd711](94cd711)) * **env:** JSON splice support for scoped selects ([#135](#135)) ([89ccf9d](89ccf9d)) * **env:** orphan-file delete plumbing ([#125](#125) phase 3) ([#137](#137)) ([5891781](5891781)) * **env:** per-key b.pin annotation ([#125](#125) phase 2) ([#136](#136)) ([9ed59db](9ed59db)) * **env:** structural 3-way merge for YAML/JSON ([#134](#134)) ([fd787b0](fd787b0)) * **env:** wrapKeyFor extracts leading identifier for filter expressions ([#131](#131)) ([f542f28](f542f28)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Merge3WayStructuralthat walks YAML/JSON maps key-by-key instead of diffing linesdoMergebehind the existinggit merge-filefallbackWhy
git merge-fileis line-based, so two independent additions on neighbouring lines (e.g. local addshelm:, upstream addskustomize:to the samebinaries:map) collide on overlapping hunks and mark the whole region conflicted. The structural merge treats each map entry as its own decision, so independent adds just merge.Conflict rules
Unsupported formats (.md, plain text) and any unresolved conflict fall back to the existing text path, so consumers still see familiar conflict markers when they need them.
Test plan
go test ./pkg/env/... -run Structuralgo test ./...golangci-lint run ./pkg/env/...🤖 Generated with Claude Code