| r"""This module contains functions and classes for manipulating |
| I{patch stack logs} (or just I{stack logs}). |
| |
| A stack log is a git branch. Each commit contains the complete state |
| of the stack at the moment it was written; the most recent commit has |
| the most recent state. |
| |
| For a branch C{I{foo}}, the stack log is stored in C{I{foo}.stgit}. |
| Each log entry makes sure to have proper references to everything it |
| needs, which means that it is safe against garbage collection -- you |
| can even pull it from one repository to another. |
| |
| Stack log format (version 0) |
| ============================ |
| |
| Version 0 was an experimental version of the stack log format; it is |
| no longer supported. |
| |
| Stack log format (version 1) |
| ============================ |
| |
| Commit message |
| -------------- |
| |
| The commit message is mostly for human consumption; in most cases it |
| is just a subject line: the stg subcommand name and possibly some |
| important command-line flag. |
| |
| An exception to this is log commits for undo and redo. Their subject |
| line is "C{undo I{n}}" and "C{redo I{n}}"; the positive integer I{n} |
| says how many steps were undone or redone. |
| |
| Tree |
| ---- |
| |
| - One blob, C{meta}, that contains the log data: |
| |
| - C{Version:} I{n} |
| |
| where I{n} must be 1. (Future versions of StGit might change |
| the log format; when this is done, this version number will be |
| incremented.) |
| |
| - C{Previous:} I{sha1 or C{None}} |
| |
| The commit of the previous log entry, or C{None} if this is |
| the first entry. |
| |
| - C{Head:} I{sha1} |
| |
| The current branch head. |
| |
| - C{Applied:} |
| |
| Marks the start of the list of applied patches. They are |
| listed in order, each on its own line: first one or more |
| spaces, then the patch name, then a colon, space, then the |
| patch's sha1. |
| |
| - C{Unapplied:} |
| |
| Same as C{Applied:}, but for the unapplied patches. |
| |
| - C{Hidden:} |
| |
| Same as C{Applied:}, but for the hidden patches. |
| |
| - One subtree, C{patches}, that contains one blob per patch:: |
| |
| Bottom: <sha1 of patch's bottom tree> |
| Top: <sha1 of patch's top tree> |
| Author: <author name and e-mail> |
| Date: <patch timestamp> |
| |
| <commit message> |
| |
| --- |
| |
| <patch diff> |
| |
| Following the message is a newline, three dashes, and another newline. |
| Then come, each on its own line, |
| |
| Parents |
| ------- |
| |
| - The first parent is the I{simplified log}, described below. |
| |
| - The rest of the parents are just there to make sure that all the |
| commits referred to in the log entry -- patches, branch head, |
| previous log entry -- are ancestors of the log commit. (This is |
| necessary to make the log safe with regard to garbage collection |
| and pulling.) |
| |
| Simplified log |
| -------------- |
| |
| The simplified log is exactly like the full log, except that its only |
| parent is the (simplified) previous log entry, if any. It's purpose is |
| mainly ease of visualization.""" |
| |
| import re |
| from stgit.lib import git, stack as libstack |
| from stgit import exception, utils |
| from stgit.out import out |
| import StringIO |
| |
| class LogException(exception.StgException): |
| pass |
| |
| class LogParseException(LogException): |
| pass |
| |
| def patch_file(repo, cd): |
| return repo.commit(git.BlobData(''.join(s + '\n' for s in [ |
| 'Bottom: %s' % cd.parent.data.tree.sha1, |
| 'Top: %s' % cd.tree.sha1, |
| 'Author: %s' % cd.author.name_email, |
| 'Date: %s' % cd.author.date, |
| '', |
| cd.message, |
| '', |
| '---', |
| '', |
| repo.diff_tree(cd.parent.data.tree, cd.tree, ['-M'] |
| ).strip()]))) |
| |
| def log_ref(branch): |
| return 'refs/heads/%s.stgit' % branch |
| |
| class LogEntry(object): |
| __separator = '\n---\n' |
| __max_parents = 16 |
| def __init__(self, repo, prev, head, applied, unapplied, hidden, |
| patches, message): |
| self.__repo = repo |
| self.__prev = prev |
| self.__simplified = None |
| self.head = head |
| self.applied = applied |
| self.unapplied = unapplied |
| self.hidden = hidden |
| self.patches = patches |
| self.message = message |
| @property |
| def simplified(self): |
| if not self.__simplified: |
| self.__simplified = self.commit.data.parents[0] |
| return self.__simplified |
| @property |
| def prev(self): |
| if self.__prev != None and not isinstance(self.__prev, LogEntry): |
| self.__prev = self.from_commit(self.__repo, self.__prev) |
| return self.__prev |
| @property |
| def base(self): |
| if self.applied: |
| return self.patches[self.applied[0]].data.parent |
| else: |
| return self.head |
| @property |
| def top(self): |
| if self.applied: |
| return self.patches[self.applied[-1]] |
| else: |
| return self.head |
| all_patches = property(lambda self: (self.applied + self.unapplied |
| + self.hidden)) |
| @classmethod |
| def from_stack(cls, prev, stack, message): |
| return cls( |
| repo = stack.repository, |
| prev = prev, |
| head = stack.head, |
| applied = list(stack.patchorder.applied), |
| unapplied = list(stack.patchorder.unapplied), |
| hidden = list(stack.patchorder.hidden), |
| patches = dict((pn, stack.patches.get(pn).commit) |
| for pn in stack.patchorder.all), |
| message = message) |
| @staticmethod |
| def __parse_metadata(repo, metadata): |
| """Parse a stack log metadata string.""" |
| if not metadata.startswith('Version:'): |
| raise LogParseException('Malformed log metadata') |
| metadata = metadata.splitlines() |
| version_str = utils.strip_prefix('Version:', metadata.pop(0)).strip() |
| try: |
| version = int(version_str) |
| except ValueError: |
| raise LogParseException( |
| 'Malformed version number: %r' % version_str) |
| if version < 1: |
| raise LogException('Log is version %d, which is too old' % version) |
| if version > 1: |
| raise LogException('Log is version %d, which is too new' % version) |
| parsed = {} |
| for line in metadata: |
| if line.startswith(' '): |
| parsed[key].append(line.strip()) |
| else: |
| key, val = [x.strip() for x in line.split(':', 1)] |
| if val: |
| parsed[key] = val |
| else: |
| parsed[key] = [] |
| prev = parsed['Previous'] |
| if prev == 'None': |
| prev = None |
| else: |
| prev = repo.get_commit(prev) |
| head = repo.get_commit(parsed['Head']) |
| lists = { 'Applied': [], 'Unapplied': [], 'Hidden': [] } |
| patches = {} |
| for lst in lists.keys(): |
| for entry in parsed[lst]: |
| pn, sha1 = [x.strip() for x in entry.split(':')] |
| lists[lst].append(pn) |
| patches[pn] = repo.get_commit(sha1) |
| return (prev, head, lists['Applied'], lists['Unapplied'], |
| lists['Hidden'], patches) |
| @classmethod |
| def from_commit(cls, repo, commit): |
| """Parse a (full or simplified) stack log commit.""" |
| message = commit.data.message |
| try: |
| perm, meta = commit.data.tree.data.entries['meta'] |
| except KeyError: |
| raise LogParseException('Not a stack log') |
| (prev, head, applied, unapplied, hidden, patches |
| ) = cls.__parse_metadata(repo, meta.data.str) |
| lg = cls(repo, prev, head, applied, unapplied, hidden, patches, message) |
| lg.commit = commit |
| return lg |
| def __metadata_string(self): |
| e = StringIO.StringIO() |
| e.write('Version: 1\n') |
| if self.prev == None: |
| e.write('Previous: None\n') |
| else: |
| e.write('Previous: %s\n' % self.prev.commit.sha1) |
| e.write('Head: %s\n' % self.head.sha1) |
| for lst, title in [(self.applied, 'Applied'), |
| (self.unapplied, 'Unapplied'), |
| (self.hidden, 'Hidden')]: |
| e.write('%s:\n' % title) |
| for pn in lst: |
| e.write(' %s: %s\n' % (pn, self.patches[pn].sha1)) |
| return e.getvalue() |
| def __parents(self): |
| """Return the set of parents this log entry needs in order to be a |
| descendant of all the commits it refers to.""" |
| xp = set([self.head]) | set(self.patches[pn] |
| for pn in self.unapplied + self.hidden) |
| if self.applied: |
| xp.add(self.patches[self.applied[-1]]) |
| if self.prev != None: |
| xp.add(self.prev.commit) |
| xp -= set(self.prev.patches.values()) |
| return xp |
| def __tree(self, metadata): |
| if self.prev == None: |
| def pf(c): |
| return patch_file(self.__repo, c.data) |
| else: |
| prev_top_tree = self.prev.commit.data.tree |
| perm, prev_patch_tree = prev_top_tree.data.entries['patches'] |
| # Map from Commit object to patch_file() results taken |
| # from the previous log entry. |
| c2b = dict((self.prev.patches[pn], pf) for pn, pf |
| in prev_patch_tree.data.entries.iteritems()) |
| def pf(c): |
| r = c2b.get(c, None) |
| if not r: |
| r = patch_file(self.__repo, c.data) |
| return r |
| patches = dict((pn, pf(c)) for pn, c in self.patches.iteritems()) |
| return self.__repo.commit(git.TreeData({ |
| 'meta': self.__repo.commit(git.BlobData(metadata)), |
| 'patches': self.__repo.commit(git.TreeData(patches)) })) |
| def write_commit(self): |
| metadata = self.__metadata_string() |
| tree = self.__tree(metadata) |
| self.__simplified = self.__repo.commit(git.CommitData( |
| tree = tree, message = self.message, |
| parents = [prev.simplified for prev in [self.prev] |
| if prev != None])) |
| parents = list(self.__parents()) |
| while len(parents) >= self.__max_parents: |
| g = self.__repo.commit(git.CommitData( |
| tree = tree, parents = parents[-self.__max_parents:], |
| message = 'Stack log parent grouping')) |
| parents[-self.__max_parents:] = [g] |
| self.commit = self.__repo.commit(git.CommitData( |
| tree = tree, message = self.message, |
| parents = [self.simplified] + parents)) |
| |
| def get_log_entry(repo, ref, commit): |
| try: |
| return LogEntry.from_commit(repo, commit) |
| except LogException, e: |
| raise LogException('While reading log from %s: %s' % (ref, e)) |
| |
| def same_state(log1, log2): |
| """Check whether two log entries describe the same current state.""" |
| s = [[lg.head, lg.applied, lg.unapplied, lg.hidden, lg.patches] |
| for lg in [log1, log2]] |
| return s[0] == s[1] |
| |
| def log_entry(stack, msg): |
| """Write a new log entry for the stack.""" |
| ref = log_ref(stack.name) |
| try: |
| last_log_commit = stack.repository.refs.get(ref) |
| except KeyError: |
| last_log_commit = None |
| try: |
| if last_log_commit: |
| last_log = get_log_entry(stack.repository, ref, last_log_commit) |
| else: |
| last_log = None |
| new_log = LogEntry.from_stack(last_log, stack, msg) |
| except LogException, e: |
| out.warn(str(e), 'No log entry written.') |
| return |
| if last_log and same_state(last_log, new_log): |
| return |
| new_log.write_commit() |
| stack.repository.refs.set(ref, new_log.commit, msg) |
| |
| class Fakestack(object): |
| """Imitates a real L{Stack<stgit.lib.stack.Stack>}, but with the |
| topmost patch popped.""" |
| def __init__(self, stack): |
| appl = list(stack.patchorder.applied) |
| unappl = list(stack.patchorder.unapplied) |
| hidd = list(stack.patchorder.hidden) |
| class patchorder(object): |
| applied = appl[:-1] |
| unapplied = [appl[-1]] + unappl |
| hidden = hidd |
| all = appl + unappl + hidd |
| self.patchorder = patchorder |
| class patches(object): |
| @staticmethod |
| def get(pn): |
| if pn == appl[-1]: |
| class patch(object): |
| commit = stack.patches.get(pn).old_commit |
| return patch |
| else: |
| return stack.patches.get(pn) |
| self.patches = patches |
| self.head = stack.head.data.parent |
| self.top = stack.top.data.parent |
| self.base = stack.base |
| self.name = stack.name |
| self.repository = stack.repository |
| def compat_log_entry(msg): |
| """Write a new log entry. (Convenience function intended for use by |
| code not yet converted to the new infrastructure.)""" |
| repo = default_repo() |
| try: |
| stack = repo.get_stack(repo.current_branch_name) |
| except libstack.StackException, e: |
| out.warn(str(e), 'Could not write to stack log') |
| else: |
| if repo.default_index.conflicts() and stack.patchorder.applied: |
| log_entry(Fakestack(stack), msg) |
| log_entry(stack, msg + ' (CONFLICT)') |
| else: |
| log_entry(stack, msg) |
| |
| def delete_log(repo, branch): |
| ref = log_ref(branch) |
| if repo.refs.exists(ref): |
| repo.refs.delete(ref) |
| |
| def rename_log(repo, old_branch, new_branch, msg): |
| old_ref = log_ref(old_branch) |
| new_ref = log_ref(new_branch) |
| if repo.refs.exists(old_ref): |
| repo.refs.set(new_ref, repo.refs.get(old_ref), msg) |
| repo.refs.delete(old_ref) |
| |
| def copy_log(repo, src_branch, dst_branch, msg): |
| src_ref = log_ref(src_branch) |
| dst_ref = log_ref(dst_branch) |
| if repo.refs.exists(src_ref): |
| repo.refs.set(dst_ref, repo.refs.get(src_ref), msg) |
| |
| def default_repo(): |
| return libstack.Repository.default() |
| |
| def reset_stack(trans, iw, state): |
| """Reset the stack to a given previous state.""" |
| for pn in trans.all_patches: |
| trans.patches[pn] = None |
| for pn in state.all_patches: |
| trans.patches[pn] = state.patches[pn] |
| trans.applied = state.applied |
| trans.unapplied = state.unapplied |
| trans.hidden = state.hidden |
| trans.base = state.base |
| trans.head = state.head |
| |
| def reset_stack_partially(trans, iw, state, only_patches): |
| """Reset the stack to a given previous state -- but only the given |
| patches, not anything else. |
| |
| @param only_patches: Touch only these patches |
| @type only_patches: iterable""" |
| only_patches = set(only_patches) |
| patches_to_reset = set(state.all_patches) & only_patches |
| existing_patches = set(trans.all_patches) |
| original_applied_order = list(trans.applied) |
| to_delete = (existing_patches - patches_to_reset) & only_patches |
| |
| # In one go, do all the popping we have to in order to pop the |
| # patches we're going to delete or modify. |
| def mod(pn): |
| if not pn in only_patches: |
| return False |
| if pn in to_delete: |
| return True |
| if trans.patches[pn] != state.patches.get(pn, None): |
| return True |
| return False |
| trans.pop_patches(mod) |
| |
| # Delete and modify/create patches. We've previously popped all |
| # patches that we touch in this step. |
| trans.delete_patches(lambda pn: pn in to_delete) |
| for pn in patches_to_reset: |
| if pn in existing_patches: |
| if trans.patches[pn] == state.patches[pn]: |
| continue |
| else: |
| out.info('Resetting %s' % pn) |
| else: |
| if pn in state.hidden: |
| trans.hidden.append(pn) |
| else: |
| trans.unapplied.append(pn) |
| out.info('Resurrecting %s' % pn) |
| trans.patches[pn] = state.patches[pn] |
| |
| # Push all the patches that we've popped, if they still |
| # exist. |
| pushable = set(trans.unapplied + trans.hidden) |
| for pn in original_applied_order: |
| if pn in pushable: |
| trans.push_patch(pn, iw) |
| |
| def undo_state(stack, undo_steps): |
| """Find the log entry C{undo_steps} steps in the past. (Successive |
| undo operations are supposed to "add up", so if we find other undo |
| operations along the way we have to add those undo steps to |
| C{undo_steps}.) |
| |
| If C{undo_steps} is negative, redo instead of undo. |
| |
| @return: The log entry that is the destination of the undo |
| operation |
| @rtype: L{LogEntry}""" |
| ref = log_ref(stack.name) |
| try: |
| commit = stack.repository.refs.get(ref) |
| except KeyError: |
| raise LogException('Log is empty') |
| log = get_log_entry(stack.repository, ref, commit) |
| while undo_steps != 0: |
| msg = log.message.strip() |
| um = re.match(r'^undo\s+(\d+)$', msg) |
| if undo_steps > 0: |
| if um: |
| undo_steps += int(um.group(1)) |
| else: |
| undo_steps -= 1 |
| else: |
| rm = re.match(r'^redo\s+(\d+)$', msg) |
| if um: |
| undo_steps += 1 |
| elif rm: |
| undo_steps -= int(rm.group(1)) |
| else: |
| raise LogException('No more redo information available') |
| if not log.prev: |
| raise LogException('Not enough undo information available') |
| log = log.prev |
| return log |
| |
| def log_external_mods(stack): |
| ref = log_ref(stack.name) |
| try: |
| log_commit = stack.repository.refs.get(ref) |
| except KeyError: |
| # No log exists yet. |
| log_entry(stack, 'start of log') |
| return |
| try: |
| log = get_log_entry(stack.repository, ref, log_commit) |
| except LogException: |
| # Something's wrong with the log, so don't bother. |
| return |
| if log.head == stack.head: |
| # No external modifications. |
| return |
| log_entry(stack, '\n'.join([ |
| 'external modifications', '', |
| 'Modifications by tools other than StGit (e.g. git).'])) |
| |
| def compat_log_external_mods(): |
| try: |
| repo = default_repo() |
| except git.RepositoryException: |
| # No repository, so we couldn't log even if we wanted to. |
| return |
| try: |
| stack = repo.get_stack(repo.current_branch_name) |
| except exception.StgException: |
| # Stack doesn't exist, so we can't log. |
| return |
| log_external_mods(stack) |