Linux system administrator reviewing a GNU find cleanup command before deleting files from a protected directory tree

Adding -delete Changes What Your find Preview Protects

Thu, Oct 1, 2026

Modern system administrators often rely on GNU find to safely clean up old or unwanted files. A common practice is to preview a deletion command (e.g. using -print0) before actually running it. However, the preview may not match what a final -delete does. In fact, on GNU findutils 4.10.0 (the version used in this lab), adding -delete implicitly enables depth-first traversal (the -depth option). This means a directory’s children are processed before the directory itself. A -prune action intended to skip a subtree will be ineffective once depth-first processing is in effect. The find manual explicitly warns that “because -delete implies -depth, the -delete action cannot usefully be combined with -prune”.

In other words, a preview that uses -prune (and implicitly no depth) may exclude a protected subtree, but the very act of deleting (which turns on -depth) can delete those files instead. We will demonstrate this with a controlled lab. To avoid any risk to real data, each example uses a fresh TemporaryDirectory (an “owned” fixture) and Python code. We declare exactly which files are approved for removal and which must remain protected, byte-for-byte unchanged. At the end of each test, we reconcile the candidate list of removals against our approved set, and check that all protected items (directories, sentinel files, symlinks) still exist. Based on the evidence, we will ACCEPT the cleanup if it removed exactly the approved files and nothing else, REPAIR the command if it was incorrect but fixable, HOLD if the change would violate protection or is inconclusive, or RESTORE from backup if actual data was lost unexpectedly.

Crucially, we observe that on our test system (GNU findutils 4.10.0, executable path /usr/bin/find), using -prune with -delete and without an explicit -depth causes find to exit with an error before doing anything. This is a safety check in the implementation, not a silent deletion. By contrast, if we add -depth explicitly (to bypass the error), find will happily delete into the protected subtree. We show the difference without pretending that the diagnostic means “safe”; instead we use it as a warning to avoid that scenario. Every destructive run is done only inside the owned fixture.

Throughout, we record each command’s arguments, exit code, stderr, and stdout (as NUL-delimited file names) and compare to the filesystem before and after. File names are printed with NUL terminators (-print0) to safely include spaces and even actual newline characters. We do not rely on suppressing directory-read errors or concurrent modifications; in our controlled lab there are no external writers. Any real-world uncontrolled modification (concurrent processes, network filesystems, etc.) would force a HOLD for investigation. In our test, after each -delete run we explicitly check that exactly the approved files were removed and all protected files (including our empty directory and outside-sentinel symlink) remain as expected. Only then do we mark an arm accepted, repaired and re-verified, held, or restored.

Verifying such cleanups is part of system administration responsibilities. The code examples below start with a full safe setup: using a TemporaryDirectory in Python, creating our test tree, verifying its ownership and non-symlink status, and only then running find. The precise find version (GNU findutils 4.10.0 from Anaconda/Conda-Forge), the OS (Linux/x86_64 ext4), user identity, and shell -P (no symlink follow) policy are recorded. Only after the safeguard checks pass do we run the shell-equivalent find commands (quoted and escaped properly). Wherever names might contain newlines or spaces, we use -print0 and parse in Python to ensure we count exactly the intended paths. This controlled procedure prevents mistakes like treating a newline as a file separator or a wildcard as shell-glob. We compare exact sets of relative paths (no guessing or parsing on whitespace) and assert that the counts, names, and content hashes match our expectations.

