Skip to content
Open
43 changes: 36 additions & 7 deletions PyGitUp/gitup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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]
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()

Expand All @@ -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:
Expand Down
156 changes: 156 additions & 0 deletions PyGitUp/tests/test_branch_sort.py
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>`` 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).

Expand All @@ -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
Expand Down