Skip to content

Add wp media find-orphans subcommand - #252

Open
agenceKanvas wants to merge 5 commits into
wp-cli:mainfrom
nouveauxterritoires:feature/find-orphans
Open

Add wp media find-orphans subcommand#252
agenceKanvas wants to merge 5 commits into
wp-cli:mainfrom
nouveauxterritoires:feature/find-orphans

Conversation

@agenceKanvas

@agenceKanvas agenceKanvas commented Jun 4, 2026

Copy link
Copy Markdown

What it does

wp media find-orphans scans a WordPress site for "orphan" media — files and attachments that are out of sync between the filesystem and the media library — and reports them. It is read-only by design: it never deletes, moves, or modifies anything.

It runs four independent detectors, selectable with --type:

  • filesystem — files present in wp-content/uploads that are not in the media library (File on disk not in media library)
  • database — attachments whose underlying file is missing from disk (Attachment file missing from disk)
  • thumbnails — generated thumbnail files (-WxH) whose parent attachment no longer exists (Thumbnail parent attachment missing)
  • usage — attachments that don't appear to be referenced in any post content (Attachment appears unused in content); featured images are not flagged

Without --type, all detectors run. Output is a table by default and supports --format=table|json|csv|yaml|ids|count, plus --fields, --limit, and --include-thumbnails.

The command always exits 0. Pass --error-on-orphans to exit 1 when any orphan is found — convenient for CI/cron checks.

Extensibility

Two WordPress filters let themes/plugins teach the detectors about their own storage and references:

  • wp_cli_media_find_orphans_ignore_paths — upload subpaths to skip (defaults cover common generated dirs such as caches and form uploads), so plugin-generated assets aren't reported as filesystem orphans.
  • wp_cli_media_find_orphans_used_ids — additional attachment IDs to treat as "used" (receives the scanned post IDs and known attachment IDs as context), so custom references (e.g. postmeta, custom fields) don't yield false positives in the usage detector.

Examples

# Full audit
wp media find-orphans

# Only files on disk missing from the library, as IDs
wp media find-orphans --type=filesystem --format=ids

# Fail a CI job if anything is orphaned
wp media find-orphans --error-on-orphans

Tests

Acceptance coverage lives in features/media-find-orphans.feature — one scenario per capability, each following Arrange → Act → Assert on a fresh WP install (with uploads_use_yearmonth_folders disabled for deterministic paths):

  1. Consistent library → reports No orphan media found and exits 0.
  2. filesystem → a stray file dropped into uploads/ is detected.
  3. database (@require-wp-5.3) → an imported attachment whose file is then rm'd is detected.
  4. thumbnails → a -150x150 file with no parent attachment is detected.
  5. usage → an unused attachment is flagged, while an image used only as a post's featured image is not.
  6. --format=json → output is valid JSON containing the expected fields (type, attachment_id, file, issue, path).
  7. --error-on-orphans → returns a non-zero exit code when orphans exist.

All 7 scenarios pass (72 steps) against the standard wp-cli/wp-cli-tests Behat harness.

Notes

Summary by CodeRabbit

  • New Features

    • Added the wp media find-orphans command to identify potential filesystem, database, thumbnail, and content-usage orphans.
    • Supports filtering by orphan type, JSON/CSV/count output, selected fields, result limits, thumbnail inclusion, and non-zero status when findings exist.
    • Scans are non-destructive and ignore generated subdirectories where appropriate.
  • Tests

    • Added comprehensive coverage for detection, validation, formatting, filtering, limits, exit codes, and clean media libraries.

@agenceKanvas
agenceKanvas requested a review from a team as a code owner June 4, 2026 14:49
@github-actions

This comment was marked as resolved.

@github-actions github-actions Bot added command:media Related to 'media' command scope:testing Related to testing labels Jun 4, 2026
@codecov

codecov Bot commented Jun 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.03704% with 42 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/Media_Command.php 87.03% 42 Missing ⚠️

📢 Thoughts on this report? Let us know!

Add a non-destructive subcommand that finds orphaned media candidates by
comparing the media library, the uploads directory, and content usage.

Detectors (via `--type`, all run by default):
- filesystem: files on disk not in the media library. Restricted to known
              media extensions (get_allowed_mime_types) and skips generated
              subdirectories (elementor, gravity_forms, cache, wpcf7_uploads)
              via the `wp_cli_media_find_orphans_ignore_paths` filter, so
              page-builder noise is excluded.
- database:   attachments whose file is missing from disk (both the
              get_attached_file() and raw _wp_attached_file paths checked
              to avoid `-scaled` false positives).
