chore: remove old code replaced by rewrite

Delete old core modules (action, stow, process, system, variables),
old services (package_defs, ssh), and all tests for deleted code.

191 tests pass with the new codebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-16 05:07:57 +02:00
parent 6ea23e02df
commit 6e9f9c9e30
22 changed files with 0 additions and 3715 deletions

View File

@@ -1,115 +0,0 @@
"""Tests for flow.core.action."""
from flow.core.action import Action, ActionExecutor
from flow.core.console import ConsoleLogger
def test_action_defaults():
a = Action(type="test", description="Test action")
assert a.status == "pending"
assert a.error is None
assert a.skip_on_error is True
assert a.os_filter is None
assert a.data == {}
def test_executor_register_and_execute(capsys):
console = ConsoleLogger()
executor = ActionExecutor(console)
results = []
def handler(data):
results.append(data["key"])
executor.register("test-action", handler)
actions = [
Action(type="test-action", description="Do thing", data={"key": "value1"}),
Action(type="test-action", description="Do another", data={"key": "value2"}),
]
executor.execute(actions, current_os="linux")
assert results == ["value1", "value2"]
assert actions[0].status == "completed"
assert actions[1].status == "completed"
def test_executor_dry_run(capsys):
console = ConsoleLogger()
executor = ActionExecutor(console)
executed = []
executor.register("test", lambda data: executed.append(1))
actions = [Action(type="test", description="Should not run")]
executor.execute(actions, dry_run=True)
assert executed == [] # Nothing executed
out = capsys.readouterr().out
assert "EXECUTION PLAN" in out
def test_executor_skip_on_error(capsys):
console = ConsoleLogger()
executor = ActionExecutor(console)
def failing_handler(data):
raise RuntimeError("boom")
executor.register("fail", failing_handler)
actions = [
Action(type="fail", description="Will fail", skip_on_error=True),
Action(type="fail", description="Should still run", skip_on_error=True),
]
executor.execute(actions, current_os="linux")
assert actions[0].status == "skipped"
assert actions[1].status == "skipped"
def test_executor_critical_failure_stops(capsys):
console = ConsoleLogger()
executor = ActionExecutor(console)
def failing_handler(data):
raise RuntimeError("critical failure")
executor.register("fail", failing_handler)
executor.register("ok", lambda data: None)
actions = [
Action(type="fail", description="Critical", skip_on_error=False),
Action(type="ok", description="Should not run"),
]
executor.execute(actions, current_os="linux")
assert actions[0].status == "failed"
assert actions[1].status == "pending" # Never reached
def test_executor_os_filter(capsys):
console = ConsoleLogger()
executor = ActionExecutor(console)
executed = []
executor.register("test", lambda data: executed.append(data.get("name")))
actions = [
Action(type="test", description="Linux only", data={"name": "linux"}, os_filter="linux"),
Action(type="test", description="macOS only", data={"name": "macos"}, os_filter="macos"),
Action(type="test", description="Any OS", data={"name": "any"}),
]
executor.execute(actions, current_os="linux")
assert "linux" in executed
assert "any" in executed
assert "macos" not in executed
def test_executor_no_handler(capsys):
console = ConsoleLogger()
executor = ActionExecutor(console)
actions = [Action(type="unknown", description="No handler registered")]
executor.execute(actions, current_os="linux")
assert actions[0].status == "skipped"

View File

@@ -1,215 +0,0 @@
"""Tests for flow.commands.bootstrap helpers and schema behavior."""
import os
from pathlib import Path
import pytest
from flow.commands.bootstrap import (
_ensure_required_variables,
_get_profiles,
_install_binary_package,
_normalize_profile_package_entry,
_resolve_package_manager,
_resolve_package_spec,
_resolve_pkg_source_name,
)
from flow.core.config import AppConfig, FlowContext
from flow.core.console import ConsoleLogger
from flow.core.platform import PlatformInfo
@pytest.fixture
def ctx():
return FlowContext(
config=AppConfig(),
manifest={
"packages": [
{
"name": "fd",
"type": "pkg",
"sources": {"apt": "fd-find", "dnf": "fd-find", "brew": "fd"},
},
{
"name": "neovim",
"type": "binary",
"source": "github:neovim/neovim",
"version": "0.10.4",
"asset-pattern": "nvim-{{os}}-{{arch}}.tar.gz",
"platform-map": {"linux-x64": {"os": "linux", "arch": "x64"}},
"install": {"bin": ["bin/nvim"]},
},
]
},
platform=PlatformInfo(os="linux", arch="x64", platform="linux-x64"),
console=ConsoleLogger(),
)
def test_get_profiles_from_manifest(ctx):
ctx.manifest = {"profiles": {"linux": {"os": "linux"}}}
assert "linux" in _get_profiles(ctx)
def test_get_profiles_rejects_environments(ctx):
ctx.manifest = {"environments": {"legacy": {"os": "linux"}}}
with pytest.raises(RuntimeError, match="no longer supported"):
_get_profiles(ctx)
def test_resolve_package_manager_explicit_value(ctx):
assert _resolve_package_manager(ctx, {"os": "linux", "package-manager": "dnf"}) == "dnf"
def test_resolve_package_manager_linux_auto_apt(monkeypatch, ctx):
monkeypatch.setattr("flow.services.bootstrap.shutil.which", lambda name: "/usr/bin/apt" if name == "apt" else None)
assert _resolve_package_manager(ctx, {"os": "linux"}) == "apt"
def test_resolve_package_manager_linux_auto_dnf(monkeypatch, ctx):
monkeypatch.setattr("flow.services.bootstrap.shutil.which", lambda name: "/usr/bin/dnf" if name == "dnf" else None)
assert _resolve_package_manager(ctx, {"os": "linux"}) == "dnf"
def test_resolve_package_manager_requires_os(ctx):
with pytest.raises(RuntimeError, match="must be set"):
_resolve_package_manager(ctx, {})
def test_normalize_package_entry_string():
assert _normalize_profile_package_entry("git") == {"name": "git"}
def test_normalize_package_entry_type_prefix():
assert _normalize_profile_package_entry("cask/wezterm") == {"name": "wezterm", "type": "cask"}
def test_normalize_package_entry_object():
out = _normalize_profile_package_entry({"name": "docker", "allow_sudo": True})
assert out["name"] == "docker"
assert out["allow_sudo"] is True
def test_resolve_package_spec_uses_catalog_type(ctx):
catalog = {
"fd": {
"name": "fd",
"type": "pkg",
"sources": {"apt": "fd-find"},
}
}
resolved = _resolve_package_spec(catalog, {"name": "fd"})
assert resolved["type"] == "pkg"
assert resolved["sources"]["apt"] == "fd-find"
def test_resolve_package_spec_defaults_to_pkg(ctx):
resolved = _resolve_package_spec({}, {"name": "git"})
assert resolved["type"] == "pkg"
def test_resolve_package_spec_profile_override(ctx):
catalog = {
"neovim": {
"name": "neovim",
"type": "binary",
"version": "0.10.4",
}
}
resolved = _resolve_package_spec(catalog, {"name": "neovim", "post-install": "echo ok"})
assert resolved["type"] == "binary"
assert resolved["post-install"] == "echo ok"
def test_resolve_pkg_source_name_with_mapping(ctx):
spec = {"name": "fd", "sources": {"apt": "fd-find", "dnf": "fd-find", "brew": "fd"}}
assert _resolve_pkg_source_name(spec, "apt") == "fd-find"
assert _resolve_pkg_source_name(spec, "dnf") == "fd-find"
assert _resolve_pkg_source_name(spec, "brew") == "fd"
def test_resolve_pkg_source_name_fallback_to_name(ctx):
spec = {"name": "ripgrep", "sources": {"apt": "ripgrep"}}
assert _resolve_pkg_source_name(spec, "dnf") == "ripgrep"
def test_ensure_required_variables_missing_raises():
with pytest.raises(RuntimeError, match="Missing required environment variables"):
_ensure_required_variables({"requires": ["USER_EMAIL", "TARGET_HOSTNAME"]}, {"USER_EMAIL": "a@b"})
def test_ensure_required_variables_accepts_vars(monkeypatch):
env = dict(os.environ)
env["USER_EMAIL"] = "a@b"
env["TARGET_HOSTNAME"] = "devbox"
_ensure_required_variables({"requires": ["USER_EMAIL", "TARGET_HOSTNAME"]}, env)
class _FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return b"archive"
def _patch_binary_download(monkeypatch, after_unpack=None):
monkeypatch.setattr(
"flow.services.bootstrap.urllib.request.urlopen",
lambda *args, **kwargs: _FakeResponse(),
)
def _fake_unpack(_archive, extract_dir):
extracted = Path(extract_dir)
extracted.mkdir(parents=True, exist_ok=True)
if after_unpack:
after_unpack(extracted)
monkeypatch.setattr("flow.services.bootstrap.shutil.unpack_archive", _fake_unpack)
def test_install_binary_package_rejects_absolute_declared_path(monkeypatch, tmp_path, ctx):
absolute_item = tmp_path / "outside-bin"
absolute_item.write_text("binary")
_patch_binary_download(monkeypatch)
monkeypatch.setattr(
"flow.services.bootstrap._copy_install_item",
lambda *args, **kwargs: pytest.fail("_copy_install_item should not be called"),
)
spec = {
"name": "demo",
"type": "binary",
"source": "https://example.invalid/demo",
"asset-pattern": "demo.tar.gz",
"install": {"bin": [str(absolute_item)]},
}
with pytest.raises(RuntimeError, match="must be relative"):
_install_binary_package(ctx, spec, {}, dry_run=False)
def test_install_binary_package_rejects_parent_traversal_declared_path(monkeypatch, ctx):
def _after_unpack(extracted):
(extracted.parent / "escape-bin").write_text("binary")
_patch_binary_download(monkeypatch, after_unpack=_after_unpack)
monkeypatch.setattr(
"flow.services.bootstrap._copy_install_item",
lambda *args, **kwargs: pytest.fail("_copy_install_item should not be called"),
)
spec = {
"name": "demo",
"type": "binary",
"source": "https://example.invalid/demo",
"asset-pattern": "demo.tar.gz",
"install": {"bin": ["../escape-bin"]},
}
with pytest.raises(RuntimeError, match="parent traversal"):
_install_binary_package(ctx, spec, {}, dry_run=False)

