Skip to content

feat(init): add --dry-run flag to preview changes without writing#1032

Merged
aeppling merged 8 commits into
rtk-ai:developfrom
hed0rah:feat/init-dry-run
May 9, 2026
Merged

feat(init): add --dry-run flag to preview changes without writing#1032
aeppling merged 8 commits into
rtk-ai:developfrom
hed0rah:feat/init-dry-run

Conversation

@hed0rah
Copy link
Copy Markdown
Contributor

@hed0rah hed0rah commented Apr 5, 2026

Summary

  • Adds --dry-run to rtk init so users can preview exactly what files would be created or modified before committing to a global install
  • All write operations (hook files, settings.json patches, RTK.md, .clinerules, .windsurfrules, etc.) are guarded — output shows [dry-run] would write: <abs_path> instead of touching the filesystem
  • Combines with --verbose (-v) to also print the full file contents that would be written, making it useful for auditing or CI validation

Test plan

  • rtk init -g --dry-run — prints paths, no files created, verify with rtk init --show still showing "not installed"
  • rtk init -g --dry-run -v — same as above but also prints file contents inline
  • rtk init -g then rtk init -g --dry-run — "already up to date" shown for existing files instead of silently skipping
  • rtk init --agent cline --dry-run — shows abs path to .clinerules in current dir
  • rtk init --agent windsurf --dry-run — same for .windsurfrules
  • rtk init -g --gemini --dry-run — shows hook file + settings.json patch
  • Verify no files/directories are created under any flag combination with --dry-run

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Apr 5, 2026

CLA assistant check
All committers have signed the CLA.

@hed0rah
Copy link
Copy Markdown
Contributor Author

hed0rah commented Apr 5, 2026

suppressed misleading success messages like "Gemini CLI hook installed (global)." and "Restart Gemini CLI. Test with: git status" that were printing even in dry-run, and printed: [dry-run] Nothing written. instead

@aeppling aeppling self-assigned this Apr 7, 2026
@hed0rah hed0rah force-pushed the feat/init-dry-run branch from 65f5fd1 to 3fa0323 Compare April 15, 2026 05:38
@aeppling
Copy link
Copy Markdown
Contributor

aeppling commented Apr 18, 2026

Hey @hed0rah , thanks for the contribution and keeping this up to date.

Few things to be check :

Merge conflicts with develop

This PR is based on a develop version that used hook scripts (rtk-rewrite.sh). Develop has since moved to binary commands (rtk hook claude/rtk hook cursor). Key functions from this PR no longer exist on develop:

  • ensure_hook_installed(), prepare_hook_paths() → removed (no scripts to install)
  • patch_settings_json(hook_path) → replaced by patch_settings_json_command(hook_command)
  • write_if_changed now uses atomic_write() instead of fs::write()
  • install_cursor_hooks simplified (no script creation, just hooks.json patching)
  • New on develop: migrate_old_hook_script() (cleans up legacy scripts)

Resolution: rebase on current develop, drop the removed functions, and thread dry_run into the new ones (patch_settings_json_command, migrate_old_hook_script, install_cursor_hooks).

Bugs to fix

Please check following issues to ensure a clean feature implementation:

  1. --uninstall --dry-run actually deletes files: uninstall() never receives dry_run. Add the parameter and guard all fs::remove_file / fs::write calls.

  2. Success messages print in dry-run mode: these functions print "installed"/"configured" messages without checking dry_run:

  • run_default_mode() (step 4 + PatchResult match)
  • run_hook_only_mode() (same pattern)
  • run_codex_mode_with_paths() (also missing [dry-run] Nothing written. footer)
  • run_copilot() (same , no guard, no footer)
  • install_cursor_hooks() (same)

