givi - Explain workfow assumptions in help text
[civicrm-core.git] / bin / givi
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 */
10 class 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
30 class Givi {
31
32 /**
33 * @var string 'checkout', 'begin', 'help', etc
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
72 /**
73 * @var array ($repoName => $relPath)
74 */
75 protected $repos;
76
77 /**
78 * @var array, non-hyphenated arguments after the basedir
79 */
80 protected $arguments;
81
82 /**
83 * @var string, the name of this program
84 */
85 protected $program;
86
87 /**
88 * @var DirStack
89 */
90 protected $dirStack;
91
92 function __construct() {
93 $this->dirStack = new DirStack();
94 $this->repos = array(
95 'core' => '.',
96 'drupal' => 'drupal',
97 'joomla' => 'joomla',
98 'packages' => 'packages',
99 'wordpress' => 'WordPress',
100 );
101 }
102
103 function main($args) {
104 if (!$this->parseOptions($args)) {
105 printf("Error parsing arguments\n");
106 $this->doHelp();
107 return FALSE;
108 }
109
110 // All operations relative to civiRoot
111 $this->dirStack->push($this->civiRoot);
112
113 // Filter branch list based on what repos actually exist
114 foreach (array_keys($this->repos) as $repo) {
115 if (!is_dir($this->repos[$repo])) {
116 unset($this->repos[$repo]);
117 }
118 }
119 if (!isset($this->repos['core']) || !isset($this->repos['packages'])) {
120 return $this->returnError("Root appears to be invalid -- missing too many repos. Try --root=<dir>\n");
121 }
122
123 // Run the action
124 switch ($this->action) {
125 case 'checkout':
126 call_user_func_array(array($this, 'doCheckoutAll'), $this->arguments);
127 break;
128 case 'fetch':
129 call_user_func_array(array($this, 'doFetchAll'), $this->arguments);
130 break;
131 case 'status':
132 call_user_func_array(array($this, 'doStatusAll'), $this->arguments);
133 break;
134 case 'begin':
135 call_user_func_array(array($this, 'doBegin'), $this->arguments);
136 break;
137 case 'resume':
138 call_user_func_array(array($this, 'doResume'), $this->arguments);
139 break;
140 case 'help':
141 case '':
142 $this->doHelp();
143 break;
144 default:
145 return $this->returnError("unrecognized action: {$this->action}\n");
146 }
147
148 $this->dirStack->pop();
149 }
150
151 /**
152 * @param $args
153 * @return bool
154 */
155 function parseOptions($args) {
156 $this->branches = array();
157 $this->arguments = array();
158
159 foreach ($args as $arg) {
160 if ($arg == '--fetch') {
161 $this->fetch = TRUE;
162 }
163 elseif ($arg == '--rebase') {
164 $this->rebase = TRUE;
165 }
166 elseif ($arg == '--dry-run' || $arg == '-n') {
167 $this->dryRun = TRUE;
168 }
169 elseif (preg_match('/^--d([678])/', $arg, $matches)) {
170 $this->drupalVersion = $matches[1];
171 }
172 elseif (preg_match('/^--root=(.*)/', $arg, $matches)) {
173 $this->civiRoot = $matches[1];
174 }
175 elseif (preg_match('/^--(core|packages|joomla|drupal|wordpress)=(.*)/', $arg, $matches)) {
176 $this->branches[$matches[1]] = $matches[2];
177 }
178 elseif (preg_match('/^-/', $arg)) {
179 printf("unrecognized argument: %s\n", $arg);
180 return FALSE;
181 }
182 else {
183 $this->arguments[] = $arg;
184 }
185 }
186
187 $this->program = @array_shift($this->arguments);
188 $this->action = @array_shift($this->arguments);
189 return TRUE;
190 }
191
192 function doHelp() {
193 $program = basename($this->program);
194 echo "Givi - Coordinate git checkouts across CiviCRM repositories\n";
195 echo "Scenario:\n";
196 echo " You have cloned and forked the CiviCRM repos. Each of the repos has two\n";
197 echo " remotes (origin + upstream). When working on a new PR, you generally want\n";
198 echo " to checkout official code (eg upstream/master) in all repos, but 1-2 repos\n";
199 echo " should use a custom branch (which tracks upstream/master).\n";
200 echo "Usage:\n";
201 echo " $program [options] checkout <branch>\n";
202 echo " $program [options] fetch\n";
203 echo " $program [options] status\n";
204 echo " $program [options] begin <base-branch> [--core=<new-branch>|--drupal=<new-branch>|...] \n";
205 echo " $program [options] resume [--rebase] <base-branch> [--core=<custom-branch>|--drupal=<custom-branch>|...] \n";
206 echo "Actions:\n";
207 echo " checkout: Checkout same branch name on all repos\n";
208 echo " fetch: Fetch remote changes on all repos\n";
209 echo " status: Display status on all repos\n";
210 echo " begin: Begin work on a new branch on some repo (and use base-branch for all others)\n";
211 echo " resume: Resume work on an existing branch on some repo (and use base-branch for all others)\n";
212 echo "Common options:\n";
213 echo " --dry-run: Don't do anything; only print commands that would be run\n";
214 echo " --d6: Specify that Drupal branches should use 6.x-* prefixes\n";
215 echo " --d7: Specify that Drupal branches should use 7.x-* prefixes (default)\n";
216 echo " --fetch: Fetch the latest code before creating, updating, or checking-out anything\n";
217 echo " --root=X: Specify CiviCRM root directory (default: .)\n";
218 echo "Special options:\n";
219 echo " --core=X: Specify the branch to use on the core repository\n";
220 echo " --packages=X: Specify the branch to use on the packages repository\n";
221 echo " --drupal=X: Specify the branch to use on the drupal repository\n";
222 echo " --joomla=X: Specify the branch to use on the joomla repository\n";
223 echo " --wordpress=X: Specify the branch to use on the wordpress repository\n";
224 echo " --rebase: Perform a rebase before starting work\n";
225 echo "Known repositories:\n";
226 foreach ($this->repos as $repo => $relPath) {
227 printf(" %-12s: %s\n", $repo, realpath($this->civiRoot . DIRECTORY_SEPARATOR . $relPath));
228 }
229 echo "When using 'begin' or 'resume' with a remote base-branch, most repositories\n";
230 echo "will have a detached HEAD. Only repos with an explicit branch will be real,\n";
231 echo "local branches.\n";
232 }
233
234 function doCheckoutAll($baseBranch = NULL) {
235 if (!$baseBranch) {
236 return $this->returnError("Missing <branch>\n");
237 }
238 $branches = $this->resolveBranches($baseBranch, $this->branches);
239 if ($this->fetch) {
240 $this->doFetchAll();
241 }
242
243 foreach ($this->repos as $repo => $relPath) {
244 $filteredBranch = $this->filterBranchName($repo, $branches[$repo]);
245 $this->run($relPath, 'git', 'checkout', $filteredBranch);
246 }
247 return TRUE;
248 }
249
250 function doStatusAll() {
251 foreach ($this->repos as $repo => $relPath) {
252 $this->run($relPath, 'git', 'status');
253 }
254 return TRUE;
255 }
256
257 function doBegin($baseBranch = NULL) {
258 if (!$baseBranch) {
259 return $this->returnError("Missing <base-branch>\n");
260 }
261 if (empty($this->branches)) {
262 return $this->returnError("Must specify a custom branch for at least one repository.\n");
263 }
264 $branches = $this->resolveBranches($baseBranch, $this->branches);
265 if ($this->fetch) {
266 $this->doFetchAll();
267 }
268
269 foreach ($this->repos as $repo => $relPath) {
270 $filteredBranch = $this->filterBranchName($repo, $branches[$repo]);
271 $filteredBaseBranch = $this->filterBranchName($repo, $baseBranch);
272
273 if ($filteredBranch == $filteredBaseBranch) {
274 $this->run($relPath, 'git', 'checkout', $filteredBranch);
275 }
276 else {
277 $this->run($relPath, 'git', 'checkout', '-b', $filteredBranch, $filteredBaseBranch);
278 }
279 }
280 }
281
282 function doResume($baseBranch = NULL) {
283 if (!$baseBranch) {
284 return $this->returnError("Missing <base-branch>\n");
285 }
286 if (empty($this->branches)) {
287 return $this->returnError("Must specify a custom branch for at least one repository.\n");
288 }
289 $branches = $this->resolveBranches($baseBranch, $this->branches);
290 if ($this->fetch) {
291 $this->doFetchAll();
292 }
293
294 foreach ($this->repos as $repo => $relPath) {
295 $filteredBranch = $this->filterBranchName($repo, $branches[$repo]);
296 $filteredBaseBranch = $this->filterBranchName($repo, $baseBranch);
297
298 $this->run($relPath, 'git', 'checkout', $filteredBranch);
299 if ($filteredBranch != $filteredBaseBranch && $this->rebase) {
300 list ($baseRemoteRepo, $baseRemoteBranch) = $this->parseBranchRepo($filteredBaseBranch);
301 $this->run($relPath, 'git', 'pull', '--rebase', $baseRemoteRepo, $baseRemoteBranch);
302 }
303 }
304 }
305
306 /**
307 * Given a ref name, determine the repo and branch
308 *
309 * FIXME: only supports $refs like "foo" (implicit origin) or "myremote/foo"
310 *
311 * @param $ref
312 * @return array
313 */
314 function parseBranchRepo($ref, $defaultRemote = 'origin') {
315 $parts = explode('/', $ref);
316 if (count($parts) == 1) {
317 return array($defaultRemote, $parts[1]);
318 }
319 elseif (count($parts) == 2) {
320 return $parts;
321 }
322 else {
323 throw new Exception("Failed to parse branch name ($ref)");
324 }
325 }
326
327 /**
328 * Run a command
329 *
330 * Any items after $command will be escaped and added to $command
331 *
332 * @param string $runDir
333 * @param string $command
334 * @return string
335 */
336 function run($runDir, $command) {
337 $this->dirStack->push($runDir);
338
339 $args = func_get_args();
340 array_shift($args);
341 array_shift($args);
342 foreach ($args as $arg) {
343 $command .= ' ' . escapeshellarg($arg);
344 }
345 printf("\nRUN [%s]: %s\n", $runDir, $command);
346 if ($this->dryRun) {
347 $r = NULL;
348 } else {
349 $r = system($command);
350 }
351
352 $this->dirStack->pop();
353 return $r;
354 }
355
356 function doFetchAll() {
357 foreach ($this->repos as $repo => $relPath) {
358 $this->run($relPath, 'git', 'fetch', '--all');
359 }
360 }
361
362 /**
363 * @param string $default branch to use by default
364 * @return array ($repoName => $gitRef)
365 */
366 function resolveBranches($default, $overrides) {
367 $branches = $overrides;
368 foreach ($this->repos as $repo => $relPath) {
369 if (!isset($branches[$repo])) {
370 $branches[$repo] = $default;
371 }
372 }
373 return $branches;
374 }
375
376 function filterBranchName($repoName, $branchName) {
377 if ($repoName == 'drupal') {
378 $parts = explode('/', $branchName);
379 $last = $this->drupalVersion . '.x-' . array_pop($parts);
380 array_push($parts, $last);
381 return implode('/', $parts);
382 }
383 return $branchName;
384 }
385
386 function returnError($message) {
387 echo "ERROR: ", $message, "\n";
388 $this->doHelp();
389 return FALSE;
390 }
391 }
392
393 $givi = new Givi();
394 $givi->main($argv);