feat(init): Preview planned file changes before initialization writes - #4312
feat(init): Preview planned file changes before initialization writes#4312darion-yaphet wants to merge 5 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
|
Please address Copilot feedback |
Quarantine external staged symlinks, strip Windows extended path prefixes, include permission bits in snapshot fingerprints, and resolve home-relative --extension specs before isolating HOME.
There was a problem hiding this comment.
🟡 Changes recommended
The preview can diverge from real initialization for external symlinks and file removals, and constitution provenance is inaccurate.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
src/specify_cli/commands/init.py:410
- The candidate set only includes paths that still exist after staging, so removals made by the canonical initializer disappear from the preview. For example, Kimi's
--migrate-legacysetup moves or removes files under.kimi/skills/; the dry run reports the new destination but omits that the original path will be deleted, so it does not match the subsequent real init. Removed initial paths need an explicit plan outcome (or this migration must be reported as unresolved).
candidates = {
path
for path, digest in staged_files.items()
if initial_files.get(path) != digest
}
candidates.update(path for path in ownership if path in staged_files)
src/specify_cli/commands/init.py:389
- This fallback misattributes preset- or extension-backed constitutions as
core.ensure_constitution_from_template()resolves the highest-priority layer, and the bundledself-testpreset providestemplates/constitution-template.md, so a dry run with that preset reports the created constitution (and its provenance sidecar) with the wrong provenance. Derive ownership from.constitution-template.jsonor the resolver's winning layer.
if relative_path.startswith(".specify/"):
return "core", None
src/specify_cli/commands/init.py:889
- An existing constitution is always recorded as a core skip, even when
.constitution-template.jsonidentifies a preset or extension as its source._merge_recorded_plan_actions()then replaces the preserve record with this hard-coded provenance, making reinitialization previews inaccurate. Read the materialized provenance before recording the skip.
_record_init_plan_action(
"skip",
".specify/memory/constitution.md",
"core",
)
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Balanced
| path.unlink() | ||
| path.mkdir() |
|
Please address Copilot feedback |
Retarget external staged links at an isolated dummy outside the project copy so setup() still rejects destinations that leave the tree, without writing through to the live target.
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’s 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