Skip to content

feat(init): Preview planned file changes before initialization writes - #4312

Open
darion-yaphet wants to merge 5 commits into
github:mainfrom
darion-yaphet:feat/init-dry-run
Open

feat(init): Preview planned file changes before initialization writes#4312
darion-yaphet wants to merge 5 commits into
github:mainfrom
darion-yaphet:feat/init-dry-run

Conversation

@darion-yaphet

Copy link
Copy Markdown
Contributor

Description

Closes #4311.

Adds specify init --dry-run to preview initialization changes without writing to the requested project.

The command stages the target in a temporary directory, invokes the normal specify init path there, then reports create, overwrite, preserve, and conflict actions. --json emits machine-readable output for CI/tooling. URL extensions are reported as unresolved and are not downloaded during preview.

Also fixes the bundle initializer’s direct callback invocation so it explicitly disables the new dry-run flags.

Testing

  • Tested locally with .venv/bin/specify init --help
  • Ran the full suite with .venv/bin/python -m pytest
    7145 passed, 180 skipped
  • Tested disposable project scenarios through tests/test_init_dry_run.py
    7 passed

Additional validation:

  • git diff --check
  • .venv/bin/python -m compileall -q src/specify_cli
  • Bundle initialization regression test passed.
  • Ruff installed and run; the repository has existing lint violations, so this PR does not claim a clean Ruff baseline.

AI Disclosure

  • I did not use AI assistance for this contribution
  • I did use AI assistance

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 actions empty; moreover, the action classifier never emits conflict. This does not provide the per-artifact conflict plan promised by the PR and issue. Continue planning against staging, then classify would-be overwrites as conflict when force was not requested.
    if directory_conflict:
        _emit_dry_run_preview(payload, json_output=json_output)
        return

src/specify_cli/commands/init.py:535

  • --json does 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 skip result. 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.

Comment thread src/specify_cli/commands/init.py
Comment thread src/specify_cli/commands/init.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 reports create. 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

Comment thread src/specify_cli/commands/init.py Outdated
Comment thread src/specify_cli/commands/init.py
Comment thread src/specify_cli/commands/init.py
Comment thread src/specify_cli/commands/init.py Outdated
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 mnriem left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread src/specify_cli/commands/init.py Outdated
Comment thread src/specify_cli/commands/init.py
Comment thread src/specify_cli/commands/init.py Outdated
@mnriem

mnriem commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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-legacy setup 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 bundled self-test preset provides templates/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.json or 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.json identifies 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

Comment thread src/specify_cli/commands/init.py Outdated
Comment on lines +541 to +542
path.unlink()
path.mkdir()
@mnriem

mnriem commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Add specify init --dry-run preview

3 participants