Files
flow/tests/test_service_dotfiles.py
Tomas Mirchev 6b7a48bb20 refactor: post-review hardening pass
Independent re-audit surfaced 11 follow-ups across two layers of review
(my fresh-eyes read + a parallel agent pass). Bundled into a single
commit because changes are small and intertwined.

Symlink / state consistency:
- FileSystem.same_symlink now uses raw readlink() instead of resolve().
  Aligns the three sites that ask "is this our link?" (_load_state,
  _check_overwrite_safe, remove_symlink) on a single rule: exact-readlink
  match. Following symlink chains would let externally-modified links
  pass as ours and be silently overwritten.
- LinkedState.from_dict raises ConfigError on missing required fields
  instead of .get(..., False) silent defaults. Matches InstalledState.
- LinkOp.source is now consistently None for remove_link ops; the
  service derives expected_source from current.links. Removes the
  asymmetry between in-state and orphan-broken removal ops.
- _apply_plan: rename shadowing local from link_target to spec.

Fail loud:
- _xdg() now treats XDG_CONFIG_HOME="" the same as unset. Previously
  an empty env var produced Path("") and state files were written to
  $PWD instead of ~/.local/state/flow.
- _resolve_target raises PlanConflict when a package contains a bare
  _root entry (no path components) instead of silently dropping it.
- _strip_prefix raises FlowError when a declared install path does not
  start with its section's expected prefix (e.g. etc/foo under install.bin).

Speculative abstraction removed (CLAUDE.md):
- core.template.substitute (the $VAR form) had no production callers --
  deleted along with its tests; only the {{var}} form remains.
- SetupModule base class -- five subclasses, no shared behaviour, no
  polymorphic call site. Deleted.
- Profile.arch -- parsed but never read. Deleted.
- PackagePlan.pm_command -- set but never read. Deleted (service
  recomputes pm_install_command at the call site).
- FileSystem.ensure_dir(mode=...), .copy_file(sudo=...), .read_text(
  default=...) -- no callers. Deleted along with their test.
- bootstrap _execute_action: the upfront `phase not in VALID_PHASES`
  check duplicated the trailing exhaustive raise. Kept the trailing
  raise as the single source of truth; phase set still documented in
  VALID_PHASES.

Completion ctx threading:
- Removed _config()/_manifest() helpers that re-loaded from disk on
  every completion call. _list_targets, _list_namespaces, _list_platforms,
  _list_bootstrap_profiles, _list_manifest_packages now take ctx and
  read from ctx.config / ctx.manifest.

Test coverage and e2e:
- e2e container test exercises a real `flow dotfiles link` (no dry-run)
  and asserts the resulting symlinks point into the dotfiles dir;
  reruns to verify idempotency.
- New tests: LinkedState corrupt-state ConfigError, LinkedState bad-version
  ConfigError, bare-_root PlanConflict, service-level _root path routing
  + skip semantics.
- 11 stale test imports removed (pyflakes clean across src/ + tests/).

357 unit tests + 1 e2e (gated) all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:23:06 +03:00

765 lines
28 KiB
Python

