Skip to content

Commit 422eb2b

Browse files
gregswiftclaude
authored andcommitted
feat(repo): detect stale clones and report a clear error
When templatron runs without --autoclean (the default), it leaves clone directories under the clone root. On a re-run, the previous templatron-* update branch is still present in the local clone, so 'git checkout -b <update_branch>' fails with sh.ErrorReturnCode_128 and the user sees a raw traceback with no hint that the fix is to delete the clone or pass --autoclean. - Add StaleCloneError to surface this case as a TemplatronException. - Add BaseRepo.local_branch_exists() helper for cheap pre-checks. - Guard switch_to_update_branch() to raise StaleCloneError up front, with a message naming the branch, the clone path, and the two remediation options. - Broaden onboard()'s exception handler from UnrecognizableBaseBranchError to TemplatronException, matching how update() already handles repo-level failures, so StaleCloneError (and any future TemplatronException subclasses raised from a repo's onboard flow) exit cleanly via die() instead of escaping as a raw traceback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ef91e18 commit 422eb2b

7 files changed

Lines changed: 89 additions & 5 deletions

File tree

templatron/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ class MissingRequiredConfigError(TemplatronException):
2222
pass
2323

2424

25+
class StaleCloneError(TemplatronException):
26+
pass
27+
28+
2529
class TemplateConfigMissingError(TemplatronException):
2630
pass
2731

templatron/repo/base_repo.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ def git_cmd(self, cmd, *args):
103103
return git(cmd, *args)
104104
return git(cmd, *args, _cwd=self.clone_path)
105105

106+
def local_branch_exists(self, branch):
107+
try:
108+
self.git_cmd("rev-parse", "--verify", f"refs/heads/{branch}")
109+
return True
110+
except ErrorReturnCode:
111+
return False
112+
106113
def maybe_switch_branch(self):
107114
if self.active_branch == self.base_branch:
108115
return

templatron/repo/repository.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from copier import run_copy, run_update
88

99
from templatron.commit_template import commit_template
10-
from templatron.exceptions import HookFailure
10+
from templatron.exceptions import HookFailure, StaleCloneError
1111
from templatron.log_or_print import log_or_print
1212
from templatron.repo.base_repo import BaseRepo
1313

@@ -316,6 +316,14 @@ def switch_to_update_branch(self):
316316
return
317317

318318
self.logger.debug(f"switch to update branch {self.update_branch_name}")
319+
if self.local_branch_exists(self.update_branch_name):
320+
raise StaleCloneError(
321+
f"branch '{self.update_branch_name}' already exists in "
322+
f"{self.clone_path} — likely left behind by a previous "
323+
"run that didn't clean up. Delete the clone directory "
324+
"and retry, or pass --autoclean to remove clones "
325+
"automatically."
326+
)
319327
self.git_cmd("checkout", "-b", self.update_branch_name)
320328

321329
def update(self, operation="updating"):

templatron/templatron.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ def onboard(self, onboarding_repo):
181181
try:
182182
repo = self.build_repo(onboarding_repo)
183183
repo.onboard()
184-
except UnrecognizableBaseBranchError as error:
184+
except TemplatronException as error:
185185
self.die(error)
186186
except KeyboardInterrupt:
187187
self.maybe_clean()

test/test_repo/test_base_repo.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from unittest import TestCase
88
from unittest.mock import MagicMock, patch
99

10-
from sh import ErrorReturnCode
10+
from sh import ErrorReturnCode, ErrorReturnCode_1
1111

1212
from templatron.exceptions import DirtyRepoError, UnrecognizableBaseBranchError
1313
from templatron.repo.base_repo import BaseRepo
@@ -222,6 +222,30 @@ def test_git_cmd_no_clone(self, mock_git):
222222
self.test_repo.git_cmd("commit", "some args")
223223
mock_git.assert_called_with("commit", "some args", _cwd="/fake/root/fake repo")
224224

