From 082468e2bd2b898c9fc7af44a405d8604d831977 Mon Sep 17 00:00:00 2001 From: Tomas Mirchev Date: Mon, 18 May 2026 03:14:15 +0300 Subject: [PATCH] update --- Makefile | 6 +- README.md | 15 +- example/README.md | 78 ++++---- .../_shared/{system => }/_root/etc/hostname | 0 .../_root/usr/local/bin/custom-script.sh | 0 .../_shared/nvim/.config/nvim/_module.yaml | 3 + .../_shared/nvim/.config/nvim/init.lua | 6 - example/module-repos/nvim-config/init.lua | 1 + .../module-repos/nvim-config/lua/plugins.lua | 1 + src/flow/app/dotfiles.py | 48 ++++- tests/e2e/__init__.py | 7 +- tests/e2e/test_dotfiles_e2e.py | 169 +++++++++++++----- tests/test_service_dotfiles.py | 52 +++++- 13 files changed, 291 insertions(+), 95 deletions(-) rename example/dotfiles-repo/_shared/{system => }/_root/etc/hostname (100%) rename example/dotfiles-repo/_shared/{system => }/_root/usr/local/bin/custom-script.sh (100%) create mode 100644 example/dotfiles-repo/_shared/nvim/.config/nvim/_module.yaml delete mode 100644 example/dotfiles-repo/_shared/nvim/.config/nvim/init.lua create mode 100644 example/module-repos/nvim-config/init.lua create mode 100644 example/module-repos/nvim-config/lua/plugins.lua diff --git a/Makefile b/Makefile index b8fb31b..2576531 100644 --- a/Makefile +++ b/Makefile @@ -10,13 +10,14 @@ VERSION := $(shell sed -n 's/^__version__ = "\(.*\)"/\1/p' src/flow/__init__.py) RELEASE_ROOT := $(BUILD_DIR)/flow-$(VERSION)-python RELEASE_ARCHIVE := $(DIST_DIR)/flow-$(VERSION)-python.tar.gz -.PHONY: help deps test test-e2e check package release-package build binary install install-local check-binary clean distclean +.PHONY: help deps test test-e2e test-e2e-cmds check package release-package build binary install install-local check-binary clean distclean help: @printf "Targets:\n" @printf " make deps Sync locked dev/build dependencies into .venv\n" @printf " make test Run unit tests\n" @printf " make test-e2e Run Docker/Podman e2e tests (FLOW_RUN_E2E=1)\n" + @printf " make test-e2e-cmds Run focused disposable-container CLI command checks (FLOW_RUN_E2E=1)\n" @printf " make check Run unit tests and CLI smoke check\n" @printf " make package Build wheel and sdist into dist/\n" @printf " make release-package Build installable Python release tarball\n" @@ -35,6 +36,9 @@ test: test-e2e: FLOW_RUN_E2E=1 $(UV) run pytest tests/e2e/ -v +test-e2e-cmds: + FLOW_RUN_E2E=1 $(UV) run pytest tests/e2e/test_dotfiles_e2e.py::test_cli_paths_run_in_disposable_container -q + check: test $(UV) run python -m flow --help >/dev/null $(UV) run python -m flow --version >/dev/null diff --git a/README.md b/README.md index 14a7e7c..06d95f7 100644 --- a/README.md +++ b/README.md @@ -231,14 +231,13 @@ See `example/README.md` for a complete runnable dotfiles/setup fixture. ```text _shared/ + _root/ + etc/hostname zsh/ .zshrc nvim/ .config/nvim/ _module.yaml - system/ - _root/ - etc/hostname linux-work/ i3/ @@ -246,7 +245,8 @@ linux-work/ ``` `_shared/` applies to every profile. Profile directories add or override package -sets. `_root/` marks absolute paths and plans sudo-backed link actions. +sets. A layer-level `_root/` directory, such as `_shared/_root/` or +`linux-work/_root/`, marks absolute paths and plans sudo-backed link actions. External module repos are declared with `_module.yaml`: @@ -301,6 +301,13 @@ Direct commands are equivalent: ```bash uv run pytest tests/ -q --ignore=tests/e2e FLOW_RUN_E2E=1 uv run pytest tests/e2e/ -v +FLOW_RUN_E2E=1 uv run pytest tests/e2e/test_dotfiles_e2e.py::test_cli_paths_run_in_disposable_container -q +FLOW_RUN_E2E=1 uv run pytest tests/e2e/test_dotfiles_e2e.py::test_dotfiles_init_and_link_in_container -q uv build uv run pyinstaller --noconfirm --clean --onefile --name flow --paths src src/flow/__main__.py ``` + +For local container-only command validation, run the e2e module directly with +`FLOW_RUN_E2E=1`. That executes all commands inside a disposable container +instance with isolated `HOME`/`XDG_*` and a read-only example repo mount, so it +does not touch host dotfiles or global state. diff --git a/example/README.md b/example/README.md index fe53564..eda7464 100644 --- a/example/README.md +++ b/example/README.md @@ -1,34 +1,41 @@ -# Example Dotfiles Repository +# Flow Example -`example/dotfiles-repo` is a complete fixture for the current flow schema. It -contains shared dotfiles, profile-specific dotfiles, package definitions, setup -profiles, root-targeted files, templates, and shell hooks. +`example/` is a single complete fixture for the current flow schema. It contains +one dotfiles repository plus one local module repository, showcasing shared +dotfiles, profile-specific dotfiles, package definitions, setup profiles, +root-targeted files, external modules, templates, and shell hooks. ## Layout ```text -dotfiles-repo/ - _shared/ - bin/.local/bin/flow-hello - flow/.config/flow/config.yaml - flow/.config/flow/packages.yaml - flow/.config/flow/profiles.yaml - git/.gitconfig - nvim/.config/nvim/init.lua - system/_root/etc/hostname - system/_root/usr/local/bin/custom-script.sh - tmux/.tmux.conf - zsh/.zshrc - linux-auto/ - ssh/.ssh/config - macos-dev/ - ghostty/.config/ghostty/config +example/ + dotfiles-repo/ + _shared/ + _root/etc/hostname + _root/usr/local/bin/custom-script.sh + bin/.local/bin/flow-hello + flow/.config/flow/config.yaml + flow/.config/flow/packages.yaml + flow/.config/flow/profiles.yaml + git/.gitconfig + nvim/.config/nvim/_module.yaml + tmux/.tmux.conf + zsh/.zshrc + linux-auto/ + ssh/.ssh/config + macos-dev/ + ghostty/.config/ghostty/config + module-repos/ + nvim-config/ + init.lua + lua/plugins.lua ``` The fixture demonstrates: - `_shared/` plus profile-specific layers - `_root/` absolute-path planning for sudo-backed links +- `_module.yaml` external module planning with a local module repository - flow config and manifest overlay under `_shared/flow/.config/flow` - package-manager, cask, and binary package definitions - profile package shorthand and object overrides @@ -45,11 +52,21 @@ repo: DEMO="$(mktemp -d)" mkdir -p "$DEMO/home" cp -a example/dotfiles-repo "$DEMO/dotfiles-src" +cp -a example/module-repos "$DEMO/module-repos" + +git -C "$DEMO/module-repos/nvim-config" init -q -b main +git -C "$DEMO/module-repos/nvim-config" config user.email e2e@example.com +git -C "$DEMO/module-repos/nvim-config" config user.name "flow example" +git -C "$DEMO/module-repos/nvim-config" add -A +git -C "$DEMO/module-repos/nvim-config" commit -q -m initial + git -C "$DEMO/dotfiles-src" init -q -b main git -C "$DEMO/dotfiles-src" config user.email e2e@example.com git -C "$DEMO/dotfiles-src" config user.name "flow example" git -C "$DEMO/dotfiles-src" add -A git -C "$DEMO/dotfiles-src" commit -q -m initial + +cd "$DEMO" ``` Run flow against that sandbox: @@ -65,16 +82,16 @@ HOME="$DEMO/home" \ XDG_CONFIG_HOME="$DEMO/config" \ XDG_DATA_HOME="$DEMO/data" \ XDG_STATE_HOME="$DEMO/state" \ -uv run flow dotfiles link --profile linux-auto --skip system --dry-run +uv run flow dotfiles link --profile linux-auto --skip _root --dry-run HOME="$DEMO/home" \ XDG_CONFIG_HOME="$DEMO/config" \ XDG_DATA_HOME="$DEMO/data" \ XDG_STATE_HOME="$DEMO/state" \ -uv run flow dotfiles link --profile linux-auto --skip system +uv run flow dotfiles link --profile linux-auto --skip _root ``` -`--skip system` avoids `_root/` paths such as `/etc/hostname` during the demo. +`--skip _root` avoids root-targeted paths such as `/etc/hostname` during the demo. Useful follow-up commands: @@ -94,16 +111,17 @@ rm -rf "$DEMO" ## External Modules -A package directory may mount a separate git repo by adding `_module.yaml` at -the package root: +A package directory may mount a separate git repo by adding `_module.yaml` +under the desired mount path: ```yaml -source: github:your-org/nvim-config +source: module-repos/nvim-config ref: branch: main ``` -This fixture does not include a real module because that would make the example -depend on an external network repo. After adding one, `flow dotfiles repos pull` -clones or updates the module cache, and `flow dotfiles link` links from that -cache. +The example nvim package mounts `example/module-repos/nvim-config` at +`.config/nvim`. Flow implements modules through `_module.yaml`; it does not +implement a top-level `_modules/` dotfiles layout convention. `flow dotfiles +repos pull` clones or updates module caches, and `flow dotfiles link` links +from those caches. diff --git a/example/dotfiles-repo/_shared/system/_root/etc/hostname b/example/dotfiles-repo/_shared/_root/etc/hostname similarity index 100% rename from example/dotfiles-repo/_shared/system/_root/etc/hostname rename to example/dotfiles-repo/_shared/_root/etc/hostname diff --git a/example/dotfiles-repo/_shared/system/_root/usr/local/bin/custom-script.sh b/example/dotfiles-repo/_shared/_root/usr/local/bin/custom-script.sh similarity index 100% rename from example/dotfiles-repo/_shared/system/_root/usr/local/bin/custom-script.sh rename to example/dotfiles-repo/_shared/_root/usr/local/bin/custom-script.sh diff --git a/example/dotfiles-repo/_shared/nvim/.config/nvim/_module.yaml b/example/dotfiles-repo/_shared/nvim/.config/nvim/_module.yaml new file mode 100644 index 0000000..37231d0 --- /dev/null +++ b/example/dotfiles-repo/_shared/nvim/.config/nvim/_module.yaml @@ -0,0 +1,3 @@ +source: module-repos/nvim-config +ref: + branch: main diff --git a/example/dotfiles-repo/_shared/nvim/.config/nvim/init.lua b/example/dotfiles-repo/_shared/nvim/.config/nvim/init.lua deleted file mode 100644 index 4555de8..0000000 --- a/example/dotfiles-repo/_shared/nvim/.config/nvim/init.lua +++ /dev/null @@ -1,6 +0,0 @@ -vim.opt.number = true -vim.opt.relativenumber = true -vim.opt.expandtab = true -vim.opt.shiftwidth = 2 - -vim.g.mapleader = " " diff --git a/example/module-repos/nvim-config/init.lua b/example/module-repos/nvim-config/init.lua new file mode 100644 index 0000000..4438234 --- /dev/null +++ b/example/module-repos/nvim-config/init.lua @@ -0,0 +1 @@ +vim.opt.number = true diff --git a/example/module-repos/nvim-config/lua/plugins.lua b/example/module-repos/nvim-config/lua/plugins.lua new file mode 100644 index 0000000..a564707 --- /dev/null +++ b/example/module-repos/nvim-config/lua/plugins.lua @@ -0,0 +1 @@ +return {} diff --git a/src/flow/app/dotfiles.py b/src/flow/app/dotfiles.py index 2572ae4..4303899 100644 --- a/src/flow/app/dotfiles.py +++ b/src/flow/app/dotfiles.py @@ -30,7 +30,7 @@ from flow.domain.dotfiles.modules import ( parse_module_ref, ) from flow.domain.dotfiles.planning import plan_link, plan_unlink -from flow.domain.dotfiles.resolution import resolve_all_targets +from flow.domain.dotfiles.resolution import RESERVED_ROOT, resolve_all_targets # Maps ref_type to its git checkout prefix. Branch and commit use the @@ -55,6 +55,14 @@ SKIP_DIRS = {".git", ".github", "__pycache__", "flow"} SKIP_FILES = {".DS_Store", ".gitkeep", "_module.yaml"} +def _path_is_relative_to(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + except ValueError: + return False + return True + + class DotfilesService: def __init__(self, ctx: FlowContext): self.ctx = ctx @@ -606,8 +614,44 @@ class DotfilesService: continue package_id = f"{layer}/{pkg_dir.name}" - module_ref = self._find_module(pkg_dir, package_id) local_files = self._collect_files(pkg_dir) + if pkg_dir.name == RESERVED_ROOT: + module_ref = None + local_files = [ + (abs_source, RESERVED_ROOT / rel) + for abs_source, rel in local_files + ] + else: + nested_root = [ + f"{package_id}/{rel}" + for _, rel in local_files + if RESERVED_ROOT in rel.parts + ] + if nested_root: + raise PlanConflict( + f"Invalid nested {RESERVED_ROOT} path: {nested_root[0]}", + [ + f"{path}: place root-targeted files under " + f"{layer}/{RESERVED_ROOT}/ instead" + for path in nested_root + ], + ) + module_ref = self._find_module(pkg_dir, package_id) + if module_ref is not None: + mounted_local_files = [ + f"{package_id}/{rel}" + for _, rel in local_files + if _path_is_relative_to(rel, module_ref.mount_path) + ] + if mounted_local_files: + raise PlanConflict( + f"Local files inside module mount: {mounted_local_files[0]}", + [ + f"{path}: only {MODULE_FILE} may exist under " + f"{package_id}/{module_ref.mount_path}" + for path in mounted_local_files + ], + ) packages.append(Package( name=pkg_dir.name, diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py index a76d7e8..dc80cd9 100644 --- a/tests/e2e/__init__.py +++ b/tests/e2e/__init__.py @@ -1 +1,6 @@ -"""End-to-end tests gated by FLOW_RUN_E2E=1.""" +"""End-to-end tests gated by FLOW_RUN_E2E=1. + +These tests run Flow inside a disposable container and mount only a read-only +fixture repo plus container-local XDG state, so host dotfiles/config are not +modified. +""" diff --git a/tests/e2e/test_dotfiles_e2e.py b/tests/e2e/test_dotfiles_e2e.py index 3b1b0ae..cb72348 100644 --- a/tests/e2e/test_dotfiles_e2e.py +++ b/tests/e2e/test_dotfiles_e2e.py @@ -10,13 +10,14 @@ import os import shutil import subprocess from pathlib import Path +import textwrap import pytest REPO_ROOT = Path(__file__).resolve().parents[2] CONTAINERFILE = Path(__file__).parent / "Containerfile" -EXAMPLE_REPO = REPO_ROOT / "example" / "dotfiles-repo" +EXAMPLE_DIR = REPO_ROOT / "example" IMAGE_TAG = "flow-e2e:test" @@ -34,15 +35,69 @@ def _pick_runtime() -> str | None: return None -@pytest.mark.skipif( - os.environ.get("FLOW_RUN_E2E") != "1", - reason="set FLOW_RUN_E2E=1 to run", -) -def test_dotfiles_init_and_link_in_container(): - runtime = _pick_runtime() - if runtime is None: +def _flow_script(*commands: str) -> str: + """Build a reusable flow sandbox script for container execution.""" + return textwrap.dedent( + """ + set -euo pipefail + + export XDG_CONFIG_HOME="/tmp/flow-config" + export XDG_DATA_HOME="/tmp/flow-data" + export XDG_STATE_HOME="/tmp/flow-state" + export TARGET_HOSTNAME="flow-e2e" + export USER_EMAIL="e2e@example.com" + export PYTHONPATH="/opt/flow-src/src:${PYTHONPATH:-}" + + rm -rf "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME" + mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME" + + cp -r /example/dotfiles-repo "$HOME/dotfiles-src" + cp -r /example/module-repos "$HOME/module-repos" + cd "$HOME/module-repos/nvim-config" + git init -q -b main + git -c user.email="$USER_EMAIL" -c user.name="Flow E2E" add -A + git -c user.email="$USER_EMAIL" -c user.name="Flow E2E" commit -q -m initial + + cd "$HOME/dotfiles-src" + git init -q -b main + git -c user.email="$USER_EMAIL" -c user.name="Flow E2E" add -A + git -c user.email="$USER_EMAIL" -c user.name="Flow E2E" commit -q -m initial + cd "$HOME" + """ + ).strip() + "\n" + "\n".join(commands) + "\n" + + +def _run_in_e2e_container(runtime: str, script: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + runtime, + "run", + "--rm", + "-v", + f"{EXAMPLE_DIR}:/example:ro", + IMAGE_TAG, + "-c", + script, + ], + capture_output=True, + text=True, + ) + + +@pytest.fixture(scope="session") +def runtime() -> str: + if os.environ.get("FLOW_RUN_E2E") != "1": + pytest.skip("set FLOW_RUN_E2E=1 to run") + + selected_runtime = _pick_runtime() + if selected_runtime is None: pytest.skip("neither podman nor docker is available") + return selected_runtime + + +@pytest.fixture(scope="session") +def image(runtime: str): build = subprocess.run( [ runtime, "build", @@ -57,49 +112,67 @@ def test_dotfiles_init_and_link_in_container(): pytest.fail(f"image build failed:\n{build.stdout}\n{build.stderr}") try: - # Run flow inside the container against the mounted example repo. - # `flow dotfiles init` clones, so we need a real git remote — turn - # the read-only example mount into a bare-ish working repo first. - # --skip system avoids the _root/ paths which would try to sudo-link - # over /etc/hostname; we already cover the link path on non-system - # packages. - script = ( - "set -eux; " - "cp -r /example /home/flowuser/dotfiles-src; " - "cd /home/flowuser/dotfiles-src; " - "git init -q -b main; " - "git -c user.email=e2e@example.com -c user.name=e2e add -A; " - "git -c user.email=e2e@example.com -c user.name=e2e commit -q -m initial; " - "cd /home/flowuser; " - "flow dotfiles init --repo /home/flowuser/dotfiles-src; " - "flow dotfiles link --profile linux-auto --skip system; " - # Verify real symlinks were created and point into the dotfiles dir. - "test -L /home/flowuser/.zshrc; " - "test -L /home/flowuser/.gitconfig; " - "readlink /home/flowuser/.zshrc | grep -q '/dotfiles/_shared/zsh/.zshrc'; " - "readlink /home/flowuser/.gitconfig | grep -q '/dotfiles/_shared/git/.gitconfig'; " - # Idempotency: rerun should be a no-op. - "flow dotfiles link --profile linux-auto --skip system; " - "flow dotfiles status" - ) - result = subprocess.run( - [ - runtime, "run", "--rm", - "-v", f"{EXAMPLE_REPO}:/example:ro", - IMAGE_TAG, - "-c", script, - ], - capture_output=True, - text=True, - ) - assert result.returncode == 0, ( - f"e2e run failed (rc={result.returncode}):\n" - f"stdout: {result.stdout}\n" - f"stderr: {result.stderr}" - ) + yield IMAGE_TAG finally: subprocess.run( [runtime, "rmi", "-f", IMAGE_TAG], capture_output=True, text=True, ) + + +@pytest.mark.skipif( + os.environ.get("FLOW_RUN_E2E") != "1", + reason="set FLOW_RUN_E2E=1 to run", +) +def test_dotfiles_init_and_link_in_container(runtime: str, image: str): + result = _run_in_e2e_container( + runtime, + _flow_script( + "flow dotfiles init --repo \"$HOME/dotfiles-src\"", + "flow dotfiles link --profile linux-auto --skip _root", + "test -L \"$HOME/.zshrc\"", + "test -L \"$HOME/.gitconfig\"", + "readlink \"$HOME/.zshrc\" | grep -q '/dotfiles/_shared/zsh/.zshrc'", + "readlink \"$HOME/.gitconfig\" | grep -q '/dotfiles/_shared/git/.gitconfig'", + "flow dotfiles link --profile linux-auto --skip _root", + "flow dotfiles status", + ), + ) + assert result.returncode == 0, ( + f"e2e run failed (rc={result.returncode}):\n" + f"stdout: {result.stdout}\n" + f"stderr: {result.stderr}" + ) + + +@pytest.mark.skipif( + os.environ.get("FLOW_RUN_E2E") != "1", + reason="set FLOW_RUN_E2E=1 to run", +) +def test_cli_paths_run_in_disposable_container(runtime: str, image: str): + result = _run_in_e2e_container( + runtime, + _flow_script( + "flow --version | tee /tmp/version.txt", + "grep -q \"flow\" /tmp/version.txt", + "flow --help | tee /tmp/help.txt", + "test -s /tmp/help.txt", + "flow completion zsh | tee /tmp/completion.zsh", + "test -s /tmp/completion.zsh", + "flow dotfiles init --repo \"$HOME/dotfiles-src\"", + "flow dotfiles link --profile linux-auto --skip _root", + "flow dotfiles status", + "flow packages list --all | tee /tmp/packages.txt", + "test -s /tmp/packages.txt", + "flow packages install --profile linux-auto --dry-run | tee /tmp/package-dry-run.txt", + "test -s /tmp/package-dry-run.txt", + "flow setup show linux-auto | tee /tmp/setup.txt", + "test -s /tmp/setup.txt", + ), + ) + assert result.returncode == 0, ( + f"e2e run failed (rc={result.returncode}):\n" + f"stdout: {result.stdout}\n" + f"stderr: {result.stderr}" + ) diff --git a/tests/test_service_dotfiles.py b/tests/test_service_dotfiles.py index 72c40b7..70c5b25 100644 --- a/tests/test_service_dotfiles.py +++ b/tests/test_service_dotfiles.py @@ -119,6 +119,31 @@ class TestDotfilesServiceLink: # Local file outside mount path should be linked assert (home / ".local" / "bin" / "nvim-wrapper").is_symlink() + def test_module_mount_rejects_local_files_inside_mount_path(self, tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + + dotfiles = tmp_path / "dotfiles" + pkg_dir = dotfiles / "_shared" / "nvim" + config_dir = pkg_dir / ".config" / "nvim" + config_dir.mkdir(parents=True) + (config_dir / "_module.yaml").write_text(yaml.dump({ + "source": "github:test/nvim-config", + "ref": {"branch": "main"}, + })) + (config_dir / "init.lua").write_text("-- local file conflicts with module mount") + + monkeypatch.setattr(paths, "HOME", home) + monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) + monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules") + monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") + + ctx = _make_ctx(tmp_path) + svc = DotfilesService(ctx) + + with pytest.raises(PlanConflict, match=".config/nvim/init.lua"): + svc._discover_packages(profile=None) + def test_unlink_removes_symlinks(self, tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir() @@ -698,12 +723,12 @@ class TestDotfilesServiceRootPaths: """`_root/` paths require sudo; verify the service routes them via the sudo branch of FileSystem.create_symlink (without actually invoking sudo).""" - def test_root_paths_route_via_sudo(self, tmp_path, monkeypatch): + def test_layer_root_paths_route_via_sudo(self, tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir() dotfiles = tmp_path / "dotfiles" - pkg_dir = dotfiles / "_shared" / "system" / "_root" / "etc" + pkg_dir = dotfiles / "_shared" / "_root" / "etc" pkg_dir.mkdir(parents=True) (pkg_dir / "ourfile").write_text("managed by flow") @@ -738,7 +763,7 @@ class TestDotfilesServiceRootPaths: svc.link(dry_run=True) assert not Path("/etc/ourfile").exists() # we did not actually touch /etc - def test_root_paths_can_be_skipped(self, tmp_path, monkeypatch): + def test_nested_root_marker_rejected(self, tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir() @@ -746,7 +771,28 @@ class TestDotfilesServiceRootPaths: pkg_dir = dotfiles / "_shared" / "system" / "_root" / "etc" pkg_dir.mkdir(parents=True) (pkg_dir / "hostname").write_text("flow-host") + + monkeypatch.setattr(paths, "HOME", home) + monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) + monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules") + monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") + + ctx = _make_ctx(tmp_path) + svc = DotfilesService(ctx) + + with pytest.raises(PlanConflict, match="_shared/system/_root"): + svc._discover_packages(profile=None) + + def test_root_paths_can_be_skipped(self, tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + + dotfiles = tmp_path / "dotfiles" + pkg_dir = dotfiles / "_shared" / "_root" / "etc" + pkg_dir.mkdir(parents=True) + (pkg_dir / "hostname").write_text("flow-host") # Non-root file in the same package shouldn't be skipped + (dotfiles / "_shared" / "system" / "README").parent.mkdir(parents=True) (dotfiles / "_shared" / "system" / "README").write_text("notes") monkeypatch.setattr(paths, "HOME", home)