]> git.ipfire.org Git - thirdparty/rsync.git/commitdiff
testsuite: structure / recursion / link coverage at depth
authorAndrew Tridgell <andrew@tridgell.net>
Sat, 23 May 2026 21:51:19 +0000 (07:51 +1000)
committerAndrew Tridgell <andrew@tridgell.net>
Sun, 24 May 2026 02:31:52 +0000 (12:31 +1000)
Cover the structure and link options at >=3 levels and across directories,
asserting each option's specific effect:

  links            -l keeps a symlink, -L dereferences it, -k follows a
                   directory symlink -- all on a symlink several levels deep.
  dirs             -d copies the top layer (file + empty dir) without recursing.
  prune-empty-dirs -m drops empty chains and chains emptied by an exclude,
                   keeps populated ones.
  hardlinks-deep   -H preserves a hard link whose names live in different
                   directories at depth; without -H they become separate inodes.
  delete-deep      --delete removes a deep extraneous file/subtree; the four
                   delete-timing variants agree; --max-delete caps deletions;
                   --existing / --ignore-existing select/skip correctly.
  relative-implied -R mirrors an implied directory's mode at depth;
                   --no-implied-dirs does not (proto 30+).

Green on master and under --protocol=29/30 (the --no-implied-dirs sub-case is
gated to protocol >= 30, where multi-component sender paths are accepted).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
testsuite/delete-deep_test.py [new file with mode: 0644]
testsuite/dirs_test.py [new file with mode: 0644]
testsuite/hardlinks-deep_test.py [new file with mode: 0644]
testsuite/links_test.py [new file with mode: 0644]
testsuite/prune-empty-dirs_test.py [new file with mode: 0644]
testsuite/relative-implied_test.py [new file with mode: 0644]

