Complete action runtime rewrite
This commit is contained in:
23
.github/workflows/test.yml
vendored
23
.github/workflows/test.yml
vendored
@@ -20,5 +20,26 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: make deps
|
run: make deps
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run unit tests
|
||||||
run: .venv/bin/python -m pytest tests/ -v --ignore=tests/e2e
|
run: .venv/bin/python -m pytest tests/ -v --ignore=tests/e2e
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python 3.13
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: make deps
|
||||||
|
|
||||||
|
- name: Verify Docker
|
||||||
|
run: docker version
|
||||||
|
|
||||||
|
- name: Run Docker-backed e2e tests
|
||||||
|
env:
|
||||||
|
FLOW_RUN_E2E: "1"
|
||||||
|
run: .venv/bin/python -m pytest tests/e2e/ -v
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -29,11 +29,11 @@ flow dotfiles repos push [--repo NAME] [--dry-run]
|
|||||||
|
|
||||||
# Packages
|
# Packages
|
||||||
flow packages install [NAMES...] [--profile NAME] [--dry-run]
|
flow packages install [NAMES...] [--profile NAME] [--dry-run]
|
||||||
flow packages remove NAMES...
|
flow packages remove NAMES... [--dry-run]
|
||||||
flow packages list [--all]
|
flow packages list [--all]
|
||||||
|
|
||||||
# Bootstrap
|
# Bootstrap
|
||||||
flow setup run PROFILE [--dry-run] # run a full bootstrap profile
|
flow setup run [PROFILE|--profile NAME] [--dry-run] [--var KEY=VALUE]
|
||||||
flow setup show PROFILE # preview profile steps
|
flow setup show PROFILE # preview profile steps
|
||||||
flow setup list
|
flow setup list
|
||||||
|
|
||||||
@@ -71,8 +71,9 @@ flow --no-color # disable colored output
|
|||||||
- `dotfiles` -> `dot`
|
- `dotfiles` -> `dot`
|
||||||
- `dotfiles repos` -> `dotfiles repo`
|
- `dotfiles repos` -> `dotfiles repo`
|
||||||
- `packages` -> `package`, `pkg`
|
- `packages` -> `package`, `pkg`
|
||||||
- `projects` -> `project`, `sync` (with `--fetch` default)
|
- `projects` -> `project`; `flow sync` -> `flow projects check --fetch`
|
||||||
- `setup` -> `bootstrap`
|
- `setup` -> `bootstrap`, `provision`
|
||||||
|
- `remote enter` -> `enter`
|
||||||
- `dev attach` -> `dev connect`
|
- `dev attach` -> `dev connect`
|
||||||
- `dev remove` -> `dev rm`
|
- `dev remove` -> `dev rm`
|
||||||
|
|
||||||
@@ -181,9 +182,15 @@ profiles:
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Four layers: **core** (runtime primitives, config, errors) -> **domain** (pure functions, frozen dataclasses) -> **services** (I/O orchestration) -> **commands** (thin CLI adapters).
|
Flow uses an action-centered runtime:
|
||||||
|
|
||||||
Core primitives (`SystemRuntime`): `CommandRunner`, `FileSystem`, `GitClient`, `TmuxClient`, `ContainerRuntime`.
|
- **cli** parses Typer command arguments and calls app use-cases.
|
||||||
|
- **app** resolves config/state, builds `ActionPlan` objects for executor-managed work, and keeps only explicit interactive boundaries outside the executor.
|
||||||
|
- **domain** modules keep planning and resolution logic pure with frozen dataclasses.
|
||||||
|
- **actions** are the execution boundary: `DomainAction` records domain intent, expansion converts it to `PrimitiveAction`, and `ActionExecutor` handles dry-run rendering, audit logging, rollback, and dispatch.
|
||||||
|
- **adapters** provide runtime primitives through `SystemRuntime`: `CommandRunner`, `FileSystem`, `GitClient`, `TmuxClient`, `ContainerRuntime`, download, and archive adapters.
|
||||||
|
|
||||||
|
Action audit records are appended to `actions.jsonl` under the relevant flow state directory.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
@@ -195,5 +202,6 @@ Core primitives (`SystemRuntime`): `CommandRunner`, `FileSystem`, `GitClient`, `
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
make deps # create .venv + install deps
|
make deps # create .venv + install deps
|
||||||
.venv/bin/python -m pytest tests/ -v # run tests
|
.venv/bin/python -m pytest tests/ -v --ignore=tests/e2e
|
||||||
|
FLOW_RUN_E2E=1 .venv/bin/python -m pytest tests/e2e/ -v # requires docker or podman
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
# Flow CLI Refactor Plan
|
# Flow CLI Refactor Status
|
||||||
|
|
||||||
> Based on code review (2026-03-22) and architecture discussion.
|
> Based on code review (2026-03-22), architecture discussion, and the current implementation.
|
||||||
> Spec: `docs/superpowers/specs/2026-03-16-flow-architecture-redesign.md`
|
> Spec: `docs/superpowers/specs/2026-03-16-flow-architecture-redesign.md`
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
The rewrite from the original "vibe-coded" codebase is **largely complete**. The four-layer
|
The action-runtime rewrite is implemented. `cli.py` is a thin Typer adapter, `flow.app` owns
|
||||||
architecture (core -> domain -> services -> commands) is in place, 303 tests pass, and the major
|
application orchestration, domain modules keep pure planning and resolution logic, and
|
||||||
structural problems from the old codebase (duplicated code, monkeypatching, dead modules, singleton
|
executor-managed mutations are represented as action plans before they reach runtime adapters.
|
||||||
abuse) have been resolved.
|
|
||||||
|
|
||||||
What remains is a second pass: finishing incomplete features, unifying the repo abstraction, and
|
The old structural problems from the original codebase (duplicated flows, monkeypatching, dead
|
||||||
trimming redundant commands.
|
modules, singleton-style runtime access) have been removed from the active command paths. The
|
||||||
|
remaining refactor work is deferred cleanup, not a blocker for the action-centered architecture.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Agreed Command Surface
|
## Command Surface
|
||||||
|
|
||||||
From the architecture discussion. This is the target.
|
This is the implemented command surface.
|
||||||
|
|
||||||
```
|
```
|
||||||
flow remote enter <target> # Host only. SSH+tmux into VM.
|
flow remote enter <target> # Host only. SSH+tmux into VM.
|
||||||
@@ -40,16 +40,16 @@ flow dotfiles edit <package> # Pull -> $EDITOR -> commit+push.
|
|||||||
|
|
||||||
flow dotfiles repos list # List ALL managed repos (dotfiles + modules).
|
flow dotfiles repos list # List ALL managed repos (dotfiles + modules).
|
||||||
flow dotfiles repos status [--repo=x] # Git status for one or all repos.
|
flow dotfiles repos status [--repo=x] # Git status for one or all repos.
|
||||||
flow dotfiles repos pull [--repo=x] # Pull one or all repos.
|
flow dotfiles repos pull [--repo=x] [--dry-run]
|
||||||
flow dotfiles repos push [--repo=x] # Push one or all repos.
|
flow dotfiles repos push [--repo=x] [--dry-run]
|
||||||
|
|
||||||
flow setup run [--profile p] # Bootstrap a machine.
|
flow setup run [profile|--profile p] [--dry-run] [--var KEY=VALUE]
|
||||||
flow setup list # List profiles.
|
flow setup list # List profiles.
|
||||||
flow setup show <profile> # Show profile plan.
|
flow setup show <profile> # Show profile plan.
|
||||||
|
|
||||||
flow packages install <name...> # Install packages from manifest.
|
flow packages install [name...] [--profile p] [--dry-run]
|
||||||
flow packages list [--all] # List packages.
|
flow packages list [--all] # List packages.
|
||||||
flow packages remove <name...> # Remove packages.
|
flow packages remove <name...> [--dry-run]
|
||||||
|
|
||||||
flow projects check [--fetch] # VM only. Git health across ~/projects.
|
flow projects check [--fetch] # VM only. Git health across ~/projects.
|
||||||
flow projects fetch # Fetch all project remotes.
|
flow projects fetch # Fetch all project remotes.
|
||||||
@@ -60,8 +60,9 @@ flow projects summary # Quick status overview.
|
|||||||
|
|
||||||
- `dotfiles` -> `dot`
|
- `dotfiles` -> `dot`
|
||||||
- `packages` -> `package`, `pkg`
|
- `packages` -> `package`, `pkg`
|
||||||
- `projects` -> `project`
|
- `projects` -> `project`; `flow sync` -> `flow projects check --fetch`
|
||||||
- `setup` -> `bootstrap`
|
- `setup` -> `bootstrap`, `provision`
|
||||||
|
- `remote enter` -> `enter`
|
||||||
- `dev attach` -> `dev connect`
|
- `dev attach` -> `dev connect`
|
||||||
- `dev rm` -> `dev remove`
|
- `dev rm` -> `dev remove`
|
||||||
- `dotfiles repos` -> `dotfiles repo`
|
- `dotfiles repos` -> `dotfiles repo`
|
||||||
@@ -72,7 +73,7 @@ flow projects summary # Quick status overview.
|
|||||||
- `--quiet` / `-q`
|
- `--quiet` / `-q`
|
||||||
- `--no-color`
|
- `--no-color`
|
||||||
|
|
||||||
### Commands removed (vs current implementation)
|
### Commands Removed During Refactor
|
||||||
|
|
||||||
| Removed | Reason |
|
| Removed | Reason |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
@@ -83,7 +84,31 @@ flow projects summary # Quick status overview.
|
|||||||
| `dotfiles modules list` | Replaced by `dotfiles repos list` |
|
| `dotfiles modules list` | Replaced by `dotfiles repos list` |
|
||||||
| `dotfiles modules sync` | Replaced by `dotfiles repos pull` |
|
| `dotfiles modules sync` | Replaced by `dotfiles repos pull` |
|
||||||
|
|
||||||
### Key design decision: modules are repos
|
## Action-Centered Architecture
|
||||||
|
|
||||||
|
The runtime boundary is `flow.actions`.
|
||||||
|
|
||||||
|
- `ActionPlan` is the unit of execution. It can contain high-level `DomainAction` entries and direct
|
||||||
|
`PrimitiveAction` entries.
|
||||||
|
- `DomainAction` records intent from a domain such as dotfiles, packages, repos, remote targets,
|
||||||
|
containers, completion, or setup.
|
||||||
|
- `expand_actions()` converts domain actions into primitive actions. Some domains supply already
|
||||||
|
expanded primitive plans when the service has concrete runtime arguments.
|
||||||
|
- `PrimitiveAction` is the canonical executor input for filesystem, process, git, download,
|
||||||
|
archive, container, and tmux operations.
|
||||||
|
- `ActionExecutor` owns dry-run output, append-only JSONL audit logging, rollback stack management,
|
||||||
|
rollback barriers, and dispatch into `SystemRuntime`.
|
||||||
|
|
||||||
|
App use-cases construct plans and pass them to the executor for action-backed commands. Direct
|
||||||
|
runtime calls are limited to explicit interactive boundaries such as attaching to tmux or entering a
|
||||||
|
container shell. Domain modules stay free of I/O where the current implementation has pure
|
||||||
|
resolution/planning functions.
|
||||||
|
|
||||||
|
Rollback is best-effort and explicit. Actions default to `rollbackable`; external boundaries such
|
||||||
|
as shell commands, remote sessions, and non-reversible git/container operations use `barrier` or
|
||||||
|
`none` policies.
|
||||||
|
|
||||||
|
## Key Design Decision: Modules Are Repos
|
||||||
|
|
||||||
The `_module.yaml` files define external git repos that provide content for dotfiles packages.
|
The `_module.yaml` files define external git repos that provide content for dotfiles packages.
|
||||||
These module repos are **not** git submodules -- they are regular git clones managed by flow.
|
These module repos are **not** git submodules -- they are regular git clones managed by flow.
|
||||||
@@ -97,185 +122,59 @@ needed (dotfiles repo is named `dotfiles`, module repos are named by their packa
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Unified Repos Abstraction
|
## Completed Work
|
||||||
|
|
||||||
This is the most impactful change. Currently:
|
### Unified Repos Abstraction
|
||||||
- `repo_status/pull/push` operate only on the dotfiles repo
|
|
||||||
- `sync_modules` handles module repos separately
|
|
||||||
- `list_modules` is a standalone method
|
|
||||||
|
|
||||||
**Target**: a single `_discover_repos() -> list[RepoInfo]` that returns all managed repos, and
|
`RepoInfo` with `module_ref` is the canonical repo model. `_discover_repos()` finds the dotfiles
|
||||||
repo commands iterate over them.
|
repo and module repos, and `repos list/status/pull/push` iterate that single collection with an
|
||||||
|
optional `--repo` filter. `dotfiles init` uses the same pull-or-clone flow.
|
||||||
|
|
||||||
### 1.1 Add `RepoInfo` model
|
### Command Trimming
|
||||||
|
|
||||||
```python
|
Removed redundant dotfiles commands:
|
||||||
# domain/dotfiles/models.py
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RepoInfo:
|
|
||||||
name: str # "dotfiles" or module package name (e.g. "nvim")
|
|
||||||
path: Path # Local clone path
|
|
||||||
remote: str # Remote URL
|
|
||||||
is_module: bool # False for dotfiles repo, True for module repos
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.2 Implement `_discover_repos`
|
- `dotfiles sync`: use `dotfiles repos pull` plus `dotfiles link`
|
||||||
|
- `dotfiles relink`: `dotfiles link` is idempotent
|
||||||
|
- `dotfiles undo`: use `dotfiles unlink`
|
||||||
|
- `dotfiles clean`: broken symlink repair is part of link planning
|
||||||
|
- `dotfiles modules list/sync`: use `dotfiles repos list/pull`
|
||||||
|
|
||||||
In `DotfilesService`: walk packages to find `_module.yaml` files, build `RepoInfo` for each
|
### Feature Completion
|
||||||
module repo, plus one for the dotfiles repo itself.
|
|
||||||
|
|
||||||
### 1.3 Refactor repo commands
|
- `dotfiles edit`: pull -> `$EDITOR`/`$VISUAL` -> scoped `git add` -> commit+push, with
|
||||||
|
`--no-commit` to skip auto-commit/push.
|
||||||
|
- `dotfiles status`: module info, link health, and package filtering.
|
||||||
|
- `dotfiles repos list`: all managed repos with name, type, local path, and clone status.
|
||||||
|
- `--no-color`: global flag added to `cli.py`.
|
||||||
|
- `--dry-run`: supported by dotfiles link/unlink, repos pull/push, packages install, setup run,
|
||||||
|
remote enter, and dev create.
|
||||||
|
|
||||||
Replace `repo_status`, `repo_pull`, `repo_push` with methods that iterate `_discover_repos()`,
|
### Improvements Over Spec
|
||||||
filtered by `--repo`. Add `repos_list`.
|
|
||||||
|
|
||||||
### 1.4 Remove `dotfiles modules` subcommand group
|
|
||||||
|
|
||||||
Delete `modules list` and `modules sync` subparsers. Remove `sync_modules`, `list_modules`
|
|
||||||
methods. Remove from completion candidates.
|
|
||||||
|
|
||||||
### 1.5 Update `dotfiles init`
|
|
||||||
|
|
||||||
`init` should clone the dotfiles repo, then discover `_module.yaml` files and clone all module
|
|
||||||
repos. Currently it calls `sync_modules()` -- this should call `repos_pull()` instead (which
|
|
||||||
pulls/clones all repos).
|
|
||||||
|
|
||||||
**Files**: `models.py`, `services/dotfiles.py`, `commands/dotfiles.py`, `commands/completion.py`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Remove Redundant Commands
|
|
||||||
|
|
||||||
### 2.1 Remove `dotfiles sync`
|
|
||||||
|
|
||||||
Currently does `git pull --ff-only` + `sync_modules` + optional relink. After the repos
|
|
||||||
unification, this is just `repos pull` + `link`. No need for a dedicated command.
|
|
||||||
|
|
||||||
### 2.2 Remove `dotfiles relink`
|
|
||||||
|
|
||||||
Currently calls `link()`. `link` is already idempotent -- calling it again reconciles state.
|
|
||||||
|
|
||||||
### 2.3 Remove `dotfiles undo`
|
|
||||||
|
|
||||||
`unlink` is the inverse of `link`. The backup/undo transaction machinery (`_save_backup`,
|
|
||||||
`_load_backup`, `_backup_path`) can be deleted.
|
|
||||||
|
|
||||||
### 2.4 Fold `dotfiles clean` into `dotfiles link`
|
|
||||||
|
|
||||||
`link` should detect and remove broken symlinks as part of reconciliation, not require a separate
|
|
||||||
`clean` step. Modify `plan_link` in `domain/dotfiles/planning.py` to include broken link removal
|
|
||||||
in its plan.
|
|
||||||
|
|
||||||
**Files**: `services/dotfiles.py`, `commands/dotfiles.py`, `commands/completion.py`,
|
|
||||||
`domain/dotfiles/planning.py`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Previously Incomplete Features -- DONE
|
|
||||||
|
|
||||||
### 3.1 `dotfiles edit` -- DONE
|
|
||||||
|
|
||||||
Implemented: pull -> `$EDITOR`/`$VISUAL` -> scoped `git add` -> commit+push.
|
|
||||||
Flag: `--no-commit` to skip auto-commit/push.
|
|
||||||
|
|
||||||
### 3.2 `dotfiles status` -- DONE
|
|
||||||
|
|
||||||
Enhanced: shows module info (`branch:main`), link health (`ok`/`broken`/`not linked`),
|
|
||||||
package name filtering via positional args.
|
|
||||||
|
|
||||||
### 3.3 `dotfiles repos list` -- DONE
|
|
||||||
|
|
||||||
Shows all managed repos (dotfiles + modules) with: name, type, local path, clone status.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Spec Deviations
|
|
||||||
|
|
||||||
### 4.1 `--no-color` global flag -- DONE
|
|
||||||
|
|
||||||
Added to `cli.py`.
|
|
||||||
|
|
||||||
### 4.2 `--dry-run` coverage -- DONE for dotfiles
|
|
||||||
|
|
||||||
`repos pull` and `repos push` now have `--dry-run`. Remaining:
|
|
||||||
|
|
||||||
| Command | Has it | Should have |
|
|
||||||
|---------|--------|-------------|
|
|
||||||
| `dev stop` | No | Consider |
|
|
||||||
| `dev rm` | No | Consider |
|
|
||||||
|
|
||||||
### 4.3 Improvements over spec
|
|
||||||
|
|
||||||
These are correct deviations -- the implementation improved on the spec:
|
These are correct deviations -- the implementation improved on the spec:
|
||||||
- `core/containers.py` + `core/tmux.py` extracted as adapters (spec had them inline)
|
|
||||||
|
- `adapters/containers.py` + `adapters/tmux.py` extracted as adapters (spec had them inline)
|
||||||
- `core/config_parse.py` + `core/yaml.py` extracted for config parsing
|
- `core/config_parse.py` + `core/yaml.py` extracted for config parsing
|
||||||
- `SystemRuntime` extended with `.containers` and `.tmux` fields
|
- `SystemRuntime` extended with containers, tmux, download, and archive runtime fields
|
||||||
|
- `flow.actions` extracted as the canonical execution layer instead of leaving mutation dispatch in
|
||||||
|
individual app use-cases
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Code Quality (done)
|
## CI
|
||||||
|
|
||||||
These were fixed in this session:
|
The GitHub Actions workflow is split into two jobs:
|
||||||
|
|
||||||
- `FakeRunner` consolidated from 3 test files into `tests/fakes.py`
|
- `unit`: installs dependencies and runs `pytest tests/ -v --ignore=tests/e2e`
|
||||||
- `services/dotfiles.py` now uses `flow.core.yaml.load_yaml_file` instead of raw `yaml`
|
- `e2e`: verifies Docker is available, sets `FLOW_RUN_E2E=1`, and runs `pytest tests/e2e/ -v`
|
||||||
- `ContainerRuntime.binary` no longer eagerly validates PATH for explicit modes
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Future (defer)
|
## Optional Future Work
|
||||||
|
|
||||||
### 6.1 Bootstrap as orchestrator
|
These are optional refinements, not blockers for the action-centered rewrite.
|
||||||
|
|
||||||
The spec envisions bootstrap as a pure orchestrator over packages + dotfiles + setup modules.
|
### Global `--dry-run`
|
||||||
Current implementation works but has its own package resolution logic. Defer until dotfiles and
|
|
||||||
packages domains are fully stable.
|
|
||||||
|
|
||||||
### 6.2 Global `--dry-run`
|
|
||||||
|
|
||||||
If per-command `--dry-run` becomes a maintenance burden, promote to a global flag in `cli.py`.
|
If per-command `--dry-run` becomes a maintenance burden, promote to a global flag in `cli.py`.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Execution Status
|
|
||||||
|
|
||||||
All phases complete. 315 tests pass, 0 failures.
|
|
||||||
|
|
||||||
### Phase A: Unify repos + trim commands -- DONE
|
|
||||||
|
|
||||||
1. `RepoInfo` model with `module_ref` field
|
|
||||||
2. `_discover_repos()` finds dotfiles + module repos
|
|
||||||
3. `repos_list/status/pull/push` iterate all repos with `--repo` filter
|
|
||||||
4. `repos list` subcommand added
|
|
||||||
5. `dotfiles modules` subcommand removed entirely
|
|
||||||
6. `dotfiles sync`, `relink`, `undo`, `clean` removed
|
|
||||||
7. Broken-symlink handling folded into `plan_link`
|
|
||||||
8. `dotfiles init` uses unified `repos_pull()`
|
|
||||||
|
|
||||||
### Phase B: Complete features -- DONE
|
|
||||||
|
|
||||||
9. `dotfiles edit` implemented (pull -> $EDITOR -> commit+push, scoped git add)
|
|
||||||
10. `dotfiles status` enhanced (module info, link health, package filtering)
|
|
||||||
|
|
||||||
### Phase C: CLI polish -- DONE
|
|
||||||
|
|
||||||
11. `--no-color` global flag added to `cli.py`
|
|
||||||
12. `--dry-run` added to `repos pull` and `repos push`
|
|
||||||
13. Zsh completion updated for new command surface
|
|
||||||
|
|
||||||
### Phase D: Code quality -- DONE
|
|
||||||
|
|
||||||
14. Dispatch patterns: completion `complete()`, dotfiles `_git_checkout_ref`, bootstrap phases
|
|
||||||
15. `FakeRunner` consolidated to single `tests/fakes.py` (all 4 test files)
|
|
||||||
16. Bootstrap `VALID_PHASES` as single source of truth in models
|
|
||||||
17. Bootstrap models: `Any` types replaced with `ProfilePackageEntry` and `PackageDef`
|
|
||||||
18. Canonical `ssh-keys` field (removed `ssh-keygen` alias)
|
|
||||||
19. `getattr` defensive patterns removed from command handlers
|
|
||||||
20. Test coverage added: `repos_status`, `repos_push`, `repos_pull --dry-run`, status filtering,
|
|
||||||
broken symlink repair
|
|
||||||
21. README updated to reflect new command surface
|
|
||||||
|
|
||||||
### Remaining (deferred)
|
|
||||||
|
|
||||||
- Bootstrap as orchestrator (section 6.1)
|
|
||||||
- Global `--dry-run` (section 6.2)
|
|
||||||
|
|||||||
@@ -52,8 +52,10 @@ class ActionExecutor:
|
|||||||
self.audit.write("action_start", {"plan": plan.name, "action": action})
|
self.audit.write("action_start", {"plan": plan.name, "action": action})
|
||||||
try:
|
try:
|
||||||
rollback = self._rollback_for(action)
|
rollback = self._rollback_for(action)
|
||||||
self._execute_primitive(action)
|
primitive_result = self._execute_primitive(action)
|
||||||
results.append(ActionResult(action.id, action.type, "success"))
|
if primitive_result is None:
|
||||||
|
primitive_result = ActionResult(action.id, action.type, "success")
|
||||||
|
results.append(primitive_result)
|
||||||
self.audit.write(
|
self.audit.write(
|
||||||
"action_success",
|
"action_success",
|
||||||
{"plan": plan.name, "action": action},
|
{"plan": plan.name, "action": action},
|
||||||
@@ -116,7 +118,7 @@ class ActionExecutor:
|
|||||||
)
|
)
|
||||||
return tuple(results)
|
return tuple(results)
|
||||||
|
|
||||||
def _execute_primitive(self, action: PrimitiveAction) -> None:
|
def _execute_primitive(self, action: PrimitiveAction) -> ActionResult | None:
|
||||||
t = action.type
|
t = action.type
|
||||||
p = action.payload
|
p = action.payload
|
||||||
|
|
||||||
@@ -193,24 +195,45 @@ class ActionExecutor:
|
|||||||
if not msg:
|
if not msg:
|
||||||
msg = f"Command failed with exit code {completed.returncode}"
|
msg = f"Command failed with exit code {completed.returncode}"
|
||||||
raise FlowError(msg)
|
raise FlowError(msg)
|
||||||
return
|
return ActionResult(
|
||||||
|
action.id,
|
||||||
|
action.type,
|
||||||
|
"success",
|
||||||
|
stdout=completed.stdout,
|
||||||
|
stderr=completed.stderr,
|
||||||
|
returncode=completed.returncode,
|
||||||
|
)
|
||||||
if t == "process.shell_user_hook":
|
if t == "process.shell_user_hook":
|
||||||
self.ctx.runtime.runner.run_shell(
|
completed = self.ctx.runtime.runner.run_shell(
|
||||||
str(p["command"]),
|
str(p["command"]),
|
||||||
cwd=Path(p["cwd"]) if p.get("cwd") else None,
|
cwd=Path(p["cwd"]) if p.get("cwd") else None,
|
||||||
env=p.get("env"),
|
env=p.get("env"),
|
||||||
capture_output=bool(p.get("capture_output", True)),
|
capture_output=bool(p.get("capture_output", True)),
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
return
|
return ActionResult(
|
||||||
|
action.id,
|
||||||
|
action.type,
|
||||||
|
"success",
|
||||||
|
stdout=completed.stdout,
|
||||||
|
stderr=completed.stderr,
|
||||||
|
returncode=completed.returncode,
|
||||||
|
)
|
||||||
|
|
||||||
if t == "git.clone":
|
if t == "git.clone":
|
||||||
argv = ["git", "clone"]
|
argv = ["git", "clone"]
|
||||||
if p.get("branch"):
|
if p.get("branch"):
|
||||||
argv.extend(["-b", str(p["branch"])])
|
argv.extend(["-b", str(p["branch"])])
|
||||||
argv.extend([str(p["source"]), str(p["target"])])
|
argv.extend([str(p["source"]), str(p["target"])])
|
||||||
self.ctx.runtime.runner.run(argv, check=True)
|
completed = self.ctx.runtime.runner.run(argv, check=True)
|
||||||
return
|
return ActionResult(
|
||||||
|
action.id,
|
||||||
|
action.type,
|
||||||
|
"success",
|
||||||
|
stdout=completed.stdout,
|
||||||
|
stderr=completed.stderr,
|
||||||
|
returncode=completed.returncode,
|
||||||
|
)
|
||||||
if t in {"git.pull", "git.push", "git.fetch", "git.checkout", "git.status"}:
|
if t in {"git.pull", "git.push", "git.fetch", "git.checkout", "git.status"}:
|
||||||
repo = Path(p["repo"])
|
repo = Path(p["repo"])
|
||||||
args = tuple(str(arg) for arg in p.get("args", ()))
|
args = tuple(str(arg) for arg in p.get("args", ()))
|
||||||
@@ -222,8 +245,15 @@ class ActionExecutor:
|
|||||||
args = args or ("fetch", "--all")
|
args = args or ("fetch", "--all")
|
||||||
elif t == "git.status":
|
elif t == "git.status":
|
||||||
args = args or ("status", "--short", "--branch")
|
args = args or ("status", "--short", "--branch")
|
||||||
self.ctx.runtime.git.run(repo, *args, check=True)
|
completed = self.ctx.runtime.git.run(repo, *args, check=True)
|
||||||
return
|
return ActionResult(
|
||||||
|
action.id,
|
||||||
|
action.type,
|
||||||
|
"success",
|
||||||
|
stdout=completed.stdout,
|
||||||
|
stderr=completed.stderr,
|
||||||
|
returncode=completed.returncode,
|
||||||
|
)
|
||||||
|
|
||||||
if t == "download.file":
|
if t == "download.file":
|
||||||
self.ctx.runtime.download.download_file(
|
self.ctx.runtime.download.download_file(
|
||||||
@@ -252,13 +282,13 @@ class ActionExecutor:
|
|||||||
self.ctx.runtime.containers.rm(str(p["name"]), force=bool(p.get("force", False)))
|
self.ctx.runtime.containers.rm(str(p["name"]), force=bool(p.get("force", False)))
|
||||||
return
|
return
|
||||||
if t == "container.exec":
|
if t == "container.exec":
|
||||||
self.ctx.runtime.containers.exec_in(
|
returncode = self.ctx.runtime.containers.exec_in(
|
||||||
str(p["name"]),
|
str(p["name"]),
|
||||||
tuple(str(arg) for arg in p["argv"]),
|
tuple(str(arg) for arg in p["argv"]),
|
||||||
interactive=bool(p.get("interactive", False)),
|
interactive=bool(p.get("interactive", False)),
|
||||||
detach_keys=p.get("detach_keys"),
|
detach_keys=p.get("detach_keys"),
|
||||||
)
|
)
|
||||||
return
|
return ActionResult(action.id, action.type, "success", returncode=returncode)
|
||||||
|
|
||||||
if t == "tmux.new_session":
|
if t == "tmux.new_session":
|
||||||
self.ctx.runtime.tmux.new_session(
|
self.ctx.runtime.tmux.new_session(
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ class ActionResult:
|
|||||||
action_type: str
|
action_type: str
|
||||||
status: str
|
status: str
|
||||||
message: str = ""
|
message: str = ""
|
||||||
|
stdout: str = ""
|
||||||
|
stderr: str = ""
|
||||||
|
returncode: int = 0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import shutil
|
import tarfile
|
||||||
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flow.adapters.filesystem import FileSystem
|
from flow.adapters.filesystem import FileSystem
|
||||||
@@ -16,7 +17,42 @@ class ArchiveClient:
|
|||||||
def extract(self, archive: Path, target: Path) -> None:
|
def extract(self, archive: Path, target: Path) -> None:
|
||||||
self.fs.ensure_dir(target)
|
self.fs.ensure_dir(target)
|
||||||
try:
|
try:
|
||||||
shutil.unpack_archive(str(archive), str(target))
|
if tarfile.is_tarfile(archive):
|
||||||
except (shutil.ReadError, ValueError) as e:
|
self._extract_tar(archive, target)
|
||||||
|
return
|
||||||
|
if zipfile.is_zipfile(archive):
|
||||||
|
self._extract_zip(archive, target)
|
||||||
|
return
|
||||||
|
except (tarfile.TarError, zipfile.BadZipFile, OSError) as e:
|
||||||
raise FlowError(f"Could not extract archive {archive}: {e}") from e
|
raise FlowError(f"Could not extract archive {archive}: {e}") from e
|
||||||
|
raise FlowError(f"Unsupported archive format: {archive}")
|
||||||
|
|
||||||
|
def _extract_tar(self, archive: Path, target: Path) -> None:
|
||||||
|
with tarfile.open(archive) as tar:
|
||||||
|
for member in tar.getmembers():
|
||||||
|
self._validate_member_path(target, member.name)
|
||||||
|
if not member.isfile() and not member.isdir():
|
||||||
|
raise FlowError(
|
||||||
|
f"Archive member type is not supported: {member.name}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
tar.extractall(target, filter="data")
|
||||||
|
except TypeError:
|
||||||
|
tar.extractall(target)
|
||||||
|
|
||||||
|
def _extract_zip(self, archive: Path, target: Path) -> None:
|
||||||
|
with zipfile.ZipFile(archive) as zf:
|
||||||
|
for member in zf.infolist():
|
||||||
|
self._validate_member_path(target, member.filename)
|
||||||
|
zf.extractall(target)
|
||||||
|
|
||||||
|
def _validate_member_path(self, target: Path, name: str) -> None:
|
||||||
|
member_path = Path(name)
|
||||||
|
if member_path.is_absolute():
|
||||||
|
raise FlowError(f"Archive member uses an absolute path: {name}")
|
||||||
|
if any(part == ".." for part in member_path.parts):
|
||||||
|
raise FlowError(f"Archive member escapes destination: {name}")
|
||||||
|
destination = (target / member_path).resolve(strict=False)
|
||||||
|
root = target.resolve(strict=False)
|
||||||
|
if not destination.is_relative_to(root):
|
||||||
|
raise FlowError(f"Archive member escapes destination: {name}")
|
||||||
|
|||||||
2
src/flow/app/__init__.py
Normal file
2
src/flow/app/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Application use-cases for CLI commands."""
|
||||||
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
# src/flow/services/bootstrap.py
|
|
||||||
"""BootstrapService -- orchestrates system setup."""
|
"""BootstrapService -- orchestrates system setup."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -86,14 +85,14 @@ class BootstrapService:
|
|||||||
without a handler here surfaces loudly at runtime.
|
without a handler here surfaces loudly at runtime.
|
||||||
"""
|
"""
|
||||||
if action.phase == "packages":
|
if action.phase == "packages":
|
||||||
from flow.services.packages import PackageService
|
from flow.app.packages import PackageService
|
||||||
pkg_svc = PackageService(self.ctx)
|
pkg_svc = PackageService(self.ctx)
|
||||||
if plan.packages_to_install:
|
if plan.packages_to_install:
|
||||||
pkg_svc.install(list(plan.packages_to_install))
|
pkg_svc.install(list(plan.packages_to_install))
|
||||||
return
|
return
|
||||||
|
|
||||||
if action.phase == "dotfiles":
|
if action.phase == "dotfiles":
|
||||||
from flow.services.dotfiles import DotfilesService
|
from flow.app.dotfiles import DotfilesService
|
||||||
dot_svc = DotfilesService(self.ctx)
|
dot_svc = DotfilesService(self.ctx)
|
||||||
dot_svc.link(profile=dotfiles_profile)
|
dot_svc.link(profile=dotfiles_profile)
|
||||||
return
|
return
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Shell completion support."""
|
"""Shell completion helpers."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
@@ -28,27 +27,6 @@ TOP_LEVEL_COMMANDS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def register(subparsers):
|
|
||||||
parser = subparsers.add_parser("completion", help="Shell completion helpers")
|
|
||||||
sub = parser.add_subparsers(dest="completion_action")
|
|
||||||
|
|
||||||
zsh = sub.add_parser("zsh", help="Print the zsh completion script")
|
|
||||||
zsh.set_defaults(handler=_run_zsh_script)
|
|
||||||
|
|
||||||
install = sub.add_parser("install-zsh", help="Install zsh completion")
|
|
||||||
install.add_argument("--dir", default="~/.zsh/completions")
|
|
||||||
install.add_argument("--rc", default="~/.zshrc")
|
|
||||||
install.add_argument("--no-rc", action="store_true")
|
|
||||||
install.set_defaults(handler=_run_install_zsh)
|
|
||||||
|
|
||||||
hidden = sub.add_parser("_zsh_complete", help=argparse.SUPPRESS)
|
|
||||||
hidden.add_argument("--cword", type=int, required=True, help=argparse.SUPPRESS)
|
|
||||||
hidden.add_argument("words", nargs="*", help=argparse.SUPPRESS)
|
|
||||||
hidden.set_defaults(handler=_run_zsh_complete)
|
|
||||||
|
|
||||||
parser.set_defaults(handler=_run_zsh_script)
|
|
||||||
|
|
||||||
|
|
||||||
def complete(ctx: FlowContext, words: Sequence[str], cword: int) -> list[str]:
|
def complete(ctx: FlowContext, words: Sequence[str], cword: int) -> list[str]:
|
||||||
before, current = _split_words(words, cword)
|
before, current = _split_words(words, cword)
|
||||||
|
|
||||||
@@ -365,11 +343,6 @@ def _complete_completion(before: Sequence[str], current: str) -> list[str]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _run_zsh_complete(ctx, args):
|
|
||||||
for item in complete(ctx, args.words, args.cword):
|
|
||||||
print(item)
|
|
||||||
|
|
||||||
|
|
||||||
def _zsh_script_text() -> str:
|
def _zsh_script_text() -> str:
|
||||||
return r'''#compdef flow
|
return r'''#compdef flow
|
||||||
|
|
||||||
@@ -394,47 +367,15 @@ compdef _flow flow
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
def _run_zsh_script(_ctx, _args):
|
def render_zsh_rc_update(content: str, completions_dir: Path) -> str:
|
||||||
print(_zsh_script_text())
|
|
||||||
|
|
||||||
|
|
||||||
def _run_install_zsh(_ctx, args):
|
|
||||||
completions_dir = Path(args.dir).expanduser()
|
|
||||||
completions_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
completion_file = completions_dir / "_flow"
|
|
||||||
completion_file.write_text(_zsh_script_text(), encoding="utf-8")
|
|
||||||
print(f"Installed completion script: {completion_file}")
|
|
||||||
|
|
||||||
if args.no_rc:
|
|
||||||
print("Skipped rc file update (--no-rc)")
|
|
||||||
return
|
|
||||||
|
|
||||||
rc_path = Path(args.rc).expanduser()
|
|
||||||
changed = _ensure_rc_snippet(rc_path, completions_dir)
|
|
||||||
if changed:
|
|
||||||
print(f"Updated shell rc: {rc_path}")
|
|
||||||
else:
|
|
||||||
print(f"Shell rc already configured: {rc_path}")
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_rc_snippet(rc_path: Path, completions_dir: Path) -> bool:
|
|
||||||
snippet = _zsh_rc_snippet(completions_dir)
|
snippet = _zsh_rc_snippet(completions_dir)
|
||||||
content = rc_path.read_text(encoding="utf-8") if rc_path.exists() else ""
|
|
||||||
|
|
||||||
if ZSH_RC_START in content and ZSH_RC_END in content:
|
if ZSH_RC_START in content and ZSH_RC_END in content:
|
||||||
start = content.find(ZSH_RC_START)
|
start = content.find(ZSH_RC_START)
|
||||||
end = content.find(ZSH_RC_END, start) + len(ZSH_RC_END)
|
end = content.find(ZSH_RC_END, start) + len(ZSH_RC_END)
|
||||||
updated = content[:start] + snippet.rstrip("\n") + content[end:]
|
return content[:start] + snippet.rstrip("\n") + content[end:]
|
||||||
if updated == content:
|
|
||||||
return False
|
|
||||||
rc_path.write_text(updated, encoding="utf-8")
|
|
||||||
return True
|
|
||||||
|
|
||||||
separator = "" if content.endswith("\n") or not content else "\n"
|
separator = "" if content.endswith("\n") or not content else "\n"
|
||||||
rc_path.parent.mkdir(parents=True, exist_ok=True)
|
return content + separator + snippet
|
||||||
rc_path.write_text(content + separator + snippet, encoding="utf-8")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _zsh_rc_snippet(completions_dir: Path) -> str:
|
def _zsh_rc_snippet(completions_dir: Path) -> str:
|
||||||
@@ -127,7 +127,20 @@ class ContainerService:
|
|||||||
if not self.rt.container_exists(cname):
|
if not self.rt.container_exists(cname):
|
||||||
raise FlowError(f"Container does not exist: {cname}")
|
raise FlowError(f"Container does not exist: {cname}")
|
||||||
if not self.rt.container_running(cname):
|
if not self.rt.container_running(cname):
|
||||||
self.rt.start(cname)
|
ActionExecutor(self.ctx).execute(
|
||||||
|
ActionPlan(
|
||||||
|
name=f"container.start.{cname}",
|
||||||
|
primitive_actions=(
|
||||||
|
PrimitiveAction(
|
||||||
|
id=f"container.{cname}.start",
|
||||||
|
type="container.start",
|
||||||
|
description=f"Start container {cname}",
|
||||||
|
payload={"name": cname},
|
||||||
|
rollback_policy=RollbackPolicy.NONE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if not shutil.which("tmux"):
|
if not shutil.which("tmux"):
|
||||||
self.ctx.console.warn("tmux not found; falling back to direct exec")
|
self.ctx.console.warn("tmux not found; falling back to direct exec")
|
||||||
@@ -138,13 +151,36 @@ class ContainerService:
|
|||||||
image_ref = parse_image_ref(image_str)
|
image_ref = parse_image_ref(image_str)
|
||||||
|
|
||||||
if not self.tmux.has_session(cname):
|
if not self.tmux.has_session(cname):
|
||||||
self.tmux.new_session(
|
ActionExecutor(self.ctx).execute(
|
||||||
cname,
|
ActionPlan(
|
||||||
detached=True,
|
name=f"tmux.session.{cname}",
|
||||||
env={"DF_IMAGE": image_ref.label},
|
primitive_actions=(
|
||||||
command=f"flow dev exec {name}",
|
PrimitiveAction(
|
||||||
|
id=f"tmux.{cname}.new-session",
|
||||||
|
type="tmux.new_session",
|
||||||
|
description=f"Create tmux session {cname}",
|
||||||
|
payload={
|
||||||
|
"name": cname,
|
||||||
|
"detached": True,
|
||||||
|
"env": {"DF_IMAGE": image_ref.label},
|
||||||
|
"command": f"flow dev exec {name}",
|
||||||
|
},
|
||||||
|
rollback_policy=RollbackPolicy.NONE,
|
||||||
|
),
|
||||||
|
PrimitiveAction(
|
||||||
|
id=f"tmux.{cname}.default-command",
|
||||||
|
type="tmux.set_option",
|
||||||
|
description=f"Set default command for {cname}",
|
||||||
|
payload={
|
||||||
|
"session": cname,
|
||||||
|
"option": "default-command",
|
||||||
|
"value": f"flow dev exec {name}",
|
||||||
|
},
|
||||||
|
rollback_policy=RollbackPolicy.NONE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
self.tmux.set_option(cname, "default-command", f"flow dev exec {name}")
|
|
||||||
|
|
||||||
self.tmux.attach_or_switch(cname)
|
self.tmux.attach_or_switch(cname)
|
||||||
|
|
||||||
@@ -201,9 +237,24 @@ class ContainerService:
|
|||||||
raise FlowError("tmux is required for respawn but was not found")
|
raise FlowError("tmux is required for respawn but was not found")
|
||||||
|
|
||||||
cname = container_name(name)
|
cname = container_name(name)
|
||||||
for pane in self.tmux.list_panes(cname):
|
panes = self.tmux.list_panes(cname)
|
||||||
|
for pane in panes:
|
||||||
self.ctx.console.info(f"Respawning {pane}...")
|
self.ctx.console.info(f"Respawning {pane}...")
|
||||||
self.tmux.respawn_pane(pane)
|
ActionExecutor(self.ctx).execute(
|
||||||
|
ActionPlan(
|
||||||
|
name=f"tmux.respawn.{cname}",
|
||||||
|
primitive_actions=tuple(
|
||||||
|
PrimitiveAction(
|
||||||
|
id=f"tmux.{cname}.respawn.{index}",
|
||||||
|
type="tmux.respawn_pane",
|
||||||
|
description=f"Respawn tmux pane {pane}",
|
||||||
|
payload={"pane": pane},
|
||||||
|
rollback_policy=RollbackPolicy.NONE,
|
||||||
|
)
|
||||||
|
for index, pane in enumerate(panes)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def list(self) -> None:
|
def list(self) -> None:
|
||||||
"""List flow-managed containers."""
|
"""List flow-managed containers."""
|
||||||
@@ -203,29 +203,84 @@ class DotfilesService:
|
|||||||
# Open editor
|
# Open editor
|
||||||
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR", "vi")
|
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR", "vi")
|
||||||
edit_dir = repo.path if repo.is_module else pkg.source_dir
|
edit_dir = repo.path if repo.is_module else pkg.source_dir
|
||||||
result = self.ctx.runtime.runner.run(
|
self._executor().execute(
|
||||||
[editor, str(edit_dir)], capture_output=False,
|
ActionPlan(
|
||||||
|
name=f"dotfiles.edit.{package_name}",
|
||||||
|
primitive_actions=(
|
||||||
|
PrimitiveAction(
|
||||||
|
id=f"dotfiles.edit.{package_name}.editor",
|
||||||
|
type="process.argv",
|
||||||
|
description=f"Open editor for {package_name}",
|
||||||
|
payload={
|
||||||
|
"argv": (editor, str(edit_dir)),
|
||||||
|
"capture_output": False,
|
||||||
|
},
|
||||||
|
rollback_policy=RollbackPolicy.BARRIER,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
|
||||||
raise FlowError(f"Editor exited with code {result.returncode}")
|
|
||||||
|
|
||||||
if no_commit:
|
if no_commit:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check for changes and auto-commit+push
|
# Check for changes and auto-commit+push
|
||||||
status = self.ctx.runtime.git.run(
|
status_summary = self._executor().execute(
|
||||||
repo.path, "status", "--porcelain", check=True,
|
ActionPlan(
|
||||||
|
name=f"dotfiles.edit.{package_name}.status",
|
||||||
|
domain_actions=(
|
||||||
|
DomainAction(
|
||||||
|
id=f"repo.{repo.name}.status",
|
||||||
|
kind="repo",
|
||||||
|
action="status",
|
||||||
|
description=f"Check changes in {repo.name}",
|
||||||
|
payload={"repo": repo.path, "args": ("status", "--porcelain")},
|
||||||
|
rollback_policy=RollbackPolicy.NONE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if not status.stdout.strip():
|
if not status_summary.results[0].stdout.strip():
|
||||||
self.ctx.console.info("No changes.")
|
self.ctx.console.info("No changes.")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.ctx.runtime.git.run(repo.path, "add", str(edit_dir), check=True)
|
self._executor().execute(
|
||||||
self.ctx.runtime.git.run(
|
ActionPlan(
|
||||||
repo.path, "commit", "-m", f"dotfiles: update {package_name}",
|
name=f"dotfiles.edit.{package_name}.commit",
|
||||||
check=True,
|
primitive_actions=(
|
||||||
|
PrimitiveAction(
|
||||||
|
id=f"dotfiles.edit.{package_name}.add",
|
||||||
|
type="process.argv",
|
||||||
|
description=f"Stage changes for {package_name}",
|
||||||
|
payload={"argv": ("git", "-C", str(repo.path), "add", str(edit_dir))},
|
||||||
|
rollback_policy=RollbackPolicy.BARRIER,
|
||||||
|
),
|
||||||
|
PrimitiveAction(
|
||||||
|
id=f"dotfiles.edit.{package_name}.commit",
|
||||||
|
type="process.argv",
|
||||||
|
description=f"Commit changes for {package_name}",
|
||||||
|
payload={
|
||||||
|
"argv": (
|
||||||
|
"git",
|
||||||
|
"-C",
|
||||||
|
str(repo.path),
|
||||||
|
"commit",
|
||||||
|
"-m",
|
||||||
|
f"dotfiles: update {package_name}",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
rollback_policy=RollbackPolicy.BARRIER,
|
||||||
|
),
|
||||||
|
PrimitiveAction(
|
||||||
|
id=f"dotfiles.edit.{package_name}.push",
|
||||||
|
type="git.push",
|
||||||
|
description=f"Push changes for {package_name}",
|
||||||
|
payload={"repo": repo.path, "args": ("push",)},
|
||||||
|
rollback_policy=RollbackPolicy.NONE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
self.ctx.runtime.git.run(repo.path, "push", check=True)
|
|
||||||
self.ctx.console.success(f"Changes to {package_name} committed and pushed.")
|
self.ctx.console.success(f"Changes to {package_name} committed and pushed.")
|
||||||
|
|
||||||
# ── Init ─────────────────────────────────────────────────────────────
|
# ── Init ─────────────────────────────────────────────────────────────
|
||||||
@@ -291,10 +346,25 @@ class DotfilesService:
|
|||||||
self.ctx.console.warn(f"{repo.name}: not cloned")
|
self.ctx.console.warn(f"{repo.name}: not cloned")
|
||||||
continue
|
continue
|
||||||
self.ctx.console.info(f"[{repo.name}]")
|
self.ctx.console.info(f"[{repo.name}]")
|
||||||
result = self.ctx.runtime.git.run(
|
summary = self._executor().execute(
|
||||||
repo.path, "status", "--short", "--branch", check=True,
|
ActionPlan(
|
||||||
|
name=f"repo.status.{repo.name}",
|
||||||
|
domain_actions=(
|
||||||
|
DomainAction(
|
||||||
|
id=f"repo.{repo.name}.status",
|
||||||
|
kind="repo",
|
||||||
|
action="status",
|
||||||
|
description=f"Show git status for {repo.name}",
|
||||||
|
payload={
|
||||||
|
"repo": repo.path,
|
||||||
|
"args": ("status", "--short", "--branch"),
|
||||||
|
},
|
||||||
|
rollback_policy=RollbackPolicy.NONE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
output = result.stdout.strip()
|
output = summary.results[0].stdout.strip()
|
||||||
if output:
|
if output:
|
||||||
self.ctx.console.info(output)
|
self.ctx.console.info(output)
|
||||||
else:
|
else:
|
||||||
@@ -481,27 +551,6 @@ class DotfilesService:
|
|||||||
primitive_actions=tuple(primitives),
|
primitive_actions=tuple(primitives),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _pull_module_repo(self, repo: RepoInfo) -> None:
|
|
||||||
"""Pull a module repo, respecting its ref type."""
|
|
||||||
module = repo.module_ref
|
|
||||||
if module is None:
|
|
||||||
self.ctx.runtime.git.run(repo.path, "pull", "--ff-only", check=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
self.ctx.runtime.git.run(repo.path, "fetch", "--all", check=True)
|
|
||||||
ref = _git_checkout_ref(module)
|
|
||||||
self.ctx.runtime.git.run(repo.path, "checkout", ref, check=True)
|
|
||||||
if module.ref_type == "branch":
|
|
||||||
self.ctx.runtime.git.run(repo.path, "pull", "--ff-only", check=True)
|
|
||||||
|
|
||||||
def _checkout_module_ref(self, repo: RepoInfo) -> None:
|
|
||||||
"""Checkout the correct ref after cloning a module repo."""
|
|
||||||
module = repo.module_ref
|
|
||||||
if module is None:
|
|
||||||
return
|
|
||||||
ref = _git_checkout_ref(module)
|
|
||||||
self.ctx.runtime.git.run(repo.path, "checkout", ref, check=True)
|
|
||||||
|
|
||||||
def _find_package_repo(self, package_name: str) -> tuple[Package, RepoInfo]:
|
def _find_package_repo(self, package_name: str) -> tuple[Package, RepoInfo]:
|
||||||
"""Find a package and its owning repo."""
|
"""Find a package and its owning repo."""
|
||||||
repos = self._discover_repos()
|
repos = self._discover_repos()
|
||||||
139
src/flow/app/projects.py
Normal file
139
src/flow/app/projects.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""ProjectService -- manages git project status."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flow.actions import ActionExecutor, ActionPlan, DomainAction, RollbackPolicy
|
||||||
|
from flow.core.config import FlowContext
|
||||||
|
from flow.core.errors import FlowError
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectService:
|
||||||
|
def __init__(self, ctx: FlowContext):
|
||||||
|
self.ctx = ctx
|
||||||
|
self.projects_dir = Path(self.ctx.config.projects_dir).expanduser()
|
||||||
|
|
||||||
|
def check(self, *, fetch: bool = False) -> None:
|
||||||
|
"""Check status of all git repos in projects dir."""
|
||||||
|
if not self.projects_dir.is_dir():
|
||||||
|
self.ctx.console.info(f"Projects directory not found: {self.projects_dir}")
|
||||||
|
return
|
||||||
|
|
||||||
|
repos = self._find_repos()
|
||||||
|
if not repos:
|
||||||
|
self.ctx.console.info("No git repositories found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if fetch:
|
||||||
|
self.ctx.console.info("Fetching all remotes...")
|
||||||
|
self._executor().execute(
|
||||||
|
self._repo_action_plan(
|
||||||
|
name="projects.fetch",
|
||||||
|
repos=repos,
|
||||||
|
action="fetch",
|
||||||
|
args=("fetch", "--all", "--quiet"),
|
||||||
|
description_prefix="Fetch remotes for",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for repo in repos:
|
||||||
|
status = self._repo_status(repo)
|
||||||
|
rows.append([repo.name, status])
|
||||||
|
|
||||||
|
self.ctx.console.table(["REPO", "STATUS"], rows)
|
||||||
|
|
||||||
|
def summary(self) -> None:
|
||||||
|
"""Quick summary without fetch."""
|
||||||
|
self.check(fetch=False)
|
||||||
|
|
||||||
|
def fetch(self) -> None:
|
||||||
|
"""Fetch all remotes then show status."""
|
||||||
|
self.check(fetch=True)
|
||||||
|
|
||||||
|
def _find_repos(self) -> list[Path]:
|
||||||
|
"""Find all git repos in projects dir (immediate children only)."""
|
||||||
|
repos = []
|
||||||
|
for child in sorted(self.projects_dir.iterdir()):
|
||||||
|
if not child.is_dir():
|
||||||
|
continue
|
||||||
|
# Check for .git dir or .git file (worktree)
|
||||||
|
git_path = child / ".git"
|
||||||
|
if git_path.exists():
|
||||||
|
repos.append(child)
|
||||||
|
return repos
|
||||||
|
|
||||||
|
def _repo_status(self, repo: Path) -> str:
|
||||||
|
"""Get human-readable status for a repo."""
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
status_result = self._executor().execute(
|
||||||
|
self._repo_action_plan(
|
||||||
|
name=f"projects.status.{repo.name}",
|
||||||
|
repos=[repo],
|
||||||
|
action="status",
|
||||||
|
args=("status", "--porcelain"),
|
||||||
|
description_prefix="Check working tree for",
|
||||||
|
)
|
||||||
|
).results[0]
|
||||||
|
if status_result.stdout.strip():
|
||||||
|
parts.append("uncommitted changes")
|
||||||
|
|
||||||
|
# Check ahead/behind
|
||||||
|
rev_result = self._executor().execute(
|
||||||
|
self._repo_action_plan(
|
||||||
|
name=f"projects.rev-list.{repo.name}",
|
||||||
|
repos=[repo],
|
||||||
|
action="status",
|
||||||
|
args=("rev-list", "--left-right", "--count", "HEAD...@{u}"),
|
||||||
|
description_prefix="Check git rev-list upstream divergence for",
|
||||||
|
)
|
||||||
|
).results[0]
|
||||||
|
counts = rev_result.stdout.strip().split()
|
||||||
|
if len(counts) != 2:
|
||||||
|
raise FlowError(
|
||||||
|
f"{repo.name}: unexpected git rev-list output: {rev_result.stdout.strip()!r}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ahead, behind = int(counts[0]), int(counts[1])
|
||||||
|
except ValueError as e:
|
||||||
|
raise FlowError(
|
||||||
|
f"{repo.name}: unexpected git rev-list output: {rev_result.stdout.strip()!r}"
|
||||||
|
) from e
|
||||||
|
if ahead > 0:
|
||||||
|
parts.append(f"{ahead} ahead")
|
||||||
|
if behind > 0:
|
||||||
|
parts.append(f"{behind} behind")
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
parts.append("clean")
|
||||||
|
|
||||||
|
return ", ".join(parts)
|
||||||
|
|
||||||
|
def _repo_action_plan(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
repos: list[Path],
|
||||||
|
action: str,
|
||||||
|
args: tuple[str, ...],
|
||||||
|
description_prefix: str,
|
||||||
|
) -> ActionPlan:
|
||||||
|
return ActionPlan(
|
||||||
|
name=name,
|
||||||
|
domain_actions=tuple(
|
||||||
|
DomainAction(
|
||||||
|
id=f"projects.{repo.name}.{action}",
|
||||||
|
kind="repo",
|
||||||
|
action=action,
|
||||||
|
description=f"{description_prefix} {repo.name}",
|
||||||
|
payload={"repo": repo, "args": args},
|
||||||
|
rollback_policy=RollbackPolicy.NONE,
|
||||||
|
)
|
||||||
|
for repo in repos
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _executor(self) -> ActionExecutor:
|
||||||
|
return ActionExecutor(self.ctx)
|
||||||
@@ -17,12 +17,12 @@ from flow.core.console import Console
|
|||||||
from flow.core.errors import FlowError
|
from flow.core.errors import FlowError
|
||||||
from flow.core.platform import detect_context, detect_platform
|
from flow.core.platform import detect_context, detect_platform
|
||||||
from flow.core.runtime import SystemRuntime
|
from flow.core.runtime import SystemRuntime
|
||||||
from flow.services.bootstrap import BootstrapService
|
from flow.app.bootstrap import BootstrapService
|
||||||
from flow.services.containers import ContainerService
|
from flow.app.containers import ContainerService
|
||||||
from flow.services.dotfiles import DotfilesService
|
from flow.app.dotfiles import DotfilesService
|
||||||
from flow.services.packages import PackageService
|
from flow.app.packages import PackageService
|
||||||
from flow.services.projects import ProjectService
|
from flow.app.projects import ProjectService
|
||||||
from flow.services.remote import RemoteService
|
from flow.app.remote import RemoteService
|
||||||
|
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
@@ -475,14 +475,14 @@ def sync_alias(
|
|||||||
@completion_app.callback(invoke_without_command=True)
|
@completion_app.callback(invoke_without_command=True)
|
||||||
def completion_default(ctx: typer.Context) -> None:
|
def completion_default(ctx: typer.Context) -> None:
|
||||||
if ctx.invoked_subcommand is None:
|
if ctx.invoked_subcommand is None:
|
||||||
from flow.commands.completion import _zsh_script_text
|
from flow.app.completion import _zsh_script_text
|
||||||
|
|
||||||
typer.echo(_zsh_script_text(), nl=False)
|
typer.echo(_zsh_script_text(), nl=False)
|
||||||
|
|
||||||
|
|
||||||
@completion_app.command("zsh")
|
@completion_app.command("zsh")
|
||||||
def completion_zsh() -> None:
|
def completion_zsh() -> None:
|
||||||
from flow.commands.completion import _zsh_script_text
|
from flow.app.completion import _zsh_script_text
|
||||||
|
|
||||||
typer.echo(_zsh_script_text(), nl=False)
|
typer.echo(_zsh_script_text(), nl=False)
|
||||||
|
|
||||||
@@ -495,7 +495,7 @@ def completion_install_zsh(
|
|||||||
no_rc: bool = typer.Option(False, "--no-rc"),
|
no_rc: bool = typer.Option(False, "--no-rc"),
|
||||||
) -> None:
|
) -> None:
|
||||||
def _install(flow_ctx: FlowContext) -> None:
|
def _install(flow_ctx: FlowContext) -> None:
|
||||||
from flow.commands.completion import _zsh_rc_snippet, _zsh_script_text
|
from flow.app.completion import render_zsh_rc_update, _zsh_script_text
|
||||||
|
|
||||||
completions_dir = Path(directory).expanduser()
|
completions_dir = Path(directory).expanduser()
|
||||||
completion_file = completions_dir / "_flow"
|
completion_file = completions_dir / "_flow"
|
||||||
@@ -510,16 +510,7 @@ def completion_install_zsh(
|
|||||||
if not no_rc:
|
if not no_rc:
|
||||||
rc_path = Path(rc).expanduser()
|
rc_path = Path(rc).expanduser()
|
||||||
content = rc_path.read_text(encoding="utf-8") if rc_path.exists() else ""
|
content = rc_path.read_text(encoding="utf-8") if rc_path.exists() else ""
|
||||||
snippet = _zsh_rc_snippet(completions_dir)
|
updated = render_zsh_rc_update(content, completions_dir)
|
||||||
start_marker = "# >>> flow completion >>>"
|
|
||||||
end_marker = "# <<< flow completion <<<"
|
|
||||||
if start_marker in content and end_marker in content:
|
|
||||||
start = content.find(start_marker)
|
|
||||||
end = content.find(end_marker, start) + len(end_marker)
|
|
||||||
updated = content[:start] + snippet.rstrip("\n") + content[end:]
|
|
||||||
else:
|
|
||||||
separator = "" if content.endswith("\n") or not content else "\n"
|
|
||||||
updated = content + separator + snippet
|
|
||||||
primitives.append(
|
primitives.append(
|
||||||
PrimitiveAction(
|
PrimitiveAction(
|
||||||
id="completion.zsh.write-rc",
|
id="completion.zsh.write-rc",
|
||||||
@@ -553,7 +544,7 @@ def completion_zsh_complete(
|
|||||||
cword: int = typer.Option(..., "--cword", help="Completion word index"),
|
cword: int = typer.Option(..., "--cword", help="Completion word index"),
|
||||||
words: Optional[list[str]] = typer.Argument(None),
|
words: Optional[list[str]] = typer.Argument(None),
|
||||||
) -> None:
|
) -> None:
|
||||||
from flow.commands.completion import complete
|
from flow.app.completion import complete
|
||||||
|
|
||||||
for item in complete(_ctx(ctx), words or [], cword):
|
for item in complete(_ctx(ctx), words or [], cword):
|
||||||
typer.echo(item)
|
typer.echo(item)
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
"""Dev container commands."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from flow.core.config import FlowContext
|
|
||||||
from flow.services.containers import ContainerService
|
|
||||||
|
|
||||||
|
|
||||||
def register(subparsers):
|
|
||||||
p = subparsers.add_parser("dev", help="Manage development containers")
|
|
||||||
sub = p.add_subparsers(dest="dev_action")
|
|
||||||
|
|
||||||
create = sub.add_parser("create", help="Create and start a development container")
|
|
||||||
create.add_argument("name", help="Container name")
|
|
||||||
create.add_argument("-i", "--image", required=True, help="Container image")
|
|
||||||
create.add_argument("-p", "--project", help="Project path to mount at /workspace")
|
|
||||||
create.add_argument("--dry-run", action="store_true")
|
|
||||||
create.set_defaults(handler=_create)
|
|
||||||
|
|
||||||
attach = sub.add_parser("attach", aliases=["connect"], help="Attach to the container tmux session")
|
|
||||||
attach.add_argument("name", help="Container name")
|
|
||||||
attach.set_defaults(handler=_attach)
|
|
||||||
|
|
||||||
exec_cmd = sub.add_parser("exec", help="Execute a command in a container")
|
|
||||||
exec_cmd.add_argument("name", help="Container name")
|
|
||||||
exec_cmd.add_argument("cmd", nargs="*", help="Command to run")
|
|
||||||
exec_cmd.set_defaults(handler=_exec)
|
|
||||||
|
|
||||||
enter = sub.add_parser("enter", help="Open an interactive shell in a container")
|
|
||||||
enter.add_argument("name", help="Container name")
|
|
||||||
enter.set_defaults(handler=_enter)
|
|
||||||
|
|
||||||
stop = sub.add_parser("stop", help="Stop a container")
|
|
||||||
stop.add_argument("name", help="Container name")
|
|
||||||
stop.add_argument("--kill", action="store_true", help="Kill instead of graceful stop")
|
|
||||||
stop.set_defaults(handler=_stop)
|
|
||||||
|
|
||||||
rm = sub.add_parser("remove", aliases=["rm"], help="Remove a container")
|
|
||||||
rm.add_argument("name", help="Container name")
|
|
||||||
rm.add_argument("-f", "--force", action="store_true", help="Force removal")
|
|
||||||
rm.set_defaults(handler=_remove)
|
|
||||||
|
|
||||||
respawn = sub.add_parser("respawn", help="Respawn tmux panes for a session")
|
|
||||||
respawn.add_argument("name", help="Container name")
|
|
||||||
respawn.set_defaults(handler=_respawn)
|
|
||||||
|
|
||||||
ls = sub.add_parser("list", help="List development containers")
|
|
||||||
ls.set_defaults(handler=_list)
|
|
||||||
|
|
||||||
p.set_defaults(handler=_default)
|
|
||||||
|
|
||||||
|
|
||||||
def _default(ctx: FlowContext, args):
|
|
||||||
_list(ctx, args)
|
|
||||||
|
|
||||||
|
|
||||||
def _create(ctx: FlowContext, args):
|
|
||||||
ContainerService(ctx).create(
|
|
||||||
args.name,
|
|
||||||
args.image,
|
|
||||||
project_path=args.project,
|
|
||||||
dry_run=args.dry_run,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _attach(ctx: FlowContext, args):
|
|
||||||
ContainerService(ctx).connect(args.name)
|
|
||||||
|
|
||||||
|
|
||||||
def _exec(ctx: FlowContext, args):
|
|
||||||
ContainerService(ctx).exec(args.name, args.cmd or None)
|
|
||||||
|
|
||||||
|
|
||||||
def _enter(ctx: FlowContext, args):
|
|
||||||
ContainerService(ctx).exec(args.name)
|
|
||||||
|
|
||||||
|
|
||||||
def _stop(ctx: FlowContext, args):
|
|
||||||
ContainerService(ctx).stop(args.name, kill=args.kill)
|
|
||||||
|
|
||||||
|
|
||||||
def _remove(ctx: FlowContext, args):
|
|
||||||
ContainerService(ctx).remove(args.name, force=args.force)
|
|
||||||
|
|
||||||
|
|
||||||
def _respawn(ctx: FlowContext, args):
|
|
||||||
ContainerService(ctx).respawn(args.name)
|
|
||||||
|
|
||||||
|
|
||||||
def _list(ctx: FlowContext, args):
|
|
||||||
ContainerService(ctx).list()
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
"""Dotfiles commands."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from flow.core.config import FlowContext
|
|
||||||
from flow.services.dotfiles import DotfilesService
|
|
||||||
|
|
||||||
|
|
||||||
def register(subparsers):
|
|
||||||
p = subparsers.add_parser("dotfiles", aliases=["dot"], help="Manage dotfile symlinks")
|
|
||||||
sub = p.add_subparsers(dest="dotfiles_action")
|
|
||||||
|
|
||||||
init = sub.add_parser("init", help="Clone the dotfiles repository")
|
|
||||||
init.add_argument("--repo", help="Override the configured repository URL")
|
|
||||||
init.set_defaults(handler=_init)
|
|
||||||
|
|
||||||
link = sub.add_parser("link", help="Reconcile dotfile symlinks")
|
|
||||||
link.add_argument("--profile", help="Profile to include")
|
|
||||||
link.add_argument("--dry-run", "-n", action="store_true")
|
|
||||||
link.add_argument("--skip", nargs="*", default=[])
|
|
||||||
link.set_defaults(handler=_link)
|
|
||||||
|
|
||||||
unlink = sub.add_parser("unlink", help="Remove managed symlinks")
|
|
||||||
unlink.add_argument("packages", nargs="*", help="Packages to unlink (all if empty)")
|
|
||||||
unlink.add_argument("--dry-run", "-n", action="store_true")
|
|
||||||
unlink.set_defaults(handler=_unlink)
|
|
||||||
|
|
||||||
status = sub.add_parser("status", help="Show package and link status")
|
|
||||||
status.add_argument("packages", nargs="*", help="Filter by package name")
|
|
||||||
status.set_defaults(handler=_status)
|
|
||||||
|
|
||||||
edit = sub.add_parser("edit", help="Edit a package (pull -> editor -> commit+push)")
|
|
||||||
edit.add_argument("package", help="Package name")
|
|
||||||
edit.add_argument("--no-commit", action="store_true", help="Skip auto-commit/push")
|
|
||||||
edit.set_defaults(handler=_edit)
|
|
||||||
|
|
||||||
# repos subcommand group (unified: dotfiles repo + module repos)
|
|
||||||
repo = sub.add_parser("repos", aliases=["repo"], help="Manage dotfiles and module repos")
|
|
||||||
repo_sub = repo.add_subparsers(dest="dotfiles_repo_action")
|
|
||||||
|
|
||||||
repo_list = repo_sub.add_parser("list", help="List all managed repos")
|
|
||||||
repo_list.set_defaults(handler=_repos_list)
|
|
||||||
|
|
||||||
repo_status = repo_sub.add_parser("status", help="Show git status")
|
|
||||||
repo_status.add_argument("--repo", dest="repo_filter", help="Filter by repo name")
|
|
||||||
repo_status.set_defaults(handler=_repos_status)
|
|
||||||
|
|
||||||
repo_pull = repo_sub.add_parser("pull", help="Pull (or clone) repos")
|
|
||||||
repo_pull.add_argument("--repo", dest="repo_filter", help="Filter by repo name")
|
|
||||||
repo_pull.add_argument("--dry-run", "-n", action="store_true")
|
|
||||||
repo_pull.set_defaults(handler=_repos_pull)
|
|
||||||
|
|
||||||
repo_push = repo_sub.add_parser("push", help="Push repos")
|
|
||||||
repo_push.add_argument("--repo", dest="repo_filter", help="Filter by repo name")
|
|
||||||
repo_push.add_argument("--dry-run", "-n", action="store_true")
|
|
||||||
repo_push.set_defaults(handler=_repos_push)
|
|
||||||
|
|
||||||
repo.set_defaults(handler=_repos_list)
|
|
||||||
|
|
||||||
p.set_defaults(handler=_default)
|
|
||||||
|
|
||||||
|
|
||||||
def _default(ctx: FlowContext, args):
|
|
||||||
DotfilesService(ctx).status()
|
|
||||||
|
|
||||||
|
|
||||||
def _init(ctx: FlowContext, args):
|
|
||||||
DotfilesService(ctx).init(repo_url=args.repo)
|
|
||||||
|
|
||||||
|
|
||||||
def _link(ctx: FlowContext, args):
|
|
||||||
DotfilesService(ctx).link(
|
|
||||||
profile=args.profile,
|
|
||||||
dry_run=args.dry_run,
|
|
||||||
skip=set(args.skip) if args.skip else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _unlink(ctx: FlowContext, args):
|
|
||||||
DotfilesService(ctx).unlink(
|
|
||||||
packages=args.packages if args.packages else None,
|
|
||||||
dry_run=args.dry_run,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _status(ctx: FlowContext, args):
|
|
||||||
DotfilesService(ctx).status(
|
|
||||||
package_filter=args.packages if args.packages else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _edit(ctx: FlowContext, args):
|
|
||||||
DotfilesService(ctx).edit(args.package, no_commit=args.no_commit)
|
|
||||||
|
|
||||||
|
|
||||||
def _repos_list(ctx: FlowContext, args):
|
|
||||||
DotfilesService(ctx).repos_list()
|
|
||||||
|
|
||||||
|
|
||||||
def _repos_status(ctx: FlowContext, args):
|
|
||||||
DotfilesService(ctx).repos_status(repo_filter=args.repo_filter)
|
|
||||||
|
|
||||||
|
|
||||||
def _repos_pull(ctx: FlowContext, args):
|
|
||||||
DotfilesService(ctx).repos_pull(
|
|
||||||
repo_filter=args.repo_filter,
|
|
||||||
dry_run=args.dry_run,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _repos_push(ctx: FlowContext, args):
|
|
||||||
DotfilesService(ctx).repos_push(
|
|
||||||
repo_filter=args.repo_filter,
|
|
||||||
dry_run=args.dry_run,
|
|
||||||
)
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
"""Package commands."""
|
|
||||||
|
|
||||||
from flow.core.config import FlowContext
|
|
||||||
from flow.services.packages import PackageService
|
|
||||||
|
|
||||||
|
|
||||||
def register(subparsers):
|
|
||||||
p = subparsers.add_parser("packages", aliases=["package", "pkg"], help="Manage packages")
|
|
||||||
sub = p.add_subparsers(dest="packages_action")
|
|
||||||
|
|
||||||
install = sub.add_parser("install", help="Install packages")
|
|
||||||
install.add_argument("packages", nargs="*")
|
|
||||||
install.add_argument("--profile", help="Install profile packages")
|
|
||||||
install.add_argument("--dry-run", "-n", action="store_true")
|
|
||||||
install.set_defaults(handler=_install)
|
|
||||||
|
|
||||||
remove = sub.add_parser("remove", help="Remove packages")
|
|
||||||
remove.add_argument("packages", nargs="+")
|
|
||||||
remove.add_argument("--dry-run", "-n", action="store_true")
|
|
||||||
remove.set_defaults(handler=_remove)
|
|
||||||
|
|
||||||
ls = sub.add_parser("list", help="List installed packages")
|
|
||||||
ls.add_argument("--all", action="store_true", help="List all known packages")
|
|
||||||
ls.set_defaults(handler=_list)
|
|
||||||
|
|
||||||
p.set_defaults(handler=_default)
|
|
||||||
|
|
||||||
|
|
||||||
def _default(ctx: FlowContext, args):
|
|
||||||
_list(ctx, args)
|
|
||||||
|
|
||||||
|
|
||||||
def _install(ctx: FlowContext, args):
|
|
||||||
svc = PackageService(ctx)
|
|
||||||
packages = svc.resolve_install_packages(
|
|
||||||
package_names=args.packages if args.packages else None,
|
|
||||||
profile=args.profile,
|
|
||||||
)
|
|
||||||
svc.install(packages, dry_run=args.dry_run)
|
|
||||||
|
|
||||||
|
|
||||||
def _remove(ctx: FlowContext, args):
|
|
||||||
svc = PackageService(ctx)
|
|
||||||
svc.remove(args.packages, dry_run=args.dry_run)
|
|
||||||
|
|
||||||
|
|
||||||
def _list(ctx: FlowContext, args):
|
|
||||||
svc = PackageService(ctx)
|
|
||||||
svc.list_packages(show_all=args.all)
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""Projects commands."""
|
|
||||||
|
|
||||||
from flow.core.config import FlowContext
|
|
||||||
from flow.services.projects import ProjectService
|
|
||||||
|
|
||||||
|
|
||||||
def register(subparsers):
|
|
||||||
_register_projects_parser(subparsers, "projects", default_fetch=False, aliases=["project"])
|
|
||||||
_register_projects_parser(subparsers, "sync", default_fetch=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _default(ctx: FlowContext, args):
|
|
||||||
_check(ctx, args)
|
|
||||||
|
|
||||||
|
|
||||||
def _check(ctx: FlowContext, args):
|
|
||||||
svc = ProjectService(ctx)
|
|
||||||
svc.check(fetch=args.fetch)
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch(ctx: FlowContext, args):
|
|
||||||
svc = ProjectService(ctx)
|
|
||||||
svc.fetch()
|
|
||||||
|
|
||||||
|
|
||||||
def _summary(ctx: FlowContext, args):
|
|
||||||
svc = ProjectService(ctx)
|
|
||||||
svc.summary()
|
|
||||||
|
|
||||||
|
|
||||||
def _register_projects_parser(subparsers, name: str, *, default_fetch: bool, aliases=None):
|
|
||||||
parser = subparsers.add_parser(name, aliases=aliases or [], help="Manage git projects")
|
|
||||||
sub = parser.add_subparsers(dest=f"{name}_action")
|
|
||||||
|
|
||||||
check = sub.add_parser("check", help="Check project status")
|
|
||||||
check.add_argument("--fetch", dest="fetch", action="store_true", help="Fetch remotes first")
|
|
||||||
if default_fetch:
|
|
||||||
check.add_argument("--no-fetch", dest="fetch", action="store_false", help="Skip fetching remotes")
|
|
||||||
check.set_defaults(fetch=default_fetch)
|
|
||||||
check.set_defaults(handler=_check)
|
|
||||||
|
|
||||||
fetch = sub.add_parser("fetch", help="Fetch all project remotes")
|
|
||||||
fetch.set_defaults(handler=_fetch)
|
|
||||||
|
|
||||||
summary = sub.add_parser("summary", help="Show a summary without fetching")
|
|
||||||
summary.set_defaults(handler=_summary)
|
|
||||||
|
|
||||||
parser.set_defaults(handler=_default, fetch=default_fetch)
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"""Remote commands."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from flow.core.config import FlowContext
|
|
||||||
from flow.services.remote import RemoteService
|
|
||||||
|
|
||||||
|
|
||||||
def register(subparsers):
|
|
||||||
p = subparsers.add_parser("remote", help="Manage remote targets")
|
|
||||||
sub = p.add_subparsers(dest="remote_action")
|
|
||||||
|
|
||||||
enter = sub.add_parser("enter", help="SSH into a target")
|
|
||||||
_add_enter_args(enter)
|
|
||||||
enter.set_defaults(handler=_enter)
|
|
||||||
|
|
||||||
ls = sub.add_parser("list", help="List configured targets")
|
|
||||||
ls.set_defaults(handler=_list)
|
|
||||||
|
|
||||||
p.set_defaults(handler=_default)
|
|
||||||
|
|
||||||
alias = subparsers.add_parser("enter", help="SSH into a target")
|
|
||||||
_add_enter_args(alias)
|
|
||||||
alias.set_defaults(handler=_enter)
|
|
||||||
|
|
||||||
|
|
||||||
def _default(ctx: FlowContext, args):
|
|
||||||
_list(ctx, args)
|
|
||||||
|
|
||||||
|
|
||||||
def _enter(ctx: FlowContext, args):
|
|
||||||
svc = RemoteService(ctx)
|
|
||||||
svc.enter(
|
|
||||||
args.target,
|
|
||||||
user=args.user,
|
|
||||||
namespace=args.namespace,
|
|
||||||
platform=args.platform,
|
|
||||||
session=args.session,
|
|
||||||
no_tmux=args.no_tmux,
|
|
||||||
dry_run=args.dry_run,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _list(ctx: FlowContext, args):
|
|
||||||
svc = RemoteService(ctx)
|
|
||||||
svc.list()
|
|
||||||
|
|
||||||
|
|
||||||
def _add_enter_args(parser) -> None:
|
|
||||||
parser.add_argument("target", help="Target ([user@]namespace@platform)")
|
|
||||||
parser.add_argument("-u", "--user", help="SSH user override")
|
|
||||||
parser.add_argument("-n", "--namespace", help="Namespace override")
|
|
||||||
parser.add_argument("-p", "--platform", help="Platform override")
|
|
||||||
parser.add_argument("-s", "--session", help="tmux session name")
|
|
||||||
parser.add_argument("--no-tmux", action="store_true", help="Open plain SSH without tmux")
|
|
||||||
parser.add_argument("--dry-run", "-d", action="store_true")
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
"""Setup/bootstrap commands."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from flow.core.config import FlowContext
|
|
||||||
from flow.core.errors import FlowError
|
|
||||||
from flow.services.bootstrap import BootstrapService
|
|
||||||
|
|
||||||
|
|
||||||
def register(subparsers):
|
|
||||||
p = subparsers.add_parser(
|
|
||||||
"setup",
|
|
||||||
aliases=["bootstrap", "provision"],
|
|
||||||
help="Bootstrap a system profile",
|
|
||||||
)
|
|
||||||
sub = p.add_subparsers(dest="setup_action")
|
|
||||||
|
|
||||||
run = sub.add_parser("run", help="Run bootstrap for a profile")
|
|
||||||
run.add_argument("profile", nargs="?", help="Profile name")
|
|
||||||
run.add_argument("--profile", dest="profile_option", help="Profile name")
|
|
||||||
run.add_argument("--dry-run", "-n", action="store_true")
|
|
||||||
run.add_argument("--var", action="append", default=[], help="Set variable KEY=VALUE")
|
|
||||||
run.set_defaults(handler=_run)
|
|
||||||
|
|
||||||
show = sub.add_parser("show", help="Show bootstrap plan")
|
|
||||||
show.add_argument("profile", help="Profile name")
|
|
||||||
show.set_defaults(handler=_show)
|
|
||||||
|
|
||||||
ls = sub.add_parser("list", help="List available profiles")
|
|
||||||
ls.set_defaults(handler=_list)
|
|
||||||
|
|
||||||
p.set_defaults(handler=_default)
|
|
||||||
|
|
||||||
|
|
||||||
def _default(ctx: FlowContext, args):
|
|
||||||
_list(ctx, args)
|
|
||||||
|
|
||||||
|
|
||||||
def _run(ctx: FlowContext, args):
|
|
||||||
svc = BootstrapService(ctx)
|
|
||||||
env = _parse_vars(args.var)
|
|
||||||
svc.run(_profile_arg(args), dry_run=args.dry_run, env=env)
|
|
||||||
|
|
||||||
|
|
||||||
def _show(ctx: FlowContext, args):
|
|
||||||
svc = BootstrapService(ctx)
|
|
||||||
svc.show(args.profile)
|
|
||||||
|
|
||||||
|
|
||||||
def _list(ctx: FlowContext, args):
|
|
||||||
svc = BootstrapService(ctx)
|
|
||||||
svc.list_profiles()
|
|
||||||
|
|
||||||
|
|
||||||
def _profile_arg(args) -> str | None:
|
|
||||||
if args.profile and args.profile_option and args.profile != args.profile_option:
|
|
||||||
raise FlowError("Specify the profile only once.")
|
|
||||||
return args.profile or args.profile_option
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_vars(items: list[str]) -> dict[str, str]:
|
|
||||||
values: dict[str, str] = {}
|
|
||||||
for item in items:
|
|
||||||
if "=" not in item:
|
|
||||||
raise FlowError(f"Invalid --var value '{item}'. Expected KEY=VALUE.")
|
|
||||||
key, value = item.split("=", 1)
|
|
||||||
if not key:
|
|
||||||
raise FlowError(f"Invalid --var value '{item}'. KEY cannot be empty.")
|
|
||||||
values[key] = value
|
|
||||||
return values
|
|
||||||
@@ -13,8 +13,8 @@ from flow.adapters.download import DownloadClient
|
|||||||
from flow.adapters.filesystem import FileSystem
|
from flow.adapters.filesystem import FileSystem
|
||||||
from flow.adapters.git import GitClient
|
from flow.adapters.git import GitClient
|
||||||
from flow.adapters.process import CommandRunner
|
from flow.adapters.process import CommandRunner
|
||||||
from flow.core.containers import ContainerRuntime
|
from flow.adapters.containers import ContainerRuntime
|
||||||
from flow.core.tmux import TmuxClient
|
from flow.adapters.tmux import TmuxClient
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from flow.core.config import TargetConfig
|
from flow.core.config import TargetConfig
|
||||||
from flow.core.errors import FlowError
|
from flow.core.errors import FlowError
|
||||||
from flow.core.tmux import build_new_session_argv
|
from flow.adapters.tmux import build_new_session_argv
|
||||||
from flow.domain.remote.models import SSHCommand, Target
|
from flow.domain.remote.models import SSHCommand, Target
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
"""Domain services for CLI commands."""
|
|
||||||
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
"""ProjectService -- manages git project status."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from flow.core.config import FlowContext
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectService:
|
|
||||||
def __init__(self, ctx: FlowContext):
|
|
||||||
self.ctx = ctx
|
|
||||||
self.projects_dir = Path(self.ctx.config.projects_dir).expanduser()
|
|
||||||
|
|
||||||
def check(self, *, fetch: bool = False) -> None:
|
|
||||||
"""Check status of all git repos in projects dir."""
|
|
||||||
if not self.projects_dir.is_dir():
|
|
||||||
self.ctx.console.info(f"Projects directory not found: {self.projects_dir}")
|
|
||||||
return
|
|
||||||
|
|
||||||
repos = self._find_repos()
|
|
||||||
if not repos:
|
|
||||||
self.ctx.console.info("No git repositories found.")
|
|
||||||
return
|
|
||||||
|
|
||||||
if fetch:
|
|
||||||
self.ctx.console.info("Fetching all remotes...")
|
|
||||||
for repo in repos:
|
|
||||||
self.ctx.runtime.git.run(repo, "fetch", "--all", "--quiet")
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
for repo in repos:
|
|
||||||
status = self._repo_status(repo)
|
|
||||||
rows.append([repo.name, status])
|
|
||||||
|
|
||||||
self.ctx.console.table(["REPO", "STATUS"], rows)
|
|
||||||
|
|
||||||
def summary(self) -> None:
|
|
||||||
"""Quick summary without fetch."""
|
|
||||||
self.check(fetch=False)
|
|
||||||
|
|
||||||
def fetch(self) -> None:
|
|
||||||
"""Fetch all remotes then show status."""
|
|
||||||
self.check(fetch=True)
|
|
||||||
|
|
||||||
def _find_repos(self) -> list[Path]:
|
|
||||||
"""Find all git repos in projects dir (immediate children only)."""
|
|
||||||
repos = []
|
|
||||||
for child in sorted(self.projects_dir.iterdir()):
|
|
||||||
if not child.is_dir():
|
|
||||||
continue
|
|
||||||
# Check for .git dir or .git file (worktree)
|
|
||||||
git_path = child / ".git"
|
|
||||||
if git_path.exists():
|
|
||||||
repos.append(child)
|
|
||||||
return repos
|
|
||||||
|
|
||||||
def _repo_status(self, repo: Path) -> str:
|
|
||||||
"""Get human-readable status for a repo."""
|
|
||||||
parts = []
|
|
||||||
|
|
||||||
# Check for uncommitted changes
|
|
||||||
result = self.ctx.runtime.git.run(
|
|
||||||
repo, "status", "--porcelain",
|
|
||||||
)
|
|
||||||
if result.stdout.strip():
|
|
||||||
parts.append("uncommitted changes")
|
|
||||||
|
|
||||||
# Check ahead/behind
|
|
||||||
result = self.ctx.runtime.git.run(
|
|
||||||
repo, "rev-list", "--left-right", "--count", "HEAD...@{u}",
|
|
||||||
)
|
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
|
||||||
counts = result.stdout.strip().split()
|
|
||||||
if len(counts) == 2:
|
|
||||||
ahead, behind = int(counts[0]), int(counts[1])
|
|
||||||
if ahead > 0:
|
|
||||||
parts.append(f"{ahead} ahead")
|
|
||||||
if behind > 0:
|
|
||||||
parts.append(f"{behind} behind")
|
|
||||||
|
|
||||||
if not parts:
|
|
||||||
parts.append("clean")
|
|
||||||
|
|
||||||
return ", ".join(parts)
|
|
||||||
@@ -22,7 +22,14 @@ IMAGE_TAG = "flow-e2e:test"
|
|||||||
|
|
||||||
def _pick_runtime() -> str | None:
|
def _pick_runtime() -> str | None:
|
||||||
for binary in ("podman", "docker"):
|
for binary in ("podman", "docker"):
|
||||||
if shutil.which(binary):
|
if not shutil.which(binary):
|
||||||
|
continue
|
||||||
|
result = subprocess.run(
|
||||||
|
[binary, "version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
return binary
|
return binary
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
60
tests/test_adapters_archive.py
Normal file
60
tests/test_adapters_archive.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Archive adapter safety tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import tarfile
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from flow.adapters.archive import ArchiveClient
|
||||||
|
from flow.adapters.filesystem import FileSystem
|
||||||
|
from flow.core.errors import FlowError
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_tar_uses_safe_member_paths(tmp_path):
|
||||||
|
archive = tmp_path / "ok.tar.gz"
|
||||||
|
with tarfile.open(archive, "w:gz") as tar:
|
||||||
|
content = b"hello"
|
||||||
|
info = tarfile.TarInfo("pkg/bin/tool")
|
||||||
|
info.size = len(content)
|
||||||
|
tar.addfile(info, io.BytesIO(content))
|
||||||
|
|
||||||
|
target = tmp_path / "extract"
|
||||||
|
ArchiveClient(FileSystem()).extract(archive, target)
|
||||||
|
|
||||||
|
assert (target / "pkg" / "bin" / "tool").read_text() == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_tar_member_parent_traversal(tmp_path):
|
||||||
|
archive = tmp_path / "bad.tar.gz"
|
||||||
|
with tarfile.open(archive, "w:gz") as tar:
|
||||||
|
content = b"bad"
|
||||||
|
info = tarfile.TarInfo("../escape")
|
||||||
|
info.size = len(content)
|
||||||
|
tar.addfile(info, io.BytesIO(content))
|
||||||
|
|
||||||
|
with pytest.raises(FlowError, match="escapes"):
|
||||||
|
ArchiveClient(FileSystem()).extract(archive, tmp_path / "extract")
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_zip_member_parent_traversal(tmp_path):
|
||||||
|
archive = tmp_path / "bad.zip"
|
||||||
|
with zipfile.ZipFile(archive, "w") as zf:
|
||||||
|
zf.writestr("../escape", "bad")
|
||||||
|
|
||||||
|
with pytest.raises(FlowError, match="escapes"):
|
||||||
|
ArchiveClient(FileSystem()).extract(archive, tmp_path / "extract")
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_tar_symlink_members(tmp_path):
|
||||||
|
archive = tmp_path / "bad-link.tar"
|
||||||
|
with tarfile.open(archive, "w") as tar:
|
||||||
|
info = tarfile.TarInfo("pkg/link")
|
||||||
|
info.type = tarfile.SYMTYPE
|
||||||
|
info.linkname = "/etc/passwd"
|
||||||
|
tar.addfile(info)
|
||||||
|
|
||||||
|
with pytest.raises(FlowError, match="member type"):
|
||||||
|
ArchiveClient(FileSystem()).extract(archive, tmp_path / "extract")
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from flow.commands.completion import complete
|
from flow.app.completion import complete
|
||||||
from flow.core.config import AppConfig, FlowContext
|
from flow.core.config import AppConfig, FlowContext
|
||||||
from flow.core.console import Console
|
from flow.core.console import Console
|
||||||
from flow.core.platform import PlatformInfo
|
from flow.core.platform import PlatformInfo
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""Tests for flow.core.containers."""
|
"""Tests for flow.adapters.containers."""
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from flow.core.containers import ContainerRuntime
|
from flow.adapters.containers import ContainerRuntime
|
||||||
from flow.core.errors import FlowError
|
from flow.core.errors import FlowError
|
||||||
|
|
||||||
from tests.fakes import FakeRunner
|
from tests.fakes import FakeRunner
|
||||||
@@ -53,7 +53,7 @@ class TestMode:
|
|||||||
rootful.write_text("")
|
rootful.write_text("")
|
||||||
compat.write_text("")
|
compat.write_text("")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"flow.core.containers.ContainerRuntime._socket_candidates",
|
"flow.adapters.containers.ContainerRuntime._socket_candidates",
|
||||||
lambda self: [rootful, rootless, compat],
|
lambda self: [rootful, rootless, compat],
|
||||||
)
|
)
|
||||||
rt = ContainerRuntime(FakeRunner(), mode="podman-rootful", binary="podman")
|
rt = ContainerRuntime(FakeRunner(), mode="podman-rootful", binary="podman")
|
||||||
@@ -65,7 +65,7 @@ class TestMode:
|
|||||||
rootless.write_text("")
|
rootless.write_text("")
|
||||||
rootful.write_text("")
|
rootful.write_text("")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"flow.core.containers.ContainerRuntime._socket_candidates",
|
"flow.adapters.containers.ContainerRuntime._socket_candidates",
|
||||||
lambda self: [rootless, rootful],
|
lambda self: [rootless, rootful],
|
||||||
)
|
)
|
||||||
rt = ContainerRuntime(FakeRunner(), mode="podman", binary="podman")
|
rt = ContainerRuntime(FakeRunner(), mode="podman", binary="podman")
|
||||||
@@ -77,7 +77,7 @@ class TestSocketPath:
|
|||||||
sock = tmp_path / "docker.sock"
|
sock = tmp_path / "docker.sock"
|
||||||
sock.write_text("")
|
sock.write_text("")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"flow.core.containers.ContainerRuntime._socket_candidates",
|
"flow.adapters.containers.ContainerRuntime._socket_candidates",
|
||||||
lambda self: [sock],
|
lambda self: [sock],
|
||||||
)
|
)
|
||||||
rt = ContainerRuntime(FakeRunner(), binary="docker")
|
rt = ContainerRuntime(FakeRunner(), binary="docker")
|
||||||
@@ -85,7 +85,7 @@ class TestSocketPath:
|
|||||||
|
|
||||||
def test_docker_socket_missing(self, monkeypatch):
|
def test_docker_socket_missing(self, monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"flow.core.containers.ContainerRuntime._socket_candidates",
|
"flow.adapters.containers.ContainerRuntime._socket_candidates",
|
||||||
lambda self: [Path("/nonexistent/docker.sock")],
|
lambda self: [Path("/nonexistent/docker.sock")],
|
||||||
)
|
)
|
||||||
rt = ContainerRuntime(FakeRunner(), binary="docker")
|
rt = ContainerRuntime(FakeRunner(), binary="docker")
|
||||||
@@ -97,7 +97,7 @@ class TestSocketPath:
|
|||||||
rootless.write_text("")
|
rootless.write_text("")
|
||||||
rootful.write_text("")
|
rootful.write_text("")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"flow.core.containers.ContainerRuntime._socket_candidates",
|
"flow.adapters.containers.ContainerRuntime._socket_candidates",
|
||||||
lambda self: [rootless, rootful],
|
lambda self: [rootless, rootful],
|
||||||
)
|
)
|
||||||
rt = ContainerRuntime(FakeRunner(), binary="podman")
|
rt = ContainerRuntime(FakeRunner(), binary="podman")
|
||||||
@@ -107,7 +107,7 @@ class TestSocketPath:
|
|||||||
rootful = tmp_path / "rootful.sock"
|
rootful = tmp_path / "rootful.sock"
|
||||||
rootful.write_text("")
|
rootful.write_text("")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"flow.core.containers.ContainerRuntime._socket_candidates",
|
"flow.adapters.containers.ContainerRuntime._socket_candidates",
|
||||||
lambda self: [Path("/nonexistent"), rootful],
|
lambda self: [Path("/nonexistent"), rootful],
|
||||||
)
|
)
|
||||||
rt = ContainerRuntime(FakeRunner(), binary="podman")
|
rt = ContainerRuntime(FakeRunner(), binary="podman")
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import os
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from flow.core.containers import ContainerRuntime
|
from flow.adapters.containers import ContainerRuntime
|
||||||
from flow.core.errors import FlowError
|
from flow.core.errors import FlowError
|
||||||
from flow.core.runtime import CommandRunner, FileSystem, GitClient, SystemRuntime
|
from flow.core.runtime import CommandRunner, FileSystem, GitClient, SystemRuntime
|
||||||
from flow.core.tmux import TmuxClient
|
from flow.adapters.tmux import TmuxClient
|
||||||
|
|
||||||
|
|
||||||
class TestFileSystem:
|
class TestFileSystem:
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Tests for flow.core.tmux."""
|
"""Tests for flow.adapters.tmux."""
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from flow.core.tmux import TmuxClient, build_new_session_argv
|
from flow.adapters.tmux import TmuxClient, build_new_session_argv
|
||||||
|
|
||||||
from tests.fakes import FakeRunner
|
from tests.fakes import FakeRunner
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from flow.core.console import Console
|
|||||||
from flow.core.errors import FlowError
|
from flow.core.errors import FlowError
|
||||||
from flow.core.platform import PlatformInfo
|
from flow.core.platform import PlatformInfo
|
||||||
from flow.core.runtime import SystemRuntime
|
from flow.core.runtime import SystemRuntime
|
||||||
from flow.services.bootstrap import BootstrapService
|
from flow.app.bootstrap import BootstrapService
|
||||||
|
|
||||||
|
|
||||||
def _make_ctx(manifest=None):
|
def _make_ctx(manifest=None):
|
||||||
@@ -74,8 +74,8 @@ class TestBootstrapService:
|
|||||||
def install(self, packages, *, dry_run=False):
|
def install(self, packages, *, dry_run=False):
|
||||||
captured["packages"] = packages
|
captured["packages"] = packages
|
||||||
|
|
||||||
monkeypatch.setattr("flow.services.packages.PackageService", StubPackageService)
|
monkeypatch.setattr("flow.app.packages.PackageService", StubPackageService)
|
||||||
monkeypatch.setattr("flow.services.dotfiles.DotfilesService.link", lambda self, profile=None: None)
|
monkeypatch.setattr("flow.app.dotfiles.DotfilesService.link", lambda self, profile=None: None)
|
||||||
|
|
||||||
manifest = {
|
manifest = {
|
||||||
"profiles": {
|
"profiles": {
|
||||||
@@ -116,7 +116,7 @@ class TestBootstrapService:
|
|||||||
def test_run_uses_dotfiles_profile_override(self, monkeypatch):
|
def test_run_uses_dotfiles_profile_override(self, monkeypatch):
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
monkeypatch.setattr("flow.services.packages.PackageService.install", lambda self, packages, dry_run=False: None)
|
monkeypatch.setattr("flow.app.packages.PackageService.install", lambda self, packages, dry_run=False: None)
|
||||||
|
|
||||||
class StubDotfilesService:
|
class StubDotfilesService:
|
||||||
def __init__(self, ctx):
|
def __init__(self, ctx):
|
||||||
@@ -125,7 +125,7 @@ class TestBootstrapService:
|
|||||||
def link(self, profile=None):
|
def link(self, profile=None):
|
||||||
captured["profile"] = profile
|
captured["profile"] = profile
|
||||||
|
|
||||||
monkeypatch.setattr("flow.services.dotfiles.DotfilesService", StubDotfilesService)
|
monkeypatch.setattr("flow.app.dotfiles.DotfilesService", StubDotfilesService)
|
||||||
|
|
||||||
manifest = {
|
manifest = {
|
||||||
"profiles": {
|
"profiles": {
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import subprocess
|
|||||||
|
|
||||||
from flow.core.config import AppConfig, FlowContext
|
from flow.core.config import AppConfig, FlowContext
|
||||||
from flow.core.console import Console
|
from flow.core.console import Console
|
||||||
from flow.core.containers import ContainerRuntime
|
from flow.adapters.containers import ContainerRuntime
|
||||||
from flow.core.platform import PlatformInfo
|
from flow.core.platform import PlatformInfo
|
||||||
from flow.core.runtime import SystemRuntime
|
from flow.core.runtime import SystemRuntime
|
||||||
from flow.core import paths
|
from flow.core import paths
|
||||||
from flow.services.containers import ContainerService
|
from flow.app.containers import ContainerService
|
||||||
|
|
||||||
from tests.fakes import FakeRunner
|
from tests.fakes import FakeRunner
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from flow.core.errors import FlowError, PlanConflict
|
|||||||
from flow.core.platform import PlatformInfo
|
from flow.core.platform import PlatformInfo
|
||||||
from flow.core.runtime import SystemRuntime
|
from flow.core.runtime import SystemRuntime
|
||||||
from flow.core import paths
|
from flow.core import paths
|
||||||
from flow.services.dotfiles import DotfilesService
|
from flow.app.dotfiles import DotfilesService
|
||||||
from tests.fakes import FakeRunner
|
from tests.fakes import FakeRunner
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from flow.core.platform import PlatformInfo
|
|||||||
from flow.core.runtime import SystemRuntime
|
from flow.core.runtime import SystemRuntime
|
||||||
from flow.core import paths
|
from flow.core import paths
|
||||||
from flow.domain.packages.models import InstalledPackage, InstalledState, PackageDef
|
from flow.domain.packages.models import InstalledPackage, InstalledState, PackageDef
|
||||||
from flow.services.packages import PackageService
|
from flow.app.packages import PackageService
|
||||||
|
|
||||||
|
|
||||||
def _make_ctx(tmp_path, manifest=None):
|
def _make_ctx(tmp_path, manifest=None):
|
||||||
|
|||||||
@@ -2,14 +2,18 @@
|
|||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from flow.core.config import AppConfig, FlowContext
|
from flow.core.config import AppConfig, FlowContext
|
||||||
from flow.core.console import Console
|
from flow.core.console import Console
|
||||||
|
from flow.core.errors import FlowError
|
||||||
from flow.core.platform import PlatformInfo
|
from flow.core.platform import PlatformInfo
|
||||||
from flow.core.runtime import SystemRuntime
|
from flow.core.runtime import SystemRuntime
|
||||||
from flow.services.projects import ProjectService
|
from flow.app.projects import ProjectService
|
||||||
|
|
||||||
|
|
||||||
def _make_ctx(projects_dir):
|
def _make_ctx(projects_dir, monkeypatch):
|
||||||
|
monkeypatch.setattr("flow.actions.executor.paths.STATE_DIR", projects_dir / ".state")
|
||||||
return FlowContext(
|
return FlowContext(
|
||||||
config=AppConfig(projects_dir=str(projects_dir)),
|
config=AppConfig(projects_dir=str(projects_dir)),
|
||||||
manifest={},
|
manifest={},
|
||||||
@@ -19,25 +23,44 @@ def _make_ctx(projects_dir):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _git(*args):
|
||||||
|
return subprocess.run(
|
||||||
|
["git", *[str(arg) for arg in args]],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _init_repo(path, commit=True):
|
def _init_repo(path, commit=True):
|
||||||
"""Create a git repo with an initial commit."""
|
"""Create a git repo with an initial commit."""
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
subprocess.run(["git", "init", str(path)], capture_output=True, check=True)
|
_git("init", path)
|
||||||
subprocess.run(["git", "-C", str(path), "config", "user.email", "test@test.com"], capture_output=True, check=True)
|
_git("-C", path, "config", "user.email", "test@test.com")
|
||||||
subprocess.run(["git", "-C", str(path), "config", "user.name", "Test"], capture_output=True, check=True)
|
_git("-C", path, "config", "user.name", "Test")
|
||||||
if commit:
|
if commit:
|
||||||
(path / "README.md").write_text("# test")
|
(path / "README.md").write_text("# test")
|
||||||
subprocess.run(["git", "-C", str(path), "add", "."], capture_output=True, check=True)
|
_git("-C", path, "add", ".")
|
||||||
subprocess.run(["git", "-C", str(path), "commit", "-m", "init"], capture_output=True, check=True)
|
_git("-C", path, "commit", "-m", "init")
|
||||||
|
|
||||||
|
|
||||||
|
def _add_upstream(repo):
|
||||||
|
remote = repo.parent / f"{repo.name}.git"
|
||||||
|
_git("init", "--bare", remote)
|
||||||
|
_git("-C", repo, "remote", "add", "origin", remote)
|
||||||
|
branch = _git("-C", repo, "branch", "--show-current").stdout.strip()
|
||||||
|
_git("-C", repo, "push", "-u", "origin", branch)
|
||||||
|
|
||||||
|
|
||||||
class TestProjectService:
|
class TestProjectService:
|
||||||
def test_check_clean_repo(self, tmp_path, capsys):
|
def test_check_clean_repo(self, tmp_path, monkeypatch, capsys):
|
||||||
projects = tmp_path / "projects"
|
projects = tmp_path / "projects"
|
||||||
projects.mkdir()
|
projects.mkdir()
|
||||||
_init_repo(projects / "myrepo")
|
repo = projects / "myrepo"
|
||||||
|
_init_repo(repo)
|
||||||
|
_add_upstream(repo)
|
||||||
|
|
||||||
ctx = _make_ctx(projects)
|
ctx = _make_ctx(projects, monkeypatch)
|
||||||
svc = ProjectService(ctx)
|
svc = ProjectService(ctx)
|
||||||
svc.check(fetch=False)
|
svc.check(fetch=False)
|
||||||
|
|
||||||
@@ -45,33 +68,74 @@ class TestProjectService:
|
|||||||
assert "myrepo" in output
|
assert "myrepo" in output
|
||||||
assert "clean" in output
|
assert "clean" in output
|
||||||
|
|
||||||
def test_check_uncommitted_changes(self, tmp_path, capsys):
|
def test_check_uncommitted_changes(self, tmp_path, monkeypatch, capsys):
|
||||||
projects = tmp_path / "projects"
|
projects = tmp_path / "projects"
|
||||||
projects.mkdir()
|
projects.mkdir()
|
||||||
_init_repo(projects / "myrepo")
|
repo = projects / "myrepo"
|
||||||
(projects / "myrepo" / "new_file.txt").write_text("changes")
|
_init_repo(repo)
|
||||||
|
_add_upstream(repo)
|
||||||
|
(repo / "new_file.txt").write_text("changes")
|
||||||
|
|
||||||
ctx = _make_ctx(projects)
|
ctx = _make_ctx(projects, monkeypatch)
|
||||||
svc = ProjectService(ctx)
|
svc = ProjectService(ctx)
|
||||||
svc.check(fetch=False)
|
svc.check(fetch=False)
|
||||||
|
|
||||||
output = capsys.readouterr().out
|
output = capsys.readouterr().out
|
||||||
assert "uncommitted" in output
|
assert "uncommitted" in output
|
||||||
|
|
||||||
def test_check_no_git_repos(self, tmp_path, capsys):
|
def test_check_no_git_repos(self, tmp_path, monkeypatch, capsys):
|
||||||
projects = tmp_path / "projects"
|
projects = tmp_path / "projects"
|
||||||
projects.mkdir()
|
projects.mkdir()
|
||||||
(projects / "not-a-repo").mkdir()
|
(projects / "not-a-repo").mkdir()
|
||||||
|
|
||||||
ctx = _make_ctx(projects)
|
ctx = _make_ctx(projects, monkeypatch)
|
||||||
svc = ProjectService(ctx)
|
svc = ProjectService(ctx)
|
||||||
svc.check(fetch=False)
|
svc.check(fetch=False)
|
||||||
|
|
||||||
output = capsys.readouterr().out
|
output = capsys.readouterr().out
|
||||||
assert "No git" in output
|
assert "No git" in output
|
||||||
|
|
||||||
def test_missing_projects_dir(self, tmp_path, capsys):
|
def test_missing_projects_dir(self, tmp_path, monkeypatch, capsys):
|
||||||
ctx = _make_ctx(tmp_path / "nonexistent")
|
ctx = _make_ctx(tmp_path / "nonexistent", monkeypatch)
|
||||||
svc = ProjectService(ctx)
|
svc = ProjectService(ctx)
|
||||||
svc.check(fetch=False)
|
svc.check(fetch=False)
|
||||||
assert "not found" in capsys.readouterr().out
|
assert "not found" in capsys.readouterr().out
|
||||||
|
|
||||||
|
def test_fetch_failure_raises(self, tmp_path, monkeypatch):
|
||||||
|
projects = tmp_path / "projects"
|
||||||
|
projects.mkdir()
|
||||||
|
repo = projects / "myrepo"
|
||||||
|
_init_repo(repo)
|
||||||
|
_git("-C", repo, "remote", "add", "origin", tmp_path / "missing-origin")
|
||||||
|
|
||||||
|
ctx = _make_ctx(projects, monkeypatch)
|
||||||
|
svc = ProjectService(ctx)
|
||||||
|
|
||||||
|
with pytest.raises(FlowError, match="Fetch remotes for myrepo"):
|
||||||
|
svc.check(fetch=True)
|
||||||
|
|
||||||
|
def test_status_failure_raises(self, tmp_path, monkeypatch):
|
||||||
|
projects = tmp_path / "projects"
|
||||||
|
projects.mkdir()
|
||||||
|
repo = projects / "myrepo"
|
||||||
|
_init_repo(repo)
|
||||||
|
(repo / ".git" / "HEAD").unlink()
|
||||||
|
|
||||||
|
ctx = _make_ctx(projects, monkeypatch)
|
||||||
|
svc = ProjectService(ctx)
|
||||||
|
|
||||||
|
with pytest.raises(FlowError, match="Check working tree for myrepo"):
|
||||||
|
svc.check(fetch=False)
|
||||||
|
|
||||||
|
def test_missing_upstream_raises_instead_of_clean(self, tmp_path, monkeypatch, capsys):
|
||||||
|
projects = tmp_path / "projects"
|
||||||
|
projects.mkdir()
|
||||||
|
_init_repo(projects / "myrepo")
|
||||||
|
|
||||||
|
ctx = _make_ctx(projects, monkeypatch)
|
||||||
|
svc = ProjectService(ctx)
|
||||||
|
|
||||||
|
with pytest.raises(FlowError, match="rev-list"):
|
||||||
|
svc.check(fetch=False)
|
||||||
|
|
||||||
|
assert "clean" not in capsys.readouterr().out
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from flow.core.console import Console
|
|||||||
from flow.core.errors import FlowError
|
from flow.core.errors import FlowError
|
||||||
from flow.core.platform import PlatformInfo
|
from flow.core.platform import PlatformInfo
|
||||||
from flow.core.runtime import SystemRuntime
|
from flow.core.runtime import SystemRuntime
|
||||||
from flow.services.remote import RemoteService
|
from flow.app.remote import RemoteService
|
||||||
|
|
||||||
|
|
||||||
def _make_ctx(targets=None):
|
def _make_ctx(targets=None):
|
||||||
|
|||||||
@@ -12,6 +12,14 @@ MUTATING_PATTERNS = (
|
|||||||
re.compile(r"runtime\.fs\.(create_symlink|remove_symlink|write_json|write_text|write_bytes|copy_file|copy_tree|remove_file|remove_tree)\("),
|
re.compile(r"runtime\.fs\.(create_symlink|remove_symlink|write_json|write_text|write_bytes|copy_file|copy_tree|remove_file|remove_tree)\("),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
COMMAND_PATTERNS = (
|
||||||
|
re.compile(r"runtime\.runner\.(run|run_shell)\("),
|
||||||
|
re.compile(r"runtime\.git\.run\("),
|
||||||
|
re.compile(r"runtime\.containers\.(run_container|start|stop|kill|rm)\("),
|
||||||
|
re.compile(r"runtime\.tmux\.(new_session|set_option|respawn_pane)\("),
|
||||||
|
re.compile(r"self\.(rt|tmux)\.(run_container|start|stop|kill|rm|new_session|set_option|respawn_pane)\("),
|
||||||
|
)
|
||||||
|
|
||||||
ALLOWED_PREFIXES = (
|
ALLOWED_PREFIXES = (
|
||||||
Path("src/flow/adapters"),
|
Path("src/flow/adapters"),
|
||||||
Path("src/flow/actions"),
|
Path("src/flow/actions"),
|
||||||
@@ -22,17 +30,12 @@ ALLOWED_FILES = {
|
|||||||
Path("src/flow/core/paths.py"),
|
Path("src/flow/core/paths.py"),
|
||||||
}
|
}
|
||||||
|
|
||||||
SKIPPED_LEGACY_COMMANDS = {
|
|
||||||
Path("src/flow/commands/completion.py"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_direct_filesystem_mutation_outside_action_boundary():
|
def test_no_direct_filesystem_mutation_outside_action_boundary():
|
||||||
root = Path(__file__).resolve().parents[1]
|
root = Path(__file__).resolve().parents[1]
|
||||||
offenders: list[str] = []
|
offenders: list[str] = []
|
||||||
for path in sorted((root / "src" / "flow").rglob("*.py")):
|
for path in sorted((root / "src" / "flow").rglob("*.py")):
|
||||||
rel = path.relative_to(root)
|
rel = path.relative_to(root)
|
||||||
if rel in ALLOWED_FILES or rel in SKIPPED_LEGACY_COMMANDS:
|
if rel in ALLOWED_FILES:
|
||||||
continue
|
continue
|
||||||
if any(rel.is_relative_to(prefix) for prefix in ALLOWED_PREFIXES):
|
if any(rel.is_relative_to(prefix) for prefix in ALLOWED_PREFIXES):
|
||||||
continue
|
continue
|
||||||
@@ -43,3 +46,17 @@ def test_no_direct_filesystem_mutation_outside_action_boundary():
|
|||||||
offenders.append(f"{rel}:{line_no}: {line.strip()}")
|
offenders.append(f"{rel}:{line_no}: {line.strip()}")
|
||||||
|
|
||||||
assert offenders == []
|
assert offenders == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_direct_command_mutation_outside_action_boundary():
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
offenders: list[str] = []
|
||||||
|
for path in sorted((root / "src" / "flow").rglob("*.py")):
|
||||||
|
rel = path.relative_to(root)
|
||||||
|
if any(rel.is_relative_to(prefix) for prefix in ALLOWED_PREFIXES):
|
||||||
|
continue
|
||||||
|
for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||||
|
if any(pattern.search(line) for pattern in COMMAND_PATTERNS):
|
||||||
|
offenders.append(f"{rel}:{line_no}: {line.strip()}")
|
||||||
|
|
||||||
|
assert offenders == []
|
||||||
|
|||||||
Reference in New Issue
Block a user