| @@ -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+. | |||
| @@ -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. | |||
| @@ -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 | |||
| @@ -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): | |||