Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
cf091e5
Add canonical_root_path() for configured roots
gavande1 Aug 12, 2026
af4f7e6
Accept a non-directory directory[] entry
gavande1 Aug 12, 2026
724b61c
Trim the handler-restore comment
gavande1 Aug 12, 2026
68a6289
Extract the file-index entry builder
gavande1 Aug 12, 2026
3794ff8
Index a file path named as a file-index root
gavande1 Aug 12, 2026
b75ef2b
Cover file-path roots at the file-index endpoint
gavande1 Aug 12, 2026
88fdf37
Document and cover --only with a file path
gavande1 Aug 12, 2026
460ae03
Trim comments to the load-bearing ones
gavande1 Aug 13, 2026
a15a5bb
Omit and bound named file-index roots
gavande1 Aug 13, 2026
e78f69b
Trim comments to one line each
gavande1 Aug 13, 2026
e6d4fe6
Refactor file-index roots around structured records
fredrikekelund Aug 17, 2026
5476260
Support named file roots in files pull
fredrikekelund Aug 17, 2026
83981f6
Clarify named file-index roots
fredrikekelund Aug 17, 2026
4cb6a04
Fix named-root pull review findings
fredrikekelund Aug 17, 2026
7306337
Clarify file-index root traversal
fredrikekelund Aug 17, 2026
37699c4
Clarify no-follow symlink selection
fredrikekelund Aug 17, 2026
93ca3d2
[Pull] Cover selected directory symlink roots
fredrikekelund Aug 17, 2026
fe47b61
[Pull] Make selected symlink E2E setup idempotent
fredrikekelund Aug 17, 2026
279b1f8
[Pull] Clarify empty directory index comment
fredrikekelund Aug 17, 2026
3e3cad5
[Pull] Clarify file-index root scheduling
fredrikekelund Aug 17, 2026
838cdda
[Pull] Read selected symlink directory content
fredrikekelund Aug 17, 2026
e194b67
Fix mixed-version exporter utility loading
fredrikekelund Aug 17, 2026
6b0e165
Revert "Fix mixed-version exporter utility loading"
fredrikekelund Aug 18, 2026
849ffd2
Merge origin/trunk into pr-564
fredrikekelund Aug 19, 2026
3f6fb78
Fix CLI argument count static analysis
fredrikekelund Aug 19, 2026
5a444bc
Clarify file-index root handling
fredrikekelund Aug 19, 2026
37cb852
Fix E2E site port collisions
fredrikekelund Aug 19, 2026
bf953a5
Simplify missing file-index root handling
fredrikekelund Aug 20, 2026
2aeb4a0
[Pull] Stop deletions climbing outside the indexed scope
fredrikekelund Aug 20, 2026
cd87a71
Fix E2E collisions with new trunk sites
fredrikekelund Aug 20, 2026
c72242a
[Pull] Name the pulled-before paths for what they assert
fredrikekelund Aug 20, 2026
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,12 @@ absolute source paths, and exclusions win when the prefixes overlap. Switching
filters after a completed run starts a new filtered delta against the shared
remote index; there is no separate skipped-file list or fetch stage.

Symlinks are followed by default. With `--no-follow-symlinks`, a selected
symlink is copied as a link without indexing its target. A selected path reached
through a symlinked parent is rejected; use the default or
`--follow-symlinks` so Reprint can preserve the requested link path and index
the physical target.

#### Pull only files.

`pull-files` runs the file side of the high-level pull pipeline:
Expand Down
221 changes: 180 additions & 41 deletions packages/reprint-client/src/import.php

Large diffs are not rendered by default.

