Snapshot Diff
MediumAI-assisted coding · AI-Assisted Debugging
Snapshot Diff
You've joined the team that owns snapdiff, the library behind a backup tool's "what changed since last time?" screen. It compares two directory snapshots and reports which files were added, removed or modified. Support has a bug report, and two feature tickets are waiting behind it.
Codebase
| File | Purpose |
|---|---|
snapdiff/app.py | run() entrypoint and the operation dispatcher |
snapdiff/store.py | In-memory registry of named snapshots |
snapdiff/tree.py | Snapshot tree helpers: validation, walking files, counting |
snapdiff/paths.py | Path helpers (join, split, sort key, ancestors) |
snapdiff/differ.py | The diff engine |
snapdiff/changes.py | The Change record |
snapdiff/options.py | Parsing of the optional options argument |
snapdiff/renames.py | Rename detection (stub, Part 2) |
snapdiff/ignore.py | Ignore patterns (stub, Part 3) |
snapdiff/report.py | Rendering changes and counting them for summaries |
snapdiff/config.py | Shared constants |
tests/ | Visible tests (plain assert functions) |
How your code is run
The grader calls run(operations) from snapdiff/app.py with a list of operations and compares the list of results with the expected one. There is one result per operation, in order. Every operation returns a value; errors are strings starting with error:, never exceptions.
Snapshots. A snapshot is a nested object. A directory is an object mapping entry names to nodes. A file is a non-empty string, its content digest. Entry names are non-empty and contain no /. A *path* is the names from the root joined with /, for example src/util/fmt.py. Empty directories are allowed but contain no files, so they never appear in a diff.
| Operation | Arguments | Returns |
|---|---|---|
load | name, tree | {"files": n, "dirs": n} (directories excluding the root). Replaces any snapshot with that name. error: invalid snapshot if tree is malformed |
files | name | sorted list of file paths |
diff | old, new, optional options | list of change objects |
summary | old, new, optional options | {"added": n, "removed": n, "modified": n, "renamed": n} (all four keys always present) |
A change object is {"op": "added" | "removed" | "modified", "path": ...}. Changes are ordered by path segment by segment: a/b.txt sorts before a.txt because a < a.txt. A file is *modified* when it exists on both sides with different digests.
Errors, checked in this order: an operation that is not a non-empty list starting with a string gives error: malformed operation; unknown operation error: unknown operation 'x'; wrong argument count error: bad arguments for 'diff'; unknown snapshot error: unknown snapshot 'name' (old checked before new); then the options errors below.
Example:
[["load", "v1", {"README": "9f86d081", "lib": {"a.py": "2c26b46b"}}],
["load", "v2", {"lib": {"a.py": "2c26b46b", "util": {"fmt.py": "fcde2b2e"}}}],
["diff", "v1", "v2"]]returns
[{"files": 2, "dirs": 1}, {"files": 2, "dirs": 2},
[{"op": "removed", "path": "README"}, {"op": "added", "path": "lib/util/fmt.py"}]]diff and summary take an optional options object (null or absent means defaults). Anything other than an object is error: invalid options; an unknown key is error: unknown option 'key'. The keys are renames (Part 2) and ignore (Part 3).
Part 1 — Wrong paths and odd results in diffs
Goal: Diffs should always report correct paths and sensible changes.
Support reports: "after we moved a file into a nested folder, the diff shows the new file at the wrong path", and "when a file was replaced by a folder of the same name the diff just says it was modified".
Rules
- Every reported path is the full path from the snapshot root, however deeply the file is nested and whichever side it exists on.
- If a name is a file on one side and a directory on the other, the old side's files are all
removedand the new side's files are alladded. It is nevermodified. - Everything else about diff and summary keeps working as described above.
Example: the worked example above. lib/util/fmt.py must not appear as util/fmt.py.
Part 2 — Rename detection (SNAP-47)
Goal: Report moved files as renames when asked.
With option "renames": true, a removed file and an added file with the same digest are replaced by one change {"op": "renamed", "from": old_path, "path": new_path}.
Rules
renamesmust be a boolean, otherwiseerror: invalid option 'renames'. Defaultfalse: output is exactly as in Part 1.- Only files that are
removedoraddedcan be paired. A file that moved *and* changed has a different digest, so staysremovedplusadded. A copy of a file that still exists is justadded. - Files whose digest equals
EMPTY_HASH(insnapdiff/config.py) are never paired. - When several files share a digest, pair them in two passes. Pass 1 goes through the removed files in path order and pairs each with the first unpaired added file that has the same file name (last path segment). Pass 2 pairs the remaining removed files, in path order, with the remaining added files in path order. Unpaired files stay
removedoradded. - A
renamedchange is sorted by its newpath, among all other changes. summarycounts renamed changes underrenamed, and they are not counted asaddedorremoved.
Example: old {"README.md": "aaaa1111", "notes.txt": "bbbb2222"}, new {"docs": {"README.md": "aaaa1111"}, "notes.txt": "cccc3333"} with {"renames": true} gives [{"op": "renamed", "from": "README.md", "path": "docs/README.md"}, {"op": "modified", "path": "notes.txt"}].
Part 3 — Ignore patterns (SNAP-52)
Goal: Let users hide build output and logs, .gitignore style.
Option "ignore" is a list of pattern strings. Ignored files are dropped from the results before anything else is computed, so they take no part in rename detection and are not counted in summary.
Rules
ignoremust be a list of strings, otherwiseerror: invalid option 'ignore'. An empty list ignores nothing.- Each pattern is trimmed of surrounding whitespace. Empty patterns and patterns starting with
#are skipped. - A leading
!negates a pattern. For any one path, the last pattern that matches decides whether it is ignored. - A trailing
/makes the pattern match directories only. A pattern with no/apart from a trailing one matches the entry name at any depth. Otherwise the pattern is anchored to the snapshot root (a leading/is allowed and dropped) and matched against the whole path. - Matching uses
fnmatchsemantics, case-sensitive:*,?and[abc]work, and*also matches/(sodocs/*.mdmatchesdocs/sub/x.md). There is no**. - Ignoring a directory ignores every file inside it. A
!pattern can never re-include a file whose parent directory (at any level) is ignored. - A pattern matches a file or directory by its own path:
build/does not match a *file* calledbuild.
Example: {"ignore": ["*.log", "build/"]} hides server.log, src/debug.log and everything under build/ (or lib/build/), but not app.py.
Notes
store.py,tree.py,paths.pyandreport.pyhave been in production for a while and are well tested.- You may use the AI assistant freely. Later parts must not break earlier ones; the visible tests are a starting point, not the full suite.
- Completing Parts 1 to 3 is a strong result.
Example 1
- Input:
- Snapshot v1 = {README, lib/a.py}, v2 = {lib/a.py, lib/util/fmt.py}; ["diff", "v1", "v2"]
- Output:
- [{"op": "removed", "path": "README"}, {"op": "added", "path": "lib/util/fmt.py"}]
- Explanation
- The new file is reported with its full path from the root, not just util/fmt.py.
Example 2
- Input:
- old {README.md: aaaa1111, notes.txt: bbbb2222}, new {docs/README.md: aaaa1111, notes.txt: cccc3333}; diff with {"renames": true}
- Output:
- [{"op": "renamed", "from": "README.md", "path": "docs/README.md"}, {"op": "modified", "path": "notes.txt"}]
- Explanation
- The README kept its digest while moving, so it is a rename; notes.txt changed in place.
Example 3
- Input:
- Diff with {"ignore": ["*.log", "build/"]} where app.py, server.log, build/out.js and src/debug.log all changed and src/main.py was added
- Output:
- [{"op": "modified", "path": "app.py"}, {"op": "added", "path": "src/main.py"}]
- Explanation
- Log files at any depth and everything under a build directory are hidden.
Guidelines
- Python 3 standard library only
- A file is a non-empty digest string; a directory is an object of entry names to nodes
- Paths are slash-joined from the root; changes are ordered segment by segment
- Every operation returns a value; errors are "error: ..." strings, never exceptions