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