From fcf7c3ecc65f1a42cd995996e1a312ca7edf5380 Mon Sep 17 00:00:00 2001
From: Marco Nenciarini <marco.nenciarini@enterprisedb.com>
Date: Mon, 10 Aug 2026 16:37:58 +0200
Subject: [PATCH v1 3/3] Add pg_upgrade_replica, a standalone tool to rebuild
 standbys

Rebuilds a physical standby's data directory against an already
pg_upgrade'd, running primary, without a full re-clone and without
ssh or rsync access to either server's filesystem.

Reads pg_upgrade's own manifest (the preceding commit) with its own
small parser matching that manifest's own bespoke format, then forges
a new, real backup_manifest (using the shared writer the first commit
in this series extracted) listing the old standby's own copy of
everything pg_upgrade kept unchanged, anchored at the new cluster's
own post-restore checkpoint. Then drives pg_basebackup --incremental
and pg_combinebackup, located as version-matched sibling binaries, to
fetch only what actually changed on the new primary and assemble the
new standby's data directory from that plus the old standby's own
files.

pg_upgrade's manifest is only an inventory of candidates, not a claim
about what the old standby itself still has: an old primary's
unlogged relation transferred its main fork unchanged, but that fork
is never present on a caught-up standby. Every candidate is checked
against the old standby's own directory and simply dropped if it
genuinely isn't there, so pg_basebackup fetches it fresh instead of
trusting a stale or absent copy. What survives that check is trusted
outright once the old standby's own control data (read via a
version-matched pg_controldata, since its pg_control predates this
build) is confirmed to match the old primary's exact shutdown
checkpoint -- the same trust any stopped data directory gets when
starting recovery from it.

Includes TAP tests covering a same-version self-upgrade scenario (the
project's established way to exercise pg_upgrade without a second
major version installed), including in-place tablespace coverage and
a write to a reused relation made on the new primary before the sync
runs. Also manually verified against a real cross-major-version
upgrade (PostgreSQL 17.10 to this tree) with a real external
tablespace; that run is not reproducible from the patch alone, since
it needs a second major version built and installed.
---
 doc/src/sgml/ref/allfiles.sgml                |   1 +
 doc/src/sgml/ref/pg_upgrade_replica.sgml      | 466 +++++++++
 doc/src/sgml/ref/pgupgrade.sgml               |  10 +
 doc/src/sgml/reference.sgml                   |   1 +
 src/bin/Makefile                              |   1 +
 src/bin/meson.build                           |   1 +
 src/bin/pg_upgrade_replica/.gitignore         |   8 +
 src/bin/pg_upgrade_replica/Makefile           |  52 +
 src/bin/pg_upgrade_replica/fetch.c            | 184 ++++
 src/bin/pg_upgrade_replica/fetch.h            |  37 +
 src/bin/pg_upgrade_replica/forge_manifest.c   | 286 ++++++
 src/bin/pg_upgrade_replica/forge_manifest.h   |  38 +
 src/bin/pg_upgrade_replica/manifest.c         | 144 +++
 src/bin/pg_upgrade_replica/manifest.h         |  59 ++
 src/bin/pg_upgrade_replica/meson.build        |  35 +
 src/bin/pg_upgrade_replica/nls.mk             |  16 +
 .../pg_upgrade_replica/pg_upgrade_replica.c   | 245 +++++
 .../pg_upgrade_replica/pg_upgrade_replica.h   |  44 +
 src/bin/pg_upgrade_replica/reuse.c            | 915 ++++++++++++++++++
 src/bin/pg_upgrade_replica/subprocess.c       | 115 +++
 src/bin/pg_upgrade_replica/subprocess.h       |  21 +
 src/bin/pg_upgrade_replica/t/001_basic.pl     |  13 +
 src/bin/pg_upgrade_replica/t/002_sync.pl      | 373 +++++++
 src/tools/pgindent/typedefs.list              |   8 +
 24 files changed, 3073 insertions(+)
 create mode 100644 doc/src/sgml/ref/pg_upgrade_replica.sgml
 create mode 100644 src/bin/pg_upgrade_replica/.gitignore
 create mode 100644 src/bin/pg_upgrade_replica/Makefile
 create mode 100644 src/bin/pg_upgrade_replica/fetch.c
 create mode 100644 src/bin/pg_upgrade_replica/fetch.h
 create mode 100644 src/bin/pg_upgrade_replica/forge_manifest.c
 create mode 100644 src/bin/pg_upgrade_replica/forge_manifest.h
 create mode 100644 src/bin/pg_upgrade_replica/manifest.c
 create mode 100644 src/bin/pg_upgrade_replica/manifest.h
 create mode 100644 src/bin/pg_upgrade_replica/meson.build
 create mode 100644 src/bin/pg_upgrade_replica/nls.mk
 create mode 100644 src/bin/pg_upgrade_replica/pg_upgrade_replica.c
 create mode 100644 src/bin/pg_upgrade_replica/pg_upgrade_replica.h
 create mode 100644 src/bin/pg_upgrade_replica/reuse.c
 create mode 100644 src/bin/pg_upgrade_replica/subprocess.c
 create mode 100644 src/bin/pg_upgrade_replica/subprocess.h
 create mode 100644 src/bin/pg_upgrade_replica/t/001_basic.pl
 create mode 100644 src/bin/pg_upgrade_replica/t/002_sync.pl

diff --git a/doc/src/sgml/ref/allfiles.sgml b/doc/src/sgml/ref/allfiles.sgml
index e1a56c36221..de152293052 100644
--- a/doc/src/sgml/ref/allfiles.sgml
+++ b/doc/src/sgml/ref/allfiles.sgml
@@ -224,6 +224,7 @@ Complete list of usable sgml source files in this directory.
 <!ENTITY pgtestfsync        SYSTEM "pgtestfsync.sgml">
 <!ENTITY pgtesttiming       SYSTEM "pgtesttiming.sgml">
 <!ENTITY pgupgrade          SYSTEM "pgupgrade.sgml">
+<!ENTITY pgupgradereplica   SYSTEM "pg_upgrade_replica.sgml">
 <!ENTITY pgwaldump          SYSTEM "pg_waldump.sgml">
 <!ENTITY pgwalsummary       SYSTEM "pg_walsummary.sgml">
 <!ENTITY postgres           SYSTEM "postgres-ref.sgml">
diff --git a/doc/src/sgml/ref/pg_upgrade_replica.sgml b/doc/src/sgml/ref/pg_upgrade_replica.sgml
new file mode 100644
index 00000000000..c01f20bae8d
--- /dev/null
+++ b/doc/src/sgml/ref/pg_upgrade_replica.sgml
@@ -0,0 +1,466 @@
+<!--
+doc/src/sgml/ref/pg_upgrade_replica.sgml
+PostgreSQL documentation
+-->
+
+<refentry id="app-pgupgradereplica">
+ <indexterm zone="app-pgupgradereplica">
+  <primary>pg_upgrade_replica</primary>
+ </indexterm>
+
+ <refmeta>
+  <refentrytitle><application>pg_upgrade_replica</application></refentrytitle>
+  <manvolnum>1</manvolnum>
+  <refmiscinfo>Application</refmiscinfo>
+ </refmeta>
+
+ <refnamediv>
+  <refname>pg_upgrade_replica</refname>
+  <refpurpose>rebuild a standby's data directory against an already
+  <application>pg_upgrade</application>'d primary</refpurpose>
+ </refnamediv>
+
+ <refsynopsisdiv>
+  <cmdsynopsis>
+   <command>pg_upgrade_replica</command>
+   <arg rep="repeat"><replaceable>connection-option</replaceable></arg>
+   <arg choice="plain"><option>--old-bindir=<replaceable>directory</replaceable></option></arg>
+   <arg choice="plain"><option>--old-replica=<replaceable>directory</replaceable></option></arg>
+   <arg choice="plain"><option>--new-replica=<replaceable>directory</replaceable></option></arg>
+   <arg rep="repeat"><replaceable>option</replaceable></arg>
+  </cmdsynopsis>
+ </refsynopsisdiv>
+
+ <refsect1>
+  <title>Description</title>
+
+  <para>
+   <application>pg_upgrade_replica</application> rebuilds a physical
+   standby's data directory after <application>pg_upgrade</application>
+   has upgraded its primary, without re-cloning the whole dataset and
+   without <application>ssh</application> or <application>rsync</application>
+   access to either server's filesystem. It needs a libpq connection to
+   the new primary, read-only access to the standby's own pre-upgrade
+   data directory on the local machine it runs on, and a copy of
+   <application>pg_basebackup</application> and
+   <application>pg_combinebackup</application> from the same
+   installation (it locates and runs them as subprocesses, and refuses
+   to proceed if their version does not match its own).
+  </para>
+
+  <para>
+   <application>pg_upgrade</application> preserves the relfilenode of
+   every relation it transfers unchanged from the old cluster to the new
+   one, and (since it knows exactly which relations those are) records
+   that list in <filename>pg_upgrade_manifest</filename> inside the new
+   cluster's data directory, along with the old cluster's own identity
+   and shutdown checkpoint. This is a small format of its own, not a
+   <filename>backup_manifest</filename>: nothing but
+   <application>pg_upgrade_replica</application> itself ever reads it.
+   <application>pg_upgrade_replica</application> fetches that record
+   over the connection to the new primary and, for each relation it
+   lists, looks for that relation's own files on
+   <option>--old-replica</option> (pg_upgrade's own record is only an
+   inventory of what the old primary itself had, not a guarantee about
+   what a particular standby still does). Whatever it confirms is
+   actually still there becomes a forged, real
+   <filename>backup_manifest</filename>. It then runs
+   <application>pg_basebackup</application> <option>--incremental</option>
+   against that forged manifest to fetch only what has actually changed
+   on the new primary since its upgrade, and
+   <application>pg_combinebackup</application> to assemble the new
+   standby's data directory from that incremental backup and
+   <option>--old-replica</option>'s own files. Everything not listed in
+   the record -- freshly created relations, catalogs, configuration
+   files, and so on -- is part of the incremental backup and so is
+   always fetched fresh from the new primary.
+  </para>
+
+  <para>
+   <option>--old-replica</option>'s files are the only ones for which
+   this is true: once its own control data is confirmed to match the old
+   primary's exact shutdown checkpoint (see below), a block reported
+   unchanged since that checkpoint by the new primary's WAL summarizer is
+   taken on trust from <option>--old-replica</option>'s copy, with no
+   independent size or checksum check against the new primary, the same
+   trust any stopped <productname>PostgreSQL</productname> data
+   directory gets when starting recovery from it. Anything the WAL
+   summarizer reports as changed is fetched fresh instead of reused, and
+   the assembled standby's own recovery reconciles everything from
+   there forward, the same as after any other base backup.
+  </para>
+
+  <para>
+   This makes rebuilding a standby after <application>pg_upgrade</application>
+   proportional to how much actually changed, the same property
+   <application>pg_upgrade</application>'s own <option>--link</option>
+   and <option>--clone</option> modes already give the primary, instead
+   of proportional to the size of the whole dataset.
+  </para>
+ </refsect1>
+
+ <refsect1>
+  <title>Options</title>
+
+  <para>
+   <application>pg_upgrade_replica</application> accepts the following
+   command-line arguments:
+
+   <variablelist>
+    <varlistentry>
+     <term><option>--old-bindir=<replaceable class="parameter">directory</replaceable></option></term>
+     <listitem>
+      <para>
+       Directory containing the old cluster's own
+       <application>pg_controldata</application>, matching the
+       <productname>PostgreSQL</productname> version of the data in
+       <option>--old-replica</option> -- the same
+       <option>--old-bindir</option> given to
+       <application>pg_upgrade</application> for the same upgrade.
+       <application>pg_upgrade_replica</application> shells out to it to
+       read <option>--old-replica</option>'s own system identifier and
+       latest checkpoint location (see below): those live in
+       <filename>pg_control</filename>, whose binary layout is not
+       guaranteed stable across major versions, so this tool's own,
+       newer build cannot always parse an older cluster's copy of it
+       directly.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><option>--old-replica=<replaceable class="parameter">directory</replaceable></option></term>
+     <listitem>
+      <para>
+       The standby's own data directory, as it was before the primary's
+       <application>pg_upgrade</application> run. This directory is only
+       ever read, never written to, regardless of whether
+       <option>--link</option> is given.
+      </para>
+      <para>
+       This standby must have stayed connected and streaming from the old
+       primary through the primary's final, pre-upgrade shutdown, and
+       must not itself have been started again since. This is checked
+       automatically: <application>pg_upgrade_replica</application> reads
+       this directory's own <filename>pg_control</filename> and refuses
+       to proceed unless its system identifier and latest checkpoint
+       location exactly match what the manifest recorded for the old
+       primary. Without this check, a standby that fell behind before the
+       shutdown would have its reused files look valid while quietly
+       being stale, since replay against the new cluster never plays
+       backward to fill in what a lagging standby missed.
+      </para>
+      <para>
+       This standby must also itself be cleanly shut down before running
+       <application>pg_upgrade_replica</application>: its own checkpoint
+       only reaches <filename>pg_control</filename> that way, so a
+       standby left running (or killed) instead of shut down normally
+       would fail this check even if it had genuinely received
+       everything. <application>pg_upgrade_replica</application> refuses
+       to proceed if this directory's <filename>postmaster.pid</filename>
+       is still present.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><option>--new-replica=<replaceable class="parameter">directory</replaceable></option></term>
+     <listitem>
+      <para>
+       Where to assemble the new standby's data directory. Must not
+       already exist, or must be empty: this tool has no logic to resume
+       or merge with a previous attempt, so a partial or stale directory
+       left there would silently combine with the new run instead of
+       being rejected.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><option>--link</option></term>
+     <listitem>
+      <para>
+       Hardlink reused files from <option>--old-replica</option> instead
+       of copying them. This avoids doubling local disk space and I/O for
+       the bulk of the data, which matters at multi-terabyte scale, but
+       it means <option>--old-replica</option> and
+       <option>--new-replica</option> end up sharing inodes for those
+       files.
+      </para>
+      <para>
+       As with <application>pg_upgrade</application>'s own
+       <option>--link</option> mode, the first write the new standby ever
+       makes to a shared file corrupts the old replica's copy too, since
+       both directory entries point at the same physical blocks. Once the
+       new standby has been started, treat
+       <option>--old-replica</option> as consumed: do not start it again.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><option>--tablespace-mapping=<replaceable class="parameter">olddir</replaceable>=<replaceable class="parameter">newdir</replaceable></option></term>
+     <listitem>
+      <para>
+       Places the tablespace whose path on the new primary (as
+       <function>pg_tablespace_location()</function> reports it) is
+       <replaceable>olddir</replaceable> at <replaceable>newdir</replaceable>
+       on this host instead, the same <option>-T</option>
+       <replaceable>olddir</replaceable>=<replaceable>newdir</replaceable>
+       convention <application>pg_basebackup</application> and
+       <application>pg_combinebackup</application> already use. Without
+       a matching mapping, the default placement policy applies: reuse
+       <option>--old-replica</option>'s existing local path for a
+       tablespace that already existed before the upgrade, or fall back
+       to the new primary's own path for one created afterward (a guess
+       that only holds if this host's mount layout matches the
+       primary's). Both <replaceable>olddir</replaceable> and
+       <replaceable>newdir</replaceable> must be absolute paths. May be
+       given more than once, once per tablespace that needs an explicit
+       path. An in-place tablespace (one created with
+       <literal>LOCATION ''</literal>) has no absolute path to match
+       against in the first place, so no mapping can ever apply to one.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><option>--no-sync</option></term>
+     <listitem>
+      <para>
+       By default, <application>pg_upgrade_replica</application> will wait
+       for all files to be written safely to disk. This option causes it to
+       return without waiting, which is faster, but means that a subsequent
+       operating system crash can leave the assembled standby corrupt.
+       Generally, this option is useful for testing but should not be used
+       when assembling a standby for production use.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><option>-V</option></term>
+     <term><option>--version</option></term>
+     <listitem><para>Display version information, then exit.</para></listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><option>-?</option></term>
+     <term><option>--help</option></term>
+     <listitem><para>Show help, then exit.</para></listitem>
+    </varlistentry>
+   </variablelist>
+  </para>
+
+  <para>
+   <application>pg_upgrade_replica</application> also accepts the
+   following command-line arguments for connection parameters:
+
+   <variablelist>
+    <varlistentry>
+     <term><option>-d <replaceable class="parameter">dbname</replaceable></option></term>
+     <term><option>--dbname=<replaceable class="parameter">dbname</replaceable></option></term>
+     <listitem>
+      <para>
+       The database to connect to on the new primary. Its content is not
+       relevant: <application>pg_upgrade_replica</application> does not
+       access any particular database's own data, only server-wide
+       information and the new cluster's own files. Defaults to
+       <literal>postgres</literal>.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><option>-h <replaceable class="parameter">host</replaceable></option></term>
+     <term><option>--host=<replaceable class="parameter">host</replaceable></option></term>
+     <listitem>
+      <para>
+       Specifies the host name of the new primary.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><option>-p <replaceable class="parameter">port</replaceable></option></term>
+     <term><option>--port=<replaceable class="parameter">port</replaceable></option></term>
+     <listitem>
+      <para>
+       Specifies the TCP port the new primary is listening on.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><option>-U <replaceable class="parameter">username</replaceable></option></term>
+     <term><option>--username=<replaceable class="parameter">username</replaceable></option></term>
+     <listitem>
+      <para>
+       User name to connect as. This role needs two separate things,
+       neither of which alone is enough: the <literal>REPLICATION</literal>
+       role attribute (or superuser), since
+       <application>pg_upgrade_replica</application> drives
+       <application>pg_basebackup</application> over a replication
+       connection using these same credentials; and execute permission on
+       <function>pg_read_binary_file</function>, which is superuser-only
+       by default but can be granted to a non-superuser role (see Notes
+       below), to fetch <filename>pg_upgrade_manifest</filename> itself
+       over an ordinary connection. <filename>pg_hba.conf</filename> must
+       also permit a <literal>replication</literal> connection for this
+       role from this host.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><option>-w</option></term>
+     <term><option>--no-password</option></term>
+     <listitem>
+      <para>
+       Never issue a password prompt.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry>
+     <term><option>-W</option></term>
+     <term><option>--password</option></term>
+     <listitem>
+      <para>
+       Force a password prompt.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </para>
+ </refsect1>
+
+ <refsect1>
+  <title>Notes</title>
+
+  <para>
+   A non-superuser role can be used in place of a superuser role,
+   provided it has both the <literal>REPLICATION</literal> attribute and
+   execute permission on <function>pg_read_binary_file</function> (the
+   same function-grant approach <application>pg_rewind</application>'s
+   own documentation shows). Here is how to create such a role, named
+   <literal>pg_upgrade_replica_user</literal> here, and permit it a
+   replication connection from this host:
+<programlisting>
+CREATE USER pg_upgrade_replica_user LOGIN REPLICATION;
+GRANT EXECUTE ON FUNCTION pg_catalog.pg_read_binary_file(text) TO pg_upgrade_replica_user;
+</programlisting>
+<programlisting>
+# in pg_hba.conf on the new primary
+host    replication     pg_upgrade_replica_user     <replaceable>address</replaceable>          scram-sha-256
+</programlisting>
+   <function>pg_read_binary_file</function> takes an arbitrary file
+   path, not one confined to the new primary's data directory: granting
+   it delegates read access to any file the server process can read,
+   not just <filename>pg_upgrade_manifest</filename>, the only file
+   <application>pg_upgrade_replica</application> itself ever reads this
+   way.
+  </para>
+
+  <para>
+   This tool has no <option>sslmode</option> setting of its own; it
+   defers entirely to the connection string or environment, same as
+   <application>psql</application> or <application>pg_dump</application>.
+   Since a sync transfers a full copy of everything not already present
+   on <option>--old-replica</option>, consider
+   <literal>sslmode=verify-full</literal> if that matters for the network
+   path to the new primary.
+  </para>
+
+  <para>
+   The check against <option>--old-replica</option>'s control data only
+   establishes that it is not stale (caught up to the exact checkpoint
+   the manifest expects); it says nothing about whether its files are
+   authentic. Both the control data check and the page checksums a
+   cluster may have enabled are plain CRCs: anyone able to write to
+   <option>--old-replica</option> can reconstruct a control file and file
+   contents that pass both, the same way anyone able to write to the old
+   primary's own data directory could fool
+   <application>pg_upgrade</application> itself. Treat
+   <option>--old-replica</option> as trusted input, the same as any other
+   data directory <application>PostgreSQL</application> starts recovery
+   from, not as untrusted data this tool authenticates. This also covers
+   the more mundane case of accidental corruption (bitrot, a bad earlier
+   copy): an operator who wants that checked can run
+   <xref linkend="app-pgchecksums"/> <literal>--check</literal> against
+   <option>--old-replica</option> before running this tool, understanding
+   that it detects corruption, not tampering.
+  </para>
+
+  <para>
+   The assembled standby's <filename>backup_label</filename> is the one
+   <application>pg_basebackup</application> itself produces: its "start
+   WAL location" is wherever the new primary happened to be when
+   <application>pg_upgrade_replica</application> ran, not the older
+   checkpoint recorded in the forged manifest (that older checkpoint only
+   appears in the backup's own "incremental from" field, which
+   <application>pg_combinebackup</application> consumes and does not
+   carry into the final result). Recovery on the assembled standby
+   therefore replays forward from that recent point, not from whenever
+   <application>pg_upgrade</application> originally ran; a write made to
+   a relation reused from <option>--old-replica</option>, at any point
+   between the primary's post-upgrade restart and this tool actually
+   running, is already reflected directly in the assembled files, not
+   left for replay to backfill. There is no operational requirement to
+   retain WAL on the new primary for as long as some standby is still
+   waiting to be resynced.
+  </para>
+
+  <para>
+   What must be retained instead is WAL <emphasis>summary</emphasis>
+   coverage, which is a separate thing from WAL itself.
+   <xref linkend="guc-summarize-wal"/> must be enabled from the new
+   primary's very first post-upgrade startup: a coverage gap left before
+   turning it on is never backfilled, and
+   <application>pg_basebackup</application> <option>--incremental</option>
+   refuses to run against an incomplete range. The summaries themselves
+   are pruned after <xref linkend="guc-wal-summary-keep-time"/> (ten days
+   by default), so every standby needing this kind of resync must be
+   resynced within that window of the new primary's first startup, or
+   have <varname>wal_summary_keep_time</varname> raised accordingly.
+   Running past that window is not a silent correctness gap: it fails
+   plainly, with <application>pg_basebackup</application> reporting the
+   required summaries as incomplete, and the only way forward at that
+   point is a full re-clone.
+  </para>
+
+  <para>
+   If <application>pg_upgrade_replica</application> is interrupted after
+   <application>pg_combinebackup</application> finishes assembling
+   <option>--new-replica</option> but before it finishes writing
+   <filename>standby.signal</filename>, the result is a data directory
+   that looks complete (it has a <filename>backup_label</filename>) but
+   is not yet configured to recover as a standby: starting
+   <productname>PostgreSQL</productname> directly on it, without rerunning
+   this tool, would replay to consistency and come up as an independent
+   primary sharing the new primary's system identifier, rather than as a
+   standby. <application>pg_basebackup</application>
+   <option>-R</option> carries this same window using the same
+   underlying mechanism, just a much shorter one, since there is no
+   comparable step between the backup finishing and the recovery
+   configuration being written. Always confirm
+   <filename>standby.signal</filename> exists in
+   <option>--new-replica</option> before starting
+   <productname>PostgreSQL</productname> on it; if this tool was
+   interrupted, rerunning it (with <option>--new-replica</option>
+   cleared out first) is the only supported recovery.
+  </para>
+ </refsect1>
+
+ <refsect1>
+  <title>See Also</title>
+
+  <simplelist type="inline">
+   <member><xref linkend="pgupgrade"/></member>
+   <member><xref linkend="app-pgbasebackup"/></member>
+   <member><xref linkend="app-pgcombinebackup"/></member>
+   <member><xref linkend="app-pgrewind"/></member>
+  </simplelist>
+ </refsect1>
+
+</refentry>
diff --git a/doc/src/sgml/ref/pgupgrade.sgml b/doc/src/sgml/ref/pgupgrade.sgml
index e4e8c02e6d6..e9b64844550 100644
--- a/doc/src/sgml/ref/pgupgrade.sgml
+++ b/doc/src/sgml/ref/pgupgrade.sgml
@@ -645,6 +645,16 @@ pg_upgrade.exe
      is running.
     </para>
 
