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