##// END OF EJS Templates
phabricator: add debug logging to show previous node values in `phabsend`...
Matt Harbison -
r45209:38f7b2f0 default
parent child Browse files
Show More
@@ -1,2070 +1,2104
1 1 # phabricator.py - simple Phabricator integration
2 2 #
3 3 # Copyright 2017 Facebook, Inc.
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 """simple Phabricator integration (EXPERIMENTAL)
8 8
9 9 This extension provides a ``phabsend`` command which sends a stack of
10 10 changesets to Phabricator, and a ``phabread`` command which prints a stack of
11 11 revisions in a format suitable for :hg:`import`, and a ``phabupdate`` command
12 12 to update statuses in batch.
13 13
14 14 A "phabstatus" view for :hg:`show` is also provided; it displays status
15 15 information of Phabricator differentials associated with unfinished
16 16 changesets.
17 17
18 18 By default, Phabricator requires ``Test Plan`` which might prevent some
19 19 changeset from being sent. The requirement could be disabled by changing
20 20 ``differential.require-test-plan-field`` config server side.
21 21
22 22 Config::
23 23
24 24 [phabricator]
25 25 # Phabricator URL
26 26 url = https://phab.example.com/
27 27
28 28 # Repo callsign. If a repo has a URL https://$HOST/diffusion/FOO, then its
29 29 # callsign is "FOO".
30 30 callsign = FOO
31 31
32 32 # curl command to use. If not set (default), use builtin HTTP library to
33 33 # communicate. If set, use the specified curl command. This could be useful
34 34 # if you need to specify advanced options that is not easily supported by
35 35 # the internal library.
36 36 curlcmd = curl --connect-timeout 2 --retry 3 --silent
37 37
38 38 [auth]
39 39 example.schemes = https
40 40 example.prefix = phab.example.com
41 41
42 42 # API token. Get it from https://$HOST/conduit/login/
43 43 example.phabtoken = cli-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
44 44 """
45 45
46 46 from __future__ import absolute_import
47 47
48 48 import base64
49 49 import contextlib
50 50 import hashlib
51 51 import itertools
52 52 import json
53 53 import mimetypes
54 54 import operator
55 55 import re
56 56
57 from mercurial.node import bin, nullid
57 from mercurial.node import bin, nullid, short
58 58 from mercurial.i18n import _
59 59 from mercurial.pycompat import getattr
60 60 from mercurial.thirdparty import attr
61 61 from mercurial import (
62 62 cmdutil,
63 63 context,
64 64 copies,
65 65 encoding,
66 66 error,
67 67 exthelper,
68 68 graphmod,
69 69 httpconnection as httpconnectionmod,
70 70 localrepo,
71 71 logcmdutil,
72 72 match,
73 73 mdiff,
74 74 obsutil,
75 75 parser,
76 76 patch,
77 77 phases,
78 78 pycompat,
79 79 scmutil,
80 80 smartset,
81 81 tags,
82 82 templatefilters,
83 83 templateutil,
84 84 url as urlmod,
85 85 util,
86 86 )
87 87 from mercurial.utils import (
88 88 procutil,
89 89 stringutil,
90 90 )
91 91 from . import show
92 92
93 93
94 94 # Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
95 95 # extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
96 96 # be specifying the version(s) of Mercurial they are tested with, or
97 97 # leave the attribute unspecified.
98 98 testedwith = b'ships-with-hg-core'
99 99
100 100 eh = exthelper.exthelper()
101 101
102 102 cmdtable = eh.cmdtable
103 103 command = eh.command
104 104 configtable = eh.configtable
105 105 templatekeyword = eh.templatekeyword
106 106 uisetup = eh.finaluisetup
107 107
108 108 # developer config: phabricator.batchsize
109 109 eh.configitem(
110 110 b'phabricator', b'batchsize', default=12,
111 111 )
112 112 eh.configitem(
113 113 b'phabricator', b'callsign', default=None,
114 114 )
115 115 eh.configitem(
116 116 b'phabricator', b'curlcmd', default=None,
117 117 )
118 # developer config: phabricator.debug
119 eh.configitem(
120 b'phabricator', b'debug', default=False,
121 )
118 122 # developer config: phabricator.repophid
119 123 eh.configitem(
120 124 b'phabricator', b'repophid', default=None,
121 125 )
122 126 eh.configitem(
123 127 b'phabricator', b'url', default=None,
124 128 )
125 129 eh.configitem(
126 130 b'phabsend', b'confirm', default=False,
127 131 )
128 132 eh.configitem(
129 133 b'phabimport', b'secret', default=False,
130 134 )
131 135 eh.configitem(
132 136 b'phabimport', b'obsolete', default=False,
133 137 )
134 138
135 139 colortable = {
136 140 b'phabricator.action.created': b'green',
137 141 b'phabricator.action.skipped': b'magenta',
138 142 b'phabricator.action.updated': b'magenta',
139 143 b'phabricator.desc': b'',
140 144 b'phabricator.drev': b'bold',
141 145 b'phabricator.node': b'',
142 146 b'phabricator.status.abandoned': b'magenta dim',
143 147 b'phabricator.status.accepted': b'green bold',
144 148 b'phabricator.status.closed': b'green',
145 149 b'phabricator.status.needsreview': b'yellow',
146 150 b'phabricator.status.needsrevision': b'red',
147 151 b'phabricator.status.changesplanned': b'red',
148 152 }
149 153
150 154 _VCR_FLAGS = [
151 155 (
152 156 b'',
153 157 b'test-vcr',
154 158 b'',
155 159 _(
156 160 b'Path to a vcr file. If nonexistent, will record a new vcr transcript'
157 161 b', otherwise will mock all http requests using the specified vcr file.'
158 162 b' (ADVANCED)'
159 163 ),
160 164 ),
161 165 ]
162 166
163 167
164 168 @eh.wrapfunction(localrepo, "loadhgrc")
165 169 def _loadhgrc(orig, ui, wdirvfs, hgvfs, requirements):
166 170 """Load ``.arcconfig`` content into a ui instance on repository open.
167 171 """
168 172 result = False
169 173 arcconfig = {}
170 174
171 175 try:
172 176 # json.loads only accepts bytes from 3.6+
173 177 rawparams = encoding.unifromlocal(wdirvfs.read(b".arcconfig"))
174 178 # json.loads only returns unicode strings
175 179 arcconfig = pycompat.rapply(
176 180 lambda x: encoding.unitolocal(x)
177 181 if isinstance(x, pycompat.unicode)
178 182 else x,
179 183 pycompat.json_loads(rawparams),
180 184 )
181 185
182 186 result = True
183 187 except ValueError:
184 188 ui.warn(_(b"invalid JSON in %s\n") % wdirvfs.join(b".arcconfig"))
185 189 except IOError:
186 190 pass
187 191
188 192 cfg = util.sortdict()
189 193
190 194 if b"repository.callsign" in arcconfig:
191 195 cfg[(b"phabricator", b"callsign")] = arcconfig[b"repository.callsign"]
192 196
193 197 if b"phabricator.uri" in arcconfig:
194 198 cfg[(b"phabricator", b"url")] = arcconfig[b"phabricator.uri"]
195 199
196 200 if cfg:
197 201 ui.applyconfig(cfg, source=wdirvfs.join(b".arcconfig"))
198 202
199 203 return orig(ui, wdirvfs, hgvfs, requirements) or result # Load .hg/hgrc
200 204
201 205
202 206 def vcrcommand(name, flags, spec, helpcategory=None, optionalrepo=False):
203 207 fullflags = flags + _VCR_FLAGS
204 208
205 209 def hgmatcher(r1, r2):
206 210 if r1.uri != r2.uri or r1.method != r2.method:
207 211 return False
208 212 r1params = util.urlreq.parseqs(r1.body)
209 213 r2params = util.urlreq.parseqs(r2.body)
210 214 for key in r1params:
211 215 if key not in r2params:
212 216 return False
213 217 value = r1params[key][0]
214 218 # we want to compare json payloads without worrying about ordering
215 219 if value.startswith(b'{') and value.endswith(b'}'):
216 220 r1json = pycompat.json_loads(value)
217 221 r2json = pycompat.json_loads(r2params[key][0])
218 222 if r1json != r2json:
219 223 return False
220 224 elif r2params[key][0] != value:
221 225 return False
222 226 return True
223 227
224 228 def sanitiserequest(request):
225 229 request.body = re.sub(
226 230 br'cli-[a-z0-9]+', br'cli-hahayouwish', request.body
227 231 )
228 232 return request
229 233
230 234 def sanitiseresponse(response):
231 235 if 'set-cookie' in response['headers']:
232 236 del response['headers']['set-cookie']
233 237 return response
234 238
235 239 def decorate(fn):
236 240 def inner(*args, **kwargs):
237 241 cassette = pycompat.fsdecode(kwargs.pop('test_vcr', None))
238 242 if cassette:
239 243 import hgdemandimport
240 244
241 245 with hgdemandimport.deactivated():
242 246 import vcr as vcrmod
243 247 import vcr.stubs as stubs
244 248
245 249 vcr = vcrmod.VCR(
246 250 serializer='json',
247 251 before_record_request=sanitiserequest,
248 252 before_record_response=sanitiseresponse,
249 253 custom_patches=[
250 254 (
251 255 urlmod,
252 256 'httpconnection',
253 257 stubs.VCRHTTPConnection,
254 258 ),
255 259 (
256 260 urlmod,
257 261 'httpsconnection',
258 262 stubs.VCRHTTPSConnection,
259 263 ),
260 264 ],
261 265 )
262 266 vcr.register_matcher('hgmatcher', hgmatcher)
263 267 with vcr.use_cassette(cassette, match_on=['hgmatcher']):
264 268 return fn(*args, **kwargs)
265 269 return fn(*args, **kwargs)
266 270
267 271 cmd = util.checksignature(inner, depth=2)
268 272 cmd.__name__ = fn.__name__
269 273 cmd.__doc__ = fn.__doc__
270 274
271 275 return command(
272 276 name,
273 277 fullflags,
274 278 spec,
275 279 helpcategory=helpcategory,
276 280 optionalrepo=optionalrepo,
277 281 )(cmd)
278 282
279 283 return decorate
280 284
281 285
286 def _debug(ui, *msg, **opts):
287 """write debug output for Phabricator if ``phabricator.debug`` is set
288
289 Specifically, this avoids dumping Conduit and HTTP auth chatter that is
290 printed with the --debug argument.
291 """
292 if ui.configbool(b"phabricator", b"debug"):
293 flag = ui.debugflag
294 try:
295 ui.debugflag = True
296 ui.write(*msg, **opts)
297 finally:
298 ui.debugflag = flag
299
300
282 301 def urlencodenested(params):
283 302 """like urlencode, but works with nested parameters.
284 303
285 304 For example, if params is {'a': ['b', 'c'], 'd': {'e': 'f'}}, it will be
286 305 flattened to {'a[0]': 'b', 'a[1]': 'c', 'd[e]': 'f'} and then passed to
287 306 urlencode. Note: the encoding is consistent with PHP's http_build_query.
288 307 """
289 308 flatparams = util.sortdict()
290 309
291 310 def process(prefix, obj):
292 311 if isinstance(obj, bool):
293 312 obj = {True: b'true', False: b'false'}[obj] # Python -> PHP form
294 313 lister = lambda l: [(b'%d' % k, v) for k, v in enumerate(l)]
295 314 items = {list: lister, dict: lambda x: x.items()}.get(type(obj))
296 315 if items is None:
297 316 flatparams[prefix] = obj
298 317 else:
299 318 for k, v in items(obj):
300 319 if prefix:
301 320 process(b'%s[%s]' % (prefix, k), v)
302 321 else:
303 322 process(k, v)
304 323
305 324 process(b'', params)
306 325 return util.urlreq.urlencode(flatparams)
307 326
308 327
309 328 def readurltoken(ui):
310 329 """return conduit url, token and make sure they exist
311 330
312 331 Currently read from [auth] config section. In the future, it might
313 332 make sense to read from .arcconfig and .arcrc as well.
314 333 """
315 334 url = ui.config(b'phabricator', b'url')
316 335 if not url:
317 336 raise error.Abort(
318 337 _(b'config %s.%s is required') % (b'phabricator', b'url')
319 338 )
320 339
321 340 res = httpconnectionmod.readauthforuri(ui, url, util.url(url).user)
322 341 token = None
323 342
324 343 if res:
325 344 group, auth = res
326 345
327 346 ui.debug(b"using auth.%s.* for authentication\n" % group)
328 347
329 348 token = auth.get(b'phabtoken')
330 349
331 350 if not token:
332 351 raise error.Abort(
333 352 _(b'Can\'t find conduit token associated to %s') % (url,)
334 353 )
335 354
336 355 return url, token
337 356
338 357
339 358 def callconduit(ui, name, params):
340 359 """call Conduit API, params is a dict. return json.loads result, or None"""
341 360 host, token = readurltoken(ui)
342 361 url, authinfo = util.url(b'/'.join([host, b'api', name])).authinfo()
343 362 ui.debug(b'Conduit Call: %s %s\n' % (url, pycompat.byterepr(params)))
344 363 params = params.copy()
345 364 params[b'__conduit__'] = {
346 365 b'token': token,
347 366 }
348 367 rawdata = {
349 368 b'params': templatefilters.json(params),
350 369 b'output': b'json',
351 370 b'__conduit__': 1,
352 371 }
353 372 data = urlencodenested(rawdata)
354 373 curlcmd = ui.config(b'phabricator', b'curlcmd')
355 374 if curlcmd:
356 375 sin, sout = procutil.popen2(
357 376 b'%s -d @- %s' % (curlcmd, procutil.shellquote(url))
358 377 )
359 378 sin.write(data)
360 379 sin.close()
361 380 body = sout.read()
362 381 else:
363 382 urlopener = urlmod.opener(ui, authinfo)
364 383 request = util.urlreq.request(pycompat.strurl(url), data=data)
365 384 with contextlib.closing(urlopener.open(request)) as rsp:
366 385 body = rsp.read()
367 386 ui.debug(b'Conduit Response: %s\n' % body)
368 387 parsed = pycompat.rapply(
369 388 lambda x: encoding.unitolocal(x)
370 389 if isinstance(x, pycompat.unicode)
371 390 else x,
372 391 # json.loads only accepts bytes from py3.6+
373 392 pycompat.json_loads(encoding.unifromlocal(body)),
374 393 )
375 394 if parsed.get(b'error_code'):
376 395 msg = _(b'Conduit Error (%s): %s') % (
377 396 parsed[b'error_code'],
378 397 parsed[b'error_info'],
379 398 )
380 399 raise error.Abort(msg)
381 400 return parsed[b'result']
382 401
383 402
384 403 @vcrcommand(b'debugcallconduit', [], _(b'METHOD'), optionalrepo=True)
385 404 def debugcallconduit(ui, repo, name):
386 405 """call Conduit API
387 406
388 407 Call parameters are read from stdin as a JSON blob. Result will be written
389 408 to stdout as a JSON blob.
390 409 """
391 410 # json.loads only accepts bytes from 3.6+
392 411 rawparams = encoding.unifromlocal(ui.fin.read())
393 412 # json.loads only returns unicode strings
394 413 params = pycompat.rapply(
395 414 lambda x: encoding.unitolocal(x)
396 415 if isinstance(x, pycompat.unicode)
397 416 else x,
398 417 pycompat.json_loads(rawparams),
399 418 )
400 419 # json.dumps only accepts unicode strings
401 420 result = pycompat.rapply(
402 421 lambda x: encoding.unifromlocal(x) if isinstance(x, bytes) else x,
403 422 callconduit(ui, name, params),
404 423 )
405 424 s = json.dumps(result, sort_keys=True, indent=2, separators=(u',', u': '))
406 425 ui.write(b'%s\n' % encoding.unitolocal(s))
407 426
408 427
409 428 def getrepophid(repo):
410 429 """given callsign, return repository PHID or None"""
411 430 # developer config: phabricator.repophid
412 431 repophid = repo.ui.config(b'phabricator', b'repophid')
413 432 if repophid:
414 433 return repophid
415 434 callsign = repo.ui.config(b'phabricator', b'callsign')
416 435 if not callsign:
417 436 return None
418 437 query = callconduit(
419 438 repo.ui,
420 439 b'diffusion.repository.search',
421 440 {b'constraints': {b'callsigns': [callsign]}},
422 441 )
423 442 if len(query[b'data']) == 0:
424 443 return None
425 444 repophid = query[b'data'][0][b'phid']
426 445 repo.ui.setconfig(b'phabricator', b'repophid', repophid)
427 446 return repophid
428 447
429 448
430 449 _differentialrevisiontagre = re.compile(br'\AD([1-9][0-9]*)\Z')
431 450 _differentialrevisiondescre = re.compile(
432 451 br'^Differential Revision:\s*(?P<url>(?:.*)D(?P<id>[1-9][0-9]*))$', re.M
433 452 )
434 453
435 454
436 455 def getoldnodedrevmap(repo, nodelist):
437 456 """find previous nodes that has been sent to Phabricator
438 457
439 458 return {node: (oldnode, Differential diff, Differential Revision ID)}
440 459 for node in nodelist with known previous sent versions, or associated
441 460 Differential Revision IDs. ``oldnode`` and ``Differential diff`` could
442 461 be ``None``.
443 462
444 463 Examines commit messages like "Differential Revision:" to get the
445 464 association information.
446 465
447 466 If such commit message line is not found, examines all precursors and their
448 467 tags. Tags with format like "D1234" are considered a match and the node
449 468 with that tag, and the number after "D" (ex. 1234) will be returned.
450 469
451 470 The ``old node``, if not None, is guaranteed to be the last diff of
452 471 corresponding Differential Revision, and exist in the repo.
453 472 """
454 473 unfi = repo.unfiltered()
455 474 has_node = unfi.changelog.index.has_node
456 475
457 476 result = {} # {node: (oldnode?, lastdiff?, drev)}
458 toconfirm = {} # {node: (force, {precnode}, drev)}
477 # ordered for test stability when printing new -> old mapping below
478 toconfirm = util.sortdict() # {node: (force, {precnode}, drev)}
459 479 for node in nodelist:
460 480 ctx = unfi[node]
461 481 # For tags like "D123", put them into "toconfirm" to verify later
462 482 precnodes = list(obsutil.allpredecessors(unfi.obsstore, [node]))
463 483 for n in precnodes:
464 484 if has_node(n):
465 485 for tag in unfi.nodetags(n):
466 486 m = _differentialrevisiontagre.match(tag)
467 487 if m:
468 488 toconfirm[node] = (0, set(precnodes), int(m.group(1)))
469 489 break
470 490 else:
471 491 continue # move to next predecessor
472 492 break # found a tag, stop
473 493 else:
474 494 # Check commit message
475 495 m = _differentialrevisiondescre.search(ctx.description())
476 496 if m:
477 497 toconfirm[node] = (1, set(precnodes), int(m.group('id')))
478 498
479 499 # Double check if tags are genuine by collecting all old nodes from
480 500 # Phabricator, and expect precursors overlap with it.
481 501 if toconfirm:
482 502 drevs = [drev for force, precs, drev in toconfirm.values()]
483 503 alldiffs = callconduit(
484 504 unfi.ui, b'differential.querydiffs', {b'revisionIDs': drevs}
485 505 )
486 506
487 507 def getnodes(d, precset):
488 508 # Ignore other nodes that were combined into the Differential
489 509 # that aren't predecessors of the current local node.
490 510 return [n for n in getlocalcommits(d) if n in precset]
491 511
492 512 for newnode, (force, precset, drev) in toconfirm.items():
493 513 diffs = [
494 514 d for d in alldiffs.values() if int(d[b'revisionID']) == drev
495 515 ]
496 516
497 517 # local predecessors known by Phabricator
498 518 phprecset = {n for d in diffs for n in getnodes(d, precset)}
499 519
500 520 # Ignore if precursors (Phabricator and local repo) do not overlap,
501 521 # and force is not set (when commit message says nothing)
502 522 if not force and not phprecset:
503 523 tagname = b'D%d' % drev
504 524 tags.tag(
505 525 repo,
506 526 tagname,
507 527 nullid,
508 528 message=None,
509 529 user=None,
510 530 date=None,
511 531 local=True,
512 532 )
513 533 unfi.ui.warn(
514 534 _(
515 535 b'D%d: local tag removed - does not match '
516 536 b'Differential history\n'
517 537 )
518 538 % drev
519 539 )
520 540 continue
521 541
522 542 # Find the last node using Phabricator metadata, and make sure it
523 543 # exists in the repo
524 544 oldnode = lastdiff = None
525 545 if diffs:
526 546 lastdiff = max(diffs, key=lambda d: int(d[b'id']))
527 547 oldnodes = getnodes(lastdiff, precset)
528 548
549 _debug(
550 unfi.ui,
551 b"%s mapped to old nodes %s\n"
552 % (
553 short(newnode),
554 stringutil.pprint([short(n) for n in sorted(oldnodes)]),
555 ),
556 )
557
529 558 # If this commit was the result of `hg fold` after submission,
530 559 # and now resubmitted with --fold, the easiest thing to do is
531 560 # to leave the node clear. This only results in creating a new
532 561 # diff for the _same_ Differential Revision if this commit is
533 562 # the first or last in the selected range.
534 563 # If this commit is the result of `hg split` in the same
535 564 # scenario, there is a single oldnode here (and multiple
536 565 # newnodes mapped to it). That makes it the same as the normal
537 566 # case, as the edges of the newnode range cleanly maps to one
538 567 # oldnode each.
539 568 if len(oldnodes) == 1:
540 569 oldnode = oldnodes[0]
541 570 if oldnode and not has_node(oldnode):
542 571 oldnode = None
543 572
544 573 result[newnode] = (oldnode, lastdiff, drev)
545 574
546 575 return result
547 576
548 577
549 578 def getdrevmap(repo, revs):
550 579 """Return a dict mapping each rev in `revs` to their Differential Revision
551 580 ID or None.
552 581 """
553 582 result = {}
554 583 for rev in revs:
555 584 result[rev] = None
556 585 ctx = repo[rev]
557 586 # Check commit message
558 587 m = _differentialrevisiondescre.search(ctx.description())
559 588 if m:
560 589 result[rev] = int(m.group('id'))
561 590 continue
562 591 # Check tags
563 592 for tag in repo.nodetags(ctx.node()):
564 593 m = _differentialrevisiontagre.match(tag)
565 594 if m:
566 595 result[rev] = int(m.group(1))
567 596 break
568 597
569 598 return result
570 599
571 600
572 601 def getdiff(basectx, ctx, diffopts):
573 602 """plain-text diff without header (user, commit message, etc)"""
574 603 output = util.stringio()
575 604 for chunk, _label in patch.diffui(
576 605 ctx.repo(), basectx.p1().node(), ctx.node(), None, opts=diffopts
577 606 ):
578 607 output.write(chunk)
579 608 return output.getvalue()
580 609
581 610
582 611 class DiffChangeType(object):
583 612 ADD = 1
584 613 CHANGE = 2
585 614 DELETE = 3
586 615 MOVE_AWAY = 4
587 616 COPY_AWAY = 5
588 617 MOVE_HERE = 6
589 618 COPY_HERE = 7
590 619 MULTICOPY = 8
591 620
592 621
593 622 class DiffFileType(object):
594 623 TEXT = 1
595 624 IMAGE = 2
596 625 BINARY = 3
597 626
598 627
599 628 @attr.s
600 629 class phabhunk(dict):
601 630 """Represents a Differential hunk, which is owned by a Differential change
602 631 """
603 632
604 633 oldOffset = attr.ib(default=0) # camelcase-required
605 634 oldLength = attr.ib(default=0) # camelcase-required
606 635 newOffset = attr.ib(default=0) # camelcase-required
607 636 newLength = attr.ib(default=0) # camelcase-required
608 637 corpus = attr.ib(default='')
609 638 # These get added to the phabchange's equivalents
610 639 addLines = attr.ib(default=0) # camelcase-required
611 640 delLines = attr.ib(default=0) # camelcase-required
612 641
613 642
614 643 @attr.s
615 644 class phabchange(object):
616 645 """Represents a Differential change, owns Differential hunks and owned by a
617 646 Differential diff. Each one represents one file in a diff.
618 647 """
619 648
620 649 currentPath = attr.ib(default=None) # camelcase-required
621 650 oldPath = attr.ib(default=None) # camelcase-required
622 651 awayPaths = attr.ib(default=attr.Factory(list)) # camelcase-required
623 652 metadata = attr.ib(default=attr.Factory(dict))
624 653 oldProperties = attr.ib(default=attr.Factory(dict)) # camelcase-required
625 654 newProperties = attr.ib(default=attr.Factory(dict)) # camelcase-required
626 655 type = attr.ib(default=DiffChangeType.CHANGE)
627 656 fileType = attr.ib(default=DiffFileType.TEXT) # camelcase-required
628 657 commitHash = attr.ib(default=None) # camelcase-required
629 658 addLines = attr.ib(default=0) # camelcase-required
630 659 delLines = attr.ib(default=0) # camelcase-required
631 660 hunks = attr.ib(default=attr.Factory(list))
632 661
633 662 def copynewmetadatatoold(self):
634 663 for key in list(self.metadata.keys()):
635 664 newkey = key.replace(b'new:', b'old:')
636 665 self.metadata[newkey] = self.metadata[key]
637 666
638 667 def addoldmode(self, value):
639 668 self.oldProperties[b'unix:filemode'] = value
640 669
641 670 def addnewmode(self, value):
642 671 self.newProperties[b'unix:filemode'] = value
643 672
644 673 def addhunk(self, hunk):
645 674 if not isinstance(hunk, phabhunk):
646 675 raise error.Abort(b'phabchange.addhunk only takes phabhunks')
647 676 self.hunks.append(pycompat.byteskwargs(attr.asdict(hunk)))
648 677 # It's useful to include these stats since the Phab web UI shows them,
649 678 # and uses them to estimate how large a change a Revision is. Also used
650 679 # in email subjects for the [+++--] bit.
651 680 self.addLines += hunk.addLines
652 681 self.delLines += hunk.delLines
653 682
654 683
655 684 @attr.s
656 685 class phabdiff(object):
657 686 """Represents a Differential diff, owns Differential changes. Corresponds
658 687 to a commit.
659 688 """
660 689
661 690 # Doesn't seem to be any reason to send this (output of uname -n)
662 691 sourceMachine = attr.ib(default=b'') # camelcase-required
663 692 sourcePath = attr.ib(default=b'/') # camelcase-required
664 693 sourceControlBaseRevision = attr.ib(default=b'0' * 40) # camelcase-required
665 694 sourceControlPath = attr.ib(default=b'/') # camelcase-required
666 695 sourceControlSystem = attr.ib(default=b'hg') # camelcase-required
667 696 branch = attr.ib(default=b'default')
668 697 bookmark = attr.ib(default=None)
669 698 creationMethod = attr.ib(default=b'phabsend') # camelcase-required
670 699 lintStatus = attr.ib(default=b'none') # camelcase-required
671 700 unitStatus = attr.ib(default=b'none') # camelcase-required
672 701 changes = attr.ib(default=attr.Factory(dict))
673 702 repositoryPHID = attr.ib(default=None) # camelcase-required
674 703
675 704 def addchange(self, change):
676 705 if not isinstance(change, phabchange):
677 706 raise error.Abort(b'phabdiff.addchange only takes phabchanges')
678 707 self.changes[change.currentPath] = pycompat.byteskwargs(
679 708 attr.asdict(change)
680 709 )
681 710
682 711
683 712 def maketext(pchange, basectx, ctx, fname):
684 713 """populate the phabchange for a text file"""
685 714 repo = ctx.repo()
686 715 fmatcher = match.exact([fname])
687 716 diffopts = mdiff.diffopts(git=True, context=32767)
688 717 _pfctx, _fctx, header, fhunks = next(
689 718 patch.diffhunks(repo, basectx.p1(), ctx, fmatcher, opts=diffopts)
690 719 )
691 720
692 721 for fhunk in fhunks:
693 722 (oldOffset, oldLength, newOffset, newLength), lines = fhunk
694 723 corpus = b''.join(lines[1:])
695 724 shunk = list(header)
696 725 shunk.extend(lines)
697 726 _mf, _mt, addLines, delLines, _hb = patch.diffstatsum(
698 727 patch.diffstatdata(util.iterlines(shunk))
699 728 )
700 729 pchange.addhunk(
701 730 phabhunk(
702 731 oldOffset,
703 732 oldLength,
704 733 newOffset,
705 734 newLength,
706 735 corpus,
707 736 addLines,
708 737 delLines,
709 738 )
710 739 )
711 740
712 741
713 742 def uploadchunks(fctx, fphid):
714 743 """upload large binary files as separate chunks.
715 744 Phab requests chunking over 8MiB, and splits into 4MiB chunks
716 745 """
717 746 ui = fctx.repo().ui
718 747 chunks = callconduit(ui, b'file.querychunks', {b'filePHID': fphid})
719 748 with ui.makeprogress(
720 749 _(b'uploading file chunks'), unit=_(b'chunks'), total=len(chunks)
721 750 ) as progress:
722 751 for chunk in chunks:
723 752 progress.increment()
724 753 if chunk[b'complete']:
725 754 continue
726 755 bstart = int(chunk[b'byteStart'])
727 756 bend = int(chunk[b'byteEnd'])
728 757 callconduit(
729 758 ui,
730 759 b'file.uploadchunk',
731 760 {
732 761 b'filePHID': fphid,
733 762 b'byteStart': bstart,
734 763 b'data': base64.b64encode(fctx.data()[bstart:bend]),
735 764 b'dataEncoding': b'base64',
736 765 },
737 766 )
738 767
739 768
740 769 def uploadfile(fctx):
741 770 """upload binary files to Phabricator"""
742 771 repo = fctx.repo()
743 772 ui = repo.ui
744 773 fname = fctx.path()
745 774 size = fctx.size()
746 775 fhash = pycompat.bytestr(hashlib.sha256(fctx.data()).hexdigest())
747 776
748 777 # an allocate call is required first to see if an upload is even required
749 778 # (Phab might already have it) and to determine if chunking is needed
750 779 allocateparams = {
751 780 b'name': fname,
752 781 b'contentLength': size,
753 782 b'contentHash': fhash,
754 783 }
755 784 filealloc = callconduit(ui, b'file.allocate', allocateparams)
756 785 fphid = filealloc[b'filePHID']
757 786
758 787 if filealloc[b'upload']:
759 788 ui.write(_(b'uploading %s\n') % bytes(fctx))
760 789 if not fphid:
761 790 uploadparams = {
762 791 b'name': fname,
763 792 b'data_base64': base64.b64encode(fctx.data()),
764 793 }
765 794 fphid = callconduit(ui, b'file.upload', uploadparams)
766 795 else:
767 796 uploadchunks(fctx, fphid)
768 797 else:
769 798 ui.debug(b'server already has %s\n' % bytes(fctx))
770 799
771 800 if not fphid:
772 801 raise error.Abort(b'Upload of %s failed.' % bytes(fctx))
773 802
774 803 return fphid
775 804
776 805
777 806 def addoldbinary(pchange, oldfctx, fctx):
778 807 """add the metadata for the previous version of a binary file to the
779 808 phabchange for the new version
780 809
781 810 ``oldfctx`` is the previous version of the file; ``fctx`` is the new
782 811 version of the file, or None if the file is being removed.
783 812 """
784 813 if not fctx or fctx.cmp(oldfctx):
785 814 # Files differ, add the old one
786 815 pchange.metadata[b'old:file:size'] = oldfctx.size()
787 816 mimeguess, _enc = mimetypes.guess_type(
788 817 encoding.unifromlocal(oldfctx.path())
789 818 )
790 819 if mimeguess:
791 820 pchange.metadata[b'old:file:mime-type'] = pycompat.bytestr(
792 821 mimeguess
793 822 )
794 823 fphid = uploadfile(oldfctx)
795 824 pchange.metadata[b'old:binary-phid'] = fphid
796 825 else:
797 826 # If it's left as IMAGE/BINARY web UI might try to display it
798 827 pchange.fileType = DiffFileType.TEXT
799 828 pchange.copynewmetadatatoold()
800 829
801 830
802 831 def makebinary(pchange, fctx):
803 832 """populate the phabchange for a binary file"""
804 833 pchange.fileType = DiffFileType.BINARY
805 834 fphid = uploadfile(fctx)
806 835 pchange.metadata[b'new:binary-phid'] = fphid
807 836 pchange.metadata[b'new:file:size'] = fctx.size()
808 837 mimeguess, _enc = mimetypes.guess_type(encoding.unifromlocal(fctx.path()))
809 838 if mimeguess:
810 839 mimeguess = pycompat.bytestr(mimeguess)
811 840 pchange.metadata[b'new:file:mime-type'] = mimeguess
812 841 if mimeguess.startswith(b'image/'):
813 842 pchange.fileType = DiffFileType.IMAGE
814 843
815 844
816 845 # Copied from mercurial/patch.py
817 846 gitmode = {b'l': b'120000', b'x': b'100755', b'': b'100644'}
818 847
819 848
820 849 def notutf8(fctx):
821 850 """detect non-UTF-8 text files since Phabricator requires them to be marked
822 851 as binary
823 852 """
824 853 try:
825 854 fctx.data().decode('utf-8')
826 855 return False
827 856 except UnicodeDecodeError:
828 857 fctx.repo().ui.write(
829 858 _(b'file %s detected as non-UTF-8, marked as binary\n')
830 859 % fctx.path()
831 860 )
832 861 return True
833 862
834 863
835 864 def addremoved(pdiff, basectx, ctx, removed):
836 865 """add removed files to the phabdiff. Shouldn't include moves"""
837 866 for fname in removed:
838 867 pchange = phabchange(
839 868 currentPath=fname, oldPath=fname, type=DiffChangeType.DELETE
840 869 )
841 870 oldfctx = basectx.p1()[fname]
842 871 pchange.addoldmode(gitmode[oldfctx.flags()])
843 872 if not (oldfctx.isbinary() or notutf8(oldfctx)):
844 873 maketext(pchange, basectx, ctx, fname)
845 874
846 875 pdiff.addchange(pchange)
847 876
848 877
849 878 def addmodified(pdiff, basectx, ctx, modified):
850 879 """add modified files to the phabdiff"""
851 880 for fname in modified:
852 881 fctx = ctx[fname]
853 882 oldfctx = basectx.p1()[fname]
854 883 pchange = phabchange(currentPath=fname, oldPath=fname)
855 884 filemode = gitmode[fctx.flags()]
856 885 originalmode = gitmode[oldfctx.flags()]
857 886 if filemode != originalmode:
858 887 pchange.addoldmode(originalmode)
859 888 pchange.addnewmode(filemode)
860 889
861 890 if (
862 891 fctx.isbinary()
863 892 or notutf8(fctx)
864 893 or oldfctx.isbinary()
865 894 or notutf8(oldfctx)
866 895 ):
867 896 makebinary(pchange, fctx)
868 897 addoldbinary(pchange, oldfctx, fctx)
869 898 else:
870 899 maketext(pchange, basectx, ctx, fname)
871 900
872 901 pdiff.addchange(pchange)
873 902
874 903
875 904 def addadded(pdiff, basectx, ctx, added, removed):
876 905 """add file adds to the phabdiff, both new files and copies/moves"""
877 906 # Keep track of files that've been recorded as moved/copied, so if there are
878 907 # additional copies we can mark them (moves get removed from removed)
879 908 copiedchanges = {}
880 909 movedchanges = {}
881 910
882 911 copy = {}
883 912 if basectx != ctx:
884 913 copy = copies.pathcopies(basectx.p1(), ctx)
885 914
886 915 for fname in added:
887 916 fctx = ctx[fname]
888 917 oldfctx = None
889 918 pchange = phabchange(currentPath=fname)
890 919
891 920 filemode = gitmode[fctx.flags()]
892 921
893 922 if copy:
894 923 originalfname = copy.get(fname, fname)
895 924 else:
896 925 originalfname = fname
897 926 if fctx.renamed():
898 927 originalfname = fctx.renamed()[0]
899 928
900 929 renamed = fname != originalfname
901 930
902 931 if renamed:
903 932 oldfctx = basectx.p1()[originalfname]
904 933 originalmode = gitmode[oldfctx.flags()]
905 934 pchange.oldPath = originalfname
906 935
907 936 if originalfname in removed:
908 937 origpchange = phabchange(
909 938 currentPath=originalfname,
910 939 oldPath=originalfname,
911 940 type=DiffChangeType.MOVE_AWAY,
912 941 awayPaths=[fname],
913 942 )
914 943 movedchanges[originalfname] = origpchange
915 944 removed.remove(originalfname)
916 945 pchange.type = DiffChangeType.MOVE_HERE
917 946 elif originalfname in movedchanges:
918 947 movedchanges[originalfname].type = DiffChangeType.MULTICOPY
919 948 movedchanges[originalfname].awayPaths.append(fname)
920 949 pchange.type = DiffChangeType.COPY_HERE
921 950 else: # pure copy
922 951 if originalfname not in copiedchanges:
923 952 origpchange = phabchange(
924 953 currentPath=originalfname, type=DiffChangeType.COPY_AWAY
925 954 )
926 955 copiedchanges[originalfname] = origpchange
927 956 else:
928 957 origpchange = copiedchanges[originalfname]
929 958 origpchange.awayPaths.append(fname)
930 959 pchange.type = DiffChangeType.COPY_HERE
931 960
932 961 if filemode != originalmode:
933 962 pchange.addoldmode(originalmode)
934 963 pchange.addnewmode(filemode)
935 964 else: # Brand-new file
936 965 pchange.addnewmode(gitmode[fctx.flags()])
937 966 pchange.type = DiffChangeType.ADD
938 967
939 968 if (
940 969 fctx.isbinary()
941 970 or notutf8(fctx)
942 971 or (oldfctx and (oldfctx.isbinary() or notutf8(oldfctx)))
943 972 ):
944 973 makebinary(pchange, fctx)
945 974 if renamed:
946 975 addoldbinary(pchange, oldfctx, fctx)
947 976 else:
948 977 maketext(pchange, basectx, ctx, fname)
949 978
950 979 pdiff.addchange(pchange)
951 980
952 981 for _path, copiedchange in copiedchanges.items():
953 982 pdiff.addchange(copiedchange)
954 983 for _path, movedchange in movedchanges.items():
955 984 pdiff.addchange(movedchange)
956 985
957 986
958 987 def creatediff(basectx, ctx):
959 988 """create a Differential Diff"""
960 989 repo = ctx.repo()
961 990 repophid = getrepophid(repo)
962 991 # Create a "Differential Diff" via "differential.creatediff" API
963 992 pdiff = phabdiff(
964 993 sourceControlBaseRevision=b'%s' % basectx.p1().hex(),
965 994 branch=b'%s' % ctx.branch(),
966 995 )
967 996 modified, added, removed, _d, _u, _i, _c = basectx.p1().status(ctx)
968 997 # addadded will remove moved files from removed, so addremoved won't get
969 998 # them
970 999 addadded(pdiff, basectx, ctx, added, removed)
971 1000 addmodified(pdiff, basectx, ctx, modified)
972 1001 addremoved(pdiff, basectx, ctx, removed)
973 1002 if repophid:
974 1003 pdiff.repositoryPHID = repophid
975 1004 diff = callconduit(
976 1005 repo.ui,
977 1006 b'differential.creatediff',
978 1007 pycompat.byteskwargs(attr.asdict(pdiff)),
979 1008 )
980 1009 if not diff:
981 1010 if basectx != ctx:
982 1011 msg = _(b'cannot create diff for %s::%s') % (basectx, ctx)
983 1012 else:
984 1013 msg = _(b'cannot create diff for %s') % ctx
985 1014 raise error.Abort(msg)
986 1015 return diff
987 1016
988 1017
989 1018 def writediffproperties(ctxs, diff):
990 1019 """write metadata to diff so patches could be applied losslessly
991 1020
992 1021 ``ctxs`` is the list of commits that created the diff, in ascending order.
993 1022 The list is generally a single commit, but may be several when using
994 1023 ``phabsend --fold``.
995 1024 """
996 1025 # creatediff returns with a diffid but query returns with an id
997 1026 diffid = diff.get(b'diffid', diff.get(b'id'))
998 1027 basectx = ctxs[0]
999 1028 tipctx = ctxs[-1]
1000 1029
1001 1030 params = {
1002 1031 b'diff_id': diffid,
1003 1032 b'name': b'hg:meta',
1004 1033 b'data': templatefilters.json(
1005 1034 {
1006 1035 b'user': tipctx.user(),
1007 1036 b'date': b'%d %d' % tipctx.date(),
1008 1037 b'branch': tipctx.branch(),
1009 1038 b'node': tipctx.hex(),
1010 1039 b'parent': basectx.p1().hex(),
1011 1040 }
1012 1041 ),
1013 1042 }
1014 1043 callconduit(basectx.repo().ui, b'differential.setdiffproperty', params)
1015 1044
1016 1045 commits = {}
1017 1046 for ctx in ctxs:
1018 1047 commits[ctx.hex()] = {
1019 1048 b'author': stringutil.person(ctx.user()),
1020 1049 b'authorEmail': stringutil.email(ctx.user()),
1021 1050 b'time': int(ctx.date()[0]),
1022 1051 b'commit': ctx.hex(),
1023 1052 b'parents': [ctx.p1().hex()],
1024 1053 b'branch': ctx.branch(),
1025 1054 }
1026 1055 params = {
1027 1056 b'diff_id': diffid,
1028 1057 b'name': b'local:commits',
1029 1058 b'data': templatefilters.json(commits),
1030 1059 }
1031 1060 callconduit(basectx.repo().ui, b'differential.setdiffproperty', params)
1032 1061
1033 1062
1034 1063 def createdifferentialrevision(
1035 1064 ctxs,
1036 1065 revid=None,
1037 1066 parentrevphid=None,
1038 1067 oldbasenode=None,
1039 1068 oldnode=None,
1040 1069 olddiff=None,
1041 1070 actions=None,
1042 1071 comment=None,
1043 1072 ):
1044 1073 """create or update a Differential Revision
1045 1074
1046 1075 If revid is None, create a new Differential Revision, otherwise update
1047 1076 revid. If parentrevphid is not None, set it as a dependency.
1048 1077
1049 1078 If there is a single commit for the new Differential Revision, ``ctxs`` will
1050 1079 be a list of that single context. Otherwise, it is a list that covers the
1051 1080 range of changes for the differential, where ``ctxs[0]`` is the first change
1052 1081 to include and ``ctxs[-1]`` is the last.
1053 1082
1054 1083 If oldnode is not None, check if the patch content (without commit message
1055 1084 and metadata) has changed before creating another diff. For a Revision with
1056 1085 a single commit, ``oldbasenode`` and ``oldnode`` have the same value. For a
1057 1086 Revision covering multiple commits, ``oldbasenode`` corresponds to
1058 1087 ``ctxs[0]`` the previous time this Revision was posted, and ``oldnode``
1059 1088 corresponds to ``ctxs[-1]``.
1060 1089
1061 1090 If actions is not None, they will be appended to the transaction.
1062 1091 """
1063 1092 ctx = ctxs[-1]
1064 1093 basectx = ctxs[0]
1065 1094
1066 1095 repo = ctx.repo()
1067 1096 if oldnode:
1068 1097 diffopts = mdiff.diffopts(git=True, context=32767)
1069 1098 unfi = repo.unfiltered()
1070 1099 oldctx = unfi[oldnode]
1071 1100 oldbasectx = unfi[oldbasenode]
1072 1101 neednewdiff = getdiff(basectx, ctx, diffopts) != getdiff(
1073 1102 oldbasectx, oldctx, diffopts
1074 1103 )
1075 1104 else:
1076 1105 neednewdiff = True
1077 1106
1078 1107 transactions = []
1079 1108 if neednewdiff:
1080 1109 diff = creatediff(basectx, ctx)
1081 1110 transactions.append({b'type': b'update', b'value': diff[b'phid']})
1082 1111 if comment:
1083 1112 transactions.append({b'type': b'comment', b'value': comment})
1084 1113 else:
1085 1114 # Even if we don't need to upload a new diff because the patch content
1086 1115 # does not change. We might still need to update its metadata so
1087 1116 # pushers could know the correct node metadata.
1088 1117 assert olddiff
1089 1118 diff = olddiff
1090 1119 writediffproperties(ctxs, diff)
1091 1120
1092 1121 # Set the parent Revision every time, so commit re-ordering is picked-up
1093 1122 if parentrevphid:
1094 1123 transactions.append(
1095 1124 {b'type': b'parents.set', b'value': [parentrevphid]}
1096 1125 )
1097 1126
1098 1127 if actions:
1099 1128 transactions += actions
1100 1129
1101 1130 # When folding multiple local commits into a single review, arcanist will
1102 1131 # take the summary line of the first commit as the title, and then
1103 1132 # concatenate the rest of the remaining messages (including each of their
1104 1133 # first lines) to the rest of the first commit message (each separated by
1105 1134 # an empty line), and use that as the summary field. Do the same here.
1106 1135 # For commits with only a one line message, there is no summary field, as
1107 1136 # this gets assigned to the title.
1108 1137 fields = util.sortdict() # sorted for stable wire protocol in tests
1109 1138
1110 1139 for i, _ctx in enumerate(ctxs):
1111 1140 # Parse commit message and update related fields.
1112 1141 desc = _ctx.description()
1113 1142 info = callconduit(
1114 1143 repo.ui, b'differential.parsecommitmessage', {b'corpus': desc}
1115 1144 )
1116 1145
1117 1146 for k in [b'title', b'summary', b'testPlan']:
1118 1147 v = info[b'fields'].get(k)
1119 1148 if not v:
1120 1149 continue
1121 1150
1122 1151 if i == 0:
1123 1152 # Title, summary and test plan (if present) are taken verbatim
1124 1153 # for the first commit.
1125 1154 fields[k] = v.rstrip()
1126 1155 continue
1127 1156 elif k == b'title':
1128 1157 # Add subsequent titles (i.e. the first line of the commit
1129 1158 # message) back to the summary.
1130 1159 k = b'summary'
1131 1160
1132 1161 # Append any current field to the existing composite field
1133 1162 fields[k] = b'\n\n'.join(filter(None, [fields.get(k), v.rstrip()]))
1134 1163
1135 1164 for k, v in fields.items():
1136 1165 transactions.append({b'type': k, b'value': v})
1137 1166
1138 1167 params = {b'transactions': transactions}
1139 1168 if revid is not None:
1140 1169 # Update an existing Differential Revision
1141 1170 params[b'objectIdentifier'] = revid
1142 1171
1143 1172 revision = callconduit(repo.ui, b'differential.revision.edit', params)
1144 1173 if not revision:
1145 1174 if len(ctxs) == 1:
1146 1175 msg = _(b'cannot create revision for %s') % ctx
1147 1176 else:
1148 1177 msg = _(b'cannot create revision for %s::%s') % (basectx, ctx)
1149 1178 raise error.Abort(msg)
1150 1179
1151 1180 return revision, diff
1152 1181
1153 1182
1154 1183 def userphids(ui, names):
1155 1184 """convert user names to PHIDs"""
1156 1185 names = [name.lower() for name in names]
1157 1186 query = {b'constraints': {b'usernames': names}}
1158 1187 result = callconduit(ui, b'user.search', query)
1159 1188 # username not found is not an error of the API. So check if we have missed
1160 1189 # some names here.
1161 1190 data = result[b'data']
1162 1191 resolved = {entry[b'fields'][b'username'].lower() for entry in data}
1163 1192 unresolved = set(names) - resolved
1164 1193 if unresolved:
1165 1194 raise error.Abort(
1166 1195 _(b'unknown username: %s') % b' '.join(sorted(unresolved))
1167 1196 )
1168 1197 return [entry[b'phid'] for entry in data]
1169 1198
1170 1199
1171 1200 def _print_phabsend_action(ui, ctx, newrevid, action):
1172 1201 """print the ``action`` that occurred when posting ``ctx`` for review
1173 1202
1174 1203 This is a utility function for the sending phase of ``phabsend``, which
1175 1204 makes it easier to show a status for all local commits with `--fold``.
1176 1205 """
1177 1206 actiondesc = ui.label(
1178 1207 {
1179 1208 b'created': _(b'created'),
1180 1209 b'skipped': _(b'skipped'),
1181 1210 b'updated': _(b'updated'),
1182 1211 }[action],
1183 1212 b'phabricator.action.%s' % action,
1184 1213 )
1185 1214 drevdesc = ui.label(b'D%d' % newrevid, b'phabricator.drev')
1186 1215 nodedesc = ui.label(bytes(ctx), b'phabricator.node')
1187 1216 desc = ui.label(ctx.description().split(b'\n')[0], b'phabricator.desc')
1188 1217 ui.write(_(b'%s - %s - %s: %s\n') % (drevdesc, actiondesc, nodedesc, desc))
1189 1218
1190 1219
1191 1220 def _amend_diff_properties(unfi, drevid, newnodes, diff):
1192 1221 """update the local commit list for the ``diff`` associated with ``drevid``
1193 1222
1194 1223 This is a utility function for the amend phase of ``phabsend``, which
1195 1224 converts failures to warning messages.
1196 1225 """
1226 _debug(
1227 unfi.ui,
1228 b"new commits: %s\n" % stringutil.pprint([short(n) for n in newnodes]),
1229 )
1230
1197 1231 try:
1198 1232 writediffproperties([unfi[newnode] for newnode in newnodes], diff)
1199 1233 except util.urlerr.urlerror:
1200 1234 # If it fails just warn and keep going, otherwise the DREV
1201 1235 # associations will be lost
1202 1236 unfi.ui.warnnoi18n(b'Failed to update metadata for D%d\n' % drevid)
1203 1237
1204 1238
1205 1239 @vcrcommand(
1206 1240 b'phabsend',
1207 1241 [
1208 1242 (b'r', b'rev', [], _(b'revisions to send'), _(b'REV')),
1209 1243 (b'', b'amend', True, _(b'update commit messages')),
1210 1244 (b'', b'reviewer', [], _(b'specify reviewers')),
1211 1245 (b'', b'blocker', [], _(b'specify blocking reviewers')),
1212 1246 (
1213 1247 b'm',
1214 1248 b'comment',
1215 1249 b'',
1216 1250 _(b'add a comment to Revisions with new/updated Diffs'),
1217 1251 ),
1218 1252 (b'', b'confirm', None, _(b'ask for confirmation before sending')),
1219 1253 ],
1220 1254 _(b'REV [OPTIONS]'),
1221 1255 helpcategory=command.CATEGORY_IMPORT_EXPORT,
1222 1256 )
1223 1257 def phabsend(ui, repo, *revs, **opts):
1224 1258 """upload changesets to Phabricator
1225 1259
1226 1260 If there are multiple revisions specified, they will be send as a stack
1227 1261 with a linear dependencies relationship using the order specified by the
1228 1262 revset.
1229 1263
1230 1264 For the first time uploading changesets, local tags will be created to
1231 1265 maintain the association. After the first time, phabsend will check
1232 1266 obsstore and tags information so it can figure out whether to update an
1233 1267 existing Differential Revision, or create a new one.
1234 1268
1235 1269 If --amend is set, update commit messages so they have the
1236 1270 ``Differential Revision`` URL, remove related tags. This is similar to what
1237 1271 arcanist will do, and is more desired in author-push workflows. Otherwise,
1238 1272 use local tags to record the ``Differential Revision`` association.
1239 1273
1240 1274 The --confirm option lets you confirm changesets before sending them. You
1241 1275 can also add following to your configuration file to make it default
1242 1276 behaviour::
1243 1277
1244 1278 [phabsend]
1245 1279 confirm = true
1246 1280
1247 1281 phabsend will check obsstore and the above association to decide whether to
1248 1282 update an existing Differential Revision, or create a new one.
1249 1283 """
1250 1284 opts = pycompat.byteskwargs(opts)
1251 1285 revs = list(revs) + opts.get(b'rev', [])
1252 1286 revs = scmutil.revrange(repo, revs)
1253 1287 revs.sort() # ascending order to preserve topological parent/child in phab
1254 1288
1255 1289 if not revs:
1256 1290 raise error.Abort(_(b'phabsend requires at least one changeset'))
1257 1291 if opts.get(b'amend'):
1258 1292 cmdutil.checkunfinished(repo)
1259 1293
1260 1294 # {newnode: (oldnode, olddiff, olddrev}
1261 1295 oldmap = getoldnodedrevmap(repo, [repo[r].node() for r in revs])
1262 1296
1263 1297 confirm = ui.configbool(b'phabsend', b'confirm')
1264 1298 confirm |= bool(opts.get(b'confirm'))
1265 1299 if confirm:
1266 1300 confirmed = _confirmbeforesend(repo, revs, oldmap)
1267 1301 if not confirmed:
1268 1302 raise error.Abort(_(b'phabsend cancelled'))
1269 1303
1270 1304 actions = []
1271 1305 reviewers = opts.get(b'reviewer', [])
1272 1306 blockers = opts.get(b'blocker', [])
1273 1307 phids = []
1274 1308 if reviewers:
1275 1309 phids.extend(userphids(repo.ui, reviewers))
1276 1310 if blockers:
1277 1311 phids.extend(
1278 1312 map(
1279 1313 lambda phid: b'blocking(%s)' % phid,
1280 1314 userphids(repo.ui, blockers),
1281 1315 )
1282 1316 )
1283 1317 if phids:
1284 1318 actions.append({b'type': b'reviewers.add', b'value': phids})
1285 1319
1286 1320 drevids = [] # [int]
1287 1321 diffmap = {} # {newnode: diff}
1288 1322
1289 1323 # Send patches one by one so we know their Differential Revision PHIDs and
1290 1324 # can provide dependency relationship
1291 1325 lastrevphid = None
1292 1326 for rev in revs:
1293 1327 ui.debug(b'sending rev %d\n' % rev)
1294 1328 ctx = repo[rev]
1295 1329
1296 1330 # Get Differential Revision ID
1297 1331 oldnode, olddiff, revid = oldmap.get(ctx.node(), (None, None, None))
1298 1332 oldbasenode = oldnode
1299 1333 if oldnode != ctx.node() or opts.get(b'amend'):
1300 1334 # Create or update Differential Revision
1301 1335 revision, diff = createdifferentialrevision(
1302 1336 [ctx],
1303 1337 revid,
1304 1338 lastrevphid,
1305 1339 oldbasenode,
1306 1340 oldnode,
1307 1341 olddiff,
1308 1342 actions,
1309 1343 opts.get(b'comment'),
1310 1344 )
1311 1345 diffmap[ctx.node()] = diff
1312 1346 newrevid = int(revision[b'object'][b'id'])
1313 1347 newrevphid = revision[b'object'][b'phid']
1314 1348 if revid:
1315 1349 action = b'updated'
1316 1350 else:
1317 1351 action = b'created'
1318 1352
1319 1353 # Create a local tag to note the association, if commit message
1320 1354 # does not have it already
1321 1355 m = _differentialrevisiondescre.search(ctx.description())
1322 1356 if not m or int(m.group('id')) != newrevid:
1323 1357 tagname = b'D%d' % newrevid
1324 1358 tags.tag(
1325 1359 repo,
1326 1360 tagname,
1327 1361 ctx.node(),
1328 1362 message=None,
1329 1363 user=None,
1330 1364 date=None,
1331 1365 local=True,
1332 1366 )
1333 1367 else:
1334 1368 # Nothing changed. But still set "newrevphid" so the next revision
1335 1369 # could depend on this one and "newrevid" for the summary line.
1336 1370 newrevphid = querydrev(repo.ui, b'%d' % revid)[0][b'phid']
1337 1371 newrevid = revid
1338 1372 action = b'skipped'
1339 1373
1340 1374 drevids.append(newrevid)
1341 1375 lastrevphid = newrevphid
1342 1376
1343 1377 _print_phabsend_action(ui, ctx, newrevid, action)
1344 1378
1345 1379 # Update commit messages and remove tags
1346 1380 if opts.get(b'amend'):
1347 1381 unfi = repo.unfiltered()
1348 1382 drevs = callconduit(ui, b'differential.query', {b'ids': drevids})
1349 1383 with repo.wlock(), repo.lock(), repo.transaction(b'phabsend'):
1350 1384 wnode = unfi[b'.'].node()
1351 1385 mapping = {} # {oldnode: [newnode]}
1352 1386 for i, rev in enumerate(revs):
1353 1387 old = unfi[rev]
1354 1388 drevid = drevids[i]
1355 1389 drev = [d for d in drevs if int(d[b'id']) == drevid][0]
1356 1390 newdesc = get_amended_desc(drev, old, False)
1357 1391 # Make sure commit message contain "Differential Revision"
1358 1392 if old.description() != newdesc:
1359 1393 if old.phase() == phases.public:
1360 1394 ui.warn(
1361 1395 _(b"warning: not updating public commit %s\n")
1362 1396 % scmutil.formatchangeid(old)
1363 1397 )
1364 1398 continue
1365 1399 parents = [
1366 1400 mapping.get(old.p1().node(), (old.p1(),))[0],
1367 1401 mapping.get(old.p2().node(), (old.p2(),))[0],
1368 1402 ]
1369 1403 new = context.metadataonlyctx(
1370 1404 repo,
1371 1405 old,
1372 1406 parents=parents,
1373 1407 text=newdesc,
1374 1408 user=old.user(),
1375 1409 date=old.date(),
1376 1410 extra=old.extra(),
1377 1411 )
1378 1412
1379 1413 newnode = new.commit()
1380 1414
1381 1415 mapping[old.node()] = [newnode]
1382 1416
1383 1417 _amend_diff_properties(
1384 1418 unfi, drevid, [newnode], diffmap[old.node()]
1385 1419 )
1386 1420 # Remove local tags since it's no longer necessary
1387 1421 tagname = b'D%d' % drevid
1388 1422 if tagname in repo.tags():
1389 1423 tags.tag(
1390 1424 repo,
1391 1425 tagname,
1392 1426 nullid,
1393 1427 message=None,
1394 1428 user=None,
1395 1429 date=None,
1396 1430 local=True,
1397 1431 )
1398 1432 scmutil.cleanupnodes(repo, mapping, b'phabsend', fixphase=True)
1399 1433 if wnode in mapping:
1400 1434 unfi.setparents(mapping[wnode][0])
1401 1435
1402 1436
1403 1437 # Map from "hg:meta" keys to header understood by "hg import". The order is
1404 1438 # consistent with "hg export" output.
1405 1439 _metanamemap = util.sortdict(
1406 1440 [
1407 1441 (b'user', b'User'),
1408 1442 (b'date', b'Date'),
1409 1443 (b'branch', b'Branch'),
1410 1444 (b'node', b'Node ID'),
1411 1445 (b'parent', b'Parent '),
1412 1446 ]
1413 1447 )
1414 1448
1415 1449
1416 1450 def _confirmbeforesend(repo, revs, oldmap):
1417 1451 url, token = readurltoken(repo.ui)
1418 1452 ui = repo.ui
1419 1453 for rev in revs:
1420 1454 ctx = repo[rev]
1421 1455 desc = ctx.description().splitlines()[0]
1422 1456 oldnode, olddiff, drevid = oldmap.get(ctx.node(), (None, None, None))
1423 1457 if drevid:
1424 1458 drevdesc = ui.label(b'D%d' % drevid, b'phabricator.drev')
1425 1459 else:
1426 1460 drevdesc = ui.label(_(b'NEW'), b'phabricator.drev')
1427 1461
1428 1462 ui.write(
1429 1463 _(b'%s - %s: %s\n')
1430 1464 % (
1431 1465 drevdesc,
1432 1466 ui.label(bytes(ctx), b'phabricator.node'),
1433 1467 ui.label(desc, b'phabricator.desc'),
1434 1468 )
1435 1469 )
1436 1470
1437 1471 if ui.promptchoice(
1438 1472 _(b'Send the above changes to %s (yn)?$$ &Yes $$ &No') % url
1439 1473 ):
1440 1474 return False
1441 1475
1442 1476 return True
1443 1477
1444 1478
1445 1479 _knownstatusnames = {
1446 1480 b'accepted',
1447 1481 b'needsreview',
1448 1482 b'needsrevision',
1449 1483 b'closed',
1450 1484 b'abandoned',
1451 1485 b'changesplanned',
1452 1486 }
1453 1487
1454 1488
1455 1489 def _getstatusname(drev):
1456 1490 """get normalized status name from a Differential Revision"""
1457 1491 return drev[b'statusName'].replace(b' ', b'').lower()
1458 1492
1459 1493
1460 1494 # Small language to specify differential revisions. Support symbols: (), :X,
1461 1495 # +, and -.
1462 1496
1463 1497 _elements = {
1464 1498 # token-type: binding-strength, primary, prefix, infix, suffix
1465 1499 b'(': (12, None, (b'group', 1, b')'), None, None),
1466 1500 b':': (8, None, (b'ancestors', 8), None, None),
1467 1501 b'&': (5, None, None, (b'and_', 5), None),
1468 1502 b'+': (4, None, None, (b'add', 4), None),
1469 1503 b'-': (4, None, None, (b'sub', 4), None),
1470 1504 b')': (0, None, None, None, None),
1471 1505 b'symbol': (0, b'symbol', None, None, None),
1472 1506 b'end': (0, None, None, None, None),
1473 1507 }
1474 1508
1475 1509
1476 1510 def _tokenize(text):
1477 1511 view = memoryview(text) # zero-copy slice
1478 1512 special = b'():+-& '
1479 1513 pos = 0
1480 1514 length = len(text)
1481 1515 while pos < length:
1482 1516 symbol = b''.join(
1483 1517 itertools.takewhile(
1484 1518 lambda ch: ch not in special, pycompat.iterbytestr(view[pos:])
1485 1519 )
1486 1520 )
1487 1521 if symbol:
1488 1522 yield (b'symbol', symbol, pos)
1489 1523 pos += len(symbol)
1490 1524 else: # special char, ignore space
1491 1525 if text[pos : pos + 1] != b' ':
1492 1526 yield (text[pos : pos + 1], None, pos)
1493 1527 pos += 1
1494 1528 yield (b'end', None, pos)
1495 1529
1496 1530
1497 1531 def _parse(text):
1498 1532 tree, pos = parser.parser(_elements).parse(_tokenize(text))
1499 1533 if pos != len(text):
1500 1534 raise error.ParseError(b'invalid token', pos)
1501 1535 return tree
1502 1536
1503 1537
1504 1538 def _parsedrev(symbol):
1505 1539 """str -> int or None, ex. 'D45' -> 45; '12' -> 12; 'x' -> None"""
1506 1540 if symbol.startswith(b'D') and symbol[1:].isdigit():
1507 1541 return int(symbol[1:])
1508 1542 if symbol.isdigit():
1509 1543 return int(symbol)
1510 1544
1511 1545
1512 1546 def _prefetchdrevs(tree):
1513 1547 """return ({single-drev-id}, {ancestor-drev-id}) to prefetch"""
1514 1548 drevs = set()
1515 1549 ancestordrevs = set()
1516 1550 op = tree[0]
1517 1551 if op == b'symbol':
1518 1552 r = _parsedrev(tree[1])
1519 1553 if r:
1520 1554 drevs.add(r)
1521 1555 elif op == b'ancestors':
1522 1556 r, a = _prefetchdrevs(tree[1])
1523 1557 drevs.update(r)
1524 1558 ancestordrevs.update(r)
1525 1559 ancestordrevs.update(a)
1526 1560 else:
1527 1561 for t in tree[1:]:
1528 1562 r, a = _prefetchdrevs(t)
1529 1563 drevs.update(r)
1530 1564 ancestordrevs.update(a)
1531 1565 return drevs, ancestordrevs
1532 1566
1533 1567
1534 1568 def querydrev(ui, spec):
1535 1569 """return a list of "Differential Revision" dicts
1536 1570
1537 1571 spec is a string using a simple query language, see docstring in phabread
1538 1572 for details.
1539 1573
1540 1574 A "Differential Revision dict" looks like:
1541 1575
1542 1576 {
1543 1577 "activeDiffPHID": "PHID-DIFF-xoqnjkobbm6k4dk6hi72",
1544 1578 "authorPHID": "PHID-USER-tv3ohwc4v4jeu34otlye",
1545 1579 "auxiliary": {
1546 1580 "phabricator:depends-on": [
1547 1581 "PHID-DREV-gbapp366kutjebt7agcd"
1548 1582 ]
1549 1583 "phabricator:projects": [],
1550 1584 },
1551 1585 "branch": "default",
1552 1586 "ccs": [],
1553 1587 "commits": [],
1554 1588 "dateCreated": "1499181406",
1555 1589 "dateModified": "1499182103",
1556 1590 "diffs": [
1557 1591 "3",
1558 1592 "4",
1559 1593 ],
1560 1594 "hashes": [],
1561 1595 "id": "2",
1562 1596 "lineCount": "2",
1563 1597 "phid": "PHID-DREV-672qvysjcczopag46qty",
1564 1598 "properties": {},
1565 1599 "repositoryPHID": "PHID-REPO-hub2hx62ieuqeheznasv",
1566 1600 "reviewers": [],
1567 1601 "sourcePath": null
1568 1602 "status": "0",
1569 1603 "statusName": "Needs Review",
1570 1604 "summary": "",
1571 1605 "testPlan": "",
1572 1606 "title": "example",
1573 1607 "uri": "https://phab.example.com/D2",
1574 1608 }
1575 1609 """
1576 1610 # TODO: replace differential.query and differential.querydiffs with
1577 1611 # differential.diff.search because the former (and their output) are
1578 1612 # frozen, and planned to be deprecated and removed.
1579 1613
1580 1614 def fetch(params):
1581 1615 """params -> single drev or None"""
1582 1616 key = (params.get(b'ids') or params.get(b'phids') or [None])[0]
1583 1617 if key in prefetched:
1584 1618 return prefetched[key]
1585 1619 drevs = callconduit(ui, b'differential.query', params)
1586 1620 # Fill prefetched with the result
1587 1621 for drev in drevs:
1588 1622 prefetched[drev[b'phid']] = drev
1589 1623 prefetched[int(drev[b'id'])] = drev
1590 1624 if key not in prefetched:
1591 1625 raise error.Abort(
1592 1626 _(b'cannot get Differential Revision %r') % params
1593 1627 )
1594 1628 return prefetched[key]
1595 1629
1596 1630 def getstack(topdrevids):
1597 1631 """given a top, get a stack from the bottom, [id] -> [id]"""
1598 1632 visited = set()
1599 1633 result = []
1600 1634 queue = [{b'ids': [i]} for i in topdrevids]
1601 1635 while queue:
1602 1636 params = queue.pop()
1603 1637 drev = fetch(params)
1604 1638 if drev[b'id'] in visited:
1605 1639 continue
1606 1640 visited.add(drev[b'id'])
1607 1641 result.append(int(drev[b'id']))
1608 1642 auxiliary = drev.get(b'auxiliary', {})
1609 1643 depends = auxiliary.get(b'phabricator:depends-on', [])
1610 1644 for phid in depends:
1611 1645 queue.append({b'phids': [phid]})
1612 1646 result.reverse()
1613 1647 return smartset.baseset(result)
1614 1648
1615 1649 # Initialize prefetch cache
1616 1650 prefetched = {} # {id or phid: drev}
1617 1651
1618 1652 tree = _parse(spec)
1619 1653 drevs, ancestordrevs = _prefetchdrevs(tree)
1620 1654
1621 1655 # developer config: phabricator.batchsize
1622 1656 batchsize = ui.configint(b'phabricator', b'batchsize')
1623 1657
1624 1658 # Prefetch Differential Revisions in batch
1625 1659 tofetch = set(drevs)
1626 1660 for r in ancestordrevs:
1627 1661 tofetch.update(range(max(1, r - batchsize), r + 1))
1628 1662 if drevs:
1629 1663 fetch({b'ids': list(tofetch)})
1630 1664 validids = sorted(set(getstack(list(ancestordrevs))) | set(drevs))
1631 1665
1632 1666 # Walk through the tree, return smartsets
1633 1667 def walk(tree):
1634 1668 op = tree[0]
1635 1669 if op == b'symbol':
1636 1670 drev = _parsedrev(tree[1])
1637 1671 if drev:
1638 1672 return smartset.baseset([drev])
1639 1673 elif tree[1] in _knownstatusnames:
1640 1674 drevs = [
1641 1675 r
1642 1676 for r in validids
1643 1677 if _getstatusname(prefetched[r]) == tree[1]
1644 1678 ]
1645 1679 return smartset.baseset(drevs)
1646 1680 else:
1647 1681 raise error.Abort(_(b'unknown symbol: %s') % tree[1])
1648 1682 elif op in {b'and_', b'add', b'sub'}:
1649 1683 assert len(tree) == 3
1650 1684 return getattr(operator, op)(walk(tree[1]), walk(tree[2]))
1651 1685 elif op == b'group':
1652 1686 return walk(tree[1])
1653 1687 elif op == b'ancestors':
1654 1688 return getstack(walk(tree[1]))
1655 1689 else:
1656 1690 raise error.ProgrammingError(b'illegal tree: %r' % tree)
1657 1691
1658 1692 return [prefetched[r] for r in walk(tree)]
1659 1693
1660 1694
1661 1695 def getdescfromdrev(drev):
1662 1696 """get description (commit message) from "Differential Revision"
1663 1697
1664 1698 This is similar to differential.getcommitmessage API. But we only care
1665 1699 about limited fields: title, summary, test plan, and URL.
1666 1700 """
1667 1701 title = drev[b'title']
1668 1702 summary = drev[b'summary'].rstrip()
1669 1703 testplan = drev[b'testPlan'].rstrip()
1670 1704 if testplan:
1671 1705 testplan = b'Test Plan:\n%s' % testplan
1672 1706 uri = b'Differential Revision: %s' % drev[b'uri']
1673 1707 return b'\n\n'.join(filter(None, [title, summary, testplan, uri]))
1674 1708
1675 1709
1676 1710 def get_amended_desc(drev, ctx, folded):
1677 1711 """similar to ``getdescfromdrev``, but supports a folded series of commits
1678 1712
1679 1713 This is used when determining if an individual commit needs to have its
1680 1714 message amended after posting it for review. The determination is made for
1681 1715 each individual commit, even when they were folded into one review.
1682 1716 """
1683 1717 if not folded:
1684 1718 return getdescfromdrev(drev)
1685 1719
1686 1720 uri = b'Differential Revision: %s' % drev[b'uri']
1687 1721
1688 1722 # Since the commit messages were combined when posting multiple commits
1689 1723 # with --fold, the fields can't be read from Phabricator here, or *all*
1690 1724 # affected local revisions will end up with the same commit message after
1691 1725 # the URI is amended in. Append in the DREV line, or update it if it
1692 1726 # exists. At worst, this means commit message or test plan updates on
1693 1727 # Phabricator aren't propagated back to the repository, but that seems
1694 1728 # reasonable for the case where local commits are effectively combined
1695 1729 # in Phabricator.
1696 1730 m = _differentialrevisiondescre.search(ctx.description())
1697 1731 if not m:
1698 1732 return b'\n\n'.join([ctx.description(), uri])
1699 1733
1700 1734 return _differentialrevisiondescre.sub(uri, ctx.description())
1701 1735
1702 1736
1703 1737 def getlocalcommits(diff):
1704 1738 """get the set of local commits from a diff object
1705 1739
1706 1740 See ``getdiffmeta()`` for an example diff object.
1707 1741 """
1708 1742 props = diff.get(b'properties') or {}
1709 1743 commits = props.get(b'local:commits') or {}
1710 1744 if len(commits) > 1:
1711 1745 return {bin(c) for c in commits.keys()}
1712 1746
1713 1747 # Storing the diff metadata predates storing `local:commits`, so continue
1714 1748 # to use that in the --no-fold case.
1715 1749 return {bin(getdiffmeta(diff).get(b'node', b'')) or None}
1716 1750
1717 1751
1718 1752 def getdiffmeta(diff):
1719 1753 """get commit metadata (date, node, user, p1) from a diff object
1720 1754
1721 1755 The metadata could be "hg:meta", sent by phabsend, like:
1722 1756
1723 1757 "properties": {
1724 1758 "hg:meta": {
1725 1759 "branch": "default",
1726 1760 "date": "1499571514 25200",
1727 1761 "node": "98c08acae292b2faf60a279b4189beb6cff1414d",
1728 1762 "user": "Foo Bar <foo@example.com>",
1729 1763 "parent": "6d0abad76b30e4724a37ab8721d630394070fe16"
1730 1764 }
1731 1765 }
1732 1766
1733 1767 Or converted from "local:commits", sent by "arc", like:
1734 1768
1735 1769 "properties": {
1736 1770 "local:commits": {
1737 1771 "98c08acae292b2faf60a279b4189beb6cff1414d": {
1738 1772 "author": "Foo Bar",
1739 1773 "authorEmail": "foo@example.com"
1740 1774 "branch": "default",
1741 1775 "commit": "98c08acae292b2faf60a279b4189beb6cff1414d",
1742 1776 "local": "1000",
1743 1777 "message": "...",
1744 1778 "parents": ["6d0abad76b30e4724a37ab8721d630394070fe16"],
1745 1779 "rev": "98c08acae292b2faf60a279b4189beb6cff1414d",
1746 1780 "summary": "...",
1747 1781 "tag": "",
1748 1782 "time": 1499546314,
1749 1783 }
1750 1784 }
1751 1785 }
1752 1786
1753 1787 Note: metadata extracted from "local:commits" will lose time zone
1754 1788 information.
1755 1789 """
1756 1790 props = diff.get(b'properties') or {}
1757 1791 meta = props.get(b'hg:meta')
1758 1792 if not meta:
1759 1793 if props.get(b'local:commits'):
1760 1794 commit = sorted(props[b'local:commits'].values())[0]
1761 1795 meta = {}
1762 1796 if b'author' in commit and b'authorEmail' in commit:
1763 1797 meta[b'user'] = b'%s <%s>' % (
1764 1798 commit[b'author'],
1765 1799 commit[b'authorEmail'],
1766 1800 )
1767 1801 if b'time' in commit:
1768 1802 meta[b'date'] = b'%d 0' % int(commit[b'time'])
1769 1803 if b'branch' in commit:
1770 1804 meta[b'branch'] = commit[b'branch']
1771 1805 node = commit.get(b'commit', commit.get(b'rev'))
1772 1806 if node:
1773 1807 meta[b'node'] = node
1774 1808 if len(commit.get(b'parents', ())) >= 1:
1775 1809 meta[b'parent'] = commit[b'parents'][0]
1776 1810 else:
1777 1811 meta = {}
1778 1812 if b'date' not in meta and b'dateCreated' in diff:
1779 1813 meta[b'date'] = b'%s 0' % diff[b'dateCreated']
1780 1814 if b'branch' not in meta and diff.get(b'branch'):
1781 1815 meta[b'branch'] = diff[b'branch']
1782 1816 if b'parent' not in meta and diff.get(b'sourceControlBaseRevision'):
1783 1817 meta[b'parent'] = diff[b'sourceControlBaseRevision']
1784 1818 return meta
1785 1819
1786 1820
1787 1821 def _getdrevs(ui, stack, specs):
1788 1822 """convert user supplied DREVSPECs into "Differential Revision" dicts
1789 1823
1790 1824 See ``hg help phabread`` for how to specify each DREVSPEC.
1791 1825 """
1792 1826 if len(specs) > 0:
1793 1827
1794 1828 def _formatspec(s):
1795 1829 if stack:
1796 1830 s = b':(%s)' % s
1797 1831 return b'(%s)' % s
1798 1832
1799 1833 spec = b'+'.join(pycompat.maplist(_formatspec, specs))
1800 1834
1801 1835 drevs = querydrev(ui, spec)
1802 1836 if drevs:
1803 1837 return drevs
1804 1838
1805 1839 raise error.Abort(_(b"empty DREVSPEC set"))
1806 1840
1807 1841
1808 1842 def readpatch(ui, drevs, write):
1809 1843 """generate plain-text patch readable by 'hg import'
1810 1844
1811 1845 write takes a list of (DREV, bytes), where DREV is the differential number
1812 1846 (as bytes, without the "D" prefix) and the bytes are the text of a patch
1813 1847 to be imported. drevs is what "querydrev" returns, results of
1814 1848 "differential.query".
1815 1849 """
1816 1850 # Prefetch hg:meta property for all diffs
1817 1851 diffids = sorted({max(int(v) for v in drev[b'diffs']) for drev in drevs})
1818 1852 diffs = callconduit(ui, b'differential.querydiffs', {b'ids': diffids})
1819 1853
1820 1854 patches = []
1821 1855
1822 1856 # Generate patch for each drev
1823 1857 for drev in drevs:
1824 1858 ui.note(_(b'reading D%s\n') % drev[b'id'])
1825 1859
1826 1860 diffid = max(int(v) for v in drev[b'diffs'])
1827 1861 body = callconduit(ui, b'differential.getrawdiff', {b'diffID': diffid})
1828 1862 desc = getdescfromdrev(drev)
1829 1863 header = b'# HG changeset patch\n'
1830 1864
1831 1865 # Try to preserve metadata from hg:meta property. Write hg patch
1832 1866 # headers that can be read by the "import" command. See patchheadermap
1833 1867 # and extract in mercurial/patch.py for supported headers.
1834 1868 meta = getdiffmeta(diffs[b'%d' % diffid])
1835 1869 for k in _metanamemap.keys():
1836 1870 if k in meta:
1837 1871 header += b'# %s %s\n' % (_metanamemap[k], meta[k])
1838 1872
1839 1873 content = b'%s%s\n%s' % (header, desc, body)
1840 1874 patches.append((drev[b'id'], content))
1841 1875
1842 1876 # Write patches to the supplied callback
1843 1877 write(patches)
1844 1878
1845 1879
1846 1880 @vcrcommand(
1847 1881 b'phabread',
1848 1882 [(b'', b'stack', False, _(b'read dependencies'))],
1849 1883 _(b'DREVSPEC... [OPTIONS]'),
1850 1884 helpcategory=command.CATEGORY_IMPORT_EXPORT,
1851 1885 optionalrepo=True,
1852 1886 )
1853 1887 def phabread(ui, repo, *specs, **opts):
1854 1888 """print patches from Phabricator suitable for importing
1855 1889
1856 1890 DREVSPEC could be a Differential Revision identity, like ``D123``, or just
1857 1891 the number ``123``. It could also have common operators like ``+``, ``-``,
1858 1892 ``&``, ``(``, ``)`` for complex queries. Prefix ``:`` could be used to
1859 1893 select a stack. If multiple DREVSPEC values are given, the result is the
1860 1894 union of each individually evaluated value. No attempt is currently made
1861 1895 to reorder the values to run from parent to child.
1862 1896
1863 1897 ``abandoned``, ``accepted``, ``closed``, ``needsreview``, ``needsrevision``
1864 1898 could be used to filter patches by status. For performance reason, they
1865 1899 only represent a subset of non-status selections and cannot be used alone.
1866 1900
1867 1901 For example, ``:D6+8-(2+D4)`` selects a stack up to D6, plus D8 and exclude
1868 1902 D2 and D4. ``:D9 & needsreview`` selects "Needs Review" revisions in a
1869 1903 stack up to D9.
1870 1904
1871 1905 If --stack is given, follow dependencies information and read all patches.
1872 1906 It is equivalent to the ``:`` operator.
1873 1907 """
1874 1908 opts = pycompat.byteskwargs(opts)
1875 1909 drevs = _getdrevs(ui, opts.get(b'stack'), specs)
1876 1910
1877 1911 def _write(patches):
1878 1912 for drev, content in patches:
1879 1913 ui.write(content)
1880 1914
1881 1915 readpatch(ui, drevs, _write)
1882 1916
1883 1917
1884 1918 @vcrcommand(
1885 1919 b'phabimport',
1886 1920 [(b'', b'stack', False, _(b'import dependencies as well'))],
1887 1921 _(b'DREVSPEC... [OPTIONS]'),
1888 1922 helpcategory=command.CATEGORY_IMPORT_EXPORT,
1889 1923 )
1890 1924 def phabimport(ui, repo, *specs, **opts):
1891 1925 """import patches from Phabricator for the specified Differential Revisions
1892 1926
1893 1927 The patches are read and applied starting at the parent of the working
1894 1928 directory.
1895 1929
1896 1930 See ``hg help phabread`` for how to specify DREVSPEC.
1897 1931 """
1898 1932 opts = pycompat.byteskwargs(opts)
1899 1933
1900 1934 # --bypass avoids losing exec and symlink bits when importing on Windows,
1901 1935 # and allows importing with a dirty wdir. It also aborts instead of leaving
1902 1936 # rejects.
1903 1937 opts[b'bypass'] = True
1904 1938
1905 1939 # Mandatory default values, synced with commands.import
1906 1940 opts[b'strip'] = 1
1907 1941 opts[b'prefix'] = b''
1908 1942 # Evolve 9.3.0 assumes this key is present in cmdutil.tryimportone()
1909 1943 opts[b'obsolete'] = False
1910 1944
1911 1945 if ui.configbool(b'phabimport', b'secret'):
1912 1946 opts[b'secret'] = True
1913 1947 if ui.configbool(b'phabimport', b'obsolete'):
1914 1948 opts[b'obsolete'] = True # Handled by evolve wrapping tryimportone()
1915 1949
1916 1950 def _write(patches):
1917 1951 parents = repo[None].parents()
1918 1952
1919 1953 with repo.wlock(), repo.lock(), repo.transaction(b'phabimport'):
1920 1954 for drev, contents in patches:
1921 1955 ui.status(_(b'applying patch from D%s\n') % drev)
1922 1956
1923 1957 with patch.extract(ui, pycompat.bytesio(contents)) as patchdata:
1924 1958 msg, node, rej = cmdutil.tryimportone(
1925 1959 ui,
1926 1960 repo,
1927 1961 patchdata,
1928 1962 parents,
1929 1963 opts,
1930 1964 [],
1931 1965 None, # Never update wdir to another revision
1932 1966 )
1933 1967
1934 1968 if not node:
1935 1969 raise error.Abort(_(b'D%s: no diffs found') % drev)
1936 1970
1937 1971 ui.note(msg + b'\n')
1938 1972 parents = [repo[node]]
1939 1973
1940 1974 drevs = _getdrevs(ui, opts.get(b'stack'), specs)
1941 1975
1942 1976 readpatch(repo.ui, drevs, _write)
1943 1977
1944 1978
1945 1979 @vcrcommand(
1946 1980 b'phabupdate',
1947 1981 [
1948 1982 (b'', b'accept', False, _(b'accept revisions')),
1949 1983 (b'', b'reject', False, _(b'reject revisions')),
1950 1984 (b'', b'abandon', False, _(b'abandon revisions')),
1951 1985 (b'', b'reclaim', False, _(b'reclaim revisions')),
1952 1986 (b'm', b'comment', b'', _(b'comment on the last revision')),
1953 1987 ],
1954 1988 _(b'DREVSPEC... [OPTIONS]'),
1955 1989 helpcategory=command.CATEGORY_IMPORT_EXPORT,
1956 1990 optionalrepo=True,
1957 1991 )
1958 1992 def phabupdate(ui, repo, *specs, **opts):
1959 1993 """update Differential Revision in batch
1960 1994
1961 1995 DREVSPEC selects revisions. See :hg:`help phabread` for its usage.
1962 1996 """
1963 1997 opts = pycompat.byteskwargs(opts)
1964 1998 flags = [n for n in b'accept reject abandon reclaim'.split() if opts.get(n)]
1965 1999 if len(flags) > 1:
1966 2000 raise error.Abort(_(b'%s cannot be used together') % b', '.join(flags))
1967 2001
1968 2002 actions = []
1969 2003 for f in flags:
1970 2004 actions.append({b'type': f, b'value': True})
1971 2005
1972 2006 drevs = _getdrevs(ui, opts.get(b'stack'), specs)
1973 2007 for i, drev in enumerate(drevs):
1974 2008 if i + 1 == len(drevs) and opts.get(b'comment'):
1975 2009 actions.append({b'type': b'comment', b'value': opts[b'comment']})
1976 2010 if actions:
1977 2011 params = {
1978 2012 b'objectIdentifier': drev[b'phid'],
1979 2013 b'transactions': actions,
1980 2014 }
1981 2015 callconduit(ui, b'differential.revision.edit', params)
1982 2016
1983 2017
1984 2018 @eh.templatekeyword(b'phabreview', requires={b'ctx'})
1985 2019 def template_review(context, mapping):
1986 2020 """:phabreview: Object describing the review for this changeset.
1987 2021 Has attributes `url` and `id`.
1988 2022 """
1989 2023 ctx = context.resource(mapping, b'ctx')
1990 2024 m = _differentialrevisiondescre.search(ctx.description())
1991 2025 if m:
1992 2026 return templateutil.hybriddict(
1993 2027 {b'url': m.group('url'), b'id': b"D%s" % m.group('id'),}
1994 2028 )
1995 2029 else:
1996 2030 tags = ctx.repo().nodetags(ctx.node())
1997 2031 for t in tags:
1998 2032 if _differentialrevisiontagre.match(t):
1999 2033 url = ctx.repo().ui.config(b'phabricator', b'url')
2000 2034 if not url.endswith(b'/'):
2001 2035 url += b'/'
2002 2036 url += t
2003 2037
2004 2038 return templateutil.hybriddict({b'url': url, b'id': t,})
2005 2039 return None
2006 2040
2007 2041
2008 2042 @eh.templatekeyword(b'phabstatus', requires={b'ctx', b'repo', b'ui'})
2009 2043 def template_status(context, mapping):
2010 2044 """:phabstatus: String. Status of Phabricator differential.
2011 2045 """
2012 2046 ctx = context.resource(mapping, b'ctx')
2013 2047 repo = context.resource(mapping, b'repo')
2014 2048 ui = context.resource(mapping, b'ui')
2015 2049
2016 2050 rev = ctx.rev()
2017 2051 try:
2018 2052 drevid = getdrevmap(repo, [rev])[rev]
2019 2053 except KeyError:
2020 2054 return None
2021 2055 drevs = callconduit(ui, b'differential.query', {b'ids': [drevid]})
2022 2056 for drev in drevs:
2023 2057 if int(drev[b'id']) == drevid:
2024 2058 return templateutil.hybriddict(
2025 2059 {b'url': drev[b'uri'], b'status': drev[b'statusName'],}
2026 2060 )
2027 2061 return None
2028 2062
2029 2063
2030 2064 @show.showview(b'phabstatus', csettopic=b'work')
2031 2065 def phabstatusshowview(ui, repo, displayer):
2032 2066 """Phabricator differiential status"""
2033 2067 revs = repo.revs('sort(_underway(), topo)')
2034 2068 drevmap = getdrevmap(repo, revs)
2035 2069 unknownrevs, drevids, revsbydrevid = [], set(), {}
2036 2070 for rev, drevid in pycompat.iteritems(drevmap):
2037 2071 if drevid is not None:
2038 2072 drevids.add(drevid)
2039 2073 revsbydrevid.setdefault(drevid, set()).add(rev)
2040 2074 else:
2041 2075 unknownrevs.append(rev)
2042 2076
2043 2077 drevs = callconduit(ui, b'differential.query', {b'ids': list(drevids)})
2044 2078 drevsbyrev = {}
2045 2079 for drev in drevs:
2046 2080 for rev in revsbydrevid[int(drev[b'id'])]:
2047 2081 drevsbyrev[rev] = drev
2048 2082
2049 2083 def phabstatus(ctx):
2050 2084 drev = drevsbyrev[ctx.rev()]
2051 2085 status = ui.label(
2052 2086 b'%(statusName)s' % drev,
2053 2087 b'phabricator.status.%s' % _getstatusname(drev),
2054 2088 )
2055 2089 ui.write(b"\n%s %s\n" % (drev[b'uri'], status))
2056 2090
2057 2091 revs -= smartset.baseset(unknownrevs)
2058 2092 revdag = graphmod.dagwalker(repo, revs)
2059 2093
2060 2094 ui.setconfig(b'experimental', b'graphshorten', True)
2061 2095 displayer._exthook = phabstatus
2062 2096 nodelen = show.longestshortest(repo, revs)
2063 2097 logcmdutil.displaygraph(
2064 2098 ui,
2065 2099 repo,
2066 2100 revdag,
2067 2101 displayer,
2068 2102 graphmod.asciiedges,
2069 2103 props={b'nodelen': nodelen},
2070 2104 )
@@ -1,398 +1,419
1 1 #require vcr
2 2 $ cat >> $HGRCPATH <<EOF
3 3 > [extensions]
4 4 > phabricator =
5 >
6 > [phabricator]
7 > debug = True
5 8 > EOF
6 9 $ hg init repo
7 10 $ cd repo
8 11 $ cat >> .hg/hgrc <<EOF
9 12 > [phabricator]
10 13 > url = https://phab.mercurial-scm.org/
11 14 > callsign = HG
12 15 >
13 16 > [auth]
14 17 > hgphab.schemes = https
15 18 > hgphab.prefix = phab.mercurial-scm.org
16 19 > # When working on the extension and making phabricator interaction
17 20 > # changes, edit this to be a real phabricator token. When done, edit
18 21 > # it back. The VCR transcripts will be auto-sanitised to replace your real
19 22 > # token with this value.
20 23 > hgphab.phabtoken = cli-hahayouwish
21 24 > EOF
22 25 $ VCR="$TESTDIR/phabricator"
23 26
24 27 Error is handled reasonably. We override the phabtoken here so that
25 28 when you're developing changes to phabricator.py you can edit the
26 29 above config and have a real token in the test but not have to edit
27 30 this test.
28 31 $ hg phabread --config auth.hgphab.phabtoken=cli-notavalidtoken \
29 32 > --test-vcr "$VCR/phabread-conduit-error.json" D4480 | head
30 33 abort: Conduit Error (ERR-INVALID-AUTH): API token "cli-notavalidtoken" has the wrong length. API tokens should be 32 characters long.
31 34
32 35 Missing arguments don't crash, and may print the command help
33 36
34 37 $ hg debugcallconduit
35 38 hg debugcallconduit: invalid arguments
36 39 hg debugcallconduit METHOD
37 40
38 41 call Conduit API
39 42
40 43 options:
41 44
42 45 (use 'hg debugcallconduit -h' to show more help)
43 46 [255]
44 47 $ hg phabread
45 48 abort: empty DREVSPEC set
46 49 [255]
47 50
48 51 Basic phabread:
49 52 $ hg phabread --test-vcr "$VCR/phabread-4480.json" D4480 | head
50 53 # HG changeset patch
51 54 # Date 1536771503 0
52 55 # Parent a5de21c9e3703f8e8eb064bd7d893ff2f703c66a
53 56 exchangev2: start to implement pull with wire protocol v2
54 57
55 58 Wire protocol version 2 will take a substantially different
56 59 approach to exchange than version 1 (at least as far as pulling
57 60 is concerned).
58 61
59 62 This commit establishes a new exchangev2 module for holding
60 63
61 64 Phabread with multiple DREVSPEC
62 65
63 66 TODO: attempt to order related revisions like --stack?
64 67 $ hg phabread --test-vcr "$VCR/phabread-multi-drev.json" D8205 8206 D8207 \
65 68 > | grep '^Differential Revision'
66 69 Differential Revision: https://phab.mercurial-scm.org/D8205
67 70 Differential Revision: https://phab.mercurial-scm.org/D8206
68 71 Differential Revision: https://phab.mercurial-scm.org/D8207
69 72
70 73 Empty DREVSPECs don't crash
71 74
72 75 $ hg phabread --test-vcr "$VCR/phabread-empty-drev.json" D7917-D7917
73 76 abort: empty DREVSPEC set
74 77 [255]
75 78
76 79
77 80 phabupdate with an accept:
78 81 $ hg phabupdate --accept D4564 \
79 82 > -m 'I think I like where this is headed. Will read rest of series later.'\
80 83 > --test-vcr "$VCR/accept-4564.json"
81 84 abort: Conduit Error (ERR-CONDUIT-CORE): Validation errors:
82 85 - You can not accept this revision because it has already been closed. Only open revisions can be accepted.
83 86 [255]
84 87 $ hg phabupdate --accept D7913 -m 'LGTM' --test-vcr "$VCR/accept-7913.json"
85 88
86 89 Create a differential diff:
87 90 $ HGENCODING=utf-8; export HGENCODING
88 91 $ echo alpha > alpha
89 92 $ hg ci --addremove -m 'create alpha for phabricator test €'
90 93 adding alpha
91 94 $ hg phabsend -r . --test-vcr "$VCR/phabsend-create-alpha.json"
92 95 D7915 - created - d386117f30e6: create alpha for phabricator test \xe2\x82\xac (esc)
96 new commits: ['347bf67801e5']
93 97 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/d386117f30e6-24ffe649-phabsend.hg
94 98 $ echo more >> alpha
95 99 $ HGEDITOR=true hg ci --amend
96 100 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/347bf67801e5-3bf313e4-amend.hg
97 101 $ echo beta > beta
98 102 $ hg ci --addremove -m 'create beta for phabricator test'
99 103 adding beta
100 104 $ hg phabsend -r ".^::" --test-vcr "$VCR/phabsend-update-alpha-create-beta.json"
105 c44b38f24a45 mapped to old nodes []
101 106 D7915 - updated - c44b38f24a45: create alpha for phabricator test \xe2\x82\xac (esc)
102 107 D7916 - created - 9e6901f21d5b: create beta for phabricator test
108 new commits: ['a692622e6937']
103 109 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/9e6901f21d5b-1fcd4f0e-phabsend.hg
104 110 $ unset HGENCODING
105 111
106 112 The amend won't explode after posting a public commit. The local tag is left
107 113 behind to identify it.
108 114
109 115 $ echo 'public change' > beta
110 116 $ hg ci -m 'create public change for phabricator testing'
111 117 $ hg phase --public .
112 118 $ echo 'draft change' > alpha
113 119 $ hg ci -m 'create draft change for phabricator testing'
114 120 $ hg phabsend --amend -r '.^::' --test-vcr "$VCR/phabsend-create-public.json"
115 121 D7917 - created - 7b4185ab5d16: create public change for phabricator testing
116 122 D7918 - created - 251c1c333fc6: create draft change for phabricator testing
117 123 warning: not updating public commit 2:7b4185ab5d16
124 new commits: ['3244dc4a3334']
118 125 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/251c1c333fc6-41cb7c3b-phabsend.hg
119 126 $ hg tags -v
120 127 tip 3:3244dc4a3334
121 128 D7917 2:7b4185ab5d16 local
122 129
123 130 $ hg debugcallconduit user.search --test-vcr "$VCR/phab-conduit.json" <<EOF
124 131 > {
125 132 > "constraints": {
126 133 > "isBot": true
127 134 > }
128 135 > }
129 136 > EOF
130 137 {
131 138 "cursor": {
132 139 "after": null,
133 140 "before": null,
134 141 "limit": 100,
135 142 "order": null
136 143 },
137 144 "data": [],
138 145 "maps": {},
139 146 "query": {
140 147 "queryKey": null
141 148 }
142 149 }
143 150
144 151 Template keywords
145 152 $ hg log -T'{rev} {phabreview|json}\n'
146 153 3 {"id": "D7918", "url": "https://phab.mercurial-scm.org/D7918"}
147 154 2 {"id": "D7917", "url": "https://phab.mercurial-scm.org/D7917"}
148 155 1 {"id": "D7916", "url": "https://phab.mercurial-scm.org/D7916"}
149 156 0 {"id": "D7915", "url": "https://phab.mercurial-scm.org/D7915"}
150 157
151 158 $ hg log -T'{rev} {if(phabreview, "{phabreview.url} {phabreview.id}")}\n'
152 159 3 https://phab.mercurial-scm.org/D7918 D7918
153 160 2 https://phab.mercurial-scm.org/D7917 D7917
154 161 1 https://phab.mercurial-scm.org/D7916 D7916
155 162 0 https://phab.mercurial-scm.org/D7915 D7915
156 163
157 164 Commenting when phabsending:
158 165 $ echo comment > comment
159 166 $ hg ci --addremove -m "create comment for phabricator test"
160 167 adding comment
161 168 $ hg phabsend -r . -m "For default branch" --test-vcr "$VCR/phabsend-comment-created.json"
162 169 D7919 - created - d5dddca9023d: create comment for phabricator test
170 new commits: ['f7db812bbe1d']
163 171 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/d5dddca9023d-adf673ba-phabsend.hg
164 172 $ echo comment2 >> comment
165 173 $ hg ci --amend
166 174 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/f7db812bbe1d-8fcded77-amend.hg
167 175 $ hg phabsend -r . -m "Address review comments" --test-vcr "$VCR/phabsend-comment-updated.json"
176 1849d7828727 mapped to old nodes []
168 177 D7919 - updated - 1849d7828727: create comment for phabricator test
169 178
170 179 Phabsending a skipped commit:
171 180 $ hg phabsend --no-amend -r . --test-vcr "$VCR/phabsend-skipped.json"
181 1849d7828727 mapped to old nodes ['1849d7828727']
172 182 D7919 - skipped - 1849d7828727: create comment for phabricator test
173 183
174 184 Phabesending a new binary, a modified binary, and a removed binary
175 185
176 186 >>> open('bin', 'wb').write(b'\0a') and None
177 187 $ hg ci -Am 'add binary'
178 188 adding bin
179 189 >>> open('bin', 'wb').write(b'\0b') and None
180 190 $ hg ci -m 'modify binary'
181 191 $ hg rm bin
182 192 $ hg ci -m 'remove binary'
183 193 $ hg phabsend -r .~2:: --test-vcr "$VCR/phabsend-binary.json"
184 194 uploading bin@aa24a81f55de
185 195 D8007 - created - aa24a81f55de: add binary
186 196 uploading bin@d8d62a881b54
187 197 D8008 - created - d8d62a881b54: modify binary
188 198 D8009 - created - af55645b2e29: remove binary
199 new commits: ['b8139fbb4a57']
200 new commits: ['c88ce4c2d2ad']
201 new commits: ['75dbbc901145']
189 202 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/aa24a81f55de-a3a0cf24-phabsend.hg
190 203
191 204 Phabsend a renamed binary and a copied binary, with and without content changes
192 205 to src and dest
193 206
194 207 >>> open('bin2', 'wb').write(b'\0c') and None
195 208 $ hg ci -Am 'add another binary'
196 209 adding bin2
197 210
198 211 TODO: "bin2" can't be viewed in this commit (left or right side), and the URL
199 212 looks much different than when viewing "bin2_moved". No idea if this is a phab
200 213 bug, or phabsend bug. The patch (as printed by phabread) look reasonable
201 214 though.
202 215
203 216 $ hg mv bin2 bin2_moved
204 217 $ hg ci -m "moved binary"
205 218
206 219 Note: "bin2_moved" is also not viewable in phabricator with this review
207 220
208 221 $ hg cp bin2_moved bin2_copied
209 222 $ hg ci -m "copied binary"
210 223
211 224 Note: "bin2_moved_again" is marked binary in phabricator, and both sides of it
212 225 are viewable in their proper state. "bin2_copied" is not viewable, and not
213 226 listed as binary in phabricator.
214 227
215 228 >>> open('bin2_copied', 'wb').write(b'\0move+mod') and None
216 229 $ hg mv bin2_copied bin2_moved_again
217 230 $ hg ci -m "move+mod copied binary"
218 231
219 232 Note: "bin2_moved" and "bin2_moved_copy" are both marked binary, and both
220 233 viewable on each side.
221 234
222 235 >>> open('bin2_moved', 'wb').write(b'\0precopy mod') and None
223 236 $ hg cp bin2_moved bin2_moved_copied
224 237 >>> open('bin2_moved', 'wb').write(b'\0copy src+mod') and None
225 238 $ hg ci -m "copy+mod moved binary"
226 239
227 240 $ hg phabsend -r .~4:: --test-vcr "$VCR/phabsend-binary-renames.json"
228 241 uploading bin2@f42f9195e00c
229 242 D8128 - created - f42f9195e00c: add another binary
230 243 D8129 - created - 834ab31d80ae: moved binary
231 244 D8130 - created - 494b750e5194: copied binary
232 245 uploading bin2_moved_again@25f766b50cc2
233 246 D8131 - created - 25f766b50cc2: move+mod copied binary
234 247 uploading bin2_moved_copied@1b87b363a5e4
235 248 uploading bin2_moved@1b87b363a5e4
236 249 D8132 - created - 1b87b363a5e4: copy+mod moved binary
250 new commits: ['90437c20312a']
251 new commits: ['f391f4da4c61']
252 new commits: ['da86a9f3268c']
253 new commits: ['003ffc16ba66']
254 new commits: ['13bd750c36fa']
237 255 saved backup bundle to $TESTTMP/repo/.hg/strip-backup/f42f9195e00c-e82a0769-phabsend.hg
238 256
239 257 Phabreading a DREV with a local:commits time as a string:
240 258 $ hg phabread --test-vcr "$VCR/phabread-str-time.json" D1285
241 259 # HG changeset patch
242 260 # User Pulkit Goyal <7895pulkit@gmail.com>
243 261 # Date 1509404054 -19800
244 262 # Node ID 44fc1c1f1774a76423b9c732af6938435099bcc5
245 263 # Parent 8feef8ef8389a3b544e0a74624f1efc3a8d85d35
246 264 repoview: add a new attribute _visibilityexceptions and related API
247 265
248 266 Currently we don't have a defined way in core to make some hidden revisions
249 267 visible in filtered repo. Extensions to achieve the purpose of unhiding some
250 268 hidden commits, wrap repoview.pinnedrevs() function.
251 269
252 270 To make the above task simple and have well defined API, this patch adds a new
253 271 attribute '_visibilityexceptions' to repoview class which will contains
254 272 the hidden revs which should be exception.
255 273 This will allow to set different exceptions for different repoview objects
256 274 backed by the same unfiltered repo.
257 275
258 276 This patch also adds API to add revs to the attribute set and get them.
259 277
260 278 Thanks to Jun for suggesting the use of repoview class instead of localrepo.
261 279
262 280 Differential Revision: https://phab.mercurial-scm.org/D1285
263 281 diff --git a/mercurial/repoview.py b/mercurial/repoview.py
264 282 --- a/mercurial/repoview.py
265 283 +++ b/mercurial/repoview.py
266 284 @@ * @@ (glob)
267 285 subclasses of `localrepo`. Eg: `bundlerepo` or `statichttprepo`.
268 286 """
269 287
270 288 + # hidden revs which should be visible
271 289 + _visibilityexceptions = set()
272 290 +
273 291 def __init__(self, repo, filtername):
274 292 object.__setattr__(self, r'_unfilteredrepo', repo)
275 293 object.__setattr__(self, r'filtername', filtername)
276 294 @@ -231,6 +234,14 @@
277 295 return self
278 296 return self.unfiltered().filtered(name)
279 297
280 298 + def addvisibilityexceptions(self, revs):
281 299 + """adds hidden revs which should be visible to set of exceptions"""
282 300 + self._visibilityexceptions.update(revs)
283 301 +
284 302 + def getvisibilityexceptions(self):
285 303 + """returns the set of hidden revs which should be visible"""
286 304 + return self._visibilityexceptions
287 305 +
288 306 # everything access are forwarded to the proxied repo
289 307 def __getattr__(self, attr):
290 308 return getattr(self._unfilteredrepo, attr)
291 309 diff --git a/mercurial/localrepo.py b/mercurial/localrepo.py
292 310 --- a/mercurial/localrepo.py
293 311 +++ b/mercurial/localrepo.py
294 312 @@ -570,6 +570,14 @@
295 313 def close(self):
296 314 self._writecaches()
297 315
298 316 + def addvisibilityexceptions(self, exceptions):
299 317 + # should be called on a filtered repository
300 318 + pass
301 319 +
302 320 + def getvisibilityexceptions(self):
303 321 + # should be called on a filtered repository
304 322 + return set()
305 323 +
306 324 def _loadextensions(self):
307 325 extensions.loadall(self.ui)
308 326
309 327
310 328 A bad .arcconfig doesn't error out
311 329 $ echo 'garbage' > .arcconfig
312 330 $ hg config phabricator --debug
313 331 invalid JSON in $TESTTMP/repo/.arcconfig
314 332 read config from: */.hgrc (glob)
333 */.hgrc:*: phabricator.debug=True (glob)
315 334 $TESTTMP/repo/.hg/hgrc:*: phabricator.url=https://phab.mercurial-scm.org/ (glob)
316 335 $TESTTMP/repo/.hg/hgrc:*: phabricator.callsign=HG (glob)
317 336
318 337 The .arcconfig content overrides global config
319 338 $ cat >> $HGRCPATH << EOF
320 339 > [phabricator]
321 340 > url = global
322 341 > callsign = global
323 342 > EOF
324 343 $ cp $TESTDIR/../.arcconfig .
325 344 $ mv .hg/hgrc .hg/hgrc.bak
326 345 $ hg config phabricator --debug
327 346 read config from: */.hgrc (glob)
347 */.hgrc:*: phabricator.debug=True (glob)
328 348 $TESTTMP/repo/.arcconfig: phabricator.callsign=HG
329 349 $TESTTMP/repo/.arcconfig: phabricator.url=https://phab.mercurial-scm.org/
330 350
331 351 But it doesn't override local config
332 352 $ cat >> .hg/hgrc << EOF
333 353 > [phabricator]
334 354 > url = local
335 355 > callsign = local
336 356 > EOF
337 357 $ hg config phabricator --debug
338 358 read config from: */.hgrc (glob)
359 */.hgrc:*: phabricator.debug=True (glob)
339 360 $TESTTMP/repo/.hg/hgrc:*: phabricator.url=local (glob)
340 361 $TESTTMP/repo/.hg/hgrc:*: phabricator.callsign=local (glob)
341 362 $ mv .hg/hgrc.bak .hg/hgrc
342 363
343 364 Phabimport works with a stack
344 365
345 366 $ cd ..
346 367 $ hg clone repo repo2 -qr 1
347 368 $ cp repo/.hg/hgrc repo2/.hg/
348 369 $ cd repo2
349 370 $ hg phabimport --stack 'D7918' --test-vcr "$VCR/phabimport-stack.json"
350 371 applying patch from D7917
351 372 applying patch from D7918
352 373 $ hg log -r .: -G -Tcompact
353 374 o 3[tip] aaef04066140 1970-01-01 00:00 +0000 test
354 375 | create draft change for phabricator testing
355 376 |
356 377 o 2 8de3712202d1 1970-01-01 00:00 +0000 test
357 378 | create public change for phabricator testing
358 379 |
359 380 @ 1 a692622e6937 1970-01-01 00:00 +0000 test
360 381 | create beta for phabricator test
361 382 ~
362 383 Phabimport can create secret commits
363 384
364 385 $ hg rollback --config ui.rollback=True
365 386 repository tip rolled back to revision 1 (undo phabimport)
366 387 $ hg phabimport --stack 'D7918' --test-vcr "$VCR/phabimport-stack.json" \
367 388 > --config phabimport.secret=True
368 389 applying patch from D7917
369 390 applying patch from D7918
370 391 $ hg log -r 'reverse(.:)' -T phases
371 392 changeset: 3:aaef04066140
372 393 tag: tip
373 394 phase: secret
374 395 user: test
375 396 date: Thu Jan 01 00:00:00 1970 +0000
376 397 summary: create draft change for phabricator testing
377 398
378 399 changeset: 2:8de3712202d1
379 400 phase: secret
380 401 user: test
381 402 date: Thu Jan 01 00:00:00 1970 +0000
382 403 summary: create public change for phabricator testing
383 404
384 405 changeset: 1:a692622e6937
385 406 phase: public
386 407 user: test
387 408 date: Thu Jan 01 00:00:00 1970 +0000
388 409 summary: create beta for phabricator test
389 410
390 411 Phabimport accepts multiple DREVSPECs
391 412
392 413 $ hg rollback --config ui.rollback=True
393 414 repository tip rolled back to revision 1 (undo phabimport)
394 415 $ hg phabimport --no-stack D7917 D7918 --test-vcr "$VCR/phabimport-multi-drev.json"
395 416 applying patch from D7917
396 417 applying patch from D7918
397 418
398 419 $ cd ..
General Comments 0
You need to be logged in to leave comments. Login now