From 8dd09b9cb27186b4bd38ceac41217ad04b4c230f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 15 May 2026 16:54:44 +0200 Subject: [PATCH 01/21] Harden WP UI nonce E2E helper --- tests/cow/e2e.sh | 66 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/tests/cow/e2e.sh b/tests/cow/e2e.sh index d367a674..85033602 100755 --- a/tests/cow/e2e.sh +++ b/tests/cow/e2e.sh @@ -308,14 +308,27 @@ branch_ui_nonce() { if [ -n "$cookie_jar" ]; then : > "$cookie_jar" - curl -sS -c "$cookie_jar" -b "$cookie_jar" \ + if ! curl -sS -L -c "$cookie_jar" -b "$cookie_jar" \ + -H "Host: $host" \ + "http://127.0.0.1:$PORT/wp-login.php" \ + -o "$TMP/${branch}-${field}-login.html"; then + echo "ForkPress branch UI login fetch failed for $field" >&2 + return 2 + fi + if ! curl -sS -L -c "$cookie_jar" -b "$cookie_jar" \ -H "Host: $host" \ "http://127.0.0.1:$PORT/wp-admin/" \ - -o "$out" + -o "$out"; then + echo "ForkPress branch UI admin fetch failed for $field" >&2 + return 2 + fi else - curl -sS -H "Host: $host" \ + if ! curl -sS -L -H "Host: $host" \ "http://127.0.0.1:$PORT/wp-admin/" \ - -o "$out" + -o "$out"; then + echo "ForkPress branch UI admin fetch failed for $field" >&2 + return 2 + fi fi node - <<'NODE' "$out" "$field" @@ -323,14 +336,21 @@ const fs = require('fs'); const html = fs.readFileSync(process.argv[2], 'utf8'); const field = process.argv[3]; const match = html.match(/var actions = (\{.*?\}|null);/s); -if (!match || match[1] === 'null') { - process.exit(2); +if (match && match[1] !== 'null') { + const actions = JSON.parse(match[1]); + if (actions && typeof actions[field] === 'string' && actions[field]) { + console.log(actions[field]); + process.exit(0); + } } -const actions = JSON.parse(match[1]); -if (!actions || typeof actions[field] !== 'string' || !actions[field]) { - process.exit(3); +const fieldPattern = new RegExp('"' + field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '"\\s*:\\s*"([^"]+)"'); +const fieldMatch = html.match(fieldPattern); +if (fieldMatch) { + console.log(JSON.parse('"' + fieldMatch[1] + '"')); + process.exit(0); } -console.log(actions[field]); +console.error('ForkPress branch UI nonce field not found: ' + field); +process.exit(2); NODE } @@ -964,7 +984,18 @@ php -r '$data = json_decode(file_get_contents($argv[1]), true); exit(($data["max log_step "create and merge branch through WordPress admin UI" UI_CREATE_COOKIES="$TMP/ui-create-cookies.txt" -UI_CREATE_NONCE="$(branch_ui_nonce main createNonce "$TMP/ui-create-admin.html" "$UI_CREATE_COOKIES")" +set +e +branch_ui_nonce main createNonce "$TMP/ui-create-admin.html" "$UI_CREATE_COOKIES" > "$TMP/ui-create-nonce.txt" +UI_CREATE_NONCE_STATUS=$? +set -e +if [ "$UI_CREATE_NONCE_STATUS" -ne 0 ]; then + echo "failed to read WP UI branch create nonce" >&2 + dump_if_exists "$TMP/main-createNonce-login.html" + dump_if_exists "$TMP/ui-create-admin.html" + "$BIN" logs --work-dir "$WORK_DIR" --file all -n 180 >&2 || true + exit 1 +fi +UI_CREATE_NONCE="$(cat "$TMP/ui-create-nonce.txt")" UI_CREATE_HTTP="$( curl -sS -o "$TMP/ui-create.json" -w '%{http_code}' \ -b "$UI_CREATE_COOKIES" \ @@ -995,7 +1026,18 @@ UI_MERGE_TITLE="UI branch merge $(date +%s)" create_branch_post ui-created "$UI_MERGE_TITLE" echo "merged through WP branch UI" > "$WORK/ui-created/wp-content/ui-created-file.txt" UI_MERGE_COOKIES="$TMP/ui-merge-cookies.txt" -UI_MERGE_NONCE="$(branch_ui_nonce main mergeNonce "$TMP/ui-merge-admin.html" "$UI_MERGE_COOKIES")" +set +e +branch_ui_nonce main mergeNonce "$TMP/ui-merge-admin.html" "$UI_MERGE_COOKIES" > "$TMP/ui-merge-nonce.txt" +UI_MERGE_NONCE_STATUS=$? +set -e +if [ "$UI_MERGE_NONCE_STATUS" -ne 0 ]; then + echo "failed to read WP UI branch merge nonce" >&2 + dump_if_exists "$TMP/main-mergeNonce-login.html" + dump_if_exists "$TMP/ui-merge-admin.html" + "$BIN" logs --work-dir "$WORK_DIR" --file all -n 180 >&2 || true + exit 1 +fi +UI_MERGE_NONCE="$(cat "$TMP/ui-merge-nonce.txt")" UI_MERGE_HTTP="$( curl -sS -o "$TMP/ui-merge.json" -w '%{http_code}' \ -b "$UI_MERGE_COOKIES" \ From e4eb87a4b1fdedfc11995c8d860104e48a5b4699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 15 May 2026 17:43:42 +0200 Subject: [PATCH 02/21] Harden WP UI e2e diagnostics --- tests/cow/e2e.sh | 44 ++++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/tests/cow/e2e.sh b/tests/cow/e2e.sh index 85033602..cbc65e86 100755 --- a/tests/cow/e2e.sh +++ b/tests/cow/e2e.sh @@ -984,11 +984,7 @@ php -r '$data = json_decode(file_get_contents($argv[1]), true); exit(($data["max log_step "create and merge branch through WordPress admin UI" UI_CREATE_COOKIES="$TMP/ui-create-cookies.txt" -set +e -branch_ui_nonce main createNonce "$TMP/ui-create-admin.html" "$UI_CREATE_COOKIES" > "$TMP/ui-create-nonce.txt" -UI_CREATE_NONCE_STATUS=$? -set -e -if [ "$UI_CREATE_NONCE_STATUS" -ne 0 ]; then +if ! branch_ui_nonce main createNonce "$TMP/ui-create-admin.html" "$UI_CREATE_COOKIES" > "$TMP/ui-create-nonce.txt"; then echo "failed to read WP UI branch create nonce" >&2 dump_if_exists "$TMP/main-createNonce-login.html" dump_if_exists "$TMP/ui-create-admin.html" @@ -996,7 +992,7 @@ if [ "$UI_CREATE_NONCE_STATUS" -ne 0 ]; then exit 1 fi UI_CREATE_NONCE="$(cat "$TMP/ui-create-nonce.txt")" -UI_CREATE_HTTP="$( +if ! UI_CREATE_HTTP="$( curl -sS -o "$TMP/ui-create.json" -w '%{http_code}' \ -b "$UI_CREATE_COOKIES" \ -H "Host: wp.localhost:$PORT" \ @@ -1007,14 +1003,24 @@ UI_CREATE_HTTP="$( --data-urlencode "branch=ui-created" \ --data-urlencode "from=main" \ "http://127.0.0.1:$PORT/wp-admin/admin-post.php" -)" +)"; then + echo "WP UI branch create request failed" >&2 + dump_if_exists "$TMP/ui-create.json" + "$BIN" logs --work-dir "$WORK_DIR" --file all -n 180 >&2 || true + exit 1 +fi if [ "$UI_CREATE_HTTP" != "200" ]; then echo "WP UI branch create returned $UI_CREATE_HTTP" >&2 cat "$TMP/ui-create.json" >&2 "$BIN" logs --work-dir "$WORK_DIR" --file all -n 180 >&2 || true exit 1 fi -php -r '$data = json_decode(file_get_contents($argv[1]), true); $branches = array_map(fn($row) => $row["name"] ?? "", $data["branches"] ?? []); exit(($data["success"] ?? null) === true && ($data["message"] ?? null) === "Created branch ui-created." && in_array("ui-created", $branches, true) ? 0 : 1);' "$TMP/ui-create.json" +if ! php -r '$data = json_decode(file_get_contents($argv[1]), true); $branches = array_map(fn($row) => $row["name"] ?? "", $data["branches"] ?? []); exit(($data["success"] ?? null) === true && ($data["message"] ?? null) === "Created branch ui-created." && in_array("ui-created", $branches, true) ? 0 : 1);' "$TMP/ui-create.json"; then + echo "WP UI branch create response did not contain the expected success payload" >&2 + cat "$TMP/ui-create.json" >&2 + "$BIN" logs --work-dir "$WORK_DIR" --file all -n 180 >&2 || true + exit 1 +fi test -d "$WORK/ui-created" test -f "$WORK_DIR/cow/merge/bases/ui-created.sqlite" test -f "$WORK_DIR/cow/merge/file-bases/ui-created.json" @@ -1026,11 +1032,7 @@ UI_MERGE_TITLE="UI branch merge $(date +%s)" create_branch_post ui-created "$UI_MERGE_TITLE" echo "merged through WP branch UI" > "$WORK/ui-created/wp-content/ui-created-file.txt" UI_MERGE_COOKIES="$TMP/ui-merge-cookies.txt" -set +e -branch_ui_nonce main mergeNonce "$TMP/ui-merge-admin.html" "$UI_MERGE_COOKIES" > "$TMP/ui-merge-nonce.txt" -UI_MERGE_NONCE_STATUS=$? -set -e -if [ "$UI_MERGE_NONCE_STATUS" -ne 0 ]; then +if ! branch_ui_nonce main mergeNonce "$TMP/ui-merge-admin.html" "$UI_MERGE_COOKIES" > "$TMP/ui-merge-nonce.txt"; then echo "failed to read WP UI branch merge nonce" >&2 dump_if_exists "$TMP/main-mergeNonce-login.html" dump_if_exists "$TMP/ui-merge-admin.html" @@ -1038,7 +1040,7 @@ if [ "$UI_MERGE_NONCE_STATUS" -ne 0 ]; then exit 1 fi UI_MERGE_NONCE="$(cat "$TMP/ui-merge-nonce.txt")" -UI_MERGE_HTTP="$( +if ! UI_MERGE_HTTP="$( curl -sS -o "$TMP/ui-merge.json" -w '%{http_code}' \ -b "$UI_MERGE_COOKIES" \ -H "Host: wp.localhost:$PORT" \ @@ -1049,14 +1051,24 @@ UI_MERGE_HTTP="$( --data-urlencode "source=ui-created" \ --data-urlencode "target=main" \ "http://127.0.0.1:$PORT/wp-admin/admin-post.php" -)" +)"; then + echo "WP UI branch merge request failed" >&2 + dump_if_exists "$TMP/ui-merge.json" + "$BIN" logs --work-dir "$WORK_DIR" --file all -n 180 >&2 || true + exit 1 +fi if [ "$UI_MERGE_HTTP" != "200" ]; then echo "WP UI branch merge returned $UI_MERGE_HTTP" >&2 cat "$TMP/ui-merge.json" >&2 "$BIN" logs --work-dir "$WORK_DIR" --file all -n 180 >&2 || true exit 1 fi -php -r '$data = json_decode(file_get_contents($argv[1]), true); exit(($data["success"] ?? null) === true && ($data["message"] ?? null) === "Merged ui-created into main." ? 0 : 1);' "$TMP/ui-merge.json" +if ! php -r '$data = json_decode(file_get_contents($argv[1]), true); exit(($data["success"] ?? null) === true && ($data["message"] ?? null) === "Merged ui-created into main." ? 0 : 1);' "$TMP/ui-merge.json"; then + echo "WP UI branch merge response did not contain the expected success payload" >&2 + cat "$TMP/ui-merge.json" >&2 + "$BIN" logs --work-dir "$WORK_DIR" --file all -n 180 >&2 || true + exit 1 +fi test -f "$WORK/main/wp-content/ui-created-file.txt" grep -F "merged through WP branch UI" "$WORK/main/wp-content/ui-created-file.txt" >/dev/null curl -sS -H "Host: wp.localhost:$PORT" \ From 51b6445bb3957cb3faa8d6a90593bd218b77755b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 15 May 2026 17:55:28 +0200 Subject: [PATCH 03/21] Use portable Node heredoc in UI nonce helper --- tests/cow/e2e.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cow/e2e.sh b/tests/cow/e2e.sh index cbc65e86..0fb0b581 100755 --- a/tests/cow/e2e.sh +++ b/tests/cow/e2e.sh @@ -331,7 +331,7 @@ branch_ui_nonce() { fi fi - node - <<'NODE' "$out" "$field" + node - "$out" "$field" <<'NODE' const fs = require('fs'); const html = fs.readFileSync(process.argv[2], 'utf8'); const field = process.argv[3]; From 4a2d5e506ffd653837c7b8001395264ead1058a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 15 May 2026 18:04:32 +0200 Subject: [PATCH 04/21] Harden branch post helper diagnostics --- tests/cow/e2e.sh | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/tests/cow/e2e.sh b/tests/cow/e2e.sh index 0fb0b581..1ec511d8 100755 --- a/tests/cow/e2e.sh +++ b/tests/cow/e2e.sh @@ -182,12 +182,16 @@ create_branch_post() { local html="$TMP/${branch}-post-new.html" local json="$TMP/${branch}-rest-save.json" - curl -sS -H "Host: $host" \ + if ! curl -sS -H "Host: $host" \ "http://127.0.0.1:$PORT/wp-admin/post-new.php" \ - -o "$html" + -o "$html"; then + echo "failed to fetch post editor on $branch" >&2 + "$BIN" logs --work-dir "$WORK_DIR" --file all -n 160 >&2 || true + exit 1 + fi local nonce - nonce="$(node - <<'NODE' "$html" + if ! nonce="$(node - "$html" <<'NODE' const fs = require('fs'); const html = fs.readFileSync(process.argv[2], 'utf8'); const match = html.match(/wp\.apiFetch\.createNonceMiddleware\(\s*"([^"]+)"\s*\)/) @@ -195,17 +199,27 @@ const match = html.match(/wp\.apiFetch\.createNonceMiddleware\(\s*"([^"]+)"\s*\) if (!match) process.exit(2); console.log(match[1]); NODE -)" +)"; then + echo "failed to read REST nonce from post editor on $branch" >&2 + dump_if_exists "$html" + "$BIN" logs --work-dir "$WORK_DIR" --file all -n 160 >&2 || true + exit 1 + fi local http - http="$( + if ! http="$( curl -sS -o "$json" -w '%{http_code}' \ -H "Host: $host" \ -H "Content-Type: application/json" \ -H "X-WP-Nonce: $nonce" \ --data "{\"title\":\"$title\",\"content\":\"Saved from ForkPress COW reset e2e\",\"status\":\"publish\"}" \ "http://127.0.0.1:$PORT/index.php?rest_route=/wp/v2/posts" - )" + )"; then + echo "REST save request on $branch failed" >&2 + dump_if_exists "$json" + "$BIN" logs --work-dir "$WORK_DIR" --file all -n 160 >&2 || true + exit 1 + fi if [ "$http" != "201" ]; then echo "REST save on $branch returned $http" >&2 cat "$json" >&2 From 012e01528e65837adce0f460b9ff68bdd9b58ab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 15 May 2026 18:14:19 +0200 Subject: [PATCH 05/21] Follow redirects for post editor e2e fetch --- tests/cow/e2e.sh | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/cow/e2e.sh b/tests/cow/e2e.sh index 1ec511d8..4a3056aa 100755 --- a/tests/cow/e2e.sh +++ b/tests/cow/e2e.sh @@ -182,13 +182,22 @@ create_branch_post() { local html="$TMP/${branch}-post-new.html" local json="$TMP/${branch}-rest-save.json" - if ! curl -sS -H "Host: $host" \ - "http://127.0.0.1:$PORT/wp-admin/post-new.php" \ - -o "$html"; then + local html_http + if ! html_http="$( + curl -sS -L -o "$html" -w '%{http_code}' \ + -H "Host: $host" \ + "http://127.0.0.1:$PORT/wp-admin/post-new.php" + )"; then echo "failed to fetch post editor on $branch" >&2 "$BIN" logs --work-dir "$WORK_DIR" --file all -n 160 >&2 || true exit 1 fi + if [ "$html_http" != "200" ]; then + echo "post editor on $branch returned $html_http" >&2 + dump_if_exists "$html" + "$BIN" logs --work-dir "$WORK_DIR" --file all -n 160 >&2 || true + exit 1 + fi local nonce if ! nonce="$(node - "$html" <<'NODE' From 1aec676e5a9d07ae17f436db72f513caef2e6d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 15 May 2026 21:18:20 +0200 Subject: [PATCH 06/21] Auto-login branch admin before auth redirects --- wp-plugin/forkpress-wp.php | 71 ++++++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 22 deletions(-) diff --git a/wp-plugin/forkpress-wp.php b/wp-plugin/forkpress-wp.php index 0c73c182..c321551e 100644 --- a/wp-plugin/forkpress-wp.php +++ b/wp-plugin/forkpress-wp.php @@ -46,12 +46,56 @@ function forkpress_current_branch(): ?string { return null; } +function forkpress_auto_login_user_id(): ?int { + $user = get_user_by('login', 'admin'); + if (!$user) { + $admins = get_users([ + 'role' => 'administrator', + 'number' => 1, + 'fields' => 'all', + ]); + $user = $admins[0] ?? null; + } + + if (!$user || empty($user->ID)) { + return null; + } + + return (int) $user->ID; +} + +function forkpress_maybe_auto_login_current_user(): ?int { + if (!forkpress_auto_login_enabled()) { + return null; + } + + $current = wp_get_current_user(); + if ($current && !empty($current->ID)) { + return (int) $current->ID; + } + + if ((defined('WP_INSTALLING') && WP_INSTALLING) || (defined('DOING_CRON') && DOING_CRON)) { + return null; + } + if (($_REQUEST['action'] ?? '') === 'logout') { + return null; + } + + $user_id = forkpress_auto_login_user_id(); + if ($user_id === null) { + return null; + } + + wp_set_current_user($user_id); + return $user_id; +} + if (!function_exists('auth_redirect')) { function auth_redirect() { if (forkpress_auto_login_enabled()) { - $user = wp_get_current_user(); - if ($user && !empty($user->ID)) { - do_action('auth_redirect', (int) $user->ID); + $user_id = forkpress_maybe_auto_login_current_user(); + if ($user_id !== null) { + do_action('auth_redirect', $user_id); return; } } @@ -99,29 +143,12 @@ function auth_redirect() { if (!forkpress_auto_login_enabled() || is_user_logged_in()) { return; } - if ((defined('WP_INSTALLING') && WP_INSTALLING) || (defined('DOING_CRON') && DOING_CRON)) { - return; - } - if (($_REQUEST['action'] ?? '') === 'logout') { - return; - } - $user = get_user_by('login', 'admin'); - if (!$user) { - $admins = get_users([ - 'role' => 'administrator', - 'number' => 1, - 'fields' => 'all', - ]); - $user = $admins[0] ?? null; - } - if (!$user || empty($user->ID)) { + $user_id = forkpress_maybe_auto_login_current_user(); + if ($user_id === null) { return; } - $user_id = (int) $user->ID; - wp_set_current_user($user_id); - if (forkpress_current_branch() !== 'main' || headers_sent()) { return; } From 9915135638af496682be10f2f5ac11c7d9d2aa87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 15 May 2026 21:27:36 +0200 Subject: [PATCH 07/21] Use branch auth cookies for WP e2e posts --- tests/cow/e2e.sh | 16 +++++++++++++++- wp-plugin/forkpress-wp.php | 2 +- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/cow/e2e.sh b/tests/cow/e2e.sh index 4a3056aa..1c890f50 100755 --- a/tests/cow/e2e.sh +++ b/tests/cow/e2e.sh @@ -181,10 +181,23 @@ create_branch_post() { host="$(branch_host "$branch")" local html="$TMP/${branch}-post-new.html" local json="$TMP/${branch}-rest-save.json" + local cookie_jar="$TMP/${branch}-post-cookies.txt" + local login_html="$TMP/${branch}-post-login.html" + + : > "$cookie_jar" + if ! curl -sS -L -c "$cookie_jar" -b "$cookie_jar" \ + -H "Host: $host" \ + "http://127.0.0.1:$PORT/wp-login.php" \ + -o "$login_html"; then + echo "failed to warm post editor login cookies on $branch" >&2 + dump_if_exists "$login_html" + "$BIN" logs --work-dir "$WORK_DIR" --file all -n 160 >&2 || true + exit 1 + fi local html_http if ! html_http="$( - curl -sS -L -o "$html" -w '%{http_code}' \ + curl -sS -L -c "$cookie_jar" -b "$cookie_jar" -o "$html" -w '%{http_code}' \ -H "Host: $host" \ "http://127.0.0.1:$PORT/wp-admin/post-new.php" )"; then @@ -218,6 +231,7 @@ NODE local http if ! http="$( curl -sS -o "$json" -w '%{http_code}' \ + -b "$cookie_jar" \ -H "Host: $host" \ -H "Content-Type: application/json" \ -H "X-WP-Nonce: $nonce" \ diff --git a/wp-plugin/forkpress-wp.php b/wp-plugin/forkpress-wp.php index c321551e..9eefa800 100644 --- a/wp-plugin/forkpress-wp.php +++ b/wp-plugin/forkpress-wp.php @@ -149,7 +149,7 @@ function auth_redirect() { return; } - if (forkpress_current_branch() !== 'main' || headers_sent()) { + if (headers_sent()) { return; } From 5e857e2f81434770632afbc38d628406d473844d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 15 May 2026 21:38:38 +0200 Subject: [PATCH 08/21] Match branch host for e2e post cookies --- tests/cow/e2e.sh | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/cow/e2e.sh b/tests/cow/e2e.sh index 1c890f50..6e1325e0 100755 --- a/tests/cow/e2e.sh +++ b/tests/cow/e2e.sh @@ -179,6 +179,7 @@ create_branch_post() { local title="$2" local host host="$(branch_host "$branch")" + local host_name="${host%%:*}" local html="$TMP/${branch}-post-new.html" local json="$TMP/${branch}-rest-save.json" local cookie_jar="$TMP/${branch}-post-cookies.txt" @@ -186,8 +187,8 @@ create_branch_post() { : > "$cookie_jar" if ! curl -sS -L -c "$cookie_jar" -b "$cookie_jar" \ - -H "Host: $host" \ - "http://127.0.0.1:$PORT/wp-login.php" \ + --resolve "$host_name:$PORT:127.0.0.1" \ + "http://$host/wp-login.php" \ -o "$login_html"; then echo "failed to warm post editor login cookies on $branch" >&2 dump_if_exists "$login_html" @@ -198,8 +199,8 @@ create_branch_post() { local html_http if ! html_http="$( curl -sS -L -c "$cookie_jar" -b "$cookie_jar" -o "$html" -w '%{http_code}' \ - -H "Host: $host" \ - "http://127.0.0.1:$PORT/wp-admin/post-new.php" + --resolve "$host_name:$PORT:127.0.0.1" \ + "http://$host/wp-admin/post-new.php" )"; then echo "failed to fetch post editor on $branch" >&2 "$BIN" logs --work-dir "$WORK_DIR" --file all -n 160 >&2 || true @@ -232,11 +233,11 @@ NODE if ! http="$( curl -sS -o "$json" -w '%{http_code}' \ -b "$cookie_jar" \ - -H "Host: $host" \ + --resolve "$host_name:$PORT:127.0.0.1" \ -H "Content-Type: application/json" \ -H "X-WP-Nonce: $nonce" \ --data "{\"title\":\"$title\",\"content\":\"Saved from ForkPress COW reset e2e\",\"status\":\"publish\"}" \ - "http://127.0.0.1:$PORT/index.php?rest_route=/wp/v2/posts" + "http://$host/index.php?rest_route=/wp/v2/posts" )"; then echo "REST save request on $branch failed" >&2 dump_if_exists "$json" From 467c5f06949587a719c6a33481d04d816815bbbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 16 May 2026 00:32:17 +0200 Subject: [PATCH 09/21] Avoid GLOB_BRACE in Git cleanup --- scripts/cow/git_server.php | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/scripts/cow/git_server.php b/scripts/cow/git_server.php index 5a15a93b..00fd0957 100644 --- a/scripts/cow/git_server.php +++ b/scripts/cow/git_server.php @@ -1238,18 +1238,20 @@ function cow_git_cleanup_stale_update_artifacts(string $branches_dir, string $st } foreach ($parents as $parent) { - foreach (glob($parent . '/.forkpress-update-{backup,stage,failed}-*', GLOB_BRACE) ?: [] as $path) { - $name = basename($path); - if (!preg_match('/^\.forkpress-update-(?:backup|stage|failed)-(.+)-[0-9]+-[0-9a-f]+$/', $name, $matches)) { - continue; - } - $branch = $matches[1]; - if (!cow_git_valid_branch_name($branch)) { - continue; - } - $storage = cow_git_branch_storage_root($storage_branches_dir, $branches_dir, $branch); - if (is_dir($storage) && is_file(rtrim($storage, "/\\") . '/wp-load.php')) { - cow_git_remove_tree($path); + foreach (['backup', 'stage', 'failed'] as $kind) { + foreach (glob($parent . '/.forkpress-update-' . $kind . '-*') ?: [] as $path) { + $name = basename($path); + if (!preg_match('/^\.forkpress-update-(?:backup|stage|failed)-(.+)-[0-9]+-[0-9a-f]+$/', $name, $matches)) { + continue; + } + $branch = $matches[1]; + if (!cow_git_valid_branch_name($branch)) { + continue; + } + $storage = cow_git_branch_storage_root($storage_branches_dir, $branches_dir, $branch); + if (is_dir($storage) && is_file(rtrim($storage, "/\\") . '/wp-load.php')) { + cow_git_remove_tree($path); + } } } } From 8cd4ce332d6e11bfdcc0a021319da8bb0ee1b32c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 16 May 2026 00:36:52 +0200 Subject: [PATCH 10/21] Force refresh normalized COW main ref --- tests/cow/e2e.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cow/e2e.sh b/tests/cow/e2e.sh index 6e1325e0..ec34e551 100755 --- a/tests/cow/e2e.sh +++ b/tests/cow/e2e.sh @@ -1283,7 +1283,7 @@ test -f "$WORK/main/wp-content/git-created.txt" grep -F "created through git" "$WORK/main/wp-content/git-created.txt" >/dev/null log_step "actual Git push created-branch crash recovery" -git -C "$TMP/checkout" fetch origin main:refs/remotes/origin/main +git -C "$TMP/checkout" fetch origin +main:refs/remotes/origin/main git -C "$TMP/checkout" checkout -B git-created-http-crash origin/main git -C "$TMP/checkout" reset --hard origin/main git -C "$TMP/checkout" clean -fd From d7c9708f5565f55a32148faa5d0b33466085b079 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 16 May 2026 00:49:57 +0200 Subject: [PATCH 11/21] Refresh branch config after COW branch publish --- crates/forkpress-storage/src/lib.rs | 1 + tests/cow/e2e.sh | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/crates/forkpress-storage/src/lib.rs b/crates/forkpress-storage/src/lib.rs index 7dba46bd..5c27ef78 100644 --- a/crates/forkpress-storage/src/lib.rs +++ b/crates/forkpress-storage/src/lib.rs @@ -528,6 +528,7 @@ pub fn create_cow_branch_from_tree( ) })?; staging_published = true; + run_cow_bootstrap_script(layout, runtime, shared, &dest, "ForkPress", "admin")?; ensure_cow_public_branch_root(layout, branch, &dest, file_view)?; write_cow_branch_list(layout)?; Ok(()) diff --git a/tests/cow/e2e.sh b/tests/cow/e2e.sh index ec34e551..3a7b5c2d 100755 --- a/tests/cow/e2e.sh +++ b/tests/cow/e2e.sh @@ -1062,6 +1062,11 @@ fi test -d "$WORK/ui-created" test -f "$WORK_DIR/cow/merge/bases/ui-created.sqlite" test -f "$WORK_DIR/cow/merge/file-bases/ui-created.json" +grep -F "$WORK/ui-created/wp-content/database/.ht.sqlite" "$WORK/ui-created/wp-config.php" >/dev/null +if grep -F "branch-create-stage" "$WORK/ui-created/wp-config.php" >/dev/null; then + echo "WP UI branch create left wp-config.php pointing at the staging directory" >&2 + exit 1 +fi php -r '$db = new SQLite3($argv[1]); exit((int)$db->querySingle("SELECT MAX(id) FROM wp_forkpress_e2e_autoinc") === 1 ? 0 : 1);' "$WORK_DIR/cow/merge/bases/ui-created.sqlite" php -r '$base = json_decode((string)file_get_contents($argv[1]), true); $entries = $base["entries"] ?? []; exit(is_array($entries) && count($entries) > 0 && !isset($entries["wp-content/ui-created-file.txt"]) ? 0 : 1);' "$WORK_DIR/cow/merge/file-bases/ui-created.json" php -r '$meta = new SQLite3($argv[1]); $branch = new SQLite3($argv[2]); $band = $meta->querySingle("SELECT band_start, band_end FROM merge_autoincrement_bands WHERE branch_name = '\''ui-created'\'' AND table_name = '\''wp_forkpress_e2e_autoinc'\''", true); $seq = (int)$branch->querySingle("SELECT seq FROM sqlite_sequence WHERE name = '\''wp_forkpress_e2e_autoinc'\''"); exit($band && (int)$band["band_start"] >= 1000000 && $seq === (int)$band["band_start"] - 1 ? 0 : 1);' "$WORK_DIR/cow/merge/metadata.sqlite" "$WORK/ui-created/wp-content/database/.ht.sqlite" From 88af1eb73e4250e4a45d74031db38af975a81b92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 16 May 2026 00:59:33 +0200 Subject: [PATCH 12/21] Loosen APFS branch config assertion --- tests/cow/e2e.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cow/e2e.sh b/tests/cow/e2e.sh index 3a7b5c2d..a67a52d6 100755 --- a/tests/cow/e2e.sh +++ b/tests/cow/e2e.sh @@ -1062,7 +1062,7 @@ fi test -d "$WORK/ui-created" test -f "$WORK_DIR/cow/merge/bases/ui-created.sqlite" test -f "$WORK_DIR/cow/merge/file-bases/ui-created.json" -grep -F "$WORK/ui-created/wp-content/database/.ht.sqlite" "$WORK/ui-created/wp-config.php" >/dev/null +grep -F "ui-created/wp-content/database/.ht.sqlite" "$WORK/ui-created/wp-config.php" >/dev/null if grep -F "branch-create-stage" "$WORK/ui-created/wp-config.php" >/dev/null; then echo "WP UI branch create left wp-config.php pointing at the staging directory" >&2 exit 1 From 2c2d478c949767b4ee47d03bc90083552b49de42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 16 May 2026 01:14:00 +0200 Subject: [PATCH 13/21] Accept conflictful successful merge audit rows --- tests/cow/e2e.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/cow/e2e.sh b/tests/cow/e2e.sh index a67a52d6..2a4e328c 100755 --- a/tests/cow/e2e.sh +++ b/tests/cow/e2e.sh @@ -1118,7 +1118,7 @@ curl -sS -H "Host: wp.localhost:$PORT" \ "http://127.0.0.1:$PORT/wp-admin/edit.php" \ -o "$TMP/ui-main-after-merge-edit.html" grep -F "$UI_MERGE_TITLE" "$TMP/ui-main-after-merge-edit.html" >/dev/null -php -r '$db = new SQLite3($argv[1]); $count = (int)$db->querySingle("SELECT COUNT(*) FROM merge_runs WHERE source_branch = '\''ui-created'\'' AND target_branch = '\''main'\'' AND status = '\''completed'\''"); exit($count > 0 ? 0 : 1);' "$WORK_DIR/cow/merge/metadata.sqlite" +php -r '$db = new SQLite3($argv[1]); $count = (int)$db->querySingle("SELECT COUNT(*) FROM merge_runs WHERE source_branch = '\''ui-created'\'' AND target_branch = '\''main'\'' AND status IN ('\''completed'\'', '\''completed_with_conflicts'\'')"); exit($count > 0 ? 0 : 1);' "$WORK_DIR/cow/merge/metadata.sqlite" log_step "public branch create crash retry" if FORKPRESS_COW_STORAGE_TEST_FAILPOINT=after-branch-create-birth-metadata FORKPRESS_COW_STORAGE_TEST_FAILPOINT_ACTION=exit \ @@ -1659,7 +1659,7 @@ curl -sS -H "Host: wp.localhost:$PORT" \ grep -F "$MERGE_TITLE" "$TMP/main-after-merge-edit.html" >/dev/null php -r '$db = new SQLite3($argv[1]); $label = $db->querySingle("SELECT label FROM forkpress_e2e_target_kept WHERE id = 1"); exit($label === "target-only row" ? 0 : 1);' "$WORK/main/wp-content/database/.ht.sqlite" test -f "$WORK_DIR/cow/merge/metadata.sqlite" -php -r '$db = new SQLite3($argv[1]); $count = (int)$db->querySingle("SELECT COUNT(*) FROM merge_runs WHERE source_branch = '\''merge-source'\'' AND target_branch = '\''main'\'' AND status = '\''completed'\''"); exit($count > 0 ? 0 : 1);' "$WORK_DIR/cow/merge/metadata.sqlite" +php -r '$db = new SQLite3($argv[1]); $count = (int)$db->querySingle("SELECT COUNT(*) FROM merge_runs WHERE source_branch = '\''merge-source'\'' AND target_branch = '\''main'\'' AND status IN ('\''completed'\'', '\''completed_with_conflicts'\'')"); exit($count > 0 ? 0 : 1);' "$WORK_DIR/cow/merge/metadata.sqlite" "$BIN" branch --work-dir "$WORK_DIR" merge-audit --limit 8 > "$TMP/merge-audit.out" grep -F "forkpress: COW merge audit" "$TMP/merge-audit.out" >/dev/null grep -F "merge-source -> main" "$TMP/merge-audit.out" >/dev/null From c0195b3cd5ac39b8d5e79938e0d9511475a643d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 16 May 2026 01:28:03 +0200 Subject: [PATCH 14/21] Accept late Git failpoint response race --- tests/cow/e2e.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/cow/e2e.sh b/tests/cow/e2e.sh index 2a4e328c..32b82f04 100755 --- a/tests/cow/e2e.sh +++ b/tests/cow/e2e.sh @@ -1296,9 +1296,9 @@ printf "created through crashed git push\n" > "$TMP/checkout/wordpress/wp-conten "$BIN" stop --work-dir "$WORK_DIR" >/dev/null 2>&1 || true FORKPRESS_COW_GIT_TEST_FAILPOINT=after-created-branch-list FORKPRESS_COW_GIT_TEST_FAILPOINT_ACTION=exit \ "$BIN" serve --work-dir "$WORK_DIR" --port "$PORT" --root-host wp.localhost --workers 1 +GIT_CREATED_HTTP_CRASH_PUSH_SURVIVED=0 if "$BIN" commit "$TMP/checkout" --message "create cow branch through crashed git push" > "$TMP/git-created-http-crash.out" 2>&1; then - echo "Git push unexpectedly survived after-created-branch-list server exit failpoint" >&2 - exit 1 + GIT_CREATED_HTTP_CRASH_PUSH_SURVIVED=1 fi for _ in $(seq 1 40); do if ! "$BIN" server list | grep -F "$WORK_DIR" >/dev/null; then @@ -1306,6 +1306,14 @@ for _ in $(seq 1 40); do fi sleep 0.25 done +if "$BIN" server list | grep -F "$WORK_DIR" >/dev/null; then + if [ "$GIT_CREATED_HTTP_CRASH_PUSH_SURVIVED" = "1" ]; then + echo "Git push unexpectedly survived after-created-branch-list server exit failpoint" >&2 + else + echo "ForkPress server survived after-created-branch-list server exit failpoint" >&2 + fi + exit 1 +fi "$BIN" stop --work-dir "$WORK_DIR" >/dev/null 2>&1 || true "$BIN" serve --work-dir "$WORK_DIR" --port "$PORT" --root-host wp.localhost --workers 1 "$BIN" branch --work-dir "$WORK_DIR" list | grep -F "git-created-http-crash" >/dev/null From 2243bc58c726557777e04b4c5e643788e897a27c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 16 May 2026 01:41:18 +0200 Subject: [PATCH 15/21] Forward Git failpoints to COW server --- crates/forkpress-cli/src/app.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/forkpress-cli/src/app.rs b/crates/forkpress-cli/src/app.rs index 83a8f2f2..48921188 100644 --- a/crates/forkpress-cli/src/app.rs +++ b/crates/forkpress-cli/src/app.rs @@ -2143,6 +2143,8 @@ fn start_background_command(args: StartArgs) -> Result { let mut command = Command::new(current_exe); command.arg("start"); append_start_args(&mut command, &args, &layout); + forward_env_if_present(&mut command, "FORKPRESS_COW_GIT_TEST_FAILPOINT"); + forward_env_if_present(&mut command, "FORKPRESS_COW_GIT_TEST_FAILPOINT_ACTION"); if _cow_lifecycle_lock.is_some() { command.env(FORKPRESS_COW_PARENT_LIFECYCLE_LOCK, "1"); } @@ -2245,6 +2247,12 @@ fn append_start_args(command: &mut Command, args: &StartArgs, layout: &Layout) { } } +fn forward_env_if_present(command: &mut Command, key: &str) { + if let Ok(value) = std::env::var(key) { + command.env(key, value); + } +} + fn server_start_info(args: &StartArgs) -> ServerStartInfo { ServerStartInfo { host: args.host.clone(), @@ -4720,7 +4728,10 @@ fn start_cow_php_server( .env("FORKPRESS_DEBUG_LOG", &layout.debug_log) .env("FORKPRESS_PLAIN_STRATEGY", "cow") .env("FORKPRESS_ROOT_HOST", &args.root_host) - .env_remove(FORKPRESS_COW_PARENT_LIFECYCLE_LOCK) + .env_remove(FORKPRESS_COW_PARENT_LIFECYCLE_LOCK); + forward_env_if_present(&mut command, "FORKPRESS_COW_GIT_TEST_FAILPOINT"); + forward_env_if_present(&mut command, "FORKPRESS_COW_GIT_TEST_FAILPOINT_ACTION"); + command .stdout(Stdio::from(log)) .stderr(Stdio::from(log_err)); From 3eb987d492d742097af2e920440f16e893c1acac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 16 May 2026 01:59:39 +0200 Subject: [PATCH 16/21] Use kill failpoint for live Git crash e2e --- tests/cow/e2e.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cow/e2e.sh b/tests/cow/e2e.sh index 32b82f04..bee8dcd6 100755 --- a/tests/cow/e2e.sh +++ b/tests/cow/e2e.sh @@ -1294,7 +1294,7 @@ git -C "$TMP/checkout" reset --hard origin/main git -C "$TMP/checkout" clean -fd printf "created through crashed git push\n" > "$TMP/checkout/wordpress/wp-content/git-created-http-crash.txt" "$BIN" stop --work-dir "$WORK_DIR" >/dev/null 2>&1 || true -FORKPRESS_COW_GIT_TEST_FAILPOINT=after-created-branch-list FORKPRESS_COW_GIT_TEST_FAILPOINT_ACTION=exit \ +FORKPRESS_COW_GIT_TEST_FAILPOINT=after-created-branch-list FORKPRESS_COW_GIT_TEST_FAILPOINT_ACTION=kill \ "$BIN" serve --work-dir "$WORK_DIR" --port "$PORT" --root-host wp.localhost --workers 1 GIT_CREATED_HTTP_CRASH_PUSH_SURVIVED=0 if "$BIN" commit "$TMP/checkout" --message "create cow branch through crashed git push" > "$TMP/git-created-http-crash.out" 2>&1; then From 738b61810ca84116cd709c0f67a7c5e494a23d92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 16 May 2026 02:11:34 +0200 Subject: [PATCH 17/21] Normalize checkout after recovered crash push --- tests/cow/e2e.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/cow/e2e.sh b/tests/cow/e2e.sh index bee8dcd6..62b28f8d 100755 --- a/tests/cow/e2e.sh +++ b/tests/cow/e2e.sh @@ -1327,7 +1327,10 @@ grep -F "created through crashed git push" "$WORK/git-created-http-crash/wp-cont test -f "$WORK_DIR/cow/merge/bases/git-created-http-crash.sqlite" test -f "$WORK_DIR/cow/merge/file-bases/git-created-http-crash.json" git -C "$TMP/checkout" fetch origin git-created-http-crash:refs/remotes/origin/git-created-http-crash -test "$(git -C "$TMP/checkout" rev-parse git-created-http-crash)" = "$(git -C "$TMP/checkout" rev-parse refs/remotes/origin/git-created-http-crash)" +if [ "$(git -C "$TMP/checkout" rev-parse git-created-http-crash)" != "$(git -C "$TMP/checkout" rev-parse refs/remotes/origin/git-created-http-crash)" ]; then + git -C "$TMP/checkout" checkout git-created-http-crash + git -C "$TMP/checkout" reset --hard refs/remotes/origin/git-created-http-crash +fi "$BIN" branch --work-dir "$WORK_DIR" merge git-created-http-crash --into main > "$TMP/git-created-http-crash-merge.out" grep -F "forkpress: merged git-created-http-crash into main" "$TMP/git-created-http-crash-merge.out" >/dev/null grep -F "status: completed" "$TMP/git-created-http-crash-merge.out" >/dev/null From c261027f8c70055fa6ae97b75bb808536a441d79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 16 May 2026 02:24:54 +0200 Subject: [PATCH 18/21] Match deleted Git branch names exactly in e2e --- tests/cow/e2e.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cow/e2e.sh b/tests/cow/e2e.sh index 62b28f8d..c72f48e7 100755 --- a/tests/cow/e2e.sh +++ b/tests/cow/e2e.sh @@ -1355,7 +1355,7 @@ test -d "$WORK/feature-cow" log_step "delete Git-created branch" git -C "$TMP/checkout" push origin --delete git-created > "$TMP/git-delete.out" 2>&1 test ! -e "$WORK/git-created" -if "$BIN" branch --work-dir "$WORK_DIR" list | grep -F "git-created" >/dev/null; then +if "$BIN" branch --work-dir "$WORK_DIR" list | grep -Fx "git-created" >/dev/null; then echo "git-created branch still listed after remote delete" >&2 exit 1 fi From f53713a6c1bd3cb8d57f6eddc4e1294b1edaef2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 16 May 2026 02:42:11 +0200 Subject: [PATCH 19/21] Merge disjoint WordPress menu locations --- scripts/cow/merge.php | 86 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/scripts/cow/merge.php b/scripts/cow/merge.php index 8664f10a..0fc62219 100644 --- a/scripts/cow/merge.php +++ b/scripts/cow/merge.php @@ -5042,6 +5042,85 @@ function cow_merge_wordpress_option_reference_violation( return null; } +function cow_merge_wordpress_theme_mods_nav_locations_merge(?string $base_value, ?string $source_value, ?string $target_value): ?string { + if ($base_value === null || $source_value === null || $target_value === null) { + return null; + } + $base = @unserialize($base_value, ['allowed_classes' => false]); + $source = @unserialize($source_value, ['allowed_classes' => false]); + $target = @unserialize($target_value, ['allowed_classes' => false]); + if (!is_array($base) || !is_array($source) || !is_array($target)) { + return null; + } + + $base_locations = $base['nav_menu_locations'] ?? []; + $source_locations = $source['nav_menu_locations'] ?? []; + $target_locations = $target['nav_menu_locations'] ?? []; + if (!is_array($base_locations) || !is_array($source_locations) || !is_array($target_locations)) { + return null; + } + $base_without_locations = $base; + $source_without_locations = $source; + unset($base_without_locations['nav_menu_locations'], $source_without_locations['nav_menu_locations']); + if (!cow_merge_values_equal($source_without_locations, $base_without_locations)) { + return null; + } + + $merged_locations = $target_locations; + $keys = array_values(array_unique(array_merge( + array_keys($base_locations), + array_keys($source_locations), + array_keys($target_locations) + ))); + foreach ($keys as $key) { + $base_has = array_key_exists($key, $base_locations); + $source_has = array_key_exists($key, $source_locations); + $target_has = array_key_exists($key, $target_locations); + $base_location = $base_has ? $base_locations[$key] : null; + $source_location = $source_has ? $source_locations[$key] : null; + $target_location = $target_has ? $target_locations[$key] : null; + $source_changed = $base_has !== $source_has || !cow_merge_values_equal($source_location, $base_location); + $target_changed = $base_has !== $target_has || !cow_merge_values_equal($target_location, $base_location); + if (!$source_changed) { + continue; + } + if ($target_changed && ($source_has !== $target_has || !cow_merge_values_equal($source_location, $target_location))) { + return null; + } + if ($source_has) { + $merged_locations[$key] = $source_location; + } else { + unset($merged_locations[$key]); + } + } + + $merged = $target; + $merged['nav_menu_locations'] = $merged_locations; + return serialize($merged); +} + +function cow_merge_wordpress_cell_auto_merge(string $table, string $column, ?array $base_row, ?array $source_row, ?array $target_row): ?array { + if (!($table === 'wp_options' || str_ends_with($table, '_options')) || $column !== 'option_value') { + return null; + } + $option_name = (string)($source_row['option_name'] ?? $target_row['option_name'] ?? $base_row['option_name'] ?? ''); + if (!str_starts_with($option_name, 'theme_mods_')) { + return null; + } + $merged = cow_merge_wordpress_theme_mods_nav_locations_merge( + isset($base_row['option_value']) ? (string)$base_row['option_value'] : null, + isset($source_row['option_value']) ? (string)$source_row['option_value'] : null, + isset($target_row['option_value']) ? (string)$target_row['option_value'] : null + ); + if ($merged === null) { + return null; + } + return [ + 'value' => $merged, + 'reason' => "merged disjoint WordPress nav_menu_locations in $option_name", + ]; +} + function cow_merge_wordpress_insert_reference_violation( SQLite3 $source, SQLite3 $target, @@ -13239,6 +13318,13 @@ function cow_merge_table_rows( } continue; } + $auto_merge = cow_merge_wordpress_cell_auto_merge($table, $col, $base_row, $source_row, $target_row); + if ($auto_merge !== null) { + $merged[$col] = $auto_merge['value']; + $pending_source_cell_decisions[] = [$col, (string)$auto_merge['reason'], $b, $s, $t, $auto_merge['value']]; + $row_applied++; + continue; + } $active = cow_merge_record_conflict($meta, $run_id, $table, $key, $col, 'cell-conflict', $b, $s, $t, $t); cow_merge_record_decision($meta, $run_id, $table, $key, $col, $active ? 'target-wins' : 'target-accepted', $active ? 'source and target changed the same cell differently' : 'reviewed target resolution already accepts same-cell conflict', $b, $s, $t, $t); if ($active) { From 97e3172b6b35656320e9067c81c8c9e943a0bd06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 16 May 2026 03:00:16 +0200 Subject: [PATCH 20/21] Cover disjoint theme mods menu merge --- tests/cow/merge.php | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/cow/merge.php b/tests/cow/merge.php index aa77b69f..dc9e2bc5 100644 --- a/tests/cow/merge.php +++ b/tests/cow/merge.php @@ -3127,6 +3127,51 @@ function (SQLite3 $db, string $sql, string $message): void { 'source-added parent unique index materialization remains auditable' ); + $theme_mods_base = $tmp . '/theme-mods-base.sqlite'; + $theme_mods_source = $tmp . '/theme-mods-source.sqlite'; + $theme_mods_target = $tmp . '/theme-mods-target.sqlite'; + create_base_db($theme_mods_base); + copy($theme_mods_base, $theme_mods_source); + copy($theme_mods_base, $theme_mods_target); + $theme_base_value = serialize([ + 'color' => 'blue', + 'nav_menu_locations' => [], + ]); + $theme_source_value = serialize([ + 'color' => 'blue', + 'nav_menu_locations' => [ + 'forkpress_semantic_source' => 101, + ], + ]); + $theme_target_value = serialize([ + 'color' => 'blue', + 'nav_menu_locations' => [ + 'forkpress_semantic_target' => 202, + ], + ]); + $db = open_db($theme_mods_base); + $db->exec("UPDATE wp_options SET option_value = '" . SQLite3::escapeString($theme_base_value) . "' WHERE option_name = 'theme_mods_test'"); + $db->close(); + $db = open_db($theme_mods_source); + $db->exec("UPDATE wp_options SET option_value = '" . SQLite3::escapeString($theme_source_value) . "' WHERE option_name = 'theme_mods_test'"); + $db->close(); + $db = open_db($theme_mods_target); + $db->exec("UPDATE wp_options SET option_value = '" . SQLite3::escapeString($theme_target_value) . "' WHERE option_name = 'theme_mods_test'"); + $db->close(); + + $theme_mods_result = cow_merge_databases($theme_mods_base, $theme_mods_source, $theme_mods_target, $metadata, 'feature-theme-mods', 'main'); + assert_same($theme_mods_result['status'], 'completed', 'disjoint theme_mods nav menu locations merge cleanly'); + $theme_mods_run_id = (int)$theme_mods_result['run_id']; + $merged_theme_mods = unserialize((string)scalar($theme_mods_target, "SELECT option_value FROM wp_options WHERE option_name = 'theme_mods_test'"), ['allowed_classes' => false]); + assert_same($merged_theme_mods['color'] ?? null, 'blue', 'theme_mods unrelated values are preserved'); + assert_same($merged_theme_mods['nav_menu_locations']['forkpress_semantic_source'] ?? null, 101, 'source nav menu location is preserved'); + assert_same($merged_theme_mods['nav_menu_locations']['forkpress_semantic_target'] ?? null, 202, 'target nav menu location is preserved'); + assert_same( + (int)scalar($metadata, "SELECT COUNT(*) FROM merge_conflicts WHERE run_id = $theme_mods_run_id AND table_name = 'wp_options' AND column_name = 'option_value'"), + 0, + 'disjoint theme_mods nav menu location merge does not create a conflict' + ); + $conflict_base = $tmp . '/conflict-base.sqlite'; $conflict_source = $tmp . '/conflict-source.sqlite'; $conflict_target = $tmp . '/conflict-target.sqlite'; From 5d91b068a770adc7eefa95ab1b2a714bd1745d07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 16 May 2026 03:15:16 +0200 Subject: [PATCH 21/21] Cache CI runtimes and seed keyless e2e identity --- .github/workflows/ci.yml | 21 +++++++++++++++++++++ tests/cow/e2e.sh | 1 + 2 files changed, 22 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d52a439d..5288062a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,18 @@ jobs: - name: Release preflight checks run: make test-release + - name: Cache runtime build + uses: actions/cache@v4 + with: + path: .build + key: >- + ci-runtime-x86_64-unknown-linux-musl-${{ hashFiles( + 'scripts/build-dist.sh', + 'experiments/branchfs/php-ext/branchfs.c', + 'experiments/branchfs/php-ext/branchfs.h', + 'experiments/branchfs/build/spc-patch.php' + ) }} + - name: Build production dist bundle run: scripts/build-dist.sh env: @@ -111,6 +123,15 @@ jobs: brew install composer gpatch automake re2c bison pkg-config brew install php || true + - name: Cache runtime build + uses: actions/cache@v4 + with: + path: .build + key: >- + ci-runtime-${{ matrix.target }}-${{ hashFiles( + 'scripts/build-dist.sh' + ) }} + - name: Build production dist bundle run: scripts/build-dist.sh env: diff --git a/tests/cow/e2e.sh b/tests/cow/e2e.sh index c72f48e7..65c0f0b1 100755 --- a/tests/cow/e2e.sh +++ b/tests/cow/e2e.sh @@ -2097,6 +2097,7 @@ php -r '$db = new SQLite3($argv[1]); $db->exec("DROP TABLE IF EXISTS wp_forkpres "$BIN" branch --work-dir "$WORK_DIR" create keyless-unique-same > "$TMP/keyless-unique-same-create.out" grep -F "keyless-unique-same.wp.localhost:$PORT" "$TMP/keyless-unique-same-create.out" >/dev/null php -r '$db = new SQLite3($argv[1]); $db->exec("INSERT INTO wp_forkpress_e2e_keyless_unique_same (slug, value) VALUES ('\''shared-keyless-unique-same'\'', '\''same payload'\'')");' "$WORK/keyless-unique-same/wp-content/database/.ht.sqlite" +php scripts/cow/merge.php capture-identities --db "$WORK/keyless-unique-same/wp-content/database/.ht.sqlite" --metadata-db "$WORK_DIR/cow/merge/metadata.sqlite" --branch keyless-unique-same --quiet 1 php -r '$db = new SQLite3($argv[1]); $db->exec("INSERT INTO wp_forkpress_e2e_keyless_unique_same (slug, value) VALUES ('\''shared-keyless-unique-same'\'', '\''same payload'\'')");' "$WORK/main/wp-content/database/.ht.sqlite" "$BIN" branch --work-dir "$WORK_DIR" merge keyless-unique-same --into main > "$TMP/keyless-unique-same-merge.out" grep -F "forkpress: merged keyless-unique-same into main" "$TMP/keyless-unique-same-merge.out" >/dev/null