Snapshot your working tree before an agent edits it, step back if you do not like the result. Zero dependencies.
Project description
unedit
A safety net for letting an agent loose on your files.
Snapshot your working tree before an agent edits it. Step back with one command if you do not like the result. No git required. No daemon running. No background watcher.
pip install 'stillworks[all]' # all five agent tools, including this one
pip install unedit # or just this one, zero dependencies
Or run it straight from a checkout, no install needed — it is stdlib only:
git clone https://github.com/iselur/unedit
cd unedit && python3 -m unedit --help
30-second quickstart
$ cd my-project/
# before you hand off to an agent:
$ unedit save -m "before agent refactor"
saved 20260802-230236-724062-qaxj (4 files, 166 B)
before agent refactor
# agent runs, makes changes. check what changed:
$ unedit diff
diff vs 20260802-230236-724062-qaxj 2026-08-02 23:02:36 — before agent refactor
added (1)
+ src/new_module.py (14 B)
modified (1)
~ src/app.py (46 B -> 154 B)
# not happy? step back:
$ unedit back --yes
plan:
1 files to restore
1 new files to move aside (created since snapshot)
auto-saving current state before restore...
safety snapshot: 20260802-230237-007494-4cx2 (run: unedit back 20260802-230237-007494-4cx2 to undo this restore)
new files moved aside to: /tmp/my-project/.unedit/aside/20260802-230237/
done. 1 restored, 1 moved aside, 0 deleted.
to undo: unedit back 20260802-230237-007494-4cx2
Restoring is never a one-way door. back prints the plan, then (if you confirm) auto-saves the current state and executes the restore. The auto-save only happens when you confirm — aborting at the prompt creates nothing. The command to undo the undo is always printed.
Why it exists
git stash requires a git repository, and every session-based tool (claude code /rewind, Gemini CLI checkpoints, OpenCode /undo) only protects edits made inside its own session using a shadow git repo internally.
unedit fills the gap where none of those apply: a directory that is not a git repository, or a session where you are handing off to a script, a shell command, or an agent running outside the tools listed above.
The model is deliberately imperative rather than reactive: one explicit unedit save before you hand off to any agent or script, one unedit back if you do not like the result. The protection never depends on a daemon that was or was not started before the session began.
Why not just ask my AI to do this?
Because the moment things go wrong is precisely the moment you cannot trust the AI to undo them cleanly. unedit runs before you hand off control. It is a snapshot of reality, not a promise by the system that is about to make changes.
Commands
unedit save [-m MSG] [--force] snapshot the current directory tree
unedit list snapshots: id, when, message, file count, size
unedit show [ID] list every file in a snapshot with sizes
unedit back [ID] [--yes] [--hard] [--force] restore a snapshot (auto-saves first)
unedit diff [ID] [--patch] what changed since a snapshot
unedit drop ID... | --all delete snapshots and reclaim disk space
unedit where print the snapshot directory and disk used
unedit --version print the version and exit
All commands accept --json, and all accept --project PATH (or --dir PATH, the older spelling) to operate on a directory other than ., before or
after the subcommand. A path that isn't there is an error naming it, not a
directory it quietly creates.
back: how new files are handled
Files that did not exist at snapshot time are moved to .unedit/aside/<timestamp>/ with their relative paths preserved, not deleted. The tool prints exactly where they went. Use --hard to delete them instead; each deleted filename is printed. Empty directories left behind by moved or deleted files are removed automatically. Without --yes, a summary is printed and confirmation is required. --json implies non-interactive: the prompt is skipped and the restore proceeds, so agents that parse structured output get clean JSON.
diff: output contract
Default output is a file-level summary (added / modified / removed, with sizes). This keeps output readable even on large trees. Use --patch to include a unified diff for each changed text file.
storage
Snapshots live in .unedit/ inside your project directory. Objects are content-addressed (SHA-256): two files with the same content are stored once, even across snapshots. You can inspect any snapshot manifest directly — they are plain JSON under .unedit/snapshots/.
A manifest that cannot be read is reported as damaged and named, never skipped: a manifest is only an index, so the file contents it points at are still in .unedit/objects/ and can be recovered by hand.
Add .unedit/ to your .gitignore.
Example session output
$ unedit show
snapshot: 20260802-230236-724062-qaxj
when: 2026-08-02 23:02:36 — before agent refactor
4 files
README.md 27 B 2026-08-02 23:02
src/app.py 46 B 2026-08-02 23:02
src/config.py 26 B 2026-08-02 23:02
tests/test_app.py 67 B 2026-08-02 23:02
$ unedit diff --patch
diff vs 20260802-230236-724062-qaxj 2026-08-02 23:02:36 — before agent refactor
added (1)
+ src/new_module.py (14 B)
modified (1)
~ src/app.py (46 B -> 154 B)
--- a/src/app.py
+++ b/src/app.py
@@ -1,2 +1,7 @@
def greet(name):
+ # agent added logging
+ print(f"Greeting {name}")
return f"Hello, {name}!"
+
+def farewell(name):
+ return f"Goodbye, {name}!"
$ unedit where
/home/user/my-project/.unedit
2 snapshots, 2.6 KB
$ unedit save --json -m "json test"
{
"id": "20260802-230237-121074-jo4u",
"file_count": 5,
"total_size": 168,
"message": "json test",
"timestamp": "2026-08-02T23:02:37+01:00",
"empty": false,
"nothing_captured": false,
"skipped": []
}
Default exclusions
unedit skips the following by default: .git, .unedit, node_modules, .venv, venv, __pycache__, .mypy_cache, .pytest_cache, dist, build, target, .next, .DS_Store.
It also respects .gitignore and .uneditignore (same format: one glob per line) if they exist in the project root.
Safety guard rails
- Snapshot ids carry a UTC clock, so the newest one is always the last one.
unedit backwith no id restores the newest, and local time does not always go forwards — daylight saving ends, a laptop lands in another zone — which once hadbackrestore an older snapshot and report success. The timestamp you read inlistis still your own local time, now with its offset on it. - Refuses to snapshot
/,/etc,/usr,/var,/opt,/System,/Windows, or your home directory. - Refuses (with a
--forceescape) when the tree exceeds 2 GB or 50,000 files. - Symlinks are stored as symlinks and restored as symlinks. They are never followed out of the tree.
- A restore that would write outside the project is refused whole, before
anything is touched — no safety snapshot, no partly-restored tree, exit
2. It says which of the two it was: a snapshot naming a path outside the project, or an ordinary path that a symlink now redirects. The second one is not the snapshot's fault, and the message names the link and where it leads, because that is the thing to remove. - Mode bits are preserved and restored.
- Exit codes:
0fine,1the command failed,2usage error,130stopped by ctrl-c,141the reader hung up (unedit diff | head, or| lessquit withq). The last two are not0, because a snapshot or a listing that was cut off finished nothing — andunedit save && rm -rf buildmust not delete anything on the strength of one. - An empty store is
1, not2, on every command that can hit it. Nothing was typed wrong — there is simply nothing saved yet.2stays reserved for a command line that was wrong, including naming a snapshot id that is not there, so a script can tell "save something first" from "that id is gone". - A restore that put back fewer files than it planned to exits
1, not0. Files can refuse to come back — a read-only directory, another owner, a full disk — andunedit back --yes && npm testmust not run the tests against a tree that was never put back. The safety snapshot id is still printed, so there is a way out of a half-done restore. - unedit uses no git internally. Snapshots are a flat content-addressed file store under
.unedit/objects/with a human-readable JSON manifest per snapshot. No git objects. No git index. Inspectable with any text editor.
Prior art (and what's different)
unedit occupies a specific gap: no background watcher required, no tie to any specific AI tool's session, no git repository required. The model is deliberately imperative — one explicit save, one explicit back. That combination is not offered by any of the tools below.
Direct overlap — tools that also protect working-tree state from agent edits:
-
Salvager (salvager.sh) — Purpose-built CLI + optional MCP server for AI-agent edit protection. Run
salvager watchin any project root and it saves a per-file revision into.salvager/whenever any file changes, including from agent edits. Restore first saves a pre-restore revision (same "undo the undo" safety). Apache 2.0, single static binary, no telemetry. The key difference: Salvager's protection is reactive and requiressalvager watchto have been started before edits begin. unedit is imperative: you invokesavewhen you choose. Neither approach is strictly better — they suit different workflows. -
Claude Code /rewind (built-in checkpointing) — Claude Code v2+ automatically snapshots files before each edit.
/rewindopens a menu to restore any checkpoint. Checkpoints persist for 30 days. Uses a shadow git repo under~/.claude/. Tied exclusively to Claude Code sessions; does not protect changes made by other tools in the same directory. -
Gemini CLI checkpointing — Creates a checkpoint (shadow git commit in
~/.gemini/history/<project_hash>/) before each approved file-modifying tool call./restorereverts all project files. Disabled by default; requires opt-in. Tied exclusively to Gemini CLI sessions. -
OpenCode /undo and /redo — OpenCode snapshots file state per conversation turn using an internal git mechanism.
/undowalks back through turns. Requires the project to be a git repository. Tied to OpenCode sessions.
Partial overlap — adjacent tools that informed this design:
-
ccundo (npm) — Reads Claude Code session JSON files on disk and reconstructs per-operation undo capability. Not a snapshot tool; it parses the agent's own log rather than capturing an independent snapshot.
-
Rewind MCP (khalilbalaree/undo-mcp) — MCP server that intercepts file modifications made through Claude Code tool calls and saves checkpoints. Coverage is only as wide as what goes through MCP tool calls; direct shell edits are invisible.
-
jj (Jujutsu VCS) — VCS that snapshots the working copy automatically before every command and supports
jj undo/jj op revertnon-destructively. Best-in-class undo story for repos that adopt jj as the VCS. -
rsnapshot — Hardlink-based incremental backup via rsync. Each snapshot is a full directory copy; unchanged files are hardlinked. Designed for cron-driven scheduled backups; requires a config file.
-
snapshotter (seanh, PyPI) — Simple
snapshotter SRC DESTcommand making hardlink snapshots. No list/restore/diff UX. Last released 2016, uses rsync under the hood. -
restic / borg / kopia — Production-grade deduplicated backup tools with encryption, remote backends, and scheduled backups. All require repository initialisation and carry significant configuration surface area.
-
VS Code Local History / JetBrains Local History — Per-file change history saved automatically on every save, inside the IDE. No CLI surface, no whole-tree snapshot, no cross-file restore.
-
git stash (with -u flag) — Stashes tracked modifications and untracked files. Only works inside a git repository.
-
git worktree — Creates an isolated working directory for parallel agent sessions. Requires git. Does not snapshot pre-existing uncommitted state for non-git projects.
Honest limits (v0.1)
-
gitignore support is partial. unedit reads
.gitignoreand.uneditignorefrom the project root only (not from subdirectories). Patterns with!(negation),**(double-star globbing across path separators), and directory-specific patterns are not fully handled. The common patterns (*.pyc,node_modules/,dist/) work correctly. -
No conflict detection. If two people restore different snapshots of the same directory at the same time, the last one wins. unedit has no locking mechanism.
-
Binary file diffs are not shown.
diff --patchskips binary files and shows the file-level summary (size before/after) only. -
Aside files are not indexed. Files moved to
.unedit/aside/during a restore are not tracked in any manifest. You navigate them by hand. -
No compression. Objects are stored as-is. Deduplication reduces storage for identical files across snapshots, but individual large files are not compressed.
-
No encryption. Snapshots contain copies of your files in plaintext. If your working directory contains secrets, those secrets are in
.unedit/too. -
Large binary files slow saves. The SHA-256 scan reads every byte of every non-excluded file. Repositories with large binary assets (video, compiled artifacts) may be slow to snapshot.
-
Symlink targets are stored verbatim. If a symlink points outside the project tree, unedit stores the target path as-is. Restoring on a different machine may produce a dangling symlink.
-
One snapshot is one row. A message and a filename both end up in a manifest in
.unedit/, and both are read by somebody deciding what to restore, so both are flattened to a single line before printing — otherwise a newline in either wrote an extra row shaped exactly like a real entry, andunedit showlisted a file that is not in the snapshot. Rows are also cut at 400 characters with a marker saying how much was dropped:unedit save -m "$(cat NOTES.md)"is an ordinary thing for a script to do, and it used to scroll every other snapshot off the screen. Nothing is lost — the manifest and--jsonkeep the whole value. -
An empty snapshot is not a safety net. An ignore rule that happens to match the whole project — a
.gitignorecontaining*, a vendored tree where the checked-in files are all excluded — used to producesaved … (0 files, 0 B)on exit0. The count was on screen and nobody reads a count next to the word saved; the net had no floor in it, and that was discovered at restore time, which is the one place it cannot be fixed. A save that captures nothing while the directory has files in it now says so, names one of the files it did not take and the ignore file responsible, and exits1. A save of a directory that really is empty is still a save and still exits0— goingbackto it means "clear this out again", which is a real thing to want.backto any snapshot holding no files now says that before it asks you to confirm, since older versions wrote plenty of them. -
Detection, not proof. A diff from unedit tells you which files changed by content (hash) and size. It does not tell you whether the change was intentional, correct, or safe. That judgment is yours.
Part of a small family
Five tools for working with coding agents, same house style: zero dependencies, MIT, no API key, nothing leaves your machine. None of them call a model — that is the point, since the thing being checked already is one.
Each of those four claims is a test rather than a promise, in
tests/test_family_claims.py: every import resolves to the standard library or
to this package, nothing that can open a socket is imported, no environment
variable that looks like a credential is read, and no model SDK or provider
hostname appears anywhere. A claim repeated in five READMEs and checked in none
of them would read as five agreements when it was one assertion.
- stillworks — record what your code does now, catch when it changes later
- agentdiff — see what the agent actually changed, before you merge
- agentlog — what did your coding agent actually do today?
- agentwatch — tail what your agent is doing, right now
- unedit — a safety net for letting an agent loose on your files ← you are here
One install gets all five, and stillworks tools says which ones you have:
pip install 'stillworks[all]'
stillworks tools
License
MIT. Copyright (c) 2026 stillworks contributors.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file unedit-0.1.5.tar.gz.
File metadata
- Download URL: unedit-0.1.5.tar.gz
- Upload date:
- Size: 89.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0f134eba49fe3cfd4acca8c28e4afe76c889a6e38c3ae19e27e46dee210b73f6
|
|
| MD5 |
98accdc7e738a0929a8d15040eefa654
|
|
| BLAKE2b-256 |
2430193676979953bde1bddd4ec2732f83d0b311c231207a51e1d6fbd1eb6647
|
File details
Details for the file unedit-0.1.5-py3-none-any.whl.
File metadata
- Download URL: unedit-0.1.5-py3-none-any.whl
- Upload date:
- Size: 32.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9086f18323bb8a0c8620a13cb75875fa2bce348ef490e6af8a0a243040a057e8
|
|
| MD5 |
38dafdee65d5a9eb4f419e5d80250592
|
|
| BLAKE2b-256 |
f8fc6ff55567cc0da236985969e225854b969056ac7692e3d4690ef23e0b4b38
|