##// END OF EJS Templates
record: some docs...
Kirill Smelkov -
r5826:cc43d9f3 default
parent child Browse files
Show More
@@ -1,415 +1,450 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'''
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 """like patch.iterhunks, but yield different events
19
20 - ('file', [header_lines + fromfile + tofile])
21 - ('context', [context_lines])
22 - ('hunk', [hunk_lines])
23 - ('range', (-start,len, +start,len, diffp))
24 """
18 25 lr = patch.linereader(fp)
19 26
20 27 def scanwhile(first, p):
28 """scan lr while predicate holds"""
21 29 lines = [first]
22 30 while True:
23 31 line = lr.readline()
24 32 if not line:
25 33 break
26 34 if p(line):
27 35 lines.append(line)
28 36 else:
29 37 lr.push(line)
30 38 break
31 39 return lines
32 40
33 41 while True:
34 42 line = lr.readline()
35 43 if not line:
36 44 break
37 45 if line.startswith('diff --git a/'):
38 46 def notheader(line):
39 47 s = line.split(None, 1)
40 48 return not s or s[0] not in ('---', 'diff')
41 49 header = scanwhile(line, notheader)
42 50 fromfile = lr.readline()
43 51 if fromfile.startswith('---'):
44 52 tofile = lr.readline()
45 53 header += [fromfile, tofile]
46 54 else:
47 55 lr.push(fromfile)
48 56 yield 'file', header
49 57 elif line[0] == ' ':
50 58 yield 'context', scanwhile(line, lambda l: l[0] in ' \\')
51 59 elif line[0] in '-+':
52 60 yield 'hunk', scanwhile(line, lambda l: l[0] in '-+\\')
53 61 else:
54 62 m = lines_re.match(line)
55 63 if m:
56 64 yield 'range', m.groups()
57 65 else:
58 66 raise patch.PatchError('unknown patch content: %r' % line)
59 67
60 68 class header(object):
69 """patch header
70
71 XXX shoudn't we move this to mercurial/patch.py ?
72 """
61 73 diff_re = re.compile('diff --git a/(.*) b/(.*)$')
62 74 allhunks_re = re.compile('(?:index|new file|deleted file) ')
63 75 pretty_re = re.compile('(?:new file|deleted file) ')
64 76 special_re = re.compile('(?:index|new|deleted|copy|rename) ')
65 77
66 78 def __init__(self, header):
67 79 self.header = header
68 80 self.hunks = []
69 81
70 82 def binary(self):
71 83 for h in self.header:
72 84 if h.startswith('index '):
73 85 return True
74 86
75 87 def pretty(self, fp):
76 88 for h in self.header:
77 89 if h.startswith('index '):
78 90 fp.write(_('this modifies a binary file (all or nothing)\n'))
79 91 break
80 92 if self.pretty_re.match(h):
81 93 fp.write(h)
82 94 if self.binary():
83 95 fp.write(_('this is a binary file\n'))
84 96 break
85 97 if h.startswith('---'):
86 98 fp.write(_('%d hunks, %d lines changed\n') %
87 99 (len(self.hunks),
88 100 sum([h.added + h.removed for h in self.hunks])))
89 101 break
90 102 fp.write(h)
91 103
92 104 def write(self, fp):
93 105 fp.write(''.join(self.header))
94 106
95 107 def allhunks(self):
96 108 for h in self.header:
97 109 if self.allhunks_re.match(h):
98 110 return True
99 111
100 112 def files(self):
101 113 fromfile, tofile = self.diff_re.match(self.header[0]).groups()
102 114 if fromfile == tofile:
103 115 return [fromfile]
104 116 return [fromfile, tofile]
105 117
106 118 def filename(self):
107 119 return self.files()[-1]
108 120
109 121 def __repr__(self):
110 122 return '<header %s>' % (' '.join(map(repr, self.files())))
111 123
112 124 def special(self):
113 125 for h in self.header:
114 126 if self.special_re.match(h):
115 127 return True
116 128
117 129 def countchanges(hunk):
130 """hunk -> (n+,n-)"""
118 131 add = len([h for h in hunk if h[0] == '+'])
119 132 rem = len([h for h in hunk if h[0] == '-'])
120 133 return add, rem
121 134
122 135 class hunk(object):
136 """patch hunk
137
138 XXX shouldn't we merge this with patch.hunk ?
139 """
123 140 maxcontext = 3
124 141
125 142 def __init__(self, header, fromline, toline, proc, before, hunk, after):
126 143 def trimcontext(number, lines):
127 144 delta = len(lines) - self.maxcontext
128 145 if False and delta > 0:
129 146 return number + delta, lines[:self.maxcontext]
130 147 return number, lines
131 148
132 149 self.header = header
133 150 self.fromline, self.before = trimcontext(fromline, before)
134 151 self.toline, self.after = trimcontext(toline, after)
135 152 self.proc = proc
136 153 self.hunk = hunk
137 154 self.added, self.removed = countchanges(self.hunk)
138 155
139 156 def write(self, fp):
140 157 delta = len(self.before) + len(self.after)
141 158 fromlen = delta + self.removed
142 159 tolen = delta + self.added
143 160 fp.write('@@ -%d,%d +%d,%d @@%s\n' %
144 161 (self.fromline, fromlen, self.toline, tolen,
145 162 self.proc and (' ' + self.proc)))
146 163 fp.write(''.join(self.before + self.hunk + self.after))
147 164
148 165 pretty = write
149 166
150 167 def filename(self):
151 168 return self.header.filename()
152 169
153 170 def __repr__(self):
154 171 return '<hunk %r@%d>' % (self.filename(), self.fromline)
155 172
156 173 def parsepatch(fp):
174 """patch -> [] of hunks """
157 175 class parser(object):
176 """patch parsing state machine"""
158 177 def __init__(self):
159 178 self.fromline = 0
160 179 self.toline = 0
161 180 self.proc = ''
162 181 self.header = None
163 182 self.context = []
164 183 self.before = []
165 184 self.hunk = []
166 185 self.stream = []
167 186
168 187 def addrange(self, (fromstart, fromend, tostart, toend, proc)):
169 188 self.fromline = int(fromstart)
170 189 self.toline = int(tostart)
171 190 self.proc = proc
172 191
173 192 def addcontext(self, context):
174 193 if self.hunk:
175 194 h = hunk(self.header, self.fromline, self.toline, self.proc,
176 195 self.before, self.hunk, context)
177 196 self.header.hunks.append(h)
178 197 self.stream.append(h)
179 198 self.fromline += len(self.before) + h.removed
180 199 self.toline += len(self.before) + h.added
181 200 self.before = []
182 201 self.hunk = []
183 202 self.proc = ''
184 203 self.context = context
185 204
186 205 def addhunk(self, hunk):
187 206 if self.context:
188 207 self.before = self.context
189 208 self.context = []
190 209 self.hunk = data
191 210
192 211 def newfile(self, hdr):
193 212 self.addcontext([])
194 213 h = header(hdr)
195 214 self.stream.append(h)
196 215 self.header = h
197 216
198 217 def finished(self):
199 218 self.addcontext([])
200 219 return self.stream
201 220
202 221 transitions = {
203 222 'file': {'context': addcontext,
204 223 'file': newfile,
205 224 'hunk': addhunk,
206 225 'range': addrange},
207 226 'context': {'file': newfile,
208 227 'hunk': addhunk,
209 228 'range': addrange},
210 229 'hunk': {'context': addcontext,
211 230 'file': newfile,
212 231 'range': addrange},
213 232 'range': {'context': addcontext,
214 233 'hunk': addhunk},
215 234 }
216 235
217 236 p = parser()
218 237
219 238 state = 'context'
220 239 for newstate, data in scanpatch(fp):
221 240 try:
222 241 p.transitions[state][newstate](p, data)
223 242 except KeyError:
224 243 raise patch.PatchError('unhandled transition: %s -> %s' %
225 244 (state, newstate))
226 245 state = newstate
227 246 return p.finished()
228 247
229 248 def filterpatch(ui, chunks):
249 """Interactively filter patch chunks into applied-only chunks"""
230 250 chunks = list(chunks)
231 251 chunks.reverse()
232 252 seen = {}
233 253 def consumefile():
254 """fetch next portion from chunks until a 'header' is seen
255 NB: header == new-file mark
256 """
234 257 consumed = []
235 258 while chunks:
236 259 if isinstance(chunks[-1], header):
237 260 break
238 261 else:
239 262 consumed.append(chunks.pop())
240 263 return consumed
241 resp_all = [None]
242 resp_file = [None]
243 applied = {}
264
265 resp_all = [None] # this two are changed from inside prompt,
266 resp_file = [None] # so can't be usual variables
267 applied = {} # 'filename' -> [] of chunks
244 268 def prompt(query):
269 """prompt query, and process base inputs
270
271 - y/n for the rest of file
272 - y/n for the rest
273 - ? (help)
274 - q (quit)
275
276 else, input is returned to the caller.
277 """
245 278 if resp_all[0] is not None:
246 279 return resp_all[0]
247 280 if resp_file[0] is not None:
248 281 return resp_file[0]
249 282 while True:
250 283 r = (ui.prompt(query + _(' [Ynsfdaq?] '), '(?i)[Ynsfdaq?]?$')
251 284 or 'y').lower()
252 285 if r == '?':
253 286 c = record.__doc__.find('y - record this change')
254 287 for l in record.__doc__[c:].splitlines():
255 288 if l: ui.write(_(l.strip()), '\n')
256 289 continue
257 290 elif r == 's':
258 291 r = resp_file[0] = 'n'
259 292 elif r == 'f':
260 293 r = resp_file[0] = 'y'
261 294 elif r == 'd':
262 295 r = resp_all[0] = 'n'
263 296 elif r == 'a':
264 297 r = resp_all[0] = 'y'
265 298 elif r == 'q':
266 299 raise util.Abort(_('user quit'))
267 300 return r
268 301 while chunks:
269 302 chunk = chunks.pop()
270 303 if isinstance(chunk, header):
304 # new-file mark
271 305 resp_file = [None]
272 306 fixoffset = 0
273 307 hdr = ''.join(chunk.header)
274 308 if hdr in seen:
275 309 consumefile()
276 310 continue
277 311 seen[hdr] = True
278 312 if resp_all[0] is None:
279 313 chunk.pretty(ui)
280 314 r = prompt(_('examine changes to %s?') %
281 315 _(' and ').join(map(repr, chunk.files())))
282 316 if r == 'y':
283 317 applied[chunk.filename()] = [chunk]
284 318 if chunk.allhunks():
285 319 applied[chunk.filename()] += consumefile()
286 320 else:
287 321 consumefile()
288 322 else:
323 # new hunk
289 324 if resp_file[0] is None and resp_all[0] is None:
290 325 chunk.pretty(ui)
291 326 r = prompt(_('record this change to %r?') %
292 327 chunk.filename())
293 328 if r == 'y':
294 329 if fixoffset:
295 330 chunk = copy.copy(chunk)
296 331 chunk.toline += fixoffset
297 332 applied[chunk.filename()].append(chunk)
298 333 else:
299 334 fixoffset += chunk.removed - chunk.added
300 335 return reduce(operator.add, [h for h in applied.itervalues()
301 336 if h[0].special() or len(h) > 1], [])
302 337
303 338 def record(ui, repo, *pats, **opts):
304 339 '''interactively select changes to commit
305 340
306 341 If a list of files is omitted, all changes reported by "hg status"
307 342 will be candidates for recording.
308 343
309 344 You will be prompted for whether to record changes to each
310 345 modified file, and for files with multiple changes, for each
311 346 change to use. For each query, the following responses are
312 347 possible:
313 348
314 349 y - record this change
315 350 n - skip this change
316 351
317 352 s - skip remaining changes to this file
318 353 f - record remaining changes to this file
319 354
320 355 d - done, skip remaining changes and files
321 356 a - record all changes to all remaining files
322 357 q - quit, recording no changes
323 358
324 359 ? - display help'''
325 360
326 361 if not ui.interactive:
327 362 raise util.Abort(_('running non-interactively, use commit instead'))
328 363
329 364 def recordfunc(ui, repo, files, message, match, opts):
330 365 if files:
331 366 changes = None
332 367 else:
333 368 changes = repo.status(files=files, match=match)[:5]
334 369 modified, added, removed = changes[:3]
335 370 files = modified + added + removed
336 371 diffopts = mdiff.diffopts(git=True, nodates=True)
337 372 fp = cStringIO.StringIO()
338 373 patch.diff(repo, repo.dirstate.parents()[0], files=files,
339 374 match=match, changes=changes, opts=diffopts, fp=fp)
340 375 fp.seek(0)
341 376
342 377 chunks = filterpatch(ui, parsepatch(fp))
343 378 del fp
344 379
345 380 contenders = {}
346 381 for h in chunks:
347 382 try: contenders.update(dict.fromkeys(h.files()))
348 383 except AttributeError: pass
349 384
350 385 newfiles = [f for f in files if f in contenders]
351 386
352 387 if not newfiles:
353 388 ui.status(_('no changes to record\n'))
354 389 return 0
355 390
356 391 if changes is None:
357 392 changes = repo.status(files=newfiles, match=match)[:5]
358 393 modified = dict.fromkeys(changes[0])
359 394
360 395 backups = {}
361 396 backupdir = repo.join('record-backups')
362 397 try:
363 398 os.mkdir(backupdir)
364 399 except OSError, err:
365 400 if err.errno != errno.EEXIST:
366 401 raise
367 402 try:
368 403 for f in newfiles:
369 404 if f not in modified:
370 405 continue
371 406 fd, tmpname = tempfile.mkstemp(prefix=f.replace('/', '_')+'.',
372 407 dir=backupdir)
373 408 os.close(fd)
374 409 ui.debug('backup %r as %r\n' % (f, tmpname))
375 410 util.copyfile(repo.wjoin(f), tmpname)
376 411 backups[f] = tmpname
377 412
378 413 fp = cStringIO.StringIO()
379 414 for c in chunks:
380 415 if c.filename() in backups:
381 416 c.write(fp)
382 417 dopatch = fp.tell()
383 418 fp.seek(0)
384 419
385 420 if backups:
386 421 hg.revert(repo, repo.dirstate.parents()[0], backups.has_key)
387 422
388 423 if dopatch:
389 424 ui.debug('applying patch\n')
390 425 ui.debug(fp.getvalue())
391 426 patch.internalpatch(fp, ui, 1, repo.root)
392 427 del fp
393 428
394 429 repo.commit(newfiles, message, opts['user'], opts['date'], match,
395 430 force_editor=opts.get('force_editor'))
396 431 return 0
397 432 finally:
398 433 try:
399 434 for realname, tmpname in backups.iteritems():
400 435 ui.debug('restoring %r to %r\n' % (tmpname, realname))
401 436 util.copyfile(tmpname, repo.wjoin(realname))
402 437 os.unlink(tmpname)
403 438 os.rmdir(backupdir)
404 439 except OSError:
405 440 pass
406 441 return cmdutil.commit(ui, repo, recordfunc, pats, opts)
407 442
408 443 cmdtable = {
409 444 "record":
410 445 (record,
411 446 [('A', 'addremove', None,
412 447 _('mark new/missing files as added/removed before committing')),
413 448 ] + commands.walkopts + commands.commitopts + commands.commitopts2,
414 449 _('hg record [OPTION]... [FILE]...')),
415 450 }
General Comments 0
You need to be logged in to leave comments. Login now