diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bd66c5b..d52a439d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - build-essential clang curl git pkg-config unzip \ + automake build-essential clang curl git pkg-config unzip \ musl-tools php-cli composer re2c bison nodejs - uses: dtolnay/rust-toolchain@stable @@ -45,6 +45,9 @@ jobs: - name: Rust unit tests (dev experiments) run: FORKPRESS_RUNTIME_BUNDLE=/dev/null cargo test -p forkpress-cli --features dev-experiments --bin forkpress-dev + - name: Release preflight checks + run: make test-release + - name: Build production dist bundle run: scripts/build-dist.sh env: @@ -99,9 +102,13 @@ jobs: - name: Rust unit tests (dev experiments) run: FORKPRESS_RUNTIME_BUNDLE=/dev/null cargo test --target ${{ matrix.target }} -p forkpress-cli --features dev-experiments --bin forkpress-dev + - name: Release preflight checks + run: make test-release + - name: Install runtime build tools run: | - brew install composer gpatch + brew update + brew install composer gpatch automake re2c bison pkg-config brew install php || true - name: Build production dist bundle diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 96c4825d..e01cce05 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,7 @@ on: - "scripts/cow/**" - "scripts/git/**" - "scripts/shared/**" + - "tests/release/**" - "installer/windows/**" - "wp-plugin/**" - "vendor/**" @@ -50,10 +51,15 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Release preflight checks + if: matrix.os != 'windows' + run: make test-release + - name: Install toolchain (mac) if: matrix.os == 'darwin' run: | - brew install composer gpatch + brew update + brew install composer gpatch automake re2c bison pkg-config # php-cli from brew drives static-php-cli brew install php || true @@ -62,7 +68,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - build-essential clang curl git pkg-config unzip \ + automake build-essential clang curl git pkg-config unzip \ musl-tools php-cli composer re2c bison - name: Install toolchain (windows) diff --git a/Makefile b/Makefile index ae9f75f3..a489ea4b 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,17 @@ PHP_DEV_DIR ?= $(firstword $(wildcard /nix/store/*-php-*-dev)) SQLITE_INC ?= $(firstword $(wildcard /nix/store/*-sqlite-*-dev/include)) SQLITE_LIB ?= $(firstword $(filter-out /nix/store/*-sqlite-*-dev/lib,$(wildcard /nix/store/*-sqlite-*/lib))) +BRANCHFS_EXT_DIR := experiments/branchfs/php-ext +BRANCHFS_EXT_SO := $(BRANCHFS_EXT_DIR)/branchfs.so +BRANCHFS_TEST_DIR := experiments/branchfs/tests +COW_TEST_DIR := tests/cow +RELEASE_TEST_DIR := tests/release +BRANCHFS_HEADER_GOALS := all init-db test test-compat test-branchfs test-all $(BRANCHFS_EXT_SO) +NEEDS_BRANCHFS_HEADERS := $(filter $(BRANCHFS_HEADER_GOALS),$(MAKECMDGOALS)) +ifeq ($(strip $(MAKECMDGOALS)),) +NEEDS_BRANCHFS_HEADERS := all +endif + PHP_INCLUDE_DIR ?= $(if $(PHP_CONFIG),$(shell $(PHP_CONFIG) --include-dir 2>/dev/null)) PHP_EXTRA_INCS := @@ -25,7 +36,7 @@ PHP_EXTRA_INCS += -I$(PHP_DEV_DIR)/include/php \ -I$(PHP_DEV_DIR)/include/php/Zend \ -I$(PHP_DEV_DIR)/include/php/ext \ -I$(PHP_DEV_DIR)/include/php/ext/date/lib -else +else ifneq ($(strip $(NEEDS_BRANCHFS_HEADERS)),) $(error Could not determine PHP headers. Install php-config or set PHP_DEV_DIR) endif @@ -47,10 +58,6 @@ CC ?= gcc CFLAGS := -fPIC -O2 -Wall -DCOMPILE_DL_BRANCHFS -DHAVE_CONFIG_H=0 $(SQLITE_CFLAGS) INCLUDES := $(PHP_EXTRA_INCS) LDFLAGS := $(SQLITE_LIBS) -BRANCHFS_EXT_DIR := experiments/branchfs/php-ext -BRANCHFS_EXT_SO := $(BRANCHFS_EXT_DIR)/branchfs.so -BRANCHFS_TEST_DIR := experiments/branchfs/tests -COW_TEST_DIR := tests/cow RUSTUP ?= $(shell command -v rustup 2>/dev/null) UNAME_S := $(shell uname -s) UNAME_M := $(shell uname -m) @@ -68,7 +75,7 @@ else ifeq ($(UNAME_S)-$(UNAME_M),Linux-aarch64) FORKPRESS_TARGET ?= aarch64-unknown-linux-musl endif -.PHONY: all clean test test-compat test-branchfs test-cow init-db test-all forkpress forkpress-dev dist dist-dev +.PHONY: all clean test test-compat test-branchfs test-cow test-release init-db test-all forkpress forkpress-dev dist dist-dev all: $(BRANCHFS_EXT_SO) @@ -100,10 +107,14 @@ test-branchfs: $(BRANCHFS_EXT_SO) test-cow: php $(COW_TEST_DIR)/git_server.php php $(COW_TEST_DIR)/merge.php + php $(COW_TEST_DIR)/branch_ui.php php $(COW_TEST_DIR)/router_paths.php php $(COW_TEST_DIR)/router_lock.php -test-all: test-branchfs test-cow +test-release: + bash $(RELEASE_TEST_DIR)/build-dist-preflight.sh + +test-all: test-branchfs test-cow test-release clean: rm -f $(BRANCHFS_EXT_SO) /tmp/branchfs_test*.db /tmp/branchfs_wp*.db diff --git a/crates/forkpress-cli/src/app.rs b/crates/forkpress-cli/src/app.rs index 16a88587..83a8f2f2 100644 --- a/crates/forkpress-cli/src/app.rs +++ b/crates/forkpress-cli/src/app.rs @@ -52,8 +52,10 @@ use forkpress_storage::{ ensure_cow_main_branch, inspect_cow_merge_audit, list_remote_sites, lock_cow_lifecycle, lock_cow_operations, merge_cow_branch, prepare_cow_file_view, print_cow_storage_status, print_linux_xfs_loop_storage_status, print_macos_cow_storage_status, probe_reflink_dir, - probe_remote_site, reset_cow_branch, resolve_cow_merge_conflict, review_cow_merge_audit_record, - show_cow_branch, write_cow_branch_list, write_cow_strategy_notes, + probe_remote_site, record_cow_plugin_validator_conflicts, recover_cow_merge_crash, + reset_cow_branch, resolve_cow_merge_conflict, revalidate_cow_merge_reviews, + review_cow_merge_audit_record, run_cow_plugin_validator, show_cow_branch, + write_cow_branch_list, write_cow_strategy_notes, }; #[cfg(feature = "dev-experiments")] use forkpress_storage::{copy_tree_cow, plain_branch_names}; @@ -3042,8 +3044,65 @@ fn cow_branch_command( Ok(0) } "merge" => { - let (source, target) = parse_cow_branch_merge_args(&args.args)?; - merge_cow_branch(&layout, &runtime, &args.shared, source, &target)?; + let merge = parse_cow_branch_merge_args(&args.args)?; + merge_cow_branch( + &layout, + &runtime, + &args.shared, + &merge.source, + &merge.target, + merge.plugin_validator.as_deref(), + )?; + Ok(0) + } + "recover-crash" | "merge-recover" => { + let recovery = parse_cow_branch_recover_crash_args(&args.args)?; + recover_cow_merge_crash( + &layout, + &runtime, + &args.shared, + recovery.run_id.as_deref(), + recovery.restore_target_db, + recovery.restore_files, + &recovery.format, + )?; + Ok(0) + } + "revalidate-reviews" | "merge-revalidate" => { + let revalidation = parse_cow_branch_revalidate_reviews_args(&args.args)?; + revalidate_cow_merge_reviews( + &layout, + &runtime, + &args.shared, + revalidation.run_id.as_deref(), + revalidation.reviewer.as_deref(), + &revalidation.format, + )?; + Ok(0) + } + "record-plugin-validator-conflicts" => { + let record = parse_cow_branch_record_plugin_validator_args(&args.args)?; + record_cow_plugin_validator_conflicts( + &layout, + &runtime, + &args.shared, + &record.run_id, + record.findings_json.as_deref(), + record.findings_file.as_deref(), + &record.format, + )?; + Ok(0) + } + "run-plugin-validator" => { + let validator = parse_cow_branch_run_plugin_validator_args(&args.args)?; + run_cow_plugin_validator( + &layout, + &runtime, + &args.shared, + &validator.run_id, + &validator.validator, + &validator.format, + )?; Ok(0) } "merge-audit" | "audit" => { @@ -3059,7 +3118,9 @@ fn cow_branch_command( let mut id_band_skips = false; let mut target_kept = false; let mut review = false; + let mut revalidate = false; let mut review_status: Option = None; + let mut reviewer: Option = None; let mut resolution_status: Option = None; let mut group_by = "none".to_string(); let mut index = 1; @@ -3142,6 +3203,10 @@ fn cow_branch_command( review = true; index += 1; } + "--revalidate" => { + revalidate = true; + index += 1; + } "--review-status" => { let Some(value) = args.args.get(index + 1) else { bail!( @@ -3151,6 +3216,13 @@ fn cow_branch_command( review_status = Some(value.clone()); index += 2; } + "--reviewer" => { + let Some(value) = args.args.get(index + 1) else { + bail!("--reviewer requires a name"); + }; + reviewer = Some(value.clone()); + index += 2; + } "--resolution-status" => { let Some(value) = args.args.get(index + 1) else { bail!("--resolution-status requires validated or applied"); @@ -3172,6 +3244,17 @@ fn cow_branch_command( } } } + if revalidate { + revalidate_cow_merge_reviews( + &layout, + &runtime, + &args.shared, + run_id.as_deref(), + reviewer.as_deref(), + &format, + )?; + return Ok(0); + } inspect_cow_merge_audit( &layout, &runtime, @@ -3265,6 +3348,7 @@ fn cow_branch_command( }; let mut choice: Option = None; let mut apply = false; + let mut after_revalidate = false; let mut note: Option = None; let mut reviewer: Option = None; let mut index = 3; @@ -3281,6 +3365,10 @@ fn cow_branch_command( apply = true; index += 1; } + "--after-revalidate" => { + after_revalidate = true; + index += 1; + } "--note" => { let Some(value) = args.args.get(index + 1) else { bail!("--note requires text"); @@ -3310,6 +3398,7 @@ fn cow_branch_command( record_id, &choice, apply, + after_revalidate, note.as_deref(), reviewer.as_deref(), )?; @@ -3366,29 +3455,392 @@ fn branch_help_text(command: Option<&str>) -> &'static str { "Usage: forkpress branch reset --from [--force]\n\nReplace a branch with a fresh copy of another branch. Resetting main requires --force.\nExample: forkpress branch reset feature --from main\n" } Some("merge") => { - "Usage: forkpress branch merge --into \n\nMerge source branch changes into the target branch and record audit metadata.\nExample: forkpress branch merge feature --into main\n" + "Usage: forkpress branch merge --into [--plugin-validator ]\n\nMerge source branch changes into the target branch and record audit metadata. Use --plugin-validator to run one plugin validator before reporting completion.\nExample: forkpress branch merge feature --into main\n" + } + Some("recover-crash") | Some("merge-recover") => { + "Usage: forkpress branch recover-crash [--run ] [--restore-target-db] [--restore-files] [--format text|json]\n\nInspect or restore pending COW merge crash-recovery artifacts. Run without restore flags to list pending artifacts first.\nExamples:\n forkpress branch recover-crash\n forkpress branch recover-crash --restore-target-db --restore-files\n" + } + Some("revalidate-reviews") | Some("merge-revalidate") => { + "Usage: forkpress branch revalidate-reviews [--run ] [--reviewer ] [--format text|json]\n\nRecheck reviewed merge conflicts against current target state. Stale reviewed conflicts are carried back into the needs-action queue without applying a resolution.\nExample: forkpress branch revalidate-reviews --reviewer alice\n" + } + Some("record-plugin-validator-conflicts") => { + "Usage: forkpress branch record-plugin-validator-conflicts --run (--findings-file |--findings-json ) [--format text|json]\n\nRecord plugin-scoped validator findings against an existing merge run. Prefer --findings-file for real validators.\n" + } + Some("run-plugin-validator") => { + "Usage: forkpress branch run-plugin-validator --run --validator [--format text|json]\n\nRun one plugin validator and record emitted findings as plugin-scoped merge conflicts.\n" } Some("merge-audit") | Some("audit") => { - "Usage: forkpress branch merge-audit [options]\n\nInspect merge runs, decisions, conflicts, resolutions, and rollback failures.\nCommon options: --format text|json, --run , --scope all|db|files, --records all|conflicts|decisions|resolutions|rollback-failures, --review, --review-status .\n" + "Usage: forkpress branch merge-audit [options]\n\nInspect merge runs, decisions, conflicts, resolutions, and rollback failures. Use --revalidate to carry stale reviewed conflicts back into needs-action before resolving.\nCommon options: --format text|json, --run , --scope all|db|files, --records all|conflicts|decisions|resolutions|rollback-failures, --review, --review-status , --revalidate.\n" } Some("merge-review") => { "Usage: forkpress branch merge-review --status --note [--reviewer ]\n\nAttach review metadata to an audit record.\n" } Some("merge-resolve") => { - "Usage: forkpress branch merge-resolve conflict --choice [--apply] [--note ] [--reviewer ]\n\nValidate or apply a reviewed merge conflict choice.\n" + "Usage: forkpress branch merge-resolve conflict --choice [--apply] [--after-revalidate] [--note ] [--reviewer ]\n\nValidate or apply a reviewed merge conflict choice. Use --after-revalidate only after merge-audit --revalidate has carried a stale DB row/cell or file conflict back to needs-action.\n" } Some("delete") | Some("rm") => { "Usage: forkpress branch delete \n\nDelete a materialized branch. Use with care.\n" } _ => { - "Usage: forkpress branch [options]\n\nCommands:\n list List branches\n show [branch] Show branch storage details\n create [--from b] Create a branch; defaults to --from main\n reset --from b Replace a branch from another branch\n merge --into target Merge one branch into another\n merge-audit [options] Inspect merge audit records\n merge-review Mark an audit record as reviewed\n merge-resolve conflict Validate or apply a conflict choice\n delete Delete a branch\n\nExamples:\n forkpress branch list\n forkpress branch create feature --from main\n forkpress branch merge feature --into main\n forkpress branch merge-audit --review --records conflicts\n\nRun `forkpress branch --help` for command-specific help.\n" + "Usage: forkpress branch [options]\n\nCommands:\n list List branches\n show [branch] Show branch storage details\n create [--from b] Create a branch; defaults to --from main\n reset --from b Replace a branch from another branch\n merge --into target Merge one branch into another; accepts --plugin-validator\n recover-crash [options] Inspect or restore pending merge crash artifacts\n revalidate-reviews [options] Recheck reviewed conflicts for stale target drift\n run-plugin-validator [opts] Run one plugin validator for a merge run\n record-plugin-validator-conflicts [opts]\n Record plugin-scoped validator findings\n merge-audit [options] Inspect merge audit records\n merge-review Mark an audit record as reviewed\n merge-resolve conflict Validate or apply a conflict choice\n delete Delete a branch\n\nExamples:\n forkpress branch list\n forkpress branch create feature --from main\n forkpress branch merge feature --into main\n forkpress branch merge feature --into main --plugin-validator ./validator.php\n forkpress branch recover-crash --restore-target-db --restore-files\n forkpress branch revalidate-reviews --reviewer alice\n forkpress branch run-plugin-validator --run 12 --validator ./validator.php\n forkpress branch merge-audit --review --records conflicts\n\nRun `forkpress branch --help` for command-specific help.\n" + } + } +} + +#[derive(Debug, PartialEq, Eq)] +struct CowBranchRecoverCrashArgs { + run_id: Option, + restore_target_db: bool, + restore_files: bool, + format: String, +} + +fn parse_cow_branch_recover_crash_args(args: &[String]) -> Result { + let mut run_id: Option = None; + let mut restore_target_db = false; + let mut restore_files = false; + let mut format = "text".to_string(); + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--run" => { + let Some(value) = args.get(index + 1) else { + bail!("--run requires a merge run id"); + }; + run_id = Some(value.clone()); + index += 2; + } + value if value.starts_with("--run=") => { + let value = value.trim_start_matches("--run="); + if value.is_empty() { + bail!("--run requires a merge run id"); + } + run_id = Some(value.to_string()); + index += 1; + } + "--restore-target-db" => { + restore_target_db = true; + index += 1; + } + "--restore-files" => { + restore_files = true; + index += 1; + } + "--format" => { + let Some(value) = args.get(index + 1) else { + bail!("--format requires text or json"); + }; + format = value.clone(); + index += 2; + } + value if value.starts_with("--format=") => { + let value = value.trim_start_matches("--format="); + if value.is_empty() { + bail!("--format requires text or json"); + } + format = value.to_string(); + index += 1; + } + other => bail!( + "unsupported argument for `forkpress branch recover-crash`: {other}\n\n{}", + branch_help_text(Some("recover-crash")) + ), + } + } + if format != "text" && format != "json" { + bail!("--format requires text or json"); + } + Ok(CowBranchRecoverCrashArgs { + run_id, + restore_target_db, + restore_files, + format, + }) +} + +#[derive(Debug, PartialEq, Eq)] +struct CowBranchRevalidateReviewsArgs { + run_id: Option, + reviewer: Option, + format: String, +} + +fn parse_cow_branch_revalidate_reviews_args( + args: &[String], +) -> Result { + let mut run_id: Option = None; + let mut reviewer: Option = None; + let mut format = "text".to_string(); + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--run" => { + let Some(value) = args.get(index + 1) else { + bail!("--run requires a merge run id"); + }; + run_id = Some(value.clone()); + index += 2; + } + value if value.starts_with("--run=") => { + let value = value.trim_start_matches("--run="); + if value.is_empty() { + bail!("--run requires a merge run id"); + } + run_id = Some(value.to_string()); + index += 1; + } + "--reviewer" => { + let Some(value) = args.get(index + 1) else { + bail!("--reviewer requires a name"); + }; + reviewer = Some(value.clone()); + index += 2; + } + value if value.starts_with("--reviewer=") => { + let value = value.trim_start_matches("--reviewer="); + if value.is_empty() { + bail!("--reviewer requires a name"); + } + reviewer = Some(value.to_string()); + index += 1; + } + "--format" => { + let Some(value) = args.get(index + 1) else { + bail!("--format requires text or json"); + }; + format = value.clone(); + index += 2; + } + value if value.starts_with("--format=") => { + let value = value.trim_start_matches("--format="); + if value.is_empty() { + bail!("--format requires text or json"); + } + format = value.to_string(); + index += 1; + } + other => bail!( + "unsupported argument for `forkpress branch revalidate-reviews`: {other}\n\n{}", + branch_help_text(Some("revalidate-reviews")) + ), + } + } + if format != "text" && format != "json" { + bail!("--format requires text or json"); + } + Ok(CowBranchRevalidateReviewsArgs { + run_id, + reviewer, + format, + }) +} + +#[derive(Debug, PartialEq, Eq)] +struct CowBranchRecordPluginValidatorArgs { + run_id: String, + findings_json: Option, + findings_file: Option, + format: String, +} + +fn parse_cow_branch_record_plugin_validator_args( + args: &[String], +) -> Result { + let mut run_id: Option = None; + let mut findings_json: Option = None; + let mut findings_file: Option = None; + let mut format = "text".to_string(); + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--run" => { + let Some(value) = args.get(index + 1) else { + bail!("--run requires a merge run id"); + }; + run_id = Some(value.clone()); + index += 2; + } + value if value.starts_with("--run=") => { + let value = value.trim_start_matches("--run="); + if value.is_empty() { + bail!("--run requires a merge run id"); + } + run_id = Some(value.to_string()); + index += 1; + } + "--findings-json" => { + let Some(value) = args.get(index + 1) else { + bail!("--findings-json requires a JSON array"); + }; + findings_json = Some(value.clone()); + index += 2; + } + value if value.starts_with("--findings-json=") => { + let value = value.trim_start_matches("--findings-json="); + if value.is_empty() { + bail!("--findings-json requires a JSON array"); + } + findings_json = Some(value.to_string()); + index += 1; + } + "--findings-file" => { + let Some(value) = args.get(index + 1) else { + bail!("--findings-file requires a path"); + }; + findings_file = Some(PathBuf::from(value)); + index += 2; + } + value if value.starts_with("--findings-file=") => { + let value = value.trim_start_matches("--findings-file="); + if value.is_empty() { + bail!("--findings-file requires a path"); + } + findings_file = Some(PathBuf::from(value)); + index += 1; + } + "--format" => { + let Some(value) = args.get(index + 1) else { + bail!("--format requires text or json"); + }; + format = value.clone(); + index += 2; + } + value if value.starts_with("--format=") => { + let value = value.trim_start_matches("--format="); + if value.is_empty() { + bail!("--format requires text or json"); + } + format = value.to_string(); + index += 1; + } + other => bail!( + "unsupported argument for `forkpress branch record-plugin-validator-conflicts`: {other}\n\n{}", + branch_help_text(Some("record-plugin-validator-conflicts")) + ), + } + } + let Some(run_id) = run_id else { + bail!( + "record-plugin-validator-conflicts requires --run .\n\n{}", + branch_help_text(Some("record-plugin-validator-conflicts")) + ); + }; + if findings_json.is_some() == findings_file.is_some() { + bail!( + "record-plugin-validator-conflicts requires exactly one of --findings-json or --findings-file" + ); + } + if format != "text" && format != "json" { + bail!("--format requires text or json"); + } + Ok(CowBranchRecordPluginValidatorArgs { + run_id, + findings_json, + findings_file, + format, + }) +} + +#[derive(Debug, PartialEq, Eq)] +struct CowBranchRunPluginValidatorArgs { + run_id: String, + validator: PathBuf, + format: String, +} + +fn parse_cow_branch_run_plugin_validator_args( + args: &[String], +) -> Result { + let mut run_id: Option = None; + let mut validator: Option = None; + let mut format = "text".to_string(); + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--run" => { + let Some(value) = args.get(index + 1) else { + bail!("--run requires a merge run id"); + }; + run_id = Some(value.clone()); + index += 2; + } + value if value.starts_with("--run=") => { + let value = value.trim_start_matches("--run="); + if value.is_empty() { + bail!("--run requires a merge run id"); + } + run_id = Some(value.to_string()); + index += 1; + } + "--validator" => { + let Some(value) = args.get(index + 1) else { + bail!("--validator requires a path"); + }; + validator = Some(PathBuf::from(value)); + index += 2; + } + value if value.starts_with("--validator=") => { + let value = value.trim_start_matches("--validator="); + if value.is_empty() { + bail!("--validator requires a path"); + } + validator = Some(PathBuf::from(value)); + index += 1; + } + "--format" => { + let Some(value) = args.get(index + 1) else { + bail!("--format requires text or json"); + }; + format = value.clone(); + index += 2; + } + value if value.starts_with("--format=") => { + let value = value.trim_start_matches("--format="); + if value.is_empty() { + bail!("--format requires text or json"); + } + format = value.to_string(); + index += 1; + } + other => bail!( + "unsupported argument for `forkpress branch run-plugin-validator`: {other}\n\n{}", + branch_help_text(Some("run-plugin-validator")) + ), } } + let Some(run_id) = run_id else { + bail!( + "run-plugin-validator requires --run .\n\n{}", + branch_help_text(Some("run-plugin-validator")) + ); + }; + let Some(validator) = validator else { + bail!( + "run-plugin-validator requires --validator .\n\n{}", + branch_help_text(Some("run-plugin-validator")) + ); + }; + if format != "text" && format != "json" { + bail!("--format requires text or json"); + } + Ok(CowBranchRunPluginValidatorArgs { + run_id, + validator, + format, + }) } -fn parse_cow_branch_merge_args(args: &[String]) -> Result<(&str, String)> { - let mut source: Option<&str> = None; +#[derive(Debug, PartialEq, Eq)] +struct CowBranchMergeArgs { + source: String, + target: String, + plugin_validator: Option, +} + +fn parse_cow_branch_merge_args(args: &[String]) -> Result { + let mut source: Option = None; let mut target: Option = None; + let mut plugin_validator: Option = None; let mut index = 1; while index < args.len() { let arg = args[index].as_str(); @@ -3409,6 +3861,28 @@ fn parse_cow_branch_merge_args(args: &[String]) -> Result<(&str, String)> { branch_help_text(Some("merge")) ); } + "--plugin-validator" => { + if plugin_validator.is_some() { + bail!( + "`--plugin-validator` may only be provided once.\n\n{}", + branch_help_text(Some("merge")) + ); + } + let Some(value) = args.get(index + 1) else { + bail!( + "`--plugin-validator` requires a path.\n\n{}", + branch_help_text(Some("merge")) + ); + }; + if value.is_empty() { + bail!( + "`--plugin-validator` requires a path.\n\n{}", + branch_help_text(Some("merge")) + ); + } + plugin_validator = Some(PathBuf::from(value)); + index += 2; + } value if value.starts_with("--into=") => { let value = value.trim_start_matches("--into="); if value.is_empty() { @@ -3420,6 +3894,23 @@ fn parse_cow_branch_merge_args(args: &[String]) -> Result<(&str, String)> { target = Some(value.to_string()); index += 1; } + value if value.starts_with("--plugin-validator=") => { + if plugin_validator.is_some() { + bail!( + "`--plugin-validator` may only be provided once.\n\n{}", + branch_help_text(Some("merge")) + ); + } + let value = value.trim_start_matches("--plugin-validator="); + if value.is_empty() { + bail!( + "`--plugin-validator` requires a path.\n\n{}", + branch_help_text(Some("merge")) + ); + } + plugin_validator = Some(PathBuf::from(value)); + index += 1; + } value if value.starts_with("--") => { bail!( "unsupported argument for `forkpress branch merge`: {value}\n\n{}", @@ -3433,7 +3924,7 @@ fn parse_cow_branch_merge_args(args: &[String]) -> Result<(&str, String)> { branch_help_text(Some("merge")) ); } - source = Some(value); + source = Some(value.to_string()); index += 1; } } @@ -3450,7 +3941,11 @@ fn parse_cow_branch_merge_args(args: &[String]) -> Result<(&str, String)> { branch_help_text(Some("merge")) ); }; - Ok((source, target)) + Ok(CowBranchMergeArgs { + source, + target, + plugin_validator, + }) } #[cfg(feature = "dev-experiments")] @@ -4408,6 +4903,188 @@ mod git_helper_tests { assert!(branch_help_text(branch_help_command(&args)).contains("--from ")); } + #[test] + fn branch_help_lists_crash_recovery_command() { + assert!(branch_help_text(None).contains("recover-crash")); + assert!(branch_help_text(Some("recover-crash")).contains("--restore-target-db")); + assert!(branch_help_text(Some("recover-crash")).contains("--restore-files")); + } + + #[test] + fn branch_help_lists_review_revalidation_command() { + assert!(branch_help_text(None).contains("revalidate-reviews")); + assert!(branch_help_text(Some("revalidate-reviews")).contains("--reviewer")); + assert!(branch_help_text(Some("revalidate-reviews")).contains("needs-action")); + assert!(branch_help_text(Some("merge-audit")).contains("--revalidate")); + } + + #[test] + fn branch_help_lists_plugin_validator_commands() { + assert!(branch_help_text(None).contains("run-plugin-validator")); + assert!(branch_help_text(None).contains("record-plugin-validator-conflicts")); + assert!(branch_help_text(None).contains("--plugin-validator ./validator.php")); + assert!(branch_help_text(Some("merge")).contains("--plugin-validator ")); + assert!(branch_help_text(Some("run-plugin-validator")).contains("--validator")); + assert!( + branch_help_text(Some("record-plugin-validator-conflicts")).contains("--findings-file") + ); + } + + #[test] + fn parses_branch_recover_crash_defaults() { + let args = vec!["recover-crash".to_string()]; + let parsed = parse_cow_branch_recover_crash_args(&args).unwrap(); + assert_eq!(parsed.run_id, None); + assert!(!parsed.restore_target_db); + assert!(!parsed.restore_files); + assert_eq!(parsed.format, "text"); + } + + #[test] + fn parses_branch_recover_crash_restore_flags() { + let args = vec![ + "recover-crash".to_string(), + "--run=7".to_string(), + "--restore-target-db".to_string(), + "--restore-files".to_string(), + "--format=json".to_string(), + ]; + let parsed = parse_cow_branch_recover_crash_args(&args).unwrap(); + assert_eq!(parsed.run_id.as_deref(), Some("7")); + assert!(parsed.restore_target_db); + assert!(parsed.restore_files); + assert_eq!(parsed.format, "json"); + } + + #[test] + fn branch_recover_crash_errors_on_unknown_flags() { + let args = vec!["recover-crash".to_string(), "--target".to_string()]; + let err = parse_cow_branch_recover_crash_args(&args) + .unwrap_err() + .to_string(); + assert!(err.contains("unsupported argument")); + assert!(err.contains("forkpress branch recover-crash")); + } + + #[test] + fn parses_branch_revalidate_reviews_defaults() { + let args = vec!["revalidate-reviews".to_string()]; + let parsed = parse_cow_branch_revalidate_reviews_args(&args).unwrap(); + assert_eq!(parsed.run_id, None); + assert_eq!(parsed.reviewer, None); + assert_eq!(parsed.format, "text"); + } + + #[test] + fn parses_branch_revalidate_reviews_filters() { + let args = vec![ + "revalidate-reviews".to_string(), + "--run=9".to_string(), + "--reviewer=alice".to_string(), + "--format=json".to_string(), + ]; + let parsed = parse_cow_branch_revalidate_reviews_args(&args).unwrap(); + assert_eq!(parsed.run_id.as_deref(), Some("9")); + assert_eq!(parsed.reviewer.as_deref(), Some("alice")); + assert_eq!(parsed.format, "json"); + } + + #[test] + fn branch_revalidate_reviews_errors_on_unknown_flags() { + let args = vec!["revalidate-reviews".to_string(), "--apply".to_string()]; + let err = parse_cow_branch_revalidate_reviews_args(&args) + .unwrap_err() + .to_string(); + assert!(err.contains("unsupported argument")); + assert!(err.contains("forkpress branch revalidate-reviews")); + } + + #[test] + fn parses_branch_record_plugin_validator_findings_file() { + let args = vec![ + "record-plugin-validator-conflicts".to_string(), + "--run=12".to_string(), + "--findings-file=validator-findings.json".to_string(), + "--format=json".to_string(), + ]; + let parsed = parse_cow_branch_record_plugin_validator_args(&args).unwrap(); + assert_eq!(parsed.run_id, "12"); + assert_eq!(parsed.findings_json, None); + assert_eq!( + parsed.findings_file.as_deref(), + Some(Path::new("validator-findings.json")) + ); + assert_eq!(parsed.format, "json"); + } + + #[test] + fn parses_branch_record_plugin_validator_findings_json() { + let args = vec![ + "record-plugin-validator-conflicts".to_string(), + "--run".to_string(), + "12".to_string(), + "--findings-json".to_string(), + "[]".to_string(), + ]; + let parsed = parse_cow_branch_record_plugin_validator_args(&args).unwrap(); + assert_eq!(parsed.run_id, "12"); + assert_eq!(parsed.findings_json.as_deref(), Some("[]")); + assert_eq!(parsed.findings_file, None); + assert_eq!(parsed.format, "text"); + } + + #[test] + fn branch_record_plugin_validator_requires_one_findings_source() { + let args = vec![ + "record-plugin-validator-conflicts".to_string(), + "--run=12".to_string(), + ]; + let err = parse_cow_branch_record_plugin_validator_args(&args) + .unwrap_err() + .to_string(); + assert!(err.contains("exactly one")); + assert!(err.contains("--findings-json")); + assert!(err.contains("--findings-file")); + } + + #[test] + fn branch_record_plugin_validator_rejects_multiple_findings_sources() { + let args = vec![ + "record-plugin-validator-conflicts".to_string(), + "--run=12".to_string(), + "--findings-json=[]".to_string(), + "--findings-file=validator-findings.json".to_string(), + ]; + let err = parse_cow_branch_record_plugin_validator_args(&args) + .unwrap_err() + .to_string(); + assert!(err.contains("exactly one")); + } + + #[test] + fn parses_branch_run_plugin_validator() { + let args = vec![ + "run-plugin-validator".to_string(), + "--run=12".to_string(), + "--validator=./validator.php".to_string(), + "--format=json".to_string(), + ]; + let parsed = parse_cow_branch_run_plugin_validator_args(&args).unwrap(); + assert_eq!(parsed.run_id, "12"); + assert_eq!(parsed.validator, PathBuf::from("./validator.php")); + assert_eq!(parsed.format, "json"); + } + + #[test] + fn branch_run_plugin_validator_requires_validator() { + let args = vec!["run-plugin-validator".to_string(), "--run=12".to_string()]; + let err = parse_cow_branch_run_plugin_validator_args(&args) + .unwrap_err() + .to_string(); + assert!(err.contains("--validator ")); + assert!(err.contains("forkpress branch run-plugin-validator")); + } + #[test] fn parses_branch_merge_source_then_target() { let args = vec![ @@ -4416,9 +5093,10 @@ mod git_helper_tests { "--into".to_string(), "main".to_string(), ]; - let (source, target) = parse_cow_branch_merge_args(&args).unwrap(); - assert_eq!(source, "feature"); - assert_eq!(target, "main"); + let parsed = parse_cow_branch_merge_args(&args).unwrap(); + assert_eq!(parsed.source, "feature"); + assert_eq!(parsed.target, "main"); + assert_eq!(parsed.plugin_validator, None); } #[test] @@ -4429,9 +5107,10 @@ mod git_helper_tests { "main".to_string(), "feature".to_string(), ]; - let (source, target) = parse_cow_branch_merge_args(&args).unwrap(); - assert_eq!(source, "feature"); - assert_eq!(target, "main"); + let parsed = parse_cow_branch_merge_args(&args).unwrap(); + assert_eq!(parsed.source, "feature"); + assert_eq!(parsed.target, "main"); + assert_eq!(parsed.plugin_validator, None); } #[test] @@ -4441,9 +5120,75 @@ mod git_helper_tests { "feature".to_string(), "--into=main".to_string(), ]; - let (source, target) = parse_cow_branch_merge_args(&args).unwrap(); - assert_eq!(source, "feature"); - assert_eq!(target, "main"); + let parsed = parse_cow_branch_merge_args(&args).unwrap(); + assert_eq!(parsed.source, "feature"); + assert_eq!(parsed.target, "main"); + assert_eq!(parsed.plugin_validator, None); + } + + #[test] + fn parses_branch_merge_plugin_validator_equals_form() { + let args = vec![ + "merge".to_string(), + "feature".to_string(), + "--into=main".to_string(), + "--plugin-validator=./validator.php".to_string(), + ]; + let parsed = parse_cow_branch_merge_args(&args).unwrap(); + assert_eq!(parsed.source, "feature"); + assert_eq!(parsed.target, "main"); + assert_eq!( + parsed.plugin_validator.as_deref(), + Some(Path::new("./validator.php")) + ); + } + + #[test] + fn parses_branch_merge_plugin_validator_space_form() { + let args = vec![ + "merge".to_string(), + "--plugin-validator".to_string(), + "./validator.php".to_string(), + "feature".to_string(), + "--into".to_string(), + "main".to_string(), + ]; + let parsed = parse_cow_branch_merge_args(&args).unwrap(); + assert_eq!(parsed.source, "feature"); + assert_eq!(parsed.target, "main"); + assert_eq!( + parsed.plugin_validator.as_deref(), + Some(Path::new("./validator.php")) + ); + } + + #[test] + fn branch_merge_errors_on_empty_plugin_validator() { + let args = vec![ + "merge".to_string(), + "feature".to_string(), + "--into=main".to_string(), + "--plugin-validator=".to_string(), + ]; + let err = parse_cow_branch_merge_args(&args).unwrap_err().to_string(); + assert!(err.contains("--plugin-validator")); + assert!(err.contains("requires a path")); + assert!(err.contains("forkpress branch merge")); + } + + #[test] + fn branch_merge_errors_on_duplicate_plugin_validator() { + let args = vec![ + "merge".to_string(), + "feature".to_string(), + "--into=main".to_string(), + "--plugin-validator=./first.php".to_string(), + "--plugin-validator=./second.php".to_string(), + ]; + let err = parse_cow_branch_merge_args(&args).unwrap_err().to_string(); + assert!(err.contains("--plugin-validator")); + assert!(err.contains("only be provided once")); + assert!(err.contains("forkpress branch merge")); } #[test] @@ -4516,6 +5261,41 @@ mod git_helper_tests { ); } + #[test] + fn parses_branch_merge_audit_revalidate_alias_args() { + let cli = Cli::try_parse_from([ + "forkpress", + "branch", + "--work-dir", + ".forkpress", + "merge-audit", + "--revalidate", + "--run", + "7", + "--reviewer", + "alice", + "--format", + "json", + ]) + .unwrap(); + let Commands::Branch(args) = cli.command else { + panic!("expected branch command"); + }; + assert_eq!( + args.args, + vec![ + "merge-audit".to_string(), + "--revalidate".to_string(), + "--run".to_string(), + "7".to_string(), + "--reviewer".to_string(), + "alice".to_string(), + "--format".to_string(), + "json".to_string(), + ] + ); + } + #[test] fn parses_branch_merge_audit_id_band_skip_shortcut() { let cli = Cli::try_parse_from([ @@ -4753,6 +5533,7 @@ mod git_helper_tests { "--choice", "source", "--apply", + "--after-revalidate", "--note", "Use source title", "--reviewer", @@ -4771,6 +5552,7 @@ mod git_helper_tests { "--choice".to_string(), "source".to_string(), "--apply".to_string(), + "--after-revalidate".to_string(), "--note".to_string(), "Use source title".to_string(), "--reviewer".to_string(), diff --git a/crates/forkpress-storage/src/lib.rs b/crates/forkpress-storage/src/lib.rs index b37cd2ad..7dba46bd 100644 --- a/crates/forkpress-storage/src/lib.rs +++ b/crates/forkpress-storage/src/lib.rs @@ -469,24 +469,84 @@ pub fn create_cow_branch_from_tree( if dest.exists() { bail!("branch already exists: {branch}"); } - if cow_branch_copies_require_cow(layout)? { - copy_tree_cow_required(source, &dest)?; - } else { - copy_tree_cow(source, &dest)?; - } - let branch_root = ensure_cow_public_branch_root(layout, branch, &dest, file_view)?; - run_cow_bootstrap_script(layout, runtime, shared, &branch_root, "ForkPress", "admin")?; + let dest_parent = dest.parent().ok_or_else(|| { + anyhow!( + "branch destination has no parent directory: {}", + dest.display() + ) + })?; + fs::create_dir_all(dest_parent) + .with_context(|| format!("failed to create {}", dest_parent.display()))?; + let staging = unique_cow_operation_dir(dest_parent, "branch-create-stage", branch); + if path_exists_no_follow(&staging) { + bail!("temporary branch creation path already exists"); + } + cleanup_unpublished_cow_branch_birth_artifacts( + layout, runtime, shared, branch, &staging, &dest, file_view, + ) + .with_context(|| { + format!("failed to clean stale unpublished COW branch birth artifacts for '{branch}'") + })?; + let source_db = cow_sqlite_db_path(source); - if source_db.is_file() { + let mut staging_published = false; + let create_result = (|| -> Result<()> { + if cow_branch_copies_require_cow(layout)? { + copy_tree_cow_required(source, &staging)?; + } else { + copy_tree_cow(source, &staging)?; + } + + run_cow_bootstrap_script(layout, runtime, shared, &staging, "ForkPress", "admin")?; + if !source_db.is_file() { + bail!( + "source branch database does not exist: {}", + source_db.display() + ); + } record_cow_merge_base_snapshot(layout, runtime, shared, branch, &source_db)?; - } - let branch_db = cow_sqlite_db_path(&branch_root); - if branch_db.is_file() { + let branch_db = cow_sqlite_db_path(&staging); + if !branch_db.is_file() { + bail!( + "created branch database does not exist: {}", + branch_db.display() + ); + } allocate_cow_autoincrement_bands(layout, runtime, shared, branch, &branch_db)?; capture_cow_row_identities(layout, runtime, shared, branch, &branch_db, seed_branch)?; + record_cow_file_merge_base_snapshot(layout, runtime, shared, branch, &staging)?; + cow_storage_failpoint("after-branch-create-birth-metadata")?; + + if path_exists_no_follow(&dest) { + bail!("branch already exists: {branch}"); + } + fs::rename(&staging, &dest).with_context(|| { + format!( + "failed to publish branch {} to {}", + staging.display(), + dest.display() + ) + })?; + staging_published = true; + ensure_cow_public_branch_root(layout, branch, &dest, file_view)?; + write_cow_branch_list(layout)?; + Ok(()) + })(); + + if let Err(err) = create_result { + let cleanup = cleanup_failed_cow_branch_create( + layout, + runtime, + shared, + branch, + &staging, + &dest, + file_view, + staging_published, + ); + return Err(err).context(format!("failed to create COW branch '{branch}'; {cleanup}")); } - record_cow_file_merge_base_snapshot(layout, runtime, shared, branch, &branch_root)?; - write_cow_branch_list(layout)?; + println!("forkpress: COW cloned {source_label} -> '{branch}'"); if let Some((root_host, port)) = url_hint { println!( @@ -511,12 +571,146 @@ pub fn ensure_cow_branch_exists( let public_root = cow_branch_root(layout, branch); let storage_root = cow_branch_storage_root(layout, branch, file_view); if public_root.join("wp-load.php").is_file() || storage_root.join("wp-load.php").is_file() { + ensure_no_pending_cow_reset(layout, branch)?; + let root = if storage_root.join("wp-load.php").is_file() { + &storage_root + } else { + &public_root + }; + let db = validate_existing_cow_branch_birth_files(layout, branch, root)?; + validate_cow_branch_birth_metadata(layout, runtime, shared, branch, &db) + .with_context(|| { + format!( + "existing COW branch '{branch}' is missing required merge metadata; reset or delete/recreate it before reuse" + ) + })?; println!("forkpress: reusing existing branch {branch}"); return Ok(()); } create_cow_branch(layout, runtime, shared, branch, from, url_hint) } +fn validate_existing_cow_branch_birth_files( + layout: &Layout, + branch: &str, + root: &Path, +) -> Result { + let db = cow_sqlite_db_path(root); + if !db.is_file() { + bail!( + "existing COW branch '{branch}' is missing its database at {}. Reset or delete/recreate it before reuse.", + db.display() + ); + } + let base_db = cow_merge_base_db_path(layout, branch)?; + if !base_db.is_file() { + bail!( + "existing COW branch '{branch}' is missing its DB merge base at {}. Reset or delete/recreate it before reuse.", + base_db.display() + ); + } + let file_base = cow_merge_file_base_path(layout, branch)?; + if !file_base.is_file() { + bail!( + "existing COW branch '{branch}' is missing its filesystem merge base at {}. Reset or delete/recreate it before reuse.", + file_base.display() + ); + } + Ok(db) +} + +fn cow_reset_pending_path(layout: &Layout, branch: &str) -> Result { + validate_branch_name(branch)?; + Ok(layout + .cow_dir + .join("reset-pending") + .join(format!("{branch}.txt"))) +} + +fn write_cow_reset_pending(layout: &Layout, branch: &str, from: &str) -> Result<()> { + let path = cow_reset_pending_path(layout, branch)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + fs::write( + &path, + format!( + "branch={branch}\nfrom={from}\ncreated_at_unix={}\n", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + ), + ) + .with_context(|| { + format!( + "failed to record pending COW branch reset at {}", + path.display() + ) + }) +} + +fn clear_cow_reset_pending(layout: &Layout, branch: &str) -> Result<()> { + let path = cow_reset_pending_path(layout, branch)?; + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err).with_context(|| { + format!( + "failed to clear pending COW branch reset marker {}", + path.display() + ) + }), + } +} + +fn ensure_no_pending_cow_reset(layout: &Layout, branch: &str) -> Result<()> { + let path = cow_reset_pending_path(layout, branch)?; + if path.exists() { + bail!( + "COW branch '{branch}' has an unfinished reset recorded at {}. Rerun `forkpress branch reset {branch} --from ` or delete/recreate the branch before reuse or merge.", + path.display() + ); + } + Ok(()) +} + +fn cow_storage_failpoint(name: &str) -> Result<()> { + let configured = match std::env::var("FORKPRESS_COW_STORAGE_TEST_FAILPOINT") { + Ok(value) if !value.is_empty() => value, + _ => return Ok(()), + }; + if !configured + .split(',') + .map(str::trim) + .any(|candidate| candidate == name) + { + return Ok(()); + } + + match std::env::var("FORKPRESS_COW_STORAGE_TEST_FAILPOINT_ACTION") + .unwrap_or_else(|_| "throw".to_string()) + .as_str() + { + "exit" => std::process::exit(86), + _ => bail!("forced COW storage failpoint: {name}"), + } +} + +fn clear_cow_reset_pending_if_rollback_complete( + layout: &Layout, + branch: &str, + rollback_notes: &[&str], +) { + if rollback_notes + .iter() + .all(|note| !note.contains("incomplete")) + { + let _ = clear_cow_reset_pending(layout, branch); + } +} + pub fn show_cow_branch(layout: &Layout, branch: &str) -> Result<()> { validate_branch_name(branch)?; let file_view = read_site_manifest(layout)? @@ -573,6 +767,7 @@ pub fn reset_cow_branch( if !target.join("wp-load.php").is_file() { bail!("target branch does not exist: {branch}"); } + ensure_no_pending_cow_reset(layout, from)?; let source_db = cow_sqlite_db_path(&source); if !source_db.is_file() { @@ -613,6 +808,7 @@ pub fn reset_cow_branch( return Err(err).context("failed to stage COW branch reset"); } + write_cow_reset_pending(layout, branch, from)?; let mut target_moved_to_backup = false; let mut staging_published = false; let publish = (|| -> Result<()> { @@ -650,23 +846,73 @@ pub fn reset_cow_branch( target_moved_to_backup, staging_published, ); + clear_cow_reset_pending_if_rollback_complete(layout, branch, &[&rollback]); return Err(err).context(format!("failed to reset COW branch; {rollback}")); } + cow_storage_failpoint("after-branch-reset-publish")?; + let metadata_backup = match snapshot_cow_reset_metadata(layout, runtime, shared, branch, parent) + { + Ok(backup) => backup, + Err(err) => { + let failed = unique_cow_operation_dir(parent, "reset-failed", branch); + let rollback = rollback_failed_reset_publish( + branch, + &target, + &backup, + &staging, + &failed, + target_moved_to_backup, + staging_published, + ); + clear_cow_reset_pending_if_rollback_complete(layout, branch, &[&rollback]); + return Err(err).context(format!( + "failed to snapshot COW reset metadata before finalizing reset; {rollback}" + )); + } + }; + let finalize = (|| -> Result<()> { + record_cow_merge_base_snapshot(layout, runtime, shared, branch, &source_db)?; + let target_db = cow_sqlite_db_path(&target); + if target_db.is_file() { + allocate_cow_autoincrement_bands(layout, runtime, shared, branch, &target_db)?; + capture_cow_row_identities(layout, runtime, shared, branch, &target_db, Some(from))?; + } + record_cow_file_merge_base_snapshot(layout, runtime, shared, branch, &target)?; + invalidate_cow_git_ref(layout, branch)?; + Ok(()) + })(); + + if let Err(err) = finalize { + let metadata_rollback = metadata_backup.restore(layout); + let failed = unique_cow_operation_dir(parent, "reset-failed", branch); + let branch_rollback = rollback_failed_reset_publish( + branch, + &target, + &backup, + &staging, + &failed, + target_moved_to_backup, + staging_published, + ); + clear_cow_reset_pending_if_rollback_complete( + layout, + branch, + &[&metadata_rollback, &branch_rollback], + ); + return Err(err).context(format!( + "failed to finalize COW branch reset metadata; {metadata_rollback}; {branch_rollback}" + )); + } + clear_cow_reset_pending(layout, branch)?; + metadata_backup.cleanup(); + if let Err(err) = fs::remove_dir_all(&backup) { eprintln!( "forkpress: warning: reset succeeded but failed to remove old branch backup {}: {err}", backup.display() ); } - record_cow_merge_base_snapshot(layout, runtime, shared, branch, &source_db)?; - let target_db = cow_sqlite_db_path(&target); - if target_db.is_file() { - allocate_cow_autoincrement_bands(layout, runtime, shared, branch, &target_db)?; - capture_cow_row_identities(layout, runtime, shared, branch, &target_db, Some(from))?; - } - record_cow_file_merge_base_snapshot(layout, runtime, shared, branch, &target)?; - invalidate_cow_git_ref(layout, branch)?; println!("forkpress: reset COW branch '{branch}' from '{from}'"); Ok(()) @@ -725,6 +971,34 @@ fn capture_cow_row_identities( ) } +fn cleanup_cow_branch_birth_metadata( + layout: &Layout, + runtime: &PortableRuntime, + shared: &SharedPaths, + branch: &str, +) -> Result<()> { + let metadata_db = cow_merge_metadata_db_path(layout); + if !metadata_db.is_file() { + return Ok(()); + } + let args: Vec = vec![ + "cleanup-branch-birth-metadata".into(), + "--metadata-db".into(), + metadata_db.as_os_str().to_os_string(), + "--branch".into(), + branch.into(), + "--quiet".into(), + "1".into(), + ]; + run_php_script( + layout, + runtime, + shared, + "scripts/cow/merge.php", + args.iter().map(|arg| arg.as_os_str()), + ) +} + fn allocate_cow_autoincrement_bands( layout: &Layout, runtime: &PortableRuntime, @@ -753,6 +1027,34 @@ fn allocate_cow_autoincrement_bands( ) } +fn validate_cow_branch_birth_metadata( + layout: &Layout, + runtime: &PortableRuntime, + shared: &SharedPaths, + branch: &str, + db: &Path, +) -> Result<()> { + let metadata_db = cow_merge_metadata_db_path(layout); + let args: Vec = vec![ + "validate-branch-birth-metadata".into(), + "--db".into(), + db.as_os_str().to_os_string(), + "--metadata-db".into(), + metadata_db.as_os_str().to_os_string(), + "--branch".into(), + branch.into(), + "--quiet".into(), + "1".into(), + ]; + run_php_script( + layout, + runtime, + shared, + "scripts/cow/merge.php", + args.iter().map(|arg| arg.as_os_str()), + ) +} + fn record_cow_merge_base_snapshot( layout: &Layout, runtime: &PortableRuntime, @@ -851,6 +1153,7 @@ pub fn merge_cow_branch( shared: &SharedPaths, source: &str, target: &str, + plugin_validator: Option<&Path>, ) -> Result<()> { validate_branch_name(source)?; validate_branch_name(target)?; @@ -869,6 +1172,8 @@ pub fn merge_cow_branch( if !target_root.join("wp-load.php").is_file() { bail!("target branch does not exist: {target}"); } + ensure_no_pending_cow_reset(layout, source)?; + ensure_no_pending_cow_reset(layout, target)?; let source_db = cow_sqlite_db_path(&source_root); let target_db = cow_sqlite_db_path(&target_root); @@ -898,9 +1203,11 @@ pub fn merge_cow_branch( base_files.display() ); } + validate_cow_branch_birth_metadata(layout, runtime, shared, source, &source_db) + .with_context(|| format!("branch '{source}' is missing required merge metadata"))?; let metadata_db = cow_merge_metadata_db_path(layout); - let args: Vec = vec![ + let mut args: Vec = vec![ "--base-db".into(), base_db.as_os_str().to_os_string(), "--source-db".into(), @@ -920,6 +1227,10 @@ pub fn merge_cow_branch( "--target-root".into(), target_root.as_os_str().to_os_string(), ]; + if let Some(plugin_validator) = plugin_validator { + args.push("--plugin-validator".into()); + args.push(plugin_validator.as_os_str().to_os_string()); + } run_php_script( layout, runtime, @@ -1025,6 +1336,140 @@ pub fn inspect_cow_merge_audit( ) } +pub fn recover_cow_merge_crash( + layout: &Layout, + runtime: &PortableRuntime, + shared: &SharedPaths, + run_id: Option<&str>, + restore_target_db: bool, + restore_files: bool, + format: &str, +) -> Result<()> { + let metadata_db = cow_merge_metadata_db_path(layout); + let mut args: Vec = vec![ + "recover-crash".into(), + "--metadata-db".into(), + metadata_db.as_os_str().to_os_string(), + "--format".into(), + format.into(), + ]; + if let Some(run_id) = run_id { + args.push("--run".into()); + args.push(run_id.into()); + } + if restore_target_db { + args.push("--restore-target-db".into()); + } + if restore_files { + args.push("--restore-files".into()); + } + run_php_script( + layout, + runtime, + shared, + "scripts/cow/merge.php", + args.iter().map(|arg| arg.as_os_str()), + ) +} + +pub fn revalidate_cow_merge_reviews( + layout: &Layout, + runtime: &PortableRuntime, + shared: &SharedPaths, + run_id: Option<&str>, + reviewer: Option<&str>, + format: &str, +) -> Result<()> { + let metadata_db = cow_merge_metadata_db_path(layout); + let mut args: Vec = vec![ + "revalidate-reviews".into(), + "--metadata-db".into(), + metadata_db.as_os_str().to_os_string(), + "--format".into(), + format.into(), + ]; + if let Some(run_id) = run_id { + args.push("--run".into()); + args.push(run_id.into()); + } + if let Some(reviewer) = reviewer { + args.push("--reviewer".into()); + args.push(reviewer.into()); + } + run_php_script( + layout, + runtime, + shared, + "scripts/cow/merge.php", + args.iter().map(|arg| arg.as_os_str()), + ) +} + +pub fn record_cow_plugin_validator_conflicts( + layout: &Layout, + runtime: &PortableRuntime, + shared: &SharedPaths, + run_id: &str, + findings_json: Option<&str>, + findings_file: Option<&Path>, + format: &str, +) -> Result<()> { + let metadata_db = cow_merge_metadata_db_path(layout); + let mut args: Vec = vec![ + "record-plugin-validator-conflicts".into(), + "--metadata-db".into(), + metadata_db.as_os_str().to_os_string(), + "--run".into(), + run_id.into(), + "--format".into(), + format.into(), + ]; + if let Some(findings_json) = findings_json { + args.push("--findings-json".into()); + args.push(findings_json.into()); + } + if let Some(findings_file) = findings_file { + args.push("--findings-file".into()); + args.push(findings_file.as_os_str().to_os_string()); + } + run_php_script( + layout, + runtime, + shared, + "scripts/cow/merge.php", + args.iter().map(|arg| arg.as_os_str()), + ) +} + +pub fn run_cow_plugin_validator( + layout: &Layout, + runtime: &PortableRuntime, + shared: &SharedPaths, + run_id: &str, + validator: &Path, + format: &str, +) -> Result<()> { + let metadata_db = cow_merge_metadata_db_path(layout); + let args: Vec = vec![ + "run-plugin-validator".into(), + "--metadata-db".into(), + metadata_db.as_os_str().to_os_string(), + "--run".into(), + run_id.into(), + "--validator".into(), + validator.as_os_str().to_os_string(), + "--format".into(), + format.into(), + ]; + run_php_script( + layout, + runtime, + shared, + "scripts/cow/merge.php", + args.iter().map(|arg| arg.as_os_str()), + ) +} + pub fn review_cow_merge_audit_record( layout: &Layout, runtime: &PortableRuntime, @@ -1069,6 +1514,7 @@ pub fn resolve_cow_merge_conflict( conflict_id: &str, choice: &str, apply: bool, + after_revalidate: bool, note: Option<&str>, reviewer: Option<&str>, ) -> Result<()> { @@ -1085,6 +1531,9 @@ pub fn resolve_cow_merge_conflict( if apply { args.push("--apply".into()); } + if after_revalidate { + args.push("--after-revalidate".into()); + } if let Some(note) = note { args.push("--note".into()); args.push(note.into()); @@ -2246,13 +2695,24 @@ fn compact_macos_apfs_sparsebundle_file_view_impl(layout: &Layout) -> Result<()> ); } - let output = hdiutil_output([ - OsString::from("compact"), - layout.macos_cow_image.as_os_str().to_owned(), - ])?; - if !output.status.success() { - bail!("{}", hdiutil_failure_message(&output)); + let mut output = None; + for attempt in 0..5 { + let attempt_output = hdiutil_output([ + OsString::from("compact"), + layout.macos_cow_image.as_os_str().to_owned(), + ])?; + if attempt_output.status.success() { + output = Some(attempt_output); + break; + } + + let message = hdiutil_failure_message(&attempt_output); + if !macos_hdiutil_compact_retryable_message(&message) || attempt == 4 { + bail!("{message}"); + } + std::thread::sleep(std::time::Duration::from_millis(250 * (attempt + 1) as u64)); } + let output = output.expect("compact retry loop must return output or fail"); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); @@ -2272,6 +2732,12 @@ fn compact_macos_apfs_sparsebundle_file_view_impl(layout: &Layout) -> Result<()> Ok(()) } +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn macos_hdiutil_compact_retryable_message(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + message.contains("resource temporarily unavailable") || message.contains("resource busy") +} + #[cfg(not(target_os = "macos"))] fn compact_macos_apfs_sparsebundle_file_view_impl(_layout: &Layout) -> Result<()> { bail!("macOS APFS sparsebundle compact is only available on macOS") @@ -2602,6 +3068,319 @@ fn remove_sqlite_file_and_sidecars(db: &Path) -> Result<()> { Ok(()) } +fn remove_branch_path_if_ours(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() || meta.is_file() => { + fs::remove_file(path) + .with_context(|| format!("failed to remove {}", path.display()))?; + Ok(true) + } + Ok(meta) if meta.is_dir() => { + fs::remove_dir_all(path) + .with_context(|| format!("failed to remove {}", path.display()))?; + Ok(true) + } + Ok(_) => bail!("{} is not a removable branch path", path.display()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(err).with_context(|| format!("failed to inspect {}", path.display())), + } +} + +fn cleanup_cow_branch_birth_files( + layout: &Layout, + branch: &str, + staging: &Path, + dest: &Path, + file_view: FileViewStrategy, + staging_published: bool, +) -> Vec { + let mut errors = Vec::new(); + if let Err(err) = remove_branch_path_if_ours(staging) { + errors.push(err.to_string()); + } + if staging_published { + let public_root = cow_branch_root(layout, branch); + if public_root != dest { + match fs::symlink_metadata(&public_root) { + Ok(meta) if meta.file_type().is_symlink() => match fs::read_link(&public_root) { + Ok(target) if target == dest => { + if let Err(err) = fs::remove_file(&public_root) { + errors + .push(format!("failed to remove {}: {err}", public_root.display())); + } + } + Ok(target) => errors.push(format!( + "{} points to {}; expected {}", + public_root.display(), + target.display(), + dest.display() + )), + Err(err) => { + errors.push(format!("failed to read {}: {err}", public_root.display())) + } + }, + Ok(_) if file_view == FileViewStrategy::MacosApfsSparsebundle => { + errors.push(format!( + "{} is not the expected branch symlink", + public_root.display() + )); + } + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => errors.push(format!( + "failed to inspect {}: {err}", + public_root.display() + )), + } + } + if let Err(err) = remove_branch_path_if_ours(dest) { + errors.push(err.to_string()); + } + } + match cow_merge_base_db_path(layout, branch) { + Ok(base_db) => { + if let Err(err) = remove_sqlite_file_and_sidecars(&base_db) { + errors.push(err.to_string()); + } + } + Err(err) => errors.push(err.to_string()), + } + match cow_merge_file_base_path(layout, branch) { + Ok(file_base) => match fs::remove_file(&file_base) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => errors.push(format!("failed to remove {}: {err}", file_base.display())), + }, + Err(err) => errors.push(err.to_string()), + } + errors +} + +fn cleanup_failed_cow_branch_create( + layout: &Layout, + runtime: &PortableRuntime, + shared: &SharedPaths, + branch: &str, + staging: &Path, + dest: &Path, + file_view: FileViewStrategy, + staging_published: bool, +) -> String { + let mut errors = + cleanup_cow_branch_birth_files(layout, branch, staging, dest, file_view, staging_published); + if let Err(err) = cleanup_cow_branch_birth_metadata(layout, runtime, shared, branch) { + errors.push(err.to_string()); + } + if errors.is_empty() { + "rolled back branch creation artifacts".to_string() + } else { + format!("rollback incomplete: {}", errors.join("; ")) + } +} + +fn cleanup_unpublished_cow_branch_birth_artifacts( + layout: &Layout, + runtime: &PortableRuntime, + shared: &SharedPaths, + branch: &str, + staging: &Path, + dest: &Path, + file_view: FileViewStrategy, +) -> Result<()> { + let mut errors = + cleanup_cow_branch_birth_files(layout, branch, staging, dest, file_view, false); + if let Err(err) = cleanup_cow_branch_birth_metadata(layout, runtime, shared, branch) { + errors.push(err.to_string()); + } + if let Err(err) = clear_cow_reset_pending(layout, branch) { + errors.push(err.to_string()); + } + if errors.is_empty() { + Ok(()) + } else { + bail!("{}", errors.join("; ")) + } +} + +struct CowResetMetadataBackup { + branch: String, + root: PathBuf, + metadata_db: Option, + merge_base_db: Option, + file_base: Option, +} + +impl CowResetMetadataBackup { + fn restore_sqlite(backup: &Option, dest: &Path, label: &str) -> Result<()> { + remove_sqlite_file_and_sidecars(dest)?; + if let Some(backup) = backup { + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + fs::copy(backup, dest).with_context(|| { + format!( + "failed to restore {label} {} from {}", + dest.display(), + backup.display() + ) + })?; + } + Ok(()) + } + + fn restore_file(backup: &Option, dest: &Path, label: &str) -> Result<()> { + match fs::remove_file(dest) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + return Err(err).with_context(|| format!("failed to remove {}", dest.display())); + } + } + if let Some(backup) = backup { + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + fs::copy(backup, dest).with_context(|| { + format!( + "failed to restore {label} {} from {}", + dest.display(), + backup.display() + ) + })?; + } + Ok(()) + } + + fn restore(&self, layout: &Layout) -> String { + let mut errors = Vec::new(); + if let Err(err) = Self::restore_sqlite( + &self.metadata_db, + &cow_merge_metadata_db_path(layout), + "metadata database", + ) { + errors.push(err.to_string()); + } + match cow_merge_base_db_path(layout, &self.branch) { + Ok(dest) => { + if let Err(err) = Self::restore_sqlite(&self.merge_base_db, &dest, "DB merge base") + { + errors.push(err.to_string()); + } + } + Err(err) => errors.push(err.to_string()), + } + match cow_merge_file_base_path(layout, &self.branch) { + Ok(dest) => { + if let Err(err) = + Self::restore_file(&self.file_base, &dest, "filesystem merge base") + { + errors.push(err.to_string()); + } + } + Err(err) => errors.push(err.to_string()), + }; + self.cleanup(); + if errors.is_empty() { + "restored previous reset metadata".to_string() + } else { + format!("metadata rollback incomplete: {}", errors.join("; ")) + } + } + + fn cleanup(&self) { + if let Err(err) = fs::remove_dir_all(&self.root) + && err.kind() != std::io::ErrorKind::NotFound + { + eprintln!( + "forkpress: warning: failed to remove reset metadata backup {}: {err}", + self.root.display() + ); + } + } +} + +fn snapshot_optional_sqlite( + layout: &Layout, + runtime: &PortableRuntime, + shared: &SharedPaths, + source: &Path, + dest: &Path, + label: &str, +) -> Result> { + if !source.is_file() { + return Ok(None); + } + hot_copy_sqlite_database(layout, runtime, shared, source, dest) + .with_context(|| format!("failed to snapshot {label} {}", source.display()))?; + Ok(Some(dest.to_path_buf())) +} + +fn snapshot_optional_file(source: &Path, dest: &Path, label: &str) -> Result> { + if !source.is_file() { + return Ok(None); + } + fs::copy(source, dest) + .with_context(|| format!("failed to snapshot {label} {}", source.display()))?; + Ok(Some(dest.to_path_buf())) +} + +fn snapshot_cow_reset_metadata( + layout: &Layout, + runtime: &PortableRuntime, + shared: &SharedPaths, + branch: &str, + parent: &Path, +) -> Result { + let root = unique_cow_operation_dir(parent, "reset-metadata-backup", branch); + if path_exists_no_follow(&root) { + bail!("temporary reset metadata backup path already exists"); + } + fs::create_dir_all(&root).with_context(|| format!("failed to create {}", root.display()))?; + + let result = (|| -> Result { + let metadata_db = snapshot_optional_sqlite( + layout, + runtime, + shared, + &cow_merge_metadata_db_path(layout), + &root.join("metadata.sqlite"), + "merge metadata database", + )?; + let merge_base_db = snapshot_optional_sqlite( + layout, + runtime, + shared, + &cow_merge_base_db_path(layout, branch)?, + &root.join("merge-base.sqlite"), + "DB merge base", + )?; + let file_base = snapshot_optional_file( + &cow_merge_file_base_path(layout, branch)?, + &root.join("file-base.json"), + "filesystem merge base", + )?; + + Ok(CowResetMetadataBackup { + branch: branch.to_string(), + root: root.clone(), + metadata_db, + merge_base_db, + file_base, + }) + })(); + if result.is_err() + && let Err(err) = fs::remove_dir_all(&root) + { + eprintln!( + "forkpress: warning: failed to remove incomplete reset metadata backup {}: {err}", + root.display() + ); + } + result +} + fn hot_copy_sqlite_database( layout: &Layout, runtime: &PortableRuntime, @@ -2944,6 +3723,273 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn branch_birth_cleanup_removes_staged_branch_and_merge_bases() { + let root = std::env::temp_dir().join(format!( + "forkpress-branch-birth-cleanup-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let layout = Layout::new(root.join(".forkpress")).unwrap(); + let staging = root.join(".forkpress-branch-create-stage-feature"); + let dest = root.join("feature"); + fs::create_dir_all(&staging).unwrap(); + fs::write(staging.join("wp-load.php"), b"")); + + clear_cow_reset_pending(&layout, "feature").unwrap(); + write_cow_reset_pending(&layout, "feature", "main").unwrap(); + let staging = root.join(".forkpress-branch-create-stage-feature"); + let dest = root.join("feature"); + cleanup_unpublished_cow_branch_birth_artifacts( + &layout, + &PortableRuntime::from_layout(&layout), + &SharedPaths { + work_dir: layout.work_dir.clone(), + php_bin: None, + }, + "feature", + &staging, + &dest, + FileViewStrategy::Copy, + ) + .unwrap(); + ensure_no_pending_cow_reset(&layout, "feature").unwrap(); + + write_cow_reset_pending(&layout, "feature", "main").unwrap(); + clear_cow_reset_pending_if_rollback_complete( + &layout, + "feature", + &["restored previous branch contents"], + ); + ensure_no_pending_cow_reset(&layout, "feature").unwrap(); + + write_cow_reset_pending(&layout, "feature", "main").unwrap(); + clear_cow_reset_pending_if_rollback_complete( + &layout, + "feature", + &["rollback incomplete: previous branch backup is missing"], + ); + assert!(cow_reset_pending_path(&layout, "feature").unwrap().exists()); + clear_cow_reset_pending(&layout, "feature").unwrap(); + ensure_no_pending_cow_reset(&layout, "feature").unwrap(); + assert!(!cow_reset_pending_path(&layout, "feature").unwrap().exists()); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn reset_metadata_backup_restores_previous_artifacts() { + let root = std::env::temp_dir().join(format!( + "forkpress-reset-metadata-backup-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let layout = Layout::new(root.join(".forkpress")).unwrap(); + let branch = "feature"; + let backup_root = root.join(".forkpress-reset-metadata-backup-feature-test"); + fs::create_dir_all(&backup_root).unwrap(); + + let metadata_db = cow_merge_metadata_db_path(&layout); + fs::create_dir_all(metadata_db.parent().unwrap()).unwrap(); + fs::write(&metadata_db, b"new metadata").unwrap(); + fs::write( + sqlite_sidecar_path(&metadata_db, "-wal"), + b"new metadata wal", + ) + .unwrap(); + fs::write(backup_root.join("metadata.sqlite"), b"old metadata").unwrap(); + + let merge_base = cow_merge_base_db_path(&layout, branch).unwrap(); + fs::create_dir_all(merge_base.parent().unwrap()).unwrap(); + fs::write(&merge_base, b"new base").unwrap(); + fs::write(sqlite_sidecar_path(&merge_base, "-wal"), b"new base wal").unwrap(); + fs::write(backup_root.join("merge-base.sqlite"), b"old base").unwrap(); + + let file_base = cow_merge_file_base_path(&layout, branch).unwrap(); + fs::create_dir_all(file_base.parent().unwrap()).unwrap(); + fs::write(&file_base, b"{\"new\":true}").unwrap(); + fs::write(backup_root.join("file-base.json"), b"{\"old\":true}").unwrap(); + + let backup = CowResetMetadataBackup { + branch: branch.to_string(), + root: backup_root.clone(), + metadata_db: Some(backup_root.join("metadata.sqlite")), + merge_base_db: Some(backup_root.join("merge-base.sqlite")), + file_base: Some(backup_root.join("file-base.json")), + }; + + let message = backup.restore(&layout); + assert!(message.contains("restored previous reset metadata")); + assert_eq!(fs::read(&metadata_db).unwrap(), b"old metadata"); + assert_eq!(fs::read(&merge_base).unwrap(), b"old base"); + assert_eq!(fs::read(&file_base).unwrap(), b"{\"old\":true}"); + assert!(!path_exists_no_follow(&sqlite_sidecar_path( + &metadata_db, + "-wal" + ))); + assert!(!path_exists_no_follow(&sqlite_sidecar_path( + &merge_base, + "-wal" + ))); + assert!(!path_exists_no_follow(&backup_root)); + + fs::remove_dir_all(root).unwrap(); + } + #[test] fn windows_refs_clone_plan_keeps_unaligned_tail_out_of_ioctl() { assert_eq!(windows_refs_clone_plan(0), (0, 0)); @@ -3063,4 +4109,17 @@ mod tests { assert_eq!(out[63], 0); assert_eq!(out[62], b'a'); } + + #[test] + fn macos_sparsebundle_compact_retries_transient_hdiutil_busy_errors() { + assert!(macos_hdiutil_compact_retryable_message( + "hdiutil exited with status exit status: 1\nstderr:\nhdiutil: compact failed - Resource temporarily unavailable" + )); + assert!(macos_hdiutil_compact_retryable_message( + "hdiutil exited with status exit status: 1\nstderr:\nhdiutil: compact failed - resource busy" + )); + assert!(!macos_hdiutil_compact_retryable_message( + "hdiutil exited with status exit status: 1\nstderr:\nhdiutil: compact failed - image not recognized" + )); + } } diff --git a/docs/merge-crash-consistency.md b/docs/merge-crash-consistency.md new file mode 100644 index 00000000..310706e0 --- /dev/null +++ b/docs/merge-crash-consistency.md @@ -0,0 +1,226 @@ +# Merge Crash Consistency + +Status: reliability map + +ForkPress merge touches four durable surfaces: + +- WordPress SQLite database +- branch filesystem tree +- ForkPress merge metadata database and rollback artifacts +- Git refs and branch publication metadata + +The reliability target is not “nothing can fail”. The target is: after any +failure, ForkPress can identify whether the target branch is still at the old +state, fully at the new state, or in a manual-recovery state with preserved +artifacts. It should not silently report success after a partial merge. + +## Current Covered Boundaries + +The PHP merge suite covers these rollback classes: + +- Target DB transaction failures before commit. +- Metadata transaction failures before commit. +- Metadata commit failures after target DB commit, with target DB restore. +- Target DB restore failures, with rollback-failure metadata and JSONL + recovery artifacts. +- File transaction failures, with per-path backups and metadata rollback. +- File rollback failures, with preserved filesystem backup artifacts. +- Whole-branch DB plus file rollback after a file-phase failure. +- Late whole-branch rollback after files were applied but metadata finalization + failed. +- Failed-run metadata write failures, including artifact-only fallback. +- ID-band allocation rollback across target DB, metadata DB, and recovery + artifacts. +- Process death immediately before or after the target DB commit but before + metadata commit, with a durable crash-recovery artifact that points at the + pre-merge target DB snapshot, proves the run was not falsely marked + completed, blocks retries while pending, and can be inspected/restored + through `recover-crash --restore-target-db`. +- Process death immediately after an individual filesystem operation but before + filesystem metadata commit, with durable crash-recovery artifacts that point + at both the staged filesystem transaction and the whole-branch pre-merge DB, + metadata, and filesystem snapshots. File-only recovery can use + `recover-crash --restore-files`; whole-branch recovery uses + `recover-crash --restore-target-db --restore-files`. +- Process death after the DB phase of a DB+file merge but before the first file + operation, with a durable whole-branch crash-recovery artifact that points at + the pre-merge target DB, metadata DB, and filesystem-root snapshots and can be + restored through `recover-crash --restore-target-db --restore-files`. +- A subsequent merge against metadata with pending crash-recovery artifacts is + rejected before DB or file mutation, forcing the operator to inspect and + restore the pending recovery state first. The product-level entry point is + `forkpress branch recover-crash`; the lower-level PHP helper remains available + as `recover-crash` for focused test fixtures. +- Process exit after crash recovery restores a target DB or filesystem + transaction but before recovery artifact cleanup leaves the artifact and + rollback material retryable; a second recovery removes the artifact and + cleanup material after confirming the target is restored. +- The product E2E suite drives `before-target-db-commit` through the public + `forkpress branch merge` command, verifies `forkpress branch recover-crash` + reports the pending artifact, verifies a second public merge is blocked while + recovery is pending, restores the target DB through the public recovery + command, and reruns the public merge successfully. +- The product E2E suite drives `before-metadata-commit` through public + `forkpress branch merge`, verifying that a DB-durable but metadata-incomplete + public merge is reported, blocks retries, restores through public recovery, + and can be retried. +- The product E2E suite drives `before-file-op` through public + `forkpress branch merge`, verifying that a DB-complete but pre-filesystem + public merge is reported, blocks retries, restores DB and files through + public recovery, and can be retried. +- The product E2E suite drives `after-crash-recovery-restore` through public + `forkpress branch recover-crash`, verifying that an interrupted recovery + cleanup remains retryable and a second public recovery clears the pending + artifact. +- The product E2E suite also drives `after-file-op` through public + `forkpress branch merge`, verifies the pending filesystem crash recovery + blocks retries, restores both DB and files through public recovery, and + reruns the public merge successfully. + +The Git server suite covers these publication classes: + +- Git-created branch publication allocates ID bands, captures DB merge base, + captures file merge base, and captures row identities. +- Git-created branch publication failure removes branch storage and file-base + artifacts. +- Process exit before Git-created branch metadata capture does not publish a + visible branch without birth metadata; the next Git apply can recreate the + branch from the pushed ref and publish it with metadata. +- Process exit after Git-created branch metadata capture but before tree + publication can leave unpublished birth metadata; a retry clears those stale + branch-birth artifacts before publishing the branch from the pushed ref. +- Process exit after Git-created storage publication but before public branch + linking can leave orphan storage; a retry removes the unpublished storage and + stale birth artifacts, then recreates and links the branch from the pushed ref. +- Process exit after separate-storage Git-created public branch linking but + before branch-list publication can leave a visible public symlink and stale + branch list; the next Git apply reconciles the branch list while preserving + finalized DB/file bases, ID-band metadata, and row identity metadata. +- Git-created branch metadata publication failure after ID-band and row-identity + capture removes branch storage, merge-base artifacts, and merge metadata. +- Git-created branch-list publication failure after the list write removes + branch storage, DB merge base artifacts, file-base artifacts, merge metadata, + and restores the branch list. +- Process exit immediately after Git-created branch-list publication leaves the + created branch visible with DB/file merge bases, ID-band metadata, and row + identity metadata already finalized. +- Process exit after Git-created branch metadata capture but before branch-list + publication can leave `branches.txt` stale; the next Git apply refreshes the + branch list from the durable branch tree while preserving the finalized DB/file + bases, ID-band metadata, and row identity metadata. +- Process exit after an existing Git branch update publishes its staged tree can + leave an old update backup; the next successful Git apply keeps the published + branch state and removes stale update artifacts for valid branch storage. +- Process exit after staging a Git branch deletion can leave stale delete + backups and a stale branch-list entry; the next Git apply keeps the branch + deleted, reconciles the branch list, and removes stale delete artifacts. +- Process exit during COW Git unreachable-object pruning may leave some + unreachable objects behind, but reachable branch objects are preserved and the + next prune removes the remaining unreachable objects. +- Multi-branch Git-created ID-band metadata failure rolls back created branch + metadata and merge-base artifacts. +- Stale-source Git-created branch publication is rejected. +- Normal branch creation removes stale unpublished merge-base and branch-birth + metadata for the requested branch before allocating fresh birth metadata, so + a retry after an interrupted create cannot inherit stale ID bands or merge + bases. It also clears stale pending-reset markers for deleted/recreated + branches. +- The product E2E suite drives a public `forkpress branch create` exit after + branch-birth metadata is captured but before publication, then retries the + same public branch creation and verifies fresh DB/file merge bases and ID-band + metadata. +- Branch reset writes a pending-reset marker before publishing replacement + branch contents and clears it only after merge-base, file-base, ID-band, row + identity, and Git-ref metadata are finalized. Public branch reuse and merge + refuse branches with an unfinished reset marker so a hard kill in the reset + publication window cannot silently merge with stale branch-birth metadata. +- The product E2E suite drives a public `forkpress branch reset` exit after the + replacement branch is published but before reset metadata finalization, + verifies public merge is blocked by the pending-reset marker, then reruns the + public reset and verifies fresh DB/file merge bases and ID-band metadata. +- The product E2E suite drives an actual smart-HTTP Git push for a Git-created + branch with the server exiting immediately after branch-list publication, + restarts ForkPress in a fresh process, then verifies the branch is visible, + has DB/file merge-base artifacts, has a matching Git ref, and can merge into + `main`. + +## Missing Fault Injection + +The remaining work is a broader product-level kill harness for entry points that +are not yet covered by the targeted public CLI failpoint tests. The public E2E +suite already kills `forkpress branch merge`, `forkpress branch create`, +`forkpress branch reset`, and `forkpress branch recover-crash` at representative +durable boundaries. The lower-level PHP/Git suites cover additional internals, +including merge subprocess death before/after target DB commit, before metadata +commit, before the file phase, after an individual file operation, and during +crash-recovery cleanup; plus Git-created branch publication before metadata +capture, after metadata capture, after storage publish, after public-link +creation, before/after branch-list publication, after existing-branch update +publish, after branch-delete staging, and after object pruning. + +The remaining release-hardening work is: + +- Broaden actual Git push failpoint coverage beyond the representative + Git-created branch-list publication checkpoint, then restart in a new process + and verify the public audit/recovery commands report the same state as the + lower-level harnesses. +- Add platform-specific kill coverage around APFS sparsebundle detach/compact. +- Add kill coverage around cleanup of rollback artifacts outside the Git + object-pruning and crash-recovery restore paths. +- Assert for each product-level checkpoint that the target branch is either the + pre-merge snapshot, the fully completed merged state, or a blocked + manual-recovery state with durable artifacts. + +These should be tested by an external harness that can terminate the process at +named checkpoints and then run a recovery/audit command in a new process. + +## Checkpoint Model + +Add named failpoints around durable boundaries: + +- `before-target-db-commit` +- `after-target-db-commit` +- `before-metadata-commit` +- `after-metadata-commit` +- `before-file-op` +- `after-file-op` +- `before-git-ref-update` +- `after-git-ref-update` +- `before-branch-list-update` +- `after-branch-list-update` +- `before-cleanup` +- `after-cleanup` + +Failpoints should be disabled in production unless an explicit test-only +environment variable is set. They should be deterministic: either exit the +process or throw before the operation, never sleep or race. + +## Recovery Expectations + +Each crash test should assert one of: + +- Target branch equals the pre-merge snapshot. +- Target branch equals the fully merged state and metadata says completed. +- Target branch is marked failed with rollback artifacts sufficient for manual + recovery. + +Any state that has changed target content, no completed run, and no recovery +artifact is a release blocker. + +If a crash-recovery artifact is present, new merges using the same metadata DB +must fail before mutation until `forkpress branch recover-crash` has inspected +and restored the pending DB snapshot and/or filesystem transaction. + +## Test Shape + +The first external crash harness should: + +1. Create a branch with one DB change and one filesystem change. +2. Run merge with one failpoint enabled. +3. Start a new process and inspect target DB, target files, metadata, and + rollback artifacts. +4. Repeat for every named checkpoint. + +This is intentionally separate from `tests/cow/merge.php`, because a PHP unit +test cannot simulate process death after the interpreter or SQLite has flushed +only part of the state. diff --git a/docs/merge-reliability.md b/docs/merge-reliability.md new file mode 100644 index 00000000..d4489f21 --- /dev/null +++ b/docs/merge-reliability.md @@ -0,0 +1,107 @@ +# Merge Reliability Matrix + +Status: 2026-05-15 + +ForkPress COW merge is intentionally conservative: it should either apply a +source change exactly, preserve target state, or leave an auditable conflict. +It should not silently rewrite WordPress data to make conflicts disappear. + +This page tracks what is already covered and where merge reliability still +depends on review, future validators, or broader end-to-end tests. + +## Objective Audit + +This audit maps the current reliability objective to concrete artifacts. It is +intentionally stricter than "tests pass": an item is only treated as covered +when there is a test or document that exercises the specific merge invariant. + +| Objective item | Evidence in this PR | Remaining gap | +| --- | --- | --- | +| 1. Real WordPress semantic merge coverage | `tests/cow/e2e.sh` creates source and target branches through runtime WordPress requests, then merges pages, branch-local page edits/deletes, postmeta, users/usermeta, authors, comments/commentmeta, hierarchical taxonomy terms, nav menus and menu locations, reusable `wp_block` rows, page-to-reusable-block refs, options and JSON options with object IDs, media uploads with generated-size metadata/files, a CPT-like `forkpress_note`, and plugin-shaped custom tables/files. `tests/cow/merge.php` adds deterministic WordPress row fingerprint and validator coverage. | Add broader concurrent edit/delete matrices for complete WP objects and deterministic repair policies only where the owner object is unambiguous. | +| 2. Plugin-specific merge semantics | `docs/plugin-merge-validators.md` defines the validator contract, including rejecting contradictory status/finding output. `scripts/cow/merge.php` discovers active plugin and mu-plugin validators, runs explicit validators, records plugin-scoped conflicts, and rolls back inline validator failures. `tests/cow/merge.php` covers clean custom-table graph merges, validator findings, audit/review grouping, validator rerun evidence, file-root context, active-plugin discovery, explicit-ID plugin graph validation, contradictory validator output rejection, and failed-validator rollback. `tests/cow/e2e.sh` covers a runtime plugin-shaped graph across custom tables, JSON, serialized data, options, postmeta, CPT data, and files. | Add validators for real plugins and add merge drivers only for plugin-owned repairs that can prove correctness. | +| 3. Remaining review-only schema cases | `scripts/cow/merge.php` validates source-added views/triggers, preserves invalid dependency cases as conflicts, and supports safe schema object resolution for deterministic subsets. `tests/cow/merge.php` covers cyclic/invalid view and trigger dependency handling, source-added dependent view ordering, and rebuild validation cases. | Improve dependency planning for more safe reorderings. Cyclic or semantically ambiguous cases should stay review-only. | +| 4. Filesystem merge hardening | `tests/cow/merge.php` covers file adds/deletes/conflicts, binary hash comparisons, symlink safety, directory/file and file/directory replacement review, rollback artifacts, upload-file validators, generated attachment file checks, original/generated dimension drift, generated-size filename drift, featured-image/image-block/media metadata drift, and unsafe metadata paths. `tests/cow/e2e.sh` verifies real merged upload originals and generated thumbnails. | Add stricter uploads-specific validators for more drift shapes and explicit attachment-regeneration decisions. | +| 5. Crash consistency across DB/files/metadata/Git | `docs/merge-crash-consistency.md` lists the covered boundaries. `tests/cow/merge.php` covers target DB, metadata, file, rollback-failure, ID-band, and whole-branch rollback paths. `tests/cow/e2e.sh` drives public merge/create/reset/recover crash/retry flows for DB, metadata, before-file, after-file, recovery-cleanup, branch-birth, branch-reset publication failpoints, and one actual smart-HTTP Git-created branch push interrupted after branch-list publication and verified after a fresh server restart. `tests/cow/git_server.php` covers Git-created branch birth, Git update/delete, stale cleanup, and object-prune interruption. | Broaden external kill harness coverage across the remaining Git-push failpoints and platform-specific APFS/cleanup checkpoints, then verify post-crash state from a fresh process. | +| 6. Branch birth always captures merge bases | `crates/forkpress-storage/src/lib.rs` requires branch birth metadata for branch reuse/merge and blocks pending reset states. `tests/cow/git_server.php` covers Git-created branch DB/file base, ID-band, row identity, and cleanup/rollback paths. `tests/cow/e2e.sh` covers public create retry after interrupted birth metadata and public reset retry after interrupted reset publication. | Keep every new creation/reuse/reset path under the same invariant and add regressions whenever a new branch publication path is introduced. | +| 7. ID-band enforcement beyond happy paths | `tests/cow/merge.php` covers AUTOINCREMENT allocation, rollback, reset below old bands, independent branch IDs, explicit out-of-band source IDs, child rows behind held explicit post/term/user IDs, inserted and updated scalar/serialized/theme/widget `wp_options`, `wp_posts`, `wp_postmeta`, `wp_comments`, `wp_commentmeta`, `wp_usermeta`, `wp_termmeta`, `wp_term_taxonomy`, `wp_term_relationships`, post-author, taxonomy menu-item, reusable/media/avatar/navigation/query block `post_content`, and comment-user references behind held explicit post/term/user IDs, JSON/serialized references that keep branch IDs distinct, plugin validator review for no-FK child rows behind held explicit plugin AUTOINCREMENT parents, and non-AUTOINCREMENT `INTEGER PRIMARY KEY` plugin graph collisions as review-held. `tests/cow/e2e.sh` verifies runtime branch post IDs fall inside branch bands. | Expand explicit-ID/import handling beyond currently covered AUTOINCREMENT row-insert/rewrite cases and enforce review for more plugin/custom logical identities that are not safely bandable. | +| 8. Better stale-audit workflow | `docs/stale-audit-workflow.md` describes the revalidation model. `scripts/cow/merge.php` implements `revalidate-reviews`, `merge-audit --revalidate`, `merge-resolve --after-revalidate`, revalidation classes, source/target drift checks, plugin validator replacement evidence, and plugin replacement conflict links. `tests/cow/merge.php` covers stale row/cell/file drift, source drift, deleted targets, no-PK rowid replacement, supported WordPress semantic fingerprints, guarded resolution, idempotent carried notes, plugin validator rerun evidence through direct and `merge-audit --revalidate` paths, duplicate identical validator rerun handling, and replacement validator conflict ids. | Add broader plugin/schema source-drift evidence, more custom logical-identity classifiers, and guarded plugin/schema-specific resolution flows where appropriate. | +| 9. Release gate issue | `scripts/build-dist.sh` and release preflight tests fail earlier when static-PHP prerequisites are missing, avoid macOS bash empty-array expansion under `set -u` while wrapping Apple Silicon `spc` commands in `arch -arm64`, and `docs/merge-reliability.md` tracks aarch64 macOS as a release gate. | Keep aarch64 macOS release and APFS sparsebundle E2E green before treating Mac artifacts as trustworthy. | + +## Current Guarantees + +- Branches have separate SQLite files and separate file trees. +- AUTOINCREMENT branch ID bands prevent routine ID collisions before JSON or + serialized references are written. +- The DB merge records source-applied, target-kept, target-wins, conflict, + resolution, ID-band, row-identity, and rollback-failure metadata outside the + WordPress database. +- Source row inserts and updates are verified after SQLite accepts the write. + If target-side triggers rewrote or removed the row, the write is rolled back + and the merge remains reviewable. +- Reviewed source conflict resolutions use the same row postcondition guard. +- File changes are merged separately from DB rows and unsafe paths remain + conflicts. +- Conflict resolution validates that the target still matches the audited + value. If the target drifted, resolution stops and asks for a fresh audit. + +## Reliability Gaps + +| Area | Current state | Missing reliability work | +| --- | --- | --- | +| WordPress semantic objects | Tests cover real post creation, postmeta references, users, usermeta, post/comment authors, threaded comments and commentmeta references, branch-local page edits/deletes, same-object page/postmeta edit-vs-delete conflicts with auditable target-wins defaults, attachment uploads plus original and generated-size files, attachment metadata, hierarchical taxonomy terms, page-linked nav menus with menu-location assignments, reusable blocks and synced patterns, options with embedded object IDs, JSON option payloads with embedded object IDs, custom post types, plugin AUTOINCREMENT tables, keyless plugin tables, unique collisions, file additions, nested plugin-owned custom-table/JSON/serialized/file graphs, branch merge visibility, a discovered media validator that reports missing original/generated upload files, duplicate attachment claims on the same upload file including same-attachment generated-file duplicates, unreadable or NUL-corrupted attachment metadata, empty or unsafe primary/generated upload metadata paths, original/generated dimension drift, generated-size filename drift, and `_wp_attached_file` versus `_wp_attachment_metadata` file drift, a discovered block-reference validator that reports pages/posts left pointing at deleted reusable blocks or synced patterns, a discovered menu-reference validator that reports nav menu items left pointing at deleted post objects, a discovered option-reference validator that reports serialized theme mods left pointing at deleted post objects, deleted nav-menu terms, or deleted custom-logo attachments plus serialized nav menu widgets, serialized media-image widgets, serialized sidebar-widget placements, scalar `site_icon`/`page_on_front`/`page_for_posts` options, and serialized `sticky_posts` options left pointing at deleted objects, a discovered featured-image validator that reports `_thumbnail_id` postmeta left pointing at deleted attachment objects/files, a discovered image-block validator that reports `core/image` block JSON left pointing at deleted attachment objects/files, a discovered term-relationship validator that reports `wp_term_relationships` left pointing at deleted taxonomy term rows, and `docs/merge-repair-policy.md` defines when semantic repairs must remain review-only. | Add broader concurrent object matrices, implement only the repair policies that have deterministic owners, and broaden plugin-owned graph conflict/drift cases. | +| Plugin-specific semantics | Generic SQLite merge is table/row/cell based and does not rewrite embedded IDs. `docs/plugin-merge-validators.md` defines the validator boundary and first test shape. PHP unit and E2E coverage now cover the clean happy path for a plugin-owned custom-table graph with JSON, serialized option/postmeta references, referenced CPT data, and a referenced file. The PHP unit suite also covers the metadata/audit foundation for plugin-scoped validator conflicts, including review queues and grouping. Normal branch merges discover validators from active plugin and mu-plugin locations in the staged candidate target; discovered custom-table graph validators can abort and roll back a candidate with a broken JSON reference, or complete the merge with plugin-scoped review conflicts for broken serialized graph row/file references and target-conflicting graph state. `forkpress branch run-plugin-validator`, `forkpress branch record-plugin-validator-conflicts`, and `forkpress branch merge --plugin-validator ` expose explicit validator execution and findings recording. Validator failures after DB/files have staged roll back the merge. | Add broader plugin-owned validators for more real plugins and plugin merge drivers only where a plugin can prove an automatic repair is safe. | +| Review-only schema cases | Cyclic views/triggers, invalid preserved trigger/view dependencies, and some rebuild dependency chains are held as auditable conflicts. | Improve dependency planning so more safe schema reorderings can apply automatically. Keep non-deterministic or semantically ambiguous cases review-only. | +| Filesystem semantics | File additions/deletions/conflicts are audited; binary file changes/conflicts are hash-verified, safe relative symlinks can merge, unsafe symlinks to absolute paths, root-escaping paths, self-references, and ForkPress-managed paths remain conflicts, directory/file and file/directory replacements get type-specific review conflicts, unchanged target descendants and source descendants under reviewed replacements are held until review, reviewed source replacements can apply supported file/dir/symlink changes including directory subtrees, WordPress E2E links attachment rows to original and generated-size upload files, and PHP coverage uses a discovered validator to cross-check attachment metadata against merged upload files and attached-file metadata drift. | Add stricter uploads-specific validators for more conflict/drift shapes, including attachment metadata regeneration decisions. | +| Crash consistency | DB, metadata, file, rollback-failure, ID-band, and Git publication paths have targeted rollback tests. DB merge process-death coverage includes crashes before/after target DB commit and before metadata commit, with pending crash artifacts that block later merges until explicit recovery. Whole-branch DB+file merges now keep recoverable target DB, metadata DB, and filesystem-root snapshots across the file phase, so a hard exit after DB commit, before file mutation, or after an individual file operation blocks later merges until `recover-crash --restore-target-db --restore-files` restores a coherent pre-merge state. Public E2E now drives merge/create/reset/recover crash paths through `forkpress` commands and covers one actual smart-HTTP Git-created branch push interrupted after branch-list publication, followed by a fresh server restart and merge verification. Git server process-death coverage includes created-branch metadata/storage/public-link/list publication, existing-branch update publication, delete staging, and object pruning. `docs/merge-crash-consistency.md` maps covered boundaries and missing product-level failpoint work. | Broaden the external product-level kill harness across the remaining actual Git-push failpoints, plus platform-specific coverage around sparsebundle detach/compact and rollback-artifact cleanup. | +| Branch birth | CLI and Git-created branches allocate ID bands and capture merge base metadata. Git-created branches finalize birth metadata before publishing the branch tree, so a pre-metadata crash cannot expose a branch without ID bands or row identities. Existing branches reused by automation must still have a database, DB merge base, filesystem merge base, and required birth metadata before reuse. The WordPress admin branch create/merge UI is covered as a thin wrapper over the same CLI paths, including validation, CLI failure surfacing, and real runtime E2E create/merge requests. | Keep branch create, Git ref create, reset, and UI creation on one invariant: DB base, file base, ID bands, and metadata must exist before user writes. | +| ID bands | AUTOINCREMENT bands protect common WordPress and plugin tables. Reset below old bands gets fresh bands. Explicit source IDs outside the reserved branch band are review-held instead of applied automatically for core WordPress and plugin AUTOINCREMENT tables, paired source deletes in the same AUTOINCREMENT table are also held when an out-of-band explicit insert is held, and source child `wp_posts`, owner/reference `wp_postmeta` including inserted and updated type-aware nav menu object refs, inserted or updated scalar/serialized/theme/widget `wp_options` references, inserted or updated `wp_comments`, `wp_commentmeta`, inserted or updated `wp_termmeta`, hierarchical and updated `wp_term_taxonomy`, inserted or primary-key-rewritten `wp_term_relationships`, inserted or updated `wp_usermeta`, inserted or updated post authors, inserted or updated reusable/media/avatar/navigation/query block `post_content` refs, inserted or updated taxonomy menu-item object refs, or inserted or updated comment user refs pointing at held explicit post/term/user IDs are review-held instead of leaving orphan WordPress child rows. Non-AUTOINCREMENT `INTEGER PRIMARY KEY` plugin graph collisions are review-held and auditable as non-bandable tables. | Enforce bands before every write path, expand explicit-ID/import handling beyond covered AUTOINCREMENT row-insert cases, and reject/review unsafe reuse. | +| Stale audits | Resolution fails if target no longer matches the audited payload. `forkpress branch revalidate-reviews` and `forkpress branch merge-audit --revalidate` carry stale reviewed conflicts back into `needs-action` while preserving prior reviewer intent, avoiding duplicate carried notes, recording revalidated payloads, linking plugin replacement validator conflicts, and storing a conservative revalidation classifier such as `compatible-target-drift`, `compatible-source-drift`, `missing`, no-primary-key rowid-reuse `incompatible`, supported WordPress primary-key semantic `incompatible`, or plugin `replacement-evidence`. `forkpress branch merge-resolve conflict --after-revalidate` can apply reviewed stale database row/cell and filesystem conflicts only when the current source/target payloads still match the latest revalidation record, the latest classifier is not `incompatible`, and the original logical row still exists. Plugin validator conflicts now return to `needs-action` when a validator rerun records changed evidence for the same plugin object, but remain outside generic guarded resolution; schema conflicts still need schema-specific evidence/planning. `docs/stale-audit-workflow.md` maps the current flow and the remaining richer classifier model. | Add broader source-drift coverage for plugin/schema-specific evidence, broader incompatible classifiers for more primary-key/higher-level semantic identities, and guarded revalidation resolution for plugin and schema conflicts. | +| Release gates | Linux, Windows, and x86_64 macOS release artifacts built in the last checked run. macOS and Linux release workflows install static PHP build prerequisites up front, `scripts/build-dist.sh` now fails before cloning/building PHP if those tools are missing instead of letting `static-php-cli` mutate package-manager state during the release bundle step, avoids macOS bash empty-array expansion under `set -u` while wrapping Apple Silicon `spc` commands in `arch -arm64`, and the default `static-php-cli` checkout is pinned to a known upstream commit with an explicit override for deliberate upgrades. | Keep aarch64 macOS release and APFS sparsebundle E2E green; those gates are required before trusting an M1-ready artifact. | + +## Test Direction + +Every new reliability claim should have one of these test shapes: + +- A PHP unit test in `tests/cow/merge.php` for deterministic DB merge behavior. +- A COW E2E test in `tests/cow/e2e.sh` for real WordPress/runtime behavior. +- A Rust unit test for CLI, storage, Git publication, release packaging, or + platform-specific lifecycle behavior. +- A CI workflow gate when the behavior only exists on a target platform, such + as APFS sparsebundles or Windows ReFS. + +Prefer adding validators before adding automatic conflict resolution for plugin +or schema cases. A reliable reviewable conflict is better than an automatic +merge that invents WordPress semantics. + +See `docs/plugin-merge-validators.md` for the proposed plugin validator +contract. + +See `docs/merge-repair-policy.md` for the repair-versus-review policy for +WordPress and plugin semantic graphs. + +See `docs/stale-audit-workflow.md` for the proposed stale-audit revalidation +workflow. + +See `docs/merge-crash-consistency.md` for the merge crash-consistency boundary +map. + +## Follow-Up Work + +PR #46 should be treated as a merge-reliability hardening milestone, not the +final proof that all merges are automatic or fully reliable. The next work +should stay focused on these areas: + +- Add validators for real plugins with known cross-table, serialized, JSON, and + file graphs. Add plugin-owned merge drivers only when the plugin can prove a + deterministic repair. +- Build broader external kill harnesses for public Git push/serve entry points, + then verify recovery from a fresh process after each interruption. +- Expand explicit-ID/import handling beyond the currently covered + AUTOINCREMENT row insert/rewrite and known WordPress reference cases. +- Add richer plugin/schema stale-audit evidence and guarded resolution flows + where the plugin or schema planner can prove the reviewed choice is still + valid. +- Improve deterministic schema dependency planning for safe view/trigger + reorderings while keeping cyclic or semantic ambiguity review-only. +- Keep aarch64 macOS release artifacts and APFS sparsebundle E2E runs green + before treating Apple Silicon merge/release behavior as trustworthy. diff --git a/docs/merge-repair-policy.md b/docs/merge-repair-policy.md new file mode 100644 index 00000000..f6fefff0 --- /dev/null +++ b/docs/merge-repair-policy.md @@ -0,0 +1,64 @@ +# Merge Repair Policy + +Status: proposed policy + +ForkPress merge should not rewrite WordPress or plugin data just because a +rewritten state would be convenient. Automatic repair is allowed only when the +repair is deterministic, preserves the user's intended object graph, and can be +validated after application. Everything else should remain an auditable conflict +or a plugin-scoped validator finding. + +## Default Rule + +The merge engine may automatically apply source rows and files when their raw +DB/file preconditions are satisfied. It must not infer WordPress semantics that +are not represented by those preconditions. + +When a semantic object spans multiple places, such as posts, postmeta, terms, +block JSON, options, upload files, or plugin tables, the safe default is: + +- preserve target state when source and target disagree; +- stage source state only when generic merge rules prove it safe; +- run validators to detect incoherent merged graphs; +- record conflicts with enough payload to review the graph manually. + +## Repair Classes + +| Class | Automatic repair policy | Reason | +| --- | --- | --- | +| Branch ID collisions | Prevent before write with AUTOINCREMENT bands. Do not rewrite embedded IDs after the fact. | IDs are commonly serialized into JSON, PHP serialization, block attributes, and plugin payloads. Rewriting every reference is not generally knowable. | +| Missing upload files from attachment metadata | Validator conflict by default. | Regenerating sizes changes bytes, dimensions, metadata, and possibly plugin expectations. A future media-specific repair may be valid only when WordPress can regenerate the exact declared size from an existing original. | +| `_wp_attached_file` versus `_wp_attachment_metadata['file']` drift | Validator conflict by default. | Either path can be intentional after a plugin move/import. The merge engine should not choose one without a media owner. | +| Featured image references to deleted attachments | Validator conflict by default. | Removing `_thumbnail_id` or restoring the attachment are both semantic editorial choices. | +| `core/image` block IDs pointing at deleted attachments | Validator conflict by default. | Updating block JSON requires knowing whether the image should be removed, replaced, or restored. | +| Reusable block or synced pattern refs pointing at deleted `wp_block` posts | Validator conflict by default. | The safe action depends on editorial intent: unlink, restore, replace, or accept deletion. | +| Nav menu items pointing at deleted objects | Validator conflict by default. | Menu repair is content semantics, not row semantics. | +| Option/theme-mod object references pointing at deleted posts | Validator conflict by default. | Options often contain theme or plugin contracts that ForkPress cannot infer. | +| Term relationships pointing at deleted taxonomy rows | Validator conflict by default. | Recreating terms may collide with slugs, hierarchy, counts, and plugin taxonomy semantics. | +| Plugin-owned graph references | Plugin validator conflict or plugin merge driver only. | Only the plugin can reliably define graph identity, invariants, and safe repairs. | + +## When Automatic Repair Becomes Acceptable + +Automatic repair can move from review-only to applied only when all conditions +are true: + +- The repair has a local owner: WordPress core semantics, a bundled ForkPress + validator with tests, or an explicit plugin merge driver. +- The repair is deterministic from base/source/target state. +- The repair validates the final candidate graph, not just individual rows. +- The audit records original source, original target, chosen repair, and reason. +- Re-running the merge after the repair is idempotent. +- Stale-review guards still block applying old decisions after target drift. + +## Test Shape + +Every new automatic repair needs both: + +- a negative validator/conflict test proving the unrepaired incoherent graph is + held for review; and +- a positive repair test proving the repaired graph is coherent, auditable, and + idempotent after a rerun. + +For WordPress media repairs, include real upload files and generated sizes. For +plugin repairs, include custom tables, JSON, serialized PHP, options/postmeta, +and any referenced files. diff --git a/docs/plugin-merge-validators.md b/docs/plugin-merge-validators.md new file mode 100644 index 00000000..e54f8c63 --- /dev/null +++ b/docs/plugin-merge-validators.md @@ -0,0 +1,197 @@ +# Plugin Merge Validators + +Status: partial implementation target + +ForkPress can merge SQLite rows and files, but plugins often store one logical +object across custom tables, `postmeta`, options, JSON, serialized PHP values, +and uploaded/generated files. A generic row merge cannot safely infer those +semantics or rewrite embedded IDs. + +Plugin validators are the intended boundary: plugins should be able to inspect +a merged candidate and either confirm that their object graph is coherent or +return reviewable conflicts. + +## Contract + +A validator should be deterministic and side-effect free. It receives: + +- The source branch name and target branch name. +- Read-only handles or paths for base, source, target-before, and candidate + target databases. +- Read-only paths for base, source, target-before, and candidate target file + trees. +- The current merge run id and metadata database path for writing findings + through ForkPress-provided helpers, not through plugin SQL. + +A validator returns one of: + +- `valid`: the merged candidate preserves this plugin's invariants. +- `conflicts`: the candidate is reviewable; each finding identifies the plugin, + affected logical object, tables/files/options involved, and a human-readable + reason. +- `failed`: the validator could not run; the merge should fail rather than + silently accept an unchecked plugin graph. + +Validators must not rewrite the candidate database or filesystem. A future +merge driver may do that, but validators are only a gate. + +## Invariants To Check + +The first validator API should support checks for: + +- Custom-table rows whose IDs are embedded in JSON or serialized values. +- Cross-table parent/child rows where the database has no foreign keys. +- Options that point at posts, terms, users, media, or plugin custom rows. +- Plugin files referenced from custom tables, options, or postmeta. +- Generated files that can be safely regenerated versus files that must merge + as user content. +- Tombstones or soft-delete markers that must agree with related rows/files. + +## Merge Behavior + +Validators run after the generic DB/files candidate has been staged and before +the merge is reported as completed. + +- `valid` findings leave the normal merge status unchanged. +- `conflicts` change the merge status to `completed_with_conflicts` and record + plugin-scoped conflict metadata. +- `failed` rolls back the staged candidate like any other merge failure. + +This preserves the current safety model: ForkPress may apply exact safe changes, +preserve target state, or stop with an auditable conflict, but it should not +invent plugin-specific rewrites. + +The current implementation has the metadata/audit foundation for validator +conflicts: ForkPress can record plugin-scoped findings against a merge run, +mark that run as `completed_with_conflicts`, filter `merge-audit` output with +`scope = plugin`, group plugin findings separately from DB/file findings, and +attach review notes. External validator runners can hand findings back through: + +```bash +forkpress branch record-plugin-validator-conflicts \ + --run 123 \ + --findings-file /tmp/forkpress-plugin-findings.json +``` + +`--findings-json` is available for small fixtures, but real validators should +prefer `--findings-file` so large candidate payloads do not hit shell argument +limits. + +ForkPress can also execute one explicit validator command and record its JSON +findings: + +```bash +forkpress branch run-plugin-validator \ + --run 123 \ + --validator ./vendor/bin/my-plugin-merge-validator +``` + +The runner passes merge context through environment variables: +`FORKPRESS_MERGE_METADATA_DB`, `FORKPRESS_MERGE_RUN`, +`FORKPRESS_MERGE_SOURCE_BRANCH`, `FORKPRESS_MERGE_TARGET_BRANCH`, +`FORKPRESS_MERGE_BASE_DB`, `FORKPRESS_MERGE_SOURCE_DB`, +`FORKPRESS_MERGE_TARGET_DB`, `FORKPRESS_MERGE_BASE_ROOT`, +`FORKPRESS_MERGE_SOURCE_ROOT`, and `FORKPRESS_MERGE_TARGET_ROOT`. A validator +may emit either a raw findings array or an object with `status` and `findings`. + +The lower-level PHP helper commands remain available for focused fixtures and +runtime integration: + +```bash +php scripts/cow/merge.php record-plugin-validator-conflicts \ + --metadata-db .forkpress/cow/merge/metadata.sqlite \ + --run 123 \ + --findings-file /tmp/forkpress-plugin-findings.json + +php scripts/cow/merge.php run-plugin-validator \ + --metadata-db .forkpress/cow/merge/metadata.sqlite \ + --run 123 \ + --validator ./vendor/bin/my-plugin-merge-validator +``` + +Normal branch merges automatically discover validators from the staged +candidate target: + +- active plugins may ship `forkpress-merge-validator.php` next to the active + plugin file's directory, such as + `wp-content/plugins/my-plugin/forkpress-merge-validator.php`; +- single-file active plugins may ship + `wp-content/plugins/my-plugin.forkpress-merge-validator.php`; +- mu-plugins may ship `wp-content/mu-plugins/forkpress-merge-validator.php`, + `wp-content/mu-plugins/*.forkpress-merge-validator.php`, or + `wp-content/mu-plugins/*/forkpress-merge-validator.php`. + +Inactive plugin validators are not run. A normal branch merge can also run one +explicit validator before reporting the merge complete: + +```bash +forkpress branch merge feature --into main \ + --plugin-validator ./vendor/bin/my-plugin-merge-validator +``` + +When this inline validator returns `conflicts`, the merge completes as +`completed_with_conflicts` and records plugin-scoped conflict rows before the +result is reported. When it returns `failed` or exits unsuccessfully, the merge +helper restores the pre-merge target database, metadata database, and target +file tree using the same rollback path as other late merge failures. + +Validator status and findings must agree. `valid` must emit no findings, and +`conflicts` must emit at least one finding. Contradictory validator output is +treated as a validator failure so plugin state is not reported with ambiguous +review evidence. + +## Review Metadata + +Plugin conflicts should be exported by `forkpress branch merge-audit` with: + +- `scope = plugin` +- plugin slug/name +- logical object identity +- involved database tables +- involved filesystem paths +- validator version +- base/source/target/candidate payload previews where safe + +Review resolution should initially support only target acceptance and +re-audit-after-change. Source application should require a plugin merge driver, +not just a validator. + +## Test Shape + +Each plugin validator claim needs both: + +- A PHP unit test in `tests/cow/merge.php` that builds custom plugin tables, + JSON/serialized references, options, and files around a deterministic merge. +- A COW E2E test in `tests/cow/e2e.sh` when the invariant depends on real + WordPress APIs, uploads, block serialization, or runtime plugin hooks. + +The first fixture should model a plugin object with: + +- one custom parent row +- one custom child row +- one option that embeds both custom IDs +- one `postmeta` JSON value that embeds the custom parent ID +- one uploaded or generated file referenced from the custom row + +The expected result is a clean merge when branch ID bands keep both graphs +distinct, and a plugin-scoped review conflict when a graph reference points at +a missing or target-conflicting object. + +The clean branch-ID-band case is covered by: + +- `tests/cow/merge.php`: deterministic custom-table graph with JSON, + serialized option/postmeta references, and a referenced file. +- `tests/cow/e2e.sh`: runtime WordPress fixture that creates the same shape + through branch-local requests before merging. + +The PHP unit suite also covers a simulated broken-reference validator finding +for that graph, plugin-scoped audit output, review metadata, automatic +validator discovery from active plugin and mu-plugin locations, inactive +plugin exclusion, automatic validator execution during a normal merge, a +discovered custom-table graph validator that aborts and rolls back a candidate +whose JSON points at a missing child row, and a discovered target-conflict +validator that completes the merge with plugin-scoped review conflicts when a +source graph references target-exclusive plugin state. It also covers a +WordPress media-shaped mu-plugin validator that inspects the candidate target +root and records plugin-scoped conflicts when attachment metadata references +missing original or generated upload files. diff --git a/docs/stale-audit-workflow.md b/docs/stale-audit-workflow.md new file mode 100644 index 00000000..701e74c9 --- /dev/null +++ b/docs/stale-audit-workflow.md @@ -0,0 +1,155 @@ +# Stale Merge Audits + +Status: partial implementation + +ForkPress already protects reviewed resolutions from applying to a target that +has changed since the conflict was audited. Resolution code checks the current +target payload against the audited payload and stops with `rerun merge-audit` +when they differ. + +That is correct for safety. The implemented revalidation path now gives +reviewers a way back to the queue: `forkpress branch revalidate-reviews` scans +reviewed conflicts, detects stale or errored target payloads, and carries the +latest reviewed note into a new `needs-action` review note without applying any +resolution. It is idempotent, so rerunning it does not duplicate carried notes. + +## Current Safety Contract + +Today a resolution may apply only when: + +- The conflict or decision still exists. +- The target row, cell, schema object, or filesystem path still matches the + audited target payload. +- The source payload still matches the audited source payload where source + application depends on it. +- Any target-side constraints still accept the requested source operation. + +If any precondition changed, resolution fails. This prevents stale review notes +from silently overwriting newer target work. + +## Implemented Revalidation Flow + +```bash +forkpress branch revalidate-reviews +forkpress branch revalidate-reviews --run 12 --reviewer alice +forkpress branch revalidate-reviews --format json +forkpress branch merge-audit --revalidate --run 12 --reviewer alice +forkpress branch merge-audit --review --review-status needs-action +``` + +The command does not mutate the target branch. It only writes review metadata in +the merge metadata database. Fresh reviewed conflicts stay reviewed. Stale or +errored reviewed conflicts are reopened as `needs-action` with a note that +preserves the prior reviewer, status, and note text. + +Each recorded revalidation now includes a conservative `revalidation_class`. +Database row/cell and filesystem conflicts are classified as `unchanged`, +`compatible-target-drift`, `compatible-source-drift`, or `missing`. +No-primary-key database conflicts can also be classified as `incompatible` when +the reviewed logical row disappeared and its old physical rowid now belongs to +a different active sidecar identity. Supported WordPress primary-key row +conflicts are also classified as `incompatible` when either side keeps the same +key but changes semantic object identity after review. Current fingerprints +cover `wp_posts` `post_type`, `wp_options` `option_name`, `wp_postmeta` +`post_id`/`meta_key`, term slugs, term taxonomy `term_id`/`taxonomy`, termmeta +`term_id`/`meta_key`, user logins, usermeta `user_id`/`meta_key`, comment +`comment_post_ID`/`comment_type`, and commentmeta `comment_id`/`meta_key`. +Plugin validator conflicts are classified as `unchanged` when the rerun reports +the same evidence and `replacement-evidence` when the validator reports changed +evidence for the same plugin object. Replacement evidence also links the stale +review to the newer validator conflict row, so audit output can point reviewers +at the exact validator record that superseded their prior review. These classes +and links are audit metadata only. They do not make stale reviews apply +automatically. + +## Future Re-Audit Model + +A richer re-audit command should build on the current conservative classifier by +comparing the old audited record with a fresh merge audit and classifying +reviewer intent: + +- `unchanged`: the reviewed target/source payload still matches; keep the + existing resolution state. +- `compatible-target-drift`: target changed, but the reviewed choice still + refers to the same logical object and no source data would be lost; carry the + review note forward and mark it as needing confirmation. +- `compatible-source-drift`: source changed in a way that still satisfies the + same logical choice; carry the review note forward and mark it as needing + confirmation. +- `incompatible`: payloads or identities changed enough that the previous + intent is no longer meaningful; reopen as unreviewed. +- `missing`: the original conflict disappeared because the merge is now clean; + close the old review note as superseded. + +The key rule is that re-audit can preserve intent, but it must not apply a +resolution automatically after drift. A human or plugin validator still needs to +confirm any compatible drift. + +## Metadata Needed + +To support this cleanly, audit metadata should retain: + +- Original conflict or decision id. +- Latest replacement conflict or decision id. Plugin validator revalidations + now store `merge_revalidations.replacement_conflict_id` and expose the latest + replacement conflict id in audit output. +- Previous review status and note. +- Re-audit classifier. The current `merge_revalidations.revalidation_class` + stores `unchanged`, `compatible-target-drift`, `compatible-source-drift`, + `missing`, `incompatible`, `replacement-evidence`, or `unclassified`; future + work should broaden source-drift coverage into plugin/schema-specific + evidence and add broader incompatible logical-identity cases beyond the + currently supported WordPress row fingerprints and no-primary-key rowid reuse. +- Logical identity fingerprint separate from the raw payload. +- Re-audit timestamp and merge run id. + +Logical identity matters because payload hashes alone cannot distinguish +unrelated edits from “same object, newer title”. + +## Guarded Resolution After Revalidation + +```bash +forkpress branch merge-resolve conflict --choice source --after-revalidate --apply +``` + +`--after-revalidate` requires the latest review status to be `needs-action` and +the current source/target payload hashes to match the latest payloads recorded +by `merge-audit --revalidate` or `revalidate-reviews`. If the source or target +drifts again after revalidation, or if the latest revalidation was classified +as `incompatible`, guarded resolution fails and asks for another revalidation +instead of applying the stale original conflict. + +The first implementation supports database cell, database row, and filesystem +conflicts. Plugin validator conflicts now have a conservative validator-evidence +classifier: if a validator rerun records changed evidence for the same plugin +object, the reviewed plugin conflict returns to `needs-action` with the +replacement validator payload and replacement conflict id visible in audit. +Generic merge resolution still cannot apply plugin conflicts; the plugin +validator or a plugin-specific repair flow remains the authority. Schema +conflicts still use the conservative stale-target guard until they have +schema-specific revalidation payloads. + +## Test Shape + +The implemented tests in `tests/cow/merge.php` cover stale cell/file detection, +carrying reviewed conflicts into `needs-action`, preserving prior reviewer +intent in the carried note, idempotent reruns, replacement revalidation payloads +after further target drift, guarded source resolution for database cells, +database rows, and filesystem paths after revalidation, revalidation classifiers +for stale database row/cell drift, source-drifted database row/cell and +filesystem conflicts, deleted database target rows, deleted filesystem target +paths, incompatible no-primary-key rowid replacement, incompatible replacement +for every currently supported source- and target-side WordPress row semantic +fingerprint, and plugin validator reruns that deduplicate unchanged evidence or +carry reviewed plugin conflicts back to `needs-action` with +`replacement-evidence`, replacement validator payloads, and replacement +conflict links when the validator reports changed evidence for the same plugin +object. + +Future classifier tests should cover plugin/custom primary-key row conflicts +where the row keeps the same key but a higher-level logical fingerprint proves +it now represents a different object. + +The existing stale-resolution tests in `tests/cow/merge.php` should remain. +They prove stale resolutions are blocked. New tests should prove reviewers get +a structured way back to a fresh review queue. diff --git a/scripts/build-dist.sh b/scripts/build-dist.sh index efb2ba7e..73e2e59d 100755 --- a/scripts/build-dist.sh +++ b/scripts/build-dist.sh @@ -10,6 +10,48 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" +require_static_php_build_tools() { + local missing=() + local required=(git composer php re2c automake bison pkg-config) + + if [ "$UNAME_S" = "Darwin" ]; then + # static-php-cli patches have failed under BSD patch on macOS; use GNU patch. + required+=(gpatch) + fi + + for cmd in "${required[@]}"; do + if ! command -v "$cmd" >/dev/null 2>&1; then + missing+=("$cmd") + fi + done + + if [ "${#missing[@]}" -eq 0 ]; then + return + fi + + echo "ERROR: missing static PHP build tools: ${missing[*]}" >&2 + if [ "$UNAME_S" = "Darwin" ]; then + echo "Install them with: brew update && brew install composer php re2c automake bison pkg-config gpatch" >&2 + elif [ "$UNAME_S" = "Linux" ]; then + echo "Install them with your package manager; CI uses: apt-get install automake php-cli composer re2c bison pkg-config" >&2 + fi + echo "Refusing to let static-php-cli auto-install prerequisites during the release bundle build." >&2 + exit 1 +} + +ensure_static_php_cli_checkout() { + mkdir -p "$BUILD_DIR" + if [ ! -d "$SPC_DIR/.git" ]; then + rm -rf "$SPC_DIR" + git clone --no-checkout https://github.com/crazywhalecc/static-php-cli.git "$SPC_DIR" + fi + + git -C "$SPC_DIR" fetch --depth 1 origin "$SPC_REF" + git -C "$SPC_DIR" checkout --detach FETCH_HEAD + git -C "$SPC_DIR" reset --hard FETCH_HEAD + printf '%s\n' "$SPC_REF" > "$SPC_REF_MARKER" +} + # --- Target detection ------------------------------------------------------ UNAME_S=$(uname -s) UNAME_M=$(uname -m) @@ -43,6 +85,8 @@ fi DIST_DIR="${FORKPRESS_DIST_DIR:-$REPO_ROOT/dist/$DIST_NAME}" BUILD_DIR="${FORKPRESS_BUILD_DIR:-$REPO_ROOT/.build/$DIST_NAME}" SPC_DIR="$BUILD_DIR/static-php-cli" +SPC_REF="${FORKPRESS_STATIC_PHP_CLI_REF:-8d038f435da7845926ba425dfbae0278cd0e0746}" +SPC_REF_MARKER="$SPC_DIR/.forkpress-static-php-cli-ref" CAS_TARGET_DIR="$BUILD_DIR/cas-ffi-target" CAS_LIB_DIR="$CAS_TARGET_DIR/$TRIPLE/release" @@ -92,6 +136,9 @@ if [ -x "$SPC_DIR/buildroot/bin/php" ]; then break fi done + if [ ! -f "$SPC_REF_MARKER" ] || [ "$(cat "$SPC_REF_MARKER")" != "$SPC_REF" ]; then + NEED_PHP_BUILD=1 + fi else rm -f "$SPC_DIR/buildroot/bin/php" fi @@ -99,10 +146,8 @@ fi if [ "$NEED_PHP_BUILD" = "1" ]; then echo "==> Building static PHP via static-php-cli (first-time: 3-5 minutes)" - if [ ! -d "$SPC_DIR" ]; then - mkdir -p "$BUILD_DIR" - git clone --depth 1 https://github.com/crazywhalecc/static-php-cli.git "$SPC_DIR" - fi + require_static_php_build_tools + ensure_static_php_cli_checkout cd "$SPC_DIR" # --ignore-platform-reqs skips strict checking of the PHP version constraint # in static-php-cli's composer.lock (which can float up to PHP >= 8.4 as @@ -122,18 +167,32 @@ if [ "$NEED_PHP_BUILD" = "1" ]; then export PATH="/opt/homebrew/bin:$PATH" fi - # On Apple Silicon, if the parent shell is running under Rosetta, native - # clang defaults to x86_64 and some vendored library builds (libzip, etc) - # use that default instead of --target=arm64-apple-darwin, producing mixed - # arch objects that fail to link. Relaunch the spc subcommands in a native - # arm64 shell so every vendored lib compiles for arm64 consistently. - SPC_RUN=( ) - if [ "$UNAME_S-$UNAME_M" = "Darwin-arm64" ] && [ "$(uname -m)" != "arm64" ]; then - SPC_RUN=( arch -arm64 ) + # For Apple Silicon release targets, if the parent shell is running under + # Rosetta, clang defaults to x86_64 and some vendored library builds (libzip, + # etc) use that default instead of arm64, producing mixed-arch objects that + # fail to link. Relaunch the spc subcommands in a native arm64 shell so every + # vendored lib compiles for arm64 consistently. + SPC_RUN_UNDER_ARM64=0 + if [ "$UNAME_S" = "Darwin" ] && [ "$TRIPLE" = "aarch64-apple-darwin" ] && [ "$(uname -m)" != "arm64" ]; then + if arch -arm64 /usr/bin/true >/dev/null 2>&1; then + SPC_RUN_UNDER_ARM64=1 + else + echo "ERROR: aarch64-apple-darwin dist builds must run in a native arm64 shell." >&2 + echo " Re-run from Apple Silicon without Rosetta, or use: arch -arm64 scripts/build-dist.sh" >&2 + exit 1 + fi fi - "${SPC_RUN[@]+"${SPC_RUN[@]}"}" ./bin/spc doctor --auto-fix - "${SPC_RUN[@]+"${SPC_RUN[@]}"}" ./bin/spc download --for-extensions="$EXTENSIONS" --with-php=8.3 + run_spc() { + if [ "$SPC_RUN_UNDER_ARM64" = "1" ]; then + arch -arm64 ./bin/spc "$@" + else + ./bin/spc "$@" + fi + } + + run_spc doctor --auto-fix + run_spc download --for-extensions="$EXTENSIONS" --with-php=8.3 if [ "$PROFILE" = "dev" ]; then # Register branchfs as a builtin extension in spc's ext.json so its @@ -149,11 +208,11 @@ file_put_contents($p, json_encode($c, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES # re-runs ./buildconf --force so the new extension is visible to configure. # Using the hook (rather than manual pre-extraction) is robust against spc # re-extracting php-src during the build phase. - "${SPC_RUN[@]+"${SPC_RUN[@]}"}" ./bin/spc build \ + run_spc build \ --with-added-patch="$REPO_ROOT/experiments/branchfs/build/spc-patch.php" \ "$EXTENSIONS,branchfs" --build-cli else - "${SPC_RUN[@]+"${SPC_RUN[@]}"}" ./bin/spc build "$EXTENSIONS" --build-cli + run_spc build "$EXTENSIONS" --build-cli fi cd "$REPO_ROOT" fi diff --git a/scripts/cow/git_server.php b/scripts/cow/git_server.php index 03c97b78..5a15a93b 100644 --- a/scripts/cow/git_server.php +++ b/scripts/cow/git_server.php @@ -21,6 +21,28 @@ use WordPress\Git\Protocol\GitProtocolEncoderPipe; use WordPress\HttpServer\Response\StreamingResponseWriter; +function cow_git_failpoint(string $name): void { + $configured = getenv('FORKPRESS_COW_GIT_TEST_FAILPOINT'); + if (!is_string($configured) || trim($configured) === '') { + return; + } + $failpoints = array_map('trim', explode(',', $configured)); + if (!in_array($name, $failpoints, true)) { + return; + } + + $action = getenv('FORKPRESS_COW_GIT_TEST_FAILPOINT_ACTION'); + $action = is_string($action) && $action !== '' ? $action : 'throw'; + if ($action === 'exit') { + exit(97); + } + if ($action === 'kill' && function_exists('posix_kill') && defined('SIGKILL')) { + posix_kill(getmypid(), SIGKILL); + exit(137); + } + throw new \RuntimeException("forced COW Git failpoint: $name"); +} + function cow_git_server_handle( string $branches_dir, string $git_repo_dir, @@ -490,11 +512,13 @@ function cow_git_apply_push_to_branches( $branches_to_sync = array_values(array_filter($changed_branches, static function($branch) use ($branches_dir) { return is_dir(rtrim($branches_dir, "/\\") . '/' . $branch); })); - cow_git_prepare_created_branch_merge_metadata($git_repo_dir, $branch_list_path, $transaction['created']); if ($branches_to_sync) { cow_git_sync_repository($repo, $branches_dir, $branches_to_sync); } cow_git_commit_apply_transaction($transaction); + cow_git_write_branch_list($branches_dir, $branch_list_path); + cow_git_cleanup_stale_update_artifacts($branches_dir, $storage_branches_dir); + cow_git_cleanup_stale_delete_artifacts($branches_dir, $storage_branches_dir); } catch (\Throwable $e) { cow_git_rollback_apply_transaction($transaction); cow_git_cleanup_created_branch_merge_base_artifacts($git_repo_dir, $branch_list_path, $transaction['created']); @@ -780,6 +804,7 @@ function cow_git_apply_all_refs_to_branches( ); if ($transaction !== null && $updated !== null) { $transaction['updates'][] = $updated; + cow_git_failpoint('after-existing-branch-update-publish'); } } @@ -816,6 +841,7 @@ function cow_git_delete_removed_branches( if (!$staged) { return []; } + cow_git_failpoint('after-branch-delete-stage'); cow_git_write_branch_list($branches_dir, $branch_list_path); } catch (\Throwable $e) { @@ -1033,24 +1059,47 @@ function cow_git_create_branch_for_ref( $dest_storage = cow_git_branch_storage_root($storage_branches_dir, $branches_dir, $branch); $dest_public = rtrim($branches_dir, "/\\") . '/' . $branch; + if ( + cow_git_normalize_path($dest_storage) !== cow_git_normalize_path($dest_public) + && (file_exists($dest_storage) || is_link($dest_storage)) + && !file_exists($dest_public) + && !is_link($dest_public) + ) { + cow_git_remove_tree($dest_storage); + cow_git_cleanup_created_branch_merge_base_artifacts($git_repo_dir, $branch_list_path, [['branch' => $branch]]); + cow_git_cleanup_created_branch_id_band_metadata($git_repo_dir, $branch_list_path, [['branch' => $branch]]); + } if (file_exists($dest_storage) || is_link($dest_storage) || file_exists($dest_public) || is_link($dest_public)) { throw new \RuntimeException("branch '$branch' already exists"); } + cow_git_cleanup_created_branch_merge_base_artifacts($git_repo_dir, $branch_list_path, [['branch' => $branch]]); + cow_git_cleanup_created_branch_id_band_metadata($git_repo_dir, $branch_list_path, [['branch' => $branch]]); $tmp = dirname($dest_storage) . '/.forkpress-new-' . $branch . '-' . getmypid() . '-' . bin2hex(random_bytes(4)); $published_storage = false; $linked_public = false; $captured_merge_bases = false; + $attempted_merge_metadata = false; try { cow_git_clone_branch_tree($source_root, $tmp, $file_view); cow_git_capture_created_branch_merge_bases($git_repo_dir, $branch_list_path, $branch, $source_root); $captured_merge_bases = true; cow_git_apply_wp_files($repo, $tmp, $wp_files); + cow_git_failpoint('before-created-branch-metadata'); + $attempted_merge_metadata = true; + cow_git_prepare_created_branch_merge_metadata($git_repo_dir, $branch_list_path, [[ + 'branch' => $branch, + 'source' => $source, + 'storage' => $tmp, + ]]); + cow_git_failpoint('after-created-branch-metadata'); + if (!rename($tmp, $dest_storage)) { throw new \RuntimeException("failed to publish git-created branch '$branch'"); } $published_storage = true; + cow_git_failpoint('after-created-branch-storage'); if (cow_git_normalize_path($dest_storage) !== cow_git_normalize_path($dest_public)) { if (!symlink($dest_storage, $dest_public)) { @@ -1059,19 +1108,26 @@ function cow_git_create_branch_for_ref( throw new \RuntimeException("failed to link git-created branch '$branch' into public branch directory"); } $linked_public = true; + cow_git_failpoint('after-created-branch-public-link'); } cow_git_rewrite_wp_config($dest_public, $debug_log); - cow_git_write_branch_list($branches_dir, $branch_list_path); - error_log("ForkPress COW git created branch '$branch' from '$source'"); - return [ + $created = [ 'branch' => $branch, 'source' => $source, 'public' => $dest_public, 'storage' => $dest_storage, 'linked_public' => $linked_public, ]; + cow_git_failpoint('before-created-branch-list'); + cow_git_write_branch_list($branches_dir, $branch_list_path); + cow_git_failpoint('after-created-branch-list'); + error_log("ForkPress COW git created branch '$branch' from '$source'"); + return $created; } catch (\Throwable $e) { + if ($attempted_merge_metadata) { + cow_git_cleanup_created_branch_id_band_metadata($git_repo_dir, $branch_list_path, [['branch' => $branch]]); + } if ($captured_merge_bases) { cow_git_cleanup_created_branch_merge_base_artifacts($git_repo_dir, $branch_list_path, [['branch' => $branch]]); } @@ -1174,6 +1230,58 @@ function cow_git_commit_apply_transaction(array $transaction): void { cow_git_discard_staged_branch_deletes($transaction['deletes']); } +function cow_git_cleanup_stale_update_artifacts(string $branches_dir, string $storage_branches_dir): void { + $parents = [rtrim($branches_dir, "/\\")]; + $storage_parent = rtrim($storage_branches_dir, "/\\"); + if ($storage_parent !== '' && !in_array($storage_parent, $parents, true)) { + $parents[] = $storage_parent; + } + + 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); + } + } + } +} + +function cow_git_cleanup_stale_delete_artifacts(string $branches_dir, string $storage_branches_dir): void { + $parents = [[rtrim($branches_dir, "/\\"), 'public']]; + $storage_parent = rtrim($storage_branches_dir, "/\\"); + if ($storage_parent !== '' && $storage_parent !== rtrim($branches_dir, "/\\")) { + $parents[] = [$storage_parent, 'storage']; + } + + foreach ($parents as [$parent, $label]) { + foreach (glob($parent . '/.forkpress-delete-' . $label . '-*') ?: [] as $path) { + $name = basename($path); + if (!preg_match('/^\.forkpress-delete-' . preg_quote($label, '/') . '-(.+)-[0-9]+-[0-9a-f]+$/', $name, $matches)) { + continue; + } + $branch = $matches[1]; + if (!cow_git_valid_branch_name($branch)) { + continue; + } + $original = $label === 'public' + ? rtrim($branches_dir, "/\\") . '/' . $branch + : cow_git_branch_storage_root($storage_branches_dir, $branches_dir, $branch); + if (!file_exists($original) && !is_link($original)) { + cow_git_remove_tree($path); + } + } + } +} + function cow_git_rollback_apply_transaction(array $transaction): void { foreach (array_reverse($transaction['updates']) as $update) { $storage = $update['storage']; @@ -1655,6 +1763,7 @@ function cow_git_prune_unreachable_objects(GitRepository $repo, string $git_repo throw new \RuntimeException("failed to prune unreachable COW Git object $oid"); } ++$deleted; + cow_git_failpoint('after-git-object-prune'); } @rmdir($dir); } diff --git a/scripts/cow/merge.php b/scripts/cow/merge.php index 431999e2..8664f10a 100644 --- a/scripts/cow/merge.php +++ b/scripts/cow/merge.php @@ -10,19 +10,24 @@ function cow_merge_usage(): void { fwrite(STDERR, "Usage:\n"); fwrite(STDERR, " php merge.php [merge] --base-db --source-db --target-db --metadata-db --source --target \n"); - fwrite(STDERR, " [--base-files --source-root --target-root ]\n"); + fwrite(STDERR, " [--base-files --source-root --target-root ] [--plugin-validator ]\n"); fwrite(STDERR, " php merge.php capture-files --root --file-base \n"); fwrite(STDERR, " php merge.php capture-identities --db --metadata-db --branch [--seed-branch ]\n"); fwrite(STDERR, " php merge.php track-identity-events --db --metadata-db --branch --events-json \n"); fwrite(STDERR, " php merge.php allocate-id-bands --db --metadata-db --branch \n"); + fwrite(STDERR, " php merge.php validate-branch-birth-metadata --db --metadata-db --branch \n"); + fwrite(STDERR, " php merge.php record-plugin-validator-conflicts --metadata-db --run ID (--findings-json |--findings-file ) [--format text|json]\n"); + fwrite(STDERR, " php merge.php run-plugin-validator --metadata-db --run ID --validator [--format text|json]\n"); + fwrite(STDERR, " php merge.php recover-crash --metadata-db [--run ID] [--restore-target-db] [--restore-files] [--format text|json]\n"); fwrite(STDERR, " php merge.php audit --metadata-db [--format text|json] [--limit N] [--run ID]\n"); - fwrite(STDERR, " [--scope all|db|files] [--records all|conflicts|decisions|resolutions|rollback-failures] [--path ] [--path-prefix ]\n"); - fwrite(STDERR, " [--scope all|db|files] [--records all|conflicts|decisions|resolutions|rollback-failures] [--conflict-type TYPE] [--decision DECISION]\n"); - fwrite(STDERR, " [--id-band-skips] [--target-kept] [--review] [--review-status unreviewed|pending|needs-action|reviewed]\n"); + fwrite(STDERR, " [--scope all|db|files|plugin] [--records all|conflicts|decisions|resolutions|rollback-failures] [--path ] [--path-prefix ]\n"); + fwrite(STDERR, " [--scope all|db|files|plugin] [--records all|conflicts|decisions|resolutions|rollback-failures] [--conflict-type TYPE] [--decision DECISION]\n"); + fwrite(STDERR, " [--id-band-skips] [--target-kept] [--review] [--review-status unreviewed|pending|needs-action|reviewed] [--revalidate] [--reviewer NAME]\n"); fwrite(STDERR, " [--resolution-status validated|applied] [--group-by none|table|status|path|type|severity]\n"); fwrite(STDERR, " --group-by supports resolutions by table/status/path, conflicts by table/type/path/severity, and decisions by table/type/path.\n"); + fwrite(STDERR, " php merge.php revalidate-reviews --metadata-db [--run ID] [--reviewer NAME] [--format text|json]\n"); fwrite(STDERR, " php merge.php review-record --metadata-db --record conflict|decision|resolution --id 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] [--note TEXT] [--reviewer NAME]\n"); + fwrite(STDERR, " php merge.php resolve-conflict --metadata-db --id ID --choice source|target [--apply] [--after-revalidate] [--note TEXT] [--reviewer NAME]\n"); } const COW_MERGE_AUTOINCREMENT_BAND_SIZE = 1000000; @@ -265,7 +270,7 @@ function cow_merge_file_manifest_for_root(string $root): array { while ($stack) { $dir = array_pop($stack); - $children = scandir($dir); + $children = @scandir($dir); if ($children === false) { throw new RuntimeException("failed to read filesystem merge directory: $dir"); } @@ -357,6 +362,30 @@ function cow_merge_test_hook(string $name, mixed ...$args): void { } } +function cow_merge_failpoint(string $name): void { + $configured = getenv('FORKPRESS_COW_MERGE_TEST_FAILPOINT'); + if (!is_string($configured) || $configured === '') { + return; + } + $failpoints = array_map('trim', explode(',', $configured)); + if (!in_array($name, $failpoints, true)) { + return; + } + + $action = getenv('FORKPRESS_COW_MERGE_TEST_FAILPOINT_ACTION'); + $action = is_string($action) && $action !== '' ? $action : 'throw'; + if ($action === 'kill') { + if (function_exists('posix_kill') && defined('SIGKILL')) { + posix_kill(getmypid(), SIGKILL); + } + exit(86); + } + if ($action === 'exit') { + exit(86); + } + throw new RuntimeException("forced COW merge failpoint: $name"); +} + function cow_merge_read_file_base(string $file_base): array { if (!is_file($file_base)) { throw new RuntimeException("filesystem merge base does not exist: $file_base"); @@ -1468,6 +1497,36 @@ function cow_merge_row_values_equal(?array $a, ?array $b, array $columns): bool return true; } +function cow_merge_row_semantic_identity(string $table, ?array $row): ?array { + if ($row === null) { + return null; + } + $profiles = [ + 'wp_posts' => ['post_type'], + 'wp_postmeta' => ['post_id', 'meta_key'], + 'wp_terms' => ['slug'], + 'wp_term_taxonomy' => ['term_id', 'taxonomy'], + 'wp_termmeta' => ['term_id', 'meta_key'], + 'wp_users' => ['user_login'], + 'wp_usermeta' => ['user_id', 'meta_key'], + 'wp_comments' => ['comment_post_ID', 'comment_type'], + 'wp_commentmeta' => ['comment_id', 'meta_key'], + 'wp_options' => ['option_name'], + ]; + $columns = $profiles[$table] ?? null; + if ($columns === null) { + return null; + } + $identity = ['table' => $table]; + foreach ($columns as $column) { + if (!array_key_exists($column, $row)) { + return null; + } + $identity[$column] = $row[$column]; + } + return $identity; +} + function cow_merge_keyless_row_identity_ambiguous(?array $base_row, ?array $source_row, ?array $target_row, array $columns): bool { if ($base_row === null || $source_row === null || $target_row === null || !$columns) { return false; @@ -3422,6 +3481,28 @@ function cow_merge_ensure_metadata(SQLite3 $meta): void { ) SQL, 'failed to create metadata table merge_conflicts'); cow_merge_exec_checked($meta, <<<'SQL' +CREATE TABLE IF NOT EXISTS merge_revalidations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conflict_id INTEGER NOT NULL, + review_note_id INTEGER NOT NULL, + run_id INTEGER NOT NULL, + replacement_conflict_id INTEGER, + revalidation_class TEXT NOT NULL DEFAULT 'unclassified', + source_payload TEXT NOT NULL, + target_payload TEXT NOT NULL, + source_hash TEXT NOT NULL, + target_hash TEXT NOT NULL, + stale_reason TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(conflict_id) REFERENCES merge_conflicts(id), + FOREIGN KEY(replacement_conflict_id) REFERENCES merge_conflicts(id), + FOREIGN KEY(review_note_id) REFERENCES merge_review_notes(id), + FOREIGN KEY(run_id) REFERENCES merge_runs(id) +) +SQL, 'failed to create metadata table merge_revalidations'); + cow_merge_ensure_metadata_column($meta, 'merge_revalidations', 'replacement_conflict_id', 'INTEGER'); + cow_merge_ensure_metadata_column($meta, 'merge_revalidations', 'revalidation_class', "TEXT NOT NULL DEFAULT 'unclassified'"); + cow_merge_exec_checked($meta, <<<'SQL' CREATE TABLE IF NOT EXISTS merge_row_identities ( id INTEGER PRIMARY KEY AUTOINCREMENT, branch_name TEXT NOT NULL, @@ -3730,6 +3811,220 @@ function cow_merge_sqlite_snapshot_artifact(?array $snapshot): ?array { ]; } +function cow_merge_crash_recovery_dir(string $metadata_db): string { + return dirname($metadata_db) . '/crash-recovery'; +} + +function cow_merge_crash_recovery_artifact_path(string $metadata_db, int $run_id, string $checkpoint): string { + $safe_checkpoint = preg_replace('/[^A-Za-z0-9_.-]/', '-', $checkpoint); + if (!is_string($safe_checkpoint) || $safe_checkpoint === '') { + $safe_checkpoint = 'unknown'; + } + return cow_merge_crash_recovery_dir($metadata_db) . '/run-' . $run_id . '-' . $safe_checkpoint . '.json'; +} + +function cow_merge_write_crash_recovery_artifact( + string $metadata_db, + int $run_id, + string $checkpoint, + array $run_context, + ?array $target_snapshot, + ?array $filesystem_transaction = null, + ?string $filesystem_target_root = null, + ?array $metadata_snapshot = null, + ?array $filesystem_snapshot = null +): string { + $path = cow_merge_crash_recovery_artifact_path($metadata_db, $run_id, $checkpoint); + $artifacts = []; + if ($target_snapshot !== null) { + $artifacts['target_db_snapshot'] = cow_merge_sqlite_snapshot_artifact($target_snapshot); + } + if ($metadata_snapshot !== null) { + $artifacts['metadata_db_snapshot'] = cow_merge_sqlite_snapshot_artifact($metadata_snapshot); + } + if ($filesystem_transaction !== null) { + $artifacts['filesystem_transaction'] = $filesystem_transaction; + $artifacts['filesystem_transaction_summary'] = cow_merge_file_transaction_artifact($filesystem_transaction, $filesystem_target_root); + } + if ($filesystem_snapshot !== null) { + $artifacts['filesystem_snapshot'] = $filesystem_snapshot; + $artifacts['filesystem_snapshot_summary'] = cow_merge_file_root_snapshot_artifact($filesystem_snapshot, $filesystem_target_root); + } + cow_merge_write_json_file($path, [ + 'version' => 1, + 'created_at' => gmdate('c'), + 'checkpoint' => $checkpoint, + 'run_id' => $run_id, + 'source_branch' => (string)($run_context['source_branch'] ?? ''), + 'target_branch' => (string)($run_context['target_branch'] ?? ''), + 'base_db' => (string)($run_context['base_db'] ?? ''), + 'source_db' => (string)($run_context['source_db'] ?? ''), + 'target_db' => (string)($run_context['target_db'] ?? ''), + 'target_root' => $filesystem_target_root, + 'artifacts' => $artifacts, + ]); + return $path; +} + +function cow_merge_remove_crash_recovery_artifact(?string $path): void { + if ($path === null) { + return; + } + if (is_file($path) || is_link($path)) { + @unlink($path); + } + $dir = dirname($path); + if (is_dir($dir)) { + $entries = scandir($dir); + if (is_array($entries) && count(array_diff($entries, ['.', '..'])) === 0) { + @rmdir($dir); + } + } +} + +function cow_merge_crash_recovery_artifacts(string $metadata_db, ?int $run_id = null): array { + $dir = cow_merge_crash_recovery_dir($metadata_db); + if (!is_dir($dir)) { + return []; + } + $files = glob($dir . '/*.json'); + if (!is_array($files)) { + return []; + } + sort($files); + $artifacts = []; + foreach ($files as $file) { + if (!is_file($file)) { + continue; + } + $decoded = json_decode((string)file_get_contents($file), true); + if (!is_array($decoded)) { + throw new RuntimeException("invalid crash recovery artifact: $file"); + } + $artifact_run_id = (int)($decoded['run_id'] ?? 0); + if ($run_id !== null && $artifact_run_id !== $run_id) { + continue; + } + $snapshot = $decoded['artifacts']['target_db_snapshot'] ?? null; + $metadata_snapshot = $decoded['artifacts']['metadata_db_snapshot'] ?? null; + $filesystem_transaction = $decoded['artifacts']['filesystem_transaction'] ?? null; + $filesystem_summary = $decoded['artifacts']['filesystem_transaction_summary'] ?? null; + $filesystem_snapshot = $decoded['artifacts']['filesystem_snapshot'] ?? null; + $filesystem_snapshot_summary = $decoded['artifacts']['filesystem_snapshot_summary'] ?? null; + $artifacts[] = [ + 'artifact_path' => $file, + 'checkpoint' => (string)($decoded['checkpoint'] ?? 'unknown'), + 'run_id' => $artifact_run_id, + 'source_branch' => (string)($decoded['source_branch'] ?? ''), + 'target_branch' => (string)($decoded['target_branch'] ?? ''), + 'target_db' => (string)($decoded['target_db'] ?? ''), + 'target_root' => (string)($decoded['target_root'] ?? ''), + 'target_db_snapshot' => is_array($snapshot) ? $snapshot : null, + 'metadata_db_snapshot' => is_array($metadata_snapshot) ? $metadata_snapshot : null, + 'filesystem_transaction' => is_array($filesystem_transaction) ? $filesystem_transaction : null, + 'filesystem_transaction_summary' => is_array($filesystem_summary) ? $filesystem_summary : null, + 'filesystem_snapshot' => is_array($filesystem_snapshot) ? $filesystem_snapshot : null, + 'filesystem_snapshot_summary' => is_array($filesystem_snapshot_summary) ? $filesystem_snapshot_summary : null, + ]; + } + return $artifacts; +} + +function cow_merge_recover_crash_artifacts( + string $metadata_db, + ?int $run_id = null, + bool $restore_target_db = false, + bool $restore_files = false +): array { + $artifacts = cow_merge_crash_recovery_artifacts($metadata_db, $run_id); + $restored = 0; + if ($restore_target_db || $restore_files) { + foreach ($artifacts as $artifact) { + $restored_snapshot = null; + $restored_metadata_snapshot = null; + $restored_filesystem_transaction = null; + $restored_filesystem_snapshot = null; + $restored_any_artifact = false; + if ($restore_target_db) { + $snapshot = $artifact['target_db_snapshot'] ?? null; + if (!is_array($snapshot) && !$restore_files) { + throw new RuntimeException("crash recovery artifact has no target DB snapshot: {$artifact['artifact_path']}"); + } + if (is_array($snapshot)) { + cow_merge_restore_sqlite_snapshot($snapshot); + $restored_snapshot = $snapshot; + $restored_any_artifact = true; + $metadata_snapshot = $artifact['metadata_db_snapshot'] ?? null; + if (is_array($metadata_snapshot)) { + cow_merge_restore_sqlite_snapshot($metadata_snapshot); + $restored_metadata_snapshot = $metadata_snapshot; + } + } + } + if ($restore_files) { + $filesystem_transaction = $artifact['filesystem_transaction'] ?? null; + $filesystem_snapshot = $artifact['filesystem_snapshot'] ?? null; + $target_root = (string)($artifact['target_root'] ?? ''); + $can_restore_filesystem = (is_array($filesystem_transaction) || is_array($filesystem_snapshot)) && $target_root !== ''; + if (!$can_restore_filesystem && !$restored_any_artifact) { + throw new RuntimeException("crash recovery artifact has no filesystem transaction: {$artifact['artifact_path']}"); + } + if ($can_restore_filesystem && is_array($filesystem_snapshot)) { + cow_merge_file_root_snapshot_restore($filesystem_snapshot, $target_root); + $restored_filesystem_snapshot = $filesystem_snapshot; + $restored_any_artifact = true; + } elseif ($can_restore_filesystem && is_array($filesystem_transaction)) { + cow_merge_file_transaction_restore($filesystem_transaction, $target_root); + $restored_filesystem_transaction = $filesystem_transaction; + $restored_any_artifact = true; + } + } + cow_merge_failpoint('after-crash-recovery-restore'); + cow_merge_remove_crash_recovery_artifact((string)$artifact['artifact_path']); + if ($restored_snapshot !== null) { + cow_merge_cleanup_sqlite_snapshot($restored_snapshot); + } + if ($restored_metadata_snapshot !== null) { + cow_merge_cleanup_sqlite_snapshot($restored_metadata_snapshot); + } + if ($restored_filesystem_transaction !== null) { + cow_merge_file_transaction_cleanup($restored_filesystem_transaction); + } + if ($restored_filesystem_snapshot !== null) { + cow_merge_file_root_snapshot_cleanup($restored_filesystem_snapshot); + } + $restored++; + } + $artifacts = cow_merge_crash_recovery_artifacts($metadata_db, $run_id); + } + return [ + 'metadata_db' => $metadata_db, + 'run_id' => $run_id, + 'restore_target_db' => $restore_target_db, + 'restore_files' => $restore_files, + 'pending' => count($artifacts), + 'restored' => $restored, + 'artifacts' => $artifacts, + ]; +} + +function cow_merge_assert_no_pending_crash_recovery(string $metadata_db): void { + $artifacts = cow_merge_crash_recovery_artifacts($metadata_db); + if (count($artifacts) === 0) { + return; + } + + $first = $artifacts[0]; + $run = (int)($first['run_id'] ?? 0); + $checkpoint = (string)($first['checkpoint'] ?? 'unknown'); + $command = PHP_BINARY . ' ' . __FILE__ . ' recover-crash --metadata-db ' . escapeshellarg($metadata_db) . ' --format json'; + throw new RuntimeException( + 'refusing to start merge while ' . count($artifacts) . ' pending COW merge crash recovery artifact(s) exist' + . " for metadata DB $metadata_db; first pending artifact is run #$run at checkpoint $checkpoint. " + . "Inspect pending recovery with `forkpress branch recover-crash` or `$command`, then restore with --restore-target-db and/or --restore-files before merging again." + ); +} + function cow_merge_file_root_snapshot_artifact(?array $snapshot, ?string $target_root): ?array { if ($snapshot === null) { return null; @@ -4261,93 +4556,739 @@ function cow_merge_lookup_autoincrement_band(SQLite3 $meta, string $branch, stri ]; } -function cow_merge_round_up_to_band(int $value, int $band_size): int { - $remainder = $value % $band_size; - if ($remainder === 0) { - return $value; +function cow_merge_autoincrement_id_band_violation( + SQLite3 $meta, + string $source_branch, + string $table, + array $source_row, + array $pk_cols +): ?string { + if (count($pk_cols) !== 1) { + return null; } - return $value + ($band_size - $remainder); + $band = cow_merge_lookup_autoincrement_band($meta, $source_branch, $table); + if ($band === null) { + return null; + } + $pk_col = $pk_cols[0]; + if (!array_key_exists($pk_col, $source_row)) { + return null; + } + $value = $source_row[$pk_col]; + if (!is_int($value) && !(is_string($value) && preg_match('/^-?\d+$/', $value))) { + return null; + } + $id = (int)$value; + $band_start = (int)$band['band_start']; + $band_end = (int)$band['band_end']; + if ($id >= $band_start && $id <= $band_end) { + return null; + } + return "source inserted explicit AUTOINCREMENT id $id outside reserved branch band $band_start-$band_end"; } -function cow_merge_next_autoincrement_band_start(SQLite3 $meta, string $table, int $min_start, int $band_size): int { +function cow_merge_wordpress_parent_reference_violation( + SQLite3 $source, + SQLite3 $target, + SQLite3 $meta, + string $source_branch, + string $child_label, + string $parent_table, + string $parent_pk, + mixed $parent_id, + string $parent_label, + string $source_action = 'inserted' +): ?string { + if (!is_int($parent_id) && !(is_string($parent_id) && preg_match('/^-?\d+$/', (string)$parent_id))) { + return null; + } + $parent_id = (int)$parent_id; + if ($parent_id <= 0 || !cow_merge_schema_object_exists($target, $parent_table)) { + return null; + } $stmt = cow_merge_prepare_checked( - $meta, - 'SELECT MAX(band_end) AS max_band_end FROM merge_autoincrement_bands WHERE table_name = :table_name', - 'failed to prepare AUTOINCREMENT band selection' + $target, + 'SELECT 1 FROM ' . cow_merge_quote_ident($parent_table) . ' WHERE ' . cow_merge_quote_ident($parent_pk) . ' = :parent_id LIMIT 1', + "failed to prepare WordPress $parent_label parent lookup" ); - cow_merge_bind($stmt, ':table_name', $table); - $res = cow_merge_execute_checked($stmt, $meta, 'failed to choose AUTOINCREMENT band'); - $row = $res->fetchArray(SQLITE3_ASSOC); - cow_merge_result_finalize_checked($res, 'failed to finalize AUTOINCREMENT band selection'); - $after_existing_bands = $row && $row['max_band_end'] !== null ? ((int)$row['max_band_end']) + 1 : COW_MERGE_AUTOINCREMENT_FIRST_BAND_START; - return cow_merge_round_up_to_band(max(COW_MERGE_AUTOINCREMENT_FIRST_BAND_START, $after_existing_bands, $min_start), $band_size); + cow_merge_bind($stmt, ':parent_id', $parent_id); + $res = cow_merge_execute_checked($stmt, $target, "failed to inspect WordPress $parent_label parent row"); + try { + if ($res->fetchArray(SQLITE3_NUM)) { + return null; + } + } finally { + cow_merge_result_finalize_checked($res, "failed to finalize WordPress $parent_label parent lookup"); + } + if (!cow_merge_schema_object_exists($source, $parent_table)) { + return "source $source_action $child_label references missing $parent_table.$parent_pk $parent_id; parent $parent_label must merge before child row"; + } + $stmt = cow_merge_prepare_checked( + $source, + 'SELECT * FROM ' . cow_merge_quote_ident($parent_table) . ' WHERE ' . cow_merge_quote_ident($parent_pk) . ' = :parent_id LIMIT 1', + "failed to prepare WordPress $parent_label source parent lookup" + ); + cow_merge_bind($stmt, ':parent_id', $parent_id); + $res = cow_merge_execute_checked($stmt, $source, "failed to inspect WordPress $parent_label source parent row"); + try { + $parent = $res->fetchArray(SQLITE3_ASSOC); + } finally { + cow_merge_result_finalize_checked($res, "failed to finalize WordPress $parent_label source parent lookup"); + } + if (!$parent) { + return "source $source_action $child_label references missing $parent_table.$parent_pk $parent_id; parent $parent_label must merge before child row"; + } + $parent_band_violation = cow_merge_autoincrement_id_band_violation($meta, $source_branch, $parent_table, $parent, [$parent_pk]); + if ($parent_band_violation !== null) { + return "source $source_action $child_label references $parent_table.$parent_pk $parent_id that is outside the source branch ID band; parent $parent_label must merge before child row"; + } + return null; } -function cow_merge_remember_autoincrement_band( +function cow_merge_wordpress_source_row(SQLite3 $source, string $table, string $pk, mixed $id): ?array { + if (!is_int($id) && !(is_string($id) && preg_match('/^-?\d+$/', (string)$id))) { + return null; + } + if (!cow_merge_schema_object_exists($source, $table)) { + return null; + } + $stmt = cow_merge_prepare_checked( + $source, + 'SELECT * FROM ' . cow_merge_quote_ident($table) . ' WHERE ' . cow_merge_quote_ident($pk) . ' = :id LIMIT 1', + "failed to prepare WordPress source row lookup for $table" + ); + cow_merge_bind($stmt, ':id', (int)$id); + $res = cow_merge_execute_checked($stmt, $source, "failed to inspect WordPress source row for $table"); + try { + $row = $res->fetchArray(SQLITE3_ASSOC); + return $row ?: null; + } finally { + cow_merge_result_finalize_checked($res, "failed to finalize WordPress source row lookup for $table"); + } +} + +function cow_merge_wordpress_source_postmeta_value(SQLite3 $source, mixed $post_id, string $meta_key): ?string { + if (!is_int($post_id) && !(is_string($post_id) && preg_match('/^-?\d+$/', (string)$post_id))) { + return null; + } + if (!cow_merge_schema_object_exists($source, 'wp_postmeta')) { + return null; + } + $stmt = cow_merge_prepare_checked( + $source, + "SELECT meta_value FROM wp_postmeta WHERE post_id = :post_id AND meta_key = :meta_key ORDER BY meta_id DESC LIMIT 1", + 'failed to prepare WordPress source postmeta lookup' + ); + cow_merge_bind($stmt, ':post_id', (int)$post_id); + cow_merge_bind($stmt, ':meta_key', $meta_key); + $res = cow_merge_execute_checked($stmt, $source, 'failed to inspect WordPress source postmeta row'); + try { + $row = $res->fetchArray(SQLITE3_ASSOC); + return $row ? (string)$row['meta_value'] : null; + } finally { + cow_merge_result_finalize_checked($res, 'failed to finalize WordPress source postmeta lookup'); + } +} + +function cow_merge_wordpress_comment_reference_violation( + SQLite3 $source, + SQLite3 $target, SQLite3 $meta, - int $run_id, - string $branch, - string $table, - int $band_start, - int $band_end, - int $band_size, - bool $new_allocation -): void { - if ($new_allocation) { - $stmt = cow_merge_prepare_checked( - $meta, - 'INSERT INTO merge_autoincrement_bands ' . - '(branch_name, table_name, band_start, band_end, band_size, allocated_run_id, last_seen_run_id) ' . - 'VALUES (:branch_name, :table_name, :band_start, :band_end, :band_size, :allocated_run_id, :last_seen_run_id) ' . - 'ON CONFLICT(branch_name, table_name) DO UPDATE SET ' . - 'band_start = excluded.band_start, band_end = excluded.band_end, band_size = excluded.band_size, ' . - 'allocated_run_id = excluded.allocated_run_id, last_seen_run_id = excluded.last_seen_run_id, updated_at = CURRENT_TIMESTAMP', - 'failed to prepare AUTOINCREMENT band upsert' - ); - cow_merge_bind($stmt, ':allocated_run_id', $run_id); - } else { - $stmt = cow_merge_prepare_checked( - $meta, - 'UPDATE merge_autoincrement_bands SET last_seen_run_id = :last_seen_run_id, updated_at = CURRENT_TIMESTAMP ' . - 'WHERE branch_name = :branch_name AND table_name = :table_name', - 'failed to prepare AUTOINCREMENT band refresh' - ); + string $source_branch, + string $child_label, + array $comment, + string $source_action = 'inserted' +): ?string { + $post_violation = cow_merge_wordpress_parent_reference_violation($source, $target, $meta, $source_branch, $child_label, 'wp_posts', 'ID', $comment['comment_post_ID'] ?? null, 'post', $source_action); + if ($post_violation !== null) { + return $post_violation; } - cow_merge_bind($stmt, ':branch_name', $branch); - cow_merge_bind($stmt, ':table_name', $table); - if ($new_allocation) { - cow_merge_bind($stmt, ':band_start', $band_start); - cow_merge_bind($stmt, ':band_end', $band_end); - cow_merge_bind($stmt, ':band_size', $band_size); + $comment_parent = $comment['comment_parent'] ?? null; + $comment_violation = cow_merge_wordpress_parent_reference_violation($source, $target, $meta, $source_branch, $child_label, 'wp_comments', 'comment_ID', $comment_parent, 'parent comment', $source_action); + if ($comment_violation !== null) { + return $comment_violation; } - cow_merge_bind($stmt, ':last_seen_run_id', $run_id); - cow_merge_execute_checked($stmt, $meta, 'failed to remember AUTOINCREMENT band'); + $parent_comment = cow_merge_wordpress_source_row($source, 'wp_comments', 'comment_ID', $comment_parent); + if ($parent_comment !== null) { + return cow_merge_wordpress_parent_reference_violation($source, $target, $meta, $source_branch, $child_label, 'wp_posts', 'ID', $parent_comment['comment_post_ID'] ?? null, 'parent comment post', $source_action); + } + return null; } -function cow_merge_allocate_autoincrement_bands( - string $db_path, - string $metadata_db, - string $branch, - int $band_size = COW_MERGE_AUTOINCREMENT_BAND_SIZE -): array { - if (!is_file($db_path)) { - throw new RuntimeException("SQLite database does not exist: $db_path"); +function cow_merge_json_object_end(string $text, int $start): ?int { + if (($text[$start] ?? '') !== '{') { + return null; } - cow_merge_mkdir_p(dirname($metadata_db)); + $depth = 0; + $in_string = false; + $escaped = false; + $length = strlen($text); + for ($i = $start; $i < $length; $i++) { + $char = $text[$i]; + if ($in_string) { + if ($escaped) { + $escaped = false; + } elseif ($char === '\\') { + $escaped = true; + } elseif ($char === '"') { + $in_string = false; + } + continue; + } + if ($char === '"') { + $in_string = true; + continue; + } + if ($char === '{') { + $depth++; + continue; + } + if ($char === '}') { + $depth--; + if ($depth === 0) { + return $i; + } + } + } + return null; +} - $db = cow_merge_open_db($db_path, SQLITE3_OPEN_READWRITE); - $meta = cow_merge_open_db($metadata_db, SQLITE3_OPEN_READWRITE | SQLITE3_OPEN_CREATE); - cow_merge_ensure_metadata($meta); - $run_id = cow_merge_start_id_band_run($meta, $branch, $db_path); +function cow_merge_wordpress_post_content_blocks(string $content): array { + if (!preg_match_all('/

Base $case page body

", + ]); + update_post_meta($id, '_forkpress_semantic_base', $case); + } + } + + if ($branch !== null) { + $suffix = ucfirst($branch); + $user_id = $must_insert_user([ + 'user_login' => "forkpress_semantic_$branch", + 'user_pass' => wp_generate_password(32, true), + 'display_name' => "Semantic $suffix Author", + 'role' => 'author', + ]); + $user_graph = [ + 'branch' => $branch, + 'user_id' => (int)$user_id, + ]; + update_user_meta($user_id, '_forkpress_semantic_user_graph', $user_graph); + update_user_meta($user_id, '_forkpress_semantic_user_serialized_graph', serialize($user_graph)); + + $edit_id = $find_page("Semantic $suffix Edit Page"); + if ($edit_id === 0) { + wp_send_json_error(['error' => "missing Semantic $suffix Edit Page"], 500); + } + $edit_result = wp_update_post([ + 'ID' => $edit_id, + 'post_title' => "Semantic $suffix Edited Page", + 'post_content' => "

Edited on $branch branch

", + 'post_author' => $user_id, + ], true); + if (is_wp_error($edit_result)) { + wp_send_json_error(['error' => $edit_result->get_error_message()], 500); + } + + $delete_id = $find_page("Semantic $suffix Delete Page"); + if ($delete_id === 0) { + wp_send_json_error(['error' => "missing Semantic $suffix Delete Page"], 500); + } + if (wp_delete_post($delete_id, true) === false) { + wp_send_json_error(['error' => "failed to delete Semantic $suffix Delete Page"], 500); + } + + $page_id = wp_insert_post([ + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => "Semantic $suffix Page", + 'post_content' => "

Semantic $branch page body

", + 'post_author' => $user_id, + ], true); + if (is_wp_error($page_id)) { + wp_send_json_error(['error' => $page_id->get_error_message()], 500); + } + update_post_meta($page_id, '_forkpress_semantic_branch', $branch); + $parent_term_id = $must_term("Semantic $suffix Parent Topic"); + $topic_term_id = $must_term("Semantic $suffix Topic", $parent_term_id); + $must_set_terms($page_id, [$topic_term_id]); + + $note_id = wp_insert_post([ + 'post_type' => 'forkpress_note', + 'post_status' => 'publish', + 'post_title' => "Semantic $suffix Note", + 'post_content' => "CPT content for $branch", + 'post_author' => $user_id, + ], true); + if (is_wp_error($note_id)) { + wp_send_json_error(['error' => $note_id->get_error_message()], 500); + } + update_post_meta($note_id, '_forkpress_semantic_note', $branch); + $must_set_terms($note_id, [$topic_term_id]); + + $block_id = wp_insert_post([ + 'post_type' => 'wp_block', + 'post_status' => 'publish', + 'post_title' => "Semantic $suffix Block", + 'post_content' => "

Reusable block for $branch

", + 'post_author' => $user_id, + ], true); + if (is_wp_error($block_id)) { + wp_send_json_error(['error' => $block_id->get_error_message()], 500); + } + $page_update = wp_update_post([ + 'ID' => (int)$page_id, + 'post_content' => "

Semantic $branch page body

\n", + ], true); + if (is_wp_error($page_update)) { + wp_send_json_error(['error' => $page_update->get_error_message()], 500); + } + + $menu_id = wp_create_nav_menu("Semantic $suffix Menu"); + if (is_wp_error($menu_id)) { + wp_send_json_error(['error' => $menu_id->get_error_message()], 500); + } + $menu_item_id = wp_update_nav_menu_item($menu_id, 0, [ + 'menu-item-title' => "Semantic $suffix Link", + 'menu-item-status' => 'publish', + 'menu-item-type' => 'post_type', + 'menu-item-object' => 'page', + 'menu-item-object-id' => (int)$page_id, + ]); + if (is_wp_error($menu_item_id)) { + wp_send_json_error(['error' => $menu_item_id->get_error_message()], 500); + } + $locations = get_theme_mod('nav_menu_locations', []); + if (!is_array($locations)) { + $locations = []; + } + $locations["forkpress_semantic_{$branch}"] = (int)$menu_id; + set_theme_mod('nav_menu_locations', $locations); + + $upload = wp_upload_dir(); + if (!empty($upload['error'])) { + wp_send_json_error(['error' => $upload['error']], 500); + } + if (!wp_mkdir_p($upload['path'])) { + wp_send_json_error(['error' => 'failed to create upload directory'], 500); + } + $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', true); + if ($png === false) { + wp_send_json_error(['error' => 'failed to decode test image'], 500); + } + $filename = "forkpress-semantic-$branch.png"; + $path = trailingslashit($upload['path']) . $filename; + if (file_put_contents($path, $png) === false) { + wp_send_json_error(['error' => 'failed to write upload file'], 500); + } + $thumbnail_filename = "forkpress-semantic-$branch-150x150.png"; + $thumbnail_path = trailingslashit($upload['path']) . $thumbnail_filename; + if (file_put_contents($thumbnail_path, $png) === false) { + wp_send_json_error(['error' => 'failed to write generated upload size'], 500); + } + $attachment_id = wp_insert_attachment([ + 'post_title' => "Semantic $suffix Media", + 'post_mime_type' => 'image/png', + 'post_status' => 'inherit', + 'post_author' => $user_id, + ], $path, $page_id, true); + if (is_wp_error($attachment_id)) { + wp_send_json_error(['error' => $attachment_id->get_error_message()], 500); + } + wp_update_attachment_metadata($attachment_id, [ + 'width' => 300, + 'height' => 300, + 'file' => _wp_relative_upload_path($path), + 'filesize' => filesize($path), + 'sizes' => [ + 'thumbnail' => [ + 'file' => $thumbnail_filename, + 'width' => 150, + 'height' => 150, + 'mime-type' => 'image/png', + 'filesize' => filesize($thumbnail_path), + ], + ], + 'image_meta' => [], + ]); + update_post_meta($attachment_id, '_forkpress_semantic_media', $branch); + + $comment_id = wp_insert_comment([ + 'comment_post_ID' => (int)$page_id, + 'comment_content' => "Semantic $suffix Comment", + 'comment_approved' => 1, + 'user_id' => (int)$user_id, + ]); + if ($comment_id === false || (int)$comment_id <= 0) { + wp_send_json_error(['error' => 'failed to insert semantic comment'], 500); + } + $comment_id = (int)$comment_id; + $reply_id = wp_insert_comment([ + 'comment_post_ID' => (int)$page_id, + 'comment_content' => "Semantic $suffix Reply", + 'comment_parent' => $comment_id, + 'comment_approved' => 1, + 'user_id' => (int)$user_id, + ]); + if ($reply_id === false || (int)$reply_id <= 0) { + wp_send_json_error(['error' => 'failed to insert semantic reply'], 500); + } + $reply_id = (int)$reply_id; + $comment_graph = [ + 'branch' => $branch, + 'page_id' => (int)$page_id, + 'comment_id' => $comment_id, + 'reply_id' => $reply_id, + 'user_id' => (int)$user_id, + ]; + add_comment_meta($comment_id, '_forkpress_semantic_comment_graph', $comment_graph); + add_comment_meta($reply_id, '_forkpress_semantic_comment_serialized_graph', serialize($comment_graph)); + + $graph = [ + 'branch' => $branch, + 'user_id' => (int)$user_id, + 'page_id' => (int)$page_id, + 'note_id' => (int)$note_id, + 'block_id' => (int)$block_id, + 'menu_id' => (int)$menu_id, + 'attachment_id' => (int)$attachment_id, + 'comment_id' => $comment_id, + 'reply_id' => $reply_id, + ]; + update_option("forkpress_semantic_{$branch}_option", $graph, false); + update_option("forkpress_semantic_{$branch}_json_option", wp_json_encode($graph), false); + + $plugin_file = trailingslashit($upload['path']) . "forkpress-plugin-graph-$branch.dat"; + if (file_put_contents($plugin_file, "plugin graph file for $branch\n") === false) { + wp_send_json_error(['error' => 'failed to write plugin graph file'], 500); + } + $plugin_file_rel = _wp_relative_upload_path($plugin_file); + $initial_graph = [ + 'branch' => $branch, + 'page_id' => (int)$page_id, + 'note_id' => (int)$note_id, + 'attachment_id' => (int)$attachment_id, + 'file' => $plugin_file_rel, + ]; + $inserted = $wpdb->insert($plugin_parent_table, [ + 'branch' => $branch, + 'label' => "Semantic $suffix Plugin Parent", + 'graph_json' => wp_json_encode($initial_graph), + 'graph_serialized' => serialize($initial_graph), + ]); + if ($inserted === false) { + wp_send_json_error(['error' => $wpdb->last_error ?: 'failed to insert plugin parent'], 500); + } + $plugin_parent_id = (int)$wpdb->insert_id; + $inserted = $wpdb->insert($plugin_child_table, [ + 'parent_id' => $plugin_parent_id, + 'branch' => $branch, + 'file_path' => $plugin_file_rel, + 'payload' => wp_json_encode([ + 'branch' => $branch, + 'parent_id' => $plugin_parent_id, + 'page_id' => (int)$page_id, + 'note_id' => (int)$note_id, + ]), + ]); + if ($inserted === false) { + wp_send_json_error(['error' => $wpdb->last_error ?: 'failed to insert plugin child'], 500); + } + $plugin_child_id = (int)$wpdb->insert_id; + $plugin_graph = $initial_graph + [ + 'parent_id' => $plugin_parent_id, + 'child_id' => $plugin_child_id, + ]; + $updated = $wpdb->update($plugin_parent_table, [ + 'graph_json' => wp_json_encode($plugin_graph), + 'graph_serialized' => serialize($plugin_graph), + ], ['id' => $plugin_parent_id]); + if ($updated === false) { + wp_send_json_error(['error' => $wpdb->last_error ?: 'failed to update plugin parent graph'], 500); + } + update_option("forkpress_semantic_plugin_{$branch}_option", $plugin_graph, false); + update_post_meta($page_id, '_forkpress_semantic_plugin_graph', wp_json_encode($plugin_graph)); + } + + $posts = get_posts([ + 'post_type' => ['page', 'forkpress_note', 'wp_block', 'attachment'], + 'post_status' => 'any', + 'numberposts' => -1, + 'orderby' => 'ID', + 'order' => 'ASC', + ]); + $rows = []; + foreach ($posts as $post) { + if (strpos($post->post_title, 'Semantic ') !== 0) { + continue; + } + $file = $post->post_type === 'attachment' ? get_attached_file($post->ID) : ''; + $metadata = $post->post_type === 'attachment' ? wp_get_attachment_metadata($post->ID) : []; + $metadata_sizes = []; + $generated_files = []; + if (is_array($metadata) && is_array($metadata['sizes'] ?? null) && $file !== '') { + foreach ($metadata['sizes'] as $size_name => $size) { + $metadata_sizes[] = (string)$size_name; + $generated_files[(string)$size_name] = isset($size['file']) + ? file_exists(trailingslashit(dirname($file)) . $size['file']) + : false; + } + sort($metadata_sizes); + ksort($generated_files); + } + $term_objects = wp_get_object_terms($post->ID, 'forkpress_topic'); + $terms = []; + $term_parents = []; + if (!is_wp_error($term_objects)) { + foreach ($term_objects as $term) { + $terms[] = $term->name; + if ((int)$term->parent > 0) { + $parent = get_term((int)$term->parent, 'forkpress_topic'); + if ($parent && !is_wp_error($parent)) { + $term_parents[$term->name] = $parent->name; + } + } + } + } + sort($terms); + ksort($term_parents); + $block_refs = []; + if (preg_match_all('//', $post->post_content, $matches)) { + $block_refs = array_map('intval', $matches[1]); + sort($block_refs); + } + $rows[] = [ + 'id' => (int)$post->ID, + 'type' => $post->post_type, + 'title' => $post->post_title, + 'content' => $post->post_content, + 'block_refs' => $block_refs, + 'author' => (int)$post->post_author, + 'branch' => get_post_meta($post->ID, '_forkpress_semantic_branch', true) + ?: get_post_meta($post->ID, '_forkpress_semantic_note', true) + ?: get_post_meta($post->ID, '_forkpress_semantic_media', true), + 'terms' => $terms, + 'term_parents' => $term_parents, + 'file_exists' => $file === '' ? null : file_exists($file), + 'metadata_sizes' => $metadata_sizes, + 'generated_files' => $generated_files, + ]; + } + + $users = []; + foreach (get_users(['search' => 'forkpress_semantic_*', 'search_columns' => ['user_login']]) as $user) { + $graph = get_user_meta($user->ID, '_forkpress_semantic_user_graph', true); + $serialized_graph = maybe_unserialize((string)get_user_meta($user->ID, '_forkpress_semantic_user_serialized_graph', true)); + $users[$user->user_login] = [ + 'id' => (int)$user->ID, + 'display_name' => $user->display_name, + 'graph_user_id' => is_array($graph) ? (int)($graph['user_id'] ?? 0) : 0, + 'serialized_graph_user_id' => is_array($serialized_graph) ? (int)($serialized_graph['user_id'] ?? 0) : 0, + ]; + } + ksort($users); + + $comments = []; + $comment_rows = get_comments([ + 'status' => 'all', + 'orderby' => 'comment_ID', + 'order' => 'ASC', + ]); + foreach ($comment_rows as $comment) { + if (strpos((string)$comment->comment_content, 'Semantic ') !== 0) { + continue; + } + $graph = get_comment_meta($comment->comment_ID, '_forkpress_semantic_comment_graph', true); + $serialized_graph = maybe_unserialize((string)get_comment_meta($comment->comment_ID, '_forkpress_semantic_comment_serialized_graph', true)); + $comments[(string)$comment->comment_content] = [ + 'id' => (int)$comment->comment_ID, + 'post_id' => (int)$comment->comment_post_ID, + 'parent' => (int)$comment->comment_parent, + 'user_id' => (int)$comment->user_id, + 'graph_comment_id' => is_array($graph) ? (int)($graph['comment_id'] ?? 0) : 0, + 'graph_reply_id' => is_array($graph) ? (int)($graph['reply_id'] ?? 0) : 0, + 'graph_user_id' => is_array($graph) ? (int)($graph['user_id'] ?? 0) : 0, + 'serialized_graph_comment_id' => is_array($serialized_graph) ? (int)($serialized_graph['comment_id'] ?? 0) : 0, + 'serialized_graph_reply_id' => is_array($serialized_graph) ? (int)($serialized_graph['reply_id'] ?? 0) : 0, + 'serialized_graph_user_id' => is_array($serialized_graph) ? (int)($serialized_graph['user_id'] ?? 0) : 0, + ]; + } + ksort($comments); + + $menus = []; + $menu_items = []; + foreach (wp_get_nav_menus(['hide_empty' => false]) as $menu) { + if (strpos($menu->name, 'Semantic ') === 0) { + $menus[] = $menu->name; + $items = wp_get_nav_menu_items($menu->term_id); + if (is_array($items)) { + foreach ($items as $item) { + if (strpos($item->title, 'Semantic ') !== 0) { + continue; + } + $menu_items[$item->title] = [ + 'menu' => $menu->name, + 'type' => $item->type, + 'object' => $item->object, + 'object_id' => (int)$item->object_id, + ]; + } + } + } + } + sort($menus); + ksort($menu_items); + $locations = []; + foreach (get_nav_menu_locations() as $location => $menu_id) { + if (strpos((string)$location, 'forkpress_semantic_') !== 0 || (int)$menu_id <= 0) { + continue; + } + $menu = wp_get_nav_menu_object((int)$menu_id); + if ($menu && !is_wp_error($menu)) { + $locations[$location] = $menu->name; + } + } + ksort($locations); + + $plugin_graphs = []; + $parent_table_exists = (string)$wpdb->get_var($wpdb->prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = %s", $plugin_parent_table)); + $child_table_exists = (string)$wpdb->get_var($wpdb->prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = %s", $plugin_child_table)); + if ($parent_table_exists === $plugin_parent_table && $child_table_exists === $plugin_child_table) { + $upload_dir = wp_upload_dir(); + $parents = $wpdb->get_results('SELECT id, branch, label, graph_json, graph_serialized FROM ' . $quote_ident($plugin_parent_table) . ' ORDER BY id', ARRAY_A); + if (!is_array($parents)) { + wp_send_json_error(['error' => $wpdb->last_error ?: 'failed to select plugin parents'], 500); + } + foreach ($parents as $parent) { + $parent_id = (int)$parent['id']; + $child = $wpdb->get_row($wpdb->prepare('SELECT id, parent_id, branch, file_path, payload FROM ' . $quote_ident($plugin_child_table) . ' WHERE parent_id = %d', $parent_id), ARRAY_A); + $graph_json = json_decode((string)$parent['graph_json'], true); + $graph_serialized = maybe_unserialize((string)$parent['graph_serialized']); + $child_payload = is_array($child) ? json_decode((string)($child['payload'] ?? ''), true) : []; + $postmeta_json = []; + $page_id = is_array($graph_json) ? (int)($graph_json['page_id'] ?? 0) : 0; + if ($page_id > 0) { + $postmeta_json = json_decode((string)get_post_meta($page_id, '_forkpress_semantic_plugin_graph', true), true); + } + $option_graph = get_option("forkpress_semantic_plugin_{$parent['branch']}_option"); + $file_path = is_array($child) ? (string)($child['file_path'] ?? '') : ''; + $plugin_graphs[(string)$parent['branch']] = [ + 'parent_id' => $parent_id, + 'child_id' => is_array($child) ? (int)$child['id'] : 0, + 'child_parent_id' => is_array($child) ? (int)$child['parent_id'] : 0, + 'label' => (string)$parent['label'], + 'json_parent_id' => is_array($graph_json) ? (int)($graph_json['parent_id'] ?? 0) : 0, + 'json_child_id' => is_array($graph_json) ? (int)($graph_json['child_id'] ?? 0) : 0, + 'json_note_id' => is_array($graph_json) ? (int)($graph_json['note_id'] ?? 0) : 0, + 'serialized_parent_id' => is_array($graph_serialized) ? (int)($graph_serialized['parent_id'] ?? 0) : 0, + 'serialized_note_id' => is_array($graph_serialized) ? (int)($graph_serialized['note_id'] ?? 0) : 0, + 'option_parent_id' => is_array($option_graph) ? (int)($option_graph['parent_id'] ?? 0) : 0, + 'option_note_id' => is_array($option_graph) ? (int)($option_graph['note_id'] ?? 0) : 0, + 'postmeta_parent_id' => is_array($postmeta_json) ? (int)($postmeta_json['parent_id'] ?? 0) : 0, + 'postmeta_note_id' => is_array($postmeta_json) ? (int)($postmeta_json['note_id'] ?? 0) : 0, + 'child_payload_note_id' => is_array($child_payload) ? (int)($child_payload['note_id'] ?? 0) : 0, + 'file_exists' => $file_path !== '' && file_exists(trailingslashit($upload_dir['basedir']) . $file_path), + ]; + } + ksort($plugin_graphs); + } + + wp_send_json([ + 'action' => $action, + 'posts' => $rows, + 'users' => $users, + 'comments' => $comments, + 'menus' => $menus, + 'menu_items' => $menu_items, + 'menu_locations' => $locations, + 'plugin_graphs' => $plugin_graphs, + 'source_option' => get_option('forkpress_semantic_source_option'), + 'target_option' => get_option('forkpress_semantic_target_option'), + 'source_json_option' => json_decode((string)get_option('forkpress_semantic_source_json_option'), true), + 'target_json_option' => json_decode((string)get_option('forkpress_semantic_target_json_option'), true), + ]); +}, 20); +PHP + autoinc_runtime_request main init "$TMP/autoinc-main-init.json" php -r '$data = json_decode(file_get_contents($argv[1]), true); exit(($data["max_id"] ?? null) === 1 ? 0 : 1);' "$TMP/autoinc-main-init.json" +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")" +UI_CREATE_HTTP="$( + curl -sS -o "$TMP/ui-create.json" -w '%{http_code}' \ + -b "$UI_CREATE_COOKIES" \ + -H "Host: wp.localhost:$PORT" \ + -H "Accept: application/json" \ + -H "X-ForkPress-Async: 1" \ + --data-urlencode "action=forkpress_branch_create" \ + --data-urlencode "_wpnonce=$UI_CREATE_NONCE" \ + --data-urlencode "branch=ui-created" \ + --data-urlencode "from=main" \ + "http://127.0.0.1:$PORT/wp-admin/admin-post.php" +)" +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" +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" +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" + +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")" +UI_MERGE_HTTP="$( + curl -sS -o "$TMP/ui-merge.json" -w '%{http_code}' \ + -b "$UI_MERGE_COOKIES" \ + -H "Host: wp.localhost:$PORT" \ + -H "Accept: application/json" \ + -H "X-ForkPress-Async: 1" \ + --data-urlencode "action=forkpress_branch_merge" \ + --data-urlencode "_wpnonce=$UI_MERGE_NONCE" \ + --data-urlencode "source=ui-created" \ + --data-urlencode "target=main" \ + "http://127.0.0.1:$PORT/wp-admin/admin-post.php" +)" +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" +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" \ + "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" + +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 \ + "$BIN" branch --work-dir "$WORK_DIR" create public-create-crash > "$TMP/public-create-crash.out" 2>&1; then + echo "public branch create unexpectedly survived after-branch-create-birth-metadata failpoint" >&2 + exit 1 +fi +test ! -e "$WORK/public-create-crash" +"$BIN" branch --work-dir "$WORK_DIR" create public-create-crash > "$TMP/public-create-crash-retry.out" +grep -F "public-create-crash.wp.localhost:$PORT" "$TMP/public-create-crash-retry.out" >/dev/null +test -d "$WORK/public-create-crash" +test -f "$WORK_DIR/cow/merge/bases/public-create-crash.sqlite" +test -f "$WORK_DIR/cow/merge/file-bases/public-create-crash.json" +php -r '$meta = new SQLite3($argv[1]); $count = (int)$meta->querySingle("SELECT COUNT(*) FROM merge_autoincrement_bands WHERE branch_name = '\''public-create-crash'\''"); exit($count > 0 ? 0 : 1);' "$WORK_DIR/cow/merge/metadata.sqlite" + log_step "create CLI branch" "$BIN" branch --work-dir "$WORK_DIR" create feature-cow > "$TMP/branch-create.out" grep -F "feature-cow.wp.localhost:$PORT" "$TMP/branch-create.out" >/dev/null test -d "$WORK/feature-cow" echo "feature only" > "$WORK/feature-cow/wp-content/forkpress-branch.txt" test ! -e "$WORK/main/wp-content/forkpress-branch.txt" +test -f "$WORK_DIR/cow/merge/bases/feature-cow.sqlite" +test -f "$WORK_DIR/cow/merge/file-bases/feature-cow.json" +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/feature-cow.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/forkpress-branch.txt"]) ? 0 : 1);' "$WORK_DIR/cow/merge/file-bases/feature-cow.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 = '\''feature-cow'\'' 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/feature-cow/wp-content/database/.ht.sqlite" autoinc_runtime_request feature-cow insert "$TMP/autoinc-feature-insert.json" php -r '$data = json_decode(file_get_contents($argv[1]), true); $meta = new SQLite3($argv[2]); $branch = new SQLite3($argv[3]); $max = (int)($data["max_id"] ?? 0); $band = $meta->querySingle("SELECT band_start, band_end FROM merge_autoincrement_bands WHERE branch_name = '\''feature-cow'\'' 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 && $max >= (int)$band["band_start"] && $max <= (int)$band["band_end"] && $seq === $max ? 0 : 1);' "$TMP/autoinc-feature-insert.json" "$WORK_DIR/cow/merge/metadata.sqlite" "$WORK/feature-cow/wp-content/database/.ht.sqlite" @@ -431,6 +1182,52 @@ curl -sS -H "Host: git-created.wp.localhost:$PORT" \ -o "$TMP/git-created.html" grep -F "Branch: git-created" "$TMP/git-created.html" >/dev/null grep -F "Branch not found" "$TMP/git-created.html" && exit 1 +test -f "$WORK_DIR/cow/merge/bases/git-created.sqlite" +test -f "$WORK_DIR/cow/merge/file-bases/git-created.json" +"$BIN" branch --work-dir "$WORK_DIR" merge git-created --into main > "$TMP/git-created-merge.out" +grep -F "forkpress: merged git-created into main" "$TMP/git-created-merge.out" >/dev/null +grep -F "status: completed" "$TMP/git-created-merge.out" >/dev/null +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" checkout -B git-created-http-crash origin/main +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 \ + "$BIN" serve --work-dir "$WORK_DIR" --port "$PORT" --root-host wp.localhost --workers 1 +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 +fi +for _ in $(seq 1 40); do + if ! "$BIN" server list | grep -F "$WORK_DIR" >/dev/null; then + break + fi + sleep 0.25 +done +"$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 +curl -sS -H "Host: git-created-http-crash.wp.localhost:$PORT" \ + "http://127.0.0.1:$PORT/" \ + -o "$TMP/git-created-http-crash-after-restart.html" +grep -F "Branch: git-created-http-crash" "$TMP/git-created-http-crash-after-restart.html" >/dev/null +grep -F "Branch not found" "$TMP/git-created-http-crash-after-restart.html" && exit 1 +test -f "$WORK/git-created-http-crash/wp-content/git-created-http-crash.txt" +grep -F "created through crashed git push" "$WORK/git-created-http-crash/wp-content/git-created-http-crash.txt" >/dev/null +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)" +"$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 +test -f "$WORK/main/wp-content/git-created-http-crash.txt" +grep -F "created through crashed git push" "$WORK/main/wp-content/git-created-http-crash.txt" >/dev/null log_step "reject multi-branch Git delete without mutation" if git -C "$TMP/checkout" push origin --delete git-created feature-cow > "$TMP/git-multi-delete.out" 2>&1; then @@ -520,6 +1317,32 @@ if "$BIN" branch --work-dir "$WORK_DIR" reset main --from reset-source > "$TMP/r fi grep -F "refusing to reset main without --force" "$TMP/reset-main.out" >/dev/null +log_step "public branch reset crash retry" +"$BIN" branch --work-dir "$WORK_DIR" create public-reset-crash-source +"$BIN" branch --work-dir "$WORK_DIR" create public-reset-crash-target +echo "public reset crash source" > "$WORK/public-reset-crash-source/wp-content/public-reset-crash-source.txt" +echo "public reset crash target" > "$WORK/public-reset-crash-target/wp-content/public-reset-crash-target.txt" +if FORKPRESS_COW_STORAGE_TEST_FAILPOINT=after-branch-reset-publish FORKPRESS_COW_STORAGE_TEST_FAILPOINT_ACTION=exit \ + "$BIN" branch --work-dir "$WORK_DIR" reset public-reset-crash-target --from public-reset-crash-source > "$TMP/public-reset-crash.out" 2>&1; then + echo "public branch reset unexpectedly survived after-branch-reset-publish failpoint" >&2 + exit 1 +fi +test -f "$WORK/public-reset-crash-target/wp-content/public-reset-crash-source.txt" +test -f "$WORK_DIR/cow/reset-pending/public-reset-crash-target.txt" +if "$BIN" branch --work-dir "$WORK_DIR" merge public-reset-crash-target --into main > "$TMP/public-reset-crash-merge-blocked.out" 2>&1; then + echo "public branch merge unexpectedly accepted a reset-pending branch" >&2 + exit 1 +fi +grep -F "unfinished reset" "$TMP/public-reset-crash-merge-blocked.out" >/dev/null +"$BIN" branch --work-dir "$WORK_DIR" reset public-reset-crash-target --from public-reset-crash-source > "$TMP/public-reset-crash-retry.out" +grep -F "reset COW branch 'public-reset-crash-target' from 'public-reset-crash-source'" "$TMP/public-reset-crash-retry.out" >/dev/null +test ! -e "$WORK_DIR/cow/reset-pending/public-reset-crash-target.txt" +test -f "$WORK/public-reset-crash-target/wp-content/public-reset-crash-source.txt" +test ! -e "$WORK/public-reset-crash-target/wp-content/public-reset-crash-target.txt" +test -f "$WORK_DIR/cow/merge/bases/public-reset-crash-target.sqlite" +test -f "$WORK_DIR/cow/merge/file-bases/public-reset-crash-target.json" +php -r '$meta = new SQLite3($argv[1]); $count = (int)$meta->querySingle("SELECT COUNT(*) FROM merge_autoincrement_bands WHERE branch_name = '\''public-reset-crash-target'\''"); exit($count > 0 ? 0 : 1);' "$WORK_DIR/cow/merge/metadata.sqlite" + log_step "merge independently banded WordPress posts" "$BIN" branch --work-dir "$WORK_DIR" create band-merge-source > "$TMP/band-merge-source-create.out" "$BIN" branch --work-dir "$WORK_DIR" create band-merge-target > "$TMP/band-merge-target-create.out" @@ -567,6 +1390,149 @@ fi "$BIN" branch --work-dir "$WORK_DIR" merge-audit --format json --review --review-status unreviewed --records decisions --scope db --limit 80 > "$TMP/band-merge-source-decision-queue.json" php -r '$data = json_decode(file_get_contents($argv[1]), true); $ok = is_array($data) && (($data["filters"]["review"] ?? false) === true) && (($data["filters"]["review_status"] ?? null) === "unreviewed") && (($data["filters"]["records"] ?? null) === "decisions") && (($data["filters"]["scope"] ?? null) === "db"); $has_source = false; foreach (($data["decisions"] ?? []) as $row) { if (($row["review_status"] ?? null) !== null) $ok = false; if ((int)($row["id"] ?? 0) === (int)$argv[2] && ($row["table_name"] ?? null) === "wp_posts" && ($row["decision"] ?? null) === "source-applied") $has_source = true; } exit($ok && $has_source ? 0 : 1);' "$TMP/band-merge-source-decision-queue.json" "$BAND_SOURCE_POST_DECISION_ID" +log_step "merge WordPress semantic object graphs" +semantic_runtime_request main seed "$TMP/semantic-seed.json" +"$BIN" branch --work-dir "$WORK_DIR" create semantic-source > "$TMP/semantic-source-create.out" +"$BIN" branch --work-dir "$WORK_DIR" create semantic-target > "$TMP/semantic-target-create.out" +semantic_runtime_request semantic-source source "$TMP/semantic-source.json" +semantic_runtime_request semantic-target target "$TMP/semantic-target.json" +php -r '$data = json_decode(file_get_contents($argv[1]), true); $posts = []; foreach (($data["posts"] ?? []) as $post) { $posts[$post["title"] ?? ""] = $post; } $menus = $data["menus"] ?? []; $locations = $data["menu_locations"] ?? []; $graph = $data["plugin_graphs"]["source"] ?? []; $note_id = $posts["Semantic Source Note"]["id"] ?? null; $graph_ok = (int)($graph["parent_id"] ?? 0) > 0 && (int)($graph["child_id"] ?? 0) > 0 && ($graph["child_parent_id"] ?? null) === ($graph["parent_id"] ?? null) && ($graph["json_parent_id"] ?? null) === ($graph["parent_id"] ?? null) && ($graph["serialized_parent_id"] ?? null) === ($graph["parent_id"] ?? null) && ($graph["option_parent_id"] ?? null) === ($graph["parent_id"] ?? null) && ($graph["postmeta_parent_id"] ?? null) === ($graph["parent_id"] ?? null) && ($graph["json_note_id"] ?? null) === $note_id && ($graph["serialized_note_id"] ?? null) === $note_id && ($graph["option_note_id"] ?? null) === $note_id && ($graph["postmeta_note_id"] ?? null) === $note_id && ($graph["child_payload_note_id"] ?? null) === $note_id && (($graph["file_exists"] ?? null) === true); $ok = isset($posts["Semantic Source Page"], $posts["Semantic Source Note"], $posts["Semantic Source Block"], $posts["Semantic Source Media"], $posts["Semantic Source Edited Page"]) && !isset($posts["Semantic Source Delete Page"]) && in_array("Semantic Source Menu", $menus, true) && (($locations["forkpress_semantic_source"] ?? null) === "Semantic Source Menu") && in_array("Semantic Source Topic", $posts["Semantic Source Page"]["terms"] ?? [], true) && (($posts["Semantic Source Page"]["term_parents"]["Semantic Source Topic"] ?? null) === "Semantic Source Parent Topic") && in_array("Semantic Source Topic", $posts["Semantic Source Note"]["terms"] ?? [], true) && (($posts["Semantic Source Media"]["file_exists"] ?? null) === true) && in_array("thumbnail", $posts["Semantic Source Media"]["metadata_sizes"] ?? [], true) && (($posts["Semantic Source Media"]["generated_files"]["thumbnail"] ?? null) === true) && (($data["source_option"]["branch"] ?? null) === "source") && $graph_ok; exit($ok ? 0 : 1);' "$TMP/semantic-source.json" +php -r '$data = json_decode(file_get_contents($argv[1]), true); $posts = []; foreach (($data["posts"] ?? []) as $post) { $posts[$post["title"] ?? ""] = $post; } $menus = $data["menus"] ?? []; $locations = $data["menu_locations"] ?? []; $graph = $data["plugin_graphs"]["target"] ?? []; $note_id = $posts["Semantic Target Note"]["id"] ?? null; $graph_ok = (int)($graph["parent_id"] ?? 0) > 0 && (int)($graph["child_id"] ?? 0) > 0 && ($graph["child_parent_id"] ?? null) === ($graph["parent_id"] ?? null) && ($graph["json_parent_id"] ?? null) === ($graph["parent_id"] ?? null) && ($graph["serialized_parent_id"] ?? null) === ($graph["parent_id"] ?? null) && ($graph["option_parent_id"] ?? null) === ($graph["parent_id"] ?? null) && ($graph["postmeta_parent_id"] ?? null) === ($graph["parent_id"] ?? null) && ($graph["json_note_id"] ?? null) === $note_id && ($graph["serialized_note_id"] ?? null) === $note_id && ($graph["option_note_id"] ?? null) === $note_id && ($graph["postmeta_note_id"] ?? null) === $note_id && ($graph["child_payload_note_id"] ?? null) === $note_id && (($graph["file_exists"] ?? null) === true); $ok = isset($posts["Semantic Target Page"], $posts["Semantic Target Note"], $posts["Semantic Target Block"], $posts["Semantic Target Media"], $posts["Semantic Target Edited Page"]) && !isset($posts["Semantic Target Delete Page"]) && in_array("Semantic Target Menu", $menus, true) && (($locations["forkpress_semantic_target"] ?? null) === "Semantic Target Menu") && in_array("Semantic Target Topic", $posts["Semantic Target Page"]["terms"] ?? [], true) && (($posts["Semantic Target Page"]["term_parents"]["Semantic Target Topic"] ?? null) === "Semantic Target Parent Topic") && in_array("Semantic Target Topic", $posts["Semantic Target Note"]["terms"] ?? [], true) && (($posts["Semantic Target Media"]["file_exists"] ?? null) === true) && in_array("thumbnail", $posts["Semantic Target Media"]["metadata_sizes"] ?? [], true) && (($posts["Semantic Target Media"]["generated_files"]["thumbnail"] ?? null) === true) && (($data["target_option"]["branch"] ?? null) === "target") && $graph_ok; exit($ok ? 0 : 1);' "$TMP/semantic-target.json" +"$BIN" branch --work-dir "$WORK_DIR" merge semantic-source --into semantic-target > "$TMP/semantic-merge.out" +grep -F "forkpress: merged semantic-source into semantic-target" "$TMP/semantic-merge.out" >/dev/null +grep -E "status: completed(_with_conflicts)?" "$TMP/semantic-merge.out" >/dev/null +semantic_runtime_request semantic-target inspect "$TMP/semantic-after-merge.json" +php -r ' +$data = json_decode(file_get_contents($argv[1]), true); +$posts = []; +foreach (($data["posts"] ?? []) as $post) { + $posts[$post["title"] ?? ""] = $post; +} +$menus = $data["menus"] ?? []; +$menu_items = $data["menu_items"] ?? []; +$locations = $data["menu_locations"] ?? []; +$users = $data["users"] ?? []; +$comments = $data["comments"] ?? []; +$required = [ + "Semantic Source Page" => "page", + "Semantic Target Page" => "page", + "Semantic Source Edited Page" => "page", + "Semantic Target Edited Page" => "page", + "Semantic Source Note" => "forkpress_note", + "Semantic Target Note" => "forkpress_note", + "Semantic Source Block" => "wp_block", + "Semantic Target Block" => "wp_block", + "Semantic Source Media" => "attachment", + "Semantic Target Media" => "attachment", +]; +$ok = true; +foreach ($required as $title => $type) { + $ok = $ok && (($posts[$title]["type"] ?? null) === $type); +} +$optionRefsValid = static function (array $option, string $branch, string $suffix) use ($posts): bool { + return (($option["branch"] ?? null) === $branch) + && ((int)($option["user_id"] ?? 0) > 0) + && ((int)($option["page_id"] ?? 0) === (int)($posts["Semantic $suffix Page"]["id"] ?? 0)) + && ((int)($option["note_id"] ?? 0) === (int)($posts["Semantic $suffix Note"]["id"] ?? 0)) + && ((int)($option["block_id"] ?? 0) === (int)($posts["Semantic $suffix Block"]["id"] ?? 0)) + && ((int)($option["attachment_id"] ?? 0) === (int)($posts["Semantic $suffix Media"]["id"] ?? 0)) + && ((int)($option["comment_id"] ?? 0) > 0) + && ((int)($option["reply_id"] ?? 0) > 0); +}; +$pluginGraphValid = static function (array $graphs, array $posts, string $branch, string $suffix): bool { + $graph = $graphs[$branch] ?? []; + $note_id = $posts["Semantic $suffix Note"]["id"] ?? null; + return (int)($graph["parent_id"] ?? 0) > 0 + && (int)($graph["child_id"] ?? 0) > 0 + && (($graph["child_parent_id"] ?? null) === ($graph["parent_id"] ?? null)) + && (($graph["json_parent_id"] ?? null) === ($graph["parent_id"] ?? null)) + && (($graph["serialized_parent_id"] ?? null) === ($graph["parent_id"] ?? null)) + && (($graph["option_parent_id"] ?? null) === ($graph["parent_id"] ?? null)) + && (($graph["postmeta_parent_id"] ?? null) === ($graph["parent_id"] ?? null)) + && (($graph["json_note_id"] ?? null) === $note_id) + && (($graph["serialized_note_id"] ?? null) === $note_id) + && (($graph["option_note_id"] ?? null) === $note_id) + && (($graph["postmeta_note_id"] ?? null) === $note_id) + && (($graph["child_payload_note_id"] ?? null) === $note_id) + && (($graph["file_exists"] ?? null) === true); +}; +$menuItemValid = static function (array $items, array $posts, string $suffix): bool { + $item = $items["Semantic $suffix Link"] ?? []; + return (($item["menu"] ?? null) === "Semantic $suffix Menu") + && (($item["type"] ?? null) === "post_type") + && (($item["object"] ?? null) === "page") + && ((int)($item["object_id"] ?? 0) === (int)($posts["Semantic $suffix Page"]["id"] ?? 0)); +}; +$userValid = static function (array $users, array $posts, array $comments, string $branch, string $suffix): bool { + $user = $users["forkpress_semantic_$branch"] ?? []; + $userId = (int)($user["id"] ?? 0); + return $userId > 0 + && (($user["display_name"] ?? null) === "Semantic $suffix Author") + && ((int)($user["graph_user_id"] ?? 0) === $userId) + && ((int)($user["serialized_graph_user_id"] ?? 0) === $userId) + && ((int)($posts["Semantic $suffix Page"]["author"] ?? 0) === $userId) + && ((int)($posts["Semantic $suffix Note"]["author"] ?? 0) === $userId) + && ((int)($posts["Semantic $suffix Media"]["author"] ?? 0) === $userId) + && ((int)($comments["Semantic $suffix Comment"]["user_id"] ?? 0) === $userId) + && ((int)($comments["Semantic $suffix Reply"]["user_id"] ?? 0) === $userId); +}; +$commentValid = static function (array $comments, array $posts, string $suffix): bool { + $comment = $comments["Semantic $suffix Comment"] ?? []; + $reply = $comments["Semantic $suffix Reply"] ?? []; + $pageId = (int)($posts["Semantic $suffix Page"]["id"] ?? 0); + $commentId = (int)($comment["id"] ?? 0); + $replyId = (int)($reply["id"] ?? 0); + return $commentId > 0 + && $replyId > 0 + && ((int)($comment["post_id"] ?? 0) === $pageId) + && ((int)($reply["post_id"] ?? 0) === $pageId) + && ((int)($reply["parent"] ?? 0) === $commentId) + && ((int)($comment["graph_comment_id"] ?? 0) === $commentId) + && ((int)($comment["graph_reply_id"] ?? 0) === $replyId) + && ((int)($reply["serialized_graph_comment_id"] ?? 0) === $commentId) + && ((int)($reply["serialized_graph_reply_id"] ?? 0) === $replyId); +}; +$reusableBlockValid = static function (array $posts, string $suffix): bool { + $blockId = (int)($posts["Semantic $suffix Block"]["id"] ?? 0); + $refs = array_map("intval", $posts["Semantic $suffix Page"]["block_refs"] ?? []); + return $blockId > 0 && in_array($blockId, $refs, true); +}; +$ok = $ok + && (($posts["Semantic Source Media"]["file_exists"] ?? null) === true) + && (($posts["Semantic Target Media"]["file_exists"] ?? null) === true) + && in_array("thumbnail", $posts["Semantic Source Media"]["metadata_sizes"] ?? [], true) + && in_array("thumbnail", $posts["Semantic Target Media"]["metadata_sizes"] ?? [], true) + && (($posts["Semantic Source Media"]["generated_files"]["thumbnail"] ?? null) === true) + && (($posts["Semantic Target Media"]["generated_files"]["thumbnail"] ?? null) === true) + && !isset($posts["Semantic Source Delete Page"]) + && !isset($posts["Semantic Target Delete Page"]) + && in_array("Semantic Source Topic", $posts["Semantic Source Page"]["terms"] ?? [], true) + && in_array("Semantic Target Topic", $posts["Semantic Target Page"]["terms"] ?? [], true) + && (($posts["Semantic Source Page"]["term_parents"]["Semantic Source Topic"] ?? null) === "Semantic Source Parent Topic") + && (($posts["Semantic Target Page"]["term_parents"]["Semantic Target Topic"] ?? null) === "Semantic Target Parent Topic") + && in_array("Semantic Source Topic", $posts["Semantic Source Note"]["terms"] ?? [], true) + && in_array("Semantic Target Topic", $posts["Semantic Target Note"]["terms"] ?? [], true) + && in_array("Semantic Source Menu", $menus, true) + && in_array("Semantic Target Menu", $menus, true) + && (($locations["forkpress_semantic_source"] ?? null) === "Semantic Source Menu") + && (($locations["forkpress_semantic_target"] ?? null) === "Semantic Target Menu") + && $menuItemValid($menu_items, $posts, "Source") + && $menuItemValid($menu_items, $posts, "Target") + && $userValid($users, $posts, $comments, "source", "Source") + && $userValid($users, $posts, $comments, "target", "Target") + && $commentValid($comments, $posts, "Source") + && $commentValid($comments, $posts, "Target") + && $reusableBlockValid($posts, "Source") + && $reusableBlockValid($posts, "Target") + && $optionRefsValid($data["source_option"] ?? [], "source", "Source") + && $optionRefsValid($data["target_option"] ?? [], "target", "Target") + && $optionRefsValid($data["source_json_option"] ?? [], "source", "Source") + && $optionRefsValid($data["target_json_option"] ?? [], "target", "Target") + && $pluginGraphValid($data["plugin_graphs"] ?? [], $posts, "source", "Source") + && $pluginGraphValid($data["plugin_graphs"] ?? [], $posts, "target", "Target"); +exit($ok ? 0 : 1); +' "$TMP/semantic-after-merge.json" + log_step "merge branch into main" php -r '$db = new SQLite3($argv[1]); $db->exec("CREATE TABLE IF NOT EXISTS forkpress_e2e_target_kept (id INTEGER PRIMARY KEY, label TEXT NOT NULL)");' "$WORK/main/wp-content/database/.ht.sqlite" "$BIN" branch --work-dir "$WORK_DIR" create merge-source @@ -575,6 +1541,14 @@ create_branch_post merge-source "$MERGE_TITLE" php -r '$db = new SQLite3($argv[1]); $db->exec("INSERT INTO forkpress_e2e_target_kept (id, label) VALUES (1, '\''target-only row'\'')");' "$WORK/main/wp-content/database/.ht.sqlite" echo "merged through branch merge" > "$WORK/merge-source/wp-content/merge-source-file.txt" echo "kept on target through branch merge" > "$WORK/main/wp-content/main-target-file.txt" +mkdir -p "$WORK_DIR/cow/reset-pending" +printf 'branch=merge-source\nfrom=main\n' > "$WORK_DIR/cow/reset-pending/merge-source.txt" +if "$BIN" branch --work-dir "$WORK_DIR" merge merge-source --into main > "$TMP/merge-pending-reset.out" 2>&1; then + echo "branch merge unexpectedly accepted a source branch with pending reset metadata" >&2 + exit 1 +fi +grep -F "unfinished reset" "$TMP/merge-pending-reset.out" >/dev/null +rm -f "$WORK_DIR/cow/reset-pending/merge-source.txt" "$BIN" branch --work-dir "$WORK_DIR" merge merge-source --into main > "$TMP/merge.out" grep -F "forkpress: merged merge-source into main" "$TMP/merge.out" >/dev/null grep -F "status: completed" "$TMP/merge.out" >/dev/null @@ -594,6 +1568,143 @@ grep -F "forkpress: COW merge audit" "$TMP/merge-audit.out" >/dev/null grep -F "merge-source -> main" "$TMP/merge-audit.out" >/dev/null "$BIN" branch --work-dir "$WORK_DIR" merge-audit --format json --limit 3 > "$TMP/merge-audit.json" php -r '$data = json_decode(file_get_contents($argv[1]), true); exit(is_array($data) && !empty($data["runs"]) ? 0 : 1);' "$TMP/merge-audit.json" + +log_step "public branch merge crash recovery" +"$BIN" branch --work-dir "$WORK_DIR" create public-crash-merge +PUBLIC_CRASH_TITLE="Public crash merge $(date +%s)" +create_branch_post public-crash-merge "$PUBLIC_CRASH_TITLE" +if FORKPRESS_COW_MERGE_TEST_FAILPOINT=before-target-db-commit FORKPRESS_COW_MERGE_TEST_FAILPOINT_ACTION=kill \ + "$BIN" branch --work-dir "$WORK_DIR" merge public-crash-merge --into main > "$TMP/public-crash-merge.out" 2>&1; then + echo "public branch merge unexpectedly survived before-target-db-commit kill failpoint" >&2 + exit 1 +fi +"$BIN" branch --work-dir "$WORK_DIR" recover-crash --format json > "$TMP/public-crash-recover.json" +php -r '$data = json_decode(file_get_contents($argv[1]), true); exit(is_array($data) && (int)($data["pending"] ?? 0) >= 1 ? 0 : 1);' "$TMP/public-crash-recover.json" +if "$BIN" branch --work-dir "$WORK_DIR" merge public-crash-merge --into main > "$TMP/public-crash-blocked.out" 2>&1; then + echo "public branch merge unexpectedly ignored pending crash recovery artifact" >&2 + exit 1 +fi +grep -F "pending COW merge crash recovery artifact" "$TMP/public-crash-blocked.out" >/dev/null +"$BIN" branch --work-dir "$WORK_DIR" recover-crash --restore-target-db --format json > "$TMP/public-crash-restore.json" +php -r '$data = json_decode(file_get_contents($argv[1]), true); exit(is_array($data) && (int)($data["pending"] ?? 0) === 0 && (int)($data["restored"] ?? 0) >= 1 ? 0 : 1);' "$TMP/public-crash-restore.json" +"$BIN" branch --work-dir "$WORK_DIR" merge public-crash-merge --into main > "$TMP/public-crash-retry.out" +grep -F "forkpress: merged public-crash-merge into main" "$TMP/public-crash-retry.out" >/dev/null +grep -F "status: completed" "$TMP/public-crash-retry.out" >/dev/null +curl -sS -H "Host: wp.localhost:$PORT" \ + "http://127.0.0.1:$PORT/wp-admin/edit.php" \ + -o "$TMP/public-crash-main-edit.html" +grep -F "$PUBLIC_CRASH_TITLE" "$TMP/public-crash-main-edit.html" >/dev/null + +log_step "public branch merge metadata crash recovery" +"$BIN" branch --work-dir "$WORK_DIR" create public-metadata-crash-merge +PUBLIC_METADATA_CRASH_TITLE="Public metadata crash merge $(date +%s)" +create_branch_post public-metadata-crash-merge "$PUBLIC_METADATA_CRASH_TITLE" +if FORKPRESS_COW_MERGE_TEST_FAILPOINT=before-metadata-commit FORKPRESS_COW_MERGE_TEST_FAILPOINT_ACTION=kill \ + "$BIN" branch --work-dir "$WORK_DIR" merge public-metadata-crash-merge --into main > "$TMP/public-metadata-crash-merge.out" 2>&1; then + echo "public branch merge unexpectedly survived before-metadata-commit kill failpoint" >&2 + exit 1 +fi +"$BIN" branch --work-dir "$WORK_DIR" recover-crash --format json > "$TMP/public-metadata-crash-recover.json" +php -r '$data = json_decode(file_get_contents($argv[1]), true); exit(is_array($data) && (int)($data["pending"] ?? 0) >= 1 ? 0 : 1);' "$TMP/public-metadata-crash-recover.json" +if "$BIN" branch --work-dir "$WORK_DIR" merge public-metadata-crash-merge --into main > "$TMP/public-metadata-crash-blocked.out" 2>&1; then + echo "public branch merge unexpectedly ignored pending metadata crash recovery artifact" >&2 + exit 1 +fi +grep -F "pending COW merge crash recovery artifact" "$TMP/public-metadata-crash-blocked.out" >/dev/null +"$BIN" branch --work-dir "$WORK_DIR" recover-crash --restore-target-db --format json > "$TMP/public-metadata-crash-restore.json" +php -r '$data = json_decode(file_get_contents($argv[1]), true); exit(is_array($data) && (int)($data["pending"] ?? 0) === 0 && (int)($data["restored"] ?? 0) >= 1 ? 0 : 1);' "$TMP/public-metadata-crash-restore.json" +"$BIN" branch --work-dir "$WORK_DIR" merge public-metadata-crash-merge --into main > "$TMP/public-metadata-crash-retry.out" +grep -F "forkpress: merged public-metadata-crash-merge into main" "$TMP/public-metadata-crash-retry.out" >/dev/null +grep -F "status: completed" "$TMP/public-metadata-crash-retry.out" >/dev/null +curl -sS -H "Host: wp.localhost:$PORT" \ + "http://127.0.0.1:$PORT/wp-admin/edit.php" \ + -o "$TMP/public-metadata-crash-main-edit.html" +grep -F "$PUBLIC_METADATA_CRASH_TITLE" "$TMP/public-metadata-crash-main-edit.html" >/dev/null + +log_step "public branch merge before-file crash recovery" +"$BIN" branch --work-dir "$WORK_DIR" create public-before-file-crash-merge +PUBLIC_BEFORE_FILE_CRASH_TITLE="Public before-file crash merge $(date +%s)" +create_branch_post public-before-file-crash-merge "$PUBLIC_BEFORE_FILE_CRASH_TITLE" +echo "public before-file crash merge" > "$WORK/public-before-file-crash-merge/wp-content/public-before-file-crash.txt" +if FORKPRESS_COW_MERGE_TEST_FAILPOINT=before-file-op FORKPRESS_COW_MERGE_TEST_FAILPOINT_ACTION=kill \ + "$BIN" branch --work-dir "$WORK_DIR" merge public-before-file-crash-merge --into main > "$TMP/public-before-file-crash-merge.out" 2>&1; then + echo "public branch merge unexpectedly survived before-file-op kill failpoint" >&2 + exit 1 +fi +"$BIN" branch --work-dir "$WORK_DIR" recover-crash --format json > "$TMP/public-before-file-crash-recover.json" +php -r '$data = json_decode(file_get_contents($argv[1]), true); exit(is_array($data) && (int)($data["pending"] ?? 0) >= 1 ? 0 : 1);' "$TMP/public-before-file-crash-recover.json" +if "$BIN" branch --work-dir "$WORK_DIR" merge public-before-file-crash-merge --into main > "$TMP/public-before-file-crash-blocked.out" 2>&1; then + echo "public branch merge unexpectedly ignored pending before-file crash recovery artifact" >&2 + exit 1 +fi +grep -F "pending COW merge crash recovery artifact" "$TMP/public-before-file-crash-blocked.out" >/dev/null +"$BIN" branch --work-dir "$WORK_DIR" recover-crash --restore-target-db --restore-files --format json > "$TMP/public-before-file-crash-restore.json" +php -r '$data = json_decode(file_get_contents($argv[1]), true); exit(is_array($data) && (int)($data["pending"] ?? 0) === 0 && (int)($data["restored"] ?? 0) >= 1 ? 0 : 1);' "$TMP/public-before-file-crash-restore.json" +"$BIN" branch --work-dir "$WORK_DIR" merge public-before-file-crash-merge --into main > "$TMP/public-before-file-crash-retry.out" +grep -F "forkpress: merged public-before-file-crash-merge into main" "$TMP/public-before-file-crash-retry.out" >/dev/null +grep -F "status: completed" "$TMP/public-before-file-crash-retry.out" >/dev/null +test -f "$WORK/main/wp-content/public-before-file-crash.txt" +grep -F "public before-file crash merge" "$WORK/main/wp-content/public-before-file-crash.txt" >/dev/null +curl -sS -H "Host: wp.localhost:$PORT" \ + "http://127.0.0.1:$PORT/wp-admin/edit.php" \ + -o "$TMP/public-before-file-crash-main-edit.html" +grep -F "$PUBLIC_BEFORE_FILE_CRASH_TITLE" "$TMP/public-before-file-crash-main-edit.html" >/dev/null + +log_step "public crash recovery cleanup interruption" +"$BIN" branch --work-dir "$WORK_DIR" create public-recovery-cleanup-crash +PUBLIC_RECOVERY_CLEANUP_CRASH_TITLE="Public recovery cleanup crash $(date +%s)" +create_branch_post public-recovery-cleanup-crash "$PUBLIC_RECOVERY_CLEANUP_CRASH_TITLE" +if FORKPRESS_COW_MERGE_TEST_FAILPOINT=before-target-db-commit FORKPRESS_COW_MERGE_TEST_FAILPOINT_ACTION=kill \ + "$BIN" branch --work-dir "$WORK_DIR" merge public-recovery-cleanup-crash --into main > "$TMP/public-recovery-cleanup-crash-merge.out" 2>&1; then + echo "public branch merge unexpectedly survived recovery-cleanup fixture kill failpoint" >&2 + exit 1 +fi +"$BIN" branch --work-dir "$WORK_DIR" recover-crash --format json > "$TMP/public-recovery-cleanup-crash-recover.json" +php -r '$data = json_decode(file_get_contents($argv[1]), true); exit(is_array($data) && (int)($data["pending"] ?? 0) >= 1 ? 0 : 1);' "$TMP/public-recovery-cleanup-crash-recover.json" +if FORKPRESS_COW_MERGE_TEST_FAILPOINT=after-crash-recovery-restore FORKPRESS_COW_MERGE_TEST_FAILPOINT_ACTION=exit \ + "$BIN" branch --work-dir "$WORK_DIR" recover-crash --restore-target-db --format json > "$TMP/public-recovery-cleanup-crash-restore.out" 2>&1; then + echo "public crash recovery unexpectedly survived after-crash-recovery-restore failpoint" >&2 + exit 1 +fi +"$BIN" branch --work-dir "$WORK_DIR" recover-crash --restore-target-db --format json > "$TMP/public-recovery-cleanup-crash-retry-restore.json" +php -r '$data = json_decode(file_get_contents($argv[1]), true); exit(is_array($data) && (int)($data["pending"] ?? 0) === 0 && (int)($data["restored"] ?? 0) >= 1 ? 0 : 1);' "$TMP/public-recovery-cleanup-crash-retry-restore.json" +"$BIN" branch --work-dir "$WORK_DIR" merge public-recovery-cleanup-crash --into main > "$TMP/public-recovery-cleanup-crash-retry.out" +grep -F "forkpress: merged public-recovery-cleanup-crash into main" "$TMP/public-recovery-cleanup-crash-retry.out" >/dev/null +grep -F "status: completed" "$TMP/public-recovery-cleanup-crash-retry.out" >/dev/null +curl -sS -H "Host: wp.localhost:$PORT" \ + "http://127.0.0.1:$PORT/wp-admin/edit.php" \ + -o "$TMP/public-recovery-cleanup-crash-main-edit.html" +grep -F "$PUBLIC_RECOVERY_CLEANUP_CRASH_TITLE" "$TMP/public-recovery-cleanup-crash-main-edit.html" >/dev/null + +log_step "public branch merge filesystem crash recovery" +"$BIN" branch --work-dir "$WORK_DIR" create public-file-crash-merge +PUBLIC_FILE_CRASH_TITLE="Public file crash merge $(date +%s)" +create_branch_post public-file-crash-merge "$PUBLIC_FILE_CRASH_TITLE" +echo "public file crash merge" > "$WORK/public-file-crash-merge/wp-content/public-file-crash.txt" +if FORKPRESS_COW_MERGE_TEST_FAILPOINT=after-file-op FORKPRESS_COW_MERGE_TEST_FAILPOINT_ACTION=kill \ + "$BIN" branch --work-dir "$WORK_DIR" merge public-file-crash-merge --into main > "$TMP/public-file-crash-merge.out" 2>&1; then + echo "public branch merge unexpectedly survived after-file-op kill failpoint" >&2 + exit 1 +fi +"$BIN" branch --work-dir "$WORK_DIR" recover-crash --format json > "$TMP/public-file-crash-recover.json" +php -r '$data = json_decode(file_get_contents($argv[1]), true); exit(is_array($data) && (int)($data["pending"] ?? 0) >= 1 ? 0 : 1);' "$TMP/public-file-crash-recover.json" +if "$BIN" branch --work-dir "$WORK_DIR" merge public-file-crash-merge --into main > "$TMP/public-file-crash-blocked.out" 2>&1; then + echo "public branch merge unexpectedly ignored pending filesystem crash recovery artifact" >&2 + exit 1 +fi +grep -F "pending COW merge crash recovery artifact" "$TMP/public-file-crash-blocked.out" >/dev/null +"$BIN" branch --work-dir "$WORK_DIR" recover-crash --restore-target-db --restore-files --format json > "$TMP/public-file-crash-restore.json" +php -r '$data = json_decode(file_get_contents($argv[1]), true); exit(is_array($data) && (int)($data["pending"] ?? 0) === 0 && (int)($data["restored"] ?? 0) >= 1 ? 0 : 1);' "$TMP/public-file-crash-restore.json" +"$BIN" branch --work-dir "$WORK_DIR" merge public-file-crash-merge --into main > "$TMP/public-file-crash-retry.out" +grep -F "forkpress: merged public-file-crash-merge into main" "$TMP/public-file-crash-retry.out" >/dev/null +grep -F "status: completed" "$TMP/public-file-crash-retry.out" >/dev/null +test -f "$WORK/main/wp-content/public-file-crash.txt" +grep -F "public file crash merge" "$WORK/main/wp-content/public-file-crash.txt" >/dev/null +curl -sS -H "Host: wp.localhost:$PORT" \ + "http://127.0.0.1:$PORT/wp-admin/edit.php" \ + -o "$TMP/public-file-crash-main-edit.html" +grep -F "$PUBLIC_FILE_CRASH_TITLE" "$TMP/public-file-crash-main-edit.html" >/dev/null + ROLLBACK_FAILURE_ARTIFACT="$WORK_DIR/cow/merge/e2e-rollback-failures.jsonl" printf '%s\n' '{"source_branch":"feature-e2e-rollback","rollback_failure":"forced runtime rollback failure"}' > "$ROLLBACK_FAILURE_ARTIFACT" ROLLBACK_FAILURE_RUN_ID="$( diff --git a/tests/cow/git_server.php b/tests/cow/git_server.php index 3f212ef2..9a3bf7ee 100644 --- a/tests/cow/git_server.php +++ b/tests/cow/git_server.php @@ -23,6 +23,34 @@ function consume_git_response($git_response): string { } return $out; } +function run_php_code_env(string $code, array $env): array { + $base_env = getenv(); + if (!is_array($base_env)) { + $base_env = []; + } + $pipes = []; + $process = proc_open( + [PHP_BINARY, '-r', $code], + [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + null, + array_merge($base_env, $env) + ); + if (!is_resource($process)) { + throw new RuntimeException('failed to start PHP subprocess'); + } + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + return [ + 'status' => proc_close($process), + 'output' => (is_string($stdout) ? $stdout : '') . (is_string($stderr) ? $stderr : ''), + ]; +} require_once __DIR__ . '/../../scripts/cow/git_server.php'; @@ -226,6 +254,41 @@ function consume_git_response($git_response): string { assert_true($gc['deleted'] >= 1, 'COW Git GC deletes unreachable loose object'); assert_true(!file_exists($orphan_path), 'COW Git GC removes unreachable loose object file'); assert_true(is_file($git . '/' . $repo->get_storage_path($repo->get_branch_tip('refs/heads/main'))), 'COW Git GC keeps reachable branch tip'); + +$crash_orphan_a = $repo->add_object('blob', "crash orphan a\n"); +$crash_orphan_b = $repo->add_object('blob', "crash orphan b\n"); +$crash_orphan_paths = [ + $git . '/' . $repo->get_storage_path($crash_orphan_a), + $git . '/' . $repo->get_storage_path($crash_orphan_b), +]; +foreach ($crash_orphan_paths as $path) { + assert_true(is_file($path), 'test setup creates unreachable prune-crash object'); +} +$reachable_tip_path = $git . '/' . $repo->get_storage_path($repo->get_branch_tip('refs/heads/main')); +$prune_crash = run_php_code_env(<<<'PHP' +require_once getenv('FORKPRESS_COW_GIT_SERVER_HELPER'); + +$git = getenv('FORKPRESS_COW_GIT_REPO'); +$fs = WordPress\Filesystem\LocalFilesystem::create($git); +$repo = new WordPress\Git\GitRepository($fs, ['default_branch' => 'main']); +cow_git_prune_unreachable_objects($repo, $git); +PHP, [ + 'FORKPRESS_COW_GIT_SERVER_HELPER' => realpath(__DIR__ . '/../../scripts/cow/git_server.php'), + 'FORKPRESS_COW_GIT_REPO' => $git, + 'FORKPRESS_COW_GIT_TEST_FAILPOINT' => 'after-git-object-prune', + 'FORKPRESS_COW_GIT_TEST_FAILPOINT_ACTION' => 'exit', +]); +assert_true($prune_crash['status'] !== 0, 'Git object prune crash terminates the prune subprocess'); +$remaining_crash_orphans = array_values(array_filter($crash_orphan_paths, static fn($path) => is_file($path))); +assert_same(count($remaining_crash_orphans), 1, 'Git object prune crash deletes only one unreachable object before exit'); +assert_true(is_file($reachable_tip_path), 'Git object prune crash preserves reachable branch tip'); +$gc_after_crash = cow_git_prune_unreachable_objects($repo, $git); +assert_true($gc_after_crash['deleted'] >= 1, 'next Git object prune removes remaining unreachable object after crash'); +foreach ($crash_orphan_paths as $path) { + assert_true(!file_exists($path), 'next Git object prune removes prune-crash orphan'); +} +assert_true(is_file($reachable_tip_path), 'next Git object prune still preserves reachable branch tip after crash'); + $orphan_blob = $repo->add_object('blob', "orphan after corruption\n"); $orphan_path = $git . '/' . $repo->get_storage_path($orphan_blob); $main_tip_path = $git . '/' . $repo->get_storage_path($repo->get_branch_tip('refs/heads/main')); @@ -269,6 +332,55 @@ function consume_git_response($git_response): string { assert_true(!file_exists($feature_tip_path), 'Git branch deletion prunes unreachable COW Git commit object'); cow_git_remove_tree($tmp); +$tmp = sys_get_temp_dir() . '/forkpress-cow-git-delete-crash-' . getmypid() . '-' . bin2hex(random_bytes(4)); +$branches = $tmp . '/branches'; +$git = $tmp . '/git'; +$branch_list = $tmp . '/branches.txt'; +mkdir($branches . '/main', 0777, true); +mkdir($branches . '/feature', 0777, true); +file_put_contents($branches . '/main/wp-load.php', " 'main']); +$repo->set_config_value(['user', 'name'], 'ForkPress COW'); +$repo->set_config_value(['user', 'email'], 'forkpress-cow@local'); +cow_git_sync_repository($repo, $branches); +cow_git_write_branch_list($branches, $branch_list); +$feature_tip = $repo->get_branch_tip('refs/heads/feature'); +$repo->delete_branch('refs/heads/feature'); +$delete_crash = run_php_code_env(<<<'PHP' +require_once getenv('FORKPRESS_COW_GIT_SERVER_HELPER'); + +$git = getenv('FORKPRESS_COW_GIT_REPO'); +$branches = getenv('FORKPRESS_COW_GIT_BRANCHES'); +$branch_list = getenv('FORKPRESS_COW_GIT_BRANCH_LIST'); +$feature_tip = getenv('FORKPRESS_COW_GIT_FEATURE_TIP'); +$fs = WordPress\Filesystem\LocalFilesystem::create($git); +$repo = new WordPress\Git\GitRepository($fs, ['default_branch' => 'main']); +$repo->set_config_value(['user', 'name'], 'ForkPress COW'); +$repo->set_config_value(['user', 'email'], 'forkpress-cow@local'); +cow_git_apply_push_to_branches($repo, $git, $branches, $branches, $branch_list, 'file-copy', '', ['feature' => $feature_tip]); +PHP, [ + 'FORKPRESS_COW_GIT_SERVER_HELPER' => realpath(__DIR__ . '/../../scripts/cow/git_server.php'), + 'FORKPRESS_COW_GIT_REPO' => $git, + 'FORKPRESS_COW_GIT_BRANCHES' => $branches, + 'FORKPRESS_COW_GIT_BRANCH_LIST' => $branch_list, + 'FORKPRESS_COW_GIT_FEATURE_TIP' => $feature_tip, + 'FORKPRESS_COW_GIT_TEST_FAILPOINT' => 'after-branch-delete-stage', + 'FORKPRESS_COW_GIT_TEST_FAILPOINT_ACTION' => 'exit', +]); +assert_true($delete_crash['status'] !== 0, 'Git branch delete crash terminates the push apply subprocess'); +assert_true(!is_dir($branches . '/feature'), 'Git branch delete crash leaves branch tree removed'); +assert_true(count(glob($branches . '/.forkpress-delete-public-feature-*') ?: []) >= 1, 'Git branch delete crash leaves staged delete backup'); +assert_true(str_contains((string)file_get_contents($branch_list), "feature\n"), 'Git branch delete crash can leave stale branch-list entry'); +cow_git_apply_push_to_branches($repo, $git, $branches, $branches, $branch_list, 'file-copy', '', ['feature' => $feature_tip]); +assert_true(!is_dir($branches . '/feature'), 'next Git apply keeps branch deleted after delete crash'); +assert_true(!str_contains((string)file_get_contents($branch_list), "feature\n"), 'next Git apply reconciles branch list after delete crash'); +assert_same(glob($branches . '/.forkpress-delete-*') ?: [], [], 'next Git apply cleans stale branch delete artifacts'); +cow_git_remove_tree($tmp); + $tmp = sys_get_temp_dir() . '/forkpress-cow-git-force-gc-' . getmypid() . '-' . bin2hex(random_bytes(4)); $branches = $tmp . '/branches'; $git = $tmp . '/git'; @@ -598,6 +710,612 @@ function consume_git_response($git_response): string { assert_same(trim((string)file_get_contents($branch_list)), 'main', 'Git-created branch ID-band allocation failure restores the branch list'); cow_git_remove_tree($tmp); +$tmp = sys_get_temp_dir() . '/forkpress-cow-git-created-branch-list-rollback-' . getmypid() . '-' . bin2hex(random_bytes(4)); +$branches = $tmp . '/branches'; +$git = $tmp . '/git'; +$branch_list = $tmp . '/branches.txt'; +mkdir($branches . '/main/wp-content/database', 0777, true); +file_put_contents($branches . '/main/wp-load.php', "exec('CREATE TABLE wp_posts (ID INTEGER PRIMARY KEY AUTOINCREMENT, post_title TEXT)'); +$db->exec("INSERT INTO wp_posts (post_title) VALUES ('Base post')"); +$db->close(); + +$fs = WordPress\Filesystem\LocalFilesystem::create($git); +$repo = new WordPress\Git\GitRepository($fs, ['default_branch' => 'main']); +$repo->set_config_value(['user', 'name'], 'ForkPress COW'); +$repo->set_config_value(['user', 'email'], 'forkpress-cow@local'); +cow_git_sync_repository($repo, $branches); +cow_git_write_branch_list($branches, $branch_list); +$main_tip = $repo->get_branch_tip('refs/heads/main'); +$repo->checkout('refs/heads/main'); +$created_tip = $repo->commit([ + 'commit' => [ + 'message' => 'create branch with branch-list publication failure', + 'author' => 'ForkPress Test ', + 'committer' => 'ForkPress Test ', + 'parents' => [$main_tip], + ], + 'updates' => ['wordpress/wp-content/git-created-list-fail.txt' => "created\n"], +]); +$repo->set_branch_tip('refs/heads/git-created-list-fail', $created_tip); +$failed = false; +$failure_message = ''; +putenv('FORKPRESS_COW_GIT_TEST_FAILPOINT=after-created-branch-list'); +putenv('FORKPRESS_COW_GIT_TEST_FAILPOINT_ACTION=throw'); +try { + cow_git_apply_push_to_branches($repo, $git, $branches, $branches, $branch_list, 'file-copy', '', ['main' => $main_tip]); +} catch (Throwable $e) { + $failed = true; + $failure_message = $e->getMessage(); +} finally { + putenv('FORKPRESS_COW_GIT_TEST_FAILPOINT'); + putenv('FORKPRESS_COW_GIT_TEST_FAILPOINT_ACTION'); +} +assert_true($failed, 'Git-created branch-list publication failure rejects push apply'); +assert_true(str_contains($failure_message, 'after-created-branch-list'), 'Git-created branch-list publication failure reports the failpoint'); +assert_true(!is_dir($branches . '/git-created-list-fail'), 'Git-created branch-list publication failure removes published branch storage'); +assert_true(!file_exists($tmp . '/merge/bases/git-created-list-fail.sqlite'), 'Git-created branch-list publication failure removes DB merge base artifacts'); +assert_true(!file_exists($tmp . '/merge/file-bases/git-created-list-fail.json'), 'Git-created branch-list publication failure removes filesystem merge base artifacts'); +assert_same(trim((string)file_get_contents($branch_list)), 'main', 'Git-created branch-list publication failure restores the branch list'); +$metadata = new SQLite3($tmp . '/merge/metadata.sqlite'); +$stale_band_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_autoincrement_bands WHERE branch_name = 'git-created-list-fail'"); +$stale_identity_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_row_identities WHERE branch_name = 'git-created-list-fail'"); +$metadata->close(); +assert_same($stale_band_count, 0, 'Git-created branch-list publication failure removes ID-band metadata'); +assert_same($stale_identity_count, 0, 'Git-created branch-list publication failure removes row identity metadata'); +$failed = false; +$failure_message = ''; +putenv('FORKPRESS_COW_GIT_TEST_FAILPOINT=after-created-branch-metadata'); +putenv('FORKPRESS_COW_GIT_TEST_FAILPOINT_ACTION=throw'); +try { + cow_git_apply_push_to_branches($repo, $git, $branches, $branches, $branch_list, 'file-copy', '', ['main' => $main_tip]); +} catch (Throwable $e) { + $failed = true; + $failure_message = $e->getMessage(); +} finally { + putenv('FORKPRESS_COW_GIT_TEST_FAILPOINT'); + putenv('FORKPRESS_COW_GIT_TEST_FAILPOINT_ACTION'); +} +assert_true($failed, 'Git-created branch metadata publication failure rejects push apply'); +assert_true(str_contains($failure_message, 'after-created-branch-metadata'), 'Git-created branch metadata publication failure reports the failpoint'); +assert_true(!is_dir($branches . '/git-created-list-fail'), 'Git-created branch metadata publication failure removes published branch storage'); +assert_true(!file_exists($tmp . '/merge/bases/git-created-list-fail.sqlite'), 'Git-created branch metadata publication failure removes DB merge base artifacts'); +assert_true(!file_exists($tmp . '/merge/file-bases/git-created-list-fail.json'), 'Git-created branch metadata publication failure removes filesystem merge base artifacts'); +assert_same(trim((string)file_get_contents($branch_list)), 'main', 'Git-created branch metadata publication failure restores the branch list'); +$metadata = new SQLite3($tmp . '/merge/metadata.sqlite'); +$stale_band_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_autoincrement_bands WHERE branch_name = 'git-created-list-fail'"); +$stale_identity_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_row_identities WHERE branch_name = 'git-created-list-fail'"); +$metadata->close(); +assert_same($stale_band_count, 0, 'Git-created branch metadata publication failure removes ID-band metadata'); +assert_same($stale_identity_count, 0, 'Git-created branch metadata publication failure removes row identity metadata'); +cow_git_remove_tree($tmp); + +$tmp = sys_get_temp_dir() . '/forkpress-cow-git-created-branch-list-crash-' . getmypid() . '-' . bin2hex(random_bytes(4)); +$branches = $tmp . '/branches'; +$git = $tmp . '/git'; +$branch_list = $tmp . '/branches.txt'; +mkdir($branches . '/main/wp-content/database', 0777, true); +file_put_contents($branches . '/main/wp-load.php', "exec('CREATE TABLE wp_posts (ID INTEGER PRIMARY KEY AUTOINCREMENT, post_title TEXT)'); +$db->exec("INSERT INTO wp_posts (post_title) VALUES ('Base post')"); +$db->exec('CREATE TABLE plugin_keyless (label TEXT, value TEXT)'); +$db->exec("INSERT INTO plugin_keyless (label, value) VALUES ('Base keyless', 'base')"); +$db->close(); + +$fs = WordPress\Filesystem\LocalFilesystem::create($git); +$repo = new WordPress\Git\GitRepository($fs, ['default_branch' => 'main']); +$repo->set_config_value(['user', 'name'], 'ForkPress COW'); +$repo->set_config_value(['user', 'email'], 'forkpress-cow@local'); +cow_git_sync_repository($repo, $branches); +cow_git_write_branch_list($branches, $branch_list); +$main_tip = $repo->get_branch_tip('refs/heads/main'); +$repo->checkout('refs/heads/main'); +$created_tip = $repo->commit([ + 'commit' => [ + 'message' => 'create branch with branch-list crash', + 'author' => 'ForkPress Test ', + 'committer' => 'ForkPress Test ', + 'parents' => [$main_tip], + ], + 'updates' => ['wordpress/wp-content/git-created-list-crash.txt' => "created\n"], +]); +$repo->set_branch_tip('refs/heads/git-created-list-crash', $created_tip); +$crash_result = run_php_code_env(<<<'PHP' +require_once getenv('FORKPRESS_COW_GIT_SERVER_HELPER'); + +$git = getenv('FORKPRESS_COW_GIT_REPO'); +$branches = getenv('FORKPRESS_COW_GIT_BRANCHES'); +$branch_list = getenv('FORKPRESS_COW_GIT_BRANCH_LIST'); +$main_tip = getenv('FORKPRESS_COW_GIT_MAIN_TIP'); +$fs = WordPress\Filesystem\LocalFilesystem::create($git); +$repo = new WordPress\Git\GitRepository($fs, ['default_branch' => 'main']); +$repo->set_config_value(['user', 'name'], 'ForkPress COW'); +$repo->set_config_value(['user', 'email'], 'forkpress-cow@local'); +cow_git_apply_push_to_branches($repo, $git, $branches, $branches, $branch_list, 'file-copy', '', ['main' => $main_tip]); +PHP, [ + 'FORKPRESS_COW_GIT_SERVER_HELPER' => realpath(__DIR__ . '/../../scripts/cow/git_server.php'), + 'FORKPRESS_COW_GIT_REPO' => $git, + 'FORKPRESS_COW_GIT_BRANCHES' => $branches, + 'FORKPRESS_COW_GIT_BRANCH_LIST' => $branch_list, + 'FORKPRESS_COW_GIT_MAIN_TIP' => $main_tip, + 'FORKPRESS_COW_GIT_TEST_FAILPOINT' => 'after-created-branch-list', + 'FORKPRESS_COW_GIT_TEST_FAILPOINT_ACTION' => 'exit', +]); +assert_true($crash_result['status'] !== 0, 'Git-created branch-list crash terminates the push apply subprocess'); +assert_true(is_dir($branches . '/git-created-list-crash'), 'Git-created branch-list crash leaves the created branch published'); +assert_same(file_get_contents($branches . '/git-created-list-crash/wp-content/git-created-list-crash.txt'), "created\n", 'Git-created branch-list crash leaves pushed WordPress files published'); +assert_true(file_exists($tmp . '/merge/bases/git-created-list-crash.sqlite'), 'Git-created branch-list crash leaves DB merge base artifacts'); +assert_true(file_exists($tmp . '/merge/file-bases/git-created-list-crash.json'), 'Git-created branch-list crash leaves filesystem merge base artifacts'); +$crashed_branch_list = (string)file_get_contents($branch_list); +assert_true(str_contains($crashed_branch_list, "main\n"), 'Git-created branch-list crash keeps main in the branch list'); +assert_true(str_contains($crashed_branch_list, "git-created-list-crash\n"), 'Git-created branch-list crash leaves the created branch in the branch list'); +$metadata = new SQLite3($tmp . '/merge/metadata.sqlite'); +$crash_band_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_autoincrement_bands WHERE branch_name = 'git-created-list-crash' AND table_name = 'wp_posts'"); +$crash_identity_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_row_identities WHERE branch_name = 'git-created-list-crash' AND table_name = 'plugin_keyless'"); +$metadata->close(); +assert_same($crash_band_count, 1, 'Git-created branch-list crash leaves ID-band metadata finalized before publication'); +assert_same($crash_identity_count, 1, 'Git-created branch-list crash leaves row identity metadata finalized before publication'); +cow_git_remove_tree($tmp); + +$tmp = sys_get_temp_dir() . '/forkpress-cow-git-created-before-metadata-crash-' . getmypid() . '-' . bin2hex(random_bytes(4)); +$branches = $tmp . '/branches'; +$git = $tmp . '/git'; +$branch_list = $tmp . '/branches.txt'; +mkdir($branches . '/main/wp-content/database', 0777, true); +file_put_contents($branches . '/main/wp-load.php', "exec('CREATE TABLE wp_posts (ID INTEGER PRIMARY KEY AUTOINCREMENT, post_title TEXT)'); +$db->exec("INSERT INTO wp_posts (post_title) VALUES ('Base post')"); +$db->exec('CREATE TABLE plugin_keyless (label TEXT, value TEXT)'); +$db->exec("INSERT INTO plugin_keyless (label, value) VALUES ('Base keyless', 'base')"); +$db->close(); + +$fs = WordPress\Filesystem\LocalFilesystem::create($git); +$repo = new WordPress\Git\GitRepository($fs, ['default_branch' => 'main']); +$repo->set_config_value(['user', 'name'], 'ForkPress COW'); +$repo->set_config_value(['user', 'email'], 'forkpress-cow@local'); +cow_git_sync_repository($repo, $branches); +cow_git_write_branch_list($branches, $branch_list); +$main_tip = $repo->get_branch_tip('refs/heads/main'); +$repo->checkout('refs/heads/main'); +$created_tip = $repo->commit([ + 'commit' => [ + 'message' => 'create branch with pre-metadata crash', + 'author' => 'ForkPress Test ', + 'committer' => 'ForkPress Test ', + 'parents' => [$main_tip], + ], + 'updates' => ['wordpress/wp-content/git-created-before-metadata-crash.txt' => "created\n"], +]); +$repo->set_branch_tip('refs/heads/git-created-before-metadata-crash', $created_tip); +$crash_result = run_php_code_env(<<<'PHP' +require_once getenv('FORKPRESS_COW_GIT_SERVER_HELPER'); + +$git = getenv('FORKPRESS_COW_GIT_REPO'); +$branches = getenv('FORKPRESS_COW_GIT_BRANCHES'); +$branch_list = getenv('FORKPRESS_COW_GIT_BRANCH_LIST'); +$main_tip = getenv('FORKPRESS_COW_GIT_MAIN_TIP'); +$fs = WordPress\Filesystem\LocalFilesystem::create($git); +$repo = new WordPress\Git\GitRepository($fs, ['default_branch' => 'main']); +$repo->set_config_value(['user', 'name'], 'ForkPress COW'); +$repo->set_config_value(['user', 'email'], 'forkpress-cow@local'); +cow_git_apply_push_to_branches($repo, $git, $branches, $branches, $branch_list, 'file-copy', '', ['main' => $main_tip]); +PHP, [ + 'FORKPRESS_COW_GIT_SERVER_HELPER' => realpath(__DIR__ . '/../../scripts/cow/git_server.php'), + 'FORKPRESS_COW_GIT_REPO' => $git, + 'FORKPRESS_COW_GIT_BRANCHES' => $branches, + 'FORKPRESS_COW_GIT_BRANCH_LIST' => $branch_list, + 'FORKPRESS_COW_GIT_MAIN_TIP' => $main_tip, + 'FORKPRESS_COW_GIT_TEST_FAILPOINT' => 'before-created-branch-metadata', + 'FORKPRESS_COW_GIT_TEST_FAILPOINT_ACTION' => 'exit', +]); +assert_true($crash_result['status'] !== 0, 'Git-created pre-metadata crash terminates the push apply subprocess'); +assert_true(!is_dir($branches . '/git-created-before-metadata-crash'), 'Git-created pre-metadata crash does not publish a branch without birth metadata'); +$stale_branch_list = (string)file_get_contents($branch_list); +assert_true(!str_contains($stale_branch_list, "git-created-before-metadata-crash\n"), 'Git-created pre-metadata crash does not publish the branch list entry'); +$metadata_path = $tmp . '/merge/metadata.sqlite'; +if (is_file($metadata_path)) { + $metadata = new SQLite3($metadata_path); + $pre_metadata_band_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_autoincrement_bands WHERE branch_name = 'git-created-before-metadata-crash'"); + $pre_metadata_identity_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_row_identities WHERE branch_name = 'git-created-before-metadata-crash'"); + $metadata->close(); +} else { + $pre_metadata_band_count = 0; + $pre_metadata_identity_count = 0; +} +assert_same($pre_metadata_band_count, 0, 'Git-created pre-metadata crash records no ID-band metadata for an unpublished branch'); +assert_same($pre_metadata_identity_count, 0, 'Git-created pre-metadata crash records no row identity metadata for an unpublished branch'); +cow_git_apply_push_to_branches($repo, $git, $branches, $branches, $branch_list, 'file-copy', '', ['main' => $main_tip]); +assert_true(is_dir($branches . '/git-created-before-metadata-crash'), 'next Git apply publishes branch after pre-metadata crash'); +assert_same(file_get_contents($branches . '/git-created-before-metadata-crash/wp-content/git-created-before-metadata-crash.txt'), "created\n", 'next Git apply preserves pushed WordPress files after pre-metadata crash'); +$reconciled_branch_list = (string)file_get_contents($branch_list); +assert_true(str_contains($reconciled_branch_list, "git-created-before-metadata-crash\n"), 'next Git apply publishes branch list after pre-metadata crash'); +$metadata = new SQLite3($metadata_path); +$reconciled_band_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_autoincrement_bands WHERE branch_name = 'git-created-before-metadata-crash' AND table_name = 'wp_posts'"); +$reconciled_identity_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_row_identities WHERE branch_name = 'git-created-before-metadata-crash' AND table_name = 'plugin_keyless'"); +$metadata->close(); +assert_same($reconciled_band_count, 1, 'next Git apply finalizes ID-band metadata after pre-metadata crash'); +assert_same($reconciled_identity_count, 1, 'next Git apply finalizes row identity metadata after pre-metadata crash'); +cow_git_remove_tree($tmp); + +$tmp = sys_get_temp_dir() . '/forkpress-cow-git-created-after-metadata-crash-' . getmypid() . '-' . bin2hex(random_bytes(4)); +$branches = $tmp . '/branches'; +$git = $tmp . '/git'; +$branch_list = $tmp . '/branches.txt'; +mkdir($branches . '/main/wp-content/database', 0777, true); +file_put_contents($branches . '/main/wp-load.php', "exec('CREATE TABLE wp_posts (ID INTEGER PRIMARY KEY AUTOINCREMENT, post_title TEXT)'); +$db->exec("INSERT INTO wp_posts (post_title) VALUES ('Base post')"); +$db->exec('CREATE TABLE plugin_keyless (label TEXT, value TEXT)'); +$db->exec("INSERT INTO plugin_keyless (label, value) VALUES ('Base keyless', 'base')"); +$db->close(); + +$fs = WordPress\Filesystem\LocalFilesystem::create($git); +$repo = new WordPress\Git\GitRepository($fs, ['default_branch' => 'main']); +$repo->set_config_value(['user', 'name'], 'ForkPress COW'); +$repo->set_config_value(['user', 'email'], 'forkpress-cow@local'); +cow_git_sync_repository($repo, $branches); +cow_git_write_branch_list($branches, $branch_list); +$main_tip = $repo->get_branch_tip('refs/heads/main'); +$repo->checkout('refs/heads/main'); +$created_tip = $repo->commit([ + 'commit' => [ + 'message' => 'create branch with post-metadata crash', + 'author' => 'ForkPress Test ', + 'committer' => 'ForkPress Test ', + 'parents' => [$main_tip], + ], + 'updates' => ['wordpress/wp-content/git-created-after-metadata-crash.txt' => "created\n"], +]); +$repo->set_branch_tip('refs/heads/git-created-after-metadata-crash', $created_tip); +$crash_result = run_php_code_env(<<<'PHP' +require_once getenv('FORKPRESS_COW_GIT_SERVER_HELPER'); + +$git = getenv('FORKPRESS_COW_GIT_REPO'); +$branches = getenv('FORKPRESS_COW_GIT_BRANCHES'); +$branch_list = getenv('FORKPRESS_COW_GIT_BRANCH_LIST'); +$main_tip = getenv('FORKPRESS_COW_GIT_MAIN_TIP'); +$fs = WordPress\Filesystem\LocalFilesystem::create($git); +$repo = new WordPress\Git\GitRepository($fs, ['default_branch' => 'main']); +$repo->set_config_value(['user', 'name'], 'ForkPress COW'); +$repo->set_config_value(['user', 'email'], 'forkpress-cow@local'); +cow_git_apply_push_to_branches($repo, $git, $branches, $branches, $branch_list, 'file-copy', '', ['main' => $main_tip]); +PHP, [ + 'FORKPRESS_COW_GIT_SERVER_HELPER' => realpath(__DIR__ . '/../../scripts/cow/git_server.php'), + 'FORKPRESS_COW_GIT_REPO' => $git, + 'FORKPRESS_COW_GIT_BRANCHES' => $branches, + 'FORKPRESS_COW_GIT_BRANCH_LIST' => $branch_list, + 'FORKPRESS_COW_GIT_MAIN_TIP' => $main_tip, + 'FORKPRESS_COW_GIT_TEST_FAILPOINT' => 'after-created-branch-metadata', + 'FORKPRESS_COW_GIT_TEST_FAILPOINT_ACTION' => 'exit', +]); +assert_true($crash_result['status'] !== 0, 'Git-created post-metadata crash terminates the push apply subprocess'); +assert_true(!is_dir($branches . '/git-created-after-metadata-crash'), 'Git-created post-metadata crash does not publish the branch tree'); +$metadata_path = $tmp . '/merge/metadata.sqlite'; +$metadata = new SQLite3($metadata_path); +$stale_band_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_autoincrement_bands WHERE branch_name = 'git-created-after-metadata-crash' AND table_name = 'wp_posts'"); +$stale_identity_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_row_identities WHERE branch_name = 'git-created-after-metadata-crash' AND table_name = 'plugin_keyless'"); +$metadata->close(); +assert_same($stale_band_count, 1, 'Git-created post-metadata crash can leave unpublished ID-band metadata'); +assert_same($stale_identity_count, 1, 'Git-created post-metadata crash can leave unpublished row identity metadata'); +cow_git_apply_push_to_branches($repo, $git, $branches, $branches, $branch_list, 'file-copy', '', ['main' => $main_tip]); +assert_true(is_dir($branches . '/git-created-after-metadata-crash'), 'next Git apply publishes branch after post-metadata crash'); +assert_same(file_get_contents($branches . '/git-created-after-metadata-crash/wp-content/git-created-after-metadata-crash.txt'), "created\n", 'next Git apply preserves pushed WordPress files after post-metadata crash'); +$reconciled_branch_list = (string)file_get_contents($branch_list); +assert_true(str_contains($reconciled_branch_list, "git-created-after-metadata-crash\n"), 'next Git apply publishes branch list after post-metadata crash'); +$metadata = new SQLite3($metadata_path); +$reconciled_band_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_autoincrement_bands WHERE branch_name = 'git-created-after-metadata-crash' AND table_name = 'wp_posts'"); +$reconciled_identity_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_row_identities WHERE branch_name = 'git-created-after-metadata-crash' AND table_name = 'plugin_keyless'"); +$stale_run_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_runs WHERE source_branch = 'git-created-after-metadata-crash' AND target_branch = 'git-created-after-metadata-crash' AND status = 'failed'"); +$metadata->close(); +assert_same($reconciled_band_count, 1, 'retry after post-metadata crash has one active ID-band row'); +assert_same($reconciled_identity_count, 1, 'retry after post-metadata crash has one active row identity'); +assert_same($stale_run_count, 0, 'retry after post-metadata crash removes stale unpublished birth runs'); +cow_git_remove_tree($tmp); + +$tmp = sys_get_temp_dir() . '/forkpress-cow-git-created-public-link-crash-' . getmypid() . '-' . bin2hex(random_bytes(4)); +$branches = $tmp . '/public-branches'; +$storage_branches = $tmp . '/storage-branches'; +$git = $tmp . '/git'; +$branch_list = $tmp . '/branches.txt'; +mkdir($storage_branches . '/main/wp-content/database', 0777, true); +mkdir($branches, 0777, true); +file_put_contents($storage_branches . '/main/wp-load.php', "exec('CREATE TABLE wp_posts (ID INTEGER PRIMARY KEY AUTOINCREMENT, post_title TEXT)'); +$db->exec("INSERT INTO wp_posts (post_title) VALUES ('Base post')"); +$db->exec('CREATE TABLE plugin_keyless (label TEXT, value TEXT)'); +$db->exec("INSERT INTO plugin_keyless (label, value) VALUES ('Base keyless', 'base')"); +$db->close(); +if (@symlink($storage_branches . '/main', $branches . '/main')) { + $fs = WordPress\Filesystem\LocalFilesystem::create($git); + $repo = new WordPress\Git\GitRepository($fs, ['default_branch' => 'main']); + $repo->set_config_value(['user', 'name'], 'ForkPress COW'); + $repo->set_config_value(['user', 'email'], 'forkpress-cow@local'); + cow_git_sync_repository($repo, $branches); + cow_git_write_branch_list($branches, $branch_list); + $main_tip = $repo->get_branch_tip('refs/heads/main'); + $repo->checkout('refs/heads/main'); + $created_tip = $repo->commit([ + 'commit' => [ + 'message' => 'create branch with public link crash', + 'author' => 'ForkPress Test ', + 'committer' => 'ForkPress Test ', + 'parents' => [$main_tip], + ], + 'updates' => ['wordpress/wp-content/git-created-public-link-crash.txt' => "created\n"], + ]); + $repo->set_branch_tip('refs/heads/git-created-public-link-crash', $created_tip); + $crash_result = run_php_code_env(<<<'PHP' +require_once getenv('FORKPRESS_COW_GIT_SERVER_HELPER'); + +$git = getenv('FORKPRESS_COW_GIT_REPO'); +$branches = getenv('FORKPRESS_COW_GIT_BRANCHES'); +$storage = getenv('FORKPRESS_COW_GIT_STORAGE_BRANCHES'); +$branch_list = getenv('FORKPRESS_COW_GIT_BRANCH_LIST'); +$main_tip = getenv('FORKPRESS_COW_GIT_MAIN_TIP'); +$fs = WordPress\Filesystem\LocalFilesystem::create($git); +$repo = new WordPress\Git\GitRepository($fs, ['default_branch' => 'main']); +$repo->set_config_value(['user', 'name'], 'ForkPress COW'); +$repo->set_config_value(['user', 'email'], 'forkpress-cow@local'); +cow_git_apply_push_to_branches($repo, $git, $branches, $storage, $branch_list, 'file-copy', '', ['main' => $main_tip]); +PHP, [ + 'FORKPRESS_COW_GIT_SERVER_HELPER' => realpath(__DIR__ . '/../../scripts/cow/git_server.php'), + 'FORKPRESS_COW_GIT_REPO' => $git, + 'FORKPRESS_COW_GIT_BRANCHES' => $branches, + 'FORKPRESS_COW_GIT_STORAGE_BRANCHES' => $storage_branches, + 'FORKPRESS_COW_GIT_BRANCH_LIST' => $branch_list, + 'FORKPRESS_COW_GIT_MAIN_TIP' => $main_tip, + 'FORKPRESS_COW_GIT_TEST_FAILPOINT' => 'after-created-branch-public-link', + 'FORKPRESS_COW_GIT_TEST_FAILPOINT_ACTION' => 'exit', + ]); + assert_true($crash_result['status'] !== 0, 'Git-created public-link crash terminates the push apply subprocess'); + assert_true(is_dir($storage_branches . '/git-created-public-link-crash'), 'Git-created public-link crash leaves storage branch published'); + assert_true(is_link($branches . '/git-created-public-link-crash'), 'Git-created public-link crash leaves public branch linked'); + assert_same(file_get_contents($branches . '/git-created-public-link-crash/wp-content/git-created-public-link-crash.txt'), "created\n", 'Git-created public-link crash leaves pushed WordPress files visible'); + $stale_branch_list = (string)file_get_contents($branch_list); + assert_true(!str_contains($stale_branch_list, "git-created-public-link-crash\n"), 'Git-created public-link crash can leave branch list stale'); + $metadata = new SQLite3($tmp . '/merge/metadata.sqlite'); + $crash_band_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_autoincrement_bands WHERE branch_name = 'git-created-public-link-crash' AND table_name = 'wp_posts'"); + $crash_identity_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_row_identities WHERE branch_name = 'git-created-public-link-crash' AND table_name = 'plugin_keyless'"); + $metadata->close(); + assert_same($crash_band_count, 1, 'Git-created public-link crash leaves ID-band metadata finalized'); + assert_same($crash_identity_count, 1, 'Git-created public-link crash leaves row identity metadata finalized'); + cow_git_apply_push_to_branches($repo, $git, $branches, $storage_branches, $branch_list, 'file-copy', '', ['main' => $main_tip]); + assert_true(is_link($branches . '/git-created-public-link-crash'), 'next Git apply keeps public branch link after public-link crash'); + assert_true(is_dir($storage_branches . '/git-created-public-link-crash'), 'next Git apply keeps storage branch after public-link crash'); + assert_same(file_get_contents($branches . '/git-created-public-link-crash/wp-content/git-created-public-link-crash.txt'), "created\n", 'next Git apply preserves pushed files after public-link crash'); + $reconciled_branch_list = (string)file_get_contents($branch_list); + assert_true(str_contains($reconciled_branch_list, "git-created-public-link-crash\n"), 'next Git apply reconciles branch list after public-link crash'); + $metadata = new SQLite3($tmp . '/merge/metadata.sqlite'); + $reconciled_band_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_autoincrement_bands WHERE branch_name = 'git-created-public-link-crash' AND table_name = 'wp_posts'"); + $reconciled_identity_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_row_identities WHERE branch_name = 'git-created-public-link-crash' AND table_name = 'plugin_keyless'"); + $metadata->close(); + assert_same($reconciled_band_count, 1, 'branch-list reconciliation after public-link crash preserves finalized ID-band metadata'); + assert_same($reconciled_identity_count, 1, 'branch-list reconciliation after public-link crash preserves finalized row identity metadata'); +} else { + echo " SKIP: separate storage public-link crash symlink test\n"; +} +cow_git_remove_tree($tmp); + +$tmp = sys_get_temp_dir() . '/forkpress-cow-git-created-storage-crash-' . getmypid() . '-' . bin2hex(random_bytes(4)); +$branches = $tmp . '/public-branches'; +$storage_branches = $tmp . '/storage-branches'; +$git = $tmp . '/git'; +$branch_list = $tmp . '/branches.txt'; +mkdir($storage_branches . '/main/wp-content/database', 0777, true); +mkdir($branches, 0777, true); +file_put_contents($storage_branches . '/main/wp-load.php', "exec('CREATE TABLE wp_posts (ID INTEGER PRIMARY KEY AUTOINCREMENT, post_title TEXT)'); +$db->exec("INSERT INTO wp_posts (post_title) VALUES ('Base post')"); +$db->exec('CREATE TABLE plugin_keyless (label TEXT, value TEXT)'); +$db->exec("INSERT INTO plugin_keyless (label, value) VALUES ('Base keyless', 'base')"); +$db->close(); +if (@symlink($storage_branches . '/main', $branches . '/main')) { + $fs = WordPress\Filesystem\LocalFilesystem::create($git); + $repo = new WordPress\Git\GitRepository($fs, ['default_branch' => 'main']); + $repo->set_config_value(['user', 'name'], 'ForkPress COW'); + $repo->set_config_value(['user', 'email'], 'forkpress-cow@local'); + cow_git_sync_repository($repo, $branches); + cow_git_write_branch_list($branches, $branch_list); + $main_tip = $repo->get_branch_tip('refs/heads/main'); + $repo->checkout('refs/heads/main'); + $created_tip = $repo->commit([ + 'commit' => [ + 'message' => 'create branch with storage publish crash', + 'author' => 'ForkPress Test ', + 'committer' => 'ForkPress Test ', + 'parents' => [$main_tip], + ], + 'updates' => ['wordpress/wp-content/git-created-storage-crash.txt' => "created\n"], + ]); + $repo->set_branch_tip('refs/heads/git-created-storage-crash', $created_tip); + $crash_result = run_php_code_env(<<<'PHP' +require_once getenv('FORKPRESS_COW_GIT_SERVER_HELPER'); + +$git = getenv('FORKPRESS_COW_GIT_REPO'); +$branches = getenv('FORKPRESS_COW_GIT_BRANCHES'); +$storage = getenv('FORKPRESS_COW_GIT_STORAGE_BRANCHES'); +$branch_list = getenv('FORKPRESS_COW_GIT_BRANCH_LIST'); +$main_tip = getenv('FORKPRESS_COW_GIT_MAIN_TIP'); +$fs = WordPress\Filesystem\LocalFilesystem::create($git); +$repo = new WordPress\Git\GitRepository($fs, ['default_branch' => 'main']); +$repo->set_config_value(['user', 'name'], 'ForkPress COW'); +$repo->set_config_value(['user', 'email'], 'forkpress-cow@local'); +cow_git_apply_push_to_branches($repo, $git, $branches, $storage, $branch_list, 'file-copy', '', ['main' => $main_tip]); +PHP, [ + 'FORKPRESS_COW_GIT_SERVER_HELPER' => realpath(__DIR__ . '/../../scripts/cow/git_server.php'), + 'FORKPRESS_COW_GIT_REPO' => $git, + 'FORKPRESS_COW_GIT_BRANCHES' => $branches, + 'FORKPRESS_COW_GIT_STORAGE_BRANCHES' => $storage_branches, + 'FORKPRESS_COW_GIT_BRANCH_LIST' => $branch_list, + 'FORKPRESS_COW_GIT_MAIN_TIP' => $main_tip, + 'FORKPRESS_COW_GIT_TEST_FAILPOINT' => 'after-created-branch-storage', + 'FORKPRESS_COW_GIT_TEST_FAILPOINT_ACTION' => 'exit', + ]); + assert_true($crash_result['status'] !== 0, 'Git-created storage publish crash terminates the push apply subprocess'); + assert_true(is_dir($storage_branches . '/git-created-storage-crash'), 'Git-created storage publish crash leaves orphan storage branch'); + assert_true(!file_exists($branches . '/git-created-storage-crash') && !is_link($branches . '/git-created-storage-crash'), 'Git-created storage publish crash leaves public branch unpublished'); + cow_git_apply_push_to_branches($repo, $git, $branches, $storage_branches, $branch_list, 'file-copy', '', ['main' => $main_tip]); + assert_true(is_link($branches . '/git-created-storage-crash'), 'next Git apply links public branch after storage publish crash'); + assert_true(is_dir($storage_branches . '/git-created-storage-crash'), 'next Git apply recreates storage branch after storage publish crash'); + assert_same(file_get_contents($branches . '/git-created-storage-crash/wp-content/git-created-storage-crash.txt'), "created\n", 'next Git apply preserves pushed files after storage publish crash'); + $storage_crash_branch_list = (string)file_get_contents($branch_list); + assert_true(str_contains($storage_crash_branch_list, "git-created-storage-crash\n"), 'next Git apply publishes branch list after storage publish crash'); + $metadata = new SQLite3($tmp . '/merge/metadata.sqlite'); + $storage_crash_band_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_autoincrement_bands WHERE branch_name = 'git-created-storage-crash' AND table_name = 'wp_posts'"); + $storage_crash_identity_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_row_identities WHERE branch_name = 'git-created-storage-crash' AND table_name = 'plugin_keyless'"); + $metadata->close(); + assert_same($storage_crash_band_count, 1, 'retry after storage publish crash has one active ID-band row'); + assert_same($storage_crash_identity_count, 1, 'retry after storage publish crash has one active row identity'); +} else { + echo " SKIP: separate storage publication crash symlink test\n"; +} +cow_git_remove_tree($tmp); + +$tmp = sys_get_temp_dir() . '/forkpress-cow-git-created-before-branch-list-crash-' . getmypid() . '-' . bin2hex(random_bytes(4)); +$branches = $tmp . '/branches'; +$git = $tmp . '/git'; +$branch_list = $tmp . '/branches.txt'; +mkdir($branches . '/main/wp-content/database', 0777, true); +file_put_contents($branches . '/main/wp-load.php', "exec('CREATE TABLE wp_posts (ID INTEGER PRIMARY KEY AUTOINCREMENT, post_title TEXT)'); +$db->exec("INSERT INTO wp_posts (post_title) VALUES ('Base post')"); +$db->exec('CREATE TABLE plugin_keyless (label TEXT, value TEXT)'); +$db->exec("INSERT INTO plugin_keyless (label, value) VALUES ('Base keyless', 'base')"); +$db->close(); + +$fs = WordPress\Filesystem\LocalFilesystem::create($git); +$repo = new WordPress\Git\GitRepository($fs, ['default_branch' => 'main']); +$repo->set_config_value(['user', 'name'], 'ForkPress COW'); +$repo->set_config_value(['user', 'email'], 'forkpress-cow@local'); +cow_git_sync_repository($repo, $branches); +cow_git_write_branch_list($branches, $branch_list); +$main_tip = $repo->get_branch_tip('refs/heads/main'); +$repo->checkout('refs/heads/main'); +$created_tip = $repo->commit([ + 'commit' => [ + 'message' => 'create branch with pre-branch-list crash', + 'author' => 'ForkPress Test ', + 'committer' => 'ForkPress Test ', + 'parents' => [$main_tip], + ], + 'updates' => ['wordpress/wp-content/git-created-before-list-crash.txt' => "created\n"], +]); +$repo->set_branch_tip('refs/heads/git-created-before-list-crash', $created_tip); +$crash_result = run_php_code_env(<<<'PHP' +require_once getenv('FORKPRESS_COW_GIT_SERVER_HELPER'); + +$git = getenv('FORKPRESS_COW_GIT_REPO'); +$branches = getenv('FORKPRESS_COW_GIT_BRANCHES'); +$branch_list = getenv('FORKPRESS_COW_GIT_BRANCH_LIST'); +$main_tip = getenv('FORKPRESS_COW_GIT_MAIN_TIP'); +$fs = WordPress\Filesystem\LocalFilesystem::create($git); +$repo = new WordPress\Git\GitRepository($fs, ['default_branch' => 'main']); +$repo->set_config_value(['user', 'name'], 'ForkPress COW'); +$repo->set_config_value(['user', 'email'], 'forkpress-cow@local'); +cow_git_apply_push_to_branches($repo, $git, $branches, $branches, $branch_list, 'file-copy', '', ['main' => $main_tip]); +PHP, [ + 'FORKPRESS_COW_GIT_SERVER_HELPER' => realpath(__DIR__ . '/../../scripts/cow/git_server.php'), + 'FORKPRESS_COW_GIT_REPO' => $git, + 'FORKPRESS_COW_GIT_BRANCHES' => $branches, + 'FORKPRESS_COW_GIT_BRANCH_LIST' => $branch_list, + 'FORKPRESS_COW_GIT_MAIN_TIP' => $main_tip, + 'FORKPRESS_COW_GIT_TEST_FAILPOINT' => 'before-created-branch-list', + 'FORKPRESS_COW_GIT_TEST_FAILPOINT_ACTION' => 'exit', +]); +assert_true($crash_result['status'] !== 0, 'Git-created pre-branch-list crash terminates the push apply subprocess'); +assert_true(is_dir($branches . '/git-created-before-list-crash'), 'Git-created pre-branch-list crash leaves the created branch published'); +assert_same(file_get_contents($branches . '/git-created-before-list-crash/wp-content/git-created-before-list-crash.txt'), "created\n", 'Git-created pre-branch-list crash leaves pushed WordPress files published'); +assert_true(file_exists($tmp . '/merge/bases/git-created-before-list-crash.sqlite'), 'Git-created pre-branch-list crash leaves DB merge base artifacts'); +assert_true(file_exists($tmp . '/merge/file-bases/git-created-before-list-crash.json'), 'Git-created pre-branch-list crash leaves filesystem merge base artifacts'); +$stale_branch_list = (string)file_get_contents($branch_list); +assert_true(str_contains($stale_branch_list, "main\n"), 'Git-created pre-branch-list crash keeps main in the stale branch list'); +assert_true(!str_contains($stale_branch_list, "git-created-before-list-crash\n"), 'Git-created pre-branch-list crash can leave the branch list stale'); +$metadata = new SQLite3($tmp . '/merge/metadata.sqlite'); +$crash_band_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_autoincrement_bands WHERE branch_name = 'git-created-before-list-crash' AND table_name = 'wp_posts'"); +$crash_identity_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_row_identities WHERE branch_name = 'git-created-before-list-crash' AND table_name = 'plugin_keyless'"); +$metadata->close(); +assert_same($crash_band_count, 1, 'Git-created pre-branch-list crash leaves ID-band metadata finalized'); +assert_same($crash_identity_count, 1, 'Git-created pre-branch-list crash leaves row identity metadata finalized'); +cow_git_apply_push_to_branches($repo, $git, $branches, $branches, $branch_list, 'file-copy', '', ['main' => $main_tip]); +$reconciled_branch_list = (string)file_get_contents($branch_list); +assert_true(str_contains($reconciled_branch_list, "git-created-before-list-crash\n"), 'next Git apply reconciles a stale branch list after a pre-publication crash'); +$metadata = new SQLite3($tmp . '/merge/metadata.sqlite'); +$reconciled_band_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_autoincrement_bands WHERE branch_name = 'git-created-before-list-crash' AND table_name = 'wp_posts'"); +$reconciled_identity_count = (int)$metadata->querySingle("SELECT COUNT(*) FROM merge_row_identities WHERE branch_name = 'git-created-before-list-crash' AND table_name = 'plugin_keyless'"); +$metadata->close(); +assert_same($reconciled_band_count, 1, 'branch-list reconciliation preserves finalized ID-band metadata'); +assert_same($reconciled_identity_count, 1, 'branch-list reconciliation preserves finalized row identity metadata'); +cow_git_remove_tree($tmp); + +$tmp = sys_get_temp_dir() . '/forkpress-cow-git-existing-update-crash-' . getmypid() . '-' . bin2hex(random_bytes(4)); +$branches = $tmp . '/branches'; +$git = $tmp . '/git'; +$branch_list = $tmp . '/branches.txt'; +mkdir($branches . '/main/wp-content', 0777, true); +file_put_contents($branches . '/main/wp-load.php', " 'main']); +$repo->set_config_value(['user', 'name'], 'ForkPress COW'); +$repo->set_config_value(['user', 'email'], 'forkpress-cow@local'); +cow_git_sync_repository($repo, $branches); +cow_git_write_branch_list($branches, $branch_list); +$main_tip = $repo->get_branch_tip('refs/heads/main'); +$repo->checkout('refs/heads/main'); +$updated_tip = $repo->commit([ + 'commit' => [ + 'message' => 'update branch with crash', + 'author' => 'ForkPress Test ', + 'committer' => 'ForkPress Test ', + 'parents' => [$main_tip], + ], + 'updates' => ['wordpress/wp-content/crash-update.txt' => "new\n"], +]); +$repo->set_branch_tip('refs/heads/main', $updated_tip); +$crash_result = run_php_code_env(<<<'PHP' +require_once getenv('FORKPRESS_COW_GIT_SERVER_HELPER'); + +$git = getenv('FORKPRESS_COW_GIT_REPO'); +$branches = getenv('FORKPRESS_COW_GIT_BRANCHES'); +$branch_list = getenv('FORKPRESS_COW_GIT_BRANCH_LIST'); +$main_tip = getenv('FORKPRESS_COW_GIT_MAIN_TIP'); +$fs = WordPress\Filesystem\LocalFilesystem::create($git); +$repo = new WordPress\Git\GitRepository($fs, ['default_branch' => 'main']); +$repo->set_config_value(['user', 'name'], 'ForkPress COW'); +$repo->set_config_value(['user', 'email'], 'forkpress-cow@local'); +cow_git_apply_push_to_branches($repo, $git, $branches, $branches, $branch_list, 'file-copy', '', ['main' => $main_tip]); +PHP, [ + 'FORKPRESS_COW_GIT_SERVER_HELPER' => realpath(__DIR__ . '/../../scripts/cow/git_server.php'), + 'FORKPRESS_COW_GIT_REPO' => $git, + 'FORKPRESS_COW_GIT_BRANCHES' => $branches, + 'FORKPRESS_COW_GIT_BRANCH_LIST' => $branch_list, + 'FORKPRESS_COW_GIT_MAIN_TIP' => $main_tip, + 'FORKPRESS_COW_GIT_TEST_FAILPOINT' => 'after-existing-branch-update-publish', + 'FORKPRESS_COW_GIT_TEST_FAILPOINT_ACTION' => 'exit', +]); +assert_true($crash_result['status'] !== 0, 'existing branch update crash terminates the push apply subprocess'); +assert_same(file_get_contents($branches . '/main/wp-content/crash-update.txt'), "new\n", 'existing branch update crash leaves the fully published new file'); +assert_true(count(glob($branches . '/.forkpress-update-backup-main-*') ?: []) >= 1, 'existing branch update crash leaves a rollback backup artifact'); +cow_git_apply_push_to_branches($repo, $git, $branches, $branches, $branch_list, 'file-copy', '', ['main' => $main_tip]); +assert_same(file_get_contents($branches . '/main/wp-content/crash-update.txt'), "new\n", 'next Git apply preserves the published branch update after crash'); +assert_same(glob($branches . '/.forkpress-update-*') ?: [], [], 'next Git apply cleans stale update artifacts after crash'); +cow_git_remove_tree($tmp); + $tmp = sys_get_temp_dir() . '/forkpress-cow-git-created-id-band-metadata-rollback-' . getmypid() . '-' . bin2hex(random_bytes(4)); $branches = $tmp . '/branches'; $git = $tmp . '/git'; diff --git a/tests/cow/merge.php b/tests/cow/merge.php index 5381b418..aa77b69f 100644 --- a/tests/cow/merge.php +++ b/tests/cow/merge.php @@ -40,6 +40,36 @@ function run_merge_cli(array $args): array { ]; } +function run_merge_cli_env(array $args, array $env): array { + $script = dirname(__DIR__, 2) . '/scripts/cow/merge.php'; + $base_env = getenv(); + if (!is_array($base_env)) { + $base_env = []; + } + $pipes = []; + $process = proc_open( + array_merge([PHP_BINARY, $script], $args), + [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + null, + array_merge($base_env, $env) + ); + if (!is_resource($process)) { + throw new RuntimeException('failed to start merge CLI subprocess'); + } + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + return [ + 'status' => proc_close($process), + 'output' => (is_string($stdout) ? $stdout : '') . (is_string($stderr) ? $stderr : ''), + ]; +} + function remove_tree(string $path): void { if (!file_exists($path) && !is_link($path)) { return; @@ -1299,6 +1329,481 @@ static function (array $snapshot) use ($merge_restore_failure_target): void { assert_true(is_string($merge_restore_failure_artifact) && str_contains($merge_restore_failure_artifact, '"target_db_snapshot"'), 'rollback-failure artifact records target DB snapshot details'); assert_true(str_contains((string)$merge_restore_failure_artifact, '"backup_exists":true'), 'rollback-failure artifact keeps the target DB snapshot backup for recovery'); + if (function_exists('posix_kill') && defined('SIGKILL')) { + $crash_before_commit_base = $tmp . '/crash-before-commit-base.sqlite'; + $crash_before_commit_source = $tmp . '/crash-before-commit-source.sqlite'; + $crash_before_commit_target = $tmp . '/crash-before-commit-target.sqlite'; + $crash_before_commit_metadata = $tmp . '/.forkpress/cow/merge/crash-before-commit/metadata.sqlite'; + create_base_db($crash_before_commit_base); + copy($crash_before_commit_base, $crash_before_commit_source); + copy($crash_before_commit_base, $crash_before_commit_target); + $db = open_db($crash_before_commit_source); + $db->exec("UPDATE wp_posts SET post_content = 'Source crash before commit content' WHERE ID = 1"); + $db->close(); + $crash_before_commit_result = run_merge_cli_env( + [ + 'merge', + '--base-db', $crash_before_commit_base, + '--source-db', $crash_before_commit_source, + '--target-db', $crash_before_commit_target, + '--metadata-db', $crash_before_commit_metadata, + '--source', 'feature-crash-before-commit', + '--target', 'main', + ], + [ + 'FORKPRESS_COW_MERGE_TEST_FAILPOINT' => 'before-target-db-commit', + 'FORKPRESS_COW_MERGE_TEST_FAILPOINT_ACTION' => 'kill', + ] + ); + assert_true($crash_before_commit_result['status'] !== 0, 'crash failpoint terminates the merge subprocess before target DB commit'); + assert_same( + scalar($crash_before_commit_target, "SELECT post_content FROM wp_posts WHERE ID = 1"), + 'Base content', + 'process death before target DB commit leaves the target DB transaction rolled back' + ); + $crash_before_commit_files = glob(dirname($crash_before_commit_metadata) . '/crash-recovery/*.json'); + assert_true(is_array($crash_before_commit_files) && count($crash_before_commit_files) === 1, 'process death before target DB commit leaves one crash recovery artifact'); + $crash_before_commit_recovery = json_decode(file_get_contents($crash_before_commit_files[0]), true); + assert_same($crash_before_commit_recovery['checkpoint'] ?? null, 'target-db-commit', 'pre-target-commit crash recovery artifact uses the target DB commit checkpoint'); + assert_same($crash_before_commit_recovery['source_branch'] ?? null, 'feature-crash-before-commit', 'pre-target-commit crash recovery artifact preserves source branch context'); + $crash_before_commit_backup = $crash_before_commit_recovery['artifacts']['target_db_snapshot']['backup'] ?? null; + assert_true(is_string($crash_before_commit_backup) && is_file($crash_before_commit_backup), 'pre-target-commit crash recovery artifact preserves the target DB snapshot'); + $blocked_crash_before_commit_rematch = run_merge_cli([ + 'merge', + '--base-db', $crash_before_commit_base, + '--source-db', $crash_before_commit_source, + '--target-db', $crash_before_commit_target, + '--metadata-db', $crash_before_commit_metadata, + '--source', 'feature-crash-before-commit', + '--target', 'main', + ]); + assert_true($blocked_crash_before_commit_rematch['status'] !== 0, 'pending pre-target-commit crash recovery blocks a subsequent merge'); + assert_true(str_contains($blocked_crash_before_commit_rematch['output'], 'pending COW merge crash recovery artifact'), 'pending pre-target-commit crash recovery error explains the recovery queue'); + $crash_before_commit_restore = run_merge_cli([ + 'recover-crash', + '--metadata-db', $crash_before_commit_metadata, + '--restore-target-db', + '--format', 'json', + ]); + assert_same($crash_before_commit_restore['status'], 0, 'crash recovery CLI restores pre-target-commit target DB snapshots'); + $crash_before_commit_restore_json = json_decode($crash_before_commit_restore['output'], true); + assert_same($crash_before_commit_restore_json['restored'] ?? null, 1, 'crash recovery CLI reports one restored pre-target-commit artifact'); + assert_same( + scalar($crash_before_commit_target, "SELECT post_content FROM wp_posts WHERE ID = 1"), + 'Base content', + 'crash recovery CLI leaves the pre-target-commit target DB at base content' + ); + + $crash_commit_base = $tmp . '/crash-commit-base.sqlite'; + $crash_commit_source = $tmp . '/crash-commit-source.sqlite'; + $crash_commit_target = $tmp . '/crash-commit-target.sqlite'; + $crash_commit_metadata = $tmp . '/.forkpress/cow/merge/crash-commit/metadata.sqlite'; + create_base_db($crash_commit_base); + copy($crash_commit_base, $crash_commit_source); + copy($crash_commit_base, $crash_commit_target); + $db = open_db($crash_commit_source); + $db->exec("UPDATE wp_posts SET post_content = 'Source crash commit content' WHERE ID = 1"); + $db->close(); + $crash_commit_result = run_merge_cli_env( + [ + 'merge', + '--base-db', $crash_commit_base, + '--source-db', $crash_commit_source, + '--target-db', $crash_commit_target, + '--metadata-db', $crash_commit_metadata, + '--source', 'feature-crash-commit', + '--target', 'main', + ], + [ + 'FORKPRESS_COW_MERGE_TEST_FAILPOINT' => 'after-target-db-commit', + 'FORKPRESS_COW_MERGE_TEST_FAILPOINT_ACTION' => 'kill', + ] + ); + assert_true($crash_commit_result['status'] !== 0, 'crash failpoint terminates the merge subprocess after target DB commit'); + assert_same( + scalar($crash_commit_target, "SELECT post_content FROM wp_posts WHERE ID = 1"), + 'Source crash commit content', + 'process death after target DB commit leaves the durable target change visible' + ); + $crash_recovery_files = glob(dirname($crash_commit_metadata) . '/crash-recovery/*.json'); + assert_true(is_array($crash_recovery_files) && count($crash_recovery_files) === 1, 'process death after target DB commit leaves one crash recovery artifact'); + $crash_recovery = json_decode(file_get_contents($crash_recovery_files[0]), true); + assert_same($crash_recovery['checkpoint'] ?? null, 'target-db-commit', 'crash recovery artifact identifies the target DB commit checkpoint'); + assert_same($crash_recovery['source_branch'] ?? null, 'feature-crash-commit', 'crash recovery artifact preserves source branch context'); + $crash_backup = $crash_recovery['artifacts']['target_db_snapshot']['backup'] ?? null; + assert_true(is_string($crash_backup) && is_file($crash_backup), 'crash recovery artifact preserves the pre-commit target DB snapshot'); + $crash_backup_check = $tmp . '/crash-commit-backup-check.sqlite'; + copy($crash_backup, $crash_backup_check); + assert_same( + scalar($crash_backup_check, "SELECT post_content FROM wp_posts WHERE ID = 1"), + 'Base content', + 'crash recovery target snapshot can restore the pre-merge target content' + ); + $crash_commit_runs = is_file($crash_commit_metadata) + ? (int)scalar($crash_commit_metadata, "SELECT COUNT(*) FROM merge_runs WHERE source_branch = 'feature-crash-commit' AND status = 'completed'") + : 0; + assert_same($crash_commit_runs, 0, 'process death before metadata commit does not falsely record a completed run'); + $crash_recovery_report = run_merge_cli([ + 'recover-crash', + '--metadata-db', $crash_commit_metadata, + '--format', 'json', + ]); + assert_same($crash_recovery_report['status'], 0, 'crash recovery CLI lists pending artifacts'); + $crash_recovery_report_json = json_decode($crash_recovery_report['output'], true); + assert_same($crash_recovery_report_json['pending'] ?? null, 1, 'crash recovery CLI reports one pending artifact'); + assert_same($crash_recovery_report_json['artifacts'][0]['checkpoint'] ?? null, 'target-db-commit', 'crash recovery CLI reports the commit checkpoint'); + $blocked_crash_rematch = run_merge_cli([ + 'merge', + '--base-db', $crash_commit_base, + '--source-db', $crash_commit_source, + '--target-db', $crash_commit_target, + '--metadata-db', $crash_commit_metadata, + '--source', 'feature-crash-commit', + '--target', 'main', + ]); + assert_true($blocked_crash_rematch['status'] !== 0, 'pending DB crash recovery blocks a subsequent merge'); + assert_true(str_contains($blocked_crash_rematch['output'], 'pending COW merge crash recovery artifact'), 'pending DB crash recovery error explains the recovery queue'); + assert_same( + scalar($crash_commit_target, "SELECT post_content FROM wp_posts WHERE ID = 1"), + 'Source crash commit content', + 'blocked merge leaves the pending DB crash state untouched' + ); + $crash_restore_interrupted = run_merge_cli_env( + [ + 'recover-crash', + '--metadata-db', $crash_commit_metadata, + '--restore-target-db', + '--format', 'json', + ], + [ + 'FORKPRESS_COW_MERGE_TEST_FAILPOINT' => 'after-crash-recovery-restore', + 'FORKPRESS_COW_MERGE_TEST_FAILPOINT_ACTION' => 'exit', + ] + ); + assert_true($crash_restore_interrupted['status'] !== 0, 'crash recovery restore cleanup failpoint terminates the recovery subprocess'); + assert_same( + scalar($crash_commit_target, "SELECT post_content FROM wp_posts WHERE ID = 1"), + 'Base content', + 'interrupted crash recovery restores the pre-merge target DB content before artifact cleanup' + ); + $interrupted_crash_recovery_files = glob(dirname($crash_commit_metadata) . '/crash-recovery/*.json'); + assert_true(is_array($interrupted_crash_recovery_files) && count($interrupted_crash_recovery_files) === 1, 'interrupted crash recovery leaves the recovery artifact retryable'); + assert_true(is_file($crash_backup), 'interrupted crash recovery keeps the target DB snapshot backup for retry'); + $crash_restore_report = run_merge_cli([ + 'recover-crash', + '--metadata-db', $crash_commit_metadata, + '--restore-target-db', + '--format', 'json', + ]); + assert_same($crash_restore_report['status'], 0, 'crash recovery CLI restores target DB snapshots explicitly'); + $crash_restore_report_json = json_decode($crash_restore_report['output'], true); + assert_same($crash_restore_report_json['restored'] ?? null, 1, 'crash recovery CLI reports one restored artifact'); + assert_same($crash_restore_report_json['pending'] ?? null, 0, 'crash recovery CLI removes restored artifacts from the pending queue'); + assert_same( + scalar($crash_commit_target, "SELECT post_content FROM wp_posts WHERE ID = 1"), + 'Base content', + 'crash recovery CLI restores the pre-merge target DB content' + ); + $restored_crash_recovery_files = glob(dirname($crash_commit_metadata) . '/crash-recovery/*.json'); + assert_true(is_array($restored_crash_recovery_files) && count($restored_crash_recovery_files) === 0, 'crash recovery CLI removes restored artifact files'); + assert_true(!file_exists($crash_backup), 'completed crash recovery cleanup removes target DB snapshot backup'); + + $crash_metadata_base = $tmp . '/crash-metadata-base.sqlite'; + $crash_metadata_source = $tmp . '/crash-metadata-source.sqlite'; + $crash_metadata_target = $tmp . '/crash-metadata-target.sqlite'; + $crash_metadata_db = $tmp . '/.forkpress/cow/merge/crash-metadata/metadata.sqlite'; + create_base_db($crash_metadata_base); + copy($crash_metadata_base, $crash_metadata_source); + copy($crash_metadata_base, $crash_metadata_target); + $db = open_db($crash_metadata_source); + $db->exec("UPDATE wp_posts SET post_content = 'Source crash metadata content' WHERE ID = 1"); + $db->close(); + $crash_metadata_result = run_merge_cli_env( + [ + 'merge', + '--base-db', $crash_metadata_base, + '--source-db', $crash_metadata_source, + '--target-db', $crash_metadata_target, + '--metadata-db', $crash_metadata_db, + '--source', 'feature-crash-metadata', + '--target', 'main', + ], + [ + 'FORKPRESS_COW_MERGE_TEST_FAILPOINT' => 'before-metadata-commit', + 'FORKPRESS_COW_MERGE_TEST_FAILPOINT_ACTION' => 'kill', + ] + ); + assert_true($crash_metadata_result['status'] !== 0, 'crash failpoint terminates the merge subprocess before metadata commit'); + assert_same( + scalar($crash_metadata_target, "SELECT post_content FROM wp_posts WHERE ID = 1"), + 'Source crash metadata content', + 'process death before metadata commit leaves the durable target change visible' + ); + $crash_metadata_files = glob(dirname($crash_metadata_db) . '/crash-recovery/*.json'); + assert_true(is_array($crash_metadata_files) && count($crash_metadata_files) === 1, 'process death before metadata commit leaves one crash recovery artifact'); + $crash_metadata_recovery = json_decode(file_get_contents($crash_metadata_files[0]), true); + assert_same($crash_metadata_recovery['checkpoint'] ?? null, 'target-db-commit', 'pre-metadata crash recovery artifact uses the target DB commit checkpoint'); + assert_same($crash_metadata_recovery['source_branch'] ?? null, 'feature-crash-metadata', 'pre-metadata crash recovery artifact preserves source branch context'); + $crash_metadata_completed = is_file($crash_metadata_db) + ? (int)scalar($crash_metadata_db, "SELECT COUNT(*) FROM merge_runs WHERE source_branch = 'feature-crash-metadata' AND status = 'completed'") + : 0; + assert_same($crash_metadata_completed, 0, 'process death before metadata commit does not publish a completed merge run'); + $blocked_crash_metadata_rematch = run_merge_cli([ + 'merge', + '--base-db', $crash_metadata_base, + '--source-db', $crash_metadata_source, + '--target-db', $crash_metadata_target, + '--metadata-db', $crash_metadata_db, + '--source', 'feature-crash-metadata', + '--target', 'main', + ]); + assert_true($blocked_crash_metadata_rematch['status'] !== 0, 'pending pre-metadata crash recovery blocks a subsequent merge'); + assert_true(str_contains($blocked_crash_metadata_rematch['output'], 'pending COW merge crash recovery artifact'), 'pending pre-metadata crash recovery error explains the recovery queue'); + $crash_metadata_restore = run_merge_cli([ + 'recover-crash', + '--metadata-db', $crash_metadata_db, + '--restore-target-db', + '--format', 'json', + ]); + assert_same($crash_metadata_restore['status'], 0, 'crash recovery CLI restores pre-metadata target DB snapshots'); + $crash_metadata_restore_json = json_decode($crash_metadata_restore['output'], true); + assert_same($crash_metadata_restore_json['restored'] ?? null, 1, 'crash recovery CLI reports one restored pre-metadata artifact'); + assert_same( + scalar($crash_metadata_target, "SELECT post_content FROM wp_posts WHERE ID = 1"), + 'Base content', + 'crash recovery CLI restores the pre-metadata target DB content' + ); + + $crash_before_file_base_db = $tmp . '/crash-before-file-base.sqlite'; + $crash_before_file_source_db = $tmp . '/crash-before-file-source.sqlite'; + $crash_before_file_target_db = $tmp . '/crash-before-file-target.sqlite'; + $crash_before_file_metadata = $tmp . '/.forkpress/cow/merge/crash-before-file/metadata.sqlite'; + create_base_db($crash_before_file_base_db); + copy($crash_before_file_base_db, $crash_before_file_source_db); + copy($crash_before_file_base_db, $crash_before_file_target_db); + $db = open_db($crash_before_file_source_db); + $db->exec("UPDATE wp_posts SET post_content = 'Source crash before file content' WHERE ID = 1"); + $db->close(); + $crash_before_file_base_root = $tmp . '/crash-before-file-base-root'; + $crash_before_file_source_root = $tmp . '/crash-before-file-source-root'; + $crash_before_file_target_root = $tmp . '/crash-before-file-target-root'; + write_test_file($crash_before_file_base_root . '/wp-content/uploads/before-file.txt', 'base before-file content'); + copy_tree_for_test($crash_before_file_base_root, $crash_before_file_source_root); + copy_tree_for_test($crash_before_file_base_root, $crash_before_file_target_root); + write_test_file($crash_before_file_source_root . '/wp-content/uploads/before-file.txt', 'source before-file content'); + $crash_before_file_manifest = $tmp . '/.forkpress/cow/merge/file-bases/feature-crash-before-file.json'; + cow_merge_capture_file_base($crash_before_file_base_root, $crash_before_file_manifest); + $crash_before_file_result = run_merge_cli_env( + [ + 'merge', + '--base-db', $crash_before_file_base_db, + '--source-db', $crash_before_file_source_db, + '--target-db', $crash_before_file_target_db, + '--metadata-db', $crash_before_file_metadata, + '--source', 'feature-crash-before-file', + '--target', 'main', + '--base-files', $crash_before_file_manifest, + '--source-root', $crash_before_file_source_root, + '--target-root', $crash_before_file_target_root, + ], + [ + 'FORKPRESS_COW_MERGE_TEST_FAILPOINT' => 'before-file-op', + 'FORKPRESS_COW_MERGE_TEST_FAILPOINT_ACTION' => 'kill', + ] + ); + assert_true($crash_before_file_result['status'] !== 0, 'crash failpoint terminates the merge subprocess before filesystem operations begin'); + assert_same( + scalar($crash_before_file_target_db, "SELECT post_content FROM wp_posts WHERE ID = 1"), + 'Source crash before file content', + 'process death before filesystem operations can leave the already committed DB phase visible' + ); + assert_same( + file_get_contents($crash_before_file_target_root . '/wp-content/uploads/before-file.txt'), + 'base before-file content', + 'process death before filesystem operations leaves files at the pre-merge content' + ); + $crash_before_file_report = run_merge_cli([ + 'recover-crash', + '--metadata-db', $crash_before_file_metadata, + '--format', 'json', + ]); + assert_same($crash_before_file_report['status'], 0, 'crash recovery CLI lists pending before-file artifacts'); + $crash_before_file_report_json = json_decode($crash_before_file_report['output'], true); + assert_same($crash_before_file_report_json['pending'] ?? null, 1, 'crash recovery CLI reports one pending before-file artifact'); + assert_same($crash_before_file_report_json['artifacts'][0]['checkpoint'] ?? null, 'before-file-op', 'crash recovery CLI reports the before-file checkpoint'); + assert_true(is_array($crash_before_file_report_json['artifacts'][0]['target_db_snapshot'] ?? null), 'before-file crash artifact preserves the target DB snapshot'); + assert_true(is_array($crash_before_file_report_json['artifacts'][0]['metadata_db_snapshot'] ?? null), 'before-file crash artifact preserves the metadata DB snapshot'); + assert_true(is_array($crash_before_file_report_json['artifacts'][0]['filesystem_snapshot_summary'] ?? null), 'before-file crash artifact preserves the filesystem root snapshot summary'); + $blocked_crash_before_file_rematch = run_merge_cli([ + 'merge', + '--base-db', $crash_before_file_base_db, + '--source-db', $crash_before_file_source_db, + '--target-db', $crash_before_file_target_db, + '--metadata-db', $crash_before_file_metadata, + '--source', 'feature-crash-before-file', + '--target', 'main', + '--base-files', $crash_before_file_manifest, + '--source-root', $crash_before_file_source_root, + '--target-root', $crash_before_file_target_root, + ]); + assert_true($blocked_crash_before_file_rematch['status'] !== 0, 'pending before-file crash recovery blocks a subsequent merge'); + assert_true(str_contains($blocked_crash_before_file_rematch['output'], 'pending COW merge crash recovery artifact'), 'pending before-file crash recovery error explains the recovery queue'); + $crash_before_file_restore = run_merge_cli([ + 'recover-crash', + '--metadata-db', $crash_before_file_metadata, + '--restore-target-db', + '--restore-files', + '--format', 'json', + ]); + assert_same($crash_before_file_restore['status'], 0, 'crash recovery CLI restores before-file whole-branch snapshots'); + $crash_before_file_restore_json = json_decode($crash_before_file_restore['output'], true); + assert_same($crash_before_file_restore_json['restored'] ?? null, 1, 'crash recovery CLI reports one restored before-file artifact'); + assert_same($crash_before_file_restore_json['pending'] ?? null, 0, 'crash recovery CLI clears the before-file crash queue after restore'); + assert_same( + scalar($crash_before_file_target_db, "SELECT post_content FROM wp_posts WHERE ID = 1"), + 'Base content', + 'before-file crash recovery restores the pre-merge target DB content' + ); + assert_same( + file_get_contents($crash_before_file_target_root . '/wp-content/uploads/before-file.txt'), + 'base before-file content', + 'before-file crash recovery leaves files at the pre-merge content' + ); + + $crash_file_base_db = $tmp . '/crash-file-base.sqlite'; + $crash_file_source_db = $tmp . '/crash-file-source.sqlite'; + $crash_file_target_db = $tmp . '/crash-file-target.sqlite'; + $crash_file_metadata = $tmp . '/.forkpress/cow/merge/crash-file/metadata.sqlite'; + create_base_db($crash_file_base_db); + copy($crash_file_base_db, $crash_file_source_db); + copy($crash_file_base_db, $crash_file_target_db); + $db = open_db($crash_file_source_db); + $db->exec("UPDATE wp_posts SET post_content = 'Source crash file DB content' WHERE ID = 1"); + $db->close(); + $crash_file_base_root = $tmp . '/crash-file-base-root'; + $crash_file_source_root = $tmp . '/crash-file-source-root'; + $crash_file_target_root = $tmp . '/crash-file-target-root'; + write_test_file($crash_file_base_root . '/wp-content/uploads/crash-file.txt', 'base file crash content'); + copy_tree_for_test($crash_file_base_root, $crash_file_source_root); + copy_tree_for_test($crash_file_base_root, $crash_file_target_root); + write_test_file($crash_file_source_root . '/wp-content/uploads/crash-file.txt', 'source file crash content'); + $crash_file_base_manifest = $tmp . '/.forkpress/cow/merge/file-bases/feature-crash-file.json'; + cow_merge_capture_file_base($crash_file_base_root, $crash_file_base_manifest); + $crash_file_result = run_merge_cli_env( + [ + 'merge', + '--base-db', $crash_file_base_db, + '--source-db', $crash_file_source_db, + '--target-db', $crash_file_target_db, + '--metadata-db', $crash_file_metadata, + '--source', 'feature-crash-file', + '--target', 'main', + '--base-files', $crash_file_base_manifest, + '--source-root', $crash_file_source_root, + '--target-root', $crash_file_target_root, + ], + [ + 'FORKPRESS_COW_MERGE_TEST_FAILPOINT' => 'after-file-op', + 'FORKPRESS_COW_MERGE_TEST_FAILPOINT_ACTION' => 'kill', + ] + ); + assert_true($crash_file_result['status'] !== 0, 'crash failpoint terminates the merge subprocess after a filesystem operation'); + assert_same( + file_get_contents($crash_file_target_root . '/wp-content/uploads/crash-file.txt'), + 'source file crash content', + 'process death after filesystem operation leaves the durable file change visible' + ); + assert_same( + scalar($crash_file_target_db, "SELECT post_content FROM wp_posts WHERE ID = 1"), + 'Source crash file DB content', + 'process death after filesystem operation leaves the durable DB change visible' + ); + $crash_file_report = run_merge_cli([ + 'recover-crash', + '--metadata-db', $crash_file_metadata, + '--format', 'json', + ]); + assert_same($crash_file_report['status'], 0, 'crash recovery CLI lists pending filesystem artifacts'); + $crash_file_report_json = json_decode($crash_file_report['output'], true); + assert_same($crash_file_report_json['pending'] ?? null, 2, 'crash recovery CLI reports whole-branch and filesystem pending artifacts'); + $crash_file_checkpoints = array_column($crash_file_report_json['artifacts'] ?? [], 'checkpoint'); + sort($crash_file_checkpoints); + assert_same($crash_file_checkpoints, ['before-file-op', 'file-op'], 'crash recovery CLI reports whole-branch and filesystem operation checkpoints'); + $crash_file_op_artifacts = array_values(array_filter($crash_file_report_json['artifacts'] ?? [], fn($artifact) => ($artifact['checkpoint'] ?? null) === 'file-op')); + assert_same($crash_file_op_artifacts[0]['filesystem_transaction_summary']['backup_count'] ?? null, 1, 'filesystem crash recovery artifact preserves file backup metadata'); + $crash_whole_branch_artifacts = array_values(array_filter($crash_file_report_json['artifacts'] ?? [], fn($artifact) => ($artifact['checkpoint'] ?? null) === 'before-file-op')); + assert_true(is_array($crash_whole_branch_artifacts[0]['target_db_snapshot'] ?? null), 'filesystem crash keeps whole-branch target DB rollback material'); + assert_true(is_array($crash_whole_branch_artifacts[0]['filesystem_snapshot_summary'] ?? null), 'filesystem crash keeps whole-branch filesystem rollback material'); + $blocked_crash_file_rematch = run_merge_cli([ + 'merge', + '--base-db', $crash_file_base_db, + '--source-db', $crash_file_source_db, + '--target-db', $crash_file_target_db, + '--metadata-db', $crash_file_metadata, + '--source', 'feature-crash-file', + '--target', 'main', + '--base-files', $crash_file_base_manifest, + '--source-root', $crash_file_source_root, + '--target-root', $crash_file_target_root, + ]); + assert_true($blocked_crash_file_rematch['status'] !== 0, 'pending filesystem crash recovery blocks a subsequent merge'); + assert_true(str_contains($blocked_crash_file_rematch['output'], 'pending COW merge crash recovery artifact'), 'pending filesystem crash recovery error explains the recovery queue'); + assert_same( + file_get_contents($crash_file_target_root . '/wp-content/uploads/crash-file.txt'), + 'source file crash content', + 'blocked merge leaves the pending filesystem crash state untouched' + ); + $crash_file_restore_interrupted = run_merge_cli_env( + [ + 'recover-crash', + '--metadata-db', $crash_file_metadata, + '--restore-target-db', + '--restore-files', + '--format', 'json', + ], + [ + 'FORKPRESS_COW_MERGE_TEST_FAILPOINT' => 'after-crash-recovery-restore', + 'FORKPRESS_COW_MERGE_TEST_FAILPOINT_ACTION' => 'exit', + ] + ); + assert_true($crash_file_restore_interrupted['status'] !== 0, 'filesystem crash recovery cleanup failpoint terminates the recovery subprocess'); + assert_same( + file_get_contents($crash_file_target_root . '/wp-content/uploads/crash-file.txt'), + 'base file crash content', + 'interrupted filesystem crash recovery restores pre-merge file content before artifact cleanup' + ); + assert_same( + scalar($crash_file_target_db, "SELECT post_content FROM wp_posts WHERE ID = 1"), + 'Base content', + 'interrupted filesystem crash recovery restores pre-merge DB content before artifact cleanup' + ); + $interrupted_crash_file_recovery_files = glob(dirname($crash_file_metadata) . '/crash-recovery/*.json'); + assert_true(is_array($interrupted_crash_file_recovery_files) && count($interrupted_crash_file_recovery_files) === 2, 'interrupted filesystem crash recovery leaves recovery artifacts retryable'); + $crash_file_restore = run_merge_cli([ + 'recover-crash', + '--metadata-db', $crash_file_metadata, + '--restore-target-db', + '--restore-files', + '--format', 'json', + ]); + assert_same($crash_file_restore['status'], 0, 'crash recovery CLI restores filesystem transactions explicitly'); + $crash_file_restore_json = json_decode($crash_file_restore['output'], true); + assert_same($crash_file_restore_json['restored'] ?? null, 2, 'crash recovery CLI reports restored whole-branch and filesystem artifacts'); + assert_same($crash_file_restore_json['pending'] ?? null, 0, 'crash recovery CLI clears the filesystem crash queue after restore'); + assert_same( + file_get_contents($crash_file_target_root . '/wp-content/uploads/crash-file.txt'), + 'base file crash content', + 'crash recovery CLI restores the pre-merge filesystem content' + ); + assert_same( + scalar($crash_file_target_db, "SELECT post_content FROM wp_posts WHERE ID = 1"), + 'Base content', + 'crash recovery CLI restores the pre-merge DB content' + ); + } else { + assert_true(true, 'process-death crash failpoint requires POSIX SIGKILL support'); + } + $unique_base = $tmp . '/unique-base.sqlite'; $unique_source = $tmp . '/unique-source.sqlite'; $unique_target = $tmp . '/unique-target.sqlite'; @@ -2654,6 +3159,8 @@ function (SQLite3 $db, string $sql, string $message): void { assert_same(count($audit['runs']), 1, 'merge audit report can focus on one run'); assert_same((int)$audit['runs'][0]['conflict_count'], 2, 'merge audit run summary includes conflict count'); assert_same(count($audit['conflicts']), 2, 'merge audit report exports conflict records for a run'); + $title_audit_conflicts = array_values(array_filter($audit['conflicts'], fn($row) => ($row['table_name'] ?? null) === 'wp_posts' && ($row['column_name'] ?? null) === 'post_title')); + assert_same($title_audit_conflicts[0]['stale_status'] ?? null, 'fresh', 'merge audit marks unchanged target conflicts as fresh'); $title_conflict_id = (int)scalar($metadata, "SELECT id FROM merge_conflicts WHERE table_name = 'wp_posts' AND column_name = 'post_title'"); $GLOBALS['cow_merge_test_hooks']['before_sqlite_result_finalize'] = [ static function (SQLite3Result $result, string $message): void { @@ -2767,6 +3274,363 @@ static function (SQLite3Result $result, string $message): void { 'target cell no longer matches', 'stale conflict resolution is blocked when target has changed since audit' ); + $stale_cell_audit = cow_merge_audit_report($metadata, $conflict_run_id, 10, ['records' => 'conflicts']); + $stale_cell_conflicts = array_values(array_filter($stale_cell_audit['conflicts'], fn($row) => (int)($row['id'] ?? 0) === $title_conflict_id)); + assert_same($stale_cell_conflicts[0]['stale_status'] ?? null, 'stale', 'merge audit marks drifted target cell conflicts as stale'); + assert_true(str_contains((string)($stale_cell_conflicts[0]['current_target_preview'] ?? ''), 'Source title'), 'stale target cell audit exposes the current target value'); + + $revalidate_base = $tmp . '/revalidate-base.sqlite'; + $revalidate_source = $tmp . '/revalidate-source.sqlite'; + $revalidate_target = $tmp . '/revalidate-target.sqlite'; + $revalidate_metadata = $tmp . '/.forkpress/cow/merge/revalidate-metadata.sqlite'; + create_base_db($revalidate_base); + copy($revalidate_base, $revalidate_source); + copy($revalidate_base, $revalidate_target); + $db = open_db($revalidate_source); + $db->exec("UPDATE plugin_items SET value = 'source revalidate conflict' WHERE item_id = 'alpha'"); + $db->close(); + $db = open_db($revalidate_target); + $db->exec("UPDATE plugin_items SET value = 'target revalidate conflict' WHERE item_id = 'alpha'"); + $db->close(); + $revalidate_merge = cow_merge_databases($revalidate_base, $revalidate_source, $revalidate_target, $revalidate_metadata, 'feature-revalidate-review', 'main'); + $revalidate_run_id = (int)$revalidate_merge['run_id']; + $revalidate_conflict_id = (int)scalar($revalidate_metadata, "SELECT id FROM merge_conflicts WHERE table_name = 'plugin_items' AND column_name = 'value'"); + cow_merge_review_record( + $revalidate_metadata, + 'conflict', + $revalidate_conflict_id, + 'reviewed', + 'Keep target plugin value for launch.', + 'cow-test' + ); + $db = open_db($revalidate_target); + $db->exec("UPDATE plugin_items SET value = 'target drift after review' WHERE item_id = 'alpha'"); + $db->close(); + $revalidated = cow_merge_revalidate_reviewed_conflicts($revalidate_metadata, $revalidate_run_id, 'cow-revalidate'); + assert_same($revalidated['checked'], 1, 'review revalidation checks conflicts in the selected run'); + assert_same($revalidated['reviewed'], 1, 'review revalidation inspects reviewed conflicts'); + assert_same($revalidated['stale'], 1, 'review revalidation detects target drift'); + assert_same($revalidated['carried'], 1, 'review revalidation carries stale reviewer intent to needs-action'); + $revalidated_audit = cow_merge_audit_report($revalidate_metadata, $revalidate_run_id, 10, [ + 'records' => 'conflicts', + 'review_status' => 'needs-action', + ]); + assert_same(count($revalidated_audit['conflicts']), 1, 'revalidated stale reviews enter the needs-action queue'); + assert_same($revalidated_audit['conflicts'][0]['stale_status'] ?? null, 'stale', 'revalidated review keeps stale audit context visible'); + assert_true(str_contains((string)$revalidated_audit['conflicts'][0]['review_note'], 'Keep target plugin value for launch.'), 'revalidated review preserves the prior reviewer note'); + $revalidated_again = run_merge_cli([ + 'revalidate-reviews', + '--metadata-db', $revalidate_metadata, + '--run', (string)$revalidate_run_id, + '--format', 'json', + ]); + assert_same($revalidated_again['status'], 0, 'review revalidation CLI accepts already-carried stale reviews'); + $revalidated_again_json = json_decode($revalidated_again['output'], true); + assert_same($revalidated_again_json['carried'] ?? null, 0, 'review revalidation CLI does not duplicate carried notes'); + assert_same($revalidated_again_json['already_needs_action'] ?? null, 1, 'review revalidation CLI reports already-carried stale reviews'); + $audit_revalidate_help = run_merge_cli(['audit', '--help']); + assert_same($audit_revalidate_help['status'], 0, 'merge audit helper help exits successfully'); + assert_true(str_contains($audit_revalidate_help['output'], '--revalidate'), 'merge audit helper help documents revalidation shortcut'); + assert_same((int)scalar($revalidate_metadata, "SELECT COUNT(*) FROM merge_revalidations WHERE conflict_id = $revalidate_conflict_id"), 1, 'review revalidation records the stale target payload for guarded resolution'); + assert_same(scalar($revalidate_metadata, "SELECT revalidation_class FROM merge_revalidations WHERE conflict_id = $revalidate_conflict_id ORDER BY id DESC LIMIT 1"), 'compatible-target-drift', 'cell revalidation classifies same-object target drift'); + $revalidated_class_audit = cow_merge_audit_report($revalidate_metadata, $revalidate_run_id, 10, ['records' => 'conflicts']); + $revalidated_class_conflicts = array_values(array_filter($revalidated_class_audit['conflicts'], fn($row) => (int)($row['id'] ?? 0) === $revalidate_conflict_id)); + assert_same($revalidated_class_conflicts[0]['revalidation_class'] ?? null, 'compatible-target-drift', 'cell audit exposes the revalidation classifier'); + assert_throws( + fn() => cow_merge_resolve_conflict($revalidate_metadata, $revalidate_conflict_id, 'source', true, 'Try stale source apply before guarded revalidation.', 'cow-test'), + 'target cell no longer matches the audited conflict target value', + 'stale conflict resolution still fails without the after-revalidate guard' + ); + $db = open_db($revalidate_target); + $db->exec("UPDATE plugin_items SET value = 'target drift after revalidation' WHERE item_id = 'alpha'"); + $db->close(); + assert_throws( + fn() => cow_merge_resolve_conflict($revalidate_metadata, $revalidate_conflict_id, 'source', true, 'Try stale source apply after new target drift.', 'cow-test', true), + 'target payload changed after latest merge revalidation', + 'after-revalidate resolution fails if target drifted again after revalidation' + ); + $revalidated_after_drift = cow_merge_revalidate_reviewed_conflicts($revalidate_metadata, $revalidate_run_id, 'cow-revalidate'); + assert_same($revalidated_after_drift['carried'], 1, 'review revalidation carries a new needs-action note after further target drift'); + assert_same((int)scalar($revalidate_metadata, "SELECT COUNT(*) FROM merge_revalidations WHERE conflict_id = $revalidate_conflict_id"), 2, 'review revalidation records the replacement stale target payload after further drift'); + $after_revalidate_resolution = cow_merge_resolve_conflict( + $revalidate_metadata, + $revalidate_conflict_id, + 'source', + true, + 'Apply source after revalidating target drift.', + 'cow-test', + true + ); + assert_same($after_revalidate_resolution['status'], 'applied', 'after-revalidate source resolution applies a revalidated stale cell conflict'); + assert_same(scalar($revalidate_target, "SELECT value FROM plugin_items WHERE item_id = 'alpha'"), 'source revalidate conflict', 'after-revalidate source resolution writes the audited source value'); + assert_same( + scalar($revalidate_metadata, "SELECT previous_payload FROM merge_resolutions WHERE conflict_id = $revalidate_conflict_id ORDER BY id DESC LIMIT 1"), + cow_merge_payload_json('target drift after revalidation'), + 'after-revalidate resolution audits the latest revalidated target payload' + ); + + $missing_cell_base = $tmp . '/missing-cell-base.sqlite'; + $missing_cell_source = $tmp . '/missing-cell-source.sqlite'; + $missing_cell_target = $tmp . '/missing-cell-target.sqlite'; + $missing_cell_metadata = $tmp . '/.forkpress/cow/merge/missing-cell-metadata.sqlite'; + create_base_db($missing_cell_base); + copy($missing_cell_base, $missing_cell_source); + copy($missing_cell_base, $missing_cell_target); + $db = open_db($missing_cell_source); + $db->exec("UPDATE plugin_items SET value = 'source missing-cell conflict' WHERE item_id = 'alpha'"); + $db->close(); + $db = open_db($missing_cell_target); + $db->exec("UPDATE plugin_items SET value = 'target missing-cell conflict' WHERE item_id = 'alpha'"); + $db->close(); + $missing_cell_merge = cow_merge_databases($missing_cell_base, $missing_cell_source, $missing_cell_target, $missing_cell_metadata, 'feature-missing-cell-review', 'main'); + $missing_cell_run_id = (int)$missing_cell_merge['run_id']; + $missing_cell_conflict_id = (int)scalar($missing_cell_metadata, "SELECT id FROM merge_conflicts WHERE table_name = 'plugin_items' AND column_name = 'value'"); + cow_merge_review_record( + $missing_cell_metadata, + 'conflict', + $missing_cell_conflict_id, + 'reviewed', + 'Revalidate before applying a row that may disappear.', + 'cow-test' + ); + $db = open_db($missing_cell_target); + $db->exec("DELETE FROM plugin_items WHERE item_id = 'alpha'"); + $db->close(); + $missing_cell_revalidated = cow_merge_revalidate_reviewed_conflicts($missing_cell_metadata, $missing_cell_run_id, 'cow-revalidate'); + assert_same($missing_cell_revalidated['carried'], 1, 'review revalidation carries missing target cell rows back to needs-action'); + assert_same(scalar($missing_cell_metadata, "SELECT revalidation_class FROM merge_revalidations WHERE conflict_id = $missing_cell_conflict_id ORDER BY id DESC LIMIT 1"), 'missing', 'cell revalidation classifies deleted target rows as missing'); + $missing_cell_audit = cow_merge_audit_report($missing_cell_metadata, $missing_cell_run_id, 10, ['records' => 'conflicts']); + $missing_cell_conflicts = array_values(array_filter($missing_cell_audit['conflicts'], fn($row) => (int)($row['id'] ?? 0) === $missing_cell_conflict_id)); + assert_same($missing_cell_conflicts[0]['revalidation_class'] ?? null, 'missing', 'cell audit exposes missing target row revalidation class'); + assert_throws( + fn() => cow_merge_resolve_conflict($missing_cell_metadata, $missing_cell_conflict_id, 'source', true, 'Try source after missing-row revalidation.', 'cow-test', true), + 'target row no longer exists', + 'after-revalidate cell resolution does not recreate a missing target row through a cell update' + ); + + $source_drift_base = $tmp . '/source-drift-base.sqlite'; + $source_drift_source = $tmp . '/source-drift-source.sqlite'; + $source_drift_target = $tmp . '/source-drift-target.sqlite'; + $source_drift_metadata = $tmp . '/.forkpress/cow/merge/source-drift-metadata.sqlite'; + create_base_db($source_drift_base); + copy($source_drift_base, $source_drift_source); + copy($source_drift_base, $source_drift_target); + $db = open_db($source_drift_source); + $db->exec("UPDATE plugin_items SET value = 'source drift original conflict' WHERE item_id = 'alpha'"); + $db->close(); + $db = open_db($source_drift_target); + $db->exec("UPDATE plugin_items SET value = 'target source-drift conflict' WHERE item_id = 'alpha'"); + $db->close(); + $source_drift_merge = cow_merge_databases($source_drift_base, $source_drift_source, $source_drift_target, $source_drift_metadata, 'feature-source-drift-review', 'main'); + $source_drift_run_id = (int)$source_drift_merge['run_id']; + $source_drift_conflict_id = (int)scalar($source_drift_metadata, "SELECT id FROM merge_conflicts WHERE table_name = 'plugin_items' AND column_name = 'value'"); + cow_merge_review_record( + $source_drift_metadata, + 'conflict', + $source_drift_conflict_id, + 'reviewed', + 'Apply source after confirming it still matches review.', + 'cow-test' + ); + $db = open_db($source_drift_source); + $db->exec("UPDATE plugin_items SET value = 'source drift after review' WHERE item_id = 'alpha'"); + $db->close(); + $source_drift_revalidated = cow_merge_revalidate_reviewed_conflicts($source_drift_metadata, $source_drift_run_id, 'cow-revalidate'); + assert_same($source_drift_revalidated['carried'], 1, 'review revalidation carries source-drifted cell conflicts to needs-action'); + assert_same(scalar($source_drift_metadata, "SELECT revalidation_class FROM merge_revalidations WHERE conflict_id = $source_drift_conflict_id ORDER BY id DESC LIMIT 1"), 'compatible-source-drift', 'cell revalidation classifies changed source payloads'); + assert_same(scalar($source_drift_metadata, "SELECT source_payload FROM merge_revalidations WHERE conflict_id = $source_drift_conflict_id ORDER BY id DESC LIMIT 1"), cow_merge_payload_json('source drift after review'), 'source-drift revalidation records the current source payload'); + $source_drift_audit = cow_merge_audit_report($source_drift_metadata, $source_drift_run_id, 10, ['records' => 'conflicts']); + $source_drift_conflicts = array_values(array_filter($source_drift_audit['conflicts'], fn($row) => (int)($row['id'] ?? 0) === $source_drift_conflict_id)); + assert_same($source_drift_conflicts[0]['revalidation_class'] ?? null, 'compatible-source-drift', 'cell audit exposes source-drift revalidation class'); + assert_throws( + fn() => cow_merge_resolve_conflict($source_drift_metadata, $source_drift_conflict_id, 'source', true, 'Try source after source-drift revalidation.', 'cow-test', true), + 'source payload changed after latest merge revalidation', + 'after-revalidate source resolution fails if the source payload changed after review' + ); + + $row_source_drift_base = $tmp . '/row-source-drift-base.sqlite'; + $row_source_drift_source = $tmp . '/row-source-drift-source.sqlite'; + $row_source_drift_target = $tmp . '/row-source-drift-target.sqlite'; + $row_source_drift_metadata = $tmp . '/.forkpress/cow/merge/row-source-drift-metadata.sqlite'; + create_base_db($row_source_drift_base); + copy($row_source_drift_base, $row_source_drift_source); + copy($row_source_drift_base, $row_source_drift_target); + $db = open_db($row_source_drift_source); + $db->exec("INSERT INTO plugin_items (item_id, label, value) VALUES ('source-drift-row', 'source row original label', 'source row original value')"); + $db->close(); + $db = open_db($row_source_drift_target); + $db->exec("INSERT INTO plugin_items (item_id, label, value) VALUES ('source-drift-row', 'target row conflict label', 'target row conflict value')"); + $db->close(); + $row_source_drift_merge = cow_merge_databases($row_source_drift_base, $row_source_drift_source, $row_source_drift_target, $row_source_drift_metadata, 'feature-row-source-drift-review', 'main'); + $row_source_drift_run_id = (int)$row_source_drift_merge['run_id']; + $row_source_drift_conflict_id = (int)scalar($row_source_drift_metadata, "SELECT id FROM merge_conflicts WHERE table_name = 'plugin_items' AND row_identity = '" . SQLite3::escapeString(cow_merge_identity_json(['item_id' => 'source-drift-row'])) . "'"); + cow_merge_review_record( + $row_source_drift_metadata, + 'conflict', + $row_source_drift_conflict_id, + 'reviewed', + 'Apply source row after confirming it still matches review.', + 'cow-test' + ); + $db = open_db($row_source_drift_source); + $db->exec("UPDATE plugin_items SET label = 'source row drifted label', value = 'source row drifted value' WHERE item_id = 'source-drift-row'"); + $db->close(); + $row_source_drift_revalidated = cow_merge_revalidate_reviewed_conflicts($row_source_drift_metadata, $row_source_drift_run_id, 'cow-revalidate'); + assert_same($row_source_drift_revalidated['carried'], 1, 'review revalidation carries source-drifted row conflicts to needs-action'); + assert_same(scalar($row_source_drift_metadata, "SELECT revalidation_class FROM merge_revalidations WHERE conflict_id = $row_source_drift_conflict_id ORDER BY id DESC LIMIT 1"), 'compatible-source-drift', 'row revalidation classifies changed source payloads'); + assert_same( + scalar($row_source_drift_metadata, "SELECT source_payload FROM merge_revalidations WHERE conflict_id = $row_source_drift_conflict_id ORDER BY id DESC LIMIT 1"), + cow_merge_payload_json(['item_id' => 'source-drift-row', 'label' => 'source row drifted label', 'value' => 'source row drifted value']), + 'row source-drift revalidation records the current source row payload' + ); + $row_source_drift_audit = cow_merge_audit_report($row_source_drift_metadata, $row_source_drift_run_id, 10, ['records' => 'conflicts']); + $row_source_drift_conflicts = array_values(array_filter($row_source_drift_audit['conflicts'], fn($row) => (int)($row['id'] ?? 0) === $row_source_drift_conflict_id)); + assert_same($row_source_drift_conflicts[0]['revalidation_class'] ?? null, 'compatible-source-drift', 'row audit exposes source-drift revalidation class'); + assert_throws( + fn() => cow_merge_resolve_conflict($row_source_drift_metadata, $row_source_drift_conflict_id, 'source', true, 'Try source row after source-drift revalidation.', 'cow-test', true), + 'source payload changed after latest merge revalidation', + 'after-revalidate row resolution fails if the source row changed after review' + ); + + $source_semantic_identity_cases = [ + 'post' => [ + 'table' => 'wp_posts', + 'create' => 'CREATE TABLE wp_posts (ID INTEGER PRIMARY KEY, post_type TEXT, post_title TEXT, post_content TEXT)', + 'source_insert' => "INSERT INTO wp_posts (ID, post_type, post_title, post_content) VALUES (110, 'page', 'Source reviewed page', 'source reviewed page content')", + 'target_insert' => "INSERT INTO wp_posts (ID, post_type, post_title, post_content) VALUES (110, 'page', 'Target reviewed page', 'target reviewed page content')", + 'source_update' => "UPDATE wp_posts SET post_type = 'attachment', post_title = 'Source replacement attachment', post_content = 'source replacement attachment content' WHERE ID = 110", + 'branch' => 'feature-source-post-identity-review', + 'label' => 'source post_type', + ], + 'option' => [ + 'table' => 'wp_options', + 'create' => 'CREATE TABLE wp_options (option_id INTEGER PRIMARY KEY, option_name TEXT, option_value TEXT)', + 'source_insert' => "INSERT INTO wp_options (option_id, option_name, option_value) VALUES (120, 'source_setting', 'source value')", + 'target_insert' => "INSERT INTO wp_options (option_id, option_name, option_value) VALUES (120, 'target_setting', 'target value')", + 'source_update' => "UPDATE wp_options SET option_name = 'replacement_setting', option_value = 'replacement value' WHERE option_id = 120", + 'branch' => 'feature-source-option-identity-review', + 'label' => 'source option_name', + ], + 'postmeta' => [ + 'table' => 'wp_postmeta', + 'create' => 'CREATE TABLE wp_postmeta (meta_id INTEGER PRIMARY KEY, post_id INTEGER, meta_key TEXT, meta_value TEXT)', + 'source_insert' => "INSERT INTO wp_postmeta (meta_id, post_id, meta_key, meta_value) VALUES (130, 10, 'source_key', 'source value')", + 'target_insert' => "INSERT INTO wp_postmeta (meta_id, post_id, meta_key, meta_value) VALUES (130, 10, 'target_key', 'target value')", + 'source_update' => "UPDATE wp_postmeta SET post_id = 11, meta_key = 'replacement_key', meta_value = 'replacement value' WHERE meta_id = 130", + 'branch' => 'feature-source-postmeta-identity-review', + 'label' => 'source post_id/meta_key', + ], + 'term' => [ + 'table' => 'wp_terms', + 'create' => 'CREATE TABLE wp_terms (term_id INTEGER PRIMARY KEY, name TEXT, slug TEXT, term_group INTEGER)', + 'source_insert' => "INSERT INTO wp_terms (term_id, name, slug, term_group) VALUES (140, 'Source term', 'source-term', 0)", + 'target_insert' => "INSERT INTO wp_terms (term_id, name, slug, term_group) VALUES (140, 'Target term', 'target-term', 0)", + 'source_update' => "UPDATE wp_terms SET name = 'Replacement term', slug = 'replacement-term' WHERE term_id = 140", + 'branch' => 'feature-source-term-identity-review', + 'label' => 'source term slug', + ], + 'term-taxonomy' => [ + 'table' => 'wp_term_taxonomy', + 'create' => 'CREATE TABLE wp_term_taxonomy (term_taxonomy_id INTEGER PRIMARY KEY, term_id INTEGER, taxonomy TEXT, description TEXT)', + 'source_insert' => "INSERT INTO wp_term_taxonomy (term_taxonomy_id, term_id, taxonomy, description) VALUES (150, 40, 'category', 'source taxonomy')", + 'target_insert' => "INSERT INTO wp_term_taxonomy (term_taxonomy_id, term_id, taxonomy, description) VALUES (150, 40, 'post_tag', 'target taxonomy')", + 'source_update' => "UPDATE wp_term_taxonomy SET term_id = 41, taxonomy = 'nav_menu', description = 'replacement taxonomy' WHERE term_taxonomy_id = 150", + 'branch' => 'feature-source-term-taxonomy-identity-review', + 'label' => 'source term_id/taxonomy', + ], + 'termmeta' => [ + 'table' => 'wp_termmeta', + 'create' => 'CREATE TABLE wp_termmeta (meta_id INTEGER PRIMARY KEY, term_id INTEGER, meta_key TEXT, meta_value TEXT)', + 'source_insert' => "INSERT INTO wp_termmeta (meta_id, term_id, meta_key, meta_value) VALUES (160, 40, 'source_key', 'source value')", + 'target_insert' => "INSERT INTO wp_termmeta (meta_id, term_id, meta_key, meta_value) VALUES (160, 40, 'target_key', 'target value')", + 'source_update' => "UPDATE wp_termmeta SET term_id = 41, meta_key = 'replacement_key', meta_value = 'replacement value' WHERE meta_id = 160", + 'branch' => 'feature-source-termmeta-identity-review', + 'label' => 'source term_id/meta_key', + ], + 'user' => [ + 'table' => 'wp_users', + 'create' => 'CREATE TABLE wp_users (ID INTEGER PRIMARY KEY, user_login TEXT, user_email TEXT)', + 'source_insert' => "INSERT INTO wp_users (ID, user_login, user_email) VALUES (170, 'source_user', 'source@example.test')", + 'target_insert' => "INSERT INTO wp_users (ID, user_login, user_email) VALUES (170, 'target_user', 'target@example.test')", + 'source_update' => "UPDATE wp_users SET user_login = 'replacement_user', user_email = 'replacement@example.test' WHERE ID = 170", + 'branch' => 'feature-source-user-identity-review', + 'label' => 'source user_login', + ], + 'usermeta' => [ + 'table' => 'wp_usermeta', + 'create' => 'CREATE TABLE wp_usermeta (umeta_id INTEGER PRIMARY KEY, user_id INTEGER, meta_key TEXT, meta_value TEXT)', + 'source_insert' => "INSERT INTO wp_usermeta (umeta_id, user_id, meta_key, meta_value) VALUES (180, 70, 'source_key', 'source value')", + 'target_insert' => "INSERT INTO wp_usermeta (umeta_id, user_id, meta_key, meta_value) VALUES (180, 70, 'target_key', 'target value')", + 'source_update' => "UPDATE wp_usermeta SET user_id = 71, meta_key = 'replacement_key', meta_value = 'replacement value' WHERE umeta_id = 180", + 'branch' => 'feature-source-usermeta-identity-review', + 'label' => 'source user_id/meta_key', + ], + 'comment' => [ + 'table' => 'wp_comments', + 'create' => 'CREATE TABLE wp_comments (comment_ID INTEGER PRIMARY KEY, comment_post_ID INTEGER, comment_type TEXT, comment_content TEXT)', + 'source_insert' => "INSERT INTO wp_comments (comment_ID, comment_post_ID, comment_type, comment_content) VALUES (190, 10, 'comment', 'source comment')", + 'target_insert' => "INSERT INTO wp_comments (comment_ID, comment_post_ID, comment_type, comment_content) VALUES (190, 10, 'review', 'target comment')", + 'source_update' => "UPDATE wp_comments SET comment_post_ID = 11, comment_type = 'pingback', comment_content = 'replacement comment' WHERE comment_ID = 190", + 'branch' => 'feature-source-comment-identity-review', + 'label' => 'source comment_post_ID/comment_type', + ], + 'commentmeta' => [ + 'table' => 'wp_commentmeta', + 'create' => 'CREATE TABLE wp_commentmeta (meta_id INTEGER PRIMARY KEY, comment_id INTEGER, meta_key TEXT, meta_value TEXT)', + 'source_insert' => "INSERT INTO wp_commentmeta (meta_id, comment_id, meta_key, meta_value) VALUES (200, 90, 'source_key', 'source value')", + 'target_insert' => "INSERT INTO wp_commentmeta (meta_id, comment_id, meta_key, meta_value) VALUES (200, 90, 'target_key', 'target value')", + 'source_update' => "UPDATE wp_commentmeta SET comment_id = 91, meta_key = 'replacement_key', meta_value = 'replacement value' WHERE meta_id = 200", + 'branch' => 'feature-source-commentmeta-identity-review', + 'label' => 'source comment_id/meta_key', + ], + ]; + foreach ($source_semantic_identity_cases as $case_name => $case) { + $case_base = $tmp . "/source-semantic-$case_name-base.sqlite"; + $case_source = $tmp . "/source-semantic-$case_name-source.sqlite"; + $case_target = $tmp . "/source-semantic-$case_name-target.sqlite"; + $case_metadata = $tmp . "/.forkpress/cow/merge/source-semantic-$case_name-metadata.sqlite"; + foreach ([$case_base, $case_source, $case_target] as $path) { + $db = open_db($path); + $db->exec($case['create']); + $db->close(); + } + $db = open_db($case_source); + $db->exec($case['source_insert']); + $db->close(); + $db = open_db($case_target); + $db->exec($case['target_insert']); + $db->close(); + $case_merge = cow_merge_databases($case_base, $case_source, $case_target, $case_metadata, $case['branch'], 'main'); + $case_run_id = (int)$case_merge['run_id']; + assert_same($case_merge['status'], 'completed_with_conflicts', $case['label'] . ' source semantic identity fixture starts with a same-ID row conflict'); + $case_conflict_id = (int)scalar($case_metadata, "SELECT id FROM merge_conflicts WHERE table_name = '{$case['table']}' AND conflict_type = 'row-insert-collision'"); + cow_merge_review_record( + $case_metadata, + 'conflict', + $case_conflict_id, + 'reviewed', + 'Apply reviewed source row only if it is still the same semantic object.', + 'cow-test' + ); + $db = open_db($case_source); + $db->exec($case['source_update']); + $db->close(); + $case_revalidated = cow_merge_revalidate_reviewed_conflicts($case_metadata, $case_run_id, 'cow-revalidate'); + assert_same($case_revalidated['carried'], 1, $case['label'] . ' source semantic replacement is carried to needs-action'); + assert_same(scalar($case_metadata, "SELECT revalidation_class FROM merge_revalidations WHERE conflict_id = $case_conflict_id ORDER BY id DESC LIMIT 1"), 'incompatible', $case['label'] . ' source semantic replacement is classified as incompatible'); + $case_audit = cow_merge_audit_report($case_metadata, $case_run_id, 10, ['records' => 'conflicts']); + $case_conflicts = array_values(array_filter($case_audit['conflicts'], fn($row) => (int)($row['id'] ?? 0) === $case_conflict_id)); + assert_same($case_conflicts[0]['revalidation_class'] ?? null, 'incompatible', $case['label'] . ' source audit exposes incompatible semantic replacement'); + assert_true(str_contains((string)($case_conflicts[0]['stale_reason'] ?? ''), 'source row semantic identity'), $case['label'] . ' source stale reason explains semantic identity drift'); + if ($case_name === 'post') { + assert_throws( + fn() => cow_merge_resolve_conflict($case_metadata, $case_conflict_id, 'source', true, 'Do not apply reviewed source row after source replacement.', 'cow-test', true), + 'latest merge revalidation is incompatible', + 'after-revalidate blocks source resolution from an incompatible source semantic replacement' + ); + } + } + $row_resolution_rollback_base = $tmp . '/row-resolution-rollback-base.sqlite'; $row_resolution_rollback_source = $tmp . '/row-resolution-rollback-source.sqlite'; $row_resolution_rollback_target = $tmp . '/row-resolution-rollback-target.sqlite'; @@ -3214,6 +4078,291 @@ static function (SQLite3Result $result, string $message): void { 'stale row conflict resolution is blocked when target row has changed since audit' ); + $row_revalidate_base = $tmp . '/row-revalidate-base.sqlite'; + $row_revalidate_source = $tmp . '/row-revalidate-source.sqlite'; + $row_revalidate_target = $tmp . '/row-revalidate-target.sqlite'; + $row_revalidate_metadata = $tmp . '/.forkpress/cow/merge/row-revalidate-metadata.sqlite'; + create_base_db($row_revalidate_base); + copy($row_revalidate_base, $row_revalidate_source); + copy($row_revalidate_base, $row_revalidate_target); + $db = open_db($row_revalidate_source); + $db->exec("INSERT INTO plugin_items (item_id, label, value) VALUES ('row-revalidate', 'Source row revalidate', 'source row revalidate')"); + $db->close(); + $db = open_db($row_revalidate_target); + $db->exec("INSERT INTO plugin_items (item_id, label, value) VALUES ('row-revalidate', 'Target row revalidate', 'target row revalidate')"); + $db->close(); + $row_revalidate_merge = cow_merge_databases($row_revalidate_base, $row_revalidate_source, $row_revalidate_target, $row_revalidate_metadata, 'feature-row-revalidate', 'main'); + $row_revalidate_run_id = (int)$row_revalidate_merge['run_id']; + $row_revalidate_conflict_id = (int)scalar($row_revalidate_metadata, "SELECT id FROM merge_conflicts WHERE table_name = 'plugin_items' AND conflict_type = 'row-insert-collision'"); + cow_merge_review_record( + $row_revalidate_metadata, + 'conflict', + $row_revalidate_conflict_id, + 'reviewed', + 'Keep target plugin row until revalidation.', + 'cow-test' + ); + $db = open_db($row_revalidate_target); + $db->exec("UPDATE plugin_items SET label = 'Target row drift after review', value = 'target row drift after review' WHERE item_id = 'row-revalidate'"); + $db->close(); + $row_revalidated = cow_merge_revalidate_reviewed_conflicts($row_revalidate_metadata, $row_revalidate_run_id, 'cow-revalidate'); + assert_same($row_revalidated['carried'], 1, 'review revalidation carries stale row conflicts to needs-action'); + assert_same((int)scalar($row_revalidate_metadata, "SELECT COUNT(*) FROM merge_revalidations WHERE conflict_id = $row_revalidate_conflict_id"), 1, 'row revalidation records the stale target row payload'); + assert_same(scalar($row_revalidate_metadata, "SELECT revalidation_class FROM merge_revalidations WHERE conflict_id = $row_revalidate_conflict_id ORDER BY id DESC LIMIT 1"), 'compatible-target-drift', 'row revalidation classifies same-identity target drift'); + assert_throws( + fn() => cow_merge_resolve_conflict($row_revalidate_metadata, $row_revalidate_conflict_id, 'source', true, 'Try stale row source before guarded revalidation.', 'cow-test'), + 'target row no longer matches', + 'stale row source resolution still fails without the after-revalidate guard' + ); + $db = open_db($row_revalidate_target); + $db->exec("UPDATE plugin_items SET label = 'Target row drift after revalidation', value = 'target row drift after revalidation' WHERE item_id = 'row-revalidate'"); + $db->close(); + assert_throws( + fn() => cow_merge_resolve_conflict($row_revalidate_metadata, $row_revalidate_conflict_id, 'source', true, 'Try stale row source after new target drift.', 'cow-test', true), + 'target payload changed after latest merge revalidation', + 'after-revalidate row resolution fails if target drifted again after revalidation' + ); + $row_revalidated_after_drift = cow_merge_revalidate_reviewed_conflicts($row_revalidate_metadata, $row_revalidate_run_id, 'cow-revalidate'); + assert_same($row_revalidated_after_drift['carried'], 1, 'review revalidation carries a new row note after further target drift'); + assert_same((int)scalar($row_revalidate_metadata, "SELECT COUNT(*) FROM merge_revalidations WHERE conflict_id = $row_revalidate_conflict_id"), 2, 'row revalidation records the replacement stale target row payload'); + $row_revalidated_target_payload = cow_merge_payload_json([ + 'item_id' => 'row-revalidate', + 'label' => 'Target row drift after revalidation', + 'value' => 'target row drift after revalidation', + ]); + $row_after_revalidate_resolution = cow_merge_resolve_conflict( + $row_revalidate_metadata, + $row_revalidate_conflict_id, + 'source', + true, + 'Apply source row after revalidating target drift.', + 'cow-test', + true + ); + assert_same($row_after_revalidate_resolution['status'], 'applied', 'after-revalidate source resolution applies a revalidated stale row conflict'); + assert_same(scalar($row_revalidate_target, "SELECT label FROM plugin_items WHERE item_id = 'row-revalidate'"), 'Source row revalidate', 'after-revalidate row resolution writes the audited source row'); + assert_same( + scalar($row_revalidate_metadata, "SELECT previous_payload FROM merge_resolutions WHERE conflict_id = $row_revalidate_conflict_id ORDER BY id DESC LIMIT 1"), + $row_revalidated_target_payload, + 'after-revalidate row resolution audits the latest revalidated target row payload' + ); + + $post_identity_base = $tmp . '/post-identity-base.sqlite'; + $post_identity_source = $tmp . '/post-identity-source.sqlite'; + $post_identity_target = $tmp . '/post-identity-target.sqlite'; + $post_identity_metadata = $tmp . '/.forkpress/cow/merge/post-identity-metadata.sqlite'; + foreach ([$post_identity_base, $post_identity_source, $post_identity_target] as $path) { + $db = open_db($path); + $db->exec('CREATE TABLE wp_posts (ID INTEGER PRIMARY KEY, post_type TEXT, post_title TEXT, post_content TEXT)'); + $db->close(); + } + $db = open_db($post_identity_source); + $db->exec("INSERT INTO wp_posts (ID, post_type, post_title, post_content) VALUES (10, 'page', 'Source page', 'source page content')"); + $db->close(); + $db = open_db($post_identity_target); + $db->exec("INSERT INTO wp_posts (ID, post_type, post_title, post_content) VALUES (10, 'page', 'Target page', 'target page content')"); + $db->close(); + $post_identity_merge = cow_merge_databases($post_identity_base, $post_identity_source, $post_identity_target, $post_identity_metadata, 'feature-post-identity-review', 'main'); + $post_identity_run_id = (int)$post_identity_merge['run_id']; + assert_same($post_identity_merge['status'], 'completed_with_conflicts', 'post semantic identity fixture starts with a same-ID row conflict'); + $post_identity_conflict_id = (int)scalar($post_identity_metadata, "SELECT id FROM merge_conflicts WHERE table_name = 'wp_posts' AND conflict_type = 'row-insert-collision'"); + cow_merge_review_record( + $post_identity_metadata, + 'conflict', + $post_identity_conflict_id, + 'reviewed', + 'Review source page before applying over target page.', + 'cow-test' + ); + $db = open_db($post_identity_target); + $db->exec("UPDATE wp_posts SET post_type = 'attachment', post_title = 'Target attachment', post_content = 'target attachment content' WHERE ID = 10"); + $db->close(); + $post_identity_revalidated = cow_merge_revalidate_reviewed_conflicts($post_identity_metadata, $post_identity_run_id, 'cow-revalidate'); + assert_same($post_identity_revalidated['carried'], 1, 'review revalidation carries semantically replaced post rows to needs-action'); + assert_same(scalar($post_identity_metadata, "SELECT revalidation_class FROM merge_revalidations WHERE conflict_id = $post_identity_conflict_id ORDER BY id DESC LIMIT 1"), 'incompatible', 'post row revalidation classifies changed post_type as incompatible'); + $post_identity_audit = cow_merge_audit_report($post_identity_metadata, $post_identity_run_id, 10, ['records' => 'conflicts']); + $post_identity_conflicts = array_values(array_filter($post_identity_audit['conflicts'], fn($row) => (int)($row['id'] ?? 0) === $post_identity_conflict_id)); + assert_same($post_identity_conflicts[0]['revalidation_class'] ?? null, 'incompatible', 'post row audit exposes incompatible semantic replacement'); + assert_true(str_contains((string)($post_identity_conflicts[0]['stale_reason'] ?? ''), 'semantic identity'), 'post row stale reason explains semantic identity drift'); + assert_throws( + fn() => cow_merge_resolve_conflict($post_identity_metadata, $post_identity_conflict_id, 'source', true, 'Do not apply source page over replacement attachment.', 'cow-test', true), + 'latest merge revalidation is incompatible', + 'after-revalidate blocks source resolution over an incompatible post semantic replacement' + ); + + $semantic_identity_cases = [ + 'option' => [ + 'table' => 'wp_options', + 'create' => 'CREATE TABLE wp_options (option_id INTEGER PRIMARY KEY, option_name TEXT, option_value TEXT)', + 'source_insert' => "INSERT INTO wp_options (option_id, option_name, option_value) VALUES (20, 'source_setting', 'source value')", + 'target_insert' => "INSERT INTO wp_options (option_id, option_name, option_value) VALUES (20, 'target_setting', 'target value')", + 'target_update' => "UPDATE wp_options SET option_name = 'replacement_setting', option_value = 'replacement value' WHERE option_id = 20", + 'branch' => 'feature-option-identity-review', + 'label' => 'option_name', + ], + 'postmeta' => [ + 'table' => 'wp_postmeta', + 'create' => 'CREATE TABLE wp_postmeta (meta_id INTEGER PRIMARY KEY, post_id INTEGER, meta_key TEXT, meta_value TEXT)', + 'source_insert' => "INSERT INTO wp_postmeta (meta_id, post_id, meta_key, meta_value) VALUES (30, 10, 'source_key', 'source value')", + 'target_insert' => "INSERT INTO wp_postmeta (meta_id, post_id, meta_key, meta_value) VALUES (30, 10, 'target_key', 'target value')", + 'target_update' => "UPDATE wp_postmeta SET post_id = 11, meta_key = 'replacement_key', meta_value = 'replacement value' WHERE meta_id = 30", + 'branch' => 'feature-postmeta-identity-review', + 'label' => 'post_id/meta_key', + ], + 'term' => [ + 'table' => 'wp_terms', + 'create' => 'CREATE TABLE wp_terms (term_id INTEGER PRIMARY KEY, name TEXT, slug TEXT, term_group INTEGER)', + 'source_insert' => "INSERT INTO wp_terms (term_id, name, slug, term_group) VALUES (40, 'Source term', 'source-term', 0)", + 'target_insert' => "INSERT INTO wp_terms (term_id, name, slug, term_group) VALUES (40, 'Target term', 'target-term', 0)", + 'target_update' => "UPDATE wp_terms SET name = 'Replacement term', slug = 'replacement-term' WHERE term_id = 40", + 'branch' => 'feature-term-identity-review', + 'label' => 'term slug', + ], + 'term-taxonomy' => [ + 'table' => 'wp_term_taxonomy', + 'create' => 'CREATE TABLE wp_term_taxonomy (term_taxonomy_id INTEGER PRIMARY KEY, term_id INTEGER, taxonomy TEXT, description TEXT)', + 'source_insert' => "INSERT INTO wp_term_taxonomy (term_taxonomy_id, term_id, taxonomy, description) VALUES (50, 40, 'category', 'source taxonomy')", + 'target_insert' => "INSERT INTO wp_term_taxonomy (term_taxonomy_id, term_id, taxonomy, description) VALUES (50, 40, 'post_tag', 'target taxonomy')", + 'target_update' => "UPDATE wp_term_taxonomy SET term_id = 41, taxonomy = 'nav_menu', description = 'replacement taxonomy' WHERE term_taxonomy_id = 50", + 'branch' => 'feature-term-taxonomy-identity-review', + 'label' => 'term_id/taxonomy', + ], + 'termmeta' => [ + 'table' => 'wp_termmeta', + 'create' => 'CREATE TABLE wp_termmeta (meta_id INTEGER PRIMARY KEY, term_id INTEGER, meta_key TEXT, meta_value TEXT)', + 'source_insert' => "INSERT INTO wp_termmeta (meta_id, term_id, meta_key, meta_value) VALUES (60, 40, 'source_key', 'source value')", + 'target_insert' => "INSERT INTO wp_termmeta (meta_id, term_id, meta_key, meta_value) VALUES (60, 40, 'target_key', 'target value')", + 'target_update' => "UPDATE wp_termmeta SET term_id = 41, meta_key = 'replacement_key', meta_value = 'replacement value' WHERE meta_id = 60", + 'branch' => 'feature-termmeta-identity-review', + 'label' => 'term_id/meta_key', + ], + 'user' => [ + 'table' => 'wp_users', + 'create' => 'CREATE TABLE wp_users (ID INTEGER PRIMARY KEY, user_login TEXT, user_email TEXT)', + 'source_insert' => "INSERT INTO wp_users (ID, user_login, user_email) VALUES (70, 'source_user', 'source@example.test')", + 'target_insert' => "INSERT INTO wp_users (ID, user_login, user_email) VALUES (70, 'target_user', 'target@example.test')", + 'target_update' => "UPDATE wp_users SET user_login = 'replacement_user', user_email = 'replacement@example.test' WHERE ID = 70", + 'branch' => 'feature-user-identity-review', + 'label' => 'user_login', + ], + 'usermeta' => [ + 'table' => 'wp_usermeta', + 'create' => 'CREATE TABLE wp_usermeta (umeta_id INTEGER PRIMARY KEY, user_id INTEGER, meta_key TEXT, meta_value TEXT)', + 'source_insert' => "INSERT INTO wp_usermeta (umeta_id, user_id, meta_key, meta_value) VALUES (80, 70, 'source_key', 'source value')", + 'target_insert' => "INSERT INTO wp_usermeta (umeta_id, user_id, meta_key, meta_value) VALUES (80, 70, 'target_key', 'target value')", + 'target_update' => "UPDATE wp_usermeta SET user_id = 71, meta_key = 'replacement_key', meta_value = 'replacement value' WHERE umeta_id = 80", + 'branch' => 'feature-usermeta-identity-review', + 'label' => 'user_id/meta_key', + ], + 'comment' => [ + 'table' => 'wp_comments', + 'create' => 'CREATE TABLE wp_comments (comment_ID INTEGER PRIMARY KEY, comment_post_ID INTEGER, comment_type TEXT, comment_content TEXT)', + 'source_insert' => "INSERT INTO wp_comments (comment_ID, comment_post_ID, comment_type, comment_content) VALUES (90, 10, 'comment', 'source comment')", + 'target_insert' => "INSERT INTO wp_comments (comment_ID, comment_post_ID, comment_type, comment_content) VALUES (90, 10, 'review', 'target comment')", + 'target_update' => "UPDATE wp_comments SET comment_post_ID = 11, comment_type = 'pingback', comment_content = 'replacement comment' WHERE comment_ID = 90", + 'branch' => 'feature-comment-identity-review', + 'label' => 'comment_post_ID/comment_type', + ], + 'commentmeta' => [ + 'table' => 'wp_commentmeta', + 'create' => 'CREATE TABLE wp_commentmeta (meta_id INTEGER PRIMARY KEY, comment_id INTEGER, meta_key TEXT, meta_value TEXT)', + 'source_insert' => "INSERT INTO wp_commentmeta (meta_id, comment_id, meta_key, meta_value) VALUES (100, 90, 'source_key', 'source value')", + 'target_insert' => "INSERT INTO wp_commentmeta (meta_id, comment_id, meta_key, meta_value) VALUES (100, 90, 'target_key', 'target value')", + 'target_update' => "UPDATE wp_commentmeta SET comment_id = 91, meta_key = 'replacement_key', meta_value = 'replacement value' WHERE meta_id = 100", + 'branch' => 'feature-commentmeta-identity-review', + 'label' => 'comment_id/meta_key', + ], + ]; + foreach ($semantic_identity_cases as $case_name => $case) { + $case_base = $tmp . "/semantic-$case_name-base.sqlite"; + $case_source = $tmp . "/semantic-$case_name-source.sqlite"; + $case_target = $tmp . "/semantic-$case_name-target.sqlite"; + $case_metadata = $tmp . "/.forkpress/cow/merge/semantic-$case_name-metadata.sqlite"; + foreach ([$case_base, $case_source, $case_target] as $path) { + $db = open_db($path); + $db->exec($case['create']); + $db->close(); + } + $db = open_db($case_source); + $db->exec($case['source_insert']); + $db->close(); + $db = open_db($case_target); + $db->exec($case['target_insert']); + $db->close(); + $case_merge = cow_merge_databases($case_base, $case_source, $case_target, $case_metadata, $case['branch'], 'main'); + $case_run_id = (int)$case_merge['run_id']; + assert_same($case_merge['status'], 'completed_with_conflicts', $case['label'] . ' semantic identity fixture starts with a same-ID row conflict'); + $case_conflict_id = (int)scalar($case_metadata, "SELECT id FROM merge_conflicts WHERE table_name = '{$case['table']}' AND conflict_type = 'row-insert-collision'"); + cow_merge_review_record( + $case_metadata, + 'conflict', + $case_conflict_id, + 'reviewed', + 'Review source row before applying over target semantic identity.', + 'cow-test' + ); + $db = open_db($case_target); + $db->exec($case['target_update']); + $db->close(); + $case_revalidated = cow_merge_revalidate_reviewed_conflicts($case_metadata, $case_run_id, 'cow-revalidate'); + assert_same($case_revalidated['carried'], 1, $case['label'] . ' semantic replacement is carried to needs-action'); + assert_same(scalar($case_metadata, "SELECT revalidation_class FROM merge_revalidations WHERE conflict_id = $case_conflict_id ORDER BY id DESC LIMIT 1"), 'incompatible', $case['label'] . ' semantic replacement is classified as incompatible'); + $case_audit = cow_merge_audit_report($case_metadata, $case_run_id, 10, ['records' => 'conflicts']); + $case_conflicts = array_values(array_filter($case_audit['conflicts'], fn($row) => (int)($row['id'] ?? 0) === $case_conflict_id)); + assert_same($case_conflicts[0]['revalidation_class'] ?? null, 'incompatible', $case['label'] . ' audit exposes incompatible semantic replacement'); + assert_true(str_contains((string)($case_conflicts[0]['stale_reason'] ?? ''), 'semantic identity'), $case['label'] . ' stale reason explains semantic identity drift'); + } + + $row_missing_base = $tmp . '/row-missing-base.sqlite'; + $row_missing_source = $tmp . '/row-missing-source.sqlite'; + $row_missing_target = $tmp . '/row-missing-target.sqlite'; + $row_missing_metadata = $tmp . '/.forkpress/cow/merge/row-missing-metadata.sqlite'; + create_base_db($row_missing_base); + copy($row_missing_base, $row_missing_source); + copy($row_missing_base, $row_missing_target); + $db = open_db($row_missing_source); + $db->exec("INSERT INTO plugin_items (item_id, label, value) VALUES ('row-missing', 'Source row missing', 'source row missing')"); + $db->close(); + $db = open_db($row_missing_target); + $db->exec("INSERT INTO plugin_items (item_id, label, value) VALUES ('row-missing', 'Target row missing', 'target row missing')"); + $db->close(); + $row_missing_merge = cow_merge_databases($row_missing_base, $row_missing_source, $row_missing_target, $row_missing_metadata, 'feature-row-missing-review', 'main'); + $row_missing_run_id = (int)$row_missing_merge['run_id']; + $row_missing_conflict_id = (int)scalar($row_missing_metadata, "SELECT id FROM merge_conflicts WHERE table_name = 'plugin_items' AND conflict_type = 'row-insert-collision'"); + cow_merge_review_record( + $row_missing_metadata, + 'conflict', + $row_missing_conflict_id, + 'reviewed', + 'Revalidate before applying a row conflict whose target disappeared.', + 'cow-test' + ); + $db = open_db($row_missing_target); + $db->exec("DELETE FROM plugin_items WHERE item_id = 'row-missing'"); + $db->close(); + $row_missing_revalidated = cow_merge_revalidate_reviewed_conflicts($row_missing_metadata, $row_missing_run_id, 'cow-revalidate'); + assert_same($row_missing_revalidated['carried'], 1, 'review revalidation carries missing target row conflicts to needs-action'); + assert_same(scalar($row_missing_metadata, "SELECT revalidation_class FROM merge_revalidations WHERE conflict_id = $row_missing_conflict_id ORDER BY id DESC LIMIT 1"), 'missing', 'row revalidation classifies deleted target rows as missing'); + $row_missing_audit = cow_merge_audit_report($row_missing_metadata, $row_missing_run_id, 10, ['records' => 'conflicts']); + $row_missing_conflicts = array_values(array_filter($row_missing_audit['conflicts'], fn($row) => (int)($row['id'] ?? 0) === $row_missing_conflict_id)); + assert_same($row_missing_conflicts[0]['revalidation_class'] ?? null, 'missing', 'row audit exposes missing target row revalidation class'); + $row_missing_resolution = cow_merge_resolve_conflict( + $row_missing_metadata, + $row_missing_conflict_id, + 'source', + true, + 'Apply source after missing-row conflict revalidation.', + 'cow-test', + true + ); + assert_same($row_missing_resolution['status'], 'applied', 'after-revalidate source resolution can restore a reviewed missing row conflict'); + assert_same(scalar($row_missing_target, "SELECT label FROM plugin_items WHERE item_id = 'row-missing'"), 'Source row missing', 'after-revalidate row resolution restores the audited source row'); + assert_same( + scalar($row_missing_metadata, "SELECT previous_payload FROM merge_resolutions WHERE conflict_id = $row_missing_conflict_id ORDER BY id DESC LIMIT 1"), + cow_merge_payload_json(null), + 'after-revalidate row resolution audits the missing revalidated target row' + ); + $row_target_choice_base = $tmp . '/row-target-choice-base.sqlite'; $row_target_choice_source = $tmp . '/row-target-choice-source.sqlite'; $row_target_choice_target = $tmp . '/row-target-choice-target.sqlite'; @@ -4607,7 +5756,12 @@ function (SQLite3 $db, string $sql, string $message) use ($legacy_review_copy_fa write_test_file($file_base_root . '/wp-content/uploads/same-change.txt', 'base same change'); write_test_file($file_base_root . '/wp-content/uploads/same-delete.txt', 'base same delete'); write_test_file($file_base_root . '/wp-content/uploads/conflict.txt', 'base conflict'); + write_test_file($file_base_root . '/wp-content/uploads/binary-source-change.bin', "base\0binary"); + write_test_file($file_base_root . '/wp-content/uploads/binary-conflict.bin', "base\0binary conflict"); create_test_symlink('shared.txt', $file_base_root . '/wp-content/uploads/shared-link.txt'); + mkdir($file_base_root . '/wp-content/uploads/replace-dir-with-file', 0777, true); + write_test_file($file_base_root . '/wp-content/uploads/replace-dir-with-file/base-child.txt', 'base child'); + write_test_file($file_base_root . '/wp-content/uploads/replace-file-with-dir', 'base file child'); mkdir($file_base_root . '/wp-content/uploads/delete-empty-dir', 0777, true); mkdir($file_base_root . '/wp-content/uploads/delete-dir-conflict', 0777, true); write_test_file($file_base_root . '/wp-config.php', 'managed base config'); @@ -4616,7 +5770,7 @@ function (SQLite3 $db, string $sql, string $message) use ($legacy_review_copy_fa copy_tree_for_test($file_base_root, $file_target_root); $file_base_manifest = $tmp . '/.forkpress/cow/merge/file-bases/feature-files.json'; $file_capture = cow_merge_capture_file_base($file_base_root, $file_base_manifest); - assert_same($file_capture['files'], 8, 'filesystem merge base excludes ForkPress-managed files'); + assert_same($file_capture['files'], 12, 'filesystem merge base excludes ForkPress-managed files'); write_test_file($file_source_root . '/wp-content/uploads/shared.txt', 'source shared'); write_test_file($file_source_root . '/wp-content/uploads/new-source.txt', 'source new'); @@ -4627,6 +5781,16 @@ function (SQLite3 $db, string $sql, string $message) use ($legacy_review_copy_fa create_test_symlink('../new-source.txt', $file_source_root . '/wp-content/uploads/links/source-link.txt'); create_test_symlink('/etc/passwd', $file_source_root . '/wp-content/uploads/absolute-link.txt'); create_test_symlink('../../../etc/passwd', $file_source_root . '/wp-content/uploads/outside-link.txt'); + create_test_symlink('self-link.txt', $file_source_root . '/wp-content/uploads/self-link.txt'); + create_test_symlink('../../wp-config.php', $file_source_root . '/wp-content/uploads/managed-link.txt'); + write_test_file($file_source_root . '/wp-content/uploads/binary-source-change.bin', "source\0binary\xff"); + write_test_file($file_source_root . '/wp-content/uploads/binary-conflict.bin', "source\0binary conflict\xff"); + unlink($file_source_root . '/wp-content/uploads/replace-dir-with-file/base-child.txt'); + rmdir($file_source_root . '/wp-content/uploads/replace-dir-with-file'); + write_test_file($file_source_root . '/wp-content/uploads/replace-dir-with-file', 'source replacement file'); + unlink($file_source_root . '/wp-content/uploads/replace-file-with-dir'); + mkdir($file_source_root . '/wp-content/uploads/replace-file-with-dir', 0777, true); + write_test_file($file_source_root . '/wp-content/uploads/replace-file-with-dir/source-child.txt', 'source replacement child'); unlink($file_source_root . '/wp-content/uploads/delete-me.txt'); unlink($file_source_root . '/wp-content/uploads/same-delete.txt'); rmdir($file_source_root . '/wp-content/uploads/delete-empty-dir'); @@ -4642,6 +5806,7 @@ function (SQLite3 $db, string $sql, string $message) use ($legacy_review_copy_fa unlink($file_target_root . '/wp-content/uploads/target-delete.txt'); unlink($file_target_root . '/wp-content/uploads/same-delete.txt'); write_test_file($file_target_root . '/wp-content/uploads/target-change.txt', 'target changed'); + write_test_file($file_target_root . '/wp-content/uploads/binary-conflict.bin', "target\0binary conflict\xfe"); write_test_file($file_target_root . '/wp-content/uploads/delete-dir-conflict/target-child.txt', 'target child'); write_test_file($file_target_root . '/wp-config.php', 'target managed config'); write_test_file($file_target_root . '/wp-content/database/.ht.sqlite', 'target managed db'); @@ -4665,8 +5830,11 @@ function (SQLite3 $db, string $sql, string $message) use ($legacy_review_copy_fa assert_same(readlink($file_target_root . '/wp-content/uploads/shared-link.txt'), 'new-source.txt', 'merged symlink keeps the source relative target'); assert_true(is_link($file_target_root . '/wp-content/uploads/links/source-link.txt'), 'source-only safe filesystem symlink addition is applied'); assert_same(readlink($file_target_root . '/wp-content/uploads/links/source-link.txt'), '../new-source.txt', 'safe symlink with in-root parent traversal is preserved'); + assert_same(file_get_contents($file_target_root . '/wp-content/uploads/binary-source-change.bin'), "source\0binary\xff", 'source-only binary filesystem modification is applied exactly'); assert_true(!file_exists($file_target_root . '/wp-content/uploads/absolute-link.txt') && !is_link($file_target_root . '/wp-content/uploads/absolute-link.txt'), 'absolute source symlink is not auto-applied'); assert_true(!file_exists($file_target_root . '/wp-content/uploads/outside-link.txt') && !is_link($file_target_root . '/wp-content/uploads/outside-link.txt'), 'path-traversing source symlink is not auto-applied'); + assert_true(!file_exists($file_target_root . '/wp-content/uploads/self-link.txt') && !is_link($file_target_root . '/wp-content/uploads/self-link.txt'), 'self-referential source symlink is not auto-applied'); + assert_true(!file_exists($file_target_root . '/wp-content/uploads/managed-link.txt') && !is_link($file_target_root . '/wp-content/uploads/managed-link.txt'), 'managed-path source symlink is not auto-applied'); assert_true(!file_exists($file_target_root . '/wp-content/uploads/delete-me.txt'), 'source-only filesystem deletion is applied'); assert_true(!file_exists($file_target_root . '/wp-content/uploads/delete-empty-dir'), 'source-only empty filesystem directory deletion is applied'); assert_same(file_get_contents($file_target_root . '/wp-content/uploads/conflict.txt'), 'target conflict', 'target filesystem path wins conflicting edits'); @@ -4675,13 +5843,18 @@ function (SQLite3 $db, string $sql, string $message) use ($legacy_review_copy_fa assert_same(file_get_contents($file_target_root . '/wp-content/uploads/target-change.txt'), 'target changed', 'target-only filesystem path change is preserved'); assert_same(file_get_contents($file_target_root . '/wp-content/uploads/same-added.txt'), 'same added', 'identical source/target filesystem addition remains present'); assert_same(file_get_contents($file_target_root . '/wp-content/uploads/same-change.txt'), 'same changed', 'identical source/target filesystem change remains present'); + assert_same(file_get_contents($file_target_root . '/wp-content/uploads/binary-conflict.bin'), "target\0binary conflict\xfe", 'target binary filesystem path wins conflicting binary edits'); assert_true(!file_exists($file_target_root . '/wp-content/uploads/same-delete.txt'), 'identical source/target filesystem deletion remains deleted'); + assert_true(is_dir($file_target_root . '/wp-content/uploads/replace-dir-with-file'), 'target directory remains after reviewed source directory-to-file replacement conflict'); + assert_same(file_get_contents($file_target_root . '/wp-content/uploads/replace-dir-with-file/base-child.txt'), 'base child', 'target directory child remains after parent replacement conflict'); + assert_same(file_get_contents($file_target_root . '/wp-content/uploads/replace-file-with-dir'), 'base file child', 'target file remains after reviewed source file-to-directory replacement conflict'); assert_same(file_get_contents($file_target_root . '/wp-content/uploads/delete-dir-conflict/target-child.txt'), 'target child', 'target-side directory descendants block automatic source directory deletion'); assert_same(file_get_contents($file_target_root . '/wp-config.php'), 'target managed config', 'managed wp-config.php is excluded from filesystem merge'); assert_same(file_get_contents($file_target_root . '/wp-content/database/.ht.sqlite'), 'target managed db', 'managed SQLite database path is excluded from filesystem merge'); - assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_conflicts WHERE table_name = '__files__' AND conflict_type = 'file-conflict'"), 1, 'filesystem conflict is auditable'); + assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_conflicts WHERE table_name = '__files__' AND conflict_type = 'file-conflict'"), 2, 'filesystem content conflicts are auditable'); assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_conflicts WHERE table_name = '__files__' AND conflict_type = 'file-directory-delete-conflict'"), 1, 'unsafe filesystem directory deletion conflict is auditable'); - assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_conflicts WHERE table_name = '__files__' AND conflict_type = 'file-unsafe-symlink'"), 2, 'unsafe filesystem symlink conflicts are auditable'); + assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_conflicts WHERE table_name = '__files__' AND conflict_type = 'file-unsafe-symlink'"), 4, 'unsafe filesystem symlink conflicts are auditable'); + assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_conflicts WHERE table_name = '__files__' AND conflict_type = 'file-type-replacement-conflict'"), 2, 'filesystem directory/file replacement conflicts are auditable'); assert_true((int)scalar($metadata, "SELECT COUNT(*) FROM merge_decisions WHERE table_name = '__files__' AND decision = 'source-applied'") >= 5, 'filesystem automatic decisions are auditable'); $source_changed_file_identity = SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/shared.txt')); $source_new_file_identity = SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/new-source.txt')); @@ -4694,6 +5867,10 @@ function (SQLite3 $db, string $sql, string $message) use ($legacy_review_copy_fa $source_delete_file_identity = SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/delete-me.txt')); $source_delete_dir_identity = SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/delete-empty-dir')); $conflict_file_identity = SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/conflict.txt')); + $source_replaced_dir_identity = SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/replace-dir-with-file')); + $source_replaced_file_identity = SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/replace-file-with-dir')); + $source_replaced_dir_child_identity = SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/replace-dir-with-file/base-child.txt')); + $source_replaced_file_child_identity = SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/replace-file-with-dir/source-child.txt')); $base_file_entries = cow_merge_file_manifest_for_root($file_base_root)['entries']; $source_file_entries = cow_merge_file_manifest_for_root($file_source_root)['entries']; $merged_file_entries = cow_merge_file_manifest_for_root($file_target_root)['entries']; @@ -4713,6 +5890,10 @@ function (SQLite3 $db, string $sql, string $message) use ($legacy_review_copy_fa $conflict_base_payload = SQLite3::escapeString(cow_merge_payload_json(cow_merge_file_path_payload('wp-content/uploads/conflict.txt', $base_file_entries['wp-content/uploads/conflict.txt']))); $conflict_source_payload = SQLite3::escapeString(cow_merge_payload_json(cow_merge_file_path_payload('wp-content/uploads/conflict.txt', $source_file_entries['wp-content/uploads/conflict.txt']))); $conflict_target_payload = SQLite3::escapeString(cow_merge_payload_json(cow_merge_file_path_payload('wp-content/uploads/conflict.txt', $merged_file_entries['wp-content/uploads/conflict.txt']))); + $binary_conflict_identity = SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/binary-conflict.bin')); + $binary_conflict_base_payload = SQLite3::escapeString(cow_merge_payload_json(cow_merge_file_path_payload('wp-content/uploads/binary-conflict.bin', $base_file_entries['wp-content/uploads/binary-conflict.bin']))); + $binary_conflict_source_payload = SQLite3::escapeString(cow_merge_payload_json(cow_merge_file_path_payload('wp-content/uploads/binary-conflict.bin', $source_file_entries['wp-content/uploads/binary-conflict.bin']))); + $binary_conflict_target_payload = SQLite3::escapeString(cow_merge_payload_json(cow_merge_file_path_payload('wp-content/uploads/binary-conflict.bin', $merged_file_entries['wp-content/uploads/binary-conflict.bin']))); $same_added_file_payload = SQLite3::escapeString(cow_merge_payload_json(cow_merge_file_path_payload('wp-content/uploads/same-added.txt', $merged_file_entries['wp-content/uploads/same-added.txt']))); $same_change_base_payload = SQLite3::escapeString(cow_merge_payload_json(cow_merge_file_path_payload('wp-content/uploads/same-change.txt', $base_file_entries['wp-content/uploads/same-change.txt']))); $same_change_file_payload = SQLite3::escapeString(cow_merge_payload_json(cow_merge_file_path_payload('wp-content/uploads/same-change.txt', $merged_file_entries['wp-content/uploads/same-change.txt']))); @@ -4775,21 +5956,28 @@ function (SQLite3 $db, string $sql, string $message) use ($legacy_review_copy_fa assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_decisions WHERE table_name = '__files__' AND decision = 'source-applied' AND row_identity = '$same_change_file_identity' AND reason = 'source and target changed filesystem path to the same state' AND base_payload = '$same_change_base_payload' AND source_payload = '$same_change_file_payload' AND target_payload = '$same_change_file_payload' AND chosen_payload = '$same_change_file_payload'"), 1, 'identical source/target filesystem change records matching source, target, and chosen payloads'); assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_decisions WHERE table_name = '__files__' AND decision = 'source-applied' AND row_identity = '$same_delete_file_identity' AND reason = 'source and target deleted the same filesystem path' AND chosen_payload IS NULL"), 1, 'identical source/target filesystem deletion is auditable'); assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_decisions WHERE table_name = '__files__' AND decision = 'source-applied' AND row_identity = '$same_delete_file_identity' AND reason = 'source and target deleted the same filesystem path' AND base_payload = '$same_delete_base_payload' AND source_payload IS NULL AND target_payload IS NULL AND chosen_payload IS NULL"), 1, 'identical source/target filesystem deletion records empty source, target, and chosen payloads'); + assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_decisions WHERE table_name = '__files__' AND decision = 'target-wins' AND row_identity = '$source_replaced_dir_identity' AND reason LIKE 'source changed filesystem path type from dir to file%'"), 1, 'directory-to-file replacement records a type-specific target-wins decision'); + assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_decisions WHERE table_name = '__files__' AND decision = 'target-wins' AND row_identity = '$source_replaced_file_identity' AND reason LIKE 'source changed filesystem path type from file to dir%'"), 1, 'file-to-directory replacement records a type-specific target-wins decision'); + assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_decisions WHERE table_name = '__files__' AND decision = 'target-kept' AND row_identity = '$source_replaced_dir_child_identity' AND reason = 'target subtree kept because parent filesystem replacement requires review'"), 1, 'directory-to-file replacement preserves unchanged target descendants under the conflicted parent'); + assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_decisions WHERE table_name = '__files__' AND decision = 'target-kept' AND row_identity = '$source_replaced_file_child_identity' AND reason = 'target subtree kept because parent filesystem replacement requires review'"), 1, 'file-to-directory replacement holds source descendants under the conflicted parent'); $file_conflict_audit = cow_merge_audit_report($metadata, null, 10, ['scope' => 'files', 'records' => 'conflicts']); assert_same($file_conflict_audit['filters']['scope'], 'files', 'merge audit JSON report includes the file scope filter'); assert_same($file_conflict_audit['filters']['records'], 'conflicts', 'merge audit JSON report includes the record-type filter'); - assert_same(count($file_conflict_audit['conflicts']), 4, 'merge audit can focus on filesystem conflicts'); + assert_same(count($file_conflict_audit['conflicts']), 9, 'merge audit can focus on filesystem conflicts'); assert_same(count($file_conflict_audit['decisions']), 0, 'conflict-only audit filter omits decisions'); assert_same(count($file_conflict_audit['autoincrement_bands']), 0, 'file conflict audit filter omits database-only band summaries'); assert_same(count($file_conflict_audit['row_identity_summary']), 0, 'file conflict audit filter omits database-only row identity summaries'); assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_conflicts WHERE table_name = '__files__' AND conflict_type = 'file-conflict' AND row_identity = '$conflict_file_identity' AND base_payload = '$conflict_base_payload' AND source_payload = '$conflict_source_payload' AND target_payload = '$conflict_target_payload' AND chosen_payload = '$conflict_target_payload'"), 1, 'filesystem content conflicts record base, source, target, and chosen target payloads'); - assert_same(count(array_filter($file_conflict_audit['conflicts'], fn($row) => $row['table_name'] === '__files__')), 4, 'filesystem audit filter exports only file records'); + assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_conflicts WHERE table_name = '__files__' AND conflict_type = 'file-conflict' AND row_identity = '$binary_conflict_identity' AND base_payload = '$binary_conflict_base_payload' AND source_payload = '$binary_conflict_source_payload' AND target_payload = '$binary_conflict_target_payload' AND chosen_payload = '$binary_conflict_target_payload'"), 1, 'binary filesystem content conflicts record hash payloads without text decoding'); + assert_same(count(array_filter($file_conflict_audit['conflicts'], fn($row) => $row['table_name'] === '__files__')), 9, 'filesystem audit filter exports only file records'); $unsafe_symlink_audit = cow_merge_audit_report($metadata, null, 10, [ 'scope' => 'files', 'records' => 'conflicts', 'conflict_type' => 'file-unsafe-symlink', ]); - assert_same(count($unsafe_symlink_audit['conflicts']), 2, 'merge audit can filter filesystem conflicts by type'); + assert_same(count($unsafe_symlink_audit['conflicts']), 4, 'merge audit can filter filesystem conflicts by type'); + assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_decisions WHERE table_name = '__files__' AND decision = 'target-wins' AND reason LIKE '%symlink target points at itself%'"), 1, 'unsafe symlink decisions explain self-referential symlinks'); + assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_decisions WHERE table_name = '__files__' AND decision = 'target-wins' AND reason LIKE '%symlink target points at a ForkPress-managed path%'"), 1, 'unsafe symlink decisions explain managed-path symlinks'); ob_start(); cow_merge_print_audit_text($unsafe_symlink_audit); $unsafe_symlink_text = ob_get_clean(); @@ -4971,9 +6159,17 @@ static function (SQLite3 $db, string $sql, string $message): void { $file_resolve_target_root = $tmp . '/files-resolve-target'; mkdir($file_resolve_base_root . '/wp-content/uploads', 0777, true); write_test_file($file_resolve_base_root . '/wp-content/uploads/conflict.txt', 'base conflict'); + write_test_file($file_resolve_base_root . '/wp-content/uploads/binary-conflict.bin', "base binary conflict\0\x80"); write_test_file($file_resolve_base_root . '/wp-content/uploads/delete-conflict.txt', 'base delete conflict'); write_test_file($file_resolve_base_root . '/wp-content/uploads/rollback-conflict.txt', 'base rollback conflict'); write_test_file($file_resolve_base_root . '/wp-content/uploads/commit-rollback-conflict.txt', 'base commit rollback conflict'); + write_test_file($file_resolve_base_root . '/wp-content/uploads/revalidate-conflict.txt', 'base revalidate conflict'); + write_test_file($file_resolve_base_root . '/wp-content/uploads/revalidate-missing.txt', 'base revalidate missing'); + write_test_file($file_resolve_base_root . '/wp-content/uploads/revalidate-source-drift.txt', 'base revalidate source drift'); + mkdir($file_resolve_base_root . '/wp-content/uploads/replace-dir-with-file', 0777, true); + write_test_file($file_resolve_base_root . '/wp-content/uploads/replace-dir-with-file/base-child.txt', 'base replacement child'); + write_test_file($file_resolve_base_root . '/wp-content/uploads/replace-file-with-dir', 'base replacement file'); + write_test_file($file_resolve_base_root . '/wp-content/uploads/replace-file-with-unsafe-dir', 'base unsafe replacement file'); copy_tree_for_test($file_resolve_base_root, $file_resolve_source_root); copy_tree_for_test($file_resolve_base_root, $file_resolve_target_root); $file_resolve_base_db = $file_resolve_base_root . '/wp-content/database/.ht.sqlite'; @@ -4988,14 +6184,32 @@ static function (SQLite3 $db, string $sql, string $message): void { $file_resolve_manifest = $tmp . '/.forkpress/cow/merge/file-bases/feature-file-resolve.json'; cow_merge_capture_file_base($file_resolve_base_root, $file_resolve_manifest); write_test_file($file_resolve_source_root . '/wp-content/uploads/conflict.txt', 'source conflict resolution'); + write_test_file($file_resolve_source_root . '/wp-content/uploads/binary-conflict.bin', "source binary resolution\0\xff"); write_test_file($file_resolve_source_root . '/wp-content/uploads/rollback-conflict.txt', 'source rollback resolution'); write_test_file($file_resolve_source_root . '/wp-content/uploads/commit-rollback-conflict.txt', 'source commit rollback resolution'); + write_test_file($file_resolve_source_root . '/wp-content/uploads/revalidate-conflict.txt', 'source revalidate resolution'); + write_test_file($file_resolve_source_root . '/wp-content/uploads/revalidate-missing.txt', 'source revalidate missing'); + write_test_file($file_resolve_source_root . '/wp-content/uploads/revalidate-source-drift.txt', 'source revalidate original'); create_test_symlink('/etc/passwd', $file_resolve_source_root . '/wp-content/uploads/unsafe-link.txt'); unlink($file_resolve_source_root . '/wp-content/uploads/delete-conflict.txt'); + unlink($file_resolve_source_root . '/wp-content/uploads/replace-dir-with-file/base-child.txt'); + rmdir($file_resolve_source_root . '/wp-content/uploads/replace-dir-with-file'); + write_test_file($file_resolve_source_root . '/wp-content/uploads/replace-dir-with-file', 'source resolved replacement file'); + unlink($file_resolve_source_root . '/wp-content/uploads/replace-file-with-dir'); + mkdir($file_resolve_source_root . '/wp-content/uploads/replace-file-with-dir', 0777, true); + write_test_file($file_resolve_source_root . '/wp-content/uploads/replace-file-with-dir/source-child.txt', 'source resolved replacement child'); + create_test_symlink('source-child.txt', $file_resolve_source_root . '/wp-content/uploads/replace-file-with-dir/source-link.txt'); + unlink($file_resolve_source_root . '/wp-content/uploads/replace-file-with-unsafe-dir'); + mkdir($file_resolve_source_root . '/wp-content/uploads/replace-file-with-unsafe-dir', 0777, true); + create_test_symlink('/etc/passwd', $file_resolve_source_root . '/wp-content/uploads/replace-file-with-unsafe-dir/unsafe-link.txt'); write_test_file($file_resolve_target_root . '/wp-content/uploads/conflict.txt', 'target conflict resolution'); + write_test_file($file_resolve_target_root . '/wp-content/uploads/binary-conflict.bin', "target binary resolution\0\xfe"); write_test_file($file_resolve_target_root . '/wp-content/uploads/delete-conflict.txt', 'target changed before source deletion'); write_test_file($file_resolve_target_root . '/wp-content/uploads/rollback-conflict.txt', 'target rollback resolution'); write_test_file($file_resolve_target_root . '/wp-content/uploads/commit-rollback-conflict.txt', 'target commit rollback resolution'); + write_test_file($file_resolve_target_root . '/wp-content/uploads/revalidate-conflict.txt', 'target revalidate resolution'); + write_test_file($file_resolve_target_root . '/wp-content/uploads/revalidate-missing.txt', 'target revalidate missing'); + write_test_file($file_resolve_target_root . '/wp-content/uploads/revalidate-source-drift.txt', 'target revalidate source drift'); cow_merge_branch_state( $file_resolve_base_db, $file_resolve_source_db, @@ -5029,44 +6243,180 @@ static function (SQLite3 $db, string $sql, string $message): void { assert_same($file_source_resolution['status'], 'applied', 'source filesystem conflict resolution records applied status'); assert_same(file_get_contents($file_resolve_target_root . '/wp-content/uploads/conflict.txt'), 'source conflict resolution', 'source filesystem conflict resolution copies the audited source file'); assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_resolutions WHERE conflict_id = $file_conflict_id AND table_name = '__files__' AND column_name = 'path' AND choice = 'source' AND applied = 1"), 1, 'filesystem conflict resolution is auditable'); + $binary_file_conflict_id = (int)scalar($metadata, "SELECT id FROM merge_conflicts WHERE table_name = '__files__' AND row_identity = '" . SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/binary-conflict.bin')) . "' ORDER BY id DESC LIMIT 1"); + $binary_file_source_resolution = cow_merge_resolve_conflict( + $metadata, + $binary_file_conflict_id, + 'source', + true, + 'Apply audited source binary file.', + 'cow-test' + ); + assert_same($binary_file_source_resolution['status'], 'applied', 'source binary filesystem conflict resolution records applied status'); + assert_same(file_get_contents($file_resolve_target_root . '/wp-content/uploads/binary-conflict.bin'), "source binary resolution\0\xff", 'source binary filesystem conflict resolution copies exact source bytes'); + assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_resolutions WHERE conflict_id = $binary_file_conflict_id AND table_name = '__files__' AND column_name = 'path' AND choice = 'source' AND applied = 1"), 1, 'binary filesystem conflict resolution is auditable'); assert_throws( fn() => cow_merge_resolve_conflict($metadata, $file_conflict_id, 'target', true, 'Try stale file keep.', 'cow-test'), 'target filesystem path no longer matches', 'stale filesystem conflict resolution is blocked after the target path changes' ); - $file_rollback_conflict_id = (int)scalar($metadata, "SELECT id FROM merge_conflicts WHERE table_name = '__files__' AND row_identity = '" . SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/rollback-conflict.txt')) . "' ORDER BY id DESC LIMIT 1"); - $file_rollback_meta = open_db($metadata); - $file_rollback_meta->exec(<<<'SQL' -CREATE TRIGGER fail_filesystem_resolution_record -BEFORE INSERT ON merge_resolutions -WHEN NEW.table_name = '__files__' - AND NEW.note = 'Try audited source file with failing metadata.' -BEGIN - SELECT RAISE(ABORT, 'forced filesystem resolution metadata failure'); -END -SQL); - $file_rollback_meta->close(); - $file_resolution_failed = false; - set_error_handler(static function (int $severity, string $message): bool { - return str_contains($message, 'forced filesystem resolution metadata failure'); - }); - try { - cow_merge_resolve_conflict( - $metadata, - $file_rollback_conflict_id, - 'source', - true, - 'Try audited source file with failing metadata.', - 'cow-test' - ); - } catch (Throwable $e) { - $file_resolution_failed = str_contains($e->getMessage(), 'forced filesystem resolution metadata failure'); - } finally { - restore_error_handler(); - } - assert_true($file_resolution_failed, 'filesystem resolution metadata failure is surfaced to the caller'); - assert_same(file_get_contents($file_resolve_target_root . '/wp-content/uploads/rollback-conflict.txt'), 'target rollback resolution', 'failed filesystem resolution restores the target file'); - assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_resolutions WHERE conflict_id = $file_rollback_conflict_id"), 0, 'failed filesystem resolution records no resolution metadata'); + $stale_file_audit = cow_merge_audit_report($metadata, null, 10, [ + 'records' => 'conflicts', + 'scope' => 'files', + 'path' => 'wp-content/uploads/conflict.txt', + ]); + assert_same($stale_file_audit['conflicts'][0]['stale_status'] ?? null, 'stale', 'merge audit marks drifted filesystem conflicts as stale'); + assert_true(isset($stale_file_audit['conflicts'][0]['current_target_preview']), 'stale filesystem audit exposes the current target file manifest'); + assert_true( + ($stale_file_audit['conflicts'][0]['current_target_preview'] ?? null) !== ($stale_file_audit['conflicts'][0]['target_preview'] ?? null), + 'stale filesystem audit distinguishes current target file state from the audited target state' + ); + $file_revalidate_conflict_id = (int)scalar($metadata, "SELECT id FROM merge_conflicts WHERE table_name = '__files__' AND row_identity = '" . SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/revalidate-conflict.txt')) . "' ORDER BY id DESC LIMIT 1"); + cow_merge_review_record( + $metadata, + 'conflict', + $file_revalidate_conflict_id, + 'reviewed', + 'Keep target uploaded file until revalidation.', + 'cow-test' + ); + write_test_file($file_resolve_target_root . '/wp-content/uploads/revalidate-conflict.txt', 'target file drift after review'); + $file_revalidated = cow_merge_revalidate_reviewed_conflicts($metadata, null, 'cow-revalidate'); + assert_true($file_revalidated['carried'] >= 1, 'review revalidation carries stale filesystem conflicts to needs-action'); + assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_revalidations WHERE conflict_id = $file_revalidate_conflict_id"), 1, 'filesystem revalidation records the stale target file payload'); + assert_same(scalar($metadata, "SELECT revalidation_class FROM merge_revalidations WHERE conflict_id = $file_revalidate_conflict_id ORDER BY id DESC LIMIT 1"), 'compatible-target-drift', 'filesystem revalidation classifies changed target files'); + $file_revalidated_class_audit = cow_merge_audit_report($metadata, null, 10, [ + 'records' => 'conflicts', + 'scope' => 'files', + 'path' => 'wp-content/uploads/revalidate-conflict.txt', + ]); + assert_same($file_revalidated_class_audit['conflicts'][0]['revalidation_class'] ?? null, 'compatible-target-drift', 'filesystem audit exposes the revalidation classifier'); + assert_throws( + fn() => cow_merge_resolve_conflict($metadata, $file_revalidate_conflict_id, 'source', true, 'Try stale file source before guarded revalidation.', 'cow-test'), + 'target filesystem path no longer matches', + 'stale filesystem source resolution still fails without the after-revalidate guard' + ); + write_test_file($file_resolve_target_root . '/wp-content/uploads/revalidate-conflict.txt', 'target file drift after revalidation'); + assert_throws( + fn() => cow_merge_resolve_conflict($metadata, $file_revalidate_conflict_id, 'source', true, 'Try stale file source after new target drift.', 'cow-test', true), + 'target payload changed after latest merge revalidation', + 'after-revalidate filesystem resolution fails if target drifted again after revalidation' + ); + $file_revalidated_after_drift = cow_merge_revalidate_reviewed_conflicts($metadata, null, 'cow-revalidate'); + assert_true($file_revalidated_after_drift['carried'] >= 1, 'review revalidation carries a new filesystem note after further target drift'); + assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_revalidations WHERE conflict_id = $file_revalidate_conflict_id"), 2, 'filesystem revalidation records the replacement stale target file payload'); + assert_same(scalar($metadata, "SELECT revalidation_class FROM merge_revalidations WHERE conflict_id = $file_revalidate_conflict_id ORDER BY id DESC LIMIT 1"), 'compatible-target-drift', 'filesystem replacement revalidation keeps same-path drift classified'); + $file_revalidated_target_entry = cow_merge_file_manifest_for_root($file_resolve_target_root)['entries']['wp-content/uploads/revalidate-conflict.txt']; + $file_revalidated_target_payload = cow_merge_payload_json(cow_merge_file_path_payload('wp-content/uploads/revalidate-conflict.txt', $file_revalidated_target_entry)); + $file_after_revalidate_resolution = cow_merge_resolve_conflict( + $metadata, + $file_revalidate_conflict_id, + 'source', + true, + 'Apply source file after revalidating target drift.', + 'cow-test', + true + ); + assert_same($file_after_revalidate_resolution['status'], 'applied', 'after-revalidate source resolution applies a revalidated stale filesystem conflict'); + assert_same(file_get_contents($file_resolve_target_root . '/wp-content/uploads/revalidate-conflict.txt'), 'source revalidate resolution', 'after-revalidate filesystem resolution copies the audited source file'); + assert_same( + scalar($metadata, "SELECT previous_payload FROM merge_resolutions WHERE conflict_id = $file_revalidate_conflict_id ORDER BY id DESC LIMIT 1"), + $file_revalidated_target_payload, + 'after-revalidate filesystem resolution audits the latest revalidated target file payload' + ); + $file_missing_conflict_id = (int)scalar($metadata, "SELECT id FROM merge_conflicts WHERE table_name = '__files__' AND row_identity = '" . SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/revalidate-missing.txt')) . "' ORDER BY id DESC LIMIT 1"); + cow_merge_review_record( + $metadata, + 'conflict', + $file_missing_conflict_id, + 'reviewed', + 'Revalidate before applying a source file whose target disappeared.', + 'cow-test' + ); + unlink($file_resolve_target_root . '/wp-content/uploads/revalidate-missing.txt'); + $file_missing_revalidated = cow_merge_revalidate_reviewed_conflicts($metadata, null, 'cow-revalidate'); + assert_true($file_missing_revalidated['carried'] >= 1, 'review revalidation carries missing filesystem paths to needs-action'); + assert_same(scalar($metadata, "SELECT revalidation_class FROM merge_revalidations WHERE conflict_id = $file_missing_conflict_id ORDER BY id DESC LIMIT 1"), 'missing', 'filesystem revalidation classifies deleted target paths as missing'); + $file_missing_audit = cow_merge_audit_report($metadata, null, 10, [ + 'records' => 'conflicts', + 'scope' => 'files', + 'path' => 'wp-content/uploads/revalidate-missing.txt', + ]); + assert_same($file_missing_audit['conflicts'][0]['revalidation_class'] ?? null, 'missing', 'filesystem audit exposes missing path revalidation class'); + $file_missing_resolution = cow_merge_resolve_conflict( + $metadata, + $file_missing_conflict_id, + 'source', + true, + 'Apply source file after missing-path revalidation.', + 'cow-test', + true + ); + assert_same($file_missing_resolution['status'], 'applied', 'after-revalidate source resolution can restore a reviewed missing filesystem path'); + assert_same(file_get_contents($file_resolve_target_root . '/wp-content/uploads/revalidate-missing.txt'), 'source revalidate missing', 'after-revalidate filesystem resolution restores the audited source path'); + $file_source_drift_conflict_id = (int)scalar($metadata, "SELECT id FROM merge_conflicts WHERE table_name = '__files__' AND row_identity = '" . SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/revalidate-source-drift.txt')) . "' ORDER BY id DESC LIMIT 1"); + cow_merge_review_record( + $metadata, + 'conflict', + $file_source_drift_conflict_id, + 'reviewed', + 'Revalidate before applying a source file whose source branch may change.', + 'cow-test' + ); + write_test_file($file_resolve_source_root . '/wp-content/uploads/revalidate-source-drift.txt', 'source file drift after review'); + $file_source_drift_revalidated = cow_merge_revalidate_reviewed_conflicts($metadata, null, 'cow-revalidate'); + assert_true($file_source_drift_revalidated['carried'] >= 1, 'review revalidation carries source-drifted filesystem paths to needs-action'); + assert_same(scalar($metadata, "SELECT revalidation_class FROM merge_revalidations WHERE conflict_id = $file_source_drift_conflict_id ORDER BY id DESC LIMIT 1"), 'compatible-source-drift', 'filesystem revalidation classifies changed source files'); + $file_source_drift_entry = cow_merge_file_manifest_for_root($file_resolve_source_root)['entries']['wp-content/uploads/revalidate-source-drift.txt']; + assert_same( + scalar($metadata, "SELECT source_payload FROM merge_revalidations WHERE conflict_id = $file_source_drift_conflict_id ORDER BY id DESC LIMIT 1"), + cow_merge_payload_json(cow_merge_file_path_payload('wp-content/uploads/revalidate-source-drift.txt', $file_source_drift_entry)), + 'filesystem source-drift revalidation records the current source file payload' + ); + $file_source_drift_audit = cow_merge_audit_report($metadata, null, 10, [ + 'records' => 'conflicts', + 'scope' => 'files', + 'path' => 'wp-content/uploads/revalidate-source-drift.txt', + ]); + assert_same($file_source_drift_audit['conflicts'][0]['revalidation_class'] ?? null, 'compatible-source-drift', 'filesystem audit exposes source-drift revalidation class'); + assert_throws( + fn() => cow_merge_resolve_conflict($metadata, $file_source_drift_conflict_id, 'source', true, 'Try source file after source-drift revalidation.', 'cow-test', true), + 'source payload changed after latest merge revalidation', + 'after-revalidate filesystem resolution fails if the source file changed after review' + ); + $file_rollback_conflict_id = (int)scalar($metadata, "SELECT id FROM merge_conflicts WHERE table_name = '__files__' AND row_identity = '" . SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/rollback-conflict.txt')) . "' ORDER BY id DESC LIMIT 1"); + $file_rollback_meta = open_db($metadata); + $file_rollback_meta->exec(<<<'SQL' +CREATE TRIGGER fail_filesystem_resolution_record +BEFORE INSERT ON merge_resolutions +WHEN NEW.table_name = '__files__' + AND NEW.note = 'Try audited source file with failing metadata.' +BEGIN + SELECT RAISE(ABORT, 'forced filesystem resolution metadata failure'); +END +SQL); + $file_rollback_meta->close(); + $file_resolution_failed = false; + set_error_handler(static function (int $severity, string $message): bool { + return str_contains($message, 'forced filesystem resolution metadata failure'); + }); + try { + cow_merge_resolve_conflict( + $metadata, + $file_rollback_conflict_id, + 'source', + true, + 'Try audited source file with failing metadata.', + 'cow-test' + ); + } catch (Throwable $e) { + $file_resolution_failed = str_contains($e->getMessage(), 'forced filesystem resolution metadata failure'); + } finally { + restore_error_handler(); + } + assert_true($file_resolution_failed, 'filesystem resolution metadata failure is surfaced to the caller'); + assert_same(file_get_contents($file_resolve_target_root . '/wp-content/uploads/rollback-conflict.txt'), 'target rollback resolution', 'failed filesystem resolution restores the target file'); + assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_resolutions WHERE conflict_id = $file_rollback_conflict_id"), 0, 'failed filesystem resolution records no resolution metadata'); $file_rollback_meta = open_db($metadata); $file_rollback_meta->exec('DROP TRIGGER fail_filesystem_resolution_record'); $file_rollback_meta->close(); @@ -5114,6 +6464,41 @@ static function (SQLite3 $db, string $sql, string $message): void { ); assert_same($file_delete_resolution['status'], 'applied', 'source filesystem deletion conflict resolution records applied status'); assert_true(!file_exists($file_resolve_target_root . '/wp-content/uploads/delete-conflict.txt'), 'source filesystem deletion conflict resolution removes the target path after validation'); + $file_type_replacement_conflict_id = (int)scalar($metadata, "SELECT id FROM merge_conflicts WHERE table_name = '__files__' AND conflict_type = 'file-type-replacement-conflict' AND row_identity = '" . SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/replace-dir-with-file')) . "' ORDER BY id DESC LIMIT 1"); + $file_type_replacement_resolution = cow_merge_resolve_conflict( + $metadata, + $file_type_replacement_conflict_id, + 'source', + true, + 'Apply reviewed source directory-to-file replacement.', + 'cow-test' + ); + assert_same($file_type_replacement_resolution['status'], 'applied', 'source directory-to-file resolution records applied status'); + assert_true(is_file($file_resolve_target_root . '/wp-content/uploads/replace-dir-with-file'), 'source directory-to-file resolution replaces the target directory with a file'); + assert_same(file_get_contents($file_resolve_target_root . '/wp-content/uploads/replace-dir-with-file'), 'source resolved replacement file', 'source directory-to-file resolution copies the audited source file'); + assert_true(!file_exists($file_resolve_target_root . '/wp-content/uploads/replace-dir-with-file/base-child.txt'), 'source directory-to-file resolution removes target directory descendants after review'); + $file_type_replacement_inverse_conflict_id = (int)scalar($metadata, "SELECT id FROM merge_conflicts WHERE table_name = '__files__' AND conflict_type = 'file-type-replacement-conflict' AND row_identity = '" . SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/replace-file-with-dir')) . "' ORDER BY id DESC LIMIT 1"); + $file_type_replacement_inverse_resolution = cow_merge_resolve_conflict( + $metadata, + $file_type_replacement_inverse_conflict_id, + 'source', + true, + 'Apply reviewed source file-to-directory replacement.', + 'cow-test' + ); + assert_same($file_type_replacement_inverse_resolution['status'], 'applied', 'source file-to-directory resolution records applied status'); + assert_true(is_dir($file_resolve_target_root . '/wp-content/uploads/replace-file-with-dir'), 'source file-to-directory resolution replaces the target file with a directory'); + assert_same(file_get_contents($file_resolve_target_root . '/wp-content/uploads/replace-file-with-dir/source-child.txt'), 'source resolved replacement child', 'source file-to-directory resolution copies the audited source directory child'); + assert_true(is_link($file_resolve_target_root . '/wp-content/uploads/replace-file-with-dir/source-link.txt'), 'source file-to-directory resolution copies safe source symlink child'); + assert_same(readlink($file_resolve_target_root . '/wp-content/uploads/replace-file-with-dir/source-link.txt'), 'source-child.txt', 'source file-to-directory resolution preserves safe source symlink child target'); + $file_type_replacement_unsafe_dir_conflict_id = (int)scalar($metadata, "SELECT id FROM merge_conflicts WHERE table_name = '__files__' AND conflict_type = 'file-type-replacement-conflict' AND row_identity = '" . SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/replace-file-with-unsafe-dir')) . "' ORDER BY id DESC LIMIT 1"); + assert_throws( + fn() => cow_merge_resolve_conflict($metadata, $file_type_replacement_unsafe_dir_conflict_id, 'source', true, 'Try reviewed source directory replacement with unsafe symlink descendant.', 'cow-test'), + 'cannot apply source filesystem directory subtree', + 'source file-to-directory resolution rejects unsafe source symlink descendants' + ); + assert_same(file_get_contents($file_resolve_target_root . '/wp-content/uploads/replace-file-with-unsafe-dir'), 'base unsafe replacement file', 'failed source file-to-directory resolution restores the target file when a subtree symlink is unsafe'); + assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_resolutions WHERE conflict_id = $file_type_replacement_unsafe_dir_conflict_id"), 0, 'failed unsafe directory replacement records no resolution metadata'); $file_resolve_rerun = cow_merge_branch_state( $file_resolve_base_db, $file_resolve_source_db, @@ -5127,17 +6512,38 @@ static function (SQLite3 $db, string $sql, string $message): void { ); assert_same($file_resolve_rerun['status'], 'completed_with_conflicts', 'rerunning after filesystem source resolutions only reports unresolved file conflicts'); assert_same(file_get_contents($file_resolve_target_root . '/wp-content/uploads/conflict.txt'), 'source conflict resolution', 'rerunning after source filesystem replacement keeps the audited source file'); + assert_same(file_get_contents($file_resolve_target_root . '/wp-content/uploads/binary-conflict.bin'), "source binary resolution\0\xff", 'rerunning after source binary filesystem replacement keeps the exact audited bytes'); assert_true(!file_exists($file_resolve_target_root . '/wp-content/uploads/delete-conflict.txt'), 'rerunning after source filesystem deletion keeps the target path deleted'); + assert_same(file_get_contents($file_resolve_target_root . '/wp-content/uploads/replace-dir-with-file'), 'source resolved replacement file', 'rerunning after source directory-to-file resolution keeps the audited source file'); + assert_same(file_get_contents($file_resolve_target_root . '/wp-content/uploads/replace-file-with-dir/source-child.txt'), 'source resolved replacement child', 'rerunning after source file-to-directory resolution keeps the audited source directory'); + assert_true(is_link($file_resolve_target_root . '/wp-content/uploads/replace-file-with-dir/source-link.txt'), 'rerunning after source file-to-directory resolution keeps the audited source symlink child'); + assert_same(readlink($file_resolve_target_root . '/wp-content/uploads/replace-file-with-dir/source-link.txt'), 'source-child.txt', 'rerunning after source file-to-directory resolution keeps the audited source symlink target'); + assert_same(file_get_contents($file_resolve_target_root . '/wp-content/uploads/replace-file-with-unsafe-dir'), 'base unsafe replacement file', 'rerunning after failed unsafe directory replacement keeps the restored target file'); assert_same( (int)scalar($metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE c.table_name = '__files__' AND c.conflict_type = 'file-conflict' AND c.row_identity = '" . SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/conflict.txt')) . "' AND r.source_branch = 'feature-file-resolve'"), 1, 'rerunning after source filesystem replacement does not rediscover the resolved file conflict' ); + assert_same( + (int)scalar($metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE c.table_name = '__files__' AND c.conflict_type = 'file-conflict' AND c.row_identity = '" . SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/binary-conflict.bin')) . "' AND r.source_branch = 'feature-file-resolve'"), + 1, + 'rerunning after source binary filesystem replacement does not rediscover the resolved binary file conflict' + ); assert_same( (int)scalar($metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE c.table_name = '__files__' AND c.conflict_type = 'file-source-deleted' AND c.row_identity = '" . SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/delete-conflict.txt')) . "' AND r.source_branch = 'feature-file-resolve'"), 1, 'rerunning after source filesystem deletion does not rediscover the resolved delete conflict' ); + assert_same( + (int)scalar($metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE c.table_name = '__files__' AND c.conflict_type = 'file-type-replacement-conflict' AND c.row_identity = '" . SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/replace-dir-with-file')) . "' AND r.source_branch = 'feature-file-resolve'"), + 1, + 'rerunning after source directory-to-file resolution does not rediscover the resolved type replacement conflict' + ); + assert_same( + (int)scalar($metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE c.table_name = '__files__' AND c.conflict_type = 'file-type-replacement-conflict' AND c.row_identity = '" . SQLite3::escapeString(cow_merge_file_identity_json('wp-content/uploads/replace-file-with-dir')) . "' AND r.source_branch = 'feature-file-resolve'"), + 1, + 'rerunning after source file-to-directory resolution does not rediscover the resolved type replacement conflict' + ); $reviewed_file_resolution_id = (int)$file_source_resolution['resolution_id']; $unreviewed_file_resolution_id = (int)$file_delete_resolution['resolution_id']; $file_resolution_review = cow_merge_review_record( @@ -7295,6 +8701,31 @@ static function (SQLite3 $db, string $sql, string $message): void { $manual_meta->close(); $schema_column_conflict_id = (int)scalar($metadata, "SELECT id FROM merge_conflicts WHERE table_name = 'plugin_items' AND column_name = 'review_note' AND conflict_type = 'schema-source-changed' ORDER BY id DESC LIMIT 1"); + cow_merge_review_record( + $metadata, + 'conflict', + $schema_column_conflict_id, + 'reviewed', + 'Schema column reviewed before revalidation.', + 'cow-test' + ); + $schema_review_audit = cow_merge_audit_report($metadata, $manual_run_id, 10, [ + 'records' => 'conflicts', + 'review_status' => 'reviewed', + ]); + assert_same(count($schema_review_audit['conflicts']), 1, 'schema review audit returns reviewed schema conflicts'); + assert_same($schema_review_audit['conflicts'][0]['stale_status'] ?? null, 'unknown', 'schema conflicts are not marked fresh or stale by generic audit'); + $schema_revalidate = cow_merge_revalidate_reviewed_conflicts($metadata, $manual_run_id, 'cow-revalidate'); + assert_same($schema_revalidate['checked'], 2, 'schema conflict revalidation checks conflicts in the selected run'); + assert_same($schema_revalidate['reviewed'], 1, 'schema conflict revalidation sees reviewed schema conflicts'); + assert_same($schema_revalidate['stale'], 0, 'schema conflict revalidation does not infer stale state generically'); + assert_same($schema_revalidate['carried'], 0, 'schema conflict revalidation does not carry schema conflicts without schema-specific evidence'); + assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_revalidations WHERE conflict_id = $schema_column_conflict_id"), 0, 'schema conflict revalidation records no guarded payload without schema-specific evidence'); + assert_throws( + fn() => cow_merge_resolve_conflict($metadata, $schema_column_conflict_id, 'source', false, 'Try guarded schema resolution.', 'cow-test', true), + '--after-revalidate currently supports database row/cell conflicts and filesystem conflicts only', + 'schema conflicts have an explicit guarded revalidation boundary' + ); $schema_column_dry = cow_merge_resolve_conflict( $metadata, $schema_column_conflict_id, @@ -7550,6 +8981,40 @@ static function (SQLite3 $db, string $sql, string $message): void { assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_decisions WHERE column_name IN ('plugin_items_source_view', 'plugin_items_source_insert') AND decision = 'source-applied'"), 2, 'source-added view and trigger decisions are auditable'); assert_same((int)scalar($metadata, "SELECT COUNT(*) FROM merge_decisions WHERE column_name IN ('plugin_items_target_view', 'plugin_items_target_insert') AND decision = 'target-kept'"), 2, 'target-added view and trigger preservation decisions are auditable'); + $schema_view_order_base = $tmp . '/schema-view-order-base.sqlite'; + $schema_view_order_source = $tmp . '/schema-view-order-source.sqlite'; + $schema_view_order_target = $tmp . '/schema-view-order-target.sqlite'; + create_base_db($schema_view_order_base); + copy($schema_view_order_base, $schema_view_order_source); + copy($schema_view_order_base, $schema_view_order_target); + $db = open_db($schema_view_order_source); + $db->exec('CREATE VIEW plugin_z_source_parent_view AS SELECT item_id, label FROM plugin_items'); + $db->exec('CREATE VIEW plugin_m_source_child_view AS SELECT item_id, label FROM plugin_z_source_parent_view'); + $db->exec('CREATE VIEW plugin_a_source_grandchild_view AS SELECT label FROM plugin_m_source_child_view'); + $db->close(); + + $schema_view_order_result = cow_merge_databases( + $schema_view_order_base, + $schema_view_order_source, + $schema_view_order_target, + $metadata, + 'feature-source-view-order', + 'main' + ); + $schema_view_order_run_id = (int)$schema_view_order_result['run_id']; + assert_same($schema_view_order_result['status'], 'completed', 'source-added dependent views merge automatically even when lexical order is unsafe'); + assert_same(scalar($schema_view_order_target, "SELECT label FROM plugin_a_source_grandchild_view WHERE label = 'Alpha'"), 'Alpha', 'source-added dependent view chain remains queryable after merge'); + assert_same( + (int)scalar($metadata, "SELECT COUNT(*) FROM merge_decisions WHERE run_id = $schema_view_order_run_id AND column_name IN ('plugin_z_source_parent_view', 'plugin_m_source_child_view', 'plugin_a_source_grandchild_view') AND decision = 'source-applied'"), + 3, + 'source-added dependent view creation order is auditable' + ); + assert_same( + (int)scalar($metadata, "SELECT COUNT(*) FROM merge_conflicts WHERE run_id = $schema_view_order_run_id AND conflict_type = 'schema-source-added-view'"), + 0, + 'source-added dependent view ordering does not create review-only schema conflicts' + ); + $schema_same_base = $tmp . '/schema-same-base.sqlite'; $schema_same_source = $tmp . '/schema-same-source.sqlite'; $schema_same_target = $tmp . '/schema-same-target.sqlite'; @@ -12002,6 +13467,57 @@ static function (SQLite3 $db, string $sql, string $message): void { ); assert_same(scalar($keyless_conflict_target, "SELECT value FROM plugin_keyless WHERE rowid = 1"), 'source keyless conflict', 'rerunning after source keyless cell resolution keeps the audited source value'); + $keyless_revalidate_base = $tmp . '/keyless-revalidate-base.sqlite'; + $keyless_revalidate_source = $tmp . '/keyless-revalidate-source.sqlite'; + $keyless_revalidate_target = $tmp . '/keyless-revalidate-target.sqlite'; + $keyless_revalidate_metadata = $tmp . '/.forkpress/cow/merge/keyless-revalidate-metadata.sqlite'; + create_base_db($keyless_revalidate_base); + copy($keyless_revalidate_base, $keyless_revalidate_source); + copy($keyless_revalidate_base, $keyless_revalidate_target); + $db = open_db($keyless_revalidate_source); + $db->exec("UPDATE plugin_keyless SET value = 'source keyless revalidate' WHERE rowid = 1"); + $db->close(); + $db = open_db($keyless_revalidate_target); + $db->exec("UPDATE plugin_keyless SET value = 'target keyless revalidate' WHERE rowid = 1"); + $db->close(); + $keyless_revalidate_result = cow_merge_databases($keyless_revalidate_base, $keyless_revalidate_source, $keyless_revalidate_target, $keyless_revalidate_metadata, 'feature-keyless-revalidate', 'main'); + $keyless_revalidate_run_id = (int)$keyless_revalidate_result['run_id']; + assert_same($keyless_revalidate_result['status'], 'completed_with_conflicts', 'keyless stale-revalidation fixture starts with a cell conflict'); + $keyless_revalidate_conflict_id = (int)scalar($keyless_revalidate_metadata, "SELECT id FROM merge_conflicts WHERE table_name = 'plugin_keyless' AND column_name = 'value' AND conflict_type = 'cell-conflict' ORDER BY id DESC LIMIT 1"); + cow_merge_review_record( + $keyless_revalidate_metadata, + 'conflict', + $keyless_revalidate_conflict_id, + 'reviewed', + 'Review original keyless row before replacement.', + 'cow-test' + ); + $db = open_db($keyless_revalidate_target); + $db->exec('DELETE FROM plugin_keyless WHERE rowid = 1'); + $db->exec("INSERT INTO plugin_keyless (label, value) VALUES ('Replacement keyless', 'runtime replacement')"); + $db->close(); + assert_same((int)scalar($keyless_revalidate_target, 'SELECT rowid FROM plugin_keyless'), 1, 'keyless stale-revalidation fixture reuses the reviewed rowid'); + cow_merge_track_row_identity_events( + $keyless_revalidate_target, + $keyless_revalidate_metadata, + 'main', + [ + ['id' => 1, 'table_name' => 'plugin_keyless', 'op' => 'delete', 'rowid' => 1, 'row' => ['label' => 'Base keyless', 'value' => 'target keyless revalidate']], + ['id' => 2, 'table_name' => 'plugin_keyless', 'op' => 'insert', 'rowid' => 1, 'row' => ['label' => 'Replacement keyless', 'value' => 'runtime replacement']], + ] + ); + $keyless_revalidated = cow_merge_revalidate_reviewed_conflicts($keyless_revalidate_metadata, $keyless_revalidate_run_id, 'cow-revalidate'); + assert_same($keyless_revalidated['carried'], 1, 'keyless rowid replacement carries stale review intent'); + assert_same(scalar($keyless_revalidate_metadata, "SELECT revalidation_class FROM merge_revalidations WHERE conflict_id = $keyless_revalidate_conflict_id ORDER BY id DESC LIMIT 1"), 'incompatible', 'keyless rowid replacement is classified as incompatible'); + $keyless_revalidated_audit = cow_merge_audit_report($keyless_revalidate_metadata, $keyless_revalidate_run_id, 10, ['records' => 'conflicts']); + $keyless_revalidated_conflicts = array_values(array_filter($keyless_revalidated_audit['conflicts'], fn($row) => (int)($row['id'] ?? 0) === $keyless_revalidate_conflict_id)); + assert_same($keyless_revalidated_conflicts[0]['revalidation_class'] ?? null, 'incompatible', 'keyless audit exposes incompatible rowid replacement'); + assert_throws( + fn() => cow_merge_resolve_conflict($keyless_revalidate_metadata, $keyless_revalidate_conflict_id, 'source', true, 'Do not apply source over replacement keyless row.', 'cow-test', true), + 'target row no longer exists', + 'after-revalidate does not apply reviewed source over an incompatible keyless replacement' + ); + $keyless_unique_base = $tmp . '/keyless-unique-base.sqlite'; $keyless_unique_source = $tmp . '/keyless-unique-source.sqlite'; $keyless_unique_target = $tmp . '/keyless-unique-target.sqlite'; @@ -12574,6 +14090,10 @@ public function query(string $sql): int|false { } require_once __DIR__ . '/../../wp-plugin/forkpress-wp.php'; + assert_true(forkpress_branch_name_is_valid('feature_safe-1'), 'WP branch UI accepts CLI-compatible branch names'); + assert_true(!forkpress_branch_name_is_valid('feature branch'), 'WP branch UI rejects branch names with spaces'); + assert_true(!forkpress_branch_name_is_valid('wp'), 'WP branch UI rejects reserved routing branch names'); + assert_true(!forkpress_branch_name_is_valid('Admin'), 'WP branch UI rejects reserved routing branch names case-insensitively'); $GLOBALS['wpdb']->query('CREATE TABLE plugin_runtime_keyless (label TEXT, value TEXT)'); $GLOBALS['wpdb']->query("INSERT INTO plugin_runtime_keyless (label, value) VALUES ('First runtime row', 'base')"); $GLOBALS['wpdb']->query('DELETE FROM plugin_runtime_keyless WHERE rowid = 1'); @@ -12890,6 +14410,58 @@ static function (array $snapshot) use ($band_restore_failure_db): void { assert_same($result['allocated'], 0, 'rerunning allocation on the same branch DB reuses existing bands'); assert_same($result['reused'], 3, 'rerunning allocation records existing bands as reused'); + $birth_cleanup_db = $tmp . '/band-birth-cleanup.sqlite'; + copy($band_base, $birth_cleanup_db); + $db = open_db($birth_cleanup_db); + $db->exec('CREATE TABLE plugin_birth_keyless (label TEXT NOT NULL)'); + $db->exec("INSERT INTO plugin_birth_keyless (label) VALUES ('birth cleanup keyless')"); + $db->close(); + cow_merge_allocate_autoincrement_bands($birth_cleanup_db, $band_metadata, 'feature-birth-cleanup'); + cow_merge_capture_row_identities($birth_cleanup_db, $band_metadata, 'feature-birth-cleanup'); + $birth_validation = cow_merge_validate_branch_birth_metadata($birth_cleanup_db, $band_metadata, 'feature-birth-cleanup'); + assert_same($birth_validation['status'], 'validated', 'branch birth metadata validation accepts complete ID bands and row identities'); + assert_true($birth_validation['autoincrement_tables'] >= 3, 'branch birth metadata validation counts AUTOINCREMENT tables'); + assert_true($birth_validation['keyless_rows'] >= 1, 'branch birth metadata validation counts keyless row identities'); + $birth_validation_cli = run_merge_cli([ + 'validate-branch-birth-metadata', + '--db', $birth_cleanup_db, + '--metadata-db', $band_metadata, + '--branch', 'feature-birth-cleanup', + ]); + assert_same($birth_validation_cli['status'], 0, 'branch birth metadata validation CLI accepts complete branch metadata'); + assert_true((int)scalar($band_metadata, "SELECT COUNT(*) FROM merge_autoincrement_bands WHERE branch_name = 'feature-birth-cleanup'") > 0, 'branch birth cleanup fixture creates band metadata'); + assert_true((int)scalar($band_metadata, "SELECT COUNT(*) FROM merge_row_identities WHERE branch_name = 'feature-birth-cleanup'") > 0, 'branch birth cleanup fixture creates row identity metadata'); + $birth_cleanup = cow_merge_cleanup_branch_birth_metadata($band_metadata, 'feature-birth-cleanup'); + assert_true($birth_cleanup['cleaned'] > 0, 'branch birth metadata cleanup reports removed rows'); + assert_same((int)scalar($band_metadata, "SELECT COUNT(*) FROM merge_autoincrement_bands WHERE branch_name = 'feature-birth-cleanup'"), 0, 'branch birth metadata cleanup removes allocated bands'); + assert_same((int)scalar($band_metadata, "SELECT COUNT(*) FROM merge_row_identities WHERE branch_name = 'feature-birth-cleanup'"), 0, 'branch birth metadata cleanup removes active row identities'); + assert_same((int)scalar($band_metadata, "SELECT COUNT(*) FROM merge_row_identity_history WHERE branch_name = 'feature-birth-cleanup'"), 0, 'branch birth metadata cleanup removes row identity history'); + assert_same((int)scalar($band_metadata, "SELECT COUNT(*) FROM merge_runs WHERE source_branch = 'feature-birth-cleanup'"), 0, 'branch birth metadata cleanup removes branch birth runs'); + assert_true((int)scalar($band_metadata, "SELECT COUNT(*) FROM merge_autoincrement_bands WHERE branch_name = 'feature-band-a'") > 0, 'branch birth metadata cleanup leaves unrelated branch bands intact'); + + $missing_birth_band_db = $tmp . '/missing-birth-band.sqlite'; + $missing_birth_band_metadata = $tmp . '/.forkpress/cow/merge/missing-birth-band-metadata.sqlite'; + copy($band_base, $missing_birth_band_db); + cow_merge_capture_row_identities($missing_birth_band_db, $missing_birth_band_metadata, 'feature-missing-birth-band'); + $missing_birth_band_cli = run_merge_cli([ + 'validate-branch-birth-metadata', + '--db', $missing_birth_band_db, + '--metadata-db', $missing_birth_band_metadata, + '--branch', 'feature-missing-birth-band', + ]); + assert_true($missing_birth_band_cli['status'] !== 0, 'branch birth metadata validation CLI rejects missing ID bands'); + assert_true(str_contains($missing_birth_band_cli['output'], 'AUTOINCREMENT ID band'), 'branch birth metadata validation explains missing ID bands'); + + $missing_birth_identity_db = $tmp . '/missing-birth-identity.sqlite'; + $missing_birth_identity_metadata = $tmp . '/.forkpress/cow/merge/missing-birth-identity-metadata.sqlite'; + copy($band_base, $missing_birth_identity_db); + cow_merge_allocate_autoincrement_bands($missing_birth_identity_db, $missing_birth_identity_metadata, 'feature-missing-birth-identity'); + assert_throws( + fn() => cow_merge_validate_branch_birth_metadata($missing_birth_identity_db, $missing_birth_identity_metadata, 'feature-missing-birth-identity'), + 'row identity for plugin_keyless rowid', + 'branch birth metadata validation rejects missing no-primary-key row identities' + ); + $result = cow_merge_allocate_autoincrement_bands($band_feature_b, $band_metadata, 'feature-band-b'); assert_same($result['allocated'], 3, 'second branch receives its own bands'); $db = open_db($band_feature_b); @@ -12898,55 +14470,3965 @@ static function (array $snapshot) use ($band_restore_failure_db): void { $post_id_b = (int)scalar($band_feature_b, "SELECT MAX(ID) FROM wp_posts"); assert_true($post_id_b > $post_id_a, 'independent branches do not allocate colliding post IDs'); - $band_ref_base = $tmp . '/band-ref-base.sqlite'; - $band_ref_source = $tmp . '/band-ref-source.sqlite'; - $band_ref_target = $tmp . '/band-ref-target.sqlite'; - create_base_db($band_ref_base); - $db = open_db($band_ref_base); - $db->exec('CREATE TABLE wp_postmeta (meta_id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER, meta_key TEXT, meta_value TEXT)'); - $db->close(); - copy($band_ref_base, $band_ref_source); - copy($band_ref_base, $band_ref_target); - cow_merge_allocate_autoincrement_bands($band_ref_source, $band_metadata, 'feature-band-ref-source'); - cow_merge_allocate_autoincrement_bands($band_ref_target, $band_metadata, 'feature-band-ref-target'); - $db = open_db($band_ref_source); - $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status) VALUES ('Banded source ref', '', 'publish')"); - $band_ref_source_id = (int)$db->lastInsertRowID(); - $band_ref_source_json = json_encode(['linkedPostId' => $band_ref_source_id, 'branch' => 'source'], JSON_UNESCAPED_SLASHES); - $band_ref_source_serialized = 'a:2:{s:12:"linkedPostId";i:' . $band_ref_source_id . ';s:6:"branch";s:6:"source";}'; - $stmt = $db->prepare('UPDATE wp_posts SET post_content = :content WHERE ID = :id'); - $stmt->bindValue(':content', $band_ref_source_json, SQLITE3_TEXT); - $stmt->bindValue(':id', $band_ref_source_id, SQLITE3_INTEGER); + $band_explicit_base = $tmp . '/band-explicit-base.sqlite'; + $band_explicit_source = $tmp . '/band-explicit-source.sqlite'; + $band_explicit_target = $tmp . '/band-explicit-target.sqlite'; + $band_explicit_metadata = $tmp . '/.forkpress/cow/merge/band-explicit-metadata.sqlite'; + copy($band_base, $band_explicit_base); + copy($band_base, $band_explicit_source); + copy($band_base, $band_explicit_target); + cow_merge_allocate_autoincrement_bands($band_explicit_source, $band_explicit_metadata, 'feature-band-explicit-source'); + $db = open_db($band_explicit_source); + $db->exec("INSERT INTO wp_posts (ID, post_title, post_content, post_status) VALUES (2, 'Imported explicit post', 'explicit id import', 'publish')"); + $db->exec("INSERT INTO plugin_autoinc (id, label) VALUES (2, 'imported explicit plugin row')"); + $db->close(); + $band_explicit_result = cow_merge_databases( + $band_explicit_base, + $band_explicit_source, + $band_explicit_target, + $band_explicit_metadata, + 'feature-band-explicit-source', + 'main' + ); + assert_same($band_explicit_result['status'], 'completed_with_conflicts', 'explicit source IDs outside the branch band are held for review'); + assert_same((int)scalar($band_explicit_target, "SELECT COUNT(*) FROM wp_posts WHERE ID = 2"), 0, 'out-of-band explicit source ID is not applied automatically'); + assert_same((int)scalar($band_explicit_target, "SELECT COUNT(*) FROM plugin_autoinc WHERE id = 2"), 0, 'out-of-band explicit plugin AUTOINCREMENT ID is not applied automatically'); + assert_same( + (int)scalar($band_explicit_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-explicit-source' AND c.table_name = 'wp_posts' AND c.conflict_type = 'row-target-constraint'"), + 1, + 'out-of-band explicit source ID records a reviewable row conflict' + ); + assert_same( + (int)scalar($band_explicit_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-explicit-source' AND c.table_name = 'plugin_autoinc' AND c.conflict_type = 'row-target-constraint'"), + 1, + 'out-of-band explicit plugin AUTOINCREMENT ID records a reviewable row conflict' + ); + assert_true( + str_contains((string)scalar($band_explicit_metadata, "SELECT reason FROM merge_decisions d JOIN merge_runs r ON r.id = d.run_id WHERE r.source_branch = 'feature-band-explicit-source' AND d.table_name = 'wp_posts' AND d.decision = 'target-wins' ORDER BY d.id DESC LIMIT 1"), 'outside reserved branch band'), + 'out-of-band explicit source ID explains the reserved-band violation' + ); + assert_true( + str_contains((string)scalar($band_explicit_metadata, "SELECT reason FROM merge_decisions d JOIN merge_runs r ON r.id = d.run_id WHERE r.source_branch = 'feature-band-explicit-source' AND d.table_name = 'plugin_autoinc' AND d.decision = 'target-wins' ORDER BY d.id DESC LIMIT 1"), 'outside reserved branch band'), + 'out-of-band explicit plugin AUTOINCREMENT ID explains the reserved-band violation' + ); + + $band_rewrite_base = $tmp . '/band-rewrite-base.sqlite'; + $band_rewrite_source = $tmp . '/band-rewrite-source.sqlite'; + $band_rewrite_target = $tmp . '/band-rewrite-target.sqlite'; + $band_rewrite_metadata = $tmp . '/.forkpress/cow/merge/band-rewrite-metadata.sqlite'; + copy($band_base, $band_rewrite_base); + copy($band_base, $band_rewrite_source); + copy($band_base, $band_rewrite_target); + cow_merge_allocate_autoincrement_bands($band_rewrite_source, $band_rewrite_metadata, 'feature-band-rewrite-source'); + $db = open_db($band_rewrite_source); + $db->exec("UPDATE wp_posts SET ID = 2, post_title = 'Rewritten explicit post ID' WHERE ID = 1"); + $db->exec("UPDATE plugin_autoinc SET id = 2, label = 'rewritten explicit plugin ID' WHERE id = 1"); + $db->close(); + $band_rewrite_result = cow_merge_databases( + $band_rewrite_base, + $band_rewrite_source, + $band_rewrite_target, + $band_rewrite_metadata, + 'feature-band-rewrite-source', + 'main' + ); + assert_same($band_rewrite_result['status'], 'completed_with_conflicts', 'out-of-band AUTOINCREMENT primary-key rewrites remain reviewable'); + assert_same((int)scalar($band_rewrite_target, 'SELECT COUNT(*) FROM wp_posts WHERE ID = 1'), 1, 'out-of-band primary-key rewrite does not delete the original target row by default'); + assert_same((int)scalar($band_rewrite_target, 'SELECT COUNT(*) FROM wp_posts WHERE ID = 2'), 0, 'out-of-band primary-key rewrite does not insert the rewritten explicit ID by default'); + assert_same((int)scalar($band_rewrite_target, 'SELECT COUNT(*) FROM plugin_autoinc WHERE id = 1'), 1, 'out-of-band plugin primary-key rewrite does not delete the original target row by default'); + assert_same((int)scalar($band_rewrite_target, 'SELECT COUNT(*) FROM plugin_autoinc WHERE id = 2'), 0, 'out-of-band plugin primary-key rewrite does not insert the rewritten explicit ID by default'); + assert_same( + (int)scalar($band_rewrite_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-rewrite-source' AND c.table_name = 'wp_posts' AND c.conflict_type = 'row-target-constraint'"), + 2, + 'out-of-band primary-key rewrite records reviewable insert and paired delete conflicts' + ); + assert_same( + (int)scalar($band_rewrite_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-rewrite-source' AND c.table_name = 'plugin_autoinc' AND c.conflict_type = 'row-target-constraint'"), + 2, + 'out-of-band plugin primary-key rewrite records reviewable insert and paired delete conflicts' + ); + assert_true( + str_contains((string)scalar($band_rewrite_metadata, "SELECT reason FROM merge_decisions d JOIN merge_runs r ON r.id = d.run_id WHERE r.source_branch = 'feature-band-rewrite-source' AND d.table_name = 'wp_posts' AND d.row_identity = '" . SQLite3::escapeString(cow_merge_identity_json(['ID' => 1])) . "' ORDER BY d.id DESC LIMIT 1"), 'held explicit AUTOINCREMENT insert'), + 'out-of-band primary-key rewrite explains why the paired source delete is held' + ); + assert_true( + str_contains((string)scalar($band_rewrite_metadata, "SELECT reason FROM merge_decisions d JOIN merge_runs r ON r.id = d.run_id WHERE r.source_branch = 'feature-band-rewrite-source' AND d.table_name = 'plugin_autoinc' AND d.row_identity = '" . SQLite3::escapeString(cow_merge_identity_json(['id' => 1])) . "' ORDER BY d.id DESC LIMIT 1"), 'held explicit AUTOINCREMENT insert'), + 'out-of-band plugin primary-key rewrite explains why the paired source delete is held' + ); + + $band_explicit_ref_base = $tmp . '/band-explicit-ref-base.sqlite'; + $band_explicit_ref_source = $tmp . '/band-explicit-ref-source.sqlite'; + $band_explicit_ref_target = $tmp . '/band-explicit-ref-target.sqlite'; + $band_explicit_ref_metadata = $tmp . '/.forkpress/cow/merge/band-explicit-ref-metadata.sqlite'; + copy($band_base, $band_explicit_ref_base); + $db = open_db($band_explicit_ref_base); + $db->exec('ALTER TABLE wp_posts ADD COLUMN post_parent INTEGER NOT NULL DEFAULT 0'); + $db->exec("ALTER TABLE wp_posts ADD COLUMN post_type TEXT NOT NULL DEFAULT 'post'"); + $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)'); + $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)'); + $db->exec('CREATE TABLE wp_term_relationships (object_id INTEGER NOT NULL, term_taxonomy_id INTEGER NOT NULL, term_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (object_id, term_taxonomy_id))'); + $db->exec("INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('page_for_posts', '1', 'yes')"); + $db->exec("INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('site_icon', '1', 'yes')"); + $db->exec("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_parent) VALUES (3, 'Base child page', '', 'publish', 0)"); + $db->exec("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_parent) VALUES (4, 'Base reusable block consumer', '

base reusable block content

', 'publish', 0)"); + $db->exec("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_parent) VALUES (5, 'Base image block consumer', '

base image block content

', 'publish', 0)"); + $db->exec("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_parent) VALUES (7, 'Base gallery block consumer', '

base gallery block content

', 'publish', 0)"); + $db->exec("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_parent) VALUES (8, 'Base media text block consumer', '

base media text block content

', 'publish', 0)"); + $db->exec("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_parent) VALUES (9, 'Base audio block consumer', '

base audio block content

', 'publish', 0)"); + $db->exec("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_parent) VALUES (10, 'Base cover block consumer', '

base cover block content

', 'publish', 0)"); + $db->exec("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_parent) VALUES (11, 'Base file block consumer', '

base file block content

', 'publish', 0)"); + $db->exec("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_parent) VALUES (12, 'Base video block consumer', '

base video block content

', 'publish', 0)"); + $db->exec("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_parent) VALUES (13, 'Base post navigation link consumer', '

base post navigation link content

', 'publish', 0)"); + $db->exec("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_parent) VALUES (14, 'Base navigation block consumer', '

base navigation block content

', 'publish', 0)"); + $db->exec("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (1, '_menu_item_menu_item_parent', '1')"); + $db->exec("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (1, '_forkpress_base_post_ref', 'base post metadata')"); + $db->exec("INSERT INTO wp_comments (comment_post_ID, comment_content) VALUES (1, 'Base comment reference')"); + $db->exec("INSERT INTO wp_comments (comment_post_ID, comment_content) VALUES (1, 'Base threaded comment reference')"); + $db->exec("INSERT INTO wp_commentmeta (comment_id, meta_key, meta_value) VALUES (1, '_forkpress_base_comment_ref', 'base comment metadata')"); + $db->exec('INSERT INTO wp_term_relationships (object_id, term_taxonomy_id, term_order) VALUES (1, 20, 0)'); + $db->close(); + copy($band_explicit_ref_base, $band_explicit_ref_source); + copy($band_explicit_ref_base, $band_explicit_ref_target); + cow_merge_allocate_autoincrement_bands($band_explicit_ref_source, $band_explicit_ref_metadata, 'feature-band-explicit-ref-source'); + $db = open_db($band_explicit_ref_source); + $db->exec("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_type) VALUES (2, 'Imported explicit reusable block', 'explicit id import with child rows', 'publish', 'wp_block')"); + $db->exec("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_type) VALUES (6, 'Imported explicit image attachment', '', 'inherit', 'attachment')"); + $db->exec("UPDATE wp_posts SET post_parent = 2 WHERE ID = 3"); + $band_explicit_ref_reusable_content = ''; + $stmt = $db->prepare('UPDATE wp_posts SET post_content = :content WHERE ID = 4'); + $stmt->bindValue(':content', $band_explicit_ref_reusable_content, SQLITE3_TEXT); $stmt->execute(); - $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:id, '_forkpress_json_ref', :json), (:id, '_forkpress_serialized_ref', :serialized)"); - $stmt->bindValue(':id', $band_ref_source_id, SQLITE3_INTEGER); - $stmt->bindValue(':json', $band_ref_source_json, SQLITE3_TEXT); - $stmt->bindValue(':serialized', $band_ref_source_serialized, SQLITE3_TEXT); + $band_explicit_ref_image_content = '
'; + $stmt = $db->prepare('UPDATE wp_posts SET post_content = :content WHERE ID = 5'); + $stmt->bindValue(':content', $band_explicit_ref_image_content, SQLITE3_TEXT); $stmt->execute(); - $db->close(); - $db = open_db($band_ref_target); - $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status) VALUES ('Banded target ref', '', 'publish')"); - $band_ref_target_id = (int)$db->lastInsertRowID(); - $band_ref_target_json = json_encode(['linkedPostId' => $band_ref_target_id, 'branch' => 'target'], JSON_UNESCAPED_SLASHES); - $band_ref_target_serialized = 'a:2:{s:12:"linkedPostId";i:' . $band_ref_target_id . ';s:6:"branch";s:6:"target";}'; - $stmt = $db->prepare('UPDATE wp_posts SET post_content = :content WHERE ID = :id'); - $stmt->bindValue(':content', $band_ref_target_json, SQLITE3_TEXT); - $stmt->bindValue(':id', $band_ref_target_id, SQLITE3_INTEGER); + $band_explicit_ref_gallery_content = ''; + $stmt = $db->prepare('UPDATE wp_posts SET post_content = :content WHERE ID = 7'); + $stmt->bindValue(':content', $band_explicit_ref_gallery_content, SQLITE3_TEXT); $stmt->execute(); - $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:id, '_forkpress_json_ref', :json), (:id, '_forkpress_serialized_ref', :serialized)"); - $stmt->bindValue(':id', $band_ref_target_id, SQLITE3_INTEGER); - $stmt->bindValue(':json', $band_ref_target_json, SQLITE3_TEXT); - $stmt->bindValue(':serialized', $band_ref_target_serialized, SQLITE3_TEXT); + $band_explicit_ref_media_text_content = '
'; + $stmt = $db->prepare('UPDATE wp_posts SET post_content = :content WHERE ID = 8'); + $stmt->bindValue(':content', $band_explicit_ref_media_text_content, SQLITE3_TEXT); $stmt->execute(); - $db->close(); - assert_true($band_ref_source_id !== $band_ref_target_id, 'banded WordPress branches assign distinct IDs before serialized references are written'); - $band_ref_result = cow_merge_databases($band_ref_base, $band_ref_source, $band_ref_target, $band_metadata, 'feature-band-ref-source', 'feature-band-ref-target'); - assert_same($band_ref_result['status'], 'completed', 'banded WordPress reference rows merge without ID collision repair'); - assert_same(scalar($band_ref_target, "SELECT post_content FROM wp_posts WHERE ID = $band_ref_source_id"), $band_ref_source_json, 'source JSON post reference remains valid after banded merge'); - assert_same(scalar($band_ref_target, "SELECT post_content FROM wp_posts WHERE ID = $band_ref_target_id"), $band_ref_target_json, 'target JSON post reference remains valid after banded merge'); - assert_same(scalar($band_ref_target, "SELECT meta_value FROM wp_postmeta WHERE post_id = $band_ref_source_id AND meta_key = '_forkpress_serialized_ref'"), $band_ref_source_serialized, 'source serialized post reference remains valid after banded merge'); - assert_same(scalar($band_ref_target, "SELECT meta_value FROM wp_postmeta WHERE post_id = $band_ref_target_id AND meta_key = '_forkpress_serialized_ref'"), $band_ref_target_serialized, 'target serialized post reference remains valid after banded merge'); - assert_same((int)scalar($band_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-ref-source'"), 0, 'banded WordPress reference merge records no ID collision conflicts'); + $band_explicit_ref_audio_content = '
'; + $stmt = $db->prepare('UPDATE wp_posts SET post_content = :content WHERE ID = 9'); + $stmt->bindValue(':content', $band_explicit_ref_audio_content, SQLITE3_TEXT); + $stmt->execute(); + $band_explicit_ref_cover_content = '
'; + $stmt = $db->prepare('UPDATE wp_posts SET post_content = :content WHERE ID = 10'); + $stmt->bindValue(':content', $band_explicit_ref_cover_content, SQLITE3_TEXT); + $stmt->execute(); + $band_explicit_ref_file_content = '
'; + $stmt = $db->prepare('UPDATE wp_posts SET post_content = :content WHERE ID = 11'); + $stmt->bindValue(':content', $band_explicit_ref_file_content, SQLITE3_TEXT); + $stmt->execute(); + $band_explicit_ref_video_content = '
'; + $stmt = $db->prepare('UPDATE wp_posts SET post_content = :content WHERE ID = 12'); + $stmt->bindValue(':content', $band_explicit_ref_video_content, SQLITE3_TEXT); + $stmt->execute(); + $band_explicit_ref_post_nav_content = ''; + $stmt = $db->prepare('UPDATE wp_posts SET post_content = :content WHERE ID = 13'); + $stmt->bindValue(':content', $band_explicit_ref_post_nav_content, SQLITE3_TEXT); + $stmt->execute(); + $band_explicit_ref_navigation_content = ''; + $stmt = $db->prepare('UPDATE wp_posts SET post_content = :content WHERE ID = 14'); + $stmt->bindValue(':content', $band_explicit_ref_navigation_content, SQLITE3_TEXT); + $stmt->execute(); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_parent) VALUES ('Imported child page behind explicit parent', 'child of held explicit id', 'publish', 2)"); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (2, '_forkpress_import_ref', :value)"); + $stmt->bindValue(':value', json_encode(['post_id' => 2, 'origin' => 'import'], JSON_UNESCAPED_SLASHES), SQLITE3_TEXT); + $stmt->execute(); + $db->exec("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (1, '_thumbnail_id', '2')"); + $db->exec("INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('page_on_front', '2', 'yes')"); + $band_explicit_ref_sticky_posts = serialize([2]); + $stmt = $db->prepare("INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('sticky_posts', :value, 'yes')"); + $stmt->bindValue(':value', $band_explicit_ref_sticky_posts, SQLITE3_TEXT); + $stmt->execute(); + $band_explicit_ref_theme_mods = serialize(['custom_logo' => 2]); + $stmt = $db->prepare("INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('theme_mods_imported_post_refs', :value, 'yes')"); + $stmt->bindValue(':value', $band_explicit_ref_theme_mods, SQLITE3_TEXT); + $stmt->execute(); + $band_explicit_ref_media_widget = serialize([ + 2 => [ + 'attachment_id' => 2, + 'caption' => 'Imported media widget behind held explicit attachment', + ], + ]); + $stmt = $db->prepare("INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('widget_media_image', :value, 'yes')"); + $stmt->bindValue(':value', $band_explicit_ref_media_widget, SQLITE3_TEXT); + $stmt->execute(); + $db->exec("UPDATE wp_options SET option_value = '2' WHERE option_name = 'page_for_posts'"); + $db->exec("UPDATE wp_options SET option_value = '2' WHERE option_name = 'site_icon'"); + $band_explicit_ref_updated_theme_mods = serialize(['custom_logo' => 2]); + $stmt = $db->prepare("UPDATE wp_options SET option_value = :value WHERE option_name = 'theme_mods_test'"); + $stmt->bindValue(':value', $band_explicit_ref_updated_theme_mods, SQLITE3_TEXT); + $stmt->execute(); + $db->exec("UPDATE wp_postmeta SET post_id = 2 WHERE meta_key = '_forkpress_base_post_ref'"); + $db->exec("UPDATE wp_postmeta SET meta_value = '2' WHERE post_id = 1 AND meta_key = '_menu_item_menu_item_parent'"); + $db->exec("UPDATE wp_comments SET comment_post_ID = 2 WHERE comment_content = 'Base comment reference'"); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_type) VALUES ('Post type menu item behind explicit post', '', 'publish', 'nav_menu_item')"); + $band_explicit_ref_menu_item_id = (int)$db->lastInsertRowID(); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:menu_item_id, '_menu_item_type', 'post_type'), (:menu_item_id, '_menu_item_object_id', '2')"); + $stmt->bindValue(':menu_item_id', $band_explicit_ref_menu_item_id, SQLITE3_INTEGER); + $stmt->execute(); + $stmt = $db->prepare("INSERT INTO wp_comments (comment_post_ID, comment_content) VALUES (2, 'Comment behind held explicit post')"); + $stmt->execute(); + $band_explicit_ref_comment_id = (int)$db->lastInsertRowID(); + $stmt = $db->prepare("UPDATE wp_comments SET comment_parent = :comment_parent WHERE comment_content = 'Base threaded comment reference'"); + $stmt->bindValue(':comment_parent', $band_explicit_ref_comment_id, SQLITE3_INTEGER); + $stmt->execute(); + $stmt = $db->prepare("INSERT INTO wp_commentmeta (comment_id, meta_key, meta_value) VALUES (:comment_id, '_forkpress_comment_ref', 'comment metadata behind held explicit post')"); + $stmt->bindValue(':comment_id', $band_explicit_ref_comment_id, SQLITE3_INTEGER); + $stmt->execute(); + $stmt = $db->prepare("UPDATE wp_commentmeta SET comment_id = :comment_id WHERE meta_key = '_forkpress_base_comment_ref'"); + $stmt->bindValue(':comment_id', $band_explicit_ref_comment_id, SQLITE3_INTEGER); + $stmt->execute(); + $stmt = $db->prepare("INSERT INTO wp_comments (comment_post_ID, comment_content, comment_parent) VALUES (1, 'Threaded comment behind held explicit post comment', :comment_parent)"); + $stmt->bindValue(':comment_parent', $band_explicit_ref_comment_id, SQLITE3_INTEGER); + $stmt->execute(); + $band_explicit_ref_child_comment_id = (int)$db->lastInsertRowID(); + $stmt = $db->prepare("INSERT INTO wp_commentmeta (comment_id, meta_key, meta_value) VALUES (:comment_id, '_forkpress_child_comment_ref', 'child comment metadata behind held explicit post comment')"); + $stmt->bindValue(':comment_id', $band_explicit_ref_child_comment_id, SQLITE3_INTEGER); + $stmt->execute(); + $db->exec('UPDATE wp_term_relationships SET object_id = 2 WHERE object_id = 1 AND term_taxonomy_id = 20'); + $db->close(); + $band_explicit_ref_result = cow_merge_databases( + $band_explicit_ref_base, + $band_explicit_ref_source, + $band_explicit_ref_target, + $band_explicit_ref_metadata, + 'feature-band-explicit-ref-source', + 'main' + ); + assert_same($band_explicit_ref_result['status'], 'completed_with_conflicts', 'explicit source post IDs hold dependent postmeta for review'); + assert_same((int)scalar($band_explicit_ref_target, "SELECT COUNT(*) FROM wp_posts WHERE ID = 2"), 0, 'out-of-band explicit source post remains unapplied'); + assert_same((int)scalar($band_explicit_ref_target, "SELECT COUNT(*) FROM wp_posts WHERE ID = 6"), 0, 'out-of-band explicit source attachment remains unapplied'); + assert_same((int)scalar($band_explicit_ref_target, "SELECT post_parent FROM wp_posts WHERE ID = 3"), 0, 'updated child posts pointing at a held explicit source post are not applied automatically'); + assert_same(scalar($band_explicit_ref_target, "SELECT post_content FROM wp_posts WHERE ID = 4"), '

base reusable block content

', 'updated reusable block refs pointing at a held explicit source block are not applied automatically'); + assert_same(scalar($band_explicit_ref_target, "SELECT post_content FROM wp_posts WHERE ID = 5"), '

base image block content

', 'updated image block refs pointing at a held explicit source attachment are not applied automatically'); + assert_same(scalar($band_explicit_ref_target, "SELECT post_content FROM wp_posts WHERE ID = 7"), '

base gallery block content

', 'updated gallery block refs pointing at a held explicit source attachment are not applied automatically'); + assert_same(scalar($band_explicit_ref_target, "SELECT post_content FROM wp_posts WHERE ID = 8"), '

base media text block content

', 'updated media-text block refs pointing at a held explicit source attachment are not applied automatically'); + assert_same(scalar($band_explicit_ref_target, "SELECT post_content FROM wp_posts WHERE ID = 9"), '

base audio block content

', 'updated audio block refs pointing at a held explicit source attachment are not applied automatically'); + assert_same(scalar($band_explicit_ref_target, "SELECT post_content FROM wp_posts WHERE ID = 10"), '

base cover block content

', 'updated cover block refs pointing at a held explicit source attachment are not applied automatically'); + assert_same(scalar($band_explicit_ref_target, "SELECT post_content FROM wp_posts WHERE ID = 11"), '

base file block content

', 'updated file block refs pointing at a held explicit source attachment are not applied automatically'); + assert_same(scalar($band_explicit_ref_target, "SELECT post_content FROM wp_posts WHERE ID = 12"), '

base video block content

', 'updated video block refs pointing at a held explicit source attachment are not applied automatically'); + assert_same(scalar($band_explicit_ref_target, "SELECT post_content FROM wp_posts WHERE ID = 13"), '

base post navigation link content

', 'updated post navigation link refs pointing at a held explicit source post are not applied automatically'); + assert_same(scalar($band_explicit_ref_target, "SELECT post_content FROM wp_posts WHERE ID = 14"), '

base navigation block content

', 'updated navigation block refs pointing at a held explicit source navigation post are not applied automatically'); + assert_same((int)scalar($band_explicit_ref_target, "SELECT COUNT(*) FROM wp_posts WHERE post_parent = 2"), 0, 'child posts pointing at a held explicit source post are not applied automatically'); + assert_same((int)scalar($band_explicit_ref_target, "SELECT COUNT(*) FROM wp_postmeta WHERE post_id = 2"), 0, 'postmeta pointing at a held explicit source post is not applied automatically'); + assert_same((int)scalar($band_explicit_ref_target, "SELECT COUNT(*) FROM wp_postmeta WHERE meta_key = '_thumbnail_id' AND meta_value = '2'"), 0, 'postmeta values pointing at a held explicit source post are not applied automatically'); + assert_same((int)scalar($band_explicit_ref_target, "SELECT COUNT(*) FROM wp_options WHERE option_name = 'page_on_front' AND option_value = '2'"), 0, 'scalar options pointing at a held explicit source post are not applied automatically'); + assert_same((int)scalar($band_explicit_ref_target, "SELECT COUNT(*) FROM wp_options WHERE option_name = 'sticky_posts'"), 0, 'serialized options pointing at a held explicit source post are not applied automatically'); + assert_same((int)scalar($band_explicit_ref_target, "SELECT COUNT(*) FROM wp_options WHERE option_name = 'theme_mods_imported_post_refs'"), 0, 'theme mods pointing at a held explicit source attachment are not applied automatically'); + assert_same((int)scalar($band_explicit_ref_target, "SELECT COUNT(*) FROM wp_options WHERE option_name = 'widget_media_image'"), 0, 'media widgets pointing at a held explicit source attachment are not applied automatically'); + assert_same(scalar($band_explicit_ref_target, "SELECT option_value FROM wp_options WHERE option_name = 'page_for_posts'"), '1', 'updated scalar options pointing at a held explicit source post are not applied automatically'); + assert_same(scalar($band_explicit_ref_target, "SELECT option_value FROM wp_options WHERE option_name = 'site_icon'"), '1', 'updated site icons pointing at a held explicit source attachment are not applied automatically'); + assert_same(scalar($band_explicit_ref_target, "SELECT option_value FROM wp_options WHERE option_name = 'theme_mods_test'"), 'a:1:{s:5:"color";s:4:"blue";}', 'updated theme mods pointing at a held explicit source attachment are not applied automatically'); + assert_same((int)scalar($band_explicit_ref_target, "SELECT post_id FROM wp_postmeta WHERE meta_key = '_forkpress_base_post_ref'"), 1, 'updated postmeta owners pointing at a held explicit source post are not applied automatically'); + assert_same(scalar($band_explicit_ref_target, "SELECT meta_value FROM wp_postmeta WHERE post_id = 1 AND meta_key = '_menu_item_menu_item_parent'"), '1', 'updated postmeta pointing at a held explicit source post is not applied automatically'); + assert_same((int)scalar($band_explicit_ref_target, "SELECT COUNT(*) FROM wp_postmeta WHERE post_id = $band_explicit_ref_menu_item_id AND meta_key = '_menu_item_object_id' AND meta_value = '2'"), 0, 'post-type menu item object references pointing at a held explicit source post are not applied automatically'); + assert_same((int)scalar($band_explicit_ref_target, "SELECT comment_post_ID FROM wp_comments WHERE comment_content = 'Base comment reference'"), 1, 'updated comments pointing at a held explicit source post are not applied automatically'); + assert_same((int)scalar($band_explicit_ref_target, "SELECT comment_parent FROM wp_comments WHERE comment_content = 'Base threaded comment reference'"), 0, 'updated threaded comments pointing at a held explicit source comment are not applied automatically'); + assert_same((int)scalar($band_explicit_ref_target, "SELECT COUNT(*) FROM wp_comments WHERE comment_post_ID = 2"), 0, 'comments pointing at a held explicit source post are not applied automatically'); + assert_same((int)scalar($band_explicit_ref_target, "SELECT COUNT(*) FROM wp_commentmeta WHERE comment_id = $band_explicit_ref_comment_id"), 0, 'comment metadata behind a held explicit source post is not applied automatically'); + assert_same((int)scalar($band_explicit_ref_target, "SELECT comment_id FROM wp_commentmeta WHERE meta_key = '_forkpress_base_comment_ref'"), 1, 'updated comment metadata behind a held explicit source post comment is not applied automatically'); + assert_same((int)scalar($band_explicit_ref_target, "SELECT COUNT(*) FROM wp_comments WHERE comment_parent = $band_explicit_ref_comment_id"), 0, 'threaded comments pointing at a held explicit source comment are not applied automatically'); + assert_same((int)scalar($band_explicit_ref_target, "SELECT COUNT(*) FROM wp_commentmeta WHERE comment_id = $band_explicit_ref_child_comment_id"), 0, 'threaded comment metadata behind a held explicit source comment is not applied automatically'); + assert_same((int)scalar($band_explicit_ref_target, "SELECT COUNT(*) FROM wp_term_relationships WHERE object_id = 2"), 0, 'term relationships pointing at a held explicit source post are not applied automatically'); + assert_same((int)scalar($band_explicit_ref_target, "SELECT COUNT(*) FROM wp_term_relationships WHERE object_id = 1 AND term_taxonomy_id = 20"), 1, 'updated term relationship object IDs pointing at a held explicit source post are not applied automatically'); + assert_same( + (int)scalar($band_explicit_ref_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-explicit-ref-source' AND c.table_name = 'wp_postmeta' AND c.conflict_type = 'row-target-constraint'"), + 5, + 'postmeta pointing at a held explicit source post records reviewable row conflicts' + ); + assert_same( + (int)scalar($band_explicit_ref_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-explicit-ref-source' AND c.table_name = 'wp_options' AND c.conflict_type = 'row-target-constraint'"), + 7, + 'options pointing at a held explicit source post record reviewable row conflicts' + ); + assert_same( + (int)scalar($band_explicit_ref_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-explicit-ref-source' AND c.table_name = 'wp_posts' AND c.conflict_type = 'row-target-constraint'"), + 14, + 'explicit parent, attachment, child, and block consumer posts behind them record reviewable row conflicts' + ); + assert_same( + (int)scalar($band_explicit_ref_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-explicit-ref-source' AND c.table_name = 'wp_comments' AND c.conflict_type = 'row-target-constraint'"), + 4, + 'comments pointing at a held explicit source post or comment record reviewable row conflicts' + ); + assert_same( + (int)scalar($band_explicit_ref_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-explicit-ref-source' AND c.table_name = 'wp_commentmeta' AND c.conflict_type = 'row-target-constraint'"), + 3, + 'comment metadata behind a held explicit source post or comment records reviewable row conflicts' + ); + assert_same( + (int)scalar($band_explicit_ref_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-explicit-ref-source' AND c.table_name = 'wp_term_relationships' AND c.conflict_type = 'row-target-constraint'"), + 2, + 'term relationships pointing at a held explicit source post record a reviewable row conflict' + ); + assert_true( + (int)scalar($band_explicit_ref_metadata, "SELECT COUNT(*) FROM merge_decisions d JOIN merge_runs r ON r.id = d.run_id WHERE r.source_branch = 'feature-band-explicit-ref-source' AND d.table_name = 'wp_postmeta' AND d.decision = 'target-wins' AND d.reason LIKE '%must merge before child row%'") === 5, + 'postmeta held behind an explicit source post explains the missing parent' + ); + assert_true( + (int)scalar($band_explicit_ref_metadata, "SELECT COUNT(*) FROM merge_decisions d JOIN merge_runs r ON r.id = d.run_id WHERE r.source_branch = 'feature-band-explicit-ref-source' AND d.table_name = 'wp_postmeta' AND d.decision = 'target-wins' AND d.reason LIKE 'source changed%'") === 2, + 'updated postmeta held behind an explicit source post explains that the source changed the row' + ); + assert_true( + (int)scalar($band_explicit_ref_metadata, "SELECT COUNT(*) FROM merge_decisions d JOIN merge_runs r ON r.id = d.run_id WHERE r.source_branch = 'feature-band-explicit-ref-source' AND d.table_name = 'wp_options' AND d.decision = 'target-wins' AND d.reason LIKE '%parent post must merge before child row%'") === 7, + 'options held behind an explicit source post explain the missing parent' + ); + assert_true( + (int)scalar($band_explicit_ref_metadata, "SELECT COUNT(*) FROM merge_decisions d JOIN merge_runs r ON r.id = d.run_id WHERE r.source_branch = 'feature-band-explicit-ref-source' AND d.table_name = 'wp_options' AND d.decision = 'target-wins' AND d.reason LIKE 'source changed%'") === 3, + 'updated options held behind an explicit source post explain that the source changed the option' + ); + assert_true( + (int)scalar($band_explicit_ref_metadata, "SELECT COUNT(*) FROM merge_decisions d JOIN merge_runs r ON r.id = d.run_id WHERE r.source_branch = 'feature-band-explicit-ref-source' AND d.table_name = 'wp_posts' AND d.decision = 'target-wins' AND d.reason LIKE '%parent post must merge before child row%'") === 12, + 'child posts and block content held behind an explicit source post explain the missing parent' + ); + assert_true( + (int)scalar($band_explicit_ref_metadata, "SELECT COUNT(*) FROM merge_decisions d JOIN merge_runs r ON r.id = d.run_id WHERE r.source_branch = 'feature-band-explicit-ref-source' AND d.table_name = 'wp_posts' AND d.decision = 'target-wins' AND d.reason LIKE 'source changed%'") === 11, + 'updated child posts and block content held behind an explicit source post explain that the source changed the row' + ); + assert_true( + (int)scalar($band_explicit_ref_metadata, "SELECT COUNT(*) FROM merge_decisions d JOIN merge_runs r ON r.id = d.run_id WHERE r.source_branch = 'feature-band-explicit-ref-source' AND d.table_name = 'wp_comments' AND d.decision = 'target-wins' AND d.reason LIKE 'source changed%'") === 2, + 'updated comments held behind an explicit source post explain that the source changed the row' + ); + assert_true( + (int)scalar($band_explicit_ref_metadata, "SELECT COUNT(*) FROM merge_decisions d JOIN merge_runs r ON r.id = d.run_id WHERE r.source_branch = 'feature-band-explicit-ref-source' AND d.table_name = 'wp_commentmeta' AND d.decision = 'target-wins' AND d.reason LIKE 'source changed%'") === 1, + 'updated comment metadata held behind an explicit source post comment explains that the source changed the row' + ); + assert_true( + (int)scalar($band_explicit_ref_metadata, "SELECT COUNT(*) FROM merge_decisions d JOIN merge_runs r ON r.id = d.run_id WHERE r.source_branch = 'feature-band-explicit-ref-source' AND d.table_name = 'wp_term_relationships' AND d.decision = 'target-wins' AND d.reason LIKE '%parent post must merge before child row%'") === 2, + 'term relationships held behind an explicit source post explain the missing parent' + ); + + $band_explicit_sticky_base = $tmp . '/band-explicit-sticky-base.sqlite'; + $band_explicit_sticky_source = $tmp . '/band-explicit-sticky-source.sqlite'; + $band_explicit_sticky_target = $tmp . '/band-explicit-sticky-target.sqlite'; + $band_explicit_sticky_metadata = $tmp . '/.forkpress/cow/merge/band-explicit-sticky-metadata.sqlite'; + copy($band_base, $band_explicit_sticky_base); + $band_explicit_sticky_base_value = serialize([1]); + $db = open_db($band_explicit_sticky_base); + $stmt = $db->prepare("INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('sticky_posts', :value, 'yes')"); + $stmt->bindValue(':value', $band_explicit_sticky_base_value, SQLITE3_TEXT); + $stmt->execute(); + $db->close(); + copy($band_explicit_sticky_base, $band_explicit_sticky_source); + copy($band_explicit_sticky_base, $band_explicit_sticky_target); + cow_merge_allocate_autoincrement_bands($band_explicit_sticky_source, $band_explicit_sticky_metadata, 'feature-band-explicit-sticky-source'); + $db = open_db($band_explicit_sticky_source); + $db->exec("INSERT INTO wp_posts (ID, post_title, post_content, post_status) VALUES (2, 'Imported explicit sticky post', 'explicit sticky id', 'publish')"); + $band_explicit_sticky_updated_value = serialize([2]); + $stmt = $db->prepare("UPDATE wp_options SET option_value = :value WHERE option_name = 'sticky_posts'"); + $stmt->bindValue(':value', $band_explicit_sticky_updated_value, SQLITE3_TEXT); + $stmt->execute(); + $db->close(); + $band_explicit_sticky_result = cow_merge_databases( + $band_explicit_sticky_base, + $band_explicit_sticky_source, + $band_explicit_sticky_target, + $band_explicit_sticky_metadata, + 'feature-band-explicit-sticky-source', + 'main' + ); + assert_same($band_explicit_sticky_result['status'], 'completed_with_conflicts', 'updated sticky posts behind explicit source post IDs remain reviewable'); + assert_same((int)scalar($band_explicit_sticky_target, 'SELECT COUNT(*) FROM wp_posts WHERE ID = 2'), 0, 'out-of-band explicit sticky source post remains unapplied'); + assert_same(scalar($band_explicit_sticky_target, "SELECT option_value FROM wp_options WHERE option_name = 'sticky_posts'"), $band_explicit_sticky_base_value, 'updated sticky posts pointing at a held explicit source post are not applied automatically'); + assert_same( + (int)scalar($band_explicit_sticky_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-explicit-sticky-source' AND c.table_name = 'wp_options' AND c.conflict_type = 'row-target-constraint'"), + 1, + 'updated sticky posts pointing at a held explicit source post record a reviewable option conflict' + ); + assert_true( + str_contains((string)scalar($band_explicit_sticky_metadata, "SELECT reason FROM merge_decisions d JOIN merge_runs r ON r.id = d.run_id WHERE r.source_branch = 'feature-band-explicit-sticky-source' AND d.table_name = 'wp_options' AND d.decision = 'target-wins' ORDER BY d.id DESC LIMIT 1"), 'parent post must merge before child row'), + 'updated sticky posts held behind an explicit source post explain the missing parent' + ); + + $band_explicit_term_base = $tmp . '/band-explicit-term-base.sqlite'; + $band_explicit_term_source = $tmp . '/band-explicit-term-source.sqlite'; + $band_explicit_term_target = $tmp . '/band-explicit-term-target.sqlite'; + $band_explicit_term_metadata = $tmp . '/.forkpress/cow/merge/band-explicit-term-metadata.sqlite'; + copy($band_base, $band_explicit_term_base); + $db = open_db($band_explicit_term_base); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status) VALUES ('Base taxonomy owner', '', 'publish')"); + $db->exec("ALTER TABLE wp_posts ADD COLUMN post_type TEXT NOT NULL DEFAULT 'post'"); + $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_terms (term_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, slug TEXT NOT NULL)'); + $db->exec('CREATE TABLE wp_termmeta (meta_id INTEGER PRIMARY KEY AUTOINCREMENT, term_id INTEGER NOT NULL, meta_key TEXT NOT NULL, meta_value TEXT NOT NULL)'); + $db->exec('CREATE TABLE wp_term_taxonomy (term_taxonomy_id INTEGER PRIMARY KEY AUTOINCREMENT, term_id INTEGER NOT NULL, taxonomy TEXT NOT NULL, description TEXT NOT NULL DEFAULT "", parent INTEGER NOT NULL DEFAULT 0, count INTEGER NOT NULL DEFAULT 0)'); + $db->exec('CREATE TABLE wp_term_relationships (object_id INTEGER NOT NULL, term_taxonomy_id INTEGER NOT NULL, term_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (object_id, term_taxonomy_id))'); + $db->exec("INSERT INTO wp_terms (term_id, name, slug) VALUES (1, 'Base category term', 'base-category-term')"); + $db->exec("INSERT INTO wp_term_taxonomy (term_taxonomy_id, term_id, taxonomy, description, count) VALUES (1, 1, 'category', '', 1)"); + $db->exec("INSERT INTO wp_term_taxonomy (term_taxonomy_id, term_id, taxonomy, description, parent, count) VALUES (3, 1, 'category', '', 0, 1)"); + $db->exec("INSERT INTO wp_termmeta (term_id, meta_key, meta_value) VALUES (1, '_forkpress_base_term_ref', 'base term metadata')"); + $db->exec('INSERT INTO wp_term_relationships (object_id, term_taxonomy_id, term_order) VALUES (1, 1, 0)'); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_type) VALUES ('Base taxonomy navigation link consumer', '

base taxonomy navigation link content

', 'publish', 'page')"); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_type) VALUES ('Base taxonomy query consumer', '

base taxonomy query content

', 'publish', 'page')"); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_type) VALUES ('Base tag query consumer', '

base tag query content

', 'publish', 'page')"); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_type) VALUES ('Base taxonomy menu item', '', 'publish', 'nav_menu_item')"); + $band_explicit_term_base_menu_item_id = (int)$db->lastInsertRowID(); + $db->exec("INSERT INTO wp_term_relationships (object_id, term_taxonomy_id, term_order) VALUES ($band_explicit_term_base_menu_item_id, 1, 0)"); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:menu_item_id, '_menu_item_type', 'taxonomy'), (:menu_item_id, '_menu_item_object_id', '1')"); + $stmt->bindValue(':menu_item_id', $band_explicit_term_base_menu_item_id, SQLITE3_INTEGER); + $stmt->execute(); + $band_explicit_term_base_theme_mods = serialize(['nav_menu_locations' => ['primary' => 1]]); + $stmt = $db->prepare("INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('theme_mods_existing_term_refs', :value, 'yes')"); + $stmt->bindValue(':value', $band_explicit_term_base_theme_mods, SQLITE3_TEXT); + $stmt->execute(); + $band_explicit_term_base_nav_widget = serialize([2 => ['nav_menu' => 1, 'title' => 'Base nav widget']]); + $stmt = $db->prepare("INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('widget_nav_menu', :value, 'yes')"); + $stmt->bindValue(':value', $band_explicit_term_base_nav_widget, SQLITE3_TEXT); + $stmt->execute(); + $db->close(); + copy($band_explicit_term_base, $band_explicit_term_source); + copy($band_explicit_term_base, $band_explicit_term_target); + cow_merge_allocate_autoincrement_bands($band_explicit_term_source, $band_explicit_term_metadata, 'feature-band-explicit-term-source'); + $db = open_db($band_explicit_term_source); + $db->exec("INSERT INTO wp_terms (term_id, name, slug) VALUES (2, 'Imported explicit term', 'imported-explicit-term')"); + $stmt = $db->prepare("INSERT INTO wp_term_taxonomy (term_id, taxonomy, description, count) VALUES (2, 'category', '', 1)"); + $stmt->execute(); + $band_explicit_term_taxonomy_id = (int)$db->lastInsertRowID(); + $db->exec("INSERT INTO wp_termmeta (term_id, meta_key, meta_value) VALUES (2, '_forkpress_term_ref', 'term metadata behind held explicit term')"); + $db->exec("INSERT INTO wp_terms (name, slug) VALUES ('Imported child term behind explicit parent', 'imported-child-term')"); + $band_explicit_child_term_id = (int)$db->lastInsertRowID(); + $stmt = $db->prepare("INSERT INTO wp_term_taxonomy (term_id, taxonomy, description, parent, count) VALUES (:term_id, 'category', '', 2, 1)"); + $stmt->bindValue(':term_id', $band_explicit_child_term_id, SQLITE3_INTEGER); + $stmt->execute(); + $stmt = $db->prepare('INSERT INTO wp_term_relationships (object_id, term_taxonomy_id, term_order) VALUES (1, :term_taxonomy_id, 0)'); + $stmt->bindValue(':term_taxonomy_id', $band_explicit_term_taxonomy_id, SQLITE3_INTEGER); + $stmt->execute(); + $db->exec("UPDATE wp_termmeta SET term_id = 2 WHERE meta_key = '_forkpress_base_term_ref'"); + $db->exec("UPDATE wp_term_taxonomy SET term_id = 2 WHERE term_taxonomy_id = 1"); + $db->exec("UPDATE wp_term_taxonomy SET parent = 2 WHERE term_taxonomy_id = 3"); + $db->exec("UPDATE wp_posts SET post_content = '' WHERE post_title = 'Base taxonomy navigation link consumer'"); + $db->exec("UPDATE wp_posts SET post_content = '' WHERE post_title = 'Base taxonomy query consumer'"); + $db->exec("UPDATE wp_posts SET post_content = '' WHERE post_title = 'Base tag query consumer'"); + $stmt = $db->prepare('UPDATE wp_term_relationships SET term_taxonomy_id = :term_taxonomy_id WHERE object_id = :object_id AND term_taxonomy_id = 1'); + $stmt->bindValue(':term_taxonomy_id', $band_explicit_term_taxonomy_id, SQLITE3_INTEGER); + $stmt->bindValue(':object_id', $band_explicit_term_base_menu_item_id, SQLITE3_INTEGER); + $stmt->execute(); + $db->exec("UPDATE wp_postmeta SET meta_value = '2' WHERE post_id = $band_explicit_term_base_menu_item_id AND meta_key = '_menu_item_object_id'"); + $band_explicit_term_updated_theme_mods = serialize(['nav_menu_locations' => ['primary' => 2]]); + $stmt = $db->prepare("UPDATE wp_options SET option_value = :value WHERE option_name = 'theme_mods_existing_term_refs'"); + $stmt->bindValue(':value', $band_explicit_term_updated_theme_mods, SQLITE3_TEXT); + $stmt->execute(); + $band_explicit_term_updated_nav_widget = serialize([2 => ['nav_menu' => 2, 'title' => 'Updated nav widget']]); + $stmt = $db->prepare("UPDATE wp_options SET option_value = :value WHERE option_name = 'widget_nav_menu'"); + $stmt->bindValue(':value', $band_explicit_term_updated_nav_widget, SQLITE3_TEXT); + $stmt->execute(); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_type) VALUES ('Taxonomy menu item behind explicit term', '', 'publish', 'nav_menu_item')"); + $band_explicit_term_menu_item_id = (int)$db->lastInsertRowID(); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:menu_item_id, '_menu_item_type', 'taxonomy'), (:menu_item_id, '_menu_item_object_id', '2')"); + $stmt->bindValue(':menu_item_id', $band_explicit_term_menu_item_id, SQLITE3_INTEGER); + $stmt->execute(); + $band_explicit_term_theme_mods = serialize([ + 'nav_menu_locations' => [ + 'primary' => 2, + ], + ]); + $stmt = $db->prepare("INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('theme_mods_imported_term_refs', :value, 'yes')"); + $stmt->bindValue(':value', $band_explicit_term_theme_mods, SQLITE3_TEXT); + $stmt->execute(); + $db->close(); + $band_explicit_term_result = cow_merge_databases( + $band_explicit_term_base, + $band_explicit_term_source, + $band_explicit_term_target, + $band_explicit_term_metadata, + 'feature-band-explicit-term-source', + 'main' + ); + assert_same($band_explicit_term_result['status'], 'completed_with_conflicts', 'explicit source term IDs hold dependent taxonomy rows for review'); + assert_same((int)scalar($band_explicit_term_target, 'SELECT COUNT(*) FROM wp_terms WHERE term_id = 2'), 0, 'out-of-band explicit source term remains unapplied'); + assert_same((int)scalar($band_explicit_term_target, 'SELECT COUNT(*) FROM wp_termmeta WHERE term_id = 2'), 0, 'term metadata pointing at a held explicit source term is not applied automatically'); + assert_same((int)scalar($band_explicit_term_target, "SELECT term_id FROM wp_termmeta WHERE meta_key = '_forkpress_base_term_ref'"), 1, 'updated term metadata pointing at a held explicit source term is not applied automatically'); + assert_same((int)scalar($band_explicit_term_target, 'SELECT COUNT(*) FROM wp_term_taxonomy WHERE term_id = 2'), 0, 'term taxonomy pointing at a held explicit source term is not applied automatically'); + assert_same((int)scalar($band_explicit_term_target, 'SELECT term_id FROM wp_term_taxonomy WHERE term_taxonomy_id = 1'), 1, 'updated term taxonomy pointing at a held explicit source term is not applied automatically'); + assert_same((int)scalar($band_explicit_term_target, 'SELECT COUNT(*) FROM wp_term_taxonomy WHERE parent = 2'), 0, 'hierarchical term taxonomy pointing at a held explicit parent term is not applied automatically'); + assert_same((int)scalar($band_explicit_term_target, 'SELECT parent FROM wp_term_taxonomy WHERE term_taxonomy_id = 3'), 0, 'updated term taxonomy parents pointing at a held explicit source term are not applied automatically'); + assert_same((int)scalar($band_explicit_term_target, "SELECT COUNT(*) FROM wp_term_relationships WHERE term_taxonomy_id = $band_explicit_term_taxonomy_id"), 0, 'term relationships pointing at held explicit source term taxonomy are not applied automatically'); + assert_same((int)scalar($band_explicit_term_target, "SELECT COUNT(*) FROM wp_term_relationships WHERE object_id = $band_explicit_term_base_menu_item_id AND term_taxonomy_id = 1"), 1, 'updated term relationships pointing at held explicit source term taxonomy are not applied automatically'); + assert_same(scalar($band_explicit_term_target, "SELECT meta_value FROM wp_postmeta WHERE post_id = $band_explicit_term_base_menu_item_id AND meta_key = '_menu_item_object_id'"), '1', 'updated taxonomy menu item object references pointing at a held explicit source term are not applied automatically'); + assert_same((int)scalar($band_explicit_term_target, "SELECT COUNT(*) FROM wp_postmeta WHERE post_id = $band_explicit_term_menu_item_id AND meta_key = '_menu_item_object_id' AND meta_value = '2'"), 0, 'taxonomy menu item object references pointing at a held explicit source term are not applied automatically'); + assert_same(scalar($band_explicit_term_target, "SELECT option_value FROM wp_options WHERE option_name = 'theme_mods_existing_term_refs'"), $band_explicit_term_base_theme_mods, 'updated theme mods pointing at a held explicit source nav menu are not applied automatically'); + assert_same(scalar($band_explicit_term_target, "SELECT option_value FROM wp_options WHERE option_name = 'widget_nav_menu'"), $band_explicit_term_base_nav_widget, 'updated nav menu widgets pointing at a held explicit source menu are not applied automatically'); + assert_same(scalar($band_explicit_term_target, "SELECT post_content FROM wp_posts WHERE post_title = 'Base taxonomy navigation link consumer'"), '

base taxonomy navigation link content

', 'updated taxonomy navigation link refs pointing at a held explicit source term are not applied automatically'); + assert_same(scalar($band_explicit_term_target, "SELECT post_content FROM wp_posts WHERE post_title = 'Base taxonomy query consumer'"), '

base taxonomy query content

', 'updated query block term refs pointing at a held explicit source term are not applied automatically'); + assert_same(scalar($band_explicit_term_target, "SELECT post_content FROM wp_posts WHERE post_title = 'Base tag query consumer'"), '

base tag query content

', 'updated query block tag refs pointing at a held explicit source term are not applied automatically'); + assert_same((int)scalar($band_explicit_term_target, "SELECT COUNT(*) FROM wp_options WHERE option_name = 'theme_mods_imported_term_refs'"), 0, 'theme mods pointing at a held explicit source nav menu are not applied automatically'); + assert_same( + (int)scalar($band_explicit_term_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-explicit-term-source' AND c.table_name = 'wp_terms' AND c.conflict_type = 'row-target-constraint'"), + 1, + 'out-of-band explicit source term records a reviewable row conflict' + ); + assert_same( + (int)scalar($band_explicit_term_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-explicit-term-source' AND c.table_name = 'wp_term_taxonomy' AND c.conflict_type = 'row-target-constraint'"), + 4, + 'term taxonomy pointing at a held explicit source term records a reviewable row conflict' + ); + assert_same( + (int)scalar($band_explicit_term_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-explicit-term-source' AND c.table_name = 'wp_termmeta' AND c.conflict_type = 'row-target-constraint'"), + 2, + 'term metadata pointing at a held explicit source term records a reviewable row conflict' + ); + assert_same( + (int)scalar($band_explicit_term_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-explicit-term-source' AND c.table_name = 'wp_term_relationships' AND c.conflict_type = 'row-target-constraint'"), + 3, + 'term relationships pointing at a held explicit source term record a reviewable row conflict' + ); + assert_same( + (int)scalar($band_explicit_term_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-explicit-term-source' AND c.table_name = 'wp_postmeta' AND c.conflict_type = 'row-target-constraint'"), + 2, + 'taxonomy menu item object references pointing at a held explicit source term record a reviewable row conflict' + ); + assert_same( + (int)scalar($band_explicit_term_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-explicit-term-source' AND c.table_name = 'wp_posts' AND c.conflict_type = 'row-target-constraint'"), + 3, + 'taxonomy block refs pointing at a held explicit source term record a reviewable row conflict' + ); + assert_same( + (int)scalar($band_explicit_term_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-explicit-term-source' AND c.table_name = 'wp_options' AND c.conflict_type = 'row-target-constraint'"), + 3, + 'options pointing at a held explicit source term record reviewable row conflicts' + ); + assert_true( + (int)scalar($band_explicit_term_metadata, "SELECT COUNT(*) FROM merge_decisions d JOIN merge_runs r ON r.id = d.run_id WHERE r.source_branch = 'feature-band-explicit-term-source' AND d.table_name = 'wp_options' AND d.decision = 'target-wins' AND d.reason LIKE '%parent term must merge before child row%'") === 3, + 'options held behind an explicit source term explain the missing parent' + ); + assert_true( + (int)scalar($band_explicit_term_metadata, "SELECT COUNT(*) FROM merge_decisions d JOIN merge_runs r ON r.id = d.run_id WHERE r.source_branch = 'feature-band-explicit-term-source' AND d.table_name IN ('wp_termmeta', 'wp_term_taxonomy', 'wp_term_relationships', 'wp_postmeta', 'wp_options', 'wp_posts') AND d.decision = 'target-wins' AND d.reason LIKE 'source changed%'") === 10, + 'updated rows held behind an explicit source term explain that the source changed the row' + ); + + $band_explicit_user_base = $tmp . '/band-explicit-user-base.sqlite'; + $band_explicit_user_source = $tmp . '/band-explicit-user-source.sqlite'; + $band_explicit_user_target = $tmp . '/band-explicit-user-target.sqlite'; + $band_explicit_user_metadata = $tmp . '/.forkpress/cow/merge/band-explicit-user-metadata.sqlite'; + copy($band_base, $band_explicit_user_base); + $db = open_db($band_explicit_user_base); + $db->exec('ALTER TABLE wp_posts ADD COLUMN post_author INTEGER NOT NULL DEFAULT 0'); + $db->exec('CREATE TABLE wp_users (ID INTEGER PRIMARY KEY AUTOINCREMENT, user_login TEXT NOT NULL, user_email TEXT NOT NULL DEFAULT "")'); + $db->exec('CREATE TABLE wp_usermeta (umeta_id INTEGER PRIMARY KEY AUTOINCREMENT, user_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("INSERT INTO wp_users (ID, user_login, user_email) VALUES (1, 'base-user', 'base@example.test')"); + $db->exec("INSERT INTO wp_usermeta (user_id, meta_key, meta_value) VALUES (1, 'nickname', 'base-user')"); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_author) VALUES ('Base authored post', 'base author should remain', 'publish', 1)"); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_author) VALUES ('Base avatar block consumer', '

base avatar content

', 'publish', 1)"); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_author) VALUES ('Base author query consumer', '

base author query content

', 'publish', 1)"); + $db->exec("INSERT INTO wp_comments (comment_post_ID, comment_content, user_id) VALUES (1, 'Base user comment', 1)"); + $db->close(); + copy($band_explicit_user_base, $band_explicit_user_source); + copy($band_explicit_user_base, $band_explicit_user_target); + cow_merge_allocate_autoincrement_bands($band_explicit_user_source, $band_explicit_user_metadata, 'feature-band-explicit-user-source'); + $db = open_db($band_explicit_user_source); + $db->exec("INSERT INTO wp_users (ID, user_login, user_email) VALUES (2, 'imported-explicit-user', 'imported@example.test')"); + $db->exec("INSERT INTO wp_usermeta (user_id, meta_key, meta_value) VALUES (2, 'description', 'metadata behind held explicit user')"); + $db->exec("UPDATE wp_usermeta SET user_id = 2 WHERE meta_key = 'nickname'"); + $db->exec("UPDATE wp_posts SET post_author = 2 WHERE post_title = 'Base authored post'"); + $db->exec("UPDATE wp_posts SET post_content = '' WHERE post_title = 'Base avatar block consumer'"); + $db->exec("UPDATE wp_posts SET post_content = '' WHERE post_title = 'Base author query consumer'"); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_author) VALUES ('Post behind explicit author', 'author should be review-held', 'publish', 2)"); + $db->exec("UPDATE wp_comments SET user_id = 2 WHERE comment_content = 'Base user comment'"); + $db->exec("INSERT INTO wp_comments (comment_post_ID, comment_content, user_id) VALUES (1, 'Comment behind held explicit user', 2)"); + $db->close(); + $band_explicit_user_result = cow_merge_databases( + $band_explicit_user_base, + $band_explicit_user_source, + $band_explicit_user_target, + $band_explicit_user_metadata, + 'feature-band-explicit-user-source', + 'main' + ); + assert_same($band_explicit_user_result['status'], 'completed_with_conflicts', 'explicit source user IDs hold dependent user rows for review'); + assert_same((int)scalar($band_explicit_user_target, 'SELECT COUNT(*) FROM wp_users WHERE ID = 2'), 0, 'out-of-band explicit source user remains unapplied'); + assert_same((int)scalar($band_explicit_user_target, 'SELECT COUNT(*) FROM wp_usermeta WHERE user_id = 2'), 0, 'usermeta pointing at a held explicit source user is not applied automatically'); + assert_same((int)scalar($band_explicit_user_target, "SELECT user_id FROM wp_usermeta WHERE meta_key = 'nickname'"), 1, 'updated usermeta pointing at a held explicit source user is not applied automatically'); + assert_same((int)scalar($band_explicit_user_target, "SELECT post_author FROM wp_posts WHERE post_title = 'Base authored post'"), 1, 'updated post authors pointing at a held explicit source user are not applied automatically'); + assert_same(scalar($band_explicit_user_target, "SELECT post_content FROM wp_posts WHERE post_title = 'Base avatar block consumer'"), '

base avatar content

', 'updated avatar block refs pointing at a held explicit source user are not applied automatically'); + assert_same(scalar($band_explicit_user_target, "SELECT post_content FROM wp_posts WHERE post_title = 'Base author query consumer'"), '

base author query content

', 'updated query block author refs pointing at a held explicit source user are not applied automatically'); + assert_same((int)scalar($band_explicit_user_target, 'SELECT COUNT(*) FROM wp_posts WHERE post_author = 2'), 0, 'posts authored by a held explicit source user are not applied automatically'); + assert_same((int)scalar($band_explicit_user_target, "SELECT user_id FROM wp_comments WHERE comment_content = 'Base user comment'"), 1, 'updated comments pointing at a held explicit source user are not applied automatically'); + assert_same((int)scalar($band_explicit_user_target, 'SELECT COUNT(*) FROM wp_comments WHERE user_id = 2'), 0, 'comments pointing at a held explicit source user are not applied automatically'); + assert_same( + (int)scalar($band_explicit_user_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-explicit-user-source' AND c.table_name = 'wp_users' AND c.conflict_type = 'row-target-constraint'"), + 1, + 'out-of-band explicit source user records a reviewable row conflict' + ); + assert_same( + (int)scalar($band_explicit_user_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-explicit-user-source' AND c.table_name = 'wp_usermeta' AND c.conflict_type = 'row-target-constraint'"), + 2, + 'usermeta pointing at a held explicit source user records a reviewable row conflict' + ); + assert_same( + (int)scalar($band_explicit_user_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-explicit-user-source' AND c.table_name = 'wp_posts' AND c.conflict_type = 'row-target-constraint'"), + 4, + 'posts authored by or referencing a held explicit source user record a reviewable row conflict' + ); + assert_same( + (int)scalar($band_explicit_user_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-explicit-user-source' AND c.table_name = 'wp_comments' AND c.conflict_type = 'row-target-constraint'"), + 2, + 'comments pointing at a held explicit source user record a reviewable row conflict' + ); + assert_true( + (int)scalar($band_explicit_user_metadata, "SELECT COUNT(*) FROM merge_decisions d JOIN merge_runs r ON r.id = d.run_id WHERE r.source_branch = 'feature-band-explicit-user-source' AND d.table_name IN ('wp_usermeta', 'wp_posts', 'wp_comments') AND d.decision = 'target-wins' AND d.reason LIKE '%parent user must merge before child row%'") === 8, + 'child rows held behind an explicit source user explain the missing parent' + ); + assert_true( + (int)scalar($band_explicit_user_metadata, "SELECT COUNT(*) FROM merge_decisions d JOIN merge_runs r ON r.id = d.run_id WHERE r.source_branch = 'feature-band-explicit-user-source' AND d.table_name IN ('wp_usermeta', 'wp_posts', 'wp_comments') AND d.decision = 'target-wins' AND d.reason LIKE 'source changed%'") === 5, + 'updated child rows held behind an explicit source user explain that the source changed the row' + ); + + $plain_graph_base = $tmp . '/plain-ipk-graph-base.sqlite'; + $plain_graph_source = $tmp . '/plain-ipk-graph-source.sqlite'; + $plain_graph_target = $tmp . '/plain-ipk-graph-target.sqlite'; + $plain_graph_metadata = $tmp . '/.forkpress/cow/merge/plain-ipk-graph-metadata.sqlite'; + create_base_db($plain_graph_base); + $db = open_db($plain_graph_base); + $db->exec('CREATE TABLE plugin_plain_ipk_graph (id INTEGER PRIMARY KEY, branch TEXT NOT NULL, graph_json TEXT NOT NULL, graph_serialized TEXT NOT NULL)'); + $db->close(); + copy($plain_graph_base, $plain_graph_source); + copy($plain_graph_base, $plain_graph_target); + cow_merge_allocate_autoincrement_bands($plain_graph_source, $plain_graph_metadata, 'feature-plain-ipk-source'); + cow_merge_allocate_autoincrement_bands($plain_graph_target, $plain_graph_metadata, 'feature-plain-ipk-target'); + $write_plain_ipk_graph = static function (string $path, string $branch): array { + $db = open_db($path); + $stmt = $db->prepare('INSERT INTO plugin_plain_ipk_graph (branch, graph_json, graph_serialized) VALUES (:branch, :json, :serialized)'); + $stmt->bindValue(':branch', $branch, SQLITE3_TEXT); + $stmt->bindValue(':json', '{}', SQLITE3_TEXT); + $stmt->bindValue(':serialized', 'a:0:{}', SQLITE3_TEXT); + $stmt->execute(); + $id = (int)$db->lastInsertRowID(); + $graph = ['branch' => $branch, 'self_id' => $id]; + $json = json_encode($graph, JSON_UNESCAPED_SLASHES); + $serialized = 'a:2:{s:6:"branch";s:' . strlen($branch) . ':"' . $branch . '";s:7:"self_id";i:' . $id . ';}'; + $stmt = $db->prepare('UPDATE plugin_plain_ipk_graph SET graph_json = :json, graph_serialized = :serialized WHERE id = :id'); + $stmt->bindValue(':json', $json, SQLITE3_TEXT); + $stmt->bindValue(':serialized', $serialized, SQLITE3_TEXT); + $stmt->bindValue(':id', $id, SQLITE3_INTEGER); + $stmt->execute(); + $db->close(); + return ['id' => $id, 'json' => $json, 'serialized' => $serialized]; + }; + $plain_source_graph = $write_plain_ipk_graph($plain_graph_source, 'source'); + $plain_target_graph = $write_plain_ipk_graph($plain_graph_target, 'target'); + assert_same($plain_source_graph['id'], $plain_target_graph['id'], 'plain INTEGER PRIMARY KEY plugin branches can reuse the same row ID before merge'); + $plain_graph_result = cow_merge_databases( + $plain_graph_base, + $plain_graph_source, + $plain_graph_target, + $plain_graph_metadata, + 'feature-plain-ipk-source', + 'feature-plain-ipk-target' + ); + assert_same($plain_graph_result['status'], 'completed_with_conflicts', 'plain INTEGER PRIMARY KEY plugin graph ID collision is held for review'); + assert_same((int)scalar($plain_graph_target, "SELECT COUNT(*) FROM plugin_plain_ipk_graph WHERE branch = 'source'"), 0, 'plain IPK source graph is not applied over a target graph with the same ID'); + assert_same((string)scalar($plain_graph_target, "SELECT graph_json FROM plugin_plain_ipk_graph WHERE branch = 'target'"), $plain_target_graph['json'], 'plain IPK target graph remains coherent after collision review hold'); + assert_same( + (int)scalar($plain_graph_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-plain-ipk-source' AND c.table_name = 'plugin_plain_ipk_graph' AND c.conflict_type = 'row-insert-collision'"), + 1, + 'plain IPK plugin graph collision records a row insert conflict' + ); + assert_true( + (int)scalar($plain_graph_metadata, "SELECT COUNT(*) FROM merge_decisions WHERE table_name = 'plugin_plain_ipk_graph' AND decision = 'id-band-skipped'") >= 2, + 'plain IPK plugin graph tables are auditable as non-bandable on both branches' + ); + + $band_ref_base = $tmp . '/band-ref-base.sqlite'; + $band_ref_source = $tmp . '/band-ref-source.sqlite'; + $band_ref_target = $tmp . '/band-ref-target.sqlite'; + create_base_db($band_ref_base); + $db = open_db($band_ref_base); + $db->exec('CREATE TABLE wp_postmeta (meta_id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER, meta_key TEXT, meta_value TEXT)'); + $db->close(); + copy($band_ref_base, $band_ref_source); + copy($band_ref_base, $band_ref_target); + cow_merge_allocate_autoincrement_bands($band_ref_source, $band_metadata, 'feature-band-ref-source'); + cow_merge_allocate_autoincrement_bands($band_ref_target, $band_metadata, 'feature-band-ref-target'); + $db = open_db($band_ref_source); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status) VALUES ('Banded source ref', '', 'publish')"); + $band_ref_source_id = (int)$db->lastInsertRowID(); + $band_ref_source_json = json_encode(['linkedPostId' => $band_ref_source_id, 'branch' => 'source'], JSON_UNESCAPED_SLASHES); + $band_ref_source_serialized = 'a:2:{s:12:"linkedPostId";i:' . $band_ref_source_id . ';s:6:"branch";s:6:"source";}'; + $stmt = $db->prepare('UPDATE wp_posts SET post_content = :content WHERE ID = :id'); + $stmt->bindValue(':content', $band_ref_source_json, SQLITE3_TEXT); + $stmt->bindValue(':id', $band_ref_source_id, SQLITE3_INTEGER); + $stmt->execute(); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:id, '_forkpress_json_ref', :json), (:id, '_forkpress_serialized_ref', :serialized)"); + $stmt->bindValue(':id', $band_ref_source_id, SQLITE3_INTEGER); + $stmt->bindValue(':json', $band_ref_source_json, SQLITE3_TEXT); + $stmt->bindValue(':serialized', $band_ref_source_serialized, SQLITE3_TEXT); + $stmt->execute(); + $db->close(); + $db = open_db($band_ref_target); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status) VALUES ('Banded target ref', '', 'publish')"); + $band_ref_target_id = (int)$db->lastInsertRowID(); + $band_ref_target_json = json_encode(['linkedPostId' => $band_ref_target_id, 'branch' => 'target'], JSON_UNESCAPED_SLASHES); + $band_ref_target_serialized = 'a:2:{s:12:"linkedPostId";i:' . $band_ref_target_id . ';s:6:"branch";s:6:"target";}'; + $stmt = $db->prepare('UPDATE wp_posts SET post_content = :content WHERE ID = :id'); + $stmt->bindValue(':content', $band_ref_target_json, SQLITE3_TEXT); + $stmt->bindValue(':id', $band_ref_target_id, SQLITE3_INTEGER); + $stmt->execute(); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:id, '_forkpress_json_ref', :json), (:id, '_forkpress_serialized_ref', :serialized)"); + $stmt->bindValue(':id', $band_ref_target_id, SQLITE3_INTEGER); + $stmt->bindValue(':json', $band_ref_target_json, SQLITE3_TEXT); + $stmt->bindValue(':serialized', $band_ref_target_serialized, SQLITE3_TEXT); + $stmt->execute(); + $db->close(); + assert_true($band_ref_source_id !== $band_ref_target_id, 'banded WordPress branches assign distinct IDs before serialized references are written'); + $band_ref_result = cow_merge_databases($band_ref_base, $band_ref_source, $band_ref_target, $band_metadata, 'feature-band-ref-source', 'feature-band-ref-target'); + assert_same($band_ref_result['status'], 'completed', 'banded WordPress reference rows merge without ID collision repair'); + assert_same(scalar($band_ref_target, "SELECT post_content FROM wp_posts WHERE ID = $band_ref_source_id"), $band_ref_source_json, 'source JSON post reference remains valid after banded merge'); + assert_same(scalar($band_ref_target, "SELECT post_content FROM wp_posts WHERE ID = $band_ref_target_id"), $band_ref_target_json, 'target JSON post reference remains valid after banded merge'); + assert_same(scalar($band_ref_target, "SELECT meta_value FROM wp_postmeta WHERE post_id = $band_ref_source_id AND meta_key = '_forkpress_serialized_ref'"), $band_ref_source_serialized, 'source serialized post reference remains valid after banded merge'); + assert_same(scalar($band_ref_target, "SELECT meta_value FROM wp_postmeta WHERE post_id = $band_ref_target_id AND meta_key = '_forkpress_serialized_ref'"), $band_ref_target_serialized, 'target serialized post reference remains valid after banded merge'); + assert_same((int)scalar($band_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-band-ref-source'"), 0, 'banded WordPress reference merge records no ID collision conflicts'); + + $wp_semantic_base = $tmp . '/wp-semantic-base.sqlite'; + $wp_semantic_source = $tmp . '/wp-semantic-source.sqlite'; + $wp_semantic_target = $tmp . '/wp-semantic-target.sqlite'; + $wp_semantic_metadata = $tmp . '/.forkpress/cow/merge/wp-semantic-metadata.sqlite'; + create_base_db($wp_semantic_base); + $db = open_db($wp_semantic_base); + $db->exec("ALTER TABLE wp_posts ADD COLUMN post_type TEXT NOT NULL DEFAULT 'post'"); + $db->exec("ALTER TABLE wp_posts ADD COLUMN post_name TEXT NOT NULL DEFAULT ''"); + $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('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)'); + $db->exec('CREATE TABLE wp_users (ID INTEGER PRIMARY KEY AUTOINCREMENT, user_login TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL)'); + $db->exec('CREATE TABLE wp_usermeta (umeta_id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, meta_key TEXT NOT NULL, meta_value TEXT NOT NULL)'); + $db->exec('CREATE TABLE wp_terms (term_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, slug TEXT NOT NULL, term_group INTEGER NOT NULL DEFAULT 0)'); + $db->exec('CREATE TABLE wp_term_taxonomy (term_taxonomy_id INTEGER PRIMARY KEY AUTOINCREMENT, term_id INTEGER NOT NULL, taxonomy TEXT NOT NULL, description TEXT NOT NULL DEFAULT "", parent INTEGER NOT NULL DEFAULT 0, count INTEGER NOT NULL DEFAULT 0)'); + $db->exec('CREATE TABLE wp_term_relationships (object_id INTEGER NOT NULL, term_taxonomy_id INTEGER NOT NULL, term_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (object_id, term_taxonomy_id))'); + $db->close(); + copy($wp_semantic_base, $wp_semantic_source); + copy($wp_semantic_base, $wp_semantic_target); + $wp_semantic_base_root = $tmp . '/wp-semantic-files-base'; + $wp_semantic_source_root = $tmp . '/wp-semantic-files-source'; + $wp_semantic_target_root = $tmp . '/wp-semantic-files-target'; + mkdir($wp_semantic_base_root . '/wp-content/uploads/2026/05', 0777, true); + copy_tree_for_test($wp_semantic_base_root, $wp_semantic_source_root); + copy_tree_for_test($wp_semantic_base_root, $wp_semantic_target_root); + $wp_semantic_file_base = $tmp . '/.forkpress/cow/merge/file-bases/wp-semantic-source.json'; + cow_merge_capture_file_base($wp_semantic_base_root, $wp_semantic_file_base); + cow_merge_allocate_autoincrement_bands($wp_semantic_source, $wp_semantic_metadata, 'feature-wp-semantic-source'); + cow_merge_allocate_autoincrement_bands($wp_semantic_target, $wp_semantic_metadata, 'feature-wp-semantic-target'); + $write_wp_semantic_bundle = static function (string $db_path, string $root, string $branch): array { + $db = open_db($db_path); + $suffix = ucfirst($branch); + $stmt = $db->prepare('INSERT INTO wp_users (user_login, display_name) VALUES (:login, :display_name)'); + $stmt->bindValue(':login', "forkpress_$branch", SQLITE3_TEXT); + $stmt->bindValue(':display_name', "$suffix Author", SQLITE3_TEXT); + $stmt->execute(); + $user_id = (int)$db->lastInsertRowID(); + $user_graph = [ + 'branch' => $branch, + 'user_id' => $user_id, + ]; + $stmt = $db->prepare("INSERT INTO wp_usermeta (user_id, meta_key, meta_value) VALUES (:user_id, '_forkpress_user_graph', :json), (:user_id, '_forkpress_user_serialized_graph', :serialized)"); + $stmt->bindValue(':user_id', $user_id, SQLITE3_INTEGER); + $stmt->bindValue(':json', json_encode($user_graph, JSON_UNESCAPED_SLASHES), SQLITE3_TEXT); + $stmt->bindValue(':serialized', serialize($user_graph), SQLITE3_TEXT); + $stmt->execute(); + $block_content = "

$suffix reusable block

"; + $stmt = $db->prepare('INSERT INTO wp_posts (post_title, post_content, post_status, post_type, post_name) VALUES (:title, :content, :status, :type, :slug)'); + $stmt->bindValue(':title', "$suffix Reusable Block", SQLITE3_TEXT); + $stmt->bindValue(':content', $block_content, SQLITE3_TEXT); + $stmt->bindValue(':status', 'publish', SQLITE3_TEXT); + $stmt->bindValue(':type', 'wp_block', SQLITE3_TEXT); + $stmt->bindValue(':slug', "$branch-reusable-block", SQLITE3_TEXT); + $stmt->execute(); + $block_id = (int)$db->lastInsertRowID(); + + $file_path = "wp-content/uploads/2026/05/$branch-image.jpg"; + write_test_file($root . '/' . $file_path, "$branch image bytes\n"); + $stmt = $db->prepare('INSERT INTO wp_posts (post_title, post_content, post_status, post_type, post_name, guid) VALUES (:title, :content, :status, :type, :slug, :guid)'); + $stmt->bindValue(':title', "$suffix Image", SQLITE3_TEXT); + $stmt->bindValue(':content', '', SQLITE3_TEXT); + $stmt->bindValue(':status', 'inherit', SQLITE3_TEXT); + $stmt->bindValue(':type', 'attachment', SQLITE3_TEXT); + $stmt->bindValue(':slug', "$branch-image", SQLITE3_TEXT); + $stmt->bindValue(':guid', $file_path, SQLITE3_TEXT); + $stmt->execute(); + $attachment_id = (int)$db->lastInsertRowID(); + $attachment_metadata = serialize([ + 'file' => '2026/05/' . basename($file_path), + 'width' => 640, + 'height' => 480, + 'sizes' => [ + 'thumbnail' => [ + 'file' => basename($file_path), + 'width' => 150, + 'height' => 150, + ], + ], + ]); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:post_id, '_wp_attached_file', :file), (:post_id, '_wp_attachment_metadata', :metadata)"); + $stmt->bindValue(':post_id', $attachment_id, SQLITE3_INTEGER); + $stmt->bindValue(':file', '2026/05/' . basename($file_path), SQLITE3_TEXT); + $stmt->bindValue(':metadata', $attachment_metadata, SQLITE3_TEXT); + $stmt->execute(); + + $page_content = '' . + '
'; + $stmt = $db->prepare('INSERT INTO wp_posts (post_title, post_content, post_status, post_type, post_name) VALUES (:title, :content, :status, :type, :slug)'); + $stmt->bindValue(':title', "$suffix Page", SQLITE3_TEXT); + $stmt->bindValue(':content', $page_content, SQLITE3_TEXT); + $stmt->bindValue(':status', 'publish', SQLITE3_TEXT); + $stmt->bindValue(':type', 'page', SQLITE3_TEXT); + $stmt->bindValue(':slug', "$branch-page", SQLITE3_TEXT); + $stmt->execute(); + $page_id = (int)$db->lastInsertRowID(); + + $stmt = $db->prepare('INSERT INTO wp_comments (comment_post_ID, comment_content, user_id) VALUES (:post_id, :content, :user_id)'); + $stmt->bindValue(':post_id', $page_id, SQLITE3_INTEGER); + $stmt->bindValue(':content', "$suffix page comment", SQLITE3_TEXT); + $stmt->bindValue(':user_id', $user_id, SQLITE3_INTEGER); + $stmt->execute(); + $comment_id = (int)$db->lastInsertRowID(); + $stmt = $db->prepare('INSERT INTO wp_comments (comment_post_ID, comment_content, comment_parent, user_id) VALUES (:post_id, :content, :parent, :user_id)'); + $stmt->bindValue(':post_id', $page_id, SQLITE3_INTEGER); + $stmt->bindValue(':content', "$suffix threaded reply", SQLITE3_TEXT); + $stmt->bindValue(':parent', $comment_id, SQLITE3_INTEGER); + $stmt->bindValue(':user_id', $user_id, SQLITE3_INTEGER); + $stmt->execute(); + $reply_comment_id = (int)$db->lastInsertRowID(); + $comment_graph = [ + 'branch' => $branch, + 'page_id' => $page_id, + 'comment_id' => $comment_id, + 'reply_comment_id' => $reply_comment_id, + ]; + $stmt = $db->prepare("INSERT INTO wp_commentmeta (comment_id, meta_key, meta_value) VALUES (:comment_id, '_forkpress_comment_graph', :json), (:reply_comment_id, '_forkpress_comment_serialized_graph', :serialized)"); + $stmt->bindValue(':comment_id', $comment_id, SQLITE3_INTEGER); + $stmt->bindValue(':reply_comment_id', $reply_comment_id, SQLITE3_INTEGER); + $stmt->bindValue(':json', json_encode($comment_graph, JSON_UNESCAPED_SLASHES), SQLITE3_TEXT); + $stmt->bindValue(':serialized', serialize($comment_graph), SQLITE3_TEXT); + $stmt->execute(); + + $stmt = $db->prepare('INSERT INTO wp_terms (name, slug) VALUES (:name, :slug)'); + $stmt->bindValue(':name', "$suffix Primary Menu", SQLITE3_TEXT); + $stmt->bindValue(':slug', "$branch-primary-menu", SQLITE3_TEXT); + $stmt->execute(); + $term_id = (int)$db->lastInsertRowID(); + $stmt = $db->prepare("INSERT INTO wp_term_taxonomy (term_id, taxonomy, description, count) VALUES (:term_id, 'nav_menu', '', 1)"); + $stmt->bindValue(':term_id', $term_id, SQLITE3_INTEGER); + $stmt->execute(); + $term_taxonomy_id = (int)$db->lastInsertRowID(); + + $stmt = $db->prepare('INSERT INTO wp_posts (post_title, post_content, post_status, post_type, post_name) VALUES (:title, :content, :status, :type, :slug)'); + $stmt->bindValue(':title', "$suffix Menu Item", SQLITE3_TEXT); + $stmt->bindValue(':content', '', SQLITE3_TEXT); + $stmt->bindValue(':status', 'publish', SQLITE3_TEXT); + $stmt->bindValue(':type', 'nav_menu_item', SQLITE3_TEXT); + $stmt->bindValue(':slug', "$branch-menu-item", SQLITE3_TEXT); + $stmt->execute(); + $menu_item_id = (int)$db->lastInsertRowID(); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES + (:menu_item_id, '_menu_item_type', 'post_type'), + (:menu_item_id, '_menu_item_object', 'page'), + (:menu_item_id, '_menu_item_object_id', :page_id), + (:menu_item_id, '_menu_item_menu_item_parent', '0'), + (:menu_item_id, '_menu_item_classes', :classes)"); + $stmt->bindValue(':menu_item_id', $menu_item_id, SQLITE3_INTEGER); + $stmt->bindValue(':page_id', (string)$page_id, SQLITE3_TEXT); + $stmt->bindValue(':classes', serialize([]), SQLITE3_TEXT); + $stmt->execute(); + $stmt = $db->prepare('INSERT INTO wp_term_relationships (object_id, term_taxonomy_id) VALUES (:object_id, :term_taxonomy_id)'); + $stmt->bindValue(':object_id', $menu_item_id, SQLITE3_INTEGER); + $stmt->bindValue(':term_taxonomy_id', $term_taxonomy_id, SQLITE3_INTEGER); + $stmt->execute(); + + $db->exec("UPDATE wp_posts SET post_author = $user_id WHERE ID IN ($block_id, $attachment_id, $page_id, $menu_item_id)"); + $graph = [ + 'branch' => $branch, + 'user_id' => $user_id, + 'page_id' => $page_id, + 'block_id' => $block_id, + 'attachment_id' => $attachment_id, + 'comment_id' => $comment_id, + 'reply_comment_id' => $reply_comment_id, + 'menu_item_id' => $menu_item_id, + 'term_id' => $term_id, + 'term_taxonomy_id' => $term_taxonomy_id, + 'file' => '2026/05/' . basename($file_path), + ]; + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:post_id, '_forkpress_semantic_bundle', :graph)"); + $stmt->bindValue(':post_id', $page_id, SQLITE3_INTEGER); + $stmt->bindValue(':graph', json_encode($graph, JSON_UNESCAPED_SLASHES), SQLITE3_TEXT); + $stmt->execute(); + $theme_mods = serialize([ + 'nav_menu_locations' => [ + 'primary' => $term_id, + ], + 'forkpress_featured_page' => $page_id, + 'forkpress_featured_block' => $block_id, + 'forkpress_featured_attachment' => $attachment_id, + ]); + $stmt = $db->prepare('INSERT INTO wp_options (option_name, option_value, autoload) VALUES (:name, :value, :autoload)'); + $stmt->bindValue(':name', "theme_mods_forkpress_$branch", SQLITE3_TEXT); + $stmt->bindValue(':value', $theme_mods, SQLITE3_TEXT); + $stmt->bindValue(':autoload', 'yes', SQLITE3_TEXT); + $stmt->execute(); + $db->close(); + return $graph; + }; + $wp_semantic_source_graph = $write_wp_semantic_bundle($wp_semantic_source, $wp_semantic_source_root, 'source'); + $wp_semantic_target_graph = $write_wp_semantic_bundle($wp_semantic_target, $wp_semantic_target_root, 'target'); + assert_true($wp_semantic_source_graph['user_id'] !== $wp_semantic_target_graph['user_id'], 'WordPress semantic branches receive distinct user IDs before post authors and usermeta are written'); + assert_true($wp_semantic_source_graph['page_id'] !== $wp_semantic_target_graph['page_id'], 'WordPress semantic branches receive distinct page IDs before block JSON is written'); + assert_true($wp_semantic_source_graph['attachment_id'] !== $wp_semantic_target_graph['attachment_id'], 'WordPress semantic branches receive distinct attachment IDs before upload metadata is written'); + assert_true($wp_semantic_source_graph['comment_id'] !== $wp_semantic_target_graph['comment_id'], 'WordPress semantic branches receive distinct comment IDs before commentmeta is written'); + assert_true($wp_semantic_source_graph['term_id'] !== $wp_semantic_target_graph['term_id'], 'WordPress semantic branches receive distinct menu term IDs before theme mods are written'); + $wp_semantic_result = cow_merge_branch_state( + $wp_semantic_base, + $wp_semantic_source, + $wp_semantic_target, + $wp_semantic_metadata, + 'feature-wp-semantic-source', + 'feature-wp-semantic-target', + $wp_semantic_file_base, + $wp_semantic_source_root, + $wp_semantic_target_root + ); + assert_same($wp_semantic_result['status'], 'completed', 'banded WordPress semantic object bundles merge cleanly'); + $assert_wp_semantic_bundle = static function (string $db_path, string $root, array $graph, string $branch): void { + $page_id = (int)$graph['page_id']; + $user_id = (int)$graph['user_id']; + $block_id = (int)$graph['block_id']; + $attachment_id = (int)$graph['attachment_id']; + $comment_id = (int)$graph['comment_id']; + $reply_comment_id = (int)$graph['reply_comment_id']; + $menu_item_id = (int)$graph['menu_item_id']; + $term_id = (int)$graph['term_id']; + $term_taxonomy_id = (int)$graph['term_taxonomy_id']; + $db = open_db($db_path); + $page = $db->querySingle("SELECT post_content, post_type FROM wp_posts WHERE ID = $page_id", true); + $user_login = $db->querySingle("SELECT user_login FROM wp_users WHERE ID = $user_id"); + $user_graph_json = $db->querySingle("SELECT meta_value FROM wp_usermeta WHERE user_id = $user_id AND meta_key = '_forkpress_user_graph'"); + $user_graph_serialized = $db->querySingle("SELECT meta_value FROM wp_usermeta WHERE user_id = $user_id AND meta_key = '_forkpress_user_serialized_graph'"); + $page_author = (int)$db->querySingle("SELECT post_author FROM wp_posts WHERE ID = $page_id"); + $block_type = $db->querySingle("SELECT post_type FROM wp_posts WHERE ID = $block_id"); + $attachment_type = $db->querySingle("SELECT post_type FROM wp_posts WHERE ID = $attachment_id"); + $attached_file = $db->querySingle("SELECT meta_value FROM wp_postmeta WHERE post_id = $attachment_id AND meta_key = '_wp_attached_file'"); + $comment_post_id = (int)$db->querySingle("SELECT comment_post_ID FROM wp_comments WHERE comment_ID = $comment_id"); + $comment_user_id = (int)$db->querySingle("SELECT user_id FROM wp_comments WHERE comment_ID = $comment_id"); + $reply_parent = (int)$db->querySingle("SELECT comment_parent FROM wp_comments WHERE comment_ID = $reply_comment_id"); + $reply_user_id = (int)$db->querySingle("SELECT user_id FROM wp_comments WHERE comment_ID = $reply_comment_id"); + $comment_graph_json = $db->querySingle("SELECT meta_value FROM wp_commentmeta WHERE comment_id = $comment_id AND meta_key = '_forkpress_comment_graph'"); + $reply_graph_serialized = $db->querySingle("SELECT meta_value FROM wp_commentmeta WHERE comment_id = $reply_comment_id AND meta_key = '_forkpress_comment_serialized_graph'"); + $menu_object_id = $db->querySingle("SELECT meta_value FROM wp_postmeta WHERE post_id = $menu_item_id AND meta_key = '_menu_item_object_id'"); + $relationship_count = (int)$db->querySingle("SELECT COUNT(*) FROM wp_term_relationships WHERE object_id = $menu_item_id AND term_taxonomy_id = $term_taxonomy_id"); + $menu_taxonomy = $db->querySingle("SELECT taxonomy FROM wp_term_taxonomy WHERE term_taxonomy_id = $term_taxonomy_id AND term_id = $term_id"); + $bundle = $db->querySingle("SELECT meta_value FROM wp_postmeta WHERE post_id = $page_id AND meta_key = '_forkpress_semantic_bundle'"); + $theme_mods = $db->querySingle("SELECT option_value FROM wp_options WHERE option_name = 'theme_mods_forkpress_$branch'"); + $db->close(); + $decoded_user_graph = is_string($user_graph_json) ? json_decode($user_graph_json, true) : null; + $decoded_serialized_user_graph = is_string($user_graph_serialized) ? unserialize($user_graph_serialized) : null; + $decoded_bundle = is_string($bundle) ? json_decode($bundle, true) : null; + $decoded_comment_graph = is_string($comment_graph_json) ? json_decode($comment_graph_json, true) : null; + $decoded_reply_graph = is_string($reply_graph_serialized) ? unserialize($reply_graph_serialized) : null; + $decoded_theme_mods = is_string($theme_mods) ? unserialize($theme_mods) : null; + assert_same($user_login, "forkpress_$branch", "WordPress $branch user survives semantic merge"); + assert_same($decoded_user_graph, [ + 'branch' => $branch, + 'user_id' => $user_id, + ], "WordPress $branch user JSON metadata keeps branch-local IDs"); + assert_same($decoded_serialized_user_graph, [ + 'branch' => $branch, + 'user_id' => $user_id, + ], "WordPress $branch user serialized metadata keeps branch-local IDs"); + assert_same($page['post_type'] ?? null, 'page', "WordPress $branch page survives semantic merge"); + assert_same($page_author, $user_id, "WordPress $branch page author points at merged user"); + assert_true(str_contains((string)($page['post_content'] ?? ''), '"ref":' . $block_id), "WordPress $branch page keeps reusable block reference"); + assert_true(str_contains((string)($page['post_content'] ?? ''), '"id":' . $attachment_id), "WordPress $branch page keeps image block attachment reference"); + assert_same($block_type, 'wp_block', "WordPress $branch reusable block survives semantic merge"); + assert_same($attachment_type, 'attachment', "WordPress $branch attachment post survives semantic merge"); + assert_same($attached_file, $graph['file'], "WordPress $branch attachment metadata keeps upload path"); + assert_true(file_exists($root . '/wp-content/uploads/' . $graph['file']), "WordPress $branch upload file survives semantic merge"); + assert_same($comment_post_id, $page_id, "WordPress $branch page comment still points at merged page"); + assert_same($comment_user_id, $user_id, "WordPress $branch page comment author points at merged user"); + assert_same($reply_parent, $comment_id, "WordPress $branch threaded comment still points at merged parent comment"); + assert_same($reply_user_id, $user_id, "WordPress $branch threaded comment author points at merged user"); + assert_same($decoded_comment_graph, [ + 'branch' => $branch, + 'page_id' => $page_id, + 'comment_id' => $comment_id, + 'reply_comment_id' => $reply_comment_id, + ], "WordPress $branch comment JSON metadata keeps branch-local IDs"); + assert_same($decoded_reply_graph, [ + 'branch' => $branch, + 'page_id' => $page_id, + 'comment_id' => $comment_id, + 'reply_comment_id' => $reply_comment_id, + ], "WordPress $branch threaded comment serialized metadata keeps branch-local IDs"); + assert_same($menu_object_id, (string)$page_id, "WordPress $branch menu item still points at merged page"); + assert_same($relationship_count, 1, "WordPress $branch menu relationship survives semantic merge"); + assert_same($menu_taxonomy, 'nav_menu', "WordPress $branch nav menu taxonomy survives semantic merge"); + assert_same($decoded_bundle, $graph, "WordPress $branch semantic bundle metadata keeps branch-local IDs"); + assert_same($decoded_theme_mods['nav_menu_locations']['primary'] ?? null, $term_id, "WordPress $branch theme mods keep menu term ID"); + assert_same($decoded_theme_mods['forkpress_featured_page'] ?? null, $page_id, "WordPress $branch theme mods keep featured page ID"); + assert_same($decoded_theme_mods['forkpress_featured_block'] ?? null, $block_id, "WordPress $branch theme mods keep reusable block ID"); + assert_same($decoded_theme_mods['forkpress_featured_attachment'] ?? null, $attachment_id, "WordPress $branch theme mods keep attachment ID"); + }; + $assert_wp_semantic_bundle($wp_semantic_target, $wp_semantic_target_root, $wp_semantic_source_graph, 'source'); + $assert_wp_semantic_bundle($wp_semantic_target, $wp_semantic_target_root, $wp_semantic_target_graph, 'target'); + assert_same((int)scalar($wp_semantic_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-wp-semantic-source'"), 0, 'WordPress semantic merge records no generic conflicts while IDs remain banded'); + + $wp_media_base_root = $tmp . '/wp-media-validator-files-base'; + $wp_media_source_root = $tmp . '/wp-media-validator-files-source'; + $wp_media_target_root = $tmp . '/wp-media-validator-files-target'; + $wp_media_base = $wp_media_base_root . '/wp-content/database/.ht.sqlite'; + $wp_media_source = $wp_media_source_root . '/wp-content/database/.ht.sqlite'; + $wp_media_target = $wp_media_target_root . '/wp-content/database/.ht.sqlite'; + $wp_media_metadata = $tmp . '/.forkpress/cow/merge/wp-media-validator-metadata.sqlite'; + mkdir($wp_media_base_root . '/wp-content/database', 0777, true); + create_base_db($wp_media_base); + $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('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' +query("SELECT p.ID, f.meta_value AS attached_file, m.meta_value AS metadata + FROM wp_posts p + JOIN wp_postmeta f ON f.post_id = p.ID AND f.meta_key = '_wp_attached_file' + JOIN wp_postmeta m ON m.post_id = p.ID AND m.meta_key = '_wp_attachment_metadata' + WHERE p.post_type = 'attachment' + ORDER BY p.ID"); +$findings = []; +$claimed_uploads = []; +while ($row = $res->fetchArray(SQLITE3_ASSOC)) { + $attached_file = (string)$row['attached_file']; + $metadata = @unserialize((string)$row['metadata']); + if (!is_array($metadata)) { + $findings[] = [ + 'plugin' => 'forkpress-wp-media', + 'object' => 'attachment:' . $row['ID'], + 'reason' => 'attachment metadata is not readable', + 'type' => 'plugin-wp-media-invalid-metadata', + 'tables' => ['wp_posts', 'wp_postmeta'], + 'validator' => 'forkpress-wp-media@1', + 'candidate' => ['attached_file' => $attached_file], + ]; + continue; + } + $metadata_file = isset($metadata['file']) ? (string)$metadata['file'] : ''; + if ($metadata_file !== '' && $metadata_file !== $attached_file) { + $findings[] = [ + 'plugin' => 'forkpress-wp-media', + 'object' => 'attachment:' . $row['ID'], + 'reason' => '_wp_attached_file does not match _wp_attachment_metadata file', + 'type' => 'plugin-wp-media-file-mismatch', + 'tables' => ['wp_posts', 'wp_postmeta'], + 'validator' => 'forkpress-wp-media@1', + 'candidate' => [ + 'attached_file' => $attached_file, + 'metadata_file' => $metadata_file, + ], + ]; + } + $metadata_width = $metadata['width'] ?? null; + $metadata_height = $metadata['height'] ?? null; + if (!is_numeric($metadata_width) || !is_numeric($metadata_height) || (int)$metadata_width <= 0 || (int)$metadata_height <= 0) { + $findings[] = [ + 'plugin' => 'forkpress-wp-media', + 'object' => 'attachment:' . $row['ID'], + 'reason' => 'attachment original dimensions are invalid', + 'type' => 'plugin-wp-media-original-dimensions-drift', + 'tables' => ['wp_posts', 'wp_postmeta'], + 'validator' => 'forkpress-wp-media@1', + 'candidate' => [ + 'attached_file' => $attached_file, + 'width' => $metadata_width, + 'height' => $metadata_height, + ], + ]; + } + $relative_files = array_values(array_unique([$attached_file, $metadata_file])); + $directory = trim(dirname($metadata_file !== '' ? $metadata_file : $attached_file), '.'); + foreach (($metadata['sizes'] ?? []) as $size_name => $size) { + if (!is_array($size) || !isset($size['file'])) { + continue; + } + $size_file = str_replace('\\', '/', (string)$size['file']); + $size_width = $size['width'] ?? null; + $size_height = $size['height'] ?? null; + if (!is_numeric($size_width) || !is_numeric($size_height) || (int)$size_width <= 0 || (int)$size_height <= 0) { + $findings[] = [ + 'plugin' => 'forkpress-wp-media', + 'object' => 'attachment:' . $row['ID'], + 'reason' => 'attachment generated size dimensions are invalid', + 'type' => 'plugin-wp-media-generated-dimensions-drift', + 'tables' => ['wp_posts', 'wp_postmeta'], + 'validator' => 'forkpress-wp-media@1', + 'candidate' => [ + 'attached_file' => $attached_file, + 'size' => (string)$size_name, + 'width' => $size_width, + 'height' => $size_height, + ], + ]; + } + if ($size_file === '' || (str_contains($size_file, '/') && $unsafe_upload_path($size_file) === null)) { + $findings[] = [ + 'plugin' => 'forkpress-wp-media', + 'object' => 'attachment:' . $row['ID'], + 'reason' => 'attachment generated size file is not a non-empty basename', + 'type' => 'plugin-wp-media-generated-file-drift', + 'tables' => ['wp_posts', 'wp_postmeta'], + 'validator' => 'forkpress-wp-media@1', + 'candidate' => [ + 'attached_file' => $attached_file, + 'size' => (string)$size_name, + 'generated_file' => (string)$size['file'], + ], + ]; + if ($size_file === '') { + continue; + } + } + $relative_files[] = trim($directory . '/' . $size_file, '/'); + } + foreach ($relative_files as $relative_file) { + $relative_file = (string)$relative_file; + $unsafe_reason = $unsafe_upload_path($relative_file); + if ($unsafe_reason !== null) { + $findings[] = [ + 'plugin' => 'forkpress-wp-media', + 'object' => 'attachment:' . $row['ID'], + 'reason' => 'attachment metadata references an unsafe upload path: ' . $unsafe_reason, + 'type' => 'plugin-wp-media-unsafe-path', + 'tables' => ['wp_posts', 'wp_postmeta'], + 'validator' => 'forkpress-wp-media@1', + 'candidate' => [ + 'attached_file' => $attached_file, + 'unsafe_file' => $relative_file, + ], + ]; + continue; + } + $path = $target_root . '/wp-content/uploads/' . ltrim($relative_file, '/'); + if (!is_file($path)) { + $findings[] = [ + 'plugin' => 'forkpress-wp-media', + 'object' => 'attachment:' . $row['ID'], + 'reason' => 'attachment metadata references a missing upload file', + 'type' => 'plugin-wp-media-missing-file', + 'tables' => ['wp_posts', 'wp_postmeta'], + 'paths' => ['wp-content/uploads/' . ltrim((string)$relative_file, '/')], + 'validator' => 'forkpress-wp-media@1', + 'candidate' => [ + 'attached_file' => $attached_file, + 'missing_file' => $relative_file, + ], + ]; + } + $claimed_uploads[$relative_file] ??= []; + $claimed_uploads[$relative_file][] = (int)$row['ID']; + } +} +foreach ($claimed_uploads as $relative_file => $attachment_ids) { + $attachment_counts = array_count_values($attachment_ids); + $duplicate_attachment_ids = array_values(array_map('intval', array_keys(array_filter( + $attachment_counts, + static fn(int $count): bool => $count > 1 + )))); + $unique_attachment_ids = array_values(array_unique($attachment_ids)); + if (count($unique_attachment_ids) < 2 && $duplicate_attachment_ids === []) { + continue; + } + $findings[] = [ + 'plugin' => 'forkpress-wp-media', + 'object' => 'upload:' . $relative_file, + 'reason' => $duplicate_attachment_ids !== [] + ? 'attachment metadata claims the same upload file multiple times' + : 'multiple attachment metadata records claim the same upload file', + 'type' => 'plugin-wp-media-duplicate-file', + 'tables' => ['wp_posts', 'wp_postmeta'], + 'paths' => ['wp-content/uploads/' . ltrim((string)$relative_file, '/')], + 'validator' => 'forkpress-wp-media@1', + 'candidate' => [ + 'file' => $relative_file, + 'attachment_ids' => $unique_attachment_ids, + 'duplicate_attachment_ids' => $duplicate_attachment_ids, + ], + ]; +} +echo json_encode([ + 'status' => $findings ? 'conflicts' : 'valid', + 'findings' => $findings, +], JSON_UNESCAPED_SLASHES); +PHP); + copy_tree_for_test($wp_media_base_root, $wp_media_source_root); + copy_tree_for_test($wp_media_base_root, $wp_media_target_root); + $wp_media_file_base = $tmp . '/.forkpress/cow/merge/file-bases/wp-media-validator.json'; + cow_merge_capture_file_base($wp_media_base_root, $wp_media_file_base); + cow_merge_allocate_autoincrement_bands($wp_media_source, $wp_media_metadata, 'feature-wp-media-source'); + cow_merge_allocate_autoincrement_bands($wp_media_target, $wp_media_metadata, 'feature-wp-media-target'); + write_test_file($wp_media_source_root . '/wp-content/uploads/2026/05/source-original.jpg', "source original image\n"); + $wp_media_attachment_metadata = serialize([ + 'file' => '2026/05/source-original.jpg', + 'width' => 640, + 'height' => 480, + 'sizes' => [ + 'thumbnail' => [ + 'file' => 'source-original-150x150.jpg', + 'width' => 150, + 'height' => 150, + ], + ], + ]); + $db = open_db($wp_media_source); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_type, guid) VALUES ('Source media missing generated file', '', 'inherit', 'attachment', 'wp-content/uploads/2026/05/source-original.jpg')"); + $wp_media_attachment_id = (int)$db->lastInsertRowID(); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:post_id, '_wp_attached_file', :file), (:post_id, '_wp_attachment_metadata', :metadata)"); + $stmt->bindValue(':post_id', $wp_media_attachment_id, SQLITE3_INTEGER); + $stmt->bindValue(':file', '2026/05/source-original.jpg', SQLITE3_TEXT); + $stmt->bindValue(':metadata', $wp_media_attachment_metadata, SQLITE3_TEXT); + $stmt->execute(); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_type, guid) VALUES ('Source media missing original file', '', 'inherit', 'attachment', 'wp-content/uploads/2026/05/source-missing-original.jpg')"); + $wp_media_missing_original_id = (int)$db->lastInsertRowID(); + $wp_media_missing_original_metadata = serialize([ + 'file' => '2026/05/source-missing-original.jpg', + 'width' => 640, + 'height' => 480, + 'sizes' => [], + ]); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:post_id, '_wp_attached_file', :file), (:post_id, '_wp_attachment_metadata', :metadata)"); + $stmt->bindValue(':post_id', $wp_media_missing_original_id, SQLITE3_INTEGER); + $stmt->bindValue(':file', '2026/05/source-missing-original.jpg', SQLITE3_TEXT); + $stmt->bindValue(':metadata', $wp_media_missing_original_metadata, SQLITE3_TEXT); + $stmt->execute(); + write_test_file($wp_media_source_root . '/wp-content/uploads/2026/05/source-attached-file.jpg', "source attached file bytes\n"); + write_test_file($wp_media_source_root . '/wp-content/uploads/2026/05/source-metadata-file.jpg', "source metadata file bytes\n"); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_type, guid) VALUES ('Source media mismatched metadata file', '', 'inherit', 'attachment', 'wp-content/uploads/2026/05/source-attached-file.jpg')"); + $wp_media_mismatch_id = (int)$db->lastInsertRowID(); + $wp_media_mismatch_metadata = serialize([ + 'file' => '2026/05/source-metadata-file.jpg', + 'width' => 640, + 'height' => 480, + 'sizes' => [], + ]); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:post_id, '_wp_attached_file', :file), (:post_id, '_wp_attachment_metadata', :metadata)"); + $stmt->bindValue(':post_id', $wp_media_mismatch_id, SQLITE3_INTEGER); + $stmt->bindValue(':file', '2026/05/source-attached-file.jpg', SQLITE3_TEXT); + $stmt->bindValue(':metadata', $wp_media_mismatch_metadata, SQLITE3_TEXT); + $stmt->execute(); + write_test_file($wp_media_source_root . '/wp-content/uploads/2026/05/source-invalid-metadata.jpg', "source invalid metadata bytes\n"); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_type, guid) VALUES ('Source media invalid attachment metadata', '', 'inherit', 'attachment', 'wp-content/uploads/2026/05/source-invalid-metadata.jpg')"); + $wp_media_invalid_metadata_id = (int)$db->lastInsertRowID(); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:post_id, '_wp_attached_file', :file), (:post_id, '_wp_attachment_metadata', :metadata)"); + $stmt->bindValue(':post_id', $wp_media_invalid_metadata_id, SQLITE3_INTEGER); + $stmt->bindValue(':file', '2026/05/source-invalid-metadata.jpg', SQLITE3_TEXT); + $stmt->bindValue(':metadata', 'not-a-serialized-attachment-metadata-payload', SQLITE3_TEXT); + $stmt->execute(); + write_test_file($wp_media_source_root . '/wp-content/uploads/2026/05/source-original-dimensions-drift.jpg', "source original dimensions drift bytes\n"); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_type, guid) VALUES ('Source media original dimensions drift', '', 'inherit', 'attachment', 'wp-content/uploads/2026/05/source-original-dimensions-drift.jpg')"); + $wp_media_original_dimensions_drift_id = (int)$db->lastInsertRowID(); + $wp_media_original_dimensions_drift_metadata = serialize([ + 'file' => '2026/05/source-original-dimensions-drift.jpg', + 'width' => 0, + 'height' => 480, + 'sizes' => [], + ]); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:post_id, '_wp_attached_file', :file), (:post_id, '_wp_attachment_metadata', :metadata)"); + $stmt->bindValue(':post_id', $wp_media_original_dimensions_drift_id, SQLITE3_INTEGER); + $stmt->bindValue(':file', '2026/05/source-original-dimensions-drift.jpg', SQLITE3_TEXT); + $stmt->bindValue(':metadata', $wp_media_original_dimensions_drift_metadata, SQLITE3_TEXT); + $stmt->execute(); + write_test_file($wp_media_source_root . '/wp-content/uploads/2026/05/source-unsafe-path.jpg', "source unsafe path original bytes\n"); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_type, guid) VALUES ('Source media unsafe generated path', '', 'inherit', 'attachment', 'wp-content/uploads/2026/05/source-unsafe-path.jpg')"); + $wp_media_unsafe_path_id = (int)$db->lastInsertRowID(); + $wp_media_unsafe_path_metadata = serialize([ + 'file' => '2026/05/source-unsafe-path.jpg', + 'width' => 640, + 'height' => 480, + 'sizes' => [ + 'thumbnail' => [ + 'file' => '../source-unsafe-path-150x150.jpg', + 'width' => 150, + 'height' => 150, + ], + ], + ]); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:post_id, '_wp_attached_file', :file), (:post_id, '_wp_attachment_metadata', :metadata)"); + $stmt->bindValue(':post_id', $wp_media_unsafe_path_id, SQLITE3_INTEGER); + $stmt->bindValue(':file', '2026/05/source-unsafe-path.jpg', SQLITE3_TEXT); + $stmt->bindValue(':metadata', $wp_media_unsafe_path_metadata, SQLITE3_TEXT); + $stmt->execute(); + write_test_file($wp_media_source_root . '/wp-content/uploads/2026/05/source-generated-path-drift.jpg', "source generated path drift original bytes\n"); + write_test_file($wp_media_source_root . '/wp-content/uploads/2026/05/nested/source-generated-path-drift-150x150.jpg', "source generated path drift nested generated bytes\n"); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_type, guid) VALUES ('Source media generated path drift', '', 'inherit', 'attachment', 'wp-content/uploads/2026/05/source-generated-path-drift.jpg')"); + $wp_media_generated_path_drift_id = (int)$db->lastInsertRowID(); + $wp_media_generated_path_drift_metadata = serialize([ + 'file' => '2026/05/source-generated-path-drift.jpg', + 'width' => 640, + 'height' => 480, + 'sizes' => [ + 'thumbnail' => [ + 'file' => 'nested/source-generated-path-drift-150x150.jpg', + 'width' => 150, + 'height' => 150, + ], + ], + ]); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:post_id, '_wp_attached_file', :file), (:post_id, '_wp_attachment_metadata', :metadata)"); + $stmt->bindValue(':post_id', $wp_media_generated_path_drift_id, SQLITE3_INTEGER); + $stmt->bindValue(':file', '2026/05/source-generated-path-drift.jpg', SQLITE3_TEXT); + $stmt->bindValue(':metadata', $wp_media_generated_path_drift_metadata, SQLITE3_TEXT); + $stmt->execute(); + write_test_file($wp_media_source_root . '/wp-content/uploads/2026/05/source-generated-empty-drift.jpg', "source generated empty drift original bytes\n"); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_type, guid) VALUES ('Source media generated empty drift', '', 'inherit', 'attachment', 'wp-content/uploads/2026/05/source-generated-empty-drift.jpg')"); + $wp_media_generated_empty_drift_id = (int)$db->lastInsertRowID(); + $wp_media_generated_empty_drift_metadata = serialize([ + 'file' => '2026/05/source-generated-empty-drift.jpg', + 'width' => 640, + 'height' => 480, + 'sizes' => [ + 'thumbnail' => [ + 'file' => '', + 'width' => 150, + 'height' => 150, + ], + ], + ]); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:post_id, '_wp_attached_file', :file), (:post_id, '_wp_attachment_metadata', :metadata)"); + $stmt->bindValue(':post_id', $wp_media_generated_empty_drift_id, SQLITE3_INTEGER); + $stmt->bindValue(':file', '2026/05/source-generated-empty-drift.jpg', SQLITE3_TEXT); + $stmt->bindValue(':metadata', $wp_media_generated_empty_drift_metadata, SQLITE3_TEXT); + $stmt->execute(); + write_test_file($wp_media_source_root . '/wp-content/uploads/2026/05/source-self-duplicate.jpg', "source self duplicate original bytes\n"); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_type, guid) VALUES ('Source media self duplicate generated file', '', 'inherit', 'attachment', 'wp-content/uploads/2026/05/source-self-duplicate.jpg')"); + $wp_media_self_duplicate_id = (int)$db->lastInsertRowID(); + $wp_media_self_duplicate_metadata = serialize([ + 'file' => '2026/05/source-self-duplicate.jpg', + 'width' => 640, + 'height' => 480, + 'sizes' => [ + 'thumbnail' => [ + 'file' => 'source-self-duplicate.jpg', + 'width' => 150, + 'height' => 150, + ], + ], + ]); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:post_id, '_wp_attached_file', :file), (:post_id, '_wp_attachment_metadata', :metadata)"); + $stmt->bindValue(':post_id', $wp_media_self_duplicate_id, SQLITE3_INTEGER); + $stmt->bindValue(':file', '2026/05/source-self-duplicate.jpg', SQLITE3_TEXT); + $stmt->bindValue(':metadata', $wp_media_self_duplicate_metadata, SQLITE3_TEXT); + $stmt->execute(); + write_test_file($wp_media_source_root . '/wp-content/uploads/2026/05/source-generated-dimensions-drift.jpg', "source generated dimensions drift original bytes\n"); + write_test_file($wp_media_source_root . '/wp-content/uploads/2026/05/source-generated-dimensions-drift-150x150.jpg', "source generated dimensions drift generated bytes\n"); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_type, guid) VALUES ('Source media generated dimensions drift', '', 'inherit', 'attachment', 'wp-content/uploads/2026/05/source-generated-dimensions-drift.jpg')"); + $wp_media_generated_dimensions_drift_id = (int)$db->lastInsertRowID(); + $wp_media_generated_dimensions_drift_metadata = serialize([ + 'file' => '2026/05/source-generated-dimensions-drift.jpg', + 'width' => 640, + 'height' => 480, + 'sizes' => [ + 'thumbnail' => [ + 'file' => 'source-generated-dimensions-drift-150x150.jpg', + 'width' => 0, + 'height' => 150, + ], + ], + ]); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:post_id, '_wp_attached_file', :file), (:post_id, '_wp_attachment_metadata', :metadata)"); + $stmt->bindValue(':post_id', $wp_media_generated_dimensions_drift_id, SQLITE3_INTEGER); + $stmt->bindValue(':file', '2026/05/source-generated-dimensions-drift.jpg', SQLITE3_TEXT); + $stmt->bindValue(':metadata', $wp_media_generated_dimensions_drift_metadata, SQLITE3_TEXT); + $stmt->execute(); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_type, guid) VALUES ('Source media unsafe attached path', '', 'inherit', 'attachment', '/tmp/source-unsafe-attached.jpg')"); + $wp_media_unsafe_attached_path_id = (int)$db->lastInsertRowID(); + $wp_media_unsafe_attached_path_metadata = serialize([ + 'file' => '/tmp/source-unsafe-attached.jpg', + 'width' => 640, + 'height' => 480, + 'sizes' => [], + ]); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:post_id, '_wp_attached_file', :file), (:post_id, '_wp_attachment_metadata', :metadata)"); + $stmt->bindValue(':post_id', $wp_media_unsafe_attached_path_id, SQLITE3_INTEGER); + $stmt->bindValue(':file', '/tmp/source-unsafe-attached.jpg', SQLITE3_TEXT); + $stmt->bindValue(':metadata', $wp_media_unsafe_attached_path_metadata, SQLITE3_TEXT); + $stmt->execute(); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_type, guid) VALUES ('Source media empty attached path', '', 'inherit', 'attachment', '')"); + $wp_media_empty_attached_path_id = (int)$db->lastInsertRowID(); + $wp_media_empty_attached_path_metadata = serialize([ + 'file' => '', + 'width' => 640, + 'height' => 480, + 'sizes' => [], + ]); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:post_id, '_wp_attached_file', :file), (:post_id, '_wp_attachment_metadata', :metadata)"); + $stmt->bindValue(':post_id', $wp_media_empty_attached_path_id, SQLITE3_INTEGER); + $stmt->bindValue(':file', '', SQLITE3_TEXT); + $stmt->bindValue(':metadata', $wp_media_empty_attached_path_metadata, SQLITE3_TEXT); + $stmt->execute(); + $wp_media_nul_attached_path = "2026/05/source-nul\0path.jpg"; + $stmt = $db->prepare("INSERT INTO wp_posts (post_title, post_content, post_status, post_type, guid) VALUES ('Source media NUL attached path', '', 'inherit', 'attachment', :guid)"); + $stmt->bindValue(':guid', 'wp-content/uploads/' . $wp_media_nul_attached_path, SQLITE3_TEXT); + $stmt->execute(); + $wp_media_nul_attached_path_id = (int)$db->lastInsertRowID(); + $wp_media_nul_attached_path_metadata = serialize([ + 'file' => $wp_media_nul_attached_path, + 'width' => 640, + 'height' => 480, + 'sizes' => [], + ]); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:post_id, '_wp_attached_file', :file), (:post_id, '_wp_attachment_metadata', :metadata)"); + $stmt->bindValue(':post_id', $wp_media_nul_attached_path_id, SQLITE3_INTEGER); + $stmt->bindValue(':file', $wp_media_nul_attached_path, SQLITE3_TEXT); + $stmt->bindValue(':metadata', $wp_media_nul_attached_path_metadata, SQLITE3_TEXT); + $stmt->execute(); + write_test_file($wp_media_source_root . '/wp-content/uploads/2026/05/source-duplicate-a.jpg', "source duplicate original a\n"); + write_test_file($wp_media_source_root . '/wp-content/uploads/2026/05/source-duplicate-b.jpg', "source duplicate original b\n"); + write_test_file($wp_media_source_root . '/wp-content/uploads/2026/05/source-duplicate-shared-150x150.jpg', "source duplicate shared generated size\n"); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_type, guid) VALUES ('Source media duplicate generated file A', '', 'inherit', 'attachment', 'wp-content/uploads/2026/05/source-duplicate-a.jpg')"); + $wp_media_duplicate_a_id = (int)$db->lastInsertRowID(); + $wp_media_duplicate_a_metadata = serialize([ + 'file' => '2026/05/source-duplicate-a.jpg', + 'width' => 640, + 'height' => 480, + 'sizes' => [ + 'thumbnail' => [ + 'file' => 'source-duplicate-shared-150x150.jpg', + 'width' => 150, + 'height' => 150, + ], + ], + ]); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:post_id, '_wp_attached_file', :file), (:post_id, '_wp_attachment_metadata', :metadata)"); + $stmt->bindValue(':post_id', $wp_media_duplicate_a_id, SQLITE3_INTEGER); + $stmt->bindValue(':file', '2026/05/source-duplicate-a.jpg', SQLITE3_TEXT); + $stmt->bindValue(':metadata', $wp_media_duplicate_a_metadata, SQLITE3_TEXT); + $stmt->execute(); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status, post_type, guid) VALUES ('Source media duplicate generated file B', '', 'inherit', 'attachment', 'wp-content/uploads/2026/05/source-duplicate-b.jpg')"); + $wp_media_duplicate_b_id = (int)$db->lastInsertRowID(); + $wp_media_duplicate_b_metadata = serialize([ + 'file' => '2026/05/source-duplicate-b.jpg', + 'width' => 640, + 'height' => 480, + 'sizes' => [ + 'thumbnail' => [ + 'file' => 'source-duplicate-shared-150x150.jpg', + 'width' => 150, + 'height' => 150, + ], + ], + ]); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:post_id, '_wp_attached_file', :file), (:post_id, '_wp_attachment_metadata', :metadata)"); + $stmt->bindValue(':post_id', $wp_media_duplicate_b_id, SQLITE3_INTEGER); + $stmt->bindValue(':file', '2026/05/source-duplicate-b.jpg', SQLITE3_TEXT); + $stmt->bindValue(':metadata', $wp_media_duplicate_b_metadata, SQLITE3_TEXT); + $stmt->execute(); + $db->close(); + $wp_media_result = cow_merge_branch_state( + $wp_media_base, + $wp_media_source, + $wp_media_target, + $wp_media_metadata, + 'feature-wp-media-source', + 'feature-wp-media-target', + $wp_media_file_base, + $wp_media_source_root, + $wp_media_target_root + ); + 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'); + assert_same((int)($wp_media_result['plugin_validator_conflicts'] ?? 0), 14, 'WordPress media validator records missing files, duplicate files, media metadata drift, and metadata mismatches'); + assert_same( + scalar($wp_media_target, "SELECT meta_value FROM wp_postmeta WHERE post_id = $wp_media_attachment_id AND meta_key = '_wp_attached_file'"), + '2026/05/source-original.jpg', + 'WordPress media validator leaves the staged attachment metadata available for review' + ); + assert_true(is_file($wp_media_target_root . '/wp-content/uploads/2026/05/source-original.jpg'), 'WordPress media validator keeps the merged original upload file'); + assert_true(is_file($wp_media_target_root . '/wp-content/uploads/2026/05/source-attached-file.jpg'), 'WordPress media validator keeps the mismatched attached upload file'); + assert_true(is_file($wp_media_target_root . '/wp-content/uploads/2026/05/source-metadata-file.jpg'), 'WordPress media validator keeps the mismatched metadata upload file'); + assert_true(is_file($wp_media_target_root . '/wp-content/uploads/2026/05/source-invalid-metadata.jpg'), 'WordPress media validator keeps the upload for unreadable attachment metadata'); + assert_true(is_file($wp_media_target_root . '/wp-content/uploads/2026/05/source-original-dimensions-drift.jpg'), 'WordPress media validator keeps original dimension drift files for review'); + assert_true(is_file($wp_media_target_root . '/wp-content/uploads/2026/05/source-unsafe-path.jpg'), 'WordPress media validator keeps the upload for unsafe generated-size metadata'); + assert_true(is_file($wp_media_target_root . '/wp-content/uploads/2026/05/nested/source-generated-path-drift-150x150.jpg'), 'WordPress media validator keeps generated-size path drift files for review'); + assert_true(is_file($wp_media_target_root . '/wp-content/uploads/2026/05/source-generated-empty-drift.jpg'), 'WordPress media validator keeps generated-size empty filename drift originals for review'); + assert_true(is_file($wp_media_target_root . '/wp-content/uploads/2026/05/source-self-duplicate.jpg'), 'WordPress media validator keeps same-attachment duplicate upload files for review'); + assert_true(is_file($wp_media_target_root . '/wp-content/uploads/2026/05/source-generated-dimensions-drift-150x150.jpg'), 'WordPress media validator keeps generated-size dimension drift files for review'); + assert_true(is_file($wp_media_target_root . '/wp-content/uploads/2026/05/source-duplicate-shared-150x150.jpg'), 'WordPress media validator keeps duplicated generated upload files for review'); + assert_true(!is_file($wp_media_target_root . '/wp-content/uploads/2026/05/source-original-150x150.jpg'), 'WordPress media validator does not invent missing generated upload files'); + assert_true(!is_file($wp_media_target_root . '/wp-content/uploads/2026/05/source-missing-original.jpg'), 'WordPress media validator does not invent missing original upload files'); + $wp_media_audit = cow_merge_audit_report($wp_media_metadata, (int)$wp_media_result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'conflict_type' => 'plugin-wp-media-missing-file', + ]); + assert_same(count($wp_media_audit['conflicts']), 2, 'WordPress media validator exposes missing upload files as plugin-scoped audit conflicts'); + $wp_media_audit_preview = implode("\n", array_map(fn($conflict) => (string)($conflict['chosen_preview'] ?? ''), $wp_media_audit['conflicts'])); + assert_true(str_contains($wp_media_audit_preview, 'source-original-150x150.jpg'), 'WordPress media validator audit includes the missing generated upload filename'); + assert_true(str_contains($wp_media_audit_preview, 'source-missing-original.jpg'), 'WordPress media validator audit includes the missing original upload filename'); + $wp_media_mismatch_audit = cow_merge_audit_report($wp_media_metadata, (int)$wp_media_result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'conflict_type' => 'plugin-wp-media-file-mismatch', + ]); + assert_same(count($wp_media_mismatch_audit['conflicts']), 1, 'WordPress media validator exposes attachment file mismatches as plugin-scoped audit conflicts'); + $wp_media_mismatch_preview = (string)($wp_media_mismatch_audit['conflicts'][0]['chosen_preview'] ?? ''); + assert_true(str_contains($wp_media_mismatch_preview, 'source-attached-file.jpg'), 'WordPress media mismatch audit includes the attached file'); + assert_true(str_contains($wp_media_mismatch_preview, 'source-metadata-file.jpg'), 'WordPress media mismatch audit includes the metadata file'); + $wp_media_invalid_audit = cow_merge_audit_report($wp_media_metadata, (int)$wp_media_result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'conflict_type' => 'plugin-wp-media-invalid-metadata', + ]); + assert_same(count($wp_media_invalid_audit['conflicts']), 2, 'WordPress media validator exposes unreadable attachment metadata as a plugin-scoped audit conflict'); + $wp_media_invalid_preview = implode("\n", array_map(fn($conflict) => (string)($conflict['chosen_preview'] ?? ''), $wp_media_invalid_audit['conflicts'])); + assert_true(str_contains($wp_media_invalid_preview, 'source-invalid-metadata.jpg'), 'WordPress media invalid metadata audit includes the attached file'); + assert_true(str_contains($wp_media_invalid_preview, 'source-nul'), 'WordPress media invalid metadata audit includes the NUL-corrupted attached file'); + $wp_media_original_dimensions_drift_audit = cow_merge_audit_report($wp_media_metadata, (int)$wp_media_result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'conflict_type' => 'plugin-wp-media-original-dimensions-drift', + ]); + assert_same(count($wp_media_original_dimensions_drift_audit['conflicts']), 1, 'WordPress media validator exposes original image dimension drift as a plugin-scoped audit conflict'); + $wp_media_original_dimensions_drift_preview = (string)($wp_media_original_dimensions_drift_audit['conflicts'][0]['chosen_preview'] ?? ''); + assert_true(str_contains($wp_media_original_dimensions_drift_preview, 'source-original-dimensions-drift.jpg'), 'WordPress media original dimension drift audit includes the affected attachment'); + $wp_media_unsafe_path_audit = cow_merge_audit_report($wp_media_metadata, (int)$wp_media_result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'conflict_type' => 'plugin-wp-media-unsafe-path', + ]); + assert_same(count($wp_media_unsafe_path_audit['conflicts']), 3, 'WordPress media validator exposes unsafe upload metadata paths as plugin-scoped audit conflicts'); + $wp_media_unsafe_path_preview = implode("\n", array_map(fn($conflict) => (string)($conflict['chosen_preview'] ?? ''), $wp_media_unsafe_path_audit['conflicts'])); + assert_true(str_contains($wp_media_unsafe_path_preview, '../source-unsafe-path-150x150.jpg'), 'WordPress media unsafe path audit includes the traversal path'); + assert_true(str_contains($wp_media_unsafe_path_preview, '/tmp/source-unsafe-attached.jpg'), 'WordPress media unsafe path audit includes the absolute attached path'); + $wp_media_generated_path_drift_audit = cow_merge_audit_report($wp_media_metadata, (int)$wp_media_result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'conflict_type' => 'plugin-wp-media-generated-file-drift', + ]); + assert_same(count($wp_media_generated_path_drift_audit['conflicts']), 2, 'WordPress media validator exposes generated-size filename drift as plugin-scoped audit conflicts'); + $wp_media_generated_path_drift_preview = implode("\n", array_map(fn($conflict) => (string)($conflict['chosen_preview'] ?? ''), $wp_media_generated_path_drift_audit['conflicts'])); + assert_true(str_contains($wp_media_generated_path_drift_preview, 'nested/source-generated-path-drift-150x150.jpg'), 'WordPress media generated path drift audit includes the nested generated filename'); + assert_true(str_contains($wp_media_generated_path_drift_preview, 'source-generated-empty-drift.jpg'), 'WordPress media generated file drift audit includes the empty generated filename attachment'); + $wp_media_generated_dimensions_drift_audit = cow_merge_audit_report($wp_media_metadata, (int)$wp_media_result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'conflict_type' => 'plugin-wp-media-generated-dimensions-drift', + ]); + assert_same(count($wp_media_generated_dimensions_drift_audit['conflicts']), 1, 'WordPress media validator exposes generated-size dimension drift as a plugin-scoped audit conflict'); + $wp_media_generated_dimensions_drift_preview = (string)($wp_media_generated_dimensions_drift_audit['conflicts'][0]['chosen_preview'] ?? ''); + assert_true(str_contains($wp_media_generated_dimensions_drift_preview, 'source-generated-dimensions-drift.jpg'), 'WordPress media generated dimension drift audit includes the affected attachment'); + $wp_media_empty_path_recorded = false; + $wp_media_meta_db = open_db($wp_media_metadata); + $wp_media_payloads = $wp_media_meta_db->query("SELECT chosen_payload FROM merge_conflicts WHERE conflict_type = 'plugin-wp-media-unsafe-path'"); + while ($wp_media_payload = $wp_media_payloads->fetchArray(SQLITE3_ASSOC)) { + $decoded = cow_merge_decode_payload_json((string)$wp_media_payload['chosen_payload'], 'wp media unsafe path payload'); + if (($decoded['reason'] ?? '') === 'attachment metadata references an unsafe upload path: upload path is empty') { + $wp_media_empty_path_recorded = true; + } + } + $wp_media_payloads->finalize(); + $wp_media_meta_db->close(); + assert_true($wp_media_empty_path_recorded, 'WordPress media unsafe path audit records the empty attached path reason'); + $wp_media_duplicate_file_audit = cow_merge_audit_report($wp_media_metadata, (int)$wp_media_result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'conflict_type' => 'plugin-wp-media-duplicate-file', + ]); + assert_same(count($wp_media_duplicate_file_audit['conflicts']), 2, 'WordPress media validator exposes duplicate upload ownership as plugin-scoped audit conflicts'); + $wp_media_duplicate_file_preview = implode("\n", array_map(fn($conflict) => (string)($conflict['chosen_preview'] ?? ''), $wp_media_duplicate_file_audit['conflicts'])); + assert_true(str_contains($wp_media_duplicate_file_preview, 'source-self-duplicate.jpg'), 'WordPress media duplicate upload audit includes the same-attachment duplicate filename'); + assert_true(str_contains($wp_media_duplicate_file_preview, (string)$wp_media_self_duplicate_id), 'WordPress media duplicate upload audit includes the same-attachment duplicate ID'); + assert_true(str_contains($wp_media_duplicate_file_preview, 'source-duplicate-shared-150x150.jpg'), 'WordPress media duplicate upload audit includes the shared generated filename'); + assert_true(str_contains($wp_media_duplicate_file_preview, (string)$wp_media_duplicate_a_id), 'WordPress media duplicate upload audit includes the first attachment ID'); + assert_true(str_contains($wp_media_duplicate_file_preview, (string)$wp_media_duplicate_b_id), 'WordPress media duplicate upload audit includes the second attachment ID'); + + $wp_block_ref_base_root = $tmp . '/wp-block-ref-validator-files-base'; + $wp_block_ref_source_root = $tmp . '/wp-block-ref-validator-files-source'; + $wp_block_ref_target_root = $tmp . '/wp-block-ref-validator-files-target'; + $wp_block_ref_base = $wp_block_ref_base_root . '/wp-content/database/.ht.sqlite'; + $wp_block_ref_source = $wp_block_ref_source_root . '/wp-content/database/.ht.sqlite'; + $wp_block_ref_target = $wp_block_ref_target_root . '/wp-content/database/.ht.sqlite'; + $wp_block_ref_metadata = $tmp . '/.forkpress/cow/merge/wp-block-ref-validator-metadata.sqlite'; + mkdir($wp_block_ref_base_root . '/wp-content/database', 0777, true); + create_base_db($wp_block_ref_base); + $db = open_db($wp_block_ref_base); + $db->exec("ALTER TABLE wp_posts ADD COLUMN post_type TEXT NOT NULL DEFAULT 'post'"); + $db->exec("ALTER TABLE wp_posts ADD COLUMN post_name 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("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_type, post_name) VALUES + (30, 'Shared reusable block', '

Shared block

', 'publish', 'wp_block', 'shared-reusable-block'), + (31, 'Page with reusable block', '

Base page content

', 'publish', 'page', 'page-with-reusable-block'), + (32, 'Shared synced pattern', '

Shared synced pattern

', 'publish', 'wp_block', 'shared-synced-pattern'), + (33, 'Page with synced pattern', '

Base synced pattern content

', 'publish', 'page', 'page-with-synced-pattern')"); + $db->exec("INSERT INTO wp_postmeta (meta_id, post_id, meta_key, meta_value) VALUES (34, 32, 'wp_pattern_sync_status', 'synced')"); + $db->close(); + write_test_file($wp_block_ref_base_root . '/wp-content/mu-plugins/forkpress-merge-validator.php', <<<'PHP' +query("SELECT ID, post_content FROM wp_posts WHERE post_type IN ('page', 'post', 'wp_template_part', 'wp_template')"); +$findings = []; +while ($row = $res->fetchArray(SQLITE3_ASSOC)) { + $content = (string)$row['post_content']; + if (!preg_match_all('/

Menu page

', 'publish', 'page', 'shared-menu-page'), + (41, 'Menu item for shared page', '', 'publish', 'nav_menu_item', 'menu-item-shared-page')"); + $db->exec("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES + (41, '_menu_item_type', 'post_type'), + (41, '_menu_item_object', 'page'), + (41, '_menu_item_object_id', '40'), + (41, '_menu_item_menu_item_parent', '0')"); + $db->close(); + write_test_file($wp_menu_ref_base_root . '/wp-content/mu-plugins/forkpress-merge-validator.php', <<<'PHP' +query("SELECT item.ID AS menu_item_id, object.meta_value AS object_type, object_id.meta_value AS object_id + FROM wp_posts item + JOIN wp_postmeta item_type ON item_type.post_id = item.ID AND item_type.meta_key = '_menu_item_type' + JOIN wp_postmeta object ON object.post_id = item.ID AND object.meta_key = '_menu_item_object' + JOIN wp_postmeta object_id ON object_id.post_id = item.ID AND object_id.meta_key = '_menu_item_object_id' + WHERE item.post_type = 'nav_menu_item' AND item_type.meta_value = 'post_type'"); +$findings = []; +while ($row = $res->fetchArray(SQLITE3_ASSOC)) { + $object_type = (string)$row['object_type']; + $object_id = (int)$row['object_id']; + $escaped_type = SQLite3::escapeString($object_type); + $exists = (int)$db->querySingle("SELECT COUNT(*) FROM wp_posts WHERE ID = $object_id AND post_type = '$escaped_type'"); + if ($exists === 0) { + $findings[] = [ + 'plugin' => 'forkpress-wp-menu-refs', + 'object' => 'nav_menu_item:' . $row['menu_item_id'], + 'reason' => 'nav menu item references a missing post object', + 'type' => 'plugin-wp-menu-missing-object', + 'tables' => ['wp_posts', 'wp_postmeta'], + 'validator' => 'forkpress-wp-menu-refs@1', + 'candidate' => [ + 'menu_item_id' => (int)$row['menu_item_id'], + 'object_type' => $object_type, + 'missing_object_id' => $object_id, + ], + ]; + } +} +echo json_encode([ + 'status' => $findings ? 'conflicts' : 'valid', + 'findings' => $findings, +], JSON_UNESCAPED_SLASHES); +PHP); + copy_tree_for_test($wp_menu_ref_base_root, $wp_menu_ref_source_root); + copy_tree_for_test($wp_menu_ref_base_root, $wp_menu_ref_target_root); + $wp_menu_ref_file_base = $tmp . '/.forkpress/cow/merge/file-bases/wp-menu-ref-validator.json'; + cow_merge_capture_file_base($wp_menu_ref_base_root, $wp_menu_ref_file_base); + cow_merge_allocate_autoincrement_bands($wp_menu_ref_source, $wp_menu_ref_metadata, 'feature-wp-menu-ref-source'); + cow_merge_allocate_autoincrement_bands($wp_menu_ref_target, $wp_menu_ref_metadata, 'feature-wp-menu-ref-target'); + $db = open_db($wp_menu_ref_source); + $db->exec('DELETE FROM wp_posts WHERE ID = 40'); + $db->close(); + $db = open_db($wp_menu_ref_target); + $db->exec("UPDATE wp_posts SET post_title = 'Target menu item still pointing at deleted page' WHERE ID = 41"); + $db->close(); + $wp_menu_ref_result = cow_merge_branch_state( + $wp_menu_ref_base, + $wp_menu_ref_source, + $wp_menu_ref_target, + $wp_menu_ref_metadata, + 'feature-wp-menu-ref-source', + 'feature-wp-menu-ref-target', + $wp_menu_ref_file_base, + $wp_menu_ref_source_root, + $wp_menu_ref_target_root + ); + assert_same($wp_menu_ref_result['status'], 'completed_with_conflicts', 'WordPress menu reference validator holds missing menu objects for review'); + assert_same((int)($wp_menu_ref_result['plugin_validators'] ?? 0), 1, 'WordPress menu reference validator is discovered from mu-plugins during merge'); + assert_same((int)($wp_menu_ref_result['plugin_validator_conflicts'] ?? 0), 1, 'WordPress menu reference validator records the missing page object'); + assert_same((int)scalar($wp_menu_ref_target, 'SELECT COUNT(*) FROM wp_posts WHERE ID = 40'), 0, 'WordPress menu reference validator leaves the source page deletion staged for review'); + assert_same(scalar($wp_menu_ref_target, 'SELECT post_title FROM wp_posts WHERE ID = 41'), 'Target menu item still pointing at deleted page', 'WordPress menu reference validator preserves the target menu item edit'); + $wp_menu_ref_audit = cow_merge_audit_report($wp_menu_ref_metadata, (int)$wp_menu_ref_result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'conflict_type' => 'plugin-wp-menu-missing-object', + ]); + assert_same(count($wp_menu_ref_audit['conflicts']), 1, 'WordPress menu reference validator exposes the missing menu object as a plugin-scoped audit conflict'); + $wp_menu_ref_preview = (string)($wp_menu_ref_audit['conflicts'][0]['chosen_preview'] ?? ''); + assert_true(str_contains($wp_menu_ref_preview, '"missing_object_id":40'), 'WordPress menu reference audit includes the missing page ID'); + assert_true(str_contains($wp_menu_ref_preview, '"object_type":"page"'), 'WordPress menu reference audit includes the menu object type'); + + $wp_option_ref_base_root = $tmp . '/wp-option-ref-validator-files-base'; + $wp_option_ref_source_root = $tmp . '/wp-option-ref-validator-files-source'; + $wp_option_ref_target_root = $tmp . '/wp-option-ref-validator-files-target'; + $wp_option_ref_base = $wp_option_ref_base_root . '/wp-content/database/.ht.sqlite'; + $wp_option_ref_source = $wp_option_ref_source_root . '/wp-content/database/.ht.sqlite'; + $wp_option_ref_target = $wp_option_ref_target_root . '/wp-content/database/.ht.sqlite'; + $wp_option_ref_metadata = $tmp . '/.forkpress/cow/merge/wp-option-ref-validator-metadata.sqlite'; + mkdir($wp_option_ref_base_root . '/wp-content/database', 0777, true); + create_base_db($wp_option_ref_base); + $db = open_db($wp_option_ref_base); + $db->exec("ALTER TABLE wp_posts ADD COLUMN post_type TEXT NOT NULL DEFAULT 'post'"); + $db->exec("ALTER TABLE wp_posts ADD COLUMN post_name TEXT NOT NULL DEFAULT ''"); + $db->exec("ALTER TABLE wp_posts ADD COLUMN guid TEXT NOT NULL DEFAULT ''"); + $db->exec('CREATE TABLE wp_terms (term_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, slug TEXT NOT NULL)'); + $db->exec('CREATE TABLE wp_term_taxonomy (term_taxonomy_id INTEGER PRIMARY KEY AUTOINCREMENT, term_id INTEGER NOT NULL, taxonomy TEXT NOT NULL, description TEXT NOT NULL DEFAULT "", parent INTEGER NOT NULL DEFAULT 0, count INTEGER NOT NULL DEFAULT 0)'); + $db->exec("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_type, post_name) VALUES + (50, 'Featured option page', '

Featured option page

', 'publish', 'page', 'featured-option-page')"); + $db->exec("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_type, post_name) VALUES + (54, 'Posts option page', '

Posts option page

', 'publish', 'page', 'posts-option-page')"); + $db->exec("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_type, post_name) VALUES + (53, 'Sticky option post', '

Sticky option post

', 'publish', 'post', 'sticky-option-post')"); + $db->exec("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_type, post_name, guid) VALUES + (52, 'Option logo', '', 'inherit', 'attachment', 'option-logo', 'http://example.test/wp-content/uploads/2026/05/option-logo.jpg')"); + $db->exec("INSERT INTO wp_terms (term_id, name, slug) VALUES (51, 'Primary menu', 'primary-menu')"); + $db->exec("INSERT INTO wp_term_taxonomy (term_taxonomy_id, term_id, taxonomy, description, parent, count) VALUES (51, 51, 'nav_menu', '', 0, 1)"); + $theme_mods_base = serialize([ + 'forkpress_featured_page' => 50, + 'nav_menu_locations' => [ + 'primary' => 51, + ], + 'custom_logo' => 52, + 'forkpress_accent' => 'base', + ]); + $stmt = $db->prepare("INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('theme_mods_forkpress_active', :value, 'yes')"); + $stmt->bindValue(':value', $theme_mods_base, SQLITE3_TEXT); + $stmt->execute(); + $db->exec("INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('site_icon', '52', 'yes')"); + $db->exec("INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('page_on_front', '50', 'yes')"); + $db->exec("INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('page_for_posts', '54', 'yes')"); + $sticky_posts_base = serialize([53]); + $stmt = $db->prepare("INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('sticky_posts', :value, 'yes')"); + $stmt->bindValue(':value', $sticky_posts_base, SQLITE3_TEXT); + $stmt->execute(); + $widget_nav_menu_base = serialize([ + 2 => [ + 'title' => 'Footer menu', + 'nav_menu' => 51, + ], + '_multiwidget' => 1, + ]); + $stmt = $db->prepare("INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('widget_nav_menu', :value, 'yes')"); + $stmt->bindValue(':value', $widget_nav_menu_base, SQLITE3_TEXT); + $stmt->execute(); + $widget_media_image_base = serialize([ + 3 => [ + 'attachment_id' => 52, + 'url' => 'http://example.test/wp-content/uploads/2026/05/option-logo.jpg', + 'caption' => 'Base media image widget', + ], + '_multiwidget' => 1, + ]); + $stmt = $db->prepare("INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('widget_media_image', :value, 'yes')"); + $stmt->bindValue(':value', $widget_media_image_base, SQLITE3_TEXT); + $stmt->execute(); + $widget_text_base = serialize([ + 4 => [ + 'title' => 'Base text widget', + 'text' => 'Base sidebar text', + ], + '_multiwidget' => 1, + ]); + $stmt = $db->prepare("INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('widget_text', :value, 'yes')"); + $stmt->bindValue(':value', $widget_text_base, SQLITE3_TEXT); + $stmt->execute(); + $sidebars_widgets_base = serialize([ + 'sidebar-1' => ['nav_menu-2', 'media_image-3', 'text-4'], + 'array_version' => 3, + ]); + $stmt = $db->prepare("INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('sidebars_widgets', :value, 'yes')"); + $stmt->bindValue(':value', $sidebars_widgets_base, SQLITE3_TEXT); + $stmt->execute(); + $db->close(); + write_test_file($wp_option_ref_base_root . '/wp-content/mu-plugins/forkpress-merge-validator.php', <<<'PHP' +query("SELECT option_name, option_value FROM wp_options WHERE option_name LIKE 'theme_mods_%' OR option_name IN ('widget_nav_menu', 'widget_media_image', 'sidebars_widgets', 'site_icon', 'page_on_front', 'page_for_posts', 'sticky_posts')"); +$findings = []; +while ($row = $res->fetchArray(SQLITE3_ASSOC)) { + if ((string)$row['option_name'] === 'page_on_front' || (string)$row['option_name'] === 'page_for_posts') { + $page_id = (int)$row['option_value']; + if ($page_id > 0) { + $exists = (int)$db->querySingle("SELECT COUNT(*) FROM wp_posts WHERE ID = $page_id AND post_type = 'page'"); + if ($exists === 0) { + $findings[] = [ + 'plugin' => 'forkpress-wp-option-refs', + 'object' => 'option:' . $row['option_name'], + 'reason' => 'front page option references a missing page', + 'type' => 'plugin-wp-option-missing-object', + 'tables' => ['wp_options', 'wp_posts'], + 'validator' => 'forkpress-wp-option-refs@1', + 'candidate' => [ + 'option_name' => (string)$row['option_name'], + 'field' => (string)$row['option_name'], + 'missing_object_id' => $page_id, + 'object_type' => 'page', + ], + ]; + } + } + continue; + } + if ((string)$row['option_name'] === 'site_icon') { + $attachment_id = (int)$row['option_value']; + if ($attachment_id > 0) { + $exists = (int)$db->querySingle("SELECT COUNT(*) FROM wp_posts WHERE ID = $attachment_id AND post_type = 'attachment'"); + if ($exists === 0) { + $findings[] = [ + 'plugin' => 'forkpress-wp-option-refs', + 'object' => 'option:' . $row['option_name'], + 'reason' => 'site icon option references a missing attachment', + 'type' => 'plugin-wp-option-missing-object', + 'tables' => ['wp_options', 'wp_posts'], + 'validator' => 'forkpress-wp-option-refs@1', + 'candidate' => [ + 'option_name' => (string)$row['option_name'], + 'field' => 'site_icon', + 'missing_object_id' => $attachment_id, + 'object_type' => 'attachment', + ], + ]; + } + } + continue; + } + if ((string)$row['option_name'] === 'sticky_posts') { + $sticky_posts = @unserialize((string)$row['option_value']); + if (is_array($sticky_posts)) { + foreach ($sticky_posts as $index => $post_id) { + $post_id = (int)$post_id; + if ($post_id <= 0) { + continue; + } + $exists = (int)$db->querySingle("SELECT COUNT(*) FROM wp_posts WHERE ID = $post_id AND post_type = 'post'"); + if ($exists !== 0) { + continue; + } + $findings[] = [ + 'plugin' => 'forkpress-wp-option-refs', + 'object' => 'option:' . $row['option_name'], + 'reason' => 'sticky posts option references a missing post', + 'type' => 'plugin-wp-option-missing-object', + 'tables' => ['wp_options', 'wp_posts'], + 'validator' => 'forkpress-wp-option-refs@1', + 'candidate' => [ + 'option_name' => (string)$row['option_name'], + 'field' => 'sticky_posts.' . (string)$index, + 'missing_object_id' => $post_id, + 'object_type' => 'post', + ], + ]; + } + } + continue; + } + $mods = @unserialize((string)$row['option_value']); + if (!is_array($mods)) { + continue; + } + if ((string)$row['option_name'] === 'sidebars_widgets') { + foreach ($mods as $sidebar_id => $widget_ids) { + if (!is_array($widget_ids)) { + continue; + } + foreach ($widget_ids as $index => $widget_id) { + if (!is_string($widget_id) || !preg_match('/^(.+)-([0-9]+)$/', $widget_id, $matches)) { + continue; + } + $option_name = 'widget_' . $matches[1]; + $widget_number = (int)$matches[2]; + $option_name_sql = SQLite3::escapeString($option_name); + $option_value = $db->querySingle("SELECT option_value FROM wp_options WHERE option_name = '$option_name_sql'"); + $instances = is_string($option_value) ? @unserialize($option_value) : null; + if (is_array($instances) && isset($instances[$widget_number])) { + continue; + } + $findings[] = [ + 'plugin' => 'forkpress-wp-option-refs', + 'object' => 'option:' . $row['option_name'], + 'reason' => 'sidebar references a missing widget instance', + 'type' => 'plugin-wp-option-missing-object', + 'tables' => ['wp_options'], + 'validator' => 'forkpress-wp-option-refs@1', + 'candidate' => [ + 'option_name' => (string)$row['option_name'], + 'field' => (string)$sidebar_id . '.' . (string)$index, + 'missing_object_id' => $widget_id, + 'object_type' => 'widget', + 'widget_option_name' => $option_name, + ], + ]; + } + } + continue; + } + if (isset($mods['forkpress_featured_page'])) { + $page_id = (int)$mods['forkpress_featured_page']; + $exists = (int)$db->querySingle("SELECT COUNT(*) FROM wp_posts WHERE ID = $page_id AND post_type = 'page'"); + if ($exists === 0) { + $findings[] = [ + 'plugin' => 'forkpress-wp-option-refs', + 'object' => 'option:' . $row['option_name'], + 'reason' => 'theme option references a missing page', + 'type' => 'plugin-wp-option-missing-object', + 'tables' => ['wp_options', 'wp_posts'], + 'validator' => 'forkpress-wp-option-refs@1', + 'candidate' => [ + 'option_name' => (string)$row['option_name'], + 'field' => 'forkpress_featured_page', + 'missing_object_id' => $page_id, + 'object_type' => 'page', + ], + ]; + } + } + if (isset($mods['custom_logo'])) { + $attachment_id = (int)$mods['custom_logo']; + $exists = (int)$db->querySingle("SELECT COUNT(*) FROM wp_posts WHERE ID = $attachment_id AND post_type = 'attachment'"); + if ($attachment_id > 0 && $exists === 0) { + $findings[] = [ + 'plugin' => 'forkpress-wp-option-refs', + 'object' => 'option:' . $row['option_name'], + 'reason' => 'theme option references a missing custom logo attachment', + 'type' => 'plugin-wp-option-missing-object', + 'tables' => ['wp_options', 'wp_posts'], + 'validator' => 'forkpress-wp-option-refs@1', + 'candidate' => [ + 'option_name' => (string)$row['option_name'], + 'field' => 'custom_logo', + 'missing_object_id' => $attachment_id, + 'object_type' => 'attachment', + ], + ]; + } + } + if ((string)$row['option_name'] === 'widget_nav_menu') { + foreach ($mods as $widget_id => $widget) { + if (!is_array($widget) || !isset($widget['nav_menu'])) { + continue; + } + $term_id = (int)$widget['nav_menu']; + if ($term_id <= 0) { + continue; + } + $exists = (int)$db->querySingle("SELECT COUNT(*) FROM wp_terms t JOIN wp_term_taxonomy tt ON tt.term_id = t.term_id AND tt.taxonomy = 'nav_menu' WHERE t.term_id = $term_id"); + if ($exists !== 0) { + continue; + } + $findings[] = [ + 'plugin' => 'forkpress-wp-option-refs', + 'object' => 'option:' . $row['option_name'], + 'reason' => 'nav menu widget references a missing nav menu', + 'type' => 'plugin-wp-option-missing-object', + 'tables' => ['wp_options', 'wp_terms', 'wp_term_taxonomy'], + 'validator' => 'forkpress-wp-option-refs@1', + 'candidate' => [ + 'option_name' => (string)$row['option_name'], + 'field' => 'widget.' . (string)$widget_id . '.nav_menu', + 'missing_object_id' => $term_id, + 'object_type' => 'nav_menu', + ], + ]; + } + } + if ((string)$row['option_name'] === 'widget_media_image') { + foreach ($mods as $widget_id => $widget) { + if (!is_array($widget) || !isset($widget['attachment_id'])) { + continue; + } + $attachment_id = (int)$widget['attachment_id']; + if ($attachment_id <= 0) { + continue; + } + $exists = (int)$db->querySingle("SELECT COUNT(*) FROM wp_posts WHERE ID = $attachment_id AND post_type = 'attachment'"); + if ($exists !== 0) { + continue; + } + $findings[] = [ + 'plugin' => 'forkpress-wp-option-refs', + 'object' => 'option:' . $row['option_name'], + 'reason' => 'media image widget references a missing attachment', + 'type' => 'plugin-wp-option-missing-object', + 'tables' => ['wp_options', 'wp_posts'], + 'validator' => 'forkpress-wp-option-refs@1', + 'candidate' => [ + 'option_name' => (string)$row['option_name'], + 'field' => 'widget.' . (string)$widget_id . '.attachment_id', + 'missing_object_id' => $attachment_id, + 'object_type' => 'attachment', + ], + ]; + } + } + foreach (($mods['nav_menu_locations'] ?? []) as $location => $term_id) { + $term_id = (int)$term_id; + if ($term_id <= 0) { + continue; + } + $exists = (int)$db->querySingle("SELECT COUNT(*) FROM wp_terms t JOIN wp_term_taxonomy tt ON tt.term_id = t.term_id AND tt.taxonomy = 'nav_menu' WHERE t.term_id = $term_id"); + if ($exists !== 0) { + continue; + } + $findings[] = [ + 'plugin' => 'forkpress-wp-option-refs', + 'object' => 'option:' . $row['option_name'], + 'reason' => 'theme option references a missing nav menu', + 'type' => 'plugin-wp-option-missing-object', + 'tables' => ['wp_options', 'wp_terms', 'wp_term_taxonomy'], + 'validator' => 'forkpress-wp-option-refs@1', + 'candidate' => [ + 'option_name' => (string)$row['option_name'], + 'field' => 'nav_menu_locations.' . (string)$location, + 'missing_object_id' => $term_id, + 'object_type' => 'nav_menu', + ], + ]; + } +} +echo json_encode([ + 'status' => $findings ? 'conflicts' : 'valid', + 'findings' => $findings, +], JSON_UNESCAPED_SLASHES); +PHP); + copy_tree_for_test($wp_option_ref_base_root, $wp_option_ref_source_root); + copy_tree_for_test($wp_option_ref_base_root, $wp_option_ref_target_root); + $wp_option_ref_file_base = $tmp . '/.forkpress/cow/merge/file-bases/wp-option-ref-validator.json'; + cow_merge_capture_file_base($wp_option_ref_base_root, $wp_option_ref_file_base); + cow_merge_allocate_autoincrement_bands($wp_option_ref_source, $wp_option_ref_metadata, 'feature-wp-option-ref-source'); + cow_merge_allocate_autoincrement_bands($wp_option_ref_target, $wp_option_ref_metadata, 'feature-wp-option-ref-target'); + $db = open_db($wp_option_ref_source); + $db->exec('DELETE FROM wp_posts WHERE ID = 50'); + $db->exec('DELETE FROM wp_posts WHERE ID = 52'); + $db->exec('DELETE FROM wp_posts WHERE ID = 53'); + $db->exec('DELETE FROM wp_posts WHERE ID = 54'); + $db->exec('DELETE FROM wp_term_taxonomy WHERE term_id = 51'); + $db->exec('DELETE FROM wp_terms WHERE term_id = 51'); + $db->exec("DELETE FROM wp_options WHERE option_name = 'widget_text'"); + $db->close(); + $db = open_db($wp_option_ref_target); + $theme_mods_target = serialize([ + 'forkpress_featured_page' => 50, + 'nav_menu_locations' => [ + 'primary' => 51, + ], + 'custom_logo' => 52, + 'forkpress_accent' => 'target', + ]); + $stmt = $db->prepare("UPDATE wp_options SET option_value = :value WHERE option_name = 'theme_mods_forkpress_active'"); + $stmt->bindValue(':value', $theme_mods_target, SQLITE3_TEXT); + $stmt->execute(); + $widget_nav_menu_target = serialize([ + 2 => [ + 'title' => 'Target footer menu', + 'nav_menu' => 51, + ], + '_multiwidget' => 1, + ]); + $stmt = $db->prepare("UPDATE wp_options SET option_value = :value WHERE option_name = 'widget_nav_menu'"); + $stmt->bindValue(':value', $widget_nav_menu_target, SQLITE3_TEXT); + $stmt->execute(); + $widget_media_image_target = serialize([ + 3 => [ + 'attachment_id' => 52, + 'url' => 'http://example.test/wp-content/uploads/2026/05/option-logo.jpg', + 'caption' => 'Target media image widget', + ], + '_multiwidget' => 1, + ]); + $stmt = $db->prepare("UPDATE wp_options SET option_value = :value WHERE option_name = 'widget_media_image'"); + $stmt->bindValue(':value', $widget_media_image_target, SQLITE3_TEXT); + $stmt->execute(); + $sidebars_widgets_target = serialize([ + 'sidebar-1' => ['nav_menu-2', 'media_image-3', 'text-4'], + 'wp_inactive_widgets' => [], + 'array_version' => 3, + ]); + $stmt = $db->prepare("UPDATE wp_options SET option_value = :value WHERE option_name = 'sidebars_widgets'"); + $stmt->bindValue(':value', $sidebars_widgets_target, SQLITE3_TEXT); + $stmt->execute(); + $db->close(); + $wp_option_ref_result = cow_merge_branch_state( + $wp_option_ref_base, + $wp_option_ref_source, + $wp_option_ref_target, + $wp_option_ref_metadata, + 'feature-wp-option-ref-source', + 'feature-wp-option-ref-target', + $wp_option_ref_file_base, + $wp_option_ref_source_root, + $wp_option_ref_target_root + ); + assert_same($wp_option_ref_result['status'], 'completed_with_conflicts', 'WordPress option reference validator holds missing option objects for review'); + assert_same((int)($wp_option_ref_result['plugin_validators'] ?? 0), 1, 'WordPress option reference validator is discovered from mu-plugins during merge'); + assert_same((int)($wp_option_ref_result['plugin_validator_conflicts'] ?? 0), 10, 'WordPress option reference validator records missing featured pages, posts page, sticky posts, sidebar widgets, media widgets, menu locations, menu widgets, and option attachments'); + assert_same((int)scalar($wp_option_ref_target, 'SELECT COUNT(*) FROM wp_posts WHERE ID = 50'), 0, 'WordPress option reference validator leaves the source featured page deletion staged for review'); + assert_same((int)scalar($wp_option_ref_target, 'SELECT COUNT(*) FROM wp_posts WHERE ID = 52'), 0, 'WordPress option reference validator leaves the source attachment deletion staged for review'); + assert_same((int)scalar($wp_option_ref_target, 'SELECT COUNT(*) FROM wp_posts WHERE ID = 53'), 0, 'WordPress option reference validator leaves the source sticky post deletion staged for review'); + assert_same((int)scalar($wp_option_ref_target, 'SELECT COUNT(*) FROM wp_posts WHERE ID = 54'), 0, 'WordPress option reference validator leaves the source posts page deletion staged for review'); + assert_same((int)scalar($wp_option_ref_target, 'SELECT COUNT(*) FROM wp_terms WHERE term_id = 51'), 0, 'WordPress option reference validator leaves the source nav menu deletion staged for review'); + $wp_option_ref_value = scalar($wp_option_ref_target, "SELECT option_value FROM wp_options WHERE option_name = 'theme_mods_forkpress_active'"); + $wp_option_ref_mods = is_string($wp_option_ref_value) ? unserialize($wp_option_ref_value) : null; + assert_same($wp_option_ref_mods['forkpress_accent'] ?? null, 'target', 'WordPress option reference validator preserves the target option edit'); + assert_same($wp_option_ref_mods['forkpress_featured_page'] ?? null, 50, 'WordPress option reference validator keeps the stale featured page reference visible for review'); + assert_same($wp_option_ref_mods['nav_menu_locations']['primary'] ?? null, 51, 'WordPress option reference validator keeps the stale nav menu location visible for review'); + assert_same($wp_option_ref_mods['custom_logo'] ?? null, 52, 'WordPress option reference validator keeps the stale custom logo reference visible for review'); + assert_same(scalar($wp_option_ref_target, "SELECT option_value FROM wp_options WHERE option_name = 'site_icon'"), '52', 'WordPress option reference validator keeps the stale site icon option visible for review'); + assert_same(scalar($wp_option_ref_target, "SELECT option_value FROM wp_options WHERE option_name = 'page_on_front'"), '50', 'WordPress option reference validator keeps the stale front page option visible for review'); + assert_same(scalar($wp_option_ref_target, "SELECT option_value FROM wp_options WHERE option_name = 'page_for_posts'"), '54', 'WordPress option reference validator keeps the stale posts page option visible for review'); + $wp_sticky_posts_value = scalar($wp_option_ref_target, "SELECT option_value FROM wp_options WHERE option_name = 'sticky_posts'"); + $wp_sticky_posts = is_string($wp_sticky_posts_value) ? unserialize($wp_sticky_posts_value) : null; + assert_same($wp_sticky_posts[0] ?? null, 53, 'WordPress option reference validator keeps the stale sticky post reference visible for review'); + $wp_widget_nav_menu_value = scalar($wp_option_ref_target, "SELECT option_value FROM wp_options WHERE option_name = 'widget_nav_menu'"); + $wp_widget_nav_menu = is_string($wp_widget_nav_menu_value) ? unserialize($wp_widget_nav_menu_value) : null; + assert_same($wp_widget_nav_menu[2]['title'] ?? null, 'Target footer menu', 'WordPress option reference validator preserves the target nav menu widget edit'); + assert_same($wp_widget_nav_menu[2]['nav_menu'] ?? null, 51, 'WordPress option reference validator keeps the stale nav menu widget reference visible for review'); + $wp_widget_media_image_value = scalar($wp_option_ref_target, "SELECT option_value FROM wp_options WHERE option_name = 'widget_media_image'"); + $wp_widget_media_image = is_string($wp_widget_media_image_value) ? unserialize($wp_widget_media_image_value) : null; + assert_same($wp_widget_media_image[3]['caption'] ?? null, 'Target media image widget', 'WordPress option reference validator preserves the target media image widget edit'); + assert_same($wp_widget_media_image[3]['attachment_id'] ?? null, 52, 'WordPress option reference validator keeps the stale media image widget attachment visible for review'); + assert_same(scalar($wp_option_ref_target, "SELECT option_value FROM wp_options WHERE option_name = 'widget_text'"), null, 'WordPress option reference validator leaves the source widget option deletion staged for review'); + $wp_sidebars_widgets_value = scalar($wp_option_ref_target, "SELECT option_value FROM wp_options WHERE option_name = 'sidebars_widgets'"); + $wp_sidebars_widgets = is_string($wp_sidebars_widgets_value) ? unserialize($wp_sidebars_widgets_value) : null; + assert_same($wp_sidebars_widgets['sidebar-1'][2] ?? null, 'text-4', 'WordPress option reference validator keeps the stale sidebar widget instance visible for review'); + $wp_option_ref_audit = cow_merge_audit_report($wp_option_ref_metadata, (int)$wp_option_ref_result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'conflict_type' => 'plugin-wp-option-missing-object', + ]); + assert_same(count($wp_option_ref_audit['conflicts']), 10, 'WordPress option reference validator exposes missing option objects as plugin-scoped audit conflicts'); + $wp_option_ref_preview = implode("\n", array_map(fn($conflict) => (string)($conflict['chosen_preview'] ?? ''), $wp_option_ref_audit['conflicts'])); + assert_true(str_contains($wp_option_ref_preview, '"missing_object_id":50'), 'WordPress option reference audit includes the missing page ID'); + assert_true(str_contains($wp_option_ref_preview, '"missing_object_id":51'), 'WordPress option reference audit includes the missing nav menu term ID'); + assert_true(str_contains($wp_option_ref_preview, '"missing_object_id":52'), 'WordPress option reference audit includes the missing attachment ID'); + assert_true(str_contains($wp_option_ref_preview, '"missing_object_id":53'), 'WordPress option reference audit includes the missing sticky post ID'); + assert_true(str_contains($wp_option_ref_preview, '"missing_object_id":54'), 'WordPress option reference audit includes the missing posts page ID'); + assert_true(str_contains($wp_option_ref_preview, '"object_type":"nav_menu"'), 'WordPress option reference audit includes the nav menu object type'); + assert_true(str_contains($wp_option_ref_preview, '"object_type":"attachment"'), 'WordPress option reference audit includes the attachment object type'); + assert_true(str_contains($wp_option_ref_preview, '"object_type":"page"'), 'WordPress option reference audit includes the page object type'); + assert_true(str_contains($wp_option_ref_preview, '"object_type":"post"'), 'WordPress option reference audit includes the post object type'); + assert_true(str_contains($wp_option_ref_preview, '"object_type":"widget"'), 'WordPress option reference audit includes the widget object type'); + assert_true(str_contains($wp_option_ref_preview, 'theme_mods_forkpress_active'), 'WordPress option reference audit includes the option name'); + assert_true(str_contains($wp_option_ref_preview, 'widget_nav_menu'), 'WordPress option reference audit includes the nav menu widget option name'); + assert_true(str_contains($wp_option_ref_preview, 'site_icon'), 'WordPress option reference audit includes the site icon option name'); + assert_true(str_contains($wp_option_ref_preview, 'page_on_front'), 'WordPress option reference audit includes the front page option name'); + assert_true(str_contains($wp_option_ref_preview, 'page_for_posts'), 'WordPress option reference audit includes the posts page option name'); + assert_true(str_contains($wp_option_ref_preview, 'sticky_posts'), 'WordPress option reference audit includes the sticky posts option name'); + assert_true(str_contains($wp_option_ref_preview, 'widget_media_image'), 'WordPress option reference audit includes the media image widget option name'); + assert_true(str_contains($wp_option_ref_preview, 'sidebars_widgets'), 'WordPress option reference audit includes the sidebars widgets option name'); + assert_true(str_contains($wp_option_ref_preview, 'widget_text'), 'WordPress option reference audit includes the missing widget option name'); + assert_true(str_contains($wp_option_ref_preview, '"field":"custom_logo"'), 'WordPress option reference audit includes the custom logo field'); + assert_true(str_contains($wp_option_ref_preview, '"field":"site_icon"'), 'WordPress option reference audit includes the site icon field'); + assert_true(str_contains($wp_option_ref_preview, '"field":"page_on_front"'), 'WordPress option reference audit includes the front page option field'); + assert_true(str_contains($wp_option_ref_preview, '"field":"page_for_posts"'), 'WordPress option reference audit includes the posts page option field'); + assert_true(str_contains($wp_option_ref_preview, '"field":"sticky_posts.0"'), 'WordPress option reference audit includes the sticky posts field'); + assert_true(str_contains($wp_option_ref_preview, '"field":"widget.2.nav_menu"'), 'WordPress option reference audit includes the nav menu widget field'); + assert_true(str_contains($wp_option_ref_preview, '"field":"widget.3.attachment_id"'), 'WordPress option reference audit includes the media image widget attachment field'); + assert_true(str_contains($wp_option_ref_preview, '"field":"sidebar-1.2"'), 'WordPress option reference audit includes the sidebar widget slot field'); + + $wp_featured_media_base_root = $tmp . '/wp-featured-media-validator-files-base'; + $wp_featured_media_source_root = $tmp . '/wp-featured-media-validator-files-source'; + $wp_featured_media_target_root = $tmp . '/wp-featured-media-validator-files-target'; + $wp_featured_media_base = $wp_featured_media_base_root . '/wp-content/database/.ht.sqlite'; + $wp_featured_media_source = $wp_featured_media_source_root . '/wp-content/database/.ht.sqlite'; + $wp_featured_media_target = $wp_featured_media_target_root . '/wp-content/database/.ht.sqlite'; + $wp_featured_media_metadata = $tmp . '/.forkpress/cow/merge/wp-featured-media-validator-metadata.sqlite'; + mkdir($wp_featured_media_base_root . '/wp-content/database', 0777, true); + create_base_db($wp_featured_media_base); + $db = open_db($wp_featured_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 post_name TEXT NOT NULL DEFAULT ''"); + $db->exec("ALTER TABLE wp_posts ADD COLUMN guid 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("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_type, post_name, guid) VALUES + (60, 'Featured media page', '

Featured media page

', 'publish', 'page', 'featured-media-page', ''), + (61, 'Featured attachment', '', 'inherit', 'attachment', 'featured-attachment', 'wp-content/uploads/2026/05/featured-image.jpg')"); + $db->exec("INSERT INTO wp_postmeta (meta_id, post_id, meta_key, meta_value) VALUES + (6000, 60, '_thumbnail_id', '61')"); + $db->close(); + write_test_file($wp_featured_media_base_root . '/wp-content/uploads/2026/05/featured-image.jpg', 'featured image bytes'); + write_test_file($wp_featured_media_base_root . '/wp-content/mu-plugins/forkpress-merge-validator.php', <<<'PHP' +query("SELECT meta_id, post_id, meta_value FROM wp_postmeta WHERE meta_key = '_thumbnail_id'"); +$findings = []; +while ($row = $res->fetchArray(SQLITE3_ASSOC)) { + $attachment_id = (int)$row['meta_value']; + if ($attachment_id <= 0) { + continue; + } + $exists = (int)$db->querySingle("SELECT COUNT(*) FROM wp_posts WHERE ID = $attachment_id AND post_type = 'attachment'"); + if ($exists === 0) { + $findings[] = [ + 'plugin' => 'forkpress-wp-featured-media', + 'object' => 'postmeta:' . $row['meta_id'], + 'reason' => 'featured image references a missing attachment', + 'type' => 'plugin-wp-featured-image-missing-attachment', + 'tables' => ['wp_postmeta', 'wp_posts'], + 'validator' => 'forkpress-wp-featured-media@1', + 'candidate' => [ + 'post_id' => (int)$row['post_id'], + 'meta_id' => (int)$row['meta_id'], + 'field' => '_thumbnail_id', + 'missing_object_id' => $attachment_id, + 'object_type' => 'attachment', + ], + ]; + } +} +echo json_encode([ + 'status' => $findings ? 'conflicts' : 'valid', + 'findings' => $findings, +], JSON_UNESCAPED_SLASHES); +PHP); + copy_tree_for_test($wp_featured_media_base_root, $wp_featured_media_source_root); + copy_tree_for_test($wp_featured_media_base_root, $wp_featured_media_target_root); + $wp_featured_media_file_base = $tmp . '/.forkpress/cow/merge/file-bases/wp-featured-media-validator.json'; + cow_merge_capture_file_base($wp_featured_media_base_root, $wp_featured_media_file_base); + cow_merge_allocate_autoincrement_bands($wp_featured_media_source, $wp_featured_media_metadata, 'feature-wp-featured-media-source'); + cow_merge_allocate_autoincrement_bands($wp_featured_media_target, $wp_featured_media_metadata, 'feature-wp-featured-media-target'); + $db = open_db($wp_featured_media_source); + $db->exec('DELETE FROM wp_posts WHERE ID = 61'); + $db->close(); + unlink($wp_featured_media_source_root . '/wp-content/uploads/2026/05/featured-image.jpg'); + $db = open_db($wp_featured_media_target); + $db->exec("UPDATE wp_posts SET post_title = 'Target page still using deleted featured image' WHERE ID = 60"); + $db->close(); + $wp_featured_media_result = cow_merge_branch_state( + $wp_featured_media_base, + $wp_featured_media_source, + $wp_featured_media_target, + $wp_featured_media_metadata, + 'feature-wp-featured-media-source', + 'feature-wp-featured-media-target', + $wp_featured_media_file_base, + $wp_featured_media_source_root, + $wp_featured_media_target_root + ); + assert_same($wp_featured_media_result['status'], 'completed_with_conflicts', 'WordPress featured image validator holds missing attachments for review'); + assert_same((int)($wp_featured_media_result['plugin_validators'] ?? 0), 1, 'WordPress featured image validator is discovered from mu-plugins during merge'); + assert_same((int)($wp_featured_media_result['plugin_validator_conflicts'] ?? 0), 1, 'WordPress featured image validator records the missing attachment'); + assert_same((int)scalar($wp_featured_media_target, 'SELECT COUNT(*) FROM wp_posts WHERE ID = 61'), 0, 'WordPress featured image validator leaves the source attachment deletion staged for review'); + assert_true(!file_exists($wp_featured_media_target_root . '/wp-content/uploads/2026/05/featured-image.jpg'), 'WordPress featured image validator leaves the source upload deletion staged for review'); + assert_same(scalar($wp_featured_media_target, 'SELECT post_title FROM wp_posts WHERE ID = 60'), 'Target page still using deleted featured image', 'WordPress featured image validator preserves the target page edit'); + assert_same(scalar($wp_featured_media_target, "SELECT meta_value FROM wp_postmeta WHERE post_id = 60 AND meta_key = '_thumbnail_id'"), '61', 'WordPress featured image validator keeps the stale thumbnail reference visible for review'); + $wp_featured_media_audit = cow_merge_audit_report($wp_featured_media_metadata, (int)$wp_featured_media_result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'conflict_type' => 'plugin-wp-featured-image-missing-attachment', + ]); + assert_same(count($wp_featured_media_audit['conflicts']), 1, 'WordPress featured image validator exposes the missing attachment as a plugin-scoped audit conflict'); + $wp_featured_media_preview = (string)($wp_featured_media_audit['conflicts'][0]['chosen_preview'] ?? ''); + assert_true(str_contains($wp_featured_media_preview, '"missing_object_id":61'), 'WordPress featured image audit includes the missing attachment ID'); + assert_true(str_contains($wp_featured_media_preview, '"field":"_thumbnail_id"'), 'WordPress featured image audit includes the thumbnail field'); + + $wp_image_block_ref_base_root = $tmp . '/wp-image-block-ref-validator-files-base'; + $wp_image_block_ref_source_root = $tmp . '/wp-image-block-ref-validator-files-source'; + $wp_image_block_ref_target_root = $tmp . '/wp-image-block-ref-validator-files-target'; + $wp_image_block_ref_base = $wp_image_block_ref_base_root . '/wp-content/database/.ht.sqlite'; + $wp_image_block_ref_source = $wp_image_block_ref_source_root . '/wp-content/database/.ht.sqlite'; + $wp_image_block_ref_target = $wp_image_block_ref_target_root . '/wp-content/database/.ht.sqlite'; + $wp_image_block_ref_metadata = $tmp . '/.forkpress/cow/merge/wp-image-block-ref-validator-metadata.sqlite'; + mkdir($wp_image_block_ref_base_root . '/wp-content/database', 0777, true); + create_base_db($wp_image_block_ref_base); + $db = open_db($wp_image_block_ref_base); + $db->exec("ALTER TABLE wp_posts ADD COLUMN post_type TEXT NOT NULL DEFAULT 'post'"); + $db->exec("ALTER TABLE wp_posts ADD COLUMN post_name TEXT NOT NULL DEFAULT ''"); + $db->exec("ALTER TABLE wp_posts ADD COLUMN guid TEXT NOT NULL DEFAULT ''"); + $image_block_content = '
'; + $stmt = $db->prepare("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_type, post_name, guid) VALUES + (70, 'Image block page', :content, 'publish', 'page', 'image-block-page', ''), + (71, 'Image block attachment', '', 'inherit', 'attachment', 'block-image', 'wp-content/uploads/2026/05/block-image.jpg')"); + $stmt->bindValue(':content', $image_block_content, SQLITE3_TEXT); + $stmt->execute(); + $db->close(); + write_test_file($wp_image_block_ref_base_root . '/wp-content/uploads/2026/05/block-image.jpg', 'image block bytes'); + write_test_file($wp_image_block_ref_base_root . '/wp-content/mu-plugins/forkpress-merge-validator.php', <<<'PHP' +query("SELECT ID, post_content FROM wp_posts WHERE post_type IN ('post', 'page')"); +$findings = []; +while ($row = $res->fetchArray(SQLITE3_ASSOC)) { + if (!preg_match_all('//', (string)$row['post_content'], $matches)) { + continue; + } + foreach ($matches[1] as $raw_attrs) { + $attrs = json_decode($raw_attrs, true); + if (!is_array($attrs) || empty($attrs['id'])) { + continue; + } + $attachment_id = (int)$attrs['id']; + $exists = (int)$db->querySingle("SELECT COUNT(*) FROM wp_posts WHERE ID = $attachment_id AND post_type = 'attachment'"); + if ($exists === 0) { + $findings[] = [ + 'plugin' => 'forkpress-wp-image-block-refs', + 'object' => 'post:' . $row['ID'], + 'reason' => 'image block references a missing attachment', + 'type' => 'plugin-wp-image-block-missing-attachment', + 'tables' => ['wp_posts'], + 'validator' => 'forkpress-wp-image-block-refs@1', + 'candidate' => [ + 'post_id' => (int)$row['ID'], + 'block_name' => 'core/image', + 'field' => 'attrs.id', + 'missing_object_id' => $attachment_id, + 'object_type' => 'attachment', + ], + ]; + } + } +} +echo json_encode([ + 'status' => $findings ? 'conflicts' : 'valid', + 'findings' => $findings, +], JSON_UNESCAPED_SLASHES); +PHP); + copy_tree_for_test($wp_image_block_ref_base_root, $wp_image_block_ref_source_root); + copy_tree_for_test($wp_image_block_ref_base_root, $wp_image_block_ref_target_root); + $wp_image_block_ref_file_base = $tmp . '/.forkpress/cow/merge/file-bases/wp-image-block-ref-validator.json'; + cow_merge_capture_file_base($wp_image_block_ref_base_root, $wp_image_block_ref_file_base); + cow_merge_allocate_autoincrement_bands($wp_image_block_ref_source, $wp_image_block_ref_metadata, 'feature-wp-image-block-ref-source'); + cow_merge_allocate_autoincrement_bands($wp_image_block_ref_target, $wp_image_block_ref_metadata, 'feature-wp-image-block-ref-target'); + $db = open_db($wp_image_block_ref_source); + $db->exec('DELETE FROM wp_posts WHERE ID = 71'); + $db->close(); + unlink($wp_image_block_ref_source_root . '/wp-content/uploads/2026/05/block-image.jpg'); + $db = open_db($wp_image_block_ref_target); + $db->exec("UPDATE wp_posts SET post_title = 'Target page still using deleted image block attachment' WHERE ID = 70"); + $db->close(); + $wp_image_block_ref_result = cow_merge_branch_state( + $wp_image_block_ref_base, + $wp_image_block_ref_source, + $wp_image_block_ref_target, + $wp_image_block_ref_metadata, + 'feature-wp-image-block-ref-source', + 'feature-wp-image-block-ref-target', + $wp_image_block_ref_file_base, + $wp_image_block_ref_source_root, + $wp_image_block_ref_target_root + ); + assert_same($wp_image_block_ref_result['status'], 'completed_with_conflicts', 'WordPress image block validator holds missing attachments for review'); + assert_same((int)($wp_image_block_ref_result['plugin_validators'] ?? 0), 1, 'WordPress image block validator is discovered from mu-plugins during merge'); + assert_same((int)($wp_image_block_ref_result['plugin_validator_conflicts'] ?? 0), 1, 'WordPress image block validator records the missing attachment'); + assert_same((int)scalar($wp_image_block_ref_target, 'SELECT COUNT(*) FROM wp_posts WHERE ID = 71'), 0, 'WordPress image block validator leaves the source attachment deletion staged for review'); + assert_true(!file_exists($wp_image_block_ref_target_root . '/wp-content/uploads/2026/05/block-image.jpg'), 'WordPress image block validator leaves the source upload deletion staged for review'); + assert_same(scalar($wp_image_block_ref_target, 'SELECT post_title FROM wp_posts WHERE ID = 70'), 'Target page still using deleted image block attachment', 'WordPress image block validator preserves the target page edit'); + assert_true(str_contains((string)scalar($wp_image_block_ref_target, 'SELECT post_content FROM wp_posts WHERE ID = 70'), '"id":71'), 'WordPress image block validator keeps the stale block attachment reference visible for review'); + $wp_image_block_ref_audit = cow_merge_audit_report($wp_image_block_ref_metadata, (int)$wp_image_block_ref_result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'conflict_type' => 'plugin-wp-image-block-missing-attachment', + ]); + assert_same(count($wp_image_block_ref_audit['conflicts']), 1, 'WordPress image block validator exposes the missing attachment as a plugin-scoped audit conflict'); + $wp_image_block_ref_preview = (string)($wp_image_block_ref_audit['conflicts'][0]['chosen_preview'] ?? ''); + assert_true(str_contains($wp_image_block_ref_preview, '"missing_object_id":71'), 'WordPress image block audit includes the missing attachment ID'); + assert_true(str_contains($wp_image_block_ref_preview, '"block_name":"core/image"') || str_contains($wp_image_block_ref_preview, '"block_name":"core\/image"'), 'WordPress image block audit includes the block name'); + + $wp_term_ref_base_root = $tmp . '/wp-term-ref-validator-files-base'; + $wp_term_ref_source_root = $tmp . '/wp-term-ref-validator-files-source'; + $wp_term_ref_target_root = $tmp . '/wp-term-ref-validator-files-target'; + $wp_term_ref_base = $wp_term_ref_base_root . '/wp-content/database/.ht.sqlite'; + $wp_term_ref_source = $wp_term_ref_source_root . '/wp-content/database/.ht.sqlite'; + $wp_term_ref_target = $wp_term_ref_target_root . '/wp-content/database/.ht.sqlite'; + $wp_term_ref_metadata = $tmp . '/.forkpress/cow/merge/wp-term-ref-validator-metadata.sqlite'; + mkdir($wp_term_ref_base_root . '/wp-content/database', 0777, true); + create_base_db($wp_term_ref_base); + $db = open_db($wp_term_ref_base); + $db->exec("ALTER TABLE wp_posts ADD COLUMN post_type TEXT NOT NULL DEFAULT 'post'"); + $db->exec("ALTER TABLE wp_posts ADD COLUMN post_name TEXT NOT NULL DEFAULT ''"); + $db->exec('CREATE TABLE wp_terms (term_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, slug TEXT NOT NULL, term_group INTEGER NOT NULL DEFAULT 0)'); + $db->exec('CREATE TABLE wp_term_taxonomy (term_taxonomy_id INTEGER PRIMARY KEY AUTOINCREMENT, term_id INTEGER NOT NULL, taxonomy TEXT NOT NULL, description TEXT NOT NULL DEFAULT "", parent INTEGER NOT NULL DEFAULT 0, count INTEGER NOT NULL DEFAULT 0)'); + $db->exec('CREATE TABLE wp_term_relationships (object_id INTEGER NOT NULL, term_taxonomy_id INTEGER NOT NULL, term_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (object_id, term_taxonomy_id))'); + $db->exec("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_type, post_name) VALUES + (80, 'Term relationship page', '

Term relationship page

', 'publish', 'page', 'term-relationship-page')"); + $db->exec("INSERT INTO wp_terms (term_id, name, slug) VALUES (81, 'Retired category', 'retired-category')"); + $db->exec("INSERT INTO wp_term_taxonomy (term_taxonomy_id, term_id, taxonomy, description, count) VALUES (82, 81, 'category', '', 0)"); + $db->close(); + write_test_file($wp_term_ref_base_root . '/wp-content/mu-plugins/forkpress-merge-validator.php', <<<'PHP' +query("SELECT object_id, term_taxonomy_id FROM wp_term_relationships"); +$findings = []; +while ($row = $res->fetchArray(SQLITE3_ASSOC)) { + $object_id = (int)$row['object_id']; + $term_taxonomy_id = (int)$row['term_taxonomy_id']; + $taxonomy = $db->querySingle("SELECT taxonomy FROM wp_term_taxonomy WHERE term_taxonomy_id = $term_taxonomy_id"); + $term_id = $db->querySingle("SELECT term_id FROM wp_term_taxonomy WHERE term_taxonomy_id = $term_taxonomy_id"); + $term_exists = $term_id === null ? 0 : (int)$db->querySingle("SELECT COUNT(*) FROM wp_terms WHERE term_id = " . (int)$term_id); + if ($taxonomy === null || $term_exists === 0) { + $findings[] = [ + 'plugin' => 'forkpress-wp-term-refs', + 'object' => 'term_relationship:' . $object_id . ':' . $term_taxonomy_id, + 'reason' => 'term relationship references a missing taxonomy term', + 'type' => 'plugin-wp-term-relationship-missing-term', + 'tables' => ['wp_term_relationships', 'wp_term_taxonomy', 'wp_terms'], + 'validator' => 'forkpress-wp-term-refs@1', + 'candidate' => [ + 'object_id' => $object_id, + 'term_taxonomy_id' => $term_taxonomy_id, + 'taxonomy' => $taxonomy, + 'missing_object_id' => $term_taxonomy_id, + 'object_type' => 'term_taxonomy', + ], + ]; + } +} +echo json_encode([ + 'status' => $findings ? 'conflicts' : 'valid', + 'findings' => $findings, +], JSON_UNESCAPED_SLASHES); +PHP); + copy_tree_for_test($wp_term_ref_base_root, $wp_term_ref_source_root); + copy_tree_for_test($wp_term_ref_base_root, $wp_term_ref_target_root); + $wp_term_ref_file_base = $tmp . '/.forkpress/cow/merge/file-bases/wp-term-ref-validator.json'; + cow_merge_capture_file_base($wp_term_ref_base_root, $wp_term_ref_file_base); + cow_merge_allocate_autoincrement_bands($wp_term_ref_source, $wp_term_ref_metadata, 'feature-wp-term-ref-source'); + cow_merge_allocate_autoincrement_bands($wp_term_ref_target, $wp_term_ref_metadata, 'feature-wp-term-ref-target'); + $db = open_db($wp_term_ref_source); + $db->exec('DELETE FROM wp_term_taxonomy WHERE term_taxonomy_id = 82'); + $db->exec('DELETE FROM wp_terms WHERE term_id = 81'); + $db->close(); + $db = open_db($wp_term_ref_target); + $db->exec("UPDATE wp_posts SET post_title = 'Target page assigned to deleted term' WHERE ID = 80"); + $db->exec('INSERT INTO wp_term_relationships (object_id, term_taxonomy_id) VALUES (80, 82)'); + $db->close(); + $wp_term_ref_result = cow_merge_branch_state( + $wp_term_ref_base, + $wp_term_ref_source, + $wp_term_ref_target, + $wp_term_ref_metadata, + 'feature-wp-term-ref-source', + 'feature-wp-term-ref-target', + $wp_term_ref_file_base, + $wp_term_ref_source_root, + $wp_term_ref_target_root + ); + assert_same($wp_term_ref_result['status'], 'completed_with_conflicts', 'WordPress term relationship validator holds missing taxonomy terms for review'); + assert_same((int)($wp_term_ref_result['plugin_validators'] ?? 0), 1, 'WordPress term relationship validator is discovered from mu-plugins during merge'); + assert_same((int)($wp_term_ref_result['plugin_validator_conflicts'] ?? 0), 1, 'WordPress term relationship validator records the missing taxonomy term'); + assert_same((int)scalar($wp_term_ref_target, 'SELECT COUNT(*) FROM wp_term_taxonomy WHERE term_taxonomy_id = 82'), 0, 'WordPress term relationship validator leaves the source taxonomy deletion staged for review'); + assert_same((int)scalar($wp_term_ref_target, 'SELECT COUNT(*) FROM wp_terms WHERE term_id = 81'), 0, 'WordPress term relationship validator leaves the source term deletion staged for review'); + assert_same((int)scalar($wp_term_ref_target, 'SELECT COUNT(*) FROM wp_term_relationships WHERE object_id = 80 AND term_taxonomy_id = 82'), 1, 'WordPress term relationship validator preserves the target relationship assignment for review'); + assert_same(scalar($wp_term_ref_target, 'SELECT post_title FROM wp_posts WHERE ID = 80'), 'Target page assigned to deleted term', 'WordPress term relationship validator preserves the target page edit'); + $wp_term_ref_audit = cow_merge_audit_report($wp_term_ref_metadata, (int)$wp_term_ref_result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'conflict_type' => 'plugin-wp-term-relationship-missing-term', + ]); + assert_same(count($wp_term_ref_audit['conflicts']), 1, 'WordPress term relationship validator exposes the missing taxonomy term as a plugin-scoped audit conflict'); + $wp_term_ref_preview = (string)($wp_term_ref_audit['conflicts'][0]['chosen_preview'] ?? ''); + assert_true(str_contains($wp_term_ref_preview, '"term_taxonomy_id":82'), 'WordPress term relationship audit includes the missing term taxonomy ID'); + assert_true(str_contains($wp_term_ref_preview, '"object_id":80'), 'WordPress term relationship audit includes the assigned object ID'); + + $wp_lifecycle_base = $tmp . '/wp-lifecycle-base.sqlite'; + $wp_lifecycle_source = $tmp . '/wp-lifecycle-source.sqlite'; + $wp_lifecycle_target = $tmp . '/wp-lifecycle-target.sqlite'; + $wp_lifecycle_metadata = $tmp . '/.forkpress/cow/merge/wp-lifecycle-metadata.sqlite'; + create_base_db($wp_lifecycle_base); + $db = open_db($wp_lifecycle_base); + $db->exec("ALTER TABLE wp_posts ADD COLUMN post_type TEXT NOT NULL DEFAULT 'post'"); + $db->exec("ALTER TABLE wp_posts ADD COLUMN post_name TEXT NOT NULL DEFAULT ''"); + $db->exec('ALTER TABLE wp_posts ADD COLUMN post_parent INTEGER NOT NULL DEFAULT 0'); + $db->exec("ALTER TABLE wp_posts ADD COLUMN guid 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("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_type, post_name) VALUES + (10, 'Base source-edited page', 'base source-edit content', 'publish', 'page', 'source-edited-page'), + (11, 'Base source-deleted page', 'base source-delete content', 'publish', 'page', 'source-deleted-page'), + (12, 'Base target-deleted page', 'base target-delete content', 'publish', 'page', 'target-deleted-page'), + (13, 'Base target-edited page', 'base target-edit content', 'publish', 'page', 'target-edited-page')"); + $db->exec("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES + (10, '_forkpress_lifecycle', 'base source edit meta'), + (11, '_forkpress_lifecycle', 'base source delete meta'), + (12, '_forkpress_lifecycle', 'base target delete meta'), + (13, '_forkpress_lifecycle', 'base target edit meta')"); + $db->close(); + copy($wp_lifecycle_base, $wp_lifecycle_source); + copy($wp_lifecycle_base, $wp_lifecycle_target); + cow_merge_allocate_autoincrement_bands($wp_lifecycle_source, $wp_lifecycle_metadata, 'feature-wp-lifecycle-source'); + cow_merge_allocate_autoincrement_bands($wp_lifecycle_target, $wp_lifecycle_metadata, 'feature-wp-lifecycle-target'); + $db = open_db($wp_lifecycle_source); + $db->exec("UPDATE wp_posts SET post_title = 'Source edited page', post_content = '

Source edited content

' WHERE ID = 10"); + $db->exec("UPDATE wp_postmeta SET meta_value = 'source edited meta' WHERE post_id = 10 AND meta_key = '_forkpress_lifecycle'"); + $db->exec('DELETE FROM wp_postmeta WHERE post_id = 11'); + $db->exec('DELETE FROM wp_posts WHERE ID = 11'); + $db->close(); + $db = open_db($wp_lifecycle_target); + $db->exec("UPDATE wp_posts SET post_title = 'Target edited page', post_content = '

Target edited content

' WHERE ID = 13"); + $db->exec("UPDATE wp_postmeta SET meta_value = 'target edited meta' WHERE post_id = 13 AND meta_key = '_forkpress_lifecycle'"); + $db->exec('DELETE FROM wp_postmeta WHERE post_id = 12'); + $db->exec('DELETE FROM wp_posts WHERE ID = 12'); + $db->close(); + $wp_lifecycle_result = cow_merge_databases( + $wp_lifecycle_base, + $wp_lifecycle_source, + $wp_lifecycle_target, + $wp_lifecycle_metadata, + 'feature-wp-lifecycle-source', + 'feature-wp-lifecycle-target' + ); + assert_same($wp_lifecycle_result['status'], 'completed', 'WordPress page edit/delete lifecycle branches merge cleanly'); + assert_same(scalar($wp_lifecycle_target, 'SELECT post_title FROM wp_posts WHERE ID = 10'), 'Source edited page', 'WordPress source page edit is applied'); + assert_same(scalar($wp_lifecycle_target, "SELECT meta_value FROM wp_postmeta WHERE post_id = 10 AND meta_key = '_forkpress_lifecycle'"), 'source edited meta', 'WordPress source page meta edit is applied'); + assert_same((int)scalar($wp_lifecycle_target, 'SELECT COUNT(*) FROM wp_posts WHERE ID = 11'), 0, 'WordPress source page delete is applied'); + assert_same((int)scalar($wp_lifecycle_target, 'SELECT COUNT(*) FROM wp_postmeta WHERE post_id = 11'), 0, 'WordPress source page delete removes related source-side meta'); + assert_same((int)scalar($wp_lifecycle_target, 'SELECT COUNT(*) FROM wp_posts WHERE ID = 12'), 0, 'WordPress target page delete is preserved'); + assert_same(scalar($wp_lifecycle_target, 'SELECT post_title FROM wp_posts WHERE ID = 13'), 'Target edited page', 'WordPress target page edit is preserved'); + assert_same(scalar($wp_lifecycle_target, "SELECT meta_value FROM wp_postmeta WHERE post_id = 13 AND meta_key = '_forkpress_lifecycle'"), 'target edited meta', 'WordPress target page meta edit is preserved'); + assert_same((int)scalar($wp_lifecycle_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-wp-lifecycle-source'"), 0, 'WordPress lifecycle merge records no conflicts for independent edits and deletes'); + + $wp_edit_delete_base = $tmp . '/wp-edit-delete-base.sqlite'; + $wp_edit_delete_source = $tmp . '/wp-edit-delete-source.sqlite'; + $wp_edit_delete_target = $tmp . '/wp-edit-delete-target.sqlite'; + $wp_edit_delete_metadata = $tmp . '/.forkpress/cow/merge/wp-edit-delete-metadata.sqlite'; + create_base_db($wp_edit_delete_base); + $db = open_db($wp_edit_delete_base); + $db->exec("ALTER TABLE wp_posts ADD COLUMN post_type TEXT NOT NULL DEFAULT 'post'"); + $db->exec("ALTER TABLE wp_posts ADD COLUMN post_name 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("INSERT INTO wp_posts (ID, post_title, post_content, post_status, post_type, post_name) VALUES + (20, 'Base source-edit target-delete page', 'base source-edit target-delete content', 'publish', 'page', 'source-edit-target-delete-page'), + (21, 'Base source-delete target-edit page', 'base source-delete target-edit content', 'publish', 'page', 'source-delete-target-edit-page')"); + $db->exec("INSERT INTO wp_postmeta (meta_id, post_id, meta_key, meta_value) VALUES + (20, 20, '_forkpress_edit_delete', 'base source-edit target-delete meta'), + (21, 21, '_forkpress_edit_delete', 'base source-delete target-edit meta')"); + $db->close(); + copy($wp_edit_delete_base, $wp_edit_delete_source); + copy($wp_edit_delete_base, $wp_edit_delete_target); + cow_merge_allocate_autoincrement_bands($wp_edit_delete_source, $wp_edit_delete_metadata, 'feature-wp-edit-delete-source'); + cow_merge_allocate_autoincrement_bands($wp_edit_delete_target, $wp_edit_delete_metadata, 'feature-wp-edit-delete-target'); + $db = open_db($wp_edit_delete_source); + $db->exec("UPDATE wp_posts SET post_title = 'Source edited target-deleted page', post_content = '

Source edit versus target delete

' WHERE ID = 20"); + $db->exec("UPDATE wp_postmeta SET meta_value = 'source edited target-deleted meta' WHERE post_id = 20 AND meta_key = '_forkpress_edit_delete'"); + $db->exec('DELETE FROM wp_postmeta WHERE post_id = 21'); + $db->exec('DELETE FROM wp_posts WHERE ID = 21'); + $db->close(); + $db = open_db($wp_edit_delete_target); + $db->exec('DELETE FROM wp_postmeta WHERE post_id = 20'); + $db->exec('DELETE FROM wp_posts WHERE ID = 20'); + $db->exec("UPDATE wp_posts SET post_title = 'Target edited source-deleted page', post_content = '

Target edit versus source delete

' WHERE ID = 21"); + $db->exec("UPDATE wp_postmeta SET meta_value = 'target edited source-deleted meta' WHERE post_id = 21 AND meta_key = '_forkpress_edit_delete'"); + $db->close(); + $wp_edit_delete_result = cow_merge_databases( + $wp_edit_delete_base, + $wp_edit_delete_source, + $wp_edit_delete_target, + $wp_edit_delete_metadata, + 'feature-wp-edit-delete-source', + 'feature-wp-edit-delete-target' + ); + assert_same($wp_edit_delete_result['status'], 'completed_with_conflicts', 'WordPress page edit/delete conflicts remain reviewable'); + assert_same((int)scalar($wp_edit_delete_target, 'SELECT COUNT(*) FROM wp_posts WHERE ID = 20'), 0, 'WordPress target page deletion wins before source edit/delete review'); + assert_same((int)scalar($wp_edit_delete_target, 'SELECT COUNT(*) FROM wp_postmeta WHERE post_id = 20'), 0, 'WordPress target page metadata deletion wins before source edit/delete review'); + assert_same(scalar($wp_edit_delete_target, 'SELECT post_title FROM wp_posts WHERE ID = 21'), 'Target edited source-deleted page', 'WordPress target page edit wins before source delete review'); + assert_same(scalar($wp_edit_delete_target, "SELECT meta_value FROM wp_postmeta WHERE post_id = 21 AND meta_key = '_forkpress_edit_delete'"), 'target edited source-deleted meta', 'WordPress target metadata edit wins before source delete review'); + assert_same( + (int)scalar($wp_edit_delete_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-wp-edit-delete-source' AND c.conflict_type = 'row-target-deleted' AND c.table_name IN ('wp_posts', 'wp_postmeta')"), + 2, + 'WordPress source edits against target deletes record page and metadata conflicts' + ); + assert_same( + (int)scalar($wp_edit_delete_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-wp-edit-delete-source' AND c.conflict_type = 'row-source-deleted' AND c.table_name IN ('wp_posts', 'wp_postmeta')"), + 2, + 'WordPress source deletes against target edits record page and metadata conflicts' + ); + assert_same( + (int)scalar($wp_edit_delete_metadata, "SELECT COUNT(*) FROM merge_decisions d JOIN merge_runs r ON r.id = d.run_id WHERE r.source_branch = 'feature-wp-edit-delete-source' AND d.decision = 'target-wins' AND d.table_name IN ('wp_posts', 'wp_postmeta')"), + 4, + 'WordPress edit/delete conflict defaults are auditable as target-wins decisions' + ); + + $plugin_graph_base = $tmp . '/plugin-graph-base.sqlite'; + $plugin_graph_source = $tmp . '/plugin-graph-source.sqlite'; + $plugin_graph_target = $tmp . '/plugin-graph-target.sqlite'; + $plugin_graph_metadata = $tmp . '/.forkpress/cow/merge/plugin-graph-metadata.sqlite'; + create_base_db($plugin_graph_base); + $db = open_db($plugin_graph_base); + $db->exec('CREATE TABLE wp_postmeta (meta_id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER, meta_key TEXT, meta_value TEXT)'); + $db->exec('CREATE TABLE plugin_graph_parent (id INTEGER PRIMARY KEY AUTOINCREMENT, branch TEXT NOT NULL, graph_json TEXT NOT NULL, graph_serialized TEXT NOT NULL)'); + $db->exec('CREATE TABLE plugin_graph_child (id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER NOT NULL, branch TEXT NOT NULL, file_path TEXT NOT NULL, payload TEXT NOT NULL)'); + $db->close(); + copy($plugin_graph_base, $plugin_graph_source); + copy($plugin_graph_base, $plugin_graph_target); + $plugin_graph_base_root = $tmp . '/plugin-graph-files-base'; + $plugin_graph_source_root = $tmp . '/plugin-graph-files-source'; + $plugin_graph_target_root = $tmp . '/plugin-graph-files-target'; + mkdir($plugin_graph_base_root . '/wp-content/uploads', 0777, true); + copy_tree_for_test($plugin_graph_base_root, $plugin_graph_source_root); + copy_tree_for_test($plugin_graph_base_root, $plugin_graph_target_root); + $plugin_graph_file_base = $tmp . '/.forkpress/cow/merge/file-bases/plugin-graph-source.json'; + cow_merge_capture_file_base($plugin_graph_base_root, $plugin_graph_file_base); + cow_merge_allocate_autoincrement_bands($plugin_graph_source, $plugin_graph_metadata, 'feature-plugin-graph-source'); + cow_merge_allocate_autoincrement_bands($plugin_graph_target, $plugin_graph_metadata, 'feature-plugin-graph-target'); + $write_plugin_graph = static function (string $db_path, string $root, string $branch): array { + $db = open_db($db_path); + $suffix = ucfirst($branch); + $db->exec("INSERT INTO wp_posts (post_title, post_content, post_status) VALUES ('Plugin $suffix Page', 'plugin graph page', 'publish')"); + $page_id = (int)$db->lastInsertRowID(); + $file_path = "wp-content/uploads/plugin-graph-$branch.dat"; + write_test_file($root . '/' . $file_path, "plugin graph file for $branch\n"); + $initial_graph = [ + 'branch' => $branch, + 'page_id' => $page_id, + 'file_path' => $file_path, + ]; + $stmt = $db->prepare('INSERT INTO plugin_graph_parent (branch, graph_json, graph_serialized) VALUES (:branch, :graph_json, :graph_serialized)'); + $stmt->bindValue(':branch', $branch, SQLITE3_TEXT); + $stmt->bindValue(':graph_json', json_encode($initial_graph, JSON_UNESCAPED_SLASHES), SQLITE3_TEXT); + $stmt->bindValue(':graph_serialized', serialize($initial_graph), SQLITE3_TEXT); + $stmt->execute(); + $parent_id = (int)$db->lastInsertRowID(); + $child_payload = [ + 'branch' => $branch, + 'parent_id' => $parent_id, + 'page_id' => $page_id, + 'file_path' => $file_path, + ]; + $stmt = $db->prepare('INSERT INTO plugin_graph_child (parent_id, branch, file_path, payload) VALUES (:parent_id, :branch, :file_path, :payload)'); + $stmt->bindValue(':parent_id', $parent_id, SQLITE3_INTEGER); + $stmt->bindValue(':branch', $branch, SQLITE3_TEXT); + $stmt->bindValue(':file_path', $file_path, SQLITE3_TEXT); + $stmt->bindValue(':payload', json_encode($child_payload, JSON_UNESCAPED_SLASHES), SQLITE3_TEXT); + $stmt->execute(); + $child_id = (int)$db->lastInsertRowID(); + $graph = $initial_graph + [ + 'parent_id' => $parent_id, + 'child_id' => $child_id, + ]; + $stmt = $db->prepare('UPDATE plugin_graph_parent SET graph_json = :graph_json, graph_serialized = :graph_serialized WHERE id = :parent_id'); + $stmt->bindValue(':graph_json', json_encode($graph, JSON_UNESCAPED_SLASHES), SQLITE3_TEXT); + $stmt->bindValue(':graph_serialized', serialize($graph), SQLITE3_TEXT); + $stmt->bindValue(':parent_id', $parent_id, SQLITE3_INTEGER); + $stmt->execute(); + $stmt = $db->prepare('INSERT INTO wp_options (option_name, option_value, autoload) VALUES (:name, :value, :autoload)'); + $stmt->bindValue(':name', "plugin_graph_$branch", SQLITE3_TEXT); + $stmt->bindValue(':value', serialize($graph), SQLITE3_TEXT); + $stmt->bindValue(':autoload', 'no', SQLITE3_TEXT); + $stmt->execute(); + $stmt = $db->prepare("INSERT INTO wp_postmeta (post_id, meta_key, meta_value) VALUES (:post_id, '_plugin_graph_ref', :value)"); + $stmt->bindValue(':post_id', $page_id, SQLITE3_INTEGER); + $stmt->bindValue(':value', json_encode($graph, JSON_UNESCAPED_SLASHES), SQLITE3_TEXT); + $stmt->execute(); + $db->close(); + return $graph; + }; + $plugin_graph_source_graph = $write_plugin_graph($plugin_graph_source, $plugin_graph_source_root, 'source'); + $plugin_graph_target_graph = $write_plugin_graph($plugin_graph_target, $plugin_graph_target_root, 'target'); + assert_true($plugin_graph_source_graph['parent_id'] !== $plugin_graph_target_graph['parent_id'], 'plugin graph branches receive distinct parent IDs before references are written'); + assert_true($plugin_graph_source_graph['child_id'] !== $plugin_graph_target_graph['child_id'], 'plugin graph branches receive distinct child IDs before references are written'); + $plugin_graph_result = cow_merge_branch_state( + $plugin_graph_base, + $plugin_graph_source, + $plugin_graph_target, + $plugin_graph_metadata, + 'feature-plugin-graph-source', + 'feature-plugin-graph-target', + $plugin_graph_file_base, + $plugin_graph_source_root, + $plugin_graph_target_root + ); + assert_same($plugin_graph_result['status'], 'completed', 'banded plugin graph with custom tables, references, and files merges cleanly'); + $assert_plugin_graph = static function (string $db_path, string $root, array $graph, string $branch): void { + $parent_id = (int)$graph['parent_id']; + $child_id = (int)$graph['child_id']; + $page_id = (int)$graph['page_id']; + $db = open_db($db_path); + $parent = $db->querySingle("SELECT graph_json, graph_serialized FROM plugin_graph_parent WHERE id = $parent_id AND branch = '$branch'", true); + $child = $db->querySingle("SELECT parent_id, file_path, payload FROM plugin_graph_child WHERE id = $child_id AND branch = '$branch'", true); + $option = $db->querySingle("SELECT option_value FROM wp_options WHERE option_name = 'plugin_graph_$branch'"); + $postmeta = $db->querySingle("SELECT meta_value FROM wp_postmeta WHERE post_id = $page_id AND meta_key = '_plugin_graph_ref'"); + $db->close(); + $parent_json = is_array($parent) ? json_decode((string)$parent['graph_json'], true) : null; + $parent_serialized = is_array($parent) ? unserialize((string)$parent['graph_serialized']) : null; + $child_payload = is_array($child) ? json_decode((string)$child['payload'], true) : null; + $option_graph = is_string($option) ? unserialize($option) : null; + $postmeta_graph = is_string($postmeta) ? json_decode($postmeta, true) : null; + assert_same($parent_json, $graph, "plugin $branch parent JSON graph references the merged object IDs"); + assert_same($parent_serialized, $graph, "plugin $branch parent serialized graph references the merged object IDs"); + assert_same($option_graph, $graph, "plugin $branch option graph references the merged object IDs"); + assert_same($postmeta_graph, $graph, "plugin $branch postmeta graph references the merged object IDs"); + assert_same((int)($child['parent_id'] ?? 0), $parent_id, "plugin $branch child row points at the merged parent"); + assert_same($child_payload['parent_id'] ?? null, $parent_id, "plugin $branch child JSON payload points at the merged parent"); + assert_true(file_exists($root . '/' . $graph['file_path']), "plugin $branch referenced file exists after merge"); + }; + $assert_plugin_graph($plugin_graph_target, $plugin_graph_target_root, $plugin_graph_source_graph, 'source'); + $assert_plugin_graph($plugin_graph_target, $plugin_graph_target_root, $plugin_graph_target_graph, 'target'); + assert_same((int)scalar($plugin_graph_metadata, "SELECT COUNT(*) FROM merge_conflicts c JOIN merge_runs r ON r.id = c.run_id WHERE r.source_branch = 'feature-plugin-graph-source'"), 0, 'plugin graph merge records no generic conflicts while IDs remain banded'); + + $plugin_validator_empty = cow_merge_record_plugin_validator_conflicts($plugin_graph_metadata, (int)$plugin_graph_result['run_id'], []); + assert_same($plugin_validator_empty['status'], 'valid', 'plugin validator accepts empty finding batches'); + assert_same($plugin_validator_empty['conflicts'], 0, 'plugin validator empty finding batch records no conflicts'); + assert_throws( + fn() => cow_merge_record_plugin_validator_conflicts($plugin_graph_metadata, (int)$plugin_graph_result['run_id'], [ + ['plugin' => '', 'object' => 'graph:broken', 'reason' => 'missing plugin name'], + ]), + 'require plugin, object, and reason', + 'plugin validator rejects findings without plugin identity' + ); + assert_throws( + fn() => cow_merge_record_plugin_validator_conflicts($plugin_graph_metadata, (int)$plugin_graph_result['run_id'], [ + ['plugin' => 'forkpress-graph', 'object' => 'graph:broken', 'reason' => 'invalid type', 'type' => 'row-conflict'], + ]), + 'must start with plugin-', + 'plugin validator rejects non-plugin conflict types' + ); + assert_throws( + fn() => cow_merge_decode_plugin_validator_stdout(json_encode([ + 'status' => 'valid', + 'findings' => [ + [ + 'plugin' => 'forkpress-graph', + 'object' => 'graph:broken', + 'reason' => 'contradictory valid status', + ], + ], + ], JSON_UNESCAPED_SLASHES), 'contradictory-valid-validator'), + 'status valid with findings', + 'plugin validator rejects valid status with findings' + ); + assert_throws( + fn() => cow_merge_decode_plugin_validator_stdout(json_encode([ + 'status' => 'conflicts', + 'findings' => [], + ], JSON_UNESCAPED_SLASHES), 'empty-conflicts-validator'), + 'status conflicts without findings', + 'plugin validator rejects conflicts status without findings' + ); + $plugin_cli_empty_record = run_merge_cli([ + 'record-plugin-validator-conflicts', + '--metadata-db', $plugin_graph_metadata, + '--run', (string)$plugin_graph_result['run_id'], + '--findings-json', '[]', + '--format', 'json', + ]); + assert_same($plugin_cli_empty_record['status'], 0, 'plugin validator record CLI accepts empty finding batches'); + $plugin_cli_empty_result = json_decode($plugin_cli_empty_record['output'], true); + assert_same($plugin_cli_empty_result['status'] ?? null, 'valid', 'plugin validator record CLI reports valid empty findings'); + assert_same($plugin_cli_empty_result['conflicts'] ?? null, 0, 'plugin validator record CLI reports zero empty conflicts'); + $plugin_cli_invalid_run = run_merge_cli([ + 'record-plugin-validator-conflicts', + '--metadata-db', $plugin_graph_metadata, + '--run', '999999', + '--findings-json', '[]', + ]); + assert_true($plugin_cli_invalid_run['status'] !== 0, 'plugin validator record CLI rejects missing merge runs'); + assert_true(str_contains($plugin_cli_invalid_run['output'], 'merge run #999999 does not exist'), 'plugin validator record CLI explains missing merge runs'); + $plugin_cli_invalid_json = run_merge_cli([ + 'record-plugin-validator-conflicts', + '--metadata-db', $plugin_graph_metadata, + '--run', (string)$plugin_graph_result['run_id'], + '--findings-json', '{"plugin":"forkpress-graph"}', + ]); + assert_true($plugin_cli_invalid_json['status'] !== 0, 'plugin validator record CLI rejects JSON objects'); + assert_true(str_contains($plugin_cli_invalid_json['output'], '--findings-json must be a JSON array'), 'plugin validator record CLI explains findings JSON shape'); + $plugin_cli_missing_findings = run_merge_cli([ + 'record-plugin-validator-conflicts', + '--metadata-db', $plugin_graph_metadata, + '--run', (string)$plugin_graph_result['run_id'], + ]); + assert_true($plugin_cli_missing_findings['status'] !== 0, 'plugin validator record CLI requires a findings source'); + assert_true(str_contains($plugin_cli_missing_findings['output'], '--findings-json or --findings-file is required'), 'plugin validator record CLI explains missing findings input'); + $plugin_validator_empty_file = $tmp . '/plugin-validator-empty-findings.json'; + write_test_file($plugin_validator_empty_file, "[]\n"); + $plugin_cli_empty_file_record = run_merge_cli([ + 'record-plugin-validator-conflicts', + '--metadata-db', $plugin_graph_metadata, + '--run', (string)$plugin_graph_result['run_id'], + '--findings-file', $plugin_validator_empty_file, + '--format', 'json', + ]); + assert_same($plugin_cli_empty_file_record['status'], 0, 'plugin validator record CLI accepts findings files'); + $plugin_cli_empty_file_result = json_decode($plugin_cli_empty_file_record['output'], true); + assert_same($plugin_cli_empty_file_result['status'] ?? null, 'valid', 'plugin validator record CLI reports valid empty findings files'); + $plugin_cli_both_findings = run_merge_cli([ + 'record-plugin-validator-conflicts', + '--metadata-db', $plugin_graph_metadata, + '--run', (string)$plugin_graph_result['run_id'], + '--findings-json', '[]', + '--findings-file', $plugin_validator_empty_file, + ]); + assert_true($plugin_cli_both_findings['status'] !== 0, 'plugin validator record CLI rejects multiple findings inputs'); + assert_true(str_contains($plugin_cli_both_findings['output'], '--findings-json and --findings-file cannot be used together'), 'plugin validator record CLI explains conflicting findings inputs'); + $plugin_cli_missing_file = run_merge_cli([ + 'record-plugin-validator-conflicts', + '--metadata-db', $plugin_graph_metadata, + '--run', (string)$plugin_graph_result['run_id'], + '--findings-file', $tmp . '/missing-plugin-validator-findings.json', + ]); + assert_true($plugin_cli_missing_file['status'] !== 0, 'plugin validator record CLI rejects missing findings files'); + assert_true(str_contains($plugin_cli_missing_file['output'], '--findings-file must point to a readable file'), 'plugin validator record CLI explains missing findings files'); + $plugin_validator_result = cow_merge_record_plugin_validator_conflicts($plugin_graph_metadata, (int)$plugin_graph_result['run_id'], [ + [ + 'plugin' => 'forkpress-graph', + 'object' => 'graph:source-parent:' . $plugin_graph_source_graph['parent_id'], + 'reason' => 'source graph references a missing child row after candidate validation', + 'tables' => ['plugin_graph_parent', 'plugin_graph_child', 'wp_options', 'wp_postmeta'], + 'files' => [$plugin_graph_source_graph['file_path']], + 'validator' => 'forkpress-graph-validator@1', + 'base' => ['parent_id' => null, 'child_id' => null], + 'source' => $plugin_graph_source_graph, + 'target' => $plugin_graph_target_graph, + 'candidate' => $plugin_graph_source_graph + ['missing_child_id' => 999999], + ], + ]); + assert_same($plugin_validator_result['status'], 'completed_with_conflicts', 'plugin validator findings mark the merge run as conflicted'); + assert_same($plugin_validator_result['conflicts'], 1, 'plugin validator records one active plugin conflict'); + assert_same(scalar($plugin_graph_metadata, "SELECT status FROM merge_runs WHERE id = " . (int)$plugin_graph_result['run_id']), 'completed_with_conflicts', 'plugin validator updates the merge run status'); + + $plugin_audit = cow_merge_audit_report($plugin_graph_metadata, (int)$plugin_graph_result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + ]); + assert_same($plugin_audit['filters']['scope'], 'plugin', 'plugin audit preserves plugin scope'); + assert_same(count($plugin_audit['conflicts']), 1, 'plugin audit returns validator conflicts'); + assert_same($plugin_audit['conflicts'][0]['table_name'], '__plugins__', 'plugin validator conflicts use the plugin audit namespace'); + assert_same($plugin_audit['conflicts'][0]['conflict_type'], 'plugin-validator-conflict', 'plugin validator conflicts use a plugin conflict type'); + assert_true(str_contains($plugin_audit['conflicts'][0]['chosen_preview'], 'missing_child_id'), 'plugin audit exposes candidate validator payloads'); + assert_true(str_contains($plugin_audit['conflicts'][0]['source_preview'], 'plugin-graph-source.dat'), 'plugin audit exposes source plugin file references'); + assert_true(str_contains($plugin_audit['conflicts'][0]['target_preview'], 'plugin-graph-target.dat'), 'plugin audit exposes target plugin graph references'); + assert_same(count($plugin_audit['autoincrement_bands']), 0, 'plugin audit scope omits DB AUTOINCREMENT band summaries'); + assert_same(count($plugin_audit['row_identity_summary']), 0, 'plugin audit scope omits DB row identity summaries'); + $plugin_conflict_id = (int)$plugin_audit['conflicts'][0]['id']; + + $plugin_db_scope_audit = cow_merge_audit_report($plugin_graph_metadata, (int)$plugin_graph_result['run_id'], 10, [ + 'scope' => 'db', + 'records' => 'conflicts', + ]); + assert_same(count(array_filter($plugin_db_scope_audit['conflicts'], fn($row) => $row['table_name'] === '__plugins__')), 0, 'DB audit scope excludes plugin validator conflicts'); + + $plugin_group_audit = cow_merge_audit_report($plugin_graph_metadata, (int)$plugin_graph_result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'group_by' => 'severity', + ]); + assert_same(count($plugin_group_audit['conflict_groups']), 1, 'plugin audit can group validator conflicts'); + assert_same($plugin_group_audit['conflict_groups'][0]['group_key'], 'plugin', 'plugin validator conflicts group under plugin severity'); + assert_same((int)$plugin_group_audit['conflict_groups'][0]['plugin_count'], 1, 'plugin conflict grouping counts plugin rows separately'); + assert_same((int)$plugin_group_audit['conflict_groups'][0]['db_count'], 0, 'plugin conflict grouping does not count plugin rows as DB rows'); + ob_start(); + cow_merge_print_audit_text($plugin_group_audit); + $plugin_group_text = ob_get_clean(); + assert_true(str_contains($plugin_group_text, 'plugin=1 db=0'), 'plugin conflict grouping is visible in text audit output'); + + cow_merge_review_record( + $plugin_graph_metadata, + 'conflict', + $plugin_conflict_id, + 'needs-action', + 'plugin graph validator needs an app-specific repair', + 'cow-test' + ); + $plugin_review_audit = cow_merge_audit_report($plugin_graph_metadata, (int)$plugin_graph_result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'review_status' => 'needs-action', + ]); + assert_same(count($plugin_review_audit['conflicts']), 1, 'plugin conflict review queue returns reviewed plugin conflicts'); + assert_same($plugin_review_audit['conflicts'][0]['review_status'], 'needs-action', 'plugin audit exposes latest plugin conflict review status'); + assert_same($plugin_review_audit['conflicts'][0]['stale_status'] ?? null, 'unknown', 'plugin validator conflicts are not marked fresh or stale without rerunning validators'); + $plugin_revalidate = cow_merge_revalidate_reviewed_conflicts($plugin_graph_metadata, (int)$plugin_graph_result['run_id'], 'cow-revalidate'); + assert_same($plugin_revalidate['checked'], 1, 'plugin conflict revalidation inspects plugin conflicts in the selected run'); + assert_same($plugin_revalidate['reviewed'], 1, 'plugin conflict revalidation sees reviewed plugin conflicts'); + assert_same($plugin_revalidate['stale'], 0, 'plugin conflict revalidation does not infer stale state without rerunning validators'); + assert_same($plugin_revalidate['carried'], 0, 'plugin conflict revalidation does not carry plugin conflicts without validator evidence'); + assert_same((int)scalar($plugin_graph_metadata, "SELECT COUNT(*) FROM merge_revalidations WHERE conflict_id = $plugin_conflict_id"), 0, 'plugin conflict revalidation records no guarded payload without rerunning validators'); + $plugin_validator_identical_result = cow_merge_record_plugin_validator_conflicts($plugin_graph_metadata, (int)$plugin_graph_result['run_id'], [ + [ + 'plugin' => 'forkpress-graph', + 'object' => 'graph:source-parent:' . $plugin_graph_source_graph['parent_id'], + 'reason' => 'source graph references a missing child row after candidate validation', + 'tables' => ['plugin_graph_parent', 'plugin_graph_child', 'wp_options', 'wp_postmeta'], + 'files' => [$plugin_graph_source_graph['file_path']], + 'validator' => 'forkpress-graph-validator@1', + 'base' => ['parent_id' => null, 'child_id' => null], + 'source' => $plugin_graph_source_graph, + 'target' => $plugin_graph_target_graph, + 'candidate' => $plugin_graph_source_graph + ['missing_child_id' => 999999], + ], + ]); + assert_same($plugin_validator_identical_result['conflicts'], 1, 'plugin validator identical rerun keeps the same active plugin conflict'); + $plugin_identical_conflict_id = (int)scalar($plugin_graph_metadata, "SELECT MAX(id) FROM merge_conflicts WHERE table_name = '__plugins__' AND id > $plugin_conflict_id"); + $plugin_revalidate_after_identical_rerun = cow_merge_revalidate_reviewed_conflicts($plugin_graph_metadata, (int)$plugin_graph_result['run_id'], 'cow-revalidate'); + assert_same($plugin_identical_conflict_id, 0, 'plugin validator identical rerun records no duplicate replacement conflict'); + assert_same($plugin_revalidate_after_identical_rerun['checked'], 1, 'plugin conflict revalidation inspects only the original finding after an identical validator rerun'); + assert_same($plugin_revalidate_after_identical_rerun['reviewed'], 1, 'plugin identical rerun keeps the reviewed original plugin conflict'); + assert_same($plugin_revalidate_after_identical_rerun['fresh'], 0, 'plugin conflict revalidation does not infer freshness without replacement evidence'); + assert_same($plugin_revalidate_after_identical_rerun['stale'], 0, 'plugin conflict revalidation does not mark identical validator evidence stale'); + assert_same($plugin_revalidate_after_identical_rerun['carried'], 0, 'plugin conflict revalidation does not carry identical validator evidence to needs-action'); + assert_same((int)scalar($plugin_graph_metadata, "SELECT COUNT(*) FROM merge_revalidations WHERE conflict_id = $plugin_conflict_id"), 0, 'plugin conflict revalidation records no replacement evidence when validator payloads are unchanged'); + $plugin_identical_audit = cow_merge_audit_report($plugin_graph_metadata, (int)$plugin_graph_result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'review_status' => 'needs-action', + ]); + $plugin_original_after_identical_rerun = array_values(array_filter($plugin_identical_audit['conflicts'], fn($row) => (int)$row['id'] === $plugin_conflict_id)); + assert_same($plugin_original_after_identical_rerun[0]['stale_status'] ?? null, 'unknown', 'plugin audit keeps reviewed conflicts unknown after deduplicated identical validator evidence'); + assert_same((int)($plugin_original_after_identical_rerun[0]['replacement_conflict_id'] ?? 0), 0, 'plugin identical rerun audit exposes no replacement conflict id'); + $plugin_validator_updated_result = cow_merge_record_plugin_validator_conflicts($plugin_graph_metadata, (int)$plugin_graph_result['run_id'], [ + [ + 'plugin' => 'forkpress-graph', + 'object' => 'graph:source-parent:' . $plugin_graph_source_graph['parent_id'], + 'reason' => 'source graph still references a missing child row after validator rerun', + 'tables' => ['plugin_graph_parent', 'plugin_graph_child', 'wp_options', 'wp_postmeta'], + 'files' => [$plugin_graph_source_graph['file_path']], + 'validator' => 'forkpress-graph-validator@1', + 'base' => ['parent_id' => null, 'child_id' => null], + 'source' => $plugin_graph_source_graph, + 'target' => $plugin_graph_target_graph, + 'candidate' => $plugin_graph_source_graph + ['missing_child_id' => 123456], + ], + ]); + assert_same($plugin_validator_updated_result['conflicts'], 1, 'plugin validator rerun records replacement evidence for the same plugin object'); + $plugin_replacement_conflict_id = (int)scalar($plugin_graph_metadata, "SELECT MAX(id) FROM merge_conflicts WHERE table_name = '__plugins__' AND id > $plugin_conflict_id"); + $plugin_cli_revalidate_after_rerun = run_merge_cli([ + 'audit', + '--metadata-db', $plugin_graph_metadata, + '--run', (string)$plugin_graph_result['run_id'], + '--revalidate', + '--reviewer', 'cow-revalidate', + '--format', 'json', + ]); + assert_same($plugin_cli_revalidate_after_rerun['status'], 0, 'plugin merge-audit --revalidate CLI exits successfully'); + $plugin_revalidate_after_rerun = json_decode($plugin_cli_revalidate_after_rerun['output'], true); + assert_true(is_array($plugin_revalidate_after_rerun), 'plugin merge-audit --revalidate CLI emits JSON'); + assert_same($plugin_revalidate_after_rerun['checked'], 2, 'plugin conflict revalidation inspects original and replacement validator findings'); + assert_same($plugin_revalidate_after_rerun['reviewed'], 1, 'plugin conflict revalidation still only carries reviewed plugin conflicts'); + assert_same($plugin_revalidate_after_rerun['stale'], 1, 'plugin conflict revalidation treats changed validator evidence as stale'); + assert_same($plugin_revalidate_after_rerun['carried'], 1, 'plugin conflict revalidation carries changed validator evidence to needs-action'); + assert_same((int)scalar($plugin_graph_metadata, "SELECT COUNT(*) FROM merge_revalidations WHERE conflict_id = $plugin_conflict_id"), 1, 'plugin conflict revalidation records replacement validator evidence for audit'); + assert_same(scalar($plugin_graph_metadata, "SELECT revalidation_class FROM merge_revalidations WHERE conflict_id = $plugin_conflict_id ORDER BY id DESC LIMIT 1"), 'replacement-evidence', 'plugin revalidation classifies changed validator evidence'); + assert_same((int)scalar($plugin_graph_metadata, "SELECT replacement_conflict_id FROM merge_revalidations WHERE conflict_id = $plugin_conflict_id ORDER BY id DESC LIMIT 1"), $plugin_replacement_conflict_id, 'plugin revalidation links to the replacement validator conflict'); + $plugin_revalidated_audit = cow_merge_audit_report($plugin_graph_metadata, (int)$plugin_graph_result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'review_status' => 'needs-action', + ]); + $plugin_original_after_rerun = array_values(array_filter($plugin_revalidated_audit['conflicts'], fn($row) => (int)$row['id'] === $plugin_conflict_id)); + assert_same($plugin_original_after_rerun[0]['stale_status'] ?? null, 'stale', 'plugin audit marks reviewed conflicts stale after validator evidence changes'); + assert_same((int)($plugin_original_after_rerun[0]['replacement_conflict_id'] ?? 0), $plugin_replacement_conflict_id, 'plugin stale audit exposes the live replacement conflict id'); + assert_same((int)($plugin_original_after_rerun[0]['latest_revalidation_replacement_conflict_id'] ?? 0), $plugin_replacement_conflict_id, 'plugin stale audit exposes the stored replacement conflict id'); + assert_true(str_contains((string)($plugin_original_after_rerun[0]['current_target_preview'] ?? ''), '123456'), 'plugin stale audit exposes replacement validator evidence'); + assert_true(str_contains((string)($plugin_original_after_rerun[0]['review_note'] ?? ''), 'plugin graph validator needs an app-specific repair'), 'plugin stale revalidation preserves prior reviewer intent'); + $plugin_revalidate_again = cow_merge_revalidate_reviewed_conflicts($plugin_graph_metadata, (int)$plugin_graph_result['run_id'], 'cow-revalidate'); + assert_same($plugin_revalidate_again['carried'], 0, 'plugin conflict revalidation does not duplicate carried validator evidence notes'); + assert_same($plugin_revalidate_again['already_needs_action'], 1, 'plugin conflict revalidation reports already-carried validator evidence'); + assert_same((int)scalar($plugin_graph_metadata, "SELECT COUNT(*) FROM merge_revalidations WHERE conflict_id = $plugin_conflict_id"), 1, 'plugin conflict revalidation keeps replacement validator evidence idempotent'); + assert_throws( + fn() => cow_merge_resolve_conflict($plugin_graph_metadata, $plugin_conflict_id, 'target', false, 'Try generic plugin resolution.', 'cow-test', true), + 'plugin validator conflicts cannot be resolved by generic merge-resolve', + 'plugin validator conflicts have an explicit generic resolution boundary' + ); + $plugin_cli_audit = run_merge_cli([ + 'audit', + '--metadata-db', $plugin_graph_metadata, + '--run', (string)$plugin_graph_result['run_id'], + '--format', 'json', + '--scope', 'plugin', + '--records', 'conflicts', + '--review-status', 'needs-action', + ]); + assert_same($plugin_cli_audit['status'], 0, 'plugin audit CLI exits successfully'); + $plugin_cli_report = json_decode($plugin_cli_audit['output'], true); + assert_true(is_array($plugin_cli_report), 'plugin audit CLI emits JSON'); + assert_same($plugin_cli_report['filters']['scope'] ?? null, 'plugin', 'plugin audit CLI preserves plugin scope'); + assert_same(count($plugin_cli_report['conflicts'] ?? []), 1, 'plugin audit CLI returns reviewed plugin conflicts'); + assert_same($plugin_cli_report['conflicts'][0]['table_name'] ?? null, '__plugins__', 'plugin audit CLI exposes plugin conflict namespace'); + $plugin_cli_text_group = run_merge_cli([ + 'audit', + '--metadata-db', $plugin_graph_metadata, + '--run', (string)$plugin_graph_result['run_id'], + '--scope', 'plugin', + '--records', 'conflicts', + '--group-by', 'severity', + ]); + assert_same($plugin_cli_text_group['status'], 0, 'plugin grouped audit CLI exits successfully'); + assert_true(str_contains($plugin_cli_text_group['output'], 'scope=plugin') && str_contains($plugin_cli_text_group['output'], 'plugin=2 db=0'), 'plugin grouped audit CLI prints plugin counts separately from DB counts'); + $plugin_cli_path_error = run_merge_cli([ + 'audit', + '--metadata-db', $plugin_graph_metadata, + '--scope', 'plugin', + '--path', 'wp-content/uploads/plugin-graph-source.dat', + ]); + assert_true($plugin_cli_path_error['status'] !== 0, 'plugin audit CLI rejects file path filters'); + assert_true(str_contains($plugin_cli_path_error['output'], '--path and --path-prefix require file audit scope'), 'plugin audit CLI explains path filter scope errors'); + $plugin_cli_record_finding = run_merge_cli([ + 'record-plugin-validator-conflicts', + '--metadata-db', $plugin_graph_metadata, + '--run', (string)$plugin_graph_result['run_id'], + '--findings-json', json_encode([ + [ + 'plugin' => 'forkpress-graph', + 'object' => 'graph:target-parent:' . $plugin_graph_target_graph['parent_id'], + 'reason' => 'target graph validator found a conflicting external option reference', + 'type' => 'plugin-target-conflict', + 'tables' => ['plugin_graph_parent', 'wp_options'], + 'files' => [$plugin_graph_target_graph['file_path']], + 'validator' => 'forkpress-graph-validator@1', + 'source' => $plugin_graph_source_graph, + 'target' => $plugin_graph_target_graph, + 'candidate' => $plugin_graph_target_graph + ['conflicting_option' => 'plugin_graph_target'], + ], + ], JSON_UNESCAPED_SLASHES), + '--format', 'json', + ]); + assert_same($plugin_cli_record_finding['status'], 0, 'plugin validator record CLI records plugin conflicts'); + $plugin_cli_record_result = json_decode($plugin_cli_record_finding['output'], true); + assert_same($plugin_cli_record_result['status'] ?? null, 'completed_with_conflicts', 'plugin validator record CLI reports conflicted findings'); + assert_same($plugin_cli_record_result['conflicts'] ?? null, 1, 'plugin validator record CLI reports recorded conflicts'); + $plugin_cli_record_audit = cow_merge_audit_report($plugin_graph_metadata, (int)$plugin_graph_result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'conflict_type' => 'plugin-target-conflict', + ]); + assert_same(count($plugin_cli_record_audit['conflicts']), 1, 'plugin validator record CLI conflicts are visible in plugin audit scope'); + assert_true(str_contains($plugin_cli_record_audit['conflicts'][0]['chosen_preview'], 'conflicting_option'), 'plugin validator record CLI stores candidate payloads'); + $plugin_validator_file_findings = $tmp . '/plugin-validator-conflict-findings.json'; + write_test_file($plugin_validator_file_findings, json_encode([ + [ + 'plugin' => 'forkpress-graph', + 'object' => 'graph:file-backed:' . $plugin_graph_target_graph['child_id'], + 'reason' => 'file-backed validator finding recorded from a findings file', + 'type' => 'plugin-file-backed-conflict', + 'tables' => ['plugin_graph_child'], + 'files' => [$plugin_graph_target_graph['file_path']], + 'validator' => 'forkpress-graph-validator@1', + 'candidate' => $plugin_graph_target_graph + ['file_backed_finding' => true], + ], + ], JSON_UNESCAPED_SLASHES) . "\n"); + $plugin_cli_record_file_finding = run_merge_cli([ + 'record-plugin-validator-conflicts', + '--metadata-db', $plugin_graph_metadata, + '--run', (string)$plugin_graph_result['run_id'], + '--findings-file', $plugin_validator_file_findings, + '--format', 'json', + ]); + assert_same($plugin_cli_record_file_finding['status'], 0, 'plugin validator record CLI records conflicts from findings files'); + $plugin_cli_record_file_result = json_decode($plugin_cli_record_file_finding['output'], true); + assert_same($plugin_cli_record_file_result['conflicts'] ?? null, 1, 'plugin validator record CLI reports file-backed conflicts'); + $plugin_cli_record_file_audit = cow_merge_audit_report($plugin_graph_metadata, (int)$plugin_graph_result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'conflict_type' => 'plugin-file-backed-conflict', + ]); + assert_same(count($plugin_cli_record_file_audit['conflicts']), 1, 'plugin validator file-backed conflicts are visible in plugin audit scope'); + assert_true(str_contains($plugin_cli_record_file_audit['conflicts'][0]['chosen_preview'], 'file_backed_finding'), 'plugin validator file-backed conflicts store candidate payloads'); + $plugin_validator_runner = $tmp . '/plugin-validator-runner.php'; + write_test_file($plugin_validator_runner, <<<'PHP' + (int)getenv('FORKPRESS_MERGE_RUN'), + 'source_branch' => getenv('FORKPRESS_MERGE_SOURCE_BRANCH'), + 'target_branch' => getenv('FORKPRESS_MERGE_TARGET_BRANCH'), + 'source_db' => basename((string)getenv('FORKPRESS_MERGE_SOURCE_DB')), + 'target_db' => basename((string)getenv('FORKPRESS_MERGE_TARGET_DB')), +]; +echo json_encode([ + 'status' => 'conflicts', + 'findings' => [ + [ + 'plugin' => 'forkpress-graph', + 'object' => 'graph:runner:' . $candidate['run'], + 'reason' => 'runner validator received merge context and found a graph issue', + 'type' => 'plugin-runner-conflict', + 'tables' => ['plugin_graph_parent'], + 'validator' => 'forkpress-graph-runner@1', + 'candidate' => $candidate, + ], + ], +], JSON_UNESCAPED_SLASHES); +PHP); + $plugin_cli_run_validator = run_merge_cli([ + 'run-plugin-validator', + '--metadata-db', $plugin_graph_metadata, + '--run', (string)$plugin_graph_result['run_id'], + '--validator', $plugin_validator_runner, + '--format', 'json', + ]); + assert_same($plugin_cli_run_validator['status'], 0, 'plugin validator runner CLI exits successfully'); + $plugin_cli_run_validator_result = json_decode($plugin_cli_run_validator['output'], true); + assert_same($plugin_cli_run_validator_result['validator_status'] ?? null, 'conflicts', 'plugin validator runner CLI reports validator status'); + assert_same($plugin_cli_run_validator_result['conflicts'] ?? null, 1, 'plugin validator runner CLI records emitted findings'); + $plugin_runner_audit = cow_merge_audit_report($plugin_graph_metadata, (int)$plugin_graph_result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'conflict_type' => 'plugin-runner-conflict', + ]); + assert_same(count($plugin_runner_audit['conflicts']), 1, 'plugin validator runner conflicts are visible in plugin audit scope'); + assert_true(str_contains($plugin_runner_audit['conflicts'][0]['chosen_preview'], 'feature-plugin-graph-source'), 'plugin validator runner passes source branch context to validators'); + + $plugin_validator_runner_contradictory_valid = $tmp . '/plugin-validator-runner-contradictory-valid.php'; + write_test_file($plugin_validator_runner_contradictory_valid, <<<'PHP' + 'valid', + 'findings' => [ + [ + 'plugin' => 'forkpress-graph', + 'object' => 'graph:contradictory-valid', + 'reason' => 'valid status must not carry findings', + 'type' => 'plugin-contradictory-valid', + ], + ], +], JSON_UNESCAPED_SLASHES); +PHP); + $plugin_cli_run_validator_contradictory_valid = run_merge_cli([ + 'run-plugin-validator', + '--metadata-db', $plugin_graph_metadata, + '--run', (string)$plugin_graph_result['run_id'], + '--validator', $plugin_validator_runner_contradictory_valid, + '--format', 'json', + ]); + assert_true($plugin_cli_run_validator_contradictory_valid['status'] !== 0, 'plugin validator runner CLI rejects valid status with findings'); + assert_true(str_contains($plugin_cli_run_validator_contradictory_valid['output'], 'status valid with findings'), 'plugin validator runner CLI explains contradictory valid findings'); + assert_same( + (int)scalar($plugin_graph_metadata, "SELECT COUNT(*) FROM merge_conflicts WHERE conflict_type = 'plugin-contradictory-valid'"), + 0, + 'plugin validator runner does not record contradictory valid findings' + ); + + $plugin_validator_runner_empty_conflicts = $tmp . '/plugin-validator-runner-empty-conflicts.php'; + write_test_file($plugin_validator_runner_empty_conflicts, <<<'PHP' + 'conflicts', + 'findings' => [], +], JSON_UNESCAPED_SLASHES); +PHP); + $plugin_cli_run_validator_empty_conflicts = run_merge_cli([ + 'run-plugin-validator', + '--metadata-db', $plugin_graph_metadata, + '--run', (string)$plugin_graph_result['run_id'], + '--validator', $plugin_validator_runner_empty_conflicts, + '--format', 'json', + ]); + assert_true($plugin_cli_run_validator_empty_conflicts['status'] !== 0, 'plugin validator runner CLI rejects conflicts status without findings'); + assert_true(str_contains($plugin_cli_run_validator_empty_conflicts['output'], 'status conflicts without findings'), 'plugin validator runner CLI explains empty conflicts status'); + + $plugin_validator_file_base_root = $tmp . '/plugin-validator-file-base'; + $plugin_validator_file_source_root = $tmp . '/plugin-validator-file-source'; + $plugin_validator_file_target_root = $tmp . '/plugin-validator-file-target'; + foreach ([$plugin_validator_file_base_root, $plugin_validator_file_source_root, $plugin_validator_file_target_root] as $root) { + mkdir($root . '/wp-content/database', 0777, true); + mkdir($root . '/wp-content/uploads', 0777, true); + } + $plugin_validator_file_base_db = $plugin_validator_file_base_root . '/wp-content/database/.ht.sqlite'; + $plugin_validator_file_source_db = $plugin_validator_file_source_root . '/wp-content/database/.ht.sqlite'; + $plugin_validator_file_target_db = $plugin_validator_file_target_root . '/wp-content/database/.ht.sqlite'; + $plugin_validator_file_metadata = $tmp . '/.forkpress/cow/merge/plugin-validator-file-env-metadata.sqlite'; + $plugin_validator_file_base = $tmp . '/.forkpress/cow/merge/file-bases/plugin-validator-file-env.json'; + create_base_db($plugin_validator_file_base_db); + copy($plugin_validator_file_base_db, $plugin_validator_file_source_db); + copy($plugin_validator_file_base_db, $plugin_validator_file_target_db); + cow_merge_capture_file_base($plugin_validator_file_base_root, $plugin_validator_file_base); + write_test_file($plugin_validator_file_source_root . '/wp-content/uploads/plugin-validator-env.txt', "source validator file\n"); + $plugin_validator_file_merge = cow_merge_branch_state( + $plugin_validator_file_base_db, + $plugin_validator_file_source_db, + $plugin_validator_file_target_db, + $plugin_validator_file_metadata, + 'feature-plugin-validator-file-source', + 'feature-plugin-validator-file-target', + $plugin_validator_file_base, + $plugin_validator_file_source_root, + $plugin_validator_file_target_root + ); + assert_same($plugin_validator_file_merge['status'], 'completed', 'validator file-root fixture merges source-only files cleanly'); + $plugin_validator_file_runner = $tmp . '/plugin-validator-file-runner.php'; + write_test_file($plugin_validator_file_runner, <<<'PHP' + 'conflicts', + 'findings' => [ + [ + 'plugin' => 'forkpress-file-validator', + 'object' => 'file:' . $relative, + 'reason' => 'runner validator inspected source and candidate target files', + 'type' => 'plugin-file-root-conflict', + 'files' => [$relative], + 'validator' => 'forkpress-file-runner@1', + 'candidate' => [ + 'source_root_basename' => basename($source_root), + 'target_root_basename' => basename($target_root), + 'source_file' => trim((string)file_get_contents($source_file)), + 'target_file' => trim((string)file_get_contents($target_file)), + ], + ], + ], +], JSON_UNESCAPED_SLASHES); +PHP); + $plugin_validator_file_run = run_merge_cli([ + 'run-plugin-validator', + '--metadata-db', $plugin_validator_file_metadata, + '--run', (string)$plugin_validator_file_merge['run_id'], + '--validator', $plugin_validator_file_runner, + '--format', 'json', + ]); + assert_same($plugin_validator_file_run['status'], 0, 'plugin validator runner passes filesystem roots to validators'); + $plugin_validator_file_result = json_decode($plugin_validator_file_run['output'], true); + assert_same($plugin_validator_file_result['conflicts'] ?? null, 1, 'file-root validator findings are recorded'); + $plugin_validator_file_audit = cow_merge_audit_report($plugin_validator_file_metadata, (int)$plugin_validator_file_merge['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'conflict_type' => 'plugin-file-root-conflict', + ]); + assert_same(count($plugin_validator_file_audit['conflicts']), 1, 'file-root validator conflicts are visible in plugin audit scope'); + assert_true(str_contains($plugin_validator_file_audit['conflicts'][0]['chosen_preview'], 'source validator file'), 'file-root validator can inspect source files'); + $plugin_validator_file_payload = cow_merge_decode_payload_json( + (string)scalar($plugin_validator_file_metadata, "SELECT chosen_payload FROM merge_conflicts WHERE conflict_type = 'plugin-file-root-conflict' ORDER BY id DESC LIMIT 1"), + 'plugin validator file-root payload' + ); + assert_same($plugin_validator_file_payload['candidate']['target_root_basename'] ?? null, 'plugin-validator-file-target', 'file-root validator receives the candidate target root'); + assert_same($plugin_validator_file_payload['candidate']['target_file'] ?? null, 'source validator file', 'file-root validator can inspect candidate target files'); + + $plugin_discovery_root = $tmp . '/plugin-validator-discovery-root'; + $plugin_discovery_db = $tmp . '/plugin-validator-discovery.sqlite'; + create_base_db($plugin_discovery_db); + $db = open_db($plugin_discovery_db); + $db->exec( + "INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('active_plugins', '" . + SQLite3::escapeString(serialize(['active-plugin/active-plugin.php', 'single-plugin.php', '../unsafe/unsafe.php'])) . + "', 'yes')" + ); + $db->close(); + write_test_file($plugin_discovery_root . '/wp-content/plugins/active-plugin/forkpress-merge-validator.php', " str_replace($plugin_discovery_root . '/', '', $path), + cow_merge_discover_plugin_validators($plugin_discovery_db, $plugin_discovery_root) + ); + assert_same($discovered_plugin_validators, [ + 'wp-content/mu-plugins/forkpress-merge-validator.php', + 'wp-content/mu-plugins/mu-extra.forkpress-merge-validator.php', + 'wp-content/mu-plugins/mu-dir/forkpress-merge-validator.php', + 'wp-content/plugins/active-plugin/forkpress-merge-validator.php', + 'wp-content/plugins/single-plugin.forkpress-merge-validator.php', + ], 'plugin validator discovery includes mu-plugin validators and active plugin validators only'); + + $auto_validator_base_root = $tmp . '/auto-validator-base'; + $auto_validator_source_root = $tmp . '/auto-validator-source'; + $auto_validator_target_root = $tmp . '/auto-validator-target'; + foreach ([$auto_validator_base_root, $auto_validator_source_root, $auto_validator_target_root] as $root) { + mkdir($root . '/wp-content/database', 0777, true); + mkdir($root . '/wp-content/uploads', 0777, true); + } + $auto_validator_base_db = $auto_validator_base_root . '/wp-content/database/.ht.sqlite'; + $auto_validator_source_db = $auto_validator_source_root . '/wp-content/database/.ht.sqlite'; + $auto_validator_target_db = $auto_validator_target_root . '/wp-content/database/.ht.sqlite'; + $auto_validator_metadata = $tmp . '/.forkpress/cow/merge/auto-validator-metadata.sqlite'; + $auto_validator_file_base = $tmp . '/.forkpress/cow/merge/file-bases/auto-validator.json'; + create_base_db($auto_validator_base_db); + copy($auto_validator_base_db, $auto_validator_source_db); + copy($auto_validator_base_db, $auto_validator_target_db); + cow_merge_capture_file_base($auto_validator_base_root, $auto_validator_file_base); + $db = open_db($auto_validator_source_db); + $db->exec("UPDATE wp_posts SET post_content = 'source automatic validator content' WHERE ID = 1"); + $db->close(); + $db = open_db($auto_validator_target_db); + $db->exec( + "INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('active_plugins', '" . + SQLite3::escapeString(serialize(['auto-validator/auto-validator.php'])) . + "', 'yes')" + ); + $db->close(); + write_test_file($auto_validator_target_root . '/wp-content/plugins/auto-validator/forkpress-merge-validator.php', <<<'PHP' + 'conflicts', + 'findings' => [ + [ + 'plugin' => 'forkpress-auto-validator', + 'object' => 'candidate:' . basename((string)getenv('FORKPRESS_MERGE_TARGET_ROOT')), + 'reason' => 'automatically discovered validator inspected the merge candidate', + 'type' => 'plugin-auto-validator-conflict', + 'validator' => 'forkpress-auto-validator@1', + 'candidate' => [ + 'target_content' => trim((string)(new SQLite3((string)getenv('FORKPRESS_MERGE_TARGET_DB')))->querySingle('SELECT post_content FROM wp_posts WHERE ID = 1')), + ], + ], + ], +], JSON_UNESCAPED_SLASHES); +PHP); + write_test_file($auto_validator_target_root . '/wp-content/plugins/inactive-validator/forkpress-merge-validator.php', <<<'PHP' +exec('CREATE TABLE plugin_import_parent (id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT NOT NULL)'); + $db->exec('CREATE TABLE plugin_import_child (id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER NOT NULL, label TEXT NOT NULL)'); + $db->exec("INSERT INTO plugin_import_parent (label) VALUES ('base plugin parent')"); + $db->close(); + copy($plugin_explicit_import_base_db, $plugin_explicit_import_source_db); + copy($plugin_explicit_import_base_db, $plugin_explicit_import_target_db); + cow_merge_capture_file_base($plugin_explicit_import_base_root, $plugin_explicit_import_file_base); + cow_merge_allocate_autoincrement_bands($plugin_explicit_import_source_db, $plugin_explicit_import_metadata, 'feature-plugin-explicit-import'); + $db = open_db($plugin_explicit_import_source_db); + $db->exec("INSERT INTO plugin_import_parent (id, label) VALUES (2, 'explicit imported plugin parent')"); + $db->exec("INSERT INTO plugin_import_child (parent_id, label) VALUES (2, 'child behind explicit plugin parent')"); + $plugin_explicit_import_child_id = (int)$db->lastInsertRowID(); + $db->close(); + $db = open_db($plugin_explicit_import_target_db); + $db->exec( + "INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('active_plugins', '" . + SQLite3::escapeString(serialize(['explicit-import-validator/explicit-import-validator.php'])) . + "', 'yes')" + ); + $db->close(); + write_test_file($plugin_explicit_import_target_root . '/wp-content/plugins/explicit-import-validator/forkpress-merge-validator.php', <<<'PHP' +query( + 'SELECT c.id, c.parent_id, c.label FROM plugin_import_child c ' . + 'LEFT JOIN plugin_import_parent p ON p.id = c.parent_id ' . + 'WHERE p.id IS NULL ORDER BY c.id' +); +$findings = []; +while ($row = $res->fetchArray(SQLITE3_ASSOC)) { + $findings[] = [ + 'plugin' => 'forkpress-explicit-import-validator', + 'object' => 'plugin_import_child:' . $row['id'], + 'reason' => 'plugin child row references a parent that generic merge held for review', + 'type' => 'plugin-explicit-import-missing-parent', + 'tables' => ['plugin_import_parent', 'plugin_import_child'], + 'validator' => 'forkpress-explicit-import-validator@1', + 'candidate' => [ + 'child_id' => (int)$row['id'], + 'parent_id' => (int)$row['parent_id'], + 'label' => (string)$row['label'], + ], + ]; +} +echo json_encode([ + 'status' => count($findings) > 0 ? 'conflicts' : 'valid', + 'findings' => $findings, +], JSON_UNESCAPED_SLASHES); +PHP); + $plugin_explicit_import_merge = run_merge_cli([ + 'merge', + '--base-db', $plugin_explicit_import_base_db, + '--source-db', $plugin_explicit_import_source_db, + '--target-db', $plugin_explicit_import_target_db, + '--metadata-db', $plugin_explicit_import_metadata, + '--source', 'feature-plugin-explicit-import', + '--target', 'main', + '--base-files', $plugin_explicit_import_file_base, + '--source-root', $plugin_explicit_import_source_root, + '--target-root', $plugin_explicit_import_target_root, + ]); + assert_same($plugin_explicit_import_merge['status'], 0, 'plugin validator runs after explicit-ID plugin import candidate is staged'); + assert_true(str_contains($plugin_explicit_import_merge['output'], 'plugins: validators=1 conflicts=1'), 'plugin validator reports explicit-ID plugin graph conflicts'); + assert_same((int)scalar($plugin_explicit_import_target_db, 'SELECT COUNT(*) FROM plugin_import_parent WHERE id = 2'), 0, 'out-of-band explicit plugin parent remains held for review'); + assert_same((int)scalar($plugin_explicit_import_target_db, "SELECT parent_id FROM plugin_import_child WHERE id = $plugin_explicit_import_child_id"), 2, 'generic merge leaves plugin child graph available for validator review'); + assert_same( + (int)scalar($plugin_explicit_import_metadata, "SELECT COUNT(*) FROM merge_conflicts WHERE table_name = 'plugin_import_parent' AND conflict_type = 'row-target-constraint'"), + 1, + 'held explicit plugin parent remains a database conflict' + ); + assert_same( + (int)scalar($plugin_explicit_import_metadata, "SELECT COUNT(*) FROM merge_conflicts WHERE table_name = '__plugins__' AND conflict_type = 'plugin-explicit-import-missing-parent'"), + 1, + 'plugin validator records incoherent explicit-ID plugin graph as a plugin-scoped conflict' + ); + $plugin_explicit_import_payload = cow_merge_decode_payload_json( + (string)scalar($plugin_explicit_import_metadata, "SELECT chosen_payload FROM merge_conflicts WHERE conflict_type = 'plugin-explicit-import-missing-parent' ORDER BY id DESC LIMIT 1"), + 'plugin explicit import payload' + ); + assert_same($plugin_explicit_import_payload['candidate']['parent_id'] ?? null, 2, 'plugin explicit import validator payload names the held parent ID'); + assert_same($plugin_explicit_import_payload['candidate']['child_id'] ?? null, $plugin_explicit_import_child_id, 'plugin explicit import validator payload names the staged child ID'); + + $graph_validator_base_root = $tmp . '/graph-validator-base'; + $graph_validator_source_root = $tmp . '/graph-validator-source'; + $graph_validator_target_root = $tmp . '/graph-validator-target'; + foreach ([$graph_validator_base_root, $graph_validator_source_root, $graph_validator_target_root] as $root) { + mkdir($root . '/wp-content/database', 0777, true); + mkdir($root . '/wp-content/uploads', 0777, true); + } + $graph_validator_base_db = $graph_validator_base_root . '/wp-content/database/.ht.sqlite'; + $graph_validator_source_db = $graph_validator_source_root . '/wp-content/database/.ht.sqlite'; + $graph_validator_target_db = $graph_validator_target_root . '/wp-content/database/.ht.sqlite'; + $graph_validator_metadata = $tmp . '/.forkpress/cow/merge/graph-validator-metadata.sqlite'; + $graph_validator_file_base = $tmp . '/.forkpress/cow/merge/file-bases/graph-validator.json'; + create_base_db($graph_validator_base_db); + $db = open_db($graph_validator_base_db); + $db->exec('CREATE TABLE plugin_graph_validator_parent (parent_id INTEGER PRIMARY KEY AUTOINCREMENT, graph_json TEXT)'); + $db->exec('CREATE TABLE plugin_graph_validator_child (child_id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER, label TEXT)'); + $db->exec("INSERT INTO plugin_graph_validator_parent (parent_id, graph_json) VALUES (1, '" . SQLite3::escapeString(json_encode(['child_id' => 1], JSON_UNESCAPED_SLASHES)) . "')"); + $db->exec("INSERT INTO plugin_graph_validator_child (child_id, parent_id, label) VALUES (1, 1, 'base child')"); + $db->close(); + copy($graph_validator_base_db, $graph_validator_source_db); + copy($graph_validator_base_db, $graph_validator_target_db); + cow_merge_capture_file_base($graph_validator_base_root, $graph_validator_file_base); + $db = open_db($graph_validator_source_db); + $db->exec("UPDATE plugin_graph_validator_parent SET graph_json = '" . SQLite3::escapeString(json_encode(['child_id' => 9999], JSON_UNESCAPED_SLASHES)) . "' WHERE parent_id = 1"); + $db->close(); + $db = open_db($graph_validator_target_db); + $db->exec( + "INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('active_plugins', '" . + SQLite3::escapeString(serialize(['graph-validator/graph-validator.php'])) . + "', 'yes')" + ); + $db->close(); + write_test_file($graph_validator_target_root . '/wp-content/plugins/graph-validator/forkpress-merge-validator.php', <<<'PHP' +query('SELECT parent_id, graph_json FROM plugin_graph_validator_parent ORDER BY parent_id'); +while ($row = $res->fetchArray(SQLITE3_ASSOC)) { + $graph = json_decode((string)$row['graph_json'], true); + $child_id = is_array($graph) ? (int)($graph['child_id'] ?? 0) : 0; + if ($child_id <= 0) { + continue; + } + $stmt = $db->prepare('SELECT COUNT(*) FROM plugin_graph_validator_child WHERE child_id = :child_id'); + $stmt->bindValue(':child_id', $child_id, SQLITE3_INTEGER); + $count = (int)$stmt->execute()->fetchArray(SQLITE3_NUM)[0]; + if ($count === 0) { + echo json_encode([ + 'status' => 'failed', + 'reason' => 'plugin graph parent ' . $row['parent_id'] . ' references missing child ' . $child_id, + 'findings' => [], + ], JSON_UNESCAPED_SLASHES); + exit(0); + } +} +echo json_encode(['status' => 'valid', 'findings' => []], JSON_UNESCAPED_SLASHES); +PHP); + $graph_validator_merge = run_merge_cli([ + 'merge', + '--base-db', $graph_validator_base_db, + '--source-db', $graph_validator_source_db, + '--target-db', $graph_validator_target_db, + '--metadata-db', $graph_validator_metadata, + '--source', 'feature-graph-validator', + '--target', 'main', + '--base-files', $graph_validator_file_base, + '--source-root', $graph_validator_source_root, + '--target-root', $graph_validator_target_root, + ]); + assert_true($graph_validator_merge['status'] !== 0, 'automatically discovered graph validator can abort incoherent plugin candidates'); + assert_true(str_contains($graph_validator_merge['output'], 'references missing child 9999'), 'graph validator failure explains the broken plugin reference'); + assert_same(scalar($graph_validator_target_db, 'SELECT graph_json FROM plugin_graph_validator_parent WHERE parent_id = 1'), json_encode(['child_id' => 1], JSON_UNESCAPED_SLASHES), 'graph validator failure rolls back staged plugin graph changes'); + assert_same( + (int)scalar($graph_validator_metadata, "SELECT COUNT(*) FROM merge_runs WHERE source_branch = 'feature-graph-validator' AND status = 'failed' AND failure_reason LIKE '%references missing child 9999%'"), + 1, + 'graph validator failure leaves an auditable failed run' + ); + + $serialized_graph_base_root = $tmp . '/serialized-graph-validator-base'; + $serialized_graph_source_root = $tmp . '/serialized-graph-validator-source'; + $serialized_graph_target_root = $tmp . '/serialized-graph-validator-target'; + foreach ([$serialized_graph_base_root, $serialized_graph_source_root, $serialized_graph_target_root] as $root) { + mkdir($root . '/wp-content/database', 0777, true); + } + $serialized_graph_base_db = $serialized_graph_base_root . '/wp-content/database/.ht.sqlite'; + $serialized_graph_source_db = $serialized_graph_source_root . '/wp-content/database/.ht.sqlite'; + $serialized_graph_target_db = $serialized_graph_target_root . '/wp-content/database/.ht.sqlite'; + $serialized_graph_metadata = $tmp . '/.forkpress/cow/merge/serialized-graph-validator-metadata.sqlite'; + $serialized_graph_file_base = $tmp . '/.forkpress/cow/merge/file-bases/serialized-graph-validator.json'; + create_base_db($serialized_graph_base_db); + $db = open_db($serialized_graph_base_db); + $db->exec('CREATE TABLE plugin_serialized_graph_parent (parent_id INTEGER PRIMARY KEY AUTOINCREMENT, graph_serialized TEXT NOT NULL)'); + $db->exec('CREATE TABLE plugin_serialized_graph_child (child_id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT NOT NULL)'); + $db->exec("INSERT INTO plugin_serialized_graph_parent (parent_id, graph_serialized) VALUES (1, '" . SQLite3::escapeString(serialize(['child_id' => 1, 'file_path' => 'wp-content/uploads/serialized-base.dat'])) . "')"); + $db->exec("INSERT INTO plugin_serialized_graph_child (child_id, label) VALUES (1, 'base serialized child')"); + $db->close(); + write_test_file($serialized_graph_base_root . '/wp-content/uploads/serialized-base.dat', "base serialized plugin file\n"); + copy($serialized_graph_base_db, $serialized_graph_source_db); + copy($serialized_graph_base_db, $serialized_graph_target_db); + copy_tree_for_test($serialized_graph_base_root . '/wp-content/uploads', $serialized_graph_source_root . '/wp-content/uploads'); + copy_tree_for_test($serialized_graph_base_root . '/wp-content/uploads', $serialized_graph_target_root . '/wp-content/uploads'); + cow_merge_capture_file_base($serialized_graph_base_root, $serialized_graph_file_base); + $db = open_db($serialized_graph_source_db); + $db->exec("UPDATE plugin_serialized_graph_parent SET graph_serialized = '" . SQLite3::escapeString(serialize(['child_id' => 9999, 'file_path' => 'wp-content/uploads/serialized-missing.dat'])) . "' WHERE parent_id = 1"); + $db->close(); + $db = open_db($serialized_graph_target_db); + $db->exec( + "INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('active_plugins', '" . + SQLite3::escapeString(serialize(['serialized-graph-validator/serialized-graph-validator.php'])) . + "', 'yes')" + ); + $db->close(); + write_test_file($serialized_graph_target_root . '/wp-content/plugins/serialized-graph-validator/forkpress-merge-validator.php', <<<'PHP' +query('SELECT parent_id, graph_serialized FROM plugin_serialized_graph_parent ORDER BY parent_id'); +$findings = []; +while ($row = $res->fetchArray(SQLITE3_ASSOC)) { + $graph = @unserialize((string)$row['graph_serialized']); + $child_id = is_array($graph) ? (int)($graph['child_id'] ?? 0) : 0; + if ($child_id > 0) { + $stmt = $db->prepare('SELECT COUNT(*) FROM plugin_serialized_graph_child WHERE child_id = :child_id'); + $stmt->bindValue(':child_id', $child_id, SQLITE3_INTEGER); + $count = (int)$stmt->execute()->fetchArray(SQLITE3_NUM)[0]; + if ($count === 0) { + $findings[] = [ + 'plugin' => 'forkpress-serialized-graph-validator', + 'object' => 'parent:' . $row['parent_id'], + 'reason' => 'serialized plugin graph references a missing child row', + 'type' => 'plugin-serialized-graph-missing-child', + 'tables' => ['plugin_serialized_graph_parent', 'plugin_serialized_graph_child'], + 'validator' => 'forkpress-serialized-graph-validator@1', + 'candidate' => [ + 'parent_id' => (int)$row['parent_id'], + 'field' => 'graph_serialized.child_id', + 'missing_child_id' => $child_id, + ], + ]; + } + } + $file_path = is_array($graph) ? (string)($graph['file_path'] ?? '') : ''; + if ($file_path !== '') { + $target_root = rtrim((string)getenv('FORKPRESS_MERGE_TARGET_ROOT'), '/'); + $target_path = $target_root . '/' . ltrim($file_path, '/'); + if (!is_file($target_path)) { + $findings[] = [ + 'plugin' => 'forkpress-serialized-graph-validator', + 'object' => 'parent:' . $row['parent_id'], + 'reason' => 'serialized plugin graph references a missing file', + 'type' => 'plugin-serialized-graph-missing-file', + 'tables' => ['plugin_serialized_graph_parent'], + 'files' => [$file_path], + 'validator' => 'forkpress-serialized-graph-validator@1', + 'candidate' => [ + 'parent_id' => (int)$row['parent_id'], + 'field' => 'graph_serialized.file_path', + 'missing_file_path' => $file_path, + ], + ]; + } + } +} +echo json_encode([ + 'status' => $findings ? 'conflicts' : 'valid', + 'findings' => $findings, +], JSON_UNESCAPED_SLASHES); +PHP); + $serialized_graph_merge = run_merge_cli([ + 'merge', + '--base-db', $serialized_graph_base_db, + '--source-db', $serialized_graph_source_db, + '--target-db', $serialized_graph_target_db, + '--metadata-db', $serialized_graph_metadata, + '--source', 'feature-serialized-graph-validator', + '--target', 'main', + '--base-files', $serialized_graph_file_base, + '--source-root', $serialized_graph_source_root, + '--target-root', $serialized_graph_target_root, + ]); + assert_same($serialized_graph_merge['status'], 0, 'serialized plugin graph validator completes the merge with review conflicts'); + assert_true(str_contains($serialized_graph_merge['output'], 'plugins: validators=1 conflicts=2'), 'serialized plugin graph validator reports plugin conflicts'); + assert_same( + scalar($serialized_graph_target_db, 'SELECT graph_serialized FROM plugin_serialized_graph_parent WHERE parent_id = 1'), + serialize(['child_id' => 9999, 'file_path' => 'wp-content/uploads/serialized-missing.dat']), + 'serialized plugin graph validator keeps the staged source serialized graph for review' + ); + assert_same( + scalar($serialized_graph_metadata, "SELECT status FROM merge_runs WHERE source_branch = 'feature-serialized-graph-validator' ORDER BY id DESC LIMIT 1"), + 'completed_with_conflicts', + 'serialized plugin graph validator marks the merge run conflicted' + ); + $serialized_graph_audit = cow_merge_audit_report($serialized_graph_metadata, (int)scalar($serialized_graph_metadata, "SELECT id FROM merge_runs WHERE source_branch = 'feature-serialized-graph-validator' ORDER BY id DESC LIMIT 1"), 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + ]); + assert_same(count($serialized_graph_audit['conflicts']), 2, 'serialized plugin graph validator records plugin-scoped audit conflicts for missing rows and files'); + $serialized_graph_preview = implode("\n", array_map(fn($conflict) => (string)($conflict['chosen_preview'] ?? ''), $serialized_graph_audit['conflicts'])); + assert_true(str_contains($serialized_graph_preview, '"missing_child_id":9999'), 'serialized plugin graph validator exposes the missing child ID'); + assert_true(str_contains($serialized_graph_preview, 'graph_serialized.child_id'), 'serialized plugin graph validator exposes the serialized child field path'); + assert_true(str_contains($serialized_graph_preview, 'serialized-missing.dat'), 'serialized plugin graph validator exposes the missing file path'); + assert_true(str_contains($serialized_graph_preview, 'graph_serialized.file_path'), 'serialized plugin graph validator exposes the serialized file field path'); + + $graph_conflict_base_root = $tmp . '/graph-validator-conflict-base'; + $graph_conflict_source_root = $tmp . '/graph-validator-conflict-source'; + $graph_conflict_target_root = $tmp . '/graph-validator-conflict-target'; + foreach ([$graph_conflict_base_root, $graph_conflict_source_root, $graph_conflict_target_root] as $root) { + mkdir($root . '/wp-content/database', 0777, true); + mkdir($root . '/wp-content/uploads', 0777, true); + } + $graph_conflict_base_db = $graph_conflict_base_root . '/wp-content/database/.ht.sqlite'; + $graph_conflict_source_db = $graph_conflict_source_root . '/wp-content/database/.ht.sqlite'; + $graph_conflict_target_db = $graph_conflict_target_root . '/wp-content/database/.ht.sqlite'; + $graph_conflict_metadata = $tmp . '/.forkpress/cow/merge/graph-validator-conflict-metadata.sqlite'; + $graph_conflict_file_base = $tmp . '/.forkpress/cow/merge/file-bases/graph-validator-conflict.json'; + create_base_db($graph_conflict_base_db); + $db = open_db($graph_conflict_base_db); + $db->exec('CREATE TABLE plugin_graph_conflict_parent (parent_key TEXT PRIMARY KEY, graph_json TEXT)'); + $db->exec('CREATE TABLE plugin_graph_conflict_child (child_key TEXT PRIMARY KEY, label TEXT)'); + $db->exec("INSERT INTO plugin_graph_conflict_child (child_key, label) VALUES ('shared-child', 'base shared child')"); + $db->close(); + copy($graph_conflict_base_db, $graph_conflict_source_db); + copy($graph_conflict_base_db, $graph_conflict_target_db); + cow_merge_capture_file_base($graph_conflict_base_root, $graph_conflict_file_base); + $db = open_db($graph_conflict_source_db); + $db->exec("INSERT INTO plugin_graph_conflict_parent (parent_key, graph_json) VALUES ('source-parent', '" . SQLite3::escapeString(json_encode(['child_key' => 'shared-child'], JSON_UNESCAPED_SLASHES)) . "')"); + $db->close(); + $db = open_db($graph_conflict_target_db); + $db->exec("UPDATE plugin_graph_conflict_child SET label = 'target-exclusive child' WHERE child_key = 'shared-child'"); + $db->exec( + "INSERT INTO wp_options (option_name, option_value, autoload) VALUES ('active_plugins', '" . + SQLite3::escapeString(serialize(['graph-conflict/graph-conflict.php'])) . + "', 'yes')" + ); + $db->close(); + write_test_file($graph_conflict_target_root . '/wp-content/plugins/graph-conflict/forkpress-merge-validator.php', <<<'PHP' +query('SELECT parent_key, graph_json FROM plugin_graph_conflict_parent ORDER BY parent_key'); +$findings = []; +while ($row = $res->fetchArray(SQLITE3_ASSOC)) { + $graph = json_decode((string)$row['graph_json'], true); + $child_key = is_array($graph) ? (string)($graph['child_key'] ?? '') : ''; + if ($child_key === '') { + continue; + } + $stmt = $db->prepare('SELECT label FROM plugin_graph_conflict_child WHERE child_key = :child_key'); + $stmt->bindValue(':child_key', $child_key, SQLITE3_TEXT); + $child = $stmt->execute()->fetchArray(SQLITE3_ASSOC); + if (is_array($child) && str_starts_with((string)$child['label'], 'target-exclusive')) { + $findings[] = [ + 'plugin' => 'forkpress-graph-conflict', + 'object' => 'parent:' . $row['parent_key'], + 'reason' => 'source graph references a target-exclusive child row', + 'type' => 'plugin-graph-target-conflict', + 'tables' => ['plugin_graph_conflict_parent', 'plugin_graph_conflict_child'], + 'validator' => 'forkpress-graph-conflict@1', + 'candidate' => [ + 'parent_key' => $row['parent_key'], + 'child_key' => $child_key, + 'child_label' => $child['label'], + ], + ]; + } +} +echo json_encode([ + 'status' => $findings ? 'conflicts' : 'valid', + 'findings' => $findings, +], JSON_UNESCAPED_SLASHES); +PHP); + $graph_conflict_merge = run_merge_cli([ + 'merge', + '--base-db', $graph_conflict_base_db, + '--source-db', $graph_conflict_source_db, + '--target-db', $graph_conflict_target_db, + '--metadata-db', $graph_conflict_metadata, + '--source', 'feature-graph-conflict', + '--target', 'main', + '--base-files', $graph_conflict_file_base, + '--source-root', $graph_conflict_source_root, + '--target-root', $graph_conflict_target_root, + ]); + assert_same($graph_conflict_merge['status'], 0, 'target-conflicting plugin graph validator completes the merge with review conflicts'); + assert_true(str_contains($graph_conflict_merge['output'], 'plugins: validators=1 conflicts=1'), 'target-conflicting plugin graph validator reports a plugin conflict'); + assert_same( + scalar($graph_conflict_target_db, "SELECT graph_json FROM plugin_graph_conflict_parent WHERE parent_key = 'source-parent'"), + json_encode(['child_key' => 'shared-child'], JSON_UNESCAPED_SLASHES), + 'target-conflicting plugin graph validator keeps the staged source graph for review' + ); + assert_same( + scalar($graph_conflict_target_db, "SELECT label FROM plugin_graph_conflict_child WHERE child_key = 'shared-child'"), + 'target-exclusive child', + 'target-conflicting plugin graph validator preserves target plugin child state' + ); + assert_same( + scalar($graph_conflict_metadata, "SELECT status FROM merge_runs WHERE source_branch = 'feature-graph-conflict' ORDER BY id DESC LIMIT 1"), + 'completed_with_conflicts', + 'target-conflicting plugin graph validator marks the merge run conflicted' + ); + $graph_conflict_audit = cow_merge_audit_report($graph_conflict_metadata, (int)scalar($graph_conflict_metadata, "SELECT id FROM merge_runs WHERE source_branch = 'feature-graph-conflict' ORDER BY id DESC LIMIT 1"), 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + 'conflict_type' => 'plugin-graph-target-conflict', + ]); + assert_same(count($graph_conflict_audit['conflicts']), 1, 'target-conflicting plugin graph validator records a plugin-scoped audit conflict'); + assert_true(str_contains($graph_conflict_audit['conflicts'][0]['chosen_preview'], 'target-exclusive child'), 'target-conflicting plugin graph validator exposes target conflict context'); + + $inline_validator_base_root = $tmp . '/inline-validator-base'; + $inline_validator_source_root = $tmp . '/inline-validator-source'; + $inline_validator_target_root = $tmp . '/inline-validator-target'; + foreach ([$inline_validator_base_root, $inline_validator_source_root, $inline_validator_target_root] as $root) { + mkdir($root . '/wp-content/database', 0777, true); + mkdir($root . '/wp-content/uploads', 0777, true); + } + $inline_validator_base_db = $inline_validator_base_root . '/wp-content/database/.ht.sqlite'; + $inline_validator_source_db = $inline_validator_source_root . '/wp-content/database/.ht.sqlite'; + $inline_validator_target_db = $inline_validator_target_root . '/wp-content/database/.ht.sqlite'; + $inline_validator_metadata = $tmp . '/.forkpress/cow/merge/inline-validator-metadata.sqlite'; + $inline_validator_file_base = $tmp . '/.forkpress/cow/merge/file-bases/inline-validator.json'; + create_base_db($inline_validator_base_db); + copy($inline_validator_base_db, $inline_validator_source_db); + copy($inline_validator_base_db, $inline_validator_target_db); + cow_merge_capture_file_base($inline_validator_base_root, $inline_validator_file_base); + $db = open_db($inline_validator_source_db); + $db->exec("UPDATE wp_posts SET post_content = 'source inline validator content' WHERE ID = 1"); + $db->close(); + write_test_file($inline_validator_source_root . '/wp-content/uploads/inline-validator.txt', "inline validator file\n"); + $inline_validator_runner = $tmp . '/inline-plugin-validator.php'; + write_test_file($inline_validator_runner, <<<'PHP' + 'conflicts', + 'findings' => [ + [ + 'plugin' => 'forkpress-inline-validator', + 'object' => 'file:' . $relative, + 'reason' => 'inline validator inspected the staged candidate before merge completion', + 'type' => 'plugin-inline-validator-conflict', + 'files' => [$relative], + 'validator' => 'forkpress-inline-validator@1', + 'candidate' => [ + 'target_file' => trim((string)file_get_contents($target_file)), + ], + ], + ], +], JSON_UNESCAPED_SLASHES); +PHP); + $inline_validator_merge = run_merge_cli([ + 'merge', + '--base-db', $inline_validator_base_db, + '--source-db', $inline_validator_source_db, + '--target-db', $inline_validator_target_db, + '--metadata-db', $inline_validator_metadata, + '--source', 'feature-inline-validator', + '--target', 'main', + '--base-files', $inline_validator_file_base, + '--source-root', $inline_validator_source_root, + '--target-root', $inline_validator_target_root, + '--plugin-validator', $inline_validator_runner, + ]); + assert_same($inline_validator_merge['status'], 0, 'inline plugin validator runs during merge'); + assert_true(str_contains($inline_validator_merge['output'], 'plugins: validators=1 conflicts=1'), 'inline plugin validator summary reports conflicts'); + assert_same(scalar($inline_validator_target_db, 'SELECT post_content FROM wp_posts WHERE ID = 1'), 'source inline validator content', 'inline validator conflict keeps the staged DB candidate'); + assert_same(file_get_contents($inline_validator_target_root . '/wp-content/uploads/inline-validator.txt'), "inline validator file\n", 'inline validator conflict keeps the staged file candidate'); + assert_same( + scalar($inline_validator_metadata, "SELECT status FROM merge_runs WHERE source_branch = 'feature-inline-validator' ORDER BY id DESC LIMIT 1"), + 'completed_with_conflicts', + 'inline validator conflict marks the merge run conflicted before completion' + ); + assert_same( + (int)scalar($inline_validator_metadata, "SELECT COUNT(*) FROM merge_conflicts WHERE table_name = '__plugins__' AND conflict_type = 'plugin-inline-validator-conflict'"), + 1, + 'inline validator records plugin-scoped conflicts during merge' + ); + + $inline_validator_failure_base_root = $tmp . '/inline-validator-failure-base'; + $inline_validator_failure_source_root = $tmp . '/inline-validator-failure-source'; + $inline_validator_failure_target_root = $tmp . '/inline-validator-failure-target'; + foreach ([$inline_validator_failure_base_root, $inline_validator_failure_source_root, $inline_validator_failure_target_root] as $root) { + mkdir($root . '/wp-content/database', 0777, true); + mkdir($root . '/wp-content/uploads', 0777, true); + } + $inline_validator_failure_base_db = $inline_validator_failure_base_root . '/wp-content/database/.ht.sqlite'; + $inline_validator_failure_source_db = $inline_validator_failure_source_root . '/wp-content/database/.ht.sqlite'; + $inline_validator_failure_target_db = $inline_validator_failure_target_root . '/wp-content/database/.ht.sqlite'; + $inline_validator_failure_metadata = $tmp . '/.forkpress/cow/merge/inline-validator-failure-metadata.sqlite'; + $inline_validator_failure_file_base = $tmp . '/.forkpress/cow/merge/file-bases/inline-validator-failure.json'; + create_base_db($inline_validator_failure_base_db); + copy($inline_validator_failure_base_db, $inline_validator_failure_source_db); + copy($inline_validator_failure_base_db, $inline_validator_failure_target_db); + cow_merge_capture_file_base($inline_validator_failure_base_root, $inline_validator_failure_file_base); + $db = open_db($inline_validator_failure_source_db); + $db->exec("UPDATE wp_posts SET post_content = 'source failed validator content' WHERE ID = 1"); + $db->close(); + write_test_file($inline_validator_failure_source_root . '/wp-content/uploads/inline-validator-failure.txt', "failed validator file\n"); + $inline_validator_failure_runner = $tmp . '/inline-plugin-validator-failure.php'; + write_test_file($inline_validator_failure_runner, <<<'PHP' + 'failed', + 'reason' => 'plugin coherence check could not inspect required generated assets', + 'findings' => [], +], JSON_UNESCAPED_SLASHES); +PHP); + $inline_validator_failed_merge = run_merge_cli([ + 'merge', + '--base-db', $inline_validator_failure_base_db, + '--source-db', $inline_validator_failure_source_db, + '--target-db', $inline_validator_failure_target_db, + '--metadata-db', $inline_validator_failure_metadata, + '--source', 'feature-inline-validator-failed', + '--target', 'main', + '--base-files', $inline_validator_failure_file_base, + '--source-root', $inline_validator_failure_source_root, + '--target-root', $inline_validator_failure_target_root, + '--plugin-validator', $inline_validator_failure_runner, + ]); + assert_true($inline_validator_failed_merge['status'] !== 0, 'failed inline plugin validator aborts the merge'); + assert_true(str_contains($inline_validator_failed_merge['output'], 'plugin coherence check could not inspect required generated assets'), 'failed inline plugin validator reports its failure reason'); + assert_same(scalar($inline_validator_failure_target_db, 'SELECT post_content FROM wp_posts WHERE ID = 1'), 'Base content', 'failed inline plugin validator rolls back staged DB changes'); + assert_true(!file_exists($inline_validator_failure_target_root . '/wp-content/uploads/inline-validator-failure.txt'), 'failed inline plugin validator rolls back staged file changes'); + assert_same( + (int)scalar($inline_validator_failure_metadata, "SELECT COUNT(*) FROM merge_runs WHERE source_branch = 'feature-inline-validator-failed' AND status = 'failed' AND failure_reason LIKE '%plugin coherence check could not inspect required generated assets%'"), + 1, + 'failed inline plugin validator leaves an auditable failed run after rollback' + ); + + $plugin_validator_runner_failure = $tmp . '/plugin-validator-runner-failure.php'; + write_test_file($plugin_validator_runner_failure, <<<'PHP' +/dev/null + +branchfs_header_log="$(mktemp "${TMPDIR:-/tmp}/forkpress-branchfs-header-preflight.XXXXXX.log")" +fake_bin="$(mktemp -d "${TMPDIR:-/tmp}/forkpress-build-dist-preflight-bin.XXXXXX")" +build_dir="$(mktemp -d "${TMPDIR:-/tmp}/forkpress-build-dist-preflight-build.XXXXXX")" +dist_dir="$(mktemp -d "${TMPDIR:-/tmp}/forkpress-build-dist-preflight-dist.XXXXXX")" +out_file="$(mktemp "${TMPDIR:-/tmp}/forkpress-build-dist-preflight.XXXXXX.log")" +trap 'rm -rf "$fake_bin" "$build_dir" "$dist_dir" "$out_file" "$branchfs_header_log"' EXIT +chmod 755 "$fake_bin" "$build_dir" "$dist_dir" + +set +e +make -n test-branchfs PHP_CONFIG= PHP_DEV_DIR= PKG_CONFIG= > "$branchfs_header_log" 2>&1 +branchfs_header_status=$? +set -e +if [ "$branchfs_header_status" -eq 0 ]; then + echo "expected branchfs targets to fail when PHP headers are missing" >&2 + cat "$branchfs_header_log" >&2 + exit 1 +fi +grep -q 'Could not determine PHP headers' "$branchfs_header_log" + +for cmd in bash dirname uname mkdir; do + ln -s "$(command -v "$cmd")" "$fake_bin/$cmd" +done + +for cmd in git composer php re2c automake bison; do + printf '#!/usr/bin/env sh\nexit 0\n' > "$fake_bin/$cmd" + chmod 755 "$fake_bin/$cmd" +done + +set +e +PATH="$fake_bin" \ +FORKPRESS_BUILD_DIR="$build_dir" \ +FORKPRESS_DIST_DIR="$dist_dir" \ + scripts/build-dist.sh > "$out_file" 2>&1 +status=$? +set -e + +if [ "$status" -eq 0 ]; then + echo "expected build-dist preflight to fail when pkg-config is missing" >&2 + cat "$out_file" >&2 + exit 1 +fi + +grep -q 'missing static PHP build tools: pkg-config' "$out_file" +grep -q 'Refusing to let static-php-cli auto-install prerequisites' "$out_file" + +grep -q 'TRIPLE" = "aarch64-apple-darwin"' scripts/build-dist.sh +grep -q 'arch -arm64 /usr/bin/true' scripts/build-dist.sh +grep -q 'SPC_RUN_UNDER_ARM64=1' scripts/build-dist.sh +grep -q 'arch -arm64 ./bin/spc "$@"' scripts/build-dist.sh +if grep -q 'SPC_RUN\[@\]' scripts/build-dist.sh; then + echo "build-dist must not expand an empty bash array under macOS bash with set -u" >&2 + exit 1 +fi + +echo "build-dist preflight checks passed" diff --git a/wp-plugin/forkpress-wp.php b/wp-plugin/forkpress-wp.php index 78d6bff5..0c73c182 100644 --- a/wp-plugin/forkpress-wp.php +++ b/wp-plugin/forkpress-wp.php @@ -475,7 +475,11 @@ function forkpress_local_branches(string $current_branch): array { } function forkpress_branch_name_is_valid(string $branch): bool { - return (bool) preg_match('/^[a-zA-Z0-9_\-]{1,63}$/', $branch); + if (!preg_match('/^[a-zA-Z0-9_\-]{1,63}$/', $branch)) { + return false; + } + + return !in_array(strtolower($branch), ['www', 'admin', 'api', 'mail', 'localhost', 'wp'], true); } function forkpress_branch_post_value(string $key): string {