##// END OF EJS Templates
record: use command wrapper properly for qnew/qrefresh (issue3001)
Matt Mackall -
r15184:351a9292 1.9.3 stable
parent child Browse files
Show More
@@ -1,598 +1,601
1 1 # record.py
2 2 #
3 3 # Copyright 2007 Bryan O'Sullivan <bos@serpentine.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 '''commands to interactively select changes for commit/qrefresh'''
9 9
10 10 from mercurial.i18n import gettext, _
11 11 from mercurial import cmdutil, commands, extensions, hg, mdiff, patch
12 12 from mercurial import util
13 13 import copy, cStringIO, errno, os, re, shutil, tempfile
14 14
15 15 cmdtable = {}
16 16 command = cmdutil.command(cmdtable)
17 17
18 18 lines_re = re.compile(r'@@ -(\d+),(\d+) \+(\d+),(\d+) @@\s*(.*)')
19 19
20 20 diffopts = [
21 21 ('w', 'ignore-all-space', False,
22 22 _('ignore white space when comparing lines')),
23 23 ('b', 'ignore-space-change', None,
24 24 _('ignore changes in the amount of white space')),
25 25 ('B', 'ignore-blank-lines', None,
26 26 _('ignore changes whose lines are all blank')),
27 27 ]
28 28
29 29 def scanpatch(fp):
30 30 """like patch.iterhunks, but yield different events
31 31
32 32 - ('file', [header_lines + fromfile + tofile])
33 33 - ('context', [context_lines])
34 34 - ('hunk', [hunk_lines])
35 35 - ('range', (-start,len, +start,len, diffp))
36 36 """
37 37 lr = patch.linereader(fp)
38 38
39 39 def scanwhile(first, p):
40 40 """scan lr while predicate holds"""
41 41 lines = [first]
42 42 while True:
43 43 line = lr.readline()
44 44 if not line:
45 45 break
46 46 if p(line):
47 47 lines.append(line)
48 48 else:
49 49 lr.push(line)
50 50 break
51 51 return lines
52 52
53 53 while True:
54 54 line = lr.readline()
55 55 if not line:
56 56 break
57 57 if line.startswith('diff --git a/') or line.startswith('diff -r '):
58 58 def notheader(line):
59 59 s = line.split(None, 1)
60 60 return not s or s[0] not in ('---', 'diff')
61 61 header = scanwhile(line, notheader)
62 62 fromfile = lr.readline()
63 63 if fromfile.startswith('---'):
64 64 tofile = lr.readline()
65 65 header += [fromfile, tofile]
66 66 else:
67 67 lr.push(fromfile)
68 68 yield 'file', header
69 69 elif line[0] == ' ':
70 70 yield 'context', scanwhile(line, lambda l: l[0] in ' \\')
71 71 elif line[0] in '-+':
72 72 yield 'hunk', scanwhile(line, lambda l: l[0] in '-+\\')
73 73 else:
74 74 m = lines_re.match(line)
75 75 if m:
76 76 yield 'range', m.groups()
77 77 else:
78 78 raise patch.PatchError('unknown patch content: %r' % line)
79 79
80 80 class header(object):
81 81 """patch header
82 82
83 83 XXX shoudn't we move this to mercurial/patch.py ?
84 84 """
85 85 diffgit_re = re.compile('diff --git a/(.*) b/(.*)$')
86 86 diff_re = re.compile('diff -r .* (.*)$')
87 87 allhunks_re = re.compile('(?:index|new file|deleted file) ')
88 88 pretty_re = re.compile('(?:new file|deleted file) ')
89 89 special_re = re.compile('(?:index|new|deleted|copy|rename) ')
90 90
91 91 def __init__(self, header):
92 92 self.header = header
93 93 self.hunks = []
94 94
95 95 def binary(self):
96 96 return util.any(h.startswith('index ') for h in self.header)
97 97
98 98 def pretty(self, fp):
99 99 for h in self.header:
100 100 if h.startswith('index '):
101 101 fp.write(_('this modifies a binary file (all or nothing)\n'))
102 102 break
103 103 if self.pretty_re.match(h):
104 104 fp.write(h)
105 105 if self.binary():
106 106 fp.write(_('this is a binary file\n'))
107 107 break
108 108 if h.startswith('---'):
109 109 fp.write(_('%d hunks, %d lines changed\n') %
110 110 (len(self.hunks),
111 111 sum([max(h.added, h.removed) for h in self.hunks])))
112 112 break
113 113 fp.write(h)
114 114
115 115 def write(self, fp):
116 116 fp.write(''.join(self.header))
117 117
118 118 def allhunks(self):
119 119 return util.any(self.allhunks_re.match(h) for h in self.header)
120 120
121 121 def files(self):
122 122 match = self.diffgit_re.match(self.header[0])
123 123 if match:
124 124 fromfile, tofile = match.groups()
125 125 if fromfile == tofile:
126 126 return [fromfile]
127 127 return [fromfile, tofile]
128 128 else:
129 129 return self.diff_re.match(self.header[0]).groups()
130 130
131 131 def filename(self):
132 132 return self.files()[-1]
133 133
134 134 def __repr__(self):
135 135 return '<header %s>' % (' '.join(map(repr, self.files())))
136 136
137 137 def special(self):
138 138 return util.any(self.special_re.match(h) for h in self.header)
139 139
140 140 def countchanges(hunk):
141 141 """hunk -> (n+,n-)"""
142 142 add = len([h for h in hunk if h[0] == '+'])
143 143 rem = len([h for h in hunk if h[0] == '-'])
144 144 return add, rem
145 145
146 146 class hunk(object):
147 147 """patch hunk
148 148
149 149 XXX shouldn't we merge this with patch.hunk ?
150 150 """
151 151 maxcontext = 3
152 152
153 153 def __init__(self, header, fromline, toline, proc, before, hunk, after):
154 154 def trimcontext(number, lines):
155 155 delta = len(lines) - self.maxcontext
156 156 if False and delta > 0:
157 157 return number + delta, lines[:self.maxcontext]
158 158 return number, lines
159 159
160 160 self.header = header
161 161 self.fromline, self.before = trimcontext(fromline, before)
162 162 self.toline, self.after = trimcontext(toline, after)
163 163 self.proc = proc
164 164 self.hunk = hunk
165 165 self.added, self.removed = countchanges(self.hunk)
166 166
167 167 def write(self, fp):
168 168 delta = len(self.before) + len(self.after)
169 169 if self.after and self.after[-1] == '\\ No newline at end of file\n':
170 170 delta -= 1
171 171 fromlen = delta + self.removed
172 172 tolen = delta + self.added
173 173 fp.write('@@ -%d,%d +%d,%d @@%s\n' %
174 174 (self.fromline, fromlen, self.toline, tolen,
175 175 self.proc and (' ' + self.proc)))
176 176 fp.write(''.join(self.before + self.hunk + self.after))
177 177
178 178 pretty = write
179 179
180 180 def filename(self):
181 181 return self.header.filename()
182 182
183 183 def __repr__(self):
184 184 return '<hunk %r@%d>' % (self.filename(), self.fromline)
185 185
186 186 def parsepatch(fp):
187 187 """patch -> [] of headers -> [] of hunks """
188 188 class parser(object):
189 189 """patch parsing state machine"""
190 190 def __init__(self):
191 191 self.fromline = 0
192 192 self.toline = 0
193 193 self.proc = ''
194 194 self.header = None
195 195 self.context = []
196 196 self.before = []
197 197 self.hunk = []
198 198 self.headers = []
199 199
200 200 def addrange(self, limits):
201 201 fromstart, fromend, tostart, toend, proc = limits
202 202 self.fromline = int(fromstart)
203 203 self.toline = int(tostart)
204 204 self.proc = proc
205 205
206 206 def addcontext(self, context):
207 207 if self.hunk:
208 208 h = hunk(self.header, self.fromline, self.toline, self.proc,
209 209 self.before, self.hunk, context)
210 210 self.header.hunks.append(h)
211 211 self.fromline += len(self.before) + h.removed
212 212 self.toline += len(self.before) + h.added
213 213 self.before = []
214 214 self.hunk = []
215 215 self.proc = ''
216 216 self.context = context
217 217
218 218 def addhunk(self, hunk):
219 219 if self.context:
220 220 self.before = self.context
221 221 self.context = []
222 222 self.hunk = hunk
223 223
224 224 def newfile(self, hdr):
225 225 self.addcontext([])
226 226 h = header(hdr)
227 227 self.headers.append(h)
228 228 self.header = h
229 229
230 230 def finished(self):
231 231 self.addcontext([])
232 232 return self.headers
233 233
234 234 transitions = {
235 235 'file': {'context': addcontext,
236 236 'file': newfile,
237 237 'hunk': addhunk,
238 238 'range': addrange},
239 239 'context': {'file': newfile,
240 240 'hunk': addhunk,
241 241 'range': addrange},
242 242 'hunk': {'context': addcontext,
243 243 'file': newfile,
244 244 'range': addrange},
245 245 'range': {'context': addcontext,
246 246 'hunk': addhunk},
247 247 }
248 248
249 249 p = parser()
250 250
251 251 state = 'context'
252 252 for newstate, data in scanpatch(fp):
253 253 try:
254 254 p.transitions[state][newstate](p, data)
255 255 except KeyError:
256 256 raise patch.PatchError('unhandled transition: %s -> %s' %
257 257 (state, newstate))
258 258 state = newstate
259 259 return p.finished()
260 260
261 261 def filterpatch(ui, headers):
262 262 """Interactively filter patch chunks into applied-only chunks"""
263 263
264 264 def prompt(skipfile, skipall, query):
265 265 """prompt query, and process base inputs
266 266
267 267 - y/n for the rest of file
268 268 - y/n for the rest
269 269 - ? (help)
270 270 - q (quit)
271 271
272 272 Return True/False and possibly updated skipfile and skipall.
273 273 """
274 274 if skipall is not None:
275 275 return skipall, skipfile, skipall
276 276 if skipfile is not None:
277 277 return skipfile, skipfile, skipall
278 278 while True:
279 279 resps = _('[Ynsfdaq?]')
280 280 choices = (_('&Yes, record this change'),
281 281 _('&No, skip this change'),
282 282 _('&Skip remaining changes to this file'),
283 283 _('Record remaining changes to this &file'),
284 284 _('&Done, skip remaining changes and files'),
285 285 _('Record &all changes to all remaining files'),
286 286 _('&Quit, recording no changes'),
287 287 _('&?'))
288 288 r = ui.promptchoice("%s %s" % (query, resps), choices)
289 289 ui.write("\n")
290 290 if r == 7: # ?
291 291 doc = gettext(record.__doc__)
292 292 c = doc.find('::') + 2
293 293 for l in doc[c:].splitlines():
294 294 if l.startswith(' '):
295 295 ui.write(l.strip(), '\n')
296 296 continue
297 297 elif r == 0: # yes
298 298 ret = True
299 299 elif r == 1: # no
300 300 ret = False
301 301 elif r == 2: # Skip
302 302 ret = skipfile = False
303 303 elif r == 3: # file (Record remaining)
304 304 ret = skipfile = True
305 305 elif r == 4: # done, skip remaining
306 306 ret = skipall = False
307 307 elif r == 5: # all
308 308 ret = skipall = True
309 309 elif r == 6: # quit
310 310 raise util.Abort(_('user quit'))
311 311 return ret, skipfile, skipall
312 312
313 313 seen = set()
314 314 applied = {} # 'filename' -> [] of chunks
315 315 skipfile, skipall = None, None
316 316 pos, total = 1, sum(len(h.hunks) for h in headers)
317 317 for h in headers:
318 318 pos += len(h.hunks)
319 319 skipfile = None
320 320 fixoffset = 0
321 321 hdr = ''.join(h.header)
322 322 if hdr in seen:
323 323 continue
324 324 seen.add(hdr)
325 325 if skipall is None:
326 326 h.pretty(ui)
327 327 msg = (_('examine changes to %s?') %
328 328 _(' and ').join(map(repr, h.files())))
329 329 r, skipfile, skipall = prompt(skipfile, skipall, msg)
330 330 if not r:
331 331 continue
332 332 applied[h.filename()] = [h]
333 333 if h.allhunks():
334 334 applied[h.filename()] += h.hunks
335 335 continue
336 336 for i, chunk in enumerate(h.hunks):
337 337 if skipfile is None and skipall is None:
338 338 chunk.pretty(ui)
339 339 if total == 1:
340 340 msg = _('record this change to %r?') % chunk.filename()
341 341 else:
342 342 idx = pos - len(h.hunks) + i
343 343 msg = _('record change %d/%d to %r?') % (idx, total,
344 344 chunk.filename())
345 345 r, skipfile, skipall = prompt(skipfile, skipall, msg)
346 346 if r:
347 347 if fixoffset:
348 348 chunk = copy.copy(chunk)
349 349 chunk.toline += fixoffset
350 350 applied[chunk.filename()].append(chunk)
351 351 else:
352 352 fixoffset += chunk.removed - chunk.added
353 353 return sum([h for h in applied.itervalues()
354 354 if h[0].special() or len(h) > 1], [])
355 355
356 356 @command("record",
357 357 # same options as commit + white space diff options
358 358 commands.table['^commit|ci'][1][:] + diffopts,
359 359 _('hg record [OPTION]... [FILE]...'))
360 360 def record(ui, repo, *pats, **opts):
361 361 '''interactively select changes to commit
362 362
363 363 If a list of files is omitted, all changes reported by :hg:`status`
364 364 will be candidates for recording.
365 365
366 366 See :hg:`help dates` for a list of formats valid for -d/--date.
367 367
368 368 You will be prompted for whether to record changes to each
369 369 modified file, and for files with multiple changes, for each
370 370 change to use. For each query, the following responses are
371 371 possible::
372 372
373 373 y - record this change
374 374 n - skip this change
375 375
376 376 s - skip remaining changes to this file
377 377 f - record remaining changes to this file
378 378
379 379 d - done, skip remaining changes and files
380 380 a - record all changes to all remaining files
381 381 q - quit, recording no changes
382 382
383 383 ? - display help
384 384
385 385 This command is not available when committing a merge.'''
386 386
387 387 dorecord(ui, repo, commands.commit, 'commit', False, *pats, **opts)
388 388
389 def qrefresh(ui, repo, *pats, **opts):
389 def qrefresh(origfn, ui, repo, *pats, **opts):
390 if not opts['interactive']:
391 return origfn(ui, repo, *pats, **opts)
392
390 393 mq = extensions.find('mq')
391 394
392 395 def committomq(ui, repo, *pats, **opts):
393 396 # At this point the working copy contains only changes that
394 397 # were accepted. All other changes were reverted.
395 398 # We can't pass *pats here since qrefresh will undo all other
396 399 # changed files in the patch that aren't in pats.
397 400 mq.refresh(ui, repo, **opts)
398 401
399 402 # backup all changed files
400 403 dorecord(ui, repo, committomq, 'qrefresh', True, *pats, **opts)
401 404
402 405 def qrecord(ui, repo, patch, *pats, **opts):
403 406 '''interactively record a new patch
404 407
405 408 See :hg:`help qnew` & :hg:`help record` for more information and
406 409 usage.
407 410 '''
408 411
409 412 try:
410 413 mq = extensions.find('mq')
411 414 except KeyError:
412 415 raise util.Abort(_("'mq' extension not loaded"))
413 416
414 417 repo.mq.checkpatchname(patch)
415 418
416 419 def committomq(ui, repo, *pats, **opts):
417 420 opts['checkname'] = False
418 421 mq.new(ui, repo, patch, *pats, **opts)
419 422
420 423 dorecord(ui, repo, committomq, 'qnew', False, *pats, **opts)
421 424
425 def qnew(origfn, ui, repo, patch, *args, **opts):
426 if opts['interactive']:
427 return qrecord(ui, repo, patch, *args, **opts)
428 return origfn(ui, repo, patch, *args, **opts)
429
422 430 def dorecord(ui, repo, commitfunc, cmdsuggest, backupall, *pats, **opts):
423 431 if not ui.interactive():
424 432 raise util.Abort(_('running non-interactively, use %s instead') %
425 433 cmdsuggest)
426 434
427 435 def recordfunc(ui, repo, message, match, opts):
428 436 """This is generic record driver.
429 437
430 438 Its job is to interactively filter local changes, and
431 439 accordingly prepare working directory into a state in which the
432 440 job can be delegated to a non-interactive commit command such as
433 441 'commit' or 'qrefresh'.
434 442
435 443 After the actual job is done by non-interactive command, the
436 444 working directory is restored to its original state.
437 445
438 446 In the end we'll record interesting changes, and everything else
439 447 will be left in place, so the user can continue working.
440 448 """
441 449
442 450 merge = len(repo[None].parents()) > 1
443 451 if merge:
444 452 raise util.Abort(_('cannot partially commit a merge '
445 453 '(use "hg commit" instead)'))
446 454
447 455 changes = repo.status(match=match)[:3]
448 456 diffopts = mdiff.diffopts(git=True, nodates=True,
449 457 ignorews=opts.get('ignore_all_space'),
450 458 ignorewsamount=opts.get('ignore_space_change'),
451 459 ignoreblanklines=opts.get('ignore_blank_lines'))
452 460 chunks = patch.diff(repo, changes=changes, opts=diffopts)
453 461 fp = cStringIO.StringIO()
454 462 fp.write(''.join(chunks))
455 463 fp.seek(0)
456 464
457 465 # 1. filter patch, so we have intending-to apply subset of it
458 466 chunks = filterpatch(ui, parsepatch(fp))
459 467 del fp
460 468
461 469 contenders = set()
462 470 for h in chunks:
463 471 try:
464 472 contenders.update(set(h.files()))
465 473 except AttributeError:
466 474 pass
467 475
468 476 changed = changes[0] + changes[1] + changes[2]
469 477 newfiles = [f for f in changed if f in contenders]
470 478 if not newfiles:
471 479 ui.status(_('no changes to record\n'))
472 480 return 0
473 481
474 482 modified = set(changes[0])
475 483
476 484 # 2. backup changed files, so we can restore them in the end
477 485 if backupall:
478 486 tobackup = changed
479 487 else:
480 488 tobackup = [f for f in newfiles if f in modified]
481 489
482 490 backups = {}
483 491 if tobackup:
484 492 backupdir = repo.join('record-backups')
485 493 try:
486 494 os.mkdir(backupdir)
487 495 except OSError, err:
488 496 if err.errno != errno.EEXIST:
489 497 raise
490 498 try:
491 499 # backup continues
492 500 for f in tobackup:
493 501 fd, tmpname = tempfile.mkstemp(prefix=f.replace('/', '_')+'.',
494 502 dir=backupdir)
495 503 os.close(fd)
496 504 ui.debug('backup %r as %r\n' % (f, tmpname))
497 505 util.copyfile(repo.wjoin(f), tmpname)
498 506 shutil.copystat(repo.wjoin(f), tmpname)
499 507 backups[f] = tmpname
500 508
501 509 fp = cStringIO.StringIO()
502 510 for c in chunks:
503 511 if c.filename() in backups:
504 512 c.write(fp)
505 513 dopatch = fp.tell()
506 514 fp.seek(0)
507 515
508 516 # 3a. apply filtered patch to clean repo (clean)
509 517 if backups:
510 518 hg.revert(repo, repo.dirstate.p1(),
511 519 lambda key: key in backups)
512 520
513 521 # 3b. (apply)
514 522 if dopatch:
515 523 try:
516 524 ui.debug('applying patch\n')
517 525 ui.debug(fp.getvalue())
518 526 patch.internalpatch(ui, repo, fp, 1, eolmode=None)
519 527 except patch.PatchError, err:
520 528 raise util.Abort(str(err))
521 529 del fp
522 530
523 531 # 4. We prepared working directory according to filtered
524 532 # patch. Now is the time to delegate the job to
525 533 # commit/qrefresh or the like!
526 534
527 535 # it is important to first chdir to repo root -- we'll call
528 536 # a highlevel command with list of pathnames relative to
529 537 # repo root
530 538 cwd = os.getcwd()
531 539 os.chdir(repo.root)
532 540 try:
533 541 commitfunc(ui, repo, *newfiles, **opts)
534 542 finally:
535 543 os.chdir(cwd)
536 544
537 545 return 0
538 546 finally:
539 547 # 5. finally restore backed-up files
540 548 try:
541 549 for realname, tmpname in backups.iteritems():
542 550 ui.debug('restoring %r to %r\n' % (tmpname, realname))
543 551 util.copyfile(tmpname, repo.wjoin(realname))
544 552 # Our calls to copystat() here and above are a
545 553 # hack to trick any editors that have f open that
546 554 # we haven't modified them.
547 555 #
548 556 # Also note that this racy as an editor could
549 557 # notice the file's mtime before we've finished
550 558 # writing it.
551 559 shutil.copystat(tmpname, repo.wjoin(realname))
552 560 os.unlink(tmpname)
553 561 if tobackup:
554 562 os.rmdir(backupdir)
555 563 except OSError:
556 564 pass
557 565
558 566 # wrap ui.write so diff output can be labeled/colorized
559 567 def wrapwrite(orig, *args, **kw):
560 568 label = kw.pop('label', '')
561 569 for chunk, l in patch.difflabel(lambda: args):
562 570 orig(chunk, label=label + l)
563 571 oldwrite = ui.write
564 572 extensions.wrapfunction(ui, 'write', wrapwrite)
565 573 try:
566 574 return cmdutil.commit(ui, repo, recordfunc, pats, opts)
567 575 finally:
568 576 ui.write = oldwrite
569 577
570 578 cmdtable["qrecord"] = \
571 579 (qrecord, [], # placeholder until mq is available
572 580 _('hg qrecord [OPTION]... PATCH [FILE]...'))
573 581
574 582 def uisetup(ui):
575 583 try:
576 584 mq = extensions.find('mq')
577 585 except KeyError:
578 586 return
579 587
580 588 cmdtable["qrecord"] = \
581 589 (qrecord,
582 590 # same options as qnew, but copy them so we don't get
583 591 # -i/--interactive for qrecord and add white space diff options
584 592 mq.cmdtable['^qnew'][1][:] + diffopts,
585 593 _('hg qrecord [OPTION]... PATCH [FILE]...'))
586 594
587 _wrapcmd('qnew', mq.cmdtable, qrecord, _("interactively record a new patch"))
595 _wrapcmd('qnew', mq.cmdtable, qnew, _("interactively record a new patch"))
588 596 _wrapcmd('qrefresh', mq.cmdtable, qrefresh,
589 597 _("interactively select changes to refresh"))
590 598
591 599 def _wrapcmd(cmd, table, wrapfn, msg):
592 '''wrap the command'''
593 def wrapper(orig, *args, **kwargs):
594 if kwargs['interactive']:
595 return wrapfn(*args, **kwargs)
596 return orig(*args, **kwargs)
597 entry = extensions.wrapcommand(table, cmd, wrapper)
600 entry = extensions.wrapcommand(table, cmd, wrapfn)
598 601 entry[1].append(('i', 'interactive', None, msg))
General Comments 0
You need to be logged in to leave comments. Login now