Compare two directory trees like rsync --dry-run
Problem statement
A release directory is built on CI and mirrored to a server. Before syncing, you want the plan: which files must be copied (only in the source), updated (in both, but different content), or deleted (only in the replica). Print one line per action, sorted by path, then a summary with the unchanged count.
Two files are the same only if their bytes are the same. Compare sizes first, because it is free, and hash only when the sizes are equal. bin/app below is the trap: same size in both trees, different content.
If either argument is not a directory, exit with an error. An empty listing would otherwise mean "copy everything" or "delete everything".
Sample trees
source/ replica/ bin/app "v2 binary" bin/app "v1 binary" configs/app.yaml "port: 80\n" configs/app.yaml "port: 80\n" configs/new.yaml "x: 1\n" README "hello" README "hello" old/legacy.sh "echo hi\n"Examples
Example 1
Input: `python solution.py` (the demo builds both trees above in a temporary directory)
Output: ~ update bin/app
+ copy configs/new.yaml
- delete old/legacy.sh
1 to copy, 1 to update, 1 to delete, 2 unchanged
Explanation: bin/app is 9 bytes on both sides, so only the hash shows it changed. README and configs/app.yaml are unchanged.
Hints
Approach
Optimal
Listings first, content only when needed.
listingwalks a tree, keeps regular files, and mapsrelpath(converted withas_posix()) to size.- For every path in the sorted union: only in source means copy; only in replica means delete; different sizes mean update without reading anything; equal sizes mean hash both sides, 1 MiB at a time, and compare.
- Count actions for the summary.
n is the number of paths. The listings are held in memory, which is fine for millions of files. File contents never are.
O(n log n + bytes hashed)Space O(n)import hashlibimport osimport statimport sysimport tempfilefrom pathlib import Path def sha256(path): h = hashlib.sha256() with open(path, "rb") as f: for block in iter(lambda: f.read(1 << 20), b""): h.update(block) return h.digest() def listing(root): """relative posix path -> size, for regular files only.""" files = {} for dirpath, _dirs, names in os.walk(root, onerror=lambda e: print(f"warning: {e}", file=sys.stderr)): for name in names: full = os.path.join(dirpath, name) try: st = os.lstat(full) except OSError: continue if stat.S_ISREG(st.st_mode): files[Path(os.path.relpath(full, root)).as_posix()] = st.st_size return files def plan(src, dst): a, b = listing(src), listing(dst) actions, unchanged = [], 0 for rel in sorted(a.keys() | b.keys()): if rel not in b: actions.append(("+", "copy", rel)) elif rel not in a: actions.append(("-", "delete", rel)) elif a[rel] != b[rel]: actions.append(("~", "update", rel)) # different size: no need to read either file elif sha256(os.path.join(src, rel)) != sha256(os.path.join(dst, rel)): actions.append(("~", "update", rel)) # same size, different bytes else: unchanged += 1 return actions, unchanged def report(src, dst): for d in (src, dst): if not os.path.isdir(d): sys.exit(f"not a directory: {d}") # an empty listing would mean 'copy everything' actions, unchanged = plan(src, dst) for sign, verb, rel in actions: print(f"{sign} {verb:<7} {rel}") n = {v: sum(1 for _, verb, _ in actions if verb == v) for v in ("copy", "update", "delete")} print(f"{n['copy']} to copy, {n['update']} to update, {n['delete']} to delete, {unchanged} unchanged") SOURCE = {"bin/app": b"v2 binary", "configs/app.yaml": b"port: 80\n", "configs/new.yaml": b"x: 1\n", "README": b"hello"}REPLICA = {"bin/app": b"v1 binary", "configs/app.yaml": b"port: 80\n", "README": b"hello", "old/legacy.sh": b"echo hi\n"} def build(base, files): for rel, data in files.items(): p = Path(base, rel) p.parent.mkdir(parents=True, exist_ok=True) p.write_bytes(data) if __name__ == "__main__": if len(sys.argv) > 2: report(sys.argv[1], sys.argv[2]) else: with tempfile.TemporaryDirectory() as src, tempfile.TemporaryDirectory() as dst: build(src, SOURCE) build(dst, REPLICA) report(src, dst)Follow-up questions
- Apply the plan: copy with
shutil.copy2, delete, then remove directories left empty. - The replica is on a remote host. What would you send over the network instead of file contents? (Listings with hashes, or rsync's rolling-checksum blocks.)
- Also compare file permissions and flag executables that lost
+x.
Frequently asked questions
By default rsync's quick check compares size and modification time and only transfers files where either differs. That is fast but can miss a change that keeps both the size and the mtime, which happens with some build tools and touch -r. rsync --checksum hashes like this script does, at the cost of reading every same-size file on both sides.
This script lists only files, so x (a file) becomes a copy and x/y becomes a delete. Applying that plan in the wrong order fails, because you cannot create file x while directory x exists. A real sync must process deletions first or handle the type change explicitly. It is a good edge case to mention without being asked.
Syncing artifacts, detecting config drift on servers and verifying backups are all tree comparisons. The interviewer looks for cheap checks before expensive ones, consistent path normalisation across operating systems, and a dry run before any destructive action.