| @@ -1 +1,7 @@ | |||
| build/ | |||
| *.pyc | |||
| *.egg | |||
| *.egg-info | |||
| .DS_Store | |||
| __pycache__ | |||
| build | |||
| dist | |||
| @@ -1,4 +1,4 @@ | |||
| Copyright (c) 2011 by Ben Kurtovic <ben.kurtovic@verizon.net> | |||
| Copyright (C) 2011-2014 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| Permission is hereby granted, free of charge, to any person obtaining a copy | |||
| of this software and associated documentation files (the "Software"), to deal | |||
| @@ -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.<name>.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`). | |||
| @@ -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.") | |||
| @@ -0,0 +1,14 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2011-2014 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # 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" | |||
| @@ -0,0 +1,104 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2011-2014 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # 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.") | |||
| @@ -0,0 +1,92 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2011-2014 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # 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.<name>.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.") | |||
| @@ -0,0 +1,282 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2011-2014 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # 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.<name>.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) | |||
| @@ -1,35 +1,35 @@ | |||
| from distutils.core import setup | |||
| import os | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2011-2014 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # 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: <http://python.org/>.") | |||
| 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 | |||
| ] | |||
| ) | |||