Fix: wrap success blocks in if !dry_run { ... }.

  1. prompt_telemetry_consent() runs in dry-run: it prompts interactively and writes to config.toml. Guard with if !dry_run.

  2. KiloCode and Antigravity modes ignore --dry-run: run_kilocode_mode() and run_antigravity_mode() don't accept dry_run, so rtk init --agent kilocode --dry-run writes files anyway.

  3. After rebase: guard integrity::store_hash in run_gemini(): develop has this call outside any dry-run guard. Once you add dry_run to run_gemini, wrap it in if !dry_run.

Missing tests

No tests exercise dry_run=true. At minimum add tests verifying:

  • write_if_changed(..., dry_run=true) does not create the file
  • run_codex_mode_with_paths(..., dry_run=true) does not create RTK.md / AGENTS.md

Minor

  • Use atomic_write() (develop's version) instead of fs::write() in write_if_changed
  • patch_settings_json_command returns PatchResult::Patched in dry-run which is misleading, consider documenting or adding a WouldPatch variant

Rebased onto current develop (binary-command era). Threads dry_run
through patch_settings_json_command, migrate_old_hook_script,
install_cursor_hooks, run_kilocode_mode, run_antigravity_mode,
run_gemini, run_copilot, and uninstall.

Fixes from PR rtk-ai#1032 review:
- --uninstall --dry-run no longer deletes files (uninstall() now takes
  dry_run, every fs::remove_file / fs::write / atomic_write guarded)
- Success messages ("installed", "configured", "Restart ...") gated on
  !dry_run in run_default_mode, run_hook_only_mode, run_codex_mode,
  run_copilot, install_cursor_hooks, run_gemini
- prompt_telemetry_consent() skipped in dry-run
- integrity::store_hash() in run_gemini guarded
- KiloCode and Antigravity modes now accept dry_run
- PatchResult::WouldPatch variant added for patch_settings_json_command
- [dry-run] Nothing written. footer printed by every sub-mode
- write_if_changed uses atomic_write (not fs::write)

Added integrity::hash_path_for() public wrapper so dry-run can check
sidecar existence without the destructive remove_hash.

Tests: write_if_changed(dry_run=true) creates nothing;
run_codex_mode_with_paths(dry_run=true) creates neither RTK.md nor
AGENTS.md. 1596 tests pass, clippy clean.
@hed0rah hed0rah force-pushed the feat/init-dry-run branch from 3fa0323 to 21a069a Compare April 19, 2026 05:10
@hed0rah
Copy link
Copy Markdown
Contributor Author

hed0rah commented Apr 19, 2026

Rebased onto current develop and addressed all review feedback. Force-pushed the branch. History is replaced since the old PR commits predate the hook-script to binary-command refactor.

Addressed

  • Rebase: dropped removed functions (ensure_hook_installed, prepare_hook_paths, old patch_settings_json). Threaded dry_run into the new ones: patch_settings_json_command, migrate_old_hook_script, install_cursor_hooks.
  • --uninstall --dry-run deleted files: uninstall() now takes dry_run, every fs::remove_file / fs::write / atomic_write guarded. Added integrity::hash_path_for() so we can report intent without triggering the destructive remove_hash.
  • Stray success messages: wrapped in if !dry_run { ... } for run_default_mode, run_hook_only_mode, run_codex_mode_with_paths, run_copilot, install_cursor_hooks, run_gemini.
  • prompt_telemetry_consent() ran in dry-run: now guarded at the call site.
  • KiloCode + Antigravity ignored --dry-run: both accept dry_run and thread it through.
  • integrity::store_hash in run_gemini: guarded.
  • PatchResult::WouldPatch variant added; patch_settings_json_command returns it in dry-run.
  • write_if_changed now uses atomic_write instead of fs::write.
  • [dry-run] Nothing written. footer printed by every sub-mode.

Tests

  • test_write_if_changed_dry_run_does_not_create_file
  • test_write_if_changed_dry_run_does_not_modify_existing_file
  • test_run_codex_mode_dry_run_writes_nothing
  • 1596 tests passing, cargo clippy --all-targets clean.

Also manually smoke-tested: rtk init --dry-run, --uninstall --dry-run, --cursor --dry-run, --codex --dry-run, --copilot --dry-run all leave the filesystem untouched.

Ready for re-review.

@hed0rah
Copy link
Copy Markdown
Contributor Author

hed0rah commented Apr 21, 2026

When --dry-run was added, the signatures of run_default_mode, run_hook_only_mode, run_claude_md_mode, and uninstall each gained a trailing dry_run: bool parameter, but the test calls in src/hooks/init.rs (lines 4233, 4255, 4256, 4272-73, 4285, 4292, 4309, 4327) were never updated.
Added a trailing , false to each call. Local cargo clippy --all-targets now compiles cleanly

…removals

The two remove_file calls flagged by semgrep already existed pre-PR.
Wrapping them in if dry_run { print } else { remove_file } shifted
their context enough that --baseline-commit re-attributed them as new.
The rule's own message says deletion is expected in hooks/init cleanup.
Copy link
Copy Markdown
Contributor

@aeppling aeppling left a comment

Choose a reason for hiding this comment

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

Review: PR #1032 feat(init): add --dry-run flag

Thanks for the rebase and V2 fixes, the filesystem guard coverage is thorough. I built the branch (8b4a146), ran 23 manual tests across all init/uninstall modes, and the full test suite (1609 pass). Every install and uninstall dry-run correctly prevents writes.

One bug and two blocking items need to be addressed before merge, plus one architectural change request.

Bug to fix (blocking)

Double [dry-run] Nothing written. footer with --agent cursor

Repro: rtk init -g --agent cursor --auto-patch --dry-run

Output:

[dry-run] would create RTK.md: ~/.claude/RTK.md
[dry-run] would patch settings.json: ~/.claude/settings.json
[dry-run] would create global filters template: ~/.config/rtk/filters.toml

[dry-run] Nothing written.          <-- first
[dry-run] would patch Cursor hooks.json: ~/.cursor/hooks.json

[dry-run] Nothing written.          <-- second (duplicate)

Cause: run_default_mode() prints its own footer, then run() calls install_cursor_hooks() which prints another.

Fix: Move print_dry_run_footer() out of sub-modes and into run() as the single exit point, after all mode+cursor work completes.

Documentation (blocking)

CONTRIBUTING.md requires documentation for new features. Please add a short section to docs/guide/getting-started/quick-start.md covering --dry-run: what it does, how to use it, and its interaction with -v for content preview.

Architecture: Replace parameter threading with InitContext struct (blocking)

The PR threads dry_run: bool through 36 function signatures, the same 36 that already carry verbose: u8. This works but doesn't scale: PR #1493 (--only/--skip) would add more parameters to these same 36 signatures, and there are 10+ open PRs adding agent modes that each need to thread all params.

RTK already uses this pattern, see RunOptions in src/core/runner.rs:18:

#[derive(Default)]
pub struct RunOptions<'a> {
    pub tee_label: Option<&'a str>,
    pub filter_stdout_only: bool,
    pub skip_filter_on_failure: bool,
    pub no_trailing_newline: bool,
}

