3 # Copyright 2007 Bryan O'Sullivan <bos@serpentine.com>
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2, incorporated herein by reference.
8 '''commands to interactively select changes for commit/qrefresh'''
10 from mercurial.i18n import gettext, _
11 from mercurial import cmdutil, commands, extensions, hg, mdiff, patch
12 from mercurial import util
13 import copy, cStringIO, errno, operator, os, re, tempfile
15 lines_re = re.compile(r'@@ -(\d+),(\d+) \+(\d+),(\d+) @@\s*(.*)')
18 """like patch.iterhunks, but yield different events
20 - ('file', [header_lines + fromfile + tofile])
21 - ('context', [context_lines])
22 - ('hunk', [hunk_lines])
23 - ('range', (-start,len, +start,len, diffp))
25 lr = patch.linereader(fp)
27 def scanwhile(first, p):
28 """scan lr while predicate holds"""
45 if line.startswith('diff --git a/'):
47 s = line.split(None, 1)
48 return not s or s[0] not in ('---', 'diff')
49 header = scanwhile(line, notheader)
50 fromfile = lr.readline()
51 if fromfile.startswith('---'):
52 tofile = lr.readline()
53 header += [fromfile, tofile]
58 yield 'context', scanwhile(line, lambda l: l[0] in ' \\')
60 yield 'hunk', scanwhile(line, lambda l: l[0] in '-+\\')
62 m = lines_re.match(line)
64 yield 'range', m.groups()
66 raise patch.PatchError('unknown patch content: %r' % line)
71 XXX shoudn't we move this to mercurial/patch.py ?
73 diff_re = re.compile('diff --git a/(.*) b/(.*)$')
74 allhunks_re = re.compile('(?:index|new file|deleted file) ')
75 pretty_re = re.compile('(?:new file|deleted file) ')
76 special_re = re.compile('(?:index|new|deleted|copy|rename) ')
78 def __init__(self, header):
84 if h.startswith('index '):
89 if h.startswith('index '):
90 fp.write(_('this modifies a binary file (all or nothing)\n'))
92 if self.pretty_re.match(h):
95 fp.write(_('this is a binary file\n'))
97 if h.startswith('---'):
98 fp.write(_('%d hunks, %d lines changed\n') %
100 sum([h.added + h.removed for h in self.hunks])))
105 fp.write(''.join(self.header))
108 for h in self.header:
109 if self.allhunks_re.match(h):
113 fromfile, tofile = self.diff_re.match(self.header[0]).groups()
114 if fromfile == tofile:
116 return [fromfile, tofile]
119 return self.files()[-1]
122 return '<header %s>' % (' '.join(map(repr, self.files())))
125 for h in self.header:
126 if self.special_re.match(h):
129 def countchanges(hunk):
130 """hunk -> (n+,n-)"""
131 add = len([h for h in hunk if h[0] == '+'])
132 rem = len([h for h in hunk if h[0] == '-'])
138 XXX shouldn't we merge this with patch.hunk ?
142 def __init__(self, header, fromline, toline, proc, before, hunk, after):
143 def trimcontext(number, lines):
144 delta = len(lines) - self.maxcontext
145 if False and delta > 0:
146 return number + delta, lines[:self.maxcontext]
150 self.fromline, self.before = trimcontext(fromline, before)
151 self.toline, self.after = trimcontext(toline, after)
154 self.added, self.removed = countchanges(self.hunk)
157 delta = len(self.before) + len(self.after)
158 if self.after and self.after[-1] == '\\ No newline at end of file\n':
160 fromlen = delta + self.removed
161 tolen = delta + self.added
162 fp.write('@@ -%d,%d +%d,%d @@%s\n' %
163 (self.fromline, fromlen, self.toline, tolen,
164 self.proc and (' ' + self.proc)))
165 fp.write(''.join(self.before + self.hunk + self.after))
170 return self.header.filename()
173 return '<hunk %r@%d>' % (self.filename(), self.fromline)
176 """patch -> [] of hunks """
177 class parser(object):
178 """patch parsing state machine"""
189 def addrange(self, (fromstart, fromend, tostart, toend, proc)):
190 self.fromline = int(fromstart)
191 self.toline = int(tostart)
194 def addcontext(self, context):
196 h = hunk(self.header, self.fromline, self.toline, self.proc,
197 self.before, self.hunk, context)
198 self.header.hunks.append(h)
199 self.stream.append(h)
200 self.fromline += len(self.before) + h.removed
201 self.toline += len(self.before) + h.added
205 self.context = context
207 def addhunk(self, hunk):
209 self.before = self.context
213 def newfile(self, hdr):
216 self.stream.append(h)
224 'file': {'context': addcontext,
228 'context': {'file': newfile,
231 'hunk': {'context': addcontext,
234 'range': {'context': addcontext,
241 for newstate, data in scanpatch(fp):
243 p.transitions[state][newstate](p, data)
245 raise patch.PatchError('unhandled transition: %s -> %s' %
250 def filterpatch(ui, chunks):
251 """Interactively filter patch chunks into applied-only chunks"""
252 chunks = list(chunks)
256 """fetch next portion from chunks until a 'header' is seen
257 NB: header == new-file mark
261 if isinstance(chunks[-1], header):
264 consumed.append(chunks.pop())
267 resp_all = [None] # this two are changed from inside prompt,
268 resp_file = [None] # so can't be usual variables
269 applied = {} # 'filename' -> [] of chunks
271 """prompt query, and process base inputs
273 - y/n for the rest of file
278 else, input is returned to the caller.
280 if resp_all[0] is not None:
282 if resp_file[0] is not None:
285 resps = _('[Ynsfdaq?]')
286 choices = (_('&Yes, record this change'),
287 _('&No, skip this change'),
288 _('&Skip remaining changes to this file'),
289 _('Record remaining changes to this &file'),
290 _('&Done, skip remaining changes and files'),
291 _('Record &all changes to all remaining files'),
292 _('&Quit, recording no changes'),
294 r = ui.promptchoice("%s %s " % (query, resps), choices)
296 doc = gettext(record.__doc__)
297 c = doc.find(_('y - record this change'))
298 for l in doc[c:].splitlines():
299 if l: ui.write(l.strip(), '\n')
306 ret = resp_file[0] = 'n'
307 elif r == 3: # file (Record remaining)
308 ret = resp_file[0] = 'y'
309 elif r == 4: # done, skip remaining
310 ret = resp_all[0] = 'n'
312 ret = resp_all[0] = 'y'
314 raise util.Abort(_('user quit'))
316 pos, total = 0, len(chunks) - 1
319 if isinstance(chunk, header):
323 hdr = ''.join(chunk.header)
328 if resp_all[0] is None:
330 r = prompt(_('examine changes to %s?') %
331 _(' and ').join(map(repr, chunk.files())))
333 applied[chunk.filename()] = [chunk]
335 applied[chunk.filename()] += consumefile()
340 if resp_file[0] is None and resp_all[0] is None:
342 r = total == 1 and prompt(_('record this change to %r?') %
344 or prompt(_('record change %d/%d to %r?') %
345 (pos, total, chunk.filename()))
348 chunk = copy.copy(chunk)
349 chunk.toline += fixoffset
350 applied[chunk.filename()].append(chunk)
352 fixoffset += chunk.removed - chunk.added
354 return reduce(operator.add, [h for h in applied.itervalues()
355 if h[0].special() or len(h) > 1], [])
357 def record(ui, repo, *pats, **opts):
358 '''interactively select changes to commit
360 If a list of files is omitted, all changes reported by "hg status"
361 will be candidates for recording.
363 See 'hg help dates' for a list of formats valid for -d/--date.
365 You will be prompted for whether to record changes to each
366 modified file, and for files with multiple changes, for each
367 change to use. For each query, the following responses are
370 y - record this change
373 s - skip remaining changes to this file
374 f - record remaining changes to this file
376 d - done, skip remaining changes and files
377 a - record all changes to all remaining files
378 q - quit, recording no changes
382 def record_committer(ui, repo, pats, opts):
383 commands.commit(ui, repo, *pats, **opts)
385 dorecord(ui, repo, record_committer, *pats, **opts)
388 def qrecord(ui, repo, patch, *pats, **opts):
389 '''interactively record a new patch
391 See 'hg help qnew' & 'hg help record' for more information and
396 mq = extensions.find('mq')
398 raise util.Abort(_("'mq' extension not loaded"))
400 def qrecord_committer(ui, repo, pats, opts):
401 mq.new(ui, repo, patch, *pats, **opts)
404 opts['force'] = True # always 'qnew -f'
405 dorecord(ui, repo, qrecord_committer, *pats, **opts)
408 def dorecord(ui, repo, committer, *pats, **opts):
409 if not ui.interactive():
410 raise util.Abort(_('running non-interactively, use commit instead'))
412 def recordfunc(ui, repo, message, match, opts):
413 """This is generic record driver.
415 Its job is to interactively filter local changes, and accordingly
416 prepare working dir into a state, where the job can be delegated to
417 non-interactive commit command such as 'commit' or 'qrefresh'.
419 After the actual job is done by non-interactive command, working dir
420 state is restored to original.
422 In the end we'll record intresting changes, and everything else will be
423 left in place, so the user can continue his work.
426 changes = repo.status(match=match)[:3]
427 diffopts = mdiff.diffopts(git=True, nodates=True)
428 chunks = patch.diff(repo, changes=changes, opts=diffopts)
429 fp = cStringIO.StringIO()
430 fp.write(''.join(chunks))
433 # 1. filter patch, so we have intending-to apply subset of it
434 chunks = filterpatch(ui, parsepatch(fp))
439 try: contenders.update(set(h.files()))
440 except AttributeError: pass
442 changed = changes[0] + changes[1] + changes[2]
443 newfiles = [f for f in changed if f in contenders]
445 ui.status(_('no changes to record\n'))
448 modified = set(changes[0])
450 # 2. backup changed files, so we can restore them in the end
452 backupdir = repo.join('record-backups')
456 if err.errno != errno.EEXIST:
461 if f not in modified:
463 fd, tmpname = tempfile.mkstemp(prefix=f.replace('/', '_')+'.',
466 ui.debug(_('backup %r as %r\n') % (f, tmpname))
467 util.copyfile(repo.wjoin(f), tmpname)
470 fp = cStringIO.StringIO()
472 if c.filename() in backups:
477 # 3a. apply filtered patch to clean repo (clean)
479 hg.revert(repo, repo.dirstate.parents()[0], backups.has_key)
484 ui.debug(_('applying patch\n'))
485 ui.debug(fp.getvalue())
487 patch.internalpatch(fp, ui, 1, repo.root, files=pfiles,
489 patch.updatedir(ui, repo, pfiles)
490 except patch.PatchError, err:
495 raise util.Abort(_('patch failed to apply'))
498 # 4. We prepared working directory according to filtered patch.
499 # Now is the time to delegate the job to commit/qrefresh or the like!
501 # it is important to first chdir to repo root -- we'll call a
502 # highlevel command with list of pathnames relative to repo root
506 committer(ui, repo, newfiles, opts)
512 # 5. finally restore backed-up files
514 for realname, tmpname in backups.iteritems():
515 ui.debug(_('restoring %r to %r\n') % (tmpname, realname))
516 util.copyfile(tmpname, repo.wjoin(realname))
521 return cmdutil.commit(ui, repo, recordfunc, pats, opts)
528 commands.table['^commit|ci'][1],
530 _('hg record [OPTION]... [FILE]...')),
536 mq = extensions.find('mq')
544 # add qnew options, except '--force'
545 [opt for opt in mq.cmdtable['qnew'][1] if opt[1] != 'force'],
547 _('hg qrecord [OPTION]... PATCH [FILE]...')),
550 cmdtable.update(qcmdtable)