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