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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user