Edit transactionally
greppy's edit commands change exactly what you name. Selectors are exact, and an edit whose preconditions do not hold fails without writing anything. Every applied edit returns a receipt, so you do not need to reopen the file to confirm it.
The examples run on a private copy of serde 1.0.229, the verification examples on a small Python project. Every edit was reversed afterwards with greppy undo.
The edit commands
| Command | Does |
|---|---|
greppy replace S [NEW] |
replaces a definition; --body replaces its body |
greppy replace-span H [NEW] |
replaces exactly the span a handle names; refused if the file changed since the handle was printed |
greppy replace-text F OLD [NEW] |
replaces OLD; refused unless it occurs exactly once (--expect N: exactly N times; --regex: OLD is a regular expression) |
greppy replace-lines F A:B [NEW] |
replaces an inclusive, 1-based line range |
greppy insert-lines F N [NEW] |
inserts after line N; line 0 inserts at the top |
greppy delete S |
removes a definition |
greppy delete-lines F A:B |
removes a line range |
greppy write PATH [NEW] |
creates or overwrites a file |
greppy rename S NAME |
renames a definition and every graph-resolved reference |
greppy patch [DIFF] |
applies a unified diff to existing files as one transaction |
greppy undo [ID] |
reverses an edit; the latest one when ID is absent |
When NEW or DIFF is absent, greppy reads the content from stdin.
A typical edit
Read the definition with a handle and replace exactly that span:
$ greppy read RenameRule::or --handle
serde_derive/src/internals/case.rs:111-117 RenameRule::or
/// Returns the `RenameRule` if it is not `None`, `rule_b` otherwise.
pub fn or(self, rule_b: Self) -> Self {
match self {
None => rule_b,
_ => self,
}
}
handle: geh2:AorPGEstJxna8J-7pG_UieOOxt36q0czJg
$ greppy replace-span geh2:AorPGEstJxna8J-7pG_UieOOxt36q0czJg < or_if.rs
applied serde_derive/src/internals/case.rs:111-118 5eeeda
│
│ 108 }
│ 109 }
│ 110
│> 111 /// Returns the `RenameRule` if it is not `None`, `rule_b` otherwise.
│> 112 pub fn or(self, rule_b: Self) -> Self {
│> 113 if self == None {
│> 114 rule_b
│> 115 } else {
│> 116 self
│> 117 }
│> 118 }
│ 119 }
│ 120
│ 121 pub struct ParseError<'a> {
If the file has changed since then, the same handle writes nothing, exit code 12:
$ greppy replace-span geh2:AorPGEstJxna8J-7pG_UieOOxt36q0czJg < or_if.rs
serde_derive/src/internals/case.rs changed since that handle was taken
You replace a whole definition by its name. --body replaces the body including its braces, and --dry-run shows beforehand what would change:
$ greppy replace RenameRule::or --body --dry-run < or_body.rs
would apply serde_derive/src/internals/case.rs:112-118
Text and line edits work on files:
greppy replace-text serde_derive/src/internals/case.rs 'expected one of ' 'expected one of: '
greppy replace-lines serde_derive/src/internals/case.rs 113:115 < or_lines.rs
greppy insert-lines serde_derive/src/internals/ctxt.rs 0 < header.rs
$ greppy replace-text serde_derive/src/internals/case.rs 'expected one of ' 'expected one of: '
applied serde_derive/src/internals/case.rs:128 2f9ff2
│
│ 125 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
│ 126 f.write_str("unknown rename rule `rename_all = ")?;
│ 127 Debug::fmt(self.unknown, f)?;
│> 128 f.write_str("`, expected one of: ")?;
│ 129 for (i, (name, _rule)) in RENAME_RULES.iter().enumerate() {
│ 130 if i > 0 {
│ 131 f.write_str(", ")?;
$ greppy insert-lines serde_derive/src/internals/ctxt.rs 0 < header.rs
applied serde_derive/src/internals/ctxt.rs:1-2 c7ade8
│
│> 1 // Error collection shared by the derive macros.
│> 2
│ 3 use quote::ToTokens;
│ 4 use std::cell::RefCell;
│ 5 use std::fmt::Display;
With --regex, $1 or ${name} in NEW inserts a capture group and $$ a literal $. greppy refuses unknown groups before it writes anything:
$ greppy replace-text serde_derive/src/internals/case.rs --regex 'children to "([^"]+)" style' 'children to `$1` style' --expect 8
applied serde_derive/src/internals/case.rs:12,14,16,19,21,24,27,29 418ff3
This turns /// Rename direct children to "lowercase" style. into /// Rename direct children to `lowercase` style., in all eight doc comments of the RenameRule enum.
Receipts and refusals
A receipt names every touched place and the edit ID. Below it you see the landed span with up to three lines of context, read fresh from disk, the landed lines marked with >. An edit at several places lists them all, for example applied serde_derive/src/internals/case.rs:56,81 ebb5f8 for --expect 2. patch and rename print one such line per file, all with the same edit ID.
A refusal states its reason with evidence and writes nothing, exit code 13:
$ greppy replace-text serde_derive/src/internals/case.rs 'returning the version expected in the source' 'as written in the source'
OLD occurs 2 times — nothing written
serde_derive/src/internals/case.rs:56:51: /// Apply a renaming rule to an enum variant, returning the version expected in the source.
serde_derive/src/internals/case.rs:81:50: /// Apply a renaming rule to a struct field, returning the version expected in the source.
If OLD differs from a place in the file in whitespace alone, the refusal names that place with its line range, the exact text and the replace-lines alternative, exit code 13:
$ greppy replace-text serde_derive/src/internals/case.rs "$(cat old_ws.txt)" 'x'
OLD occurs 0 times — nothing written
nearest match differs only in whitespace at serde_derive/src/internals/case.rs:130-131; copy these lines exactly as OLD, or use: greppy replace-lines serde_derive/src/internals/case.rs 130:131 [NEW]
if i > 0 {
f.write_str(", ")?;
Before writing, greppy checks the syntax of the result. An edit that introduces new syntax errors is refused, even without --verify, exit code 13:
$ greppy replace-text units.py 'return high' 'return high)'
refused: syntax validation failed in proposed units.py:9:21 (Python suite requires a statement; --body replacements must include indentation (for example, four spaces before return); use pass for an empty body; column is a byte offset); errors 0 -> 2, missing nodes 0 -> 0 — nothing written. Location refers to the proposed result, not the unchanged file
An edit whose selector matches nothing or whose symbol is ambiguous exits with a non-zero code and writes nothing.
Preview and verify
| Flag | Effect |
|---|---|
--dry-run |
reports what would change, for example would apply serde_derive/src/internals/case.rs:112-118, and changes nothing |
--verify |
runs a bounded local checker for the language of the touched file and reports its diagnostics |
--json |
the receipt as data, with hashes before and after the edit and a handle on the new span |
--verify prints the selected command and its live state, then the result. Project tests run when you select them with GREPPY_VERIFY_TEST_COMMAND:
$ greppy replace clamp --verify < clamp_ok.py
verify: running Python syntax check — python3 -m py_compile units.py (timeout 60s)
verify: passed — Python syntax check
verify: tests not run — select affected tests with GREPPY_VERIFY_TEST_COMMAND or run them through `greppy bash-smart` before finishing
applied units.py:4-6 2e04c7
│
│ 1 """Small unit conversion helpers."""
│ 2
│ 3
│> 4 def clamp(value, low, high):
│> 5 """Restrict a value to the closed range [low, high]."""
│> 6 return max(low, min(value, high))
│ 7
│ 8
│ 9 def celsius_to_fahrenheit(celsius):
verify: passed — Python syntax check
verify: tests not run — select affected tests with GREPPY_VERIFY_TEST_COMMAND or run them through `greppy bash-smart` before finishing
With GREPPY_VERIFY_TEST_COMMAND, the test command runs through greppy bash-smart within the same bounded check. A failed check does not roll the edit back: the edit stays applied, the receipt reports verify: failed, and the command exits with code 17:
$ GREPPY_VERIFY_TEST_COMMAND='python3 -m unittest discover -s tests -t .' greppy replace-text units.py ' return high' ' return high - 1' --verify
verify: running Python syntax check — python3 -m py_compile units.py (timeout 60s)
verify: passed — Python syntax check
…
verify: failed (exit 1) — selected project tests
applied units.py:9 4a15f0
│
│ 6 if value < low:
│ 7 return low
│ 8 if value > high:
│> 9 return high - 1
│ 10 return value
…
verify: failed (exit 1) — selected project tests
AssertionError: 2 != 3
FAILED — exit 1: 4 errors, 0 warnings
verify: edit remains applied; repair it or use `greppy undo` to restore the previous edit
Reverse the edit with greppy undo or repair it with the next edit. The checker follows the language of the touched file; for units.py it is python3 -m py_compile.
patch
greppy patch --dry-run < or_and_doc.diff
greppy patch --verify < or_and_doc.diff
patch takes a unified diff and lands it as a whole. Hunks anchor on their context lines, and the line numbers in @@ are hints. In the following diff, the second hunk actually sits at line 37, not at 20:
--- a/serde_derive/src/internals/case.rs
+++ b/serde_derive/src/internals/case.rs
@@ -110,9 +110,10 @@
/// Returns the `RenameRule` if it is not `None`, `rule_b` otherwise.
pub fn or(self, rule_b: Self) -> Self {
- match self {
- None => rule_b,
- _ => self,
+ if self == None {
+ rule_b
+ } else {
+ self
}
}
}
--- a/serde_derive/src/internals/ctxt.rs
+++ b/serde_derive/src/internals/ctxt.rs
@@ -20,7 +20,7 @@
.push(syn::Error::new_spanned(obj.into_token_stream(), msg));
}
- /// Add one of Syn's parse errors.
+ /// Record one of Syn's parse errors.
pub fn syn_error(&self, err: syn::Error) {
self.errors.borrow_mut().as_mut().unwrap().push(err);
}
$ greppy patch --dry-run < or_and_doc.diff
would apply serde_derive/src/internals/case.rs:110-119
would apply serde_derive/src/internals/ctxt.rs:37-43
$ greppy patch < or_and_doc.diff
applied serde_derive/src/internals/case.rs:110-119 09b3f2
applied serde_derive/src/internals/ctxt.rs:37-43 09b3f2
$ greppy undo
reversed 09b3f2 2 files
Every file in the diff changes together with the others, or none does. If the context of a hunk does not match, greppy refuses the whole patch, even if other hunks would match, exit code 13:
$ greppy patch < or_and_doc_bad_context.diff
serde_derive/src/internals/ctxt.rs: input hunk 2 at patch line 19: hunk context did not match (the @@ line 37 is advisory). Removed and context lines must match complete source lines, not substrings; regenerate with `git diff --no-color -- PATH`, or use `greppy replace-text` for an intentional substring replacement — nothing written
Put related changes into one patch instead of a series of single edits. One greppy undo reverses the whole patch.
patch changes existing files. You create a new file with greppy write PATH, with the content on stdin. That is a separate transaction, apart from the edits of a patch. A patch with a new file ends with exit code 20:
$ greppy patch < newfile.diff
new_mod.py: patch only edits existing files; to create a file, use `greppy write PATH` with its content on stdin. This is a separate transaction, not atomic with edits in this patch — nothing written
$ printf 'X = 1\n' | greppy write new_mod.py
applied new_mod.py:1 5a79a7
│
│> 1 X = 1
rename
greppy rename visit_content_map visit_content_entries --dry-run
greppy rename visit_content_map visit_content_entries
rename changes the definition and every reference the graph resolves. For visit_content_map, these are exactly the three callers who-calls names, plus the definition:
$ greppy rename visit_content_map visit_content_entries --dry-run
would apply serde/src/private/de.rs:1103,1152,1399,1415
$ greppy rename visit_content_map visit_content_entries
applied serde/src/private/de.rs:1103,1152,1399,1415 bf5b9c
$ git diff -U0
…
@@ -1103 +1103 @@ mod content {
- fn visit_content_map<'de, V, E>(
+ fn visit_content_entries<'de, V, E>(
@@ -1152 +1152 @@ mod content {
- Content::Map(v) => visit_content_map(v, visitor),
+ Content::Map(v) => visit_content_entries(v, visitor),
…
$ greppy rename wrap_in_const wrap_in_dummy_const
applied serde_derive/src/de.rs:78 e57388
applied serde_derive/src/dummy.rs:4 e57388
applied serde_derive/src/ser.rs:60 e57388
If greppy finds the old name as an identifier in places where the graph resolved no reference, it refuses the rename and writes nothing, exit code 12:
$ greppy rename Ctxt::error_spanned_by error_at
graph reference scope serde_derive/src/internals/attr.rs contains 5 live `error_spanned_by` identifiers for `Ctxt::error_spanned_by`; select a narrower symbol or refresh the index — nothing written
rename neither renames nor reports text the graph does not model as a reference, such as comments, strings or reflection-based lookups. After a rename, search for the old name with greppy search-pattern OLD --fixed. Read more in Limits.
undo
greppy undo # reverse the latest edit
greppy undo ID # reverse a named edit
$ greppy undo
reversed 5eeeda serde_derive/src/internals/case.rs
undo ID reverses an older edit as long as its files still look the way that edit left them. Later edits to other files do not interfere. If a later edit changed the same file, greppy refuses, exit code 12:
$ greppy undo ebb5f8
serde_derive/src/internals/case.rs no longer looks the way that edit left it
Then reverse the later edits first: greppy undo without an ID walks the chain backwards until nothing to undo in this workspace (exit code 10) appears. Use undo to reverse a published edit instead of reconstructing the old text by hand. undo after write removes the created file again.
Exit codes
The examples on this page return these exit codes:
| Code | Situation |
|---|---|
| 0 | edit applied, preview or undo successful |
| 10 | nothing left to undo |
| 12 | stale handle, undo on a file changed later, rename with unresolved identifiers |
| 13 | OLD does not occur exactly as often as required, syntax error in the result, hunk context does not match |
| 17 | edit applied, --verify failed |
| 20 | patch with a new file |
Agents and edits
The agent prompt tells agents to prefer one precise edit, or one greppy patch --verify for related changes, and not to reopen touched source after a verified edit succeeds. Follow-up is called for when the result reports an ambiguity, incomplete evidence or a failed verification. See Agent setup.