fix(dotfiles): make symlink handling robust and fix _module checkout

- Atomic state writes (tempfile + os.replace) so a crash mid-write cannot
  corrupt linked.json.
- Managed-symlink guards in FileSystem.create_symlink and the new
  remove_symlink: refuse to overwrite or delete a path unless it is
  absent or already a symlink pointing to the expected source. Stops
  silent user-file deletion in the plan/apply race window.
- plan_link adopts orphan symlinks whose readlink already matches the
  desired source, so a partial-apply failure can be recovered by rerun.
- _load_state warns loudly on each stale entry it drops, and status()
  no longer rewrites linked.json as a side effect of read.
- _apply_plan dispatches exhaustively; unknown LinkOp types raise.
- Remove _checkout_module_ref early-return for branch == "main" -- it
  assumed the remote default was main, breaking master-default repos.
  Always run the explicit checkout (idempotent).
- Warn when a module's cache_dir is absent during link, suggesting
  flow dotfiles repos pull.
- LinkOp.type and ModuleRef.ref_type tightened to Literal[...]; dead
  "create_dir" enum value removed from the model.

Tests: +29 covering atomic writes, overwrite guards, remove_symlink
semantics, orphan adoption (match/mismatch), partial-failure rerun,
status read-only, branch/tag/commit checkout argv, uncloned-module
warning, stale-state warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 00:01:46 +03:00
parent 78f95bc88e
commit c0e2758057
7 changed files with 582 additions and 32 deletions

View File

@@ -1,8 +1,13 @@
"""Tests for flow.core.runtime."""
import json
import os
from pathlib import Path
import pytest
from flow.core.containers import ContainerRuntime
from flow.core.errors import FlowError
from flow.core.runtime import CommandRunner, FileSystem, GitClient, SystemRuntime
from flow.core.tmux import TmuxClient
@@ -77,6 +82,128 @@ class TestFileSystem:
fs.copy_file(src, dst)
assert dst.read_text() == "data"
def test_write_json_atomic_leaves_no_tmp_residue(self, tmp_path):
fs = FileSystem()
path = tmp_path / "state" / "data.json"
fs.write_json(path, {"a": 1})
assert path.read_text()
assert json.loads(path.read_text()) == {"a": 1}
residue = list(path.parent.glob("*.tmp"))
assert residue == []
def test_write_json_overwrites_stale_tmp(self, tmp_path):
fs = FileSystem()
path = tmp_path / "data.json"
stale = path.with_suffix(path.suffix + ".tmp")
stale.write_text("garbage")
fs.write_json(path, {"k": "v"})
assert json.loads(path.read_text()) == {"k": "v"}
assert not stale.exists()
def test_remove_tree_raises_when_missing(self, tmp_path):
fs = FileSystem()
with pytest.raises(FileNotFoundError):
fs.remove_tree(tmp_path / "absent")
def test_remove_tree_missing_ok(self, tmp_path):
fs = FileSystem()
fs.remove_tree(tmp_path / "absent", missing_ok=True) # no error
def test_remove_tree_raises_on_permission_error(self, tmp_path):
fs = FileSystem()
# Create a parent dir we can't traverse, holding a child directory.
# If we can't actually drop permissions (e.g. running as root), skip.
if os.geteuid() == 0:
pytest.skip("Running as root: cannot simulate permission error")
parent = tmp_path / "locked"
parent.mkdir()
(parent / "child").mkdir()
try:
parent.chmod(0o500) # r-x: can list but not modify
with pytest.raises(OSError):
fs.remove_tree(parent / "child")
finally:
parent.chmod(0o700)
def test_create_symlink_refuses_real_file(self, tmp_path):
fs = FileSystem()
source = tmp_path / "source"
source.write_text("src")
target = tmp_path / "target"
target.write_text("user content")
with pytest.raises(FlowError, match="Refusing to overwrite"):
fs.create_symlink(source, target)
# Target untouched.
assert target.read_text() == "user content"
def test_create_symlink_refuses_foreign_symlink(self, tmp_path):
fs = FileSystem()
source = tmp_path / "source"
source.write_text("src")
elsewhere = tmp_path / "elsewhere"
elsewhere.write_text("other")
target = tmp_path / "target"
target.symlink_to(elsewhere)
with pytest.raises(FlowError, match="Refusing to overwrite"):
fs.create_symlink(source, target)
# Foreign symlink preserved.
assert target.readlink() == elsewhere
def test_create_symlink_when_target_absent(self, tmp_path):
fs = FileSystem()
source = tmp_path / "source"
source.write_text("src")
target = tmp_path / "sub" / "target"
fs.create_symlink(source, target)
assert target.is_symlink()
assert target.readlink() == source
def test_create_symlink_idempotent_overwrite(self, tmp_path):
fs = FileSystem()
source = tmp_path / "source"
source.write_text("src")
target = tmp_path / "target"
target.symlink_to(source)
fs.create_symlink(source, target) # must not raise
assert target.is_symlink()
assert target.readlink() == source
def test_remove_symlink_with_matching_expected(self, tmp_path):
fs = FileSystem()
source = tmp_path / "source"
source.write_text("src")
target = tmp_path / "target"
target.symlink_to(source)
fs.remove_symlink(target, expected_source=source)
assert not target.exists()
assert not target.is_symlink()
def test_remove_symlink_refuses_regular_file(self, tmp_path):
fs = FileSystem()
target = tmp_path / "target"
target.write_text("real file")
with pytest.raises(FlowError, match="Refusing to remove non-symlink"):
fs.remove_symlink(target)
assert target.read_text() == "real file"
def test_remove_symlink_refuses_mismatched_expected(self, tmp_path):
fs = FileSystem()
source = tmp_path / "source"
source.write_text("src")
elsewhere = tmp_path / "elsewhere"
elsewhere.write_text("other")
target = tmp_path / "target"
target.symlink_to(elsewhere)
with pytest.raises(FlowError, match="Refusing to remove symlink"):
fs.remove_symlink(target, expected_source=source)
# Untouched.
assert target.is_symlink()
assert target.readlink() == elsewhere
def test_remove_symlink_absent_is_noop(self, tmp_path):
fs = FileSystem()
fs.remove_symlink(tmp_path / "missing") # no error
class TestCommandRunner:
def test_run_echo(self):