@@ -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" | ||||
] | ] | ||||
) | ) |