Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 46 additions & 19 deletions devflow/utils/temp_directory.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,17 +198,15 @@ def _prompt_for_branch_selection(repo_path: Path) -> Optional[str]:
console_print(f"[yellow]⚠[/yellow] No branches found on remote '{remote}'")
return None

# Determine default branch with priority: upstream/main > upstream/master > origin/main > origin/master
default_branch = None
default_candidates = ["main", "master"]
for candidate in default_candidates:
if candidate in branches:
default_branch = candidate
break

if not default_branch and branches:
# If no main/master, use first available branch
default_branch = branches[0]
# Resolve the selected remote's actual default branch. Do not guess based on
# common names or branch-list ordering; either can select an unrelated base.
default_branch = GitUtils.get_default_branch(repo_path, remote=remote)
if default_branch not in branches:
default_branch = None

default_selection = (
str(branches.index(default_branch) + 1) if default_branch is not None else None
)

# Build branch selection menu
console_print("\nSelect branch to checkout:")
Expand All @@ -223,15 +221,25 @@ def _prompt_for_branch_selection(repo_path: Path) -> Optional[str]:
while True:
choice = Prompt.ask(
"\nEnter selection",
default="1" if default_branch else None,
show_default=True
default=default_selection,
show_default=default_selection is not None,
)

choice_text = choice or ""

if not choice_text:
if default_branch is not None:
console_print(f"[green]✓[/green] Selected branch: {default_branch}")
return default_branch
console_print("[red]✗[/red] Please enter a branch number or name")
continue

# Allow cancel
if choice_text.lower() in ['cancel', 'q']:
console_print(f"[dim]Using default branch: {default_branch}[/dim]")
if choice_text.lower() in ["cancel", "q"]:
if default_branch is not None:
console_print(f"[dim]Using default branch: {default_branch}[/dim]")
else:
console_print("[dim]Branch selection cancelled[/dim]")
return default_branch

# Parse selection (can be number or branch name)
Expand All @@ -244,15 +252,31 @@ def _prompt_for_branch_selection(repo_path: Path) -> Optional[str]:
selected_branch = branches[selection_num - 1]
else:
console_print(f"[red]✗[/red] Invalid selection: {choice_text}")
console_print("[dim]Please try again or type 'cancel' to use default[/dim]")
cancel_hint = (
"use the default branch"
if default_branch is not None
else "cancel selection"
)
console_print(
f"[dim]Please try again or type 'cancel' to {cancel_hint}[/dim]"
)
continue
except ValueError:
# Not a number, treat as branch name
if choice_text in branches:
selected_branch = choice_text
else:
console_print(f"[red]✗[/red] Branch '{choice_text}' not found in available branches")
console_print("[dim]Please try again or type 'cancel' to use default[/dim]")
console_print(
f"[red]✗[/red] Branch '{choice_text}' not found in available branches"
)
cancel_hint = (
"use the default branch"
if default_branch is not None
else "cancel selection"
)
console_print(
f"[dim]Please try again or type 'cancel' to {cancel_hint}[/dim]"
)
continue

# Valid selection made
Expand All @@ -261,7 +285,10 @@ def _prompt_for_branch_selection(repo_path: Path) -> Optional[str]:
return selected_branch

except (KeyboardInterrupt, EOFError):
console_print("\n[dim]Branch selection cancelled, using default[/dim]")
if default_branch is not None:
console_print("\n[dim]Branch selection cancelled, using default[/dim]")
else:
console_print("\n[dim]Branch selection cancelled[/dim]")
return default_branch


Expand Down
12 changes: 12 additions & 0 deletions docs/guides/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,18 @@ credentials, private repository details, and sensitive stderr before sharing.
Do not treat a reported silent fallback as a confirmed authentication or
GitLab-only failure without this evidence.

### Temporary-Clone Branch Selection

The interactive temporary-clone picker used by `daf git new`, `daf jira new`,
and `daf investigate` marks the selected remote's actual default branch when it
can be resolved and is present in the listed branches. Pressing Enter selects
that marked branch even when it is not the first option. It does not guess
`main`/`master` or substitute the first listed branch. If no matching default
is available, choose a branch by number or name; cancelling leaves the existing
fallback checkout behavior in place. This only chooses the branch checked out
inside the analysis clone; it does not create a development branch. Mock and
JSON modes continue to skip the prompt.

