"""Tests for dotfiles link planning, root markers, and module sources.""" import json import subprocess from pathlib import Path import pytest from flow.commands.dotfiles import ( LinkSpec, _collect_home_specs, _list_profiles, _load_link_specs_from_state, _pull_requires_ack, _resolved_package_source, _save_link_specs_to_state, _sync_modules, ) from flow.core.config import AppConfig, FlowContext from flow.core.console import ConsoleLogger from flow.core.platform import PlatformInfo def _ctx() -> FlowContext: return FlowContext( config=AppConfig(), manifest={"profiles": {"work": {"os": "linux", "configs": {"skip": []}}}}, platform=PlatformInfo(os="linux", arch="x64", platform="linux-x64"), console=ConsoleLogger(), ) def _make_flow_tree(tmp_path: Path) -> Path: flow_root = tmp_path (flow_root / "_shared" / "git").mkdir(parents=True) (flow_root / "_shared" / "git" / ".gitconfig").write_text("shared") (flow_root / "_shared" / "tmux").mkdir(parents=True) (flow_root / "_shared" / "tmux" / ".tmux.conf").write_text("tmux") (flow_root / "work" / "git").mkdir(parents=True) (flow_root / "work" / "git" / ".gitconfig").write_text("profile") (flow_root / "_shared" / "dnsmasq" / "_root" / "etc").mkdir(parents=True) (flow_root / "_shared" / "dnsmasq" / "_root" / "etc" / "hostname").write_text("devbox") return flow_root def test_list_profiles_ignores_reserved_dirs(tmp_path): flow_root = _make_flow_tree(tmp_path) profiles = _list_profiles(flow_root) assert profiles == ["work"] def test_collect_home_specs_conflict_fails(tmp_path): flow_root = _make_flow_tree(tmp_path) home = tmp_path / "home" home.mkdir() with pytest.raises(RuntimeError, match="Conflicting dotfile targets"): _collect_home_specs(_ctx(), flow_root, home, "work", set(), None) def test_collect_home_specs_maps_root_marker_to_absolute(tmp_path): flow_root = tmp_path (flow_root / "_shared" / "dnsmasq" / "_root" / "opt" / "homebrew" / "etc").mkdir(parents=True) src = flow_root / "_shared" / "dnsmasq" / "_root" / "opt" / "homebrew" / "etc" / "dnsmasq.conf" src.write_text("conf") home = tmp_path / "home" home.mkdir() specs = _collect_home_specs(_ctx(), flow_root, home, None, set(), None) assert Path("/opt/homebrew/etc/dnsmasq.conf") in specs assert specs[Path("/opt/homebrew/etc/dnsmasq.conf")].source == src def test_collect_home_specs_skip_root_marker(tmp_path): flow_root = tmp_path (flow_root / "_shared" / "dnsmasq" / "_root" / "etc").mkdir(parents=True) (flow_root / "_shared" / "dnsmasq" / "_root" / "etc" / "hostname").write_text("devbox") home = tmp_path / "home" home.mkdir() specs = _collect_home_specs(_ctx(), flow_root, home, None, {"_root"}, None) assert Path("/etc/hostname") not in specs def test_state_round_trip(tmp_path, monkeypatch): state_file = tmp_path / "linked.json" monkeypatch.setattr("flow.commands.dotfiles.LINKED_STATE", state_file) specs = { Path("/home/user/.gitconfig"): LinkSpec( source=Path("/repo/_shared/git/.gitconfig"), target=Path("/home/user/.gitconfig"), package="_shared/git", ) } _save_link_specs_to_state(specs) loaded = _load_link_specs_from_state() assert Path("/home/user/.gitconfig") in loaded assert loaded[Path("/home/user/.gitconfig")].package == "_shared/git" def test_state_old_format_rejected(tmp_path, monkeypatch): state_file = tmp_path / "linked.json" monkeypatch.setattr("flow.commands.dotfiles.LINKED_STATE", state_file) state_file.write_text( json.dumps( { "links": { "zsh": { "/home/user/.zshrc": "/repo/.zshrc", } } } ) ) with pytest.raises(RuntimeError, match="Unsupported linked state format"): _load_link_specs_from_state() def test_module_source_requires_sync(tmp_path): package_dir = tmp_path / "_shared" / "nvim" package_dir.mkdir(parents=True) (package_dir / "_module.yaml").write_text( "source: github:dummy/example\n" "ref:\n" " branch: main\n" ) with pytest.raises(RuntimeError, match="Run 'flow dotfiles sync' first"): _resolved_package_source(_ctx(), "_shared/nvim", package_dir) def test_sync_modules_populates_cache_and_resolves_source(tmp_path, monkeypatch): module_src = tmp_path / "module-src" module_src.mkdir() subprocess.run(["git", "init", "-b", "main", str(module_src)], check=True) (module_src / ".config" / "nvim").mkdir(parents=True) (module_src / ".config" / "nvim" / "init.lua").write_text("-- module") subprocess.run(["git", "-C", str(module_src), "add", "."], check=True) subprocess.run( [ "git", "-C", str(module_src), "-c", "user.name=Flow Test", "-c", "user.email=flow-test@example.com", "commit", "-m", "init module", ], check=True, ) dotfiles = tmp_path / "dotfiles" package_dir = dotfiles / "_shared" / "nvim" package_dir.mkdir(parents=True) (package_dir / "_module.yaml").write_text( f"source: {module_src}\n" "ref:\n" " branch: main\n" ) (package_dir / "notes.txt").write_text("ignore me") monkeypatch.setattr("flow.commands.dotfiles.DOTFILES_DIR", dotfiles) monkeypatch.setattr("flow.commands.dotfiles.MODULES_DIR", tmp_path / "modules") _sync_modules(_ctx(), verbose=False) resolved = _resolved_package_source(_ctx(), "_shared/nvim", package_dir) assert (resolved / ".config" / "nvim" / "init.lua").exists() def test_module_backed_link_specs_exclude_git_internals(tmp_path, monkeypatch): module_src = tmp_path / "module-src" module_src.mkdir() subprocess.run(["git", "init", "-b", "main", str(module_src)], check=True) (module_src / ".config" / "nvim").mkdir(parents=True) (module_src / ".config" / "nvim" / "init.lua").write_text("-- module") subprocess.run(["git", "-C", str(module_src), "add", "."], check=True) subprocess.run( [ "git", "-C", str(module_src), "-c", "user.name=Flow Test", "-c", "user.email=flow-test@example.com", "commit", "-m", "init module", ], check=True, ) dotfiles = tmp_path / "dotfiles" package_dir = dotfiles / "_shared" / "nvim" package_dir.mkdir(parents=True) (package_dir / "_module.yaml").write_text( f"source: {module_src}\n" "ref:\n" " branch: main\n" ) monkeypatch.setattr("flow.commands.dotfiles.DOTFILES_DIR", dotfiles) monkeypatch.setattr("flow.commands.dotfiles.MODULES_DIR", tmp_path / "modules") _sync_modules(_ctx(), verbose=False) home = tmp_path / "home" home.mkdir() specs = _collect_home_specs(_ctx(), dotfiles, home, None, set(), None) assert home / ".config" / "nvim" / "init.lua" in specs assert not any(target.relative_to(home).parts[0] == ".git" for target in specs) def test_sync_modules_resolves_relative_source_independent_of_cwd(tmp_path, monkeypatch): module_src = tmp_path / "module-src" module_src.mkdir() subprocess.run(["git", "init", "-b", "main", str(module_src)], check=True) (module_src / ".config" / "nvim").mkdir(parents=True) (module_src / ".config" / "nvim" / "init.lua").write_text("-- module") subprocess.run(["git", "-C", str(module_src), "add", "."], check=True) subprocess.run( [ "git", "-C", str(module_src), "-c", "user.name=Flow Test", "-c", "user.email=flow-test@example.com", "commit", "-m", "init module", ], check=True, ) dotfiles = tmp_path / "dotfiles" package_dir = dotfiles / "_shared" / "nvim" package_dir.mkdir(parents=True) relative_source = Path("../../../module-src") (package_dir / "_module.yaml").write_text( f"source: {relative_source}\n" "ref:\n" " branch: main\n" ) unrelated_cwd = tmp_path / "unrelated-cwd" unrelated_cwd.mkdir() monkeypatch.chdir(unrelated_cwd) monkeypatch.setattr("flow.commands.dotfiles.DOTFILES_DIR", dotfiles) monkeypatch.setattr("flow.commands.dotfiles.MODULES_DIR", tmp_path / "modules") _sync_modules(_ctx(), verbose=False) resolved = _resolved_package_source(_ctx(), "_shared/nvim", package_dir) assert (resolved / ".config" / "nvim" / "init.lua").exists() def test_pull_requires_ack_only_on_real_updates(): assert _pull_requires_ack("Already up to date.\n", "") is False assert _pull_requires_ack("Updating 123..456\n", "") is True