One Cursor Agent turn can touch a dozen files, and nothing promises it will change them as small patches. Cursor’s release notes now give you a Last Agent Turn scope in the Changes tab, a Restore Checkpoint control in chat, and Undo and Redo that follow whichever surface has focus. Those tools help while you review. They leave you without a durable, scriptable record of what each turn did to your repository. This tutorial builds that record in 13 steps and about 50 minutes, using only Git and the Python standard library. If you already measure how often Cursor rewrites instead of patching, this is the follow-up: acting on what you measure.
Cursor now sells itself as a coding agent whose agents run in parallel and hand back changes for review, according to a November 2026 review from TestMu AI. Review is the operative word. Reading a diff and reversing part of it are two separate jobs, and the second one gets hard when a turn rewrites whole files, edits a file you were also typing in, or runs a terminal command that changes things no editor tracks.
The tool we build is called turnguard. It snapshots your working tree before and after every agent turn, stores both snapshots as hidden Git refs, and lets you list, inspect, undo, redo, or hand-pick individual hunks from any turn. It never touches your branch, your index, or your stash. We ran it against a scripted stand-in for an agent turn (two small edits, one full-file rewrite with CRLF line endings, and one new file). The Cursor interface steps follow Cursor’s release notes as published in early November 2026, so check menu names against your own build.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
What Cursor’s Review Tools Can Do in November 2026
Start with what the editor already gives you, because turnguard should fill gaps instead of duplicating features. Cursor’s release notes, updated in early November 2026, describe four review and undo behaviors that matter for this tutorial.
- Last Agent Turn scope. The Changes tab can show only the edits from the agent’s latest turn. Clicking Review or a file in the end-of-turn summary opens the tab in that scope, so you skip earlier changes.
- Context-aware Undo and Redo. Edit, then Undo and Redo from the menu bar now apply to the prompt, editor, terminal, or browser that had focus before you opened the menu. They only undo window actions such as archiving an agent when nothing editable has focus.
- Restore Checkpoint in chat. The notes mention the control in a fix for screen readers, which confirms it exists in current builds. Cursor’s notes do not spell out exactly what it restores, so Step 13 tests that directly.
- Persistent Changes tab in the Agents Window. The tab keeps its scope, branch, and picked commits when you hide the panel, switch agents, or restart.
The review granularity differs between the two windows. A Cursor forum answer from October 12, 2026, summarized in a Voyager comparison of Cursor and Claude Code, says per-change Keep and Undo remain available in the Editor window. The Agents Window shows a Git-style diff with file-level discard instead of per-change Keep and Undo. In practice, that means a long agent run in the Agents Window gives you a coarse lever (discard a whole file) and nothing finer.
Pull request review got a smaller upgrade. With a pull request open on the Review screen, changed files and line counts refresh within about 30 seconds of a new commit, with no manual pull. That helps teams, but it only sees commits. Anything the agent changed and you never committed is invisible to it.
| Tool | Granularity | Where it lives | Gap turnguard fills |
|---|---|---|---|
| Editor window Keep and Undo | Per change | Cursor editor UI | No record after you accept |
| Agents Window file discard | Per file | Cursor Agents Window | No per-hunk choice |
| Last Agent Turn scope | View only, latest turn | Changes tab | Older turns drop out of view |
| Restore Checkpoint | Chat-level control | Chat panel | Scope for terminal side effects is untested |
| Context-aware Undo and Redo | Focused surface | Menu bar | Depends on which pane had focus |
| turnguard | Turn, file, or hunk | Hidden Git refs | Needs a terminal |
Why Turn-Level Rollback Still Matters
Cursor’s public documentation and release notes do not say whether its agent applies a given edit as a targeted patch or as a full-file rewrite, and the research behind this article found no official source that substantiates either architecture for current builds. That gap is why measuring beats guessing. You can break-even cost model for diffs and rewrites once, or you can record every turn and let the numbers accumulate. A turn log shows you the pattern across your own codebase and your own model choices.
Model choice changes the pattern. Cursor’s own Composer 2.5, released May 18, 2026 and priced at $0.50 per million input tokens and $2.50 per million output tokens in standard mode, is built to keep pace with interactive sessions while it handles file edits, terminal commands, and multi-file changes. The fast variant costs $3 and $15 per million tokens. Cursor’s October 1 release notes also added Claude Sonnet 5.5 and GLM 5.3 to the picker. Cheap, fast models invite more turns per hour, and more turns mean more chances for one bad rewrite to hide inside a pile of good edits.
Three failure modes show up repeatedly in Cursor Agent workflows:
- The buried rewrite. A turn changes 14 files correctly and quietly rewrites a 22-line helper. Per-change review catches it only if you read every hunk.
- The unrecorded side effect. The agent runs a formatter or a code generator in the terminal. Editor-level undo does not describe that work.
- The tangled edit. You fix a typo in a file while the agent is mid-turn. Now neither undo path is clean.
Git already stores everything needed to fix all three. It just needs a hook point at the start and end of each turn. A disposable test repository inside your repo gives you the safe place to practice before trusting the tool on real work. For the underlying idea of why a patch engine is faster than a rewrite, see our walkthrough of a fast-apply patch engine.
Prerequisites and Versions
You need three things on the machine, and none of them costs money beyond your existing Cursor plan.
| Requirement | Version | Why you need it |
|---|---|---|
| Git | 2.43.0 (tested), any recent release | Snapshots use a temporary index, write-tree, commit-tree, and apply |
| Python | 3.10 or newer | turnguard is one file with no third-party packages |
| Cursor | Any 3.x build with Agent access | Provides the agent turn, Changes tab, and chat controls |
| Terminal | bash, zsh, or Git Bash on Windows | Cursor’s integrated terminal works |
| Disk | Under 5 MB for the demo | Snapshots share objects with your repo |
Plan names changed in October 2026. Cursor now sells the X Subscription tiers Free at $0, Plus at $8, Premium at $30, Super at $100, and Ultra at $200 per month, according to the AI Agents Watch pricing tracker, and Agent access varies by tier. Nothing in this tutorial depends on a particular tier. Any plan that lets you start an agent turn works.
The tutorial uses unittest instead of pytest, so a fresh machine needs no pip installs. Everything runs from the repository root. Windows users should run the commands in Git Bash and swap python3 for py -3 if the first name is not found.
What You Will Build: A Turn Ledger Inside Git
turnguard works by treating each agent turn as a pair of snapshots. Before a turn it writes your whole working tree (tracked files, untracked files, minus anything in .gitignore) into a Git tree object. It then wraps that tree in a commit whose parent is your current HEAD and stores the commit under refs/turnguard/0001-before. After the turn it repeats the process and stores 0001-after. Everything else is arithmetic on those two commits.
The trick that keeps your repository clean is the temporary index. Setting GIT_INDEX_FILE to a throwaway path lets git add -A and git write-tree run without touching the real index, so staged changes stay staged and nothing shows up in git status. The refs keep the objects alive, which means git gc will not collect them until you prune.
| Command | What it does |
|---|---|
| begin [label] | Opens a turn and stores the before snapshot |
| end | Closes the open turn and stores the after snapshot |
| abort | Discards an open turn |
| status [–fail-if-open] | Shows edits since begin, optionally fails for a hook |
| list | One row per turn with files, additions, deletions, and a REWRITE flag |
| show N [–stat] | Prints the patience diff for a turn |
| undo N [–file F] [–pick] [–force] [–dry-run] | Reverse-applies a turn, a file, or chosen hunks |
| redo N | Re-applies a turn you undid |
| prune [–keep K] | Deletes the oldest turn refs |
Because turnguard records disk state, it also pairs well with measurement work. Once you have a ledger, you can compare models on your own code with the benchmarked Cursor CLI edits and see which one produces the smallest turns.
The finished project has this layout:
invoice-lib/
invoices/
__init__.py
pricing.py
formatting.py
tests/
__init__.py
test_pricing.py
test_formatting.py
test_turnguard.py
tools/
turnguard.py (the tool, 329 lines)
fake_agent.py (stand-in for an agent turn)
README.md
.gitignore
Steps 1 to 3: Prepare the Demo Repository
Step 1: Confirm your tools
Open a terminal in an empty folder and check the three versions. If any command fails, install that tool before continuing.
git --version
python3 --version
mkdir invoice-lib && cd invoice-lib
git init -b main
git rev-parse --is-inside-work-tree
A healthy machine prints the Git and Python versions, then true on the last line. The tested setup printed git version 2.43.0 and Python 3.12.3.
Step 2: Create the library files
The demo library is small on purpose. Two modules give us one file the agent will edit in two places and one file it will rewrite. Create the package folders, then save these two files.
mkdir -p invoices tests tools
touch invoices/__init__.py tests/__init__.py
# invoices/pricing.py
"""Pricing helpers for the invoice-lib demo."""
TAX_RATE = 0.0825
def subtotal(items):
"""Sum quantity * unit price for each line item."""
total = 0
for item in items:
total += item["qty"] * item["unit_price"]
return total
def discount(amount, percent):
"""Return the discount for an amount at a given percentage."""
if percent < 0 or percent > 100:
raise ValueError("percent must be between 0 and 100")
return amount * percent / 100
def tax(amount):
"""Return sales tax for an amount."""
return amount * TAX_RATE
def shipping(weight_kg):
"""Flat shipping bands by weight."""
if weight_kg <= 1:
return 4.5
if weight_kg <= 5:
return 9.0
return 15.0
def total(items, percent=0, weight_kg=0):
"""Full invoice total: subtotal, minus discount, plus tax and shipping."""
base = subtotal(items)
base -= discount(base, percent)
return base + tax(base) + shipping(weight_kg)
# invoices/formatting.py
"""Formatting helpers for the invoice-lib demo."""
def format_money(value):
return "${:,.2f}".format(value)
def render_line(item):
amount = item["qty"] * item["unit_price"]
return "{name:<20} {qty:>3} x {price:>8} = {amount:>10}".format(
name=item["name"],
qty=item["qty"],
price=format_money(item["unit_price"]),
amount=format_money(amount),
)
def render_invoice(items, grand_total):
lines = [render_line(item) for item in items]
lines.append("-" * 50)
lines.append("{:<38} {:>10}".format("TOTAL", format_money(grand_total)))
return "\n".join(lines)
Step 3: Add tests and commit a baseline
Tests are the early-warning system. The stand-in agent in Step 11 will break two of them, which proves the rollback restores working code and not just matching bytes.
# tests/test_pricing.py
import unittest
from invoices import pricing
class PricingTests(unittest.TestCase):
def test_subtotal(self):
items = [{"qty": 2, "unit_price": 10.0}, {"qty": 1, "unit_price": 5.5}]
self.assertAlmostEqual(pricing.subtotal(items), 25.5)
def test_discount_bounds(self):
with self.assertRaises(ValueError):
pricing.discount(100, 120)
def test_total_with_shipping(self):
items = [{"qty": 1, "unit_price": 100.0}]
self.assertAlmostEqual(pricing.total(items, 10, 2), 106.425, places=3)
if __name__ == "__main__":
unittest.main()
# tests/test_formatting.py
import unittest
from invoices import formatting
class FormattingTests(unittest.TestCase):
def test_format_money(self):
self.assertEqual(formatting.format_money(1234.5), "$1,234.50")
def test_render_invoice_has_total(self):
items = [{"name": "Widget", "qty": 2, "unit_price": 3.0}]
out = formatting.render_invoice(items, 6.0)
self.assertIn("TOTAL", out)
self.assertIn("$6.00", out)
if __name__ == "__main__":
unittest.main()
printf '# invoice-lib\n\nTiny invoice math and formatting helpers used to test AI edit rollback.\n' > README.md
printf '__pycache__/\n*.pyc\n' > .gitignore
python3 -m unittest discover -s tests -t .
git add -A
git commit -m "Baseline invoice-lib"
The test run should end with Ran 5 tests and OK. Note the .gitignore line for __pycache__/. Anything ignored never enters a snapshot, a point that returns in the pitfalls section.
Steps 4 to 6: Build the Snapshot Engine and Turn Lifecycle
Step 4: Write the helpers and the snapshot function
Create tools/turnguard.py. The first block holds the Git wrapper, the snapshot functions, and the ref bookkeeping. Every Git call goes through run, which works in bytes. That detail matters: text mode would translate CRLF line endings and corrupt patches for files that use them.
#!/usr/bin/env python3
"""turnguard: per-turn git snapshots so any AI agent turn can be rolled back."""
import argparse
import os
import re
import subprocess
import sys
import tempfile
NS = "refs/turnguard"
IDENT = {
"GIT_AUTHOR_NAME": "turnguard",
"GIT_AUTHOR_EMAIL": "turnguard@localhost",
"GIT_COMMITTER_NAME": "turnguard",
"GIT_COMMITTER_EMAIL": "turnguard@localhost",
}
def run(args, env=None, cwd=None, data=None, ok=(0,)):
full = os.environ.copy()
full.update(env or {})
p = subprocess.run(["git", *args], capture_output=True, env=full, cwd=cwd, input=data)
if p.returncode not in ok:
err = p.stderr.decode(errors="replace").strip()
sys.exit("git %s failed:\n%s" % (" ".join(args), err))
return p
def out(args, **kw):
return run(args, **kw).stdout.decode(errors="surrogateescape").strip()
def top():
return out(["rev-parse", "--show-toplevel"])
def snapshot_tree():
"""Write the working tree (tracked + untracked, minus ignored) as a tree object."""
root = top()
with tempfile.TemporaryDirectory() as td:
env = {"GIT_INDEX_FILE": os.path.join(td, "index")}
run(["add", "-A", "."], env=env, cwd=root)
return out(["write-tree"], env=env, cwd=root)
def snapshot_commit(message):
tree = snapshot_tree()
head = out(["rev-parse", "--verify", "HEAD"])
return out(["commit-tree", tree, "-p", head, "-m", message], env=IDENT)
def refs():
names = out(["for-each-ref", "--format=%(refname)", NS + "/"]).splitlines()
return sorted(n for n in names if n)
def turn_numbers():
found = set()
for name in refs():
m = re.match(NS + r"/(\d{4})-(before|after)$", name)
if m:
found.add(int(m.group(1)))
return sorted(found)
def ref(n, kind):
return "%s/%04d-%s" % (NS, n, kind)
def has_ref(name):
return run(["rev-parse", "--verify", "-q", name], ok=(0, 1)).returncode == 0
def open_turn():
for n in turn_numbers():
if has_ref(ref(n, "before")) and not has_ref(ref(n, "after")):
return n
return None
Read snapshot_tree closely, because the rest of the tool rests on it. It points GIT_INDEX_FILE at a file inside a temporary directory, adds everything non-ignored, writes the tree, and lets the directory vanish. snapshot_commit then wraps the tree in a commit with a fixed author identity, so the tool works even on a machine where user.name is not set. We avoided git stash create on purpose. It skips untracked files by default, and new files are exactly what agents create.
Step 5: Add begin, end, abort, and status
The lifecycle commands are thin. begin refuses to start while another turn is open, which prevents the most common bookkeeping mistake. end prints a one-line summary straight from git diff --shortstat, so you see the blast radius the moment the turn closes.
def cmd_begin(args):
n = open_turn()
if n is not None:
sys.exit("turn %d is still open. Run 'end' or 'abort' first." % n)
nums = turn_numbers()
n = (nums[-1] + 1) if nums else 1
sha = snapshot_commit("before: " + (args.label or "turn %d" % n))
run(["update-ref", ref(n, "before"), sha])
print("turn %d open (before = %s)" % (n, sha[:10]))
def cmd_end(args):
n = open_turn()
if n is None:
sys.exit("no open turn. Run 'begin' first.")
sha = snapshot_commit("after: turn %d" % n)
run(["update-ref", ref(n, "after"), sha])
stat = out(["diff", "--shortstat", ref(n, "before"), sha])
print("turn %d closed: %s" % (n, stat or "no changes"))
def cmd_abort(args):
n = open_turn()
if n is None:
sys.exit("no open turn.")
run(["update-ref", "-d", ref(n, "before")])
print("turn %d discarded" % n)
def cmd_status(args):
n = open_turn()
if n is None:
print("no open turn")
return
tree = snapshot_tree()
stat = out(["diff", "--stat", ref(n, "before"), tree])
print("turn %d is open" % n)
print(stat or " (no edits yet)")
if args.fail_if_open:
sys.exit(1)
status does double duty. Run mid-turn, it takes a throwaway snapshot and diffs it against the before commit, which answers a question Cursor’s UI cannot: what has already hit the disk? With --fail-if-open it exits with code 1 while a turn is open, a hook we use in Step 13.
Step 6: Add list and show, with a rewrite flag
list turns the ledger into a table. For each closed turn it sums additions and deletions from git diff --numstat. It also flags any file that was large enough to matter before the turn and lost a big share of its lines. That heuristic is deliberately blunt. A file that loses most of its lines in one turn deserves a human look whether the cause was a rewrite or a legitimate cleanup. If you want a sharper detector, a structural diff tool is the next upgrade, since line diffs sometimes hide what really moved.
def numstat(a, b):
rows = []
for line in out(["diff", "--numstat", a, b]).splitlines():
add, dele, path = line.split("\t", 2)
rows.append((path, 0 if add == "-" else int(add), 0 if dele == "-" else int(dele)))
return rows
def line_count(commit, path):
p = run(["cat-file", "blob", "%s:%s" % (commit, path)], ok=(0, 128))
return p.stdout.count(b"\n") if p.returncode == 0 else 0
def rewrite_flag(before, rows):
flagged = []
for path, add, dele in rows:
size = line_count(before, path)
if size >= 10 and dele / size >= 0.6:
flagged.append(path)
return flagged
def label_of(n):
msg = out(["log", "-1", "--format=%s", ref(n, "before")])
return msg.replace("before: ", "", 1)
def cmd_list(args):
nums = turn_numbers()
if not nums:
print("no turns recorded")
return
print("%-5s %-6s %-6s %-6s %s" % ("turn", "files", "+", "-", "label"))
for n in nums:
if not has_ref(ref(n, "after")):
print("%-5d %-6s %-6s %-6s %s (open)" % (n, "-", "-", "-", label_of(n)))
continue
rows = numstat(ref(n, "before"), ref(n, "after"))
add = sum(r[1] for r in rows)
dele = sum(r[2] for r in rows)
flag = rewrite_flag(ref(n, "before"), rows)
note = " REWRITE: " + ", ".join(flag) if flag else ""
print("%-5d %-6d %-6d %-6d %s%s" % (n, len(rows), add, dele, label_of(n), note))
def cmd_show(args):
n = args.turn
a, b = ref(n, "before"), ref(n, "after")
if not has_ref(b):
sys.exit("turn %d has no 'after' snapshot" % n)
flags = ["--patience", "--stat"] if args.stat else ["--patience"]
sys.stdout.write(out(["diff", *flags, a, b]) + "\n")
show uses --patience, which tends to produce readable hunks when a model reorders or reformats code. Our comparison of git diff algorithms covers when that choice matters.
Steps 7 to 9: Patches, the Drift Guard, and Hunk-Level Undo
Step 7: Generate the patch and add the drift guard
To undo a turn, turnguard builds a patch from the before and after commits and applies it in reverse. The risky part is the moment between those two actions. If you edited a file after the turn ended, reversing the patch could erase your work or leave conflict markers. The drift guard snapshots the tree again and compares it with the expected state: the after commit for an undo, the before commit for a redo.
def make_patch(n, paths):
a, b = ref(n, "before"), ref(n, "after")
cmd = ["diff", "--binary", "--patience", a, b]
if paths:
cmd += ["--", *paths]
return run(cmd).stdout
def drifted(n, paths, reverse):
"""Files changed since the snapshot we are about to roll back from."""
expected = ref(n, "after") if reverse else ref(n, "before")
now = snapshot_tree()
cmd = ["diff", "--name-only", expected, now]
if paths:
cmd += ["--", *paths]
return [p for p in out(cmd).splitlines() if p]
The --binary flag lets patches carry images and other binary files. When the guard finds a changed file, the command stops and names it. That refusal is the single most useful line in the tool.
Step 8: Split patches into hunks and prompt for each
Git’s own git add -p does interactive hunk selection for staging, but it cannot reverse a past turn. So turnguard splits the patch itself. It keeps each file header, breaks the body at every @@ line, asks about each hunk, and rebuilds a patch from the hunks you approve. Binary or mode-only changes have no hunks, so the tool asks about the whole file instead.
def split_hunks(patch):
"""Split a unified diff (bytes) into [(file_header, [hunk, ...]), ...]."""
lines = [l + b"\n" for l in patch.split(b"\n")]
if lines and lines[-1] == b"\n":
lines.pop()
files, cur = [], None
for line in lines:
if line.startswith(b"diff --git "):
cur = {"head": [line], "hunks": []}
files.append(cur)
elif cur is None:
continue
elif line.startswith(b"@@ "):
cur["hunks"].append([line])
elif cur["hunks"]:
cur["hunks"][-1].append(line)
else:
cur["head"].append(line)
return files
def pick_hunks(patch):
chosen, quit_now = [], False
for f in split_hunks(patch):
if quit_now:
break
name = f["head"][0].decode(errors="replace").strip()
if not f["hunks"]:
print("\n%s (no text hunks, whole-file change)" % name)
if input("undo this file change? [y/n] ").strip().lower() == "y":
chosen.append(b"".join(f["head"]))
continue
keep = []
for i, h in enumerate(f["hunks"], 1):
print("\n%s hunk %d/%d" % (name, i, len(f["hunks"])))
sys.stdout.write(b"".join(h).decode(errors="replace"))
ans = input("undo this hunk? [y/n/q] ").strip().lower()
if ans == "q":
quit_now = True
break
if ans == "y":
keep.append(b"".join(h))
if keep:
chosen.append(b"".join(f["head"]) + b"".join(keep))
return b"".join(chosen)
Step 9: Apply the patch, with undo and redo
replay is the engine behind both directions. It checks the drift guard, optionally runs the picker, then calls git apply --check before changing anything. A failed check aborts with the working tree untouched. The --force path swaps the check for --reject, which applies what it can and writes the rest to .rej files for hand repair.
def replay(args, reverse):
n = args.turn
if not has_ref(ref(n, "after")):
sys.exit("turn %d has no 'after' snapshot" % n)
paths = args.file or []
patch = make_patch(n, paths)
if not patch.strip():
sys.exit("nothing to %s for turn %d" % ("undo" if reverse else "redo", n))
drift = drifted(n, paths, reverse)
if drift and not args.force:
print("refusing: these files changed after turn %d:" % n)
for p in drift:
print(" " + p)
sys.exit("re-run with --force to use --reject (leaves .rej files), or undo by hand.")
if args.pick:
patch = pick_hunks(patch)
if not patch:
sys.exit("no hunks selected")
flags = ["apply", "--whitespace=nowarn"] + (["-R"] if reverse else [])
root = top()
if not args.force:
run(flags + ["--check"], data=patch, cwd=root)
if args.dry_run:
print("dry run ok: patch applies cleanly")
return
p = run(flags + (["--reject"] if args.force else []), data=patch, cwd=root, ok=(0, 1))
if p.returncode:
print(p.stderr.decode(errors="replace"))
sys.exit("some hunks were rejected, see *.rej files")
scope = " (%s)" % ", ".join(paths) if paths else ""
print("%s turn %d%s" % ("undid" if reverse else "redid", n, scope))
def cmd_undo(args):
replay(args, True)
def cmd_redo(args):
replay(args, False)
Because the patch is built from stored snapshots and applied to files, it works whether or not you committed in between. A commit does not change the working tree, so the drift guard stays quiet and the undo still lands.
Step 10: Wire Up the Command Line
The last block adds prune and the argument parser. prune --keep 20 deletes the oldest turn refs and leaves the newest 20, which keeps the ledger from growing without limit.
def cmd_prune(args):
nums = turn_numbers()
doomed = nums[: max(0, len(nums) - args.keep)]
for n in doomed:
for kind in ("before", "after"):
if has_ref(ref(n, kind)):
run(["update-ref", "-d", ref(n, kind)])
print("pruned %d turn(s), kept %d" % (len(doomed), len(nums) - len(doomed)))
def build_parser():
p = argparse.ArgumentParser(prog="turnguard")
sub = p.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("begin")
s.add_argument("label", nargs="?")
s.set_defaults(fn=cmd_begin)
sub.add_parser("end").set_defaults(fn=cmd_end)
sub.add_parser("abort").set_defaults(fn=cmd_abort)
s = sub.add_parser("status")
s.add_argument("--fail-if-open", action="store_true")
s.set_defaults(fn=cmd_status)
sub.add_parser("list").set_defaults(fn=cmd_list)
s = sub.add_parser("show")
s.add_argument("turn", type=int)
s.add_argument("--stat", action="store_true")
s.set_defaults(fn=cmd_show)
for name, fn in (("undo", cmd_undo), ("redo", cmd_redo)):
s = sub.add_parser(name)
s.add_argument("turn", type=int)
s.add_argument("--file", action="append")
s.add_argument("--pick", action="store_true")
s.add_argument("--force", action="store_true")
s.add_argument("--dry-run", action="store_true")
s.set_defaults(fn=fn)
s = sub.add_parser("prune")
s.add_argument("--keep", type=int, default=20)
s.set_defaults(fn=cmd_prune)
return p
def main():
args = build_parser().parse_args()
args.fn(args)
if __name__ == "__main__":
main()
Make the script executable and give yourself a short entry point. A Git alias works from any subfolder and needs no PATH change:
chmod +x tools/turnguard.py
git config alias.tg '!python3 tools/turnguard.py'
git tg list
On a fresh repo, git tg list prints no turns recorded. If it prints a traceback instead, jump to the troubleshooting table. Before moving on, run the project’s own tests. The four tests in tests/test_turnguard.py create a temporary repository, run a turn that changes a CRLF file and adds a new one, then check that undo restores the exact bytes, that redo re-applies the turn, that the drift guard refuses, and that a second begin fails while a turn is open.
import pathlib
import subprocess
import sys
import tempfile
import unittest
TOOL = pathlib.Path(__file__).resolve().parent.parent / "tools" / "turnguard.py"
def git(cwd, *args):
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
def tg(cwd, *args):
return subprocess.run([sys.executable, str(TOOL), *args], cwd=cwd, capture_output=True, text=True)
class TurnguardTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = pathlib.Path(self.tmp.name)
git(self.root, "init", "-q", "-b", "main")
(self.root / "a.txt").write_bytes(b"one\r\ntwo\r\nthree\r\n")
git(self.root, "add", "-A")
git(self.root, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "base")
def tearDown(self):
self.tmp.cleanup()
def run_turn(self):
self.assertEqual(tg(self.root, "begin", "demo").returncode, 0)
(self.root / "a.txt").write_bytes(b"one\r\nTWO\r\nthree\r\n")
(self.root / "new.txt").write_text("hello\n")
self.assertEqual(tg(self.root, "end").returncode, 0)
def test_undo_restores_bytes_and_removes_new_file(self):
self.run_turn()
res = tg(self.root, "undo", "1")
self.assertEqual(res.returncode, 0, res.stderr)
self.assertEqual((self.root / "a.txt").read_bytes(), b"one\r\ntwo\r\nthree\r\n")
self.assertFalse((self.root / "new.txt").exists())
def test_redo_reapplies_turn(self):
self.run_turn()
tg(self.root, "undo", "1")
res = tg(self.root, "redo", "1")
self.assertEqual(res.returncode, 0, res.stderr)
self.assertEqual((self.root / "a.txt").read_bytes(), b"one\r\nTWO\r\nthree\r\n")
def test_drift_guard_refuses(self):
self.run_turn()
(self.root / "a.txt").write_bytes(b"one\r\nTWO\r\nthree\r\nfour\r\n")
res = tg(self.root, "undo", "1", "--file", "a.txt")
self.assertNotEqual(res.returncode, 0)
self.assertIn("refusing", res.stdout)
def test_second_begin_refused_while_open(self):
tg(self.root, "begin")
self.assertNotEqual(tg(self.root, "begin").returncode, 0)
if __name__ == "__main__":
unittest.main()
python3 -m unittest tests.test_turnguard -v
All four should pass in about a second. If the CRLF test fails, your Git is rewriting line endings. Our guide to gitattributes for line endings explains the settings behind that.
Steps 11 to 13: Run a Real Agent Turn, Review It, and Roll Back
Step 11: Rehearse with a stand-in agent
Before trusting the tool with Cursor, rehearse with a script whose behavior you know. tools/fake_agent.py makes two small edits to pricing.py, rewrites formatting.py wholesale with CRLF endings and new function names, and adds a notes file.
#!/usr/bin/env python3
"""Stand-in for an agent turn: two small edits, one full rewrite, one new file."""
import pathlib
root = pathlib.Path(__file__).resolve().parent.parent
pricing = root / "invoices" / "pricing.py"
text = pricing.read_text()
text = text.replace(" return amount * TAX_RATE\n", " return round(amount * TAX_RATE, 2)\n")
text = text.replace(" return 15.0\n", " return 16.0\n")
pricing.write_text(text)
fmt = root / "invoices" / "formatting.py"
fmt.write_bytes(
b'"""Invoice formatting."""\r\n\r\n\r\ndef money(value):\r\n return f"${value:,.2f}"\r\n\r\n\r\n'
b'def line(item):\r\n amount = item["qty"] * item["unit_price"]\r\n'
b' return f"{item[\'name\']:<20} {item[\'qty\']:>3} x {money(item[\'unit_price\']):>8} = {money(amount):>10}"\r\n\r\n\r\n'
b'def render(items, grand_total):\r\n rows = [line(i) for i in items]\r\n rows.append("-" * 50)\r\n'
b' rows.append(f"{\'TOTAL\':<38} {money(grand_total):>10}")\r\n return "\\n".join(rows)\r\n'
)
(root / "NOTES.md").write_text("Agent notes: rounded tax, raised heavy shipping, rewrote formatting.\n")
git tg begin "round tax and tidy formatting"
python3 tools/fake_agent.py
git tg status
git tg end
git tg list
python3 -m unittest discover -s tests -t .
Expect the list to show three files, 20 additions, 24 deletions, and a REWRITE flag on invoices/formatting.py. The test run then fails, because the stand-in rounded the tax and renamed the formatting functions. That failure is the point. Now practice the rollback ladder, from the smallest lever to the largest.
git tg undo 1 --dry-run
printf 'n\ny\n' | git tg undo 1 --pick --file invoices/pricing.py
git tg undo 1 --file invoices/pricing.py
git tg undo 1 --file invoices/formatting.py
The dry run proves the patch applies cleanly. The second command undoes only the shipping hunk. The third refuses, because pricing.py has now drifted from the after snapshot. The fourth succeeds, since the formatting file never moved. To practice the whole-turn undo, reset with git checkout -- invoices, delete NOTES.md, then run git tg redo 1 followed by git tg undo 1. The final undo reverts all three files and deletes the notes file.
Step 12: Run a real Cursor Agent turn
Now repeat the cycle with the real thing. Reset the demo to the baseline with git checkout -- . and delete any leftover NOTES.md, then open the folder in Cursor and start a turn like this:
git tg begin "cursor: round tax"
Send the agent a narrow prompt in a new chat, for example: In invoices/pricing.py, round the result of tax() to two decimals. Change nothing else. While the agent works, run git tg status in a second terminal. If the stat already lists invoices/pricing.py while Cursor still shows the edit as pending, the edit is on disk and your snapshot will capture it. That is the behavior we expect, but verify it on your own build because it decides when you should run end.
When the agent finishes, close the turn:
git tg end
git tg list
A well-behaved turn on this prompt should show one file and two changed lines with no REWRITE flag. If the list shows several files or a flag, your prompt gave the agent too much room. Tightening it is the subject of our guide to Cursor rules that steer edit size.
A scale note: a stand-in turn that touches three files takes a fraction of a second to snapshot. On a large monorepo, the git add -A step is the slow part, so keep build output and dependency folders in .gitignore.
Step 13: Review in Cursor, then roll back with Git
With the turn closed, review the Cursor Agent’s work in Cursor first. Open the Changes tab and pick the Last Agent Turn scope. Use Keep and Undo in the Editor window for quick per-change decisions. If you work in the Agents Window, remember it offers file-level discard only, which is where git tg undo N --pick earns its keep.
Then run the experiment we promised earlier. In a throwaway chat, ask the agent to create a file by running a terminal command, for example run a shell command that writes build-output.txt. Afterward use the Restore Checkpoint control on that chat message and check whether the file disappears. Whatever happens, git tg undo N will remove the file, because turnguard snapshots the disk and not the chat. Write down the result for your build, since Cursor’s notes do not document the scope.
Finally, install the commit guard so an unclosed turn never slips into history:
cat > .git/hooks/pre-commit <<'EOF'
#!/bin/sh
if ! python3 tools/turnguard.py status --fail-if-open >/dev/null; then
echo "turnguard: a turn is still open. Run 'end' or 'abort' before committing." >&2
exit 1
fi
EOF
chmod +x .git/hooks/pre-commit
Test it: open a turn, stage a file, and try to commit. The commit should fail with the message above. Run git tg abort and the commit goes through.
What a Clean Run Looks Like
Compare your output with the real session from the stand-in rehearsal. The mid-turn status, the close message, and the list should read like this:
$ git tg begin "round tax and tidy formatting"
turn 1 open (before = 70ffae3095)
$ python3 tools/fake_agent.py
$ git tg status
turn 1 is open
NOTES.md | 1 +
invoices/formatting.py | 39 +++++++++++++++++----------------------
invoices/pricing.py | 4 ++--
3 files changed, 20 insertions(+), 24 deletions(-)
$ git tg end
turn 1 closed: 3 files changed, 20 insertions(+), 24 deletions(-)
$ git tg list
turn files + - label
1 3 20 24 round tax and tidy formatting REWRITE: invoices/formatting.py
The commit hash after before = will differ on your machine. Everything else should match exactly. The drift guard and dry-run outputs look like this:
$ git tg undo 1 --dry-run
dry run ok: patch applies cleanly
$ git tg undo 1 --file invoices/pricing.py
refusing: these files changed after turn 1:
invoices/pricing.py
re-run with --force to use --reject (leaves .rej files), or undo by hand.
| File | Lines before | Added | Deleted | Read of the change |
|---|---|---|---|---|
| invoices/pricing.py | 39 | 2 | 2 | Two small hunks, safe to review line by line |
| invoices/formatting.py | 22 | 17 | 22 | Every original line replaced, flagged as REWRITE |
| NOTES.md | 0 (new file) | 1 | 0 | New untracked file, captured because it is not ignored |
| Whole turn | – | 20 | 24 | Tests fail until the turn is undone |
After the full undo, the test run returns to Ran 5 tests and OK, and git status shows only the untracked tools/ folder. That last detail confirms the undo removed the agent’s new file without touching yours.
Common Pitfalls
- Forgetting to run end. An open turn keeps absorbing your own edits into the next diff. Use the pre-commit hook from Step 13 and make
enda habit before you read the agent’s work. - Editing while the Cursor Agent runs. Any file you change during a turn lands in the same snapshot pair. Undoing the turn then also undoes your edit. Stay hands-off until you close the turn, or work in a second Git worktree.
- Trusting ignored files. Anything matched by
.gitignore, including.envfiles, build output, and local databases, never enters a snapshot. An agent that edits an ignored file leaves no recoverable history here. - Running two agents at once. Parallel agents in one folder produce one blended turn. Give each agent its own worktree with
git worktree addand run turnguard in each. - Skipping the dry run.
--dry-runcosts nothing and tells you whether the patch applies. Use it before every undo you have not rehearsed. - Assuming a commit protects you. A commit protects history, not the turn record. Pruning refs deletes the ability to undo. Keep at least 20 turns, and never run
prune --keep 0mid-project. - Reading line counts as intent. A high deleted count can mean a rewrite or a legitimate move. The REWRITE flag is a prompt to look, not a verdict, and structural diffs with Difftastic can separate moved code from replaced code.
Troubleshooting Guide
These are the failures you are most likely to meet. Each row names the symptom, the cause, and the fix.
| Symptom | Cause | Fix |
|---|---|---|
| git rev-parse –verify HEAD failed | The repo has no commits yet | Commit a baseline first, then run begin |
| turn N is still open | A previous begin was never closed | Run end to keep it or abort to discard it |
| refusing: these files changed after turn N | You edited files after the turn closed | Undo per file with –file, or use –force and repair .rej files |
| error: patch failed / does not apply | The file moved away from the snapshot | Run with –dry-run, then use –pick or merge by hand |
| New file missing from the diff | The path matches .gitignore | Adjust the ignore rule or copy the file in yourself |
| list shows no REWRITE flag on a big change | The file is too small, or too small a share of it was deleted, to cross the thresholds | Lower the thresholds in rewrite_flag |
| EOFError during –pick | No interactive terminal attached | Run in a real terminal, or pipe answers with printf |
| CRLF test fails | core.autocrlf rewrites endings | Set a .gitattributes policy for the affected files |
| Snapshots feel slow | Large untracked or build folders | Add them to .gitignore |
| python3 not found on Windows | The launcher is named py | Use py -3 or install Python and reopen the terminal |
| Hook never fires | Hook file is not executable | Run chmod +x .git/hooks/pre-commit |
| git tg: command not found | Alias set in a different repo | Re-run git config alias.tg inside this repo |
When a failure is not on the list, run the failing git command by hand. turnguard prints the exact Git invocation and its error text, so reproducing it takes a few seconds.
Advanced Tips
Name every turn after the prompt
Pass the prompt’s first line as the label: git tg begin "cursor: round tax". Six months later, git tg list doubles as a log of what you asked for and what it cost you in changed lines.
Pair the ledger with Cursor’s hooks and rules
Cursor supports hooks, rules, and skills for customizing the agent. A hook that runs begin when a turn starts and end when it stops would remove the manual steps. Check Cursor’s documentation for the hook event names and payload format in your build before wiring that up, since they changed between releases. Rules also help prevent the damage in the first place, since wording shifts the size of edits.
Feed the ledger to CI
Because turnguard refs live in your clone, they never reach a remote unless you push them. Run git push origin 'refs/turnguard/*' only if you want a shared audit trail. For a team gate that works on pull requests instead, a diff-budget check is a better fit, and the edit drift checker for Plan mode covers the same ground for Plan mode.
Use worktrees for cloud and parallel agents
Cursor’s Agent SDK, released in October 2026, lets teams build managed agents, as the Stackness analysis of the Agent SDK describes. Anything that runs agents in parallel should run each in its own worktree, with its own turn ledger. One git worktree add ../invoice-lib-agent2 per agent keeps the numbering and the drift guard meaningful.
Tune the rewrite heuristic
The default deletion threshold suits small modules. For files with generated sections, count only non-blank lines or raise the minimum file size. Change one constant and rerun list, since the snapshots never change.
The Complete Working Project
Everything in this tutorial lives in one repository. The tool itself is tools/turnguard.py. Concatenate the code blocks from Steps 4 to 10 in order and you have the whole file. A final smoke test confirms the setup end to end:
python3 -m unittest discover -s tests -t .
git tg list
git tg begin "smoke test"
echo "# scratch" >> README.md
git tg end
git tg show 1 --stat
git tg undo 1
git diff --stat
git tg prune --keep 20
The last git diff --stat should print nothing, since the undo restored the README. If it does, the project is working. From here you can add your own commands. Useful candidates are an export N command that writes a turn’s patch to a file for code review, or a diffstat --json output for dashboards.
One honest limit remains. turnguard sees the filesystem, not the Cursor Agent. It cannot tell you why a turn rewrote a file, which model ran it, or what the prompt said unless you put that in the label. It also cannot reverse effects outside the repository, such as a database migration or a package installed globally. Treat it as a seatbelt for file edits, and keep the usual caution around commands the agent runs. For more developer how-tos in this category, browse our software tutorials.
Frequently Asked Questions
Does turnguard change my branch, index, or stash?
No. It uses a temporary index file, writes objects, and stores refs under refs/turnguard/. Your branches, staged changes, and stash stay exactly as they were.
Can I undo one hunk instead of a whole file?
Yes. Run git tg undo N --pick and answer y, n, or q for each hunk. Add --file path to limit the prompts to one file.
How is this different from Cursor’s Restore Checkpoint?
Restore Checkpoint is a chat control, and Cursor’s notes do not document its scope. turnguard records disk state, so it covers any change in non-ignored files, including those made by terminal commands. Step 13 shows how to test the difference on your own build.
Does it work with other AI editors?
Yes. turnguard never talks to Cursor. It snapshots the folder, so it works with any editor, CLI agent, or script that changes files in a Git repository.
Will snapshots bloat my repository?
Git stores unchanged files once, so a snapshot only adds objects for files that changed. For the demo it adds a few kilobytes. Use prune --keep 20 and git gc to reclaim space from old turns.
What happens if I commit between begin and end?
The tool still works, since it snapshots the working tree and not the index. The after snapshot’s parent is whichever commit HEAD points at then. The diff between the two snapshots stays correct.
Why not just use git stash or a commit before each prompt?
A commit before every prompt clutters history and mixes your work with agent work. A stash skips untracked files by default and moves your changes out from under the editor. Hidden refs avoid both problems.





