]> git.ipfire.org Git - thirdparty/rsync.git/commitdiff
testsuite: filtering coverage at depth
authorAndrew Tridgell <andrew@tridgell.net>
Sat, 23 May 2026 21:55:45 +0000 (07:55 +1000)
committerAndrew Tridgell <andrew@tridgell.net>
Sun, 24 May 2026 02:31:52 +0000 (12:31 +1000)
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) <noreply@anthropic.com>
testsuite/cvs-exclude_test.py [new file with mode: 0644]
testsuite/files-from-depth_test.py [new file with mode: 0644]
testsuite/filter-depth_test.py [new file with mode: 0644]
testsuite/size-filter_test.py [new file with mode: 0644]

diff --git a/testsuite/cvs-exclude_test.py b/testsuite/cvs-exclude_test.py
new file mode 100644 (file)
index 0000000..3416d5c
--- /dev/null
@@ -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 (file)
index 0000000..beaa857
--- /dev/null
@@ -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 (file)
index 0000000..9e4c99f
--- /dev/null
@@ -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 (file)
index 0000000..ce52868
--- /dev/null
@@ -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")