- thumbnails: generated thumbnails whose parent attachment is gone.
- usage:      attachments unreferenced in content (conservative; scans all
              registered post types; O(M+N) precomputed path->id lookup).

Options: --type, --format (table/json/csv/yaml/ids/count), --fields,
--include-thumbnails, --limit, --error-on-orphans (exit 1 when found).

Usage detection is extensible via the `wp_cli_media_find_orphans_used_ids`
filter so plugins can declare postmeta/ACF/page-builder references.

Adds a Behat feature covering all four types, JSON output, and the
error-on-orphans exit code. Registers the command in composer.json.

Implements wp-cli/ideas#216.
@agenceKanvas
agenceKanvas force-pushed the feature/find-orphans branch from c0a9910 to 10a55e4 Compare June 4, 2026 16:04
@agenceKanvas

Copy link
Copy Markdown
Author

I've tried my best to get a 100% coverage on Codecov, but the missing lines would imply a lot of fixtures and heavy coding for a small result. Is it mandatory ?

@swissspidy

Copy link
Copy Markdown
Member

I've tried my best to get a 100% coverage on Codecov, but the missing lines would imply a lot of fixtures and heavy coding for a small result. Is it mandatory ?

No, not mandatory at all. Thanks for keeping an eye on it though!

This comment was marked as resolved.

agenceKanvas and others added 2 commits June 5, 2026 11:54
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
The post_parent scan collected parent post IDs into $used_ids, which is
a set of attachment IDs. This could misclassify attachments uploaded to
a post (but not referenced in content) as unused, and could shield an
unrelated attachment whose ID collided with a parent post ID. Select the
attachment IDs themselves instead, and cover the post_parent-only case
in the usage scenario.
@agenceKanvas

Copy link
Copy Markdown
Author

Ready to test again :)

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

The PR adds wp media find-orphans to the bundled commands. The command scans filesystem, database, thumbnail, and content-usage orphan candidates with filtering, formatting, limits, and exit-status options. Behat scenarios cover detection, validation, and output behavior.

Media orphan detection

Layer / File(s) Summary
Command entry and output handling
composer.json, src/Media_Command.php, features/media-find-orphans.feature
Registers the command and implements option validation, detector dispatch, result formatting, result limits, empty results, and --error-on-orphans.
Filesystem and media metadata detection
src/Media_Command.php, features/media-find-orphans.feature
Indexes library paths and detects untracked files, missing attachment files, and unregistered thumbnails. The scan skips unsupported, hidden, symlinked, and ignored paths.
Content usage detection
src/Media_Command.php, features/media-find-orphans.feature
Checks featured images, attachment parents, galleries, blocks, attachment URLs, and plugin-specific references before classifying unused attachments.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant WPCLI
  participant find_orphans
  participant OrphanDetectors
  participant OutputFormatter
  WPCLI->>find_orphans: Provide scan options
  find_orphans->>OrphanDetectors: Run selected orphan detectors
  OrphanDetectors-->>find_orphans: Return orphan candidates
  find_orphans->>OutputFormatter: Apply fields, format, and limit
  OutputFormatter-->>WPCLI: Render results and status
Loading

Possibly related issues

  • wp-cli/ideas#216 — Specifies the wp media find-orphans command and its orphan detectors and options.

Suggested reviewers: swissspidy

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the primary change: adding the wp media find-orphans subcommand.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
features/media-find-orphans.feature (1)

137-144: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the negative ID assertions exact.

STDOUT should not contain performs a substring match over the whole table, including the file and path columns. If an attachment ID becomes a substring of another printed number, the scenario fails for the wrong reason. Assert on a single column instead, for example wp media find-orphans --type=usage --field=attachment_id, and compare the exact ID list.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@features/media-find-orphans.feature` around lines 137 - 144, Update the
negative assertions in the media find-orphans scenario to query only the
attachment_id field and compare the exact returned ID list, rather than using
whole-STDOUT substring checks. Preserve the expectation that
FEATURED_ATTACHMENT_ID and ATTACHED_ATTACHMENT_ID are absent from the command’s
exact attachment ID results.
src/Media_Command.php (3)

2848-2856: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Apply the media-extension filter to the thumbnail scan.

detect_filesystem_orphans() restricts candidates to allowed media extensions on Line 2742. This scan does not. A non-media file whose name matches -WxH.ext, for example sprite-12x12.css, is reported as a thumbnail orphan. Reuse get_allowed_media_extensions() here for consistent results between the two scans.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Media_Command.php` around lines 2848 - 2856, Update the thumbnail scan in
detect_filesystem_orphans(), around the is_thumbnail_filename() check, to first
require the file extension from get_allowed_media_extensions(). Continue
scanning non-thumbnail and known declared-size files as before, while excluding
matching thumbnail names with disallowed media extensions such as CSS.

