diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-svn-watcher.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-svn-watcher.php
index 195e3713d8..1cafc3b668 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-svn-watcher.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-svn-watcher.php
@@ -174,8 +174,13 @@ protected function summarize_plugin_changes( $logs ) {
}
if ( 'trunk' == $path_parts[1] ) {
- $plugin['tags_touched'][] = 'trunk';
-
+ /*
+ * A deleted /trunk has nothing left to export. It isn't recorded in tags_deleted
+ * either, as that removes the matching release, and trunk has no release of its own.
+ */
+ if ( ! $is_deletion || isset( $path_parts[2] ) /* a file within trunk */ ) {
+ $plugin['tags_touched'][] = 'trunk';
+ }
} elseif ( 'tags' == $path_parts[1] && isset( $path_parts[2] ) ) {
if ( $is_deletion && ! isset( $path_parts[3] ) /* not a file deletion */ ) {
$plugin['tags_deleted'][] = $path_parts[2];
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/SVN_Watcher_Log_Summary_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/SVN_Watcher_Log_Summary_Test.php
index f047911f17..ec4c09bbf1 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/SVN_Watcher_Log_Summary_Test.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/SVN_Watcher_Log_Summary_Test.php
@@ -185,6 +185,69 @@ public function test_tag_deletion_is_distinguished_from_a_file_deletion_within_a
$this->assertSame( array( '2.0' ), $plugins['plugin-a']['tags_touched'] );
}
+ /**
+ * Deleting /trunk leaves nothing to export, so it mustn't be queued as a touched version.
+ *
+ * This is a real release pattern: the tag is added and trunk deleted in one commit, then
+ * trunk is re-created from that tag a few seconds later. An import queued from the first
+ * commit runs while trunk is missing, and the ZIP build fails against a URL that's gone.
+ */
+ public function test_trunk_deletion_is_not_queued_as_a_touched_version() {
+ $plugins = $this->summarize(
+ array(
+ $this->log_entry(
+ 3653586,
+ array(
+ '/wp-better-permalinks/tags/4.3.1' => 'A',
+ '/wp-better-permalinks/tags/4.3.1/readme.txt' => 'A',
+ '/wp-better-permalinks/trunk' => 'D',
+ )
+ ),
+ )
+ );
+
+ $this->assertSame( array( '4.3.1' ), $plugins['wp-better-permalinks']['tags_touched'] );
+ $this->assertNotContains( 'trunk', $plugins['wp-better-permalinks']['tags_touched'] );
+
+ // trunk has no release of its own, so there's nothing for the importer to remove either.
+ $this->assertSame( array(), $plugins['wp-better-permalinks']['tags_deleted'] );
+ }
+
+ /**
+ * Deleting a file inside /trunk is a change to trunk, not the removal of it.
+ */
+ public function test_file_deletion_within_trunk_still_touches_trunk() {
+ $plugins = $this->summarize(
+ array(
+ $this->log_entry( 600, array( '/plugin-a/trunk/deprecated.php' => 'D' ) ),
+ )
+ );
+
+ $this->assertSame( array( 'trunk' ), $plugins['plugin-a']['tags_touched'] );
+ }
+
+ /**
+ * Once trunk is back, it's a change like any other — including when both commits land in
+ * the same batch of revisions, where the deletion and the re-creation are seen together.
+ */
+ public function test_recreated_trunk_is_queued_again() {
+ $plugins = $this->summarize(
+ array(
+ $this->log_entry(
+ 3653586,
+ array(
+ '/plugin-a/tags/1.0' => 'A',
+ '/plugin-a/trunk' => 'D',
+ )
+ ),
+ $this->log_entry( 3653587, array( '/plugin-a/trunk' => 'A' ) ),
+ )
+ );
+
+ $this->assertEqualsCanonicalizing( array( '1.0', 'trunk' ), $plugins['plugin-a']['tags_touched'] );
+ $this->assertSame( array( 3653586, 3653587 ), $plugins['plugin-a']['revisions'] );
+ }
+
/**
* Paths that don't name anything below the plugin root carry no importable change.
*/