| @@ -0,0 +1,37 @@ | |||
| v0.3 (released June 7, 2015): | |||
| - Added support for Python 3. | |||
| - Fixed behavior on bare repositories. | |||
| - Made branch updating code safer in general: only fast-forwardable branches | |||
| tracking upstreams are updated. This deprecates `--merge` and `--rebase`. | |||
| - Added `--fetch-only` to disable branch updating entirely, if desired. | |||
| - Fixed trying to fetch remotes without configured refspecs. | |||
| - Miscellaneous fixes and tweaks. | |||
| v0.2.4 (released May 23, 2015): | |||
| - Follow the XDG Base Directory Specification for the config file. | |||
| - Added installation instructions for Homebrew. | |||
| v0.2.3 (released March 14, 2015): | |||
| - Added support for newer versions of GitPython. | |||
| v0.2.2 (released April 27, 2014): | |||
| - Fixed an error being raised when HEAD is detached. | |||
| v0.2.1 (released April 21, 2014): | |||
| - Fixed a bug when handling errors during a fetch. | |||
| v0.2 (released April 21, 2014): | |||
| - Rewrote backend to use GitPython instead of direct shell calls. Improved | |||
| stability and fixed various bugs. | |||
| - Use colorama for highlighting instead of ANSI escape codes. | |||
| - Added `--current-only`, `--merge`, and `--rebase` options. | |||
| v0.1 (released June 7, 2011): | |||
| - Initial release. | |||
| @@ -1,4 +1,4 @@ | |||
| Copyright (C) 2011-2014 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| Copyright (C) 2011-2015 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 | |||
| @@ -6,13 +6,13 @@ 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. | |||
| version of git and either Python 2.7 or Python 3 installed. | |||
| # Installation | |||
| With [Homebrew](http://brew.sh/): | |||
| brew install pr0d1r2/contrib/gitup && brew link gitup | |||
| brew install gitup | |||
| ## From source | |||
| @@ -82,14 +82,14 @@ 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. | |||
| (or `-c`) to make it fetch _only_ 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. | |||
| Also by default, gitup will try to fast-forward all branches that have | |||
| upstreams configured. It will always skip branches where this is not possible | |||
| (e.g. dirty working directory or a merge/rebase is required). Pass | |||
| `--fetch-only` (or `-f`) to only fetch remotes. | |||
| For a list of all command arguments and abbreviations: | |||
| For a full list of all command arguments and abbreviations: | |||
| gitup --help | |||
| @@ -1,14 +1,14 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2011-2014 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # See the LICENSE file for details. | |||
| # Copyright (C) 2011-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # Released under the terms of the MIT License. See LICENSE for details. | |||
| """ | |||
| gitup: the git repository updater | |||
| """ | |||
| __author__ = "Ben Kurtovic" | |||
| __copyright__ = "Copyright (C) 2011-2014 Ben Kurtovic" | |||
| __copyright__ = "Copyright (C) 2011-2015 Ben Kurtovic" | |||
| __license__ = "MIT License" | |||
| __version__ = "0.2.4" | |||
| __version__ = "0.3" | |||
| __email__ = "ben.kurtovic@gmail.com" | |||
| @@ -1,13 +1,17 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2011-2014 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # See the LICENSE file for details. | |||
| # Copyright (C) 2011-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # Released under the terms of the MIT License. See LICENSE for details. | |||
| from __future__ import print_function | |||
| import ConfigParser as configparser | |||
| import os | |||
| try: | |||
| import configparser | |||
| except ImportError: # Python 2 | |||
| import ConfigParser as configparser | |||
| from colorama import Fore, Style | |||
| __all__ = ["get_bookmarks", "add_bookmarks", "delete_bookmarks", | |||
| @@ -58,7 +62,7 @@ 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") | |||
| return [path for path, _ in config.items("bookmarks")] | |||
| except configparser.NoSectionError: | |||
| return [] | |||
| @@ -119,7 +123,7 @@ def list_bookmarks(): | |||
| bookmarks = get_bookmarks() | |||
| if bookmarks: | |||
| print(YELLOW + "Current bookmarks:") | |||
| for bookmark_path, _ in bookmarks: | |||
| for bookmark_path in bookmarks: | |||
| print(INDENT1, bookmark_path) | |||
| else: | |||
| print("You have no bookmarks to display.") | |||
| @@ -1,15 +1,15 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2011-2014 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # See the LICENSE file for details. | |||
| # Copyright (C) 2011-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # Released under the terms of the MIT License. See LICENSE for details. | |||
| from __future__ import print_function | |||
| import argparse | |||
| from colorama import init as color_init, Style | |||
| from colorama import init as color_init, Fore, Style | |||
| from . import __version__, __email__ | |||
| from . import __version__ | |||
| from .config import (get_bookmarks, add_bookmarks, delete_bookmarks, | |||
| list_bookmarks) | |||
| from .update import update_bookmarks, update_directories | |||
| @@ -17,16 +17,16 @@ 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.""", | |||
| 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__), | |||
| Direct bug reports and feature requests to: | |||
| https://github.com/earwig/git-repo-updater.""", | |||
| 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", | |||
| @@ -38,13 +38,9 @@ def main(): | |||
| 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_u.add_argument( | |||
| '-f', '--fetch-only', action="store_true", | |||
| help="only fetch remotes, don't try to fast-forward any branches") | |||
| group_b.add_argument( | |||
| '-a', '--add', dest="bookmarks_to_add", nargs="+", metavar="path", | |||
| @@ -55,19 +51,33 @@ def main(): | |||
| 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__) | |||
| version="gitup " + __version__) | |||
| # TODO: deprecated arguments, for removal in v1.0: | |||
| parser.add_argument( | |||
| '-m', '--merge', action="store_true", help=argparse.SUPPRESS) | |||
| parser.add_argument( | |||
| '-r', '--rebase', action="store_true", help=argparse.SUPPRESS) | |||
| color_init(autoreset=True) | |||
| args = parser.parse_args() | |||
| update_args = args.current_only, args.rebase, args.merge | |||
| update_args = args.current_only, args.fetch_only | |||
| print(Style.BRIGHT + "gitup" + Style.RESET_ALL + ": the git-repo-updater") | |||
| print() | |||
| # TODO: remove in v1.0 | |||
| if args.merge or args.rebase: | |||
| print(Style.BRIGHT + Fore.YELLOW + "Warning:", "--merge and --rebase " | |||
| "are deprecated. Branches are only updated if they\ntrack an " | |||
| "upstream branch and can be safely fast-forwarded. Use " | |||
| "--fetch-only to\navoid updating any branches.\n") | |||
| acted = False | |||
| if args.bookmarks_to_add: | |||
| add_bookmarks(args.bookmarks_to_add) | |||
| @@ -1,7 +1,7 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2011-2014 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # See the LICENSE file for details. | |||
| # Copyright (C) 2011-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # Released under the terms of the MIT License. See LICENSE for details. | |||
| from __future__ import print_function | |||
| @@ -53,34 +53,6 @@ class _ProgressMonitor(RemoteProgress): | |||
| print(str(cur_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): | |||
| @@ -94,6 +66,11 @@ def _fetch_remotes(remotes): | |||
| for remote in remotes: | |||
| print(INDENT2, "Fetching", BOLD + remote.name, end="") | |||
| if not remote.config_reader.has_option("fetch"): | |||
| print(":", YELLOW + "skipped:", "no configured refspec.") | |||
| continue | |||
| try: | |||
| results = remote.fetch(progress=_ProgressMonitor()) | |||
| except exc.GitCommandError as err: | |||
| @@ -117,56 +94,7 @@ def _fetch_remotes(remotes): | |||
| 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): | |||
| def _update_branch(repo, branch, is_active=False): | |||
| """Update a single branch.""" | |||
| print(INDENT2, "Updating", BOLD + branch.name, end=": ") | |||
| upstream = branch.tracking_branch() | |||
| @@ -179,43 +107,39 @@ def _update_branch(repo, branch, merge, rebase, stasher=None): | |||
| except ValueError: | |||
| print(YELLOW + "skipped:", "branch has no revisions.") | |||
| return | |||
| if _is_up_to_date(repo, branch, upstream): | |||
| base = repo.git.merge_base(branch.commit, upstream.commit) | |||
| if repo.commit(base) == upstream.commit: | |||
| 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.""" | |||
| if active: | |||
| _update_branch(repo, active, merge, rebase) | |||
| branches = set(repo.heads) - {active} | |||
| if branches: | |||
| stasher = _Stasher(repo) | |||
| if is_active: | |||
| try: | |||
| for branch in sorted(branches, key=lambda b: b.name): | |||
| _update_branch(repo, branch, merge, rebase, stasher) | |||
| finally: | |||
| if active: | |||
| active.checkout() | |||
| stasher.restore() | |||
| repo.git.merge(upstream.name, ff_only=True) | |||
| print(GREEN + "done", end=".\n") | |||
| except exc.GitCommandError as err: | |||
| msg = err.stderr | |||
| if "local changes" in msg and "would be overwritten" in msg: | |||
| print(YELLOW + "skipped:", "uncommitted changes.") | |||
| else: | |||
| print(YELLOW + "skipped:", "not possible to fast-forward.") | |||
| else: | |||
| status = repo.git.merge_base( | |||
| branch.commit, upstream.commit, is_ancestor=True, | |||
| with_extended_output=True, with_exceptions=False)[0] | |||
| if status != 0: | |||
| print(YELLOW + "skipped:", "not possible to fast-forward.") | |||
| else: | |||
| repo.git.branch(branch.name, upstream.name, force=True) | |||
| print(GREEN + "done", end=".\n") | |||
| def _update_repository(repo, current_only=False, rebase=False, merge=False): | |||
| def _update_repository(repo, current_only=False, fetch_only=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. | |||
| current branch if ``True``. If *fetch_only* is ``False``, we will also | |||
| update all fast-forwardable branches that are tracking valid upstreams. | |||
| """ | |||
| print(INDENT1, BOLD + os.path.split(repo.working_dir)[1] + ":") | |||
| @@ -224,22 +148,28 @@ def _update_repository(repo, current_only=False, rebase=False, merge=False): | |||
| except TypeError: # Happens when HEAD is detached | |||
| active = None | |||
| if current_only: | |||
| ref = active.tracking_branch() if active else None | |||
| if not active: | |||
| print(INDENT2, ERROR, | |||
| "--current-only doesn't make sense with a detached HEAD.") | |||
| return | |||
| 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.") | |||
| print(INDENT2, ERROR, "no remotes configured to fetch.") | |||
| 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): | |||
| if not fetch_only: | |||
| for branch in sorted(repo.heads, key=lambda b: b.name): | |||
| _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): | |||
| @@ -249,13 +179,12 @@ def _update_subdirectories(path, long_name, update_args): | |||
| 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)) | |||
| 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) | |||
| def _update_directory(path, update_args, is_bookmark=False): | |||
| def _update_directory(path, update_args): | |||
| """Update a particular directory. | |||
| Determine whether the directory is a git repo on its own, a directory of | |||
| @@ -263,29 +192,24 @@ def _update_directory(path, update_args, is_bookmark=False): | |||
| 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!") | |||
| print(ERROR, BOLD + path, "doesn't exist!") | |||
| except exc.InvalidGitRepositoryError: | |||
| if os.path.isdir(path): | |||
| _update_subdirectories(path, long_name, update_args) | |||
| _update_subdirectories(path, update_args) | |||
| else: | |||
| print(ERROR, long_name, "isn't a repository!") | |||
| print(ERROR, BOLD + path, "isn't a repository!") | |||
| else: | |||
| long_name = (dir_type.capitalize() + ' "' + BOLD + repo.working_dir + | |||
| RESET + '"') | |||
| print(long_name, "is a git repository:") | |||
| print(BOLD + repo.working_dir, "(1 repo):") | |||
| _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) | |||
| for path in bookmarks: | |||
| _update_directory(path, update_args) | |||
| else: | |||
| print("You don't have any bookmarks configured! Get help with 'gitup -h'.") | |||
| @@ -293,4 +217,4 @@ 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) | |||
| _update_directory(full_path, update_args) | |||
| @@ -1,7 +1,7 @@ | |||
| # -*- coding: utf-8 -*- | |||
| # | |||
| # Copyright (C) 2011-2014 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # See the LICENSE file for details. | |||
| # Copyright (C) 2011-2015 Ben Kurtovic <ben.kurtovic@gmail.com> | |||
| # Released under the terms of the MIT License. See LICENSE for details. | |||
| import sys | |||
| @@ -23,7 +23,7 @@ setup( | |||
| version = __version__, | |||
| author = "Ben Kurtovic", | |||
| author_email = "ben.kurtovic@gmail.com", | |||
| description = "Easily pull to multiple git repositories at once.", | |||
| description = "Easily pull to multiple git repositories at once", | |||
| long_description = long_desc, | |||
| license = "MIT License", | |||
| keywords = "git repository pull update", | |||
| @@ -35,8 +35,14 @@ setup( | |||
| "Natural Language :: English", | |||
| "Operating System :: MacOS :: MacOS X", | |||
| "Operating System :: POSIX :: Linux", | |||
| "Operating System :: Microsoft :: Windows", | |||
| "Programming Language :: Python", | |||
| "Programming Language :: Python :: 2.7", | |||
| "Programming Language :: Python :: 3", | |||
| "Programming Language :: Python :: 3.2", | |||
| "Programming Language :: Python :: 3.3", | |||
| "Programming Language :: Python :: 3.4", | |||
| "Programming Language :: Python :: 3.5", | |||
| "Topic :: Software Development :: Version Control" | |||
| ] | |||
| ) | |||