Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,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 test-cow-branch-birth test-cow-branch-ui test-cow-changed test-cow-e2e-git-existing-update-crash test-cow-e2e-remote-cache test-cow-e2e-semantic test-cow-explicit-ids test-cow-fast test-cow-filesystem test-cow-git-server test-cow-id-bands test-cow-media-validator test-cow-merge test-cow-merge-smoke test-cow-plugin-validator test-cow-schema-review test-cow-semantic-fast test-cow-stale-audit test-cow-wp-semantic-validator test-branch-cli-fast test-release init-db test-all forkpress forkpress-dev dist dist-dev
.PHONY: all clean test test-compat test-branchfs test-cow test-cow-branch-birth test-cow-branch-ui test-cow-changed test-cow-e2e-git-existing-update-crash test-cow-e2e-remote-cache test-cow-e2e-semantic test-cow-explicit-ids test-cow-fast test-cow-filesystem test-cow-git-server test-cow-id-bands test-cow-media-validator test-cow-merge test-cow-merge-smoke test-cow-mysql-import test-cow-plugin-validator test-cow-schema-review test-cow-semantic-fast test-cow-stale-audit test-cow-wp-semantic-validator test-branch-cli-fast test-release init-db test-all forkpress forkpress-dev dist dist-dev

all: $(BRANCHFS_EXT_SO)

Expand Down Expand Up @@ -114,6 +114,9 @@ test-cow-changed:
test-cow-merge: test-cow-merge-smoke
php $(COW_TEST_DIR)/merge.php

test-cow-mysql-import:
php $(COW_TEST_DIR)/mysql_import.php

test-cow-git-server:
php $(COW_TEST_DIR)/git_server.php

Expand Down Expand Up @@ -170,7 +173,7 @@ test-cow-semantic-fast: test-cow-merge-smoke
php $(COW_TEST_DIR)/stale_audit.php
php $(COW_TEST_DIR)/wp_semantic_validator.php

