Skip to content
Open
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
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ This roadmap outlines upcoming features and priorities for the gibr CLI.
- [ ] Support for Forgejo (#44)

## 🚧 Planned features
- [ ] Support dry run flag (#39)
- [x] Support dry run flag (#39)
- [ ] Issue types derived from labels (#38)

## 🔮 Future
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,8 @@ omit = [
"tests/*"
]

[dependency-groups]
dev = [
"pytest-cov>=7.0.0",
]

11 changes: 9 additions & 2 deletions src/gibr/cli/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@

@click.command("create")
@click.argument("issue_number")
@click.option(
"--dry-run",
"dry_run",
is_flag=True,
default=False,
help="Show what would happen without creating or pushing the branch.",
)
@click.pass_context
def create(ctx, issue_number):
def create(ctx, issue_number, dry_run):
"""Generate a branch based on the issue number provided."""
config = ctx.obj["config"]
tracker = ctx.obj["tracker"]
Expand All @@ -33,4 +40,4 @@ def create(ctx, issue_number):
click.echo(f"Branch name: {branch_name}")
is_push = config.config["DEFAULT"].get("push", "true")
is_push = str(is_push).lower() in ("true", "1", "yes", "on")
create_and_push_branch(branch_name, is_push)
create_and_push_branch(branch_name, is_push, dry_run)
97 changes: 69 additions & 28 deletions src/gibr/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,47 +8,88 @@
from gibr.notify import error, info, success, warning


def create_and_push_branch(branch_name: str, is_push: str = True) -> None:
def _validate_repo_state(repo: Repo) -> bool:
"""Validate repository state and emit warnings/errors.

Returns True if repo is valid to proceed, False otherwise.
"""
if repo.is_dirty(untracked_files=False):
warning("Working tree is dirty — uncommitted changes present.")

if not repo.head.is_valid():
error("Please make an initial commit before using gibr.")
return False

if repo.head.is_detached:
warning("HEAD is detached (not on a branch).")

return True


def _handle_existing_branch(repo: Repo, branch_name: str, dry_run: bool) -> str | None:
"""Handle case when branch already exists.

Returns the branch name to use (possibly with suffix), or None to cancel.
"""
current_branch = repo.active_branch.name

if current_branch == branch_name:
warning(f"Branch '{branch_name}' already exists and is checked out")
return None

warning(f"Branch '{branch_name}' already exists locally.")

if dry_run:
info(
f"[DRY RUN] Would prompt to create branch with suffix "
f"since '{branch_name}' exists."
)
return None

if click.confirm(
"Would you like to create a new branch with a suffix?", default=True
):
suffix = click.prompt("Enter suffix", default="take2", show_default=True)
new_name = f"{branch_name}-{suffix}"
info(f"Creating new branch '{new_name}' instead.")
return new_name

info("Operation canceled by user.")
return None


def create_and_push_branch(
branch_name: str, is_push: bool = True, dry_run: bool = False
) -> None:
"""Create a new branch and push it to origin."""
try:
repo = Repo(".", search_parent_directories=True)
if repo.is_dirty(untracked_files=False):
warning("Working tree is dirty — uncommitted changes present.")

# Handle repo with no commits yet (no HEAD)
if not repo.head.is_valid():
error("Please make an initial commit before using gibr.")
if not _validate_repo_state(repo):
repo.close()
return

# Handle detached HEAD (e.g. checkout of specific commit)
if repo.head.is_detached:
warning("HEAD is detached (not on a branch).")

# Determine current branch
current_branch = repo.active_branch.name
logging.debug(f"Current branch: {current_branch}")

# Check if branch already exists locally
if branch_name in repo.heads:
if current_branch == branch_name:
warning(f"Branch '{branch_name}' already exists and is checked out")
result = _handle_existing_branch(repo, branch_name, dry_run)
if result is None:
repo.close()
return
else:
warning(f"Branch '{branch_name}' already exists locally.")
# Ask user what to do
if click.confirm(
"Would you like to create a new branch with a suffix?", default=True
):
suffix = click.prompt(
"Enter suffix", default="take2", show_default=True
)
branch_name = f"{branch_name}-{suffix}"
info(f"Creating new branch '{branch_name}' instead.")
else:
info("Operation canceled by user.")
repo.close()
return
branch_name = result

if dry_run:
info(
f"[DRY RUN] Would create branch '{branch_name}' from {current_branch}."
)
info(f"[DRY RUN] Would checkout branch: {branch_name}")
if is_push:
info(f"[DRY RUN] Would push branch '{branch_name}' to origin.")
repo.close()
return

# Create new branch from current HEAD
new_branch = repo.create_head(branch_name)
success(f"Created branch '{branch_name}' from {current_branch}.")
Expand Down
58 changes: 58 additions & 0 deletions tests/test_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,61 @@ def test_create_with_missing_assignee_but_no_assignee_in_format(mock_echo, mock_
assert result.exit_code == 0
mock_branch.assert_called_once()
mock_echo.assert_any_call("Generating branch name for issue #456: Add dark mode")


@patch("gibr.cli.create.create_and_push_branch")
@patch("gibr.cli.create.click.echo")
def test_create_with_dry_run_flag(mock_echo, mock_branch):
"""Test that --dry-run flag passes dry_run=True to create_and_push_branch."""
mock_config = MagicMock()
mock_config.config = {
"DEFAULT": {"branch_name_format": "{issue}-{title}", "push": "true"}
}

mock_issue = MagicMock(id=789, title="Test dry run", assignee=None)
mock_tracker = MagicMock()
mock_tracker.numeric_issues = True
mock_tracker.get_issue.return_value = mock_issue

runner = CliRunner()
with runner.isolated_filesystem():
result = runner.invoke(
create,
["789", "--dry-run"],
obj={"config": mock_config, "tracker": mock_tracker},
)

assert result.exit_code == 0
mock_branch.assert_called_once()
# Verify dry_run=True was passed (third argument)
call_args = mock_branch.call_args
assert call_args[0][2] is True # dry_run is the third positional argument


@patch("gibr.cli.create.create_and_push_branch")
@patch("gibr.cli.create.click.echo")
def test_create_without_dry_run_flag(mock_echo, mock_branch):
"""Test that dry_run=False is passed when --dry-run flag is omitted."""
mock_config = MagicMock()
mock_config.config = {
"DEFAULT": {"branch_name_format": "{issue}-{title}", "push": "true"}
}

mock_issue = MagicMock(id=101, title="Test no dry run", assignee=None)
mock_tracker = MagicMock()
mock_tracker.numeric_issues = True
mock_tracker.get_issue.return_value = mock_issue

runner = CliRunner()
with runner.isolated_filesystem():
result = runner.invoke(
create,
["101"],
obj={"config": mock_config, "tracker": mock_tracker},
)

assert result.exit_code == 0
mock_branch.assert_called_once()
# Verify dry_run=False was passed (third argument)
call_args = mock_branch.call_args
assert call_args[0][2] is False # dry_run is the third positional argument
Loading