You ask the Cursor AI code editor to raise one price constant. The diff view lights up with forty changed lines. Nothing in the task justified that, yet the file now looks different from top to bottom, and your reviewer will have to read all of it.
That near-miss pattern is a familiar complaint. The 2025 Stack Overflow Developer Survey found that 84% of respondents use or plan to use AI tools, while 66% named answers that are almost right but not quite as a frustration and 45% said debugging AI-generated code takes longer than writing it. A silent whole-file rewrite is the most expensive version of almost right, because the bug hides inside lines you never asked anyone to touch.
Rules help, and our guide to Cursor rules that tune diff versus rewrite edits covers the prompt side. This tutorial covers the other half: evidence. You will build a single-file Python tool called editscope that watches your repository while the Cursor AI code editor works, labels every settled save as a patch, a mixed edit, a rewrite or whitespace noise, and then blocks oversized changes at commit time with a Git hook. The whole project is 221 lines of standard-library Python, takes about 45 minutes to build, and works with any agent that writes files, not just Cursor.
By the end you will have a rule file, a watcher, a report command, a pre-commit gate, five unit tests and a repeatable way to compare models on the same prompt. Every code block below comes from the working project. The output samples come from a scripted dry run of that project on Python 3.12.3 and Git 2.43.0, and they are labeled as such, so you know which numbers are measured and which ones you will produce yourself.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Why the Cursor AI Code Editor Sometimes Rewrites Whole Files
An AI editor has to turn a model’s answer into a change on disk. There are two broad shapes for that answer. The model can describe a targeted replacement, meaning a few lines to find and the lines to put in their place. Or it can emit the full new body of the file and let the editor work out what changed. In day-to-day use you see both. Which one you get depends on the model, the size of the file, how much of it was in context and how your prompt was worded.
Cursor does not give you a documented switch that guarantees small diffs. After reading the current release notes and docs pages for this article, I found no setting that forces minimal edits or reports edit churn. Nothing in the official material I reviewed describes one for Claude Code, Grok Build or Copilot either. Minimal-diff behavior is therefore a policy you enforce yourself, and you cannot enforce what you do not measure.
That is the gap editscope fills. It sits outside the editor, watches the files, and answers one narrow question after each save: of the lines that existed before, how many survived? A save that keeps nearly all the old lines is a patch. A save that keeps only a small share is a rewrite, whatever the diff gutter made it look like.
The cost of a rewrite is easy to underestimate. Review time scales with the number of lines a human must read, not with the number the task needed. Blame history loses the thread, because every line now points at the same commit. Merge conflicts multiply when a teammate has a branch that touches the same file, and a conflict inside code you never meant to change is the most annoying kind. Even when the regenerated file passes its tests, you paid for a larger review, a muddier history and a higher chance of a subtle behavior change hiding in a renamed variable.
Teams usually discover the problem by accident. Someone opens a pull request, sees four hundred changed lines for a ten-line fix, and asks what happened. By then the agent session is gone and nobody remembers the prompt. A tracer that logs each save with a label and a ratio turns that mystery into a record you can search, and it gives you a number to put in a team rule instead of a vague request to be careful.
What the tracer can and cannot see
Be clear about the limits before you trust the numbers. editscope sees what lands on disk. It does not see the tokens the model produced, and it cannot tell whether the editor applied a surgical replacement or a full file body, only how much of the file changed as a result. If you want the model-side view, the benchmark of the Cursor CLI diff and rewrite edits measures speed and token cost. This tutorial measures the effect on your repository, which is the part your teammates actually review.
Unsaved changes also stay invisible. If an edit sits in the editor buffer waiting for you to accept it, the file on disk has not moved, and the watcher stays quiet until the save happens. That is fine for this purpose, because the saved state is what ends up in Git.
Prerequisites and Versions
The tool uses only the Python standard library plus Git, so there is nothing to install from a package index. You do need a Cursor build recent enough to read project rules from .cursor/rules. Cursor joined The X Subscription on October 12, 2026, according to a ScriptByAI timeline of xAI releases, and existing subscribers can keep their current plans, so nothing in this tutorial depends on which plan you hold. If your team shares settings across editors, the setup in running Cursor and VS Code on one repo with shared AI rules pairs well with the rule file you will create here.
| Component | Version used | Why it matters |
|---|---|---|
| Cursor desktop app | Latest stable build | Reads .cursor/rules/*.mdc and runs the agent that edits your files |
| Python | 3.9 or newer (tested on 3.12.3) | Runs editscope, uses only difflib, subprocess and json |
| Git | 2.30 or newer (tested on 2.43.0) | Provides ls-files, diff --numstat and the pre-commit hook |
| Shell | sh, bash or zsh (Git Bash on Windows) | Runs the hook script and the dry-run helper |
| Disk space | Under 5 MB | The event log is plain JSON lines, one short line per save |
| Time | About 45 minutes | Steps 1 to 10 take 30 minutes, the live Cursor runs take 15 |
Check your toolchain before starting. The commands below should print version numbers, not errors. On Windows, run everything from Git Bash or WSL so that the hook script and the sed commands in the dry run behave the same way they do on Linux and macOS.
python3 --version
git --version
git config user.name
git config user.email
If the last two commands print nothing, set them for this repository in Step 1. Git will refuse to create the baseline commit without an identity, and the gate needs that commit to compare against.
How the Patch, Mixed and Rewrite Labels Work
The core idea is a survival ratio. Take the file as it was before the save and as it is after. Count how many of the old lines still appear, in order, in the new version. Subtract that share from one and you get the rewrite ratio. A ratio of 0.02 means two percent of the original lines changed. A ratio of 0.61 means most of the file was replaced. The comparison runs on lines through difflib.SequenceMatcher, with junk heuristics switched off so that repetitive code, which AI editors love to produce, does not confuse the matcher.
| Label | Rule | Typical cause |
|---|---|---|
| patch | Ratio at or below 0.20 and at most 6 changed hunks | A targeted replacement of a few lines |
| mixed | Anything between patch and rewrite | Several scattered edits or one large block rewritten |
| rewrite | Ratio at or above 0.60 | The whole file regenerated, often with renames and reindenting |
| whitespace | Lines identical once spacing is collapsed | Formatter, tab to space swap or trailing space cleanup |
| create | File did not exist when the watcher started | A new file written by the agent |
These thresholds are starting points, not laws. A 12-line utility file will hit 0.60 after a perfectly sensible edit, which is why the Git gate you build later ignores files shorter than 30 lines. If your team already runs a lint pass on agent output, as in the tutorial on linting AI edits to catch rewrite damage, keep it. Linting asks whether the result is clean. editscope asks whether the change was proportionate, and those are different questions.
The whitespace label deserves a note. Reindenting a file from four spaces to two changes nearly every line, yet no behavior changes. Counting that as a rewrite would flood you with false alarms, so editscope collapses runs of spacing before it compares. The gate still counts whitespace churn separately, because noisy diffs hurt review and blame history even when the code stays correct.
Set Up the Repository and the Cursor Rule (Steps 1 and 2)
Step 1: Create the demo repository
Start with a small Python project that has one file long enough to show the difference between a patch and a rewrite. The generator below writes 54 lines of billing helpers, eight near-identical tier functions plus a discount function. Near-identical functions are exactly where an AI editor is tempted to regenerate the lot, so the file makes a good test subject.
mkdir editscope && cd editscope
git init
git config user.email "[email protected]"
git config user.name "Your Name"
mkdir -p app tools tests .cursor/rules
printf '.editscope/\n__pycache__/\n' > .gitignore
python3 - <<'PY'
lines = ['"""Billing helpers for the demo shop."""', '', 'from decimal import Decimal, ROUND_HALF_UP', '']
for i in range(1, 9):
lines += [f'def tier_{i}_price(units: int) -> Decimal:',
f' """Price for tier {i}."""',
f' base = Decimal("{i}.50")',
' total = base * units',
' return total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)', '']
lines += ['def apply_discount(total: Decimal, pct: int) -> Decimal:',
' return total - (total * pct / 100)', '']
open('app/billing.py', 'w').write("\n".join(lines))
PY
wc -l app/billing.py
git add -A && git commit -m "baseline"
The wc -l line should print 54. The .gitignore entry matters more than it looks. The watcher writes its log into .editscope/, and if Git tracked that folder, every log line would register as a change and the tool would end up measuring itself.
Step 2: Add the minimal-diff rule for Cursor
Next, tell the agent what you expect. Cursor reads project rules from .cursor/rules, and a rule marked alwaysApply goes into every request. Keep the text short and concrete. Vague advice such as be careful does nothing, while a numeric trigger, such as stopping to propose a split past roughly 40 changed lines, gives the model something it can act on. Rule files also travel outside the editor, and the walkthrough on building a Cursor rules extension for VS Code shows one way to share them.
---
description: Keep every edit small and local. Applies to all code changes.
alwaysApply: true
---
# Minimal diff policy
- Edit only the lines the task requires. Leave every other line byte-for-byte unchanged.
- Never regenerate or rewrite a whole file. If a file needs more than about 40 changed lines, stop and propose a split first.
- Do not reformat, re-indent, reorder imports, rename unrelated symbols or change line endings.
- Keep comments, blank lines and existing ordering exactly as they are.
- One logical change per prompt. List the files you intend to touch before editing.
- After editing, state how many lines you changed per file.
Save that as .cursor/rules/minimal-diff.mdc and commit it. The last bullet is the useful one for this project. When the agent states how many lines it changed per file, you can compare its claim with what editscope measures, and a mismatch between the two is itself a finding.
Treat the rule as a request, not a guarantee. Models follow it most of the time and ignore it some of the time, and the tracer exists to tell you which. The rule lowers the rate of rewrites. The gate stops the ones that still happen.
Build the Measuring Core (Steps 3 and 4)
Step 3: Write the helpers and the measuring function
Create tools/editscope.py and add the imports, the constants, three small helpers and the measure function. The helpers wrap Git calls, list the files worth watching and read a file as lines while skipping binaries and anything over 20,000 lines. Skipping the giants is deliberate, because difflib slows sharply on huge inputs and generated files are not what you are trying to audit.
#!/usr/bin/env python3
"""editscope: label every settled file save as patch, mixed, rewrite or whitespace."""
import argparse
import difflib
import fnmatch
import json
import os
import subprocess
import sys
import time
from collections import defaultdict
from pathlib import Path
LOG_DIR = ".editscope"
LOG_FILE = "events.jsonl"
MAX_LINES = 20000
def git(root, *args):
return subprocess.run(["git", *args], cwd=root, capture_output=True, check=True).stdout
def repo_files(root):
raw = git(root, "ls-files", "-co", "--exclude-standard", "-z").decode()
return [p for p in raw.split("\0") if p and not p.startswith(LOG_DIR + "/")]
def read_lines(path):
try:
data = path.read_bytes()
except OSError:
return None
if b"\0" in data[:8000] or data.count(b"\n") > MAX_LINES:
return None
return data.decode("utf-8", errors="replace").splitlines()
def measure(old, new):
norm = lambda ls: [" ".join(l.split()) for l in ls]
if old == new:
return None
if norm(old) == norm(new):
return {"kind": "whitespace", "rewrite_ratio": 0.0, "hunks": 0, "added": 0, "removed": 0}
ops = difflib.SequenceMatcher(None, old, new, autojunk=False).get_opcodes()
kept = sum(i2 - i1 for t, i1, i2, _, _ in ops if t == "equal")
removed = sum(i2 - i1 for t, i1, i2, _, _ in ops if t != "equal")
added = sum(j2 - j1 for t, _, _, j1, j2 in ops if t != "equal")
hunks = sum(1 for t, *_ in ops if t != "equal")
ratio = 1 - kept / max(len(old), 1)
if ratio <= 0.20 and hunks <= 6:
kind = "patch"
elif ratio >= 0.60:
kind = "rewrite"
else:
kind = "mixed"
return {"kind": kind, "rewrite_ratio": round(ratio, 3), "hunks": hunks, "added": added, "removed": removed}
Look at the order of checks inside measure. Identical files return nothing, so a save without changes never produces an event. The whitespace test runs before the expensive diff, which saves time on formatter-only saves. The survival ratio comes last, and the hunk count is a second opinion: five scattered one-line edits are still a patch, but twenty small hunks usually mean something regenerated the file around your code.
The five unit tests in Step 8 pin this behavior down. Run them whenever you touch a threshold, because a threshold change quietly changes every historical comparison you make afterward.
Step 4: Add the event log and the watch loop
The watcher polls file metadata instead of using operating-system file events. That choice costs a little CPU, but it behaves the same on Linux, macOS, Windows and WSL, and it needs no third-party package. Every 100 milliseconds it compares the modification time and size of each tracked file. When one changes, it waits 150 milliseconds and checks again, so a file that is mid-write does not get measured half finished. Only a settled save produces an event. Line-ending flips are the one change it cannot see, since splitlines hides them. The guide to gitattributes for Cursor diffs covers that gap, and the Git gate in Step 6 catches it from the other side.
def log(root, event):
d = Path(root) / LOG_DIR
d.mkdir(exist_ok=True)
event["t"] = round(time.time(), 3)
with open(d / LOG_FILE, "a", encoding="utf-8") as fh:
fh.write(json.dumps(event) + "\n")
def cmd_watch(args):
root = Path(args.root).resolve()
state = {}
last_scan = 0.0
names = []
primed = False
print(f"editscope watching {root} (poll {args.interval}s, settle {args.settle}s). Ctrl+C to stop.", flush=True)
while True:
if time.time() - last_scan > 2:
names = repo_files(root)
last_scan = time.time()
for name in names:
p = root / name
try:
st = p.stat()
except OSError:
continue
sig = (st.st_mtime_ns, st.st_size)
if name not in state:
lines = read_lines(p)
state[name] = (sig, lines)
if lines is not None and primed:
log(root, {"type": "write", "file": name, "kind": "create", "rewrite_ratio": 1.0,
"hunks": 1, "added": len(lines), "removed": 0, "lines": len(lines)})
print(f" create {name} ({len(lines)} lines)", flush=True)
continue
if state[name][0] == sig:
continue
time.sleep(args.settle)
st2 = p.stat()
if (st2.st_mtime_ns, st2.st_size) != sig:
continue
new = read_lines(p)
old = state[name][1]
state[name] = (sig, new)
if old is None or new is None:
continue
m = measure(old, new)
if m:
m.update({"type": "write", "file": name, "lines": len(new)})
log(root, m)
print(f" {m['kind']:<10} {name} ratio={m['rewrite_ratio']:.2f} hunks={m['hunks']} +{m['added']}/-{m['removed']}", flush=True)
primed = True
time.sleep(args.interval)
Two details are worth understanding. The file list refreshes every two seconds through git ls-files -co --exclude-standard, which means the watcher honors your .gitignore automatically and picks up new files without a restart. And the primed flag stops the first pass from announcing every existing file as new. After that first loop, any file that appears is logged as a create event with a ratio of 1.0.
Add Marks, Reports and the Git Gate (Steps 5 to 7)
Step 5: Add marks and the report command
A raw event stream tells you what changed but not why. Marks fix that. Before each prompt you type one short command that drops a labeled marker into the log, and the report groups every later event under the latest marker. Two seconds of typing per prompt buys you a per-prompt scorecard, which is the unit you actually care about.
def cmd_mark(args):
log(Path(args.root).resolve(), {"type": "mark", "label": args.label})
print(f"marked: {args.label}")
def cmd_report(args):
path = Path(args.root).resolve() / LOG_DIR / LOG_FILE
if not path.exists():
sys.exit("no events yet. Run 'watch' first.")
groups, label = defaultdict(list), "(before first mark)"
for line in path.read_text().splitlines():
e = json.loads(line)
if e["type"] == "mark":
label = e["label"]
else:
groups[label].append(e)
for label, events in groups.items():
kinds = defaultdict(int)
for e in events:
kinds[e["kind"]] += 1
worst = max(events, key=lambda e: e["rewrite_ratio"])
summary = ", ".join(f"{k}={v}" for k, v in sorted(kinds.items()))
print(f"\n[{label}]\n writes: {len(events)} ({summary})")
print(f" worst: {worst['file']} ratio={worst['rewrite_ratio']:.2f} kind={worst['kind']}")
The report prints, for each prompt, how many writes happened, how they split across labels and which file had the highest rewrite ratio. It is intentionally plain text. If you later want charts, the JSON lines file loads into any notebook or spreadsheet in one line.
Step 6: Add the Git gate
The watcher gives you live feedback. The gate gives you enforcement. It compares what you are about to commit against HEAD using git diff --numstat and applies three budgets. A file fails if more than 35% of its original lines were removed (for files of at least 30 lines), if it carries more than 150 changed lines, or if more than 20 of its changed lines are whitespace only. The whitespace figure comes from running the diff twice, once normally and once with -w, and subtracting. It is the same trick reviewers use by eye, automated. A related safety net, covered in the Cursor Fast Apply review guide, adds human checkpoints on top of this machine check.
def numstat(root, cached, ignore_ws):
cmd = ["diff", "--numstat", "--no-renames"]
if cached:
cmd.append("--cached")
else:
cmd.append("HEAD")
if ignore_ws:
cmd.insert(1, "-w")
out = {}
for line in git(root, *cmd).decode().splitlines():
a, d, name = line.split("\t", 2)
if a != "-":
out[name] = (int(a), int(d))
return out
def cmd_gate(args):
root = Path(args.root).resolve()
if os.environ.get("EDITSCOPE_SKIP") == "1":
print("editscope gate skipped (EDITSCOPE_SKIP=1)")
return
try:
git(root, "rev-parse", "--verify", "HEAD")
except subprocess.CalledProcessError:
print("editscope gate: no HEAD yet, nothing to compare")
return
raw, clean = numstat(root, args.cached, False), numstat(root, args.cached, True)
failures = []
for name, (added, removed) in raw.items():
if any(fnmatch.fnmatch(name, pat) or fnmatch.fnmatch(Path(name).name, pat) for pat in args.exclude):
continue
try:
old_lines = len(git(root, "cat-file", "-p", f"HEAD:{name}").decode(errors="replace").splitlines())
except subprocess.CalledProcessError:
continue
ca, cr = clean.get(name, (0, 0))
ratio = removed / old_lines if old_lines else 0
noise = (added + removed) - (ca + cr)
if old_lines >= args.min_lines and ratio > args.max_ratio:
failures.append(f"{name}: {removed}/{old_lines} original lines removed ({ratio:.0%} > {args.max_ratio:.0%})")
if added + removed > args.max_lines:
failures.append(f"{name}: {added + removed} changed lines (budget {args.max_lines})")
if noise > args.max_noise:
failures.append(f"{name}: {noise} whitespace-only changed lines (budget {args.max_noise})")
if failures:
print("editscope gate FAILED:")
for f in failures:
print(" -", f)
print("Split the change, revert the noise, or set EDITSCOPE_SKIP=1 once.")
sys.exit(1)
print(f"editscope gate ok ({len(raw)} file(s) checked)")
Lockfiles, minified bundles and snapshots change wholesale by design, so the gate skips them through --exclude patterns. Add your own generated files to that list the first time the gate trips on one. Setting EDITSCOPE_SKIP=1 bypasses the gate for a single commit when a big change is intentional, such as a planned migration. Use it sparingly and say why in the commit message.
Step 7: Wire up the command-line entry point
The last block adds the argument parser. Four subcommands share one --root option, and the gate exposes every budget as a flag so you can tune it per repository without editing code.
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--root", default=".")
sub = ap.add_subparsers(dest="cmd", required=True)
w = sub.add_parser("watch")
w.add_argument("--interval", type=float, default=0.1)
w.add_argument("--settle", type=float, default=0.15)
w.set_defaults(fn=cmd_watch)
m = sub.add_parser("mark")
m.add_argument("label")
m.set_defaults(fn=cmd_mark)
r = sub.add_parser("report")
r.set_defaults(fn=cmd_report)
g = sub.add_parser("gate")
g.add_argument("--cached", action="store_true")
g.add_argument("--max-ratio", type=float, default=0.35)
g.add_argument("--min-lines", type=int, default=30)
g.add_argument("--max-lines", type=int, default=150)
g.add_argument("--max-noise", type=int, default=20)
g.add_argument("--exclude", action="append", default=["*.lock", "package-lock.json", "*.min.js", "*.snap"])
g.set_defaults(fn=cmd_gate)
args = ap.parse_args()
try:
args.fn(args)
except KeyboardInterrupt:
print("\nstopped")
if __name__ == "__main__":
main()
Make the script executable with chmod +x tools/editscope.py if you like, then run python3 tools/editscope.py --help. You should see the four subcommands listed. The file now runs to 221 lines, which includes the exclude option and the flush calls that keep watcher output readable when you pipe it.
Test the Tool and Block Bad Commits (Steps 8 to 10)
Step 8: Add unit tests
Five tests cover the five outcomes of measure. They are cheap, they run in milliseconds and they protect you from the classic failure of tuning a threshold for one prompt and breaking it for everything else.
import sys
import unittest
sys.path.insert(0, "tools")
from editscope import measure
BASE = [f"line {i}" for i in range(50)]
class MeasureTests(unittest.TestCase):
def test_identical_returns_none(self):
self.assertIsNone(measure(BASE, list(BASE)))
def test_single_line_edit_is_patch(self):
new = list(BASE)
new[10] = "line ten"
self.assertEqual(measure(BASE, new)["kind"], "patch")
def test_regenerated_file_is_rewrite(self):
new = [f"row {i}" for i in range(50)]
self.assertEqual(measure(BASE, new)["kind"], "rewrite")
def test_reindent_is_whitespace(self):
new = [" " + line for line in BASE]
self.assertEqual(measure(BASE, new)["kind"], "whitespace")
def test_scattered_edits_are_mixed(self):
new = list(BASE)
for i in range(0, 50, 3):
new[i] = f"changed {i}"
self.assertEqual(measure(BASE, new)["kind"], "mixed")
if __name__ == "__main__":
unittest.main()
python3 -m unittest discover -s tests -v
Expect five lines ending in ok, then Ran 5 tests and OK. If the scattered-edits test fails after you change a threshold, that is the test doing its job. Decide whether the new boundary is what you want, then update the test to match.
Step 9: Install the pre-commit hook
A Git hook is an executable file in .git/hooks. The one you need is two lines long. It hands control to the gate and passes along the exit code, and Git aborts the commit whenever that code is non-zero.
cat > .git/hooks/pre-commit <<'EOF'
#!/bin/sh
exec python3 tools/editscope.py gate --cached
EOF
chmod +x .git/hooks/pre-commit
git add -A && git commit -m "add editscope, rule and tests"
The commit itself is the first test. The hook runs, finds only additions, and prints a line like editscope gate ok (3 file(s) checked). Hooks live inside .git and are not versioned, so teammates need to install their own. Step 13 covers a shared alternative.
Step 10: Dry run with a scripted agent
Before trusting the tool with a live model, check it against edits you control. The script below plays four roles in sequence. It makes a one-line price change, then a regenerated file with renames and two-space indentation, then a blank-line whitespace pass, then a new file. Run the watcher in one terminal and the script in another.
# terminal 1
python3 tools/editscope.py watch
# terminal 2 (stand-in for an agent)
sed -i 's/Decimal("3.50")/Decimal("3.75")/' app/billing.py
sleep 1
python3 - <<'PY'
s = open('app/billing.py').read()
for a, b in [('Price for tier', 'Compute the price for tier'),
('base =', 'base_price ='), ('base * units', 'base_price * units'),
('total =', 'amount ='), ('total.quantize', 'amount.quantize'),
(' ', ' ')]:
s = s.replace(a, b)
open('app/billing.py', 'w').write(s)
PY
sleep 1
sed -i 's/^$/ /' app/billing.py
sleep 1
printf 'def tax(x):\n return x * 1.2\n' > app/tax.py
On macOS, use sed -i '' with an empty backup suffix instead of sed -i. Here is what the watcher printed in my scripted run on Linux:
editscope watching /home/you/editscope (poll 0.1s, settle 0.15s). Ctrl+C to stop.
patch app/billing.py ratio=0.02 hunks=1 +1/-1
rewrite app/billing.py ratio=0.61 hunks=9 +33/-33
whitespace app/billing.py ratio=0.00 hunks=0 +0/-0
create app/tax.py (2 lines)
| Scripted action | Label | Rewrite ratio | Hunks | Lines added / removed |
|---|---|---|---|---|
| Change one price constant | patch | 0.02 | 1 | +1 / -1 |
| Rename variables and reindent | rewrite | 0.61 | 9 | +33 / -33 |
| Blank lines padded with a space | whitespace | 0.00 | 0 | +0 / -0 |
Add app/tax.py | create | 1.00 | 1 | +2 / -0 |
Now stage the rewrite and try to commit it. In my run the hook blocked the commit with this message:
editscope gate FAILED:
- app/billing.py: 33/54 original lines removed (61% > 35%)
Split the change, revert the noise, or set EDITSCOPE_SKIP=1 once.
Notice that the watcher and the gate agree. Both flagged the same save from different angles, one live and one at commit time. When the two disagree in real use, trust the gate, since it measures the actual Git diff that reviewers will see.
Run Real Cursor Prompts and Compare Models (Steps 11 to 13)
Step 11: Run a prompt that should produce a patch
Now point the real editor at the repository. Open the folder in Cursor, start the watcher in an integrated terminal, and drop a marker. Then give the agent a narrow task with a named file and a named function. If you have not configured the agent yet, the Cursor Composer diff versus rewrite setup guide walks through the editor-side options.
python3 tools/editscope.py watch &
python3 tools/editscope.py mark "P1 sonnet-5.5: raise tier_3 base to 3.75"
A prompt such as the following works well. It names the file, the function and the exact change, and it repeats the constraint from the rule file because repetition at the point of the request carries more weight than a rule in the background.
In app/billing.py, change only the base price inside tier_3_price from 3.50 to 3.75.
Do not touch any other line. Tell me how many lines you changed.
Accept the edit, save, and wait a second. You should see a patch line with a ratio near 0.02. If you see a rewrite here, the model regenerated the file for a one-line task, and that result is the most useful one you will collect today.
Step 12: Run a prompt that tempts a rewrite
Next, ask for something that invites broad changes across the repeated functions. Cross-cutting requests are where rewrites show up most. Drop a fresh marker so the report keeps the two prompts apart.
python3 tools/editscope.py mark "P2 sonnet-5.5: add docstring examples to every tier function"
Add a one-line usage example to the docstring of each tier_N_price function in
app/billing.py. Keep every other line exactly as it is.
Eight functions need touching, so a patch here may legitimately report eight hunks and land in the mixed band. That is fine. What you are watching for is the ratio. Eight docstring lines out of 54 is about 15%, so a healthy edit stays near that figure. A ratio above 0.60 means the agent regenerated the file to add eight lines, and the gate will stop the commit.
Run the report after both prompts:
python3 tools/editscope.py report
The report prints one block per marker with the write count, the split by label and the worst offender. Keep the log. Next week’s prompt is more informative when you can set it beside this week’s.
Read the results with some skepticism in both directions. A clean patch on a one-line task proves little, because almost every model manages it. The interesting data comes from the broad request, where the model has to choose between editing eight places and regenerating the file. Also watch the second and third events after a prompt. Some agents patch first, then run a follow-up pass that fixes a lint warning or a failing test, and the follow-up occasionally rewrites more than the original edit did.
Step 13: Compare models and tune the thresholds
The real payoff comes from repeating the same two prompts across models. Cursor’s own release notes list Claude Opus 5.5, Claude Sonnet 5.5, Grok 4.7 and Claude Haiku 5.5 among the models you can pick, with Haiku 5.5 as an opt-in model you switch on under Cursor Settings and Models. Run each prompt on a clean checkout, record the result and reset with git checkout -- app/billing.py between runs.
Billing is part of the picture. On The X Subscription, third-party models such as Claude, GPT and Gemini are not covered by the subscription allowance and are charged at API list prices from the first request, per the same ScriptByAI timeline. A model that rewrites a file also tends to spend more output tokens than one that patches it, so rewrite-prone models can cost you twice, once in review time and once in spend. Use the table below as a log sheet and fill in your own measurements.
| Model in picker | Prompt | Writes | Label | Rewrite ratio | Gate verdict |
|---|---|---|---|---|---|
| Claude Opus 5.5 | P1 one-line price change | your run | your run | your run | your run |
| Claude Sonnet 5.5 | P1 one-line price change | your run | your run | your run | your run |
| Claude Haiku 5.5 (opt-in) | P1 one-line price change | your run | your run | your run | your run |
| Grok 4.7 | P2 docstrings on 8 functions | your run | your run | your run | your run |
| Auto | P2 docstrings on 8 functions | your run | your run | your run | your run |
Run each cell at least three times. Model behavior varies from run to run, and a single sample tells you very little. When you have a dozen results, set the gate budgets just above the worst legitimate patch you recorded. That calibrates the tool to your codebase instead of to my demo file.
Finally, share the gate with the team. Git can point every clone at a tracked hooks folder with git config core.hooksPath tools/hooks. Move the two-line hook there, commit it, and each developer runs that one config command once.
Common Pitfalls When Tracing AI Edits
- Mixing your own edits with the agent’s. The watcher cannot tell who typed. If you edit the same file while a prompt runs, your changes inflate the numbers. Keep your hands off the file until the marker’s work is done.
- Letting format-on-save pollute results. A formatter runs after the agent writes, so one prompt produces two events, a patch and then a whitespace pass. That is correct behavior, but read the two events together before blaming the model.
- Treating every rewrite label as a defect. New files, tiny files and genuine refactors will all score high. The label is a prompt to look, not a verdict. The gate’s 30-line minimum exists for this reason.
- Tuning thresholds on a single sample. One lucky patch and one unlucky rewrite tell you nothing about a model. Collect at least a dozen runs before moving a budget.
- Forgetting that CRLF flips are invisible to the watcher. Python’s
splitlinestreats both line endings alike, so a whole-file line-ending change produces no event. The Git gate sees it as a full-file diff, which is why you run both tools. - Running the gate without
--cachedin the hook. Without it, the gate compares your working tree, not the staged commit, and a clean index can fail because of unrelated unstaged edits. - Letting the log grow forever. The file is small, but a month of heavy use still adds up. Rotate it with a monthly rename, and keep the old files for trend comparisons.
- Trusting the model’s own line count. The rule asks the agent to report changed lines. Treat that number as a claim to verify against the tracer, not as data.
If you want a deeper post-hoc check on top of the live tracer, the Cursor Composer rewrite audit tool reviews finished changes file by file and pairs naturally with this project.
Troubleshooting
These are the problems readers hit most often, with the fastest fix for each.
- The report says no events yet. The watcher never ran, or it ran from a different folder. Start it from the repository root, or pass
--rootbefore the subcommand, as inpython3 tools/editscope.py --root /path/to/repo watch. - The watcher prints nothing when I edit a file. The file is probably ignored by Git. The watcher only tracks what
git ls-files -co --exclude-standardreturns, so checkgit check-ignore -v path/to/file. - New files take a couple of seconds to appear. The file list refreshes every two seconds. Wait briefly after creating a file, or lower the refresh interval in
cmd_watch. - Fatal: not a git repository. Run
git initin the project root. Both the watcher and the gate depend on Git to list and compare files. - The gate prints no HEAD yet. You have not made the first commit. Commit a baseline, then the gate has something to compare against.
- The hook never runs. Check that
.git/hooks/pre-commitis executable and has no extension. If you use Husky or setcore.hooksPath, Git ignores the default hooks folder, so put the script in the configured path instead. - The gate fails on a lockfile or a generated file. Add a pattern with
--excludein the hook line, for example--exclude '*.generated.ts'. Passing the option appends to the defaults. - Every save of a large file shows as a rewrite. The watcher may be reading the file while the editor is still writing it. Raise
--settleto 0.4 or higher and test again. - CPU usage is high on a big monorepo. Polling thousands of files every 100 milliseconds adds up. Use
--interval 0.5, or run the watcher from a sub-folder with--root. - python3 is not found on Windows. Git Bash often exposes the interpreter as
python. Change the hook to callpython, or create an alias. - The gate fails on a file that changed only a little. Check the whitespace budget. A formatter run bundled into the same commit can trip the 20-line noise limit. Commit formatting separately.
- No event appears until I accept the edit. Correct. Unsaved buffer content is not on disk. Save the file or accept the change and the event follows within a second.
Advanced Tips
Per-directory budgets. A config folder tolerates bigger churn than a payments module. Call the gate several times from the hook with different --root values or add a path prefix filter. The same approach works for stricter limits on code you consider fragile. For a deeper treatment of building your own edit-application layer, read the walkthrough on building a fast-apply patch engine.
Run the gate in CI. The hook runs on a developer machine, so a determined skip is easy. Add a pipeline step that checks out the pull request with enough history and calls the gate against the merge base. Replace HEAD in the numstat call and in the cat-file call with the base branch ref, and the same code enforces the budget on the server.
Log the prompt text, not just a label. Extend mark to read from standard input, then pipe the prompt in. Months later you can sort events by prompt wording and learn which phrasing produces patches and which produces rewrites.
Trace other agents the same way. The tracer watches files, not an editor, so it works unchanged with other tools. GitHub’s changelog shows Copilot gaining Claude Haiku 5.5 on October 7, 2026 and Gemini 4 Argon later that month, and you can see the October entries on the GitHub changelog. Claude Code runs in the terminal and in IDEs, and Grok Build is available in beta, according to the ScriptByAI timeline, with an interactive terminal interface and headless runs for scripts. Run the watcher beside each one and fill the same comparison table.
Add a velocity guard. Count events per marker and flag any prompt that produces more than, say, ten writes to the same file. Agents that loop through repeated fix attempts leave a distinctive trail of small writes, and it is a good early signal that a prompt was too broad. For wider context on Cursor workflows, browse the software coverage on the site.
Tune the ratio per language. Python and Go files tend to be compact, so a ratio of 0.20 already covers a sizable edit. Generated TypeScript with long import blocks or JSON with one key per line behaves differently, since a single schema change can move dozens of lines legitimately. Keep two or three threshold profiles, store them as shell aliases that call the gate with different flags, and pick the profile by folder.
Pair the numbers with a human habit. Make the report part of the pull request template. A reviewer who sees that a change was three patches and no rewrites can skim with confidence, and a reviewer who sees a rewrite flag knows exactly where to slow down.
Snapshot before risky prompts. Make a throwaway commit or stash before a prompt that touches many files. The tracer tells you what went wrong, and Git lets you undo it in one command.
The Complete Project at a Glance
Here is the finished layout and the commands you will use every day. Everything lives in four small places, and nothing needs installing.
editscope/
.cursor/rules/minimal-diff.mdc # the policy Cursor reads
.gitignore # ignores .editscope/ and __pycache__/
.git/hooks/pre-commit # calls the gate on staged changes
app/billing.py # the demo file you edit
tools/editscope.py # watch, mark, report, gate (221 lines)
tests/test_editscope.py # five tests for measure()
.editscope/events.jsonl # created by the watcher
# daily loop
python3 tools/editscope.py watch & # start once per session
python3 tools/editscope.py mark "P3 model-x: add logging" # before each prompt
# ...run the prompt in Cursor, accept, save...
python3 tools/editscope.py report # per-prompt scorecard
git add -A && git commit -m "..." # gate runs automatically
# one-off overrides
EDITSCOPE_SKIP=1 git commit -m "planned migration"
python3 tools/editscope.py gate --max-ratio 0.5 --max-lines 300
A short checklist makes the habit stick. Start the watcher when you open the project. Mark before every prompt, even trivial ones. Read the report before you commit. Treat any rewrite on a file over 30 lines as a reason to look at the diff twice, and tighten the rule file when the same pattern repeats.
The idea reaches beyond Cursor. Once you can put a number on how much of a file an agent touched, arguments about whether an AI editor is too aggressive turn into data you can compare across models, prompts and weeks. That is the real output of this project, and it costs you about 45 minutes and 221 lines.
FAQ
Does Cursor have a built-in setting that forces minimal diffs?
I found none in the current release notes and docs. Cursor’s rules let you request small edits, and the agent usually complies, but nothing in the documentation I reviewed guarantees it. That is why this tutorial pairs a rule with measurement and a commit-time gate.
Will editscope slow down the Cursor AI code editor?
No. It runs as a separate process and only reads file metadata a few times per second. On a repository with a few thousand tracked files, raise the polling interval to 0.5 seconds and the load stays negligible.
Why use a rewrite ratio instead of counting changed lines?
Raw line counts punish large files unfairly. Forty changed lines in a 2,000-line file is a tiny edit, while the same forty lines in a 50-line file is a rewrite. The ratio scales with file size, so one threshold works across both.
Can I run this with VS Code, Copilot or Claude Code instead of Cursor?
Yes. The tool watches the file system, so it does not care which program writes the files. Start the watcher, mark each prompt, and the report works the same way for any agent.
What if a legitimate refactor trips the gate?
Set EDITSCOPE_SKIP=1 for that one commit, or raise the budget flags for the run. Better still, split the refactor into smaller commits, which also makes the review easier for your teammates.
Do the thresholds of 0.20 and 0.60 apply to every language?
They are reasonable defaults for most code, but verbose languages and files with long import lists behave differently. Run a dozen prompts on your own codebase, look at where genuine patches land, and set the boundaries accordingly.
Is the dry-run output real?
Yes. The sample output in Step 10 came from running the exact script in this article against the demo repository, using a scripted stand-in for an agent. The model comparison table is blank on purpose, because those numbers depend on your prompts, your files and the model versions you select.