View File

@@ -1,74 +0,0 @@
"""Tests for command modules — registration and target parsing."""
from flow.commands.enter import _parse_target, _terminfo_fix_command
from flow.commands.container import _cname, _parse_image_ref
class TestParseTarget:
def test_full_target(self):
user, ns, plat = _parse_target("root@personal@orb")
assert user == "root"
assert ns == "personal"
assert plat == "orb"
def test_no_user(self):
user, ns, plat = _parse_target("personal@orb")
assert user is None
assert ns == "personal"
assert plat == "orb"
def test_namespace_only(self):
user, ns, plat = _parse_target("personal")
assert user is None
assert ns == "personal"
assert plat is None
class TestTerminfoFixCommand:
def test_ghostty_command(self):
cmd = _terminfo_fix_command("xterm-ghostty", "devbox.core.lan")
assert cmd == "infocmp -x xterm-ghostty | ssh devbox.core.lan -- tic -x -"
def test_wezterm_command(self):
cmd = _terminfo_fix_command("wezterm", "user@devbox.core.lan")
assert cmd is not None
assert "wezterm.terminfo" in cmd
assert "ssh user@devbox.core.lan" in cmd
def test_unknown_term(self):
assert _terminfo_fix_command("xterm-256color", "devbox.core.lan") is None
class TestCname:
def test_adds_prefix(self):
assert _cname("api") == "dev-api"
def test_no_double_prefix(self):
assert _cname("dev-api") == "dev-api"
class TestParseImageRef:
def test_simple_image(self):
ref, repo, tag, label = _parse_image_ref("node")
assert ref == "registry.tomastm.com/node:latest"
assert tag == "latest"
def test_tm0_shorthand(self):
ref, repo, tag, label = _parse_image_ref("tm0/node")
assert "registry.tomastm.com" in ref
assert "node" in ref
def test_docker_shorthand(self):
ref, repo, tag, label = _parse_image_ref("docker/python")
assert "docker.io" in ref
assert "python" in ref
def test_with_tag(self):
ref, repo, tag, label = _parse_image_ref("node:20")
assert tag == "20"
assert ":20" in ref
def test_full_registry(self):
ref, repo, tag, label = _parse_image_ref("ghcr.io/user/image:v1")
assert ref == "ghcr.io/user/image:v1"
assert tag == "v1"

View File

@@ -1,77 +0,0 @@
"""Tests for flow.core.config."""
import pytest
from flow.core.config import AppConfig, load_config, load_manifest
def test_load_config_missing_path(tmp_path):
cfg = load_config(tmp_path / "nonexistent")
assert isinstance(cfg, AppConfig)
assert cfg.dotfiles_url == ""
assert cfg.container_registry == "registry.tomastm.com"
assert cfg.dotfiles_pull_before_edit is True
def test_load_config_merged_yaml(tmp_path):
(tmp_path / "10-config.yaml").write_text(
"repository:\n"
" dotfiles-url: git@github.com:user/dots.git\n"
" dotfiles-branch: dev\n"
" pull-before-edit: false\n"
"paths:\n"
" projects-dir: ~/code\n"
"defaults:\n"
" container-registry: my.registry.com\n"
" container-tag: v1\n"
" tmux-session: main\n"
"targets:\n"
" personal: orb personal@orb\n"
" work@ec2: work.ec2.internal ~/.ssh/id_work\n"
)
cfg = load_config(tmp_path)
assert cfg.dotfiles_url == "git@github.com:user/dots.git"
assert cfg.dotfiles_branch == "dev"
assert cfg.dotfiles_pull_before_edit is False
assert cfg.projects_dir == "~/code"
assert cfg.container_registry == "my.registry.com"
assert cfg.container_tag == "v1"
assert cfg.tmux_session == "main"
assert len(cfg.targets) == 2
assert cfg.targets[0].namespace == "personal"
assert cfg.targets[1].ssh_identity == "~/.ssh/id_work"
def test_load_config_pull_before_edit_string_true(tmp_path):
(tmp_path / "10-config.yaml").write_text(
"repository:\n"
" pull-before-edit: yes\n"
)
cfg = load_config(tmp_path)
assert cfg.dotfiles_pull_before_edit is True
def test_load_manifest_missing_path(tmp_path):
result = load_manifest(tmp_path / "nonexistent")
assert result == {}
def test_load_manifest_valid_directory(tmp_path):
(tmp_path / "manifest.yaml").write_text(
"profiles:\n"
" linux-vm:\n"
" os: linux\n"
" hostname: devbox\n"
)
result = load_manifest(tmp_path)
assert result["profiles"]["linux-vm"]["os"] == "linux"
def test_load_manifest_non_dict_raises(tmp_path):
bad = tmp_path / "bad.yaml"
bad.write_text("- a\n- b\n")
with pytest.raises(RuntimeError, match="must contain a mapping"):
load_manifest(bad)

View File

