##// END OF EJS Templates
record: turn consumefile() into a pure function
Patrick Mezard -
r13290:82133e91 default
parent child Browse files
Show More
@@ -1,576 +1,575
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 chunks = list(chunks)
259 chunks.reverse()
260 seen = set()
261 def consumefile():
258 def consumefile(chunks):
262 259 """fetch next portion from chunks until a 'header' is seen
263 260 NB: header == new-file mark
264 261 """
265 262 consumed = []
266 263 while chunks:
267 264 if isinstance(chunks[-1], header):
268 265 break
269 else:
270 consumed.append(chunks.pop())
266 consumed.append(chunks.pop())
271 267 return consumed
272 268
269 chunks = list(chunks)
270 chunks.reverse()
271 seen = set()
273 272 resp_all = [None] # this two are changed from inside prompt,
274 273 resp_file = [None] # so can't be usual variables
275 274 applied = {} # 'filename' -> [] of chunks
276 275 def prompt(query):
277 276 """prompt query, and process base inputs
278 277
279 278 - y/n for the rest of file
280 279 - y/n for the rest
281 280 - ? (help)
282 281 - q (quit)
283 282
284 283 Returns True/False and sets reps_all and resp_file as
285 284 appropriate.
286 285 """
287 286 if resp_all[0] is not None:
288 287 return resp_all[0]
289 288 if resp_file[0] is not None:
290 289 return resp_file[0]
291 290 while True:
292 291 resps = _('[Ynsfdaq?]')
293 292 choices = (_('&Yes, record this change'),
294 293 _('&No, skip this change'),
295 294 _('&Skip remaining changes to this file'),
296 295 _('Record remaining changes to this &file'),
297 296 _('&Done, skip remaining changes and files'),
298 297 _('Record &all changes to all remaining files'),
299 298 _('&Quit, recording no changes'),
300 299 _('&?'))
301 300 r = ui.promptchoice("%s %s" % (query, resps), choices)
302 301 ui.write("\n")
303 302 if r == 7: # ?
304 303 doc = gettext(record.__doc__)
305 304 c = doc.find('::') + 2
306 305 for l in doc[c:].splitlines():
307 306 if l.startswith(' '):
308 307 ui.write(l.strip(), '\n')
309 308 continue
310 309 elif r == 0: # yes
311 310 ret = True
312 311 elif r == 1: # no
313 312 ret = False
314 313 elif r == 2: # Skip
315 314 ret = resp_file[0] = False
316 315 elif r == 3: # file (Record remaining)
317 316 ret = resp_file[0] = True
318 317 elif r == 4: # done, skip remaining
319 318 ret = resp_all[0] = False
320 319 elif r == 5: # all
321 320 ret = resp_all[0] = True
322 321 elif r == 6: # quit
323 322 raise util.Abort(_('user quit'))
324 323 return ret
325 324 pos, total = 0, len(chunks) - 1
326 325 while chunks:
327 326 pos = total - len(chunks) + 1
328 327 chunk = chunks.pop()
329 328 if isinstance(chunk, header):
330 329 # new-file mark
331 330 resp_file = [None]
332 331 fixoffset = 0
333 332 hdr = ''.join(chunk.header)
334 333 if hdr in seen:
335 consumefile()
334 consumefile(chunks)
336 335 continue
337 336 seen.add(hdr)
338 337 if resp_all[0] is None:
339 338 chunk.pretty(ui)
340 339 r = prompt(_('examine changes to %s?') %
341 340 _(' and ').join(map(repr, chunk.files())))
342 341 if r:
343 342 applied[chunk.filename()] = [chunk]
344 343 if chunk.allhunks():
345 applied[chunk.filename()] += consumefile()
344 applied[chunk.filename()] += consumefile(chunks)
346 345 else:
347 consumefile()
346 consumefile(chunks)
348 347 else:
349 348 # new hunk
350 349 if resp_file[0] is None and resp_all[0] is None:
351 350 chunk.pretty(ui)
352 351 r = (total == 1
353 352 and prompt(_('record this change to %r?') % chunk.filename())
354 353 or prompt(_('record change %d/%d to %r?') %
355 354 (pos, total, chunk.filename())))
356 355 if r:
357 356 if fixoffset:
358 357 chunk = copy.copy(chunk)
359 358 chunk.toline += fixoffset
360 359 applied[chunk.filename()].append(chunk)
361 360 else:
362 361 fixoffset += chunk.removed - chunk.added
363 362 return sum([h for h in applied.itervalues()
364 363 if h[0].special() or len(h) > 1], [])
365 364
366 365 def record(ui, repo, *pats, **opts):
367 366 '''interactively select changes to commit
368 367
369 368 If a list of files is omitted, all changes reported by :hg:`status`
370 369 will be candidates for recording.
371 370
372 371 See :hg:`help dates` for a list of formats valid for -d/--date.
373 372
374 373 You will be prompted for whether to record changes to each
375 374 modified file, and for files with multiple changes, for each
376 375 change to use. For each query, the following responses are
377 376 possible::
378 377
379 378 y - record this change
380 379 n - skip this change
381 380
382 381 s - skip remaining changes to this file
383 382 f - record remaining changes to this file
384 383
385 384 d - done, skip remaining changes and files
386 385 a - record all changes to all remaining files
387 386 q - quit, recording no changes
388 387
389 388 ? - display help
390 389
391 390 This command is not available when committing a merge.'''
392 391
393 392 dorecord(ui, repo, commands.commit, *pats, **opts)
394 393
395 394
396 395 def qrecord(ui, repo, patch, *pats, **opts):
397 396 '''interactively record a new patch
398 397
399 398 See :hg:`help qnew` & :hg:`help record` for more information and
400 399 usage.
401 400 '''
402 401
403 402 try:
404 403 mq = extensions.find('mq')
405 404 except KeyError:
406 405 raise util.Abort(_("'mq' extension not loaded"))
407 406
408 407 def committomq(ui, repo, *pats, **opts):
409 408 mq.new(ui, repo, patch, *pats, **opts)
410 409
411 410 dorecord(ui, repo, committomq, *pats, **opts)
412 411
413 412
414 413 def dorecord(ui, repo, commitfunc, *pats, **opts):
415 414 if not ui.interactive():
416 415 raise util.Abort(_('running non-interactively, use commit instead'))
417 416
418 417 def recordfunc(ui, repo, message, match, opts):
419 418 """This is generic record driver.
420 419
421 420 Its job is to interactively filter local changes, and
422 421 accordingly prepare working directory into a state in which the
423 422 job can be delegated to a non-interactive commit command such as
424 423 'commit' or 'qrefresh'.
425 424
426 425 After the actual job is done by non-interactive command, the
427 426 working directory is restored to its original state.
428 427
429 428 In the end we'll record interesting changes, and everything else
430 429 will be left in place, so the user can continue working.
431 430 """
432 431
433 432 merge = len(repo[None].parents()) > 1
434 433 if merge:
435 434 raise util.Abort(_('cannot partially commit a merge '
436 435 '(use "hg commit" instead)'))
437 436
438 437 changes = repo.status(match=match)[:3]
439 438 diffopts = mdiff.diffopts(git=True, nodates=True)
440 439 chunks = patch.diff(repo, changes=changes, opts=diffopts)
441 440 fp = cStringIO.StringIO()
442 441 fp.write(''.join(chunks))
443 442 fp.seek(0)
444 443
445 444 # 1. filter patch, so we have intending-to apply subset of it
446 445 chunks = filterpatch(ui, parsepatch(fp))
447 446 del fp
448 447
449 448 contenders = set()
450 449 for h in chunks:
451 450 try:
452 451 contenders.update(set(h.files()))
453 452 except AttributeError:
454 453 pass
455 454
456 455 changed = changes[0] + changes[1] + changes[2]
457 456 newfiles = [f for f in changed if f in contenders]
458 457 if not newfiles:
459 458 ui.status(_('no changes to record\n'))
460 459 return 0
461 460
462 461 modified = set(changes[0])
463 462
464 463 # 2. backup changed files, so we can restore them in the end
465 464 backups = {}
466 465 backupdir = repo.join('record-backups')
467 466 try:
468 467 os.mkdir(backupdir)
469 468 except OSError, err:
470 469 if err.errno != errno.EEXIST:
471 470 raise
472 471 try:
473 472 # backup continues
474 473 for f in newfiles:
475 474 if f not in modified:
476 475 continue
477 476 fd, tmpname = tempfile.mkstemp(prefix=f.replace('/', '_')+'.',
478 477 dir=backupdir)
479 478 os.close(fd)
480 479 ui.debug('backup %r as %r\n' % (f, tmpname))
481 480 util.copyfile(repo.wjoin(f), tmpname)
482 481 shutil.copystat(repo.wjoin(f), tmpname)
483 482 backups[f] = tmpname
484 483
485 484 fp = cStringIO.StringIO()
486 485 for c in chunks:
487 486 if c.filename() in backups:
488 487 c.write(fp)
489 488 dopatch = fp.tell()
490 489 fp.seek(0)
491 490
492 491 # 3a. apply filtered patch to clean repo (clean)
493 492 if backups:
494 493 hg.revert(repo, repo.dirstate.parents()[0],
495 494 lambda key: key in backups)
496 495
497 496 # 3b. (apply)
498 497 if dopatch:
499 498 try:
500 499 ui.debug('applying patch\n')
501 500 ui.debug(fp.getvalue())
502 501 pfiles = {}
503 502 patch.internalpatch(fp, ui, 1, repo.root, files=pfiles,
504 503 eolmode=None)
505 504 cmdutil.updatedir(ui, repo, pfiles)
506 505 except patch.PatchError, err:
507 506 raise util.Abort(str(err))
508 507 del fp
509 508
510 509 # 4. We prepared working directory according to filtered
511 510 # patch. Now is the time to delegate the job to
512 511 # commit/qrefresh or the like!
513 512
514 513 # it is important to first chdir to repo root -- we'll call
515 514 # a highlevel command with list of pathnames relative to
516 515 # repo root
517 516 cwd = os.getcwd()
518 517 os.chdir(repo.root)
519 518 try:
520 519 commitfunc(ui, repo, *newfiles, **opts)
521 520 finally:
522 521 os.chdir(cwd)
523 522
524 523 return 0
525 524 finally:
526 525 # 5. finally restore backed-up files
527 526 try:
528 527 for realname, tmpname in backups.iteritems():
529 528 ui.debug('restoring %r to %r\n' % (tmpname, realname))
530 529 util.copyfile(tmpname, repo.wjoin(realname))
531 530 # Our calls to copystat() here and above are a
532 531 # hack to trick any editors that have f open that
533 532 # we haven't modified them.
534 533 #
535 534 # Also note that this racy as an editor could
536 535 # notice the file's mtime before we've finished
537 536 # writing it.
538 537 shutil.copystat(tmpname, repo.wjoin(realname))
539 538 os.unlink(tmpname)
540 539 os.rmdir(backupdir)
541 540 except OSError:
542 541 pass
543 542
544 543 # wrap ui.write so diff output can be labeled/colorized
545 544 def wrapwrite(orig, *args, **kw):
546 545 label = kw.pop('label', '')
547 546 for chunk, l in patch.difflabel(lambda: args):
548 547 orig(chunk, label=label + l)
549 548 oldwrite = ui.write
550 549 extensions.wrapfunction(ui, 'write', wrapwrite)
551 550 try:
552 551 return cmdutil.commit(ui, repo, recordfunc, pats, opts)
553 552 finally:
554 553 ui.write = oldwrite
555 554
556 555 cmdtable = {
557 556 "record":
558 557 (record, commands.table['^commit|ci'][1], # same options as commit
559 558 _('hg record [OPTION]... [FILE]...')),
560 559 }
561 560
562 561
563 562 def uisetup(ui):
564 563 try:
565 564 mq = extensions.find('mq')
566 565 except KeyError:
567 566 return
568 567
569 568 qcmdtable = {
570 569 "qrecord":
571 570 (qrecord, mq.cmdtable['^qnew'][1], # same options as qnew
572 571 _('hg qrecord [OPTION]... PATCH [FILE]...')),
573 572 }
574 573
575 574 cmdtable.update(qcmdtable)
576 575
General Comments 0
You need to be logged in to leave comments. Login now