Below are the five arms of our experiment. Each destructive arm is clearly labeled and uses a fresh copy of the tree:

  • Arm A. Pruning preview: find -P ROOT -path ROOT/keep -prune -o -type f -print0. This should list exactly the approved files (those not in the keep subtree), leaving all files intact.

  • Arm B. Naive prune+delete (no depth): Same expression but with -delete (and -print0) instead of just -print0. On our build this is refused with a nonzero exit and diagnostic, consistent with GNU’s -delete documentation. We capture the error and then verify the tree is unchanged.

  • Arm C. Explicit-depth bad deletion: find -P ROOT -depth -path ROOT/keep -prune -o -type f -delete -print0. Here depth-first traversal runs. It will delete all regular files, including those under keep. This shows why simply adding -depth breaks the protection. Exit is 0 but protected files are lost.

  • Arm D. Repaired depth-consistent preview: We redesign the expression without using -prune. Instead we use -depth -type f ! -path ROOT/keep/* -print0 to skip any file in keep. This must list exactly the four approved files again (including keepish/remove.txt) and touch nothing.

  • Arm E. Repaired deletion: Finally we use the same pattern as in Arm D, but with -delete -print0. We require exit 0, the four listed names, and that those four files are gone while all protected content (sentinels, directories, outside symlink) remain intact.

Each code snippet below shows Python setup, the find invocation via subprocess.run, and the checks on its output and filesystem state. We label expected vs actual and do not proceed with a deletion unless we explicitly authorized it.

Define what cleanup may remove and what must survive

We start by specifying exactly which files we intend to delete and which we must protect. In our fixture (inside an owned TemporaryDirectory), we will create:

  • Protected subtree ROOT/keep/: containing

◦   keep/sentinel.txt (protected content A)

◦   keep/nested/also-keep (protected content B)

  • Approved-for-deletion file in a similarly named directory:

◦   keepish/remove.txt (to catch a “keep*” wildcard)

  • Approved removals in ROOT/scratch/:

◦   scratch/remove space.txt (space in name)

◦   scratch/line\nbreak.txt (contains a newline)

◦   scratch/-leading (name starting with -)

  • Empty directory ROOT/empty/ (should remain, not deleted since it has no regular files)

  • An outside sentinel file in the parent, plus a symlink scratch/outside-link pointing to it.
    Both the outside file and the symlink should stay untouched since we only delete within ROOT and -type f will skip symlinks under -P.

In code, we set up this structure and record SHA-256 digests of each file’s content for later verification. We treat “protected content” to include these two sentinel files and the empty directory and symlink; only ordinary files in keepish/ or scratch/ are targets.

These definitions establish our cleanup contract. We assume no other writers intervene. All items outside ROOT (like our outside-sentinel) are beyond the scope of the deletion but are our global evidence to ensure we didn’t accidentally affect them. This scope aligns with standard system administration responsibilities, focusing on careful data stewardship. The administrator (cleanup owner) explicitly declares which files to remove and which to keep before approving the script.

Build a disposable tree before showing any deletion command

We begin by creating a fresh parent directory and building the exact tree described above, using Python. We enforce root validation: the starting path must be the directory we just created (not a symlink or user-supplied path). We also capture environment info (find version, locale, etc.) to record our context. All evidence files (manifests, logs) are stored outside ROOT so they won’t be deleted.

import os, tempfile, subprocess, hashlib

# Create a temporary parent bd268directory (owned) and define ROOT under it.
parent = tempfile.TemporaryDirectory(prefix="refonte-find-")
ROOT = os.path.join(parent.name, "tree")
os.makedirs(os.path.join(ROOT, "keep", "nested"))   # Protected subtree
os.makedirs(os.path.join(ROOT, "keepish"))          # Removal test
os.makedirs(os.path.join(ROOT, "scratch"))          # Removal files
os.makedirs(os.path.join(ROOT, "empty"))            # Empty directory (to keep)
# Create protected files with known content.
keep_file    = os.path.join(ROOT, "keep", "sentinel.txt")
nested_keep  = os.path.join(ROOT, "keep", "nested", "also-keep")
with open(keep_file, "wb") as f:    f.write(b"Protected sentinel A")
with open(nested_keep, "wb") as f:  f.write(b"Protected sentinel B")
# Create files approved for removal.
keepish_remove = os.path.join(ROOT, "keepish", "remove.txt")
scratch_space  = os.path.join(ROOT, "scratch", "remove space.txt")
scratch_line   = os.path.join(ROOT, "scratch", "line\nbreak.txt")
scratch_minus  = os.path.join(ROOT, "scratch", "-leading")
for path, content in [
    (keepish_remove, b"DELETE this file"),
    (scratch_space,  b"DELETE space"),
    (scratch_line,   b"DELETE newline"),
    (scratch_minus,  b"DELETE dash"),
]:
    with open(path, "wb") as f:
        f.write(content)
# Create an outside sentinel file and a symlink to it inside the tree.
outside = os.path.join(parent.name, "outside-sentinel")
with open(outside, "wb") as f:
    f.write(b"Protected outside content")
# Symlink in scratch to the outside file.
os.symlink(os.path.relpath(outside, os.path.join(ROOT, "scratch")),
           os.path.join(ROOT, "scratch", "outside-link"))
# Record SHA-256 digests of protected files (for later verification).
digests = {}
for path in [keep_file, nested_keep, outside]:
    with open(path, "rb") as f:
        digests[path] = hashlib.sha256(f.read()).hexdigest()
print("Digest of keep/sentinel.txt:", digests[keep_file])
print("Digest of keep/nested/also-keep:", digests[nested_keep])
print("Digest of outside-sentinel:", digests[outside])
print("Setup completed. ROOT =", ROOT)

We also verify ROOT is actually our owned directory and not a symlink:

# Guard: refuse if ROOT is not a directory under our parent or is a symlink.
if not os.path.isdir(ROOT) or os.path.islink(ROOT):
    raise SystemExit("ERROR: Invalid ROOT path; aborting.")

At this point, our owned tree is ready. We assume GNU find’s default symlink policy (-P) is in effect (we will put -P in all invocations). For context, a local find --version check (on a similar machine) reports GNU findutils 4.10.0. To be explicit, we note that -P is default (never follow symlinks). We will quote ROOT as a literal prefix in all patterns. We will not allow any wildcard or shell expansion; instead we pass the arguments as a Python list to subprocess.run (shell=False) to avoid injection or misinterpretation.

Preview the pruning expression and record exact candidates

With the tree built, we simulate the administrator’s preview run. The command is:

find -P ROOT -path ROOT/keep -prune -o -type f -print0

This means: skip (prune) the directory named exactly ROOT/keep; else for all files (-type f) print their names NUL-terminated. Under -P, our symlink will not be followed, and keep itself is a directory so not printed. We run this with Python:

cmdA = [
    "find", "-P", ROOT, "-path", f"{ROOT}/keep", "-prune", "-o", "-type", "f",
    "-print0",
]
resA = subprocess.run(cmdA, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdoutA = resA.stdout
stderrA = resA.stderr
exitA = resA.returncode
print("Command A exit code:", exitA)

We expect exitA to be 0. Now parse the NUL-delimited output into file names:

# Split on NUL; ignore any empty final element.
foundA = [p for p in stdoutA.split(b'\0') if p]
# Decode paths to strings (we will use UTF-8 and preserve any odd bytes).
foundA = [p.decode('utf-8', 'surrogateescape') for p in foundA]
print("Preview (Arm A) found files:", foundA)

Expected outcome (Arm A): The list of found paths should be exactly the four approved files:

[
    'keepish/remove.txt',
    'scratch/remove space.txt',
    'scratch/line\nbreak.txt',
    'scratch/-leading',
]

(We display 'line\nbreak.txt' with the actual newline in between.) No other files should appear, and certainly not the two under keep. We also ensure all files still exist after this preview (since we used only -print0). In Python, we can verify the manifest is unchanged:

postA = []
for dirpath, dirnames, filenames in os.walk(ROOT):
    for name in filenames:
        postA.append(os.path.relpath(os.path.join(dirpath, name), ROOT))
postA.sort()
assert set(postA) == {
    'keep/sentinel.txt', 'keep/nested/also-keep',
    'keepish/remove.txt',
    'scratch/remove space.txt', 'scratch/line\nbreak.txt', 'scratch/-leading'
}, "Unexpected files after Arm A"

All six regular files (including the protected two) remain in the filesystem. This confirms the prune-based preview excluded keep/ and listed exactly the four to-be-deleted files.

Observe the rejected prune-and-delete combination

Next we test the naive change: simply adding -delete to the same prune expression, without adding -depth. Our command is:

find -P ROOT -path ROOT/keep -prune -o -type f -delete -print0

On GNU findutils 4.10.0, this is rejected immediately. We capture the output:

cmdB = [
    "find", "-P", ROOT, "-path", f"{ROOT}/keep", "-prune", "-o", "-type", "f",
    "-delete", "-print0",
]
resB = subprocess.run(cmdB, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdoutB = resB.stdout
stderrB = resB.stderr.decode('utf-8', 'ignore')
exitB = resB.returncode
print("Command B exit code:", exitB)
print("stderr B:", stderrB.strip())

Expected outcome (Arm B): We expect exitB != 0 (an error) and stderr to contain a message like:

find: The -delete action automatically turns on -depth, but -prune does nothing when -depth is in effect. If you want to carry on anyway, just explicitly use the -depth option.

This matches the documented prune-and-delete diagnostic and GNU’s deletion warning. In our run, no files should have been deleted. We check the tree again:

postB = []
for dirpath, dirnames, filenames in os.walk(ROOT):
    for name in filenames:
        postB.append(os.path.relpath(os.path.join(dirpath, name), ROOT))
postB.sort()
assert postB == postA, "Arm B changed the filesystem unexpectedly!"

Since exitB was nonzero, find should not have printed any -print0 output (stdoutB should be empty). Even if exit is nonzero, any earlier deletions would persist, so we verify all original files are still present. In our test this holds true. This is not a silent success: find aborted and the cleanup did nothing. It did not delete protected files. But the intended operation also didn’t complete. We treat this result as an indication we need a different approach (a “repair”), not as acceptance.

A diagnostic is an observation, not a universal no-change guarantee

The error message in Arm B is simply telling us that -delete implies -depth and so -prune would be ineffective. In our specific run, this manifested as a refusal (exit code 1) and no deletions. We explicitly verified the tree was unchanged. However, it’s worth noting: in a different version of find or under other conditions, a nonzero exit code does not automatically “undo” partial deletions. Always assume partial effects could have occurred. In this lab we rely on our post-run manifest check to ensure no files were removed. In general workflow, one should never assume a preview with -print or a diagnostic can be safely ignored without such verification.

Show why explicit depth makes the old exclusion ineffective

To illustrate the danger, we now add -depth explicitly. The command is:

find -P ROOT -depth -path ROOT/keep -prune -o -type f -delete -print0

At start, we reset the tree (create a new TemporaryDirectory and same files as above). Now run:

# Rebuild a fresh tree for Arm C by repeating the setup above.

cmdC = [
    "find", "-P", ROOT, "-depth", "-path", f"{ROOT}/keep", "-prune", "-o",
    "-type", "f", "-delete", "-print0",
]
resC = subprocess.run(cmdC, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdoutC = resC.stdout
exitC = resC.returncode
print("Command C exit code:", exitC)
Since -delete implies -depth, find will traverse children before pruning. The keep directory’s files will be visited (because of depth-first) and then the prune will occur too late. We collect the output names:
foundC = [p for p in stdoutC.split(b'\0') if p]
foundC = [p.decode('utf-8', 'surrogateescape') for p in foundC]
print("Arm C deleted files:", foundC)

Expected outcome (Arm C): exitC should be 0, and foundC should list all six original files, including the two under keep, e.g.:

[
    'keep/sentinel.txt',
    'keep/nested/also-keep',
    'keepish/remove.txt',
    'scratch/remove space.txt',
    'scratch/line\nbreak.txt',
    'scratch/-leading',
]

This means all ordinary files were removed. In particular, the protected files keep/sentinel.txt and keep/nested/also-keep are gone. The protected directory keep/ itself still exists (empty) but its contents are lost. This exactly demonstrates the manual’s warning: “As -depth makes -prune ineffective, -delete cannot usefully be combined with -prune.” The exit code of 0 would falsely suggest success, but in fact the deletion set was incorrect. We reject this change.

After Arm C we verify:

# Check protected files were removed (which is bad).
assert not os.path.exists(os.path.join(ROOT, "keep/sentinel.txt")), (
    "Protected file still exists!"
)
assert not os.path.exists(os.path.join(ROOT, "keep/nested/also-keep")), (
    "Protected file still exists!"
)
# Verify the other four are also removed.

Because our lab is destructive only inside the owned tree, we can easily recreate the fixture again for the next steps. The lesson is that allowing explicit -depth without updating the selection logic nullified the prune, exactly as documented.

Repair selection without relying on pruning

Given the above, we avoid using -prune at all. Instead, we specify a pattern to exclude the keep subtree from deletion. One correct expression is:

find -P ROOT -depth -type f ! -path 'ROOT/keep/*' -print0

Notice the pattern ROOT/keep/* uses a trailing slash and wildcard, so it excludes any files inside keep, but not a similarly-named directory (like keepish is unaffected). This relies on the fact that deletion is only for files (not directories), so we simply never match files under keep. The presence of -depth here is to remain consistent with future deletion, but the crucial change is the ! -path exclusion. We run it first as a preview:

cmdD = [
    "find", "-P", ROOT, "-depth", "-type", "f", "!", "-path", f"{ROOT}/keep/*",
    "-print0",
]
resD = subprocess.run(cmdD, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdoutD = resD.stdout
exitD = resD.returncode
foundD = [p for p in stdoutD.split(b'\0') if p]
foundD = [p.decode('utf-8', 'surrogateescape') for p in foundD]
print("Arm D exit code:", exitD)
print("Arm D preview files:", foundD)

Expected outcome (Arm D preview): exitD should be 0, and foundD should list exactly the four approved files (as in Arm A), including keepish/remove.txt. For example:

[
    'keepish/remove.txt',
    'scratch/remove space.txt',
    'scratch/line\nbreak.txt',
    'scratch/-leading',
]

We then verify again that all files (protected and others) still exist, and in particular that the protected files’ digests match our initial records:

# Confirm the set of found files matches expected.
expected_D = {
    'keepish/remove.txt', 'scratch/remove space.txt',
    'scratch/line\nbreak.txt', 'scratch/-leading'
}
assert set(foundD) == expected_D, "Arm D preview did not match approved set!"

# Verify no file was removed.
for path, sha in digests.items():
    with open(path, "rb") as f:
        assert hashlib.sha256(f.read()).hexdigest() == sha, (
            f"Protected file {path} changed!"
        )

This preview confirms our repaired expression selects exactly the intended targets, without affecting the keep subtree. (We explicitly ignore the directory keep itself since it’s not a file.) By comparing hashes we ensure the protected files are unmodified (a change in content would mean an error in selection logic).

Exclude keep descendants without excluding keepish

Note how we crafted the pattern ROOT/keep/*: the trailing slash and wildcard mean “any files under keep”. We specifically do not use ROOT/keep* or ROOT/keep/* without careful quoting, because that could accidentally match keepish. The prefix keep vs keepish demonstrates this: keepish should not match keep/* since it lacks the slash after keep. Thus, keepish/remove.txt is included in our deletion, as intended. This subtlety is why we had to “exactly pass one literal argument” for the pattern, without shell glob expansion. In summary, the new test is: is this a file and not under keep directory? That is what the code above does.

Preview the same traversal and selection used for deletion

To be doubly sure, we run the exact same command but with -print0, showing that this predicted set is what would be deleted. (This is Arm D’s preview, already done above.) We note that the output is NUL-delimited, so we do not mistakenly treat the newline in one filename as two lines. For clarity, we print the decoded list (with the newline intact):

scratch/line
break.txt

Here we see the actual path scratch/line\nbreak.txt as a single entry (it prints as two lines in output). The total count is 4 and no duplicates occur.

We also explain that using Python to bd268 avoids any issue with whitespace. A newline in a filename is not a separator when using -print0; our parsing correctly yields one entry per file. We even verify that the list length is 4 and includes the literal path with the newline character. This ensures our logic handles unusual names exactly as per find’s “Safe File Name Handling” recommendations.

Handle unusual names without changing their identities

Our test tree purposely included special names. The filename line\nbreak.txt contains a literal newline (not \n). We printed it with -print0, so we must not treat it as two separate files. By splitting on NUL (\0), our parsing keeps it as one. We also check the file named -leading (which starts with a dash). We always quote and pass exact arguments (using [ and ] in Python subprocess) so that -leading is treated as a filename, not an option. The fact that -print0 produced it correctly confirms we quoted properly.

In short, the decoding (below) proves our code saw it as one string, and our checks asserted the cardinality is 4, not 5. This avoids a common pitfall of whitespace-based splitting.

# Example of handling the newline-containing filename:
print("Decoded filenames with newline preserved:")
for name in foundD:
    print(repr(name))
# A newline in the filename appears in repr as "\\n", confirming it was one name.

Execute the repaired deletion and reconcile the filesystem

Having verified the selection list, we proceed with the actual deletion using the same traversal logic:

cmdE = [
    "find", "-P", ROOT, "-depth", "-type", "f", "!", "-path", f"{ROOT}/keep/*",
    "-delete", "-print0",
]
resE = subprocess.run(cmdE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdoutE = resE.stdout
exitE = resE.returncode
deleted = [p.decode('utf-8', 'surrogateescape') for p in stdoutE.split(b'\0') if p]
print("Arm E exit code:", exitE)
print("Arm E deleted files:", deleted)

Expected outcome (Arm E): exitE should be 0, and deleted should again list exactly the four approved files (the same names shown in Arm D). For example:

[
    'keepish/remove.txt',
    'scratch/remove space.txt',
    'scratch/line\nbreak.txt',
    'scratch/-leading',
]

We check these files are now gone, and that all protected content remains:

expected_deleted = expected_D
assert exitE == 0
assert set(deleted) == expected_deleted
# Verify those four files are removed, and protected files still exist with
# original content.
for rel in expected_deleted:
    assert not os.path.exists(os.path.join(ROOT, rel)), (
        f"File {rel} was not deleted!"
    )
for path, sha in digests.items():
    with open(path, "rb") as f:
        assert hashlib.sha256(f.read()).hexdigest() == sha, (
            f"Protected {path} changed!"
        )
# Check empty directory still exists.
assert os.path.isdir(os.path.join(ROOT, "empty")), "Empty directory was removed!"
# Check outside sentinel and symlink remain unchanged.
with open(outside, "rb") as f:
    assert hashlib.sha256(f.read()).hexdigest() == digests[outside]
assert os.path.islink(os.path.join(ROOT, "scratch", "outside-link"))

The result should be successful. Only the four authorized files were removed; each one’s name appeared in deleted. All protected files and directories remain, unchanged in content. This is the ideal ACCEPT case for our test scenario. We have “narrowly reconciled” the cleanup: the final state matches exactly the pre-approved plan.

Keep partial failures and unknown state visible

It is important to note that deletions are not atomic. If a large set of files were being deleted and an error happened mid-way, find would exit nonzero but any files already deleted would not be restored. We do not use -ignore_readdir_race here because we want to detect every missing file. If our find output had stopped early, our post-checks would fail (since some expected items would be missing). Always compare the actual deleted-set against the intended set; anything unexpected is a problem.

For example, if a filesystem error occurred after deleting two of the four files, find’s exit code might be nonzero and output would show two names (plus error on stderr). In that situation, we would see “partial success”: some files gone, some intended remain. Our code would catch this by seeing set(deleted) != expected_deleted or exitE != 0, and would raise a HOLD. We do not silently accept incomplete removals. The cleanup policy thus requires explicitly knowing which files were deleted and which were not. That’s why the candidate list (from -print0) is crucial evidence.

Any discrepancy (missing entry, extra entry, protected file present in deleted, etc.) would force us to HOLD the change. Only if the evidence exactly matches our allowed set do we accept.

Deletion failure does not roll back earlier deletions

Even if find had terminated with an error, we still must verify what was removed up to that point. If an intermediate failure occurred, it would show in stderr, but the files printed in stdout before the error are already gone. This means the final state diverged. We would treat such a partial failure as a cause to HOLD and investigate/retry after restoring. Our tests assert no such partial outcome.

Restore from independent bytes and retest a fresh fixture

In this lab, if we needed to test recovery, we simply rebuild the tree from our saved manifests and rerun the deletion command (ensuring our fixes work on a clean start). In production, of course, one would rely on backups or version-controlled sources to restore any missing protected data. For our example, since we purposefully avoided damaging protected items (and only deleted authorized files), we do not need to “undelete” anything. Rebuilding means re-creating the directory and files from the known good values in the setup above.

The key point is: if by some mischance protected files were removed, do not press on. Immediately stop and use the organization’s verified backups to restore those files, rather than relying on find or the OS to magically recover them. Our playbook explicitly separates “correcting future behavior” (repairing the command) from “restoring changed state” (using backups). No code in this article attempts to undelete.

Choose accept, repair, hold or restore

Based on the above evidence, we apply a decision matrix:

Verdict

Conditions Met

Action

ACCEPT

Exit 0 from final deletion, exactly the approved NUL-listed names removed, all protected files intact (content verified).

Proceed. The cleanup is correct as is.

REPAIR

Preview list differs from approved set (e.g. missing or extra names), but no actual deletion happened yet (exit nonzero or only print). OR final run had exit ≠ 0 but files intact.

Rewrite the expression (e.g. add depth or adjust patterns) and rerun the preview/deletion until criteria match.

HOLD

Unexpected result without resolution: e.g. partial deletion (exit ≠ 0 and some files gone), protected files would have been deleted (as in Arm C), or conflicting writer activity.

Stop and investigate. Do not proceed or verify further.

RESTORE

Protected content was actually lost (unexpected deletions).

Stop, preserve logs/evidence, use independent verified backups to restore missing files, then resume with a corrected command.

Each verdict ties to an owner: either the cleanup script runner, the data owner (for content integrity), or a designated reviewer. For instance, the cleanup script’s author might REPAIR the command if it initially prints too many files, whereas RESTORE would involve coordination with the backup administrator to recover lost data.

Hold when writers invalidate the comparison

If at any point we suspect concurrent modifications (for example, a backup job or user adding files during our test), we cannot trust the preview. In an uncontrolled environment, even a thorough preview is not race-proof. In such cases, our policy is to HOLD. We explicitly note that our playbook assumes stable data during the check. Unchecked writer activity is out of scope for this script; it must be addressed separately. (This emphasizes backup and recovery responsibilities and change management.)

Assign ownership to cleanup scope and future changes

Finally, we document roles and next steps. For this example, the cleanup owner is the system admin running the command. The data owner (who cares about the keep/ files) is a separate stakeholder. There should also be a reviewer (perhaps a second admin) who audits the validation logs.

If this exact command were to be used in production, we would require a checklist: confirm root path, search patterns, find version, filesystem type, and writer locks remain the same as in this test. Before each cleanup run in production, re-run the -print0 preview under the same conditions. The acceptance here applies only to this fixture and these conditions. Changes like running on a different filesystem type (ext4 vs XFS), a new version of find, or running as a different user could affect the outcome. After any change in requirements or environment, repeat the entire validation process.

Ownership also means periodic re-validation: if new files are added to scratch/ that match our criteria, the admin must ensure they are covered. Any changes to the cleanup policy (e.g. adding a new pattern of file to remove) should go through the same preview-and-verify cycle. This disciplined approach aligns with reviewed DevOps automation practices. Treat automation scripts as code under test.

Practice controlled administration with Refonte Learning

Controlled, verifiable operations like this are the bread-and-butter of a well-trained system administrator. Refonte Learning’s System Administration program offers broader practice in Linux file management, troubleshooting and backup-and-recovery foundations. Their courses emphasize exactly these systematic, evidence-driven techniques. By following a structured playbook (as above) you ensure that each step, from a safe preview to a final deletion, is tested and documented. This approach transforms routine cleanup into a reliable, reviewable process, protecting critical data while achieving the cleanup goals. For more on building these practical skills, see Refonte’s System Administration program.