Переглянути джерело

Merge branch 'feature/gitpython' into develop

tags/v0.2
Ben Kurtovic 10 роки тому
джерело
коміт
0ad8d2cb71
3 змінених файлів з 278 додано та 125 видалено
  1. +18
    -11
      README.md
  2. +22
    -8
      gitup/script.py
  3. +238
    -106
      gitup/update.py

+ 18
- 11
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.<name>.rebase` is specified in git's config. Pass `--rebase` or `-r` to
make it always _rebase_ (like doing `git pull --rebase=preserve`). Pass
`--merge` or `-m` to make it always merge.

For a list of all command arguments and abbreviations:

gitup --help


+ 22
- 8
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.<name>.rebase`
in git config (like `git pull --rebase=preserve`)""")
rebase_or_merge.add_argument(
'-m', '--merge', action="store_true", help="""like --rebase, but merge
instead""")

group_b.add_argument(
'-a', '--add', dest="bookmarks_to_add", nargs="+", metavar="path",
help="add directory(s) as bookmarks")
@@ -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."""


+ 238
- 106
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.<name>.rebase`` is set in config; *rebase* will
cause us to always rebase with ``--preserve-merges``, and *merge* will
cause us to always merge.
"""
print(INDENT1, BOLD + os.path.split(repo.working_dir)[1] + ":")

active = repo.active_branch
if current_only:
ref = active.tracking_branch()
if not ref:
print(INDENT2, ERROR, "no remote tracked by current branch.")
return
remotes = [repo.remotes[ref.remote_name]]
else:
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)

Завантаження…
Відмінити
Зберегти