From b54574943f81a06945178c94e0728c93b8c71baa Mon Sep 17 00:00:00 2001 From: ellieayla <1447600+me@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:58:34 -0400 Subject: [PATCH 1/5] Reproduce issue #1 - Reference at ... does not exist Upstream branch used to exist (and tracked) but has been deleted --- tests/test_repos.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_repos.py b/tests/test_repos.py index 71bbe5d..8ca9a2b 100644 --- a/tests/test_repos.py +++ b/tests/test_repos.py @@ -1,6 +1,7 @@ from datetime import UTC, datetime, timedelta from pathlib import Path +import pytest from git import Repo import git_unneeded @@ -149,3 +150,28 @@ def test_local_branch_unknown_to_remote(cloned_repo: Repo) -> None: assert "branch localbranch is not known to remotes" in r.reason assert "has commits" in r.reason assert git_unneeded.describe_commit_one_line(local_commit) in r.suggestions + + +def test_upstream_is_gone(cloned_repo: Repo) -> None: + """Issue #1""" + tmp_path = Path(cloned_repo.working_dir).parent + + # Squash commits from new-b onto main, similar to GitLab's merge-with-squash option + remote_git_repo = Repo(tmp_path / "Upstream") + remote_git_repo.git.execute(["git", "merge", "--squash", "new-b"]) + remote_git_repo.git.execute(["git", "commit", "--all", "-m", "squashed new-b into main"]) + + # Deleting like vvv doesn't result in the state where "... but the upstream is gone" is seen. + # remote_git_repo.delete_head("new-b", force=True) # git branch -D new-b + + cloned_repo.refs["main"].checkout() + pil = cloned_repo.remote("origin").push("new-b", delete=True) + assert pil[0].summary == "[deleted]\n" # delete remote ref + + switch_result = cloned_repo.git.switch("new-b") + assert "but the upstream is gone" in switch_result + + with pytest.raises(ValueError) as e: + _reasons = list(repository_safe_to_delete(cloned_repo, fetch=True)) + + assert e.value.args[0] == "Reference at 'refs/remotes/origin/new-b' does not exist" From 8476900b0b22ada192d919016fe8d7e3e830d5a6 Mon Sep 17 00:00:00 2001 From: ellieayla <1447600+me@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:00:56 -0400 Subject: [PATCH 2/5] Squashing commits is unnecessary to reproduce #1 --- tests/test_repos.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/test_repos.py b/tests/test_repos.py index 8ca9a2b..ad8b0f3 100644 --- a/tests/test_repos.py +++ b/tests/test_repos.py @@ -154,12 +154,6 @@ def test_local_branch_unknown_to_remote(cloned_repo: Repo) -> None: def test_upstream_is_gone(cloned_repo: Repo) -> None: """Issue #1""" - tmp_path = Path(cloned_repo.working_dir).parent - - # Squash commits from new-b onto main, similar to GitLab's merge-with-squash option - remote_git_repo = Repo(tmp_path / "Upstream") - remote_git_repo.git.execute(["git", "merge", "--squash", "new-b"]) - remote_git_repo.git.execute(["git", "commit", "--all", "-m", "squashed new-b into main"]) # Deleting like vvv doesn't result in the state where "... but the upstream is gone" is seen. # remote_git_repo.delete_head("new-b", force=True) # git branch -D new-b From 6d679f01bf5118d78fa6a0e1dddb5a5445a767d1 Mon Sep 17 00:00:00 2001 From: ellieayla <1447600+me@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:07:11 -0400 Subject: [PATCH 3/5] In test repos, set default branch name to main so it can easily be switched back to/ checked out by name --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index f105971..ef5cbc7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -70,7 +70,7 @@ def cloned_repo(tmp_path: Path) -> Iterable[Repo]: upstream_path = tmp_path / "Upstream" upstream_path.mkdir() - repo = Repo.init(upstream_path) + repo = Repo.init(upstream_path, initial_branch="main") original_branch = repo.active_branch From 037347ad11a58d89170f3a011a410f88ecbda0a5 Mon Sep 17 00:00:00 2001 From: ellieayla <1447600+me@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:37:44 -0400 Subject: [PATCH 4/5] Fix #1 - If remote tracking branch existed but was deleted, local can probably be deleted too Don't throw an exception if remote tracking branch no longer exists --- git_unneeded.py | 27 +++++++++++++++++++++++---- tests/test_repos.py | 17 ++++++++++++++--- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/git_unneeded.py b/git_unneeded.py index 1ebf926..d0c35b0 100755 --- a/git_unneeded.py +++ b/git_unneeded.py @@ -179,10 +179,29 @@ def repository_safe_to_delete(repo: git.Repo, fetch: bool = True) -> Generator[S ) continue - # branch b tracks remote branch - if tracking_branch.commit == b.commit: - # common & fast path - bl.info(f"Branch {b.path} and remote {tracking_branch.path} point to same commit {b.commit}") + try: + # branch b tracks remote branch + if tracking_branch.commit == b.commit: + # common & fast path + bl.info(f"Branch {b.path} and remote {tracking_branch.path} point to same commit {b.commit}") + if not is_main_branch(b): + # don't bother saying that main could be deleted + yield Safe( + repo, + f"Branch {b.path} and remote {tracking_branch.path} point to same commit.", + suggestions=[f"{b.commit}"] + ) + continue + except ValueError as e: + bl.info(f"Branch {b.path} cites {tracking_branch.path}, but {tracking_branch.path} isn't known. It was probably deleted: {e}") + yield Safe( + repo, + f"Local branch {b.path} cites {tracking_branch.path}", + suggestions=[ + f"{e}.", + "It was probably deleted from the remote. If so, delete the local branch." + ] + ) continue bl.info(f"Local branch {b.path} points to commit {b.commit}") diff --git a/tests/test_repos.py b/tests/test_repos.py index ad8b0f3..ab36123 100644 --- a/tests/test_repos.py +++ b/tests/test_repos.py @@ -2,7 +2,7 @@ from pathlib import Path import pytest -from git import Repo +from git import RemoteReference, Repo import git_unneeded from git_unneeded import Safe, Unsafe, repository_safe_to_delete @@ -165,7 +165,18 @@ def test_upstream_is_gone(cloned_repo: Repo) -> None: switch_result = cloned_repo.git.switch("new-b") assert "but the upstream is gone" in switch_result - with pytest.raises(ValueError) as e: - _reasons = list(repository_safe_to_delete(cloned_repo, fetch=True)) + new_b = cloned_repo.branches["new-b"] + tracking_branch = new_b.tracking_branch() + + assert isinstance(tracking_branch, RemoteReference) + assert tracking_branch.path == "refs/remotes/origin/new-b" + + with pytest.raises(ValueError): + tracking_branch.commit + with pytest.raises(ValueError) as e: + tracking_branch.dereference_recursive(tracking_branch.repo, tracking_branch.path) assert e.value.args[0] == "Reference at 'refs/remotes/origin/new-b' does not exist" + + reasons = list(repository_safe_to_delete(cloned_repo, fetch=True)) + assert "It was probably deleted" in str(reasons[0]) From fd48b8481aebe286c1f4cf2dc954cfeb2d372623 Mon Sep 17 00:00:00 2001 From: ellieayla <1447600+me@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:40:11 -0400 Subject: [PATCH 5/5] Remove extra Safe() for common case --- git_unneeded.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/git_unneeded.py b/git_unneeded.py index d0c35b0..95a29d1 100755 --- a/git_unneeded.py +++ b/git_unneeded.py @@ -184,13 +184,6 @@ def repository_safe_to_delete(repo: git.Repo, fetch: bool = True) -> Generator[S if tracking_branch.commit == b.commit: # common & fast path bl.info(f"Branch {b.path} and remote {tracking_branch.path} point to same commit {b.commit}") - if not is_main_branch(b): - # don't bother saying that main could be deleted - yield Safe( - repo, - f"Branch {b.path} and remote {tracking_branch.path} point to same commit.", - suggestions=[f"{b.commit}"] - ) continue except ValueError as e: bl.info(f"Branch {b.path} cites {tracking_branch.path}, but {tracking_branch.path} isn't known. It was probably deleted: {e}")