blob: 0492a4657a65f548c9e21f88ccb67f3c9acabb6f [file] [log] [blame]
agable@chromium.org5a306a22014-02-24 22:13:59 +00001#!/usr/bin/env python
2# Copyright 2014 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""A git command for managing a local cache of git repositories."""
7
szager@chromium.org848fd492014-04-09 19:06:44 +00008from __future__ import print_function
agable@chromium.org5a306a22014-02-24 22:13:59 +00009import errno
10import logging
11import optparse
12import os
szager@chromium.org174766f2014-05-13 21:27:46 +000013import re
agable@chromium.org5a306a22014-02-24 22:13:59 +000014import tempfile
pgervais@chromium.orgf3726102014-04-17 17:24:15 +000015import time
agable@chromium.org5a306a22014-02-24 22:13:59 +000016import subprocess
17import sys
18import urlparse
hinoka@google.com776a2c32014-04-25 07:54:25 +000019import zipfile
agable@chromium.org5a306a22014-02-24 22:13:59 +000020
hinoka@google.com563559c2014-04-02 00:36:24 +000021from download_from_google_storage import Gsutil
agable@chromium.org5a306a22014-02-24 22:13:59 +000022import gclient_utils
23import subcommand
24
szager@chromium.org848fd492014-04-09 19:06:44 +000025try:
26 # pylint: disable=E0602
27 WinErr = WindowsError
28except NameError:
29 class WinErr(Exception):
30 pass
agable@chromium.org5a306a22014-02-24 22:13:59 +000031
32class LockError(Exception):
33 pass
34
35
36class Lockfile(object):
37 """Class to represent a cross-platform process-specific lockfile."""
38
39 def __init__(self, path):
40 self.path = os.path.abspath(path)
41 self.lockfile = self.path + ".lock"
42 self.pid = os.getpid()
43
44 def _read_pid(self):
45 """Read the pid stored in the lockfile.
46
47 Note: This method is potentially racy. By the time it returns the lockfile
48 may have been unlocked, removed, or stolen by some other process.
49 """
50 try:
51 with open(self.lockfile, 'r') as f:
52 pid = int(f.readline().strip())
53 except (IOError, ValueError):
54 pid = None
55 return pid
56
57 def _make_lockfile(self):
58 """Safely creates a lockfile containing the current pid."""
59 open_flags = (os.O_CREAT | os.O_EXCL | os.O_WRONLY)
60 fd = os.open(self.lockfile, open_flags, 0o644)
61 f = os.fdopen(fd, 'w')
szager@chromium.org848fd492014-04-09 19:06:44 +000062 print(self.pid, file=f)
agable@chromium.org5a306a22014-02-24 22:13:59 +000063 f.close()
64
65 def _remove_lockfile(self):
pgervais@chromium.orgf3726102014-04-17 17:24:15 +000066 """Delete the lockfile. Complains (implicitly) if it doesn't exist.
67
68 See gclient_utils.py:rmtree docstring for more explanation on the
69 windows case.
70 """
71 if sys.platform == 'win32':
72 lockfile = os.path.normcase(self.lockfile)
73 for _ in xrange(3):
74 exitcode = subprocess.call(['cmd.exe', '/c',
75 'del', '/f', '/q', lockfile])
76 if exitcode == 0:
77 return
78 time.sleep(3)
79 raise LockError('Failed to remove lock: %s' % lockfile)
80 else:
81 os.remove(self.lockfile)
agable@chromium.org5a306a22014-02-24 22:13:59 +000082
83 def lock(self):
84 """Acquire the lock.
85
86 Note: This is a NON-BLOCKING FAIL-FAST operation.
87 Do. Or do not. There is no try.
88 """
89 try:
90 self._make_lockfile()
91 except OSError as e:
92 if e.errno == errno.EEXIST:
93 raise LockError("%s is already locked" % self.path)
94 else:
95 raise LockError("Failed to create %s (err %s)" % (self.path, e.errno))
96
97 def unlock(self):
98 """Release the lock."""
99 if not self.is_locked():
100 raise LockError("%s is not locked" % self.path)
101 if not self.i_am_locking():
102 raise LockError("%s is locked, but not by me" % self.path)
103 self._remove_lockfile()
104
105 def break_lock(self):
106 """Remove the lock, even if it was created by someone else."""
107 try:
108 self._remove_lockfile()
109 return True
110 except OSError as exc:
111 if exc.errno == errno.ENOENT:
112 return False
113 else:
114 raise
115
116 def is_locked(self):
117 """Test if the file is locked by anyone.
118
119 Note: This method is potentially racy. By the time it returns the lockfile
120 may have been unlocked, removed, or stolen by some other process.
121 """
122 return os.path.exists(self.lockfile)
123
124 def i_am_locking(self):
125 """Test if the file is locked by this process."""
126 return self.is_locked() and self.pid == self._read_pid()
127
128 def __enter__(self):
129 self.lock()
130 return self
131
132 def __exit__(self, *_exc):
szager@chromium.org848fd492014-04-09 19:06:44 +0000133 # Windows is unreliable when it comes to file locking. YMMV.
134 try:
135 self.unlock()
136 except WinErr:
137 pass
agable@chromium.org5a306a22014-02-24 22:13:59 +0000138
139
szager@chromium.org848fd492014-04-09 19:06:44 +0000140class Mirror(object):
141
142 git_exe = 'git.bat' if sys.platform.startswith('win') else 'git'
143 gsutil_exe = os.path.join(
144 os.path.dirname(os.path.abspath(__file__)),
145 'third_party', 'gsutil', 'gsutil')
szager@chromium.org848fd492014-04-09 19:06:44 +0000146
147 def __init__(self, url, refs=None, print_func=None):
148 self.url = url
149 self.refs = refs or []
150 self.basedir = self.UrlToCacheDir(url)
151 self.mirror_path = os.path.join(self.GetCachePath(), self.basedir)
152 self.print = print_func or print
153
hinoka@chromium.orgf8fa23d2014-06-05 01:00:04 +0000154 @property
155 def bootstrap_bucket(self):
156 if 'chrome-internal' in self.url:
157 return 'chrome-git-cache'
158 else:
159 return 'chromium-git-cache'
160
szager@chromium.org174766f2014-05-13 21:27:46 +0000161 @classmethod
162 def FromPath(cls, path):
163 return cls(cls.CacheDirToUrl(path))
164
szager@chromium.org848fd492014-04-09 19:06:44 +0000165 @staticmethod
166 def UrlToCacheDir(url):
167 """Convert a git url to a normalized form for the cache dir path."""
168 parsed = urlparse.urlparse(url)
169 norm_url = parsed.netloc + parsed.path
170 if norm_url.endswith('.git'):
171 norm_url = norm_url[:-len('.git')]
172 return norm_url.replace('-', '--').replace('/', '-').lower()
173
174 @staticmethod
szager@chromium.org174766f2014-05-13 21:27:46 +0000175 def CacheDirToUrl(path):
176 """Convert a cache dir path to its corresponding url."""
177 netpath = re.sub(r'\b-\b', '/', os.path.basename(path)).replace('--', '-')
178 return 'https://%s' % netpath
179
180 @staticmethod
szager@chromium.org848fd492014-04-09 19:06:44 +0000181 def FindExecutable(executable):
182 """This mimics the "which" utility."""
183 path_folders = os.environ.get('PATH').split(os.pathsep)
184
185 for path_folder in path_folders:
186 target = os.path.join(path_folder, executable)
187 # Just incase we have some ~/blah paths.
188 target = os.path.abspath(os.path.expanduser(target))
189 if os.path.isfile(target) and os.access(target, os.X_OK):
190 return target
szager@chromium.org6b5faf52014-04-09 21:54:21 +0000191 if sys.platform.startswith('win'):
192 for suffix in ('.bat', '.cmd', '.exe'):
193 alt_target = target + suffix
szager@chromium.org4039b312014-04-09 21:56:46 +0000194 if os.path.isfile(alt_target) and os.access(alt_target, os.X_OK):
szager@chromium.org6b5faf52014-04-09 21:54:21 +0000195 return alt_target
szager@chromium.org848fd492014-04-09 19:06:44 +0000196 return None
197
198 @classmethod
199 def SetCachePath(cls, cachepath):
200 setattr(cls, 'cachepath', cachepath)
201
202 @classmethod
203 def GetCachePath(cls):
204 if not hasattr(cls, 'cachepath'):
205 try:
206 cachepath = subprocess.check_output(
207 [cls.git_exe, 'config', '--global', 'cache.cachepath']).strip()
208 except subprocess.CalledProcessError:
209 cachepath = None
210 if not cachepath:
211 raise RuntimeError('No global cache.cachepath git configuration found.')
212 setattr(cls, 'cachepath', cachepath)
213 return getattr(cls, 'cachepath')
214
215 def RunGit(self, cmd, **kwargs):
216 """Run git in a subprocess."""
217 cwd = kwargs.setdefault('cwd', self.mirror_path)
218 kwargs.setdefault('print_stdout', False)
219 kwargs.setdefault('filter_fn', self.print)
220 env = kwargs.get('env') or kwargs.setdefault('env', os.environ.copy())
221 env.setdefault('GIT_ASKPASS', 'true')
222 env.setdefault('SSH_ASKPASS', 'true')
223 self.print('running "git %s" in "%s"' % (' '.join(cmd), cwd))
224 gclient_utils.CheckCallAndFilter([self.git_exe] + cmd, **kwargs)
225
226 def config(self, cwd=None):
227 if cwd is None:
228 cwd = self.mirror_path
229 self.RunGit(['config', 'core.deltaBaseCacheLimit',
230 gclient_utils.DefaultDeltaBaseCacheLimit()], cwd=cwd)
231 self.RunGit(['config', 'remote.origin.url', self.url], cwd=cwd)
232 self.RunGit(['config', '--replace-all', 'remote.origin.fetch',
233 '+refs/heads/*:refs/heads/*'], cwd=cwd)
234 for ref in self.refs:
235 ref = ref.lstrip('+').rstrip('/')
236 if ref.startswith('refs/'):
237 refspec = '+%s:%s' % (ref, ref)
238 else:
239 refspec = '+refs/%s/*:refs/%s/*' % (ref, ref)
240 self.RunGit(['config', '--add', 'remote.origin.fetch', refspec], cwd=cwd)
241
242 def bootstrap_repo(self, directory):
hinoka@google.com776a2c32014-04-25 07:54:25 +0000243 """Bootstrap the repo from Google Stroage if possible."""
szager@chromium.org848fd492014-04-09 19:06:44 +0000244
hinoka@google.com776a2c32014-04-25 07:54:25 +0000245 python_fallback = False
246 if sys.platform.startswith('win') and not self.FindExecutable('7z'):
247 python_fallback = True
248 elif sys.platform.startswith('darwin'):
249 # The OSX version of unzip doesn't support zip64.
250 python_fallback = True
251 elif not self.FindExecutable('unzip'):
252 python_fallback = True
szager@chromium.org848fd492014-04-09 19:06:44 +0000253
254 gs_folder = 'gs://%s/%s' % (self.bootstrap_bucket, self.basedir)
255 gsutil = Gsutil(
256 self.gsutil_exe, boto_path=os.devnull, bypass_prodaccess=True)
257 # Get the most recent version of the zipfile.
258 _, ls_out, _ = gsutil.check_call('ls', gs_folder)
259 ls_out_sorted = sorted(ls_out.splitlines())
260 if not ls_out_sorted:
261 # This repo is not on Google Storage.
262 return False
263 latest_checkout = ls_out_sorted[-1]
264
265 # Download zip file to a temporary directory.
266 try:
267 tempdir = tempfile.mkdtemp()
268 self.print('Downloading %s' % latest_checkout)
269 code, out, err = gsutil.check_call('cp', latest_checkout, tempdir)
270 if code:
271 self.print('%s\n%s' % (out, err))
272 return False
273 filename = os.path.join(tempdir, latest_checkout.split('/')[-1])
274
hinoka@google.com776a2c32014-04-25 07:54:25 +0000275 # Unpack the file with 7z on Windows, unzip on linux, or fallback.
276 if not python_fallback:
277 if sys.platform.startswith('win'):
278 cmd = ['7z', 'x', '-o%s' % directory, '-tzip', filename]
279 else:
280 cmd = ['unzip', filename, '-d', directory]
281 retcode = subprocess.call(cmd)
szager@chromium.org848fd492014-04-09 19:06:44 +0000282 else:
hinoka@google.com776a2c32014-04-25 07:54:25 +0000283 try:
284 with zipfile.ZipFile(filename, 'r') as f:
285 f.printdir()
286 f.extractall(directory)
287 except Exception as e:
288 self.print('Encountered error: %s' % str(e), file=sys.stderr)
289 retcode = 1
290 else:
291 retcode = 0
szager@chromium.org848fd492014-04-09 19:06:44 +0000292 finally:
293 # Clean up the downloaded zipfile.
294 gclient_utils.rmtree(tempdir)
295
296 if retcode:
297 self.print(
298 'Extracting bootstrap zipfile %s failed.\n'
299 'Resuming normal operations.' % filename)
300 return False
301 return True
302
303 def exists(self):
304 return os.path.isfile(os.path.join(self.mirror_path, 'config'))
305
306 def populate(self, depth=None, shallow=False, bootstrap=False,
307 verbose=False):
308 if shallow and not depth:
309 depth = 10000
310 gclient_utils.safe_makedirs(self.GetCachePath())
311
312 v = []
313 if verbose:
314 v = ['-v', '--progress']
315
316 d = []
317 if depth:
318 d = ['--depth', str(depth)]
319
320
321 with Lockfile(self.mirror_path):
322 # Setup from scratch if the repo is new or is in a bad state.
323 tempdir = None
324 if not os.path.exists(os.path.join(self.mirror_path, 'config')):
325 gclient_utils.rmtree(self.mirror_path)
326 tempdir = tempfile.mkdtemp(
327 suffix=self.basedir, dir=self.GetCachePath())
328 bootstrapped = not depth and bootstrap and self.bootstrap_repo(tempdir)
329 if not bootstrapped:
330 self.RunGit(['init', '--bare'], cwd=tempdir)
331 else:
332 if depth and os.path.exists(os.path.join(self.mirror_path, 'shallow')):
333 logging.warn(
334 'Shallow fetch requested, but repo cache already exists.')
335 d = []
336
337 rundir = tempdir or self.mirror_path
338 self.config(rundir)
339 fetch_cmd = ['fetch'] + v + d + ['origin']
340 fetch_specs = subprocess.check_output(
341 [self.git_exe, 'config', '--get-all', 'remote.origin.fetch'],
342 cwd=rundir).strip().splitlines()
343 for spec in fetch_specs:
344 try:
345 self.RunGit(fetch_cmd + [spec], cwd=rundir, retry=True)
346 except subprocess.CalledProcessError:
347 logging.warn('Fetch of %s failed' % spec)
348 if tempdir:
349 os.rename(tempdir, self.mirror_path)
350
351 def update_bootstrap(self):
352 # The files are named <git number>.zip
353 gen_number = subprocess.check_output(
354 [self.git_exe, 'number', 'master'], cwd=self.mirror_path).strip()
355 self.RunGit(['gc']) # Run Garbage Collect to compress packfile.
356 # Creating a temp file and then deleting it ensures we can use this name.
357 _, tmp_zipfile = tempfile.mkstemp(suffix='.zip')
358 os.remove(tmp_zipfile)
359 subprocess.call(['zip', '-r', tmp_zipfile, '.'], cwd=self.mirror_path)
360 gsutil = Gsutil(path=self.gsutil_exe, boto_path=None)
361 dest_name = 'gs://%s/%s/%s.zip' % (
362 self.bootstrap_bucket, self.basedir, gen_number)
363 gsutil.call('cp', tmp_zipfile, dest_name)
364 os.remove(tmp_zipfile)
365
szager@chromium.org174766f2014-05-13 21:27:46 +0000366
367 @staticmethod
368 def BreakLocks(path):
369 did_unlock = False
370 lf = Lockfile(path)
371 if lf.break_lock():
372 did_unlock = True
373 # Look for lock files that might have been left behind by an interrupted
374 # git process.
375 lf = os.path.join(path, 'config.lock')
376 if os.path.exists(lf):
377 os.remove(lf)
378 did_unlock = True
379 return did_unlock
380
szager@chromium.org848fd492014-04-09 19:06:44 +0000381 def unlock(self):
szager@chromium.org174766f2014-05-13 21:27:46 +0000382 return self.BreakLocks(self.mirror_path)
383
384 @classmethod
385 def UnlockAll(cls):
386 cachepath = cls.GetCachePath()
387 dirlist = os.listdir(cachepath)
388 repo_dirs = set([os.path.join(cachepath, path) for path in dirlist
389 if os.path.isdir(os.path.join(cachepath, path))])
390 for dirent in dirlist:
391 if (dirent.endswith('.lock') and
392 os.path.isfile(os.path.join(cachepath, dirent))):
393 repo_dirs.add(os.path.join(cachepath, dirent[:-5]))
394
395 unlocked_repos = []
396 for repo_dir in repo_dirs:
397 if cls.BreakLocks(repo_dir):
398 unlocked_repos.append(repo_dir)
399
400 return unlocked_repos
szager@chromium.org848fd492014-04-09 19:06:44 +0000401
agable@chromium.org5a306a22014-02-24 22:13:59 +0000402@subcommand.usage('[url of repo to check for caching]')
403def CMDexists(parser, args):
404 """Check to see if there already is a cache of the given repo."""
szager@chromium.org848fd492014-04-09 19:06:44 +0000405 _, args = parser.parse_args(args)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000406 if not len(args) == 1:
407 parser.error('git cache exists only takes exactly one repo url.')
408 url = args[0]
szager@chromium.org848fd492014-04-09 19:06:44 +0000409 mirror = Mirror(url)
410 if mirror.exists():
411 print(mirror.mirror_path)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000412 return 0
413 return 1
414
415
hinoka@google.com563559c2014-04-02 00:36:24 +0000416@subcommand.usage('[url of repo to create a bootstrap zip file]')
417def CMDupdate_bootstrap(parser, args):
418 """Create and uploads a bootstrap tarball."""
419 # Lets just assert we can't do this on Windows.
420 if sys.platform.startswith('win'):
szager@chromium.org848fd492014-04-09 19:06:44 +0000421 print('Sorry, update bootstrap will not work on Windows.', file=sys.stderr)
hinoka@google.com563559c2014-04-02 00:36:24 +0000422 return 1
423
424 # First, we need to ensure the cache is populated.
425 populate_args = args[:]
426 populate_args.append('--no_bootstrap')
427 CMDpopulate(parser, populate_args)
428
429 # Get the repo directory.
szager@chromium.org848fd492014-04-09 19:06:44 +0000430 _, args = parser.parse_args(args)
hinoka@google.com563559c2014-04-02 00:36:24 +0000431 url = args[0]
szager@chromium.org848fd492014-04-09 19:06:44 +0000432 mirror = Mirror(url)
433 mirror.update_bootstrap()
434 return 0
hinoka@google.com563559c2014-04-02 00:36:24 +0000435
436
agable@chromium.org5a306a22014-02-24 22:13:59 +0000437@subcommand.usage('[url of repo to add to or update in cache]')
438def CMDpopulate(parser, args):
439 """Ensure that the cache has all up-to-date objects for the given repo."""
440 parser.add_option('--depth', type='int',
441 help='Only cache DEPTH commits of history')
442 parser.add_option('--shallow', '-s', action='store_true',
443 help='Only cache 10000 commits of history')
444 parser.add_option('--ref', action='append',
445 help='Specify additional refs to be fetched')
hinoka@google.com563559c2014-04-02 00:36:24 +0000446 parser.add_option('--no_bootstrap', action='store_true',
447 help='Don\'t bootstrap from Google Storage')
448
agable@chromium.org5a306a22014-02-24 22:13:59 +0000449 options, args = parser.parse_args(args)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000450 if not len(args) == 1:
451 parser.error('git cache populate only takes exactly one repo url.')
452 url = args[0]
453
szager@chromium.org848fd492014-04-09 19:06:44 +0000454 mirror = Mirror(url, refs=options.ref)
455 kwargs = {
456 'verbose': options.verbose,
457 'shallow': options.shallow,
458 'bootstrap': not options.no_bootstrap,
459 }
agable@chromium.org5a306a22014-02-24 22:13:59 +0000460 if options.depth:
szager@chromium.org848fd492014-04-09 19:06:44 +0000461 kwargs['depth'] = options.depth
462 mirror.populate(**kwargs)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000463
464
465@subcommand.usage('[url of repo to unlock, or -a|--all]')
466def CMDunlock(parser, args):
467 """Unlock one or all repos if their lock files are still around."""
468 parser.add_option('--force', '-f', action='store_true',
469 help='Actually perform the action')
470 parser.add_option('--all', '-a', action='store_true',
471 help='Unlock all repository caches')
472 options, args = parser.parse_args(args)
473 if len(args) > 1 or (len(args) == 0 and not options.all):
474 parser.error('git cache unlock takes exactly one repo url, or --all')
475
agable@chromium.org5a306a22014-02-24 22:13:59 +0000476 if not options.force:
szager@chromium.org174766f2014-05-13 21:27:46 +0000477 cachepath = Mirror.GetCachePath()
478 lockfiles = [os.path.join(cachepath, path)
479 for path in os.listdir(cachepath)
480 if path.endswith('.lock') and os.path.isfile(path)]
agable@chromium.org5a306a22014-02-24 22:13:59 +0000481 parser.error('git cache unlock requires -f|--force to do anything. '
482 'Refusing to unlock the following repo caches: '
483 ', '.join(lockfiles))
484
szager@chromium.org848fd492014-04-09 19:06:44 +0000485 unlocked_repos = []
szager@chromium.org174766f2014-05-13 21:27:46 +0000486 if options.all:
487 unlocked_repos.extend(Mirror.UnlockAll())
488 else:
489 m = Mirror(args[0])
490 if m.unlock():
491 unlocked_repos.append(m.mirror_path)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000492
szager@chromium.org848fd492014-04-09 19:06:44 +0000493 if unlocked_repos:
494 logging.info('Broke locks on these caches:\n %s' % '\n '.join(
495 unlocked_repos))
agable@chromium.org5a306a22014-02-24 22:13:59 +0000496
497
498class OptionParser(optparse.OptionParser):
499 """Wrapper class for OptionParser to handle global options."""
500
501 def __init__(self, *args, **kwargs):
502 optparse.OptionParser.__init__(self, *args, prog='git cache', **kwargs)
503 self.add_option('-c', '--cache-dir',
504 help='Path to the directory containing the cache')
szager@chromium.org2c391af2014-05-23 09:07:15 +0000505 self.add_option('-v', '--verbose', action='count', default=1,
agable@chromium.org5a306a22014-02-24 22:13:59 +0000506 help='Increase verbosity (can be passed multiple times)')
szager@chromium.org2c391af2014-05-23 09:07:15 +0000507 self.add_option('-q', '--quiet', action='store_true',
508 help='Suppress all extraneous output')
agable@chromium.org5a306a22014-02-24 22:13:59 +0000509
510 def parse_args(self, args=None, values=None):
511 options, args = optparse.OptionParser.parse_args(self, args, values)
szager@chromium.org2c391af2014-05-23 09:07:15 +0000512 if options.quiet:
513 options.verbose = 0
514
515 levels = [logging.ERROR, logging.WARNING, logging.INFO, logging.DEBUG]
516 logging.basicConfig(level=levels[min(options.verbose, len(levels) - 1)])
agable@chromium.org5a306a22014-02-24 22:13:59 +0000517
518 try:
szager@chromium.org848fd492014-04-09 19:06:44 +0000519 global_cache_dir = Mirror.GetCachePath()
520 except RuntimeError:
521 global_cache_dir = None
522 if options.cache_dir:
523 if global_cache_dir and (
524 os.path.abspath(options.cache_dir) !=
525 os.path.abspath(global_cache_dir)):
526 logging.warn('Overriding globally-configured cache directory.')
527 Mirror.SetCachePath(options.cache_dir)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000528
agable@chromium.org5a306a22014-02-24 22:13:59 +0000529 return options, args
530
531
532def main(argv):
533 dispatcher = subcommand.CommandDispatcher(__name__)
534 return dispatcher.execute(OptionParser(), argv)
535
536
537if __name__ == '__main__':
538 sys.exit(main(sys.argv[1:]))