@@ -1,95 +0,0 @@
"""Tests for flow.core.console."""
from flow.core.console import ConsoleLogger
def test_console_info(capsys):
c = ConsoleLogger()
c.info("hello")
out = capsys.readouterr().out
assert "[INFO]" in out
assert "hello" in out
def test_console_warn(capsys):
c = ConsoleLogger()
c.warn("caution")
out = capsys.readouterr().out
assert "[WARN]" in out
assert "caution" in out
def test_console_error(capsys):
c = ConsoleLogger()
c.error("bad thing")
out = capsys.readouterr().out
assert "[ERROR]" in out
assert "bad thing" in out
def test_console_success(capsys):
c = ConsoleLogger()
c.success("done")
out = capsys.readouterr().out
assert "[SUCCESS]" in out
assert "done" in out
def test_console_step_lifecycle(capsys):
c = ConsoleLogger()
c.step_start(1, 3, "Test step")
c.step_command("echo hi")
c.step_output("hi")
c.step_complete("Done")
out = capsys.readouterr().out
assert "Step 1/3" in out
assert "$ echo hi" in out
assert "Done" in out
def test_console_step_skip(capsys):
c = ConsoleLogger()
c.start_time = 0
c.step_skip("not needed")
out = capsys.readouterr().out
assert "Skipped" in out
def test_console_step_fail(capsys):
c = ConsoleLogger()
c.start_time = 0
c.step_fail("exploded")
out = capsys.readouterr().out
assert "Failed" in out
def test_console_table(capsys):
c = ConsoleLogger()
c.table(["NAME", "VALUE"], [["foo", "bar"], ["baz", "qux"]])
out = capsys.readouterr().out
assert "NAME" in out
assert "foo" in out
assert "baz" in out
def test_console_table_empty(capsys):
c = ConsoleLogger()
c.table(["NAME"], [])
out = capsys.readouterr().out
assert out == ""
def test_console_section_header(capsys):
c = ConsoleLogger()
c.section_header("Test", "sub")
out = capsys.readouterr().out
assert "TEST" in out
assert "sub" in out
def test_console_plan_header(capsys):
c = ConsoleLogger()
c.plan_header("My Plan", 5)
out = capsys.readouterr().out
assert "MY PLAN" in out
assert "5 actions" in out

View File

@@ -1,104 +0,0 @@
"""Tests for flow.services.dotfiles discovery and path resolution."""
import pytest
from flow.services.dotfiles import _collect_home_specs, _discover_packages, _resolve_edit_target, _walk_package
from flow.core.config import AppConfig, FlowContext
from flow.core.console import ConsoleLogger
from flow.core.platform import PlatformInfo
def _make_tree(tmp_path):
flow_root = tmp_path
shared = flow_root / "_shared"
(shared / "zsh").mkdir(parents=True)
(shared / "zsh" / ".zshrc").write_text("# zsh")
(shared / "tmux").mkdir(parents=True)
(shared / "tmux" / ".tmux.conf").write_text("# tmux")
profile = flow_root / "work"
(profile / "git").mkdir(parents=True)
(profile / "git" / ".gitconfig").write_text("[user]\nname = Work")
return tmp_path
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 test_discover_packages_shared_only(tmp_path):
tree = _make_tree(tmp_path)
packages = _discover_packages(tree)
assert "zsh" in packages
assert "tmux" in packages
assert "git" not in packages
def test_discover_packages_with_profile(tmp_path):
tree = _make_tree(tmp_path)
packages = _discover_packages(tree, profile="work")
assert "zsh" in packages
assert "tmux" in packages
assert "git" in packages
def test_discover_packages_profile_overrides_shared(tmp_path):
tree = _make_tree(tmp_path)
profile_zsh = tree / "work" / "zsh"
profile_zsh.mkdir(parents=True)
(profile_zsh / ".zshrc").write_text("# work zsh")
with pytest.raises(RuntimeError, match="Conflicting dotfile targets"):
_collect_home_specs(_ctx(), tree, tmp_path / "home", "work", set(), None)
def test_walk_package_returns_relative_paths(tmp_path):
tree = _make_tree(tmp_path)
source = tree / "_shared" / "zsh"
pairs = list(_walk_package(source))
assert len(pairs) == 1
src, rel = pairs[0]
assert src.name == ".zshrc"
assert str(rel) == ".zshrc"
def test_resolve_edit_target_package(tmp_path):
tree = _make_tree(tmp_path)
target = _resolve_edit_target("zsh", dotfiles_dir=tree)
assert target == tree / "_shared" / "zsh"
def test_resolve_edit_target_repo_path(tmp_path):
tree = _make_tree(tmp_path)
target = _resolve_edit_target("_shared/zsh/.zshrc", dotfiles_dir=tree)
assert target == tree / "_shared" / "zsh" / ".zshrc"
def test_resolve_edit_target_rejects_parent_traversal(tmp_path):
tree = _make_tree(tmp_path / "repo")
outside = tmp_path / "outside.txt"
outside.write_text("secret")
target = _resolve_edit_target("../outside.txt", dotfiles_dir=tree)
assert target is None
def test_resolve_edit_target_rejects_nested_repo_escape(tmp_path):
tree = _make_tree(tmp_path / "repo")
outside = tmp_path / "escape.txt"
outside.write_text("secret")
target = _resolve_edit_target("_shared/../../escape.txt", dotfiles_dir=tree)
assert target is None
def test_resolve_edit_target_missing_returns_none(tmp_path):
tree = _make_tree(tmp_path)
assert _resolve_edit_target("does-not-exist", dotfiles_dir=tree) is None

View File

