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:
@@ -1,120 +0,0 @@
|
||||
"""Action dataclass and ActionExecutor for plan-then-execute workflows."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from flow.core.console import ConsoleLogger
|
||||
|
||||
|
||||
@dataclass
|
||||
class Action:
|
||||
type: str
|
||||
description: str
|
||||
data: Dict[str, Any] = field(default_factory=dict)
|
||||
skip_on_error: bool = True
|
||||
os_filter: Optional[str] = None
|
||||
status: str = "pending"
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class ActionExecutor:
|
||||
"""Register handlers for action types, then execute a plan."""
|
||||
|
||||
def __init__(self, console: ConsoleLogger):
|
||||
self.console = console
|
||||
self._handlers: Dict[str, Callable] = {}
|
||||
self.post_comments: List[str] = []
|
||||
|
||||
def register(self, action_type: str, handler: Callable) -> None:
|
||||
self._handlers[action_type] = handler
|
||||
|
||||
def execute(self, actions: List[Action], *, dry_run: bool = False, current_os: str = "") -> None:
|
||||
if dry_run:
|
||||
self._print_plan(actions)
|
||||
return
|
||||
|
||||
# Filter OS-incompatible actions
|
||||
compatible = [a for a in actions if a.os_filter is None or a.os_filter == current_os]
|
||||
skipped_count = len(actions) - len(compatible)
|
||||
if skipped_count:
|
||||
self.console.info(f"Skipped {skipped_count} OS-incompatible actions")
|
||||
|
||||
self.console.section_header(f"EXECUTING {len(compatible)} ACTIONS")
|
||||
|
||||
for i, action in enumerate(compatible, 1):
|
||||
self.console.step_start(i, len(compatible), action.description)
|
||||
|
||||
handler = self._handlers.get(action.type)
|
||||
if not handler:
|
||||
action.status = "skipped"
|
||||
self.console.step_skip(f"No handler for action type: {action.type}")
|
||||
continue
|
||||
|
||||
try:
|
||||
handler(action.data)
|
||||
action.status = "completed"
|
||||
self.console.step_complete()
|
||||
except Exception as e:
|
||||
action.error = str(e)
|
||||
if action.skip_on_error:
|
||||
action.status = "skipped"
|
||||
self.console.step_skip(str(e))
|
||||
else:
|
||||
action.status = "failed"
|
||||
self.console.step_fail(str(e))
|
||||
print(f"\n{self.console.RED}Critical action failed, stopping execution{self.console.RESET}")
|
||||
break
|
||||
|
||||
self._print_summary(compatible)
|
||||
|
||||
def _print_plan(self, actions: List[Action]) -> None:
|
||||
self.console.plan_header("EXECUTION PLAN", len(actions))
|
||||
|
||||
grouped: Dict[str, List[Action]] = {}
|
||||
for action in actions:
|
||||
category = action.type.split("-")[0]
|
||||
grouped.setdefault(category, []).append(action)
|
||||
|
||||
for category, category_actions in grouped.items():
|
||||
self.console.plan_category(category)
|
||||
for i, action in enumerate(category_actions, 1):
|
||||
self.console.plan_item(
|
||||
i,
|
||||
action.description,
|
||||
action.os_filter,
|
||||
not action.skip_on_error,
|
||||
)
|
||||
|
||||
self.console.plan_legend()
|
||||
|
||||
def _print_summary(self, actions: List[Action]) -> None:
|
||||
completed = sum(1 for a in actions if a.status == "completed")
|
||||
failed = sum(1 for a in actions if a.status == "failed")
|
||||
skipped = sum(1 for a in actions if a.status == "skipped")
|
||||
|
||||
self.console.section_summary("EXECUTION SUMMARY")
|
||||
c = self.console
|
||||
|
||||
print(f"Total actions: {c.BOLD}{len(actions)}{c.RESET}")
|
||||
print(f"Completed: {c.GREEN}{completed}{c.RESET}")
|
||||
if failed:
|
||||
print(f"Failed: {c.RED}{failed}{c.RESET}")
|
||||
if skipped:
|
||||
print(f"Skipped: {c.YELLOW}{skipped}{c.RESET}")
|
||||
|
||||
if self.post_comments:
|
||||
print(f"\n{c.BOLD}POST-INSTALL NOTES{c.RESET}")
|
||||
print(f"{c.CYAN}{'-' * 25}{c.RESET}")
|
||||
for i, comment in enumerate(self.post_comments, 1):
|
||||
print(f"{i}. {comment}")
|
||||
|
||||
if failed:
|
||||
print(f"\n{c.BOLD}FAILED ACTIONS{c.RESET}")
|
||||
print(f"{c.RED}{'-' * 20}{c.RESET}")
|
||||
for action in actions:
|
||||
if action.status == "failed":
|
||||
print(f"{c.RED}>{c.RESET} {action.description}")
|
||||
print(f" {c.GRAY}Error: {action.error}{c.RESET}")
|
||||
print(f"\n{c.RED}{failed} action(s) failed. Check the errors above.{c.RESET}")
|
||||
else:
|
||||
print(f"\n{c.GREEN}All actions completed successfully!{c.RESET}")
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Command execution helpers."""
|
||||
|
||||
import subprocess
|
||||
|
||||
from flow.core.console import ConsoleLogger
|
||||
from flow.core.system import CommandRunner
|
||||
|
||||
|
||||
def run_command(
|
||||
command: str,
|
||||
console: ConsoleLogger,
|
||||
*,
|
||||
check: bool = True,
|
||||
shell: bool = True,
|
||||
capture: bool = False,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a command with real-time streamed output."""
|
||||
if not shell:
|
||||
raise RuntimeError("run_command only supports shell commands")
|
||||
|
||||
runner = CommandRunner()
|
||||
if capture:
|
||||
result = runner.run_shell(command, capture_output=True, check=False)
|
||||
if check and result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"Command failed (exit {result.returncode}): {command}"
|
||||
)
|
||||
return result
|
||||
|
||||
return runner.stream_shell(command, console, check=check)
|
||||
@@ -1,358 +0,0 @@
|
||||
"""GNU Stow-style tree folding/unfolding for efficient symlink management."""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
|
||||
@dataclass
|
||||
class LinkOperation:
|
||||
"""Represents a single operation to perform during linking."""
|
||||
|
||||
type: str # "create_symlink" | "create_dir" | "unfold" | "remove" | "remove_dir"
|
||||
source: Path
|
||||
target: Path
|
||||
package: str
|
||||
is_directory_link: bool = False
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.type == "create_symlink":
|
||||
link_type = "DIR" if self.is_directory_link else "FILE"
|
||||
return f" {link_type} LINK: {self.target} -> {self.source}"
|
||||
elif self.type == "create_dir":
|
||||
return f" CREATE DIR: {self.target}"
|
||||
elif self.type == "unfold":
|
||||
return f" UNFOLD: {self.target} (directory symlink -> individual file symlinks)"
|
||||
elif self.type == "remove":
|
||||
return f" REMOVE: {self.target}"
|
||||
elif self.type == "remove_dir":
|
||||
return f" REMOVE DIR: {self.target}"
|
||||
return f" {self.type}: {self.target}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LinkTree:
|
||||
"""Represents the current state of symlinks."""
|
||||
|
||||
links: Dict[Path, Path] = field(default_factory=dict) # target -> source
|
||||
packages: Dict[Path, str] = field(default_factory=dict) # target -> package_name
|
||||
directory_links: Set[Path] = field(default_factory=set) # targets that are directory links
|
||||
|
||||
def add_link(self, target: Path, source: Path, package: str, is_dir_link: bool = False):
|
||||
"""Add a link to the tree."""
|
||||
self.links[target] = source
|
||||
self.packages[target] = package
|
||||
if is_dir_link:
|
||||
self.directory_links.add(target)
|
||||
|
||||
def remove_link(self, target: Path):
|
||||
"""Remove a link from the tree."""
|
||||
self.links.pop(target, None)
|
||||
self.packages.pop(target, None)
|
||||
self.directory_links.discard(target)
|
||||
|
||||
def is_directory_link(self, target: Path) -> bool:
|
||||
"""Check if a target is a directory symlink."""
|
||||
return target in self.directory_links
|
||||
|
||||
def get_package(self, target: Path) -> Optional[str]:
|
||||
"""Get the package that owns a link."""
|
||||
return self.packages.get(target)
|
||||
|
||||
def can_fold(self, target_dir: Path, package: str) -> bool:
|
||||
"""Check if all links in target_dir belong to the same package.
|
||||
|
||||
Returns True if we can create a single directory symlink instead of
|
||||
individual file symlinks.
|
||||
"""
|
||||
# Check all direct children of target_dir
|
||||
for link_target, link_package in self.packages.items():
|
||||
# If link_target is a child of target_dir
|
||||
try:
|
||||
link_target.relative_to(target_dir)
|
||||
# If parent is target_dir and package differs, cannot fold
|
||||
if link_target.parent == target_dir and link_package != package:
|
||||
return False
|
||||
except ValueError:
|
||||
# link_target is not under target_dir, skip
|
||||
continue
|
||||
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def from_state(cls, state: dict) -> "LinkTree":
|
||||
"""Build a LinkTree from the linked.json state format (v2 only)."""
|
||||
tree = cls()
|
||||
links_dict = state.get("links", {})
|
||||
|
||||
for package_name, pkg_links in links_dict.items():
|
||||
for target_str, link_info in pkg_links.items():
|
||||
target = Path(target_str)
|
||||
if not isinstance(link_info, dict) or "source" not in link_info:
|
||||
raise RuntimeError(
|
||||
"Unsupported linked state format. Remove linked.json and relink dotfiles."
|
||||
)
|
||||
|
||||
source = Path(link_info["source"])
|
||||
is_dir_link = bool(link_info.get("is_directory_link", False))
|
||||
|
||||
tree.add_link(target, source, package_name, is_dir_link)
|
||||
|
||||
return tree
|
||||
|
||||
def to_state(self) -> dict:
|
||||
"""Convert LinkTree to linked.json state format."""
|
||||
state = {"version": 2, "links": {}}
|
||||
|
||||
# Group links by package
|
||||
package_links: Dict[str, Dict[str, dict]] = {}
|
||||
for target, source in self.links.items():
|
||||
package = self.packages[target]
|
||||
if package not in package_links:
|
||||
package_links[package] = {}
|
||||
|
||||
package_links[package][str(target)] = {
|
||||
"source": str(source),
|
||||
"is_directory_link": target in self.directory_links,
|
||||
}
|
||||
|
||||
state["links"] = package_links
|
||||
return state
|
||||
|
||||
|
||||
class TreeFolder:
|
||||
"""Implements GNU Stow tree folding/unfolding algorithm."""
|
||||
|
||||
def __init__(self, tree: LinkTree):
|
||||
self.tree = tree
|
||||
|
||||
def plan_link(
|
||||
self, source: Path, target: Path, package: str, is_dir_link: bool = False
|
||||
) -> List[LinkOperation]:
|
||||
"""Plan operations needed to create a link (may include unfolding).
|
||||
|
||||
Args:
|
||||
source: Source path (file or directory in dotfiles)
|
||||
target: Target path (where symlink should be created)
|
||||
package: Package name
|
||||
is_dir_link: Whether this is a directory symlink (folded)
|
||||
|
||||
Returns a list of operations to execute in order.
|
||||
"""
|
||||
operations = []
|
||||
|
||||
# Check if parent is a directory symlink that needs unfolding
|
||||
parent = target.parent
|
||||
if parent in self.tree.links and self.tree.is_directory_link(parent):
|
||||
# Parent is a folded directory symlink, need to unfold
|
||||
unfold_ops = self._plan_unfold(parent)
|
||||
operations.extend(unfold_ops)
|
||||
|
||||
# Create symlink operation (conflict detection will handle existing links)
|
||||
operations.append(
|
||||
LinkOperation(
|
||||
type="create_symlink",
|
||||
source=source,
|
||||
target=target,
|
||||
package=package,
|
||||
is_directory_link=is_dir_link,
|
||||
)
|
||||
)
|
||||
|
||||
return operations
|
||||
|
||||
def _find_fold_point(
|
||||
self, source: Path, target: Path, package: str
|
||||
) -> Path:
|
||||
"""Find the deepest directory level where we can create a folder symlink.
|
||||
|
||||
Returns the target path where the symlink should be created.
|
||||
For single files, this should just return the file path (no folding).
|
||||
Folding only makes sense when linking entire directories.
|
||||
"""
|
||||
# For now, disable automatic folding at the plan_link level
|
||||
# Folding should be done at a higher level when we know we're
|
||||
# linking an entire directory tree from a package
|
||||
return target
|
||||
|
||||
def _plan_unfold(self, folded_dir: Path) -> List[LinkOperation]:
|
||||
"""Plan operations to unfold a directory symlink.
|
||||
|
||||
When unfolding:
|
||||
1. Remove the directory symlink
|
||||
2. Create a real directory
|
||||
3. Create individual file symlinks for all files
|
||||
"""
|
||||
operations = []
|
||||
|
||||
# Get the source of the folded directory
|
||||
source_dir = self.tree.links.get(folded_dir)
|
||||
if not source_dir:
|
||||
return operations
|
||||
|
||||
package = self.tree.packages.get(folded_dir, "")
|
||||
|
||||
# Remove the directory symlink
|
||||
operations.append(
|
||||
LinkOperation(
|
||||
type="remove",
|
||||
source=source_dir,
|
||||
target=folded_dir,
|
||||
package=package,
|
||||
is_directory_link=True,
|
||||
)
|
||||
)
|
||||
|
||||
# Create real directory
|
||||
operations.append(
|
||||
LinkOperation(
|
||||
type="create_dir",
|
||||
source=source_dir,
|
||||
target=folded_dir,
|
||||
package=package,
|
||||
)
|
||||
)
|
||||
|
||||
# Create individual file symlinks for all files in source
|
||||
if source_dir.exists() and source_dir.is_dir():
|
||||
for root, _dirs, files in os.walk(source_dir):
|
||||
for fname in files:
|
||||
src_file = Path(root) / fname
|
||||
rel = src_file.relative_to(source_dir)
|
||||
dst_file = folded_dir / rel
|
||||
|
||||
operations.append(
|
||||
LinkOperation(
|
||||
type="create_symlink",
|
||||
source=src_file,
|
||||
target=dst_file,
|
||||
package=package,
|
||||
is_directory_link=False,
|
||||
)
|
||||
)
|
||||
|
||||
return operations
|
||||
|
||||
def plan_unlink(self, target: Path, package: str) -> List[LinkOperation]:
|
||||
"""Plan operations to remove a link (may include refolding)."""
|
||||
operations = []
|
||||
|
||||
# Check if this is a directory link
|
||||
if self.tree.is_directory_link(target):
|
||||
# Remove all file links under this directory
|
||||
to_remove = []
|
||||
for link_target in self.tree.links.keys():
|
||||
try:
|
||||
link_target.relative_to(target)
|
||||
to_remove.append(link_target)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
for link_target in to_remove:
|
||||
operations.append(
|
||||
LinkOperation(
|
||||
type="remove",
|
||||
source=self.tree.links[link_target],
|
||||
target=link_target,
|
||||
package=self.tree.packages[link_target],
|
||||
is_directory_link=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Remove the link itself
|
||||
if target in self.tree.links:
|
||||
operations.append(
|
||||
LinkOperation(
|
||||
type="remove",
|
||||
source=self.tree.links[target],
|
||||
target=target,
|
||||
package=package,
|
||||
is_directory_link=self.tree.is_directory_link(target),
|
||||
)
|
||||
)
|
||||
|
||||
return operations
|
||||
|
||||
def detect_conflicts(self, operations: List[LinkOperation]) -> List[str]:
|
||||
"""Detect conflicts before executing operations.
|
||||
|
||||
Returns a list of conflict error messages.
|
||||
"""
|
||||
conflicts = []
|
||||
|
||||
for op in operations:
|
||||
if op.type == "create_symlink":
|
||||
# Check if target already exists in tree (managed by flow)
|
||||
if op.target in self.tree.links:
|
||||
existing_pkg = self.tree.packages[op.target]
|
||||
if existing_pkg != op.package:
|
||||
conflicts.append(
|
||||
f"Conflict: {op.target} is already linked by package '{existing_pkg}'"
|
||||
)
|
||||
# Check if target exists on disk but not managed by flow
|
||||
elif op.target.exists() or op.target.is_symlink():
|
||||
conflicts.append(
|
||||
f"Conflict: {op.target} already exists and is not managed by flow"
|
||||
)
|
||||
|
||||
# Check if target's parent is a file (can't create file in file)
|
||||
if op.target.parent.exists() and op.target.parent.is_file():
|
||||
conflicts.append(
|
||||
f"Conflict: {op.target.parent} is a file, cannot create {op.target}"
|
||||
)
|
||||
|
||||
return conflicts
|
||||
|
||||
def execute_operations(
|
||||
self, operations: List[LinkOperation], dry_run: bool = False
|
||||
) -> None:
|
||||
"""Execute a list of operations atomically.
|
||||
|
||||
If dry_run is True, only print what would be done.
|
||||
"""
|
||||
if dry_run:
|
||||
for op in operations:
|
||||
print(str(op))
|
||||
return
|
||||
|
||||
# Execute operations
|
||||
for op in operations:
|
||||
if op.type == "create_symlink":
|
||||
# Create parent directories
|
||||
op.target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if op.target.is_symlink():
|
||||
current = op.target.resolve(strict=False)
|
||||
desired = op.source.resolve(strict=False)
|
||||
if current == desired:
|
||||
self.tree.add_link(op.target, op.source, op.package, op.is_directory_link)
|
||||
continue
|
||||
op.target.unlink()
|
||||
elif op.target.exists():
|
||||
if op.target.is_file():
|
||||
op.target.unlink()
|
||||
else:
|
||||
raise RuntimeError(f"Cannot overwrite directory: {op.target}")
|
||||
|
||||
# Create symlink
|
||||
op.target.symlink_to(op.source)
|
||||
|
||||
# Update tree
|
||||
self.tree.add_link(op.target, op.source, op.package, op.is_directory_link)
|
||||
|
||||
elif op.type == "create_dir":
|
||||
op.target.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
elif op.type == "remove":
|
||||
if op.target.exists() or op.target.is_symlink():
|
||||
op.target.unlink()
|
||||
self.tree.remove_link(op.target)
|
||||
|
||||
elif op.type == "remove_dir":
|
||||
if op.target.exists() and op.target.is_dir():
|
||||
op.target.rmdir()
|
||||
|
||||
def to_state(self) -> dict:
|
||||
"""Convert current tree to state format for persistence."""
|
||||
return self.tree.to_state()
|
||||
@@ -1,327 +0,0 @@
|
||||
"""Runtime primitives for process, git, state, and filesystem access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping, Optional, Sequence
|
||||
|
||||
from flow.core.console import ConsoleLogger
|
||||
from flow.core.errors import FlowError
|
||||
|
||||
|
||||
def _as_argv(argv: Sequence[str] | Iterable[str]) -> list[str]:
|
||||
return [str(part) for part in argv]
|
||||
|
||||
|
||||
class CommandRunner:
|
||||
"""Small wrapper around subprocess with consistent defaults."""
|
||||
|
||||
def format_command(self, argv: Sequence[str] | Iterable[str]) -> str:
|
||||
return " ".join(shlex.quote(part) for part in _as_argv(argv))
|
||||
|
||||
def require_binary(self, name: str) -> str:
|
||||
path = shutil.which(name)
|
||||
if path is None:
|
||||
raise FlowError(f"Required executable not found: {name}")
|
||||
return path
|
||||
|
||||
def run(
|
||||
self,
|
||||
argv: Sequence[str] | Iterable[str],
|
||||
*,
|
||||
cwd: Optional[Path] = None,
|
||||
env: Optional[Mapping[str, str]] = None,
|
||||
capture_output: bool = True,
|
||||
check: bool = False,
|
||||
timeout: Optional[int | float] = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
completed = subprocess.run(
|
||||
_as_argv(argv),
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
env=dict(env) if env is not None else None,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=timeout,
|
||||
)
|
||||
if check and completed.returncode != 0:
|
||||
message = completed.stderr.strip() or completed.stdout.strip()
|
||||
if not message:
|
||||
message = f"Command failed with exit code {completed.returncode}"
|
||||
raise FlowError(message)
|
||||
return completed
|
||||
|
||||
def run_shell(
|
||||
self,
|
||||
command: str,
|
||||
*,
|
||||
cwd: Optional[Path] = None,
|
||||
env: Optional[Mapping[str, str]] = None,
|
||||
capture_output: bool = True,
|
||||
check: bool = False,
|
||||
timeout: Optional[int | float] = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
env=dict(env) if env is not None else None,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=timeout,
|
||||
)
|
||||
if check and completed.returncode != 0:
|
||||
message = completed.stderr.strip() or completed.stdout.strip()
|
||||
if not message:
|
||||
message = f"Command failed with exit code {completed.returncode}"
|
||||
raise FlowError(message)
|
||||
return completed
|
||||
|
||||
def stream_shell(
|
||||
self,
|
||||
command: str,
|
||||
console: ConsoleLogger,
|
||||
*,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
console.step_command(command)
|
||||
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
universal_newlines=True,
|
||||
bufsize=1,
|
||||
)
|
||||
|
||||
output_lines: list[str] = []
|
||||
assert process.stdout is not None
|
||||
try:
|
||||
for line in process.stdout:
|
||||
line = line.rstrip()
|
||||
if not line:
|
||||
continue
|
||||
console.step_output(line)
|
||||
output_lines.append(line)
|
||||
finally:
|
||||
process.stdout.close()
|
||||
process.wait()
|
||||
|
||||
if check and process.returncode != 0:
|
||||
raise FlowError(
|
||||
f"Command failed (exit {process.returncode}): {command}"
|
||||
)
|
||||
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
process.returncode,
|
||||
stdout="\n".join(output_lines),
|
||||
stderr="",
|
||||
)
|
||||
|
||||
|
||||
class GitClient:
|
||||
"""Thin git adapter that always scopes commands to a repository root."""
|
||||
|
||||
def __init__(self, runner: CommandRunner):
|
||||
self.runner = runner
|
||||
|
||||
def run(
|
||||
self,
|
||||
repo_dir: Path,
|
||||
*args: str,
|
||||
capture_output: bool = True,
|
||||
check: bool = False,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return self.runner.run(
|
||||
["git", "-C", str(repo_dir), *args],
|
||||
capture_output=capture_output,
|
||||
check=check,
|
||||
)
|
||||
|
||||
|
||||
class FileSystem:
|
||||
"""Filesystem wrapper for all mutating operations."""
|
||||
|
||||
def ensure_dir(
|
||||
self,
|
||||
path: Path,
|
||||
*,
|
||||
sudo: bool = False,
|
||||
runner: Optional[CommandRunner] = None,
|
||||
mode: Optional[int] = None,
|
||||
) -> None:
|
||||
if sudo:
|
||||
if runner is None:
|
||||
raise FlowError("A command runner is required for sudo operations")
|
||||
runner.require_binary("sudo")
|
||||
argv = ["sudo", "mkdir", "-p"]
|
||||
if mode is not None:
|
||||
argv.extend(["-m", f"{mode:o}"])
|
||||
argv.append(str(path))
|
||||
runner.run(argv, check=True)
|
||||
return
|
||||
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
if mode is not None:
|
||||
path.chmod(mode)
|
||||
|
||||
def remove_file(
|
||||
self,
|
||||
path: Path,
|
||||
*,
|
||||
sudo: bool = False,
|
||||
runner: Optional[CommandRunner] = None,
|
||||
missing_ok: bool = True,
|
||||
) -> None:
|
||||
if sudo:
|
||||
if runner is None:
|
||||
raise FlowError("A command runner is required for sudo operations")
|
||||
runner.require_binary("sudo")
|
||||
argv = ["sudo", "rm"]
|
||||
if missing_ok:
|
||||
argv.append("-f")
|
||||
argv.append(str(path))
|
||||
runner.run(argv, check=True)
|
||||
return
|
||||
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
if not missing_ok:
|
||||
raise
|
||||
|
||||
def remove_tree(self, path: Path) -> None:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
def copy_file(
|
||||
self,
|
||||
source: Path,
|
||||
target: Path,
|
||||
*,
|
||||
sudo: bool = False,
|
||||
runner: Optional[CommandRunner] = None,
|
||||
) -> None:
|
||||
if sudo:
|
||||
if runner is None:
|
||||
raise FlowError("A command runner is required for sudo operations")
|
||||
runner.require_binary("sudo")
|
||||
self.ensure_dir(target.parent, sudo=True, runner=runner)
|
||||
runner.run(["sudo", "cp", "-a", str(source), str(target)], check=True)
|
||||
return
|
||||
|
||||
self.ensure_dir(target.parent)
|
||||
shutil.copy2(source, target)
|
||||
|
||||
def copy_tree(self, source: Path, target: Path) -> None:
|
||||
self.ensure_dir(target.parent)
|
||||
shutil.copytree(source, target, dirs_exist_ok=True)
|
||||
|
||||
def create_symlink(
|
||||
self,
|
||||
source: Path,
|
||||
target: Path,
|
||||
*,
|
||||
sudo: bool = False,
|
||||
runner: Optional[CommandRunner] = None,
|
||||
) -> None:
|
||||
if sudo:
|
||||
if runner is None:
|
||||
raise FlowError("A command runner is required for sudo operations")
|
||||
runner.require_binary("sudo")
|
||||
self.ensure_dir(target.parent, sudo=True, runner=runner)
|
||||
runner.run(["sudo", "ln", "-sfn", str(source), str(target)], check=True)
|
||||
return
|
||||
|
||||
self.ensure_dir(target.parent)
|
||||
target.symlink_to(source)
|
||||
|
||||
def read_text(self, path: Path, *, default: Optional[str] = None) -> str:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
if default is None:
|
||||
raise
|
||||
return default
|
||||
|
||||
def write_text(self, path: Path, content: str) -> None:
|
||||
self.ensure_dir(path.parent)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
def write_bytes(self, path: Path, content: bytes) -> None:
|
||||
self.ensure_dir(path.parent)
|
||||
path.write_bytes(content)
|
||||
|
||||
def write_bytes(self, path: Path, content: bytes) -> None:
|
||||
self.ensure_dir(path.parent)
|
||||
path.write_bytes(content)
|
||||
|
||||
def read_json(self, path: Path, *, default: Any = None) -> Any:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
except FileNotFoundError:
|
||||
return default
|
||||
|
||||
def write_json(self, path: Path, data: Any) -> None:
|
||||
self.ensure_dir(path.parent)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=2)
|
||||
|
||||
def same_symlink(self, target: Path, source: Path) -> bool:
|
||||
if not target.is_symlink():
|
||||
return False
|
||||
return target.resolve(strict=False) == source.resolve(strict=False)
|
||||
|
||||
def is_within(self, path: Path, parent: Path) -> bool:
|
||||
try:
|
||||
path.resolve(strict=False).relative_to(parent.resolve(strict=False))
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def path_in_home(self, path: Path, home: Optional[Path] = None) -> bool:
|
||||
root = (home or Path.home()).resolve(strict=False)
|
||||
try:
|
||||
path.resolve(strict=False).relative_to(root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class JsonStateStore:
|
||||
"""JSON file-backed state store."""
|
||||
|
||||
path: Path
|
||||
fs: FileSystem
|
||||
default_factory: Any
|
||||
|
||||
def load(self) -> Any:
|
||||
data = self.fs.read_json(self.path, default=None)
|
||||
if data is None:
|
||||
return self.default_factory()
|
||||
return data
|
||||
|
||||
def save(self, data: Any) -> None:
|
||||
self.fs.write_json(self.path, data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemRuntime:
|
||||
"""Shared runtime dependencies carried through the command context."""
|
||||
|
||||
runner: CommandRunner = field(default_factory=CommandRunner)
|
||||
fs: FileSystem = field(default_factory=FileSystem)
|
||||
git: GitClient = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.git = GitClient(self.runner)
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Variable substitution for shell-style and template expressions."""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def substitute(text: str, variables: Dict[str, str]) -> str:
|
||||
"""Replace $VAR and ${VAR} with values from variables dict or env."""
|
||||
if not isinstance(text, str):
|
||||
return text
|
||||
|
||||
pattern = re.compile(r"\$(\w+)|\$\{([^}]+)\}")
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
key = match.group(1) or match.group(2) or ""
|
||||
if key in variables:
|
||||
return str(variables[key])
|
||||
if key == "HOME":
|
||||
return str(Path.home())
|
||||
if key in os.environ:
|
||||
return os.environ[key]
|
||||
return match.group(0)
|
||||
|
||||
return pattern.sub(_replace, text)
|
||||
|
||||
|
||||
def _resolve_template_value(expr: str, context: Dict[str, Any]) -> Any:
|
||||
if expr.startswith("env."):
|
||||
env_key = expr.split(".", 1)[1]
|
||||
env_ctx = context.get("env", {})
|
||||
if isinstance(env_ctx, dict) and env_key in env_ctx:
|
||||
return env_ctx[env_key]
|
||||
return os.environ.get(env_key)
|
||||
|
||||
if expr in context:
|
||||
return context[expr]
|
||||
|
||||
current: Any = context
|
||||
for part in expr.split("."):
|
||||
if not isinstance(current, dict) or part not in current:
|
||||
return None
|
||||
current = current[part]
|
||||
|
||||
return current
|
||||
|
||||
|
||||
def substitute_template(text: str, context: Dict[str, Any]) -> str:
|
||||
"""Replace {{expr}} placeholders with values from context dict."""
|
||||
if not isinstance(text, str):
|
||||
return text
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
key = match.group(1).strip()
|
||||
value = _resolve_template_value(key, context)
|
||||
if value is None:
|
||||
return match.group(0)
|
||||
return str(value)
|
||||
|
||||
return re.sub(r"\{\{\s*([^{}]+?)\s*\}\}", _replace, text)
|
||||
@@ -1,350 +0,0 @@
|
||||
"""Shared package-manifest normalization and binary install helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from flow.core.config import FlowContext
|
||||
from flow.core.errors import FlowError
|
||||
from flow.core.variables import substitute_template
|
||||
|
||||
PACKAGE_TYPES = {"pkg", "binary", "cask"}
|
||||
|
||||
|
||||
def linux_detect_package_manager() -> Optional[str]:
|
||||
if shutil.which("apt") or shutil.which("apt-get"):
|
||||
return "apt"
|
||||
if shutil.which("dnf"):
|
||||
return "dnf"
|
||||
return None
|
||||
|
||||
|
||||
def resolve_package_manager(ctx: FlowContext, profile_cfg: dict) -> str:
|
||||
explicit = profile_cfg.get("package-manager")
|
||||
if isinstance(explicit, str) and explicit:
|
||||
return explicit
|
||||
|
||||
profile_os = profile_cfg.get("os")
|
||||
if profile_os == "macos":
|
||||
return "brew"
|
||||
if profile_os == "linux":
|
||||
detected = linux_detect_package_manager()
|
||||
if detected:
|
||||
return detected
|
||||
raise FlowError("Unable to auto-detect package manager (expected apt or dnf)")
|
||||
raise FlowError("Profile 'os' must be set to 'linux' or 'macos'")
|
||||
|
||||
|
||||
def get_package_catalog(ctx: FlowContext) -> Dict[str, Dict[str, Any]]:
|
||||
raw = ctx.manifest.get("packages", [])
|
||||
catalog: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
if isinstance(raw, dict):
|
||||
for name, definition in raw.items():
|
||||
if not isinstance(definition, dict):
|
||||
continue
|
||||
package = dict(definition)
|
||||
package["name"] = str(package.get("name") or name)
|
||||
package.setdefault("type", "pkg")
|
||||
catalog[package["name"]] = package
|
||||
return catalog
|
||||
|
||||
if not isinstance(raw, list):
|
||||
return catalog
|
||||
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
continue
|
||||
package = dict(item)
|
||||
package.setdefault("type", "pkg")
|
||||
catalog[name] = package
|
||||
|
||||
return catalog
|
||||
|
||||
|
||||
def normalize_profile_package_entry(entry: Any) -> Dict[str, Any]:
|
||||
if isinstance(entry, str):
|
||||
if "/" in entry:
|
||||
prefix, name = entry.split("/", 1)
|
||||
if prefix in PACKAGE_TYPES and name:
|
||||
return {"name": name, "type": prefix}
|
||||
return {"name": entry}
|
||||
|
||||
if isinstance(entry, dict):
|
||||
name = entry.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
raise FlowError("Package object entries must include a non-empty 'name'")
|
||||
return dict(entry)
|
||||
|
||||
raise FlowError(f"Unsupported package entry: {entry!r}")
|
||||
|
||||
|
||||
def resolve_package_spec(
|
||||
catalog: Dict[str, Dict[str, Any]],
|
||||
profile_entry: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
name = profile_entry["name"]
|
||||
merged = dict(catalog.get(name, {}))
|
||||
merged.update(profile_entry)
|
||||
merged["name"] = name
|
||||
|
||||
pkg_type = merged.get("type") or "pkg"
|
||||
if pkg_type not in PACKAGE_TYPES:
|
||||
raise FlowError(f"Unsupported package type '{pkg_type}' for package '{name}'")
|
||||
merged["type"] = pkg_type
|
||||
return merged
|
||||
|
||||
|
||||
def resolve_pkg_source_name(spec: Dict[str, Any], package_manager: str) -> str:
|
||||
sources = spec.get("sources", {})
|
||||
if not isinstance(sources, dict):
|
||||
return spec["name"]
|
||||
|
||||
keys = [package_manager]
|
||||
if package_manager == "apt":
|
||||
keys.append("apt-get")
|
||||
if package_manager == "apt-get":
|
||||
keys.append("apt")
|
||||
|
||||
for key in keys:
|
||||
value = sources.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return spec["name"]
|
||||
|
||||
|
||||
def platform_lookup_keys(ctx: FlowContext) -> List[str]:
|
||||
keys = [ctx.platform.platform]
|
||||
if ctx.platform.os == "macos":
|
||||
keys.append(f"darwin-{ctx.platform.arch}")
|
||||
if ctx.platform.arch == "x64":
|
||||
keys.append(f"{ctx.platform.os}-amd64")
|
||||
if ctx.platform.os == "macos":
|
||||
keys.append("darwin-amd64")
|
||||
ordered: list[str] = []
|
||||
for key in keys:
|
||||
if key not in ordered:
|
||||
ordered.append(key)
|
||||
return ordered
|
||||
|
||||
|
||||
def profile_template_context(
|
||||
ctx: FlowContext,
|
||||
extra_env: Dict[str, str],
|
||||
extra: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
env_map = dict(os.environ)
|
||||
env_map.update(extra_env)
|
||||
template_ctx: Dict[str, Any] = {
|
||||
"env": env_map,
|
||||
"os": ctx.platform.os,
|
||||
"arch": ctx.platform.arch,
|
||||
}
|
||||
if extra:
|
||||
template_ctx.update(extra)
|
||||
return template_ctx
|
||||
|
||||
|
||||
def render_template_value(value: Any, template_ctx: Dict[str, Any]) -> Any:
|
||||
if isinstance(value, str):
|
||||
return substitute_template(value, template_ctx)
|
||||
if isinstance(value, list):
|
||||
return [render_template_value(item, template_ctx) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: render_template_value(item, template_ctx) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def resolve_binary_platform_vars(ctx: FlowContext, spec: Dict[str, Any]) -> Dict[str, str]:
|
||||
platform_vars = {
|
||||
"os": ctx.platform.os,
|
||||
"arch": ctx.platform.arch,
|
||||
}
|
||||
platform_map = spec.get("platform-map", {})
|
||||
if isinstance(platform_map, dict):
|
||||
for key in platform_lookup_keys(ctx):
|
||||
mapping = platform_map.get(key)
|
||||
if isinstance(mapping, dict):
|
||||
for map_key, map_value in mapping.items():
|
||||
if isinstance(map_value, str):
|
||||
platform_vars[map_key] = map_value
|
||||
break
|
||||
return platform_vars
|
||||
|
||||
|
||||
def resolve_binary_asset(ctx: FlowContext, spec: Dict[str, Any], template_ctx: Dict[str, Any]) -> str:
|
||||
assets = spec.get("assets", {})
|
||||
if isinstance(assets, dict) and assets:
|
||||
for key in platform_lookup_keys(ctx):
|
||||
value = assets.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return substitute_template(value, template_ctx)
|
||||
raise FlowError(
|
||||
f"No binary asset mapping for platform {ctx.platform.platform} in package '{spec['name']}'"
|
||||
)
|
||||
|
||||
pattern = spec.get("asset-pattern")
|
||||
if not isinstance(pattern, str) or not pattern:
|
||||
raise FlowError(
|
||||
f"Binary package '{spec['name']}' must define either 'assets' or 'asset-pattern'"
|
||||
)
|
||||
return substitute_template(pattern, template_ctx)
|
||||
|
||||
|
||||
def resolve_binary_download_url(
|
||||
spec: Dict[str, Any],
|
||||
asset_name: str,
|
||||
template_ctx: Dict[str, Any],
|
||||
) -> str:
|
||||
source = spec.get("source")
|
||||
if not isinstance(source, str) or not source:
|
||||
raise FlowError(f"Binary package '{spec['name']}' is missing 'source'")
|
||||
|
||||
version = str(spec.get("version", ""))
|
||||
if source.startswith("github:"):
|
||||
owner_repo = source[len("github:") :]
|
||||
if not owner_repo:
|
||||
raise FlowError(f"Invalid github source in package '{spec['name']}'")
|
||||
if not version:
|
||||
raise FlowError(f"Binary package '{spec['name']}' requires 'version'")
|
||||
return f"https://github.com/{owner_repo}/releases/download/v{version}/{asset_name}"
|
||||
|
||||
rendered_source = substitute_template(source, template_ctx)
|
||||
if not asset_name or rendered_source.endswith(asset_name):
|
||||
return rendered_source
|
||||
if rendered_source.endswith("/"):
|
||||
return rendered_source + asset_name
|
||||
return f"{rendered_source}/{asset_name}"
|
||||
|
||||
|
||||
def strip_prefix(path: Path, prefix: Path) -> Path:
|
||||
try:
|
||||
return path.relative_to(prefix)
|
||||
except ValueError:
|
||||
return path
|
||||
|
||||
|
||||
def validate_declared_install_path(package_name: str, declared_path: Path) -> None:
|
||||
if declared_path.is_absolute():
|
||||
raise FlowError(f"Install path for '{package_name}' must be relative: {declared_path}")
|
||||
if any(part == ".." for part in declared_path.parts):
|
||||
raise FlowError(
|
||||
f"Install path for '{package_name}' must not include parent traversal: {declared_path}"
|
||||
)
|
||||
|
||||
|
||||
def install_destination(kind: str) -> Path:
|
||||
home = Path.home()
|
||||
if kind == "bin":
|
||||
return home / ".local" / "bin"
|
||||
if kind == "share":
|
||||
return home / ".local" / "share"
|
||||
if kind == "man":
|
||||
return home / ".local" / "share" / "man"
|
||||
if kind == "lib":
|
||||
return home / ".local" / "lib"
|
||||
raise FlowError(f"Unsupported install section: {kind}")
|
||||
|
||||
|
||||
def install_strip_prefix(kind: str) -> Path:
|
||||
if kind == "bin":
|
||||
return Path("bin")
|
||||
if kind == "share":
|
||||
return Path("share")
|
||||
if kind == "man":
|
||||
return Path("share") / "man"
|
||||
if kind == "lib":
|
||||
return Path("lib")
|
||||
return Path(".")
|
||||
|
||||
|
||||
class BinaryInstaller:
|
||||
def __init__(self, ctx: FlowContext):
|
||||
self.ctx = ctx
|
||||
self.fs = ctx.runtime.fs
|
||||
|
||||
def copy_install_item(self, kind: str, src: Path, declared_path: Path) -> None:
|
||||
destination_root = install_destination(kind)
|
||||
stripped = strip_prefix(declared_path, install_strip_prefix(kind))
|
||||
destination = destination_root / stripped
|
||||
|
||||
if src.is_dir():
|
||||
self.fs.copy_tree(src, destination)
|
||||
else:
|
||||
self.fs.copy_file(src, destination)
|
||||
if kind == "bin":
|
||||
destination.chmod(destination.stat().st_mode | 0o111)
|
||||
|
||||
def install(self, spec: Dict[str, Any], extra_env: Dict[str, str], *, dry_run: bool) -> None:
|
||||
version = str(spec.get("version", ""))
|
||||
platform_vars = resolve_binary_platform_vars(self.ctx, spec)
|
||||
template_ctx = profile_template_context(
|
||||
self.ctx,
|
||||
extra_env,
|
||||
{"name": spec["name"], "version": version, **platform_vars},
|
||||
)
|
||||
|
||||
asset_name = resolve_binary_asset(self.ctx, spec, template_ctx)
|
||||
template_ctx["asset"] = asset_name
|
||||
download_url = resolve_binary_download_url(spec, asset_name, template_ctx)
|
||||
template_ctx["downloadUrl"] = download_url
|
||||
|
||||
if dry_run:
|
||||
self.ctx.console.info(f"[{spec['name']}] Would download: {download_url}")
|
||||
return
|
||||
|
||||
install_map = spec.get("install", {})
|
||||
if not isinstance(install_map, dict) or not install_map:
|
||||
raise FlowError(f"Binary package '{spec['name']}' must define non-empty 'install'")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=f"flow-{spec['name']}-") as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
archive_path = tmp_dir / asset_name
|
||||
extracted = tmp_dir / "extract"
|
||||
|
||||
self.ctx.console.info(f"Downloading {spec['name']} from {download_url}")
|
||||
with urllib.request.urlopen(download_url, timeout=60) as response:
|
||||
self.fs.write_bytes(archive_path, response.read())
|
||||
|
||||
self.fs.ensure_dir(extracted)
|
||||
try:
|
||||
shutil.unpack_archive(str(archive_path), str(extracted))
|
||||
except (shutil.ReadError, ValueError) as exc:
|
||||
raise FlowError(f"Could not extract archive for '{spec['name']}': {exc}") from exc
|
||||
|
||||
extract_dir_value = substitute_template(str(spec.get("extract-dir", ".")), template_ctx)
|
||||
source_root = extracted if extract_dir_value == "." else extracted / extract_dir_value
|
||||
if not source_root.exists():
|
||||
raise FlowError(
|
||||
f"extract-dir '{extract_dir_value}' not found for package '{spec['name']}'"
|
||||
)
|
||||
source_root_resolved = source_root.resolve(strict=False)
|
||||
|
||||
for kind in ("bin", "share", "man", "lib"):
|
||||
items = install_map.get(kind, [])
|
||||
if not isinstance(items, list):
|
||||
continue
|
||||
for raw_item in items:
|
||||
if not isinstance(raw_item, str):
|
||||
continue
|
||||
rendered = substitute_template(raw_item, template_ctx)
|
||||
declared_path = Path(rendered)
|
||||
validate_declared_install_path(spec["name"], declared_path)
|
||||
source = (source_root / declared_path).resolve(strict=False)
|
||||
if not str(source).startswith(str(source_root_resolved)):
|
||||
raise FlowError(
|
||||
f"Install path escapes extract-dir for '{spec['name']}': {declared_path}"
|
||||
)
|
||||
if not source.exists():
|
||||
raise FlowError(
|
||||
f"Install path not found for '{spec['name']}': {declared_path}"
|
||||
)
|
||||
self.copy_install_item(kind, source, declared_path)
|
||||
@@ -1,184 +0,0 @@
|
||||
"""SSH target parsing and connection behavior for `flow enter`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import getpass
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from flow.core.config import FlowContext
|
||||
from flow.core.errors import FlowError
|
||||
|
||||
# Default host templates per platform
|
||||
HOST_TEMPLATES = {
|
||||
"orb": "<namespace>.orb",
|
||||
"utm": "<namespace>.utm.local",
|
||||
"core": "<namespace>.core.lan",
|
||||
}
|
||||
|
||||
|
||||
def parse_target(target: str) -> tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
"""Parse [user@]namespace@platform into (user, namespace, platform)."""
|
||||
user = None
|
||||
namespace = None
|
||||
platform = None
|
||||
|
||||
if "@" in target:
|
||||
platform = target.rsplit("@", 1)[1]
|
||||
rest = target.rsplit("@", 1)[0]
|
||||
else:
|
||||
rest = target
|
||||
|
||||
if "@" in rest:
|
||||
user = rest.rsplit("@", 1)[0]
|
||||
namespace = rest.rsplit("@", 1)[1]
|
||||
else:
|
||||
namespace = rest
|
||||
|
||||
return user, namespace, platform
|
||||
|
||||
|
||||
def build_destination(user: str, host: str, preserve_host_user: bool = False) -> str:
|
||||
if "@" in host:
|
||||
host_user, host_name = host.rsplit("@", 1)
|
||||
effective_user = host_user if preserve_host_user else (user or host_user)
|
||||
return f"{effective_user}@{host_name}"
|
||||
if not user:
|
||||
return host
|
||||
return f"{user}@{host}"
|
||||
|
||||
|
||||
def terminfo_fix_command(term: Optional[str], destination: str) -> Optional[str]:
|
||||
normalized_term = (term or "").strip().lower()
|
||||
|
||||
if normalized_term == "xterm-ghostty":
|
||||
return f"infocmp -x xterm-ghostty | ssh {destination} -- tic -x -"
|
||||
|
||||
if normalized_term == "wezterm":
|
||||
return (
|
||||
f"ssh {destination} -- sh -lc "
|
||||
"'tempfile=$(mktemp) && curl -fsSL -o \"$tempfile\" "
|
||||
"https://raw.githubusercontent.com/wezterm/wezterm/main/termwiz/data/wezterm.terminfo "
|
||||
"&& tic -x -o ~/.terminfo \"$tempfile\" && rm \"$tempfile\"'"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def handle_terminfo_warning(
|
||||
ctx: FlowContext,
|
||||
term: Optional[str],
|
||||
destination: str,
|
||||
dry_run: bool,
|
||||
) -> bool:
|
||||
install_cmd = terminfo_fix_command(term, destination)
|
||||
if not install_cmd:
|
||||
return True
|
||||
|
||||
ctx.console.warn(
|
||||
f"Detected TERM={term}. Remote host may be missing this terminfo entry."
|
||||
)
|
||||
ctx.console.info("flow will not install or modify terminfo on the target automatically.")
|
||||
ctx.console.info("If needed, run this command manually before reconnecting:")
|
||||
print(f" {install_cmd}")
|
||||
|
||||
if dry_run or not os.isatty(0):
|
||||
return True
|
||||
|
||||
response = ""
|
||||
try:
|
||||
response = input("Continue with SSH connection? [Y/n] ").strip().lower()
|
||||
except EOFError:
|
||||
return True
|
||||
|
||||
if response in {"n", "no"}:
|
||||
ctx.console.warn("Cancelled before opening SSH session")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class EnterService:
|
||||
"""Resolve enter targets and execute the SSH handoff."""
|
||||
|
||||
def __init__(self, ctx: FlowContext):
|
||||
self.ctx = ctx
|
||||
|
||||
def run(self, args) -> None:
|
||||
if os.environ.get("DF_NAMESPACE") and os.environ.get("DF_PLATFORM"):
|
||||
ns = os.environ["DF_NAMESPACE"]
|
||||
plat = os.environ["DF_PLATFORM"]
|
||||
raise FlowError(
|
||||
f"Not recommended inside an instance. Currently in: {ns}@{plat}"
|
||||
)
|
||||
|
||||
user, namespace, platform = parse_target(args.target)
|
||||
|
||||
if args.user:
|
||||
user = args.user
|
||||
if args.namespace:
|
||||
namespace = args.namespace
|
||||
if args.platform:
|
||||
platform = args.platform
|
||||
|
||||
user_was_explicit = bool(user)
|
||||
|
||||
if not user:
|
||||
user = os.environ.get("USER") or getpass.getuser()
|
||||
if not namespace:
|
||||
raise FlowError("Namespace is required in target")
|
||||
if not platform:
|
||||
raise FlowError("Platform is required in target")
|
||||
|
||||
host_template = HOST_TEMPLATES.get(platform)
|
||||
ssh_identity = None
|
||||
|
||||
for target in self.ctx.config.targets:
|
||||
if target.namespace == namespace and target.platform == platform:
|
||||
host_template = target.ssh_host
|
||||
ssh_identity = target.ssh_identity
|
||||
break
|
||||
|
||||
if not host_template:
|
||||
raise FlowError(f"Unknown platform: {platform}")
|
||||
|
||||
ssh_host = host_template.replace("<namespace>", namespace)
|
||||
destination = build_destination(
|
||||
user,
|
||||
ssh_host,
|
||||
preserve_host_user=not user_was_explicit,
|
||||
)
|
||||
|
||||
if not handle_terminfo_warning(
|
||||
self.ctx,
|
||||
os.environ.get("TERM"),
|
||||
destination,
|
||||
dry_run=args.dry_run,
|
||||
):
|
||||
raise FlowError("Cancelled before opening SSH session")
|
||||
|
||||
ssh_cmd = ["ssh", "-tt"]
|
||||
if ssh_identity:
|
||||
ssh_cmd.extend(["-i", os.path.expanduser(ssh_identity)])
|
||||
ssh_cmd.append(destination)
|
||||
|
||||
if not args.no_tmux:
|
||||
ssh_cmd.extend(
|
||||
[
|
||||
"tmux",
|
||||
"new-session",
|
||||
"-As",
|
||||
args.session,
|
||||
"-e",
|
||||
f"DF_NAMESPACE={namespace}",
|
||||
"-e",
|
||||
f"DF_PLATFORM={platform}",
|
||||
]
|
||||
)
|
||||
|
||||
if args.dry_run:
|
||||
self.ctx.console.info("Dry run command:")
|
||||
print(" " + " ".join(ssh_cmd))
|
||||
return
|
||||
|
||||
os.execvp("ssh", ssh_cmd)
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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."]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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."]
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user