@@ -4,6 +4,7 @@ package cmd
44import (
55 "fmt"
66 "os"
7+ "regexp"
78 "strings"
89
910 "github.com/boneskull/gh-stack/internal/config"
@@ -514,6 +515,10 @@ func promptMarkPRReady(ghClient *github.Client, prNumber int, branch, trunk stri
514515// generatePRBody creates a PR description from the commits between base and head.
515516// For a single commit: returns the commit body.
516517// For multiple commits: returns each commit as a markdown section.
518+ //
519+ // Commit message bodies are unwrapped so that hard line breaks within paragraphs
520+ // (typical of the ~72-column git convention) are removed. This produces better
521+ // rendering in GitHub's PR description, which treats single newlines as <br> tags.
517522func generatePRBody (g * git.Git , base , head string ) (string , error ) {
518523 commits , err := g .GetCommits (base , head )
519524 if err != nil {
@@ -526,7 +531,7 @@ func generatePRBody(g *git.Git, base, head string) (string, error) {
526531
527532 if len (commits ) == 1 {
528533 // Single commit: just use the body
529- return commits [0 ].Body , nil
534+ return unwrapParagraphs ( commits [0 ].Body ) , nil
530535 }
531536
532537 // Multiple commits: format as markdown sections
@@ -540,10 +545,190 @@ func generatePRBody(g *git.Git, base, head string) (string, error) {
540545 sb .WriteString ("\n " )
541546 if commit .Body != "" {
542547 sb .WriteString ("\n " )
543- sb .WriteString (commit .Body )
548+ sb .WriteString (unwrapParagraphs ( commit .Body ) )
544549 sb .WriteString ("\n " )
545550 }
546551 }
547552
548553 return sb .String (), nil
549554}
555+
556+ // unwrapParagraphs removes hard line breaks within plain-text paragraphs while
557+ // preserving intentional structure: blank lines, markdown block-level syntax
558+ // (headers, lists, blockquotes, horizontal rules), and code blocks (both fenced
559+ // and indented). This converts the ~72-column convention used in commit messages
560+ // into flowing text suitable for GitHub's markdown renderer.
561+ //
562+ // If HTML tags are found in prose (outside code blocks and inline code spans),
563+ // the entire text is returned as-is — anyone writing raw HTML in a commit message
564+ // is doing something intentional with formatting.
565+
566+ // htmlTagRe matches anything that looks like an HTML tag (e.g. <div>, </span>, <br/>).
567+ var htmlTagRe = regexp .MustCompile (`</?[a-zA-Z][a-zA-Z0-9]*[\s/>]` )
568+
569+ // inlineCodeRe matches backtick-enclosed inline code spans so we can strip them
570+ // before checking for HTML. Otherwise `<token>` in code would trigger a false positive.
571+ var inlineCodeRe = regexp .MustCompile ("`[^`]+`" )
572+
573+ // containsHTMLOutsideCode scans the text for HTML tags that appear in prose,
574+ // ignoring content inside fenced code blocks, indented code blocks, and inline
575+ // code spans. Returns true if HTML is found in any prose line.
576+ func containsHTMLOutsideCode (text string ) bool {
577+ lines := strings .Split (text , "\n " )
578+ inFencedCodeBlock := false
579+
580+ for _ , line := range lines {
581+ trimmed := strings .TrimRight (line , " \t " )
582+
583+ // Track fenced code blocks (``` or ~~~)
584+ if ! inFencedCodeBlock && (strings .HasPrefix (trimmed , "```" ) || strings .HasPrefix (trimmed , "~~~" )) {
585+ inFencedCodeBlock = true
586+ continue
587+ }
588+ if inFencedCodeBlock {
589+ if strings .HasPrefix (trimmed , "```" ) || strings .HasPrefix (trimmed , "~~~" ) {
590+ inFencedCodeBlock = false
591+ }
592+ continue
593+ }
594+
595+ // Skip indented code blocks (4+ spaces or tab)
596+ if strings .HasPrefix (line , " " ) || strings .HasPrefix (line , "\t " ) {
597+ continue
598+ }
599+
600+ // Strip inline code spans, then check for HTML
601+ stripped := inlineCodeRe .ReplaceAllString (line , "" )
602+ if htmlTagRe .MatchString (stripped ) {
603+ return true
604+ }
605+ }
606+
607+ return false
608+ }
609+
610+ func unwrapParagraphs (text string ) string {
611+ if text == "" {
612+ return ""
613+ }
614+
615+ // Bail if the text contains HTML tags in prose — don't mess with it.
616+ if containsHTMLOutsideCode (text ) {
617+ return text
618+ }
619+
620+ lines := strings .Split (text , "\n " )
621+ var result []string
622+ var paragraph []string
623+ inFencedCodeBlock := false
624+
625+ flushParagraph := func () {
626+ if len (paragraph ) > 0 {
627+ result = append (result , strings .Join (paragraph , " " ))
628+ paragraph = nil
629+ }
630+ }
631+
632+ for _ , line := range lines {
633+ trimmed := strings .TrimRight (line , " \t " )
634+
635+ // Track fenced code blocks (``` or ~~~)
636+ if ! inFencedCodeBlock && (strings .HasPrefix (trimmed , "```" ) || strings .HasPrefix (trimmed , "~~~" )) {
637+ flushParagraph ()
638+ result = append (result , line )
639+ inFencedCodeBlock = true
640+ continue
641+ }
642+ if inFencedCodeBlock {
643+ result = append (result , line )
644+ if strings .HasPrefix (trimmed , "```" ) || strings .HasPrefix (trimmed , "~~~" ) {
645+ inFencedCodeBlock = false
646+ }
647+ continue
648+ }
649+
650+ // Blank line = paragraph break
651+ if trimmed == "" {
652+ flushParagraph ()
653+ result = append (result , "" )
654+ continue
655+ }
656+
657+ // Preserve lines that are markdown block-level elements
658+ if isBlockElement (trimmed ) {
659+ flushParagraph ()
660+ result = append (result , line )
661+ continue
662+ }
663+
664+ // Indented code block (4+ spaces or tab)
665+ if strings .HasPrefix (line , " " ) || strings .HasPrefix (line , "\t " ) {
666+ flushParagraph ()
667+ result = append (result , line )
668+ continue
669+ }
670+
671+ // Otherwise it's a paragraph line — accumulate it
672+ paragraph = append (paragraph , trimmed )
673+ }
674+
675+ flushParagraph ()
676+
677+ return strings .Join (result , "\n " )
678+ }
679+
680+ // isBlockElement returns true if the line starts with markdown block-level syntax
681+ // that should not be joined with adjacent lines.
682+ func isBlockElement (line string ) bool {
683+ // Headers
684+ if strings .HasPrefix (line , "#" ) {
685+ return true
686+ }
687+ // Unordered lists
688+ if strings .HasPrefix (line , "- " ) || strings .HasPrefix (line , "* " ) || strings .HasPrefix (line , "+ " ) ||
689+ line == "-" || line == "*" || line == "+" {
690+ return true
691+ }
692+ // Ordered lists (e.g. "1. ", "12. ")
693+ for i , ch := range line {
694+ if ch >= '0' && ch <= '9' {
695+ continue
696+ }
697+ if ch == '.' && i > 0 && i + 1 < len (line ) && line [i + 1 ] == ' ' {
698+ return true
699+ }
700+ break
701+ }
702+ // Blockquotes
703+ if strings .HasPrefix (line , ">" ) {
704+ return true
705+ }
706+ // Horizontal rules (---, ***, ___)
707+ if isHorizontalRule (line ) {
708+ return true
709+ }
710+ // Pipe tables
711+ if strings .HasPrefix (line , "|" ) {
712+ return true
713+ }
714+ return false
715+ }
716+
717+ // isHorizontalRule checks for markdown horizontal rules: three or more
718+ // -, *, or _ characters (with optional spaces).
719+ func isHorizontalRule (line string ) bool {
720+ stripped := strings .ReplaceAll (line , " " , "" )
721+ if len (stripped ) < 3 {
722+ return false
723+ }
724+ ch := stripped [0 ]
725+ if ch != '-' && ch != '*' && ch != '_' {
726+ return false
727+ }
728+ for _ , c := range stripped {
729+ if byte (c ) != ch {
730+ return false
731+ }
732+ }
733+ return true
734+ }
0 commit comments