+    <para>
+     If <application>rsync</application> or direct filesystem access to
+     the old and new clusters is not available, for example because the
+     primary and its standbys run in separate containers,
+     <xref linkend="app-pgupgradereplica"/> rebuilds a standby against the
+     new primary over a single connection instead, reusing the standby's
+     existing local files where <application>pg_upgrade</application>'s
+     own manifest says they are unchanged.
+    </para>
+
     <procedure>
 
      <step>
diff --git a/doc/src/sgml/reference.sgml b/doc/src/sgml/reference.sgml
index 674ac17e82c..5f7e4758eff 100644
--- a/doc/src/sgml/reference.sgml
+++ b/doc/src/sgml/reference.sgml
@@ -294,6 +294,7 @@
    &pgtestfsync;
    &pgtesttiming;
    &pgupgrade;
+   &pgupgradereplica;
    &pgwaldump;
    &pgwalsummary;
    &postgres;
diff --git a/src/bin/Makefile b/src/bin/Makefile
index 538af88a523..b563d9fd93e 100644
--- a/src/bin/Makefile
+++ b/src/bin/Makefile
@@ -29,6 +29,7 @@ SUBDIRS = \
 	pg_test_fsync \
 	pg_test_timing \
 	pg_upgrade \
+	pg_upgrade_replica \
 	pg_verifybackup \
 	pg_waldump \
 	pg_walsummary \
diff --git a/src/bin/meson.build b/src/bin/meson.build
index bf765381d89..c98dd5bfcf7 100644
--- a/src/bin/meson.build
+++ b/src/bin/meson.build
@@ -15,6 +15,7 @@ subdir('pg_rewind')
 subdir('pg_test_fsync')
 subdir('pg_test_timing')
 subdir('pg_upgrade')
+subdir('pg_upgrade_replica')
 subdir('pg_verifybackup')
 subdir('pg_waldump')
 subdir('pg_walsummary')
