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