Apply the same pattern to init:

#[derive(Clone, Copy, Default)]
pub struct InitContext {
    pub verbose: u8,
    pub dry_run: bool,
}

Every signature changes from:

fn write_if_changed(path: &Path, content: &str, name: &str, verbose: u8, dry_run: bool) -> Result<bool>
fn run_default_mode(global: bool, patch_mode: PatchMode, verbose: u8, install_opencode: bool, dry_run: bool) -> Result<()>
fn patch_cursor_hooks_json(path: &Path, verbose: u8, dry_run: bool) -> Result<bool>

to:

fn write_if_changed(path: &Path, content: &str, name: &str, ctx: InitContext) -> Result<bool>
fn run_default_mode(global: bool, patch_mode: PatchMode, install_opencode: bool, ctx: InitContext) -> Result<()>
fn patch_cursor_hooks_json(path: &Path, ctx: InitContext) -> Result<bool>

Construction at call site:

let ctx = InitContext { verbose: cli.verbose, dry_run };
hooks::init::run(global, install_claude, ..., ctx)?;

Why this matters now:

  • verbose: u8 already pollutes 36 signatures, this PR doubles the problem
  • #[derive(Copy, Clone, Default)] means zero allocation, passable by value
  • Adding future flags (--only, --skip, --quiet) is one struct field instead of 36+ signature changes
  • Every open PR adding an agent mode benefits immediately, they just accept ctx: InitContext
  • This is a mechanical refactor (find-replace), no logic changes

