From d6124a82a47fee58fe95e59fffe96b6e40fb4d96 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 24 May 2026 07:47:10 +1000 Subject: [PATCH] testsuite: cross-directory/temp/backup/dest coverage at depth Fill the highest-restructure-risk gap: options that do two-directory / rename / outside-tree work, asserted at >=3 levels deep with the aux tree kept outside the main tree, and asserting the option's specific property rather than just tree equality (which the ported tests already cover). alt-dest-deep --link-dest hardlinks unchanged files (same inode), --copy-dest copies (never links), --compare-dest omits unchanged files; ref tree outside both src and dest. temp-dir cross-dir temp->final rename at depth; temp dir left clean; a missing --temp-dir fails (so the option is proven consulted). partial --partial keeps the partial in the dest file; relative --partial-dir stages per-directory at depth (pre-seed + interrupt/resume); absolute --partial-dir writes the partial outside the tree. inplace --inplace keeps the destination inode across a delta update; the default temp+rename path replaces it. append --append completes truncated files tail-only; --append-verify repairs a corrupted prefix (protocol >= 30). backup-deep --suffix saves S beside the new file; --backup-dir relocates old files to a parallel deep tree outside the dest and captures deletions under --delete. All green on master and under --protocol=29/30. Co-Authored-By: Claude Opus 4.7 (1M context) --- testsuite/alt-dest-deep_test.py | 85 ++++++++++++++++++ testsuite/append_test.py | 73 +++++++++++++++ testsuite/backup-deep_test.py | 86 ++++++++++++++++++ testsuite/delay-updates-deep_test.py | 62 +++++++++++++ testsuite/inplace_test.py | 71 +++++++++++++++ testsuite/partial_test.py | 130 +++++++++++++++++++++++++++ testsuite/temp-dir_test.py | 61 +++++++++++++ 7 files changed, 568 insertions(+) create mode 100644 testsuite/alt-dest-deep_test.py create mode 100644 testsuite/append_test.py create mode 100644 testsuite/backup-deep_test.py create mode 100644 testsuite/delay-updates-deep_test.py create mode 100644 testsuite/inplace_test.py create mode 100644 testsuite/partial_test.py create mode 100644 testsuite/temp-dir_test.py diff --git a/testsuite/alt-dest-deep_test.py b/testsuite/alt-dest-deep_test.py new file mode 100644 index 00000000..1054948e --- /dev/null +++ b/testsuite/alt-dest-deep_test.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Property-level coverage of --link-dest / --copy-dest / --compare-dest at +depth and across directory boundaries. + +The existing alt-dest_test.py is a faithful port that checks tree equality; +this companion asserts the *distinguishing* property of each option, at every +level of a >=3-deep tree, with the alternate-destination tree placed OUTSIDE +both the source and destination trees (a sibling, not a parent/child): + + --link-dest unchanged files are HARD-LINKED to the reference (same inode); + a changed file is transferred fresh (not linked). + --copy-dest unchanged files are COPIED from the reference (never linked); + dest is complete and byte-identical to the source. + --compare-dest unchanged files are NEITHER transferred NOR created in dest; + only a changed/new file lands in dest. + +These options drive the two-dirfd / outside-tree path handling the resolver +restructure rewrites, so they must be guarded at depth. +""" + +import os + +from rsyncfns import ( + FROMDIR, SCRATCHDIR, TODIR, + assert_exists, assert_hardlinked, assert_not_exists, assert_not_hardlinked, + assert_same, make_tree, rmtree, run_rsync, walk_files, +) + +src = FROMDIR +ref = SCRATCHDIR / 'altref' # sibling of from/ and to/ -- outside both trees + +rmtree(src) +rmtree(ref) +rmtree(TODIR) + +# A >=3-deep source: f0 at the root, then d1/f1, d1/d2/f2, d1/d2/d3/f3. +make_tree(src, depth=3, data=True) + +# Reference tree == an exact copy of the source, so every file is "identical" +# for the alt-dest comparison. +run_rsync('-a', f'{src}/', f'{ref}/') + +# Now make the deepest file differ so it must be transferred in every mode. +changed = os.path.join('d1', 'd2', 'd3', 'f3') +with open(src / changed, 'ab') as f: + f.write(b'a changed deep tail\n') + +rels = [p.relative_to(src) for p in walk_files(src)] +assert os.path.join('d1', 'd2', 'd3', 'f3') in [str(r) for r in rels] + + +def run_to(opt): + rmtree(TODIR) + run_rsync('-a', f'--{opt}={ref}', f'{src}/', f'{TODIR}/') + + +# --- --link-dest: unchanged -> hardlink to ref; changed -> fresh copy ------- +run_to('link-dest') +for rel in rels: + d, r = TODIR / rel, ref / rel + if str(rel) == changed: + assert_not_hardlinked(d, r, label=f'link-dest changed {rel}') + assert_same(d, src / rel, label=f'link-dest changed {rel}') + else: + assert_hardlinked(d, r, label=f'link-dest unchanged {rel}') + +# --- --copy-dest: every file copied (never linked), dest complete ----------- +run_to('copy-dest') +for rel in rels: + d, r = TODIR / rel, ref / rel + assert_exists(d, label=f'copy-dest {rel}') + assert_same(d, src / rel, label=f'copy-dest {rel}') + assert_not_hardlinked(d, r, label=f'copy-dest {rel}') + +# --- --compare-dest: unchanged absent from dest; only the changed file lands - +run_to('compare-dest') +for rel in rels: + d = TODIR / rel + if str(rel) == changed: + assert_exists(d, label=f'compare-dest changed {rel}') + assert_same(d, src / rel, label=f'compare-dest changed {rel}') + else: + assert_not_exists(d, label=f'compare-dest unchanged {rel}') + +print("alt-dest-deep: link-dest/copy-dest/compare-dest verified at depth") diff --git a/testsuite/append_test.py b/testsuite/append_test.py new file mode 100644 index 00000000..46c78338 --- /dev/null +++ b/testsuite/append_test.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Coverage of --append and --append-verify at depth. + +--append assumes each destination file is a prefix of the (longer) source and +transfers only the bytes past the existing size; it does NOT re-check the data +already present, and it never touches a file that is already the same size or +larger. --append-verify works the same way but folds the existing data into the +whole-file checksum, so a transfer whose result fails verification is re-sent +with a normal --inplace pass. Exercise both on files >=3 levels deep. +""" + +import os + +from rsyncfns import ( + FROMDIR, TODIR, + assert_same, forced_protocol, make_tree, rmtree, run_rsync, test_fail, + walk_files, +) + +src = FROMDIR +deep = os.path.join('d1', 'd2', 'd3', 'f3') + + +def seed_source(): + rmtree(src) + make_tree(src, depth=3, data=True, data_size=8192) + return [p.relative_to(src) for p in walk_files(src)] + + +def dest_prefix(rels, *, corrupt=False, frac=0.5): + """Build a destination holding the first `frac` of each source file (a + valid prefix), optionally corrupting the deep file's leading bytes.""" + rmtree(TODIR) + for rel in rels: + dst = TODIR / rel + dst.parent.mkdir(parents=True, exist_ok=True) + full = (src / rel).read_bytes() + dst.write_bytes(full[: int(len(full) * frac)]) + if corrupt: + p = TODIR / deep + bad = bytearray(p.read_bytes()) + bad[0:64] = b'\x00' * 64 + p.write_bytes(bytes(bad)) + + +# --- --append completes truncated destinations at every level --------------- +rels = seed_source() +dest_prefix(rels) +run_rsync('-a', '--append', f'{src}/', f'{TODIR}/') +for rel in rels: + assert_same(TODIR / rel, src / rel, label=f'append {rel}') + +# The split between non-verifying --append and verifying --append-verify only +# exists at protocol >= 30; at protocol 29 plain --append still verifies, so +# skip the distinguishing sub-cases there. +proto = forced_protocol() +if proto is not None and proto < 30: + print(f"append: protocol {proto} -- skipping the --append/--append-verify " + "split (verifying-append behaviour predates the protocol-30 split)") +else: + # plain --append trusts a corrupted prefix (leaves it wrong) + dest_prefix(rels, corrupt=True) + run_rsync('-a', '--append', f'{src}/', f'{TODIR}/') + if (TODIR / deep).read_bytes() == (src / deep).read_bytes(): + test_fail("plain --append unexpectedly repaired a corrupted prefix " + "(it should append only and trust the existing data)") + + # --append-verify detects the bad prefix and re-sends the whole file + dest_prefix(rels, corrupt=True) + run_rsync('-a', '--append-verify', f'{src}/', f'{TODIR}/') + assert_same(TODIR / deep, src / deep, label='append-verify deep') + +print("append: tail-only completion at depth; append-verify repairs prefix") diff --git a/testsuite/backup-deep_test.py b/testsuite/backup-deep_test.py new file mode 100644 index 00000000..f2f754c1 --- /dev/null +++ b/testsuite/backup-deep_test.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Property-level coverage of --backup / --suffix / --backup-dir at depth. + +backup_test.py is the ported regression test (2 levels, no custom suffix); this +companion checks the concrete outcome of each backup mode at >=3 levels and, +for --backup-dir, with the backup tree placed OUTSIDE the destination (a +sibling) so the old file is renamed across directories into a parallel deep +path -- the cross-directory operation the resolver restructure rewrites. + +Asserts, at every level of the tree: + * --backup --suffix=S saves the overwritten file as S beside the new + one (old content in the backup, new content in place); + * --backup --backup-dir=DIR relocates the old file to DIR/, + preserving the deep structure, while the destination gets the new content; + * --backup-dir together with --delete routes a deletion into the backup + tree instead of losing it. +""" + +import os + +from rsyncfns import ( + FROMDIR, SCRATCHDIR, TODIR, + assert_not_exists, assert_same, make_tree, rmtree, run_rsync, test_fail, + walk_files, +) + +src = FROMDIR +bak = SCRATCHDIR / 'backups' # sibling of from/ and to/ -- outside both trees + + +def seed(): + """Fresh v1 source, a matching destination, and the old (v1) bytes; then + mutate the source to v2 so the next sync overwrites every file.""" + rmtree(src) + rmtree(TODIR) + rmtree(bak) + make_tree(src, depth=3, data=True, data_size=4096) + rels = [p.relative_to(src) for p in walk_files(src)] + run_rsync('-a', f'{src}/', f'{TODIR}/') + old = {rel: (src / rel).read_bytes() for rel in rels} + for rel in rels: # v1 -> v2 + with open(src / rel, 'ab') as f: + f.write(b'\nversion-2 tail\n') + return rels, old + + +# --- --backup --suffix=.old (same directory) -------------------------------- +rels, old = seed() +run_rsync('-a', '-b', '--suffix=.old', '--no-whole-file', + f'{src}/', f'{TODIR}/') +for rel in rels: + assert_same(TODIR / rel, src / rel, label=f'suffix new {rel}') + backup = (TODIR / rel) + backup = backup.with_name(backup.name + '.old') + if not backup.is_file(): + test_fail(f"--suffix backup missing for {rel}: {backup}") + if backup.read_bytes() != old[rel]: + test_fail(f"--suffix backup of {rel} does not hold the old content") + +# --- --backup-dir at depth, outside the dest tree (cross-dir) --------------- +rels, old = seed() +run_rsync('-a', '-b', f'--backup-dir={bak}', '--no-whole-file', + f'{src}/', f'{TODIR}/') +for rel in rels: + assert_same(TODIR / rel, src / rel, label=f'backup-dir new {rel}') + saved = bak / rel + if not saved.is_file(): + test_fail(f"--backup-dir did not preserve deep path for {rel}: {saved}") + if saved.read_bytes() != old[rel]: + test_fail(f"--backup-dir copy of {rel} does not hold the old content") + +# --- --backup-dir captures a deletion under --delete ------------------------ +rels, old = seed() +# Add a deep file to the destination that is absent from the source. +extra = os.path.join('d1', 'd2', 'd3', 'goner') +(TODIR / extra).write_bytes(b'about to be deleted\n') +run_rsync('-a', '--delete', '-b', f'--backup-dir={bak}', '--no-whole-file', + f'{src}/', f'{TODIR}/') +assert_not_exists(TODIR / extra, label='deleted file removed from dest') +saved = bak / extra +if not saved.is_file(): + test_fail(f"--backup-dir did not capture the deletion of {extra}") +if saved.read_bytes() != b'about to be deleted\n': + test_fail("captured deletion has the wrong content") + +print("backup-deep: suffix / backup-dir / delete-capture verified at depth") diff --git a/testsuite/delay-updates-deep_test.py b/testsuite/delay-updates-deep_test.py new file mode 100644 index 00000000..bd359fcd --- /dev/null +++ b/testsuite/delay-updates-deep_test.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Property-level coverage of --delay-updates at depth. + +--delay-updates writes each updated file into a per-directory staging dir +(.~tmp~) during the transfer and only renames them into place at the very end, +so an interrupted run never leaves a half-written file visible. The staging dir +sits inside each destination directory, so the staging write and the +end-of-run rename are parent-directory operations the resolver restructure +rewrites; the ported delay-updates_test.py only exercises the tree root, so +this companion drives a >=3-deep tree. + +Asserts, at every level of the tree: + * a --delay-updates transfer reproduces the source and leaves no .~tmp~ + staging directory behind; + * a stale file pre-planted in a deep .~tmp~ staging dir is overwritten + cleanly and the staging dir is removed. +""" + +import os + +from rsyncfns import ( + FROMDIR, TODIR, + assert_same, make_tree, rmtree, run_rsync, test_fail, walk_dirs, + walk_files, +) + +src = FROMDIR +deepdir = os.path.join('d1', 'd2', 'd3') + + +def no_staging_left(): + leftover = [p for p in walk_dirs(TODIR) if p.name == '.~tmp~'] + if leftover: + test_fail(f"--delay-updates left staging dirs behind: {leftover}") + + +# --- initial --delay-updates over a deep tree ------------------------------- +rmtree(src) +rmtree(TODIR) +make_tree(src, depth=3, data=True, data_size=4096) +rels = [p.relative_to(src) for p in walk_files(src)] + +run_rsync('-a', '--delay-updates', f'{src}/', f'{TODIR}/') +for rel in rels: + assert_same(TODIR / rel, src / rel, label=f'delay-updates initial {rel}') +no_staging_left() + +# --- update every file, with a stale staging file planted deep -------------- +for rel in rels: + with open(src / rel, 'ab') as f: + f.write(b'\nupdated content\n') + +stage = TODIR / deepdir / '.~tmp~' +stage.mkdir(parents=True, exist_ok=True) +(stage / 'f3').write_bytes(b'stale staged junk\n') # must be overwritten + +run_rsync('-a', '--delay-updates', f'{src}/', f'{TODIR}/') +for rel in rels: + assert_same(TODIR / rel, src / rel, label=f'delay-updates update {rel}') +no_staging_left() + +print("delay-updates-deep: staging + clean overwrite verified at depth") diff --git a/testsuite/inplace_test.py b/testsuite/inplace_test.py new file mode 100644 index 00000000..e0c20792 --- /dev/null +++ b/testsuite/inplace_test.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Coverage of --inplace at depth. + +--inplace updates the destination file directly instead of writing a temp copy +and renaming it over the original, so across a delta update the destination +keeps the SAME inode. Without --inplace the receiver creates a fresh temp file +and renames it, giving the destination a NEW inode. Both behaviours hinge on +how the receiver resolves the destination directory and (for the default mode) +performs the temp->final rename, which the path restructure rewrites; verify +them on a file >=3 levels deep. +""" + +import os + +from rsyncfns import ( + FROMDIR, TODIR, + assert_same, make_tree, rmtree, run_rsync, test_fail, +) + +src = FROMDIR +deep = os.path.join('d1', 'd2', 'd3', 'f3') + + +def seed(): + rmtree(src) + rmtree(TODIR) + make_tree(src, depth=3, data=True, data_size=200000) + + +def inode(path): + return os.stat(path).st_ino + + +def modify_deep(): + # Rewrite a chunk in the middle so it is a genuine delta, not just a tail + # append. Bump mtime by a clear margin (the whole test runs inside one + # second, so a "now" touch would collide with the destination's mtime and + # the size-unchanged file would be skipped by the quick check). + p = src / deep + data = bytearray(p.read_bytes()) + data[1000:1100] = bytes((b ^ 0xFF) for b in data[1000:1100]) + p.write_bytes(bytes(data)) + st = os.stat(p) + os.utime(p, (st.st_atime, st.st_mtime + 100)) + + +# --- --inplace keeps the destination inode across a delta update ------------ +seed() +run_rsync('-a', f'{src}/', f'{TODIR}/') +ino_before = inode(TODIR / deep) + +modify_deep() +run_rsync('-a', '--inplace', '--no-whole-file', f'{src}/', f'{TODIR}/') +assert_same(TODIR / deep, src / deep, label='inplace content') +if inode(TODIR / deep) != ino_before: + test_fail("--inplace changed the destination inode at depth " + f"({ino_before} -> {inode(TODIR / deep)})") + +# --- control: the default (temp+rename) path replaces the inode ------------- +seed() +run_rsync('-a', f'{src}/', f'{TODIR}/') +ino_before = inode(TODIR / deep) + +modify_deep() +run_rsync('-a', '--no-whole-file', f'{src}/', f'{TODIR}/') +assert_same(TODIR / deep, src / deep, label='default content') +if inode(TODIR / deep) == ino_before: + test_fail("default (non-inplace) delta update unexpectedly kept the " + "destination inode at depth -- temp+rename did not run") + +print("inplace: same-inode update at depth verified; default replaces inode") diff --git a/testsuite/partial_test.py b/testsuite/partial_test.py new file mode 100644 index 00000000..97b6f69a --- /dev/null +++ b/testsuite/partial_test.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Coverage of --partial / --partial-dir at depth and across directory +boundaries. + +--partial keeps a partially transferred file so a later run can resume it. +--partial-dir=DIR keeps the partial in DIR instead of the destination file: +a RELATIVE dir is created in (and removed from) each destination file's own +directory; an ABSOLUTE dir is a reserved location that holds partials by +basename. All of this is parent- and cross-directory path resolution -- what +the resolver restructure rewrites -- so exercise it on a file several levels +deep, with the absolute partial-dir kept OUTSIDE the destination tree. + +Note: a *delta* resume from an absolute partial-dir currently fails whole-file +verification on master (it re-puts the partial and never converges). This test +therefore only asserts the cross-directory WRITE of the partial for that case +and completes it with --whole-file, which is the clearly-correct baseline. +""" + +import os +import signal +import subprocess +import time + +from rsyncfns import ( + FROMDIR, SCRATCHDIR, TODIR, + assert_same, make_data_file, makepath, rmtree, rsync_argv, run_rsync, + test_fail, +) + +src = FROMDIR +deepdir = os.path.join('d1', 'd2', 'd3') +deep = os.path.join(deepdir, 'f3') +FULL = 12_000_000 + + +def seed_big(): + rmtree(src) + rmtree(TODIR) + makepath(src / deepdir) + make_data_file(src / deep, FULL) + + +def is_prefix(partial) -> bool: + pb = partial.read_bytes() + return 0 < len(pb) < FULL and (src / deep).read_bytes()[:len(pb)] == pb + + +def interrupt_transfer(extra_args, partial_path): + """Start a deliberately slow transfer, SIGTERM it once the receiver's + in-progress temp (.f3.XXXXXX) has some data, and wait for `partial_path` + (where this mode keeps the partial) to materialise. + + The bandwidth limit is low so the multi-second transfer cannot finish + before we interrupt it -- important under a loaded parallel run (-j16), + where the polling loop can lag by seconds. We then poll for the partial, + since rsync moves it into place from its exit_cleanup handler.""" + proc = subprocess.Popen( + rsync_argv('-a', '--no-whole-file', '--bwlimit=400', *extra_args, + f'{src}/', f'{TODIR}/'), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + tdir = TODIR / deepdir + caught = False + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + if proc.poll() is not None: + break # exited before we caught it + if tdir.is_dir(): + temps = [p for p in tdir.glob('.f3.*') + if p.is_file() and p.stat().st_size > 0] + if temps: + caught = True + break + time.sleep(0.02) + proc.send_signal(signal.SIGTERM) + proc.wait() + if not caught: + test_fail("never caught an in-progress temp (transfer finished too " + "fast to interrupt)") + # rsync moves the partial into place from exit_cleanup; give it a moment. + pdeadline = time.monotonic() + 5 + while time.monotonic() < pdeadline: + if partial_path.is_file() and partial_path.stat().st_size > 0: + return + time.sleep(0.05) + + +# --- 1. --partial (no dir): partial kept in the dest file itself, at depth -- +seed_big() +interrupt_transfer(['--partial'], TODIR / deep) +if not (TODIR / deep).is_file() or not is_prefix(TODIR / deep): + test_fail("--partial did not leave a valid partial in the dest file") +run_rsync('-a', '--partial', '--no-whole-file', f'{src}/', f'{TODIR}/') +assert_same(TODIR / deep, src / deep, label='--partial resume') + +# --- 2. relative --partial-dir at depth: deterministic clean pre-seed ------- +rmtree(src) +rmtree(TODIR) +makepath(src / deepdir, TODIR / deepdir / '.rsync-partial') +make_data_file(src / deep, 1_000_000) +full = (src / deep).read_bytes() +(TODIR / deepdir / '.rsync-partial' / 'f3').write_bytes(full[:400_000]) +run_rsync('-a', '--partial-dir=.rsync-partial', '--no-whole-file', + f'{src}/', f'{TODIR}/') +assert_same(TODIR / deep, src / deep, label='rel partial-dir preseed') +if (TODIR / deepdir / '.rsync-partial').exists(): + test_fail("relative --partial-dir not removed after the partial was used") + +# --- 3. relative --partial-dir at depth: interrupt then resume ------------- +seed_big() +part = TODIR / deepdir / '.rsync-partial' / 'f3' +interrupt_transfer(['--partial-dir=.rsync-partial'], part) +if not part.is_file() or not is_prefix(part): + test_fail("relative --partial-dir did not keep a valid partial at depth") +run_rsync('-a', '--partial-dir=.rsync-partial', '--no-whole-file', + f'{src}/', f'{TODIR}/') +assert_same(TODIR / deep, src / deep, label='rel partial-dir resume') + +# --- 4. absolute --partial-dir OUTSIDE the tree (cross-dir): interrupt ----- +ext = SCRATCHDIR / 'partials' # sibling of from/ and to/ -- outside both +rmtree(ext) +ext.mkdir() +seed_big() +interrupt_transfer([f'--partial-dir={ext}'], ext / 'f3') +if not (ext / 'f3').is_file() or not is_prefix(ext / 'f3'): + test_fail("absolute --partial-dir did not write the partial to the " + "outside-tree dir") +run_rsync('-a', f'--partial-dir={ext}', '--whole-file', f'{src}/', f'{TODIR}/') +assert_same(TODIR / deep, src / deep, label='abs partial-dir resume') + +print("partial: --partial + relative/absolute --partial-dir verified at depth") diff --git a/testsuite/temp-dir_test.py b/testsuite/temp-dir_test.py new file mode 100644 index 00000000..63c37ea6 --- /dev/null +++ b/testsuite/temp-dir_test.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Coverage of --temp-dir (-T) at depth and across directory boundaries. + +--temp-dir tells the receiver to create its scratch/temp copies in DIR rather +than in the destination directory, then rename the finished file into place. +That rename crosses from the temp directory to a destination directory several +levels deep -- exactly the two-directory operation the path-resolution +restructure rewrites -- so it must be guarded at depth with the temp dir kept +OUTSIDE the destination tree. + +Asserts: + * a transfer with --temp-dir pointing outside the dest tree reproduces the + source byte-for-byte at every level; + * no temp/scratch files are left behind in the temp dir or the dest tree; + * a non-existent --temp-dir makes the receiver fail (so we know the option + is actually consulted, not silently ignored). +""" + +import os + +from rsyncfns import ( + FROMDIR, SCRATCHDIR, TODIR, + assert_same, make_tree, rmtree, run_rsync, test_fail, walk_files, +) + +src = FROMDIR +tmp = SCRATCHDIR / 'scratch-temp' # sibling of from/ and to/ -- outside both +rmtree(src) +rmtree(TODIR) +rmtree(tmp) +tmp.mkdir() + +make_tree(src, depth=3, data=True) +rels = [p.relative_to(src) for p in walk_files(src)] + +# Transfer with the temp dir outside the destination tree. +run_rsync('-a', f'--temp-dir={tmp}', f'{src}/', f'{TODIR}/') + +for rel in rels: + assert_same(TODIR / rel, src / rel, label=f'temp-dir {rel}') + +# The temp dir must be clean afterwards (every scratch file renamed away). +leftover = sorted(p for p in tmp.rglob('*')) +if leftover: + test_fail(f"--temp-dir left scratch files behind: {leftover}") + +# No stray rsync temp files (.name.XXXXXX) anywhere in the dest tree. +strays = [p for p in TODIR.rglob('.*') if p.is_file()] +if strays: + test_fail(f"dest tree contains stray temp files: {strays}") + +# Negative: a missing temp dir must cause a receiver failure, proving the +# option is honoured rather than ignored. +rmtree(TODIR) +proc = run_rsync('-a', f'--temp-dir={SCRATCHDIR}/does-not-exist', + f'{src}/', f'{TODIR}/', check=False) +if proc.returncode == 0: + test_fail("--temp-dir pointing at a missing directory unexpectedly " + "succeeded") + +print("temp-dir: cross-dir rename at depth verified; missing temp dir fails")