Skip to content

Commit 375db28

Browse files
committed
refactor(cli): collapse duplicate dest-check helpers
Code-review followup on #148: - Drop `check_dest_safe`: it raised the same error as `_require_empty_dest` with hand-synced wording. Move the validation into `create_workspace` (which previously didn't validate at all — only `create_host` did). - Unify parameter name across the call chain: `tolerate_existing` / `allowed` / `preserve_existing` all collapsed to `preserve_existing`. - `app_project.py` no longer pre-checks the target — `create_workspace` (workspace mode) or `create_host` (flat mode) raises immediately if anything unexpected is pre-existing. - `new.py` uses `Path.is_relative_to` instead of try/except. - Drop narrative inline comments from the new regression tests; add an assertion that `.git/` survives instead of merely setting up `.git/HEAD`. https://claude.ai/code/session_01K84RjsX1ToXorMyuaxNBT6
1 parent df3d5a7 commit 375db28

4 files changed

Lines changed: 25 additions & 59 deletions

File tree

‎framework/cli/simple_module_cli/app_project.py‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
from simple_module_cli.scaffolding import (
3030
SAFE_PRESERVED_NAMES,
3131
_module_to_pypi_name,
32-
check_dest_safe,
3332
create_host,
3433
create_module,
3534
create_workspace,
@@ -105,29 +104,26 @@ def create_app_project(
105104
Workspace mode (default) lays down a uv + npm workspace at ``target/``
106105
with the host under ``target/host/`` and a sample module under
107106
``target/modules/hello/``. Flat mode keeps the legacy single-host layout.
108-
Tolerates safe pre-existing entries at ``target`` (see ``check_dest_safe``)
109-
and returns paths whose scaffold copy was skipped so callers can warn.
107+
Tolerates ``SAFE_PRESERVED_NAMES`` at ``target`` and returns the paths
108+
whose scaffold copy was skipped so callers can warn.
110109
"""
111-
check_dest_safe(target)
112-
113110
chosen = list(selected) if selected is not None else list(PRESETS["standard"])
114111
resolved, _added = expand_deps(chosen)
115112

116113
display_names = [to_pascal_case(CATALOG[m].display) for m in resolved]
117114
host_dir = target if flat else target / "host"
118115
preserved: list[Path] = []
119116
if not flat:
120-
target.mkdir(parents=True, exist_ok=True)
121117
preserved.extend(
122-
create_workspace(target, name=name, tolerate_existing=SAFE_PRESERVED_NAMES)
118+
create_workspace(target, name=name, preserve_existing=SAFE_PRESERVED_NAMES)
123119
)
124120
preserved.extend(
125121
create_host(
126122
host_dir,
127123
name=name,
128124
modules=display_names,
129125
framework_version=_FRAMEWORK_VERSION,
130-
tolerate_existing=SAFE_PRESERVED_NAMES if flat else frozenset(),
126+
preserve_existing=SAFE_PRESERVED_NAMES if flat else frozenset(),
131127
)
132128
)
133129
if not flat:

‎framework/cli/simple_module_cli/new.py‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,7 @@ def new_project(
120120
"merge by hand if you want their contents):"
121121
)
122122
for path in preserved:
123-
try:
124-
rel = path.relative_to(target)
125-
except ValueError:
126-
rel = path
123+
rel = path.relative_to(target) if path.is_relative_to(target) else path
127124
typer.echo(f" {rel}")
128125
typer.echo("\nNext steps:")
129126
typer.echo(f" cd {target}")

‎framework/cli/simple_module_cli/scaffolding.py‎

Lines changed: 19 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525

2626
__all__ = [
2727
"SAFE_PRESERVED_NAMES",
28-
"check_dest_safe",
2928
"create_host",
3029
"create_module",
3130
"create_workspace",
@@ -36,10 +35,8 @@
3635
_TEMPLATES_PACKAGE = "simple_module_cli.templates"
3736
_PACKAGE_PATH_TOKEN = "__PACKAGE__"
3837

39-
# Top-level entries we tolerate at the scaffold target — typical leftovers
40-
# from ``git init`` / ``gh repo create`` / IDE setup. The scaffold's copy is
41-
# skipped (the user's file wins) and the preserved path is surfaced so users
42-
# can merge content in by hand if they care.
38+
# Pre-existing entries we tolerate at a scaffold target — typical leftovers
39+
# from ``git init`` / ``gh repo create`` / IDE setup.
4340
SAFE_PRESERVED_NAMES = frozenset(
4441
{".git", ".gitignore", ".gitattributes", ".editorconfig", ".DS_Store"}
4542
| {".claude", ".vscode", ".idea"}
@@ -49,19 +46,6 @@
4946
)
5047

5148

52-
def check_dest_safe(target: Path) -> None:
53-
"""Refuse a non-empty target unless every entry is in ``SAFE_PRESERVED_NAMES``."""
54-
if not target.exists():
55-
return
56-
unexpected = sorted(p.name for p in target.iterdir() if p.name not in SAFE_PRESERVED_NAMES)
57-
if unexpected:
58-
raise FileExistsError(
59-
f"Destination {target} exists and contains files that would collide "
60-
f"with the scaffold: {', '.join(unexpected)}. "
61-
"Move them aside or choose another path."
62-
)
63-
64-
6549
def _module_to_pypi_name(name: str) -> str:
6650
return f"simple_module_{name.lower()}"
6751

@@ -76,15 +60,15 @@ def _iter_template_files(template_root: Path):
7660
yield path
7761

7862

79-
def _require_empty_dest(dest: Path, *, allowed: frozenset[str] = frozenset()) -> None:
63+
def _require_empty_dest(dest: Path, *, preserve_existing: frozenset[str] = frozenset()) -> None:
8064
"""Refuse a non-empty destination unless every top-level entry is allowed.
8165
82-
``allowed`` is matched against the *name* of each top-level entry, so callers
83-
can permit common pre-existing files (``.git``, ``README.md``, ...) without
84-
silently overwriting unrelated user content.
66+
``preserve_existing`` is matched against the *name* of each top-level entry,
67+
so callers can permit common pre-existing files (``.git``, ``README.md``,
68+
...) without silently overwriting unrelated user content.
8569
"""
8670
if dest.exists():
87-
unexpected = sorted(p.name for p in dest.iterdir() if p.name not in allowed)
71+
unexpected = sorted(p.name for p in dest.iterdir() if p.name not in preserve_existing)
8872
if unexpected:
8973
raise FileExistsError(
9074
f"Destination {dest} exists and contains files that would collide "
@@ -108,12 +92,7 @@ def _apply_template_files(
10892
path_rewrites: Mapping[str, str] | None = None,
10993
preserve_existing: frozenset[str] = frozenset(),
11094
) -> list[Path]:
111-
"""Write template files into ``dest``; return paths that were preserved.
112-
113-
When a top-level path segment matches ``preserve_existing`` *and* the target
114-
file already exists, the scaffold's copy is skipped and the path is
115-
returned so the caller can warn the user.
116-
"""
95+
"""Write template files into ``dest``; return paths skipped to preserve the user's copy."""
11796
preserved: list[Path] = []
11897
for src in _iter_template_files(src_root):
11998
rel_str = str(src.relative_to(src_root))
@@ -141,26 +120,27 @@ def create_workspace(
141120
name: str,
142121
template_root: Path | None = None,
143122
*,
144-
tolerate_existing: frozenset[str] = frozenset(),
123+
preserve_existing: frozenset[str] = frozenset(),
145124
) -> list[Path]:
146-
"""Materialize the workspace-root shell at ``dest``.
125+
"""Materialize the workspace-root shell at ``dest``; return preserved paths.
147126
148127
Lays down the top-level ``pyproject.toml`` (uv workspace), ``package.json``
149128
(npm workspace), ``Makefile`` (delegates to host), ``.env.example``,
150129
``.gitignore``, and ``README.md``. Does NOT create the host or any
151130
modules — those go under ``dest/host`` and ``dest/modules/`` afterwards.
152131
153-
``tolerate_existing`` lists top-level entry names (``.git``, ``README.md``,
132+
``preserve_existing`` lists top-level entry names (``.git``, ``README.md``,
154133
...) that may already exist in ``dest``; the scaffold's copy is skipped and
155-
the preserved path is included in the returned list.
134+
the preserved path is included in the returned list. Other pre-existing
135+
entries raise ``FileExistsError``.
156136
"""
157137
dest = Path(dest)
158-
dest.mkdir(parents=True, exist_ok=True)
138+
_require_empty_dest(dest, preserve_existing=preserve_existing)
159139
preserved = _apply_template_files(
160140
_resolve_template_root("workspace", template_root),
161141
dest,
162142
{"{{HOST_NAME}}": to_kebab_case(name)},
163-
preserve_existing=tolerate_existing,
143+
preserve_existing=preserve_existing,
164144
)
165145
logger.info("Scaffolded workspace root at %s", dest)
166146
return preserved
@@ -173,17 +153,14 @@ def create_host(
173153
template_root: Path | None = None,
174154
framework_version: str = "*",
175155
*,
176-
tolerate_existing: frozenset[str] = frozenset(),
156+
preserve_existing: frozenset[str] = frozenset(),
177157
) -> list[Path]:
178158
"""Scaffold a host project at ``dest``; return preserved pre-existing paths.
179159
180-
``tolerate_existing`` lists top-level entry names (``.git``, ``README.md``,
181-
...) that may already exist in ``dest``. Their scaffold counterparts are
182-
skipped and returned so the caller can surface a notice. Anything else
183-
pre-existing raises ``FileExistsError``.
160+
``preserve_existing`` semantics match :func:`create_workspace`.
184161
"""
185162
dest = Path(dest)
186-
_require_empty_dest(dest, allowed=tolerate_existing)
163+
_require_empty_dest(dest, preserve_existing=preserve_existing)
187164
module_dep_lines = "\n".join(f' "{_module_to_pypi_name(m)}>=0.1,<1.0",' for m in modules)
188165
preserved = _apply_template_files(
189166
_resolve_template_root("host", template_root),
@@ -193,7 +170,7 @@ def create_host(
193170
"{{MODULE_DEPS}}": module_dep_lines,
194171
"{{FRAMEWORK_VERSION}}": framework_version,
195172
},
196-
preserve_existing=tolerate_existing,
173+
preserve_existing=preserve_existing,
197174
)
198175
logger.info(
199176
"Scaffolded host '%s' at %s (modules: %s)", name, dest, ", ".join(modules) or "<none>"

‎framework/cli/tests/test_cli_new_regressions.py‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,6 @@ def test_sm_new_tolerates_git_init_leftovers_in_dest(tmp_path: Path) -> None:
128128
target = tmp_path / "demo"
129129
target.mkdir()
130130
(target / ".git").mkdir()
131-
(target / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
132131
user_gitignore = "*.pyc\n"
133132
(target / ".gitignore").write_text(user_gitignore)
134133
user_readme = "# demo (user-authored)\n"
@@ -141,14 +140,12 @@ def test_sm_new_tolerates_git_init_leftovers_in_dest(tmp_path: Path) -> None:
141140
)
142141

143142
assert result.exit_code == 0, result.output
144-
# User-authored files survived unchanged.
143+
assert (target / ".git").is_dir()
145144
assert (target / ".gitignore").read_text() == user_gitignore
146145
assert (target / "README.md").read_text() == user_readme
147146
assert (target / "LICENSE").read_text() == "MIT\n"
148-
# Scaffold's own files landed alongside them.
149147
assert (target / "host" / "pyproject.toml").is_file()
150148
assert (target / "modules" / "hello").is_dir()
151-
# CLI surfaced the preservation notice so users can merge by hand.
152149
assert "Preserved existing files" in result.output
153150
assert ".gitignore" in result.output
154151
assert "README.md" in result.output
@@ -201,5 +198,4 @@ def test_sm_new_still_refuses_unrelated_files_in_dest(tmp_path: Path) -> None:
201198
assert result.exit_code != 0
202199
output = result.output + (result.stderr or "")
203200
assert "my_notes.md" in output
204-
# User's file is intact.
205201
assert (target / "my_notes.md").read_text() == "don't clobber me"

0 commit comments

Comments
 (0)