##// END OF EJS Templates
filemerge: make `_maketempfiles()` more reusable...
Martin von Zweigbergk -
r49637:f9033770 default
parent child Browse files
Show More
@@ -1,1239 +1,1233 b''
1 1 # filemerge.py - file-level merge handling for Mercurial
2 2 #
3 3 # Copyright 2006, 2007, 2008 Olivia Mackall <olivia@selenic.com>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8 from __future__ import absolute_import
9 9
10 10 import contextlib
11 11 import os
12 12 import re
13 13 import shutil
14 14
15 15 from .i18n import _
16 16 from .node import (
17 17 hex,
18 18 short,
19 19 )
20 20 from .pycompat import (
21 21 getattr,
22 22 )
23 23
24 24 from . import (
25 25 encoding,
26 26 error,
27 27 formatter,
28 28 match,
29 29 pycompat,
30 30 registrar,
31 31 scmutil,
32 32 simplemerge,
33 33 tagmerge,
34 34 templatekw,
35 35 templater,
36 36 templateutil,
37 37 util,
38 38 )
39 39
40 40 from .utils import (
41 41 procutil,
42 42 stringutil,
43 43 )
44 44
45 45
46 46 def _toolstr(ui, tool, part, *args):
47 47 return ui.config(b"merge-tools", tool + b"." + part, *args)
48 48
49 49
50 50 def _toolbool(ui, tool, part, *args):
51 51 return ui.configbool(b"merge-tools", tool + b"." + part, *args)
52 52
53 53
54 54 def _toollist(ui, tool, part):
55 55 return ui.configlist(b"merge-tools", tool + b"." + part)
56 56
57 57
58 58 internals = {}
59 59 # Merge tools to document.
60 60 internalsdoc = {}
61 61
62 62 internaltool = registrar.internalmerge()
63 63
64 64 # internal tool merge types
65 65 nomerge = internaltool.nomerge
66 66 mergeonly = internaltool.mergeonly # just the full merge, no premerge
67 67 fullmerge = internaltool.fullmerge # both premerge and merge
68 68
69 69 # IMPORTANT: keep the last line of this prompt very short ("What do you want to
70 70 # do?") because of issue6158, ideally to <40 English characters (to allow other
71 71 # languages that may take more columns to still have a chance to fit in an
72 72 # 80-column screen).
73 73 _localchangedotherdeletedmsg = _(
74 74 b"file '%(fd)s' was deleted in other%(o)s but was modified in local%(l)s.\n"
75 75 b"You can use (c)hanged version, (d)elete, or leave (u)nresolved.\n"
76 76 b"What do you want to do?"
77 77 b"$$ &Changed $$ &Delete $$ &Unresolved"
78 78 )
79 79
80 80 _otherchangedlocaldeletedmsg = _(
81 81 b"file '%(fd)s' was deleted in local%(l)s but was modified in other%(o)s.\n"
82 82 b"You can use (c)hanged version, leave (d)eleted, or leave (u)nresolved.\n"
83 83 b"What do you want to do?"
84 84 b"$$ &Changed $$ &Deleted $$ &Unresolved"
85 85 )
86 86
87 87
88 88 class absentfilectx(object):
89 89 """Represents a file that's ostensibly in a context but is actually not
90 90 present in it.
91 91
92 92 This is here because it's very specific to the filemerge code for now --
93 93 other code is likely going to break with the values this returns."""
94 94
95 95 def __init__(self, ctx, f):
96 96 self._ctx = ctx
97 97 self._f = f
98 98
99 99 def __bytes__(self):
100 100 return b'absent file %s@%s' % (self._f, self._ctx)
101 101
102 102 def path(self):
103 103 return self._f
104 104
105 105 def size(self):
106 106 return None
107 107
108 108 def data(self):
109 109 return None
110 110
111 111 def filenode(self):
112 112 return self._ctx.repo().nullid
113 113
114 114 _customcmp = True
115 115
116 116 def cmp(self, fctx):
117 117 """compare with other file context
118 118
119 119 returns True if different from fctx.
120 120 """
121 121 return not (
122 122 fctx.isabsent()
123 123 and fctx.changectx() == self.changectx()
124 124 and fctx.path() == self.path()
125 125 )
126 126
127 127 def flags(self):
128 128 return b''
129 129
130 130 def changectx(self):
131 131 return self._ctx
132 132
133 133 def isbinary(self):
134 134 return False
135 135
136 136 def isabsent(self):
137 137 return True
138 138
139 139
140 140 def _findtool(ui, tool):
141 141 if tool in internals:
142 142 return tool
143 143 cmd = _toolstr(ui, tool, b"executable", tool)
144 144 if cmd.startswith(b'python:'):
145 145 return cmd
146 146 return findexternaltool(ui, tool)
147 147
148 148
149 149 def _quotetoolpath(cmd):
150 150 if cmd.startswith(b'python:'):
151 151 return cmd
152 152 return procutil.shellquote(cmd)
153 153
154 154
155 155 def findexternaltool(ui, tool):
156 156 for kn in (b"regkey", b"regkeyalt"):
157 157 k = _toolstr(ui, tool, kn)
158 158 if not k:
159 159 continue
160 160 p = util.lookupreg(k, _toolstr(ui, tool, b"regname"))
161 161 if p:
162 162 p = procutil.findexe(p + _toolstr(ui, tool, b"regappend", b""))
163 163 if p:
164 164 return p
165 165 exe = _toolstr(ui, tool, b"executable", tool)
166 166 return procutil.findexe(util.expandpath(exe))
167 167
168 168
169 169 def _picktool(repo, ui, path, binary, symlink, changedelete):
170 170 strictcheck = ui.configbool(b'merge', b'strict-capability-check')
171 171
172 172 def hascapability(tool, capability, strict=False):
173 173 if tool in internals:
174 174 return strict and internals[tool].capabilities.get(capability)
175 175 return _toolbool(ui, tool, capability)
176 176
177 177 def supportscd(tool):
178 178 return tool in internals and internals[tool].mergetype == nomerge
179 179
180 180 def check(tool, pat, symlink, binary, changedelete):
181 181 tmsg = tool
182 182 if pat:
183 183 tmsg = _(b"%s (for pattern %s)") % (tool, pat)
184 184 if not _findtool(ui, tool):
185 185 if pat: # explicitly requested tool deserves a warning
186 186 ui.warn(_(b"couldn't find merge tool %s\n") % tmsg)
187 187 else: # configured but non-existing tools are more silent
188 188 ui.note(_(b"couldn't find merge tool %s\n") % tmsg)
189 189 elif symlink and not hascapability(tool, b"symlink", strictcheck):
190 190 ui.warn(_(b"tool %s can't handle symlinks\n") % tmsg)
191 191 elif binary and not hascapability(tool, b"binary", strictcheck):
192 192 ui.warn(_(b"tool %s can't handle binary\n") % tmsg)
193 193 elif changedelete and not supportscd(tool):
194 194 # the nomerge tools are the only tools that support change/delete
195 195 # conflicts
196 196 pass
197 197 elif not procutil.gui() and _toolbool(ui, tool, b"gui"):
198 198 ui.warn(_(b"tool %s requires a GUI\n") % tmsg)
199 199 else:
200 200 return True
201 201 return False
202 202
203 203 # internal config: ui.forcemerge
204 204 # forcemerge comes from command line arguments, highest priority
205 205 force = ui.config(b'ui', b'forcemerge')
206 206 if force:
207 207 toolpath = _findtool(ui, force)
208 208 if changedelete and not supportscd(toolpath):
209 209 return b":prompt", None
210 210 else:
211 211 if toolpath:
212 212 return (force, _quotetoolpath(toolpath))
213 213 else:
214 214 # mimic HGMERGE if given tool not found
215 215 return (force, force)
216 216
217 217 # HGMERGE takes next precedence
218 218 hgmerge = encoding.environ.get(b"HGMERGE")
219 219 if hgmerge:
220 220 if changedelete and not supportscd(hgmerge):
221 221 return b":prompt", None
222 222 else:
223 223 return (hgmerge, hgmerge)
224 224
225 225 # then patterns
226 226
227 227 # whether binary capability should be checked strictly
228 228 binarycap = binary and strictcheck
229 229
230 230 for pat, tool in ui.configitems(b"merge-patterns"):
231 231 mf = match.match(repo.root, b'', [pat])
232 232 if mf(path) and check(tool, pat, symlink, binarycap, changedelete):
233 233 if binary and not hascapability(tool, b"binary", strict=True):
234 234 ui.warn(
235 235 _(
236 236 b"warning: check merge-patterns configurations,"
237 237 b" if %r for binary file %r is unintentional\n"
238 238 b"(see 'hg help merge-tools'"
239 239 b" for binary files capability)\n"
240 240 )
241 241 % (pycompat.bytestr(tool), pycompat.bytestr(path))
242 242 )
243 243 toolpath = _findtool(ui, tool)
244 244 return (tool, _quotetoolpath(toolpath))
245 245
246 246 # then merge tools
247 247 tools = {}
248 248 disabled = set()
249 249 for k, v in ui.configitems(b"merge-tools"):
250 250 t = k.split(b'.')[0]
251 251 if t not in tools:
252 252 tools[t] = int(_toolstr(ui, t, b"priority"))
253 253 if _toolbool(ui, t, b"disabled"):
254 254 disabled.add(t)
255 255 names = tools.keys()
256 256 tools = sorted(
257 257 [(-p, tool) for tool, p in tools.items() if tool not in disabled]
258 258 )
259 259 uimerge = ui.config(b"ui", b"merge")
260 260 if uimerge:
261 261 # external tools defined in uimerge won't be able to handle
262 262 # change/delete conflicts
263 263 if check(uimerge, path, symlink, binary, changedelete):
264 264 if uimerge not in names and not changedelete:
265 265 return (uimerge, uimerge)
266 266 tools.insert(0, (None, uimerge)) # highest priority
267 267 tools.append((None, b"hgmerge")) # the old default, if found
268 268 for p, t in tools:
269 269 if check(t, None, symlink, binary, changedelete):
270 270 toolpath = _findtool(ui, t)
271 271 return (t, _quotetoolpath(toolpath))
272 272
273 273 # internal merge or prompt as last resort
274 274 if symlink or binary or changedelete:
275 275 if not changedelete and len(tools):
276 276 # any tool is rejected by capability for symlink or binary
277 277 ui.warn(_(b"no tool found to merge %s\n") % path)
278 278 return b":prompt", None
279 279 return b":merge", None
280 280
281 281
282 282 def _eoltype(data):
283 283 """Guess the EOL type of a file"""
284 284 if b'\0' in data: # binary
285 285 return None
286 286 if b'\r\n' in data: # Windows
287 287 return b'\r\n'
288 288 if b'\r' in data: # Old Mac
289 289 return b'\r'
290 290 if b'\n' in data: # UNIX
291 291 return b'\n'
292 292 return None # unknown
293 293
294 294
295 295 def _matcheol(file, backup):
296 296 """Convert EOL markers in a file to match origfile"""
297 297 tostyle = _eoltype(backup.data()) # No repo.wread filters?
298 298 if tostyle:
299 299 data = util.readfile(file)
300 300 style = _eoltype(data)
301 301 if style:
302 302 newdata = data.replace(style, tostyle)
303 303 if newdata != data:
304 304 util.writefile(file, newdata)
305 305
306 306
307 307 @internaltool(b'prompt', nomerge)
308 308 def _iprompt(repo, mynode, local, other, base, toolconf):
309 309 """Asks the user which of the local `p1()` or the other `p2()` version to
310 310 keep as the merged version."""
311 311 ui = repo.ui
312 312 fd = local.fctx.path()
313 313 uipathfn = scmutil.getuipathfn(repo)
314 314
315 315 # Avoid prompting during an in-memory merge since it doesn't support merge
316 316 # conflicts.
317 317 if local.fctx.changectx().isinmemory():
318 318 raise error.InMemoryMergeConflictsError(
319 319 b'in-memory merge does not support file conflicts'
320 320 )
321 321
322 322 prompts = partextras([local.label, other.label])
323 323 prompts[b'fd'] = uipathfn(fd)
324 324 try:
325 325 if other.fctx.isabsent():
326 326 index = ui.promptchoice(_localchangedotherdeletedmsg % prompts, 2)
327 327 choice = [b'local', b'other', b'unresolved'][index]
328 328 elif local.fctx.isabsent():
329 329 index = ui.promptchoice(_otherchangedlocaldeletedmsg % prompts, 2)
330 330 choice = [b'other', b'local', b'unresolved'][index]
331 331 else:
332 332 # IMPORTANT: keep the last line of this prompt ("What do you want to
333 333 # do?") very short, see comment next to _localchangedotherdeletedmsg
334 334 # at the top of the file for details.
335 335 index = ui.promptchoice(
336 336 _(
337 337 b"file '%(fd)s' needs to be resolved.\n"
338 338 b"You can keep (l)ocal%(l)s, take (o)ther%(o)s, or leave "
339 339 b"(u)nresolved.\n"
340 340 b"What do you want to do?"
341 341 b"$$ &Local $$ &Other $$ &Unresolved"
342 342 )
343 343 % prompts,
344 344 2,
345 345 )
346 346 choice = [b'local', b'other', b'unresolved'][index]
347 347
348 348 if choice == b'other':
349 349 return _iother(repo, mynode, local, other, base, toolconf)
350 350 elif choice == b'local':
351 351 return _ilocal(repo, mynode, local, other, base, toolconf)
352 352 elif choice == b'unresolved':
353 353 return _ifail(repo, mynode, local, other, base, toolconf)
354 354 except error.ResponseExpected:
355 355 ui.write(b"\n")
356 356 return _ifail(repo, mynode, local, other, base, toolconf)
357 357
358 358
359 359 @internaltool(b'local', nomerge)
360 360 def _ilocal(repo, mynode, local, other, base, toolconf):
361 361 """Uses the local `p1()` version of files as the merged version."""
362 362 return 0, local.fctx.isabsent()
363 363
364 364
365 365 @internaltool(b'other', nomerge)
366 366 def _iother(repo, mynode, local, other, base, toolconf):
367 367 """Uses the other `p2()` version of files as the merged version."""
368 368 if other.fctx.isabsent():
369 369 # local changed, remote deleted -- 'deleted' picked
370 370 _underlyingfctxifabsent(local.fctx).remove()
371 371 deleted = True
372 372 else:
373 373 _underlyingfctxifabsent(local.fctx).write(
374 374 other.fctx.data(), other.fctx.flags()
375 375 )
376 376 deleted = False
377 377 return 0, deleted
378 378
379 379
380 380 @internaltool(b'fail', nomerge)
381 381 def _ifail(repo, mynode, local, other, base, toolconf):
382 382 """
383 383 Rather than attempting to merge files that were modified on both
384 384 branches, it marks them as unresolved. The resolve command must be
385 385 used to resolve these conflicts."""
386 386 # for change/delete conflicts write out the changed version, then fail
387 387 if local.fctx.isabsent():
388 388 _underlyingfctxifabsent(local.fctx).write(
389 389 other.fctx.data(), other.fctx.flags()
390 390 )
391 391 return 1, False
392 392
393 393
394 394 def _underlyingfctxifabsent(filectx):
395 395 """Sometimes when resolving, our fcd is actually an absentfilectx, but
396 396 we want to write to it (to do the resolve). This helper returns the
397 397 underyling workingfilectx in that case.
398 398 """
399 399 if filectx.isabsent():
400 400 return filectx.changectx()[filectx.path()]
401 401 else:
402 402 return filectx
403 403
404 404
405 405 def _verifytext(input, ui):
406 406 """verifies that text is non-binary"""
407 407 if stringutil.binary(input.text()):
408 408 msg = _(b"%s looks like a binary file.") % input.fctx.path()
409 409 ui.warn(_(b'warning: %s\n') % msg)
410 410 raise error.Abort(msg)
411 411
412 412
413 413 def _premerge(repo, local, other, base, toolconf):
414 414 tool, toolpath, binary, symlink, scriptfn = toolconf
415 415 if symlink or local.fctx.isabsent() or other.fctx.isabsent():
416 416 return 1
417 417
418 418 ui = repo.ui
419 419
420 420 validkeep = [b'keep', b'keep-merge3', b'keep-mergediff']
421 421
422 422 # do we attempt to simplemerge first?
423 423 try:
424 424 premerge = _toolbool(ui, tool, b"premerge", not binary)
425 425 except error.ConfigError:
426 426 premerge = _toolstr(ui, tool, b"premerge", b"").lower()
427 427 if premerge not in validkeep:
428 428 _valid = b', '.join([b"'" + v + b"'" for v in validkeep])
429 429 raise error.ConfigError(
430 430 _(b"%s.premerge not valid ('%s' is neither boolean nor %s)")
431 431 % (tool, premerge, _valid)
432 432 )
433 433
434 434 if premerge:
435 435 mode = b'merge'
436 436 if premerge == b'keep-mergediff':
437 437 mode = b'mergediff'
438 438 elif premerge == b'keep-merge3':
439 439 mode = b'merge3'
440 440 if any(
441 441 stringutil.binary(input.text()) for input in (local, base, other)
442 442 ):
443 443 return 1 # continue merging
444 444 merged_text, conflicts = simplemerge.simplemerge(
445 445 local, base, other, mode=mode
446 446 )
447 447 if not conflicts or premerge in validkeep:
448 448 # fcd.flags() already has the merged flags (done in
449 449 # mergestate.resolve())
450 450 local.fctx.write(merged_text, local.fctx.flags())
451 451 if not conflicts:
452 452 ui.debug(b" premerge successful\n")
453 453 return 0
454 454 return 1 # continue merging
455 455
456 456
457 457 def _mergecheck(repo, mynode, fcd, fco, fca, toolconf):
458 458 tool, toolpath, binary, symlink, scriptfn = toolconf
459 459 uipathfn = scmutil.getuipathfn(repo)
460 460 if symlink:
461 461 repo.ui.warn(
462 462 _(b'warning: internal %s cannot merge symlinks for %s\n')
463 463 % (tool, uipathfn(fcd.path()))
464 464 )
465 465 return False
466 466 if fcd.isabsent() or fco.isabsent():
467 467 repo.ui.warn(
468 468 _(
469 469 b'warning: internal %s cannot merge change/delete '
470 470 b'conflict for %s\n'
471 471 )
472 472 % (tool, uipathfn(fcd.path()))
473 473 )
474 474 return False
475 475 return True
476 476
477 477
478 478 def _merge(repo, local, other, base, mode):
479 479 """
480 480 Uses the internal non-interactive simple merge algorithm for merging
481 481 files. It will fail if there are any conflicts and leave markers in
482 482 the partially merged file. Markers will have two sections, one for each side
483 483 of merge, unless mode equals 'union' which suppresses the markers."""
484 484 ui = repo.ui
485 485
486 486 try:
487 487 _verifytext(local, ui)
488 488 _verifytext(base, ui)
489 489 _verifytext(other, ui)
490 490 except error.Abort:
491 491 return True, True, False
492 492 else:
493 493 merged_text, conflicts = simplemerge.simplemerge(
494 494 local, base, other, mode=mode
495 495 )
496 496 # fcd.flags() already has the merged flags (done in
497 497 # mergestate.resolve())
498 498 local.fctx.write(merged_text, local.fctx.flags())
499 499 return True, conflicts, False
500 500
501 501
502 502 @internaltool(
503 503 b'union',
504 504 fullmerge,
505 505 _(
506 506 b"warning: conflicts while merging %s! "
507 507 b"(edit, then use 'hg resolve --mark')\n"
508 508 ),
509 509 precheck=_mergecheck,
510 510 )
511 511 def _iunion(repo, mynode, local, other, base, toolconf, backup):
512 512 """
513 513 Uses the internal non-interactive simple merge algorithm for merging
514 514 files. It will use both left and right sides for conflict regions.
515 515 No markers are inserted."""
516 516 return _merge(repo, local, other, base, b'union')
517 517
518 518
519 519 @internaltool(
520 520 b'merge',
521 521 fullmerge,
522 522 _(
523 523 b"warning: conflicts while merging %s! "
524 524 b"(edit, then use 'hg resolve --mark')\n"
525 525 ),
526 526 precheck=_mergecheck,
527 527 )
528 528 def _imerge(repo, mynode, local, other, base, toolconf, backup):
529 529 """
530 530 Uses the internal non-interactive simple merge algorithm for merging
531 531 files. It will fail if there are any conflicts and leave markers in
532 532 the partially merged file. Markers will have two sections, one for each side
533 533 of merge."""
534 534 return _merge(repo, local, other, base, b'merge')
535 535
536 536
537 537 @internaltool(
538 538 b'merge3',
539 539 fullmerge,
540 540 _(
541 541 b"warning: conflicts while merging %s! "
542 542 b"(edit, then use 'hg resolve --mark')\n"
543 543 ),
544 544 precheck=_mergecheck,
545 545 )
546 546 def _imerge3(repo, mynode, local, other, base, toolconf, backup):
547 547 """
548 548 Uses the internal non-interactive simple merge algorithm for merging
549 549 files. It will fail if there are any conflicts and leave markers in
550 550 the partially merged file. Marker will have three sections, one from each
551 551 side of the merge and one for the base content."""
552 552 return _merge(repo, local, other, base, b'merge3')
553 553
554 554
555 555 @internaltool(
556 556 b'merge3-lie-about-conflicts',
557 557 fullmerge,
558 558 b'',
559 559 precheck=_mergecheck,
560 560 )
561 561 def _imerge3alwaysgood(*args, **kwargs):
562 562 # Like merge3, but record conflicts as resolved with markers in place.
563 563 #
564 564 # This is used for `diff.merge` to show the differences between
565 565 # the auto-merge state and the committed merge state. It may be
566 566 # useful for other things.
567 567 b1, junk, b2 = _imerge3(*args, **kwargs)
568 568 # TODO is this right? I'm not sure what these return values mean,
569 569 # but as far as I can tell this will indicate to callers tha the
570 570 # merge succeeded.
571 571 return b1, False, b2
572 572
573 573
574 574 @internaltool(
575 575 b'mergediff',
576 576 fullmerge,
577 577 _(
578 578 b"warning: conflicts while merging %s! "
579 579 b"(edit, then use 'hg resolve --mark')\n"
580 580 ),
581 581 precheck=_mergecheck,
582 582 )
583 583 def _imerge_diff(repo, mynode, local, other, base, toolconf, backup):
584 584 """
585 585 Uses the internal non-interactive simple merge algorithm for merging
586 586 files. It will fail if there are any conflicts and leave markers in
587 587 the partially merged file. The marker will have two sections, one with the
588 588 content from one side of the merge, and one with a diff from the base
589 589 content to the content on the other side. (experimental)"""
590 590 return _merge(repo, local, other, base, b'mergediff')
591 591
592 592
593 593 @internaltool(b'merge-local', mergeonly, precheck=_mergecheck)
594 594 def _imergelocal(repo, mynode, local, other, base, toolconf, backup):
595 595 """
596 596 Like :merge, but resolve all conflicts non-interactively in favor
597 597 of the local `p1()` changes."""
598 598 return _merge(repo, local, other, base, b'local')
599 599
600 600
601 601 @internaltool(b'merge-other', mergeonly, precheck=_mergecheck)
602 602 def _imergeother(repo, mynode, local, other, base, toolconf, backup):
603 603 """
604 604 Like :merge, but resolve all conflicts non-interactively in favor
605 605 of the other `p2()` changes."""
606 606 return _merge(repo, local, other, base, b'other')
607 607
608 608
609 609 @internaltool(
610 610 b'tagmerge',
611 611 mergeonly,
612 612 _(
613 613 b"automatic tag merging of %s failed! "
614 614 b"(use 'hg resolve --tool :merge' or another merge "
615 615 b"tool of your choice)\n"
616 616 ),
617 617 )
618 618 def _itagmerge(repo, mynode, local, other, base, toolconf, backup):
619 619 """
620 620 Uses the internal tag merge algorithm (experimental).
621 621 """
622 622 success, status = tagmerge.merge(repo, local.fctx, other.fctx, base.fctx)
623 623 return success, status, False
624 624
625 625
626 626 @internaltool(b'dump', fullmerge, binary=True, symlink=True)
627 627 def _idump(repo, mynode, local, other, base, toolconf, backup):
628 628 """
629 629 Creates three versions of the files to merge, containing the
630 630 contents of local, other and base. These files can then be used to
631 631 perform a merge manually. If the file to be merged is named
632 632 ``a.txt``, these files will accordingly be named ``a.txt.local``,
633 633 ``a.txt.other`` and ``a.txt.base`` and they will be placed in the
634 634 same directory as ``a.txt``.
635 635
636 636 This implies premerge. Therefore, files aren't dumped, if premerge
637 637 runs successfully. Use :forcedump to forcibly write files out.
638 638 """
639 639 a = _workingpath(repo, local.fctx)
640 640 fd = local.fctx.path()
641 641
642 642 from . import context
643 643
644 644 if isinstance(local.fctx, context.overlayworkingfilectx):
645 645 raise error.InMemoryMergeConflictsError(
646 646 b'in-memory merge does not support the :dump tool.'
647 647 )
648 648
649 649 util.writefile(a + b".local", local.fctx.decodeddata())
650 650 repo.wwrite(fd + b".other", other.fctx.data(), other.fctx.flags())
651 651 repo.wwrite(fd + b".base", base.fctx.data(), base.fctx.flags())
652 652 return False, 1, False
653 653
654 654
655 655 @internaltool(b'forcedump', mergeonly, binary=True, symlink=True)
656 656 def _forcedump(repo, mynode, local, other, base, toolconf, backup):
657 657 """
658 658 Creates three versions of the files as same as :dump, but omits premerge.
659 659 """
660 660 return _idump(repo, mynode, local, other, base, toolconf, backup)
661 661
662 662
663 663 def _xmergeimm(repo, mynode, local, other, base, toolconf, backup):
664 664 # In-memory merge simply raises an exception on all external merge tools,
665 665 # for now.
666 666 #
667 667 # It would be possible to run most tools with temporary files, but this
668 668 # raises the question of what to do if the user only partially resolves the
669 669 # file -- we can't leave a merge state. (Copy to somewhere in the .hg/
670 670 # directory and tell the user how to get it is my best idea, but it's
671 671 # clunky.)
672 672 raise error.InMemoryMergeConflictsError(
673 673 b'in-memory merge does not support external merge tools'
674 674 )
675 675
676 676
677 677 def _describemerge(ui, repo, mynode, fcl, fcb, fco, env, toolpath, args):
678 678 tmpl = ui.config(b'command-templates', b'pre-merge-tool-output')
679 679 if not tmpl:
680 680 return
681 681
682 682 mappingdict = templateutil.mappingdict
683 683 props = {
684 684 b'ctx': fcl.changectx(),
685 685 b'node': hex(mynode),
686 686 b'path': fcl.path(),
687 687 b'local': mappingdict(
688 688 {
689 689 b'ctx': fcl.changectx(),
690 690 b'fctx': fcl,
691 691 b'node': hex(mynode),
692 692 b'name': _(b'local'),
693 693 b'islink': b'l' in fcl.flags(),
694 694 b'label': env[b'HG_MY_LABEL'],
695 695 }
696 696 ),
697 697 b'base': mappingdict(
698 698 {
699 699 b'ctx': fcb.changectx(),
700 700 b'fctx': fcb,
701 701 b'name': _(b'base'),
702 702 b'islink': b'l' in fcb.flags(),
703 703 b'label': env[b'HG_BASE_LABEL'],
704 704 }
705 705 ),
706 706 b'other': mappingdict(
707 707 {
708 708 b'ctx': fco.changectx(),
709 709 b'fctx': fco,
710 710 b'name': _(b'other'),
711 711 b'islink': b'l' in fco.flags(),
712 712 b'label': env[b'HG_OTHER_LABEL'],
713 713 }
714 714 ),
715 715 b'toolpath': toolpath,
716 716 b'toolargs': args,
717 717 }
718 718
719 719 # TODO: make all of this something that can be specified on a per-tool basis
720 720 tmpl = templater.unquotestring(tmpl)
721 721
722 722 # Not using cmdutil.rendertemplate here since it causes errors importing
723 723 # things for us to import cmdutil.
724 724 tres = formatter.templateresources(ui, repo)
725 725 t = formatter.maketemplater(
726 726 ui, tmpl, defaults=templatekw.keywords, resources=tres
727 727 )
728 728 ui.status(t.renderdefault(props))
729 729
730 730
731 731 def _xmerge(repo, mynode, local, other, base, toolconf, backup):
732 732 fcd = local.fctx
733 733 fco = other.fctx
734 734 fca = base.fctx
735 735 tool, toolpath, binary, symlink, scriptfn = toolconf
736 736 uipathfn = scmutil.getuipathfn(repo)
737 737 if fcd.isabsent() or fco.isabsent():
738 738 repo.ui.warn(
739 739 _(b'warning: %s cannot merge change/delete conflict for %s\n')
740 740 % (tool, uipathfn(fcd.path()))
741 741 )
742 742 return False, 1, None
743 743 localpath = _workingpath(repo, fcd)
744 744 args = _toolstr(repo.ui, tool, b"args")
745 localoutputpath = None
745
746 files = [
747 (b"base", fca.path(), fca.decodeddata()),
748 (b"other", fco.path(), fco.decodeddata()),
749 ]
750 outpath = b""
746 751 if b"$output" in args:
752 # read input from backup, write to original
753 outpath = localpath
747 754 localoutputpath = backup.path()
748 755 # Remove the .orig to make syntax-highlighting more likely.
749 756 if localoutputpath.endswith(b'.orig'):
750 757 localoutputpath, ext = os.path.splitext(localoutputpath)
758 localdata = util.readfile(localpath)
759 files.append((b"local", localoutputpath, localdata))
751 760
752 with _maketempfiles(
753 fco,
754 fca,
755 localoutputpath,
756 ) as temppaths:
757 basepath, otherpath, localoutputpath = temppaths
758 outpath = b""
761 with _maketempfiles(files) as temppaths:
762 basepath, otherpath = temppaths[:2]
763 if len(temppaths) == 3:
764 localpath = temppaths[2]
759 765
760 766 def format_label(input):
761 767 if input.label_detail:
762 768 return b'%s: %s' % (input.label, input.label_detail)
763 769 else:
764 770 return input.label
765 771
766 772 env = {
767 773 b'HG_FILE': fcd.path(),
768 774 b'HG_MY_NODE': short(mynode),
769 775 b'HG_OTHER_NODE': short(fco.changectx().node()),
770 776 b'HG_BASE_NODE': short(fca.changectx().node()),
771 777 b'HG_MY_ISLINK': b'l' in fcd.flags(),
772 778 b'HG_OTHER_ISLINK': b'l' in fco.flags(),
773 779 b'HG_BASE_ISLINK': b'l' in fca.flags(),
774 780 b'HG_MY_LABEL': format_label(local),
775 781 b'HG_OTHER_LABEL': format_label(other),
776 782 b'HG_BASE_LABEL': format_label(base),
777 783 }
778 784 ui = repo.ui
779 785
780 if b"$output" in args:
781 # read input from backup, write to original
782 outpath = localpath
783 localpath = localoutputpath
784 786 replace = {
785 787 b'local': localpath,
786 788 b'base': basepath,
787 789 b'other': otherpath,
788 790 b'output': outpath,
789 791 b'labellocal': format_label(local),
790 792 b'labelother': format_label(other),
791 793 b'labelbase': format_label(base),
792 794 }
793 795 args = util.interpolate(
794 796 br'\$',
795 797 replace,
796 798 args,
797 799 lambda s: procutil.shellquote(util.localpath(s)),
798 800 )
799 801 if _toolbool(ui, tool, b"gui"):
800 802 repo.ui.status(
801 803 _(b'running merge tool %s for file %s\n')
802 804 % (tool, uipathfn(fcd.path()))
803 805 )
804 806 if scriptfn is None:
805 807 cmd = toolpath + b' ' + args
806 808 repo.ui.debug(b'launching merge tool: %s\n' % cmd)
807 809 _describemerge(ui, repo, mynode, fcd, fca, fco, env, toolpath, args)
808 810 r = ui.system(
809 811 cmd, cwd=repo.root, environ=env, blockedtag=b'mergetool'
810 812 )
811 813 else:
812 814 repo.ui.debug(
813 815 b'launching python merge script: %s:%s\n' % (toolpath, scriptfn)
814 816 )
815 817 r = 0
816 818 try:
817 819 # avoid cycle cmdutil->merge->filemerge->extensions->cmdutil
818 820 from . import extensions
819 821
820 822 mod = extensions.loadpath(toolpath, b'hgmerge.%s' % tool)
821 823 except Exception:
822 824 raise error.Abort(
823 825 _(b"loading python merge script failed: %s") % toolpath
824 826 )
825 827 mergefn = getattr(mod, scriptfn, None)
826 828 if mergefn is None:
827 829 raise error.Abort(
828 830 _(b"%s does not have function: %s") % (toolpath, scriptfn)
829 831 )
830 832 argslist = procutil.shellsplit(args)
831 833 # avoid cycle cmdutil->merge->filemerge->hook->extensions->cmdutil
832 834 from . import hook
833 835
834 836 ret, raised = hook.pythonhook(
835 837 ui, repo, b"merge", toolpath, mergefn, {b'args': argslist}, True
836 838 )
837 839 if raised:
838 840 r = 1
839 841 repo.ui.debug(b'merge tool returned: %d\n' % r)
840 842 return True, r, False
841 843
842 844
843 845 def _populate_label_detail(input, template):
844 846 """Applies the given template to the ctx and stores it in the input."""
845 847 ctx = input.fctx.changectx()
846 848 if ctx.node() is None:
847 849 ctx = ctx.p1()
848 850
849 851 props = {b'ctx': ctx}
850 852 templateresult = template.renderdefault(props)
851 853 input.label_detail = templateresult.splitlines()[0] # split for safety
852 854
853 855
854 856 def _populate_label_details(repo, inputs, tool=None):
855 857 """Populates the label details using the conflict marker template."""
856 858 ui = repo.ui
857 859 template = ui.config(b'command-templates', b'mergemarker')
858 860 if tool is not None:
859 861 template = _toolstr(ui, tool, b'mergemarkertemplate', template)
860 862 template = templater.unquotestring(template)
861 863 tres = formatter.templateresources(ui, repo)
862 864 tmpl = formatter.maketemplater(
863 865 ui, template, defaults=templatekw.keywords, resources=tres
864 866 )
865 867
866 868 for input in inputs:
867 869 _populate_label_detail(input, tmpl)
868 870
869 871
870 872 def partextras(labels):
871 873 """Return a dictionary of extra labels for use in prompts to the user
872 874
873 875 Intended use is in strings of the form "(l)ocal%(l)s".
874 876 """
875 877 if labels is None:
876 878 return {
877 879 b"l": b"",
878 880 b"o": b"",
879 881 }
880 882
881 883 return {
882 884 b"l": b" [%s]" % labels[0],
883 885 b"o": b" [%s]" % labels[1],
884 886 }
885 887
886 888
887 889 def _makebackup(repo, ui, fcd):
888 890 """Makes and returns a filectx-like object for ``fcd``'s backup file.
889 891
890 892 In addition to preserving the user's pre-existing modifications to `fcd`
891 893 (if any), the backup is used to undo certain premerges, confirm whether a
892 894 merge changed anything, and determine what line endings the new file should
893 895 have.
894 896
895 897 Backups only need to be written once since their content doesn't change
896 898 afterwards.
897 899 """
898 900 if fcd.isabsent():
899 901 return None
900 902 # TODO: Break this import cycle somehow. (filectx -> ctx -> fileset ->
901 903 # merge -> filemerge). (I suspect the fileset import is the weakest link)
902 904 from . import context
903 905
904 906 if isinstance(fcd, context.overlayworkingfilectx):
905 907 # If we're merging in-memory, we're free to put the backup anywhere.
906 908 fd, backup = pycompat.mkstemp(b'hg-merge-backup')
907 909 with os.fdopen(fd, 'wb') as f:
908 910 f.write(fcd.data())
909 911 else:
910 912 backup = scmutil.backuppath(ui, repo, fcd.path())
911 913 a = _workingpath(repo, fcd)
912 914 util.copyfile(a, backup)
913 915
914 916 return context.arbitraryfilectx(backup, repo=repo)
915 917
916 918
917 919 @contextlib.contextmanager
918 def _maketempfiles(fco, fca, localpath):
919 """Writes out `fco` and `fca` as temporary files, and (if localpath is not
920 None) copies `localpath` to another temporary file, so an external merge
921 tool may use them.
920 def _maketempfiles(files):
921 """Creates a temporary file for each (prefix, path, data) tuple in `files`,
922 so an external merge tool may use them.
922 923 """
923 924 tmproot = pycompat.mkdtemp(prefix=b'hgmerge-')
924 925
925 926 def maketempfrompath(prefix, path, data):
926 927 fullbase, ext = os.path.splitext(path)
927 928 pre = b"%s~%s" % (os.path.basename(fullbase), prefix)
928 929 name = os.path.join(tmproot, pre)
929 930 if ext:
930 931 name += ext
931 932 util.writefile(name, data)
932 933 return name
933 934
934 def tempfromcontext(prefix, ctx):
935 return maketempfrompath(prefix, ctx.path(), ctx.decodeddata())
936
937 b = tempfromcontext(b"base", fca)
938 c = tempfromcontext(b"other", fco)
939 d = localpath
940 if localpath is not None:
941 data = util.readfile(localpath)
942 d = maketempfrompath(b"local", localpath, data)
943
935 temp_files = []
936 for prefix, path, data in files:
937 temp_files.append(maketempfrompath(prefix, path, data))
944 938 try:
945 yield b, c, d
939 yield temp_files
946 940 finally:
947 941 shutil.rmtree(tmproot)
948 942
949 943
950 944 def filemerge(repo, wctx, mynode, orig, fcd, fco, fca, labels=None):
951 945 """perform a 3-way merge in the working directory
952 946
953 947 mynode = parent node before merge
954 948 orig = original local filename before merge
955 949 fco = other file context
956 950 fca = ancestor file context
957 951 fcd = local file context for current/destination file
958 952
959 953 Returns whether the merge is complete, the return value of the merge, and
960 954 a boolean indicating whether the file was deleted from disk."""
961 955 ui = repo.ui
962 956 fd = fcd.path()
963 957 uipathfn = scmutil.getuipathfn(repo)
964 958 fduipath = uipathfn(fd)
965 959 binary = fcd.isbinary() or fco.isbinary() or fca.isbinary()
966 960 symlink = b'l' in fcd.flags() + fco.flags()
967 961 changedelete = fcd.isabsent() or fco.isabsent()
968 962 tool, toolpath = _picktool(repo, ui, fd, binary, symlink, changedelete)
969 963 scriptfn = None
970 964 if tool in internals and tool.startswith(b'internal:'):
971 965 # normalize to new-style names (':merge' etc)
972 966 tool = tool[len(b'internal') :]
973 967 if toolpath and toolpath.startswith(b'python:'):
974 968 invalidsyntax = False
975 969 if toolpath.count(b':') >= 2:
976 970 script, scriptfn = toolpath[7:].rsplit(b':', 1)
977 971 if not scriptfn:
978 972 invalidsyntax = True
979 973 # missing :callable can lead to spliting on windows drive letter
980 974 if b'\\' in scriptfn or b'/' in scriptfn:
981 975 invalidsyntax = True
982 976 else:
983 977 invalidsyntax = True
984 978 if invalidsyntax:
985 979 raise error.Abort(_(b"invalid 'python:' syntax: %s") % toolpath)
986 980 toolpath = script
987 981 ui.debug(
988 982 b"picked tool '%s' for %s (binary %s symlink %s changedelete %s)\n"
989 983 % (
990 984 tool,
991 985 fduipath,
992 986 pycompat.bytestr(binary),
993 987 pycompat.bytestr(symlink),
994 988 pycompat.bytestr(changedelete),
995 989 )
996 990 )
997 991
998 992 if tool in internals:
999 993 func = internals[tool]
1000 994 mergetype = func.mergetype
1001 995 onfailure = func.onfailure
1002 996 precheck = func.precheck
1003 997 isexternal = False
1004 998 else:
1005 999 if wctx.isinmemory():
1006 1000 func = _xmergeimm
1007 1001 else:
1008 1002 func = _xmerge
1009 1003 mergetype = fullmerge
1010 1004 onfailure = _(b"merging %s failed!\n")
1011 1005 precheck = None
1012 1006 isexternal = True
1013 1007
1014 1008 toolconf = tool, toolpath, binary, symlink, scriptfn
1015 1009
1016 1010 if not labels:
1017 1011 labels = [b'local', b'other']
1018 1012 if len(labels) < 3:
1019 1013 labels.append(b'base')
1020 1014 local = simplemerge.MergeInput(fcd, labels[0])
1021 1015 other = simplemerge.MergeInput(fco, labels[1])
1022 1016 base = simplemerge.MergeInput(fca, labels[2])
1023 1017 if mergetype == nomerge:
1024 1018 return func(
1025 1019 repo,
1026 1020 mynode,
1027 1021 local,
1028 1022 other,
1029 1023 base,
1030 1024 toolconf,
1031 1025 )
1032 1026
1033 1027 if orig != fco.path():
1034 1028 ui.status(
1035 1029 _(b"merging %s and %s to %s\n")
1036 1030 % (uipathfn(orig), uipathfn(fco.path()), fduipath)
1037 1031 )
1038 1032 else:
1039 1033 ui.status(_(b"merging %s\n") % fduipath)
1040 1034
1041 1035 ui.debug(b"my %s other %s ancestor %s\n" % (fcd, fco, fca))
1042 1036
1043 1037 if precheck and not precheck(repo, mynode, fcd, fco, fca, toolconf):
1044 1038 if onfailure:
1045 1039 if wctx.isinmemory():
1046 1040 raise error.InMemoryMergeConflictsError(
1047 1041 b'in-memory merge does not support merge conflicts'
1048 1042 )
1049 1043 ui.warn(onfailure % fduipath)
1050 1044 return 1, False
1051 1045
1052 1046 backup = _makebackup(repo, ui, fcd)
1053 1047 r = 1
1054 1048 try:
1055 1049 internalmarkerstyle = ui.config(b'ui', b'mergemarkers')
1056 1050 if isexternal:
1057 1051 markerstyle = _toolstr(ui, tool, b'mergemarkers')
1058 1052 else:
1059 1053 markerstyle = internalmarkerstyle
1060 1054
1061 1055 if mergetype == fullmerge:
1062 1056 # conflict markers generated by premerge will use 'detailed'
1063 1057 # settings if either ui.mergemarkers or the tool's mergemarkers
1064 1058 # setting is 'detailed'. This way tools can have basic labels in
1065 1059 # space-constrained areas of the UI, but still get full information
1066 1060 # in conflict markers if premerge is 'keep' or 'keep-merge3'.
1067 1061 labeltool = None
1068 1062 if markerstyle != b'basic':
1069 1063 # respect 'tool's mergemarkertemplate (which defaults to
1070 1064 # command-templates.mergemarker)
1071 1065 labeltool = tool
1072 1066 if internalmarkerstyle != b'basic' or markerstyle != b'basic':
1073 1067 _populate_label_details(
1074 1068 repo, [local, other, base], tool=labeltool
1075 1069 )
1076 1070
1077 1071 r = _premerge(
1078 1072 repo,
1079 1073 local,
1080 1074 other,
1081 1075 base,
1082 1076 toolconf,
1083 1077 )
1084 1078 # we're done if premerge was successful (r is 0)
1085 1079 if not r:
1086 1080 return r, False
1087 1081
1088 1082 # Reset to basic labels
1089 1083 local.label_detail = None
1090 1084 other.label_detail = None
1091 1085 base.label_detail = None
1092 1086
1093 1087 if markerstyle != b'basic':
1094 1088 _populate_label_details(repo, [local, other, base], tool=tool)
1095 1089
1096 1090 needcheck, r, deleted = func(
1097 1091 repo,
1098 1092 mynode,
1099 1093 local,
1100 1094 other,
1101 1095 base,
1102 1096 toolconf,
1103 1097 backup,
1104 1098 )
1105 1099
1106 1100 if needcheck:
1107 1101 r = _check(repo, r, ui, tool, fcd, backup)
1108 1102
1109 1103 if r:
1110 1104 if onfailure:
1111 1105 if wctx.isinmemory():
1112 1106 raise error.InMemoryMergeConflictsError(
1113 1107 b'in-memory merge '
1114 1108 b'does not support '
1115 1109 b'merge conflicts'
1116 1110 )
1117 1111 ui.warn(onfailure % fduipath)
1118 1112 _onfilemergefailure(ui)
1119 1113
1120 1114 return r, deleted
1121 1115 finally:
1122 1116 if not r and backup is not None:
1123 1117 backup.remove()
1124 1118
1125 1119
1126 1120 def _haltmerge():
1127 1121 msg = _(b'merge halted after failed merge (see hg resolve)')
1128 1122 raise error.InterventionRequired(msg)
1129 1123
1130 1124
1131 1125 def _onfilemergefailure(ui):
1132 1126 action = ui.config(b'merge', b'on-failure')
1133 1127 if action == b'prompt':
1134 1128 msg = _(b'continue merge operation (yn)?$$ &Yes $$ &No')
1135 1129 if ui.promptchoice(msg, 0) == 1:
1136 1130 _haltmerge()
1137 1131 if action == b'halt':
1138 1132 _haltmerge()
1139 1133 # default action is 'continue', in which case we neither prompt nor halt
1140 1134
1141 1135
1142 1136 def hasconflictmarkers(data):
1143 1137 # Detect lines starting with a string of 7 identical characters from the
1144 1138 # subset Mercurial uses for conflict markers, followed by either the end of
1145 1139 # line or a space and some text. Note that using [<>=+|-]{7} would detect
1146 1140 # `<><><><><` as a conflict marker, which we don't want.
1147 1141 return bool(
1148 1142 re.search(
1149 1143 br"^([<>=+|-])\1{6}( .*)$",
1150 1144 data,
1151 1145 re.MULTILINE,
1152 1146 )
1153 1147 )
1154 1148
1155 1149
1156 1150 def _check(repo, r, ui, tool, fcd, backup):
1157 1151 fd = fcd.path()
1158 1152 uipathfn = scmutil.getuipathfn(repo)
1159 1153
1160 1154 if not r and (
1161 1155 _toolbool(ui, tool, b"checkconflicts")
1162 1156 or b'conflicts' in _toollist(ui, tool, b"check")
1163 1157 ):
1164 1158 if hasconflictmarkers(fcd.data()):
1165 1159 r = 1
1166 1160
1167 1161 checked = False
1168 1162 if b'prompt' in _toollist(ui, tool, b"check"):
1169 1163 checked = True
1170 1164 if ui.promptchoice(
1171 1165 _(b"was merge of '%s' successful (yn)?$$ &Yes $$ &No")
1172 1166 % uipathfn(fd),
1173 1167 1,
1174 1168 ):
1175 1169 r = 1
1176 1170
1177 1171 if (
1178 1172 not r
1179 1173 and not checked
1180 1174 and (
1181 1175 _toolbool(ui, tool, b"checkchanged")
1182 1176 or b'changed' in _toollist(ui, tool, b"check")
1183 1177 )
1184 1178 ):
1185 1179 if backup is not None and not fcd.cmp(backup):
1186 1180 if ui.promptchoice(
1187 1181 _(
1188 1182 b" output file %s appears unchanged\n"
1189 1183 b"was merge successful (yn)?"
1190 1184 b"$$ &Yes $$ &No"
1191 1185 )
1192 1186 % uipathfn(fd),
1193 1187 1,
1194 1188 ):
1195 1189 r = 1
1196 1190
1197 1191 if backup is not None and _toolbool(ui, tool, b"fixeol"):
1198 1192 _matcheol(_workingpath(repo, fcd), backup)
1199 1193
1200 1194 return r
1201 1195
1202 1196
1203 1197 def _workingpath(repo, ctx):
1204 1198 return repo.wjoin(ctx.path())
1205 1199
1206 1200
1207 1201 def loadinternalmerge(ui, extname, registrarobj):
1208 1202 """Load internal merge tool from specified registrarobj"""
1209 1203 for name, func in pycompat.iteritems(registrarobj._table):
1210 1204 fullname = b':' + name
1211 1205 internals[fullname] = func
1212 1206 internals[b'internal:' + name] = func
1213 1207 internalsdoc[fullname] = func
1214 1208
1215 1209 capabilities = sorted([k for k, v in func.capabilities.items() if v])
1216 1210 if capabilities:
1217 1211 capdesc = b" (actual capabilities: %s)" % b', '.join(
1218 1212 capabilities
1219 1213 )
1220 1214 func.__doc__ = func.__doc__ + pycompat.sysstr(b"\n\n%s" % capdesc)
1221 1215
1222 1216 # to put i18n comments into hg.pot for automatically generated texts
1223 1217
1224 1218 # i18n: "binary" and "symlink" are keywords
1225 1219 # i18n: this text is added automatically
1226 1220 _(b" (actual capabilities: binary, symlink)")
1227 1221 # i18n: "binary" is keyword
1228 1222 # i18n: this text is added automatically
1229 1223 _(b" (actual capabilities: binary)")
1230 1224 # i18n: "symlink" is keyword
1231 1225 # i18n: this text is added automatically
1232 1226 _(b" (actual capabilities: symlink)")
1233 1227
1234 1228
1235 1229 # load built-in merge tools explicitly to setup internalsdoc
1236 1230 loadinternalmerge(None, None, internaltool)
1237 1231
1238 1232 # tell hggettext to extract docstrings from these functions:
1239 1233 i18nfunctions = internals.values()
General Comments 0
You need to be logged in to leave comments. Login now