1+ import json
12import os
23import re
4+ import signal
5+ import subprocess
36import time
47import urllib .parse
58
69from git import Repo
710
811from socketsecurity .core import log
912
13+ REMOTE_HEAD_TIMEOUT_SECONDS = 30
14+ IS_WINDOWS = os .name == "nt"
15+
1016
1117class 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