From bc0f102ae7143f66f53709a45aeeefe988963cf9 Mon Sep 17 00:00:00 2001 From: Drew Schatt Date: Wed, 26 Aug 2026 16:59:45 -0700 Subject: [PATCH] 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