You are here

git_deploy.module in Git Deploy 6.2

Adds project, version and date information to projects checked out with Git.

File

git_deploy.module
View source
<?php

/**
 * @file
 * Adds project, version and date information to projects checked out with Git.
 */

/**
 * Null device.
 *
 * If /dev/null does not exist, assume we are on Windows.
 */
define('GIT_DEPLOY_ERROR_DUMP', file_exists('/dev/null') ? '/dev/null' : 'nul');

/**
 * Implements hook_help().
 */
function git_deploy_help($path, $arg) {
  if ($path == 'admin/reports/updates' && !module_exists('mydropwizard')) {
    return '<p><strong>' . t('Drupal 6 has reached its community End-of-Life (EOL) date and is now in a Long-Term Support (LTS) phase, where support is provided by a <a href="http://drupal.org/project/d6lts">small group of vendors</a>. To get accurate update status information, enable the <a href="https://www.drupal.org/project/mydropwizard">myDropWizard</a> module.') . '</strong></p>';
  }
}

/**
 * Stores shared static variables for Git Deploy.
 *
 * @param string|NULL $name
 *   Unique variable name. If omitted, all variables.
 * @param $default_value
 *   Value to store.
 * @param bool $reset
 *   Internal: TRUE to reset one or all variables.
 */
function &_git_deploy_static($name, $default_value = NULL, $reset = FALSE) {
  static $data = array(), $default = array();
  if (isset($name)) {
    if (array_key_exists($name, $data)) {
      if ($reset) {
        $data[$name] = $default[$name];
      }
    }
    else {
      $data[$name] = $default[$name] = $default_value;
    }
    return $data[$name];
  }
  elseif ($reset) {
    foreach ($default as $name => $value) {
      $data[$name] = $default[$name];
    }
  }
  return $data;
}

/**
 * Resets one or all Git Deploy static variables.
 *
 * @param string|null $name
 *   Name of static variable to reset. Omit to reset all variables.
 */
function _git_deploy_static_reset($name = NULL) {
  _git_deploy_static($name, NULL, TRUE);
}

/**
 * Implements hook_system_info_alter().
 */
function git_deploy_system_info_alter(&$info, $file) {

  // Use _git_deploy_static() so we can share static variables and free up
  // memory later.
  $projects =& _git_deploy_static('projects', array());
  $available =& _git_deploy_static('available');
  $update =& _git_deploy_static('update', array());
  $last_check =& _git_deploy_static('last_check');

  // Core has hard-coded version numbers, so we need to verify them. Otherwise,
  // a valid version number indicates that this project was not installed with
  // Git.
  $is_core = isset($info['package']) && strpos($info['package'], 'Core -') !== FALSE;
  if (empty($info['version']) || ($is_core ? strstr($info['version'], '-dev') == '-dev' : $info['version'] == VERSION || !preg_match('/^6\\.x-\\d+\\..+/', $info['version']))) {

    // Work around bug that causes Git to fail for users without home directory.
    $home = getenv('HOME') === FALSE ? 'HOME=' . DRUPAL_ROOT . ' ' : '';

    // Verify that we are in a Git repository. For core, also verify that this
    // is really a Drupal repository.
    $directory = exec($home . 'git -C ' . escapeshellarg(dirname($file->filename)) . ' rev-parse --show-toplevel 2> ' . GIT_DEPLOY_ERROR_DUMP);
    if (!empty($directory) && (!$is_core || $directory == DRUPAL_ROOT)) {

      // Only check Git once per repository.
      if (!isset($projects[$directory])) {
        $projects[$directory] = array();

        // Make sure Git operates in the right directory.
        $git = $home . 'git -C ' . escapeshellarg($directory);

        // Ensure file is in repository.
        if (exec("{$git} ls-files " . escapeshellarg(str_replace("{$directory}/", '', realpath($file->filename))) . ' 2> ' . GIT_DEPLOY_ERROR_DUMP)) {

          // Get upstream info.
          $upstream = _git_deploy_get_upstream($git, $is_core ? array(
            '6',
          ) : array(
            '6.x-*',
          ));
          if ($is_core) {
            $project_name = 'drupal';
          }
          elseif (isset($upstream['remote'])) {

            // Find the project name based on fetch URL.
            $fetch_url = exec("{$git} config --get " . escapeshellarg("remote.{$upstream['remote']}.url") . ' 2> ' . GIT_DEPLOY_ERROR_DUMP);
            if (!empty($fetch_url)) {
              $project_name = basename($fetch_url, '.git');
              $projects[$directory]['project'] = $project_name;
            }
          }

          // Set project datestamp.
          if (isset($upstream['datestamp'])) {
            $projects[$directory]['datestamp'] = $upstream['datestamp'];

            // The '_info_file_ctime' should always get the latest value.
            if (empty($info['_info_file_ctime'])) {
              $projects[$directory]['_info_file_ctime'] = $upstream['datestamp'];
            }
            else {
              $projects[$directory]['_info_file_ctime'] = max($info['_info_file_ctime'], $upstream['datestamp']);
            }
          }

          // Set version from tag.
          if (isset($upstream['tag'])) {
            $projects[$directory]['version'] = $upstream['tag'];
          }
          elseif (isset($upstream['branch'])) {
            if ($upstream['branch'] != 'master') {
              $projects[$directory]['version'] = "{$upstream['branch']}-dev";
            }
            if (module_exists('mydropwizard') && ($upstream['synced'] || $upstream['branch'] == 'master')) {
              if (!isset($available)) {

                // We only get this from the cache because an update status
                // query is supposed to done only after processing all enabled
                // modules and themes.
                if (($cache = _mydropwizard_cache_get('mydropwizard_available_releases')) && $cache->expire > time()) {
                  $available = $cache->data;
                  $last_check = variable_get('mydropwizard_last_check', 0);
                }
              }
              if (!empty($available[$project_name]['releases'])) {
                if ($upstream['branch'] == 'master') {

                  // If there's available update_status data, we can use the
                  // version string the release node pointing to HEAD really
                  // has.
                  foreach ($available[$project_name]['releases'] as $release) {
                    if (isset($release['tag']) && $release['tag'] == 'HEAD') {
                      $projects[$directory]['version'] = $release['version'];
                      break;
                    }
                  }
                }
                if ($upstream['synced']) {

                  // This project's datestamp needs to be synced with upstream
                  // release.
                  $version = $projects[$directory]['version'];
                  if (!isset($available[$project_name]['releases'][$version]) || $projects[$directory]['_info_file_ctime'] > $last_check) {

                    // We need to update available release data for this
                    // project.
                    $update[] = $project_name;
                  }
                  else {
                    _git_deploy_datestamp_sync($projects[$directory], $available[$project_name]['releases'][$version]);
                  }
                }
              }
            }
          }
        }
      }
      $info = $projects[$directory] + $info;
    }
  }
}

/**
 * Gets upstream info.
 *
 * @param string $git
 *   Git formatted for command line.
 * @param string[] $patterns
 *   List of patterns for matching branch names, without trailing ".x". Must not
 *   include repository name. Also used to check for release tags.
 *
 * @return string[]
 *   Array with the following keys, if found:
 *   - branch: Best matching remote branch.
 *   - remote: Remote repository containing best matching branch.
 *   - tag: Release tag from last common commit in matching branch.
 *   - datestamp: Unix timestamp of last common commit.
 */
function _git_deploy_get_upstream($git, array $patterns = array(
  '*',
)) {
  $upstream = array(
    'synced' => FALSE,
  );

  // Check that there are remote repositories.
  exec("{$git} remote 2> " . GIT_DEPLOY_ERROR_DUMP, $remotes);
  if (!empty($remotes)) {

    // Get most recent tag.
    $tag = exec("{$git} describe --tags --abbrev=0 --match " . implode(' --match ', substr_replace($patterns, '.*', array_map('strlen', $patterns))) . ' 2> ' . GIT_DEPLOY_ERROR_DUMP);

    // Get current commit.
    $last_base = $head = exec("{$git} log -1 --pretty=format:%H 2> " . GIT_DEPLOY_ERROR_DUMP);

    // Get tracked upstream branch.
    $remote = exec("{$git} rev-parse --abbrev-ref @{upstream} 2> " . GIT_DEPLOY_ERROR_DUMP);
    if ($remote !== '' && preg_match('/^.+\\/(?:' . implode('|', str_replace(array(
      '.',
      '*',
    ), array(
      '\\.',
      '\\d+',
    ), $patterns)) . ')\\.x$/', $remote)) {

      // Set remote.
      list($upstream['branch'], $upstream['remote']) = array_reverse(explode('/', $remote));

      // Find last common commit in both local and upstream.
      $last_base = exec("{$git} merge-base HEAD " . escapeshellarg($remote) . ' 2> ' . GIT_DEPLOY_ERROR_DUMP);
      if (!empty($tag)) {

        // See if most recent tag is in our branch.
        exec("{$git} describe --tags --contains " . escapeshellarg($last_base) . ' --match ' . escapeshellarg($tag) . ' 2> ' . GIT_DEPLOY_ERROR_DUMP, $output, $status);
      }
      if (empty($tag) || $status !== 0) {

        // Compare with last remote commit for update status.
        $upstream['synced'] = $last_base == exec("{$git} log -1 --pretty=format:%H " . escapeshellarg($remote) . ' 2> ' . GIT_DEPLOY_ERROR_DUMP);
      }
      else {
        $upstream['tag'] = $tag;
      }
    }
    else {

      // If local does not track an upstream branch, find best matching remote.
      if (in_array('origin', $remotes)) {

        // If origin exists, don't check any other remote repositories.
        $upstream['remote'] = 'origin';
      }
      elseif ($remote !== '') {

        // If we are tracking a remote repository, don't check any other remote
        // repositories.
        list(, $upstream['remote']) = array_reverse(explode('/', $remote));
      }
      if (isset($upstream['remote'])) {
        $branch_patterns = substr_replace($patterns, "{$upstream['remote']}/", 0, 0);
      }
      else {
        $upstream['remote'] = current($remotes);
        $branch_patterns = substr_replace($patterns, '*/', 0, 0);
      }
      if (!empty($tag)) {

        // See if we are on a tag.
        exec("{$git} describe --tags --exact-match --match " . escapeshellarg($tag) . ' 2> ' . GIT_DEPLOY_ERROR_DUMP, $output, $status);
      }

      // If we are not on a tag, find the best matching remote branch.
      if (empty($tag) || $status !== 0) {

        // Append ".x" to the branch patterns.
        $branch_patterns = substr_replace($branch_patterns, '.x', array_map('strlen', $branch_patterns));

        // Enclose branch patterns in quotes and join together.
        $branch_pattern = implode(' ', array_map('escapeshellarg', $branch_patterns));

        // List matching branches by version in descending order.
        exec("{$git} branch -r --list {$branch_pattern} master 2> " . GIT_DEPLOY_ERROR_DUMP, $branches);
        if (!empty($branches)) {
          usort($branches, 'version_compare');
          foreach (array_reverse(array_map('trim', $branches)) as $branch) {
            $tip = exec("{$git} log -1 --pretty=format:%H " . escapeshellarg($branch) . ' 2> ' . GIT_DEPLOY_ERROR_DUMP);
            if ($tip == $head) {

              // If remote branch matches local branch, it is the best match.
              list($upstream['branch'], $upstream['remote']) = array_reverse(explode('/', $branch));
              $last_base = $tip;

              // Local history contains last remote commit.
              $upstream['synced'] = TRUE;
              break;
            }
            if (isset($upstream['branch'])) {

              // Replace branch tip with last common commit.
              $tip = exec("{$git} merge-base HEAD " . escapeshellarg($tip) . ' 2> ' . GIT_DEPLOY_ERROR_DUMP);
              if ($tip != $last_base) {

                // See if this branch is older than last branch.
                exec("{$git} merge-base --is-ancestor " . escapeshellarg($tip) . ' ' . escapeshellarg($last_base) . ' 2> ' . GIT_DEPLOY_ERROR_DUMP, $output, $status);
              }
              if ($tip == $last_base || $status === 0) {

                // Last remote branch was more recent. For performance, stop
                // looking.
                break;
              }

              // This remote branch is more recent.
              list($upstream['branch'], $upstream['remote']) = array_reverse(explode('/', $branch));
              $last_base = $tip;
            }
            else {

              // Find most recent common commit.
              list($upstream['branch'], $upstream['remote']) = array_reverse(explode('/', $branch));
              $last_base = exec("{$git} merge-base HEAD " . escapeshellarg($tip) . ' 2> ' . GIT_DEPLOY_ERROR_DUMP);

              // Check for latest commit in local history.
              $upstream['synced'] = $last_base == $tip;
            }
          }
          if (!empty($tag)) {

            // See if most recent tag is in our branch.
            exec("{$git} describe --tags --contains " . escapeshellarg($last_base) . ' --match ' . escapeshellarg($tag) . ' 2> ' . GIT_DEPLOY_ERROR_DUMP, $output, $status);
            if ($status === 0) {
              $upstream['tag'] = $tag;
            }
          }
        }
      }
      else {
        $upstream['tag'] = $tag;
      }
    }

    // Find the timestamp for the current commit.
    $upstream['datestamp'] = exec("{$git} log -1 --pretty=format:%at " . escapeshellarg($last_base) . " 2> " . GIT_DEPLOY_ERROR_DUMP);
  }
  return $upstream;
}

