##// END OF EJS Templates
convert: backout a7492fb2107b...
Patrick Mezard -
r7476:6644c111 default
parent child Browse files
Show More
@@ -1,1184 +1,1167
1 1 # Subversion 1.4/1.5 Python API backend
2 2 #
3 3 # Copyright(C) 2007 Daniel Holth et al
4 4 #
5 5 # Configuration options:
6 6 #
7 7 # convert.svn.trunk
8 8 # Relative path to the trunk (default: "trunk")
9 9 # convert.svn.branches
10 10 # Relative path to tree of branches (default: "branches")
11 11 # convert.svn.tags
12 12 # Relative path to tree of tags (default: "tags")
13 13 #
14 14 # Set these in a hgrc, or on the command line as follows:
15 15 #
16 16 # hg convert --config convert.svn.trunk=wackoname [...]
17 17
18 18 import locale
19 19 import os
20 20 import re
21 21 import sys
22 22 import cPickle as pickle
23 23 import tempfile
24 24 import urllib
25 25
26 26 from mercurial import strutil, util
27 27 from mercurial.i18n import _
28 28
29 29 # Subversion stuff. Works best with very recent Python SVN bindings
30 30 # e.g. SVN 1.5 or backports. Thanks to the bzr folks for enhancing
31 31 # these bindings.
32 32
33 33 from cStringIO import StringIO
34 34
35 35 from common import NoRepo, MissingTool, commit, encodeargs, decodeargs
36 36 from common import commandline, converter_source, converter_sink, mapfile
37 37
38 38 try:
39 39 from svn.core import SubversionException, Pool
40 40 import svn
41 41 import svn.client
42 42 import svn.core
43 43 import svn.ra
44 44 import svn.delta
45 45 import transport
46 46 except ImportError:
47 47 pass
48 48
49 49 class SvnPathNotFound(Exception):
50 50 pass
51 51
52 52 def geturl(path):
53 53 try:
54 54 return svn.client.url_from_path(svn.core.svn_path_canonicalize(path))
55 55 except SubversionException:
56 56 pass
57 57 if os.path.isdir(path):
58 58 path = os.path.normpath(os.path.abspath(path))
59 59 if os.name == 'nt':
60 60 path = '/' + util.normpath(path)
61 61 return 'file://%s' % urllib.quote(path)
62 62 return path
63 63
64 64 def optrev(number):
65 65 optrev = svn.core.svn_opt_revision_t()
66 66 optrev.kind = svn.core.svn_opt_revision_number
67 67 optrev.value.number = number
68 68 return optrev
69 69
70 70 class changedpath(object):
71 71 def __init__(self, p):
72 72 self.copyfrom_path = p.copyfrom_path
73 73 self.copyfrom_rev = p.copyfrom_rev
74 74 self.action = p.action
75 75
76 76 def get_log_child(fp, url, paths, start, end, limit=0, discover_changed_paths=True,
77 77 strict_node_history=False):
78 78 protocol = -1
79 79 def receiver(orig_paths, revnum, author, date, message, pool):
80 80 if orig_paths is not None:
81 81 for k, v in orig_paths.iteritems():
82 82 orig_paths[k] = changedpath(v)
83 83 pickle.dump((orig_paths, revnum, author, date, message),
84 84 fp, protocol)
85 85
86 86 try:
87 87 # Use an ra of our own so that our parent can consume
88 88 # our results without confusing the server.
89 89 t = transport.SvnRaTransport(url=url)
90 90 svn.ra.get_log(t.ra, paths, start, end, limit,
91 91 discover_changed_paths,
92 92 strict_node_history,
93 93 receiver)
94 94 except SubversionException, (inst, num):
95 95 pickle.dump(num, fp, protocol)
96 96 except IOError:
97 97 # Caller may interrupt the iteration
98 98 pickle.dump(None, fp, protocol)
99 99 else:
100 100 pickle.dump(None, fp, protocol)
101 101 fp.close()
102 102 # With large history, cleanup process goes crazy and suddenly
103 103 # consumes *huge* amount of memory. The output file being closed,
104 104 # there is no need for clean termination.
105 105 os._exit(0)
106 106
107 107 def debugsvnlog(ui, **opts):
108 108 """Fetch SVN log in a subprocess and channel them back to parent to
109 109 avoid memory collection issues.
110 110 """
111 111 util.set_binary(sys.stdin)
112 112 util.set_binary(sys.stdout)
113 113 args = decodeargs(sys.stdin.read())
114 114 get_log_child(sys.stdout, *args)
115 115
116 116 class logstream:
117 117 """Interruptible revision log iterator."""
118 118 def __init__(self, stdout):
119 119 self._stdout = stdout
120 120
121 121 def __iter__(self):
122 122 while True:
123 123 entry = pickle.load(self._stdout)
124 124 try:
125 125 orig_paths, revnum, author, date, message = entry
126 126 except:
127 127 if entry is None:
128 128 break
129 129 raise SubversionException("child raised exception", entry)
130 130 yield entry
131 131
132 132 def close(self):
133 133 if self._stdout:
134 134 self._stdout.close()
135 135 self._stdout = None
136 136
137 137 # SVN conversion code stolen from bzr-svn and tailor
138 138 #
139 139 # Subversion looks like a versioned filesystem, branches structures
140 140 # are defined by conventions and not enforced by the tool. First,
141 141 # we define the potential branches (modules) as "trunk" and "branches"
142 142 # children directories. Revisions are then identified by their
143 143 # module and revision number (and a repository identifier).
144 144 #
145 145 # The revision graph is really a tree (or a forest). By default, a
146 146 # revision parent is the previous revision in the same module. If the
147 147 # module directory is copied/moved from another module then the
148 148 # revision is the module root and its parent the source revision in
149 149 # the parent module. A revision has at most one parent.
150 150 #
151 151 class svn_source(converter_source):
152 152 def __init__(self, ui, url, rev=None):
153 153 super(svn_source, self).__init__(ui, url, rev=rev)
154 154
155 155 try:
156 156 SubversionException
157 157 except NameError:
158 158 raise MissingTool(_('Subversion python bindings could not be loaded'))
159 159
160 160 try:
161 161 version = svn.core.SVN_VER_MAJOR, svn.core.SVN_VER_MINOR
162 162 if version < (1, 4):
163 163 raise MissingTool(_('Subversion python bindings %d.%d found, '
164 164 '1.4 or later required') % version)
165 165 except AttributeError:
166 166 raise MissingTool(_('Subversion python bindings are too old, 1.4 '
167 167 'or later required'))
168 168
169 169 self.encoding = locale.getpreferredencoding()
170 170 self.lastrevs = {}
171 171
172 172 latest = None
173 173 try:
174 174 # Support file://path@rev syntax. Useful e.g. to convert
175 175 # deleted branches.
176 176 at = url.rfind('@')
177 177 if at >= 0:
178 178 latest = int(url[at+1:])
179 179 url = url[:at]
180 180 except ValueError, e:
181 181 pass
182 182 self.url = geturl(url)
183 183 self.encoding = 'UTF-8' # Subversion is always nominal UTF-8
184 184 try:
185 185 self.transport = transport.SvnRaTransport(url=self.url)
186 186 self.ra = self.transport.ra
187 187 self.ctx = self.transport.client
188 188 self.baseurl = svn.ra.get_repos_root(self.ra)
189 189 # Module is either empty or a repository path starting with
190 190 # a slash and not ending with a slash.
191 191 self.module = urllib.unquote(self.url[len(self.baseurl):])
192 192 self.prevmodule = None
193 193 self.rootmodule = self.module
194 194 self.commits = {}
195 195 self.paths = {}
196 196 self.uuid = svn.ra.get_uuid(self.ra).decode(self.encoding)
197 197 except SubversionException, e:
198 198 ui.print_exc()
199 199 raise NoRepo("%s does not look like a Subversion repo" % self.url)
200 200
201 201 if rev:
202 202 try:
203 203 latest = int(rev)
204 204 except ValueError:
205 205 raise util.Abort(_('svn: revision %s is not an integer') % rev)
206 206
207 207 self.startrev = self.ui.config('convert', 'svn.startrev', default=0)
208 208 try:
209 209 self.startrev = int(self.startrev)
210 210 if self.startrev < 0:
211 211 self.startrev = 0
212 212 except ValueError:
213 213 raise util.Abort(_('svn: start revision %s is not an integer')
214 214 % self.startrev)
215 215
216 216 try:
217 217 self.get_blacklist()
218 218 except IOError, e:
219 219 pass
220 220
221 221 self.head = self.latest(self.module, latest)
222 222 if not self.head:
223 223 raise util.Abort(_('no revision found in module %s') %
224 224 self.module.encode(self.encoding))
225 225 self.last_changed = self.revnum(self.head)
226 226
227 227 self._changescache = None
228 228
229 229 if os.path.exists(os.path.join(url, '.svn/entries')):
230 230 self.wc = url
231 231 else:
232 232 self.wc = None
233 233 self.convertfp = None
234 234
235 235 def setrevmap(self, revmap):
236 236 lastrevs = {}
237 237 for revid in revmap.iterkeys():
238 238 uuid, module, revnum = self.revsplit(revid)
239 239 lastrevnum = lastrevs.setdefault(module, revnum)
240 240 if revnum > lastrevnum:
241 241 lastrevs[module] = revnum
242 242 self.lastrevs = lastrevs
243 243
244 244 def exists(self, path, optrev):
245 245 try:
246 246 svn.client.ls(self.url.rstrip('/') + '/' + urllib.quote(path),
247 247 optrev, False, self.ctx)
248 248 return True
249 249 except SubversionException, err:
250 250 return False
251 251
252 252 def getheads(self):
253 253
254 254 def isdir(path, revnum):
255 255 kind = self._checkpath(path, revnum)
256 256 return kind == svn.core.svn_node_dir
257 257
258 258 def getcfgpath(name, rev):
259 259 cfgpath = self.ui.config('convert', 'svn.' + name)
260 260 if cfgpath is not None and cfgpath.strip() == '':
261 261 return None
262 262 path = (cfgpath or name).strip('/')
263 263 if not self.exists(path, rev):
264 264 if cfgpath:
265 265 raise util.Abort(_('expected %s to be at %r, but not found')
266 266 % (name, path))
267 267 return None
268 268 self.ui.note(_('found %s at %r\n') % (name, path))
269 269 return path
270 270
271 271 rev = optrev(self.last_changed)
272 272 oldmodule = ''
273 273 trunk = getcfgpath('trunk', rev)
274 274 self.tags = getcfgpath('tags', rev)
275 275 branches = getcfgpath('branches', rev)
276 276
277 277 # If the project has a trunk or branches, we will extract heads
278 278 # from them. We keep the project root otherwise.
279 279 if trunk:
280 280 oldmodule = self.module or ''
281 281 self.module += '/' + trunk
282 282 self.head = self.latest(self.module, self.last_changed)
283 283 if not self.head:
284 284 raise util.Abort(_('no revision found in module %s') %
285 285 self.module.encode(self.encoding))
286 286
287 287 # First head in the list is the module's head
288 288 self.heads = [self.head]
289 289 if self.tags is not None:
290 290 self.tags = '%s/%s' % (oldmodule , (self.tags or 'tags'))
291 291
292 292 # Check if branches bring a few more heads to the list
293 293 if branches:
294 294 rpath = self.url.strip('/')
295 295 branchnames = svn.client.ls(rpath + '/' + urllib.quote(branches),
296 296 rev, False, self.ctx)
297 297 for branch in branchnames.keys():
298 298 module = '%s/%s/%s' % (oldmodule, branches, branch)
299 299 if not isdir(module, self.last_changed):
300 300 continue
301 301 brevid = self.latest(module, self.last_changed)
302 302 if not brevid:
303 303 self.ui.note(_('ignoring empty branch %s\n') %
304 304 branch.encode(self.encoding))
305 305 continue
306 306 self.ui.note(_('found branch %s at %d\n') %
307 307 (branch, self.revnum(brevid)))
308 308 self.heads.append(brevid)
309 309
310 310 if self.startrev and self.heads:
311 311 if len(self.heads) > 1:
312 312 raise util.Abort(_('svn: start revision is not supported with '
313 313 'with more than one branch'))
314 314 revnum = self.revnum(self.heads[0])
315 315 if revnum < self.startrev:
316 316 raise util.Abort(_('svn: no revision found after start revision %d')
317 317 % self.startrev)
318 318
319 319 return self.heads
320 320
321 321 def getfile(self, file, rev):
322 322 data, mode = self._getfile(file, rev)
323 323 self.modecache[(file, rev)] = mode
324 324 return data
325 325
326 326 def getmode(self, file, rev):
327 327 return self.modecache[(file, rev)]
328 328
329 329 def getchanges(self, rev):
330 330 if self._changescache and self._changescache[0] == rev:
331 331 return self._changescache[1]
332 332 self._changescache = None
333 333 self.modecache = {}
334 334 (paths, parents) = self.paths[rev]
335 335 if parents:
336 336 files, copies = self.expandpaths(rev, paths, parents)
337 337 else:
338 338 # Perform a full checkout on roots
339 339 uuid, module, revnum = self.revsplit(rev)
340 340 entries = svn.client.ls(self.baseurl + urllib.quote(module),
341 341 optrev(revnum), True, self.ctx)
342 342 files = [n for n,e in entries.iteritems()
343 343 if e.kind == svn.core.svn_node_file]
344 344 copies = {}
345 345
346 346 files.sort()
347 347 files = zip(files, [rev] * len(files))
348 348
349 349 # caller caches the result, so free it here to release memory
350 350 del self.paths[rev]
351 351 return (files, copies)
352 352
353 353 def getchangedfiles(self, rev, i):
354 354 changes = self.getchanges(rev)
355 355 self._changescache = (rev, changes)
356 356 return [f[0] for f in changes[0]]
357 357
358 358 def getcommit(self, rev):
359 359 if rev not in self.commits:
360 360 uuid, module, revnum = self.revsplit(rev)
361 361 self.module = module
362 362 self.reparent(module)
363 363 # We assume that:
364 364 # - requests for revisions after "stop" come from the
365 365 # revision graph backward traversal. Cache all of them
366 366 # down to stop, they will be used eventually.
367 367 # - requests for revisions before "stop" come to get
368 368 # isolated branches parents. Just fetch what is needed.
369 369 stop = self.lastrevs.get(module, 0)
370 370 if revnum < stop:
371 371 stop = revnum + 1
372 372 self._fetch_revisions(revnum, stop)
373 373 commit = self.commits[rev]
374 374 # caller caches the result, so free it here to release memory
375 375 del self.commits[rev]
376 376 return commit
377 377
378 378 def gettags(self):
379 379 tags = {}
380 380 if self.tags is None:
381 381 return tags
382 382
383 383 # svn tags are just a convention, project branches left in a
384 384 # 'tags' directory. There is no other relationship than
385 385 # ancestry, which is expensive to discover and makes them hard
386 386 # to update incrementally. Worse, past revisions may be
387 387 # referenced by tags far away in the future, requiring a deep
388 388 # history traversal on every calculation. Current code
389 389 # performs a single backward traversal, tracking moves within
390 390 # the tags directory (tag renaming) and recording a new tag
391 391 # everytime a project is copied from outside the tags
392 392 # directory. It also lists deleted tags, this behaviour may
393 393 # change in the future.
394 394 pendings = []
395 395 tagspath = self.tags
396 396 start = svn.ra.get_latest_revnum(self.ra)
397 397 try:
398 398 for entry in self._getlog([self.tags], start, self.startrev):
399 399 origpaths, revnum, author, date, message = entry
400 400 copies = [(e.copyfrom_path, e.copyfrom_rev, p) for p, e
401 401 in origpaths.iteritems() if e.copyfrom_path]
402 402 copies.sort()
403 403 # Apply moves/copies from more specific to general
404 404 copies.reverse()
405 405
406 406 srctagspath = tagspath
407 407 if copies and copies[-1][2] == tagspath:
408 408 # Track tags directory moves
409 409 srctagspath = copies.pop()[0]
410 410
411 411 for source, sourcerev, dest in copies:
412 412 if not dest.startswith(tagspath + '/'):
413 413 continue
414 414 for tag in pendings:
415 415 if tag[0].startswith(dest):
416 416 tagpath = source + tag[0][len(dest):]
417 417 tag[:2] = [tagpath, sourcerev]
418 418 break
419 419 else:
420 420 pendings.append([source, sourcerev, dest.split('/')[-1]])
421 421
422 422 # Tell tag renamings from tag creations
423 423 remainings = []
424 424 for source, sourcerev, tagname in pendings:
425 425 if source.startswith(srctagspath):
426 426 remainings.append([source, sourcerev, tagname])
427 427 continue
428 428 # From revision may be fake, get one with changes
429 429 try:
430 430 tagid = self.latest(source, sourcerev)
431 431 if tagid:
432 432 tags[tagname] = tagid
433 433 except SvnPathNotFound:
434 434 # It happens when we are following directories we assumed
435 435 # were copied with their parents but were really created
436 436 # in the tag directory.
437 437 pass
438 438 pendings = remainings
439 439 tagspath = srctagspath
440 440
441 441 except SubversionException, (inst, num):
442 442 self.ui.note(_('no tags found at revision %d\n') % start)
443 443 return tags
444 444
445 445 def converted(self, rev, destrev):
446 446 if not self.wc:
447 447 return
448 448 if self.convertfp is None:
449 449 self.convertfp = open(os.path.join(self.wc, '.svn', 'hg-shamap'),
450 450 'a')
451 451 self.convertfp.write('%s %d\n' % (destrev, self.revnum(rev)))
452 452 self.convertfp.flush()
453 453
454 454 # -- helper functions --
455 455
456 456 def revid(self, revnum, module=None):
457 457 if not module:
458 458 module = self.module
459 459 return u"svn:%s%s@%s" % (self.uuid, module.decode(self.encoding),
460 460 revnum)
461 461
462 462 def revnum(self, rev):
463 463 return int(rev.split('@')[-1])
464 464
465 465 def revsplit(self, rev):
466 466 url, revnum = rev.encode(self.encoding).split('@', 1)
467 467 revnum = int(revnum)
468 468 parts = url.split('/', 1)
469 469 uuid = parts.pop(0)[4:]
470 470 mod = ''
471 471 if parts:
472 472 mod = '/' + parts[0]
473 473 return uuid, mod, revnum
474 474
475 475 def latest(self, path, stop=0):
476 476 """Find the latest revid affecting path, up to stop. It may return
477 477 a revision in a different module, since a branch may be moved without
478 478 a change being reported. Return None if computed module does not
479 479 belong to rootmodule subtree.
480 480 """
481 481 if not path.startswith(self.rootmodule):
482 482 # Requests on foreign branches may be forbidden at server level
483 483 self.ui.debug(_('ignoring foreign branch %r\n') % path)
484 484 return None
485 485
486 486 if not stop:
487 487 stop = svn.ra.get_latest_revnum(self.ra)
488 488 try:
489 489 prevmodule = self.reparent('')
490 490 dirent = svn.ra.stat(self.ra, path.strip('/'), stop)
491 491 self.reparent(prevmodule)
492 492 except SubversionException:
493 493 dirent = None
494 494 if not dirent:
495 495 raise SvnPathNotFound(_('%s not found up to revision %d') % (path, stop))
496 496
497 497 # stat() gives us the previous revision on this line of development, but
498 498 # it might be in *another module*. Fetch the log and detect renames down
499 499 # to the latest revision.
500 500 stream = self._getlog([path], stop, dirent.created_rev)
501 501 try:
502 502 for entry in stream:
503 503 paths, revnum, author, date, message = entry
504 504 if revnum <= dirent.created_rev:
505 505 break
506 506
507 507 for p in paths:
508 508 if not path.startswith(p) or not paths[p].copyfrom_path:
509 509 continue
510 510 newpath = paths[p].copyfrom_path + path[len(p):]
511 511 self.ui.debug(_("branch renamed from %s to %s at %d\n") %
512 512 (path, newpath, revnum))
513 513 path = newpath
514 514 break
515 515 finally:
516 516 stream.close()
517 517
518 518 if not path.startswith(self.rootmodule):
519 519 self.ui.debug(_('ignoring foreign branch %r\n') % path)
520 520 return None
521 521 return self.revid(dirent.created_rev, path)
522 522
523 523 def get_blacklist(self):
524 524 """Avoid certain revision numbers.
525 525 It is not uncommon for two nearby revisions to cancel each other
526 526 out, e.g. 'I copied trunk into a subdirectory of itself instead
527 527 of making a branch'. The converted repository is significantly
528 528 smaller if we ignore such revisions."""
529 529 self.blacklist = util.set()
530 530 blacklist = self.blacklist
531 531 for line in file("blacklist.txt", "r"):
532 532 if not line.startswith("#"):
533 533 try:
534 534 svn_rev = int(line.strip())
535 535 blacklist.add(svn_rev)
536 536 except ValueError, e:
537 537 pass # not an integer or a comment
538 538
539 539 def is_blacklisted(self, svn_rev):
540 540 return svn_rev in self.blacklist
541 541
542 542 def reparent(self, module):
543 543 """Reparent the svn transport and return the previous parent."""
544 544 if self.prevmodule == module:
545 545 return module
546 546 svnurl = self.baseurl + urllib.quote(module)
547 547 prevmodule = self.prevmodule
548 548 if prevmodule is None:
549 549 prevmodule = ''
550 550 self.ui.debug(_("reparent to %s\n") % svnurl)
551 551 svn.ra.reparent(self.ra, svnurl)
552 552 self.prevmodule = module
553 553 return prevmodule
554 554
555 555 def expandpaths(self, rev, paths, parents):
556 556 entries = []
557 557 copyfrom = {} # Map of entrypath, revision for finding source of deleted revisions.
558 558 copies = {}
559 559
560 560 new_module, revnum = self.revsplit(rev)[1:]
561 561 if new_module != self.module:
562 562 self.module = new_module
563 563 self.reparent(self.module)
564 564
565 565 for path, ent in paths:
566 566 entrypath = self.getrelpath(path)
567 567 entry = entrypath.decode(self.encoding)
568 568
569 569 kind = self._checkpath(entrypath, revnum)
570 570 if kind == svn.core.svn_node_file:
571 571 entries.append(self.recode(entry))
572 572 if not ent.copyfrom_path or not parents:
573 573 continue
574 574 # Copy sources not in parent revisions cannot be represented,
575 575 # ignore their origin for now
576 576 pmodule, prevnum = self.revsplit(parents[0])[1:]
577 577 if ent.copyfrom_rev < prevnum:
578 578 continue
579 579 copyfrom_path = self.getrelpath(ent.copyfrom_path, pmodule)
580 580 if not copyfrom_path:
581 581 continue
582 582 self.ui.debug(_("copied to %s from %s@%s\n") %
583 583 (entrypath, copyfrom_path, ent.copyfrom_rev))
584 584 copies[self.recode(entry)] = self.recode(copyfrom_path)
585 585 elif kind == 0: # gone, but had better be a deleted *file*
586 586 self.ui.debug(_("gone from %s\n") % ent.copyfrom_rev)
587 587
588 588 # if a branch is created but entries are removed in the same
589 589 # changeset, get the right fromrev
590 590 # parents cannot be empty here, you cannot remove things from
591 591 # a root revision.
592 592 uuid, old_module, fromrev = self.revsplit(parents[0])
593 593
594 594 basepath = old_module + "/" + self.getrelpath(path)
595 595 entrypath = basepath
596 596
597 597 def lookup_parts(p):
598 598 rc = None
599 599 parts = p.split("/")
600 600 for i in range(len(parts)):
601 601 part = "/".join(parts[:i])
602 602 info = part, copyfrom.get(part, None)
603 603 if info[1] is not None:
604 604 self.ui.debug(_("Found parent directory %s\n") % info[1])
605 605 rc = info
606 606 return rc
607 607
608 608 self.ui.debug(_("base, entry %s %s\n") % (basepath, entrypath))
609 609
610 610 frompath, froment = lookup_parts(entrypath) or (None, revnum - 1)
611 611
612 612 # need to remove fragment from lookup_parts and replace with copyfrom_path
613 613 if frompath is not None:
614 614 self.ui.debug(_("munge-o-matic\n"))
615 615 self.ui.debug(entrypath + '\n')
616 616 self.ui.debug(entrypath[len(frompath):] + '\n')
617 617 entrypath = froment.copyfrom_path + entrypath[len(frompath):]
618 618 fromrev = froment.copyfrom_rev
619 619 self.ui.debug(_("Info: %s %s %s %s\n") % (frompath, froment, ent, entrypath))
620 620
621 621 # We can avoid the reparent calls if the module has not changed
622 622 # but it probably does not worth the pain.
623 623 prevmodule = self.reparent('')
624 624 fromkind = svn.ra.check_path(self.ra, entrypath.strip('/'), fromrev)
625 625 self.reparent(prevmodule)
626 626
627 627 if fromkind == svn.core.svn_node_file: # a deleted file
628 628 entries.append(self.recode(entry))
629 629 elif fromkind == svn.core.svn_node_dir:
630 630 # print "Deleted/moved non-file:", revnum, path, ent
631 631 # children = self._find_children(path, revnum - 1)
632 632 # print "find children %s@%d from %d action %s" % (path, revnum, ent.copyfrom_rev, ent.action)
633 633 # Sometimes this is tricky. For example: in
634 634 # The Subversion Repository revision 6940 a dir
635 635 # was copied and one of its files was deleted
636 636 # from the new location in the same commit. This
637 637 # code can't deal with that yet.
638 638 if ent.action == 'C':
639 639 children = self._find_children(path, fromrev)
640 640 else:
641 641 oroot = entrypath.strip('/')
642 642 nroot = path.strip('/')
643 643 children = self._find_children(oroot, fromrev)
644 644 children = [s.replace(oroot,nroot) for s in children]
645 645 # Mark all [files, not directories] as deleted.
646 646 for child in children:
647 647 # Can we move a child directory and its
648 648 # parent in the same commit? (probably can). Could
649 649 # cause problems if instead of revnum -1,
650 650 # we have to look in (copyfrom_path, revnum - 1)
651 651 entrypath = self.getrelpath("/" + child, module=old_module)
652 652 if entrypath:
653 653 entry = self.recode(entrypath.decode(self.encoding))
654 654 if entry in copies:
655 655 # deleted file within a copy
656 656 del copies[entry]
657 657 else:
658 658 entries.append(entry)
659 659 else:
660 660 self.ui.debug(_('unknown path in revision %d: %s\n') % \
661 661 (revnum, path))
662 662 elif kind == svn.core.svn_node_dir:
663 663 # Should probably synthesize normal file entries
664 664 # and handle as above to clean up copy/rename handling.
665 665
666 666 # If the directory just had a prop change,
667 667 # then we shouldn't need to look for its children.
668 668 if ent.action == 'M':
669 669 continue
670 670
671 671 # Also this could create duplicate entries. Not sure
672 672 # whether this will matter. Maybe should make entries a set.
673 673 # print "Changed directory", revnum, path, ent.action, ent.copyfrom_path, ent.copyfrom_rev
674 674 # This will fail if a directory was copied
675 675 # from another branch and then some of its files
676 676 # were deleted in the same transaction.
677 677 children = util.sort(self._find_children(path, revnum))
678 678 for child in children:
679 679 # Can we move a child directory and its
680 680 # parent in the same commit? (probably can). Could
681 681 # cause problems if instead of revnum -1,
682 682 # we have to look in (copyfrom_path, revnum - 1)
683 683 entrypath = self.getrelpath("/" + child)
684 684 # print child, self.module, entrypath
685 685 if entrypath:
686 686 # Need to filter out directories here...
687 687 kind = self._checkpath(entrypath, revnum)
688 688 if kind != svn.core.svn_node_dir:
689 689 entries.append(self.recode(entrypath))
690 690
691 691 # Copies here (must copy all from source)
692 692 # Probably not a real problem for us if
693 693 # source does not exist
694 694 if not ent.copyfrom_path or not parents:
695 695 continue
696 696 # Copy sources not in parent revisions cannot be represented,
697 697 # ignore their origin for now
698 698 pmodule, prevnum = self.revsplit(parents[0])[1:]
699 699 if ent.copyfrom_rev < prevnum:
700 700 continue
701 701 copyfrompath = ent.copyfrom_path.decode(self.encoding)
702 702 copyfrompath = self.getrelpath(copyfrompath, pmodule)
703 703 if not copyfrompath:
704 704 continue
705 705 copyfrom[path] = ent
706 706 self.ui.debug(_("mark %s came from %s:%d\n")
707 707 % (path, copyfrompath, ent.copyfrom_rev))
708 708 children = self._find_children(ent.copyfrom_path, ent.copyfrom_rev)
709 709 children.sort()
710 710 for child in children:
711 711 entrypath = self.getrelpath("/" + child, pmodule)
712 712 if not entrypath:
713 713 continue
714 714 entry = entrypath.decode(self.encoding)
715 715 copytopath = path + entry[len(copyfrompath):]
716 716 copytopath = self.getrelpath(copytopath)
717 717 copies[self.recode(copytopath)] = self.recode(entry, pmodule)
718 718
719 719 return (util.unique(entries), copies)
720 720
721 721 def _fetch_revisions(self, from_revnum, to_revnum):
722 722 if from_revnum < to_revnum:
723 723 from_revnum, to_revnum = to_revnum, from_revnum
724 724
725 725 self.child_cset = None
726 726
727 def isdescendantof(parent, child):
728 if not child or not parent or not child.startswith(parent):
729 return False
730 subpath = child[len(parent):]
731 return len(subpath) > 1 and subpath[0] == '/'
732
733 727 def parselogentry(orig_paths, revnum, author, date, message):
734 728 """Return the parsed commit object or None, and True if
735 729 the revision is a branch root.
736 730 """
737 731 self.ui.debug(_("parsing revision %d (%d changes)\n") %
738 732 (revnum, len(orig_paths)))
739 733
740 734 branched = False
741 735 rev = self.revid(revnum)
742 736 # branch log might return entries for a parent we already have
743 737
744 738 if (rev in self.commits or revnum < to_revnum):
745 739 return None, branched
746 740
747 741 parents = []
748 742 # check whether this revision is the start of a branch or part
749 743 # of a branch renaming
750 744 orig_paths = util.sort(orig_paths.items())
751 745 root_paths = [(p,e) for p,e in orig_paths if self.module.startswith(p)]
752 746 if root_paths:
753 747 path, ent = root_paths[-1]
754 748 if ent.copyfrom_path:
755 # If dir was moved while one of its file was removed
756 # the log may look like:
757 # A /dir (from /dir:x)
758 # A /dir/a (from /dir/a:y)
759 # A /dir/b (from /dir/b:z)
760 # ...
761 # for all remaining children.
762 # Let's take the highest child element from rev as source.
763 copies = [(p,e) for p,e in orig_paths[:-1]
764 if isdescendantof(ent.copyfrom_path, e.copyfrom_path)]
765 fromrev = max([e.copyfrom_rev for p,e in copies] + [ent.copyfrom_rev])
766 749 branched = True
767 750 newpath = ent.copyfrom_path + self.module[len(path):]
768 751 # ent.copyfrom_rev may not be the actual last revision
769 previd = self.latest(newpath, fromrev)
752 previd = self.latest(newpath, ent.copyfrom_rev)
770 753 if previd is not None:
771 754 prevmodule, prevnum = self.revsplit(previd)[1:]
772 755 if prevnum >= self.startrev:
773 756 parents = [previd]
774 757 self.ui.note(_('found parent of branch %s at %d: %s\n') %
775 758 (self.module, prevnum, prevmodule))
776 759 else:
777 760 self.ui.debug(_("No copyfrom path, don't know what to do.\n"))
778 761
779 762 paths = []
780 763 # filter out unrelated paths
781 764 for path, ent in orig_paths:
782 765 if self.getrelpath(path) is None:
783 766 continue
784 767 paths.append((path, ent))
785 768
786 769 # Example SVN datetime. Includes microseconds.
787 770 # ISO-8601 conformant
788 771 # '2007-01-04T17:35:00.902377Z'
789 772 date = util.parsedate(date[:19] + " UTC", ["%Y-%m-%dT%H:%M:%S"])
790 773
791 774 log = message and self.recode(message) or ''
792 775 author = author and self.recode(author) or ''
793 776 try:
794 777 branch = self.module.split("/")[-1]
795 778 if branch == 'trunk':
796 779 branch = ''
797 780 except IndexError:
798 781 branch = None
799 782
800 783 cset = commit(author=author,
801 784 date=util.datestr(date),
802 785 desc=log,
803 786 parents=parents,
804 787 branch=branch,
805 788 rev=rev.encode('utf-8'))
806 789
807 790 self.commits[rev] = cset
808 791 # The parents list is *shared* among self.paths and the
809 792 # commit object. Both will be updated below.
810 793 self.paths[rev] = (paths, cset.parents)
811 794 if self.child_cset and not self.child_cset.parents:
812 795 self.child_cset.parents[:] = [rev]
813 796 self.child_cset = cset
814 797 return cset, branched
815 798
816 799 self.ui.note(_('fetching revision log for "%s" from %d to %d\n') %
817 800 (self.module, from_revnum, to_revnum))
818 801
819 802 try:
820 803 firstcset = None
821 804 lastonbranch = False
822 805 stream = self._getlog([self.module], from_revnum, to_revnum)
823 806 try:
824 807 for entry in stream:
825 808 paths, revnum, author, date, message = entry
826 809 if revnum < self.startrev:
827 810 lastonbranch = True
828 811 break
829 812 if self.is_blacklisted(revnum):
830 813 self.ui.note(_('skipping blacklisted revision %d\n')
831 814 % revnum)
832 815 continue
833 816 if paths is None:
834 817 self.ui.debug(_('revision %d has no entries\n') % revnum)
835 818 continue
836 819 cset, lastonbranch = parselogentry(paths, revnum, author,
837 820 date, message)
838 821 if cset:
839 822 firstcset = cset
840 823 if lastonbranch:
841 824 break
842 825 finally:
843 826 stream.close()
844 827
845 828 if not lastonbranch and firstcset and not firstcset.parents:
846 829 # The first revision of the sequence (the last fetched one)
847 830 # has invalid parents if not a branch root. Find the parent
848 831 # revision now, if any.
849 832 try:
850 833 firstrevnum = self.revnum(firstcset.rev)
851 834 if firstrevnum > 1:
852 835 latest = self.latest(self.module, firstrevnum - 1)
853 836 if latest:
854 837 firstcset.parents.append(latest)
855 838 except SvnPathNotFound:
856 839 pass
857 840 except SubversionException, (inst, num):
858 841 if num == svn.core.SVN_ERR_FS_NO_SUCH_REVISION:
859 842 raise util.Abort(_('svn: branch has no revision %s') % to_revnum)
860 843 raise
861 844
862 845 def _getfile(self, file, rev):
863 846 # TODO: ra.get_file transmits the whole file instead of diffs.
864 847 mode = ''
865 848 try:
866 849 new_module, revnum = self.revsplit(rev)[1:]
867 850 if self.module != new_module:
868 851 self.module = new_module
869 852 self.reparent(self.module)
870 853 io = StringIO()
871 854 info = svn.ra.get_file(self.ra, file, revnum, io)
872 855 data = io.getvalue()
873 856 # ra.get_files() seems to keep a reference on the input buffer
874 857 # preventing collection. Release it explicitely.
875 858 io.close()
876 859 if isinstance(info, list):
877 860 info = info[-1]
878 861 mode = ("svn:executable" in info) and 'x' or ''
879 862 mode = ("svn:special" in info) and 'l' or mode
880 863 except SubversionException, e:
881 864 notfound = (svn.core.SVN_ERR_FS_NOT_FOUND,
882 865 svn.core.SVN_ERR_RA_DAV_PATH_NOT_FOUND)
883 866 if e.apr_err in notfound: # File not found
884 867 raise IOError()
885 868 raise
886 869 if mode == 'l':
887 870 link_prefix = "link "
888 871 if data.startswith(link_prefix):
889 872 data = data[len(link_prefix):]
890 873 return data, mode
891 874
892 875 def _find_children(self, path, revnum):
893 876 path = path.strip('/')
894 877 pool = Pool()
895 878 rpath = '/'.join([self.baseurl, urllib.quote(path)]).strip('/')
896 879 return ['%s/%s' % (path, x) for x in
897 880 svn.client.ls(rpath, optrev(revnum), True, self.ctx, pool).keys()]
898 881
899 882 def getrelpath(self, path, module=None):
900 883 if module is None:
901 884 module = self.module
902 885 # Given the repository url of this wc, say
903 886 # "http://server/plone/CMFPlone/branches/Plone-2_0-branch"
904 887 # extract the "entry" portion (a relative path) from what
905 888 # svn log --xml says, ie
906 889 # "/CMFPlone/branches/Plone-2_0-branch/tests/PloneTestCase.py"
907 890 # that is to say "tests/PloneTestCase.py"
908 891 if path.startswith(module):
909 892 relative = path.rstrip('/')[len(module):]
910 893 if relative.startswith('/'):
911 894 return relative[1:]
912 895 elif relative == '':
913 896 return relative
914 897
915 898 # The path is outside our tracked tree...
916 899 self.ui.debug(_('%r is not under %r, ignoring\n') % (path, module))
917 900 return None
918 901
919 902 def _checkpath(self, path, revnum):
920 903 # ra.check_path does not like leading slashes very much, it leads
921 904 # to PROPFIND subversion errors
922 905 return svn.ra.check_path(self.ra, path.strip('/'), revnum)
923 906
924 907 def _getlog(self, paths, start, end, limit=0, discover_changed_paths=True,
925 908 strict_node_history=False):
926 909 # Normalize path names, svn >= 1.5 only wants paths relative to
927 910 # supplied URL
928 911 relpaths = []
929 912 for p in paths:
930 913 if not p.startswith('/'):
931 914 p = self.module + '/' + p
932 915 relpaths.append(p.strip('/'))
933 916 args = [self.baseurl, relpaths, start, end, limit, discover_changed_paths,
934 917 strict_node_history]
935 918 arg = encodeargs(args)
936 919 hgexe = util.hgexecutable()
937 920 cmd = '%s debugsvnlog' % util.shellquote(hgexe)
938 921 stdin, stdout = util.popen2(cmd, 'b')
939 922 stdin.write(arg)
940 923 stdin.close()
941 924 return logstream(stdout)
942 925
943 926 pre_revprop_change = '''#!/bin/sh
944 927
945 928 REPOS="$1"
946 929 REV="$2"
947 930 USER="$3"
948 931 PROPNAME="$4"
949 932 ACTION="$5"
950 933
951 934 if [ "$ACTION" = "M" -a "$PROPNAME" = "svn:log" ]; then exit 0; fi
952 935 if [ "$ACTION" = "A" -a "$PROPNAME" = "hg:convert-branch" ]; then exit 0; fi
953 936 if [ "$ACTION" = "A" -a "$PROPNAME" = "hg:convert-rev" ]; then exit 0; fi
954 937
955 938 echo "Changing prohibited revision property" >&2
956 939 exit 1
957 940 '''
958 941
959 942 class svn_sink(converter_sink, commandline):
960 943 commit_re = re.compile(r'Committed revision (\d+).', re.M)
961 944
962 945 def prerun(self):
963 946 if self.wc:
964 947 os.chdir(self.wc)
965 948
966 949 def postrun(self):
967 950 if self.wc:
968 951 os.chdir(self.cwd)
969 952
970 953 def join(self, name):
971 954 return os.path.join(self.wc, '.svn', name)
972 955
973 956 def revmapfile(self):
974 957 return self.join('hg-shamap')
975 958
976 959 def authorfile(self):
977 960 return self.join('hg-authormap')
978 961
979 962 def __init__(self, ui, path):
980 963 converter_sink.__init__(self, ui, path)
981 964 commandline.__init__(self, ui, 'svn')
982 965 self.delete = []
983 966 self.setexec = []
984 967 self.delexec = []
985 968 self.copies = []
986 969 self.wc = None
987 970 self.cwd = os.getcwd()
988 971
989 972 path = os.path.realpath(path)
990 973
991 974 created = False
992 975 if os.path.isfile(os.path.join(path, '.svn', 'entries')):
993 976 self.wc = path
994 977 self.run0('update')
995 978 else:
996 979 wcpath = os.path.join(os.getcwd(), os.path.basename(path) + '-wc')
997 980
998 981 if os.path.isdir(os.path.dirname(path)):
999 982 if not os.path.exists(os.path.join(path, 'db', 'fs-type')):
1000 983 ui.status(_('initializing svn repo %r\n') %
1001 984 os.path.basename(path))
1002 985 commandline(ui, 'svnadmin').run0('create', path)
1003 986 created = path
1004 987 path = util.normpath(path)
1005 988 if not path.startswith('/'):
1006 989 path = '/' + path
1007 990 path = 'file://' + path
1008 991
1009 992 ui.status(_('initializing svn wc %r\n') % os.path.basename(wcpath))
1010 993 self.run0('checkout', path, wcpath)
1011 994
1012 995 self.wc = wcpath
1013 996 self.opener = util.opener(self.wc)
1014 997 self.wopener = util.opener(self.wc)
1015 998 self.childmap = mapfile(ui, self.join('hg-childmap'))
1016 999 self.is_exec = util.checkexec(self.wc) and util.is_exec or None
1017 1000
1018 1001 if created:
1019 1002 hook = os.path.join(created, 'hooks', 'pre-revprop-change')
1020 1003 fp = open(hook, 'w')
1021 1004 fp.write(pre_revprop_change)
1022 1005 fp.close()
1023 1006 util.set_flags(hook, False, True)
1024 1007
1025 1008 xport = transport.SvnRaTransport(url=geturl(path))
1026 1009 self.uuid = svn.ra.get_uuid(xport.ra)
1027 1010
1028 1011 def wjoin(self, *names):
1029 1012 return os.path.join(self.wc, *names)
1030 1013
1031 1014 def putfile(self, filename, flags, data):
1032 1015 if 'l' in flags:
1033 1016 self.wopener.symlink(data, filename)
1034 1017 else:
1035 1018 try:
1036 1019 if os.path.islink(self.wjoin(filename)):
1037 1020 os.unlink(filename)
1038 1021 except OSError:
1039 1022 pass
1040 1023 self.wopener(filename, 'w').write(data)
1041 1024
1042 1025 if self.is_exec:
1043 1026 was_exec = self.is_exec(self.wjoin(filename))
1044 1027 else:
1045 1028 # On filesystems not supporting execute-bit, there is no way
1046 1029 # to know if it is set but asking subversion. Setting it
1047 1030 # systematically is just as expensive and much simpler.
1048 1031 was_exec = 'x' not in flags
1049 1032
1050 1033 util.set_flags(self.wjoin(filename), False, 'x' in flags)
1051 1034 if was_exec:
1052 1035 if 'x' not in flags:
1053 1036 self.delexec.append(filename)
1054 1037 else:
1055 1038 if 'x' in flags:
1056 1039 self.setexec.append(filename)
1057 1040
1058 1041 def _copyfile(self, source, dest):
1059 1042 # SVN's copy command pukes if the destination file exists, but
1060 1043 # our copyfile method expects to record a copy that has
1061 1044 # already occurred. Cross the semantic gap.
1062 1045 wdest = self.wjoin(dest)
1063 1046 exists = os.path.exists(wdest)
1064 1047 if exists:
1065 1048 fd, tempname = tempfile.mkstemp(
1066 1049 prefix='hg-copy-', dir=os.path.dirname(wdest))
1067 1050 os.close(fd)
1068 1051 os.unlink(tempname)
1069 1052 os.rename(wdest, tempname)
1070 1053 try:
1071 1054 self.run0('copy', source, dest)
1072 1055 finally:
1073 1056 if exists:
1074 1057 try:
1075 1058 os.unlink(wdest)
1076 1059 except OSError:
1077 1060 pass
1078 1061 os.rename(tempname, wdest)
1079 1062
1080 1063 def dirs_of(self, files):
1081 1064 dirs = util.set()
1082 1065 for f in files:
1083 1066 if os.path.isdir(self.wjoin(f)):
1084 1067 dirs.add(f)
1085 1068 for i in strutil.rfindall(f, '/'):
1086 1069 dirs.add(f[:i])
1087 1070 return dirs
1088 1071
1089 1072 def add_dirs(self, files):
1090 1073 add_dirs = [d for d in util.sort(self.dirs_of(files))
1091 1074 if not os.path.exists(self.wjoin(d, '.svn', 'entries'))]
1092 1075 if add_dirs:
1093 1076 self.xargs(add_dirs, 'add', non_recursive=True, quiet=True)
1094 1077 return add_dirs
1095 1078
1096 1079 def add_files(self, files):
1097 1080 if files:
1098 1081 self.xargs(files, 'add', quiet=True)
1099 1082 return files
1100 1083
1101 1084 def tidy_dirs(self, names):
1102 1085 dirs = util.sort(self.dirs_of(names))
1103 1086 dirs.reverse()
1104 1087 deleted = []
1105 1088 for d in dirs:
1106 1089 wd = self.wjoin(d)
1107 1090 if os.listdir(wd) == '.svn':
1108 1091 self.run0('delete', d)
1109 1092 deleted.append(d)
1110 1093 return deleted
1111 1094
1112 1095 def addchild(self, parent, child):
1113 1096 self.childmap[parent] = child
1114 1097
1115 1098 def revid(self, rev):
1116 1099 return u"svn:%s@%s" % (self.uuid, rev)
1117 1100
1118 1101 def putcommit(self, files, copies, parents, commit, source):
1119 1102 # Apply changes to working copy
1120 1103 for f, v in files:
1121 1104 try:
1122 1105 data = source.getfile(f, v)
1123 1106 except IOError, inst:
1124 1107 self.delete.append(f)
1125 1108 else:
1126 1109 e = source.getmode(f, v)
1127 1110 self.putfile(f, e, data)
1128 1111 if f in copies:
1129 1112 self.copies.append([copies[f], f])
1130 1113 files = [f[0] for f in files]
1131 1114
1132 1115 for parent in parents:
1133 1116 try:
1134 1117 return self.revid(self.childmap[parent])
1135 1118 except KeyError:
1136 1119 pass
1137 1120 entries = util.set(self.delete)
1138 1121 files = util.frozenset(files)
1139 1122 entries.update(self.add_dirs(files.difference(entries)))
1140 1123 if self.copies:
1141 1124 for s, d in self.copies:
1142 1125 self._copyfile(s, d)
1143 1126 self.copies = []
1144 1127 if self.delete:
1145 1128 self.xargs(self.delete, 'delete')
1146 1129 self.delete = []
1147 1130 entries.update(self.add_files(files.difference(entries)))
1148 1131 entries.update(self.tidy_dirs(entries))
1149 1132 if self.delexec:
1150 1133 self.xargs(self.delexec, 'propdel', 'svn:executable')
1151 1134 self.delexec = []
1152 1135 if self.setexec:
1153 1136 self.xargs(self.setexec, 'propset', 'svn:executable', '*')
1154 1137 self.setexec = []
1155 1138
1156 1139 fd, messagefile = tempfile.mkstemp(prefix='hg-convert-')
1157 1140 fp = os.fdopen(fd, 'w')
1158 1141 fp.write(commit.desc)
1159 1142 fp.close()
1160 1143 try:
1161 1144 output = self.run0('commit',
1162 1145 username=util.shortuser(commit.author),
1163 1146 file=messagefile,
1164 1147 encoding='utf-8')
1165 1148 try:
1166 1149 rev = self.commit_re.search(output).group(1)
1167 1150 except AttributeError:
1168 1151 self.ui.warn(_('unexpected svn output:\n'))
1169 1152 self.ui.warn(output)
1170 1153 raise util.Abort(_('unable to cope with svn output'))
1171 1154 if commit.rev:
1172 1155 self.run('propset', 'hg:convert-rev', commit.rev,
1173 1156 revprop=True, revision=rev)
1174 1157 if commit.branch and commit.branch != 'default':
1175 1158 self.run('propset', 'hg:convert-branch', commit.branch,
1176 1159 revprop=True, revision=rev)
1177 1160 for parent in parents:
1178 1161 self.addchild(parent, rev)
1179 1162 return self.revid(rev)
1180 1163 finally:
1181 1164 os.unlink(messagefile)
1182 1165
1183 1166 def puttags(self, tags):
1184 1167 self.ui.warn(_('XXX TAGS NOT IMPLEMENTED YET\n'))
@@ -1,50 +1,50
1 1 % convert trunk and branches
2 2 initializing destination A-hg repository
3 3 scanning source...
4 4 sorting...
5 5 converting...
6 6 10 init projA
7 7 9 hello
8 8 8 branch trunk, remove c
9 9 7 change a
10 10 6 change b
11 11 5 move and update c
12 12 4 move and update c
13 13 3 change b again
14 14 2 move to old2
15 15 1 move back to old
16 16 0 last change to a
17 17 % convert again
18 18 scanning source...
19 19 sorting...
20 20 converting...
21 21 0 branch trunk@1 into old3
22 22 o branch=old3 11 branch trunk@1 into old3 files:
23 23 |
24 24 | o branch= 10 last change to a files: a
25 25 | |
26 26 | | o branch=old 9 move back to old files:
27 27 | | |
28 28 | | o branch=old2 8 move to old2 files:
29 29 | | |
30 30 | | o branch=old 7 change b again files: b
31 31 | | |
32 32 | o | branch= 6 move and update c files: b
33 33 | | |
34 34 | | o branch=old 5 move and update c files: c
35 35 | | |
36 36 | | o branch=old 4 change b files: b
37 37 | | |
38 38 | o | branch= 3 change a files: a
39 39 | | |
40 | | o branch=old 2 branch trunk, remove c files:
41 | |/
40 +---o branch=old 2 branch trunk, remove c files: a b
41 | |
42 42 | o branch= 1 hello files: a b c
43 43 |/
44 44 o branch= 0 init projA files:
45 45
46 46 old3 11:
47 47 default 10:
48 48 old 9:
49 49 old2 8:
50 50 tip
General Comments 0
You need to be logged in to leave comments. Login now