diff --git a/testsuite/delete-deep_test.py b/testsuite/delete-deep_test.py
new file mode 100644 (file)
index 0000000..3525c2e
--- /dev/null
@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+"""Coverage of the --delete family at depth, plus --max-delete, --existing and
+--ignore-existing.
+
+delete_test.py covers --del dry-run output, --remove-source-files and per-dir
+protect filters; this companion asserts the concrete outcome of deletion deep
+in the tree (the subtree walk the resolver restructure touches) and the
+controls that bound or invert it.
+"""
+
+import os
+
+from rsyncfns import (
+    FROMDIR, TODIR,
+    assert_not_exists, assert_same, make_tree, makepath, rmtree, run_rsync,
+    test_fail, walk_files,
+)
+
+src = FROMDIR
+
+
+def seed_src():
+    rmtree(src)
+    make_tree(src, depth=3)
+    return [p.relative_to(src) for p in walk_files(src)]
+
+
+def fresh_dest():
+    rmtree(TODIR)
+    run_rsync('-a', f'{src}/', f'{TODIR}/')
+
+
+# --- --delete removes a deep extraneous file and subtree --------------------
+rels = seed_src()
+fresh_dest()
+makepath(TODIR / 'd1' / 'd2' / 'extra')
+(TODIR / 'd1' / 'd2' / 'extra' / 'junk').write_text("x\n")
+(TODIR / 'd1' / 'd2' / 'orphan').write_text("y\n")
+run_rsync('-a', '--delete', f'{src}/', f'{TODIR}/')
+assert_not_exists(TODIR / 'd1' / 'd2' / 'extra', label='--delete deep dir')
+assert_not_exists(TODIR / 'd1' / 'd2' / 'orphan', label='--delete deep file')
+for rel in rels:
+    assert_same(TODIR / rel, src / rel, label=f'--delete kept {rel}')
+
+# --- every delete-timing variant yields the same deep deletion --------------
+for variant in ('--delete-before', '--delete-during', '--delete-delay',
+                '--delete-after'):
+    fresh_dest()
+    (TODIR / 'd1' / 'd2' / 'gone.txt').write_text("z\n")
+    run_rsync('-a', variant, f'{src}/', f'{TODIR}/')
+    assert_not_exists(TODIR / 'd1' / 'd2' / 'gone.txt', label=f'{variant} deep')
+
+# --- --max-delete caps the number of deletions ------------------------------
+fresh_dest()
+for i in range(5):
+    (TODIR / f'extra{i}').write_text("e\n")
+run_rsync('-a', '--delete', '--max-delete=2', f'{src}/', f'{TODIR}/',
+          check=False)            # rsync exits 25 when the limit is hit
+remaining = list(TODIR.glob('extra*'))
+if len(remaining) != 3:
+    test_fail(f"--max-delete=2 should leave 3 of 5 extras, found "
+              f"{len(remaining)}: {remaining}")
+
+# --- --existing only updates files already present (creates nothing) ---------
+seed_src()
+rmtree(TODIR)
+makepath(TODIR / 'd1')
+(TODIR / 'd1' / 'f1').write_text("old\n")
+run_rsync('-a', '--existing', f'{src}/', f'{TODIR}/')
+assert_same(TODIR / 'd1' / 'f1', src / 'd1' / 'f1',
+            label='--existing updated existing deep file')
+assert_not_exists(TODIR / 'f0', label='--existing did not create new top file')
+assert_not_exists(TODIR / 'd1' / 'd2', label='--existing did not create new dir')
+
+# --- --ignore-existing skips present files, creates the rest -----------------
+seed_src()
+rmtree(TODIR)
+makepath(TODIR / 'd1')
+(TODIR / 'd1' / 'f1').write_text("KEEP THIS\n")
+run_rsync('-a', '--ignore-existing', f'{src}/', f'{TODIR}/')
+if (TODIR / 'd1' / 'f1').read_text() != "KEEP THIS\n":
+    test_fail("--ignore-existing overwrote an existing deep file")
+assert_same(TODIR / 'f0', src / 'f0', label='--ignore-existing created new file')
+
+print("delete-deep: delete family, max-delete, existing/ignore-existing at depth")
diff --git a/testsuite/dirs_test.py b/testsuite/dirs_test.py
new file mode 100644 (file)
index 0000000..3772734
--- /dev/null
@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+"""Coverage of -d / --dirs: transfer directories without recursing into them.
+
+-d copies the entries directly named (or directly inside a trailing-slash
+source): regular files at the top level are copied, and a top-level directory
+is created as an empty directory -- its contents are NOT transferred. Verify
+that on a tree that is several levels deep, only the top layer materialises.
+"""
+
+from rsyncfns import (
+    FROMDIR, TODIR,
+    assert_same, make_tree, rmtree, run_rsync, test_fail,
+)
+
+src = FROMDIR
+rmtree(src)
+rmtree(TODIR)
+make_tree(src, depth=3)   # f0 at root; d1/{f1, d2/{f2, d3/f3}}
+
+run_rsync('-d', f'{src}/', f'{TODIR}/')
+
+# The top-level file is copied.
+assert_same(TODIR / 'f0', src / 'f0', label='-d top-level file')
+# The top-level directory is created...
+if not (TODIR / 'd1').is_dir():
+    test_fail("-d did not create the top-level directory")
+# ...but NOT recursed into.
+if (TODIR / 'd1' / 'f1').exists():
+    test_fail("-d recursed into a directory (f1 should not exist)")
+if list((TODIR / 'd1').iterdir()):
+    test_fail("-d populated the directory; it should be empty")
+
+print("dirs: -d copies the top layer without recursing")
diff --git a/testsuite/hardlinks-deep_test.py b/testsuite/hardlinks-deep_test.py
new file mode 100644 (file)
index 0000000..def0aa2
--- /dev/null
@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+"""Coverage of -H across directory boundaries.
+
+hardlinks_test.py exercises -H on sibling files at the tree root; this
+companion checks that -H preserves a hard link whose two names live in
+DIFFERENT directories several levels deep (the cross-directory case the
+resolver restructure touches), and that without -H the names become
+independent inodes.
+"""
+
+from rsyncfns import (
+    FROMDIR, TODIR,
+    assert_hardlinked, assert_not_hardlinked, makepath, rmtree, run_rsync,
+)
+import os
+
+src = FROMDIR
+a = os.path.join('a', 'aa', 'orig')
+b = os.path.join('b', 'bb', 'hardlink')
+
+rmtree(src)
+rmtree(TODIR)
+makepath(src / 'a' / 'aa', src / 'b' / 'bb')
+(src / a).write_text("shared content across directories\n")
+os.link(src / a, src / b)            # one inode, two names in different dirs
+
+# -H preserves the cross-directory hard link.
+run_rsync('-aH', f'{src}/', f'{TODIR}/')
+assert_hardlinked(TODIR / a, TODIR / b, label='-H cross-dir hardlink')
+
+# Without -H the two names are copied as independent files.
+rmtree(TODIR)
+run_rsync('-a', f'{src}/', f'{TODIR}/')
+assert_not_hardlinked(TODIR / a, TODIR / b, label='no -H => separate inodes')
+
+print("hardlinks-deep: -H preserves a cross-directory hard link at depth")
diff --git a/testsuite/links_test.py b/testsuite/links_test.py
new file mode 100644 (file)
index 0000000..f7b412d
--- /dev/null
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+"""Coverage of symlink-handling options -l / -L / -k at depth.
+
+  -l (--links)         copy a symlink as a symlink (target preserved);
+  -L (--copy-links)    replace a symlink with the file/dir it points to;
+  -k (--copy-dirlinks) treat a symlink-to-directory as the real directory.
+
+(-K --keep-dirlinks -- the receiver-side follow of an in-tree directory
+symlink, issue #715 -- is covered by symlink-dirlink-basis_test.py and needs
+the secure resolver; here we cover the portable source-side options, all on a
+symlink that lives several directory levels deep.)
+"""
+
+import os
+
+from rsyncfns import (
+    FROMDIR, TODIR,
+    assert_is_symlink, assert_same, make_tree, rmtree, run_rsync, test_fail,
+)
+
+src = FROMDIR
+deepdir = os.path.join('d1', 'd2', 'd3')
+
+
+def seed():
+    rmtree(src)
+    rmtree(TODIR)
+    make_tree(src, depth=3)
+    os.symlink('f3', src / deepdir / 'sl')          # file symlink, deep
+    (src / deepdir / 'realdir').mkdir()
+    (src / deepdir / 'realdir' / 'inside').write_text("inside\n")
+    os.symlink('realdir', src / deepdir / 'dirlink')  # dir symlink, deep
+
+
+# --- -l: symlinks preserved as symlinks at depth ----------------------------
+seed()
+run_rsync('-rl', f'{src}/', f'{TODIR}/')
+assert_is_symlink(TODIR / deepdir / 'sl', target='f3', label='-l file symlink')
+assert_is_symlink(TODIR / deepdir / 'dirlink', target='realdir',
+                  label='-l dir symlink')
+
+# --- -L: symlinks dereferenced into their referents at depth -----------------
+seed()
+run_rsync('-rL', f'{src}/', f'{TODIR}/')
+if os.path.islink(TODIR / deepdir / 'sl'):
+    test_fail("-L left a file symlink at depth instead of dereferencing it")
+assert_same(TODIR / deepdir / 'sl', src / deepdir / 'f3', label='-L deref file')
+if os.path.islink(TODIR / deepdir / 'dirlink'):
+    test_fail("-L left a dir symlink at depth instead of dereferencing it")
+assert_same(TODIR / deepdir / 'dirlink' / 'inside',
+            src / deepdir / 'realdir' / 'inside', label='-L deref dir')
+
+# --- -k: only the dir-symlink is followed; the file symlink stays a symlink --
+seed()
+run_rsync('-rlk', f'{src}/', f'{TODIR}/')
+if os.path.islink(TODIR / deepdir / 'dirlink'):
+    test_fail("-k left the dir symlink as a symlink")
+if not (TODIR / deepdir / 'dirlink').is_dir():
+    test_fail("-k did not turn the dir symlink into a real directory")
+assert_same(TODIR / deepdir / 'dirlink' / 'inside',
+            src / deepdir / 'realdir' / 'inside', label='-k dir contents')
+assert_is_symlink(TODIR / deepdir / 'sl', target='f3',
+                  label='-k keeps the file symlink')
+
+print("links: -l preserves, -L dereferences, -k follows dir-symlinks (at depth)")
diff --git a/testsuite/prune-empty-dirs_test.py b/testsuite/prune-empty-dirs_test.py
new file mode 100644 (file)
index 0000000..c53adfb
--- /dev/null
@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+"""Coverage of -m / --prune-empty-dirs at depth.
+
+--prune-empty-dirs drops directory chains that would end up empty at the
+destination -- both chains that are empty in the source and chains that become
+empty because a filter excluded their only files. Populated chains are kept.
+"""
+
+from rsyncfns import (
+    FROMDIR, TODIR,
+    assert_not_exists, assert_same, makepath, rmtree, run_rsync,
+)
+
+src = FROMDIR
+
+
+def reseed():
+    rmtree(src)
+    rmtree(TODIR)
+
+
+# --- a deep empty chain is pruned; a deep populated chain is kept ------------
+reseed()
+makepath(src / 'empty' / 'e1' / 'e2', src / 'full' / 'd1' / 'd2')
+(src / 'full' / 'd1' / 'd2' / 'file').write_text("data\n")
+
+run_rsync('-a', '-m', f'{src}/', f'{TODIR}/')
+assert_not_exists(TODIR / 'empty', label='-m pruned an empty chain')
+assert_same(TODIR / 'full' / 'd1' / 'd2' / 'file',
+            src / 'full' / 'd1' / 'd2' / 'file', label='-m kept populated chain')
+
+# --- a chain emptied by an exclude filter is also pruned --------------------
+reseed()
+makepath(src / 'mixed' / 'sub', src / 'onlylogs' / 'sub')
+(src / 'mixed' / 'sub' / 'keep.txt').write_text("k\n")
+(src / 'mixed' / 'sub' / 'drop.log').write_text("d\n")
+(src / 'onlylogs' / 'sub' / 'a.log').write_text("a\n")
+
+run_rsync('-a', '-m', '--exclude=*.log', f'{src}/', f'{TODIR}/')
+assert_same(TODIR / 'mixed' / 'sub' / 'keep.txt',
+            src / 'mixed' / 'sub' / 'keep.txt', label='-m kept non-empty dir')
+assert_not_exists(TODIR / 'onlylogs',
+                  label='-m pruned a dir emptied by an exclude')
+
+print("prune-empty-dirs: empty and filter-emptied chains pruned, populated kept")
diff --git a/testsuite/relative-implied_test.py b/testsuite/relative-implied_test.py
new file mode 100644 (file)
index 0000000..167d79d
--- /dev/null
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+"""Coverage of -R implied directories and --no-implied-dirs at depth.
+
+With -R the directories implied by a source path are recreated at the
+destination AND have their attributes mirrored from the source. With
+--no-implied-dirs those implied directories are still created as needed but get
+default attributes instead of the source's. Verify the distinction on an
+implied directory carrying a non-default mode, several levels deep.
+"""
+
+import os
+import stat
+
+from rsyncfns import (
+    SCRATCHDIR, TODIR,
+    assert_mode, assert_same, forced_protocol, makepath, rmtree, run_rsync,
+    test_fail,
+)
+
+base = SCRATCHDIR / 'rbase'
+rmtree(base)
+rmtree(TODIR)
+makepath(base / 'a' / 'b' / 'c')
+os.chmod(base / 'a' / 'b', 0o750)         # distinctive mode on an implied dir
+(base / 'a' / 'b' / 'c' / 'file').write_text("data\n")
+
+os.chdir(base / 'a')
+
+# -R: implied dirs b and c are recreated with the source's attributes.
+run_rsync('-aR', 'b/c/file', f'{TODIR}/')
+assert_mode(TODIR / 'b', 0o750, label='-R mirrors implied-dir mode')
+assert_same(TODIR / 'b' / 'c' / 'file', base / 'a' / 'b' / 'c' / 'file',
+            label='-R deep file')
+
+# --no-implied-dirs: implied dir b is created with default (not source) attrs.
+# At protocol 29 the generator rejects a multi-component path that has no
+# transmitted directory entries ("invalid path from sender"), so this half is
+# protocol-30+.
+proto = forced_protocol()
+if proto is not None and proto < 30:
+    print(f"relative-implied: protocol {proto} -- skipping --no-implied-dirs "
+          "(the multi-component path is rejected by the proto-29 generator)")
+else:
+    rmtree(TODIR)
+    run_rsync('-aR', '--no-implied-dirs', 'b/c/file', f'{TODIR}/')
+    m = stat.S_IMODE(os.stat(TODIR / 'b').st_mode)
+    if m == 0o750:
+        test_fail("--no-implied-dirs unexpectedly mirrored the source mode "
+                  "0750 onto the implied directory")
+    assert_same(TODIR / 'b' / 'c' / 'file', base / 'a' / 'b' / 'c' / 'file',
+                label='--no-implied-dirs deep file')
+
+print("relative-implied: -R mirrors implied-dir attrs; --no-implied-dirs does not")