diff --git a/factory/worktree.py b/factory/worktree.py index d0d036d06..152d85d12 100644 --- a/factory/worktree.py +++ b/factory/worktree.py @@ -469,7 +469,9 @@ def _bootstrap_unborn_repo(project_path: Path) -> None: def detect_default_branch(project_path: Path) -> str: """Detect the default branch for a git repository. - Cascade: remote HEAD → probe main/master → current HEAD → fallback 'main'. + Cascade: remote HEAD, then probe main/master, then current HEAD, then the + symbolic HEAD of an unborn repo. Raises RuntimeError if none of these + resolve, rather than guessing a branch that does not exist. """ project_path = project_path.resolve() @@ -524,8 +526,20 @@ def detect_default_branch(project_path: Path) -> str: log.debug("detect_default_branch", source="symbolic_ref", branch=branch) return branch - log.debug("detect_default_branch", source="fallback", branch="main") - return "main" + # Every detection method failed: no origin/HEAD, no local main or master, + # HEAD is detached, and the symbolic HEAD did not resolve either. Probing + # for main or master already happened above, so returning a hardcoded + # "main" here would only hand create_worktree a branch that does not + # exist, which then surfaces as an opaque "git rev-parse main returned + # non-zero exit status 128" error. Fail with something the caller can act + # on instead. + log.debug("detect_default_branch", source="fallback", branch=None) + raise RuntimeError( + f"Could not determine a default branch for {project_path}. " + "There is no origin/HEAD, no local 'main' or 'master' branch, and " + "HEAD is detached. Set `target_branch` in .factory/config.json or " + "check out a branch before running." + ) def _list_active_worktrees(project_path: Path) -> set[str]: diff --git a/tests/test_worktree.py b/tests/test_worktree.py index 13f1ce58e..68ee65b19 100644 --- a/tests/test_worktree.py +++ b/tests/test_worktree.py @@ -352,6 +352,49 @@ def test_fallback_to_current_branch(self, tmp_path: Path) -> None: assert detect_default_branch(project) == "develop" + def test_raises_when_no_branch_detectable(self, tmp_path: Path) -> None: + """A detached HEAD with no main/master and no origin raises instead of + returning a nonexistent 'main' branch.""" + project = tmp_path / "project" + project.mkdir() + + env = { + "GIT_AUTHOR_NAME": "test", + "GIT_AUTHOR_EMAIL": "test@test.com", + "GIT_COMMITTER_NAME": "test", + "GIT_COMMITTER_EMAIL": "test@test.com", + "HOME": str(tmp_path), + "PATH": "/usr/bin:/bin:/usr/local/bin", + } + + subprocess.run( + ["git", "init", "-b", "develop"], + cwd=project, + capture_output=True, + check=True, + ) + (project / "README.md").write_text("hello") + subprocess.run(["git", "add", "."], cwd=project, capture_output=True, check=True) + subprocess.run( + ["git", "commit", "-m", "initial"], + cwd=project, + capture_output=True, + check=True, + env=env, + ) + # Detach HEAD so abbrev-ref returns "HEAD" and symbolic-ref fails, + # leaving no detectable default branch. + subprocess.run( + ["git", "checkout", "--detach", "HEAD"], + cwd=project, + capture_output=True, + check=True, + env=env, + ) + + with pytest.raises(RuntimeError, match="Could not determine a default branch"): + detect_default_branch(project) + class TestCreateWorktreeWithMaster: def test_create_worktree_on_master_repo(self, git_project_master: Path) -> None: