diff --git a/.gitignore b/.gitignore index 567609b..cc17441 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ -build/ +*.pyc +*.egg +*.egg-info +.DS_Store +__pycache__ +build +dist diff --git a/LICENSE b/LICENSE index 6572aa7..b755367 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2011 by Ben Kurtovic +Copyright (C) 2011-2014 Ben Kurtovic Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 0dd6ea1..a6f6cdf 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ __gitup__ (the _git-repo-updater_) -gitup is a tool designed to pull to a large number of git repositories at once. -It is smart enough to ignore repos with dirty working directories, and provides -a (hopefully) great way to get everything up-to-date for those short periods of -internet access between long periods of none. +gitup is a tool designed to update a large number of git repositories at once. +It is smart enough to handle multiple remotes, branches, dirty working +directories, and more, hopefully providing a great way to get everything +up-to-date for short periods of internet access between long periods of none. -gitup works on both OS X and Linux. You should have the latest version of git -and at least Python 2.7 installed. +gitup should work on OS X, Linux, and Windows. You should have the latest +version of git and at least Python 2.7 installed. # Installation @@ -25,6 +25,12 @@ Then, to install for everyone: Finally, simply delete the `git-repo-updater` directory, and you're done! +__Note:__ If you are using Windows, you may wish to add a macro so you can +invoke gitup in any directory. Note that `C:\python27\` refers to the +directory where Python is installed: + + DOSKEY gitup=c:\python27\python.exe c:\python27\Scripts\gitup $* + # Usage There are two ways to update repos: you can pass them as command arguments, @@ -34,28 +40,27 @@ For example: gitup ~/repos/foo ~/repos/bar ~/repos/baz -...will automatically pull to the `foo`, `bar`, and `baz` git repositories if -their working directories are clean (to avoid merge conflicts). Additionally, -you can just type: +will automatically pull to the `foo`, `bar`, and `baz` git repositories. +Additionally, you can just type: gitup ~/repos -...to automatically update all git repositories in that directory. +to automatically update all git repositories in that directory. To add a bookmark (or bookmarks), either of these will work: gitup --add ~/repos/foo ~/repos/bar ~/repos/baz gitup --add ~/repos -Then, to update (pull to) all of your bookmarks, just run gitup without args: +Then, to update all of your bookmarks, just run gitup without args: gitup -Deleting a bookmark is as easy as adding one: +Delete a bookmark: gitup --delete ~/repos -Want to view your current bookmarks? Simple: +View your current bookmarks: gitup --list @@ -66,13 +71,21 @@ You can mix and match bookmarks and command arguments: gitup # update 'foo' and 'bar' only gitup ~/repos/baz --update # update all three! -Want to update all git repositories in your current directory? +Update all git repositories in your current directory: gitup . +By default, gitup will fetch all remotes in a repository. Pass `--current-only` +(or `-c`) to make it only fetch the remote tracked by the current branch. + +gitup will _merge_ upstream branches by default unless `pull.rebase` or +`branch..rebase` is specified in git's config. Pass `--rebase` or `-r` to +make it always _rebase_ (like doing `git pull --rebase=preserve`). Pass +`--merge` or `-m` to make it always merge. + For a list of all command arguments and abbreviations: gitup --help -Finally, all paths can be either absolute (e.g. /path/to/repo) or relative -(e.g. ../my/repo). +Finally, all paths can be either absolute (e.g. `/path/to/repo`) or relative +(e.g. `../my/repo`). diff --git a/gitup.py b/gitup.py deleted file mode 100644 index 4820480..0000000 --- a/gitup.py +++ /dev/null @@ -1,303 +0,0 @@ -#! /usr/bin/python -# -*- coding: utf-8 -*- - -""" -gitup: the git repository updater -""" - -import argparse -import ConfigParser as configparser -import os -import re -import shlex -import subprocess - -__author__ = "Ben Kurtovic" -__copyright__ = "Copyright (c) 2011 by Ben Kurtovic" -__license__ = "MIT License" -__version__ = "0.1" -__email__ = "ben.kurtovic@verizon.net" - -config_filename = os.path.join(os.path.expanduser("~"), ".gitup") - -ansi = { # ANSI escape codes to make terminal output colorful - "reset": "\x1b[0m", - "bold": "\x1b[1m", - "red": "\x1b[1m\x1b[31m", - "green": "\x1b[1m\x1b[32m", - "yellow": "\x1b[1m\x1b[33m", - "blue": "\x1b[1m\x1b[34m", -} - -def out(indent, msg): - """Print a message at a given indentation level.""" - width = 4 # amount to indent at each level - if indent == 0: - spacing = "\n" - else: - spacing = " " * width * indent - msg = re.sub("\s+", " ", msg) # collapse multiple spaces into one - print spacing + msg - -def exec_shell(command): - """Execute a shell command and get the output.""" - command = shlex.split(command) - result = subprocess.check_output(command, stderr=subprocess.STDOUT) - if result: - result = result[:-1] # strip newline if command returned anything - return result - -def directory_is_git_repo(directory_path): - """Check if a directory is a git repository.""" - if os.path.isdir(directory_path): - git_subfolder = os.path.join(directory_path, ".git") - if os.path.isdir(git_subfolder): # check for path/to/repository/.git - return True - return False - -def update_repository(repo_path, repo_name): - """Update a single git repository by pulling from the remote.""" - out(1, "{}{}{}:".format(ansi['bold'], repo_name, ansi['reset'])) - - os.chdir(repo_path) # cd into our folder so git commands target the correct - # repo - - try: - dry_fetch = exec_shell("git fetch --dry-run") # check if there is - # anything to pull, but - # don't do it yet - except subprocess.CalledProcessError: - out(2, """{}Error:{} cannot fetch; do you have a remote repository - configured correctly?""".format(ansi['red'], ansi['reset'])) - return - - try: - last = exec_shell("git log -n 1 --pretty=\"%ar\"") # last commit time - except subprocess.CalledProcessError: - last = "never" # couldn't get a log, so no commits - - if not dry_fetch: # no new changes to pull - out(2, "{}No new changes.{} Last commit was {}.".format(ansi['blue'], - ansi['reset'], last)) - - else: # stuff has happened! - out(2, "There are new changes upstream...") - status = exec_shell("git status") - - if status.endswith("nothing to commit (working directory clean)"): - out(2, "{}Pulling new changes...{}".format(ansi['green'], - ansi['reset'])) - result = exec_shell("git pull") - out(2, "The following changes have been made since {}:".format( - last)) - print result - - else: - out(2, """{}Warning:{} You have uncommitted changes in this - repository!""".format(ansi['red'], ansi['reset'])) - out(2, "Ignoring.") - -def update_directory(dir_path, dir_name, is_bookmark=False): - """First, make sure the specified object is actually a directory, then - determine whether the directory is a git repo on its own or a directory - of git repositories. If the former, update the single repository; if the - latter, update all repositories contained within.""" - if is_bookmark: - dir_source = "Bookmark" # where did we get this directory from? - else: - dir_source = "Directory" - - try: - os.listdir(dir_path) # test if we can access this directory - except OSError: - out(0, "{}Error:{} cannot enter {} '{}{}{}'; does it exist?".format( - ansi['red'], ansi['reset'], dir_source.lower(), ansi['bold'], dir_path, - ansi['reset'])) - return - - if not os.path.isdir(dir_path): - if os.path.exists(dir_path): - error_message = "is not a directory" - else: - error_message = "does not exist" - - out(0, "{}Error{}: {} '{}{}{}' {}!".format(ansi['red'], ansi['reset'], - dir_source, ansi['bold'], dir_path, ansi['reset'], - error_message)) - return - - if directory_is_git_repo(dir_path): - out(0, "{} '{}{}{}' is a git repository:".format(dir_source, - ansi['bold'], dir_path, ansi['reset'])) - update_repository(dir_path, dir_name) - - else: - repositories = [] - - dir_contents = os.listdir(dir_path) # get potential repos in directory - for item in dir_contents: - repo_path = os.path.join(dir_path, item) - repo_name = os.path.join(dir_name, item) - if directory_is_git_repo(repo_path): # filter out non-repositories - repositories.append((repo_path, repo_name)) - - repo_count = len(repositories) - if repo_count == 1: - pluralize = "repository" - else: - pluralize = "repositories" - - out(0, "{} '{}{}{}' contains {} git {}:".format(dir_source, - ansi['bold'], dir_path, ansi['reset'], repo_count, pluralize)) - - for repo_path, repo_name in repositories: - update_repository(repo_path, repo_name) - -def update_directories(paths): - """Update a list of directories supplied by command arguments.""" - for path in paths: - path = os.path.abspath(path) # convert relative to absolute path - path_name = os.path.split(path)[1] # directory name; "x" in /path/to/x/ - update_directory(path, path_name, is_bookmark=False) - -def update_bookmarks(): - """Loop through and update all bookmarks.""" - try: - bookmarks = load_config_file().items("bookmarks") - except configparser.NoSectionError: - bookmarks = [] - - if bookmarks: - for bookmark_path, bookmark_name in bookmarks: - update_directory(bookmark_path, bookmark_name, is_bookmark=True) - else: - out(0, """You don't have any bookmarks configured! Get help with - 'gitup -h'.""") - -def load_config_file(): - """Read the file storing our config options from config_filename and return - the resulting SafeConfigParser() object.""" - config = configparser.SafeConfigParser() - config.optionxform = str # don't lowercase option names, because we are - # storing paths there - config.read(config_filename) - return config - -def save_config_file(config): - """Save our config changes to the config file specified by - config_filename.""" - with open(config_filename, "wb") as config_file: - config.write(config_file) - -def add_bookmarks(paths): - """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") - - out(0, "{}Added bookmarks:{}".format(ansi['yellow'], ansi['reset'])) - for path in paths: - path = os.path.abspath(path) # convert relative to absolute path - if config.has_option("bookmarks", path): - out(1, "'{}' is already bookmarked.".format(path)) - else: - path_name = os.path.split(path)[1] - config.set("bookmarks", path, path_name) - out(1, "{}{}{}".format(ansi['bold'], path, ansi['reset'])) - - save_config_file(config) - -def delete_bookmarks(paths): - """Remove a list of paths from the bookmark config file.""" - config = load_config_file() - - if config.has_section("bookmarks"): - out(0, "{}Deleted bookmarks:{}".format(ansi['yellow'], ansi['reset'])) - for path in paths: - path = os.path.abspath(path) # convert relative to absolute path - config_was_changed = config.remove_option("bookmarks", path) - if config_was_changed: - out(1, "{}{}{}".format(ansi['bold'], path, ansi['reset'])) - else: - out(1, "'{}' is not bookmarked.".format(path)) - save_config_file(config) - - else: - out(0, "There are no bookmarks to delete!") - -def list_bookmarks(): - """Print all of our current bookmarks.""" - config = load_config_file() - try: - bookmarks = config.items("bookmarks") - except configparser.NoSectionError: - bookmarks = [] - - if bookmarks: - out(0, "{}Current bookmarks:{}".format(ansi['yellow'], ansi['reset'])) - for bookmark_path, bookmark_name in bookmarks: - out(1, bookmark_path) - else: - out(0, "You have no bookmarks to display.") - -def main(): - """Parse arguments and then call the appropriate function(s).""" - parser = argparse.ArgumentParser(description="""Easily pull to multiple git - repositories at once.""", epilog="""Both relative and absolute - paths are accepted by all arguments. Questions? Comments? Email the - author at {}.""".format(__email__), add_help=False) - - group_u = parser.add_argument_group("updating repositories") - group_b = parser.add_argument_group("bookmarking") - group_m = parser.add_argument_group("miscellaneous") - - group_u.add_argument('directories_to_update', nargs="*", metavar="path", - help="""update all repositories in this directory (or the directory - itself, if it is a repo)""") - - group_u.add_argument('-u', '--update', action="store_true", help="""update - all bookmarks (default behavior when called without arguments)""") - - group_b.add_argument('-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)") - - group_b.add_argument('-l', '--list', dest="list_bookmarks", - action="store_true", help="list current bookmarks") - - group_m.add_argument('-h', '--help', action="help", - help="show this help message and exit") - - group_m.add_argument('-v', '--version', action="version", - version="gitup version "+__version__) - - args = parser.parse_args() - - print "{}gitup{}: the git-repo-updater".format(ansi['bold'], ansi['reset']) - - if args.bookmarks_to_add: - add_bookmarks(args.bookmarks_to_add) - - if args.bookmarks_to_del: - delete_bookmarks(args.bookmarks_to_del) - - if args.list_bookmarks: - list_bookmarks() - - if args.directories_to_update: - update_directories(args.directories_to_update) - - if args.update: - update_bookmarks() - - if not any(vars(args).values()): # if they did not tell us to do anything, - update_bookmarks() # automatically update bookmarks - -if __name__ == "__main__": - try: - main() - except KeyboardInterrupt: - out(0, "Stopped by user.") diff --git a/gitup/__init__.py b/gitup/__init__.py new file mode 100644 index 0000000..a361042 --- /dev/null +++ b/gitup/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2011-2014 Ben Kurtovic +# See the LICENSE file for details. + +""" +gitup: the git repository updater +""" + +__author__ = "Ben Kurtovic" +__copyright__ = "Copyright (C) 2011-2014 Ben Kurtovic" +__license__ = "MIT License" +__version__ = "0.2" +__email__ = "ben.kurtovic@gmail.com" diff --git a/gitup/config.py b/gitup/config.py new file mode 100644 index 0000000..5571cfe --- /dev/null +++ b/gitup/config.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2011-2014 Ben Kurtovic +# See the LICENSE file for details. + +from __future__ import print_function + +import ConfigParser as configparser +import os + +from colorama import Fore, Style + +__all__ = ["get_bookmarks", "add_bookmarks", "delete_bookmarks", + "list_bookmarks"] + +CONFIG_FILENAME = os.path.join(os.path.expanduser("~"), ".gitup") + +YELLOW = Fore.YELLOW + Style.BRIGHT +RED = Fore.RED + Style.BRIGHT + +INDENT1 = " " * 3 + +def _load_config_file(): + """Read the config file and return a SafeConfigParser() object.""" + config = configparser.SafeConfigParser() + # Don't lowercase option names, because we are storing paths there: + config.optionxform = str + config.read(CONFIG_FILENAME) + return config + +def _save_config_file(config): + """Save config changes to the config file specified by CONFIG_FILENAME.""" + with open(CONFIG_FILENAME, "wb") as config_file: + config.write(config_file) + +def get_bookmarks(): + """Get a list of all bookmarks, or an empty list if there are none.""" + config = _load_config_file() + try: + return config.items("bookmarks") + except configparser.NoSectionError: + return [] + +def add_bookmarks(paths): + """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") + + added, exists = [], [] + for path in paths: + path = os.path.abspath(path) + if config.has_option("bookmarks", path): + exists.append(path) + else: + path_name = os.path.split(path)[1] + config.set("bookmarks", path, path_name) + added.append(path) + _save_config_file(config) + + if added: + print(YELLOW + "Added bookmarks:") + for path in added: + print(INDENT1, path) + if exists: + print(RED + "Already bookmarked:") + for path in exists: + print(INDENT1, path) + +def delete_bookmarks(paths): + """Remove a list of paths from the bookmark config file.""" + config = _load_config_file() + + deleted, notmarked = [], [] + if config.has_section("bookmarks"): + for path in paths: + path = os.path.abspath(path) + config_was_changed = config.remove_option("bookmarks", path) + if config_was_changed: + deleted.append(path) + else: + notmarked.append(path) + _save_config_file(config) + else: + notmarked = [os.path.abspath(path) for path in paths] + + if deleted: + print(YELLOW + "Deleted bookmarks:") + for path in deleted: + print(INDENT1, path) + if notmarked: + print(RED + "Not bookmarked:") + for path in notmarked: + print(INDENT1, path) + +def list_bookmarks(): + """Print all of our current bookmarks.""" + bookmarks = get_bookmarks() + if bookmarks: + print(YELLOW + "Current bookmarks:") + for bookmark_path, _ in bookmarks: + print(INDENT1, bookmark_path) + else: + print("You have no bookmarks to display.") diff --git a/gitup/script.py b/gitup/script.py new file mode 100644 index 0000000..a29982f --- /dev/null +++ b/gitup/script.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2011-2014 Ben Kurtovic +# See the LICENSE file for details. + +from __future__ import print_function + +import argparse + +from colorama import init as color_init, Style + +from . import __version__, __email__ +from .config import (get_bookmarks, add_bookmarks, delete_bookmarks, + list_bookmarks) +from .update import update_bookmarks, update_directories + +def main(): + """Parse arguments and then call the appropriate function(s).""" + parser = argparse.ArgumentParser( + description="""Easily update multiple git repositories at once.""", + epilog=""" + Both relative and absolute paths are accepted by all arguments. + Questions? Comments? Email the author at {0}.""".format(__email__), + add_help=False) + + group_u = parser.add_argument_group("updating repositories") + group_b = parser.add_argument_group("bookmarking") + group_m = parser.add_argument_group("miscellaneous") + rebase_or_merge = group_u.add_mutually_exclusive_group() + + group_u.add_argument( + 'directories_to_update', nargs="*", metavar="path", + help="""update all repositories in this directory (or the directory + itself, if it is a repo)""") + group_u.add_argument( + '-u', '--update', action="store_true", help="""update all bookmarks + (default behavior when called without arguments)""") + group_u.add_argument( + '-c', '--current-only', action="store_true", help="""only fetch the + remote tracked by the current branch instead of all remotes""") + rebase_or_merge.add_argument( + '-r', '--rebase', action="store_true", help="""always rebase upstream + branches instead of following `pull.rebase` and `branch..rebase` + in git config (like `git pull --rebase=preserve`)""") + rebase_or_merge.add_argument( + '-m', '--merge', action="store_true", help="""like --rebase, but merge + instead""") + + group_b.add_argument( + '-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)") + group_b.add_argument( + '-l', '--list', dest="list_bookmarks", action="store_true", + help="list current bookmarks") + group_m.add_argument( + '-h', '--help', action="help", help="show this help message and exit") + group_m.add_argument( + '-v', '--version', action="version", + version="gitup version " + __version__) + + color_init(autoreset=True) + args = parser.parse_args() + update_args = args.current_only, args.rebase, args.merge + + print(Style.BRIGHT + "gitup" + Style.RESET_ALL + ": the git-repo-updater") + print() + + acted = False + if args.bookmarks_to_add: + add_bookmarks(args.bookmarks_to_add) + acted = True + if args.bookmarks_to_del: + delete_bookmarks(args.bookmarks_to_del) + acted = True + if args.list_bookmarks: + list_bookmarks() + acted = True + 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(), update_args) + +def run(): + """Thin wrapper for main() that catches KeyboardInterrupts.""" + try: + main() + except KeyboardInterrupt: + print("Stopped by user.") diff --git a/gitup/update.py b/gitup/update.py new file mode 100644 index 0000000..9ef7d42 --- /dev/null +++ b/gitup/update.py @@ -0,0 +1,282 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2011-2014 Ben Kurtovic +# See the LICENSE file for details. + +from __future__ import print_function + +import os + +from colorama import Fore, Style +from git import RemoteReference as RemoteRef, Repo, exc +from git.util import RemoteProgress + +__all__ = ["update_bookmarks", "update_directories"] + +BOLD = Style.BRIGHT +BLUE = Fore.BLUE + BOLD +GREEN = Fore.GREEN + BOLD +RED = Fore.RED + BOLD +YELLOW = Fore.YELLOW + BOLD +RESET = Style.RESET_ALL + +INDENT1 = " " * 3 +INDENT2 = " " * 7 +ERROR = RED + "Error:" + RESET + +class _ProgressMonitor(RemoteProgress): + """Displays relevant output during the fetching process.""" + + def __init__(self): + super(_ProgressMonitor, self).__init__() + self._started = False + + 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): + if op_code & self.BEGIN: + print("\b, " if self._started else " (", end="") + if not self._started: + self._started = True + if op_code & self.END: + end = ")" + else: + end = "\b" * (1 + len(cur_count) + len(max_count)) + print("{0}/{1}".format(cur_count, max_count), end=end) + + +class _Stasher(object): + """Manages the stash state of a given repository.""" + + def __init__(self, repo): + self._repo = repo + self._clean = self._stashed = False + + def clean(self): + """Ensure the working directory is clean, so we can do checkouts.""" + if not self._clean: + res = self._repo.git.stash("--all") + self._clean = True + if res != "No local changes to save": + self._stashed = True + + def restore(self): + """Restore the pre-stash state.""" + if self._stashed: + self._repo.git.stash("pop", "--index") + + +def _read_config(repo, attr): + """Read an attribute from git config.""" + try: + return repo.git.config("--get", attr) + except exc.GitCommandError: + return None + +def _fetch_remotes(remotes): + """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 + + 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: + print(INDENT2, "Fetching", BOLD + remote.name, end="") + try: + results = remote.fetch(progress=_ProgressMonitor()) + except exc.GitCommandError as err: + msg = err.command[0].replace("Error when fetching: ", "") + if not msg.endswith("."): + msg += "." + print(RED + "error:", msg) + 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.") + rlist = [] + for attr, singular, plural in info: + 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 _is_up_to_date(repo, branch, upstream): + """Return whether *branch* is up-to-date with its *upstream*.""" + base = repo.git.merge_base(branch.commit, upstream.commit) + return repo.commit(base) == upstream.commit + +def _rebase(repo, name): + """Rebase the current HEAD of *repo* onto the branch *name*.""" + print(GREEN + "rebasing...", end="") + try: + res = repo.git.rebase(name, "--preserve-merges") + except exc.GitCommandError as err: + msg = err.stderr.replace("\n", " ").strip() + if not msg.endswith("."): + msg += "." + if "unstaged changes" in msg: + print(RED + " error:", "unstaged changes.") + elif "uncommitted changes" in msg: + print(RED + " error:", "uncommitted changes.") + else: + try: + repo.git.rebase("--abort") + except exc.GitCommandError: + pass + print(RED + " error:", msg if msg else "rebase conflict.", + "Aborted.") + else: + print("\b" * 6 + " " * 6 + "\b" * 6 + GREEN + "ed", end=".\n") + +def _merge(repo, name): + """Merge the branch *name* into the current HEAD of *repo*.""" + print(GREEN + "merging...", end="") + try: + repo.git.merge(name) + except exc.GitCommandError as err: + msg = err.stderr.replace("\n", " ").strip() + if not msg.endswith("."): + msg += "." + if "local changes" in msg and "would be overwritten" in msg: + print(RED + " error:", "uncommitted changes.") + else: + try: + repo.git.merge("--abort") + except exc.GitCommandError: + pass + print(RED + " error:", msg if msg else "merge conflict.", + "Aborted.") + else: + print("\b" * 6 + " " * 6 + "\b" * 6 + GREEN + "ed", end=".\n") + +def _update_branch(repo, branch, merge, rebase, stasher=None): + """Update a single branch.""" + print(INDENT2, "Updating", BOLD + branch.name, end=": ") + upstream = branch.tracking_branch() + if not upstream: + print(YELLOW + "skipped:", "no upstream is tracked.") + return + + try: + branch.commit, upstream.commit + except ValueError: + print(YELLOW + "skipped:", "branch has no revisions.") + return + if _is_up_to_date(repo, branch, upstream): + print(BLUE + "up to date", end=".\n") + return + + if stasher: + stasher.clean() + branch.checkout() + config_attr = "branch.{0}.rebase".format(branch.name) + if not merge and (rebase or _read_config(repo, config_attr)): + _rebase(repo, upstream.name) + else: + _merge(repo, upstream.name) + +def _update_branches(repo, active, merge, rebase): + """Update a list of branches.""" + _update_branch(repo, active, merge, rebase) + branches = set(repo.heads) - {active} + if branches: + stasher = _Stasher(repo) + try: + for branch in sorted(branches, key=lambda b: b.name): + _update_branch(repo, branch, merge, rebase, stasher) + finally: + active.checkout() + stasher.restore() + +def _update_repository(repo, current_only=False, rebase=False, merge=False): + """Update a single git repository by fetching remotes and rebasing/merging. + + 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 + current branch if ``True``. By default, we will merge unless + ``pull.rebase`` or ``branch..rebase`` is set in config; *rebase* will + cause us to always rebase with ``--preserve-merges``, and *merge* will + cause us to always merge. + """ + print(INDENT1, BOLD + os.path.split(repo.working_dir)[1] + ":") + + active = repo.active_branch + if current_only: + ref = active.tracking_branch() + if not ref: + print(INDENT2, ERROR, "no remote tracked by current branch.") + return + remotes = [repo.remotes[ref.remote_name]] + else: + remotes = repo.remotes + if not remotes: + print(INDENT2, ERROR, "no remotes configured to pull from.") + return + rebase = rebase or _read_config(repo, "pull.rebase") + + _fetch_remotes(remotes) + _update_branches(repo, active, merge, rebase) + +def _update_subdirectories(path, long_name, update_args): + """Update all subdirectories that are git repos in a given directory.""" + repos = [] + for item in os.listdir(path): + try: + repo = Repo(os.path.join(path, item)) + except (exc.InvalidGitRepositoryError, exc.NoSuchPathError): + continue + repos.append(repo) + + suffix = "ies" if len(repos) != 1 else "y" + print(long_name[0].upper() + long_name[1:], + "contains {0} git repositor{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) + +def _update_directory(path, update_args, is_bookmark=False): + """Update a particular directory. + + 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. + """ + dir_type = "bookmark" if is_bookmark else "directory" + long_name = dir_type + ' "' + BOLD + path + RESET + '"' + + try: + repo = Repo(path) + except exc.NoSuchPathError: + print(ERROR, long_name, "doesn't exist!") + except exc.InvalidGitRepositoryError: + if os.path.isdir(path): + _update_subdirectories(path, long_name, update_args) + else: + print(ERROR, long_name, "isn't a repository!") + else: + long_name = (dir_type.capitalize() + ' "' + BOLD + repo.working_dir + + RESET + '"') + print(long_name, "is a git repository:") + _update_repository(repo, *update_args) + +def update_bookmarks(bookmarks, update_args): + """Loop through and update all bookmarks.""" + if bookmarks: + for path, name in bookmarks: + _update_directory(path, update_args, is_bookmark=True) + else: + print("You don't have any bookmarks configured! Get help with 'gitup -h'.") + +def update_directories(paths, update_args): + """Update a list of directories supplied by command arguments.""" + for path in paths: + full_path = os.path.abspath(path) + _update_directory(full_path, update_args, is_bookmark=False) diff --git a/setup.py b/setup.py index 0052eff..4a907ad 100644 --- a/setup.py +++ b/setup.py @@ -1,35 +1,35 @@ -from distutils.core import setup -import os +# -*- coding: utf-8 -*- +# +# Copyright (C) 2011-2014 Ben Kurtovic +# See the LICENSE file for details. + import sys +from setuptools import setup, find_packages + if sys.hexversion < 0x02070000: exit("Please upgrade to Python 2.7 or greater: .") -remove_py_extension = True # install script as "gitup" instead of "gitup.py" - -if os.path.exists("gitup"): - remove_py_extension = False -else: - os.rename("gitup.py", "gitup") - -desc = "Easily pull to multiple git repositories at once." +from gitup import __version__ -with open('README.md') as file: - long_desc = file.read() +with open('README.md') as fp: + long_desc = fp.read() -try: - setup( - name = "gitup", - version = "0.1", - scripts = ['gitup'], - author = "Ben Kurtovic", - author_email = "ben.kurtovic@verizon.net", - description = desc, - long_description = long_desc, - license = "MIT License", - keywords = "git repository pull update", - url = "http://github.com/earwig/git-repo-updater", - classifiers = ["Environment :: Console", +setup( + name = "gitup", + packages = find_packages(), + entry_points = {"console_scripts": ["gitup = gitup.script:run"]}, + install_requires = ["GitPython >= 0.3.2.RC1", "colorama >= 0.2.7"], + version = __version__, + author = "Ben Kurtovic", + author_email = "ben.kurtovic@gmail.com", + description = "Easily pull to multiple git repositories at once.", + long_description = long_desc, + license = "MIT License", + keywords = "git repository pull update", + url = "http://github.com/earwig/git-repo-updater", + classifiers = [ + "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Natural Language :: English", @@ -38,9 +38,5 @@ try: "Programming Language :: Python", "Programming Language :: Python :: 2.7", "Topic :: Software Development :: Version Control" - ] - ) - -finally: - if remove_py_extension: - os.rename("gitup", "gitup.py") # restore file location + ] +)