@@ -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" | |||
] | |||
) |