chiark / gitweb /
8b7d2e420207e6a16a24d972462c771d2f6d15d8
[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     @classmethod
168     def from_stack(cls, prev, stack, message):
169         return cls(
170             repo = stack.repository,
171             prev = prev,
172             head = stack.head,
173             applied = list(stack.patchorder.applied),
174             unapplied = list(stack.patchorder.unapplied),
175             hidden = list(stack.patchorder.hidden),
176             patches = dict((pn, stack.patches.get(pn).commit)
177                            for pn in stack.patchorder.all),
178             message = message)
179     @staticmethod
180     def __parse_metadata(repo, metadata):
181         """Parse a stack log metadata string."""
182         if not metadata.startswith('Version:'):
183             raise LogParseException('Malformed log metadata')
184         metadata = metadata.splitlines()
185         version_str = utils.strip_prefix('Version:', metadata.pop(0)).strip()
186         try:
187             version = int(version_str)
188         except ValueError:
189             raise LogParseException(
190                 'Malformed version number: %r' % version_str)
191         if version < 1:
192             raise LogException('Log is version %d, which is too old' % version)
193         if version > 1:
194             raise LogException('Log is version %d, which is too new' % version)
195         parsed = {}
196         for line in metadata:
197             if line.startswith(' '):
198                 parsed[key].append(line.strip())
199             else:
200                 key, val = [x.strip() for x in line.split(':', 1)]
201                 if val:
202                     parsed[key] = val
203                 else:
204                     parsed[key] = []
205         prev = parsed['Previous']
206         if prev == 'None':
207             prev = None
208         else:
209             prev = repo.get_commit(prev)
210         head = repo.get_commit(parsed['Head'])
211         lists = { 'Applied': [], 'Unapplied': [], 'Hidden': [] }
212         patches = {}
213         for lst in lists.keys():
214             for entry in parsed[lst]:
215                 pn, sha1 = [x.strip() for x in entry.split(':')]
216                 lists[lst].append(pn)
217                 patches[pn] = repo.get_commit(sha1)
218         return (prev, head, lists['Applied'], lists['Unapplied'],
219                 lists['Hidden'], patches)
220     @classmethod
221     def from_commit(cls, repo, commit):
222         """Parse a (full or simplified) stack log commit."""
223         message = commit.data.message
224         try:
225             perm, meta = commit.data.tree.data.entries['meta']
226         except KeyError:
227             raise LogParseException('Not a stack log')
228         (prev, head, applied, unapplied, hidden, patches
229          ) = cls.__parse_metadata(repo, meta.data.str)
230         lg = cls(repo, prev, head, applied, unapplied, hidden, patches, message)
231         lg.commit = commit
232         return lg
233     def __metadata_string(self):
234         e = StringIO.StringIO()
235         e.write('Version: 1\n')
236         if self.prev == None:
237             e.write('Previous: None\n')
238         else:
239             e.write('Previous: %s\n' % self.prev.commit.sha1)
240         e.write('Head: %s\n' % self.head.sha1)
241         for lst, title in [(self.applied, 'Applied'),
242                            (self.unapplied, 'Unapplied'),
243                            (self.hidden, 'Hidden')]:
244             e.write('%s:\n' % title)
245             for pn in lst:
246                 e.write('  %s: %s\n' % (pn, self.patches[pn].sha1))
247         return e.getvalue()
248     def __parents(self):
249         """Return the set of parents this log entry needs in order to be a
250         descendant of all the commits it refers to."""
251         xp = set([self.head]) | set(self.patches[pn]
252                                     for pn in self.unapplied + self.hidden)
253         if self.applied:
254             xp.add(self.patches[self.applied[-1]])
255         if self.prev != None:
256             xp.add(self.prev.commit)
257             xp -= set(self.prev.patches.values())
258         return xp
259     def __tree(self, metadata):
260         if self.prev == None:
261             def pf(c):
262                 return patch_file(self.__repo, c.data)
263         else:
264             prev_top_tree = self.prev.commit.data.tree
265             perm, prev_patch_tree = prev_top_tree.data.entries['patches']
266             # Map from Commit object to patch_file() results taken
267             # from the previous log entry.
268             c2b = dict((self.prev.patches[pn], pf) for pn, pf
269                        in prev_patch_tree.data.entries.iteritems())
270             def pf(c):
271                 r = c2b.get(c, None)
272                 if not r:
273                     r = patch_file(self.__repo, c.data)
274                 return r
275         patches = dict((pn, pf(c)) for pn, c in self.patches.iteritems())
276         return self.__repo.commit(git.TreeData({
277                     'meta': self.__repo.commit(git.BlobData(metadata)),
278                     'patches': self.__repo.commit(git.TreeData(patches)) }))
279     def write_commit(self):
280         metadata = self.__metadata_string()
281         tree = self.__tree(metadata)
282         self.__simplified = self.__repo.commit(git.CommitData(
283                 tree = tree, message = self.message,
284                 parents = [prev.simplified for prev in [self.prev]
285                            if prev != None]))
286         parents = list(self.__parents())
287         while len(parents) >= self.__max_parents:
288             g = self.__repo.commit(git.CommitData(
289                     tree = tree, parents = parents[-self.__max_parents:],
290                     message = 'Stack log parent grouping'))
291             parents[-self.__max_parents:] = [g]
292         self.commit = self.__repo.commit(git.CommitData(
293                 tree = tree, message = self.message,
294                 parents = [self.simplified] + parents))
295
296 def get_log_entry(repo, ref, commit):
297     try:
298         return LogEntry.from_commit(repo, commit)
299     except LogException, e:
300         raise LogException('While reading log from %s: %s' % (ref, e))
301
302 def same_state(log1, log2):
303     """Check whether two log entries describe the same current state."""
304     s = [[lg.head, lg.applied, lg.unapplied, lg.hidden, lg.patches]
305          for lg in [log1, log2]]
306     return s[0] == s[1]
307
308 def log_entry(stack, msg):
309     """Write a new log entry for the stack."""
310     ref = log_ref(stack.name)
311     try:
312         last_log_commit = stack.repository.refs.get(ref)
313     except KeyError:
314         last_log_commit = None
315     try:
316         if last_log_commit:
317             last_log = get_log_entry(stack.repository, ref, last_log_commit)
318         else:
319             last_log = None
320         new_log = LogEntry.from_stack(last_log, stack, msg)
321     except LogException, e:
322         out.warn(str(e), 'No log entry written.')
323         return
324     if last_log and same_state(last_log, new_log):
325         return
326     new_log.write_commit()
327     stack.repository.refs.set(ref, new_log.commit, msg)
328
329 class Fakestack(object):
330     """Imitates a real L{Stack<stgit.lib.stack.Stack>}, but with the
331     topmost patch popped."""
332     def __init__(self, stack):
333         appl = list(stack.patchorder.applied)
334         unappl = list(stack.patchorder.unapplied)
335         hidd = list(stack.patchorder.hidden)
336         class patchorder(object):
337             applied = appl[:-1]
338             unapplied = [appl[-1]] + unappl
339             hidden = hidd
340             all = appl + unappl + hidd
341         self.patchorder = patchorder
342         class patches(object):
343             @staticmethod
344             def get(pn):
345                 if pn == appl[-1]:
346                     class patch(object):
347                         commit = stack.patches.get(pn).old_commit
348                     return patch
349                 else:
350                     return stack.patches.get(pn)
351         self.patches = patches
352         self.head = stack.head.data.parent
353         self.top = stack.top.data.parent
354         self.base = stack.base
355         self.name = stack.name
356         self.repository = stack.repository
357 def compat_log_entry(msg):
358     """Write a new log entry. (Convenience function intended for use by
359     code not yet converted to the new infrastructure.)"""
360     repo = default_repo()
361     try:
362         stack = repo.get_stack(repo.current_branch_name)
363     except libstack.StackException, e:
364         out.warn(str(e), 'Could not write to stack log')
365     else:
366         if repo.default_index.conflicts() and stack.patchorder.applied:
367             log_entry(Fakestack(stack), msg)
368             log_entry(stack, msg + ' (CONFLICT)')
369         else:
370             log_entry(stack, msg)
371
372 def delete_log(repo, branch):
373     ref = log_ref(branch)
374     if repo.refs.exists(ref):
375         repo.refs.delete(ref)
376
377 def rename_log(repo, old_branch, new_branch, msg):
378     old_ref = log_ref(old_branch)
379     new_ref = log_ref(new_branch)
380     if repo.refs.exists(old_ref):
381         repo.refs.set(new_ref, repo.refs.get(old_ref), msg)
382         repo.refs.delete(old_ref)
383
384 def copy_log(repo, src_branch, dst_branch, msg):
385     src_ref = log_ref(src_branch)
386     dst_ref = log_ref(dst_branch)
387     if repo.refs.exists(src_ref):
388         repo.refs.set(dst_ref, repo.refs.get(src_ref), msg)
389
390 def default_repo():
391     return libstack.Repository.default()
392
393 def reset_stack(trans, iw, state, only_patches):
394     """Reset the stack to a given previous state. If C{only_patches} is
395     not empty, touch only patches whose names appear in it.
396
397     @param only_patches: Reset only these patches
398     @type only_patches: iterable"""
399     only_patches = set(only_patches)
400     def mask(s):
401         if only_patches:
402             return s & only_patches
403         else:
404             return s
405     patches_to_reset = mask(set(state.applied + state.unapplied + state.hidden))
406     existing_patches = set(trans.all_patches)
407     original_applied_order = list(trans.applied)
408     to_delete = mask(existing_patches - patches_to_reset)
409
410     # If we have to change the stack base, we need to pop all patches
411     # first.
412     if not only_patches and trans.base != state.base:
413         trans.pop_patches(lambda pn: True)
414         out.info('Setting stack base to %s' % state.base.sha1)
415         trans.base = state.base
416
417     # In one go, do all the popping we have to in order to pop the
418     # patches we're going to delete or modify.
419     def mod(pn):
420         if only_patches and not pn in only_patches:
421             return False
422         if pn in to_delete:
423             return True
424         if trans.patches[pn] != state.patches.get(pn, None):
425             return True
426         return False
427     trans.pop_patches(mod)
428
429     # Delete and modify/create patches. We've previously popped all
430     # patches that we touch in this step.
431     trans.delete_patches(lambda pn: pn in to_delete)
432     for pn in patches_to_reset:
433         if pn in existing_patches:
434             if trans.patches[pn] == state.patches[pn]:
435                 continue
436             else:
437                 out.info('Resetting %s' % pn)
438         else:
439             if pn in state.hidden:
440                 trans.hidden.append(pn)
441             else:
442                 trans.unapplied.append(pn)
443             out.info('Resurrecting %s' % pn)
444         trans.patches[pn] = state.patches[pn]
445
446     # Push/pop patches as necessary.
447     if only_patches:
448         # Push all the patches that we've popped, if they still
449         # exist.
450         pushable = set(trans.unapplied)
451         for pn in original_applied_order:
452             if pn in pushable:
453                 trans.push_patch(pn, iw)
454     else:
455         # Recreate the exact order specified by the goal state.
456         trans.reorder_patches(state.applied, state.unapplied, state.hidden, iw)
457
458 def undo_state(stack, undo_steps):
459     """Find the log entry C{undo_steps} steps in the past. (Successive
460     undo operations are supposed to "add up", so if we find other undo
461     operations along the way we have to add those undo steps to
462     C{undo_steps}.)
463
464     @return: The log entry that is the destination of the undo
465              operation
466     @rtype: L{LogEntry}"""
467     ref = log_ref(stack.name)
468     try:
469         commit = stack.repository.refs.get(ref)
470     except KeyError:
471         raise LogException('Log is empty')
472     log = get_log_entry(stack.repository, ref, commit)
473     while undo_steps > 0:
474         msg = log.message.strip()
475         m = re.match(r'^undo\s+(\d+)$', msg)
476         if m:
477             undo_steps += int(m.group(1))
478         else:
479             undo_steps -= 1
480         if not log.prev:
481             raise LogException('Not enough undo information available')
482         log = log.prev
483     return log