pax_global_header00006660000000000000000000000064135410523330014511gustar00rootroot0000000000000052 comment=b502b2eaa46a6a10d9db228209f984bb235444a7 git-repo-updater-0.5.1/000077500000000000000000000000001354105233300147045ustar00rootroot00000000000000git-repo-updater-0.5.1/.gitignore000066400000000000000000000001271354105233300166740ustar00rootroot00000000000000*.pyc *.egg *.egg-info .DS_Store Pipfile.lock __pycache__/ .pytest_cache/ build/ dist/ git-repo-updater-0.5.1/CHANGELOG000066400000000000000000000055451354105233300161270ustar00rootroot00000000000000v0.5.1 (released September 20, 2019): - Support simple comments in the bookmarks file. (#51) - Add an integrated pytest testing suite, runnable with `--selftest`. - Refactor internals, remove deprecated options, and drop support for end-of-life Python versions. v0.5 (released August 28, 2018): - Added a `--depth` flag to control recursion depth when searching for repositories inside of subdirectories. For example: - `--depth 0` will never recurse into subdirectories; the provided paths must be repositories by themselves. - `--depth 1` will descend one level to look for repositories. This is the old behavior. - `--depth 3` will look three levels deep. This is the new default. - `--depth -1` will recurse indefinitely. This is not recommended. - Allow gitup to be run directly as a Python module (python -m gitup). - Fixed an error when updating branches if the upstream is completely unrelated from the local branch (no common ancestor). - Fixed error message when fetching from a remote fails. v0.4.1 (released December 13, 2017): - Bump dependencies to deal with newer versions of Git. v0.4 (released January 17, 2017): - Added a `--prune` flag to delete remote-tracking branches that no longer exist on their remote after fetching. - Added a `--bookmark-file` flag to support multiple bookmark config files. - Added a `--cleanup` flag to remove old bookmarks that don't exist. - Added an `--exec` flag to run a shell command on all of your repos. - Added support for shell glob patterns and tilde expansion in bookmark files. - Cleaned up the bookmark file format, fixing a related Windows bug. The script will automatically migrate to the new one. - Fixed a bug related to Python 3 compatibility. - Fixed Unicode support. v0.3 (released June 7, 2015): - 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. git-repo-updater-0.5.1/LICENSE000066400000000000000000000020761354105233300157160ustar00rootroot00000000000000Copyright (C) 2011-2019 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 in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. git-repo-updater-0.5.1/Pipfile000066400000000000000000000007141354105233300162210ustar00rootroot00000000000000[[source]] url = "https://pypi.org/simple" verify_ssl = true name = "pypi" [dev-packages] pylint = "*" pytest = "*" twine = "*" [packages] "e1839a8" = {path = ".", editable = true} GitPython = ">= 2.1.8" colorama = ">= 0.3.9" [requires] python_version = "3.7" [scripts] test = "pytest gitup -v -rxw" lint = "pylint --disable=missing-docstring --output-format=colorized gitup" cloc = "cloc --vcs=git" build = "python setup.py sdist bdist_wheel --universal" git-repo-updater-0.5.1/README.md000066400000000000000000000065071354105233300161730ustar00rootroot00000000000000__gitup__ (the _git-repo-updater_) gitup is a tool for updating multiple git repositories at once. It is smart enough to handle several remotes, dirty working directories, diverged local branches, detached HEADs, and more. It was originally created to manage a large collection of projects and deal with sporadic internet access. gitup should work on macOS, Linux, and Windows. You should have the latest version of git and either Python 2.7 or Python 3 installed. # Installation With [pip](https://github.com/pypa/pip/): pip install gitup With [Homebrew](http://brew.sh/): brew install gitup ## From source First: git clone git://github.com/earwig/git-repo-updater.git cd git-repo-updater Then, to install for everyone: sudo python setup.py install or for just yourself (make sure you have `~/.local/bin` in your PATH): python setup.py install --user 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, or save them as "bookmarks". For example: gitup ~/repos/foo ~/repos/bar ~/repos/baz 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 add bookmarks, either of these will work: gitup --add ~/repos/foo ~/repos/bar ~/repos/baz gitup --add ~/repos Then, to update all of your bookmarks, just run gitup without args: gitup Delete a bookmark: gitup --delete ~/repos View your current bookmarks: gitup --list You can mix and match bookmarks and command arguments: gitup --add ~/repos/foo ~/repos/bar gitup ~/repos/baz # update 'baz' only gitup # update 'foo' and 'bar' only gitup ~/repos/baz --update # update all three! Update all git repositories in your current directory: gitup . You can control how deep gitup will look for repositories in a given directory, if that directory is not a git repo by itself, with the `--depth` (or `-t`) option. `--depth 0` will disable recursion entirely, meaning the provided paths must be repos by themselves. `--depth 1` will descend one level (this is the old behavior from pre-0.5 gitup). `--depth -1` will recurse indefinitely, which is not recommended. The default is `--depth 3`. By default, gitup will fetch all remotes in a repository. Pass `--current-only` (or `-c`) to make it fetch only the remote tracked by the current branch. 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 skip this step and only fetch remotes. After fetching, gitup will _keep_ remote-tracking branches that no longer exist upstream. Pass `--prune` (or `-p`) to delete them, or set `fetch.prune` or `remote..prune` in your git config to do this by default. For a full list of all command arguments and abbreviations: gitup --help git-repo-updater-0.5.1/gitup/000077500000000000000000000000001354105233300160345ustar00rootroot00000000000000git-repo-updater-0.5.1/gitup/__init__.py000066400000000000000000000005711354105233300201500ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright (C) 2011-2019 Ben Kurtovic # Released under the terms of the MIT License. See LICENSE for details. """ gitup: the git repository updater """ __author__ = "Ben Kurtovic" __copyright__ = "Copyright (C) 2011-2019 Ben Kurtovic" __license__ = "MIT License" __version__ = "0.5.1" __email__ = "ben.kurtovic@gmail.com" git-repo-updater-0.5.1/gitup/__main__.py000066400000000000000000000003441354105233300201270ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright (C) 2011-2018 Ben Kurtovic # Released under the terms of the MIT License. See LICENSE for details. from gitup.cli import run if __name__ == "__main__": run() git-repo-updater-0.5.1/gitup/cli.py000066400000000000000000000130001354105233300171470ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright (C) 2011-2018 Ben Kurtovic # Released under the terms of the MIT License. See LICENSE for details. from __future__ import print_function import argparse import os import platform import sys from colorama import init as color_init, Style from gitup import __version__ from gitup.config import (get_default_config_path, get_bookmarks, add_bookmarks, delete_bookmarks, list_bookmarks, clean_bookmarks) from gitup.update import update_bookmarks, update_directories, run_command def _decode(path): """Decode the given string using the system's filesystem encoding.""" if sys.version_info.major > 2: return path return path.decode(sys.getfilesystemencoding()) def _build_parser(): """Build and return the argument parser.""" parser = argparse.ArgumentParser( description="Easily update multiple git repositories at once.", epilog=""" Both relative and absolute paths are accepted by all arguments. 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_a = parser.add_argument_group("advanced") group_m = parser.add_argument_group("miscellaneous") group_u.add_argument( 'directories_to_update', nargs="*", metavar="path", type=_decode, help="""update this repository, or all repositories it contains (if not a repo directly)""") group_u.add_argument( '-u', '--update', action="store_true", help="""update all bookmarks (default behavior when called without arguments)""") group_u.add_argument( '-t', '--depth', dest="max_depth", metavar="n", type=int, default=3, help="""max recursion depth when searching for repos in subdirectories (default: 3; use 0 for no recursion, or -1 for unlimited)""") group_u.add_argument( '-c', '--current-only', action="store_true", help="""only fetch the remote tracked by the current branch instead of all remotes""") group_u.add_argument( '-f', '--fetch-only', action="store_true", help="only fetch remotes, don't try to fast-forward any branches") group_u.add_argument( '-p', '--prune', action="store_true", help="""after fetching, delete remote-tracking branches that no longer exist on their remote""") group_b.add_argument( '-a', '--add', dest="bookmarks_to_add", nargs="+", metavar="path", type=_decode, help="add directory(s) as bookmarks") group_b.add_argument( '-d', '--delete', dest="bookmarks_to_del", nargs="+", metavar="path", type=_decode, 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_b.add_argument( '-n', '--clean', '--cleanup', dest="clean_bookmarks", action="store_true", help="delete any bookmarks that don't exist") group_b.add_argument( '-b', '--bookmark-file', nargs="?", metavar="path", type=_decode, help="use a specific bookmark config file (default: {0})".format( get_default_config_path())) group_a.add_argument( '-e', '--exec', '--batch', dest="command", metavar="command", help="run a shell command on all repos") group_m.add_argument( '-h', '--help', action="help", help="show this help message and exit") group_m.add_argument( '-v', '--version', action="version", version="gitup {0} (Python {1})".format( __version__, platform.python_version())) group_m.add_argument( '--selftest', action="store_true", help="run integrated test suite and exit (pytest must be available)") return parser def _selftest(): """Run the integrated test suite with pytest.""" from .test import run_tests run_tests() def main(): """Parse arguments and then call the appropriate function(s).""" parser = _build_parser() color_init(autoreset=True) args = parser.parse_args() print(Style.BRIGHT + "gitup" + Style.RESET_ALL + ": the git-repo-updater") print() if args.selftest: _selftest() return if args.bookmark_file: args.bookmark_file = os.path.expanduser(args.bookmark_file) acted = False if args.bookmarks_to_add: add_bookmarks(args.bookmarks_to_add, args.bookmark_file) acted = True if args.bookmarks_to_del: delete_bookmarks(args.bookmarks_to_del, args.bookmark_file) acted = True if args.list_bookmarks: list_bookmarks(args.bookmark_file) acted = True if args.clean_bookmarks: clean_bookmarks(args.bookmark_file) acted = True if args.command: if args.directories_to_update: run_command(args.directories_to_update, args) if args.update or not args.directories_to_update: run_command(get_bookmarks(args.bookmark_file), args) else: if args.directories_to_update: update_directories(args.directories_to_update, args) acted = True if args.update or not acted: update_bookmarks(get_bookmarks(args.bookmark_file), args) def run(): """Thin wrapper for main() that catches KeyboardInterrupts.""" try: main() except KeyboardInterrupt: print("Stopped by user.") git-repo-updater-0.5.1/gitup/config.py000066400000000000000000000107571354105233300176650ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright (C) 2011-2018 Ben Kurtovic # Released under the terms of the MIT License. See LICENSE for details. from __future__ import print_function from glob import glob import os from colorama import Fore, Style from gitup.migrate import run_migrations __all__ = ["get_default_config_path", "get_bookmarks", "add_bookmarks", "delete_bookmarks", "list_bookmarks", "clean_bookmarks"] YELLOW = Fore.YELLOW + Style.BRIGHT RED = Fore.RED + Style.BRIGHT INDENT1 = " " * 3 def _ensure_dirs(path): """Ensure the directories within the given pathname exist.""" dirname = os.path.dirname(path) if dirname and not os.path.exists(dirname): # Race condition, meh... os.makedirs(dirname) def _load_config_file(config_path=None): """Read the config file and return a list of bookmarks.""" run_migrations() cfg_path = config_path or get_default_config_path() try: with open(cfg_path, "rb") as config_file: paths = config_file.read().split(b"\n") except IOError: return [] paths = [path.decode("utf8").strip() for path in paths] return [path for path in paths if path] def _save_config_file(bookmarks, config_path=None): """Save the bookmarks list to the given config file.""" run_migrations() cfg_path = config_path or get_default_config_path() _ensure_dirs(cfg_path) dump = b"\n".join(path.encode("utf8") for path in bookmarks) with open(cfg_path, "wb") as config_file: config_file.write(dump) def _normalize_path(path): """Normalize the given path.""" if path.startswith("~"): return os.path.normcase(os.path.normpath(path)) return os.path.normcase(os.path.abspath(path)) def get_default_config_path(): """Return the default path to the configuration file.""" xdg_cfg = os.environ.get("XDG_CONFIG_HOME") or os.path.join("~", ".config") return os.path.join(os.path.expanduser(xdg_cfg), "gitup", "bookmarks") def get_bookmarks(config_path=None): """Get a list of all bookmarks, or an empty list if there are none.""" return _load_config_file(config_path) def add_bookmarks(paths, config_path=None): """Add a list of paths as bookmarks to the config file.""" config = _load_config_file(config_path) paths = [_normalize_path(path) for path in paths] added, exists = [], [] for path in paths: if path in config: exists.append(path) else: config.append(path) added.append(path) _save_config_file(config, config_path) 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, config_path=None): """Remove a list of paths from the bookmark config file.""" config = _load_config_file(config_path) paths = [_normalize_path(path) for path in paths] deleted, notmarked = [], [] if config: for path in paths: if path in config: config.remove(path) deleted.append(path) else: notmarked.append(path) _save_config_file(config, config_path) else: notmarked = 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(config_path=None): """Print all of our current bookmarks.""" bookmarks = _load_config_file(config_path) if bookmarks: print(YELLOW + "Current bookmarks:") for bookmark_path in bookmarks: print(INDENT1, bookmark_path) else: print("You have no bookmarks to display.") def clean_bookmarks(config_path=None): """Delete any bookmarks that don't exist.""" bookmarks = _load_config_file(config_path) if not bookmarks: print("You have no bookmarks to clean up.") return delete = [path for path in bookmarks if not (os.path.isdir(path) or glob(os.path.expanduser(path)))] if not delete: print("All of your bookmarks are valid.") return bookmarks = [path for path in bookmarks if path not in delete] _save_config_file(bookmarks, config_path) print(YELLOW + "Deleted bookmarks:") for path in delete: print(INDENT1, path) git-repo-updater-0.5.1/gitup/migrate.py000066400000000000000000000035751354105233300200500ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright (C) 2011-2018 Ben Kurtovic # Released under the terms of the MIT License. See LICENSE for details. import os try: from configparser import ConfigParser, NoSectionError PY3K = True except ImportError: # Python 2 from ConfigParser import SafeConfigParser as ConfigParser, NoSectionError PY3K = False __all__ = ["run_migrations"] def _get_old_path(): """Return the old default path to the configuration file.""" xdg_cfg = os.environ.get("XDG_CONFIG_HOME") or os.path.join("~", ".config") return os.path.join(os.path.expanduser(xdg_cfg), "gitup", "config.ini") def _migrate_old_path(): """Migrate the old config location (~/.gitup) to the new one.""" old_path = os.path.expanduser(os.path.join("~", ".gitup")) if not os.path.exists(old_path): return temp_path = _get_old_path() temp_dir = os.path.dirname(temp_path) if not os.path.exists(temp_dir): os.makedirs(temp_dir) os.rename(old_path, temp_path) def _migrate_old_format(): """Migrate the old config file format (.INI) to our custom list format.""" old_path = _get_old_path() if not os.path.exists(old_path): return config = ConfigParser(delimiters="=") if PY3K else ConfigParser() config.optionxform = lambda opt: opt config.read(old_path) try: bookmarks = [path for path, _ in config.items("bookmarks")] except NoSectionError: bookmarks = [] if PY3K: bookmarks = [path.encode("utf8") for path in bookmarks] new_path = os.path.join(os.path.split(old_path)[0], "bookmarks") os.rename(old_path, new_path) with open(new_path, "wb") as handle: handle.write(b"\n".join(bookmarks)) def run_migrations(): """Run any necessary migrations to ensure the config file is up-to-date.""" _migrate_old_path() _migrate_old_format() git-repo-updater-0.5.1/gitup/test/000077500000000000000000000000001354105233300170135ustar00rootroot00000000000000git-repo-updater-0.5.1/gitup/test/__init__.py000066400000000000000000000004401354105233300211220ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright (C) 2011-2018 Ben Kurtovic # Released under the terms of the MIT License. See LICENSE for details. def run_tests(args=None): import pytest if args is None: args = ["-v", "-rxw"] return pytest.main(args) git-repo-updater-0.5.1/gitup/test/test_bookmarks.py000066400000000000000000000007131354105233300224150ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright (C) 2011-2018 Ben Kurtovic # Released under the terms of the MIT License. See LICENSE for details. from __future__ import print_function, unicode_literals from gitup import config def test_empty_list(tmpdir, capsys): config_path = tmpdir / "config" config.list_bookmarks(config_path) captured = capsys.readouterr() assert captured.out == "You have no bookmarks to display.\n" git-repo-updater-0.5.1/gitup/test/test_cli.py000066400000000000000000000012631354105233300211750ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright (C) 2011-2018 Ben Kurtovic # Released under the terms of the MIT License. See LICENSE for details. from __future__ import print_function, unicode_literals import platform import subprocess import sys from gitup import __version__ def run_cli(*args): cmd = [sys.executable, "-m", "gitup"] + list(args) output = subprocess.check_output(cmd) return output.strip().decode("utf8") def test_cli_version(): """make sure we're using the right version of gitup""" output = run_cli("-v") expected = "gitup {} (Python {})".format( __version__, platform.python_version()) assert output == expected git-repo-updater-0.5.1/gitup/update.py000066400000000000000000000261161354105233300176760ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright (C) 2011-2018 Ben Kurtovic # Released under the terms of the MIT License. See LICENSE for details. from __future__ import print_function from glob import glob import os import pipes import re import shlex from colorama import Fore, Style from git import RemoteReference as RemoteRef, Repo, exc from git.util import RemoteProgress __all__ = ["update_bookmarks", "update_directories", "run_command"] BOLD = Style.BRIGHT BLUE = Fore.BLUE + BOLD GREEN = Fore.GREEN + BOLD RED = Fore.RED + BOLD CYAN = Fore.CYAN + 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): cur_count = str(int(cur_count)) if max_count: max_count = str(int(max_count)) 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 = ")" elif max_count: end = "\b" * (1 + len(cur_count) + len(max_count)) else: end = "\b" * len(cur_count) if max_count: print("{0}/{1}".format(cur_count, max_count), end=end) else: print(str(cur_count), end=end) def _fetch_remotes(remotes, prune): """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 # TODO: missing branch deleted (via --prune): 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="") if not remote.config_reader.has_option("fetch"): print(":", YELLOW + "skipped:", "no configured refspec.") continue try: results = remote.fetch(progress=_ProgressMonitor(), prune=prune) except exc.GitCommandError as err: # We should have to do this ourselves, but GitPython doesn't give # us a sensible way to get the raw stderr... msg = re.sub(r"\s+", " ", err.stderr).strip() msg = re.sub(r"^stderr: *'(fatal: *)?", "", msg).strip("'") if not msg: command = " ".join(pipes.quote(arg) for arg in err.command) msg = "{0} failed with status {1}.".format(command, err.status) elif 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.") return 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 _update_branch(repo, branch, is_active=False): """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 except ValueError: print(YELLOW + "skipped:", "branch has no revisions.") return try: upstream.commit except ValueError: print(YELLOW + "skipped:", "upstream does not exist.") return try: base = repo.git.merge_base(branch.commit, upstream.commit) except exc.GitCommandError as err: print(YELLOW + "skipped:", "can't find merge base with upstream.") return if repo.commit(base) == upstream.commit: print(BLUE + "up to date", end=".\n") return if is_active: try: 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, repo_name, args): """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 *args.current_only* is ``False``, or only the remote tracked by the current branch if ``True``. If *args.fetch_only* is ``False``, we will also update all fast-forwardable branches that are tracking valid upstreams. If *args.prune* is ``True``, remote-tracking branches that no longer exist on their remote after fetching will be deleted. """ print(INDENT1, BOLD + repo_name + ":") try: active = repo.active_branch except TypeError: # Happens when HEAD is detached active = None if args.current_only: 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 fetch.") return _fetch_remotes(remotes, args.prune) if not args.fetch_only: for branch in sorted(repo.heads, key=lambda b: b.name): _update_branch(repo, branch, branch == active) def _run_command(repo, repo_name, args): """Run an arbitrary shell command on the given repository.""" print(INDENT1, BOLD + repo_name + ":") cmd = shlex.split(args.command) try: out = repo.git.execute( cmd, with_extended_output=True, with_exceptions=False) except exc.GitCommandNotFound as err: print(INDENT2, ERROR, err) return for line in out[1].splitlines() + out[2].splitlines(): print(INDENT2, line) def _dispatch(base_path, callback, args): """Apply a callback function on each valid repo in the given path. Determine whether the directory is a git repo on its own, a directory of git repositories, a shell glob pattern, or something invalid. If the first, apply the callback on it; if the second or third, apply the callback on all repositories contained within; if the last, print an error. The given args are passed directly to the callback function after the repo. """ def _collect(paths, max_depth): """Return all valid repo paths in the given paths, recursively.""" if max_depth == 0: return [] valid = [] for path in paths: try: Repo(path) valid.append(path) except exc.InvalidGitRepositoryError: if not os.path.isdir(path): continue children = [os.path.join(path, it) for it in os.listdir(path)] valid += _collect(children, max_depth - 1) except exc.NoSuchPathError: continue return valid def _get_basename(base, path): """Return a reasonable name for a repo path in the given base.""" if path.startswith(base + os.path.sep): return path.split(base + os.path.sep, 1)[1] prefix = os.path.commonprefix([base, path]) while not base.startswith(prefix + os.path.sep): old = prefix prefix = os.path.split(prefix)[0] if prefix == old: break # Prevent infinite loop, but should be almost impossible return path.split(prefix + os.path.sep, 1)[1] base = os.path.expanduser(base_path) max_depth = args.max_depth if max_depth >= 0: max_depth += 1 try: Repo(base) valid = [base] except exc.NoSuchPathError: if is_comment(base): comment = get_comment(base) if comment: print(CYAN + BOLD + comment) return paths = glob(base) if not paths: print(ERROR, BOLD + base, "doesn't exist!") return valid = _collect(paths, max_depth) except exc.InvalidGitRepositoryError: if not os.path.isdir(base) or args.max_depth == 0: print(ERROR, BOLD + base, "isn't a repository!") return valid = _collect([base], max_depth) base = os.path.abspath(base) suffix = "" if len(valid) == 1 else "s" print(BOLD + base, "({0} repo{1}):".format(len(valid), suffix)) valid = [os.path.abspath(path) for path in valid] paths = [(_get_basename(base, path), path) for path in valid] for name, path in sorted(paths): callback(Repo(path), name, args) def is_comment(path): """Does the line start with a # symbol?""" return path.lstrip().startswith("#") def get_comment(path): """Return the string minus the comment symbol.""" return path.lstrip().lstrip("#").strip() def update_bookmarks(bookmarks, args): """Loop through and update all bookmarks.""" if not bookmarks: print("You don't have any bookmarks configured! Get help with 'gitup -h'.") return for path in bookmarks: _dispatch(path, _update_repository, args) def update_directories(paths, args): """Update a list of directories supplied by command arguments.""" for path in paths: _dispatch(path, _update_repository, args) def run_command(paths, args): """Run an arbitrary shell command on all repos.""" for path in paths: _dispatch(path, _run_command, args) git-repo-updater-0.5.1/setup.py000066400000000000000000000033601354105233300164200ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright (C) 2011-2018 Ben Kurtovic # Released under the terms of the MIT License. See LICENSE for details. import sys from setuptools import setup, find_packages if sys.hexversion < 0x02070000: exit("Please upgrade to Python 2.7 or greater: .") from gitup import __version__ with open('README.md') as fp: long_desc = fp.read() setup( name = "gitup", packages = find_packages(), entry_points = {"console_scripts": ["gitup = gitup.cli:run"]}, install_requires = ["GitPython >= 2.1.8", "colorama >= 0.3.9"], version = __version__, author = "Ben Kurtovic", author_email = "ben.kurtovic@gmail.com", description = "Easily update multiple git repositories at once", long_description = long_desc, long_description_content_type = "text/markdown", license = "MIT License", keywords = "git repository pull update", url = "https://github.com/earwig/git-repo-updater", classifiers = [ "Development Status :: 4 - Beta", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: MacOS :: MacOS X", "Operating System :: POSIX", "Operating System :: Microsoft :: Windows", "Programming Language :: Python", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Topic :: Software Development :: Version Control :: Git" ] )