Skip to content

Instantly share code, notes, and snippets.

@westonruter
Last active September 8, 2026 04:06
Show Gist options
  • Select an option

  • Save westonruter/9272d79a35f07fca8979227d0e704522 to your computer and use it in GitHub Desktop.

Select an option

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.
<?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();
<?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 . '&section=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