chiark / gitweb /
Log and undo external modifications
[stgit] / stgit / lib / transaction.py
1 """The L{StackTransaction} class makes it possible to make complex
2 updates to an StGit stack in a safe and convenient way."""
3
4 import atexit
5 import itertools as it
6
7 from stgit import exception, utils
8 from stgit.utils import any, all
9 from stgit.out import *
10 from stgit.lib import git, log
11
12 class TransactionException(exception.StgException):
13     """Exception raised when something goes wrong with a
14     L{StackTransaction}."""
15
16 class TransactionHalted(TransactionException):
17     """Exception raised when a L{StackTransaction} stops part-way through.
18     Used to make a non-local jump from the transaction setup to the
19     part of the transaction code where the transaction is run."""
20
21 def _print_current_patch(old_applied, new_applied):
22     def now_at(pn):
23         out.info('Now at patch "%s"' % pn)
24     if not old_applied and not new_applied:
25         pass
26     elif not old_applied:
27         now_at(new_applied[-1])
28     elif not new_applied:
29         out.info('No patch applied')
30     elif old_applied[-1] == new_applied[-1]:
31         pass
32     else:
33         now_at(new_applied[-1])
34
35 class _TransPatchMap(dict):
36     """Maps patch names to sha1 strings."""
37     def __init__(self, stack):
38         dict.__init__(self)
39         self.__stack = stack
40     def __getitem__(self, pn):
41         try:
42             return dict.__getitem__(self, pn)
43         except KeyError:
44             return self.__stack.patches.get(pn).commit
45
46 class StackTransaction(object):
47     """A stack transaction, used for making complex updates to an StGit
48     stack in one single operation that will either succeed or fail
49     cleanly.
50
51     The basic theory of operation is the following:
52
53       1. Create a transaction object.
54
55       2. Inside a::
56
57          try
58            ...
59          except TransactionHalted:
60            pass
61
62       block, update the transaction with e.g. methods like
63       L{pop_patches} and L{push_patch}. This may create new git
64       objects such as commits, but will not write any refs; this means
65       that in case of a fatal error we can just walk away, no clean-up
66       required.
67
68       (Some operations may need to touch your index and working tree,
69       though. But they are cleaned up when needed.)
70
71       3. After the C{try} block -- wheher or not the setup ran to
72       completion or halted part-way through by raising a
73       L{TransactionHalted} exception -- call the transaction's L{run}
74       method. This will either succeed in writing the updated state to
75       your refs and index+worktree, or fail without having done
76       anything."""
77     def __init__(self, stack, msg, discard_changes = False,
78                  allow_conflicts = False):
79         """Create a new L{StackTransaction}.
80
81         @param discard_changes: Discard any changes in index+worktree
82         @type discard_changes: bool
83         @param allow_conflicts: Whether to allow pre-existing conflicts
84         @type allow_conflicts: bool or function of L{StackTransaction}"""
85         self.__stack = stack
86         self.__msg = msg
87         self.__patches = _TransPatchMap(stack)
88         self.__applied = list(self.__stack.patchorder.applied)
89         self.__unapplied = list(self.__stack.patchorder.unapplied)
90         self.__hidden = list(self.__stack.patchorder.hidden)
91         self.__conflicting_push = None
92         self.__error = None
93         self.__current_tree = self.__stack.head.data.tree
94         self.__base = self.__stack.base
95         self.__discard_changes = discard_changes
96         self.__bad_head = None
97         if isinstance(allow_conflicts, bool):
98             self.__allow_conflicts = lambda trans: allow_conflicts
99         else:
100             self.__allow_conflicts = allow_conflicts
101         self.__temp_index = self.temp_index_tree = None
102     stack = property(lambda self: self.__stack)
103     patches = property(lambda self: self.__patches)
104     def __set_applied(self, val):
105         self.__applied = list(val)
106     applied = property(lambda self: self.__applied, __set_applied)
107     def __set_unapplied(self, val):
108         self.__unapplied = list(val)
109     unapplied = property(lambda self: self.__unapplied, __set_unapplied)
110     def __set_hidden(self, val):
111         self.__hidden = list(val)
112     hidden = property(lambda self: self.__hidden, __set_hidden)
113     all_patches = property(lambda self: (self.__applied + self.__unapplied
114                                          + self.__hidden))
115     def __set_base(self, val):
116         assert (not self.__applied
117                 or self.patches[self.applied[0]].data.parent == val)
118         self.__base = val
119     base = property(lambda self: self.__base, __set_base)
120     @property
121     def temp_index(self):
122         if not self.__temp_index:
123             self.__temp_index = self.__stack.repository.temp_index()
124             atexit.register(self.__temp_index.delete)
125         return self.__temp_index
126     @property
127     def top(self):
128         if self.__applied:
129             return self.__patches[self.__applied[-1]]
130         else:
131             return self.__base
132     def __get_head(self):
133         if self.__bad_head:
134             return self.__bad_head
135         else:
136             return self.top
137     def __set_head(self, val):
138         self.__bad_head = val
139     head = property(__get_head, __set_head)
140     def __checkout(self, tree, iw, allow_bad_head):
141         if not (allow_bad_head or self.__stack.head_top_equal()):
142             out.error(
143                 'HEAD and top are not the same.',
144                 'This can happen if you modify a branch with git.',
145                 '"stg repair --help" explains more about what to do next.')
146             self.__abort()
147         if self.__current_tree == tree and not self.__discard_changes:
148             # No tree change, but we still want to make sure that
149             # there are no unresolved conflicts. Conflicts
150             # conceptually "belong" to the topmost patch, and just
151             # carrying them along to another patch is confusing.
152             if (self.__allow_conflicts(self) or iw == None
153                 or not iw.index.conflicts()):
154                 return
155             out.error('Need to resolve conflicts first')
156             self.__abort()
157         assert iw != None
158         if self.__discard_changes:
159             iw.checkout_hard(tree)
160         else:
161             iw.checkout(self.__current_tree, tree)
162         self.__current_tree = tree
163     @staticmethod
164     def __abort():
165         raise TransactionException(
166             'Command aborted (all changes rolled back)')
167     def __check_consistency(self):
168         remaining = set(self.all_patches)
169         for pn, commit in self.__patches.iteritems():
170             if commit == None:
171                 assert self.__stack.patches.exists(pn)
172             else:
173                 assert pn in remaining
174     def abort(self, iw = None):
175         # The only state we need to restore is index+worktree.
176         if iw:
177             self.__checkout(self.__stack.head.data.tree, iw,
178                             allow_bad_head = True)
179     def run(self, iw = None, set_head = True, allow_bad_head = False):
180         """Execute the transaction. Will either succeed, or fail (with an
181         exception) and do nothing."""
182         self.__check_consistency()
183         log.log_external_mods(self.__stack)
184         new_head = self.head
185
186         # Set branch head.
187         if set_head:
188             if iw:
189                 try:
190                     self.__checkout(new_head.data.tree, iw, allow_bad_head)
191                 except git.CheckoutException:
192                     # We have to abort the transaction.
193                     self.abort(iw)
194                     self.__abort()
195             self.__stack.set_head(new_head, self.__msg)
196
197         if self.__error:
198             out.error(self.__error)
199
200         # Write patches.
201         def write(msg):
202             for pn, commit in self.__patches.iteritems():
203                 if self.__stack.patches.exists(pn):
204                     p = self.__stack.patches.get(pn)
205                     if commit == None:
206                         p.delete()
207                     else:
208                         p.set_commit(commit, msg)
209                 else:
210                     self.__stack.patches.new(pn, commit, msg)
211             self.__stack.patchorder.applied = self.__applied
212             self.__stack.patchorder.unapplied = self.__unapplied
213             self.__stack.patchorder.hidden = self.__hidden
214             log.log_entry(self.__stack, msg)
215         old_applied = self.__stack.patchorder.applied
216         write(self.__msg)
217         if self.__conflicting_push != None:
218             self.__patches = _TransPatchMap(self.__stack)
219             self.__conflicting_push()
220             write(self.__msg + ' (CONFLICT)')
221         _print_current_patch(old_applied, self.__applied)
222
223         if self.__error:
224             return utils.STGIT_CONFLICT
225         else:
226             return utils.STGIT_SUCCESS
227
228     def __halt(self, msg):
229         self.__error = msg
230         raise TransactionHalted(msg)
231
232     @staticmethod
233     def __print_popped(popped):
234         if len(popped) == 0:
235             pass
236         elif len(popped) == 1:
237             out.info('Popped %s' % popped[0])
238         else:
239             out.info('Popped %s -- %s' % (popped[-1], popped[0]))
240
241     def pop_patches(self, p):
242         """Pop all patches pn for which p(pn) is true. Return the list of
243         other patches that had to be popped to accomplish this. Always
244         succeeds."""
245         popped = []
246         for i in xrange(len(self.applied)):
247             if p(self.applied[i]):
248                 popped = self.applied[i:]
249                 del self.applied[i:]
250                 break
251         popped1 = [pn for pn in popped if not p(pn)]
252         popped2 = [pn for pn in popped if p(pn)]
253         self.unapplied = popped1 + popped2 + self.unapplied
254         self.__print_popped(popped)
255         return popped1
256
257     def delete_patches(self, p):
258         """Delete all patches pn for which p(pn) is true. Return the list of
259         other patches that had to be popped to accomplish this. Always
260         succeeds."""
261         popped = []
262         all_patches = self.applied + self.unapplied + self.hidden
263         for i in xrange(len(self.applied)):
264             if p(self.applied[i]):
265                 popped = self.applied[i:]
266                 del self.applied[i:]
267                 break
268         popped = [pn for pn in popped if not p(pn)]
269         self.unapplied = popped + [pn for pn in self.unapplied if not p(pn)]
270         self.hidden = [pn for pn in self.hidden if not p(pn)]
271         self.__print_popped(popped)
272         for pn in all_patches:
273             if p(pn):
274                 s = ['', ' (empty)'][self.patches[pn].data.is_nochange()]
275                 self.patches[pn] = None
276                 out.info('Deleted %s%s' % (pn, s))
277         return popped
278
279     def push_patch(self, pn, iw = None):
280         """Attempt to push the named patch. If this results in conflicts,
281         halts the transaction. If index+worktree are given, spill any
282         conflicts to them."""
283         orig_cd = self.patches[pn].data
284         cd = orig_cd.set_committer(None)
285         s = ['', ' (empty)'][cd.is_nochange()]
286         oldparent = cd.parent
287         cd = cd.set_parent(self.top)
288         base = oldparent.data.tree
289         ours = cd.parent.data.tree
290         theirs = cd.tree
291         tree, self.temp_index_tree = self.temp_index.merge(
292             base, ours, theirs, self.temp_index_tree)
293         merge_conflict = False
294         if not tree:
295             if iw == None:
296                 self.__halt('%s does not apply cleanly' % pn)
297             try:
298                 self.__checkout(ours, iw, allow_bad_head = False)
299             except git.CheckoutException:
300                 self.__halt('Index/worktree dirty')
301             try:
302                 iw.merge(base, ours, theirs)
303                 tree = iw.index.write_tree()
304                 self.__current_tree = tree
305                 s = ' (modified)'
306             except git.MergeConflictException:
307                 tree = ours
308                 merge_conflict = True
309                 s = ' (conflict)'
310             except git.MergeException, e:
311                 self.__halt(str(e))
312         cd = cd.set_tree(tree)
313         if any(getattr(cd, a) != getattr(orig_cd, a) for a in
314                ['parent', 'tree', 'author', 'message']):
315             comm = self.__stack.repository.commit(cd)
316         else:
317             comm = None
318             s = ' (unmodified)'
319         out.info('Pushed %s%s' % (pn, s))
320         def update():
321             if comm:
322                 self.patches[pn] = comm
323             if pn in self.hidden:
324                 x = self.hidden
325             else:
326                 x = self.unapplied
327             del x[x.index(pn)]
328             self.applied.append(pn)
329         if merge_conflict:
330             # We've just caused conflicts, so we must allow them in
331             # the final checkout.
332             self.__allow_conflicts = lambda trans: True
333
334             # Save this update so that we can run it a little later.
335             self.__conflicting_push = update
336             self.__halt('Merge conflict')
337         else:
338             # Update immediately.
339             update()
340
341     def reorder_patches(self, applied, unapplied, hidden, iw = None):
342         """Push and pop patches to attain the given ordering."""
343         common = len(list(it.takewhile(lambda (a, b): a == b,
344                                        zip(self.applied, applied))))
345         to_pop = set(self.applied[common:])
346         self.pop_patches(lambda pn: pn in to_pop)
347         for pn in applied[common:]:
348             self.push_patch(pn, iw)
349         assert self.applied == applied
350         assert set(self.unapplied + self.hidden) == set(unapplied + hidden)
351         self.unapplied = unapplied
352         self.hidden = hidden