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