/**
 * Implements hook_mydropwizard_projects_alter().
 */
function git_deploy_mydropwizard_projects_alter(&$projects) {

  // Get projects whose datestamps need to be synced with upstream releases.
  $update = _git_deploy_static('update', array());

  // Free up memory.
  _git_deploy_static_reset();
  if (!empty($update)) {

    // Fetch updated release data.
    $available = _git_deploy_update_refresh($projects);
    foreach ($update as $project_name) {
      $project =& $projects[$project_name];
      $version = $project['info']['version'];
      if (isset($available[$project_name]['releases'][$version])) {
        _git_deploy_datestamp_sync($project, $available[$project_name]['releases'][$version]);
      }
    }
  }
}

/**
 * Updates available release data given a list of projects.
 *
 * We need this because loading projects within the function that fetches
 * available releases would start an endless loop.
 *
 * @param array[] $projects
 *   List of available projects.
 *
 * @return array[]
 *   List of available releases, keyed by project name.
 *
 * @see _mydropwizard_refresh()
 */
function _git_deploy_update_refresh($projects) {
  static $fail = array();
  global $base_url;
  module_load_include('inc', 'mydropwizard', 'mydropwizard.compare');

  // Since we're fetching new available update data, we want to clear our cache
  // of both the projects we care about, and the current update status of the
  // site. We do *not* want to clear the cache of available releases just yet,
  // since that data (even if it's stale) can be useful during
  // mydropwizard_get_projects(); for example, to modules that implement
  // hook_system_info_alter() such as cvs_deploy.
  _mydropwizard_cache_clear('mydropwizard_project_projects');
  _mydropwizard_cache_clear('mydropwizard_project_data');
  $available = array();
  $data = array();
  $site_key = md5($base_url . drupal_get_private_key());

  // Now that we have the list of projects, we should also clear our cache of
  // available release data, since even if we fail to fetch new data, we need
  // to clear out the stale data at this point.
  _mydropwizard_cache_clear('mydropwizard_available_releases');
  $max_fetch_attempts = variable_get('mydropwizard_max_fetch_attempts', UPDATE_MAX_FETCH_ATTEMPTS);

  // Call back to the server to share staistics, and see if we're using an
  // out-of-date version of the mydropwizard module.
  $project_versions = array();
  foreach ($projects as $key => $project) {
    if (strpos($project['project_type'], 'disabled') === FALSE) {
      if (!empty($project['info']['version'])) {
        $version = $project['info']['version'];
      }
      else {

        // Stub version to encode the core compatibility.
        $version = DRUPAL_CORE_COMPATIBILITY;
      }
      $project_versions[$key] = $version;
    }
  }
  $statistics_url = variable_get('mydropwizard_statistics_url', MYDROPWIZARD_STATISTICS_URL);
  $statistics_data = drupal_to_js(array(
    'site_key' => $site_key,
    'projects' => $project_versions,
    'mydropwizard_key' => variable_get('mydropwizard_customer_key', ''),
  ));
  if (variable_get('mydropwizard_http_post_disabled', FALSE)) {

    // This is a hack for a customer site that seemingly can't do HTTP POST
    // for mysterious reasons. I don't like it, but it works.
    $response = drupal_http_request($statistics_url . '?data=' . base64_encode($statistics_data));
  }
  else {
    $response = drupal_http_request($statistics_url, array(
      'Content-type' => 'application/json',
    ), 'POST', $statistics_data);
  }
  $update_status = $response->data;
  variable_set('mydropwizard_update_status', $update_status);

  // If the server says we're good, then pull the individual status for each project.
  if ($update_status == 'OK' || variable_get('mydropwizard_ignore_statistics_errors', FALSE)) {
    module_load_include('inc', 'mydropwizard', 'mydropwizard.fetch');
    foreach ($projects as $key => $project) {
      $url = _mydropwizard_build_fetch_url($project, $site_key);
      $fetch_url_base = _mydropwizard_get_fetch_url_base($project);
      if (empty($fail[$fetch_url_base]) || count($fail[$fetch_url_base]) < $max_fetch_attempts) {
        $xml = drupal_http_request($url);
        if (isset($xml->data)) {
          $data[] = $xml->data;
        }
        else {

          // Connection likely broken; prepare to give up.
          $fail[$fetch_url_base][$key] = 1;
        }
      }
      else {

        // Didn't bother trying to fetch.
        $fail[$fetch_url_base][$key] = 1;
      }
    }
  }
  if ($data) {
    $parser = new mydropwizard_xml_parser();
    $available = $parser
      ->parse($data);
  }
  if (!empty($available) && is_array($available)) {

    // Record the projects where we failed to fetch data.
    foreach ($fail as $fetch_url_base => $failures) {
      foreach ($failures as $key => $value) {
        $available[$key]['project_status'] = 'not-fetched';
      }
    }
    $frequency = variable_get('mydropwizard_check_frequency', 1);
    _mydropwizard_cache_set('mydropwizard_available_releases', $available, time() + 60 * 60 * 24 * $frequency);
    watchdog('mydropwizard', 'Attempted to fetch information about all available new releases and updates.', array(), WATCHDOG_NOTICE, l(t('view'), 'admin/reports/updates'));
  }
  else {
    watchdog('mydropwizard', 'Unable to fetch any information about available new releases and updates.', array(), WATCHDOG_ERROR, l(t('view'), 'admin/reports/updates'));
  }

  // Whether this worked or not, we did just (try to) check for updates.
  variable_set('mydropwizard_last_check', time());
  return $available;
}

