chiark / gitweb /
9568e8f9d1b77d27bb3c222467fc062e59355afc
[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 from stgit.lib import git, stack as libstack
103 from stgit import exception, utils
104 from stgit.out import out
105 import StringIO
106
107 class LogException(exception.StgException):
108     pass
109
110 class LogParseException(LogException):
111     pass
112
113 def patch_file(repo, cd):
114     return repo.commit(git.BlobData(''.join(s + '\n' for s in [
115                     'Bottom: %s' % cd.parent.data.tree.sha1,
116                     'Top:    %s' % cd.tree.sha1,
117                     'Author: %s' % cd.author.name_email,
118                     'Date:   %s' % cd.author.date,
119                     '',
120                     cd.message,
121                     '',
122                     '---',
123                     '',
124                     repo.diff_tree(cd.parent.data.tree, cd.tree, ['-M']
125                                    ).strip()])))
126
127 def log_ref(branch):
128     return 'refs/heads/%s.stgit' % branch
129
130 class LogEntry(object):
131     __separator = '\n---\n'
132     __max_parents = 16
133     def __init__(self, repo, prev, head, applied, unapplied, hidden,
134                  patches, message):
135         self.__repo = repo
136         self.__prev = prev
137         self.__simplified = None
138         self.head = head
139         self.applied = applied
140         self.unapplied = unapplied
141         self.hidden = hidden
142         self.patches = patches
143         self.message = message
144     @property
145     def simplified(self):
146         if not self.__simplified:
147             self.__simplified = self.commit.data.parents[0]
148         return self.__simplified
149     @property
150     def prev(self):
151         if self.__prev != None and not isinstance(self.__prev, LogEntry):
152             self.__prev = self.from_commit(self.__repo, self.__prev)
153         return self.__prev
154     @property
155     def base(self):
156         if self.applied:
157             return self.patches[self.applied[0]].data.parent
158         else:
159             return self.head
160     @classmethod
161     def from_stack(cls, prev, stack, message):
162         return cls(
163             repo = stack.repository,
164             prev = prev,
165             head = stack.head,
166             applied = list(stack.patchorder.applied),
167             unapplied = list(stack.patchorder.unapplied),
168             hidden = list(stack.patchorder.hidden),
169             patches = dict((pn, stack.patches.get(pn).commit)
170                            for pn in stack.patchorder.all),
171             message = message)
172     @staticmethod
173     def __parse_metadata(repo, metadata):
174         """Parse a stack log metadata string."""
175         if not metadata.startswith('Version:'):
176             raise LogParseException('Malformed log metadata')
177         metadata = metadata.splitlines()
178         version_str = utils.strip_prefix('Version:', metadata.pop(0)).strip()
179         try:
180             version = int(version_str)
181         except ValueError:
182             raise LogParseException(
183                 'Malformed version number: %r' % version_str)
184         if version < 1:
185             raise LogException('Log is version %d, which is too old' % version)
186         if version > 1:
187             raise LogException('Log is version %d, which is too new' % version)
188         parsed = {}
189         for line in metadata:
190             if line.startswith(' '):
191                 parsed[key].append(line.strip())
192             else:
193                 key, val = [x.strip() for x in line.split(':', 1)]
194                 if val:
195                     parsed[key] = val
196                 else:
197                     parsed[key] = []
198         prev = parsed['Previous']
199         if prev == 'None':
200             prev = None
201         else:
202             prev = repo.get_commit(prev)
203         head = repo.get_commit(parsed['Head'])
204         lists = { 'Applied': [], 'Unapplied': [], 'Hidden': [] }
205         patches = {}
206         for lst in lists.keys():
207             for entry in parsed[lst]:
208                 pn, sha1 = [x.strip() for x in entry.split(':')]
209                 lists[lst].append(pn)
210                 patches[pn] = repo.get_commit(sha1)
211         return (prev, head, lists['Applied'], lists['Unapplied'],
212                 lists['Hidden'], patches)
213     @classmethod
214     def from_commit(cls, repo, commit):
215         """Parse a (full or simplified) stack log commit."""
216         message = commit.data.message
217         try:
218             perm, meta = commit.data.tree.data.entries['meta']
219         except KeyError:
220             raise LogParseException('Not a stack log')
221         (prev, head, applied, unapplied, hidden, patches
222          ) = cls.__parse_metadata(repo, meta.data.str)
223         lg = cls(repo, prev, head, applied, unapplied, hidden, patches, message)
224         lg.commit = commit
225         return lg
226     def __metadata_string(self):
227         e = StringIO.StringIO()
228         e.write('Version: 1\n')
229         if self.prev == None:
230             e.write('Previous: None\n')
231         else:
232             e.write('Previous: %s\n' % self.prev.commit.sha1)
233         e.write('Head: %s\n' % self.head.sha1)
234         for lst, title in [(self.applied, 'Applied'),
235                            (self.unapplied, 'Unapplied'),
236                            (self.hidden, 'Hidden')]:
237             e.write('%s:\n' % title)
238             for pn in lst:
239                 e.write('  %s: %s\n' % (pn, self.patches[pn].sha1))
240         return e.getvalue()
241     def __parents(self):
242         """Return the set of parents this log entry needs in order to be a
243         descendant of all the commits it refers to."""
244         xp = set([self.head]) | set(self.patches[pn]
245                                     for pn in self.unapplied + self.hidden)
246         if self.applied:
247             xp.add(self.patches[self.applied[-1]])
248         if self.prev != None:
249             xp.add(self.prev.commit)
250             xp -= set(self.prev.patches.values())
251         return xp
252     def __tree(self, metadata):
253         if self.prev == None:
254             def pf(c):
255                 return patch_file(self.__repo, c.data)
256         else:
257             prev_top_tree = self.prev.commit.data.tree
258             perm, prev_patch_tree = prev_top_tree.data.entries['patches']
259             # Map from Commit object to patch_file() results taken
260             # from the previous log entry.
261             c2b = dict((self.prev.patches[pn], pf) for pn, pf
262                        in prev_patch_tree.data.entries.iteritems())
263             def pf(c):
264                 r = c2b.get(c, None)
265                 if not r:
266                     r = patch_file(self.__repo, c.data)
267                 return r
268         patches = dict((pn, pf(c)) for pn, c in self.patches.iteritems())
269         return self.__repo.commit(git.TreeData({
270                     'meta': self.__repo.commit(git.BlobData(metadata)),
271                     'patches': self.__repo.commit(git.TreeData(patches)) }))
272     def write_commit(self):
273         metadata = self.__metadata_string()
274         tree = self.__tree(metadata)
275         self.__simplified = self.__repo.commit(git.CommitData(
276                 tree = tree, message = self.message,
277                 parents = [prev.simplified for prev in [self.prev]
278                            if prev != None]))
279         parents = list(self.__parents())
280         while len(parents) >= self.__max_parents:
281             g = self.__repo.commit(git.CommitData(
282                     tree = tree, parents = parents[-self.__max_parents:],
283                     message = 'Stack log parent grouping'))
284             parents[-self.__max_parents:] = [g]
285         self.commit = self.__repo.commit(git.CommitData(
286                 tree = tree, message = self.message,
287                 parents = [self.simplified] + parents))
288
289 def get_log_entry(repo, ref, commit):
290     try:
291         return LogEntry.from_commit(repo, commit)
292     except LogException, e:
293         raise LogException('While reading log from %s: %s' % (ref, e))
294
295 def same_state(log1, log2):
296     """Check whether two log entries describe the same current state."""
297     s = [[lg.head, lg.applied, lg.unapplied, lg.hidden, lg.patches]
298          for lg in [log1, log2]]
299     return s[0] == s[1]
300
301 def log_entry(stack, msg):
302     """Write a new log entry for the stack."""
303     ref = log_ref(stack.name)
304     try:
305         last_log_commit = stack.repository.refs.get(ref)
306     except KeyError:
307         last_log_commit = None
308     try:
309         if last_log_commit:
310             last_log = get_log_entry(stack.repository, ref, last_log_commit)
311         else:
312             last_log = None
313         new_log = LogEntry.from_stack(last_log, stack, msg)
314     except LogException, e:
315         out.warn(str(e), 'No log entry written.')
316         return
317     if last_log and same_state(last_log, new_log):
318         return
319     new_log.write_commit()
320     stack.repository.refs.set(ref, new_log.commit, msg)
321
322 class Fakestack(object):
323     """Imitates a real L{Stack<stgit.lib.stack.Stack>}, but with the
324     topmost patch popped."""
325     def __init__(self, stack):
326         appl = list(stack.patchorder.applied)
327         unappl = list(stack.patchorder.unapplied)
328         hidd = list(stack.patchorder.hidden)
329         class patchorder(object):
330             applied = appl[:-1]
331             unapplied = [appl[-1]] + unappl
332             hidden = hidd
333             all = appl + unappl + hidd
334         self.patchorder = patchorder
335         class patches(object):
336             @staticmethod
337             def get(pn):
338                 if pn == appl[-1]:
339                     class patch(object):
340                         commit = stack.patches.get(pn).old_commit
341                     return patch
342                 else:
343                     return stack.patches.get(pn)
344         self.patches = patches
345         self.head = stack.head.data.parent
346         self.top = stack.top.data.parent
347         self.base = stack.base
348         self.name = stack.name
349         self.repository = stack.repository
350 def compat_log_entry(msg):
351     """Write a new log entry. (Convenience function intended for use by
352     code not yet converted to the new infrastructure.)"""
353     repo = default_repo()
354     try:
355         stack = repo.get_stack(repo.current_branch_name)
356     except libstack.StackException, e:
357         out.warn(str(e), 'Could not write to stack log')
358     else:
359         if repo.default_index.conflicts() and stack.patchorder.applied:
360             log_entry(Fakestack(stack), msg)
361             log_entry(stack, msg + ' (CONFLICT)')
362         else:
363             log_entry(stack, msg)
364
365 def delete_log(repo, branch):
366     ref = log_ref(branch)
367     if repo.refs.exists(ref):
368         repo.refs.delete(ref)
369
370 def rename_log(repo, old_branch, new_branch, msg):
371     old_ref = log_ref(old_branch)
372     new_ref = log_ref(new_branch)
373     if repo.refs.exists(old_ref):
374         repo.refs.set(new_ref, repo.refs.get(old_ref), msg)
375         repo.refs.delete(old_ref)
376
377 def copy_log(repo, src_branch, dst_branch, msg):
378     src_ref = log_ref(src_branch)
379     dst_ref = log_ref(dst_branch)
380     if repo.refs.exists(src_ref):
381         repo.refs.set(dst_ref, repo.refs.get(src_ref), msg)
382
383 def default_repo():
384     return libstack.Repository.default()