blob: 9ef4873874e34557211344623e4b41fadfe7d310 [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
8import errno
9import logging
10import optparse
11import os
12import tempfile
13import subprocess
14import sys
15import urlparse
16
17import gclient_utils
18import subcommand
19
20
21GIT_EXECUTABLE = 'git.bat' if sys.platform.startswith('win') else 'git'
22
23
agable@chromium.org5a306a22014-02-24 22:13:59 +000024def UrlToCacheDir(url):
25 """Convert a git url to a normalized form for the cache dir path."""
26 parsed = urlparse.urlparse(url)
27 norm_url = parsed.netloc + parsed.path
28 if norm_url.endswith('.git'):
29 norm_url = norm_url[:-len('.git')]
30 return norm_url.replace('-', '--').replace('/', '-').lower()
31
32
33def RunGit(cmd, **kwargs):
34 """Run git in a subprocess."""
35 kwargs.setdefault('cwd', os.getcwd())
36 if kwargs.get('filter_fn'):
37 kwargs['filter_fn'] = gclient_utils.GitFilter(kwargs.get('filter_fn'))
38 kwargs.setdefault('print_stdout', False)
39 env = kwargs.get('env') or kwargs.setdefault('env', os.environ.copy())
40 env.setdefault('GIT_ASKPASS', 'true')
41 env.setdefault('SSH_ASKPASS', 'true')
42 else:
43 kwargs.setdefault('print_stdout', True)
44 stdout = kwargs.get('stdout', sys.stdout)
45 print >> stdout, 'running "git %s" in "%s"' % (' '.join(cmd), kwargs['cwd'])
46 gclient_utils.CheckCallAndFilter([GIT_EXECUTABLE] + cmd, **kwargs)
47
48
49class LockError(Exception):
50 pass
51
52
53class Lockfile(object):
54 """Class to represent a cross-platform process-specific lockfile."""
55
56 def __init__(self, path):
57 self.path = os.path.abspath(path)
58 self.lockfile = self.path + ".lock"
59 self.pid = os.getpid()
60
61 def _read_pid(self):
62 """Read the pid stored in the lockfile.
63
64 Note: This method is potentially racy. By the time it returns the lockfile
65 may have been unlocked, removed, or stolen by some other process.
66 """
67 try:
68 with open(self.lockfile, 'r') as f:
69 pid = int(f.readline().strip())
70 except (IOError, ValueError):
71 pid = None
72 return pid
73
74 def _make_lockfile(self):
75 """Safely creates a lockfile containing the current pid."""
76 open_flags = (os.O_CREAT | os.O_EXCL | os.O_WRONLY)
77 fd = os.open(self.lockfile, open_flags, 0o644)
78 f = os.fdopen(fd, 'w')
79 print >> f, self.pid
80 f.close()
81
82 def _remove_lockfile(self):
83 """Delete the lockfile. Complains (implicitly) if it doesn't exist."""
84 os.remove(self.lockfile)
85
86 def lock(self):
87 """Acquire the lock.
88
89 Note: This is a NON-BLOCKING FAIL-FAST operation.
90 Do. Or do not. There is no try.
91 """
92 try:
93 self._make_lockfile()
94 except OSError as e:
95 if e.errno == errno.EEXIST:
96 raise LockError("%s is already locked" % self.path)
97 else:
98 raise LockError("Failed to create %s (err %s)" % (self.path, e.errno))
99
100 def unlock(self):
101 """Release the lock."""
102 if not self.is_locked():
103 raise LockError("%s is not locked" % self.path)
104 if not self.i_am_locking():
105 raise LockError("%s is locked, but not by me" % self.path)
106 self._remove_lockfile()
107
108 def break_lock(self):
109 """Remove the lock, even if it was created by someone else."""
110 try:
111 self._remove_lockfile()
112 return True
113 except OSError as exc:
114 if exc.errno == errno.ENOENT:
115 return False
116 else:
117 raise
118
119 def is_locked(self):
120 """Test if the file is locked by anyone.
121
122 Note: This method is potentially racy. By the time it returns the lockfile
123 may have been unlocked, removed, or stolen by some other process.
124 """
125 return os.path.exists(self.lockfile)
126
127 def i_am_locking(self):
128 """Test if the file is locked by this process."""
129 return self.is_locked() and self.pid == self._read_pid()
130
131 def __enter__(self):
132 self.lock()
133 return self
134
135 def __exit__(self, *_exc):
136 self.unlock()
137
138
139@subcommand.usage('[url of repo to check for caching]')
140def CMDexists(parser, args):
141 """Check to see if there already is a cache of the given repo."""
142 options, args = parser.parse_args(args)
143 if not len(args) == 1:
144 parser.error('git cache exists only takes exactly one repo url.')
145 url = args[0]
146 repo_dir = os.path.join(options.cache_dir, UrlToCacheDir(url))
147 flag_file = os.path.join(repo_dir, 'config')
148 if os.path.isdir(repo_dir) and os.path.isfile(flag_file):
149 print repo_dir
150 return 0
151 return 1
152
153
154@subcommand.usage('[url of repo to add to or update in cache]')
155def CMDpopulate(parser, args):
156 """Ensure that the cache has all up-to-date objects for the given repo."""
157 parser.add_option('--depth', type='int',
158 help='Only cache DEPTH commits of history')
159 parser.add_option('--shallow', '-s', action='store_true',
160 help='Only cache 10000 commits of history')
161 parser.add_option('--ref', action='append',
162 help='Specify additional refs to be fetched')
163 options, args = parser.parse_args(args)
164 if options.shallow and not options.depth:
165 options.depth = 10000
166 if not len(args) == 1:
167 parser.error('git cache populate only takes exactly one repo url.')
168 url = args[0]
169
170 gclient_utils.safe_makedirs(options.cache_dir)
171 repo_dir = os.path.join(options.cache_dir, UrlToCacheDir(url))
172
173 v = []
174 filter_fn = lambda l: '[up to date]' not in l
175 if options.verbose:
176 v = ['-v', '--progress']
177 filter_fn = None
178
179 d = []
180 if options.depth:
181 d = ['--depth', '%d' % options.depth]
182
183 def _config(directory):
szager@chromium.orgfc616382014-03-18 20:32:04 +0000184 RunGit(['config', 'core.deltaBaseCacheLimit',
185 gclient_utils.DefaultDeltaBaseCacheLimit()], cwd=directory)
agable@chromium.org99f9c922014-03-12 01:43:39 +0000186 RunGit(['config', 'remote.origin.url', url],
agable@chromium.org5a306a22014-02-24 22:13:59 +0000187 cwd=directory)
188 RunGit(['config', '--replace-all', 'remote.origin.fetch',
189 '+refs/heads/*:refs/heads/*'],
190 cwd=directory)
hinoka@chromium.orgfc330cb2014-02-27 21:33:52 +0000191 RunGit(['config', '--add', 'remote.origin.fetch',
192 '+refs/tags/*:refs/tags/*'],
193 cwd=directory)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000194 for ref in options.ref or []:
195 ref = ref.rstrip('/')
196 refspec = '+refs/%s/*:refs/%s/*' % (ref, ref)
197 RunGit(['config', '--add', 'remote.origin.fetch', refspec],
198 cwd=directory)
199
200 with Lockfile(repo_dir):
201 # Setup from scratch if the repo is new or is in a bad state.
202 if not os.path.exists(os.path.join(repo_dir, 'config')):
203 gclient_utils.rmtree(repo_dir)
204 tempdir = tempfile.mkdtemp(suffix=UrlToCacheDir(url),
205 dir=options.cache_dir)
206 RunGit(['init', '--bare'], cwd=tempdir)
207 _config(tempdir)
hinoka@chromium.orgfc330cb2014-02-27 21:33:52 +0000208 fetch_cmd = ['fetch'] + v + d + ['origin']
agable@chromium.org5a306a22014-02-24 22:13:59 +0000209 RunGit(fetch_cmd, filter_fn=filter_fn, cwd=tempdir, retry=True)
210 os.rename(tempdir, repo_dir)
211 else:
212 _config(repo_dir)
213 if options.depth and os.path.exists(os.path.join(repo_dir, 'shallow')):
214 logging.warn('Shallow fetch requested, but repo cache already exists.')
hinoka@chromium.orgfc330cb2014-02-27 21:33:52 +0000215 fetch_cmd = ['fetch'] + v + ['origin']
agable@chromium.org5a306a22014-02-24 22:13:59 +0000216 RunGit(fetch_cmd, filter_fn=filter_fn, cwd=repo_dir, retry=True)
217
218
219@subcommand.usage('[url of repo to unlock, or -a|--all]')
220def CMDunlock(parser, args):
221 """Unlock one or all repos if their lock files are still around."""
222 parser.add_option('--force', '-f', action='store_true',
223 help='Actually perform the action')
224 parser.add_option('--all', '-a', action='store_true',
225 help='Unlock all repository caches')
226 options, args = parser.parse_args(args)
227 if len(args) > 1 or (len(args) == 0 and not options.all):
228 parser.error('git cache unlock takes exactly one repo url, or --all')
229
230 if not options.all:
231 url = args[0]
232 repo_dirs = [os.path.join(options.cache_dir, UrlToCacheDir(url))]
233 else:
hinoka@google.com267f33e2014-02-28 22:02:32 +0000234 repo_dirs = [os.path.join(options.cache_dir, path)
235 for path in os.listdir(options.cache_dir)
236 if os.path.isdir(os.path.join(options.cache_dir, path))]
hinoka@google.comb16a1652014-03-05 20:22:00 +0000237 repo_dirs.extend([os.path.join(options.cache_dir,
238 lockfile.replace('.lock', ''))
239 for lockfile in os.listdir(options.cache_dir)
240 if os.path.isfile(os.path.join(options.cache_dir,
241 lockfile))
242 and lockfile.endswith('.lock')
243 and os.path.join(options.cache_dir, lockfile)
244 not in repo_dirs])
agable@chromium.org5a306a22014-02-24 22:13:59 +0000245 lockfiles = [repo_dir + '.lock' for repo_dir in repo_dirs
246 if os.path.exists(repo_dir + '.lock')]
247
248 if not options.force:
249 parser.error('git cache unlock requires -f|--force to do anything. '
250 'Refusing to unlock the following repo caches: '
251 ', '.join(lockfiles))
252
253 unlocked = []
254 untouched = []
255 for repo_dir in repo_dirs:
256 lf = Lockfile(repo_dir)
hinoka@google.comb16a1652014-03-05 20:22:00 +0000257 config_lock = os.path.join(repo_dir, 'config.lock')
258 unlocked = False
259 if os.path.exists(config_lock):
260 os.remove(config_lock)
261 unlocked = True
agable@chromium.org5a306a22014-02-24 22:13:59 +0000262 if lf.break_lock():
hinoka@google.comb16a1652014-03-05 20:22:00 +0000263 unlocked = True
264
265 if unlocked:
266 unlocked.append(repo_dir)
agable@chromium.org5a306a22014-02-24 22:13:59 +0000267 else:
268 untouched.append(repo_dir)
269
270 if unlocked:
271 logging.info('Broke locks on these caches: %s' % unlocked)
272 if untouched:
273 logging.debug('Did not touch these caches: %s' % untouched)
274
275
276class OptionParser(optparse.OptionParser):
277 """Wrapper class for OptionParser to handle global options."""
278
279 def __init__(self, *args, **kwargs):
280 optparse.OptionParser.__init__(self, *args, prog='git cache', **kwargs)
281 self.add_option('-c', '--cache-dir',
282 help='Path to the directory containing the cache')
283 self.add_option('-v', '--verbose', action='count', default=0,
284 help='Increase verbosity (can be passed multiple times)')
285
286 def parse_args(self, args=None, values=None):
287 options, args = optparse.OptionParser.parse_args(self, args, values)
288
289 try:
290 global_cache_dir = subprocess.check_output(
291 [GIT_EXECUTABLE, 'config', '--global', 'cache.cachepath']).strip()
292 if options.cache_dir:
293 logging.warn('Overriding globally-configured cache directory.')
294 else:
295 options.cache_dir = global_cache_dir
296 except subprocess.CalledProcessError:
297 if not options.cache_dir:
298 self.error('No cache directory specified on command line '
299 'or in cache.cachepath.')
300 options.cache_dir = os.path.abspath(options.cache_dir)
301
302 levels = [logging.WARNING, logging.INFO, logging.DEBUG]
303 logging.basicConfig(level=levels[min(options.verbose, len(levels) - 1)])
304
305 return options, args
306
307
308def main(argv):
309 dispatcher = subcommand.CommandDispatcher(__name__)
310 return dispatcher.execute(OptionParser(), argv)
311
312
313if __name__ == '__main__':
314 sys.exit(main(sys.argv[1:]))