diff --git a/README.md b/README.md index 3709582..a6f6cdf 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ __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 should work on OS X, Linux, and Windows. You should have the latest version of git and at least Python 2.7 installed. @@ -40,9 +40,8 @@ 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 @@ -53,15 +52,15 @@ 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 @@ -72,10 +71,18 @@ 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 diff --git a/gitup/script.py b/gitup/script.py index 8105d8a..a29982f 100644 --- a/gitup/script.py +++ b/gitup/script.py @@ -17,7 +17,7 @@ from .update import update_bookmarks, update_directories def main(): """Parse arguments and then call the appropriate function(s).""" parser = argparse.ArgumentParser( - description="""Easily pull to multiple git repositories at once.""", + 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__), @@ -26,6 +26,7 @@ def main(): 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", @@ -34,6 +35,17 @@ def main(): 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") @@ -51,24 +63,26 @@ def main(): 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) - if args.update: - update_bookmarks(get_bookmarks()) - - # If they did not tell us to do anything, automatically update bookmarks: - if not any(vars(args).values()): - update_bookmarks(get_bookmarks()) + 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.""" diff --git a/gitup/update.py b/gitup/update.py index de2d01b..9ef7d42 100644 --- a/gitup/update.py +++ b/gitup/update.py @@ -6,145 +6,277 @@ from __future__ import print_function import os -import shlex -import subprocess 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 -RED = Fore.RED + BOLD -GREEN = Fore.GREEN + BOLD 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 -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.""" - 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 - - print(INDENT1, BOLD + repo_name + ":") - - # cd into our folder so git commands target the correct repo: - os.chdir(repo_path) # TODO: remove this when using gitpython +class _ProgressMonitor(RemoteProgress): + """Displays relevant output during the fetching process.""" - try: - # Check if there is anything to pull, but don't do it yet: - dry_fetch = _exec_shell("git fetch --dry-run") - except subprocess.CalledProcessError: - print(INDENT2, RED + "Error:" + RESET, "cannot fetch;", - "do you have a remote repository configured correctly?") - return + def __init__(self): + super(_ProgressMonitor, self).__init__() + self._started = False - try: - last_commit = _exec_shell("git log -n 1 --pretty=\"%ar\"") - except subprocess.CalledProcessError: - last_commit = "never" # Couldn't get a log, so no commits - - if not dry_fetch: # No new changes to pull - print(INDENT2, BLUE + "No new changes." + RESET, - "Last commit was {0}.".format(last_commit)) - - else: # Stuff has happened! - print(INDENT2, "There are new changes upstream...") - status = _exec_shell("git status") - - if status.endswith("nothing to commit, working directory clean"): - print(INDENT2, GREEN + "Pulling new changes...") - result = _exec_shell("git pull") - if last_commit == "never": - print(INDENT2, "The following changes have been made:") + 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: - print(INDENT2, "The following changes have been made since", - last_commit + ":") - print(result) + end = "\b" * (1 + len(cur_count) + len(max_count)) + print("{0}/{1}".format(cur_count, max_count), end=end) - else: - print(INDENT2, RED + "Warning:" + RESET, - "you have uncommitted changes in this repository!") - print(INDENT2, "Ignoring.") -def _update_directory(dir_path, dir_name, is_bookmark=False): - """Update a particular directory. +class _Stasher(object): + """Manages the stash state of a given repository.""" - 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_type = "bookmark" # Where did we get this directory from? + 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: - dir_type = "directory" - dir_long_name = dir_type + ' "' + BOLD + dir_path + RESET + '"' + 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: - os.listdir(dir_path) # Test if we can access this directory - except OSError: - print(RED + "Error:" + RESET, - "cannot enter {0}; does it exist?".format(dir_long_name)) + 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 - if not os.path.isdir(dir_path): - if os.path.exists(dir_path): - print(RED + "Error:" + RESET, dir_long_name, "is not a directory!") - else: - print(RED + "Error:" + RESET, dir_long_name, "does not exist!") + 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 _directory_is_git_repo(dir_path): - print(dir_long_name.capitalize(), "is a git repository:") - _update_repository(dir_path, dir_name) + 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: - 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-repos - repositories.append((repo_path, repo_name)) - - num_of_repos = len(repositories) - if num_of_repos == 1: - print(dir_long_name.capitalize(), "contains 1 git repository:") - else: - print(dir_long_name.capitalize(), - "contains {0} git repositories:".format(num_of_repos)) + 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. - repositories.sort() # Go alphabetically instead of randomly - for repo_path, repo_name in repositories: - _update_repository(repo_path, repo_name) + 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): +def update_bookmarks(bookmarks, update_args): """Loop through and update all bookmarks.""" if bookmarks: - for bookmark_path, bookmark_name in bookmarks: - _update_directory(bookmark_path, bookmark_name, is_bookmark=True) + 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): +def update_directories(paths, update_args): """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] # Dir name ("x" in /path/to/x/) - _update_directory(path, path_name, is_bookmark=False) + full_path = os.path.abspath(path) + _update_directory(full_path, update_args, is_bookmark=False)