Last active
September 8, 2026 04:06
-
-
Save westonruter/9272d79a35f07fca8979227d0e704522 to your computer and use it in GitHub Desktop.
WordPress plugin which blocks updating a plugin or theme whose root directory contains a .git directory, so Git checkouts are never overwritten.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * Block Updates of Git Checkouts Plugin for WordPress | |
| * | |
| * @package BlockGitCheckoutUpdates | |
| * @author Weston Ruter | |
| * @license GPL-2.0-or-later | |
| * @copyright Copyleft 2026, Weston Ruter | |
| * | |
| * @wordpress-plugin | |
| * Plugin Name: Block Updates of Git Checkouts | |
| * Description: Blocks updating a plugin or theme whose root directory contains a <code>.git</code> directory, so Git checkouts are never overwritten. | |
| * Requires at least: 5.5 | |
| * Requires PHP: 7.4 | |
| * Version: 0.2.0 | |
| * Author: Weston Ruter | |
| * Author URI: https://weston.ruter.net/ | |
| * License: GPLv2 or later | |
| * License URI: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html | |
| * Update URI: https://gist.github.com/westonruter/9272d79a35f07fca8979227d0e704522 | |
| * Gist Plugin URI: https://gist.github.com/westonruter/9272d79a35f07fca8979227d0e704522 | |
| * Provenance: Written with Claude Code using the Claude Fable 5.1 model (claude-fable-5-1), directed and reviewed by Weston Ruter. | |
| * | |
| * This plugin was authored by Claude Code (Anthropic's CLI agent) running the | |
| * Claude Fable 5.1 model (model ID: claude-fable-5-1). The prompt and review | |
| * were provided by Weston Ruter. Session: https://claude.ai/code/session_01RzPyQW6Dhd9iHZ8MA1F4np | |
| */ | |
| declare( strict_types = 1 ); | |
| namespace BlockGitCheckoutUpdates; | |
| if ( ! defined( 'ABSPATH' ) ) { | |
| exit; // @codeCoverageIgnore | |
| } | |
| require_once __DIR__ . '/class-plugin.php'; | |
| ( new Plugin() )->init(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| /** | |
| * Plugin class. | |
| * | |
| * @package BlockGitCheckoutUpdates | |
| */ | |
| declare( strict_types = 1 ); | |
| namespace BlockGitCheckoutUpdates; | |
| use WP_Error; | |
| use WP_MS_Themes_List_Table; | |
| use WP_Plugins_List_Table; | |
| use WP_Theme; | |
| use WP_Upgrader; | |
| use stdClass; | |
| /** | |
| * Blocks updates of plugins and themes that are Git checkouts, showing a notice to update with Git instead. | |
| */ | |
| final class Plugin { | |
| /** | |
| * Plugin updates withheld from the `update_plugins` transient, keyed by plugin basename. | |
| * | |
| * @var array<string, object> | |
| */ | |
| private array $withheld_plugin_updates = array(); | |
| /** | |
| * Theme updates withheld from the `update_themes` transient, keyed by stylesheet. | |
| * | |
| * @var array<string, array<string, mixed>> | |
| */ | |
| private array $withheld_theme_updates = array(); | |
| /** | |
| * Adds hooks. | |
| */ | |
| public function init(): void { | |
| // Backstops in case an update reaches the upgrader some other way (e.g. a direct WP-CLI update). | |
| add_filter( 'upgrader_pre_download', array( $this, 'filter_upgrader_pre_download' ), 0, 4 ); | |
| add_filter( 'upgrader_pre_install', array( $this, 'filter_upgrader_pre_install' ), 0, 2 ); | |
| // Withhold updates for Git checkouts so WordPress never offers to install them. | |
| add_filter( 'site_transient_update_plugins', array( $this, 'filter_update_plugins_transient' ) ); | |
| add_filter( 'site_transient_update_themes', array( $this, 'filter_update_themes_transient' ) ); | |
| // Show a notice about the withheld updates instead. | |
| add_action( 'after_plugin_row', array( $this, 'print_plugin_notice_row' ), 10, 2 ); | |
| add_action( 'after_theme_row', array( $this, 'print_theme_notice_row' ), 10, 2 ); | |
| add_filter( 'wp_prepare_themes_for_js', array( $this, 'filter_prepared_themes' ) ); | |
| add_action( 'admin_print_styles-plugins.php', array( $this, 'print_admin_styles' ) ); | |
| add_action( 'admin_print_styles-themes.php', array( $this, 'print_admin_styles' ) ); | |
| } | |
| /** | |
| * Gets the root directory of a plugin. | |
| * | |
| * @param string $plugin_file Plugin basename, e.g. `foo/foo.php`. | |
| * @return string|null Absolute path to the plugin root, or null for a single-file plugin. | |
| */ | |
| private function get_plugin_root_directory( string $plugin_file ): ?string { | |
| // A single-file plugin has no directory of its own to be a checkout. | |
| if ( ! str_contains( $plugin_file, '/' ) ) { | |
| return null; | |
| } | |
| return WP_PLUGIN_DIR . '/' . dirname( $plugin_file ); | |
| } | |
| /** | |
| * Gets the root directory of a theme. | |
| * | |
| * @param string $stylesheet Theme stylesheet slug. | |
| * @return string|null Absolute path to the theme root, or null if the theme does not exist. | |
| */ | |
| private function get_theme_root_directory( string $stylesheet ): ?string { | |
| $theme = wp_get_theme( $stylesheet ); | |
| if ( ! $theme->exists() ) { | |
| return null; | |
| } | |
| return $theme->get_stylesheet_directory(); | |
| } | |
| /** | |
| * Determines whether a directory is a Git checkout. | |
| * | |
| * @param string|null $root Directory path. | |
| * @return bool Whether the directory contains a .git directory. | |
| */ | |
| private function is_git_checkout( ?string $root ): bool { | |
| return null !== $root && is_dir( $root . '/.git' ); | |
| } | |
| /** | |
| * Returns a value if it is a non-empty string, otherwise a default. | |
| * | |
| * @param mixed $value Value to check. | |
| * @param string $fallback Fallback when the value is not a non-empty string. | |
| * @return string String value. | |
| */ | |
| private function string_or( $value, string $fallback ): string { | |
| return is_string( $value ) && '' !== $value ? $value : $fallback; | |
| } | |
| /** | |
| * Gets the root directory of the plugin or theme targeted by an upgrade. | |
| * | |
| * @param array<string, mixed> $hook_extra Extra arguments passed to upgrader hooks. | |
| * @return string|null Absolute path to the plugin or theme root, or null if the upgrade is not for a plugin or theme. | |
| */ | |
| private function get_target_root_directory( array $hook_extra ): ?string { | |
| if ( isset( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) && '' !== $hook_extra['plugin'] ) { | |
| return $this->get_plugin_root_directory( $hook_extra['plugin'] ); | |
| } | |
| if ( isset( $hook_extra['theme'] ) && is_string( $hook_extra['theme'] ) && '' !== $hook_extra['theme'] ) { | |
| return $this->get_theme_root_directory( $hook_extra['theme'] ); | |
| } | |
| return null; | |
| } | |
| /** | |
| * Gets an error if the upgrade target is a Git checkout. | |
| * | |
| * @param array<string, mixed> $hook_extra Extra arguments passed to upgrader hooks. | |
| * @return WP_Error|null Error if the target contains a .git directory, otherwise null. | |
| */ | |
| private function get_git_checkout_error( array $hook_extra ): ?WP_Error { | |
| $root = $this->get_target_root_directory( $hook_extra ); | |
| if ( ! $this->is_git_checkout( $root ) ) { | |
| return null; | |
| } | |
| return new WP_Error( | |
| 'git_checkout_update_blocked', | |
| sprintf( | |
| /* translators: %s: Directory path. */ | |
| __( 'Update blocked: %s is a Git checkout (it contains a .git directory). Update it with Git instead.' ), | |
| $root | |
| ) | |
| ); | |
| } | |
| /** | |
| * Short-circuits the package download when the upgrade target is a Git checkout. | |
| * | |
| * @param false|string|WP_Error $reply Whether to short-circuit the download. | |
| * @param string $package The package URI. | |
| * @param WP_Upgrader $upgrader The upgrader instance. | |
| * @param array<string, mixed> $hook_extra Extra arguments passed to hooked filters. | |
| * @return false|string|WP_Error Error if the target is a Git checkout, otherwise the unchanged reply. | |
| */ | |
| public function filter_upgrader_pre_download( $reply, string $package, WP_Upgrader $upgrader, array $hook_extra = array() ) { | |
| unset( $package, $upgrader ); | |
| if ( false !== $reply ) { | |
| return $reply; | |
| } | |
| return $this->get_git_checkout_error( $hook_extra ) ?? false; | |
| } | |
| /** | |
| * Aborts installation when the upgrade target is a Git checkout. | |
| * | |
| * @param bool|WP_Error $response Installation response. | |
| * @param array<string, mixed> $hook_extra Extra arguments passed to hooked filters. | |
| * @return bool|WP_Error Error if the target is a Git checkout, otherwise the unchanged response. | |
| */ | |
| public function filter_upgrader_pre_install( $response, array $hook_extra = array() ) { | |
| if ( is_wp_error( $response ) ) { | |
| return $response; | |
| } | |
| return $this->get_git_checkout_error( $hook_extra ) ?? $response; | |
| } | |
| /** | |
| * Removes plugin updates for Git checkouts from the `update_plugins` transient. | |
| * | |
| * @param mixed $transient Transient value. | |
| * @return mixed Filtered transient value. | |
| */ | |
| public function filter_update_plugins_transient( $transient ) { | |
| $this->withheld_plugin_updates = array(); | |
| if ( ! ( $transient instanceof stdClass ) || ! isset( $transient->response ) || ! is_array( $transient->response ) ) { | |
| return $transient; | |
| } | |
| foreach ( $transient->response as $plugin_file => $update ) { | |
| if ( ! is_string( $plugin_file ) || ! is_object( $update ) || ! $this->is_git_checkout( $this->get_plugin_root_directory( $plugin_file ) ) ) { | |
| continue; | |
| } | |
| $this->withheld_plugin_updates[ $plugin_file ] = $update; | |
| $this->withhold_update( $transient, $plugin_file ); | |
| } | |
| return $transient; | |
| } | |
| /** | |
| * Removes theme updates for Git checkouts from the `update_themes` transient. | |
| * | |
| * @param mixed $transient Transient value. | |
| * @return mixed Filtered transient value. | |
| */ | |
| public function filter_update_themes_transient( $transient ) { | |
| $this->withheld_theme_updates = array(); | |
| if ( ! ( $transient instanceof stdClass ) || ! isset( $transient->response ) || ! is_array( $transient->response ) ) { | |
| return $transient; | |
| } | |
| foreach ( $transient->response as $stylesheet => $update ) { | |
| if ( ! is_string( $stylesheet ) || ! is_array( $update ) || ! $this->is_git_checkout( $this->get_theme_root_directory( $stylesheet ) ) ) { | |
| continue; | |
| } | |
| /** @var array<string, mixed> $update */ | |
| $this->withheld_theme_updates[ $stylesheet ] = $update; | |
| $this->withhold_update( $transient, $stylesheet ); | |
| } | |
| return $transient; | |
| } | |
| /** | |
| * Moves an update from `response` to `no_update` in an update transient so the item still counts as checked. | |
| * | |
| * @param stdClass $transient Update transient with a `response` array. | |
| * @param string $key Plugin basename or theme stylesheet. | |
| */ | |
| private function withhold_update( stdClass $transient, string $key ): void { | |
| if ( ! isset( $transient->response ) || ! is_array( $transient->response ) ) { | |
| return; | |
| } | |
| if ( ! isset( $transient->no_update ) || ! is_array( $transient->no_update ) ) { | |
| $transient->no_update = array(); | |
| } | |
| $transient->no_update[ $key ] = $transient->response[ $key ]; | |
| unset( $transient->response[ $key ] ); | |
| } | |
| /** | |
| * Gets the allowed HTML for plugin names in update notices. | |
| * | |
| * @return array<string, array<string, array<never>>> Allowed HTML. | |
| */ | |
| private function get_name_allowed_html(): array { | |
| return array( | |
| 'a' => array( | |
| 'href' => array(), | |
| 'title' => array(), | |
| ), | |
| 'abbr' => array( 'title' => array() ), | |
| 'acronym' => array( 'title' => array() ), | |
| 'code' => array(), | |
| 'em' => array(), | |
| 'strong' => array(), | |
| ); | |
| } | |
| /** | |
| * Builds the notice text telling the user to update a Git checkout with Git. | |
| * | |
| * @param string $name Plugin or theme name (already escaped). | |
| * @param string $new_version New version. | |
| * @param string $details_url URL to the version details, or empty string if unknown. | |
| * @return string Notice HTML. | |
| */ | |
| private function get_notice_html( string $name, string $new_version, string $details_url ): string { | |
| $html = sprintf( | |
| /* translators: 1: Plugin or theme name, 2: Version number, 3: Example Git command. */ | |
| __( 'Version %2$s of %1$s is available, but this is a Git checkout so it must be updated with Git (e.g. %3$s).' ), | |
| $name, | |
| esc_html( $new_version ), | |
| '<code>git pull</code>' | |
| ); | |
| if ( '' !== $details_url ) { | |
| $details_url = add_query_arg( | |
| array( | |
| 'TB_iframe' => 'true', | |
| 'width' => 600, | |
| 'height' => 800, | |
| ), | |
| $details_url | |
| ); | |
| $html .= ' ' . sprintf( | |
| '<a href="%1$s" class="thickbox open-plugin-details-modal" aria-label="%2$s">%3$s</a>.', | |
| esc_url( $details_url ), | |
| /* translators: 1: Plugin or theme name, 2: Version number. */ | |
| esc_attr( sprintf( __( 'View %1$s version %2$s details' ), wp_strip_all_tags( $name ), $new_version ) ), | |
| /* translators: %s: Version number. */ | |
| sprintf( __( 'View version %s details' ), esc_html( $new_version ) ) | |
| ); | |
| } | |
| return $html; | |
| } | |
| /** | |
| * Prints a notice row after a plugin in the plugins list table when an update is being withheld. | |
| * | |
| * @param string $plugin_file Plugin basename. | |
| * @param array<string, mixed> $plugin_data Plugin data. | |
| */ | |
| public function print_plugin_notice_row( string $plugin_file, array $plugin_data ): void { | |
| if ( ! isset( $this->withheld_plugin_updates[ $plugin_file ] ) ) { | |
| return; | |
| } | |
| $response = $this->withheld_plugin_updates[ $plugin_file ]; | |
| $name = wp_kses( $this->string_or( $plugin_data['Name'] ?? null, $plugin_file ), $this->get_name_allowed_html() ); | |
| $new_version = $this->string_or( $response->new_version ?? null, '' ); | |
| $slug = $this->string_or( $response->slug ?? null, $this->string_or( $response->id ?? null, dirname( $plugin_file ) ) ); | |
| if ( isset( $response->slug ) ) { | |
| $details_url = self_admin_url( 'plugin-install.php?tab=plugin-information&plugin=' . $slug . '§ion=changelog' ); | |
| } else { | |
| $details_url = $this->string_or( $response->url ?? null, $this->string_or( $plugin_data['PluginURI'] ?? null, '' ) ); | |
| } | |
| /** @var WP_Plugins_List_Table $wp_list_table */ | |
| $wp_list_table = _get_list_table( 'WP_Plugins_List_Table', array( 'screen' => get_current_screen() ) ); | |
| $active_class = ( is_network_admin() ? is_plugin_active_for_network( $plugin_file ) : is_plugin_active( $plugin_file ) ) ? ' active' : ''; | |
| printf( | |
| '<tr class="plugin-update-tr git-checkout-update-tr%s" id="%s" data-slug="%s" data-plugin="%s">' . | |
| '<td colspan="%s" class="plugin-update colspanchange">' . | |
| '<div class="update-message notice inline notice-warning notice-alt"><p>%s</p></div></td></tr>', | |
| $active_class, | |
| esc_attr( $slug . '-update' ), | |
| esc_attr( $slug ), | |
| esc_attr( $plugin_file ), | |
| esc_attr( (string) $wp_list_table->get_column_count() ), | |
| $this->get_notice_html( $name, $new_version, $details_url ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped when built. | |
| ); | |
| } | |
| /** | |
| * Prints a notice row after a theme in the network themes list table when an update is being withheld. | |
| * | |
| * @param string $stylesheet Theme stylesheet. | |
| * @param WP_Theme $theme Theme object. | |
| */ | |
| public function print_theme_notice_row( string $stylesheet, WP_Theme $theme ): void { | |
| if ( ! isset( $this->withheld_theme_updates[ $stylesheet ] ) ) { | |
| return; | |
| } | |
| $response = $this->withheld_theme_updates[ $stylesheet ]; | |
| /** @var WP_MS_Themes_List_Table $wp_list_table */ | |
| $wp_list_table = _get_list_table( 'WP_MS_Themes_List_Table' ); | |
| printf( | |
| '<tr class="plugin-update-tr git-checkout-update-tr%s" id="%s" data-slug="%s">' . | |
| '<td colspan="%s" class="plugin-update colspanchange">' . | |
| '<div class="update-message notice inline notice-warning notice-alt"><p>%s</p></div></td></tr>', | |
| $theme->is_allowed( 'network' ) ? ' active' : '', | |
| esc_attr( $stylesheet . '-update' ), | |
| esc_attr( $stylesheet ), | |
| esc_attr( (string) $wp_list_table->get_column_count() ), | |
| $this->get_notice_html( // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped when built. | |
| $this->string_or( $theme->display( 'Name' ), esc_html( $stylesheet ) ), | |
| $this->string_or( $response['new_version'] ?? null, '' ), | |
| $this->string_or( $response['url'] ?? null, '' ) | |
| ) | |
| ); | |
| } | |
| /** | |
| * Marks Git-checkout themes as having an update in the Themes screen so a notice is shown, without a package to install. | |
| * | |
| * The theme card shows "New version available." (no Update button, since there is no package), | |
| * and the theme details overlay shows the full notice explaining to update with Git. | |
| * | |
| * @param array<string, array<string, mixed>> $prepared_themes Themes prepared for JS, keyed by stylesheet. | |
| * @return array<string, array<string, mixed>> Filtered themes. | |
| */ | |
| public function filter_prepared_themes( array $prepared_themes ): array { | |
| foreach ( $this->withheld_theme_updates as $stylesheet => $response ) { | |
| if ( ! isset( $prepared_themes[ $stylesheet ] ) ) { | |
| continue; | |
| } | |
| $theme = wp_get_theme( $stylesheet ); | |
| $prepared_themes[ $stylesheet ]['hasUpdate'] = true; | |
| $prepared_themes[ $stylesheet ]['hasPackage'] = false; | |
| $prepared_themes[ $stylesheet ]['updateResponse'] = array( | |
| 'compatibleWP' => true, | |
| 'compatiblePHP' => true, | |
| ); | |
| $prepared_themes[ $stylesheet ]['update'] = '<p>' . $this->get_notice_html( | |
| $this->string_or( $theme->display( 'Name' ), esc_html( $stylesheet ) ), | |
| $this->string_or( $response['new_version'] ?? null, '' ), | |
| $this->string_or( $response['url'] ?? null, '' ) | |
| ) . '</p>'; | |
| } | |
| return $prepared_themes; | |
| } | |
| /** | |
| * Prints styles so the withheld-update notice row joins the plugin row above it like core's update row does. | |
| */ | |
| public function print_admin_styles(): void { | |
| ?> | |
| <style> | |
| .plugins tr:has(+ tr.git-checkout-update-tr) th, | |
| .plugins tr:has(+ tr.git-checkout-update-tr) td { | |
| border-bottom: 0; box-shadow: none; | |
| } | |
| </style> | |
| <?php | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment