From 78a2cabc546195df534f7361f3d59e5a8651bdbb Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Fri, 24 Jul 2026 23:50:25 +0300 Subject: [PATCH 1/3] feat: add interactive update conflict resolution --- go.mod | 1 + go.sum | 2 + internal/cli/cli.go | 65 ++++++++++++++- internal/cli/cli_test.go | 96 ++++++++++++++++++++++ internal/ownership/plan_regression_test.go | 36 ++++++++ internal/ownership/types.go | 19 +++-- internal/ownership/update.go | 13 ++- 7 files changed, 220 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index a36eee0..52709f7 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +4,6 @@ go 1.21 require ( golang.org/x/sys v0.17.0 + golang.org/x/term v0.17.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 68706d9..ab07185 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index a7fe8ed..4014396 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -2,11 +2,13 @@ package cli import ( + "bufio" "encoding/json" "errors" "flag" "fmt" "io" + "os" "path/filepath" "sort" "strings" @@ -17,6 +19,7 @@ import ( "github.com/dapi/memory-bank-cli/internal/ownership" "github.com/dapi/memory-bank-cli/internal/push" "github.com/dapi/memory-bank-cli/internal/repository" + "golang.org/x/term" ) const ( @@ -49,9 +52,9 @@ func Run(arguments []string, version string, stdout, stderr io.Writer) int { case "lint": return runLint(arguments[1:], "memory-bank-cli lint", version, stdout, stderr) case "init": - return runOwnership(arguments[1:], "init", stdout, stderr) + return runOwnership(arguments[1:], "init", os.Stdin, term.IsTerminal(int(os.Stdin.Fd())), stdout, stderr) case "update": - return runOwnership(arguments[1:], "update", stdout, stderr) + return runOwnership(arguments[1:], "update", os.Stdin, term.IsTerminal(int(os.Stdin.Fd())), stdout, stderr) case "doctor": return runDoctor(arguments[1:], stdout, stderr) case "github": @@ -194,7 +197,7 @@ func runGitHubAdapter(arguments []string, stdout, stderr io.Writer) int { return exitSuccess } -func runOwnership(arguments []string, command string, stdout, stderr io.Writer) int { +func runOwnership(arguments []string, command string, stdin io.Reader, stdinIsTerminal bool, stdout, stderr io.Writer) int { flags := flag.NewFlagSet("memory-bank-cli "+command, flag.ContinueOnError) flags.SetOutput(stderr) flags.Usage = func() { @@ -206,6 +209,7 @@ func runOwnership(arguments []string, command string, stdout, stderr io.Writer) templateVersion := flags.String("template-version", "", "human-readable template version") sourceRef := flags.String("source-ref", "", "full commit SHA matching the source checkout HEAD") dryRun := flags.Bool("dry-run", false, "print the complete mutation plan without applying it") + ask := flags.Bool("ask", false, "interactively resolve user-owned managed-file collisions") agentFile := flags.String("agent-file", "AGENTS.md", "single repository-relative agent instruction file to manage") jsonOutput := addJSONOutputFlag(flags) if err := flags.Parse(arguments); err != nil { @@ -218,6 +222,14 @@ func runOwnership(arguments []string, command string, stdout, stderr io.Writer) fmt.Fprintf(stderr, "memory-bank-cli %s: unexpected arguments: %v\n", command, flags.Args()) return exitUsage } + if *ask && command != "update" { + fmt.Fprintln(stderr, "memory-bank-cli init: --ask is only supported by update") + return exitUsage + } + if *ask && !stdinIsTerminal { + fmt.Fprintln(stderr, "memory-bank-cli update: --ask requires an interactive terminal; rerun without --ask in CI or piped execution") + return exitFailure + } explicitSource := *sourceRootArgument != "" || *templateVersion != "" || *sourceRef != "" if explicitSource && (*sourceRootArgument == "" || *templateVersion == "" || *sourceRef == "") { fmt.Fprintf(stderr, "memory-bank-cli %s: --source, --template-version, and --source-ref are required\n", command) @@ -240,6 +252,21 @@ func runOwnership(arguments []string, command string, stdout, stderr io.Writer) AgentFile: *agentFile, } var report ownership.Report + if *ask { + planOptions := options + planOptions.DryRun = true + plan, planErr := ownership.Update(planOptions) + if planErr != nil { + fmt.Fprintln(stderr, planErr) + return exitFailure + } + overwrites, askErr := askUserOwnedCollisions(stdin, stderr, plan) + if askErr != nil { + fmt.Fprintln(stderr, askErr) + return exitFailure + } + options.UserOwnedResolutions = overwrites + } if command == "init" { report, err = ownership.Init(options) } else { @@ -268,6 +295,38 @@ func runOwnership(arguments []string, command string, stdout, stderr io.Writer) return exitSuccess } +func askUserOwnedCollisions(stdin io.Reader, writer io.Writer, report ownership.Report) (map[string]bool, error) { + overwrites := make(map[string]bool) + scanner := bufio.NewScanner(stdin) + for _, decision := range report.Decisions { + if !decision.CanOverwrite { + continue + } + for { + fmt.Fprintf(writer, "User-owned collision: %s\nPlanned safe action: keep local file (%s).\nChoose [k]eep local or [o]verwrite from source: ", decision.Path, decision.Reason) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read --ask response: %w", err) + } + return nil, errors.New("read --ask response: input ended before all collisions were resolved; no files changed") + } + switch strings.ToLower(strings.TrimSpace(scanner.Text())) { + case "k", "keep": + overwrites[decision.Path] = false + break + case "o", "overwrite": + overwrites[decision.Path] = true + break + default: + fmt.Fprintln(writer, "Please enter k/keep or o/overwrite.") + continue + } + break + } + } + return overwrites, nil +} + func printOwnershipReport(writer io.Writer, report ownership.Report) { decisions := append([]ownership.Decision(nil), report.Decisions...) sort.Slice(decisions, func(i, j int) bool { return decisions[i].Path < decisions[j].Path }) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 4ec5812..facefd2 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -721,3 +721,99 @@ func TestOwnershipDryRunJSONReportsNewManagedPathCollision(t *testing.T) { } t.Fatalf("collision decision missing from report: %#v", report.Decisions) } + +func TestUpdateAskResolvesCollisionsWithoutPartialChanges(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + seed := filepath.Join(source, "template", "memory-bank", "dna", "seed.md") + if err := os.MkdirAll(filepath.Dir(seed), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(seed, []byte("seed\n"), 0o644); err != nil { + t.Fatal(err) + } + initialRef := commitCLISource(t, source, "initial source") + initArgs := []string{"init", "--repo-root", repo, "--source", source, "--template-version", "v1", "--source-ref", initialRef} + var stdout, stderr bytes.Buffer + if exitCode := Run(initArgs, "test", &stdout, &stderr); exitCode != 0 { + t.Fatalf("init exit=%d stderr=%s", exitCode, stderr.String()) + } + first := filepath.Join("memory-bank", "dna", "first.md") + second := filepath.Join("memory-bank", "dna", "second.md") + for _, collision := range []string{first, second} { + if err := os.WriteFile(filepath.Join(repo, collision), []byte("local "+collision+"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "template", collision), []byte("source "+collision+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + updatedRef := commitCLISource(t, source, "add collisions") + updateArgs := []string{"--repo-root", repo, "--source", source, "--template-version", "v2", "--source-ref", updatedRef, "--ask"} + stdout.Reset() + stderr.Reset() + if exitCode := runOwnership(updateArgs, "update", strings.NewReader("bad\no\nk\n"), true, &stdout, &stderr); exitCode != 0 { + t.Fatalf("ask update exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "User-owned collision: "+filepath.ToSlash(first)) || !strings.Contains(stderr.String(), "Please enter k/keep or o/overwrite.") { + t.Fatalf("ask prompt omitted collision context or invalid-input guidance: %s", stderr.String()) + } + if got, want := string(mustReadFile(t, filepath.Join(repo, first))), "source "+first+"\n"; got != want { + t.Fatalf("overwrite payload=%q want=%q", got, want) + } + if got, want := string(mustReadFile(t, filepath.Join(repo, second))), "local "+second+"\n"; got != want { + t.Fatalf("keep payload=%q want=%q", got, want) + } + lock, exists, err := ownership.ReadLock(repo) + if err != nil || !exists || lock.Files[filepath.ToSlash(first)].Ownership != ownership.Managed || lock.Files[filepath.ToSlash(second)].Ownership != ownership.UserOwned { + t.Fatalf("ask resolutions not recorded: lock=%#v exists=%v err=%v", lock.Files, exists, err) + } + + lockBefore := mustReadFile(t, filepath.Join(repo, "memory-bank", ".lock")) + stdout.Reset() + stderr.Reset() + if exitCode := runOwnership(append(append([]string{}, updateArgs...), "--dry-run"), "update", strings.NewReader("o\n"), true, &stdout, &stderr); exitCode != 0 { + t.Fatalf("ask dry-run exit=%d stderr=%s", exitCode, stderr.String()) + } + if got := string(mustReadFile(t, filepath.Join(repo, second))); got != "local "+second+"\n" { + t.Fatalf("ask dry-run changed payload: %q", got) + } + if got := mustReadFile(t, filepath.Join(repo, "memory-bank", ".lock")); !bytes.Equal(got, lockBefore) { + t.Fatal("ask dry-run changed lock") + } + + third := filepath.Join("memory-bank", "dna", "third.md") + if err := os.WriteFile(filepath.Join(repo, third), []byte("local third\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "template", third), []byte("source third\n"), 0o644); err != nil { + t.Fatal(err) + } + thirdRef := commitCLISource(t, source, "add third collision") + attemptArgs := []string{"--repo-root", repo, "--source", source, "--template-version", "v3", "--source-ref", thirdRef, "--ask"} + stdout.Reset() + stderr.Reset() + if exitCode := runOwnership(attemptArgs, "update", strings.NewReader("o\n"), true, &stdout, &stderr); exitCode != exitFailure || !strings.Contains(stderr.String(), "input ended") { + t.Fatalf("incomplete answers exit=%d stderr=%s", exitCode, stderr.String()) + } + if got := string(mustReadFile(t, filepath.Join(repo, second))); got != "local "+second+"\n" { + t.Fatalf("incomplete answers partially changed earlier collision: %q", got) + } + if got := string(mustReadFile(t, filepath.Join(repo, third))); got != "local third\n" { + t.Fatalf("incomplete answers changed later collision: %q", got) + } + + stdout.Reset() + stderr.Reset() + if exitCode := runOwnership([]string{"--ask"}, "update", strings.NewReader("o\n"), false, &stdout, &stderr); exitCode != exitFailure || !strings.Contains(stderr.String(), "requires an interactive terminal") { + t.Fatalf("non-interactive --ask exit=%d stderr=%s", exitCode, stderr.String()) + } +} + +func mustReadFile(t *testing.T, path string) []byte { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return data +} diff --git a/internal/ownership/plan_regression_test.go b/internal/ownership/plan_regression_test.go index 007010a..c268ede 100644 --- a/internal/ownership/plan_regression_test.go +++ b/internal/ownership/plan_regression_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -133,6 +134,41 @@ func TestCollisionWithNewManagedFileIsRejected(t *testing.T) { } } +func TestExplicitUserOwnedOverwriteReplacesPayloadAndMode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows does not expose Unix executable bits") + } + repo, source := t.TempDir(), t.TempDir() + seed := "memory-bank/dna/seed.md" + path := "memory-bank/dna/collision.sh" + write(t, source, "template/"+seed, "seed\n") + initialize(t, repo, source) + write(t, repo, path, "local\n") + write(t, source, "template/"+path, "#!/bin/sh\necho source\n") + if err := os.Chmod(filepath.Join(source, "template", filepath.FromSlash(path)), 0o755); err != nil { + t.Fatal(err) + } + + options := opts(repo, source, "b") + options.UserOwnedResolutions = map[string]bool{path: true} + report, err := Update(options) + decision := decisionFor(t, report, path) + if err != nil || !report.Applied || decision.Action != UpdateFile || decision.Ownership != Managed { + t.Fatalf("explicit overwrite did not resolve collision: report=%#v err=%v", report, err) + } + if got := read(t, repo, path); got != "#!/bin/sh\necho source\n" { + t.Fatalf("overwrite did not install source payload: %q", got) + } + info, err := os.Stat(filepath.Join(repo, filepath.FromSlash(path))) + if err != nil || info.Mode().Perm() != 0o755 { + t.Fatalf("overwrite did not install source mode: info=%v err=%v", info, err) + } + lock, exists, err := ReadLock(repo) + if err != nil || !exists || lock.Files[path].Ownership != Managed || lock.Files[path].PayloadMode != "100755" { + t.Fatalf("overwrite did not record managed ownership: lock=%#v exists=%v err=%v", lock.Files[path], exists, err) + } +} + func TestManagedEditAfterPlanningIsNotOverwritten(t *testing.T) { repo, source := t.TempDir(), t.TempDir() path := "memory-bank/dna/rule.md" diff --git a/internal/ownership/types.go b/internal/ownership/types.go index 9de4cdf..6cf01d4 100644 --- a/internal/ownership/types.go +++ b/internal/ownership/types.go @@ -54,11 +54,12 @@ const ( ) type Decision struct { - Path string `json:"path"` - Ownership Class `json:"ownership"` - Action Action `json:"action"` - Reason string `json:"reason"` - Diff string `json:"diff,omitempty"` + Path string `json:"path"` + Ownership Class `json:"ownership"` + Action Action `json:"action"` + Reason string `json:"reason"` + Diff string `json:"diff,omitempty"` + CanOverwrite bool `json:"-"` } type Report struct { @@ -76,8 +77,12 @@ type Options struct { TemplateVersion string SourceRef string DryRun bool - AgentFile string - Now func() time.Time + // UserOwnedResolutions maps user-owned managed-file collisions to their + // explicit resolution: false keeps local content, true replaces it with the + // incoming source payload. + UserOwnedResolutions map[string]bool + AgentFile string + Now func() time.Time // verifySource is replaced by unit tests that use synthetic source trees. // CLI callers always use the Git-backed provenance verifier. verifySource func(string, string) error diff --git a/internal/ownership/update.go b/internal/ownership/update.go index 69c35cb..33177f3 100644 --- a/internal/ownership/update.go +++ b/internal/ownership/update.go @@ -112,7 +112,7 @@ func run(options Options, old Lock, hasLock bool, repo pinnedRepo, lockDigest st if err := verifySource(pinnedSource.root, options.SourceRef); err != nil { return Report{}, fmt.Errorf("source checkout changed while reading template: %w", err) } - mutations, decisions, next, err := buildPlan(repo, source, old, hasLock) + mutations, decisions, next, err := buildPlan(repo, source, old, hasLock, options.UserOwnedResolutions) if err != nil { return Report{}, err } @@ -401,7 +401,7 @@ func modeMatches(observed, expected string) bool { return observed == "" || observed == expected } -func buildPlan(repo pinnedRepo, source map[string]payload, old Lock, hasLock bool) ([]mutation, []Decision, Lock, error) { +func buildPlan(repo pinnedRepo, source map[string]payload, old Lock, hasLock bool, userOwnedResolutions map[string]bool) ([]mutation, []Decision, Lock, error) { if _, err := inspectRepoRoot(repo.root, repo.info); err != nil { return nil, nil, Lock{}, err } @@ -594,6 +594,15 @@ func buildPlan(repo pinnedRepo, source map[string]payload, old Lock, hasLock boo decision.Action, decision.Reason = Preserve, "preserve downstream adaptation" } } + if resolution, resolved := userOwnedResolutions[path]; class == Managed && exists && file.Ownership == UserOwned && resolved { + if resolution { + decision.Action, decision.Reason = UpdateFile, "replace user-owned file from source by explicit resolution" + file = File{Ownership: Managed, BaseDigest: incoming.digest, PayloadDigest: incoming.digest, BaseMode: incoming.mode, PayloadMode: incoming.mode} + } else { + decision.Action, decision.Reason = Preserve, "keep user-owned file by explicit resolution" + } + } + decision.CanOverwrite = class == Managed && exists && file.Ownership == UserOwned decision.Ownership = file.Ownership if decision.Action == Create || decision.Action == UpdateFile { sourceMutations = append(sourceMutations, mutation{ From 5b3f0e2c046ce0af4c309292b88d9dc56a4d124d Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Sat, 25 Jul 2026 00:02:03 +0300 Subject: [PATCH 2/3] test: document interactive update resolution --- README.md | 15 +++++++++++++++ internal/cli/cli_test.go | 12 ++++++++++++ 2 files changed, 27 insertions(+) diff --git a/README.md b/README.md index 1a6f22d..c3eebdd 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,21 @@ Existing locks from the legacy payload roots are migrated conservatively: unchanged files adopt canonical ownership, while local customization is preserved for explicit resolution. +## Resolve update collisions interactively + +`update` preserves user-owned files by default. To decide each managed-file +collision interactively, run it from a terminal with `--ask`: + +```sh +memory-bank-cli update --ask +``` + +Choose `keep` to retain the local file and its ownership, or `overwrite` to +replace it (including its executable mode) from the source template. All +answers are collected before changes are applied. `--ask --dry-run` shows the +resolved plan without changing files; `--ask` is rejected when standard input +is not a terminal. + ## Publish managed changes upstream From a downstream Git repository with a clean upstream checkout at `memory-bank/.repo`, preview the managed changes that can be proposed upstream: diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index facefd2..c486829 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" @@ -747,6 +748,9 @@ func TestUpdateAskResolvesCollisionsWithoutPartialChanges(t *testing.T) { t.Fatal(err) } } + if err := os.Chmod(filepath.Join(repo, second), 0o755); err != nil { + t.Fatal(err) + } updatedRef := commitCLISource(t, source, "add collisions") updateArgs := []string{"--repo-root", repo, "--source", source, "--template-version", "v2", "--source-ref", updatedRef, "--ask"} stdout.Reset() @@ -763,6 +767,11 @@ func TestUpdateAskResolvesCollisionsWithoutPartialChanges(t *testing.T) { if got, want := string(mustReadFile(t, filepath.Join(repo, second))), "local "+second+"\n"; got != want { t.Fatalf("keep payload=%q want=%q", got, want) } + if runtime.GOOS != "windows" { + if info, err := os.Stat(filepath.Join(repo, second)); err != nil || info.Mode().Perm() != 0o755 { + t.Fatalf("keep mode=%v err=%v, want 0755", info, err) + } + } lock, exists, err := ownership.ReadLock(repo) if err != nil || !exists || lock.Files[filepath.ToSlash(first)].Ownership != ownership.Managed || lock.Files[filepath.ToSlash(second)].Ownership != ownership.UserOwned { t.Fatalf("ask resolutions not recorded: lock=%#v exists=%v err=%v", lock.Files, exists, err) @@ -780,6 +789,9 @@ func TestUpdateAskResolvesCollisionsWithoutPartialChanges(t *testing.T) { if got := mustReadFile(t, filepath.Join(repo, "memory-bank", ".lock")); !bytes.Equal(got, lockBefore) { t.Fatal("ask dry-run changed lock") } + if !strings.Contains(stdout.String(), "update\tmanaged\t"+filepath.ToSlash(second)+"\treplace user-owned file from source by explicit resolution") { + t.Fatalf("ask dry-run did not print resolved plan: %s", stdout.String()) + } third := filepath.Join("memory-bank", "dna", "third.md") if err := os.WriteFile(filepath.Join(repo, third), []byte("local third\n"), 0o644); err != nil { From 80d19aafd649eb6fa4a0fe852fa1df35aeb9ec36 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Sat, 25 Jul 2026 00:33:31 +0300 Subject: [PATCH 3/3] test: preserve explicit keep resolution --- internal/ownership/plan_regression_test.go | 25 ++++++++++++++++++++++ internal/ownership/update.go | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/internal/ownership/plan_regression_test.go b/internal/ownership/plan_regression_test.go index c268ede..b7f1216 100644 --- a/internal/ownership/plan_regression_test.go +++ b/internal/ownership/plan_regression_test.go @@ -169,6 +169,31 @@ func TestExplicitUserOwnedOverwriteReplacesPayloadAndMode(t *testing.T) { } } +func TestExplicitUserOwnedKeepPreservesOwnership(t *testing.T) { + repo, source := t.TempDir(), t.TempDir() + seed := "memory-bank/dna/seed.md" + path := "memory-bank/dna/collision.md" + write(t, source, "template/"+seed, "seed\n") + write(t, source, "template/"+path, "source\n") + write(t, repo, path, "local\n") + initialize(t, repo, source) + + options := opts(repo, source, "b") + options.UserOwnedResolutions = map[string]bool{path: false} + report, err := Update(options) + decision := decisionFor(t, report, path) + if err != nil || !report.Applied || decision.Action != Preserve || decision.Reason != "keep user-owned file by explicit resolution" || decision.Ownership != UserOwned { + t.Fatalf("explicit keep did not preserve collision: report=%#v err=%v", report, err) + } + if got := read(t, repo, path); got != "local\n" { + t.Fatalf("keep replaced local payload: %q", got) + } + lock, exists, err := ReadLock(repo) + if err != nil || !exists || lock.Files[path].Ownership != UserOwned { + t.Fatalf("keep did not preserve user-owned lock entry: lock=%#v exists=%v err=%v", lock.Files[path], exists, err) + } +} + func TestManagedEditAfterPlanningIsNotOverwritten(t *testing.T) { repo, source := t.TempDir(), t.TempDir() path := "memory-bank/dna/rule.md" diff --git a/internal/ownership/update.go b/internal/ownership/update.go index 33177f3..15036c7 100644 --- a/internal/ownership/update.go +++ b/internal/ownership/update.go @@ -594,7 +594,7 @@ func buildPlan(repo pinnedRepo, source map[string]payload, old Lock, hasLock boo decision.Action, decision.Reason = Preserve, "preserve downstream adaptation" } } - if resolution, resolved := userOwnedResolutions[path]; class == Managed && exists && file.Ownership == UserOwned && resolved { + if resolution, resolved := userOwnedResolutions[path]; resolved && class == Managed && exists && file.Ownership == UserOwned { if resolution { decision.Action, decision.Reason = UpdateFile, "replace user-owned file from source by explicit resolution" file = File{Ownership: Managed, BaseDigest: incoming.digest, PayloadDigest: incoming.digest, BaseMode: incoming.mode, PayloadMode: incoming.mode}