blob: b60073e972b1aa84e0eb091230df8e4a068ddf04 [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
158 return None
159
160 @classmethod
161 def SetCachePath(cls, cachepath):
162 setattr(cls, 'cachepath', cachepath)
163
164 @classmethod
165 def GetCachePath(cls):
166 if not hasattr(cls, 'cachepath'):
167 try:
168 cachepath = subprocess.check_output(
169 [cls.git_exe, 'config', '--global', 'cache.cachepath']).strip()
170 except subprocess.CalledProcessError:
171 cachepath = None
172 if not cachepath:
173 raise RuntimeError('No global cache.cachepath git configuration found.')
174 setattr(cls, 'cachepath', cachepath)
175 return getattr(cls, 'cachepath')
176
177 def RunGit(self, cmd, **kwargs):
178 """Run git in a subprocess."""
179 cwd = kwargs.setdefault('cwd', self.mirror_path)
180 kwargs.setdefault('print_stdout', False)
181 kwargs.setdefault('filter_fn', self.print)
182 env = kwargs.get('env') or kwargs.setdefault('env', os.environ.copy())
183 env.setdefault('GIT_ASKPASS', 'true')
184 env.setdefault('SSH_ASKPASS', 'true')
185 self.print('running "git %s" in "%s"' % (' '.join(cmd), cwd))
186 gclient_utils.CheckCallAndFilter([self.git_exe] + cmd, **kwargs)
187
188 def config(self, cwd=None):
189 if cwd is None:
190 cwd = self.mirror_path
191 self.RunGit(['config', 'core.deltaBaseCacheLimit',
192 gclient_utils.DefaultDeltaBaseCacheLimit()], cwd=cwd)
193 self.RunGit(['config', 'remote.origin.url', self.url], cwd=cwd)
194 self.RunGit(['config', '--replace-all', 'remote.origin.fetch',
195 '+refs/heads/*:refs/heads/*'], cwd=cwd)
196 for ref in self.refs:
197 ref = ref.lstrip('+').rstrip('/')
198 if ref.startswith('refs/'):
199 refspec = '+%s:%s' % (ref, ref)
200 else:
201 refspec = '+refs/%s/*:refs/%s/*' % (ref, ref)
202 self.RunGit(['config', '--add', 'remote.origin.fetch', refspec], cwd=cwd)
203
204 def bootstrap_repo(self, directory):
205 """Bootstrap the repo from Google Stroage if possible.
206
207 Requires 7z on Windows and Unzip on Linux/Mac.
208 """
209 if sys.platform.startswith('win'):
210 if not self.FindExecutable('7z'):
211 self.print('''
212Cannot find 7z in the path. If you want git cache to be able to bootstrap from
213Google Storage, please install 7z from:
214
215http://www.7-zip.org/download.html
216''')
217 return False
218 else:
219 if not self.FindExecutable('unzip'):
220 self.print('''
221Cannot find unzip in the path. If you want git cache to be able to bootstrap
222from Google Storage, please ensure unzip is present on your system.
223''')
224 return False
225
226 gs_folder = 'gs://%s/%s' % (self.bootstrap_bucket, self.basedir)
227 gsutil = Gsutil(
228 self.gsutil_exe, boto_path=os.devnull, bypass_prodaccess=True)
229 # Get the most recent version of the zipfile.
230 _, ls_out, _ = gsutil.check_call('ls', gs_folder)
231 ls_out_sorted = sorted(ls_out.splitlines())
232 if not ls_out_sorted:
233 # This repo is not on Google Storage.
234 return False
235 latest_checkout = ls_out_sorted[-1]
236
237 # Download zip file to a temporary directory.
238 try:
239 tempdir = tempfile.mkdtemp()
240 self.print('Downloading %s' % latest_checkout)
241 code, out, err = gsutil.check_call('cp', latest_checkout, tempdir)
242 if code:
243 self.print('%s\n%s' % (out, err))
244 return False
245 filename = os.path.join(tempdir, latest_checkout.split('/')[-1])
246
247 # Unpack the file with 7z on Windows, or unzip everywhere else.
248 if sys.platform.startswith('win'):
249 cmd = ['7z', 'x', '-o%s' % directory, '-tzip', filename]
250 else:
251 cmd = ['unzip', filename, '-d', directory]
252 retcode = subprocess.call(cmd)
253 finally:
254 # Clean up the downloaded zipfile.
255 gclient_utils.rmtree(tempdir)
256
257 if retcode:
258 self.print(
259 'Extracting bootstrap zipfile %s failed.\n'
260 'Resuming normal operations.' % filename)
261 return False
262 return True
263
264 def exists(self):
265 return os.path.isfile(os.path.join(self.mirror_path, 'config'))
266
267 def populate(self, depth=None, shallow=False, bootstrap=False,
268 verbose=False):
269 if shallow and not depth:
270 depth = 10000
271 gclient_utils.safe_makedirs(self.GetCachePath())
272
273 v = []
274 if verbose:
275 v = ['-v', '--progress']
276
277 d = []
278 if depth:
279 d = ['--depth', str(depth)]
280
281
282 with Lockfile(self.mirror_path):
283 # Setup from scratch if the repo is new or is in a bad state.
284 tempdir = None
285 if not os.path.exists(os.path.join(self.mirror_path, 'config')):
286 gclient_utils.rmtree(self.mirror_path)
287 tempdir = tempfile.mkdtemp(
288 suffix=self.basedir, dir=self.GetCachePath())
289 bootstrapped = not depth and bootstrap and self.bootstrap_repo(tempdir)
290 if not bootstrapped:
291 self.RunGit(['init', '--bare'], cwd=tempdir)
292 else:
293 if depth and os.path.exists(os.path.join(self.mirror_path, 'shallow')):
294 logging.warn(
295 'Shallow fetch requested, but repo cache already exists.')
296 d = []
297
298 rundir = tempdir or self.mirror_path
299 self.config(rundir)
300 fetch_cmd = ['fetch'] + v + d + ['origin']
301 fetch_specs = subprocess.check_output(
302 [self.git_exe, 'config', '--get-all', 'remote.origin.fetch'],
303 cwd=rundir).strip().splitlines()
304 for spec in fetch_specs:
305 try:
306 self.RunGit(fetch_cmd + [spec], cwd=rundir, retry=True)
307 except subprocess.CalledProcessError:
308 logging.warn('Fetch of %s failed' % spec)
309 if tempdir:
310 os.rename(tempdir, self.mirror_path)
311
312 def update_bootstrap(self):
313 # The files are named <git number>.zip
314 gen_number = subprocess.check_output(
315 [self.git_exe, 'number', 'master'], cwd=self.mirror_path).strip()
316 self.RunGit(['gc']) # Run Garbage Collect to compress packfile.
317 # Creating a temp file and then deleting it ensures we can use this name.
318 _, tmp_zipfile = tempfile.mkstemp(suffix='.zip')
319 os.remove(tmp_zipfile)
320 subprocess.call(['zip', '-r', tmp_zipfile, '.'], cwd=self.mirror_path)
321 gsutil = Gsutil(path=self.gsutil_exe, boto_path=None)
322 dest_name = 'gs://%s/%s/%s.zip' % (
323 self.bootstrap_bucket, self.basedir, gen_number)
324 gsutil.call('cp', tmp_zipfile, dest_name)
325 os.remove(tmp_zipfile)
326
327 def unlock(self):
328 lf = Lockfile(self.mirror_path)
329 config_lock = os.path.join(self.mirror_path, 'config.lock')
330 if os.path.exists(config_lock):
331 os.remove(config_lock)
332 lf.break_lock()
333
agable@chromium.org5a306a22014-02-24 22:13:59 +0000334@subcommand.usage('[url of repo to check for caching]')
335def CMDexists(parser, args):
336 """Check to see if there already is a cache of the given repo."""
szager@chromium.org848fd492014-04-09 19:06:44 +0000337 _, args = parser.parse_args(args)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000338 if not len(args) == 1:
339 parser.error('git cache exists only takes exactly one repo url.')
340 url = args[0]
szager@chromium.org848fd492014-04-09 19:06:44 +0000341 mirror = Mirror(url)
342 if mirror.exists():
343 print(mirror.mirror_path)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000344 return 0
345 return 1
346
347
hinoka@google.com563559c2014-04-02 00:36:24 +0000348@subcommand.usage('[url of repo to create a bootstrap zip file]')
349def CMDupdate_bootstrap(parser, args):
350 """Create and uploads a bootstrap tarball."""
351 # Lets just assert we can't do this on Windows.
352 if sys.platform.startswith('win'):
szager@chromium.org848fd492014-04-09 19:06:44 +0000353 print('Sorry, update bootstrap will not work on Windows.', file=sys.stderr)
hinoka@google.com563559c2014-04-02 00:36:24 +0000354 return 1
355
356 # First, we need to ensure the cache is populated.
357 populate_args = args[:]
358 populate_args.append('--no_bootstrap')
359 CMDpopulate(parser, populate_args)
360
361 # Get the repo directory.
szager@chromium.org848fd492014-04-09 19:06:44 +0000362 _, args = parser.parse_args(args)
hinoka@google.com563559c2014-04-02 00:36:24 +0000363 url = args[0]
szager@chromium.org848fd492014-04-09 19:06:44 +0000364 mirror = Mirror(url)
365 mirror.update_bootstrap()
366 return 0
hinoka@google.com563559c2014-04-02 00:36:24 +0000367
368
agable@chromium.org5a306a22014-02-24 22:13:59 +0000369@subcommand.usage('[url of repo to add to or update in cache]')
370def CMDpopulate(parser, args):
371 """Ensure that the cache has all up-to-date objects for the given repo."""
372 parser.add_option('--depth', type='int',
373 help='Only cache DEPTH commits of history')
374 parser.add_option('--shallow', '-s', action='store_true',
375 help='Only cache 10000 commits of history')
376 parser.add_option('--ref', action='append',
377 help='Specify additional refs to be fetched')
hinoka@google.com563559c2014-04-02 00:36:24 +0000378 parser.add_option('--no_bootstrap', action='store_true',
379 help='Don\'t bootstrap from Google Storage')
380
agable@chromium.org5a306a22014-02-24 22:13:59 +0000381 options, args = parser.parse_args(args)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000382 if not len(args) == 1:
383 parser.error('git cache populate only takes exactly one repo url.')
384 url = args[0]
385
szager@chromium.org848fd492014-04-09 19:06:44 +0000386 mirror = Mirror(url, refs=options.ref)
387 kwargs = {
388 'verbose': options.verbose,
389 'shallow': options.shallow,
390 'bootstrap': not options.no_bootstrap,
391 }
agable@chromium.org5a306a22014-02-24 22:13:59 +0000392 if options.depth:
szager@chromium.org848fd492014-04-09 19:06:44 +0000393 kwargs['depth'] = options.depth
394 mirror.populate(**kwargs)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000395
396
397@subcommand.usage('[url of repo to unlock, or -a|--all]')
398def CMDunlock(parser, args):
399 """Unlock one or all repos if their lock files are still around."""
400 parser.add_option('--force', '-f', action='store_true',
401 help='Actually perform the action')
402 parser.add_option('--all', '-a', action='store_true',
403 help='Unlock all repository caches')
404 options, args = parser.parse_args(args)
405 if len(args) > 1 or (len(args) == 0 and not options.all):
406 parser.error('git cache unlock takes exactly one repo url, or --all')
407
szager@chromium.org848fd492014-04-09 19:06:44 +0000408 repo_dirs = []
agable@chromium.org5a306a22014-02-24 22:13:59 +0000409 if not options.all:
410 url = args[0]
szager@chromium.org848fd492014-04-09 19:06:44 +0000411 repo_dirs.append(Mirror(url).mirror_path)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000412 else:
szager@chromium.org848fd492014-04-09 19:06:44 +0000413 cachepath = Mirror.GetCachePath()
414 repo_dirs = [os.path.join(cachepath, path)
415 for path in os.listdir(cachepath)
416 if os.path.isdir(os.path.join(cachepath, path))]
417 repo_dirs.extend([os.path.join(cachepath,
hinoka@google.comb16a1652014-03-05 20:22:00 +0000418 lockfile.replace('.lock', ''))
szager@chromium.org848fd492014-04-09 19:06:44 +0000419 for lockfile in os.listdir(cachepath)
420 if os.path.isfile(os.path.join(cachepath,
hinoka@google.comb16a1652014-03-05 20:22:00 +0000421 lockfile))
422 and lockfile.endswith('.lock')
szager@chromium.org848fd492014-04-09 19:06:44 +0000423 and os.path.join(cachepath, lockfile)
hinoka@google.comb16a1652014-03-05 20:22:00 +0000424 not in repo_dirs])
agable@chromium.org5a306a22014-02-24 22:13:59 +0000425 lockfiles = [repo_dir + '.lock' for repo_dir in repo_dirs
426 if os.path.exists(repo_dir + '.lock')]
427
428 if not options.force:
429 parser.error('git cache unlock requires -f|--force to do anything. '
430 'Refusing to unlock the following repo caches: '
431 ', '.join(lockfiles))
432
szager@chromium.org848fd492014-04-09 19:06:44 +0000433 unlocked_repos = []
434 untouched_repos = []
agable@chromium.org5a306a22014-02-24 22:13:59 +0000435 for repo_dir in repo_dirs:
436 lf = Lockfile(repo_dir)
hinoka@google.comb16a1652014-03-05 20:22:00 +0000437 config_lock = os.path.join(repo_dir, 'config.lock')
438 unlocked = False
439 if os.path.exists(config_lock):
440 os.remove(config_lock)
441 unlocked = True
agable@chromium.org5a306a22014-02-24 22:13:59 +0000442 if lf.break_lock():
hinoka@google.comb16a1652014-03-05 20:22:00 +0000443 unlocked = True
444
445 if unlocked:
szager@chromium.org848fd492014-04-09 19:06:44 +0000446 unlocked_repos.append(repo_dir)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000447 else:
szager@chromium.org848fd492014-04-09 19:06:44 +0000448 untouched_repos.append(repo_dir)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000449
szager@chromium.org848fd492014-04-09 19:06:44 +0000450 if unlocked_repos:
451 logging.info('Broke locks on these caches:\n %s' % '\n '.join(
452 unlocked_repos))
453 if untouched_repos:
454 logging.debug('Did not touch these caches:\n %s' % '\n '.join(
455 untouched_repos))
agable@chromium.org5a306a22014-02-24 22:13:59 +0000456
457
458class OptionParser(optparse.OptionParser):
459 """Wrapper class for OptionParser to handle global options."""
460
461 def __init__(self, *args, **kwargs):
462 optparse.OptionParser.__init__(self, *args, prog='git cache', **kwargs)
463 self.add_option('-c', '--cache-dir',
464 help='Path to the directory containing the cache')
465 self.add_option('-v', '--verbose', action='count', default=0,
466 help='Increase verbosity (can be passed multiple times)')
467
468 def parse_args(self, args=None, values=None):
469 options, args = optparse.OptionParser.parse_args(self, args, values)
470
471 try:
szager@chromium.org848fd492014-04-09 19:06:44 +0000472 global_cache_dir = Mirror.GetCachePath()
473 except RuntimeError:
474 global_cache_dir = None
475 if options.cache_dir:
476 if global_cache_dir and (
477 os.path.abspath(options.cache_dir) !=
478 os.path.abspath(global_cache_dir)):
479 logging.warn('Overriding globally-configured cache directory.')
480 Mirror.SetCachePath(options.cache_dir)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000481
482 levels = [logging.WARNING, logging.INFO, logging.DEBUG]
483 logging.basicConfig(level=levels[min(options.verbose, len(levels) - 1)])
484
485 return options, args
486
487
488def main(argv):
489 dispatcher = subcommand.CommandDispatcher(__name__)
490 return dispatcher.execute(OptionParser(), argv)
491
492
493if __name__ == '__main__':
494 sys.exit(main(sys.argv[1:]))