Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
49.84% covered (danger)
49.84%
157 / 315
18.75% covered (danger)
18.75%
3 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
MergeHistory
49.84% covered (danger)
49.84%
157 / 315
18.75% covered (danger)
18.75%
3 / 16
530.57
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 toProperPageIdentity
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getRevisionCount
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getMergedRevisionCount
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 authorizeInternal
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 probablyCanMerge
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 authorizeMerge
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 isValidMerge
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
14
 hasOverlappingTimestamps
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 wouldClobberDestLatest
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 wouldClobberSourceLatest
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 merge
81.25% covered (warning)
81.25%
65 / 80
0.00% covered (danger)
0.00%
0 / 1
8.42
 updateSourcePage
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
72
 getTimestampLimit
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getTimeWhere
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 initTimestampLimits
71.62% covered (warning)
71.62%
53 / 74
0.00% covered (danger)
0.00%
0 / 1
12.29
1<?php
2
3/**
4 * Copyright © 2015 Geoffrey Mon <geofbot@gmail.com>
5 *
6 * @license GPL-2.0-or-later
7 * @file
8 */
9
10namespace MediaWiki\Page;
11
12use InvalidArgumentException;
13use MediaWiki;
14use MediaWiki\Content\Content;
15use MediaWiki\Content\IContentHandlerFactory;
16use MediaWiki\EditPage\SpamChecker;
17use MediaWiki\HookContainer\HookContainer;
18use MediaWiki\HookContainer\HookRunner;
19use MediaWiki\Logging\ManualLogEntry;
20use MediaWiki\Message\Message;
21use MediaWiki\Permissions\Authority;
22use MediaWiki\Permissions\PermissionStatus;
23use MediaWiki\Revision\SlotRecord;
24use MediaWiki\Status\Status;
25use MediaWiki\Storage\PageUpdater;
26use MediaWiki\Storage\PageUpdaterFactory;
27use MediaWiki\Title\Title;
28use MediaWiki\Title\TitleFactory;
29use MediaWiki\Title\TitleFormatter;
30use MediaWiki\Utils\MWTimestamp;
31use MediaWiki\Watchlist\WatchedItemStoreInterface;
32use Wikimedia\Assert\Assert;
33use Wikimedia\Rdbms\IConnectionProvider;
34use Wikimedia\Rdbms\IDatabase;
35use Wikimedia\Timestamp\TimestampException;
36use Wikimedia\Timestamp\TimestampFormat as TS;
37
38/**
39 * Handles the backend logic of merging the histories of two
40 * pages.
41 *
42 * @since 1.27
43 */
44class MergeHistory {
45
46    /** Maximum number of revisions that can be merged at once */
47    public const REVISION_LIMIT = 5000;
48
49    /** @var ProperPageIdentity Page from which history will be merged */
50    protected $source;
51
52    /** @var ProperPageIdentity Page to which history will be merged */
53    protected $dest;
54
55    /** @var IDatabase Database that we are using */
56    protected $dbw;
57
58    /** @var ?string Timestamp up to which history from the source will be merged */
59    private $timestamp;
60    /** @var ?string Timestamp from which history from the source will be merged */
61    private $timestampStart;
62
63    /**
64     * @var array|false|null SQL WHERE condition that selects source revisions
65     * to insert into destination. Use ::getTimeWhere to lazy-initialize.
66     */
67    protected $timeWhere = false;
68
69    /**
70     * @var MWTimestamp|false|null Timestamp upto which history from the source will be merged.
71     * Use getTimestampLimit to lazily initialize.
72     */
73    protected $timestampLimit = false;
74
75    /**
76     * @var MWTimestamp|false|null Timestamp upto which history from the source will be merged.
77     * Use getTimestampLimit to lazily initialize.
78     */
79    protected $timestampStartLimit = false;
80
81    /**
82     * @var string|null
83     */
84    private $revidLimit = null;
85    /**
86     * @var string|null
87     */
88    private $revidStart = null;
89
90    /** @var int Number of revisions merged (for Special:MergeHistory success message) */
91    protected $revisionsMerged;
92
93    private IContentHandlerFactory $contentHandlerFactory;
94    private WatchedItemStoreInterface $watchedItemStore;
95    private SpamChecker $spamChecker;
96    private HookRunner $hookRunner;
97    private PageUpdaterFactory $pageUpdaterFactory;
98    private TitleFormatter $titleFormatter;
99    private TitleFactory $titleFactory;
100    private DeletePageFactory $deletePageFactory;
101
102    /**
103     * @param PageIdentity $source Page from which history will be merged
104     * @param PageIdentity $dest Page to which history will be merged
105     * @param ?string $timestamp Timestamp up to which history from the source will be merged
106     * @param ?string $timestampStart Timestamp after which history from the source will be merged
107     * @param IConnectionProvider $dbProvider
108     * @param IContentHandlerFactory $contentHandlerFactory
109     * @param WatchedItemStoreInterface $watchedItemStore
110     * @param SpamChecker $spamChecker
111     * @param HookContainer $hookContainer
112     * @param PageUpdaterFactory $pageUpdaterFactory
113     * @param TitleFormatter $titleFormatter
114     * @param TitleFactory $titleFactory
115     * @param DeletePageFactory $deletePageFactory
116     */
117    public function __construct(
118        PageIdentity $source,
119        PageIdentity $dest,
120        ?string $timestamp,
121        ?string $timestampStart,
122        IConnectionProvider $dbProvider,
123        IContentHandlerFactory $contentHandlerFactory,
124        WatchedItemStoreInterface $watchedItemStore,
125        SpamChecker $spamChecker,
126        HookContainer $hookContainer,
127        PageUpdaterFactory $pageUpdaterFactory,
128        TitleFormatter $titleFormatter,
129        TitleFactory $titleFactory,
130        DeletePageFactory $deletePageFactory
131    ) {
132        // Save the parameters
133        $this->source = self::toProperPageIdentity( $source, '$source' );
134        $this->dest = self::toProperPageIdentity( $dest, '$dest' );
135        $this->timestamp = $timestamp;
136        $this->timestampStart = $timestampStart;
137
138        // Get the database
139        $this->dbw = $dbProvider->getPrimaryDatabase();
140
141        $this->contentHandlerFactory = $contentHandlerFactory;
142        $this->watchedItemStore = $watchedItemStore;
143        $this->spamChecker = $spamChecker;
144        $this->hookRunner = new HookRunner( $hookContainer );
145        $this->pageUpdaterFactory = $pageUpdaterFactory;
146        $this->titleFormatter = $titleFormatter;
147        $this->titleFactory = $titleFactory;
148        $this->deletePageFactory = $deletePageFactory;
149    }
150
151    private static function toProperPageIdentity(
152        PageIdentity $page,
153        string $name
154    ): ProperPageIdentity {
155        // Make sure $source and $dest are proper pages
156        if ( $page instanceof Title ) {
157            $page = $page->toPageIdentity();
158        }
159
160        Assert::parameterType(
161            ProperPageIdentity::class,
162            $page,
163            $name
164        );
165        '@phan-var ProperPageIdentity $page';
166
167        return $page;
168    }
169
170    /**
171     * Get the number of revisions that will be moved
172     * @return int
173     */
174    public function getRevisionCount() {
175        $count = $this->dbw->newSelectQueryBuilder()
176            ->select( '1' )
177            ->from( 'revision' )
178            ->where( [ 'rev_page' => $this->source->getId(), ...$this->getTimeWhere() ] )
179            ->limit( self::REVISION_LIMIT + 1 )
180            ->caller( __METHOD__ )->fetchRowCount();
181
182        return $count;
183    }
184
185    /**
186     * Get the number of revisions that were moved
187     * Used in the SpecialMergeHistory success message
188     * @return int
189     */
190    public function getMergedRevisionCount() {
191        return $this->revisionsMerged;
192    }
193
194    /**
195     * @param callable $authorizer ( string $action, PageIdentity $target, PermissionStatus $status )
196     * @param Authority $performer
197     * @param string $reason
198     * @return PermissionStatus
199     */
200    private function authorizeInternal(
201        callable $authorizer,
202        Authority $performer,
203        string $reason
204    ) {
205        $status = PermissionStatus::newEmpty();
206
207        $authorizer( 'edit', $this->source, $status );
208        $authorizer( 'edit', $this->dest, $status );
209
210        // Anti-spam
211        if ( $this->spamChecker->checkSummary( $reason ) !== false ) {
212            // This is kind of lame, won't display nice
213            $status->fatal( 'spamprotectiontext' );
214        }
215
216        // Check mergehistory permission
217        if ( !$performer->isAllowed( 'mergehistory' ) ) {
218            // User doesn't have the right to merge histories
219            $status->fatal( 'mergehistory-fail-permission' );
220        }
221        return $status;
222    }
223
224    /**
225     * Check whether $performer can execute the merge.
226     *
227     * @note this method does not guarantee full permissions check, so it should
228     * only be used to to decide whether to show a merge form. To authorize the merge
229     * action use {@link self::authorizeMerge} instead.
230     *
231     * @param Authority $performer
232     * @param string|null $reason
233     * @return PermissionStatus
234     */
235    public function probablyCanMerge( Authority $performer, ?string $reason = null ): PermissionStatus {
236        return $this->authorizeInternal(
237            static function ( string $action, PageIdentity $target, PermissionStatus $status ) use ( $performer ) {
238                return $performer->probablyCan( $action, $target, $status );
239            },
240            $performer,
241            $reason
242        );
243    }
244
245    /**
246     * Authorize the merge by $performer.
247     *
248     * @note this method should be used right before the actual merge is performed.
249     * To check whether a current performer has the potential to merge the history,
250     * use {@link self::probablyCanMerge} instead.
251     *
252     * @param Authority $performer
253     * @param string|null $reason
254     * @return PermissionStatus
255     */
256    public function authorizeMerge( Authority $performer, ?string $reason = null ): PermissionStatus {
257        return $this->authorizeInternal(
258            static function ( string $action, PageIdentity $target, PermissionStatus $status ) use ( $performer ) {
259                return $performer->authorizeWrite( $action, $target, $status );
260            },
261            $performer,
262            $reason
263        );
264    }
265
266    /**
267     * Does various checks that the merge is
268     * valid. Only things based on the two pages
269     * should be checked here.
270     *
271     * @return Status
272     */
273    public function isValidMerge() {
274        $status = new Status();
275
276        // If either article ID is 0, then revisions cannot be reliably selected
277        if ( $this->source->getId() === 0 ) {
278            $status->fatal( 'mergehistory-fail-invalid-source' );
279        }
280        if ( $this->dest->getId() === 0 ) {
281            $status->fatal( 'mergehistory-fail-invalid-dest' );
282        }
283
284        // Make sure page aren't the same
285        if ( $this->source->isSamePageAs( $this->dest ) ) {
286            $status->fatal( 'mergehistory-fail-self-merge' );
287        }
288
289        // Make sure the timestamp is valid
290        $ts = $this->getTimestampLimit();
291        if ( !$ts ) {
292            $status->fatal( 'mergehistory-fail-bad-timestamp' );
293        }
294        // Now all of the required params are correctly specified so test the trickier cases
295        if ( $status->isGood() ) {
296            $tss = $this->timestampStartLimit;
297            if ( $tss && $tss > $ts ) {
298                $status->fatal( 'mergehistory-fail-start-after-end', );
299            } elseif ( $tss == $ts && $this->revidStart > $this->revidLimit ) {
300                $status->fatal( 'mergehistory-fail-start-after-end' );
301            }
302            // Check that there are not too many revisions to move
303            if ( $this->getRevisionCount() > self::REVISION_LIMIT ) {
304                $status->fatal( 'mergehistory-fail-toobig', Message::numParam( self::REVISION_LIMIT ) );
305            }
306            // Don't allow overlapping timestamps (destination page revisions that meet the timeWhere )
307            if ( $this->hasOverlappingTimestamps() ) {
308                $status->fatal( 'mergehistory-fail-timestamps-overlap' );
309            }
310            // Don't allow changing the current revision of a page via a merge
311            // (except for a full merge of the entire history)
312            // as that would require reparsing the page etc. and it's easier to not deal
313            if ( $this->wouldClobberDestLatest() ) {
314                $status->fatal( 'mergehistory-fail-change-current-revision' );
315            }
316            if ( $this->wouldClobberSourceLatest() ) {
317                $status->fatal( 'mergehistory-fail-change-current-revision' );
318            }
319        }
320        return $status;
321    }
322
323    private function hasOverlappingTimestamps(): bool {
324        return $this->dbw->newSelectQueryBuilder()
325            ->select( '1' )
326            ->from( 'revision' )
327            ->where( [ 'rev_page' => $this->dest->getId(), ...$this->getTimeWhere() ] )
328            ->caller( __METHOD__ )->fetchField();
329    }
330
331    private function wouldClobberDestLatest(): bool {
332        // Return whether page_latest of the source page meets the conditions to move
333        $row = $this->dbw->newSelectQueryBuilder()
334            ->select( [ 'rev_id', 'rev_timestamp' ] )
335            ->from( 'page' )
336            ->join( 'revision', null, 'rev_id = page_latest' )
337            ->where( [ 'page_id' => $this->dest->getId() ] )
338            ->caller( __METHOD__ )->fetchRow();
339        $tsLimitString = $this->timestampLimit !== false ? $this->timestampLimit?->getTimestamp() : null;
340        $destTsString = ( new MWTimestamp( $row->rev_timestamp ) )->getTimestamp();
341        if ( $tsLimitString > $destTsString ) {
342            // Easy case
343            return true;
344        } elseif ( $tsLimitString === $destTsString ) {
345            // Life is full of suffering; fetch the maximum rev ID that would be merged
346            $maxRevidMerged = $this->dbw->newSelectQueryBuilder()
347                ->select( 'MAX(rev_id)' )
348                ->from( 'page' )
349                ->join( 'revision', null, 'rev_page = page_id' )
350                ->where( [
351                     'page_id' => $this->source->getId(),
352                     ...$this->getTimeWhere(),
353                     'rev_timestamp' => $this->dbw->timestamp( $destTsString )
354                ] )
355                ->caller( __METHOD__ )->fetchField();
356            return $maxRevidMerged > $row->rev_id;
357        } else {
358            // Easy case
359            return false;
360        }
361    }
362
363    private function wouldClobberSourceLatest(): bool {
364        if ( !$this->timestampStartLimit ) {
365            // We don't care; it would merge the entire history
366            return false;
367        }
368        // Return whether page_latest of the source page meets the conditions to move
369        return $this->dbw->newSelectQueryBuilder()
370            ->select( 'rev_id' )
371            ->from( 'page' )
372            ->join( 'revision', null, 'rev_id = page_latest' )
373            ->where( [ 'page_id' => $this->source->getId(),
374                      ...$this->getTimeWhere()
375        ] )
376            ->caller( __METHOD__ )->fetchField();
377    }
378
379    /**
380     * Actually attempt the history move
381     *
382     * @todo if all versions of page A are moved to B and then a user
383     * tries to do a reverse-merge via the "unmerge" log link, then page
384     * A will still be a redirect (as it was after the original merge),
385     * though it will have the old revisions back from before (as expected).
386     * The user may have to "undo" the redirect manually to finish the "unmerge".
387     * Maybe this should delete redirects at the source page of merges?
388     *
389     * @param Authority $performer
390     * @param string $reason
391     * @return Status status of the history merge
392     */
393    public function merge( Authority $performer, $reason = '' ) {
394        $status = new Status();
395
396        // Check validity and permissions required for merge
397        $validCheck = $this->isValidMerge(); // Check this first to check for null pages
398        if ( !$validCheck->isOK() ) {
399            return $validCheck;
400        }
401        $permCheck = $this->authorizeMerge( $performer, $reason );
402        if ( !$permCheck->isOK() ) {
403            return Status::wrap( $permCheck );
404        }
405
406        $updater = $this->pageUpdaterFactory->newPageUpdater(
407            $this->source,
408            $performer->getUser()
409        );
410
411        $this->dbw->startAtomic( __METHOD__ );
412        $updater->grabParentRevision(); // preserve latest revision for later
413
414        $this->dbw->newUpdateQueryBuilder()
415            ->update( 'revision' )
416            ->set( [ 'rev_page' => $this->dest->getId() ] )
417            ->where( [ 'rev_page' => $this->source->getId(), ...$this->getTimeWhere() ] )
418            ->caller( __METHOD__ )->execute();
419
420        // Check if this did anything
421        $this->revisionsMerged = $this->dbw->affectedRows();
422        if ( $this->revisionsMerged < 1 ) {
423            $this->dbw->endAtomic( __METHOD__ );
424            return $status->fatal( 'mergehistory-fail-no-change' );
425        }
426
427        $haveRevisions = $this->dbw->newSelectQueryBuilder()
428            ->from( 'revision' )
429            ->where( [ 'rev_page' => $this->source->getId() ] )
430            ->forUpdate()
431            ->caller( __METHOD__ )
432            ->fetchRowCount();
433
434        $legacySource = $this->titleFactory->newFromPageIdentity( $this->source );
435        $legacyDest = $this->titleFactory->newFromPageIdentity( $this->dest );
436
437        // Update source page, histories and invalidate caches
438        if ( !$haveRevisions ) {
439            if ( $reason ) {
440                $revisionComment = wfMessage(
441                    'mergehistory-comment',
442                    $this->titleFormatter->getPrefixedText( $this->source ),
443                    $this->titleFormatter->getPrefixedText( $this->dest ),
444                    $reason
445                )->inContentLanguage()->text();
446            } else {
447                $revisionComment = wfMessage(
448                    'mergehistory-autocomment',
449                    $this->titleFormatter->getPrefixedText( $this->source ),
450                    $this->titleFormatter->getPrefixedText( $this->dest )
451                )->inContentLanguage()->text();
452            }
453
454            $this->updateSourcePage( $status, $performer, $revisionComment, $updater );
455
456            // Duplicate watchers of the old article to the new article
457            $this->watchedItemStore->duplicateAllAssociatedEntries( $this->source, $this->dest );
458        } else {
459            $legacySource->invalidateCache();
460        }
461        $legacyDest->invalidateCache();
462
463        // Update our logs
464        $logEntry = new ManualLogEntry( 'merge', 'merge' );
465        $logEntry->setPerformer( $performer->getUser() );
466        $logEntry->setComment( $reason );
467        $logEntry->setTarget( $this->source );
468        $srcParams = [
469            '4::dest' => $this->titleFormatter->getPrefixedText( $this->dest ),
470            '5::mergepoint' => $this->getTimestampLimit()->getTimestamp( TS::MW ),
471            '6::mergerevid' => $this->revidLimit
472        ];
473        if ( $this->timestampStartLimit ) {
474            $srcParams['7::mergestart'] = $this->timestampStartLimit->getTimestamp( TS::MW );
475            $srcParams['8::mergestartid'] = $this->revidStart;
476        }
477        $logEntry->setParameters( $srcParams );
478        $logId = $logEntry->insert();
479        $logEntry->publish( $logId );
480
481        // And at the destination
482        // https://phabricator.wikimedia.org/T118132
483        $destLog = new ManualLogEntry( 'merge', 'merge-into' );
484        $destLog->setPerformer( $performer->getUser() );
485        $destLog->setComment( $reason );
486        $destLog->setTarget( $this->dest );
487
488        $destParams = [
489            '4::src'        => $this->titleFormatter->getPrefixedText( $this->source ),
490            '5::mergepoint' => $this->getTimestampLimit()->getTimestamp( TS::MW ),
491            '6::mergerevid' => $this->revidLimit
492        ];
493        if ( $this->timestampStartLimit ) {
494            $destParams['7::mergestart'] = $this->timestampStartLimit->getTimestamp( TS::MW );
495            $destParams['8::mergestartid'] = $this->revidStart;
496        }
497
498        $destLog->setParameters( $destParams );
499        $destId = $destLog->insert();
500        $destLog->publish( $destId );
501
502        $this->hookRunner->onArticleMergeComplete( $legacySource, $legacyDest );
503
504        $this->dbw->endAtomic( __METHOD__ );
505
506        return $status;
507    }
508
509    /**
510     * Do various cleanup work and updates to the source page. This method
511     * will only be called if no revision is remaining on the page.
512     *
513     * At the end, there would be either a redirect page or a deleted page,
514     * depending on whether the content model of the page supports redirects or not.
515     *
516     * @param Status $status
517     * @param Authority $performer
518     * @param string $revisionComment Edit summary for the redirect or empty revision
519     *   to be created in place of the source page
520     * @param PageUpdater $updater For turning the source page into a redirect
521     */
522    private function updateSourcePage( $status, $performer, $revisionComment, PageUpdater $updater ): void {
523        $deleteSource = false;
524        $legacySourceTitle = $this->titleFactory->newFromPageIdentity( $this->source );
525        $legacyDestTitle = $this->titleFactory->newFromPageIdentity( $this->dest );
526        $sourceModel = $legacySourceTitle->getContentModel();
527        $contentHandler = $this->contentHandlerFactory->getContentHandler( $sourceModel );
528
529        if ( !$contentHandler->supportsRedirects() || (
530            // Do not create redirects for wikitext message overrides (T376399).
531            // Maybe one day they will have a custom content model and this special case won't be needed.
532            $legacySourceTitle->getNamespace() === NS_MEDIAWIKI &&
533            $legacySourceTitle->getContentModel() === CONTENT_MODEL_WIKITEXT
534        ) ) {
535            $deleteSource = true;
536            $newContent = $contentHandler->makeEmptyContent();
537        } else {
538            $msg = wfMessage( 'mergehistory-redirect-text' )->inContentLanguage()->plain();
539            $newContent = $contentHandler->makeRedirectContent( $legacyDestTitle, $msg );
540        }
541
542        if ( !$newContent instanceof Content ) {
543            // Handler supports redirect but cannot create new redirect content?
544            // Not possible to proceed without Content.
545
546            // @todo. Remove this once there's no evidence it's happening or if it's
547            // determined all violating handlers have been fixed.
548            // This is mostly kept because previous code was also blindly checking
549            // existing of the Content for both content models that supports redirects
550            // and those that that don't, so it's hard to know what it was masking.
551            $logger = MediaWiki\Logger\LoggerFactory::getInstance( 'ContentHandler' );
552            $logger->warning(
553                'ContentHandler for {model} says it supports redirects but failed '
554                . 'to return Content object from ContentHandler::makeRedirectContent().'
555                . ' {value} returned instead.',
556                [
557                    'value' => get_debug_type( $newContent ),
558                    'model' => $sourceModel
559                ]
560            );
561
562            throw new InvalidArgumentException(
563                "ContentHandler for '$sourceModel' supports redirects" .
564                ' but cannot create redirect content during History merge.'
565            );
566        }
567
568        // T263340/T93469: Create revision record to also serve as the page revision.
569        // This revision will be used to create page content. If the source page's
570        // content model supports redirects, then it will be the redirect content.
571        // If the content model does not support redirects, this content will aid
572        // proper deletion of the page below.
573
574        $updater->setContent( SlotRecord::MAIN, $newContent )
575            ->setHints( [ 'suppressDerivedDataUpdates' => $deleteSource ] )
576            ->saveRevision( $revisionComment, EDIT_INTERNAL | EDIT_IMPLICIT | EDIT_SILENT );
577
578        if ( $deleteSource ) {
579            // T263340/T93469: Delete the source page to prevent errors because its
580            // revisions are now tied to a different title and its content model
581            // does not support redirects, so we cannot leave a new revision on it.
582            // This deletion does not depend on userright but may still fail. If it
583            // fails, it will be communicated in the status response.
584            $reason = wfMessage( 'mergehistory-source-deleted-reason' )->inContentLanguage()->plain();
585            $delPage = $this->deletePageFactory->newDeletePage( $this->source, $performer );
586            $deletionStatus = $delPage->deleteUnsafe( $reason );
587            if ( $deletionStatus->isGood() && $delPage->deletionsWereScheduled()[DeletePage::PAGE_BASE] ) {
588                $deletionStatus->warning(
589                    'delete-scheduled',
590                    wfEscapeWikiText( $this->titleFormatter->getPrefixedText( $this->source ) )
591                );
592            }
593            // Notify callers that the source page has been deleted.
594            $status->value = 'source-deleted';
595            $status->merge( $deletionStatus );
596        }
597    }
598
599    /**
600     * Get the timestamp upto which history from the source will be merged,
601     * or null if something went wrong
602     */
603    private function getTimestampLimit(): ?MWTimestamp {
604        if ( $this->timestampLimit === false ) {
605            $this->initTimestampLimits();
606        }
607        return $this->timestampLimit;
608    }
609
610    /**
611     * Get the SQL WHERE condition that selects source revisions to insert into destination,
612     * or null if something went wrong
613     */
614    private function getTimeWhere(): array {
615        if ( $this->timeWhere === false ) {
616            $this->initTimestampLimits();
617        }
618        return $this->timeWhere;
619    }
620
621    /**
622     * Lazily initializes timestamp (and possibly revid) limits and conditions.
623     */
624    private function initTimestampLimits() {
625        $this->revidLimit = null;
626        // Get the timestamp pivot condition
627        try {
628            if ( $this->timestamp ) {
629                $parts = explode( '|', $this->timestamp );
630                if ( count( $parts ) == 2 ) {
631                    $timestamp = $parts[0];
632                    $this->revidLimit = $parts[1];
633                } else {
634                    $timestamp = $this->timestamp;
635                }
636
637                $lastWorkingTimestamp = $this->dbw->newSelectQueryBuilder()
638                    ->select( 'MAX(rev_timestamp)' )
639                    ->from( 'revision' )
640                    ->where( [
641                        // If we have a requested timestamp, use the
642                        // latest revision up to that point as the insertion point
643                        $this->dbw->expr( 'rev_timestamp', '<=', $this->dbw->timestamp( $timestamp ) ),
644                        'rev_page' => $this->source->getId()
645                    ] )
646                    ->caller( __METHOD__ )->fetchField();
647                $mwLastWorkingTimestamp = new MWTimestamp( $lastWorkingTimestamp );
648
649                $timeInsert = $mwLastWorkingTimestamp;
650                $this->timestampLimit = $mwLastWorkingTimestamp;
651            } else {
652                // If we don't, merge entire source page history into the
653                // beginning of destination page history
654                $firstDestTimestamp = $this->dbw->newSelectQueryBuilder()
655                    ->select( 'MIN(rev_timestamp)' )
656                    ->from( 'revision' )
657                    ->where( [ 'rev_page' => $this->dest->getId() ] )
658                    ->caller( __METHOD__ )->fetchField();
659                // Get the latest timestamp of the source
660                $row = $this->dbw->newSelectQueryBuilder()
661                    ->select( [ 'rev_timestamp', 'rev_id' ] )
662                    ->from( 'page' )
663                    ->join( 'revision', null, 'page_latest = rev_id' )
664                    ->where( [ 'page_id' => $this->source->getId() ] )
665                    ->caller( __METHOD__ )->fetchRow();
666                $timeInsert = new MWTimestamp( $firstDestTimestamp );
667                if ( $row ) {
668                    $lasttimestamp = new MWTimestamp( $row->rev_timestamp );
669                    $this->timestampLimit = $lasttimestamp;
670                    $this->revidLimit = $row->rev_id;
671                } else {
672                    $this->timestampLimit = null;
673                }
674            }
675            // Now parse start time
676            if ( $this->timestampStart ) {
677                $parts = explode( '|', $this->timestampStart );
678                if ( count( $parts ) == 2 ) {
679                    $timestampStart = $parts[0];
680                    $this->revidStart = $parts[1];
681                } else {
682                    $timestampStart = $this->timestampStart;
683                }
684
685                $lastWorkingTimestamp = $this->dbw->newSelectQueryBuilder()
686                    ->select( 'MIN(rev_timestamp)' )
687                    ->from( 'revision' )
688                    ->where( [
689                        // If we have a requested timestamp, use the
690                        // earliest revision after that point as the insertion point
691                        $this->dbw->expr( 'rev_timestamp', '>=', $this->dbw->timestamp( $timestampStart ) ),
692                        'rev_page' => $this->source->getId()
693                    ] )
694                    ->caller( __METHOD__ )->fetchField();
695                // If there is no such revision, then this returns the current time
696                // which eventually fails with "start-after-end"; not ideal but workable
697                $this->timestampStartLimit = new MWTimestamp( $lastWorkingTimestamp );
698            } else {
699                $this->timestampStartLimit = null;
700            }
701            $dbLimit = $this->dbw->timestamp( $timeInsert->getTimestamp() );
702            if ( $this->revidLimit ) {
703                $endQuery = $this->dbw->buildComparison( '<=',
704                    [ 'rev_timestamp' => $dbLimit, 'rev_id' => $this->revidLimit ]
705                );
706            } else {
707                $endQuery = $this->dbw->buildComparison( '<=',
708                    [ 'rev_timestamp' => $dbLimit ]
709                );
710            }
711            if ( $this->timestampStartLimit ) {
712                $dbLimitStart = $this->dbw->timestamp( $this->timestampStartLimit->getTimestamp() );
713                if ( $this->revidStart ) {
714                    $startQuery = $this->dbw->buildComparison( '>=',
715                        [ 'rev_timestamp' => $dbLimitStart, 'rev_id' => $this->revidStart ]
716                    );
717                } else {
718                    $startQuery = $this->dbw->buildComparison( '>=',
719                        [ 'rev_timestamp' => $dbLimitStart ]
720                    );
721                }
722                $this->timeWhere = [ $endQuery, $startQuery ];
723            } else {
724                $this->timeWhere = [ $endQuery ];
725            }
726        } catch ( TimestampException ) {
727            // The timestamp we got is screwed up and merge cannot continue
728            // This should be detected by $this->isValidMerge()
729            $this->timestampLimit = null;
730            $this->timeWhere = null;
731            $this->timestampStartLimit = null;
732        }
733    }
734}