test-cow-fast: test-cow-git-server test-cow-merge-smoke
test-cow-fast: test-cow-git-server test-cow-merge-smoke test-cow-mysql-import
php $(COW_TEST_DIR)/branch_birth.php
php $(COW_TEST_DIR)/explicit_ids.php
php $(COW_TEST_DIR)/filesystem.php
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,10 @@ forkpress remote clone production \
```

The first sync is thin by default: uploads, caches, backups, logs, and upgrade
temp files are skipped so the branch can boot quickly. Use `--include-uploads`
or `--full-sync` when you need more of the remote tree locally.
temp files are skipped so the branch can boot quickly. ForkPress can branch from
an existing ForkPress SQLite sidecar or import a normal MySQL-backed WordPress
database over the same SSH connection. Use `--include-uploads` or `--full-sync`
when you need more of the remote tree locally.

Install plugins from the branch's WordPress admin at
`/wp-admin/plugin-install.php`. ForkPress tracks the WordPress.org top 100
Expand Down Expand Up @@ -480,7 +482,7 @@ Useful log files:
| `forkpress agents [dir]` | Create agent branches and Git worktrees. |
| `forkpress commit -m "message"` | Commit and push the current Git branch back to ForkPress. |
| `forkpress pull` | Pull with rebase and autostash. |
| `forkpress remote clone <name> --ssh <host> --path <wp-root> --branch <branch>` | Thin-clone a boot-ready remote WordPress root over SSH, skipping uploads, caches, backups, logs, and upgrade temp files by default, then create a local COW branch. |
| `forkpress remote clone <name> --ssh <host> --path <wp-root> --branch <branch>` | Thin-clone a boot-ready remote WordPress root over SSH, import MySQL into SQLite when needed, then create a local COW branch. |
| `forkpress logs --file <name>` | Read WordPress, PHP, server, and maintenance logs. |
| `forkpress storage status` | Show selected storage and mount state. |
| `forkpress storage mount` | Attach mount-backed storage. |
Expand Down
4 changes: 4 additions & 0 deletions crates/forkpress-cli/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ fn main() -> Result<()> {
"runtime/wp.zip",
"scripts/cow/git_server.php",
"scripts/cow/merge.php",
"scripts/cow/mysql_export.php",
"scripts/cow/mysql_import_sqlite.php",
"scripts/git/autoload.php",
"scripts/shared/sqlite_backup.php",
"scripts/shared/sqlite_retry.php",
Expand Down Expand Up @@ -466,6 +468,8 @@ fn build_bundle(
} else {
add_file(&mut tar, repo_root, "scripts/cow/git_server.php")?;
add_file(&mut tar, repo_root, "scripts/cow/merge.php")?;
add_file(&mut tar, repo_root, "scripts/cow/mysql_export.php")?;
add_file(&mut tar, repo_root, "scripts/cow/mysql_import_sqlite.php")?;
add_file(&mut tar, repo_root, "scripts/git/autoload.php")?;
add_file(&mut tar, repo_root, "scripts/shared/sqlite_backup.php")?;
add_file(&mut tar, repo_root, "scripts/shared/sqlite_retry.php")?;
Expand Down
133 changes: 132 additions & 1 deletion crates/forkpress-cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ use forkpress_git::{
};
#[cfg(feature = "dev-experiments")]
use forkpress_runtime::run_php_script;
#[cfg(feature = "dev-experiments")]
use forkpress_runtime::write_filtered_output;
use forkpress_runtime::{
PortableRuntime, php_base_command, prepare_runtime as prepare_embedded_runtime,
Expand Down Expand Up @@ -2847,6 +2846,16 @@ fn remote_clone_command(
cache_root.display()
);
}
if !cache_root.join("wp-content/database/.ht.sqlite").is_file()
&& cache_root.join("wp-config.php").is_file()
{
prepare_runtime(layout)?;
let runtime = PortableRuntime::from_layout(layout);
remote_clone_import_mysql_database(shared, layout, &runtime, &args, &cache_root)
.with_context(
|| "remote site did not include ForkPress SQLite data and MySQL import failed",
)?;
}

let manifest = add_remote_site(
layout,
Expand Down Expand Up @@ -2925,6 +2934,90 @@ fn remote_clone_cache_root(layout: &Layout, name: &str) -> Result<PathBuf> {
Ok(layout.cow_dir.join("remote-sites").join(name).join("cache"))
}

fn remote_clone_import_mysql_database(
shared: &SharedPaths,
layout: &Layout,
runtime: &PortableRuntime,
args: &RemoteCloneArgs,
cache_root: &Path,
) -> Result<()> {
let export_path = cache_root
.parent()
.ok_or_else(|| anyhow!("failed to resolve remote cache parent"))?
.join("mysql-export.jsonl");
let export_file = File::create(&export_path)
.with_context(|| format!("failed to create {}", export_path.display()))?;
let exporter = fs::read_to_string(layout.runtime_dir.join("scripts/cow/mysql_export.php"))
.context("failed to read bundled MySQL export helper")?;

let mut command = remote_clone_ssh_command(args);
command
.arg(&args.ssh)
.arg(format!("php -- {}", shell_quote(&args.remote_path)))
.stdin(Stdio::piped())
.stdout(Stdio::from(export_file))
.stderr(Stdio::piped());
let mut child = command
.spawn()
.context("failed to start ssh for remote MySQL export")?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(exporter.as_bytes())
.context("failed to send MySQL export helper over ssh")?;
}
let output = child
.wait_with_output()
.context("failed to wait for remote MySQL export")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!(
"remote MySQL export failed with status {}{}{}",
output.status,
if stderr.trim().is_empty() { "" } else { ": " },
stderr.trim()
);
}
let metadata = fs::metadata(&export_path)
.with_context(|| format!("failed to stat {}", export_path.display()))?;
if metadata.len() == 0 {
bail!("remote MySQL export produced no data");
}

let db_path = cache_root.join("wp-content/database/.ht.sqlite");
let mut import = php_base_command(layout, runtime, shared);
import
.arg(
layout
.runtime_dir
.join("scripts/cow/mysql_import_sqlite.php"),
)
.arg(&export_path)
.arg(&db_path);
let output = import
.output()
.context("failed to run bundled MySQL-to-SQLite importer")?;
write_filtered_output(&output.stdout, &output.stderr)?;
if !output.status.success() {
bail!(
"MySQL-to-SQLite importer exited with status {}",
output.status
);
}
let _ = fs::remove_file(&export_path);
Ok(())
}

fn remote_clone_ssh_command(args: &RemoteCloneArgs) -> Command {
let mut command = Command::new("ssh");
if let Some(key) = &args.ssh_key {
command.arg("-i").arg(key);
}
if let Some(port) = args.ssh_port {
command.arg("-p").arg(port.to_string());
}
command
}

fn remote_clone_rsync_source(ssh: &str, remote_path: &str) -> String {
let mut path = remote_path.trim_end_matches('/').to_string();
path.push('/');
Expand Down Expand Up @@ -8326,6 +8419,44 @@ mod git_helper_tests {
assert_eq!(remote_clone_rsync_ssh_command(&clone), None);
}

#[test]
fn remote_clone_mysql_export_ssh_reuses_credentials() {
let clone = RemoteCloneArgs {
name: "production".to_string(),
ssh: "deploy@example.com".to_string(),
ssh_key: Some(PathBuf::from("/Users/alex/.ssh/forkpress id")),
ssh_port: Some(2222),
remote_path: "/srv/www/example with spaces".to_string(),
branch: None,
remote_url: None,
local_url: None,
include_uploads: false,
full_sync: false,
excludes: Vec::new(),
no_delete: false,
force: false,
};
let mut command = remote_clone_ssh_command(&clone);
command
.arg(&clone.ssh)
.arg(format!("php -- {}", shell_quote(&clone.remote_path)));
let args: Vec<String> = command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
assert_eq!(
args,
vec![
"-i",
"/Users/alex/.ssh/forkpress id",
"-p",
"2222",
"deploy@example.com",
"php -- '/srv/www/example with spaces'",
]
);
}

#[test]
fn registry_fields_round_trip_control_chars() {
let value = "dir\\with\ttabs\nand\rreturns";
Expand Down
2 changes: 1 addition & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ audit queues, stale reviews, and source/target/apply-reviewed choices, see
| `forkpress commit -m "message"` | Commit and push the current Git branch back to ForkPress. |
| `forkpress pull` | Pull with rebase and autostash. |
| `forkpress agents [dir]` | Create agent branches and Git worktrees. |
| `forkpress remote clone <name> --ssh <host> --ssh-key <key> --ssh-port <port> --path <wp-root> --url <url> --branch <branch>` | Thin-clone a boot-ready remote WordPress root over SSH, skipping uploads, caches, backups, logs, and upgrade temp files by default, then create a local COW branch. |
| `forkpress remote clone <name> --ssh <host> --ssh-key <key> --ssh-port <port> --path <wp-root> --url <url> --branch <branch>` | Thin-clone a boot-ready remote WordPress root over SSH, import MySQL into the local SQLite sidecar when needed, then create a local COW branch. |
| `forkpress remote add <name> --cache-root <dir>` | Register an existing local remote-site cache. |
| `forkpress remote branch <name> <branch>` | Create a local COW branch from a registered remote cache. |

Expand Down
18 changes: 13 additions & 5 deletions docs/remote-sites.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,15 @@ forkpress remote clone production \
```

