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