- -n/--delete sends the same protected/size-skipped/scope as a real run, so the read-only would-delete walk no longer over-reports (row -> caveat) - --delete-delay charges --max-delete on actual removals and recursively re-scans a refilled deferred directory at commit; independent deferred cap - --info=name emits the leading ./ root line and name2 'is uptodate' lines - differential tests promoted from residual pins to rsync parity assertions
513 lines
23 KiB
Python
513 lines
23 KiB
Python
"""Differential + regression coverage for rsync's delete timing.
|
|
|
|
``--delete-during``/``--delete-delay`` stream a per-directory delete plan instead
|
|
of one whole-tree manifest, so the timing is observable:
|
|
|
|
* ``--delete-during`` removes a directory's extras as it processes that
|
|
directory (so an interrupted transfer has already removed the extras of the
|
|
directories it reached);
|
|
* ``--delete-delay`` snapshots those extras while scanning and commits the
|
|
removals only after a fully-successful transfer (so an extra created in the
|
|
destination after its directory's plan survives, and a failed transfer
|
|
removes nothing);
|
|
* ``--delete-after`` re-scans the destination at the end (so that same
|
|
late-created extra is removed).
|
|
|
|
The final-state tests compare against real ``rsync 3.4.1`` where a deterministic
|
|
comparison exists; the timing tests use a byte-slicing proxy to force a
|
|
mid-transfer failure or to create a destination entry while the transfer is in
|
|
flight.
|
|
"""
|
|
import os
|
|
import select
|
|
import shutil
|
|
import socket
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, os.path.dirname(__file__))
|
|
from common import ( # noqa: E402
|
|
BUILD_DIR,
|
|
TEST_DATA_DIR,
|
|
ServerManager,
|
|
clean_dir,
|
|
get_dest_received_dir,
|
|
run_client,
|
|
)
|
|
|
|
# Every test here is deterministic (the proxy throttles until the delete-plan
|
|
# frames are processed), so the PR gate runs the whole module.
|
|
pytestmark = pytest.mark.ci
|
|
|
|
RSYNC = shutil.which("rsync")
|
|
requires_rsync = pytest.mark.skipif(RSYNC is None, reason="rsync 3.4.1 not installed")
|
|
|
|
BIG_BYTES = 8 * 1024 * 1024
|
|
# Forward/cut this far into the stream: past the (small) delete-plan frames and
|
|
# well into the big payload, so the receiver has already processed the plan.
|
|
MID_TRANSFER_BYTES = 256 * 1024
|
|
# Throttle the proxy so the receiver keeps up with the (fast) client and the
|
|
# plan frames are provably processed before the hook/cut offset is reached.
|
|
PROXY_THROTTLE = 0.001
|
|
|
|
|
|
def _write(path, content):
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
with open(path, "wb") as fh:
|
|
fh.write(content)
|
|
|
|
|
|
def _seed_pair(tag, big=False):
|
|
"""Create a source tree and a destination mirror seeded with extras.
|
|
|
|
The tree is a single directory ``d`` containing the transferred files plus,
|
|
in the destination, an extra ``d/old_extra``.
|
|
"""
|
|
source = os.path.join(TEST_DATA_DIR, f"dtp_{tag}_src")
|
|
dest = os.path.join(TEST_DATA_DIR, f"dtp_{tag}_dst")
|
|
clean_dir(source)
|
|
clean_dir(dest)
|
|
_write(os.path.join(source, "d", "keep.txt"), b"kept payload\n")
|
|
if big:
|
|
_write(os.path.join(source, "d", "big.bin"), b"B" * BIG_BYTES)
|
|
received = get_dest_received_dir(dest, source)
|
|
os.makedirs(os.path.join(received, "d"), exist_ok=True)
|
|
_write(os.path.join(received, "d", "old_extra"), b"stale extra\n")
|
|
return source, dest, received
|
|
|
|
|
|
def _tree(root):
|
|
"""Sorted relative paths of every entry below root (files and dirs)."""
|
|
out = []
|
|
for dirpath, dirs, files in os.walk(root):
|
|
for name in dirs:
|
|
out.append(os.path.relpath(os.path.join(dirpath, name), root))
|
|
for name in files:
|
|
out.append(os.path.relpath(os.path.join(dirpath, name), root))
|
|
return sorted(out)
|
|
|
|
|
|
def _rsync(args):
|
|
env = dict(os.environ, LC_ALL="C")
|
|
return subprocess.run([RSYNC] + args, capture_output=True, text=True, env=env, timeout=120)
|
|
|
|
|
|
class _SlicingProxy:
|
|
"""Forward the client stream to a server, optionally cutting it or invoking a
|
|
hook after a byte threshold. ``forward_limit`` mode resets both ends after
|
|
that many client bytes (a mid-transfer failure). ``hook`` mode calls the
|
|
hook once and keeps forwarding to completion.
|
|
|
|
With ``wait_for_reply`` the hook is a real barrier, not a timing guess: it
|
|
fires only after the server has sent *any* reply, which the receiver does
|
|
only after it has consumed the frames that precede the payload (the
|
|
per-directory delete plan for ``--delete-delay``). The caller pairs it with
|
|
``--incremental`` so a per-file handshake reply is guaranteed mid-transfer.
|
|
"""
|
|
|
|
def __init__(self, target_port, forward_limit=None, hook=None, hook_after=0,
|
|
throttle=0.0, wait_for_reply=False):
|
|
self.target = ("127.0.0.1", target_port)
|
|
self.forward_limit = forward_limit
|
|
self.hook = hook
|
|
self.hook_after = hook_after
|
|
self.throttle = throttle
|
|
self.wait_for_reply = wait_for_reply
|
|
self.server_replied = threading.Event()
|
|
self.hook_called = threading.Event()
|
|
self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
self.listener.bind(("127.0.0.1", 0))
|
|
self.listener.listen(1)
|
|
self.listener.settimeout(20)
|
|
self.port = self.listener.getsockname()[1]
|
|
self._thread = threading.Thread(target=self._serve, daemon=True)
|
|
self._thread.start()
|
|
|
|
def _serve(self):
|
|
try:
|
|
client, _ = self.listener.accept()
|
|
except OSError:
|
|
return
|
|
try:
|
|
backend = socket.create_connection(self.target, timeout=10)
|
|
except OSError:
|
|
client.close()
|
|
return
|
|
client.settimeout(20)
|
|
backend.settimeout(20)
|
|
forwarded = 0
|
|
socks = [client, backend]
|
|
try:
|
|
while socks:
|
|
ready, _, _ = select.select(socks, [], [], 20)
|
|
if not ready:
|
|
break
|
|
for sock in ready:
|
|
data = sock.recv(65536)
|
|
if not data:
|
|
socks.remove(sock)
|
|
peer = backend if sock is client else client
|
|
try:
|
|
peer.shutdown(socket.SHUT_WR)
|
|
except OSError:
|
|
pass
|
|
continue
|
|
if sock is client:
|
|
if self.forward_limit is not None:
|
|
room = self.forward_limit - forwarded
|
|
if room <= 0:
|
|
socks = []
|
|
break
|
|
data = data[:room]
|
|
backend.sendall(data)
|
|
forwarded += len(data)
|
|
self._maybe_hook(forwarded)
|
|
if self.forward_limit is not None and forwarded >= self.forward_limit:
|
|
socks = []
|
|
break
|
|
if self.throttle > 0:
|
|
time.sleep(self.throttle)
|
|
else:
|
|
client.sendall(data)
|
|
# Any server reply proves the receiver consumed the
|
|
# frames that precede it, so the hook barrier is met.
|
|
self.server_replied.set()
|
|
self._maybe_hook(forwarded)
|
|
except OSError:
|
|
pass
|
|
for sock in (client, backend):
|
|
try:
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
|
|
except OSError:
|
|
pass
|
|
try:
|
|
sock.close()
|
|
except OSError:
|
|
pass
|
|
try:
|
|
self.listener.close()
|
|
except OSError:
|
|
pass
|
|
|
|
def _maybe_hook(self, forwarded):
|
|
"""Fire the one-shot hook once its barrier is satisfied: enough client
|
|
bytes have been forwarded and, when ``wait_for_reply`` is set, the
|
|
server has sent a reply proving it processed the preceding frames."""
|
|
if self.hook is None or self.hook_called.is_set():
|
|
return
|
|
if forwarded < self.hook_after:
|
|
return
|
|
if self.wait_for_reply and not self.server_replied.is_set():
|
|
return
|
|
self.hook()
|
|
self.hook_called.set()
|
|
|
|
def finish(self):
|
|
self._thread.join(30)
|
|
try:
|
|
self.listener.close()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
class TestDeleteTimingFinalStateParity:
|
|
"""On a successful transfer the per-directory timings match rsync's result."""
|
|
|
|
def _run_fastsync(self, tag, timing):
|
|
source, dest, received = _seed_pair(tag)
|
|
with ServerManager() as server:
|
|
server.start(extra_args=["--allow-delete"])
|
|
result, _ = run_client(source, dest, flags=[timing], port=server.port)
|
|
return result, received
|
|
|
|
@pytest.mark.parametrize("timing", ["--delete-during", "--delete-delay"])
|
|
@requires_rsync
|
|
def test_success_final_state_matches_rsync(self, timing):
|
|
# Worker-safe names: xdist may run both parametrizations concurrently, so
|
|
# the timing is part of every fixture path.
|
|
label = timing.lstrip("-")
|
|
# Build the rsync fixture from the same seed so both sides start equal.
|
|
source, dest, received = _seed_pair(f"parity_rsync_{label}")
|
|
source2 = source
|
|
rsync_dst = os.path.join(TEST_DATA_DIR, f"dtp_rsync_{label}_dst")
|
|
clean_dir(rsync_dst)
|
|
# rsync mirrors src/ into dst/; seed the same extra.
|
|
_write(os.path.join(rsync_dst, "d", "old_extra"), b"stale extra\n")
|
|
|
|
rsync_result = _rsync(["-a", timing, source2 + "/", rsync_dst + "/"])
|
|
assert rsync_result.returncode == 0, rsync_result.stderr
|
|
rsync_tree = _tree(rsync_dst)
|
|
|
|
with ServerManager() as server:
|
|
server.start(extra_args=["--allow-delete"])
|
|
result, _ = run_client(source, dest, flags=[timing], port=server.port)
|
|
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
|
fastsync_tree = _tree(received)
|
|
assert fastsync_tree == rsync_tree, (
|
|
f"{timing}: fastsync tree {fastsync_tree} != rsync tree {rsync_tree}"
|
|
)
|
|
|
|
|
|
class TestDeleteTimingTypeConflictParity:
|
|
"""A destination entry whose type differs from the source is replaced, in
|
|
both per-directory timings and in both directions, exactly like rsync."""
|
|
|
|
@pytest.mark.parametrize("timing", ["--delete-during", "--delete-delay"])
|
|
@requires_rsync
|
|
def test_type_conflicts_match_rsync(self, timing):
|
|
label = timing.lstrip("-")
|
|
source = os.path.join(TEST_DATA_DIR, f"dtc_{label}_src")
|
|
clean_dir(source)
|
|
_write(os.path.join(source, "foo"), b"now a file\n")
|
|
_write(os.path.join(source, "bar", "inner.txt"), b"now a dir\n")
|
|
|
|
def seed_dest(root):
|
|
clean_dir(root)
|
|
_write(os.path.join(root, "foo", "inner.txt"), b"was a dir\n")
|
|
_write(os.path.join(root, "bar"), b"was a file\n")
|
|
|
|
rsync_dst = os.path.join(TEST_DATA_DIR, f"dtc_{label}_rsync_dst")
|
|
seed_dest(rsync_dst)
|
|
rsync_result = _rsync(["-a", timing, source + "/", rsync_dst + "/"])
|
|
assert rsync_result.returncode == 0, rsync_result.stderr
|
|
rsync_tree = _tree(rsync_dst)
|
|
|
|
dest = os.path.join(TEST_DATA_DIR, f"dtc_{label}_dst")
|
|
clean_dir(dest)
|
|
received = get_dest_received_dir(dest, source)
|
|
seed_dest(received)
|
|
with ServerManager() as server:
|
|
server.start(extra_args=["--allow-delete"])
|
|
result, _ = run_client(source, dest, flags=[timing], port=server.port)
|
|
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
|
assert _tree(received) == rsync_tree, (
|
|
f"{timing}: fastsync tree {_tree(received)} != rsync tree {rsync_tree}"
|
|
)
|
|
|
|
|
|
class TestDeleteTimingFailure:
|
|
"""A mid-transfer failure distinguishes during from delay."""
|
|
|
|
@pytest.mark.parametrize("mt", [False, True])
|
|
def test_during_removes_delay_preserves_on_failure(self, mt):
|
|
source, dest, received = _seed_pair(f"failure_mt{int(mt)}", big=True)
|
|
extra = os.path.join(received, "d", "old_extra")
|
|
assert os.path.exists(extra)
|
|
with ServerManager() as server:
|
|
server.start(extra_args=["--allow-delete"])
|
|
for timing, expect_removed in (("--delete-during", True),
|
|
("--delete-delay", False)):
|
|
# Re-seed the extra before each run.
|
|
_write(extra, b"stale extra\n")
|
|
proxy = _SlicingProxy(server.port, forward_limit=MID_TRANSFER_BYTES, throttle=PROXY_THROTTLE)
|
|
flags = [timing] + (["--threads"] if mt else [])
|
|
result, _ = run_client(source, dest, flags=flags, port=proxy.port)
|
|
proxy.finish()
|
|
assert result.returncode != 0, f"{timing}: truncated transfer succeeded"
|
|
present = os.path.exists(extra)
|
|
assert present != expect_removed, (
|
|
f"{timing} (mt={mt}): extra present={present}, expected "
|
|
f"removed={expect_removed}"
|
|
)
|
|
|
|
|
|
class TestDeleteDelayDeletedCount:
|
|
"""The reported deleted count must reflect entries actually removed."""
|
|
|
|
def test_refilled_deferred_dir_is_recursively_removed_and_counted(self):
|
|
"""A directory snapshotted into a --delete-delay plan that is refilled
|
|
before the commit is re-scanned and removed recursively (rsync parity):
|
|
the late file and the directory are both counted as deleted."""
|
|
source = os.path.join(TEST_DATA_DIR, "ddc_src")
|
|
dest = os.path.join(TEST_DATA_DIR, "ddc_dst")
|
|
clean_dir(source)
|
|
clean_dir(dest)
|
|
_write(os.path.join(source, "d", "keep.txt"), b"kept payload\n")
|
|
_write(os.path.join(source, "d", "big.bin"), b"B" * BIG_BYTES)
|
|
received = get_dest_received_dir(dest, source)
|
|
extra_dir = os.path.join(received, "d", "extradir")
|
|
os.makedirs(extra_dir, exist_ok=True)
|
|
|
|
def hook():
|
|
# Runs while big.bin is in flight, after d's delete plan was processed.
|
|
_write(os.path.join(extra_dir, "new.txt"), b"created mid-transfer\n")
|
|
|
|
with ServerManager() as server:
|
|
server.start(extra_args=["--allow-delete"])
|
|
proxy = _SlicingProxy(server.port, hook=hook, hook_after=MID_TRANSFER_BYTES,
|
|
throttle=PROXY_THROTTLE, wait_for_reply=True)
|
|
flags = ["--delete-delay", "--incremental", "--ignore-times", "--stats"]
|
|
result, _ = run_client(source, dest, flags=flags, port=proxy.port)
|
|
proxy.finish()
|
|
assert result.returncode == 0, (result.stderr or result.stdout)[:400]
|
|
assert proxy.hook_called.is_set(), "hook never fired"
|
|
assert not os.path.exists(os.path.join(extra_dir, "new.txt")), "late file survived"
|
|
assert not os.path.isdir(extra_dir), "refilled extra dir survived"
|
|
deleted = None
|
|
for line in result.stdout.splitlines():
|
|
if line.startswith("Number of deleted files:"):
|
|
deleted = int(line.split(":", 1)[1].split()[0])
|
|
assert deleted == 2, (deleted, result.stdout)
|
|
|
|
|
|
class TestDeleteDelayMaxDeleteParity:
|
|
"""--max-delete with --delete-delay: a partial deletion still reports the
|
|
number of entries actually removed, matching rsync (the exact surviving set
|
|
can differ; only the count is compared)."""
|
|
|
|
@requires_rsync
|
|
def test_max_delete_count_matches_rsync(self):
|
|
source = os.path.join(TEST_DATA_DIR, "ddm_src")
|
|
rsync_dst = os.path.join(TEST_DATA_DIR, "ddm_rsync_dst")
|
|
clean_dir(source)
|
|
clean_dir(rsync_dst)
|
|
_write(os.path.join(source, "d", "keep.txt"), b"keep\n")
|
|
for i in range(1, 6):
|
|
_write(os.path.join(rsync_dst, "d", f"e{i}.txt"), f"extra{i}\n".encode())
|
|
|
|
rsync_result = _rsync(["-a", "--delete-delay", "--max-delete=2", "--stats",
|
|
source + "/", rsync_dst + "/"])
|
|
# rsync exits 25 ("the --max-delete limit stopped deletions").
|
|
assert rsync_result.returncode == 25, rsync_result.stderr
|
|
rsync_count = _deleted_count(rsync_result.stdout)
|
|
assert rsync_count == 2, rsync_result.stdout
|
|
|
|
dest = os.path.join(TEST_DATA_DIR, "ddm_dst")
|
|
clean_dir(dest)
|
|
received = get_dest_received_dir(dest, source)
|
|
for i in range(1, 6):
|
|
_write(os.path.join(received, "d", f"e{i}.txt"), f"extra{i}\n".encode())
|
|
with ServerManager() as server:
|
|
server.start(extra_args=["--allow-delete"])
|
|
result, _ = run_client(
|
|
source, dest,
|
|
flags=["--delete-delay", "--max-delete=2", "--stats"],
|
|
port=server.port,
|
|
)
|
|
# A capped --max-delete commit is a successful transfer that both tools
|
|
# report with exit 25.
|
|
assert result.returncode == 25, (result.stderr or result.stdout)[:300]
|
|
assert _deleted_count(result.stdout) == rsync_count, result.stdout
|
|
|
|
|
|
def _deleted_count(text):
|
|
for line in text.splitlines():
|
|
if line.startswith("Number of deleted files:"):
|
|
return int(line.split(":", 1)[1].split()[0])
|
|
return None
|
|
|
|
|
|
class TestDeleteDelayVsAfterSnapshot:
|
|
"""A destination entry created after its directory's scan survives under
|
|
--delete-delay but is removed by --delete-after's fresh end scan."""
|
|
|
|
@pytest.mark.parametrize("mt", [False, True])
|
|
def test_late_created_extra_survives_delay_not_after(self, mt):
|
|
source, dest, received = _seed_pair(f"latecreate_mt{int(mt)}", big=True)
|
|
old_extra = os.path.join(received, "d", "old_extra")
|
|
new_extra = os.path.join(received, "d", "new_extra")
|
|
with ServerManager() as server:
|
|
server.start(extra_args=["--allow-delete"])
|
|
for timing, new_survives in (("--delete-delay", True),
|
|
("--delete-after", False)):
|
|
_write(old_extra, b"stale extra\n")
|
|
if os.path.exists(new_extra):
|
|
os.unlink(new_extra)
|
|
|
|
def hook():
|
|
# Runs on the proxy thread while the big file is in flight,
|
|
# after the directory's plan (delay) has been processed.
|
|
_write(new_extra, b"created mid-transfer\n")
|
|
|
|
# --incremental gives the receiver a mid-transfer handshake
|
|
# reply; the proxy waits for it (wait_for_reply) so the hook is
|
|
# causally after the plan frame, never a timing guess.
|
|
# --ignore-times forces the big file to transfer on the second
|
|
# timing too (the first run already installed it), keeping the
|
|
# mid-transfer reply present in both iterations.
|
|
proxy = _SlicingProxy(server.port, hook=hook,
|
|
hook_after=MID_TRANSFER_BYTES,
|
|
throttle=PROXY_THROTTLE, wait_for_reply=True)
|
|
flags = [timing, "--incremental", "--ignore-times"] + (["--threads"] if mt else [])
|
|
result, _ = run_client(source, dest, flags=flags, port=proxy.port)
|
|
proxy.finish()
|
|
assert result.returncode == 0, (
|
|
f"{timing}: {(result.stderr or result.stdout)[:300]}"
|
|
)
|
|
assert proxy.hook_called.is_set(), f"{timing}: hook never fired"
|
|
assert not os.path.exists(old_extra), f"{timing}: old extra survived"
|
|
assert os.path.exists(new_extra) == new_survives, (
|
|
f"{timing} (mt={mt}): new_extra present="
|
|
f"{os.path.exists(new_extra)}, expected survives={new_survives}"
|
|
)
|
|
|
|
|
|
class TestDeleteAfterThreadsKeepSet:
|
|
"""Regression: -m/--threads with the default delete-after timing (plain
|
|
--delete) must still transmit the keep-set manifest and remove destination
|
|
extras. PipelineContextSender.delete_suppressed was left uninitialized, so a
|
|
garbage true silently skipped the manifest under --threads."""
|
|
|
|
@pytest.mark.parametrize("delete_flag", ["--delete", "--delete-after"])
|
|
def test_threads_delete_after_sends_keep_set(self, delete_flag):
|
|
source, dest, received = _seed_pair("mtkeep")
|
|
extra = os.path.join(received, "d", "old_extra")
|
|
assert os.path.exists(extra)
|
|
with ServerManager() as server:
|
|
server.start(extra_args=["--allow-delete"])
|
|
result, _ = run_client(source, dest, flags=["--threads", delete_flag],
|
|
port=server.port)
|
|
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
|
|
assert not os.path.exists(extra), (
|
|
f"{delete_flag} --threads did not remove an extra: keep-set manifest was suppressed"
|
|
)
|
|
|
|
class TestDeleteDelayMaxDeleteRefilledDir:
|
|
"""--delete-delay charges the --max-delete budget on ACTUAL removals: the
|
|
refilled directory's late content is removed first (consuming the one slot),
|
|
so the directory itself and a later extra are skipped, matching rsync.
|
|
|
|
The refilled directory is at the destination ROOT (its plan is always sent
|
|
first) and the skipped extra is under a separate source directory, so the
|
|
ordering that decides the budget charge is deterministic -- not readdir
|
|
order. The refill is injected through the byte-barrier proxy so it is
|
|
causally after the plan frame."""
|
|
|
|
def test_budget_charged_on_actual_removal(self):
|
|
source = os.path.join(TEST_DATA_DIR, "ddmb_src")
|
|
dest = os.path.join(TEST_DATA_DIR, "ddmb_dst")
|
|
clean_dir(source)
|
|
clean_dir(dest)
|
|
_write(os.path.join(source, "a", "keep.bin"), b"B" * BIG_BYTES)
|
|
_write(os.path.join(source, "b", "keep.txt"), b"keep\n")
|
|
received = get_dest_received_dir(dest, source)
|
|
refilled_dir = os.path.join(received, "xdir")
|
|
os.makedirs(refilled_dir, exist_ok=True)
|
|
later_dir = os.path.join(received, "b", "ydir")
|
|
os.makedirs(later_dir, exist_ok=True)
|
|
|
|
def hook():
|
|
_write(os.path.join(refilled_dir, "new.txt"), b"created mid-transfer\n")
|
|
|
|
with ServerManager() as server:
|
|
server.start(extra_args=["--allow-delete"])
|
|
proxy = _SlicingProxy(server.port, hook=hook, hook_after=MID_TRANSFER_BYTES,
|
|
throttle=PROXY_THROTTLE, wait_for_reply=True)
|
|
flags = ["--delete-delay", "--max-delete=1", "--incremental", "--ignore-times", "--stats"]
|
|
result, _ = run_client(source, dest, flags=flags, port=proxy.port)
|
|
proxy.finish()
|
|
assert result.returncode == 25, (result.stderr or result.stdout)[:400]
|
|
assert proxy.hook_called.is_set(), "hook never fired"
|
|
# The late content consumes the single budget slot; the refilled
|
|
# directory itself and the later extra are skipped.
|
|
assert not os.path.exists(os.path.join(refilled_dir, "new.txt")), "late file survived"
|
|
assert os.path.isdir(later_dir), "later extra was not skipped by the budget"
|
|
# The one actual removal is reported.
|
|
assert _deleted_count(result.stdout) == 1, result.stdout
|