comparison hgext/shelve.py @ 19854:49d4919d21c2

shelve: add a shelve extension to save/restore working changes This extension saves shelved changes using a temporary draft commit, and bundles the temporary commit and its draft ancestors, then strips them. This strategy makes it possible to use Mercurial's bundle and merge machinery to resolve conflicts if necessary when unshelving, even when the destination commit or its ancestors have been amended, squashed, or evolved. (Once a change has been unshelved, its associated unbundled commits are either rolled back or stripped.) Storing the shelved change as a bundle also avoids the difficulty that hidden commits would cause, of making it impossible to amend the parent if it is a draft commits (a common scenario). Although this extension shares its name and some functionality with the third party hgshelve extension, it has little else in common. Notably, the hgshelve extension shelves changes as unified diffs, which makes conflict resolution a matter of finding .rej files and conflict markers, and cleaning up the mess by hand. We do not yet allow hunk-level choosing of changes to record. Compared to the hgshelve extension, this is a small regression in usability, but we hope to integrate that at a later point, once the record machinery becomes more reusable and robust.
author David Soria Parra <dsp@experimentalworks.net>
date Thu, 29 Aug 2013 09:22:13 -0700
parents
children a3b285882724
comparison
equal deleted inserted replaced
19853:eddc2a2d57e6 19854:49d4919d21c2
1 # shelve.py - save/restore working directory state
2 #
3 # Copyright 2013 Facebook, Inc.
4 #
5 # This software may be used and distributed according to the terms of the
6 # GNU General Public License version 2 or any later version.
7
8 """save and restore changes to the working directory
9
10 The "hg shelve" command saves changes made to the working directory
11 and reverts those changes, resetting the working directory to a clean
12 state.
13
14 Later on, the "hg unshelve" command restores the changes saved by "hg
15 shelve". Changes can be restored even after updating to a different
16 parent, in which case Mercurial's merge machinery will resolve any
17 conflicts if necessary.
18
19 You can have more than one shelved change outstanding at a time; each
20 shelved change has a distinct name. For details, see the help for "hg
21 shelve".
22 """
23
24 try:
25 import cPickle as pickle
26 pickle.dump # import now
27 except ImportError:
28 import pickle
29 from mercurial.i18n import _
30 from mercurial.node import nullid
31 from mercurial import changegroup, cmdutil, scmutil, phases
32 from mercurial import error, hg, mdiff, merge, patch, repair, util
33 from mercurial import templatefilters
34 from mercurial import lock as lockmod
35 import errno
36
37 cmdtable = {}
38 command = cmdutil.command(cmdtable)
39 testedwith = 'internal'
40
41 class shelvedfile(object):
42 """Handles common functions on shelve files (.hg/.files/.patch) using
43 the vfs layer"""
44 def __init__(self, repo, name, filetype=None):
45 self.repo = repo
46 self.name = name
47 self.vfs = scmutil.vfs(repo.join('shelved'))
48 if filetype:
49 self.fname = name + '.' + filetype
50 else:
51 self.fname = name
52
53 def exists(self):
54 return self.vfs.exists(self.fname)
55
56 def filename(self):
57 return self.vfs.join(self.fname)
58
59 def unlink(self):
60 util.unlink(self.filename())
61
62 def stat(self):
63 return self.vfs.stat(self.fname)
64
65 def opener(self, mode='rb'):
66 try:
67 return self.vfs(self.fname, mode)
68 except IOError, err:
69 if err.errno != errno.ENOENT:
70 raise
71 if mode[0] in 'wa':
72 try:
73 self.vfs.mkdir()
74 return self.vfs(self.fname, mode)
75 except IOError, err:
76 if err.errno != errno.EEXIST:
77 raise
78 elif mode[0] == 'r':
79 raise util.Abort(_("shelved change '%s' not found") %
80 self.name)
81
82 class shelvedstate(object):
83 """Handles saving and restoring a shelved state. Ensures that different
84 versions of a shelved state are possible and handles them appropriate"""
85 _version = 1
86 _filename = 'shelvedstate'
87
88 @classmethod
89 def load(cls, repo):
90 fp = repo.opener(cls._filename)
91 (version, name, parents, stripnodes) = pickle.load(fp)
92
93 if version != cls._version:
94 raise util.Abort(_('this version of shelve is incompatible '
95 'with the version used in this repo'))
96
97 obj = cls()
98 obj.name = name
99 obj.parents = parents
100 obj.stripnodes = stripnodes
101
102 return obj
103
104 @classmethod
105 def save(cls, repo, name, stripnodes):
106 fp = repo.opener(cls._filename, 'wb')
107 pickle.dump((cls._version, name,
108 repo.dirstate.parents(),
109 stripnodes), fp)
110 fp.close()
111
112 @staticmethod
113 def clear(repo):
114 util.unlinkpath(repo.join('shelvedstate'), ignoremissing=True)
115
116 def createcmd(ui, repo, pats, opts):
117 def publicancestors(ctx):
118 """Compute the heads of the public ancestors of a commit.
119
120 Much faster than the revset heads(ancestors(ctx) - draft())"""
121 seen = set()
122 visit = util.deque()
123 visit.append(ctx)
124 while visit:
125 ctx = visit.popleft()
126 for parent in ctx.parents():
127 rev = parent.rev()
128 if rev not in seen:
129 seen.add(rev)
130 if parent.mutable():
131 visit.append(parent)
132 else:
133 yield parent.node()
134
135 wctx = repo[None]
136 parents = wctx.parents()
137 if len(parents) > 1:
138 raise util.Abort(_('cannot shelve while merging'))
139 parent = parents[0]
140
141 # we never need the user, so we use a generic user for all shelve operations
142 user = 'shelve@localhost'
143 label = repo._bookmarkcurrent or parent.branch() or 'default'
144
145 # slashes aren't allowed in filenames, therefore we rename it
146 origlabel, label = label, label.replace('/', '_')
147
148 def gennames():
149 yield label
150 for i in xrange(1, 100):
151 yield '%s-%02d' % (label, i)
152
153 shelvedfiles = []
154
155 def commitfunc(ui, repo, message, match, opts):
156 # check modified, added, removed, deleted only
157 for flist in repo.status(match=match)[:4]:
158 shelvedfiles.extend(flist)
159 return repo.commit(message, user, opts.get('date'), match)
160
161 if parent.node() != nullid:
162 desc = parent.description().split('\n', 1)[0]
163 desc = _('shelved from %s (%s): %s') % (label, str(parent)[:8], desc)
164 else:
165 desc = '(empty repository)'
166
167 if not opts['message']:
168 opts['message'] = desc
169
170 name = opts['name']
171
172 wlock = lock = tr = None
173 try:
174 wlock = repo.wlock()
175 lock = repo.lock()
176
177 # use an uncommited transaction to generate the bundle to avoid
178 # pull races. ensure we don't print the abort message to stderr.
179 tr = repo.transaction('commit', report=lambda x: None)
180
181 if name:
182 if shelvedfile(repo, name, 'hg').exists():
183 raise util.Abort(_("a shelved change named '%s' already exists")
184 % name)
185 else:
186 for n in gennames():
187 if not shelvedfile(repo, n, 'hg').exists():
188 name = n
189 break
190 else:
191 raise util.Abort(_("too many shelved changes named '%s'") %
192 label)
193
194 # ensure we are not creating a subdirectory or a hidden file
195 if '/' in name or '\\' in name:
196 raise util.Abort(_('shelved change names may not contain slashes'))
197 if name.startswith('.'):
198 raise util.Abort(_("shelved change names may not start with '.'"))
199
200 node = cmdutil.commit(ui, repo, commitfunc, pats, opts)
201
202 if not node:
203 stat = repo.status(match=scmutil.match(repo[None], pats, opts))
204 if stat[3]:
205 ui.status(_("nothing changed (%d missing files, see "
206 "'hg status')\n") % len(stat[3]))
207 else:
208 ui.status(_("nothing changed\n"))
209 return 1
210
211 phases.retractboundary(repo, phases.secret, [node])
212
213 fp = shelvedfile(repo, name, 'files').opener('wb')
214 fp.write('\0'.join(shelvedfiles))
215
216 bases = list(publicancestors(repo[node]))
217 cg = repo.changegroupsubset(bases, [node], 'shelve')
218 changegroup.writebundle(cg, shelvedfile(repo, name, 'hg').filename(),
219 'HG10UN')
220 cmdutil.export(repo, [node],
221 fp=shelvedfile(repo, name, 'patch').opener('wb'),
222 opts=mdiff.diffopts(git=True))
223
224 if ui.formatted():
225 desc = util.ellipsis(desc, ui.termwidth())
226 ui.status(desc + '\n')
227 ui.status(_('shelved as %s\n') % name)
228 hg.update(repo, parent.node())
229 finally:
230 if tr:
231 tr.abort()
232 lockmod.release(lock, wlock)
233
234 def cleanupcmd(ui, repo):
235 wlock = None
236 try:
237 wlock = repo.wlock()
238 for (name, _) in repo.vfs.readdir('shelved'):
239 suffix = name.rsplit('.', 1)[-1]
240 if suffix in ('hg', 'files', 'patch'):
241 shelvedfile(repo, name).unlink()
242 finally:
243 lockmod.release(wlock)
244
245 def deletecmd(ui, repo, pats):
246 if not pats:
247 raise util.Abort(_('no shelved changes specified!'))
248 wlock = None
249 try:
250 wlock = repo.wlock()
251 try:
252 for name in pats:
253 for suffix in 'hg files patch'.split():
254 shelvedfile(repo, name, suffix).unlink()
255 except OSError, err:
256 if err.errno != errno.ENOENT:
257 raise
258 raise util.Abort(_("shelved change '%s' not found") % name)
259 finally:
260 lockmod.release(wlock)
261
262 def listshelves(repo):
263 try:
264 names = repo.vfs.readdir('shelved')
265 except OSError, err:
266 if err.errno != errno.ENOENT:
267 raise
268 return []
269 info = []
270 for (name, _) in names:
271 pfx, sfx = name.rsplit('.', 1)
272 if not pfx or sfx != 'patch':
273 continue
274 st = shelvedfile(repo, name).stat()
275 info.append((st.st_mtime, shelvedfile(repo, pfx).filename()))
276 return sorted(info, reverse=True)
277
278 def listcmd(ui, repo, pats, opts):
279 pats = set(pats)
280 width = 80
281 if not ui.plain():
282 width = ui.termwidth()
283 namelabel = 'shelve.newest'
284 for mtime, name in listshelves(repo):
285 sname = util.split(name)[1]
286 if pats and sname not in pats:
287 continue
288 ui.write(sname, label=namelabel)
289 namelabel = 'shelve.name'
290 if ui.quiet:
291 ui.write('\n')
292 continue
293 ui.write(' ' * (16 - len(sname)))
294 used = 16
295 age = '[%s]' % templatefilters.age(util.makedate(mtime))
296 ui.write(age, label='shelve.age')
297 ui.write(' ' * (18 - len(age)))
298 used += 18
299 fp = open(name + '.patch', 'rb')
300 try:
301 while True:
302 line = fp.readline()
303 if not line:
304 break
305 if not line.startswith('#'):
306 desc = line.rstrip()
307 if ui.formatted():
308 desc = util.ellipsis(desc, width - used)
309 ui.write(desc)
310 break
311 ui.write('\n')
312 if not (opts['patch'] or opts['stat']):
313 continue
314 difflines = fp.readlines()
315 if opts['patch']:
316 for chunk, label in patch.difflabel(iter, difflines):
317 ui.write(chunk, label=label)
318 if opts['stat']:
319 for chunk, label in patch.diffstatui(difflines, width=width,
320 git=True):
321 ui.write(chunk, label=label)
322 finally:
323 fp.close()
324
325 def readshelvedfiles(repo, basename):
326 fp = shelvedfile(repo, basename, 'files').opener()
327 return fp.read().split('\0')
328
329 def checkparents(repo, state):
330 if state.parents != repo.dirstate.parents():
331 raise util.Abort(_('working directory parents do not match unshelve '
332 'state'))
333
334 def unshelveabort(ui, repo, state, opts):
335 wlock = repo.wlock()
336 lock = None
337 try:
338 checkparents(repo, state)
339 lock = repo.lock()
340 merge.mergestate(repo).reset()
341 if opts['keep']:
342 repo.setparents(repo.dirstate.parents()[0])
343 else:
344 revertfiles = readshelvedfiles(repo, state.name)
345 wctx = repo.parents()[0]
346 cmdutil.revert(ui, repo, wctx, [wctx.node(), nullid],
347 *revertfiles, no_backup=True)
348 # fix up the weird dirstate states the merge left behind
349 mf = wctx.manifest()
350 dirstate = repo.dirstate
351 for f in revertfiles:
352 if f in mf:
353 dirstate.normallookup(f)
354 else:
355 dirstate.drop(f)
356 dirstate._pl = (wctx.node(), nullid)
357 dirstate._dirty = True
358 repair.strip(ui, repo, state.stripnodes, backup='none', topic='shelve')
359 shelvedstate.clear(repo)
360 ui.warn(_("unshelve of '%s' aborted\n") % state.name)
361 finally:
362 lockmod.release(lock, wlock)
363
364 def unshelvecleanup(ui, repo, name, opts):
365 if not opts['keep']:
366 for filetype in 'hg files patch'.split():
367 shelvedfile(repo, name, filetype).unlink()
368
369 def finishmerge(ui, repo, ms, stripnodes, name, opts):
370 # Reset the working dir so it's no longer in a merge state.
371 dirstate = repo.dirstate
372 for f in ms:
373 if dirstate[f] == 'm':
374 dirstate.normallookup(f)
375 dirstate._pl = (dirstate._pl[0], nullid)
376 dirstate._dirty = dirstate._dirtypl = True
377 shelvedstate.clear(repo)
378
379 def unshelvecontinue(ui, repo, state, opts):
380 # We're finishing off a merge. First parent is our original
381 # parent, second is the temporary "fake" commit we're unshelving.
382 wlock = repo.wlock()
383 lock = None
384 try:
385 checkparents(repo, state)
386 ms = merge.mergestate(repo)
387 if [f for f in ms if ms[f] == 'u']:
388 raise util.Abort(
389 _("unresolved conflicts, can't continue"),
390 hint=_("see 'hg resolve', then 'hg unshelve --continue'"))
391 finishmerge(ui, repo, ms, state.stripnodes, state.name, opts)
392 lock = repo.lock()
393 repair.strip(ui, repo, state.stripnodes, backup='none', topic='shelve')
394 unshelvecleanup(ui, repo, state.name, opts)
395 ui.status(_("unshelve of '%s' complete\n") % state.name)
396 finally:
397 lockmod.release(lock, wlock)
398
399 @command('unshelve',
400 [('a', 'abort', None,
401 _('abort an incomplete unshelve operation')),
402 ('c', 'continue', None,
403 _('continue an incomplete unshelve operation')),
404 ('', 'keep', None,
405 _('keep shelve after unshelving'))],
406 _('hg unshelve [SHELVED]'))
407 def unshelve(ui, repo, *shelved, **opts):
408 """restore a shelved change to the working directory
409
410 This command accepts an optional name of a shelved change to
411 restore. If none is given, the most recent shelved change is used.
412
413 If a shelved change is applied successfully, the bundle that
414 contains the shelved changes is deleted afterwards.
415
416 Since you can restore a shelved change on top of an arbitrary
417 commit, it is possible that unshelving will result in a conflict
418 between your changes and the commits you are unshelving onto. If
419 this occurs, you must resolve the conflict, then use
420 ``--continue`` to complete the unshelve operation. (The bundle
421 will not be deleted until you successfully complete the unshelve.)
422
423 (Alternatively, you can use ``--abort`` to abandon an unshelve
424 that causes a conflict. This reverts the unshelved changes, and
425 does not delete the bundle.)
426 """
427 abortf = opts['abort']
428 continuef = opts['continue']
429 if not abortf and not continuef:
430 cmdutil.checkunfinished(repo)
431
432 if abortf or continuef:
433 if abortf and continuef:
434 raise util.Abort(_('cannot use both abort and continue'))
435 if shelved:
436 raise util.Abort(_('cannot combine abort/continue with '
437 'naming a shelved change'))
438
439 try:
440 state = shelvedstate.load(repo)
441 except IOError, err:
442 if err.errno != errno.ENOENT:
443 raise
444 raise util.Abort(_('no unshelve operation underway'))
445
446 if abortf:
447 return unshelveabort(ui, repo, state, opts)
448 elif continuef:
449 return unshelvecontinue(ui, repo, state, opts)
450 elif len(shelved) > 1:
451 raise util.Abort(_('can only unshelve one change at a time'))
452 elif not shelved:
453 shelved = listshelves(repo)
454 if not shelved:
455 raise util.Abort(_('no shelved changes to apply!'))
456 basename = util.split(shelved[0][1])[1]
457 ui.status(_("unshelving change '%s'\n") % basename)
458 else:
459 basename = shelved[0]
460
461 shelvedfiles = readshelvedfiles(repo, basename)
462
463 m, a, r, d = repo.status()[:4]
464 unsafe = set(m + a + r + d).intersection(shelvedfiles)
465 if unsafe:
466 ui.warn(_('the following shelved files have been modified:\n'))
467 for f in sorted(unsafe):
468 ui.warn(' %s\n' % f)
469 ui.warn(_('you must commit, revert, or shelve your changes before you '
470 'can proceed\n'))
471 raise util.Abort(_('cannot unshelve due to local changes\n'))
472
473 wlock = lock = tr = None
474 try:
475 lock = repo.lock()
476
477 tr = repo.transaction('unshelve', report=lambda x: None)
478 oldtiprev = len(repo)
479 try:
480 fp = shelvedfile(repo, basename, 'hg').opener()
481 gen = changegroup.readbundle(fp, fp.name)
482 repo.addchangegroup(gen, 'unshelve', 'bundle:' + fp.name)
483 nodes = [ctx.node() for ctx in repo.set('%d:', oldtiprev)]
484 phases.retractboundary(repo, phases.secret, nodes)
485 tr.close()
486 finally:
487 fp.close()
488
489 tip = repo['tip']
490 wctx = repo['.']
491 ancestor = tip.ancestor(wctx)
492
493 wlock = repo.wlock()
494
495 if ancestor.node() != wctx.node():
496 conflicts = hg.merge(repo, tip.node(), force=True, remind=False)
497 ms = merge.mergestate(repo)
498 stripnodes = [repo.changelog.node(rev)
499 for rev in xrange(oldtiprev, len(repo))]
500 if conflicts:
501 shelvedstate.save(repo, basename, stripnodes)
502 # Fix up the dirstate entries of files from the second
503 # parent as if we were not merging, except for those
504 # with unresolved conflicts.
505 parents = repo.parents()
506 revertfiles = set(parents[1].files()).difference(ms)
507 cmdutil.revert(ui, repo, parents[1],
508 (parents[0].node(), nullid),
509 *revertfiles, no_backup=True)
510 raise error.InterventionRequired(
511 _("unresolved conflicts (see 'hg resolve', then "
512 "'hg unshelve --continue')"))
513 finishmerge(ui, repo, ms, stripnodes, basename, opts)
514 else:
515 parent = tip.parents()[0]
516 hg.update(repo, parent.node())
517 cmdutil.revert(ui, repo, tip, repo.dirstate.parents(), *tip.files(),
518 no_backup=True)
519
520 prevquiet = ui.quiet
521 ui.quiet = True
522 try:
523 repo.rollback(force=True)
524 finally:
525 ui.quiet = prevquiet
526
527 unshelvecleanup(ui, repo, basename, opts)
528 finally:
529 if tr:
530 tr.release()
531 lockmod.release(lock, wlock)
532
533 @command('shelve',
534 [('A', 'addremove', None,
535 _('mark new/missing files as added/removed before shelving')),
536 ('', 'cleanup', None,
537 _('delete all shelved changes')),
538 ('', 'date', '',
539 _('shelve with the specified commit date'), _('DATE')),
540 ('d', 'delete', None,
541 _('delete the named shelved change(s)')),
542 ('l', 'list', None,
543 _('list current shelves')),
544 ('m', 'message', '',
545 _('use text as shelve message'), _('TEXT')),
546 ('n', 'name', '',
547 _('use the given name for the shelved commit'), _('NAME')),
548 ('p', 'patch', None,
549 _('show patch')),
550 ('', 'stat', None,
551 _('output diffstat-style summary of changes'))],
552 _('hg shelve'))
553 def shelvecmd(ui, repo, *pats, **opts):
554 '''save and set aside changes from the working directory
555
556 Shelving takes files that "hg status" reports as not clean, saves
557 the modifications to a bundle (a shelved change), and reverts the
558 files so that their state in the working directory becomes clean.
559
560 To restore these changes to the working directory, using "hg
561 unshelve"; this will work even if you switch to a different
562 commit.
563
564 When no files are specified, "hg shelve" saves all not-clean
565 files. If specific files or directories are named, only changes to
566 those files are shelved.
567
568 Each shelved change has a name that makes it easier to find later.
569 The name of a shelved change defaults to being based on the active
570 bookmark, or if there is no active bookmark, the current named
571 branch. To specify a different name, use ``--name``.
572
573 To see a list of existing shelved changes, use the ``--list``
574 option. For each shelved change, this will print its name, age,
575 and description; use ``--patch`` or ``--stat`` for more details.
576
577 To delete specific shelved changes, use ``--delete``. To delete
578 all shelved changes, use ``--cleanup``.
579 '''
580 cmdutil.checkunfinished(repo)
581
582 def checkopt(opt, incompatible):
583 if opts[opt]:
584 for i in incompatible.split():
585 if opts[i]:
586 raise util.Abort(_("options '--%s' and '--%s' may not be "
587 "used together") % (opt, i))
588 return True
589 if checkopt('cleanup', 'addremove delete list message name patch stat'):
590 if pats:
591 raise util.Abort(_("cannot specify names when using '--cleanup'"))
592 return cleanupcmd(ui, repo)
593 elif checkopt('delete', 'addremove cleanup list message name patch stat'):
594 return deletecmd(ui, repo, pats)
595 elif checkopt('list', 'addremove cleanup delete message name'):
596 return listcmd(ui, repo, pats, opts)
597 else:
598 for i in ('patch', 'stat'):
599 if opts[i]:
600 raise util.Abort(_("option '--%s' may not be "
601 "used when shelving a change") % (i,))
602 return createcmd(ui, repo, pats, opts)
603
604 def extsetup(ui):
605 cmdutil.unfinishedstates.append(
606 [shelvedstate._filename, False, True, _('unshelve already in progress'),
607 _("use 'hg unshelve --continue' or 'hg unshelve --abort'")])