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)