givi - Misc improvements:
[civicrm-core.git] / bin / givi
CommitLineData
6130de47
TO
1#!/usr/bin/env php
2<?php
3
4// This is the minimalist denialist implementation that doesn't check it's
5// pre-conditions and will screw up if you don't know what you're doing.
6
7/**
8 * Manage the current working directory as a stack.
9 */
10class DirStack {
11 protected $dirs;
12
13 function __construct($dirs = array()) {
14 $this->dirs = $dirs;
15 }
16
17 function push($dir) {
18 $this->dirs[] = getcwd();
19 if (!chdir($dir)) {
20 throw new Exception("Failed to chdir($dir)");
21 }
22 }
23
24 function pop() {
25 $oldDir = array_pop($this->dirs);
26 chdir($oldDir);
27 }
28}
29
30class Givi {
31
32 /**
749e432e 33 * @var string 'checkout', 'begin', 'help', etc
6130de47
TO
34 */
35 protected $action;
36
37 /**
38 * @var string
39 */
40 protected $baseBranch;
41
42 /**
43 * @var array ($repoName => $gitRef)
44 */
45 protected $branches;
46
47 /**
48 * @var string
49 */
50 protected $civiRoot = '.';
51
52 /**
53 * @var int
54 */
55 protected $drupalVersion = 7;
56
57 /**
58 * @var bool
59 */
60 protected $dryRun = FALSE;
61
62 /**
63 * @var bool
64 */
65 protected $fetch = FALSE;
66
67 /**
68 * @var bool
69 */
70 protected $rebase = FALSE;
71
8d64bbe0
TO
72 /**
73 * @var string, the word 'all' or comma-delimited list of repo names
74 */
75 protected $repoFilter = 'all';
76
6130de47
TO
77 /**
78 * @var array ($repoName => $relPath)
79 */
80 protected $repos;
81
82 /**
83 * @var array, non-hyphenated arguments after the basedir
84 */
85 protected $arguments;
86
87 /**
88 * @var string, the name of this program
89 */
90 protected $program;
91
92 /**
93 * @var DirStack
94 */
95 protected $dirStack;
96
97 function __construct() {
98 $this->dirStack = new DirStack();
99 $this->repos = array(
100 'core' => '.',
6130de47 101 'drupal' => 'drupal',
4842290b
TO
102 'joomla' => 'joomla',
103 'packages' => 'packages',
6130de47
TO
104 'wordpress' => 'WordPress',
105 );
106 }
107
108 function main($args) {
109 if (!$this->parseOptions($args)) {
110 printf("Error parsing arguments\n");
111 $this->doHelp();
112 return FALSE;
113 }
114
115 // All operations relative to civiRoot
116 $this->dirStack->push($this->civiRoot);
117
118 // Filter branch list based on what repos actually exist
119 foreach (array_keys($this->repos) as $repo) {
120 if (!is_dir($this->repos[$repo])) {
121 unset($this->repos[$repo]);
122 }
123 }
124 if (!isset($this->repos['core']) || !isset($this->repos['packages'])) {
125 return $this->returnError("Root appears to be invalid -- missing too many repos. Try --root=<dir>\n");
126 }
127
8d64bbe0
TO
128 $this->repos = $this->filterRepos($this->repoFilter, $this->repos);
129
6130de47
TO
130 // Run the action
131 switch ($this->action) {
749e432e 132 case 'checkout':
6130de47
TO
133 call_user_func_array(array($this, 'doCheckoutAll'), $this->arguments);
134 break;
749e432e 135 case 'fetch':
6130de47
TO
136 call_user_func_array(array($this, 'doFetchAll'), $this->arguments);
137 break;
749e432e 138 case 'status':
6130de47
TO
139 call_user_func_array(array($this, 'doStatusAll'), $this->arguments);
140 break;
141 case 'begin':
142 call_user_func_array(array($this, 'doBegin'), $this->arguments);
143 break;
144 case 'resume':
145 call_user_func_array(array($this, 'doResume'), $this->arguments);
146 break;
8d64bbe0
TO
147 //case 'merge-forward':
148 // call_user_func_array(array($this, 'doMergeForward'), $this->arguments);
149 // break;
150 case 'push':
151 call_user_func_array(array($this, 'doPush'), $this->arguments);
152 break;
6130de47 153 case 'help':
03da1773 154 case '':
6130de47
TO
155 $this->doHelp();
156 break;
157 default:
158 return $this->returnError("unrecognized action: {$this->action}\n");
159 }
160
161 $this->dirStack->pop();
162 }
163
164 /**
165 * @param $args
166 * @return bool
167 */
168 function parseOptions($args) {
169 $this->branches = array();
170 $this->arguments = array();
171
172 foreach ($args as $arg) {
173 if ($arg == '--fetch') {
174 $this->fetch = TRUE;
175 }
176 elseif ($arg == '--rebase') {
177 $this->rebase = TRUE;
178 }
179 elseif ($arg == '--dry-run' || $arg == '-n') {
180 $this->dryRun = TRUE;
181 }
182 elseif (preg_match('/^--d([678])/', $arg, $matches)) {
183 $this->drupalVersion = $matches[1];
184 }
185 elseif (preg_match('/^--root=(.*)/', $arg, $matches)) {
186 $this->civiRoot = $matches[1];
187 }
8d64bbe0
TO
188 elseif (preg_match('/^--repos=(.*)/', $arg, $matches)) {
189 $this->repoFilter = $matches[1];
190 }
6130de47
TO
191 elseif (preg_match('/^--(core|packages|joomla|drupal|wordpress)=(.*)/', $arg, $matches)) {
192 $this->branches[$matches[1]] = $matches[2];
193 }
194 elseif (preg_match('/^-/', $arg)) {
195 printf("unrecognized argument: %s\n", $arg);
196 return FALSE;
197 }
198 else {
199 $this->arguments[] = $arg;
200 }
201 }
202
203 $this->program = @array_shift($this->arguments);
204 $this->action = @array_shift($this->arguments);
205 return TRUE;
206 }
207
208 function doHelp() {
209 $program = basename($this->program);
210 echo "Givi - Coordinate git checkouts across CiviCRM repositories\n";
729ccb4d
TO
211 echo "Scenario:\n";
212 echo " You have cloned and forked the CiviCRM repos. Each of the repos has two\n";
213 echo " remotes (origin + upstream). When working on a new PR, you generally want\n";
214 echo " to checkout official code (eg upstream/master) in all repos, but 1-2 repos\n";
215 echo " should use a custom branch (which tracks upstream/master).\n";
6130de47 216 echo "Usage:\n";
749e432e
TO
217 echo " $program [options] checkout <branch>\n";
218 echo " $program [options] fetch\n";
219 echo " $program [options] status\n";
6130de47
TO
220 echo " $program [options] begin <base-branch> [--core=<new-branch>|--drupal=<new-branch>|...] \n";
221 echo " $program [options] resume [--rebase] <base-branch> [--core=<custom-branch>|--drupal=<custom-branch>|...] \n";
8d64bbe0
TO
222 #echo " $program [options] merge-forward <maintenace-branch> <development-branch>\n";
223 #echo " $program [options] push <remote> <branch>[:<branch>]\n";
6130de47 224 echo "Actions:\n";
749e432e
TO
225 echo " checkout: Checkout same branch name on all repos\n";
226 echo " fetch: Fetch remote changes on all repos\n";
227 echo " status: Display status on all repos\n";
6130de47
TO
228 echo " begin: Begin work on a new branch on some repo (and use base-branch for all others)\n";
229 echo " resume: Resume work on an existing branch on some repo (and use base-branch for all others)\n";
8d64bbe0
TO
230 #echo " merge-forward: On each repo, merge changes from maintenance branch to development branch\n";
231 #echo " push: On each repo, push a branch to a remote (Note: only intended for use with merge-forward)\n";
6130de47 232 echo "Common options:\n";
749e432e 233 echo " --dry-run: Don't do anything; only print commands that would be run\n";
6130de47
TO
234 echo " --d6: Specify that Drupal branches should use 6.x-* prefixes\n";
235 echo " --d7: Specify that Drupal branches should use 7.x-* prefixes (default)\n";
236 echo " --fetch: Fetch the latest code before creating, updating, or checking-out anything\n";
8d64bbe0 237 echo " --repos=X: Restrict operations to the listed repos (comma-delimited list) (default: all)";
6130de47
TO
238 echo " --root=X: Specify CiviCRM root directory (default: .)\n";
239 echo "Special options:\n";
240 echo " --core=X: Specify the branch to use on the core repository\n";
241 echo " --packages=X: Specify the branch to use on the packages repository\n";
242 echo " --drupal=X: Specify the branch to use on the drupal repository\n";
243 echo " --joomla=X: Specify the branch to use on the joomla repository\n";
244 echo " --wordpress=X: Specify the branch to use on the wordpress repository\n";
245 echo " --rebase: Perform a rebase before starting work\n";
ab79d84c
TO
246 echo "Known repositories:\n";
247 foreach ($this->repos as $repo => $relPath) {
248 printf(" %-12s: %s\n", $repo, realpath($this->civiRoot . DIRECTORY_SEPARATOR . $relPath));
249 }
6130de47
TO
250 echo "When using 'begin' or 'resume' with a remote base-branch, most repositories\n";
251 echo "will have a detached HEAD. Only repos with an explicit branch will be real,\n";
252 echo "local branches.\n";
253 }
254
255 function doCheckoutAll($baseBranch = NULL) {
256 if (!$baseBranch) {
257 return $this->returnError("Missing <branch>\n");
258 }
259 $branches = $this->resolveBranches($baseBranch, $this->branches);
260 if ($this->fetch) {
261 $this->doFetchAll();
262 }
263
264 foreach ($this->repos as $repo => $relPath) {
265 $filteredBranch = $this->filterBranchName($repo, $branches[$repo]);
8d64bbe0 266 $this->run($repo, $relPath, 'git', 'checkout', $filteredBranch);
6130de47
TO
267 }
268 return TRUE;
269 }
270
271 function doStatusAll() {
272 foreach ($this->repos as $repo => $relPath) {
8d64bbe0 273 $this->run($repo, $relPath, 'git', 'status');
6130de47
TO
274 }
275 return TRUE;
276 }
277
278 function doBegin($baseBranch = NULL) {
279 if (!$baseBranch) {
280 return $this->returnError("Missing <base-branch>\n");
281 }
282 if (empty($this->branches)) {
283 return $this->returnError("Must specify a custom branch for at least one repository.\n");
284 }
285 $branches = $this->resolveBranches($baseBranch, $this->branches);
286 if ($this->fetch) {
287 $this->doFetchAll();
288 }
289
290 foreach ($this->repos as $repo => $relPath) {
291 $filteredBranch = $this->filterBranchName($repo, $branches[$repo]);
292 $filteredBaseBranch = $this->filterBranchName($repo, $baseBranch);
9476bf6f 293
6130de47 294 if ($filteredBranch == $filteredBaseBranch) {
8d64bbe0 295 $this->run($repo, $relPath, 'git', 'checkout', $filteredBranch);
6130de47
TO
296 }
297 else {
8d64bbe0 298 $this->run($repo, $relPath, 'git', 'checkout', '-b', $filteredBranch, $filteredBaseBranch);
6130de47
TO
299 }
300 }
301 }
302
303 function doResume($baseBranch = NULL) {
304 if (!$baseBranch) {
305 return $this->returnError("Missing <base-branch>\n");
306 }
307 if (empty($this->branches)) {
308 return $this->returnError("Must specify a custom branch for at least one repository.\n");
309 }
310 $branches = $this->resolveBranches($baseBranch, $this->branches);
311 if ($this->fetch) {
312 $this->doFetchAll();
313 }
314
315 foreach ($this->repos as $repo => $relPath) {
9476bf6f
TO
316 $filteredBranch = $this->filterBranchName($repo, $branches[$repo]);
317 $filteredBaseBranch = $this->filterBranchName($repo, $baseBranch);
318
8d64bbe0 319 $this->run($repo, $relPath, 'git', 'checkout', $filteredBranch);
9476bf6f
TO
320 if ($filteredBranch != $filteredBaseBranch && $this->rebase) {
321 list ($baseRemoteRepo, $baseRemoteBranch) = $this->parseBranchRepo($filteredBaseBranch);
8d64bbe0 322 $this->run($repo, $relPath, 'git', 'pull', '--rebase', $baseRemoteRepo, $baseRemoteBranch);
6130de47
TO
323 }
324 }
325 }
326
8d64bbe0
TO
327 /*
328
329 If we want merge-forward changes to be subject to PR process, then this
330 should useful. Currently using a simpler process based on
331 toosl/scripts/merge-forward
332
333 function doMergeForward($maintBranch, $devBranch) {
334 if (!$maintBranch) {
335 return $this->returnError("Missing <maintenace-base-branch>\n");
336 }
337 if (!$devBranch) {
338 return $this->returnError("Missing <development-base-branch>\n");
339 }
340 list ($maintBranchRepo, $maintBranchName) = $this->parseBranchRepo($maintBranch);
341 list ($devBranchRepo, $devBranchName) = $this->parseBranchRepo($devBranch);
342
343 $newBranchRepo = $devBranchRepo;
344 $newBranchName = $maintBranchName . '-' . $devBranchName . '-' . date('Y-m-d-H-i-s');
345
346 if ($this->fetch) {
347 $this->doFetchAll();
348 }
349
350 foreach ($this->repos as $repo => $relPath) {
351 $filteredMaintBranch = $this->filterBranchName($repo, $maintBranch);
352 $filteredDevBranch = $this->filterBranchName($repo, $devBranch);
353 $filteredNewBranchName = $this->filterBranchName($repo, $newBranchName);
354
355 $this->run($repo, $relPath, 'git', 'checkout', '-b', $filteredNewBranchName, $filteredDevBranch);
356 $this->run($repo, $relPath, 'git', 'merge', $filteredMaintBranch);
357 }
358 }
359 */
360
361 function doPush($newBranchRepo, $newBranchNames) {
362 if (!$newBranchRepo) {
363 return $this->returnError("Missing <remote>\n");
364 }
365 if (!$newBranchNames) {
366 return $this->returnError("Missing <branch>[:<branch>]\n");
367 }
368 if (FALSE !== strpos($newBranchNames, ':')) {
369 list ($newBranchFromName,$newBranchToName) = explode(':', $newBranchNames);
370 foreach ($this->repos as $repo => $relPath) {
371 $filteredFromName = $this->filterBranchName($repo, $newBranchFromName);
372 $filteredToName = $this->filterBranchName($repo, $newBranchToName);
373
374 $this->run($repo, $relPath, 'git', 'push', $newBranchRepo, $filteredFromName . ':' . $filteredToName);
375 }
376 } else {
377 foreach ($this->repos as $repo => $relPath) {
378 $filteredName = $this->filterBranchName($repo, $newBranchNames);
379 $this->run($repo, $relPath, 'git', 'push', $newBranchRepo, $filteredName);
380 }
381 }
382
383 }
384
6130de47
TO
385 /**
386 * Given a ref name, determine the repo and branch
387 *
388 * FIXME: only supports $refs like "foo" (implicit origin) or "myremote/foo"
389 *
390 * @param $ref
391 * @return array
392 */
393 function parseBranchRepo($ref, $defaultRemote = 'origin') {
394 $parts = explode('/', $ref);
395 if (count($parts) == 1) {
396 return array($defaultRemote, $parts[1]);
397 }
398 elseif (count($parts) == 2) {
399 return $parts;
400 }
401 else {
402 throw new Exception("Failed to parse branch name ($ref)");
403 }
404 }
405
406 /**
407 * Run a command
408 *
409 * Any items after $command will be escaped and added to $command
410 *
411 * @param string $runDir
412 * @param string $command
413 * @return string
414 */
8d64bbe0 415 function run($repoName, $runDir, $command) {
6130de47
TO
416 $this->dirStack->push($runDir);
417
418 $args = func_get_args();
419 array_shift($args);
420 array_shift($args);
8d64bbe0 421 array_shift($args);
6130de47
TO
422 foreach ($args as $arg) {
423 $command .= ' ' . escapeshellarg($arg);
424 }
8d64bbe0 425 printf("\n\n\nRUN [%s]: %s\n", $repoName, $command);
6130de47
TO
426 if ($this->dryRun) {
427 $r = NULL;
428 } else {
429 $r = system($command);
430 }
431
432 $this->dirStack->pop();
433 return $r;
434 }
435
436 function doFetchAll() {
437 foreach ($this->repos as $repo => $relPath) {
8d64bbe0 438 $this->run($repo, $relPath, 'git', 'fetch', '--all');
6130de47
TO
439 }
440 }
441
442 /**
443 * @param string $default branch to use by default
444 * @return array ($repoName => $gitRef)
445 */
446 function resolveBranches($default, $overrides) {
447 $branches = $overrides;
448 foreach ($this->repos as $repo => $relPath) {
449 if (!isset($branches[$repo])) {
450 $branches[$repo] = $default;
451 }
452 }
453 return $branches;
454 }
455
456 function filterBranchName($repoName, $branchName) {
8d64bbe0
TO
457 if ($branchName == '') {
458 return '';
459 }
6130de47
TO
460 if ($repoName == 'drupal') {
461 $parts = explode('/', $branchName);
462 $last = $this->drupalVersion . '.x-' . array_pop($parts);
463 array_push($parts, $last);
464 return implode('/', $parts);
465 }
466 return $branchName;
467 }
468
8d64bbe0
TO
469 /**
470 * @param string $filter e.g. "all" or "repo1,repo2"
471 * @param array $repos ($repoName => $repoDir)
472 * @return array ($repoName => $repoDir)
473 */
474 function filterRepos($filter, $repos) {
475 if ($filter == 'all') {
476 return $repos;
477 }
478
479 $inclRepos = explode(',', $filter);
480 $unknowns = array_diff($inclRepos, array_keys($repos));
481 if (!empty($unknowns)) {
482 throw new Exception("Unknown Repos: " . implode(',', $unknowns));
483 }
484 $unwanted = array_diff(array_keys($repos), $inclRepos);
485 foreach ($unwanted as $repo) {
486 unset($repos[$repo]);
487 }
488 return $repos;
489 }
490
6130de47
TO
491 function returnError($message) {
492 echo "ERROR: ", $message, "\n";
493 $this->doHelp();
494 return FALSE;
495 }
496}
497
498$givi = new Givi();
499$givi->main($argv);