def get_changed_files(diff_spec): files = [] git_ = Git(os.getcwd()) changed_files = git_.diff(diff_spec, '--name-only').strip().split('\n') # Changed files will contain a single empty string if no files were changed if len(changed_files) == 1 and not changed_files[0]: return [] files.extend(changed_files) return files
def determine_modified_files_between_commits(repo_path, branch, start_commit, end_commit): ret_val = [] format='--name-only' g = Git(repo_path) differ = g.diff('%s..%s' % (start_commit, end_commit), format).split('\n') for line in differ: if len(line): ret_val.append('/' + line) return ret_val
class GitFlow(object): """ Creates a :class:`GitFlow` instance. :param working_dir: The directory where the Git repo is located. If not specified, the current working directory is used. When a :class:`GitFlow` class is instantiated, it auto-discovers all subclasses of :class:`gitflow.branches.BranchManager`, so there is no explicit registration required. """ def _discover_branch_managers(self): managers = {} for cls in itersubclasses(BranchManager): # TODO: Initialize managers with the gitflow branch prefixes managers[cls.identifier] = cls(self) return managers def __init__(self, working_dir='.'): # Allow Repos to be passed in instead of strings self.repo = None if isinstance(working_dir, Repo): self.working_dir = working_dir.working_dir else: self.working_dir = working_dir self.git = Git(self.working_dir) try: self.repo = Repo(self.working_dir) except InvalidGitRepositoryError: pass self.managers = self._discover_branch_managers() self.defaults = { 'gitflow.branch.master': 'master', 'gitflow.branch.develop': 'develop', 'gitflow.prefix.versiontag': '', 'gitflow.origin': 'origin', } for identifier, manager in self.managers.items(): self.defaults['gitflow.prefix.%s' % identifier] = manager.DEFAULT_PREFIX def _init_config(self, master=None, develop=None, prefixes={}, names={}, force_defaults=False): for setting, default in self.defaults.items(): if force_defaults: value = default elif setting == 'gitflow.branch.master': value = master elif setting == 'gitflow.branch.develop': value = develop elif setting.startswith('gitflow.prefix.'): name = setting[len('gitflow.prefix.'):] value = prefixes.get(name, None) else: name = setting[len('gitflow.'):] value = names.get(name, None) if value is None: value = self.get(setting, default) self.set(setting, value) def _init_initial_commit(self): master = self.master_name() if master in self.repo.branches: # local `master` branch exists return elif self.origin_name(master) in self.repo.refs: # the origin branch counterpart exists origin = self.repo.refs[self.origin_name(master)] branch = self.repo.create_head(master, origin) branch.set_tracking_branch(origin) elif self.repo.heads: raise NotImplementedError( 'Local and remote branches exist, but neither %s nor %s' % ( master, self.origin_name(master) )) else: # Create 'master' branch info('Creating branch %r' % master) c = self.repo.index.commit('Initial commit', head=False) self.repo.create_head(master, c) def _init_develop_branch(self): # assert master already exists assert self.master_name() in self.repo.refs develop = self.develop_name() if develop in self.repo.branches: # local `develop` branch exists, but do not switch there return if self.origin_name(develop) in self.repo.refs: # the origin branch counterpart exists origin = self.repo.refs[self.origin_name(develop)] branch = self.repo.create_head(develop, origin) branch.set_tracking_branch(origin) else: # Create 'develop' branch info('Creating branch %r' % develop) branch = self.repo.create_head(develop, self.master()) # switch to develop branch if its newly created info('Switching to branch %s' % branch) branch.checkout() def _enforce_git_repo(self): """ Ensure a (maybe empty) repository exists we can work on. This is to be used by the `init` sub-command. """ if self.repo is None: self.git.init(self.working_dir) self.repo = Repo(self.working_dir) def init(self, master=None, develop=None, prefixes={}, names={}, force_defaults=False): self._enforce_git_repo() self._init_config(master, develop, prefixes, names, force_defaults) self._init_initial_commit() self._init_develop_branch() return self def is_initialized(self): return (self.repo and self.is_set('gitflow.branch.master') and self.is_set('gitflow.branch.develop') and self.is_set('gitflow.prefix.feature') and self.is_set('gitflow.prefix.release') and self.is_set('gitflow.prefix.hotfix') and self.is_set('gitflow.prefix.support') and self.is_set('gitflow.prefix.versiontag')) def _parse_setting(self, setting): groups = setting.split('.', 2) if len(groups) == 2: section, option = groups elif len(groups) == 3: section, subsection, option = groups section = '%s "%s"' % (section, subsection) else: raise ValueError('Invalid setting name: %s' % setting) return (section, option) @requires_repo def get(self, setting, default=_NONE): section, option = self._parse_setting(setting) try: return self.repo.config_reader().get_value(section, option) except (NoSectionError, NoOptionError): if default is not _NONE: return default raise def get_prefix(self, identifier): return self._safe_get('gitflow.prefix.%s' % (identifier,)) @requires_repo def set(self, setting, value): section, option = self._parse_setting(setting) writer = self.repo.config_writer() writer.set_value(section, option, value) writer.release() del writer def is_set(self, setting): return self.get(setting, None) is not None @requires_repo def _safe_get(self, setting_name): try: return self.get(setting_name) except (NoSectionError, NoOptionError): raise NotInitialized('This repo has not yet been initialized.') def master_name(self): return self._safe_get('gitflow.branch.master') def develop_name(self): return self._safe_get('gitflow.branch.develop') def origin_name(self, name=None): origin = self.get('gitflow.origin', self.defaults['gitflow.origin']) if name is not None: return origin + '/' + name else: return origin @requires_repo def require_remote(self, name): try: return self.repo.remotes[name] except IndexError: raise NoSuchRemoteError(name) def origin(self): return self.require_remote(self.origin_name()) @requires_repo def develop(self): return self.repo.branches[self.develop_name()] @requires_repo def master(self): return self.repo.branches[self.master_name()] @requires_repo def branch_names(self, remote=False): if remote: return [r.name for r in self.repo.refs if isinstance(r, RemoteReference)] else: return [r.name for r in self.repo.branches] @requires_repo def nameprefix_or_current(self, identifier, prefix): """ :param identifier: The identifier for the type of branch to work on. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param prefix: If the empty, see if the current branch is of type `identifier`. If so, returns the current branches short name, otherwise raises :exc:`NoSuchBranchError`. :returns: If exactly one branch of type `identifier` starts with the given name `prefix`, returns that branches short name. Raises :exc:`NoSuchBranchError` in case no branch exists with the given prefix, or :exc:`PrefixNotUniqueError` in case multiple matches are found. """ repo = self.repo manager = self.managers[identifier] if not prefix: if repo.active_branch.name.startswith(manager.prefix): return manager.shorten(repo.active_branch.name) else: raise NoSuchBranchError( 'The current branch is no %s branch. ' 'Please specify one explicitly.' % identifier) return manager.shorten(manager.by_name_prefix(prefix).name) @requires_repo def name_or_current(self, identifier, name, must_exist=True): """ :param identifier: The identifier for the type of branch to work on. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param name: If the `name` is empty, see if the current branch is of type `identifier`. If so, returns the current branches short name, otherwise raises :exc:`NoSuchBranchError`. :param must_exist: If `True` (the default), raises :exc:`NoSuchBranchError` in case no branch exists with the given `name`. Otherwise return the `name` unchanged. """ repo = self.repo manager = self.managers[identifier] if not name: if repo.active_branch.name.startswith(manager.prefix): return manager.shorten(repo.active_branch.name) else: raise NoSuchBranchError( 'The current branch is no %s branch. ' 'Please specify one explicitly.' % identifier) elif must_exist and not manager.full_name(name) in (b.name for b in manager.list()): raise NoSuchBranchError('There is no %s branch named %s.' % (identifier, name)) return name @requires_repo def status(self): result = [] for b in self.repo.branches: tup = self.branch_info(b.name) result.append(tup) return result @requires_repo def branch_info(self, name): active_branch = self.repo.active_branch b = self.repo.heads[name] return (name, b.commit.hexsha, b == active_branch) @requires_repo def is_dirty(self): """ Returns whether or not the current working directory contains uncommitted changes. """ return self.repo.is_dirty() @requires_repo def has_staged_commits(self): """ Returns whether or not the current repo contains local changes checked into the index but not committed. """ return len(self.repo.index.diff(self.repo.head.commit)) > 0 @requires_repo def require_no_merge_conflict(self): """ Raises :exc:`MergeConflict` if the current working directory contains a merge conflict. """ try: git.Reference(self.repo, 'MERGE_HEAD', check_path=False).commit # reference exists, so there is a merge conflict raise MergeConflict() except ValueError: # no such reference, so there is no merge conflict pass def is_merged_into(self, commit, target_branch): """ Checks whether `commit` is successfully merged into branch `target_branch`. :param commit: The commit or branch that ought to be merged. This may be a full branch-name, a commit-hexsha or any of branch-, head-, reference- or commit-object. :param target_branch: The branch which should contain the commit. This may be a full branch-name, or any of branch-, head- or reference-object. """ try: commit = self.repo.rev_parse(str(commit)) except (git.BadObject, git.BadName): raise BadObjectError(commit) if isinstance(target_branch, git.RemoteReference): target_branch = 'remotes/' + target_branch.name elif isinstance(target_branch, git.SymbolicReference): target_branch = target_branch.name # :todo: implement this more efficiently return target_branch in [ b.lstrip('* ') for b in self.git.branch('-a', '--contains', commit).splitlines()] def must_be_uptodate(self, branch, fetch): remote_branch = self.origin_name(branch) if remote_branch in self.branch_names(remote=True): if fetch: self.origin().fetch(branch) self.require_branches_equal(branch, remote_branch) @requires_repo def _compare_branches(self, branch1, branch2): """ Tests whether branches and their 'origin' counterparts have diverged and need merging first. It returns error codes to provide more detail, like so: 0 Branch heads point to the same commit 1 First given branch needs fast-forwarding 2 Second given branch needs fast-forwarding 3 Branch needs a real merge 4 There is no merge base, i.e. the branches have no common ancestors """ try: commit1 = self.repo.rev_parse(branch1) commit2 = self.repo.rev_parse(branch2) except (git.BadObject, git.BadName) as e: raise NoSuchBranchError(e.args[0]) if commit1 == commit2: return 0 try: # merge_base() returns a list of Commit objects # this list will have at max one Commit # or it will be empty if no common merge base exists base = self.repo.merge_base(commit1, commit2)[0] except (GitCommandError, IndexError): return 4 if base == commit1: return 1 elif base == commit2: return 2 else: return 3 @requires_repo def require_branches_equal(self, branch1, branch2): status = self._compare_branches(branch1, branch2) if status == 0: # branches are equal return else: warn("Branches '%s' and '%s' have diverged." % (branch1, branch2)) if status == 1: raise SystemExit("And branch '%s' may be fast-forwarded." % branch1) elif status == 2: # Warn here, since there is no harm in being ahead warn("And local branch '%s' is ahead of '%s'." % (branch1, branch2)) else: raise SystemExit("Branches need merging first.") @requires_repo def start_transaction(self, message=None): if message: info(message) @requires_initialized def tag(self, tagname, commit, message=None, sign=False, signingkey=None): kwargs = {} if sign: kwargs['s'] = True if signingkey: kwargs['u'] = signingkey self.repo.create_tag(tagname, commit, message=message or None, **kwargs) # # ====== sub commands ===== # @requires_repo def list(self, identifier, arg0_name, verbose, use_tagname): """ List the all branches of the given type. If there are not branches of this type, raises :exc:`Usage` with an explanation on how to start a branch of this type. :param identifier: The identifier for the type of branch to work on. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param arg0_name: Name of the first argument for the command line to be put into the explanation on how to start a branch of this type. This typically is `name` or `version`. :param verbose: If True, give more information about the state of the branch: Whether it's ahead or behind it's default base, may be rebased, etc. :param use_tagname: If True, try to describe the state based on the next tag. """ repo = self.repo manager = self.managers[identifier] branches = manager.list() if not branches: raise Usage( 'No %s branches exist.' % identifier, 'You can start a new %s branch with the command:' % identifier, ' git flow %s start <%s> [<base>]' % (identifier, arg0_name) ) # determine the longest branch name width = max(len(b.name) for b in branches) - len(manager.prefix) + 1 basebranch_sha = repo.branches[manager.default_base()].commit.hexsha for branch in branches: if repo.active_branch == branch: prefix = '* ' else: prefix = ' ' name = manager.shorten(branch.name) extra_info = '' if verbose: name = name.ljust(width) branch_sha = branch.commit.hexsha base_sha = repo.git.merge_base(basebranch_sha, branch_sha) if branch_sha == basebranch_sha: extra_info = '(no commits yet)' elif use_tagname: try: extra_info = self.git.name_rev('--tags', '--name-only', '--no-undefined', base_sha) extra_info = '(based on %s)' % extra_info except GitCommandError: pass if not extra_info: if base_sha == branch_sha: extra_info = '(is behind %s, may ff)' % manager.default_base() elif base_sha == basebranch_sha: extra_info = '(based on latest %s)' % manager.default_base() else: extra_info = '(may be rebased)' info(prefix + name + extra_info) @requires_initialized def create(self, identifier, name, base, fetch): """ Creates a branch of the given type, with the given short name. :param identifier: The identifier for the type of branch to create. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param name: The friendly (short) name to create. :param base: The alternative base to branch off from. If not given, the default base for the given branch type is used. :returns: The newly created :class:`git.refs.Head` branch. """ return self.managers[identifier].create(name, base, fetch=fetch) @requires_initialized def finish(self, identifier, name, fetch, rebase, keep, force_delete, tagging_info): """ Finishes a branch of the given type, with the given short name. :param identifier: The identifier for the type of branch to finish. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param name: The friendly (short) name to finish. """ mgr = self.managers[identifier] branch = mgr.by_name_prefix(name) try: self.require_no_merge_conflict() except MergeConflict as e: raise Usage(e, "You can then complete the finish by running it again:", " git flow %s finish %s" % (identifier, name) ) return mgr.finish(mgr.shorten(branch.name), fetch=fetch, rebase=rebase, keep=keep, force_delete=force_delete, tagging_info=tagging_info) @requires_initialized def checkout(self, identifier, name): """ Checkout a branch of the given type, with the given short name. :param identifier: The identifier for the type of branch to checkout. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param name: The friendly (short) name to checkout. :returns: The checked out :class:`git.refs.Head` branch. """ mgr = self.managers[identifier] branch = mgr.by_name_prefix(name) return branch.checkout() @requires_initialized def diff(self, identifier, name): """ Print the diff of changes since this branch branched off. :param identifier: The identifier for the type of branch to work on. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param name: The friendly (short) name to work on. """ mgr = self.managers[identifier] full_name = mgr.full_name(name) base = self.git.merge_base(mgr.default_base(), full_name) print(self.git.diff('%s..%s' % (base, full_name))) @requires_initialized def rebase(self, identifier, name, interactive): """ Rebase a branch of the given type, with the given short name, on top of it's default base. :param identifier: The identifier for the type of branch to rebase. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param name: The friendly (short) name to rebase. :param interactive: If True, do an interactive rebase. """ warn("Will try to rebase %s branch '%s' ..." % (identifier, name)) mgr = self.managers[identifier] mgr.full_name(name) # :todo: require_clean_working_tree self.checkout(identifier, name) args = [] if interactive: args.append('-i') args.append(mgr.default_base()) self.git.rebase(*args) @requires_initialized def publish(self, identifier, name): """ Publish a branch of the given type, with the given short name, to `origin` (or whatever is configured as `remote` for gitflow.) :param identifier: The identifier for the type of branch to publish. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param name: The friendly (short) name to publish. :returns: The full name of the published branch. """ repo = self.repo mgr = self.managers[identifier] # sanity checks # :todo: require_clean_working_tree full_name = mgr.full_name(name) remote_name = self.origin_name(full_name) if full_name not in repo.branches: raise NoSuchBranchError(full_name) if remote_name in repo.refs: raise BranchExistsError(remote_name) # :todo: check if full_name already has a tracking branch # :todo: check if full_name already has the same tracking branch # create remote branch origin = self.origin() info = origin.push('%s:refs/heads/%s' % (full_name, full_name))[0] origin.fetch() # configure remote tracking repo.branches[full_name].set_tracking_branch(info.remote_ref) return full_name @requires_initialized def pull(self, identifier, remote, name): """ Pull a branch of the given type, with the given short name, from the given remote peer. :param identifier: The identifier for the type of branch to pull. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param remote: The remote to pull from. This must have been configured by `git remote add ...`. :param name: The friendly (short) name to pull. """ def avoid_accidental_cross_branch_action(branch_name): current_branch = repo.active_branch if branch_name != current_branch.name: warn("Trying to pull from '%s' while currently on branch '%s'." % (branch_name, current_branch)) raise SystemExit("To avoid unintended merges, git-flow aborted.") repo = self.repo mgr = self.managers[identifier] full_name = mgr.full_name(name) # To avoid accidentally merging different feature branches # into each other, die if the current feature branch differs # from the requested $NAME argument. if repo.active_branch.name.startswith(self.get_prefix(identifier)): # We are on a local `identifier` branch already, so `full_name` # must be equal to the current branch. avoid_accidental_cross_branch_action(full_name) # :todo: require_clean_working_tree if full_name in self.repo.branches: # Again, avoid accidental merges avoid_accidental_cross_branch_action(full_name) # We already have a local branch called like this, so # simply pull the remote changes in self.require_remote(remote).pull(full_name) # :fixme: why is the branch not checked out here? info("Pulled %s's changes into %s." % (remote, full_name)) else: # Setup the non-tracking local branch clone for the first time self.require_remote(remote).fetch(full_name + ':' + full_name) repo.heads[full_name].checkout() info("Created local branch %s based on %s's %s." % (full_name, remote, full_name)) @requires_initialized def track(self, identifier, name): """ Track a branch of the given type, with the given short name, from `origin` (or whatever is configured as `remote` for gitflow.) :param identifier: The identifier for the type of branch to track. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param name: The friendly (short) name to track. :param base: The alternative base to branch off from. If not given, the default base for the given branch type is used. :returns: The newly created :class:`git.refs.Head` branch. """ repo = self.repo mgr = self.managers[identifier] # sanity checks # :todo: require_clean_working_tree full_name = mgr.full_name(name) if full_name in repo.branches: raise BranchExistsError(full_name) self.origin().fetch(full_name) remote_branch = self.origin().refs[full_name] branch = repo.create_head(full_name, remote_branch) branch.set_tracking_branch(remote_branch) return branch.checkout()
class GitHyperBlame: def __init__(self, path: str): self.g = Git(path) self.diff_hunks_cache = {} def parse_blame(self, blameoutput): """Parses the output of git blame -p into a data structure.""" lines = blameoutput.split('\n') i = 0 commits = {} while i < len(lines): # Read a commit line and parse it. line = lines[i] i += 1 if not line.strip(): continue commitline = line.split() commithash = commitline[0] lineno_then = int(commitline[1]) lineno_now = int(commitline[2]) try: commit = commits[commithash] except KeyError: commit = HyperBlameCommit(commithash) commits[commithash] = commit # Read commit details until we find a context line. while i < len(lines): line = lines[i] i += 1 if line.startswith('\t'): break try: key, value = line.split(' ', 1) except ValueError: key = line value = True setattr(commit, key.replace('-', '_'), value) context = line[1:] yield BlameLine(commit, context, lineno_then, lineno_now, False) def get_parsed_blame(self, filename, revision='HEAD'): blame = self.g.blame('-p', revision, '--', filename) return list(self.parse_blame(blame)) def hyper_blame(self, ignored, filename, revision='HEAD'): # Map from commit to parsed blame from that commit. blame_from = {} def cache_blame_from(filename, commithash): try: return blame_from[commithash] except KeyError: parsed = self.get_parsed_blame(filename, commithash) blame_from[commithash] = parsed return parsed parsed = cache_blame_from(filename, revision) new_parsed = [] for line in parsed: # If a line references an ignored commit, blame that commit's # parent repeatedly until we find a non-ignored commit. while line.commit.commithash in ignored: if line.commit.previous is None: # You can't ignore the commit that added this file. break previouscommit, previousfilename = line.commit.previous.split( ' ', 1) parent_blame = cache_blame_from(previousfilename, previouscommit) if len(parent_blame) == 0: # The previous version of this file was empty, # therefore, you can't ignore this commit. break # line.lineno_then is the line number in question at # line.commit. We need # to translate that line number so that it refers to the # position of the same line on previouscommit. lineno_previous = self.approx_lineno_across_revs( line.commit.filename, previousfilename, line.commit.commithash, previouscommit, line.lineno_then) logger.debug('ignore commit %s on line p%d/t%d/n%d', line.commit.commithash, lineno_previous, line.lineno_then, line.lineno_now) # Get the line at lineno_previous in the parent commit. assert 1 <= lineno_previous <= len(parent_blame) newline = parent_blame[lineno_previous - 1] # Replace the commit and lineno_then, but not the lineno_now # or context. line = BlameLine(newline.commit, line.context, newline.lineno_then, line.lineno_now, True) logger.debug('replacing with %r', line) new_parsed.append(line) return self.build_result(new_parsed) def approx_lineno_across_revs(self, filename, newfilename, revision, newrevision, lineno): """Computes the approximate movement of a line number between two revisions. Consider line |lineno| in |filename| at |revision|. This function computes the line number of that line in |newfilename| at |newrevision|. This is necessarily approximate. Args: filename: The file (within the repo) at |revision|. newfilename: The name of the same file at |newrevision|. revision: A git revision. newrevision: Another git revision. Note: Can be ahead or behind |revision|. lineno: Line number within |filename| at |revision|. Returns: Line number within |newfilename| at |newrevision|. """ # This doesn't work that well if there are a lot of line changes # within the # hunk (demonstrated by # GitHyperBlameLineMotionTest.testIntraHunkLineMotion). # A fuzzy heuristic that takes the text of the new line and tries to # find a # deleted line within the hunk that mostly matches the new line # could help. # Use the <revision>:<filename> syntax to diff between two blobs. # This is the only way to diff a file that has been renamed. old = '%s:%s' % (revision, filename) new = '%s:%s' % (newrevision, newfilename) hunks = self.cache_diff_hunks(old, new) cumulative_offset = 0 # Find the hunk containing lineno (if any). for (oldstart, oldlength), (newstart, newlength) in hunks: cumulative_offset += newlength - oldlength if lineno >= oldstart + oldlength: # Not there yet. continue if lineno < oldstart: # Gone too far. break # lineno is in [oldstart, oldlength] at revision; [newstart, # newlength] at # newrevision. # If newlength == 0, newstart will be the line before the # deleted hunk. # Since the line must have been deleted, just return that as the # nearest # line in the new file. Caution: newstart can be 0 in this case. if newlength == 0: return max(1, newstart) newend = newstart + newlength - 1 # Move lineno based on the amount the entire hunk shifted. lineno = lineno + newstart - oldstart # Constrain the output within the range [newstart, newend]. return min(newend, max(newstart, lineno)) # Wasn't in a hunk. Figure out the line motion based on the # difference in # length between the hunks seen so far. return lineno + cumulative_offset def cache_diff_hunks(self, oldrev, newrev): def parse_start_length(s): # Chop the '-' or '+'. s = s[1:] # Length is optional (defaults to 1). try: start, length = s.split(',') except ValueError: start = s length = 1 return int(start), int(length) try: return self.diff_hunks_cache[(oldrev, newrev)] except KeyError: pass # Use -U0 to get the smallest possible hunks. diff = self.g.diff(oldrev, newrev, '-U0') # Get all the hunks. hunks = [] for line in diff.split('\n'): if not line.startswith('@@'): continue ranges = line.split(' ', 3)[1:3] ranges = tuple(parse_start_length(r) for r in ranges) hunks.append(ranges) self.diff_hunks_cache[(oldrev, newrev)] = hunks return hunks def build_result(self, parsedblame): table = [] for line in parsedblame: offset = line.commit.author_tz hours = int(offset[:-2]) minutes = int(offset[-2:]) tz = timezone(timedelta(hours=hours, minutes=minutes)) author_time = datetime.utcfromtimestamp( int(line.commit.author_time)) + timedelta(hours=hours, minutes=minutes) author_time = author_time.replace(tzinfo=tz) row = '' row = [ line.commit.commithash[:8], '(' + line.commit.author, author_time.strftime('%Y-%m-%d %H:%M:%S %z'), str(line.lineno_now) + ('*' if line.modified else '') + ')', line.context ] row.insert(1, line.commit.filename) row = ' '.join(row) table.append(row) return table
class GitFlow(object): """ Creates a :class:`GitFlow` instance. :param working_dir: The directory where the Git repo is located. If not specified, the current working directory is used. When a :class:`GitFlow` class is instantiated, it auto-discovers all subclasses of :class:`gitflow.branches.BranchManager`, so there is no explicit registration required. """ def _discover_branch_managers(self): managers = {} for cls in itersubclasses(BranchManager): # TODO: Initialize managers with the gitflow branch prefixes managers[cls.identifier] = cls(self) return managers def __init__(self, working_dir='.'): # Allow Repos to be passed in instead of strings self.repo = None if isinstance(working_dir, Repo): self.working_dir = working_dir.working_dir else: self.working_dir = working_dir self.git = Git(self.working_dir) try: self.repo = Repo(self.working_dir) except InvalidGitRepositoryError: pass self.managers = self._discover_branch_managers() self.defaults = { 'gitflow.branch.master': 'master', 'gitflow.branch.develop': 'develop', 'gitflow.prefix.versiontag': '', 'gitflow.origin': 'origin', } for identifier, manager in self.managers.items(): self.defaults['gitflow.prefix.%s' % identifier] = manager.DEFAULT_PREFIX def _init_config(self, master=None, develop=None, prefixes={}, names={}, force_defaults=False): for setting, default in self.defaults.items(): if force_defaults: value = default elif setting == 'gitflow.branch.master': value = master elif setting == 'gitflow.branch.develop': value = develop elif setting.startswith('gitflow.prefix.'): name = setting[len('gitflow.prefix.'):] value = prefixes.get(name, None) else: name = setting[len('gitflow.'):] value = names.get(name, None) if value is None: value = self.get(setting, default) self.set(setting, value) def _init_initial_commit(self): master = self.master_name() if master in self.repo.branches: # local `master` branch exists return elif self.origin_name(master) in self.repo.refs: # the origin branch counterpart exists origin = self.repo.refs[self.origin_name(master)] branch = self.repo.create_head(master, origin) branch.set_tracking_branch(origin) elif self.repo.heads: raise NotImplementedError( 'Local and remote branches exist, but neither %s nor %s' % (master, self.origin_name(master))) else: # Create 'master' branch info('Creating branch %r' % master) c = self.repo.index.commit('Initial commit', head=False) self.repo.create_head(master, c) def _init_develop_branch(self): # assert master already exists assert self.master_name() in self.repo.refs develop = self.develop_name() if develop in self.repo.branches: # local `develop` branch exists, but do not switch there return if self.origin_name(develop) in self.repo.refs: # the origin branch counterpart exists origin = self.repo.refs[self.origin_name(develop)] branch = self.repo.create_head(develop, origin) branch.set_tracking_branch(origin) else: # Create 'develop' branch info('Creating branch %r' % develop) branch = self.repo.create_head(develop, self.master()) # switch to develop branch if its newly created info('Switching to branch %s' % branch) branch.checkout() def _enforce_git_repo(self): """ Ensure a (maybe empty) repository exists we can work on. This is to be used by the `init` sub-command. """ if self.repo is None: self.git.init(self.working_dir) self.repo = Repo(self.working_dir) def init(self, master=None, develop=None, prefixes={}, names={}, force_defaults=False): self._enforce_git_repo() self._init_config(master, develop, prefixes, names, force_defaults) self._init_initial_commit() self._init_develop_branch() return self def is_initialized(self): return (self.repo and self.is_set('gitflow.branch.master') and self.is_set('gitflow.branch.develop') and self.is_set('gitflow.prefix.feature') and self.is_set('gitflow.prefix.release') and self.is_set('gitflow.prefix.hotfix') and self.is_set('gitflow.prefix.support') and self.is_set('gitflow.prefix.versiontag')) def _parse_setting(self, setting): groups = setting.split('.', 2) if len(groups) == 2: section, option = groups elif len(groups) == 3: section, subsection, option = groups section = '%s "%s"' % (section, subsection) else: raise ValueError('Invalid setting name: %s' % setting) return (section, option) @requires_repo def get(self, setting, default=_NONE): section, option = self._parse_setting(setting) try: return self.repo.config_reader().get_value(section, option) except (NoSectionError, NoOptionError): if default is not _NONE: return default raise def get_prefix(self, identifier): return self._safe_get('gitflow.prefix.%s' % (identifier, )) @requires_repo def set(self, setting, value): section, option = self._parse_setting(setting) writer = self.repo.config_writer() writer.set_value(section, option, value) writer.release() del writer def is_set(self, setting): return self.get(setting, None) is not None @requires_repo def _safe_get(self, setting_name): try: return self.get(setting_name) except (NoSectionError, NoOptionError): raise NotInitialized('This repo has not yet been initialized.') def master_name(self): return self._safe_get('gitflow.branch.master') def develop_name(self): return self._safe_get('gitflow.branch.develop') def origin_name(self, name=None): origin = self.get('gitflow.origin', self.defaults['gitflow.origin']) if name is not None: return origin + '/' + name else: return origin @requires_repo def require_remote(self, name): try: return self.repo.remotes[name] except IndexError: raise NoSuchRemoteError(name) def origin(self): return self.require_remote(self.origin_name()) @requires_repo def develop(self): return self.repo.branches[self.develop_name()] @requires_repo def master(self): return self.repo.branches[self.master_name()] @requires_repo def branch_names(self, remote=False): if remote: return [ r.name for r in self.repo.refs if isinstance(r, RemoteReference) ] else: return [r.name for r in self.repo.branches] @requires_repo def nameprefix_or_current(self, identifier, prefix): """ :param identifier: The identifier for the type of branch to work on. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param prefix: If the empty, see if the current branch is of type `identifier`. If so, returns the current branches short name, otherwise raises :exc:`NoSuchBranchError`. :returns: If exactly one branch of type `identifier` starts with the given name `prefix`, returns that branches short name. Raises :exc:`NoSuchBranchError` in case no branch exists with the given prefix, or :exc:`PrefixNotUniqueError` in case multiple matches are found. """ repo = self.repo manager = self.managers[identifier] if not prefix: if repo.active_branch.name.startswith(manager.prefix): return manager.shorten(repo.active_branch.name) else: raise NoSuchBranchError('The current branch is no %s branch. ' 'Please specify one explicitly.' % identifier) return manager.shorten(manager.by_name_prefix(prefix).name) @requires_repo def name_or_current(self, identifier, name, must_exist=True): """ :param identifier: The identifier for the type of branch to work on. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param name: If the `name` is empty, see if the current branch is of type `identifier`. If so, returns the current branches short name, otherwise raises :exc:`NoSuchBranchError`. :param must_exist: If `True` (the default), raises :exc:`NoSuchBranchError` in case no branch exists with the given `name`. Otherwise return the `name` unchanged. """ repo = self.repo manager = self.managers[identifier] if not name: if repo.active_branch.name.startswith(manager.prefix): return manager.shorten(repo.active_branch.name) else: raise NoSuchBranchError('The current branch is no %s branch. ' 'Please specify one explicitly.' % identifier) elif must_exist and not manager.full_name(name) in ( b.name for b in manager.list()): raise NoSuchBranchError('There is no %s branch named %s.' % (identifier, name)) return name @requires_repo def status(self): result = [] for b in self.repo.branches: tup = self.branch_info(b.name) result.append(tup) return result @requires_repo def branch_info(self, name): active_branch = self.repo.active_branch b = self.repo.heads[name] return (name, b.commit.hexsha, b == active_branch) @requires_repo def is_dirty(self): """ Returns whether or not the current working directory contains uncommitted changes. """ return self.repo.is_dirty() @requires_repo def has_staged_commits(self): """ Returns whether or not the current repo contains local changes checked into the index but not committed. """ return len(self.repo.index.diff(self.repo.head.commit)) > 0 @requires_repo def require_no_merge_conflict(self): """ Raises :exc:`MergeConflict` if the current working directory contains a merge conflict. """ try: git.Reference(self.repo, 'MERGE_HEAD', check_path=False).commit # reference exists, so there is a merge conflict raise MergeConflict() except ValueError: # no such reference, so there is no merge conflict pass def is_merged_into(self, commit, target_branch): """ Checks whether `commit` is successfully merged into branch `target_branch`. :param commit: The commit or branch that ought to be merged. This may be a full branch-name, a commit-hexsha or any of branch-, head-, reference- or commit-object. :param target_branch: The branch which should contain the commit. This may be a full branch-name, or any of branch-, head- or reference-object. """ try: commit = self.repo.rev_parse(str(commit)) except (git.BadObject, git.BadName): raise BadObjectError(commit) if isinstance(target_branch, git.RemoteReference): target_branch = 'remotes/' + target_branch.name elif isinstance(target_branch, git.SymbolicReference): target_branch = target_branch.name # :todo: implement this more efficiently return target_branch in [ b.lstrip('* ') for b in self.git.branch('-a', '--contains', commit).splitlines() ] def must_be_uptodate(self, branch, fetch): remote_branch = self.origin_name(branch) if remote_branch in self.branch_names(remote=True): if fetch: self.origin().fetch(branch) self.require_branches_equal(branch, remote_branch) @requires_repo def _compare_branches(self, branch1, branch2): """ Tests whether branches and their 'origin' counterparts have diverged and need merging first. It returns error codes to provide more detail, like so: 0 Branch heads point to the same commit 1 First given branch needs fast-forwarding 2 Second given branch needs fast-forwarding 3 Branch needs a real merge 4 There is no merge base, i.e. the branches have no common ancestors """ try: commit1 = self.repo.rev_parse(branch1) commit2 = self.repo.rev_parse(branch2) except (git.BadObject, git.BadName) as e: raise NoSuchBranchError(e.args[0]) if commit1 == commit2: return 0 try: # merge_base() returns a list of Commit objects # this list will have at max one Commit # or it will be empty if no common merge base exists base = self.repo.merge_base(commit1, commit2)[0] except (GitCommandError, IndexError): return 4 if base == commit1: return 1 elif base == commit2: return 2 else: return 3 @requires_repo def require_branches_equal(self, branch1, branch2): status = self._compare_branches(branch1, branch2) if status == 0: # branches are equal return else: warn("Branches '%s' and '%s' have diverged." % (branch1, branch2)) if status == 1: raise SystemExit("And branch '%s' may be fast-forwarded." % branch1) elif status == 2: # Warn here, since there is no harm in being ahead warn("And local branch '%s' is ahead of '%s'." % (branch1, branch2)) else: raise SystemExit("Branches need merging first.") @requires_repo def start_transaction(self, message=None): if message: info(message) @requires_initialized def tag(self, tagname, commit, message=None, sign=False, signingkey=None): kwargs = {} if sign: kwargs['s'] = True if signingkey: kwargs['u'] = signingkey self.repo.create_tag(tagname, commit, message=message or None, **kwargs) # # ====== sub commands ===== # @requires_repo def list(self, identifier, arg0_name, verbose, use_tagname): """ List the all branches of the given type. If there are not branches of this type, raises :exc:`Usage` with an explanation on how to start a branch of this type. :param identifier: The identifier for the type of branch to work on. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param arg0_name: Name of the first argument for the command line to be put into the explanation on how to start a branch of this type. This typically is `name` or `version`. :param verbose: If True, give more information about the state of the branch: Whether it's ahead or behind it's default base, may be rebased, etc. :param use_tagname: If True, try to describe the state based on the next tag. """ repo = self.repo manager = self.managers[identifier] branches = manager.list() if not branches: raise Usage( 'No %s branches exist.' % identifier, 'You can start a new %s branch with the command:' % identifier, ' git flow %s start <%s> [<base>]' % (identifier, arg0_name)) # determine the longest branch name width = max(len(b.name) for b in branches) - len(manager.prefix) + 1 basebranch_sha = repo.branches[manager.default_base()].commit.hexsha for branch in branches: if repo.active_branch == branch: prefix = '* ' else: prefix = ' ' name = manager.shorten(branch.name) extra_info = '' if verbose: name = name.ljust(width) branch_sha = branch.commit.hexsha base_sha = repo.git.merge_base(basebranch_sha, branch_sha) if branch_sha == basebranch_sha: extra_info = '(no commits yet)' elif use_tagname: try: extra_info = self.git.name_rev('--tags', '--name-only', '--no-undefined', base_sha) extra_info = '(based on %s)' % extra_info except GitCommandError: pass if not extra_info: if base_sha == branch_sha: extra_info = '(is behind %s, may ff)' % manager.default_base( ) elif base_sha == basebranch_sha: extra_info = '(based on latest %s)' % manager.default_base( ) else: extra_info = '(may be rebased)' info(prefix + name + extra_info) @requires_initialized def create(self, identifier, name, base, fetch): """ Creates a branch of the given type, with the given short name. :param identifier: The identifier for the type of branch to create. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param name: The friendly (short) name to create. :param base: The alternative base to branch off from. If not given, the default base for the given branch type is used. :returns: The newly created :class:`git.refs.Head` branch. """ return self.managers[identifier].create(name, base, fetch=fetch) @requires_initialized def finish(self, identifier, name, fetch, rebase, keep, force_delete, tagging_info): """ Finishes a branch of the given type, with the given short name. :param identifier: The identifier for the type of branch to finish. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param name: The friendly (short) name to finish. """ mgr = self.managers[identifier] branch = mgr.by_name_prefix(name) try: self.require_no_merge_conflict() except MergeConflict as e: raise Usage( e, "You can then complete the finish by running it again:", " git flow %s finish %s" % (identifier, name)) return mgr.finish(mgr.shorten(branch.name), fetch=fetch, rebase=rebase, keep=keep, force_delete=force_delete, tagging_info=tagging_info) @requires_initialized def checkout(self, identifier, name): """ Checkout a branch of the given type, with the given short name. :param identifier: The identifier for the type of branch to checkout. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param name: The friendly (short) name to checkout. :returns: The checked out :class:`git.refs.Head` branch. """ mgr = self.managers[identifier] branch = mgr.by_name_prefix(name) return branch.checkout() @requires_initialized def diff(self, identifier, name): """ Print the diff of changes since this branch branched off. :param identifier: The identifier for the type of branch to work on. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param name: The friendly (short) name to work on. """ mgr = self.managers[identifier] full_name = mgr.full_name(name) base = self.git.merge_base(mgr.default_base(), full_name) print(self.git.diff('%s..%s' % (base, full_name))) @requires_initialized def rebase(self, identifier, name, interactive): """ Rebase a branch of the given type, with the given short name, on top of it's default base. :param identifier: The identifier for the type of branch to rebase. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param name: The friendly (short) name to rebase. :param interactive: If True, do an interactive rebase. """ warn("Will try to rebase %s branch '%s' ..." % (identifier, name)) mgr = self.managers[identifier] mgr.full_name(name) # :todo: require_clean_working_tree self.checkout(identifier, name) args = [] if interactive: args.append('-i') args.append(mgr.default_base()) self.git.rebase(*args) @requires_initialized def publish(self, identifier, name): """ Publish a branch of the given type, with the given short name, to `origin` (or whatever is configured as `remote` for gitflow.) :param identifier: The identifier for the type of branch to publish. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param name: The friendly (short) name to publish. :returns: The full name of the published branch. """ repo = self.repo mgr = self.managers[identifier] # sanity checks # :todo: require_clean_working_tree full_name = mgr.full_name(name) remote_name = self.origin_name(full_name) if full_name not in repo.branches: raise NoSuchBranchError(full_name) if remote_name in repo.refs: raise BranchExistsError(remote_name) # :todo: check if full_name already has a tracking branch # :todo: check if full_name already has the same tracking branch # create remote branch origin = self.origin() info = origin.push('%s:refs/heads/%s' % (full_name, full_name))[0] origin.fetch() # configure remote tracking repo.branches[full_name].set_tracking_branch(info.remote_ref) return full_name @requires_initialized def pull(self, identifier, remote, name): """ Pull a branch of the given type, with the given short name, from the given remote peer. :param identifier: The identifier for the type of branch to pull. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param remote: The remote to pull from. This must have been configured by `git remote add ...`. :param name: The friendly (short) name to pull. """ def avoid_accidental_cross_branch_action(branch_name): current_branch = repo.active_branch if branch_name != current_branch.name: warn("Trying to pull from '%s' while currently on branch '%s'." % (branch_name, current_branch)) raise SystemExit( "To avoid unintended merges, git-flow aborted.") repo = self.repo mgr = self.managers[identifier] full_name = mgr.full_name(name) # To avoid accidentally merging different feature branches # into each other, die if the current feature branch differs # from the requested $NAME argument. if repo.active_branch.name.startswith(self.get_prefix(identifier)): # We are on a local `identifier` branch already, so `full_name` # must be equal to the current branch. avoid_accidental_cross_branch_action(full_name) # :todo: require_clean_working_tree if full_name in self.repo.branches: # Again, avoid accidental merges avoid_accidental_cross_branch_action(full_name) # We already have a local branch called like this, so # simply pull the remote changes in self.require_remote(remote).pull(full_name) # :fixme: why is the branch not checked out here? info("Pulled %s's changes into %s." % (remote, full_name)) else: # Setup the non-tracking local branch clone for the first time self.require_remote(remote).fetch(full_name + ':' + full_name) repo.heads[full_name].checkout() info("Created local branch %s based on %s's %s." % (full_name, remote, full_name)) @requires_initialized def track(self, identifier, name): """ Track a branch of the given type, with the given short name, from `origin` (or whatever is configured as `remote` for gitflow.) :param identifier: The identifier for the type of branch to track. A :class:`BranchManager <git.branches.BranchManager>` for the given identifier must exist in the :attr:`self.managers`. :param name: The friendly (short) name to track. :param base: The alternative base to branch off from. If not given, the default base for the given branch type is used. :returns: The newly created :class:`git.refs.Head` branch. """ repo = self.repo mgr = self.managers[identifier] # sanity checks # :todo: require_clean_working_tree full_name = mgr.full_name(name) if full_name in repo.branches: raise BranchExistsError(full_name) self.origin().fetch(full_name) remote_branch = self.origin().refs[full_name] branch = repo.create_head(full_name, remote_branch) branch.set_tracking_branch(remote_branch) return branch.checkout()