From 2bab28749e299e7a3d8121f2712db3a220c05259 Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Fri, 28 Aug 2026 02:58:03 +0000 Subject: [PATCH 1/5] chore: stop renovate merging its own pull requests --- renovate.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/renovate.json b/renovate.json index ccc14f1..e012c42 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,6 @@ { "extends": ["config:recommended"], - "automerge": true, + "automerge": false, "rangeStrategy": "bump", "dependencyDashboard": true, "pinDigests": true, From 226cf3bee2b996a6776f503cc0599a58177b7fa7 Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Fri, 28 Aug 2026 02:58:03 +0000 Subject: [PATCH 2/5] style: use null coalescing assignment in the drush commands --- src/Drush/Commands/Commands.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Drush/Commands/Commands.php b/src/Drush/Commands/Commands.php index cce311b..d0133f0 100644 --- a/src/Drush/Commands/Commands.php +++ b/src/Drush/Commands/Commands.php @@ -145,11 +145,9 @@ protected function buildInfo(): array { ]; } - if (!isset($info[$entity_type_name][$bundle])) { - $info[$entity_type_name][$bundle] = [ - '#label' => sprintf('%s (%s)', $bundles_info[$bundle]['label'], $bundle), - ]; - } + $info[$entity_type_name][$bundle] ??= [ + '#label' => sprintf('%s (%s)', $bundles_info[$bundle]['label'], $bundle), + ]; $field_instance = $this->entityFieldManager->getFieldDefinitions($entity_type_name, $bundle)[$field->getName()] ?? NULL; if ($field_instance && is_array($info[$entity_type_name][$bundle])) { $info[$entity_type_name][$bundle][$field_instance->getName()] = sprintf('%s (%s)', $field_instance->getLabel(), $field_instance->getName()); From e5d9c8370677d5fd3d3a036bf6d5bafdc5a4361d Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Wed, 2 Sep 2026 18:57:12 +1000 Subject: [PATCH 3/5] chore: hold rector below 2.6.5, which silently drops the drupal rules --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 4b142ba..3580524 100644 --- a/composer.json +++ b/composer.json @@ -9,7 +9,8 @@ "drupal/pathauto": "*", "drupal/token": "*", "drupal/redirect": "*", - "drush/drush": ">=12.5.0" + "drush/drush": ">=12.5.0", + "rector/rector": "<2.6.5" }, "conflict": { "drush/drush": "<12.5.0" From 03597d09425e1d884fec5a49789b1b48c51ccac8 Mon Sep 17 00:00:00 2001 From: "w.drupal" <2537336-w.drupal@no-reply.drupal.org> Date: Thu, 27 Aug 2026 05:05:47 +0000 Subject: [PATCH 4/5] fix(#3045063): generate the dedup hash from the source path and language --- src/Redirect.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Redirect.php b/src/Redirect.php index 7e8c396..c99fe2f 100644 --- a/src/Redirect.php +++ b/src/Redirect.php @@ -52,9 +52,10 @@ public function createRedirect($source, $path, LanguageInterface $language): voi $redirect->setSource($parsed_source); $redirect->setRedirect($parsed_path); $redirect->setStatusCode($this->configFactory->get('redirect.settings')->get('default_status_code')); + $redirect->setLanguage($language->getId()); // Check if the redirect doesn't already exist before saving. - $hash = $redirect->generateHash($parsed_path, [], $language->getId()); + $hash = $redirect->generateHash(ltrim($parsed_source, '/'), [], $language->getId()); $redirects = $storage->loadByProperties(['hash' => $hash]); if (empty($redirects)) { // Redirect does not exist yet, save as new one. From 9e8415ec00d2941fdeca10bf557a3ece76738971 Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Sat, 29 Aug 2026 00:32:10 +0000 Subject: [PATCH 5/5] fix(#3045063): build the pre-check hash from the entity's own language --- src/Redirect.php | 9 +++--- tests/src/Kernel/RedirectTest.php | 48 ++++++++++++++++++------------- 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/src/Redirect.php b/src/Redirect.php index c99fe2f..72b4b50 100644 --- a/src/Redirect.php +++ b/src/Redirect.php @@ -52,10 +52,11 @@ public function createRedirect($source, $path, LanguageInterface $language): voi $redirect->setSource($parsed_source); $redirect->setRedirect($parsed_path); $redirect->setStatusCode($this->configFactory->get('redirect.settings')->get('default_status_code')); - $redirect->setLanguage($language->getId()); - - // Check if the redirect doesn't already exist before saving. - $hash = $redirect->generateHash(ltrim($parsed_source, '/'), [], $language->getId()); + // Redirect::preSave() builds the stored hash from the source path and the + // entity's own language. Read the language back off the entity so the + // check below asks for the hash the save will actually produce, rather + // than one built from a language the entity does not carry. + $hash = $redirect->generateHash(ltrim($parsed_source, '/'), [], $redirect->get('language')->value); $redirects = $storage->loadByProperties(['hash' => $hash]); if (empty($redirects)) { // Redirect does not exist yet, save as new one. diff --git a/tests/src/Kernel/RedirectTest.php b/tests/src/Kernel/RedirectTest.php index a5582f0..f34a1ec 100644 --- a/tests/src/Kernel/RedirectTest.php +++ b/tests/src/Kernel/RedirectTest.php @@ -76,31 +76,17 @@ public function testCreateRedirectForPublicScheme(): void { $this->assertStringEndsWith('new/destination.txt', $redirect->getRedirectUrl()->toUriString()); } - /** - * Whether the redirect dedup hash bug has been fixed. - * - * Flip to TRUE to re-enable the test. - */ - private static bool $issueRedirectDedupFixed = FALSE; - /** * Tests that duplicate redirects are silently skipped, not thrown. * - * Bug (undocumented, found while writing this test): the pre-check hash in - * Redirect::createRedirect() is generated from the destination path - * ($parsed_path), but \Drupal\redirect\Entity\Redirect::preSave() always - * recomputes the stored hash from the *source* path. The two hashes are - * never the same value for a real redirect, so the existence check almost - * never matches, and calling createRedirect() twice with an identical - * source/destination throws an uncaught database unique-constraint - * exception instead of being silently skipped. This is not yet filed as a - * Drupal.org issue; recorded here as a baseline for future investigation. + * Until #3045063 was fixed, the pre-check hashed the destination path while + * Redirect::preSave() hashed the source, so the two values never matched + * and a second identical call crashed on the redirect table's unique hash + * index instead of being skipped. + * + * @see https://www.drupal.org/i/3045063 */ public function testCreateRedirectTwiceWithSameArgumentsDoesNotThrow(): void { - if (!self::$issueRedirectDedupFixed) { - $this->markTestSkipped('Reproduces redirect dedup hash bug: duplicate createRedirect() throws instead of being skipped. Flip $issueRedirectDedupFixed once fixed.'); - } - /** @var \Drupal\filefield_paths\RedirectInterface $redirect_service */ $redirect_service = $this->container->get('filefield_paths.redirect'); $language = new Language(['id' => 'en']); @@ -115,6 +101,28 @@ public function testCreateRedirectTwiceWithSameArgumentsDoesNotThrow(): void { $this->assertCount(1, $redirects, 'Duplicate redirect should be skipped, not stored twice.'); } + /** + * The same skip happens when the file's language is not the site default. + * + * The stored hash is built in Redirect::preSave() from the entity's own + * language. Until #3045063 was fixed the pre-check used the language passed + * in by the caller, which is the file's and often not the one the entity + * carries, so the two hashes disagreed and the duplicate was missed. + * + * @see https://www.drupal.org/i/3045063 + */ + public function testCreateRedirectTwiceWithNonDefaultLanguageDoesNotThrow(): void { + /** @var \Drupal\filefield_paths\RedirectInterface $redirect_service */ + $redirect_service = $this->container->get('filefield_paths.redirect'); + $language = new Language(['id' => 'de']); + + $redirect_service->createRedirect('public://old/source.txt', 'public://new/destination.txt', $language); + $redirect_service->createRedirect('public://old/source.txt', 'public://new/destination.txt', $language); + + $redirects = $this->container->get('entity_type.manager')->getStorage('redirect')->loadMultiple(); + $this->assertCount(1, $redirects, 'Duplicate redirect should be skipped for a non-default language too.'); + } + /** * Tests that a private:// destination produces a web-accessible redirect. *