/**
 * Syncs the project datestamp to the release datestamp.
 *
 * @param array $project
 *   Project data. Datestamp will be synced if up to date with release.
 * @param array $release
 *   Release data.
 */
function _git_deploy_datestamp_sync(array &$project, array $release) {

  // We need to compare commit time to release time because our remote tracking
  // branch could be out of date. Allow a 12 hour time difference between
  // release and last commit, because dev releases are packaged only twice a
  // day. Add a 100-second buffer to account for packaging time.
  if ($project['datestamp'] + 43200 + 100 > $release['date']) {

    // A dev release for the latest commit may be created later than an official
    // release, so use release time only if it is later than the commit time.
    $project['datestamp'] = max($release['date'], $project['datestamp']);
  }
}

/**
 * Implements hook_mydropwizard_status_alter().
 */
function git_deploy_mydropwizard_status_alter(&$projects) {

  // Git Deploy for Drupal 6 is still supported.
  if ($projects['git_deploy']['status'] == UPDATE_NOT_SUPPORTED && $projects['git_deploy']['project_status'] == 'unsupported') {
    array_shift($projects['git_deploy']['extra']);
    switch ($projects['git_deploy']['install_type']) {
      case 'official':
        if ($projects['git_deploy']['existing_version'] === $projects['git_deploy']['recommended'] || $projects['git_deploy']['existing_version'] === $projects['git_deploy']['latest_version']) {
          $projects['git_deploy']['status'] = UPDATE_CURRENT;
        }
        else {
          $projects['git_deploy']['status'] = UPDATE_NOT_CURRENT;
        }
        break;
      case 'dev':
        if (isset($projects['git_deploy']['dev_version']) && $projects['git_deploy']['releases'][$projects['git_deploy']['dev_version']]['date'] > $projects['git_deploy']['releases'][$projects['git_deploy']['latest_version']]['date']) {
          $latest = $projects['git_deploy']['releases'][$projects['git_deploy']['dev_version']];
        }
        else {
          $latest = $projects['git_deploy']['releases'][$projects['git_deploy']['latest_version']];
        }
        if (empty($projects['git_deploy']['datestamp'])) {
          $projects['git_deploy']['status'] = UPDATE_NOT_CHECKED;
          $projects['git_deploy']['reason'] = t('Unknown release date');
        }
        elseif ($projects['git_deploy']['datestamp'] + 100 > $latest['date']) {
          $projects['git_deploy']['status'] = UPDATE_CURRENT;
        }
        else {
          $projects['git_deploy']['status'] = UPDATE_NOT_CURRENT;
        }
        break;
      default:
        $projects['git_deploy']['status'] = UPDATE_UNKNOWN;
        $projects['git_deploy']['reason'] = t('Invalid info');
    }
  }
}

Functions

Namesort descending Description
git_deploy_help Implements hook_help().
git_deploy_mydropwizard_projects_alter Implements hook_mydropwizard_projects_alter().
git_deploy_mydropwizard_status_alter Implements hook_mydropwizard_status_alter().
git_deploy_system_info_alter Implements hook_system_info_alter().
_git_deploy_datestamp_sync Syncs the project datestamp to the release datestamp.
_git_deploy_get_upstream Gets upstream info.
_git_deploy_static Stores shared static variables for Git Deploy.
_git_deploy_static_reset Resets one or all Git Deploy static variables.
_git_deploy_update_refresh Updates available release data given a list of projects.

Constants

Namesort descending Description
GIT_DEPLOY_ERROR_DUMP Null device.