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