##// END OF EJS Templates
cleanupnodes: pass multiple predecessors to `createmarkers` directly
Boris Feld -
r39959:61f39a89 default
parent child Browse files
Show More
@@ -1,1802 +1,1801 b''
1 1 # scmutil.py - Mercurial core utility functions
2 2 #
3 3 # Copyright Matt Mackall <mpm@selenic.com>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8 from __future__ import absolute_import
9 9
10 10 import errno
11 11 import glob
12 12 import hashlib
13 13 import os
14 14 import re
15 15 import socket
16 16 import subprocess
17 17 import weakref
18 18
19 19 from .i18n import _
20 20 from .node import (
21 21 bin,
22 22 hex,
23 23 nullid,
24 24 nullrev,
25 25 short,
26 26 wdirid,
27 27 wdirrev,
28 28 )
29 29
30 30 from . import (
31 31 encoding,
32 32 error,
33 33 match as matchmod,
34 34 obsolete,
35 35 obsutil,
36 36 pathutil,
37 37 phases,
38 38 policy,
39 39 pycompat,
40 40 revsetlang,
41 41 similar,
42 42 smartset,
43 43 url,
44 44 util,
45 45 vfs,
46 46 )
47 47
48 48 from .utils import (
49 49 procutil,
50 50 stringutil,
51 51 )
52 52
53 53 if pycompat.iswindows:
54 54 from . import scmwindows as scmplatform
55 55 else:
56 56 from . import scmposix as scmplatform
57 57
58 58 parsers = policy.importmod(r'parsers')
59 59
60 60 termsize = scmplatform.termsize
61 61
62 62 class status(tuple):
63 63 '''Named tuple with a list of files per status. The 'deleted', 'unknown'
64 64 and 'ignored' properties are only relevant to the working copy.
65 65 '''
66 66
67 67 __slots__ = ()
68 68
69 69 def __new__(cls, modified, added, removed, deleted, unknown, ignored,
70 70 clean):
71 71 return tuple.__new__(cls, (modified, added, removed, deleted, unknown,
72 72 ignored, clean))
73 73
74 74 @property
75 75 def modified(self):
76 76 '''files that have been modified'''
77 77 return self[0]
78 78
79 79 @property
80 80 def added(self):
81 81 '''files that have been added'''
82 82 return self[1]
83 83
84 84 @property
85 85 def removed(self):
86 86 '''files that have been removed'''
87 87 return self[2]
88 88
89 89 @property
90 90 def deleted(self):
91 91 '''files that are in the dirstate, but have been deleted from the
92 92 working copy (aka "missing")
93 93 '''
94 94 return self[3]
95 95
96 96 @property
97 97 def unknown(self):
98 98 '''files not in the dirstate that are not ignored'''
99 99 return self[4]
100 100
101 101 @property
102 102 def ignored(self):
103 103 '''files not in the dirstate that are ignored (by _dirignore())'''
104 104 return self[5]
105 105
106 106 @property
107 107 def clean(self):
108 108 '''files that have not been modified'''
109 109 return self[6]
110 110
111 111 def __repr__(self, *args, **kwargs):
112 112 return ((r'<status modified=%s, added=%s, removed=%s, deleted=%s, '
113 113 r'unknown=%s, ignored=%s, clean=%s>') %
114 114 tuple(pycompat.sysstr(stringutil.pprint(v)) for v in self))
115 115
116 116 def itersubrepos(ctx1, ctx2):
117 117 """find subrepos in ctx1 or ctx2"""
118 118 # Create a (subpath, ctx) mapping where we prefer subpaths from
119 119 # ctx1. The subpaths from ctx2 are important when the .hgsub file
120 120 # has been modified (in ctx2) but not yet committed (in ctx1).
121 121 subpaths = dict.fromkeys(ctx2.substate, ctx2)
122 122 subpaths.update(dict.fromkeys(ctx1.substate, ctx1))
123 123
124 124 missing = set()
125 125
126 126 for subpath in ctx2.substate:
127 127 if subpath not in ctx1.substate:
128 128 del subpaths[subpath]
129 129 missing.add(subpath)
130 130
131 131 for subpath, ctx in sorted(subpaths.iteritems()):
132 132 yield subpath, ctx.sub(subpath)
133 133
134 134 # Yield an empty subrepo based on ctx1 for anything only in ctx2. That way,
135 135 # status and diff will have an accurate result when it does
136 136 # 'sub.{status|diff}(rev2)'. Otherwise, the ctx2 subrepo is compared
137 137 # against itself.
138 138 for subpath in missing:
139 139 yield subpath, ctx2.nullsub(subpath, ctx1)
140 140
141 141 def nochangesfound(ui, repo, excluded=None):
142 142 '''Report no changes for push/pull, excluded is None or a list of
143 143 nodes excluded from the push/pull.
144 144 '''
145 145 secretlist = []
146 146 if excluded:
147 147 for n in excluded:
148 148 ctx = repo[n]
149 149 if ctx.phase() >= phases.secret and not ctx.extinct():
150 150 secretlist.append(n)
151 151
152 152 if secretlist:
153 153 ui.status(_("no changes found (ignored %d secret changesets)\n")
154 154 % len(secretlist))
155 155 else:
156 156 ui.status(_("no changes found\n"))
157 157
158 158 def callcatch(ui, func):
159 159 """call func() with global exception handling
160 160
161 161 return func() if no exception happens. otherwise do some error handling
162 162 and return an exit code accordingly. does not handle all exceptions.
163 163 """
164 164 try:
165 165 try:
166 166 return func()
167 167 except: # re-raises
168 168 ui.traceback()
169 169 raise
170 170 # Global exception handling, alphabetically
171 171 # Mercurial-specific first, followed by built-in and library exceptions
172 172 except error.LockHeld as inst:
173 173 if inst.errno == errno.ETIMEDOUT:
174 174 reason = _('timed out waiting for lock held by %r') % inst.locker
175 175 else:
176 176 reason = _('lock held by %r') % inst.locker
177 177 ui.error(_("abort: %s: %s\n") % (
178 178 inst.desc or stringutil.forcebytestr(inst.filename), reason))
179 179 if not inst.locker:
180 180 ui.error(_("(lock might be very busy)\n"))
181 181 except error.LockUnavailable as inst:
182 182 ui.error(_("abort: could not lock %s: %s\n") %
183 183 (inst.desc or stringutil.forcebytestr(inst.filename),
184 184 encoding.strtolocal(inst.strerror)))
185 185 except error.OutOfBandError as inst:
186 186 if inst.args:
187 187 msg = _("abort: remote error:\n")
188 188 else:
189 189 msg = _("abort: remote error\n")
190 190 ui.error(msg)
191 191 if inst.args:
192 192 ui.error(''.join(inst.args))
193 193 if inst.hint:
194 194 ui.error('(%s)\n' % inst.hint)
195 195 except error.RepoError as inst:
196 196 ui.error(_("abort: %s!\n") % inst)
197 197 if inst.hint:
198 198 ui.error(_("(%s)\n") % inst.hint)
199 199 except error.ResponseError as inst:
200 200 ui.error(_("abort: %s") % inst.args[0])
201 201 msg = inst.args[1]
202 202 if isinstance(msg, type(u'')):
203 203 msg = pycompat.sysbytes(msg)
204 204 if not isinstance(msg, bytes):
205 205 ui.error(" %r\n" % (msg,))
206 206 elif not msg:
207 207 ui.error(_(" empty string\n"))
208 208 else:
209 209 ui.error("\n%r\n" % pycompat.bytestr(stringutil.ellipsis(msg)))
210 210 except error.CensoredNodeError as inst:
211 211 ui.error(_("abort: file censored %s!\n") % inst)
212 212 except error.StorageError as inst:
213 213 ui.error(_("abort: %s!\n") % inst)
214 214 except error.InterventionRequired as inst:
215 215 ui.error("%s\n" % inst)
216 216 if inst.hint:
217 217 ui.error(_("(%s)\n") % inst.hint)
218 218 return 1
219 219 except error.WdirUnsupported:
220 220 ui.error(_("abort: working directory revision cannot be specified\n"))
221 221 except error.Abort as inst:
222 222 ui.error(_("abort: %s\n") % inst)
223 223 if inst.hint:
224 224 ui.error(_("(%s)\n") % inst.hint)
225 225 except ImportError as inst:
226 226 ui.error(_("abort: %s!\n") % stringutil.forcebytestr(inst))
227 227 m = stringutil.forcebytestr(inst).split()[-1]
228 228 if m in "mpatch bdiff".split():
229 229 ui.error(_("(did you forget to compile extensions?)\n"))
230 230 elif m in "zlib".split():
231 231 ui.error(_("(is your Python install correct?)\n"))
232 232 except IOError as inst:
233 233 if util.safehasattr(inst, "code"):
234 234 ui.error(_("abort: %s\n") % stringutil.forcebytestr(inst))
235 235 elif util.safehasattr(inst, "reason"):
236 236 try: # usually it is in the form (errno, strerror)
237 237 reason = inst.reason.args[1]
238 238 except (AttributeError, IndexError):
239 239 # it might be anything, for example a string
240 240 reason = inst.reason
241 241 if isinstance(reason, pycompat.unicode):
242 242 # SSLError of Python 2.7.9 contains a unicode
243 243 reason = encoding.unitolocal(reason)
244 244 ui.error(_("abort: error: %s\n") % reason)
245 245 elif (util.safehasattr(inst, "args")
246 246 and inst.args and inst.args[0] == errno.EPIPE):
247 247 pass
248 248 elif getattr(inst, "strerror", None):
249 249 if getattr(inst, "filename", None):
250 250 ui.error(_("abort: %s: %s\n") % (
251 251 encoding.strtolocal(inst.strerror),
252 252 stringutil.forcebytestr(inst.filename)))
253 253 else:
254 254 ui.error(_("abort: %s\n") % encoding.strtolocal(inst.strerror))
255 255 else:
256 256 raise
257 257 except OSError as inst:
258 258 if getattr(inst, "filename", None) is not None:
259 259 ui.error(_("abort: %s: '%s'\n") % (
260 260 encoding.strtolocal(inst.strerror),
261 261 stringutil.forcebytestr(inst.filename)))
262 262 else:
263 263 ui.error(_("abort: %s\n") % encoding.strtolocal(inst.strerror))
264 264 except MemoryError:
265 265 ui.error(_("abort: out of memory\n"))
266 266 except SystemExit as inst:
267 267 # Commands shouldn't sys.exit directly, but give a return code.
268 268 # Just in case catch this and and pass exit code to caller.
269 269 return inst.code
270 270 except socket.error as inst:
271 271 ui.error(_("abort: %s\n") % stringutil.forcebytestr(inst.args[-1]))
272 272
273 273 return -1
274 274
275 275 def checknewlabel(repo, lbl, kind):
276 276 # Do not use the "kind" parameter in ui output.
277 277 # It makes strings difficult to translate.
278 278 if lbl in ['tip', '.', 'null']:
279 279 raise error.Abort(_("the name '%s' is reserved") % lbl)
280 280 for c in (':', '\0', '\n', '\r'):
281 281 if c in lbl:
282 282 raise error.Abort(
283 283 _("%r cannot be used in a name") % pycompat.bytestr(c))
284 284 try:
285 285 int(lbl)
286 286 raise error.Abort(_("cannot use an integer as a name"))
287 287 except ValueError:
288 288 pass
289 289 if lbl.strip() != lbl:
290 290 raise error.Abort(_("leading or trailing whitespace in name %r") % lbl)
291 291
292 292 def checkfilename(f):
293 293 '''Check that the filename f is an acceptable filename for a tracked file'''
294 294 if '\r' in f or '\n' in f:
295 295 raise error.Abort(_("'\\n' and '\\r' disallowed in filenames: %r")
296 296 % pycompat.bytestr(f))
297 297
298 298 def checkportable(ui, f):
299 299 '''Check if filename f is portable and warn or abort depending on config'''
300 300 checkfilename(f)
301 301 abort, warn = checkportabilityalert(ui)
302 302 if abort or warn:
303 303 msg = util.checkwinfilename(f)
304 304 if msg:
305 305 msg = "%s: %s" % (msg, procutil.shellquote(f))
306 306 if abort:
307 307 raise error.Abort(msg)
308 308 ui.warn(_("warning: %s\n") % msg)
309 309
310 310 def checkportabilityalert(ui):
311 311 '''check if the user's config requests nothing, a warning, or abort for
312 312 non-portable filenames'''
313 313 val = ui.config('ui', 'portablefilenames')
314 314 lval = val.lower()
315 315 bval = stringutil.parsebool(val)
316 316 abort = pycompat.iswindows or lval == 'abort'
317 317 warn = bval or lval == 'warn'
318 318 if bval is None and not (warn or abort or lval == 'ignore'):
319 319 raise error.ConfigError(
320 320 _("ui.portablefilenames value is invalid ('%s')") % val)
321 321 return abort, warn
322 322
323 323 class casecollisionauditor(object):
324 324 def __init__(self, ui, abort, dirstate):
325 325 self._ui = ui
326 326 self._abort = abort
327 327 allfiles = '\0'.join(dirstate._map)
328 328 self._loweredfiles = set(encoding.lower(allfiles).split('\0'))
329 329 self._dirstate = dirstate
330 330 # The purpose of _newfiles is so that we don't complain about
331 331 # case collisions if someone were to call this object with the
332 332 # same filename twice.
333 333 self._newfiles = set()
334 334
335 335 def __call__(self, f):
336 336 if f in self._newfiles:
337 337 return
338 338 fl = encoding.lower(f)
339 339 if fl in self._loweredfiles and f not in self._dirstate:
340 340 msg = _('possible case-folding collision for %s') % f
341 341 if self._abort:
342 342 raise error.Abort(msg)
343 343 self._ui.warn(_("warning: %s\n") % msg)
344 344 self._loweredfiles.add(fl)
345 345 self._newfiles.add(f)
346 346
347 347 def filteredhash(repo, maxrev):
348 348 """build hash of filtered revisions in the current repoview.
349 349
350 350 Multiple caches perform up-to-date validation by checking that the
351 351 tiprev and tipnode stored in the cache file match the current repository.
352 352 However, this is not sufficient for validating repoviews because the set
353 353 of revisions in the view may change without the repository tiprev and
354 354 tipnode changing.
355 355
356 356 This function hashes all the revs filtered from the view and returns
357 357 that SHA-1 digest.
358 358 """
359 359 cl = repo.changelog
360 360 if not cl.filteredrevs:
361 361 return None
362 362 key = None
363 363 revs = sorted(r for r in cl.filteredrevs if r <= maxrev)
364 364 if revs:
365 365 s = hashlib.sha1()
366 366 for rev in revs:
367 367 s.update('%d;' % rev)
368 368 key = s.digest()
369 369 return key
370 370
371 371 def walkrepos(path, followsym=False, seen_dirs=None, recurse=False):
372 372 '''yield every hg repository under path, always recursively.
373 373 The recurse flag will only control recursion into repo working dirs'''
374 374 def errhandler(err):
375 375 if err.filename == path:
376 376 raise err
377 377 samestat = getattr(os.path, 'samestat', None)
378 378 if followsym and samestat is not None:
379 379 def adddir(dirlst, dirname):
380 380 dirstat = os.stat(dirname)
381 381 match = any(samestat(dirstat, lstdirstat) for lstdirstat in dirlst)
382 382 if not match:
383 383 dirlst.append(dirstat)
384 384 return not match
385 385 else:
386 386 followsym = False
387 387
388 388 if (seen_dirs is None) and followsym:
389 389 seen_dirs = []
390 390 adddir(seen_dirs, path)
391 391 for root, dirs, files in os.walk(path, topdown=True, onerror=errhandler):
392 392 dirs.sort()
393 393 if '.hg' in dirs:
394 394 yield root # found a repository
395 395 qroot = os.path.join(root, '.hg', 'patches')
396 396 if os.path.isdir(os.path.join(qroot, '.hg')):
397 397 yield qroot # we have a patch queue repo here
398 398 if recurse:
399 399 # avoid recursing inside the .hg directory
400 400 dirs.remove('.hg')
401 401 else:
402 402 dirs[:] = [] # don't descend further
403 403 elif followsym:
404 404 newdirs = []
405 405 for d in dirs:
406 406 fname = os.path.join(root, d)
407 407 if adddir(seen_dirs, fname):
408 408 if os.path.islink(fname):
409 409 for hgname in walkrepos(fname, True, seen_dirs):
410 410 yield hgname
411 411 else:
412 412 newdirs.append(d)
413 413 dirs[:] = newdirs
414 414
415 415 def binnode(ctx):
416 416 """Return binary node id for a given basectx"""
417 417 node = ctx.node()
418 418 if node is None:
419 419 return wdirid
420 420 return node
421 421
422 422 def intrev(ctx):
423 423 """Return integer for a given basectx that can be used in comparison or
424 424 arithmetic operation"""
425 425 rev = ctx.rev()
426 426 if rev is None:
427 427 return wdirrev
428 428 return rev
429 429
430 430 def formatchangeid(ctx):
431 431 """Format changectx as '{rev}:{node|formatnode}', which is the default
432 432 template provided by logcmdutil.changesettemplater"""
433 433 repo = ctx.repo()
434 434 return formatrevnode(repo.ui, intrev(ctx), binnode(ctx))
435 435
436 436 def formatrevnode(ui, rev, node):
437 437 """Format given revision and node depending on the current verbosity"""
438 438 if ui.debugflag:
439 439 hexfunc = hex
440 440 else:
441 441 hexfunc = short
442 442 return '%d:%s' % (rev, hexfunc(node))
443 443
444 444 def resolvehexnodeidprefix(repo, prefix):
445 445 if (prefix.startswith('x') and
446 446 repo.ui.configbool('experimental', 'revisions.prefixhexnode')):
447 447 prefix = prefix[1:]
448 448 try:
449 449 # Uses unfiltered repo because it's faster when prefix is ambiguous/
450 450 # This matches the shortesthexnodeidprefix() function below.
451 451 node = repo.unfiltered().changelog._partialmatch(prefix)
452 452 except error.AmbiguousPrefixLookupError:
453 453 revset = repo.ui.config('experimental', 'revisions.disambiguatewithin')
454 454 if revset:
455 455 # Clear config to avoid infinite recursion
456 456 configoverrides = {('experimental',
457 457 'revisions.disambiguatewithin'): None}
458 458 with repo.ui.configoverride(configoverrides):
459 459 revs = repo.anyrevs([revset], user=True)
460 460 matches = []
461 461 for rev in revs:
462 462 node = repo.changelog.node(rev)
463 463 if hex(node).startswith(prefix):
464 464 matches.append(node)
465 465 if len(matches) == 1:
466 466 return matches[0]
467 467 raise
468 468 if node is None:
469 469 return
470 470 repo.changelog.rev(node) # make sure node isn't filtered
471 471 return node
472 472
473 473 def mayberevnum(repo, prefix):
474 474 """Checks if the given prefix may be mistaken for a revision number"""
475 475 try:
476 476 i = int(prefix)
477 477 # if we are a pure int, then starting with zero will not be
478 478 # confused as a rev; or, obviously, if the int is larger
479 479 # than the value of the tip rev
480 480 if prefix[0:1] == b'0' or i >= len(repo):
481 481 return False
482 482 return True
483 483 except ValueError:
484 484 return False
485 485
486 486 def shortesthexnodeidprefix(repo, node, minlength=1, cache=None):
487 487 """Find the shortest unambiguous prefix that matches hexnode.
488 488
489 489 If "cache" is not None, it must be a dictionary that can be used for
490 490 caching between calls to this method.
491 491 """
492 492 # _partialmatch() of filtered changelog could take O(len(repo)) time,
493 493 # which would be unacceptably slow. so we look for hash collision in
494 494 # unfiltered space, which means some hashes may be slightly longer.
495 495
496 496 def disambiguate(prefix):
497 497 """Disambiguate against revnums."""
498 498 if repo.ui.configbool('experimental', 'revisions.prefixhexnode'):
499 499 if mayberevnum(repo, prefix):
500 500 return 'x' + prefix
501 501 else:
502 502 return prefix
503 503
504 504 hexnode = hex(node)
505 505 for length in range(len(prefix), len(hexnode) + 1):
506 506 prefix = hexnode[:length]
507 507 if not mayberevnum(repo, prefix):
508 508 return prefix
509 509
510 510 cl = repo.unfiltered().changelog
511 511 revset = repo.ui.config('experimental', 'revisions.disambiguatewithin')
512 512 if revset:
513 513 revs = None
514 514 if cache is not None:
515 515 revs = cache.get('disambiguationrevset')
516 516 if revs is None:
517 517 revs = repo.anyrevs([revset], user=True)
518 518 if cache is not None:
519 519 cache['disambiguationrevset'] = revs
520 520 if cl.rev(node) in revs:
521 521 hexnode = hex(node)
522 522 nodetree = None
523 523 if cache is not None:
524 524 nodetree = cache.get('disambiguationnodetree')
525 525 if not nodetree:
526 526 try:
527 527 nodetree = parsers.nodetree(cl.index, len(revs))
528 528 except AttributeError:
529 529 # no native nodetree
530 530 pass
531 531 else:
532 532 for r in revs:
533 533 nodetree.insert(r)
534 534 if cache is not None:
535 535 cache['disambiguationnodetree'] = nodetree
536 536 if nodetree is not None:
537 537 length = max(nodetree.shortest(node), minlength)
538 538 prefix = hexnode[:length]
539 539 return disambiguate(prefix)
540 540 for length in range(minlength, len(hexnode) + 1):
541 541 matches = []
542 542 prefix = hexnode[:length]
543 543 for rev in revs:
544 544 otherhexnode = repo[rev].hex()
545 545 if prefix == otherhexnode[:length]:
546 546 matches.append(otherhexnode)
547 547 if len(matches) == 1:
548 548 return disambiguate(prefix)
549 549
550 550 try:
551 551 return disambiguate(cl.shortest(node, minlength))
552 552 except error.LookupError:
553 553 raise error.RepoLookupError()
554 554
555 555 def isrevsymbol(repo, symbol):
556 556 """Checks if a symbol exists in the repo.
557 557
558 558 See revsymbol() for details. Raises error.AmbiguousPrefixLookupError if the
559 559 symbol is an ambiguous nodeid prefix.
560 560 """
561 561 try:
562 562 revsymbol(repo, symbol)
563 563 return True
564 564 except error.RepoLookupError:
565 565 return False
566 566
567 567 def revsymbol(repo, symbol):
568 568 """Returns a context given a single revision symbol (as string).
569 569
570 570 This is similar to revsingle(), but accepts only a single revision symbol,
571 571 i.e. things like ".", "tip", "1234", "deadbeef", "my-bookmark" work, but
572 572 not "max(public())".
573 573 """
574 574 if not isinstance(symbol, bytes):
575 575 msg = ("symbol (%s of type %s) was not a string, did you mean "
576 576 "repo[symbol]?" % (symbol, type(symbol)))
577 577 raise error.ProgrammingError(msg)
578 578 try:
579 579 if symbol in ('.', 'tip', 'null'):
580 580 return repo[symbol]
581 581
582 582 try:
583 583 r = int(symbol)
584 584 if '%d' % r != symbol:
585 585 raise ValueError
586 586 l = len(repo.changelog)
587 587 if r < 0:
588 588 r += l
589 589 if r < 0 or r >= l and r != wdirrev:
590 590 raise ValueError
591 591 return repo[r]
592 592 except error.FilteredIndexError:
593 593 raise
594 594 except (ValueError, OverflowError, IndexError):
595 595 pass
596 596
597 597 if len(symbol) == 40:
598 598 try:
599 599 node = bin(symbol)
600 600 rev = repo.changelog.rev(node)
601 601 return repo[rev]
602 602 except error.FilteredLookupError:
603 603 raise
604 604 except (TypeError, LookupError):
605 605 pass
606 606
607 607 # look up bookmarks through the name interface
608 608 try:
609 609 node = repo.names.singlenode(repo, symbol)
610 610 rev = repo.changelog.rev(node)
611 611 return repo[rev]
612 612 except KeyError:
613 613 pass
614 614
615 615 node = resolvehexnodeidprefix(repo, symbol)
616 616 if node is not None:
617 617 rev = repo.changelog.rev(node)
618 618 return repo[rev]
619 619
620 620 raise error.RepoLookupError(_("unknown revision '%s'") % symbol)
621 621
622 622 except error.WdirUnsupported:
623 623 return repo[None]
624 624 except (error.FilteredIndexError, error.FilteredLookupError,
625 625 error.FilteredRepoLookupError):
626 626 raise _filterederror(repo, symbol)
627 627
628 628 def _filterederror(repo, changeid):
629 629 """build an exception to be raised about a filtered changeid
630 630
631 631 This is extracted in a function to help extensions (eg: evolve) to
632 632 experiment with various message variants."""
633 633 if repo.filtername.startswith('visible'):
634 634
635 635 # Check if the changeset is obsolete
636 636 unfilteredrepo = repo.unfiltered()
637 637 ctx = revsymbol(unfilteredrepo, changeid)
638 638
639 639 # If the changeset is obsolete, enrich the message with the reason
640 640 # that made this changeset not visible
641 641 if ctx.obsolete():
642 642 msg = obsutil._getfilteredreason(repo, changeid, ctx)
643 643 else:
644 644 msg = _("hidden revision '%s'") % changeid
645 645
646 646 hint = _('use --hidden to access hidden revisions')
647 647
648 648 return error.FilteredRepoLookupError(msg, hint=hint)
649 649 msg = _("filtered revision '%s' (not in '%s' subset)")
650 650 msg %= (changeid, repo.filtername)
651 651 return error.FilteredRepoLookupError(msg)
652 652
653 653 def revsingle(repo, revspec, default='.', localalias=None):
654 654 if not revspec and revspec != 0:
655 655 return repo[default]
656 656
657 657 l = revrange(repo, [revspec], localalias=localalias)
658 658 if not l:
659 659 raise error.Abort(_('empty revision set'))
660 660 return repo[l.last()]
661 661
662 662 def _pairspec(revspec):
663 663 tree = revsetlang.parse(revspec)
664 664 return tree and tree[0] in ('range', 'rangepre', 'rangepost', 'rangeall')
665 665
666 666 def revpair(repo, revs):
667 667 if not revs:
668 668 return repo['.'], repo[None]
669 669
670 670 l = revrange(repo, revs)
671 671
672 672 if not l:
673 673 first = second = None
674 674 elif l.isascending():
675 675 first = l.min()
676 676 second = l.max()
677 677 elif l.isdescending():
678 678 first = l.max()
679 679 second = l.min()
680 680 else:
681 681 first = l.first()
682 682 second = l.last()
683 683
684 684 if first is None:
685 685 raise error.Abort(_('empty revision range'))
686 686 if (first == second and len(revs) >= 2
687 687 and not all(revrange(repo, [r]) for r in revs)):
688 688 raise error.Abort(_('empty revision on one side of range'))
689 689
690 690 # if top-level is range expression, the result must always be a pair
691 691 if first == second and len(revs) == 1 and not _pairspec(revs[0]):
692 692 return repo[first], repo[None]
693 693
694 694 return repo[first], repo[second]
695 695
696 696 def revrange(repo, specs, localalias=None):
697 697 """Execute 1 to many revsets and return the union.
698 698
699 699 This is the preferred mechanism for executing revsets using user-specified
700 700 config options, such as revset aliases.
701 701
702 702 The revsets specified by ``specs`` will be executed via a chained ``OR``
703 703 expression. If ``specs`` is empty, an empty result is returned.
704 704
705 705 ``specs`` can contain integers, in which case they are assumed to be
706 706 revision numbers.
707 707
708 708 It is assumed the revsets are already formatted. If you have arguments
709 709 that need to be expanded in the revset, call ``revsetlang.formatspec()``
710 710 and pass the result as an element of ``specs``.
711 711
712 712 Specifying a single revset is allowed.
713 713
714 714 Returns a ``revset.abstractsmartset`` which is a list-like interface over
715 715 integer revisions.
716 716 """
717 717 allspecs = []
718 718 for spec in specs:
719 719 if isinstance(spec, int):
720 720 spec = revsetlang.formatspec('rev(%d)', spec)
721 721 allspecs.append(spec)
722 722 return repo.anyrevs(allspecs, user=True, localalias=localalias)
723 723
724 724 def meaningfulparents(repo, ctx):
725 725 """Return list of meaningful (or all if debug) parentrevs for rev.
726 726
727 727 For merges (two non-nullrev revisions) both parents are meaningful.
728 728 Otherwise the first parent revision is considered meaningful if it
729 729 is not the preceding revision.
730 730 """
731 731 parents = ctx.parents()
732 732 if len(parents) > 1:
733 733 return parents
734 734 if repo.ui.debugflag:
735 735 return [parents[0], repo[nullrev]]
736 736 if parents[0].rev() >= intrev(ctx) - 1:
737 737 return []
738 738 return parents
739 739
740 740 def expandpats(pats):
741 741 '''Expand bare globs when running on windows.
742 742 On posix we assume it already has already been done by sh.'''
743 743 if not util.expandglobs:
744 744 return list(pats)
745 745 ret = []
746 746 for kindpat in pats:
747 747 kind, pat = matchmod._patsplit(kindpat, None)
748 748 if kind is None:
749 749 try:
750 750 globbed = glob.glob(pat)
751 751 except re.error:
752 752 globbed = [pat]
753 753 if globbed:
754 754 ret.extend(globbed)
755 755 continue
756 756 ret.append(kindpat)
757 757 return ret
758 758
759 759 def matchandpats(ctx, pats=(), opts=None, globbed=False, default='relpath',
760 760 badfn=None):
761 761 '''Return a matcher and the patterns that were used.
762 762 The matcher will warn about bad matches, unless an alternate badfn callback
763 763 is provided.'''
764 764 if pats == ("",):
765 765 pats = []
766 766 if opts is None:
767 767 opts = {}
768 768 if not globbed and default == 'relpath':
769 769 pats = expandpats(pats or [])
770 770
771 771 def bad(f, msg):
772 772 ctx.repo().ui.warn("%s: %s\n" % (m.rel(f), msg))
773 773
774 774 if badfn is None:
775 775 badfn = bad
776 776
777 777 m = ctx.match(pats, opts.get('include'), opts.get('exclude'),
778 778 default, listsubrepos=opts.get('subrepos'), badfn=badfn)
779 779
780 780 if m.always():
781 781 pats = []
782 782 return m, pats
783 783
784 784 def match(ctx, pats=(), opts=None, globbed=False, default='relpath',
785 785 badfn=None):
786 786 '''Return a matcher that will warn about bad matches.'''
787 787 return matchandpats(ctx, pats, opts, globbed, default, badfn=badfn)[0]
788 788
789 789 def matchall(repo):
790 790 '''Return a matcher that will efficiently match everything.'''
791 791 return matchmod.always(repo.root, repo.getcwd())
792 792
793 793 def matchfiles(repo, files, badfn=None):
794 794 '''Return a matcher that will efficiently match exactly these files.'''
795 795 return matchmod.exact(repo.root, repo.getcwd(), files, badfn=badfn)
796 796
797 797 def parsefollowlinespattern(repo, rev, pat, msg):
798 798 """Return a file name from `pat` pattern suitable for usage in followlines
799 799 logic.
800 800 """
801 801 if not matchmod.patkind(pat):
802 802 return pathutil.canonpath(repo.root, repo.getcwd(), pat)
803 803 else:
804 804 ctx = repo[rev]
805 805 m = matchmod.match(repo.root, repo.getcwd(), [pat], ctx=ctx)
806 806 files = [f for f in ctx if m(f)]
807 807 if len(files) != 1:
808 808 raise error.ParseError(msg)
809 809 return files[0]
810 810
811 811 def origpath(ui, repo, filepath):
812 812 '''customize where .orig files are created
813 813
814 814 Fetch user defined path from config file: [ui] origbackuppath = <path>
815 815 Fall back to default (filepath with .orig suffix) if not specified
816 816 '''
817 817 origbackuppath = ui.config('ui', 'origbackuppath')
818 818 if not origbackuppath:
819 819 return filepath + ".orig"
820 820
821 821 # Convert filepath from an absolute path into a path inside the repo.
822 822 filepathfromroot = util.normpath(os.path.relpath(filepath,
823 823 start=repo.root))
824 824
825 825 origvfs = vfs.vfs(repo.wjoin(origbackuppath))
826 826 origbackupdir = origvfs.dirname(filepathfromroot)
827 827 if not origvfs.isdir(origbackupdir) or origvfs.islink(origbackupdir):
828 828 ui.note(_('creating directory: %s\n') % origvfs.join(origbackupdir))
829 829
830 830 # Remove any files that conflict with the backup file's path
831 831 for f in reversed(list(util.finddirs(filepathfromroot))):
832 832 if origvfs.isfileorlink(f):
833 833 ui.note(_('removing conflicting file: %s\n')
834 834 % origvfs.join(f))
835 835 origvfs.unlink(f)
836 836 break
837 837
838 838 origvfs.makedirs(origbackupdir)
839 839
840 840 if origvfs.isdir(filepathfromroot) and not origvfs.islink(filepathfromroot):
841 841 ui.note(_('removing conflicting directory: %s\n')
842 842 % origvfs.join(filepathfromroot))
843 843 origvfs.rmtree(filepathfromroot, forcibly=True)
844 844
845 845 return origvfs.join(filepathfromroot)
846 846
847 847 class _containsnode(object):
848 848 """proxy __contains__(node) to container.__contains__ which accepts revs"""
849 849
850 850 def __init__(self, repo, revcontainer):
851 851 self._torev = repo.changelog.rev
852 852 self._revcontains = revcontainer.__contains__
853 853
854 854 def __contains__(self, node):
855 855 return self._revcontains(self._torev(node))
856 856
857 857 def cleanupnodes(repo, replacements, operation, moves=None, metadata=None,
858 858 fixphase=False, targetphase=None, backup=True):
859 859 """do common cleanups when old nodes are replaced by new nodes
860 860
861 861 That includes writing obsmarkers or stripping nodes, and moving bookmarks.
862 862 (we might also want to move working directory parent in the future)
863 863
864 864 By default, bookmark moves are calculated automatically from 'replacements',
865 865 but 'moves' can be used to override that. Also, 'moves' may include
866 866 additional bookmark moves that should not have associated obsmarkers.
867 867
868 868 replacements is {oldnode: [newnode]} or a iterable of nodes if they do not
869 869 have replacements. operation is a string, like "rebase".
870 870
871 871 metadata is dictionary containing metadata to be stored in obsmarker if
872 872 obsolescence is enabled.
873 873 """
874 874 assert fixphase or targetphase is None
875 875 if not replacements and not moves:
876 876 return
877 877
878 878 # translate mapping's other forms
879 879 if not util.safehasattr(replacements, 'items'):
880 880 replacements = {(n,): () for n in replacements}
881 881 else:
882 882 # upgrading non tuple "source" to tuple ones for BC
883 883 repls = {}
884 884 for key, value in replacements.items():
885 885 if not isinstance(key, tuple):
886 886 key = (key,)
887 887 repls[key] = value
888 888 replacements = repls
889 889
890 890 # Calculate bookmark movements
891 891 if moves is None:
892 892 moves = {}
893 893 # Unfiltered repo is needed since nodes in replacements might be hidden.
894 894 unfi = repo.unfiltered()
895 895 for oldnodes, newnodes in replacements.items():
896 896 for oldnode in oldnodes:
897 897 if oldnode in moves:
898 898 continue
899 899 if len(newnodes) > 1:
900 900 # usually a split, take the one with biggest rev number
901 901 newnode = next(unfi.set('max(%ln)', newnodes)).node()
902 902 elif len(newnodes) == 0:
903 903 # move bookmark backwards
904 904 allreplaced = []
905 905 for rep in replacements:
906 906 allreplaced.extend(rep)
907 907 roots = list(unfi.set('max((::%n) - %ln)', oldnode,
908 908 allreplaced))
909 909 if roots:
910 910 newnode = roots[0].node()
911 911 else:
912 912 newnode = nullid
913 913 else:
914 914 newnode = newnodes[0]
915 915 moves[oldnode] = newnode
916 916
917 917 allnewnodes = [n for ns in replacements.values() for n in ns]
918 918 toretract = {}
919 919 toadvance = {}
920 920 if fixphase:
921 921 precursors = {}
922 922 for oldnodes, newnodes in replacements.items():
923 923 for oldnode in oldnodes:
924 924 for newnode in newnodes:
925 925 precursors.setdefault(newnode, []).append(oldnode)
926 926
927 927 allnewnodes.sort(key=lambda n: unfi[n].rev())
928 928 newphases = {}
929 929 def phase(ctx):
930 930 return newphases.get(ctx.node(), ctx.phase())
931 931 for newnode in allnewnodes:
932 932 ctx = unfi[newnode]
933 933 parentphase = max(phase(p) for p in ctx.parents())
934 934 if targetphase is None:
935 935 oldphase = max(unfi[oldnode].phase()
936 936 for oldnode in precursors[newnode])
937 937 newphase = max(oldphase, parentphase)
938 938 else:
939 939 newphase = max(targetphase, parentphase)
940 940 newphases[newnode] = newphase
941 941 if newphase > ctx.phase():
942 942 toretract.setdefault(newphase, []).append(newnode)
943 943 elif newphase < ctx.phase():
944 944 toadvance.setdefault(newphase, []).append(newnode)
945 945
946 946 with repo.transaction('cleanup') as tr:
947 947 # Move bookmarks
948 948 bmarks = repo._bookmarks
949 949 bmarkchanges = []
950 950 for oldnode, newnode in moves.items():
951 951 oldbmarks = repo.nodebookmarks(oldnode)
952 952 if not oldbmarks:
953 953 continue
954 954 from . import bookmarks # avoid import cycle
955 955 repo.ui.debug('moving bookmarks %r from %s to %s\n' %
956 956 (pycompat.rapply(pycompat.maybebytestr, oldbmarks),
957 957 hex(oldnode), hex(newnode)))
958 958 # Delete divergent bookmarks being parents of related newnodes
959 959 deleterevs = repo.revs('parents(roots(%ln & (::%n))) - parents(%n)',
960 960 allnewnodes, newnode, oldnode)
961 961 deletenodes = _containsnode(repo, deleterevs)
962 962 for name in oldbmarks:
963 963 bmarkchanges.append((name, newnode))
964 964 for b in bookmarks.divergent2delete(repo, deletenodes, name):
965 965 bmarkchanges.append((b, None))
966 966
967 967 if bmarkchanges:
968 968 bmarks.applychanges(repo, tr, bmarkchanges)
969 969
970 970 for phase, nodes in toretract.items():
971 971 phases.retractboundary(repo, tr, phase, nodes)
972 972 for phase, nodes in toadvance.items():
973 973 phases.advanceboundary(repo, tr, phase, nodes)
974 974
975 975 # Obsolete or strip nodes
976 976 if obsolete.isenabled(repo, obsolete.createmarkersopt):
977 977 # If a node is already obsoleted, and we want to obsolete it
978 978 # without a successor, skip that obssolete request since it's
979 979 # unnecessary. That's the "if s or not isobs(n)" check below.
980 980 # Also sort the node in topology order, that might be useful for
981 981 # some obsstore logic.
982 982 # NOTE: the filtering and sorting might belong to createmarkers.
983 983 torev = unfi.changelog.rev
984 984 sortfunc = lambda ns: torev(ns[0][0])
985 985 rels = []
986 986 for ns, s in sorted(replacements.items(), key=sortfunc):
987 for n in ns:
988 rel = (unfi[n], tuple(unfi[m] for m in s))
989 rels.append(rel)
987 rel = (tuple(unfi[n] for n in ns), tuple(unfi[m] for m in s))
988 rels.append(rel)
990 989 if rels:
991 990 obsolete.createmarkers(repo, rels, operation=operation,
992 991 metadata=metadata)
993 992 else:
994 993 from . import repair # avoid import cycle
995 994 tostrip = list(n for ns in replacements for n in ns)
996 995 if tostrip:
997 996 repair.delayedstrip(repo.ui, repo, tostrip, operation,
998 997 backup=backup)
999 998
1000 999 def addremove(repo, matcher, prefix, opts=None):
1001 1000 if opts is None:
1002 1001 opts = {}
1003 1002 m = matcher
1004 1003 dry_run = opts.get('dry_run')
1005 1004 try:
1006 1005 similarity = float(opts.get('similarity') or 0)
1007 1006 except ValueError:
1008 1007 raise error.Abort(_('similarity must be a number'))
1009 1008 if similarity < 0 or similarity > 100:
1010 1009 raise error.Abort(_('similarity must be between 0 and 100'))
1011 1010 similarity /= 100.0
1012 1011
1013 1012 ret = 0
1014 1013 join = lambda f: os.path.join(prefix, f)
1015 1014
1016 1015 wctx = repo[None]
1017 1016 for subpath in sorted(wctx.substate):
1018 1017 submatch = matchmod.subdirmatcher(subpath, m)
1019 1018 if opts.get('subrepos') or m.exact(subpath) or any(submatch.files()):
1020 1019 sub = wctx.sub(subpath)
1021 1020 try:
1022 1021 if sub.addremove(submatch, prefix, opts):
1023 1022 ret = 1
1024 1023 except error.LookupError:
1025 1024 repo.ui.status(_("skipping missing subrepository: %s\n")
1026 1025 % join(subpath))
1027 1026
1028 1027 rejected = []
1029 1028 def badfn(f, msg):
1030 1029 if f in m.files():
1031 1030 m.bad(f, msg)
1032 1031 rejected.append(f)
1033 1032
1034 1033 badmatch = matchmod.badmatch(m, badfn)
1035 1034 added, unknown, deleted, removed, forgotten = _interestingfiles(repo,
1036 1035 badmatch)
1037 1036
1038 1037 unknownset = set(unknown + forgotten)
1039 1038 toprint = unknownset.copy()
1040 1039 toprint.update(deleted)
1041 1040 for abs in sorted(toprint):
1042 1041 if repo.ui.verbose or not m.exact(abs):
1043 1042 if abs in unknownset:
1044 1043 status = _('adding %s\n') % m.uipath(abs)
1045 1044 label = 'addremove.added'
1046 1045 else:
1047 1046 status = _('removing %s\n') % m.uipath(abs)
1048 1047 label = 'addremove.removed'
1049 1048 repo.ui.status(status, label=label)
1050 1049
1051 1050 renames = _findrenames(repo, m, added + unknown, removed + deleted,
1052 1051 similarity)
1053 1052
1054 1053 if not dry_run:
1055 1054 _markchanges(repo, unknown + forgotten, deleted, renames)
1056 1055
1057 1056 for f in rejected:
1058 1057 if f in m.files():
1059 1058 return 1
1060 1059 return ret
1061 1060
1062 1061 def marktouched(repo, files, similarity=0.0):
1063 1062 '''Assert that files have somehow been operated upon. files are relative to
1064 1063 the repo root.'''
1065 1064 m = matchfiles(repo, files, badfn=lambda x, y: rejected.append(x))
1066 1065 rejected = []
1067 1066
1068 1067 added, unknown, deleted, removed, forgotten = _interestingfiles(repo, m)
1069 1068
1070 1069 if repo.ui.verbose:
1071 1070 unknownset = set(unknown + forgotten)
1072 1071 toprint = unknownset.copy()
1073 1072 toprint.update(deleted)
1074 1073 for abs in sorted(toprint):
1075 1074 if abs in unknownset:
1076 1075 status = _('adding %s\n') % abs
1077 1076 else:
1078 1077 status = _('removing %s\n') % abs
1079 1078 repo.ui.status(status)
1080 1079
1081 1080 renames = _findrenames(repo, m, added + unknown, removed + deleted,
1082 1081 similarity)
1083 1082
1084 1083 _markchanges(repo, unknown + forgotten, deleted, renames)
1085 1084
1086 1085 for f in rejected:
1087 1086 if f in m.files():
1088 1087 return 1
1089 1088 return 0
1090 1089
1091 1090 def _interestingfiles(repo, matcher):
1092 1091 '''Walk dirstate with matcher, looking for files that addremove would care
1093 1092 about.
1094 1093
1095 1094 This is different from dirstate.status because it doesn't care about
1096 1095 whether files are modified or clean.'''
1097 1096 added, unknown, deleted, removed, forgotten = [], [], [], [], []
1098 1097 audit_path = pathutil.pathauditor(repo.root, cached=True)
1099 1098
1100 1099 ctx = repo[None]
1101 1100 dirstate = repo.dirstate
1102 1101 walkresults = dirstate.walk(matcher, subrepos=sorted(ctx.substate),
1103 1102 unknown=True, ignored=False, full=False)
1104 1103 for abs, st in walkresults.iteritems():
1105 1104 dstate = dirstate[abs]
1106 1105 if dstate == '?' and audit_path.check(abs):
1107 1106 unknown.append(abs)
1108 1107 elif dstate != 'r' and not st:
1109 1108 deleted.append(abs)
1110 1109 elif dstate == 'r' and st:
1111 1110 forgotten.append(abs)
1112 1111 # for finding renames
1113 1112 elif dstate == 'r' and not st:
1114 1113 removed.append(abs)
1115 1114 elif dstate == 'a':
1116 1115 added.append(abs)
1117 1116
1118 1117 return added, unknown, deleted, removed, forgotten
1119 1118
1120 1119 def _findrenames(repo, matcher, added, removed, similarity):
1121 1120 '''Find renames from removed files to added ones.'''
1122 1121 renames = {}
1123 1122 if similarity > 0:
1124 1123 for old, new, score in similar.findrenames(repo, added, removed,
1125 1124 similarity):
1126 1125 if (repo.ui.verbose or not matcher.exact(old)
1127 1126 or not matcher.exact(new)):
1128 1127 repo.ui.status(_('recording removal of %s as rename to %s '
1129 1128 '(%d%% similar)\n') %
1130 1129 (matcher.rel(old), matcher.rel(new),
1131 1130 score * 100))
1132 1131 renames[new] = old
1133 1132 return renames
1134 1133
1135 1134 def _markchanges(repo, unknown, deleted, renames):
1136 1135 '''Marks the files in unknown as added, the files in deleted as removed,
1137 1136 and the files in renames as copied.'''
1138 1137 wctx = repo[None]
1139 1138 with repo.wlock():
1140 1139 wctx.forget(deleted)
1141 1140 wctx.add(unknown)
1142 1141 for new, old in renames.iteritems():
1143 1142 wctx.copy(old, new)
1144 1143
1145 1144 def dirstatecopy(ui, repo, wctx, src, dst, dryrun=False, cwd=None):
1146 1145 """Update the dirstate to reflect the intent of copying src to dst. For
1147 1146 different reasons it might not end with dst being marked as copied from src.
1148 1147 """
1149 1148 origsrc = repo.dirstate.copied(src) or src
1150 1149 if dst == origsrc: # copying back a copy?
1151 1150 if repo.dirstate[dst] not in 'mn' and not dryrun:
1152 1151 repo.dirstate.normallookup(dst)
1153 1152 else:
1154 1153 if repo.dirstate[origsrc] == 'a' and origsrc == src:
1155 1154 if not ui.quiet:
1156 1155 ui.warn(_("%s has not been committed yet, so no copy "
1157 1156 "data will be stored for %s.\n")
1158 1157 % (repo.pathto(origsrc, cwd), repo.pathto(dst, cwd)))
1159 1158 if repo.dirstate[dst] in '?r' and not dryrun:
1160 1159 wctx.add([dst])
1161 1160 elif not dryrun:
1162 1161 wctx.copy(origsrc, dst)
1163 1162
1164 1163 def writerequires(opener, requirements):
1165 1164 with opener('requires', 'w') as fp:
1166 1165 for r in sorted(requirements):
1167 1166 fp.write("%s\n" % r)
1168 1167
1169 1168 class filecachesubentry(object):
1170 1169 def __init__(self, path, stat):
1171 1170 self.path = path
1172 1171 self.cachestat = None
1173 1172 self._cacheable = None
1174 1173
1175 1174 if stat:
1176 1175 self.cachestat = filecachesubentry.stat(self.path)
1177 1176
1178 1177 if self.cachestat:
1179 1178 self._cacheable = self.cachestat.cacheable()
1180 1179 else:
1181 1180 # None means we don't know yet
1182 1181 self._cacheable = None
1183 1182
1184 1183 def refresh(self):
1185 1184 if self.cacheable():
1186 1185 self.cachestat = filecachesubentry.stat(self.path)
1187 1186
1188 1187 def cacheable(self):
1189 1188 if self._cacheable is not None:
1190 1189 return self._cacheable
1191 1190
1192 1191 # we don't know yet, assume it is for now
1193 1192 return True
1194 1193
1195 1194 def changed(self):
1196 1195 # no point in going further if we can't cache it
1197 1196 if not self.cacheable():
1198 1197 return True
1199 1198
1200 1199 newstat = filecachesubentry.stat(self.path)
1201 1200
1202 1201 # we may not know if it's cacheable yet, check again now
1203 1202 if newstat and self._cacheable is None:
1204 1203 self._cacheable = newstat.cacheable()
1205 1204
1206 1205 # check again
1207 1206 if not self._cacheable:
1208 1207 return True
1209 1208
1210 1209 if self.cachestat != newstat:
1211 1210 self.cachestat = newstat
1212 1211 return True
1213 1212 else:
1214 1213 return False
1215 1214
1216 1215 @staticmethod
1217 1216 def stat(path):
1218 1217 try:
1219 1218 return util.cachestat(path)
1220 1219 except OSError as e:
1221 1220 if e.errno != errno.ENOENT:
1222 1221 raise
1223 1222
1224 1223 class filecacheentry(object):
1225 1224 def __init__(self, paths, stat=True):
1226 1225 self._entries = []
1227 1226 for path in paths:
1228 1227 self._entries.append(filecachesubentry(path, stat))
1229 1228
1230 1229 def changed(self):
1231 1230 '''true if any entry has changed'''
1232 1231 for entry in self._entries:
1233 1232 if entry.changed():
1234 1233 return True
1235 1234 return False
1236 1235
1237 1236 def refresh(self):
1238 1237 for entry in self._entries:
1239 1238 entry.refresh()
1240 1239
1241 1240 class filecache(object):
1242 1241 """A property like decorator that tracks files under .hg/ for updates.
1243 1242
1244 1243 On first access, the files defined as arguments are stat()ed and the
1245 1244 results cached. The decorated function is called. The results are stashed
1246 1245 away in a ``_filecache`` dict on the object whose method is decorated.
1247 1246
1248 1247 On subsequent access, the cached result is returned.
1249 1248
1250 1249 On external property set operations, stat() calls are performed and the new
1251 1250 value is cached.
1252 1251
1253 1252 On property delete operations, cached data is removed.
1254 1253
1255 1254 When using the property API, cached data is always returned, if available:
1256 1255 no stat() is performed to check if the file has changed and if the function
1257 1256 needs to be called to reflect file changes.
1258 1257
1259 1258 Others can muck about with the state of the ``_filecache`` dict. e.g. they
1260 1259 can populate an entry before the property's getter is called. In this case,
1261 1260 entries in ``_filecache`` will be used during property operations,
1262 1261 if available. If the underlying file changes, it is up to external callers
1263 1262 to reflect this by e.g. calling ``delattr(obj, attr)`` to remove the cached
1264 1263 method result as well as possibly calling ``del obj._filecache[attr]`` to
1265 1264 remove the ``filecacheentry``.
1266 1265 """
1267 1266
1268 1267 def __init__(self, *paths):
1269 1268 self.paths = paths
1270 1269
1271 1270 def join(self, obj, fname):
1272 1271 """Used to compute the runtime path of a cached file.
1273 1272
1274 1273 Users should subclass filecache and provide their own version of this
1275 1274 function to call the appropriate join function on 'obj' (an instance
1276 1275 of the class that its member function was decorated).
1277 1276 """
1278 1277 raise NotImplementedError
1279 1278
1280 1279 def __call__(self, func):
1281 1280 self.func = func
1282 1281 self.sname = func.__name__
1283 1282 self.name = pycompat.sysbytes(self.sname)
1284 1283 return self
1285 1284
1286 1285 def __get__(self, obj, type=None):
1287 1286 # if accessed on the class, return the descriptor itself.
1288 1287 if obj is None:
1289 1288 return self
1290 1289 # do we need to check if the file changed?
1291 1290 if self.sname in obj.__dict__:
1292 1291 assert self.name in obj._filecache, self.name
1293 1292 return obj.__dict__[self.sname]
1294 1293
1295 1294 entry = obj._filecache.get(self.name)
1296 1295
1297 1296 if entry:
1298 1297 if entry.changed():
1299 1298 entry.obj = self.func(obj)
1300 1299 else:
1301 1300 paths = [self.join(obj, path) for path in self.paths]
1302 1301
1303 1302 # We stat -before- creating the object so our cache doesn't lie if
1304 1303 # a writer modified between the time we read and stat
1305 1304 entry = filecacheentry(paths, True)
1306 1305 entry.obj = self.func(obj)
1307 1306
1308 1307 obj._filecache[self.name] = entry
1309 1308
1310 1309 obj.__dict__[self.sname] = entry.obj
1311 1310 return entry.obj
1312 1311
1313 1312 def __set__(self, obj, value):
1314 1313 if self.name not in obj._filecache:
1315 1314 # we add an entry for the missing value because X in __dict__
1316 1315 # implies X in _filecache
1317 1316 paths = [self.join(obj, path) for path in self.paths]
1318 1317 ce = filecacheentry(paths, False)
1319 1318 obj._filecache[self.name] = ce
1320 1319 else:
1321 1320 ce = obj._filecache[self.name]
1322 1321
1323 1322 ce.obj = value # update cached copy
1324 1323 obj.__dict__[self.sname] = value # update copy returned by obj.x
1325 1324
1326 1325 def __delete__(self, obj):
1327 1326 try:
1328 1327 del obj.__dict__[self.sname]
1329 1328 except KeyError:
1330 1329 raise AttributeError(self.sname)
1331 1330
1332 1331 def extdatasource(repo, source):
1333 1332 """Gather a map of rev -> value dict from the specified source
1334 1333
1335 1334 A source spec is treated as a URL, with a special case shell: type
1336 1335 for parsing the output from a shell command.
1337 1336
1338 1337 The data is parsed as a series of newline-separated records where
1339 1338 each record is a revision specifier optionally followed by a space
1340 1339 and a freeform string value. If the revision is known locally, it
1341 1340 is converted to a rev, otherwise the record is skipped.
1342 1341
1343 1342 Note that both key and value are treated as UTF-8 and converted to
1344 1343 the local encoding. This allows uniformity between local and
1345 1344 remote data sources.
1346 1345 """
1347 1346
1348 1347 spec = repo.ui.config("extdata", source)
1349 1348 if not spec:
1350 1349 raise error.Abort(_("unknown extdata source '%s'") % source)
1351 1350
1352 1351 data = {}
1353 1352 src = proc = None
1354 1353 try:
1355 1354 if spec.startswith("shell:"):
1356 1355 # external commands should be run relative to the repo root
1357 1356 cmd = spec[6:]
1358 1357 proc = subprocess.Popen(procutil.tonativestr(cmd),
1359 1358 shell=True, bufsize=-1,
1360 1359 close_fds=procutil.closefds,
1361 1360 stdout=subprocess.PIPE,
1362 1361 cwd=procutil.tonativestr(repo.root))
1363 1362 src = proc.stdout
1364 1363 else:
1365 1364 # treat as a URL or file
1366 1365 src = url.open(repo.ui, spec)
1367 1366 for l in src:
1368 1367 if " " in l:
1369 1368 k, v = l.strip().split(" ", 1)
1370 1369 else:
1371 1370 k, v = l.strip(), ""
1372 1371
1373 1372 k = encoding.tolocal(k)
1374 1373 try:
1375 1374 data[revsingle(repo, k).rev()] = encoding.tolocal(v)
1376 1375 except (error.LookupError, error.RepoLookupError):
1377 1376 pass # we ignore data for nodes that don't exist locally
1378 1377 finally:
1379 1378 if proc:
1380 1379 proc.communicate()
1381 1380 if src:
1382 1381 src.close()
1383 1382 if proc and proc.returncode != 0:
1384 1383 raise error.Abort(_("extdata command '%s' failed: %s")
1385 1384 % (cmd, procutil.explainexit(proc.returncode)))
1386 1385
1387 1386 return data
1388 1387
1389 1388 def _locksub(repo, lock, envvar, cmd, environ=None, *args, **kwargs):
1390 1389 if lock is None:
1391 1390 raise error.LockInheritanceContractViolation(
1392 1391 'lock can only be inherited while held')
1393 1392 if environ is None:
1394 1393 environ = {}
1395 1394 with lock.inherit() as locker:
1396 1395 environ[envvar] = locker
1397 1396 return repo.ui.system(cmd, environ=environ, *args, **kwargs)
1398 1397
1399 1398 def wlocksub(repo, cmd, *args, **kwargs):
1400 1399 """run cmd as a subprocess that allows inheriting repo's wlock
1401 1400
1402 1401 This can only be called while the wlock is held. This takes all the
1403 1402 arguments that ui.system does, and returns the exit code of the
1404 1403 subprocess."""
1405 1404 return _locksub(repo, repo.currentwlock(), 'HG_WLOCK_LOCKER', cmd, *args,
1406 1405 **kwargs)
1407 1406
1408 1407 class progress(object):
1409 1408 def __init__(self, ui, topic, unit="", total=None):
1410 1409 self.ui = ui
1411 1410 self.pos = 0
1412 1411 self.topic = topic
1413 1412 self.unit = unit
1414 1413 self.total = total
1415 1414
1416 1415 def __enter__(self):
1417 1416 return self
1418 1417
1419 1418 def __exit__(self, exc_type, exc_value, exc_tb):
1420 1419 self.complete()
1421 1420
1422 1421 def update(self, pos, item="", total=None):
1423 1422 assert pos is not None
1424 1423 if total:
1425 1424 self.total = total
1426 1425 self.pos = pos
1427 1426 self._print(item)
1428 1427
1429 1428 def increment(self, step=1, item="", total=None):
1430 1429 self.update(self.pos + step, item, total)
1431 1430
1432 1431 def complete(self):
1433 1432 self.ui.progress(self.topic, None)
1434 1433
1435 1434 def _print(self, item):
1436 1435 self.ui.progress(self.topic, self.pos, item, self.unit,
1437 1436 self.total)
1438 1437
1439 1438 def gdinitconfig(ui):
1440 1439 """helper function to know if a repo should be created as general delta
1441 1440 """
1442 1441 # experimental config: format.generaldelta
1443 1442 return (ui.configbool('format', 'generaldelta')
1444 1443 or ui.configbool('format', 'usegeneraldelta')
1445 1444 or ui.configbool('format', 'sparse-revlog'))
1446 1445
1447 1446 def gddeltaconfig(ui):
1448 1447 """helper function to know if incoming delta should be optimised
1449 1448 """
1450 1449 # experimental config: format.generaldelta
1451 1450 return ui.configbool('format', 'generaldelta')
1452 1451
1453 1452 class simplekeyvaluefile(object):
1454 1453 """A simple file with key=value lines
1455 1454
1456 1455 Keys must be alphanumerics and start with a letter, values must not
1457 1456 contain '\n' characters"""
1458 1457 firstlinekey = '__firstline'
1459 1458
1460 1459 def __init__(self, vfs, path, keys=None):
1461 1460 self.vfs = vfs
1462 1461 self.path = path
1463 1462
1464 1463 def read(self, firstlinenonkeyval=False):
1465 1464 """Read the contents of a simple key-value file
1466 1465
1467 1466 'firstlinenonkeyval' indicates whether the first line of file should
1468 1467 be treated as a key-value pair or reuturned fully under the
1469 1468 __firstline key."""
1470 1469 lines = self.vfs.readlines(self.path)
1471 1470 d = {}
1472 1471 if firstlinenonkeyval:
1473 1472 if not lines:
1474 1473 e = _("empty simplekeyvalue file")
1475 1474 raise error.CorruptedState(e)
1476 1475 # we don't want to include '\n' in the __firstline
1477 1476 d[self.firstlinekey] = lines[0][:-1]
1478 1477 del lines[0]
1479 1478
1480 1479 try:
1481 1480 # the 'if line.strip()' part prevents us from failing on empty
1482 1481 # lines which only contain '\n' therefore are not skipped
1483 1482 # by 'if line'
1484 1483 updatedict = dict(line[:-1].split('=', 1) for line in lines
1485 1484 if line.strip())
1486 1485 if self.firstlinekey in updatedict:
1487 1486 e = _("%r can't be used as a key")
1488 1487 raise error.CorruptedState(e % self.firstlinekey)
1489 1488 d.update(updatedict)
1490 1489 except ValueError as e:
1491 1490 raise error.CorruptedState(str(e))
1492 1491 return d
1493 1492
1494 1493 def write(self, data, firstline=None):
1495 1494 """Write key=>value mapping to a file
1496 1495 data is a dict. Keys must be alphanumerical and start with a letter.
1497 1496 Values must not contain newline characters.
1498 1497
1499 1498 If 'firstline' is not None, it is written to file before
1500 1499 everything else, as it is, not in a key=value form"""
1501 1500 lines = []
1502 1501 if firstline is not None:
1503 1502 lines.append('%s\n' % firstline)
1504 1503
1505 1504 for k, v in data.items():
1506 1505 if k == self.firstlinekey:
1507 1506 e = "key name '%s' is reserved" % self.firstlinekey
1508 1507 raise error.ProgrammingError(e)
1509 1508 if not k[0:1].isalpha():
1510 1509 e = "keys must start with a letter in a key-value file"
1511 1510 raise error.ProgrammingError(e)
1512 1511 if not k.isalnum():
1513 1512 e = "invalid key name in a simple key-value file"
1514 1513 raise error.ProgrammingError(e)
1515 1514 if '\n' in v:
1516 1515 e = "invalid value in a simple key-value file"
1517 1516 raise error.ProgrammingError(e)
1518 1517 lines.append("%s=%s\n" % (k, v))
1519 1518 with self.vfs(self.path, mode='wb', atomictemp=True) as fp:
1520 1519 fp.write(''.join(lines))
1521 1520
1522 1521 _reportobsoletedsource = [
1523 1522 'debugobsolete',
1524 1523 'pull',
1525 1524 'push',
1526 1525 'serve',
1527 1526 'unbundle',
1528 1527 ]
1529 1528
1530 1529 _reportnewcssource = [
1531 1530 'pull',
1532 1531 'unbundle',
1533 1532 ]
1534 1533
1535 1534 def prefetchfiles(repo, revs, match):
1536 1535 """Invokes the registered file prefetch functions, allowing extensions to
1537 1536 ensure the corresponding files are available locally, before the command
1538 1537 uses them."""
1539 1538 if match:
1540 1539 # The command itself will complain about files that don't exist, so
1541 1540 # don't duplicate the message.
1542 1541 match = matchmod.badmatch(match, lambda fn, msg: None)
1543 1542 else:
1544 1543 match = matchall(repo)
1545 1544
1546 1545 fileprefetchhooks(repo, revs, match)
1547 1546
1548 1547 # a list of (repo, revs, match) prefetch functions
1549 1548 fileprefetchhooks = util.hooks()
1550 1549
1551 1550 # A marker that tells the evolve extension to suppress its own reporting
1552 1551 _reportstroubledchangesets = True
1553 1552
1554 1553 def registersummarycallback(repo, otr, txnname=''):
1555 1554 """register a callback to issue a summary after the transaction is closed
1556 1555 """
1557 1556 def txmatch(sources):
1558 1557 return any(txnname.startswith(source) for source in sources)
1559 1558
1560 1559 categories = []
1561 1560
1562 1561 def reportsummary(func):
1563 1562 """decorator for report callbacks."""
1564 1563 # The repoview life cycle is shorter than the one of the actual
1565 1564 # underlying repository. So the filtered object can die before the
1566 1565 # weakref is used leading to troubles. We keep a reference to the
1567 1566 # unfiltered object and restore the filtering when retrieving the
1568 1567 # repository through the weakref.
1569 1568 filtername = repo.filtername
1570 1569 reporef = weakref.ref(repo.unfiltered())
1571 1570 def wrapped(tr):
1572 1571 repo = reporef()
1573 1572 if filtername:
1574 1573 repo = repo.filtered(filtername)
1575 1574 func(repo, tr)
1576 1575 newcat = '%02i-txnreport' % len(categories)
1577 1576 otr.addpostclose(newcat, wrapped)
1578 1577 categories.append(newcat)
1579 1578 return wrapped
1580 1579
1581 1580 if txmatch(_reportobsoletedsource):
1582 1581 @reportsummary
1583 1582 def reportobsoleted(repo, tr):
1584 1583 obsoleted = obsutil.getobsoleted(repo, tr)
1585 1584 if obsoleted:
1586 1585 repo.ui.status(_('obsoleted %i changesets\n')
1587 1586 % len(obsoleted))
1588 1587
1589 1588 if (obsolete.isenabled(repo, obsolete.createmarkersopt) and
1590 1589 repo.ui.configbool('experimental', 'evolution.report-instabilities')):
1591 1590 instabilitytypes = [
1592 1591 ('orphan', 'orphan'),
1593 1592 ('phase-divergent', 'phasedivergent'),
1594 1593 ('content-divergent', 'contentdivergent'),
1595 1594 ]
1596 1595
1597 1596 def getinstabilitycounts(repo):
1598 1597 filtered = repo.changelog.filteredrevs
1599 1598 counts = {}
1600 1599 for instability, revset in instabilitytypes:
1601 1600 counts[instability] = len(set(obsolete.getrevs(repo, revset)) -
1602 1601 filtered)
1603 1602 return counts
1604 1603
1605 1604 oldinstabilitycounts = getinstabilitycounts(repo)
1606 1605 @reportsummary
1607 1606 def reportnewinstabilities(repo, tr):
1608 1607 newinstabilitycounts = getinstabilitycounts(repo)
1609 1608 for instability, revset in instabilitytypes:
1610 1609 delta = (newinstabilitycounts[instability] -
1611 1610 oldinstabilitycounts[instability])
1612 1611 msg = getinstabilitymessage(delta, instability)
1613 1612 if msg:
1614 1613 repo.ui.warn(msg)
1615 1614
1616 1615 if txmatch(_reportnewcssource):
1617 1616 @reportsummary
1618 1617 def reportnewcs(repo, tr):
1619 1618 """Report the range of new revisions pulled/unbundled."""
1620 1619 origrepolen = tr.changes.get('origrepolen', len(repo))
1621 1620 unfi = repo.unfiltered()
1622 1621 if origrepolen >= len(unfi):
1623 1622 return
1624 1623
1625 1624 # Compute the bounds of new visible revisions' range.
1626 1625 revs = smartset.spanset(repo, start=origrepolen)
1627 1626 if revs:
1628 1627 minrev, maxrev = repo[revs.min()], repo[revs.max()]
1629 1628
1630 1629 if minrev == maxrev:
1631 1630 revrange = minrev
1632 1631 else:
1633 1632 revrange = '%s:%s' % (minrev, maxrev)
1634 1633 draft = len(repo.revs('%ld and draft()', revs))
1635 1634 secret = len(repo.revs('%ld and secret()', revs))
1636 1635 if not (draft or secret):
1637 1636 msg = _('new changesets %s\n') % revrange
1638 1637 elif draft and secret:
1639 1638 msg = _('new changesets %s (%d drafts, %d secrets)\n')
1640 1639 msg %= (revrange, draft, secret)
1641 1640 elif draft:
1642 1641 msg = _('new changesets %s (%d drafts)\n')
1643 1642 msg %= (revrange, draft)
1644 1643 elif secret:
1645 1644 msg = _('new changesets %s (%d secrets)\n')
1646 1645 msg %= (revrange, secret)
1647 1646 else:
1648 1647 errormsg = 'entered unreachable condition'
1649 1648 raise error.ProgrammingError(errormsg)
1650 1649 repo.ui.status(msg)
1651 1650
1652 1651 # search new changesets directly pulled as obsolete
1653 1652 duplicates = tr.changes.get('revduplicates', ())
1654 1653 obsadded = unfi.revs('(%d: + %ld) and obsolete()',
1655 1654 origrepolen, duplicates)
1656 1655 cl = repo.changelog
1657 1656 extinctadded = [r for r in obsadded if r not in cl]
1658 1657 if extinctadded:
1659 1658 # They are not just obsolete, but obsolete and invisible
1660 1659 # we call them "extinct" internally but the terms have not been
1661 1660 # exposed to users.
1662 1661 msg = '(%d other changesets obsolete on arrival)\n'
1663 1662 repo.ui.status(msg % len(extinctadded))
1664 1663
1665 1664 @reportsummary
1666 1665 def reportphasechanges(repo, tr):
1667 1666 """Report statistics of phase changes for changesets pre-existing
1668 1667 pull/unbundle.
1669 1668 """
1670 1669 origrepolen = tr.changes.get('origrepolen', len(repo))
1671 1670 phasetracking = tr.changes.get('phases', {})
1672 1671 if not phasetracking:
1673 1672 return
1674 1673 published = [
1675 1674 rev for rev, (old, new) in phasetracking.iteritems()
1676 1675 if new == phases.public and rev < origrepolen
1677 1676 ]
1678 1677 if not published:
1679 1678 return
1680 1679 repo.ui.status(_('%d local changesets published\n')
1681 1680 % len(published))
1682 1681
1683 1682 def getinstabilitymessage(delta, instability):
1684 1683 """function to return the message to show warning about new instabilities
1685 1684
1686 1685 exists as a separate function so that extension can wrap to show more
1687 1686 information like how to fix instabilities"""
1688 1687 if delta > 0:
1689 1688 return _('%i new %s changesets\n') % (delta, instability)
1690 1689
1691 1690 def nodesummaries(repo, nodes, maxnumnodes=4):
1692 1691 if len(nodes) <= maxnumnodes or repo.ui.verbose:
1693 1692 return ' '.join(short(h) for h in nodes)
1694 1693 first = ' '.join(short(h) for h in nodes[:maxnumnodes])
1695 1694 return _("%s and %d others") % (first, len(nodes) - maxnumnodes)
1696 1695
1697 1696 def enforcesinglehead(repo, tr, desc):
1698 1697 """check that no named branch has multiple heads"""
1699 1698 if desc in ('strip', 'repair'):
1700 1699 # skip the logic during strip
1701 1700 return
1702 1701 visible = repo.filtered('visible')
1703 1702 # possible improvement: we could restrict the check to affected branch
1704 1703 for name, heads in visible.branchmap().iteritems():
1705 1704 if len(heads) > 1:
1706 1705 msg = _('rejecting multiple heads on branch "%s"')
1707 1706 msg %= name
1708 1707 hint = _('%d heads: %s')
1709 1708 hint %= (len(heads), nodesummaries(repo, heads))
1710 1709 raise error.Abort(msg, hint=hint)
1711 1710
1712 1711 def wrapconvertsink(sink):
1713 1712 """Allow extensions to wrap the sink returned by convcmd.convertsink()
1714 1713 before it is used, whether or not the convert extension was formally loaded.
1715 1714 """
1716 1715 return sink
1717 1716
1718 1717 def unhidehashlikerevs(repo, specs, hiddentype):
1719 1718 """parse the user specs and unhide changesets whose hash or revision number
1720 1719 is passed.
1721 1720
1722 1721 hiddentype can be: 1) 'warn': warn while unhiding changesets
1723 1722 2) 'nowarn': don't warn while unhiding changesets
1724 1723
1725 1724 returns a repo object with the required changesets unhidden
1726 1725 """
1727 1726 if not repo.filtername or not repo.ui.configbool('experimental',
1728 1727 'directaccess'):
1729 1728 return repo
1730 1729
1731 1730 if repo.filtername not in ('visible', 'visible-hidden'):
1732 1731 return repo
1733 1732
1734 1733 symbols = set()
1735 1734 for spec in specs:
1736 1735 try:
1737 1736 tree = revsetlang.parse(spec)
1738 1737 except error.ParseError: # will be reported by scmutil.revrange()
1739 1738 continue
1740 1739
1741 1740 symbols.update(revsetlang.gethashlikesymbols(tree))
1742 1741
1743 1742 if not symbols:
1744 1743 return repo
1745 1744
1746 1745 revs = _getrevsfromsymbols(repo, symbols)
1747 1746
1748 1747 if not revs:
1749 1748 return repo
1750 1749
1751 1750 if hiddentype == 'warn':
1752 1751 unfi = repo.unfiltered()
1753 1752 revstr = ", ".join([pycompat.bytestr(unfi[l]) for l in revs])
1754 1753 repo.ui.warn(_("warning: accessing hidden changesets for write "
1755 1754 "operation: %s\n") % revstr)
1756 1755
1757 1756 # we have to use new filtername to separate branch/tags cache until we can
1758 1757 # disbale these cache when revisions are dynamically pinned.
1759 1758 return repo.filtered('visible-hidden', revs)
1760 1759
1761 1760 def _getrevsfromsymbols(repo, symbols):
1762 1761 """parse the list of symbols and returns a set of revision numbers of hidden
1763 1762 changesets present in symbols"""
1764 1763 revs = set()
1765 1764 unfi = repo.unfiltered()
1766 1765 unficl = unfi.changelog
1767 1766 cl = repo.changelog
1768 1767 tiprev = len(unficl)
1769 1768 allowrevnums = repo.ui.configbool('experimental', 'directaccess.revnums')
1770 1769 for s in symbols:
1771 1770 try:
1772 1771 n = int(s)
1773 1772 if n <= tiprev:
1774 1773 if not allowrevnums:
1775 1774 continue
1776 1775 else:
1777 1776 if n not in cl:
1778 1777 revs.add(n)
1779 1778 continue
1780 1779 except ValueError:
1781 1780 pass
1782 1781
1783 1782 try:
1784 1783 s = resolvehexnodeidprefix(unfi, s)
1785 1784 except (error.LookupError, error.WdirUnsupported):
1786 1785 s = None
1787 1786
1788 1787 if s is not None:
1789 1788 rev = unficl.rev(s)
1790 1789 if rev not in cl:
1791 1790 revs.add(rev)
1792 1791
1793 1792 return revs
1794 1793
1795 1794 def bookmarkrevs(repo, mark):
1796 1795 """
1797 1796 Select revisions reachable by a given bookmark
1798 1797 """
1799 1798 return repo.revs("ancestors(bookmark(%s)) - "
1800 1799 "ancestors(head() and not bookmark(%s)) - "
1801 1800 "ancestors(bookmark() and not bookmark(%s))",
1802 1801 mark, mark, mark)
General Comments 0
You need to be logged in to leave comments. Login now