| @@ -10,8 +10,14 @@ import platform | |||
| from colorama import init as color_init, Style | |||
| from gitup import __version__ | |||
| from gitup.config import (get_default_config_path, get_bookmarks, add_bookmarks, | |||
| delete_bookmarks, list_bookmarks, clean_bookmarks) | |||
| from gitup.config import ( | |||
| get_default_config_path, | |||
| get_bookmarks, | |||
| add_bookmarks, | |||
| delete_bookmarks, | |||
| list_bookmarks, | |||
| clean_bookmarks, | |||
| ) | |||
| from gitup.update import update_bookmarks, update_directories, run_command | |||
| @@ -23,7 +29,8 @@ def _build_parser(): | |||
| Both relative and absolute paths are accepted by all arguments. | |||
| Direct bug reports and feature requests to | |||
| https://github.com/earwig/git-repo-updater.""", | |||
| add_help=False) | |||
| add_help=False, | |||
| ) | |||
| group_u = parser.add_argument_group("updating repositories") | |||
| group_b = parser.add_argument_group("bookmarking") | |||
| @@ -31,63 +38,125 @@ def _build_parser(): | |||
| group_m = parser.add_argument_group("miscellaneous") | |||
| group_u.add_argument( | |||
| 'directories_to_update', nargs="*", metavar="path", | |||
| "directories_to_update", | |||
| nargs="*", | |||
| metavar="path", | |||
| help="""update this repository, or all repositories it contains | |||
| (if not a repo directly)""") | |||
| (if not a repo directly)""", | |||
| ) | |||
| group_u.add_argument( | |||
| '-u', '--update', action="store_true", help="""update all bookmarks | |||
| (default behavior when called without arguments)""") | |||
| "-u", | |||
| "--update", | |||
| action="store_true", | |||
| help="""update all bookmarks | |||
| (default behavior when called without arguments)""", | |||
| ) | |||
| group_u.add_argument( | |||
| '-t', '--depth', dest="max_depth", metavar="n", type=int, default=3, | |||
| "-t", | |||
| "--depth", | |||
| dest="max_depth", | |||
| metavar="n", | |||
| type=int, | |||
| default=3, | |||
| help="""max recursion depth when searching for repos in subdirectories | |||
| (default: 3; use 0 for no recursion, or -1 for unlimited)""") | |||
| (default: 3; use 0 for no recursion, or -1 for unlimited)""", | |||
| ) | |||
| group_u.add_argument( | |||
| '-c', '--current-only', action="store_true", help="""only fetch the | |||
| remote tracked by the current branch instead of all remotes""") | |||
| "-c", | |||
| "--current-only", | |||
| action="store_true", | |||
| help="""only fetch the | |||
| remote tracked by the current branch instead of all remotes""", | |||
| ) | |||
| group_u.add_argument( | |||
| '-f', '--fetch-only', action="store_true", | |||
| help="only fetch remotes, don't try to fast-forward any branches") | |||
| "-f", | |||
| "--fetch-only", | |||
| action="store_true", | |||
| help="only fetch remotes, don't try to fast-forward any branches", | |||
| ) | |||
| group_u.add_argument( | |||
| '-p', '--prune', action="store_true", help="""after fetching, delete | |||
| remote-tracking branches that no longer exist on their remote""") | |||
| "-p", | |||
| "--prune", | |||
| action="store_true", | |||
| help="""after fetching, delete | |||
| remote-tracking branches that no longer exist on their remote""", | |||
| ) | |||
| group_b.add_argument( | |||
| '-a', '--add', dest="bookmarks_to_add", nargs="+", metavar="path", help="add directory(s) as bookmarks") | |||
| "-a", | |||
| "--add", | |||
| dest="bookmarks_to_add", | |||
| nargs="+", | |||
| metavar="path", | |||
| help="add directory(s) as bookmarks", | |||
| ) | |||
| group_b.add_argument( | |||
| '-d', '--delete', dest="bookmarks_to_del", nargs="+", metavar="path", | |||
| help="delete bookmark(s) (leaves actual directories alone)") | |||
| "-d", | |||
| "--delete", | |||
| dest="bookmarks_to_del", | |||
| nargs="+", | |||
| metavar="path", | |||
| help="delete bookmark(s) (leaves actual directories alone)", | |||
| ) | |||
| group_b.add_argument( | |||
| '-l', '--list', dest="list_bookmarks", action="store_true", | |||
| help="list current bookmarks") | |||
| "-l", | |||
| "--list", | |||
| dest="list_bookmarks", | |||
| action="store_true", | |||
| help="list current bookmarks", | |||
| ) | |||
| group_b.add_argument( | |||
| '-n', '--clean', '--cleanup', dest="clean_bookmarks", | |||
| action="store_true", help="delete any bookmarks that don't exist") | |||
| "-n", | |||
| "--clean", | |||
| "--cleanup", | |||
| dest="clean_bookmarks", | |||
| action="store_true", | |||
| help="delete any bookmarks that don't exist", | |||
| ) | |||
| group_b.add_argument( | |||
| '-b', '--bookmark-file', nargs="?", metavar="path", | |||
| "-b", | |||
| "--bookmark-file", | |||
| nargs="?", | |||
| metavar="path", | |||
| help="use a specific bookmark config file (default: {0})".format( | |||
| get_default_config_path())) | |||
| get_default_config_path() | |||
| ), | |||
| ) | |||
| group_a.add_argument( | |||
| '-e', '--exec', '--batch', dest="command", metavar="command", | |||
| help="run a shell command on all repos") | |||
| "-e", | |||
| "--exec", | |||
| "--batch", | |||
| dest="command", | |||
| metavar="command", | |||
| help="run a shell command on all repos", | |||
| ) | |||
| group_m.add_argument( | |||
| '-h', '--help', action="help", help="show this help message and exit") | |||
| "-h", "--help", action="help", help="show this help message and exit" | |||
| ) | |||
| group_m.add_argument( | |||
| '-v', '--version', action="version", | |||
| version="gitup {0} (Python {1})".format( | |||
| __version__, platform.python_version())) | |||
| "-v", | |||
| "--version", | |||
| action="version", | |||
| version="gitup {0} (Python {1})".format(__version__, platform.python_version()), | |||
| ) | |||
| group_m.add_argument( | |||
| '--selftest', action="store_true", | |||
| help="run integrated test suite and exit (pytest must be available)") | |||
| "--selftest", | |||
| action="store_true", | |||
| help="run integrated test suite and exit (pytest must be available)", | |||
| ) | |||
| return parser | |||
| def _selftest(): | |||
| """Run the integrated test suite with pytest.""" | |||
| from .test import run_tests | |||
| run_tests() | |||
| def main(): | |||
| """Parse arguments and then call the appropriate function(s).""" | |||
| parser = _build_parser() | |||
| @@ -130,6 +199,7 @@ def main(): | |||
| if args.update or not acted: | |||
| update_bookmarks(get_bookmarks(args.bookmark_file), args) | |||
| def run(): | |||
| """Thin wrapper for main() that catches KeyboardInterrupts.""" | |||
| try: | |||
| @@ -10,20 +10,28 @@ from colorama import Fore, Style | |||
| from gitup.migrate import run_migrations | |||
| __all__ = ["get_default_config_path", "get_bookmarks", "add_bookmarks", | |||
| "delete_bookmarks", "list_bookmarks", "clean_bookmarks"] | |||
| __all__ = [ | |||
| "get_default_config_path", | |||
| "get_bookmarks", | |||
| "add_bookmarks", | |||
| "delete_bookmarks", | |||
| "list_bookmarks", | |||
| "clean_bookmarks", | |||
| ] | |||
| YELLOW = Fore.YELLOW + Style.BRIGHT | |||
| RED = Fore.RED + Style.BRIGHT | |||
| INDENT1 = " " * 3 | |||
| def _ensure_dirs(path): | |||
| """Ensure the directories within the given pathname exist.""" | |||
| dirname = os.path.dirname(path) | |||
| if dirname and not os.path.exists(dirname): # Race condition, meh... | |||
| os.makedirs(dirname) | |||
| def _load_config_file(config_path=None): | |||
| """Read the config file and return a list of bookmarks.""" | |||
| run_migrations() | |||
| @@ -37,6 +45,7 @@ def _load_config_file(config_path=None): | |||
| paths = [path.decode("utf8").strip() for path in paths] | |||
| return [path for path in paths if path] | |||
| def _save_config_file(bookmarks, config_path=None): | |||
| """Save the bookmarks list to the given config file.""" | |||
| run_migrations() | |||
| @@ -47,21 +56,25 @@ def _save_config_file(bookmarks, config_path=None): | |||
| with open(cfg_path, "wb") as config_file: | |||
| config_file.write(dump) | |||
| def _normalize_path(path): | |||
| """Normalize the given path.""" | |||
| if path.startswith("~"): | |||
| return os.path.normcase(os.path.normpath(path)) | |||
| return os.path.normcase(os.path.abspath(path)) | |||
| def get_default_config_path(): | |||
| """Return the default path to the configuration file.""" | |||
| xdg_cfg = os.environ.get("XDG_CONFIG_HOME") or os.path.join("~", ".config") | |||
| return os.path.join(os.path.expanduser(xdg_cfg), "gitup", "bookmarks") | |||
| def get_bookmarks(config_path=None): | |||
| """Get a list of all bookmarks, or an empty list if there are none.""" | |||
| return _load_config_file(config_path) | |||
| def add_bookmarks(paths, config_path=None): | |||
| """Add a list of paths as bookmarks to the config file.""" | |||
| config = _load_config_file(config_path) | |||
| @@ -85,6 +98,7 @@ def add_bookmarks(paths, config_path=None): | |||
| for path in exists: | |||
| print(INDENT1, path) | |||
| def delete_bookmarks(paths, config_path=None): | |||
| """Remove a list of paths from the bookmark config file.""" | |||
| config = _load_config_file(config_path) | |||
| @@ -111,6 +125,7 @@ def delete_bookmarks(paths, config_path=None): | |||
| for path in notmarked: | |||
| print(INDENT1, path) | |||
| def list_bookmarks(config_path=None): | |||
| """Print all of our current bookmarks.""" | |||
| bookmarks = _load_config_file(config_path) | |||
| @@ -121,6 +136,7 @@ def list_bookmarks(config_path=None): | |||
| else: | |||
| print("You have no bookmarks to display.") | |||
| def clean_bookmarks(config_path=None): | |||
| """Delete any bookmarks that don't exist.""" | |||
| bookmarks = _load_config_file(config_path) | |||
| @@ -128,8 +144,11 @@ def clean_bookmarks(config_path=None): | |||
| print("You have no bookmarks to clean up.") | |||
| return | |||
| delete = [path for path in bookmarks | |||
| if not (os.path.isdir(path) or glob(os.path.expanduser(path)))] | |||
| delete = [ | |||
| path | |||
| for path in bookmarks | |||
| if not (os.path.isdir(path) or glob(os.path.expanduser(path))) | |||
| ] | |||
| if not delete: | |||
| print("All of your bookmarks are valid.") | |||
| return | |||
| @@ -9,11 +9,13 @@ from configparser import ConfigParser, NoSectionError | |||
| __all__ = ["run_migrations"] | |||
| def _get_old_path(): | |||
| """Return the old default path to the configuration file.""" | |||
| xdg_cfg = os.environ.get("XDG_CONFIG_HOME") or os.path.join("~", ".config") | |||
| return os.path.join(os.path.expanduser(xdg_cfg), "gitup", "config.ini") | |||
| def _migrate_old_path(): | |||
| """Migrate the old config location (~/.gitup) to the new one.""" | |||
| old_path = os.path.expanduser(os.path.join("~", ".gitup")) | |||
| @@ -26,6 +28,7 @@ def _migrate_old_path(): | |||
| os.makedirs(temp_dir) | |||
| os.rename(old_path, temp_path) | |||
| def _migrate_old_format(): | |||
| """Migrate the old config file format (.INI) to our custom list format.""" | |||
| old_path = _get_old_path() | |||
| @@ -47,6 +50,7 @@ def _migrate_old_format(): | |||
| with open(new_path, "wb") as handle: | |||
| handle.write(b"\n".join(bookmarks)) | |||
| def run_migrations(): | |||
| """Run any necessary migrations to ensure the config file is up-to-date.""" | |||
| _migrate_old_path() | |||
| @@ -3,8 +3,10 @@ | |||
| # Copyright (C) 2011-2018 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # Released under the terms of the MIT License. See LICENSE for details. | |||
| def run_tests(args=None): | |||
| import pytest | |||
| if args is None: | |||
| args = ["-v", "-rxw"] | |||
| return pytest.main(args) | |||
| @@ -5,6 +5,7 @@ | |||
| from gitup import config | |||
| def test_empty_list(tmpdir, capsys): | |||
| config_path = tmpdir / "config" | |||
| config.list_bookmarks(config_path) | |||
| @@ -9,14 +9,15 @@ import sys | |||
| from gitup import __version__ | |||
| def run_cli(*args): | |||
| cmd = [sys.executable, "-m", "gitup"] + list(args) | |||
| output = subprocess.check_output(cmd) | |||
| return output.strip().decode("utf8") | |||
| def test_cli_version(): | |||
| """make sure we're using the right version of gitup""" | |||
| output = run_cli("-v") | |||
| expected = "gitup {} (Python {})".format( | |||
| __version__, platform.python_version()) | |||
| expected = "gitup {} (Python {})".format(__version__, platform.python_version()) | |||
| assert output == expected | |||
| @@ -28,6 +28,7 @@ INDENT1 = " " * 3 | |||
| INDENT2 = " " * 7 | |||
| ERROR = RED + "Error:" + RESET | |||
| class _ProgressMonitor(RemoteProgress): | |||
| """Displays relevant output during the fetching process.""" | |||
| @@ -35,7 +36,7 @@ class _ProgressMonitor(RemoteProgress): | |||
| super(_ProgressMonitor, self).__init__() | |||
| self._started = False | |||
| def update(self, op_code, cur_count, max_count=None, message=''): | |||
| def update(self, op_code, cur_count, max_count=None, message=""): | |||
| """Called whenever progress changes. Overrides default behavior.""" | |||
| if op_code & (self.COMPRESSING | self.RECEIVING): | |||
| cur_count = str(int(cur_count)) | |||
| @@ -59,14 +60,17 @@ class _ProgressMonitor(RemoteProgress): | |||
| def _fetch_remotes(remotes, prune): | |||
| """Fetch a list of remotes, displaying progress info along the way.""" | |||
| def _get_name(ref): | |||
| """Return the local name of a remote or tag reference.""" | |||
| return ref.remote_head if isinstance(ref, RemoteRef) else ref.name | |||
| # TODO: missing branch deleted (via --prune): | |||
| info = [("NEW_HEAD", "new branch", "new branches"), | |||
| ("NEW_TAG", "new tag", "new tags"), | |||
| ("FAST_FORWARD", "branch update", "branch updates")] | |||
| info = [ | |||
| ("NEW_HEAD", "new branch", "new branches"), | |||
| ("NEW_TAG", "new tag", "new tags"), | |||
| ("FAST_FORWARD", "branch update", "branch updates"), | |||
| ] | |||
| up_to_date = BLUE + "up to date" + RESET | |||
| for remote in remotes: | |||
| @@ -92,19 +96,25 @@ def _fetch_remotes(remotes, prune): | |||
| return | |||
| except AssertionError: # Seems to be the result of a bug in GitPython | |||
| # This happens when git initiates an auto-gc during fetch: | |||
| print(":", RED + "error:", "something went wrong in GitPython,", | |||
| "but the fetch might have been successful.") | |||
| print( | |||
| ":", | |||
| RED + "error:", | |||
| "something went wrong in GitPython,", | |||
| "but the fetch might have been successful.", | |||
| ) | |||
| return | |||
| rlist = [] | |||
| for attr, singular, plural in info: | |||
| names = [_get_name(res.ref) | |||
| for res in results if res.flags & getattr(res, attr)] | |||
| names = [ | |||
| _get_name(res.ref) for res in results if res.flags & getattr(res, attr) | |||
| ] | |||
| if names: | |||
| desc = singular if len(names) == 1 else plural | |||
| colored = GREEN + desc + RESET | |||
| rlist.append("{0} ({1})".format(colored, ", ".join(names))) | |||
| print(":", (", ".join(rlist) if rlist else up_to_date) + ".") | |||
| def _update_branch(repo, branch, is_active=False): | |||
| """Update a single branch.""" | |||
| print(INDENT2, "Updating", BOLD + branch.name, end=": ") | |||
| @@ -146,14 +156,19 @@ def _update_branch(repo, branch, is_active=False): | |||
| print(YELLOW + "skipped:", "not possible to fast-forward.") | |||
| else: | |||
| status = repo.git.merge_base( | |||
| branch.commit, upstream.commit, is_ancestor=True, | |||
| with_extended_output=True, with_exceptions=False)[0] | |||
| branch.commit, | |||
| upstream.commit, | |||
| is_ancestor=True, | |||
| with_extended_output=True, | |||
| with_exceptions=False, | |||
| )[0] | |||
| if status != 0: | |||
| print(YELLOW + "skipped:", "not possible to fast-forward.") | |||
| else: | |||
| repo.git.branch(branch.name, upstream.name, force=True) | |||
| print(GREEN + "done", end=".\n") | |||
| def _update_repository(repo, repo_name, args): | |||
| """Update a single git repository by fetching remotes and rebasing/merging. | |||
| @@ -172,8 +187,11 @@ def _update_repository(repo, repo_name, args): | |||
| active = None | |||
| if args.current_only: | |||
| if not active: | |||
| print(INDENT2, ERROR, | |||
| "--current-only doesn't make sense with a detached HEAD.") | |||
| print( | |||
| INDENT2, | |||
| ERROR, | |||
| "--current-only doesn't make sense with a detached HEAD.", | |||
| ) | |||
| return | |||
| ref = active.tracking_branch() | |||
| if not ref: | |||
| @@ -192,14 +210,14 @@ def _update_repository(repo, repo_name, args): | |||
| for branch in sorted(repo.heads, key=lambda b: b.name): | |||
| _update_branch(repo, branch, branch == active) | |||
| def _run_command(repo, repo_name, args): | |||
| """Run an arbitrary shell command on the given repository.""" | |||
| print(INDENT1, BOLD + repo_name + ":") | |||
| cmd = shlex.split(args.command) | |||
| try: | |||
| out = repo.git.execute( | |||
| cmd, with_extended_output=True, with_exceptions=False) | |||
| out = repo.git.execute(cmd, with_extended_output=True, with_exceptions=False) | |||
| except exc.GitCommandNotFound as err: | |||
| print(INDENT2, ERROR, err) | |||
| return | |||
| @@ -207,6 +225,7 @@ def _run_command(repo, repo_name, args): | |||
| for line in out[1].splitlines() + out[2].splitlines(): | |||
| print(INDENT2, line) | |||
| def _dispatch(base_path, callback, args): | |||
| """Apply a callback function on each valid repo in the given path. | |||
| @@ -217,6 +236,7 @@ def _dispatch(base_path, callback, args): | |||
| The given args are passed directly to the callback function after the repo. | |||
| """ | |||
| def _collect(paths, max_depth): | |||
| """Return all valid repo paths in the given paths, recursively.""" | |||
| if max_depth == 0: | |||
| @@ -282,14 +302,17 @@ def _dispatch(base_path, callback, args): | |||
| for name, path in sorted(paths): | |||
| callback(Repo(path), name, args) | |||
| def is_comment(path): | |||
| """Does the line start with a # symbol?""" | |||
| return path.lstrip().startswith("#") | |||
| def get_comment(path): | |||
| """Return the string minus the comment symbol.""" | |||
| return path.lstrip().lstrip("#").strip() | |||
| def update_bookmarks(bookmarks, args): | |||
| """Loop through and update all bookmarks.""" | |||
| if not bookmarks: | |||
| @@ -299,11 +322,13 @@ def update_bookmarks(bookmarks, args): | |||
| for path in bookmarks: | |||
| _dispatch(path, _update_repository, args) | |||
| def update_directories(paths, args): | |||
| """Update a list of directories supplied by command arguments.""" | |||
| for path in paths: | |||
| _dispatch(path, _update_repository, args) | |||
| def run_command(paths, args): | |||
| """Run an arbitrary shell command on all repos.""" | |||
| for path in paths: | |||