@@ -1,296 +0,0 @@
"""Containerized e2e tests for dotfiles link safety.
These tests are opt-in and run only when FLOW_RUN_E2E_CONTAINER=1.
"""
import os
import shutil
import subprocess
import uuid
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
def _runtime_available(runtime: str) -> bool:
if shutil.which(runtime) is None:
return False
result = subprocess.run(
[runtime, "info"],
capture_output=True,
text=True,
check=False,
)
return result.returncode == 0
def _container_runtime() -> str | None:
preferred = os.environ.get("FLOW_E2E_CONTAINER_RUNTIME")
candidates = [preferred] if preferred else ["podman", "docker"]
for runtime in candidates:
if not runtime:
continue
if _runtime_available(runtime):
return runtime
return None
def _require_container_e2e() -> str:
if os.environ.get("FLOW_RUN_E2E_CONTAINER") != "1":
pytest.skip("Set FLOW_RUN_E2E_CONTAINER=1 to run container e2e tests")
runtime = _container_runtime()
if runtime is None:
pytest.skip("Podman or Docker is required for container e2e tests")
return runtime
@pytest.fixture(scope="module")
def e2e_runtime():
return _require_container_e2e()
@pytest.fixture(scope="module")
def e2e_image(tmp_path_factory, e2e_runtime):
runtime = e2e_runtime
context_dir = tmp_path_factory.mktemp("flow-e2e-docker-context")
dockerfile = context_dir / "Dockerfile"
dockerfile.write_text(
"FROM python:3.11-slim\n"
"RUN apt-get update && apt-get install -y --no-install-recommends sudo && rm -rf /var/lib/apt/lists/*\n"
"RUN pip install --no-cache-dir pyyaml\n"
"RUN useradd -m -s /bin/bash flow\n"
"RUN echo 'flow ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/flow && chmod 440 /etc/sudoers.d/flow\n"
"USER flow\n"
"WORKDIR /workspace\n"
)
tag = f"flow-e2e-{uuid.uuid4().hex[:10]}"
subprocess.run(
[runtime, "build", "-t", tag, str(context_dir)],
check=True,
capture_output=True,
text=True,
)
try:
yield tag
finally:
subprocess.run([runtime, "rmi", "-f", tag], capture_output=True, text=True, check=False)
def _run_in_container(runtime: str, image_tag: str, script: str) -> subprocess.CompletedProcess:
return subprocess.run(
[
runtime,
"run",
"--rm",
"-v",
f"{REPO_ROOT}:/workspace/flow-cli:ro",
image_tag,
"bash",
"-lc",
script,
],
capture_output=True,
text=True,
check=False,
)
def _assert_ok(run: subprocess.CompletedProcess) -> None:
if run.returncode != 0:
raise AssertionError(f"Container e2e failed:\nSTDOUT:\n{run.stdout}\nSTDERR:\n{run.stderr}")
def test_e2e_link_and_undo_with_root_targets(e2e_runtime, e2e_image):
script = r"""
set -euo pipefail
export HOME=/home/flow
export XDG_DATA_HOME=/tmp/xdg-data
export XDG_CONFIG_HOME=/tmp/xdg-config
export XDG_STATE_HOME=/tmp/xdg-state
mkdir -p "$XDG_DATA_HOME/flow/dotfiles" "$XDG_CONFIG_HOME/flow" "$XDG_STATE_HOME/flow"
dot="$XDG_DATA_HOME/flow/dotfiles"
mkdir -p "$dot/_shared/zsh"
mkdir -p "$dot/_shared/rootpkg/_root/tmp"
echo '# managed zshrc' > "$dot/_shared/zsh/.zshrc"
echo 'root-target' > "$dot/_shared/rootpkg/_root/tmp/flow-e2e-root-target"
echo '# before' > "$HOME/.zshrc"
PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles link --force
test -L "$HOME/.zshrc"
test -L /tmp/flow-e2e-root-target
PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles undo
test -f "$HOME/.zshrc"
test ! -L "$HOME/.zshrc"
grep -q '^# before$' "$HOME/.zshrc"
test ! -e /tmp/flow-e2e-root-target
"""
_assert_ok(_run_in_container(e2e_runtime, e2e_image, script))
def test_e2e_dry_run_force_is_read_only_in_both_flag_orders(e2e_runtime, e2e_image):
script = r"""
set -euo pipefail
export HOME=/home/flow
export XDG_DATA_HOME=/tmp/xdg-data
export XDG_CONFIG_HOME=/tmp/xdg-config
export XDG_STATE_HOME=/tmp/xdg-state
mkdir -p "$XDG_DATA_HOME/flow/dotfiles" "$XDG_CONFIG_HOME/flow" "$XDG_STATE_HOME/flow"
dot="$XDG_DATA_HOME/flow/dotfiles"
mkdir -p "$dot/_shared/zsh"
echo '# managed zshrc' > "$dot/_shared/zsh/.zshrc"
echo '# original' > "$HOME/.zshrc"
PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles link --dry-run --force
PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles link --force --dry-run
test -f "$HOME/.zshrc"
test ! -L "$HOME/.zshrc"
grep -q '^# original$' "$HOME/.zshrc"
state="$XDG_STATE_HOME/flow/linked.json"
if [ -f "$state" ]; then
python - "$state" <<'PY'
import json, sys
data = json.load(open(sys.argv[1], encoding="utf-8"))
assert data.get("links", {}) == {}, data
assert "last_transaction" not in data, data
PY
fi
"""
_assert_ok(_run_in_container(e2e_runtime, e2e_image, script))
def test_e2e_unmanaged_conflict_without_force_is_non_destructive(e2e_runtime, e2e_image):
script = r"""
set -euo pipefail
export HOME=/home/flow
export XDG_DATA_HOME=/tmp/xdg-data
export XDG_CONFIG_HOME=/tmp/xdg-config
export XDG_STATE_HOME=/tmp/xdg-state
mkdir -p "$XDG_DATA_HOME/flow/dotfiles" "$XDG_CONFIG_HOME/flow" "$XDG_STATE_HOME/flow"
dot="$XDG_DATA_HOME/flow/dotfiles"
mkdir -p "$dot/_shared/zsh"
echo '# managed zshrc' > "$dot/_shared/zsh/.zshrc"
echo '# user-file' > "$HOME/.zshrc"
set +e
PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles link
rc=$?
set -e
test "$rc" -ne 0
test -f "$HOME/.zshrc"
test ! -L "$HOME/.zshrc"
grep -q '^# user-file$' "$HOME/.zshrc"
"""
_assert_ok(_run_in_container(e2e_runtime, e2e_image, script))
def test_e2e_managed_drift_requires_force(e2e_runtime, e2e_image):
script = r"""
set -euo pipefail
export HOME=/home/flow
export XDG_DATA_HOME=/tmp/xdg-data
export XDG_CONFIG_HOME=/tmp/xdg-config
export XDG_STATE_HOME=/tmp/xdg-state
mkdir -p "$XDG_DATA_HOME/flow/dotfiles" "$XDG_CONFIG_HOME/flow" "$XDG_STATE_HOME/flow"
dot="$XDG_DATA_HOME/flow/dotfiles"
mkdir -p "$dot/_shared/zsh"
echo '# managed zshrc' > "$dot/_shared/zsh/.zshrc"
PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles link --force
test -L "$HOME/.zshrc"
rm -f "$HOME/.zshrc"
echo '# drifted-manual' > "$HOME/.zshrc"
set +e
PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles link
rc=$?
set -e
test "$rc" -ne 0
test -f "$HOME/.zshrc"
test ! -L "$HOME/.zshrc"
grep -q '^# drifted-manual$' "$HOME/.zshrc"
"""
_assert_ok(_run_in_container(e2e_runtime, e2e_image, script))
def test_e2e_directory_conflict_is_atomic_even_with_force(e2e_runtime, e2e_image):
script = r"""
set -euo pipefail
export HOME=/home/flow
export XDG_DATA_HOME=/tmp/xdg-data
export XDG_CONFIG_HOME=/tmp/xdg-config
export XDG_STATE_HOME=/tmp/xdg-state
mkdir -p "$XDG_DATA_HOME/flow/dotfiles" "$XDG_CONFIG_HOME/flow" "$XDG_STATE_HOME/flow"
dot="$XDG_DATA_HOME/flow/dotfiles"
mkdir -p "$dot/_shared/zsh" "$dot/_shared/git"
echo '# managed zshrc' > "$dot/_shared/zsh/.zshrc"
echo '[user]' > "$dot/_shared/git/.gitconfig"
mkdir -p "$HOME/.zshrc"
set +e
PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles link --force
rc=$?
set -e
test "$rc" -ne 0
test -d "$HOME/.zshrc"
test ! -e "$HOME/.gitconfig"
"""
_assert_ok(_run_in_container(e2e_runtime, e2e_image, script))
def test_e2e_undo_after_failed_followup_link_restores_last_transaction(e2e_runtime, e2e_image):
script = r"""
set -euo pipefail
export HOME=/home/flow
export XDG_DATA_HOME=/tmp/xdg-data
export XDG_CONFIG_HOME=/tmp/xdg-config
export XDG_STATE_HOME=/tmp/xdg-state
mkdir -p "$XDG_DATA_HOME/flow/dotfiles" "$XDG_CONFIG_HOME/flow" "$XDG_STATE_HOME/flow"
dot="$XDG_DATA_HOME/flow/dotfiles"
mkdir -p "$dot/_shared/a" "$dot/_shared/b"
echo '# aaa' > "$dot/_shared/a/.a"
echo '# bbb' > "$dot/_shared/b/.b"
echo '# pre-a' > "$HOME/.a"
echo '# pre-b' > "$HOME/.b"
PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles link --force a
test -L "$HOME/.a"
# Turn .b into a directory to force a fatal conflict, while .a stays desired and unchanged.
rm -f "$HOME/.b"
mkdir -p "$HOME/.b"
set +e
PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles link --force
rc=$?
set -e
test "$rc" -ne 0
PYTHONPATH=/workspace/flow-cli/src python -m flow dotfiles undo
test -f "$HOME/.a"
test ! -L "$HOME/.a"
grep -q '^# pre-a$' "$HOME/.a"
"""
_assert_ok(_run_in_container(e2e_runtime, e2e_image, script))

View File

