working version
This commit is contained in:
@@ -1,298 +1,94 @@
|
||||
"""Integration tests for dotfiles tree folding behavior."""
|
||||
"""Tests for flat-layout dotfiles helpers and state format."""
|
||||
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from flow.commands.dotfiles import _discover_packages, _walk_package
|
||||
from flow.core.config import AppConfig, FlowContext
|
||||
from flow.core.console import ConsoleLogger
|
||||
from flow.core.platform import PlatformInfo
|
||||
from flow.core.stow import LinkTree, TreeFolder
|
||||
from flow.commands.dotfiles import (
|
||||
LinkSpec,
|
||||
_collect_home_specs,
|
||||
_collect_root_specs,
|
||||
_list_profiles,
|
||||
_load_link_specs_from_state,
|
||||
_save_link_specs_to_state,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx():
|
||||
"""Create a mock FlowContext."""
|
||||
return FlowContext(
|
||||
config=AppConfig(),
|
||||
manifest={},
|
||||
platform=PlatformInfo(),
|
||||
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 / "work" / "nvim").mkdir(parents=True)
|
||||
(flow_root / "work" / "nvim" / ".config" / "nvim").mkdir(parents=True)
|
||||
(flow_root / "work" / "nvim" / ".config" / "nvim" / "init.lua").write_text("-- init")
|
||||
|
||||
(flow_root / "_root" / "general" / "etc").mkdir(parents=True)
|
||||
(flow_root / "_root" / "general" / "etc" / "hostname").write_text("devbox")
|
||||
|
||||
return flow_root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dotfiles_with_nested(tmp_path):
|
||||
"""Create dotfiles with nested directory structure for folding tests."""
|
||||
common = tmp_path / "common"
|
||||
|
||||
# nvim package with nested config
|
||||
nvim = common / "nvim" / ".config" / "nvim"
|
||||
nvim.mkdir(parents=True)
|
||||
(nvim / "init.lua").write_text("-- init")
|
||||
(nvim / "lua").mkdir()
|
||||
(nvim / "lua" / "config.lua").write_text("-- config")
|
||||
(nvim / "lua" / "plugins.lua").write_text("-- plugins")
|
||||
|
||||
# zsh package with flat structure
|
||||
zsh = common / "zsh"
|
||||
zsh.mkdir(parents=True)
|
||||
(zsh / ".zshrc").write_text("# zshrc")
|
||||
(zsh / ".zshenv").write_text("# zshenv")
|
||||
|
||||
return tmp_path
|
||||
def test_list_profiles_ignores_reserved_dirs(tmp_path):
|
||||
flow_root = _make_flow_tree(tmp_path)
|
||||
profiles = _list_profiles(flow_root)
|
||||
assert profiles == ["work"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def home_dir(tmp_path):
|
||||
"""Create a temporary home directory."""
|
||||
def test_collect_home_specs_conflict_fails(tmp_path):
|
||||
flow_root = _make_flow_tree(tmp_path)
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
return home
|
||||
|
||||
with pytest.raises(RuntimeError, match="Conflicting dotfile targets"):
|
||||
_collect_home_specs(flow_root, home, "work", set(), None)
|
||||
|
||||
|
||||
def test_tree_folding_single_package(dotfiles_with_nested, home_dir):
|
||||
"""Test that a single package can be folded into directory symlink."""
|
||||
# Discover nvim package
|
||||
packages = _discover_packages(dotfiles_with_nested)
|
||||
nvim_source = packages["nvim"]
|
||||
|
||||
# Build link tree
|
||||
tree = LinkTree()
|
||||
folder = TreeFolder(tree)
|
||||
|
||||
# Plan links for all nvim files
|
||||
operations = []
|
||||
for src, dst in _walk_package(nvim_source, home_dir):
|
||||
ops = folder.plan_link(src, dst, "nvim")
|
||||
operations.extend(ops)
|
||||
|
||||
# Execute operations
|
||||
folder.execute_operations(operations, dry_run=False)
|
||||
|
||||
# Check that we created efficient symlinks
|
||||
# In ideal case, we'd have one directory symlink instead of 3 file symlinks
|
||||
nvim_config = home_dir / ".config" / "nvim"
|
||||
|
||||
# Verify links work
|
||||
assert (nvim_config / "init.lua").exists()
|
||||
assert (nvim_config / "lua" / "config.lua").exists()
|
||||
def test_collect_root_specs_maps_to_absolute_paths(tmp_path):
|
||||
flow_root = _make_flow_tree(tmp_path)
|
||||
specs = _collect_root_specs(flow_root, set(), include_root=True)
|
||||
assert Path("/etc/hostname") in specs
|
||||
assert specs[Path("/etc/hostname")].package == "_root/general"
|
||||
|
||||
|
||||
def test_tree_unfolding_conflict(dotfiles_with_nested, home_dir):
|
||||
"""Test that tree unfolds when second package needs same directory."""
|
||||
common = dotfiles_with_nested / "common"
|
||||
def test_state_round_trip(tmp_path, monkeypatch):
|
||||
state_file = tmp_path / "linked.json"
|
||||
monkeypatch.setattr("flow.commands.dotfiles.LINKED_STATE", state_file)
|
||||
|
||||
# Create second package that shares .config
|
||||
tmux = common / "tmux" / ".config" / "tmux"
|
||||
tmux.mkdir(parents=True)
|
||||
(tmux / "tmux.conf").write_text("# tmux")
|
||||
|
||||
# First, link nvim (can fold .config/nvim)
|
||||
tree = LinkTree()
|
||||
folder = TreeFolder(tree)
|
||||
|
||||
nvim_source = common / "nvim"
|
||||
for src, dst in _walk_package(nvim_source, home_dir):
|
||||
ops = folder.plan_link(src, dst, "nvim")
|
||||
folder.execute_operations(ops, dry_run=False)
|
||||
|
||||
# Now link tmux (should unfold if needed)
|
||||
tmux_source = common / "tmux"
|
||||
for src, dst in _walk_package(tmux_source, home_dir):
|
||||
ops = folder.plan_link(src, dst, "tmux")
|
||||
folder.execute_operations(ops, dry_run=False)
|
||||
|
||||
# Both packages should be linked
|
||||
assert (home_dir / ".config" / "nvim" / "init.lua").exists()
|
||||
assert (home_dir / ".config" / "tmux" / "tmux.conf").exists()
|
||||
|
||||
|
||||
def test_state_format_with_directory_links(dotfiles_with_nested, home_dir):
|
||||
"""Test that state file correctly tracks directory vs file links."""
|
||||
tree = LinkTree()
|
||||
|
||||
# Add a directory link
|
||||
tree.add_link(
|
||||
home_dir / ".config" / "nvim",
|
||||
dotfiles_with_nested / "common" / "nvim" / ".config" / "nvim",
|
||||
"nvim",
|
||||
is_dir_link=True,
|
||||
)
|
||||
|
||||
# Add a file link
|
||||
tree.add_link(
|
||||
home_dir / ".zshrc",
|
||||
dotfiles_with_nested / "common" / "zsh" / ".zshrc",
|
||||
"zsh",
|
||||
is_dir_link=False,
|
||||
)
|
||||
|
||||
# Convert to state
|
||||
state = tree.to_state()
|
||||
|
||||
# Verify format
|
||||
assert state["version"] == 2
|
||||
nvim_link = state["links"]["nvim"][str(home_dir / ".config" / "nvim")]
|
||||
assert nvim_link["is_directory_link"] is True
|
||||
|
||||
zsh_link = state["links"]["zsh"][str(home_dir / ".zshrc")]
|
||||
assert zsh_link["is_directory_link"] is False
|
||||
|
||||
|
||||
def test_state_backward_compatibility_rejected(home_dir):
|
||||
"""Old state format should be rejected (no backward compatibility)."""
|
||||
old_state = {
|
||||
"links": {
|
||||
"zsh": {
|
||||
str(home_dir / ".zshrc"): str(home_dir.parent / "dotfiles" / "zsh" / ".zshrc"),
|
||||
}
|
||||
}
|
||||
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"):
|
||||
LinkTree.from_state(old_state)
|
||||
|
||||
|
||||
def test_discover_packages_with_flow_package(tmp_path):
|
||||
"""Test discovering the flow package itself from dotfiles."""
|
||||
common = tmp_path / "common"
|
||||
|
||||
# Create flow package
|
||||
flow_pkg = common / "flow" / ".config" / "flow"
|
||||
flow_pkg.mkdir(parents=True)
|
||||
(flow_pkg / "manifest.yaml").write_text("profiles: {}")
|
||||
(flow_pkg / "config").write_text("[repository]\n")
|
||||
|
||||
packages = _discover_packages(tmp_path)
|
||||
|
||||
# Flow package should be discovered like any other
|
||||
assert "flow" in packages
|
||||
assert packages["flow"] == common / "flow"
|
||||
|
||||
|
||||
def test_walk_flow_package(tmp_path):
|
||||
"""Test walking the flow package structure."""
|
||||
flow_pkg = tmp_path / "flow"
|
||||
flow_config = flow_pkg / ".config" / "flow"
|
||||
flow_config.mkdir(parents=True)
|
||||
(flow_config / "manifest.yaml").write_text("profiles: {}")
|
||||
(flow_config / "config").write_text("[repository]\n")
|
||||
|
||||
home = Path("/tmp/fakehome")
|
||||
pairs = list(_walk_package(flow_pkg, home))
|
||||
|
||||
# Should find both files
|
||||
assert len(pairs) == 2
|
||||
targets = [str(t) for _, t in pairs]
|
||||
assert str(home / ".config" / "flow" / "manifest.yaml") in targets
|
||||
assert str(home / ".config" / "flow" / "config") in targets
|
||||
|
||||
|
||||
def test_conflict_detection_before_execution(dotfiles_with_nested, home_dir):
|
||||
"""Test that conflicts are detected before any changes are made."""
|
||||
# Create existing file that conflicts
|
||||
existing = home_dir / ".zshrc"
|
||||
existing.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing.write_text("# existing zshrc")
|
||||
|
||||
# Try to link package that wants .zshrc
|
||||
tree = LinkTree()
|
||||
folder = TreeFolder(tree)
|
||||
|
||||
zsh_source = dotfiles_with_nested / "common" / "zsh"
|
||||
operations = []
|
||||
for src, dst in _walk_package(zsh_source, home_dir):
|
||||
ops = folder.plan_link(src, dst, "zsh")
|
||||
operations.extend(ops)
|
||||
|
||||
# Should detect conflict
|
||||
conflicts = folder.detect_conflicts(operations)
|
||||
assert len(conflicts) > 0
|
||||
assert any("already exists" in c for c in conflicts)
|
||||
|
||||
# Original file should be unchanged
|
||||
assert existing.read_text() == "# existing zshrc"
|
||||
|
||||
|
||||
def test_profile_switching_relink(tmp_path):
|
||||
"""Test switching between profiles maintains correct links."""
|
||||
# Create profiles
|
||||
common = tmp_path / "common"
|
||||
profiles = tmp_path / "profiles"
|
||||
|
||||
# Common zsh
|
||||
(common / "zsh").mkdir(parents=True)
|
||||
(common / "zsh" / ".zshrc").write_text("# common zsh")
|
||||
|
||||
# Work profile override
|
||||
(profiles / "work" / "zsh").mkdir(parents=True)
|
||||
(profiles / "work" / "zsh" / ".zshrc").write_text("# work zsh")
|
||||
|
||||
# Personal profile override
|
||||
(profiles / "personal" / "zsh").mkdir(parents=True)
|
||||
(profiles / "personal" / "zsh" / ".zshrc").write_text("# personal zsh")
|
||||
|
||||
# Test that profile discovery works correctly
|
||||
work_packages = _discover_packages(tmp_path, profile="work")
|
||||
personal_packages = _discover_packages(tmp_path, profile="personal")
|
||||
|
||||
# Both should find zsh, but from different sources
|
||||
assert "zsh" in work_packages
|
||||
assert "zsh" in personal_packages
|
||||
assert work_packages["zsh"] != personal_packages["zsh"]
|
||||
|
||||
|
||||
def test_can_fold_empty_directory():
|
||||
"""Test can_fold with empty directory."""
|
||||
tree = LinkTree()
|
||||
target_dir = Path("/home/user/.config/nvim")
|
||||
|
||||
# Empty directory - should be able to fold
|
||||
assert tree.can_fold(target_dir, "nvim")
|
||||
|
||||
|
||||
def test_can_fold_with_subdirectories():
|
||||
"""Test can_fold with nested directory structure."""
|
||||
tree = LinkTree()
|
||||
base = Path("/home/user/.config/nvim")
|
||||
|
||||
# Add nested files from same package
|
||||
tree.add_link(base / "init.lua", Path("/dotfiles/nvim/init.lua"), "nvim")
|
||||
tree.add_link(base / "lua" / "config.lua", Path("/dotfiles/nvim/lua/config.lua"), "nvim")
|
||||
tree.add_link(base / "lua" / "plugins" / "init.lua", Path("/dotfiles/nvim/lua/plugins/init.lua"), "nvim")
|
||||
|
||||
# Should be able to fold at base level
|
||||
assert tree.can_fold(base, "nvim")
|
||||
|
||||
# Add file from different package
|
||||
tree.add_link(base / "other.lua", Path("/dotfiles/other/other.lua"), "other")
|
||||
|
||||
# Now cannot fold
|
||||
assert not tree.can_fold(base, "nvim")
|
||||
|
||||
|
||||
def test_execute_operations_creates_parent_dirs(tmp_path):
|
||||
"""Test that execute_operations creates necessary parent directories."""
|
||||
tree = LinkTree()
|
||||
folder = TreeFolder(tree)
|
||||
|
||||
source = tmp_path / "dotfiles" / "nvim" / ".config" / "nvim" / "init.lua"
|
||||
target = tmp_path / "home" / ".config" / "nvim" / "init.lua"
|
||||
|
||||
# Create source
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text("-- init")
|
||||
|
||||
# Target parent doesn't exist yet
|
||||
assert not target.parent.exists()
|
||||
|
||||
# Plan and execute
|
||||
ops = folder.plan_link(source, target, "nvim")
|
||||
folder.execute_operations(ops, dry_run=False)
|
||||
|
||||
# Parent should be created
|
||||
assert target.parent.exists()
|
||||
assert target.is_symlink()
|
||||
_load_link_specs_from_state()
|
||||
|
||||
Reference in New Issue
Block a user