blob: 88c55e611dbb9f2fbd5d997a649f76edde7facd8 [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
13import tempfile
14import subprocess
15import sys
16import urlparse
17
hinoka@google.com563559c2014-04-02 00:36:24 +000018from download_from_google_storage import Gsutil
agable@chromium.org5a306a22014-02-24 22:13:59 +000019import gclient_utils
20import subcommand
21
szager@chromium.org848fd492014-04-09 19:06:44 +000022try:
23 # pylint: disable=E0602
24 WinErr = WindowsError
25except NameError:
26 class WinErr(Exception):
27 pass
agable@chromium.org5a306a22014-02-24 22:13:59 +000028
29class LockError(Exception):
30 pass
31
32
33class Lockfile(object):
34 """Class to represent a cross-platform process-specific lockfile."""
35
36 def __init__(self, path):
37 self.path = os.path.abspath(path)
38 self.lockfile = self.path + ".lock"
39 self.pid = os.getpid()
40
41 def _read_pid(self):
42 """Read the pid stored in the lockfile.
43
44 Note: This method is potentially racy. By the time it returns the lockfile
45 may have been unlocked, removed, or stolen by some other process.
46 """
47 try:
48 with open(self.lockfile, 'r') as f:
49 pid = int(f.readline().strip())
50 except (IOError, ValueError):
51 pid = None
52 return pid
53
54 def _make_lockfile(self):
55 """Safely creates a lockfile containing the current pid."""
56 open_flags = (os.O_CREAT | os.O_EXCL | os.O_WRONLY)
57 fd = os.open(self.lockfile, open_flags, 0o644)
58 f = os.fdopen(fd, 'w')
szager@chromium.org848fd492014-04-09 19:06:44 +000059 print(self.pid, file=f)
agable@chromium.org5a306a22014-02-24 22:13:59 +000060 f.close()
61
62 def _remove_lockfile(self):
63 """Delete the lockfile. Complains (implicitly) if it doesn't exist."""
64 os.remove(self.lockfile)
65
66 def lock(self):
67 """Acquire the lock.
68
69 Note: This is a NON-BLOCKING FAIL-FAST operation.
70 Do. Or do not. There is no try.
71 """
72 try:
73 self._make_lockfile()
74 except OSError as e:
75 if e.errno == errno.EEXIST:
76 raise LockError("%s is already locked" % self.path)
77 else:
78 raise LockError("Failed to create %s (err %s)" % (self.path, e.errno))
79
80 def unlock(self):
81 """Release the lock."""
82 if not self.is_locked():
83 raise LockError("%s is not locked" % self.path)
84 if not self.i_am_locking():
85 raise LockError("%s is locked, but not by me" % self.path)
86 self._remove_lockfile()
87
88 def break_lock(self):
89 """Remove the lock, even if it was created by someone else."""
90 try:
91 self._remove_lockfile()
92 return True
93 except OSError as exc:
94 if exc.errno == errno.ENOENT:
95 return False
96 else:
97 raise
98
99 def is_locked(self):
100 """Test if the file is locked by anyone.
101
102 Note: This method is potentially racy. By the time it returns the lockfile
103 may have been unlocked, removed, or stolen by some other process.
104 """
105 return os.path.exists(self.lockfile)
106
107 def i_am_locking(self):
108 """Test if the file is locked by this process."""
109 return self.is_locked() and self.pid == self._read_pid()
110
111 def __enter__(self):
112 self.lock()
113 return self
114
115 def __exit__(self, *_exc):
szager@chromium.org848fd492014-04-09 19:06:44 +0000116 # Windows is unreliable when it comes to file locking. YMMV.
117 try:
118 self.unlock()
119 except WinErr:
120 pass
agable@chromium.org5a306a22014-02-24 22:13:59 +0000121
122
szager@chromium.org848fd492014-04-09 19:06:44 +0000123class Mirror(object):
124
125 git_exe = 'git.bat' if sys.platform.startswith('win') else 'git'
126 gsutil_exe = os.path.join(
127 os.path.dirname(os.path.abspath(__file__)),
128 'third_party', 'gsutil', 'gsutil')
129 bootstrap_bucket = 'chromium-git-cache'
130
131 def __init__(self, url, refs=None, print_func=None):
132 self.url = url
133 self.refs = refs or []
134 self.basedir = self.UrlToCacheDir(url)
135 self.mirror_path = os.path.join(self.GetCachePath(), self.basedir)
136 self.print = print_func or print
137
138 @staticmethod
139 def UrlToCacheDir(url):
140 """Convert a git url to a normalized form for the cache dir path."""
141 parsed = urlparse.urlparse(url)
142 norm_url = parsed.netloc + parsed.path
143 if norm_url.endswith('.git'):
144 norm_url = norm_url[:-len('.git')]
145 return norm_url.replace('-', '--').replace('/', '-').lower()
146
147 @staticmethod
148 def FindExecutable(executable):
149 """This mimics the "which" utility."""
150 path_folders = os.environ.get('PATH').split(os.pathsep)
151
152 for path_folder in path_folders:
153 target = os.path.join(path_folder, executable)
154 # Just incase we have some ~/blah paths.
155 target = os.path.abspath(os.path.expanduser(target))
156 if os.path.isfile(target) and os.access(target, os.X_OK):
157 return target
szager@chromium.org6b5faf52014-04-09 21:54:21 +0000158 if sys.platform.startswith('win'):
159 for suffix in ('.bat', '.cmd', '.exe'):
160 alt_target = target + suffix
szager@chromium.org4039b312014-04-09 21:56:46 +0000161 if os.path.isfile(alt_target) and os.access(alt_target, os.X_OK):
szager@chromium.org6b5faf52014-04-09 21:54:21 +0000162 return alt_target
szager@chromium.org848fd492014-04-09 19:06:44 +0000163 return None
164
165 @classmethod
166 def SetCachePath(cls, cachepath):
167 setattr(cls, 'cachepath', cachepath)
168
169 @classmethod
170 def GetCachePath(cls):
171 if not hasattr(cls, 'cachepath'):
172 try:
173 cachepath = subprocess.check_output(
174 [cls.git_exe, 'config', '--global', 'cache.cachepath']).strip()
175 except subprocess.CalledProcessError:
176 cachepath = None
177 if not cachepath:
178 raise RuntimeError('No global cache.cachepath git configuration found.')
179 setattr(cls, 'cachepath', cachepath)
180 return getattr(cls, 'cachepath')
181
182 def RunGit(self, cmd, **kwargs):
183 """Run git in a subprocess."""
184 cwd = kwargs.setdefault('cwd', self.mirror_path)
185 kwargs.setdefault('print_stdout', False)
186 kwargs.setdefault('filter_fn', self.print)
187 env = kwargs.get('env') or kwargs.setdefault('env', os.environ.copy())
188 env.setdefault('GIT_ASKPASS', 'true')
189 env.setdefault('SSH_ASKPASS', 'true')
190 self.print('running "git %s" in "%s"' % (' '.join(cmd), cwd))
191 gclient_utils.CheckCallAndFilter([self.git_exe] + cmd, **kwargs)
192
193 def config(self, cwd=None):
194 if cwd is None:
195 cwd = self.mirror_path
196 self.RunGit(['config', 'core.deltaBaseCacheLimit',
197 gclient_utils.DefaultDeltaBaseCacheLimit()], cwd=cwd)
198 self.RunGit(['config', 'remote.origin.url', self.url], cwd=cwd)
199 self.RunGit(['config', '--replace-all', 'remote.origin.fetch',
200 '+refs/heads/*:refs/heads/*'], cwd=cwd)
201 for ref in self.refs:
202 ref = ref.lstrip('+').rstrip('/')
203 if ref.startswith('refs/'):
204 refspec = '+%s:%s' % (ref, ref)
205 else:
206 refspec = '+refs/%s/*:refs/%s/*' % (ref, ref)
207 self.RunGit(['config', '--add', 'remote.origin.fetch', refspec], cwd=cwd)
208
209 def bootstrap_repo(self, directory):
210 """Bootstrap the repo from Google Stroage if possible.
211
212 Requires 7z on Windows and Unzip on Linux/Mac.
213 """
214 if sys.platform.startswith('win'):
215 if not self.FindExecutable('7z'):
216 self.print('''
217Cannot find 7z in the path. If you want git cache to be able to bootstrap from
218Google Storage, please install 7z from:
219
220http://www.7-zip.org/download.html
221''')
222 return False
223 else:
224 if not self.FindExecutable('unzip'):
225 self.print('''
226Cannot find unzip in the path. If you want git cache to be able to bootstrap
227from Google Storage, please ensure unzip is present on your system.
228''')
229 return False
230
231 gs_folder = 'gs://%s/%s' % (self.bootstrap_bucket, self.basedir)
232 gsutil = Gsutil(
233 self.gsutil_exe, boto_path=os.devnull, bypass_prodaccess=True)
234 # Get the most recent version of the zipfile.
235 _, ls_out, _ = gsutil.check_call('ls', gs_folder)
236 ls_out_sorted = sorted(ls_out.splitlines())
237 if not ls_out_sorted:
238 # This repo is not on Google Storage.
239 return False
240 latest_checkout = ls_out_sorted[-1]
241
242 # Download zip file to a temporary directory.
243 try:
244 tempdir = tempfile.mkdtemp()
245 self.print('Downloading %s' % latest_checkout)
246 code, out, err = gsutil.check_call('cp', latest_checkout, tempdir)
247 if code:
248 self.print('%s\n%s' % (out, err))
249 return False
250 filename = os.path.join(tempdir, latest_checkout.split('/')[-1])
251
252 # Unpack the file with 7z on Windows, or unzip everywhere else.
253 if sys.platform.startswith('win'):
254 cmd = ['7z', 'x', '-o%s' % directory, '-tzip', filename]
255 else:
256 cmd = ['unzip', filename, '-d', directory]
257 retcode = subprocess.call(cmd)
258 finally:
259 # Clean up the downloaded zipfile.
260 gclient_utils.rmtree(tempdir)
261
262 if retcode:
263 self.print(
264 'Extracting bootstrap zipfile %s failed.\n'
265 'Resuming normal operations.' % filename)
266 return False
267 return True
268
269 def exists(self):
270 return os.path.isfile(os.path.join(self.mirror_path, 'config'))
271
272 def populate(self, depth=None, shallow=False, bootstrap=False,
273 verbose=False):
274 if shallow and not depth:
275 depth = 10000
276 gclient_utils.safe_makedirs(self.GetCachePath())
277
278 v = []
279 if verbose:
280 v = ['-v', '--progress']
281
282 d = []
283 if depth:
284 d = ['--depth', str(depth)]
285
286
287 with Lockfile(self.mirror_path):
288 # Setup from scratch if the repo is new or is in a bad state.
289 tempdir = None
290 if not os.path.exists(os.path.join(self.mirror_path, 'config')):
291 gclient_utils.rmtree(self.mirror_path)
292 tempdir = tempfile.mkdtemp(
293 suffix=self.basedir, dir=self.GetCachePath())
294 bootstrapped = not depth and bootstrap and self.bootstrap_repo(tempdir)
295 if not bootstrapped:
296 self.RunGit(['init', '--bare'], cwd=tempdir)
297 else:
298 if depth and os.path.exists(os.path.join(self.mirror_path, 'shallow')):
299 logging.warn(
300 'Shallow fetch requested, but repo cache already exists.')
301 d = []
302
303 rundir = tempdir or self.mirror_path
304 self.config(rundir)
305 fetch_cmd = ['fetch'] + v + d + ['origin']
306 fetch_specs = subprocess.check_output(
307 [self.git_exe, 'config', '--get-all', 'remote.origin.fetch'],
308 cwd=rundir).strip().splitlines()
309 for spec in fetch_specs:
310 try:
311 self.RunGit(fetch_cmd + [spec], cwd=rundir, retry=True)
312 except subprocess.CalledProcessError:
313 logging.warn('Fetch of %s failed' % spec)
314 if tempdir:
315 os.rename(tempdir, self.mirror_path)
316
317 def update_bootstrap(self):
318 # The files are named <git number>.zip
319 gen_number = subprocess.check_output(
320 [self.git_exe, 'number', 'master'], cwd=self.mirror_path).strip()
321 self.RunGit(['gc']) # Run Garbage Collect to compress packfile.
322 # Creating a temp file and then deleting it ensures we can use this name.
323 _, tmp_zipfile = tempfile.mkstemp(suffix='.zip')
324 os.remove(tmp_zipfile)
325 subprocess.call(['zip', '-r', tmp_zipfile, '.'], cwd=self.mirror_path)
326 gsutil = Gsutil(path=self.gsutil_exe, boto_path=None)
327 dest_name = 'gs://%s/%s/%s.zip' % (
328 self.bootstrap_bucket, self.basedir, gen_number)
329 gsutil.call('cp', tmp_zipfile, dest_name)
330 os.remove(tmp_zipfile)
331
332 def unlock(self):
333 lf = Lockfile(self.mirror_path)
334 config_lock = os.path.join(self.mirror_path, 'config.lock')
335 if os.path.exists(config_lock):
336 os.remove(config_lock)
337 lf.break_lock()
338
agable@chromium.org5a306a22014-02-24 22:13:59 +0000339@subcommand.usage('[url of repo to check for caching]')
340def CMDexists(parser, args):
341 """Check to see if there already is a cache of the given repo."""
szager@chromium.org848fd492014-04-09 19:06:44 +0000342 _, args = parser.parse_args(args)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000343 if not len(args) == 1:
344 parser.error('git cache exists only takes exactly one repo url.')
345 url = args[0]
szager@chromium.org848fd492014-04-09 19:06:44 +0000346 mirror = Mirror(url)
347 if mirror.exists():
348 print(mirror.mirror_path)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000349 return 0
350 return 1
351
352
hinoka@google.com563559c2014-04-02 00:36:24 +0000353@subcommand.usage('[url of repo to create a bootstrap zip file]')
354def CMDupdate_bootstrap(parser, args):
355 """Create and uploads a bootstrap tarball."""
356 # Lets just assert we can't do this on Windows.
357 if sys.platform.startswith('win'):
szager@chromium.org848fd492014-04-09 19:06:44 +0000358 print('Sorry, update bootstrap will not work on Windows.', file=sys.stderr)
hinoka@google.com563559c2014-04-02 00:36:24 +0000359 return 1
360
361 # First, we need to ensure the cache is populated.
362 populate_args = args[:]
363 populate_args.append('--no_bootstrap')
364 CMDpopulate(parser, populate_args)
365
366 # Get the repo directory.
szager@chromium.org848fd492014-04-09 19:06:44 +0000367 _, args = parser.parse_args(args)
hinoka@google.com563559c2014-04-02 00:36:24 +0000368 url = args[0]
szager@chromium.org848fd492014-04-09 19:06:44 +0000369 mirror = Mirror(url)
370 mirror.update_bootstrap()
371 return 0
hinoka@google.com563559c2014-04-02 00:36:24 +0000372
373
agable@chromium.org5a306a22014-02-24 22:13:59 +0000374@subcommand.usage('[url of repo to add to or update in cache]')
375def CMDpopulate(parser, args):
376 """Ensure that the cache has all up-to-date objects for the given repo."""
377 parser.add_option('--depth', type='int',
378 help='Only cache DEPTH commits of history')
379 parser.add_option('--shallow', '-s', action='store_true',
380 help='Only cache 10000 commits of history')
381 parser.add_option('--ref', action='append',
382 help='Specify additional refs to be fetched')
hinoka@google.com563559c2014-04-02 00:36:24 +0000383 parser.add_option('--no_bootstrap', action='store_true',
384 help='Don\'t bootstrap from Google Storage')
385
agable@chromium.org5a306a22014-02-24 22:13:59 +0000386 options, args = parser.parse_args(args)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000387 if not len(args) == 1:
388 parser.error('git cache populate only takes exactly one repo url.')
389 url = args[0]
390
szager@chromium.org848fd492014-04-09 19:06:44 +0000391 mirror = Mirror(url, refs=options.ref)
392 kwargs = {
393 'verbose': options.verbose,
394 'shallow': options.shallow,
395 'bootstrap': not options.no_bootstrap,
396 }
agable@chromium.org5a306a22014-02-24 22:13:59 +0000397 if options.depth:
szager@chromium.org848fd492014-04-09 19:06:44 +0000398 kwargs['depth'] = options.depth
399 mirror.populate(**kwargs)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000400
401
402@subcommand.usage('[url of repo to unlock, or -a|--all]')
403def CMDunlock(parser, args):
404 """Unlock one or all repos if their lock files are still around."""
405 parser.add_option('--force', '-f', action='store_true',
406 help='Actually perform the action')
407 parser.add_option('--all', '-a', action='store_true',
408 help='Unlock all repository caches')
409 options, args = parser.parse_args(args)
410 if len(args) > 1 or (len(args) == 0 and not options.all):
411 parser.error('git cache unlock takes exactly one repo url, or --all')
412
szager@chromium.org848fd492014-04-09 19:06:44 +0000413 repo_dirs = []
agable@chromium.org5a306a22014-02-24 22:13:59 +0000414 if not options.all:
415 url = args[0]
szager@chromium.org848fd492014-04-09 19:06:44 +0000416 repo_dirs.append(Mirror(url).mirror_path)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000417 else:
szager@chromium.org848fd492014-04-09 19:06:44 +0000418 cachepath = Mirror.GetCachePath()
419 repo_dirs = [os.path.join(cachepath, path)
420 for path in os.listdir(cachepath)
421 if os.path.isdir(os.path.join(cachepath, path))]
422 repo_dirs.extend([os.path.join(cachepath,
hinoka@google.comb16a1652014-03-05 20:22:00 +0000423 lockfile.replace('.lock', ''))
szager@chromium.org848fd492014-04-09 19:06:44 +0000424 for lockfile in os.listdir(cachepath)
425 if os.path.isfile(os.path.join(cachepath,
hinoka@google.comb16a1652014-03-05 20:22:00 +0000426 lockfile))
427 and lockfile.endswith('.lock')
szager@chromium.org848fd492014-04-09 19:06:44 +0000428 and os.path.join(cachepath, lockfile)
hinoka@google.comb16a1652014-03-05 20:22:00 +0000429 not in repo_dirs])
agable@chromium.org5a306a22014-02-24 22:13:59 +0000430 lockfiles = [repo_dir + '.lock' for repo_dir in repo_dirs
431 if os.path.exists(repo_dir + '.lock')]
432
433 if not options.force:
434 parser.error('git cache unlock requires -f|--force to do anything. '
435 'Refusing to unlock the following repo caches: '
436 ', '.join(lockfiles))
437
szager@chromium.org848fd492014-04-09 19:06:44 +0000438 unlocked_repos = []
439 untouched_repos = []
agable@chromium.org5a306a22014-02-24 22:13:59 +0000440 for repo_dir in repo_dirs:
441 lf = Lockfile(repo_dir)
hinoka@google.comb16a1652014-03-05 20:22:00 +0000442 config_lock = os.path.join(repo_dir, 'config.lock')
443 unlocked = False
444 if os.path.exists(config_lock):
445 os.remove(config_lock)
446 unlocked = True
agable@chromium.org5a306a22014-02-24 22:13:59 +0000447 if lf.break_lock():
hinoka@google.comb16a1652014-03-05 20:22:00 +0000448 unlocked = True
449
450 if unlocked:
szager@chromium.org848fd492014-04-09 19:06:44 +0000451 unlocked_repos.append(repo_dir)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000452 else:
szager@chromium.org848fd492014-04-09 19:06:44 +0000453 untouched_repos.append(repo_dir)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000454
szager@chromium.org848fd492014-04-09 19:06:44 +0000455 if unlocked_repos:
456 logging.info('Broke locks on these caches:\n %s' % '\n '.join(
457 unlocked_repos))
458 if untouched_repos:
459 logging.debug('Did not touch these caches:\n %s' % '\n '.join(
460 untouched_repos))
agable@chromium.org5a306a22014-02-24 22:13:59 +0000461
462
463class OptionParser(optparse.OptionParser):
464 """Wrapper class for OptionParser to handle global options."""
465
466 def __init__(self, *args, **kwargs):
467 optparse.OptionParser.__init__(self, *args, prog='git cache', **kwargs)
468 self.add_option('-c', '--cache-dir',
469 help='Path to the directory containing the cache')
470 self.add_option('-v', '--verbose', action='count', default=0,
471 help='Increase verbosity (can be passed multiple times)')
472
473 def parse_args(self, args=None, values=None):
474 options, args = optparse.OptionParser.parse_args(self, args, values)
475
476 try:
szager@chromium.org848fd492014-04-09 19:06:44 +0000477 global_cache_dir = Mirror.GetCachePath()
478 except RuntimeError:
479 global_cache_dir = None
480 if options.cache_dir:
481 if global_cache_dir and (
482 os.path.abspath(options.cache_dir) !=
483 os.path.abspath(global_cache_dir)):
484 logging.warn('Overriding globally-configured cache directory.')
485 Mirror.SetCachePath(options.cache_dir)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000486
487 levels = [logging.WARNING, logging.INFO, logging.DEBUG]
488 logging.basicConfig(level=levels[min(options.verbose, len(levels) - 1)])
489
490 return options, args
491
492
493def main(argv):
494 dispatcher = subcommand.CommandDispatcher(__name__)
495 return dispatcher.execute(OptionParser(), argv)
496
497
498if __name__ == '__main__':
499 sys.exit(main(sys.argv[1:]))