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