diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml index dcdb18a6..7a2d1e71 100644 --- a/.github/workflows/release-verify.yml +++ b/.github/workflows/release-verify.yml @@ -153,6 +153,7 @@ jobs: # includes every input that affects the produced binaries. - name: Cache .build uses: actions/cache@v5 + continue-on-error: true with: path: .build key: >- diff --git a/crates/forkpress-cli/src/app.rs b/crates/forkpress-cli/src/app.rs index 6717e2bb..983357af 100644 --- a/crates/forkpress-cli/src/app.rs +++ b/crates/forkpress-cli/src/app.rs @@ -4037,6 +4037,7 @@ fn cow_branch_command( let mut apply = false; let mut apply_reviewed = false; let mut after_revalidate = false; + let mut replace_applied = false; let mut note: Option = None; let mut reviewer: Option = None; let mut run: Option = None; @@ -4062,6 +4063,10 @@ fn cow_branch_command( after_revalidate = true; index += 1; } + "--replace-applied" => { + replace_applied = true; + index += 1; + } "--note" => { let Some(value) = args.args.get(index + 1) else { bail!("--note requires text"); @@ -4104,6 +4109,20 @@ fn cow_branch_command( if !apply_reviewed && choice.is_none() { bail!("branch merge-resolve requires --choice source|target or --apply-reviewed"); } + if replace_applied && record_type != "conflict" { + bail!( + "--replace-applied can only be used with `forkpress branch merge-resolve conflict `" + ); + } + if replace_applied && apply_reviewed { + bail!("--replace-applied cannot be combined with --apply-reviewed"); + } + if replace_applied && !apply { + bail!("--replace-applied requires --apply"); + } + if replace_applied && after_revalidate { + bail!("--replace-applied cannot be combined with --after-revalidate"); + } if record_type == "conflict-key" { resolve_cow_merge_conflict_key( &layout, @@ -4128,6 +4147,7 @@ fn cow_branch_command( apply, apply_reviewed, after_revalidate, + replace_applied, note.as_deref(), reviewer.as_deref(), )?; @@ -4215,7 +4235,7 @@ fn branch_help_text(command: Option<&str>) -> &'static str { "Usage: forkpress branch merge-review --status --note [--reviewer ]\n forkpress branch merge-review conflict-key [--run ] --status --note [--reviewer ]\n\nAttach review metadata to an audit record. Reviewing by conflict key is allowed only when the key identifies one unresolved conflict, or when --run disambiguates it.\n" } Some("merge-resolve") => { - "Usage: forkpress branch merge-resolve conflict (--choice [--apply]|--apply-reviewed) [--after-revalidate] [--note ] [--reviewer ]\n forkpress branch merge-resolve conflict-key [--run ] (--choice [--apply]|--apply-reviewed) [--after-revalidate] [--note ] [--reviewer ]\n\nValidate or apply a reviewed merge conflict choice. Resolving by conflict key is allowed only when the key identifies one unresolved conflict, or when --run disambiguates it. Use --apply-reviewed to apply the latest validated choice. Use --after-revalidate only after merge-audit --revalidate has carried a stale DB row/cell, file conflict, or compatible source-added schema index/view/trigger conflict back to needs-action.\n" + "Usage: forkpress branch merge-resolve conflict (--choice [--apply]|--apply-reviewed) [--after-revalidate] [--replace-applied] [--note ] [--reviewer ]\n forkpress branch merge-resolve conflict-key [--run ] (--choice [--apply]|--apply-reviewed) [--after-revalidate] [--note ] [--reviewer ]\n\nValidate or apply a reviewed merge conflict choice. Resolving by conflict key is allowed only when the key identifies one unresolved conflict, or when --run disambiguates it. Use --apply-reviewed to apply the latest validated choice. Use --after-revalidate only after merge-audit --revalidate has carried a stale DB row/cell, file conflict, or compatible source-added schema index/view/trigger conflict back to needs-action. Use --replace-applied with conflict , --choice source|target, and --apply to change an already-applied DB cell conflict resolution when the target cell still matches the previous applied resolution.\n" } Some("merge-apply-reviewed") => { "Usage: forkpress branch merge-apply-reviewed [--run ] [--limit ] [--note ] [--reviewer ] [--format text|json]\n\nApply every currently validated, unapplied generic conflict resolution in the review queue. Inspect the same queue first with `forkpress branch merge-audit --next-action apply-reviewed-choice`.\n" @@ -6968,6 +6988,7 @@ mod git_helper_tests { branch_help_text(Some("merge-resolve")) .contains("source-added schema index/view/trigger") ); + assert!(branch_help_text(Some("merge-resolve")).contains("--replace-applied")); assert!(branch_help_text(None).contains("merge-apply-reviewed")); assert!(branch_help_text(Some("merge-apply-reviewed")).contains("apply-reviewed-choice")); assert!(branch_help_text(None).contains("conflicts [options]")); diff --git a/crates/forkpress-storage/src/lib.rs b/crates/forkpress-storage/src/lib.rs index 9a3b991c..f9e3cbaa 100644 --- a/crates/forkpress-storage/src/lib.rs +++ b/crates/forkpress-storage/src/lib.rs @@ -1937,6 +1937,7 @@ pub fn resolve_cow_merge_conflict( apply: bool, apply_reviewed: bool, after_revalidate: bool, + replace_applied: bool, note: Option<&str>, reviewer: Option<&str>, ) -> Result<()> { @@ -1961,6 +1962,9 @@ pub fn resolve_cow_merge_conflict( if after_revalidate { args.push("--after-revalidate".into()); } + if replace_applied { + args.push("--replace-applied".into()); + } if let Some(note) = note { args.push("--note".into()); args.push(note.into()); diff --git a/docs/assets/pr-395/branch-timeline.png b/docs/assets/pr-395/branch-timeline.png new file mode 100644 index 00000000..9c3f3d2a Binary files /dev/null and b/docs/assets/pr-395/branch-timeline.png differ diff --git a/docs/assets/pr-395/conflict-mobile.png b/docs/assets/pr-395/conflict-mobile.png new file mode 100644 index 00000000..207546fe Binary files /dev/null and b/docs/assets/pr-395/conflict-mobile.png differ diff --git a/docs/assets/pr-395/conflict-resolution-plugin.png b/docs/assets/pr-395/conflict-resolution-plugin.png new file mode 100644 index 00000000..b4bf0d93 Binary files /dev/null and b/docs/assets/pr-395/conflict-resolution-plugin.png differ diff --git a/docs/assets/pr-395/conflict-theme.png b/docs/assets/pr-395/conflict-theme.png new file mode 100644 index 00000000..3662b3e5 Binary files /dev/null and b/docs/assets/pr-395/conflict-theme.png differ diff --git a/runtime/cow/router.php b/runtime/cow/router.php index 657b2953..fef1b053 100644 --- a/runtime/cow/router.php +++ b/runtime/cow/router.php @@ -144,7 +144,7 @@ function forkpress_cow_acquire_request_lock(): bool { function forkpress_cow_is_admin_branch_action(string $path): bool { $action = $_REQUEST['action'] ?? ''; - if (!is_string($action) || !in_array($action, ['forkpress_branch_create', 'forkpress_branch_merge', 'forkpress_branch_conflicts', 'forkpress_branch_restore_crash', 'forkpress_branch_revalidate_conflicts', 'forkpress_branch_review_conflict', 'forkpress_branch_resolve_conflict', 'forkpress_branch_apply_reviewed_conflicts', 'forkpress_branch_run_plugin_driver'], true)) { + if (!is_string($action) || !in_array($action, ['forkpress_branch_create', 'forkpress_branch_merge', 'forkpress_branch_history', 'forkpress_branch_tree', 'forkpress_branch_conflicts', 'forkpress_branch_restore_crash', 'forkpress_branch_revalidate_conflicts', 'forkpress_branch_review_conflict', 'forkpress_branch_resolve_conflict', 'forkpress_branch_apply_reviewed_conflicts', 'forkpress_branch_run_plugin_driver'], true)) { return false; } if ($path === '/wp-admin/admin-post.php') { @@ -222,12 +222,39 @@ function forkpress_cow_branch_conflict_audit_filters(): array { return ['error' => null, 'args' => $args, 'filters' => $filters]; } +function forkpress_cow_request_scheme(): string { + $forwarded = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''; + if (is_string($forwarded) && $forwarded !== '') { + $scheme = strtolower(trim(explode(',', $forwarded)[0])); + if (in_array($scheme, ['http', 'https'], true)) { + return $scheme; + } + } + $forwarded_ssl = strtolower((string)($_SERVER['HTTP_X_FORWARDED_SSL'] ?? '')); + if ($forwarded_ssl === 'on' || $forwarded_ssl === '1') { + return 'https'; + } + $https = strtolower((string)($_SERVER['HTTPS'] ?? '')); + if ($https !== '' && $https !== 'off') { + return 'https'; + } + $request_scheme = strtolower((string)($_SERVER['REQUEST_SCHEME'] ?? '')); + if (in_array($request_scheme, ['http', 'https'], true)) { + return $request_scheme; + } + return 'http'; +} + function forkpress_cow_branch_url(string $branch, string $uri = '/wp-admin/'): string { $root_host = getenv('FORKPRESS_ROOT_HOST') ?: 'wp.localhost'; $current_host = $_SERVER['HTTP_HOST'] ?? ''; $port = preg_match('/:(\d+)$/', $current_host, $m) ? ':' . $m[1] : ''; $host = $branch === 'main' ? $root_host : $branch . '.' . $root_host; - return 'http://' . $host . $port . $uri; + return forkpress_cow_request_scheme() . '://' . $host . $port . $uri; +} + +function forkpress_cow_branch_manager_url(string $branch): string { + return forkpress_cow_branch_url($branch, '/_forkpress/branches'); } function forkpress_cow_request_can_write(): bool { @@ -235,7 +262,7 @@ function forkpress_cow_request_can_write(): bool { return !in_array($method, ['GET', 'HEAD', 'OPTIONS'], true); } -function forkpress_cow_branch_names(string $current_branch): array { +function forkpress_cow_branch_names(string $current_branch, array $extra_branches = []): array { $branch_list = getenv('FORKPRESS_BRANCH_LIST') ?: ''; $branches = []; if (is_string($branch_list) && $branch_list !== '' && is_readable($branch_list)) { @@ -246,6 +273,7 @@ function forkpress_cow_branch_names(string $current_branch): array { } } } + $branches = array_merge($branches, $extra_branches); if (!$branches) { $branches[] = $current_branch; } @@ -261,19 +289,24 @@ function forkpress_cow_branch_names(string $current_branch): array { return $branches; } -function forkpress_cow_branch_switcher_data(string $current_branch): array { - return array_map(static function (string $branch) use ($current_branch): array { +function forkpress_cow_branch_switcher_data(string $current_branch, string $uri = '/wp-admin/', array $extra_branches = []): array { + return array_map(static function (string $branch) use ($current_branch, $uri): array { return [ 'name' => $branch, - 'url' => forkpress_cow_branch_url($branch), + 'url' => forkpress_cow_branch_url($branch, $uri), + 'siteUrl' => forkpress_cow_branch_url($branch, '/'), + 'adminUrl' => forkpress_cow_branch_url($branch, '/wp-admin/'), + 'managerUrl' => forkpress_cow_branch_manager_url($branch), 'current' => $branch === $current_branch, ]; - }, forkpress_cow_branch_names($current_branch)); + }, forkpress_cow_branch_names($current_branch, $extra_branches)); } function forkpress_cow_branch_finish_json(int $status, string $url, bool $success, string $message, array $data = []): void { http_response_code($status); header('Content-Type: application/json; charset=UTF-8'); + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + header('Pragma: no-cache'); echo json_encode(array_merge([ 'success' => $success, 'type' => $success ? 'notice' : 'error', @@ -314,6 +347,141 @@ function forkpress_cow_branch_run_cli(array $args): array { return [(int)$code, trim((string)$stdout . "\n" . (string)$stderr)]; } +function forkpress_cow_metadata_db_path(string $branches_dir): string { + $cow_dir = getenv('FORKPRESS_COW_DIR') ?: dirname(rtrim($branches_dir, "/\\")); + return rtrim($cow_dir, "/\\") . '/merge/metadata.sqlite'; +} + +function forkpress_cow_metadata_branch_names(string $branches_dir): array { + $metadata_db = forkpress_cow_metadata_db_path($branches_dir); + if (!class_exists('SQLite3') || !is_file($metadata_db)) { + return []; + } + + try { + $db = new SQLite3($metadata_db, SQLITE3_OPEN_READONLY); + $names = []; + foreach ([ + 'SELECT DISTINCT source_branch AS branch FROM merge_runs UNION SELECT DISTINCT target_branch AS branch FROM merge_runs', + 'SELECT DISTINCT branch_name AS branch FROM merge_autoincrement_bands', + 'SELECT DISTINCT branch_name AS branch FROM merge_row_identities', + ] as $sql) { + $rows = @$db->query($sql); + if (!$rows instanceof SQLite3Result) { + continue; + } + while ($row = $rows->fetchArray(SQLITE3_ASSOC)) { + $name = trim((string)($row['branch'] ?? '')); + if ($name !== '' && preg_match('/^[a-zA-Z0-9_\-]{1,63}$/', $name)) { + $names[$name] = true; + } + } + $rows->finalize(); + } + $db->close(); + return array_keys($names); + } catch (Throwable) { + return []; + } +} + +function forkpress_cow_branch_tree_report_from_metadata(string $branches_dir, int $limit): ?array { + $metadata_db = forkpress_cow_metadata_db_path($branches_dir); + if (!class_exists('SQLite3') || !is_file($metadata_db)) { + return null; + } + + try { + $db = new SQLite3($metadata_db, SQLITE3_OPEN_READONLY); + $stmt = $db->prepare( + 'SELECT r.id, r.source_branch, r.target_branch, r.base_ref, r.started_at, r.finished_at, r.status, r.policy, ' . + 'r.source_db, r.target_db, r.base_db, r.source_root, r.target_root, r.target_before_db, r.target_before_root, r.failure_reason, ' . + '(SELECT COUNT(*) FROM merge_conflicts c WHERE c.run_id = r.id) AS conflict_count, ' . + '(SELECT COUNT(*) FROM merge_decisions d WHERE d.run_id = r.id) AS decision_count ' . + 'FROM merge_runs r ORDER BY r.id DESC LIMIT :limit' + ); + if (!$stmt instanceof SQLite3Stmt) { + $db->close(); + return null; + } + $stmt->bindValue(':limit', $limit, SQLITE3_INTEGER); + $rows = $stmt->execute(); + if (!$rows instanceof SQLite3Result) { + $db->close(); + return null; + } + $runs = []; + while ($row = $rows->fetchArray(SQLITE3_ASSOC)) { + $row['id'] = (int)($row['id'] ?? 0); + $row['conflict_count'] = (int)($row['conflict_count'] ?? 0); + $row['decision_count'] = (int)($row['decision_count'] ?? 0); + $runs[] = $row; + } + $rows->finalize(); + $db->close(); + return [ + 'runs' => $runs, + 'metadata_db' => $metadata_db, + 'branches' => forkpress_cow_metadata_branch_names($branches_dir), + 'source' => 'metadata', + ]; + } catch (Throwable) { + return null; + } +} + +function forkpress_cow_branch_conflict_report_from_metadata(string $branches_dir, int $run): ?array { + $metadata_db = forkpress_cow_metadata_db_path($branches_dir); + if (!class_exists('SQLite3') || !is_file($metadata_db)) { + return null; + } + + try { + $db = new SQLite3($metadata_db, SQLITE3_OPEN_READONLY); + $stmt = $db->prepare( + 'SELECT c.*, r.source_branch, r.target_branch, r.source_db, r.target_db, r.base_db, ' . + '(SELECT ce.lifecycle_state FROM merge_conflict_events ce WHERE ce.conflict_id = c.id ORDER BY ce.id DESC LIMIT 1) AS lifecycle_state, ' . + '(SELECT ce.event_type FROM merge_conflict_events ce WHERE ce.conflict_id = c.id ORDER BY ce.id DESC LIMIT 1) AS latest_event_type, ' . + '(SELECT rn.status FROM merge_review_notes rn WHERE rn.record_type = "conflict" AND rn.record_id = c.id ORDER BY rn.id DESC LIMIT 1) AS review_status, ' . + '(SELECT rn.note FROM merge_review_notes rn WHERE rn.record_type = "conflict" AND rn.record_id = c.id ORDER BY rn.id DESC LIMIT 1) AS review_note, ' . + '(SELECT mr.id FROM merge_resolutions mr WHERE mr.conflict_id = c.id ORDER BY mr.id DESC LIMIT 1) AS latest_resolution_id, ' . + '(SELECT mr.choice FROM merge_resolutions mr WHERE mr.conflict_id = c.id ORDER BY mr.id DESC LIMIT 1) AS latest_resolution_choice, ' . + '(SELECT mr.applied FROM merge_resolutions mr WHERE mr.conflict_id = c.id ORDER BY mr.id DESC LIMIT 1) AS latest_resolution_applied, ' . + '(SELECT mr.status FROM merge_resolutions mr WHERE mr.conflict_id = c.id ORDER BY mr.id DESC LIMIT 1) AS latest_resolution_status, ' . + '(SELECT rv.revalidation_class FROM merge_revalidations rv WHERE rv.conflict_id = c.id ORDER BY rv.id DESC LIMIT 1) AS stale_status ' . + 'FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE c.run_id = :run ORDER BY c.id ASC' + ); + if (!$stmt instanceof SQLite3Stmt) { + $db->close(); + return null; + } + $stmt->bindValue(':run', $run, SQLITE3_INTEGER); + $rows = $stmt->execute(); + if (!$rows instanceof SQLite3Result) { + $db->close(); + return null; + } + $conflicts = []; + while ($row = $rows->fetchArray(SQLITE3_ASSOC)) { + $row['id'] = (int)($row['id'] ?? 0); + $row['run_id'] = (int)($row['run_id'] ?? 0); + $row['latest_resolution_id'] = isset($row['latest_resolution_id']) ? (int)$row['latest_resolution_id'] : null; + $row['latest_resolution_applied'] = isset($row['latest_resolution_applied']) ? (int)$row['latest_resolution_applied'] : 0; + $row['resolution_choices'] = ['source', 'target']; + $conflicts[] = $row; + } + $rows->finalize(); + $db->close(); + return [ + 'conflicts' => $conflicts, + 'metadata_db' => $metadata_db, + 'source' => 'metadata', + ]; + } catch (Throwable) { + return null; + } +} + function forkpress_cow_branch_plugin_driver_entry(string $plugin, string $driver): ?array { $plugin = trim($plugin); $driver = trim($driver); @@ -516,8 +684,433 @@ function forkpress_cow_branch_crash_recovery_summary(array $report, int $run): a ]; } +function forkpress_cow_branch_history_summary(array $report, int $limit): array { + $records = is_array($report['runs'] ?? null) ? array_values($report['runs']) : []; + return [ + 'records' => $records, + 'recordCount' => count($records), + 'limit' => $limit, + 'historyCommand' => 'forkpress branch history --limit ' . $limit . ' --format json', + 'audit' => $report, + ]; +} + +function forkpress_cow_branch_run_conflict_summaries(array $report, array $records): array { + $metadata_db = (string)($report['metadata_db'] ?? ''); + if ($metadata_db === '' || !is_file($metadata_db) || !class_exists('SQLite3')) { + return []; + } + $run_ids = []; + foreach ($records as $record) { + if (!is_array($record)) { + continue; + } + $id = (int)($record['id'] ?? 0); + if ($id > 0 && (int)($record['conflict_count'] ?? 0) > 0) { + $run_ids[$id] = true; + } + } + if ($run_ids === []) { + return []; + } + + try { + $db = new SQLite3($metadata_db, SQLITE3_OPEN_READONLY); + $ids = implode(',', array_keys($run_ids)); + $rows = $db->query( + "SELECT c.run_id, COUNT(*) AS total, " . + "SUM(CASE WHEN COALESCE((SELECT ce.lifecycle_state FROM merge_conflict_events ce WHERE ce.conflict_id = c.id ORDER BY ce.id DESC LIMIT 1), '') = 'resolved' " . + "OR COALESCE((SELECT mr.applied FROM merge_resolutions mr WHERE mr.conflict_id = c.id ORDER BY mr.id DESC LIMIT 1), 0) = 1 " . + "THEN 1 ELSE 0 END) AS resolved " . + "FROM merge_conflicts c WHERE c.run_id IN ($ids) GROUP BY c.run_id" + ); + if (!$rows instanceof SQLite3Result) { + return []; + } + $summaries = []; + while ($row = $rows->fetchArray(SQLITE3_ASSOC)) { + $run = (int)($row['run_id'] ?? 0); + $total = max(0, (int)($row['total'] ?? 0)); + $resolved = max(0, min($total, (int)($row['resolved'] ?? 0))); + $summaries[$run] = [ + 'total' => $total, + 'resolved' => $resolved, + 'unresolved' => max(0, $total - $resolved), + ]; + } + $rows->finalize(); + $db->close(); + return $summaries; + } catch (Throwable $e) { + return []; + } +} + +function forkpress_cow_branch_tree_summary(array $report, int $limit): array { + $records = is_array($report['runs'] ?? null) ? array_values($report['runs']) : []; + $summaries = forkpress_cow_branch_run_conflict_summaries($report, $records); + foreach ($records as &$record) { + if (!is_array($record)) { + continue; + } + $conflicts = (int)($record['conflict_count'] ?? 0); + if ($conflicts <= 0 || isset($record['conflictSummary']) || isset($record['conflict_summary'])) { + continue; + } + $id = (int)($record['id'] ?? 0); + if (isset($summaries[$id])) { + $record['conflictSummary'] = $summaries[$id]; + } else { + $record['conflictSummary'] = [ + 'total' => $conflicts, + 'resolved' => 0, + 'unresolved' => $conflicts, + 'estimated' => true, + ]; + } + } + unset($record); + return [ + 'records' => $records, + 'recordCount' => count($records), + 'limit' => $limit, + 'treeCommand' => 'forkpress branch tree --limit ' . $limit . ' --format json', + 'audit' => $report, + ]; +} + +function forkpress_cow_decode_typed_payload($value) { + if (!is_string($value) || $value === '') { + return null; + } + $decoded = json_decode($value, true); + if (!is_array($decoded)) { + return null; + } + $plain = static function ($item) use (&$plain) { + if (!is_array($item)) { + return $item; + } + if (array_key_exists('type', $item)) { + $type = (string)($item['type'] ?? ''); + if ($type === 'bytes' && is_string($item['base64'] ?? null)) { + $bytes = base64_decode((string)$item['base64'], true); + return $bytes === false ? '' : $bytes; + } + if (array_key_exists('value', $item)) { + return $item['value']; + } + if ($type === 'null') { + return null; + } + } + $out = []; + foreach ($item as $key => $child) { + $out[$key] = $plain($child); + } + return $out; + }; + return $plain($decoded); +} + +if (!function_exists('forkpress_cow_sqlite_identifier')) { + function forkpress_cow_sqlite_identifier(string $name): string { + return '"' . str_replace('"', '""', $name) . '"'; + } +} + +function forkpress_cow_sqlite_columns(SQLite3 $db, string $table): array { + $columns = []; + $res = @$db->query('PRAGMA table_info(' . forkpress_cow_sqlite_identifier($table) . ')'); + if (!$res instanceof SQLite3Result) { + return []; + } + while ($row = $res->fetchArray(SQLITE3_ASSOC)) { + if (is_string($row['name'] ?? null)) { + $columns[(string)$row['name']] = true; + } + } + $res->finalize(); + return $columns; +} + +function forkpress_cow_first_conflict_context_row(array $record, string $table, string $where_column, $where_value, array $select_columns): ?array { + if (!class_exists('SQLite3')) { + return null; + } + foreach (['target_db', 'source_db'] as $db_key) { + $path = (string)($record[$db_key] ?? ''); + if ($path === '' || !is_file($path)) { + continue; + } + try { + $db = new SQLite3($path, SQLITE3_OPEN_READONLY); + $columns = forkpress_cow_sqlite_columns($db, $table); + if (!isset($columns[$where_column])) { + $db->close(); + continue; + } + $usable = []; + foreach ($select_columns as $column) { + if (isset($columns[$column])) { + $usable[] = $column; + } + } + if ($usable === []) { + $usable[] = $where_column; + } + $select = implode(', ', array_map('forkpress_cow_sqlite_identifier', array_unique($usable))); + $sql = 'SELECT ' . $select . ' FROM ' . forkpress_cow_sqlite_identifier($table) . ' WHERE ' . forkpress_cow_sqlite_identifier($where_column) . ' = :value LIMIT 1'; + $stmt = $db->prepare($sql); + if (!$stmt instanceof SQLite3Stmt) { + $db->close(); + continue; + } + $stmt->bindValue(':value', $where_value, is_int($where_value) ? SQLITE3_INTEGER : SQLITE3_TEXT); + $res = $stmt->execute(); + if (!$res instanceof SQLite3Result) { + $db->close(); + continue; + } + $row = $res->fetchArray(SQLITE3_ASSOC); + $res->finalize(); + $db->close(); + if (is_array($row)) { + $row['_db_source'] = $db_key === 'target_db' ? 'target' : 'source'; + return $row; + } + } catch (Throwable) { + continue; + } + } + return null; +} + +function forkpress_cow_conflict_row_payload(array $record): array { + foreach (['target_row_payload', 'source_row_payload'] as $key) { + $row = forkpress_cow_decode_typed_payload((string)($record[$key] ?? '')); + if (!is_array($row) || $row === []) { + continue; + } + $row['_db_source'] = $key === 'target_row_payload' ? 'target row payload' : 'source row payload'; + return $row; + } + return []; +} + +function forkpress_cow_conflict_metadata_payloads(string $metadata_db, int $conflict_id): array { + if ($metadata_db === '' || $conflict_id <= 0 || !class_exists('SQLite3') || !is_file($metadata_db)) { + return []; + } + try { + $db = new SQLite3($metadata_db, SQLITE3_OPEN_READONLY); + $stmt = $db->prepare('SELECT source_row_payload, target_row_payload FROM merge_conflicts WHERE id = :id LIMIT 1'); + if (!$stmt instanceof SQLite3Stmt) { + $db->close(); + return []; + } + $stmt->bindValue(':id', $conflict_id, SQLITE3_INTEGER); + $res = $stmt->execute(); + if (!$res instanceof SQLite3Result) { + $db->close(); + return []; + } + $row = $res->fetchArray(SQLITE3_ASSOC); + $res->finalize(); + $db->close(); + return is_array($row) ? $row : []; + } catch (Throwable) { + return []; + } +} + +function forkpress_cow_post_context_from_id(array $record, int $post_id): ?array { + $row = forkpress_cow_first_conflict_context_row($record, 'wp_posts', 'ID', $post_id, ['ID', 'post_type', 'post_title', 'post_name', 'post_status']); + if (!is_array($row)) { + $payload_row = forkpress_cow_conflict_row_payload($record); + $payload_post_id = isset($payload_row['ID']) ? (int)$payload_row['ID'] : 0; + if ($payload_post_id === $post_id) { + $row = $payload_row; + } + } + if (!is_array($row)) { + return null; + } + $title = trim((string)($row['post_title'] ?? '')); + $type = trim((string)($row['post_type'] ?? 'post')); + $slug = trim((string)($row['post_name'] ?? '')); + return [ + 'entityType' => 'post', + 'entityLabel' => ucfirst($type) . ' #' . $post_id, + 'identifier' => 'ID ' . $post_id, + 'context' => trim(($title !== '' ? $title : '(untitled)') . ($slug !== '' ? ' / ' . $slug : '')), + 'details' => array_filter([ + 'post_id' => $post_id, + 'post_type' => $type, + 'post_title' => $title, + 'post_name' => $slug, + 'post_status' => $row['post_status'] ?? null, + 'database' => $row['_db_source'] ?? null, + ], static fn($value) => $value !== null && $value !== ''), + ]; +} + +function forkpress_cow_conflict_entity_context(array $record): array { + $table = (string)($record['table_name'] ?? ''); + $column = (string)($record['column_name'] ?? ''); + $identity = forkpress_cow_decode_typed_payload((string)($record['row_identity'] ?? '')); + $identity = is_array($identity) ? $identity : []; + $field = $column !== '' ? $column : (string)($record['conflict_type'] ?? ''); + + if ($table === '__plugins__' || isset($record['plugin'])) { + $object = (string)($record['plugin_object'] ?? ''); + $plugin = (string)($record['plugin'] ?? ''); + $type = str_contains((string)($record['conflict_type'] ?? ''), 'theme') || str_starts_with($object, 'theme:') + ? 'theme' + : 'plugin'; + return [ + 'entityType' => $type, + 'entityLabel' => $type === 'theme' ? 'Theme conflict' : 'Plugin conflict', + 'identifier' => $object !== '' ? $object : ($plugin !== '' ? $plugin : (string)($record['conflict_key'] ?? '')), + 'field' => (string)($record['conflict_type'] ?? 'plugin-validator-conflict'), + 'context' => (string)($record['plugin_reason'] ?? $record['plugin_suggested_action'] ?? ''), + 'details' => array_filter([ + 'plugin' => $plugin, + 'object' => $object, + 'severity' => $record['plugin_severity'] ?? null, + 'plugin_check' => $record['plugin_validator'] ?? null, + 'semantic_scope' => $record['semantic_scope'] ?? null, + 'files' => $record['plugin_files'] ?? null, + 'tables' => $record['plugin_tables'] ?? null, + ], static fn($value) => $value !== null && $value !== '' && $value !== []), + ]; + } + + if ($table === '__files__') { + $path = (string)($identity['path'] ?? $record['path'] ?? $record['file_path'] ?? ''); + return [ + 'entityType' => 'file', + 'entityLabel' => 'File', + 'identifier' => $path, + 'field' => 'path', + 'context' => (string)($record['conflict_type'] ?? ''), + 'details' => ['path' => $path], + ]; + } + + if ($table === 'wp_options') { + $option_id = isset($identity['option_id']) ? (int)$identity['option_id'] : null; + $row = $option_id !== null ? forkpress_cow_first_conflict_context_row($record, 'wp_options', 'option_id', $option_id, ['option_id', 'option_name', 'autoload']) : null; + $payload_row = is_array($row) ? [] : forkpress_cow_conflict_row_payload($record); + $context_row = is_array($row) ? $row : $payload_row; + $option_name = (string)($context_row['option_name'] ?? $identity['option_name'] ?? ''); + return [ + 'entityType' => 'option', + 'entityLabel' => 'Option', + 'identifier' => $option_name !== '' ? $option_name : ($option_id !== null ? 'option_id ' . $option_id : ''), + 'field' => $field, + 'context' => $option_id !== null ? 'option_id ' . $option_id : '', + 'details' => array_filter([ + 'option_id' => $option_id, + 'option_name' => $option_name, + 'autoload' => $context_row['autoload'] ?? null, + 'database' => $context_row['_db_source'] ?? null, + 'row_lookup' => $context_row === [] ? 'not found in source or target database' : null, + ], static fn($value) => $value !== null && $value !== ''), + ]; + } + + if ($table === 'wp_posts' && isset($identity['ID'])) { + $context = forkpress_cow_post_context_from_id($record, (int)$identity['ID']); + if ($context !== null) { + $context['field'] = $field; + return $context; + } + } + + if ($table === 'wp_postmeta') { + $meta_id = isset($identity['meta_id']) ? (int)$identity['meta_id'] : null; + $row = $meta_id !== null ? forkpress_cow_first_conflict_context_row($record, 'wp_postmeta', 'meta_id', $meta_id, ['meta_id', 'post_id', 'meta_key']) : null; + $payload_row = is_array($row) ? [] : forkpress_cow_conflict_row_payload($record); + $context_row = is_array($row) ? $row : $payload_row; + $post_id = (int)($context_row['post_id'] ?? $identity['post_id'] ?? 0); + $post_context = $post_id > 0 ? forkpress_cow_post_context_from_id($record, $post_id) : null; + $meta_key = (string)($context_row['meta_key'] ?? $identity['meta_key'] ?? ''); + return [ + 'entityType' => 'postmeta', + 'entityLabel' => 'Post meta', + 'identifier' => $meta_key !== '' ? $meta_key : ($meta_id !== null ? 'meta_id ' . $meta_id : ''), + 'field' => $field, + 'context' => trim(($post_id > 0 ? 'post_id ' . $post_id : '') . ($post_context ? ' / ' . $post_context['context'] : '')), + 'details' => array_filter([ + 'meta_id' => $meta_id, + 'post_id' => $post_id > 0 ? $post_id : null, + 'meta_key' => $meta_key, + 'post' => $post_context['context'] ?? null, + 'database' => $context_row['_db_source'] ?? null, + 'row_lookup' => $context_row === [] ? 'not found in source or target database' : null, + ], static fn($value) => $value !== null && $value !== ''), + ]; + } + + if ($table === 'wp_terms') { + $term_id = isset($identity['term_id']) ? (int)$identity['term_id'] : null; + $row = $term_id !== null ? forkpress_cow_first_conflict_context_row($record, 'wp_terms', 'term_id', $term_id, ['term_id', 'name', 'slug']) : null; + $payload_row = is_array($row) ? [] : forkpress_cow_conflict_row_payload($record); + $context_row = is_array($row) ? $row : $payload_row; + return [ + 'entityType' => 'term', + 'entityLabel' => 'Term', + 'identifier' => (string)($context_row['name'] ?? '') !== '' ? (string)$context_row['name'] : ($term_id !== null ? 'term_id ' . $term_id : ''), + 'field' => $field, + 'context' => (string)($context_row['slug'] ?? ''), + 'details' => array_filter([ + 'term_id' => $term_id, + 'name' => $context_row['name'] ?? null, + 'slug' => $context_row['slug'] ?? null, + 'database' => $context_row['_db_source'] ?? null, + 'row_lookup' => $context_row === [] ? 'not found in source or target database' : null, + ], static fn($value) => $value !== null && $value !== ''), + ]; + } + + $identifier_parts = []; + foreach ($identity as $key => $value) { + if (is_scalar($value)) { + $identifier_parts[] = $key . '=' . (string)$value; + } + } + return [ + 'entityType' => $table !== '' ? $table : 'record', + 'entityLabel' => $table !== '' ? $table : 'Record', + 'identifier' => implode(', ', $identifier_parts), + 'field' => $field, + 'context' => (string)($record['conflict_key'] ?? ''), + 'details' => $identity, + ]; +} + +function forkpress_cow_enrich_conflict_records(array $records, string $metadata_db = ''): array { + foreach ($records as &$record) { + if (is_array($record) && (!isset($record['source_row_payload']) || !isset($record['target_row_payload']))) { + $payloads = forkpress_cow_conflict_metadata_payloads($metadata_db, (int)($record['id'] ?? 0)); + foreach (['source_row_payload', 'target_row_payload'] as $key) { + if (!isset($record[$key]) && isset($payloads[$key])) { + $record[$key] = $payloads[$key]; + } + } + } + if (is_array($record) && !isset($record['entityContext']) && !isset($record['entity_context'])) { + $record['entityContext'] = forkpress_cow_conflict_entity_context($record); + } + } + unset($record); + return $records; +} + function forkpress_cow_branch_conflict_audit_summary(array $report, int $run, array $filters = []): array { $records = is_array($report['conflicts'] ?? null) ? array_values($report['conflicts']) : []; + $records = forkpress_cow_enrich_conflict_records($records, (string)($report['metadata_db'] ?? '')); $total = count($records); $runs = is_array($report['runs'] ?? null) ? $report['runs'] : []; foreach ($runs as $run_record) { @@ -528,11 +1121,52 @@ function forkpress_cow_branch_conflict_audit_summary(array $report, int $run, ar break; } + $conflict_summary = is_array($report['conflict_summary'] ?? null) ? $report['conflict_summary'] : null; + if ($conflict_summary === null) { + $conflict_summary = [ + 'total' => count($records), + 'resolved' => 0, + 'unresolved' => 0, + 'by_lifecycle' => [], + 'by_next_action' => [], + 'by_scope' => [], + ]; + foreach ($records as $record) { + if (!is_array($record)) { + continue; + } + $lifecycle = trim((string)($record['lifecycle_state'] ?? $record['latest_event_lifecycle_state'] ?? 'unreviewed')); + if ($lifecycle === '') { + $lifecycle = 'unreviewed'; + } + $next_action = trim((string)($record['next_action'] ?? 'review')); + if ($next_action === '') { + $next_action = 'review'; + } + $scope = 'db'; + $table = (string)($record['table_name'] ?? ''); + if ($table === '__files__' || isset($record['path'])) { + $scope = 'files'; + } elseif (isset($record['plugin']) || isset($record['plugin_object'])) { + $scope = 'plugin'; + } + $conflict_summary['by_lifecycle'][$lifecycle] = (int)($conflict_summary['by_lifecycle'][$lifecycle] ?? 0) + 1; + $conflict_summary['by_next_action'][$next_action] = (int)($conflict_summary['by_next_action'][$next_action] ?? 0) + 1; + $conflict_summary['by_scope'][$scope] = (int)($conflict_summary['by_scope'][$scope] ?? 0) + 1; + if ($lifecycle === 'resolved') { + $conflict_summary['resolved']++; + } else { + $conflict_summary['unresolved']++; + } + } + } + return [ 'run' => $run, 'records' => $records, 'recordCount' => count($records), 'totalConflicts' => $total, + 'conflictSummary' => $conflict_summary, 'filters' => $filters, 'audit' => $report, 'auditCommand' => forkpress_cow_branch_merge_audit_command($run, $filters) . ' --format json', @@ -547,7 +1181,7 @@ function forkpress_cow_branch_revalidate_merge_run(int $run): array { $result = json_decode($output, true); if (!is_array($result)) { - return [1, 'ForkPress returned invalid revalidation JSON.', null]; + return [1, 'ForkPress returned invalid conflict change-check JSON.', null]; } return [0, $output, $result]; @@ -592,7 +1226,7 @@ function forkpress_cow_handle_admin_branch_action(string $path, string $current_ forkpress_cow_branch_url($branch, '/wp-admin/'), true, 'Created branch ' . $branch . '.', - ['branches' => forkpress_cow_branch_switcher_data($current_branch)] + ['branches' => forkpress_cow_branch_switcher_data($branch, '/wp-admin/', [$branch, 'main'])] ); return true; } @@ -627,7 +1261,7 @@ function forkpress_cow_handle_admin_branch_action(string $path, string $current_ $message, [ 'type' => 'warning', - 'branches' => forkpress_cow_branch_switcher_data($current_branch), + 'branches' => forkpress_cow_branch_switcher_data($target, '/wp-admin/'), 'mergeStatus' => $summary['status'], 'conflicts' => $conflicts, 'run' => $run, @@ -641,82 +1275,153 @@ function forkpress_cow_handle_admin_branch_action(string $path, string $current_ forkpress_cow_branch_url($target, '/wp-admin/'), true, 'Merged ' . $source . ' into ' . $target . '.', - ['branches' => forkpress_cow_branch_switcher_data($current_branch)] + ['branches' => forkpress_cow_branch_switcher_data($target, '/wp-admin/')] ); return true; } - if ($action === 'forkpress_branch_conflicts') { - $run = forkpress_cow_branch_post_int('run'); - if ($run === null) { - forkpress_cow_branch_finish_json(400, $current_url, false, 'Choose a merge run to inspect.'); - return true; - } - - $filters = forkpress_cow_branch_conflict_audit_filters(); - if (($filters['error'] ?? null) !== null) { - forkpress_cow_branch_finish_json(400, $current_url, false, (string)$filters['error']); - return true; - } - - [$crash_code, $crash_output] = forkpress_cow_branch_run_cli(['merge-audit', '--records', 'crash-recovery', '--run', (string)$run, '--format', 'json']); - if ($crash_code !== 0) { - forkpress_cow_branch_finish_json(400, $current_url, false, $crash_output ?: 'ForkPress could not inspect pending crash recovery.'); - return true; - } - $crash_report = json_decode($crash_output, true); - if (!is_array($crash_report)) { - forkpress_cow_branch_finish_json(400, $current_url, false, 'ForkPress returned invalid crash recovery JSON.'); - return true; - } - $crash_summary = forkpress_cow_branch_crash_recovery_summary($crash_report, $run); - if (($crash_summary['crashRecoveryCount'] ?? 0) > 0) { - $message = 'Merge run ' . $run . ' has pending crash recovery. Restore it before reviewing conflicts.'; - forkpress_cow_branch_finish_json(200, $current_url, true, $message, array_merge(['type' => 'warning'], $crash_summary)); + if ($action === 'forkpress_branch_history') { + $limit = forkpress_cow_branch_post_int('limit') ?? 10; + if ($limit < 1 || $limit > 50) { + forkpress_cow_branch_finish_json(400, $current_url, false, 'Choose a merge history limit from 1 to 50.'); return true; } - $audit_args = array_merge(['merge-audit', '--records', 'conflicts', '--run', (string)$run, '--format', 'json'], $filters['args']); - [$code, $output] = forkpress_cow_branch_run_cli($audit_args); + [$code, $output] = forkpress_cow_branch_run_cli(['history', '--limit', (string)$limit, '--format', 'json']); if ($code !== 0) { - forkpress_cow_branch_finish_json(400, $current_url, false, $output ?: 'ForkPress could not inspect merge conflicts.'); + forkpress_cow_branch_finish_json(400, $current_url, false, $output ?: 'ForkPress could not inspect merge history.'); return true; } $report = json_decode($output, true); if (!is_array($report)) { - forkpress_cow_branch_finish_json(400, $current_url, false, 'ForkPress returned invalid merge audit JSON.'); + forkpress_cow_branch_finish_json(400, $current_url, false, 'ForkPress returned invalid merge history JSON.'); return true; } - $summary = forkpress_cow_branch_conflict_audit_summary($report, $run, $filters['filters']); - $message = 'Loaded ' . $summary['recordCount'] . ' of ' . $summary['totalConflicts'] . ' conflict record' . ($summary['totalConflicts'] === 1 ? '' : 's') . ' for merge run ' . $run . '.'; - forkpress_cow_branch_finish_json(200, $current_url, true, $message, $summary); + $summary = forkpress_cow_branch_history_summary($report, $limit); + $count = (int)($summary['recordCount'] ?? 0); + forkpress_cow_branch_finish_json( + 200, + $current_url, + true, + $count > 0 ? 'Loaded ' . $count . ' merge history ' . ($count === 1 ? 'run.' : 'runs.') : 'No merge history found.', + $summary + ); return true; } - if ($action === 'forkpress_branch_restore_crash') { - $run = forkpress_cow_branch_post_int('run'); - if ($run === null) { - forkpress_cow_branch_finish_json(400, $current_url, false, 'Choose a merge run to restore.'); + if ($action === 'forkpress_branch_tree') { + $limit = forkpress_cow_branch_post_int('limit') ?? 20; + if ($limit < 1 || $limit > 50) { + forkpress_cow_branch_finish_json(400, $current_url, false, 'Choose a branch tree limit from 1 to 50.'); return true; } - [$code, $output] = forkpress_cow_branch_run_cli(['recover-crash', '--run', (string)$run, '--restore-target-db', '--restore-files', '--format', 'json']); + [$code, $output] = forkpress_cow_branch_run_cli(['tree', '--limit', (string)$limit, '--format', 'json']); if ($code !== 0) { - forkpress_cow_branch_finish_json(400, $current_url, false, $output ?: 'ForkPress could not restore pending crash recovery.'); - return true; - } - $result = json_decode($output, true); - if (!is_array($result)) { - forkpress_cow_branch_finish_json(400, $current_url, false, 'ForkPress returned invalid crash recovery restore JSON.'); - return true; + $report = forkpress_cow_branch_tree_report_from_metadata($branches_dir, $limit); + if ($report === null) { + forkpress_cow_branch_finish_json(400, $current_url, false, $output ?: 'ForkPress could not inspect the branch tree.'); + return true; + } + } else { + $report = json_decode($output, true); + if (!is_array($report)) { + forkpress_cow_branch_finish_json(400, $current_url, false, 'ForkPress returned invalid branch tree JSON.'); + return true; + } } - $restored = max(0, (int)($result['restored'] ?? 0)); - $pending = max(0, (int)($result['pending'] ?? 0)); - $message = $restored > 0 - ? 'Restored pending crash recovery for merge run ' . $run . '.' - : 'No pending crash recovery artifacts were restored for merge run ' . $run . '.'; + $summary = forkpress_cow_branch_tree_summary($report, $limit); + if (is_array($report['branches'] ?? null)) { + $summary['branches'] = forkpress_cow_branch_switcher_data($current_branch, '/wp-admin/', $report['branches']); + } + $count = (int)($summary['recordCount'] ?? 0); + forkpress_cow_branch_finish_json( + 200, + $current_url, + true, + $count > 0 ? 'Loaded ' . $count . ' branch tree ' . ($count === 1 ? 'edge.' : 'edges.') : 'No branch tree edges found.', + $summary + ); + return true; + } + + if ($action === 'forkpress_branch_conflicts') { + $run = forkpress_cow_branch_post_int('run'); + if ($run === null) { + forkpress_cow_branch_finish_json(400, $current_url, false, 'Choose a merge run to inspect.'); + return true; + } + + $filters = forkpress_cow_branch_conflict_audit_filters(); + if (($filters['error'] ?? null) !== null) { + forkpress_cow_branch_finish_json(400, $current_url, false, (string)$filters['error']); + return true; + } + + [$crash_code, $crash_output] = forkpress_cow_branch_run_cli(['merge-audit', '--records', 'crash-recovery', '--run', (string)$run, '--format', 'json']); + if ($crash_code !== 0) { + $crash_summary = ['crashRecoveryCount' => 0]; + } else { + $crash_report = json_decode($crash_output, true); + if (!is_array($crash_report)) { + forkpress_cow_branch_finish_json(400, $current_url, false, 'ForkPress returned invalid crash recovery JSON.'); + return true; + } + $crash_summary = forkpress_cow_branch_crash_recovery_summary($crash_report, $run); + if (($crash_summary['crashRecoveryCount'] ?? 0) > 0) { + $message = 'Merge run ' . $run . ' has pending crash recovery. Restore it before reviewing conflicts.'; + forkpress_cow_branch_finish_json(200, $current_url, true, $message, array_merge(['type' => 'warning'], $crash_summary)); + return true; + } + } + + $audit_args = array_merge(['merge-audit', '--records', 'conflicts', '--run', (string)$run, '--format', 'json'], $filters['args']); + [$code, $output] = forkpress_cow_branch_run_cli($audit_args); + if ($code !== 0) { + $report = forkpress_cow_branch_conflict_report_from_metadata($branches_dir, $run); + if ($report === null) { + forkpress_cow_branch_finish_json(400, $current_url, false, $output ?: 'ForkPress could not inspect merge conflicts.'); + return true; + } + } else { + $report = json_decode($output, true); + if (!is_array($report)) { + forkpress_cow_branch_finish_json(400, $current_url, false, 'ForkPress returned invalid merge audit JSON.'); + return true; + } + } + + $summary = forkpress_cow_branch_conflict_audit_summary($report, $run, $filters['filters']); + $message = 'Loaded ' . $summary['recordCount'] . ' of ' . $summary['totalConflicts'] . ' conflict record' . ($summary['totalConflicts'] === 1 ? '' : 's') . ' for merge run ' . $run . '.'; + forkpress_cow_branch_finish_json(200, $current_url, true, $message, $summary); + return true; + } + + if ($action === 'forkpress_branch_restore_crash') { + $run = forkpress_cow_branch_post_int('run'); + if ($run === null) { + forkpress_cow_branch_finish_json(400, $current_url, false, 'Choose a merge run to restore.'); + return true; + } + + [$code, $output] = forkpress_cow_branch_run_cli(['recover-crash', '--run', (string)$run, '--restore-target-db', '--restore-files', '--format', 'json']); + if ($code !== 0) { + forkpress_cow_branch_finish_json(400, $current_url, false, $output ?: 'ForkPress could not restore pending crash recovery.'); + return true; + } + $result = json_decode($output, true); + if (!is_array($result)) { + forkpress_cow_branch_finish_json(400, $current_url, false, 'ForkPress returned invalid crash recovery restore JSON.'); + return true; + } + + $restored = max(0, (int)($result['restored'] ?? 0)); + $pending = max(0, (int)($result['pending'] ?? 0)); + $message = $restored > 0 + ? 'Restored pending crash recovery for merge run ' . $run . '.' + : 'No pending crash recovery artifacts were restored for merge run ' . $run . '.'; forkpress_cow_branch_finish_json( 200, $current_url, @@ -737,20 +1442,20 @@ function forkpress_cow_handle_admin_branch_action(string $path, string $current_ if ($action === 'forkpress_branch_revalidate_conflicts') { $run = forkpress_cow_branch_post_int('run'); if ($run === null) { - forkpress_cow_branch_finish_json(400, $current_url, false, 'Choose a merge run to revalidate.'); + forkpress_cow_branch_finish_json(400, $current_url, false, 'Choose a merge run to check for changes.'); return true; } [$code, $output, $result] = forkpress_cow_branch_revalidate_merge_run($run); if ($code !== 0) { - forkpress_cow_branch_finish_json(400, $current_url, false, $output ?: 'ForkPress could not revalidate merge conflicts.'); + forkpress_cow_branch_finish_json(400, $current_url, false, $output ?: 'ForkPress could not check merge conflicts for changes.'); return true; } $checked = max(0, (int)($result['checked'] ?? 0)); $stale = max(0, (int)($result['stale'] ?? 0)); $carried = max(0, (int)($result['carried'] ?? 0)); - $message = 'Revalidated merge run ' . $run . ': checked ' . $checked . ', stale ' . $stale . ', carried ' . $carried . '.'; + $message = 'Checked merge run ' . $run . ' for changes: ' . $checked . ' checked, ' . $stale . ' changed, ' . $carried . ' unchanged.'; forkpress_cow_branch_finish_json( 200, $current_url, @@ -762,6 +1467,7 @@ function forkpress_cow_handle_admin_branch_action(string $path, string $current_ 'checked' => $checked, 'stale' => $stale, 'carried' => $carried, + 'changeCheck' => $result, 'revalidation' => $result, 'auditCommand' => 'forkpress branch merge-audit --revalidate --run ' . $run . ' --reviewer wordpress-ui --format json', ] @@ -782,6 +1488,11 @@ function forkpress_cow_handle_admin_branch_action(string $path, string $current_ return true; } + $review_note = forkpress_cow_branch_post_value('note'); + if (strlen($review_note) > 2000) { + forkpress_cow_branch_finish_json(400, $current_url, false, 'Keep conflict review notes under 2000 characters.'); + return true; + } $notes = [ 'pending' => 'Marked pending from the WordPress branch switcher.', 'needs-action' => 'Marked needs-action from the WordPress branch switcher.', @@ -794,7 +1505,7 @@ function forkpress_cow_handle_admin_branch_action(string $path, string $current_ '--status', $status, '--note', - $notes[$status], + $review_note !== '' ? $review_note : $notes[$status], '--reviewer', 'wordpress-ui', ]); @@ -826,14 +1537,23 @@ function forkpress_cow_handle_admin_branch_action(string $path, string $current_ } $apply_reviewed = forkpress_cow_branch_post_value('applyReviewed') === '1'; + $replace_applied = forkpress_cow_branch_post_value('replaceApplied') === '1'; $after_revalidate = forkpress_cow_branch_post_value('afterRevalidate') === '1'; $choice = forkpress_cow_branch_post_value('choice'); if ($apply_reviewed && $choice !== '') { forkpress_cow_branch_finish_json(400, $current_url, false, 'Apply reviewed cannot be combined with a new source or target choice.'); return true; } + if ($apply_reviewed && $replace_applied) { + forkpress_cow_branch_finish_json(400, $current_url, false, 'Changing an applied resolution requires a new source or target choice.'); + return true; + } if ($apply_reviewed && $after_revalidate) { - forkpress_cow_branch_finish_json(400, $current_url, false, 'After revalidate requires a source or target choice.'); + forkpress_cow_branch_finish_json(400, $current_url, false, 'Checking again requires a source or target choice.'); + return true; + } + if ($replace_applied && $after_revalidate) { + forkpress_cow_branch_finish_json(400, $current_url, false, 'Changing an applied resolution cannot be combined with checking again.'); return true; } if (!$apply_reviewed && !in_array($choice, ['source', 'target'], true)) { @@ -845,7 +1565,7 @@ function forkpress_cow_handle_admin_branch_action(string $path, string $current_ if ($apply_reviewed && $run !== null) { [$code, $output, $revalidation] = forkpress_cow_branch_revalidate_merge_run($run); if ($code !== 0) { - forkpress_cow_branch_finish_json(400, $current_url, false, $output ?: 'ForkPress could not revalidate merge conflicts before applying the reviewed choice.'); + forkpress_cow_branch_finish_json(400, $current_url, false, $output ?: 'ForkPress could not check merge conflicts for changes before applying the reviewed choice.'); return true; } if ($revalidation !== null && forkpress_cow_branch_revalidation_needs_action_for_conflict($revalidation, $conflict)) { @@ -856,13 +1576,14 @@ function forkpress_cow_handle_admin_branch_action(string $path, string $current_ 400, $current_url, false, - 'Conflict #' . $conflict . ' changed since review. Revalidate and review it before applying the reviewed choice.', + 'Conflict #' . $conflict . ' changed since review. Check it again and review it before applying the reviewed choice.', [ 'run' => $run, 'conflict' => $conflict, 'checked' => $checked, 'stale' => $stale, 'carried' => $carried, + 'changeCheck' => $revalidation, 'revalidation' => $revalidation, 'auditCommand' => 'forkpress branch merge-audit --revalidate --run ' . $run . ' --reviewer wordpress-ui --format json', ] @@ -871,6 +1592,11 @@ function forkpress_cow_handle_admin_branch_action(string $path, string $current_ } } + $review_note = forkpress_cow_branch_post_value('note'); + if (strlen($review_note) > 2000) { + forkpress_cow_branch_finish_json(400, $current_url, false, 'Keep conflict resolution notes under 2000 characters.'); + return true; + } $notes = [ 'source' => 'Applied source choice from the WordPress branch switcher.', 'target' => 'Applied target choice from the WordPress branch switcher.', @@ -891,10 +1617,13 @@ function forkpress_cow_handle_admin_branch_action(string $path, string $current_ if ($after_revalidate) { $resolve_args[] = '--after-revalidate'; } + if ($replace_applied) { + $resolve_args[] = '--replace-applied'; + } $note = $notes[$choice]; } $resolve_args[] = '--note'; - $resolve_args[] = $note; + $resolve_args[] = $review_note !== '' ? $review_note : $note; $resolve_args[] = '--reviewer'; $resolve_args[] = 'wordpress-ui'; [$code, $output] = forkpress_cow_branch_run_cli($resolve_args); @@ -907,12 +1636,13 @@ function forkpress_cow_handle_admin_branch_action(string $path, string $current_ 200, $current_url, true, - $apply_reviewed ? 'Applied reviewed choice for conflict #' . $conflict . '.' : 'Applied ' . $choice . ' for conflict #' . $conflict . '.', + $apply_reviewed ? 'Applied reviewed choice for conflict #' . $conflict . '.' : ($replace_applied ? 'Changed conflict #' . $conflict . ' to ' . $choice . '.' : 'Applied ' . $choice . ' for conflict #' . $conflict . '.'), [ 'run' => $run, 'conflict' => $conflict, 'resolutionChoice' => $apply_reviewed ? 'reviewed' : $choice, 'afterRevalidate' => $after_revalidate, + 'replaceApplied' => $replace_applied, ] ); return true; @@ -1030,6 +1760,2695 @@ function forkpress_cow_handle_admin_branch_action(string $path, string $current_ return false; } +function forkpress_cow_json_encode($value): string { + $json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + return is_string($json) ? $json : 'null'; +} + +function forkpress_cow_branch_manager_html(string $current_branch): string { + return << + + + + +ForkPress Branches + + + +
+
+
ForkPress Branches
+
+ Open WordPress admin +
+
+
+
+
+

