Ticket #9058: hgshelve.py

File hgshelve.py, 21.1 KB (added by nielx, 8 years ago)

Another test

Line 
1# shelve.py
2#
3# Copyright 2007 Bryan O'Sullivan <bos@serpentine.com>
4# Copyright 2007 TK Soh <teekaysoh@gmailcom>
5#
6# This software may be used and distributed according to the terms of
7# the GNU General Public License, incorporated herein by reference.
8
9'''interactive change selection to set aside that may be restored later'''
10
11from mercurial.i18n import _
12from mercurial import cmdutil, commands, cmdutil, hg, mdiff, patch, revlog
13from mercurial import util, fancyopts, extensions
14import copy, cStringIO, errno, operator, os, re, shutil, tempfile
15
16lines_re = re.compile(r'@@ -(\d+),(\d+) \+(\d+),(\d+) @@\s*(.*)')
17
18def scanpatch(fp):
19 lr = patch.linereader(fp)
20
21 def scanwhile(first, p):
22 lines = [first]
23 while True:
24 line = lr.readline()
25 if not line:
26 break
27 if p(line):
28 lines.append(line)
29 else:
30 lr.push(line)
31 break
32 return lines
33
34 while True:
35 line = lr.readline()
36 if not line:
37 break
38 if line.startswith('diff --git a/'):
39 def notheader(line):
40 s = line.split(None, 1)
41 return not s or s[0] not in ('---', 'diff')
42 header = scanwhile(line, notheader)
43 fromfile = lr.readline()
44 if fromfile.startswith('---'):
45 tofile = lr.readline()
46 header += [fromfile, tofile]
47 else:
48 lr.push(fromfile)
49 yield 'file', header
50 elif line[0] == ' ':
51 yield 'context', scanwhile(line, lambda l: l[0] in ' \\')
52 elif line[0] in '-+':
53 yield 'hunk', scanwhile(line, lambda l: l[0] in '-+\\')
54 else:
55 m = lines_re.match(line)
56 if m:
57 yield 'range', m.groups()
58 else:
59 raise patch.PatchError('unknown patch content: %r' % line)
60
61class header(object):
62 diff_re = re.compile('diff --git a/(.*) b/(.*)$')
63 allhunks_re = re.compile('(?:index|new file|deleted file) ')
64 pretty_re = re.compile('(?:new file|deleted file) ')
65 special_re = re.compile('(?:index|new|deleted|copy|rename) ')
66
67 def __init__(self, header):
68 self.header = header
69 self.hunks = []
70
71 def binary(self):
72 for h in self.header:
73 if h.startswith('index '):
74 return True
75
76 def pretty(self, fp):
77 for h in self.header:
78 if h.startswith('index '):
79 fp.write(_('this modifies a binary file (all or nothing)\n'))
80 break
81 if self.pretty_re.match(h):
82 fp.write(h)
83 if self.binary():
84 fp.write(_('this is a binary file\n'))
85 break
86 if h.startswith('---'):
87 fp.write(_('%d hunks, %d lines changed\n') %
88 (len(self.hunks),
89 sum([h.added + h.removed for h in self.hunks])))
90 break
91 fp.write(h)
92
93 def write(self, fp):
94 fp.write(''.join(self.header))
95
96 def allhunks(self):
97 for h in self.header:
98 if self.allhunks_re.match(h):
99 return True
100
101 def files(self):
102 fromfile, tofile = self.diff_re.match(self.header[0]).groups()
103 if fromfile == tofile:
104 return [fromfile]
105 return [fromfile, tofile]
106
107 def filename(self):
108 return self.files()[-1]
109
110 def __repr__(self):
111 return '<header %s>' % (' '.join(map(repr, self.files())))
112
113 def special(self):
114 for h in self.header:
115 if self.special_re.match(h):
116 return True
117
118def countchanges(hunk):
119 add = len([h for h in hunk if h[0] == '+'])
120 rem = len([h for h in hunk if h[0] == '-'])
121 return add, rem
122
123class hunk(object):
124 maxcontext = 3
125
126 def __init__(self, header, fromline, toline, proc, before, hunk, after):
127 def trimcontext(number, lines):
128 delta = len(lines) - self.maxcontext
129 if False and delta > 0:
130 return number + delta, lines[:self.maxcontext]
131 return number, lines
132
133 self.header = header
134 self.fromline, self.before = trimcontext(fromline, before)
135 self.toline, self.after = trimcontext(toline, after)
136 self.proc = proc
137 self.hunk = hunk
138 self.added, self.removed = countchanges(self.hunk)
139
140 def __cmp__(self, rhs):
141 # since the hunk().toline needs to be adjusted when hunks are
142 # removed/added, we can't take it into account when we cmp
143 attrs = ['header', 'fromline', 'proc', 'hunk', 'added', 'removed']
144 for attr in attrs:
145 selfattr = getattr(self, attr, None)
146 rhsattr = getattr(rhs, attr, None)
147
148 if selfattr is None or rhsattr is None:
149 raise util.Abort(_('non-existant attribute %s') % attr)
150
151 rv = cmp(selfattr, rhsattr)
152 if rv != 0:
153 return rv
154 return rv
155
156
157 def write(self, fp):
158 delta = len(self.before) + len(self.after)
159 if self.after and self.after[-1] == '\\ No newline at end of file\n':
160 delta -= 1
161 fromlen = delta + self.removed
162 tolen = delta + self.added
163 fp.write('@@ -%d,%d +%d,%d @@%s\n' %
164 (self.fromline, fromlen, self.toline, tolen,
165 self.proc and (' ' + self.proc)))
166 fp.write(''.join(self.before + self.hunk + self.after))
167
168 pretty = write
169
170 def filename(self):
171 return self.header.filename()
172
173 def __repr__(self):
174 return '<hunk %r@%d>' % (self.filename(), self.fromline)
175
176def parsepatch(fp):
177 class parser(object):
178 def __init__(self):
179 self.fromline = 0
180 self.toline = 0
181 self.proc = ''
182 self.header = None
183 self.context = []
184 self.before = []
185 self.hunk = []
186 self.stream = []
187
188 def addrange(self, (fromstart, fromend, tostart, toend, proc)):
189 self.fromline = int(fromstart)
190 self.toline = int(tostart)
191 self.proc = proc
192
193 def addcontext(self, context):
194 if self.hunk:
195 h = hunk(self.header, self.fromline, self.toline, self.proc,
196 self.before, self.hunk, context)
197 self.header.hunks.append(h)
198 self.stream.append(h)
199 self.fromline += len(self.before) + h.removed
200 self.toline += len(self.before) + h.added
201 self.before = []
202 self.hunk = []
203 self.proc = ''
204 self.context = context
205
206 def addhunk(self, hunk):
207 if self.context:
208 self.before = self.context
209 self.context = []
210 self.hunk = hunk
211
212 def newfile(self, hdr):
213 self.addcontext([])
214 h = header(hdr)
215 self.stream.append(h)
216 self.header = h
217
218 def finished(self):
219 self.addcontext([])
220 return self.stream
221
222 transitions = {
223 'file': {'context': addcontext,
224 'file': newfile,
225 'hunk': addhunk,
226 'range': addrange},
227 'context': {'file': newfile,
228 'hunk': addhunk,
229 'range': addrange},
230 'hunk': {'context': addcontext,
231 'file': newfile,
232 'range': addrange},
233 'range': {'context': addcontext,
234 'hunk': addhunk},
235 }
236
237 p = parser()
238
239 state = 'context'
240 for newstate, data in scanpatch(fp):
241 try:
242 p.transitions[state][newstate](p, data)
243 except KeyError:
244 raise patch.PatchError('unhandled transition: %s -> %s' %
245 (state, newstate))
246 state = newstate
247 return p.finished()
248
249def filterpatch(ui, chunks, shouldprompt=True):
250 chunks = list(chunks)
251 chunks.reverse()
252 seen = {}
253 def consumefile():
254 consumed = []
255 while chunks:
256 if isinstance(chunks[-1], header):
257 break
258 else:
259 consumed.append(chunks.pop())
260 return consumed
261
262 resp_all = [None]
263
264 """ If we're not to prompt (i.e. they specified the --all flag)
265 we pre-emptively set the 'all' flag """
266 if shouldprompt == False:
267 resp_all = ['y']
268
269 resp_file = [None]
270 applied = {}
271 def prompt(query):
272 if resp_all[0] is not None:
273 return resp_all[0]
274 if resp_file[0] is not None:
275 return resp_file[0]
276 while True:
277 resps = _('[Ynsfdaq?]')
278 choices = (_('&Yes, shelve this change'),
279 _('&No, skip this change'),
280 _('&Skip remaining changes to this file'),
281 _('Shelve remaining changes to this &file'),
282 _('&Done, skip remaining changes and files'),
283 _('Shelve &all changes to all remaining files'),
284 _('&Quit, shelving no changes'),
285 _('&?'))
286 r = ui.promptchoice("%s %s " % (query, resps), choices)
287 if r == 7:
288 c = shelve.__doc__.find('y - shelve this change')
289 for l in shelve.__doc__[c:].splitlines():
290 if l: ui.write(_(l.strip()) + '\n')
291 continue
292 elif r == 0: # yes
293 ret = 'y'
294 elif r == 1: # no
295 ret = 'n'
296 elif r == 2: # Skip
297 ret = resp_file[0] = 'n'
298 elif r == 3: # file (shelve remaining)
299 ret = resp_file[0] = 'y'
300 elif r == 4: # done, skip remaining
301 ret = resp_all[0] = 'n'
302 elif r == 5: # all
303 ret = resp_all[0] = 'y'
304 elif r == 6: # quit
305 raise util.Abort(_('user quit'))
306 return ret
307 while chunks:
308 chunk = chunks.pop()
309 if isinstance(chunk, header):
310 resp_file = [None]
311 fixoffset = 0
312 hdr = ''.join(chunk.header)
313 if hdr in seen:
314 consumefile()
315 continue
316 seen[hdr] = True
317 if resp_all[0] is None:
318 chunk.pretty(ui)
319 if shouldprompt == True:
320 r = prompt(_('shelve changes to %s?') %
321 _(' and ').join(map(repr, chunk.files())))
322 else:
323 r = 'y'
324
325 if r == 'y':
326 applied[chunk.filename()] = [chunk]
327 if chunk.allhunks():
328 applied[chunk.filename()] += consumefile()
329 else:
330 consumefile()
331 else:
332 if resp_file[0] is None and resp_all[0] is None:
333 chunk.pretty(ui)
334 r = prompt(_('shelve this change to %r?') %
335 chunk.filename())
336 if r == 'y':
337 if fixoffset:
338 chunk = copy.copy(chunk)
339 chunk.toline += fixoffset
340 applied[chunk.filename()].append(chunk)
341 else:
342 fixoffset += chunk.removed - chunk.added
343 return reduce(operator.add, [h for h in applied.itervalues()
344 if h[0].special() or len(h) > 1], [])
345
346def refilterpatch(allchunk, selected):
347 ''' return unshelved chunks of files to be shelved '''
348 l = []
349 fil = []
350 for c in allchunk:
351 if isinstance(c, header):
352 if len(l) > 1 and l[0] in selected:
353 fil += l
354 l = [c]
355 elif c not in selected:
356 l.append(c)
357 if len(l) > 1 and l[0] in selected:
358 fil += l
359 return fil
360
361def makebackup(ui, repo, dir, files):
362 try:
363 os.mkdir(dir)
364 except OSError, err:
365 if err.errno != errno.EEXIST:
366 raise
367
368 backups = {}
369 for f in files:
370 fd, tmpname = tempfile.mkstemp(prefix=f.replace('/', '_')+'.',
371 dir=dir)
372 os.close(fd)
373 ui.debug('backup %r as %r\n' % (f, tmpname))
374 util.copyfile(repo.wjoin(f), tmpname)
375 backups[f] = tmpname
376
377 return backups
378
379def getshelfpath(repo, name):
380 if name:
381 shelfpath = "shelves/" + name
382 else:
383 # Check if a shelf from an older version exists
384 if os.path.isfile(repo.join('shelve')):
385 shelfpath = 'shelve'
386 else:
387 shelfpath = "shelves/default"
388
389 return shelfpath
390
391def shelve(ui, repo, *pats, **opts):
392 '''interactively select changes to set aside
393
394 If a list of files is omitted, all changes reported by "hg status"
395 will be candidates for shelving.
396
397 You will be prompted for whether to shelve changes to each
398 modified file, and for files with multiple changes, for each
399 change to use.
400
401 The shelve command works with the Color extension to display
402 diffs in color.
403
404 On each prompt, the following responses are possible::
405
406 y - shelve this change
407 n - skip this change
408
409 s - skip remaining changes to this file
410 f - shelve remaining changes to this file
411
412 d - done, skip remaining changes and files
413 a - shelve all changes to all remaining files
414 q - quit, shelving no changes
415
416 ? - display help'''
417
418 if not ui.interactive:
419 raise util.Abort(_('shelve can only be run interactively'))
420
421 # List all the active shelves by name and return '
422 if opts['list']:
423 listshelves(ui,repo)
424 return
425
426 forced = opts['force'] or opts['append']
427
428 # Shelf name and path
429 shelfname = opts.get('name')
430 shelfpath = getshelfpath(repo, shelfname)
431
432 if os.path.exists(repo.join(shelfpath)) and not forced:
433 raise util.Abort(_('shelve data already exists'))
434
435 def shelvefunc(ui, repo, message, match, opts):
436 changes = repo.status(match=match)[:5]
437 modified, added, removed = changes[:3]
438 files = modified + added + removed
439 diffopts = mdiff.diffopts(git=True, nodates=True)
440 patch_diff = ''.join(patch.diff(repo, repo.dirstate.parents()[0],
441 match=match, changes=changes, opts=diffopts))
442
443 fp = cStringIO.StringIO(patch_diff)
444 ac = parsepatch(fp)
445 fp.close()
446
447 chunks = filterpatch(ui, ac, not opts['all'])
448 rc = refilterpatch(ac, chunks)
449
450 contenders = {}
451 for h in chunks:
452 try: contenders.update(dict.fromkeys(h.files()))
453 except AttributeError: pass
454
455 newfiles = [f for f in files if f in contenders]
456
457 if not newfiles:
458 ui.status(_('no changes to shelve\n'))
459 return 0
460
461 modified = dict.fromkeys(changes[0])
462
463 backupdir = repo.join('shelve-backups')
464
465 try:
466 bkfiles = [f for f in newfiles if f in modified]
467 backups = makebackup(ui, repo, backupdir, bkfiles)
468
469 # patch to shelve
470 sp = cStringIO.StringIO()
471 for c in chunks:
472 if c.filename() in backups:
473 c.write(sp)
474 doshelve = sp.tell()
475 sp.seek(0)
476
477 # patch to apply to shelved files
478 fp = cStringIO.StringIO()
479 for c in rc:
480 if c.filename() in backups:
481 c.write(fp)
482 dopatch = fp.tell()
483 fp.seek(0)
484
485 try:
486 # 3a. apply filtered patch to clean repo (clean)
487 if backups:
488 hg.revert(repo, repo.dirstate.parents()[0], backups.has_key)
489
490 # 3b. apply filtered patch to clean repo (apply)
491 if dopatch:
492 ui.debug('applying patch\n')
493 ui.debug(fp.getvalue())
494 patch.internalpatch(ui, repo, fp, 1)
495 del fp
496
497 # 3c. apply filtered patch to clean repo (shelve)
498 if doshelve:
499 ui.debug("saving patch to shelve\n")
500 if opts['append']:
501 f = repo.opener(shelfpath, "a")
502 else:
503 f = repo.opener(shelfpath, "w")
504 f.write(sp.getvalue())
505 del f
506 del sp
507 except:
508 try:
509 for realname, tmpname in backups.iteritems():
510 ui.debug('restoring %r to %r\n' % (tmpname, realname))
511 util.copyfile(tmpname, repo.wjoin(realname))
512 ui.debug('removing shelve file\n')
513 os.unlink(repo.join(shelfpath))
514 except OSError:
515 pass
516
517 return 0
518 finally:
519 try:
520 for realname, tmpname in backups.iteritems():
521 ui.debug('removing backup for %r : %r\n' % (realname, tmpname))
522 os.unlink(tmpname)
523 os.rmdir(backupdir)
524 except OSError:
525 pass
526 fancyopts.fancyopts([], commands.commitopts, opts)
527
528 # wrap ui.write so diff output can be labeled/colorized
529 def wrapwrite(orig, *args, **kw):
530 label = kw.pop('label', '')
531 if label: label += ' '
532 for chunk, l in patch.difflabel(lambda: args):
533 orig(chunk, label=label + l)
534 oldwrite = ui.write
535 extensions.wrapfunction(ui, 'write', wrapwrite)
536 try:
537 return cmdutil.commit(ui, repo, shelvefunc, pats, opts)
538 finally:
539 ui.write = oldwrite
540
541def listshelves(ui, repo):
542 # Check for shelve file at old location first
543 if os.path.isfile(repo.join('shelve')):
544 ui.status('default\n')
545
546 # Now go through all the files in the shelves folder and list them out
547 dirname = repo.join('shelves')
548 if os.path.isdir(dirname):
549 for filename in os.listdir(repo.join('shelves')):
550 ui.status(filename + '\n')
551
552def unshelve(ui, repo, **opts):
553 '''restore shelved changes'''
554
555 # Shelf name and path
556 shelfname = opts.get('name')
557 shelfpath = getshelfpath(repo, shelfname)
558
559 # List all the active shelves by name and return '
560 if opts['list']:
561 listshelves(ui,repo)
562 return
563
564 try:
565 patch_diff = repo.opener(shelfpath).read()
566 fp = cStringIO.StringIO(patch_diff)
567 if opts['inspect']:
568 # wrap ui.write so diff output can be labeled/colorized
569 def wrapwrite(orig, *args, **kw):
570 label = kw.pop('label', '')
571 if label: label += ' '
572 for chunk, l in patch.difflabel(lambda: args):
573 orig(chunk, label=label + l)
574 oldwrite = ui.write
575 extensions.wrapfunction(ui, 'write', wrapwrite)
576 try:
577 ui.status(fp.getvalue())
578 finally:
579 ui.write = oldwrite
580 else:
581 files = []
582 ac = parsepatch(fp)
583 for chunk in ac:
584 if isinstance(chunk, header):
585 files += chunk.files()
586 backupdir = repo.join('shelve-backups')
587 backups = makebackup(ui, repo, backupdir, set(files))
588
589 ui.debug('applying shelved patch\n')
590 patchdone = 0
591 try:
592 try:
593 fp.seek(0)
594 patch.internalpatch(ui, repo, fp, 1)
595 patchdone = 1
596 except:
597 if opts['force']:
598 patchdone = 1
599 else:
600 ui.status('restoring backup files\n')
601 for realname, tmpname in backups.iteritems():
602 ui.debug('restoring %r to %r\n' %
603 (tmpname, realname))
604 util.copyfile(tmpname, repo.wjoin(realname))
605 finally:
606 try:
607 ui.debug('removing backup files\n')
608 shutil.rmtree(backupdir, True)
609 except OSError:
610 pass
611
612 if patchdone:
613 ui.debug("removing shelved patches\n")
614 os.unlink(repo.join(shelfpath))
615 ui.status("unshelve completed\n")
616 except IOError:
617 ui.warn('nothing to unshelve\n')
618
619cmdtable = {
620 "shelve":
621 (shelve,
622 [('A', 'addremove', None,
623 _('mark new/missing files as added/removed before shelving')),
624 ('f', 'force', None,
625 _('overwrite existing shelve data')),
626 ('a', 'append', None,
627 _('append to existing shelve data')),
628 ('', 'all', None,
629 _('shelve all changes')),
630 ('n', 'name', '',
631 _('shelve changes to specified shelf name')),
632 ('l', 'list', None, _('list active shelves')),
633 ] + commands.walkopts,
634 _('hg shelve [OPTION]... [FILE]...')),
635 "unshelve":
636 (unshelve,
637 [('i', 'inspect', None, _('inspect shelved changes only')),
638 ('f', 'force', None,
639 _('proceed even if patches do not unshelve cleanly')),
640 ('n', 'name', '',
641 _('unshelve changes from specified shelf name')),
642 ('l', 'list', None, _('list active shelves')),
643 ],
644 _('hg unshelve [OPTION]...')),
645}