Skip to content

fix(install): show each change in --dry-run and write nothing before the question - #1173

Merged
tirth8205 merged 6 commits into
stagingfrom
fix/173-install-preview
Nov 9, 2026
Merged

tirth8205 merged 6 commits into
stagingfrom
fix/173-install-preview

Conversation

@tirth8205

@tirth8205 tirth8205 commented Nov 9, 2026 •

Copy link
Copy Markdown
Owner

Part of #173.

Merge this after #1186. This branch now has the merge train train/2026-11-09 merged into it (a merge commit, no rebase), because #1157, #1155, #1172 and #1180 in that train changed the same install writes. Merged before #1186, it would bring the whole train with it.

What was wrong

  • install --dry-run listed file names only, never what would be written.
  • A real install wrote the MCP config, .gitignore, skills and hooks before it asked about CLAUDE.md and the other instruction files.

What this does

Every install read and write now goes through a new module, install_plan.py. Inside a plan a write is held in memory, later reads in the same run see it (Gemini's .gemini/settings.json gets the MCP entry and then the hooks), and nothing reaches the disk until Plan.apply(). Outside a plan the helpers write straight to disk, so direct callers and existing tests behave as before.

  • install --dry-run prints one unified diff per file, for every writer of every platform in PLATFORMS: MCP configs (JSON, JSONC, TOML, YAML), .gitignore, Claude/Gemini/CodeBuddy/Hermes/Qoder/Codex skills, Claude/Qoder/CodeBuddy/Codex/Cursor/Gemini hooks, git hooks, the OpenCode plugin, the instruction files, the --steering hook and the --gitignore block. A new file shows as its content. Each file is cut at 60 lines with ... N more lines not shown. It writes nothing.
  • A real install lists the files it will change, asks its question about instruction files, then writes exactly that plan. Progress messages print after the write, so none claims a change that has not happened.
  • Ctrl-C at the question stops with nothing written (exit 130). A step that fails while planning writes nothing. A file edited while the question was open is left alone and reported.
  • JSONC configs go through the same splice in both modes, so comments survive the preview and the write.

Redone on top of the train (#1186)

The train's install writers now all go through the plan too:

Proof

Throwaway HOME and throwaway git repo per platform, lived in: a CLAUDE.md, a .gitignore, a commented opencode.jsonc, a commented .claude/settings.json, a commented .gemini/settings.json, a commented ~/.cursor/hooks.json and a ~/.codex/config.toml holding another server. For each of the 16 platforms in PLATFORMS, and all: hash the whole tree, run code-review-graph install --dry-run --platform X, hash again; then run the real install and compare what it wrote with the dry run, both as rendered diffs and as the full bytes and modes of every file (the plan is read back from a second dry run in-process).

Plain install:

antigravity  files=  4  tree before=03da6c51c086 after=03da6c51c086  unchanged=True  diffs-match=True  bytes-match=True
claude       files= 13  tree before=b83eb499a13f after=b83eb499a13f  unchanged=True  diffs-match=True  bytes-match=True
codebuddy    files=  8  tree before=ef2bb92d1a39 after=ef2bb92d1a39  unchanged=True  diffs-match=True  bytes-match=True
codex        files= 12  tree before=b888bae38451 after=b888bae38451  unchanged=True  diffs-match=True  bytes-match=True
continue     files=  2  tree before=037095f70613 after=037095f70613  unchanged=True  diffs-match=True  bytes-match=True
copilot      files=  3  tree before=1aa08640af92 after=1aa08640af92  unchanged=True  diffs-match=True  bytes-match=True
copilot-cli  files=  3  tree before=2158d0aa04e2 after=2158d0aa04e2  unchanged=True  diffs-match=True  bytes-match=True
cursor       files=  9  tree before=373525b6a884 after=373525b6a884  unchanged=True  diffs-match=True  bytes-match=True
gemini-cli   files= 10  tree before=1244ac06abdd after=1244ac06abdd  unchanged=True  diffs-match=True  bytes-match=True
hermes       files=  7  tree before=848b7cee4e5a after=848b7cee4e5a  unchanged=True  diffs-match=True  bytes-match=True
kiro         files=  3  tree before=b39bddae6f70 after=b39bddae6f70  unchanged=True  diffs-match=True  bytes-match=True
opencode     files=  4  tree before=b00a3f498ebd after=b00a3f498ebd  unchanged=True  diffs-match=True  bytes-match=True
qoder        files= 15  tree before=b4388e7fe124 after=b4388e7fe124  unchanged=True  diffs-match=True  bytes-match=True
qwen         files=  2  tree before=d3b22a10d5a7 after=d3b22a10d5a7  unchanged=True  diffs-match=True  bytes-match=True
windsurf     files=  3  tree before=830c93481da2 after=830c93481da2  unchanged=True  diffs-match=True  bytes-match=True
zed          files=  2  tree before=5df4ecd5628c after=5df4ecd5628c  unchanged=True  diffs-match=True  bytes-match=True
all          files= 70  tree before=14f869c3d21b after=14f869c3d21b  unchanged=True  diffs-match=True  bytes-match=True

With --steering --gitignore:

antigravity  files=  4  tree before=ca64a0166b47 after=ca64a0166b47  unchanged=True  diffs-match=True  bytes-match=True
claude       files= 13  tree before=ed019bae3ce8 after=ed019bae3ce8  unchanged=True  diffs-match=True  bytes-match=True
codebuddy    files=  8  tree before=7e603b69dc6e after=7e603b69dc6e  unchanged=True  diffs-match=True  bytes-match=True
codex        files= 12  tree before=413fcfe97cf0 after=413fcfe97cf0  unchanged=True  diffs-match=True  bytes-match=True
continue     files=  2  tree before=c419ee62965d after=c419ee62965d  unchanged=True  diffs-match=True  bytes-match=True
copilot      files=  3  tree before=0b1b2c3538c9 after=0b1b2c3538c9  unchanged=True  diffs-match=True  bytes-match=True
copilot-cli  files=  3  tree before=9d6456194b0c after=9d6456194b0c  unchanged=True  diffs-match=True  bytes-match=True
cursor       files=  9  tree before=b1d5fa46a1b3 after=b1d5fa46a1b3  unchanged=True  diffs-match=True  bytes-match=True
gemini-cli   files= 10  tree before=973b613b14ff after=973b613b14ff  unchanged=True  diffs-match=True  bytes-match=True
hermes       files=  7  tree before=af064e89aa61 after=af064e89aa61  unchanged=True  diffs-match=True  bytes-match=True
kiro         files=  3  tree before=7434b8f55bb0 after=7434b8f55bb0  unchanged=True  diffs-match=True  bytes-match=True
opencode     files=  4  tree before=106ffe1b6c05 after=106ffe1b6c05  unchanged=True  diffs-match=True  bytes-match=True
qoder        files= 15  tree before=2318179abc73 after=2318179abc73  unchanged=True  diffs-match=True  bytes-match=True
qwen         files=  2  tree before=b691e2d2a4fc after=b691e2d2a4fc  unchanged=True  diffs-match=True  bytes-match=True
windsurf     files=  3  tree before=81c2d2652e68 after=81c2d2652e68  unchanged=True  diffs-match=True  bytes-match=True
zed          files=  2  tree before=e1ef54e57ce2 after=e1ef54e57ce2  unchanged=True  diffs-match=True  bytes-match=True
all          files= 70  tree before=b359c0118eb3 after=b359c0118eb3  unchanged=True  diffs-match=True  bytes-match=True

Nothing written before the question

install --platform X --steering --gitignore run through a real pseudo terminal; the tree is hashed while the process waits at [Y/n], then n is typed.

On the train (train/2026-11-09, 30c9427):

platform claude, answer 'n', flags --steering --gitignore
tree before install:        53795dbbfd4c
tree while at the question: 90140d24c9a4  (same: False)
tree after answering:       ce0ff2adcd9b
CLAUDE.md untouched: True
.mcp.json written after the answer: True

On this branch:

platform claude, answer 'n', flags --steering --gitignore
tree before install:        8897fd2c3b9d
tree while at the question: 8897fd2c3b9d  (same: True)
tree after answering:       07e54e55d2c4
CLAUDE.md untouched: True
.mcp.json written after the answer: True
platform all, answer 'n', flags --steering --gitignore
tree before install:        67ad6957edaf
tree while at the question: 67ad6957edaf  (same: True)
tree after answering:       8ee5b9586fd1
CLAUDE.md untouched: True
.mcp.json written after the answer: True

Dry run on the train and on this branch

install --dry-run --platform claude --steering --gitignore on the lived-in tree. On the train it lists names only:

Installing MCP server config...
  [dry-run] Claude Code: would write $SANDBOX/repo/.mcp.json

Configured 1 platform(s): Claude Code

Graph instructions will be injected into:
  CLAUDE.md (append)

[dry-run] Would add the steering hook to .claude/settings.json.

[dry-run] Would ensure .gitignore ignores .code-review-graph/.
[dry-run] Would add to .gitignore, in the code-review-graph install files block:
    /.claude/settings.json.bak
    /.claude/skills/
    /.mcp.json
[dry-run] No files were modified.

On this branch (excerpts; 13 files). The commented hook settings keep their comment, and the steering hook sits after the other hooks:

[dry-run] Would add the steering hook to $SANDBOX/repo/.claude/settings.json.

[dry-run] Would add to .gitignore, in the code-review-graph install files block:
    /.claude/settings.json.bak
    /.claude/skills/
    /.mcp.json

[dry-run] Install would change 13 file(s):
...
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,9 @@
 node_modules/
+# Added by code-review-graph
+.code-review-graph/
+# >>> code-review-graph install files >>>
+# Added by `code-review-graph install --gitignore`. `uninstall` removes this block.
+/.claude/settings.json.bak
+/.claude/skills/
+/.mcp.json
+# <<< code-review-graph install files <<<
...
+++ b/.claude/settings.json
@@ -1,4 +1,42 @@
 {
   // team rule: Bash is fine
-  "permissions": {"allow": ["Bash"]}
+  "permissions": {"allow": ["Bash"]},
+  "hooks": {
+    "PostToolUse": [
+      {
+        "matcher": "Edit|Write",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "command -v code-review-graph >/dev/null 2>&1 && code-review-graph hook-update; cat >/dev/null || true",
...
+    "PreToolUse": [
+      {
+        "matcher": "Grep|Glob|Bash|Read",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "cat | { if command -v code-review-graph >/dev/null 2>&1; then code-review-graph enrich; else cat >/dev/null; fi; } || true",
...
[dry-run] No files were modified.

A real install prints, before the question:

Install will change these files:
  .mcp.json (new)
  .gitignore (update)
  .claude/skills/explore-codebase/SKILL.md (new)
  ...
  .git/hooks/post-rewrite (new)
Run with --dry-run to see each change first.

Graph instructions will be injected into:
  CLAUDE.md (append)
Inject graph instructions into these files? [Y/n]

Tests

  • tests/test_install_preview.py (111 tests). For every platform, a dry run leaves the tree byte for byte unchanged and shows a diff for each planned file; a real install writes exactly the dry-run plan, fresh and lived in (commented hook settings, hooks with a mode change, TOML, YAML, JSONC), plain and with --steering --gitignore; nothing is written before the question (claude and all, plain and with the opt-ins); Ctrl-C and a failing step write nothing; JSONC comments are kept; long files are cut with a note; a file edited after the preview is left alone.
  • New for the train: the steering hook in a dry run; --no-steering removes what the same run planned; commented hook settings and their backup stay in the plan; a hook settings file install will leave alone is named in the dry run; the --gitignore block in a dry run matches the real write; saying no keeps CLAUDE.md out of the block. With the train writers left writing straight to disk, 41 of these tests fail.
  • uv run ruff check code_review_graph/: clean.
  • uv run --extra dev python -m mypy code_review_graph/ --ignore-missing-imports --no-strict-optional: no issues in 92 files.
  • uv run pytest tests/test_platform_lifecycle.py -m platform_lifecycle: 124 passed, 17 xfailed (as before).
  • Full suite uv run pytest tests/ --tb=short -q: 7842 passed, 919 skipped, 3 xfailed, 3 xpassed, 6 failed. None of the six is from this branch: three test_token_budget.py cases fail only because of the long temp path I ran under (144 passed with a short one), and three test_http_multi_repo.py cases fail the same way on the train itself at 30c9427 with the local mcp 2.3.0 (float + timedelta in the client).

Left for a maintainer decision

The question still covers the instruction files only, as before: saying no skips them, and the MCP config, skills and hooks are still written after the answer. If a real install should instead ask one yes/no for every file (or print the diffs inline before asking), that is a change to what install does by default, so I left it out. That is why this says "Part of #173".

🤖 Generated with Claude Code

…the question (#173)

install --dry-run listed file names only, and a real install wrote the MCP
config, skills and hooks before it asked about CLAUDE.md and the other
instruction files.

Every install read and write now goes through install_plan. Inside a plan a
write is held in memory, later reads see it, and nothing reaches the disk
until Plan.apply. --dry-run prints the plan as one unified diff per file
(a new file as its content, cut at 60 lines with a note) and writes nothing.
A real install lists the files it will change, asks its question, and then
writes exactly that plan. Ctrl-C at the question writes nothing, a step that
fails while planning writes nothing, and a file edited while the question
was open is left alone and reported. JSONC configs go through the same
splice in both modes, so comments are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

code-review-graph review

Overall risk: 0.65 (MEDIUM) — 131 changed function(s)/class(es), 32 affected flow(s), 59 test gap(s) (11 with no tested caller found, 48 reached only through a caller)

Risk-scored changes

Risk Level Symbol Location Tested
0.65 medium code_review_graph/cli.py::_confirm_yes_no code_review_graph/cli.py:212 indirect
0.65 medium code_review_graph/cli.py::_report_gitignore_block code_review_graph/cli.py:351 indirect
0.65 medium code_review_graph/cli.py::_print_instruction_conflicts code_review_graph/cli.py:713 indirect
0.65 medium code_review_graph/install_plan.py::_mode_changes code_review_graph/install_plan.py:57 indirect
0.65 medium code_review_graph/install_plan.py::_key code_review_graph/install_plan.py:64 indirect
0.65 medium code_review_graph/install_plan.py::_real_key code_review_graph/install_plan.py:68 indirect
0.65 medium code_review_graph/install_plan.py::_disk_state code_review_graph/install_plan.py:74 indirect
0.65 medium code_review_graph/install_plan.py::Plan._link_entry code_review_graph/install_plan.py:107 indirect
0.65 medium code_review_graph/install_plan.py::Plan._planned code_review_graph/install_plan.py:111 indirect
0.65 medium code_review_graph/install_plan.py::Plan.is_file code_review_graph/install_plan.py:120 indirect

Affected execution flows

  • benchmark_debug_workflow — criticality 0.74, 839 node(s) across 20 file(s)
  • benchmark_pre_merge_workflow — criticality 0.74, 789 node(s) across 19 file(s)
  • benchmark_architecture_workflow — criticality 0.74, 780 node(s) across 19 file(s)
  • benchmark_onboard_workflow — criticality 0.74, 770 node(s) across 17 file(s)
  • main — criticality 0.72, 1501 node(s) across 54 file(s)
  • ...and 27 more affected flow(s)

Test gaps

  • code_review_graph/cli.py::_say_nothing (code_review_graph/cli.py:305)
  • code_review_graph/install_gitignore.py::_created_entries (code_review_graph/install_gitignore.py:259)
  • code_review_graph/install_plan.py::FileChange.is_change (code_review_graph/install_plan.py:51)
  • code_review_graph/install_plan.py::Plan.exists (code_review_graph/install_plan.py:114)
  • code_review_graph/install_plan.py::Plan.lexists (code_review_graph/install_plan.py:134)
  • ...and 6 more with no direct test

Reached only through a caller

No test names these directly, but a tested caller reaches them along the call path shown. That is a path in the call graph, not a record of execution -- the caller's tests may never take this branch. Treat it as where to look, not as coverage: these count as gaps and are scored as untested.

  • code_review_graph/cli.py::_instruction_files_to_modify (code_review_graph/cli.py:164) — via code_review_graph/cli.py::_handle_init, 1 hop(s)
  • code_review_graph/cli.py::_describe (code_review_graph/cli.py:186) — via code_review_graph/cli.py::_handle_init, 2 hop(s)
  • code_review_graph/cli.py::_confirm_yes_no (code_review_graph/cli.py:212) — via code_review_graph/cli.py::_handle_init, 1 hop(s)
  • code_review_graph/cli.py::_apply_steering_choice (code_review_graph/cli.py:309) — via code_review_graph/cli.py::_handle_init, 1 hop(s)
  • code_review_graph/cli.py::_report_gitignore_block (code_review_graph/cli.py:351) — via code_review_graph/cli.py::_handle_init, 1 hop(s)
  • ...and 43 more reached only through a caller

Powered by code-review-graph — local-first analysis; no code leaves the CI runner.

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

Incomplete conflict protection can recreate instruction files deleted during confirmation and silently re-enable hooks whose execute permissions the user removed.

5 open issues
What changed in this PR

This PR partially addresses #173 by adding an in-memory installation plan, making installer changes visible before they reach disk.

Changes:

  • Adds per-file diff previews for install --dry-run.
  • Defers installation writes until after the instruction-file confirmation, with cancellation and conflict handling.
  • Adds cross-platform preview tests and updates installation documentation.
File Description
tests/​test_install_preview.py Tests previews, deferred writes, cancellation, and conflicts.
README.md Explains preview and confirmation behavior.
docs/​USAGE.md Documents installation options and write timing.
docs/​COMMANDS.md Clarifies the dry-run command description.
code_review_graph/​skills.py Routes configuration, skill, hook, and instruction changes through plans.
code_review_graph/​install_plan.py Adds buffered file operations, application, and diff rendering.
code_review_graph/​incremental.py Makes .gitignore updates plan-aware.
code_review_graph/​codex_skill.py Makes Codex skill installation operations plan-aware.
code_review_graph/​cli.py Coordinates planning, previews, confirmation, and application.
CLAUDE.md Documents the installation-plan module.
CHANGELOG.md Records the new installation behavior.

Review effort: Balanced

Comment thread code_review_graph/cli.py Outdated
raise SystemExit(130) from None
with contextlib.redirect_stdout(report):
if inject:
outcomes = inject_instruction_files(repo_root, target=target)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 66a46de: instruction files are now planned before the question and dropped if the answer is no, so an edit made while it is open is left alone and reported.

Comment thread code_review_graph/install_plan.py Outdated
failures: list[tuple[Path, str]] = []
for change in self.changes():
try:
if _disk_state(change.path)[0] != change.before:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 66a46de: apply now checks the permission bits as well as the bytes, so a hook turned off while the question is open stays off.

Comment thread code_review_graph/cli.py Outdated
Comment on lines +321 to +324
if not skip_instructions:
_print_instruction_conflicts(
inject_instruction_files(repo_root, target=target)
)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 66a46de: both modes now build the same instruction plan, the old Copilot file is listed before the question, and the real install removes it as the dry run showed.

Comment thread code_review_graph/cli.py Outdated
failures = plan.apply()

print()
sys.stdout.write(report.getvalue())

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 66a46de: when a write is left alone the progress messages are dropped and only the files actually written are named.



def _key(path: PathLike) -> Path:
return Path(os.path.abspath(os.fspath(path)))

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 66a46de: the plan keys each file by its real path, so CLAUDE.md linked to AGENTS.md is planned and written once; removing a symlink still removes only the link.

tirth8205 and others added 2 commits November 9, 2026 20:31
…des and symlinks

Copilot review on #1173:
- instruction files are planned before the question, so an edit made
  while it is open is left alone, and the dry run and the real install
  build the same plan, old Copilot instruction file included
- apply also compares permission bits, so a hook turned off while the
  question is open is not turned back on
- when a write is left alone, the progress messages are not replayed;
  only the files written are named
- the plan keys each file by its real path, so CLAUDE.md linked to
  AGENTS.md is written once; removing a symlink still removes the link

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Moving the hook chmod calls to install_plan.chmod(path, 0o755) put the
mode in the place bandit B103 checks, so the security job failed. A hook
has to be executable, so each call carries a nosec with that reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tirth8205 and others added 3 commits November 9, 2026 22:30
#1157 in the train rewrote the hook settings reads and writes to keep
comments, and #1155, #1172 and #1180 build on that. Each of them now goes
through the install plan, so --dry-run still writes nothing and a real
install still writes exactly what the dry run showed:

- _read_settings_for_merge and _write_merged_settings read, splice, back
  up and write through install_plan (the splice as bytes, so CRLF stays).
- The steering hook is planned after the other hooks, and --no-steering
  removes it through the plan, so it sees what this run planned.
- install_gitignore.apply reads, checks paths and writes through the
  plan. The block is planned after the instruction files and again
  without them when the question is answered no.
- Gemini's hook scripts and the Cursor and Codex hook folders are no
  longer created before the plan is applied.
- A hook settings file install will leave alone is named in the dry run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the two CI test fixes on train/2026-11-09 (30c9427).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tirth8205
tirth8205 merged commit 3eefc4a into staging Nov 9, 2026
26 checks passed
jay23606 pushed a commit to jay23606/code-review-graph that referenced this pull request Nov 9, 2026
Each claim in docs/, the get_docs_section_tool reference, CONTRIBUTING,
SECURITY, AGENTS.md, the Copilot instructions, the skills and the VS Code
README was checked against main.py, cli.py --help, PLATFORMS, migrations.py,
action.yml and the parsers, and fixed where it was wrong or missing after
the 2026-11-09 train and tirth8205#1173. The two changed skills are edited in
skills.py and regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants