@@ -480,6 +480,131 @@ def validate_clean_git_tree(project_path: Path) -> tuple[bool, str]:
480480 return False , f"Error checking git status: { e } "
481481
482482
483+ def _format_commits_as_changelog (
484+ commits : list [tuple [str , str ]], github_url : str
485+ ) -> str :
486+ """
487+ Format a list of (hash, message) commits as a categorized changelog.
488+
489+ Args:
490+ commits: List of (commit_hash, message) tuples
491+ github_url: Base GitHub URL for commit links
492+
493+ Returns:
494+ Formatted changelog string
495+ """
496+ if not commits :
497+ return "No changes"
498+
499+ features : list [tuple [str , str ]] = []
500+ fixes : list [tuple [str , str ]] = []
501+ breaking : list [tuple [str , str ]] = []
502+ other : list [tuple [str , str ]] = []
503+
504+ for commit_hash , message in commits :
505+ message_lower = message .lower ()
506+ if "breaking:" in message_lower or "breaking change" in message_lower :
507+ breaking .append ((commit_hash , message ))
508+ elif message .startswith ("feat:" ) or message .startswith ("feature:" ):
509+ features .append ((commit_hash , message ))
510+ elif message .startswith ("fix:" ):
511+ fixes .append ((commit_hash , message ))
512+ else :
513+ other .append ((commit_hash , message ))
514+
515+ def format_commit (commit_hash : str , message : str ) -> str :
516+ """Format commit with GitHub link."""
517+ if commit_hash :
518+ return f" • { message } ([{ commit_hash } ]({ github_url } /commit/{ commit_hash } ))"
519+ return f" • { message } "
520+
521+ lines = []
522+
523+ if breaking :
524+ lines .append ("Breaking Changes:" )
525+ for commit_hash , message in breaking :
526+ lines .append (format_commit (commit_hash , message ))
527+ lines .append ("" )
528+
529+ if features :
530+ lines .append ("New Features:" )
531+ for commit_hash , message in features :
532+ lines .append (format_commit (commit_hash , message ))
533+ lines .append ("" )
534+
535+ if fixes :
536+ lines .append ("Bug Fixes:" )
537+ for commit_hash , message in fixes :
538+ lines .append (format_commit (commit_hash , message ))
539+ lines .append ("" )
540+
541+ if other :
542+ lines .append ("Other Changes:" )
543+ for commit_hash , message in other :
544+ lines .append (format_commit (commit_hash , message ))
545+
546+ return "\n " .join (lines ).strip ()
547+
548+
549+ def _get_changelog_from_github (from_ref : str , to_ref : str ) -> str :
550+ """
551+ Fetch changelog from GitHub Compare API when not in a git repo.
552+
553+ Used when running from installed package (pip/uvx) instead of git checkout.
554+
555+ Args:
556+ from_ref: Starting git reference (commit hash)
557+ to_ref: Ending git reference (commit, tag, or "HEAD")
558+
559+ Returns:
560+ Formatted changelog string or error message with fallback link
561+ """
562+ import json
563+ import urllib .request
564+ from urllib .parse import urlparse
565+
566+ github_url = GITHUB_REPO_URL
567+
568+ # Extract owner/repo from GITHUB_REPO_URL (e.g., "lbedner/aegis-stack")
569+ parsed = urlparse (github_url )
570+ repo_path = parsed .path .strip ("/" ) # "lbedner/aegis-stack"
571+
572+ # Normalize refs (HEAD -> main for API)
573+ base = from_ref
574+ head = "main" if to_ref == "HEAD" else to_ref
575+
576+ api_url = f"https://api.github.com/repos/{ repo_path } /compare/{ base } ...{ head } "
577+
578+ try :
579+ req = urllib .request .Request (
580+ api_url ,
581+ headers = {
582+ "Accept" : "application/vnd.github.v3+json" ,
583+ "User-Agent" : "aegis-stack-cli" ,
584+ },
585+ )
586+ with urllib .request .urlopen (req , timeout = 10 ) as response :
587+ data = json .loads (response .read ().decode ())
588+
589+ api_commits = data .get ("commits" , [])
590+ if not api_commits :
591+ return "No changes"
592+
593+ # Extract (hash, message) tuples from API response
594+ commits = [
595+ (c ["sha" ][:7 ], c ["commit" ]["message" ].split ("\n " )[0 ]) for c in api_commits
596+ ]
597+
598+ return _format_commits_as_changelog (commits , github_url )
599+
600+ except Exception as e :
601+ # Fallback to compare link
602+ return (
603+ f"Changelog not available: { e } \n "
604+ f"View changes: { github_url } /compare/{ from_ref } ...main"
605+ )
606+
607+
483608def get_changelog (from_ref : str , to_ref : str , template_root : Path | None = None ) -> str :
484609 """
485610 Get changelog between two git references.
@@ -495,6 +620,10 @@ def get_changelog(from_ref: str, to_ref: str, template_root: Path | None = None)
495620 if template_root is None :
496621 template_root = get_template_root ()
497622
623+ # Check if we're in a git repository (not installed package mode)
624+ if not (template_root / ".git" ).exists ():
625+ return _get_changelog_from_github (from_ref , to_ref )
626+
498627 try :
499628 # Get commit log between refs (hash|message format for GitHub links)
500629 result = subprocess .run (
@@ -515,65 +644,17 @@ def get_changelog(from_ref: str, to_ref: str, template_root: Path | None = None)
515644 if not result .stdout .strip ():
516645 return "No changes"
517646
518- # GitHub repository URL for commit links
519- github_url = GITHUB_REPO_URL
520-
521- # Parse commits and categorize
647+ # Parse commits from git output
522648 raw_commits = result .stdout .strip ().split ("\n " )
523- features : list [tuple [str , str ]] = [] # (hash, message)
524- fixes : list [tuple [str , str ]] = []
525- breaking : list [tuple [str , str ]] = []
526- other : list [tuple [str , str ]] = []
527-
649+ commits = []
528650 for line in raw_commits :
529651 if "|" in line :
530652 commit_hash , message = line .split ("|" , 1 )
531653 else :
532654 commit_hash , message = "" , line
655+ commits .append ((commit_hash , message ))
533656
534- message_lower = message .lower ()
535- if "breaking:" in message_lower or "breaking change" in message_lower :
536- breaking .append ((commit_hash , message ))
537- elif message .startswith ("feat:" ) or message .startswith ("feature:" ):
538- features .append ((commit_hash , message ))
539- elif message .startswith ("fix:" ):
540- fixes .append ((commit_hash , message ))
541- else :
542- other .append ((commit_hash , message ))
543-
544- def format_commit (commit_hash : str , message : str ) -> str :
545- """Format commit with GitHub link."""
546- if commit_hash :
547- return f" • { message } ([{ commit_hash } ]({ github_url } /commit/{ commit_hash } ))"
548- return f" • { message } "
549-
550- # Format changelog
551- lines = []
552-
553- if breaking :
554- lines .append ("Breaking Changes:" )
555- for commit_hash , message in breaking :
556- lines .append (format_commit (commit_hash , message ))
557- lines .append ("" )
558-
559- if features :
560- lines .append ("New Features:" )
561- for commit_hash , message in features :
562- lines .append (format_commit (commit_hash , message ))
563- lines .append ("" )
564-
565- if fixes :
566- lines .append ("Bug Fixes:" )
567- for commit_hash , message in fixes :
568- lines .append (format_commit (commit_hash , message ))
569- lines .append ("" )
570-
571- if other :
572- lines .append ("Other Changes:" )
573- for commit_hash , message in other :
574- lines .append (format_commit (commit_hash , message ))
575-
576- return "\n " .join (lines ).strip ()
657+ return _format_commits_as_changelog (commits , GITHUB_REPO_URL )
577658
578659 except Exception as e :
579660 return f"Error generating changelog: { e } "
0 commit comments