@@ -1,622 +0,0 @@
"""Tests for dotfiles link planning, root markers, and module sources."""
from argparse import Namespace
import json
import subprocess
from pathlib import Path
import pytest
from flow.services.dotfiles import (
LinkSpec,
_collect_home_specs,
_list_profiles,
_load_link_specs_from_state,
_load_state,
_pull_requires_ack,
_resolved_package_source,
_run_sudo,
run_relink,
run_undo,
_save_link_specs_to_state,
_sync_to_desired,
_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.services.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.services.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_root = tmp_path / "_shared" / "nvim"
module_mount = package_root / ".config" / "nvim"
module_mount.mkdir(parents=True)
(module_mount / "_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_root)
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 / "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_root = dotfiles / "_shared" / "nvim"
module_mount = package_root / ".config" / "nvim"
module_mount.mkdir(parents=True)
(module_mount / "_module.yaml").write_text(
f"source: {module_src}\n"
"ref:\n"
" branch: main\n"
)
(package_root / "notes.txt").write_text("ignore me")
monkeypatch.setattr("flow.services.dotfiles.DOTFILES_DIR", dotfiles)
monkeypatch.setattr("flow.services.dotfiles.MODULES_DIR", tmp_path / "modules")
_sync_modules(_ctx(), verbose=False)
resolved = _resolved_package_source(_ctx(), "_shared/nvim", package_root)
assert (resolved / "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 / "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_root = dotfiles / "_shared" / "nvim"
module_mount = package_root / ".config" / "nvim"
module_mount.mkdir(parents=True)
(module_mount / "_module.yaml").write_text(
f"source: {module_src}\n"
"ref:\n"
" branch: main\n"
)
monkeypatch.setattr("flow.services.dotfiles.DOTFILES_DIR", dotfiles)
monkeypatch.setattr("flow.services.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 / "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_root = dotfiles / "_shared" / "nvim"
module_mount = package_root / ".config" / "nvim"
module_mount.mkdir(parents=True)
relative_source = Path("../../../../../module-src")
(module_mount / "_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.services.dotfiles.DOTFILES_DIR", dotfiles)
monkeypatch.setattr("flow.services.dotfiles.MODULES_DIR", tmp_path / "modules")
_sync_modules(_ctx(), verbose=False)
resolved = _resolved_package_source(_ctx(), "_shared/nvim", package_root)
assert (resolved / "init.lua").exists()
def test_module_mount_inherits_directory_path(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 / "init.lua").write_text("-- module")
(module_src / "lua").mkdir()
(module_src / "lua" / "config.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_root = dotfiles / "_shared" / "nvim"
module_mount = package_root / ".config" / "nvim"
module_mount.mkdir(parents=True)
(module_mount / "_module.yaml").write_text(
f"source: {module_src}\n"
"ref:\n"
" branch: main\n"
)
monkeypatch.setattr("flow.services.dotfiles.DOTFILES_DIR", dotfiles)
monkeypatch.setattr("flow.services.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 home / ".config" / "nvim" / "lua" / "config.lua" in specs
assert home / "init.lua" not in specs
assert home / "lua" / "config.lua" not in specs
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
def test_run_relink_uses_transactional_link_path(monkeypatch):
calls = []
monkeypatch.setattr("flow.services.dotfiles._ensure_flow_dir", lambda _ctx: None)
monkeypatch.setattr(
"flow.services.dotfiles.run_unlink",
lambda _ctx, _args: (_ for _ in ()).throw(AssertionError("run_unlink must not be used")),
)
def _fake_run_link(_ctx, args):
calls.append((args.packages, args.profile, args.copy, args.force, args.dry_run))
monkeypatch.setattr("flow.services.dotfiles.run_link", _fake_run_link)
run_relink(_ctx(), Namespace(packages=["git"], profile="work"))
assert calls == [(["git"], "work", False, False, False)]
def test_sync_to_desired_dry_run_force_is_read_only(tmp_path, monkeypatch):
state_file = tmp_path / "linked.json"
monkeypatch.setattr("flow.services.dotfiles.LINKED_STATE", state_file)
monkeypatch.setattr("flow.services.dotfiles._is_in_home", lambda _path, _home: True)
source = tmp_path / "source" / ".zshrc"
source.parent.mkdir(parents=True)
source.write_text("# new")
target = tmp_path / "home" / ".zshrc"
target.parent.mkdir(parents=True)
target.write_text("# old")
desired = {
target: LinkSpec(
source=source,
target=target,
package="_shared/zsh",
)
}
_sync_to_desired(
_ctx(),
desired,
force=True,
dry_run=True,
copy=False,
)
assert target.exists()
assert not target.is_symlink()
assert target.read_text() == "# old"
assert not state_file.exists()
def test_sync_to_desired_force_fails_before_any_writes_on_directory_conflict(tmp_path, monkeypatch):
state_file = tmp_path / "linked.json"
monkeypatch.setattr("flow.services.dotfiles.LINKED_STATE", state_file)
monkeypatch.setattr("flow.services.dotfiles._is_in_home", lambda _path, _home: True)
source_root = tmp_path / "source"
source_root.mkdir()
source_ok = source_root / "ok"
source_ok.write_text("ok")
source_conflict = source_root / "conflict"
source_conflict.write_text("conflict")
home = tmp_path / "home"
home.mkdir()
target_ok = home / "a-file"
target_conflict = home / "z-dir"
target_conflict.mkdir()
desired = {
target_ok: LinkSpec(source=source_ok, target=target_ok, package="_shared/test"),
target_conflict: LinkSpec(source=source_conflict, target=target_conflict, package="_shared/test"),
}
with pytest.raises(RuntimeError, match="cannot be overwritten"):
_sync_to_desired(
_ctx(),
desired,
force=True,
dry_run=False,
copy=False,
)
assert not target_ok.exists()
assert not target_ok.is_symlink()
assert not state_file.exists()
def test_undo_restores_previous_file_and_link_state(tmp_path, monkeypatch):
state_file = tmp_path / "linked.json"
monkeypatch.setattr("flow.services.dotfiles.LINKED_STATE", state_file)
monkeypatch.setattr("flow.services.dotfiles.LINK_BACKUP_DIR", tmp_path / "link-backups")
monkeypatch.setattr("flow.services.dotfiles._is_in_home", lambda _path, _home: True)
source = tmp_path / "source" / ".zshrc"
source.parent.mkdir(parents=True)
source.write_text("# managed")
target = tmp_path / "home" / ".zshrc"
target.parent.mkdir(parents=True)
target.write_text("# previous")
desired = {
target: LinkSpec(
source=source,
target=target,
package="_shared/zsh",
)
}
_sync_to_desired(
_ctx(),
desired,
force=True,
dry_run=False,
copy=False,
)
assert target.is_symlink()
state_after_link = _load_state()
assert "last_transaction" in state_after_link
tx = state_after_link["last_transaction"]
assert isinstance(tx, dict)
assert tx.get("targets")
run_undo(_ctx(), Namespace())
assert target.exists()
assert not target.is_symlink()
assert target.read_text() == "# previous"
state_after_undo = _load_state()
assert state_after_undo.get("links") == {}
assert "last_transaction" not in state_after_undo
def test_sync_to_desired_persists_incomplete_transaction_on_failure(tmp_path, monkeypatch):
state_file = tmp_path / "linked.json"
monkeypatch.setattr("flow.services.dotfiles.LINKED_STATE", state_file)
monkeypatch.setattr("flow.services.dotfiles.LINK_BACKUP_DIR", tmp_path / "link-backups")
monkeypatch.setattr("flow.services.dotfiles._is_in_home", lambda _path, _home: True)
source = tmp_path / "source"
source.mkdir()
src_a = source / "a"
src_b = source / "b"
src_a.write_text("a")
src_b.write_text("b")
home = tmp_path / "home"
home.mkdir()
target_a = home / "a"
target_b = home / "b"
target_a.write_text("old-a")
desired = {
target_a: LinkSpec(source=src_a, target=target_a, package="_shared/test"),
target_b: LinkSpec(source=src_b, target=target_b, package="_shared/test"),
}
call_count = {"n": 0}
def _failing_apply(spec, *, copy, dry_run): # noqa: ARG001
call_count["n"] += 1
if call_count["n"] == 2:
raise RuntimeError("simulated failure")
spec.target.parent.mkdir(parents=True, exist_ok=True)
spec.target.symlink_to(spec.source)
return True
monkeypatch.setattr("flow.services.dotfiles._apply_link_spec", _failing_apply)
with pytest.raises(RuntimeError, match="simulated failure"):
_sync_to_desired(
_ctx(),
desired,
force=True,
dry_run=False,
copy=False,
)
state_after_failure = _load_state()
tx = state_after_failure.get("last_transaction")
assert isinstance(tx, dict)
assert tx.get("incomplete") is True
assert target_a.is_symlink()
run_undo(_ctx(), Namespace())
assert target_a.exists()
assert not target_a.is_symlink()
assert target_a.read_text() == "old-a"
assert not target_b.exists()
assert _load_state().get("links") == {}
def test_sync_to_desired_requires_force_to_remove_modified_managed_target(tmp_path, monkeypatch):
state_file = tmp_path / "linked.json"
monkeypatch.setattr("flow.services.dotfiles.LINKED_STATE", state_file)
monkeypatch.setattr("flow.services.dotfiles._is_in_home", lambda _path, _home: True)
source = tmp_path / "source" / ".old"
source.parent.mkdir(parents=True)
source.write_text("managed")
target = tmp_path / "home" / ".zshrc"
target.parent.mkdir(parents=True)
target.write_text("user-edited")
_save_link_specs_to_state(
{
target: LinkSpec(
source=source,
target=target,
package="_shared/zsh",
)
}
)
with pytest.raises(RuntimeError, match="Use --force"):
_sync_to_desired(
_ctx(),
{},
force=False,
dry_run=False,
copy=False,
)
assert target.exists()
assert not target.is_symlink()
assert target.read_text() == "user-edited"
assert target in _load_link_specs_from_state()
def test_sync_to_desired_requires_force_to_replace_modified_managed_target(tmp_path, monkeypatch):
state_file = tmp_path / "linked.json"
monkeypatch.setattr("flow.services.dotfiles.LINKED_STATE", state_file)
monkeypatch.setattr("flow.services.dotfiles._is_in_home", lambda _path, _home: True)
old_source = tmp_path / "source" / ".old"
new_source = tmp_path / "source" / ".new"
old_source.parent.mkdir(parents=True)
old_source.write_text("managed-old")
new_source.write_text("managed-new")
target = tmp_path / "home" / ".gitconfig"
target.parent.mkdir(parents=True)
target.write_text("manual-file")
_save_link_specs_to_state(
{
target: LinkSpec(
source=old_source,
target=target,
package="_shared/git",
)
}
)
desired = {
target: LinkSpec(
source=new_source,
target=target,
package="_shared/git",
)
}
with pytest.raises(RuntimeError, match="Use --force"):
_sync_to_desired(
_ctx(),
desired,
force=False,
dry_run=False,
copy=False,
)
assert target.exists()
assert not target.is_symlink()
assert target.read_text() == "manual-file"
assert _load_link_specs_from_state()[target].source == old_source
def test_run_sudo_errors_when_binary_missing(monkeypatch):
monkeypatch.setattr("flow.services.dotfiles.shutil.which", lambda _name: None)
with pytest.raises(RuntimeError, match="sudo is required"):
_run_sudo(["true"], dry_run=False)

View File

@@ -1,45 +0,0 @@
"""Tests for flow.commands.package."""
from types import SimpleNamespace
from flow.commands import package
def test_load_installed_returns_empty_on_malformed_json(tmp_path, monkeypatch):
state_file = tmp_path / "installed.json"
state_file.write_text("{broken", encoding="utf-8")
monkeypatch.setattr(package, "INSTALLED_STATE", state_file)
assert package._load_installed() == {}
def test_load_installed_returns_empty_on_non_mapping_json(tmp_path, monkeypatch):
state_file = tmp_path / "installed.json"
state_file.write_text('["neovim"]', encoding="utf-8")
monkeypatch.setattr(package, "INSTALLED_STATE", state_file)
assert package._load_installed() == {}
class _ConsoleCapture:
def __init__(self):
self.info_messages = []
def info(self, message):
self.info_messages.append(message)
def table(self, _headers, _rows):
raise AssertionError("table() should not be called when installed state is malformed")
def test_run_list_handles_malformed_installed_state(tmp_path, monkeypatch):
state_file = tmp_path / "installed.json"
state_file.write_text("{oops", encoding="utf-8")
monkeypatch.setattr(package, "INSTALLED_STATE", state_file)
monkeypatch.setattr(package, "_get_definitions", lambda _ctx: {})
ctx = SimpleNamespace(console=_ConsoleCapture())
package.run_list(ctx, SimpleNamespace(all=False))
assert ctx.console.info_messages == ["No packages installed."]

View File

@@ -1,77 +0,0 @@
"""Tests for flow.core.paths."""
from pathlib import Path
from flow.core.paths import (
CONFIG_DIR,
CONFIG_FILE,
DATA_DIR,
DOTFILES_DIR,
INSTALLED_STATE,
LINKED_STATE,
MANIFEST_FILE,
MODULES_DIR,
PACKAGES_DIR,
SCRATCH_DIR,
STATE_DIR,
ensure_dirs,
)
def test_config_dir_under_home():
assert ".config/flow" in str(CONFIG_DIR)
def test_data_dir_under_home():
assert ".local/share/flow" in str(DATA_DIR)
def test_state_dir_under_home():
assert ".local/state/flow" in str(STATE_DIR)
def test_manifest_file_in_config_dir():
assert MANIFEST_FILE == CONFIG_DIR / "manifest.yaml"
def test_config_file_in_config_dir():
assert CONFIG_FILE == CONFIG_DIR / "config.yaml"
def test_dotfiles_dir():
assert DOTFILES_DIR == DATA_DIR / "dotfiles"
def test_packages_dir():
assert PACKAGES_DIR == DATA_DIR / "packages"
def test_modules_dir():
assert MODULES_DIR == DATA_DIR / "modules"
def test_scratch_dir():
assert SCRATCH_DIR == DATA_DIR / "scratch"
def test_state_files():
assert LINKED_STATE == STATE_DIR / "linked.json"
assert INSTALLED_STATE == STATE_DIR / "installed.json"
def test_ensure_dirs(tmp_path, monkeypatch):
monkeypatch.setattr("flow.core.paths.CONFIG_DIR", tmp_path / "config")
monkeypatch.setattr("flow.core.paths.DATA_DIR", tmp_path / "data")
monkeypatch.setattr("flow.core.paths.STATE_DIR", tmp_path / "state")
monkeypatch.setattr("flow.core.paths.MODULES_DIR", tmp_path / "data" / "modules")
monkeypatch.setattr("flow.core.paths.PACKAGES_DIR", tmp_path / "data" / "packages")
monkeypatch.setattr("flow.core.paths.SCRATCH_DIR", tmp_path / "data" / "scratch")
ensure_dirs()
assert (tmp_path / "config").is_dir()
assert (tmp_path / "data").is_dir()
assert (tmp_path / "state").is_dir()
assert (tmp_path / "data" / "modules").is_dir()
assert (tmp_path / "data" / "packages").is_dir()
assert (tmp_path / "data" / "scratch").is_dir()

View File

@@ -1,29 +0,0 @@
"""Tests for flow.core.platform."""
import platform as _platform
import pytest
from flow.core.platform import PlatformInfo, detect_platform
def test_detect_platform_returns_platforminfo():
info = detect_platform()
assert isinstance(info, PlatformInfo)
assert info.os in ("linux", "macos")
assert info.arch in ("x64", "arm64")
assert info.platform == f"{info.os}-{info.arch}"
def test_detect_platform_unsupported_os(monkeypatch):
monkeypatch.setattr(_platform, "system", lambda: "FreeBSD")
with pytest.raises(RuntimeError, match="Unsupported operating system"):
detect_platform()
def test_detect_platform_unsupported_arch(monkeypatch):
monkeypatch.setattr(_platform, "machine", lambda: "mips")
with pytest.raises(RuntimeError, match="Unsupported architecture"):
detect_platform()

View File

@@ -1,81 +0,0 @@
"""Tests for self-hosted merged YAML config loading."""
from pathlib import Path
import pytest
from flow.core import paths as paths_module
from flow.core.config import load_config, load_manifest
@pytest.fixture
def mock_roots(tmp_path, monkeypatch):
local_root = tmp_path / "local-flow"
dotfiles_root = tmp_path / "dotfiles" / "_shared" / "flow" / ".config" / "flow"
local_root.mkdir(parents=True)
dotfiles_root.mkdir(parents=True)
monkeypatch.setattr(paths_module, "CONFIG_DIR", local_root)
monkeypatch.setattr(paths_module, "DOTFILES_FLOW_CONFIG", dotfiles_root)
return {
"local": local_root,
"dotfiles": dotfiles_root,
}
def test_load_manifest_priority_dotfiles_first(mock_roots):
(mock_roots["local"] / "profiles.yaml").write_text("profiles:\n local: {os: linux}\n")
(mock_roots["dotfiles"] / "profiles.yaml").write_text("profiles:\n dotfiles: {os: macos}\n")
manifest = load_manifest()
assert "dotfiles" in manifest.get("profiles", {})
assert "local" not in manifest.get("profiles", {})
def test_load_manifest_fallback_to_local(mock_roots):
(mock_roots["local"] / "profiles.yaml").write_text("profiles:\n local: {os: linux}\n")
# Remove dotfiles yaml file so local takes over.
dot_yaml = mock_roots["dotfiles"] / "profiles.yaml"
if dot_yaml.exists():
dot_yaml.unlink()
manifest = load_manifest()
assert "local" in manifest.get("profiles", {})
def test_load_manifest_empty_when_none_exist(mock_roots):
manifest = load_manifest()
assert manifest == {}
def test_load_config_from_merged_yaml(mock_roots):
(mock_roots["dotfiles"] / "config.yaml").write_text(
"repository:\n"
" dotfiles-url: git@github.com:user/dotfiles.git\n"
"defaults:\n"
" container-registry: registry.example.com\n"
)
cfg = load_config()
assert cfg.dotfiles_url == "git@github.com:user/dotfiles.git"
assert cfg.container_registry == "registry.example.com"
def test_yaml_merge_is_alphabetical_last_writer_wins(mock_roots):
(mock_roots["local"] / "10-a.yaml").write_text("profiles:\n a: {os: linux}\n")
(mock_roots["local"] / "20-b.yaml").write_text("profiles:\n b: {os: linux}\n")
manifest = load_manifest(mock_roots["local"])
assert "b" in manifest.get("profiles", {})
assert "a" not in manifest.get("profiles", {})
def test_explicit_file_path_loads_single_yaml(tmp_path):
one_file = tmp_path / "single.yaml"
one_file.write_text("profiles:\n only: {os: linux}\n")
manifest = load_manifest(one_file)
assert "only" in manifest["profiles"]

View File

@@ -1,310 +0,0 @@
"""Tests for flow.core.stow — GNU Stow-style tree folding/unfolding."""
import os
from pathlib import Path
import pytest
from flow.core.stow import LinkOperation, LinkTree, TreeFolder
@pytest.fixture
def temp_home(tmp_path):
"""Create a temporary home directory."""
home = tmp_path / "home"
home.mkdir()
return home
@pytest.fixture
def temp_dotfiles(tmp_path):
"""Create a temporary dotfiles repository."""
dotfiles = tmp_path / "dotfiles"
dotfiles.mkdir()
return dotfiles
def test_linktree_add_remove():
"""Test basic LinkTree operations."""
tree = LinkTree()
source = Path("/dotfiles/zsh/.zshrc")
target = Path("/home/user/.zshrc")
tree.add_link(target, source, "zsh", is_dir_link=False)
assert target in tree.links
assert tree.links[target] == source
assert tree.packages[target] == "zsh"
assert not tree.is_directory_link(target)
tree.remove_link(target)
assert target not in tree.links
assert target not in tree.packages
def test_linktree_directory_link():
"""Test directory link tracking."""
tree = LinkTree()
source = Path("/dotfiles/nvim/.config/nvim")
target = Path("/home/user/.config/nvim")
tree.add_link(target, source, "nvim", is_dir_link=True)
assert tree.is_directory_link(target)
def test_linktree_can_fold_single_package():
"""Test can_fold with single package."""
tree = LinkTree()
target_dir = Path("/home/user/.config/nvim")
# Add files from same package
tree.add_link(target_dir / "init.lua", Path("/dotfiles/nvim/.config/nvim/init.lua"), "nvim")
tree.add_link(target_dir / "lua" / "config.lua", Path("/dotfiles/nvim/.config/nvim/lua/config.lua"), "nvim")
# Should be able to fold since all files are from same package
assert tree.can_fold(target_dir, "nvim")
def test_linktree_can_fold_multiple_packages():
"""Test can_fold with multiple packages."""
tree = LinkTree()
target_dir = Path("/home/user/.config")
# Add files from different packages
tree.add_link(target_dir / "nvim", Path("/dotfiles/nvim/.config/nvim"), "nvim", is_dir_link=True)
tree.add_link(target_dir / "tmux", Path("/dotfiles/tmux/.config/tmux"), "tmux", is_dir_link=True)
# Cannot fold .config since it has files from multiple packages
assert not tree.can_fold(target_dir, "nvim")
def test_linktree_from_state_old_format_rejected():
"""Old state format should be rejected (no backward compatibility)."""
state = {
"links": {
"zsh": {
"/home/user/.zshrc": "/dotfiles/zsh/.zshrc",
"/home/user/.zshenv": "/dotfiles/zsh/.zshenv",
}
}
}
with pytest.raises(RuntimeError, match="Unsupported linked state format"):
LinkTree.from_state(state)
def test_linktree_from_state_new_format():
"""Test loading from new state format (with is_directory_link)."""
state = {
"version": 2,
"links": {
"nvim": {
"/home/user/.config/nvim": {
"source": "/dotfiles/nvim/.config/nvim",
"is_directory_link": True,
}
}
}
}
tree = LinkTree.from_state(state)
target = Path("/home/user/.config/nvim")
assert target in tree.links
assert tree.is_directory_link(target)
assert tree.packages[target] == "nvim"
def test_linktree_to_state():
"""Test converting LinkTree to state format."""
tree = LinkTree()
tree.add_link(
Path("/home/user/.config/nvim"),
Path("/dotfiles/nvim/.config/nvim"),
"nvim",
is_dir_link=True,
)
tree.add_link(
Path("/home/user/.zshrc"),
Path("/dotfiles/zsh/.zshrc"),
"zsh",
is_dir_link=False,
)
state = tree.to_state()
assert state["version"] == 2
assert "nvim" in state["links"]
assert "zsh" in state["links"]
nvim_link = state["links"]["nvim"]["/home/user/.config/nvim"]
assert nvim_link["is_directory_link"] is True
zsh_link = state["links"]["zsh"]["/home/user/.zshrc"]
assert zsh_link["is_directory_link"] is False
def test_treefolder_plan_link_simple(temp_home, temp_dotfiles):
"""Test planning a simple file link."""
tree = LinkTree()
folder = TreeFolder(tree)
source = temp_dotfiles / "zsh" / ".zshrc"
target = temp_home / ".zshrc"
# Create source file
source.parent.mkdir(parents=True)
source.write_text("# zshrc")
ops = folder.plan_link(source, target, "zsh")
assert len(ops) == 1
assert ops[0].type == "create_symlink"
assert ops[0].source == source
assert ops[0].target == target
assert ops[0].package == "zsh"
def test_treefolder_detect_conflicts_existing_file(temp_home, temp_dotfiles):
"""Test conflict detection for existing files."""
tree = LinkTree()
folder = TreeFolder(tree)
source = temp_dotfiles / "zsh" / ".zshrc"
target = temp_home / ".zshrc"
# Create existing file
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("# existing")
source.parent.mkdir(parents=True)
source.write_text("# zshrc")
ops = folder.plan_link(source, target, "zsh")
conflicts = folder.detect_conflicts(ops)
assert len(conflicts) == 1
assert "already exists" in conflicts[0]
def test_treefolder_detect_conflicts_different_package(temp_home, temp_dotfiles):
"""Test conflict detection for links from different packages."""
tree = LinkTree()
target = temp_home / ".bashrc"
# Add existing link from different package
tree.add_link(target, Path("/dotfiles/bash/.bashrc"), "bash")
folder = TreeFolder(tree)
source = temp_dotfiles / "zsh" / ".bashrc"
source.parent.mkdir(parents=True)
source.write_text("# bashrc")
ops = folder.plan_link(source, target, "zsh")
conflicts = folder.detect_conflicts(ops)
assert len(conflicts) == 1
assert "bash" in conflicts[0]
def test_treefolder_execute_operations_dry_run(temp_home, temp_dotfiles, capsys):
"""Test dry-run mode."""
tree = LinkTree()
folder = TreeFolder(tree)
source = temp_dotfiles / "zsh" / ".zshrc"
target = temp_home / ".zshrc"
source.parent.mkdir(parents=True)
source.write_text("# zshrc")
ops = folder.plan_link(source, target, "zsh")
folder.execute_operations(ops, dry_run=True)
# Check output
captured = capsys.readouterr()
assert "FILE LINK" in captured.out
assert str(target) in captured.out
# No actual symlink created
assert not target.exists()
def test_treefolder_execute_operations_create_symlink(temp_home, temp_dotfiles):
"""Test creating actual symlinks."""
tree = LinkTree()
folder = TreeFolder(tree)
source = temp_dotfiles / "zsh" / ".zshrc"
target = temp_home / ".zshrc"
source.parent.mkdir(parents=True)
source.write_text("# zshrc")
ops = folder.plan_link(source, target, "zsh")
folder.execute_operations(ops, dry_run=False)
# Check symlink created
assert target.is_symlink()
assert target.resolve() == source.resolve()
# Check tree updated
assert target in folder.tree.links
def test_treefolder_plan_unlink(temp_home, temp_dotfiles):
"""Test planning unlink operations."""
tree = LinkTree()
target = temp_home / ".zshrc"
source = temp_dotfiles / "zsh" / ".zshrc"
tree.add_link(target, source, "zsh")
folder = TreeFolder(tree)
ops = folder.plan_unlink(target, "zsh")
assert len(ops) == 1
assert ops[0].type == "remove"
assert ops[0].target == target
def test_treefolder_plan_unlink_directory_link(temp_home, temp_dotfiles):
"""Test planning unlink for directory symlink."""
tree = LinkTree()
target = temp_home / ".config" / "nvim"
source = temp_dotfiles / "nvim" / ".config" / "nvim"
tree.add_link(target, source, "nvim", is_dir_link=True)
folder = TreeFolder(tree)
ops = folder.plan_unlink(target, "nvim")
# Should remove the directory link
assert len(ops) >= 1
assert ops[-1].type == "remove"
assert ops[-1].is_directory_link
def test_linkoperation_str():
"""Test LinkOperation string representation."""
op1 = LinkOperation(
type="create_symlink",
source=Path("/src"),
target=Path("/dst"),
package="test",
is_directory_link=False,
)
assert "FILE LINK" in str(op1)
op2 = LinkOperation(
type="create_symlink",
source=Path("/src"),
target=Path("/dst"),
package="test",
is_directory_link=True,
)
assert "DIR LINK" in str(op2)
op3 = LinkOperation(
type="unfold",
source=Path("/src"),
target=Path("/dst"),
package="test",
)
assert "UNFOLD" in str(op3)

View File

@@ -1,88 +0,0 @@
"""Tests for flow.commands.sync."""
from types import SimpleNamespace
import pytest
from flow.commands import sync
def _git_clean_repo(_repo, *cmd, capture=True):
_ = capture
if cmd == ("rev-parse", "--abbrev-ref", "HEAD"):
return SimpleNamespace(returncode=0, stdout="main\n")
if cmd == ("diff", "--quiet"):
return SimpleNamespace(returncode=0, stdout="")
if cmd == ("diff", "--cached", "--quiet"):
return SimpleNamespace(returncode=0, stdout="")
if cmd == ("ls-files", "--others", "--exclude-standard"):
return SimpleNamespace(returncode=0, stdout="")
if cmd == ("rev-parse", "--abbrev-ref", "main@{u}"):
return SimpleNamespace(returncode=0, stdout="origin/main\n")
if cmd == ("rev-list", "--oneline", "main@{u}..main"):
return SimpleNamespace(returncode=0, stdout="")
if cmd == ("for-each-ref", "--format=%(refname:short)", "refs/heads"):
return SimpleNamespace(returncode=0, stdout="main\n")
raise AssertionError(f"Unexpected git command: {cmd!r}")
@pytest.mark.parametrize("git_style", ["dir", "file"])
def test_check_repo_detects_git_dir_and_worktree_file(tmp_path, monkeypatch, git_style):
repo = tmp_path / "repo"
repo.mkdir()
if git_style == "dir":
(repo / ".git").mkdir()
else:
(repo / ".git").write_text("gitdir: /tmp/worktrees/repo\n", encoding="utf-8")
monkeypatch.setattr(sync, "_git", _git_clean_repo)
name, issues = sync._check_repo(str(repo), do_fetch=False)
assert name == "repo"
assert issues == []
class _ConsoleCapture:
def __init__(self):
self.info_messages = []
self.error_messages = []
self.success_messages = []
def info(self, message):
self.info_messages.append(message)
def error(self, message):
self.error_messages.append(message)
def success(self, message):
self.success_messages.append(message)
def test_run_fetch_includes_worktree_style_repo(tmp_path, monkeypatch):
projects = tmp_path / "projects"
projects.mkdir()
worktree_repo = projects / "worktree"
worktree_repo.mkdir()
(worktree_repo / ".git").write_text("gitdir: /tmp/worktrees/worktree\n", encoding="utf-8")
(projects / "non_git").mkdir()
calls = []
def _git_fetch(repo, *cmd, capture=True):
_ = capture
calls.append((repo, cmd))
return SimpleNamespace(returncode=0, stdout="")
monkeypatch.setattr(sync, "_git", _git_fetch)
console = _ConsoleCapture()
ctx = SimpleNamespace(config=SimpleNamespace(projects_dir=str(projects)), console=console)
sync.run_fetch(ctx, SimpleNamespace())
assert calls == [(str(worktree_repo), ("fetch", "--all", "--quiet"))]
assert console.success_messages == ["All remotes fetched."]

View File

@@ -1,57 +0,0 @@
"""Tests for flow.core.variables."""
from flow.core.variables import substitute, substitute_template
def test_substitute_dollar():
result = substitute("hello $NAME", {"NAME": "world"})
assert result == "hello world"
def test_substitute_braces():
result = substitute("hello ${NAME}", {"NAME": "world"})
assert result == "hello world"
def test_substitute_multiple():
result = substitute("$A and ${B}", {"A": "1", "B": "2"})
assert result == "1 and 2"
def test_substitute_home():
result = substitute("dir=$HOME", {})
assert "$HOME" not in result
def test_substitute_user():
import os
result = substitute("u=$USER", {})
assert result == f"u={os.getenv('USER', '')}"
def test_substitute_non_string():
assert substitute(123, {}) == 123
def test_substitute_template_basic():
result = substitute_template("nvim-{{os}}-{{arch}}.tar.gz", {"os": "linux", "arch": "x86_64"})
assert result == "nvim-linux-x86_64.tar.gz"
def test_substitute_template_missing_key():
result = substitute_template("{{missing}}", {})
assert result == "{{missing}}"
def test_substitute_template_non_string():
assert substitute_template(42, {}) == 42
def test_substitute_template_no_placeholders():
result = substitute_template("plain text", {"os": "linux"})
assert result == "plain text"
def test_substitute_template_env_namespace():
result = substitute_template("{{ env.USER_EMAIL }}", {"env": {"USER_EMAIL": "you@example.com"}})
assert result == "you@example.com"