| @@ -1,3 +1,16 @@ | |||||
| v0.4 (released January 17, 2017): | |||||
| - Added a `--prune` flag to delete remote-tracking branches that no longer | |||||
| exist on their remote after fetching. | |||||
| - Added a `--bookmark-file` flag to support multiple bookmark config files. | |||||
| - Added a `--cleanup` flag to remove old bookmarks that don't exist. | |||||
| - Added an `--exec` flag to run a shell command on all of your repos. | |||||
| - Added support for shell glob patterns and tilde expansion in bookmark files. | |||||
| - Cleaned up the bookmark file format, fixing a related Windows bug. The script | |||||
| will automatically migrate to the new one. | |||||
| - Fixed a bug related to Python 3 compatibility. | |||||
| - Fixed unicode support. | |||||
| v0.3 (released June 7, 2015): | v0.3 (released June 7, 2015): | ||||
| - Added support for Python 3. | - Added support for Python 3. | ||||
| @@ -1,4 +1,4 @@ | |||||
| Copyright (C) 2011-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| Copyright (C) 2011-2017 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| Permission is hereby granted, free of charge, to any person obtaining a copy | Permission is hereby granted, free of charge, to any person obtaining a copy | ||||
| of this software and associated documentation files (the "Software"), to deal | of this software and associated documentation files (the "Software"), to deal | ||||
| @@ -89,6 +89,10 @@ upstreams configured. It will always skip branches where this is not possible | |||||
| (e.g. dirty working directory or a merge/rebase is required). Pass | (e.g. dirty working directory or a merge/rebase is required). Pass | ||||
| `--fetch-only` (or `-f`) to only fetch remotes. | `--fetch-only` (or `-f`) to only fetch remotes. | ||||
| After fetching, gitup will _keep_ remote-tracking branches that no longer exist | |||||
| upstream. Pass `--prune` (or `-p`) to delete them, or set `fetch.prune` or | |||||
| `remote.<name>.prune` in git config to do this by default. | |||||
| For a full list of all command arguments and abbreviations: | For a full list of all command arguments and abbreviations: | ||||
| gitup --help | gitup --help | ||||
| @@ -1,6 +1,6 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| # | # | ||||
| # Copyright (C) 2011-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| # Copyright (C) 2011-2017 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| # Released under the terms of the MIT License. See LICENSE for details. | # Released under the terms of the MIT License. See LICENSE for details. | ||||
| """ | """ | ||||
| @@ -8,7 +8,7 @@ gitup: the git repository updater | |||||
| """ | """ | ||||
| __author__ = "Ben Kurtovic" | __author__ = "Ben Kurtovic" | ||||
| __copyright__ = "Copyright (C) 2011-2015 Ben Kurtovic" | |||||
| __copyright__ = "Copyright (C) 2011-2017 Ben Kurtovic" | |||||
| __license__ = "MIT License" | __license__ = "MIT License" | ||||
| __version__ = "0.3" | |||||
| __version__ = "0.4" | |||||
| __email__ = "ben.kurtovic@gmail.com" | __email__ = "ben.kurtovic@gmail.com" | ||||
| @@ -1,21 +1,19 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| # | # | ||||
| # Copyright (C) 2011-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| # Copyright (C) 2011-2016 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| # Released under the terms of the MIT License. See LICENSE for details. | # Released under the terms of the MIT License. See LICENSE for details. | ||||
| from __future__ import print_function | from __future__ import print_function | ||||
| from glob import glob | |||||
| import os | import os | ||||
| try: | |||||
| import configparser | |||||
| except ImportError: # Python 2 | |||||
| import ConfigParser as configparser | |||||
| from colorama import Fore, Style | from colorama import Fore, Style | ||||
| __all__ = ["get_bookmarks", "add_bookmarks", "delete_bookmarks", | |||||
| "list_bookmarks"] | |||||
| from .migrate import run_migrations | |||||
| __all__ = ["get_default_config_path", "get_bookmarks", "add_bookmarks", | |||||
| "delete_bookmarks", "list_bookmarks", "clean_bookmarks"] | |||||
| YELLOW = Fore.YELLOW + Style.BRIGHT | YELLOW = Fore.YELLOW + Style.BRIGHT | ||||
| RED = Fore.RED + Style.BRIGHT | RED = Fore.RED + Style.BRIGHT | ||||
| @@ -25,63 +23,60 @@ INDENT1 = " " * 3 | |||||
| def _ensure_dirs(path): | def _ensure_dirs(path): | ||||
| """Ensure the directories within the given pathname exist.""" | """Ensure the directories within the given pathname exist.""" | ||||
| dirname = os.path.dirname(path) | dirname = os.path.dirname(path) | ||||
| if not os.path.exists(dirname): # Race condition, meh... | |||||
| if dirname and not os.path.exists(dirname): # Race condition, meh... | |||||
| os.makedirs(dirname) | os.makedirs(dirname) | ||||
| def _get_config_path(): | |||||
| """Return the 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_config_path(): | |||||
| """Migrate the old config location (~/.gitup) to the new one.""" | |||||
| old_path = os.path.expanduser(os.path.join("~", ".gitup")) | |||||
| if os.path.exists(old_path): | |||||
| new_path = _get_config_path() | |||||
| _ensure_dirs(new_path) | |||||
| os.rename(old_path, new_path) | |||||
| def _load_config_file(): | |||||
| """Read the config file and return a SafeConfigParser() object.""" | |||||
| _migrate_old_config_path() | |||||
| config = configparser.SafeConfigParser() | |||||
| # Don't lowercase option names, because we are storing paths there: | |||||
| config.optionxform = str | |||||
| config.read(_get_config_path()) | |||||
| return config | |||||
| def _save_config_file(config): | |||||
| """Save config changes to the config file returned by _get_config_path.""" | |||||
| _migrate_old_config_path() | |||||
| cfg_path = _get_config_path() | |||||
| def _load_config_file(config_path=None): | |||||
| """Read the config file and return a list of bookmarks.""" | |||||
| run_migrations() | |||||
| cfg_path = config_path or get_default_config_path() | |||||
| try: | |||||
| with open(cfg_path, "rb") as config_file: | |||||
| paths = config_file.read().split(b"\n") | |||||
| except IOError: | |||||
| return [] | |||||
| 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() | |||||
| cfg_path = config_path or get_default_config_path() | |||||
| _ensure_dirs(cfg_path) | _ensure_dirs(cfg_path) | ||||
| dump = b"\n".join(path.encode("utf8") for path in bookmarks) | |||||
| with open(cfg_path, "wb") as config_file: | with open(cfg_path, "wb") as config_file: | ||||
| config.write(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(): | |||||
| def get_bookmarks(config_path=None): | |||||
| """Get a list of all bookmarks, or an empty list if there are none.""" | """Get a list of all bookmarks, or an empty list if there are none.""" | ||||
| config = _load_config_file() | |||||
| try: | |||||
| return [path for path, _ in config.items("bookmarks")] | |||||
| except configparser.NoSectionError: | |||||
| return [] | |||||
| return _load_config_file(config_path) | |||||
| def add_bookmarks(paths): | |||||
| def add_bookmarks(paths, config_path=None): | |||||
| """Add a list of paths as bookmarks to the config file.""" | """Add a list of paths as bookmarks to the config file.""" | ||||
| config = _load_config_file() | |||||
| if not config.has_section("bookmarks"): | |||||
| config.add_section("bookmarks") | |||||
| config = _load_config_file(config_path) | |||||
| paths = [_normalize_path(path) for path in paths] | |||||
| added, exists = [], [] | added, exists = [], [] | ||||
| for path in paths: | for path in paths: | ||||
| path = os.path.abspath(path) | |||||
| if config.has_option("bookmarks", path): | |||||
| if path in config: | |||||
| exists.append(path) | exists.append(path) | ||||
| else: | else: | ||||
| path_name = os.path.split(path)[1] | |||||
| config.set("bookmarks", path, path_name) | |||||
| config.append(path) | |||||
| added.append(path) | added.append(path) | ||||
| _save_config_file(config) | |||||
| _save_config_file(config, config_path) | |||||
| if added: | if added: | ||||
| print(YELLOW + "Added bookmarks:") | print(YELLOW + "Added bookmarks:") | ||||
| @@ -92,22 +87,22 @@ def add_bookmarks(paths): | |||||
| for path in exists: | for path in exists: | ||||
| print(INDENT1, path) | print(INDENT1, path) | ||||
| def delete_bookmarks(paths): | |||||
| def delete_bookmarks(paths, config_path=None): | |||||
| """Remove a list of paths from the bookmark config file.""" | """Remove a list of paths from the bookmark config file.""" | ||||
| config = _load_config_file() | |||||
| config = _load_config_file(config_path) | |||||
| paths = [_normalize_path(path) for path in paths] | |||||
| deleted, notmarked = [], [] | deleted, notmarked = [], [] | ||||
| if config.has_section("bookmarks"): | |||||
| if config: | |||||
| for path in paths: | for path in paths: | ||||
| path = os.path.abspath(path) | |||||
| config_was_changed = config.remove_option("bookmarks", path) | |||||
| if config_was_changed: | |||||
| if path in config: | |||||
| config.remove(path) | |||||
| deleted.append(path) | deleted.append(path) | ||||
| else: | else: | ||||
| notmarked.append(path) | notmarked.append(path) | ||||
| _save_config_file(config) | |||||
| _save_config_file(config, config_path) | |||||
| else: | else: | ||||
| notmarked = [os.path.abspath(path) for path in paths] | |||||
| notmarked = paths | |||||
| if deleted: | if deleted: | ||||
| print(YELLOW + "Deleted bookmarks:") | print(YELLOW + "Deleted bookmarks:") | ||||
| @@ -118,12 +113,32 @@ def delete_bookmarks(paths): | |||||
| for path in notmarked: | for path in notmarked: | ||||
| print(INDENT1, path) | print(INDENT1, path) | ||||
| def list_bookmarks(): | |||||
| def list_bookmarks(config_path=None): | |||||
| """Print all of our current bookmarks.""" | """Print all of our current bookmarks.""" | ||||
| bookmarks = get_bookmarks() | |||||
| bookmarks = _load_config_file(config_path) | |||||
| if bookmarks: | if bookmarks: | ||||
| print(YELLOW + "Current bookmarks:") | print(YELLOW + "Current bookmarks:") | ||||
| for bookmark_path in bookmarks: | for bookmark_path in bookmarks: | ||||
| print(INDENT1, bookmark_path) | print(INDENT1, bookmark_path) | ||||
| else: | else: | ||||
| print("You have no bookmarks to display.") | 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) | |||||
| if not bookmarks: | |||||
| 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)))] | |||||
| if not delete: | |||||
| print("All of your bookmarks are valid.") | |||||
| return | |||||
| bookmarks = [path for path in bookmarks if path not in delete] | |||||
| _save_config_file(bookmarks, config_path) | |||||
| print(YELLOW + "Deleted bookmarks:") | |||||
| for path in delete: | |||||
| print(INDENT1, path) | |||||
| @@ -0,0 +1,60 @@ | |||||
| # -*- coding: utf-8 -*- | |||||
| # | |||||
| # Copyright (C) 2011-2016 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| # Released under the terms of the MIT License. See LICENSE for details. | |||||
| import os | |||||
| try: | |||||
| from configparser import ConfigParser, NoSectionError | |||||
| PY3K = True | |||||
| except ImportError: # Python 2 | |||||
| from ConfigParser import SafeConfigParser as ConfigParser, NoSectionError | |||||
| PY3K = False | |||||
| __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")) | |||||
| if not os.path.exists(old_path): | |||||
| return | |||||
| temp_path = _get_old_path() | |||||
| temp_dir = os.path.dirname(temp_path) | |||||
| if not os.path.exists(temp_dir): | |||||
| 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() | |||||
| if not os.path.exists(old_path): | |||||
| return | |||||
| config = ConfigParser(delimiters="=") if PY3K else ConfigParser() | |||||
| config.optionxform = lambda opt: opt | |||||
| config.read(old_path) | |||||
| try: | |||||
| bookmarks = [path for path, _ in config.items("bookmarks")] | |||||
| except NoSectionError: | |||||
| bookmarks = [] | |||||
| if PY3K: | |||||
| bookmarks = [path.encode("utf8") for path in bookmarks] | |||||
| new_path = os.path.join(os.path.split(old_path)[0], "bookmarks") | |||||
| os.rename(old_path, new_path) | |||||
| 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() | |||||
| _migrate_old_format() | |||||
| @@ -1,18 +1,26 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| # | # | ||||
| # Copyright (C) 2011-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| # Copyright (C) 2011-2016 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| # Released under the terms of the MIT License. See LICENSE for details. | # Released under the terms of the MIT License. See LICENSE for details. | ||||
| from __future__ import print_function | from __future__ import print_function | ||||
| import argparse | import argparse | ||||
| import os | |||||
| import sys | |||||
| from colorama import init as color_init, Fore, Style | from colorama import init as color_init, Fore, Style | ||||
| from . import __version__ | from . import __version__ | ||||
| from .config import (get_bookmarks, add_bookmarks, delete_bookmarks, | |||||
| list_bookmarks) | |||||
| from .update import update_bookmarks, update_directories | |||||
| from .config import (get_default_config_path, get_bookmarks, add_bookmarks, | |||||
| delete_bookmarks, list_bookmarks, clean_bookmarks) | |||||
| from .update import update_bookmarks, update_directories, run_command | |||||
| def _decode(path): | |||||
| """Decode the given string using the system's filesystem encoding.""" | |||||
| if sys.version_info.major > 2: | |||||
| return path | |||||
| return path.decode(sys.getfilesystemencoding()) | |||||
| def main(): | def main(): | ||||
| """Parse arguments and then call the appropriate function(s).""" | """Parse arguments and then call the appropriate function(s).""" | ||||
| @@ -20,16 +28,17 @@ def main(): | |||||
| description="Easily update multiple git repositories at once.", | description="Easily update multiple git repositories at once.", | ||||
| epilog=""" | epilog=""" | ||||
| Both relative and absolute paths are accepted by all arguments. | Both relative and absolute paths are accepted by all arguments. | ||||
| Direct bug reports and feature requests to: | |||||
| Direct bug reports and feature requests to | |||||
| https://github.com/earwig/git-repo-updater.""", | https://github.com/earwig/git-repo-updater.""", | ||||
| add_help=False) | add_help=False) | ||||
| group_u = parser.add_argument_group("updating repositories") | group_u = parser.add_argument_group("updating repositories") | ||||
| group_b = parser.add_argument_group("bookmarking") | group_b = parser.add_argument_group("bookmarking") | ||||
| group_a = parser.add_argument_group("advanced") | |||||
| group_m = parser.add_argument_group("miscellaneous") | group_m = parser.add_argument_group("miscellaneous") | ||||
| group_u.add_argument( | group_u.add_argument( | ||||
| 'directories_to_update', nargs="*", metavar="path", | |||||
| 'directories_to_update', nargs="*", metavar="path", type=_decode, | |||||
| help="""update all repositories in this directory (or the directory | help="""update all repositories in this directory (or the directory | ||||
| itself, if it is a repo)""") | itself, if it is a repo)""") | ||||
| group_u.add_argument( | group_u.add_argument( | ||||
| @@ -41,16 +50,31 @@ def main(): | |||||
| group_u.add_argument( | group_u.add_argument( | ||||
| '-f', '--fetch-only', action="store_true", | '-f', '--fetch-only', action="store_true", | ||||
| help="only fetch remotes, don't try to fast-forward any branches") | 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""") | |||||
| group_b.add_argument( | group_b.add_argument( | ||||
| '-a', '--add', dest="bookmarks_to_add", nargs="+", metavar="path", | '-a', '--add', dest="bookmarks_to_add", nargs="+", metavar="path", | ||||
| help="add directory(s) as bookmarks") | |||||
| type=_decode, help="add directory(s) as bookmarks") | |||||
| group_b.add_argument( | group_b.add_argument( | ||||
| '-d', '--delete', dest="bookmarks_to_del", nargs="+", metavar="path", | '-d', '--delete', dest="bookmarks_to_del", nargs="+", metavar="path", | ||||
| type=_decode, | |||||
| help="delete bookmark(s) (leaves actual directories alone)") | help="delete bookmark(s) (leaves actual directories alone)") | ||||
| group_b.add_argument( | group_b.add_argument( | ||||
| '-l', '--list', dest="list_bookmarks", action="store_true", | '-l', '--list', dest="list_bookmarks", action="store_true", | ||||
| help="list current bookmarks") | 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") | |||||
| group_b.add_argument( | |||||
| '-b', '--bookmark-file', nargs="?", metavar="path", type=_decode, | |||||
| help="use a specific bookmark config file (default: {0})".format( | |||||
| get_default_config_path())) | |||||
| group_a.add_argument( | |||||
| '-e', '--exec', '--batch', dest="command", metavar="command", | |||||
| help="run a shell command on all repos") | |||||
| group_m.add_argument( | 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") | ||||
| @@ -66,7 +90,7 @@ def main(): | |||||
| color_init(autoreset=True) | color_init(autoreset=True) | ||||
| args = parser.parse_args() | args = parser.parse_args() | ||||
| update_args = args.current_only, args.fetch_only | |||||
| update_args = args.current_only, args.fetch_only, args.prune | |||||
| print(Style.BRIGHT + "gitup" + Style.RESET_ALL + ": the git-repo-updater") | print(Style.BRIGHT + "gitup" + Style.RESET_ALL + ": the git-repo-updater") | ||||
| print() | print() | ||||
| @@ -78,21 +102,34 @@ def main(): | |||||
| "upstream branch and can be safely fast-forwarded. Use " | "upstream branch and can be safely fast-forwarded. Use " | ||||
| "--fetch-only to\navoid updating any branches.\n") | "--fetch-only to\navoid updating any branches.\n") | ||||
| if args.bookmark_file: | |||||
| args.bookmark_file = os.path.expanduser(args.bookmark_file) | |||||
| acted = False | acted = False | ||||
| if args.bookmarks_to_add: | if args.bookmarks_to_add: | ||||
| add_bookmarks(args.bookmarks_to_add) | |||||
| add_bookmarks(args.bookmarks_to_add, args.bookmark_file) | |||||
| acted = True | acted = True | ||||
| if args.bookmarks_to_del: | if args.bookmarks_to_del: | ||||
| delete_bookmarks(args.bookmarks_to_del) | |||||
| delete_bookmarks(args.bookmarks_to_del, args.bookmark_file) | |||||
| acted = True | acted = True | ||||
| if args.list_bookmarks: | if args.list_bookmarks: | ||||
| list_bookmarks() | |||||
| list_bookmarks(args.bookmark_file) | |||||
| acted = True | acted = True | ||||
| if args.directories_to_update: | |||||
| update_directories(args.directories_to_update, update_args) | |||||
| if args.clean_bookmarks: | |||||
| clean_bookmarks(args.bookmark_file) | |||||
| acted = True | acted = True | ||||
| if args.update or not acted: | |||||
| update_bookmarks(get_bookmarks(), update_args) | |||||
| if args.command: | |||||
| if args.directories_to_update: | |||||
| run_command(args.directories_to_update, args.command) | |||||
| if args.update or not args.directories_to_update: | |||||
| run_command(get_bookmarks(args.bookmark_file), args.command) | |||||
| else: | |||||
| if args.directories_to_update: | |||||
| update_directories(args.directories_to_update, update_args) | |||||
| acted = True | |||||
| if args.update or not acted: | |||||
| update_bookmarks(get_bookmarks(args.bookmark_file), update_args) | |||||
| def run(): | def run(): | ||||
| """Thin wrapper for main() that catches KeyboardInterrupts.""" | """Thin wrapper for main() that catches KeyboardInterrupts.""" | ||||
| @@ -1,17 +1,19 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| # | # | ||||
| # Copyright (C) 2011-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| # Copyright (C) 2011-2016 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| # Released under the terms of the MIT License. See LICENSE for details. | # Released under the terms of the MIT License. See LICENSE for details. | ||||
| from __future__ import print_function | from __future__ import print_function | ||||
| from glob import glob | |||||
| import os | import os | ||||
| import shlex | |||||
| from colorama import Fore, Style | from colorama import Fore, Style | ||||
| from git import RemoteReference as RemoteRef, Repo, exc | from git import RemoteReference as RemoteRef, Repo, exc | ||||
| from git.util import RemoteProgress | from git.util import RemoteProgress | ||||
| __all__ = ["update_bookmarks", "update_directories"] | |||||
| __all__ = ["update_bookmarks", "update_directories", "run_command"] | |||||
| BOLD = Style.BRIGHT | BOLD = Style.BRIGHT | ||||
| BLUE = Fore.BLUE + BOLD | BLUE = Fore.BLUE + BOLD | ||||
| @@ -53,12 +55,13 @@ class _ProgressMonitor(RemoteProgress): | |||||
| print(str(cur_count), end=end) | print(str(cur_count), end=end) | ||||
| def _fetch_remotes(remotes): | |||||
| def _fetch_remotes(remotes, prune): | |||||
| """Fetch a list of remotes, displaying progress info along the way.""" | """Fetch a list of remotes, displaying progress info along the way.""" | ||||
| def _get_name(ref): | def _get_name(ref): | ||||
| """Return the local name of a remote or tag reference.""" | """Return the local name of a remote or tag reference.""" | ||||
| return ref.remote_head if isinstance(ref, RemoteRef) else ref.name | return ref.remote_head if isinstance(ref, RemoteRef) else ref.name | ||||
| # TODO: missing branch deleted (via --prune): | |||||
| info = [("NEW_HEAD", "new branch", "new branches"), | info = [("NEW_HEAD", "new branch", "new branches"), | ||||
| ("NEW_TAG", "new tag", "new tags"), | ("NEW_TAG", "new tag", "new tags"), | ||||
| ("FAST_FORWARD", "branch update", "branch updates")] | ("FAST_FORWARD", "branch update", "branch updates")] | ||||
| @@ -72,7 +75,7 @@ def _fetch_remotes(remotes): | |||||
| continue | continue | ||||
| try: | try: | ||||
| results = remote.fetch(progress=_ProgressMonitor()) | |||||
| results = remote.fetch(progress=_ProgressMonitor(), prune=prune) | |||||
| except exc.GitCommandError as err: | except exc.GitCommandError as err: | ||||
| msg = err.command[0].replace("Error when fetching: ", "") | msg = err.command[0].replace("Error when fetching: ", "") | ||||
| if not msg.endswith("."): | if not msg.endswith("."): | ||||
| @@ -101,12 +104,16 @@ def _update_branch(repo, branch, is_active=False): | |||||
| if not upstream: | if not upstream: | ||||
| print(YELLOW + "skipped:", "no upstream is tracked.") | print(YELLOW + "skipped:", "no upstream is tracked.") | ||||
| return | return | ||||
| try: | try: | ||||
| branch.commit, upstream.commit | |||||
| branch.commit | |||||
| except ValueError: | except ValueError: | ||||
| print(YELLOW + "skipped:", "branch has no revisions.") | print(YELLOW + "skipped:", "branch has no revisions.") | ||||
| return | return | ||||
| try: | |||||
| upstream.commit | |||||
| except ValueError: | |||||
| print(YELLOW + "skipped:", "upstream does not exist.") | |||||
| return | |||||
| base = repo.git.merge_base(branch.commit, upstream.commit) | base = repo.git.merge_base(branch.commit, upstream.commit) | ||||
| if repo.commit(base) == upstream.commit: | if repo.commit(base) == upstream.commit: | ||||
| @@ -133,13 +140,15 @@ def _update_branch(repo, branch, is_active=False): | |||||
| repo.git.branch(branch.name, upstream.name, force=True) | repo.git.branch(branch.name, upstream.name, force=True) | ||||
| print(GREEN + "done", end=".\n") | print(GREEN + "done", end=".\n") | ||||
| def _update_repository(repo, current_only=False, fetch_only=False): | |||||
| def _update_repository(repo, current_only, fetch_only, prune): | |||||
| """Update a single git repository by fetching remotes and rebasing/merging. | """Update a single git repository by fetching remotes and rebasing/merging. | ||||
| The specific actions depend on the arguments given. We will fetch all | The specific actions depend on the arguments given. We will fetch all | ||||
| remotes if *current_only* is ``False``, or only the remote tracked by the | remotes if *current_only* is ``False``, or only the remote tracked by the | ||||
| current branch if ``True``. If *fetch_only* is ``False``, we will also | current branch if ``True``. If *fetch_only* is ``False``, we will also | ||||
| update all fast-forwardable branches that are tracking valid upstreams. | update all fast-forwardable branches that are tracking valid upstreams. | ||||
| If *prune* is ``True``, remote-tracking branches that no longer exist on | |||||
| their remote after fetching will be deleted. | |||||
| """ | """ | ||||
| print(INDENT1, BOLD + os.path.split(repo.working_dir)[1] + ":") | print(INDENT1, BOLD + os.path.split(repo.working_dir)[1] + ":") | ||||
| @@ -163,58 +172,88 @@ def _update_repository(repo, current_only=False, fetch_only=False): | |||||
| if not remotes: | if not remotes: | ||||
| print(INDENT2, ERROR, "no remotes configured to fetch.") | print(INDENT2, ERROR, "no remotes configured to fetch.") | ||||
| return | return | ||||
| _fetch_remotes(remotes) | |||||
| _fetch_remotes(remotes, prune) | |||||
| if not fetch_only: | if not fetch_only: | ||||
| for branch in sorted(repo.heads, key=lambda b: b.name): | for branch in sorted(repo.heads, key=lambda b: b.name): | ||||
| _update_branch(repo, branch, branch == active) | _update_branch(repo, branch, branch == active) | ||||
| def _update_subdirectories(path, update_args): | |||||
| """Update all subdirectories that are git repos in a given directory.""" | |||||
| repos = [] | |||||
| for item in os.listdir(path): | |||||
| def _run_command(repo, command): | |||||
| """Run an arbitrary shell command on the given repository.""" | |||||
| print(INDENT1, BOLD + os.path.split(repo.working_dir)[1] + ":") | |||||
| cmd = shlex.split(command) | |||||
| try: | |||||
| out = repo.git.execute( | |||||
| cmd, with_extended_output=True, with_exceptions=False) | |||||
| except exc.GitCommandNotFound as err: | |||||
| print(INDENT2, ERROR, err) | |||||
| return | |||||
| for line in out[1].splitlines() + out[2].splitlines(): | |||||
| print(INDENT2, line) | |||||
| def _dispatch_multi(base, paths, callback, *args): | |||||
| """Apply the callback to all git repos in the list of paths.""" | |||||
| valid = [] | |||||
| for path in paths: | |||||
| try: | try: | ||||
| repo = Repo(os.path.join(path, item)) | |||||
| Repo(path) | |||||
| except (exc.InvalidGitRepositoryError, exc.NoSuchPathError): | except (exc.InvalidGitRepositoryError, exc.NoSuchPathError): | ||||
| continue | continue | ||||
| repos.append(repo) | |||||
| valid.append(path) | |||||
| base = os.path.abspath(base) | |||||
| suffix = "" if len(valid) == 1 else "s" | |||||
| print(BOLD + base, "({0} repo{1}):".format(len(valid), suffix)) | |||||
| suffix = "" if len(repos) == 1 else "s" | |||||
| print(BOLD + path, "({0} repo{1}):".format(len(repos), suffix)) | |||||
| for repo in sorted(repos, key=lambda r: os.path.split(r.working_dir)[1]): | |||||
| _update_repository(repo, *update_args) | |||||
| for path in sorted(valid, key=os.path.basename): | |||||
| callback(Repo(path), *args) | |||||
| def _update_directory(path, update_args): | |||||
| """Update a particular directory. | |||||
| def _dispatch(path, callback, *args): | |||||
| """Apply a callback function on each valid repo in the given path. | |||||
| Determine whether the directory is a git repo on its own, a directory of | Determine whether the directory is a git repo on its own, a directory of | ||||
| git repositories, or something invalid. If the first, update the single | |||||
| repository; if the second, update all repositories contained within; if the | |||||
| third, print an error. | |||||
| git repositories, a shell glob pattern, or something invalid. If the first, | |||||
| apply the callback on it; if the second or third, apply the callback on all | |||||
| repositories contained within; if the last, print an error. | |||||
| The given args are passed directly to the callback function after the repo. | |||||
| """ | """ | ||||
| path = os.path.expanduser(path) | |||||
| try: | try: | ||||
| repo = Repo(path) | repo = Repo(path) | ||||
| except exc.NoSuchPathError: | except exc.NoSuchPathError: | ||||
| print(ERROR, BOLD + path, "doesn't exist!") | |||||
| paths = glob(path) | |||||
| if paths: | |||||
| _dispatch_multi(path, paths, callback, *args) | |||||
| else: | |||||
| print(ERROR, BOLD + path, "doesn't exist!") | |||||
| except exc.InvalidGitRepositoryError: | except exc.InvalidGitRepositoryError: | ||||
| if os.path.isdir(path): | if os.path.isdir(path): | ||||
| _update_subdirectories(path, update_args) | |||||
| paths = [os.path.join(path, item) for item in os.listdir(path)] | |||||
| _dispatch_multi(path, paths, callback, *args) | |||||
| else: | else: | ||||
| print(ERROR, BOLD + path, "isn't a repository!") | print(ERROR, BOLD + path, "isn't a repository!") | ||||
| else: | else: | ||||
| print(BOLD + repo.working_dir, "(1 repo):") | print(BOLD + repo.working_dir, "(1 repo):") | ||||
| _update_repository(repo, *update_args) | |||||
| callback(repo, *args) | |||||
| def update_bookmarks(bookmarks, update_args): | def update_bookmarks(bookmarks, update_args): | ||||
| """Loop through and update all bookmarks.""" | """Loop through and update all bookmarks.""" | ||||
| if bookmarks: | |||||
| for path in bookmarks: | |||||
| _update_directory(path, update_args) | |||||
| else: | |||||
| if not bookmarks: | |||||
| print("You don't have any bookmarks configured! Get help with 'gitup -h'.") | print("You don't have any bookmarks configured! Get help with 'gitup -h'.") | ||||
| return | |||||
| for path in bookmarks: | |||||
| _dispatch(path, _update_repository, *update_args) | |||||
| def update_directories(paths, update_args): | def update_directories(paths, update_args): | ||||
| """Update a list of directories supplied by command arguments.""" | """Update a list of directories supplied by command arguments.""" | ||||
| for path in paths: | for path in paths: | ||||
| full_path = os.path.abspath(path) | |||||
| _update_directory(full_path, update_args) | |||||
| _dispatch(path, _update_repository, *update_args) | |||||
| def run_command(paths, command): | |||||
| """Run an arbitrary shell command on all repos.""" | |||||
| for path in paths: | |||||
| _dispatch(path, _run_command, command) | |||||
| @@ -1,6 +1,6 @@ | |||||
| # -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
| # | # | ||||
| # Copyright (C) 2011-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| # Copyright (C) 2011-2016 Ben Kurtovic <ben.kurtovic@gmail.com> | |||||
| # Released under the terms of the MIT License. See LICENSE for details. | # Released under the terms of the MIT License. See LICENSE for details. | ||||
| import sys | import sys | ||||
| @@ -19,7 +19,7 @@ setup( | |||||
| name = "gitup", | name = "gitup", | ||||
| packages = find_packages(), | packages = find_packages(), | ||||
| entry_points = {"console_scripts": ["gitup = gitup.script:run"]}, | entry_points = {"console_scripts": ["gitup = gitup.script:run"]}, | ||||
| install_requires = ["GitPython >= 1.0.1", "colorama >= 0.3.3"], | |||||
| install_requires = ["GitPython >= 2.1.1", "colorama >= 0.3.7"], | |||||
| version = __version__, | version = __version__, | ||||
| author = "Ben Kurtovic", | author = "Ben Kurtovic", | ||||
| author_email = "ben.kurtovic@gmail.com", | author_email = "ben.kurtovic@gmail.com", | ||||
| @@ -43,6 +43,7 @@ setup( | |||||
| "Programming Language :: Python :: 3.3", | "Programming Language :: Python :: 3.3", | ||||
| "Programming Language :: Python :: 3.4", | "Programming Language :: Python :: 3.4", | ||||
| "Programming Language :: Python :: 3.5", | "Programming Language :: Python :: 3.5", | ||||
| "Programming Language :: Python :: 3.6", | |||||
| "Topic :: Software Development :: Version Control" | "Topic :: Software Development :: Version Control" | ||||
| ] | ] | ||||
| ) | ) | ||||