| @@ -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): | v0.5.2 (released June 9, 2025): | ||||
| - Support Python 3.13+. | - 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 | gitup is a tool for updating multiple git repositories at once. It is smart | ||||
| enough to handle several remotes, dirty working directories, diverged local | 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 | gitup works on macOS, Linux, and Windows. You should have a recent version of | ||||
| git and Python 3.10+ installed. | 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: | except exc.GitCommandError as err: | ||||
| # We should have to do this ourselves, but GitPython doesn't give | # We should have to do this ourselves, but GitPython doesn't give | ||||
| # us a sensible way to get the raw stderr... | # 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: | if not msg: | ||||
| command = " ".join(shlex.quote(arg) for arg in err.command) | command = " ".join(shlex.quote(arg) for arg in err.command) | ||||
| msg = "{0} failed with status {1}.".format(command, err.status) | 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) + ".") | 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=": ") | 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() | upstream = branch.tracking_branch() | ||||
| if not upstream: | if not upstream: | ||||
| print(YELLOW + "skipped:", "no upstream is tracked.") | 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") | print(BLUE + "up to date", end=".\n") | ||||
| return | return | ||||
| if is_active: | |||||
| if worktrees is None: | |||||
| worktrees = _branch_worktrees(repo) | |||||
| checkout = worktrees.get(branch.name) | |||||
| if is_active or checkout: | |||||
| try: | 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): | def _update_repository(repo, repo_name, args): | ||||
| @@ -207,8 +310,9 @@ def _update_repository(repo, repo_name, args): | |||||
| _fetch_remotes(remotes, args.prune) | _fetch_remotes(remotes, args.prune) | ||||
| if not args.fetch_only: | if not args.fetch_only: | ||||
| worktrees = _branch_worktrees(repo) | |||||
| for branch in sorted(repo.heads, key=lambda b: b.name): | 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): | def _run_command(repo, repo_name, args): | ||||