Skip to content

Commit a37ffca

Browse files
committed
improve diff
1 parent 9486c38 commit a37ffca

2 files changed

Lines changed: 151 additions & 7 deletions

File tree

cmd/diff.go

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
var (
1414
diffTarget string
1515
diffSnapshotDir string
16+
diffIgnoreBase string // New flag
1617
diffRefresh bool
1718
diffFormat string
1819
diffNamespace string
@@ -75,6 +76,16 @@ func runDiff() error {
7576
return fmt.Errorf("failed to load target files: %w", err)
7677
}
7778

79+
// Load base resources if provided
80+
var baseResources []*diff.Resource
81+
if diffIgnoreBase != "" {
82+
baseResources, err = differ.LoadLocalResource(diffIgnoreBase)
83+
if err != nil {
84+
// Log warning but don't fail? Or fail? Fail is safer to ensure validation logic holds.
85+
return fmt.Errorf("failed to load base file: %w", err)
86+
}
87+
}
88+
7889
for _, localResource := range localResources {
7990
// 4. Get comparison resource (Snapshot)
8091
// We always compare against snapshot (which might have been just refreshed)
@@ -84,8 +95,21 @@ func runDiff() error {
8495
return fmt.Errorf("failed to load snapshot: %w", err)
8596
}
8697

87-
// 5. Perform Diff
88-
result, err := differ.Diff(localResource, remoteResource)
98+
// Find matching base resource if available
99+
var baseResource *diff.Resource
100+
if len(baseResources) > 0 {
101+
for _, b := range baseResources {
102+
// Simple GVK + Name Match
103+
if b.Object.GetKind() == localResource.Object.GetKind() &&
104+
b.Object.GetName() == localResource.Object.GetName() {
105+
baseResource = b
106+
break
107+
}
108+
}
109+
}
110+
111+
// 5. Perform Diff (3-way)
112+
result, err := differ.Diff(localResource, remoteResource, baseResource)
89113
if err != nil {
90114
return fmt.Errorf("diff failed: %w", err)
91115
}

pkg/diff/differ.go

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"path/filepath"
88
"time"
99

10+
"strings"
11+
1012
"github.com/google/go-cmp/cmp"
1113
"gopkg.in/yaml.v3"
1214
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -188,8 +190,8 @@ type DiffResult struct {
188190
HasDrift bool
189191
}
190192

191-
// Diff compares two resources
192-
func (d *Differ) Diff(local, remote *Resource) (*DiffResult, error) {
193+
// Diff compares two resources with optional base for 3-way merge
194+
func (d *Differ) Diff(local, remote, base *Resource) (*DiffResult, error) {
193195
if !remote.Exists {
194196
return &DiffResult{IsNew: true}, nil
195197
}
@@ -198,14 +200,25 @@ func (d *Differ) Diff(local, remote *Resource) (*DiffResult, error) {
198200
l := cleanObject(local.Object)
199201
r := cleanObject(remote.Object)
200202

203+
// Smart Drift Detection: Exclude differences that match PR changes
204+
// If base is provided, we mask changes that are present in (Base -> Local)
205+
if base != nil && base.Exists {
206+
b := cleanObject(base.Object)
207+
208+
// 1. Find what changed in the PR (Base vs Local)
209+
prChanges := findChangedPaths(b, l, "")
210+
211+
// 2. Mask these changes in the Remote object
212+
// Effectively saying: "Pretend Remote has the new PR value for these fields"
213+
// This way cmp.Diff won't report them as drift.
214+
applyOverrides(r, l, prChanges)
215+
}
216+
201217
// Compare
202218
diff := cmp.Diff(r, l) // Remote is "base", Local is "new" intention
203219

204220
hasDrift := diff != ""
205221

206-
// Check for critical drift if requested?
207-
// For now just basic diff
208-
209222
isStale := false
210223
if remote.Source == "snapshot" {
211224
if time.Since(remote.Timestamp) > 24*time.Hour {
@@ -221,6 +234,113 @@ func (d *Differ) Diff(local, remote *Resource) (*DiffResult, error) {
221234
}, nil
222235
}
223236

237+
// findChangedPaths recursively finds keys that differ between a and b
238+
func findChangedPaths(a, b interface{}, prefix string) []string {
239+
var paths []string
240+
241+
if a == nil && b == nil {
242+
return nil
243+
}
244+
245+
// If one is nil and other isn't, it changed
246+
if a == nil || b == nil {
247+
return []string{prefix}
248+
}
249+
250+
// Type mismatch = changed
251+
if fmt.Sprintf("%T", a) != fmt.Sprintf("%T", b) {
252+
return []string{prefix}
253+
}
254+
255+
switch valA := a.(type) {
256+
case map[string]interface{}:
257+
valB := b.(map[string]interface{})
258+
// Check all keys in A
259+
for k, v := range valA {
260+
p := k
261+
if prefix != "" {
262+
p = prefix + "." + k
263+
}
264+
265+
if vB, ok := valB[k]; ok {
266+
// Key exists in both, recurse
267+
paths = append(paths, findChangedPaths(v, vB, p)...)
268+
} else {
269+
// Key removed in B = changed
270+
paths = append(paths, p)
271+
}
272+
}
273+
// Check keys in B that are not in A (Added)
274+
for k := range valB {
275+
if _, ok := valA[k]; !ok {
276+
p := k
277+
if prefix != "" {
278+
p = prefix + "." + k
279+
}
280+
paths = append(paths, p)
281+
}
282+
}
283+
284+
case []interface{}:
285+
// For slices, deep comparison is complex.
286+
// If length differs or any element differs, verify the whole slice as changed?
287+
// Or try to index? K8s uses patch merge keys...
288+
// For simplicity/robustness: if slice differs at all, mark the whole slice path.
289+
if !cmp.Equal(a, b) {
290+
return []string{prefix}
291+
}
292+
293+
default:
294+
if !cmp.Equal(a, b) {
295+
return []string{prefix}
296+
}
297+
}
298+
299+
return paths
300+
}
301+
302+
// applyOverrides copies values from source to target for the given paths
303+
// Simplified implementation: re-traverses and updates.
304+
// Note: This relies on dot notation which is imperfect for keys with dots,
305+
// but sufficient for standard K8s fields.
306+
func applyOverrides(target, source map[string]interface{}, paths []string) {
307+
for _, path := range paths {
308+
// Split path
309+
parts := strings.Split(path, ".")
310+
311+
var t, s interface{} = target, source
312+
var parentT map[string]interface{}
313+
var lastKey string
314+
315+
valid := true
316+
for _, part := range parts {
317+
tMap, tOk := t.(map[string]interface{})
318+
sMap, sOk := s.(map[string]interface{})
319+
320+
if !tOk || !sOk {
321+
valid = false
322+
break
323+
}
324+
325+
parentT = tMap
326+
lastKey = part
327+
328+
t = tMap[part]
329+
s = sMap[part]
330+
}
331+
332+
if valid && parentT != nil {
333+
// Copy source value (PR value) to target (Remote)
334+
// effectively masking the diff
335+
if s == nil {
336+
delete(parentT, lastKey)
337+
} else {
338+
parentT[lastKey] = s
339+
}
340+
}
341+
}
342+
}
343+
224344
func cleanObject(u *unstructured.Unstructured) map[string]interface{} {
225345
obj := u.DeepCopy().Object
226346

0 commit comments

Comments
 (0)