givi - Add helper to coordinate checkouts across all repos
authorTim Otten <totten@civicrm.org>
Tue, 12 Mar 2013 11:17:46 +0000 (07:17 -0400)
committerTim Otten <totten@civicrm.org>
Tue, 12 Mar 2013 11:26:23 +0000 (07:26 -0400)
bin/givi.php [new file with mode: 0755]

diff --git a/bin/givi.php b/bin/givi.php
new file mode 100755 (executable)
index 0000000..9e5bccc
--- /dev/null
@@ -0,0 +1,379 @@
+#!/usr/bin/env php
+<?php
+
+// This is the minimalist denialist implementation that doesn't check it's
+// pre-conditions and will screw up if you don't know what you're doing.
+
+/**
+ * Manage the current working directory as a stack.
+ */
+class DirStack {
+  protected $dirs;
+
+  function __construct($dirs = array()) {
+    $this->dirs = $dirs;
+  }
+
+  function push($dir) {
+    $this->dirs[] = getcwd();
+    if (!chdir($dir)) {
+      throw new Exception("Failed to chdir($dir)");
+    }
+  }
+
+  function pop() {
+    $oldDir = array_pop($this->dirs);
+    chdir($oldDir);
+  }
+}
+
+class Givi {
+
+  /**
+   * @var string 'checkout-all', 'begin', 'help', etc
+   */
+  protected $action;
+
+  /**
+   * @var string
+   */
+  protected $baseBranch;
+
+  /**
+   * @var array ($repoName => $gitRef)
+   */
+  protected $branches;
+
+  /**
+   * @var string
+   */
+  protected $civiRoot = '.';
+
+  /**
+   * @var int
+   */
+  protected $drupalVersion = 7;
+
+  /**
+   * @var bool
+   */
+  protected $dryRun = FALSE;
+
+  /**
+   * @var bool
+   */
+  protected $fetch = FALSE;
+
+  /**
+   * @var bool
+   */
+  protected $rebase = FALSE;
+
+  /**
+   * @var array ($repoName => $relPath)
+   */
+  protected $repos;
+
+  /**
+   * @var array, non-hyphenated arguments after the basedir
+   */
+  protected $arguments;
+
+  /**
+   * @var string, the name of this program
+   */
+  protected $program;
+
+  /**
+   * @var DirStack
+   */
+  protected $dirStack;
+
+  function __construct() {
+    $this->dirStack = new DirStack();
+    $this->repos = array(
+      'core' => '.',
+      'packages' => 'packages',
+      'joomla' => 'joomla',
+      'drupal' => 'drupal',
+      'wordpress' => 'WordPress',
+    );
+  }
+
+  function main($args) {
+    if (!$this->parseOptions($args)) {
+      printf("Error parsing arguments\n");
+      $this->doHelp();
+      return FALSE;
+    }
+
+    // All operations relative to civiRoot
+    $this->dirStack->push($this->civiRoot);
+
+    // Filter branch list based on what repos actually exist
+    foreach (array_keys($this->repos) as $repo) {
+      if (!is_dir($this->repos[$repo])) {
+        unset($this->repos[$repo]);
+      }
+    }
+    if (!isset($this->repos['core']) || !isset($this->repos['packages'])) {
+      return $this->returnError("Root appears to be invalid -- missing too many repos. Try --root=<dir>\n");
+    }
+
+    // Run the action
+    switch ($this->action) {
+      case 'checkout-all':
+        call_user_func_array(array($this, 'doCheckoutAll'), $this->arguments);
+        break;
+      case 'fetch-all':
+        call_user_func_array(array($this, 'doFetchAll'), $this->arguments);
+        break;
+      case 'status-all':
+        call_user_func_array(array($this, 'doStatusAll'), $this->arguments);
+        break;
+      case 'begin':
+        call_user_func_array(array($this, 'doBegin'), $this->arguments);
+        break;
+      case 'resume':
+        call_user_func_array(array($this, 'doResume'), $this->arguments);
+        break;
+      case 'help':
+        $this->doHelp();
+        break;
+      default:
+        return $this->returnError("unrecognized action: {$this->action}\n");
+    }
+
+    $this->dirStack->pop();
+  }
+
+  /**
+   * @param $args
+   * @return bool
+   */
+  function parseOptions($args) {
+    $this->branches = array();
+    $this->arguments = array();
+
+    foreach ($args as $arg) {
+      if ($arg == '--fetch') {
+        $this->fetch = TRUE;
+      }
+      elseif ($arg == '--rebase') {
+        $this->rebase = TRUE;
+      }
+      elseif ($arg == '--dry-run' || $arg == '-n') {
+        $this->dryRun = TRUE;
+      }
+      elseif (preg_match('/^--d([678])/', $arg, $matches)) {
+        $this->drupalVersion = $matches[1];
+      }
+      elseif (preg_match('/^--root=(.*)/', $arg, $matches)) {
+        $this->civiRoot = $matches[1];
+      }
+      elseif (preg_match('/^--(core|packages|joomla|drupal|wordpress)=(.*)/', $arg, $matches)) {
+        $this->branches[$matches[1]] = $matches[2];
+      }
+      elseif (preg_match('/^-/', $arg)) {
+        printf("unrecognized argument: %s\n", $arg);
+        return FALSE;
+      }
+      else {
+        $this->arguments[] = $arg;
+      }
+    }
+
+    $this->program = @array_shift($this->arguments);
+    $this->action = @array_shift($this->arguments);
+    return TRUE;
+  }
+
+  function doHelp() {
+    $program = basename($this->program);
+    echo "Givi - Coordinate git checkouts across CiviCRM repositories\n";
+    echo "Usage:\n";
+    echo "  $program [options] checkout-all <branch>\n";
+    echo "  $program [options] fetch-all\n";
+    echo "  $program [options] status-all\n";
+    echo "  $program [options] begin <base-branch> [--core=<new-branch>|--drupal=<new-branch>|...] \n";
+    echo "  $program [options] resume [--rebase] <base-branch> [--core=<custom-branch>|--drupal=<custom-branch>|...] \n";
+    echo "Actions:\n";
+    echo "  checkout-all: Checkout same branch name on all repos\n";
+    echo "  status-all: Display status on all repos\n";
+    echo "  begin: Begin work on a new branch on some repo (and use base-branch for all others)\n";
+    echo "  resume: Resume work on an existing branch on some repo (and use base-branch for all others)\n";
+    echo "Common options:\n";
+    echo "  --d6: Specify that Drupal branches should use 6.x-* prefixes\n";
+    echo "  --d7: Specify that Drupal branches should use 7.x-* prefixes (default)\n";
+    echo "  --fetch: Fetch the latest code before creating, updating, or checking-out anything\n";
+    echo "  --root=X: Specify CiviCRM root directory (default: .)\n";
+    echo "Special options:\n";
+    echo "  --core=X: Specify the branch to use on the core repository\n";
+    echo "  --packages=X: Specify the branch to use on the packages repository\n";
+    echo "  --drupal=X: Specify the branch to use on the drupal repository\n";
+    echo "  --joomla=X: Specify the branch to use on the joomla repository\n";
+    echo "  --wordpress=X: Specify the branch to use on the wordpress repository\n";
+    echo "  --rebase: Perform a rebase before starting work\n";
+    echo "When using 'begin' or 'resume' with a remote base-branch, most repositories\n";
+    echo "will have a detached HEAD. Only repos with an explicit branch will be real,\n";
+    echo "local branches.\n";
+  }
+
+  function doCheckoutAll($baseBranch = NULL) {
+    if (!$baseBranch) {
+      return $this->returnError("Missing <branch>\n");
+    }
+    $branches = $this->resolveBranches($baseBranch, $this->branches);
+    if ($this->fetch) {
+      $this->doFetchAll();
+    }
+
+    foreach ($this->repos as $repo => $relPath) {
+      $filteredBranch = $this->filterBranchName($repo, $branches[$repo]);
+      $this->run($relPath, 'git', 'checkout', $filteredBranch);
+    }
+    return TRUE;
+  }
+
+  function doStatusAll() {
+    foreach ($this->repos as $repo => $relPath) {
+      $this->run($relPath, 'git', 'status');
+    }
+    return TRUE;
+  }
+
+  function doBegin($baseBranch = NULL) {
+    if (!$baseBranch) {
+      return $this->returnError("Missing <base-branch>\n");
+    }
+    if (empty($this->branches)) {
+      return $this->returnError("Must specify a custom branch for at least one repository.\n");
+    }
+    $branches = $this->resolveBranches($baseBranch, $this->branches);
+    if ($this->fetch) {
+      $this->doFetchAll();
+    }
+
+    foreach ($this->repos as $repo => $relPath) {
+      $filteredBranch = $this->filterBranchName($repo, $branches[$repo]);
+      $filteredBaseBranch = $this->filterBranchName($repo, $baseBranch);
+      if ($filteredBranch == $filteredBaseBranch) {
+        $this->run($relPath, 'git', 'checkout', $filteredBranch);
+      }
+      else {
+        $this->run($relPath, 'git', 'checkout', '-b', $filteredBranch, $filteredBaseBranch);
+      }
+    }
+  }
+
+  function doResume($baseBranch = NULL) {
+    if (!$baseBranch) {
+      return $this->returnError("Missing <base-branch>\n");
+    }
+    if (empty($this->branches)) {
+      return $this->returnError("Must specify a custom branch for at least one repository.\n");
+    }
+    $branches = $this->resolveBranches($baseBranch, $this->branches);
+    if ($this->fetch) {
+      $this->doFetchAll();
+    }
+
+    foreach ($this->repos as $repo => $relPath) {
+      $this->run($relPath, 'git', 'checkout', $branches[$repo]);
+      if ($branches[$repo] != $baseBranch && $this->rebase) {
+        // FIXME: assumes
+        list ($baseRemoteRepo, $baseRemoteBranch) = $this->parseBranchRepo($baseBranch);
+        $this->run($relPath, 'git', 'pull', '--rebase', $baseRemoteRepo, $baseRemoteBranch);
+      }
+    }
+  }
+
+  /**
+   * Given a ref name, determine the repo and branch
+   *
+   * FIXME: only supports $refs like "foo" (implicit origin) or "myremote/foo"
+   *
+   * @param $ref
+   * @return array
+   */
+  function parseBranchRepo($ref, $defaultRemote = 'origin') {
+    $parts = explode('/', $ref);
+    if (count($parts) == 1) {
+      return array($defaultRemote, $parts[1]);
+    }
+    elseif (count($parts) == 2) {
+      return $parts;
+    }
+    else {
+      throw new Exception("Failed to parse branch name ($ref)");
+    }
+  }
+
+  /**
+   * Run a command
+   *
+   * Any items after $command will be escaped and added to $command
+   *
+   * @param string $runDir
+   * @param string $command
+   * @return string
+   */
+  function run($runDir, $command) {
+    $this->dirStack->push($runDir);
+
+    $args = func_get_args();
+    array_shift($args);
+    array_shift($args);
+    foreach ($args as $arg) {
+      $command .= ' ' . escapeshellarg($arg);
+    }
+    printf("\nRUN [%s]: %s\n", $runDir, $command);
+    if ($this->dryRun) {
+      $r = NULL;
+    } else {
+      $r = system($command);
+    }
+
+    $this->dirStack->pop();
+    return $r;
+  }
+
+  function doFetchAll() {
+    foreach ($this->repos as $repo => $relPath) {
+      $this->run($relPath, 'git', 'fetch', '--all');
+    }
+  }
+
+  /**
+   * @param string $default branch to use by default
+   * @return array ($repoName => $gitRef)
+   */
+  function resolveBranches($default, $overrides) {
+    $branches = $overrides;
+    foreach ($this->repos as $repo => $relPath) {
+      if (!isset($branches[$repo])) {
+        $branches[$repo] = $default;
+      }
+    }
+    return $branches;
+  }
+
+  function filterBranchName($repoName, $branchName) {
+    if ($repoName == 'drupal') {
+      $parts = explode('/', $branchName);
+      $last = $this->drupalVersion . '.x-' . array_pop($parts);
+      array_push($parts, $last);
+      return implode('/', $parts);
+    }
+    return $branchName;
+  }
+
+  function returnError($message) {
+    echo "ERROR: ", $message, "\n";
+    $this->doHelp();
+    return FALSE;
+  }
+}
+
+$givi = new Givi();
+$givi->main($argv);