##// END OF EJS Templates
with: use context manager in maybeperformlegacystreamclone
Bryan O'Sullivan -
r27850:49cfddbf default
parent child Browse files
Show More
@@ -1,384 +1,381 b''
1 # streamclone.py - producing and consuming streaming repository data
1 # streamclone.py - producing and consuming streaming repository data
2 #
2 #
3 # Copyright 2015 Gregory Szorc <gregory.szorc@gmail.com>
3 # Copyright 2015 Gregory Szorc <gregory.szorc@gmail.com>
4 #
4 #
5 # This software may be used and distributed according to the terms of the
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2 or any later version.
6 # GNU General Public License version 2 or any later version.
7
7
8 from __future__ import absolute_import
8 from __future__ import absolute_import
9
9
10 import struct
10 import struct
11 import time
11 import time
12
12
13 from .i18n import _
13 from .i18n import _
14 from . import (
14 from . import (
15 branchmap,
15 branchmap,
16 error,
16 error,
17 store,
17 store,
18 util,
18 util,
19 )
19 )
20
20
21 def canperformstreamclone(pullop, bailifbundle2supported=False):
21 def canperformstreamclone(pullop, bailifbundle2supported=False):
22 """Whether it is possible to perform a streaming clone as part of pull.
22 """Whether it is possible to perform a streaming clone as part of pull.
23
23
24 ``bailifbundle2supported`` will cause the function to return False if
24 ``bailifbundle2supported`` will cause the function to return False if
25 bundle2 stream clones are supported. It should only be called by the
25 bundle2 stream clones are supported. It should only be called by the
26 legacy stream clone code path.
26 legacy stream clone code path.
27
27
28 Returns a tuple of (supported, requirements). ``supported`` is True if
28 Returns a tuple of (supported, requirements). ``supported`` is True if
29 streaming clone is supported and False otherwise. ``requirements`` is
29 streaming clone is supported and False otherwise. ``requirements`` is
30 a set of repo requirements from the remote, or ``None`` if stream clone
30 a set of repo requirements from the remote, or ``None`` if stream clone
31 isn't supported.
31 isn't supported.
32 """
32 """
33 repo = pullop.repo
33 repo = pullop.repo
34 remote = pullop.remote
34 remote = pullop.remote
35
35
36 bundle2supported = False
36 bundle2supported = False
37 if pullop.canusebundle2:
37 if pullop.canusebundle2:
38 if 'v1' in pullop.remotebundle2caps.get('stream', []):
38 if 'v1' in pullop.remotebundle2caps.get('stream', []):
39 bundle2supported = True
39 bundle2supported = True
40 # else
40 # else
41 # Server doesn't support bundle2 stream clone or doesn't support
41 # Server doesn't support bundle2 stream clone or doesn't support
42 # the versions we support. Fall back and possibly allow legacy.
42 # the versions we support. Fall back and possibly allow legacy.
43
43
44 # Ensures legacy code path uses available bundle2.
44 # Ensures legacy code path uses available bundle2.
45 if bailifbundle2supported and bundle2supported:
45 if bailifbundle2supported and bundle2supported:
46 return False, None
46 return False, None
47 # Ensures bundle2 doesn't try to do a stream clone if it isn't supported.
47 # Ensures bundle2 doesn't try to do a stream clone if it isn't supported.
48 #elif not bailifbundle2supported and not bundle2supported:
48 #elif not bailifbundle2supported and not bundle2supported:
49 # return False, None
49 # return False, None
50
50
51 # Streaming clone only works on empty repositories.
51 # Streaming clone only works on empty repositories.
52 if len(repo):
52 if len(repo):
53 return False, None
53 return False, None
54
54
55 # Streaming clone only works if all data is being requested.
55 # Streaming clone only works if all data is being requested.
56 if pullop.heads:
56 if pullop.heads:
57 return False, None
57 return False, None
58
58
59 streamrequested = pullop.streamclonerequested
59 streamrequested = pullop.streamclonerequested
60
60
61 # If we don't have a preference, let the server decide for us. This
61 # If we don't have a preference, let the server decide for us. This
62 # likely only comes into play in LANs.
62 # likely only comes into play in LANs.
63 if streamrequested is None:
63 if streamrequested is None:
64 # The server can advertise whether to prefer streaming clone.
64 # The server can advertise whether to prefer streaming clone.
65 streamrequested = remote.capable('stream-preferred')
65 streamrequested = remote.capable('stream-preferred')
66
66
67 if not streamrequested:
67 if not streamrequested:
68 return False, None
68 return False, None
69
69
70 # In order for stream clone to work, the client has to support all the
70 # In order for stream clone to work, the client has to support all the
71 # requirements advertised by the server.
71 # requirements advertised by the server.
72 #
72 #
73 # The server advertises its requirements via the "stream" and "streamreqs"
73 # The server advertises its requirements via the "stream" and "streamreqs"
74 # capability. "stream" (a value-less capability) is advertised if and only
74 # capability. "stream" (a value-less capability) is advertised if and only
75 # if the only requirement is "revlogv1." Else, the "streamreqs" capability
75 # if the only requirement is "revlogv1." Else, the "streamreqs" capability
76 # is advertised and contains a comma-delimited list of requirements.
76 # is advertised and contains a comma-delimited list of requirements.
77 requirements = set()
77 requirements = set()
78 if remote.capable('stream'):
78 if remote.capable('stream'):
79 requirements.add('revlogv1')
79 requirements.add('revlogv1')
80 else:
80 else:
81 streamreqs = remote.capable('streamreqs')
81 streamreqs = remote.capable('streamreqs')
82 # This is weird and shouldn't happen with modern servers.
82 # This is weird and shouldn't happen with modern servers.
83 if not streamreqs:
83 if not streamreqs:
84 return False, None
84 return False, None
85
85
86 streamreqs = set(streamreqs.split(','))
86 streamreqs = set(streamreqs.split(','))
87 # Server requires something we don't support. Bail.
87 # Server requires something we don't support. Bail.
88 if streamreqs - repo.supportedformats:
88 if streamreqs - repo.supportedformats:
89 return False, None
89 return False, None
90 requirements = streamreqs
90 requirements = streamreqs
91
91
92 return True, requirements
92 return True, requirements
93
93
94 def maybeperformlegacystreamclone(pullop):
94 def maybeperformlegacystreamclone(pullop):
95 """Possibly perform a legacy stream clone operation.
95 """Possibly perform a legacy stream clone operation.
96
96
97 Legacy stream clones are performed as part of pull but before all other
97 Legacy stream clones are performed as part of pull but before all other
98 operations.
98 operations.
99
99
100 A legacy stream clone will not be performed if a bundle2 stream clone is
100 A legacy stream clone will not be performed if a bundle2 stream clone is
101 supported.
101 supported.
102 """
102 """
103 supported, requirements = canperformstreamclone(pullop)
103 supported, requirements = canperformstreamclone(pullop)
104
104
105 if not supported:
105 if not supported:
106 return
106 return
107
107
108 repo = pullop.repo
108 repo = pullop.repo
109 remote = pullop.remote
109 remote = pullop.remote
110
110
111 # Save remote branchmap. We will use it later to speed up branchcache
111 # Save remote branchmap. We will use it later to speed up branchcache
112 # creation.
112 # creation.
113 rbranchmap = None
113 rbranchmap = None
114 if remote.capable('branchmap'):
114 if remote.capable('branchmap'):
115 rbranchmap = remote.branchmap()
115 rbranchmap = remote.branchmap()
116
116
117 repo.ui.status(_('streaming all changes\n'))
117 repo.ui.status(_('streaming all changes\n'))
118
118
119 fp = remote.stream_out()
119 fp = remote.stream_out()
120 l = fp.readline()
120 l = fp.readline()
121 try:
121 try:
122 resp = int(l)
122 resp = int(l)
123 except ValueError:
123 except ValueError:
124 raise error.ResponseError(
124 raise error.ResponseError(
125 _('unexpected response from remote server:'), l)
125 _('unexpected response from remote server:'), l)
126 if resp == 1:
126 if resp == 1:
127 raise error.Abort(_('operation forbidden by server'))
127 raise error.Abort(_('operation forbidden by server'))
128 elif resp == 2:
128 elif resp == 2:
129 raise error.Abort(_('locking the remote repository failed'))
129 raise error.Abort(_('locking the remote repository failed'))
130 elif resp != 0:
130 elif resp != 0:
131 raise error.Abort(_('the server sent an unknown error code'))
131 raise error.Abort(_('the server sent an unknown error code'))
132
132
133 l = fp.readline()
133 l = fp.readline()
134 try:
134 try:
135 filecount, bytecount = map(int, l.split(' ', 1))
135 filecount, bytecount = map(int, l.split(' ', 1))
136 except (ValueError, TypeError):
136 except (ValueError, TypeError):
137 raise error.ResponseError(
137 raise error.ResponseError(
138 _('unexpected response from remote server:'), l)
138 _('unexpected response from remote server:'), l)
139
139
140 lock = repo.lock()
140 with repo.lock():
141 try:
142 consumev1(repo, fp, filecount, bytecount)
141 consumev1(repo, fp, filecount, bytecount)
143
142
144 # new requirements = old non-format requirements +
143 # new requirements = old non-format requirements +
145 # new format-related remote requirements
144 # new format-related remote requirements
146 # requirements from the streamed-in repository
145 # requirements from the streamed-in repository
147 repo.requirements = requirements | (
146 repo.requirements = requirements | (
148 repo.requirements - repo.supportedformats)
147 repo.requirements - repo.supportedformats)
149 repo._applyopenerreqs()
148 repo._applyopenerreqs()
150 repo._writerequirements()
149 repo._writerequirements()
151
150
152 if rbranchmap:
151 if rbranchmap:
153 branchmap.replacecache(repo, rbranchmap)
152 branchmap.replacecache(repo, rbranchmap)
154
153
155 repo.invalidate()
154 repo.invalidate()
156 finally:
157 lock.release()
158
155
159 def allowservergeneration(ui):
156 def allowservergeneration(ui):
160 """Whether streaming clones are allowed from the server."""
157 """Whether streaming clones are allowed from the server."""
161 return ui.configbool('server', 'uncompressed', True, untrusted=True)
158 return ui.configbool('server', 'uncompressed', True, untrusted=True)
162
159
163 # This is it's own function so extensions can override it.
160 # This is it's own function so extensions can override it.
164 def _walkstreamfiles(repo):
161 def _walkstreamfiles(repo):
165 return repo.store.walk()
162 return repo.store.walk()
166
163
167 def generatev1(repo):
164 def generatev1(repo):
168 """Emit content for version 1 of a streaming clone.
165 """Emit content for version 1 of a streaming clone.
169
166
170 This returns a 3-tuple of (file count, byte size, data iterator).
167 This returns a 3-tuple of (file count, byte size, data iterator).
171
168
172 The data iterator consists of N entries for each file being transferred.
169 The data iterator consists of N entries for each file being transferred.
173 Each file entry starts as a line with the file name and integer size
170 Each file entry starts as a line with the file name and integer size
174 delimited by a null byte.
171 delimited by a null byte.
175
172
176 The raw file data follows. Following the raw file data is the next file
173 The raw file data follows. Following the raw file data is the next file
177 entry, or EOF.
174 entry, or EOF.
178
175
179 When used on the wire protocol, an additional line indicating protocol
176 When used on the wire protocol, an additional line indicating protocol
180 success will be prepended to the stream. This function is not responsible
177 success will be prepended to the stream. This function is not responsible
181 for adding it.
178 for adding it.
182
179
183 This function will obtain a repository lock to ensure a consistent view of
180 This function will obtain a repository lock to ensure a consistent view of
184 the store is captured. It therefore may raise LockError.
181 the store is captured. It therefore may raise LockError.
185 """
182 """
186 entries = []
183 entries = []
187 total_bytes = 0
184 total_bytes = 0
188 # Get consistent snapshot of repo, lock during scan.
185 # Get consistent snapshot of repo, lock during scan.
189 with repo.lock():
186 with repo.lock():
190 repo.ui.debug('scanning\n')
187 repo.ui.debug('scanning\n')
191 for name, ename, size in _walkstreamfiles(repo):
188 for name, ename, size in _walkstreamfiles(repo):
192 if size:
189 if size:
193 entries.append((name, size))
190 entries.append((name, size))
194 total_bytes += size
191 total_bytes += size
195
192
196 repo.ui.debug('%d files, %d bytes to transfer\n' %
193 repo.ui.debug('%d files, %d bytes to transfer\n' %
197 (len(entries), total_bytes))
194 (len(entries), total_bytes))
198
195
199 svfs = repo.svfs
196 svfs = repo.svfs
200 oldaudit = svfs.mustaudit
197 oldaudit = svfs.mustaudit
201 debugflag = repo.ui.debugflag
198 debugflag = repo.ui.debugflag
202 svfs.mustaudit = False
199 svfs.mustaudit = False
203
200
204 def emitrevlogdata():
201 def emitrevlogdata():
205 try:
202 try:
206 for name, size in entries:
203 for name, size in entries:
207 if debugflag:
204 if debugflag:
208 repo.ui.debug('sending %s (%d bytes)\n' % (name, size))
205 repo.ui.debug('sending %s (%d bytes)\n' % (name, size))
209 # partially encode name over the wire for backwards compat
206 # partially encode name over the wire for backwards compat
210 yield '%s\0%d\n' % (store.encodedir(name), size)
207 yield '%s\0%d\n' % (store.encodedir(name), size)
211 if size <= 65536:
208 if size <= 65536:
212 yield svfs.read(name)
209 yield svfs.read(name)
213 else:
210 else:
214 for chunk in util.filechunkiter(svfs(name), limit=size):
211 for chunk in util.filechunkiter(svfs(name), limit=size):
215 yield chunk
212 yield chunk
216 finally:
213 finally:
217 svfs.mustaudit = oldaudit
214 svfs.mustaudit = oldaudit
218
215
219 return len(entries), total_bytes, emitrevlogdata()
216 return len(entries), total_bytes, emitrevlogdata()
220
217
221 def generatev1wireproto(repo):
218 def generatev1wireproto(repo):
222 """Emit content for version 1 of streaming clone suitable for the wire.
219 """Emit content for version 1 of streaming clone suitable for the wire.
223
220
224 This is the data output from ``generatev1()`` with a header line
221 This is the data output from ``generatev1()`` with a header line
225 indicating file count and byte size.
222 indicating file count and byte size.
226 """
223 """
227 filecount, bytecount, it = generatev1(repo)
224 filecount, bytecount, it = generatev1(repo)
228 yield '%d %d\n' % (filecount, bytecount)
225 yield '%d %d\n' % (filecount, bytecount)
229 for chunk in it:
226 for chunk in it:
230 yield chunk
227 yield chunk
231
228
232 def generatebundlev1(repo, compression='UN'):
229 def generatebundlev1(repo, compression='UN'):
233 """Emit content for version 1 of a stream clone bundle.
230 """Emit content for version 1 of a stream clone bundle.
234
231
235 The first 4 bytes of the output ("HGS1") denote this as stream clone
232 The first 4 bytes of the output ("HGS1") denote this as stream clone
236 bundle version 1.
233 bundle version 1.
237
234
238 The next 2 bytes indicate the compression type. Only "UN" is currently
235 The next 2 bytes indicate the compression type. Only "UN" is currently
239 supported.
236 supported.
240
237
241 The next 16 bytes are two 64-bit big endian unsigned integers indicating
238 The next 16 bytes are two 64-bit big endian unsigned integers indicating
242 file count and byte count, respectively.
239 file count and byte count, respectively.
243
240
244 The next 2 bytes is a 16-bit big endian unsigned short declaring the length
241 The next 2 bytes is a 16-bit big endian unsigned short declaring the length
245 of the requirements string, including a trailing \0. The following N bytes
242 of the requirements string, including a trailing \0. The following N bytes
246 are the requirements string, which is ASCII containing a comma-delimited
243 are the requirements string, which is ASCII containing a comma-delimited
247 list of repo requirements that are needed to support the data.
244 list of repo requirements that are needed to support the data.
248
245
249 The remaining content is the output of ``generatev1()`` (which may be
246 The remaining content is the output of ``generatev1()`` (which may be
250 compressed in the future).
247 compressed in the future).
251
248
252 Returns a tuple of (requirements, data generator).
249 Returns a tuple of (requirements, data generator).
253 """
250 """
254 if compression != 'UN':
251 if compression != 'UN':
255 raise ValueError('we do not support the compression argument yet')
252 raise ValueError('we do not support the compression argument yet')
256
253
257 requirements = repo.requirements & repo.supportedformats
254 requirements = repo.requirements & repo.supportedformats
258 requires = ','.join(sorted(requirements))
255 requires = ','.join(sorted(requirements))
259
256
260 def gen():
257 def gen():
261 yield 'HGS1'
258 yield 'HGS1'
262 yield compression
259 yield compression
263
260
264 filecount, bytecount, it = generatev1(repo)
261 filecount, bytecount, it = generatev1(repo)
265 repo.ui.status(_('writing %d bytes for %d files\n') %
262 repo.ui.status(_('writing %d bytes for %d files\n') %
266 (bytecount, filecount))
263 (bytecount, filecount))
267
264
268 yield struct.pack('>QQ', filecount, bytecount)
265 yield struct.pack('>QQ', filecount, bytecount)
269 yield struct.pack('>H', len(requires) + 1)
266 yield struct.pack('>H', len(requires) + 1)
270 yield requires + '\0'
267 yield requires + '\0'
271
268
272 # This is where we'll add compression in the future.
269 # This is where we'll add compression in the future.
273 assert compression == 'UN'
270 assert compression == 'UN'
274
271
275 seen = 0
272 seen = 0
276 repo.ui.progress(_('bundle'), 0, total=bytecount)
273 repo.ui.progress(_('bundle'), 0, total=bytecount)
277
274
278 for chunk in it:
275 for chunk in it:
279 seen += len(chunk)
276 seen += len(chunk)
280 repo.ui.progress(_('bundle'), seen, total=bytecount)
277 repo.ui.progress(_('bundle'), seen, total=bytecount)
281 yield chunk
278 yield chunk
282
279
283 repo.ui.progress(_('bundle'), None)
280 repo.ui.progress(_('bundle'), None)
284
281
285 return requirements, gen()
282 return requirements, gen()
286
283
287 def consumev1(repo, fp, filecount, bytecount):
284 def consumev1(repo, fp, filecount, bytecount):
288 """Apply the contents from version 1 of a streaming clone file handle.
285 """Apply the contents from version 1 of a streaming clone file handle.
289
286
290 This takes the output from "streamout" and applies it to the specified
287 This takes the output from "streamout" and applies it to the specified
291 repository.
288 repository.
292
289
293 Like "streamout," the status line added by the wire protocol is not handled
290 Like "streamout," the status line added by the wire protocol is not handled
294 by this function.
291 by this function.
295 """
292 """
296 lock = repo.lock()
293 lock = repo.lock()
297 try:
294 try:
298 repo.ui.status(_('%d files to transfer, %s of data\n') %
295 repo.ui.status(_('%d files to transfer, %s of data\n') %
299 (filecount, util.bytecount(bytecount)))
296 (filecount, util.bytecount(bytecount)))
300 handled_bytes = 0
297 handled_bytes = 0
301 repo.ui.progress(_('clone'), 0, total=bytecount)
298 repo.ui.progress(_('clone'), 0, total=bytecount)
302 start = time.time()
299 start = time.time()
303
300
304 tr = repo.transaction('clone')
301 tr = repo.transaction('clone')
305 try:
302 try:
306 for i in xrange(filecount):
303 for i in xrange(filecount):
307 # XXX doesn't support '\n' or '\r' in filenames
304 # XXX doesn't support '\n' or '\r' in filenames
308 l = fp.readline()
305 l = fp.readline()
309 try:
306 try:
310 name, size = l.split('\0', 1)
307 name, size = l.split('\0', 1)
311 size = int(size)
308 size = int(size)
312 except (ValueError, TypeError):
309 except (ValueError, TypeError):
313 raise error.ResponseError(
310 raise error.ResponseError(
314 _('unexpected response from remote server:'), l)
311 _('unexpected response from remote server:'), l)
315 if repo.ui.debugflag:
312 if repo.ui.debugflag:
316 repo.ui.debug('adding %s (%s)\n' %
313 repo.ui.debug('adding %s (%s)\n' %
317 (name, util.bytecount(size)))
314 (name, util.bytecount(size)))
318 # for backwards compat, name was partially encoded
315 # for backwards compat, name was partially encoded
319 with repo.svfs(store.decodedir(name), 'w') as ofp:
316 with repo.svfs(store.decodedir(name), 'w') as ofp:
320 for chunk in util.filechunkiter(fp, limit=size):
317 for chunk in util.filechunkiter(fp, limit=size):
321 handled_bytes += len(chunk)
318 handled_bytes += len(chunk)
322 repo.ui.progress(_('clone'), handled_bytes,
319 repo.ui.progress(_('clone'), handled_bytes,
323 total=bytecount)
320 total=bytecount)
324 ofp.write(chunk)
321 ofp.write(chunk)
325 tr.close()
322 tr.close()
326 finally:
323 finally:
327 tr.release()
324 tr.release()
328
325
329 # Writing straight to files circumvented the inmemory caches
326 # Writing straight to files circumvented the inmemory caches
330 repo.invalidate()
327 repo.invalidate()
331
328
332 elapsed = time.time() - start
329 elapsed = time.time() - start
333 if elapsed <= 0:
330 if elapsed <= 0:
334 elapsed = 0.001
331 elapsed = 0.001
335 repo.ui.progress(_('clone'), None)
332 repo.ui.progress(_('clone'), None)
336 repo.ui.status(_('transferred %s in %.1f seconds (%s/sec)\n') %
333 repo.ui.status(_('transferred %s in %.1f seconds (%s/sec)\n') %
337 (util.bytecount(bytecount), elapsed,
334 (util.bytecount(bytecount), elapsed,
338 util.bytecount(bytecount / elapsed)))
335 util.bytecount(bytecount / elapsed)))
339 finally:
336 finally:
340 lock.release()
337 lock.release()
341
338
342 def applybundlev1(repo, fp):
339 def applybundlev1(repo, fp):
343 """Apply the content from a stream clone bundle version 1.
340 """Apply the content from a stream clone bundle version 1.
344
341
345 We assume the 4 byte header has been read and validated and the file handle
342 We assume the 4 byte header has been read and validated and the file handle
346 is at the 2 byte compression identifier.
343 is at the 2 byte compression identifier.
347 """
344 """
348 if len(repo):
345 if len(repo):
349 raise error.Abort(_('cannot apply stream clone bundle on non-empty '
346 raise error.Abort(_('cannot apply stream clone bundle on non-empty '
350 'repo'))
347 'repo'))
351
348
352 compression = fp.read(2)
349 compression = fp.read(2)
353 if compression != 'UN':
350 if compression != 'UN':
354 raise error.Abort(_('only uncompressed stream clone bundles are '
351 raise error.Abort(_('only uncompressed stream clone bundles are '
355 'supported; got %s') % compression)
352 'supported; got %s') % compression)
356
353
357 filecount, bytecount = struct.unpack('>QQ', fp.read(16))
354 filecount, bytecount = struct.unpack('>QQ', fp.read(16))
358 requireslen = struct.unpack('>H', fp.read(2))[0]
355 requireslen = struct.unpack('>H', fp.read(2))[0]
359 requires = fp.read(requireslen)
356 requires = fp.read(requireslen)
360
357
361 if not requires.endswith('\0'):
358 if not requires.endswith('\0'):
362 raise error.Abort(_('malformed stream clone bundle: '
359 raise error.Abort(_('malformed stream clone bundle: '
363 'requirements not properly encoded'))
360 'requirements not properly encoded'))
364
361
365 requirements = set(requires.rstrip('\0').split(','))
362 requirements = set(requires.rstrip('\0').split(','))
366 missingreqs = requirements - repo.supportedformats
363 missingreqs = requirements - repo.supportedformats
367 if missingreqs:
364 if missingreqs:
368 raise error.Abort(_('unable to apply stream clone: '
365 raise error.Abort(_('unable to apply stream clone: '
369 'unsupported format: %s') %
366 'unsupported format: %s') %
370 ', '.join(sorted(missingreqs)))
367 ', '.join(sorted(missingreqs)))
371
368
372 consumev1(repo, fp, filecount, bytecount)
369 consumev1(repo, fp, filecount, bytecount)
373
370
374 class streamcloneapplier(object):
371 class streamcloneapplier(object):
375 """Class to manage applying streaming clone bundles.
372 """Class to manage applying streaming clone bundles.
376
373
377 We need to wrap ``applybundlev1()`` in a dedicated type to enable bundle
374 We need to wrap ``applybundlev1()`` in a dedicated type to enable bundle
378 readers to perform bundle type-specific functionality.
375 readers to perform bundle type-specific functionality.
379 """
376 """
380 def __init__(self, fh):
377 def __init__(self, fh):
381 self._fh = fh
378 self._fh = fh
382
379
383 def apply(self, repo):
380 def apply(self, repo):
384 return applybundlev1(repo, self._fh)
381 return applybundlev1(repo, self._fh)
General Comments 0
You need to be logged in to leave comments. Login now