diff --git a/PyGitUp/gitup.py b/PyGitUp/gitup.py index 0b50991..9dc13a9 100644 --- a/PyGitUp/gitup.py +++ b/PyGitUp/gitup.py @@ -131,9 +131,11 @@ class GitUp: 'push.auto': False, 'push.tags': False, 'push.all': False, + 'branch.sort': None, } - def __init__(self, testing=False, sparse=False, quiet=False): + def __init__(self, testing=False, sparse=False, quiet=False, + branch_sort=None): self.quiet = quiet # Sparse init: config only @@ -182,6 +184,13 @@ def __init__(self, testing=False, sparse=False, quiet=False): self.git = GitWrapper(self.repo) + # Load configuration before building the branch list, because its + # order can be configured. + self.settings = self.default_settings.copy() + self.load_config() + if branch_sort is not None: + self.settings['branch.sort'] = branch_sort + # target_map: map local branch names to remote tracking branches #: :type: dict[str, git.refs.remote.RemoteReference] self.target_map = dict() @@ -201,7 +210,7 @@ def __init__(self, testing=False, sparse=False, quiet=False): # branches: all local branches with tracking information #: :type: list[git.refs.head.Head] self.branches = [b for b in self.repo.branches if b.tracking_branch()] - self.branches.sort(key=lambda br: br.name) + self._sort_branches() # remotes: all remotes that are associated with local branches #: :type: list[git.refs.remote.RemoteReference] @@ -219,10 +228,6 @@ def __init__(self, testing=False, sparse=False, quiet=False): # Build worktree map: branch name -> worktree path self.worktree_map, self.in_progress_branches = self._build_worktree_map() - # Load configuration - self.settings = self.default_settings.copy() - self.load_config() - def run(self): """ Run all the git-up stuff. """ try: @@ -737,6 +742,27 @@ def version_info(self): # Helpers ########################################################################### + def _sort_branches(self): + """Sort branches using the same keys as ``git branch --sort``.""" + sort_key = self.settings['branch.sort'] + if not sort_key: + # Preserve the historical behavior exactly unless sorting was + # explicitly configured. + self.branches.sort(key=lambda branch: branch.name) + return + + try: + names = self.git.for_each_ref( + f'--sort={sort_key}', '--format=%(refname:lstrip=2)', + 'refs/heads' + ).splitlines() + except GitError as error: + error.message = f"Failed to sort branches by '{sort_key}'." + self.print_error(error) + raise + positions = {name: position for position, name in enumerate(names)} + self.branches.sort(key=lambda branch: positions[branch.name]) + def load_config(self): """ Load the configuration from git config. @@ -837,6 +863,9 @@ def run(): # pragma: no cover help='Don\'t try to fetch from origin.') parser.add_argument('-p', '--push', action='store_true', help='Push the changes after pulling successfully.') + parser.add_argument('--branch-sort', metavar='KEY', + help='Sort branches by a git-for-each-ref field, e.g. ' + "'-committerdate'.") args = parser.parse_args() @@ -852,7 +881,7 @@ def run(): # pragma: no cover sys.stdout = StringIO() try: - gitup = GitUp(quiet=args.quiet) + gitup = GitUp(quiet=args.quiet, branch_sort=args.branch_sort) # Only turn pushing on, never off: not passing `--push` must leave # git-up.push.auto from the git config alone. if args.push: diff --git a/PyGitUp/tests/test_branch_sort.py b/PyGitUp/tests/test_branch_sort.py new file mode 100644 index 0000000..221c413 --- /dev/null +++ b/PyGitUp/tests/test_branch_sort.py @@ -0,0 +1,156 @@ +import os +import sys +from os.path import join + +import pytest + +from git import GitCmdObjectDB, Repo + +from PyGitUp.tests import basepath, init_master, update_file + + +test_name = 'branch-sort' +repo_path = join(basepath, test_name + os.sep) + + +def setup_module(): + master_path, master = init_master(test_name) + + branches = [ + ('a-old', '2001-01-01T00:00:00+00:00'), + ('z-new', '2003-01-01T00:00:00+00:00'), + ('m-middle', '2002-01-01T00:00:00+00:00'), + ] + for branch_name, commit_date in branches: + master.git.checkout('initial') + master.git.checkout(b=branch_name) + update_file(master, branch_name) + master.git.commit( + '--amend', '--no-edit', + env={ + 'GIT_AUTHOR_DATE': commit_date, + 'GIT_COMMITTER_DATE': commit_date, + }, + ) + + master.clone(repo_path, b='a-old') + repo = Repo(repo_path, odbt=GitCmdObjectDB) + for branch_name, _ in branches[1:]: + repo.git.branch('--track', branch_name, f'origin/{branch_name}') + + +def branch_names(branch_sort=None): + from PyGitUp.gitup import GitUp + + os.chdir(repo_path) + return [branch.name for branch in GitUp( + testing=True, branch_sort=branch_sort + ).branches] + + +def unset_sort_config(repo): + for key in ('branch.sort', 'git-up.branch.sort'): + try: + repo.git.config('--unset-all', key) + except Exception: + pass + + +def test_branches_are_alphabetical_by_default(monkeypatch): + from PyGitUp.git_wrapper import GitWrapper + + repo = Repo(repo_path) + unset_sort_config(repo) + monkeypatch.setattr( + GitWrapper, + 'for_each_ref', + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError('default sorting must not invoke git for-each-ref') + ), + raising=False, + ) + + assert branch_names() == ['a-old', 'm-middle', 'z-new'] + + +def test_native_branch_sort_does_not_change_default(): + repo = Repo(repo_path) + unset_sort_config(repo) + repo.git.config('branch.sort', '-committerdate') + + assert branch_names() == ['a-old', 'm-middle', 'z-new'] + + +def test_git_up_branch_sort_overrides_native_config(): + repo = Repo(repo_path) + unset_sort_config(repo) + repo.git.config('branch.sort', 'refname') + repo.git.config('git-up.branch.sort', '-committerdate') + + assert branch_names() == ['z-new', 'm-middle', 'a-old'] + + +def test_cli_branch_sort_overrides_config(monkeypatch): + from PyGitUp import gitup + + repo = Repo(repo_path) + unset_sort_config(repo) + repo.git.config('git-up.branch.sort', 'refname') + os.chdir(repo_path) + + recorded = [] + monkeypatch.setattr( + gitup.GitUp, + 'run', + lambda self: recorded.extend(branch.name for branch in self.branches), + ) + monkeypatch.setattr( + sys, 'argv', ['git-up', '--branch-sort=-committerdate'] + ) + + gitup.run() + + assert recorded == ['z-new', 'm-middle', 'a-old'] + + +def test_branch_sort_with_matching_tag(): + repo = Repo(repo_path) + unset_sort_config(repo) + repo.git.branch('--track', 'v1', 'origin/a-old') + repo.git.tag('v1', 'refs/heads/v1') + try: + assert branch_names('refname') == [ + 'a-old', 'm-middle', 'v1', 'z-new' + ] + finally: + repo.git.tag('-d', 'v1') + repo.git.branch('-D', 'v1') + + +@pytest.mark.parametrize('source', ['cli', 'config']) +@pytest.mark.parametrize('quiet', [False, True]) +def test_invalid_branch_sort_prints_error(source, quiet, monkeypatch, capsys): + from PyGitUp import gitup + + repo = Repo(repo_path) + unset_sort_config(repo) + monkeypatch.chdir(repo_path) + args = ['git-up'] + if source == 'cli': + args.append('--branch-sort=bogus') + else: + repo.git.config('git-up.branch.sort', 'bogus') + if quiet: + args.append('--quiet') + monkeypatch.setattr(sys, 'argv', args) + # run() redirects stdout in quiet mode; restore it after the test. + monkeypatch.setattr(sys, 'stdout', sys.stdout) + + with pytest.raises(SystemExit) as error: + gitup.run() + + assert error.value.code == 1 + captured = capsys.readouterr() + assert captured.out == '' + assert "Failed to sort branches by 'bogus'" in captured.err + assert 'unknown field name: bogus' in captured.err diff --git a/README.rst b/README.rst index 6b01742..73cffb9 100644 --- a/README.rst +++ b/README.rst @@ -99,6 +99,11 @@ Command Line Arguments - ``git up -p``/``git up --push`` pushes the changes after pulling successfully, like ``git-up.push.auto`` does. +- ``git up --branch-sort `` sorts branches by a + ``git for-each-ref --sort`` field. For example, use + ``--branch-sort=-committerdate`` to process the most recently committed + branches first. + - ``git up -V``/``git up --version`` shows the current version and optionally checks for updates (see ``git-up.updates.check`` below). @@ -109,6 +114,13 @@ To configure ``PyGitUp``, you can set options in your git config. Run ``git config [--global] git-up.[name] [value]`` to set one of these options: +- ``git-up.branch.sort [key]``: Sort branches by a + ``git for-each-ref --sort`` field, for example ``-committerdate`` for the + most recently committed branches first. If unset, branches retain their + historical alphabetical order. Git's native ``branch.sort`` setting is + deliberately not applied, so upgrading PyGitUp cannot implicitly change + the processing order. + - ``git-up.fetch.prune [*true*|false]``: If set to ``true``, ``PyGitUp`` will append the ``--prune``\ option to ``git fetch`` and thus remove any remote tracking branches which no longer exist on