chiark / gitweb /
Merge branch 'stable'
[stgit] / stgit / lib / log.py
1 r"""This module contains functions and classes for manipulating
2 I{patch stack logs} (or just I{stack logs}).
3
4 A stack log is a git branch. Each commit contains the complete state
5 of the stack at the moment it was written; the most recent commit has
6 the most recent state.
7
8 For a branch C{I{foo}}, the stack log is stored in C{I{foo}.stgit}.
9 Each log entry makes sure to have proper references to everything it
10 needs, which means that it is safe against garbage collection -- you
11 can even pull it from one repository to another.
12
13 Stack log format (version 0)
14 ============================
15
16 Version 0 was an experimental version of the stack log format; it is
17 no longer supported.
18
19 Stack log format (version 1)
20 ============================
21
22 Commit message
23 --------------
24
25 The commit message is mostly for human consumption; in most cases it
26 is just a subject line: the stg subcommand name and possibly some
27 important command-line flag.
28
29 An exception to this is log commits for undo and redo. Their subject
30 line is "C{undo I{n}}" and "C{redo I{n}}"; the positive integer I{n}
31 says how many steps were undone or redone.
32
33 Tree
34 ----
35
36   - One blob, C{meta}, that contains the log data:
37
38       - C{Version:} I{n}
39
40         where I{n} must be 1. (Future versions of StGit might change
41         the log format; when this is done, this version number will be
42         incremented.)
43
44       - C{Previous:} I{sha1 or C{None}}
45
46         The commit of the previous log entry, or C{None} if this is
47         the first entry.
48
49       - C{Head:} I{sha1}
50
51         The current branch head.
52
53       - C{Applied:}
54
55         Marks the start of the list of applied patches. They are
56         listed in order, each on its own line: first one or more
57         spaces, then the patch name, then a colon, space, then the
58         patch's sha1.
59
60       - C{Unapplied:}
61
62         Same as C{Applied:}, but for the unapplied patches.
63
64       - C{Hidden:}
65
66         Same as C{Applied:}, but for the hidden patches.
67
68   - One subtree, C{patches}, that contains one blob per patch::
69
70       Bottom: <sha1 of patch's bottom tree>
71       Top:    <sha1 of patch's top tree>
72       Author: <author name and e-mail>
73       Date:   <patch timestamp>
74
75       <commit message>
76
77       ---
78
79       <patch diff>
80
81 Following the message is a newline, three dashes, and another newline.
82 Then come, each on its own line,
83
84 Parents
85 -------
86
87   - The first parent is the I{simplified log}, described below.
88
89   - The rest of the parents are just there to make sure that all the
90     commits referred to in the log entry -- patches, branch head,
91     previous log entry -- are ancestors of the log commit. (This is
92     necessary to make the log safe with regard to garbage collection
93     and pulling.)
94
95 Simplified log
96 --------------
97
98 The simplified log is exactly like the full log, except that its only
99 parent is the (simplified) previous log entry, if any. It's purpose is
100 mainly ease of visualization."""
101
102 import re
103 from stgit.lib import git, stack as libstack
104 from stgit import exception, utils
105 from stgit.out import out
106 import StringIO
107
108 class LogException(exception.StgException):
109     pass
110
111 class LogParseException(LogException):
112     pass
113
114 def patch_file(repo, cd):
115     return repo.commit(git.BlobData(''.join(s + '\n' for s in [
116                     'Bottom: %s' % cd.parent.data.tree.sha1,
117                     'Top:    %s' % cd.tree.sha1,
118                     'Author: %s' % cd.author.name_email,
119                     'Date:   %s' % cd.author.date,
120                     '',
121                     cd.message,
122                     '',
123                     '---',
124                     '',
125                     repo.diff_tree(cd.parent.data.tree, cd.tree, ['-M']
126                                    ).strip()])))
127
128 def log_ref(branch):
129     return 'refs/heads/%s.stgit' % branch
130
131 class LogEntry(object):
132     __separator = '\n---\n'
133     __max_parents = 16
134     def __init__(self, repo, prev, head, applied, unapplied, hidden,
135                  patches, message):
136         self.__repo = repo
137         self.__prev = prev
138         self.__simplified = None
139         self.head = head
140         self.applied = applied
141         self.unapplied = unapplied
142         self.hidden = hidden
143         self.patches = patches
144         self.message = message
145     @property
146     def simplified(self):
147         if not self.__simplified:
148             self.__simplified = self.commit.data.parents[0]
149         return self.__simplified
150     @property
151     def prev(self):
152         if self.__prev != None and not isinstance(self.__prev, LogEntry):
153             self.__prev = self.from_commit(self.__repo, self.__prev)
154         return self.__prev
155     @property
156     def base(self):
157         if self.applied:
158             return self.patches[self.applied[0]].data.parent
159         else:
160             return self.head
161     @property
162     def top(self):
163         if self.applied:
164             return self.patches[self.applied[-1]]
165         else:
166             return self.head
167     all_patches = property(lambda self: (self.applied + self.unapplied
168                                          + self.hidden))
169     @classmethod
170     def from_stack(cls, prev, stack, message):
171         return cls(
172             repo = stack.repository,
173             prev = prev,
174             head = stack.head,
175             applied = list(stack.patchorder.applied),
176             unapplied = list(stack.patchorder.unapplied),
177             hidden = list(stack.patchorder.hidden),
178             patches = dict((pn, stack.patches.get(pn).commit)
179                            for pn in stack.patchorder.all),
180             message = message)
181     @staticmethod
182     def __parse_metadata(repo, metadata):
183         """Parse a stack log metadata string."""
184         if not metadata.startswith('Version:'):
185             raise LogParseException('Malformed log metadata')
186         metadata = metadata.splitlines()
187         version_str = utils.strip_prefix('Version:', metadata.pop(0)).strip()
188         try:
189             version = int(version_str)
190         except ValueError:
191             raise LogParseException(
192                 'Malformed version number: %r' % version_str)
193         if version < 1:
194             raise LogException('Log is version %d, which is too old' % version)
195         if version > 1:
196             raise LogException('Log is version %d, which is too new' % version)
197         parsed = {}
198         for line in metadata:
199             if line.startswith(' '):
200                 parsed[key].append(line.strip())
201             else:
202                 key, val = [x.strip() for x in line.split(':', 1)]
203                 if val:
204                     parsed[key] = val
205                 else:
206                     parsed[key] = []
207         prev = parsed['Previous']
208         if prev == 'None':
209             prev = None
210         else:
211             prev = repo.get_commit(prev)
212         head = repo.get_commit(parsed['Head'])
213         lists = { 'Applied': [], 'Unapplied': [], 'Hidden': [] }
214         patches = {}
215         for lst in lists.keys():
216             for entry in parsed[lst]:
217                 pn, sha1 = [x.strip() for x in entry.split(':')]
218                 lists[lst].append(pn)
219                 patches[pn] = repo.get_commit(sha1)
220         return (prev, head, lists['Applied'], lists['Unapplied'],
221                 lists['Hidden'], patches)
222     @classmethod
223     def from_commit(cls, repo, commit):
224         """Parse a (full or simplified) stack log commit."""
225         message = commit.data.message
226         try:
227             perm, meta = commit.data.tree.data.entries['meta']
228         except KeyError:
229             raise LogParseException('Not a stack log')
230         (prev, head, applied, unapplied, hidden, patches
231          ) = cls.__parse_metadata(repo, meta.data.str)
232         lg = cls(repo, prev, head, applied, unapplied, hidden, patches, message)
233         lg.commit = commit
234         return lg
235     def __metadata_string(self):
236         e = StringIO.StringIO()
237         e.write('Version: 1\n')
238         if self.prev == None:
239             e.write('Previous: None\n')
240         else:
241             e.write('Previous: %s\n' % self.prev.commit.sha1)
242         e.write('Head: %s\n' % self.head.sha1)
243         for lst, title in [(self.applied, 'Applied'),
244                            (self.unapplied, 'Unapplied'),
245                            (self.hidden, 'Hidden')]:
246             e.write('%s:\n' % title)
247             for pn in lst:
248                 e.write('  %s: %s\n' % (pn, self.patches[pn].sha1))
249         return e.getvalue()
250     def __parents(self):
251         """Return the set of parents this log entry needs in order to be a
252         descendant of all the commits it refers to."""
253         xp = set([self.head]) | set(self.patches[pn]
254                                     for pn in self.unapplied + self.hidden)
255         if self.applied:
256             xp.add(self.patches[self.applied[-1]])
257         if self.prev != None:
258             xp.add(self.prev.commit)
259             xp -= set(self.prev.patches.values())
260         return xp
261     def __tree(self, metadata):
262         if self.prev == None:
263             def pf(c):
264                 return patch_file(self.__repo, c.data)
265         else:
266             prev_top_tree = self.prev.commit.data.tree
267             perm, prev_patch_tree = prev_top_tree.data.entries['patches']
268             # Map from Commit object to patch_file() results taken
269             # from the previous log entry.
270             c2b = dict((self.prev.patches[pn], pf) for pn, pf
271                        in prev_patch_tree.data.entries.iteritems())
272             def pf(c):
273                 r = c2b.get(c, None)
274                 if not r:
275                     r = patch_file(self.__repo, c.data)
276                 return r
277         patches = dict((pn, pf(c)) for pn, c in self.patches.iteritems())
278         return self.__repo.commit(git.TreeData({
279                     'meta': self.__repo.commit(git.BlobData(metadata)),
280                     'patches': self.__repo.commit(git.TreeData(patches)) }))
281     def write_commit(self):
282         metadata = self.__metadata_string()
283         tree = self.__tree(metadata)
284         self.__simplified = self.__repo.commit(git.CommitData(
285                 tree = tree, message = self.message,
286                 parents = [prev.simplified for prev in [self.prev]
287                            if prev != None]))
288         parents = list(self.__parents())
289         while len(parents) >= self.__max_parents:
290             g = self.__repo.commit(git.CommitData(
291                     tree = tree, parents = parents[-self.__max_parents:],
292                     message = 'Stack log parent grouping'))
293             parents[-self.__max_parents:] = [g]
294         self.commit = self.__repo.commit(git.CommitData(
295                 tree = tree, message = self.message,
296                 parents = [self.simplified] + parents))
297
298 def get_log_entry(repo, ref, commit):
299     try:
300         return LogEntry.from_commit(repo, commit)
301     except LogException, e:
302         raise LogException('While reading log from %s: %s' % (ref, e))
303
304 def same_state(log1, log2):
305     """Check whether two log entries describe the same current state."""
306     s = [[lg.head, lg.applied, lg.unapplied, lg.hidden, lg.patches]
307          for lg in [log1, log2]]
308     return s[0] == s[1]
309
310 def log_entry(stack, msg):
311     """Write a new log entry for the stack."""
312     ref = log_ref(stack.name)
313     try:
314         last_log_commit = stack.repository.refs.get(ref)
315     except KeyError:
316         last_log_commit = None
317     try:
318         if last_log_commit:
319             last_log = get_log_entry(stack.repository, ref, last_log_commit)
320         else:
321             last_log = None
322         new_log = LogEntry.from_stack(last_log, stack, msg)
323     except LogException, e:
324         out.warn(str(e), 'No log entry written.')
325         return
326     if last_log and same_state(last_log, new_log):
327         return
328     new_log.write_commit()
329     stack.repository.refs.set(ref, new_log.commit, msg)
330
331 class Fakestack(object):
332     """Imitates a real L{Stack<stgit.lib.stack.Stack>}, but with the
333     topmost patch popped."""
334     def __init__(self, stack):
335         appl = list(stack.patchorder.applied)
336         unappl = list(stack.patchorder.unapplied)
337         hidd = list(stack.patchorder.hidden)
338         class patchorder(object):
339             applied = appl[:-1]
340             unapplied = [appl[-1]] + unappl
341             hidden = hidd
342             all = appl + unappl + hidd
343         self.patchorder = patchorder
344         class patches(object):
345             @staticmethod
346             def get(pn):
347                 if pn == appl[-1]:
348                     class patch(object):
349                         commit = stack.patches.get(pn).old_commit
350                     return patch
351                 else:
352                     return stack.patches.get(pn)
353         self.patches = patches
354         self.head = stack.head.data.parent
355         self.top = stack.top.data.parent
356         self.base = stack.base
357         self.name = stack.name
358         self.repository = stack.repository
359 def compat_log_entry(msg):
360     """Write a new log entry. (Convenience function intended for use by
361     code not yet converted to the new infrastructure.)"""
362     repo = default_repo()
363     try:
364         stack = repo.get_stack(repo.current_branch_name)
365     except libstack.StackException, e:
366         out.warn(str(e), 'Could not write to stack log')
367     else:
368         if repo.default_index.conflicts() and stack.patchorder.applied:
369             log_entry(Fakestack(stack), msg)
370             log_entry(stack, msg + ' (CONFLICT)')
371         else:
372             log_entry(stack, msg)
373
374 def delete_log(repo, branch):
375     ref = log_ref(branch)
376     if repo.refs.exists(ref):
377         repo.refs.delete(ref)
378
379 def rename_log(repo, old_branch, new_branch, msg):
380     old_ref = log_ref(old_branch)
381     new_ref = log_ref(new_branch)
382     if repo.refs.exists(old_ref):
383         repo.refs.set(new_ref, repo.refs.get(old_ref), msg)
384         repo.refs.delete(old_ref)
385
386 def copy_log(repo, src_branch, dst_branch, msg):
387     src_ref = log_ref(src_branch)
388     dst_ref = log_ref(dst_branch)
389     if repo.refs.exists(src_ref):
390         repo.refs.set(dst_ref, repo.refs.get(src_ref), msg)
391
392 def default_repo():
393     return libstack.Repository.default()
394
395 def reset_stack(trans, iw, state):
396     """Reset the stack to a given previous state."""
397     for pn in trans.all_patches:
398         trans.patches[pn] = None
399     for pn in state.all_patches:
400         trans.patches[pn] = state.patches[pn]
401     trans.applied = state.applied
402     trans.unapplied = state.unapplied
403     trans.hidden = state.hidden
404     trans.base = state.base
405     trans.head = state.head
406
407 def reset_stack_partially(trans, iw, state, only_patches):
408     """Reset the stack to a given previous state -- but only the given
409     patches, not anything else.
410
411     @param only_patches: Touch only these patches
412     @type only_patches: iterable"""
413     only_patches = set(only_patches)
414     patches_to_reset = set(state.all_patches) & only_patches
415     existing_patches = set(trans.all_patches)
416     original_applied_order = list(trans.applied)
417     to_delete = (existing_patches - patches_to_reset) & only_patches
418
419     # In one go, do all the popping we have to in order to pop the
420     # patches we're going to delete or modify.
421     def mod(pn):
422         if not pn in only_patches:
423             return False
424         if pn in to_delete:
425             return True
426         if trans.patches[pn] != state.patches.get(pn, None):
427             return True
428         return False
429     trans.pop_patches(mod)
430
431     # Delete and modify/create patches. We've previously popped all
432     # patches that we touch in this step.
433     trans.delete_patches(lambda pn: pn in to_delete)
434     for pn in patches_to_reset:
435         if pn in existing_patches:
436             if trans.patches[pn] == state.patches[pn]:
437                 continue
438             else:
439                 out.info('Resetting %s' % pn)
440         else:
441             if pn in state.hidden:
442                 trans.hidden.append(pn)
443             else:
444                 trans.unapplied.append(pn)
445             out.info('Resurrecting %s' % pn)
446         trans.patches[pn] = state.patches[pn]
447
448     # Push all the patches that we've popped, if they still
449     # exist.
450     pushable = set(trans.unapplied + trans.hidden)
451     for pn in original_applied_order:
452         if pn in pushable:
453             trans.push_patch(pn, iw)
454
455 def undo_state(stack, undo_steps):
456     """Find the log entry C{undo_steps} steps in the past. (Successive
457     undo operations are supposed to "add up", so if we find other undo
458     operations along the way we have to add those undo steps to
459     C{undo_steps}.)
460
461     If C{undo_steps} is negative, redo instead of undo.
462
463     @return: The log entry that is the destination of the undo
464              operation
465     @rtype: L{LogEntry}"""
466     ref = log_ref(stack.name)
467     try:
468         commit = stack.repository.refs.get(ref)
469     except KeyError:
470         raise LogException('Log is empty')
471     log = get_log_entry(stack.repository, ref, commit)
472     while undo_steps != 0:
473         msg = log.message.strip()
474         um = re.match(r'^undo\s+(\d+)$', msg)
475         if undo_steps > 0:
476             if um:
477                 undo_steps += int(um.group(1))
478             else:
479                 undo_steps -= 1
480         else:
481             rm = re.match(r'^redo\s+(\d+)$', msg)
482             if um:
483                 undo_steps += 1
484             elif rm:
485                 undo_steps -= int(rm.group(1))
486             else:
487                 raise LogException('No more redo information available')
488         if not log.prev:
489             raise LogException('Not enough undo information available')
490         log = log.prev
491     return log
492
493 def log_external_mods(stack):
494     ref = log_ref(stack.name)
495     try:
496         log_commit = stack.repository.refs.get(ref)
497     except KeyError:
498         # No log exists yet.
499         log_entry(stack, 'start of log')
500         return
501     try:
502         log = get_log_entry(stack.repository, ref, log_commit)
503     except LogException:
504         # Something's wrong with the log, so don't bother.
505         return
506     if log.head == stack.head:
507         # No external modifications.
508         return
509     log_entry(stack, '\n'.join([
510                 'external modifications', '',
511                 'Modifications by tools other than StGit (e.g. git).']))
512
513 def compat_log_external_mods():
514     try:
515         repo = default_repo()
516     except git.RepositoryException:
517         # No repository, so we couldn't log even if we wanted to.
518         return
519     try:
520         stack = repo.get_stack(repo.current_branch_name)
521     except exception.StgException:
522         # Stack doesn't exist, so we can't log.
523         return
524     log_external_mods(stack)