### Checking Authentication Before Operations

**Best Practice:**
Expand Down
171 changes: 144 additions & 27 deletions tests/test_temp_directory_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,15 @@ def test_cleans_up_directory_with_nested_content(self, tmp_path):
class TestPromptForBranchSelection:
"""Test the _prompt_for_branch_selection function."""

@pytest.fixture(autouse=True)
def stub_default_branch_lookup(self):
"""Keep tests independent of live remote lookups unless explicitly mocked."""
with patch(
"devflow.utils.temp_directory.GitUtils.get_default_branch",
return_value=None,
) as mock_get_default_branch:
yield mock_get_default_branch

def test_returns_none_in_mock_mode(self, mock_git_repo):
"""Test that function returns None when in mock mode."""
with patch("devflow.utils.temp_directory.is_mock_mode", return_value=True):
Expand Down Expand Up @@ -538,45 +547,148 @@ def test_returns_none_when_no_branches(self, mock_git_repo):

def test_prefers_upstream_over_origin(self, mock_git_repo):
"""Test that function prefers upstream remote over origin."""
with patch("devflow.utils.temp_directory.is_mock_mode", return_value=False), \
patch("devflow.utils.temp_directory.is_json_mode", return_value=False), \
patch("devflow.utils.temp_directory.GitUtils.get_remote_names", return_value=["origin", "upstream"]), \
patch("devflow.utils.temp_directory.GitUtils.list_remote_branches", return_value=["main", "develop"]) as mock_list_branches, \
patch("devflow.utils.temp_directory.Prompt.ask", return_value="1"):
with (
patch("devflow.utils.temp_directory.is_mock_mode", return_value=False),
patch("devflow.utils.temp_directory.is_json_mode", return_value=False),
patch(
"devflow.utils.temp_directory.GitUtils.get_remote_names",
return_value=["origin", "upstream"],
),
patch(
"devflow.utils.temp_directory.GitUtils.list_remote_branches",
return_value=["main", "develop"],
) as mock_list_branches,
patch(
"devflow.utils.temp_directory.GitUtils.get_default_branch",
return_value="develop",
) as mock_default_branch,
patch("devflow.utils.temp_directory.Prompt.ask", return_value="2"),
):
result = _prompt_for_branch_selection(mock_git_repo)
mock_list_branches.assert_called_once_with(mock_git_repo, "upstream")
assert result == "main"
mock_default_branch.assert_called_once_with(
mock_git_repo, remote="upstream"
)
assert result == "develop"

def test_uses_origin_when_upstream_not_available(self, mock_git_repo):
"""Test that function uses origin when upstream is not available."""
with patch("devflow.utils.temp_directory.is_mock_mode", return_value=False), \
patch("devflow.utils.temp_directory.is_json_mode", return_value=False), \
patch("devflow.utils.temp_directory.GitUtils.get_remote_names", return_value=["origin"]), \
patch("devflow.utils.temp_directory.GitUtils.list_remote_branches", return_value=["main", "develop"]) as mock_list_branches, \
patch("devflow.utils.temp_directory.Prompt.ask", return_value="1"):
with (
patch("devflow.utils.temp_directory.is_mock_mode", return_value=False),
patch("devflow.utils.temp_directory.is_json_mode", return_value=False),
patch(
"devflow.utils.temp_directory.GitUtils.get_remote_names",
return_value=["origin"],
),
patch(
"devflow.utils.temp_directory.GitUtils.list_remote_branches",
return_value=["main", "develop"],
) as mock_list_branches,
patch(
"devflow.utils.temp_directory.GitUtils.get_default_branch",
return_value="main",
) as mock_default_branch,
patch("devflow.utils.temp_directory.Prompt.ask", return_value="1"),
):
result = _prompt_for_branch_selection(mock_git_repo)
mock_list_branches.assert_called_once_with(mock_git_repo, "origin")
mock_default_branch.assert_called_once_with(mock_git_repo, remote="origin")
assert result == "main"

