##// END OF EJS Templates
hg qrecord -- like record, but for mq...
Kirill Smelkov -
r5830:c32d41af default
parent child Browse files
Show More
@@ -1,485 +1,523 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 '''interactive change selection during commit'''
8 '''interactive change selection during commit or qrefresh'''
9 9
10 10 from mercurial.i18n import _
11 from mercurial import cmdutil, commands, cmdutil, hg, mdiff, patch, revlog
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 def record_commiter(ui, repo, pats, opts):
361 def record_committer(ui, repo, pats, opts):
362 362 commands.commit(ui, repo, *pats, **opts)
363 363
364 dorecord(ui, repo, record_commiter, *pats, **opts)
364 dorecord(ui, repo, record_committer, *pats, **opts)
365
366
367 def qrecord(ui, repo, *pats, **opts):
368 '''interactively select changes for qrefresh
369
370 see 'hg help record' for more information and usage
371 '''
372
373 try:
374 mq = extensions.find('mq')
375 except KeyError:
376 raise util.Abort(_("'mq' extension not loaded"))
377
378 def qrecord_committer(ui, repo, pats, opts):
379 mq.refresh(ui, repo, *pats, **opts)
380
381 dorecord(ui, repo, qrecord_committer, *pats, **opts)
365 382
366 383
367 384 def dorecord(ui, repo, committer, *pats, **opts):
368 385 if not ui.interactive:
369 386 raise util.Abort(_('running non-interactively, use commit instead'))
370 387
371 388 def recordfunc(ui, repo, files, message, match, opts):
372 389 """This is generic record driver.
373 390
374 391 It's job is to interactively filter local changes, and accordingly
375 392 prepare working dir into a state, where the job can be delegated to
376 393 non-interactive commit command such as 'commit' or 'qrefresh'.
377 394
378 395 After the actual job is done by non-interactive command, working dir
379 396 state is restored to original.
380 397
381 398 In the end we'll record intresting changes, and everything else will be
382 399 left in place, so the user can continue his work.
383 400 """
384 401 if files:
385 402 changes = None
386 403 else:
387 404 changes = repo.status(files=files, match=match)[:5]
388 405 modified, added, removed = changes[:3]
389 406 files = modified + added + removed
390 407 diffopts = mdiff.diffopts(git=True, nodates=True)
391 408 fp = cStringIO.StringIO()
392 409 patch.diff(repo, repo.dirstate.parents()[0], files=files,
393 410 match=match, changes=changes, opts=diffopts, fp=fp)
394 411 fp.seek(0)
395 412
396 413 # 1. filter patch, so we have intending-to apply subset of it
397 414 chunks = filterpatch(ui, parsepatch(fp))
398 415 del fp
399 416
400 417 contenders = {}
401 418 for h in chunks:
402 419 try: contenders.update(dict.fromkeys(h.files()))
403 420 except AttributeError: pass
404 421
405 422 newfiles = [f for f in files if f in contenders]
406 423
407 424 if not newfiles:
408 425 ui.status(_('no changes to record\n'))
409 426 return 0
410 427
411 428 if changes is None:
412 429 changes = repo.status(files=newfiles, match=match)[:5]
413 430 modified = dict.fromkeys(changes[0])
414 431
415 432 # 2. backup changed files, so we can restore them in the end
416 433 backups = {}
417 434 backupdir = repo.join('record-backups')
418 435 try:
419 436 os.mkdir(backupdir)
420 437 except OSError, err:
421 438 if err.errno != errno.EEXIST:
422 439 raise
423 440 try:
424 441 # backup continues
425 442 for f in newfiles:
426 443 if f not in modified:
427 444 continue
428 445 fd, tmpname = tempfile.mkstemp(prefix=f.replace('/', '_')+'.',
429 446 dir=backupdir)
430 447 os.close(fd)
431 448 ui.debug('backup %r as %r\n' % (f, tmpname))
432 449 util.copyfile(repo.wjoin(f), tmpname)
433 450 backups[f] = tmpname
434 451
435 452 fp = cStringIO.StringIO()
436 453 for c in chunks:
437 454 if c.filename() in backups:
438 455 c.write(fp)
439 456 dopatch = fp.tell()
440 457 fp.seek(0)
441 458
442 459 # 3a. apply filtered patch to clean repo (clean)
443 460 if backups:
444 461 hg.revert(repo, repo.dirstate.parents()[0], backups.has_key)
445 462
446 463 # 3b. (apply)
447 464 if dopatch:
448 465 ui.debug('applying patch\n')
449 466 ui.debug(fp.getvalue())
450 467 patch.internalpatch(fp, ui, 1, repo.root)
451 468 del fp
452 469
453 470 # 4. We prepared working directory according to filtered patch.
454 471 # Now is the time to delegate the job to commit/qrefresh or the like!
455 472
456 473 # it is important to first chdir to repo root -- we'll call a
457 474 # highlevel command with list of pathnames relative to repo root
458 475 cwd = os.getcwd()
459 476 os.chdir(repo.root)
460 477 try:
461 478 committer(ui, repo, newfiles, opts)
462 479 finally:
463 480 os.chdir(cwd)
464 481
465 482 return 0
466 483 finally:
467 484 # 5. finally restore backed-up files
468 485 try:
469 486 for realname, tmpname in backups.iteritems():
470 487 ui.debug('restoring %r to %r\n' % (tmpname, realname))
471 488 util.copyfile(tmpname, repo.wjoin(realname))
472 489 os.unlink(tmpname)
473 490 os.rmdir(backupdir)
474 491 except OSError:
475 492 pass
476 493 return cmdutil.commit(ui, repo, recordfunc, pats, opts)
477 494
478 495 cmdtable = {
479 496 "record":
480 497 (record,
481 [('A', 'addremove', None,
482 _('mark new/missing files as added/removed before committing')),
483 ] + commands.walkopts + commands.commitopts + commands.commitopts2,
498
499 # add commit options
500 commands.table['^commit|ci'][1],
501
484 502 _('hg record [OPTION]... [FILE]...')),
485 503 }
504
505
506 def extsetup():
507 try:
508 mq = extensions.find('mq')
509 except KeyError:
510 return
511
512 qcmdtable = {
513 "qrecord":
514 (qrecord,
515
516 # add qrefresh options
517 mq.cmdtable['^qrefresh'][1],
518
519 _('hg qrecord [OPTION]... [FILE]...')),
520 }
521
522 cmdtable.update(qcmdtable)
523
General Comments 0
You need to be logged in to leave comments. Login now