"""Tests for DotfilesService."""
import json
import time
from pathlib import Path
import pytest
import yaml
from flow.core.config import AppConfig, FlowContext
from flow.core.console import Console
from flow.core.errors import FlowError
from flow.core.platform import PlatformInfo
from flow.core.runtime import SystemRuntime
from flow.core import paths
from flow.services.dotfiles import DotfilesService
from tests.fakes import FakeRunner
class _CapturingConsole(Console):
"""Console that records every warn() call for assertions."""
def __init__(self):
super().__init__(color=False)
self.warnings: list[str] = []
def warn(self, msg: str) -> None:
self.warnings.append(msg)
super().warn(msg)
def _make_ctx(tmp_path, console=None):
"""Build a FlowContext for testing."""
return FlowContext(
config=AppConfig(),
manifest={},
platform=PlatformInfo(),
console=console or Console(color=False),
runtime=SystemRuntime(),
)
def _setup_dotfiles(tmp_path, packages_files):
"""Set up a fake dotfiles directory structure.
packages_files: dict of {package_name: {relative_path: content}}
"""
dotfiles = tmp_path / "dotfiles"
shared = dotfiles / "_shared"
for pkg_name, files in packages_files.items():
pkg_dir = shared / pkg_name
for rel_path, content in files.items():
file_path = pkg_dir / rel_path
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content)
return dotfiles
class TestDotfilesServiceLink:
def test_link_creates_symlinks(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh config"},
"git": {".config/git/config": "[user]\n name = test"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
svc.link()
assert (home / ".zshrc").is_symlink()
assert (home / ".config" / "git" / "config").is_symlink()
def test_link_with_module(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = tmp_path / "dotfiles"
modules = tmp_path / "modules"
# Set up package with _module.yaml
pkg_dir = dotfiles / "_shared" / "nvim"
config_dir = pkg_dir / ".config" / "nvim"
config_dir.mkdir(parents=True)
(config_dir / "_module.yaml").write_text(yaml.dump({
"source": "github:test/nvim-config",
"ref": {"branch": "main"},
}))
# Set up local file outside mount path
(pkg_dir / ".local" / "bin").mkdir(parents=True)
(pkg_dir / ".local" / "bin" / "nvim-wrapper").write_text("#!/bin/sh")
# Set up cloned module
module_dir = modules / "_shared--nvim"
module_dir.mkdir(parents=True)
(module_dir / "init.lua").write_text("-- init")
(module_dir / "lua").mkdir()
(module_dir / "lua" / "plugins.lua").write_text("-- plugins")
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", modules)
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
svc.link()
# Module files should be linked under .config/nvim/
assert (home / ".config" / "nvim" / "init.lua").is_symlink()
assert (home / ".config" / "nvim" / "lua" / "plugins.lua").is_symlink()
# Local file outside mount path should be linked
assert (home / ".local" / "bin" / "nvim-wrapper").is_symlink()
def test_unlink_removes_symlinks(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
# Link first
svc.link()
assert (home / ".zshrc").is_symlink()
# Then unlink
svc.unlink()
assert not (home / ".zshrc").exists()
def test_link_dry_run_no_changes(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
svc.link(dry_run=True)
# No symlinks should exist
assert not (home / ".zshrc").exists()
def test_status_shows_packages(self, tmp_path, monkeypatch, capsys):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
# Link first to populate state
svc.link()
# Check status
svc.status()
output = capsys.readouterr().out
assert "zsh" in output
def test_relink_does_not_remove_unmanaged_file(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
svc.link()
target = home / ".zshrc"
target.unlink()
target.write_text("user managed file")
svc.link()
assert target.read_text() == "user managed file"
assert not target.is_symlink()
def test_status_shows_module_info(self, tmp_path, monkeypatch, capsys):
home = tmp_path / "home"
home.mkdir()
dotfiles = tmp_path / "dotfiles"
modules = tmp_path / "modules"
# Set up package with _module.yaml
pkg_dir = dotfiles / "_shared" / "nvim"
config_dir = pkg_dir / ".config" / "nvim"
config_dir.mkdir(parents=True)
(config_dir / "_module.yaml").write_text(yaml.dump({
"source": "github:test/nvim-config",
"ref": {"branch": "main"},
}))
# Set up cloned module
module_dir = modules / "_shared--nvim"
module_dir.mkdir(parents=True)
(module_dir / "init.lua").write_text("-- init")
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", modules)
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
svc.link()
svc.status()
output = capsys.readouterr().out
assert "nvim" in output
assert "branch:main" in output
def test_repos_list_shows_dotfiles_and_modules(self, tmp_path, monkeypatch, capsys):
home = tmp_path / "home"
home.mkdir()
dotfiles = tmp_path / "dotfiles"
modules = tmp_path / "modules"
pkg_dir = dotfiles / "_shared" / "nvim"
config_dir = pkg_dir / ".config" / "nvim"
config_dir.mkdir(parents=True)
(config_dir / "_module.yaml").write_text(yaml.dump({
"source": "github:test/nvim-config",
"ref": {"branch": "main"},
}))
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", modules)
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
svc.repos_list()
output = capsys.readouterr().out
assert "dotfiles" in output
assert "nvim" in output
assert "module" in output
def test_repos_pull_includes_profile_module_repos(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = tmp_path / "dotfiles"
profile_pkg = dotfiles / "linux-work" / "nvim" / ".config" / "nvim"
profile_pkg.mkdir(parents=True)
(profile_pkg / "_module.yaml").write_text(yaml.dump({
"source": "github:test/nvim-config",
"ref": {"branch": "main"},
}))
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
runtime = SystemRuntime()
runner = FakeRunner()
runtime.runner = runner
runtime.git.runner = runner
ctx = FlowContext(
config=AppConfig(),
manifest={},
platform=PlatformInfo(),
console=Console(color=False),
runtime=runtime,
)
DotfilesService(ctx).repos_pull()
assert any("linux-work--nvim" in " ".join(call) for call in runner.calls)
def test_repos_status_shows_repo_names(self, tmp_path, monkeypatch, capsys):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
# Make dotfiles dir look like a git repo for status
(dotfiles / ".git").mkdir()
runtime = SystemRuntime()
runner = FakeRunner()
runtime.runner = runner
runtime.git.runner = runner
ctx = FlowContext(
config=AppConfig(),
manifest={},
platform=PlatformInfo(),
console=Console(color=False),
runtime=runtime,
)
DotfilesService(ctx).repos_status()
output = capsys.readouterr().out
assert "dotfiles" in output
def test_repos_push_calls_git_push(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
runtime = SystemRuntime()
runner = FakeRunner()
runtime.runner = runner
runtime.git.runner = runner
ctx = FlowContext(
config=AppConfig(),
manifest={},
platform=PlatformInfo(),
console=Console(color=False),
runtime=runtime,
)
DotfilesService(ctx).repos_push()
assert any("push" in " ".join(call) for call in runner.calls)
def test_repos_pull_dry_run_no_calls(self, tmp_path, monkeypatch, capsys):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
runtime = SystemRuntime()
runner = FakeRunner()
runtime.runner = runner
runtime.git.runner = runner
ctx = FlowContext(
config=AppConfig(),
manifest={},
platform=PlatformInfo(),
console=Console(color=False),
runtime=runtime,
)
DotfilesService(ctx).repos_pull(dry_run=True)
output = capsys.readouterr().out
assert "Would" in output
# No git calls should be made in dry run
assert not runner.calls
def test_status_filter_by_package(self, tmp_path, monkeypatch, capsys):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh"},
"git": {".gitconfig": "[user]"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
svc.link()
capsys.readouterr() # discard link output
svc.status(package_filter=["zsh"])
output = capsys.readouterr().out
assert "zsh" in output
# Only zsh should appear, not git
assert "_shared/git" not in output
def test_link_repairs_broken_symlinks(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh config"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
# Link normally
svc.link()
assert (home / ".zshrc").is_symlink()
# Break the symlink by removing its target
real_target = (home / ".zshrc").resolve()
(home / ".zshrc").unlink()
(home / ".zshrc").symlink_to("/nonexistent/path")
# Re-link should repair the broken symlink
svc.link()
assert (home / ".zshrc").is_symlink()
assert (home / ".zshrc").resolve() == real_target.resolve()
def _setup_module_pkg(tmp_path, *, ref_key: str, ref_value: str, profile: str = "_shared"):
"""Build a dotfiles tree with one module package and its module cache."""
dotfiles = tmp_path / "dotfiles"
modules = tmp_path / "modules"
pkg_dir = dotfiles / profile / "nvim"
config_dir = pkg_dir / ".config" / "nvim"
config_dir.mkdir(parents=True)
(config_dir / "_module.yaml").write_text(yaml.dump({
"source": "github:test/nvim-config",
"ref": {ref_key: ref_value},
}))
return dotfiles, modules
def _make_runner_ctx(tmp_path, home, dotfiles, modules, monkeypatch, console=None):
"""Build a FlowContext with a FakeRunner and the path monkeypatches set."""
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", modules)
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
runtime = SystemRuntime()
runner = FakeRunner()
runtime.runner = runner
runtime.git.runner = runner
ctx = FlowContext(
config=AppConfig(),
manifest={},
platform=PlatformInfo(),
console=console or Console(color=False),
runtime=runtime,
)
return ctx, runner
class TestCheckoutModuleRef:
"""The branch == 'main' early-return is gone: every ref runs git checkout."""
def test_branch_main_still_runs_checkout(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles, modules = _setup_module_pkg(tmp_path, ref_key="branch", ref_value="main")
ctx, runner = _make_runner_ctx(tmp_path, home, dotfiles, modules, monkeypatch)
DotfilesService(ctx).repos_pull()
# Should have cloned (cache missing) and then checked out 'main'.
clone_calls = [c for c in runner.calls if "clone" in c]
checkout_calls = [c for c in runner.calls if "checkout" in c]
assert clone_calls, f"expected git clone, calls: {runner.calls}"
assert checkout_calls, f"expected git checkout, calls: {runner.calls}"
assert any("main" in c for c in checkout_calls)
def test_tag_ref_uses_tags_prefix(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles, modules = _setup_module_pkg(tmp_path, ref_key="tag", ref_value="v1.0")
ctx, runner = _make_runner_ctx(tmp_path, home, dotfiles, modules, monkeypatch)
DotfilesService(ctx).repos_pull()
checkout_calls = [c for c in runner.calls if "checkout" in c]
assert checkout_calls
# tags/v1.0 form -- detached checkout.
assert any("tags/v1.0" in arg for c in checkout_calls for arg in c)
def test_commit_ref_uses_raw_sha(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
sha = "deadbeefcafe1234567890abcdef1234567890ab"
dotfiles, modules = _setup_module_pkg(tmp_path, ref_key="commit", ref_value=sha)
ctx, runner = _make_runner_ctx(tmp_path, home, dotfiles, modules, monkeypatch)
DotfilesService(ctx).repos_pull()
checkout_calls = [c for c in runner.calls if "checkout" in c]
assert checkout_calls
# No tags/ prefix on commit refs.
assert any(sha in arg for c in checkout_calls for arg in c)
assert not any(f"tags/{sha}" in arg for c in checkout_calls for arg in c)
class TestStaleStateReconciliation:
"""_load_state warns on stale entries and only persists when invoked
from a mutating path."""
def _populate_stale_state(self, tmp_path):
"""Write a linked.json that points at a target with no symlink."""
state_path = tmp_path / "state" / "linked.json"
state_path.parent.mkdir(parents=True)
state_path.write_text(json.dumps({
"version": 2,
"links": {
"_shared/zsh": {
str(tmp_path / "home" / ".zshrc"): {
"source": str(tmp_path / "dotfiles" / "_shared" / "zsh" / ".zshrc"),
"from_module": False,
"needs_sudo": False,
}
}
},
}))
return state_path
def test_load_state_warns_on_stale(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {"zsh": {".zshrc": "# zsh"}})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
state_path = self._populate_stale_state(tmp_path)
monkeypatch.setattr(paths, "LINKED_STATE", state_path)
console = _CapturingConsole()
ctx = _make_ctx(tmp_path, console=console)
svc = DotfilesService(ctx)
state = svc._load_state()
assert state.links == {} # stale entry dropped
assert any("stale link record" in w for w in console.warnings)
def test_status_does_not_rewrite_state(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {"zsh": {".zshrc": "# zsh"}})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
state_path = self._populate_stale_state(tmp_path)
monkeypatch.setattr(paths, "LINKED_STATE", state_path)
before_mtime = state_path.stat().st_mtime_ns
before_content = state_path.read_text()
# Sleep just enough that mtime would change if we rewrote.
time.sleep(0.01)
console = _CapturingConsole()
ctx = _make_ctx(tmp_path, console=console)
DotfilesService(ctx).status()
assert state_path.stat().st_mtime_ns == before_mtime
assert state_path.read_text() == before_content
# Still warned about the stale entry.
assert any("stale link record" in w for w in console.warnings)
class TestUnclonedModuleWarning:
def test_link_warns_when_module_cache_missing(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles, modules = _setup_module_pkg(tmp_path, ref_key="branch", ref_value="main")
# No module cache: the module dir does not exist.
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", modules)
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
console = _CapturingConsole()
ctx = _make_ctx(tmp_path, console=console)
svc = DotfilesService(ctx)
svc.link() # must not crash
assert any(
"not cloned" in w and "repos pull" in w for w in console.warnings
), console.warnings
class TestOrphanAdoption:
"""After a partial-failure rerun the planner adopts pre-existing matching
symlinks. We simulate a failure during _apply_plan by replacing
create_symlink mid-flight, then re-running link()."""
def test_partial_apply_failure_recoverable_via_rerun(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {
"zsh": {".zshrc": "# zsh"},
"git": {".gitconfig": "[user]\n name = t"},
})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
state_path = tmp_path / "state" / "linked.json"
monkeypatch.setattr(paths, "LINKED_STATE", state_path)
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
# Patch create_symlink to fail on the SECOND call so the first
# symlink lands on disk but the state is not persisted.
real_create = ctx.runtime.fs.create_symlink
calls = {"n": 0}
def flaky_create(source, target, **kw):
calls["n"] += 1
if calls["n"] >= 2:
raise FlowError("simulated mid-apply failure")
return real_create(source, target, **kw)
monkeypatch.setattr(ctx.runtime.fs, "create_symlink", flaky_create)
with pytest.raises(FlowError, match="simulated"):
svc.link()
# State file should NOT have been written (atomic semantics: we
# only persist when _apply_plan completes).
assert not state_path.exists()
# First symlink landed on disk.
existing_links = sorted(
p.name for p in home.rglob("*") if p.is_symlink()
)
assert len(existing_links) == 1
# Restore real implementation and re-run: orphan adoption kicks in.
monkeypatch.setattr(ctx.runtime.fs, "create_symlink", real_create)
svc.link()
assert (home / ".zshrc").is_symlink()
assert (home / ".gitconfig").is_symlink()
assert state_path.exists()
class TestStatePersistsAtomically:
def test_save_state_uses_tmp_file_atomically(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = _setup_dotfiles(tmp_path, {"zsh": {".zshrc": "# zsh"}})
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
state_path = tmp_path / "state" / "linked.json"
monkeypatch.setattr(paths, "LINKED_STATE", state_path)
ctx = _make_ctx(tmp_path)
DotfilesService(ctx).link()
# No leftover tmp file.
residue = list(state_path.parent.glob("*.tmp"))
assert residue == []
# Final content is valid JSON.
json.loads(state_path.read_text())
class TestDotfilesServiceRootPaths:
"""`_root/` paths require sudo; verify the service routes them via the
sudo branch of FileSystem.create_symlink (without actually invoking sudo)."""
def test_root_paths_route_via_sudo(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = tmp_path / "dotfiles"
pkg_dir = dotfiles / "_shared" / "system" / "_root" / "etc"
pkg_dir.mkdir(parents=True)
(pkg_dir / "ourfile").write_text("managed by flow")
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
# Replace the FS layer with one that records sudo calls instead of
# actually invoking sudo. We still want create_symlink's pre-check
# to run, so we patch only the sudo branch's runner.
runner = FakeRunner()
ctx = _make_ctx(tmp_path)
ctx.runtime.runner = runner
svc = DotfilesService(ctx)
# Plan first to inspect the operations -- a _root entry must carry
# needs_sudo=True so create_symlink takes the sudo branch.
packages = svc._discover_packages(profile=None)
assert any(
p.local_files and any("_root" in str(rel) for _, rel in p.local_files)
for p in packages
)
from flow.domain.dotfiles.resolution import resolve_all_targets
targets = resolve_all_targets(packages, home, set())
assert any(t.needs_sudo and t.target == Path("/etc/ourfile") for t in targets)
# Running link() against a real /etc would require root; instead
# confirm that with --dry-run the plan surfaces the sudo op without
# any FS mutation.
svc.link(dry_run=True)
assert not Path("/etc/ourfile").exists() # we did not actually touch /etc
def test_root_paths_can_be_skipped(self, tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
dotfiles = tmp_path / "dotfiles"
pkg_dir = dotfiles / "_shared" / "system" / "_root" / "etc"
pkg_dir.mkdir(parents=True)
(pkg_dir / "hostname").write_text("flow-host")
# Non-root file in the same package shouldn't be skipped
(dotfiles / "_shared" / "system" / "README").write_text("notes")
monkeypatch.setattr(paths, "HOME", home)
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
ctx = _make_ctx(tmp_path)
svc = DotfilesService(ctx)
svc.link(skip={"_root"})
assert not Path("/etc/hostname").exists() or (home / "etc" / "hostname").is_symlink() is False
# README is not under _root, so it should be linked
assert (home / "README").is_symlink()