def test_selects_main_as_default_over_master(self, mock_git_repo):
"""Test that main is preferred over master as default branch."""
with patch("devflow.utils.temp_directory.is_mock_mode", return_value=False), \
patch("devflow.utils.temp_directory.is_json_mode", return_value=False), \
patch("devflow.utils.temp_directory.GitUtils.get_remote_names", return_value=["origin"]), \
patch("devflow.utils.temp_directory.GitUtils.list_remote_branches", return_value=["develop", "main", "master"]), \
patch("devflow.utils.temp_directory.Prompt.ask", return_value="1"):
def test_uses_non_main_remote_default_at_its_menu_position(self, mock_git_repo):
"""Use the actual default branch and make Enter select its listed position."""
with (
patch("devflow.utils.temp_directory.is_mock_mode", return_value=False),
patch("devflow.utils.temp_directory.is_json_mode", return_value=False),
patch(
"devflow.utils.temp_directory.GitUtils.get_remote_names",
return_value=["origin"],
),
patch(
"devflow.utils.temp_directory.GitUtils.list_remote_branches",
return_value=["main", "develop", "feature/test"],
),
patch(
"devflow.utils.temp_directory.GitUtils.get_default_branch",
return_value="develop",
),
patch(
"devflow.utils.temp_directory.Prompt.ask",
side_effect=lambda *_args, **kwargs: kwargs["default"],
) as mock_prompt,
patch("devflow.utils.temp_directory.console_print") as mock_console_print,
):
result = _prompt_for_branch_selection(mock_git_repo)
assert result == "develop"

def test_selects_master_as_default_when_no_main(self, mock_git_repo):
"""Test that master is used as default when main is not available."""
with patch("devflow.utils.temp_directory.is_mock_mode", return_value=False), \
patch("devflow.utils.temp_directory.is_json_mode", return_value=False), \
patch("devflow.utils.temp_directory.GitUtils.get_remote_names", return_value=["origin"]), \
patch("devflow.utils.temp_directory.GitUtils.list_remote_branches", return_value=["develop", "master", "release/2.5"]), \
patch("devflow.utils.temp_directory.Prompt.ask", return_value="1"):
assert mock_prompt.call_args.kwargs["default"] == "2"
assert mock_prompt.call_args.kwargs["show_default"] is True
assert any(
"develop [cyan](default - from origin)" in str(call.args[0])
for call in mock_console_print.call_args_list
)

def test_unresolved_default_requires_explicit_branch_selection(self, mock_git_repo):
"""Do not silently default to the first listed branch when remote HEAD is unknown."""
with (
patch("devflow.utils.temp_directory.is_mock_mode", return_value=False),
patch("devflow.utils.temp_directory.is_json_mode", return_value=False),
patch(
"devflow.utils.temp_directory.GitUtils.get_remote_names",
return_value=["origin"],
),
patch(
"devflow.utils.temp_directory.GitUtils.list_remote_branches",
return_value=["main", "develop"],
),
patch(
"devflow.utils.temp_directory.GitUtils.get_default_branch",
return_value=None,
),
patch(
"devflow.utils.temp_directory.Prompt.ask", side_effect=["", "2"]
) as mock_prompt,
patch("devflow.utils.temp_directory.console_print") as mock_console_print,
):
result = _prompt_for_branch_selection(mock_git_repo)
assert result == "develop"
assert mock_prompt.call_count == 2
assert all(call.kwargs["default"] is None for call in mock_prompt.call_args_list)
assert not any(
"(default - from origin)" in str(call.args[0])
for call in mock_console_print.call_args_list
)

def test_unavailable_remote_default_is_not_presented_as_default(
self, mock_git_repo
):
"""An actual default missing from the listed branches is not substituted."""
with (
patch("devflow.utils.temp_directory.is_mock_mode", return_value=False),
patch("devflow.utils.temp_directory.is_json_mode", return_value=False),
patch(
"devflow.utils.temp_directory.GitUtils.get_remote_names",
return_value=["origin"],
),
patch(
"devflow.utils.temp_directory.GitUtils.list_remote_branches",
return_value=["main", "develop"],
),
patch(
"devflow.utils.temp_directory.GitUtils.get_default_branch",
return_value="trunk",
),
patch(
"devflow.utils.temp_directory.Prompt.ask", return_value="2"
) as mock_prompt,
patch("devflow.utils.temp_directory.console_print") as mock_console_print,
):
result = _prompt_for_branch_selection(mock_git_repo)
assert result == "develop"
assert mock_prompt.call_args.kwargs["default"] is None
assert not any(
"(default - from origin)" in str(call.args[0])
for call in mock_console_print.call_args_list
)

