From 05fb942175cd2efc78096ec4b21c2a60a1a4e8b0 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 21:36:46 +0200 Subject: [PATCH 01/38] Add out-of-band branch graph manager --- runtime/cow/router.php | 756 ++++++++++++++++++++++++++++++++++++- tests/cow/branch_ui.php | 62 +-- tests/cow/router_lock.php | 90 ++++- wp-plugin/forkpress-wp.php | 49 ++- 4 files changed, 883 insertions(+), 74 deletions(-) diff --git a/runtime/cow/router.php b/runtime/cow/router.php index 657b2953..091199f0 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') { @@ -230,12 +230,16 @@ function forkpress_cow_branch_url(string $branch, string $uri = '/wp-admin/'): s return 'http://' . $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 { $method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')); 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 +250,7 @@ function forkpress_cow_branch_names(string $current_branch): array { } } } + $branches = array_merge($branches, $extra_branches); if (!$branches) { $branches[] = $current_branch; } @@ -261,14 +266,17 @@ 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 { @@ -516,6 +524,28 @@ 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_tree_summary(array $report, int $limit): array { + $records = is_array($report['runs'] ?? null) ? array_values($report['runs']) : []; + return [ + 'records' => $records, + 'recordCount' => count($records), + 'limit' => $limit, + 'treeCommand' => 'forkpress branch tree --limit ' . $limit . ' --format json', + 'audit' => $report, + ]; +} + function forkpress_cow_branch_conflict_audit_summary(array $report, int $run, array $filters = []): array { $records = is_array($report['conflicts'] ?? null) ? array_values($report['conflicts']) : []; $total = count($records); @@ -528,11 +558,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', @@ -592,7 +663,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 +698,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,7 +712,67 @@ 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_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; + } + + [$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 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 history JSON.'); + return true; + } + + $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_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(['tree', '--limit', (string)$limit, '--format', 'json']); + if ($code !== 0) { + forkpress_cow_branch_finish_json(400, $current_url, false, $output ?: 'ForkPress could not inspect the branch tree.'); + return true; + } + $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; + } + + $summary = forkpress_cow_branch_tree_summary($report, $limit); + $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; } @@ -1030,6 +1161,613 @@ 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

+
+
+
+ + +
+
+
+ +
+
+ +
+
+ + + +HTML; +} + +function forkpress_cow_handle_branch_manager(string $path, string $current_branch): bool { + if ($path !== '/_forkpress/branches') { + return false; + } + http_response_code(200); + header('Content-Type: text/html; charset=UTF-8'); + echo str_replace('__STATE__', forkpress_cow_json_encode([ + 'currentBranch' => $current_branch, + 'branches' => forkpress_cow_branch_switcher_data($current_branch, '/wp-admin/'), + 'actionUrl' => forkpress_cow_branch_url($current_branch, '/_forkpress/action'), + 'rootUrl' => forkpress_cow_branch_url('main', '/_forkpress/branches'), + ]), forkpress_cow_branch_manager_html($current_branch)); + return true; +} + +if (forkpress_cow_handle_branch_manager($path, $branch)) { + return true; +} + if (forkpress_cow_handle_admin_branch_action($path, $branch, $branches_dir)) { return true; } diff --git a/tests/cow/branch_ui.php b/tests/cow/branch_ui.php index e1a1054d..31e11171 100644 --- a/tests/cow/branch_ui.php +++ b/tests/cow/branch_ui.php @@ -314,6 +314,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), @@ -1148,7 +1152,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'); @@ -1224,60 +1229,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/router_lock.php b/tests/cow/router_lock.php index 051057bb..d86c5bd3 100644 --- a/tests/cow/router_lock.php +++ b/tests/cow/router_lock.php @@ -40,6 +40,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 +52,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'); + usleep(150000); + $early_body = stream_get_contents($pipes[1]); + 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, '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, 'Branch actions'), 'out-of-band branch manager keeps create and merge actions available'); + 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'); +} + 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..2863799c 100644 --- a/wp-plugin/forkpress-wp.php +++ b/wp-plugin/forkpress-wp.php @@ -451,6 +451,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 +928,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); @@ -1360,7 +1374,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 +1414,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 +1427,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'); @@ -1948,6 +1962,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