Branch Graph

+
+
+ revision + merge event + conflicts + into target +
+
+
+ + + +
+
+
+ +
+
+
+
+
+

Branch Workbench

+
Select a branch, revision, or merge event.
+
+
+
+ Fork from here +
+
+
+
+ +
+
+
+
+ Merge this revision +
+
+
+
+ +
+
+
+
+
+
+
+
+

Selection

+
+
+
+
+
+

+                
+
+
+
+
+

Conflicts

+
Select a conflicting revision to inspect entity-level details.
+
+
+
+
+
+
+
+
+
+
+
+ + + +HTML; +} + +function forkpress_cow_handle_branch_manager(string $path, string $current_branch, string $branches_dir): bool { + if ($path !== '/_forkpress/branches') { + return false; + } + http_response_code(200); + header('Content-Type: text/html; charset=UTF-8'); + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + header('Pragma: no-cache'); + echo str_replace('__STATE__', forkpress_cow_json_encode([ + 'currentBranch' => $current_branch, + 'branches' => forkpress_cow_branch_switcher_data($current_branch, '/wp-admin/', forkpress_cow_metadata_branch_names($branches_dir)), + 'pluginDrivers' => array_values(forkpress_cow_branch_plugin_driver_map($branches_dir, $current_branch)), + 'actionUrl' => '/_forkpress/action', + 'rootUrl' => '/_forkpress/branches', + ]), forkpress_cow_branch_manager_html($current_branch)); + return true; +} + +if (forkpress_cow_handle_branch_manager($path, $branch, $branches_dir)) { + return true; +} + if (forkpress_cow_handle_admin_branch_action($path, $branch, $branches_dir)) { return true; } diff --git a/scripts/cow/merge.php b/scripts/cow/merge.php index 83a9f292..35bb2f71 100644 --- a/scripts/cow/merge.php +++ b/scripts/cow/merge.php @@ -34,7 +34,7 @@ function cow_merge_usage(): void { fwrite(STDERR, " --revalidate accepts only --run, --conflict-id, --conflict-key, --reviewer, --format, and --quiet; omit --revalidate to filter audit output.\n"); fwrite(STDERR, " php merge.php revalidate-reviews --metadata-db [--run ID] [--conflict-id ID|--conflict-key KEY] [--reviewer NAME] [--format text|json]\n"); fwrite(STDERR, " php merge.php review-record --metadata-db --record conflict|decision|resolution (--id ID|--conflict-key KEY [--run ID]) --status pending|needs-action|reviewed --note TEXT [--reviewer NAME]\n"); - fwrite(STDERR, " php merge.php resolve-conflict --metadata-db --id ID (--choice source|target [--apply]|--apply-reviewed) [--after-revalidate] [--note TEXT] [--reviewer NAME]\n"); + fwrite(STDERR, " php merge.php resolve-conflict --metadata-db --id ID (--choice source|target [--apply]|--apply-reviewed) [--after-revalidate] [--replace-applied] [--note TEXT] [--reviewer NAME]\n"); fwrite(STDERR, " php merge.php apply-reviewed-resolutions --metadata-db [--run ID] [--limit N] [--note TEXT] [--reviewer NAME] [--format text|json]\n"); } @@ -7414,6 +7414,20 @@ function cow_merge_latest_applied_resolution_choice(SQLite3 $meta, int $conflict return (string)$row['choice']; } +function cow_merge_latest_applied_resolution(SQLite3 $meta, int $conflict_id): ?array { + $stmt = cow_merge_prepare_checked( + $meta, + 'SELECT id, choice, applied, status, target_db, table_name, row_identity, column_name, previous_payload, resolved_payload, created_at ' . + 'FROM merge_resolutions WHERE conflict_id = :conflict_id AND applied = 1 ORDER BY id DESC LIMIT 1', + 'failed to prepare latest applied resolution lookup' + ); + cow_merge_bind($stmt, ':conflict_id', $conflict_id); + $res = cow_merge_execute_checked($stmt, $meta, 'failed to read latest applied resolution'); + $row = $res->fetchArray(SQLITE3_ASSOC); + cow_merge_result_finalize_checked($res, 'failed to finalize latest applied resolution lookup'); + return $row ? $row : null; +} + function cow_merge_require_unresolved_conflict(SQLite3 $meta, int $conflict_id): void { if (cow_merge_latest_applied_resolution_choice($meta, $conflict_id) !== null) { throw new InvalidArgumentException("conflict #$conflict_id is already resolved"); @@ -15442,7 +15456,8 @@ function cow_merge_resolve_conflict( bool $apply, string $note, string $reviewer, - bool $after_revalidate = false + bool $after_revalidate = false, + bool $replace_applied = false ): array { if (!is_file($metadata_db)) { throw new InvalidArgumentException("merge metadata database does not exist: $metadata_db"); @@ -15466,7 +15481,21 @@ function cow_merge_resolve_conflict( if (!$conflict) { throw new InvalidArgumentException("conflict #$conflict_id does not exist in merge metadata"); } - cow_merge_require_unresolved_conflict($meta, $conflict_id); + $latest_applied_resolution = cow_merge_latest_applied_resolution($meta, $conflict_id); + if ($latest_applied_resolution !== null && !$replace_applied) { + throw new InvalidArgumentException("conflict #$conflict_id is already resolved"); + } + if ($replace_applied) { + if (!$apply) { + throw new InvalidArgumentException('--replace-applied requires --apply'); + } + if ($after_revalidate) { + throw new InvalidArgumentException('--replace-applied cannot be combined with --after-revalidate'); + } + if ($latest_applied_resolution === null) { + throw new InvalidArgumentException("conflict #$conflict_id does not have an applied resolution to replace"); + } + } $table = (string)$conflict['table_name']; $column = (string)($conflict['column_name'] ?? ''); $conflict_type = (string)$conflict['conflict_type']; @@ -15497,6 +15526,9 @@ function cow_merge_resolve_conflict( throw new InvalidArgumentException("resolution choice $choice is blocked for conflict #$conflict_id: $blocked_choice"); } if ($table === '__files__') { + if ($replace_applied) { + throw new InvalidArgumentException('changing an applied filesystem conflict resolution is not supported yet; rerun merge-audit before choosing a new file resolution'); + } $file_conflict_types = [ 'file-conflict', 'file-add-collision', @@ -15668,6 +15700,9 @@ function cow_merge_resolve_conflict( ]; } if (str_starts_with($conflict_type, 'schema-')) { + if ($replace_applied) { + throw new InvalidArgumentException('changing an applied schema conflict resolution is not supported yet; rerun merge-audit before choosing a new schema resolution'); + } return cow_merge_resolve_schema_conflict( $meta, $conflict, @@ -15687,6 +15722,9 @@ function cow_merge_resolve_conflict( if ($conflict_type !== 'cell-conflict' && !in_array($conflict_type, $row_conflict_types, true)) { throw new InvalidArgumentException('resolve-conflict currently supports DB cell-conflict, row-insert-collision, row-unique-collision, row-target-constraint, row-identity-ambiguous, row-target-deleted, and row-source-deleted records only'); } + if ($replace_applied && $conflict_type !== 'cell-conflict') { + throw new InvalidArgumentException('changing an applied DB conflict resolution is currently supported only for cell-conflict records'); + } if ($conflict_type === 'cell-conflict' && $column === '') { throw new InvalidArgumentException('cell conflict resolution requires a column name'); } @@ -15728,7 +15766,12 @@ function cow_merge_resolve_conflict( throw new RuntimeException("cannot resolve $table.$column conflict because the target row no longer exists"); } $current_value = cow_merge_select_current_cell($target, $table, $where_identity, $pk_cols, $column); - if ($after_revalidate) { + if ($replace_applied) { + $latest_resolved_value = cow_merge_decode_payload_json((string)$latest_applied_resolution['resolved_payload'], 'latest applied resolution'); + if (!cow_merge_values_equal($current_value, $latest_resolved_value)) { + throw new RuntimeException('target cell no longer matches the latest applied resolution; rerun merge-audit before changing it'); + } + } elseif ($after_revalidate) { if ($choice === 'source') { $latest_revalidation = cow_merge_latest_revalidation($meta, $conflict_id); if ($latest_revalidation !== null && (string)($latest_revalidation['revalidation_class'] ?? '') === 'compatible-source-drift') { @@ -15867,14 +15910,14 @@ function cow_merge_resolve_conflict( cow_merge_exec_checked($meta, 'BEGIN IMMEDIATE', 'failed to start row resolution metadata transaction'); cow_merge_exec_checked($target, 'BEGIN IMMEDIATE', 'failed to start row resolution target transaction'); try { - if ($choice === 'source') { - if ($conflict_type === 'cell-conflict') { + if ($conflict_type === 'cell-conflict') { + if (!cow_merge_values_equal($current_value, $resolved_value)) { $columns = cow_merge_table_columns($target, $table); $expected_row = cow_merge_select_current_row($target, $table, $where_identity, $pk_cols); if ($expected_row === null) { throw new RuntimeException("cannot resolve $table.$column conflict because the target row no longer exists"); } - $expected_row[$column] = $source_value; + $expected_row[$column] = $resolved_value; $update_result = cow_merge_try_update_row_preserving_payload( $target, $table, @@ -15885,14 +15928,16 @@ function cow_merge_resolve_conflict( "failed to apply conflict resolution to $table.$column", "failed to finalize conflict resolution update for $table.$column" ); - cow_merge_require_source_apply_result($update_result, "failed to resolve $table.$column with source value"); + cow_merge_require_source_apply_result($update_result, "failed to resolve $table.$column with $choice value"); if (!$pk_cols) { $updated_row = cow_merge_select_current_row($target, $table, $where_identity, $pk_cols); if ($updated_row !== null) { cow_merge_remember_row_identity($meta, (int)$conflict['run_id'], $target_branch, $table, (int)$where_identity['rowid'], $identity, $updated_row); } } - } elseif ($conflict_type === 'row-source-deleted') { + } + } elseif ($choice === 'source') { + if ($conflict_type === 'row-source-deleted') { cow_merge_delete_row($target, $table, $where_identity, $pk_cols); if (!$pk_cols) { cow_merge_forget_row_identity($meta, (int)$conflict['run_id'], $target_branch, $table, (int)$where_identity['rowid']); @@ -19639,7 +19684,7 @@ function cow_merge_audit_report(string $metadata_db, ?int $run_id = null, int $l $db, 'merge_conflicts', "SELECT merge_conflicts.id AS id, run_id, $conflict_key_select, table_name, row_identity, column_name, conflict_type, resolver, resolved_at, created_at, " . - "base_payload, source_payload, target_payload, chosen_payload, r.source_db, r.target_db, r.source_branch, r.target_branch, " . + "base_payload, source_payload, target_payload, chosen_payload, source_row_payload, target_row_payload, r.source_db, r.target_db, r.source_branch, r.target_branch, " . cow_merge_audit_conflict_target_constraint_reason_sql('merge_conflicts') . " AS target_constraint_reason$conflict_review_select$conflict_resolution_select$conflict_event_select " . "FROM merge_conflicts JOIN merge_runs r ON r.id = merge_conflicts.run_id $conflict_filter ORDER BY merge_conflicts.id DESC LIMIT :limit", $conflict_params @@ -23190,7 +23235,7 @@ function cow_merge_parse_cli(array $argv, array $required, int $start_index = 1) $args[$key] = $value; continue; } - if (in_array($key, ['id-band-skips', 'target-kept', 'review', 'revalidate', 'apply', 'apply-reviewed', 'after-revalidate', 'applied', 'restore-target-db', 'restore-files', 'quiet', 'fail-on-unresolved'], true) && (!isset($argv[$i + 1]) || str_starts_with($argv[$i + 1], '--'))) { + if (in_array($key, ['id-band-skips', 'target-kept', 'review', 'revalidate', 'apply', 'apply-reviewed', 'after-revalidate', 'replace-applied', 'applied', 'restore-target-db', 'restore-files', 'quiet', 'fail-on-unresolved'], true) && (!isset($argv[$i + 1]) || str_starts_with($argv[$i + 1], '--'))) { $args[$key] = '1'; continue; } @@ -23715,23 +23760,36 @@ function cow_merge_print_revalidation_text(array $result): void { if ($command === 'resolve-conflict') { $args = cow_merge_parse_cli($argv, ['metadata-db'], 2); $apply_reviewed = cow_merge_bool_flag($args['apply-reviewed'] ?? '0'); + $replace_applied = cow_merge_bool_flag($args['replace-applied'] ?? '0'); if ($apply_reviewed && array_key_exists('choice', $args)) { throw new InvalidArgumentException('--apply-reviewed cannot be combined with --choice'); } if ($apply_reviewed && cow_merge_bool_flag($args['apply'] ?? '0')) { throw new InvalidArgumentException('--apply-reviewed already applies the latest validated choice; do not combine it with --apply'); } + if ($apply_reviewed && $replace_applied) { + throw new InvalidArgumentException('--replace-applied cannot be combined with --apply-reviewed'); + } $has_id = array_key_exists('id', $args) && (string)$args['id'] !== ''; $has_conflict_key = array_key_exists('conflict-key', $args) && (string)$args['conflict-key'] !== ''; if ($has_id === $has_conflict_key) { throw new InvalidArgumentException('resolve-conflict requires exactly one of --id or --conflict-key'); } + if ($replace_applied && !$has_id) { + throw new InvalidArgumentException('--replace-applied requires --id'); + } $run_id = array_key_exists('run', $args) ? cow_merge_audit_run_id($args['run'] ?? null) : null; if ($has_id && $run_id !== null) { throw new InvalidArgumentException('--run can only be combined with --conflict-key'); } + if ($replace_applied && !cow_merge_bool_flag($args['apply'] ?? '0')) { + throw new InvalidArgumentException('--replace-applied requires --apply'); + } + if ($replace_applied && cow_merge_bool_flag($args['after-revalidate'] ?? '0')) { + throw new InvalidArgumentException('--replace-applied cannot be combined with --after-revalidate'); + } if ($has_conflict_key) { $meta = cow_merge_open_db($args['metadata-db'], SQLITE3_OPEN_READWRITE); try { @@ -23754,7 +23812,8 @@ function cow_merge_print_revalidation_text(array $result): void { $apply, cow_merge_review_text($args['note'] ?? 'deterministic conflict resolution', 'note'), cow_merge_review_text($args['reviewer'] ?? 'user', 'reviewer'), - cow_merge_bool_flag($args['after-revalidate'] ?? '0') + cow_merge_bool_flag($args['after-revalidate'] ?? '0'), + $replace_applied ); if (($args['quiet'] ?? '0') !== '1') { echo "forkpress: validated COW merge conflict resolution\n"; diff --git a/tests/cow/branch_ui.php b/tests/cow/branch_ui.php index e1a1054d..7c3094cd 100644 --- a/tests/cow/branch_ui.php +++ b/tests/cow/branch_ui.php @@ -165,6 +165,11 @@ function get_site_option($name, $default = false) { if (is_string($fqdb) && $fqdb !== '' && !defined('FQDB')) { define('FQDB', $fqdb); } +if (getenv('FORKPRESS_TEST_PREDEFINE_SQLITE_IDENTIFIER') === '1' && !function_exists('forkpress_cow_sqlite_identifier')) { + function forkpress_cow_sqlite_identifier(string $name): string { + return '"' . str_replace('"', '""', $name) . '"'; + } +} $async = getenv('FORKPRESS_TEST_ASYNC') !== '0'; $_SERVER = [ @@ -172,6 +177,10 @@ function get_site_option($name, $default = false) { 'HTTP_ACCEPT' => $async ? 'application/json' : 'text/html', 'REQUEST_URI' => '/wp-admin/', ]; +$forwarded_proto = getenv('FORKPRESS_TEST_FORWARDED_PROTO'); +if (is_string($forwarded_proto) && $forwarded_proto !== '') { + $_SERVER['HTTP_X_FORWARDED_PROTO'] = $forwarded_proto; +} if ($async) { $_SERVER['HTTP_X_FORKPRESS_ASYNC'] = '1'; } @@ -305,6 +314,18 @@ function decode_branch_ui_payload(array $result): array { return is_array($payload) ? $payload : []; } +$predeclared_helper_admin_page = run_branch_ui_action( + ['action' => 'forkpress_branch_admin_page'], + ['main', 'feature'], + false, + true, + true, + ['FORKPRESS_TEST_PREDEFINE_SQLITE_IDENTIFIER' => '1'] +); +$predeclared_helper_payload = decode_branch_ui_payload($predeclared_helper_admin_page); +assert_same($predeclared_helper_admin_page['status'], 0, 'branch manager plugin loads when router already declared shared SQLite helpers'); +assert_true(str_contains($predeclared_helper_payload['html'] ?? '', '/_forkpress/branches'), 'branch manager plugin still renders admin page after shared helper predeclaration'); + $create = run_branch_ui_action( ['action' => 'forkpress_branch_create', 'branch' => 'new_feature', 'from' => 'feature'], ['main', 'feature'] @@ -314,6 +335,10 @@ function decode_branch_ui_payload(array $result): array { assert_same($create_payload['success'] ?? null, true, 'branch create admin action returns JSON success'); assert_same($create_payload['message'] ?? null, 'Created branch new_feature.', 'branch create admin action reports the created branch'); assert_same($create_payload['url'] ?? null, 'http://new_feature.wp.localhost:18080/wp-admin/', 'branch create admin action redirects to the new branch admin'); +assert_same($create_payload['branches'][0]['name'] ?? null, 'new_feature', 'branch create response marks the new branch as current in refreshed switcher data'); +assert_same($create_payload['branches'][0]['url'] ?? null, 'http://new_feature.wp.localhost:18080/wp-admin/', 'branch create response gives the new branch a usable admin URL'); +assert_same($create_payload['branches'][1]['url'] ?? null, 'http://wp.localhost:18080/wp-admin/', 'branch create response gives main its own admin URL'); +assert_same($create_payload['branches'][2]['url'] ?? null, 'http://feature.wp.localhost:18080/wp-admin/', 'branch create response keeps existing branch URLs distinct'); assert_same(count($create['argv']), 1, 'branch create admin action invokes ForkPress CLI once'); assert_same( array_slice($create['argv'][0] ?? [], 1), @@ -321,6 +346,19 @@ function decode_branch_ui_payload(array $result): array { 'branch create admin action uses safe branch birth CLI path' ); +$forwarded_create = run_branch_ui_action( + ['action' => 'forkpress_branch_create', 'branch' => 'forwarded_feature', 'from' => 'feature'], + ['main', 'feature'], + false, + true, + true, + ['FORKPRESS_TEST_FORWARDED_PROTO' => 'https'] +); +$forwarded_create_payload = decode_branch_ui_payload($forwarded_create); +assert_same($forwarded_create['status'], 0, 'branch create respects forwarded HTTPS proxy headers'); +assert_same($forwarded_create_payload['url'] ?? null, 'https://forwarded_feature.wp.localhost:18080/wp-admin/', 'branch create returns HTTPS branch admin URL behind a proxy'); +assert_same($forwarded_create_payload['branches'][1]['url'] ?? null, 'https://wp.localhost:18080/wp-admin/', 'branch create returns HTTPS main URL behind a proxy'); + $non_async_create = run_branch_ui_action( ['action' => 'forkpress_branch_create', 'branch' => 'no_async_feature', 'from' => 'feature'], ['main', 'feature'], @@ -407,6 +445,9 @@ function decode_branch_ui_payload(array $result): array { assert_same($tree_payload['recordCount'] ?? null, 1, 'branch tree admin action reports record count'); assert_same($tree_payload['records'][0]['source_branch'] ?? null, 'feature', 'branch tree admin action exposes source branch'); assert_same($tree_payload['records'][0]['target_branch'] ?? null, 'main', 'branch tree admin action exposes target branch'); +assert_same($tree_payload['records'][0]['conflictSummary']['total'] ?? null, 3, 'branch tree admin action includes conflict totals for fast graph badges'); +assert_same($tree_payload['records'][0]['conflictSummary']['unresolved'] ?? null, 3, 'branch tree admin action seeds unresolved conflict count without extra audits'); +assert_same($tree_payload['records'][0]['conflictSummary']['estimated'] ?? null, true, 'branch tree admin action marks seeded conflict summaries as estimated'); assert_same($tree_payload['treeCommand'] ?? null, 'forkpress branch tree --limit 5 --format json', 'branch tree admin action exposes the matching CLI command'); assert_same(count($tree['argv']), 1, 'branch tree admin action invokes ForkPress CLI once'); assert_same( @@ -415,6 +456,43 @@ function decode_branch_ui_payload(array $result): array { 'branch tree admin action uses audited branch tree CLI path' ); +$metadata_db = $tmp . '/metadata.sqlite'; +$metadata = new SQLite3($metadata_db); +$metadata->exec('CREATE TABLE merge_conflicts (id INTEGER PRIMARY KEY, run_id INTEGER NOT NULL)'); +$metadata->exec('CREATE TABLE merge_conflict_events (id INTEGER PRIMARY KEY, conflict_id INTEGER NOT NULL, lifecycle_state TEXT NOT NULL)'); +$metadata->exec('CREATE TABLE merge_resolutions (id INTEGER PRIMARY KEY, conflict_id INTEGER NOT NULL, applied INTEGER NOT NULL)'); +$metadata->exec('INSERT INTO merge_conflicts (id, run_id) VALUES (1, 42), (2, 42), (3, 42)'); +$metadata->exec("INSERT INTO merge_conflict_events (id, conflict_id, lifecycle_state) VALUES (1, 1, 'resolved'), (2, 2, 'unreviewed')"); +$metadata->exec('INSERT INTO merge_resolutions (id, conflict_id, applied) VALUES (1, 3, 1)'); +$metadata->close(); +$tree_with_metadata_json = json_encode([ + 'metadata_db' => $metadata_db, + 'runs' => [ + [ + 'id' => 42, + 'source_branch' => 'feature', + 'target_branch' => 'main', + 'status' => 'completed_with_conflicts', + 'decision_count' => 9, + 'conflict_count' => 3, + 'finished_at' => '2026-05-18 12:00:00', + ], + ], +], JSON_UNESCAPED_SLASHES); +$tree_with_metadata = run_branch_ui_action( + ['action' => 'forkpress_branch_tree', 'limit' => '5'], + ['main', 'feature'], + false, + true, + true, + ['FORKPRESS_TEST_CLI_OUTPUT' => $tree_with_metadata_json] +); +$tree_with_metadata_payload = decode_branch_ui_payload($tree_with_metadata); +assert_same($tree_with_metadata_payload['records'][0]['conflictSummary']['total'] ?? null, 3, 'branch tree admin action reads conflict summary totals from metadata'); +assert_same($tree_with_metadata_payload['records'][0]['conflictSummary']['resolved'] ?? null, 2, 'branch tree admin action reads resolved conflict counts from metadata'); +assert_same($tree_with_metadata_payload['records'][0]['conflictSummary']['unresolved'] ?? null, 1, 'branch tree admin action reads unresolved conflict counts from metadata'); +assert_same(isset($tree_with_metadata_payload['records'][0]['conflictSummary']['estimated']), false, 'branch tree admin action does not mark metadata-backed conflict summaries as estimated'); + $conflicted_merge_output = "forkpress: merged feature into main\\n run: 42\\n status: completed_with_conflicts\\n applied: yes\\n conflicts: 3\\n"; $conflicted_merge = run_branch_ui_action( ['action' => 'forkpress_branch_merge', 'source' => 'feature', 'target' => 'main'], @@ -554,6 +632,17 @@ function decode_branch_ui_payload(array $result): array { 'branch conflict review admin action records a first-class merge review note' ); +$custom_conflict_review = run_branch_ui_action( + ['action' => 'forkpress_branch_review_conflict', 'conflict' => '7', 'run' => '42', 'status' => 'needs-action', 'note' => 'Check the source value with the editor before applying.'], + ['main', 'feature'] +); +assert_same($custom_conflict_review['status'], 0, 'branch conflict review accepts editable notes'); +assert_same( + array_slice($custom_conflict_review['argv'][0] ?? [], 1), + ['branch', '--work-dir', $work_dir, 'merge-review', 'conflict', '7', '--status', 'needs-action', '--note', 'Check the source value with the editor before applying.', '--reviewer', 'wordpress-ui'], + 'branch conflict review passes editable branch-manager notes to the CLI' +); + $invalid_conflict_review = run_branch_ui_action( ['action' => 'forkpress_branch_review_conflict', 'conflict' => '7', 'status' => 'done'], ['main', 'feature'] @@ -578,6 +667,35 @@ function decode_branch_ui_payload(array $result): array { 'branch conflict resolution admin action applies a first-class merge resolution' ); +$custom_conflict_resolution = run_branch_ui_action( + ['action' => 'forkpress_branch_resolve_conflict', 'conflict' => '7', 'run' => '42', 'choice' => 'target', 'note' => 'Keep the production copy because the source branch is stale.'], + ['main', 'feature'] +); +assert_same($custom_conflict_resolution['status'], 0, 'branch conflict resolution accepts editable notes'); +assert_same( + array_slice($custom_conflict_resolution['argv'][0] ?? [], 1), + ['branch', '--work-dir', $work_dir, 'merge-resolve', 'conflict', '7', '--choice', 'target', '--apply', '--note', 'Keep the production copy because the source branch is stale.', '--reviewer', 'wordpress-ui'], + 'branch conflict resolution passes editable branch-manager notes to the CLI' +); + +$change_applied_resolution = run_branch_ui_action( + ['action' => 'forkpress_branch_resolve_conflict', 'conflict' => '7', 'run' => '42', 'choice' => 'target', 'replaceApplied' => '1', 'note' => 'Switch the already applied resolution back to target.'], + ['main', 'feature'] +); +$change_applied_resolution_payload = decode_branch_ui_payload($change_applied_resolution); +assert_same($change_applied_resolution_payload['success'] ?? null, true, 'branch conflict resolution can request an applied-resolution change'); +assert_same($change_applied_resolution_payload['replaceApplied'] ?? null, true, 'branch conflict resolution reports applied-resolution replacement mode'); +assert_same( + $change_applied_resolution_payload['message'] ?? null, + 'Changed conflict #7 to target.', + 'branch conflict resolution explains applied-resolution replacement' +); +assert_same( + array_slice($change_applied_resolution['argv'][0] ?? [], 1), + ['branch', '--work-dir', $work_dir, 'merge-resolve', 'conflict', '7', '--choice', 'target', '--apply', '--replace-applied', '--note', 'Switch the already applied resolution back to target.', '--reviewer', 'wordpress-ui'], + 'branch conflict resolution passes replace-applied mode to the CLI' +); + $invalid_conflict_resolution = run_branch_ui_action( ['action' => 'forkpress_branch_resolve_conflict', 'conflict' => '7', 'choice' => 'both'], ['main', 'feature'] @@ -650,6 +768,14 @@ function decode_branch_ui_payload(array $result): array { assert_same($mixed_conflict_resolution_payload['success'] ?? null, false, 'branch conflict resolution rejects mixed choice and apply-reviewed'); assert_same(count($mixed_conflict_resolution['argv']), 0, 'branch conflict resolution rejects mixed apply modes before invoking CLI'); +$mixed_replace_applied_resolution = run_branch_ui_action( + ['action' => 'forkpress_branch_resolve_conflict', 'conflict' => '7', 'applyReviewed' => '1', 'replaceApplied' => '1'], + ['main', 'feature'] +); +$mixed_replace_applied_resolution_payload = decode_branch_ui_payload($mixed_replace_applied_resolution); +assert_same($mixed_replace_applied_resolution_payload['success'] ?? null, false, 'branch conflict resolution rejects mixed apply-reviewed and replace-applied'); +assert_same(count($mixed_replace_applied_resolution['argv']), 0, 'branch conflict resolution rejects replace-applied apply modes before invoking CLI'); + $after_revalidate_resolution = run_branch_ui_action( ['action' => 'forkpress_branch_resolve_conflict', 'conflict' => '7', 'run' => '42', 'choice' => 'source', 'afterRevalidate' => '1'], ['main', 'feature'] @@ -830,9 +956,11 @@ function decode_branch_ui_payload(array $result): array { $revalidation_payload = decode_branch_ui_payload($revalidation); assert_same($revalidation_payload['success'] ?? null, true, 'branch conflict revalidation returns JSON success'); assert_same($revalidation_payload['type'] ?? null, 'warning', 'branch conflict revalidation returns warning type'); +assert_same($revalidation_payload['message'] ?? null, 'Checked merge run 42 for changes: 4 checked, 2 changed, 1 unchanged.', 'branch conflict change check uses clear user-facing wording'); assert_same($revalidation_payload['checked'] ?? null, 4, 'branch conflict revalidation exposes checked count'); assert_same($revalidation_payload['stale'] ?? null, 2, 'branch conflict revalidation exposes stale count'); assert_same($revalidation_payload['carried'] ?? null, 1, 'branch conflict revalidation exposes carried count'); +assert_same($revalidation_payload['changeCheck']['checked'] ?? null, 4, 'branch conflict change check exposes structured change-check details'); assert_same( $revalidation_payload['auditCommand'] ?? null, 'forkpress branch merge-audit --revalidate --run 42 --reviewer wordpress-ui --format json', @@ -1020,7 +1148,7 @@ function decode_branch_ui_payload(array $result): array { ); $invalid_revalidation_payload = decode_branch_ui_payload($invalid_revalidation); assert_same($invalid_revalidation_payload['success'] ?? null, false, 'branch conflict revalidation rejects invalid run ids'); -assert_same($invalid_revalidation_payload['message'] ?? null, 'Choose a merge run to revalidate.', 'branch conflict revalidation explains invalid run ids'); +assert_same($invalid_revalidation_payload['message'] ?? null, 'Choose a merge run to check for changes.', 'branch conflict revalidation explains invalid run ids'); assert_same(count($invalid_revalidation['argv']), 0, 'branch conflict revalidation does not invoke CLI for invalid run ids'); $invalid_apply_reviewed = run_branch_ui_action( @@ -1042,7 +1170,7 @@ function decode_branch_ui_payload(array $result): array { ); $invalid_revalidation_json_payload = decode_branch_ui_payload($invalid_revalidation_json); assert_same($invalid_revalidation_json_payload['success'] ?? null, false, 'branch conflict revalidation rejects invalid CLI JSON'); -assert_same($invalid_revalidation_json_payload['message'] ?? null, 'ForkPress returned invalid revalidation JSON.', 'branch conflict revalidation explains invalid CLI JSON'); +assert_same($invalid_revalidation_json_payload['message'] ?? null, 'ForkPress returned invalid conflict change-check JSON.', 'branch conflict revalidation explains invalid CLI JSON'); $invalid_json_audit = run_branch_ui_action( ['action' => 'forkpress_branch_conflicts', 'run' => '42'], @@ -1148,7 +1276,8 @@ function decode_branch_ui_payload(array $result): array { $switcher_render_payload = decode_branch_ui_payload($switcher_render); $switcher_html = (string)($switcher_render_payload['html'] ?? ''); assert_true(str_contains($switcher_html, 'Open branch manager'), 'branch switcher links to the full branch manager page'); -assert_true(str_contains($switcher_html, '/wp-admin/admin.php?page=forkpress-branches'), 'branch switcher uses the wp-admin branch manager URL'); +assert_true(str_contains($switcher_html, '/_forkpress/branches'), 'branch switcher uses the out-of-band branch manager URL'); +assert_true(str_contains($switcher_html, "window.location.assign(payload.url)"), 'branch switcher navigates to the new branch after create'); assert_true(str_contains($switcher_html, 'forkpress_branch_history'), 'branch switcher renders branch history action'); assert_true(str_contains($switcher_html, 'nonce-forkpress_branch_history'), 'branch switcher renders branch history nonce'); assert_true(str_contains($switcher_html, 'Show merge history'), 'branch switcher renders branch history button text'); @@ -1180,6 +1309,8 @@ function decode_branch_ui_payload(array $result): array { assert_true(str_contains($switcher_html, 'forkpress_branch_revalidate_conflicts'), 'branch switcher renders conflict revalidation action'); assert_true(str_contains($switcher_html, 'nonce-forkpress_branch_revalidate_conflicts'), 'branch switcher renders conflict revalidation nonce'); assert_true(str_contains($switcher_html, 'function fetchConflictRevalidation'), 'branch switcher renders conflict revalidation client handler'); +assert_true(str_contains($switcher_html, 'Check for changes'), 'branch switcher labels conflict rechecks without internal validation wording'); +assert_true(str_contains($switcher_html, 'Checked conflicts for changes.'), 'branch switcher reports conflict rechecks without internal validation wording'); assert_true(str_contains($switcher_html, 'forkpress_branch_review_conflict'), 'branch switcher renders conflict review action'); assert_true(str_contains($switcher_html, 'nonce-forkpress_branch_review_conflict'), 'branch switcher renders conflict review nonce'); assert_true(str_contains($switcher_html, 'function fetchConflictReview'), 'branch switcher renders conflict review client handler'); @@ -1191,11 +1322,14 @@ function decode_branch_ui_payload(array $result): array { assert_true(str_contains($switcher_html, 'function conflictResolutionChoiceAvailable'), 'branch switcher checks conflict resolution availability'); assert_true(str_contains($switcher_html, 'function conflictApplyReviewedAvailable'), 'branch switcher checks apply-reviewed availability'); assert_true(str_contains($switcher_html, 'function conflictResolutionAfterRevalidate'), 'branch switcher detects after-revalidate resolution guards'); +assert_true(str_contains($switcher_html, 'function conflictResolutionChangeAvailable'), 'branch switcher detects replace-applied resolution guards'); assert_true(str_contains($switcher_html, 'Use source'), 'branch switcher renders source resolution action'); assert_true(str_contains($switcher_html, 'Keep target'), 'branch switcher renders target resolution action'); +assert_true(str_contains($switcher_html, 'Change applied resolution'), 'branch switcher renders applied-resolution change action'); assert_true(str_contains($switcher_html, 'Apply reviewed'), 'branch switcher renders apply-reviewed action'); assert_true(str_contains($switcher_html, "body.append('applyReviewed', '1')"), 'branch switcher sends apply-reviewed resolution payloads'); assert_true(str_contains($switcher_html, "body.append('afterRevalidate', '1')"), 'branch switcher sends after-revalidate resolution payloads'); +assert_true(str_contains($switcher_html, "body.append('replaceApplied', '1')"), 'branch switcher sends replace-applied resolution payloads'); assert_true(str_contains($switcher_html, 'forkpress_branch_apply_reviewed_conflicts'), 'branch switcher renders reviewed-resolution apply action'); assert_true(str_contains($switcher_html, 'nonce-forkpress_branch_apply_reviewed_conflicts'), 'branch switcher renders reviewed-resolution apply nonce'); assert_true(str_contains($switcher_html, 'function fetchApplyReviewedConflicts'), 'branch switcher renders reviewed-resolution apply client handler'); @@ -1204,6 +1338,7 @@ function decode_branch_ui_payload(array $result): array { assert_true(str_contains($switcher_html, 'record.plugin_object'), 'branch switcher renders plugin conflict object metadata'); assert_true(str_contains($switcher_html, 'record.plugin_severity'), 'branch switcher renders plugin conflict severity metadata'); assert_true(str_contains($switcher_html, 'record.plugin_validator'), 'branch switcher renders plugin conflict validator metadata'); +assert_true(str_contains($switcher_html, 'plugin check: '), 'branch switcher labels plugin validator metadata as plugin checks'); assert_true(str_contains($switcher_html, 'function conflictPluginGuidance'), 'branch switcher renders plugin conflict guidance metadata'); assert_true(str_contains($switcher_html, 'record.plugin_resolution_policy'), 'branch switcher renders plugin conflict resolution policy'); assert_true(str_contains($switcher_html, 'record.plugin_suggested_action'), 'branch switcher renders plugin conflict suggested action'); @@ -1224,60 +1359,11 @@ function decode_branch_ui_payload(array $result): array { $admin_page_menus = $admin_page_payload['menus'] ?? []; assert_same($admin_page['status'], 0, 'branch manager admin page renders cleanly'); assert_true(str_contains($admin_page_html, '

ForkPress Branches

'), 'branch manager admin page has a wp-admin page title'); -assert_true(str_contains($admin_page_html, 'action="\/wp-admin\/admin-post.php"') || str_contains($admin_page_html, 'action="/wp-admin/admin-post.php"'), 'branch manager admin page posts to admin-post.php'); -assert_true(str_contains($admin_page_html, 'name="action" value="forkpress_branch_create"'), 'branch manager admin page renders create form action'); -assert_true(str_contains($admin_page_html, 'id="forkpress-branch-create-name"'), 'branch manager admin page renders branch name input'); -assert_true(str_contains($admin_page_html, 'name="from"'), 'branch manager admin page renders source branch selector for creates'); -assert_true(str_contains($admin_page_html, 'name="action" value="forkpress_branch_merge"'), 'branch manager admin page renders merge form action'); -assert_true(str_contains($admin_page_html, 'name="source"'), 'branch manager admin page renders merge source selector'); -assert_true(str_contains($admin_page_html, 'name="target"'), 'branch manager admin page renders merge target selector'); -assert_true(str_contains($admin_page_html, 'id="forkpress-branch-history-load"'), 'branch manager admin page renders merge history button'); -assert_true(str_contains($admin_page_html, 'id="forkpress-branch-tree-load"'), 'branch manager admin page renders branch tree button'); -assert_true(str_contains($admin_page_html, 'forkpress_branch_history'), 'branch manager admin page renders merge history action'); -assert_true(str_contains($admin_page_html, 'nonce-forkpress_branch_history'), 'branch manager admin page renders merge history nonce'); -assert_true(str_contains($admin_page_html, 'forkpress_branch_tree'), 'branch manager admin page renders branch tree action'); -assert_true(str_contains($admin_page_html, 'nonce-forkpress_branch_tree'), 'branch manager admin page renders branch tree nonce'); -assert_true(str_contains($admin_page_html, 'forkpress-branch-history-list'), 'branch manager admin page renders merge history list target'); -assert_true(str_contains($admin_page_html, 'forkpress-branch-tree-list'), 'branch manager admin page renders branch tree list target'); -assert_true(str_contains($admin_page_html, "source + ' -> ' + target"), 'branch manager admin page renders source-to-target history rows'); -assert_true(str_contains($admin_page_html, "target + ' <- ' + branches[target].join(', ')"), 'branch manager admin page renders target-to-source branch tree rows'); -assert_true(str_contains($admin_page_html, 'forkpress_branch_conflicts'), 'branch manager admin page renders conflict audit action'); -assert_true(str_contains($admin_page_html, 'nonce-forkpress_branch_conflicts'), 'branch manager admin page renders conflict audit nonce'); -assert_true(str_contains($admin_page_html, 'forkpress-branch-review-conflicts'), 'branch manager admin page renders conflict drilldown buttons'); -assert_true(str_contains($admin_page_html, 'function fetchConflicts'), 'branch manager admin page renders conflict drilldown fetch handler'); -assert_true(str_contains($admin_page_html, 'function renderConflicts'), 'branch manager admin page renders conflict drilldown display handler'); -assert_true(str_contains($admin_page_html, 'forkpress-branch-conflict-list'), 'branch manager admin page renders conflict list target'); -assert_true(str_contains($admin_page_html, 'forkpress-branch-conflict-actions'), 'branch manager admin page renders conflict action controls'); -assert_true(str_contains($admin_page_html, 'nonce-forkpress_branch_review_conflict'), 'branch manager admin page renders conflict review nonce'); -assert_true(str_contains($admin_page_html, 'nonce-forkpress_branch_resolve_conflict'), 'branch manager admin page renders conflict resolution nonce'); -assert_true(str_contains($admin_page_html, 'function fetchConflictReview'), 'branch manager admin page renders conflict review handler'); -assert_true(str_contains($admin_page_html, 'function fetchConflictResolution'), 'branch manager admin page renders conflict resolution handler'); -assert_true(str_contains($admin_page_html, 'function conflictResolutionChoiceAvailable'), 'branch manager admin page checks conflict resolution availability'); -assert_true(str_contains($admin_page_html, 'function conflictApplyReviewedAvailable'), 'branch manager admin page checks apply-reviewed availability'); -assert_true(str_contains($admin_page_html, 'Use source'), 'branch manager admin page renders source resolution action'); -assert_true(str_contains($admin_page_html, 'Keep target'), 'branch manager admin page renders target resolution action'); -assert_true(str_contains($admin_page_html, 'Apply reviewed'), 'branch manager admin page renders apply-reviewed action'); +assert_true(str_contains($admin_page_html, 'Open ForkPress branch manager'), 'wp-admin branch page links to the out-of-band manager'); +assert_true(str_contains($admin_page_html, '/_forkpress/branches'), 'wp-admin branch page points at the out-of-band manager URL'); +assert_true(!str_contains($admin_page_html, 'id="forkpress-branch-create-name"'), 'wp-admin branch page no longer owns branch creation UI'); assert_same($admin_page_menus[0]['menu_slug'] ?? null, 'forkpress-branches', 'branch manager registers a wp-admin menu page'); -$admin_page_with_driver = run_branch_ui_action( - ['action' => 'forkpress_branch_admin_page'], - ['main', 'feature'], - false, - true, - true, - ['FORKPRESS_PLUGIN_MERGE_DRIVERS' => json_encode(['forkpress-plugin-graph' => realpath($plugin_driver)], JSON_UNESCAPED_SLASHES)] -); -$admin_page_with_driver_payload = decode_branch_ui_payload($admin_page_with_driver); -$admin_page_with_driver_html = (string)($admin_page_with_driver_payload['html'] ?? ''); -assert_same($admin_page_with_driver['status'], 0, 'branch manager admin page with plugin driver renders cleanly'); -assert_true(str_contains($admin_page_with_driver_html, 'forkpress_branch_run_plugin_driver'), 'branch manager admin page renders plugin driver action'); -assert_true(str_contains($admin_page_with_driver_html, 'nonce-forkpress_branch_run_plugin_driver'), 'branch manager admin page renders plugin driver nonce'); -assert_true(str_contains($admin_page_with_driver_html, 'pluginDrivers'), 'branch manager admin page exposes approved plugin driver metadata'); -assert_true(str_contains($admin_page_with_driver_html, $driver_key), 'branch manager admin page exposes the approved plugin driver key'); -assert_true(str_contains($admin_page_with_driver_html, 'function driverForConflict'), 'branch manager admin page renders plugin driver matching helper'); -assert_true(str_contains($admin_page_with_driver_html, 'function fetchPluginDriver'), 'branch manager admin page renders plugin driver client handler'); -assert_true(str_contains($admin_page_with_driver_html, 'Run plugin driver'), 'branch manager admin page renders plugin driver button text'); - $forbidden = run_branch_ui_action( ['action' => 'forkpress_branch_create', 'branch' => 'new_feature', 'from' => 'main'], ['main', 'feature'], diff --git a/tests/cow/merge.php b/tests/cow/merge.php index ec42c343..3521621a 100644 --- a/tests/cow/merge.php +++ b/tests/cow/merge.php @@ -218,6 +218,63 @@ function column_type(string $db_path, string $table, string $column): ?string { 'independent target cell preservation is auditable' ); + $replace_base = $tmp . '/replace-applied-base.sqlite'; + $replace_source = $tmp . '/replace-applied-source.sqlite'; + $replace_target = $tmp . '/replace-applied-target.sqlite'; + $replace_metadata = $tmp . '/.forkpress/cow/merge/replace-applied-metadata.sqlite'; + create_base_db($replace_base); + copy($replace_base, $replace_source); + copy($replace_base, $replace_target); + $db = open_db($replace_source); + $db->exec("UPDATE wp_posts SET post_content = 'Source conflict content' WHERE ID = 1"); + $db->close(); + $db = open_db($replace_target); + $db->exec("UPDATE wp_posts SET post_content = 'Target conflict content' WHERE ID = 1"); + $db->close(); + $replace_result = cow_merge_databases($replace_base, $replace_source, $replace_target, $replace_metadata, 'feature-replace-resolution', 'main'); + assert_same($replace_result['status'], 'completed_with_conflicts', 'conflicting cell edits are audited before applied-resolution replacement'); + $replace_conflict_id = (int)scalar($replace_metadata, "SELECT id FROM merge_conflicts WHERE table_name = 'wp_posts' AND column_name = 'post_content' AND conflict_type = 'cell-conflict' ORDER BY id DESC LIMIT 1"); + $replace_source_resolution = cow_merge_resolve_conflict( + $replace_metadata, + $replace_conflict_id, + 'source', + true, + 'Apply source before changing the applied resolution.', + 'cow-test' + ); + assert_same($replace_source_resolution['status'], 'applied', 'source cell conflict resolution applies normally'); + assert_same(scalar($replace_target, "SELECT post_content FROM wp_posts WHERE ID = 1"), 'Source conflict content', 'source cell resolution mutates the target cell'); + assert_throws( + fn() => cow_merge_resolve_conflict($replace_metadata, $replace_conflict_id, 'target', true, 'Changing without explicit replace should fail.', 'cow-test'), + 'already resolved', + 'applied cell conflict resolution cannot be changed without an explicit replace flag' + ); + $replace_target_resolution = cow_merge_resolve_conflict( + $replace_metadata, + $replace_conflict_id, + 'target', + true, + 'Change the already applied resolution back to target.', + 'cow-test', + false, + true + ); + assert_same($replace_target_resolution['status'], 'applied', 'replace-applied cell conflict resolution records applied status'); + assert_same(scalar($replace_target, "SELECT post_content FROM wp_posts WHERE ID = 1"), 'Target conflict content', 'replace-applied cell resolution can restore the audited target value'); + assert_same( + (int)scalar($replace_metadata, "SELECT COUNT(*) FROM merge_resolutions WHERE conflict_id = $replace_conflict_id AND applied = 1"), + 2, + 'replace-applied cell conflict keeps both applied resolution records' + ); + $db = open_db($replace_target); + $db->exec("UPDATE wp_posts SET post_content = 'Manual post-resolution edit' WHERE ID = 1"); + $db->close(); + assert_throws( + fn() => cow_merge_resolve_conflict($replace_metadata, $replace_conflict_id, 'source', true, 'Do not overwrite later target drift.', 'cow-test', false, true), + 'target cell no longer matches the latest applied resolution', + 'replace-applied cell resolution refuses to overwrite target drift' + ); + $volatile_base = $tmp . '/volatile-usermeta-base.sqlite'; $volatile_source = $tmp . '/volatile-usermeta-source.sqlite'; $volatile_target = $tmp . '/volatile-usermeta-target.sqlite'; @@ -16782,6 +16839,7 @@ static function (array $snapshot) use ($band_restore_failure_db): void { $db->exec('ALTER TABLE wp_posts ADD COLUMN post_parent INTEGER NOT NULL DEFAULT 0'); $db->exec('ALTER TABLE wp_posts ADD COLUMN post_author INTEGER NOT NULL DEFAULT 0'); $db->exec("ALTER TABLE wp_posts ADD COLUMN guid TEXT NOT NULL DEFAULT ''"); + $db->exec("ALTER TABLE wp_posts ADD COLUMN post_mime_type TEXT NOT NULL DEFAULT ''"); $db->exec('CREATE TABLE wp_postmeta (meta_id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL, meta_key TEXT NOT NULL, meta_value TEXT NOT NULL)'); $db->exec('CREATE TABLE wp_comments (comment_ID INTEGER PRIMARY KEY AUTOINCREMENT, comment_post_ID INTEGER NOT NULL, comment_content TEXT NOT NULL, comment_parent INTEGER NOT NULL DEFAULT 0, user_id INTEGER NOT NULL DEFAULT 0)'); $db->exec('CREATE TABLE wp_commentmeta (meta_id INTEGER PRIMARY KEY AUTOINCREMENT, comment_id INTEGER NOT NULL, meta_key TEXT NOT NULL, meta_value TEXT NOT NULL)'); @@ -16858,6 +16916,7 @@ static function (array $snapshot) use ($band_restore_failure_db): void { $stmt->bindValue(':file', '2026/05/' . basename($file_path), SQLITE3_TEXT); $stmt->bindValue(':metadata', $attachment_metadata, SQLITE3_TEXT); $stmt->execute(); + $db->exec("UPDATE wp_posts SET post_mime_type = 'image/jpeg' WHERE ID = $attachment_id"); $page_content = '' . '
'; @@ -17077,6 +17136,7 @@ static function (array $snapshot) use ($band_restore_failure_db): void { $db = open_db($wp_media_base); $db->exec("ALTER TABLE wp_posts ADD COLUMN post_type TEXT NOT NULL DEFAULT 'post'"); $db->exec("ALTER TABLE wp_posts ADD COLUMN guid TEXT NOT NULL DEFAULT ''"); + $db->exec("ALTER TABLE wp_posts ADD COLUMN post_mime_type TEXT NOT NULL DEFAULT ''"); $db->exec('CREATE TABLE wp_postmeta (meta_id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL, meta_key TEXT NOT NULL, meta_value TEXT NOT NULL)'); $db->close(); write_test_file($wp_media_base_root . '/wp-content/mu-plugins/forkpress-merge-validator.php', <<<'PHP' @@ -17656,6 +17716,7 @@ static function (array $snapshot) use ($band_restore_failure_db): void { $stmt->bindValue(':file', '2026/05/source-duplicate-b.jpg', SQLITE3_TEXT); $stmt->bindValue(':metadata', $wp_media_duplicate_b_metadata, SQLITE3_TEXT); $stmt->execute(); + $db->exec("UPDATE wp_posts SET post_mime_type = 'image/jpeg' WHERE post_type = 'attachment'"); $db->close(); $wp_media_result = cow_merge_branch_state( $wp_media_base, @@ -17671,7 +17732,7 @@ static function (array $snapshot) use ($band_restore_failure_db): void { assert_same($wp_media_result['status'], 'completed_with_conflicts', 'WordPress media validator holds missing generated upload files for review'); assert_same((int)($wp_media_result['plugin_validators'] ?? 0), 1, 'WordPress media validator is discovered from mu-plugins during merge'); $wp_media_semantic_conflicts = (int)($wp_media_result['wordpress_semantic_validator_conflicts'] ?? 0); - assert_same($wp_media_semantic_conflicts, 13, 'built-in WordPress semantic validators record upload coherence conflicts'); + assert_same($wp_media_semantic_conflicts, 17, 'built-in WordPress semantic validators record upload coherence conflicts'); assert_same( (int)($wp_media_result['plugin_validator_conflicts'] ?? 0) - $wp_media_semantic_conflicts, 17, diff --git a/tests/cow/router_branch_actions.php b/tests/cow/router_branch_actions.php index 0041f156..9d158694 100644 --- a/tests/cow/router_branch_actions.php +++ b/tests/cow/router_branch_actions.php @@ -531,7 +531,7 @@ function router_branch_action_request( ['action' => 'forkpress_branch_revalidate_conflicts', 'run' => 'abc'] ); assert_same($invalid_revalidation['status'], 400, 'async router branch conflict revalidation rejects invalid run ids before CLI'); -assert_same($invalid_revalidation['json']['message'] ?? null, 'Choose a merge run to revalidate.', 'async router branch conflict revalidation explains invalid run ids'); +assert_same($invalid_revalidation['json']['message'] ?? null, 'Choose a merge run to check for changes.', 'async router branch conflict revalidation explains invalid run ids'); assert_true(!str_contains($invalid_revalidation['body'], 'WORDPRESS'), 'invalid async router branch conflict revalidation does not reach WordPress admin-post'); $invalid_apply_reviewed = router_branch_action_request( diff --git a/tests/cow/router_lock.php b/tests/cow/router_lock.php index 051057bb..f4899831 100644 --- a/tests/cow/router_lock.php +++ b/tests/cow/router_lock.php @@ -30,6 +30,25 @@ function rm_tree(string $path): void { } @rmdir($path); } +function read_stream_until($stream, string $needle, float $timeout_seconds): string { + $body = ''; + $deadline = microtime(true) + $timeout_seconds; + while (microtime(true) < $deadline) { + $chunk = stream_get_contents($stream); + if ($chunk !== false && $chunk !== '') { + $body .= $chunk; + } + if ($needle !== '' && str_contains($body, $needle)) { + break; + } + usleep(10000); + } + $chunk = stream_get_contents($stream); + if ($chunk !== false && $chunk !== '') { + $body .= $chunk; + } + return $body; +} echo "=== COW router operation lock ===\n"; @@ -40,6 +59,7 @@ function rm_tree(string $path): void { $entered = $tmp . '/router-entered.txt'; $started = $tmp . '/request-started.txt'; $child = $tmp . '/request.php'; +$fake_bin = $tmp . '/forkpress'; $router = realpath(__DIR__ . '/../../runtime/cow/router.php'); assert_true($router !== false, 'router fixture exists'); register_shutdown_function(static function() use ($tmp): void { @@ -51,6 +71,10 @@ function rm_tree(string $path): void { file_put_contents($main . '/index.php', " ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = proc_open([PHP_BINARY, $child, $branches, $cow, $router, '/_forkpress/branches', $entered], $descriptor, $pipes); + assert_true(is_resource($process), 'spawned out-of-band branch manager request process'); + if (is_resource($process)) { + fclose($pipes[0]); + stream_set_blocking($pipes[1], false); + $deadline = microtime(true) + 2.0; + while (!file_exists($entered) && microtime(true) < $deadline) { + usleep(10000); + } + assert_true(file_exists($entered), 'out-of-band branch manager reached pre-lock gate'); + $early_body = read_stream_until($pipes[1], '', 2.0); + assert_true(str_contains($early_body, 'ForkPress Branches'), 'out-of-band branch manager renders before lock release'); + assert_true(str_contains($early_body, 'fp-graph'), 'out-of-band branch manager renders the branch graph surface'); + assert_true(str_contains($early_body, 'fp-bottom'), 'out-of-band branch manager renders one persistent bottom workbench'); + assert_true(str_contains($early_body, 'fp-bottom-actions'), 'out-of-band branch manager keeps branch actions in the bottom workbench'); + assert_true(str_contains($early_body, 'fp-workbench-mode'), 'out-of-band branch manager labels the current workbench mode'); + assert_true(str_contains($early_body, 'role="status" aria-live="polite"'), 'out-of-band branch manager announces async status changes'); + assert_true(str_contains($early_body, 'function statusKindForPayload'), 'out-of-band branch manager normalizes warning/notice action status'); + assert_true(!str_contains($early_body, 'fp-side'), 'out-of-band branch manager no longer alternates through a right sidebar'); + assert_true(str_contains($early_body, '"actionUrl":"/_forkpress/action"'), 'out-of-band branch manager uses same-origin action endpoint'); + assert_true(!str_contains($early_body, 'http://wp.localhost/_forkpress/action'), 'out-of-band branch manager does not hard-code an HTTP action endpoint'); + assert_true(str_contains($early_body, '"pluginDrivers"'), 'out-of-band branch manager receives approved plugin merge drivers'); + assert_true(str_contains($early_body, 'fp-timeline-lane'), 'out-of-band branch manager renders compact git-style graph lanes'); + assert_true(str_contains($early_body, 'fp-timeline-fork'), 'out-of-band branch manager renders branch fork curves'); + assert_true(str_contains($early_body, 'laneActivity'), 'out-of-band branch manager computes finite branch lifetimes from revision rows'); + assert_true(str_contains($early_body, 'branchForkParents'), 'out-of-band branch manager infers branch fork parents for timeline rendering'); + assert_true(str_contains($early_body, 'isBranchForkRun'), 'out-of-band branch manager treats branch setup rows as fork points'); + assert_true(str_contains($early_body, 'fp-lane-boundary'), 'out-of-band branch manager renders branch line boundaries as ticks, not extra revision dots'); + assert_true(str_contains($early_body, 'fp-timeline-merge'), 'out-of-band branch manager renders merge curves between lanes'); + assert_true(str_contains($early_body, 'marker-end'), 'out-of-band branch manager renders merge direction arrows into the target branch'); + assert_true(str_contains($early_body, 'arrowEndX'), 'out-of-band branch manager points merge arrows at the target revision node edge'); + assert_true(str_contains($early_body, 'fp-merge-flow'), 'out-of-band branch manager labels merge source-to-target flow inline on the graph'); + assert_true(str_contains($early_body, 'if (isSelected) {'), 'out-of-band branch manager limits inline merge-flow labels to the selected event'); + assert_true(str_contains($early_body, 'badgeWidth'), 'out-of-band branch manager renders conflict counts as badges rather than extra revision nodes'); + assert_true(str_contains($early_body, 'merge into'), 'out-of-band branch manager labels merge revisions by target branch'); + assert_true(str_contains($early_body, 'fp-row-title'), 'out-of-band branch manager renders revisions as timeline rows'); + assert_true(str_contains($early_body, 'sortedRunEntries'), 'out-of-band branch manager sorts real revision records, not one row per branch'); + assert_true(str_contains($early_body, 'runTimelineTimestamp'), 'out-of-band branch manager sorts revisions by timestamp before run id'); + assert_true(str_contains($early_body, 'events / newest first'), 'out-of-band branch manager labels timeline direction'); + assert_true(str_contains($early_body, 'branchLabelText'), 'out-of-band branch manager compacts and staggers branch lane labels'); + assert_true(str_contains($early_body, 'fp-node-merge'), 'out-of-band branch manager renders merge events with a distinct target marker'); + assert_true(str_contains($early_body, 'Merge event #'), 'out-of-band branch manager distinguishes merge events from ordinary revisions'); + assert_true(str_contains($early_body, 'fp-legend'), 'out-of-band branch manager explains graph symbols with an inline legend'); + assert_true(str_contains($early_body, 'conflicts'), 'out-of-band branch manager labels graph conflicts plainly'); + assert_true(str_contains($early_body, 'merge result exists'), 'out-of-band branch manager makes clear conflicted merges already have target branch results'); + assert_true(str_contains($early_body, 'merged with conflicts'), 'out-of-band branch manager avoids implying conflicted merges are value-less'); + assert_true(str_contains($early_body, 'fp-row-selected'), 'out-of-band branch manager highlights the selected timeline event'); + assert_true(str_contains($early_body, 'function scrollSelectedRunIntoView'), 'out-of-band branch manager scrolls the graph to deep-linked selected events'); + assert_true(str_contains($early_body, 'Focus selected'), 'out-of-band branch manager offers a clearly labeled graph control to re-center the selected event'); + assert_true(str_contains($early_body, "graph.addEventListener('keydown'"), 'out-of-band branch manager supports keyboard selection in the graph'); + assert_true(str_contains($early_body, 'seedConflictSummaries'), 'out-of-band branch manager seeds conflict summaries without extra conflict audits'); + assert_true(str_contains($early_body, 'selectedRunId'), 'out-of-band branch manager keeps the selected revision synced after conflict audits load'); + assert_true(str_contains($early_body, 'didRestoreInitialSelection'), 'out-of-band branch manager restores URL selection once without overriding later selections'); + assert_true(str_contains($early_body, 'preserveSelection'), 'out-of-band branch manager preserves selected timeline events during refreshes'); + assert_true(str_contains($early_body, 'skipConflictReload'), 'out-of-band branch manager avoids duplicate conflict reloads during graph refreshes'); + assert_true(str_contains($early_body, 'initialParams'), 'out-of-band branch manager can restore selected review state from the URL'); + assert_true(str_contains($early_body, 'function syncReviewUrl'), 'out-of-band branch manager writes selected run and conflict state to the URL'); + assert_true(str_contains($early_body, "params.set('run'"), 'out-of-band branch manager deep-links selected merge/revision runs'); + assert_true(str_contains($early_body, "params.set('conflict'"), 'out-of-band branch manager deep-links selected conflict rows'); + assert_true(str_contains($early_body, "params.set('filter'"), 'out-of-band branch manager deep-links the active conflict filter'); + assert_true(str_contains($early_body, 'requestedRun'), 'out-of-band branch manager ignores stale conflict loads after selecting another revision'); + assert_true(str_contains($early_body, 'refreshSelectedRunConflictState'), 'out-of-band branch manager refreshes selected revision conflict totals after audit details load'); + assert_true(!str_contains($early_body, 'Promise.all(conflictRuns.map'), 'out-of-band branch manager avoids N+1 conflict audit loading'); + assert_true(str_contains($early_body, 'renderConflictLoading'), 'out-of-band branch manager renders an immediate conflict loader'); + assert_true(str_contains($early_body, 'function forgetConflictCache'), 'out-of-band branch manager invalidates cached conflict records after resolution actions'); + assert_true(str_contains($early_body, 'function refreshAfterConflictAction'), 'out-of-band branch manager refreshes graph and conflict data after resolution actions'); + assert_true(str_contains($early_body, 'forkpress_branch_tree'), 'out-of-band branch manager can load branch tree data'); + assert_true(str_contains($early_body, 'forkpress_branch_conflicts'), 'out-of-band branch manager can revisit conflicts'); + assert_true(str_contains($early_body, 'forkpress_branch_revalidate_conflicts'), 'out-of-band branch manager can revalidate conflicts from the full-page review'); + assert_true(str_contains($early_body, 'forkpress_branch_restore_crash'), 'out-of-band branch manager can restore pending crash recovery from the full-page review'); + assert_true(str_contains($early_body, 'fp-conflict-workbench'), 'out-of-band branch manager renders conflicts in the bottom workbench'); + assert_true(str_contains($early_body, 'fp-conflict-review-grid'), 'out-of-band branch manager keeps conflict table and inspector on one screen'); + assert_true(str_contains($early_body, 'body.fp-reviewing .fp-bottom-body'), 'out-of-band branch manager expands conflict review to the full workbench width'); + assert_true(str_contains($early_body, 'grid-template-rows: minmax(180px, 26vh) minmax(0, 1fr)'), 'out-of-band branch manager gives review mode more vertical room for conflicts'); + assert_true(str_contains($early_body, 'repeat(auto-fit, minmax(128px, 1fr))'), 'out-of-band branch manager compresses merge metadata into a responsive strip'); + assert_true(str_contains($early_body, 'body.fp-reviewing .fp-bottom-primary'), 'out-of-band branch manager can hide duplicated run metadata while reviewing conflicts'); + assert_true(str_contains($early_body, 'grid-template-rows: minmax(0, 1fr)'), 'out-of-band branch manager gives the conflict workbench the full lower pane in review mode'); + assert_true(str_contains($early_body, 'fp-conflict-inspector'), 'out-of-band branch manager renders one selected conflict inspector instead of every detail card'); + assert_true(str_contains($early_body, 'setSelectedConflict'), 'out-of-band branch manager changes selected conflicts without rerendering the whole workbench'); + assert_true(str_contains($early_body, 'fp-conflict-inspector-slot'), 'out-of-band branch manager has a stable selected-conflict inspector slot'); + assert_true(!str_contains($early_body, 'Review conflicts'), 'out-of-band branch manager auto-loads conflicts instead of requiring a separate review button'); + assert_true(str_contains($early_body, 'function renderConflictTable'), 'out-of-band branch manager renders conflict summaries as an entity table'); + assert_true(str_contains($early_body, 'data-label'), 'out-of-band branch manager can render mobile conflict rows as labeled cards'); + assert_true(str_contains($early_body, 'cell.title = tooltip.join(\'\\n\')'), 'out-of-band branch manager exposes full conflict table cell text on hover without breaking generated JavaScript'); + assert_true(str_contains($early_body, "row.setAttribute('role', 'button')"), 'out-of-band branch manager makes conflict rows keyboard-operable review targets'); + assert_true(str_contains($early_body, "row.addEventListener('keydown'"), 'out-of-band branch manager supports keyboard selection in the conflict table'); + assert_true(str_contains($early_body, 'fp-summary-chips'), 'out-of-band branch manager renders compact conflict summary chips'); + assert_true(str_contains($early_body, 'merge result exists'), 'out-of-band branch manager separates material merge outcome from resolution choices'); + assert_true(str_contains($early_body, 'choices applied'), 'out-of-band branch manager describes applied conflict choices directly'); + assert_true(str_contains($early_body, 'without explicit choice'), 'out-of-band branch manager describes conflicts without an explicit choice directly'); + assert_true(str_contains($early_body, 'function conflictResolutionSummaryText'), 'out-of-band branch manager centralizes resolution-state terminology'); + assert_true(str_contains($early_body, 'function conflictHasAppliedResolution'), 'out-of-band branch manager names applied resolution state without review workflow terms'); + assert_true(str_contains($early_body, 'function compactValuePreview'), 'out-of-band branch manager keeps resolution value previews compact'); + assert_true(str_contains($early_body, 'Target branch value'), 'out-of-band branch manager explains target-branch conflict resolutions directly'); + assert_true(str_contains($early_body, 'too long to display'), 'out-of-band branch manager avoids rendering huge resolution values inline'); + assert_true(!str_contains($early_body, 'Current target value'), 'out-of-band branch manager does not use abstract current-target resolution labels'); + assert_true(str_contains($early_body, 'function normalizeConflictFilter'), 'out-of-band branch manager preserves old filter links while using clearer terms'); + assert_true(str_contains($early_body, 'function conflictPendingChoiceCount'), 'out-of-band branch manager names pending choice counts directly'); + assert_true(str_contains($early_body, 'fp-filterbar'), 'out-of-band branch manager renders conflict filter controls'); + assert_true(str_contains($early_body, 'function conflictScope'), 'out-of-band branch manager classifies conflict scope for filtering'); + assert_true(str_contains($early_body, 'function conflictFilterCounts'), 'out-of-band branch manager counts conflicts by status and scope'); + assert_true(str_contains($early_body, 'function conflictFilterLabel'), 'out-of-band branch manager names empty conflict filters clearly'); + assert_true(str_contains($early_body, 'aria-pressed'), 'out-of-band branch manager exposes active conflict filters accessibly'); + assert_true(str_contains($early_body, 'Applied choices'), 'out-of-band branch manager filters by applied resolution choices, not review state'); + assert_true(!str_contains($early_body, "['unreviewed', 'Unreviewed']"), 'out-of-band branch manager does not expose an unreviewed workflow filter'); + assert_true(!str_contains($early_body, "['accepted', 'Accepted']"), 'out-of-band branch manager does not expose an accepted workflow filter'); + assert_true(str_contains($early_body, "['Conflict', 'DB table', 'ID', 'Resolution', 'Summary']"), 'out-of-band branch manager keeps DB table, entity IDs, and resolution visible in the conflict table'); + assert_true(str_contains($early_body, "button('Prev'"), 'out-of-band branch manager can move backward through the filtered review queue'); + assert_true(str_contains($early_body, "button('Next'"), 'out-of-band branch manager can move forward through the filtered review queue'); + assert_true(str_contains($early_body, 'fp-review-progress'), 'out-of-band branch manager labels the selected conflict check position compactly'); + assert_true(str_contains($early_body, 'Refresh conflicts'), 'out-of-band branch manager exposes a clear conflict refresh action near the queue'); + assert_true(str_contains($early_body, 'fp-toolbar-more'), 'out-of-band branch manager moves source/target/review-link actions into a compact secondary menu'); + assert_true(str_contains($early_body, 'humanConflictType'), 'out-of-band branch manager translates internal conflict type labels before rendering them'); + assert_true(str_contains($early_body, '.fp-buttons .fp-conflict-meta'), 'out-of-band branch manager keeps inline action labels from stretching mobile controls'); + assert_true(str_contains($early_body, 'overflow-x: auto'), 'out-of-band branch manager keeps mobile conflict actions compact'); + assert_true(str_contains($early_body, 'b.title = label'), 'out-of-band branch manager gives compact buttons full action titles'); + assert_true(str_contains($early_body, 'function setButtonBusy'), 'out-of-band branch manager centralizes busy button state'); + assert_true(str_contains($early_body, 'function runButtonAction'), 'out-of-band branch manager prevents double-clicked async actions'); + assert_true(str_contains($early_body, "buttonNode.setAttribute('aria-busy'"), 'out-of-band branch manager exposes busy state accessibly'); + assert_true(str_contains($early_body, 'button.is-busy::after'), 'out-of-band branch manager shows busy action feedback without layout changes'); + assert_true(str_contains($early_body, 'function appendBranchPreviewLinks'), 'out-of-band branch manager keeps source and target branch actions available in filtered states'); + assert_true(str_contains($early_body, 'function branchManagerHref'), 'out-of-band branch manager gives source and target actions same-origin manager URLs'); + assert_true(str_contains($early_body, 'function branchManagerLink'), 'out-of-band branch manager focuses source and target branches without leaving the manager'); + assert_true(str_contains($early_body, 'initialBranchName'), 'out-of-band branch manager can restore a directly linked branch detail view'); + assert_true(str_contains($early_body, "params.set('branch'"), 'out-of-band branch manager deep-links selected branch detail views'); + assert_true(str_contains($early_body, 'Show source:'), 'out-of-band branch manager labels source branch focus actions clearly'); + assert_true(str_contains($early_body, 'Show target:'), 'out-of-band branch manager labels target branch focus actions clearly'); + assert_true(!str_contains($early_body, "link('Open source:"), 'out-of-band branch manager does not link source actions to branch WordPress hosts'); + assert_true(!str_contains($early_body, "link('Open target:"), 'out-of-band branch manager does not link target actions to branch WordPress hosts'); + assert_true(str_contains($early_body, 'Copy conflict link'), 'out-of-band branch manager exposes a copyable deep link for conflict state'); + assert_true(str_contains($early_body, 'navigator.clipboard.writeText'), 'out-of-band branch manager copies the current review URL when supported'); + assert_true(str_contains($early_body, 'function conflictEntityContext'), 'out-of-band branch manager normalizes conflict entity context for review'); + assert_true(str_contains($early_body, 'entityContext'), 'out-of-band branch manager receives enriched conflict entity context from audits'); + assert_true(str_contains(file_get_contents($router), 'forkpress_cow_conflict_row_payload'), 'out-of-band branch manager can enrich stale conflict context from captured row payloads'); + assert_true(str_contains($early_body, 'wp_options'), 'out-of-band branch manager explains option conflicts by option row context'); + assert_true(str_contains($early_body, 'option_name'), 'out-of-band branch manager exposes option names in conflict context'); + assert_true(str_contains($early_body, 'wp_posts'), 'out-of-band branch manager explains post conflicts by post ID context'); + assert_true(str_contains($early_body, 'post_id'), 'out-of-band branch manager exposes post IDs in conflict context'); + assert_true(str_contains($early_body, 'meta_key'), 'out-of-band branch manager exposes post meta keys in conflict context'); + assert_true(str_contains($early_body, 'fp-conflict-grid'), 'out-of-band branch manager renders conflict values as review fields'); + assert_true(str_contains($early_body, 'decodeAuditPayload'), 'out-of-band branch manager decodes audit payloads for review'); + assert_true(str_contains($early_body, 'Resolution note'), 'out-of-band branch manager exposes editable resolution notes'); + assert_true(str_contains($early_body, 'fp-disclosure'), 'out-of-band branch manager progressively discloses secondary conflict details'); + assert_true(str_contains($early_body, 'Compare values'), 'out-of-band branch manager groups source/base/target values behind one value section'); + assert_true(str_contains($early_body, 'extensionLabel +'), 'out-of-band branch manager labels plugin/theme metadata disclosures by conflict scope'); + assert_true(str_contains($early_body, 'Conflict check payload'), 'out-of-band branch manager gives plugin/theme payload details a neutral label'); + assert_true(str_contains($early_body, 'Apply selected choice'), 'out-of-band branch manager exposes a selected conflict resolution action'); + assert_true(str_contains($early_body, 'fp-conflict-action-row'), 'out-of-band branch manager keeps conflict resolution actions visible in the inspector'); + assert_true(str_contains($early_body, 'fp-conflict-card-head'), 'out-of-band branch manager uses a compact conflict card header'); + assert_true(str_contains($early_body, 'fp-conflict-card-controls'), 'out-of-band branch manager keeps conflict actions outside long payload scrolling'); + assert_true(str_contains($early_body, 'fp-conflict-scroll'), 'out-of-band branch manager gives each conflict card a dedicated scroll body'); + assert_true(strpos($early_body, 'controls.appendChild(row);') < strpos($early_body, 'node.appendChild(scrollBody);'), 'out-of-band branch manager places review actions before scrollable details'); + assert_true(str_contains($early_body, "disclosure(extensionLabel + ' details', pluginPanel, false)"), 'out-of-band branch manager keeps plugin/theme metadata in collapsed secondary details'); + assert_true(str_contains($early_body, 'Change applied resolution'), 'out-of-band branch manager exposes applied-resolution changes'); + assert_true(str_contains($early_body, 'prioritizeResolutionChange'), 'out-of-band branch manager surfaces applied-resolution changes before large value previews'); + assert_true(str_contains($early_body, 'replaceApplied'), 'out-of-band branch manager sends replace-applied resolution payloads'); + assert_true(str_contains($early_body, 'Change the already-applied resolution'), 'out-of-band branch manager confirms before replacing an applied resolution'); + assert_true(!str_contains($early_body, 'Apply reviewed choices'), 'out-of-band branch manager does not expose a reviewed/unreviewed workflow action'); + assert_true(!str_contains($early_body, 'function applyReviewedConflicts'), 'out-of-band branch manager does not ship unused reviewed-resolution workflow JavaScript'); + assert_true(!str_contains($early_body, 'Apply all reviewed conflict choices'), 'out-of-band branch manager does not expose bulk reviewed-choice workflow copy'); + assert_true(str_contains($early_body, 'function renderCrashRecovery'), 'out-of-band branch manager renders pending crash recovery as a blocking state'); + assert_true(str_contains($early_body, 'fp-crash-recovery'), 'out-of-band branch manager styles crash recovery separately from ordinary conflicts'); + assert_true(str_contains($early_body, 'function restoreCrashRecovery'), 'out-of-band branch manager can invoke crash recovery restoration'); + assert_true(str_contains($early_body, 'Restore crash recovery artifacts'), 'out-of-band branch manager confirms before restoring crash recovery artifacts'); + assert_true(str_contains($early_body, 'function conflictPluginMeta'), 'out-of-band branch manager renders plugin and theme conflict metadata'); + assert_true(str_contains($early_body, 'function conflictPluginGuidance'), 'out-of-band branch manager renders plugin and theme conflict guidance'); + assert_true(str_contains($early_body, 'record.plugin_suggested_action'), 'out-of-band branch manager exposes validator suggested actions'); + assert_true(str_contains($early_body, 'record.semantic_scope'), 'out-of-band branch manager exposes validator semantic scope'); + assert_true(str_contains($early_body, 'function pluginDriverFor'), 'out-of-band branch manager matches plugin conflicts to approved drivers'); + assert_true(str_contains($early_body, 'Run plugin driver'), 'out-of-band branch manager exposes approved plugin driver actions'); + assert_true(str_contains($early_body, 'No approved automation for this'), 'out-of-band branch manager labels plugin and theme conflicts without configured drivers clearly'); + assert_true(str_contains($early_body, 'ForkPress has no trusted merge driver approved'), 'out-of-band branch manager explains why plugin conflicts without configured drivers need manual inspection'); + assert_true(str_contains($early_body, 'FORKPRESS_PLUGIN_MERGE_DRIVERS'), 'out-of-band branch manager explains how trusted plugin drivers are configured'); + assert_true(!str_contains($early_body, 'function pluginDriverExplanationPanel'), 'out-of-band branch manager does not duplicate missing-driver explanation in a separate panel'); + assert_true(str_contains($early_body, 'function runPluginDriver'), 'out-of-band branch manager can run approved plugin drivers from conflict review'); + assert_true(str_contains($early_body, 'Run the approved plugin driver'), 'out-of-band branch manager confirms before running plugin driver automation'); + assert_true(str_contains($early_body, 'Fork from here'), 'out-of-band branch manager exposes branch creation as a direct contextual action'); + assert_true(str_contains($early_body, 'Merge this revision'), 'out-of-band branch manager exposes branch merging as a direct contextual action'); + assert_true(str_contains($early_body, 'Fork a branch from the selected context'), 'out-of-band branch manager labels the fork action accessibly'); + assert_true(str_contains($early_body, 'Merge the selected revision branch'), 'out-of-band branch manager labels the merge action accessibly'); + assert_true(str_contains($early_body, 'function configureForkAction'), 'out-of-band branch manager points fork actions at the selected branch context'); + assert_true(str_contains($early_body, 'function configureMergeAction'), 'out-of-band branch manager points merge actions at the selected revision context'); + assert_true(str_contains($early_body, 'function closeActionDetails'), 'out-of-band branch manager keeps one branch action popover open at a time'); + assert_true(str_contains($early_body, 'summary::-webkit-details-marker'), 'out-of-band branch manager suppresses default disclosure triangles on branch action buttons'); + assert_true(!str_contains($early_body, 'Flag follow-up'), 'out-of-band branch manager does not expose follow-up workflow actions'); + assert_true(!str_contains($early_body, 'Mark reviewed'), 'out-of-band branch manager does not expose mark-reviewed workflow actions'); + assert_true(str_contains($early_body, 'renderConflicts(payload);'), 'out-of-band branch manager rerenders conflict navigation after selecting a different check'); + assert_true(str_contains($early_body, 'body.fp-reviewing .fp-conflict-workbench-body'), 'out-of-band branch manager bounds conflict review panes inside the viewport'); + assert_true(str_contains($early_body, '.fp-conflict-scroll'), 'out-of-band branch manager locks long card content to an internal scroll region'); + assert_true(str_contains($early_body, 'overscroll-behavior: contain'), 'out-of-band branch manager keeps conflict-pane scrolling contained'); + assert_true(!file_exists($started), 'out-of-band branch manager did not execute branch PHP'); + + flock($lock, LOCK_UN); + fclose($lock); + stream_set_blocking($pipes[1], true); + + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $status = proc_close($process); + + assert_same($status, 0, 'out-of-band branch manager exits cleanly'); + assert_same($stdout, '', 'out-of-band branch manager output was already consumed'); + assert_same($stderr, '', 'out-of-band branch manager produced no stderr'); + } +} + +@unlink($entered); +@unlink($started); +$descriptor = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], +]; +$process = proc_open([PHP_BINARY, $child, $branches, $cow, $router, '/wp-admin/admin-post.php?action=forkpress_branch_create&branch=feature&from=main', $entered], $descriptor, $pipes); +assert_true(is_resource($process), 'spawned router branch create action request process'); +if (is_resource($process)) { + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $status = proc_close($process); + $payload = json_decode($stdout, true); + assert_same($status, 0, 'router branch create action exits cleanly'); + assert_same($stderr, '', 'router branch create action produced no stderr'); + assert_true(is_array($payload), 'router branch create action returns JSON'); + assert_same($payload['success'] ?? null, true, 'router branch create action reports success'); + assert_same($payload['url'] ?? null, 'http://feature.wp.localhost/wp-admin/', 'router branch create action returns the new branch admin URL'); + assert_same($payload['branches'][0]['name'] ?? null, 'feature', 'router branch create action marks new branch current'); + assert_same($payload['branches'][0]['url'] ?? null, 'http://feature.wp.localhost/wp-admin/', 'router branch create action returns a branch-specific feature URL'); + assert_same($payload['branches'][1]['url'] ?? null, 'http://wp.localhost/wp-admin/', 'router branch create action returns a distinct main URL'); + assert_true(!file_exists($started), 'router branch create action did not fall through to WordPress'); +} + +if (class_exists('SQLite3')) { + @mkdir($cow . '/merge', 0777, true); + $metadata = new SQLite3($cow . '/merge/metadata.sqlite'); + $metadata->exec('CREATE TABLE merge_runs (id INTEGER PRIMARY KEY AUTOINCREMENT, source_branch TEXT NOT NULL, target_branch TEXT NOT NULL, base_ref TEXT, started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, finished_at TEXT, status TEXT NOT NULL, policy TEXT NOT NULL, source_db TEXT NOT NULL, target_db TEXT NOT NULL, base_db TEXT NOT NULL, source_root TEXT NOT NULL DEFAULT "", target_root TEXT NOT NULL DEFAULT "", target_before_db TEXT NOT NULL DEFAULT "", target_before_root TEXT NOT NULL DEFAULT "", failure_reason TEXT)'); + $metadata->exec('CREATE TABLE merge_conflicts (id INTEGER PRIMARY KEY AUTOINCREMENT, run_id INTEGER NOT NULL, table_name TEXT NOT NULL, conflict_type TEXT NOT NULL)'); + $metadata->exec('CREATE TABLE merge_decisions (id INTEGER PRIMARY KEY AUTOINCREMENT, run_id INTEGER NOT NULL)'); + $metadata->exec("INSERT INTO merge_runs (source_branch, target_branch, base_ref, started_at, finished_at, status, policy, source_db, target_db, base_db) VALUES ('feature-a', 'main', 'test', '2026-05-20 12:00:00', '2026-05-20 12:00:01', 'completed', 'target-wins', '', '', '')"); + $metadata->close(); + + $process = proc_open( + [PHP_BINARY, $child, $branches, $cow, $router, '/wp-admin/admin-post.php?action=forkpress_branch_tree&limit=50', $entered], + $descriptor, + $pipes, + null, + ['FORKPRESS_BIN' => '', 'FORKPRESS_WORK_DIR' => ''] + ); + assert_true(is_resource($process), 'spawned metadata fallback branch tree request process'); + if (is_resource($process)) { + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $status = proc_close($process); + $payload = json_decode($stdout, true); + assert_same($status, 0, 'metadata fallback branch tree exits cleanly'); + assert_same($stderr, '', 'metadata fallback branch tree produced no stderr'); + assert_true(is_array($payload), 'metadata fallback branch tree returns JSON'); + assert_same($payload['success'] ?? null, true, 'metadata fallback branch tree reports success'); + assert_same($payload['records'][0]['source_branch'] ?? null, 'feature-a', 'metadata fallback branch tree returns merge runs'); + $branch_names = array_map(static fn($branch) => $branch['name'] ?? '', $payload['branches'] ?? []); + assert_true(in_array('feature-a', $branch_names, true), 'metadata fallback branch tree returns branches discovered from merge metadata'); + } +} + rm_tree($tmp); echo "\n=== COW router lock tests: $pass passed, $fail failed ===\n"; diff --git a/wp-plugin/forkpress-wp.php b/wp-plugin/forkpress-wp.php index a85f3478..ab4d9566 100644 --- a/wp-plugin/forkpress-wp.php +++ b/wp-plugin/forkpress-wp.php @@ -191,8 +191,10 @@ function forkpress_db_path(): ?string { return null; } -function forkpress_cow_sqlite_identifier(string $name): string { - return '"' . str_replace('"', '""', $name) . '"'; +if (!function_exists('forkpress_cow_sqlite_identifier')) { + function forkpress_cow_sqlite_identifier(string $name): string { + return '"' . str_replace('"', '""', $name) . '"'; + } } function forkpress_cow_sqlite_pdo(): ?PDO { @@ -437,12 +439,38 @@ function forkpress_root_host(): string { return is_string($root_host) && $root_host !== '' ? $root_host : 'wp.localhost'; } +function forkpress_request_scheme(): string { + $forwarded = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''; + if (is_string($forwarded) && $forwarded !== '') { + $scheme = strtolower(trim(explode(',', $forwarded)[0])); + if (in_array($scheme, ['http', 'https'], true)) { + return $scheme; + } + } + $forwarded_ssl = strtolower((string)($_SERVER['HTTP_X_FORWARDED_SSL'] ?? '')); + if ($forwarded_ssl === 'on' || $forwarded_ssl === '1') { + return 'https'; + } + if (function_exists('is_ssl') && is_ssl()) { + return 'https'; + } + $https = strtolower((string)($_SERVER['HTTPS'] ?? '')); + if ($https !== '' && $https !== 'off') { + return 'https'; + } + $request_scheme = strtolower((string)($_SERVER['REQUEST_SCHEME'] ?? '')); + if (in_array($request_scheme, ['http', 'https'], true)) { + return $request_scheme; + } + return 'http'; +} + function forkpress_branch_url(string $branch, ?string $uri = null): string { $root_host = forkpress_root_host(); $current_host = $_SERVER['HTTP_HOST'] ?? ''; $port = preg_match('/:(\d+)$/', $current_host, $m) ? ':' . $m[1] : ''; $host = $branch === 'main' ? $root_host : $branch . '.' . $root_host; - $scheme = is_ssl() ? 'https' : 'http'; + $scheme = forkpress_request_scheme(); $uri = $uri ?? ($_SERVER['REQUEST_URI'] ?? '/wp-admin/'); if (!is_string($uri) || $uri === '') { $uri = '/wp-admin/'; @@ -451,6 +479,10 @@ function forkpress_branch_url(string $branch, ?string $uri = null): string { return $scheme . '://' . $host . $port . $uri; } +function forkpress_branch_manager_url(?string $branch = null): string { + return forkpress_branch_url($branch ?: (forkpress_current_branch() ?: 'main'), '/_forkpress/branches'); +} + function forkpress_current_preview_origin(): ?string { $host = $_SERVER['HTTP_HOST'] ?? ''; if (!is_string($host) || $host === '') { @@ -924,15 +956,25 @@ function forkpress_branch_action_url(): string { } function forkpress_branch_admin_page_url(): string { - return function_exists('admin_url') ? admin_url('admin.php?page=forkpress-branches') : '/wp-admin/admin.php?page=forkpress-branches'; -} - -function forkpress_branch_switcher_data(string $current): array { - $branches = array_values(array_unique(forkpress_local_branches($current))); + return forkpress_branch_manager_url(); +} + +function forkpress_branch_switcher_data(string $current, ?string $uri = null, array $extra_branches = []): array { + $branches = array_values(array_unique(array_merge(forkpress_local_branches($current), $extra_branches))); + usort($branches, function (string $a, string $b) use ($current): int { + if ($a === $current) return -1; + if ($b === $current) return 1; + if ($a === 'main') return -1; + if ($b === 'main') return 1; + return strnatcasecmp($a, $b); + }); return array_map(function (string $branch) use ($current): array { return [ 'name' => $branch, - 'url' => forkpress_branch_url($branch), + 'url' => forkpress_branch_url($branch, '/wp-admin/'), + 'siteUrl' => forkpress_branch_url($branch, '/'), + 'adminUrl'=> forkpress_branch_url($branch, '/wp-admin/'), + 'managerUrl' => forkpress_branch_manager_url($branch), 'current' => $branch === $current, ]; }, $branches); @@ -1279,8 +1321,81 @@ function forkpress_branch_history_summary(array $report, int $limit): array { ]; } +function forkpress_branch_run_conflict_summaries(array $report, array $records): array { + $metadata_db = (string)($report['metadata_db'] ?? ''); + if ($metadata_db === '' || !is_file($metadata_db) || !class_exists('SQLite3')) { + return []; + } + $run_ids = []; + foreach ($records as $record) { + if (!is_array($record)) { + continue; + } + $id = (int)($record['id'] ?? 0); + if ($id > 0 && (int)($record['conflict_count'] ?? 0) > 0) { + $run_ids[$id] = true; + } + } + if ($run_ids === []) { + return []; + } + + try { + $db = new SQLite3($metadata_db, SQLITE3_OPEN_READONLY); + $ids = implode(',', array_keys($run_ids)); + $rows = $db->query( + "SELECT c.run_id, COUNT(*) AS total, " . + "SUM(CASE WHEN COALESCE((SELECT ce.lifecycle_state FROM merge_conflict_events ce WHERE ce.conflict_id = c.id ORDER BY ce.id DESC LIMIT 1), '') = 'resolved' " . + "OR COALESCE((SELECT mr.applied FROM merge_resolutions mr WHERE mr.conflict_id = c.id ORDER BY mr.id DESC LIMIT 1), 0) = 1 " . + "THEN 1 ELSE 0 END) AS resolved " . + "FROM merge_conflicts c WHERE c.run_id IN ($ids) GROUP BY c.run_id" + ); + if (!$rows instanceof SQLite3Result) { + return []; + } + $summaries = []; + while ($row = $rows->fetchArray(SQLITE3_ASSOC)) { + $run = (int)($row['run_id'] ?? 0); + $total = max(0, (int)($row['total'] ?? 0)); + $resolved = max(0, min($total, (int)($row['resolved'] ?? 0))); + $summaries[$run] = [ + 'total' => $total, + 'resolved' => $resolved, + 'unresolved' => max(0, $total - $resolved), + ]; + } + $rows->finalize(); + $db->close(); + return $summaries; + } catch (Throwable $e) { + return []; + } +} + function forkpress_branch_tree_summary(array $report, int $limit): array { $records = is_array($report['runs'] ?? null) ? array_values($report['runs']) : []; + $summaries = forkpress_branch_run_conflict_summaries($report, $records); + foreach ($records as &$record) { + if (!is_array($record)) { + continue; + } + $conflicts = (int)($record['conflict_count'] ?? 0); + if ($conflicts <= 0 || isset($record['conflictSummary']) || isset($record['conflict_summary'])) { + continue; + } + $id = (int)($record['id'] ?? 0); + if (isset($summaries[$id])) { + $record['conflictSummary'] = $summaries[$id]; + } else { + $record['conflictSummary'] = [ + 'total' => $conflicts, + 'resolved' => 0, + 'unresolved' => $conflicts, + 'estimated' => true, + ]; + } + } + unset($record); return [ 'records' => $records, 'recordCount' => count($records), @@ -1298,7 +1413,7 @@ function forkpress_branch_revalidate_merge_run(int $run): array { $result = json_decode($output, true); if (!is_array($result)) { - return [1, 'ForkPress returned invalid revalidation JSON.', null]; + return [1, 'ForkPress returned invalid conflict change-check JSON.', null]; } return [0, $output, $result]; @@ -1360,7 +1475,7 @@ function forkpress_handle_branch_create(): void { forkpress_branch_url($branch, '/wp-admin/'), 'notice', 'Created branch ' . $branch . '.', - ['branches' => forkpress_branch_switcher_data($current)] + ['branches' => forkpress_branch_switcher_data($branch, '/wp-admin/', [$branch, 'main'])] ); } add_action('admin_post_forkpress_branch_create', 'forkpress_handle_branch_create'); @@ -1400,7 +1515,7 @@ function forkpress_handle_branch_merge(): void { 'warning', $message, [ - 'branches' => forkpress_branch_switcher_data($current), + 'branches' => forkpress_branch_switcher_data($target, '/wp-admin/'), 'mergeStatus' => $summary['status'], 'conflicts' => $conflicts, 'run' => $run, @@ -1413,7 +1528,7 @@ function forkpress_handle_branch_merge(): void { forkpress_branch_url($target, '/wp-admin/'), 'notice', 'Merged ' . $source . ' into ' . $target . '.', - ['branches' => forkpress_branch_switcher_data($current)] + ['branches' => forkpress_branch_switcher_data($target, '/wp-admin/')] ); } add_action('admin_post_forkpress_branch_merge', 'forkpress_handle_branch_merge'); @@ -1594,7 +1709,7 @@ function forkpress_handle_branch_restore_crash(): void { function forkpress_handle_branch_revalidate_conflicts(): void { if (!forkpress_branch_can_manage()) { - forkpress_branch_finish_action(forkpress_branch_url(forkpress_current_branch() ?: 'main', '/wp-admin/'), 'error', 'You cannot revalidate ForkPress merge conflicts from this site.'); + forkpress_branch_finish_action(forkpress_branch_url(forkpress_current_branch() ?: 'main', '/wp-admin/'), 'error', 'You cannot check ForkPress merge conflicts for changes from this site.'); } if (function_exists('check_admin_referer')) { check_admin_referer('forkpress_branch_revalidate_conflicts'); @@ -1603,18 +1718,18 @@ function forkpress_handle_branch_revalidate_conflicts(): void { $current = forkpress_current_branch() ?: 'main'; $run = forkpress_branch_post_int('run'); if ($run === null) { - forkpress_branch_finish_action(forkpress_branch_url($current, '/wp-admin/'), 'error', 'Choose a merge run to revalidate.'); + forkpress_branch_finish_action(forkpress_branch_url($current, '/wp-admin/'), 'error', 'Choose a merge run to check for changes.'); } [$code, $output, $result] = forkpress_branch_revalidate_merge_run($run); if ($code !== 0) { - forkpress_branch_finish_action(forkpress_branch_url($current, '/wp-admin/'), 'error', $output ?: 'ForkPress could not revalidate merge conflicts.'); + forkpress_branch_finish_action(forkpress_branch_url($current, '/wp-admin/'), 'error', $output ?: 'ForkPress could not check merge conflicts for changes.'); } $checked = max(0, (int)($result['checked'] ?? 0)); $stale = max(0, (int)($result['stale'] ?? 0)); $carried = max(0, (int)($result['carried'] ?? 0)); - $message = 'Revalidated merge run ' . $run . ': checked ' . $checked . ', stale ' . $stale . ', carried ' . $carried . '.'; + $message = 'Checked merge run ' . $run . ' for changes: ' . $checked . ' checked, ' . $stale . ' changed, ' . $carried . ' unchanged.'; forkpress_branch_finish_action( forkpress_branch_url($current, '/wp-admin/'), 'warning', @@ -1624,6 +1739,7 @@ function forkpress_handle_branch_revalidate_conflicts(): void { 'checked' => $checked, 'stale' => $stale, 'carried' => $carried, + 'changeCheck' => $result, 'revalidation' => $result, 'auditCommand' => 'forkpress branch merge-audit --revalidate --run ' . $run . ' --reviewer wordpress-ui --format json', ] @@ -1650,6 +1766,10 @@ function forkpress_handle_branch_review_conflict(): void { forkpress_branch_finish_action(forkpress_branch_url($current, '/wp-admin/'), 'error', 'Choose pending, needs-action, or reviewed for the conflict review status.'); } + $review_note = forkpress_branch_post_value('note'); + if (strlen($review_note) > 2000) { + forkpress_branch_finish_action(forkpress_branch_url($current, '/wp-admin/'), 'error', 'Keep conflict review notes under 2000 characters.'); + } $notes = [ 'pending' => 'Marked pending from the WordPress branch switcher.', 'needs-action' => 'Marked needs-action from the WordPress branch switcher.', @@ -1662,7 +1782,7 @@ function forkpress_handle_branch_review_conflict(): void { '--status', $status, '--note', - $notes[$status], + $review_note !== '' ? $review_note : $notes[$status], '--reviewer', 'wordpress-ui', ]); @@ -1699,23 +1819,34 @@ function forkpress_handle_branch_resolve_conflict(): void { } $apply_reviewed = forkpress_branch_post_value('applyReviewed') === '1'; + $replace_applied = forkpress_branch_post_value('replaceApplied') === '1'; $after_revalidate = forkpress_branch_post_value('afterRevalidate') === '1'; $choice = forkpress_branch_post_value('choice'); if ($apply_reviewed && $choice !== '') { forkpress_branch_finish_action(forkpress_branch_url($current, '/wp-admin/'), 'error', 'Apply reviewed cannot be combined with a new source or target choice.'); } + if ($apply_reviewed && $replace_applied) { + forkpress_branch_finish_action(forkpress_branch_url($current, '/wp-admin/'), 'error', 'Changing an applied resolution requires a new source or target choice.'); + } if ($apply_reviewed && $after_revalidate) { - forkpress_branch_finish_action(forkpress_branch_url($current, '/wp-admin/'), 'error', 'After revalidate requires a source or target choice.'); + forkpress_branch_finish_action(forkpress_branch_url($current, '/wp-admin/'), 'error', 'Checking again requires a source or target choice.'); + } + if ($replace_applied && $after_revalidate) { + forkpress_branch_finish_action(forkpress_branch_url($current, '/wp-admin/'), 'error', 'Changing an applied resolution cannot be combined with checking again.'); } if (!$apply_reviewed && !in_array($choice, ['source', 'target'], true)) { forkpress_branch_finish_action(forkpress_branch_url($current, '/wp-admin/'), 'error', 'Choose source or target for the conflict resolution.'); } + $review_note = forkpress_branch_post_value('note'); + if (strlen($review_note) > 2000) { + forkpress_branch_finish_action(forkpress_branch_url($current, '/wp-admin/'), 'error', 'Keep conflict resolution notes under 2000 characters.'); + } $run = forkpress_branch_post_int('run'); if ($apply_reviewed && $run !== null) { [$code, $output, $revalidation] = forkpress_branch_revalidate_merge_run($run); if ($code !== 0) { - forkpress_branch_finish_action(forkpress_branch_url($current, '/wp-admin/'), 'error', $output ?: 'ForkPress could not revalidate merge conflicts before applying the reviewed choice.'); + forkpress_branch_finish_action(forkpress_branch_url($current, '/wp-admin/'), 'error', $output ?: 'ForkPress could not check merge conflicts for changes before applying the reviewed choice.'); } if ($revalidation !== null && forkpress_branch_revalidation_needs_action_for_conflict($revalidation, $conflict)) { $checked = max(0, (int)($revalidation['checked'] ?? 0)); @@ -1724,13 +1855,14 @@ function forkpress_handle_branch_resolve_conflict(): void { forkpress_branch_finish_action( forkpress_branch_url($current, '/wp-admin/'), 'error', - 'Conflict #' . $conflict . ' changed since review. Revalidate and review it before applying the reviewed choice.', + 'Conflict #' . $conflict . ' changed since review. Check it again and review it before applying the reviewed choice.', [ 'run' => $run, 'conflict' => $conflict, 'checked' => $checked, 'stale' => $stale, 'carried' => $carried, + 'changeCheck' => $revalidation, 'revalidation' => $revalidation, 'auditCommand' => 'forkpress branch merge-audit --revalidate --run ' . $run . ' --reviewer wordpress-ui --format json', ] @@ -1758,10 +1890,13 @@ function forkpress_handle_branch_resolve_conflict(): void { if ($after_revalidate) { $resolve_args[] = '--after-revalidate'; } + if ($replace_applied) { + $resolve_args[] = '--replace-applied'; + } $note = $notes[$choice]; } $resolve_args[] = '--note'; - $resolve_args[] = $note; + $resolve_args[] = $review_note !== '' ? $review_note : $note; $resolve_args[] = '--reviewer'; $resolve_args[] = 'wordpress-ui'; [$code, $output] = forkpress_branch_run_cli($resolve_args); @@ -1772,12 +1907,13 @@ function forkpress_handle_branch_resolve_conflict(): void { forkpress_branch_finish_action( forkpress_branch_url($current, '/wp-admin/'), 'notice', - $apply_reviewed ? 'Applied reviewed choice for conflict #' . $conflict . '.' : 'Applied ' . $choice . ' for conflict #' . $conflict . '.', + $apply_reviewed ? 'Applied reviewed choice for conflict #' . $conflict . '.' : ($replace_applied ? 'Changed conflict #' . $conflict . ' to ' . $choice . '.' : 'Applied ' . $choice . ' for conflict #' . $conflict . '.'), [ 'run' => $run, 'conflict' => $conflict, 'resolutionChoice' => $apply_reviewed ? 'reviewed' : $choice, 'afterRevalidate' => $after_revalidate, + 'replaceApplied' => $replace_applied, ] ); } @@ -1948,6 +2084,18 @@ function forkpress_render_branch_admin_page(): void { } $current = forkpress_current_branch() ?: 'main'; + $manager_url = forkpress_branch_manager_url($current); + if (function_exists('wp_redirect') && !headers_sent()) { + wp_redirect($manager_url); + } + ?> +
+

ForkPress Branches

+

Open ForkPress branch manager

+
+