225+
@patch("templatron.repo.base_repo.BaseRepo.git_cmd")
226+
def test_local_branch_exists_true(self, mock_git):
227+
"""
228+
Test BaseRepo.local_branch_exists() returns True when
229+
git rev-parse --verify succeeds.
230+
"""
231+
232+
self.assertTrue(self.test_repo.local_branch_exists("fake_branch"))
233+
mock_git.assert_called_with(
234+
"rev-parse", "--verify", "refs/heads/fake_branch"
235+
)
236+
237+
@patch("templatron.repo.base_repo.BaseRepo.git_cmd")
238+
def test_local_branch_exists_false(self, mock_git):
239+
"""
240+
Test BaseRepo.local_branch_exists() returns False when
241+
git rev-parse --verify exits non-zero (branch missing).
242+
"""
243+
244+
mock_git.side_effect = ErrorReturnCode_1(
245+
full_cmd="git", stdout=b"", stderr=b""
246+
)
247+
self.assertFalse(self.test_repo.local_branch_exists("fake_branch"))
248+
225249
@patch.object(BaseRepo, "active_branch", "fake_branch")
226250
@patch.object(BaseRepo, "base_branch", "fake_branch")
227251
@patch("templatron.repo.base_repo.BaseRepo.git_cmd")

test/test_repo/test_repository.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from unittest import TestCase
1010
from unittest.mock import MagicMock, patch
1111

12-
from templatron.exceptions import HookFailure
12+
from templatron.exceptions import HookFailure, StaleCloneError
1313
from templatron.repo.repository import Repository
1414
from templatron.repo.template import Template
1515

@@ -572,16 +572,36 @@ def test_switch_to_update_branch_fixing(self, mock_git):
572572
mock_git.assert_not_called()
573573

574574
@patch.object(Repository, "update_branch_name", "fake_branch")
575+
@patch("templatron.repo.repository.Repository.local_branch_exists")
575576
@patch("templatron.repo.repository.Repository.git_cmd")
576-
def test_switch_to_update_branch_not_fixing(self, mock_git):
577+
def test_switch_to_update_branch_not_fixing(self, mock_git, mock_exists):
577578
"""
578579
Docs
579580
"""
580581

582+
mock_exists.return_value = False
581583
self.test_repo.operation = "not fixing"
582584
self.test_repo.switch_to_update_branch()
583585
mock_git.assert_called_with("checkout", "-b", "fake_branch")
584586

587+
@patch.object(Repository, "update_branch_name", "fake_branch")
588+
@patch("templatron.repo.repository.Repository.local_branch_exists")
589+
@patch("templatron.repo.repository.Repository.git_cmd")
590+
def test_switch_to_update_branch_stale_branch(
591+
self, mock_git, mock_exists
592+
):
593+
"""
594+
Test switch_to_update_branch() raises StaleCloneError when the
595+
target update branch already exists locally — typically from a
596+
prior run that didn't clean up.
597+
"""
598+
599+
mock_exists.return_value = True
600+
self.test_repo.operation = "not fixing"
601+
with self.assertRaisesRegex(StaleCloneError, "fake_branch"):
602+
self.test_repo.switch_to_update_branch()
603+
mock_git.assert_not_called()
604+
585605
@patch.object(Repository, "needs_update", True)
586606
@patch("templatron.repo.repository.Repository.clean_stale_branches")
587607
@patch("templatron.repo.repository.Repository.clone")

test/test_templatron.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
TemplatronException,
1616
GitConfigError,
1717
MissingRequiredConfigError,
18+
StaleCloneError,
1819
UnrecognizableBaseBranchError,
1920
)
2021
from templatron.templatron import Templatron
@@ -476,6 +477,26 @@ def test_onboard_unrecognizable_branch(
476477
mock_start.assert_called_with("onboarding")
477478
mock_stop.assert_not_called()
478479

480+
@patch("templatron.templatron.Templatron.die")
481+
@patch("templatron.templatron.Templatron.start")
482+
@patch("templatron.templatron.Templatron.build_repo")
483+
@patch("templatron.templatron.Templatron.stop")
484+
def test_onboard_stale_clone(
485+
self, mock_stop, mock_build, mock_start, mock_die
486+
):
487+
"""
488+
Test Templatron.onboard() exits cleanly via die() when the
489+
repo's update branch already exists from a prior run, rather
490+
than letting the exception propagate as a raw traceback.
491+
"""
492+
493+
mock_build().onboard.side_effect = StaleCloneError("stale")
494+
mock_die.side_effect = SystemExit
495+
with self.assertRaises(SystemExit):
496+
self.templatron.onboard("repo")
497+
mock_start.assert_called_with("onboarding")
498+
mock_stop.assert_not_called()
499+
479500
@patch("templatron.templatron.Templatron.maybe_clean")
480501
@patch("templatron.templatron.Templatron.start")
481502
@patch("templatron.templatron.Templatron.build_repo")

0 commit comments

Comments
 (0)