working version

This commit is contained in:
2026-02-13 12:15:46 +02:00
parent 1217337fbb
commit 6cff65f288
37 changed files with 2232 additions and 1872 deletions

View File

@@ -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()