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