Skip to content

Commit 3eb5433

Browse files
mtorplelia
andauthored
Detect non-main default branches in single-branch CI checkouts (#383)
* Detect non-main default branches in single-branch CI checkouts actions/checkout fetches one branch and leaves no origin/HEAD. Since 2.6.6 dropped the git fetch --all that recreated origin/HEAD, default-branch detection fell back to main/master, so scans on repos whose default branch is dev never became the branch head. When origin/HEAD is missing, read the default branch from the GitHub event payload, then from git ls-remote --symref origin HEAD, before the main/master fallback. * Bound the remote default-branch lookup on every platform GitPython's kill_after_timeout is rejected on Windows and relies on ps --ppid, which macOS lacks. It also leaves git-remote-https holding the output pipe after the parent dies, so a stalled remote blocked startup past the timeout. Run git ls-remote in its own process group and kill the whole group on timeout, with taskkill /T on Windows. * Read CI default-branch variables in the shared lookup GitLab's CI_DEFAULT_BRANCH and Buildkite's BUILDKITE_PIPELINE_DEFAULT_BRANCH only fed the final branch comparison. The commit-on-default check still went to the remote, so single-branch checkouts on those CIs paid for a git ls-remote call even when CI already knew the answer. Both variables now come first in get_default_branch_name. Also simplifies the remote lookup. start_new_session works on every platform because Windows ignores it and taskkill walks the tree by PID. The stalled-remote fixture is now a listener that never accepts, and the test clears proxy variables so it really waits for the timeout. * Avoid remote default-branch lookup for PR builds --------- Co-authored-by: lelia <2418071+lelia@users.noreply.github.com>
1 parent 8bf6f3b commit 3eb5433

6 files changed

Lines changed: 317 additions & 58 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Changelog
22

3+
## 2.10.6
4+
5+
### Fixed: default-branch detection in single-branch CI checkouts
6+
7+
- Repositories whose default branch isn't `main` or `master` are detected as the
8+
default branch again when the checkout has no `origin/HEAD`, as with
9+
`actions/checkout`. The CLI reads the default branch from the GitHub event
10+
payload or asks the remote, before falling back to `main`/`master`. Scans on
11+
those branches become the branch head again.
12+
- GitLab's `CI_DEFAULT_BRANCH` and Buildkite's `BUILDKITE_PIPELINE_DEFAULT_BRANCH`
13+
now apply to every default-branch check, including whether the commit is on
14+
the default branch.
15+
316
## 2.10.5
417

518
### Changed: bump pinned @coana-tech/cli to 15.11.4

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
66

77
[project]
88
name = "socketsecurity"
9-
version = "2.10.5"
9+
version = "2.10.6"
1010
requires-python = ">= 3.11"
1111
license = {"file" = "LICENSE"}
1212
dependencies = [

‎socketsecurity/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__author__ = 'socket.dev'
2-
__version__ = '2.10.5'
2+
__version__ = '2.10.6'
33
USER_AGENT = f'SocketPythonCLI/{__version__}'

‎socketsecurity/core/git_interface.py‎

Lines changed: 147 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
1+
import json
12
import os
23
import re
4+
import signal
5+
import subprocess
36
import time
47
import urllib.parse
58

69
from git import Repo
710

811
from socketsecurity.core import log
912

13+
REMOTE_HEAD_TIMEOUT_SECONDS = 30
14+
IS_WINDOWS = os.name == "nt"
15+
1016

1117
class Git:
1218
repo: Repo
@@ -27,6 +33,7 @@ def __init__(self, path: str, base_commit_sha: str | None = None):
2733
self.path = path
2834
self.base_commit_sha = base_commit_sha
2935
self._fetched_ref_commits = {}
36+
self._default_branch_name: str | None = None
3037
self.ensure_safe_directory(path)
3138
self.repo = Repo(path)
3239
assert self.repo
@@ -407,31 +414,46 @@ def _is_commit_and_branch_default(self) -> bool:
407414
True if commit is on default branch and we're processing the default branch
408415
"""
409416
try:
410-
# First check if the commit is reachable from the default branch
411-
if not self.is_commit_on_default_branch():
412-
log.debug("Commit is not on default branch")
413-
return False
414-
415-
# Check if we're processing the default branch via CI environment variables
416417
github_ref = os.getenv('GITHUB_REF') # e.g., 'refs/heads/main' or 'refs/pull/123/merge'
417418
gitlab_branch = os.getenv('CI_COMMIT_BRANCH')
418419
gitlab_mr_branch = os.getenv('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME')
419-
gitlab_default_branch = os.getenv('CI_DEFAULT_BRANCH', '')
420420
bitbucket_branch = os.getenv('BITBUCKET_BRANCH')
421+
bitbucket_pr = os.getenv('BITBUCKET_PR_ID')
421422
buildkite_branch = os.getenv('BUILDKITE_BRANCH')
422423
buildkite_pr = os.getenv('BUILDKITE_PULL_REQUEST')
423-
buildkite_default_branch = os.getenv('BUILDKITE_PIPELINE_DEFAULT_BRANCH')
424-
425-
# Handle Buildkite before GitHub because some Buildkite pipelines
426-
# intentionally provide GitHub-compatible environment variables.
424+
425+
# PR and non-branch builds cannot become the default branch head.
426+
# Decide that locally before default-branch lookup contacts origin.
427427
if buildkite_branch:
428428
if self._is_buildkite_pull_request(buildkite_pr):
429429
log.debug(
430430
f"Processing Buildkite pull request from branch: {buildkite_branch}, "
431431
"not default branch"
432432
)
433433
return False
434-
default_branch_name = buildkite_default_branch or self.get_default_branch_name()
434+
elif github_ref:
435+
if github_ref.startswith('refs/pull/'):
436+
log.debug("Processing a pull request, not default branch")
437+
return False
438+
if not github_ref.startswith('refs/heads/'):
439+
log.debug(f"Non-branch ref: {github_ref}, not default branch")
440+
return False
441+
elif gitlab_branch or gitlab_mr_branch:
442+
if gitlab_mr_branch:
443+
log.debug(f"Processing GitLab MR from branch: {gitlab_mr_branch}, not default branch")
444+
return False
445+
elif bitbucket_branch and bitbucket_pr:
446+
log.debug(f"Processing Bitbucket pull request from branch: {bitbucket_branch}, not default branch")
447+
return False
448+
449+
if not self.is_commit_on_default_branch():
450+
log.debug("Commit is not on default branch")
451+
return False
452+
453+
# Handle Buildkite before GitHub because some Buildkite pipelines
454+
# intentionally provide GitHub-compatible environment variables.
455+
if buildkite_branch:
456+
default_branch_name = self.get_default_branch_name()
435457
is_default = buildkite_branch == default_branch_name
436458
log.debug(
437459
f"Buildkite branch: {buildkite_branch}, Default: {default_branch_name}, "
@@ -442,35 +464,16 @@ def _is_commit_and_branch_default(self) -> bool:
442464
# Handle GitHub Actions
443465
elif github_ref:
444466
log.debug(f"GitHub ref: {github_ref}")
445-
446-
# Handle pull requests - they're not on the default branch
447-
if github_ref.startswith('refs/pull/'):
448-
log.debug("Processing a pull request, not default branch")
449-
return False
450-
451-
# Handle regular branch pushes
452-
if github_ref.startswith('refs/heads/'):
453-
branch_from_ref = github_ref.replace('refs/heads/', '')
454-
default_branch_name = self.get_default_branch_name()
455-
is_default = branch_from_ref == default_branch_name
456-
log.debug(f"Branch from GITHUB_REF: {branch_from_ref}, Default: {default_branch_name}, Is default: {is_default}")
457-
return is_default
458-
459-
# Handle tags or other refs - not default branch
460-
log.debug(f"Non-branch ref: {github_ref}, not default branch")
461-
return False
467+
branch_from_ref = github_ref.removeprefix('refs/heads/')
468+
default_branch_name = self.get_default_branch_name()
469+
is_default = branch_from_ref == default_branch_name
470+
log.debug(f"Branch from GITHUB_REF: {branch_from_ref}, Default: {default_branch_name}, Is default: {is_default}")
471+
return is_default
462472

463473
# Handle GitLab CI
464474
elif gitlab_branch or gitlab_mr_branch:
465-
# If this is a merge request, use the source branch
466475
current_branch = gitlab_mr_branch or gitlab_branch
467-
default_branch_name = gitlab_default_branch or self.get_default_branch_name()
468-
469-
# For merge requests, they're typically not considered "default branch"
470-
if gitlab_mr_branch:
471-
log.debug(f"Processing GitLab MR from branch: {gitlab_mr_branch}, not default branch")
472-
return False
473-
476+
default_branch_name = self.get_default_branch_name()
474477
is_default = current_branch == default_branch_name
475478
log.debug(f"GitLab branch: {current_branch}, Default: {default_branch_name}, Is default: {is_default}")
476479
return is_default
@@ -638,27 +641,116 @@ def get_default_branch_name(self) -> str:
638641
Returns:
639642
Default branch name (e.g., 'main', 'master')
640643
"""
644+
if self._default_branch_name is None:
645+
self._default_branch_name = self._detect_default_branch_name()
646+
return self._default_branch_name
647+
648+
def _detect_default_branch_name(self) -> str:
649+
for variable in ('CI_DEFAULT_BRANCH', 'BUILDKITE_PIPELINE_DEFAULT_BRANCH'):
650+
default_branch = os.getenv(variable)
651+
if default_branch:
652+
log.debug(f"Default branch detected from {variable}: {default_branch}")
653+
return default_branch
654+
641655
try:
642-
# Try to get the default branch from remote HEAD
643-
remote_head = self.repo.remotes.origin.refs.HEAD
644-
# Extract branch name from refs/remotes/origin/HEAD -> refs/remotes/origin/main
645-
default_branch = str(remote_head.reference).split('/')[-1]
646-
log.debug(f"Default branch detected: {default_branch}")
656+
default_branch = self.repo.remotes.origin.refs.HEAD.reference.remote_head
657+
log.debug(f"Default branch detected from origin/HEAD: {default_branch}")
647658
return default_branch
648659
except Exception as error:
649-
log.debug(f"Could not determine default branch from remote: {error}")
650-
# Fallback: check common default branch names
651-
for branch_name in ['main', 'master']:
652-
try:
653-
if f'origin/{branch_name}' in [str(ref) for ref in self.repo.remotes.origin.refs]:
654-
log.debug(f"Using fallback default branch: {branch_name}")
655-
return branch_name
656-
except Exception:
657-
continue
658-
659-
# Last fallback: assume 'main'
660-
log.debug("Using final fallback default branch: main")
661-
return 'main'
660+
log.debug(f"Could not determine default branch from origin/HEAD: {error}")
661+
662+
# CI checkouts such as actions/checkout fetch a single branch and leave no origin/HEAD.
663+
default_branch = (
664+
self._default_branch_from_github_event()
665+
or self._default_branch_from_remote()
666+
)
667+
if default_branch:
668+
return default_branch
669+
670+
try:
671+
remote_refs = {str(ref) for ref in self.repo.remotes.origin.refs}
672+
except Exception:
673+
remote_refs = set()
674+
for branch_name in ['main', 'master']:
675+
if f'origin/{branch_name}' in remote_refs:
676+
log.debug(f"Using fallback default branch: {branch_name}")
677+
return branch_name
678+
679+
log.debug("Using final fallback default branch: main")
680+
return 'main'
681+
682+
@staticmethod
683+
def _default_branch_from_github_event() -> str | None:
684+
event_path = os.getenv('GITHUB_EVENT_PATH')
685+
if not event_path:
686+
return None
687+
try:
688+
with open(event_path, encoding="utf-8") as event_file:
689+
default_branch = json.load(event_file).get("repository", {}).get("default_branch")
690+
except Exception as error:
691+
log.debug(f"Could not read default branch from GitHub event payload: {error}")
692+
return None
693+
if default_branch:
694+
log.debug(f"Default branch detected from GitHub event payload: {default_branch}")
695+
return default_branch or None
696+
697+
def _default_branch_from_remote(self) -> str | None:
698+
# A new session lets a timeout also kill the remote helpers, which otherwise
699+
# hold stdout open and keep communicate() blocked. Windows ignores it.
700+
try:
701+
process = subprocess.Popen(
702+
["git", "ls-remote", "--symref", "origin", "HEAD"],
703+
cwd=self.repo.working_dir,
704+
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
705+
stdin=subprocess.DEVNULL,
706+
stdout=subprocess.PIPE,
707+
stderr=subprocess.DEVNULL,
708+
text=True,
709+
start_new_session=True,
710+
)
711+
except Exception as error:
712+
log.debug(f"Could not query origin for its default branch: {error}")
713+
return None
714+
try:
715+
output, _ = process.communicate(timeout=REMOTE_HEAD_TIMEOUT_SECONDS)
716+
except subprocess.TimeoutExpired:
717+
self._kill_process_tree(process)
718+
log.debug(
719+
f"Querying origin for its default branch timed out after "
720+
f"{REMOTE_HEAD_TIMEOUT_SECONDS}s"
721+
)
722+
return None
723+
if process.returncode != 0:
724+
log.debug(f"Querying origin for its default branch exited with {process.returncode}")
725+
return None
726+
for line in output.splitlines():
727+
match = re.match(r"ref: refs/heads/(\S+)\tHEAD$", line)
728+
if match:
729+
log.debug(f"Default branch detected from origin: {match.group(1)}")
730+
return match.group(1)
731+
return None
732+
733+
@staticmethod
734+
def _kill_process_tree(process: subprocess.Popen) -> None:
735+
try:
736+
if IS_WINDOWS:
737+
subprocess.run(
738+
["taskkill", "/F", "/T", "/PID", str(process.pid)],
739+
stdout=subprocess.DEVNULL,
740+
stderr=subprocess.DEVNULL,
741+
timeout=10,
742+
)
743+
else:
744+
os.killpg(process.pid, signal.SIGKILL)
745+
except Exception as error:
746+
log.debug(f"Failed to stop git ls-remote process tree: {error}")
747+
process.kill()
748+
if process.stdout:
749+
process.stdout.close()
750+
try:
751+
process.wait(timeout=5)
752+
except subprocess.TimeoutExpired:
753+
log.debug("git ls-remote did not exit after being killed")
662754

663755
def is_commit_on_default_branch(self) -> bool:
664756
"""

0 commit comments

Comments
 (0)