]> git.lizzy.rs Git - plan9front.git/blob - sys/lib/python/hgext/record.py
hgwebfs: write headers individually, so they are not limited by webfs iounit (thanks...
[plan9front.git] / sys / lib / python / hgext / record.py
1 # record.py
2 #
3 # Copyright 2007 Bryan O'Sullivan <bos@serpentine.com>
4 #
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.
7
8 '''commands to interactively select changes for commit/qrefresh'''
9
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
14
15 lines_re = re.compile(r'@@ -(\d+),(\d+) \+(\d+),(\d+) @@\s*(.*)')
16
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     """
25     lr = patch.linereader(fp)
26
27     def scanwhile(first, p):
28         """scan lr while predicate holds"""
29         lines = [first]
30         while True:
31             line = lr.readline()
32             if not line:
33                 break
34             if p(line):
35                 lines.append(line)
36             else:
37                 lr.push(line)
38                 break
39         return lines
40
41     while True:
42         line = lr.readline()
43         if not line:
44             break
45         if line.startswith('diff --git a/'):
46             def notheader(line):
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]
54             else:
55                 lr.push(fromfile)
56             yield 'file', header
57         elif line[0] == ' ':
58             yield 'context', scanwhile(line, lambda l: l[0] in ' \\')
59         elif line[0] in '-+':
60             yield 'hunk', scanwhile(line, lambda l: l[0] in '-+\\')
61         else:
62             m = lines_re.match(line)
63             if m:
64                 yield 'range', m.groups()
65             else:
66                 raise patch.PatchError('unknown patch content: %r' % line)
67
68 class header(object):
69     """patch header
70
71     XXX shoudn't we move this to mercurial/patch.py ?
72     """
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) ')
77
78     def __init__(self, header):
79         self.header = header
80         self.hunks = []
81
82     def binary(self):
83         for h in self.header:
84             if h.startswith('index '):
85                 return True
86
87     def pretty(self, fp):
88         for h in self.header:
89             if h.startswith('index '):
90                 fp.write(_('this modifies a binary file (all or nothing)\n'))
91                 break
92             if self.pretty_re.match(h):
93                 fp.write(h)
94                 if self.binary():
95                     fp.write(_('this is a binary file\n'))
96                 break
97             if h.startswith('---'):
98                 fp.write(_('%d hunks, %d lines changed\n') %
99                          (len(self.hunks),
100                           sum([h.added + h.removed for h in self.hunks])))
101                 break
102             fp.write(h)
103
104     def write(self, fp):
105         fp.write(''.join(self.header))
106
107     def allhunks(self):
108         for h in self.header:
109             if self.allhunks_re.match(h):
110                 return True
111
112     def files(self):
113         fromfile, tofile = self.diff_re.match(self.header[0]).groups()
114         if fromfile == tofile:
115             return [fromfile]
116         return [fromfile, tofile]
117
118     def filename(self):
119         return self.files()[-1]
120
121     def __repr__(self):
122         return '<header %s>' % (' '.join(map(repr, self.files())))
123
124     def special(self):
125         for h in self.header:
126             if self.special_re.match(h):
127                 return True
128
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] == '-'])
133     return add, rem
134
135 class hunk(object):
136     """patch hunk
137
138     XXX shouldn't we merge this with patch.hunk ?
139     """
140     maxcontext = 3
141
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]
147             return number, lines
148
149         self.header = header
150         self.fromline, self.before = trimcontext(fromline, before)
151         self.toline, self.after = trimcontext(toline, after)
152         self.proc = proc
153         self.hunk = hunk
154         self.added, self.removed = countchanges(self.hunk)
155
156     def write(self, fp):
157         delta = len(self.before) + len(self.after)
158         if self.after and self.after[-1] == '\\ No newline at end of file\n':
159             delta -= 1
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))
166
167     pretty = write
168
169     def filename(self):
170         return self.header.filename()
171
172     def __repr__(self):
173         return '<hunk %r@%d>' % (self.filename(), self.fromline)
174
175 def parsepatch(fp):
176     """patch -> [] of hunks """
177     class parser(object):
178         """patch parsing state machine"""
179         def __init__(self):
180             self.fromline = 0
181             self.toline = 0
182             self.proc = ''
183             self.header = None
184             self.context = []
185             self.before = []
186             self.hunk = []
187             self.stream = []
188
189         def addrange(self, (fromstart, fromend, tostart, toend, proc)):
190             self.fromline = int(fromstart)
191             self.toline = int(tostart)
192             self.proc = proc
193
194         def addcontext(self, context):
195             if self.hunk:
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
202                 self.before = []
203                 self.hunk = []
204                 self.proc = ''
205             self.context = context
206
207         def addhunk(self, hunk):
208             if self.context:
209                 self.before = self.context
210                 self.context = []
211             self.hunk = hunk
212
213         def newfile(self, hdr):
214             self.addcontext([])
215             h = header(hdr)
216             self.stream.append(h)
217             self.header = h
218
219         def finished(self):
220             self.addcontext([])
221             return self.stream
222
223         transitions = {
224             'file': {'context': addcontext,
225                      'file': newfile,
226                      'hunk': addhunk,
227                      'range': addrange},
228             'context': {'file': newfile,
229                         'hunk': addhunk,
230                         'range': addrange},
231             'hunk': {'context': addcontext,
232                      'file': newfile,
233                      'range': addrange},
234             'range': {'context': addcontext,
235                       'hunk': addhunk},
236             }
237
238     p = parser()
239
240     state = 'context'
241     for newstate, data in scanpatch(fp):
242         try:
243             p.transitions[state][newstate](p, data)
244         except KeyError:
245             raise patch.PatchError('unhandled transition: %s -> %s' %
246                                    (state, newstate))
247         state = newstate
248     return p.finished()
249
250 def filterpatch(ui, chunks):
251     """Interactively filter patch chunks into applied-only chunks"""
252     chunks = list(chunks)
253     chunks.reverse()
254     seen = set()
255     def consumefile():
256         """fetch next portion from chunks until a 'header' is seen
257         NB: header == new-file mark
258         """
259         consumed = []
260         while chunks:
261             if isinstance(chunks[-1], header):
262                 break
263             else:
264                 consumed.append(chunks.pop())
265         return consumed
266
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
270     def prompt(query):
271         """prompt query, and process base inputs
272
273         - y/n for the rest of file
274         - y/n for the rest
275         - ? (help)
276         - q (quit)
277
278         else, input is returned to the caller.
279         """
280         if resp_all[0] is not None:
281             return resp_all[0]
282         if resp_file[0] is not None:
283             return resp_file[0]
284         while True:
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'),
293                     _('&?'))
294             r = ui.promptchoice("%s %s " % (query, resps), choices)
295             if r == 7: # ?
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')
300                 continue
301             elif r == 0: # yes
302                 ret = 'y'
303             elif r == 1: # no
304                 ret = 'n'
305             elif r == 2: # Skip
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'
311             elif r == 5: # all
312                 ret = resp_all[0] = 'y'
313             elif r == 6: # quit
314                 raise util.Abort(_('user quit'))
315             return ret
316     pos, total = 0, len(chunks) - 1
317     while chunks:
318         chunk = chunks.pop()
319         if isinstance(chunk, header):
320             # new-file mark
321             resp_file = [None]
322             fixoffset = 0
323             hdr = ''.join(chunk.header)
324             if hdr in seen:
325                 consumefile()
326                 continue
327             seen.add(hdr)
328             if resp_all[0] is None:
329                 chunk.pretty(ui)
330             r = prompt(_('examine changes to %s?') %
331                        _(' and ').join(map(repr, chunk.files())))
332             if r == _('y'):
333                 applied[chunk.filename()] = [chunk]
334                 if chunk.allhunks():
335                     applied[chunk.filename()] += consumefile()
336             else:
337                 consumefile()
338         else:
339             # new hunk
340             if resp_file[0] is None and resp_all[0] is None:
341                 chunk.pretty(ui)
342             r = total == 1 and prompt(_('record this change to %r?') %
343                                       chunk.filename()) \
344                            or  prompt(_('record change %d/%d to %r?') %
345                                       (pos, total, chunk.filename()))
346             if r == _('y'):
347                 if fixoffset:
348                     chunk = copy.copy(chunk)
349                     chunk.toline += fixoffset
350                 applied[chunk.filename()].append(chunk)
351             else:
352                 fixoffset += chunk.removed - chunk.added
353         pos = pos + 1
354     return reduce(operator.add, [h for h in applied.itervalues()
355                                  if h[0].special() or len(h) > 1], [])
356
357 def record(ui, repo, *pats, **opts):
358     '''interactively select changes to commit
359
360     If a list of files is omitted, all changes reported by "hg status"
361     will be candidates for recording.
362
363     See 'hg help dates' for a list of formats valid for -d/--date.
364
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
368     possible::
369
370       y - record this change
371       n - skip this change
372
373       s - skip remaining changes to this file
374       f - record remaining changes to this file
375
376       d - done, skip remaining changes and files
377       a - record all changes to all remaining files
378       q - quit, recording no changes
379
380       ? - display help'''
381
382     def record_committer(ui, repo, pats, opts):
383         commands.commit(ui, repo, *pats, **opts)
384
385     dorecord(ui, repo, record_committer, *pats, **opts)
386
387
388 def qrecord(ui, repo, patch, *pats, **opts):
389     '''interactively record a new patch
390
391     See 'hg help qnew' & 'hg help record' for more information and
392     usage.
393     '''
394
395     try:
396         mq = extensions.find('mq')
397     except KeyError:
398         raise util.Abort(_("'mq' extension not loaded"))
399
400     def qrecord_committer(ui, repo, pats, opts):
401         mq.new(ui, repo, patch, *pats, **opts)
402
403     opts = opts.copy()
404     opts['force'] = True    # always 'qnew -f'
405     dorecord(ui, repo, qrecord_committer, *pats, **opts)
406
407
408 def dorecord(ui, repo, committer, *pats, **opts):
409     if not ui.interactive():
410         raise util.Abort(_('running non-interactively, use commit instead'))
411
412     def recordfunc(ui, repo, message, match, opts):
413         """This is generic record driver.
414
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'.
418
419         After the actual job is done by non-interactive command, working dir
420         state is restored to original.
421
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.
424         """
425
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))
431         fp.seek(0)
432
433         # 1. filter patch, so we have intending-to apply subset of it
434         chunks = filterpatch(ui, parsepatch(fp))
435         del fp
436
437         contenders = set()
438         for h in chunks:
439             try: contenders.update(set(h.files()))
440             except AttributeError: pass
441
442         changed = changes[0] + changes[1] + changes[2]
443         newfiles = [f for f in changed if f in contenders]
444         if not newfiles:
445             ui.status(_('no changes to record\n'))
446             return 0
447
448         modified = set(changes[0])
449
450         # 2. backup changed files, so we can restore them in the end
451         backups = {}
452         backupdir = repo.join('record-backups')
453         try:
454             os.mkdir(backupdir)
455         except OSError, err:
456             if err.errno != errno.EEXIST:
457                 raise
458         try:
459             # backup continues
460             for f in newfiles:
461                 if f not in modified:
462                     continue
463                 fd, tmpname = tempfile.mkstemp(prefix=f.replace('/', '_')+'.',
464                                                dir=backupdir)
465                 os.close(fd)
466                 ui.debug(_('backup %r as %r\n') % (f, tmpname))
467                 util.copyfile(repo.wjoin(f), tmpname)
468                 backups[f] = tmpname
469
470             fp = cStringIO.StringIO()
471             for c in chunks:
472                 if c.filename() in backups:
473                     c.write(fp)
474             dopatch = fp.tell()
475             fp.seek(0)
476
477             # 3a. apply filtered patch to clean repo  (clean)
478             if backups:
479                 hg.revert(repo, repo.dirstate.parents()[0], backups.has_key)
480
481             # 3b. (apply)
482             if dopatch:
483                 try:
484                     ui.debug(_('applying patch\n'))
485                     ui.debug(fp.getvalue())
486                     pfiles = {}
487                     patch.internalpatch(fp, ui, 1, repo.root, files=pfiles,
488                                         eolmode=None)
489                     patch.updatedir(ui, repo, pfiles)
490                 except patch.PatchError, err:
491                     s = str(err)
492                     if s:
493                         raise util.Abort(s)
494                     else:
495                         raise util.Abort(_('patch failed to apply'))
496             del fp
497
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!
500
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
503             cwd = os.getcwd()
504             os.chdir(repo.root)
505             try:
506                 committer(ui, repo, newfiles, opts)
507             finally:
508                 os.chdir(cwd)
509
510             return 0
511         finally:
512             # 5. finally restore backed-up files
513             try:
514                 for realname, tmpname in backups.iteritems():
515                     ui.debug(_('restoring %r to %r\n') % (tmpname, realname))
516                     util.copyfile(tmpname, repo.wjoin(realname))
517                     os.unlink(tmpname)
518                 os.rmdir(backupdir)
519             except OSError:
520                 pass
521     return cmdutil.commit(ui, repo, recordfunc, pats, opts)
522
523 cmdtable = {
524     "record":
525         (record,
526
527          # add commit options
528          commands.table['^commit|ci'][1],
529
530          _('hg record [OPTION]... [FILE]...')),
531 }
532
533
534 def extsetup():
535     try:
536         mq = extensions.find('mq')
537     except KeyError:
538         return
539
540     qcmdtable = {
541     "qrecord":
542         (qrecord,
543
544          # add qnew options, except '--force'
545          [opt for opt in mq.cmdtable['qnew'][1] if opt[1] != 'force'],
546
547          _('hg qrecord [OPTION]... PATCH [FILE]...')),
548     }
549
550     cmdtable.update(qcmdtable)
551