fix: reject checkout shorthand when creating branches (#4843)

* fix: reject branch shorthand during validation

* test: add regression coverage for branch-ref validation

Cover github_create_branch: rejects checkout shorthand (@{-1}) via literal
refs/heads/<name> validation, accepts ordinary slash names, and never uses
--branch (which would expand shorthand before checkout -B).

Co-authored-by: Mervin Praison <[email protected]>

* fix: clear rejected branch from sticky comment

* fix: preserve sticky branch after rejected creation

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-09-05 22:52:41 +01:00
1 parent 5eb979318f
commit bae2b6474f
4 files changed
+104 -2

No files matched your search

@@ -136,7 +136,7 @@ def github_create_branch(branch_name: str) -> str:
# Validate branch name to prevent misinterpretation as git options or invalid refs
try:
subprocess.run(
["git", "check-ref-format", "--branch", branch_name],
["git", "check-ref-format", f"refs/heads/{branch_name}"],
check=True, capture_output=True, text=True
)
except subprocess.CalledProcessError as e:
@@ -255,6 +255,53 @@ class TestEnhancedStickyComment:
# File should be clickable link
assert "https://github.com/MervinPraison/PraisonAI/blob/feature/test/src/main.py" in body
def test_failed_branch_creation_clears_sticky_branch(self):
"""A rejected branch must not remain rendered as active."""
from praisonai.cli.commands.github import StickyComment
sticky = StickyComment(
api_base="https://api.github.com",
issue=112,
token="fake",
task_type="Issue",
title="Test",
run_url="",
repo_name="owner/repo",
)
sticky.set_branch("@{-1}")
sticky.on_tool_end("agent", "github_create_branch", "Error: invalid branch name '@{-1}'")
assert sticky._branch_name is None
assert "@{-1}" not in sticky._build_body()
def test_failed_branch_creation_restores_prior_sticky_branch_and_refreshes(self, monkeypatch):
"""A failed later creation restores the displayed active branch."""
from praisonai.cli.commands.github import StickyComment
sticky = StickyComment(
api_base="https://api.github.com",
issue=113,
token="fake",
task_type="Issue",
title="Test",
run_url="",
repo_name="owner/repo",
)
sticky.set_branch("feature/active")
sticky.on_tool_start("agent", "github_create_branch", {"branch_name": "@{-1}"})
sticky.set_branch("@{-1}")
refreshes = []
monkeypatch.setattr(sticky, "_schedule_push", lambda: refreshes.append(True))
sticky.on_tool_end("agent", "github_create_branch", "Error: invalid branch name '@{-1}'")
assert sticky._branch_name == "feature/active"
body = sticky._build_body()
assert "[🌿 `feature/active`]" in body
assert "[🌿 `@{-1}`]" not in body
assert refreshes == [True]
@pytest.mark.manual
class TestEnhancedIntegration:
@@ -215,3 +215,44 @@ def test_slugify_and_auto_branch():
name = github_tools._auto_branch_name("Fix parser")
assert name.startswith("praisonai/fix-parser-")
assert github_tools._branch_push_safety("praisonai/anything") is None
def test_create_branch_validates_full_ref(monkeypatch):
# Validation must check the literal ``refs/heads/<name>`` ref so Git cannot
# expand checkout shorthand (e.g. ``@{-1}``) before ``checkout -B`` runs.
responses = {
("rev-parse", "--is-inside-work-tree"): ("true\n", 0),
}
calls = _call(monkeypatch, responses)
_run_tool(github_tools.github_create_branch, "praisonai/fix")
ref_checks = [c for c in calls if c[:2] == ["git", "check-ref-format"]]
assert ref_checks, "expected a check-ref-format validation"
for c in ref_checks:
assert "--branch" not in c, "must not use --branch (expands shorthand)"
assert "refs/heads/praisonai/fix" in c
def test_create_branch_rejects_checkout_shorthand(monkeypatch):
# ``@{-1}`` is a valid checkout shorthand but an invalid full ref, so it must
# be rejected before any checkout runs (no destructive branch reset).
responses = {
("rev-parse", "--is-inside-work-tree"): ("true\n", 0),
("check-ref-format", "refs/heads/@{-1}"): ("", 1),
}
calls = _call(monkeypatch, responses)
result = _run_tool(github_tools.github_create_branch, "@{-1}")
assert "invalid branch name" in result
assert not [c for c in calls if c[:2] == ["git", "checkout"]]
def test_create_branch_accepts_slash_names(monkeypatch):
# Ordinary slash-containing branch names must still be created.
responses = {
("rev-parse", "--is-inside-work-tree"): ("true\n", 0),
}
calls = _call(monkeypatch, responses)
result = _run_tool(github_tools.github_create_branch, "feature/new-thing")
assert "Successfully" in result
checkouts = [c for c in calls if c[:2] == ["git", "checkout"]]
assert checkouts, "expected a checkout for a valid branch name"
assert ["git", "checkout", "-B", "feature/new-thing"] in checkouts
@@ -73,6 +73,7 @@ class StickyComment:
# Branch and PR tracking
self._branch_name: Optional[str] = None
self._branch_before_create: Optional[str] = None
self._pr_url: Optional[str] = None
self._pr_number: Optional[int] = None
self._summary_lines: List[str] = []
@@ -161,6 +162,8 @@ class StickyComment:
def on_tool_start(self, agent_name: str, tool_name: str, tool_args: dict) -> None:
with self._lock:
self._current_tool = tool_name
if tool_name == "github_create_branch":
self._branch_before_create = self._branch_name
label = self._tool_label(tool_name, tool_args)
self._logs.append(f"🔧 {label}")
# Extract file modifications from commands
@@ -169,12 +172,23 @@ class StickyComment:
self._schedule_push()
def on_tool_end(self, agent_name: str, tool_name: str, result: Any = None) -> None:
refresh = False
with self._lock:
self._current_tool = None
# A branch name is captured eagerly at tool start so the live
# comment can show the operation immediately. Do not leave a
# rejected branch rendered as active after validation or checkout
# fails.
if tool_name == "github_create_branch" and str(result).startswith("Error"):
self._branch_name = self._branch_before_create
refresh = True
if tool_name == "github_create_branch":
self._branch_before_create = None
# Extract features from tool results
if result and isinstance(result, str):
self._extract_features_from_output(result)
# no extra log for tool end – keeps comment clean
if refresh:
self._schedule_push()
def _extract_file_from_command(self, command: str) -> None:
"""Extract file paths from shell commands to track modifications."""