2981-2991: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

Fetch post_content in bulk instead of per post.

get_posts() on Line 2916 uses fields => 'ids', so the post rows are not primed. get_post_field() then issues one query per post. On a large site this is an N+1 over every published and draft post of every post type. Select ID, post_content in batches with $wpdb, or prime the cache with _prime_post_caches() per chunk.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Media_Command.php` around lines 2981 - 2991, Update the processing flow
around the post ID query and foreach loop to avoid calling get_post_field() once
per post. Fetch post IDs with their post_content in batches using $wpdb, or
prime post caches with _prime_post_caches() per chunk, then read each post’s
content from the bulk-loaded data while preserving the existing empty-content
filtering and cache-clearing interval.

2724-2727: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

The filesystem and thumbnail detectors repeat the same two full scans. Each detector independently rebuilds the library path index and walks the whole uploads tree. A default run, which executes both detectors, performs the attachment query and the recursive directory walk twice.

  • src/Media_Command.php#L2724-L2727: cache the get_known_library_paths() result in a private property and reuse it.
  • src/Media_Command.php#L2837-L2837: reuse a single uploads walk for both detectors, or run both classifications inside one pass over iterate_upload_files().
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Media_Command.php` around lines 2724 - 2727, In src/Media_Command.php
lines 2724-2727, cache the result of get_known_library_paths() in a private
property and reuse it across detector runs instead of rebuilding the index. In
src/Media_Command.php line 2837, reuse one iterate_upload_files() traversal for
both filesystem and thumbnail classifications, preserving both detectors’
existing classification behavior while eliminating the duplicate uploads walk.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Media_Command.php`:
- Around line 2636-2643: In src/Media_Command.php lines 2636-2643, cast the raw
fields value and format value to string before passing them to explode() and
Utils::format_items(). In src/Media_Command.php line 3054, replace the intval
callback with a closure that explicitly casts its argument to int. In
src/Media_Command.php lines 3181-3186, filter the
wp_cli_media_find_orphans_ignore_paths results to skip non-string entries rather
than casting them.
- Around line 3087-3093: Update the RecursiveIteratorIterator construction in
the upload directory scan to include the
RecursiveIteratorIterator::CATCH_GET_CHILD flag, allowing unreadable
subdirectories to be skipped while preserving the existing recursive leaf-only
traversal.

---

Nitpick comments:
In `@features/media-find-orphans.feature`:
- Around line 137-144: Update the negative assertions in the media find-orphans
scenario to query only the attachment_id field and compare the exact returned ID
list, rather than using whole-STDOUT substring checks. Preserve the expectation
that FEATURED_ATTACHMENT_ID and ATTACHED_ATTACHMENT_ID are absent from the
command’s exact attachment ID results.

In `@src/Media_Command.php`:
- Around line 2848-2856: Update the thumbnail scan in
detect_filesystem_orphans(), around the is_thumbnail_filename() check, to first
require the file extension from get_allowed_media_extensions(). Continue
scanning non-thumbnail and known declared-size files as before, while excluding
matching thumbnail names with disallowed media extensions such as CSS.
- Around line 2981-2991: Update the processing flow around the post ID query and
foreach loop to avoid calling get_post_field() once per post. Fetch post IDs
with their post_content in batches using $wpdb, or prime post caches with
_prime_post_caches() per chunk, then read each post’s content from the
bulk-loaded data while preserving the existing empty-content filtering and
cache-clearing interval.
- Around line 2724-2727: In src/Media_Command.php lines 2724-2727, cache the
result of get_known_library_paths() in a private property and reuse it across
detector runs instead of rebuilding the index. In src/Media_Command.php line
2837, reuse one iterate_upload_files() traversal for both filesystem and
thumbnail classifications, preserving both detectors’ existing classification
behavior while eliminating the duplicate uploads walk.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b9c03f74-81bb-4aa6-a896-e1a317e61125

📥 Commits

Reviewing files that changed from the base of the PR and between e97f2ba and cd58f81.

📒 Files selected for processing (3)
  • composer.json
  • features/media-find-orphans.feature
  • src/Media_Command.php

Comment thread src/Media_Command.php
Comment thread src/Media_Command.php
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

command:media Related to 'media' command scope:testing Related to testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants