Drew Schatt 3 dni temu
committed by GitHub
rodzic
commit
7ff6cfe600
Nie znaleziono w bazie danych klucza dla tego podpisu ID klucza GPG: B5690EEEBB952194
4 zmienionych plików z 355 dodań i 29 usunięć
  1. +6
    -0
      CHANGELOG
  2. +2
    -2
      README.md
  3. +216
    -0
      src/gitup/test/test_update.py
  4. +131
    -27
      src/gitup/update.py

+ 6
- 0
CHANGELOG Wyświetl plik

@@ -1,3 +1,9 @@
v0.5.3 (unreleased):

- Fast-forward branches that are checked out in linked worktrees instead of
crashing on ``git branch --force``. Dirty or diverged worktrees are skipped,
and a failure on one branch no longer aborts the rest of the run.

v0.5.2 (released June 9, 2025):

- Support Python 3.13+.


+ 2
- 2
README.md Wyświetl plik

@@ -4,8 +4,8 @@ __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.
branches, detached HEADs, linked worktrees, and more. It was originally created
to manage a large collection of projects and deal with sporadic internet access.

gitup works on macOS, Linux, and Windows. You should have a recent version of
git and Python 3.10+ installed.


+ 216
- 0
src/gitup/test/test_update.py Wyświetl plik

@@ -0,0 +1,216 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2011-2026 Ben Kurtovic <ben.kurtovic@gmail.com>
# Released under the terms of the MIT License. See LICENSE for details.

import os
import re
import subprocess
from types import SimpleNamespace

import pytest
from git import Repo
from git.exc import GitCommandError

from gitup.update import (
_branch_worktrees,
_parse_worktree_porcelain,
_update_branch,
_update_repository,
)

ANSI = re.compile(r"\x1b\[[0-9;]*m")

GIT = [
"git",
"-c",
"user.name=gitup-test",
"-c",
"user.email=gitup-test@example.com",
"-c",
"commit.gpgsign=false",
"-c",
"init.defaultBranch=main",
]


def _plain(text):
return ANSI.sub("", text)


def _git(cwd, *args):
try:
subprocess.check_output(
GIT + list(args),
cwd=os.fspath(cwd),
stderr=subprocess.STDOUT,
)
except subprocess.CalledProcessError as err:
output = err.output.decode("utf8", errors="replace") if err.output else ""
raise AssertionError(
"git {0} failed:\n{1}".format(" ".join(args), output)
) from err


def _write(path, contents):
path.write_text(contents, encoding="utf8")


def _fetch_args(**overrides):
args = SimpleNamespace(current_only=False, fetch_only=False, prune=False)
for key, value in overrides.items():
setattr(args, key, value)
return args


@pytest.fixture
def tracking_repos(tmp_path):
"""A clone with a slash-named tracking branch checked out in a worktree.

The worktree branch is behind ``origin`` after setup; ``main`` stays
checked out in the clone.
"""
origin = tmp_path / "origin.git"
_git(tmp_path, "init", "--bare", "-b", "main", os.fspath(origin))

clone = tmp_path / "clone"
_git(tmp_path, "clone", os.fspath(origin), os.fspath(clone))
_write(clone / "README", "base\n")
_git(clone, "add", "README")
_git(clone, "commit", "-m", "init")
_git(clone, "push", "-u", "origin", "main")

branch = "wip/382-eliminate-tautological-expects"
_git(clone, "branch", branch)
_git(clone, "push", "-u", "origin", branch)

worktree = tmp_path / "wt-382"
_git(clone, "worktree", "add", os.fspath(worktree), branch)

pusher = tmp_path / "pusher"
_git(tmp_path, "clone", "-b", branch, os.fspath(origin), os.fspath(pusher))
_write(pusher / "README", "upstream\n")
_git(pusher, "add", "README")
_git(pusher, "commit", "-m", "advance feature")
_git(pusher, "push")

_git(clone, "fetch", "origin")

extra = "done/386-presentation-size"
_git(clone, "branch", extra, "main")
_git(clone, "push", "-u", "origin", extra)
_write(pusher / "EXTRA", "extra\n")
_git(pusher, "checkout", "-B", extra, "origin/main")
_git(pusher, "add", "EXTRA")
_git(pusher, "commit", "-m", "advance extra")
_git(pusher, "push", "-u", "origin", extra)
_git(clone, "fetch", "origin")

return SimpleNamespace(
origin=origin,
clone=clone,
worktree=worktree,
branch=branch,
extra=extra,
)


def _head(path):
return Repo(os.fspath(path)).head.commit.hexsha


def test_worktree_branch_force_update_does_not_raise(tracking_repos):
repo = Repo(os.fspath(tracking_repos.clone))
branch = repo.heads[tracking_repos.branch]

try:
_update_branch(repo, branch, is_active=False)
except GitCommandError as err:
pytest.fail("updating a worktree branch raised GitCommandError: {0}".format(err))


def test_worktree_branch_is_fast_forwarded_in_its_worktree(tracking_repos, capsys):
repo = Repo(os.fspath(tracking_repos.clone))
branch = repo.heads[tracking_repos.branch]
expected = repo.remotes.origin.refs[tracking_repos.branch].commit.hexsha

_update_branch(repo, branch, is_active=False)
out = _plain(capsys.readouterr().out)

assert "done" in out
assert _head(tracking_repos.worktree) == expected
assert repo.heads[tracking_repos.branch].commit.hexsha == expected


def test_dirty_worktree_is_skipped_not_crashed(tracking_repos, capsys):
repo = Repo(os.fspath(tracking_repos.clone))
branch = repo.heads[tracking_repos.branch]
before = _head(tracking_repos.worktree)
_write(tracking_repos.worktree / "README", "local dirty\n")

_update_branch(repo, branch, is_active=False)
out = _plain(capsys.readouterr().out)

assert "skipped" in out
assert "uncommitted changes" in out
assert _head(tracking_repos.worktree) == before


def test_later_branches_still_update_when_a_worktree_branch_needs_ff(
tracking_repos, capsys
):
repo = Repo(os.fspath(tracking_repos.clone))
expected_extra = repo.remotes.origin.refs[tracking_repos.extra].commit.hexsha
expected_wt = repo.remotes.origin.refs[tracking_repos.branch].commit.hexsha

_update_repository(repo, "clone", _fetch_args())
out = _plain(capsys.readouterr().out)

assert "Error" not in out or "no remotes" not in out
assert repo.heads[tracking_repos.extra].commit.hexsha == expected_extra
assert _head(tracking_repos.worktree) == expected_wt


def test_parse_worktree_porcelain_maps_slash_branches_and_skips_detached():
output = """worktree /repo
HEAD abc123
branch refs/heads/main

worktree /repo-wt
HEAD def456
branch refs/heads/wip/382-eliminate-tautological-expects

worktree /repo-det
HEAD ghi789
detached
"""
assert _parse_worktree_porcelain(output) == {
"main": "/repo",
"wip/382-eliminate-tautological-expects": "/repo-wt",
}


def test_branch_worktrees_includes_linked_slash_branch(tracking_repos):
repo = Repo(os.fspath(tracking_repos.clone))
mapping = _branch_worktrees(repo)
worktree_path = os.path.realpath(os.fspath(tracking_repos.worktree))
assert tracking_repos.branch in mapping
assert os.path.realpath(mapping[tracking_repos.branch]) == worktree_path


def test_diverged_worktree_is_skipped(tracking_repos, capsys):
repo = Repo(os.fspath(tracking_repos.clone))
branch = repo.heads[tracking_repos.branch]
_write(tracking_repos.worktree / "LOCAL", "worktree-only\n")
_git(tracking_repos.worktree, "add", "LOCAL")
_git(tracking_repos.worktree, "commit", "-m", "diverge worktree")
diverged = _head(tracking_repos.worktree)
upstream = repo.remotes.origin.refs[tracking_repos.branch].commit.hexsha

_update_branch(repo, branch, is_active=False)
out = _plain(capsys.readouterr().out)

assert "skipped" in out
assert "not possible to fast-forward" in out
assert _head(tracking_repos.worktree) == diverged
assert diverged != upstream

+ 131
- 27
src/gitup/update.py Wyświetl plik

@@ -85,8 +85,7 @@ def _fetch_remotes(remotes, 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("'")
msg = _git_error_text(err)
if not msg:
command = " ".join(shlex.quote(arg) for arg in err.command)
msg = "{0} failed with status {1}.".format(command, err.status)
@@ -115,9 +114,111 @@ def _fetch_remotes(remotes, prune):
print(":", (", ".join(rlist) if rlist else up_to_date) + ".")


def _update_branch(repo, branch, is_active=False):
"""Update a single branch."""
def _git_error_text(err):
"""Return a concise message from a GitPython command error."""
raw = err.stderr or ""
if isinstance(raw, bytes):
raw = raw.decode("utf8", errors="replace")
msg = re.sub(r"\s+", " ", raw).strip()
msg = re.sub(r"^stderr: *'(fatal: *)?", "", msg).strip("'")
return msg


def _same_path(left, right):
"""Return whether two filesystem paths refer to the same location."""
if not left or not right:
return False
try:
return os.path.samefile(left, right)
except OSError:
return os.path.normcase(os.path.realpath(left)) == os.path.normcase(
os.path.realpath(right)
)


def _parse_worktree_porcelain(output):
"""Map local branch names to the worktree path where they are checked out."""

def attrs_from_entry(entry):
attrs = {}
for line in entry.splitlines():
if not line:
continue
if " " in line:
key, value = line.split(" ", 1)
attrs[key] = value
else:
attrs[line] = True
return attrs

def branch_and_path(attrs):
path = attrs.get("worktree")
ref = attrs.get("branch")
if not path or not isinstance(ref, str):
return None
name = (
ref[len("refs/heads/") :] if ref.startswith("refs/heads/") else ref
)
return name, path

normalized = output.replace("\r\n", "\n")
pairs = [
branch_and_path(attrs_from_entry(entry))
for entry in normalized.split("\n\n")
]
return dict(pair for pair in pairs if pair)


def _branch_worktrees(repo):
"""Return {branch_name: worktree_path} for checkouts with a branch."""
try:
# GitPython puts kwargs before subcommands (`worktree --porcelain list`),
# which git rejects. Pass --porcelain as a positional argument.
output = repo.git.worktree("list", "--porcelain")
except exc.GitCommandError:
return {}
return _parse_worktree_porcelain(output or "")


def _repo_for_workdir(repo, worktree_path):
"""Return a Repo for *worktree_path*, reusing *repo* when it is the same tree."""
if repo.working_dir and _same_path(repo.working_dir, worktree_path):
return repo
return Repo(worktree_path)


def _merge_ff_only(repo, upstream_name):
"""Fast-forward *repo*'s current checkout; print done or skipped."""
try:
repo.git.merge(upstream_name, ff_only=True)
print(GREEN + "done", end=".\n")
except exc.GitCommandError as err:
msg = _git_error_text(err)
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.")


def _update_branch(repo, branch, is_active=False, worktrees=None):
"""Update a single branch.

Branches checked out in this repo or another worktree are fast-forwarded
with ``merge --ff-only`` in that checkout. Other tracking branches are
moved with ``git branch --force`` when the update is a fast-forward.
"""
print(INDENT2, "Updating", BOLD + branch.name, end=": ")
try:
_update_branch_body(repo, branch, is_active, worktrees)
except exc.GitCommandError as err:
msg = _git_error_text(err) or "git command failed"
if not msg.endswith("."):
msg += "."
print(YELLOW + "skipped:", msg)


def _update_branch_body(repo, branch, is_active, worktrees):
"""Perform the update for a single branch, printing the result."""
upstream = branch.tracking_branch()
if not upstream:
print(YELLOW + "skipped:", "no upstream is tracked.")
@@ -144,29 +245,31 @@ def _update_branch(repo, branch, is_active=False):
print(BLUE + "up to date", end=".\n")
return

if is_active:
if worktrees is None:
worktrees = _branch_worktrees(repo)
checkout = worktrees.get(branch.name)

if is_active or checkout:
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")
target = _repo_for_workdir(repo, checkout) if checkout else repo
except (exc.NoSuchPathError, exc.InvalidGitRepositoryError):
print(YELLOW + "skipped:", "worktree path does not exist.")
return
_merge_ff_only(target, upstream.name)
return

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.")
return
repo.git.branch(branch.name, upstream.name, force=True)
print(GREEN + "done", end=".\n")


def _update_repository(repo, repo_name, args):
@@ -207,8 +310,9 @@ def _update_repository(repo, repo_name, args):
_fetch_remotes(remotes, args.prune)

if not args.fetch_only:
worktrees = _branch_worktrees(repo)
for branch in sorted(repo.heads, key=lambda b: b.name):
_update_branch(repo, branch, branch == active)
_update_branch(repo, branch, branch == active, worktrees)


def _run_command(repo, repo_name, args):


Ładowanie…
Anuluj
Zapisz