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+
224344func cleanObject (u * unstructured.Unstructured ) map [string ]interface {} {
225345 obj := u .DeepCopy ().Object
226346
0 commit comments