diff --git a/src/bin/pg_upgrade_replica/.gitignore b/src/bin/pg_upgrade_replica/.gitignore
new file mode 100644
index 00000000000..1b6299a9308
--- /dev/null
+++ b/src/bin/pg_upgrade_replica/.gitignore
@@ -0,0 +1,8 @@
+/pg_upgrade_replica
+# Generated by manual invocation of pg_upgrade during testing
+/delete_old_cluster.sh
+/delete_old_cluster.bat
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/bin/pg_upgrade_replica/Makefile b/src/bin/pg_upgrade_replica/Makefile
new file mode 100644
index 00000000000..ea0925c80a5
--- /dev/null
+++ b/src/bin/pg_upgrade_replica/Makefile
@@ -0,0 +1,52 @@
+#-------------------------------------------------------------------------
+#
+# Makefile for src/bin/pg_upgrade_replica
+#
+# Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+#
+# src/bin/pg_upgrade_replica/Makefile
+#
+#-------------------------------------------------------------------------
+
+PGFILEDESC = "pg_upgrade_replica - rebuild a standby against an upgraded primary"
+PGAPPICON = win32
+
+subdir = src/bin/pg_upgrade_replica
+top_builddir = ../../..
+include $(top_builddir)/src/Makefile.global
+
+override CPPFLAGS := -I$(libpq_srcdir) $(CPPFLAGS)
+LDFLAGS_INTERNAL += -L$(top_builddir)/src/fe_utils -lpgfeutils $(libpq_pgport)
+
+OBJS = \
+	$(WIN32RES) \
+	fetch.o \
+	forge_manifest.o \
+	manifest.o \
+	pg_upgrade_replica.o \
+	reuse.o \
+	subprocess.o
+
+all: pg_upgrade_replica
+
+pg_upgrade_replica: $(OBJS) | submake-libpq submake-libpgport submake-libpgfeutils
+	$(CC) $(CFLAGS) $^ $(LDFLAGS) $(LDFLAGS_EX) $(LIBS) -o $@$(X)
+
+install: all installdirs
+	$(INSTALL_PROGRAM) pg_upgrade_replica$(X) '$(DESTDIR)$(bindir)/pg_upgrade_replica$(X)'
+
+installdirs:
+	$(MKDIR_P) '$(DESTDIR)$(bindir)'
+
+uninstall:
+	rm -f '$(DESTDIR)$(bindir)/pg_upgrade_replica$(X)'
+
+clean distclean:
+	rm -f pg_upgrade_replica$(X) $(OBJS)
+	rm -rf tmp_check
+
+check:
+	$(prove_check)
+
+installcheck:
+	$(prove_installcheck)
diff --git a/src/bin/pg_upgrade_replica/fetch.c b/src/bin/pg_upgrade_replica/fetch.c
new file mode 100644
index 00000000000..08ae44b3982
--- /dev/null
+++ b/src/bin/pg_upgrade_replica/fetch.c
@@ -0,0 +1,184 @@
+/*-------------------------------------------------------------------------
+ *
+ * fetch.c
+ *		Everything that talks to the new primary over a plain libpq/SQL
+ *		connection.
+ *
+ * Bulk file transfer is no longer this tool's own protocol -- it's done by
+ * the pg_basebackup/pg_combinebackup subprocesses reuse.c drives, so this
+ * file only needs to cover the small, one-off reads that happen before
+ * either of those runs: the pg_upgrade_manifest file itself, and a couple
+ * of catalog/GUC values (see reuse.c).
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * src/bin/pg_upgrade_replica/fetch.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres_fe.h"
+
+#include "catalog/catversion.h"
+#include "catalog/pg_control.h"
+#include "common/logging.h"
+
+#include "fetch.h"
+
+static void run_command(PGconn *conn, const char *sql);
+static void check_server_build_matches(PGconn *conn);
+
+/*
+ * Connect to the new primary and put the connection in the state this
+ * tool needs: read-only, no timeouts that could fire mid-sync.
+ *
+ * This connection is only ever used for a handful of small SQL queries
+ * (the manifest file, a couple of catalog lookups); the actual bulk data
+ * transfer goes through pg_basebackup's own replication-protocol
+ * connection, made separately by that subprocess. This tool has no
+ * full_page_writes concern of its own: nothing here does a
+ * pg_read_binary_file() against a live, concurrently-written file (which
+ * would need one), and pg_basebackup's BASE_BACKUP command already
+ * forces full_page_writes on for its own duration server-side (see
+ * forcePageWrites in xlog.c) regardless.
+ */
+RemoteConn *
+remote_connect(const ConnParams *cparams, const char *progname)
+{
+	RemoteConn *rconn;
+	PGconn	   *conn;
+
+	/*
+	 * connectDatabase() already installs a secure, empty search_path
+	 * before returning, so every query below (including
+	 * check_server_build_matches()'s unqualified pg_control_system()
+	 * call) is already safe from a same-named function on the connecting
+	 * role's default search_path intercepting it.
+	 */
+	conn = connectDatabase(cparams, progname, false, false, true);
+
+	run_command(conn, "SET statement_timeout = 0");
+	run_command(conn, "SET lock_timeout = 0");
+	run_command(conn, "SET idle_in_transaction_session_timeout = 0");
+	run_command(conn, "SET transaction_timeout = 0");
+	run_command(conn, "SET default_transaction_read_only = on");
+
+	check_server_build_matches(conn);
+
+	rconn = pg_malloc0(sizeof(RemoteConn));
+	rconn->conn = conn;
+	rconn->data_directory = run_scalar_query(conn, "SHOW data_directory");
+
+	return rconn;
+}
+
+/*
+ * find_sibling_exec() already confirms pg_basebackup/pg_combinebackup match
+ * this tool's own build; nothing separately confirmed this build itself
+ * matches the new primary it just connected to. Two call sites in reuse.c
+ * assume that anyway: tablespace_version_dir() combines the connection's
+ * own server_version_num with this build's own compiled-in
+ * CATALOG_VERSION_NO, and write_old_replica_view_metadata() casts the new
+ * primary's own raw pg_control bytes directly to this build's own
+ * ControlFileData. Both are silently wrong, not just unsupported, against a
+ * mismatched build (a stale binary left on a container image after a minor
+ * bump is a realistic way to hit this), so check the one thing that
+ * actually governs both: pg_control_system() reports the connected
+ * server's real pg_control_version and catalog_version_no, compared here
+ * against this build's own compiled-in constants.
+ */
+static void
+check_server_build_matches(PGconn *conn)
+{
+	PGresult   *res;
+	uint32		remote_control_version;
+	uint32		remote_catalog_version;
+
+	res = PQexec(conn,
+				 "SELECT pg_control_version, catalog_version_no "
+				 "FROM pg_control_system()");
+	if (PQresultStatus(res) != PGRES_TUPLES_OK)
+		pg_fatal("could not query pg_control_system(): %s",
+				 PQresultErrorMessage(res));
+	if (PQntuples(res) != 1)
+		pg_fatal("unexpected result from pg_control_system()");
+
+	remote_control_version = strtoul(PQgetvalue(res, 0, 0), NULL, 10);
+	remote_catalog_version = strtoul(PQgetvalue(res, 0, 1), NULL, 10);
+	PQclear(res);
+
+	if (remote_control_version != PG_CONTROL_VERSION ||
+		remote_catalog_version != CATALOG_VERSION_NO)
+		pg_fatal("this build of pg_upgrade_replica (pg_control version %u, "
+				 "catalog version %u) does not match the new primary's own "
+				 "(pg_control version %u, catalog version %u) -- "
+				 "pg_upgrade_replica must be run from the very same build "
+				 "as the new primary",
+				 PG_CONTROL_VERSION, CATALOG_VERSION_NO,
+				 remote_control_version, remote_catalog_version);
+}
+
+void
+remote_disconnect(RemoteConn *rconn)
+{
+	disconnectDatabase(rconn->conn);
+	pg_free(rconn->data_directory);
+	pg_free(rconn);
+}
+
+char *
+run_scalar_query(PGconn *conn, const char *sql)
+{
+	PGresult   *res;
+	char	   *result;
+
+	res = PQexec(conn, sql);
+	if (PQresultStatus(res) != PGRES_TUPLES_OK)
+		pg_fatal("query failed: %s: %s", sql, PQresultErrorMessage(res));
+	if (PQntuples(res) != 1 || PQnfields(res) != 1)
+		pg_fatal("unexpected result from query: %s", sql);
+	result = pg_strdup(PQgetvalue(res, 0, 0));
+	PQclear(res);
+	return result;
+}
+
+static void
+run_command(PGconn *conn, const char *sql)
+{
+	PGresult   *res;
+
+	res = PQexec(conn, sql);
+	if (PQresultStatus(res) != PGRES_COMMAND_OK)
+		pg_fatal("command failed: %s: %s", sql, PQresultErrorMessage(res));
+	PQclear(res);
+}
+
+/*
+ * One-off read of a small file (just the manifest, in practice): a plain
+ * PQexecParams requesting a binary result, so the bytea comes back as raw
+ * bytes with no text/hex encoding overhead.
+ */
+char *
+remote_read_whole_file(RemoteConn *rconn, const char *abspath, size_t *len_p)
+{
+	const char *params[1];
+	PGresult   *res;
+	int			len;
+	char	   *result;
+
+	params[0] = abspath;
+	res = PQexecParams(rconn->conn, "SELECT pg_read_binary_file($1)",
+					   1, NULL, params, NULL, NULL, 1);
+	if (PQresultStatus(res) != PGRES_TUPLES_OK)
+		pg_fatal("could not read file \"%s\": %s", abspath,
+				 PQresultErrorMessage(res));
+	if (PQntuples(res) != 1 || PQgetisnull(res, 0, 0))
+		pg_fatal("file \"%s\" is missing on the new primary", abspath);
+
+	len = PQgetlength(res, 0, 0);
+	result = pg_malloc(len);
+	memcpy(result, PQgetvalue(res, 0, 0), len);
+	PQclear(res);
+
+	*len_p = len;
+	return result;
+}
diff --git a/src/bin/pg_upgrade_replica/fetch.h b/src/bin/pg_upgrade_replica/fetch.h
new file mode 100644
index 00000000000..011842fd787
--- /dev/null
+++ b/src/bin/pg_upgrade_replica/fetch.h
@@ -0,0 +1,37 @@
+/*-------------------------------------------------------------------------
+ *
+ * fetch.h
+ *		Everything that talks to the new primary over a plain libpq/SQL
+ *		connection: connection setup, and the one-off reads (the small
+ *		pg_upgrade_manifest file, a few catalog/GUC values) this tool still
+ *		needs directly. Bulk data transfer is no longer this tool's own
+ *		protocol -- see forge_manifest.h and the pg_basebackup/
+ *		pg_combinebackup subprocesses driven from reuse.c.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * src/bin/pg_upgrade_replica/fetch.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGUR_FETCH_H
+#define PGUR_FETCH_H
+
+#include "fe_utils/connect_utils.h"
+#include "libpq-fe.h"
+
+typedef struct RemoteConn
+{
+	PGconn	   *conn;
+	char	   *data_directory;
+} RemoteConn;
+
+extern RemoteConn *remote_connect(const ConnParams *cparams,
+								  const char *progname);
+extern void remote_disconnect(RemoteConn *rconn);
+extern char *run_scalar_query(PGconn *conn, const char *sql);
+
+extern char *remote_read_whole_file(RemoteConn *rconn, const char *abspath,
+									size_t *len_p);
+
+#endif							/* PGUR_FETCH_H */
diff --git a/src/bin/pg_upgrade_replica/forge_manifest.c b/src/bin/pg_upgrade_replica/forge_manifest.c
new file mode 100644
index 00000000000..3497a2a0017
--- /dev/null
+++ b/src/bin/pg_upgrade_replica/forge_manifest.c
@@ -0,0 +1,286 @@
+/*-------------------------------------------------------------------------
+ *
+ * forge_manifest.c
+ *		Builds a backup_manifest anchored at the new cluster's own
+ *		checkpoint, listing every relation file the pg_upgrade manifest
+ *		says was left unchanged. Only Path is ever consulted by the
+ *		server's incremental-backup logic (basebackup_incremental.c's
+ *		GetFileBackupMethod() looks the path up and stats the file itself
+ *		on the new primary rather than trusting this manifest's own Size,
+ *		and ignores the checksum fields entirely), so no checksum is
+ *		computed here -- --old-replica gets the same trust any stopped
+ *		PostgreSQL data directory gets when starting recovery from it, the
+ *		same trust model this tool has always used. Size is still filled
+ *		in faithfully below (walk_db_oids()'s own stat() call), just not
+ *		because the incremental decision itself needs it.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * src/bin/pg_upgrade_replica/forge_manifest.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres_fe.h"
+
+#include <dirent.h>
+#include <sys/stat.h>
+
+#include "common/logging.h"
+#include "fe_utils/write_manifest.h"
+
+#include "forge_manifest.h"
+
+/*
+ * Matches a relation file's path relative to PGDATA against
+ * base/<db_oid>/<relfilenumber>[_fsm|_vm|_init][.<segment>], or the
+ * tablespace-relative equivalent
+ * pg_tblspc/<ts_oid>/<version_dir>/<db_oid>/<relfilenumber>[...]. The
+ * version-dir component isn't validated against any particular value:
+ * callers here always pass one they already know is correct.
+ *
+ * Deliberately returns false for an _init fork: pg_upgrade never transfers
+ * one (transfer_relfile() only ever uses "", "_fsm", "_vm"), an unlogged
+ * relation's init fork is created fresh by the new cluster's own DDL
+ * replay during restore, so the copy already sitting on the new primary
+ * is the authoritative one -- reusing --old-replica's would mean trusting
+ * that its init fork and a freshly-created one are byte-identical across
+ * whatever version gap this upgrade spans, which is never actually
+ * checked. Returning false here means it's never added to this manifest,
+ * so pg_basebackup fetches it fresh; the fetch is cheap regardless, since
+ * an init fork is always a single page.
+ */
+static bool
+parse_rel_path(const char *rel, Oid *db_oid, Oid *relfilenumber, int *segment)
+{
+	const char *p = rel;
+	char	   *end;
+	unsigned long db,
+				relnum,
+				seg;
+
+	if (strncmp(p, "base/", 5) == 0)
+		p += 5;
+	else if (strncmp(p, "pg_tblspc/", 10) == 0)
+	{
+		int			slashes_left = 2;	/* skip "<oid>/<version_dir>/" */
+
+		p += 10;
+		while (*p && slashes_left > 0)
+		{
+			if (*p == '/')
+				slashes_left--;
+			p++;
+		}
+		if (slashes_left != 0)
+			return false;
+	}
+	else
+		return false;
+
+	db = strtoul(p, &end, 10);
+	if (end == p || *end != '/')
+		return false;
+	p = end + 1;
+
+	relnum = strtoul(p, &end, 10);
+	if (end == p)
+		return false;
+	p = end;
+
+	if (strncmp(p, "_fsm", 4) == 0)
+		p += 4;
+	else if (strncmp(p, "_vm", 3) == 0)
+		p += 3;
+	else if (strncmp(p, "_init", 5) == 0)
+		return false;
+
+	if (*p == '.')
+	{
+		p++;
+		seg = strtoul(p, &end, 10);
+		if (end == p)
+			return false;
+		p = end;
+	}
+	else
+		seg = 0;
+
+	if (*p != '\0')
+		return false;
+
+	*db_oid = (Oid) db;
+	*relfilenumber = (Oid) relnum;
+	*segment = (int) seg;
+	return true;
+}
+
+/*
+ * Finds the single version-dir subdirectory (PG_<major>_<catver>) under a
+ * tablespace's physical location on --old-replica. A frozen standby's own
+ * tablespace directory should hold exactly one: unlike the *new primary's*
+ * tablespace root (which legitimately keeps the old cluster's own
+ * version-dir around until delete_old_cluster.sh runs), --old-replica was
+ * never itself pg_upgrade'd, so there is nothing else it should ever have
+ * collected here. Zero or more than one is refused rather than guessed at.
+ */
+static char *
+find_old_version_dir(const char *tablespace_dir)
+{
+	DIR		   *dir = opendir(tablespace_dir);
+	struct dirent *de;
+	char	   *found = NULL;
+
+	if (dir == NULL)
+		pg_fatal("could not open directory \"%s\": %m", tablespace_dir);
+
+	while ((de = readdir(dir)) != NULL)
+	{
+		unsigned	major,
+					catver;
+		int			nchars;
+
+		if (sscanf(de->d_name, "PG_%u_%u%n", &major, &catver, &nchars) == 2 &&
+			nchars == (int) strlen(de->d_name))
+		{
+			if (found != NULL)
+				pg_fatal("refusing to sync: \"%s\" has more than one version "
+						 "subdirectory (\"%s\" and \"%s\") -- --old-replica "
+						 "should hold only its own data, not a leftover from "
+						 "some other installation",
+						 tablespace_dir, found, de->d_name);
+			found = pg_strdup(de->d_name);
+		}
+	}
+	closedir(dir);
+
+	if (found == NULL)
+		pg_fatal("refusing to sync: \"%s\" has no version subdirectory",
+				 tablespace_dir);
+
+	return found;
+}
+
+/*
+ * Walks one physical directory on --old-replica whose immediate
+ * subdirectories are database OIDs -- either old_replica/base, or one
+ * tablespace's version-dir -- and adds a manifest entry for every relation
+ * file the pg_upgrade manifest lists as unchanged.
+ *
+ * manifest_prefix is the PGDATA-relative prefix to use when constructing
+ * each entry's Path: "base" for the default tablespace, or
+ * "pg_tblspc/<oid>/<new_version_dir>" for a tablespace. That's what makes
+ * the manifest describe the *new* primary's own layout even though the
+ * size being recorded comes from reading --old-replica's differently
+ * versioned copy of the same relation.
+ */
+static int
+walk_db_oids(manifest_writer *mwriter, const Manifest *manifest,
+			 const char *physical_dir, const char *manifest_prefix)
+{
+	DIR		   *dbdir = opendir(physical_dir);
+	struct dirent *dbent;
+	int			count = 0;
+
+	if (dbdir == NULL)
+	{
+		if (errno == ENOENT)
+			return 0;
+		pg_fatal("could not open directory \"%s\": %m", physical_dir);
+	}
+
+	while ((dbent = readdir(dbdir)) != NULL)
+	{
+		char		db_physical_dir[MAXPGPATH];
+		DIR		   *reldir;
+		struct dirent *relent;
+
+		if (dbent->d_name[0] == '\0' ||
+			strspn(dbent->d_name, "0123456789") != strlen(dbent->d_name))
+			continue;			/* not a db_oid directory */
+
+		snprintf(db_physical_dir, sizeof(db_physical_dir), "%s/%s",
+				 physical_dir, dbent->d_name);
+
+		reldir = opendir(db_physical_dir);
+		if (reldir == NULL)
+			continue;			/* not actually a directory */
+
+		while ((relent = readdir(reldir)) != NULL)
+		{
+			char		rel_path[MAXPGPATH];
+			Oid			db_oid,
+						relfilenumber;
+			int			segment;
+			char		physical_path[MAXPGPATH];
+			struct stat sb;
+
+			if (strcmp(relent->d_name, ".") == 0 ||
+				strcmp(relent->d_name, "..") == 0)
+				continue;
+
+			snprintf(rel_path, sizeof(rel_path), "%s/%s/%s",
+					 manifest_prefix, dbent->d_name, relent->d_name);
+
+			if (!parse_rel_path(rel_path, &db_oid, &relfilenumber, &segment) ||
+				!manifest_is_kept(manifest, db_oid, relfilenumber))
+				continue;
+
+			snprintf(physical_path, sizeof(physical_path), "%s/%s",
+					 db_physical_dir, relent->d_name);
+			if (stat(physical_path, &sb) != 0)
+				pg_fatal("could not stat file \"%s\": %m", physical_path);
+
+			add_file_to_manifest(mwriter, rel_path, sb.st_size, sb.st_mtime,
+								 CHECKSUM_TYPE_NONE, 0, NULL);
+			count++;
+		}
+		closedir(reldir);
+	}
+	closedir(dbdir);
+
+	return count;
+}
+
+int
+forge_manifest(char *manifest_dir, const char *old_replica,
+			   const Manifest *manifest, uint64 system_identifier,
+			   uint32 timeline, const char *new_version_dir,
+			   const KeptTablespace *tablespaces, int n_tablespaces)
+{
+	manifest_writer *mwriter;
+	manifest_wal_range wal_range = {0};
+	int			count = 0;
+	char		base_dir[MAXPGPATH];
+
+	mwriter = create_manifest_writer(manifest_dir, system_identifier);
+
+	snprintf(base_dir, sizeof(base_dir), "%s/base", old_replica);
+	count += walk_db_oids(mwriter, manifest, base_dir, "base");
+
+	for (int i = 0; i < n_tablespaces; i++)
+	{
+		char	   *old_version_dir;
+		char		physical_dir[MAXPGPATH];
+		char		manifest_prefix[MAXPGPATH];
+
+		if (tablespaces[i].old_target[0] == '\0')
+			continue;
+
+		old_version_dir = find_old_version_dir(tablespaces[i].old_target);
+		snprintf(physical_dir, sizeof(physical_dir), "%s/%s",
+				 tablespaces[i].old_target, old_version_dir);
+		snprintf(manifest_prefix, sizeof(manifest_prefix), "pg_tblspc/%u/%s",
+				 tablespaces[i].oid, new_version_dir);
+
+		count += walk_db_oids(mwriter, manifest, physical_dir, manifest_prefix);
+		pg_free(old_version_dir);
+	}
+
+	wal_range.tli = timeline;
+	wal_range.start_lsn = manifest->new_chkpnt_loc;
+	wal_range.end_lsn = manifest->new_chkpnt_loc;
+	wal_range.next = NULL;
+	finalize_manifest(mwriter, &wal_range);
+
+	return count;
+}
diff --git a/src/bin/pg_upgrade_replica/forge_manifest.h b/src/bin/pg_upgrade_replica/forge_manifest.h
new file mode 100644
index 00000000000..b724f9ca7bd
--- /dev/null
+++ b/src/bin/pg_upgrade_replica/forge_manifest.h
@@ -0,0 +1,38 @@
+/*-------------------------------------------------------------------------
+ *
+ * forge_manifest.h
+ *		Forges a real backup_manifest from pg_upgrade's own manifest (see
+ *		manifest.h), listing every file --old-replica's own copy of which
+ *		pg_upgrade left unchanged, so pg_basebackup --incremental can be
+ *		pointed at it directly.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * src/bin/pg_upgrade_replica/forge_manifest.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGUR_FORGE_MANIFEST_H
+#define PGUR_FORGE_MANIFEST_H
+
+#include "manifest.h"
+
+/* One non-default tablespace, as needed to locate its files under --old-replica. */
+typedef struct KeptTablespace
+{
+	Oid			oid;
+
+	/*
+	 * --old-replica's own pg_tblspc/<oid> symlink target, or "" if
+	 * --old-replica has no local copy of this tablespace at all (nothing from
+	 * it can be reused; pg_basebackup will fetch all of it fresh).
+	 */
+	char	   *old_target;
+} KeptTablespace;
+
+extern int	forge_manifest(char *manifest_dir, const char *old_replica,
+						   const Manifest *manifest, uint64 system_identifier,
+						   uint32 timeline, const char *new_version_dir,
+						   const KeptTablespace *tablespaces, int n_tablespaces);
+
+#endif							/* PGUR_FORGE_MANIFEST_H */
diff --git a/src/bin/pg_upgrade_replica/manifest.c b/src/bin/pg_upgrade_replica/manifest.c
new file mode 100644
index 00000000000..24bec809fde
--- /dev/null
+++ b/src/bin/pg_upgrade_replica/manifest.c
@@ -0,0 +1,144 @@
+/*-------------------------------------------------------------------------
+ *
+ * manifest.c
+ *		Parsing of pg_upgrade's pg_upgrade_manifest file.
+ *
+ * The format is deliberately tiny and line-based, see
+ * relfilenumber.c:finalize_upgrade_manifest() and
+ * append_new_cluster_checkpoint() on the pg_upgrade side:
+ *
+ *   PG_UPGRADE_MANIFEST 1 <old_sysid> <old_chkpnt_loc>
+ *   <db_oid> <relfilenumber>
+ *   ... one line per relation pg_upgrade transferred unchanged ...
+ *   NEW_CHECKPOINT <new_chkpnt_loc>
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * src/bin/pg_upgrade_replica/manifest.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres_fe.h"
+
+#include "common/logging.h"
+
+#include "manifest.h"
+
+#define SH_PREFIX		kept_rels
+#define SH_ELEMENT_TYPE	KeptRel
+#define SH_KEY_TYPE		uint64
+#define SH_KEY			key
+#define SH_HASH_KEY(tb, key)	fasthash32((const char *) &(key), sizeof(uint64), 0)
+#define SH_EQUAL(tb, a, b)		((a) == (b))
+#define SH_SCOPE		extern
+#define SH_RAW_ALLOCATOR	pg_malloc0
+#define SH_DEFINE
+#include "lib/simplehash.h"
+
+static bool
+parse_lsn(const char *s, XLogRecPtr *result)
+{
+	uint32		hi;
+	uint32		lo;
+
+	if (sscanf(s, "%X/%X", &hi, &lo) != 2)
+		return false;
+	*result = ((uint64) hi << 32) | (uint64) lo;
+	return true;
+}
+
+Manifest *
+parse_manifest(const char *raw, size_t rawlen)
+{
+	Manifest   *manifest;
+	char	   *copy;
+	char	   *line;
+	char	   *lines_saveptr;
+	bool		have_new_checkpoint = false;
+
+	copy = pg_malloc(rawlen + 1);
+	memcpy(copy, raw, rawlen);
+	copy[rawlen] = '\0';
+
+	manifest = pg_malloc0(sizeof(Manifest));
+	manifest->kept = kept_rels_create(1024, NULL);
+
+	line = strtok_r(copy, "\n", &lines_saveptr);
+	if (line == NULL)
+		pg_fatal("empty pg_upgrade_manifest");
+
+	{
+		char	   *header_saveptr;
+		char	   *magic,
+				   *version_str,
+				   *old_sysid_str,
+				   *old_chkpnt_str;
+		unsigned long version;
+
+		magic = strtok_r(line, " ", &header_saveptr);
+		version_str = strtok_r(NULL, " ", &header_saveptr);
+		old_sysid_str = strtok_r(NULL, " ", &header_saveptr);
+		old_chkpnt_str = strtok_r(NULL, " ", &header_saveptr);
+
+		if (magic == NULL || version_str == NULL || old_sysid_str == NULL ||
+			old_chkpnt_str == NULL || strcmp(magic, "PG_UPGRADE_MANIFEST") != 0)
+			pg_fatal("malformed pg_upgrade_manifest header: \"%s\"", line);
+
+		version = strtoul(version_str, NULL, 10);
+		if (version != 1)
+			pg_fatal("unsupported pg_upgrade_manifest format version %lu "
+					 "(this client only understands version 1)", version);
+
+		manifest->old_sysid = strtou64(old_sysid_str, NULL, 10);
+		if (!parse_lsn(old_chkpnt_str, &manifest->old_chkpnt_loc))
+			pg_fatal("malformed checkpoint location in pg_upgrade_manifest header: \"%s\"",
+					 old_chkpnt_str);
+	}
+
+	for (line = strtok_r(NULL, "\n", &lines_saveptr);
+		 line != NULL;
+		 line = strtok_r(NULL, "\n", &lines_saveptr))
+	{
+		char	   *entry_saveptr;
+		char	   *db_oid_str,
+				   *relfilenumber_str;
+		uint64		key;
+		KeptRel    *entry;
+		bool		found;		/* unused: a duplicate line just re-inserts
+								 * the same key harmlessly */
+
+		if (strncmp(line, "NEW_CHECKPOINT ", 15) == 0)
+		{
+			if (!parse_lsn(line + 15, &manifest->new_chkpnt_loc))
+				pg_fatal("malformed NEW_CHECKPOINT line in pg_upgrade_manifest: \"%s\"",
+						 line);
+			have_new_checkpoint = true;
+			continue;
+		}
+
+		db_oid_str = strtok_r(line, " ", &entry_saveptr);
+		relfilenumber_str = strtok_r(NULL, " ", &entry_saveptr);
+		if (db_oid_str == NULL || relfilenumber_str == NULL)
+			pg_fatal("malformed pg_upgrade_manifest line: \"%s\"", line);
+
+		key = kept_rel_key((Oid) strtoul(db_oid_str, NULL, 10),
+						   (Oid) strtoul(relfilenumber_str, NULL, 10));
+		entry = kept_rels_insert(manifest->kept, key, &found);
+		entry->key = key;
+	}
+
+	if (!have_new_checkpoint)
+		pg_fatal("pg_upgrade_manifest has no NEW_CHECKPOINT trailer -- "
+				 "was pg_upgrade interrupted before it finished?");
+
+	pg_free(copy);
+	return manifest;
+}
+
+bool
+manifest_is_kept(const Manifest *manifest, Oid db_oid, Oid relfilenumber)
+{
+	uint64		key = kept_rel_key(db_oid, relfilenumber);
+
+	return kept_rels_lookup(manifest->kept, key) != NULL;
+}
diff --git a/src/bin/pg_upgrade_replica/manifest.h b/src/bin/pg_upgrade_replica/manifest.h
new file mode 100644
index 00000000000..0b0087dd818
--- /dev/null
+++ b/src/bin/pg_upgrade_replica/manifest.h
@@ -0,0 +1,59 @@
+/*-------------------------------------------------------------------------
+ *
+ * manifest.h
+ *		Parsing of pg_upgrade's pg_upgrade_manifest file.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * src/bin/pg_upgrade_replica/manifest.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGUR_MANIFEST_H
+#define PGUR_MANIFEST_H
+
+#include "access/xlogdefs.h"
+#include "common/hashfn_unstable.h"
+
+/*
+ * One entry per (db_oid, relfilenumber) pair the manifest lists as
+ * unchanged. The two are packed into a single uint64 key (db_oid in the
+ * high 32 bits) so a plain scalar hash table can be used instead of a
+ * struct-keyed one.
+ */
+typedef struct KeptRel
+{
+	uint32		status;			/* hash status, required by simplehash */
+	uint64		key;
+} KeptRel;
+
+static inline uint64
+kept_rel_key(Oid db_oid, Oid relfilenumber)
+{
+	return ((uint64) db_oid << 32) | (uint64) relfilenumber;
+}
+
+#define SH_PREFIX		kept_rels
+#define SH_ELEMENT_TYPE	KeptRel
+#define SH_KEY_TYPE		uint64
+#define SH_KEY			key
+#define SH_HASH_KEY(tb, key)	fasthash32((const char *) &(key), sizeof(uint64), 0)
+#define SH_EQUAL(tb, a, b)		((a) == (b))
+#define SH_SCOPE		extern
+#define SH_RAW_ALLOCATOR	pg_malloc0
+#define SH_DECLARE
+#include "lib/simplehash.h"
+
+typedef struct Manifest
+{
+	uint64		old_sysid;
+	XLogRecPtr	old_chkpnt_loc;
+	XLogRecPtr	new_chkpnt_loc;
+	kept_rels_hash *kept;
+} Manifest;
+
+extern Manifest *parse_manifest(const char *raw, size_t rawlen);
+extern bool manifest_is_kept(const Manifest *manifest, Oid db_oid,
+							 Oid relfilenumber);
+
+#endif							/* PGUR_MANIFEST_H */
diff --git a/src/bin/pg_upgrade_replica/meson.build b/src/bin/pg_upgrade_replica/meson.build
new file mode 100644
index 00000000000..4bfa15040bc
--- /dev/null
+++ b/src/bin/pg_upgrade_replica/meson.build
@@ -0,0 +1,35 @@
+# Copyright (c) 2022-2026, PostgreSQL Global Development Group
+
+pg_upgrade_replica_sources = files(
+  'fetch.c',
+  'forge_manifest.c',
+  'manifest.c',
+  'pg_upgrade_replica.c',
+  'reuse.c',
+  'subprocess.c',
+)
+
+if host_system == 'windows'
+  pg_upgrade_replica_sources += rc_bin_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'pg_upgrade_replica',
+    '--FILEDESC', 'pg_upgrade_replica - rebuild a standby after pg_upgrade'])
+endif
+
+pg_upgrade_replica = executable('pg_upgrade_replica',
+  pg_upgrade_replica_sources,
+  dependencies: [frontend_code, libpq],
+  kwargs: default_bin_args,
+)
+bin_targets += pg_upgrade_replica
+
+tests += {
+  'name': 'pg_upgrade_replica',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'tests': [
+      't/001_basic.pl',
+      't/002_sync.pl',
+    ],
+  },
+}
diff --git a/src/bin/pg_upgrade_replica/nls.mk b/src/bin/pg_upgrade_replica/nls.mk
new file mode 100644
index 00000000000..9210fc9795b
--- /dev/null
+++ b/src/bin/pg_upgrade_replica/nls.mk
@@ -0,0 +1,16 @@
+# src/bin/pg_upgrade_replica/nls.mk
+CATALOG_NAME     = pg_upgrade_replica
+GETTEXT_FILES    = $(FRONTEND_COMMON_GETTEXT_FILES) \
+                   fetch.c \
+                   forge_manifest.c \
+                   manifest.c \
+                   pg_upgrade_replica.c \
+                   reuse.c \
+                   subprocess.c \
+                   ../../common/controldata_utils.c \
+                   ../../fe_utils/connect_utils.c \
+                   ../../fe_utils/option_utils.c \
+                   ../../fe_utils/recovery_gen.c \
+                   ../../fe_utils/write_manifest.c
+GETTEXT_TRIGGERS = $(FRONTEND_COMMON_GETTEXT_TRIGGERS)
+GETTEXT_FLAGS    = $(FRONTEND_COMMON_GETTEXT_FLAGS)
diff --git a/src/bin/pg_upgrade_replica/pg_upgrade_replica.c b/src/bin/pg_upgrade_replica/pg_upgrade_replica.c
new file mode 100644
index 00000000000..aea59f997ed
--- /dev/null
+++ b/src/bin/pg_upgrade_replica/pg_upgrade_replica.c
@@ -0,0 +1,245 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_upgrade_replica.c
+ *		Rebuilds a standby's data directory against an already
+ *		pg_upgrade'd, running primary, without a full re-clone. Forges a
+ *		backup_manifest listing every relation pg_upgrade's own manifest
+ *		says it transferred unchanged, anchored at the new cluster's own
+ *		checkpoint, then drives pg_basebackup --incremental and
+ *		pg_combinebackup from it: only what actually changed since that
+ *		checkpoint is ever fetched over the wire, and --old-replica's own
+ *		copy of everything else is reused in place. No ssh/rsync, and no
+ *		filesystem access to the primary at all.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * src/bin/pg_upgrade_replica/pg_upgrade_replica.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres_fe.h"
+
+#include "common/logging.h"
+#include "fe_utils/option_utils.h"
+#include "getopt_long.h"
+#include "port.h"
+
+#include "fetch.h"
+#include "pg_upgrade_replica.h"
+
+static void
+usage(const char *progname)
+{
+	printf(_("%s rebuilds a standby's data directory against an upgraded primary.\n\n"), progname);
+	printf(_("Usage:\n  %s [OPTION]...\n\n"), progname);
+	printf(_("Options:\n"));
+	printf(_("      --old-bindir=DIRECTORY      directory containing the old replica's own\n"
+			 "                                  pg_controldata (its version must match the\n"
+			 "                                  data in --old-replica)\n"));
+	printf(_("      --old-replica=DIRECTORY     the standby's own pre-upgrade data directory\n"));
+	printf(_("      --new-replica=DIRECTORY     directory to assemble the new standby into\n"
+			 "                                  (must not exist or be empty)\n"));
+	printf(_("      --link                      hardlink reused files instead of copying them\n"));
+	printf(_("      --tablespace-mapping=OLDDIR=NEWDIR\n"
+			 "                                  relocate the tablespace at OLDDIR (as reported\n"
+			 "                                  by the new primary) to NEWDIR\n"
+			 "                                  (may be given more than once)\n"));
+	printf(_("      --no-sync                   do not wait for changes to be written\n"
+			 "                                  safely to disk\n"));
+	printf(_("  -V, --version                   output version information, then exit\n"));
+	printf(_("  -?, --help                      show this help, then exit\n"));
+	printf(_("\nConnection options:\n"));
+	printf(_("  -h, --host=HOSTNAME             new primary's host\n"));
+	printf(_("  -p, --port=PORT                 new primary's port\n"));
+	printf(_("  -U, --username=USERNAME         connect as this user\n"));
+	printf(_("  -d, --dbname=DBNAME             database to connect to (default: postgres)\n"));
+	printf(_("  -w, --no-password               never prompt for password\n"));
+	printf(_("  -W, --password                  force password prompt\n"));
+	printf(_("\nReport bugs to <%s>.\n"), PACKAGE_BUGREPORT);
+	printf(_("%s home page: <%s>\n"), PACKAGE_NAME, PACKAGE_URL);
+}
+
+/*
+ * Splits "OLDDIR=NEWDIR" the same way pg_basebackup's own
+ * tablespace_list_append() does: the first unescaped '=' is the
+ * separator, "\=" is a literal '=' inside either directory.
+ */
+static void
+add_tablespace_mapping(SyncOptions *opts, const char *arg)
+{
+	TablespaceMapping *m;
+	char		old_dir[MAXPGPATH] = {0};
+	char		new_dir[MAXPGPATH] = {0};
+	char	   *dst,
+			   *dst_ptr;
+	const char *arg_ptr;
+
+	dst_ptr = dst = old_dir;
+	for (arg_ptr = arg; *arg_ptr; arg_ptr++)
+	{
+		if (dst_ptr - dst >= MAXPGPATH)
+			pg_fatal("directory name too long");
+
+		if (*arg_ptr == '\\' && *(arg_ptr + 1) == '=')
+			;					/* skip backslash escaping = */
+		else if (*arg_ptr == '=' && (arg_ptr == arg || *(arg_ptr - 1) != '\\'))
+		{
+			if (*new_dir)
+				pg_fatal("multiple \"=\" signs in tablespace mapping");
+			else
+				dst = dst_ptr = new_dir;
+		}
+		else
+			*dst_ptr++ = *arg_ptr;
+	}
+
+	if (!*old_dir || !*new_dir)
+		pg_fatal("invalid --tablespace-mapping \"%s\", expected OLDDIR=NEWDIR", arg);
+
+	if (!is_absolute_path(old_dir))
+		pg_fatal("old directory is not an absolute path in tablespace mapping: %s",
+				 old_dir);
+	if (!is_absolute_path(new_dir))
+		pg_fatal("new directory is not an absolute path in tablespace mapping: %s",
+				 new_dir);
+
+	m = pg_malloc(sizeof(TablespaceMapping));
+	m->old_dir = pg_strdup(old_dir);
+	m->new_dir = make_absolute_path(new_dir);
+	m->next = opts->tablespace_mappings;
+	opts->tablespace_mappings = m;
+}
+
+int
+main(int argc, char **argv)
+{
+	static struct option long_options[] = {
+		{"old-replica", required_argument, NULL, 1},
+		{"new-replica", required_argument, NULL, 2},
+		{"link", no_argument, NULL, 3},
+		{"tablespace-mapping", required_argument, NULL, 4},
+		{"no-sync", no_argument, NULL, 5},
+		{"old-bindir", required_argument, NULL, 6},
+		{"host", required_argument, NULL, 'h'},
+		{"port", required_argument, NULL, 'p'},
+		{"username", required_argument, NULL, 'U'},
+		{"dbname", required_argument, NULL, 'd'},
+		{"no-password", no_argument, NULL, 'w'},
+		{"password", no_argument, NULL, 'W'},
+		{"help", no_argument, NULL, '?'},
+		{"version", no_argument, NULL, 'V'},
+		{NULL, 0, NULL, 0}
+	};
+	const char *progname;
+	int			c,
+				option_index;
+	SyncOptions opts = {0};
+	ConnParams	cparams = {0};
+	RemoteConn *rconn;
+	int			check_dir;
+
+	pg_logging_init(argv[0]);
+	pg_logging_set_level(PG_LOG_INFO);
+	progname = get_progname(argv[0]);
+	handle_help_version_opts(argc, argv, progname, usage);
+
+	cparams.dbname = "postgres";
+	cparams.prompt_password = TRI_DEFAULT;
+
+	while ((c = getopt_long(argc, argv, "h:p:U:d:wW", long_options,
+							&option_index)) != -1)
+	{
+		switch (c)
+		{
+			case 1:
+				opts.old_replica = make_absolute_path(optarg);
+				break;
+			case 2:
+				opts.new_replica = make_absolute_path(optarg);
+				break;
+			case 3:
+				opts.link = true;
+				break;
+			case 4:
+				add_tablespace_mapping(&opts, optarg);
+				break;
+			case 5:
+				opts.no_sync = true;
+				break;
+			case 6:
+				opts.old_bindir = make_absolute_path(optarg);
+				break;
+			case 'h':
+				cparams.pghost = pg_strdup(optarg);
+				break;
+			case 'p':
+				cparams.pgport = pg_strdup(optarg);
+				break;
+			case 'U':
+				cparams.pguser = pg_strdup(optarg);
+				break;
+			case 'd':
+				cparams.dbname = pg_strdup(optarg);
+				break;
+			case 'w':
+				cparams.prompt_password = TRI_NO;
+				break;
+			case 'W':
+				cparams.prompt_password = TRI_YES;
+				break;
+			default:
+				pg_log_error_hint("Try \"%s --help\" for more information.", progname);
+				exit(1);
+		}
+	}
+
+	if (optind < argc)
+	{
+		pg_log_error("too many command-line arguments (first is \"%s\")",
+					 argv[optind]);
+		pg_log_error_hint("Try \"%s --help\" for more information.", progname);
+		exit(1);
+	}
+
+	if (opts.old_bindir == NULL)
+	{
+		pg_log_error("--old-bindir is required");
+		pg_log_error_hint("Try \"%s --help\" for more information.", progname);
+		exit(1);
+	}
+	if (opts.old_replica == NULL)
+	{
+		pg_log_error("--old-replica is required");
+		pg_log_error_hint("Try \"%s --help\" for more information.", progname);
+		exit(1);
+	}
+	if (opts.new_replica == NULL)
+	{
+		pg_log_error("--new-replica is required");
+		pg_log_error_hint("Try \"%s --help\" for more information.", progname);
+		exit(1);
+	}
+
+	/*
+	 * this tool has no resume/merge logic: a partial or stale attempt left in
+	 * --new-replica would silently combine with this run. Just validate for
+	 * now -- actually creating the directory is deferred to sync_replica(),
+	 * once the old replica is confirmed caught up, so a rejected run doesn't
+	 * leave a stray empty directory behind.
+	 */
+	check_dir = pg_check_dir(opts.new_replica);
+	if (check_dir < 0)
+		pg_fatal("could not access directory \"%s\": %m", opts.new_replica);
+	if (check_dir > 1)
+		pg_fatal("refusing to run: \"%s\" already exists and is not empty -- "
+				 "remove it (or point --new-replica elsewhere) and retry",
+				 opts.new_replica);
+
+	rconn = remote_connect(&cparams, progname);
+	pg_log_info("new primary data_directory = %s", rconn->data_directory);
+
+	sync_replica(rconn, &cparams, &opts, argv[0]);
+
+	remote_disconnect(rconn);
+	return 0;
+}
diff --git a/src/bin/pg_upgrade_replica/pg_upgrade_replica.h b/src/bin/pg_upgrade_replica/pg_upgrade_replica.h
new file mode 100644
index 00000000000..b88492b61b4
--- /dev/null
+++ b/src/bin/pg_upgrade_replica/pg_upgrade_replica.h
@@ -0,0 +1,44 @@
+/*-------------------------------------------------------------------------
+ *
+ * pg_upgrade_replica.h
+ *		Command-line options and the sync_replica() entry point.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * src/bin/pg_upgrade_replica/pg_upgrade_replica.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_UPGRADE_REPLICA_H
+#define PG_UPGRADE_REPLICA_H
+
+#include "access/xlogdefs.h"
+#include "fetch.h"
+
+/*
+ * Command-line/connection options, gathered in one place so every module
+ * can take a single (const SyncOptions *) instead of a growing list of
+ * separate arguments.
+ */
+typedef struct SyncOptions
+{
+	char	   *old_bindir;
+	char	   *old_replica;
+	char	   *new_replica;
+	bool		link;
+	bool		no_sync;
+	/* old dir -> new dir, from repeated --tablespace-mapping OLDDIR=NEWDIR */
+	struct TablespaceMapping *tablespace_mappings;
+} SyncOptions;
+
+typedef struct TablespaceMapping
+{
+	char	   *old_dir;
+	char	   *new_dir;
+	struct TablespaceMapping *next;
+} TablespaceMapping;
+
+extern void sync_replica(RemoteConn *rconn, const ConnParams *cparams,
+						 const SyncOptions *opts, const char *argv0);
+
+#endif							/* PG_UPGRADE_REPLICA_H */
diff --git a/src/bin/pg_upgrade_replica/reuse.c b/src/bin/pg_upgrade_replica/reuse.c
new file mode 100644
index 00000000000..6a20a54bb66
--- /dev/null
+++ b/src/bin/pg_upgrade_replica/reuse.c
@@ -0,0 +1,915 @@
+/*-------------------------------------------------------------------------
+ *
+ * reuse.c
+ *		Assembles the new standby by forging a backup_manifest anchored at
+ *		the new cluster's own checkpoint (see forge_manifest.h) and driving
+ *		two already-hardened core tools from it: pg_basebackup --incremental
+ *		fetches only what actually changed since that checkpoint (whole
+ *		files for anything pg_upgrade rewrote, changed blocks only for
+ *		anything it left alone but that was written to since), and
+ *		pg_combinebackup reconstructs the final directory by combining that
+ *		against --old-replica's own copy of every relation the manifest
+ *		lists as unchanged.
+ *
+ * Bulk file transfer, tablespace placement, and backup_label/pg_control
+ * handling for --new-replica itself are core's job here, the same code
+ * every pg_basebackup/pg_combinebackup user already relies on. What
+ * remains in this file: validating --old-replica is actually caught up,
+ * presenting --old-replica's data under paths matching the *new*
+ * cluster's layout so pg_combinebackup can find it
+ * (build_old_replica_view(), needed only because tablespace directories
+ * are named after the catalog version, and --old-replica's is
+ * necessarily the old one), and recovery config
+ * (standby.signal/primary_conninfo), which neither child tool has any
+ * reason to know it should write.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * src/bin/pg_upgrade_replica/reuse.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres_fe.h"
+
+#include <dirent.h>
+#include <sys/stat.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "access/xlog_internal.h"
+#include "catalog/catversion.h"
+#include "catalog/pg_control.h"
+#include "common/file_perm.h"
+#include "common/logging.h"
+#include "fe_utils/recovery_gen.h"
+#include "fe_utils/string_utils.h"
+#include "lib/stringinfo.h"
+#include "port.h"
+
+#include "fetch.h"
+#include "forge_manifest.h"
+#include "pg_upgrade_replica.h"
+#include "subprocess.h"
+
+/*
+ * The work directory holds a full staged incremental backup; on any
+ * pg_fatal() exit it would otherwise be left behind, multi-gigabyte and
+ * orphaned, the same failure mode pg_basebackup's and pg_combinebackup's
+ * own output-directory atexit cleanup exists to avoid. Cleared once the
+ * normal, successful path has already removed it itself.
+ */
+static char *work_dir_to_cleanup = NULL;
+
+static void
+cleanup_work_dir_atexit(void)
+{
+	if (work_dir_to_cleanup == NULL)
+		return;
+	if (!rmtree(work_dir_to_cleanup, true))
+		pg_log_warning("could not remove temporary directory \"%s\"",
+					   work_dir_to_cleanup);
+}
+
+typedef struct TablespaceInfo
+{
+	Oid			oid;
+	bool		in_place;		/* stored inside PGDATA itself, no separate
+								 * location */
+	char	   *primary_path;	/* as pg_tablespace_location() reports on the
+								 * new primary */
+	char	   *old_target;		/* --old-replica's own copy of this
+								 * tablespace's data, or "" */
+	char	   *chosen_path;	/* where this tablespace's data will live
+								 * under --new-replica, unused when in_place */
+} TablespaceInfo;
+
+static const char *
+find_tablespace_mapping(const SyncOptions *opts, const char *primary_path)
+{
+	for (TablespaceMapping *m = opts->tablespace_mappings; m != NULL; m = m->next)
+	{
+		if (strcmp(m->old_dir, primary_path) == 0)
+			return m->new_dir;
+	}
+	return NULL;
+}
+
+/*
+ * Appends path to buf with every literal "=" backslash-escaped, the
+ * encoding side of the same OLDDIR=NEWDIR convention
+ * add_tablespace_mapping()'s decoder (pg_upgrade_replica.c, mirroring
+ * pg_basebackup's own tablespace_list_append()) expects on the way in.
+ * Needed here because the OLDDIR/NEWDIR operands we build below come from
+ * pg_tablespace_location() and --tablespace-mapping's own NEWDIR, neither
+ * of which is guaranteed "=" free.
+ */
+static void
+append_tablespace_mapping_operand(PQExpBuffer buf, const char *path)
+{
+	for (const char *p = path; *p; p++)
+	{
+		if (*p == '=')
+			appendPQExpBufferChar(buf, '\\');
+		appendPQExpBufferChar(buf, *p);
+	}
+}
+
+/*
+ * Reads --old-replica's own system identifier and latest checkpoint
+ * location by shelling out to old_bindir's own pg_controldata, the same
+ * pattern pg_upgrade itself uses (controldata.c) rather than parsing
+ * pg_control as a struct: --old-replica is necessarily an *old* major
+ * version's data directory (that's the whole point of this tool), and
+ * pg_control's own binary layout is not guaranteed stable across major
+ * versions -- get_controlfile(), compiled against this tool's own
+ * version, cannot reliably parse a different one's. sscanf("%X/%X", ...)
+ * for the LSN tolerates the zero-padding differences across
+ * pg_controldata builds that a byte-for-byte struct read would not need
+ * to worry about, but a text-parsing approach does.
+ *
+ * Nothing here verifies --old-bindir's own version actually matches
+ * --old-replica's on-disk major version, the same way pg_upgrade itself
+ * doesn't either (check_bin_dir() in exec.c only checks -V for the *new*
+ * cluster's own bindir, never the old one's): a wrong --old-bindir would
+ * need its own pg_controldata to coincidentally produce a sysid and LSN
+ * matching the manifest to slip past check_old_replica_caught_up(), so
+ * this is the same accepted trust in the operator's own arguments core
+ * already extends on the old side.
+ */
+static void
+get_old_replica_controldata(const char *old_bindir, const char *old_replica,
+							uint64 *sysid, XLogRecPtr *chkpnt_loc)
+{
+	PQExpBuffer cmd;
+	char	   *output;
+	char	   *line;
+	bool		got_sysid = false;
+	bool		got_chkpnt = false;
+	char	   *lc_collate = NULL;
+	char	   *lc_ctype = NULL;
+	char	   *lc_monetary = NULL;
+	char	   *lc_numeric = NULL;
+	char	   *lc_time = NULL;
+	char	   *lang = NULL;
+	char	   *language = NULL;
+	char	   *lc_all = NULL;
+	char	   *lc_messages = NULL;
+
+	/*
+	 * We test pg_controldata's output as English strings below, so it has to
+	 * actually be in English. Same env-save/force-C/restore dance
+	 * pg_upgrade's own get_control_data() (controldata.c) does around its own
+	 * pg_controldata call, for the same reason.
+	 */
+	if (getenv("LC_COLLATE"))
+		lc_collate = pg_strdup(getenv("LC_COLLATE"));
+	if (getenv("LC_CTYPE"))
+		lc_ctype = pg_strdup(getenv("LC_CTYPE"));
+	if (getenv("LC_MONETARY"))
+		lc_monetary = pg_strdup(getenv("LC_MONETARY"));
+	if (getenv("LC_NUMERIC"))
+		lc_numeric = pg_strdup(getenv("LC_NUMERIC"));
+	if (getenv("LC_TIME"))
+		lc_time = pg_strdup(getenv("LC_TIME"));
+	if (getenv("LANG"))
+		lang = pg_strdup(getenv("LANG"));
+	if (getenv("LANGUAGE"))
+		language = pg_strdup(getenv("LANGUAGE"));
+	if (getenv("LC_ALL"))
+		lc_all = pg_strdup(getenv("LC_ALL"));
+	if (getenv("LC_MESSAGES"))
+		lc_messages = pg_strdup(getenv("LC_MESSAGES"));
+
+	unsetenv("LC_COLLATE");
+	unsetenv("LC_CTYPE");
+	unsetenv("LC_MONETARY");
+	unsetenv("LC_NUMERIC");
+	unsetenv("LC_TIME");
+#ifndef WIN32
+	unsetenv("LANG");
+#else
+	/* On Windows the default locale may not be English, so force it */
+	setenv("LANG", "en", 1);
+#endif
+	unsetenv("LANGUAGE");
+	unsetenv("LC_ALL");
+	setenv("LC_MESSAGES", "C", 1);
+
+	cmd = createPQExpBuffer();
+	{
+		char		pg_controldata_path[MAXPGPATH];
+
+		snprintf(pg_controldata_path, sizeof(pg_controldata_path),
+				 "%s/pg_controldata", old_bindir);
+		appendShellString(cmd, pg_controldata_path);
+	}
+	appendPQExpBufferChar(cmd, ' ');
+	appendShellString(cmd, old_replica);
+	output = run_pg_tool_capture(cmd->data);
+	destroyPQExpBuffer(cmd);
+
+	if (lc_collate)
+		setenv("LC_COLLATE", lc_collate, 1);
+	if (lc_ctype)
+		setenv("LC_CTYPE", lc_ctype, 1);
+	if (lc_monetary)
+		setenv("LC_MONETARY", lc_monetary, 1);
+	if (lc_numeric)
+		setenv("LC_NUMERIC", lc_numeric, 1);
+	if (lc_time)
+		setenv("LC_TIME", lc_time, 1);
+	if (lang)
+		setenv("LANG", lang, 1);
+	else
+		unsetenv("LANG");
+	if (language)
+		setenv("LANGUAGE", language, 1);
+	if (lc_all)
+		setenv("LC_ALL", lc_all, 1);
+	if (lc_messages)
+		setenv("LC_MESSAGES", lc_messages, 1);
+	else
+		unsetenv("LC_MESSAGES");
+
+	pg_free(lc_collate);
+	pg_free(lc_ctype);
+	pg_free(lc_monetary);
+	pg_free(lc_numeric);
+	pg_free(lc_time);
+	pg_free(lang);
+	pg_free(language);
+	pg_free(lc_all);
+	pg_free(lc_messages);
+
+	for (line = strtok(output, "\n"); line != NULL; line = strtok(NULL, "\n"))
+	{
+		char	   *p;
+
+		if ((p = strstr(line, "Database system identifier:")) != NULL)
+		{
+			p = strchr(p, ':');
+			if (p == NULL || strlen(p) <= 1)
+				pg_fatal("could not parse system identifier reported by "
+						 "\"%s/pg_controldata\" for \"%s\"", old_bindir, old_replica);
+			p++;
+			*sysid = strtou64(p, NULL, 10);
+			got_sysid = true;
+		}
+		else if ((p = strstr(line, "Latest checkpoint location:")) != NULL)
+		{
+			uint32		hi;
+			uint32		lo;
+
+			p = strchr(p, ':');
+			if (p == NULL || strlen(p) <= 1)
+				pg_fatal("could not parse checkpoint location reported by "
+						 "\"%s/pg_controldata\" for \"%s\"", old_bindir, old_replica);
+			p++;
+			if (sscanf(p, "%X/%X", &hi, &lo) != 2)
+				pg_fatal("could not parse checkpoint location reported by "
+						 "\"%s/pg_controldata\" for \"%s\"", old_bindir, old_replica);
+			*chkpnt_loc = ((uint64) hi) << 32 | lo;
+			got_chkpnt = true;
+		}
+	}
+	pg_free(output);
+
+	if (!got_sysid || !got_chkpnt)
+		pg_fatal("\"%s/pg_controldata\" did not report a system identifier "
+				 "and checkpoint location for \"%s\"", old_bindir, old_replica);
+}
+
+/*
+ * Refuses to proceed unless the old replica's own pg_control shows it was
+ * caught up to the exact old-cluster checkpoint recorded in the manifest.
+ * Without this, a lagging replica's reused files would be silently stale
+ * forever: the new cluster's recovery only ever replays forward from its
+ * own checkpoint, never backward to fill in what an old replica missed.
+ */
+static void
+check_old_replica_caught_up(const char *old_bindir, const char *old_replica,
+							const Manifest *manifest)
+{
+	uint64		sysid;
+	XLogRecPtr	chkpnt_loc;
+	char		postmaster_pid_path[MAXPGPATH];
+
+	/*
+	 * pg_controldata's own "Latest checkpoint location" only reflects a
+	 * restartpoint actually flushed to pg_control, which for a running
+	 * standby happens on an unpredictable schedule -- reliably only via a
+	 * clean shutdown. Refuse rather than risk a confusing "not caught up"
+	 * rejection against a replica that genuinely received everything but just
+	 * hasn't taken a restartpoint at exactly this LSN yet, and rather than
+	 * reuse files out of a directory whose state was never confirmed static.
+	 */
+	snprintf(postmaster_pid_path, sizeof(postmaster_pid_path),
+			 "%s/postmaster.pid", old_replica);
+	if (access(postmaster_pid_path, F_OK) == 0)
+		pg_fatal("refusing to sync: \"%s\" exists -- --old-replica must be "
+				 "cleanly shut down before running this tool", postmaster_pid_path);
+
+	get_old_replica_controldata(old_bindir, old_replica, &sysid, &chkpnt_loc);
+
+	if (sysid != manifest->old_sysid)
+		pg_fatal("refusing to sync: old replica system identifier "
+				 "%llu does not match old primary's %llu "
+				 "(wrong replica for this upgrade)",
+				 (unsigned long long) sysid,
+				 (unsigned long long) manifest->old_sysid);
+
+	if (chkpnt_loc != manifest->old_chkpnt_loc)
+		pg_fatal("refusing to sync: old replica's latest checkpoint location "
+				 "%X/%08X does not match the old primary's shutdown checkpoint "
+				 "%X/%08X recorded in the manifest -- the replica is not "
+				 "caught up (it must stay running/streaming through the "
+				 "primary's pre-upgrade shutdown), so unchanged relation "
+				 "files on it cannot be trusted",
+				 LSN_FORMAT_ARGS(chkpnt_loc),
+				 LSN_FORMAT_ARGS(manifest->old_chkpnt_loc));
+
+	pg_log_info("old replica caught up: system identifier and checkpoint "
+				"location both match the old primary");
+}
+
+/*
+ * TABLESPACE_VERSION_DIRECTORY (relpath.h) names each tablespace
+ * subdirectory "PG_" PG_MAJORVERSION "_" CATALOG_VERSION_NO. Derive it from
+ * the connection's own server_version_num and this build's own compiled-in
+ * CATALOG_VERSION_NO -- not from anything fetched from the new primary or
+ * read from a file, since remote_connect() (fetch.c) already refused to
+ * proceed if this build's own catalog version didn't match the new
+ * primary's, so its own catalog version already equals the new cluster's.
+ */
+static char *
+tablespace_version_dir(RemoteConn *rconn)
+{
+	int			version_num = PQserverVersion(rconn->conn);
+
+	return psprintf("PG_%d_%u", version_num / 10000, CATALOG_VERSION_NO);
+}
+
+/*
+ * Discovers every non-default tablespace via the pg_tablespace catalog
+ * (authoritative, unlike inferring it from a directory listing) and
+ * decides where each one's data should live on this host. Doesn't create
+ * anything: pg_basebackup's own -T handling already verifies each target
+ * directory is empty (or creates it) exactly the same way it already does
+ * for -D itself, so there's nothing left here for this tool to duplicate.
+ */
+static TablespaceInfo *
+discover_tablespaces(RemoteConn *rconn, const SyncOptions *opts, int *n_entries)
+{
+	PGresult   *res;
+	int			n;
+	TablespaceInfo *entries;
+
+	res = PQexec(rconn->conn,
+				 "SELECT oid, pg_tablespace_location(oid) FROM pg_tablespace "
+				 "WHERE pg_tablespace_location(oid) != ''");
+	if (PQresultStatus(res) != PGRES_TUPLES_OK)
+		pg_fatal("could not list tablespaces: %s", PQresultErrorMessage(res));
+
+	n = PQntuples(res);
+	entries = pg_malloc_array(TablespaceInfo, n);
+
+	for (int i = 0; i < n; i++)
+	{
+		Oid			oid = (Oid) strtoul(PQgetvalue(res, i, 0), NULL, 10);
+		const char *primary_path = PQgetvalue(res, i, 1);
+		char		old_target[MAXPGPATH];
+		const char *mapped;
+		bool		in_place = !is_absolute_path(primary_path);
+
+		if (in_place)
+		{
+			/*
+			 * pg_tablespace_location() returns a path relative to PGDATA for
+			 * an in-place tablespace (allow_in_place_tablespaces,
+			 * testing-only): its data lives at pg_tblspc/<oid> inside the
+			 * data directory itself, not at a separate, relocatable location
+			 * -- there is no symlink to read, on either side, and neither
+			 * pg_basebackup nor pg_combinebackup take a -T entry for it, they
+			 * place it automatically. --old-replica's own copy is at the same
+			 * fixed spot for the same reason.
+			 */
+			struct stat st;
+
+			snprintf(old_target, sizeof(old_target), "%s/pg_tblspc/%u",
+					 opts->old_replica, oid);
+			if (stat(old_target, &st) != 0)
+			{
+				if (errno == ENOENT)
+					old_target[0] = '\0';
+				else
+					pg_fatal("could not stat \"%s\": %m", old_target);
+			}
+		}
+		else
+		{
+			char		old_link[MAXPGPATH];
+			ssize_t		len;
+
+			snprintf(old_link, sizeof(old_link), "%s/pg_tblspc/%u",
+					 opts->old_replica, oid);
+			len = readlink(old_link, old_target, sizeof(old_target) - 1);
+			if (len >= 0)
+			{
+				if (len == sizeof(old_target) - 1)
+					pg_fatal("refusing to sync: symlink target of \"%s\" is too "
+							 "long to fit in %zu bytes", old_link, sizeof(old_target));
+				old_target[len] = '\0';
+			}
+			else if (errno == ENOENT)
+				old_target[0] = '\0';
+			else
+				pg_fatal("could not read symbolic link \"%s\": %m", old_link);
+		}
+
+		/*
+		 * find_tablespace_mapping() matches by primary_path now, and every
+		 * --tablespace-mapping OLDDIR is required to be absolute (checked at
+		 * parse time) -- an in-place tablespace's own primary_path never is,
+		 * so no valid mapping can ever match one. Nothing further to check
+		 * here for that case.
+		 */
+		mapped = find_tablespace_mapping(opts, primary_path);
+
+		entries[i].oid = oid;
+		entries[i].in_place = in_place;
+		entries[i].primary_path = pg_strdup(primary_path);
+		entries[i].old_target = pg_strdup(old_target);
+		entries[i].chosen_path = pg_strdup(mapped != NULL ? mapped :
+										   old_target[0] != '\0' ? old_target :
+										   primary_path);
+	}
+	PQclear(res);
+
+	*n_entries = n;
+	return entries;
+}
+
+/*
+ * pg_upgrade always resets the new cluster to timeline 1 (see
+ * copy_xact_xlog_xid()'s final pg_resetwal call), but that's a fact about
+ * the moment pg_upgrade finished, not necessarily about the moment this
+ * tool runs against it -- ask the new primary for its actual current
+ * timeline rather than assuming, in case anything timeline-advancing
+ * happened to it in between.
+ */
+static uint32
+get_current_timeline(RemoteConn *rconn)
+{
+	char	   *str = run_scalar_query(rconn->conn,
+									   "SELECT timeline_id FROM pg_control_checkpoint()");
+	uint32		timeline = (uint32) strtoul(str, NULL, 10);
+
+	pg_free(str);
+	return timeline;
+}
+
+static uint64
+get_system_identifier(RemoteConn *rconn)
+{
+	char	   *str = run_scalar_query(rconn->conn,
+									   "SELECT system_identifier FROM pg_control_system()");
+	uint64		sysid = strtou64(str, NULL, 10);
+
+	pg_free(str);
+	return sysid;
+}
+
+/*
+ * pg_combinebackup requires every input directory -- including
+ * --old-replica, standing in for the "prior backup" in its combine chain
+ * -- to carry a valid global/pg_control (matching the *new* cluster's own
+ * system identifier: this run's whole point is treating --old-replica's
+ * files as a stand-in for what the new cluster looked like at its own
+ * checkpoint, not for the old cluster it actually is) and a backup_label
+ * establishing where that stand-in "backup" starts, matching exactly the
+ * anchor forge_manifest() already put in the manifest's WAL-Ranges entry.
+ * pg_basebackup's own generated backup_label for the incremental side
+ * encodes "INCREMENTAL FROM" using that same anchor, taken from the
+ * manifest we hand it -- so the two agree by construction, not by luck.
+ *
+ * A plain byte copy of the new primary's live pg_control is safe to trust
+ * here even though it's never parsed or rewritten: read_backup_label()
+ * overrides checkPoint/state and re-derives minRecoveryPoint from
+ * checkPoint.redo on startup regardless of what's already in this file,
+ * and pg_control's active content is capped at PG_CONTROL_MAX_SAFE_SIZE
+ * (512 bytes, one disk sector) specifically so concurrent reads through
+ * the same page cache are atomic. This copy of pg_control is never itself
+ * part of the final --new-replica output -- that one comes from
+ * pg_basebackup's own real fetch of it -- so its ordering relative to
+ * everything else here doesn't matter.
+ */
+static void
+write_old_replica_view_metadata(RemoteConn *rconn, const char *view_dir,
+								XLogRecPtr new_chkpnt_loc, uint32 timeline)
+{
+	char	   *control_raw;
+	size_t		control_len;
+	ControlFileData *control_data;
+	char		path[MAXPGPATH];
+	FILE	   *f;
+	char		strftime_buf[128];
+	time_t		now = time(NULL);
+	XLogSegNo	segno;
+	char		wal_file_name[MAXFNAMELEN];
+
+	snprintf(path, sizeof(path), "%s/global", view_dir);
+	if (pg_mkdir_p(path, pg_dir_create_mode) != 0 && errno != EEXIST)
+		pg_fatal("could not create directory \"%s\": %m", path);
+
+	snprintf(path, sizeof(path), "%s/global/pg_control", rconn->data_directory);
+	control_raw = remote_read_whole_file(rconn, path, &control_len);
+	if (control_len < sizeof(ControlFileData))
+		pg_fatal("file \"%s\" is shorter than expected", path);
+
+	/*
+	 * remote_connect() (fetch.c) already refused to proceed if this build's
+	 * own pg_control_version didn't match the new primary's, so pg_control's
+	 * own layout here is guaranteed byte-compatible -- unlike --old-replica's
+	 * copy (see get_old_replica_controldata()'s own comment), which is
+	 * necessarily a different major version and has to be read via
+	 * pg_controldata text output instead.
+	 */
+	control_data = (ControlFileData *) control_raw;
+	XLByteToSeg(new_chkpnt_loc, segno, control_data->xlog_seg_size);
+	XLogFileName(wal_file_name, timeline, segno, control_data->xlog_seg_size);
+
+	snprintf(path, sizeof(path), "%s/global/pg_control", view_dir);
+	f = fopen(path, "wb");
+	if (f == NULL)
+		pg_fatal("could not create file \"%s\": %m", path);
+	if (fwrite(control_raw, 1, control_len, f) != control_len)
+		pg_fatal("could not write file \"%s\": %m", path);
+	if (fclose(f) != 0)
+		pg_fatal("could not write file \"%s\": %m", path);
+	pg_free(control_raw);
+
+	strftime(strftime_buf, sizeof(strftime_buf), "%Y-%m-%d %H:%M:%S %Z",
+			 localtime(&now));
+
+	snprintf(path, sizeof(path), "%s/backup_label", view_dir);
+	f = fopen(path, "w");
+	if (f == NULL)
+		pg_fatal("could not create file \"%s\": %m", path);
+	/* Deliberately no "INCREMENTAL FROM": this is the base of the chain. */
+	fprintf(f,
+			"START WAL LOCATION: %X/%08X (file %s)\n"
+			"CHECKPOINT LOCATION: %X/%08X\n"
+			"BACKUP METHOD: streamed\n"
+			"BACKUP FROM: primary\n"
+			"START TIME: %s\n"
+			"LABEL: pg_upgrade_replica old-replica-view\n"
+			"START TIMELINE: %u\n",
+			LSN_FORMAT_ARGS(new_chkpnt_loc), wal_file_name,
+			LSN_FORMAT_ARGS(new_chkpnt_loc), strftime_buf, timeline);
+	if (fclose(f) != 0)
+		pg_fatal("could not write file \"%s\": %m", path);
+}
+
+/*
+ * Makes --old-replica's data reachable under paths matching the *new*
+ * cluster's layout, without copying or modifying anything in
+ * --old-replica itself: pg_combinebackup looks up a reused file's "prior"
+ * copy by joining the *new* cluster's own relative path (exactly as the
+ * forged manifest states it) onto this view directory, and a tablespace's
+ * relative path includes its version-dir component, which necessarily
+ * differs between the two clusters (old catalog version vs. new).
+ *
+ * base/ has no such component, so it can just be symlinked wholesale.
+ * Each tablespace with any local data on --old-replica gets its own real
+ * directory containing one symlink, renaming only the version-dir
+ * component; a tablespace --old-replica has no copy of at all is simply
+ * omitted here (forge_manifest() already won't have listed anything from
+ * it, so pg_basebackup fetches all of it fresh, and pg_combinebackup never
+ * needs to look inside this view for it).
+ */
+static char *
+build_old_replica_view(const char *work_dir, const char *old_replica,
+					   const char *new_version_dir,
+					   const TablespaceInfo *tablespaces, int n_tablespaces)
+{
+	char		view_dir[MAXPGPATH];
+	char		link_path[MAXPGPATH];
+	char		target_path[MAXPGPATH];
+
+	snprintf(view_dir, sizeof(view_dir), "%s/old_replica_view", work_dir);
+	if (pg_mkdir_p(view_dir, pg_dir_create_mode) != 0 && errno != EEXIST)
+		pg_fatal("could not create directory \"%s\": %m", view_dir);
+
+	snprintf(link_path, sizeof(link_path), "%s/base", view_dir);
+	snprintf(target_path, sizeof(target_path), "%s/base", old_replica);
+	if (symlink(target_path, link_path) != 0)
+		pg_fatal("could not create symbolic link \"%s\": %m", link_path);
+
+	if (n_tablespaces > 0)
+	{
+		snprintf(link_path, sizeof(link_path), "%s/pg_tblspc", view_dir);
+		if (pg_mkdir_p(link_path, pg_dir_create_mode) != 0 && errno != EEXIST)
+			pg_fatal("could not create directory \"%s\": %m", link_path);
+	}
+
+	for (int i = 0; i < n_tablespaces; i++)
+	{
+		char		ts_dir[MAXPGPATH];
+		DIR		   *dir;
+		struct dirent *de;
+		char	   *old_version_dir = NULL;
+
+		if (tablespaces[i].old_target[0] == '\0')
+			continue;
+
+		dir = opendir(tablespaces[i].old_target);
+		if (dir == NULL)
+			pg_fatal("could not open directory \"%s\": %m",
+					 tablespaces[i].old_target);
+		while ((de = readdir(dir)) != NULL)
+		{
+			unsigned	major,
+						catver;
+			int			nchars;
+
+			if (sscanf(de->d_name, "PG_%u_%u%n", &major, &catver, &nchars) == 2 &&
+				nchars == (int) strlen(de->d_name))
+			{
+				if (old_version_dir != NULL)
+					pg_fatal("refusing to sync: \"%s\" has more than one "
+							 "version subdirectory (\"%s\" and \"%s\")",
+							 tablespaces[i].old_target, old_version_dir,
+							 de->d_name);
+				old_version_dir = pg_strdup(de->d_name);
+			}
+		}
+		closedir(dir);
+		if (old_version_dir == NULL)
+			pg_fatal("refusing to sync: \"%s\" has no version subdirectory",
+					 tablespaces[i].old_target);
+
+		snprintf(ts_dir, sizeof(ts_dir), "%s/pg_tblspc/%u", view_dir,
+				 tablespaces[i].oid);
+		if (pg_mkdir_p(ts_dir, pg_dir_create_mode) != 0 && errno != EEXIST)
+			pg_fatal("could not create directory \"%s\": %m", ts_dir);
+
+		snprintf(link_path, sizeof(link_path), "%s/%s", ts_dir, new_version_dir);
+		snprintf(target_path, sizeof(target_path), "%s/%s",
+				 tablespaces[i].old_target, old_version_dir);
+		if (symlink(target_path, link_path) != 0)
+			pg_fatal("could not create symbolic link \"%s\": %m", link_path);
+		pg_free(old_version_dir);
+	}
+
+	return pg_strdup(view_dir);
+}
+
+/*
+ * Builds a KeptTablespace array (just oid + old_target, forge_manifest()'s
+ * own concern) from the richer TablespaceInfo array this file otherwise
+ * needs.
+ */
+static KeptTablespace *
+as_kept_tablespaces(const TablespaceInfo *tablespaces, int n)
+{
+	KeptTablespace *out = pg_malloc_array(KeptTablespace, Max(n, 1));
+
+	for (int i = 0; i < n; i++)
+	{
+		out[i].oid = tablespaces[i].oid;
+		out[i].old_target = tablespaces[i].old_target;
+	}
+	return out;
+}
+
+void
+sync_replica(RemoteConn *rconn, const ConnParams *cparams,
+			 const SyncOptions *opts, const char *argv0)
+{
+	char	   *manifest_raw;
+	size_t		manifest_len;
+	char		manifest_path[MAXPGPATH];
+	Manifest   *manifest;
+	uint32		timeline;
+	char	   *version_dir;
+	int			n_tablespaces;
+	TablespaceInfo *tablespaces;
+	KeptTablespace *kept_tablespaces;
+	uint64		system_identifier;
+	char		work_dir_template[MAXPGPATH];
+	char	   *work_dir;
+	char	   *view_dir;
+	char		staging_dir[MAXPGPATH];
+	char	   *pg_basebackup_path;
+	char	   *pg_combinebackup_path;
+	PQExpBuffer cmd;
+	int			n_kept_files;
+
+	snprintf(manifest_path, sizeof(manifest_path), "%s/pg_upgrade_manifest",
+			 rconn->data_directory);
+	manifest_raw = remote_read_whole_file(rconn, manifest_path, &manifest_len);
+	manifest = parse_manifest(manifest_raw, manifest_len);
+	pg_free(manifest_raw);
+	pg_log_info("manifest fetched and parsed successfully");
+
+	check_old_replica_caught_up(opts->old_bindir, opts->old_replica, manifest);
+
+	version_dir = tablespace_version_dir(rconn);
+	tablespaces = discover_tablespaces(rconn, opts, &n_tablespaces);
+	if (n_tablespaces > 0)
+		pg_log_info("tablespaces: %d found", n_tablespaces);
+
+	system_identifier = get_system_identifier(rconn);
+	timeline = get_current_timeline(rconn);
+
+	/*
+	 * The work directory is created next to --new-replica (not under it):
+	 * --new-replica itself must not exist or be empty when pg_basebackup gets
+	 * to -D it, and pg_combinebackup's own --link needs its inputs on the
+	 * same filesystem as its output to hardlink at all.
+	 */
+	{
+		char	   *new_replica_abs = make_absolute_path(opts->new_replica);
+		char		new_replica_parent[MAXPGPATH];
+
+		strlcpy(new_replica_parent, new_replica_abs, sizeof(new_replica_parent));
+		get_parent_directory(new_replica_parent);
+		snprintf(work_dir_template, sizeof(work_dir_template),
+				 "%s/pgur_work-XXXXXX", new_replica_parent);
+		pg_free(new_replica_abs);
+	}
+	work_dir = mkdtemp(work_dir_template);
+	if (work_dir == NULL)
+		pg_fatal("could not create temporary directory \"%s\": %m",
+				 work_dir_template);
+	work_dir_to_cleanup = work_dir;
+	atexit(cleanup_work_dir_atexit);
+
+	view_dir = build_old_replica_view(work_dir, opts->old_replica, version_dir,
+									  tablespaces, n_tablespaces);
+	write_old_replica_view_metadata(rconn, view_dir, manifest->new_chkpnt_loc,
+									timeline);
+
+	kept_tablespaces = as_kept_tablespaces(tablespaces, n_tablespaces);
+	n_kept_files = forge_manifest(work_dir, opts->old_replica,
+								  manifest, system_identifier, timeline,
+								  version_dir, kept_tablespaces, n_tablespaces);
+	pg_log_info("manifest lists %d relation files as unchanged, anchored at "
+				"checkpoint %X/%08X", n_kept_files,
+				LSN_FORMAT_ARGS(manifest->new_chkpnt_loc));
+	pg_free(kept_tablespaces);
+
+	/*
+	 * pg_combinebackup treats a missing backup_manifest in an input directory
+	 * as merely unable to cross-check its WAL range, not fatal, but it warns
+	 * loudly. view_dir describes the exact same forged backup as
+	 * work_dir/backup_manifest, so give it the same manifest too, rather than
+	 * leave every run printing a warning about something that isn't actually
+	 * wrong.
+	 */
+	{
+		char		forged_manifest[MAXPGPATH];
+		char		view_manifest[MAXPGPATH];
+
+		snprintf(forged_manifest, sizeof(forged_manifest), "%s/backup_manifest",
+				 work_dir);
+		snprintf(view_manifest, sizeof(view_manifest), "%s/backup_manifest",
+				 view_dir);
+		if (link(forged_manifest, view_manifest) != 0)
+			pg_fatal("could not link \"%s\" to \"%s\": %m",
+					 forged_manifest, view_manifest);
+	}
+
+	pg_basebackup_path = find_sibling_exec(argv0, "pg_basebackup");
+	pg_combinebackup_path = find_sibling_exec(argv0, "pg_combinebackup");
+
+	snprintf(staging_dir, sizeof(staging_dir), "%s/staging", work_dir);
+
+	cmd = createPQExpBuffer();
+	appendShellString(cmd, pg_basebackup_path);
+	appendPQExpBufferStr(cmd, " -D ");
+	appendShellString(cmd, staging_dir);
+	appendPQExpBufferStr(cmd, " -i ");
+	{
+		char		staging_manifest_path[MAXPGPATH];
+
+		snprintf(staging_manifest_path, sizeof(staging_manifest_path),
+				 "%s/backup_manifest", work_dir);
+		appendShellString(cmd, staging_manifest_path);
+	}
+	appendPQExpBufferStr(cmd, " --no-sync");
+	if (cparams->pghost)
+	{
+		appendPQExpBufferStr(cmd, " -h ");
+		appendShellString(cmd, cparams->pghost);
+	}
+	if (cparams->pgport)
+	{
+		appendPQExpBufferStr(cmd, " -p ");
+		appendShellString(cmd, cparams->pgport);
+	}
+	if (cparams->pguser)
+	{
+		appendPQExpBufferStr(cmd, " -U ");
+		appendShellString(cmd, cparams->pguser);
+	}
+	if (cparams->prompt_password == TRI_NO)
+		appendPQExpBufferStr(cmd, " -w");
+	for (int i = 0; i < n_tablespaces; i++)
+		if (!tablespaces[i].in_place)
+		{
+			char		staging_ts_dir[MAXPGPATH];
+			PQExpBuffer mapping;
+
+			/*
+			 * pg_basebackup's own -T target is where it stages this
+			 * tablespace's incremental output (INCREMENTAL.* fragments, same
+			 * as -D itself), not the final destination: it needs
+			 * pg_combinebackup to process it afterward, same as -D's own
+			 * staging_dir does. Pointing pg_basebackup directly at
+			 * chosen_path (the actual final directory) here would make
+			 * pg_combinebackup's own -T below try to treat chosen_path as
+			 * both its input and its output at once, which it refuses (wants
+			 * its output pre-empty).
+			 */
+			snprintf(staging_ts_dir, sizeof(staging_ts_dir),
+					 "%s/staging_ts_%u", work_dir, tablespaces[i].oid);
+			mapping = createPQExpBuffer();
+			append_tablespace_mapping_operand(mapping, tablespaces[i].primary_path);
+			appendPQExpBufferChar(mapping, '=');
+			append_tablespace_mapping_operand(mapping, staging_ts_dir);
+			appendPQExpBufferStr(cmd, " -T ");
+			appendShellString(cmd, mapping->data);
+			destroyPQExpBuffer(mapping);
+		}
+
+	pg_log_info("fetching incremental backup from the new primary ...");
+	run_pg_tool(cmd->data);
+	resetPQExpBuffer(cmd);
+
+	appendShellString(cmd, pg_combinebackup_path);
+	appendPQExpBufferChar(cmd, ' ');
+	appendShellString(cmd, view_dir);
+	appendPQExpBufferChar(cmd, ' ');
+	appendShellString(cmd, staging_dir);
+	appendPQExpBufferStr(cmd, " -o ");
+	appendShellString(cmd, opts->new_replica);
+	for (int i = 0; i < n_tablespaces; i++)
+		if (!tablespaces[i].in_place)
+		{
+			char		staging_ts_dir[MAXPGPATH];
+			PQExpBuffer mapping;
+
+			snprintf(staging_ts_dir, sizeof(staging_ts_dir),
+					 "%s/staging_ts_%u", work_dir, tablespaces[i].oid);
+			mapping = createPQExpBuffer();
+			append_tablespace_mapping_operand(mapping, staging_ts_dir);
+			appendPQExpBufferChar(mapping, '=');
+			append_tablespace_mapping_operand(mapping, tablespaces[i].chosen_path);
+			appendPQExpBufferStr(cmd, " -T ");
+			appendShellString(cmd, mapping->data);
+			destroyPQExpBuffer(mapping);
+		}
+	if (opts->link)
+		appendPQExpBufferStr(cmd, " -k");
+	if (opts->no_sync)
+		appendPQExpBufferStr(cmd, " -N");
+
+	pg_log_info("reconstructing the new standby ...");
+	run_pg_tool(cmd->data);
+	destroyPQExpBuffer(cmd);
+
+	/*
+	 * From here until WriteRecoveryConfig() finishes, --new-replica has a
+	 * backup_label (pg_combinebackup already wrote it) but not yet a
+	 * standby.signal: if this process is killed in that window, the result
+	 * looks like a complete data directory but would come up as an
+	 * independent primary, not a standby, if started directly. This is the
+	 * same window pg_basebackup -R has with this same pair of calls, just
+	 * wider here because pg_combinebackup's own run sits in front of it
+	 * instead of a single tar extraction. See the tool's own documentation
+	 * for the operational guidance this implies.
+	 */
+	{
+		PQExpBuffer recovery_conf = GenerateRecoveryConfig(rconn->conn, NULL, NULL);
+
+		WriteRecoveryConfig(rconn->conn, opts->new_replica, recovery_conf);
+		destroyPQExpBuffer(recovery_conf);
+	}
+
+	if (!rmtree(work_dir, true))
+		pg_log_warning("could not remove temporary directory \"%s\"", work_dir);
+	work_dir_to_cleanup = NULL;
+
+	pg_free(pg_basebackup_path);
+	pg_free(pg_combinebackup_path);
+	pg_free(view_dir);
+	for (int i = 0; i < n_tablespaces; i++)
+	{
+		pg_free(tablespaces[i].primary_path);
+		pg_free(tablespaces[i].old_target);
+		pg_free(tablespaces[i].chosen_path);
+	}
+	pg_free(tablespaces);
+	pg_free(version_dir);
+}
diff --git a/src/bin/pg_upgrade_replica/subprocess.c b/src/bin/pg_upgrade_replica/subprocess.c
new file mode 100644
index 00000000000..7a2019aae3f
--- /dev/null
+++ b/src/bin/pg_upgrade_replica/subprocess.c
@@ -0,0 +1,115 @@
+/*-------------------------------------------------------------------------
+ *
+ * subprocess.c
+ *		Locating and running the pg_basebackup/pg_combinebackup binaries
+ *		this tool now drives as subprocesses. Follows the same pattern
+ *		pg_createsubscriber and pg_upgrade already use for shelling out to
+ *		a sibling frontend tool: find_other_exec() to locate and
+ *		version-check the binary, system() to run it. Callers build each
+ *		command line with appendShellString() (fe_utils/string_utils.h),
+ *		the same properly shell-quoting helper pg_createsubscriber and
+ *		pg_rewind already use for their own subprocess arguments, rather
+ *		than a bare double-quoted %s.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * src/bin/pg_upgrade_replica/subprocess.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres_fe.h"
+
+#include "common/logging.h"
+#include "lib/stringinfo.h"
+#include "port.h"
+
+#include "subprocess.h"
+
+/*
+ * Finds progname (e.g. "pg_basebackup") next to this tool's own binary,
+ * and confirms it's the same PostgreSQL version. Returns its full path;
+ * the caller owns the returned string.
+ */
+char *
+find_sibling_exec(const char *argv0, const char *progname)
+{
+	char	   *versionstr;
+	char	   *exec_path;
+	int			ret;
+
+	versionstr = psprintf("%s (PostgreSQL) %s\n", progname, PG_VERSION);
+	exec_path = pg_malloc(MAXPGPATH);
+	ret = find_other_exec(argv0, progname, versionstr, exec_path);
+
+	if (ret < 0)
+	{
+		char		full_path[MAXPGPATH];
+
+		if (find_my_exec(argv0, full_path) < 0)
+			strlcpy(full_path, progname, sizeof(full_path));
+
+		if (ret == -1)
+			pg_fatal("program \"%s\" is needed by %s but was not found in "
+					 "the same directory as \"%s\"",
+					 progname, "pg_upgrade_replica", full_path);
+		else
+			pg_fatal("program \"%s\" was found by \"%s\" but was not the "
+					 "same version as pg_upgrade_replica",
+					 progname, full_path);
+	}
+
+	pg_free(versionstr);
+	pg_log_debug("%s path is: %s", progname, exec_path);
+
+	return exec_path;
+}
+
+/*
+ * Runs an already-fully-quoted shell command, fatal on anything but a clean
+ * exit. Output is not redirected: pg_basebackup/pg_combinebackup's own
+ * progress and error messages go straight to this tool's stdout/stderr,
+ * same as any other command a user runs interactively.
+ */
+void
+run_pg_tool(const char *cmd)
+{
+	int			rc;
+
+	pg_log_debug("running: %s", cmd);
+	fflush(NULL);
+	rc = system(cmd);
+	if (rc != 0)
+		pg_fatal("command failed: %s: %s", cmd, wait_result_to_str(rc));
+}
+
+/*
+ * Like run_pg_tool(), but for a command whose stdout this tool needs to
+ * read itself (pg_controldata's text output, in practice), rather than one
+ * whose progress/error messages should just pass through to the user.
+ * Returns the captured output; the caller owns the returned string.
+ */
+char *
+run_pg_tool_capture(const char *cmd)
+{
+	FILE	   *fp;
+	StringInfoData buf;
+	char		chunk[4096];
+	size_t		nread;
+	int			rc;
+
+	pg_log_debug("running: %s", cmd);
+	fflush(NULL);
+	fp = popen(cmd, "r");
+	if (fp == NULL)
+		pg_fatal("could not execute command \"%s\": %m", cmd);
+
+	initStringInfo(&buf);
+	while ((nread = fread(chunk, 1, sizeof(chunk), fp)) > 0)
+		appendBinaryStringInfo(&buf, chunk, nread);
+
+	rc = pclose(fp);
+	if (rc != 0)
+		pg_fatal("command failed: %s: %s", cmd, wait_result_to_str(rc));
+
+	return buf.data;
+}
diff --git a/src/bin/pg_upgrade_replica/subprocess.h b/src/bin/pg_upgrade_replica/subprocess.h
new file mode 100644
index 00000000000..44860649dbb
--- /dev/null
+++ b/src/bin/pg_upgrade_replica/subprocess.h
@@ -0,0 +1,21 @@
+/*-------------------------------------------------------------------------
+ *
+ * subprocess.h
+ *		Locating and running the pg_basebackup/pg_combinebackup binaries
+ *		this tool now drives as subprocesses, instead of doing its own
+ *		bulk file transfer.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * src/bin/pg_upgrade_replica/subprocess.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PGUR_SUBPROCESS_H
+#define PGUR_SUBPROCESS_H
+
+extern char *find_sibling_exec(const char *argv0, const char *progname);
+extern void run_pg_tool(const char *cmd);
+extern char *run_pg_tool_capture(const char *cmd);
+
+#endif							/* PGUR_SUBPROCESS_H */
diff --git a/src/bin/pg_upgrade_replica/t/001_basic.pl b/src/bin/pg_upgrade_replica/t/001_basic.pl
new file mode 100644
index 00000000000..09e8cb81c92
--- /dev/null
+++ b/src/bin/pg_upgrade_replica/t/001_basic.pl
@@ -0,0 +1,13 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+program_help_ok('pg_upgrade_replica');
+program_version_ok('pg_upgrade_replica');
+program_options_handling_ok('pg_upgrade_replica');
+
+done_testing();
diff --git a/src/bin/pg_upgrade_replica/t/002_sync.pl b/src/bin/pg_upgrade_replica/t/002_sync.pl
new file mode 100644
index 00000000000..4e2a25bccd1
--- /dev/null
+++ b/src/bin/pg_upgrade_replica/t/002_sync.pl
@@ -0,0 +1,373 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+#
+# End-to-end test: a standby taken before a same-version self-upgrade (the
+# pg_upgrade test mode also used by pg_upgrade's own TAP suite) is rebuilt
+# with pg_upgrade_replica against the upgraded primary, without ever
+# re-cloning the dataset, and boots as a working standby of it.
+#
+# pg_upgrade_replica forges a backup_manifest and drives pg_basebackup
+# --incremental + pg_combinebackup rather than fetching files itself, so
+# the new primary needs summarize_wal=on from its very first startup (set
+# below, before $new_primary->start) for the incremental backup protocol
+# to have anything to work from.
+
+use strict;
+use warnings FATAL => 'all';
+
+use File::Basename qw(dirname);
+use File::Path     qw(rmtree);
+use IPC::Run       qw(run);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Standbys categorically refuse SQL access to unlogged relations (their
+# content is only reset at end of recovery, which a standby never
+# reaches), so t1_unlogged's presence on the assembled standby can only
+# be checked at the filesystem level below, not by querying it.
+
+my $old_primary = PostgreSQL::Test::Cluster->new('old_primary');
+$old_primary->init(allows_streaming => 1);
+
+# Needed at startup, not just when creating the in-place tablespace
+# below: any server (old_primary itself on its own later restart during
+# pg_upgrade, old_standby entering recovery, ...) that finds one already
+# on disk PANICs at startup without this set, not just refuses to create
+# a new one.
+$old_primary->append_conf('postgresql.conf',
+	'allow_in_place_tablespaces = on');
+$old_primary->start;
+
+$old_primary->safe_psql('postgres',
+		"create table t1 as select g, repeat('x', 80) as pad "
+	  . "from generate_series(1, 5000) g;"
+	  . "create index on t1(g);"
+	  . "create unlogged table t1_unlogged as select g from generate_series(1, 10) g;"
+);
+
+my $t1_relpath =
+  $old_primary->safe_psql('postgres', "select pg_relation_filepath('t1')");
+
+# A real, non-default tablespace: exercises build_old_replica_view()'s
+# version-dir-renaming symlink and forge_manifest.c's tablespace walk,
+# neither of which the default-tablespace-only checks above can reach.
+# In-place (allow_in_place_tablespaces, testing-only: data lives at
+# pg_tblspc/<oid> inside the data directory itself) rather than a real
+# external location, so this works under the same-version self-upgrade
+# this whole test relies on -- pg_upgrade refuses a real external
+# tablespace when old and new are the same catalog version, since it
+# can't tell the two clusters' copies apart at that one shared path.
+$old_primary->safe_psql('postgres',
+		"create tablespace test_tblspc location '';"
+	  . "create table t2 tablespace test_tblspc as select g, repeat('y', 80) as pad "
+	  . "from generate_series(1, 3000) g;");
+my $t2_oid = $old_primary->safe_psql('postgres',
+	"select oid from pg_tablespace where spcname = 'test_tblspc'");
+my $t2_relpath =
+  $old_primary->safe_psql('postgres', "select pg_relation_filepath('t2')");
+
+# No tablespace-specific backup/mapping options needed: an in-place
+# tablespace is just a plain subdirectory of the data directory as far
+# as pg_basebackup and copypath() are both concerned, unlike a real
+# external one (which would need a -T/tablespace_map entry at each step
+# below to relocate it).
+$old_primary->backup('old_standby_backup');
+my $old_standby = PostgreSQL::Test::Cluster->new('old_standby');
+$old_standby->init_from_backup($old_primary, 'old_standby_backup',
+	has_streaming => 1);
+$old_standby->start;
+$old_primary->wait_for_catchup($old_standby, 'replay');
+
+is($old_standby->safe_psql('postgres', 'select count(*) from t1'),
+	'5000', 'old standby caught up before upgrade');
+is($old_standby->safe_psql('postgres', 'select count(*) from t2'),
+	'3000', 'old standby caught up on the tablespace-resident table too');
+
+# Correct shutdown order: primary, then standby, so the standby actually
+# reaches the primary's final checkpoint before the upgrade runs.
+$old_primary->stop;
+$old_standby->stop;
+
+# t1's frozen size on --old-replica, captured now while it's still just
+# the original 5000 rows -- checked below against the *new* primary's own
+# current (larger) size, to confirm the two genuinely differ going into
+# the sync.
+my $t1_frozen_size = -s ($old_standby->data_dir . '/' . $t1_relpath);
+ok( defined $t1_frozen_size && $t1_frozen_size > 0,
+	"captured t1's frozen file size on the old standby");
+
+my $t2_frozen_size = -s ($old_standby->data_dir . '/' . $t2_relpath);
+ok( defined $t2_frozen_size && $t2_frozen_size > 0,
+	"captured t2's frozen file size on the old standby");
+
+my $new_primary = PostgreSQL::Test::Cluster->new('new_primary');
+$new_primary->init(allows_streaming => 1);
+
+# Must be set before the new primary is ever started: the WAL summarizer
+# only builds summaries forward from whenever it was actually enabled, so
+# turning this on after the fact would leave a permanent gap starting at
+# the new cluster's own checkpoint, exactly the range pg_basebackup
+# --incremental needs summarized.
+$new_primary->append_conf('postgresql.conf', 'summarize_wal = on');
+
+# pg_upgrade doesn't carry postgresql.conf settings forward from the old
+# cluster (a fresh initdb produced this one), so the in-place tablespace
+# needs this set here too, before this cluster is ever started -- same
+# PANIC-at-startup reasoning as old_primary/old_standby above.
+$new_primary->append_conf('postgresql.conf',
+	'allow_in_place_tablespaces = on');
+
+my $bindir = $new_primary->config_data('--bindir');
+command_ok(
+	[
+		'pg_upgrade', '--no-sync',
+		'--old-datadir' => $old_primary->data_dir,
+		'--new-datadir' => $new_primary->data_dir,
+		'--old-bindir' => $bindir,
+		'--new-bindir' => $bindir,
+		'--socketdir' => $new_primary->host,
+		'--old-port' => $old_primary->port,
+		'--new-port' => $new_primary->port,
+	],
+	'pg_upgrade self-upgrade for pg_upgrade_replica test');
+
+$new_primary->start;
+
+# A write to a *reused* relation, made on the new primary before
+# pg_upgrade_replica ever runs, must still show up on the assembled
+# standby: recovery is anchored at the new cluster's own checkpoint, not
+# at whenever the sync happened to run. This is only a real test of that
+# property if the write genuinely lands before the sync -- t1 keeps its
+# relfilenode across the self-upgrade (default transfer mode), so it's a
+# reused relation, and this insert has to happen in the gap between the
+# primary starting and the sync running, not any later.
+$new_primary->safe_psql('postgres',
+	"insert into t1 values (99998, 'presync')");
+
+# A write that actually changes t1's file length (not just an in-place
+# page update, like the single-row insert above) must also still work,
+# and now via a genuine incremental block fetch rather than either a
+# whole-file reuse or a whole-file refetch.
+$new_primary->safe_psql('postgres',
+		"insert into t1 select g, repeat('x', 80) "
+	  . "from generate_series(100000, 119999) g;");
+
+my $t1_grown_size =
+  $new_primary->safe_psql('postgres', "select pg_relation_size('t1')");
+cmp_ok($t1_grown_size, '>', $t1_frozen_size,
+	"t1 grew on the new primary past its frozen size on the old standby");
+
+$new_primary->safe_psql('postgres',
+		"insert into t2 select g, repeat('y', 80) "
+	  . "from generate_series(100000, 109999) g;");
+my $t2_grown_size =
+  $new_primary->safe_psql('postgres', "select pg_relation_size('t2')");
+cmp_ok($t2_grown_size, '>', $t2_frozen_size,
+	"t2 grew on the new primary past its frozen size on the old standby");
+
+my $unlogged_relpath = $new_primary->safe_psql('postgres',
+	"select pg_relation_filepath('t1_unlogged')");
+
+# An in-place tablespace created on the new primary after pg_upgrade ran
+# has no counterpart at all on --old-replica's own pg_tblspc/<oid>
+# (--old-replica is old_standby's pre-upgrade snapshot, taken before this
+# tablespace ever existed): discover_tablespaces() must notice its
+# in-place path is missing there and fall back to fetching it fresh via
+# pg_basebackup, the same way it already does for a missing symlink
+# target on a real external tablespace.
+$new_primary->safe_psql('postgres',
+		"create tablespace test_tblspc_new location '';"
+	  . "create table t3 tablespace test_tblspc_new as select g, repeat('z', 80) as pad "
+	  . "from generate_series(1, 2000) g;");
+my $t3_oid = $new_primary->safe_psql('postgres',
+	"select oid from pg_tablespace where spcname = 'test_tblspc_new'");
+my $t3_relpath =
+  $new_primary->safe_psql('postgres', "select pg_relation_filepath('t3')");
+
+# A checkpoint gives the WAL summarizer a boundary to finalize a summary
+# through, rather than waiting for the next automatic one on this
+# cluster's default 5-minute checkpoint_timeout.
+$new_primary->safe_psql('postgres', 'checkpoint');
+
+my $new_replica = PostgreSQL::Test::Cluster->new('new_replica');
+
+# check_old_replica_caught_up() (reuse.c) is the one thing standing between
+# a lagging or plain-wrong --old-replica and silently trusting its reused
+# files forever. Exercise its two refusal branches for real, against this
+# same new primary/manifest, before relying on the happy-path run below to
+# prove the tool works at all.
+#
+# A never-started cluster is already "cleanly shut down" (no
+# postmaster.pid ever written), a cheap stand-in for "wrong replica for
+# this upgrade" -- but only with force_initdb: a plain init() copies a
+# shared template directory (see Cluster.pm's init(), INITDB_TEMPLATE)
+# rather than running initdb for real, which would give this cluster the
+# exact same system identifier as every other plain init() in this suite,
+# including old_primary's.
+my $bogus_replica = PostgreSQL::Test::Cluster->new('bogus_replica');
+$bogus_replica->init(force_initdb => 1);
+
+command_fails_like(
+	[
+		'pg_upgrade_replica',
+		'--old-bindir' => $bindir,
+		'--old-replica' => $bogus_replica->data_dir,
+		'--new-replica' => $new_replica->data_dir . '_negtest',
+		'--no-sync',
+		'-h' => $new_primary->host,
+		'-p' => $new_primary->port
+	],
+	qr/refusing to sync: old replica system identifier .* does not match/,
+	'pg_upgrade_replica refuses a replica with the wrong system identifier');
+
+# old_standby is genuinely caught up and cleanly stopped at this point (see
+# above), so a fake postmaster.pid is the only thing standing between it
+# and the refusal this checks for -- removed again right after, so the
+# real run below still sees a cleanly-stopped replica.
+my $fake_postmaster_pid = $old_standby->data_dir . '/postmaster.pid';
+open(my $fh, '>', $fake_postmaster_pid)
+  or die "could not create $fake_postmaster_pid: $!";
+close $fh;
+command_fails_like(
+	[
+		'pg_upgrade_replica',
+		'--old-bindir' => $bindir,
+		'--old-replica' => $old_standby->data_dir,
+		'--new-replica' => $new_replica->data_dir . '_negtest',
+		'--no-sync',
+		'-h' => $new_primary->host,
+		'-p' => $new_primary->port
+	],
+	qr/refusing to sync: ".*postmaster\.pid" exists/,
+	'pg_upgrade_replica refuses an old replica that is not cleanly shut down'
+);
+unlink($fake_postmaster_pid)
+  or die "could not remove $fake_postmaster_pid: $!";
+
+# pg_basebackup --incremental checks WAL summary coverage against
+# whatever it reads as "now" at the moment it actually runs, which the
+# summarizer -- a background process working through its own backlog --
+# is not guaranteed to have caught up to yet even right after a
+# checkpoint (there's always a small, structural trailing gap between
+# the summarizer's own pending and finalized positions). Retrying is the
+# normal, expected remedy rather than a workaround for a bug: the same
+# thing a real operator would do against the same transient error.
+#
+# This only exercises that transient case, never the terminal one
+# (summarize_wal never enabled, or past wal_summary_keep_time), so
+# pg_basebackup's own "fails plainly" behavior for that case is not
+# covered here. Reaching it deterministically needs a second
+# self-upgrade cycle with summarize_wal left off, real added setup cost
+# for one error message.
+my ($stdout, $stderr, $result);
+my @cmd = (
+	'pg_upgrade_replica',
+	'--old-bindir' => $bindir,
+	'--old-replica' => $old_standby->data_dir,
+	'--new-replica' => $new_replica->data_dir,
+	'--no-sync',
+	'-h' => $new_primary->host,
+	'-p' => $new_primary->port);
+print("# Running: " . join(" ", @cmd) . "\n");
+for (my $attempt = 1; $attempt <= 10; $attempt++)
+{
+	$result = run(\@cmd, '>' => \$stdout, '2>' => \$stderr);
+	last
+	  if $result
+	  || $stderr !~ /WAL summaries .* are incomplete/;
+	diag("WAL summarizer not caught up yet, retrying ($attempt/10)");
+	rmtree($new_replica->data_dir) if -e $new_replica->data_dir;
+	$new_primary->safe_psql('postgres', 'checkpoint');
+	sleep 1;
+}
+ok($result,
+	'pg_upgrade_replica syncs a standby against the upgraded primary');
+diag($stderr);
+
+like(
+	$stderr,
+	qr/manifest lists \d+ relation files as unchanged/,
+	'sync reports the forged manifest\'s kept-relation count');
+
+ok( -f $new_replica->data_dir . '/backup_label',
+	'assembled directory has a backup_label');
+ok( -f $new_replica->data_dir . '/standby.signal',
+	'assembled directory has standby.signal');
+
+# basebackup.c excludes every fork of an unlogged relation except the
+# init fork from any base backup, full or incremental -- its main fork's
+# content is meaningless until end-of-recovery resets it, which a
+# standby never reaches, so pg_basebackup never sends it at all and it
+# must NOT be present here. --old-replica has none either: an unlogged
+# relation's main fork gets removed at the *standby's own* startup (not
+# just at end-of-recovery/promotion), so forge_manifest.c's walk of
+# --old-replica never finds one to list as kept in the first place.
+ok( !-e $new_replica->data_dir . '/' . $unlogged_relpath,
+	'assembled directory correctly has no main fork for the unlogged relation'
+);
+ok( -f $new_replica->data_dir . '/' . $unlogged_relpath . '_init',
+	'assembled directory has the unlogged relation\'s init fork');
+
+# An in-place tablespace's pg_tblspc/<oid> is a real directory, not a
+# symlink to an external location -- pg_basebackup/pg_combinebackup
+# place it there automatically, with no -T mapping from this tool.
+ok( -d $new_replica->data_dir . "/pg_tblspc/$t2_oid"
+	  && !-l $new_replica->data_dir . "/pg_tblspc/$t2_oid",
+	'assembled directory has the in-place tablespace as a real directory');
+ok(-f $new_replica->data_dir . '/' . $t2_relpath,
+	'assembled directory has the tablespace-resident table\'s file');
+
+# test_tblspc_new (created on the new primary after the upgrade, so
+# --old-replica has no pg_tblspc/<oid> for it at all) must still come
+# through, fetched fresh rather than causing the run above to fail.
+ok( -d $new_replica->data_dir . "/pg_tblspc/$t3_oid"
+	  && !-l $new_replica->data_dir . "/pg_tblspc/$t3_oid",
+	'assembled directory has the post-upgrade in-place tablespace as a real directory'
+);
+ok( -f $new_replica->data_dir . '/' . $t3_relpath,
+	'assembled directory has the post-upgrade tablespace-resident table\'s file'
+);
+
+$new_replica->append_conf('postgresql.auto.conf',
+	'port = ' . $new_replica->port);
+$new_replica->start;
+
+is($new_replica->safe_psql('postgres', 'select pg_is_in_recovery()'),
+	't', 'assembled standby is in recovery');
+is($new_replica->safe_psql('postgres', 'select count(*) from t1'),
+	'25001', 'assembled standby has the correct data');
+is( $new_replica->safe_psql('postgres', 'select count(*) from t2'),
+	'13000',
+	'assembled standby has the correct data for the tablespace-resident table'
+);
+is( $new_replica->safe_psql('postgres', 'select count(*) from t3'),
+	'2000',
+	'assembled standby has the correct data for the post-upgrade tablespace-resident table'
+);
+
+# Checked before any catchup wait: these rows were already in the WAL by
+# the time the standby was assembled, so they must be present from replay
+# alone, not because streaming caught up afterward.
+is( $new_replica->safe_psql('postgres', 'select pad from t1 where g = 99998'),
+	'presync',
+	'a write made before the sync ran still replays onto the standby');
+is( $new_replica->safe_psql(
+		'postgres',
+		'select count(*) from t1 where g between 100000 and 119999'),
+	'20000',
+	'a write that grew a reused file before the sync ran still replays onto the standby'
+);
+
+# Separately, live streaming after boot must keep working normally: a
+# fresh write made now, well after the standby is already caught up.
+$new_primary->safe_psql('postgres',
+	"insert into t1 values (99999, 'poststream')");
+$new_primary->wait_for_catchup($new_replica, 'replay');
+is( $new_replica->safe_psql('postgres', 'select pad from t1 where g = 99999'),
+	'poststream',
+	'live streaming after sync delivers a fresh write');
+
+$new_replica->stop;
+$new_primary->stop;
+
+done_testing();
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 85d989f395d..fb46c032ba8 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1540,6 +1540,8 @@ JsonbValue
 JumbleState
 JunkFilter
 KAXCompressReason
+KeptRel
+KeptTablespace
 KeyAction
 KeyActions
 KeyArray
@@ -1748,6 +1750,7 @@ MVDependencies
 MVDependency
 MVNDistinct
 MVNDistinctItem
+Manifest
 ManyTestResource
 ManyTestResourceKind
 Material
@@ -2630,6 +2633,7 @@ RelptrFreePageBtree
 RelptrFreePageManager
 RelptrFreePageSpanLeader
 RemoteAttributeMapping
+RemoteConn
 RemoteSlot
 RemoteStatsResults
 RenameStmt
@@ -3070,6 +3074,7 @@ SupportRequestSimplifyAggref
 SupportRequestWFuncMonotonic
 Syn
 SyncOps
+SyncOptions
 SyncRepConfigData
 SyncRepStandbyData
 SyncRequestHandler
@@ -3163,8 +3168,10 @@ TableScanInstrumentation
 TableSpaceCacheEntry
 TableSpaceOpts
 TableToProcess
+TablespaceInfo
 TablespaceList
 TablespaceListCell
+TablespaceMapping
 TapeBlockTrailer
 TapeShare
 TarMethodData
@@ -3930,6 +3937,7 @@ json_scalar_action
 json_struct_action
 keepwal_entry
 keepwal_hash
+kept_rels_hash
 key_t
 lclContext
 lclTocEntry
-- 
2.47.3

