refactor-1

This commit is contained in:
2026-03-15 21:46:50 +02:00
parent 24d682adf1
commit c0b378c424
25 changed files with 4839 additions and 3136 deletions

View File

@@ -7,7 +7,7 @@ from pathlib import Path
import pytest
from flow.commands.dotfiles import (
from flow.services.dotfiles import (
LinkSpec,
_collect_home_specs,
_list_profiles,
@@ -16,6 +16,7 @@ from flow.commands.dotfiles import (
_pull_requires_ack,
_resolved_package_source,
_run_sudo,
run_relink,
run_undo,
_save_link_specs_to_state,
_sync_to_desired,
@@ -95,7 +96,7 @@ def test_collect_home_specs_skip_root_marker(tmp_path):
def test_state_round_trip(tmp_path, monkeypatch):
state_file = tmp_path / "linked.json"
monkeypatch.setattr("flow.commands.dotfiles.LINKED_STATE", state_file)
monkeypatch.setattr("flow.services.dotfiles.LINKED_STATE", state_file)
specs = {
Path("/home/user/.gitconfig"): LinkSpec(
@@ -113,7 +114,7 @@ def test_state_round_trip(tmp_path, monkeypatch):
def test_state_old_format_rejected(tmp_path, monkeypatch):
state_file = tmp_path / "linked.json"
monkeypatch.setattr("flow.commands.dotfiles.LINKED_STATE", state_file)
monkeypatch.setattr("flow.services.dotfiles.LINKED_STATE", state_file)
state_file.write_text(
json.dumps(
{
@@ -131,24 +132,24 @@ def test_state_old_format_rejected(tmp_path, monkeypatch):
def test_module_source_requires_sync(tmp_path):
package_dir = tmp_path / "_shared" / "nvim"
package_dir.mkdir(parents=True)
(package_dir / "_module.yaml").write_text(
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_dir)
_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 / ".config" / "nvim").mkdir(parents=True)
(module_src / ".config" / "nvim" / "init.lua").write_text("-- module")
(module_src / "init.lua").write_text("-- module")
subprocess.run(["git", "-C", str(module_src), "add", "."], check=True)
subprocess.run(
[
@@ -167,30 +168,30 @@ def test_sync_modules_populates_cache_and_resolves_source(tmp_path, monkeypatch)
)
dotfiles = tmp_path / "dotfiles"
package_dir = dotfiles / "_shared" / "nvim"
package_dir.mkdir(parents=True)
(package_dir / "_module.yaml").write_text(
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_dir / "notes.txt").write_text("ignore me")
(package_root / "notes.txt").write_text("ignore me")
monkeypatch.setattr("flow.commands.dotfiles.DOTFILES_DIR", dotfiles)
monkeypatch.setattr("flow.commands.dotfiles.MODULES_DIR", tmp_path / "modules")
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_dir)
resolved = _resolved_package_source(_ctx(), "_shared/nvim", package_root)
assert (resolved / ".config" / "nvim" / "init.lua").exists()
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 / ".config" / "nvim").mkdir(parents=True)
(module_src / ".config" / "nvim" / "init.lua").write_text("-- module")
(module_src / "init.lua").write_text("-- module")
subprocess.run(["git", "-C", str(module_src), "add", "."], check=True)
subprocess.run(
[
@@ -209,16 +210,17 @@ def test_module_backed_link_specs_exclude_git_internals(tmp_path, monkeypatch):
)
dotfiles = tmp_path / "dotfiles"
package_dir = dotfiles / "_shared" / "nvim"
package_dir.mkdir(parents=True)
(package_dir / "_module.yaml").write_text(
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.commands.dotfiles.DOTFILES_DIR", dotfiles)
monkeypatch.setattr("flow.commands.dotfiles.MODULES_DIR", tmp_path / "modules")
monkeypatch.setattr("flow.services.dotfiles.DOTFILES_DIR", dotfiles)
monkeypatch.setattr("flow.services.dotfiles.MODULES_DIR", tmp_path / "modules")
_sync_modules(_ctx(), verbose=False)
@@ -234,8 +236,7 @@ def test_sync_modules_resolves_relative_source_independent_of_cwd(tmp_path, monk
module_src = tmp_path / "module-src"
module_src.mkdir()
subprocess.run(["git", "init", "-b", "main", str(module_src)], check=True)
(module_src / ".config" / "nvim").mkdir(parents=True)
(module_src / ".config" / "nvim" / "init.lua").write_text("-- module")
(module_src / "init.lua").write_text("-- module")
subprocess.run(["git", "-C", str(module_src), "add", "."], check=True)
subprocess.run(
[
@@ -254,10 +255,11 @@ def test_sync_modules_resolves_relative_source_independent_of_cwd(tmp_path, monk
)
dotfiles = tmp_path / "dotfiles"
package_dir = dotfiles / "_shared" / "nvim"
package_dir.mkdir(parents=True)
relative_source = Path("../../../module-src")
(package_dir / "_module.yaml").write_text(
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"
@@ -266,13 +268,61 @@ def test_sync_modules_resolves_relative_source_independent_of_cwd(tmp_path, monk
unrelated_cwd = tmp_path / "unrelated-cwd"
unrelated_cwd.mkdir()
monkeypatch.chdir(unrelated_cwd)
monkeypatch.setattr("flow.commands.dotfiles.DOTFILES_DIR", dotfiles)
monkeypatch.setattr("flow.commands.dotfiles.MODULES_DIR", tmp_path / "modules")
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_dir)
resolved = _resolved_package_source(_ctx(), "_shared/nvim", package_root)
assert (resolved / ".config" / "nvim" / "init.lua").exists()
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():
@@ -280,10 +330,29 @@ def test_pull_requires_ack_only_on_real_updates():
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.commands.dotfiles.LINKED_STATE", state_file)
monkeypatch.setattr("flow.commands.dotfiles._is_in_home", lambda _path, _home: True)
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)
@@ -317,8 +386,8 @@ def test_sync_to_desired_dry_run_force_is_read_only(tmp_path, monkeypatch):
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.commands.dotfiles.LINKED_STATE", state_file)
monkeypatch.setattr("flow.commands.dotfiles._is_in_home", lambda _path, _home: True)
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()
@@ -354,9 +423,9 @@ def test_sync_to_desired_force_fails_before_any_writes_on_directory_conflict(tmp
def test_undo_restores_previous_file_and_link_state(tmp_path, monkeypatch):
state_file = tmp_path / "linked.json"
monkeypatch.setattr("flow.commands.dotfiles.LINKED_STATE", state_file)
monkeypatch.setattr("flow.commands.dotfiles.LINK_BACKUP_DIR", tmp_path / "link-backups")
monkeypatch.setattr("flow.commands.dotfiles._is_in_home", lambda _path, _home: True)
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)
@@ -403,9 +472,9 @@ def test_undo_restores_previous_file_and_link_state(tmp_path, monkeypatch):
def test_sync_to_desired_persists_incomplete_transaction_on_failure(tmp_path, monkeypatch):
state_file = tmp_path / "linked.json"
monkeypatch.setattr("flow.commands.dotfiles.LINKED_STATE", state_file)
monkeypatch.setattr("flow.commands.dotfiles.LINK_BACKUP_DIR", tmp_path / "link-backups")
monkeypatch.setattr("flow.commands.dotfiles._is_in_home", lambda _path, _home: True)
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()
@@ -435,7 +504,7 @@ def test_sync_to_desired_persists_incomplete_transaction_on_failure(tmp_path, mo
spec.target.symlink_to(spec.source)
return True
monkeypatch.setattr("flow.commands.dotfiles._apply_link_spec", _failing_apply)
monkeypatch.setattr("flow.services.dotfiles._apply_link_spec", _failing_apply)
with pytest.raises(RuntimeError, match="simulated failure"):
_sync_to_desired(
@@ -463,8 +532,8 @@ def test_sync_to_desired_persists_incomplete_transaction_on_failure(tmp_path, mo
def test_sync_to_desired_requires_force_to_remove_modified_managed_target(tmp_path, monkeypatch):
state_file = tmp_path / "linked.json"
monkeypatch.setattr("flow.commands.dotfiles.LINKED_STATE", state_file)
monkeypatch.setattr("flow.commands.dotfiles._is_in_home", lambda _path, _home: True)
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)
@@ -501,8 +570,8 @@ def test_sync_to_desired_requires_force_to_remove_modified_managed_target(tmp_pa
def test_sync_to_desired_requires_force_to_replace_modified_managed_target(tmp_path, monkeypatch):
state_file = tmp_path / "linked.json"
monkeypatch.setattr("flow.commands.dotfiles.LINKED_STATE", state_file)
monkeypatch.setattr("flow.commands.dotfiles._is_in_home", lambda _path, _home: True)
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"
@@ -548,6 +617,6 @@ def test_sync_to_desired_requires_force_to_replace_modified_managed_target(tmp_p
def test_run_sudo_errors_when_binary_missing(monkeypatch):
monkeypatch.setattr("flow.commands.dotfiles.shutil.which", lambda _name: None)
monkeypatch.setattr("flow.services.dotfiles.shutil.which", lambda _name: None)
with pytest.raises(RuntimeError, match="sudo is required"):
_run_sudo(["true"], dry_run=False)