def test_user_can_select_branch_by_number(self, mock_git_repo):
"""Test that user can select a branch by entering its number."""
Expand Down Expand Up @@ -624,6 +736,7 @@ def test_cancel_returns_default(self, mock_git_repo):
patch("devflow.utils.temp_directory.is_json_mode", return_value=False), \
patch("devflow.utils.temp_directory.GitUtils.get_remote_names", return_value=["origin"]), \
patch("devflow.utils.temp_directory.GitUtils.list_remote_branches", return_value=["develop", "main", "release/2.5"]), \
patch("devflow.utils.temp_directory.GitUtils.get_default_branch", return_value="main"), \
patch("devflow.utils.temp_directory.Prompt.ask", return_value="cancel"):
result = _prompt_for_branch_selection(mock_git_repo)
assert result == "main"
Expand All @@ -634,6 +747,7 @@ def test_q_returns_default(self, mock_git_repo):
patch("devflow.utils.temp_directory.is_json_mode", return_value=False), \
patch("devflow.utils.temp_directory.GitUtils.get_remote_names", return_value=["origin"]), \
patch("devflow.utils.temp_directory.GitUtils.list_remote_branches", return_value=["develop", "main", "release/2.5"]), \
patch("devflow.utils.temp_directory.GitUtils.get_default_branch", return_value="main"), \
patch("devflow.utils.temp_directory.Prompt.ask", return_value="q"):
result = _prompt_for_branch_selection(mock_git_repo)
assert result == "main"
Expand All @@ -644,6 +758,7 @@ def test_multiple_retries_then_cancel(self, mock_git_repo):
patch("devflow.utils.temp_directory.is_json_mode", return_value=False), \
patch("devflow.utils.temp_directory.GitUtils.get_remote_names", return_value=["origin"]), \
patch("devflow.utils.temp_directory.GitUtils.list_remote_branches", return_value=["develop", "main", "release/2.5"]), \
patch("devflow.utils.temp_directory.GitUtils.get_default_branch", return_value="main"), \
patch("devflow.utils.temp_directory.Prompt.ask", side_effect=["invalid1", "99", "cancel"]):
result = _prompt_for_branch_selection(mock_git_repo)
assert result == "main"
Expand All @@ -654,6 +769,7 @@ def test_keyboard_interrupt_returns_default(self, mock_git_repo):
patch("devflow.utils.temp_directory.is_json_mode", return_value=False), \
patch("devflow.utils.temp_directory.GitUtils.get_remote_names", return_value=["origin"]), \
patch("devflow.utils.temp_directory.GitUtils.list_remote_branches", return_value=["main", "develop"]), \
patch("devflow.utils.temp_directory.GitUtils.get_default_branch", return_value="main"), \
patch("devflow.utils.temp_directory.Prompt.ask", side_effect=KeyboardInterrupt()):
result = _prompt_for_branch_selection(mock_git_repo)
assert result == "main"
Expand All @@ -664,7 +780,8 @@ def test_empty_input_uses_default(self, mock_git_repo):
patch("devflow.utils.temp_directory.is_json_mode", return_value=False), \
patch("devflow.utils.temp_directory.GitUtils.get_remote_names", return_value=["origin"]), \
patch("devflow.utils.temp_directory.GitUtils.list_remote_branches", return_value=["main", "develop", "release/2.5"]), \
patch("devflow.utils.temp_directory.Prompt.ask", return_value="1"):
patch("devflow.utils.temp_directory.GitUtils.get_default_branch", return_value="main"), \
patch("devflow.utils.temp_directory.Prompt.ask", return_value=""):
result = _prompt_for_branch_selection(mock_git_repo)
assert result == "main"

Expand Down
Loading