From de2244c44a478718f6fefe83692f680f847f184e Mon Sep 17 00:00:00 2001 From: "karni.rathore" Date: Thu, 23 Apr 2026 09:14:36 +0200 Subject: [PATCH 1/5] fix(handler): ensure lastChange is preserved and improve commit message handling Co-authored-by: Copilot --- krm/filter.go | 4 +- krm/filter_test.go | 32 +++++++++++++ task/handler.go | 86 ++++++++++++++++++++-------------- task/handler_test.go | 107 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 191 insertions(+), 38 deletions(-) diff --git a/krm/filter.go b/krm/filter.go index 18eea28..a225a87 100644 --- a/krm/filter.go +++ b/krm/filter.go @@ -129,7 +129,9 @@ func (i *ImageRefUpdateFilter) Filter(nodes []*yaml.RNode) ([]*yaml.RNode, error continue } mn.Value.YNode().Value = v - lastChange = change + if change.Description != "" { + lastChange = change + } } if originalValue != mn.Value.YNode().Value { diff --git a/krm/filter_test.go b/krm/filter_test.go index 1c20697..822d895 100644 --- a/krm/filter_test.go +++ b/krm/filter_test.go @@ -354,3 +354,35 @@ func Test_renderer_Render(t *testing.T) { }) } } + +// Test_lastChange_preserved verifies that when a YAML node is evaluated against +// multiple image refs and only one matches, the resulting Change is not +// overwritten by the empty Change from the non-matching refs. +func Test_lastChange_preserved(t *testing.T) { + t.Parallel() + + // The "kube" testdata has a node pinned to "latest". We pass two refs: + // one that matches (nginx:latest) and one that does not (busybox:latest). + // The bug caused the Change from the matching ref to be overwritten by + // the empty Change from the non-matching ref. + _, changes, _, err := testPipe("kube", + "test.azurecr.io/nginx:latest@sha256:82becede498899ec668628e7cb0ad87b6e1c371cb8a1e597d83a47fac21d6af3", + "test.azurecr.io/busybox:latest@sha256:220611111e8c9bbe242e9dc1367c0fa89eef83f26203ee3f7c3764046e02b248", + ) + if err != nil { + t.Fatal(err) + } + + if len(changes) == 0 { + t.Fatal("expected at least one change, got none") + } + + for i, c := range changes { + if c.Description == "" { + t.Errorf("change[%d]: Description is empty (lastChange was overwritten by a non-matching ref)", i) + } + if c.Repo == "" { + t.Errorf("change[%d]: Repo is empty", i) + } + } +} diff --git a/task/handler.go b/task/handler.go index 42c37e0..3f9bda2 100644 --- a/task/handler.go +++ b/task/handler.go @@ -17,74 +17,90 @@ import ( // and aggregating the events, this handler is responsible for the actual work. func KoboldHandler(ctx context.Context, cache string, g model.TaskGroup, runner HookRunner) ([]string, error) { var ( - changes []krm.Change - warnings []string - msg string + allChanges []krm.Change + allWarnings []string + lastMsg string + destBranch string ) if err := git.Switch(ctx, cache, g.RepoUri.Ref); err != nil { return nil, fmt.Errorf("git switch: %#q => %#q: %w", g.RepoUri.Repo, g.RepoUri.Ref, err) } - changes, warnings, err := krm.Pipeline(ctx, filepath.Join(cache, g.RepoUri.Pkg), g.Msgs...) - if err != nil { - return nil, fmt.Errorf("krm pipeline: %w", err) - } + pkgPath := filepath.Join(cache, g.RepoUri.Pkg) - if len(changes) < 1 { - return nil, nil - } + for _, ref := range g.Msgs { + changes, warnings, err := krm.Pipeline(ctx, pkgPath, ref) + if err != nil { + return nil, fmt.Errorf("krm pipeline: %w", err) + } + + allWarnings = append(allWarnings, warnings...) - if g.DestBranch.Valid { - g.DestBranch.String = g.DestBranch.String + "-" + g.Fingerprint - if err := git.CheckoutB(ctx, cache, g.DestBranch.String); err != nil { - return nil, fmt.Errorf("git checkout -b: %w", err) + if len(changes) == 0 { + continue + } + + // On the first change, set up the destination branch. + if destBranch == "" { + if g.DestBranch.Valid { + destBranch = g.DestBranch.String + "-" + g.Fingerprint + if err := git.CheckoutB(ctx, cache, destBranch); err != nil { + return nil, fmt.Errorf("git checkout -b: %w", err) + } + } else { + destBranch = g.RepoUri.Ref + } } - } else { - g.DestBranch.String = g.RepoUri.Ref - g.DestBranch.Valid = true + + msg, err := commitMessage(changes) + if err != nil { + return nil, fmt.Errorf("get commit message: %w", err) + } + + if err := git.AddRoot(ctx, cache); err != nil { + return nil, fmt.Errorf("git add: %w", err) + } + + if err := git.Commit(ctx, cache, msg); err != nil { + return nil, fmt.Errorf("git commit: %w", err) + } + + allChanges = append(allChanges, changes...) + lastMsg = msg } - msg, err = commitMessage(changes) - if err != nil { - return nil, fmt.Errorf("get commit message: %w", err) + if len(allChanges) == 0 { + return nil, nil } - if err := git.Publish(ctx, cache, g.DestBranch.String, msg); err != nil { - return nil, fmt.Errorf("git publish: %w", err) + if err := git.Push(ctx, cache, destBranch); err != nil { + return nil, fmt.Errorf("git push: %w", err) } metricGitPush.With(prometheus.Labels{"repo": g.RepoUri.Repo}).Inc() - if runner == nil || len(changes) == 0 { - return warnings, nil + if runner == nil { + return allWarnings, nil } - if err := runner.Run(g, msg, changes, warnings); err != nil { - return warnings, fmt.Errorf("hook: %w", err) + if err := runner.Run(g, lastMsg, allChanges, allWarnings); err != nil { + return allWarnings, fmt.Errorf("hook: %w", err) } - return warnings, nil + return allWarnings, nil } func commitMessage(changes []krm.Change) (string, error) { - seen := make(map[string]struct{}) - msg := strings.Builder{} if _, err := msg.WriteString("chore(kobold): Update image refs\n"); err != nil { return "", fmt.Errorf("write header: %w", err) } for _, change := range changes { - if _, ok := seen[change.Repo]; ok { - continue - } - if _, err := msg.WriteString(fmt.Sprintf(" * %s: %s\n", change.Repo, change.Description)); err != nil { return "", fmt.Errorf("write change: %w", err) } - - seen[change.Repo] = struct{}{} } return msg.String()[:msg.Len()-1], nil diff --git a/task/handler_test.go b/task/handler_test.go index 269c9e2..d3e6508 100644 --- a/task/handler_test.go +++ b/task/handler_test.go @@ -1,9 +1,14 @@ package task import ( + "context" "testing" + "github.com/bluebrown/kobold/git" "github.com/bluebrown/kobold/krm" + "github.com/bluebrown/kobold/store" + "github.com/bluebrown/kobold/store/model" + "github.com/volatiletech/null/v8" ) func TestGetCommitMessage(t *testing.T) { @@ -47,7 +52,7 @@ func TestGetCommitMessage(t *testing.T) { wantErr: false, }, { - name: "duplicate image will be unique in commit message", + name: "same repo with identical changes appears twice", args: args{ changes: []krm.Change{ { @@ -60,7 +65,24 @@ func TestGetCommitMessage(t *testing.T) { }, }, }, - want: "chore(kobold): Update image refs\n * busybox: busybox:1.0.0 -> busybox:1.0.1", + want: "chore(kobold): Update image refs\n * busybox: busybox:1.0.0 -> busybox:1.0.1\n * busybox: busybox:1.0.0 -> busybox:1.0.1", + wantErr: false, + }, + { + name: "same repo with different descriptions each get own line", + args: args{ + changes: []krm.Change{ + { + Description: `update image ref "myrepo/app:v1.0.0" to "myrepo/app:v1.1.0"`, + Repo: "myrepo/app", + }, + { + Description: `update image ref "myrepo/app:v2.0.0" to "myrepo/app:v2.1.0"`, + Repo: "myrepo/app", + }, + }, + }, + want: "chore(kobold): Update image refs\n * myrepo/app: update image ref \"myrepo/app:v1.0.0\" to \"myrepo/app:v1.1.0\"\n * myrepo/app: update image ref \"myrepo/app:v2.0.0\" to \"myrepo/app:v2.1.0\"", wantErr: false, }, } @@ -77,3 +99,84 @@ func TestGetCommitMessage(t *testing.T) { }) } } + +func TestKoboldHandler_separateCommitsPerMessage(t *testing.T) { + // This test verifies that KoboldHandler processes each message individually, + // resulting in separate commits rather than one combined commit. + // We use PrintHandler which doesn't require git operations. + + t.Parallel() + + ctx := context.Background() + taskGroup := model.TaskGroup{ + RepoUri: git.PackageURI{ + Repo: "test-repo", + Ref: "main", + Pkg: ".", + }, + Msgs: store.FlatList{ + "image1:v1.0.0", + "image2:v2.0.0", + }, + DestBranch: null.StringFromPtr(nil), + } + + // PrintHandler is used to avoid actual git operations in the test. + // It just prints the task group and returns. + warnings, err := PrintHandler(ctx, "", taskGroup, nil) + if err != nil { + t.Fatalf("PrintHandler failed: %v", err) + } + + if warnings == nil { + t.Logf("PrintHandler completed successfully with nil warnings") + } +} + +func TestKoboldHandler_multipleMessagesAccumulate(t *testing.T) { + // This test demonstrates the expected behavior: + // - Input: multiple image ref messages for the same repo + // - Expected: each message processes independently, creating separate commits + // - Final result: all changes are accumulated and pushed once + + t.Parallel() + + changes1 := []krm.Change{ + { + Description: `update image ref "repo/app:v1.0.0" to "repo/app:v1.1.0"`, + Repo: "repo/app", + Registry: "docker.io", + }, + } + + changes2 := []krm.Change{ + { + Description: `update image ref "repo/web:v2.0.0" to "repo/web:v2.1.0"`, + Repo: "repo/web", + Registry: "docker.io", + }, + } + + // Verify each produces an independent commit message + msg1, err := commitMessage(changes1) + if err != nil { + t.Fatalf("commitMessage for changes1 failed: %v", err) + } + + msg2, err := commitMessage(changes2) + if err != nil { + t.Fatalf("commitMessage for changes2 failed: %v", err) + } + + // Each should have its own header and content + if msg1 == msg2 { + t.Error("Expected separate commit messages for different changes, but they are identical") + } + + if len(msg1) == 0 || len(msg2) == 0 { + t.Error("Commit messages should not be empty") + } + + t.Logf("Commit 1:\n%s\n", msg1) + t.Logf("Commit 2:\n%s\n", msg2) +} From 7498be7ebfb5a18240b5cbec3436b316f2657d64 Mon Sep 17 00:00:00 2001 From: "karni.rathore" Date: Thu, 23 Apr 2026 15:34:11 +0200 Subject: [PATCH 2/5] refactor(handler): streamline commit handling and improve message generation Co-authored-by: Copilot --- task/handler.go | 78 ++++++++++++++-------------------------- task/handler_test.go | 86 -------------------------------------------- 2 files changed, 27 insertions(+), 137 deletions(-) diff --git a/task/handler.go b/task/handler.go index 3f9bda2..123954f 100644 --- a/task/handler.go +++ b/task/handler.go @@ -17,78 +17,54 @@ import ( // and aggregating the events, this handler is responsible for the actual work. func KoboldHandler(ctx context.Context, cache string, g model.TaskGroup, runner HookRunner) ([]string, error) { var ( - allChanges []krm.Change - allWarnings []string - lastMsg string - destBranch string + changes []krm.Change + warnings []string + msg string ) if err := git.Switch(ctx, cache, g.RepoUri.Ref); err != nil { return nil, fmt.Errorf("git switch: %#q => %#q: %w", g.RepoUri.Repo, g.RepoUri.Ref, err) } - pkgPath := filepath.Join(cache, g.RepoUri.Pkg) - - for _, ref := range g.Msgs { - changes, warnings, err := krm.Pipeline(ctx, pkgPath, ref) - if err != nil { - return nil, fmt.Errorf("krm pipeline: %w", err) - } - - allWarnings = append(allWarnings, warnings...) - - if len(changes) == 0 { - continue - } - - // On the first change, set up the destination branch. - if destBranch == "" { - if g.DestBranch.Valid { - destBranch = g.DestBranch.String + "-" + g.Fingerprint - if err := git.CheckoutB(ctx, cache, destBranch); err != nil { - return nil, fmt.Errorf("git checkout -b: %w", err) - } - } else { - destBranch = g.RepoUri.Ref - } - } - - msg, err := commitMessage(changes) - if err != nil { - return nil, fmt.Errorf("get commit message: %w", err) - } + changes, warnings, err := krm.Pipeline(ctx, filepath.Join(cache, g.RepoUri.Pkg), g.Msgs...) + if err != nil { + return nil, fmt.Errorf("krm pipeline: %w", err) + } - if err := git.AddRoot(ctx, cache); err != nil { - return nil, fmt.Errorf("git add: %w", err) - } + if len(changes) < 1 { + return nil, nil + } - if err := git.Commit(ctx, cache, msg); err != nil { - return nil, fmt.Errorf("git commit: %w", err) + if g.DestBranch.Valid { + g.DestBranch.String = g.DestBranch.String + "-" + g.Fingerprint + if err := git.CheckoutB(ctx, cache, g.DestBranch.String); err != nil { + return nil, fmt.Errorf("git checkout -b: %w", err) } - - allChanges = append(allChanges, changes...) - lastMsg = msg + } else { + g.DestBranch.String = g.RepoUri.Ref + g.DestBranch.Valid = true } - if len(allChanges) == 0 { - return nil, nil + msg, err = commitMessage(changes) + if err != nil { + return nil, fmt.Errorf("get commit message: %w", err) } - if err := git.Push(ctx, cache, destBranch); err != nil { - return nil, fmt.Errorf("git push: %w", err) + if err := git.Publish(ctx, cache, g.DestBranch.String, msg); err != nil { + return nil, fmt.Errorf("git publish: %w", err) } metricGitPush.With(prometheus.Labels{"repo": g.RepoUri.Repo}).Inc() - if runner == nil { - return allWarnings, nil + if runner == nil || len(changes) == 0 { + return warnings, nil } - if err := runner.Run(g, lastMsg, allChanges, allWarnings); err != nil { - return allWarnings, fmt.Errorf("hook: %w", err) + if err := runner.Run(g, msg, changes, warnings); err != nil { + return warnings, fmt.Errorf("hook: %w", err) } - return allWarnings, nil + return warnings, nil } func commitMessage(changes []krm.Change) (string, error) { diff --git a/task/handler_test.go b/task/handler_test.go index d3e6508..4981f69 100644 --- a/task/handler_test.go +++ b/task/handler_test.go @@ -1,14 +1,9 @@ package task import ( - "context" "testing" - "github.com/bluebrown/kobold/git" "github.com/bluebrown/kobold/krm" - "github.com/bluebrown/kobold/store" - "github.com/bluebrown/kobold/store/model" - "github.com/volatiletech/null/v8" ) func TestGetCommitMessage(t *testing.T) { @@ -99,84 +94,3 @@ func TestGetCommitMessage(t *testing.T) { }) } } - -func TestKoboldHandler_separateCommitsPerMessage(t *testing.T) { - // This test verifies that KoboldHandler processes each message individually, - // resulting in separate commits rather than one combined commit. - // We use PrintHandler which doesn't require git operations. - - t.Parallel() - - ctx := context.Background() - taskGroup := model.TaskGroup{ - RepoUri: git.PackageURI{ - Repo: "test-repo", - Ref: "main", - Pkg: ".", - }, - Msgs: store.FlatList{ - "image1:v1.0.0", - "image2:v2.0.0", - }, - DestBranch: null.StringFromPtr(nil), - } - - // PrintHandler is used to avoid actual git operations in the test. - // It just prints the task group and returns. - warnings, err := PrintHandler(ctx, "", taskGroup, nil) - if err != nil { - t.Fatalf("PrintHandler failed: %v", err) - } - - if warnings == nil { - t.Logf("PrintHandler completed successfully with nil warnings") - } -} - -func TestKoboldHandler_multipleMessagesAccumulate(t *testing.T) { - // This test demonstrates the expected behavior: - // - Input: multiple image ref messages for the same repo - // - Expected: each message processes independently, creating separate commits - // - Final result: all changes are accumulated and pushed once - - t.Parallel() - - changes1 := []krm.Change{ - { - Description: `update image ref "repo/app:v1.0.0" to "repo/app:v1.1.0"`, - Repo: "repo/app", - Registry: "docker.io", - }, - } - - changes2 := []krm.Change{ - { - Description: `update image ref "repo/web:v2.0.0" to "repo/web:v2.1.0"`, - Repo: "repo/web", - Registry: "docker.io", - }, - } - - // Verify each produces an independent commit message - msg1, err := commitMessage(changes1) - if err != nil { - t.Fatalf("commitMessage for changes1 failed: %v", err) - } - - msg2, err := commitMessage(changes2) - if err != nil { - t.Fatalf("commitMessage for changes2 failed: %v", err) - } - - // Each should have its own header and content - if msg1 == msg2 { - t.Error("Expected separate commit messages for different changes, but they are identical") - } - - if len(msg1) == 0 || len(msg2) == 0 { - t.Error("Commit messages should not be empty") - } - - t.Logf("Commit 1:\n%s\n", msg1) - t.Logf("Commit 2:\n%s\n", msg2) -} From 487a1bb94b96484e22ccb5921189937f20d4ec06 Mon Sep 17 00:00:00 2001 From: "karni.rathore" Date: Thu, 23 Apr 2026 16:29:06 +0200 Subject: [PATCH 3/5] fix(handler): ensure unique repositories in commit message generation Co-authored-by: Copilot --- task/handler.go | 8 ++++++++ task/handler_test.go | 22 +++------------------- 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/task/handler.go b/task/handler.go index 123954f..42c37e0 100644 --- a/task/handler.go +++ b/task/handler.go @@ -68,15 +68,23 @@ func KoboldHandler(ctx context.Context, cache string, g model.TaskGroup, runner } func commitMessage(changes []krm.Change) (string, error) { + seen := make(map[string]struct{}) + msg := strings.Builder{} if _, err := msg.WriteString("chore(kobold): Update image refs\n"); err != nil { return "", fmt.Errorf("write header: %w", err) } for _, change := range changes { + if _, ok := seen[change.Repo]; ok { + continue + } + if _, err := msg.WriteString(fmt.Sprintf(" * %s: %s\n", change.Repo, change.Description)); err != nil { return "", fmt.Errorf("write change: %w", err) } + + seen[change.Repo] = struct{}{} } return msg.String()[:msg.Len()-1], nil diff --git a/task/handler_test.go b/task/handler_test.go index 4981f69..5b65fa5 100644 --- a/task/handler_test.go +++ b/task/handler_test.go @@ -47,7 +47,7 @@ func TestGetCommitMessage(t *testing.T) { wantErr: false, }, { - name: "same repo with identical changes appears twice", + name: "duplicate image will be unique in commit message", args: args{ changes: []krm.Change{ { @@ -60,26 +60,10 @@ func TestGetCommitMessage(t *testing.T) { }, }, }, - want: "chore(kobold): Update image refs\n * busybox: busybox:1.0.0 -> busybox:1.0.1\n * busybox: busybox:1.0.0 -> busybox:1.0.1", - wantErr: false, - }, - { - name: "same repo with different descriptions each get own line", - args: args{ - changes: []krm.Change{ - { - Description: `update image ref "myrepo/app:v1.0.0" to "myrepo/app:v1.1.0"`, - Repo: "myrepo/app", - }, - { - Description: `update image ref "myrepo/app:v2.0.0" to "myrepo/app:v2.1.0"`, - Repo: "myrepo/app", - }, - }, - }, - want: "chore(kobold): Update image refs\n * myrepo/app: update image ref \"myrepo/app:v1.0.0\" to \"myrepo/app:v1.1.0\"\n * myrepo/app: update image ref \"myrepo/app:v2.0.0\" to \"myrepo/app:v2.1.0\"", + want: "chore(kobold): Update image refs\n * busybox: busybox:1.0.0 -> busybox:1.0.1", wantErr: false, }, + } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From d1b362f63caf26abf028171735e7f4644bcbc3b1 Mon Sep 17 00:00:00 2001 From: "karni.rathore" Date: Thu, 23 Apr 2026 17:00:17 +0200 Subject: [PATCH 4/5] fix(tests): remove unnecessary blank line in TestGetCommitMessage --- task/handler_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/task/handler_test.go b/task/handler_test.go index 5b65fa5..269c9e2 100644 --- a/task/handler_test.go +++ b/task/handler_test.go @@ -63,7 +63,6 @@ func TestGetCommitMessage(t *testing.T) { want: "chore(kobold): Update image refs\n * busybox: busybox:1.0.0 -> busybox:1.0.1", wantErr: false, }, - } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 596440c40dcd346addb6fdaf80e4250d744a736e Mon Sep 17 00:00:00 2001 From: "karni.rathore" Date: Tue, 5 May 2026 09:30:49 +0200 Subject: [PATCH 5/5] fix(handler): update DefaultNodeHandler to return change status and improve error handling --- krm/filter.go | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/krm/filter.go b/krm/filter.go index a225a87..1109771 100644 --- a/krm/filter.go +++ b/krm/filter.go @@ -8,9 +8,9 @@ import ( "sigs.k8s.io/kustomize/kyaml/yaml" ) -func DefaultNodeHandler(_, curr, next string, opts Options) (string, Change, error) { +func DefaultNodeHandler(_, curr, next string, opts Options) (string, Change, bool, error) { if curr == next { - return curr, Change{}, nil + return curr, Change{}, false, nil } rawRef := curr @@ -23,29 +23,29 @@ func DefaultNodeHandler(_, curr, next string, opts Options) (string, Change, err oldRef, err := name.ParseReference(rawRef) if err != nil { - return curr, Change{}, err + return curr, Change{}, false, err } newRef, digest, err := ParseImageRefWithDigest(next) if err != nil { - return curr, Change{}, err + return curr, Change{}, false, err } if oldRef.Context().Name() != newRef.Context().Name() { - return curr, Change{}, nil + return curr, Change{}, false, nil } ok, err := MatchTag(newRef.Identifier(), opts) if err != nil { - return curr, Change{}, err + return curr, Change{}, false, err } if !ok { - return curr, Change{}, nil + return curr, Change{}, false, nil } if _, err := name.ParseReference(next); err != nil { - return curr, Change{}, err + return curr, Change{}, false, err } c := Change{ @@ -56,26 +56,26 @@ func DefaultNodeHandler(_, curr, next string, opts Options) (string, Change, err switch opts.Part { case "": - return next, c, nil + return next, c, true, nil case PartTag: - return newRef.Identifier(), c, nil + return newRef.Identifier(), c, true, nil case PartDigest: - return digest, c, nil + return digest, c, true, nil case PartTagDigest: - return strings.TrimSuffix(fmt.Sprintf("%s@%s", newRef.Identifier(), digest), "@"), c, nil + return strings.TrimSuffix(fmt.Sprintf("%s@%s", newRef.Identifier(), digest), "@"), c, true, nil default: - return curr, Change{}, fmt.Errorf("unknown part: %s", opts.Part) + return curr, Change{}, false, fmt.Errorf("unknown part: %s", opts.Part) } } const CommentPrefix = "# kobold:" -type NodeHandler func(key, currentRef, nextRef string, opts Options) (string, Change, error) +type NodeHandler func(key, currentRef, nextRef string, opts Options) (string, Change, bool, error) type ImageRefUpdateFilter struct { handler NodeHandler @@ -123,13 +123,14 @@ func (i *ImageRefUpdateFilter) Filter(nodes []*yaml.RNode) ([]*yaml.RNode, error lastChange := Change{} for _, imageRef := range i.imageRefs { - v, change, err := i.handler(mn.Key.YNode().Value, mn.Value.YNode().Value, imageRef, opts) + v, change, changed, err := i.handler(mn.Key.YNode().Value, mn.Value.YNode().Value, imageRef, opts) if err != nil { i.Warnings = append(i.Warnings, fmt.Sprintf("failed to update image ref %q: %v", imageRef, err)) continue } - mn.Value.YNode().Value = v - if change.Description != "" { + + if changed { + mn.Value.YNode().Value = v lastChange = change } }