Browse Source

Rework branch updating code to be safer and support bare repos (#14)

* Only update _fast-forwardable_ branches to their upstreams. This uses
  `git merge --no-ff` for the active branch and `git branch --force`
  for others. Checks for clean working directories and ancestry are
  performed as expected.
* Added --fetch-only / -f to disable branch updating entirely, if
  desired.
* Deprecated --merge and --rebase. These have no effect now and instead
  give a warning.
tags/v0.3
Ben Kurtovic 9 years ago
parent
commit
31a94772ff
3 changed files with 53 additions and 125 deletions
  1. +6
    -6
      README.md
  2. +19
    -10
      gitup/script.py
  3. +28
    -109
      gitup/update.py

+ 6
- 6
README.md View File

@@ -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 your git config. Pass `--rebase` or `-r`
to make it always _rebase_ (this is 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



+ 19
- 10
gitup/script.py View File

@@ -7,7 +7,7 @@ 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__
from .config import (get_bookmarks, add_bookmarks, delete_bookmarks,
@@ -27,7 +27,6 @@ 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",
@@ -39,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 (behaves 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",
@@ -56,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__)

# 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)


+ 28
- 109
gitup/update.py View File

@@ -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):
@@ -122,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()
@@ -184,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] + ":")

@@ -246,9 +165,9 @@ def _update_repository(repo, current_only=False, rebase=False, merge=False):
return
_fetch_remotes(remotes)

if not repo.bare:
rebase = rebase or _read_config(repo, "pull.rebase")
_update_branches(repo, active, merge, rebase)
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, long_name, update_args):
"""Update all subdirectories that are git repos in a given directory."""


Loading…
Cancel
Save