From bc0f102ae7143f66f53709a45aeeefe988963cf9 Mon Sep 17 00:00:00 2001 From: Drew Schatt Date: Wed, 26 Aug 2026 16:59:45 -0700 Subject: [PATCH 1/5] Add regression tests for updating branches checked out in worktrees. git branch --force cannot move a worktree-checked-out ref, so these tests lock in that gitup must not abort the run and should fast-forward the worktree instead. --- src/gitup/test/test_update.py | 166 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 src/gitup/test/test_update.py diff --git a/src/gitup/test/test_update.py b/src/gitup/test/test_update.py new file mode 100644 index 0000000..62868f2 --- /dev/null +++ b/src/gitup/test/test_update.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2011-2026 Ben Kurtovic +# 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 _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 From eaea728aa05f49d78e6ac6fcca249c2521fbfd05 Mon Sep 17 00:00:00 2001 From: Drew Schatt Date: Wed, 26 Aug 2026 17:04:24 -0700 Subject: [PATCH 2/5] Fast-forward worktree checkouts instead of git branch --force. Git refuses to force-move a branch checked out in a linked worktree, which aborted the whole gitup run. Detect those checkouts, merge --ff-only there, and skip remaining git errors so other branches still update. --- src/gitup/update.py | 158 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 131 insertions(+), 27 deletions(-) diff --git a/src/gitup/update.py b/src/gitup/update.py index a62536d..6c35a49 100644 --- a/src/gitup/update.py +++ b/src/gitup/update.py @@ -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): From 9c36a7ff6eafdcfa361ade051998fd84cf642dac Mon Sep 17 00:00:00 2001 From: Drew Schatt Date: Wed, 26 Aug 2026 17:05:26 -0700 Subject: [PATCH 3/5] Cover worktree porcelain parsing and diverged checkouts. These cases would have missed GitPython's flag ordering and a non-fast-forward worktree merge. --- src/gitup/test/test_update.py | 52 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/src/gitup/test/test_update.py b/src/gitup/test/test_update.py index 62868f2..4d124e8 100644 --- a/src/gitup/test/test_update.py +++ b/src/gitup/test/test_update.py @@ -12,7 +12,12 @@ import pytest from git import Repo from git.exc import GitCommandError -from gitup.update import _update_branch, _update_repository +from gitup.update import ( + _branch_worktrees, + _parse_worktree_porcelain, + _update_branch, + _update_repository, +) ANSI = re.compile(r"\x1b\[[0-9;]*m") @@ -164,3 +169,48 @@ def test_later_branches_still_update_when_a_worktree_branch_needs_ff( 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 From a9ecd560a3583953c7bf8a69ad27356f51bb2fe7 Mon Sep 17 00:00:00 2001 From: Drew Schatt Date: Wed, 26 Aug 2026 17:05:51 -0700 Subject: [PATCH 4/5] Note worktree fast-forward handling in the changelog. --- CHANGELOG | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 700741c..70872d3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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+. From 4d43a14d7f18a4c9324ccc98c116fbb0b0bbac15 Mon Sep 17 00:00:00 2001 From: Drew Schatt Date: Wed, 26 Aug 2026 17:05:52 -0700 Subject: [PATCH 5/5] Mention linked worktrees in the README feature list. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8a0271c..d552807 100644 --- a/README.md +++ b/README.md @@ -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.