feat(init): Preview planned file changes before initialization writes - #4312
feat(init): Preview planned file changes before initialization writes#4312darion-yaphet wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
馃煛 Changes recommended
Dry-run can modify global Hermes files, and several advertised JSON, conflict, skip, and provenance guarantees are incomplete.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds specify init --dry-run to preview initialization changes through staged execution.
Changes:
- Adds human-readable and JSON preview output.
- Classifies staged file changes and unresolved URL extensions.
- Updates bundle initialization and adds dry-run contract tests.
File summaries
| File | Description |
|---|---|
src/specify_cli/commands/init.py |
Implements dry-run staging and reporting. |
src/specify_cli/commands/bundle/__init__.py |
Supplies new callback flags. |
tests/test_init_dry_run.py |
Covers preview output, parity, conflicts, and extensions. |
Review details
Suppressed comments (3)
src/specify_cli/commands/init.py:181
- A non-forced existing target returns before the initializer is staged, leaving
actionsempty; moreover, the action classifier never emitsconflict. This does not provide the per-artifact conflict plan promised by the PR and issue. Continue planning against staging, then classify would-be overwrites asconflictwhen force was not requested.
if directory_conflict:
_emit_dry_run_preview(payload, json_output=json_output)
return
src/specify_cli/commands/init.py:535
--jsondoes not reliably produce a single JSON document unless callers also specify the selections. With no--integration, non-interactive execution prints the defaulting message at lines 739-741 before the payload, while an interactive terminal opens the selection UI. Make JSON mode select defaults non-interactively and suppress or redirect all pre-payload human output.
json_output: bool = typer.Option(
False,
"--json",
help="Emit the dry-run preview as a single JSON document.",
src/specify_cli/commands/init.py:230
- The real initializer treats some requested-source failures as recoverable: for example, a missing preset warns and skips, and an invalid local extension records a tracker error, but both still exit zero. Because successful child output is discarded here, the preview silently omits that requested source and never emits the required
skipresult. Carry recoverable staged outcomes into the payload instead of only handling nonzero exits.
if result.returncode:
details = (result.stderr or result.stdout).strip().replace("\n", " ")
raise RuntimeError(f"staged initialization failed: {details[:240]}")
payload["actions"] = _build_preview_actions(initial_files, staged_root)
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Balanced
馃挕 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
馃煛 Changes recommended
Artifact-level conflicts, skipped files, JSON purity, and staging parity have unresolved correctness issues.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
src/specify_cli/commands/init.py:404
- Starting the staged home empty loses filesystem state that affects the real initialization. For example, Hermes writes directly to
~/.hermes/skills/.../SKILL.md(integrations/hermes/__init__.py:204-209): if the real path is a directory or symlink, a real init fails or follows the link, while this child succeeds against an empty home and the comparison reportscreate. Pre-stage the relevant existing home entries without following symlinks so the preview matches the subsequent invocation.
staged_home = Path(tmp_dir) / "home"
staged_home.mkdir()
- Files reviewed: 3/3 changed files
- Comments generated: 4
- Review effort level: Balanced
Dry-run stages the target and invokes the public initializer in an isolated child process, then reports create, overwrite, and preserve actions without writing to the requested project. This keeps previews aligned with integration-specific installation behavior.
HermesIntegration.setup() was writing into the real user home during init --dry-run, which let preview runs touch global files. Ownership for materialized preset and extension commands also depended on destination-path heuristics, which misclassified agent-directory outputs and lost the true provenance signal. Dry-run staging now keeps home-scoped output in an isolated preview environment, and ownership is derived from the staged registries and markers instead of from destination paths. The manifest keeps the concrete source_id separate from the required provenance category.
mnriem
left a comment
There was a problem hiding this comment.
Please address Copilot feedback
Stage existing projects without --force, classify colliding artifacts as conflict, keep --json stdout parseable, report skipped already-installed artifacts, and remap in-project absolute symlinks onto the staged copy.
fc515b3 to
9220041
Compare
There was a problem hiding this comment.
馃煛 Changes recommended
External symlinks can escape staging, while home-relative extensions and permission changes produce inaccurate plans.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 3
- Review effort level: Balanced
| ``copytree(..., symlinks=True)`` preserves absolute targets, so an in-tree | ||
| link keeps pointing at the live project. Remap those to the corresponding | ||
| staged path. Links that resolve outside *project_root* are left unchanged | ||
| so the initializer's containment check still rejects them. |
| def _snapshot_files(root: Path) -> dict[str, str]: | ||
| """Return SHA-256 digests for regular files below *root*.""" | ||
| if not root.exists(): | ||
| return {} | ||
|
|
||
| files: dict[str, str] = {} | ||
| for path in root.rglob("*"): | ||
| if not path.is_file() or path.is_symlink(): | ||
| continue | ||
| digest = hashlib.sha256(path.read_bytes()).hexdigest() | ||
| files[path.relative_to(root).as_posix()] = digest | ||
| return files | ||
|
|
||
|
|
||
| def _snapshot_matching_files(root: Path, relative_paths: set[str]) -> dict[str, str]: | ||
| """Return digests for selected regular files below *root*.""" | ||
| files: dict[str, str] = {} | ||
| for relative_path in relative_paths: | ||
| path = root / relative_path | ||
| if not path.is_file() or path.is_symlink(): | ||
| continue | ||
| files[relative_path] = hashlib.sha256(path.read_bytes()).hexdigest() | ||
| return files |
| for extension in staged_extensions: | ||
| command.extend(["--extension", extension]) |
|
Please address Copilot feedback |
Description
Closes #4311.
Adds
specify init --dry-runto preview initialization changes without writing to the requested project.The command stages the target in a temporary directory, invokes the normal
specify initpath there, then reportscreate,overwrite,preserve, andconflictactions.--jsonemits machine-readable output for CI/tooling. URL extensions are reported asunresolvedand are not downloaded during preview.Also fixes the bundle initializer鈥檚 direct callback invocation so it explicitly disables the new dry-run flags.
Testing
.venv/bin/specify init --help.venv/bin/python -m pytest7145 passed, 180 skippedtests/test_init_dry_run.py7 passedAdditional validation:
git diff --check.venv/bin/python -m compileall -q src/specify_cliAI Disclosure