##// END OF EJS Templates
record: get the right position (account for skipped files)
Benoit Boissinot -
r9688:dd5a16ad default
parent child Browse files
Show More
@@ -1,551 +1,551 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, incorporated herein by reference.
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, 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 = set()
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 resps = _('[Ynsfdaq?]')
286 286 choices = (_('&Yes, record this change'),
287 287 _('&No, skip this change'),
288 288 _('&Skip remaining changes to this file'),
289 289 _('Record remaining changes to this &file'),
290 290 _('&Done, skip remaining changes and files'),
291 291 _('Record &all changes to all remaining files'),
292 292 _('&Quit, recording no changes'),
293 293 _('&?'))
294 294 r = ui.promptchoice("%s %s" % (query, resps), choices)
295 295 if r == 7: # ?
296 296 doc = gettext(record.__doc__)
297 297 c = doc.find(_('y - record this change'))
298 298 for l in doc[c:].splitlines():
299 299 if l: ui.write(l.strip(), '\n')
300 300 continue
301 301 elif r == 0: # yes
302 302 ret = 'y'
303 303 elif r == 1: # no
304 304 ret = 'n'
305 305 elif r == 2: # Skip
306 306 ret = resp_file[0] = 'n'
307 307 elif r == 3: # file (Record remaining)
308 308 ret = resp_file[0] = 'y'
309 309 elif r == 4: # done, skip remaining
310 310 ret = resp_all[0] = 'n'
311 311 elif r == 5: # all
312 312 ret = resp_all[0] = 'y'
313 313 elif r == 6: # quit
314 314 raise util.Abort(_('user quit'))
315 315 return ret
316 316 pos, total = 0, len(chunks) - 1
317 317 while chunks:
318 pos = total - len(chunks) + 1
318 319 chunk = chunks.pop()
319 320 if isinstance(chunk, header):
320 321 # new-file mark
321 322 resp_file = [None]
322 323 fixoffset = 0
323 324 hdr = ''.join(chunk.header)
324 325 if hdr in seen:
325 326 consumefile()
326 327 continue
327 328 seen.add(hdr)
328 329 if resp_all[0] is None:
329 330 chunk.pretty(ui)
330 331 r = prompt(_('examine changes to %s?') %
331 332 _(' and ').join(map(repr, chunk.files())))
332 333 if r == _('y'):
333 334 applied[chunk.filename()] = [chunk]
334 335 if chunk.allhunks():
335 336 applied[chunk.filename()] += consumefile()
336 337 else:
337 338 consumefile()
338 339 else:
339 340 # new hunk
340 341 if resp_file[0] is None and resp_all[0] is None:
341 342 chunk.pretty(ui)
342 343 r = total == 1 and prompt(_('record this change to %r?') %
343 344 chunk.filename()) \
344 345 or prompt(_('record change %d/%d to %r?') %
345 346 (pos, total, chunk.filename()))
346 347 if r == _('y'):
347 348 if fixoffset:
348 349 chunk = copy.copy(chunk)
349 350 chunk.toline += fixoffset
350 351 applied[chunk.filename()].append(chunk)
351 352 else:
352 353 fixoffset += chunk.removed - chunk.added
353 pos = pos + 1
354 354 return reduce(operator.add, [h for h in applied.itervalues()
355 355 if h[0].special() or len(h) > 1], [])
356 356
357 357 def record(ui, repo, *pats, **opts):
358 358 '''interactively select changes to commit
359 359
360 360 If a list of files is omitted, all changes reported by "hg status"
361 361 will be candidates for recording.
362 362
363 363 See 'hg help dates' for a list of formats valid for -d/--date.
364 364
365 365 You will be prompted for whether to record changes to each
366 366 modified file, and for files with multiple changes, for each
367 367 change to use. For each query, the following responses are
368 368 possible::
369 369
370 370 y - record this change
371 371 n - skip this change
372 372
373 373 s - skip remaining changes to this file
374 374 f - record remaining changes to this file
375 375
376 376 d - done, skip remaining changes and files
377 377 a - record all changes to all remaining files
378 378 q - quit, recording no changes
379 379
380 380 ? - display help'''
381 381
382 382 def record_committer(ui, repo, pats, opts):
383 383 commands.commit(ui, repo, *pats, **opts)
384 384
385 385 dorecord(ui, repo, record_committer, *pats, **opts)
386 386
387 387
388 388 def qrecord(ui, repo, patch, *pats, **opts):
389 389 '''interactively record a new patch
390 390
391 391 See 'hg help qnew' & 'hg help record' for more information and
392 392 usage.
393 393 '''
394 394
395 395 try:
396 396 mq = extensions.find('mq')
397 397 except KeyError:
398 398 raise util.Abort(_("'mq' extension not loaded"))
399 399
400 400 def qrecord_committer(ui, repo, pats, opts):
401 401 mq.new(ui, repo, patch, *pats, **opts)
402 402
403 403 opts = opts.copy()
404 404 opts['force'] = True # always 'qnew -f'
405 405 dorecord(ui, repo, qrecord_committer, *pats, **opts)
406 406
407 407
408 408 def dorecord(ui, repo, committer, *pats, **opts):
409 409 if not ui.interactive():
410 410 raise util.Abort(_('running non-interactively, use commit instead'))
411 411
412 412 def recordfunc(ui, repo, message, match, opts):
413 413 """This is generic record driver.
414 414
415 415 Its job is to interactively filter local changes, and accordingly
416 416 prepare working dir into a state, where the job can be delegated to
417 417 non-interactive commit command such as 'commit' or 'qrefresh'.
418 418
419 419 After the actual job is done by non-interactive command, working dir
420 420 state is restored to original.
421 421
422 422 In the end we'll record intresting changes, and everything else will be
423 423 left in place, so the user can continue his work.
424 424 """
425 425
426 426 changes = repo.status(match=match)[:3]
427 427 diffopts = mdiff.diffopts(git=True, nodates=True)
428 428 chunks = patch.diff(repo, changes=changes, opts=diffopts)
429 429 fp = cStringIO.StringIO()
430 430 fp.write(''.join(chunks))
431 431 fp.seek(0)
432 432
433 433 # 1. filter patch, so we have intending-to apply subset of it
434 434 chunks = filterpatch(ui, parsepatch(fp))
435 435 del fp
436 436
437 437 contenders = set()
438 438 for h in chunks:
439 439 try: contenders.update(set(h.files()))
440 440 except AttributeError: pass
441 441
442 442 changed = changes[0] + changes[1] + changes[2]
443 443 newfiles = [f for f in changed if f in contenders]
444 444 if not newfiles:
445 445 ui.status(_('no changes to record\n'))
446 446 return 0
447 447
448 448 modified = set(changes[0])
449 449
450 450 # 2. backup changed files, so we can restore them in the end
451 451 backups = {}
452 452 backupdir = repo.join('record-backups')
453 453 try:
454 454 os.mkdir(backupdir)
455 455 except OSError, err:
456 456 if err.errno != errno.EEXIST:
457 457 raise
458 458 try:
459 459 # backup continues
460 460 for f in newfiles:
461 461 if f not in modified:
462 462 continue
463 463 fd, tmpname = tempfile.mkstemp(prefix=f.replace('/', '_')+'.',
464 464 dir=backupdir)
465 465 os.close(fd)
466 466 ui.debug('backup %r as %r\n' % (f, tmpname))
467 467 util.copyfile(repo.wjoin(f), tmpname)
468 468 backups[f] = tmpname
469 469
470 470 fp = cStringIO.StringIO()
471 471 for c in chunks:
472 472 if c.filename() in backups:
473 473 c.write(fp)
474 474 dopatch = fp.tell()
475 475 fp.seek(0)
476 476
477 477 # 3a. apply filtered patch to clean repo (clean)
478 478 if backups:
479 479 hg.revert(repo, repo.dirstate.parents()[0], backups.has_key)
480 480
481 481 # 3b. (apply)
482 482 if dopatch:
483 483 try:
484 484 ui.debug('applying patch\n')
485 485 ui.debug(fp.getvalue())
486 486 pfiles = {}
487 487 patch.internalpatch(fp, ui, 1, repo.root, files=pfiles,
488 488 eolmode=None)
489 489 patch.updatedir(ui, repo, pfiles)
490 490 except patch.PatchError, err:
491 491 s = str(err)
492 492 if s:
493 493 raise util.Abort(s)
494 494 else:
495 495 raise util.Abort(_('patch failed to apply'))
496 496 del fp
497 497
498 498 # 4. We prepared working directory according to filtered patch.
499 499 # Now is the time to delegate the job to commit/qrefresh or the like!
500 500
501 501 # it is important to first chdir to repo root -- we'll call a
502 502 # highlevel command with list of pathnames relative to repo root
503 503 cwd = os.getcwd()
504 504 os.chdir(repo.root)
505 505 try:
506 506 committer(ui, repo, newfiles, opts)
507 507 finally:
508 508 os.chdir(cwd)
509 509
510 510 return 0
511 511 finally:
512 512 # 5. finally restore backed-up files
513 513 try:
514 514 for realname, tmpname in backups.iteritems():
515 515 ui.debug('restoring %r to %r\n' % (tmpname, realname))
516 516 util.copyfile(tmpname, repo.wjoin(realname))
517 517 os.unlink(tmpname)
518 518 os.rmdir(backupdir)
519 519 except OSError:
520 520 pass
521 521 return cmdutil.commit(ui, repo, recordfunc, pats, opts)
522 522
523 523 cmdtable = {
524 524 "record":
525 525 (record,
526 526
527 527 # add commit options
528 528 commands.table['^commit|ci'][1],
529 529
530 530 _('hg record [OPTION]... [FILE]...')),
531 531 }
532 532
533 533
534 534 def extsetup():
535 535 try:
536 536 mq = extensions.find('mq')
537 537 except KeyError:
538 538 return
539 539
540 540 qcmdtable = {
541 541 "qrecord":
542 542 (qrecord,
543 543
544 544 # add qnew options, except '--force'
545 545 [opt for opt in mq.cmdtable['qnew'][1] if opt[1] != 'force'],
546 546
547 547 _('hg qrecord [OPTION]... PATCH [FILE]...')),
548 548 }
549 549
550 550 cmdtable.update(qcmdtable)
551 551
General Comments 0
You need to be logged in to leave comments. Login now