From 8231353cbd54b5067c844a507af8f86fc864c7aa Mon Sep 17 00:00:00 2001 From: John Lettman Date: Sat, 1 Aug 2026 15:03:01 -0400 Subject: [PATCH] fix(context): merge defaults across successive tasks Recursively merge Dotbot defaults instead of replacing the existing mapping, preserving unrelated plugin settings while allowing later values to override specific nested options. Add regression tests covering nested merges, overrides, and input isolation. --- src/dotbot/context.py | 17 ++++++++++++-- tests/test_context.py | 54 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 tests/test_context.py diff --git a/src/dotbot/context.py b/src/dotbot/context.py index caef9f0..1f26fea 100644 --- a/src/dotbot/context.py +++ b/src/dotbot/context.py @@ -1,7 +1,7 @@ import copy import os from argparse import Namespace -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type +from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Type if TYPE_CHECKING: from dotbot.plugin import Plugin @@ -33,7 +33,20 @@ def base_directory(self, canonical_path: bool = True) -> str: # noqa: FBT001, F return base_directory def set_defaults(self, defaults: Dict[str, Any]) -> None: - self._defaults = defaults + self._defaults = self._merge_defaults(self._defaults, defaults) + + @classmethod + def _merge_defaults( + cls, current: Mapping[str, Any], updates: Mapping[str, Any] + ) -> Dict[str, Any]: + """Merge default mappings recursively, with later values taking precedence.""" + merged = copy.deepcopy(dict(current)) + for key, value in updates.items(): + if isinstance(value, Mapping) and isinstance(merged.get(key), Mapping): + merged[key] = cls._merge_defaults(merged[key], value) + else: + merged[key] = copy.deepcopy(value) + return merged def defaults(self) -> Dict[str, Any]: return copy.deepcopy(self._defaults) diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 0000000..83bb565 --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,54 @@ +from typing import Any, Dict + +from dotbot.context import Context + + +def test_set_defaults_merges_nested_mappings() -> None: + """Verify that successive defaults preserve mappings from earlier tasks.""" + + context = Context(".") + + context.set_defaults( + { + "shell": {"quiet": True}, + "link": {"create": True, "relink": True}, + } + ) + context.set_defaults({"plugin": {"interactive": False}}) + + assert context.defaults() == { + "shell": {"quiet": True}, + "link": {"create": True, "relink": True}, + "plugin": {"interactive": False}, + } + + +def test_set_defaults_later_values_override_without_dropping_siblings() -> None: + """Verify that later nested defaults override only the selected value.""" + + context = Context(".") + context.set_defaults( + { + "link": {"create": True, "relink": False}, + "options": ["--safe-links"], + } + ) + + context.set_defaults({"link": {"relink": True}}) + + assert context.defaults() == { + "link": {"create": True, "relink": True}, + "options": ["--safe-links"], + } + + +def test_defaults_are_copied_when_merged() -> None: + """Verify that mutating input defaults does not mutate the context.""" + + context = Context(".") + defaults: Dict[str, Any] = {"plugin": {"interactive": False}} + + context.set_defaults(defaults) + defaults["plugin"]["interactive"] = True + + assert context.defaults()["plugin"]["interactive"] is False