Minor (non-blocking)

  • --show --dry-run: Silently ignores --dry-run. Consider #[arg(conflicts_with = "show")] on dry_run.
  • Missing tests: No test for uninstall dry-run path (the #1 bug from V1), no test for default mode dry-run. The 3 existing tests cover the primitive, but the most destructive paths deserve regression tests.

hed0rah added 2 commits April 26, 2026 17:53
Addresses three review items from PR rtk-ai#1032:

- Bundle verbose+dry_run into a Clone+Copy InitContext struct (mirrors
  RunOptions in src/core/runner.rs). Collapses 25+ function signatures
  that already carried both fields and makes future flags one struct
  field instead of N signature changes.
- Emit "[dry-run] Nothing written." exactly once from the top-level
  run() and uninstall() exit points instead of from every sub-mode.
  Fixes the double footer when --agent cursor combined with default
  mode.
- Reject --show with --dry-run via clap conflicts_with rather than
  silently ignoring --dry-run.
- Add regression tests for run_default_mode and uninstall dry-run paths
  using the existing with_claude_dir_override scaffolding.
Adds a "Preview without writing" subsection under Step 1 covering the
--dry-run flag, -v interaction for content preview, that telemetry
consent is skipped, and the --show conflict. Required by CONTRIBUTING.md
section 4 (new features need documentation).
@hed0rah
Copy link
Copy Markdown
Contributor Author

hed0rah commented Apr 27, 2026

@aeppling thanks for the thorough review. Pushed two commits addressing all five items.

Blocking (refactor): 98fe82d introduces InitContext { verbose, dry_run } with #[derive(Clone, Copy, Default)] mirroring RunOptions at src/core/runner.rs:17. Collapses 25 helper signatures plus the public run/uninstall/run_gemini/run_copilot/run_kilocode_mode/run_antigravity_mode entry points. Construction at the CLI seam in src/main.rs. Tests (16 sites) updated to InitContext::default() or struct literal.

Blocking (bug): Same commit. Removed all print_dry_run_footer() calls from sub-modes reached via run() (run_default_mode, run_hook_only_mode, run_claude_md_mode, run_opencode_only_mode, run_codex_mode_with_paths, run_windsurf_mode, run_cline_mode, install_cursor_hooks). Added a single call before Ok(()) in run() and uninstall(). Verified against the original repro: rtk init -g --agent cursor --auto-patch --dry-run now prints exactly one footer.

Blocking (docs): 2f185a4 adds a "Preview without writing" subsection to docs/guide/getting-started/quick-start.md under Step 1.

Minor (conflicts_with): Same code commit. #[arg(long = "dry-run", conflicts_with = "show")] on the dry_run flag. clap now rejects --show --dry-run with a clear error.

Minor (missing tests): Same code commit. Added test_run_default_mode_dry_run_writes_nothing and test_uninstall_dry_run_preserves_artifacts using the existing with_claude_dir_override helper. Both pass.

Full gate: cargo fmt && cargo clippy --all-targets && cargo test --all reports 1612 passed, 0 failed. Smoke-tested the release build against ~/.claude with an mtime snapshot diff and saw zero unintended writes.

@aeppling
Copy link
Copy Markdown
Contributor

aeppling commented May 9, 2026

LGTM , thanks for contributing to RTK !

@aeppling aeppling merged commit 172ec54 into rtk-ai:develop May 9, 2026
11 checks passed
@aeppling aeppling mentioned this pull request May 9, 2026
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.

3 participants