chiark / gitweb /
cb32d3e3406bc0d81b3177b41d29f68963d00004
[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     @classmethod
155     def from_stack(cls, prev, stack, message):
156         return cls(
157             repo = stack.repository,
158             prev = prev,
159             head = stack.head,
160             applied = list(stack.patchorder.applied),
161             unapplied = list(stack.patchorder.unapplied),
162             hidden = list(stack.patchorder.hidden),
163             patches = dict((pn, stack.patches.get(pn).commit)
164                            for pn in stack.patchorder.all),
165             message = message)
166     @staticmethod
167     def __parse_metadata(repo, metadata):
168         """Parse a stack log metadata string."""
169         if not metadata.startswith('Version:'):
170             raise LogParseException('Malformed log metadata')
171         metadata = metadata.splitlines()
172         version_str = utils.strip_prefix('Version:', metadata.pop(0)).strip()
173         try:
174             version = int(version_str)
175         except ValueError:
176             raise LogParseException(
177                 'Malformed version number: %r' % version_str)
178         if version < 1:
179             raise LogException('Log is version %d, which is too old' % version)
180         if version > 1:
181             raise LogException('Log is version %d, which is too new' % version)
182         parsed = {}
183         for line in metadata:
184             if line.startswith(' '):
185                 parsed[key].append(line.strip())
186             else:
187                 key, val = [x.strip() for x in line.split(':', 1)]
188                 if val:
189                     parsed[key] = val
190                 else:
191                     parsed[key] = []
192         prev = parsed['Previous']
193         if prev == 'None':
194             prev = None
195         else:
196             prev = repo.get_commit(prev)
197         head = repo.get_commit(parsed['Head'])
198         lists = { 'Applied': [], 'Unapplied': [], 'Hidden': [] }
199         patches = {}
200         for lst in lists.keys():
201             for entry in parsed[lst]:
202                 pn, sha1 = [x.strip() for x in entry.split(':')]
203                 lists[lst].append(pn)
204                 patches[pn] = repo.get_commit(sha1)
205         return (prev, head, lists['Applied'], lists['Unapplied'],
206                 lists['Hidden'], patches)
207     @classmethod
208     def from_commit(cls, repo, commit):
209         """Parse a (full or simplified) stack log commit."""
210         message = commit.data.message
211         try:
212             perm, meta = commit.data.tree.data.entries['meta']
213         except KeyError:
214             raise LogParseException('Not a stack log')
215         (prev, head, applied, unapplied, hidden, patches
216          ) = cls.__parse_metadata(repo, meta.data.str)
217         lg = cls(repo, prev, head, applied, unapplied, hidden, patches, message)
218         lg.commit = commit
219         return lg
220     def __metadata_string(self):
221         e = StringIO.StringIO()
222         e.write('Version: 1\n')
223         if self.prev == None:
224             e.write('Previous: None\n')
225         else:
226             e.write('Previous: %s\n' % self.prev.commit.sha1)
227         e.write('Head: %s\n' % self.head.sha1)
228         for lst, title in [(self.applied, 'Applied'),
229                            (self.unapplied, 'Unapplied'),
230                            (self.hidden, 'Hidden')]:
231             e.write('%s:\n' % title)
232             for pn in lst:
233                 e.write('  %s: %s\n' % (pn, self.patches[pn].sha1))
234         return e.getvalue()
235     def __parents(self):
236         """Return the set of parents this log entry needs in order to be a
237         descendant of all the commits it refers to."""
238         xp = set([self.head]) | set(self.patches[pn]
239                                     for pn in self.unapplied + self.hidden)
240         if self.applied:
241             xp.add(self.patches[self.applied[-1]])
242         if self.prev != None:
243             xp.add(self.prev.commit)
244             xp -= set(self.prev.patches.values())
245         return xp
246     def __tree(self, metadata):
247         if self.prev == None:
248             def pf(c):
249                 return patch_file(self.__repo, c.data)
250         else:
251             prev_top_tree = self.prev.commit.data.tree
252             perm, prev_patch_tree = prev_top_tree.data.entries['patches']
253             # Map from Commit object to patch_file() results taken
254             # from the previous log entry.
255             c2b = dict((self.prev.patches[pn], pf) for pn, pf
256                        in prev_patch_tree.data.entries.iteritems())
257             def pf(c):
258                 r = c2b.get(c, None)
259                 if not r:
260                     r = patch_file(self.__repo, c.data)
261                 return r
262         patches = dict((pn, pf(c)) for pn, c in self.patches.iteritems())
263         return self.__repo.commit(git.TreeData({
264                     'meta': self.__repo.commit(git.BlobData(metadata)),
265                     'patches': self.__repo.commit(git.TreeData(patches)) }))
266     def write_commit(self):
267         metadata = self.__metadata_string()
268         tree = self.__tree(metadata)
269         self.__simplified = self.__repo.commit(git.CommitData(
270                 tree = tree, message = self.message,
271                 parents = [prev.simplified for prev in [self.prev]
272                            if prev != None]))
273         parents = list(self.__parents())
274         while len(parents) >= self.__max_parents:
275             g = self.__repo.commit(git.CommitData(
276                     tree = tree, parents = parents[-self.__max_parents:],
277                     message = 'Stack log parent grouping'))
278             parents[-self.__max_parents:] = [g]
279         self.commit = self.__repo.commit(git.CommitData(
280                 tree = tree, message = self.message,
281                 parents = [self.simplified] + parents))
282
283 def log_entry(stack, msg):
284     """Write a new log entry for the stack."""
285     ref = log_ref(stack.name)
286     try:
287         last_log = stack.repository.refs.get(ref)
288     except KeyError:
289         last_log = None
290     try:
291         new_log = LogEntry.from_stack(last_log, stack, msg)
292     except LogException, e:
293         out.warn(str(e), 'No log entry written.')
294         return
295     new_log.write_commit()
296     stack.repository.refs.set(ref, new_log.commit, msg)
297
298 def compat_log_entry(msg):
299     """Write a new log entry. (Convenience function intended for use by
300     code not yet converted to the new infrastructure.)"""
301     repo = default_repo()
302     try:
303         stack = repo.get_stack(repo.current_branch_name)
304     except libstack.StackException, e:
305         out.warn(str(e), 'Could not write to stack log')
306     else:
307         log_entry(stack, msg)
308
309 def delete_log(repo, branch):
310     ref = log_ref(branch)
311     if repo.refs.exists(ref):
312         repo.refs.delete(ref)
313
314 def rename_log(repo, old_branch, new_branch, msg):
315     old_ref = log_ref(old_branch)
316     new_ref = log_ref(new_branch)
317     if repo.refs.exists(old_ref):
318         repo.refs.set(new_ref, repo.refs.get(old_ref), msg)
319         repo.refs.delete(old_ref)
320
321 def copy_log(repo, src_branch, dst_branch, msg):
322     src_ref = log_ref(src_branch)
323     dst_ref = log_ref(dst_branch)
324     if repo.refs.exists(src_ref):
325         repo.refs.set(dst_ref, repo.refs.get(src_ref), msg)
326
327 def default_repo():
328     return libstack.Repository.default()