@@ -587,13 +587,21 @@ private function build_worktree_metadata_reconciliation_row( array $wt, array &$
587587 }
588588
589589 if ( array () !== (array ) $ identity ['conflicts ' ] ) {
590+ $ identity_classification = $ this ->classify_worktree_identity_metadata_conflict ($ wt , $ identity );
591+ if ( ! empty ($ identity_classification ['repairable ' ]) && 'stale_identity_metadata ' === (string ) $ identity_classification ['reason_code ' ] ) {
592+ return $ this ->build_stale_worktree_identity_metadata_repair_row ($ base_row , $ metadata , $ identity_classification );
593+ }
594+
590595 return array (
591596 'skip ' => array_merge (
592597 $ base_row ,
593598 array (
594- 'reason_code ' => 'inconsistent_identity_metadata ' ,
595- 'reason ' => 'stored worktree identity metadata does not match the handle/path row ' ,
596- 'identity_conflicts ' => $ identity ['conflicts ' ],
599+ 'reason_code ' => (string ) $ identity_classification ['reason_code ' ],
600+ 'reason ' => (string ) $ identity_classification ['reason ' ],
601+ 'identity_conflicts ' => $ identity ['conflicts ' ],
602+ 'identity_classification ' => (string ) $ identity_classification ['classification ' ],
603+ 'proposed_source_of_truth ' => $ identity_classification ['proposed_source_of_truth ' ],
604+ 'next_command ' => (string ) $ identity_classification ['next_command ' ],
597605 )
598606 ),
599607 );
@@ -1029,6 +1037,204 @@ private function set_reconciled_metadata_field( array &$metadata, array &$source
10291037 $ source_map [ $ field ] = $ source ;
10301038 }
10311039
1040+ /**
1041+ * Classify identity metadata conflicts into operator-actionable buckets.
1042+ *
1043+ * @param array<string,mixed> $wt Worktree row.
1044+ * @param array<string,mixed> $identity Recovered identity data.
1045+ * @return array<string,mixed>
1046+ */
1047+ private function classify_worktree_identity_metadata_conflict ( array $ wt , array $ identity ): array {
1048+ $ handle = (string ) ( $ wt ['handle ' ] ?? '' );
1049+ $ repo = (string ) ( $ wt ['repo ' ] ?? '' );
1050+ $ branch = (string ) ( $ wt ['branch ' ] ?? '' );
1051+ $ path = rtrim ((string ) ( $ wt ['path ' ] ?? '' ), '/ ' );
1052+ $ parsed = '' !== $ handle ? $ this ->parse_handle ($ handle ) : array ( 'repo ' => '' , 'branch_slug ' => '' , 'is_worktree ' => false );
1053+ $ handle_branch = (string ) ( $ parsed ['branch_slug ' ] ?? '' );
1054+ $ branch_slug = $ this ->slugify_branch ($ branch );
1055+ $ path_basename = '' !== $ path ? basename ($ path ) : '' ;
1056+ $ handle_path = '' !== $ handle && $ path_basename === $ handle ;
1057+ $ handle_repo = ! empty ($ parsed ['is_worktree ' ]) && (string ) ( $ parsed ['repo ' ] ?? '' ) === $ repo ;
1058+ $ handle_branch_matches_current = '' !== $ branch_slug && $ branch_slug === $ handle_branch ;
1059+ $ default_branch = $ this ->resolve_worktree_identity_default_branch ((string ) ( $ identity ['repo ' ] ?? $ repo ));
1060+
1061+ $ base = array (
1062+ 'classification ' => 'manual_review_identity_metadata ' ,
1063+ 'reason_code ' => 'manual_review_identity_metadata ' ,
1064+ 'reason ' => 'stored worktree identity metadata conflicts with the current row and no safe automatic source of truth is available ' ,
1065+ 'repairable ' => false ,
1066+ 'proposed_source_of_truth ' => array (
1067+ 'handle ' => 'manual_review ' ,
1068+ 'repo ' => 'manual_review ' ,
1069+ 'branch ' => 'manual_review ' ,
1070+ 'path ' => 'manual_review ' ,
1071+ ),
1072+ 'next_command ' => 'studio wp datamachine-code workspace worktree reconcile-metadata --dry-run --format=json ' ,
1073+ );
1074+
1075+ if ( $ handle_repo && $ handle_path && $ handle_branch_matches_current ) {
1076+ return array_merge (
1077+ $ base ,
1078+ array (
1079+ 'classification ' => 'stale_identity_metadata ' ,
1080+ 'reason_code ' => 'stale_identity_metadata ' ,
1081+ 'reason ' => 'stored identity metadata is stale; current handle, path, and git branch agree ' ,
1082+ 'repairable ' => true ,
1083+ 'proposed_source_of_truth ' => array (
1084+ 'handle ' => 'filesystem_handle ' ,
1085+ 'repo ' => 'filesystem_handle ' ,
1086+ 'branch ' => 'current_git_branch ' ,
1087+ 'path ' => 'git_worktree_path ' ,
1088+ ),
1089+ 'next_command ' => 'studio wp datamachine-code workspace worktree reconcile-metadata --apply --format=json ' ,
1090+ )
1091+ );
1092+ }
1093+
1094+ if ( $ handle_repo && $ handle_path && '' !== $ branch && $ default_branch === $ branch ) {
1095+ return array_merge (
1096+ $ base ,
1097+ array (
1098+ 'classification ' => 'default_branch_checkout_in_feature_worktree ' ,
1099+ 'reason_code ' => 'default_branch_checkout_in_feature_worktree ' ,
1100+ 'reason ' => sprintf ('worktree handle is feature-scoped, but git is currently on the default branch %s ' , $ branch ),
1101+ 'proposed_source_of_truth ' => array (
1102+ 'handle ' => 'filesystem_handle ' ,
1103+ 'repo ' => 'filesystem_handle ' ,
1104+ 'branch ' => 'operator_review_required ' ,
1105+ 'path ' => 'git_worktree_path ' ,
1106+ ),
1107+ 'next_command ' => sprintf ('git -C %s switch <intended-feature-branch> ' , escapeshellarg ($ path )),
1108+ )
1109+ );
1110+ }
1111+
1112+ if ( $ handle_repo && $ handle_path && '' !== $ branch && ! $ handle_branch_matches_current ) {
1113+ return array_merge (
1114+ $ base ,
1115+ array (
1116+ 'classification ' => 'branch_renamed_worktree ' ,
1117+ 'reason_code ' => 'branch_renamed_worktree ' ,
1118+ 'reason ' => 'git branch no longer matches the canonical branch slug encoded in the worktree handle/path ' ,
1119+ 'proposed_source_of_truth ' => array (
1120+ 'handle ' => 'filesystem_handle ' ,
1121+ 'repo ' => 'filesystem_handle ' ,
1122+ 'branch ' => 'current_git_branch ' ,
1123+ 'path ' => 'git_worktree_path ' ,
1124+ ),
1125+ 'next_command ' => sprintf ('studio wp datamachine-code workspace worktree add %s %s --from=%s ' , escapeshellarg ($ repo ), escapeshellarg ($ branch ), escapeshellarg ($ branch )),
1126+ )
1127+ );
1128+ }
1129+
1130+ return $ base ;
1131+ }
1132+
1133+ /**
1134+ * Resolve the short default branch name for identity diagnostics.
1135+ */
1136+ private function resolve_worktree_identity_default_branch ( string $ repo ): string {
1137+ if ( '' === $ repo ) {
1138+ return '' ;
1139+ }
1140+
1141+ $ primary_path = $ this ->get_primary_path ($ repo );
1142+ if ( ! is_dir ($ primary_path . '/.git ' ) ) {
1143+ return '' ;
1144+ }
1145+
1146+ $ default_ref = $ this ->resolve_remote_default_ref ($ primary_path , self ::CLEANUP_GIT_PROBE_TIMEOUT );
1147+ if ( is_wp_error ($ default_ref ) || ! is_string ($ default_ref ) || '' === $ default_ref ) {
1148+ return '' ;
1149+ }
1150+
1151+ $ prefix = 'refs/remotes/origin/ ' ;
1152+ return str_starts_with ($ default_ref , $ prefix ) ? substr ($ default_ref , strlen ($ prefix )) : basename ($ default_ref );
1153+ }
1154+
1155+ /**
1156+ * Build a metadata-only repair proposal for stale stored identity metadata.
1157+ *
1158+ * @param array<string,mixed> $base_row Shared reconciliation row data.
1159+ * @param array<string,mixed> $metadata Stored metadata.
1160+ * @param array<string,mixed> $classification Identity classification.
1161+ * @return array{proposal?:array<string,mixed>,skip?:array<string,mixed>}
1162+ */
1163+ private function build_stale_worktree_identity_metadata_repair_row ( array $ base_row , array $ metadata , array $ classification ): array {
1164+ $ handle = (string ) ( $ base_row ['handle ' ] ?? '' );
1165+ $ repo = (string ) ( $ base_row ['repo ' ] ?? '' );
1166+ $ branch = (string ) ( $ base_row ['branch ' ] ?? '' );
1167+ $ path = (string ) ( $ base_row ['path ' ] ?? '' );
1168+
1169+ $ dirty = $ this ->probe_worktree_dirty_count ($ path , self ::CLEANUP_GIT_PROBE_TIMEOUT );
1170+ if ( is_wp_error ($ dirty ) ) {
1171+ $ diagnostic = $ this ->classify_worktree_git_probe_failure ($ handle , $ repo , $ path , $ dirty , 'dirty-state probe ' , 'leaving stale identity metadata unchanged ' );
1172+ return array ( 'skip ' => array_merge ($ base_row , $ classification , $ diagnostic ) );
1173+ }
1174+
1175+ $ unpushed = $ this ->count_unpushed_commits ($ path );
1176+ if ( is_wp_error ($ unpushed ) ) {
1177+ $ diagnostic = $ this ->classify_worktree_git_probe_failure ($ handle , $ repo , $ path , $ unpushed , 'cleanup safety probe ' , 'leaving stale identity metadata unchanged ' );
1178+ return array ( 'skip ' => array_merge ($ base_row , $ classification , $ diagnostic ) );
1179+ }
1180+
1181+ if ( (int ) $ dirty > 0 || (int ) $ unpushed > 0 ) {
1182+ return array (
1183+ 'skip ' => array_merge (
1184+ $ base_row ,
1185+ $ classification ,
1186+ array (
1187+ 'reason_code ' => 'unsafe_stale_identity_metadata ' ,
1188+ 'reason ' => 'stale identity metadata is repairable, but dirty or unpushed worktree state blocks automatic metadata writes ' ,
1189+ 'dirty ' => (int ) $ dirty ,
1190+ 'unpushed ' => (int ) $ unpushed ,
1191+ )
1192+ ),
1193+ );
1194+ }
1195+
1196+ $ proposed = $ metadata ;
1197+ $ source_map = array ();
1198+ $ this ->set_reconciled_metadata_field ($ proposed , $ source_map , 'handle ' , $ handle , 'filesystem ' );
1199+ $ this ->set_reconciled_metadata_field ($ proposed , $ source_map , 'repo ' , $ repo , 'filesystem ' );
1200+ $ this ->set_reconciled_metadata_field ($ proposed , $ source_map , 'branch ' , $ branch , 'git ' );
1201+ $ this ->set_reconciled_metadata_field ($ proposed , $ source_map , 'path ' , $ path , 'git ' );
1202+ $ this ->set_reconciled_metadata_field ($ proposed , $ source_map , 'observed_at ' , gmdate ('c ' ), 'reconcile_run ' );
1203+
1204+ $ created_at = '' ;
1205+ if ( ! empty ($ metadata ['created_at ' ]) && false !== strtotime ((string ) $ metadata ['created_at ' ]) ) {
1206+ $ created_at = gmdate ('c ' , (int ) strtotime ((string ) $ metadata ['created_at ' ]));
1207+ $ this ->set_reconciled_metadata_field ($ proposed , $ source_map , 'created_at ' , $ created_at , 'metadata ' );
1208+ } else {
1209+ $ mtime = file_exists ($ path ) ? filemtime ($ path ) : false ;
1210+ if ( false !== $ mtime ) {
1211+ $ created_at = gmdate ('c ' , (int ) $ mtime );
1212+ $ this ->set_reconciled_metadata_field ($ proposed , $ source_map , 'created_at ' , $ created_at , 'filesystem ' );
1213+ }
1214+ }
1215+
1216+ $ state = isset ($ metadata ['lifecycle_state ' ]) ? WorktreeContextInjector::normalize_state ((string ) $ metadata ['lifecycle_state ' ]) : null ;
1217+ $ this ->set_reconciled_metadata_field ($ proposed , $ source_map , 'lifecycle_state ' , $ state ?? WorktreeContextInjector::STATE_ACTIVE , null === $ state ? 'operator_plan ' : 'metadata ' );
1218+
1219+ return array (
1220+ 'proposal ' => array_merge (
1221+ $ base_row ,
1222+ array (
1223+ 'reason_code ' => 'stale_identity_metadata ' ,
1224+ 'reason ' => (string ) $ classification ['reason ' ],
1225+ 'dirty ' => (int ) $ dirty ,
1226+ 'unpushed ' => (int ) $ unpushed ,
1227+ 'identity_conflicts ' => $ base_row ['identity_conflicts ' ] ?? array (),
1228+ 'identity_classification ' => 'stale_identity_metadata ' ,
1229+ 'proposed_source_of_truth ' => $ classification ['proposed_source_of_truth ' ],
1230+ 'next_command ' => (string ) $ classification ['next_command ' ],
1231+ 'proposed_metadata ' => $ proposed ,
1232+ 'source_map ' => $ source_map ,
1233+ )
1234+ ),
1235+ );
1236+ }
1237+
10321238 /**
10331239 * Apply a reviewed metadata reconciliation plan after exact revalidation.
10341240 *
0 commit comments