update
This commit is contained in:
6
Makefile
6
Makefile
@@ -10,13 +10,14 @@ VERSION := $(shell sed -n 's/^__version__ = "\(.*\)"/\1/p' src/flow/__init__.py)
|
|||||||
RELEASE_ROOT := $(BUILD_DIR)/flow-$(VERSION)-python
|
RELEASE_ROOT := $(BUILD_DIR)/flow-$(VERSION)-python
|
||||||
RELEASE_ARCHIVE := $(DIST_DIR)/flow-$(VERSION)-python.tar.gz
|
RELEASE_ARCHIVE := $(DIST_DIR)/flow-$(VERSION)-python.tar.gz
|
||||||
|
|
||||||
.PHONY: help deps test test-e2e check package release-package build binary install install-local check-binary clean distclean
|
.PHONY: help deps test test-e2e test-e2e-cmds check package release-package build binary install install-local check-binary clean distclean
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@printf "Targets:\n"
|
@printf "Targets:\n"
|
||||||
@printf " make deps Sync locked dev/build dependencies into .venv\n"
|
@printf " make deps Sync locked dev/build dependencies into .venv\n"
|
||||||
@printf " make test Run unit tests\n"
|
@printf " make test Run unit tests\n"
|
||||||
@printf " make test-e2e Run Docker/Podman e2e tests (FLOW_RUN_E2E=1)\n"
|
@printf " make test-e2e Run Docker/Podman e2e tests (FLOW_RUN_E2E=1)\n"
|
||||||
|
@printf " make test-e2e-cmds Run focused disposable-container CLI command checks (FLOW_RUN_E2E=1)\n"
|
||||||
@printf " make check Run unit tests and CLI smoke check\n"
|
@printf " make check Run unit tests and CLI smoke check\n"
|
||||||
@printf " make package Build wheel and sdist into dist/\n"
|
@printf " make package Build wheel and sdist into dist/\n"
|
||||||
@printf " make release-package Build installable Python release tarball\n"
|
@printf " make release-package Build installable Python release tarball\n"
|
||||||
@@ -35,6 +36,9 @@ test:
|
|||||||
test-e2e:
|
test-e2e:
|
||||||
FLOW_RUN_E2E=1 $(UV) run pytest tests/e2e/ -v
|
FLOW_RUN_E2E=1 $(UV) run pytest tests/e2e/ -v
|
||||||
|
|
||||||
|
test-e2e-cmds:
|
||||||
|
FLOW_RUN_E2E=1 $(UV) run pytest tests/e2e/test_dotfiles_e2e.py::test_cli_paths_run_in_disposable_container -q
|
||||||
|
|
||||||
check: test
|
check: test
|
||||||
$(UV) run python -m flow --help >/dev/null
|
$(UV) run python -m flow --help >/dev/null
|
||||||
$(UV) run python -m flow --version >/dev/null
|
$(UV) run python -m flow --version >/dev/null
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -231,14 +231,13 @@ See `example/README.md` for a complete runnable dotfiles/setup fixture.
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
_shared/
|
_shared/
|
||||||
|
_root/
|
||||||
|
etc/hostname
|
||||||
zsh/
|
zsh/
|
||||||
.zshrc
|
.zshrc
|
||||||
nvim/
|
nvim/
|
||||||
.config/nvim/
|
.config/nvim/
|
||||||
_module.yaml
|
_module.yaml
|
||||||
system/
|
|
||||||
_root/
|
|
||||||
etc/hostname
|
|
||||||
|
|
||||||
linux-work/
|
linux-work/
|
||||||
i3/
|
i3/
|
||||||
@@ -246,7 +245,8 @@ linux-work/
|
|||||||
```
|
```
|
||||||
|
|
||||||
`_shared/` applies to every profile. Profile directories add or override package
|
`_shared/` applies to every profile. Profile directories add or override package
|
||||||
sets. `_root/` marks absolute paths and plans sudo-backed link actions.
|
sets. A layer-level `_root/` directory, such as `_shared/_root/` or
|
||||||
|
`linux-work/_root/`, marks absolute paths and plans sudo-backed link actions.
|
||||||
|
|
||||||
External module repos are declared with `_module.yaml`:
|
External module repos are declared with `_module.yaml`:
|
||||||
|
|
||||||
@@ -301,6 +301,13 @@ Direct commands are equivalent:
|
|||||||
```bash
|
```bash
|
||||||
uv run pytest tests/ -q --ignore=tests/e2e
|
uv run pytest tests/ -q --ignore=tests/e2e
|
||||||
FLOW_RUN_E2E=1 uv run pytest tests/e2e/ -v
|
FLOW_RUN_E2E=1 uv run pytest tests/e2e/ -v
|
||||||
|
FLOW_RUN_E2E=1 uv run pytest tests/e2e/test_dotfiles_e2e.py::test_cli_paths_run_in_disposable_container -q
|
||||||
|
FLOW_RUN_E2E=1 uv run pytest tests/e2e/test_dotfiles_e2e.py::test_dotfiles_init_and_link_in_container -q
|
||||||
uv build
|
uv build
|
||||||
uv run pyinstaller --noconfirm --clean --onefile --name flow --paths src src/flow/__main__.py
|
uv run pyinstaller --noconfirm --clean --onefile --name flow --paths src src/flow/__main__.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For local container-only command validation, run the e2e module directly with
|
||||||
|
`FLOW_RUN_E2E=1`. That executes all commands inside a disposable container
|
||||||
|
instance with isolated `HOME`/`XDG_*` and a read-only example repo mount, so it
|
||||||
|
does not touch host dotfiles or global state.
|
||||||
|
|||||||
@@ -1,34 +1,41 @@
|
|||||||
# Example Dotfiles Repository
|
# Flow Example
|
||||||
|
|
||||||
`example/dotfiles-repo` is a complete fixture for the current flow schema. It
|
`example/` is a single complete fixture for the current flow schema. It contains
|
||||||
contains shared dotfiles, profile-specific dotfiles, package definitions, setup
|
one dotfiles repository plus one local module repository, showcasing shared
|
||||||
profiles, root-targeted files, templates, and shell hooks.
|
dotfiles, profile-specific dotfiles, package definitions, setup profiles,
|
||||||
|
root-targeted files, external modules, templates, and shell hooks.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
example/
|
||||||
dotfiles-repo/
|
dotfiles-repo/
|
||||||
_shared/
|
_shared/
|
||||||
|
_root/etc/hostname
|
||||||
|
_root/usr/local/bin/custom-script.sh
|
||||||
bin/.local/bin/flow-hello
|
bin/.local/bin/flow-hello
|
||||||
flow/.config/flow/config.yaml
|
flow/.config/flow/config.yaml
|
||||||
flow/.config/flow/packages.yaml
|
flow/.config/flow/packages.yaml
|
||||||
flow/.config/flow/profiles.yaml
|
flow/.config/flow/profiles.yaml
|
||||||
git/.gitconfig
|
git/.gitconfig
|
||||||
nvim/.config/nvim/init.lua
|
nvim/.config/nvim/_module.yaml
|
||||||
system/_root/etc/hostname
|
|
||||||
system/_root/usr/local/bin/custom-script.sh
|
|
||||||
tmux/.tmux.conf
|
tmux/.tmux.conf
|
||||||
zsh/.zshrc
|
zsh/.zshrc
|
||||||
linux-auto/
|
linux-auto/
|
||||||
ssh/.ssh/config
|
ssh/.ssh/config
|
||||||
macos-dev/
|
macos-dev/
|
||||||
ghostty/.config/ghostty/config
|
ghostty/.config/ghostty/config
|
||||||
|
module-repos/
|
||||||
|
nvim-config/
|
||||||
|
init.lua
|
||||||
|
lua/plugins.lua
|
||||||
```
|
```
|
||||||
|
|
||||||
The fixture demonstrates:
|
The fixture demonstrates:
|
||||||
|
|
||||||
- `_shared/` plus profile-specific layers
|
- `_shared/` plus profile-specific layers
|
||||||
- `_root/` absolute-path planning for sudo-backed links
|
- `_root/` absolute-path planning for sudo-backed links
|
||||||
|
- `_module.yaml` external module planning with a local module repository
|
||||||
- flow config and manifest overlay under `_shared/flow/.config/flow`
|
- flow config and manifest overlay under `_shared/flow/.config/flow`
|
||||||
- package-manager, cask, and binary package definitions
|
- package-manager, cask, and binary package definitions
|
||||||
- profile package shorthand and object overrides
|
- profile package shorthand and object overrides
|
||||||
@@ -45,11 +52,21 @@ repo:
|
|||||||
DEMO="$(mktemp -d)"
|
DEMO="$(mktemp -d)"
|
||||||
mkdir -p "$DEMO/home"
|
mkdir -p "$DEMO/home"
|
||||||
cp -a example/dotfiles-repo "$DEMO/dotfiles-src"
|
cp -a example/dotfiles-repo "$DEMO/dotfiles-src"
|
||||||
|
cp -a example/module-repos "$DEMO/module-repos"
|
||||||
|
|
||||||
|
git -C "$DEMO/module-repos/nvim-config" init -q -b main
|
||||||
|
git -C "$DEMO/module-repos/nvim-config" config user.email e2e@example.com
|
||||||
|
git -C "$DEMO/module-repos/nvim-config" config user.name "flow example"
|
||||||
|
git -C "$DEMO/module-repos/nvim-config" add -A
|
||||||
|
git -C "$DEMO/module-repos/nvim-config" commit -q -m initial
|
||||||
|
|
||||||
git -C "$DEMO/dotfiles-src" init -q -b main
|
git -C "$DEMO/dotfiles-src" init -q -b main
|
||||||
git -C "$DEMO/dotfiles-src" config user.email e2e@example.com
|
git -C "$DEMO/dotfiles-src" config user.email e2e@example.com
|
||||||
git -C "$DEMO/dotfiles-src" config user.name "flow example"
|
git -C "$DEMO/dotfiles-src" config user.name "flow example"
|
||||||
git -C "$DEMO/dotfiles-src" add -A
|
git -C "$DEMO/dotfiles-src" add -A
|
||||||
git -C "$DEMO/dotfiles-src" commit -q -m initial
|
git -C "$DEMO/dotfiles-src" commit -q -m initial
|
||||||
|
|
||||||
|
cd "$DEMO"
|
||||||
```
|
```
|
||||||
|
|
||||||
Run flow against that sandbox:
|
Run flow against that sandbox:
|
||||||
@@ -65,16 +82,16 @@ HOME="$DEMO/home" \
|
|||||||
XDG_CONFIG_HOME="$DEMO/config" \
|
XDG_CONFIG_HOME="$DEMO/config" \
|
||||||
XDG_DATA_HOME="$DEMO/data" \
|
XDG_DATA_HOME="$DEMO/data" \
|
||||||
XDG_STATE_HOME="$DEMO/state" \
|
XDG_STATE_HOME="$DEMO/state" \
|
||||||
uv run flow dotfiles link --profile linux-auto --skip system --dry-run
|
uv run flow dotfiles link --profile linux-auto --skip _root --dry-run
|
||||||
|
|
||||||
HOME="$DEMO/home" \
|
HOME="$DEMO/home" \
|
||||||
XDG_CONFIG_HOME="$DEMO/config" \
|
XDG_CONFIG_HOME="$DEMO/config" \
|
||||||
XDG_DATA_HOME="$DEMO/data" \
|
XDG_DATA_HOME="$DEMO/data" \
|
||||||
XDG_STATE_HOME="$DEMO/state" \
|
XDG_STATE_HOME="$DEMO/state" \
|
||||||
uv run flow dotfiles link --profile linux-auto --skip system
|
uv run flow dotfiles link --profile linux-auto --skip _root
|
||||||
```
|
```
|
||||||
|
|
||||||
`--skip system` avoids `_root/` paths such as `/etc/hostname` during the demo.
|
`--skip _root` avoids root-targeted paths such as `/etc/hostname` during the demo.
|
||||||
|
|
||||||
Useful follow-up commands:
|
Useful follow-up commands:
|
||||||
|
|
||||||
@@ -94,16 +111,17 @@ rm -rf "$DEMO"
|
|||||||
|
|
||||||
## External Modules
|
## External Modules
|
||||||
|
|
||||||
A package directory may mount a separate git repo by adding `_module.yaml` at
|
A package directory may mount a separate git repo by adding `_module.yaml`
|
||||||
the package root:
|
under the desired mount path:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
source: github:your-org/nvim-config
|
source: module-repos/nvim-config
|
||||||
ref:
|
ref:
|
||||||
branch: main
|
branch: main
|
||||||
```
|
```
|
||||||
|
|
||||||
This fixture does not include a real module because that would make the example
|
The example nvim package mounts `example/module-repos/nvim-config` at
|
||||||
depend on an external network repo. After adding one, `flow dotfiles repos pull`
|
`.config/nvim`. Flow implements modules through `_module.yaml`; it does not
|
||||||
clones or updates the module cache, and `flow dotfiles link` links from that
|
implement a top-level `_modules/` dotfiles layout convention. `flow dotfiles
|
||||||
cache.
|
repos pull` clones or updates module caches, and `flow dotfiles link` links
|
||||||
|
from those caches.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
source: module-repos/nvim-config
|
||||||
|
ref:
|
||||||
|
branch: main
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
vim.opt.number = true
|
|
||||||
vim.opt.relativenumber = true
|
|
||||||
vim.opt.expandtab = true
|
|
||||||
vim.opt.shiftwidth = 2
|
|
||||||
|
|
||||||
vim.g.mapleader = " "
|
|
||||||
1
example/module-repos/nvim-config/init.lua
Normal file
1
example/module-repos/nvim-config/init.lua
Normal file
@@ -0,0 +1 @@
|
|||||||
|
vim.opt.number = true
|
||||||
1
example/module-repos/nvim-config/lua/plugins.lua
Normal file
1
example/module-repos/nvim-config/lua/plugins.lua
Normal file
@@ -0,0 +1 @@
|
|||||||
|
return {}
|
||||||
@@ -30,7 +30,7 @@ from flow.domain.dotfiles.modules import (
|
|||||||
parse_module_ref,
|
parse_module_ref,
|
||||||
)
|
)
|
||||||
from flow.domain.dotfiles.planning import plan_link, plan_unlink
|
from flow.domain.dotfiles.planning import plan_link, plan_unlink
|
||||||
from flow.domain.dotfiles.resolution import resolve_all_targets
|
from flow.domain.dotfiles.resolution import RESERVED_ROOT, resolve_all_targets
|
||||||
|
|
||||||
|
|
||||||
# Maps ref_type to its git checkout prefix. Branch and commit use the
|
# Maps ref_type to its git checkout prefix. Branch and commit use the
|
||||||
@@ -55,6 +55,14 @@ SKIP_DIRS = {".git", ".github", "__pycache__", "flow"}
|
|||||||
SKIP_FILES = {".DS_Store", ".gitkeep", "_module.yaml"}
|
SKIP_FILES = {".DS_Store", ".gitkeep", "_module.yaml"}
|
||||||
|
|
||||||
|
|
||||||
|
def _path_is_relative_to(path: Path, parent: Path) -> bool:
|
||||||
|
try:
|
||||||
|
path.relative_to(parent)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class DotfilesService:
|
class DotfilesService:
|
||||||
def __init__(self, ctx: FlowContext):
|
def __init__(self, ctx: FlowContext):
|
||||||
self.ctx = ctx
|
self.ctx = ctx
|
||||||
@@ -606,8 +614,44 @@ class DotfilesService:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
package_id = f"{layer}/{pkg_dir.name}"
|
package_id = f"{layer}/{pkg_dir.name}"
|
||||||
module_ref = self._find_module(pkg_dir, package_id)
|
|
||||||
local_files = self._collect_files(pkg_dir)
|
local_files = self._collect_files(pkg_dir)
|
||||||
|
if pkg_dir.name == RESERVED_ROOT:
|
||||||
|
module_ref = None
|
||||||
|
local_files = [
|
||||||
|
(abs_source, RESERVED_ROOT / rel)
|
||||||
|
for abs_source, rel in local_files
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
nested_root = [
|
||||||
|
f"{package_id}/{rel}"
|
||||||
|
for _, rel in local_files
|
||||||
|
if RESERVED_ROOT in rel.parts
|
||||||
|
]
|
||||||
|
if nested_root:
|
||||||
|
raise PlanConflict(
|
||||||
|
f"Invalid nested {RESERVED_ROOT} path: {nested_root[0]}",
|
||||||
|
[
|
||||||
|
f"{path}: place root-targeted files under "
|
||||||
|
f"{layer}/{RESERVED_ROOT}/ instead"
|
||||||
|
for path in nested_root
|
||||||
|
],
|
||||||
|
)
|
||||||
|
module_ref = self._find_module(pkg_dir, package_id)
|
||||||
|
if module_ref is not None:
|
||||||
|
mounted_local_files = [
|
||||||
|
f"{package_id}/{rel}"
|
||||||
|
for _, rel in local_files
|
||||||
|
if _path_is_relative_to(rel, module_ref.mount_path)
|
||||||
|
]
|
||||||
|
if mounted_local_files:
|
||||||
|
raise PlanConflict(
|
||||||
|
f"Local files inside module mount: {mounted_local_files[0]}",
|
||||||
|
[
|
||||||
|
f"{path}: only {MODULE_FILE} may exist under "
|
||||||
|
f"{package_id}/{module_ref.mount_path}"
|
||||||
|
for path in mounted_local_files
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
packages.append(Package(
|
packages.append(Package(
|
||||||
name=pkg_dir.name,
|
name=pkg_dir.name,
|
||||||
|
|||||||
@@ -1 +1,6 @@
|
|||||||
"""End-to-end tests gated by FLOW_RUN_E2E=1."""
|
"""End-to-end tests gated by FLOW_RUN_E2E=1.
|
||||||
|
|
||||||
|
These tests run Flow inside a disposable container and mount only a read-only
|
||||||
|
fixture repo plus container-local XDG state, so host dotfiles/config are not
|
||||||
|
modified.
|
||||||
|
"""
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import textwrap
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
CONTAINERFILE = Path(__file__).parent / "Containerfile"
|
CONTAINERFILE = Path(__file__).parent / "Containerfile"
|
||||||
EXAMPLE_REPO = REPO_ROOT / "example" / "dotfiles-repo"
|
EXAMPLE_DIR = REPO_ROOT / "example"
|
||||||
IMAGE_TAG = "flow-e2e:test"
|
IMAGE_TAG = "flow-e2e:test"
|
||||||
|
|
||||||
|
|
||||||
@@ -34,15 +35,69 @@ def _pick_runtime() -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
def _flow_script(*commands: str) -> str:
|
||||||
os.environ.get("FLOW_RUN_E2E") != "1",
|
"""Build a reusable flow sandbox script for container execution."""
|
||||||
reason="set FLOW_RUN_E2E=1 to run",
|
return textwrap.dedent(
|
||||||
|
"""
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
export XDG_CONFIG_HOME="/tmp/flow-config"
|
||||||
|
export XDG_DATA_HOME="/tmp/flow-data"
|
||||||
|
export XDG_STATE_HOME="/tmp/flow-state"
|
||||||
|
export TARGET_HOSTNAME="flow-e2e"
|
||||||
|
export USER_EMAIL="e2e@example.com"
|
||||||
|
export PYTHONPATH="/opt/flow-src/src:${PYTHONPATH:-}"
|
||||||
|
|
||||||
|
rm -rf "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME"
|
||||||
|
mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME"
|
||||||
|
|
||||||
|
cp -r /example/dotfiles-repo "$HOME/dotfiles-src"
|
||||||
|
cp -r /example/module-repos "$HOME/module-repos"
|
||||||
|
cd "$HOME/module-repos/nvim-config"
|
||||||
|
git init -q -b main
|
||||||
|
git -c user.email="$USER_EMAIL" -c user.name="Flow E2E" add -A
|
||||||
|
git -c user.email="$USER_EMAIL" -c user.name="Flow E2E" commit -q -m initial
|
||||||
|
|
||||||
|
cd "$HOME/dotfiles-src"
|
||||||
|
git init -q -b main
|
||||||
|
git -c user.email="$USER_EMAIL" -c user.name="Flow E2E" add -A
|
||||||
|
git -c user.email="$USER_EMAIL" -c user.name="Flow E2E" commit -q -m initial
|
||||||
|
cd "$HOME"
|
||||||
|
"""
|
||||||
|
).strip() + "\n" + "\n".join(commands) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _run_in_e2e_container(runtime: str, script: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
[
|
||||||
|
runtime,
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"-v",
|
||||||
|
f"{EXAMPLE_DIR}:/example:ro",
|
||||||
|
IMAGE_TAG,
|
||||||
|
"-c",
|
||||||
|
script,
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
)
|
)
|
||||||
def test_dotfiles_init_and_link_in_container():
|
|
||||||
runtime = _pick_runtime()
|
|
||||||
if runtime is None:
|
@pytest.fixture(scope="session")
|
||||||
|
def runtime() -> str:
|
||||||
|
if os.environ.get("FLOW_RUN_E2E") != "1":
|
||||||
|
pytest.skip("set FLOW_RUN_E2E=1 to run")
|
||||||
|
|
||||||
|
selected_runtime = _pick_runtime()
|
||||||
|
if selected_runtime is None:
|
||||||
pytest.skip("neither podman nor docker is available")
|
pytest.skip("neither podman nor docker is available")
|
||||||
|
|
||||||
|
return selected_runtime
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def image(runtime: str):
|
||||||
build = subprocess.run(
|
build = subprocess.run(
|
||||||
[
|
[
|
||||||
runtime, "build",
|
runtime, "build",
|
||||||
@@ -57,49 +112,67 @@ def test_dotfiles_init_and_link_in_container():
|
|||||||
pytest.fail(f"image build failed:\n{build.stdout}\n{build.stderr}")
|
pytest.fail(f"image build failed:\n{build.stdout}\n{build.stderr}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Run flow inside the container against the mounted example repo.
|
yield IMAGE_TAG
|
||||||
# `flow dotfiles init` clones, so we need a real git remote — turn
|
|
||||||
# the read-only example mount into a bare-ish working repo first.
|
|
||||||
# --skip system avoids the _root/ paths which would try to sudo-link
|
|
||||||
# over /etc/hostname; we already cover the link path on non-system
|
|
||||||
# packages.
|
|
||||||
script = (
|
|
||||||
"set -eux; "
|
|
||||||
"cp -r /example /home/flowuser/dotfiles-src; "
|
|
||||||
"cd /home/flowuser/dotfiles-src; "
|
|
||||||
"git init -q -b main; "
|
|
||||||
"git -c user.email=e2e@example.com -c user.name=e2e add -A; "
|
|
||||||
"git -c user.email=e2e@example.com -c user.name=e2e commit -q -m initial; "
|
|
||||||
"cd /home/flowuser; "
|
|
||||||
"flow dotfiles init --repo /home/flowuser/dotfiles-src; "
|
|
||||||
"flow dotfiles link --profile linux-auto --skip system; "
|
|
||||||
# Verify real symlinks were created and point into the dotfiles dir.
|
|
||||||
"test -L /home/flowuser/.zshrc; "
|
|
||||||
"test -L /home/flowuser/.gitconfig; "
|
|
||||||
"readlink /home/flowuser/.zshrc | grep -q '/dotfiles/_shared/zsh/.zshrc'; "
|
|
||||||
"readlink /home/flowuser/.gitconfig | grep -q '/dotfiles/_shared/git/.gitconfig'; "
|
|
||||||
# Idempotency: rerun should be a no-op.
|
|
||||||
"flow dotfiles link --profile linux-auto --skip system; "
|
|
||||||
"flow dotfiles status"
|
|
||||||
)
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
runtime, "run", "--rm",
|
|
||||||
"-v", f"{EXAMPLE_REPO}:/example:ro",
|
|
||||||
IMAGE_TAG,
|
|
||||||
"-c", script,
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
assert result.returncode == 0, (
|
|
||||||
f"e2e run failed (rc={result.returncode}):\n"
|
|
||||||
f"stdout: {result.stdout}\n"
|
|
||||||
f"stderr: {result.stderr}"
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[runtime, "rmi", "-f", IMAGE_TAG],
|
[runtime, "rmi", "-f", IMAGE_TAG],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
os.environ.get("FLOW_RUN_E2E") != "1",
|
||||||
|
reason="set FLOW_RUN_E2E=1 to run",
|
||||||
|
)
|
||||||
|
def test_dotfiles_init_and_link_in_container(runtime: str, image: str):
|
||||||
|
result = _run_in_e2e_container(
|
||||||
|
runtime,
|
||||||
|
_flow_script(
|
||||||
|
"flow dotfiles init --repo \"$HOME/dotfiles-src\"",
|
||||||
|
"flow dotfiles link --profile linux-auto --skip _root",
|
||||||
|
"test -L \"$HOME/.zshrc\"",
|
||||||
|
"test -L \"$HOME/.gitconfig\"",
|
||||||
|
"readlink \"$HOME/.zshrc\" | grep -q '/dotfiles/_shared/zsh/.zshrc'",
|
||||||
|
"readlink \"$HOME/.gitconfig\" | grep -q '/dotfiles/_shared/git/.gitconfig'",
|
||||||
|
"flow dotfiles link --profile linux-auto --skip _root",
|
||||||
|
"flow dotfiles status",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"e2e run failed (rc={result.returncode}):\n"
|
||||||
|
f"stdout: {result.stdout}\n"
|
||||||
|
f"stderr: {result.stderr}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
os.environ.get("FLOW_RUN_E2E") != "1",
|
||||||
|
reason="set FLOW_RUN_E2E=1 to run",
|
||||||
|
)
|
||||||
|
def test_cli_paths_run_in_disposable_container(runtime: str, image: str):
|
||||||
|
result = _run_in_e2e_container(
|
||||||
|
runtime,
|
||||||
|
_flow_script(
|
||||||
|
"flow --version | tee /tmp/version.txt",
|
||||||
|
"grep -q \"flow\" /tmp/version.txt",
|
||||||
|
"flow --help | tee /tmp/help.txt",
|
||||||
|
"test -s /tmp/help.txt",
|
||||||
|
"flow completion zsh | tee /tmp/completion.zsh",
|
||||||
|
"test -s /tmp/completion.zsh",
|
||||||
|
"flow dotfiles init --repo \"$HOME/dotfiles-src\"",
|
||||||
|
"flow dotfiles link --profile linux-auto --skip _root",
|
||||||
|
"flow dotfiles status",
|
||||||
|
"flow packages list --all | tee /tmp/packages.txt",
|
||||||
|
"test -s /tmp/packages.txt",
|
||||||
|
"flow packages install --profile linux-auto --dry-run | tee /tmp/package-dry-run.txt",
|
||||||
|
"test -s /tmp/package-dry-run.txt",
|
||||||
|
"flow setup show linux-auto | tee /tmp/setup.txt",
|
||||||
|
"test -s /tmp/setup.txt",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"e2e run failed (rc={result.returncode}):\n"
|
||||||
|
f"stdout: {result.stdout}\n"
|
||||||
|
f"stderr: {result.stderr}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -119,6 +119,31 @@ class TestDotfilesServiceLink:
|
|||||||
# Local file outside mount path should be linked
|
# Local file outside mount path should be linked
|
||||||
assert (home / ".local" / "bin" / "nvim-wrapper").is_symlink()
|
assert (home / ".local" / "bin" / "nvim-wrapper").is_symlink()
|
||||||
|
|
||||||
|
def test_module_mount_rejects_local_files_inside_mount_path(self, tmp_path, monkeypatch):
|
||||||
|
home = tmp_path / "home"
|
||||||
|
home.mkdir()
|
||||||
|
|
||||||
|
dotfiles = tmp_path / "dotfiles"
|
||||||
|
pkg_dir = dotfiles / "_shared" / "nvim"
|
||||||
|
config_dir = pkg_dir / ".config" / "nvim"
|
||||||
|
config_dir.mkdir(parents=True)
|
||||||
|
(config_dir / "_module.yaml").write_text(yaml.dump({
|
||||||
|
"source": "github:test/nvim-config",
|
||||||
|
"ref": {"branch": "main"},
|
||||||
|
}))
|
||||||
|
(config_dir / "init.lua").write_text("-- local file conflicts with module mount")
|
||||||
|
|
||||||
|
monkeypatch.setattr(paths, "HOME", home)
|
||||||
|
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
|
||||||
|
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
|
||||||
|
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
|
||||||
|
|
||||||
|
ctx = _make_ctx(tmp_path)
|
||||||
|
svc = DotfilesService(ctx)
|
||||||
|
|
||||||
|
with pytest.raises(PlanConflict, match=".config/nvim/init.lua"):
|
||||||
|
svc._discover_packages(profile=None)
|
||||||
|
|
||||||
def test_unlink_removes_symlinks(self, tmp_path, monkeypatch):
|
def test_unlink_removes_symlinks(self, tmp_path, monkeypatch):
|
||||||
home = tmp_path / "home"
|
home = tmp_path / "home"
|
||||||
home.mkdir()
|
home.mkdir()
|
||||||
@@ -698,12 +723,12 @@ class TestDotfilesServiceRootPaths:
|
|||||||
"""`_root/` paths require sudo; verify the service routes them via the
|
"""`_root/` paths require sudo; verify the service routes them via the
|
||||||
sudo branch of FileSystem.create_symlink (without actually invoking sudo)."""
|
sudo branch of FileSystem.create_symlink (without actually invoking sudo)."""
|
||||||
|
|
||||||
def test_root_paths_route_via_sudo(self, tmp_path, monkeypatch):
|
def test_layer_root_paths_route_via_sudo(self, tmp_path, monkeypatch):
|
||||||
home = tmp_path / "home"
|
home = tmp_path / "home"
|
||||||
home.mkdir()
|
home.mkdir()
|
||||||
|
|
||||||
dotfiles = tmp_path / "dotfiles"
|
dotfiles = tmp_path / "dotfiles"
|
||||||
pkg_dir = dotfiles / "_shared" / "system" / "_root" / "etc"
|
pkg_dir = dotfiles / "_shared" / "_root" / "etc"
|
||||||
pkg_dir.mkdir(parents=True)
|
pkg_dir.mkdir(parents=True)
|
||||||
(pkg_dir / "ourfile").write_text("managed by flow")
|
(pkg_dir / "ourfile").write_text("managed by flow")
|
||||||
|
|
||||||
@@ -738,7 +763,7 @@ class TestDotfilesServiceRootPaths:
|
|||||||
svc.link(dry_run=True)
|
svc.link(dry_run=True)
|
||||||
assert not Path("/etc/ourfile").exists() # we did not actually touch /etc
|
assert not Path("/etc/ourfile").exists() # we did not actually touch /etc
|
||||||
|
|
||||||
def test_root_paths_can_be_skipped(self, tmp_path, monkeypatch):
|
def test_nested_root_marker_rejected(self, tmp_path, monkeypatch):
|
||||||
home = tmp_path / "home"
|
home = tmp_path / "home"
|
||||||
home.mkdir()
|
home.mkdir()
|
||||||
|
|
||||||
@@ -746,7 +771,28 @@ class TestDotfilesServiceRootPaths:
|
|||||||
pkg_dir = dotfiles / "_shared" / "system" / "_root" / "etc"
|
pkg_dir = dotfiles / "_shared" / "system" / "_root" / "etc"
|
||||||
pkg_dir.mkdir(parents=True)
|
pkg_dir.mkdir(parents=True)
|
||||||
(pkg_dir / "hostname").write_text("flow-host")
|
(pkg_dir / "hostname").write_text("flow-host")
|
||||||
|
|
||||||
|
monkeypatch.setattr(paths, "HOME", home)
|
||||||
|
monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles)
|
||||||
|
monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules")
|
||||||
|
monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json")
|
||||||
|
|
||||||
|
ctx = _make_ctx(tmp_path)
|
||||||
|
svc = DotfilesService(ctx)
|
||||||
|
|
||||||
|
with pytest.raises(PlanConflict, match="_shared/system/_root"):
|
||||||
|
svc._discover_packages(profile=None)
|
||||||
|
|
||||||
|
def test_root_paths_can_be_skipped(self, tmp_path, monkeypatch):
|
||||||
|
home = tmp_path / "home"
|
||||||
|
home.mkdir()
|
||||||
|
|
||||||
|
dotfiles = tmp_path / "dotfiles"
|
||||||
|
pkg_dir = dotfiles / "_shared" / "_root" / "etc"
|
||||||
|
pkg_dir.mkdir(parents=True)
|
||||||
|
(pkg_dir / "hostname").write_text("flow-host")
|
||||||
# Non-root file in the same package shouldn't be skipped
|
# Non-root file in the same package shouldn't be skipped
|
||||||
|
(dotfiles / "_shared" / "system" / "README").parent.mkdir(parents=True)
|
||||||
(dotfiles / "_shared" / "system" / "README").write_text("notes")
|
(dotfiles / "_shared" / "system" / "README").write_text("notes")
|
||||||
|
|
||||||
monkeypatch.setattr(paths, "HOME", home)
|
monkeypatch.setattr(paths, "HOME", home)
|
||||||
|
|||||||
Reference in New Issue
Block a user