From 922681e140afbf19386185bbf4ed129e3acc1aa8 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Sun, 24 May 2026 07:55:45 +1000 Subject: [PATCH] testsuite: filtering coverage at depth Assert exactly which entries are/aren't transferred, deep in the tree: filter-depth --exclude/--include precedence on files at every level, and a -F per-directory .rsync-filter loaded from a deep dir that applies to that subtree only (not above it). cvs-exclude -C built-in cruft patterns (*.o, *~) at every level plus a deep per-directory .cvsignore scoped to its subtree. size-filter --max-size / --min-size select the right files all the way down. files-from-depth --files-from selects only the listed deep paths (implied parents created); --from0 NUL-delimited; --exclude-from / --include-from filter at depth. (--existing / --ignore-existing are covered in delete-deep_test.py.) Green on master and under --protocol=29/30. Co-Authored-By: Claude Opus 4.7 (1M context) --- testsuite/cvs-exclude_test.py | 46 +++++++++++++++++++ testsuite/files-from-depth_test.py | 68 ++++++++++++++++++++++++++++ testsuite/filter-depth_test.py | 71 ++++++++++++++++++++++++++++++ testsuite/size-filter_test.py | 50 +++++++++++++++++++++ 4 files changed, 235 insertions(+) create mode 100644 testsuite/cvs-exclude_test.py create mode 100644 testsuite/files-from-depth_test.py create mode 100644 testsuite/filter-depth_test.py create mode 100644 testsuite/size-filter_test.py diff --git a/testsuite/cvs-exclude_test.py b/testsuite/cvs-exclude_test.py new file mode 100644 index 00000000..3416d5ce --- /dev/null +++ b/testsuite/cvs-exclude_test.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Coverage of -C / --cvs-exclude at depth. + +-C ignores the usual CVS cruft (object files, core, editor backups, VCS dirs, +...) and also honours a per-directory .cvsignore. Verify both the built-in +patterns and a deep .cvsignore on a >=3-level tree. +""" + +from rsyncfns import ( + FROMDIR, TODIR, + assert_exists, assert_not_exists, makepath, rmtree, run_rsync, +) + +src = FROMDIR +rmtree(src) +rmtree(TODIR) +makepath(src / 'd1' / 'd2' / 'd3') + +# A real file plus default-CVS-cruft at every level. +cur = src +for lvl in range(4): + (cur / f'real{lvl}.c').write_text('code\n') + (cur / f'obj{lvl}.o').write_text('obj\n') # *.o is built-in cruft + (cur / f'back{lvl}~').write_text('backup\n') # *~ is built-in cruft + cur = cur / f'd{lvl + 1}' + +# A per-directory .cvsignore deep in the tree adds "*.junk" for that subtree. +(src / 'd1' / 'd2' / '.cvsignore').write_text('*.junk\n') +(src / 'd1' / 'd2' / 'local.junk').write_text('j\n') +(src / 'top.junk').write_text('j\n') # not covered by that .cvsignore + +run_rsync('-aC', f'{src}/', f'{TODIR}/') + +cur = TODIR +for lvl in range(4): + assert_exists(cur / f'real{lvl}.c', label=f'-C kept real L{lvl}') + assert_not_exists(cur / f'obj{lvl}.o', label=f'-C dropped *.o L{lvl}') + assert_not_exists(cur / f'back{lvl}~', label=f'-C dropped *~ L{lvl}') + cur = cur / f'd{lvl + 1}' + +# .cvsignore is scoped to its directory subtree. +assert_not_exists(TODIR / 'd1' / 'd2' / 'local.junk', + label='-C deep .cvsignore applied') +assert_exists(TODIR / 'top.junk', label='-C deep .cvsignore not applied above') + +print("cvs-exclude: built-in patterns + deep .cvsignore honoured at depth") diff --git a/testsuite/files-from-depth_test.py b/testsuite/files-from-depth_test.py new file mode 100644 index 00000000..beaa8575 --- /dev/null +++ b/testsuite/files-from-depth_test.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Coverage of --files-from, -0/--from0, --exclude-from, --include-from at depth. + +--files-from selects exactly the listed source-relative paths (creating their +implied parent dirs); --from0 makes the list NUL-delimited; --exclude-from / +--include-from read filter patterns from a file. All resolve names several +levels deep. +""" + +from rsyncfns import ( + FROMDIR, SCRATCHDIR, TODIR, + assert_exists, assert_not_exists, assert_same, make_tree, rmtree, + run_rsync, +) + +src = FROMDIR +listed = ['d1/f1', 'd1/d2/d3/f3'] +unlisted = ['f0', 'd1/d2/f2'] + + +def seed(): + rmtree(src) + rmtree(TODIR) + make_tree(src, depth=3) + + +# --- --files-from selects only the listed deep paths ------------------------ +seed() +lf = SCRATCHDIR / 'files.lst' +lf.write_text('\n'.join(listed) + '\n') +run_rsync('-a', f'--files-from={lf}', f'{src}/', f'{TODIR}/') +for rel in listed: + assert_same(TODIR / rel, src / rel, label=f'--files-from {rel}') +for rel in unlisted: + assert_not_exists(TODIR / rel, label=f'--files-from excluded {rel}') + +# --- --from0: the same list, NUL-delimited ---------------------------------- +rmtree(TODIR) +lf0 = SCRATCHDIR / 'files0.lst' +lf0.write_bytes(b'\0'.join(p.encode() for p in listed) + b'\0') +run_rsync('-a', '--from0', f'--files-from={lf0}', f'{src}/', f'{TODIR}/') +for rel in listed: + assert_same(TODIR / rel, src / rel, label=f'--from0 {rel}') +for rel in unlisted: + assert_not_exists(TODIR / rel, label=f'--from0 excluded {rel}') + +# --- --exclude-from drops matching files at depth --------------------------- +seed() +(src / 'a.skip').write_text('s\n') +(src / 'd1' / 'd2' / 'a.skip').write_text('s\n') +ef = SCRATCHDIR / 'excl.lst' +ef.write_text('*.skip\n') +run_rsync('-a', f'--exclude-from={ef}', f'{src}/', f'{TODIR}/') +assert_not_exists(TODIR / 'a.skip', label='--exclude-from top') +assert_not_exists(TODIR / 'd1' / 'd2' / 'a.skip', label='--exclude-from deep') +assert_same(TODIR / 'd1' / 'd2' / 'f2', src / 'd1' / 'd2' / 'f2', + label='--exclude-from kept others') + +# --- --include-from keeps only matching files at depth ---------------------- +seed() +(src / 'd1' / 'd2' / 'k.keepme').write_text('k\n') +inc = SCRATCHDIR / 'inc.lst' +inc.write_text('*/\n*.keepme\n') +run_rsync('-a', f'--include-from={inc}', '--exclude=*', f'{src}/', f'{TODIR}/') +assert_exists(TODIR / 'd1' / 'd2' / 'k.keepme', label='--include-from kept') +assert_not_exists(TODIR / 'd1' / 'd2' / 'f2', label='--include-from excluded rest') + +print("files-from-depth: --files-from/--from0/--exclude-from/--include-from at depth") diff --git a/testsuite/filter-depth_test.py b/testsuite/filter-depth_test.py new file mode 100644 index 00000000..9e4c99f0 --- /dev/null +++ b/testsuite/filter-depth_test.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Coverage of --exclude / --include / --filter / -F at depth. + +The interesting case for the resolver restructure is a per-directory merge file +(-F reads .rsync-filter from each directory as it descends): the rule set is +loaded from a file several levels deep and must apply to that directory and +below, but not above. Also check plain --exclude / --include precedence on +files spread through the tree. +""" + +import os + +from rsyncfns import ( + FROMDIR, TODIR, + assert_exists, assert_not_exists, assert_same, makepath, rmtree, run_rsync, +) + +src = FROMDIR + + +def seed_ext(): + """A tree with a .log and a .txt at every level.""" + rmtree(src) + rmtree(TODIR) + cur = src + for lvl in range(4): + cur.mkdir(parents=True, exist_ok=True) + (cur / f'keep{lvl}.txt').write_text(f'txt {lvl}\n') + (cur / f'drop{lvl}.log').write_text(f'log {lvl}\n') + cur = cur / f'd{lvl + 1}' + + +# --- --exclude drops matching files at every level -------------------------- +seed_ext() +run_rsync('-a', '--exclude=*.log', f'{src}/', f'{TODIR}/') +cur = TODIR +for lvl in range(4): + assert_exists(cur / f'keep{lvl}.txt', label=f'--exclude kept txt L{lvl}') + assert_not_exists(cur / f'drop{lvl}.log', label=f'--exclude dropped log L{lvl}') + cur = cur / f'd{lvl + 1}' + +# --- --include before --exclude='*' keeps only .txt at every level ---------- +seed_ext() +run_rsync('-a', '--include=*/', '--include=*.txt', '--exclude=*', + f'{src}/', f'{TODIR}/') +cur = TODIR +for lvl in range(4): + assert_exists(cur / f'keep{lvl}.txt', label=f'--include txt L{lvl}') + assert_not_exists(cur / f'drop{lvl}.log', label=f'--include excluded log L{lvl}') + cur = cur / f'd{lvl + 1}' + +# --- -F per-directory merge file loaded from a deep directory --------------- +# .rsync-filter at d1/d2 excludes "secret*" for d1/d2 and below only. +rmtree(src) +rmtree(TODIR) +makepath(src / 'd1' / 'd2' / 'd3') +for rel in ('secret.top', 'd1/secret.mid', 'd1/d2/secret.deep', + 'd1/d2/d3/secret.deeper'): + (src / rel).write_text('x\n') +(src / 'd1' / 'd2' / '.rsync-filter').write_text('- secret*\n') + +run_rsync('-aF', f'{src}/', f'{TODIR}/') +# Above the merge file: not affected. +assert_exists(TODIR / 'secret.top', label='-F above merge dir') +assert_exists(TODIR / 'd1' / 'secret.mid', label='-F above merge dir') +# At and below the merge file: excluded. +assert_not_exists(TODIR / 'd1' / 'd2' / 'secret.deep', label='-F at merge dir') +assert_not_exists(TODIR / 'd1' / 'd2' / 'd3' / 'secret.deeper', + label='-F below merge dir') + +print("filter-depth: --exclude/--include precedence and -F per-dir merge at depth") diff --git a/testsuite/size-filter_test.py b/testsuite/size-filter_test.py new file mode 100644 index 00000000..ce528689 --- /dev/null +++ b/testsuite/size-filter_test.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Coverage of --max-size / --min-size at depth. + +A small and a large file at every level; --max-size must transfer only the +small ones and --min-size only the large ones, the selection holding all the +way down the tree. +""" + +import os + +from rsyncfns import ( + FROMDIR, TODIR, + assert_exists, assert_not_exists, make_data_file, rmtree, run_rsync, +) + +src = FROMDIR +SMALL = 500 +LARGE = 5000 + + +def seed(): + rmtree(src) + rmtree(TODIR) + cur = src + for lvl in range(4): + cur.mkdir(parents=True, exist_ok=True) + make_data_file(cur / f'small{lvl}', SMALL) + make_data_file(cur / f'large{lvl}', LARGE) + cur = cur / f'd{lvl + 1}' + + +# --- --max-size keeps only the small files at every level ------------------- +seed() +run_rsync('-a', '--max-size=1000', f'{src}/', f'{TODIR}/') +cur = TODIR +for lvl in range(4): + assert_exists(cur / f'small{lvl}', label=f'--max-size kept small L{lvl}') + assert_not_exists(cur / f'large{lvl}', label=f'--max-size dropped large L{lvl}') + cur = cur / f'd{lvl + 1}' + +# --- --min-size keeps only the large files at every level ------------------- +seed() +run_rsync('-a', '--min-size=1000', f'{src}/', f'{TODIR}/') +cur = TODIR +for lvl in range(4): + assert_exists(cur / f'large{lvl}', label=f'--min-size kept large L{lvl}') + assert_not_exists(cur / f'small{lvl}', label=f'--min-size dropped small L{lvl}') + cur = cur / f'd{lvl + 1}' + +print("size-filter: --max-size / --min-size select correctly at depth")