16 changes: 13 additions & 3 deletions packages/reprint-client/src/lib/push/class-push-plan.php
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,14 @@ public static function start(
if (!is_resource($plan->fresh_local_index_handle)) {
throw new RuntimeException("Failed to open the fresh local index: {$plan->fresh_local_index_file}");
}
$filesystem_root_record = [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we declare a new class to represent this data type? Not a blocker.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered this, mainly for type safety. I decided not to because there are guidelines in AGENTS.md that say this:

Inline single-use helpers; no wrapper classes that only rename a concept. A "transport" callable, a plan-then-send request object, and a processor that materialized a file list were all deleted from this repo. A helper earns its name at two or more callers.

For type safety, we use a PHPDoc FileIndexRoot type that PHPStan checks in the composer analyze script. We can revisit this if we want, but the current approach is OK, IMO.

"requested_path" => $plan->filesystem_root,
"resolved_path" => $plan->filesystem_root,
"type" => "directory",
];
$plan->file_index_processor = FileIndexProcessor::start(
[$plan->filesystem_root],
$plan->filesystem_root,
[$filesystem_root_record],
$filesystem_root_record,
false,
false,
$plan->plan_directory
Expand Down Expand Up @@ -363,8 +368,13 @@ private function open_fresh_local_index_for_continuation(): void
if (fseek($this->fresh_local_index_handle, $cursor["fresh_local_index_byte_offset"]) !== 0) {
throw new RuntimeException("Failed to seek to the fresh local index byte offset.");
}
$filesystem_root_record = [
"requested_path" => $this->filesystem_root,
"resolved_path" => $this->filesystem_root,
"type" => "directory",
];
$this->file_index_processor = FileIndexProcessor::resume(
[$this->filesystem_root],
[$filesystem_root_record],
json_encode($cursor["file_index_cursor"], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
false,
false,
Expand Down
557 changes: 411 additions & 146 deletions packages/reprint-server/src/class-file-index-processor.php

Large diffs are not rendered by default.

195 changes: 186 additions & 9 deletions packages/reprint-server/src/export.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use function WordPress\Reprint\Server\build_pdo_dsn;
use function WordPress\Reprint\Server\generate_random_bytes;
use function WordPress\Reprint\Server\json_encode_or_throw;
use function WordPress\Reprint\Server\normalize_path;
use function WordPress\Reprint\Server\parse_size;
use function WordPress\Reprint\Server\path_is_same_as_or_descendant_of;
use function WordPress\Reprint\Server\trim_right_slash;
Expand Down Expand Up @@ -1220,7 +1221,7 @@ function endpoint_db_index(
}

/**
* Resolves directory paths from config.
* Resolves directory paths from config for operations which can walk only directories.
*/
function resolve_directories(array $config): array
{
Expand All @@ -1245,10 +1246,11 @@ function resolve_directories(array $config): array
$directory = trim($directory);
assert_valid_path($directory, "directory entry");

$real_directory = realpath($directory);
if ($real_directory === false) {
clearstatcache(true, $directory);
$real_directory = @realpath($directory);
if ($real_directory === false || !is_dir($real_directory)) {
throw new InvalidArgumentException(
"directory does not exist or is not accessible: {$directory}\n" .
"directory entry is not an accessible directory: {$directory}\n" .
"Current working directory: " .
getcwd() .
"\n" .
Expand All @@ -1267,6 +1269,176 @@ function resolve_directories(array $config): array
return $directories;
}

/**
* Builds file-index roots from the file_index request's `directory` parameter.
*
* The `directory` parameter contains the selected paths. In spite of the
* parameter name, a selected path may name a directory, regular file, or
* symlink.
*
* Unlike resolve_directories(), this keeps one file-index root for every
* selected path. `requested_path` is the normalized spelling supplied by the
* client. `resolved_path` is its realpath() target.
*
* FileIndexProcessor adds a link entry at `requested_path` to the file index.
* It walks a followed directory target at `resolved_path`. It also uses
* `resolved_path` so aliases to the same target are indexed once.
*
* Example: for /site/theme -> /shared/theme, this returns a file-index root
* with `requested_path` /site/theme, `resolved_path` /shared/theme, and type
* symlink. With symlink following enabled, it adds the link entry at
* /site/theme and indexes the target tree at /shared/theme.
*
* @return array[] {
* File-index roots.
*
* @type string $requested_path Normalized path supplied by the client.
* @type string|null $resolved_path realpath() target, when available.
* @type string $type directory, file, symlink, or missing.
* }
*/
function resolve_file_index_roots(array $config): array
{
$roots_input = $config["directory"] ?? null;
if (!$roots_input) {
throw new InvalidArgumentException("directory is required for files operation");
}

$roots = [];
foreach (is_array($roots_input) ? $roots_input : [$roots_input] as $root_input) {
if (!is_string($root_input)) {
throw new InvalidArgumentException("directory entries must be non-empty strings");
}
$root_input = trim($root_input);
assert_valid_path($root_input, "directory entry");
$requested_path = normalize_path($root_input);
clearstatcache(true, $requested_path);
$stat = @lstat($requested_path);
if ($stat === false) {
// The client sends `pulled_before` for selected paths an earlier pull
// already saw. Absence there means the source deleted the path, so it
// becomes a missing root instead of an error. Anything else absent is
// a bad path and still throws below.
$paths_pulled_before = isset($config["pulled_before"]) && is_array($config["pulled_before"])
? $config["pulled_before"]
: [];
if (in_array($requested_path, $paths_pulled_before, true)) {
$roots[] = [
"requested_path" => $requested_path,
"resolved_path" => null,
"type" => "missing",
];
continue;
}
throw new InvalidArgumentException(
"Selected file-index root does not exist or is not accessible: {$requested_path}"
);
}

$mode = $stat["mode"] & STAT_TYPE_MASK;
$type = $mode === STAT_TYPE_LINK ? "symlink" : ( is_dir($requested_path) ? "directory" : "file" );
$resolved_path = @realpath($requested_path);
if ($type === "symlink" && $resolved_path === false) {
throw new InvalidArgumentException("Selected file-index root is a broken symlink: {$requested_path}");
}
if ($resolved_path === false) {
throw new InvalidArgumentException(
"Selected file-index root does not exist or is not accessible: {$requested_path}"
);
}
if (empty($config["follow_symlinks"])) {
$parent_link = file_index_parent_symlink($requested_path);
if ($parent_link !== null) {
throw new InvalidArgumentException(
"Selected file-index root {$requested_path} is reached through parent symlink " .
"{$parent_link["path"]} targeting {$parent_link["target"]}; use --follow-symlinks."
);
}
}
$roots[] = [
"requested_path" => $requested_path,
"resolved_path" => $resolved_path,
"type" => $type,
];
}

return $roots;
}

/**
* Returns the file-index root for the file_index request's `list_dir` parameter.
*
* `list_dir` normally names a path from `directory[]`. When following symlinks,
* it may instead name a resolved directory found through a link below one of
* those selected paths.
*
* Example: `directory[]` contains /site. If indexing /site finds a link from
* /site/theme to /shared/theme, the client later requests
* `list_dir=/shared/theme`. This function returns a file-index root for
* /shared/theme even though it is not in `directory[]`.
*
* @param array[] $roots File-index roots returned by resolve_file_index_roots().
* @param string $list_directory Value sent as `list_dir`.
* @param bool $follow_symlinks Whether `list_dir` may name a directory reached through a link.
* @return array {
* File-index root for `list_dir`.
*
* @type string $requested_path Requested normalized root path.
* @type string|null $resolved_path Resolved root path, when available.
* @type string $type directory, file, symlink, or missing.
* }
*/
function resolve_file_index_start_root(
array $roots,
string $list_directory,
bool $follow_symlinks
): array {
$requested_path = normalize_path($list_directory);
foreach ($roots as $root) {
if ($root["requested_path"] === $requested_path) {
return $root;
}
}

if (!$follow_symlinks) {
throw new InvalidArgumentException(
"list_dir must name a selected root unless follow_symlinks is enabled: {$requested_path}"
);
}

$resolved_path = @realpath($requested_path);
if ($resolved_path === false || !is_dir($resolved_path)) {
throw new InvalidArgumentException(
"Followed symlink target directory does not exist or is not accessible: {$requested_path}"
);
}

return [
"requested_path" => $requested_path,
"resolved_path" => $resolved_path,
"type" => "directory",
];
}

/** Returns the first symlink in a requested root's parent path. */
function file_index_parent_symlink(string $requested_path): ?array

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There may be some existing helper that helps with this or a similar problem. I'm not 100% sure though.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I double-checked. My agent identified FileIndexProcessor::find_parent_symlinks() as a similar candidate, but they're not similar enough that it's clear-cut how to harmonize them. We could refactor this in the future, or leave as is.

{
$current = "/";
$parts = explode("/", trim(dirname($requested_path), "/"));
foreach ($parts as $part) {
if ($part === "") {
continue;
}
$current = wp_join_unix_paths($current, $part);
if (!@is_link($current)) {
continue;
}
$target = @readlink($current);
return ["path" => $current, "target" => $target === false ? "(unreadable)" : $target];
}
return null;
}

/**
* Returns lightweight preflight checks: filesystem accessibility, DB connectivity,
* and environment details useful for diagnostics.
Expand Down Expand Up @@ -2620,7 +2792,7 @@ function endpoint_file_index(
// requests so path type transitions (symlink/file/dir) are seen correctly.
clearstatcache(true);

$directories = resolve_directories($config);
$file_index_roots = resolve_file_index_roots($config);
$batch_size = require_int_range(
"batch_size",
(int) ($config["batch_size"] ?? 5000),
Expand All @@ -2635,7 +2807,7 @@ function endpoint_file_index(

if (isset($config["cursor"])) {
$file_index = FileIndexProcessor::resume(
$directories,
$file_index_roots,
$config["cursor"],
$follow_symlinks,
$include_caches,
Expand All @@ -2646,9 +2818,14 @@ function endpoint_file_index(
if (!is_string($list_directory) || $list_directory === "") {
throw new InvalidArgumentException("list_dir is required for file_index");
}
$file_index = FileIndexProcessor::start(
$directories,
$start_root = resolve_file_index_start_root(
$file_index_roots,
$list_directory,
$follow_symlinks
);
$file_index = FileIndexProcessor::start(
$file_index_roots,
$start_root,
$follow_symlinks,
$include_caches,
$storage_path
Expand All @@ -2660,7 +2837,7 @@ function endpoint_file_index(
}

$list_directory = $file_index->get_index_directory();
$filesystem_root = $directories[0] ?? "/";
$filesystem_root = $file_index_roots[0]["resolved_path"] ?? "/";

prepare_streaming_response();
['gz' => $gz, 'boundary' => $boundary] = begin_multipart_stream();
Expand Down
Loading
Loading