The remote path must be a materialized WordPress root. It needs `wp-load.php`
and, for COW branch creation, the ForkPress-compatible SQLite database at
`wp-content/database/.ht.sqlite`.
and `wp-config.php`.

If the remote site already has ForkPress's SQLite sidecar at
`wp-content/database/.ht.sqlite`, ForkPress uses it directly. If the remote
site is a normal MySQL-backed WordPress install, ForkPress reads the database
connection constants from `wp-config.php`, exports the WordPress tables over the
same SSH connection, and imports them into the local cache as
`wp-content/database/.ht.sqlite` before creating the branch. The remote PHP must
have `mysqli` enabled.

ForkPress uses `rsync` over SSH. By default it creates a boot cache and skips
large or rebuildable directories:
Expand Down Expand Up @@ -171,9 +178,10 @@ and plugin-specific semantic recipes, see

## Troubleshooting

If branch creation reports that the source branch database does not exist, the
remote cache is not a ForkPress-compatible SQLite WordPress root. Register or
sync a cache that includes `wp-content/database/.ht.sqlite`.
If MySQL import fails, confirm that remote PHP has `mysqli`, that `DB_NAME`,
`DB_USER`, `DB_PASSWORD`, `DB_HOST`, and `$table_prefix` are string literals in
`wp-config.php`, and that the SSH user can connect to the site's MySQL database
from the remote host.

If `rsync` fails, verify the SSH target, key, port, and remote path outside
ForkPress first:
Expand Down
Loading
Loading