@@ -191,6 +191,126 @@ public function mark_missing( string $handle ): bool {
191191 return false !== $ result ;
192192 }
193193
194+ /**
195+ * Prune inventory rows flagged missing_path whose on-disk path is still absent.
196+ *
197+ * Safety guards:
198+ * - Re-probes each candidate's path on disk; only deletes when the path is
199+ * STILL absent (a stale missing_path flag alone is not trusted).
200+ * - Refuses to delete rows with unpushed_count > 0 or a non-empty pr_url
201+ * unless 'force' is true; such rows are reported as skipped.
202+ *
203+ * @param array{dry_run?: bool, force?: bool} $opts Options.
204+ * @return array<string,mixed> Result with deleted/skipped lists and summary.
205+ */
206+ public function pruneMissing ( array $ opts = array () ): array {
207+ $ dry_run = ! empty ($ opts ['dry_run ' ]);
208+ $ force = ! empty ($ opts ['force ' ]);
209+
210+ $ rows = $ this ->missing_path_rows ();
211+ $ deleted = array ();
212+ $ skipped = array ();
213+
214+ foreach ( $ rows as $ row ) {
215+ $ handle = (string ) ( $ row ['handle ' ] ?? '' );
216+ $ path = (string ) ( $ row ['path ' ] ?? '' );
217+
218+ // Re-probe the disk: only reap when the path is STILL absent.
219+ if ( '' !== $ path && is_dir ($ path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_dir
220+ $ skipped [] = array (
221+ 'handle ' => $ handle ,
222+ 'path ' => $ path ,
223+ 'reason ' => 'path_present_on_disk ' ,
224+ );
225+ continue ;
226+ }
227+
228+ // Protect rows carrying unpushed work or an open PR unless forced.
229+ if ( ! $ force ) {
230+ $ unpushed = isset ($ row ['unpushed_count ' ]) ? (int ) $ row ['unpushed_count ' ] : 0 ;
231+ $ pr_url = isset ($ row ['pr_url ' ]) ? trim ( (string ) $ row ['pr_url ' ] ) : '' ;
232+
233+ if ( $ unpushed > 0 ) {
234+ $ skipped [] = array (
235+ 'handle ' => $ handle ,
236+ 'path ' => $ path ,
237+ 'reason ' => 'unpushed_count ' ,
238+ 'unpushed_count ' => $ unpushed ,
239+ );
240+ continue ;
241+ }
242+
243+ if ( '' !== $ pr_url ) {
244+ $ skipped [] = array (
245+ 'handle ' => $ handle ,
246+ 'path ' => $ path ,
247+ 'reason ' => 'pr_url ' ,
248+ 'pr_url ' => $ pr_url ,
249+ );
250+ continue ;
251+ }
252+ }
253+
254+ if ( $ dry_run ) {
255+ $ deleted [] = array (
256+ 'handle ' => $ handle ,
257+ 'path ' => $ path ,
258+ 'repo ' => (string ) ( $ row ['repo ' ] ?? '' ),
259+ );
260+ continue ;
261+ }
262+
263+ if ( $ this ->delete ($ handle ) ) {
264+ $ deleted [] = array (
265+ 'handle ' => $ handle ,
266+ 'path ' => $ path ,
267+ 'repo ' => (string ) ( $ row ['repo ' ] ?? '' ),
268+ );
269+ } else {
270+ $ skipped [] = array (
271+ 'handle ' => $ handle ,
272+ 'path ' => $ path ,
273+ 'reason ' => 'delete_failed ' ,
274+ );
275+ }
276+ }
277+
278+ return array (
279+ 'success ' => true ,
280+ 'pruned_at ' => gmdate ('c ' ),
281+ 'dry_run ' => $ dry_run ,
282+ 'deleted ' => $ deleted ,
283+ 'skipped ' => $ skipped ,
284+ 'summary ' => array (
285+ 'deleted ' => count ($ deleted ),
286+ 'skipped ' => count ($ skipped ),
287+ 'total ' => count ($ rows ),
288+ ),
289+ );
290+ }
291+
292+ /**
293+ * Fetch all rows currently flagged missing_path.
294+ *
295+ * @return array<int,array<string,mixed>>
296+ */
297+ private function missing_path_rows (): array {
298+ global $ wpdb ;
299+
300+ if ( ! isset ($ wpdb ) || ! method_exists ($ wpdb , 'get_results ' ) ) {
301+ return array ();
302+ }
303+
304+ $ table = self ::table_name ();
305+ // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input.
306+ $ sql = "SELECT * FROM {$ table } WHERE missing_path = 1 ORDER BY handle ASC " ;
307+ // phpcs:enable WordPress.DB.PreparedSQL
308+
309+ // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Static string, no user input.
310+ $ rows = $ wpdb ->get_results ($ sql , ARRAY_A );
311+ return is_array ($ rows ) ? array_map (array ( $ this , 'decode_row ' ), $ rows ) : array ();
312+ }
313+
194314 /**
195315 * Fetch all rows, optionally filtered by repo.
196316 *
@@ -275,11 +395,11 @@ private function normalize_row( array $row ): array {
275395 'last_seen_at ' => $ this ->datetime ($ row ['last_seen_at ' ] ?? $ metadata ['last_seen_at ' ] ?? null ),
276396 'last_probe_at ' => current_time ('mysql ' , true ),
277397 'last_probe_status ' => ! empty ($ row ['missing_path ' ]) ? 'missing_path ' : 'present ' ,
278- 'dirty_count ' => isset ($ row ['dirty ' ]) ? ( null === $ row [ ' dirty ' ] ? null : ( int ) $ row ['dirty ' ] ) : ( isset ($ row ['dirty_count ' ]) ? (int ) $ row ['dirty_count ' ] : null ),
398+ 'dirty_count ' => isset ($ row ['dirty ' ]) ? (int ) $ row ['dirty ' ] : ( isset ($ row ['dirty_count ' ]) ? (int ) $ row ['dirty_count ' ] : null ),
279399 'unpushed_count ' => isset ($ row ['unpushed_count ' ]) ? (int ) $ row ['unpushed_count ' ] : null ,
280400 'artifact_count ' => isset ($ row ['artifacts ' ]) && is_array ($ row ['artifacts ' ]) ? count ($ row ['artifacts ' ]) : ( isset ($ row ['artifact_count ' ]) ? (int ) $ row ['artifact_count ' ] : null ),
281401 'artifact_size_bytes ' => isset ($ row ['artifact_size_bytes ' ]) ? (int ) $ row ['artifact_size_bytes ' ] : null ,
282- 'size_bytes ' => isset ($ row ['size_bytes ' ]) ? ( null === $ row [ ' size_bytes ' ] ? null : ( int ) $ row ['size_bytes ' ] ) : null ,
402+ 'size_bytes ' => isset ($ row ['size_bytes ' ]) ? (int ) $ row ['size_bytes ' ] : null ,
283403 'cleanup_signal ' => $ this ->cleanup_signal ($ row , $ metadata ),
284404 'missing_path ' => ! empty ($ row ['missing_path ' ]) ? 1 : 0 ,
285405 'metadata ' => JsonCodec::encode_or_default ($ metadata ),
0 commit comments