+
+ Date: Tue, 19 May 2026 22:18:31 +0200 Subject: [PATCH 02/38] Render branch manager as revision graph --- runtime/cow/router.php | 181 +++++++++++++++++++++++++++++--------- tests/cow/router_lock.php | 5 ++ 2 files changed, 145 insertions(+), 41 deletions(-) diff --git a/runtime/cow/router.php b/runtime/cow/router.php index 091199f0..6829162d 100644 --- a/runtime/cow/router.php +++ b/runtime/cow/router.php @@ -1258,13 +1258,24 @@ function forkpress_cow_branch_manager_html(string $current_branch): string { } .fp-lane-label { fill: #50575e; font-size: 12px; font-weight: 650; } .fp-lane-line { stroke: #dcdcde; stroke-width: 2; } + .fp-lane-stem { stroke: #c3c4c7; stroke-width: 3; } + .fp-lane-head { cursor: pointer; fill: #fff; stroke: #8c8f94; stroke-width: 2; } + .fp-row-line { stroke: #f0f0f1; stroke-width: 1; } + .fp-row-label { fill: #1d2327; font-size: 11px; font-weight: 700; } + .fp-row-status { fill: #646970; font-size: 10px; } .fp-edge { fill: none; stroke-width: 3; } .fp-edge.is-conflict { stroke: var(--conflict); stroke-dasharray: 7 5; } + .fp-edge.is-resolved { stroke: var(--ok); } .fp-node { cursor: pointer; stroke: #fff; stroke-width: 3; } .fp-node.is-current { stroke: #1d2327; stroke-width: 4; } .fp-node.is-conflict { fill: var(--conflict); } + .fp-node.is-resolved { fill: var(--ok); } + .fp-node.is-setup { fill: #f6f7f7; stroke: #8c8f94; } + .fp-node.is-failed { fill: var(--danger); } + .fp-merge-dot { fill: #fff; stroke-width: 3; } .fp-node-label { fill: #1d2327; font-size: 11px; font-weight: 700; pointer-events: none; } .fp-conflict-badge { fill: var(--danger); } + .fp-conflict-badge.is-resolved { fill: var(--ok); } .fp-badge-text { fill: #fff; font-size: 10px; font-weight: 700; pointer-events: none; } .fp-side { display: grid; @@ -1515,69 +1526,147 @@ function svg(tag, attrs) { Object.keys(attrs || {}).forEach(function (key) { el.setAttribute(key, attrs[key]); }); return el; } - function laneNames() { + function runNumber(run) { + var id = Number(run && run.id ? run.id : 0); + return Number.isFinite(id) ? id : 0; + } + function sortedRunEntries() { + return records.map(function (run, index) { + return { run: run, index: index }; + }).sort(function (a, b) { + var byId = runNumber(b.run) - runNumber(a.run); + if (byId !== 0) return byId; + return String(b.run.finished_at || b.run.started_at || '').localeCompare(String(a.run.finished_at || a.run.started_at || '')); + }); + } + function conflictSummary(run) { + var summary = run && (run.conflictSummary || run.conflict_summary || run._conflictSummary); + var total = Number(run && run.conflict_count || 0); + var resolved = Number(summary && summary.resolved || 0); + var unresolved = Number(summary && summary.unresolved || 0); + if (!summary && total > 0) unresolved = total; + return { total: total, resolved: resolved, unresolved: unresolved }; + } + function runVisualClass(run) { + var summary = conflictSummary(run); + var status = String(run.status || ''); + if (summary.total > 0 && summary.unresolved === 0 && (run._conflictSummary || run.conflictSummary || run.conflict_summary)) return ' is-resolved'; + if (summary.total > 0) return ' is-conflict'; + if (status === 'id_bands_allocated' || status === 'identity_captured') return ' is-setup'; + if (status.indexOf('failed') !== -1 || status.indexOf('rolled_back') !== -1) return ' is-failed'; + return ' is-clean'; + } + function laneNames(entries) { var map = {}; - state.branches.forEach(function (item) { map[item.name] = true; }); - records.forEach(function (run) { - if (run.source_branch) map[String(run.source_branch)] = true; - if (run.target_branch) map[String(run.target_branch)] = true; + var ordered = []; + function add(name) { + name = String(name || ''); + if (!name || map[name]) return; + map[name] = true; + ordered.push(name); + } + add('main'); + if (state.currentBranch !== 'main') add(state.currentBranch); + (entries || sortedRunEntries()).forEach(function (entry) { + var run = entry.run; + add(run.target_branch); + add(run.source_branch); }); - return Object.keys(map).sort(function (a, b) { - if (a === state.currentBranch) return -1; - if (b === state.currentBranch) return 1; + state.branches.forEach(function (item) { add(item.name); }); + return ordered.sort(function (a, b) { if (a === 'main') return -1; if (b === 'main') return 1; - return a.localeCompare(b, undefined, { numeric: true }); + if (a === state.currentBranch) return -1; + if (b === state.currentBranch) return 1; + return 0; }); } + function annotateConflictRuns() { + var conflictRuns = records.filter(function (run) { + return Number(run.conflict_count || 0) > 0 && run.id && !run._conflictSummary; + }); + if (!conflictRuns.length) return Promise.resolve(); + return Promise.all(conflictRuns.map(function (run) { + return post('forkpress_branch_conflicts', { run: String(run.id) }).then(function (payload) { + run._conflictSummary = payload.conflictSummary || payload.conflict_summary || null; + run._conflictRecords = Array.isArray(payload.records) ? payload.records : []; + }).catch(function () { + run._conflictSummary = { total: Number(run.conflict_count || 0), resolved: 0, unresolved: Number(run.conflict_count || 0) }; + }); + })).then(function () {}); + } function renderGraph() { - var lanes = laneNames(); - var width = Math.max(820, 230 + Math.max(records.length, 1) * 150); - var height = Math.max(460, 88 + lanes.length * 72); + var entries = sortedRunEntries(); + var lanes = laneNames(entries); + var left = 150; + var top = 82; + var laneGap = 132; + var rowGap = 58; + var width = Math.max(900, left + Math.max(lanes.length, 1) * laneGap + 72); + var height = Math.max(520, top + Math.max(entries.length, 1) * rowGap + 56); graph.setAttribute('viewBox', '0 0 ' + width + ' ' + height); graph.setAttribute('width', width); graph.setAttribute('height', height); graph.innerHTML = ''; - var y = {}; + var x = {}; lanes.forEach(function (name, index) { - y[name] = 56 + index * 72; + x[name] = left + index * laneGap; var color = colors[index % colors.length]; - graph.appendChild(svg('line', { x1: 118, x2: width - 34, y1: y[name], y2: y[name], class: 'fp-lane-line' })); - var label = svg('text', { x: 20, y: y[name] + 4, class: 'fp-lane-label' }); + graph.appendChild(svg('line', { x1: x[name], x2: x[name], y1: 46, y2: height - 34, class: 'fp-lane-stem' })); + var label = svg('text', { x: x[name], y: 26, class: 'fp-lane-label', 'text-anchor': 'middle' }); label.textContent = name; graph.appendChild(label); - var node = svg('circle', { cx: 108, cy: y[name], r: 9, fill: color, class: 'fp-node' + (name === state.currentBranch ? ' is-current' : ''), 'data-kind': 'branch', 'data-name': name }); - node.appendChild(svg('title', {})).textContent = 'Open ' + name; - graph.appendChild(node); + var head = svg('circle', { cx: x[name], cy: 46, r: 8, class: 'fp-lane-head', stroke: color, 'data-kind': 'branch', 'data-name': name }); + head.appendChild(svg('title', {})).textContent = 'Branch ' + name; + graph.appendChild(head); }); - records.forEach(function (run, index) { + entries.forEach(function (entry, rowIndex) { + var run = entry.run; var source = String(run.source_branch || '?'); - var target = String(run.target_branch || '?'); - var x = 220 + index * 150; - var sy = y[source] || y[state.currentBranch] || 56; - var ty = y[target] || y.main || sy; - var color = colors[(lanes.indexOf(source) + colors.length) % colors.length]; - var conflictCount = Number(run.conflict_count || 0); - var path = svg('path', { - d: 'M ' + (x - 70) + ' ' + sy + ' C ' + (x - 22) + ' ' + sy + ', ' + (x - 22) + ' ' + ty + ', ' + x + ' ' + ty, - class: 'fp-edge' + (conflictCount > 0 ? ' is-conflict' : ''), - stroke: conflictCount > 0 ? '#b35c00' : color + var target = String(run.target_branch || source || '?'); + var sx = x[source] !== undefined ? x[source] : (x[target] || left); + var tx = x[target] !== undefined ? x[target] : sx; + var y = top + rowIndex * rowGap; + var laneIndex = Math.max(0, lanes.indexOf(source)); + var color = colors[laneIndex % colors.length]; + var conflict = conflictSummary(run); + var visual = runVisualClass(run); + var isMerge = source !== target; + graph.appendChild(svg('line', { x1: 8, x2: width - 28, y1: y, y2: y, class: 'fp-row-line' })); + if (isMerge) { + var edge = svg('path', { + d: 'M ' + sx + ' ' + y + ' C ' + (sx + ((tx - sx) * 0.45)) + ' ' + y + ', ' + (sx + ((tx - sx) * 0.55)) + ' ' + y + ', ' + tx + ' ' + y, + class: 'fp-edge' + (visual.indexOf('is-conflict') !== -1 ? ' is-conflict' : '') + (visual.indexOf('is-resolved') !== -1 ? ' is-resolved' : ''), + stroke: visual.indexOf('is-conflict') !== -1 ? '#b35c00' : (visual.indexOf('is-resolved') !== -1 ? '#008a20' : color) + }); + graph.appendChild(edge); + graph.appendChild(svg('circle', { cx: sx, cy: y, r: 5, class: 'fp-merge-dot', stroke: color })); + } + var node = svg('circle', { + cx: tx, + cy: y, + r: visual.indexOf('is-setup') !== -1 ? 8 : 11, + fill: visual.indexOf('is-conflict') !== -1 ? '#b35c00' : (visual.indexOf('is-resolved') !== -1 ? '#008a20' : color), + class: 'fp-node' + visual, + 'data-kind': 'run', + 'data-index': entry.index }); - graph.appendChild(path); - var node = svg('circle', { cx: x, cy: ty, r: 11, fill: conflictCount > 0 ? '#b35c00' : color, class: 'fp-node' + (conflictCount > 0 ? ' is-conflict' : ''), 'data-kind': 'run', 'data-index': index }); - node.appendChild(svg('title', {})).textContent = '#' + String(run.id || '') + ' ' + source + ' -> ' + target; + node.appendChild(svg('title', {})).textContent = '#' + String(run.id || '') + ' ' + source + ' -> ' + target + ' / ' + String(run.status || ''); graph.appendChild(node); - var text = svg('text', { x: x + 16, y: ty + 4, class: 'fp-node-label' }); - text.textContent = '#' + String(run.id || index + 1); - graph.appendChild(text); - if (conflictCount > 0) { - graph.appendChild(svg('circle', { cx: x + 9, cy: ty - 13, r: 9, class: 'fp-conflict-badge' })); - var badge = svg('text', { x: x + 9, y: ty - 9, class: 'fp-badge-text', 'text-anchor': 'middle' }); - badge.textContent = String(conflictCount); + var rowLabel = svg('text', { x: 14, y: y - 5, class: 'fp-row-label' }); + rowLabel.textContent = '#' + String(run.id || rowIndex + 1) + ' ' + source + ' -> ' + target; + graph.appendChild(rowLabel); + var rowStatus = svg('text', { x: 14, y: y + 12, class: 'fp-row-status' }); + rowStatus.textContent = String(run.status || '') + (conflict.total > 0 ? ' / conflicts ' + conflict.unresolved + ' unresolved of ' + conflict.total : ''); + graph.appendChild(rowStatus); + if (conflict.total > 0) { + graph.appendChild(svg('circle', { cx: tx + 10, cy: y - 14, r: 9, class: 'fp-conflict-badge' + (conflict.unresolved === 0 && run._conflictSummary ? ' is-resolved' : '') })); + var badge = svg('text', { x: tx + 10, y: y - 10, class: 'fp-badge-text', 'text-anchor': 'middle' }); + badge.textContent = String(conflict.unresolved === 0 && run._conflictSummary ? conflict.total : conflict.unresolved); graph.appendChild(badge); } }); - summary.textContent = lanes.length + ' branches / ' + records.length + ' merge revisions'; + summary.textContent = lanes.length + ' branches / ' + entries.length + ' revision records / newest first'; } function setDetail(title, rows, actions, object) { detailTitle.textContent = title; @@ -1635,6 +1724,11 @@ function selectRun(run) { target: run.target_branch || '', status: run.status || '', conflicts: run.conflict_count || 0, + 'conflict state': (function () { + var summary = conflictSummary(run); + if (!summary.total) return 'none'; + return summary.unresolved + ' unresolved / ' + summary.resolved + ' resolved / ' + summary.total + ' total'; + }()), decisions: run.decision_count || 0, finished: run.finished_at || run.started_at || '' }, actions, run); @@ -1677,6 +1771,10 @@ function loadTree() { renderGraph(); clearStatus(); if (records.length) selectRun(records[0]); else selectBranch(state.currentBranch); + annotateConflictRuns().then(function () { + renderGraph(); + if (records.length) selectRun(records[0]); + }); }).catch(function (error) { records = []; renderGraph(); @@ -1690,6 +1788,7 @@ function loadHistory() { records = Array.isArray(payload.records) ? payload.records : []; renderGraph(); setStatus('ok', payload.message || 'Loaded history.'); + annotateConflictRuns().then(renderGraph); }).catch(function (error) { setStatus('error', error.message || 'Could not load history.'); }); } function loadConflicts(run) { diff --git a/tests/cow/router_lock.php b/tests/cow/router_lock.php index d86c5bd3..2bfa2d9a 100644 --- a/tests/cow/router_lock.php +++ b/tests/cow/router_lock.php @@ -241,6 +241,11 @@ function rm_tree(string $path): void { $early_body = stream_get_contents($pipes[1]); 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-lane-stem'), 'out-of-band branch manager renders branches as graph lanes'); + assert_true(str_contains($early_body, 'fp-row-label'), 'out-of-band branch manager renders revisions as graph 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, 'revision records / newest first'), 'out-of-band branch manager labels revision timeline direction'); + assert_true(str_contains($early_body, 'annotateConflictRuns'), 'out-of-band branch manager annotates conflict runs with review state'); 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, 'Branch actions'), 'out-of-band branch manager keeps create and merge actions available'); From 4710acada486de7c0d44b595d1337a431e5ba4fd Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 22:23:23 +0200 Subject: [PATCH 03/38] Show revision cross-sections in branch graph --- runtime/cow/router.php | 6 ++++++ tests/cow/router_lock.php | 1 + 2 files changed, 7 insertions(+) diff --git a/runtime/cow/router.php b/runtime/cow/router.php index 6829162d..a7275a06 100644 --- a/runtime/cow/router.php +++ b/runtime/cow/router.php @@ -1263,6 +1263,7 @@ function forkpress_cow_branch_manager_html(string $current_branch): string { .fp-row-line { stroke: #f0f0f1; stroke-width: 1; } .fp-row-label { fill: #1d2327; font-size: 11px; font-weight: 700; } .fp-row-status { fill: #646970; font-size: 10px; } + .fp-cross-point { fill: #fff; stroke: #c3c4c7; stroke-width: 1.5; } .fp-edge { fill: none; stroke-width: 3; } .fp-edge.is-conflict { stroke: var(--conflict); stroke-dasharray: 7 5; } .fp-edge.is-resolved { stroke: var(--ok); } @@ -1633,6 +1634,11 @@ function renderGraph() { var visual = runVisualClass(run); var isMerge = source !== target; graph.appendChild(svg('line', { x1: 8, x2: width - 28, y1: y, y2: y, class: 'fp-row-line' })); + lanes.forEach(function (lane) { + var point = svg('circle', { cx: x[lane], cy: y, r: 3.5, class: 'fp-cross-point' }); + point.appendChild(svg('title', {})).textContent = lane + ' at revision #' + String(run.id || rowIndex + 1); + graph.appendChild(point); + }); if (isMerge) { var edge = svg('path', { d: 'M ' + sx + ' ' + y + ' C ' + (sx + ((tx - sx) * 0.45)) + ' ' + y + ', ' + (sx + ((tx - sx) * 0.55)) + ' ' + y + ', ' + tx + ' ' + y, diff --git a/tests/cow/router_lock.php b/tests/cow/router_lock.php index 2bfa2d9a..5e6c1aba 100644 --- a/tests/cow/router_lock.php +++ b/tests/cow/router_lock.php @@ -243,6 +243,7 @@ function rm_tree(string $path): void { 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-lane-stem'), 'out-of-band branch manager renders branches as graph lanes'); assert_true(str_contains($early_body, 'fp-row-label'), 'out-of-band branch manager renders revisions as graph rows'); + assert_true(str_contains($early_body, 'fp-cross-point'), 'out-of-band branch manager renders lane cross-section points for each revision row'); 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, 'revision records / newest first'), 'out-of-band branch manager labels revision timeline direction'); assert_true(str_contains($early_body, 'annotateConflictRuns'), 'out-of-band branch manager annotates conflict runs with review state'); From 8009640ae9b6e0f18c273b54159ea5844c9b067c Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 22:25:42 +0200 Subject: [PATCH 04/38] Disable caching for branch manager --- runtime/cow/router.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/runtime/cow/router.php b/runtime/cow/router.php index a7275a06..2fb4d351 100644 --- a/runtime/cow/router.php +++ b/runtime/cow/router.php @@ -282,6 +282,8 @@ function forkpress_cow_branch_switcher_data(string $current_branch, string $uri 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', @@ -1860,6 +1862,8 @@ function forkpress_cow_handle_branch_manager(string $path, string $current_branc } 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/'), From 9993bdb62ad82d09edf7e8bf437524860be1186a Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 22:34:31 +0200 Subject: [PATCH 05/38] Render branch manager as git-style timeline --- runtime/cow/router.php | 152 +++++++++++++++++++++++++------------- tests/cow/router_lock.php | 8 +- 2 files changed, 106 insertions(+), 54 deletions(-) diff --git a/runtime/cow/router.php b/runtime/cow/router.php index 2fb4d351..6cfebe21 100644 --- a/runtime/cow/router.php +++ b/runtime/cow/router.php @@ -1258,25 +1258,24 @@ function forkpress_cow_branch_manager_html(string $current_branch): string { min-height: 440px; min-width: 760px; } - .fp-lane-label { fill: #50575e; font-size: 12px; font-weight: 650; } - .fp-lane-line { stroke: #dcdcde; stroke-width: 2; } - .fp-lane-stem { stroke: #c3c4c7; stroke-width: 3; } - .fp-lane-head { cursor: pointer; fill: #fff; stroke: #8c8f94; stroke-width: 2; } - .fp-row-line { stroke: #f0f0f1; stroke-width: 1; } - .fp-row-label { fill: #1d2327; font-size: 11px; font-weight: 700; } - .fp-row-status { fill: #646970; font-size: 10px; } - .fp-cross-point { fill: #fff; stroke: #c3c4c7; stroke-width: 1.5; } - .fp-edge { fill: none; stroke-width: 3; } - .fp-edge.is-conflict { stroke: var(--conflict); stroke-dasharray: 7 5; } - .fp-edge.is-resolved { stroke: var(--ok); } + .fp-row-hit { cursor: pointer; fill: transparent; } + .fp-row-hit:hover { fill: #f6f7f7; } + .fp-timeline-lane { fill: none; stroke-linecap: round; stroke-width: 3.5; } + .fp-timeline-merge { fill: none; stroke-linecap: round; stroke-width: 3; } + .fp-timeline-merge.is-conflict { stroke: var(--conflict); stroke-dasharray: 7 5; } + .fp-timeline-merge.is-resolved { stroke: var(--ok); } + .fp-row-divider { stroke: #f0f0f1; stroke-width: 1; } + .fp-row-title { fill: #1d2327; font-size: 12px; font-weight: 700; pointer-events: none; } + .fp-row-meta { fill: #646970; font-size: 11px; pointer-events: none; } + .fp-branch-pill { fill: #f6f7f7; stroke: #dcdcde; stroke-width: 1; } + .fp-branch-pill-text { fill: #50575e; font-size: 10px; font-weight: 650; pointer-events: none; } .fp-node { cursor: pointer; stroke: #fff; stroke-width: 3; } .fp-node.is-current { stroke: #1d2327; stroke-width: 4; } .fp-node.is-conflict { fill: var(--conflict); } .fp-node.is-resolved { fill: var(--ok); } .fp-node.is-setup { fill: #f6f7f7; stroke: #8c8f94; } .fp-node.is-failed { fill: var(--danger); } - .fp-merge-dot { fill: #fff; stroke-width: 3; } - .fp-node-label { fill: #1d2327; font-size: 11px; font-weight: 700; pointer-events: none; } + .fp-merge-dot { fill: #fff; stroke-width: 3; cursor: pointer; } .fp-conflict-badge { fill: var(--danger); } .fp-conflict-badge.is-resolved { fill: var(--ok); } .fp-badge-text { fill: #fff; font-size: 10px; font-weight: 700; pointer-events: none; } @@ -1559,6 +1558,41 @@ function runVisualClass(run) { if (status.indexOf('failed') !== -1 || status.indexOf('rolled_back') !== -1) return ' is-failed'; return ' is-clean'; } + function branchColor(name, lanes) { + var index = Math.max(0, lanes.indexOf(name)); + return colors[index % colors.length]; + } + function runLabel(run) { + var source = String(run.source_branch || '?'); + var target = String(run.target_branch || source || '?'); + var prefix = '#' + String(run.id || ''); + if (source === target) { + return prefix + ' ' + source; + } + return prefix + ' ' + source + ' -> ' + target; + } + function runMeta(run) { + var status = String(run.status || ''); + var parts = [status]; + var conflict = conflictSummary(run); + if (conflict.total > 0) { + parts.push(conflict.unresolved + ' unresolved / ' + conflict.total + ' conflicts'); + } else if (Number(run.decision_count || 0) > 0) { + parts.push(String(run.decision_count) + ' decisions'); + } + var when = run.finished_at || run.started_at || ''; + if (when) parts.push(when); + return parts.filter(Boolean).join(' · '); + } + function drawPill(text, x, y, color) { + var width = Math.max(46, Math.min(150, 14 + String(text).length * 6.3)); + var rect = svg('rect', { x: x, y: y - 12, width: width, height: 18, rx: 9, class: 'fp-branch-pill', stroke: color }); + var label = svg('text', { x: x + 8, y: y + 1, class: 'fp-branch-pill-text' }); + label.textContent = text.length > 20 ? text.slice(0, 18) + '...' : text; + graph.appendChild(rect); + graph.appendChild(label); + return width; + } function laneNames(entries) { var map = {}; var ordered = []; @@ -1601,72 +1635,90 @@ function annotateConflictRuns() { function renderGraph() { var entries = sortedRunEntries(); var lanes = laneNames(entries); - var left = 150; - var top = 82; - var laneGap = 132; - var rowGap = 58; - var width = Math.max(900, left + Math.max(lanes.length, 1) * laneGap + 72); - var height = Math.max(520, top + Math.max(entries.length, 1) * rowGap + 56); + var graphLeft = 58; + var laneGap = 24; + var graphWidth = Math.max(1, lanes.length) * laneGap; + var textX = graphLeft + graphWidth + 48; + var top = 44; + var rowGap = 56; + var rowHeight = 48; + var width = Math.max(1040, textX + 720); + var height = Math.max(520, top + Math.max(entries.length, 1) * rowGap + 34); graph.setAttribute('viewBox', '0 0 ' + width + ' ' + height); graph.setAttribute('width', width); graph.setAttribute('height', height); graph.innerHTML = ''; var x = {}; lanes.forEach(function (name, index) { - x[name] = left + index * laneGap; - var color = colors[index % colors.length]; - graph.appendChild(svg('line', { x1: x[name], x2: x[name], y1: 46, y2: height - 34, class: 'fp-lane-stem' })); - var label = svg('text', { x: x[name], y: 26, class: 'fp-lane-label', 'text-anchor': 'middle' }); - label.textContent = name; - graph.appendChild(label); - var head = svg('circle', { cx: x[name], cy: 46, r: 8, class: 'fp-lane-head', stroke: color, 'data-kind': 'branch', 'data-name': name }); - head.appendChild(svg('title', {})).textContent = 'Branch ' + name; - graph.appendChild(head); + x[name] = graphLeft + index * laneGap; + graph.appendChild(svg('path', { + d: 'M ' + x[name] + ' ' + (top - 24) + ' L ' + x[name] + ' ' + (height - 24), + class: 'fp-timeline-lane', + stroke: branchColor(name, lanes), + opacity: name === 'main' ? '.55' : '.38' + })); }); entries.forEach(function (entry, rowIndex) { var run = entry.run; var source = String(run.source_branch || '?'); var target = String(run.target_branch || source || '?'); - var sx = x[source] !== undefined ? x[source] : (x[target] || left); + var sx = x[source] !== undefined ? x[source] : (x[target] || graphLeft); var tx = x[target] !== undefined ? x[target] : sx; var y = top + rowIndex * rowGap; - var laneIndex = Math.max(0, lanes.indexOf(source)); - var color = colors[laneIndex % colors.length]; + var sourceColor = branchColor(source, lanes); + var targetColor = branchColor(target, lanes); var conflict = conflictSummary(run); var visual = runVisualClass(run); var isMerge = source !== target; - graph.appendChild(svg('line', { x1: 8, x2: width - 28, y1: y, y2: y, class: 'fp-row-line' })); - lanes.forEach(function (lane) { - var point = svg('circle', { cx: x[lane], cy: y, r: 3.5, class: 'fp-cross-point' }); - point.appendChild(svg('title', {})).textContent = lane + ' at revision #' + String(run.id || rowIndex + 1); - graph.appendChild(point); + var hit = svg('rect', { + x: 0, + y: y - rowHeight / 2, + width: width, + height: rowHeight, + class: 'fp-row-hit', + 'data-kind': 'run', + 'data-index': entry.index }); + graph.appendChild(hit); + graph.appendChild(svg('line', { x1: 0, x2: width - 20, y1: y + rowHeight / 2, y2: y + rowHeight / 2, class: 'fp-row-divider' })); if (isMerge) { - var edge = svg('path', { - d: 'M ' + sx + ' ' + y + ' C ' + (sx + ((tx - sx) * 0.45)) + ' ' + y + ', ' + (sx + ((tx - sx) * 0.55)) + ' ' + y + ', ' + tx + ' ' + y, - class: 'fp-edge' + (visual.indexOf('is-conflict') !== -1 ? ' is-conflict' : '') + (visual.indexOf('is-resolved') !== -1 ? ' is-resolved' : ''), - stroke: visual.indexOf('is-conflict') !== -1 ? '#b35c00' : (visual.indexOf('is-resolved') !== -1 ? '#008a20' : color) + var bend = Math.max(16, Math.abs(tx - sx) / 2); + var mergePath = svg('path', { + d: 'M ' + sx + ' ' + (y - 18) + ' C ' + sx + ' ' + (y - 4) + ', ' + (tx + (sx < tx ? -bend : bend)) + ' ' + (y - 4) + ', ' + tx + ' ' + y, + class: 'fp-timeline-merge' + (visual.indexOf('is-conflict') !== -1 ? ' is-conflict' : '') + (visual.indexOf('is-resolved') !== -1 ? ' is-resolved' : ''), + stroke: visual.indexOf('is-conflict') !== -1 ? '#b35c00' : (visual.indexOf('is-resolved') !== -1 ? '#008a20' : sourceColor), + 'data-kind': 'run', + 'data-index': entry.index }); - graph.appendChild(edge); - graph.appendChild(svg('circle', { cx: sx, cy: y, r: 5, class: 'fp-merge-dot', stroke: color })); + graph.appendChild(mergePath); + graph.appendChild(svg('circle', { cx: sx, cy: y - 18, r: 4.5, class: 'fp-merge-dot', stroke: sourceColor, 'data-kind': 'run', 'data-index': entry.index })); } var node = svg('circle', { cx: tx, cy: y, r: visual.indexOf('is-setup') !== -1 ? 8 : 11, - fill: visual.indexOf('is-conflict') !== -1 ? '#b35c00' : (visual.indexOf('is-resolved') !== -1 ? '#008a20' : color), + fill: visual.indexOf('is-conflict') !== -1 ? '#b35c00' : (visual.indexOf('is-resolved') !== -1 ? '#008a20' : targetColor), class: 'fp-node' + visual, 'data-kind': 'run', 'data-index': entry.index }); node.appendChild(svg('title', {})).textContent = '#' + String(run.id || '') + ' ' + source + ' -> ' + target + ' / ' + String(run.status || ''); graph.appendChild(node); - var rowLabel = svg('text', { x: 14, y: y - 5, class: 'fp-row-label' }); - rowLabel.textContent = '#' + String(run.id || rowIndex + 1) + ' ' + source + ' -> ' + target; - graph.appendChild(rowLabel); - var rowStatus = svg('text', { x: 14, y: y + 12, class: 'fp-row-status' }); - rowStatus.textContent = String(run.status || '') + (conflict.total > 0 ? ' / conflicts ' + conflict.unresolved + ' unresolved of ' + conflict.total : ''); - graph.appendChild(rowStatus); + var title = svg('text', { x: textX, y: y - 6, class: 'fp-row-title' }); + title.textContent = runLabel(run); + graph.appendChild(title); + var meta = svg('text', { x: textX, y: y + 12, class: 'fp-row-meta' }); + meta.textContent = runMeta(run); + graph.appendChild(meta); + var pillX = textX + 280; + pillX += drawPill(source, pillX, y - 7, sourceColor) + 8; + if (isMerge) { + var arrow = svg('text', { x: pillX, y: y - 6, class: 'fp-row-meta' }); + arrow.textContent = 'into'; + graph.appendChild(arrow); + pillX += 30; + drawPill(target, pillX, y - 7, targetColor); + } if (conflict.total > 0) { graph.appendChild(svg('circle', { cx: tx + 10, cy: y - 14, r: 9, class: 'fp-conflict-badge' + (conflict.unresolved === 0 && run._conflictSummary ? ' is-resolved' : '') })); var badge = svg('text', { x: tx + 10, y: y - 10, class: 'fp-badge-text', 'text-anchor': 'middle' }); @@ -1674,7 +1726,7 @@ class: 'fp-node' + visual, graph.appendChild(badge); } }); - summary.textContent = lanes.length + ' branches / ' + entries.length + ' revision records / newest first'; + summary.textContent = lanes.length + ' graph lanes / ' + entries.length + ' timeline revisions / newest first'; } function setDetail(title, rows, actions, object) { detailTitle.textContent = title; diff --git a/tests/cow/router_lock.php b/tests/cow/router_lock.php index 5e6c1aba..a9f65e30 100644 --- a/tests/cow/router_lock.php +++ b/tests/cow/router_lock.php @@ -241,11 +241,11 @@ function rm_tree(string $path): void { $early_body = stream_get_contents($pipes[1]); 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-lane-stem'), 'out-of-band branch manager renders branches as graph lanes'); - assert_true(str_contains($early_body, 'fp-row-label'), 'out-of-band branch manager renders revisions as graph rows'); - assert_true(str_contains($early_body, 'fp-cross-point'), 'out-of-band branch manager renders lane cross-section points for each revision row'); + 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-merge'), 'out-of-band branch manager renders merge curves between lanes'); + 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, 'revision records / newest first'), 'out-of-band branch manager labels revision timeline direction'); + assert_true(str_contains($early_body, 'timeline revisions / newest first'), 'out-of-band branch manager labels revision timeline direction'); assert_true(str_contains($early_body, 'annotateConflictRuns'), 'out-of-band branch manager annotates conflict runs with review state'); 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'); From dcaab3fc18aeebcc42c5968b8f421c5c39f25d79 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 19 May 2026 22:48:11 +0200 Subject: [PATCH 06/38] Refine branch timeline graph UI --- runtime/cow/router.php | 323 ++++++++++++++++++++++++++++++++----- tests/cow/branch_ui.php | 22 +++ tests/cow/router_lock.php | 9 ++ wp-plugin/forkpress-wp.php | 12 +- 4 files changed, 326 insertions(+), 40 deletions(-) diff --git a/runtime/cow/router.php b/runtime/cow/router.php index 6cfebe21..c50ae0f4 100644 --- a/runtime/cow/router.php +++ b/runtime/cow/router.php @@ -915,6 +915,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.', @@ -927,7 +932,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', ]); @@ -1004,6 +1009,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.', @@ -1027,7 +1037,7 @@ function forkpress_cow_handle_admin_branch_action(string $path, string $current_ $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); @@ -1260,13 +1270,17 @@ function forkpress_cow_branch_manager_html(string $current_branch): string { } .fp-row-hit { cursor: pointer; fill: transparent; } .fp-row-hit:hover { fill: #f6f7f7; } - .fp-timeline-lane { fill: none; stroke-linecap: round; stroke-width: 3.5; } - .fp-timeline-merge { fill: none; stroke-linecap: round; stroke-width: 3; } + .fp-timeline-lane { fill: none; stroke-linecap: round; stroke-width: 4; } + .fp-timeline-fork { fill: none; stroke-linecap: round; stroke-width: 4; } + .fp-timeline-merge { fill: none; stroke-linecap: round; stroke-width: 4; } .fp-timeline-merge.is-conflict { stroke: var(--conflict); stroke-dasharray: 7 5; } .fp-timeline-merge.is-resolved { stroke: var(--ok); } .fp-row-divider { stroke: #f0f0f1; stroke-width: 1; } .fp-row-title { fill: #1d2327; font-size: 12px; font-weight: 700; pointer-events: none; } .fp-row-meta { fill: #646970; font-size: 11px; pointer-events: none; } + .fp-lane-cap { fill: #fff; stroke-width: 2.5; } + .fp-lane-end { fill: #f6f7f7; stroke-width: 2; } + .fp-lane-label { fill: #50575e; font-size: 10px; font-weight: 700; pointer-events: none; } .fp-branch-pill { fill: #f6f7f7; stroke: #dcdcde; stroke-width: 1; } .fp-branch-pill-text { fill: #50575e; font-size: 10px; font-weight: 650; pointer-events: none; } .fp-node { cursor: pointer; stroke: #fff; stroke-width: 3; } @@ -1385,6 +1399,39 @@ function forkpress_cow_branch_manager_html(string $current_branch): string { } .fp-conflict-title { font-size: 13px; font-weight: 700; overflow-wrap: anywhere; } .fp-conflict-meta { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; } + .fp-conflict-grid { + display: grid; + gap: 8px; + grid-template-columns: 1fr 1fr; + } + .fp-conflict-value { + display: grid; + gap: 4px; + min-width: 0; + } + .fp-conflict-value label, + .fp-conflict-note label, + .fp-conflict-choice label { + color: var(--muted); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + } + textarea { + border: 1px solid #c3c4c7; + border-radius: 6px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + min-height: 72px; + padding: 7px 8px; + resize: vertical; + width: 100%; + } + textarea[readonly] { background: #f6f7f7; color: #1d2327; } + .fp-conflict-note, .fp-conflict-choice { + display: grid; + gap: 4px; + } pre { background: #f6f7f7; border: 1px solid var(--line); @@ -1399,6 +1446,7 @@ function forkpress_cow_branch_manager_html(string $current_branch): string { .fp-layout { grid-template-columns: 1fr; padding: 12px; } .fp-topbar { flex-wrap: wrap; padding: 10px 14px; } .fp-open-admin { margin-left: 0; } + .fp-conflict-grid { grid-template-columns: 1fr; } } @@ -1558,6 +1606,44 @@ function runVisualClass(run) { if (status.indexOf('failed') !== -1 || status.indexOf('rolled_back') !== -1) return ' is-failed'; return ' is-clean'; } + function isSetupRun(run) { + var status = String(run && run.status || ''); + var policy = String(run && run.policy || ''); + var base = String(run && run.base_ref || ''); + return status === 'id_bands_allocated' + || status === 'identity_captured' + || policy === 'autoincrement-id-band-allocation' + || policy === 'sidecar-row-identity-capture' + || base === 'autoincrement-id-band' + || base === 'identity-capture'; + } + function isBranchForkRun(run) { + var source = String(run && run.source_branch || ''); + var target = String(run && run.target_branch || ''); + var policy = String(run && run.policy || ''); + var base = String(run && run.base_ref || ''); + return source !== '' + && source === target + && source !== 'main' + && (policy === 'autoincrement-id-band-allocation' || base === 'autoincrement-id-band'); + } + function branchForkParents(entries) { + var parents = {}; + (entries || []).slice().reverse().forEach(function (entry) { + var run = entry.run || {}; + var source = String(run.source_branch || ''); + var target = String(run.target_branch || ''); + if (!source || !target || source === target) return; + if (!parents[source]) parents[source] = target; + if (!parents[target] && target !== 'main') parents[target] = source; + }); + (entries || []).forEach(function (entry) { + var run = entry.run || {}; + var branch = String(run.source_branch || ''); + if (isBranchForkRun(run) && !parents[branch]) parents[branch] = 'main'; + }); + return parents; + } function branchColor(name, lanes) { var index = Math.max(0, lanes.indexOf(name)); return colors[index % colors.length]; @@ -1593,6 +1679,25 @@ function drawPill(text, x, y, color) { graph.appendChild(label); return width; } + function laneActivity(entries, lanes) { + var activity = {}; + var forkParents = branchForkParents(entries); + function touch(name, rowIndex) { + name = String(name || ''); + if (!name || !activity[name]) return; + if (activity[name].first === null || rowIndex < activity[name].first) activity[name].first = rowIndex; + if (activity[name].last === null || rowIndex > activity[name].last) activity[name].last = rowIndex; + } + lanes.forEach(function (lane) { activity[lane] = { first: null, last: null }; }); + entries.forEach(function (entry, rowIndex) { + var run = entry.run || {}; + [run.source_branch, run.target_branch].forEach(function (name) { + touch(name, rowIndex); + }); + if (isBranchForkRun(run)) touch(forkParents[String(run.source_branch || '')] || 'main', rowIndex); + }); + return activity; + } function laneNames(entries) { var map = {}; var ordered = []; @@ -1635,28 +1740,48 @@ function annotateConflictRuns() { function renderGraph() { var entries = sortedRunEntries(); var lanes = laneNames(entries); - var graphLeft = 58; - var laneGap = 24; + var forkParents = branchForkParents(entries); + var graphLeft = 72; + var laneGap = 54; var graphWidth = Math.max(1, lanes.length) * laneGap; - var textX = graphLeft + graphWidth + 48; - var top = 44; - var rowGap = 56; - var rowHeight = 48; - var width = Math.max(1040, textX + 720); + var textX = graphLeft + graphWidth + 56; + var top = 58; + var rowGap = 64; + var rowHeight = 56; + var width = Math.max(1120, textX + 720); var height = Math.max(520, top + Math.max(entries.length, 1) * rowGap + 34); graph.setAttribute('viewBox', '0 0 ' + width + ' ' + height); graph.setAttribute('width', width); graph.setAttribute('height', height); graph.innerHTML = ''; var x = {}; + var activity = laneActivity(entries, lanes); lanes.forEach(function (name, index) { x[name] = graphLeft + index * laneGap; + }); + lanes.forEach(function (name) { + var span = activity[name]; + if (!span || span.first === null || span.last === null) return; + var color = branchColor(name, lanes); + var startY = top + span.first * rowGap - 18; + var endY = top + span.last * rowGap + 18; graph.appendChild(svg('path', { - d: 'M ' + x[name] + ' ' + (top - 24) + ' L ' + x[name] + ' ' + (height - 24), + d: 'M ' + x[name] + ' ' + startY + ' L ' + x[name] + ' ' + endY, class: 'fp-timeline-lane', - stroke: branchColor(name, lanes), - opacity: name === 'main' ? '.55' : '.38' + stroke: color, + opacity: name === 'main' ? '.72' : '.58' })); + var cap = svg('circle', { cx: x[name], cy: startY, r: 4, class: 'fp-lane-cap', stroke: color }); + cap.appendChild(svg('title', {})).textContent = name + ' latest visible revision'; + graph.appendChild(cap); + var end = svg('circle', { cx: x[name], cy: endY, r: 4, class: 'fp-lane-end', stroke: color }); + end.appendChild(svg('title', {})).textContent = name + ' oldest visible revision'; + graph.appendChild(end); + if (span.first < 6) { + var label = svg('text', { x: x[name] + 7, y: startY - 4, class: 'fp-lane-label' }); + label.textContent = name.length > 15 ? name.slice(0, 13) + '...' : name; + graph.appendChild(label); + } }); entries.forEach(function (entry, rowIndex) { var run = entry.run; @@ -1670,6 +1795,9 @@ class: 'fp-timeline-lane', var conflict = conflictSummary(run); var visual = runVisualClass(run); var isMerge = source !== target; + var isFork = isBranchForkRun(run); + var forkParent = forkParents[source] || 'main'; + var px = x[forkParent] !== undefined ? x[forkParent] : graphLeft; var hit = svg('rect', { x: 0, y: y - rowHeight / 2, @@ -1681,17 +1809,30 @@ class: 'fp-row-hit', }); graph.appendChild(hit); graph.appendChild(svg('line', { x1: 0, x2: width - 20, y1: y + rowHeight / 2, y2: y + rowHeight / 2, class: 'fp-row-divider' })); + if (isFork && px !== sx) { + var forkPath = svg('path', { + d: 'M ' + px + ' ' + y + ' C ' + px + ' ' + (y - 26) + ', ' + sx + ' ' + (y - 26) + ', ' + sx + ' ' + (y - 2), + class: 'fp-timeline-fork', + stroke: sourceColor, + opacity: '.92', + 'data-kind': 'run', + 'data-index': entry.index + }); + forkPath.appendChild(svg('title', {})).textContent = source + ' branched from ' + forkParent; + graph.appendChild(forkPath); + graph.appendChild(svg('circle', { cx: px, cy: y, r: 5, class: 'fp-merge-dot', stroke: branchColor(forkParent, lanes), 'data-kind': 'run', 'data-index': entry.index })); + } if (isMerge) { - var bend = Math.max(16, Math.abs(tx - sx) / 2); + var bend = Math.max(28, Math.abs(tx - sx) / 2); var mergePath = svg('path', { - d: 'M ' + sx + ' ' + (y - 18) + ' C ' + sx + ' ' + (y - 4) + ', ' + (tx + (sx < tx ? -bend : bend)) + ' ' + (y - 4) + ', ' + tx + ' ' + y, + d: 'M ' + sx + ' ' + (y - 26) + ' C ' + sx + ' ' + (y - 4) + ', ' + (tx + (sx < tx ? -bend : bend)) + ' ' + (y - 4) + ', ' + tx + ' ' + y, class: 'fp-timeline-merge' + (visual.indexOf('is-conflict') !== -1 ? ' is-conflict' : '') + (visual.indexOf('is-resolved') !== -1 ? ' is-resolved' : ''), stroke: visual.indexOf('is-conflict') !== -1 ? '#b35c00' : (visual.indexOf('is-resolved') !== -1 ? '#008a20' : sourceColor), 'data-kind': 'run', 'data-index': entry.index }); graph.appendChild(mergePath); - graph.appendChild(svg('circle', { cx: sx, cy: y - 18, r: 4.5, class: 'fp-merge-dot', stroke: sourceColor, 'data-kind': 'run', 'data-index': entry.index })); + graph.appendChild(svg('circle', { cx: sx, cy: y - 26, r: 5, class: 'fp-merge-dot', stroke: sourceColor, 'data-kind': 'run', 'data-index': entry.index })); } var node = svg('circle', { cx: tx, @@ -1702,16 +1843,25 @@ class: 'fp-node' + visual, 'data-kind': 'run', 'data-index': entry.index }); - node.appendChild(svg('title', {})).textContent = '#' + String(run.id || '') + ' ' + source + ' -> ' + target + ' / ' + String(run.status || ''); + node.appendChild(svg('title', {})).textContent = isFork ? ('#' + String(run.id || '') + ' ' + source + ' branched from ' + forkParent) : ('#' + String(run.id || '') + ' ' + source + ' -> ' + target + ' / ' + String(run.status || '')); graph.appendChild(node); var title = svg('text', { x: textX, y: y - 6, class: 'fp-row-title' }); - title.textContent = runLabel(run); + title.textContent = isFork ? ('#' + String(run.id || '') + ' ' + source + ' branched from ' + forkParent) : runLabel(run); graph.appendChild(title); var meta = svg('text', { x: textX, y: y + 12, class: 'fp-row-meta' }); meta.textContent = runMeta(run); graph.appendChild(meta); var pillX = textX + 280; - pillX += drawPill(source, pillX, y - 7, sourceColor) + 8; + if (isFork) { + pillX += drawPill(forkParent, pillX, y - 7, branchColor(forkParent, lanes)) + 8; + var forkText = svg('text', { x: pillX, y: y - 6, class: 'fp-row-meta' }); + forkText.textContent = 'forks'; + graph.appendChild(forkText); + pillX += 34; + drawPill(source, pillX, y - 7, sourceColor); + } else { + pillX += drawPill(source, pillX, y - 7, sourceColor) + 8; + } if (isMerge) { var arrow = svg('text', { x: pillX, y: y - 6, class: 'fp-row-meta' }); arrow.textContent = 'into'; @@ -1745,6 +1895,7 @@ function setDetail(title, rows, actions, object) { }); (actions || []).forEach(function (action) { detailActions.appendChild(action); }); raw.textContent = object ? JSON.stringify(object, null, 2) : ''; + raw.style.display = object ? 'block' : 'none'; } function link(label, href) { var a = document.createElement('a'); @@ -1761,6 +1912,65 @@ function button(label, fn, extraClass) { b.addEventListener('click', fn); return b; } + function textNode(tag, className, text) { + var node = document.createElement(tag); + if (className) node.className = className; + node.textContent = text === undefined || text === null ? '' : String(text); + return node; + } + function cleanPreview(value) { + if (value === undefined || value === null) return ''; + var text = String(value); + try { + var parsed = JSON.parse(text); + if (typeof parsed === 'string') return parsed; + } catch (error) {} + return text; + } + function decodeAuditPayload(value) { + if (value === undefined || value === null || value === '') return ''; + if (typeof value !== 'string') return JSON.stringify(value, null, 2); + try { + var payload = JSON.parse(value); + if (payload && payload.type === 'bytes' && typeof payload.base64 === 'string') { + var binary = atob(payload.base64); + if (window.TextDecoder) { + var bytes = new Uint8Array(binary.length); + for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new TextDecoder('utf-8', { fatal: false }).decode(bytes); + } + return binary; + } + return JSON.stringify(payload, null, 2); + } catch (error) { + return value; + } + } + function conflictValue(record, key, previewKey) { + if (record[previewKey] !== undefined && record[previewKey] !== null && record[previewKey] !== '') { + return cleanPreview(record[previewKey]); + } + return decodeAuditPayload(record[key]); + } + function conflictObjectLabel(record) { + if (record.table_name && record.column_name) return record.table_name + '.' + record.column_name; + if (record.path) return record.path; + if (record.file_path) return record.file_path; + if (record.plugin_object) return record.plugin_object; + return record.conflict_key || ('Conflict #' + record.id); + } + function conflictValueField(label, value) { + var wrap = document.createElement('div'); + wrap.className = 'fp-conflict-value'; + var labelNode = document.createElement('label'); + labelNode.textContent = label; + var textarea = document.createElement('textarea'); + textarea.readOnly = true; + textarea.value = value || ''; + wrap.appendChild(labelNode); + wrap.appendChild(textarea); + return wrap; + } function selectBranch(name) { var item = branch(name); clearStatus(); @@ -1797,32 +2007,69 @@ function renderConflicts(payload) { var list = Array.isArray(payload.records) ? payload.records : []; setStatus(list.length ? 'warn' : 'ok', payload.message || 'Loaded conflict details.'); conflicts.innerHTML = ''; + raw.textContent = ''; + raw.style.display = 'none'; list.forEach(function (record) { var node = document.createElement('div'); node.className = 'fp-conflict'; - var title = document.createElement('div'); - title.className = 'fp-conflict-title'; - title.textContent = record.conflict_key || record.path || record.table_name || ('Conflict #' + record.id); - var meta = document.createElement('div'); - meta.className = 'fp-conflict-meta'; - meta.textContent = [record.conflict_type, record.lifecycle_state, record.next_action, record.plugin].filter(Boolean).join(' / '); + var title = textNode('div', 'fp-conflict-title', '#' + String(record.id || '') + ' ' + conflictObjectLabel(record)); + var metaParts = [ + record.conflict_type, + record.lifecycle_state, + record.next_action, + record.stale_status ? 'stale: ' + record.stale_status : '', + record.review_status ? 'review: ' + record.review_status : '', + record.latest_resolution_status ? 'resolution: ' + record.latest_resolution_status : '' + ].filter(Boolean); + var meta = textNode('div', 'fp-conflict-meta', metaParts.join(' / ')); + var values = document.createElement('div'); + values.className = 'fp-conflict-grid'; + values.appendChild(conflictValueField('Base', conflictValue(record, 'base_payload', 'base_preview'))); + values.appendChild(conflictValueField('Source', conflictValue(record, 'source_payload', 'source_preview'))); + values.appendChild(conflictValueField('Target', conflictValue(record, 'target_payload', 'target_preview'))); + values.appendChild(conflictValueField('Chosen / current target', conflictValue(record, 'chosen_payload', 'chosen_preview') || conflictValue(record, 'current_target_payload', 'current_target_preview'))); + var noteWrap = document.createElement('div'); + noteWrap.className = 'fp-conflict-note'; + var noteLabel = document.createElement('label'); + noteLabel.textContent = 'Review note'; + var note = document.createElement('textarea'); + note.value = record.review_note || ''; + note.placeholder = 'Record why this choice is correct, what still needs action, or what changed after revalidation.'; + noteWrap.appendChild(noteLabel); + noteWrap.appendChild(note); + var choiceWrap = document.createElement('div'); + choiceWrap.className = 'fp-conflict-choice'; + var choiceLabel = document.createElement('label'); + choiceLabel.textContent = 'Resolution choice'; + var choice = document.createElement('select'); + (Array.isArray(record.resolution_choices) ? record.resolution_choices : ['source', 'target']).forEach(function (value) { + var option = document.createElement('option'); + option.value = value; + option.textContent = value === 'source' ? 'Use source value' : (value === 'target' ? 'Keep target value' : value); + option.selected = record.latest_resolution_choice === value; + choice.appendChild(option); + }); + choiceWrap.appendChild(choiceLabel); + choiceWrap.appendChild(choice); var row = document.createElement('div'); row.className = 'fp-buttons'; if (record.id && record.lifecycle_state !== 'resolved') { - row.appendChild(button('Mark reviewed', function () { reviewConflict(record.id, 'reviewed', payload.run); })); - if (Array.isArray(record.resolution_choices) && record.resolution_choices.indexOf('source') !== -1) { - row.appendChild(button('Use source', function () { resolveConflict(record.id, 'source', payload.run); })); - } - if (Array.isArray(record.resolution_choices) && record.resolution_choices.indexOf('target') !== -1) { - row.appendChild(button('Keep target', function () { resolveConflict(record.id, 'target', payload.run); })); - } + row.appendChild(button('Needs action', function () { reviewConflict(record.id, 'needs-action', payload.run, note.value); })); + row.appendChild(button('Mark reviewed', function () { reviewConflict(record.id, 'reviewed', payload.run, note.value); })); + row.appendChild(button('Apply selected', function () { resolveConflict(record.id, choice.value, payload.run, note.value); }, 'primary')); + } else if (record.id && record.latest_resolution_status && record.latest_resolution_applied !== 1) { + row.appendChild(button('Apply reviewed choice', function () { resolveConflict(record.id, '', payload.run, note.value, true); }, 'primary')); + } else { + row.appendChild(textNode('span', 'fp-conflict-meta', 'Resolved; no action needed.')); } node.appendChild(title); node.appendChild(meta); + node.appendChild(values); + node.appendChild(noteWrap); + node.appendChild(choiceWrap); node.appendChild(row); conflicts.appendChild(node); }); - raw.textContent = JSON.stringify(payload, null, 2); } function loadTree() { setStatus('warn', 'Loading branch graph...'); @@ -1857,11 +2104,11 @@ function loadConflicts(run) { setStatus('error', error.message || 'Could not load conflicts.'); }); } - function reviewConflict(id, value, run) { - post('forkpress_branch_review_conflict', { conflict: String(id), status: value, run: String(run || '') }).then(function () { loadConflicts(run); }).catch(function (error) { setStatus('error', error.message); }); + function reviewConflict(id, value, run, note) { + post('forkpress_branch_review_conflict', { conflict: String(id), status: value, run: String(run || ''), note: note || '' }).then(function () { loadConflicts(run); }).catch(function (error) { setStatus('error', error.message); }); } - function resolveConflict(id, choice, run) { - post('forkpress_branch_resolve_conflict', { conflict: String(id), choice: choice, run: String(run || '') }).then(function () { loadConflicts(run); }).catch(function (error) { setStatus('error', error.message); }); + function resolveConflict(id, choice, run, note, applyReviewed) { + post('forkpress_branch_resolve_conflict', { conflict: String(id), choice: choice || '', run: String(run || ''), note: note || '', applyReviewed: applyReviewed ? '1' : '' }).then(function () { loadConflicts(run); loadTree(); }).catch(function (error) { setStatus('error', error.message); }); } graph.addEventListener('click', function (event) { var target = event.target.closest ? event.target.closest('[data-kind]') : null; diff --git a/tests/cow/branch_ui.php b/tests/cow/branch_ui.php index 31e11171..42f83d67 100644 --- a/tests/cow/branch_ui.php +++ b/tests/cow/branch_ui.php @@ -558,6 +558,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'] @@ -582,6 +593,17 @@ 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' +); + $invalid_conflict_resolution = run_branch_ui_action( ['action' => 'forkpress_branch_resolve_conflict', 'conflict' => '7', 'choice' => 'both'], ['main', 'feature'] diff --git a/tests/cow/router_lock.php b/tests/cow/router_lock.php index a9f65e30..ffabf956 100644 --- a/tests/cow/router_lock.php +++ b/tests/cow/router_lock.php @@ -242,6 +242,11 @@ function rm_tree(string $path): void { 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-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-end'), 'out-of-band branch manager renders branch line end caps'); 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, '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'); @@ -249,6 +254,10 @@ function rm_tree(string $path): void { assert_true(str_contains($early_body, 'annotateConflictRuns'), 'out-of-band branch manager annotates conflict runs with review state'); 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, '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, 'Review note'), 'out-of-band branch manager exposes editable review notes'); + assert_true(str_contains($early_body, 'Apply selected'), 'out-of-band branch manager exposes a selected conflict resolution action'); assert_true(str_contains($early_body, 'Branch actions'), 'out-of-band branch manager keeps create and merge actions available'); assert_true(!file_exists($started), 'out-of-band branch manager did not execute branch PHP'); diff --git a/wp-plugin/forkpress-wp.php b/wp-plugin/forkpress-wp.php index 2863799c..e99f0c60 100644 --- a/wp-plugin/forkpress-wp.php +++ b/wp-plugin/forkpress-wp.php @@ -1664,6 +1664,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.', @@ -1676,7 +1680,7 @@ function forkpress_handle_branch_review_conflict(): void { '--status', $status, '--note', - $notes[$status], + $review_note !== '' ? $review_note : $notes[$status], '--reviewer', 'wordpress-ui', ]); @@ -1725,6 +1729,10 @@ function forkpress_handle_branch_resolve_conflict(): void { 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); @@ -1775,7 +1783,7 @@ function forkpress_handle_branch_resolve_conflict(): void { $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); From 57fd6e508bc6a89990e09620eaf42d234ec6eed6 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 20 May 2026 00:34:03 +0200 Subject: [PATCH 07/38] Use same-origin branch manager actions --- runtime/cow/router.php | 29 ++++++++++++++++++++++++++--- tests/cow/branch_ui.php | 17 +++++++++++++++++ tests/cow/router_lock.php | 2 ++ wp-plugin/forkpress-wp.php | 28 +++++++++++++++++++++++++++- 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/runtime/cow/router.php b/runtime/cow/router.php index c50ae0f4..ece92785 100644 --- a/runtime/cow/router.php +++ b/runtime/cow/router.php @@ -222,12 +222,35 @@ 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 { @@ -2166,8 +2189,8 @@ function forkpress_cow_handle_branch_manager(string $path, string $current_branc echo str_replace('__STATE__', forkpress_cow_json_encode([ 'currentBranch' => $current_branch, 'branches' => forkpress_cow_branch_switcher_data($current_branch, '/wp-admin/'), - 'actionUrl' => forkpress_cow_branch_url($current_branch, '/_forkpress/action'), - 'rootUrl' => forkpress_cow_branch_url('main', '/_forkpress/branches'), + 'actionUrl' => '/_forkpress/action', + 'rootUrl' => '/_forkpress/branches', ]), forkpress_cow_branch_manager_html($current_branch)); return true; } diff --git a/tests/cow/branch_ui.php b/tests/cow/branch_ui.php index 42f83d67..ed3765a8 100644 --- a/tests/cow/branch_ui.php +++ b/tests/cow/branch_ui.php @@ -172,6 +172,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'; } @@ -325,6 +329,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'], diff --git a/tests/cow/router_lock.php b/tests/cow/router_lock.php index ffabf956..62af8dd2 100644 --- a/tests/cow/router_lock.php +++ b/tests/cow/router_lock.php @@ -241,6 +241,8 @@ function rm_tree(string $path): void { $early_body = stream_get_contents($pipes[1]); 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, '"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, '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'); diff --git a/wp-plugin/forkpress-wp.php b/wp-plugin/forkpress-wp.php index e99f0c60..9af54392 100644 --- a/wp-plugin/forkpress-wp.php +++ b/wp-plugin/forkpress-wp.php @@ -437,12 +437,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/'; From be27c5aa13647c9b1f4b5a3ce1227acee5090e49 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 20 May 2026 02:38:28 +0200 Subject: [PATCH 08/38] Polish branch graph conflict loading --- runtime/cow/router.php | 206 ++++++++++++++++++++++++------------- tests/cow/branch_ui.php | 3 + tests/cow/router_lock.php | 4 +- wp-plugin/forkpress-wp.php | 15 +++ 4 files changed, 158 insertions(+), 70 deletions(-) diff --git a/runtime/cow/router.php b/runtime/cow/router.php index ece92785..21e73d32 100644 --- a/runtime/cow/router.php +++ b/runtime/cow/router.php @@ -562,6 +562,21 @@ function forkpress_cow_branch_history_summary(array $report, int $limit): array function forkpress_cow_branch_tree_summary(array $report, int $limit): array { $records = is_array($report['runs'] ?? null) ? array_values($report['runs']) : []; + 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'])) { + $record['conflictSummary'] = [ + 'total' => $conflicts, + 'resolved' => 0, + 'unresolved' => $conflicts, + 'estimated' => true, + ]; + } + } + unset($record); return [ 'records' => $records, 'recordCount' => count($records), @@ -1291,8 +1306,8 @@ function forkpress_cow_branch_manager_html(string $current_branch): string { min-height: 440px; min-width: 760px; } - .fp-row-hit { cursor: pointer; fill: transparent; } - .fp-row-hit:hover { fill: #f6f7f7; } + .fp-row-hit { cursor: pointer; fill: transparent; pointer-events: all; stroke: transparent; stroke-width: 1; } + .fp-row-hit:hover { fill: transparent; stroke: #cfe7ff; } .fp-timeline-lane { fill: none; stroke-linecap: round; stroke-width: 4; } .fp-timeline-fork { fill: none; stroke-linecap: round; stroke-width: 4; } .fp-timeline-merge { fill: none; stroke-linecap: round; stroke-width: 4; } @@ -1420,6 +1435,14 @@ function forkpress_cow_branch_manager_html(string $current_branch): string { gap: 8px; padding: 10px; } + .fp-conflict-loading { + background: #f6f7f7; + border: 1px solid var(--line); + border-radius: 6px; + color: var(--muted); + font-size: 13px; + padding: 12px; + } .fp-conflict-title { font-size: 13px; font-weight: 700; overflow-wrap: anywhere; } .fp-conflict-meta { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; } .fp-conflict-grid { @@ -1620,6 +1643,9 @@ function conflictSummary(run) { if (!summary && total > 0) unresolved = total; return { total: total, resolved: resolved, unresolved: unresolved }; } + function hasConflictSummary(run) { + return !!(run && (run._conflictSummary || run.conflictSummary || run.conflict_summary)); + } function runVisualClass(run) { var summary = conflictSummary(run); var status = String(run.status || ''); @@ -1693,13 +1719,14 @@ function runMeta(run) { if (when) parts.push(when); return parts.filter(Boolean).join(' · '); } - function drawPill(text, x, y, color) { + function drawPill(text, x, y, color, layer) { + var parent = layer || graph; var width = Math.max(46, Math.min(150, 14 + String(text).length * 6.3)); var rect = svg('rect', { x: x, y: y - 12, width: width, height: 18, rx: 9, class: 'fp-branch-pill', stroke: color }); var label = svg('text', { x: x + 8, y: y + 1, class: 'fp-branch-pill-text' }); label.textContent = text.length > 20 ? text.slice(0, 18) + '...' : text; - graph.appendChild(rect); - graph.appendChild(label); + parent.appendChild(rect); + parent.appendChild(label); return width; } function laneActivity(entries, lanes) { @@ -1746,49 +1773,61 @@ function add(name) { return 0; }); } - function annotateConflictRuns() { - var conflictRuns = records.filter(function (run) { - return Number(run.conflict_count || 0) > 0 && run.id && !run._conflictSummary; + function seedConflictSummaries() { + records.forEach(function (run) { + var total = Number(run && run.conflict_count || 0); + if (total > 0 && !hasConflictSummary(run)) { + run._conflictSummary = { total: total, resolved: 0, unresolved: total, estimated: true }; + } }); - if (!conflictRuns.length) return Promise.resolve(); - return Promise.all(conflictRuns.map(function (run) { - return post('forkpress_branch_conflicts', { run: String(run.id) }).then(function (payload) { - run._conflictSummary = payload.conflictSummary || payload.conflict_summary || null; - run._conflictRecords = Array.isArray(payload.records) ? payload.records : []; - }).catch(function () { - run._conflictSummary = { total: Number(run.conflict_count || 0), resolved: 0, unresolved: Number(run.conflict_count || 0) }; - }); - })).then(function () {}); + } + function firstConflictRun() { + return records.find(function (run) { return Number(run && run.conflict_count || 0) > 0 && run.id; }) || null; + } + function runById(run) { + var id = String(run || ''); + return records.find(function (item) { return String(item && item.id || '') === id; }) || null; } function renderGraph() { var entries = sortedRunEntries(); var lanes = laneNames(entries); var forkParents = branchForkParents(entries); - var graphLeft = 72; - var laneGap = 54; + var graphLeft = 84; + var laneGap = 64; var graphWidth = Math.max(1, lanes.length) * laneGap; var textX = graphLeft + graphWidth + 56; - var top = 58; - var rowGap = 64; - var rowHeight = 56; - var width = Math.max(1120, textX + 720); + var top = 64; + var rowGap = 70; + var rowHeight = 60; + var width = Math.max(1180, textX + 740); var height = Math.max(520, top + Math.max(entries.length, 1) * rowGap + 34); graph.setAttribute('viewBox', '0 0 ' + width + ' ' + height); graph.setAttribute('width', width); graph.setAttribute('height', height); graph.innerHTML = ''; + var rowLayer = svg('g', { class: 'fp-row-layer' }); + var laneLayer = svg('g', { class: 'fp-lane-layer' }); + var edgeLayer = svg('g', { class: 'fp-edge-layer' }); + var nodeLayer = svg('g', { class: 'fp-node-layer' }); + var textLayer = svg('g', { class: 'fp-text-layer' }); + var hitLayer = svg('g', { class: 'fp-hit-layer' }); + [rowLayer, laneLayer, edgeLayer, nodeLayer, textLayer, hitLayer].forEach(function (layer) { graph.appendChild(layer); }); var x = {}; var activity = laneActivity(entries, lanes); lanes.forEach(function (name, index) { x[name] = graphLeft + index * laneGap; }); + entries.forEach(function (entry, rowIndex) { + var y = top + rowIndex * rowGap; + rowLayer.appendChild(svg('line', { x1: 0, x2: width - 20, y1: y + rowHeight / 2, y2: y + rowHeight / 2, class: 'fp-row-divider' })); + }); lanes.forEach(function (name) { var span = activity[name]; if (!span || span.first === null || span.last === null) return; var color = branchColor(name, lanes); - var startY = top + span.first * rowGap - 18; - var endY = top + span.last * rowGap + 18; - graph.appendChild(svg('path', { + var startY = top + span.first * rowGap - 22; + var endY = top + span.last * rowGap + 22; + laneLayer.appendChild(svg('path', { d: 'M ' + x[name] + ' ' + startY + ' L ' + x[name] + ' ' + endY, class: 'fp-timeline-lane', stroke: color, @@ -1796,14 +1835,14 @@ class: 'fp-timeline-lane', })); var cap = svg('circle', { cx: x[name], cy: startY, r: 4, class: 'fp-lane-cap', stroke: color }); cap.appendChild(svg('title', {})).textContent = name + ' latest visible revision'; - graph.appendChild(cap); + nodeLayer.appendChild(cap); var end = svg('circle', { cx: x[name], cy: endY, r: 4, class: 'fp-lane-end', stroke: color }); end.appendChild(svg('title', {})).textContent = name + ' oldest visible revision'; - graph.appendChild(end); + nodeLayer.appendChild(end); if (span.first < 6) { var label = svg('text', { x: x[name] + 7, y: startY - 4, class: 'fp-lane-label' }); label.textContent = name.length > 15 ? name.slice(0, 13) + '...' : name; - graph.appendChild(label); + textLayer.appendChild(label); } }); entries.forEach(function (entry, rowIndex) { @@ -1821,20 +1860,9 @@ class: 'fp-timeline-lane', var isFork = isBranchForkRun(run); var forkParent = forkParents[source] || 'main'; var px = x[forkParent] !== undefined ? x[forkParent] : graphLeft; - var hit = svg('rect', { - x: 0, - y: y - rowHeight / 2, - width: width, - height: rowHeight, - class: 'fp-row-hit', - 'data-kind': 'run', - 'data-index': entry.index - }); - graph.appendChild(hit); - graph.appendChild(svg('line', { x1: 0, x2: width - 20, y1: y + rowHeight / 2, y2: y + rowHeight / 2, class: 'fp-row-divider' })); if (isFork && px !== sx) { var forkPath = svg('path', { - d: 'M ' + px + ' ' + y + ' C ' + px + ' ' + (y - 26) + ', ' + sx + ' ' + (y - 26) + ', ' + sx + ' ' + (y - 2), + d: 'M ' + px + ' ' + y + ' C ' + px + ' ' + (y - 24) + ', ' + sx + ' ' + (y - 24) + ', ' + sx + ' ' + y, class: 'fp-timeline-fork', stroke: sourceColor, opacity: '.92', @@ -1842,20 +1870,20 @@ class: 'fp-timeline-fork', 'data-index': entry.index }); forkPath.appendChild(svg('title', {})).textContent = source + ' branched from ' + forkParent; - graph.appendChild(forkPath); - graph.appendChild(svg('circle', { cx: px, cy: y, r: 5, class: 'fp-merge-dot', stroke: branchColor(forkParent, lanes), 'data-kind': 'run', 'data-index': entry.index })); + edgeLayer.appendChild(forkPath); + nodeLayer.appendChild(svg('circle', { cx: px, cy: y, r: 5, class: 'fp-merge-dot', stroke: branchColor(forkParent, lanes), 'data-kind': 'run', 'data-index': entry.index })); } if (isMerge) { - var bend = Math.max(28, Math.abs(tx - sx) / 2); + var bend = Math.max(32, Math.abs(tx - sx) / 2); var mergePath = svg('path', { - d: 'M ' + sx + ' ' + (y - 26) + ' C ' + sx + ' ' + (y - 4) + ', ' + (tx + (sx < tx ? -bend : bend)) + ' ' + (y - 4) + ', ' + tx + ' ' + y, + d: 'M ' + sx + ' ' + y + ' C ' + sx + ' ' + (y - 24) + ', ' + (tx + (sx < tx ? -bend : bend)) + ' ' + (y - 24) + ', ' + tx + ' ' + y, class: 'fp-timeline-merge' + (visual.indexOf('is-conflict') !== -1 ? ' is-conflict' : '') + (visual.indexOf('is-resolved') !== -1 ? ' is-resolved' : ''), stroke: visual.indexOf('is-conflict') !== -1 ? '#b35c00' : (visual.indexOf('is-resolved') !== -1 ? '#008a20' : sourceColor), 'data-kind': 'run', 'data-index': entry.index }); - graph.appendChild(mergePath); - graph.appendChild(svg('circle', { cx: sx, cy: y - 26, r: 5, class: 'fp-merge-dot', stroke: sourceColor, 'data-kind': 'run', 'data-index': entry.index })); + edgeLayer.appendChild(mergePath); + nodeLayer.appendChild(svg('circle', { cx: sx, cy: y, r: 5, class: 'fp-merge-dot', stroke: sourceColor, 'data-kind': 'run', 'data-index': entry.index })); } var node = svg('circle', { cx: tx, @@ -1867,37 +1895,46 @@ class: 'fp-node' + visual, 'data-index': entry.index }); node.appendChild(svg('title', {})).textContent = isFork ? ('#' + String(run.id || '') + ' ' + source + ' branched from ' + forkParent) : ('#' + String(run.id || '') + ' ' + source + ' -> ' + target + ' / ' + String(run.status || '')); - graph.appendChild(node); + nodeLayer.appendChild(node); var title = svg('text', { x: textX, y: y - 6, class: 'fp-row-title' }); title.textContent = isFork ? ('#' + String(run.id || '') + ' ' + source + ' branched from ' + forkParent) : runLabel(run); - graph.appendChild(title); + textLayer.appendChild(title); var meta = svg('text', { x: textX, y: y + 12, class: 'fp-row-meta' }); meta.textContent = runMeta(run); - graph.appendChild(meta); - var pillX = textX + 280; + textLayer.appendChild(meta); + var pillX = textX + 305; if (isFork) { - pillX += drawPill(forkParent, pillX, y - 7, branchColor(forkParent, lanes)) + 8; + pillX += drawPill(forkParent, pillX, y - 7, branchColor(forkParent, lanes), textLayer) + 8; var forkText = svg('text', { x: pillX, y: y - 6, class: 'fp-row-meta' }); forkText.textContent = 'forks'; - graph.appendChild(forkText); + textLayer.appendChild(forkText); pillX += 34; - drawPill(source, pillX, y - 7, sourceColor); + drawPill(source, pillX, y - 7, sourceColor, textLayer); } else { - pillX += drawPill(source, pillX, y - 7, sourceColor) + 8; + pillX += drawPill(source, pillX, y - 7, sourceColor, textLayer) + 8; } if (isMerge) { var arrow = svg('text', { x: pillX, y: y - 6, class: 'fp-row-meta' }); arrow.textContent = 'into'; - graph.appendChild(arrow); + textLayer.appendChild(arrow); pillX += 30; - drawPill(target, pillX, y - 7, targetColor); + drawPill(target, pillX, y - 7, targetColor, textLayer); } if (conflict.total > 0) { - graph.appendChild(svg('circle', { cx: tx + 10, cy: y - 14, r: 9, class: 'fp-conflict-badge' + (conflict.unresolved === 0 && run._conflictSummary ? ' is-resolved' : '') })); - var badge = svg('text', { x: tx + 10, y: y - 10, class: 'fp-badge-text', 'text-anchor': 'middle' }); - badge.textContent = String(conflict.unresolved === 0 && run._conflictSummary ? conflict.total : conflict.unresolved); - graph.appendChild(badge); + nodeLayer.appendChild(svg('circle', { cx: tx + 16, cy: y - 17, r: 9, class: 'fp-conflict-badge' + (conflict.unresolved === 0 && hasConflictSummary(run) ? ' is-resolved' : '') })); + var badge = svg('text', { x: tx + 16, y: y - 13, class: 'fp-badge-text', 'text-anchor': 'middle' }); + badge.textContent = String(conflict.unresolved === 0 && hasConflictSummary(run) ? conflict.total : conflict.unresolved); + nodeLayer.appendChild(badge); } + hitLayer.appendChild(svg('rect', { + x: 0, + y: y - rowHeight / 2, + width: width, + height: rowHeight, + class: 'fp-row-hit', + 'data-kind': 'run', + 'data-index': entry.index + })); }); summary.textContent = lanes.length + ' graph lanes / ' + entries.length + ' timeline revisions / newest first'; } @@ -2098,13 +2135,16 @@ function loadTree() { setStatus('warn', 'Loading branch graph...'); return post('forkpress_branch_tree', { limit: '50' }).then(function (payload) { records = Array.isArray(payload.records) ? payload.records : []; + seedConflictSummaries(); renderGraph(); clearStatus(); - if (records.length) selectRun(records[0]); else selectBranch(state.currentBranch); - annotateConflictRuns().then(function () { - renderGraph(); - if (records.length) selectRun(records[0]); - }); + var initialRun = firstConflictRun() || records[0] || null; + if (initialRun) { + selectRun(initialRun); + if (Number(initialRun.conflict_count || 0) > 0) loadConflicts(initialRun.id, { defaultLoad: true }); + } else { + selectBranch(state.currentBranch); + } }).catch(function (error) { records = []; renderGraph(); @@ -2116,14 +2156,42 @@ function loadHistory() { setStatus('warn', 'Loading history...'); post('forkpress_branch_history', { limit: '50' }).then(function (payload) { records = Array.isArray(payload.records) ? payload.records : []; + seedConflictSummaries(); renderGraph(); setStatus('ok', payload.message || 'Loaded history.'); - annotateConflictRuns().then(renderGraph); }).catch(function (error) { setStatus('error', error.message || 'Could not load history.'); }); } - function loadConflicts(run) { + function renderConflictLoading(run) { + conflicts.innerHTML = ''; + raw.textContent = ''; + raw.style.display = 'none'; + conflicts.appendChild(textNode('div', 'fp-conflict-loading', 'Loading conflict list for run #' + String(run) + '...')); + } + function cacheConflictPayload(run, payload) { + var item = runById(run); + if (!item) return; + item._conflictSummary = payload.conflictSummary || payload.conflict_summary || item._conflictSummary || null; + item._conflictRecords = Array.isArray(payload.records) ? payload.records : []; + } + function loadConflicts(run, options) { + var item = runById(run); + if (item && Array.isArray(item._conflictRecords)) { + renderConflicts({ + run: Number(run), + records: item._conflictRecords, + conflictSummary: item._conflictSummary || null, + message: 'Loaded cached conflict details.' + }); + if (!options || !options.refresh) return Promise.resolve(); + } else { + renderConflictLoading(run); + } setStatus('warn', 'Loading conflicts...'); - post('forkpress_branch_conflicts', { run: String(run) }).then(renderConflicts).catch(function (error) { + return post('forkpress_branch_conflicts', { run: String(run) }).then(function (payload) { + cacheConflictPayload(run, payload); + renderConflicts(payload); + renderGraph(); + }).catch(function (error) { setStatus('error', error.message || 'Could not load conflicts.'); }); } diff --git a/tests/cow/branch_ui.php b/tests/cow/branch_ui.php index ed3765a8..ef03c9b3 100644 --- a/tests/cow/branch_ui.php +++ b/tests/cow/branch_ui.php @@ -428,6 +428,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( diff --git a/tests/cow/router_lock.php b/tests/cow/router_lock.php index 62af8dd2..ec9bf56e 100644 --- a/tests/cow/router_lock.php +++ b/tests/cow/router_lock.php @@ -253,7 +253,9 @@ function rm_tree(string $path): void { 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, 'timeline revisions / newest first'), 'out-of-band branch manager labels revision timeline direction'); - assert_true(str_contains($early_body, 'annotateConflictRuns'), 'out-of-band branch manager annotates conflict runs with review state'); + 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, '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, '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, 'fp-conflict-grid'), 'out-of-band branch manager renders conflict values as review fields'); diff --git a/wp-plugin/forkpress-wp.php b/wp-plugin/forkpress-wp.php index 9af54392..d3f23729 100644 --- a/wp-plugin/forkpress-wp.php +++ b/wp-plugin/forkpress-wp.php @@ -1321,6 +1321,21 @@ function forkpress_branch_history_summary(array $report, int $limit): array { function forkpress_branch_tree_summary(array $report, int $limit): array { $records = is_array($report['runs'] ?? null) ? array_values($report['runs']) : []; + 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'])) { + $record['conflictSummary'] = [ + 'total' => $conflicts, + 'resolved' => 0, + 'unresolved' => $conflicts, + 'estimated' => true, + ]; + } + } + unset($record); return [ 'records' => $records, 'recordCount' => count($records), From f45f48a0056b25856d347ffa8d9d72aaca97c467 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 20 May 2026 03:58:37 +0200 Subject: [PATCH 09/38] Support revising applied merge resolutions --- crates/forkpress-cli/src/app.rs | 23 ++++- crates/forkpress-storage/src/lib.rs | 4 + runtime/cow/router.php | 148 +++++++++++++++++++++++--- scripts/cow/merge.php | 81 +++++++++++++-- tests/cow/branch_ui.php | 66 ++++++++++++ tests/cow/merge.php | 57 ++++++++++ tests/cow/router_lock.php | 8 +- wp-plugin/forkpress-wp.php | 154 +++++++++++++++++++++++++++- 8 files changed, 511 insertions(+), 30 deletions(-) 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/runtime/cow/router.php b/runtime/cow/router.php index 21e73d32..abd5c5bf 100644 --- a/runtime/cow/router.php +++ b/runtime/cow/router.php @@ -560,14 +560,72 @@ function forkpress_cow_branch_history_summary(array $report, int $limit): array ]; } +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'])) { + 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, @@ -1002,16 +1060,25 @@ 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.'); return true; } + if ($replace_applied && $after_revalidate) { + forkpress_cow_branch_finish_json(400, $current_url, false, 'Changing an applied resolution cannot be combined with after revalidate.'); + return true; + } if (!$apply_reviewed && !in_array($choice, ['source', 'target'], true)) { forkpress_cow_branch_finish_json(400, $current_url, false, 'Choose source or target for the conflict resolution.'); return true; @@ -1072,6 +1139,9 @@ 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'; @@ -1088,12 +1158,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; @@ -1573,6 +1644,7 @@ function forkpress_cow_branch_manager_html(string $current_branch): string { var mergeSource = document.getElementById('fp-merge-source'); var mergeTarget = document.getElementById('fp-merge-target'); var records = []; + var selectedRunId = null; function branch(name) { return state.branches.find(function (item) { return item.name === name; }) || { name: name, url: '#', siteUrl: '#', adminUrl: '#', managerUrl: '#' }; @@ -1626,18 +1698,25 @@ function runNumber(run) { var id = Number(run && run.id ? run.id : 0); return Number.isFinite(id) ? id : 0; } + function runTimelineTimestamp(run) { + var value = String(run && (run.finished_at || run.started_at || run.created_at) || ''); + if (!value) return 0; + var normalized = value.indexOf('T') === -1 ? value.replace(' ', 'T') + 'Z' : value; + var parsed = Date.parse(normalized); + return Number.isFinite(parsed) ? parsed : 0; + } function sortedRunEntries() { return records.map(function (run, index) { return { run: run, index: index }; }).sort(function (a, b) { - var byId = runNumber(b.run) - runNumber(a.run); - if (byId !== 0) return byId; - return String(b.run.finished_at || b.run.started_at || '').localeCompare(String(a.run.finished_at || a.run.started_at || '')); + var byTime = runTimelineTimestamp(b.run) - runTimelineTimestamp(a.run); + if (byTime !== 0) return byTime; + return runNumber(b.run) - runNumber(a.run); }); } function conflictSummary(run) { var summary = run && (run.conflictSummary || run.conflict_summary || run._conflictSummary); - var total = Number(run && run.conflict_count || 0); + var total = Number(summary && summary.total || run && run.conflict_count || 0); var resolved = Number(summary && summary.resolved || 0); var unresolved = Number(summary && summary.unresolved || 0); if (!summary && total > 0) unresolved = total; @@ -1936,7 +2015,7 @@ class: 'fp-row-hit', 'data-index': entry.index })); }); - summary.textContent = lanes.length + ' graph lanes / ' + entries.length + ' timeline revisions / newest first'; + summary.textContent = lanes.length + ' graph lanes / ' + entries.length + ' interleaved timeline revisions / newest first'; } function setDetail(title, rows, actions, object) { detailTitle.textContent = title; @@ -1957,6 +2036,18 @@ function setDetail(title, rows, actions, object) { raw.textContent = object ? JSON.stringify(object, null, 2) : ''; raw.style.display = object ? 'block' : 'none'; } + function refreshSelectedRunConflictState(run, summary) { + var item = runById(run); + if (item && summary) item._conflictSummary = summary; + if (!summary || String(selectedRunId) !== String(run)) return false; + Array.prototype.slice.call(detail.querySelectorAll('div')).some(function (row) { + var cells = row.querySelectorAll('span'); + if (cells.length < 2 || cells[0].textContent !== 'conflict state') return false; + cells[1].textContent = String(summary.unresolved || 0) + ' unresolved / ' + String(summary.resolved || 0) + ' resolved / ' + String(summary.total || 0) + ' total'; + return true; + }); + return true; + } function link(label, href) { var a = document.createElement('a'); a.className = 'fp-button'; @@ -2031,8 +2122,19 @@ function conflictValueField(label, value) { wrap.appendChild(textarea); return wrap; } + function conflictResolutionChangeAvailable(record) { + if (!record || !record.id || Number(record.latest_resolution_applied || 0) !== 1) return false; + if (record.conflict_type !== 'cell-conflict') return false; + var choices = Array.isArray(record.resolution_choices) ? record.resolution_choices : ['source', 'target']; + return choices.indexOf('source') !== -1 && choices.indexOf('target') !== -1; + } + function conflictResolutionAppliedLabel(record) { + if (!record || !record.latest_resolution_id) return 'Current applied resolution can be changed by selecting source or target.'; + return 'Current applied resolution #' + String(record.latest_resolution_id) + ' used ' + String(record.latest_resolution_choice || 'a reviewed') + ' choice.'; + } function selectBranch(name) { var item = branch(name); + selectedRunId = null; clearStatus(); setDetail('Branch ' + name, { branch: name, current: name === state.currentBranch ? 'yes' : 'no' }, [ link('Open site', item.siteUrl || item.url), @@ -2041,6 +2143,7 @@ function selectBranch(name) { ], item); } function selectRun(run) { + selectedRunId = String(run && run.id || ''); clearStatus(); var actions = [ link('Open source', branch(String(run.source_branch || '')).siteUrl), @@ -2065,6 +2168,9 @@ function selectRun(run) { } function renderConflicts(payload) { var list = Array.isArray(payload.records) ? payload.records : []; + if (refreshSelectedRunConflictState(payload.run, payload.conflictSummary || payload.conflict_summary || null)) { + renderGraph(); + } setStatus(list.length ? 'warn' : 'ok', payload.message || 'Loaded conflict details.'); conflicts.innerHTML = ''; raw.textContent = ''; @@ -2113,21 +2219,34 @@ function renderConflicts(payload) { choiceWrap.appendChild(choice); var row = document.createElement('div'); row.className = 'fp-buttons'; + var prioritizeResolutionChange = conflictResolutionChangeAvailable(record); if (record.id && record.lifecycle_state !== 'resolved') { row.appendChild(button('Needs action', function () { reviewConflict(record.id, 'needs-action', payload.run, note.value); })); row.appendChild(button('Mark reviewed', function () { reviewConflict(record.id, 'reviewed', payload.run, note.value); })); row.appendChild(button('Apply selected', function () { resolveConflict(record.id, choice.value, payload.run, note.value); }, 'primary')); + } else if (prioritizeResolutionChange) { + row.appendChild(button('Change applied resolution', function () { resolveConflict(record.id, choice.value, payload.run, note.value, false, true); }, 'primary')); + row.appendChild(textNode('span', 'fp-conflict-meta', conflictResolutionAppliedLabel(record))); } else if (record.id && record.latest_resolution_status && record.latest_resolution_applied !== 1) { row.appendChild(button('Apply reviewed choice', function () { resolveConflict(record.id, '', payload.run, note.value, true); }, 'primary')); + } else if (record.id && Number(record.latest_resolution_applied || 0) === 1) { + row.appendChild(textNode('span', 'fp-conflict-meta', 'Resolved; changing this conflict type requires a fresh merge audit.')); } else { row.appendChild(textNode('span', 'fp-conflict-meta', 'Resolved; no action needed.')); } node.appendChild(title); node.appendChild(meta); - node.appendChild(values); - node.appendChild(noteWrap); - node.appendChild(choiceWrap); - node.appendChild(row); + if (prioritizeResolutionChange) { + node.appendChild(choiceWrap); + node.appendChild(noteWrap); + node.appendChild(row); + node.appendChild(values); + } else { + node.appendChild(values); + node.appendChild(noteWrap); + node.appendChild(choiceWrap); + node.appendChild(row); + } conflicts.appendChild(node); }); } @@ -2172,6 +2291,9 @@ function cacheConflictPayload(run, payload) { if (!item) return; item._conflictSummary = payload.conflictSummary || payload.conflict_summary || item._conflictSummary || null; item._conflictRecords = Array.isArray(payload.records) ? payload.records : []; + if (String(selectedRunId) === String(run)) { + selectRun(item); + } } function loadConflicts(run, options) { var item = runById(run); @@ -2198,8 +2320,8 @@ function loadConflicts(run, options) { function reviewConflict(id, value, run, note) { post('forkpress_branch_review_conflict', { conflict: String(id), status: value, run: String(run || ''), note: note || '' }).then(function () { loadConflicts(run); }).catch(function (error) { setStatus('error', error.message); }); } - function resolveConflict(id, choice, run, note, applyReviewed) { - post('forkpress_branch_resolve_conflict', { conflict: String(id), choice: choice || '', run: String(run || ''), note: note || '', applyReviewed: applyReviewed ? '1' : '' }).then(function () { loadConflicts(run); loadTree(); }).catch(function (error) { setStatus('error', error.message); }); + function resolveConflict(id, choice, run, note, applyReviewed, replaceApplied) { + post('forkpress_branch_resolve_conflict', { conflict: String(id), choice: choice || '', run: String(run || ''), note: note || '', applyReviewed: applyReviewed ? '1' : '', replaceApplied: replaceApplied ? '1' : '' }).then(function () { loadConflicts(run); loadTree(); }).catch(function (error) { setStatus('error', error.message); }); } graph.addEventListener('click', function (event) { var target = event.target.closest ? event.target.closest('[data-kind]') : null; diff --git a/scripts/cow/merge.php b/scripts/cow/merge.php index 83a9f292..77f1676f 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']); @@ -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 ef03c9b3..849c8fb8 100644 --- a/tests/cow/branch_ui.php +++ b/tests/cow/branch_ui.php @@ -439,6 +439,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'], @@ -624,6 +661,24 @@ function decode_branch_ui_payload(array $result): array { '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'] @@ -696,6 +751,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'] @@ -1238,11 +1301,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'); diff --git a/tests/cow/merge.php b/tests/cow/merge.php index ec42c343..1a5052b3 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'; diff --git a/tests/cow/router_lock.php b/tests/cow/router_lock.php index ec9bf56e..d62450f8 100644 --- a/tests/cow/router_lock.php +++ b/tests/cow/router_lock.php @@ -252,8 +252,11 @@ function rm_tree(string $path): void { 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, '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, 'timeline revisions / newest first'), 'out-of-band branch manager labels revision timeline direction'); + 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, 'interleaved timeline revisions / newest first'), 'out-of-band branch manager labels interleaved revision timeline direction'); 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, '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, 'forkpress_branch_tree'), 'out-of-band branch manager can load branch tree data'); @@ -262,6 +265,9 @@ function rm_tree(string $path): void { assert_true(str_contains($early_body, 'decodeAuditPayload'), 'out-of-band branch manager decodes audit payloads for review'); assert_true(str_contains($early_body, 'Review note'), 'out-of-band branch manager exposes editable review notes'); assert_true(str_contains($early_body, 'Apply selected'), 'out-of-band branch manager exposes a selected conflict resolution action'); + 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, 'Branch actions'), 'out-of-band branch manager keeps create and merge actions available'); assert_true(!file_exists($started), 'out-of-band branch manager did not execute branch PHP'); diff --git a/wp-plugin/forkpress-wp.php b/wp-plugin/forkpress-wp.php index d3f23729..2aec7216 100644 --- a/wp-plugin/forkpress-wp.php +++ b/wp-plugin/forkpress-wp.php @@ -1319,14 +1319,72 @@ 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'])) { + 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, @@ -1758,14 +1816,21 @@ 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.'); } + if ($replace_applied && $after_revalidate) { + forkpress_branch_finish_action(forkpress_branch_url($current, '/wp-admin/'), 'error', 'Changing an applied resolution cannot be combined with after revalidate.'); + } 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.'); } @@ -1821,6 +1886,9 @@ 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'; @@ -1835,12 +1903,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, ] ); } @@ -2203,6 +2272,25 @@ function renderConflicts(payload) { if (actions.childNodes.length) { item.appendChild(actions); } + } else if (record && record.id && (conflictResolutionChangeAvailable(record, 'source') || conflictResolutionChangeAvailable(record, 'target'))) { + var changeActions = document.createElement('div'); + changeActions.className = 'forkpress-branch-conflict-actions'; + ['source', 'target'].forEach(function (choice) { + if (!conflictResolutionChangeAvailable(record, choice)) { + return; + } + var changeButton = document.createElement('button'); + changeButton.className = 'button button-small'; + changeButton.type = 'button'; + changeButton.textContent = choice === 'source' ? 'Change applied resolution to source' : 'Change applied resolution to target'; + changeButton.addEventListener('click', function (record, choice, run) { + return function () { + fetchConflictResolution(record, choice, run, false, true); + }; + }(record, choice, run)); + changeActions.appendChild(changeButton); + }); + item.appendChild(changeActions); } list.appendChild(item); }); @@ -2222,6 +2310,22 @@ function conflictResolutionChoiceAvailable(record, choice) { } return true; } + function conflictResolutionChangeAvailable(record, choice) { + if (!record || !record.id || Number(record.latest_resolution_applied || 0) !== 1) { + return false; + } + if (record.conflict_type !== 'cell-conflict') { + return false; + } + var choices = Array.isArray(record.resolution_choices) ? record.resolution_choices : ['source', 'target']; + if (choices.indexOf(choice) === -1) { + return false; + } + if (record.blocked_resolution_choices && record.blocked_resolution_choices[choice]) { + return false; + } + return true; + } function conflictApplyReviewedAvailable(record) { if (!record || record.lifecycle_state === 'resolved') { return false; @@ -2290,7 +2394,7 @@ function fetchConflictReview(record, status, run) { results.textContent = error && error.message ? error.message : 'ForkPress conflict review failed.'; }); } - function fetchConflictResolution(record, choice, run, afterRevalidate) { + function fetchConflictResolution(record, choice, run, afterRevalidate, replaceApplied) { if (!record || !record.id) { return; } @@ -2305,6 +2409,9 @@ function fetchConflictResolution(record, choice, run, afterRevalidate) { if (afterRevalidate) { body.append('afterRevalidate', '1'); } + if (replaceApplied) { + body.append('replaceApplied', '1'); + } } if (run) { body.append('run', String(run)); @@ -3121,6 +3228,23 @@ function conflictResolutionChoiceAvailable(record, choice) { return true; } + function conflictResolutionChangeAvailable(record, choice) { + if (!record || !record.id || Number(record.latest_resolution_applied || 0) !== 1) { + return false; + } + if (record.conflict_type !== 'cell-conflict') { + return false; + } + var choices = Array.isArray(record.resolution_choices) ? record.resolution_choices : ['source', 'target']; + if (choices.indexOf(choice) === -1) { + return false; + } + if (record.blocked_resolution_choices && record.blocked_resolution_choices[choice]) { + return false; + } + return true; + } + function conflictApplyReviewedAvailable(record) { if (!record || record.lifecycle_state === 'resolved') { return false; @@ -3252,6 +3376,25 @@ function renderConflictAudit(payload, fallbackMessage) { actionsRow.appendChild(driverButton); } row.appendChild(actionsRow); + } else if (record.id && (conflictResolutionChangeAvailable(record, 'source') || conflictResolutionChangeAvailable(record, 'target'))) { + var changeRow = document.createElement('div'); + changeRow.className = 'forkpress-conflict-actions'; + ['source', 'target'].forEach(function (choice) { + if (!conflictResolutionChangeAvailable(record, choice)) { + return; + } + var changeButton = document.createElement('button'); + changeButton.className = 'forkpress-switcher-button'; + changeButton.type = 'button'; + changeButton.textContent = choice === 'source' ? 'Change applied resolution to source' : 'Change applied resolution to target'; + changeButton.addEventListener('click', function (record, choice) { + return function () { + fetchConflictResolution(record, choice, payload.run, false, true); + }; + }(record, choice)); + changeRow.appendChild(changeButton); + }); + row.appendChild(changeRow); } conflictList.appendChild(row); }); @@ -3713,7 +3856,7 @@ function fetchConflictReview(record, status, run) { }); } - function fetchConflictResolution(record, choice, run, afterRevalidate) { + function fetchConflictResolution(record, choice, run, afterRevalidate, replaceApplied) { if (!actions || !actions.resolveNonce || !window.fetch || !window.FormData || !record || !record.id) { return; } @@ -3728,6 +3871,9 @@ function fetchConflictResolution(record, choice, run, afterRevalidate) { if (afterRevalidate) { body.append('afterRevalidate', '1'); } + if (replaceApplied) { + body.append('replaceApplied', '1'); + } } if (run) { body.append('run', String(run)); From 3c34f8f3382c5b5d17b701a971a3c0bdff58b23f Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 20 May 2026 04:17:28 +0200 Subject: [PATCH 10/38] Fix WordPress media merge fixture MIME data --- tests/cow/merge.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/cow/merge.php b/tests/cow/merge.php index 1a5052b3..3521621a 100644 --- a/tests/cow/merge.php +++ b/tests/cow/merge.php @@ -16839,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)'); @@ -16915,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 = '' . '
'; @@ -17134,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' @@ -17713,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, @@ -17728,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, From 451828b5998a4eaa8e5a1643c8aa97439b5c534a Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 20 May 2026 11:31:17 +0200 Subject: [PATCH 11/38] Show plugin and theme conflict details in branch manager --- runtime/cow/router.php | 92 +++++++++++++++++++++++++++++++++++++-- tests/cow/router_lock.php | 4 ++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/runtime/cow/router.php b/runtime/cow/router.php index abd5c5bf..f7d739ca 100644 --- a/runtime/cow/router.php +++ b/runtime/cow/router.php @@ -1516,6 +1516,28 @@ function forkpress_cow_branch_manager_html(string $current_branch): string { } .fp-conflict-title { font-size: 13px; font-weight: 700; overflow-wrap: anywhere; } .fp-conflict-meta { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; } + .fp-conflict-plugin { + background: #f6f7f7; + border: 1px solid var(--line); + border-radius: 6px; + display: grid; + gap: 6px; + padding: 9px 10px; + } + .fp-conflict-plugin div { + display: grid; + gap: 2px; + } + .fp-conflict-plugin span:first-child { + color: var(--muted); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + } + .fp-conflict-plugin span:last-child { + font-size: 12px; + overflow-wrap: anywhere; + } .fp-conflict-grid { display: grid; gap: 8px; @@ -2104,12 +2126,34 @@ function conflictValue(record, key, previewKey) { return decodeAuditPayload(record[key]); } function conflictObjectLabel(record) { + if (record.table_name === '__plugins__' && record.plugin_object) return record.plugin_object; if (record.table_name && record.column_name) return record.table_name + '.' + record.column_name; if (record.path) return record.path; if (record.file_path) return record.file_path; if (record.plugin_object) return record.plugin_object; return record.conflict_key || ('Conflict #' + record.id); } + function conflictPluginMeta(record) { + if (!record || (record.table_name !== '__plugins__' && !record.plugin)) return ''; + return [ + record.semantic_scope ? 'scope: ' + String(record.semantic_scope) : '', + record.plugin ? 'plugin: ' + String(record.plugin) : '', + record.plugin_object ? 'object: ' + String(record.plugin_object) : '', + record.plugin_severity ? 'severity: ' + String(record.plugin_severity) : '', + record.plugin_validator ? 'validator: ' + String(record.plugin_validator) : '' + ].filter(Boolean).join(' / '); + } + function conflictPluginGuidance(record) { + if (!record || (record.table_name !== '__plugins__' && !record.plugin)) return ''; + return [ + record.plugin_resolution_policy ? 'policy: ' + String(record.plugin_resolution_policy) : '', + record.plugin_suggested_action ? 'action: ' + String(record.plugin_suggested_action) : '', + record.plugin_manual_review_reason ? 'manual review: ' + String(record.plugin_manual_review_reason) : '' + ].filter(Boolean).join(' / '); + } + function conflictIsPluginRecord(record) { + return !!(record && (record.table_name === '__plugins__' || record.plugin)); + } function conflictValueField(label, value) { var wrap = document.createElement('div'); wrap.className = 'fp-conflict-value'; @@ -2122,6 +2166,30 @@ function conflictValueField(label, value) { wrap.appendChild(textarea); return wrap; } + function appendConflictInfo(parent, label, value) { + if (value === undefined || value === null || value === '') return; + var row = document.createElement('div'); + row.appendChild(textNode('span', '', label)); + row.appendChild(textNode('span', '', Array.isArray(value) ? value.join(', ') : String(value))); + parent.appendChild(row); + } + function conflictPluginPanel(record) { + if (!conflictIsPluginRecord(record)) return null; + var panel = document.createElement('div'); + panel.className = 'fp-conflict-plugin'; + appendConflictInfo(panel, 'scope', record.semantic_scope || 'plugin'); + appendConflictInfo(panel, 'plugin', record.plugin); + appendConflictInfo(panel, 'object', record.plugin_object); + appendConflictInfo(panel, 'severity', record.plugin_severity); + appendConflictInfo(panel, 'validator', record.plugin_validator); + appendConflictInfo(panel, 'reason', record.plugin_reason); + appendConflictInfo(panel, 'policy', record.plugin_resolution_policy); + appendConflictInfo(panel, 'manual review', record.plugin_manual_review_reason); + appendConflictInfo(panel, 'suggested action', record.plugin_suggested_action); + appendConflictInfo(panel, 'tables', record.plugin_tables); + appendConflictInfo(panel, 'files', record.plugin_files); + return panel.childNodes.length ? panel : null; + } function conflictResolutionChangeAvailable(record) { if (!record || !record.id || Number(record.latest_resolution_applied || 0) !== 1) return false; if (record.conflict_type !== 'cell-conflict') return false; @@ -2185,9 +2253,11 @@ function renderConflicts(payload) { record.next_action, record.stale_status ? 'stale: ' + record.stale_status : '', record.review_status ? 'review: ' + record.review_status : '', - record.latest_resolution_status ? 'resolution: ' + record.latest_resolution_status : '' + record.latest_resolution_status ? 'resolution: ' + record.latest_resolution_status : '', + conflictPluginMeta(record) ].filter(Boolean); var meta = textNode('div', 'fp-conflict-meta', metaParts.join(' / ')); + var pluginPanel = conflictPluginPanel(record); var values = document.createElement('div'); values.className = 'fp-conflict-grid'; values.appendChild(conflictValueField('Base', conflictValue(record, 'base_payload', 'base_preview'))); @@ -2220,7 +2290,12 @@ function renderConflicts(payload) { var row = document.createElement('div'); row.className = 'fp-buttons'; var prioritizeResolutionChange = conflictResolutionChangeAvailable(record); - if (record.id && record.lifecycle_state !== 'resolved') { + var isPluginConflict = conflictIsPluginRecord(record); + if (isPluginConflict && record.id && record.lifecycle_state !== 'resolved') { + row.appendChild(button('Needs action', function () { reviewConflict(record.id, 'needs-action', payload.run, note.value); })); + row.appendChild(button('Mark reviewed', function () { reviewConflict(record.id, 'reviewed', payload.run, note.value); })); + row.appendChild(textNode('span', 'fp-conflict-meta', conflictPluginGuidance(record) || 'Plugin and theme validator conflicts are review-only here; use the suggested action or a plugin merge driver.')); + } else if (record.id && record.lifecycle_state !== 'resolved') { row.appendChild(button('Needs action', function () { reviewConflict(record.id, 'needs-action', payload.run, note.value); })); row.appendChild(button('Mark reviewed', function () { reviewConflict(record.id, 'reviewed', payload.run, note.value); })); row.appendChild(button('Apply selected', function () { resolveConflict(record.id, choice.value, payload.run, note.value); }, 'primary')); @@ -2236,7 +2311,18 @@ function renderConflicts(payload) { } node.appendChild(title); node.appendChild(meta); - if (prioritizeResolutionChange) { + if (pluginPanel) node.appendChild(pluginPanel); + if (isPluginConflict) { + node.appendChild(noteWrap); + node.appendChild(row); + var pluginPayload = conflictValue(record, 'chosen_payload', 'chosen_preview'); + if (pluginPayload) { + var pluginValues = document.createElement('div'); + pluginValues.className = 'fp-conflict-grid'; + pluginValues.appendChild(conflictValueField('Validator payload', pluginPayload)); + node.appendChild(pluginValues); + } + } else if (prioritizeResolutionChange) { node.appendChild(choiceWrap); node.appendChild(noteWrap); node.appendChild(row); diff --git a/tests/cow/router_lock.php b/tests/cow/router_lock.php index d62450f8..8358d9f9 100644 --- a/tests/cow/router_lock.php +++ b/tests/cow/router_lock.php @@ -268,6 +268,10 @@ function rm_tree(string $path): void { 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, '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, 'Branch actions'), 'out-of-band branch manager keeps create and merge actions available'); assert_true(!file_exists($started), 'out-of-band branch manager did not execute branch PHP'); From 0bd38cb65bbf11f04dc6b50e248e3852f6984164 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 20 May 2026 11:44:26 +0200 Subject: [PATCH 12/38] Show conflict entity context table --- runtime/cow/router.php | 613 +++++++++++++++++++++++++++++++++++++- scripts/cow/merge.php | 2 +- tests/cow/router_lock.php | 10 + 3 files changed, 621 insertions(+), 4 deletions(-) diff --git a/runtime/cow/router.php b/runtime/cow/router.php index f7d739ca..0453a428 100644 --- a/runtime/cow/router.php +++ b/runtime/cow/router.php @@ -644,8 +644,336 @@ function forkpress_cow_branch_tree_summary(array $report, int $limit): array { ]; } +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); +} + +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, + 'validator' => $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) { @@ -1498,6 +1826,60 @@ function forkpress_cow_branch_manager_html(string $current_branch): string { display: grid; gap: 10px; } + .fp-conflict-workbench { + display: none; + grid-column: 1 / -1; + } + .fp-conflict-workbench.is-visible { display: block; } + .fp-conflict-workbench-body { + display: grid; + gap: 14px; + padding: 14px 16px 16px; + } + .fp-conflict-table-wrap { + border: 1px solid var(--line); + border-radius: 6px; + overflow: auto; + } + .fp-conflict-table { + border-collapse: collapse; + min-width: 1160px; + width: 100%; + } + .fp-conflict-table th { + background: #f6f7f7; + border-bottom: 1px solid var(--line); + color: var(--muted); + font-size: 10px; + font-weight: 700; + padding: 8px; + position: sticky; + text-align: left; + text-transform: uppercase; + top: 0; + vertical-align: top; + z-index: 1; + } + .fp-conflict-table td { + border-top: 1px solid #f0f0f1; + font-size: 12px; + line-height: 1.35; + max-width: 260px; + overflow-wrap: anywhere; + padding: 9px 8px; + vertical-align: top; + } + .fp-conflict-table tbody tr:hover { background: #f6fbff; } + .fp-table-primary { font-weight: 700; } + .fp-table-muted { + color: var(--muted); + font-size: 11px; + margin-top: 3px; + } + .fp-conflict-details { + display: grid; + gap: 10px; + } .fp-conflict { border: 1px solid var(--line); border-left: 4px solid var(--conflict); @@ -1621,7 +2003,6 @@ function forkpress_cow_branch_manager_html(string $current_branch): string {
-

                 
             
@@ -1643,6 +2024,17 @@ function forkpress_cow_branch_manager_html(string $current_branch): string {
                 
             
         
+        
+
+
+

Conflict Review

+
Select a conflicting revision to inspect entity-level details.
+
+
+
+
+
+